Keep globe renderers warm across live engine switches

This commit is contained in:
German Khokhlov committed 2026-11-02 13:45:23 +04:00
1 parent f71c76c306
commit 5e658d8c9d
15 files changed
+343 -34

No files matched your search

+49 -6
View File
@@ -40,6 +40,8 @@ try {
await page.waitForFunction(
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
document.querySelector('#globe-engine')?.value === kind &&
!document.querySelector('#globe-engine').disabled &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
kind,
@@ -65,19 +67,60 @@ try {
await page.waitForSelector('#globe-engine', { visible: true });
await page.evaluate(() => {
window.__switchDocument = document;
window.__renderers ??= new Map();
window.__renderers.set(
window.__godsEyeView.engine.kind,
window.__godsEyeView.engine,
);
window.__switchSplash = false;
window.__switchObserver = new MutationObserver(() => {
if (!document.querySelector('#loading-screen.hidden'))
window.__switchSplash = true;
});
window.__switchObserver.observe(
document.querySelector('#loading-screen'),
{ attributes: true },
);
window.__outgoingEngine = window.__godsEyeView.engine;
});
await page.select('#globe-engine', kind);
await ready(kind);
assert.ok(await page.evaluate(() => window.__switchDocument === document));
assert.ok(
await page.evaluate(
() =>
window.__outgoingEngine.isDestroyed() &&
await page.evaluate(() => {
window.__switchObserver.disconnect();
const { engine } = window.__godsEyeView;
const previous = window.__renderers.get(engine.kind);
return (
!window.__switchSplash &&
(!previous || previous === engine) &&
!window.__outgoingEngine.isDestroyed() &&
!window.__outgoingEngine.host.canvas.isConnected &&
engine.host.canvas.isConnected &&
window.__godsEyeView.dataManager
.getEnabledLayerIds()
.has('earthquakes'),
),
.has('earthquakes')
);
}),
);
await page.waitForFunction(
() => !document.querySelector('.engine-switch-frame'),
);
assert.equal(
await page.evaluate(async () => {
const parked = window.__outgoingEngine;
let frames = 0;
const remove = parked.frames.postRender.addEventListener(() => {
frames++;
});
parked.render.request('qa-parked');
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
remove();
return frames;
}),
0,
);
const url = new URL(page.url());
assert.equal(url.searchParams.get('engine'), kind);
@@ -155,7 +198,7 @@ try {
assert.equal(await stored(), 'cesium');
assert.deepEqual(errors, []);
console.log(
'PASS Cesium default, both switches, same document, outgoing engine destroyed, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
);
} finally {
await browser.close();
+2
View File
@@ -20,6 +20,7 @@ export function createApplicationControls({
defer,
initialShareCode = null,
localePicker = true,
engineSwitch = false,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -40,6 +41,7 @@ export function createApplicationControls({
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
engineSwitch,
localePicker,
});
defer(() => styleManager.orbitController.stop());
+3 -3
View File
@@ -177,7 +177,7 @@ export async function registerNavaraLayerRenderers() {
* credits in its own attribution UI.
*/
export async function createNavaraScene({
operations,
container = document.getElementById('cesiumContainer'),
cesiumToken,
credits,
MapController,
@@ -191,7 +191,7 @@ export async function createNavaraScene({
const createNavaraGlobe = await loadNavaraGlobe();
signal.throwIfAborted();
const globe = await createNavaraGlobe({
container: document.getElementById('cesiumContainer'),
container,
// The cinematic look unless `?look=photoreal|minimal` asks otherwise.
search: globalThis.location?.search ?? '',
});
@@ -241,8 +241,8 @@ export async function createNavaraScene({
viewer: null,
engine,
tileset: null,
setRenderLoop: globe.setRenderLoop,
mapStackController,
operations,
};
}
+33 -12
View File
@@ -26,16 +26,10 @@ import { onLocaleChange, t } from '../i18n/index.js';
export async function createApplicationScene({
requestServices,
googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
mapOptions = {},
loaderStatus,
engineKind = 'cesium',
loadNavaraGlobe,
signal,
defer,
rendererCache,
...options
}) {
const operations = createApplicationOperations({
requests: requestServices,
@@ -51,11 +45,38 @@ export async function createApplicationScene({
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
const build = (resources) =>
createRenderer({
...options,
googleApiKey,
signal,
...resources,
});
const { viewer, engine, tileset, mapStackController } = rendererCache
? await rendererCache.acquire(options.engineKind, build, defer)
: await build({ defer });
return { viewer, engine, tileset, mapStackController, operations };
}
async function createRenderer({
googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
mapOptions = {},
loaderStatus,
engineKind = 'cesium',
loadNavaraGlobe,
signal,
defer,
container,
}) {
// 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')
return (await import('./navaraMode.js')).createNavaraScene({
operations,
container,
cesiumToken,
credits,
MapController,
@@ -71,10 +92,10 @@ export async function createApplicationScene({
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
(container ?? document.body).appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
container: container ?? 'cesiumContainer',
creditContainer,
});
defer(() => {
@@ -160,5 +181,5 @@ export async function createApplicationScene({
});
signal.throwIfAborted();
return { viewer, engine, tileset, mapStackController, operations };
return { viewer, engine, tileset, mapStackController };
}
+4 -2
View File
@@ -16,7 +16,9 @@ export function startApplicationChrome({
const minimumDelay = new Promise((resolve) => {
resolveDelay = resolve;
});
const delayTimer = setTimeout(resolveDelay, 1000);
const switching = loadingScreen.classList.contains?.('hidden');
const delayTimer = switching ? null : setTimeout(resolveDelay, 1000);
if (switching) resolveDelay();
const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return;
firstRun = initializeWelcome?.({ styleManager, dataManager });
@@ -28,7 +30,7 @@ export function startApplicationChrome({
/* Restoration reports its own outcome through the controls. */
})
.then(() => {
if (disposed || signal.aborted) return;
if (disposed || signal.aborted || switching) return;
loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, {
once: true,
+2
View File
@@ -16,6 +16,7 @@ import {
forEachLuminanceUniform,
keepCascadedLightIds,
navaraHooks,
setRenderLoopEnabled,
} from './internals.js';
import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js';
@@ -201,6 +202,7 @@ export async function createNavaraGlobe({
NavaraEffect,
DrapedMesh,
setupMaterialForDrape,
setRenderLoop: (enabled) => setRenderLoopEnabled(view, enabled),
destroy() {
if (destroyed) return;
destroyed = true;
+8
View File
@@ -9,6 +9,14 @@ import { patchCascadedLightCopy } from './cascadedLightCopy.js';
export const NAVARA_INTERNALS_VERSION = '0.2.1';
// Navara 0.2.1's animation=false still updates the tile tree every RAF.
// Until ThreeView exposes suspend/resume, stop its underlying Three loop
// while this view is parked and reinstall its normal loop when it returns.
export function setRenderLoopEnabled(view, enabled) {
if (enabled) view._startMainLoop();
else view._renderer.setAnimationLoop(null);
}
let restoreRelease = null;
/**
+58 -5
View File
@@ -2,6 +2,7 @@ import {
createStandaloneApplication,
pageEngineKind,
} from './standalone/application.js';
import { createRendererCache } from './standalone/rendererCache.js';
import { describeError } from './standalone/errors.js';
import { ENGINE_STORAGE_KEY } from './app/engineChoice.js';
import { useDisplayStore } from './ui/stores/display.js';
@@ -13,16 +14,26 @@ const loading = document.getElementById('loading-screen');
let kind = pageEngineKind();
let application;
let busy = false;
const renderers = createRendererCache();
window.addEventListener('pagehide', (event) => {
if (!event.persisted) renderers.clear();
});
async function start(next) {
async function start(next, engineSwitch = false) {
kind = next;
application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
allowQaRegistration: import.meta.env.DEV,
engineKind: next,
rendererCache: renderers,
engineSwitch,
});
await application.start();
const { scene, controls } = await application.start();
if (engineSwitch) {
await controls.styleManager.initialRestorePromise;
await waitForGlobeFrame(scene.engine);
}
}
function present() {
@@ -48,6 +59,45 @@ function select(next) {
void reconcile(true);
}
// Keep the last visible frame while the first visit initializes the other renderer.
function keepGlobeFrame(engine) {
const source = engine.host.canvas;
const frame = document.createElement('canvas');
frame.width = source.width;
frame.height = source.height;
if (!frame.width || !frame.height) return null;
const context = frame.getContext('2d');
if (!context) return null;
context.drawImage(source, 0, 0);
frame.className = 'engine-switch-frame';
frame.setAttribute('aria-hidden', 'true');
document.getElementById('cesiumContainer').append(frame);
return frame;
}
// Hand off the visible globe only after the new camera has actually drawn.
function waitForGlobeFrame(engine) {
if (document.hidden) return;
return new Promise((resolve, reject) => {
const finish = () => {
remove();
clearTimeout(timer);
document.removeEventListener('visibilitychange', done);
};
const remove = engine.frames.postRender.addEventListener(done);
const timer = setTimeout(() => {
finish();
reject(new Error('The globe did not draw a frame'));
}, 8000);
function done() {
finish();
resolve();
}
document.addEventListener('visibilitychange', done, { once: true });
engine.render.request('engine-switch');
});
}
// popstate can arrive again while an engine is starting. After teardown and
// after startup, read the actual address so the latest history entry wins.
async function reconcile(remember = false) {
@@ -55,11 +105,13 @@ async function reconcile(remember = false) {
busy = true;
present();
const previous = kind;
let cover;
try {
while (pageEngineKind() !== kind) {
loading.classList.remove('hidden');
cover ??= keepGlobeFrame(application.getComponents().scene.engine);
renderers.retain(kind);
await application.destroy();
await start(pageEngineKind());
await start(pageEngineKind(), true);
}
if (remember) {
try {
@@ -75,7 +127,7 @@ async function reconcile(remember = false) {
history.replaceState(history.state, '', url);
try {
await application.destroy();
await start(previous);
await start(previous, true);
application
.getComponents()
.controls.styleManager._showToast(describeError(error));
@@ -83,6 +135,7 @@ async function reconcile(remember = false) {
report(recoveryError);
}
} finally {
cover?.remove();
busy = false;
present();
}
+9 -2
View File
@@ -333,7 +333,10 @@ export class ShareLinkManager {
/**
* Apply a parsed state to the camera + style manager.
*/
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
async applyState(
state,
{ applyCamera = true, instant = false, navigationToken = null } = {},
) {
if (this._destroyed || !state)
return { succeeded: false, reason: 'unavailable' };
const view = {
@@ -347,7 +350,11 @@ export class ShareLinkManager {
let cameraPromise = Promise.resolve({
status: applyCamera ? 'superseded' : 'skipped',
});
if (applyCamera && this._isNavigationCurrent(navigationToken)) {
if (applyCamera && instant && this._isNavigationCurrent(navigationToken)) {
this.engine.camera.setView(view);
this.engine.render.request('engine-switch');
cameraPromise = Promise.resolve({ status: 'applied' });
} else if (applyCamera && this._isNavigationCurrent(navigationToken)) {
const restoreGeneration = ++this._restoreGeneration;
let settleCamera;
cameraPromise = new Promise((resolve) => {
+4
View File
@@ -41,6 +41,8 @@ export function createStandaloneApplication({
streetLevelProviders,
allowQaRegistration = false,
engineKind = pageEngineKind(),
rendererCache,
engineSwitch = false,
}) {
if (owner)
throw new Error('The standalone application already owns this page');
@@ -86,6 +88,7 @@ export function createStandaloneApplication({
cesiumToken,
loaderStatus,
engineKind,
rendererCache,
});
// The catalog of this engine: on Navara only the layers with a Navara
// renderer (src/app/layers/renderers.js; none yet), the others' state
@@ -123,6 +126,7 @@ export function createStandaloneApplication({
createControls: (context) =>
createStandaloneControls({
...context,
engineSwitch,
loaderStatus,
placeSearch,
catalog,
+89
View File
@@ -0,0 +1,89 @@
import { releaseCameraVerbs } from '../cameraVerbs.js';
import { uninstallRenderGovernor } from '../renderGovernor.js';
// Only the renderer and its map survive a switch. The outgoing UI, data feeds,
// tools and their subscriptions are still disposed with their application.
export function createRendererCache() {
const idle = new Map();
let retainKind;
async function acquire(kind, build, defer) {
const host = document.getElementById('cesiumContainer');
let entry = idle.get(kind);
idle.delete(kind);
if (entry?.lost) {
entry.dispose();
entry = null;
}
if (!entry) {
const container = document.createElement('div');
container.className = 'globe-renderer';
host.prepend(container);
const cleanups = [() => container.remove()];
const dispose = () => {
const errors = [];
while (cleanups.length) {
try {
cleanups.pop()();
} catch (error) {
errors.push(error);
}
}
if (errors.length)
throw new AggregateError(errors, 'Renderer cleanup failed');
};
try {
const scene = await build({
container,
defer: (fn) => cleanups.push(fn),
});
entry = { container, scene, dispose, lost: false };
scene.engine.host.canvas.addEventListener(
'webglcontextlost',
() => {
entry.lost = true;
},
{ once: true },
);
} catch (error) {
dispose();
throw error;
}
} else {
host.prepend(entry.container);
if (entry.scene.viewer)
entry.scene.viewer.useDefaultRenderLoop = !document.hidden;
else entry.scene.setRenderLoop(true);
entry.scene.engine.render.request('engine-resume');
}
const { viewer, engine } = entry.scene;
defer(() => {
const keep = retainKind === kind && !entry.lost;
retainKind = null;
if (!keep) return entry.dispose();
uninstallRenderGovernor(viewer ?? engine);
engine.camera.cancelFlight();
if (viewer) {
releaseCameraVerbs(viewer);
viewer.useDefaultRenderLoop = false;
} else {
engine.render.setContinuous(false);
entry.scene.setRenderLoop(false);
}
entry.container.remove();
idle.set(kind, entry);
});
return entry.scene;
}
return {
acquire,
retain(kind) {
retainKind = kind;
},
clear() {
for (const entry of idle.values()) entry.dispose();
idle.clear();
},
};
}
+61
View File
@@ -0,0 +1,61 @@
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) => {
const host = {
prepend: (el) => {
el.isConnected = true;
},
};
const saved = globalThis.document;
globalThis.document = {
getElementById: () => host,
createElement: () => ({
remove() {
this.isConnected = false;
},
}),
};
t.after(() => {
globalThis.document = saved;
});
const cache = createRendererCache();
let released = 0;
let release;
const build = ({ container, defer }) => {
defer(() => {
released += 1;
});
return {
setRenderLoop(enabled) {
this.running = enabled;
},
engine: {
host: { container, canvas: new EventTarget() },
camera: { cancelFlight() {} },
render: { request() {}, setContinuous() {} },
},
};
};
const open = () =>
cache.acquire('navara', build, (stop) => {
release = stop;
});
const first = await open();
cache.retain('navara');
release();
assert.equal(first.running, false);
assert.equal(first.engine.host.container.isConnected, false);
assert.equal(await open(), first);
assert.equal(first.running, true);
assert.equal(first.engine.host.container.isConnected, true);
cache.retain('navara');
release();
first.engine.host.canvas.dispatchEvent(new Event('webglcontextlost'));
assert.notEqual(await open(), first);
assert.equal(released, 1);
release();
cache.clear();
assert.equal(released, 2);
});
+2 -1
View File
@@ -104,6 +104,7 @@ export class StyleManager extends ShellFacade {
services,
requestServices,
initialShareCode = null,
engineSwitch = false,
localePicker = true,
} = {},
) {
@@ -620,7 +621,7 @@ export class StyleManager extends ShellFacade {
this._publishActiveStyle();
this._updateLocationMiniStatus();
this._shareRestoration.start();
this._shareRestoration.start({ engineSwitch });
// Keep the parameter panel from overlapping toggle controls.
this._layoutRightPanels();
+6 -3
View File
@@ -153,7 +153,7 @@ export class ShareRestoration {
this.shareLinkManager = shareLinkManager;
this._initialShareState = shareLinkManager.parseInitialHash();
}
start() {
start({ engineSwitch = false } = {}) {
// Restore from URL hash if present
const savedState = this._initialShareState;
this._initialShareRestorePromise = savedState
@@ -169,6 +169,7 @@ export class ShareRestoration {
this.navigation._beginDeferredNavigation('shared view', {
cancelPendingSelection: false,
});
const delay = engineSwitch ? 0 : 1500;
this._initialShareRestoreTimeout = setTimeout(() => {
this._initialShareRestoreTimeout = null;
if (this._disposed) return;
@@ -178,11 +179,12 @@ export class ShareRestoration {
this.navigation._reassertNavigationHandoff(generation);
void this._runShareRestore(savedState, {
applyCamera,
instant: engineSwitch,
navigationToken: generation,
layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result),
});
}, 1500);
}, delay);
} else {
this.syncShareState();
if (this.shareLinkManager?.rejectedShareCode)
@@ -236,7 +238,7 @@ export class ShareRestoration {
*/
async _runShareRestore(
state,
{ applyCamera, navigationToken, layersPromise, settle },
{ applyCamera, instant = false, navigationToken, layersPromise, settle },
) {
const generation = this._shareRestoreGeneration;
const current = () =>
@@ -244,6 +246,7 @@ export class ShareRestoration {
try {
const share = await this.shareLinkManager.applyState(state, {
applyCamera,
instant,
navigationToken,
});
const layers = await (layersPromise || Promise.resolve([]));
+13
View File
@@ -122,6 +122,19 @@ body {
left: 0;
}
.globe-renderer,
.engine-switch-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.engine-switch-frame {
z-index: 1;
pointer-events: none;
}
/* Unified world-overlay host: detection uses one host-owned blend surface
beneath the shared text/card canvas; both share one frame path. The root
holds ONLY the card canvas — it is a stacking context (z-index below), and