diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index ab735ba2b..8b29ba5d2 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -25,6 +25,7 @@ import { installRenderGovernor, _resetRenderGovernorForTest, } from '../renderGovernor.js'; +import { framePortFor } from '../testSupport/fakeFramePort.mjs'; // A resolver that replays a scripted sequence of outcomes (undefined = transient, // null = definitive miss, object = footprint) and counts its invocations. @@ -1547,7 +1548,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one _resetRenderGovernorForTest(); t.after(() => _resetRenderGovernorForTest()); let renders = 0; - installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } }); + installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) })); const { renderer } = fakeRenderer(); const engine = createAnnotationEngine({ viewer: {}, renderer }); await engine.annotate([ diff --git a/src/app/mapLifecycle.test.mjs b/src/app/mapLifecycle.test.mjs index ef015bf52..0ab2e7f39 100644 --- a/src/app/mapLifecycle.test.mjs +++ b/src/app/mapLifecycle.test.mjs @@ -8,6 +8,7 @@ import { governorRequestRender, getRenderGovernorDiagnostics, } from '../renderGovernor.js'; +import { framePortFor } from '../testSupport/fakeFramePort.mjs'; test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { let resolveProvider; @@ -44,11 +45,11 @@ test('uninstall only releases the current viewer and makes later render requests }, }, }; - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); holdContinuousRender('fixture'); - uninstallRenderGovernor({}); + uninstallRenderGovernor(framePortFor({})); assert.equal(getRenderGovernorDiagnostics().installed, true); - uninstallRenderGovernor(viewer); + uninstallRenderGovernor(framePortFor(viewer.scene)); const before = renders; governorRequestRender('late'); assert.equal(renders, before); diff --git a/src/app/scene.js b/src/app/scene.js index e3f090dc0..87f9736ee 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -12,6 +12,8 @@ import { loadPhotorealisticTileset } from '../mapStartup.js'; import { initLogoGaze } from '../logoGaze.js'; import { inputFor } from '../input/dispatcher.js'; import { + holdContinuousRender, + installRenderGovernor, uninstallRenderGovernor, governorRequestRender, } from '../renderGovernor.js'; @@ -69,9 +71,14 @@ export async function createApplicationScene({ } throw refusal; } + // The governor keeps its holds on the renderer's frame port. The loop + // draws every frame until the application's tools are up (they release + // 'boot'), as it did before anything could hold it. + installRenderGovernor(renderer.frame); + holdContinuousRender('boot'); defer(() => { try { - uninstallRenderGovernor(viewer); + uninstallRenderGovernor(renderer.frame); } finally { renderer.destroy(); } diff --git a/src/app/tools.js b/src/app/tools.js index 767e49eeb..fe647635b 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -11,7 +11,6 @@ import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { - installRenderGovernor, getRenderGovernorDiagnostics, governorRequestRender, holdContinuousRender, @@ -34,7 +33,7 @@ export function createApplicationTools({ signal, defer, }) { - const { viewer, tileset, mapStackController, operations } = scene; + const { viewer, renderer, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { @@ -127,10 +126,10 @@ export function createApplicationTools({ } if (startChrome) defer(startChrome({ loadingScreen, styleManager, dataManager, signal })); - // Idle render governor: flips the scene into requestRenderMode whenever - // nothing animates per frame. Installed AFTER every module above has had - // its chance to register pre-install holds. (perf wave 2) - installRenderGovernor(viewer); + // Idle render governor: the loop draws on demand whenever nothing animates + // per frame. It goes idle only now, after every module above has had its + // chance to hold. (perf wave 2) + releaseContinuousRender('boot'); // Cesium keeps a turned-off layer's draw commands (and so all its entities) // alive in reused per-pass arrays; trim them after each disable. defer( @@ -163,7 +162,7 @@ export function createApplicationTools({ // A panel's host may report it hidden while it is on screen; the panel // keeps drawing itself (see keepPanelRendering in embed.js). const hidden = document.hidden && !isEmbeddedInline(); - viewer.useDefaultRenderLoop = !hidden; + renderer.frame.suspend(hidden); cockpitCloudEffects?.setSuspended?.(hidden); if (!hidden) { data.presentation.flushVisible(); diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 5a014b1f5..87f9ffd7b 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -19,6 +19,7 @@ import { installRenderGovernor, _resetRenderGovernorForTest, } from '../renderGovernor.js'; +import { framePortFor } from '../testSupport/fakeFramePort.mjs'; const FULL_RAW = { id: 'us7000abcd', @@ -246,7 +247,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () }); _resetRenderGovernorForTest(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); // Installing the governor enters idle mode, which itself paints one settling // frame. Drop that from the baseline or the enable assertion below can pass on // the install alone, without the manager ever requesting anything. diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 32f7254d8..20925d293 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -23,6 +23,7 @@ import { } from '../renderGovernor.js'; import { getContextStore } from './contextStore.js'; import { readEntityRecord } from './entityRecords.js'; +import { framePortFor } from '../testSupport/fakeFramePort.mjs'; /** The catalog's services, with the overlay host each test supplies. */ const createLocalGeoJsonLayer = (options) => @@ -793,7 +794,7 @@ for (const [terrainHeight, sampledHeight, expectedHeight, globeShown] of [ test('a failed ground sample schedules the retry frame the idle governor would never produce', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); + installRenderGovernor(framePortFor({ requestRender() {} })); t.mock.timers.enable({ apis: ['setTimeout'] }); t.after(() => { env.layer.destroy(env.viewer); @@ -821,7 +822,7 @@ test('a failed ground sample schedules the retry frame the idle governor would n test('a far camera arms no retry render — the governor stays fully idle', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); + installRenderGovernor(framePortFor({ requestRender() {} })); t.mock.timers.enable({ apis: ['setTimeout'] }); t.after(() => { env.layer.destroy(env.viewer); @@ -844,7 +845,7 @@ test('a far camera arms no retry render — the governor stays fully idle', asyn test('disable cancels a pending ground-retry render', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); + installRenderGovernor(framePortFor({ requestRender() {} })); t.mock.timers.enable({ apis: ['setTimeout'] }); t.after(() => { env.layer.destroy(env.viewer); @@ -928,7 +929,7 @@ test('a scene that cannot sample heights never arms a retry — not even once', const env = await createRealLocalLayerHarness({ sampleHeightSupported: false }); _resetRenderGovernorForTest(); const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); + installRenderGovernor(framePortFor(governorScene)); governorScene.renders = 0; // discard the governor's own install settling frame t.mock.timers.enable({ apis: ['setTimeout'] }); const clock = installFakeClock(t); @@ -965,7 +966,7 @@ test('a sampleable scene still measures the retry distance it needs', async (t) // remove it — a scene that CAN sample still pays one distance per walk. const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); + installRenderGovernor(framePortFor({ requestRender() {} })); t.mock.timers.enable({ apis: ['setTimeout'] }); const clock = installFakeClock(t); t.after(() => { @@ -991,7 +992,7 @@ test('capability arriving late re-opens a spent budget without camera motion', a const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); + installRenderGovernor(framePortFor(governorScene)); governorScene.renders = 0; t.mock.timers.enable({ apis: ['setTimeout'] }); const clock = installFakeClock(t); @@ -1024,7 +1025,7 @@ test('a sampleable scene that keeps failing gives up after a bounded run of arms const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); + installRenderGovernor(framePortFor(governorScene)); governorScene.renders = 0; // discard the governor's own install settling frame t.mock.timers.enable({ apis: ['setTimeout'] }); const clock = installFakeClock(t); @@ -1056,7 +1057,7 @@ test('after the cap a camera-motion frame still samples, and re-opens the budget const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); _resetRenderGovernorForTest(); const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); + installRenderGovernor(framePortFor(governorScene)); governorScene.renders = 0; // discard the governor's own install settling frame t.mock.timers.enable({ apis: ['setTimeout'] }); const clock = installFakeClock(t); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index f159f0655..641c241fc 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -47,6 +47,7 @@ import { } from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; import { withEntityRecord } from './entityRecords.js'; +import { framePortFor } from '../testSupport/fakeFramePort.mjs'; const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), @@ -3860,7 +3861,7 @@ test('parked Contacts takes no continuous-render hold and requests at most the p try { Date.now = () => clockMs; - installRenderGovernor(runtime.viewer); + installRenderGovernor(framePortFor(runtime.viewer.scene)); runtime.viewer.camera = { positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000), heading: 0, @@ -3996,7 +3997,7 @@ test('a moving view over failed feeds takes no continuous-render hold', () => { try { Date.now = () => clockMs; - installRenderGovernor(runtime.viewer); + installRenderGovernor(framePortFor(runtime.viewer.scene)); runtime.viewer.camera = { positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000), heading: 0, diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index ff1d2dc1b..1b4065a0b 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -350,7 +350,7 @@ async function runInstallationLoad({ }; try { militaryInstallationsLayer.init(viewer); } finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; } - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); @@ -799,6 +799,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui // by source probes against the shipped file, the same technique the HUD datum // tests use where a full boot is impractical. import { installationRetryDelayMs } from './militaryInstallations.js'; +import { framePortFor } from '../testSupport/fakeFramePort.mjs'; const installationsSource = readLayerSource( new URL('./militaryInstallations.js', import.meta.url), 'utf8'); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index d5039f11f..f2536f271 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -43,6 +43,7 @@ import { uninstallRenderGovernor, } from '../../renderGovernor.js'; import { fakeInput } from '../../input/testDoubles.mjs'; +import { framePortFor } from '../../testSupport/fakeFramePort.mjs'; const BOSTON = { lat: 42.3601, lon: -71.0589 }; @@ -302,7 +303,7 @@ function harness( }, }); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); layer.init(viewer); t.after(() => { @@ -311,7 +312,7 @@ function harness( } catch { /* a test may already have destroyed it */ } - uninstallRenderGovernor(viewer); + uninstallRenderGovernor(framePortFor(viewer.scene)); _resetRenderGovernorForTest(); globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index bf548bb51..4c1d1bacc 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -9,10 +9,7 @@ * 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), `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 + * application's clock). 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 @@ -23,14 +20,6 @@ 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), - ), - ), - ), + frame: ports.frame, 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 d3b96f5b8..a80abd67a 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -342,11 +342,7 @@ const EXPECTED_MISSING = [ 'draw.atlas', 'draw.clampState', 'draw.on', - 'frame.diagnostics', - 'frame.holdContinuous', - 'frame.setTargetFps', 'frame.setTime', - 'frame.suspend', 'renderer.credits', 'renderer.effects', 'renderer.globe', diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 74f920bd1..0240b5d8e 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -3,7 +3,8 @@ * @description The Cesium renderer's contract ports, built over a Cesium * widget or viewer someone else made and configured: frame phases, the * draw sets, `setView` and the pose, picks and projection, and capture. - * Building them changes nothing in the scene; only calls do. + * Building them puts the loop on demand (no holds yet, and no redraws for + * scene time alone); nothing else in the scene changes until a call. * * The conformance renderer (`sceneRenderer.js`) and the application's * renderer (`appRenderer.js`) both use them, so what conformance @@ -233,8 +234,12 @@ export function createCesiumPorts(widget, { capabilities }) { if (scene.requestRenderMode === !continuous) return; scene.requestRenderMode = !continuous; } + // On demand means drawn when asked, held or the view changed: never + // because scene time moved on by itself. With no holds yet, it starts so. + scene.maximumRenderTimeChange = Infinity; + scene.requestRenderMode = true; let suspended = false; - let targetFps = 0; + let targetFps = widget.targetFrameRate > 0 ? widget.targetFrameRate : 0; // Pending one-shot renders; disposal settles them rather than leave them // attached to a scene that will not render again. diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index 32d0408aa..549217258 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -442,3 +442,15 @@ test('releasing the last hold asks for one more frame', () => { assert.equal(requests(), before + 1, 'a second release does nothing'); ports.dispose(); }); + +test('the loop starts on demand and never draws for scene time alone', () => { + const { widget, scene } = tickingWidget(); + scene.requestRenderMode = false; + scene.maximumRenderTimeChange = 0; + widget.targetFrameRate = 60; + const ports = createCesiumPorts(widget, { capabilities: all }); + assert.equal(scene.requestRenderMode, true); + assert.equal(scene.maximumRenderTimeChange, Infinity); + assert.equal(ports.frame.diagnostics().targetFps, 60, 'the widget’s cap'); + ports.dispose(); +}); diff --git a/src/renderGovernor.js b/src/renderGovernor.js index 215132c1c..f59382244 100644 --- a/src/renderGovernor.js +++ b/src/renderGovernor.js @@ -8,22 +8,23 @@ * whenever nothing animates per frame, and return to the continuous loop the * moment something does. * - * Architecture — a binary mode driven by ref-counted holds: + * Architecture — a binary mode driven by owner holds, kept by the + * renderer's frame port (`src/render/contract/frame.d.ts`), which is the one + * place the loop's mode is set: * - * - **Continuous mode** (`requestRenderMode = false`, today's behavior) - * while ANY hold is registered. Every per-frame animator — fleet + * - **Continuous mode** (every frame drawn) while ANY hold is registered. Every per-frame animator — fleet * interpolation, traffic sim, satellite motion, tracked-entity follow, * style crossfades, CCTV projection — registers a hold for exactly the * lifetime of its scene-loop listener or animation. While one is active, * behavior is byte-identical to pre-governor main: the locked * interpolation/tracking invariants are preserved by construction. - * - **Idle mode** (`requestRenderMode = true`) when zero holds. Cesium - * auto-renders on camera input and tile loads; every other scene mutation + * - **Idle mode** (drawn on demand) when zero holds. The renderer draws + * on camera input and tile loads by itself; every other scene mutation * must call `governorRequestRender()` for its one frame. Discrete * mutators (layer poll ticks, slider writes, annotation changes) route * through that. * - * Holds are identity-keyed (a Set of owner ids), NOT a counter — a module + * Holds are identity-keyed (one per owner id), NOT a counter — a module * that double-holds or double-releases cannot corrupt the mode. Owners are * short stable strings ('flights', 'traffic', 'style-anim', …) so the * diagnostics read like a story. @@ -31,45 +32,45 @@ * The governor is O(1) passive: no per-frame work of its own, ever. */ -let _viewer = null; -let _installed = false; -const _holds = new Set(); +/** @typedef {import('./render/contract/frame').FramePort} FramePort */ + +let _frame = null; +/** Owners holding, each with the frame port's release once installed. */ +const _holds = new Map(); /** Debug trail of the most recent one-shot render requests (idle mode only). */ const _recentRequests = []; const RECENT_REQUEST_CAP = 16; -function applyMode() { - if (!_installed || !_viewer?.scene) return; - const continuous = _holds.size > 0; - const scene = _viewer.scene; - if (scene.requestRenderMode === !continuous) return; - scene.requestRenderMode = !continuous; - if (!continuous) { - // Entering idle: render one settling frame so anything the last - // continuous frame mutated is on screen before the loop stops. - scene.requestRender?.(); - } +function holdOnFrame(ownerId) { + if (_frame && !_holds.get(ownerId)) + _holds.set(ownerId, _frame.holdContinuous(ownerId)); } /** - * Install the governor on the viewer. Idempotent. Before install, - * hold/release still record into the holds set (and apply at install time); - * requests are safe no-ops — so modules can call all three unconditionally - * in tests without a viewer. - * @param {Cesium.Viewer} viewer + * Install the governor on the renderer's frame port, which keeps the loop + * drawing every frame while any owner holds and on demand otherwise (and + * draws one settling frame when the last hold goes). Idempotent. Before + * install, hold/release still record into the holds (and apply at install + * time); requests are safe no-ops — so modules can call all three + * unconditionally in tests without a renderer. + * @param {FramePort} frame * @returns {void} */ -export function installRenderGovernor(viewer) { - if (!viewer?.scene) - throw new TypeError('installRenderGovernor requires a Cesium viewer'); - _viewer = viewer; - _installed = true; - // Never let Cesium re-render on simulation-time deltas behind our back — - // idle means idle. All re-renders are camera/tiles (Cesium-native) or - // explicit requests. - viewer.scene.maximumRenderTimeChange = Infinity; - applyMode(); +export function installRenderGovernor(frame) { + if (typeof frame?.holdContinuous !== 'function') + throw new TypeError('installRenderGovernor requires a frame port'); + if (_frame === frame) return; + if (_frame) releaseFrameHolds(); + _frame = frame; + for (const ownerId of _holds.keys()) holdOnFrame(ownerId); +} + +function releaseFrameHolds() { + for (const [ownerId, release] of _holds) { + release?.(); + _holds.set(ownerId, null); + } } /** @@ -81,8 +82,8 @@ export function installRenderGovernor(viewer) { */ export function holdContinuousRender(ownerId) { if (!ownerId) return; - _holds.add(ownerId); - applyMode(); + if (!_holds.has(ownerId)) _holds.set(ownerId, null); + holdOnFrame(ownerId); } /** @@ -93,27 +94,28 @@ export function holdContinuousRender(ownerId) { * @returns {void} */ export function releaseContinuousRender(ownerId) { - if (!ownerId) return; + if (!ownerId || !_holds.has(ownerId)) return; + const release = _holds.get(ownerId); _holds.delete(ownerId); - applyMode(); + release?.(); } /** * One-shot render request for a discrete scene mutation (layer tick, slider - * write, annotation change). Always forwards to scene.requestRender() — in - * continuous mode that is a harmless flag set (and forwarding closes the - * request-then-last-release race); only idle-mode requests are recorded in - * diagnostics. Cheap enough to call unconditionally after any mutation. + * write, annotation change). Always forwards to the frame port — while held + * that is harmless (and forwarding closes the request-then-last-release + * race); only idle-mode requests are recorded in diagnostics. Cheap enough + * to call unconditionally after any mutation. * @param {string} [reason] For diagnostics only. * @returns {void} */ export function governorRequestRender(reason = 'unspecified') { - if (!_installed || !_viewer?.scene) return; + if (!_frame) return; if (_holds.size === 0) { _recentRequests.push({ reason, at: Date.now() }); if (_recentRequests.length > RECENT_REQUEST_CAP) _recentRequests.shift(); } - _viewer.scene.requestRender?.(); + _frame.requestRender(reason); } /** @@ -122,26 +124,25 @@ export function governorRequestRender(reason = 'unspecified') { */ export function getRenderGovernorDiagnostics() { return { - installed: _installed, + installed: Boolean(_frame), mode: _holds.size > 0 ? 'continuous' : 'idle', - holds: [..._holds].sort(), + holds: [..._holds.keys()].sort(), recentRequests: [..._recentRequests], }; } -/** Release the installed viewer after its animation owners have stopped. */ -export function uninstallRenderGovernor(viewer) { - if (_viewer !== viewer) return; - _viewer = null; - _installed = false; +/** Release the installed frame port after its animation owners have stopped. */ +export function uninstallRenderGovernor(frame) { + if (!_frame || _frame !== frame) return; + releaseFrameHolds(); + _frame = null; _holds.clear(); _recentRequests.length = 0; } /** Test seam: reset module state between unit tests. */ export function _resetRenderGovernorForTest() { - _viewer = null; - _installed = false; + _frame = null; _holds.clear(); _recentRequests.length = 0; } diff --git a/src/renderGovernor.test.mjs b/src/renderGovernor.test.mjs index 5057fd926..7f770d6da 100644 --- a/src/renderGovernor.test.mjs +++ b/src/renderGovernor.test.mjs @@ -7,7 +7,9 @@ import { governorRequestRender, getRenderGovernorDiagnostics, _resetRenderGovernorForTest, + uninstallRenderGovernor, } from './renderGovernor.js'; +import { framePortFor } from './testSupport/fakeFramePort.mjs'; function makeViewer() { const calls = { requestRender: 0 }; @@ -21,17 +23,40 @@ function makeViewer() { beforeEach(() => _resetRenderGovernorForTest()); -test('install with zero holds enters idle mode and pins maximumRenderTimeChange', () => { +test('install with zero holds leaves the loop on demand', () => { const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); assert.equal(scene.requestRenderMode, true); - assert.equal(scene.maximumRenderTimeChange, Infinity); assert.equal(getRenderGovernorDiagnostics().mode, 'idle'); }); +test('holds taken before install apply at install', () => { + holdContinuousRender('boot'); + const { viewer, scene } = makeViewer(); + installRenderGovernor(framePortFor(viewer.scene)); + assert.equal(scene.requestRenderMode, false); + releaseContinuousRender('boot'); + assert.equal(scene.requestRenderMode, true); +}); + +test('uninstall gives back what the governor holds on the port', () => { + const { viewer, scene } = makeViewer(); + const frame = framePortFor(viewer.scene); + installRenderGovernor(frame); + holdContinuousRender('flights'); + assert.equal(scene.requestRenderMode, false); + uninstallRenderGovernor(frame); + assert.equal(scene.requestRenderMode, true); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); +}); + +test('install needs a frame port', () => { + assert.throws(() => installRenderGovernor({ scene: {} }), TypeError); +}); + test('a hold flips to continuous; releasing the last hold returns to idle with a settling frame', () => { const { viewer, scene, calls } = makeViewer(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); const settleBaseline = calls.requestRender; holdContinuousRender('flights'); assert.equal(scene.requestRenderMode, false); @@ -44,7 +69,7 @@ test('a hold flips to continuous; releasing the last hold returns to idle with a test('holds are identity-keyed: double-hold cannot leak, double-release cannot corrupt', () => { const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); holdContinuousRender('traffic'); holdContinuousRender('traffic'); releaseContinuousRender('traffic'); @@ -56,7 +81,7 @@ test('holds are identity-keyed: double-hold cannot leak, double-release cannot c test('mode stays continuous until the LAST holder releases', () => { const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); holdContinuousRender('flights'); holdContinuousRender('satellites'); releaseContinuousRender('flights'); @@ -68,7 +93,7 @@ test('mode stays continuous until the LAST holder releases', () => { test('governorRequestRender forwards to the scene and records reasons only in idle mode', () => { const { viewer, calls } = makeViewer(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); const baseline = calls.requestRender; governorRequestRender('layer-tick:earthquakes'); assert.equal(calls.requestRender, baseline + 1); @@ -93,7 +118,7 @@ test('hold/release/request are safe no-ops before install (test environments wit test('holds registered before install apply at install time', () => { holdContinuousRender('flights'); const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); + installRenderGovernor(framePortFor(viewer.scene)); assert.equal(scene.requestRenderMode, false, 'pre-install hold keeps continuous mode'); releaseContinuousRender('flights'); assert.equal(scene.requestRenderMode, true); diff --git a/src/testSupport/fakeFramePort.mjs b/src/testSupport/fakeFramePort.mjs new file mode 100644 index 000000000..4f0259c5d --- /dev/null +++ b/src/testSupport/fakeFramePort.mjs @@ -0,0 +1,44 @@ +/** + * A frame port over a fake Cesium scene, for tests of code that steers the + * render loop through `renderGovernor.js`. It writes the scene the way the + * Cesium port does (`src/render/cesium/ports.js`): holds, counted per + * reason, keep `requestRenderMode` false; releasing the last asks for one + * frame; a request is the scene's `requestRender`. Only the members the + * governor uses. One port per scene, so a test can uninstall what it + * installed. + * @param {{requestRenderMode?: boolean, requestRender?: () => void}} scene + */ +export function framePortFor(scene) { + let port = ports.get(scene); + if (!port) ports.set(scene, (port = makePort(scene))); + return port; +} + +const ports = new WeakMap(); + +function makePort(scene) { + const holds = new Map(); + const apply = () => { + scene.requestRenderMode = holds.size === 0; + }; + apply(); + return { + requestRender() { + scene.requestRender?.(); + }, + holdContinuous(reason) { + holds.set(reason, (holds.get(reason) ?? 0) + 1); + apply(); + 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); + apply(); + if (holds.size === 0) scene.requestRender?.(); + }; + }, + }; +}