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.
This commit is contained in:
Sameh Khamis committed 2026-11-05 17:24:34 -08:00
1 parent 48f06c68c8
commit 3837e453ca
16 files changed
+192 -113

No files matched your search

+2 -1
View File
@@ -25,6 +25,7 @@ import {
installRenderGovernor, installRenderGovernor,
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
// A resolver that replays a scripted sequence of outcomes (undefined = transient, // A resolver that replays a scripted sequence of outcomes (undefined = transient,
// null = definitive miss, object = footprint) and counts its invocations. // 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(); _resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest()); t.after(() => _resetRenderGovernorForTest());
let renders = 0; let renders = 0;
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } }); installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) }));
const { renderer } = fakeRenderer(); const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer }); const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([ await engine.annotate([
+4 -3
View File
@@ -8,6 +8,7 @@ import {
governorRequestRender, governorRequestRender,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
let resolveProvider; 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'); holdContinuousRender('fixture');
uninstallRenderGovernor({}); uninstallRenderGovernor(framePortFor({}));
assert.equal(getRenderGovernorDiagnostics().installed, true); assert.equal(getRenderGovernorDiagnostics().installed, true);
uninstallRenderGovernor(viewer); uninstallRenderGovernor(framePortFor(viewer.scene));
const before = renders; const before = renders;
governorRequestRender('late'); governorRequestRender('late');
assert.equal(renders, before); assert.equal(renders, before);
+8 -1
View File
@@ -12,6 +12,8 @@ import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js'; import { initLogoGaze } from '../logoGaze.js';
import { inputFor } from '../input/dispatcher.js'; import { inputFor } from '../input/dispatcher.js';
import { import {
holdContinuousRender,
installRenderGovernor,
uninstallRenderGovernor, uninstallRenderGovernor,
governorRequestRender, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
@@ -69,9 +71,14 @@ export async function createApplicationScene({
} }
throw refusal; 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(() => { defer(() => {
try { try {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(renderer.frame);
} finally { } finally {
renderer.destroy(); renderer.destroy();
} }
+6 -7
View File
@@ -11,7 +11,6 @@ import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import { import {
installRenderGovernor,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
governorRequestRender, governorRequestRender,
holdContinuousRender, holdContinuousRender,
@@ -34,7 +33,7 @@ export function createApplicationTools({
signal, signal,
defer, defer,
}) { }) {
const { viewer, tileset, mapStackController, operations } = scene; const { viewer, renderer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data; const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
@@ -127,10 +126,10 @@ export function createApplicationTools({
} }
if (startChrome) if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal })); defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever // Idle render governor: the loop draws on demand whenever nothing animates
// nothing animates per frame. Installed AFTER every module above has had // per frame. It goes idle only now, after every module above has had its
// its chance to register pre-install holds. (perf wave 2) // chance to hold. (perf wave 2)
installRenderGovernor(viewer); releaseContinuousRender('boot');
// Cesium keeps a turned-off layer's draw commands (and so all its entities) // 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. // alive in reused per-pass arrays; trim them after each disable.
defer( defer(
@@ -163,7 +162,7 @@ export function createApplicationTools({
// A panel's host may report it hidden while it is on screen; the panel // A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js). // keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline(); const hidden = document.hidden && !isEmbeddedInline();
viewer.useDefaultRenderLoop = !hidden; renderer.frame.suspend(hidden);
cockpitCloudEffects?.setSuspended?.(hidden); cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) { if (!hidden) {
data.presentation.flushVisible(); data.presentation.flushVisible();
+2 -1
View File
@@ -19,6 +19,7 @@ import {
installRenderGovernor, installRenderGovernor,
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
const FULL_RAW = { const FULL_RAW = {
id: 'us7000abcd', id: 'us7000abcd',
@@ -246,7 +247,7 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
}); });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
// Installing the governor enters idle mode, which itself paints one settling // 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 // frame. Drop that from the baseline or the enable assertion below can pass on
// the install alone, without the manager ever requesting anything. // the install alone, without the manager ever requesting anything.
+9 -8
View File
@@ -23,6 +23,7 @@ import {
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { getContextStore } from './contextStore.js'; import { getContextStore } from './contextStore.js';
import { readEntityRecord } from './entityRecords.js'; import { readEntityRecord } from './entityRecords.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
/** The catalog's services, with the overlay host each test supplies. */ /** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) => 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) => { test('a failed ground sample schedules the retry frame the idle governor would never produce', async (t) => {
const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
installRenderGovernor({ scene: { requestRender() {} } }); installRenderGovernor(framePortFor({ requestRender() {} }));
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => { t.after(() => {
env.layer.destroy(env.viewer); 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) => { test('a far camera arms no retry render — the governor stays fully idle', async (t) => {
const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
installRenderGovernor({ scene: { requestRender() {} } }); installRenderGovernor(framePortFor({ requestRender() {} }));
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => { t.after(() => {
env.layer.destroy(env.viewer); 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) => { test('disable cancels a pending ground-retry render', async (t) => {
const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
installRenderGovernor({ scene: { requestRender() {} } }); installRenderGovernor(framePortFor({ requestRender() {} }));
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => { t.after(() => {
env.layer.destroy(env.viewer); 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 }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: false });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; 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 governorScene.renders = 0; // discard the governor's own install settling frame
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t); 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. // remove it — a scene that CAN sample still pays one distance per walk.
const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
installRenderGovernor({ scene: { requestRender() {} } }); installRenderGovernor(framePortFor({ requestRender() {} }));
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t); const clock = installFakeClock(t);
t.after(() => { 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 }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
installRenderGovernor({ scene: governorScene }); installRenderGovernor(framePortFor(governorScene));
governorScene.renders = 0; governorScene.renders = 0;
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t); 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 }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; 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 governorScene.renders = 0; // discard the governor's own install settling frame
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t); 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 }); const env = await createRealLocalLayerHarness({ sampleHeightSupported: true });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; 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 governorScene.renders = 0; // discard the governor's own install settling frame
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t); const clock = installFakeClock(t);
+3 -2
View File
@@ -47,6 +47,7 @@ import {
} from '../layers/awareness/panel.js'; } from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
import { withEntityRecord } from './entityRecords.js'; import { withEntityRecord } from './entityRecords.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
const militaryAwarenessSource = readLayerSource( const militaryAwarenessSource = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url), 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 { try {
Date.now = () => clockMs; Date.now = () => clockMs;
installRenderGovernor(runtime.viewer); installRenderGovernor(framePortFor(runtime.viewer.scene));
runtime.viewer.camera = { runtime.viewer.camera = {
positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000), positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000),
heading: 0, heading: 0,
@@ -3996,7 +3997,7 @@ test('a moving view over failed feeds takes no continuous-render hold', () => {
try { try {
Date.now = () => clockMs; Date.now = () => clockMs;
installRenderGovernor(runtime.viewer); installRenderGovernor(framePortFor(runtime.viewer.scene));
runtime.viewer.camera = { runtime.viewer.camera = {
positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000), positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000),
heading: 0, heading: 0,
+2 -1
View File
@@ -350,7 +350,7 @@ async function runInstallationLoad({
}; };
try { militaryInstallationsLayer.init(viewer); } try { militaryInstallationsLayer.init(viewer); }
finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; } finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; }
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
militaryInstallationsLayer.enable(); militaryInstallationsLayer.enable();
await militaryInstallationsLayer.update(); 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 // by source probes against the shipped file, the same technique the HUD datum
// tests use where a full boot is impractical. // tests use where a full boot is impractical.
import { installationRetryDelayMs } from './militaryInstallations.js'; import { installationRetryDelayMs } from './militaryInstallations.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
const installationsSource = readLayerSource( const installationsSource = readLayerSource(
new URL('./militaryInstallations.js', import.meta.url), 'utf8'); new URL('./militaryInstallations.js', import.meta.url), 'utf8');
+3 -2
View File
@@ -43,6 +43,7 @@ import {
uninstallRenderGovernor, uninstallRenderGovernor,
} from '../../renderGovernor.js'; } from '../../renderGovernor.js';
import { fakeInput } from '../../input/testDoubles.mjs'; import { fakeInput } from '../../input/testDoubles.mjs';
import { framePortFor } from '../../testSupport/fakeFramePort.mjs';
const BOSTON = { lat: 42.3601, lon: -71.0589 }; const BOSTON = { lat: 42.3601, lon: -71.0589 };
@@ -302,7 +303,7 @@ function harness(
}, },
}); });
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
layer.init(viewer); layer.init(viewer);
t.after(() => { t.after(() => {
@@ -311,7 +312,7 @@ function harness(
} catch { } catch {
/* a test may already have destroyed it */ /* a test may already have destroyed it */
} }
uninstallRenderGovernor(viewer); uninstallRenderGovernor(framePortFor(viewer.scene));
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
if (originalDocument === undefined) delete globalThis.document; if (originalDocument === undefined) delete globalThis.document;
+2 -19
View File
@@ -9,10 +9,7 @@
* less the members that would not be true of the application yet: the * less the members that would not be true of the application yet: the
* globe (it draws photoreal tiles the port does not know), `whenSettled` * globe (it draws photoreal tiles the port does not know), `whenSettled`
* (it would not wait for them), `frame.setTime` (it would stop the * (it would not wait for them), `frame.setTime` (it would stop the
* application's clock), and the frame members that steer the loop * application's clock). Those arrive with the layer families that need
* (`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 * them (phase 2 of the renderer plan); `checkRendererShape` lists what is
* still missing. * still missing.
* Until a family is ported, its code reaches the Cesium viewer through * 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 { CONTRACT_VERSION } from '../contract/index.js';
import { cesiumCapabilities, createCesiumPorts } from './ports.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. * The members a transitional renderer has today.
* @typedef {Pick<import('../contract/renderer').Renderer, * @typedef {Pick<import('../contract/renderer').Renderer,
@@ -109,13 +98,7 @@ export function createCesiumRendererPlugin({
contractVersion: CONTRACT_VERSION, contractVersion: CONTRACT_VERSION,
capabilities, capabilities,
canvas: viewer.canvas, canvas: viewer.canvas,
frame: Object.freeze( frame: ports.frame,
Object.fromEntries(
Object.entries(ports.frame).filter(
([name]) => !LOOP_OWNED.has(name),
),
),
),
draw: ports.draw, draw: ports.draw,
camera: ports.camera, camera: ports.camera,
query: ports.query, query: ports.query,
-4
View File
@@ -342,11 +342,7 @@ const EXPECTED_MISSING = [
'draw.atlas', 'draw.atlas',
'draw.clampState', 'draw.clampState',
'draw.on', 'draw.on',
'frame.diagnostics',
'frame.holdContinuous',
'frame.setTargetFps',
'frame.setTime', 'frame.setTime',
'frame.suspend',
'renderer.credits', 'renderer.credits',
'renderer.effects', 'renderer.effects',
'renderer.globe', 'renderer.globe',
+7 -2
View File
@@ -3,7 +3,8 @@
* @description The Cesium renderer's contract ports, built over a Cesium * @description The Cesium renderer's contract ports, built over a Cesium
* widget or viewer someone else made and configured: frame phases, the * widget or viewer someone else made and configured: frame phases, the
* draw sets, `setView` and the pose, picks and projection, and capture. * 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 * The conformance renderer (`sceneRenderer.js`) and the application's
* renderer (`appRenderer.js`) both use them, so what conformance * renderer (`appRenderer.js`) both use them, so what conformance
@@ -233,8 +234,12 @@ export function createCesiumPorts(widget, { capabilities }) {
if (scene.requestRenderMode === !continuous) return; if (scene.requestRenderMode === !continuous) return;
scene.requestRenderMode = !continuous; 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 suspended = false;
let targetFps = 0; let targetFps = widget.targetFrameRate > 0 ? widget.targetFrameRate : 0;
// Pending one-shot renders; disposal settles them rather than leave them // Pending one-shot renders; disposal settles them rather than leave them
// attached to a scene that will not render again. // attached to a scene that will not render again.
+12
View File
@@ -442,3 +442,15 @@ test('releasing the last hold asks for one more frame', () => {
assert.equal(requests(), before + 1, 'a second release does nothing'); assert.equal(requests(), before + 1, 'a second release does nothing');
ports.dispose(); 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();
});
+55 -54
View File
@@ -8,22 +8,23 @@
* whenever nothing animates per frame, and return to the continuous loop the * whenever nothing animates per frame, and return to the continuous loop the
* moment something does. * 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) * - **Continuous mode** (every frame drawn) while ANY hold is registered. Every per-frame animator — fleet
* while ANY hold is registered. Every per-frame animator — fleet
* interpolation, traffic sim, satellite motion, tracked-entity follow, * interpolation, traffic sim, satellite motion, tracked-entity follow,
* style crossfades, CCTV projection — registers a hold for exactly the * style crossfades, CCTV projection — registers a hold for exactly the
* lifetime of its scene-loop listener or animation. While one is active, * lifetime of its scene-loop listener or animation. While one is active,
* behavior is byte-identical to pre-governor main: the locked * behavior is byte-identical to pre-governor main: the locked
* interpolation/tracking invariants are preserved by construction. * interpolation/tracking invariants are preserved by construction.
* - **Idle mode** (`requestRenderMode = true`) when zero holds. Cesium * - **Idle mode** (drawn on demand) when zero holds. The renderer draws
* auto-renders on camera input and tile loads; every other scene mutation * on camera input and tile loads by itself; every other scene mutation
* must call `governorRequestRender()` for its one frame. Discrete * must call `governorRequestRender()` for its one frame. Discrete
* mutators (layer poll ticks, slider writes, annotation changes) route * mutators (layer poll ticks, slider writes, annotation changes) route
* through that. * 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 * that double-holds or double-releases cannot corrupt the mode. Owners are
* short stable strings ('flights', 'traffic', 'style-anim', …) so the * short stable strings ('flights', 'traffic', 'style-anim', …) so the
* diagnostics read like a story. * diagnostics read like a story.
@@ -31,45 +32,45 @@
* The governor is O(1) passive: no per-frame work of its own, ever. * The governor is O(1) passive: no per-frame work of its own, ever.
*/ */
let _viewer = null; /** @typedef {import('./render/contract/frame').FramePort} FramePort */
let _installed = false;
const _holds = new Set(); 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). */ /** Debug trail of the most recent one-shot render requests (idle mode only). */
const _recentRequests = []; const _recentRequests = [];
const RECENT_REQUEST_CAP = 16; const RECENT_REQUEST_CAP = 16;
function applyMode() { function holdOnFrame(ownerId) {
if (!_installed || !_viewer?.scene) return; if (_frame && !_holds.get(ownerId))
const continuous = _holds.size > 0; _holds.set(ownerId, _frame.holdContinuous(ownerId));
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?.();
}
} }
/** /**
* Install the governor on the viewer. Idempotent. Before install, * Install the governor on the renderer's frame port, which keeps the loop
* hold/release still record into the holds set (and apply at install time); * drawing every frame while any owner holds and on demand otherwise (and
* requests are safe no-ops — so modules can call all three unconditionally * draws one settling frame when the last hold goes). Idempotent. Before
* in tests without a viewer. * install, hold/release still record into the holds (and apply at install
* @param {Cesium.Viewer} viewer * time); requests are safe no-ops — so modules can call all three
* unconditionally in tests without a renderer.
* @param {FramePort} frame
* @returns {void} * @returns {void}
*/ */
export function installRenderGovernor(viewer) { export function installRenderGovernor(frame) {
if (!viewer?.scene) if (typeof frame?.holdContinuous !== 'function')
throw new TypeError('installRenderGovernor requires a Cesium viewer'); throw new TypeError('installRenderGovernor requires a frame port');
_viewer = viewer; if (_frame === frame) return;
_installed = true; if (_frame) releaseFrameHolds();
// Never let Cesium re-render on simulation-time deltas behind our back — _frame = frame;
// idle means idle. All re-renders are camera/tiles (Cesium-native) or for (const ownerId of _holds.keys()) holdOnFrame(ownerId);
// explicit requests. }
viewer.scene.maximumRenderTimeChange = Infinity;
applyMode(); 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) { export function holdContinuousRender(ownerId) {
if (!ownerId) return; if (!ownerId) return;
_holds.add(ownerId); if (!_holds.has(ownerId)) _holds.set(ownerId, null);
applyMode(); holdOnFrame(ownerId);
} }
/** /**
@@ -93,27 +94,28 @@ export function holdContinuousRender(ownerId) {
* @returns {void} * @returns {void}
*/ */
export function releaseContinuousRender(ownerId) { export function releaseContinuousRender(ownerId) {
if (!ownerId) return; if (!ownerId || !_holds.has(ownerId)) return;
const release = _holds.get(ownerId);
_holds.delete(ownerId); _holds.delete(ownerId);
applyMode(); release?.();
} }
/** /**
* One-shot render request for a discrete scene mutation (layer tick, slider * One-shot render request for a discrete scene mutation (layer tick, slider
* write, annotation change). Always forwards to scene.requestRender() — in * write, annotation change). Always forwards to the frame port — while held
* continuous mode that is a harmless flag set (and forwarding closes the * that is harmless (and forwarding closes the request-then-last-release
* request-then-last-release race); only idle-mode requests are recorded in * race); only idle-mode requests are recorded in diagnostics. Cheap enough
* diagnostics. Cheap enough to call unconditionally after any mutation. * to call unconditionally after any mutation.
* @param {string} [reason] For diagnostics only. * @param {string} [reason] For diagnostics only.
* @returns {void} * @returns {void}
*/ */
export function governorRequestRender(reason = 'unspecified') { export function governorRequestRender(reason = 'unspecified') {
if (!_installed || !_viewer?.scene) return; if (!_frame) return;
if (_holds.size === 0) { if (_holds.size === 0) {
_recentRequests.push({ reason, at: Date.now() }); _recentRequests.push({ reason, at: Date.now() });
if (_recentRequests.length > RECENT_REQUEST_CAP) _recentRequests.shift(); 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() { export function getRenderGovernorDiagnostics() {
return { return {
installed: _installed, installed: Boolean(_frame),
mode: _holds.size > 0 ? 'continuous' : 'idle', mode: _holds.size > 0 ? 'continuous' : 'idle',
holds: [..._holds].sort(), holds: [..._holds.keys()].sort(),
recentRequests: [..._recentRequests], recentRequests: [..._recentRequests],
}; };
} }
/** Release the installed viewer after its animation owners have stopped. */ /** Release the installed frame port after its animation owners have stopped. */
export function uninstallRenderGovernor(viewer) { export function uninstallRenderGovernor(frame) {
if (_viewer !== viewer) return; if (!_frame || _frame !== frame) return;
_viewer = null; releaseFrameHolds();
_installed = false; _frame = null;
_holds.clear(); _holds.clear();
_recentRequests.length = 0; _recentRequests.length = 0;
} }
/** Test seam: reset module state between unit tests. */ /** Test seam: reset module state between unit tests. */
export function _resetRenderGovernorForTest() { export function _resetRenderGovernorForTest() {
_viewer = null; _frame = null;
_installed = false;
_holds.clear(); _holds.clear();
_recentRequests.length = 0; _recentRequests.length = 0;
} }
+33 -8
View File
@@ -7,7 +7,9 @@ import {
governorRequestRender, governorRequestRender,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
uninstallRenderGovernor,
} from './renderGovernor.js'; } from './renderGovernor.js';
import { framePortFor } from './testSupport/fakeFramePort.mjs';
function makeViewer() { function makeViewer() {
const calls = { requestRender: 0 }; const calls = { requestRender: 0 };
@@ -21,17 +23,40 @@ function makeViewer() {
beforeEach(() => _resetRenderGovernorForTest()); 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(); const { viewer, scene } = makeViewer();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
assert.equal(scene.requestRenderMode, true); assert.equal(scene.requestRenderMode, true);
assert.equal(scene.maximumRenderTimeChange, Infinity);
assert.equal(getRenderGovernorDiagnostics().mode, 'idle'); 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', () => { test('a hold flips to continuous; releasing the last hold returns to idle with a settling frame', () => {
const { viewer, scene, calls } = makeViewer(); const { viewer, scene, calls } = makeViewer();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
const settleBaseline = calls.requestRender; const settleBaseline = calls.requestRender;
holdContinuousRender('flights'); holdContinuousRender('flights');
assert.equal(scene.requestRenderMode, false); 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', () => { test('holds are identity-keyed: double-hold cannot leak, double-release cannot corrupt', () => {
const { viewer, scene } = makeViewer(); const { viewer, scene } = makeViewer();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
holdContinuousRender('traffic'); holdContinuousRender('traffic');
holdContinuousRender('traffic'); holdContinuousRender('traffic');
releaseContinuousRender('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', () => { test('mode stays continuous until the LAST holder releases', () => {
const { viewer, scene } = makeViewer(); const { viewer, scene } = makeViewer();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
holdContinuousRender('flights'); holdContinuousRender('flights');
holdContinuousRender('satellites'); holdContinuousRender('satellites');
releaseContinuousRender('flights'); 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', () => { test('governorRequestRender forwards to the scene and records reasons only in idle mode', () => {
const { viewer, calls } = makeViewer(); const { viewer, calls } = makeViewer();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
const baseline = calls.requestRender; const baseline = calls.requestRender;
governorRequestRender('layer-tick:earthquakes'); governorRequestRender('layer-tick:earthquakes');
assert.equal(calls.requestRender, baseline + 1); 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', () => { test('holds registered before install apply at install time', () => {
holdContinuousRender('flights'); holdContinuousRender('flights');
const { viewer, scene } = makeViewer(); const { viewer, scene } = makeViewer();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
assert.equal(scene.requestRenderMode, false, 'pre-install hold keeps continuous mode'); assert.equal(scene.requestRenderMode, false, 'pre-install hold keeps continuous mode');
releaseContinuousRender('flights'); releaseContinuousRender('flights');
assert.equal(scene.requestRenderMode, true); assert.equal(scene.requestRenderMode, true);
+44
View File
@@ -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?.();
};
},
};
}