From 5e658d8c9dff02c6df707c8102b9c061bad82f38 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 13:45:23 +0400 Subject: [PATCH] Keep globe renderers warm across live engine switches --- scripts/qa-engine-switch.mjs | 55 +++++++++++++++-- src/app/controls.js | 2 + src/app/navaraMode.js | 6 +- src/app/scene.js | 45 ++++++++++---- src/app/startupChrome.js | 6 +- src/globe/navara/index.js | 2 + src/globe/navara/internals.js | 8 +++ src/main.js | 63 +++++++++++++++++-- src/sharelink.js | 11 +++- src/standalone/application.js | 4 ++ src/standalone/rendererCache.js | 89 +++++++++++++++++++++++++++ src/standalone/rendererCache.test.mjs | 61 ++++++++++++++++++ src/ui/applicationShell.js | 3 +- src/ui/shareRestoration.js | 9 ++- src/ui/styles/foundation.css | 13 ++++ 15 files changed, 343 insertions(+), 34 deletions(-) create mode 100644 src/standalone/rendererCache.js create mode 100644 src/standalone/rendererCache.test.mjs diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index 74e46bea7..d0599eb6c 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -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(); diff --git a/src/app/controls.js b/src/app/controls.js index 041ecb1c3..c81246e51 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -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()); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 5102d542e..96c39669d 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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, }; } diff --git a/src/app/scene.js b/src/app/scene.js index 6c8e02e1f..84965b40d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -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 }; } diff --git a/src/app/startupChrome.js b/src/app/startupChrome.js index 0cda050f4..bef64f434 100644 --- a/src/app/startupChrome.js +++ b/src/app/startupChrome.js @@ -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, diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 0ae575f10..30244e159 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -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; diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index f97cb31d8..8fd028c4f 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -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; /** diff --git a/src/main.js b/src/main.js index 5b15264a9..7bfbccb5a 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); } diff --git a/src/sharelink.js b/src/sharelink.js index 0aeddca5d..34cad5787 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -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) => { diff --git a/src/standalone/application.js b/src/standalone/application.js index 3b4a07fe1..224c41be8 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -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, diff --git a/src/standalone/rendererCache.js b/src/standalone/rendererCache.js new file mode 100644 index 000000000..c739158f2 --- /dev/null +++ b/src/standalone/rendererCache.js @@ -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(); + }, + }; +} diff --git a/src/standalone/rendererCache.test.mjs b/src/standalone/rendererCache.test.mjs new file mode 100644 index 000000000..efe1c51ed --- /dev/null +++ b/src/standalone/rendererCache.test.mjs @@ -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); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index d2a6cfe16..a838fbf66 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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(); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index b8556ae8d..b191a4e4c 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -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([])); diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index 9fac5bfbd..6786ac669 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -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