diff --git a/scripts/qa-navara-visual-styles.mjs b/scripts/qa-navara-visual-styles.mjs new file mode 100644 index 000000000..30c552ec2 --- /dev/null +++ b/scripts/qa-navara-visual-styles.mjs @@ -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:/ [--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), + ); + }, +); diff --git a/src/globe/contract.js b/src/globe/contract.js index 3376f2579..c6a111cb1 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -233,6 +233,23 @@ * state here (src/ui/visualEffects.js). * @property {() => { enabled: boolean, amount: number }} sharpen * 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 }) => 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 }} StyleState * * @typedef {object} PickResult * @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'. */ export const GLOBE_ENGINE_MEMBERS = Object.freeze([ 'kind', @@ -671,6 +738,13 @@ export function assertGlobeEngine(engine) { for (const [member, valid] of Object.entries(members)) 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.isDestroyed)) problems.push('isDestroyed'); if (problems.length) @@ -699,3 +773,37 @@ export function mergeSharpen(current, { enabled, 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 }; +} diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index a8215d4a4..f7642d099 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -5,11 +5,15 @@ import { GLOBE_ENGINE_MEMBERS, INPUT_TYPES, MODIFIERS, + ENGINE_CAPABILITIES, + OPTIONAL_ENGINE_MEMBERS, SHARPEN_INITIAL, assertGlobeEngine, createEngineEvent, + engineCapabilities, isEngineEvent, mergeSharpen, + mergeStyle, } from './contract.js'; import { createFakeEngine } from './testing/fakeEngine.js'; import { @@ -376,3 +380,72 @@ test('the contract carries the follow camera', () => { const broken = { ...engine, camera: { ...engine.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))); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index a6442923b..350dcb1d6 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -9,8 +9,10 @@ import * as geo from '../../geo/index.js'; import { SHARPEN_INITIAL, + STYLE_INITIAL, createEngineEvent, mergeSharpen, + mergeStyle, } from '../contract.js'; import { cesiumFovy } from './cameraMath.js'; import { createCameraModel } from './cameraModel.js'; @@ -88,9 +90,12 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * clock?: object, * appClock?: import('../../time/clock.js').AppClock, * sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void }, + * styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record, set: (name: string, state: { intensity: number, uniforms: Record }) => void, destroy: () => void }, * }} options `hooks` are internals.js; `container` defaults to the * canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without * it the engine only keeps the state) and the engine destroys it; + * `styles` draws render.setStyle (stylePasses.js; without it the engine + * has no visualStyles capability) and the engine destroys it; * `appClock` (the engine clock's time) defaults to the page's app clock; * `movers` is engine.movers on the view's three runtime (movers/index.js; * without it createSet throws), `lines` engine.lines (lines/index.js; @@ -109,6 +114,7 @@ export function createNavaraEngine( movers: moversPort = null, lines: linesPort = null, sharpen: sharpenPort = null, + styles: stylesPort = null, } = {}, ) { if (!view?.camera?.raw) @@ -263,6 +269,47 @@ export function createNavaraEngine( let sharpen = { ...SHARPEN_INITIAL }; 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 { kind: 'navara', host: { container: container ?? canvas.parentElement, canvas, size }, @@ -291,6 +338,7 @@ export function createNavaraEngine( view.forceUpdate(); }, sharpen: () => ({ ...sharpen }), + ...styleRender, }, surface, input: input.input, @@ -311,10 +359,14 @@ export function createNavaraEngine( credits.destroy(); maps.destroy(); sharpenPort?.destroy(); + stylesPort?.destroy(); moversPort?.destroy(); linesPort?.destroy(); }, isDestroyed: () => destroyed, + // Navara only, not contract: the post pipeline as QA reads it (the + // passes in draw order, whether the styles pass shows). + pipeline: () => stylesPort?.diagnostics?.() ?? null, // Navara only, not contract: the ground the floor last sampled under // the camera and the margin it keeps above it (QA measures against it). groundFloor: { diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 2dc649b60..714bc2d37 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; 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 { 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 }); }); +// 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", () => { const { engine, view } = setup(); near(engine.camera.pose().cartographic.height, 1e7, 1e-3, 'height'); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index d5d6c85d1..8f0b86b30 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -22,6 +22,12 @@ import { } from './internals.js'; import { buildNavaraLook } from './look.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 { createNavaraLines } from './lines/index.js'; import { createNavaraOverlay } from './overlay.js'; @@ -51,6 +57,16 @@ function preservedCanvas(container) { 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 // allocation): degrees for the sun's elevation, and the height. const lookCarto = { longitude: 0, latitude: 0, height: 0 }; @@ -141,6 +157,10 @@ export async function createNavaraGlobe({ container, ...(appClock ? { appClock } : {}), sharpen: createNavaraSharpen(view), + styles: createNavaraStyles(view, { + shaders: STYLE_SHADERS, + onStarsDimmed: (amount) => look.setStarsDimmed(amount), + }), movers: createNavaraMovers(view, { overlay, // Model colour follows the display-space stage, after the look's ACES. diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index 8fd028c4f..c43878a06 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -162,3 +162,14 @@ export const navaraHooks = (view) => ({ tileRoots: () => tileRoots(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; +} diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index 67e9c0013..2ede0c8f6 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -198,15 +198,35 @@ export function buildNavaraLook( } 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 { handles, frame, resetDaylight() { 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({ aerialPerspective: Object.freeze({ irradiance: true }), }); diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index 31f75b1b5..c3964acd8 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -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 }); 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); +}); diff --git a/src/globe/navara/stylePass.js b/src/globe/navara/stylePass.js new file mode 100644 index 000000000..4076c1434 --- /dev/null +++ b/src/globe/navara/stylePass.js @@ -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, 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(); + }, + }; +} diff --git a/src/globe/navara/stylePass.test.mjs b/src/globe/navara/stylePass.test.mjs new file mode 100644 index 000000000..a987aac5b --- /dev/null +++ b/src/globe/navara/stylePass.test.mjs @@ -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'); +}); diff --git a/src/globe/navara/stylePasses.js b/src/globe/navara/stylePasses.js new file mode 100644 index 000000000..95379da53 --- /dev/null +++ b/src/globe/navara/stylePasses.js @@ -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[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), + }), + }; +} diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 2ac47f97e..041191bc4 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -10,7 +10,12 @@ // Each check sets the view it needs; checks may leave the camera anywhere. 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 WGS84 = ellipsoid.WGS84; @@ -2763,9 +2768,97 @@ export function createContractSuite( ]; if (mapSources?.photoreal && mapSources.imagery?.length >= 2) 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 })); } +// 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(). function mapChecks(maps, { imagery: [first, second], photoreal }) { const shows = (baseImagery, tiles, label) => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 8477bf438..6952f1b0f 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -23,8 +23,10 @@ import { MODIFIERS, normalizeInputOptions, SHARPEN_INITIAL, + STYLE_INITIAL, createEngineEvent, mergeSharpen, + mergeStyle, } from '../contract.js'; import { createAppClock } from '../../time/clock.js'; import { createLineRegistry } from '../lines.js'; @@ -227,17 +229,50 @@ function rectangleFromCartographics(points) { 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, * longitude?: number, latitude?: number, altitude?: number, * container?: object, canvas?: object, nowMs?: number, - * appClock?: import('../../time/clock.js').AppClock }} [options] + * appClock?: import('../../time/clock.js').AppClock, + * visualStyles?: Record }} [options] * Canvas size in CSS px, angles in radians; `container`/`canvas` stand in * for the host elements (DOM mocks for shell tests). The initial pose looks straight * down from `altitude` metres (10 000 km). The engine clock reads * `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 * `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 }} */ export function createFakeEngine(options = {}) { @@ -923,6 +958,7 @@ export function createFakeEngine(options = {}) { sharpen = mergeSharpen(sharpen, options); }, sharpen: () => ({ ...sharpen }), + ...fakeStyles(options.visualStyles, record), }, surface: { sampleHeight(position, exclude) { diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index dfdf7db77..93b949b10 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -1,4 +1,5 @@ import { PostProcessStage } from 'cesium'; +import { engineCapabilities } from '../globe/contract.js'; import { bloomStrengthFromIntensity, clampBloomIntensity, @@ -11,6 +12,42 @@ import { sharpenAmount, } 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. */ export class VisualEffects { /** @@ -37,6 +74,10 @@ export class VisualEffects { // only); an engine without a viewer (Navara) sharpens in its pipeline. this.engineRender = 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.holdRender = holdRender; this.releaseRender = releaseRender; @@ -61,11 +102,21 @@ export class VisualEffects { this.previousBloom = null; } - // Post-process stages are Cesium's: without a viewer (the Navara engine) - // there are none, and the style and bloom controls change nothing (the - // engine sharpens, see engineRender). + // Post-process stages are Cesium's. Without a viewer (the Navara engine) + // an engine that draws the styles gets stand-in stages (engineStyleStage), + // which start every style at 0 on it; otherwise there are none and the + // style controls change nothing (the engine sharpens, see engineRender). 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)) { const uniforms = { intensity: 0.0 }; if (shader.fragmentShader.includes('uniform float time')) @@ -127,9 +178,10 @@ export class VisualEffects { stage.enabled = value > 0.001; // 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. + // The engine fades its own stars with Anime (render.setStyle). if ( stage === this.stages.anime && - this.viewer.scene.skyBox?.milkyWayVisibility !== undefined + this.viewer?.scene.skyBox?.milkyWayVisibility !== undefined ) this.viewer.scene.skyBox.milkyWayVisibility = 1 - value; if (stage.enabled && stage.uniforms.time !== undefined) diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 1eb9df2cb..b01aa3e7c 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; 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 { GLOBAL_POST_DEFAULTS, @@ -338,6 +340,132 @@ test('without a viewer the engine sharpens with the same amount scale', () => { 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', () => { let state = { enabled: false, amount: 1 }; const engine = {