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)">
+