mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +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
+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