mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
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:
1 parent
074f4af10e
commit
c392d6fb50
15 files changed
+893
-178
No files matched your search
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
};
|
||||
@@ -248,6 +248,17 @@
|
||||
* Every style starts at intensity 0 with its shader's defaults.
|
||||
* @property {(name: string) => StyleState} [style] The style's state last
|
||||
* set (a copy).
|
||||
* @property {(options: { enabled?: boolean, strength?: number }) => void} [setBloom]
|
||||
* Optional, with bloom (capability `bloom`): a glow around the bright parts
|
||||
* of the final frame, before the visual styles. `strength` is the app's
|
||||
* Bloom slider normalized to 0..1 (src/bloom.js
|
||||
* bloomStrengthFromIntensity); at or under 0.06 nothing glows, as with
|
||||
* Cesium's bloom stage, whose easing the engine follows. Omitted fields
|
||||
* keep their value; a non-boolean `enabled` or a strength outside 0..1
|
||||
* throws a TypeError. Off at strength 0 until set. Cesium has none: in
|
||||
* Cesium mode the app drives the scene's own bloom stage.
|
||||
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
|
||||
* last set (a copy).
|
||||
*
|
||||
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
|
||||
*
|
||||
@@ -666,6 +677,10 @@ const OPTIONAL_SHAPE = {
|
||||
'render.setStyle': isFunction,
|
||||
'render.style': isFunction,
|
||||
},
|
||||
bloom: {
|
||||
'render.setBloom': isFunction,
|
||||
'render.bloom': isFunction,
|
||||
},
|
||||
};
|
||||
|
||||
/** The optional capability flags, e.g. 'visualStyles'. */
|
||||
@@ -807,3 +822,24 @@ export function mergeStyle(current, { intensity, uniforms } = {}, names = []) {
|
||||
}
|
||||
return { intensity: intensity ?? current.intensity, uniforms: next };
|
||||
}
|
||||
|
||||
/** The bloom an engine starts with (GlobeRender.bloom). */
|
||||
export const BLOOM_INITIAL = Object.freeze({ enabled: false, strength: 0 });
|
||||
|
||||
/**
|
||||
* GlobeRender.setBloom's argument merged onto `current`: a new state, or a
|
||||
* TypeError for a non-boolean `enabled` or a strength outside 0..1.
|
||||
*/
|
||||
export function mergeBloom(current, { enabled, strength } = {}) {
|
||||
if (enabled !== undefined && !isBoolean(enabled))
|
||||
throw new TypeError(`Bloom enabled must be a boolean: ${enabled}`);
|
||||
if (
|
||||
strength !== undefined &&
|
||||
!(isNumber(strength) && strength >= 0 && strength <= 1)
|
||||
)
|
||||
throw new TypeError(`Bloom strength must be a number in 0..1: ${strength}`);
|
||||
return {
|
||||
enabled: enabled ?? current.enabled,
|
||||
strength: strength ?? current.strength,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GLOBE_ENGINE_MEMBERS,
|
||||
INPUT_TYPES,
|
||||
MODIFIERS,
|
||||
BLOOM_INITIAL,
|
||||
ENGINE_CAPABILITIES,
|
||||
OPTIONAL_ENGINE_MEMBERS,
|
||||
SHARPEN_INITIAL,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
createEngineEvent,
|
||||
engineCapabilities,
|
||||
isEngineEvent,
|
||||
mergeBloom,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from './contract.js';
|
||||
@@ -449,3 +451,28 @@ test('a fake engine with visual styles passes the optional style check', async (
|
||||
});
|
||||
assert.ok(!plain.some(({ name }) => /visualStyles/.test(name)));
|
||||
});
|
||||
|
||||
test('the bloom capability: whole or absent, merged and checked', async (t) => {
|
||||
assert.ok(ENGINE_CAPABILITIES.includes('bloom'));
|
||||
assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.bloom, [
|
||||
'render.setBloom',
|
||||
'render.bloom',
|
||||
]);
|
||||
const engine = createFakeEngine({ bloom: true });
|
||||
assert.equal(engineCapabilities(engine).bloom, true);
|
||||
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||
assert.deepEqual(engine.render.bloom(), BLOOM_INITIAL);
|
||||
const half = { ...engine, render: { ...engine.render, bloom: undefined } };
|
||||
assert.throws(() => assertGlobeEngine(half), /render\.bloom\b/);
|
||||
assert.deepEqual(mergeBloom(BLOOM_INITIAL, { strength: 0.5 }), {
|
||||
enabled: false,
|
||||
strength: 0.5,
|
||||
});
|
||||
for (const bad of [{ enabled: 'on' }, { strength: 2 }, { strength: -1 }])
|
||||
assert.throws(() => mergeBloom(BLOOM_INITIAL, bad), TypeError);
|
||||
const checks = createContractSuite(engine, {
|
||||
waitFrames: async (count) => engine.fake.frames(count),
|
||||
}).filter(({ name }) => /\(bloom\)/.test(name));
|
||||
assert.equal(checks.length, 1);
|
||||
for (const check of checks) await t.test(check.name, () => check.run());
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// The app's Bloom control on Navara: postprocessing's BloomEffect (mipmap
|
||||
// blur) as a Navara custom effect, with the settings of bloomSettings.js.
|
||||
// Navara 0.2.1 has no bloom of its own. It runs just before the visual
|
||||
// styles (stylePasses.js), after everything the scene and its overlays
|
||||
// draw, as Cesium's bloom stage precedes the app's post stages and glows
|
||||
// billboards too. A hidden pass is skipped: off costs nothing.
|
||||
|
||||
import { Effect as NavaraEffect, EffectDesc } from '@navaramap/three';
|
||||
import { BloomEffect } from 'postprocessing';
|
||||
import { createBloomPort } from './bloomSettings.js';
|
||||
import { pipelinePassNames } from './internals.js';
|
||||
|
||||
export const BLOOM_EFFECT_KEY = 'gevBloom';
|
||||
|
||||
function apply(effect, settings) {
|
||||
effect.luminanceMaterial.threshold = settings.luminanceThreshold;
|
||||
effect.luminanceMaterial.smoothing = settings.luminanceSmoothing;
|
||||
effect.mipmapBlurPass.radius = settings.radius;
|
||||
effect.intensity = settings.intensity;
|
||||
}
|
||||
|
||||
/** config.gevBloom: { settings } (bloomSettingsFromStrength). */
|
||||
class BloomEffectDesc extends EffectDesc {
|
||||
static key = BLOOM_EFFECT_KEY;
|
||||
// Nothing anchors after it but the styles: before them, else before the
|
||||
// final copy (the styles anchor after it).
|
||||
static insertAfter = [];
|
||||
static insertBefore = ['gevStyles', 'final'];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
this.settings = config[BLOOM_EFFECT_KEY].settings;
|
||||
config[BLOOM_EFFECT_KEY].onContext?.(ctx);
|
||||
}
|
||||
|
||||
createPass() {
|
||||
const camera = this.view.camera.raw;
|
||||
if (!camera) throw new Error('Camera not available for the bloom');
|
||||
this.effect = new BloomEffect({ mipmapBlur: true, levels: 6 });
|
||||
apply(this.effect, this.settings);
|
||||
return new NavaraEffect(camera, this.effect);
|
||||
}
|
||||
|
||||
onUpdateConfig(updates) {
|
||||
super.onUpdateConfig(updates);
|
||||
const settings = updates?.[BLOOM_EFFECT_KEY]?.settings;
|
||||
if (!settings) return;
|
||||
this.settings = settings;
|
||||
if (this.effect) apply(this.effect, settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bloom port createNavaraEngine takes (bloomSettings.js
|
||||
* createBloomPort) on a view with the effect registered.
|
||||
* @param {object} view an initialised ThreeView
|
||||
*/
|
||||
export function createNavaraBloom(view) {
|
||||
view.registerEffect(BLOOM_EFFECT_KEY, BloomEffectDesc);
|
||||
let context = null;
|
||||
const port = createBloomPort((settings) => {
|
||||
const handle = view.addEffect({
|
||||
[BLOOM_EFFECT_KEY]: {
|
||||
settings,
|
||||
onContext: (ctx) => {
|
||||
context = ctx;
|
||||
},
|
||||
},
|
||||
visible: true,
|
||||
});
|
||||
return {
|
||||
update: ({ settings: next, visible }) =>
|
||||
handle.update({ [BLOOM_EFFECT_KEY]: { settings: next }, visible }),
|
||||
delete: () => handle.delete(),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...port,
|
||||
/** Navara only (QA): whether bloom draws, and the pipeline order. */
|
||||
diagnostics: () => ({
|
||||
shown: port.shown(),
|
||||
passes: pipelinePassNames(context),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// The app's Bloom control on Navara, without Navara itself (Node tests):
|
||||
// the strength the Display slider sets (src/bloom.js
|
||||
// bloomStrengthFromIntensity, 0..1) as postprocessing BloomEffect settings,
|
||||
// and the bloom port createNavaraEngine takes. bloom.js adds the effect.
|
||||
//
|
||||
// Cesium's bloom stage (src/ui/visualEffects.js applyBloomIntensity) eases
|
||||
// the strength above a 0.06 dead zone (smoothstep) into a lower contrast, a
|
||||
// brighter offset and a wider blur: more of the frame glows, further out.
|
||||
// The same eased value drives the threshold, softness, blur radius and
|
||||
// strength here. The threshold is chosen in display (sRGB) terms, as
|
||||
// Cesium's stage reads display RGB, and handed to the effect in the linear
|
||||
// terms Navara's buffers hold (the exact transfer, as sharpen.js uses).
|
||||
|
||||
/** Strength at or under which bloom draws nothing (Cesium's dead zone). */
|
||||
export const BLOOM_DEAD_ZONE = 0.06;
|
||||
|
||||
const srgbToLinear = (value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
/** The dead zone removed and smoothstepped, as Cesium's stage eases it. */
|
||||
export function easedBloomStrength(strength) {
|
||||
const raw = Math.min(1, Math.max(0, Number(strength) || 0));
|
||||
if (raw <= BLOOM_DEAD_ZONE) return 0;
|
||||
const t = (raw - BLOOM_DEAD_ZONE) / (1 - BLOOM_DEAD_ZONE);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* BloomEffect settings for a strength: the luminance threshold falls from
|
||||
* display 0.92 to 0.72, its smoothing widens, the mipmap blur reaches
|
||||
* further and the glow strengthens. At full strength a bright map keeps its
|
||||
* detail under the glow, as on Cesium (QA: qa-navara-bloom.mjs).
|
||||
*/
|
||||
export function bloomSettingsFromStrength(strength) {
|
||||
const eased = easedBloomStrength(strength);
|
||||
return {
|
||||
eased,
|
||||
luminanceThreshold: srgbToLinear(0.92 - 0.2 * eased),
|
||||
luminanceSmoothing: 0.04 + 0.14 * eased,
|
||||
radius: 0.4 + 0.4 * eased,
|
||||
intensity: 0.25 + 0.75 * eased,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bloom port createNavaraEngine takes: `set({ enabled, strength })`
|
||||
* (already checked) adds the effect the first time bloom draws, updates its
|
||||
* settings and hides it while off or in the dead zone, so a view that never
|
||||
* blooms has no pass. `add(settings)` adds the effect and returns its
|
||||
* handle ({ update, delete }); bloom.js supplies it.
|
||||
*/
|
||||
export function createBloomPort(add) {
|
||||
let handle = null;
|
||||
let shown = false;
|
||||
return {
|
||||
set({ enabled, strength }) {
|
||||
const settings = bloomSettingsFromStrength(strength);
|
||||
const next = enabled && settings.eased > 0;
|
||||
if (!handle) {
|
||||
if (!next) return;
|
||||
handle = add(settings);
|
||||
shown = true;
|
||||
return;
|
||||
}
|
||||
handle.update({ settings, visible: next });
|
||||
shown = next;
|
||||
},
|
||||
/** Navara only (QA): whether the bloom pass draws. */
|
||||
shown: () => shown,
|
||||
destroy() {
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
shown = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bloomStrengthFromIntensity } from '../../bloom.js';
|
||||
import {
|
||||
BLOOM_DEAD_ZONE,
|
||||
bloomSettingsFromStrength,
|
||||
createBloomPort,
|
||||
easedBloomStrength,
|
||||
} from './bloomSettings.js';
|
||||
|
||||
const close = (actual, expected, label) =>
|
||||
assert.ok(Math.abs(actual - expected) < 1e-9, `${label}: ${actual}`);
|
||||
|
||||
test('the strength eases above the dead zone as Cesium bloom stage does', () => {
|
||||
assert.equal(BLOOM_DEAD_ZONE, 0.06);
|
||||
assert.equal(easedBloomStrength(0), 0);
|
||||
assert.equal(easedBloomStrength(0.06), 0);
|
||||
assert.equal(easedBloomStrength(1), 1);
|
||||
assert.equal(easedBloomStrength(2), 1, 'clamped');
|
||||
assert.equal(easedBloomStrength(Number.NaN), 0);
|
||||
// VisualEffects.applyBloomIntensity: (s - 0.06) / 0.94, smoothstepped.
|
||||
const s = bloomStrengthFromIntensity(100);
|
||||
const t = (s - 0.06) / 0.94;
|
||||
close(easedBloomStrength(s), t * t * (3 - 2 * t), 'slider 100');
|
||||
});
|
||||
|
||||
test('a stronger bloom lowers the threshold and widens and strengthens the glow', () => {
|
||||
const weak = bloomSettingsFromStrength(0.2);
|
||||
const strong = bloomSettingsFromStrength(1);
|
||||
assert.ok(strong.luminanceThreshold < weak.luminanceThreshold);
|
||||
assert.ok(strong.luminanceSmoothing > weak.luminanceSmoothing);
|
||||
assert.ok(strong.radius > weak.radius);
|
||||
assert.ok(strong.intensity > weak.intensity);
|
||||
// The threshold is display 0.92..0.72 in the linear terms Navara holds.
|
||||
const linear = (v) => ((v + 0.055) / 1.055) ** 2.4;
|
||||
close(bloomSettingsFromStrength(0).luminanceThreshold, linear(0.92), 'off');
|
||||
close(strong.luminanceThreshold, linear(0.72), 'full');
|
||||
assert.equal(strong.eased, 1);
|
||||
});
|
||||
|
||||
test('the bloom port adds the effect when it first glows and hides it after', () => {
|
||||
const added = [];
|
||||
const updates = [];
|
||||
let deleted = 0;
|
||||
const port = createBloomPort((settings) => {
|
||||
added.push(settings);
|
||||
return {
|
||||
update: (next) => updates.push(next),
|
||||
delete: () => deleted++,
|
||||
};
|
||||
});
|
||||
port.set({ enabled: true, strength: 0.05 });
|
||||
port.set({ enabled: false, strength: 1 });
|
||||
assert.equal(added.length, 0, 'no glow, no pass');
|
||||
port.set({ enabled: true, strength: 1 });
|
||||
assert.equal(added.length, 1);
|
||||
assert.deepEqual(added[0], bloomSettingsFromStrength(1));
|
||||
assert.equal(port.shown(), true);
|
||||
port.set({ enabled: true, strength: 0.03 });
|
||||
assert.equal(port.shown(), false, 'the dead zone hides it');
|
||||
port.set({ enabled: true, strength: 0.5 });
|
||||
port.set({ enabled: false, strength: 0.5 });
|
||||
assert.deepEqual(
|
||||
updates.map(({ visible }) => visible),
|
||||
[false, true, false],
|
||||
);
|
||||
assert.deepEqual(updates[1].settings, bloomSettingsFromStrength(0.5));
|
||||
port.destroy();
|
||||
port.destroy();
|
||||
assert.equal(deleted, 1);
|
||||
});
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
import * as geo from '../../geo/index.js';
|
||||
import {
|
||||
BLOOM_INITIAL,
|
||||
SHARPEN_INITIAL,
|
||||
STYLE_INITIAL,
|
||||
createEngineEvent,
|
||||
mergeBloom,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from '../contract.js';
|
||||
@@ -90,12 +92,15 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
||||
* clock?: object,
|
||||
* appClock?: import('../../time/clock.js').AppClock,
|
||||
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
|
||||
* bloom?: { set: (state: { enabled: boolean, strength: number }) => void, destroy: () => void },
|
||||
* styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => void, destroy: () => void },
|
||||
* }} options `hooks` are internals.js; `container` defaults to the
|
||||
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
|
||||
* it the engine only keeps the state) and the engine destroys it;
|
||||
* `styles` draws render.setStyle (stylePasses.js; without it the engine
|
||||
* has no visualStyles capability) and the engine destroys it;
|
||||
* has no visualStyles capability) and the engine destroys it; `bloom`
|
||||
* draws render.setBloom (bloom.js; without it no bloom capability), also
|
||||
* destroyed with the engine;
|
||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
@@ -115,6 +120,7 @@ export function createNavaraEngine(
|
||||
lines: linesPort = null,
|
||||
sharpen: sharpenPort = null,
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!view?.camera?.raw)
|
||||
@@ -310,6 +316,21 @@ export function createNavaraEngine(
|
||||
},
|
||||
}
|
||||
: {};
|
||||
let bloom = { ...BLOOM_INITIAL };
|
||||
const bloomRender = bloomPort
|
||||
? {
|
||||
setBloom(options) {
|
||||
const next = mergeBloom(bloom, options);
|
||||
const changed =
|
||||
next.enabled !== bloom.enabled || next.strength !== bloom.strength;
|
||||
bloom = next;
|
||||
if (!changed || destroyed) return;
|
||||
bloomPort.set({ ...next });
|
||||
view.forceUpdate();
|
||||
},
|
||||
bloom: () => ({ ...bloom }),
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
kind: 'navara',
|
||||
host: { container: container ?? canvas.parentElement, canvas, size },
|
||||
@@ -339,6 +360,7 @@ export function createNavaraEngine(
|
||||
},
|
||||
sharpen: () => ({ ...sharpen }),
|
||||
...styleRender,
|
||||
...bloomRender,
|
||||
},
|
||||
surface,
|
||||
input: input.input,
|
||||
@@ -360,13 +382,23 @@ export function createNavaraEngine(
|
||||
maps.destroy();
|
||||
sharpenPort?.destroy();
|
||||
stylesPort?.destroy();
|
||||
bloomPort?.destroy();
|
||||
moversPort?.destroy();
|
||||
linesPort?.destroy();
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
// Navara only, not contract: the post pipeline as QA reads it (the
|
||||
// passes in draw order, whether the styles pass shows).
|
||||
pipeline: () => stylesPort?.diagnostics?.() ?? null,
|
||||
pipeline: () => {
|
||||
const styles = stylesPort?.diagnostics?.();
|
||||
const glow = bloomPort?.diagnostics?.();
|
||||
if (!styles && !glow) return null;
|
||||
return {
|
||||
shown: styles?.shown ?? false,
|
||||
bloomShown: glow?.shown ?? false,
|
||||
passes: styles?.passes ?? glow?.passes ?? null,
|
||||
};
|
||||
},
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
// the camera and the margin it keeps above it (QA measures against it).
|
||||
groundFloor: {
|
||||
|
||||
@@ -264,10 +264,39 @@ test('setStyle hands changed style states to the styles port and draws a frame',
|
||||
assert.equal(port.destroyed, 1);
|
||||
});
|
||||
|
||||
test('setBloom hands changed states to the bloom port and draws a frame', () => {
|
||||
const view = fakeView();
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
hooks,
|
||||
bloom: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
assertGlobeEngine(engine);
|
||||
assert.equal(engineCapabilities(engine).bloom, true);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: false, strength: 0 });
|
||||
engine.render.setBloom({ strength: 0.5 });
|
||||
engine.render.setBloom({ enabled: true });
|
||||
assert.deepEqual(view.calls.at(-1), ['forceUpdate']);
|
||||
const calls = view.calls.length;
|
||||
engine.render.setBloom({ enabled: true, strength: 0.5 });
|
||||
assert.equal(view.calls.length, calls, 'an unchanged state draws nothing');
|
||||
assert.throws(() => engine.render.setBloom({ strength: 3 }), TypeError);
|
||||
assert.deepEqual(shown, [
|
||||
{ enabled: false, strength: 0.5 },
|
||||
{ enabled: true, strength: 0.5 },
|
||||
]);
|
||||
engine.destroy();
|
||||
engine.destroy();
|
||||
assert.equal(destroyed, 1);
|
||||
});
|
||||
|
||||
test('without a styles port the engine has no visualStyles capability', () => {
|
||||
const { engine } = setup();
|
||||
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||
assert.equal(engineCapabilities(engine).bloom, false);
|
||||
assert.equal(engine.render.setStyle, undefined);
|
||||
assert.equal(engine.render.setBloom, undefined);
|
||||
});
|
||||
|
||||
test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { buildNavaraLook } from './look.js';
|
||||
import { createNavaraSharpen } from './sharpen.js';
|
||||
import { createNavaraStyles } from './stylePasses.js';
|
||||
import { createNavaraBloom } from './bloom.js';
|
||||
import { retroShader } from '../../styles/retro.js';
|
||||
import { nightVisionShader } from '../../styles/surveillance.js';
|
||||
import { thermalShader } from '../../styles/thermal.js';
|
||||
@@ -157,6 +158,7 @@ export async function createNavaraGlobe({
|
||||
container,
|
||||
...(appClock ? { appClock } : {}),
|
||||
sharpen: createNavaraSharpen(view),
|
||||
bloom: createNavaraBloom(view),
|
||||
styles: createNavaraStyles(view, {
|
||||
shaders: STYLE_SHADERS,
|
||||
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
||||
|
||||
@@ -2771,9 +2771,68 @@ export function createContractSuite(
|
||||
const capabilities = engineCapabilities(engine);
|
||||
if (capabilities.visualStyles)
|
||||
checks.push(...styleChecks(engine.render, settle));
|
||||
if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle));
|
||||
return checks.map(([name, run]) => ({ name, run }));
|
||||
}
|
||||
|
||||
// The optional bloom capability (render.setBloom).
|
||||
function bloomChecks(render, settle) {
|
||||
return [
|
||||
[
|
||||
'render.setBloom (bloom): merges, copies, rejects bad values, draws',
|
||||
async () => {
|
||||
const previous = render.bloom();
|
||||
ensure(
|
||||
typeof previous.enabled === 'boolean' &&
|
||||
Number.isFinite(previous.strength),
|
||||
`bloom() ${JSON.stringify(previous)} is not { enabled, strength }`,
|
||||
);
|
||||
const same = (expected, label) => {
|
||||
const actual = render.bloom();
|
||||
ensure(
|
||||
actual.enabled === expected.enabled &&
|
||||
actual.strength === expected.strength,
|
||||
`${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`,
|
||||
);
|
||||
};
|
||||
try {
|
||||
render.setBloom({ enabled: true, strength: 0.8 });
|
||||
same({ enabled: true, strength: 0.8 }, 'after enabling');
|
||||
render.bloom().strength = 0;
|
||||
same({ enabled: true, strength: 0.8 }, 'bloom() is a copy');
|
||||
// A frame with the bloom on draws without errors.
|
||||
await settle(2);
|
||||
render.setBloom({ strength: 0.05 });
|
||||
same({ enabled: true, strength: 0.05 }, 'in the dead zone');
|
||||
render.setBloom({ enabled: false });
|
||||
same({ enabled: false, strength: 0.05 }, 'after disabling');
|
||||
for (const bad of [
|
||||
{ enabled: 1 },
|
||||
{ strength: -0.1 },
|
||||
{ strength: 1.5 },
|
||||
{ strength: Number.NaN },
|
||||
]) {
|
||||
let error = null;
|
||||
try {
|
||||
render.setBloom(bad);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
ensure(
|
||||
error instanceof TypeError,
|
||||
`setBloom(${JSON.stringify(bad)}) did not throw a TypeError`,
|
||||
);
|
||||
}
|
||||
same({ enabled: false, strength: 0.05 }, 'after rejected values');
|
||||
} finally {
|
||||
render.setBloom(previous);
|
||||
await settle(1);
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// The optional visualStyles capability (render.setStyle).
|
||||
function styleChecks(render, settle) {
|
||||
return [
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
INPUT_TYPES,
|
||||
MODIFIERS,
|
||||
normalizeInputOptions,
|
||||
BLOOM_INITIAL,
|
||||
SHARPEN_INITIAL,
|
||||
STYLE_INITIAL,
|
||||
createEngineEvent,
|
||||
mergeBloom,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from '../contract.js';
|
||||
@@ -229,6 +231,19 @@ function rectangleFromCartographics(points) {
|
||||
return { west, south, east, north };
|
||||
}
|
||||
|
||||
// render.setBloom/bloom when asked; nothing otherwise.
|
||||
function fakeBloom(enabled, record) {
|
||||
if (!enabled) return {};
|
||||
let bloom = { ...BLOOM_INITIAL };
|
||||
return {
|
||||
setBloom(options) {
|
||||
record('render.setBloom', [options]);
|
||||
bloom = mergeBloom(bloom, options);
|
||||
},
|
||||
bloom: () => ({ ...bloom }),
|
||||
};
|
||||
}
|
||||
|
||||
// render.visualStyles/setStyle/style for `visualStyles` ({ name: uniform
|
||||
// names }); nothing without it.
|
||||
function fakeStyles(visualStyles, record) {
|
||||
@@ -264,7 +279,7 @@ function fakeStyles(visualStyles, record) {
|
||||
* longitude?: number, latitude?: number, altitude?: number,
|
||||
* container?: object, canvas?: object, nowMs?: number,
|
||||
* appClock?: import('../../time/clock.js').AppClock,
|
||||
* visualStyles?: Record<string, string[]> }} [options]
|
||||
* visualStyles?: Record<string, string[]>, bloom?: boolean }} [options]
|
||||
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
|
||||
* for the host elements (DOM mocks for shell tests). The initial pose looks straight
|
||||
* down from `altitude` metres (10 000 km). The engine clock reads
|
||||
@@ -272,7 +287,8 @@ function fakeStyles(visualStyles, record) {
|
||||
* `nowMs` (2026-01-01T00:00Z) and moves only with `fake.frame(dt)` and
|
||||
* `fake.advanceTime(ms)` (a given appClock keeps its own wall clock).
|
||||
* `visualStyles` ({ style: its uniform names }) adds the optional
|
||||
* visualStyles capability (render.setStyle), each uniform starting at 0.
|
||||
* visualStyles capability (render.setStyle), each uniform starting at 0;
|
||||
* `bloom: true` the optional bloom capability (render.setBloom).
|
||||
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
||||
*/
|
||||
export function createFakeEngine(options = {}) {
|
||||
@@ -959,6 +975,7 @@ export function createFakeEngine(options = {}) {
|
||||
},
|
||||
sharpen: () => ({ ...sharpen }),
|
||||
...fakeStyles(options.visualStyles, record),
|
||||
...fakeBloom(options.bloom, record),
|
||||
},
|
||||
surface: {
|
||||
sampleHeight(position, exclude) {
|
||||
|
||||
+26
-5
@@ -53,7 +53,8 @@ export class VisualEffects {
|
||||
/**
|
||||
* @param {object} options - Viewer, render ownership callbacks and optional clocks.
|
||||
* Without a viewer, `engine` (the globe engine) draws the sharpening
|
||||
* through render.setSharpen. Construction is inert; initStyles and
|
||||
* through render.setSharpen, and the styles and bloom through
|
||||
* render.setStyle and render.setBloom when it has them. Construction is inert; initStyles and
|
||||
* initPostProcess retain startup ordering.
|
||||
*/
|
||||
constructor({
|
||||
@@ -76,8 +77,11 @@ export class VisualEffects {
|
||||
typeof engine?.render?.setSharpen === 'function' ? engine.render : null;
|
||||
// Without a viewer, an engine that draws the styles itself
|
||||
// (render.setStyle) gets them through stand-in stages (initStyles).
|
||||
const capabilities = engineCapabilities(engine);
|
||||
this.engineStyles =
|
||||
!viewer && engineCapabilities(engine).visualStyles ? engine.render : null;
|
||||
!viewer && capabilities.visualStyles ? engine.render : null;
|
||||
// Likewise the bloom (render.setBloom) for Cesium's bloom stage.
|
||||
this.engineBloom = !viewer && capabilities.bloom ? engine.render : null;
|
||||
this.requestRender = requestRender;
|
||||
this.holdRender = holdRender;
|
||||
this.releaseRender = releaseRender;
|
||||
@@ -136,7 +140,16 @@ export class VisualEffects {
|
||||
}
|
||||
|
||||
initPostProcess(sharpenIntensity = this.sharpenIntensity) {
|
||||
if (!this.viewer || this.stopped || this.sharpenStage) return;
|
||||
if (this.stopped || this.sharpenStage) return;
|
||||
// An engine that blooms starts from this owner's bloom (off), whatever
|
||||
// a previous owner left on it.
|
||||
if (!this.viewer) {
|
||||
this.engineBloom?.setBloom({
|
||||
enabled: false,
|
||||
strength: bloomStrengthFromIntensity(this.bloomIntensity),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.bloomStage = this.viewer.scene.postProcessStages.bloom;
|
||||
const names = [
|
||||
'glowOnly',
|
||||
@@ -195,16 +208,24 @@ export class VisualEffects {
|
||||
}
|
||||
|
||||
syncBloomEnabled() {
|
||||
if (this.stopped || !this.bloomStage) return;
|
||||
this.bloomStage.enabled =
|
||||
if (this.stopped) return;
|
||||
const enabled =
|
||||
this.bloomEnabled &&
|
||||
bloomStrengthFromIntensity(this.bloomIntensity) > 0.06;
|
||||
if (this.bloomStage) this.bloomStage.enabled = enabled;
|
||||
this.engineBloom?.setBloom({ enabled });
|
||||
}
|
||||
|
||||
applyBloomIntensity(intensity) {
|
||||
if (this.stopped) return;
|
||||
this.bloomIntensity = clampBloomIntensity(intensity);
|
||||
this.requestRender('bloom');
|
||||
if (this.engineBloom) {
|
||||
this.engineBloom.setBloom({
|
||||
strength: bloomStrengthFromIntensity(this.bloomIntensity),
|
||||
});
|
||||
this.syncBloomEnabled();
|
||||
}
|
||||
if (!this.bloomStage) return;
|
||||
const rawStrength = bloomStrengthFromIntensity(this.bloomIntensity);
|
||||
const strength = rawStrength <= 0.06 ? 0.0 : (rawStrength - 0.06) / 0.94;
|
||||
|
||||
@@ -459,6 +459,33 @@ test('Anime on an engine without a viewer reaches no sky box', () => {
|
||||
effects.destroy();
|
||||
});
|
||||
|
||||
test('without a viewer an engine that blooms gets the slider strength and dead zone', () => {
|
||||
const { engine, effects, requests } = engineFixture(
|
||||
createFakeEngine({ kind: 'navara', bloom: true }),
|
||||
);
|
||||
engine.render.setBloom({ enabled: true, strength: 1 });
|
||||
effects.initPostProcess(0.49);
|
||||
assert.deepEqual(
|
||||
engine.render.bloom(),
|
||||
{ enabled: false, strength: 0 },
|
||||
'a new owner starts the engine from its own bloom',
|
||||
);
|
||||
effects.applyBloomIntensity(150);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: false, strength: 0.75 });
|
||||
effects.setBloomEnabled(true);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: true, strength: 0.75 });
|
||||
// Cesium's dead zone: the toggle stays on, nothing glows.
|
||||
effects.applyBloomIntensity(10);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: false, strength: 0.05 });
|
||||
effects.applyBloomIntensity(200);
|
||||
assert.deepEqual(engine.render.bloom(), { enabled: true, strength: 1 });
|
||||
effects.setBloomEnabled(false);
|
||||
assert.equal(engine.render.bloom().enabled, false);
|
||||
assert.ok(requests.includes('bloom'));
|
||||
assert.equal(effects.bloomStage, null);
|
||||
effects.destroy();
|
||||
});
|
||||
|
||||
test('without the visualStyles capability an engine gets no stages', () => {
|
||||
const { effects } = engineFixture(createFakeEngine({ kind: 'navara' }));
|
||||
effects.initStyles();
|
||||
|
||||
Reference in new issue
Block a user