From 3837e453ca1eaa55962a2101351cdbf2172f5184 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 17:24:34 -0800 Subject: [PATCH] Keep the render governor's holds on the frame port The governor no longer writes the Cesium scene's render mode itself: each owner holds the renderer's frame port once, requests go to it, and the port is the one place the loop's mode is set. The port now also keeps the loop from redrawing for scene time alone, which the governor did. The application installs the governor as soon as its renderer exists and holds the loop continuous ('boot') until its tools are up, where the governor used to be installed, so boot draws as before. The hidden-tab suspension goes through the port too, and the application's renderer now has the whole frame port. --- src/annotations/annotationEngine.test.mjs | 3 +- src/app/mapLifecycle.test.mjs | 7 +- src/app/scene.js | 9 +- src/app/tools.js | 13 ++- src/data/earthquakes.test.mjs | 3 +- src/data/localGeojson.test.mjs | 17 ++-- src/data/militaryAwareness.test.mjs | 5 +- src/data/militaryInstallations.test.mjs | 3 +- src/layers/transit/lifecycle.test.mjs | 5 +- src/render/cesium/appRenderer.js | 21 +---- src/render/cesium/appRenderer.test.mjs | 4 - src/render/cesium/ports.js | 9 +- src/render/cesium/ports.test.mjs | 12 +++ src/renderGovernor.js | 109 +++++++++++----------- src/renderGovernor.test.mjs | 41 ++++++-- src/testSupport/fakeFramePort.mjs | 44 +++++++++ 16 files changed, 192 insertions(+), 113 deletions(-) create mode 100644 src/testSupport/fakeFramePort.mjs 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?.(); + }; + }, + }; +}