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( await page.waitForFunction(
(kind) => (kind) =>
window.__godsEyeView?.engine?.kind === kind && window.__godsEyeView?.engine?.kind === kind &&
document.querySelector('#globe-engine')?.value === kind &&
!document.querySelector('#globe-engine').disabled &&
document.getElementById('loading-screen')?.classList.contains('hidden'), document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 }, { timeout: 90000 },
kind, kind,
@@ -65,19 +67,60 @@ try {
await page.waitForSelector('#globe-engine', { visible: true }); await page.waitForSelector('#globe-engine', { visible: true });
await page.evaluate(() => { await page.evaluate(() => {
window.__switchDocument = document; 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; window.__outgoingEngine = window.__godsEyeView.engine;
}); });
await page.select('#globe-engine', kind); await page.select('#globe-engine', kind);
await ready(kind); await ready(kind);
assert.ok(await page.evaluate(() => window.__switchDocument === document)); assert.ok(await page.evaluate(() => window.__switchDocument === document));
assert.ok( assert.ok(
await page.evaluate( await page.evaluate(() => {
() => window.__switchObserver.disconnect();
window.__outgoingEngine.isDestroyed() && 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 window.__godsEyeView.dataManager
.getEnabledLayerIds() .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()); const url = new URL(page.url());
assert.equal(url.searchParams.get('engine'), kind); assert.equal(url.searchParams.get('engine'), kind);
@@ -155,7 +198,7 @@ try {
assert.equal(await stored(), 'cesium'); assert.equal(await stored(), 'cesium');
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
console.log( 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 { } finally {
await browser.close(); await browser.close();
+2
View File
@@ -20,6 +20,7 @@ export function createApplicationControls({
defer, defer,
initialShareCode = null, initialShareCode = null,
localePicker = true, localePicker = true,
engineSwitch = false,
}) { }) {
// Initialize the style manager (post-processing, HUD, locations, share links) // Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, { const styleManager = new Controls(viewer, {
@@ -40,6 +41,7 @@ export function createApplicationControls({
placeSearch, placeSearch,
// A share code to open at start-up instead of the page's address. // A share code to open at start-up instead of the page's address.
initialShareCode, initialShareCode,
engineSwitch,
localePicker, localePicker,
}); });
defer(() => styleManager.orbitController.stop()); defer(() => styleManager.orbitController.stop());
+3 -3
View File
@@ -177,7 +177,7 @@ export async function registerNavaraLayerRenderers() {
* credits in its own attribution UI. * credits in its own attribution UI.
*/ */
export async function createNavaraScene({ export async function createNavaraScene({
operations, container = document.getElementById('cesiumContainer'),
cesiumToken, cesiumToken,
credits, credits,
MapController, MapController,
@@ -191,7 +191,7 @@ export async function createNavaraScene({
const createNavaraGlobe = await loadNavaraGlobe(); const createNavaraGlobe = await loadNavaraGlobe();
signal.throwIfAborted(); signal.throwIfAborted();
const globe = await createNavaraGlobe({ const globe = await createNavaraGlobe({
container: document.getElementById('cesiumContainer'), container,
// The cinematic look unless `?look=photoreal|minimal` asks otherwise. // The cinematic look unless `?look=photoreal|minimal` asks otherwise.
search: globalThis.location?.search ?? '', search: globalThis.location?.search ?? '',
}); });
@@ -241,8 +241,8 @@ export async function createNavaraScene({
viewer: null, viewer: null,
engine, engine,
tileset: null, tileset: null,
setRenderLoop: globe.setRenderLoop,
mapStackController, mapStackController,
operations,
}; };
} }
+33 -12
View File
@@ -26,16 +26,10 @@ import { onLocaleChange, t } from '../i18n/index.js';
export async function createApplicationScene({ export async function createApplicationScene({
requestServices, requestServices,
googleApiKey, googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
mapOptions = {},
loaderStatus,
engineKind = 'cesium',
loadNavaraGlobe,
signal, signal,
defer, defer,
rendererCache,
...options
}) { }) {
const operations = createApplicationOperations({ const operations = createApplicationOperations({
requests: requestServices, requests: requestServices,
@@ -51,11 +45,38 @@ export async function createApplicationScene({
else window.__GOOGLE_MAPS_API_KEY__ = previousKey; 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, // The Navara mode's own code loads only in that mode, with Navara itself,
// so the Cesium-mode bundle carries none of it. // so the Cesium-mode bundle carries none of it.
if (engineKind === 'navara') if (engineKind === 'navara')
return (await import('./navaraMode.js')).createNavaraScene({ return (await import('./navaraMode.js')).createNavaraScene({
operations, container,
cesiumToken, cesiumToken,
credits, credits,
MapController, MapController,
@@ -71,10 +92,10 @@ export async function createApplicationScene({
// Provider attribution stays visible, including clean-view and recording. // Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div'); const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits'; creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer); (container ?? document.body).appendChild(creditContainer);
defer(() => creditContainer.remove()); defer(() => creditContainer.remove());
const viewer = createApplicationViewer({ const viewer = createApplicationViewer({
container: 'cesiumContainer', container: container ?? 'cesiumContainer',
creditContainer, creditContainer,
}); });
defer(() => { defer(() => {
@@ -160,5 +181,5 @@ export async function createApplicationScene({
}); });
signal.throwIfAborted(); 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) => { const minimumDelay = new Promise((resolve) => {
resolveDelay = 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 = () => { const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return; if (disposed || signal.aborted || firstRun) return;
firstRun = initializeWelcome?.({ styleManager, dataManager }); firstRun = initializeWelcome?.({ styleManager, dataManager });
@@ -28,7 +30,7 @@ export function startApplicationChrome({
/* Restoration reports its own outcome through the controls. */ /* Restoration reports its own outcome through the controls. */
}) })
.then(() => { .then(() => {
if (disposed || signal.aborted) return; if (disposed || signal.aborted || switching) return;
loadingScreen.classList.add('hidden'); loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, { loadingScreen.addEventListener('transitionend', revealFirstRun, {
once: true, once: true,
+2
View File
@@ -16,6 +16,7 @@ import {
forEachLuminanceUniform, forEachLuminanceUniform,
keepCascadedLightIds, keepCascadedLightIds,
navaraHooks, navaraHooks,
setRenderLoopEnabled,
} from './internals.js'; } from './internals.js';
import { buildNavaraLook } from './look.js'; import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js'; import { createNavaraSharpen } from './sharpen.js';
@@ -201,6 +202,7 @@ export async function createNavaraGlobe({
NavaraEffect, NavaraEffect,
DrapedMesh, DrapedMesh,
setupMaterialForDrape, setupMaterialForDrape,
setRenderLoop: (enabled) => setRenderLoopEnabled(view, enabled),
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
+8
View File
@@ -9,6 +9,14 @@ import { patchCascadedLightCopy } from './cascadedLightCopy.js';
export const NAVARA_INTERNALS_VERSION = '0.2.1'; 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; let restoreRelease = null;
/** /**
+58 -5
View File
@@ -2,6 +2,7 @@ import {
createStandaloneApplication, createStandaloneApplication,
pageEngineKind, pageEngineKind,
} from './standalone/application.js'; } from './standalone/application.js';
import { createRendererCache } from './standalone/rendererCache.js';
import { describeError } from './standalone/errors.js'; import { describeError } from './standalone/errors.js';
import { ENGINE_STORAGE_KEY } from './app/engineChoice.js'; import { ENGINE_STORAGE_KEY } from './app/engineChoice.js';
import { useDisplayStore } from './ui/stores/display.js'; import { useDisplayStore } from './ui/stores/display.js';
@@ -13,16 +14,26 @@ const loading = document.getElementById('loading-screen');
let kind = pageEngineKind(); let kind = pageEngineKind();
let application; let application;
let busy = false; 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; kind = next;
application = createStandaloneApplication({ application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN, cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
allowQaRegistration: import.meta.env.DEV, allowQaRegistration: import.meta.env.DEV,
engineKind: next, 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() { function present() {
@@ -48,6 +59,45 @@ function select(next) {
void reconcile(true); 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 // popstate can arrive again while an engine is starting. After teardown and
// after startup, read the actual address so the latest history entry wins. // after startup, read the actual address so the latest history entry wins.
async function reconcile(remember = false) { async function reconcile(remember = false) {
@@ -55,11 +105,13 @@ async function reconcile(remember = false) {
busy = true; busy = true;
present(); present();
const previous = kind; const previous = kind;
let cover;
try { try {
while (pageEngineKind() !== kind) { while (pageEngineKind() !== kind) {
loading.classList.remove('hidden'); cover ??= keepGlobeFrame(application.getComponents().scene.engine);
renderers.retain(kind);
await application.destroy(); await application.destroy();
await start(pageEngineKind()); await start(pageEngineKind(), true);
} }
if (remember) { if (remember) {
try { try {
@@ -75,7 +127,7 @@ async function reconcile(remember = false) {
history.replaceState(history.state, '', url); history.replaceState(history.state, '', url);
try { try {
await application.destroy(); await application.destroy();
await start(previous); await start(previous, true);
application application
.getComponents() .getComponents()
.controls.styleManager._showToast(describeError(error)); .controls.styleManager._showToast(describeError(error));
@@ -83,6 +135,7 @@ async function reconcile(remember = false) {
report(recoveryError); report(recoveryError);
} }
} finally { } finally {
cover?.remove();
busy = false; busy = false;
present(); present();
} }
+9 -2
View File
@@ -333,7 +333,10 @@ export class ShareLinkManager {
/** /**
* Apply a parsed state to the camera + style manager. * 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) if (this._destroyed || !state)
return { succeeded: false, reason: 'unavailable' }; return { succeeded: false, reason: 'unavailable' };
const view = { const view = {
@@ -347,7 +350,11 @@ export class ShareLinkManager {
let cameraPromise = Promise.resolve({ let cameraPromise = Promise.resolve({
status: applyCamera ? 'superseded' : 'skipped', 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; const restoreGeneration = ++this._restoreGeneration;
let settleCamera; let settleCamera;
cameraPromise = new Promise((resolve) => { cameraPromise = new Promise((resolve) => {
+4
View File
@@ -41,6 +41,8 @@ export function createStandaloneApplication({
streetLevelProviders, streetLevelProviders,
allowQaRegistration = false, allowQaRegistration = false,
engineKind = pageEngineKind(), engineKind = pageEngineKind(),
rendererCache,
engineSwitch = false,
}) { }) {
if (owner) if (owner)
throw new Error('The standalone application already owns this page'); throw new Error('The standalone application already owns this page');
@@ -86,6 +88,7 @@ export function createStandaloneApplication({
cesiumToken, cesiumToken,
loaderStatus, loaderStatus,
engineKind, engineKind,
rendererCache,
}); });
// The catalog of this engine: on Navara only the layers with a Navara // The catalog of this engine: on Navara only the layers with a Navara
// renderer (src/app/layers/renderers.js; none yet), the others' state // renderer (src/app/layers/renderers.js; none yet), the others' state
@@ -123,6 +126,7 @@ export function createStandaloneApplication({
createControls: (context) => createControls: (context) =>
createStandaloneControls({ createStandaloneControls({
...context, ...context,
engineSwitch,
loaderStatus, loaderStatus,
placeSearch, placeSearch,
catalog, 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, services,
requestServices, requestServices,
initialShareCode = null, initialShareCode = null,
engineSwitch = false,
localePicker = true, localePicker = true,
} = {}, } = {},
) { ) {
@@ -620,7 +621,7 @@ export class StyleManager extends ShellFacade {
this._publishActiveStyle(); this._publishActiveStyle();
this._updateLocationMiniStatus(); this._updateLocationMiniStatus();
this._shareRestoration.start(); this._shareRestoration.start({ engineSwitch });
// Keep the parameter panel from overlapping toggle controls. // Keep the parameter panel from overlapping toggle controls.
this._layoutRightPanels(); this._layoutRightPanels();
+6 -3
View File
@@ -153,7 +153,7 @@ export class ShareRestoration {
this.shareLinkManager = shareLinkManager; this.shareLinkManager = shareLinkManager;
this._initialShareState = shareLinkManager.parseInitialHash(); this._initialShareState = shareLinkManager.parseInitialHash();
} }
start() { start({ engineSwitch = false } = {}) {
// Restore from URL hash if present // Restore from URL hash if present
const savedState = this._initialShareState; const savedState = this._initialShareState;
this._initialShareRestorePromise = savedState this._initialShareRestorePromise = savedState
@@ -169,6 +169,7 @@ export class ShareRestoration {
this.navigation._beginDeferredNavigation('shared view', { this.navigation._beginDeferredNavigation('shared view', {
cancelPendingSelection: false, cancelPendingSelection: false,
}); });
const delay = engineSwitch ? 0 : 1500;
this._initialShareRestoreTimeout = setTimeout(() => { this._initialShareRestoreTimeout = setTimeout(() => {
this._initialShareRestoreTimeout = null; this._initialShareRestoreTimeout = null;
if (this._disposed) return; if (this._disposed) return;
@@ -178,11 +179,12 @@ export class ShareRestoration {
this.navigation._reassertNavigationHandoff(generation); this.navigation._reassertNavigationHandoff(generation);
void this._runShareRestore(savedState, { void this._runShareRestore(savedState, {
applyCamera, applyCamera,
instant: engineSwitch,
navigationToken: generation, navigationToken: generation,
layersPromise: this._layerStateRestorePromise, layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result), settle: (result) => this._settleInitialShareRestore(result),
}); });
}, 1500); }, delay);
} else { } else {
this.syncShareState(); this.syncShareState();
if (this.shareLinkManager?.rejectedShareCode) if (this.shareLinkManager?.rejectedShareCode)
@@ -236,7 +238,7 @@ export class ShareRestoration {
*/ */
async _runShareRestore( async _runShareRestore(
state, state,
{ applyCamera, navigationToken, layersPromise, settle }, { applyCamera, instant = false, navigationToken, layersPromise, settle },
) { ) {
const generation = this._shareRestoreGeneration; const generation = this._shareRestoreGeneration;
const current = () => const current = () =>
@@ -244,6 +246,7 @@ export class ShareRestoration {
try { try {
const share = await this.shareLinkManager.applyState(state, { const share = await this.shareLinkManager.applyState(state, {
applyCamera, applyCamera,
instant,
navigationToken, navigationToken,
}); });
const layers = await (layersPromise || Promise.resolve([])); const layers = await (layersPromise || Promise.resolve([]));
+13
View File
@@ -122,6 +122,19 @@ body {
left: 0; 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 /* Unified world-overlay host: detection uses one host-owned blend surface
beneath the shared text/card canvas; both share one frame path. The root 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 holds ONLY the card canvas — it is a stacking context (z-index below), and