diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index df11714bd..d9a5a8ce1 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -7,10 +7,11 @@ * The renderer it makes is transitional. Its ports are the shared Cesium * ports (`ports.js`), the ones the conformance renderer is checked with, * less the members that would not be true of the application yet: the - * globe (it draws photoreal tiles the port does not know) and - * `whenSettled` (it would not wait for them). Those arrive with the layer - * families that need them (phase 2 of the renderer plan); - * `checkRendererShape` lists what is still missing. + * globe (it draws photoreal tiles the port does not know). Those arrive + * with the layer families that need them (phase 2 of the renderer plan); + * `checkRendererShape` lists what is still missing. `whenSettled` waits + * for the globe, the scene's 3D tiles and what the draw sets hold, not for + * what layers still draw through the viewer themselves. * Until a family is ported, its code reaches the Cesium viewer through * `cesiumViewerOf`, never through a renderer member, so no engine type * crosses the contract and each call marks code still to port. Ported code @@ -27,8 +28,7 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; * @typedef {Pick} + * | 'credits' | 'whenSettled' | 'on' | 'resize' | 'destroy'>} * TransitionalRenderer */ @@ -118,6 +118,7 @@ export function createCesiumRendererPlugin({ quality: ports.quality, capture: ports.capture, credits: ports.credits, + whenSettled: ports.whenSettled, on: ports.on, resize() { viewer.resize(); diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index b351b8d70..d21f5fc34 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -321,5 +321,4 @@ const EXPECTED_MISSING = [ 'renderer.globe', 'renderer.readPixels', 'renderer.releaseUnused', - 'renderer.whenSettled', ].map((member) => `${member} is missing`); diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 4fd652a1b..1f380eaef 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -24,6 +24,7 @@ import { billboardLoadError, lastRenderTime, lastSeenCamera, + onEngineWorkCompleted, renderPending, } from './internals.js'; @@ -1147,7 +1148,7 @@ export function createCesiumPorts(widget, { capabilities }) { ); } - /** The live model sets, for a renderer's own `whenSettled`. */ + /** The live model sets, for `whenSettled`. */ const modelSets = () => [...sets.values()].filter((r) => r.kind === 'models' && r.host); @@ -1170,6 +1171,148 @@ export function createCesiumPorts(widget, { capabilities }) { return loading ? 'loading' : 'settled'; } + /** A hidden globe loads nothing, so it is never what is still loading. */ + const globeLoaded = () => + !scene.globe?.show || scene.globe.tilesLoaded !== false; + /** Whether every shown 3D tileset has loaded what is in view. */ + const tilesLoaded = () => + tilesets().every((tileset) => !tileset.show || tileset.tilesLoaded); + + // --- Settling -------------------------------------------------------------- + + // Idle watches in progress; disposal ends them rather than leave them + // waiting on a scene that will not tick again. + const watching = new Set(); + + /** How long the loop must stay idle, drawing nothing, to be settled. */ + const IDLE_MS = 100; + /** How long without a tick a loop counts as stopped. */ + const STALL_MS = 250; + + /** + * Watches the loop, without asking for frames, for at least two whole + * ticks and `IDLE_MS`. Resolves `'idle'` if in that time it drew nothing + * of its own accord and `busy()` stayed false; `'busy'` as soon as it did; + * `'ended'` if the ports are disposed or `end` passes first. A draw while + * a hold keeps the loop drawing every frame is the hold's, not the + * scene's own; `busy` is asked at each tick's start, before Cesium decides + * whether it draws. + * @param {number} end + * @param {() => boolean} busy Whether work landed that will draw again. + */ + function watchIdle(end, busy) { + return new Promise((resolve) => { + const from = performance.now(); + let ticks = 0; + const offDraw = scene.postRender.addEventListener(() => { + if (scene.requestRenderMode) finish('busy'); + }); + // Each preUpdate also ends the tick before it. + const offTick = scene.preUpdate.addEventListener(() => { + if (busy()) return finish('busy'); + ticks += 1; + if (ticks > 2 && performance.now() - from >= IDLE_MS) finish('idle'); + }); + const timer = setTimeout( + () => finish('ended'), + Math.max(0, end - performance.now()), + ); + watching.add(finish); + function finish(outcome) { + watching.delete(finish); + offDraw(); + offTick(); + clearTimeout(timer); + resolve(outcome); + } + }); + } + + async function whenSettled({ timeoutMs = 20000 } = {}) { + const end = performance.now() + timeoutMs; + // Cesium redraws on its own after each request or worker task it + // completes (a terrain picker build after the camera moved, say), which + // no loading flag shows; settled means that has stopped too. Every + // scene in the page redraws for any completion, so another scene's work + // keeps this one from settling: it is drawing. + let completed = 0; + // Whether the last look found the engine still at work. + let busy = false; + // When the loop last ticked: one that stopped (suspended, say) is named + // in a timeout, not one that merely ran out of time mid-frame. + let lastTick = performance.now(); + const offTick = scene.preUpdate.addEventListener( + () => (lastTick = performance.now()), + ); + const off = onEngineWorkCompleted(Cesium, () => (completed += 1)); + try { + let quiet = 0; + while (performance.now() < end) { + completed = 0; + if (disposed) return { state: 'failed', blockers: ['destroyed'] }; + // Bounded by the time left: a suspended loop draws nothing. + const drawn = await nextPostRender({ + timeoutMs: Math.max(0, end - performance.now()), + }).then( + () => true, + () => false, + ); + if (disposed) return { state: 'failed', blockers: ['destroyed'] }; + if (!drawn) break; + if (modelSets().some((r) => r.host.failed > 0)) + return { state: 'failed', blockers: ['models'] }; + const icons = iconState(); + if (icons === 'failed') return { state: 'failed', blockers: ['icons'] }; + quiet = loaded() && !idleBlocked() ? quiet + 1 : 0; + if (quiet < 3) continue; + // Then the loop left alone must draw nothing by itself for a while: + // a worker's first task can take tens of milliseconds, and what is + // in flight cannot be counted. Something that starts loading + // meanwhile ends the watch too; the next pass sees it, or its + // failure. + const outcome = await watchIdle(end, () => !loaded() || idleBlocked()); + if (outcome === 'idle') return { state: 'settled', blockers: [] }; + if (outcome !== 'busy') break; + // Work or a draw, unless it was something loading. + busy = loaded(); + quiet = 0; + } + if (disposed) return { state: 'failed', blockers: ['destroyed'] }; + const blockers = []; + if (!globeLoaded()) blockers.push('globe'); + if (!tilesLoaded()) blockers.push('tiles'); + if (modelSets().some((r) => r.host.loading > 0)) blockers.push('models'); + if (iconState() !== 'settled') blockers.push('icons'); + if (busy) blockers.push('redraw'); + if (performance.now() - lastTick >= STALL_MS) blockers.push('frames'); + return { state: 'timeout', blockers }; + } finally { + off(); + offTick(); + } + + /** Whether what is in view has loaded, nothing failing. */ + function loaded() { + const icons = iconState(); + return ( + globeLoaded() && + tilesLoaded() && + icons === 'settled' && + !modelSets().some((r) => r.host.loading > 0 || r.host.failed > 0) + ); + } + + /** + * Work since the last look that will draw again, or a draw queued (one a + * hold makes anyway is the hold's). + */ + function idleBlocked() { + busy = completed > 0 || (scene.requestRenderMode && renderPending(scene)); + completed = 0; + return busy; + } + } + return { /** The renderer's own events: `error`, `contextLost`, `contextRestored`. */ on(event, cb) { @@ -1184,12 +1327,11 @@ export function createCesiumPorts(widget, { capabilities }) { effects: effectsPart.port, quality: qualityPart.port, capture, - modelSets, - iconState, - nextPostRender, + whenSettled, /** Detach from the scene; the widget's owner destroys the widget. */ dispose() { disposed = true; + for (const finish of [...watching]) finish('ended'); // The tick under way is no longer seen through: it is not claimed. settleTick(false); for (const off of removers) off(); diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index f2d4c571b..421959418 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -712,3 +712,61 @@ test('setTime moves scene time and draws it, leaving the clock running or not', assert.deepEqual(ports.frame.diagnostics().pendingReasons, ['time']); ports.dispose(); }); + +test('whenSettled waits for shown 3D tiles, not for a hidden globe', async () => { + const { widget, scene, raise } = tickingWidget(); + const list = []; + scene.primitives = listedPrimitives(list); + scene.globe = { show: false, tilesLoaded: false }; + // Each asked-for frame is drawn soon after. + scene.requestRender = () => setTimeout(() => raise('postRender'), 1); + const ports = createCesiumPorts(widget, { capabilities: all }); + const tileset = Object.create(Cesium.Cesium3DTileset.prototype); + Object.defineProperty(tileset, 'tilesLoaded', { value: false }); + tileset.show = true; + list.push(tileset); + const result = await ports.whenSettled({ timeoutMs: 60 }); + assert.equal(result.state, 'timeout'); + assert.ok(result.blockers.includes('tiles'), result.blockers.join()); + assert.ok(!result.blockers.includes('globe'), 'a hidden globe loads nothing'); + tileset.show = false; + scene.globe.show = true; + const shown = await ports.whenSettled({ timeoutMs: 60 }); + assert.ok(!shown.blockers.includes('tiles'), 'hidden tiles load nothing'); + assert.ok(shown.blockers.includes('globe'), 'a shown globe still loading'); + ports.dispose(); +}); + +test('whenSettled under way fails as destroyed once the ports are disposed', async () => { + const { widget, scene, raise } = tickingWidget(); + scene.primitives = listedPrimitives([]); + scene.requestRender = () => setTimeout(() => raise('postRender'), 1); + const ports = createCesiumPorts(widget, { capabilities: all }); + const pending = ports.whenSettled({ timeoutMs: 5000 }); + setTimeout(() => ports.dispose(), 5); + assert.deepEqual(await pending, { state: 'failed', blockers: ['destroyed'] }); +}); + +test('whenSettled settles once shown tiles have loaded under a hidden globe', async () => { + const { widget, scene, raise } = tickingWidget(); + const list = []; + scene.primitives = listedPrimitives(list); + scene.globe = { show: false, tilesLoaded: false }; + scene.requestRender = () => setTimeout(() => raise('postRender'), 1); + const tileset = Object.create(Cesium.Cesium3DTileset.prototype); + Object.defineProperty(tileset, 'tilesLoaded', { value: true }); + tileset.show = true; + list.push(tileset); + const ports = createCesiumPorts(widget, { capabilities: all }); + // The loop ticks without drawing. + const ticker = setInterval(() => raise('preUpdate'), 5); + try { + assert.deepEqual(await ports.whenSettled({ timeoutMs: 2000 }), { + state: 'settled', + blockers: [], + }); + } finally { + clearInterval(ticker); + ports.dispose(); + } +}); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 1fa54cb4a..78997be70 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -18,7 +18,6 @@ */ import * as Cesium from 'cesium'; import { createCesiumInput } from './input.js'; -import { onEngineWorkCompleted, renderPending } from './internals.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; export { toCartesian, toColor, toImage } from './ports.js'; @@ -125,138 +124,6 @@ export async function createCesiumSceneRenderer({ // --- Renderer -------------------------------------------------------------- - let destroyed = false; - // Idle watches in progress; destroy ends them rather than leave them - // waiting on a scene that will not tick again. - const watching = new Set(); - - /** How long the loop must stay idle, drawing nothing, to be settled. */ - const IDLE_MS = 100; - /** How long without a tick a loop counts as stopped. */ - const STALL_MS = 250; - - /** - * Watches the loop, without asking for frames, for at least two whole - * ticks and `IDLE_MS`. Resolves `'idle'` if in that time it drew nothing - * of its own accord and `busy()` stayed false; `'busy'` as soon as it did; - * `'ended'` if the renderer is destroyed or `end` passes first. A draw while a hold keeps - * the loop drawing every frame is the hold's, not the scene's own; `busy` - * is asked at each tick's start, before Cesium decides whether it draws. - * @param {number} end - * @param {() => boolean} busy Whether work landed that will draw again. - */ - function watchIdle(end, busy) { - return new Promise((resolve) => { - const from = performance.now(); - let ticks = 0; - const offDraw = scene.postRender.addEventListener(() => { - if (scene.requestRenderMode) finish('busy'); - }); - // Each preUpdate also ends the tick before it. - const offTick = scene.preUpdate.addEventListener(() => { - if (busy()) return finish('busy'); - ticks += 1; - if (ticks > 2 && performance.now() - from >= IDLE_MS) finish('idle'); - }); - const timer = setTimeout( - () => finish('ended'), - Math.max(0, end - performance.now()), - ); - watching.add(finish); - function finish(outcome) { - watching.delete(finish); - offDraw(); - offTick(); - clearTimeout(timer); - resolve(outcome); - } - }); - } - - async function whenSettled({ timeoutMs = 20000 } = {}) { - const models = ports.modelSets; - const end = performance.now() + timeoutMs; - // Cesium redraws on its own after each request or worker task it - // completes (a terrain picker build after the camera moved, say), which - // no loading flag shows; settled means that has stopped too. Every - // scene in the page redraws for any completion, so another scene's work - // keeps this one from settling: it is drawing. - let completed = 0; - // Whether the last look found the engine still at work. - let busy = false; - // When the loop last ticked: one that stopped (suspended, say) is named - // in a timeout, not one that merely ran out of time mid-frame. - let lastTick = performance.now(); - const offTick = scene.preUpdate.addEventListener( - () => (lastTick = performance.now()), - ); - const off = onEngineWorkCompleted(Cesium, () => (completed += 1)); - try { - let quiet = 0; - while (performance.now() < end) { - completed = 0; - if (destroyed) return { state: 'failed', blockers: ['destroyed'] }; - // Bounded by the time left: a suspended loop draws nothing. - const drawn = await ports - .nextPostRender({ timeoutMs: Math.max(0, end - performance.now()) }) - .then( - () => true, - () => false, - ); - if (destroyed) return { state: 'failed', blockers: ['destroyed'] }; - if (!drawn) break; - if (models().some((r) => r.host.failed > 0)) - return { state: 'failed', blockers: ['models'] }; - const icons = ports.iconState(); - if (icons === 'failed') return { state: 'failed', blockers: ['icons'] }; - quiet = loaded() && !idleBlocked() ? quiet + 1 : 0; - if (quiet < 3) continue; - // Then the loop left alone must draw nothing by itself for a while: - // a worker's first task can take tens of milliseconds, and what is - // in flight cannot be counted. Something that starts loading - // meanwhile ends the watch too; the next pass sees it, or its - // failure. - const outcome = await watchIdle(end, () => !loaded() || idleBlocked()); - if (outcome === 'idle') return { state: 'settled', blockers: [] }; - if (outcome !== 'busy') break; - // Work or a draw, unless it was something loading. - busy = loaded(); - quiet = 0; - } - if (destroyed) return { state: 'failed', blockers: ['destroyed'] }; - const blockers = []; - if (!scene.globe.tilesLoaded) blockers.push('globe'); - if (models().some((r) => r.host.loading > 0)) blockers.push('models'); - if (ports.iconState() !== 'settled') blockers.push('icons'); - if (busy) blockers.push('redraw'); - if (performance.now() - lastTick >= STALL_MS) blockers.push('frames'); - return { state: 'timeout', blockers }; - } finally { - off(); - offTick(); - } - - /** Whether what is in view has loaded, nothing failing. */ - function loaded() { - const icons = ports.iconState(); - return ( - scene.globe.tilesLoaded && - icons === 'settled' && - !models().some((r) => r.host.loading > 0 || r.host.failed > 0) - ); - } - - /** - * Work since the last look that will draw again, or a draw queued (one a - * hold makes anyway is the hold's). - */ - function idleBlocked() { - busy = completed > 0 || (scene.requestRenderMode && renderPending(scene)); - completed = 0; - return busy; - } - } - const renderer = { id: 'cesium', contractVersion: '0', @@ -277,12 +144,10 @@ export async function createCesiumSceneRenderer({ quality: ports.quality, capture: ports.capture, credits: ports.credits, - whenSettled, + whenSettled: ports.whenSettled, resize: () => widget.resize(), on: ports.on, destroy() { - destroyed = true; - for (const finish of [...watching]) finish('ended'); input.dispose(); ports.dispose(); widget.destroy();