diff --git a/changelog.d/1555-navara-globe-engine.md b/changelog.d/1555-navara-globe-engine.md index 629873f78..f10aca8b6 100644 --- a/changelog.d/1555-navara-globe-engine.md +++ b/changelog.d/1555-navara-globe-engine.md @@ -1,5 +1,7 @@ - Add Navara, an experimental three.js/WASM globe, as a second engine next to Cesium, switchable live under Display → Globe Engine or with - `?engine=navara`. Layers keep their data across a switch and draw on both - engines; Cesium loads only when a Cesium globe or a Cesium-only feature - needs it (German, @gekh, #1555). + `?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD + (`?engine=navara-hd`) draws at the screen's full resolution and needs about + 1 GiB more on a Retina screen. Layers keep their data across a switch and + draw on both engines; Cesium loads only when a Cesium globe or a Cesium-only + feature needs it (German, @gekh, #1555). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 04d2b00b7..464537929 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -105,6 +105,24 @@ next to Cesium. It is picked under Display → Globe Engine or with `?engine=navara`; Cesium stays the default. Switching is live: the page keeps one application, and only the globe-bound parts move to the next engine. +- **Two Navara choices** (`src/app/engineChoice.js`). **Navara** draws at CSS + pixels without MSAA, as Cesium does; SMAA still smooths edges. **Navara HD** + (`?engine=navara-hd`) draws at the device pixel ratio with MSAA 4. Both are + the `navara` engine to the layers; a pick between them builds the other + variant cold and releases the first, so only one Navara holds memory. + Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and + 1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB + (Navara used 1.95 and 3.0 GiB before). +- **Memory.** The canvas context has no antialias (Navara draws into its own + targets and copies the result), the cloud shadow maps use the low preset's + 256² and 2 cascades, and the tile cache budget is the screen-sized targets + plus 768 MiB instead of Navara's default of up to 2 GiB, which a long session + filled. A released Navara keeps no JS: catalog closures capture the + operations, not the scene; a released globe aborts with a reason made at + module load (a fresh one keeps the stack, and with it the scene); and + `Pass.fullscreenGeometry` from postprocessing is disposed after the view, + which otherwise pins the canvas through a three listener. + - **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and `src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks @@ -114,7 +132,11 @@ one application, and only the globe-bound parts move to the next engine. camera mode and URL state outlive a switch. A layer that is a session model gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt, so its data is not fetched again. The outgoing globe is parked - (`src/standalone/rendererCache.js`), so switching back is warm. + (`src/standalone/rendererCache.js`), so switching back within three minutes + is warm (60-90 ms). After that the parked globe is disposed and its WebGL + context lost, which frees about 1.1 GiB for a parked Navara; switching back + then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA + (`scripts/qa-strict-parked-release.mjs`). - **Renderers per engine.** Each layer keeps its model and display policy in engine-neutral files and draws through `render/cesium*.js` or `render/navara*.js`. Cesium renderers are listed in diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index 4b1931b31..f6bb378f4 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -362,6 +362,8 @@ const runs = [ ['strict-rapid-switch', 'strict-rapid-switch', []], ['strict-hidden-switch', 'strict-hidden-switch', []], ['strict-context-loss', 'strict-context-loss', []], + ['strict-parked-release', 'strict-parked-release', []], + ['strict-navara-hd', 'strict-navara-hd', []], ['strict-failure-matrix', 'strict-failure-matrix', [], [], 600], ['strict-cold-chunks', 'strict-cold-chunks', []], ['strict-prod-smoke', 'strict-prod-smoke', []], diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs index 42cebbcdf..1fbeae9d5 100644 --- a/scripts/qa-strict-harness.mjs +++ b/scripts/qa-strict-harness.mjs @@ -29,6 +29,8 @@ import { import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs'; export const ENGINES = Object.freeze(['cesium', 'navara']); +/** What the Globe Engine picker offers: the engines and Navara HD. */ +const CHOICES = Object.freeze([...ENGINES, 'navara-hd']); /** Local ports that belong to the user's own servers: never a QA preview. */ export const RESERVED_PORTS = Object.freeze([ '', @@ -406,7 +408,7 @@ export async function pickEngine( kind, { settle = true, afterInput = null } = {}, ) { - assert.ok(ENGINES.includes(kind), kind); + assert.ok(CHOICES.includes(kind), kind); const expanded = () => page.$eval( '#control-panel-toggle', @@ -430,6 +432,21 @@ export async function pickEngine( }); const from = await page.$eval('#globe-engine', (el) => el.value); assert.notEqual(from, kind, `the picker already shows ${kind}`); + // The key typed below is `kind`'s first letter: it selects the next option + // starting with it (after `from` when `from` starts with it too). Each key + // is a change that starts a switch, so `kind` must be one key away + // (Cesium → Navara HD goes through Navara). + const values = await page.$$eval('#globe-engine option', (options) => + options.map((option) => option.value), + ); + const matches = values.filter((value) => value[0] === kind[0]); + assert.equal( + from[0] === kind[0] + ? matches[(matches.indexOf(from) + 1) % matches.length] + : matches[0], + kind, + `${kind} is not one key away from ${from}`, + ); // Where a pointer can reach the picker: the label's own text (focuses the // select without opening its menu), else the select itself. const target = await page.$eval('#globe-engine', (el) => { @@ -537,7 +554,9 @@ export async function pickEngine( export async function waitForEngine(page, kind, { timeout = 120000 } = {}) { await page.waitForFunction( (expected) => - window.__godsEyeView?.engine?.kind === expected && + // Navara HD runs the Navara engine. + window.__godsEyeView?.engine?.kind === + (expected === 'cesium' ? 'cesium' : 'navara') && document.querySelector('#globe-engine')?.value === expected && !document.querySelector('#globe-engine')?.disabled && !document.querySelector('.engine-switch-frame') && diff --git a/scripts/qa-strict-navara-hd.mjs b/scripts/qa-strict-navara-hd.mjs new file mode 100644 index 000000000..f0fa86795 --- /dev/null +++ b/scripts/qa-strict-navara-hd.mjs @@ -0,0 +1,145 @@ +// Contract: the two Navara choices on a 2x screen. Navara draws at CSS +// pixels, Navara HD at the device pixel ratio. A pick between them builds +// the other variant on the same session and releases the one it leaves (its +// WebGL context is lost: only one Navara holds memory), the new one draws a +// non-blank frame, the address and the stored choice follow the pick, and +// Cesium stays the warm, parked engine across them. +// +// node scripts/qa-strict-navara-hd.mjs http://127.0.0.1:4208 + +import assert from 'node:assert/strict'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; +import { + installFirstFrameProbe, + installSessionFixtures, + openApp, + rememberEngine, + sessionState, + startSession, +} from './qa-strict-session.mjs'; + +const base = localPreview(process.argv[2]); +const strict = createStrict({ origin: base.origin, layers: [] }); +await strict.run({}, async (b) => { + const p = await b.newPage(); + await p.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 }); + const errors = []; + p.on('pageerror', (e) => errors.push(e.message)); + await installSessionFixtures(p, { flights: 20 }); + await installFirstFrameProbe(p); + await openApp(p, base.origin, 'navara'); + await startSession(p, ['earthquakes', 'flights']); + const contract = (name, value, detail) => { + assert.ok(value, name + ' ' + JSON.stringify(detail ?? '')); + strict.extra.contracts ??= []; + strict.extra.contracts.push({ name, pass: true, detail }); + console.log('PASS', name); + }; + // The running engine's canvas: its pixel ratio, and whether the engine + // remembered under `kind` still has a live context. + const look = (kind) => + p.evaluate((kind) => { + const { engine } = window.__godsEyeView; + const canvas = engine.host.canvas; + const old = window.__qaEngines.get(kind); + const oldCanvas = old?.host.canvas; + return { + engine: engine.kind, + ratio: Math.round((canvas.width / canvas.clientWidth) * 100) / 100, + same: old === engine, + oldLost: + ( + oldCanvas?.getContext('webgl2') ?? oldCanvas?.getContext('webgl') + )?.isContextLost() ?? null, + stored: localStorage.getItem('gev.engine'), + }; + }, kind); + const draws = (kind) => + p.evaluate( + (kind) => + Promise.race([ + window.__qaFirstFrame(kind).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 15000)), + ]), + kind, + ); + const session = async () => { + const state = await sessionState(p); + return state.manager && state.models && state.enabled ? state : null; + }; + const pick = async (choice) => { + await strict.pick(p, choice); + await waitForEngine(p, choice); + }; + + const start = await look('navara'); + contract( + 'Navara draws at CSS pixels on a 2x screen', + start.ratio === 1, + start, + ); + + // Navara → Navara HD: a new engine at the device pixel ratio. + await pick('navara-hd'); + const hd = await look('navara'); + const hdState = await session(); + contract( + 'Navara HD is a new Navara engine at the device pixel ratio; the Navara it left is released', + hd.engine === 'navara' && + hd.ratio === 2 && + !hd.same && + hd.oldLost === true && + hdState?.address === 'navara-hd' && + hdState?.picker === 'navara-hd' && + hd.stored === 'navara-hd', + { ...hd, address: hdState?.address, picker: hdState?.picker }, + ); + await rememberEngine(p); + contract('Navara HD draws a non-blank frame', await draws('navara')); + + // Navara HD → Cesium → Navara HD: Cesium is built once, HD stays warm. + await pick('cesium'); + await rememberEngine(p); + await pick('navara'); + await pick('cesium'); + const cesium = await look('cesium'); + contract( + 'Cesium stays the warm parked engine across the Navara variants', + cesium.same && cesium.ratio === 1, + cesium, + ); + + // Back to Navara HD through Navara: each pick releases the other variant. + await pick('navara'); + await rememberEngine(p); + await pick('navara-hd'); + const again = await look('navara'); + contract( + 'a pick from Navara to Navara HD releases Navara and keeps the session', + !again.same && + again.oldLost === true && + again.ratio === 2 && + (await session()) !== null, + again, + ); + await rememberEngine(p); + await pick('navara'); + const back = await look('navara'); + const backState = await session(); + contract( + 'a pick from Navara HD to Navara draws at CSS pixels again, on the same session', + !back.same && + back.oldLost === true && + back.ratio === 1 && + backState?.address === 'navara' && + back.stored === 'navara', + back, + ); + await rememberEngine(p); + contract('Navara draws a non-blank frame again', await draws('navara')); + contract('no uncaught page errors', errors.length === 0, errors); +}); diff --git a/scripts/qa-strict-parked-release.mjs b/scripts/qa-strict-parked-release.mjs new file mode 100644 index 000000000..1a3e54cec --- /dev/null +++ b/scripts/qa-strict-parked-release.mjs @@ -0,0 +1,113 @@ +// Contract: a parked globe is released after ?parkedMs= (3 s here; three +// minutes by default). A switch back in time is warm (the same engine +// object); after the delay the parked engine's WebGL context is lost and a +// switch back builds a new engine on the same session that draws a +// non-blank frame. Both directions. +// +// node scripts/qa-strict-parked-release.mjs http://127.0.0.1:4208 + +import assert from 'node:assert/strict'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; +import { + assertSession, + installFirstFrameProbe, + installSessionFixtures, + openApp, + rememberEngine, + startSession, +} from './qa-strict-session.mjs'; + +const PARKED_MS = 3000; +const base = localPreview(process.argv[2]); +const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara'); +const strict = createStrict({ origin: base.origin, layers: [] }); +await strict.run({}, async (b) => { + const p = await b.newPage(); + await p.setViewport({ width: 1280, height: 800 }); + const errors = []; + p.on('pageerror', (e) => errors.push(e.message)); + await installSessionFixtures(p, { flights: 20 }); + await installFirstFrameProbe(p); + await openApp(p, base.origin, 'navara', undefined, `&parkedMs=${PARKED_MS}`); + await startSession(p, ['earthquakes', 'flights']); + const contract = (name, value, detail) => { + assert.ok(value, name + ' ' + JSON.stringify(detail ?? '')); + strict.extra.contracts ??= []; + strict.extra.contracts.push({ name, pass: true, detail }); + console.log('PASS', name); + }; + const pick = async (kind) => { + await strict.pick(p, kind); + await waitForEngine(p, kind); + }; + // The running engine is the one remembered under its kind (warm), and + // the context of `kind`'s remembered engine is lost or not. + const engines = (kind) => + p.evaluate((kind) => { + const engine = window.__qaEngines.get(kind); + const canvas = engine?.host.canvas; + const context = + canvas?.getContext('webgl2') ?? canvas?.getContext('webgl'); + return { + warm: + window.__qaEngines.get(window.__godsEyeView.engine.kind) === + window.__godsEyeView.engine, + lost: context?.isContextLost() ?? null, + }; + }, kind); + const draws = (kind) => + p.evaluate( + (kind) => + Promise.race([ + window.__qaFirstFrame(kind).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 15000)), + ]), + kind, + ); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + for (const parked of ['navara', 'cesium']) { + const running = other(parked); + // Park `parked` afresh (either engine may have been released before). + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== parked) { + await pick(parked); + await rememberEngine(p); + } + await pick(running); + await rememberEngine(p); + // A switch back in time is warm. + await wait(PARKED_MS / 3); + await pick(parked); + const early = await engines(parked); + await assertSession(p, parked, `a quick switch back to ${parked}`); + contract( + `a switch back to ${parked} within the delay reuses the parked engine`, + early.warm && early.lost === false, + early, + ); + // Parked past the delay: released, and the next switch back is cold. + await pick(running); + await wait(PARKED_MS + 1500); + const released = await engines(parked); + contract( + `${parked} parked past the delay is released: its context is lost`, + released.lost === true, + released, + ); + await pick(parked); + const cold = await engines(parked); + await assertSession(p, parked, `the cold switch back to ${parked}`); + await rememberEngine(p); + const drawn = await draws(parked); + contract( + `the switch back to a released ${parked} builds a new engine on the same session and draws`, + !cold.warm && drawn, + { ...cold, drawn }, + ); + } + contract('no uncaught page errors', errors.length === 0, errors); +}); diff --git a/scripts/qa-strict-session.mjs b/scripts/qa-strict-session.mjs index aa1f9aadc..9609485bb 100644 --- a/scripts/qa-strict-session.mjs +++ b/scripts/qa-strict-session.mjs @@ -103,10 +103,19 @@ export async function installSessionFixtures( ); } -/** Open the app on `engine` with the session hash and wait until it runs. */ -export async function openApp(page, origin, engine, hash = SESSION_HASH) { +/** + * Open the app on `engine` with the session hash (and `search`, more query + * parameters such as `&parkedMs=3000`) and wait until it runs. + */ +export async function openApp( + page, + origin, + engine, + hash = SESSION_HASH, + search = '', +) { const url = new URL(origin); - url.search = `?engine=${engine}&welcome=0`; + url.search = `?engine=${engine}&welcome=0${search}`; url.hash = hash; await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await waitForEngine(page, engine); diff --git a/src/app/engineChoice.js b/src/app/engineChoice.js index 26608a31e..95d7ce9c8 100644 --- a/src/app/engineChoice.js +++ b/src/app/engineChoice.js @@ -1,21 +1,30 @@ /** The localStorage key that remembers an engine choice between visits. */ export const ENGINE_STORAGE_KEY = 'gev.engine'; -const KINDS = new Set(['cesium', 'navara']); +/** + * The globe choices: Cesium, Navara at CSS pixels without MSAA (as Cesium + * draws), and Navara HD at the device pixel ratio with MSAA 4 (on a 2x + * screen about 1 GiB more: 2.7 GiB against 1.8). + */ +export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']); + +/** The engine a choice runs: both Navara choices are the Navara engine. */ +export const engineOfChoice = (choice) => + choice === 'cesium' ? 'cesium' : 'navara'; const known = (value) => { const kind = String(value ?? '') .trim() .toLowerCase(); - return KINDS.has(kind) ? kind : null; + return ENGINE_CHOICES.includes(kind) ? kind : null; }; /** - * The globe engine for this page: `?engine=cesium|navara`, else the stored - * `gev.engine`, else Cesium. Navara is experimental; an unknown or unreadable - * value never selects it. + * The globe choice for this page: `?engine=cesium|navara|navara-hd`, else + * the stored `gev.engine`, else Cesium. Navara is experimental; an unknown or + * unreadable value never selects it. * @param {{ search?: string, storage?: { getItem(key: string): string | null } | null }} [where] - * @returns {'cesium' | 'navara'} + * @returns {'cesium' | 'navara' | 'navara-hd'} */ export function resolveEngineKind({ search = '', storage = null } = {}) { const fromQuery = known(new URLSearchParams(search).get('engine')); diff --git a/src/app/engineChoice.test.mjs b/src/app/engineChoice.test.mjs index 45bf3b4b1..1c0e2d492 100644 --- a/src/app/engineChoice.test.mjs +++ b/src/app/engineChoice.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { ENGINE_STORAGE_KEY, resolveEngineKind } from './engineChoice.js'; +import { + ENGINE_STORAGE_KEY, + engineOfChoice, + resolveEngineKind, +} from './engineChoice.js'; const storage = (value) => ({ getItem: (key) => (key === ENGINE_STORAGE_KEY ? value : null), @@ -53,3 +57,23 @@ test('unknown values and unreadable storage fall back to Cesium', () => { }; assert.equal(resolveEngineKind({ search: '', storage: throwing }), 'cesium'); }); + +test('Navara HD is a choice of the Navara engine', () => { + assert.equal( + resolveEngineKind({ + search: '?engine=navara-hd', + storage: storage('cesium'), + }), + 'navara-hd', + ); + assert.equal( + resolveEngineKind({ search: '', storage: storage('navara-hd') }), + 'navara-hd', + ); + assert.equal(resolveEngineKind({ search: '?engine=navara-4k' }), 'cesium'); + assert.deepEqual(['cesium', 'navara', 'navara-hd'].map(engineOfChoice), [ + 'cesium', + 'navara', + 'navara', + ]); +}); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index bbe1af7ee..30bbc6cb7 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -336,6 +336,7 @@ export async function createNavaraScene({ mapOptions, loaderStatus, loadNavaraGlobe = loadNavaraGlobeModule, + hd = false, signal, defer, }) { @@ -346,6 +347,8 @@ export async function createNavaraScene({ container, // The cinematic look unless `?look=photoreal|minimal` asks otherwise. search: globalThis.location?.search ?? '', + // Navara HD: the device pixel ratio and MSAA (viewOptions.js). + hd, }); defer(() => globe.destroy()); const { engine } = globe; diff --git a/src/app/scene.js b/src/app/scene.js index d670dc92e..2fe787321 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -21,8 +21,9 @@ import { loadCesium } from './loadCesium.js'; /** * Construct the application globe using the caller's local configuration. - * `engineKind` 'navara' builds the experimental Navara engine instead of the - * Cesium viewer: the scene then has no viewer (`viewer: null`). + * `engineKind` 'navara' or 'navara-hd' (the page's engine choice, + * src/app/engineChoice.js) builds the experimental Navara engine instead of + * the Cesium viewer: the scene then has no viewer (`viewer: null`). */ export async function createApplicationScene({ requestServices, @@ -113,7 +114,7 @@ async function createRenderer({ }) { // The Navara mode's own code loads only in that mode, with Navara itself, // so the Cesium-mode bundle carries none of it. - if (engineKind === 'navara') { + if (engineKind === 'navara' || engineKind === 'navara-hd') { const scene = await ( await import('./navaraMode.js') ).createNavaraScene({ @@ -124,6 +125,7 @@ async function createRenderer({ mapOptions, loaderStatus, loadNavaraGlobe, + hd: engineKind === 'navara-hd', signal, defer, }); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 7dbcee1aa..a0217e77e 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -13,6 +13,7 @@ import ThreeView, { } from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; +import { Pass } from 'postprocessing'; import { createNavaraEngine } from './createNavaraEngine.js'; import { forEachLuminanceUniform, @@ -43,6 +44,10 @@ import { resolveNavaraLook, } from './viewOptions.js'; +// Room for tiles in Navara's tile cache, over the screen-sized render +// targets that its budget also counts (fixedGpuBytes). +const TILE_CACHE_ROOM = 768 * 1024 * 1024; + // Navara 0.2.1 has no preserveDrawingBuffer option but takes a canvas whose // WebGL2 context is already created; three then uses that context. function preservedCanvas(container) { @@ -108,12 +113,14 @@ function groundInView(engine) { * @param {{ * container: HTMLElement, * search?: string, + * hd?: boolean, * }} options `search`, the page's query string, picks the look from - * `?look=` (cinematic by default). The sun follows the engine clock (the + * `?look=` (cinematic by default); `hd` draws at the device pixel ratio + * with MSAA (Navara HD), else at CSS pixels without it. The sun follows the engine clock (the * page's app clock): the look reads its frame time. * @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>} */ -export async function createNavaraGlobe({ container, search }) { +export async function createNavaraGlobe({ container, search, hd = false }) { if (!container) throw new TypeError('createNavaraGlobe needs a container'); const canvas = preservedCanvas(container); let view; @@ -125,7 +132,7 @@ export async function createNavaraGlobe({ container, search }) { // Parked for a switch back: its render loop is off (setRenderLoop). let parked = false; try { - view = new ThreeView(navaraViewOptions({ container, canvas })); + view = new ThreeView(navaraViewOptions({ container, canvas, hd })); const plugin = new DefaultPlugin(); view.addPlugin(plugin); await view.init(); @@ -212,6 +219,24 @@ export async function createNavaraGlobe({ container, search }) { engine.render.request('navara-look'); }), ]; + // The tile cache keeps tiles that left the view until it reaches its + // budget, by default a quarter of device memory up to 2 GiB: a long + // session grew to about 1.4 GiB of GPU memory. The budget counts the + // screen-sized targets too, which Navara reports after it draws them, so + // it follows them (a fixed total coarsened the view on a 2x screen). + let fixedGpuBytes = 0; + let budgetedAt = -Infinity; + removers.push( + engine.frames.postRender.addEventListener(() => { + const now = performance.now(); + if (now - budgetedAt < 1000) return; + budgetedAt = now; + const fixed = view.memoryStats()?.fixedGpuBytes ?? 0; + if (fixed <= 0 || fixed === fixedGpuBytes) return; + fixedGpuBytes = fixed; + view.cacheBytes = fixed + TILE_CACHE_ROOM; + }), + ); removeLook = () => removers.forEach((remove) => remove()); } catch (error) { removeLook?.(); @@ -251,6 +276,11 @@ export async function createNavaraGlobe({ container, search }) { engine.destroy(); overlay.destroy(); view.dispose(); + // postprocessing's fullscreen triangle is one module-level geometry: + // every renderer that drew it left a 'dispose' listener holding its GL + // state, which pins this canvas and the whole view. Disposing it drops + // those listeners; the next view uploads it again. + Pass.fullscreenGeometry.dispose(); canvas.remove(); }, }; diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index b0b5cfaf9..12694e1c0 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -1,16 +1,24 @@ // How the product builds its Navara view: render on demand (the engine's -// render.setContinuous turns animation on), MSAA 4 as Cesium's msaaSamples, -// the device pixel ratio, a canvas whose WebGL2 context keeps its drawing +// render.setContinuous turns animation on), at CSS pixels without MSAA as +// Cesium draws, or in HD at the device pixel ratio with MSAA 4 as Cesium's +// msaaSamples, a canvas whose WebGL2 context keeps its drawing // buffer (Cesium's contextOptions.webgl.preserveDrawingBuffer, for captures), // and a look: `cinematic` by default (navaraScenePlan below), Navara's own // `photoreal` scene (aerial perspective, tone mapping and SMAA, about 5 ms a // frame) with `?look=photoreal`, and `minimal` (sky, stars, sky light and sun // without post effects) for benches. -/** WebGL2 attributes Navara 0.2.1 asks for, plus preserveDrawingBuffer. */ +import { Vector2 } from 'three'; + +/** + * WebGL2 attributes Navara 0.2.1 asks for, plus preserveDrawingBuffer, but + * without antialias: Navara draws into its own (MSAA) targets and only copies + * the result to the canvas, so a multisampled canvas costs about 36 MiB for + * nothing (pixel-identical frames). + */ export const NAVARA_CONTEXT_ATTRIBUTES = Object.freeze({ alpha: true, - antialias: true, + antialias: false, depth: true, stencil: true, premultipliedAlpha: true, @@ -18,15 +26,19 @@ export const NAVARA_CONTEXT_ATTRIBUTES = Object.freeze({ powerPreference: 'default', }); -const MSAA_SAMPLES = 4; +const HD_MSAA_SAMPLES = 4; -/** ThreeView options. */ -export function navaraViewOptions({ container, canvas } = {}) { +/** + * ThreeView options. Without `hd` the view draws at CSS pixels without MSAA + * (on a 2x screen about 1 GiB less than HD; SMAA still smooths edges). + */ +export function navaraViewOptions({ container, canvas, hd = false } = {}) { return { container, ...(canvas ? { canvas } : {}), animation: false, - multisampling: MSAA_SAMPLES, + ...(hd ? {} : { pixelRatio: 1 }), + multisampling: hd ? HD_MSAA_SAMPLES : 0, }; } @@ -175,6 +187,11 @@ const cinematic = (mobile) => ({ // No cloud shadows on the ground: dark patches over the map // read as features (and cost nothing measurable either way). shadows: false, + // The clouds still shade themselves from these maps. The low + // preset's 256² and 2 cascades, which Navara 0.2.1 overwrites + // with its 512² and 3 (4 MiB instead of 24). + shadowMapSize: new Vector2(256, 256), + shadowCascadeCount: 2, // The two cumulus layers start at 1,500 m instead of 750 and // 1,000 m, above the city views (Austin's 600 m, Manhattan's // 900 m): clouds fill the sky without hiding the streets. diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index 9a6d9d87c..824c19852 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -23,10 +23,17 @@ import { cloudsSunlitAt, } from './viewOptions.js'; -test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => { +test('Navara renders on demand at CSS pixels without MSAA; Navara HD at the device pixel ratio with MSAA 4', () => { const container = { id: 'globe' }; const canvas = { id: 'canvas' }; assert.deepEqual(navaraViewOptions({ container, canvas }), { + container, + canvas, + animation: false, + pixelRatio: 1, + multisampling: 0, + }); + assert.deepEqual(navaraViewOptions({ container, canvas, hd: true }), { container, canvas, animation: false, @@ -34,10 +41,10 @@ test('the product view renders on demand with MSAA 4 at the native pixel ratio', }); }); -test('the canvas context keeps its drawing buffer, with the attributes Navara asks for', () => { +test('the canvas context keeps its drawing buffer, with the attributes Navara asks for but no antialias', () => { assert.deepEqual(NAVARA_CONTEXT_ATTRIBUTES, { alpha: true, - antialias: true, + antialias: false, depth: true, stencil: true, premultipliedAlpha: true, @@ -99,6 +106,14 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no }); assert.equal(clouds.clouds.coverage, 0.36); assert.equal(clouds.clouds.shadows, false, 'no cloud shadow maps'); + assert.deepEqual( + [ + clouds.clouds.shadowMapSize.x, + clouds.clouds.shadowMapSize.y, + clouds.clouds.shadowCascadeCount, + ], + [256, 256, 2], + ); assert.ok( clouds.clouds.cloudLayers.every(({ altitude }) => altitude >= 1000), 'cloud layers sit above the city views', diff --git a/src/main.js b/src/main.js index 2a560c38e..a6c244792 100644 --- a/src/main.js +++ b/src/main.js @@ -11,7 +11,7 @@ import { resumeSession, waitForFirstFrame, } from './standalone/engineSwitch.js'; -import { ENGINE_STORAGE_KEY } from './app/engineChoice.js'; +import { ENGINE_STORAGE_KEY, engineOfChoice } from './app/engineChoice.js'; import { createSessionDiagnostics } from './app/sessionDiagnostics.js'; import { useDisplayStore } from './ui/stores/display.js'; import { applicationPinia } from './ui/vueRuntime.js'; @@ -33,7 +33,14 @@ let application; let engineSwitch = null; let cover = null; let cockpit = null; -const renderers = createRendererCache(); +// ?parkedMs= shortens how long a parked globe waits before it is released, +// so a QA scenario can switch back to a released one. +const renderers = createRendererCache({ + // Navara and Navara HD are one engine: only one of them stays parked. + engineOf: engineOfChoice, + parkedMs: + Number(new URLSearchParams(location.search).get('parkedMs')) || undefined, +}); const layerSession = { lifetime: new AbortController() }; const { signal } = layerSession.lifetime; // Which session objects this page holds now (scripts/qa-app-once.mjs). @@ -239,6 +246,7 @@ function offerEngineRetry() { for (const [kind, name] of [ ['cesium', 'Cesium'], ['navara', 'Navara'], + ['navara-hd', 'Navara HD'], ]) { const button = document.createElement('button'); button.type = 'button'; diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index d9cbb2b3d..af24c3cbb 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -78,6 +78,7 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { CITY_POIS: [], createApplication: (options) => ({ ...options, subscribe() {} }), resolveEngineKind: () => 'cesium', + engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'), createStandalonePlaceSearch: () => ({}), createStandaloneCatalog: () => ({}), createGoogleTokenSource: () => createGoogleTokenSource({ fetchImpl }), @@ -91,6 +92,7 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { attachScenePart, AbortController, AbortSignal, + DOMException, }); vm.runInContext(source, context); const app = context.createStandaloneApplication(credentials); diff --git a/src/standalone/application.js b/src/standalone/application.js index 53ac7cf1c..2b542b6ea 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -15,13 +15,16 @@ import { } from '../i18n/index.js'; import { mountApplicationViews } from '../ui/views/index.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; -import { resolveEngineKind } from '../app/engineChoice.js'; +import { engineOfChoice, resolveEngineKind } from '../app/engineChoice.js'; import { createSceneHost } from '../app/sceneHost.js'; import { attachScenePart } from '../app/sceneParts.js'; // Only one engine may own the page at a time. let owner = null; +/** The abort reason of a released globe, made once at module load. */ +const SCENE_RELEASED = new DOMException('The globe was released', 'AbortError'); + /** The page's engine choice: ?engine=, else the stored gev.engine, else Cesium. */ export function pageEngineKind() { let storage = null; @@ -91,7 +94,9 @@ export function createStandaloneApplication({ const controller = new AbortController(); const releases = []; const release = async () => { - controller.abort(); + // A reason made here would keep this call's stack, and with it the + // released scene, alive in every closure that still holds `signal`. + controller.abort(SCENE_RELEASED); const errors = []; while (releases.length) { try { @@ -123,13 +128,16 @@ export function createStandaloneApplication({ // The catalog of this engine: on Navara only the layers with a Navara // renderer (src/app/layers/renderers.js), the others' state kept for // links. The layer models are the session's (layerSession.models). + // Session models keep the catalog's closures past this scene: they + // capture the operations, never the scene (it holds the engine). + const { operations } = scene; const sceneCatalog = createStandaloneCatalog({ - engineKind: kind, + engineKind: engineOfChoice(kind), layerSession, sources: { ...sources, ...qaSources }, streetLevelProviders, nepalBoundaryResolver: (resolverSignal) => - scene.operations.annotationResolver.resolveRegionRingForQuery( + operations.annotationResolver.resolveRegionRingForQuery( 'Nepal', resolverSignal, placeSearch, @@ -137,7 +145,7 @@ export function createStandaloneApplication({ { budgetMs: Infinity }, ), signal, - surface: scene.operations.surface, + surface: operations.surface, }); return Object.freeze({ ...scene, diff --git a/src/standalone/applicationSwitch.test.mjs b/src/standalone/applicationSwitch.test.mjs index 35d4346f2..26bd718cf 100644 --- a/src/standalone/applicationSwitch.test.mjs +++ b/src/standalone/applicationSwitch.test.mjs @@ -37,6 +37,7 @@ function standalone({ failOpen = null } = {}) { getState: () => ({ status: 'ready' }), }), resolveEngineKind: () => 'cesium', + engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'), createStandalonePlaceSearch: () => ({ kind: 'search' }), createStandaloneCatalog: ({ engineKind }) => ({ engineKind }), createGoogleTokenSource: () => null, @@ -51,6 +52,7 @@ function standalone({ failOpen = null } = {}) { attachScenePart, AbortController, AbortSignal, + DOMException, }); vm.runInContext(source, context); const app = context.createStandaloneApplication({}); diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 2bc25310d..c58a0535f 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -12,7 +12,7 @@ // Requests that arrive while a switch or a recovery runs are never dropped: // once the current attempt settles, the address is read again and reconciled. -const ENGINES = Object.freeze(['cesium', 'navara']); +import { ENGINE_CHOICES } from '../app/engineChoice.js'; /** A started engine whose first frame takes longer than this is shown anyway. */ const FIRST_FRAME_WAIT_MS = 10_000; @@ -88,7 +88,7 @@ export function createEngineSwitch({ /** The picker's choice. While a switch runs it is queued, not dropped. */ function select(next) { - if (!ENGINES.includes(next) || signal.aborted) return null; + if (!ENGINE_CHOICES.includes(next) || signal.aborted) return null; requests++; if (busy) { queued = next; diff --git a/src/standalone/rendererCache.js b/src/standalone/rendererCache.js index 532220752..1a6fb04ae 100644 --- a/src/standalone/rendererCache.js +++ b/src/standalone/rendererCache.js @@ -1,13 +1,52 @@ // This cache owns the parked renderer and its map. The layer session owns // feeds, selection, media and the Director across both renderer attachments. -export function createRendererCache() { + +/** How long a parked renderer waits for a switch back before it is + * disposed: a parked Navara holds about 1.3 GiB, a parked Cesium about + * 0.26 GiB. A later switch back builds it cold (1.5-2 s). */ +export const PARKED_RELEASE_MS = 3 * 60 * 1000; + +/** + * `engineOf(kind)` names the engine a kind runs: kinds of one engine (Navara + * and Navara HD) never stay parked together, so acquiring one releases the + * other first. + */ +export function createRendererCache({ + engineOf = (kind) => kind, + parkedMs = PARKED_RELEASE_MS, + onReleaseError = (error) => + console.error('Parked renderer release failed:', error), +} = {}) { const idle = new Map(); let retainKind; + function take(kind) { + const entry = idle.get(kind); + idle.delete(kind); + if (entry) clearTimeout(entry.releaseTimer); + return entry; + } + + // Dispose a parked renderer for good. + function release(kind) { + const entry = take(kind); + try { + entry.dispose(); + } catch (error) { + onReleaseError(error); + } finally { + // A disposed globe's context stays live while anything still + // references its canvas; losing it frees the GPU memory now (the + // canvas is out of the document, so no page listener hears it). + entry.context?.getExtension?.('WEBGL_lose_context')?.loseContext(); + } + } + async function acquire(kind, build, defer) { const host = document.getElementById('cesiumContainer'); - let entry = idle.get(kind); - idle.delete(kind); + for (const other of [...idle.keys()]) + if (other !== kind && engineOf(other) === engineOf(kind)) release(other); + let entry = take(kind); // loseContext() takes effect before its queued DOM event is delivered. // Check the context itself too, before resuming a parked render loop. if (entry && (entry.lost || entry.context?.isContextLost?.())) { @@ -76,6 +115,7 @@ export function createRendererCache() { } entry.container.remove(); idle.set(kind, entry); + entry.releaseTimer = setTimeout(() => release(kind), parkedMs); }); return entry.scene; } @@ -86,8 +126,7 @@ export function createRendererCache() { retainKind = kind; }, clear() { - for (const entry of idle.values()) entry.dispose(); - idle.clear(); + for (const kind of [...idle.keys()]) take(kind).dispose(); }, }; } diff --git a/src/standalone/rendererCache.test.mjs b/src/standalone/rendererCache.test.mjs index 2810f2aaf..0f9f92d2f 100644 --- a/src/standalone/rendererCache.test.mjs +++ b/src/standalone/rendererCache.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createRendererCache } from './rendererCache.js'; -test('a parked renderer is reused, but a lost context is disposed before rebuilding', async (t) => { +function installDocument(t) { const host = { prepend: (el) => { el.isConnected = true; @@ -20,15 +20,27 @@ test('a parked renderer is reused, but a lost context is disposed before rebuild t.after(() => { globalThis.document = saved; }); - const cache = createRendererCache(); - let released = 0; - let release; - const build = ({ container, defer }) => { +} + +/** A renderer build that counts its releases in `released.count`. */ +function fakeBuild(released) { + return ({ container, defer }) => { defer(() => { - released += 1; + released.count += 1; }); const canvas = new EventTarget(); - const context = { lost: false, isContextLost: () => context.lost }; + const context = { + lost: false, + isContextLost: () => context.lost, + getExtension: (name) => + name === 'WEBGL_lose_context' + ? { + loseContext() { + context.lost = true; + }, + } + : null, + }; canvas.getContext = (type) => (type === 'webgl2' ? context : null); return { context, @@ -44,6 +56,14 @@ test('a parked renderer is reused, but a lost context is disposed before rebuild }, }; }; +} + +test('a parked renderer is reused, but a lost context is disposed before rebuilding', async (t) => { + installDocument(t); + const cache = createRendererCache(); + const released = { count: 0 }; + let release; + const build = fakeBuild(released); const open = () => cache.acquire('navara', build, (stop) => { release = stop; @@ -72,7 +92,7 @@ test('a parked renderer is reused, but a lost context is disposed before rebuild }; const second = await open(); assert.notEqual(second, first); - assert.equal(released, 1); + assert.equal(released.count, 1); cache.retain('navara'); release(); second.resume = () => { @@ -80,14 +100,14 @@ test('a parked renderer is reused, but a lost context is disposed before rebuild }; await assert.rejects(open(), /resume failed/); assert.equal(second.engine.host.container.isConnected, false); - assert.equal(released, 2); + assert.equal(released.count, 2); const eventLost = await open(); cache.retain('navara'); release(); eventLost.engine.host.canvas.dispatchEvent(new Event('webglcontextlost')); const third = await open(); assert.notEqual(third, eventLost); - assert.equal(released, 3); + assert.equal(released.count, 3); third.suspend = () => { throw new Error('suspend failed'); }; @@ -95,5 +115,112 @@ test('a parked renderer is reused, but a lost context is disposed before rebuild assert.throws(release, /suspend failed/); assert.equal(third.engine.host.container.isConnected, false); cache.clear(); - assert.equal(released, 4); + assert.equal(released.count, 4); +}); + +test('a parked renderer is released after a while, unless the page switches back first', async (t) => { + installDocument(t); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const errors = []; + const cache = createRendererCache({ + parkedMs: 1000, + onReleaseError: (error) => errors.push(error), + }); + const released = { count: 0 }; + let release; + const open = (build = fakeBuild(released)) => + cache.acquire('navara', build, (stop) => { + release = stop; + }); + const park = () => { + cache.retain('navara'); + release(); + }; + + const first = await open(); + park(); + t.mock.timers.tick(999); + assert.equal(await open(), first, 'a switch back in time is warm'); + assert.equal(first.context.lost, false); + park(); + // The timer of the earlier parking was cancelled: this one starts afresh. + t.mock.timers.tick(999); + assert.equal(released.count, 0); + t.mock.timers.tick(1); + assert.equal(released.count, 1, 'released once the delay runs out'); + assert.equal(first.context.lost, true, 'its GPU memory goes with it'); + const cold = await open(); + assert.notEqual(cold, first, 'a switch back after the release builds anew'); + + // A release that fails is reported, and the entry is gone either way. + const failingCache = createRendererCache({ + parkedMs: 1000, + onReleaseError: (error) => errors.push(error), + }); + let releaseBroken; + const brokenBuild = (options) => { + options.defer(() => { + throw new Error('dispose failed'); + }); + return fakeBuild({ count: 0 })(options); + }; + const openBroken = () => + failingCache.acquire('navara', brokenBuild, (stop) => { + releaseBroken = stop; + }); + const broken = await openBroken(); + failingCache.retain('navara'); + releaseBroken(); + t.mock.timers.tick(1000); + assert.equal(errors.length, 1); + assert.match(errors[0].errors[0].message, /dispose failed/); + assert.notEqual(await openBroken(), broken); + + // clear() disposes what is parked and leaves no timer behind. + park(); + const before = released.count; + cache.clear(); + t.mock.timers.tick(1000); + assert.equal(released.count, before + 1); +}); + +test('acquiring one kind of an engine releases the other parked kind of it first', async (t) => { + installDocument(t); + const cache = createRendererCache({ + engineOf: (kind) => (kind === 'cesium' ? 'cesium' : 'navara'), + }); + const released = { count: 0 }; + const build = fakeBuild(released); + let release; + const open = (kind) => + cache.acquire(kind, build, (stop) => { + release = stop; + }); + const park = (kind) => { + cache.retain(kind); + release(); + }; + const navara = await open('navara'); + park('navara'); + const cesium = await open('cesium'); + park('cesium'); + let builtWhileParked = null; + const hd = await cache.acquire( + 'navara-hd', + (options) => { + builtWhileParked = released.count; + return build(options); + }, + (stop) => { + release = stop; + }, + ); + assert.equal(builtWhileParked, 1, 'Navara went before Navara HD was built'); + assert.equal(navara.context.lost, true); + park('navara-hd'); + assert.equal(await open('cesium'), cesium, 'Cesium stays parked, warm'); + park('cesium'); + assert.notEqual(await open('navara'), navara); + assert.equal(hd.context.lost, true); + cache.clear(); }); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index 55dd3032d..e06832b22 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -119,6 +119,7 @@ function selectMapSource(chip) { @change="globeEngine.select($event.target.value)"> +