diff --git a/src/styles/bloomPasses.test.mjs b/src/styles/bloomPasses.test.mjs new file mode 100644 index 000000000..695d40c52 --- /dev/null +++ b/src/styles/bloomPasses.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { nightVisionShader } from './surveillance.js'; +import { thermalShader } from './thermal.js'; + +/** + * Texture reads one pixel costs: every `texture(` call, multiplied by the + * trip counts of the `for (int v = a; v <= b; v++)` loops around it. + */ +function textureReads(source) { + const token = + /for \(int \w+ = (-?\d+); \w+ <= (-?\d+); \w+\+\+\) \{|texture\(|[{}]/g; + const scopes = [1]; + let reads = 0; + for (const match of source.matchAll(token)) { + const inside = scopes.at(-1); + if (match[0] === 'texture(') reads += inside; + else if (match[0] === '}') scopes.pop(); + else if (match[0] === '{') scopes.push(inside); + else scopes.push(inside * (Number(match[2]) - Number(match[1]) + 1)); + } + assert.deepEqual(scopes, [1], 'braces balance'); + return reads; +} + +function readsPerPixel(shader) { + return [shader, ...(shader.passes || [])].reduce( + (total, pass) => total + textureReads(pass.fragmentShader), + 0, + ); +} + +test('the read counter multiplies nested loop trips', () => { + const nested = `void main() { + for (int y = -5; y <= 5; y++) { + for (int x = -5; x <= 5; x++) { a += texture(t, uv).r; } + } + b = texture(t, uv); + }`; + assert.equal(textureReads(nested), 122); +}); + +test('night vision blooms with two 11-sample sums, not 121 samples', () => { + // 11 in the row pass, 11 column sums and the pixel itself. + assert.equal(readsPerPixel(nightVisionShader), 23); +}); + +test('FLIR blooms with two 9-sample sums, not 81 samples', () => { + // 9 in the row pass, 9 column sums, the 5x5 soft blur, the pixel itself + // and the centre sample behind the temperature readout. + assert.equal(readsPerPixel(thermalShader), 45); +}); + +for (const shader of [nightVisionShader, thermalShader]) + test(`${shader.name}: each pass is fed the uniforms it declares`, () => { + assert.ok(shader.passes.length > 0); + for (const pass of shader.passes) { + const declared = [ + ...pass.fragmentShader.matchAll(/uniform float (\w+);/g), + ].map((match) => match[1]); + assert.deepEqual(declared, pass.uniforms); + for (const uniform of pass.uniforms) + assert.ok(uniform in shader.uniforms, `${uniform} is a style uniform`); + assert.match( + shader.fragmentShader, + new RegExp(`uniform sampler2D ${pass.texture};`), + ); + assert.match( + shader.fragmentShader, + new RegExp(`texture\\(${pass.texture},`), + ); + } + }); + +test('the row and column weights multiply back to the single-pass weight', () => { + // Night vision: exp(-(x² + y²) / 18). FLIR: exp(-0.5 (x² + y²) / 8). + const cases = [ + [ + 5, + (x, y) => Math.exp(-(x * x + y * y) / 18), + (v) => Math.exp(-(v * v) / 18), + ], + [ + 4, + (x, y) => Math.exp((-0.5 * (x * x + y * y)) / 8), + (v) => Math.exp((-0.5 * v * v) / 8), + ], + ]; + for (const [reach, whole, part] of cases) + for (let y = -reach; y <= reach; y++) + for (let x = -reach; x <= reach; x++) + assert.ok(Math.abs(whole(x, y) - part(x) * part(y)) < 1e-12); +}); diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 5eae7fdd3..27cf02b50 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -11,8 +11,55 @@ import { msg } from '../i18n/marker.js'; * scanlineStr (0-1) — scanline intensity (kept for compatibility) * pixelation (1-6) — intensifier tube resolution pixelation */ + +/** + * The bloom is an 11x11 Gaussian over samples four texels apart. Its weight + * exp(-(x² + y²) / 18) is a product of a row weight and a column weight, and + * the stage reads its input with nearest sampling, so every sample is a whole + * texel away from the first. This pass therefore sums the eleven samples of + * each row once per texel and the main pass sums eleven of those: 22 reads + * for each pixel where one pass took 121, with the same result. + * + * The row sum is written across two 8-bit channels (whole 255ths in red, the + * remainder in green) so the stage keeps the default render target. + */ +const NVG_BLOOM_ROWS_SHADER = /* glsl */ ` + uniform sampler2D colorTexture; + uniform vec2 colorTextureDimensions; + uniform float gain; + in vec2 v_textureCoordinates; + + void main() { + vec2 texel = 1.0 / colorTextureDimensions; + float gainLevel = mix(0.8, 2.5, gain); + float rowSum = 0.0; + float rowW = 0.0; + for (int x = -5; x <= 5; x++) { + vec2 offset = vec2(float(x), 0.0) * texel * 4.0; + float sLuma = dot(texture(colorTexture, v_textureCoordinates + offset).rgb, vec3(0.299, 0.587, 0.114)); + float bright = smoothstep(0.4, 0.9, sLuma * gainLevel); + float w = exp(-float(x * x) / 18.0); + rowSum += bright * w; + rowW += w; + } + float scaled = rowSum / rowW * 255.0; + out_FragColor = vec4(floor(scaled) / 255.0, fract(scaled), 0.0, 1.0); + } + `; + export const nightVisionShader = { name: 'surveillance', + // The film grain, scanline drift and clock digits step at this rate, and + // the scene is redrawn at it while the style is the only thing moving. + timeHz: 15, + passes: [ + { + name: 'bloomRows', + texture: 'bloomRowsTexture', + uniforms: ['gain'], + fragmentShader: NVG_BLOOM_ROWS_SHADER, + }, + ], uniforms: { gain: { default: 0.55, min: 0, max: 1, label: msg('Gain') }, bloom: { default: 0.3, min: 0, max: 1, label: msg('Bloom') }, @@ -21,6 +68,7 @@ export const nightVisionShader = { }, fragmentShader: /* glsl */ ` uniform sampler2D colorTexture; + uniform sampler2D bloomRowsTexture; uniform vec2 colorTextureDimensions; uniform float intensity; uniform float time; @@ -189,17 +237,15 @@ export const nightVisionShader = { // ── Intensifier tube bloom (THE key NVG visual) ───── // Bloom around bright sources — wider kernel for realistic halos + // Rows were summed by the bloomRows pass; the columns are summed here. float bloomAccum = 0.0; float bloomW = 0.0; for (int y = -5; y <= 5; y++) { - for (int x = -5; x <= 5; x++) { - vec2 offset = vec2(float(x), float(y)) * texel * 4.0; - float sLuma = dot(texture(colorTexture, distUV + offset).rgb, vec3(0.299, 0.587, 0.114)); - float bright = smoothstep(0.4, 0.9, sLuma * gainLevel); - float w = exp(-float(x * x + y * y) / 18.0); - bloomAccum += bright * w; - bloomW += w; - } + vec2 offset = vec2(0.0, float(y)) * texel * 4.0; + vec2 rows = texture(bloomRowsTexture, distUV + offset).rg; + float w = exp(-float(y * y) / 18.0); + bloomAccum += (rows.r + rows.g / 255.0) * w; + bloomW += w; } bloomAccum /= bloomW; diff --git a/src/styles/thermal.js b/src/styles/thermal.js index f62327507..9c5a2bf92 100644 --- a/src/styles/thermal.js +++ b/src/styles/thermal.js @@ -13,8 +13,55 @@ import { msg } from '../i18n/marker.js'; * pixelation (1-6) — sensor resolution pixelation grid size * palette (0-1) — 0 = monochrome FLIR, 1 = Ironbow "Predator" color ramp */ + +/** + * The hot-spot bloom is a 9x9 Gaussian over samples three texels apart. Its + * weight is a product of a row weight and a column weight, and the stage + * reads its input with nearest sampling, so this pass sums the nine samples + * of each row once per texel and the main pass sums nine of those: 18 reads + * for each pixel where one pass took 81, with the same result. The row sum is + * written across two 8-bit channels, as in the night-vision bloom. + */ +const FLIR_BLOOM_ROWS_SHADER = /* glsl */ ` + uniform sampler2D colorTexture; + uniform vec2 colorTextureDimensions; + uniform float sensitivity; + uniform float mode; + in vec2 v_textureCoordinates; + + void main() { + vec2 texel = 1.0 / colorTextureDimensions; + float sens = mix(0.25, 1.0, sensitivity); + float isBlackHot = step(0.5, mode); + float rowSum = 0.0; + float rowW = 0.0; + for (int x = -4; x <= 4; x++) { + vec2 offset = vec2(float(x), 0.0) * texel * 3.0; + float sLuma = dot(texture(colorTexture, v_textureCoordinates + offset).rgb, vec3(0.299, 0.587, 0.114)); + float sMapped = clamp((sLuma - (0.5 - sens * 0.5)) / sens, 0.0, 1.0); + float sFinal = mix(sMapped, 1.0 - sMapped, isBlackHot); + float w = exp(-0.5 * float(x * x) / 8.0); + rowSum += smoothstep(0.6, 1.0, sFinal) * w; + rowW += w; + } + float scaled = rowSum / rowW * 255.0; + out_FragColor = vec4(floor(scaled) / 255.0, fract(scaled), 0.0, 1.0); + } + `; + export const thermalShader = { name: 'thermal', + // The drifting noise and the frame counter step at this rate, and the scene + // is redrawn at it while the style is the only thing moving. + timeHz: 15, + passes: [ + { + name: 'bloomRows', + texture: 'bloomRowsTexture', + uniforms: ['sensitivity', 'mode'], + fragmentShader: FLIR_BLOOM_ROWS_SHADER, + }, + ], uniforms: { sensitivity: { default: 0.75, min: 0, max: 1, label: msg('Sensitivity') }, bloom: { default: 0.65, min: 0, max: 1, label: msg('Bloom') }, @@ -24,6 +71,7 @@ export const thermalShader = { }, fragmentShader: /* glsl */ ` uniform sampler2D colorTexture; + uniform sampler2D bloomRowsTexture; uniform vec2 colorTextureDimensions; uniform float intensity; uniform float time; @@ -209,21 +257,15 @@ export const thermalShader = { // ── Hot-spot bloom/bleed ──────────────────────────── // Sample a wider area for bloom on bright spots + // Rows were summed by the bloomRows pass; the columns are summed here. float bloomSample = 0.0; float bloomWeight = 0.0; for (int y = -4; y <= 4; y++) { - for (int x = -4; x <= 4; x++) { - vec2 offset = vec2(float(x), float(y)) * texel * 3.0; - float sLuma = dot(texture(colorTexture, uv + offset).rgb, vec3(0.299, 0.587, 0.114)); - float sMapped = clamp((sLuma - (0.5 - sens * 0.5)) / sens, 0.0, 1.0); - float sFinal = mix(sMapped, 1.0 - sMapped, isBlackHot); - float w = exp(-0.5 * float(x * x + y * y) / 8.0); - // Only bloom the "hot" pixels (bright in WHOT, dark values in BHOT... - // but since we already inverted, just bloom high values) - float hotness = smoothstep(0.6, 1.0, sFinal); - bloomSample += hotness * w; - bloomWeight += w; - } + vec2 offset = vec2(0.0, float(y)) * texel * 3.0; + vec2 rows = texture(bloomRowsTexture, uv + offset).rg; + float w = exp(-0.5 * float(y * y) / 8.0); + bloomSample += (rows.r + rows.g / 255.0) * w; + bloomWeight += w; } bloomSample /= bloomWeight; thermalColor += bloomSample * bloom * 0.8; diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 9d10f0cef..6c5d3795f 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -1,4 +1,4 @@ -import { PostProcessStage } from 'cesium'; +import { PostProcessStage, PostProcessStageComposite } from 'cesium'; import { bloomStrengthFromIntensity, clampBloomIntensity, @@ -26,6 +26,7 @@ export class VisualEffects { now = () => performance.now(), wallNow = () => Date.now(), createStage = (options) => new PostProcessStage(options), + createComposite = (options) => new PostProcessStageComposite(options), }) { this.viewer = viewer; this.requestRender = requestRender; @@ -36,6 +37,7 @@ export class VisualEffects { this.now = now; this.wallNow = wallNow; this.createStage = createStage; + this.createComposite = createComposite; this.stages = {}; this.transitions = new Map(); this.startTime = wallNow(); @@ -60,11 +62,11 @@ export class VisualEffects { uniforms.time = 0.0; for (const [name, meta] of Object.entries(shader.uniforms || {})) uniforms[name] = meta.default; - const stage = this.createStage({ - name: `godsEyeView_${name}`, - fragmentShader: shader.fragmentShader, + const stage = this.createStyleStage( + `godsEyeView_${name}`, + shader, uniforms, - }); + ); stage.enabled = false; this.viewer.scene.postProcessStages.add(stage); this.stages[name] = stage; @@ -72,6 +74,57 @@ export class VisualEffects { this.stageEntries = Object.entries(this.stages); } + /** + * One stage for a single-pass style. A style with `passes` becomes a + * composite: each pass reads the scene, the main shader reads the scene and + * each pass's output, and the composite answers to the style's name, + * `enabled` and `uniforms` as a single stage would. + * + * Cesium replaces a stage's `uniforms` object on its first frame, so the + * composite's uniforms and the passes' read the main stage's current object + * each time rather than the one it was created with. + */ + createStyleStage(name, shader, uniforms) { + if (!shader.passes?.length) + return this.createStage({ + name, + fragmentShader: shader.fragmentShader, + uniforms, + }); + let main = null; + const shared = {}; + for (const uniform of Object.keys(uniforms)) + Object.defineProperty(shared, uniform, { + enumerable: true, + get: () => main.uniforms[uniform], + set: (value) => { + main.uniforms[uniform] = value; + }, + }); + const passes = shader.passes.map((pass) => { + const passName = `${name}_${pass.name}`; + uniforms[pass.texture] = passName; + return this.createStage({ + name: passName, + fragmentShader: pass.fragmentShader, + uniforms: Object.fromEntries( + pass.uniforms.map((uniform) => [uniform, () => shared[uniform]]), + ), + }); + }); + main = this.createStage({ + name: `${name}_main`, + fragmentShader: shader.fragmentShader, + uniforms, + }); + return this.createComposite({ + name, + stages: [...passes, main], + inputPreviousStageTexture: false, + uniforms: shared, + }); + } + initPostProcess(sharpenIntensity = this.sharpenIntensity) { if (this.stopped || this.sharpenStage) return; this.bloomStage = this.viewer.scene.postProcessStages.bloom; @@ -213,16 +266,29 @@ export class VisualEffects { this.transitions.delete(name); } } + // A style with `timeHz` moves its clock in steps and asks for one frame + // a step; any other animated style is drawn every frame. let animatedStageVisible = false; - for (const [, stage] of this.stageEntries) { + let everyFrame = this.transitions.size > 0; + let stepped = false; + for (const [name, stage] of this.stageEntries) { if (stage.enabled && stage.uniforms.time !== undefined) { - stage.uniforms.time = elapsedSec; - if (stage.uniforms.intensity > 0.001) animatedStageVisible = true; + const hz = STYLES[name].timeHz; + const time = hz ? Math.floor(elapsedSec * hz) / hz : elapsedSec; + if (stage.uniforms.intensity > 0.001) { + animatedStageVisible = true; + if (!hz) everyFrame = true; + else if (time !== stage.uniforms.time) stepped = true; + } + stage.uniforms.time = time; } } const needed = this.transitions.size > 0 || animatedStageVisible; - if (needed) this.holdRender('style-anim'); - else this.releaseRender('style-anim'); + if (everyFrame) this.holdRender('style-anim'); + else { + this.releaseRender('style-anim'); + if (stepped) this.requestRender('style-anim'); + } this.frameId = needed ? this.requestFrame(update) : null; }; this.frameId = this.requestFrame(update); diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 142bbbe4f..a22f5810b 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -8,7 +8,7 @@ import { MILITARY_DETECTION_PRESET, } from './visualPresets.js'; -function fixture() { +function fixture(overrides = {}) { let time = 0; let next = 0; const frames = new Map(); @@ -52,6 +52,7 @@ function fixture() { now: () => time, wallNow: () => 10_000 + time, createStage: (options) => ({ ...options, enabled: true }), + ...overrides, }); function tick(value) { time = value; @@ -149,6 +150,111 @@ test('only visible animated stages keep the animation clock and render hold aliv f.effects.destroy(); }); +test('night vision and FLIR step their clock at 15 Hz and ask for one frame a step', () => { + for (const name of ['surveillance', 'thermal']) { + const f = fixture(); + f.effects.initStyles(); + const stage = f.effects.stages[name]; + f.effects.setStageIntensity(stage, 1); + const steps = () => + f.requests.filter((reason) => reason === 'style-anim').length; + f.tick(1000); + assert.equal(stage.uniforms.time, 1); + assert.equal(f.holds.size, 0, 'no continuous render while idle'); + assert.equal(steps(), 1); + assert.equal(f.frames.size, 1, 'the clock keeps running'); + for (const time of [1016, 1033, 1050]) f.tick(time); + assert.equal(stage.uniforms.time, 1, 'the clock holds between steps'); + assert.equal(steps(), 1, 'no frame is asked for between steps'); + f.tick(1067); + assert.equal(stage.uniforms.time, 16 / 15); + assert.equal(steps(), 2); + f.tick(2000); + assert.equal(stage.uniforms.time, 2); + assert.equal(steps(), 3); + f.effects.setStageIntensity(stage, 0); + f.tick(2100); + assert.equal(f.frames.size, 0); + f.effects.destroy(); + } +}); + +test('a crossfade into a stepped style is still drawn every frame', () => { + const f = fixture(); + f.effects.initStyles(); + f.effects.startTransition('thermal', 0, 1); + f.tick(250); + assert.equal(f.holds.has('style-anim'), true); + f.tick(500); + assert.equal(f.effects.stages.thermal.uniforms.intensity, 1); + f.tick(520); + assert.equal(f.holds.size, 0, 'the hold ends with the crossfade'); + assert.equal(f.frames.size, 1); + f.effects.destroy(); +}); + +test('a style with passes is one composite whose uniforms reach every pass', () => { + const created = []; + const composites = []; + const f = fixture({ + createStage: (options) => { + const stage = { ...options, enabled: true }; + created.push(stage); + return stage; + }, + createComposite: (options) => { + const composite = { + ...options, + get enabled() { + return options.stages[0].enabled; + }, + set enabled(value) { + for (const stage of options.stages) stage.enabled = value; + }, + }; + composites.push(composite); + return composite; + }, + }); + f.effects.initStyles(); + assert.deepEqual( + composites.map((composite) => composite.name), + ['godsEyeView_surveillance', 'godsEyeView_thermal'], + ); + assert.equal(f.stages.size, 5, 'one pipeline entry per style'); + const composite = f.effects.stages.surveillance; + const [rows, main] = composite.stages; + assert.equal(composite.inputPreviousStageTexture, false); + assert.equal(rows.name, 'godsEyeView_surveillance_bloomRows'); + assert.equal(main.uniforms.bloomRowsTexture, rows.name); + assert.deepEqual(Object.keys(rows.uniforms), ['gain']); + assert.deepEqual(Object.keys(composite.uniforms), [ + 'intensity', + 'time', + 'gain', + 'bloom', + 'scanlineStr', + 'pixelation', + ]); + assert.equal(rows.enabled, false); + assert.equal(main.enabled, false); + // Cesium swaps a stage's uniforms object on its first frame. + main.uniforms = { ...main.uniforms }; + composite.uniforms.gain = 0.9; + assert.equal(main.uniforms.gain, 0.9); + assert.equal(rows.uniforms.gain(), 0.9); + f.effects.setStageIntensity(composite, 1); + assert.equal(main.uniforms.intensity, 1); + assert.equal(rows.enabled, true); + assert.equal(main.enabled, true); + assert.equal( + created.filter((stage) => stage.name.startsWith('godsEyeView_thermal')) + .length, + 2, + ); + f.effects.destroy(); +}); + test('direct Cockpit intensity writes are reconciled before rendering', () => { const f = fixture(); f.effects.initStyles();