From 367827e7423a3614ec0d2849616dbed6b7b7afcc Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 13:01:45 -0500 Subject: [PATCH] perf(vessels): let the scene idle with AIS vessels enabled Vessels move only when a poll lands, yet enabling the layer held the continuous render loop for its lifetime, so a parked vessels-only scene rendered every frame. The layer now: - requests a frame for every forced visibility pass (new data, selection); - leaves one trailing frame when the camera moved inside the 800 ms visibility cadence, so the view the camera stopped on is evaluated. It is keyed to camera motion alone: the pass's own label publication requests a frame, and scheduling on that looped at ~1.2 Hz; - holds 'ais-vessels' only while focus emphasis is animating. The 80 ms focus pass, which runs while anything is tracked, also skips hidden sprites whose focus state is at rest (new spriteFocusAtRest) and whose alpha is already 1: advancing them changes nothing, and with a 12k worldwide feed they are most of the fleet. Measured: a parked vessels-only scene (headless SwiftShader) went from continuous mode and 28 frames in 5 s to idle and 0; horizon visibility had no mismatches after the camera moved and settled. The focus pass over 12,000 vessels (11,353 hidden) went from ~4.0 ms to ~1.2 ms. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ docs/CURRENT-STATE.md | 7 ++++ src/data/aisLiveVessels.test.mjs | 47 ++++++++++++++++++++++ src/data/focusDeemphasis.js | 15 +++++++ src/layers/vessels/lifecycle.js | 6 ++- src/layers/vessels/ownership.test.mjs | 6 ++- src/layers/vessels/rendering.js | 58 ++++++++++++++++++++++++++- src/layers/vessels/state.js | 2 + 8 files changed, 145 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..d9232fe14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- 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. 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 (11,353 hidden). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..95035a908 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1357,6 +1357,13 @@ 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 is animating. 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 7beb2508b..b507ca2e3 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1691,3 +1691,50 @@ test('vessel selection passes the opaque source reference to optional history', aisLiveVesselsLayer.setSource(createAisStreamSource()); } }); + +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 3c8c8e245..55d0e9243 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -14,7 +14,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; @@ -128,6 +128,7 @@ export function createLifecycle({ state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; + state.trailingVisibilityTimer = null; state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; @@ -167,7 +168,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); @@ -203,6 +203,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); @@ -221,6 +222,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 ce55aaf0f..6b92d1601 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/rendering.js b/src/layers/vessels/rendering.js index 2d3c7d276..7eab3d681 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -74,7 +74,13 @@ export function createRendering({ focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite, + spriteFocusAtRest, } = services.focus; + const { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + } = services.render; function renderRowLimit() { const configured = Number(options.maxRows); @@ -195,6 +201,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(() => @@ -202,8 +216,27 @@ export function createRendering({ ); } + function scheduleTrailingVisibilityPass(now) { + if (state.trailingVisibilityTimer) return; + const waitMs = Math.max( + 0, + VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate), + ); + state.trailingVisibilityTimer = setTimeout(() => { + state.trailingVisibilityTimer = null; + if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); + }, waitMs); + } + + function cancelTrailingVisibilityPass() { + if (!state.trailingVisibilityTimer) return; + 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 = @@ -211,13 +244,22 @@ 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; + if (focusPass) { + state.activeFocusCount = 0; + releaseContinuousRender('ais-vessels'); + } return; } @@ -271,6 +313,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'); } } @@ -301,6 +346,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. @@ -506,6 +561,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. */