diff --git a/changelog.d/871-vessels-idle-render.md b/changelog.d/871-vessels-idle-render.md new file mode 100644 index 000000000..928a73591 --- /dev/null +++ b/changelog.d/871-vessels-idle-render.md @@ -0,0 +1,7 @@ +- Let the scene idle with AIS vessels enabled. Vessels no longer hold the + continuous render loop; they request frames for new data and selection, one + trailing frame after the camera stops, and hold the loop only while focus + emphasis animates or a vessel stays dimmed under a tracked target. A parked + vessels-only scene went from continuous rendering to 0 frames in 5 s. While + anything is tracked, the vessel focus pass skips hidden sprites at rest: + about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 09ae87f1a..146e157d8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2286,6 +2286,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the > six zero-intensity style stages anymore (those are disabled; see the > history note in `_initStages`). Hidden tabs stop the render loop. +> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move +> only when a poll lands, so the layer requests a frame for every forced pass +> (new data, selection), leaves one trailing frame when the camera moved inside +> the 800 ms visibility cadence, and holds `ais-vessels` only while focus +> emphasis animates or any vessel stays dimmed under a live focus target. The +> trailing frame goes through the layer's injectable runtime clock and is +> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the +> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose +> focus state is at rest (`spriteFocusAtRest`), since advancing them changes +> nothing; with a 12k worldwide feed that is most of the fleet. > The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 > at/above **10 Mm**, so > faint stars survive in the corners of a TRUE full-globe view, fading quickly diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 96a5cd105..285ffb08e 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history', aisLiveVesselsLayer.setSource(createVesselSource()); } }); + +test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => { + const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); + const sprite = (show) => ({ + position: { x: 1, y: 2, z: 3 }, + show, + color: makeColor(1), + }); + const visible = sprite(true); + const hidden = Array.from({ length: 50 }, () => sprite(false)); + const records = [visible, ...hidden].map((billboard) => ({ + position: billboard.position, + billboard, + })); + const target = { + screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, + paddingPx: 0, + cameraDistance: 1000, + }; + let projected = 0; + let measured = 0; + const apply = (nowMs) => + applyVesselFocusDeemphasis({ + records, + target, + nowMs, + screenPositionFor: () => { + projected += 1; + return { x: 50, y: 50 }; + }, + cameraDistanceFor: () => { + measured += 1; + return 1200; + }, + params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 }, + }); + + apply(0); + const result = apply(300); + + // Only the on-screen sprite pays for projection and distance. + assert.equal(projected, 2); + assert.equal(measured, 2); + assert.equal(visible.color.alpha, 0.25); + assert.equal(result.activeCount, 1); + for (const billboard of hidden) assert.equal(billboard.color.alpha, 1); +}); diff --git a/src/data/focusDeemphasis.js b/src/data/focusDeemphasis.js index 396bbc687..f3e8792a1 100644 --- a/src/data/focusDeemphasis.js +++ b/src/data/focusDeemphasis.js @@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) { return _focusEvidenceNowMs; } +/** + * True when a sprite holds no dimming state, or has settled at full emphasis + * with nothing pending. Advancing such a sprite without a screen position (a + * hidden sprite) changes neither its state nor its alpha, so a layer pass may + * skip it. + * @param {object} sprite + * @returns {boolean} + */ +export function spriteFocusAtRest(sprite) { + const state = _spriteStates.get(sprite); + return ( + !state || (state.factor === 1 && state.desired === 1 && !state.wasFarther) + ); +} + /** Forget state when a primitive is removed from its owning collection. */ export function forgetSpriteFocus(sprite) { if (sprite) _spriteStates.delete(sprite); diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index bb566320b..1c1f611cc 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -11,7 +11,7 @@ export function createLifecycle({ }) { const { state } = vesselState; const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; - const { holdContinuousRender, releaseContinuousRender } = services.render; + const { releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -125,6 +125,7 @@ export function createLifecycle({ state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; + components.rendering.cancelTrailingVisibilityPass(); state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; @@ -164,7 +165,6 @@ export function createLifecycle({ const wasEnabled = state.feed.enabled; state.feed.enabled = true; if (!wasEnabled) beginAisSession(); - holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2) const activeViewer = viewer || state.viewer; components.rendering.ensureCollections(activeViewer); components.selection.installInteraction(activeViewer); @@ -208,6 +208,7 @@ export function createLifecycle({ state.feed.enabled = false; invalidateAisSession(); releaseContinuousRender('ais-vessels'); + components.rendering.cancelTrailingVisibilityPass(); unregisterPickOwner('ais-live-vessels'); components.rendering.setVisible(false); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); @@ -227,6 +228,7 @@ export function createLifecycle({ const activeViewer = viewer || state.viewer; invalidateAisSession(); releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + components.rendering.cancelTrailingVisibilityPass(); if (state.feed.abort) state.feed.abort.abort(); unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 28455a293..4ef9b7525 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -29,7 +29,11 @@ function services() { forgetSpriteFocus: noop, }, worldFocus: {}, - render: { releaseContinuousRender: noop }, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, }; } function setup(source, options = {}) { diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs new file mode 100644 index 000000000..2fec27871 --- /dev/null +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { VISIBILITY_UPDATE_MS } from './policy.js'; + +// Vessels move only when a poll lands, so the layer must let the scene idle: +// it asks for frames for forced passes and one trailing frame after the camera +// stops, and never schedules a frame from a pass the camera did not cause. + +const noop = () => {}; + +function setup() { + let clock = 10_000; + const requests = []; + const holds = new Set(); + const timers = new Map(); + let nextTimer = 1; + const layer = createVesselLayer({ + source: { getSnapshot: async () => ({ records: [] }) }, + services: { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: () => clock, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { + holdContinuousRender: (id) => holds.add(id), + releaseContinuousRender: (id) => holds.delete(id), + governorRequestRender: (reason) => requests.push(reason), + }, + }, + }); + const camera = { + positionWC: { x: 0, y: 0, z: 7_000_000 }, + heading: 0, + pitch: -1.2, + roll: 0, + }; + layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } }); + layer.testing._setAisRuntimeForTest({ + now: () => clock, + setTimeout(fn, ms) { + const id = nextTimer++; + timers.set(id, { fn, at: clock + ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }); + const advance = (ms) => { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > clock) continue; + timers.delete(id); + timer.fn(); + } + }; + const frame = () => layer.testing._runVisibilityPassForTest(); + return { layer, camera, requests, holds, timers, advance, frame }; +} + +test('a parked camera schedules no frames after the cadence pass', () => { + const { requests, holds, timers, advance, frame } = setup(); + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, []); + assert.equal(timers.size, 0); + assert.equal(holds.has('ais-vessels'), false); +}); + +test('a camera that stops inside the cadence gets exactly one trailing frame', () => { + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + advance(100); + frame(); + assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass'); + assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true); + + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, ['ais-vessels-visibility']); + // The trailing frame runs the regular pass on the pose the camera stopped + // on. Its own frames must not schedule another one. + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, ['ais-vessels-visibility']); + assert.equal(timers.size, 0); +}); + +test('a forced pass requests its own frame', () => { + const { layer, requests } = setup(); + layer.testing._runVisibilityPassForTest(true); + assert.deepEqual(requests, ['ais-vessels-update']); +}); + +test('destroy cancels a pending trailing frame', (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + assert.equal(timers.size, 1); + layer.destroy(); + assert.equal(timers.size, 0); + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, []); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index f29ced5aa..328dd4aee 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -75,7 +75,13 @@ export function createRendering({ focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite, + spriteFocusAtRest, } = services.focus; + const { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + } = services.render; function renderRowLimit() { const configured = Number(options.maxRows); @@ -196,6 +202,14 @@ export function createRendering({ return icon; } + // Vessels move only when a poll lands, so the layer does not hold the + // continuous render loop. Its passes run in preRender, which idle mode fires + // only for rendered frames: a forced pass (new data, selection) requests its + // frame, a frame that moved the camera but fell inside the 800 ms cadence + // leaves a trailing frame so the view the camera stopped on is always + // evaluated, and the continuous hold is kept only while focus emphasis is + // animating. The trailing frame is keyed to camera motion alone: the pass's + // own label publication requests a frame, and scheduling on that would loop. function installRuntime(viewer) { if (state.preRenderRemover || !viewer) return; state.preRenderRemover = viewer.scene.preRender.addEventListener(() => @@ -203,8 +217,27 @@ export function createRendering({ ); } + function scheduleTrailingVisibilityPass(now) { + if (state.trailingVisibilityTimer) return; + const waitMs = Math.max( + 0, + VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate), + ); + state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => { + state.trailingVisibilityTimer = null; + if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); + }, waitMs); + } + + function cancelTrailingVisibilityPass() { + if (!state.trailingVisibilityTimer) return; + vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer); + state.trailingVisibilityTimer = null; + } + function updateVisibility(force = false) { if (!state.feed.enabled) return; + if (force) governorRequestRender('ais-vessels-update'); const now = focusNowMs(performance.now()); const focusTarget = getFocusTarget(); const regularPass = @@ -212,13 +245,30 @@ export function createRendering({ const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount) && (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS); + if ( + !regularPass && + state.viewer?.camera && + cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig + ) + scheduleTrailingVisibilityPass(now); if (!regularPass && !focusPass) return; if (regularPass) state.lastVisibilityUpdate = now; if (focusPass) state.lastFocusUpdate = now; if (!state.records.all.length) { // No records — flush any lingering card entries (vanished-feed case). - if (regularPass) updateClusteredLabels([]); - if (focusPass) state.activeFocusCount = 0; + // Still record the pose this pass evaluated, or every later frame that + // falls inside the cadence would schedule another trailing frame. + if (regularPass) { + if (state.viewer?.camera) + vesselState._lastCamPoseSig = cameraPoseSignature( + state.viewer.camera, + ); + updateClusteredLabels([]); + } + if (focusPass) { + state.activeFocusCount = 0; + releaseContinuousRender('ais-vessels'); + } return; } @@ -272,6 +322,9 @@ export function createRendering({ Cesium.Cartesian3.distance(camera.positionWC, position), }); state.activeFocusCount = result.activeCount; + if (result.activeCount > 0 || result.transitioning) + holdContinuousRender('ais-vessels'); + else releaseContinuousRender('ais-vessels'); } } @@ -302,6 +355,16 @@ export function createRendering({ const bb = visual?.billboard; const position = bb?.position || visual?.position; if (!bb || !position) continue; + // Most of a worldwide feed sits behind the horizon. A hidden sprite with + // no pending focus state and full alpha would advance to exactly what it + // already is, so skip it instead of paying its distance and state work + // every 80 ms while anything is tracked. + if ( + bb.show === false && + spriteFocusAtRest(bb) && + !focusAlphaNeedsWrite(bb.color?.alpha, 1, params) + ) + continue; const focus = advanceSpriteFocus(bb, { // Hidden/far-side sprites still finish any pending release so the active // count remains truthful and they cannot reappear with stale dim alpha. @@ -510,6 +573,7 @@ export function createRendering({ shipIcon, installRuntime, updateVisibility, + cancelTrailingVisibilityPass, applyVesselFocusDeemphasis, makeOccluder, isVisible, diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index d0b8afb18..3ccaf08d9 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -64,6 +64,8 @@ export function createVesselState({ source, services }) { /** Test-only key target paired with interactionHandlerFactory. */ interactionKeyTarget: null, preRenderRemover: null, + /** Pending timer that renders one frame after a cadence-skipped pass. */ + trailingVisibilityTimer: null, lastVisibilityUpdate: 0, lastFocusUpdate: 0, /** Sprites whose animated emphasis remains outside the 1.0 deadband. */ diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index dc11f3003..6cab17086 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -151,6 +151,18 @@ export function createTesting({ }; } + /** Run one preRender visibility/focus pass. Test-only seam. */ + + function _runVisibilityPassForTest(force = false) { + components.rendering.updateVisibility(force); + } + + /** True while a trailing visibility frame is scheduled. Test-only seam. */ + + function _hasTrailingVisibilityPassForTest() { + return state.trailingVisibilityTimer !== null; + } + /** * Read lifecycle ownership state without exposing the mutable state object. * Test-only seam. @@ -176,5 +188,7 @@ export function createTesting({ _setAisRuntimeForTest, _getVesselFeedStateForTest, _getVesselStateForTest, + _runVisibilityPassForTest, + _hasTrailingVisibilityPassForTest, }; }