Glow the Navara frame with the Display bloom behind an optional render.setBloom capability

postprocessing's BloomEffect runs after the overlays and before the visual styles, follows Cesium's eased strength and dead zone with its threshold set in display terms, and VisualEffects drives it when the engine has the bloom capability.
This commit is contained in:
German Khokhlov committed 2026-11-03 21:11:53 +04:00
1 parent 074f4af10e
commit c392d6fb50
15 files changed
+893 -178

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
// The app's Bloom on Navara (Stage 6 B2): the Display bloom glows the
// Navara frame (brighter, more at a higher slider), it sits after the
// overlays and before the visual styles, Cesium's dead zone draws nothing,
// turning it off restores the frame exactly, and on Cesium the scene's own
// bloom stage still runs with the engine left alone. Keyless preview under
// the strict default-deny network policy.
//
// node scripts/qa-navara-bloom.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
capture,
checker,
diff,
freeze,
pickStyle,
saveCapture,
stats,
visualPageUrl,
} from './qa/navaraVisual.mjs';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const strict = createStrict({ origin: base.origin, layers: [] });
const report = { checks: [] };
const check = checker(report, assert);
const save = (name, pixels) => saveCapture(outDir, name, pixels);
const luma = (s) => 0.299 * s.r + 0.587 * s.g + 0.114 * s.b;
// The Display bloom, as its toggle and slider set it.
const setBloom = (page, enabled, intensityPct) =>
page.evaluate(
(enabled, intensityPct) => {
const a = window.__godsEyeView;
a.styleManager.setBloom({ enabled, intensityPct });
return {
control: a.styleManager.getControlState().bloom,
engine: a.engine.render.bloom?.() ?? null,
pipeline: a.engine.pipeline?.() ?? null,
};
},
enabled,
intensityPct,
);
await strict.run(
{ args: ['--use-angle=metal', '--enable-gpu'] },
async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.evaluateOnNewDocument(() => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
});
await page.setViewport({ width: 1280, height: 800 });
await page.goto(visualPageUrl(base.origin, 'navara'), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
await freeze(page);
const initial = await page.evaluate(() => {
const { engine } = window.__godsEyeView;
return { bloom: engine.render.bloom(), pipeline: engine.pipeline() };
});
check(
'Navara blooms itself and starts with bloom off',
initial.bloom.enabled === false && !initial.pipeline?.bloomShown,
initial,
);
const normal = await capture(page);
save('normal', normal);
const normalStats = stats(normal);
const full = await setBloom(page, true, 200);
check(
'the slider at 200 reaches the engine at full strength',
full.engine.enabled === true &&
full.engine.strength === 1 &&
full.pipeline.bloomShown === true,
full,
);
const order = full.pipeline.passes ?? [];
const at = order.indexOf('gevBloom');
check(
'bloom runs after the scene and overlays, just before the final copy',
at >= 0 &&
order[at + 1] === 'final' &&
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
(key) => !order.includes(key) || order.indexOf(key) < at,
),
order,
);
const glowFull = await capture(page);
save('bloom-200', glowFull);
const fullChange = diff(normal, glowFull);
const fullStats = stats(glowFull);
check(
'bloom at 200 glows the frame brighter',
fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2,
{
...fullChange,
normalLuma: luma(normalStats),
bloomLuma: luma(fullStats),
},
);
await setBloom(page, true, 100);
const glowHalf = await capture(page);
save('bloom-100', glowHalf);
const halfChange = diff(normal, glowHalf);
check(
'a lower slider glows less',
halfChange.mean > 0.5 && halfChange.mean < fullChange.mean,
{ half: halfChange.mean, full: fullChange.mean },
);
const dead = await setBloom(page, true, 10);
const deadFrame = await capture(page);
check(
"Cesium's dead zone: the toggle on, nothing glows",
dead.engine.enabled === false &&
dead.pipeline.bloomShown === false &&
diff(normal, deadFrame).mean === 0,
{ engine: dead.engine, diff: diff(normal, deadFrame) },
);
// With a style: bloom first, then the style, then the final copy.
await setBloom(page, true, 200);
const styled = await pickStyle(page, 'noir', 'normal');
const styledOrder = styled.pipeline?.passes ?? [];
check(
'bloom runs before the visual styles',
styledOrder.indexOf('gevBloom') >= 0 &&
styledOrder.indexOf('gevBloom') + 1 ===
styledOrder.indexOf('gevStyles') &&
styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'),
styledOrder,
);
await pickStyle(page, 'normal', 'noir');
const off = await setBloom(page, false, 200);
const offFrame = await capture(page);
check(
'turning bloom off restores the frame',
off.engine.enabled === false &&
off.pipeline.bloomShown === false &&
diff(normal, offFrame).mean === 0,
{ engine: off.engine, diff: diff(normal, offFrame) },
);
// Cesium keeps its scene bloom stage; its engine has no bloom.
await strict.switchTo(page, 'cesium');
await freeze(page);
await setBloom(page, false, 200);
const cesiumNormal = await capture(page);
const cesium = await setBloom(page, true, 200);
const cesiumStage = await page.evaluate(() => {
const a = window.__godsEyeView;
const stage = a.viewer?.scene?.postProcessStages?.bloom;
return {
setBloom: typeof a.engine.render.setBloom,
enabled: stage?.enabled ?? null,
contrast: stage?.uniforms?.contrast ?? null,
};
});
const cesiumGlow = await capture(page);
save('cesium-bloom-200', cesiumGlow);
check(
"Cesium: its scene's bloom stage glows, the engine draws none",
cesium.engine === null &&
cesiumStage.setBloom === 'undefined' &&
cesiumStage.enabled !== false &&
diff(cesiumNormal, cesiumGlow).mean > 1,
{ stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) },
);
await setBloom(page, false, 200);
const cesiumBack = diff(cesiumNormal, await capture(page));
check(
'Cesium: bloom off restores its frame',
cesiumBack.mean < 0.5,
cesiumBack,
);
check('no page errors', errors.length === 0, errors);
if (outDir)
fs.writeFileSync(
path.join(outDir, 'bloom.json'),
JSON.stringify(report, null, 2),
);
},
);
+15 -169
View File
@@ -11,192 +11,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
STILL_VIEW,
capture,
checker,
diff,
freeze,
histogramShift,
pickStyle,
saveCapture,
stats,
visualPageUrl,
} from './qa/navaraVisual.mjs';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const VIEW =
'v=2&lat=30.2672&lon=-97.7431&alt=20000&heading=0&pitch=-70&map=osm&l=';
const STYLES = ['retro', 'surveillance', 'thermal', 'anime', 'noir'];
const URL_NAMES = { retro: 'crt', surveillance: 'nvg', thermal: 'flir' };
const pageUrl = (engine, extra = '') =>
`${base.origin}/?engine=${engine}&welcome=0&look=cinematic#${VIEW}${extra}`;
visualPageUrl(base.origin, engine, extra, STILL_VIEW);
const strict = createStrict({ origin: base.origin, layers: [] });
const report = { checks: [] };
const check = (name, pass, detail) => {
report.checks.push({ name, pass: Boolean(pass), detail });
console.log(
pass ? 'PASS' : 'FAIL',
name,
detail ? JSON.stringify(detail) : '',
);
assert.ok(pass, `${name} ${JSON.stringify(detail)}`);
};
const check = checker(report, assert);
// The engine canvas as drawn, downscaled to 160×100 RGBA in the page, read
// inside the engine's postRender (Cesium keeps no drawing buffer after it).
async function capture(page) {
return page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const canvas = document.createElement('canvas');
canvas.width = 160;
canvas.height = 100;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
await new Promise((resolve) => {
const off = engine.frames.postRender.addEventListener(() => {
off();
ctx.drawImage(engine.host.canvas, 0, 0, 160, 100);
resolve();
});
engine.render.request('qa-visual-styles');
});
return Array.from(ctx.getImageData(0, 0, 160, 100).data);
});
}
function stats(pixels) {
let r = 0,
g = 0,
b = 0,
saturation = 0;
const histogram = new Array(16).fill(0);
const count = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 4) {
const [pr, pg, pb] = [pixels[i], pixels[i + 1], pixels[i + 2]];
r += pr;
g += pg;
b += pb;
saturation += Math.max(pr, pg, pb) - Math.min(pr, pg, pb);
histogram[
Math.min(15, Math.floor((0.299 * pr + 0.587 * pg + 0.114 * pb) / 16))
]++;
}
// The vignette: the corners' luma over the centre's (160×100 capture).
const luma = (x0, y0) => {
let sum = 0;
for (let y = y0; y < y0 + 10; y++)
for (let x = x0; x < x0 + 16; x++) {
const i = (y * 160 + x) * 4;
sum +=
0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
}
return sum / 160;
};
const corners = (luma(0, 0) + luma(144, 0) + luma(0, 90) + luma(144, 90)) / 4;
return {
vignette: corners / Math.max(1, luma(72, 45)),
r: r / count,
g: g / count,
b: b / count,
saturation: saturation / count,
histogram: histogram.map((n) => n / count),
};
}
function diff(a, b) {
let sum = 0;
let changed = 0;
for (let i = 0; i < a.length; i += 4) {
const d =
Math.abs(a[i] - b[i]) +
Math.abs(a[i + 1] - b[i + 1]) +
Math.abs(a[i + 2] - b[i + 2]);
sum += d / 3;
if (d / 3 > 8) changed++;
}
const count = a.length / 4;
return { mean: sum / count, changedShare: changed / count };
}
// Histogram distance (half the L1 of two normalised histograms, 0..1).
const histogramShift = (a, b) =>
a.histogram.reduce(
(sum, value, i) => sum + Math.abs(value - b.histogram[i]),
0,
) / 2;
async function freeze(page) {
await page.evaluate(async () => {
const a = window.__godsEyeView;
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
throw new Error('Keyless preview required');
a.styleManager.orbitController.stop();
a.engine.camera.cancelFlight();
a.styleManager._setDetectionMode?.('OFF');
for (const [id, row] of a.dataManager.layers)
if (row.enabled) await a.dataManager.setEnabled(id, false);
});
await page.waitForFunction(
() => window.__godsEyeView.engine.surface.tilesLoaded(),
{ timeout: 90000, polling: 250 },
);
await new Promise((resolve) => setTimeout(resolve, 2500));
}
// Picks a style the way the style keys do, then follows the crossfade on
// the engine every frame until VisualEffects has no transition left.
async function pickStyle(page, name, previous) {
return page.evaluate(
async (name, previous) => {
const a = window.__godsEyeView;
const render = a.engine.render;
const drawn = typeof render.setStyle === 'function';
const intensity = (style) =>
!style || style === 'normal'
? 0
: drawn
? render.style(style).intensity
: a.styleManager.stages[style].uniforms.intensity;
const samples = [];
const started = performance.now();
a.styleManager.setStyle(name, { userInitiated: true });
while (performance.now() - started < 3000) {
await new Promise((resolve) => requestAnimationFrame(resolve));
samples.push([intensity(previous), intensity(name)]);
if (a.styleManager.transitions.size === 0) break;
}
return {
ms: performance.now() - started,
pending: a.styleManager.transitions.size,
samples,
active: a.styleManager.activeStyle,
intensities: drawn
? Object.fromEntries(
render.visualStyles.map((style) => [
style,
render.style(style).intensity,
]),
)
: null,
pipeline: a.engine.pipeline?.() ?? null,
};
},
name,
previous,
);
}
const save = (name, pixels) => {
if (!outDir) return;
fs.mkdirSync(outDir, { recursive: true });
void sharp(Buffer.from(pixels), {
raw: { width: 160, height: 100, channels: 4 },
})
.png()
.toFile(path.join(outDir, `${name}.png`));
fs.writeFileSync(
path.join(outDir, `${name}.json`),
JSON.stringify(stats(pixels)),
);
};
const save = (name, pixels) => saveCapture(outDir, name, pixels);
await strict.run(
{ args: ['--use-angle=metal', '--enable-gpu'] },
+186
View File
@@ -0,0 +1,186 @@
// Shared by the Navara visual QA scenarios (qa-navara-visual-styles,
// qa-navara-bloom, qa-navara-sonar, qa-navara-cockpit-clouds): read the
// engine canvas, compare frames, settle a still view, follow a style
// crossfade.
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
/** A still view over Austin, no layers, OSM imagery. */
export const STILL_VIEW =
'v=2&lat=30.2672&lon=-97.7431&alt=20000&heading=0&pitch=-70&map=osm&l=';
/** The keyless page on `engine` with the still view (and `extra` hash). */
export const visualPageUrl = (origin, engine, extra = '', view = STILL_VIEW) =>
`${origin}/?engine=${engine}&welcome=0&look=cinematic#${view}${extra}`;
/** A check that logs, records into `report` and asserts. */
export function checker(report, assert) {
return (name, pass, detail) => {
report.checks.push({ name, pass: Boolean(pass), detail });
console.log(
pass ? 'PASS' : 'FAIL',
name,
detail ? JSON.stringify(detail) : '',
);
assert.ok(pass, `${name} ${JSON.stringify(detail)}`);
};
}
// The engine canvas as drawn, downscaled to 160×100 RGBA in the page, read
// inside the engine's postRender (Cesium keeps no drawing buffer after it).
export async function capture(page) {
return page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const canvas = document.createElement('canvas');
canvas.width = 160;
canvas.height = 100;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
await new Promise((resolve) => {
const off = engine.frames.postRender.addEventListener(() => {
off();
ctx.drawImage(engine.host.canvas, 0, 0, 160, 100);
resolve();
});
engine.render.request('qa-visual-styles');
});
return Array.from(ctx.getImageData(0, 0, 160, 100).data);
});
}
export function stats(pixels) {
let r = 0,
g = 0,
b = 0,
saturation = 0;
const histogram = new Array(16).fill(0);
const count = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 4) {
const [pr, pg, pb] = [pixels[i], pixels[i + 1], pixels[i + 2]];
r += pr;
g += pg;
b += pb;
saturation += Math.max(pr, pg, pb) - Math.min(pr, pg, pb);
histogram[
Math.min(15, Math.floor((0.299 * pr + 0.587 * pg + 0.114 * pb) / 16))
]++;
}
// The vignette: the corners' luma over the centre's (160×100 capture).
const luma = (x0, y0) => {
let sum = 0;
for (let y = y0; y < y0 + 10; y++)
for (let x = x0; x < x0 + 16; x++) {
const i = (y * 160 + x) * 4;
sum +=
0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
}
return sum / 160;
};
const corners = (luma(0, 0) + luma(144, 0) + luma(0, 90) + luma(144, 90)) / 4;
return {
vignette: corners / Math.max(1, luma(72, 45)),
r: r / count,
g: g / count,
b: b / count,
saturation: saturation / count,
histogram: histogram.map((n) => n / count),
};
}
export function diff(a, b) {
let sum = 0;
let changed = 0;
for (let i = 0; i < a.length; i += 4) {
const d =
Math.abs(a[i] - b[i]) +
Math.abs(a[i + 1] - b[i + 1]) +
Math.abs(a[i + 2] - b[i + 2]);
sum += d / 3;
if (d / 3 > 8) changed++;
}
const count = a.length / 4;
return { mean: sum / count, changedShare: changed / count };
}
// Histogram distance (half the L1 of two normalised histograms, 0..1).
export const histogramShift = (a, b) =>
a.histogram.reduce(
(sum, value, i) => sum + Math.abs(value - b.histogram[i]),
0,
) / 2;
export async function freeze(page) {
await page.evaluate(async () => {
const a = window.__godsEyeView;
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
throw new Error('Keyless preview required');
a.styleManager.orbitController.stop();
a.engine.camera.cancelFlight();
a.styleManager._setDetectionMode?.('OFF');
for (const [id, row] of a.dataManager.layers)
if (row.enabled) await a.dataManager.setEnabled(id, false);
});
await page.waitForFunction(
() => window.__godsEyeView.engine.surface.tilesLoaded(),
{ timeout: 90000, polling: 250 },
);
await new Promise((resolve) => setTimeout(resolve, 2500));
}
// Picks a style the way the style keys do, then follows the crossfade on
// the engine every frame until VisualEffects has no transition left.
export async function pickStyle(page, name, previous) {
return page.evaluate(
async (name, previous) => {
const a = window.__godsEyeView;
const render = a.engine.render;
const drawn = typeof render.setStyle === 'function';
const intensity = (style) =>
!style || style === 'normal'
? 0
: drawn
? render.style(style).intensity
: a.styleManager.stages[style].uniforms.intensity;
const samples = [];
const started = performance.now();
a.styleManager.setStyle(name, { userInitiated: true });
while (performance.now() - started < 3000) {
await new Promise((resolve) => requestAnimationFrame(resolve));
samples.push([intensity(previous), intensity(name)]);
if (a.styleManager.transitions.size === 0) break;
}
return {
ms: performance.now() - started,
pending: a.styleManager.transitions.size,
samples,
active: a.styleManager.activeStyle,
intensities: drawn
? Object.fromEntries(
render.visualStyles.map((style) => [
style,
render.style(style).intensity,
]),
)
: null,
pipeline: a.engine.pipeline?.() ?? null,
};
},
name,
previous,
);
}
export const saveCapture = (outDir, name, pixels) => {
if (!outDir) return;
fs.mkdirSync(outDir, { recursive: true });
void sharp(Buffer.from(pixels), {
raw: { width: 160, height: 100, channels: 4 },
})
.png()
.toFile(path.join(outDir, `${name}.png`));
fs.writeFileSync(
path.join(outDir, `${name}.json`),
JSON.stringify(stats(pixels)),
);
};