mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Draw the visual styles on Navara as one post pass behind an optional render.setStyle capability
CRT, NVG, FLIR, Anime and Noir run their Cesium shaders through a GLSL prelude with the exact sRGB transfer, crossfade in the stage order, fade the Navara stars with Anime, and report the visualStyles capability through engineCapabilities.
This commit is contained in:
1 parent
00dfed7959
commit
4c28cc0212
16 files changed
+1805
-8
No files matched your search
@@ -0,0 +1,485 @@
|
|||||||
|
// The app's visual styles on Navara (Stage 6 B1): each of CRT, NVG, FLIR,
|
||||||
|
// Anime and Noir filters the Navara frame (pixels and a style-specific
|
||||||
|
// signature against Normal), switching styles crossfades (both styles drawn
|
||||||
|
// at once mid-way, the fade completes), Normal restores the frame, the
|
||||||
|
// retired Snow opens on Normal, a link's style opens on Navara, and on
|
||||||
|
// Cesium the styles still run as Cesium post stages with the engine left
|
||||||
|
// alone. Keyless preview under the strict default-deny network policy.
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-visual-styles.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 sharp from 'sharp';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.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}`;
|
||||||
|
|
||||||
|
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)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(pageUrl('navara'), { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
await freeze(page);
|
||||||
|
|
||||||
|
const capabilities = await page.evaluate(() => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
styles: engine.render.visualStyles,
|
||||||
|
pipeline: engine.pipeline(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Navara draws every app style itself',
|
||||||
|
JSON.stringify(capabilities.styles) === JSON.stringify(STYLES),
|
||||||
|
capabilities,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'no style pass shows on Normal',
|
||||||
|
capabilities.pipeline?.shown === false,
|
||||||
|
capabilities.pipeline,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The military styles' presets set sharpening and detection (as on
|
||||||
|
// Cesium); Normal keeps them, so they are put back before comparing.
|
||||||
|
const display = await page.evaluate(
|
||||||
|
() => window.__godsEyeView.styleManager.getControlState().sharpen,
|
||||||
|
);
|
||||||
|
const normal = await capture(page);
|
||||||
|
const normalAgain = await capture(page);
|
||||||
|
const noise = diff(normal, normalAgain);
|
||||||
|
check(
|
||||||
|
'Normal is a still frame to compare against',
|
||||||
|
noise.mean < 0.5,
|
||||||
|
noise,
|
||||||
|
);
|
||||||
|
save('normal', normal);
|
||||||
|
const normalStats = stats(normal);
|
||||||
|
|
||||||
|
let previous = 'normal';
|
||||||
|
for (const name of STYLES) {
|
||||||
|
const fade = await pickStyle(page, name, previous);
|
||||||
|
check(
|
||||||
|
`${name}: the crossfade completes`,
|
||||||
|
fade.pending === 0 && fade.ms < 1500 && fade.active === name,
|
||||||
|
{ ms: fade.ms, pending: fade.pending },
|
||||||
|
);
|
||||||
|
const between = fade.samples.filter(
|
||||||
|
([, next]) => next > 0.02 && next < 0.98,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${name}: the engine fades it in over several frames`,
|
||||||
|
between.length >= 1,
|
||||||
|
{ frames: fade.samples.length, between: between.length },
|
||||||
|
);
|
||||||
|
if (previous !== 'normal') {
|
||||||
|
const both = fade.samples.filter(
|
||||||
|
([out, next]) =>
|
||||||
|
out > 0.05 && out < 0.95 && next > 0.05 && next < 0.95,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${name}: ${previous} and ${name} draw together mid-crossfade`,
|
||||||
|
both.length >= 1,
|
||||||
|
{ both: both.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const others = Object.entries(fade.intensities).filter(
|
||||||
|
([style, value]) => style !== name && value !== 0,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${name}: alone at full intensity once settled`,
|
||||||
|
fade.intensities[name] === 1 && others.length === 0,
|
||||||
|
fade.intensities,
|
||||||
|
);
|
||||||
|
const order = fade.pipeline?.passes ?? [];
|
||||||
|
const at = order.indexOf('gevStyles');
|
||||||
|
check(
|
||||||
|
`${name}: the style pass shows, last before the final copy and after the overlays`,
|
||||||
|
fade.pipeline?.shown === true &&
|
||||||
|
at >= 0 &&
|
||||||
|
order[at + 1] === 'final' &&
|
||||||
|
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
|
||||||
|
(key) => !order.includes(key) || order.indexOf(key) < at,
|
||||||
|
),
|
||||||
|
fade.pipeline,
|
||||||
|
);
|
||||||
|
const pixels = await capture(page);
|
||||||
|
save(name, pixels);
|
||||||
|
const styled = stats(pixels);
|
||||||
|
const change = diff(normal, pixels);
|
||||||
|
const shift = histogramShift(normalStats, styled);
|
||||||
|
check(
|
||||||
|
`${name}: the frame changes against Normal`,
|
||||||
|
change.mean > 6 && change.changedShare > 0.25,
|
||||||
|
{ ...change, histogramShift: shift },
|
||||||
|
);
|
||||||
|
const signature = {
|
||||||
|
// Night vision: phosphor green over red and blue.
|
||||||
|
surveillance: styled.g > styled.r * 1.3 && styled.g > styled.b * 1.3,
|
||||||
|
// FLIR white-hot (palette 0): grey.
|
||||||
|
thermal: styled.saturation < normalStats.saturation * 0.5,
|
||||||
|
// Noir: greyer, with a slight sepia, and vignetted corners.
|
||||||
|
noir:
|
||||||
|
styled.saturation < normalStats.saturation &&
|
||||||
|
styled.vignette < normalStats.vignette * 0.85,
|
||||||
|
// Anime: cel bands, more saturated.
|
||||||
|
anime:
|
||||||
|
styled.saturation > normalStats.saturation * 1.05 || shift > 0.05,
|
||||||
|
// CRT: pixelated, dithered, scanlined.
|
||||||
|
retro: shift > 0.05 || change.mean > 12,
|
||||||
|
}[name];
|
||||||
|
check(`${name}: the style's own look`, signature, {
|
||||||
|
normal: {
|
||||||
|
r: normalStats.r,
|
||||||
|
g: normalStats.g,
|
||||||
|
b: normalStats.b,
|
||||||
|
saturation: normalStats.saturation,
|
||||||
|
vignette: normalStats.vignette,
|
||||||
|
},
|
||||||
|
styled: {
|
||||||
|
r: styled.r,
|
||||||
|
g: styled.g,
|
||||||
|
b: styled.b,
|
||||||
|
saturation: styled.saturation,
|
||||||
|
vignette: styled.vignette,
|
||||||
|
},
|
||||||
|
histogramShift: shift,
|
||||||
|
});
|
||||||
|
previous = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A style parameter reaches the Navara pass (FLIR's Ironbow palette).
|
||||||
|
await pickStyle(page, 'thermal', previous);
|
||||||
|
previous = 'thermal';
|
||||||
|
const grey = await capture(page);
|
||||||
|
const ironbow = await page.evaluate(() => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
a.styleManager.stages.thermal.uniforms.palette = 1;
|
||||||
|
return a.engine.render.style('thermal').uniforms.palette;
|
||||||
|
});
|
||||||
|
const coloured = await capture(page);
|
||||||
|
check(
|
||||||
|
'FLIR Ironbow reaches the engine and colours the frame',
|
||||||
|
ironbow === 1 && stats(coloured).saturation > stats(grey).saturation + 10,
|
||||||
|
{
|
||||||
|
grey: stats(grey).saturation,
|
||||||
|
ironbow: stats(coloured).saturation,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__godsEyeView.styleManager.stages.thermal.uniforms.palette = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const back = await pickStyle(page, 'normal', previous);
|
||||||
|
check(
|
||||||
|
'Normal: every style back at 0 and the pass hidden',
|
||||||
|
Object.values(back.intensities).every((v) => v === 0) &&
|
||||||
|
back.pipeline?.shown === false,
|
||||||
|
back,
|
||||||
|
);
|
||||||
|
await page.evaluate((sharpen) => {
|
||||||
|
const { styleManager } = window.__godsEyeView;
|
||||||
|
styleManager._setDetectionMode?.('OFF');
|
||||||
|
styleManager.setSharpen(sharpen);
|
||||||
|
}, display);
|
||||||
|
const restored = await capture(page);
|
||||||
|
save('normal-restored', restored);
|
||||||
|
const residue = diff(normal, restored);
|
||||||
|
check(
|
||||||
|
'Normal restores the frame',
|
||||||
|
residue.mean < 1 && residue.changedShare < 0.01,
|
||||||
|
residue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The retired Snow opens on Normal; a link's style opens on Navara.
|
||||||
|
await page.goto(pageUrl('navara', '&style=snow'), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
const snow = await page.evaluate(() => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
active: a.styleManager.activeStyle,
|
||||||
|
intensities: a.engine.render.visualStyles.map(
|
||||||
|
(s) => a.engine.render.style(s).intensity,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'style=snow opens on Normal',
|
||||||
|
snow.active === 'normal' && snow.intensities.every((v) => v === 0),
|
||||||
|
snow,
|
||||||
|
);
|
||||||
|
await page.goto(pageUrl('navara', `&style=${URL_NAMES.thermal}`), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.engine.render.style('thermal').intensity === 1,
|
||||||
|
{ timeout: 10000, polling: 50 },
|
||||||
|
);
|
||||||
|
check('a FLIR link opens filtered on Navara', true);
|
||||||
|
|
||||||
|
// Cesium keeps its own post stages; its engine draws no styles. The
|
||||||
|
// switch is real input on the picker, in the same document.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.setStyle('normal'),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||||
|
);
|
||||||
|
await strict.switchTo(page, 'cesium');
|
||||||
|
await freeze(page);
|
||||||
|
// The address may still carry the FLIR link: Normal first.
|
||||||
|
await pickStyle(page, 'normal', 'thermal');
|
||||||
|
await page.evaluate((sharpen) => {
|
||||||
|
const { styleManager } = window.__godsEyeView;
|
||||||
|
styleManager._setDetectionMode?.('OFF');
|
||||||
|
styleManager.setSharpen(sharpen);
|
||||||
|
}, display);
|
||||||
|
const cesiumNormal = await capture(page);
|
||||||
|
const cesium = await pickStyle(page, 'thermal', 'normal');
|
||||||
|
const cesiumState = await page.evaluate(() => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
const stage = a.styleManager.stages.thermal;
|
||||||
|
return {
|
||||||
|
setStyle: typeof a.engine.render.setStyle,
|
||||||
|
stageName: stage.name,
|
||||||
|
enabled: stage.enabled,
|
||||||
|
intensity: stage.uniforms.intensity,
|
||||||
|
inScene: a.viewer?.scene?.postProcessStages?.contains?.(stage) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const cesiumThermal = await capture(page);
|
||||||
|
save('cesium-normal', cesiumNormal);
|
||||||
|
save('cesium-thermal', cesiumThermal);
|
||||||
|
if (outDir)
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(outDir, 'cesium-thermal-page.png'),
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Cesium: FLIR is its own post stage, the engine draws none',
|
||||||
|
cesium.pending === 0 &&
|
||||||
|
cesiumState.setStyle === 'undefined' &&
|
||||||
|
cesiumState.enabled === true &&
|
||||||
|
cesiumState.intensity === 1 &&
|
||||||
|
cesiumState.inScene !== false,
|
||||||
|
cesiumState,
|
||||||
|
);
|
||||||
|
const cesiumChange = diff(cesiumNormal, cesiumThermal);
|
||||||
|
check(
|
||||||
|
'Cesium: FLIR changes its frame',
|
||||||
|
cesiumChange.mean > 6,
|
||||||
|
cesiumChange,
|
||||||
|
);
|
||||||
|
await pickStyle(page, 'normal', 'thermal');
|
||||||
|
await page.evaluate((sharpen) => {
|
||||||
|
const { styleManager } = window.__godsEyeView;
|
||||||
|
styleManager._setDetectionMode?.('OFF');
|
||||||
|
styleManager.setSharpen(sharpen);
|
||||||
|
}, display);
|
||||||
|
const cesiumBack = diff(cesiumNormal, await capture(page));
|
||||||
|
check(
|
||||||
|
'Cesium: Normal restores its frame',
|
||||||
|
cesiumBack.mean < 1.5,
|
||||||
|
cesiumBack,
|
||||||
|
);
|
||||||
|
|
||||||
|
check('no page errors', errors.length === 0, errors);
|
||||||
|
if (outDir)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, 'visual-styles.json'),
|
||||||
|
JSON.stringify(report, null, 2),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -233,6 +233,23 @@
|
|||||||
* state here (src/ui/visualEffects.js).
|
* state here (src/ui/visualEffects.js).
|
||||||
* @property {() => { enabled: boolean, amount: number }} sharpen
|
* @property {() => { enabled: boolean, amount: number }} sharpen
|
||||||
* The sharpening last set (a copy).
|
* The sharpening last set (a copy).
|
||||||
|
* @property {readonly string[]} [visualStyles] Optional, with setStyle and
|
||||||
|
* style (capability `visualStyles`, see engineCapabilities): the
|
||||||
|
* full-frame styles the engine draws itself, by the app's style names
|
||||||
|
* (src/styles: retro, surveillance, thermal, anime, noir). Cesium has none:
|
||||||
|
* in Cesium mode the app's own post stages draw the styles
|
||||||
|
* (src/ui/visualEffects.js).
|
||||||
|
* @property {(name: string, options: { intensity?: number, uniforms?: Record<string, number> }) => void} [setStyle]
|
||||||
|
* Shows one style at `intensity` (0..1, 0 draws nothing; a crossfade shows
|
||||||
|
* two at once, drawn in the order of visualStyles) with its own uniforms
|
||||||
|
* (the style's parameters and `time`, seconds, for an animated one).
|
||||||
|
* Omitted fields keep their value; an unknown style or uniform name, an
|
||||||
|
* intensity outside 0..1 or a non-finite uniform throws a TypeError.
|
||||||
|
* Every style starts at intensity 0 with its shader's defaults.
|
||||||
|
* @property {(name: string) => StyleState} [style] The style's state last
|
||||||
|
* set (a copy).
|
||||||
|
*
|
||||||
|
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
|
||||||
*
|
*
|
||||||
* @typedef {object} PickResult
|
* @typedef {object} PickResult
|
||||||
* @property {any} id The picked entity or object id (a mover's or line's
|
* @property {any} id The picked entity or object id (a mover's or line's
|
||||||
@@ -635,6 +652,56 @@ const SHAPE = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNameList = (value) =>
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length > 0 &&
|
||||||
|
value.every((name) => typeof name === 'string' && name.length > 0);
|
||||||
|
|
||||||
|
// Optional capabilities: flag → member path → predicate. An engine has each
|
||||||
|
// group whole or not at all (assertGlobeEngine); engineCapabilities says
|
||||||
|
// which it has.
|
||||||
|
const OPTIONAL_SHAPE = {
|
||||||
|
visualStyles: {
|
||||||
|
'render.visualStyles': isNameList,
|
||||||
|
'render.setStyle': isFunction,
|
||||||
|
'render.style': isFunction,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The optional capability flags, e.g. 'visualStyles'. */
|
||||||
|
export const ENGINE_CAPABILITIES = Object.freeze(Object.keys(OPTIONAL_SHAPE));
|
||||||
|
|
||||||
|
/** The member paths of each optional capability. */
|
||||||
|
export const OPTIONAL_ENGINE_MEMBERS = Object.freeze(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
|
||||||
|
flag,
|
||||||
|
Object.freeze(Object.keys(members)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberAt = (engine, path) =>
|
||||||
|
path.split('.').reduce((value, key) => value?.[key], engine);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which optional capabilities an engine has: { visualStyles, ... } as
|
||||||
|
* booleans. A caller that refuses a feature on an engine without a Cesium
|
||||||
|
* viewer (voice, say) asks here instead: Cesium draws these through the app's
|
||||||
|
* own stages and reports false.
|
||||||
|
*/
|
||||||
|
export function engineCapabilities(engine) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
|
||||||
|
flag,
|
||||||
|
isObject(engine) &&
|
||||||
|
Object.entries(members).every(([path, valid]) =>
|
||||||
|
valid(memberAt(engine, path)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The capability paths the contract requires, e.g. 'camera.flyTo'. */
|
/** The capability paths the contract requires, e.g. 'camera.flyTo'. */
|
||||||
export const GLOBE_ENGINE_MEMBERS = Object.freeze([
|
export const GLOBE_ENGINE_MEMBERS = Object.freeze([
|
||||||
'kind',
|
'kind',
|
||||||
@@ -671,6 +738,13 @@ export function assertGlobeEngine(engine) {
|
|||||||
for (const [member, valid] of Object.entries(members))
|
for (const [member, valid] of Object.entries(members))
|
||||||
if (!valid(value[member])) problems.push(`${capability}.${member}`);
|
if (!valid(value[member])) problems.push(`${capability}.${member}`);
|
||||||
}
|
}
|
||||||
|
// An optional capability is whole or absent.
|
||||||
|
for (const members of Object.values(OPTIONAL_SHAPE)) {
|
||||||
|
const paths = Object.keys(members);
|
||||||
|
if (paths.every((path) => memberAt(engine, path) === undefined)) continue;
|
||||||
|
for (const [path, valid] of Object.entries(members))
|
||||||
|
if (!valid(memberAt(engine, path))) problems.push(path);
|
||||||
|
}
|
||||||
if (!isFunction(engine.destroy)) problems.push('destroy');
|
if (!isFunction(engine.destroy)) problems.push('destroy');
|
||||||
if (!isFunction(engine.isDestroyed)) problems.push('isDestroyed');
|
if (!isFunction(engine.isDestroyed)) problems.push('isDestroyed');
|
||||||
if (problems.length)
|
if (problems.length)
|
||||||
@@ -699,3 +773,37 @@ export function mergeSharpen(current, { enabled, amount } = {}) {
|
|||||||
amount: amount ?? current.amount,
|
amount: amount ?? current.amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A style's state before it is first set (GlobeRender.style), less its uniforms. */
|
||||||
|
export const STYLE_INITIAL = Object.freeze({ intensity: 0 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobeRender.setStyle's argument merged onto `current` ({ intensity,
|
||||||
|
* uniforms }): a new state, or a TypeError for an intensity outside 0..1, a
|
||||||
|
* uniform not in `names` or a non-finite uniform value.
|
||||||
|
*/
|
||||||
|
export function mergeStyle(current, { intensity, uniforms } = {}, names = []) {
|
||||||
|
if (
|
||||||
|
intensity !== undefined &&
|
||||||
|
!(isNumber(intensity) && intensity >= 0 && intensity <= 1)
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
`Style intensity must be a number in 0..1: ${intensity}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
uniforms !== undefined &&
|
||||||
|
(uniforms === null || typeof uniforms !== 'object')
|
||||||
|
)
|
||||||
|
throw new TypeError('Style uniforms must be an object');
|
||||||
|
const next = { ...current.uniforms };
|
||||||
|
for (const [name, value] of Object.entries(uniforms ?? {})) {
|
||||||
|
if (!names.includes(name))
|
||||||
|
throw new TypeError(`Unknown style uniform: ${name}`);
|
||||||
|
if (!isNumber(value))
|
||||||
|
throw new TypeError(
|
||||||
|
`Style uniform ${name} must be a finite number: ${value}`,
|
||||||
|
);
|
||||||
|
next[name] = value;
|
||||||
|
}
|
||||||
|
return { intensity: intensity ?? current.intensity, uniforms: next };
|
||||||
|
}
|
||||||
@@ -5,11 +5,15 @@ import {
|
|||||||
GLOBE_ENGINE_MEMBERS,
|
GLOBE_ENGINE_MEMBERS,
|
||||||
INPUT_TYPES,
|
INPUT_TYPES,
|
||||||
MODIFIERS,
|
MODIFIERS,
|
||||||
|
ENGINE_CAPABILITIES,
|
||||||
|
OPTIONAL_ENGINE_MEMBERS,
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
assertGlobeEngine,
|
assertGlobeEngine,
|
||||||
createEngineEvent,
|
createEngineEvent,
|
||||||
|
engineCapabilities,
|
||||||
isEngineEvent,
|
isEngineEvent,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
|
mergeStyle,
|
||||||
} from './contract.js';
|
} from './contract.js';
|
||||||
import { createFakeEngine } from './testing/fakeEngine.js';
|
import { createFakeEngine } from './testing/fakeEngine.js';
|
||||||
import {
|
import {
|
||||||
@@ -376,3 +380,72 @@ test('the contract carries the follow camera', () => {
|
|||||||
const broken = { ...engine, camera: { ...engine.camera, followChanged: {} } };
|
const broken = { ...engine, camera: { ...engine.camera, followChanged: {} } };
|
||||||
assert.throws(() => assertGlobeEngine(broken), /camera\.followChanged/);
|
assert.throws(() => assertGlobeEngine(broken), /camera\.followChanged/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('engineCapabilities names the optional capabilities an engine has whole', () => {
|
||||||
|
assert.ok(ENGINE_CAPABILITIES.includes('visualStyles'));
|
||||||
|
assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.visualStyles, [
|
||||||
|
'render.visualStyles',
|
||||||
|
'render.setStyle',
|
||||||
|
'render.style',
|
||||||
|
]);
|
||||||
|
const plain = createFakeEngine();
|
||||||
|
for (const flag of ENGINE_CAPABILITIES)
|
||||||
|
assert.equal(engineCapabilities(plain)[flag], false, flag);
|
||||||
|
const styled = createFakeEngine({
|
||||||
|
visualStyles: { thermal: ['palette', 'time'] },
|
||||||
|
});
|
||||||
|
assert.equal(engineCapabilities(styled).visualStyles, true);
|
||||||
|
assert.equal(assertGlobeEngine(styled), styled);
|
||||||
|
assert.equal(engineCapabilities(null).visualStyles, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertGlobeEngine refuses half an optional capability', () => {
|
||||||
|
const engine = createFakeEngine({ visualStyles: { noir: ['grainAmt'] } });
|
||||||
|
const half = { ...engine, render: { ...engine.render, style: undefined } };
|
||||||
|
assert.throws(() => assertGlobeEngine(half), /render\.style\b/);
|
||||||
|
const badList = {
|
||||||
|
...engine,
|
||||||
|
render: { ...engine.render, visualStyles: [] },
|
||||||
|
};
|
||||||
|
assert.throws(() => assertGlobeEngine(badList), /render\.visualStyles/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeStyle keeps omitted fields, copies uniforms and rejects bad values', () => {
|
||||||
|
const current = { intensity: 0, uniforms: { palette: 0, time: 0 } };
|
||||||
|
const names = ['palette', 'time'];
|
||||||
|
const next = mergeStyle(current, { intensity: 0.4 }, names);
|
||||||
|
assert.deepEqual(next, { intensity: 0.4, uniforms: { palette: 0, time: 0 } });
|
||||||
|
assert.notEqual(next.uniforms, current.uniforms);
|
||||||
|
assert.deepEqual(mergeStyle(next, { uniforms: { palette: 1 } }, names), {
|
||||||
|
intensity: 0.4,
|
||||||
|
uniforms: { palette: 1, time: 0 },
|
||||||
|
});
|
||||||
|
for (const bad of [
|
||||||
|
{ intensity: 1.01 },
|
||||||
|
{ intensity: -1 },
|
||||||
|
{ intensity: Number.NaN },
|
||||||
|
{ intensity: '1' },
|
||||||
|
{ uniforms: null },
|
||||||
|
{ uniforms: { other: 1 } },
|
||||||
|
{ uniforms: { palette: Number.POSITIVE_INFINITY } },
|
||||||
|
])
|
||||||
|
assert.throws(() => mergeStyle(current, bad, names), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fake engine with visual styles passes the optional style check', async (t) => {
|
||||||
|
const engine = createFakeEngine({
|
||||||
|
visualStyles: { retro: ['pixelation', 'time'], noir: ['grainAmt'] },
|
||||||
|
});
|
||||||
|
const checks = createContractSuite(engine, {
|
||||||
|
waitFrames: async (count) => engine.fake.frames(count),
|
||||||
|
});
|
||||||
|
const style = checks.filter(({ name }) => /visualStyles/.test(name));
|
||||||
|
assert.equal(style.length, 1);
|
||||||
|
for (const check of style) await t.test(check.name, () => check.run());
|
||||||
|
assert.equal(engine.render.style('retro').intensity, 0);
|
||||||
|
// Without the capability the check is not in the suite.
|
||||||
|
const plain = createContractSuite(createFakeEngine(), {
|
||||||
|
waitFrames: async () => {},
|
||||||
|
});
|
||||||
|
assert.ok(!plain.some(({ name }) => /visualStyles/.test(name)));
|
||||||
|
});
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import {
|
import {
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
|
STYLE_INITIAL,
|
||||||
createEngineEvent,
|
createEngineEvent,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
|
mergeStyle,
|
||||||
} from '../contract.js';
|
} from '../contract.js';
|
||||||
import { cesiumFovy } from './cameraMath.js';
|
import { cesiumFovy } from './cameraMath.js';
|
||||||
import { createCameraModel } from './cameraModel.js';
|
import { createCameraModel } from './cameraModel.js';
|
||||||
@@ -88,9 +90,12 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
|||||||
* clock?: object,
|
* clock?: object,
|
||||||
* appClock?: import('../../time/clock.js').AppClock,
|
* appClock?: import('../../time/clock.js').AppClock,
|
||||||
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
|
* sharpen?: { set: (state: { enabled: boolean, amount: 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
|
* }} options `hooks` are internals.js; `container` defaults to the
|
||||||
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
|
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
|
||||||
* it the engine only keeps the state) and the engine destroys it;
|
* 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;
|
||||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
* `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;
|
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||||
@@ -109,6 +114,7 @@ export function createNavaraEngine(
|
|||||||
movers: moversPort = null,
|
movers: moversPort = null,
|
||||||
lines: linesPort = null,
|
lines: linesPort = null,
|
||||||
sharpen: sharpenPort = null,
|
sharpen: sharpenPort = null,
|
||||||
|
styles: stylesPort = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!view?.camera?.raw)
|
if (!view?.camera?.raw)
|
||||||
@@ -263,6 +269,47 @@ export function createNavaraEngine(
|
|||||||
|
|
||||||
let sharpen = { ...SHARPEN_INITIAL };
|
let sharpen = { ...SHARPEN_INITIAL };
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
// render.visualStyles/setStyle/style, with a styles port only.
|
||||||
|
const styleStates = new Map(
|
||||||
|
(stylesPort?.names ?? []).map((name) => [
|
||||||
|
name,
|
||||||
|
{ ...STYLE_INITIAL, uniforms: stylesPort.defaults(name) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const knownStyle = (name) => {
|
||||||
|
if (!styleStates.has(name))
|
||||||
|
throw new TypeError(`Unknown visual style: ${name}`);
|
||||||
|
return styleStates.get(name);
|
||||||
|
};
|
||||||
|
const styleRender = stylesPort
|
||||||
|
? {
|
||||||
|
visualStyles: Object.freeze([...stylesPort.names]),
|
||||||
|
setStyle(name, options) {
|
||||||
|
const current = knownStyle(name);
|
||||||
|
const next = mergeStyle(
|
||||||
|
current,
|
||||||
|
options,
|
||||||
|
stylesPort.uniformNames(name),
|
||||||
|
);
|
||||||
|
const changed =
|
||||||
|
next.intensity !== current.intensity ||
|
||||||
|
Object.entries(next.uniforms).some(
|
||||||
|
([key, value]) => current.uniforms[key] !== value,
|
||||||
|
);
|
||||||
|
styleStates.set(name, next);
|
||||||
|
if (!changed || destroyed) return;
|
||||||
|
stylesPort.set(name, {
|
||||||
|
intensity: next.intensity,
|
||||||
|
uniforms: { ...next.uniforms },
|
||||||
|
});
|
||||||
|
view.forceUpdate();
|
||||||
|
},
|
||||||
|
style(name) {
|
||||||
|
const { intensity, uniforms } = knownStyle(name);
|
||||||
|
return { intensity, uniforms: { ...uniforms } };
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
return {
|
return {
|
||||||
kind: 'navara',
|
kind: 'navara',
|
||||||
host: { container: container ?? canvas.parentElement, canvas, size },
|
host: { container: container ?? canvas.parentElement, canvas, size },
|
||||||
@@ -291,6 +338,7 @@ export function createNavaraEngine(
|
|||||||
view.forceUpdate();
|
view.forceUpdate();
|
||||||
},
|
},
|
||||||
sharpen: () => ({ ...sharpen }),
|
sharpen: () => ({ ...sharpen }),
|
||||||
|
...styleRender,
|
||||||
},
|
},
|
||||||
surface,
|
surface,
|
||||||
input: input.input,
|
input: input.input,
|
||||||
@@ -311,10 +359,14 @@ export function createNavaraEngine(
|
|||||||
credits.destroy();
|
credits.destroy();
|
||||||
maps.destroy();
|
maps.destroy();
|
||||||
sharpenPort?.destroy();
|
sharpenPort?.destroy();
|
||||||
|
stylesPort?.destroy();
|
||||||
moversPort?.destroy();
|
moversPort?.destroy();
|
||||||
linesPort?.destroy();
|
linesPort?.destroy();
|
||||||
},
|
},
|
||||||
isDestroyed: () => destroyed,
|
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,
|
||||||
// Navara only, not contract: the ground the floor last sampled under
|
// Navara only, not contract: the ground the floor last sampled under
|
||||||
// the camera and the margin it keeps above it (QA measures against it).
|
// the camera and the margin it keeps above it (QA measures against it).
|
||||||
groundFloor: {
|
groundFloor: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { assertGlobeEngine } from '../contract.js';
|
import { assertGlobeEngine, engineCapabilities } from '../contract.js';
|
||||||
import { cesiumFovy, orientationVectors } from './cameraMath.js';
|
import { cesiumFovy, orientationVectors } from './cameraMath.js';
|
||||||
import { createNavaraEngine } from './createNavaraEngine.js';
|
import { createNavaraEngine } from './createNavaraEngine.js';
|
||||||
|
|
||||||
@@ -208,6 +208,68 @@ test('without a sharpen port setSharpen keeps the state only', () => {
|
|||||||
assert.deepEqual(engine.render.sharpen(), { enabled: true, amount: 0.5 });
|
assert.deepEqual(engine.render.sharpen(), { enabled: true, amount: 0.5 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A styles port (stylePass.js createStylesPort) on two styles.
|
||||||
|
function stylesPort() {
|
||||||
|
const port = {
|
||||||
|
shown: [],
|
||||||
|
destroyed: 0,
|
||||||
|
names: ['retro', 'noir'],
|
||||||
|
uniformNames: (name) =>
|
||||||
|
name === 'retro' ? ['pixelation', 'time'] : ['grainAmt'],
|
||||||
|
defaults: (name) =>
|
||||||
|
name === 'retro' ? { pixelation: 5, time: 0 } : { grainAmt: 0.5 },
|
||||||
|
set: (name, state) => port.shown.push([name, state]),
|
||||||
|
destroy: () => port.destroyed++,
|
||||||
|
};
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('setStyle hands changed style states to the styles port and draws a frame', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const port = stylesPort();
|
||||||
|
const engine = createNavaraEngine(view, { hooks, styles: port });
|
||||||
|
assertGlobeEngine(engine);
|
||||||
|
assert.equal(engineCapabilities(engine).visualStyles, true);
|
||||||
|
assert.deepEqual(engine.render.visualStyles, ['retro', 'noir']);
|
||||||
|
assert.deepEqual(engine.render.style('retro'), {
|
||||||
|
intensity: 0,
|
||||||
|
uniforms: { pixelation: 5, time: 0 },
|
||||||
|
});
|
||||||
|
engine.render.setStyle('retro', { intensity: 0.5 });
|
||||||
|
assert.deepEqual(view.calls.at(-1), ['forceUpdate']);
|
||||||
|
engine.render.setStyle('retro', { uniforms: { time: 2 } });
|
||||||
|
const calls = view.calls.length;
|
||||||
|
engine.render.setStyle('retro', { intensity: 0.5, uniforms: { time: 2 } });
|
||||||
|
assert.equal(view.calls.length, calls, 'an unchanged state draws nothing');
|
||||||
|
assert.throws(() => engine.render.setStyle('snow', {}), TypeError);
|
||||||
|
assert.throws(
|
||||||
|
() => engine.render.setStyle('noir', { uniforms: { time: 1 } }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => engine.render.setStyle('noir', { intensity: 1.5 }),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.deepEqual(port.shown, [
|
||||||
|
['retro', { intensity: 0.5, uniforms: { pixelation: 5, time: 0 } }],
|
||||||
|
['retro', { intensity: 0.5, uniforms: { pixelation: 5, time: 2 } }],
|
||||||
|
]);
|
||||||
|
// The port gets its own copy.
|
||||||
|
port.shown[1][1].uniforms.time = 99;
|
||||||
|
assert.equal(engine.render.style('retro').uniforms.time, 2);
|
||||||
|
engine.destroy();
|
||||||
|
engine.render.setStyle('noir', { intensity: 1 });
|
||||||
|
assert.equal(port.shown.length, 2, 'nothing reaches a destroyed port');
|
||||||
|
engine.destroy();
|
||||||
|
assert.equal(port.destroyed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a styles port the engine has no visualStyles capability', () => {
|
||||||
|
const { engine } = setup();
|
||||||
|
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||||
|
assert.equal(engine.render.setStyle, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => {
|
test("the camera starts from Navara's drawn camera and pushes poses with setCamera", () => {
|
||||||
const { engine, view } = setup();
|
const { engine, view } = setup();
|
||||||
near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height');
|
near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height');
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
} from './internals.js';
|
} from './internals.js';
|
||||||
import { buildNavaraLook } from './look.js';
|
import { buildNavaraLook } from './look.js';
|
||||||
import { createNavaraSharpen } from './sharpen.js';
|
import { createNavaraSharpen } from './sharpen.js';
|
||||||
|
import { createNavaraStyles } from './stylePasses.js';
|
||||||
|
import { retroShader } from '../../styles/retro.js';
|
||||||
|
import { nightVisionShader } from '../../styles/surveillance.js';
|
||||||
|
import { thermalShader } from '../../styles/thermal.js';
|
||||||
|
import { animeShader } from '../../styles/anime.js';
|
||||||
|
import { noirShader } from '../../styles/noir.js';
|
||||||
import { createNavaraMovers } from './movers/index.js';
|
import { createNavaraMovers } from './movers/index.js';
|
||||||
import { createNavaraLines } from './lines/index.js';
|
import { createNavaraLines } from './lines/index.js';
|
||||||
import { createNavaraOverlay } from './overlay.js';
|
import { createNavaraOverlay } from './overlay.js';
|
||||||
@@ -51,6 +57,16 @@ function preservedCanvas(container) {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The app's styles by their names, in Cesium's stage order
|
||||||
|
// (src/ui/visualPresets.js STYLES).
|
||||||
|
const STYLE_SHADERS = Object.freeze({
|
||||||
|
retro: retroShader,
|
||||||
|
surveillance: nightVisionShader,
|
||||||
|
thermal: thermalShader,
|
||||||
|
anime: animeShader,
|
||||||
|
noir: noirShader,
|
||||||
|
});
|
||||||
|
|
||||||
// The camera's place for the look, refreshed in place each frame (no
|
// The camera's place for the look, refreshed in place each frame (no
|
||||||
// allocation): degrees for the sun's elevation, and the height.
|
// allocation): degrees for the sun's elevation, and the height.
|
||||||
const lookCarto = { longitude: 0, latitude: 0, height: 0 };
|
const lookCarto = { longitude: 0, latitude: 0, height: 0 };
|
||||||
@@ -141,6 +157,10 @@ export async function createNavaraGlobe({
|
|||||||
container,
|
container,
|
||||||
...(appClock ? { appClock } : {}),
|
...(appClock ? { appClock } : {}),
|
||||||
sharpen: createNavaraSharpen(view),
|
sharpen: createNavaraSharpen(view),
|
||||||
|
styles: createNavaraStyles(view, {
|
||||||
|
shaders: STYLE_SHADERS,
|
||||||
|
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
||||||
|
}),
|
||||||
movers: createNavaraMovers(view, {
|
movers: createNavaraMovers(view, {
|
||||||
overlay,
|
overlay,
|
||||||
// Model colour follows the display-space stage, after the look's ACES.
|
// Model colour follows the display-space stage, after the look's ACES.
|
||||||
|
|||||||
@@ -162,3 +162,14 @@ export const navaraHooks = (view) => ({
|
|||||||
tileRoots: () => tileRoots(view),
|
tileRoots: () => tileRoots(view),
|
||||||
isIdle: () => isIdle(view),
|
isIdle: () => isIdle(view),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 6 (QA diagnostics only): the names of the passes in the view's
|
||||||
|
* post-processing pipeline, in draw order, or null when the path is gone.
|
||||||
|
* Private: ctx.renderPassOrchestrator.passMap.list ({ name, pass }).
|
||||||
|
* Upstream ask: a public read of the pipeline order.
|
||||||
|
*/
|
||||||
|
export function pipelinePassNames(ctx) {
|
||||||
|
const list = ctx?.renderPassOrchestrator?.passMap?.list;
|
||||||
|
return Array.isArray(list) ? list.map((entry) => entry.name) : null;
|
||||||
|
}
|
||||||
@@ -198,15 +198,35 @@ export function buildNavaraLook(
|
|||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
};
|
};
|
||||||
|
// Anime's bands turn the starfield into blotches: the stars fade with the
|
||||||
|
// style (Cesium fades its Milky Way), from the look's own brightness.
|
||||||
|
const starsMesh = mesh('stars');
|
||||||
|
const starsIntensity =
|
||||||
|
plan.meshes.find((desc) => Object.hasOwn(desc, 'stars'))?.stars
|
||||||
|
?.intensity ?? NAVARA_STARS_INTENSITY;
|
||||||
|
let starsDimmed = 0;
|
||||||
return {
|
return {
|
||||||
handles,
|
handles,
|
||||||
frame,
|
frame,
|
||||||
resetDaylight() {
|
resetDaylight() {
|
||||||
daylightTime = null;
|
daylightTime = null;
|
||||||
},
|
},
|
||||||
|
/** Dims the stars by `amount` (0 none, 1 hidden); true when it changed. */
|
||||||
|
setStarsDimmed(amount) {
|
||||||
|
const next = Math.min(1, Math.max(0, Number(amount) || 0));
|
||||||
|
if (!starsMesh || next === starsDimmed) return false;
|
||||||
|
starsDimmed = next;
|
||||||
|
starsMesh.update({
|
||||||
|
stars: { intensity: starsIntensity * (1 - next) },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navara's own stars intensity, for a look that does not set one. */
|
||||||
|
export const NAVARA_STARS_INTENSITY = 10;
|
||||||
|
|
||||||
const IRRADIANCE_ON = Object.freeze({
|
const IRRADIANCE_ON = Object.freeze({
|
||||||
aerialPerspective: Object.freeze({ irradiance: true }),
|
aerialPerspective: Object.freeze({ irradiance: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -469,3 +469,43 @@ test('the minimal look keeps its stars and clouds whatever the sun', () => {
|
|||||||
look.frame(900, false, 0, { lat: 0, lng: 0 });
|
look.frame(900, false, 0, { lat: 0, lng: 0 });
|
||||||
assert.deepEqual(view.added[1].updates, []);
|
assert.deepEqual(view.added[1].updates, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Anime fades the stars from the look own brightness and back', () => {
|
||||||
|
const view = fakeView(0);
|
||||||
|
const look = warmCinematic(view);
|
||||||
|
const stars = view.added.find(({ desc }) => Object.hasOwn(desc, 'stars'));
|
||||||
|
assert.equal(look.setStarsDimmed(0), false, 'already undimmed');
|
||||||
|
assert.equal(look.setStarsDimmed(0.25), true);
|
||||||
|
assert.equal(look.setStarsDimmed(0.25), false);
|
||||||
|
assert.equal(look.setStarsDimmed(1), true);
|
||||||
|
assert.equal(look.setStarsDimmed(0), true);
|
||||||
|
assert.deepEqual(
|
||||||
|
stars.updates.filter((update) => update.stars),
|
||||||
|
[
|
||||||
|
{ stars: { intensity: 90 } },
|
||||||
|
{ stars: { intensity: 0 } },
|
||||||
|
{ stars: { intensity: 120 } },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// The minimal look keeps Navara's own intensity as its base.
|
||||||
|
const minimalView = fakeView(0);
|
||||||
|
const minimal = buildNavaraLook(
|
||||||
|
minimalView,
|
||||||
|
plugin([]),
|
||||||
|
navaraScenePlan('minimal'),
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
minimal.setStarsDimmed(0.5);
|
||||||
|
const minimalStars = minimalView.added.find(({ desc }) =>
|
||||||
|
Object.hasOwn(desc, 'stars'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(minimalStars.updates, [{ stars: { intensity: 5 } }]);
|
||||||
|
// A look without its own stars (the plugin's photoreal scene) ignores it.
|
||||||
|
const photoreal = buildNavaraLook(
|
||||||
|
fakeView(0),
|
||||||
|
plugin([]),
|
||||||
|
navaraScenePlan('photoreal'),
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
assert.equal(photoreal.setStarsDimmed(1), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// The app's visual styles on Navara (CRT, NVG, FLIR, Anime, Noir): each
|
||||||
|
// style's own Cesium post-process shader (src/styles/*.js) drawn as a
|
||||||
|
// full-screen pass of the Navara pipeline, through the public EffectDesc API.
|
||||||
|
//
|
||||||
|
// The shaders use only Cesium's post-stage names (colorTexture,
|
||||||
|
// colorTextureDimensions, v_textureCoordinates, out_FragColor) plus their
|
||||||
|
// own uniforms, no czm_ built-ins and no depth. A GLSL prelude maps those
|
||||||
|
// names onto a three RawShaderMaterial and wraps the style's main().
|
||||||
|
//
|
||||||
|
// Colour: Cesium's styles filter encoded display RGB. Navara buffers stay
|
||||||
|
// linear, so, as the sharpening does (sharpen.js), every read of the input
|
||||||
|
// goes through the exact sRGB transfer and the style's result through its
|
||||||
|
// inverse (a gamma-2.2 approximation shifts dark FLIR bands visibly).
|
||||||
|
//
|
||||||
|
// One pass holds every style. A crossfade draws the outgoing and incoming
|
||||||
|
// styles in Cesium's stage order, one after the other (a scratch target
|
||||||
|
// between them); a frame with no style showing skips the pass (disabled).
|
||||||
|
// The pass runs after everything the scene draws (tone mapping, the layers'
|
||||||
|
// ground fields, the sharpening, anti-aliasing and the screen overlays):
|
||||||
|
// Cesium's stages filter its whole frame, billboards included. Bloom, when
|
||||||
|
// on, runs just before it (bloom.js), as Cesium's bloom precedes its stages.
|
||||||
|
|
||||||
|
import { Pass } from 'postprocessing';
|
||||||
|
import {
|
||||||
|
GLSL3,
|
||||||
|
HalfFloatType,
|
||||||
|
LinearFilter,
|
||||||
|
NoColorSpace,
|
||||||
|
RawShaderMaterial,
|
||||||
|
SRGBColorSpace,
|
||||||
|
Vector2,
|
||||||
|
WebGLRenderTarget,
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
export const STYLES_EFFECT_KEY = 'gevStyles';
|
||||||
|
/** Intensity under which a style draws nothing (as VisualEffects disables a stage). */
|
||||||
|
export const STYLE_VISIBLE_INTENSITY = 0.001;
|
||||||
|
|
||||||
|
const VERTEX_SHADER = /* glsl */ `precision highp float;
|
||||||
|
in vec3 position;
|
||||||
|
in vec2 uv;
|
||||||
|
out vec2 v_textureCoordinates;
|
||||||
|
void main() {
|
||||||
|
v_textureCoordinates = uv;
|
||||||
|
gl_Position = vec4(position.xy, 1.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRELUDE = /* glsl */ `precision highp float;
|
||||||
|
precision highp int;
|
||||||
|
out vec4 gevFragColor;
|
||||||
|
// What the style writes, in Cesium's name; resolved in main() below.
|
||||||
|
vec4 out_FragColor;
|
||||||
|
vec3 gevEncodeSrgb(vec3 color) {
|
||||||
|
return mix(12.92*color,1.055*pow(max(color,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),color));
|
||||||
|
}
|
||||||
|
vec3 gevDecodeSrgb(vec3 color) {
|
||||||
|
return mix(color/12.92,pow(max((color+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),color));
|
||||||
|
}
|
||||||
|
// Every texture read the style makes is of colorTexture: hand it display RGB.
|
||||||
|
vec4 gevStyleTexture(sampler2D image, vec2 uv) {
|
||||||
|
vec4 color = texture(image, uv);
|
||||||
|
return vec4(gevEncodeSrgb(color.rgb), color.a);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EPILOGUE = /* glsl */ `
|
||||||
|
void main() {
|
||||||
|
out_FragColor = vec4(0.0);
|
||||||
|
gevStyleMain();
|
||||||
|
vec3 display = clamp(out_FragColor.rgb, 0.0, 1.0);
|
||||||
|
#if GEV_DISPLAY_OUTPUT
|
||||||
|
gevFragColor = vec4(display, out_FragColor.a);
|
||||||
|
#else
|
||||||
|
gevFragColor = vec4(gevDecodeSrgb(display), out_FragColor.a);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MAIN = /void\s+main\s*\(\s*\)/g;
|
||||||
|
const TEXTURE_CALL = /\btexture\s*\(/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fragment shader a Cesium style shader runs as on Navara (GLSL ES 3):
|
||||||
|
* its texture() reads go through the sRGB encode, its main() becomes
|
||||||
|
* gevStyleMain(), and the prelude's main() decodes what it wrote.
|
||||||
|
*/
|
||||||
|
export function navaraStyleFragmentShader(source) {
|
||||||
|
if (typeof source !== 'string' || (source.match(MAIN) ?? []).length !== 1)
|
||||||
|
throw new TypeError('A style shader needs one main()');
|
||||||
|
if (/\bczm_/.test(source))
|
||||||
|
throw new TypeError('A Navara style shader cannot use Cesium built-ins');
|
||||||
|
const body = source
|
||||||
|
.replace(MAIN, 'void gevStyleMain()')
|
||||||
|
.replace(TEXTURE_CALL, 'gevStyleTexture(');
|
||||||
|
return `${PRELUDE}\n${body}\n${EPILOGUE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uniform names a style takes besides intensity: its own, and `time` when it animates. */
|
||||||
|
export function styleUniformNames(shader) {
|
||||||
|
const names = Object.keys(shader.uniforms ?? {});
|
||||||
|
if (/uniform\s+float\s+time\s*;/.test(shader.fragmentShader))
|
||||||
|
names.push('time');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A style's uniforms at their defaults (time 0). */
|
||||||
|
export function styleUniformDefaults(shader) {
|
||||||
|
const values = {};
|
||||||
|
for (const name of styleUniformNames(shader))
|
||||||
|
values[name] = name === 'time' ? 0 : shader.uniforms[name].default;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The styles drawn this frame, in draw order (the order of `shaders`, which
|
||||||
|
* is Cesium's stage order): [name, state] for each visible one.
|
||||||
|
*/
|
||||||
|
export function visibleStyles(order, states) {
|
||||||
|
const drawn = [];
|
||||||
|
for (const name of order) {
|
||||||
|
const state = states.get(name);
|
||||||
|
if (state && state.intensity > STYLE_VISIBLE_INTENSITY)
|
||||||
|
drawn.push([name, state]);
|
||||||
|
}
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleMaterial(shader) {
|
||||||
|
const uniforms = {
|
||||||
|
colorTexture: { value: null },
|
||||||
|
colorTextureDimensions: { value: new Vector2(1, 1) },
|
||||||
|
intensity: { value: 0 },
|
||||||
|
};
|
||||||
|
for (const [name, value] of Object.entries(styleUniformDefaults(shader)))
|
||||||
|
uniforms[name] = { value };
|
||||||
|
return new RawShaderMaterial({
|
||||||
|
name: `GevStyle_${shader.name}`,
|
||||||
|
glslVersion: GLSL3,
|
||||||
|
vertexShader: VERTEX_SHADER,
|
||||||
|
fragmentShader: navaraStyleFragmentShader(shader.fragmentShader),
|
||||||
|
uniforms,
|
||||||
|
defines: { GEV_DISPLAY_OUTPUT: 0 },
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws the visible styles from the input buffer into the output buffer. */
|
||||||
|
export class GevStylesPass extends Pass {
|
||||||
|
constructor(controller) {
|
||||||
|
super('GevStylesPass');
|
||||||
|
this.controller = controller;
|
||||||
|
this.materials = new Map();
|
||||||
|
this.scratch = null;
|
||||||
|
this.needsSwap = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
material(name) {
|
||||||
|
let material = this.materials.get(name);
|
||||||
|
if (!material) {
|
||||||
|
material = styleMaterial(this.controller.shaders[name]);
|
||||||
|
this.materials.set(name, material);
|
||||||
|
}
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
scratchLike(buffer) {
|
||||||
|
if (!this.scratch) {
|
||||||
|
this.scratch = new WebGLRenderTarget(buffer.width, buffer.height, {
|
||||||
|
type: buffer.texture?.type ?? HalfFloatType,
|
||||||
|
minFilter: LinearFilter,
|
||||||
|
magFilter: LinearFilter,
|
||||||
|
depthBuffer: false,
|
||||||
|
stencilBuffer: false,
|
||||||
|
});
|
||||||
|
this.scratch.texture.colorSpace = NoColorSpace;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.scratch.width !== buffer.width ||
|
||||||
|
this.scratch.height !== buffer.height
|
||||||
|
)
|
||||||
|
this.scratch.setSize(buffer.width, buffer.height);
|
||||||
|
return this.scratch;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(renderer, inputBuffer, outputBuffer) {
|
||||||
|
const drawn = this.controller.visible();
|
||||||
|
this.needsSwap = drawn.length > 0;
|
||||||
|
if (!drawn.length) return;
|
||||||
|
let source = inputBuffer;
|
||||||
|
for (let index = 0; index < drawn.length; index++) {
|
||||||
|
const [name, state] = drawn[index];
|
||||||
|
const last = index === drawn.length - 1;
|
||||||
|
// Ping-pong through one scratch target; the input is free once read.
|
||||||
|
const target = last
|
||||||
|
? this.renderToScreen
|
||||||
|
? null
|
||||||
|
: outputBuffer
|
||||||
|
: source === inputBuffer
|
||||||
|
? this.scratchLike(inputBuffer)
|
||||||
|
: inputBuffer;
|
||||||
|
const material = this.material(name);
|
||||||
|
const uniforms = material.uniforms;
|
||||||
|
uniforms.colorTexture.value = source.texture;
|
||||||
|
uniforms.colorTextureDimensions.value.set(source.width, source.height);
|
||||||
|
uniforms.intensity.value = state.intensity;
|
||||||
|
for (const [key, value] of Object.entries(state.uniforms))
|
||||||
|
if (uniforms[key]) uniforms[key].value = value;
|
||||||
|
// To the screen the final copy would have encoded: keep display RGB.
|
||||||
|
const display =
|
||||||
|
target === null && renderer.outputColorSpace === SRGBColorSpace ? 1 : 0;
|
||||||
|
if (material.defines.GEV_DISPLAY_OUTPUT !== display) {
|
||||||
|
material.defines.GEV_DISPLAY_OUTPUT = display;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
this.fullscreenMaterial = material;
|
||||||
|
renderer.setRenderTarget(target);
|
||||||
|
renderer.render(this.scene, this.camera);
|
||||||
|
source = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const material of this.materials.values()) material.dispose();
|
||||||
|
this.materials.clear();
|
||||||
|
this.scratch?.dispose();
|
||||||
|
this.scratch = null;
|
||||||
|
super.dispose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The styles port createNavaraEngine takes: `names` (the styles it draws),
|
||||||
|
* `set(name, state)` shows a style's state ({ intensity, uniforms }, already
|
||||||
|
* checked). The pass is added the first time a style shows, so a view that
|
||||||
|
* never filters has none; with every style at 0 it is hidden (skipped).
|
||||||
|
* `onStarsDimmed(amount)` hears Anime's intensity: its bands turn the faint
|
||||||
|
* starfield into blotches, so the stars fade with the style (Cesium fades
|
||||||
|
* its Milky Way the same way).
|
||||||
|
* The caller registers the effect (stylePasses.js createNavaraStyles).
|
||||||
|
* @param {object} view an initialised ThreeView
|
||||||
|
* @param {{ shaders: Record<string, { name: string, uniforms?: object, fragmentShader: string }>, onStarsDimmed?: (amount: number) => void }} options
|
||||||
|
*/
|
||||||
|
export function createStylesPort(view, { shaders, onStarsDimmed = null }) {
|
||||||
|
const order = Object.keys(shaders);
|
||||||
|
for (const name of order)
|
||||||
|
navaraStyleFragmentShader(shaders[name].fragmentShader);
|
||||||
|
const states = new Map();
|
||||||
|
const controller = {
|
||||||
|
shaders,
|
||||||
|
visible: () => visibleStyles(order, states),
|
||||||
|
};
|
||||||
|
let handle = null;
|
||||||
|
let shown = false;
|
||||||
|
return {
|
||||||
|
names: Object.freeze([...order]),
|
||||||
|
uniformNames: (name) => styleUniformNames(shaders[name]),
|
||||||
|
defaults: (name) => styleUniformDefaults(shaders[name]),
|
||||||
|
set(name, state) {
|
||||||
|
states.set(name, {
|
||||||
|
intensity: state.intensity,
|
||||||
|
uniforms: { ...state.uniforms },
|
||||||
|
});
|
||||||
|
if (name === 'anime') onStarsDimmed?.(state.intensity);
|
||||||
|
const next = controller.visible().length > 0;
|
||||||
|
if (!handle) {
|
||||||
|
if (!next) return;
|
||||||
|
handle = view.addEffect({
|
||||||
|
[STYLES_EFFECT_KEY]: { controller },
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
shown = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next !== shown) {
|
||||||
|
shown = next;
|
||||||
|
handle.update({ visible: next });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Navara only (QA): whether the pass is in the pipeline and shown. */
|
||||||
|
passShown: () => Boolean(handle) && shown,
|
||||||
|
controller,
|
||||||
|
destroy() {
|
||||||
|
handle?.delete();
|
||||||
|
handle = null;
|
||||||
|
shown = false;
|
||||||
|
if (states.get('anime')?.intensity) onStarsDimmed?.(0);
|
||||||
|
states.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { HalfFloatType, SRGBColorSpace, Texture } from 'three';
|
||||||
|
import { STYLES } from '../../ui/visualPresets.js';
|
||||||
|
import {
|
||||||
|
GevStylesPass,
|
||||||
|
createStylesPort,
|
||||||
|
navaraStyleFragmentShader,
|
||||||
|
styleUniformDefaults,
|
||||||
|
styleUniformNames,
|
||||||
|
visibleStyles,
|
||||||
|
} from './stylePass.js';
|
||||||
|
|
||||||
|
const buffer = (name, width = 640, height = 400) => ({
|
||||||
|
name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
texture: Object.assign(new Texture(), { type: HalfFloatType, name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every app style compiles into the Navara prelude with Cesium names mapped', () => {
|
||||||
|
assert.deepEqual(Object.keys(STYLES), [
|
||||||
|
'retro',
|
||||||
|
'surveillance',
|
||||||
|
'thermal',
|
||||||
|
'anime',
|
||||||
|
'noir',
|
||||||
|
]);
|
||||||
|
for (const [name, shader] of Object.entries(STYLES)) {
|
||||||
|
const source = navaraStyleFragmentShader(shader.fragmentShader);
|
||||||
|
// One real main(): the prelude's, calling the style's renamed one.
|
||||||
|
assert.equal(source.match(/void\s+main\s*\(\s*\)/g).length, 1, name);
|
||||||
|
assert.equal(source.match(/void\s+gevStyleMain\s*\(\s*\)/g).length, 1);
|
||||||
|
assert.match(source, /gevStyleMain\(\);/);
|
||||||
|
// Every read of the input is the encoding wrapper's; the only bare
|
||||||
|
// texture() is the wrapper's own.
|
||||||
|
const reads = shader.fragmentShader.match(/\btexture\s*\(/g)?.length ?? 0;
|
||||||
|
assert.ok(reads > 0, `${name} reads its input`);
|
||||||
|
assert.equal(
|
||||||
|
source.match(/\bgevStyleTexture\s*\(/g).length,
|
||||||
|
reads + 1,
|
||||||
|
`${name}: every texture() goes through the encode`,
|
||||||
|
);
|
||||||
|
assert.equal(source.match(/\btexture\s*\(/g).length, 1, name);
|
||||||
|
// Cesium's output name is a plain global the prelude resolves; the
|
||||||
|
// decode is the inverse of the encode (sharpen.js's exact transfer).
|
||||||
|
assert.match(source, /^vec4 out_FragColor;$/m);
|
||||||
|
assert.match(source, /out vec4 gevFragColor;/);
|
||||||
|
assert.match(source, /gevDecodeSrgb\(display\)/);
|
||||||
|
assert.match(source, /1\.0\/2\.4/);
|
||||||
|
assert.match(source, /vec3\(2\.4\)/);
|
||||||
|
assert.match(source, /#if GEV_DISPLAY_OUTPUT/);
|
||||||
|
assert.doesNotMatch(source, /czm_/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the prelude refuses shaders it cannot map', () => {
|
||||||
|
assert.throws(() => navaraStyleFragmentShader('void foo() {}'), TypeError);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
navaraStyleFragmentShader(
|
||||||
|
'void main() { out_FragColor = czm_gammaCorrect(vec4(1.0)); }',
|
||||||
|
),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => navaraStyleFragmentShader('void main() {} void main() {}'),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style uniforms: the shader parameters, plus time for an animated one', () => {
|
||||||
|
assert.deepEqual(styleUniformNames(STYLES.retro), [
|
||||||
|
'pixelation',
|
||||||
|
'distortion',
|
||||||
|
'instability',
|
||||||
|
'time',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(styleUniformNames(STYLES.noir), [
|
||||||
|
'contrastAmt',
|
||||||
|
'grainAmt',
|
||||||
|
'vignetteAmt',
|
||||||
|
]);
|
||||||
|
assert.ok(styleUniformNames(STYLES.thermal).includes('time'));
|
||||||
|
assert.ok(styleUniformNames(STYLES.surveillance).includes('time'));
|
||||||
|
assert.ok(!styleUniformNames(STYLES.anime).includes('time'));
|
||||||
|
assert.deepEqual(styleUniformDefaults(STYLES.thermal), {
|
||||||
|
sensitivity: 0.75,
|
||||||
|
bloom: 0.65,
|
||||||
|
mode: 0,
|
||||||
|
pixelation: 1.5,
|
||||||
|
palette: 0,
|
||||||
|
time: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visible styles keep the stage order and skip faded ones', () => {
|
||||||
|
const states = new Map([
|
||||||
|
['noir', { intensity: 0.6, uniforms: {} }],
|
||||||
|
['retro', { intensity: 0.4, uniforms: {} }],
|
||||||
|
['anime', { intensity: 0.0005, uniforms: {} }],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
visibleStyles(Object.keys(STYLES), states).map(([name]) => name),
|
||||||
|
['retro', 'noir'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(visibleStyles(Object.keys(STYLES), new Map()), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderer() {
|
||||||
|
const draws = [];
|
||||||
|
let target;
|
||||||
|
return {
|
||||||
|
draws,
|
||||||
|
outputColorSpace: SRGBColorSpace,
|
||||||
|
setRenderTarget(next) {
|
||||||
|
target = next;
|
||||||
|
},
|
||||||
|
render(scene) {
|
||||||
|
const material = scene.children[0].material;
|
||||||
|
draws.push({
|
||||||
|
target: target === null ? null : target.name || 'scratch',
|
||||||
|
material: material.name,
|
||||||
|
input: material.uniforms.colorTexture.value?.name || 'scratch',
|
||||||
|
dimensions: material.uniforms.colorTextureDimensions.value.toArray(),
|
||||||
|
intensity: material.uniforms.intensity.value,
|
||||||
|
uniforms: Object.fromEntries(
|
||||||
|
Object.entries(material.uniforms)
|
||||||
|
.filter(([key]) => !key.startsWith('color'))
|
||||||
|
.map(([key, { value }]) => [key, value]),
|
||||||
|
),
|
||||||
|
display: material.defines.GEV_DISPLAY_OUTPUT,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the pass maps a style state onto its material and draws input to output', () => {
|
||||||
|
const states = new Map();
|
||||||
|
const pass = new GevStylesPass({
|
||||||
|
shaders: STYLES,
|
||||||
|
visible: () => visibleStyles(Object.keys(STYLES), states),
|
||||||
|
});
|
||||||
|
const gl = renderer();
|
||||||
|
const input = buffer('input');
|
||||||
|
const output = buffer('output');
|
||||||
|
pass.render(gl, input, output);
|
||||||
|
assert.equal(gl.draws.length, 0);
|
||||||
|
assert.equal(pass.needsSwap, false, 'nothing drawn keeps the input');
|
||||||
|
|
||||||
|
states.set('thermal', {
|
||||||
|
intensity: 0.75,
|
||||||
|
uniforms: { palette: 1, time: 12.5 },
|
||||||
|
});
|
||||||
|
pass.render(gl, input, output);
|
||||||
|
assert.equal(pass.needsSwap, true);
|
||||||
|
assert.equal(gl.draws.length, 1);
|
||||||
|
const [draw] = gl.draws;
|
||||||
|
assert.equal(draw.material, 'GevStyle_thermal');
|
||||||
|
assert.equal(draw.target, 'output');
|
||||||
|
assert.equal(draw.input, 'input');
|
||||||
|
assert.deepEqual(draw.dimensions, [640, 400]);
|
||||||
|
assert.equal(draw.intensity, 0.75);
|
||||||
|
assert.equal(draw.uniforms.palette, 1);
|
||||||
|
assert.equal(draw.uniforms.time, 12.5);
|
||||||
|
// Defaults for what the state does not name.
|
||||||
|
assert.equal(draw.uniforms.sensitivity, 0.75);
|
||||||
|
assert.equal(draw.display, 0);
|
||||||
|
pass.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a crossfade draws both styles in stage order through one scratch target', () => {
|
||||||
|
const states = new Map([
|
||||||
|
['noir', { intensity: 0.3, uniforms: {} }],
|
||||||
|
['surveillance', { intensity: 0.7, uniforms: { time: 3 } }],
|
||||||
|
['retro', { intensity: 0.2, uniforms: { pixelation: 2 } }],
|
||||||
|
]);
|
||||||
|
const pass = new GevStylesPass({
|
||||||
|
shaders: STYLES,
|
||||||
|
visible: () => visibleStyles(Object.keys(STYLES), states),
|
||||||
|
});
|
||||||
|
const gl = renderer();
|
||||||
|
pass.render(gl, buffer('input'), buffer('output'));
|
||||||
|
assert.deepEqual(
|
||||||
|
gl.draws.map(({ material, input, target }) => [material, input, target]),
|
||||||
|
[
|
||||||
|
['GevStyle_retro', 'input', 'scratch'],
|
||||||
|
['GevStyle_surveillance', 'scratch', 'input'],
|
||||||
|
['GevStyle_noir', 'input', 'output'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(pass.scratch.width, 640);
|
||||||
|
assert.equal(pass.scratch.texture.type, HalfFloatType);
|
||||||
|
pass.dispose();
|
||||||
|
assert.equal(pass.scratch, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drawn to the screen, the pass keeps display RGB', () => {
|
||||||
|
const states = new Map([['anime', { intensity: 1, uniforms: {} }]]);
|
||||||
|
const pass = new GevStylesPass({
|
||||||
|
shaders: STYLES,
|
||||||
|
visible: () => visibleStyles(Object.keys(STYLES), states),
|
||||||
|
});
|
||||||
|
pass.renderToScreen = true;
|
||||||
|
const gl = renderer();
|
||||||
|
pass.render(gl, buffer('input'), buffer('output'));
|
||||||
|
assert.equal(gl.draws[0].target, null);
|
||||||
|
assert.equal(gl.draws[0].display, 1);
|
||||||
|
pass.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeView() {
|
||||||
|
const view = { added: [], updates: [], deleted: 0 };
|
||||||
|
view.addEffect = (config) => {
|
||||||
|
const handle = {
|
||||||
|
config,
|
||||||
|
update(next) {
|
||||||
|
view.updates.push(next);
|
||||||
|
},
|
||||||
|
delete() {
|
||||||
|
view.deleted++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
view.added.push(handle);
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the styles port adds the pass once, hides it at 0 and fades the stars with Anime', () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const dimmed = [];
|
||||||
|
const port = createStylesPort(view, {
|
||||||
|
shaders: STYLES,
|
||||||
|
onStarsDimmed: (amount) => dimmed.push(amount),
|
||||||
|
});
|
||||||
|
assert.deepEqual(port.names, Object.keys(STYLES));
|
||||||
|
assert.deepEqual(port.uniformNames('noir'), styleUniformNames(STYLES.noir));
|
||||||
|
assert.deepEqual(port.defaults('retro'), styleUniformDefaults(STYLES.retro));
|
||||||
|
port.set('noir', { intensity: 0, uniforms: {} });
|
||||||
|
assert.equal(view.added.length, 0, 'a view that never filters has no pass');
|
||||||
|
port.set('noir', { intensity: 1, uniforms: { grainAmt: 0.2 } });
|
||||||
|
assert.equal(view.added.length, 1);
|
||||||
|
assert.equal(port.passShown(), true);
|
||||||
|
const { controller } = view.added[0].config.gevStyles;
|
||||||
|
assert.deepEqual(controller.visible(), [
|
||||||
|
['noir', { intensity: 1, uniforms: { grainAmt: 0.2 } }],
|
||||||
|
]);
|
||||||
|
port.set('anime', { intensity: 0.5, uniforms: {} });
|
||||||
|
port.set('noir', { intensity: 0, uniforms: {} });
|
||||||
|
port.set('anime', { intensity: 0, uniforms: {} });
|
||||||
|
assert.deepEqual(view.updates, [{ visible: false }]);
|
||||||
|
assert.equal(port.passShown(), false);
|
||||||
|
port.set('retro', { intensity: 1, uniforms: {} });
|
||||||
|
assert.deepEqual(view.updates, [{ visible: false }, { visible: true }]);
|
||||||
|
assert.equal(view.added.length, 1, 'shown again, not added again');
|
||||||
|
assert.deepEqual(dimmed, [0.5, 0]);
|
||||||
|
port.set('anime', { intensity: 0.9, uniforms: {} });
|
||||||
|
port.destroy();
|
||||||
|
assert.equal(view.deleted, 1);
|
||||||
|
assert.deepEqual(dimmed, [0.5, 0, 0.9, 0], 'destroy brings the stars back');
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// The Navara effect of the app's visual styles (stylePass.js): one
|
||||||
|
// EffectDesc whose pass draws every style that shows, and the styles port
|
||||||
|
// createNavaraEngine takes. Kept apart from stylePass.js so the shaders,
|
||||||
|
// the pass and the port run in Node tests (Navara itself does not load
|
||||||
|
// there).
|
||||||
|
|
||||||
|
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
|
||||||
|
import { pipelinePassNames } from './internals.js';
|
||||||
|
import {
|
||||||
|
GevStylesPass,
|
||||||
|
STYLES_EFFECT_KEY,
|
||||||
|
createStylesPort,
|
||||||
|
} from './stylePass.js';
|
||||||
|
|
||||||
|
class StylesEffectDesc extends EffectDesc {
|
||||||
|
static key = STYLES_EFFECT_KEY;
|
||||||
|
// Last before Navara's final copy: after bloom when it is on, else after
|
||||||
|
// everything the scene and its overlays draw (nothing anchors after it).
|
||||||
|
static insertAfter = ['gevBloom'];
|
||||||
|
static insertBefore = ['final'];
|
||||||
|
|
||||||
|
constructor(view, ctx, config) {
|
||||||
|
super(view, ctx, config);
|
||||||
|
this.controller = config[STYLES_EFFECT_KEY].controller;
|
||||||
|
// For the diagnostics below.
|
||||||
|
this.controller.ctx = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPass() {
|
||||||
|
// Navara inserts a bare pass only when it comes from its own
|
||||||
|
// postprocessing copy: wrap it (as overlay.js does).
|
||||||
|
return new NavaraPass(new GevStylesPass(this.controller), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
const pass = this.raw?.rawPass;
|
||||||
|
try {
|
||||||
|
super.onDestroy();
|
||||||
|
} finally {
|
||||||
|
pass?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createStylesPort (stylePass.js) on a view with the styles effect
|
||||||
|
* registered.
|
||||||
|
* @param {object} view an initialised ThreeView
|
||||||
|
* @param {Parameters<typeof createStylesPort>[1]} options
|
||||||
|
*/
|
||||||
|
export function createNavaraStyles(view, options) {
|
||||||
|
view.registerEffect(STYLES_EFFECT_KEY, StylesEffectDesc);
|
||||||
|
const port = createStylesPort(view, options);
|
||||||
|
return {
|
||||||
|
...port,
|
||||||
|
/** Navara only (QA): whether the pass shows, and the pipeline order. */
|
||||||
|
diagnostics: () => ({
|
||||||
|
shown: port.passShown(),
|
||||||
|
passes: pipelinePassNames(port.controller.ctx),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,7 +10,12 @@
|
|||||||
// Each check sets the view it needs; checks may leave the camera anywhere.
|
// Each check sets the view it needs; checks may leave the camera anywhere.
|
||||||
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { INPUT_TYPES, MODIFIERS, assertGlobeEngine } from '../contract.js';
|
import {
|
||||||
|
INPUT_TYPES,
|
||||||
|
MODIFIERS,
|
||||||
|
assertGlobeEngine,
|
||||||
|
engineCapabilities,
|
||||||
|
} from '../contract.js';
|
||||||
|
|
||||||
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
||||||
const WGS84 = ellipsoid.WGS84;
|
const WGS84 = ellipsoid.WGS84;
|
||||||
@@ -2763,9 +2768,97 @@ export function createContractSuite(
|
|||||||
];
|
];
|
||||||
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
|
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
|
||||||
checks.push(...mapChecks(engine.maps, mapSources));
|
checks.push(...mapChecks(engine.maps, mapSources));
|
||||||
|
const capabilities = engineCapabilities(engine);
|
||||||
|
if (capabilities.visualStyles)
|
||||||
|
checks.push(...styleChecks(engine.render, settle));
|
||||||
return checks.map(([name, run]) => ({ name, run }));
|
return checks.map(([name, run]) => ({ name, run }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The optional visualStyles capability (render.setStyle).
|
||||||
|
function styleChecks(render, settle) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'render.setStyle (visualStyles): merges, copies, rejects bad values, draws',
|
||||||
|
async () => {
|
||||||
|
const names = render.visualStyles;
|
||||||
|
ensure(
|
||||||
|
names.length > 0 && new Set(names).size === names.length,
|
||||||
|
`visualStyles ${JSON.stringify(names)} is not a list of names`,
|
||||||
|
);
|
||||||
|
const [name] = names;
|
||||||
|
const previous = render.style(name);
|
||||||
|
ensure(
|
||||||
|
previous.intensity === 0,
|
||||||
|
`${name} starts at intensity ${previous.intensity}`,
|
||||||
|
);
|
||||||
|
const [uniform] = Object.keys(previous.uniforms);
|
||||||
|
try {
|
||||||
|
render.setStyle(name, { intensity: 1 });
|
||||||
|
ensure(render.style(name).intensity === 1, 'intensity not set');
|
||||||
|
// A frame with the style on draws without errors.
|
||||||
|
await settle(2);
|
||||||
|
if (uniform) {
|
||||||
|
render.setStyle(name, { uniforms: { [uniform]: 0.25 } });
|
||||||
|
const state = render.style(name);
|
||||||
|
ensure(
|
||||||
|
state.intensity === 1 && state.uniforms[uniform] === 0.25,
|
||||||
|
`after a uniform alone: ${JSON.stringify(state)}`,
|
||||||
|
);
|
||||||
|
state.uniforms[uniform] = 9;
|
||||||
|
ensure(
|
||||||
|
render.style(name).uniforms[uniform] === 0.25,
|
||||||
|
'style() is a copy',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render.setStyle(name, { intensity: 0.5 });
|
||||||
|
ensure(render.style(name).intensity === 0.5, 'crossfade step');
|
||||||
|
for (const [label, call] of [
|
||||||
|
['unknown style', () => render.setStyle('no-such-style', {})],
|
||||||
|
['intensity 2', () => render.setStyle(name, { intensity: 2 })],
|
||||||
|
[
|
||||||
|
'intensity -0.1',
|
||||||
|
() => render.setStyle(name, { intensity: -0.1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'unknown uniform',
|
||||||
|
() => render.setStyle(name, { uniforms: { noSuchUniform: 1 } }),
|
||||||
|
],
|
||||||
|
...(uniform
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
'NaN uniform',
|
||||||
|
() =>
|
||||||
|
render.setStyle(name, {
|
||||||
|
uniforms: { [uniform]: Number.NaN },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]) {
|
||||||
|
let error = null;
|
||||||
|
try {
|
||||||
|
call();
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
ensure(
|
||||||
|
error instanceof TypeError,
|
||||||
|
`setStyle with ${label} did not throw a TypeError`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ensure(
|
||||||
|
render.style(name).intensity === 0.5,
|
||||||
|
'a rejected call changed the style',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
render.setStyle(name, previous);
|
||||||
|
await settle(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// The maps switch order, observed through maps.current().
|
// The maps switch order, observed through maps.current().
|
||||||
function mapChecks(maps, { imagery: [first, second], photoreal }) {
|
function mapChecks(maps, { imagery: [first, second], photoreal }) {
|
||||||
const shows = (baseImagery, tiles, label) => {
|
const shows = (baseImagery, tiles, label) => {
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
MODIFIERS,
|
MODIFIERS,
|
||||||
normalizeInputOptions,
|
normalizeInputOptions,
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
|
STYLE_INITIAL,
|
||||||
createEngineEvent,
|
createEngineEvent,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
|
mergeStyle,
|
||||||
} from '../contract.js';
|
} from '../contract.js';
|
||||||
import { createAppClock } from '../../time/clock.js';
|
import { createAppClock } from '../../time/clock.js';
|
||||||
import { createLineRegistry } from '../lines.js';
|
import { createLineRegistry } from '../lines.js';
|
||||||
@@ -227,17 +229,50 @@ function rectangleFromCartographics(points) {
|
|||||||
return { west, south, east, north };
|
return { west, south, east, north };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// render.visualStyles/setStyle/style for `visualStyles` ({ name: uniform
|
||||||
|
// names }); nothing without it.
|
||||||
|
function fakeStyles(visualStyles, record) {
|
||||||
|
if (!visualStyles) return {};
|
||||||
|
const states = new Map(
|
||||||
|
Object.entries(visualStyles).map(([name, uniforms]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
...STYLE_INITIAL,
|
||||||
|
uniforms: Object.fromEntries(uniforms.map((uniform) => [uniform, 0])),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const known = (name) => {
|
||||||
|
if (!states.has(name)) throw new TypeError(`Unknown visual style: ${name}`);
|
||||||
|
return states.get(name);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
visualStyles: Object.freeze(Object.keys(visualStyles)),
|
||||||
|
setStyle(name, options) {
|
||||||
|
record('render.setStyle', [name, options]);
|
||||||
|
states.set(name, mergeStyle(known(name), options, visualStyles[name]));
|
||||||
|
},
|
||||||
|
style(name) {
|
||||||
|
const { intensity, uniforms } = known(name);
|
||||||
|
return { intensity, uniforms: { ...uniforms } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ kind?: 'cesium'|'navara', width?: number, height?: number, fovy?: number,
|
* @param {{ kind?: 'cesium'|'navara', width?: number, height?: number, fovy?: number,
|
||||||
* longitude?: number, latitude?: number, altitude?: number,
|
* longitude?: number, latitude?: number, altitude?: number,
|
||||||
* container?: object, canvas?: object, nowMs?: number,
|
* container?: object, canvas?: object, nowMs?: number,
|
||||||
* appClock?: import('../../time/clock.js').AppClock }} [options]
|
* appClock?: import('../../time/clock.js').AppClock,
|
||||||
|
* visualStyles?: Record<string, string[]> }} [options]
|
||||||
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
|
* 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
|
* 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
|
* down from `altitude` metres (10 000 km). The engine clock reads
|
||||||
* `appClock`, by default an app clock on a fake wall clock that starts at
|
* `appClock`, by default an app clock on a fake wall clock that starts at
|
||||||
* `nowMs` (2026-01-01T00:00Z) and moves only with `fake.frame(dt)` and
|
* `nowMs` (2026-01-01T00:00Z) and moves only with `fake.frame(dt)` and
|
||||||
* `fake.advanceTime(ms)` (a given appClock keeps its own wall clock).
|
* `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.
|
||||||
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
||||||
*/
|
*/
|
||||||
export function createFakeEngine(options = {}) {
|
export function createFakeEngine(options = {}) {
|
||||||
@@ -923,6 +958,7 @@ export function createFakeEngine(options = {}) {
|
|||||||
sharpen = mergeSharpen(sharpen, options);
|
sharpen = mergeSharpen(sharpen, options);
|
||||||
},
|
},
|
||||||
sharpen: () => ({ ...sharpen }),
|
sharpen: () => ({ ...sharpen }),
|
||||||
|
...fakeStyles(options.visualStyles, record),
|
||||||
},
|
},
|
||||||
surface: {
|
surface: {
|
||||||
sampleHeight(position, exclude) {
|
sampleHeight(position, exclude) {
|
||||||
|
|||||||
+57
-5
@@ -1,4 +1,5 @@
|
|||||||
import { PostProcessStage } from 'cesium';
|
import { PostProcessStage } from 'cesium';
|
||||||
|
import { engineCapabilities } from '../globe/contract.js';
|
||||||
import {
|
import {
|
||||||
bloomStrengthFromIntensity,
|
bloomStrengthFromIntensity,
|
||||||
clampBloomIntensity,
|
clampBloomIntensity,
|
||||||
@@ -11,6 +12,42 @@ import {
|
|||||||
sharpenAmount,
|
sharpenAmount,
|
||||||
} from './visualPresets.js';
|
} from './visualPresets.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A style stage drawn by the globe engine (render.setStyle) instead of a
|
||||||
|
* Cesium post stage: the same { uniforms, enabled } shape, so the owner of
|
||||||
|
* the stages writes its uniforms as it writes a Cesium stage's; each write
|
||||||
|
* reaches the engine. A value the engine refuses (NaN from an old link,
|
||||||
|
* say) stays here and is reported, as a Cesium uniform would just draw it.
|
||||||
|
*/
|
||||||
|
export function engineStyleStage(render, name, shader) {
|
||||||
|
const values = { intensity: 0 };
|
||||||
|
if (shader.fragmentShader.includes('uniform float time')) values.time = 0;
|
||||||
|
for (const [uniform, meta] of Object.entries(shader.uniforms || {}))
|
||||||
|
values[uniform] = meta.default;
|
||||||
|
const { intensity, ...uniforms } = values;
|
||||||
|
render.setStyle(name, { intensity, uniforms });
|
||||||
|
const forward = (key, value) => {
|
||||||
|
try {
|
||||||
|
if (key === 'intensity') render.setStyle(name, { intensity: value });
|
||||||
|
else render.setStyle(name, { uniforms: { [key]: value } });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`The ${name} style refused ${key}=${value}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: `godsEyeView_${name}`,
|
||||||
|
enabled: false,
|
||||||
|
uniforms: new Proxy(values, {
|
||||||
|
set(target, key, value) {
|
||||||
|
target[key] = value;
|
||||||
|
if (typeof key === 'string' && Object.hasOwn(values, key))
|
||||||
|
forward(key, value);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Own the post-process stages and their animation, without DOM dependencies. */
|
/** Own the post-process stages and their animation, without DOM dependencies. */
|
||||||
export class VisualEffects {
|
export class VisualEffects {
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +74,10 @@ export class VisualEffects {
|
|||||||
// only); an engine without a viewer (Navara) sharpens in its pipeline.
|
// only); an engine without a viewer (Navara) sharpens in its pipeline.
|
||||||
this.engineRender =
|
this.engineRender =
|
||||||
typeof engine?.render?.setSharpen === 'function' ? engine.render : null;
|
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).
|
||||||
|
this.engineStyles =
|
||||||
|
!viewer && engineCapabilities(engine).visualStyles ? engine.render : null;
|
||||||
this.requestRender = requestRender;
|
this.requestRender = requestRender;
|
||||||
this.holdRender = holdRender;
|
this.holdRender = holdRender;
|
||||||
this.releaseRender = releaseRender;
|
this.releaseRender = releaseRender;
|
||||||
@@ -61,11 +102,21 @@ export class VisualEffects {
|
|||||||
this.previousBloom = null;
|
this.previousBloom = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-process stages are Cesium's: without a viewer (the Navara engine)
|
// Post-process stages are Cesium's. Without a viewer (the Navara engine)
|
||||||
// there are none, and the style and bloom controls change nothing (the
|
// an engine that draws the styles gets stand-in stages (engineStyleStage),
|
||||||
// engine sharpens, see engineRender).
|
// which start every style at 0 on it; otherwise there are none and the
|
||||||
|
// style controls change nothing (the engine sharpens, see engineRender).
|
||||||
initStyles() {
|
initStyles() {
|
||||||
if (!this.viewer || this.stopped || this.stageEntries.length) return;
|
if (this.stopped || this.stageEntries.length) return;
|
||||||
|
if (!this.viewer) {
|
||||||
|
if (!this.engineStyles) return;
|
||||||
|
const drawn = this.engineStyles.visualStyles;
|
||||||
|
for (const [name, shader] of Object.entries(STYLES))
|
||||||
|
if (drawn.includes(name))
|
||||||
|
this.stages[name] = engineStyleStage(this.engineStyles, name, shader);
|
||||||
|
this.stageEntries = Object.entries(this.stages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const [name, shader] of Object.entries(STYLES)) {
|
for (const [name, shader] of Object.entries(STYLES)) {
|
||||||
const uniforms = { intensity: 0.0 };
|
const uniforms = { intensity: 0.0 };
|
||||||
if (shader.fragmentShader.includes('uniform float time'))
|
if (shader.fragmentShader.includes('uniform float time'))
|
||||||
@@ -127,9 +178,10 @@ export class VisualEffects {
|
|||||||
stage.enabled = value > 0.001;
|
stage.enabled = value > 0.001;
|
||||||
// Anime's bands turn the faint Milky Way into blotches, and depth cannot
|
// Anime's bands turn the faint Milky Way into blotches, and depth cannot
|
||||||
// tell the sky from far geometry, so the glow fades with the style.
|
// tell the sky from far geometry, so the glow fades with the style.
|
||||||
|
// The engine fades its own stars with Anime (render.setStyle).
|
||||||
if (
|
if (
|
||||||
stage === this.stages.anime &&
|
stage === this.stages.anime &&
|
||||||
this.viewer.scene.skyBox?.milkyWayVisibility !== undefined
|
this.viewer?.scene.skyBox?.milkyWayVisibility !== undefined
|
||||||
)
|
)
|
||||||
this.viewer.scene.skyBox.milkyWayVisibility = 1 - value;
|
this.viewer.scene.skyBox.milkyWayVisibility = 1 - value;
|
||||||
if (stage.enabled && stage.uniforms.time !== undefined)
|
if (stage.enabled && stage.uniforms.time !== undefined)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { VisualEffects } from './visualEffects.js';
|
import { VisualEffects } from './visualEffects.js';
|
||||||
|
import { STYLES } from './visualPresets.js';
|
||||||
|
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||||
import { MILKY_WAY_INTENSITY, StarrySky } from '../sky/starrySky.js';
|
import { MILKY_WAY_INTENSITY, StarrySky } from '../sky/starrySky.js';
|
||||||
import {
|
import {
|
||||||
GLOBAL_POST_DEFAULTS,
|
GLOBAL_POST_DEFAULTS,
|
||||||
@@ -338,6 +340,132 @@ test('without a viewer the engine sharpens with the same amount scale', () => {
|
|||||||
effects.destroy();
|
effects.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A fake engine drawing the app's styles (render.setStyle), as Navara does.
|
||||||
|
function styledEngine() {
|
||||||
|
return createFakeEngine({
|
||||||
|
kind: 'navara',
|
||||||
|
visualStyles: Object.fromEntries(
|
||||||
|
Object.entries(STYLES).map(([name, shader]) => [
|
||||||
|
name,
|
||||||
|
[
|
||||||
|
...Object.keys(shader.uniforms),
|
||||||
|
...(shader.fragmentShader.includes('uniform float time')
|
||||||
|
? ['time']
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineFixture(engine = styledEngine()) {
|
||||||
|
let time = 0;
|
||||||
|
let next = 0;
|
||||||
|
const frames = new Map();
|
||||||
|
const holds = new Set();
|
||||||
|
const requests = [];
|
||||||
|
const effects = new VisualEffects({
|
||||||
|
viewer: null,
|
||||||
|
engine,
|
||||||
|
requestRender: (reason) => requests.push(reason),
|
||||||
|
holdRender: (reason) => holds.add(reason),
|
||||||
|
releaseRender: (reason) => holds.delete(reason),
|
||||||
|
requestFrame: (callback) => {
|
||||||
|
const id = next++;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelFrame: (id) => frames.delete(id),
|
||||||
|
now: () => time,
|
||||||
|
wallNow: () => 10_000 + time,
|
||||||
|
});
|
||||||
|
const tick = (value) => {
|
||||||
|
time = value;
|
||||||
|
const pending = [...frames.values()];
|
||||||
|
frames.clear();
|
||||||
|
for (const callback of pending) callback();
|
||||||
|
};
|
||||||
|
return { engine, effects, holds, requests, tick };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('without a viewer an engine that draws styles gets stand-in stages', () => {
|
||||||
|
const { engine, effects } = engineFixture();
|
||||||
|
effects.initStyles();
|
||||||
|
assert.deepEqual(Object.keys(effects.stages), Object.keys(STYLES));
|
||||||
|
// Every style starts at 0 with its defaults on the engine.
|
||||||
|
for (const name of Object.keys(STYLES)) {
|
||||||
|
assert.equal(engine.render.style(name).intensity, 0);
|
||||||
|
assert.equal(effects.stages[name].enabled, false);
|
||||||
|
}
|
||||||
|
assert.deepEqual(engine.render.style('thermal').uniforms, {
|
||||||
|
sensitivity: 0.75,
|
||||||
|
bloom: 0.65,
|
||||||
|
mode: 0,
|
||||||
|
pixelation: 1.5,
|
||||||
|
palette: 0,
|
||||||
|
time: 0,
|
||||||
|
});
|
||||||
|
// A uniform write reaches the engine, as a Cesium stage uniform would.
|
||||||
|
effects.stages.thermal.uniforms.palette = 1;
|
||||||
|
assert.equal(engine.render.style('thermal').uniforms.palette, 1);
|
||||||
|
assert.equal(effects.stages.thermal.uniforms.palette, 1);
|
||||||
|
// A value the engine refuses stays local and is reported, not thrown.
|
||||||
|
const warn = console.warn;
|
||||||
|
const warned = [];
|
||||||
|
console.warn = (...args) => warned.push(args);
|
||||||
|
try {
|
||||||
|
effects.stages.noir.uniforms.grainAmt = Number.NaN;
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
}
|
||||||
|
assert.equal(warned.length, 1);
|
||||||
|
assert.equal(engine.render.style('noir').uniforms.grainAmt, 0.5);
|
||||||
|
effects.initStyles();
|
||||||
|
assert.equal(Object.keys(effects.stages).length, 5, 'initialised once');
|
||||||
|
effects.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an engine crossfade eases both styles and animates time on the engine', () => {
|
||||||
|
const { engine, effects, holds, tick } = engineFixture();
|
||||||
|
effects.initStyles();
|
||||||
|
effects.startTransition('thermal', 0, 1);
|
||||||
|
tick(250);
|
||||||
|
assert.equal(engine.render.style('thermal').intensity, 0.5);
|
||||||
|
assert.equal(effects.stages.thermal.enabled, true);
|
||||||
|
assert.ok(holds.has('style-anim'));
|
||||||
|
tick(500);
|
||||||
|
assert.equal(engine.render.style('thermal').intensity, 1);
|
||||||
|
// The FLIR clock keeps running on the engine while the style shows.
|
||||||
|
tick(1500);
|
||||||
|
assert.equal(engine.render.style('thermal').uniforms.time, 1.5);
|
||||||
|
effects.startTransition('thermal', 1, 0);
|
||||||
|
effects.startTransition('noir', 0, 1);
|
||||||
|
tick(1750);
|
||||||
|
assert.equal(engine.render.style('thermal').intensity, 0.5);
|
||||||
|
assert.equal(engine.render.style('noir').intensity, 0.5);
|
||||||
|
tick(2000);
|
||||||
|
tick(2016);
|
||||||
|
assert.equal(engine.render.style('thermal').intensity, 0);
|
||||||
|
assert.equal(engine.render.style('noir').intensity, 1);
|
||||||
|
assert.equal(holds.has('style-anim'), false, 'noir does not animate');
|
||||||
|
effects.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Anime on an engine without a viewer reaches no sky box', () => {
|
||||||
|
const { engine, effects } = engineFixture();
|
||||||
|
effects.initStyles();
|
||||||
|
effects.setStageIntensity(effects.stages.anime, 0.7);
|
||||||
|
assert.equal(engine.render.style('anime').intensity, 0.7);
|
||||||
|
effects.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without the visualStyles capability an engine gets no stages', () => {
|
||||||
|
const { effects } = engineFixture(createFakeEngine({ kind: 'navara' }));
|
||||||
|
effects.initStyles();
|
||||||
|
assert.deepEqual(effects.stages, {});
|
||||||
|
effects.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('with a viewer the Cesium stage sharpens and the engine mirrors its state', () => {
|
test('with a viewer the Cesium stage sharpens and the engine mirrors its state', () => {
|
||||||
let state = { enabled: false, amount: 1 };
|
let state = { enabled: false, amount: 1 };
|
||||||
const engine = {
|
const engine = {
|
||||||
|
|||||||
Reference in new issue
Block a user