From 48f06c68c830c58e52d8433c88f41feaaf5c4700 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 17:11:25 -0800 Subject: [PATCH] Complete the Cesium frame port The frame port now does everything the contract asks of it on Cesium: preUpdate says whether the tick draws (Cesium's own render test, read live until the tick is over), holds are counted per reason, and suspend, target fps, renderNow and diagnostics are real. Releasing the last hold asks for one more frame. whenSettled also waits until Cesium stops redrawing on its own. Each request or worker task Cesium completes queues a redraw (a terrain picker build after the camera moved, say), which no loading flag shows, so it now waits for quiet frames and then a short stretch in which the loop draws nothing by itself. It is bounded by its timeout, names a stopped loop, and ends on destroy. The app renderer still leaves out the members that steer the loop: the render governor and the page's visibility handling own those settings until they move onto the port. The new frame.prediction check compares the preUpdate answer with what each tick drew. --- src/render/cesium/appRenderer.js | 23 ++- src/render/cesium/appRenderer.test.mjs | 8 +- src/render/cesium/harness.js | 32 ++-- src/render/cesium/internals.js | 65 +++++++ src/render/cesium/internals.test.mjs | 81 ++++++++ src/render/cesium/ports.js | 185 +++++++++++++++++- src/render/cesium/ports.test.mjs | 256 +++++++++++++++++++++++++ src/render/cesium/sceneRenderer.js | 143 ++++++++++++-- src/render/conformance/checks/ports.js | 92 +++++++-- src/render/contract/frame.d.ts | 7 +- src/render/contract/renderer.d.ts | 5 +- 11 files changed, 842 insertions(+), 55 deletions(-) diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index fc80c8743..bf548bb51 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -8,8 +8,11 @@ * 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), `whenSettled` - * (it would not wait for them) and `frame.setTime` (it would stop the - * application's clock). Those arrive with the layer families that need + * (it would not wait for them), `frame.setTime` (it would stop the + * application's clock), and the frame members that steer the loop + * (`holdContinuous`, `suspend`, `setTargetFps`) with the `diagnostics` that + * report them: `renderGovernor.js` and the page's visibility handling still + * own those settings, and two owners would undo each other. Those arrive with the layer families that need * them (phase 2 of the renderer plan); `checkRendererShape` lists what is * still missing. * Until a family is ported, its code reaches the Cesium viewer through @@ -20,6 +23,14 @@ import { createApplicationViewer } from '../../app/viewer.js'; import { CONTRACT_VERSION } from '../contract/index.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; +/** Frame members the application's own loop code still owns. */ +const LOOP_OWNED = new Set([ + 'diagnostics', + 'holdContinuous', + 'setTargetFps', + 'suspend', +]); + /** * The members a transitional renderer has today. * @typedef {Pick !LOOP_OWNED.has(name), + ), + ), + ), draw: ports.draw, camera: ports.camera, query: ports.query, diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 9ebda81ce..d3b96f5b8 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -146,12 +146,17 @@ test('work pending at destroy settles and lets go of the scene', async (t) => { await renderer.frame.renderOnce(); const waiting = await plugin.create({ container: {} }); + const standing = viewers[1].listeners.size; await assert.rejects( waiting.frame.renderOnce({ timeoutMs: 5 }), /timed out/, 'a frame that never comes times out when asked to', ); - assert.equal(viewers[1].listeners.size, 4, 'and lets go of its listener'); + assert.equal( + viewers[1].listeners.size, + standing, + 'and lets go of its listener', + ); waiting.destroy(); // A model that finishes loading after destroy is destroyed, not added. @@ -339,7 +344,6 @@ const EXPECTED_MISSING = [ 'draw.on', 'frame.diagnostics', 'frame.holdContinuous', - 'frame.mode', 'frame.setTargetFps', 'frame.setTime', 'frame.suspend', diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 9feb8fa18..d796e715d 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -19,8 +19,11 @@ export const CESIUM_CHECKS = new Set([ 'draw.models', 'draw.pick', 'draw.sets', + 'frame.demand', 'frame.order', + 'frame.prediction', 'frame.renderOnce', + 'frame.suspend', 'frame.time', 'globe.sky', 'query.heights', @@ -31,25 +34,24 @@ export const CESIUM_CHECKS = new Set([ 'renderer.whenSettled', ]); -/** A tick fails the check rather than hang it when the loop has stopped. */ -const TICK_TIMEOUT_MS = 5000; +/** A frame fails the check rather than hang it when frames stop. */ +const FRAME_TIMEOUT_MS = 5000; /** - * One tick of the render loop, through the contract: it resolves once the - * next tick (drawn or not) has run, so loads and timers progress between - * ticks. It asks for no frame itself, so a check's own `requestRender` is - * what makes a tick draw. + * One frame of the page: it resolves once the render loop's tick for the + * next animation frame (drawn or not, or none while suspended) has run, so + * loads and timers progress between frames. Cesium's loop asks for its + * animation frame before this does, so its tick runs first. It asks for no + * render itself: a check's own `requestRender` is what makes a tick draw. */ -function nextTick(r) { +function nextFrame() { return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - off(); - reject(new Error('the render loop did not tick')); - }, TICK_TIMEOUT_MS); - const off = r.frame.on('preUpdate', () => { - off(); + const timer = setTimeout( + () => reject(new Error('no animation frame came')), + FRAME_TIMEOUT_MS, + ); + requestAnimationFrame(() => { clearTimeout(timer); - // The rest of the tick (render, postRender) runs in the same task. setTimeout(resolve, 0); }); }); @@ -90,7 +92,7 @@ export function createCesiumHarness({ name = 'cesium', model } = {}) { }; return renderer; }, - frame: nextTick, + frame: nextFrame, now: () => performance.now(), toEcef, tolerance: DEFAULT_TOLERANCE, diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index ab6386cfc..49fa1cdd4 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -66,6 +66,71 @@ export function closeRenderLoopLatch(viewer) { if (widget) widget._renderLoopRunning = false; } +/** + * Whether the scene has a render pending that its next tick will draw in + * request-render mode: a request, or a depth-buffer or HDR change. With + * `cameraMovedSinceRender` and the time check, this is the test + * `Scene.render` makes after `preUpdate`, so the frame port can say at + * `preUpdate` whether the tick draws. + * @param {object} scene Cesium Scene. + * @returns {boolean} + */ +export function renderPending(scene) { + return Boolean( + scene._renderRequested || scene._logDepthBufferDirty || scene._hdrDirty, + ); +} + +/** + * Calls `cb` each time Cesium completes a request or a worker task, in any + * scene. A scene redraws on its own after each (its `requestRenderAfterFrame` + * listeners): a terrain picker build after the camera moves, say, which no + * loading flag shows. `whenSettled` uses it to tell when that work has + * stopped. Both events are marked private. + * @param {{RequestScheduler: object, TaskProcessor: object}} cesium The + * Cesium namespace. + * @param {() => void} cb + * @returns {() => void} Removes the listeners. + */ +export function onEngineWorkCompleted({ RequestScheduler, TaskProcessor }, cb) { + const offs = [ + RequestScheduler.requestCompletedEvent.addEventListener(() => cb()), + TaskProcessor.taskCompletedEvent.addEventListener(() => cb()), + ]; + return () => offs.forEach((off) => off()); +} + +/** + * Runs `fn` at the end of the scene's current tick, drawn or not: Cesium's + * after-render queue, which every `Scene.render` empties after its draw (if + * any) and before `postRender`. `fn` must return nothing; a truthy return + * asks for another frame. + * @param {object} scene Cesium Scene. + * @param {() => void} fn + */ +export function atEndOfTick(scene, fn) { + scene._frameState.afterRender.push(fn); +} + +/** + * The scene time of the last drawn frame, for `maximumRenderTimeChange`. + * @param {object} scene Cesium Scene. + * @returns {object|undefined} Cesium JulianDate. + */ +export function lastRenderTime(scene) { + return scene._lastRenderTime; +} + +/** + * The camera as the scene last saw it move, which `Scene.render` compares + * the camera with to decide whether it moved. + * @param {object} scene Cesium Scene. + * @returns {object|undefined} Cesium Camera. + */ +export function lastSeenCamera(scene) { + return scene._view?._cameraClone; +} + /** * The scene's WebGL context, for diagnostics that read the GPU directly. * @param {object} scene Cesium Scene. diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index dc78d1671..fe74af373 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { + atEndOfTick, billboardCollectionOf, billboardLoadError, cameraFlightActive, @@ -12,7 +13,11 @@ import { clusterPointCollection, commandExtents, defaultView, + lastRenderTime, + lastSeenCamera, + onEngineWorkCompleted, pickOffscreenView, + renderPending, updateCameraChanged, webglContext, } from './internals.js'; @@ -41,6 +46,13 @@ test('every private field Core reaches is still declared by the installed Cesium [WIDGETS, 'Viewer/Viewer.js', '_cesiumWidget'], [ENGINE, 'Renderer/Context.js', '_gl'], [ENGINE, 'Scene/Camera.js', '_currentFlight'], + [ENGINE, 'Scene/Scene.js', '_renderRequested'], + [ENGINE, 'Scene/Scene.js', '_logDepthBufferDirty'], + [ENGINE, 'Scene/Scene.js', '_hdrDirty'], + [ENGINE, 'Scene/Scene.js', '_lastRenderTime'], + [ENGINE, 'Scene/Scene.js', '_view'], + [ENGINE, 'Scene/View.js', '_cameraClone'], + [ENGINE, 'Scene/Scene.js', '_frameState'], ]; const missing = fields.filter( ([root, file, field]) => @@ -56,6 +68,39 @@ test('every private field Core reaches is still declared by the installed Cesium 'utf8', ); assert.match(billboard, /\n {2}loadError: \{/, 'Billboard#loadError'); + const read = (file) => fs.readFileSync(path.join(ENGINE, file), 'utf8'); + assert.match( + read('Core/RequestScheduler.js'), + /\nRequestScheduler\.requestCompletedEvent = /, + ); + assert.match( + read('Core/TaskProcessor.js'), + /\nTaskProcessor\.taskCompletedEvent = /, + ); + assert.match(read('Scene/FrameState.js'), /this\.afterRender = \[\]/); + // What makes them matter: each completion redraws the scene. + const scene = read('Scene/Scene.js'); + // Every tick empties the after-render queue, outside its draw. + const render = scene.slice( + scene.indexOf('Scene.prototype.render = function'), + ); + const body = render.slice(0, render.indexOf('\n};')); + assert.match( + body, + /\n {2}callAfterRenderFunctions\(this\);\n/, + 'callAfterRenderFunctions runs at the top level of Scene#render', + ); + for (const event of [ + 'RequestScheduler.requestCompletedEvent', + 'TaskProcessor.taskCompletedEvent', + ]) + assert.match( + scene, + new RegExp( + `${event.replace('.', '\\.')}\\.addEventListener\\(\\s*requestRenderAfterFrame\\(this\\)`, + ), + event, + ); }); test('the helpers read and write exactly those fields', () => { @@ -81,6 +126,15 @@ test('the helpers read and write exactly those fields', () => { closeRenderLoopLatch({ _cesiumWidget: widget }); assert.equal(widget._renderLoopRunning, false); closeRenderLoopLatch({}); + assert.equal(renderPending({}), false); + assert.equal(renderPending({ _renderRequested: true }), true); + assert.equal(renderPending({ _hdrDirty: true }), true); + assert.equal(renderPending({ _logDepthBufferDirty: true }), true); + const time = {}; + assert.equal(lastRenderTime({ _lastRenderTime: time }), time); + const clone = {}; + assert.equal(lastSeenCamera({ _view: { _cameraClone: clone } }), clone); + assert.equal(lastSeenCamera({}), undefined); const error = new Error('404'); assert.equal(billboardLoadError({ loadError: error }), error); const gl = {}; @@ -90,6 +144,33 @@ test('the helpers read and write exactly those fields', () => { let checked = 0; updateCameraChanged({ _updateCameraChanged: () => (checked += 1) }); assert.equal(checked, 1); + const afterRender = []; + const fn = () => {}; + atEndOfTick({ _frameState: { afterRender } }, fn); + assert.deepEqual(afterRender, [fn]); + const event = () => { + const listeners = new Set(); + return { + addEventListener(cb) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + raise: () => listeners.forEach((cb) => cb('ignored')), + }; + }; + const RequestScheduler = { requestCompletedEvent: event() }; + const TaskProcessor = { taskCompletedEvent: event() }; + const calls = []; + const off = onEngineWorkCompleted( + { RequestScheduler, TaskProcessor }, + (...args) => calls.push(args), + ); + RequestScheduler.requestCompletedEvent.raise(); + TaskProcessor.taskCompletedEvent.raise(); + assert.deepEqual(calls, [[], []]); + off(); + TaskProcessor.taskCompletedEvent.raise(); + assert.equal(calls.length, 2); }); test('private Cesium fields are reached only through internals.js', () => { diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index dc4494f1c..74f920bd1 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -12,7 +12,13 @@ */ import * as Cesium from 'cesium'; import { heightsFor } from '../../queries/heights.js'; -import { billboardLoadError } from './internals.js'; +import { + atEndOfTick, + billboardLoadError, + lastRenderTime, + lastSeenCamera, + renderPending, +} from './internals.js'; /** The contract's colour as Cesium's. */ export function toColor(color, alpha = 1) { @@ -46,6 +52,52 @@ const ANCHORS = { top: Cesium.VerticalOrigin.TOP, }; +const scratchPosition0 = new Cesium.Cartesian3(); +const scratchPosition1 = new Cesium.Cartesian3(); + +/** Cesium's own test (`View.js`) for whether the camera moved. */ +function cameraEqual(camera0, camera1, epsilon) { + const maximumPositionComponent = Math.max( + Cesium.Cartesian3.maximumComponent( + Cesium.Cartesian3.abs(camera0.position, scratchPosition0), + ), + Cesium.Cartesian3.maximumComponent( + Cesium.Cartesian3.abs(camera1.position, scratchPosition1), + ), + ); + const scalar = 1 / Math.max(1, maximumPositionComponent); + Cesium.Cartesian3.multiplyByScalar( + camera0.position, + scalar, + scratchPosition0, + ); + Cesium.Cartesian3.multiplyByScalar( + camera1.position, + scalar, + scratchPosition1, + ); + return ( + Cesium.Cartesian3.equalsEpsilon( + scratchPosition0, + scratchPosition1, + epsilon, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera0.direction, + camera1.direction, + epsilon, + ) && + Cesium.Cartesian3.equalsEpsilon(camera0.up, camera1.up, epsilon) && + Cesium.Cartesian3.equalsEpsilon(camera0.right, camera1.right, epsilon) && + Cesium.Matrix4.equalsEpsilon( + camera0.transform, + camera1.transform, + epsilon, + ) && + camera0.frustum.equalsEpsilon(camera1.frustum, epsilon) + ); +} + /** * What a Cesium scene can do, as contract capabilities; `only` limits them * to those it lists. Depth picks, height samples and ray casts all need @@ -76,11 +128,27 @@ export function createCesiumPorts(widget, { capabilities }) { const frameListeners = { preUpdate: [], preRender: [], postRender: [] }; let frames = 0; + let rendered = 0; // Every tick raises preUpdate, drawn or not; the phases of a tick share // its clock reading. The first tick counts from when the loop was made. let tickNow = performance.now(); let dt = 0; + // The preUpdate FrameTime of the tick under way: its `rendering` reads + // live until the tick has drawn or not, then holds that answer. + let liveTick = null; + const settleTick = (drawn) => { + if (liveTick) + Object.defineProperty(liveTick, 'rendering', { + value: drawn, + enumerable: true, + }); + liveTick = null; + }; const tick = () => { + settleTick(false); + // A tick that does not draw settles at its end; one that does, at + // preRender, before this runs. + atEndOfTick(scene, () => void settleTick(false)); const now = performance.now(); dt = (now - tickNow) / 1000; tickNow = now; @@ -95,18 +163,79 @@ export function createCesiumPorts(widget, { capabilities }) { ).getTime(), now: tickNow, dt, - rendering: phase !== 'preUpdate', + rendering: true, }; + // At preUpdate, Cesium has not decided yet: this applies its test when + // read, so it counts what earlier listeners did (a camera move, a + // request, a hold) but not what later ones will. + if (phase === 'preUpdate') { + Object.defineProperty(t, 'rendering', { + enumerable: true, + configurable: true, + get: () => willDraw(time), + }); + liveTick = t; + } for (const cb of frameListeners[phase].slice()) cb(t); }; + // Reasons given before a frame starts drawing are answered by it; one + // given while it draws waits for the next, as Cesium's own request does. + const pendingReasons = []; + let answering = 0; + const drawing = () => { + settleTick(true); + answering = pendingReasons.length; + }; + const drew = () => { + rendered += 1; + pendingReasons.splice(0, answering); + answering = 0; + }; let disposed = false; const removers = [ scene.preUpdate.addEventListener(tick), scene.preUpdate.addEventListener(onPhase('preUpdate')), + scene.preRender.addEventListener(drawing), scene.preRender.addEventListener(onPhase('preRender')), + scene.postRender.addEventListener(drew), scene.postRender.addEventListener(onPhase('postRender')), ]; + /** + * Whether this tick draws: the test `Scene.render` makes just after + * `preUpdate`, made here so `preUpdate` can say. Not seen: a request made + * in between, by a `camera.moveEnd` listener (Cesium raises it there); no + * Core listener does that, and the answer is corrected at preRender. + */ + function willDraw(time) { + if (!scene.requestRenderMode || renderPending(scene)) return true; + if (scene.mode === Cesium.SceneMode.MORPHING) return true; + const seen = lastSeenCamera(scene); + if (!seen || !cameraEqual(camera, seen, Cesium.Math.EPSILON15)) return true; + const last = lastRenderTime(scene); + const max = scene.maximumRenderTimeChange; + return ( + last !== undefined && + max !== undefined && + Math.abs( + Cesium.JulianDate.secondsDifference( + last, + time ?? Cesium.JulianDate.now(), + ), + ) > max + ); + } + + // Holds keep the loop drawing every frame; counted per reason. + const holds = new Map(); + function applyHolds() { + const continuous = holds.size > 0; + if (scene.requestRenderMode === !continuous) return; + scene.requestRenderMode = !continuous; + } + let suspended = false; + let targetFps = 0; + // Pending one-shot renders; disposal settles them rather than leave them // attached to a scene that will not render again. const pendingRenders = new Set(); @@ -142,9 +271,55 @@ export function createCesiumPorts(widget, { capabilities }) { if (i >= 0) frameListeners[phase].splice(i, 1); }; }, - requestRender: () => scene.requestRender(), - renderNow: () => scene.render(widget.clock.currentTime), + requestRender(reason = 'unspecified') { + // During a draw the reasons it answers are spoken for: a reason given + // again waits for the next frame. + if (!pendingReasons.includes(reason, answering)) + pendingReasons.push(reason); + scene.requestRender(); + }, + holdContinuous(reason) { + holds.set(reason, (holds.get(reason) ?? 0) + 1); + applyHolds(); + let released = false; + return () => { + if (released) return; + released = true; + const left = holds.get(reason) - 1; + if (left > 0) holds.set(reason, left); + else holds.delete(reason); + applyHolds(); + // What changed in the last held frame still gets drawn. + if (holds.size === 0) scene.requestRender(); + }; + }, + /** Cesium can idle, so it draws on demand. */ + mode: () => 'demand', + diagnostics: () => ({ + mode: 'demand', + frames, + rendered, + pendingReasons: [...pendingReasons], + holds: [...holds].flatMap(([reason, n]) => Array(n).fill(reason)), + suspended, + targetFps, + }), + suspend(hidden) { + if (Boolean(hidden) === suspended) return; + suspended = Boolean(hidden); + // The loop's queued callback sees the flag and stops without a tick. + // The latch stays as Cesium keeps it: a resume before that callback + // runs keeps the one loop rather than start a second. + widget.useDefaultRenderLoop = !suspended; + if (!suspended) scene.requestRender(); + }, + // Draws even when nothing asked for a frame. + renderNow: () => scene.forceRender(widget.clock.currentTime), renderOnce: (options) => nextPostRender(options), + setTargetFps(fps) { + targetFps = fps > 0 ? fps : 0; + widget.targetFrameRate = targetFps || undefined; + }, time: () => Cesium.JulianDate.toDate(widget.clock.currentTime).getTime(), }; @@ -923,6 +1098,8 @@ export function createCesiumPorts(widget, { capabilities }) { /** Detach from the scene; the widget's owner destroys the widget. */ dispose() { disposed = true; + // The tick under way is no longer seen through: it is not claimed. + settleTick(false); for (const off of removers) off(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index 235bffdb6..32d0408aa 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; import test from 'node:test'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; @@ -186,3 +187,258 @@ test('clampToSurface misses a position it cannot read, not the batch', async () assert.ok(Math.abs(Math.hypot(good.x, good.y, good.z) - 6378142) < 1e-6); ports.dispose(); }); + +/** A widget whose scene events can be raised by hand. */ +function tickingWidget() { + const events = {}; + const event = (name) => { + const list = (events[name] = []); + return { + addEventListener(cb) { + list.push(cb); + return () => list.splice(list.indexOf(cb), 1); + }, + }; + }; + const camera = new Cesium.Camera({ + canvas: { clientWidth: 100, clientHeight: 100 }, + drawingBufferWidth: 100, + drawingBufferHeight: 100, + mapProjection: new Cesium.GeographicProjection(), + }); + const seen = Cesium.Camera.clone(camera); + let requested = 0; + const scene = { + camera, + _view: { _cameraClone: seen }, + _frameState: { afterRender: [] }, + requestRenderMode: true, + maximumRenderTimeChange: Infinity, + primitives: { add() {}, remove() {} }, + requestRender() { + requested += 1; + scene._renderRequested = true; + }, + preUpdate: event('preUpdate'), + preRender: event('preRender'), + postRender: event('postRender'), + }; + const widget = { + scene, + clock: { currentTime: Cesium.JulianDate.now() }, + useDefaultRenderLoop: true, + _renderLoopRunning: true, + }; + const raise = (name) => { + // `end`: the tick empties its after-render queue, as Scene#render does. + if (name === 'end') { + for (const fn of scene._frameState.afterRender.splice(0)) fn(); + return; + } + for (const cb of [...events[name]]) cb(scene, widget.clock.currentTime); + }; + return { widget, scene, raise, requests: () => requested }; +} + +test('preUpdate says whether the tick draws, as Cesium decides it', () => { + const { widget, scene, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const seen = []; + ports.frame.on('preUpdate', (t) => seen.push(t.rendering)); + raise('preUpdate'); + ports.frame.requestRender('r'); + raise('preUpdate'); + scene._renderRequested = false; + scene.camera.position = new Cesium.Cartesian3(1, 2, 3); + raise('preUpdate'); + scene.camera.position = Cesium.Cartesian3.clone( + scene._view._cameraClone.position, + ); + scene.requestRenderMode = false; + raise('preUpdate'); + assert.deepEqual(seen, [false, true, true, true]); + ports.dispose(); +}); + +test('preUpdate counts what earlier listeners did this tick', () => { + const { widget, scene, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const seen = []; + ports.frame.on('preUpdate', (t) => seen.push(['before', t.rendering])); + ports.frame.on('preUpdate', () => { + scene.camera.position = new Cesium.Cartesian3(7, 8, 9); + }); + ports.frame.on('preUpdate', (t) => seen.push(['after', t.rendering])); + raise('preUpdate'); + assert.deepEqual(seen, [ + ['before', false], + ['after', true], + ]); + ports.dispose(); +}); + +test('holds are counted per reason and switch the loop to every frame', () => { + const { widget, scene, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const a = ports.frame.holdContinuous('a'); + const a2 = ports.frame.holdContinuous('a'); + assert.equal(scene.requestRenderMode, false); + assert.deepEqual(ports.frame.diagnostics().holds, ['a', 'a']); + a(); + a(); + assert.equal(scene.requestRenderMode, false, 'one hold of a still out'); + a2(); + assert.equal(scene.requestRenderMode, true); + assert.deepEqual(ports.frame.diagnostics().holds, []); + ports.frame.requestRender('x'); + ports.frame.requestRender('x'); + assert.deepEqual(ports.frame.diagnostics().pendingReasons, ['x']); + raise('preUpdate'); + raise('preRender'); + raise('postRender'); + const d = ports.frame.diagnostics(); + assert.deepEqual(d.pendingReasons, [], 'a drawn frame answers them'); + assert.equal(d.frames, 1); + assert.equal(d.rendered, 1); + assert.equal(ports.frame.mode(), 'demand'); + ports.dispose(); +}); + +test('suspend stops the loop at once and resumes it; target fps caps it', () => { + const { widget, requests } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + ports.frame.suspend(true); + assert.equal(widget.useDefaultRenderLoop, false); + assert.equal( + widget._renderLoopRunning, + true, + 'the queued callback stops the loop itself', + ); + assert.equal(ports.frame.diagnostics().suspended, true); + const before = requests(); + ports.frame.suspend(false); + assert.equal(widget.useDefaultRenderLoop, true); + assert.equal(requests(), before + 1, 'a frame on resume'); + ports.frame.setTargetFps(30); + assert.equal(widget.targetFrameRate, 30); + assert.equal(ports.frame.diagnostics().targetFps, 30); + ports.frame.setTargetFps(0); + assert.equal(widget.targetFrameRate, undefined, '0 is the display rate'); + ports.dispose(); +}); + +test('rendering holds the tick’s answer once the tick is over', () => { + const { widget, scene, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const kept = []; + ports.frame.on('preUpdate', (t) => kept.push(t)); + ports.frame.requestRender('r'); + raise('preUpdate'); + raise('preRender'); + scene._renderRequested = false; + raise('postRender'); + raise('preUpdate'); + scene.requestRenderMode = false; + raise('preUpdate'); + assert.deepEqual( + kept.map((t) => t.rendering), + [true, false, true], + 'drawn, not drawn, and the live tick', + ); + ports.dispose(); +}); + +test('a reason given while a frame draws waits for the next one', () => { + const { widget, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + ports.frame.requestRender('before'); + ports.frame.on('preRender', () => ports.frame.requestRender('during')); + raise('preUpdate'); + raise('preRender'); + raise('postRender'); + assert.deepEqual(ports.frame.diagnostics().pendingReasons, ['during']); + ports.dispose(); +}); + +test('the same reason given again while it is answered stays pending', () => { + const { widget, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + ports.frame.requestRender('animation'); + ports.frame.requestRender('animation'); + assert.deepEqual(ports.frame.diagnostics().pendingReasons, ['animation']); + ports.frame.on('preRender', () => ports.frame.requestRender('animation')); + raise('preUpdate'); + raise('preRender'); + raise('postRender'); + assert.deepEqual(ports.frame.diagnostics().pendingReasons, ['animation']); + ports.dispose(); +}); + +test('scene time past maximumRenderTimeChange draws', () => { + const { widget, scene, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const seen = []; + ports.frame.on('preUpdate', (t) => seen.push(t.rendering)); + scene.maximumRenderTimeChange = 1; + scene._lastRenderTime = Cesium.JulianDate.addSeconds( + widget.clock.currentTime, + -0.5, + new Cesium.JulianDate(), + ); + raise('preUpdate'); + scene._lastRenderTime = Cesium.JulianDate.addSeconds( + widget.clock.currentTime, + -2, + new Cesium.JulianDate(), + ); + raise('preUpdate'); + assert.deepEqual(seen, [false, true]); + ports.dispose(); +}); + +test('holds work from a scene that already draws every frame', () => { + const { widget, scene } = tickingWidget(); + scene.requestRenderMode = false; + const ports = createCesiumPorts(widget, { capabilities: all }); + const release = ports.frame.holdContinuous('a'); + ports.frame.holdContinuous('b'); + ports.frame.holdContinuous('b'); + assert.deepEqual(ports.frame.diagnostics().holds.sort(), ['a', 'b', 'b']); + release(); + assert.equal(scene.requestRenderMode, false, 'b still holds'); + ports.dispose(); +}); + +test('a tick that did not draw says so once it is over', () => { + const { widget, scene, raise } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + let kept; + ports.frame.on('preUpdate', (t) => (kept = t)); + raise('preUpdate'); + raise('end'); + assert.equal(kept.rendering, false); + scene.requestRender(); + assert.equal(kept.rendering, false, 'a later request is the next tick’s'); + raise('preUpdate'); + assert.equal(kept.rendering, true, 'the request draws the next tick'); + const live = kept; + ports.dispose(); + // The port no longer sees the tick through, so it does not claim a draw. + assert.equal(live.rendering, false, 'disposal settles the tick under way'); +}); + +test('releasing the last hold asks for one more frame', () => { + const { widget, scene, requests } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const a = ports.frame.holdContinuous('a'); + const b = ports.frame.holdContinuous('b'); + const before = requests(); + a(); + assert.equal(requests(), before, 'a hold still out draws anyway'); + b(); + assert.equal(requests(), before + 1); + assert.equal(scene.requestRenderMode, true); + b(); + assert.equal(requests(), before + 1, 'a second release does nothing'); + ports.dispose(); +}); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index a96aa5dbc..4d3f91c01 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -15,6 +15,7 @@ * Members it does not have are absent, never stubbed. */ import * as Cesium from 'cesium'; +import { onEngineWorkCompleted, renderPending } from './internals.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; export { toCartesian, toColor, toImage } from './ports.js'; @@ -114,26 +115,136 @@ 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; - let quiet = 0; - while (performance.now() < end) { - await ports.nextPostRender(); - if (models().some((r) => r.host.failed > 0)) - return { state: 'failed', blockers: ['models'] }; + // 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(); - if (icons === 'failed') return { state: 'failed', blockers: ['icons'] }; - const loading = - models().some((r) => r.host.loading > 0) || icons === 'loading'; - quiet = scene.globe.tilesLoaded && !loading ? quiet + 1 : 0; - if (quiet >= 3) return { state: 'settled', blockers: [] }; + 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 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'); - return { state: 'timeout', blockers }; } return { @@ -155,6 +266,8 @@ export async function createCesiumSceneRenderer({ whenSettled, resize: () => widget.resize(), destroy() { + destroyed = true; + for (const finish of [...watching]) finish('ended'); ports.dispose(); widget.destroy(); }, diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index 005ba916f..88aef2c54 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -61,6 +61,32 @@ async function settled(r) { equal(result.state, 'settled', `settled (${result.blockers.join(', ')})`); } +/** + * Settles, then watches `ticks` ticks and returns what they did: preUpdate's + * `['u', rendering]` and a draw's `['r']`. An engine may still draw on its + * own once in a while after settling (roundoff in its camera bookkeeping, + * say), so a watch that saw a draw is tried again, up to `tries` times; one + * that always draws fails every try. + */ +async function idleWatch(h, r, ticks, tries = 3) { + let seen = []; + const off = [ + r.frame.on('preUpdate', (t) => seen.push(['u', t.rendering])), + r.frame.on('postRender', () => seen.push(['r'])), + ]; + try { + for (let i = 0; i < tries; i++) { + await settled(r); + seen = []; + for (let k = 0; k < ticks; k++) await h.frame(r); + if (!seen.some(([kind]) => kind === 'r')) break; + } + return seen; + } finally { + for (const f of off) f(); + } +} + async function drawn(h, r, frames = 2) { for (let i = 0; i < frames; i++) { r.frame.requestRender('conformance'); @@ -355,16 +381,15 @@ export function portChecks() { if (r.frame.mode() === 'continuous') return { skip: 'continuous engine' }; await drawn(h, r, 3); - const seen = []; - r.frame.on('preUpdate', (t) => seen.push(['u', t.rendering])); - r.frame.on('postRender', () => seen.push(['r'])); - await h.frame(r); + // A renderer still loading is not idle. deepEqual( - seen, + await idleWatch(h, r, 1), [['u', false]], 'an idle tick runs preUpdate and draws nothing', ); - seen.length = 0; + const seen = []; + r.frame.on('preUpdate', (t) => seen.push(['u', t.rendering])); + r.frame.on('postRender', () => seen.push(['r'])); r.frame.requestRender('one'); ok( r.frame.diagnostics().pendingReasons.includes('one'), @@ -372,9 +397,14 @@ export function portChecks() { ); await h.frame(r); deepEqual(seen, [['u', true], ['r']], 'a request draws one frame'); - seen.length = 0; - await h.frame(r); - deepEqual(seen, [['u', false]], 'and only one'); + deepEqual( + r.frame.diagnostics().pendingReasons, + [], + 'and that frame answers it', + ); + // What that frame started loading (tiles it now needs) may draw on + // its own; once that is done the loop is idle again. + deepEqual(await idleWatch(h, r, 1), [['u', false]], 'and only one'); const a = r.frame.holdContinuous('a'); const b = r.frame.holdContinuous('b'); deepEqual( @@ -396,10 +426,46 @@ export function portChecks() { await h.frame(r); equal(seen.filter(([k]) => k === 'r').length, 1, 'one hold still out'); b(); - seen.length = 0; - await h.frame(r); - await h.frame(r); - equal(seen.filter(([k]) => k === 'r').length, 0, 'no holds, no frames'); + equal( + (await idleWatch(h, r, 2)).filter(([k]) => k === 'r').length, + 0, + 'no holds, no frames', + ); + }, + ), + check( + 'frame.prediction', + 'preUpdate rendering says whether the tick draws, whatever came before', + async (h, r) => { + if (r.frame.mode() === 'continuous') + return { skip: 'continuous engine' }; + await settled(r); + // An earlier listener asks for frames and takes and gives back + // holds; the prediction, read after it, must match each tick. + const script = ['idle', 'request', 'idle', 'hold', 'idle', 'release']; + let step = 0; + let release = null; + r.frame.on('preUpdate', () => { + const op = script[step++ % script.length]; + if (op === 'request') r.frame.requestRender('prediction'); + if (op === 'hold') release = r.frame.holdContinuous('prediction'); + if (op === 'release') release?.(); + }); + const predicted = new Map(); + const drew = new Set(); + r.frame.on('preUpdate', (t) => predicted.set(t.frame, t.rendering)); + r.frame.on('postRender', (t) => drew.add(t.frame)); + for (let i = 0; i < 4 * script.length; i++) await h.frame(r); + release?.(); + const wrong = [...predicted] + .filter(([frame, rendering]) => rendering !== drew.has(frame)) + .map(([frame, rendering]) => `${frame}:${rendering}`); + deepEqual(wrong, [], 'ticks predicted wrongly (frame:prediction)'); + ok( + [...predicted.values()].some((x) => x) && + [...predicted.values()].some((x) => !x), + 'both kinds of tick were seen', + ); }, ), check( diff --git a/src/render/contract/frame.d.ts b/src/render/contract/frame.d.ts index bb9d3fd34..f6dd91a8c 100644 --- a/src/render/contract/frame.d.ts +++ b/src/render/contract/frame.d.ts @@ -41,7 +41,12 @@ export interface FrameTime { now: number; /** Seconds since the previous tick. */ dt: number; - /** Whether this tick draws. */ + /** + * Whether this tick draws. Read during `preUpdate` it counts what has been + * asked so far; a request or hold made later in `preUpdate` draws this + * tick or the next, as the engine decides, and readers after it see + * which. Once the tick is over it keeps its answer. + */ rendering: boolean; } diff --git a/src/render/contract/renderer.d.ts b/src/render/contract/renderer.d.ts index 7be48054b..124cf8ae0 100644 --- a/src/render/contract/renderer.d.ts +++ b/src/render/contract/renderer.d.ts @@ -96,8 +96,9 @@ export interface Renderer { */ readPixels(options?: { crop?: ScreenRect; scale?: number }): ImageData; /** - * Resolves once what is in view has loaded, or the timeout passes, or a - * load failed for good. `blockers` names what was still loading or + * Resolves once what is in view has loaded and the last presented frame + * shows it (no redraw of the renderer's own is pending), or the timeout + * passes, or a load failed for good. `blockers` names what was still loading or * failed (`'tiles'`, `'imagery:'`, `'models'`, ...); empty when settled. */ whenSettled(options?: { timeoutMs?: number }): Promise;