mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 20:34:02 +00:00
Add a Navara HD renderer choice, release a parked engine after three minutes, and cut Navara's memory
Navara now draws at CSS pixels without MSAA, like Cesium; Navara HD keeps the device pixel ratio and MSAA 4. Only one Navara variant is ever kept. A parked engine is released after three minutes and its WebGL context is lost. Three leaks that kept a released Navara alive are fixed, the canvas context drops its unused antialias buffer, cloud shadows use two 256 px cascades, and the tile cache is budgeted from the screen buffers.
This commit is contained in:
1 parent
a6c64713ee
commit
9825b02aab
22 files changed
+657
-58
No files matched your search
@@ -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).
|
||||
+23
-1
@@ -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
|
||||
|
||||
@@ -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', []],
|
||||
|
||||
@@ -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') &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+15
-6
@@ -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'));
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+5
-3
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
+10
-2
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -119,6 +119,7 @@ function selectMapSource(chip) {
|
||||
@change="globeEngine.select($event.target.value)">
|
||||
<option value="cesium">Cesium</option>
|
||||
<option value="navara">Navara</option>
|
||||
<option value="navara-hd">Navara HD</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user