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;