From 63e1e599ebc62d319433456cd7a551f4c1b3f128 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 13 Nov 2026 17:24:55 +0100 Subject: [PATCH] perf(styles): NVG and FLIR bloom in two passes and redraw at 15 Hz when idle Night vision read the scene 122 times for each pixel and FLIR 108, almost all of it in an 11x11 and a 9x9 bloom loop, and either style held the scene at 60 renders a second while nothing else moved. Both bloom weights are a row weight times a column weight, and the stages read their input with nearest sampling, so a new bloomRows pass sums each row once per texel and the main shader sums the columns: 23 reads for night vision and 45 for FLIR, with the same picture. A style with passes is built as a PostProcessStageComposite that answers to the style's name, enabled and uniforms as the single stage did. Styles that declare timeHz (these two, at 15) step their clock at that rate and ask for one frame a step, so an idle scene is drawn 15 times a second with them on. Crossfades and the CRT style are still drawn every frame. Co-Authored-By: Claude Fable 5.2 --- src/styles/bloomPasses.test.mjs | 93 +++++++++++++++++++++++++++ src/styles/surveillance.js | 62 +++++++++++++++--- src/styles/thermal.js | 66 +++++++++++++++---- src/ui/visualEffects.js | 86 ++++++++++++++++++++++--- src/ui/visualEffects.test.mjs | 108 +++++++++++++++++++++++++++++++- 5 files changed, 384 insertions(+), 31 deletions(-) create mode 100644 src/styles/bloomPasses.test.mjs 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();