diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2ef7c4ea3..af267483c 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -534,7 +534,7 @@ perform no network requests and discover no additional modules. `maps/controller` coordinates scene changes and lifetimes. `maps/imagery`, `maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the standard sources and their setup/fallback policy. The standalone facade wires -these to the application's render governor. +these to their engine's render requests. A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`. Each source has a user-facing `descriptor`, availability/reason, and either an @@ -638,8 +638,11 @@ refresh policy. `app/layerSession.js` defines the retained session fields and handoff predicate. `standalone/sceneSwitcher.js` coordinates page handoff; `ui/shellScene.js` owns scene-bound shell construction, while `applicationShell` keeps page controls and policy. Request, host and shell queues are not yet one -queue. Overlay, render-governor, tracked-readout and pick services still include -module singletons; debug access still exposes the native viewer and tileset. +queue. Pick ownership, selection dispatch and render demand belong to +`scene.services`; render demand drives the engine contract, with no dependency +from the Cesium render backend to the governor. Overlay and tracked-readout +services still include module singletons; debug access still exposes the native +viewer and tileset. ## UI state owners diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c5421eb4b..4c76d0428 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -277,8 +277,13 @@ one application, and only the globe-bound parts move to the next engine. resolve these services with their attached engine; simultaneous engines do not share owner predicates or click subscriptions. Detach releases both services before the renderer is parked. Reattachment creates fresh services, - while retained layers register their current picks again. The overlay host, - render governor and tracked readout still have module-owned state. + while retained layers register their current picks again. +- **Scene render demand.** `scene.services.render` owns the attachment's holds + and recent idle requests. It drives `engine.render` on both engines; + Cesium implements its render mode directly. Retained layers identify their + attached engine, and scene callbacks capture their governor. Teardown clears + only that scene's holds; an old service cannot release a replacement's hold. + The overlay host and tracked readout still have module-owned state. - **The session owns the state.** Catalog, layer models, sources, selection, camera mode and URL state outlive a switch. A layer that is a session model gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt, diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 1d6b9bf72..ba7e79fe9 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -547,6 +547,24 @@ the same services on first use. The application destroys them on detach and creates fresh services when reattaching a parked engine. Identical layer IDs in simultaneous scenes cannot replace or unregister one another's picks. +## Render demand + +`scene.services.render` owns continuous-render holds and the last 16 idle +request reasons for one attachment. `holdContinuousRender(id)` and +`releaseContinuousRender(id)` are idempotent per owner; the last release +requests one settling frame. `governorRequestRender(reason)` forwards a +one-shot request even while another owner holds continuous rendering. +The tools call `install()` after scene consumers have registered their holds. + +The governor drives `engine.render` on every engine. Cesium implements +`setContinuous` with its scene's `requestRenderMode` and disables automatic +simulation-time renders with `maximumRenderTimeChange = Infinity`. Engine +internals request their own scene directly and never import the governor. +Layer service helpers take their attached engine as the final argument; +attachment-bound callbacks can capture `renderGovernorFor(engine)` directly. +Destroying the scene clears its holds and makes the captured service inert. +A parked engine gets a fresh service when reattached. + ## Render effects The optional `visualStyles`, `bloom` and `contactSonar` capabilities are diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 61f646732..05e830c14 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -3,14 +3,14 @@ "cesiumLayerRendererLines": 0, "navaraLayerRendererLines": 189, "cesiumImportFiles": 3, - "viewerLines": 910, - "navaraKindChecks": 2, + "viewerLines": 891, + "navaraKindChecks": 1, "engineSniffCalls": 0, "engineExtensionReads": 3, "groundEffectPasses": 1, "cesiumSdkLoads": 3, "screenSpaceHandlerFiles": 4, - "engineNameLines": 33 + "engineNameLines": 32 }, - "measuredAt": "Enforce SDK ownership, relocate annotation and wind backends, and rehearse every shared display" + "measuredAt": "Scope render demand to scene attachments and make the Cesium render backend independent" } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 57bbaa5e4..da5967c6c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -191,7 +191,6 @@ "src/globe/cesium/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", - "src/renderGovernor.js", "src/globe/contract.js", "src/maps/google3d.js" ], @@ -980,6 +979,11 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/renderGovernor.js", + "src/data/sceneServices.js", + "src/data/pickRegistry.js", + "src/data/selectionDispatcher.js", + "src/data/trackingGesture.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", "src/i18n/marker.js", diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 8a2747cbd..a687f6fed 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -1226,7 +1226,7 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 2000)); remove(); return { frames, durationMs: performance.now() - started, - governor: getRenderGovernorDiagnostics() }; + governor: getRenderGovernorDiagnostics(app.engine) }; }); const geomAfter30s = await serializeGeom(activeId); evidence.geometry = { diff --git a/scripts/qa-military-names.mjs b/scripts/qa-military-names.mjs index 6afb86000..d27cac453 100644 --- a/scripts/qa-military-names.mjs +++ b/scripts/qa-military-names.mjs @@ -190,7 +190,7 @@ try { // Use the governor's hold so an asynchronous idle transition cannot // overwrite a raw scene flag during the sustained-render measurement. const { holdContinuousRender } = await import('/src/renderGovernor.js'); - holdContinuousRender('qa-military-names-fps'); + holdContinuousRender('qa-military-names-fps', window.__godsEyeView.engine); }); const t0 = await pageNow(page); await sleep(3500); @@ -198,7 +198,7 @@ try { await page.evaluate(async () => { const { releaseContinuousRender } = await import('/src/renderGovernor.js'); - releaseContinuousRender('qa-military-names-fps'); + releaseContinuousRender('qa-military-names-fps', window.__godsEyeView.engine); }); const metrics = frameStats(frames); const seconds = frames.reduce((sum, frame) => sum + frame.dt, 0) / 1000; diff --git a/src/annotations/adminOutline.test.mjs b/src/annotations/adminOutline.test.mjs index b0f4ece87..e12960e52 100644 --- a/src/annotations/adminOutline.test.mjs +++ b/src/annotations/adminOutline.test.mjs @@ -5,7 +5,6 @@ import assert from 'node:assert/strict'; import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; import { createAnnotationResolver } from './resolver.js'; import { createAnnotationEngine } from './annotationEngine.js'; -import { _resetRenderGovernorForTest } from '../renderGovernor.js'; import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs'; function viewerAt(lat, lon, height = 20_000) { @@ -253,8 +252,6 @@ test('non-admin asks and entity facts keep the existing path', async () => { test('the engine keeps every part on the mark, drawn at once or upgraded', async (t) => { globalThis.requestAnimationFrame ??= () => 1; globalThis.cancelAnimationFrame ??= () => {}; - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const polygons = [ [ [ diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 9c7201f3c..3cac9d390 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -2,11 +2,7 @@ import { isUnavailableCapability } from '../sources/capability.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as geo from '../geo/index.js'; import { greatCircleMeters } from '../geoDistance.js'; -import { - holdContinuousRender, - releaseContinuousRender, - governorRequestRender, -} from '../renderGovernor.js'; +import { renderGovernorFor } from '../renderGovernor.js'; import { isRateLimitedOutcome, resolveAnnotationTarget, @@ -141,6 +137,7 @@ export function createAnnotationEngine({ outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS, resolveTarget = resolveAnnotationTarget, }) { + let renderGovernor = renderGovernorFor(globe); /** @type {Map} live annotations keyed by id */ const annotations = new Map(); let destroyed = false; @@ -220,8 +217,8 @@ export function createAnnotationEngine({ // hidden from Draw's list lets the scene idle. function syncAnnotationHold() { const visible = [...annotations.values()].some((anno) => !anno.hidden); - if (visible) holdContinuousRender('annotations'); - else releaseContinuousRender('annotations'); + if (visible) renderGovernor?.holdContinuousRender('annotations'); + else renderGovernor?.releaseContinuousRender('annotations'); } /** @@ -386,7 +383,7 @@ export function createAnnotationEngine({ syncAnnotationHold(); // The frame that takes a just-hidden mark off: with every mark hidden // the hold is gone, and nothing else would draw it. - governorRequestRender('annotations'); + renderGovernor?.governorRequestRender('annotations'); } if (expired) notifyChange(); // Stop once the board is STABLE — persistent marks (persist=true is the default) @@ -1616,7 +1613,7 @@ export function createAnnotationEngine({ // mark hidden before its first frame has no alpha change to wait for. renderer.sync(annotations); syncAnnotationHold(); - governorRequestRender('annotations'); + renderGovernor?.governorRequestRender('annotations'); ensureTicking(); notifyChange(); return true; @@ -1817,6 +1814,8 @@ export function createAnnotationEngine({ dropQueuedOutlineUpgrades(); generation++; const previous = renderer; + const previousGovernor = renderGovernor; + renderGovernor = null; renderer = DETACHED_RENDERER; globe = null; for (const anno of annotations.values()) { @@ -1830,7 +1829,7 @@ export function createAnnotationEngine({ try { previous.destroy(); } finally { - releaseContinuousRender('annotations'); + previousGovernor?.releaseContinuousRender('annotations'); } }, /** @@ -1841,6 +1840,7 @@ export function createAnnotationEngine({ if (destroyed) return; engine.detachRenderer(); globe = nextGlobe; + renderGovernor = renderGovernorFor(globe); renderer = nextRenderer ?? DETACHED_RENDERER; for (const anno of annotations.values()) { try { @@ -1853,7 +1853,7 @@ export function createAnnotationEngine({ renderer.sync(annotations); syncAnnotationHold(); ensureTicking(); - governorRequestRender('annotations'); + renderGovernor?.governorRequestRender('annotations'); }, /** Whether a globe draws the marks now. */ get attached() { @@ -1869,7 +1869,7 @@ export function createAnnotationEngine({ tickHandle = null; outlineListeners.clear(); changeListeners.clear(); - releaseContinuousRender('annotations'); + renderGovernor?.releaseContinuousRender('annotations'); renderer.destroy(); } }, diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 544d81a4d..277310fad 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; // Voice-annotation resilience contract tests — pure logic, no network, no browser. // @@ -22,8 +23,7 @@ import { import { resolveAnnotationTarget } from './annotationResolver.js'; import { getRenderGovernorDiagnostics, - installRenderGovernor, - _resetRenderGovernorForTest, + renderGovernorFor, } from '../renderGovernor.js'; // A resolver that replays a scripted sequence of outcomes (undefined = transient, @@ -241,7 +241,7 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight', }; }; const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget, }); @@ -313,7 +313,7 @@ test('outline queue: clear drops queued-but-unstarted upgrades without a later f }), }); engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget, }); @@ -379,7 +379,7 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429 }); const resolved = await resolveAnnotationTarget({ - globe: {}, + globe: testGlobe, target: 'PT2 rate-limit fixture', latitude: 12.345, longitude: 67.89, @@ -453,7 +453,7 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde }); const resolved = await resolveAnnotationTarget({ - globe: {}, + globe: testGlobe, target: 'PT2 transient fixture', latitude: -12.345, longitude: -67.89, @@ -711,11 +711,9 @@ const BUILDING_FP = { test('fresh async building batch reserves slots together and releases failed/cleared installs', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer: h.renderer, resolveTarget: async ({ target }) => ({ ...BUILDING_FP, @@ -744,7 +742,7 @@ test('fresh async building batch reserves slots together and releases failed/cle assert.equal(engine.list()[0].pendingOutline, false); engine.clear(); assert.equal(engine.count(), 0); - assert.ok(!getRenderGovernorDiagnostics().holds.includes('annotations')); + assert.ok(!getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations')); const cancelled = engine.annotate([{ type: 'area', target: 'third' }]); await flushMicrotasks(); @@ -762,7 +760,7 @@ test('outline workers retain completion ownership and clear cancels resolved-but const h = controlledBuildingRenderer(); const started = []; const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer: h.renderer, resolveTarget: async ({ target }) => ({ ...FIXED_POINT, @@ -805,7 +803,7 @@ test('building recaption timeout retains old route and a newer replacement owns installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); @@ -860,7 +858,7 @@ test('replacement cleanup failure still removes the old route and retains failed installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]); await flushMicrotasks(); @@ -905,7 +903,7 @@ test('identical refresh revokes an older building replacement even if cancellati installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]); await flushMicrotasks(); h.pending[0].finish(); await initial; @@ -936,7 +934,7 @@ test('successful replacement survives failed old-route retirement and keeps clea installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]); await flushMicrotasks(); h.pending[0].finish(); await initial; @@ -975,7 +973,7 @@ test('pending fresh predecessor failure cannot erase a newer building intent', a globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; }; const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const first = engine.annotate([{ type: 'area', target: 'Capitol', label: 'First' }]); await flushMicrotasks(); @@ -1004,7 +1002,7 @@ test('duplicate pending building results reflect final failure rather than a dea installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const spec = { type: 'area', target: 'Capitol', label: 'Same' }; const batch = engine.annotate([spec, spec]); @@ -1023,7 +1021,7 @@ test('separate duplicate callers observe the original building install outcome', for (const outcome of ['installed', 'failed', 'timeout', 'clear']) { const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); @@ -1077,7 +1075,7 @@ test('a duplicate joins the physical deferred install but not another outline lo const h = controlledBuildingRenderer(); let lookups = 0; const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer: h.renderer, resolveTarget: async () => ({ ...FIXED_POINT, @@ -1114,12 +1112,10 @@ test('a duplicate joins the physical deferred install but not another outline lo test('duplicate-replacement: a renderer throw leaves no phantom mark and no leaked hold', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, failAddOnce } = throwingRendererHarness(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1130,7 +1126,7 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak assert.equal(first.drawn, 1); assert.equal(engine.list().length, 1); assert.ok( - getRenderGovernorDiagnostics().holds.includes('annotations'), + getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'), 'a live mark must hold continuous render', ); @@ -1161,19 +1157,17 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak 'no phantom annotation survives the failed swap', ); assert.ok( - !getRenderGovernorDiagnostics().holds.includes('annotations'), + !getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'), 'the annotations hold must be released — a leak defeats the idle governor permanently', ); }); test('fresh path: a renderer throw is rolled back the same way', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, failAddOnce } = throwingRendererHarness(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1190,7 +1184,7 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => { 'no phantom annotation from a failed first add', ); assert.ok( - !getRenderGovernorDiagnostics().holds.includes('annotations'), + !getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'), 'a never-rendered mark must not hold continuous render', ); }); @@ -1232,12 +1226,10 @@ function partialStateRendererHarness() { test('fresh path: a throw AFTER partial renderer state leaves nothing on the board', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, failAddOnce, live } = partialStateRendererHarness(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1254,7 +1246,7 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa 'the rollback must release what the renderer had already created', ); assert.ok( - !getRenderGovernorDiagnostics().holds.includes('annotations'), + !getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'), 'a never-rendered mark must not hold continuous render', ); @@ -1273,12 +1265,10 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing on the board', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, failAddOnce, live } = partialStateRendererHarness(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1308,7 +1298,7 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing ); assert.equal(live.size, 0, 'and no half-swapped mark survives on the board'); assert.ok( - !getRenderGovernorDiagnostics().holds.includes('annotations'), + !getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'), 'the annotations hold must be released', ); @@ -1335,7 +1325,7 @@ test('destroy releases the renderer and prevents subsequent annotation work', as renderer.destroy = () => { destroyed++; }; - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); const drawn = await engine.annotate([ { type: 'pin', longitude: -97.74, latitude: 30.27 }, ]); @@ -1350,7 +1340,7 @@ test('destroy releases the renderer and prevents subsequent annotation work', as assert.equal(engine.count(), 0); engine.destroy(); assert.equal(destroyed, 1); - assert.ok(!getRenderGovernorDiagnostics().holds.includes('annotations')); + assert.ok(!getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations')); }); test('a late annotation resolver cannot redraw after destruction', async (t) => { @@ -1360,7 +1350,7 @@ test('a late annotation resolver cannot redraw after destruction', async (t) => let release; let signal; const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: (options) => { signal = options.signal; @@ -1419,13 +1409,11 @@ test('caller cancellation prevents a late annotation and camera mutation', async test('only an explicit navigation request permits resolving distant annotation targets', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer } = throwingRendererHarness(); renderer.destroy = () => {}; const received = []; const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: async (options) => { received.push(options); @@ -1449,7 +1437,7 @@ test('recaption inherits outline credit from borrowed geometry rather than its n const { renderer } = fakeRenderer(); let first = true; const ring = [[-99,31],[-98.99,31],[-98.99,31.01],[-99,31]]; - const engine = createAnnotationEngine({ globe: {}, renderer, + const engine = createAnnotationEngine({ globe: testGlobe, renderer, resolveTarget: async () => { if (first) { first = false; return { ...FIXED_POINT, ring, footprintKind: 'area', outlineCredit: 'tiles' }; } return { ...FIXED_POINT, source: 'osm-local', resolveOutline: async () => null }; @@ -1467,8 +1455,6 @@ test('recaption inherits outline credit from borrowed geometry rather than its n // frame and remove it, and hear about every change to the board. test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and removed', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, calls } = fakeRenderer(); const flights = []; const engine = createAnnotationEngine({ @@ -1529,11 +1515,9 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec globalThis.cancelAnimationFrame = () => {}; let now = 1_000_000; t.mock.method(performance, 'now', () => now); - _resetRenderGovernorForTest(); t.after(() => { globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; - _resetRenderGovernorForTest(); }); const runFrames = () => { for (let i = 0; i < 5 && frames.length; i += 1) frames.shift()(); @@ -1636,10 +1620,8 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec test('hand-drawn marks: a voice mark or a hidden mark is not taken as their duplicate', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); await engine.annotate( [ { @@ -1688,11 +1670,9 @@ test('hand-drawn marks: a voice mark or a hidden mark is not taken as their dupl test('hand-drawn marks: only a manual mark can be restyled', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer } = fakeRenderer(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1707,7 +1687,7 @@ test('a drawn line that rounds up to the next unit is labelled in it, not as 100 installAnimationFrameStubs(t); const labels = []; const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} }; - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; for (const metres of [994, 996, 9940, 9960]) { await engine.annotate([ @@ -1720,10 +1700,8 @@ test('a drawn line that rounds up to the next unit is labelled in it, not as 100 test('hand-drawn marks: two unnamed pins close together are two pins, whatever their colours', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color }); await engine.annotate([pin(-97.74, 'primary')], { persist: true }); await engine.annotate([pin(-97.7402, 'primary')], { persist: true }); @@ -1737,36 +1715,33 @@ test('hand-drawn marks: two unnamed pins close together are two pins, whatever t test('hand-drawn marks: hiding a mark before its first frame takes it off at once', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); let syncs = 0; const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} }; - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true }); const [mark] = engine.list(); const before = syncs; engine.setHidden(mark.id, true); assert.ok(syncs > before, 'the renderer hears about it now'); assert.ok( - !getRenderGovernorDiagnostics().holds.includes('annotations'), + !getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'), 'and the render hold goes with the last visible mark', ); }); test('hand-drawn marks: with every mark hidden the scene idles, and showing one wakes it', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); let renders = 0; - installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } }); + testGlobe.render.request = () => (renders += 1); + renderGovernorFor(testGlobe).install(); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); await engine.annotate([ { type: 'pin', manual: true, latitude: 1, longitude: 1 }, { type: 'pin', manual: true, latitude: 2, longitude: 2 }, ], { persist: true }); const [first, second] = engine.list(); - const held = () => getRenderGovernorDiagnostics().holds.includes('annotations'); + const held = () => getRenderGovernorDiagnostics(testGlobe).holds.includes('annotations'); assert.ok(held(), 'visible marks keep the scene drawing'); engine.setHidden(first.id, true); assert.ok(held(), 'one visible mark is enough'); @@ -1780,12 +1755,10 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off and says so', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, failAddOnce } = throwingRendererHarness(); let syncs = 0; renderer.sync = () => (syncs += 1); - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true }); const [mark] = engine.list(); let changes = 0; @@ -1799,8 +1772,6 @@ test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off an }); test('hand-drawn marks: a mark shown again right after it was drawn appears at once, not fading in', async (t) => { - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const queued = []; const raf = globalThis.requestAnimationFrame; const caf = globalThis.cancelAnimationFrame; @@ -1812,7 +1783,7 @@ test('hand-drawn marks: a mark shown again right after it was drawn appears at o }); const tick = () => queued.splice(0).forEach((callback) => callback()); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ globe: {}, renderer }); + const engine = createAnnotationEngine({ globe: testGlobe, renderer }); await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true }); const [mark] = engine.list(); engine.setHidden(mark.id, true); @@ -1825,8 +1796,6 @@ test('hand-drawn marks: a mark shown again right after it was drawn appears at o test('removing one mark whose cleanup fails keeps it addressable for a retry', async (t) => { installAnimationFrameStubs(t); - _resetRenderGovernorForTest(); - t.after(() => _resetRenderGovernorForTest()); const { renderer, calls } = fakeRenderer(); let failRemove = true; renderer.remove = () => { @@ -1851,7 +1820,7 @@ test('a building install in the batch does not re-judge marks that never install installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - globe: {}, + globe: testGlobe, renderer: h.renderer, resolveTarget: async ({ target }) => target === 'Tower' ? { ...BUILDING_FP, lon: -97.7 } : { ...FIXED_POINT }, @@ -1874,7 +1843,7 @@ test('plain recaption keeps the old mark addressable when its removal throws', a installAnimationFrameStubs(t); const { renderer } = fakeRenderer(); const engine = createAnnotationEngine({ - globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), + globe: testGlobe, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); await engine.annotate([{ type: 'point', target: 'Capitol', label: 'Capitol' }]); const old = engine.list()[0]; @@ -1899,3 +1868,7 @@ test('plain recaption keeps the old mark addressable when its removal throws', a ); assert.equal(engine.count(), 0); }); + +let testGlobe; +test.beforeEach(() => { testGlobe = createFakeEngine(); }); +test.afterEach(() => { renderGovernorFor(testGlobe).destroy(); testGlobe.destroy(); }); diff --git a/src/app/debugHandleSwitch.test.mjs b/src/app/debugHandleSwitch.test.mjs index 952103cea..8bd5fbbcb 100644 --- a/src/app/debugHandleSwitch.test.mjs +++ b/src/app/debugHandleSwitch.test.mjs @@ -6,7 +6,6 @@ import assert from 'node:assert/strict'; import { createApplicationTools } from './tools.js'; import { createSceneHost } from './sceneHost.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; -import { _resetRenderGovernorForTest } from '../renderGovernor.js'; test('the debug handle leaves window between engines and comes back the same', async (t) => { const saved = { @@ -32,7 +31,6 @@ test('the debug handle leaves window between engines and comes back the same', a removeEventListener() {}, }; t.after(() => { - _resetRenderGovernorForTest(); for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete globalThis[name]; else globalThis[name] = value; diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index c07780bce..f389b7eaf 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -45,7 +45,9 @@ test('the scene engine defaults to the Cesium engine factory', () => { test('the scene constructs the engine with the viewer and returns it beside the viewer', () => { const scene = read('src/app/scene.js'); - const viewerTeardown = scene.indexOf('uninstallRenderGovernor(viewer);'); + const viewerTeardown = scene.indexOf( + 'if (!viewer.isDestroyed()) viewer.destroy();', + ); const engineAt = scene.indexOf('createSceneEngine(viewer, {'); assert.ok(viewerTeardown >= 0 && engineAt >= 0); // Cleanups run last-in first-out: the engine goes before the viewer. diff --git a/src/app/layerPresentation.js b/src/app/layerPresentation.js index 80cc3aeef..72894b5f7 100644 --- a/src/app/layerPresentation.js +++ b/src/app/layerPresentation.js @@ -8,7 +8,7 @@ export class LayerPresentation { manager, { weatherClock, - requestRender = governorRequestRender, + requestRender = (reason) => governorRequestRender(reason, manager.engine), invalidateDetection = markDetectionSourcesChanged, } = {}, ) { diff --git a/src/app/localGeojsonServices.js b/src/app/localGeojsonServices.js index 66189c3e5..a5552d02c 100644 --- a/src/app/localGeojsonServices.js +++ b/src/app/localGeojsonServices.js @@ -1,5 +1,4 @@ import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; -import { governorRequestRender } from '../renderGovernor.js'; import { clearSelectedEntityContextForLayer, registerEntityContext, @@ -24,7 +23,6 @@ export const localGeoJsonServices = Object.freeze({ removeEntityContextsForLayer, selectEntityContext, getSelectedEntityContext, - governorRequestRender, showOsmCredit, hideOsmCredit, }); diff --git a/src/app/mapLifecycle.test.mjs b/src/app/mapLifecycle.test.mjs index ef015bf52..2a2e6df6f 100644 --- a/src/app/mapLifecycle.test.mjs +++ b/src/app/mapLifecycle.test.mjs @@ -1,13 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { MapStackController } from '../mapStackController.js'; -import { - installRenderGovernor, - uninstallRenderGovernor, - holdContinuousRender, - governorRequestRender, - getRenderGovernorDiagnostics, -} from '../renderGovernor.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createSceneServices } from '../data/sceneServices.js'; test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { let resolveProvider; @@ -35,23 +30,18 @@ test('destroy invalidates a pending imagery provider before it can touch the vie assert.equal(removed, 1); }); -test('uninstall only releases the current viewer and makes later render requests inactive', () => { - let renders = 0; - const viewer = { - scene: { - requestRender: () => { - renders++; - }, - }, - }; - installRenderGovernor(viewer); - holdContinuousRender('fixture'); - uninstallRenderGovernor({}); - assert.equal(getRenderGovernorDiagnostics().installed, true); - uninstallRenderGovernor(viewer); - const before = renders; - governorRequestRender('late'); - assert.equal(renders, before); - assert.equal(getRenderGovernorDiagnostics().installed, false); - assert.deepEqual(getRenderGovernorDiagnostics().holds, []); +test('scene teardown makes its later render requests inactive', () => { + const engine = createFakeEngine(); + const services = createSceneServices(engine); + const governor = services.render; + governor.install(); + governor.holdContinuousRender('fixture'); + assert.equal(governor.getRenderGovernorDiagnostics().installed, true); + services.destroy(); + engine.fake.resetCalls(); + governor.governorRequestRender('late'); + assert.equal(engine.fake.callsOf('render.request').length, 0); + assert.equal(governor.getRenderGovernorDiagnostics().installed, false); + assert.deepEqual(governor.getRenderGovernorDiagnostics().holds, []); + engine.destroy(); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 5b505fde4..19e86da86 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -7,11 +7,6 @@ import { t } from '../i18n/index.js'; import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js'; import { createLayerAvailability } from './layers/renderers.js'; -import { - governorRequestRender, - installEngineRenderGovernor, - uninstallRenderGovernor, -} from '../renderGovernor.js'; import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; /** The Navara globe module, loaded on demand. */ @@ -93,12 +88,10 @@ export async function createNavaraScene({ }); defer(() => globe.destroy()); const { engine } = globe; - // The tools install the governor on this engine; it goes before the engine. - defer(() => uninstallRenderGovernor(engine)); signal.throwIfAborted(); loaderStatus.textContent = t('Initializing systems...'); const mapStackController = new MapController(null, { - requestRender: governorRequestRender, + requestRender: (reason) => engine.render.request(reason), engine, ...mapOptions, cesiumToken, @@ -125,7 +118,6 @@ export async function createNavaraScene({ engine, tileset: null, suspend() { - uninstallRenderGovernor(engine); engine.camera.cancelFlight(); engine.render.setContinuous(false); globe.setRenderLoop(false); @@ -156,9 +148,6 @@ export function navaraToolBackend({ engine }) { // The session's tool on the engine's input and preview. draw: () => ({ engine }), recentImagery: () => ({ engine }), - // Holds drive engine.render.setContinuous; requests go to the engine. A - // parked engine uninstalls it (createNavaraScene suspend). - installGovernor: () => installEngineRenderGovernor(engine), attach({ data, styleManager }, { signal, defer }) { void styleManager.initialRestorePromise?.then(() => { if (!signal.aborted) views.alignDaylightToRestoredView(); diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index b1b0d525d..023ce929f 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -3,10 +3,7 @@ import assert from 'node:assert/strict'; import { createApplicationScene } from './scene.js'; import { sceneServicesFor } from '../data/sceneServices.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; -import { - installEngineRenderGovernor, - isRenderGovernorInstalled, -} from '../renderGovernor.js'; +import { renderGovernorFor } from '../renderGovernor.js'; // The scene's Navara branch in Node: the globe loader is a seam, so the fake // engine stands in for Navara and nothing Cesium is built. @@ -165,12 +162,15 @@ test('the Navara scene tears down the controller, then the globe', async (t) => const run = await startScene(); // The tools install the governor on the engine (src/app/tools.js); the // scene removes it before the engine goes. - installEngineRenderGovernor(run.engine); + renderGovernorFor(run.engine).install(); const view = globalThis.window; await run.teardown(); assert.equal(run.globeDestroyed(), 1); assert.equal(run.engine.isDestroyed(), true); - assert.equal(isRenderGovernorInstalled(run.engine), false); + assert.equal( + renderGovernorFor(run.engine).getRenderGovernorDiagnostics().installed, + false, + ); assert.equal(view.__GOOGLE_MAPS_API_KEY__, undefined); }); @@ -262,16 +262,21 @@ test('parking or tearing down the Navara scene releases a running camera verb', } }); -test('the application scene owns and releases its pick services', async () => { +test('the application scene owns and releases its pick and render services', async () => { const run = await startScene({ googleApiKey: '' }); const { engine, services } = run.scene; try { assert.equal(sceneServicesFor(engine), services); + services.render.install(); + services.render.holdContinuousRender('flights'); + assert.equal(engine.render.isContinuous(), true); services.selection.register('flights', { owns: (id) => id === 'contact' }); assert.equal(services.picking.findPickOwner('contact'), 'flights'); } finally { await run.teardown(); } + assert.equal(services.render.getRenderGovernorDiagnostics().installed, false); + assert.deepEqual(services.render.getRenderGovernorDiagnostics().holds, []); assert.equal(services.picking.findPickOwner('contact'), null); services.picking.registerPickOwner('late', () => true); assert.equal(services.picking.findPickOwner('contact'), null); diff --git a/src/app/scene.js b/src/app/scene.js index f777fdca5..ec2d5f737 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -11,10 +11,6 @@ import { registerDataCredits } from '../data/dataCredits.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { initLogoGaze } from '../logoGaze.js'; -import { - uninstallRenderGovernor, - governorRequestRender, -} from '../renderGovernor.js'; import { describeError } from './errors.js'; import { createSceneEngine } from './globeEngine.js'; import { onLocaleChange, t } from '../i18n/index.js'; @@ -157,7 +153,6 @@ async function createRenderer({ creditContainer, }); defer(() => { - uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); // The globe engine over this viewer; Google's startup tileset is named @@ -216,7 +211,7 @@ async function createRenderer({ loaderStatus.textContent = t('Initializing systems...'); const mapStackController = new MapController(viewer, { - requestRender: governorRequestRender, + requestRender: (reason) => engine.render.request(reason), // Map sources reach the scene through the globe engine's maps. engine, ...mapOptions, @@ -247,7 +242,6 @@ async function createRenderer({ tileset, mapStackController, suspend() { - uninstallRenderGovernor(viewer); engine.camera.cancelFlight(); releaseCameraVerbs(engine); viewer.useDefaultRenderLoop = false; diff --git a/src/app/tools.js b/src/app/tools.js index fca4d0519..128d45cc7 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -22,13 +22,7 @@ import { import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; import { installGlobeInViewRecovery } from '../globe/cesium/viewer.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; -import { - installRenderGovernor, - getRenderGovernorDiagnostics, - governorRequestRender, - holdContinuousRender, - releaseContinuousRender, -} from '../renderGovernor.js'; +import { renderGovernorFor } from '../renderGovernor.js'; /** * Attach scene tools, rendering listeners and the application debug handle. @@ -148,9 +142,11 @@ export async function createApplicationTools({ weatherEffects: controls.weatherEffects ?? null, cockpitCloudEffects: null, voiceCommands: null, - getRenderGovernorDiagnostics, + getRenderGovernorDiagnostics: () => + renderGovernorFor(debug.engine)?.getRenderGovernorDiagnostics(), surfaceServices: operations.surface, - requestRender: governorRequestRender, + requestRender: (reason) => + renderGovernorFor(debug.engine)?.governorRequestRender(reason), }; defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; @@ -266,6 +262,11 @@ const TOOL_BACKENDS = Object.freeze({ /** The Cesium tool backend: the shared tools and its own on the viewer. */ function cesiumToolBackend({ engine, tileset }) { const viewer = cesiumViewerOf(engine); + const { + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, + } = renderGovernorFor(engine); return { viewer, startupChrome: {}, @@ -276,10 +277,6 @@ function cesiumToolBackend({ engine, tileset }) { layer.attachTileset(tileset); return { viewer }; }, - // Idle render governor: flips the scene into requestRenderMode whenever - // nothing animates per frame. (perf wave 2) A parked viewer uninstalls - // it (src/app/scene.js suspend). - installGovernor: () => installRenderGovernor(viewer), attach({ dataManager }, { defer }) { // Cesium keeps a turned-off layer's draw commands (and so all its // entities) alive in reused per-pass arrays; trim them after each @@ -336,7 +333,7 @@ function cesiumToolBackend({ engine, tileset }) { * engine kind's backend (TOOL_BACKENDS) says where the shared tools draw: * `board()` and `draw()` give their attachScene arguments (null: not on * this engine), `recentImagery(layer)` the panel's globe, - * `installGovernor()` the governor's; `attach(context, attachment)` adds + * `attach(context, attachment)` adds * the engine's own tools, `suspend(hidden, controls)` its hidden-tab * handling and `attachViews(context, attachment)` its embedded views. The * shared tools keep their session state across engines. Returns the @@ -358,6 +355,7 @@ async function attachEngineTools( { signal, defer }, ) { const { engine, mapStackController } = scene; + const governor = scene.services?.render ?? renderGovernorFor(engine); const backend = (await TOOL_BACKENDS[engine.kind]())(scene); // The page session's board, Draw and Import (src/app/sessionTools.js): // an engine switch only takes their drawing off this globe. @@ -412,7 +410,7 @@ async function attachEngineTools( } // Installed AFTER every module above has had its chance to register // pre-install holds. - backend.installGovernor(); + governor.install(); backend.attach?.({ data, styleManager, dataManager }, { signal, defer }); // Install the explicit scope mask used by the DISPLAY controls: a canvas @@ -430,7 +428,7 @@ async function attachEngineTools( backend.suspend?.(hidden, controls); if (!hidden) { data.presentation.flushVisible(); - governorRequestRender('visibility-restore'); + governor.governorRequestRender('visibility-restore'); } }; document.addEventListener('visibilitychange', syncVisibilitySuspension); diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 2786e6238..88dfd5dee 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -5,9 +5,8 @@ import { navaraModeNotice } from './navaraMode.js'; import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { - _resetRenderGovernorForTest, + renderGovernorFor, holdContinuousRender, - isRenderGovernorInstalled, releaseContinuousRender, } from '../renderGovernor.js'; @@ -47,7 +46,6 @@ test('without a viewer the tools run the governor on the engine, show the notice }, }; t.after(() => { - _resetRenderGovernorForTest(); for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete globalThis[name]; else globalThis[name] = value; @@ -146,10 +144,13 @@ test('without a viewer the tools run the governor on the engine, show the notice 'Data layers: Live Flights, Satellites', ); // The governor drives the engine's continuous rendering. - assert.equal(isRenderGovernorInstalled(engine), true); - holdContinuousRender('qa'); + assert.equal( + renderGovernorFor(engine).getRenderGovernorDiagnostics().installed, + true, + ); + holdContinuousRender('qa', engine); assert.equal(engine.render.isContinuous(), true); - releaseContinuousRender('qa'); + releaseContinuousRender('qa', engine); assert.equal(engine.render.isContinuous(), false); const debug = window.__godsEyeView; assert.equal(debug.viewer, null); diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index e2d461f31..82f6532b7 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -30,8 +30,8 @@ const clone = (value) => Cesium.Cartesian3.clone(value); const longitude = (camera) => Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; const wrappedAngle = Cesium.Math.negativePiToPi; -const hasRenderHold = () => - getRenderGovernorDiagnostics().holds.includes('camera-verb'); +const hasRenderHold = (engine) => + getRenderGovernorDiagnostics(engine).holds.includes('camera-verb'); // The frame measure judges a pose; these tests read the Cesium camera's. const globeFrameShare = (camera, options) => poseFrameShare(cesiumFramePose(camera), options); @@ -160,7 +160,7 @@ function harness( } test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { - const { viewer, camera, tick } = harness(t); + const { viewer, camera, tick, engine } = harness(t); assert.equal( moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, true, @@ -176,7 +176,7 @@ test('globe rotation yields to tracking acquired and cleared before the next tic viewer.trackedEntity = undefined; tick(); samePose(camera, before); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => { @@ -214,13 +214,14 @@ test('a replaced engine keeps no listener and its tracking stays out of the new assert.equal(activeCameraMotionId(), id); next.viewer.trackedEntity = { id: 'new-aircraft' }; assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(first.engine), false); + assert.equal(hasRenderHold(next.engine), false); assert.equal(next.scene.preRender.numberOfListeners, 0); }); for (const latitude of [0, 30, -60, 89.5]) { test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => { - const { camera, tick, finishOnce } = harness(t, { latitude }); + const { camera, tick, finishOnce, engine } = harness(t, { latitude }); const before = projection(camera); const radius = Cesium.Cartesian3.magnitude(camera.positionWC); const direction = clone(camera.directionWC); @@ -242,7 +243,7 @@ for (const latitude of [0, 30, -60, 89.5]) { Cesium.Cartesian3.angleBetween(camera.directionWC, direction), radians(15), ); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); } @@ -377,14 +378,14 @@ for (const [speed, degreesPerSecond] of [ ['fast', 20], ]) { test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => { - const { camera, tick } = harness(t); + const { camera, tick, engine } = harness(t); const result = moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed, }); assert.equal(result.ok, true); - assert.equal(hasRenderHold(), true); + assert.equal(hasRenderHold(engine), true); tick(); // First tick initializes the motion's wall-clock anchor. const before = clone(camera.directionWC); tick(0.1); @@ -395,7 +396,7 @@ for (const [speed, degreesPerSecond] of [ assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); const stopped = moveCamera({ motion: 'stop' }); assert.equal(stopped.stopped, true); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); const after = pose(camera); tick(); samePose(camera, after); @@ -405,21 +406,21 @@ for (const [speed, degreesPerSecond] of [ for (const event of ['pointerdown', 'touchstart', 'wheel']) { test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => { - const { camera, canvas, tick } = harness(t); + const { camera, canvas, tick, engine } = harness(t); moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); tick(); tick(); const before = pose(camera); canvas.dispatchEvent(new Event(event)); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); tick(); samePose(camera, before); }); } test('new navigation can cancel globe rotation before setting a new destination', (t) => { - const { camera, tick } = harness(t); + const { camera, tick, engine } = harness(t); moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); tick(); assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true); @@ -429,11 +430,11 @@ test('new navigation can cancel globe rotation before setting a new destination' const destination = pose(camera); tick(); samePose(camera, destination); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('replacement motion rejects a stale globe owner cancellation', (t) => { - const { camera, tick } = harness(t); + const { camera, tick, engine } = harness(t); moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); const globeId = activeCameraMotionId(); moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }); @@ -446,11 +447,11 @@ test('replacement motion rejects a stale globe owner cancellation', (t) => { tick(); near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6); assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('replacing the engine retires globe motion and its frame listener', (t) => { - const { viewer, scene, camera, tick } = harness(t); + const { viewer, scene, camera, tick, engine } = harness(t); assert.equal( moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, true, @@ -462,7 +463,7 @@ test('replacing the engine retires globe motion and its frame listener', (t) => t.after(() => nextEngine.destroy()); initCameraVerbs(nextEngine); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); assert.equal(scene.preRender.numberOfListeners, 0); tick(); samePose(camera, before); @@ -470,7 +471,7 @@ test('replacing the engine retires globe motion and its frame listener', (t) => for (const direction of ['up', 'down', 'diagonal']) { test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => { - const { camera } = harness(t); + const { camera, engine } = harness(t); const before = pose(camera); let handoffs = 0; const result = moveCamera({ motion: 'rotate_globe', direction }, () => { @@ -480,7 +481,7 @@ for (const direction of ['up', 'down', 'diagonal']) { assert.match(result.error, /left or right/); assert.equal(handoffs, 0); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); samePose(camera, before); }); } @@ -488,11 +489,11 @@ for (const direction of ['up', 'down', 'diagonal']) { for (const mode of ['once', 'continuous']) { for (const acquisition of ['tracking', 'reference frame']) { test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => { - const { viewer, camera, target, tick } = harness(t); + const { viewer, camera, target, tick, engine } = harness(t); assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true); tick(); tick(); - assert.equal(hasRenderHold(), true); + assert.equal(hasRenderHold(engine), true); const tracked = { id: 'new-tracked-aircraft' }; if (acquisition === 'tracking') viewer.trackedEntity = tracked; else @@ -504,7 +505,7 @@ for (const mode of ['once', 'continuous']) { const transform = Cesium.Matrix4.clone(camera.transform); tick(); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); assert.equal( viewer.trackedEntity, acquisition === 'tracking' ? tracked : undefined, @@ -518,7 +519,7 @@ for (const mode of ['once', 'continuous']) { } test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { - const { viewer, camera, target } = harness(t); + const { viewer, camera, target, engine } = harness(t); viewer.trackedEntity = { id: 'tracked-aircraft' }; camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); const before = pose(camera); @@ -529,7 +530,7 @@ test('direct globe rotation refuses a tracked camera without disturbing its refe assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); samePose(camera, before); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('the existing Globe View preset remains unchanged', () => { @@ -553,7 +554,7 @@ for (const latitude of [0, 90, -90]) near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); }); test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => { - const { camera, tick } = harness(t, { latitude: 90 }); + const { camera, tick, engine } = harness(t, { latitude: 90 }); const radius = Cesium.Cartesian3.magnitude(camera.positionWC); assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0); assert.equal( @@ -569,7 +570,7 @@ test('polar preset admission keeps centre-distance although geodetic altitude va assert.equal(getActiveCameraMotion().kind, 'rotate_globe'); near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); moveCamera({ motion: 'stop' }); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('close-up refusal preserves an inherited target frame', (t) => { const { camera, target } = harness(t); @@ -617,7 +618,7 @@ test('direct globe request refuses Cockpit before navigation mutation', (t) => { ); assert.equal(calls, 0); samePose(camera, before); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => { @@ -699,7 +700,7 @@ test('legacy orbit still revolves around the selected ground target', (t) => { // Actual UI/tool authority routes, not a direct call to the interrupt helper. test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => { const { NavigationController } = await import('./ui/navigationController.js'); - const { viewer, camera, tick } = harness(t); + const { viewer, camera, tick, engine } = harness(t); const navigation = new NavigationController({ viewer, tracking: { @@ -730,7 +731,7 @@ test('new UI navigation intent stops globe motion before its destination and sta ); navigation._runExplicitNavigation('camera', () => { assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); const after = pose(camera); tick(); @@ -738,7 +739,7 @@ test('new UI navigation intent stops globe motion before its destination and sta }); test('orbit-toggle controller stops globe motion before toggling', async (t) => { const { LocationNavigation } = await import('./ui/locationNavigation.js'); - const { target } = harness(t); + const { target, engine } = harness(t); let toggled = false; moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); LocationNavigation.prototype._toggleOrbit.call({ @@ -748,7 +749,7 @@ test('orbit-toggle controller stops globe motion before toggling', async (t) => orbitController: { toggle() { assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); toggled = true; return true; }, @@ -842,13 +843,13 @@ for (const owner of ['tracking', 'cockpit', 'close-up']) }); test('an external radius change retires globe rotation without another write', (t) => { - const { camera, tick } = harness(t); + const { camera, tick, engine } = harness(t); moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); camera.moveForward(1000); const changed = pose(camera); tick(); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); samePose(camera, changed); }); @@ -1083,7 +1084,7 @@ test('keyhole overlap after resize stops rotation before another pose write', (t const before = pose(h.camera); h.tick(); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(h.engine), false); samePose(h.camera, before); }); @@ -1097,7 +1098,7 @@ test('a depth-near globe is unsupported without a reset offer', (t) => { assert.equal(result.ok, false); assert.equal(result.reason, 'unsupported-view'); samePose(h.camera, before); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(h.engine), false); }); test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { @@ -1125,7 +1126,7 @@ for (const [near, far] of [ }); test('bare globe rotation defaults right and continues beyond a nudge', (t) => { - const { camera, tick } = harness(t, { latitude: 0 }); + const { camera, tick, engine } = harness(t, { latitude: 0 }); const before = projection(camera); const result = moveCamera({ motion: 'rotate_globe' }); assert.equal(result.ok, true); @@ -1137,7 +1138,7 @@ test('bare globe rotation defaults right and continues beyond a nudge', (t) => { near(projection(camera).x, before.x); near(projection(camera).y, before.y); assert.equal(moveCamera({ motion: 'stop' }).stopped, true); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); for (const [args, direction, mode, speed] of [ [{ mode: 'once' }, 'right', 'once', 'normal'], @@ -1195,7 +1196,7 @@ test('canvas-only clipping after resize preserves a keyhole-fitting rotation', ( h.tick(); assert.notDeepEqual(pose(h.camera).position, before.position); assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); - assert.equal(hasRenderHold(), true); + assert.equal(hasRenderHold(h.engine), true); }); test('an invalid camera request leaves the active globe rotation untouched', (t) => { @@ -1206,7 +1207,7 @@ test('an invalid camera request leaves the active globe rotation untouched', (t) assert.equal(moveCamera({ motion: 'pan' }).ok, false); assert.deepEqual(getActiveCameraMotion(), active); samePose(h.camera, before); - assert.equal(hasRenderHold(), true); + assert.equal(hasRenderHold(h.engine), true); }); test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => { @@ -1223,7 +1224,7 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer assert.equal(result.reason, 'globe-reset-unavailable'); assert.equal(handoffs, 0); samePose(h.camera, before); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(h.engine), false); }); /** diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 1c9245d8a..f3a883806 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -34,10 +34,7 @@ import { KEYHOLE_OUTER_RADIUS, } from './celestialRing.js'; import { isScopeMaskEnabled } from './scopeMask.js'; -import { - holdContinuousRender, - releaseContinuousRender, -} from './renderGovernor.js'; +import { renderGovernorFor } from './renderGovernor.js'; let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ @@ -1372,7 +1369,7 @@ export function suspendRouteFlight(id) { const state = _active; _active = null; unbindEngineMotion(); - releaseContinuousRender('camera-verb'); + state.renderGovernor?.releaseContinuousRender('camera-verb'); return state; } @@ -1383,9 +1380,10 @@ export function suspendRouteFlight(id) { export function resumeRouteFlight(state, engine) { if (!state || !engine || _active) return false; state.rig = engineRigFor(engine); + state.renderGovernor = renderGovernorFor(engine); _active = state; bindEngineMotion(state.rig); - holdContinuousRender('camera-verb'); + _active?.renderGovernor?.holdContinuousRender('camera-verb'); return true; } /** Monotonic id for the motion currently running; 0 means "no motion". */ @@ -1447,6 +1445,7 @@ function countManualInput(engine) { function setActiveMotion(state) { _motionSeq += 1; state.motionId = _motionSeq; + state.renderGovernor = renderGovernorFor(state.rig.engine); _active = state; bindEngineMotion(state.rig); return state; @@ -1694,7 +1693,7 @@ export function interruptCameraMotion(reason = 'interrupt') { } unbindEngineMotion(); _active = null; - releaseContinuousRender('camera-verb'); + motion?.renderGovernor?.releaseContinuousRender('camera-verb'); return { wasActive, reason, leveled }; } @@ -2604,7 +2603,7 @@ export function moveCamera(args = {}, runNavigation = null) { mode, pending: true, }); - holdContinuousRender('camera-verb'); + _active?.renderGovernor?.holdContinuousRender('camera-verb'); return { ok: true, action: 'move_camera', @@ -2623,7 +2622,7 @@ export function moveCamera(args = {}, runNavigation = null) { setActiveMotion(state); // Verb motion drives the camera per frame — hold until interrupted; // interruptCameraMotion is the single release path. (perf wave 2) - holdContinuousRender('camera-verb'); + _active?.renderGovernor?.holdContinuousRender('camera-verb'); return { ok: true, action: 'move_camera', @@ -2860,7 +2859,7 @@ export function flyRoute( }); flight.rig = rig; setActiveMotion(flight); - holdContinuousRender('camera-verb'); + _active?.renderGovernor?.holdContinuousRender('camera-verb'); return { ok: true, action: 'fly_route', diff --git a/src/cameraVerbsEngine.test.mjs b/src/cameraVerbsEngine.test.mjs index 4b2d5e7a4..49f69f0e9 100644 --- a/src/cameraVerbsEngine.test.mjs +++ b/src/cameraVerbsEngine.test.mjs @@ -27,8 +27,8 @@ import { getRenderGovernorDiagnostics } from './renderGovernor.js'; const WIDTH = 1280; const HEIGHT = 800; const radians = geo.math.toRadians; -const hasRenderHold = () => - getRenderGovernorDiagnostics().holds.includes('camera-verb'); +const hasRenderHold = (engine) => + getRenderGovernorDiagnostics(engine).holds.includes('camera-verb'); /** Wall clock the verbs read (performance.now), moved only by the tests. */ function installClock(t) { @@ -279,12 +279,12 @@ test('a once nudge spends its budget in 0.9 s and then lets go', (t) => { initCameraVerbs(engine); const before = camera(); assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); - assert.equal(hasRenderHold(), true); + assert.equal(hasRenderHold(engine), true); run(0.8); assert.equal(getActiveCameraMotion()?.kind, 'tilt', 'still easing'); run(0.2); assert.equal(getActiveCameraMotion(), null, 'finished after 0.9 s'); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); near( geo.math.toDegrees(camera().pitch - before.pitch), 15, @@ -337,7 +337,7 @@ test('manual input on the canvas stops the motion and releases the hold', (t) => run(0.3); engine.host.canvas.dispatchEvent(new Event(event)); assert.equal(getActiveCameraMotion(), null, event); - assert.equal(hasRenderHold(), false, event); + assert.equal(hasRenderHold(engine), false, event); assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); engine.fake.resetCalls(); run(0.3); @@ -367,7 +367,7 @@ test('a new verb supersedes the running one and stop ends it', (t) => { motion: 'stop', stopped: true, }); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('one frame listener and three input listeners exist only while a motion runs', (t) => { @@ -407,7 +407,7 @@ test('an engine switch releases the verb: the parked engine is never driven agai // What the Navara scene's suspend does (src/app/scene.js). releaseCameraVerbs(first.engine); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(first.engine), false); first.engine.fake.resetCalls(); first.run(0.5); assert.equal(first.engine.fake.callsOf('camera.setView').length, 0); @@ -454,7 +454,7 @@ test('globe rotation yields when a follow takes the engine camera', (t) => { viewFrom: geo.cartesian3.create(0, -2000, 1000), }); assert.equal(getActiveCameraMotion(), null); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); // And a request while following is refused before any handoff. let handoffs = 0; const refused = moveCamera({ motion: 'rotate_globe' }, () => { @@ -514,7 +514,7 @@ test('fly_route flies the installed engine camera and lands level', (t) => { run(result.durationS + 2); assert.equal(getActiveCameraMotion(), null, 'landed'); near(geo.math.negativePiToPi(engine.camera.pose().roll), 0, 1e-5, 'roll'); - assert.equal(hasRenderHold(), false); + assert.equal(hasRenderHold(engine), false); }); test('a parked route flight resumes on another engine without jumping', (t) => { diff --git a/src/celestialRing.js b/src/celestialRing.js index b5db3eaf9..de7797041 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -7,7 +7,7 @@ import { } from './globe/cesium/celestial.js'; import { createCelestialEphemeris } from './celestialRing/ephemeris.js'; import { appClock } from './time/clock.js'; -import { governorRequestRender } from './renderGovernor.js'; +import { renderGovernorFor } from './renderGovernor.js'; /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -413,6 +413,9 @@ export class CelestialRing { this._engineView = viewer?.kind ? createCelestialView(viewer) : null; this.viewer = this._engineView?.view ?? viewer; this._projector = engine?.projector ?? null; + this._renderGovernor = renderGovernorFor( + engine ?? (viewer?.kind ? viewer : null), + ); this.enabled = !!enabled; this.visible = false; this._onAutoDisable = @@ -460,7 +463,7 @@ export class CelestialRing { // dirty flag up immediately. (review finding) this._ephemerisDirty = true; if (typeof document !== 'undefined' && document.hidden) return; - governorRequestRender('celestial-ephemeris'); + this._renderGovernor?.governorRequestRender('celestial-ephemeris'); }, 60_000); this.setEnabled(this.enabled); } @@ -526,7 +529,8 @@ export class CelestialRing { this._moonCtx = ctx; this._moonRenderKey = ''; } else return; - if (canvas && this.enabled) governorRequestRender('celestial-ring'); + if (canvas && this.enabled) + this._renderGovernor?.governorRequestRender('celestial-ring'); } /** Enable or disable the user preference for the effect. */ @@ -539,7 +543,8 @@ export class CelestialRing { // clearing disable) must request its frame or the ring never draws at // all. Camera motion covers every later repaint; the 60 s ephemeris // timer requests its own. (perf wave 2 fix — owner playtest finding) - if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring'); + if (this.enabled !== wasEnabled) + this._renderGovernor?.governorRequestRender('celestial-ring'); if (!this.enabled) { this.visible = false; this._clear(); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 1b976038a..bff2e169e 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1136,7 +1136,7 @@ export function createBhoteKoshiEventLayer({ if (!_enabled || generation !== _comparisonSurfaceGeneration) return; syncFloodSurfaceMode(); updateProgress(_progress); - renderHost.request('bhote-koshi-comparison-ready'); + renderHost.request('bhote-koshi-comparison-ready', _engine); } function attachDataManager(dataManager) { @@ -1421,7 +1421,8 @@ export function createBhoteKoshiEventLayer({ } _sceneMediaPlaybackBeatId = null; if (pauseMedia) _embeddedMedia?.pause?.(); - if (hadPlayback) renderHost.request('bhote-koshi-scene-media-hold'); + if (hadPlayback) + renderHost.request('bhote-koshi-scene-media-hold', _engine); } function completeSceneMediaPlayback() { @@ -1439,7 +1440,7 @@ export function createBhoteKoshiEventLayer({ if (_sceneMediaPlaybackBeatId !== beatId) return; _sceneMediaPlaybackTimer = null; _sceneMediaPlaybackBeatId = null; - renderHost.request('bhote-koshi-scene-media-exit'); + renderHost.request('bhote-koshi-scene-media-exit', _engine); }, exitDurationSec * 1000) ?? null; } @@ -1762,7 +1763,8 @@ export function createBhoteKoshiEventLayer({ if (_sceneRevealAnimationFrame != null) cancelAnimationFrame(_sceneRevealAnimationFrame); _sceneRevealAnimationFrame = null; - if (_sceneRevealHoldActive) renderHost.release(SCENE_REVEAL_RENDER_HOLD); + if (_sceneRevealHoldActive) + renderHost.release(SCENE_REVEAL_RENDER_HOLD, _engine); _sceneRevealHoldActive = false; } @@ -1773,7 +1775,8 @@ export function createBhoteKoshiEventLayer({ if (_scenePathDeadlineTimer != null) globalThis.window?.clearTimeout?.(_scenePathDeadlineTimer); _scenePathDeadlineTimer = null; - if (_scenePathHoldActive) renderHost.release(SCENE_PATH_RENDER_HOLD); + if (_scenePathHoldActive) + renderHost.release(SCENE_PATH_RENDER_HOLD, _engine); _scenePathHoldActive = false; } @@ -1787,8 +1790,9 @@ export function createBhoteKoshiEventLayer({ _scenePathRevealProgress = targetProgress; updateFloodTrace(_scenePathRevealProgress); updateSurgeFront(_scenePathRevealProgress); - renderHost.request(requestReason); - if (_scenePathHoldActive) renderHost.release(SCENE_PATH_RENDER_HOLD); + renderHost.request(requestReason, _engine); + if (_scenePathHoldActive) + renderHost.release(SCENE_PATH_RENDER_HOLD, _engine); _scenePathHoldActive = false; } @@ -1959,7 +1963,7 @@ export function createBhoteKoshiEventLayer({ lastTickAt = tickAt; return activeElapsedMs; }; - renderHost.hold(SCENE_PATH_RENDER_HOLD); + renderHost.hold(SCENE_PATH_RENDER_HOLD, _engine); _scenePathHoldActive = true; // RAF normally paints every intermediate frame. This independent deadline // is registered before Cesium starts the flight, so a throttled or briefly @@ -2008,7 +2012,7 @@ export function createBhoteKoshiEventLayer({ startProgress + (targetProgress - startProgress) * eased; updateFloodTrace(_scenePathRevealProgress); updateSurgeFront(_scenePathRevealProgress); - renderHost.request('bhote-koshi-scene-path-travel'); + renderHost.request('bhote-koshi-scene-path-travel', _engine); if (linear < 1) { _scenePathAnimationFrame = requestAnimationFrame(step); } else { @@ -2066,7 +2070,7 @@ export function createBhoteKoshiEventLayer({ if (sequenceWindow) updateProgress(sequenceWindow.startProgress); let lastTickAt = performance.now(); let activeElapsedMs = 0; - renderHost.hold(SCENE_REVEAL_RENDER_HOLD); + renderHost.hold(SCENE_REVEAL_RENDER_HOLD, _engine); _sceneRevealHoldActive = true; const step = (now) => { if ( @@ -2097,12 +2101,12 @@ export function createBhoteKoshiEventLayer({ updateFloodTrace(floodProgress); updateSurgeFront(floodProgress); } - renderHost.request('bhote-koshi-scene-beat-reveal'); + renderHost.request('bhote-koshi-scene-beat-reveal', _engine); if (linear < 1) { _sceneRevealAnimationFrame = requestAnimationFrame(step); } else { _sceneRevealAnimationFrame = null; - renderHost.release(SCENE_REVEAL_RENDER_HOLD); + renderHost.release(SCENE_REVEAL_RENDER_HOLD, _engine); _sceneRevealHoldActive = false; } }; @@ -2147,7 +2151,7 @@ export function createBhoteKoshiEventLayer({ ), }) ) { - renderHost.request('bhote-koshi-evidence-video-frame'); + renderHost.request('bhote-koshi-evidence-video-frame', _engine); } _evidenceVideoFrameHandle = video.requestVideoFrameCallback(drawFrame); }; @@ -2176,7 +2180,7 @@ export function createBhoteKoshiEventLayer({ previousIndex >= 0 && redrawEvidenceSlot(previousIndex) ) { - renderHost.request('bhote-koshi-evidence-poster-restore'); + renderHost.request('bhote-koshi-evidence-poster-restore', _engine); } } @@ -2204,7 +2208,7 @@ export function createBhoteKoshiEventLayer({ ) return; _evidenceVideoReady = true; - renderHost.request('bhote-koshi-evidence-video-ready'); + renderHost.request('bhote-koshi-evidence-video-ready', _engine); syncEvidenceMedia(); }; video.addEventListener?.('loadeddata', markReady, { once: true }); @@ -2215,7 +2219,7 @@ export function createBhoteKoshiEventLayer({ video !== _evidenceVideo ) return; - renderHost.request('bhote-koshi-evidence-video-seek'); + renderHost.request('bhote-koshi-evidence-video-seek', _engine); }); video.src = eventAsset(videoPath); video.load?.(); @@ -2363,7 +2367,7 @@ export function createBhoteKoshiEventLayer({ mediaProgress: presentation.localProgress, }) ) { - renderHost.request('bhote-koshi-evidence-video-frame'); + renderHost.request('bhote-koshi-evidence-video-frame', _engine); } } @@ -2509,7 +2513,7 @@ export function createBhoteKoshiEventLayer({ slot.poster = poster; poster = null; if (!redrawEvidenceSlot(index)) return; - renderHost.request('bhote-koshi-evidence-poster'); + renderHost.request('bhote-koshi-evidence-poster', _engine); } catch (error) { closeFrame(poster); if ( @@ -2785,7 +2789,7 @@ export function createBhoteKoshiEventLayer({ if (releaseCamera) cameraForScene()?.lookAtTransform?.(Cesium.Matrix4.IDENTITY); syncCinematicButton(); - renderHost.request('bhote-koshi-cinematic-stop'); + renderHost.request('bhote-koshi-cinematic-stop', _engine); } function startCinematic({ play = true } = {}) { @@ -3446,7 +3450,7 @@ export function createBhoteKoshiEventLayer({ applyCinematicCamera(); syncEvidenceMedia(); syncPanel(); - renderHost.request('bhote-koshi-progress'); + renderHost.request('bhote-koshi-progress', _engine); } function setSplit(split) { @@ -3454,7 +3458,7 @@ export function createBhoteKoshiEventLayer({ _splitControl?.setValue(_split); _native?.setSplit(_split); syncPanel(); - renderHost.request('bhote-koshi-split'); + renderHost.request('bhote-koshi-split', _engine); } function stopPlayback() { @@ -3462,7 +3466,7 @@ export function createBhoteKoshiEventLayer({ _playing = false; if (_animationFrame != null) cancelAnimationFrame(_animationFrame); _animationFrame = null; - renderHost.release(FLOOD_RENDER_HOLD); + renderHost.release(FLOOD_RENDER_HOLD, _engine); syncEvidenceMedia(); syncPanel(); } @@ -3486,7 +3490,7 @@ export function createBhoteKoshiEventLayer({ _playing = true; _playStartProgress = _progress; _playStartedAt = performance.now(); - renderHost.hold(FLOOD_RENDER_HOLD); + renderHost.hold(FLOOD_RENDER_HOLD, _engine); syncEvidenceMedia(); syncPanel(); _animationFrame = requestAnimationFrame(animationTick); @@ -3832,7 +3836,7 @@ export function createBhoteKoshiEventLayer({ try { await comparisonLease?.release(); } finally { - renderHost.request('bhote-koshi-disable'); + renderHost.request('bhote-koshi-disable', _engine); } return true; } diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index d99cf3e20..8ebb443d3 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -291,10 +291,7 @@ export function createBhoteKoshiLocatorLayer({ let _native = null; let _pausedApproach = null; const cameraForScene = () => _engine?.camera ?? _viewer?.camera; - const requestRender = (reason) => - _engine?.kind === 'navara' - ? _engine.render.request(reason) - : requestSceneRender(reason); + const requestRender = (reason) => requestSceneRender(reason, _engine); let _dataSource = null; let _enabled = false; let _boundaryReady = false; diff --git a/src/data/detection.js b/src/data/detection.js index 254e3858b..35b890df8 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -400,7 +400,7 @@ function _scheduleCyberSonarRender(nowMs) { _cyberSonarRenderTimer = globalThis.setTimeout(() => { _cyberSonarRenderTimer = null; if (isCyberSonarActive()) { - governorRequestRender('cyber-sonar-cadence'); + governorRequestRender('cyber-sonar-cadence', _engine); } }, delay); } @@ -655,7 +655,7 @@ function _syncSurfaceVisibility() { // owes itself follow-up frames only while a bounded animation is in flight // (see `detectionRenderDemand.js` for why a hold was wrong here, and why // removing it is safe). - governorRequestRender('detection-visibility'); + governorRequestRender('detection-visibility', _engine); if (!_hostSurface) return; _hostSurface.style.display = _mode === MODE_OFF ? 'none' : 'block'; _hostSurface.style.opacity = _suspended ? '0' : '1'; @@ -750,7 +750,7 @@ function _shouldPaintDetectionLane(frame) { if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount; if (decision.requestFollowUp) - governorRequestRender('detection-paint-skipped'); + governorRequestRender('detection-paint-skipped', _engine); return false; } return true; @@ -815,7 +815,7 @@ function _paintDetectionLane(frame) { solvePending: result.solvePending === true, }) ) { - governorRequestRender('detection-animation'); + governorRequestRender('detection-animation', _engine); } } diff --git a/src/data/detectionRenderDemand.test.mjs b/src/data/detectionRenderDemand.test.mjs index 02eb87db9..c2347404b 100644 --- a/src/data/detectionRenderDemand.test.mjs +++ b/src/data/detectionRenderDemand.test.mjs @@ -275,9 +275,9 @@ test('detection holds nothing, and asks for its own frames instead', async () => assert.doesNotMatch(source, /releaseContinuousRender/, 'and with no hold there is nothing to release'); - assert.match(source, /governorRequestRender\('detection-visibility'\)/, + assert.match(source, /governorRequestRender\('detection-visibility', _engine\)/, 'mode/suspend transitions request their own repaint'); - assert.match(source, /if \(\s*detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\s*\) \{\s*\n\s*governorRequestRender\('detection-animation'\);/, + assert.match(source, /if \(\s*detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\s*\) \{\s*\n\s*governorRequestRender\('detection-animation', _engine\);/, 'the follow-up frame is gated on the policy, never unconditional'); // The demand call must read the FRAME's timestamp, not a fresh sample. @@ -339,7 +339,7 @@ test('detection holds nothing, and asks for its own frames instead', async () => 'the valve asks the shared policy instead of inlining its own threshold'); assert.doesNotMatch(shouldPaint, /_lastPaintMs > 22/, 'the old inline threshold must be gone, not shadowing the policy'); - assert.match(shouldPaint, /if \(decision\.requestFollowUp\)\s*governorRequestRender\('detection-paint-skipped'\)/, + assert.match(shouldPaint, /if \(decision\.requestFollowUp\)\s*governorRequestRender\('detection-paint-skipped', _engine\)/, 'skipping a paint must hand the request forward, not swallow it'); // The scanline must not go back to the frame counter: that is what made a @@ -478,13 +478,13 @@ test('aircraft brackets stay prompt because the aircraft layers hold the render assert.ok(enable, `${file}: enable() is still identifiable`); assert.match( enable, - /holdContinuousRender\('(flights|military)'\)/, + /holdContinuousRender\(\s*'(flights|military)',\s*flightState\._renderer\?\.engine,?\s*\)/, `${file}: enabling the layer must hold continuous render — detection no longer ` + 'holds one, so this is what keeps its AIR brackets repainting on a parked scene', ); assert.match( source, - /releaseContinuousRender\('(flights|military)'\)/, + /releaseContinuousRender\(\s*'(flights|military)',\s*flightState\._renderer\?\.engine,?\s*\)/, `${file}: and the hold must be released, or the governor can never idle`, ); } diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 29673d804..da12a6fb4 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -17,8 +17,7 @@ import { import { DataLayerManager } from './manager.js'; import { getRenderGovernorDiagnostics, - installRenderGovernor, - _resetRenderGovernorForTest, + renderGovernorFor, } from '../renderGovernor.js'; import { loadLayerRenderers } from '../app/layers/renderers.js'; @@ -214,6 +213,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () const originalFetch = globalThis.fetch; const renderRequests = []; const ground = createFakeGroundHarness(); + ground.engine.render.request = () => renderRequests.push(Date.now()); const viewer = { scene: { requestRenderMode: false, requestRender: () => renderRequests.push(Date.now()) }, }; @@ -231,8 +231,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () }), }); - _resetRenderGovernorForTest(); - installRenderGovernor(viewer); + renderGovernorFor(ground.engine).install(); // 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. @@ -244,9 +243,9 @@ test('a quake poll still reaches the screen with the render loop idle', async () await manager.setEnabled('earthquakes', true, { origin: 'test' }); // The whole point of the change: enabling quakes must not pin the loop on. - assert.equal(getRenderGovernorDiagnostics().mode, 'idle', 'quakes must not force continuous render'); - assert.deepEqual(getRenderGovernorDiagnostics().holds, []); - assert.equal(viewer.scene.requestRenderMode, true, 'the governor really is in idle mode'); + assert.equal(getRenderGovernorDiagnostics(ground.engine).mode, 'idle', 'quakes must not force continuous render'); + assert.deepEqual(getRenderGovernorDiagnostics(ground.engine).holds, []); + assert.equal(ground.engine.render.isContinuous(), false, 'the governor really is in idle mode'); // ...and the poll that populated the discs must still have asked for a frame, // because in idle mode nothing repaints on its own. @@ -255,7 +254,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () // Named, not merely counted: the frame has to come from the manager's // visibility request, not from some incidental repaint. assert.ok( - getRenderGovernorDiagnostics().recentRequests.some(({ reason }) => reason === 'layer-visibility'), + getRenderGovernorDiagnostics(ground.engine).recentRequests.some(({ reason }) => reason === 'layer-visibility'), 'the enable frame must be the manager\'s layer-visibility request', ); @@ -267,7 +266,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () renderRequests.length > beforeRefresh, 'each refresh tick must request its own render while the loop is idle', ); - const reasons = getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason); + const reasons = getRenderGovernorDiagnostics(ground.engine).recentRequests.map(({ reason }) => reason); assert.ok( reasons.includes('layer-tick:earthquakes'), `the tick request must be attributed to the layer, got ${JSON.stringify(reasons)}`, @@ -275,7 +274,6 @@ test('a quake poll still reaches the screen with the render loop idle', async () } finally { globalThis.fetch = originalFetch; await manager.setEnabled('earthquakes', false, { origin: 'test' }).catch(() => {}); - _resetRenderGovernorForTest(); ground.destroy(); } }); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index c8a468db1..54dbdabe6 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -32,9 +32,8 @@ import aisLiveVesselsLayer, { } from './aisLiveVessels.js'; import militaryInstallationsLayer from './militaryInstallations.js'; import { - _resetRenderGovernorForTest, getRenderGovernorDiagnostics, - installRenderGovernor, + renderGovernorFor, } from '../renderGovernor.js'; import { AWARENESS_RADIUS_M, @@ -54,7 +53,10 @@ import { registerLayerRenderers } from '../app/layers/renderers.js'; // the layer through the renderer registry, under an engine kind of its own. const VIEWER_ENGINE = 'awareness-viewer-test'; registerLayerRenderers(VIEWER_ENGINE, { - 'military-awareness': ({ viewer }) => ({ + 'military-awareness': (engine) => { + const { viewer } = engine; + return { + engine, container: viewer.container, canvas: viewer.canvas, get camera() { @@ -70,9 +72,21 @@ registerLayerRenderers(VIEWER_ENGINE, { viewer.camera.flyToBoundingSphere({ center, radius }, { duration }), createRim: () => ({ move() {}, setAlpha() {}, destroy() {} }), createPulses: () => null, - }), + }; + }, }); -const viewerEngine = (viewer) => ({ kind: VIEWER_ENGINE, viewer }); +const viewerEngines = new WeakMap(); +const viewerEngine = (viewer) => { + if (!viewerEngines.has(viewer)) viewerEngines.set(viewer, { + kind: VIEWER_ENGINE, viewer, + render: { + request: () => viewer.scene.requestRender?.(), + setContinuous: (enabled) => { viewer.scene.requestRenderMode = !enabled; }, + isContinuous: () => viewer.scene.requestRenderMode === false, + }, + }); + return viewerEngines.get(viewer); +}; const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), @@ -3922,13 +3936,12 @@ test('parked Contacts takes no continuous-render hold and requests at most the p { icao24: 'neighbour-flight', position: neighbour, distanceM: 4000 }, ], }); - _resetRenderGovernorForTest(); const runtime = installAwarenessRuntime(); - const holds = () => getRenderGovernorDiagnostics().holds; + const holds = () => getRenderGovernorDiagnostics(viewerEngine(runtime.viewer)).holds; try { Date.now = () => clockMs; - installRenderGovernor(runtime.viewer); + renderGovernorFor(viewerEngine(runtime.viewer)).install(); runtime.viewer.camera = { positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000), heading: 0, @@ -4003,7 +4016,6 @@ test('parked Contacts takes no continuous-render hold and requests at most the p Date.now = realNow; runtime.restore(); restoreCollections(); - _resetRenderGovernorForTest(); } }); @@ -4058,13 +4070,12 @@ test('a moving view over failed feeds takes no continuous-render hold', () => { // Every dependency reports unavailable, so every cohort summarizes to count // null — the exact state the old Boolean(state.results) gate still held for. const restoreCollections = stubAwarenessCollections({}); - _resetRenderGovernorForTest(); const runtime = installAwarenessRuntime({ isEnabled: () => false }); - const holds = () => getRenderGovernorDiagnostics().holds; + const holds = () => getRenderGovernorDiagnostics(viewerEngine(runtime.viewer)).holds; try { Date.now = () => clockMs; - installRenderGovernor(runtime.viewer); + renderGovernorFor(viewerEngine(runtime.viewer)).install(); runtime.viewer.camera = { positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000), heading: 0, @@ -4096,7 +4107,6 @@ test('a moving view over failed feeds takes no continuous-render hold', () => { Date.now = realNow; runtime.restore(); restoreCollections(); - _resetRenderGovernorForTest(); } }); diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index 954632474..e1f47a2d8 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -19,9 +19,8 @@ import { } from './groundFloor.js'; import { _resetFireAnchorsForTest } from './fireAnchors.js'; import { - _resetRenderGovernorForTest, getRenderGovernorDiagnostics, - installRenderGovernor, + renderGovernorFor, } from '../renderGovernor.js'; import * as Cesium from 'cesium'; import { getContextStore, registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js'; @@ -283,7 +282,6 @@ async function runInstallationLoad({ const originalFetch = globalThis.fetch; const requests = []; const contextEvents = []; - _resetRenderGovernorForTest(); globalThis.document = { addEventListener() {}, removeEventListener() {} }; globalThis.window = { dispatchEvent(event) { @@ -312,17 +310,8 @@ async function runInstallationLoad({ return { ok: true, status: 200, json: async () => payload }; }; const engine = engineOver(VIEWPORT); - // Enough of a viewer for the real render governor, so one-shot render - // requests are observable. - const viewer = { - scene: { - requestRenderMode: false, - maximumRenderTimeChange: 0, - requestRender() {}, - }, - }; militaryInstallationsLayer.init(null, { engine }); - installRenderGovernor(viewer); + renderGovernorFor(engine).install(); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); @@ -338,11 +327,11 @@ async function runInstallationLoad({ engine.fake.input('click', { position: { x: 0, y: 0 } }); engine.fake.unscript('pick'); }, - renderRequests: () => getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason), + renderRequests: () => getRenderGovernorDiagnostics(engine).recentRequests.map((item) => item.reason), restore() { militaryInstallationsLayer.destroy(); + renderGovernorFor(engine).destroy(); engine.destroy(); - _resetRenderGovernorForTest(); globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; diff --git a/src/data/sceneServices.js b/src/data/sceneServices.js index 43565fd97..6db7f5e33 100644 --- a/src/data/sceneServices.js +++ b/src/data/sceneServices.js @@ -1,3 +1,4 @@ +import { createRenderGovernor } from '../renderGovernor.js'; import { createPickRegistry } from './pickRegistry.js'; import { createSelectionDispatcher } from './selectionDispatcher.js'; @@ -8,17 +9,20 @@ const scenes = new WeakMap(); /** Create the services for one attachment, including reuse of a parked engine. */ export function createSceneServices(engine, options = {}) { scenes.get(engine)?.destroy(); + const render = createRenderGovernor(engine); const picking = createPickRegistry(); const selection = createSelectionDispatcher(engine, { ...options, picking }); let destroyed = false; const services = { picking, selection, + render, destroy() { if (destroyed) return; destroyed = true; selection.destroy(); picking.destroy(); + render.destroy(); }, }; scenes.set(engine, services); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 38422aae3..a8067c5d2 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -45,9 +45,7 @@ import { createCesiumSurface } from './surface.js'; * imagery.js) are test seams, `createStage` and `createSonar` too * (render.js). * - * `render.setContinuous` takes a hold on the app's singleton renderGovernor - * (src/renderGovernor.js), which governs the one viewer it was installed on; - * so one Cesium engine per page, on that viewer. + * `render` owns this scene's requests and continuous-render mode. * @returns {import('../contract.js').GlobeEngine} */ export function createCesiumEngine(viewer, options = {}) { diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index fefe3ff77..c5adde0c6 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -2,11 +2,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { assertGlobeEngine, engineCapabilities } from '../contract.js'; -import { - _resetRenderGovernorForTest, - getRenderGovernorDiagnostics, - installRenderGovernor, -} from '../../renderGovernor.js'; import { createContractSuite } from '../testing/contractSuite.mjs'; import { createCesiumEngine } from './createCesiumEngine.js'; import { createCameraViewer } from './testing/cameraViewer.mjs'; @@ -15,8 +10,6 @@ import { createFakeViewer, } from './testing/fakeViewer.mjs'; -test.afterEach(() => _resetRenderGovernorForTest()); - function snapshot(viewer) { const { scene } = viewer; return { @@ -85,7 +78,6 @@ test('the render option reaches render.request', () => { test('destroy releases holds, listeners, handlers and engine-owned map state', async () => { const viewer = createFakeViewer(); - installRenderGovernor(viewer); const handlers = createFakeEventHandlers(); const engine = createCesiumEngine(viewer, { createEventHandler: handlers.create, @@ -104,7 +96,7 @@ test('destroy releases holds, listeners, handlers and engine-owned map state', a assert.equal(viewer.scene.camera.changed.numberOfListeners, 0); assert.equal(viewer.scene.postRender.numberOfListeners, 0); assert.equal(handlers.handlers[0].destroyed, true); - assert.deepEqual(getRenderGovernorDiagnostics().holds, []); + assert.equal(viewer.scene.requestRenderMode, true); assert.deepEqual(viewer.staticCredits, []); assert.deepEqual(viewer.imageryList, []); }); diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js index de9222322..8c2102cd5 100644 --- a/src/globe/cesium/render.js +++ b/src/globe/cesium/render.js @@ -1,15 +1,5 @@ -// GlobeRender on the app's render governor, which owns Cesium's -// requestRenderMode and maximumRenderTimeChange; the engine only takes a -// hold under its own owner id. The full-frame effects (visual styles, -// bloom, sharpening) are the scene's post stages (postStages.js), the -// contact sonar the scene's native draw commands (sonar.js). +// Cesium owns its render mode and scene effects. Scene services drive this API. -import { - governorRequestRender, - holdContinuousRender, - isRenderGovernorInstalled, - releaseContinuousRender, -} from '../../renderGovernor.js'; import { CONTACT_SONAR_INITIAL, mergeContactSonar } from '../contract.js'; import { createCesiumPostStages } from './postStages.js'; import { createCesiumSonar } from './sonar.js'; @@ -21,8 +11,7 @@ export const RENDER_OWNER = 'globe-engine'; * @param {{ requestRender?: (reason: string) => void, * createStage?: (options: object) => object, * createSonar?: typeof createCesiumSonar }} [options] `requestRender` - * defaults to the render governor when it governs this viewer, else the - * scene itself (before the governor is installed, or on another viewer); + * defaults to the scene itself; * `createStage` (postStages.js) and `createSonar` are test seams. * @returns {{ render: import('../contract.js').GlobeRender, * sonarDiagnostics: () => object, destroy: () => void }} @@ -30,15 +19,12 @@ export const RENDER_OWNER = 'globe-engine'; export function createCesiumRender( viewer, { - requestRender = (reason) => { - if (isRenderGovernorInstalled(viewer)) governorRequestRender(reason); - else viewer.scene.requestRender?.(); - }, + requestRender = () => viewer.scene.requestRender?.(), createStage, createSonar = createCesiumSonar, } = {}, ) { - let held = false; + viewer.scene.maximumRenderTimeChange = Infinity; const effects = createCesiumPostStages(viewer, { requestRender, ...(createStage ? { createStage } : {}), @@ -69,11 +55,8 @@ export function createCesiumRender( requestRender(reason); }, setContinuous(enabled) { - held = Boolean(enabled); - if (held) holdContinuousRender(RENDER_OWNER); - else releaseContinuousRender(RENDER_OWNER); + viewer.scene.requestRenderMode = !enabled; }, - // The scene's real mode: another owner's hold keeps it continuous. isContinuous: () => viewer.scene.requestRenderMode === false, ...effects.render, setContactSonar(options) { @@ -93,7 +76,7 @@ export function createCesiumRender( render, sonarDiagnostics: () => sonar.diagnostics(), destroy() { - if (held) render.setContinuous(false); + render.setContinuous(false); removeFrameWatch?.(); sonar.destroy(); effects.destroy(); diff --git a/src/globe/cesium/render.test.mjs b/src/globe/cesium/render.test.mjs index d2ffd3d22..e542f4947 100644 --- a/src/globe/cesium/render.test.mjs +++ b/src/globe/cesium/render.test.mjs @@ -1,17 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - _resetRenderGovernorForTest, - getRenderGovernorDiagnostics, - holdContinuousRender, - installRenderGovernor, -} from '../../renderGovernor.js'; import { createCesiumRender, RENDER_OWNER } from './render.js'; import { createFakeViewer } from './testing/fakeViewer.mjs'; -test.beforeEach(() => _resetRenderGovernorForTest()); -test.after(() => _resetRenderGovernorForTest()); - test('request goes through the injected requestRender with a reason', () => { const viewer = createFakeViewer(); const reasons = []; @@ -19,84 +10,37 @@ test('request goes through the injected requestRender with a reason', () => { requestRender: (reason) => reasons.push(reason), }); render.request(); - assert.deepEqual(reasons, [RENDER_OWNER]); + render.request('world-overlay'); + assert.deepEqual(reasons, [RENDER_OWNER, 'world-overlay']); assert.equal(viewer.callsOf('scene.requestRender').length, 0); }); -test('request defaults to the render governor', () => { - const viewer = createFakeViewer(); - installRenderGovernor(viewer); - const { render } = createCesiumRender(viewer); - render.request(); - assert.equal(viewer.callsOf('scene.requestRender').length, 1); - assert.deepEqual( - getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason), - [RENDER_OWNER], - ); -}); - -test('creation leaves the scene and the governor alone', () => { +test('creation disables time-driven renders and preserves the current mode', () => { const viewer = createFakeViewer(); viewer.scene.maximumRenderTimeChange = 0.5; + viewer.scene.requestRenderMode = false; createCesiumRender(viewer); - assert.equal(viewer.scene.requestRenderMode, true); - assert.equal(viewer.scene.maximumRenderTimeChange, 0.5); - assert.equal(getRenderGovernorDiagnostics().installed, false); - assert.deepEqual(getRenderGovernorDiagnostics().holds, []); -}); - -test('setContinuous holds and releases the governor under its own owner', () => { - const viewer = createFakeViewer(); - installRenderGovernor(viewer); - const { render } = createCesiumRender(viewer); - assert.equal(render.isContinuous(), false); - render.setContinuous(true); - assert.deepEqual(getRenderGovernorDiagnostics().holds, [RENDER_OWNER]); assert.equal(viewer.scene.requestRenderMode, false); - assert.equal(render.isContinuous(), true); - render.setContinuous(false); - assert.deepEqual(getRenderGovernorDiagnostics().holds, []); - assert.equal(render.isContinuous(), false); + assert.equal(viewer.scene.maximumRenderTimeChange, Infinity); }); -test("isContinuous reports the scene's mode, including other owners' holds", () => { - const viewer = createFakeViewer(); - installRenderGovernor(viewer); - const { render } = createCesiumRender(viewer); - holdContinuousRender('flights'); - render.setContinuous(false); - assert.equal(render.isContinuous(), true); -}); - -test('destroy releases the engine hold', () => { - const viewer = createFakeViewer(); - installRenderGovernor(viewer); - const { render, destroy } = createCesiumRender(viewer); - render.setContinuous(true); - destroy(); - assert.deepEqual(getRenderGovernorDiagnostics().holds, []); -}); - -test('request passes a caller reason through to the governor', () => { - const viewer = createFakeViewer(); - installRenderGovernor(viewer); - const { render } = createCesiumRender(viewer); - render.request('world-overlay'); - assert.deepEqual( - getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason), - ['world-overlay'], - ); -}); - -test('without a governor on its viewer, request renders the scene directly', () => { - const viewer = createFakeViewer(); - const { render } = createCesiumRender(viewer); - render.request(); - assert.equal(viewer.callsOf('scene.requestRender').length, 1); - const governed = createFakeViewer(); - installRenderGovernor(governed); - render.request(); - assert.equal(viewer.callsOf('scene.requestRender').length, 2); - assert.equal(governed.callsOf('scene.requestRender').length, 0); - assert.deepEqual(getRenderGovernorDiagnostics().recentRequests, []); +test('render mode, requests and destruction affect only the owning scene', () => { + const first = createFakeViewer(); + const second = createFakeViewer(); + const a = createCesiumRender(first); + const b = createCesiumRender(second); + a.render.setContinuous(true); + b.render.setContinuous(true); + assert.equal(a.render.isContinuous(), true); + assert.equal(b.render.isContinuous(), true); + a.render.setContinuous(false); + assert.equal(a.render.isContinuous(), false); + assert.equal(b.render.isContinuous(), true); + a.render.request('world-overlay'); + assert.equal(first.callsOf('scene.requestRender').length, 1); + assert.equal(second.callsOf('scene.requestRender').length, 0); + a.destroy(); + assert.equal(b.render.isContinuous(), true); + b.destroy(); + assert.equal(b.render.isContinuous(), false); }); diff --git a/src/globe/cesium/starrySky.js b/src/globe/cesium/starrySky.js index 6c00cd121..e22e20784 100644 --- a/src/globe/cesium/starrySky.js +++ b/src/globe/cesium/starrySky.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { loadBundledJson } from '../../data/bundledJson.js'; -import { governorRequestRender } from '../../renderGovernor.js'; import { buildStarVertices, STAR_VERTEX_FLOATS } from '../../sky/stars.js'; /** @@ -143,9 +142,7 @@ export class StarrySky { * @param {() => void} [options.requestRender] renders one frame when an * asset or the precise rotation arrives; the idle governor by default. */ - constructor({ - requestRender = () => governorRequestRender('sky-assets'), - } = {}) { + constructor({ requestRender = () => {} } = {}) { /** Matches `SkyBox#show`. */ this.show = true; this.milkyWayIntensity = MILKY_WAY_INTENSITY; diff --git a/src/globe/cesium/viewer.js b/src/globe/cesium/viewer.js index faa0dc1b8..ad6b543c3 100644 --- a/src/globe/cesium/viewer.js +++ b/src/globe/cesium/viewer.js @@ -387,7 +387,7 @@ export const loadCesiumPhotorealTileset = (keys) => loadPhotorealisticTileset(Cesium, keys); /** Cesium.Viewer options of the standard globe viewer. */ -export function applicationViewerOptions(creditContainer) { +export function applicationViewerOptions(creditContainer, requestRender) { return { timeline: false, animation: false, @@ -403,7 +403,7 @@ export function applicationViewerOptions(creditContainer) { baseLayer: false, // Replaces the blocky Tycho-2 default. An option, not `skyBox: false`, // which would also stop CesiumWidget from creating the Sun and Moon. - skyBox: new StarrySky(), + skyBox: new StarrySky({ requestRender }), creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, @@ -416,7 +416,9 @@ export function createApplicationViewer({ container, creditContainer }) { throw new TypeError('Viewer and credit containers are required'); const viewer = new Cesium.Viewer( container, - applicationViewerOptions(creditContainer), + applicationViewerOptions(creditContainer, () => + viewer.scene.requestRender(), + ), ); try { viewer.targetFrameRate = 60; diff --git a/src/globe/cesium/zoomOutGuard.js b/src/globe/cesium/zoomOutGuard.js index 129b92d7d..c3ac9a6b8 100644 --- a/src/globe/cesium/zoomOutGuard.js +++ b/src/globe/cesium/zoomOutGuard.js @@ -1,5 +1,4 @@ import * as Cesium from 'cesium'; -import { governorRequestRender } from '../../renderGovernor.js'; /** Whole-Earth input limit; explicit camera destinations can go farther. */ export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000; @@ -84,7 +83,7 @@ function readInput(aggregator, bindings) { export function installZoomOutGuard( viewer, { - requestRender = governorRequestRender, + requestRender = () => viewer.scene.requestRender(), subscribeCameraHandoff, createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas), } = {}, diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js index bfb4121c0..2323ff6ad 100644 --- a/src/layers/aircraft/sceneBinding.js +++ b/src/layers/aircraft/sceneBinding.js @@ -83,7 +83,10 @@ export function createAircraftSceneBinding({ const renderer = flightState._renderer; if (!renderer) return; removeSceneListeners(); - services.render.releaseContinuousRender(id); + services.render.releaseContinuousRender( + id, + flightState._renderer?.engine, + ); flightState._trackedCameraFrameStop?.(); flightState._trackedCameraFrameStop = null; if (flightState._trackedEntity) { diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index d9796f4e6..5f58ff0c4 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -75,7 +75,7 @@ export function createAlprCamerasLayer({ if (state.status === status && state.error === error) return; state.status = status; state.error = error; - governorRequestRender('alpr-status'); + governorRequestRender('alpr-status', sceneEngine); } function scheduleUnavailableRetry() { @@ -260,7 +260,7 @@ export function createAlprCamerasLayer({ state.pendingQueryBox = null; state.loading = false; state.retrying = false; - governorRequestRender('alpr-status'); + governorRequestRender('alpr-status', sceneEngine); } } } diff --git a/src/layers/alpr/render/display.js b/src/layers/alpr/render/display.js index 0d1deeb43..feb7a4b36 100644 --- a/src/layers/alpr/render/display.js +++ b/src/layers/alpr/render/display.js @@ -296,7 +296,7 @@ export function createAlprDisplay(engine, { state, services, source }) { } resolveFloors(); flushSprites(); - governorRequestRender('alpr-surface'); + governorRequestRender('alpr-surface', engine); } /** @@ -352,7 +352,7 @@ export function createAlprDisplay(engine, { state, services, source }) { } if (!moved) return; flushSprites(); - governorRequestRender('alpr-floor'); + governorRequestRender('alpr-floor', engine); }); floorRequest = request; } @@ -399,7 +399,7 @@ export function createAlprDisplay(engine, { state, services, source }) { creditTimer = null; removeCredit?.(); removeCredit = null; - governorRequestRender('alpr-credit'); + governorRequestRender('alpr-credit', engine); } /** @@ -419,7 +419,7 @@ export function createAlprDisplay(engine, { state, services, source }) { creditPresented = true; removeCredit = engine.credits.addStatic(creditMarkup, { onScreen: true }); creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS); - governorRequestRender('alpr-credit'); + governorRequestRender('alpr-credit', engine); } function renderRecords() { @@ -442,7 +442,7 @@ export function createAlprDisplay(engine, { state, services, source }) { dropSelectedSprite(); clearSelectedEntityContextForLayer(LAYER_ID); } - governorRequestRender('alpr-render'); + governorRequestRender('alpr-render', engine); const ids = new Set(shown.map((record) => record.id)); let changed = false; for (const id of [...markers.keys()]) @@ -553,7 +553,7 @@ export function createAlprDisplay(engine, { state, services, source }) { updateSelectedAnchor(); overlay.sync(visibleRecords); flushSprites(); - governorRequestRender('alpr-selection'); + governorRequestRender('alpr-selection', engine); return true; } @@ -566,7 +566,7 @@ export function createAlprDisplay(engine, { state, services, source }) { overlay.sync(visibleRecords); flushSprites(); clearSelectedEntityContextForLayer(LAYER_ID); - governorRequestRender('alpr-selection'); + governorRequestRender('alpr-selection', engine); } /** @@ -600,7 +600,7 @@ export function createAlprDisplay(engine, { state, services, source }) { geo.cartesian3.distance(next, marker.displayPosition) > 0.1 ) { marker.displayPosition = next; - governorRequestRender('alpr-anchor'); + governorRequestRender('alpr-anchor', engine); } } @@ -637,7 +637,7 @@ export function createAlprDisplay(engine, { state, services, source }) { range: 800, }, ); - governorRequestRender('alpr-focus'); + governorRequestRender('alpr-focus', engine); return true; } diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index f3048ee4f..d182aaa72 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -111,8 +111,9 @@ export function createLifecycle({ function syncAwarenessRenderHold() { if (layerState.enabled && parts.model.awarenessNeedsContinuousRender()) - holdContinuousRender('military-awareness'); - else releaseContinuousRender('military-awareness'); + holdContinuousRender('military-awareness', layerState.display?.engine); + else + releaseContinuousRender('military-awareness', layerState.display?.engine); } function attachRuntimeListeners() { @@ -218,7 +219,7 @@ export function createLifecycle({ layerState.cameraMoving = false; // No frames will arrive to run the per-frame release once the listener is // gone, so drop the hold here. - releaseContinuousRender('military-awareness'); + releaseContinuousRender('military-awareness', layerState.display?.engine); parts.rendering.cancelDirectionOverlayUpdate(); layerState.runtimeListenersAttached = false; } @@ -320,7 +321,7 @@ export function createLifecycle({ disable() { layerState.enabled = false; - releaseContinuousRender('military-awareness'); + releaseContinuousRender('military-awareness', layerState.display?.engine); detachRuntimeListeners(); parts.panel.stopAwarenessPageRotation(); layerState.cohortPages.clear(); diff --git a/src/layers/awareness/render/display.js b/src/layers/awareness/render/display.js index b28d4002c..63b9a4671 100644 --- a/src/layers/awareness/render/display.js +++ b/src/layers/awareness/render/display.js @@ -46,6 +46,7 @@ export function createAwarenessDisplay(engine) { }); const style = AWARENESS_RANGE_RING_STYLE; return { + engine, container: engine.host.container, canvas: engine.host.canvas, // Camera-shaped: positionWC, heading, rightWC, upWC, refreshed per frame. diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index ebeecdfa8..5748f856d 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -457,7 +457,7 @@ export function createRendering({ visual.rim.destroy(); visual.disc?.destroy(); // Idle mode renders only on request; a removed ring must not linger. - governorRequestRender('awareness-visual'); + governorRequestRender('awareness-visual', layerState.display?.engine); } layerState.visual = null; } @@ -529,7 +529,7 @@ export function createRendering({ if (performance.now() < wake.untilMs && !belowRim()) timer = globalThis.setTimeout(() => { timer = null; - governorRequestRender('awareness-pulse'); + governorRequestRender('awareness-pulse', layerState.display?.engine); }, CYBER_SONAR_RENDER_INTERVAL_MS); }); setAwarenessEchoField({ @@ -612,7 +612,7 @@ export function createRendering({ stopPulses, removeMotionListener: watchReducedMotion(), }; - governorRequestRender('awareness-visual'); + governorRequestRender('awareness-visual', layerState.display?.engine); } function renderVisual(subject) { @@ -642,7 +642,7 @@ export function createRendering({ if (performance.now() < visual.wake?.untilMs) visual.disc?.move(groundCenter); // Content actually changed — buy exactly one frame instead of holding. - governorRequestRender('awareness-visual'); + governorRequestRender('awareness-visual', layerState.display?.engine); } return; } diff --git a/src/layers/bikeshare/stationPoints.js b/src/layers/bikeshare/stationPoints.js index 63d68decf..8f1cb936a 100644 --- a/src/layers/bikeshare/stationPoints.js +++ b/src/layers/bikeshare/stationPoints.js @@ -37,7 +37,7 @@ export function createStationPoints({ state, services, parts }, points) { function ensureCityPoints(cityId, stationMap) { // Deferred debounce/fetch completions change points after the camera // settled: each commit needs one frame in idle mode. - services.render.governorRequestRender('bikeshare-points'); + services.render.governorRequestRender('bikeshare-points', state._engine); const runtime = ensureCityRuntime(cityId); for (const station of stationMap.values()) { @@ -120,7 +120,7 @@ export function createStationPoints({ state, services, parts }, points) { * @param {Map} statusMap - Parsed station status map. */ function applyStatusToPoints(cityId, statusMap) { - services.render.governorRequestRender('bikeshare-status'); + services.render.governorRequestRender('bikeshare-status', state._engine); const runtime = state._cityRuntime.get(cityId); if (!runtime) return; diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 9efb06cee..8d41924cc 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -66,7 +66,7 @@ export function createViewport({ state: layerState, services, parts, camera }) { */ function deactivateCity(cityId) { - governorRequestRender('bikeshare-deactivate'); + governorRequestRender('bikeshare-deactivate', layerState._engine); parts.ingestion.abortInFlight(layerState._inFlightInfo, cityId); parts.ingestion.abortInFlight(layerState._inFlightStatus, cityId); parts.rendering?.removeCityPoints(cityId); diff --git a/src/layers/cctv/controls.js b/src/layers/cctv/controls.js index 492ff1758..262699a67 100644 --- a/src/layers/cctv/controls.js +++ b/src/layers/cctv/controls.js @@ -153,9 +153,9 @@ export function createControls({ state: layerState, services, parts, source }) { layerState._gizmo?.setEnabled(true); // ADJUST mode: gizmo drags mutate entity geometry from pointer events, // which don't trigger renders in requestRenderMode. (perf wave 2) - holdContinuousRender('cctv-adjust'); + holdContinuousRender('cctv-adjust', parts.renderer?.engine); } else { - releaseContinuousRender('cctv-adjust'); + releaseContinuousRender('cctv-adjust', parts.renderer?.engine); layerState._gizmo?.setEnabled(false); } } diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index f659e8770..9271e385f 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -453,7 +453,10 @@ export function createLifecycle({ if (layerState._calibrationMode) { parts.calibration.ensureGizmo(); layerState._gizmo?.setEnabled(true); - services.render.holdContinuousRender('cctv-adjust'); + services.render.holdContinuousRender( + 'cctv-adjust', + parts.renderer?.engine, + ); } settleView(); }, @@ -481,7 +484,7 @@ export function createLifecycle({ null; layerState._gizmo?.destroy(); layerState._gizmo = null; - releaseContinuousRender('cctv-adjust'); + releaseContinuousRender('cctv-adjust', parts.renderer?.engine); layerState._removeClick?.(); layerState._removeClick = null; parts.projection.stopProjectionLoop(); @@ -580,7 +583,7 @@ export function createLifecycle({ unregisterPickOwner('cctv', parts.renderer?.engine); // ADJUST mode does not survive a layer toggle — predictable re-entry. layerState._calibrationMode = false; - releaseContinuousRender('cctv-adjust'); + releaseContinuousRender('cctv-adjust', parts.renderer?.engine); layerState._gizmo?.setEnabled(false); layerState._removeFocusAppearListener?.(); layerState._removeFocusAppearListener = null; @@ -631,7 +634,7 @@ export function createLifecycle({ layerState._gizmo = null; } layerState._calibrationMode = false; - releaseContinuousRender('cctv-adjust'); + releaseContinuousRender('cctv-adjust', parts.renderer?.engine); layerState._removeClick?.(); layerState._removeClick = null; parts.projection.stopProjectionLoop(); diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index 7849904a5..8bf5f00c3 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -389,7 +389,7 @@ export function createProjection({ layerState._projectionRaf = 0; layerState._projectionTimer = 0; if (!parts.renderer?.ready || !parts.model.projectionLoopIsNeeded()) { - releaseContinuousRender('cctv-projection'); + releaseContinuousRender('cctv-projection', parts.renderer?.engine); return; } @@ -401,7 +401,10 @@ export function createProjection({ if (active.projection && !active.projection.video) { parts.frames.drawProjectionFrame(active); if (parts.frames.refreshProjectionTextures(active)) - governorRequestRender('cctv-projection-image'); + governorRequestRender( + 'cctv-projection-image', + parts.renderer?.engine, + ); } } @@ -412,12 +415,12 @@ export function createProjection({ layerState._activeFocusStyleCount, ); if (continuous) { - holdContinuousRender('cctv-projection'); + holdContinuousRender('cctv-projection', parts.renderer?.engine); layerState._projectionRaf = requestAnimationFrame(tick); } else { // Stills retain the existing fetch/texture cadence without rendering // unchanged pixels at display refresh rate. - releaseContinuousRender('cctv-projection'); + releaseContinuousRender('cctv-projection', parts.renderer?.engine); layerState._projectionTimer = setTimeout( tick, PROJECTION_TEXTURE_SWAP_MS, @@ -438,7 +441,7 @@ export function createProjection({ cancelAnimationFrame(layerState._projectionRaf); layerState._projectionRaf = 0; } - releaseContinuousRender('cctv-projection'); + releaseContinuousRender('cctv-projection', parts.renderer?.engine); } /** diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 065f5f669..08f314cc4 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -999,8 +999,9 @@ export function createDirectionsLayer({ const shouldHold = _enabled && _routeDrawn; if (shouldHold === _renderHeld) return; _renderHeld = shouldHold; - if (shouldHold) services.render.holdContinuousRender?.('directions'); - else services.render.releaseContinuousRender?.('directions'); + if (shouldHold) + services.render.holdContinuousRender?.('directions', _engine); + else services.render.releaseContinuousRender?.('directions', _engine); } function clearRouteGraphics() { @@ -1053,7 +1054,7 @@ export function createDirectionsLayer({ pushSteps(); // A card already open on a step that has just been anchored moves with it. if (_selectedStep !== null) refreshStepCard(_selectedStep); - services.render.governorRequestRender('directions-anchor'); + services.render.governorRequestRender('directions-anchor', _engine); if (!unresolved) { _anchorAttempts = 0; _anchorStartedAt = 0; @@ -1089,7 +1090,7 @@ export function createDirectionsLayer({ _anchorAttempts = 0; _anchorStartedAt = Date.now(); anchorStepPoints(); - services.render.governorRequestRender('directions-route'); + services.render.governorRequestRender('directions-route', _engine); } async function requestRoute() { @@ -1149,7 +1150,7 @@ export function createDirectionsLayer({ clearTimeout(timer); if (_routeAbort === controller) _routeAbort = null; if (seq === _routeSeq) notifyRow(); - services.render.governorRequestRender('directions-route'); + services.render.governorRequestRender('directions-route', _engine); } } @@ -1214,7 +1215,7 @@ export function createDirectionsLayer({ placeMarker(which, point); if (_a && _b) void requestRoute(); else notifyRow(); - services.render.governorRequestRender('directions-place'); + services.render.governorRequestRender('directions-place', _engine); return true; } @@ -1277,7 +1278,7 @@ export function createDirectionsLayer({ placeMarker('a', null); placeMarker('b', null); clearRouteGraphics(); - services.render.governorRequestRender('directions-clear'); + services.render.governorRequestRender('directions-clear', _engine); } /** @@ -1434,7 +1435,7 @@ export function createDirectionsLayer({ } _selectedStep = index; _display?.selectStep(index); - services.render.governorRequestRender('directions-select'); + services.render.governorRequestRender('directions-select', _engine); return true; } @@ -1601,7 +1602,7 @@ export function createDirectionsLayer({ } if (patch.fly) flyCurrentRoute(); notifyRow(); - services.render.governorRequestRender('directions-params'); + services.render.governorRequestRender('directions-params', _engine); return true; }, diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs index 8ed7226e8..c6bed8f46 100644 --- a/src/layers/directions/render/display.test.mjs +++ b/src/layers/directions/render/display.test.mjs @@ -193,7 +193,7 @@ for (const makeHarness of GROUND_HARNESSES) { true, ); assert.ok( - getRenderGovernorDiagnostics().holds.includes('directions'), + getRenderGovernorDiagnostics(f.engine).holds.includes('directions'), 'a drawn route holds continuous render', ); if (f.cesium) { @@ -215,7 +215,7 @@ for (const makeHarness of GROUND_HARNESSES) { f.layer.setParams({ clear: true }); assert.equal(f.set(IDS.route), undefined); assert.ok( - !getRenderGovernorDiagnostics().holds.includes('directions'), + !getRenderGovernorDiagnostics(f.engine).holds.includes('directions'), 'a cleared route releases it', ); }); diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 8202131b9..343f2ed8a 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -150,7 +150,10 @@ export function createViewport({ settleTimer = setTimeout(() => { settleTimer = null; if (layerState._enabled && !layerState._destroyed) - governorRequestRender('firms-lod-settle'); + governorRequestRender( + 'firms-lod-settle', + layerState._renderer?.engine, + ); }, LOD_CHECK_MS + 40); }); } diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 47e661b85..bc60a3e4b 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -115,7 +115,8 @@ export function createLifecycle({ const renderer = flightState._renderer; flightState.enabled = true; renderer?.fleet.setVisible(true); - if (renderer) holdContinuousRender('flights'); // per-frame animator + if (renderer) + holdContinuousRender('flights', flightState._renderer?.engine); // per-frame animator renderer?.models.setVisible(true); parts.tracking._setCockpitContactMode( globalThis.document?.body.classList.contains('cockpit-mode') === true, @@ -181,7 +182,7 @@ export function createLifecycle({ parts.controller._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); flightState._renderer?.fleet.setVisible(false); - releaseContinuousRender('flights'); + releaseContinuousRender('flights', flightState._renderer?.engine); parts.rendering._releaseModels(); flightState._renderer?.models.setVisible(false); parts.tracking._clearTracking(); @@ -201,7 +202,7 @@ export function createLifecycle({ flightState._enrichActive = 0; flightState._enrichLastDispatchMs = 0; parts.controller._abortActiveUpdates(); - releaseContinuousRender('flights'); // direct-destroy path (perf wave 2 fix) + releaseContinuousRender('flights', flightState._renderer?.engine); // direct-destroy path (perf wave 2 fix) parts.tracking._clearTracking(); parts.tracking._destroyTrail(); parts.tracking._cancelPendingTrackingRestore(); diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 52858579b..3232df359 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -69,7 +69,7 @@ export function createIngestion({ if (layerState.status === status && layerState.error === error) return; layerState.status = status; layerState.error = error; - governorRequestRender('installations-status'); + governorRequestRender('installations-status', layerState.engine); } async function loadInstallations() { diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js index c96df265e..e9b11aac2 100644 --- a/src/layers/installations/render/display.js +++ b/src/layers/installations/render/display.js @@ -327,7 +327,7 @@ export function createInstallationsDisplay( })), ); fill.commit(); - governorRequestRender('installation-selection-fill'); + governorRequestRender('installation-selection-fill', engine); } function clearRendered() { @@ -433,7 +433,7 @@ export function createInstallationsDisplay( } // Post-moveEnd debounced fetches commit after the camera settles; the // rebuilt sets need one frame in idle mode. - governorRequestRender('installations-render'); + governorRequestRender('installations-render', engine); syncOsmCredit(state.records); syncPlacesCredit(state.records); const records = renderableRecords(); diff --git a/src/layers/installations/render/namedMarkers.js b/src/layers/installations/render/namedMarkers.js index 33aae6fd2..fff284cf7 100644 --- a/src/layers/installations/render/namedMarkers.js +++ b/src/layers/installations/render/namedMarkers.js @@ -101,7 +101,7 @@ export function createNamedMarkers( function cameraChanged() { refresh(); - services.render.governorRequestRender('installation-names'); + services.render.governorRequestRender('installation-names', engine); } // Small pose changes raise no camera event: test the discs before the @@ -232,7 +232,7 @@ export function createNamedMarkers( points.commit(); if (state.enabled) publishLabels(); refresh(); - services.render.governorRequestRender('installation-names'); + services.render.governorRequestRender('installation-names', engine); } return { diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index f4cb36892..56fca7736 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -70,7 +70,7 @@ export function createLifecycle({ ); parts.launchPad.initLaunchPadZonePrimitive(); if (layerState._enabled) { - holdContinuousRender('rocket-launches'); + holdContinuousRender('rocket-launches', layerState._engine); getRenderer().setVisible(true); layerState._orbitMatches = 0; for (const launch of layerState._launches) @@ -144,16 +144,17 @@ export function createLifecycle({ layerState._satelliteTelemetry.clear(); layerState._replayTracks.clear(); parts.rendering.removeMissionElements(); + if (layerState._enabled) + releaseContinuousRender('rocket-launches', layerState._engine); layerState._renderer.destroy(); layerState._renderer = layerState._engine = null; - if (layerState._enabled) releaseContinuousRender('rocket-launches'); }, async enable() { layerState._sourceController.abort(); layerState._sourceController = new AbortController(); layerState._enabled = true; - holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) + holdContinuousRender('rocket-launches', layerState._engine); // per-frame animator (perf wave 2) layerState._lifecycleToken++; layerState._postTleRetryCount = 0; try { @@ -218,7 +219,7 @@ export function createLifecycle({ entryView, layerState._entryLandedView, ); - releaseContinuousRender('rocket-launches'); + releaseContinuousRender('rocket-launches', layerState._engine); layerState._lifecycleToken++; layerState._updateDirty = false; parts.ingestion.clearPostTleRetry(); @@ -244,7 +245,7 @@ export function createLifecycle({ layerState._resumeRenderer?.(); layerState._waitForRenderer = layerState._resumeRenderer = null; layerState._sourceController.abort(); - releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) + releaseContinuousRender('rocket-launches', layerState._engine); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; layerState._lifecycleToken++; layerState._updateDirty = false; diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 141ae8e41..3631a539d 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -136,7 +136,7 @@ export function createLocalAdsbLayer({ onChange: () => { for (const marker of markers.values()) refreshClass(marker); refreshSelectedCard(); - governorRequestRender('local-adsb-enrichment'); + governorRequestRender('local-adsb-enrichment', renderer?.engine); }, }); @@ -441,8 +441,8 @@ export function createLocalAdsbLayer({ const want = Boolean(renderer) && enabled && markers.size > 0; if (want === renderHeld) return; renderHeld = want; - if (want) holdContinuousRender(RENDER_HOLD_ID); - else releaseContinuousRender(RENDER_HOLD_ID); + if (want) holdContinuousRender(RENDER_HOLD_ID, renderer?.engine); + else releaseContinuousRender(RENDER_HOLD_ID, renderer?.engine); } function requestEnrichment() { @@ -488,7 +488,8 @@ export function createLocalAdsbLayer({ } requestEnrichment(); updateRenderHold(); - if (live.size || removed) governorRequestRender('local-adsb-update'); + if (live.size || removed) + governorRequestRender('local-adsb-update', renderer?.engine); // Detection re-solves labels only when the set of contacts changes, not // on every position report. if (added || removed) markSourcesChanged('local-adsb-update'); @@ -521,7 +522,7 @@ export function createLocalAdsbLayer({ marker.entity.gevLabelModel = cardModel(marker, now()); selectEntityContext(marker.entity); if (!reselect) startTrail(marker); - governorRequestRender('local-adsb-selection'); + governorRequestRender('local-adsb-selection', renderer?.engine); return true; } @@ -560,7 +561,7 @@ export function createLocalAdsbLayer({ onEmptyClick() { if (!enabled || !selectedId) return; clearSelection(); - governorRequestRender('local-adsb-selection'); + governorRequestRender('local-adsb-selection', renderer?.engine); }, }); } @@ -667,6 +668,8 @@ export function createLocalAdsbLayer({ releaseTrail(); models?.destroy(); models = null; + releaseContinuousRender(RENDER_HOLD_ID, renderer.engine); + renderHeld = false; renderer.detach(); renderer.destroy(); renderer = null; diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 71218bc90..3562a5385 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -98,7 +98,8 @@ export function createLifecycle({ const renderer = flightState._renderer; flightState.enabled = true; renderer?.fleet.setVisible(true); - if (renderer) holdContinuousRender('military'); // per-frame animator + if (renderer) + holdContinuousRender('military', flightState._renderer?.engine); // per-frame animator renderer?.models.setVisible(true); parts.tracking._setCockpitContactMode( globalThis.document?.body.classList.contains('cockpit-mode') === true, @@ -161,7 +162,7 @@ export function createLifecycle({ parts.controller._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); flightState._renderer?.fleet.setVisible(false); - releaseContinuousRender('military'); + releaseContinuousRender('military', flightState._renderer?.engine); parts.rendering._releaseModels(); flightState._renderer?.models.setVisible(false); parts.tracking._clearTracking(); @@ -181,7 +182,7 @@ export function createLifecycle({ setMilitaryLayerActive(false); flightState.lifetime.abort(); parts.controller._abortActiveUpdates(); - releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) + releaseContinuousRender('military', flightState._renderer?.engine); // direct-destroy path (perf wave 2 fix) parts.tracking._clearTracking(); parts.tracking._destroyTrail(); parts.tracking._cancelPendingTrackingRestore(); diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index d054ca0de..3f4aa60ea 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -227,7 +227,8 @@ export function createRendering({ scheduleRadioOverlayPublish(); // The horizon timer can commit AFTER the camera settles and the governor // parks the scene — a changed show flag needs one frame. (perf wave 2 fix) - if (visibilityChanged) governorRequestRender('radio-horizon'); + if (visibilityChanged) + governorRequestRender('radio-horizon', parts.renderer?.engine); } function reconcileStations(stations) { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 538a0e469..ab3482347 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -126,13 +126,14 @@ export function createLifecycle({ } layerState._detectionObjects.clear(); renderer.removePoints(layerState._pointCollection); + if (layerState._enabled) + releaseContinuousRender('satellites', layerState._engine); renderer.destroy(); layerState._engine = layerState._renderer = layerState._pointCollection = null; layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - if (layerState._enabled) releaseContinuousRender('satellites'); }, attachRenderer( @@ -187,7 +188,7 @@ export function createLifecycle({ enable() { layerState._enabled = true; - holdContinuousRender('satellites'); // per-frame animator (perf wave 2) + holdContinuousRender('satellites', layerState._engine); // per-frame animator (perf wave 2) if (layerState._pointCollection) layerState._pointCollection.show = parts.controls.satelliteVisualsVisible( @@ -225,7 +226,7 @@ export function createLifecycle({ parts.catalog._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); layerState._enabled = false; - releaseContinuousRender('satellites'); + releaseContinuousRender('satellites', layerState._engine); if (layerState._pointCollection) layerState._pointCollection.show = false; for (const path of layerState._orbitPaths.values()) path.primitive.show = false; @@ -252,7 +253,7 @@ export function createLifecycle({ layerState._resumeRenderer?.(); layerState._waitForRenderer = layerState._resumeRenderer = null; parts.catalog._abortActiveUpdates(); - releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) + releaseContinuousRender('satellites', layerState._engine); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; parts.tracking._clearTracking(); parts.tracking._cancelPendingTrackingRestore(); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index f09d0802c..1e698ddaa 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -95,7 +95,16 @@ export function createStreetLevelLayer({ function providerContext(entry) { return Object.freeze({ - services: state.services, + services: { + ...state.services, + render: { + governorRequestRender: (reason) => + state.services.render?.governorRequestRender?.( + reason, + state.map?.engine, + ), + }, + }, /** Resolved against now, so "since N days" keeps up with the clock. */ getFilter: () => resolveFilter(state.filter), /** The layer and this provider's switch are both on. */ diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 83797f77f..5eacb0bff 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -126,7 +126,7 @@ export function createLineCoverage({ : Infinity; function requestRender() { - render?.governorRequestRender?.(renderKey); + render?.governorRequestRender?.(renderKey, state.map?.engine); } function tileKey({ x, y, z }) { diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index 75e6fcaaf..bab87fd6f 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -27,7 +27,7 @@ export function createMarker({ state }) { const { render } = state.services; function requestRender() { - render?.governorRequestRender?.('street-level-marker'); + render?.governorRequestRender?.('street-level-marker', state.map?.engine); } function ensure(view) { diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index 691456e0c..4bd0f0ec4 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -67,7 +67,7 @@ export function createSequenceCones({ const { render } = state.services; function requestRender() { - render?.governorRequestRender?.(renderKey); + render?.governorRequestRender?.(renderKey, state.map?.engine); } function notify() { diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index c8b1f794e..bad7dcb2a 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -29,9 +29,10 @@ export function createAnimation({ layerState._renderHeld = needed; if (needed) { layerState._lastAnimTime = 0; - services.render.holdContinuousRender('traffic'); + services.render.holdContinuousRender('traffic', layerState._engine); layerState._engine?.render.request('traffic'); - } else services.render.releaseContinuousRender('traffic'); + } else + services.render.releaseContinuousRender('traffic', layerState._engine); } /** Writes the dots to the engine's display (once a frame, and on a clear). */ diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index a69d3a3b9..8a8aaf933 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -199,7 +199,7 @@ export function createLifecycle({ // The dots keep their look (`dot.point`) for the next engine. layerState._display?.destroy(); if (layerState._renderHeld) - services.render.releaseContinuousRender('traffic'); + services.render.releaseContinuousRender('traffic', layerState._engine); layerState._renderHeld = false; layerState._engine = layerState._display = null; layerState._waitForScene ??= new Promise((resolve) => { diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index ea0b0d927..f7b4a66c2 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -196,7 +196,7 @@ export function createHeight({ state, services, parts }) { // adopted by the next bounded reread. for (const entry of entries) parts.trails.prepareEntry(entry); parts.rendering.requestVisibility(); - governorRequestRender('transit-floor'); + governorRequestRender('transit-floor', parts.renderer?.engine); if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS) state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS); } diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 656274f43..37899dd75 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -147,7 +147,7 @@ export function createIngestion({ state, services, parts, source }) { // Nothing else would notice: the per-frame pass returns early on an empty // fleet, so the hold would sit there held by a fleet that no longer exists. parts.rendering.syncRenderHold(); - governorRequestRender('transit-evict'); + governorRequestRender('transit-evict', parts.renderer?.engine); state._dataManager?.refreshLayerStats?.(); } return removed; @@ -190,7 +190,7 @@ export function createIngestion({ state, services, parts, source }) { removeFeedVehicles(feed.id); status.count = 0; parts.rendering.syncRenderHold(); - governorRequestRender('transit-stale-refused'); + governorRequestRender('transit-stale-refused', parts.renderer?.engine); state._dataManager?.refreshLayerStats?.(); return; } @@ -414,7 +414,7 @@ export function createIngestion({ state, services, parts, source }) { state._detectRevision++; parts.rendering.requestVisibility(); parts.rendering.syncRenderHold(); - governorRequestRender('transit-poll'); + governorRequestRender('transit-poll', parts.renderer?.engine); state._dataManager?.refreshLayerStats?.(); } diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 4a427f948..ea1c7861d 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -246,7 +246,8 @@ export function createLifecycle({ state._detectBuiltAt = -Infinity; state._visible.clear(); state._moving.clear(); - if (state._renderHeld) releaseContinuousRender('transit'); + if (state._renderHeld) + releaseContinuousRender('transit', parts.renderer?.engine); state._renderHeld = false; parts.renderer.destroy(); parts.renderer = @@ -309,7 +310,7 @@ export function createLifecycle({ state._rotationPose = null; state._rotationDirty = false; if (state._renderHeld) { - releaseContinuousRender('transit'); + releaseContinuousRender('transit', parts.renderer?.engine); state._renderHeld = false; } }, diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 2c673815c..23e1dceb1 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -37,13 +37,11 @@ import { releaseCameraSensitivity, } from '../../data/cameraSensitivity.js'; import { - _resetRenderGovernorForTest, getRenderGovernorDiagnostics, governorRequestRender, holdContinuousRender, - installRenderGovernor, + renderGovernorFor, releaseContinuousRender, - uninstallRenderGovernor, } from '../../renderGovernor.js'; const BOSTON = { lat: 42.3601, lon: -71.0589 }; @@ -283,7 +281,7 @@ function harness( }, }); - installRenderGovernor(viewer); + renderGovernorFor(engine).install(); layer.init(viewer, { engine }); t.after(() => { @@ -293,8 +291,8 @@ function harness( /* a test may already have destroyed it */ } engine.destroy(); - uninstallRenderGovernor(viewer); - _resetRenderGovernorForTest(); + renderGovernorFor(engine).destroy(); + globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; @@ -361,7 +359,7 @@ function harness( frame(); } }, - holds: () => getRenderGovernorDiagnostics().holds, + holds: () => getRenderGovernorDiagnostics(engine).holds, ground, meshCalls, /** @@ -839,7 +837,7 @@ test('the render hold follows real motion, and is released when everything settl ); app.frame(); assert.equal(app.holds().includes('transit'), false); - assert.equal(app.viewer.scene.requestRenderMode, true); + assert.equal(!app.engine.render.isContinuous(), true); // A second fix IS motion to come: the vehicle is armed and the loop held. app.advance(TRANSIT_POLL_MS); @@ -861,7 +859,7 @@ test('the render hold follows real motion, and is released when everything settl app.holds().includes('transit'), 'a moving vehicle keeps the loop awake', ); - assert.equal(app.viewer.scene.requestRenderMode, false); + assert.equal(!app.engine.render.isContinuous(), false); // Past the second fix it holds and the loop is released again. app.run(TRANSIT_POLL_MS); @@ -870,7 +868,7 @@ test('the render hold follows real motion, and is released when everything settl false, 'a settled fleet does not hold the GPU', ); - assert.equal(app.viewer.scene.requestRenderMode, true); + assert.equal(!app.engine.render.isContinuous(), true); }); test('disable hands back the camera sensitivity it borrowed', async (t) => { const app = harness(t); @@ -1112,7 +1110,7 @@ test('a parked fleet that keeps reporting the same position stops asking for fra ); assert.equal(app.state()._moving.size, 0); } - assert.equal(app.viewer.scene.requestRenderMode, true); + assert.equal(!app.engine.render.isContinuous(), true); assert.equal(app.vehicles().length, 2, 'and the fleet is still on screen'); // One vehicle finally moves: that IS work, and the hold comes back. @@ -1198,7 +1196,7 @@ test('the last vehicle leaving mid-segment takes the render hold with it', async false, 'no hold may outlive the fleet that justified it', ); - assert.equal(app.viewer.scene.requestRenderMode, true); + assert.equal(!app.engine.render.isContinuous(), true); assert.ok(Date.now() - fetchedAt > FEED_EVICT_AFTER_MS); }); test('only the vehicles near the view are animated frame by frame', async (t) => { @@ -3110,7 +3108,7 @@ for (const bounds of ['null', 'stale']) { parts.rendering.refreshVisibility(); assert.equal(state._visible.size, loaded.count); assert.equal(state._moving.size, loaded.count); - assert.equal(app.viewer.scene.requestRenderMode, false); + assert.equal(!app.engine.render.isContinuous(), false); const entry = app.vehicles()[0], before = Cesium.Cartesian3.clone(entry.marker.position); app.run(1000, 16); @@ -3182,7 +3180,7 @@ for (const count of [800, 3000]) { before = Cesium.Cartesian3.clone(entry.marker.position); app.run(1000, 16); assert.ok(Cesium.Cartesian3.distance(before, entry.marker.position) > 0.9); - assert.equal(app.viewer.scene.requestRenderMode, false); + assert.equal(!app.engine.render.isContinuous(), false); }); } @@ -3235,7 +3233,7 @@ test('intercepted scripted reports resolve their explicit floor and wake without assert.equal(entry.surfaceReady, true); assert.equal(entry.marker.show, true); assert.ok(app.state()._moving.has(entry)); - assert.equal(app.viewer.scene.requestRenderMode, false); + assert.equal(!app.engine.render.isContinuous(), false); const before = Cesium.Cartesian3.clone(entry.marker.position); app.run(1000, 16); assert.ok(Cesium.Cartesian3.distance(before, entry.marker.position) > 1); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 4c130e49d..04557befb 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -168,7 +168,10 @@ export function createRendering({ state, services, parts }) { placeSample(entry); schedulePlayback(entry); syncRenderHold(); - governorRequestRender('transit-motion-boundary'); + governorRequestRender( + 'transit-motion-boundary', + parts.renderer?.engine, + ); }, Math.max(1, sample.nextWakeMonoMs - performance.now()), ); @@ -280,7 +283,7 @@ export function createRendering({ state, services, parts }) { state._stylePreset = next; for (const entry of state._vehicles.values()) applySpriteStyle(entry); parts.trails.style(); - governorRequestRender('transit-style'); + governorRequestRender('transit-style', parts.renderer?.engine); } /** @@ -358,12 +361,12 @@ export function createRendering({ state, services, parts }) { // frames on — it is not moving. const working = state._enabled && state._moving.size > 0; if (working && !state._renderHeld) { - holdContinuousRender('transit'); + holdContinuousRender('transit', parts.renderer?.engine); state._renderHeld = true; } else if (!working && state._renderHeld) { - releaseContinuousRender('transit'); + releaseContinuousRender('transit', parts.renderer?.engine); state._renderHeld = false; - governorRequestRender('transit-settled'); + governorRequestRender('transit-settled', parts.renderer?.engine); } } @@ -421,7 +424,8 @@ export function createRendering({ state, services, parts }) { ) : 0; } - if (state._heightDirty.size) governorRequestRender('transit-height-batch'); + if (state._heightDirty.size) + governorRequestRender('transit-height-batch', parts.renderer?.engine); syncRenderHold(); } @@ -508,7 +512,7 @@ export function createRendering({ state, services, parts }) { state._shownCount = state._visible.size; if (changed) state._detectRevision++; syncRenderHold(); - governorRequestRender('transit-visibility'); + governorRequestRender('transit-visibility', parts.renderer?.engine); return state._shownCount; } function resumePlayback() { @@ -529,7 +533,7 @@ export function createRendering({ state, services, parts }) { parts.selection.refreshSelectedCard(true); state._detectRevision++; syncRenderHold(); - governorRequestRender('transit-re-entry'); + governorRequestRender('transit-re-entry', parts.renderer?.engine); } // Text and membership refreshes are bounded timer work, never paint work. function maintainPresentation() { diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index 9a18b1a40..e7ef481f1 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -106,7 +106,7 @@ export function createSelection({ state, services, parts }) { parts.rendering.paintSelected(entry, true); parts.trails.select(entry); refreshSelectedCard(true); - governorRequestRender('transit-select'); + governorRequestRender('transit-select', parts.renderer?.engine); } function onKeyDown(event) { diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index bd009e4be..b1ce8bbbf 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -189,7 +189,10 @@ export function createTrails({ state, services, parts, source }) { }); entry.trailVertices = vertices; entry.trailSegments = all; - services.render.governorRequestRender('transit-trail-history'); + services.render.governorRequestRender( + 'transit-trail-history', + parts.renderer?.engine, + ); } } return changed; @@ -300,7 +303,10 @@ export function createTrails({ state, services, parts, source }) { parts.height.anchorFloors(); prepareEntry(entry); update(); - services.render.governorRequestRender('transit-trail-backfill'); + services.render.governorRequestRender( + 'transit-trail-backfill', + parts.renderer?.engine, + ); }) .catch(() => { // Session history remains usable if the optional retained read fails. @@ -313,7 +319,10 @@ export function createTrails({ state, services, parts, source }) { if (!state._enabled || selected !== entry) return; parts.rendering.sampleIdle(entry); update(); - services.render.governorRequestRender('transit-trail-age'); + services.render.governorRequestRender( + 'transit-trail-age', + parts.renderer?.engine, + ); }, 1000); } function detachRenderer() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 3d10e463c..51a6a2497 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -113,7 +113,7 @@ export function createViewport({ state, services, parts }) { } parts.ingestion.sweepAgedVehicles(Date.now()); parts.rendering.syncRenderHold(); - governorRequestRender('transit-proximity'); + governorRequestRender('transit-proximity', parts.renderer?.engine); } function onCameraChanged() { diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index f2c82c3fb..a9e3681d3 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -189,7 +189,7 @@ export function createLifecycle({ state.preRenderRemover?.(); state.preRenderRemover = null; components.rendering.cancelTrailingVisibilityPass(); - releaseContinuousRender('ais-vessels'); + releaseContinuousRender('ais-vessels', state.renderer?.engine); unregisterPickOwner('ais-live-vessels', state.renderer?.engine); components.tracking.detachSelectedVesselTrail(); for (const record of state.records.all) @@ -242,7 +242,7 @@ export function createLifecycle({ removeViewListener(); state.feed.enabled = false; invalidateAisSession(); - releaseContinuousRender('ais-vessels'); + releaseContinuousRender('ais-vessels', state.renderer?.engine); components.rendering.cancelTrailingVisibilityPass(); unregisterPickOwner('ais-live-vessels', state.renderer?.engine); components.rendering.setVisible(false); @@ -261,7 +261,7 @@ export function createLifecycle({ destroy() { removeViewListener(); invalidateAisSession(); - releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + releaseContinuousRender('ais-vessels', state.renderer?.engine); // direct-destroy path (perf wave 2 fix) components.rendering.cancelTrailingVisibilityPass(); if (state.feed.abort) state.feed.abort.abort(); unregisterPickOwner('ais-live-vessels', state.renderer?.engine); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 70eb71a67..31a633a38 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -215,7 +215,8 @@ export function createRendering({ ); state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => { state.trailingVisibilityTimer = null; - if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); + if (state.feed.enabled) + governorRequestRender('ais-vessels-visibility', state.renderer?.engine); }, waitMs); } @@ -232,7 +233,8 @@ export function createRendering({ function updateVisibility(force = false) { if (!state.feed.enabled || (!state.renderer && !state.viewer)) return; - if (force) governorRequestRender('ais-vessels-update'); + if (force) + governorRequestRender('ais-vessels-update', state.renderer?.engine); const now = focusNowMs(performance.now()); const focusTarget = getFocusTarget(); const regularPass = @@ -261,7 +263,7 @@ export function createRendering({ } if (focusPass) { state.activeFocusCount = 0; - releaseContinuousRender('ais-vessels'); + releaseContinuousRender('ais-vessels', state.renderer?.engine); } return; } @@ -314,8 +316,8 @@ export function createRendering({ }); state.activeFocusCount = result.activeCount; if (result.activeCount > 0 || result.transitioning) - holdContinuousRender('ais-vessels'); - else releaseContinuousRender('ais-vessels'); + holdContinuousRender('ais-vessels', state.renderer?.engine); + else releaseContinuousRender('ais-vessels', state.renderer?.engine); } } diff --git a/src/layers/wildlifeSightings/rendering.js b/src/layers/wildlifeSightings/rendering.js index ee47c2759..43f2caccb 100644 --- a/src/layers/wildlifeSightings/rendering.js +++ b/src/layers/wildlifeSightings/rendering.js @@ -104,7 +104,10 @@ export function createRendering({ state, services, parts }) { return; } commit(); - services.render?.governorRequestRender?.(`wildlife-sightings-${reason}`); + services.render?.governorRequestRender?.( + `wildlife-sightings-${reason}`, + state.engine, + ); }; function beginBatch() { batchDepth += 1; diff --git a/src/mapStackController.js b/src/mapStackController.js index 382f73608..6a85ef41d 100644 --- a/src/mapStackController.js +++ b/src/mapStackController.js @@ -22,7 +22,9 @@ export class MapStackController extends MapSourceController { ? options.initialStack || 'photoreal' : registry.defaultId, ...options, - requestRender: governorRequestRender, + requestRender: + options.requestRender ?? + ((reason) => governorRequestRender(reason, options.engine)), }); this.googleTileset = options.googleTileset || null; this.cesiumToken = String(options.cesiumToken || '').trim(); diff --git a/src/orbit.js b/src/orbit.js index 390da774a..4e37b2fa7 100644 --- a/src/orbit.js +++ b/src/orbit.js @@ -1,8 +1,5 @@ import * as geo from './geo/index.js'; -import { - holdContinuousRender, - releaseContinuousRender, -} from './renderGovernor.js'; +import { renderGovernorFor } from './renderGovernor.js'; /** * OrbitController — smooth orbit around a target point. @@ -43,7 +40,8 @@ export class OrbitController { this.active = true; // Orbit mutates the camera from preRender — without a hold the loop // starves after its first idle frame. (perf wave 2) - holdContinuousRender('camera-orbit'); + this._renderGovernor = renderGovernorFor(this.engine); + this._renderGovernor?.holdContinuousRender('camera-orbit'); // Start from the camera's current heading for seamless transition this.angle = camera.pose().heading; @@ -71,7 +69,7 @@ export class OrbitController { */ stop() { this.active = false; - releaseContinuousRender('camera-orbit'); + this._renderGovernor?.releaseContinuousRender('camera-orbit'); if (this._removeListener) { this._removeListener(); this._removeListener = null; @@ -87,7 +85,7 @@ export class OrbitController { attachEngine(engine) { if (this.active || this._removeListener) { this.active = false; - releaseContinuousRender('camera-orbit'); + this._renderGovernor?.releaseContinuousRender('camera-orbit'); this._removeListener?.(); this._removeListener = null; } diff --git a/src/orbit.test.mjs b/src/orbit.test.mjs index 1ac2b2ca5..b5dcb6ac8 100644 --- a/src/orbit.test.mjs +++ b/src/orbit.test.mjs @@ -3,12 +3,7 @@ import assert from 'node:assert/strict'; import * as geo from './geo/index.js'; import { createFakeEngine } from './globe/testing/fakeEngine.js'; import { OrbitController } from './orbit.js'; -import { - _resetRenderGovernorForTest, - getRenderGovernorDiagnostics, -} from './renderGovernor.js'; - -test.afterEach(() => _resetRenderGovernorForTest()); +import { getRenderGovernorDiagnostics } from './renderGovernor.js'; test('orbit locks onto the target every frame from the current heading, and stop releases it', (t) => { t.mock.timers.enable({ apis: ['Date'], now: 1000 }); @@ -25,7 +20,9 @@ test('orbit locks onto the target every frame from the current heading, and stop orbit.toggle(target, { radius: 800, pitch: -20, speed: 10 }), true, ); - assert.ok(getRenderGovernorDiagnostics().holds.includes('camera-orbit')); + assert.ok( + getRenderGovernorDiagnostics(engine).holds.includes('camera-orbit'), + ); t.mock.timers.tick(500); engine.fake.frame(); const [first] = engine.fake.callsOf('camera.lookAt'); @@ -37,7 +34,9 @@ test('orbit locks onto the target every frame from the current heading, and stop assert.equal(first.args[1].range, 800); assert.equal(orbit.toggle(), false); assert.equal(engine.fake.callsOf('camera.release').length, 1); - assert.ok(!getRenderGovernorDiagnostics().holds.includes('camera-orbit')); + assert.ok( + !getRenderGovernorDiagnostics(engine).holds.includes('camera-orbit'), + ); engine.fake.frame(); assert.equal( engine.fake.callsOf('camera.lookAt').length, diff --git a/src/renderGovernor.js b/src/renderGovernor.js index 72f469ad0..2b7c25d7f 100644 --- a/src/renderGovernor.js +++ b/src/renderGovernor.js @@ -1,197 +1,85 @@ -/** - * Idle render governor — the wave-2 flagship of the 2026-08-05 perf - * investigation. - * - * The problem: Cesium's default render loop repaints every vsync forever, so - * the app burned ~60% GPU + ~54% of a core with ZERO layers enabled and a - * parked camera. The fix: flip the scene into Cesium's `requestRenderMode` - * whenever nothing animates per frame, and return to the continuous loop the - * moment something does. - * - * Architecture — a binary mode driven by ref-counted holds: - * - * - **Continuous mode** (`requestRenderMode = false`, today's behavior) - * 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 - * 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 - * 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. - * - * The governor is O(1) passive: no per-frame work of its own, ever. - * - * Globe engine: the governor is the Cesium engine's render backend. - * `engine.render.request()/setContinuous()` (src/globe/cesium/render.js) - * delegate here, so the governor never uses `engine.render` (that would be a - * cycle) and keeps the viewer for what the contract leaves to the engine: - * `scene.requestRenderMode`, `scene.maximumRenderTimeChange` and - * `scene.requestRender()`. It needs no frame events (`engine.frames`). - * - * Any other globe engine (Navara) is the governor's backend instead: install - * it on the engine (installEngineRenderGovernor) and the owner-counted holds - * drive `engine.render.setContinuous`, requests go to `engine.render.request`. - */ +import { sceneServicesFor } from './data/sceneServices.js'; -let _viewer = null; -/** The non-Cesium engine the governor drives, or null (Cesium: `_viewer`). */ -let _engine = null; -let _installed = false; -const _holds = new Set(); - -/** Debug trail of the most recent one-shot render requests (idle mode only). */ -const _recentRequests = []; -const RECENT_REQUEST_CAP = 16; - -function applyMode() { - if (!_installed) return; - const continuous = _holds.size > 0; - if (_engine) { - if (_engine.render.isContinuous() === continuous) return; - _engine.render.setContinuous(continuous); - if (!continuous) _engine.render.request('governor-idle'); - return; +/** Owner-keyed render demand for one scene attachment, on any globe engine. */ +export function createRenderGovernor(engine) { + const holds = new Set(); + const recentRequests = []; + let installed = false; + let destroyed = false; + function applyMode() { + if (!installed || destroyed) return; + const continuous = holds.size > 0; + if (engine.render.isContinuous() === continuous) return; + engine.render.setContinuous(continuous); + if (!continuous) engine.render.request('governor-idle'); } - if (!_viewer?.scene) return; - 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 a Cesium viewer, as the Cesium engine's render - * backend. 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 - * @returns {void} - */ -export function installRenderGovernor(viewer) { - if (!viewer?.scene) - throw new TypeError('installRenderGovernor requires a Cesium viewer'); - _engine = null; - _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(); -} - -/** - * Install the governor on a globe engine that renders by itself (Navara): - * the holds drive `engine.render.setContinuous`, requests go to - * `engine.render.request`. Idempotent, like installRenderGovernor. - * @param {import('./globe/contract.js').GlobeEngine} engine - * @returns {void} - */ -export function installEngineRenderGovernor(engine) { - if (typeof engine?.render?.setContinuous !== 'function') - throw new TypeError('installEngineRenderGovernor requires a globe engine'); - _engine = engine; - _viewer = engine; - _installed = true; - applyMode(); -} - -/** - * Register a continuous-render hold. Idempotent per owner. - * Call where the owner's per-frame work BEGINS (scene listener installed, - * animation starts, tracking begins). - * @param {string} ownerId Short stable id, e.g. 'flights', 'traffic'. - * @returns {void} - */ -export function holdContinuousRender(ownerId) { - if (!ownerId) return; - _holds.add(ownerId); - applyMode(); -} - -/** - * Release a hold. Safe when never held. - * Call where the owner's per-frame work ENDS (listener removed, animation - * settled, tracking stopped, layer disabled). - * @param {string} ownerId - * @returns {void} - */ -export function releaseContinuousRender(ownerId) { - if (!ownerId) return; - _holds.delete(ownerId); - applyMode(); -} - -/** - * 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. - * @param {string} [reason] For diagnostics only. - * @returns {void} - */ -export function governorRequestRender(reason = 'unspecified') { - if (!_installed || (!_engine && !_viewer?.scene)) return; - if (_holds.size === 0) { - _recentRequests.push({ reason, at: Date.now() }); - if (_recentRequests.length > RECENT_REQUEST_CAP) _recentRequests.shift(); - } - if (_engine) _engine.render.request(reason); - else _viewer.scene.requestRender?.(); -} - -/** - * @returns {{installed: boolean, mode: 'continuous'|'idle', holds: string[], - * recentRequests: Array<{reason: string, at: number}>}} - */ -export function getRenderGovernorDiagnostics() { return { - installed: _installed, - mode: _holds.size > 0 ? 'continuous' : 'idle', - holds: [..._holds].sort(), - recentRequests: [..._recentRequests], + install() { + if (destroyed) return; + installed = true; + applyMode(); + }, + holdContinuousRender(ownerId) { + if (destroyed || !ownerId) return; + holds.add(ownerId); + applyMode(); + }, + releaseContinuousRender(ownerId) { + if (destroyed || !ownerId) return; + holds.delete(ownerId); + applyMode(); + }, + governorRequestRender(reason = 'unspecified') { + if (!installed || destroyed) return; + if (holds.size === 0) { + recentRequests.push({ reason, at: Date.now() }); + if (recentRequests.length > 16) recentRequests.shift(); + } + engine.render.request(reason); + }, + getRenderGovernorDiagnostics() { + return { + installed, + mode: holds.size > 0 ? 'continuous' : 'idle', + holds: [...holds].sort(), + recentRequests: [...recentRequests], + }; + }, + destroy() { + if (destroyed) return; + holds.clear(); + applyMode(); + installed = false; + destroyed = true; + recentRequests.length = 0; + }, }; } -/** - * Whether the governor is installed on this viewer (its render requests then - * go through `governorRequestRender`). - * @param {object} viewer - * @returns {boolean} - */ -export function isRenderGovernorInstalled(viewer) { - return _installed && viewer != null && _viewer === viewer; +/** Portable callers resolve the same governor exposed as scene.services.render. */ +export function renderGovernorFor(engine) { + return sceneServicesFor(engine)?.render ?? null; } -/** Release the installed viewer after its animation owners have stopped. */ -export function uninstallRenderGovernor(viewer) { - if (_viewer !== viewer) return; - _viewer = null; - _engine = null; - _installed = false; - _holds.clear(); - _recentRequests.length = 0; +export function holdContinuousRender(ownerId, engine) { + renderGovernorFor(engine)?.holdContinuousRender(ownerId); } -/** Test seam: reset module state between unit tests. */ -export function _resetRenderGovernorForTest() { - _viewer = null; - _engine = null; - _installed = false; - _holds.clear(); - _recentRequests.length = 0; +export function releaseContinuousRender(ownerId, engine) { + renderGovernorFor(engine)?.releaseContinuousRender(ownerId); +} + +export function governorRequestRender(reason, engine) { + renderGovernorFor(engine)?.governorRequestRender(reason); +} + +export function getRenderGovernorDiagnostics(engine) { + return ( + renderGovernorFor(engine)?.getRenderGovernorDiagnostics() ?? { + installed: false, + mode: 'idle', + holds: [], + recentRequests: [], + } + ); } diff --git a/src/renderGovernor.test.mjs b/src/renderGovernor.test.mjs index 369642ae0..89dab79bf 100644 --- a/src/renderGovernor.test.mjs +++ b/src/renderGovernor.test.mjs @@ -1,156 +1,108 @@ -import { test, beforeEach } from 'node:test'; +import test from 'node:test'; import assert from 'node:assert/strict'; -import { - installRenderGovernor, - installEngineRenderGovernor, - holdContinuousRender, - releaseContinuousRender, - governorRequestRender, - getRenderGovernorDiagnostics, - _resetRenderGovernorForTest, - isRenderGovernorInstalled, - uninstallRenderGovernor, -} from './renderGovernor.js'; +import { OrbitController } from './orbit.js'; +import { GROUND_HARNESSES } from './testSupport/groundDisplayEngines.mjs'; +import { createSceneServices, sceneServicesFor } from './data/sceneServices.js'; +import { renderGovernorFor, governorRequestRender } from './renderGovernor.js'; -function makeViewer() { - const calls = { requestRender: 0 }; - const scene = { - requestRenderMode: false, - maximumRenderTimeChange: 0, - requestRender() { calls.requestRender += 1; }, - }; - return { viewer: { scene }, scene, calls }; +for (const makeHarness of GROUND_HARNESSES) { + test(`${makeHarness.name}: owner holds, installation and idle requests`, (t) => { + const harness = makeHarness(); + const { engine } = harness; + const services = createSceneServices(engine); + const governor = services.render; + t.after(() => { services.destroy(); harness.destroy(); }); + const requests = []; + const request = engine.render.request; + engine.render.request = (reason) => { requests.push(reason); request(reason); }; + assert.equal(renderGovernorFor(engine), governor); + governor.holdContinuousRender('flights'); + governor.governorRequestRender('before-install'); + assert.equal(requests.length, 0); + governor.install(); + assert.equal(engine.render.isContinuous(), true); + governor.holdContinuousRender('flights'); + governor.holdContinuousRender('satellites'); + governor.governorRequestRender('while-held'); + assert.equal(requests.at(-1), 'while-held', 'requests forward even while held'); + assert.deepEqual(governor.getRenderGovernorDiagnostics().recentRequests, []); + governor.releaseContinuousRender('flights'); + assert.equal(engine.render.isContinuous(), true); + assert.deepEqual(governor.getRenderGovernorDiagnostics().holds, ['satellites']); + governor.releaseContinuousRender('satellites'); + assert.equal(engine.render.isContinuous(), false); + assert.equal(requests.at(-1), 'governor-idle', 'last release paints a settling frame'); + const settled = requests.length; + governor.releaseContinuousRender('satellites'); + governor.releaseContinuousRender('absent'); + assert.equal(requests.length, settled, 'absent releases do not wake an idle engine'); + for (let i = 0; i < 20; i++) governorRequestRender(`tick-${i}`, engine); + const diagnostics = governor.getRenderGovernorDiagnostics(); + assert.equal(diagnostics.recentRequests.length, 16); + assert.equal(diagnostics.recentRequests[0].reason, 'tick-4'); + diagnostics.holds.push('external'); + assert.deepEqual(governor.getRenderGovernorDiagnostics().holds, []); + if (harness.viewer) assert.equal(harness.viewer.scene.maximumRenderTimeChange, Infinity); + }); + + test(`${makeHarness.name}: identical owners and teardown stay in their scene`, (t) => { + const first = makeHarness(); + const second = makeHarness(); + const a = createSceneServices(first.engine); + const b = createSceneServices(second.engine); + t.after(() => { sceneServicesFor(first.engine).destroy(); b.destroy(); first.destroy(); second.destroy(); }); + a.render.install(); + b.render.install(); + a.render.holdContinuousRender('flights'); + b.render.holdContinuousRender('flights'); + a.render.releaseContinuousRender('flights'); + assert.equal(first.engine.render.isContinuous(), false); + assert.equal(second.engine.render.isContinuous(), true); + a.destroy(); + const replacement = createSceneServices(first.engine); + replacement.render.install(); + replacement.render.holdContinuousRender('flights'); + const oldRelease = a.render.releaseContinuousRender; + oldRelease('flights'); + a.render.install(); + a.render.holdContinuousRender('late'); + a.render.governorRequestRender('late'); + a.destroy(); + assert.deepEqual(a.render.getRenderGovernorDiagnostics(), { + installed: false, mode: 'idle', holds: [], recentRequests: [], + }); + assert.equal(first.engine.render.isContinuous(), true); + assert.equal(second.engine.render.isContinuous(), true); + b.destroy(); + assert.equal(second.engine.render.isContinuous(), false); + assert.equal(first.engine.render.isContinuous(), true); + replacement.destroy(); + assert.equal(first.engine.render.isContinuous(), false); + assert.equal(sceneServicesFor(first.engine), replacement, 'late lookups cannot revive a detached scene'); + }); } -beforeEach(() => _resetRenderGovernorForTest()); - -test('install with zero holds enters idle mode and pins maximumRenderTimeChange', () => { - const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); - assert.equal(scene.requestRenderMode, true); - assert.equal(scene.maximumRenderTimeChange, Infinity); - assert.equal(getRenderGovernorDiagnostics().mode, 'idle'); -}); - -test('a hold flips to continuous; releasing the last hold returns to idle with a settling frame', () => { - const { viewer, scene, calls } = makeViewer(); - installRenderGovernor(viewer); - const settleBaseline = calls.requestRender; - holdContinuousRender('flights'); - assert.equal(scene.requestRenderMode, false); - assert.equal(getRenderGovernorDiagnostics().mode, 'continuous'); - releaseContinuousRender('flights'); - assert.equal(scene.requestRenderMode, true); - // Entering idle renders one settling frame. - assert.equal(calls.requestRender, settleBaseline + 1); -}); - -test('holds are identity-keyed: double-hold cannot leak, double-release cannot corrupt', () => { - const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); - holdContinuousRender('traffic'); - holdContinuousRender('traffic'); - releaseContinuousRender('traffic'); - assert.equal(scene.requestRenderMode, true, 'single release clears an idempotent double-hold'); - releaseContinuousRender('traffic'); - releaseContinuousRender('never-held'); - assert.equal(scene.requestRenderMode, true); -}); - -test('mode stays continuous until the LAST holder releases', () => { - const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); - holdContinuousRender('flights'); - holdContinuousRender('satellites'); - releaseContinuousRender('flights'); - assert.equal(scene.requestRenderMode, false); - assert.deepEqual(getRenderGovernorDiagnostics().holds, ['satellites']); - releaseContinuousRender('satellites'); - assert.equal(scene.requestRenderMode, true); -}); - -test('governorRequestRender forwards to the scene and records reasons only in idle mode', () => { - const { viewer, calls } = makeViewer(); - installRenderGovernor(viewer); - const baseline = calls.requestRender; - governorRequestRender('layer-tick:earthquakes'); - assert.equal(calls.requestRender, baseline + 1); - assert.equal(getRenderGovernorDiagnostics().recentRequests.at(-1).reason, 'layer-tick:earthquakes'); - holdContinuousRender('flights'); - const idleRequests = getRenderGovernorDiagnostics().recentRequests.length; - governorRequestRender('slider'); - assert.equal( - getRenderGovernorDiagnostics().recentRequests.length, - idleRequests, - 'continuous-mode requests are not recorded as idle diagnostics', - ); -}); - -test('hold/release/request are safe no-ops before install (test environments without a viewer)', () => { - holdContinuousRender('flights'); - releaseContinuousRender('flights'); - governorRequestRender('noop'); - assert.equal(getRenderGovernorDiagnostics().installed, false); -}); - -test('holds registered before install apply at install time', () => { - holdContinuousRender('flights'); - const { viewer, scene } = makeViewer(); - installRenderGovernor(viewer); - assert.equal(scene.requestRenderMode, false, 'pre-install hold keeps continuous mode'); - releaseContinuousRender('flights'); - assert.equal(scene.requestRenderMode, true); -}); - -test('isRenderGovernorInstalled names the one governed viewer', async () => { - const { isRenderGovernorInstalled, uninstallRenderGovernor } = await import('./renderGovernor.js'); - const { viewer } = makeViewer(); - const other = makeViewer().viewer; - assert.equal(isRenderGovernorInstalled(viewer), false); - installRenderGovernor(viewer); - assert.equal(isRenderGovernorInstalled(viewer), true); - assert.equal(isRenderGovernorInstalled(other), false); - uninstallRenderGovernor(viewer); - assert.equal(isRenderGovernorInstalled(viewer), false); -}); - -test('on a non-Cesium globe engine the governor drives engine.render with owner-counted holds', async () => { - const { createFakeEngine } = await import('./globe/testing/fakeEngine.js'); - _resetRenderGovernorForTest(); - const engine = createFakeEngine({ kind: 'navara' }); - holdContinuousRender('before-install'); - installEngineRenderGovernor(engine); - assert.equal(engine.render.isContinuous(), true, 'a hold taken before install applies'); - releaseContinuousRender('before-install'); - assert.equal(engine.render.isContinuous(), false); - holdContinuousRender('a'); - holdContinuousRender('b'); - assert.equal(engine.render.isContinuous(), true); - releaseContinuousRender('a'); - assert.equal(engine.render.isContinuous(), true, 'b still holds'); - releaseContinuousRender('b'); - assert.equal(engine.render.isContinuous(), false); - engine.fake.resetCalls(); - governorRequestRender('slider'); - assert.deepEqual(engine.fake.callsOf('render.request').map((c) => c.args), [['slider']]); - assert.equal(isRenderGovernorInstalled(engine), true); - uninstallRenderGovernor(engine); - _resetRenderGovernorForTest(); -}); - -test('releasing an absent Navara hold does not wake an already idle engine', async () => { - const { createFakeEngine } = await import('./globe/testing/fakeEngine.js'); - _resetRenderGovernorForTest(); - const engine = createFakeEngine({ kind: 'navara' }); - installEngineRenderGovernor(engine); - engine.fake.resetCalls(); - for (let i = 0; i < 22; i++) releaseContinuousRender('cctv-projection'); - assert.equal(engine.fake.callsOf('render.request').length, 0); - holdContinuousRender('video'); - releaseContinuousRender('video'); - assert.equal(engine.fake.callsOf('render.request').length, 1); - releaseContinuousRender('video'); - assert.equal(engine.fake.callsOf('render.request').length, 1); - uninstallRenderGovernor(engine); - _resetRenderGovernorForTest(); -}); +for (const makeHarness of GROUND_HARNESSES) { + test(`${makeHarness.name}: an old orbit release cannot idle a replacement attachment`, (t) => { + const harness = makeHarness(); + const { engine } = harness; + const first = createSceneServices(engine); + first.render.install(); + const oldOrbit = new OrbitController(engine); + const newOrbit = new OrbitController(engine); + t.after(() => { + oldOrbit.stop(); newOrbit.stop(); sceneServicesFor(engine).destroy(); harness.destroy(); + }); + oldOrbit.start({ x: 6378137, y: 0, z: 0 }); + assert.equal(engine.render.isContinuous(), true); + first.destroy(); + const next = createSceneServices(engine); + next.render.install(); + newOrbit.start({ x: 6378137, y: 0, z: 0 }); + oldOrbit.stop(); + assert.deepEqual(next.render.getRenderGovernorDiagnostics().holds, ['camera-orbit']); + assert.equal(engine.render.isContinuous(), true); + newOrbit.stop(); + assert.equal(engine.render.isContinuous(), false); + }); +} diff --git a/src/ui/composition.js b/src/ui/composition.js index 3b95c1a8c..5fe4593b0 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -46,11 +46,6 @@ import { setDetectionTuning, } from '../data/detection.js'; import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js'; -import { - holdContinuousRender, - releaseContinuousRender, - governorRequestRender, -} from '../renderGovernor.js'; import { setScopeMaskEnabled, isScopeMaskEnabled, @@ -105,9 +100,6 @@ export class StyleManager extends ApplicationShell { setDetectionTuning, isTr3b, toggleTr3b, - holdContinuousRender, - releaseContinuousRender, - governorRequestRender, setScopeMaskEnabled, isScopeMaskEnabled, setScopeMaskFeather, diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 75746b6e3..6f31afcfd 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -694,7 +694,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () assert.match(detection, /sonarSampler\.label\(sonarFactor\)/); assert.match(detection, /createCyberSonarSampler\(width, height, now\)/); assert.match(cyberStyles, /data-cyber-sonar='off'/); - assert.match(detection, /governorRequestRender\('cyber-sonar-cadence'\)/); + assert.match(detection, /governorRequestRender\('cyber-sonar-cadence', _engine\)/); assert.match( detection, /const animatingCount = countAnimatingRenderEntries\(renderEntries\);/, diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index 96e4f6ab3..684e93097 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -1,3 +1,4 @@ +import { renderGovernorFor } from '../renderGovernor.js'; import { retainsLayerSession } from '../app/layerSession.js'; import { resolveImageryHost } from '../layers/weather/imageryHost.js'; import { LayerBindings } from './layerBindings.js'; @@ -28,7 +29,10 @@ export class ShellScene extends ShellFacade { /** Share restoration, visual settings and layer bindings of this globe. */ _buildSceneControls() { const { viewer, engine, mapStackController } = this._scene; - const services = this.services; + const services = { + ...this.services, + ...renderGovernorFor(this._scene.engine), + }; const layerSession = this._layerSession; this._shareRestoration = new ShareRestoration({ engine, @@ -141,7 +145,10 @@ export class ShellScene extends ShellFacade { /** Cockpit and the celestial ring of this globe. */ _buildSceneCockpit() { const { viewer, engine } = this._scene; - const services = this.services; + const services = { + ...this.services, + ...renderGovernorFor(this._scene.engine), + }; const { CelestialRing } = services; this._cockpitCoordinator = new CockpitCoordinator({ viewer,