From 57bacd98a803ca756b13af0c6aab729889746152 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 23:18:57 -0800 Subject: [PATCH 1/3] Move vessels every frame and ask for frames as they move Vessels between two fixes, and live vessels a host placement places, were redrawn at most every 250 ms, and a frame was asked for only once a second. With nothing else asking for frames the scene drew vessels at about 1 to 2 fps live, and none at all while the world clock played at 1x. They are now redrawn every frame the scene draws, moved in place without new allocations. After each pass the layer works out how far the fastest drawn vessel moved on screen and asks for the next frame for when it will have moved another half pixel, at least once a second while any drawn vessel moves. A vessel crossing the view close up therefore glides, and one far away lets the scene idle. A frame already asked for later is brought forward when a vessel speeds up. --- docs/CURRENT-STATE.md | 10 +- src/layers/vessels/renderGovernor.test.mjs | 59 ++++++++- src/layers/vessels/rendering.js | 146 +++++++++++++++------ 3 files changed, 169 insertions(+), 46 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2ba3165f0..5f4635cfb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -380,9 +380,10 @@ in view while their placed position changes; one it holds still lets the render loop idle and is asked again each second, moving on once its placement does. A placed transit vehicle is drawn at its placed point, and a placed train where it is placed rather than on its bound railway line. Live vessels -a provider places move on between refreshes: redrawn in place at most every -250 ms, with a frame asked for each second while the provider places any -drawn vessel, and none once the layer is off. +a provider places move on between refreshes: redrawn in place every frame, +with the next frame asked for as soon as the fastest drawn vessel will have +moved half a pixel on screen, at least once a second while the provider places +any drawn vessel, and none once the layer is off. Application catalog composition accepts an optional `streetLevelProviders` list. An explicit list replaces the default Mapillary provider; an empty list removes @@ -1999,7 +2000,8 @@ Playing, the layer stays a snapshot ahead of the time, and play does not pass a snapshot still loading for more than 8 seconds. Those snapshots apply as polls do, so contacts move on smoothly: aircraft are drawn at the clock's time rather than behind it, and vessels between two fixes move along the great -circle, never past the newer one. Records, absences and the Layers panel's age +circle, never past the newer one, redrawn every frame with frames asked for +as for placed live vessels. Records, absences and the Layers panel's age are judged at the clock's time. Outside the coverage the layer shows no contacts and loads nothing; while the coverage is unknown it loads nothing and clears nothing. A snapshot that fails clears the time and is asked again after diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs index a1f6ec93c..128e1ce16 100644 --- a/src/layers/vessels/renderGovernor.test.mjs +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createVesselLayer } from './index.js'; import { VISIBILITY_UPDATE_MS } from './policy.js'; import { layerPlacement } from '../../data/placementProvider.js'; +import * as Cesium from 'cesium'; // 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 @@ -75,7 +76,16 @@ function setup({ placement = null } = {}) { } }; const frame = () => layer.testing._runVisibilityPassForTest(); - return { layer, camera, requests, holds, timers, advance, frame }; + return { + layer, + camera, + requests, + holds, + timers, + advance, + frame, + now: () => clock, + }; } test('a parked camera schedules no frames after the cadence pass', () => { @@ -196,3 +206,50 @@ test('a placement that places nothing asks for no frames; one that places a draw advance(1_000); assert.deepEqual(requests, ['ais-vessels-placement']); }); + +test('a vessel moving visibly asks for its next frame as soon as it moves half a pixel; a slow one once a second', (t) => { + let mono = 0; + t.mock.method(performance, 'now', () => mono); + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + let spot = { lat: 51.93, lon: 4.05 }; + const { layer, camera, timers, advance, now } = setup({ + placement: { layer: () => ({ place: () => spot }) }, + }); + // 20 km above the vessel, in a 1,000 px tall view about 57° tall. + camera.positionWC = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 20_000); + camera.frustum = { fovy: 1 }; + const record = { + mmsi: '111', + lat: 51.93, + lon: 4.05, + billboard: { show: true, position: null }, + }; + layer.testing._setVesselStateForTest({ + viewer: { camera, scene: {}, canvas: { clientHeight: 1000 } }, + records: [record], + }); + layer.testing._prepareVesselVisualForTest(record); + // How long after a pass the next frame is asked for, once the vessel moved + // `dLat` in the 16 ms since the pass before. + const waitAfterStep = (dLat) => { + layer.testing._runPlacementPassForTest(); + advance(1_000); // whatever frame that asked for comes and goes + assert.equal(timers.size, 0); + mono += 16; + spot = { lat: spot.lat + dLat, lon: spot.lon }; + layer.testing._runPlacementPassForTest(); + assert.equal(timers.size, 1); + const [timer] = [...timers.values()]; + return timer.at - now(); + }; + // About 11 m in a frame, 20 km away: over half a pixel, so the next frame + // is asked for within the frame. + assert.ok(waitAfterStep(0.0001) <= 16); + // About 1 cm in a frame: half a pixel takes seconds, so once a second. + assert.equal(waitAfterStep(0.0000001), 1000); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index dcb85e82f..7dba03e11 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -31,14 +31,19 @@ import { import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; import { isAidToNavigation } from '../../data/vesselTypes.js'; -/** How often vessels move on as the world clock plays (wall ms). */ -const FOLLOW_POSITION_MS = 250; -/** A frame is asked for this often while a placement moves live vessels. */ +/** A moving vessel's next frame is asked for once it would move this far on + * screen (pixels), so it glides when it moves visibly and the scene idles + * when it does not. */ +const VESSEL_STEP_PX = 0.5; +/** A frame is asked for at least this often while any drawn vessel moves. */ const PLACEMENT_FRAME_MS = 1000; /** How often the vessels near enough for a model are gathered again (wall ms). */ const MODEL_CANDIDATES_MS = 500; +/** Scratch for where a moved vessel was drawn. */ +const _movedFrom = new Cesium.Cartesian3(); + export function createRendering({ vesselState, services, @@ -442,62 +447,121 @@ export function createRendering({ }); } - // As the world clock plays, vessels between two fixes move on along them. - let followedAtWallMs = -Infinity; + // As the world clock plays, vessels between two fixes move on along them, + // every frame. let followedAtMs = null; function followClockPositions() { - const wallMs = performance.now(); - if (wallMs - followedAtWallMs < FOLLOW_POSITION_MS) return; - followedAtWallMs = wallMs; const atMs = vesselState.worldNow(); if (atMs === followedAtMs) return; followedAtMs = atMs; + const motion = startMotionPass(); for (const record of state.records.all) { if (!record.motion) continue; - const visual = prepareRecordVisual(record); + let visual = visualRecords.get(record); + if (visual?.position) { + const { lat, lon } = vesselPositionAt(record, atMs); + moveDrawnVisual(visual, lat, lon, motion); + } else visual = prepareRecordVisual(record); if (visual.billboard) visual.billboard.position = visual.position; } + askMotionFrame(motion); } // Live with a host-supplied placement, vessels move on between refreshes: - // drawn where it places them at most every FOLLOW_POSITION_MS, with a frame - // asked for every PLACEMENT_FRAME_MS while it places any drawn vessel, so - // they move while the scene idles. - let placedAtWallMs = -Infinity; - let placing = false; - let placementFrameTimer = null; + // drawn where it places them every frame, with the next frame asked for + // as soon as a drawn vessel would move visibly, so they move while the + // scene idles. function followPlacements() { - const wallMs = performance.now(); - if (wallMs - placedAtWallMs >= FOLLOW_POSITION_MS) { - placedAtWallMs = wallMs; - placing = false; - const nowMs = vesselState._aisRuntime.now(); - // Drawn vessels only, as a sprite or a model: a hidden one is placed - // again when it is drawn. - for (const record of state.records.all) { - const visual = visualRecords.get(record); - if (!visual?.position) continue; - if (!visual.billboard?.show && !visual.modelOwnsVisual) continue; - const placed = services.placement.place(record.id, nowMs); - if (placed) placing = true; - const { lat, lon } = placed ?? record; - if (lat === visual.drawnLat && lon === visual.drawnLon) continue; - moveRecordVisual(visual, lat, lon); - if (visual.billboard) visual.billboard.position = visual.position; - } + const nowMs = vesselState._aisRuntime.now(); + const motion = startMotionPass(); + // Drawn vessels only, as a sprite or a model: a hidden one is placed + // again when it is drawn. + for (const record of state.records.all) { + const visual = visualRecords.get(record); + if (!visual?.position) continue; + if (!visual.billboard?.show && !visual.modelOwnsVisual) continue; + const placed = services.placement.place(record.id, nowMs); + if (placed) motion.moving = true; + const { lat, lon } = placed ?? record; + if (lat === visual.drawnLat && lon === visual.drawnLon) continue; + moveDrawnVisual(visual, lat, lon, motion); + if (visual.billboard) visual.billboard.position = visual.position; } - if (placing && placementFrameTimer === null) - placementFrameTimer = vesselState._aisRuntime.setTimeout(() => { - placementFrameTimer = null; - if (state.feed.enabled && !followsClock(vesselState)) - governorRequestRender('ais-vessels-placement'); - }, PLACEMENT_FRAME_MS); + askMotionFrame(motion); + } + + // One pass of moving vessels: whether any drawn vessel is moving, the + // largest angle one moved through as seen from the camera (radians), and + // the wall time since the pass before. + let motionPassWallMs = null; + const motionPass = { camera: null, sinceMs: 0, moving: false, angle: 0 }; + function startMotionPass() { + const wallMs = performance.now(); + motionPass.camera = state.viewer?.camera ?? null; + motionPass.sinceMs = + motionPassWallMs === null ? 0 : wallMs - motionPassWallMs; + motionPass.moving = false; + motionPass.angle = 0; + motionPassWallMs = wallMs; + return motionPass; + } + + /** Move a drawn visual, noting how far it moved as seen from the camera. */ + function moveDrawnVisual(visual, lat, lon, motion) { + const from = Cesium.Cartesian3.clone(visual.position, _movedFrom); + moveRecordVisual(visual, lat, lon); + if (!visual.billboard?.show && !visual.modelOwnsVisual) return; + motion.moving = true; + const eye = motion.camera?.positionWC; + if (!eye) return; + const distance = Cesium.Cartesian3.distance(eye, from); + if (distance > 0) + motion.angle = Math.max( + motion.angle, + Cesium.Cartesian3.distance(from, visual.position) / distance, + ); + } + + // The timer for the next vessel frame while one is asked for, and when it + // is due (the AIS runtime's clock). + let placementFrameTimer = null; + let placementFrameDueMs = 0; + /** + * While a drawn vessel moves, ask for the next frame when the fastest one + * will have moved VESSEL_STEP_PX on screen at the speed this pass saw, and + * at most PLACEMENT_FRAME_MS away. + */ + function askMotionFrame(motion) { + if (!motion.moving) return; + let waitMs = PLACEMENT_FRAME_MS; + const canvasHeight = state.viewer?.canvas?.clientHeight; + const fovy = motion.camera?.frustum?.fovy; + if ( + canvasHeight > 0 && + fovy > 0 && + motion.angle > 0 && + motion.sinceMs > 0 + ) { + const movedPx = (motion.angle * canvasHeight) / (2 * Math.tan(fovy / 2)); + waitMs = Math.min(waitMs, (VESSEL_STEP_PX * motion.sinceMs) / movedPx); + } + // A frame already asked for no later than this one stands. + const runtime = vesselState._aisRuntime; + const dueMs = runtime.now() + waitMs; + if (placementFrameTimer !== null) { + if (placementFrameDueMs <= dueMs) return; + runtime.clearTimeout(placementFrameTimer); + } + placementFrameDueMs = dueMs; + placementFrameTimer = runtime.setTimeout(() => { + placementFrameTimer = null; + if (state.feed.enabled) governorRequestRender('ais-vessels-placement'); + }, waitMs); } /** Stop asking for placement frames (the layer turned off or went away). */ function cancelPlacementFrame() { - placedAtWallMs = -Infinity; - placing = false; + motionPassWallMs = null; if (placementFrameTimer === null) return; vesselState._aisRuntime.clearTimeout(placementFrameTimer); placementFrameTimer = null; From 7bea88b395b1a606c761b25bb0c9586cf748cdbc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 23:19:27 -0800 Subject: [PATCH 2/3] Add the changelog fragment for #1747 --- changelog.d/1747-vessels-every-frame.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1747-vessels-every-frame.md diff --git a/changelog.d/1747-vessels-every-frame.md b/changelog.d/1747-vessels-every-frame.md new file mode 100644 index 000000000..d0b452c47 --- /dev/null +++ b/changelog.d/1747-vessels-every-frame.md @@ -0,0 +1,5 @@ +- Move vessels every frame. Vessels between fixes, and live vessels a host + placement places, were redrawn at most every 250 ms with a frame asked for + once a second; they are now redrawn every frame, and the next frame is asked + for once the fastest drawn vessel will have moved half a pixel on screen + (Sameh Khamis, #1747). From 52695daf5c8dd97162eb58c0ddc0d2bfecc23a68 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 23:30:38 -0800 Subject: [PATCH 3/3] fix(vessels): world-clock motion moves only drawn vessels, once per world time Vessels out of view move on every 250 ms so one coming into view is current; vessels at the end of their fixes ask for no frames; nothing moves while the layer is off; 2D views judge the half-pixel step across the view's width; a re-floor redraws vessels where they are on the next pass. --- docs/CURRENT-STATE.md | 2 +- src/layers/vessels/renderGovernor.test.mjs | 108 ++++++++++++++++++++- src/layers/vessels/rendering.js | 101 ++++++++++++------- src/layers/vessels/testing.js | 8 ++ src/layers/vessels/tracking.js | 9 +- 5 files changed, 183 insertions(+), 45 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5f4635cfb..ed33813aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2001,7 +2001,7 @@ snapshot still loading for more than 8 seconds. Those snapshots apply as polls do, so contacts move on smoothly: aircraft are drawn at the clock's time rather than behind it, and vessels between two fixes move along the great circle, never past the newer one, redrawn every frame with frames asked for -as for placed live vessels. Records, absences and the Layers panel's age +as for placed live vessels (vessels out of view move on every 250 ms). Records, absences and the Layers panel's age are judged at the clock's time. Outside the coverage the layer shows no contacts and loads nothing; while the coverage is unknown it loads nothing and clears nothing. A snapshot that fails clears the time and is asked again after diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs index 128e1ce16..c5ce2e855 100644 --- a/src/layers/vessels/renderGovernor.test.mjs +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -5,9 +5,9 @@ import { VISIBILITY_UPDATE_MS } from './policy.js'; import { layerPlacement } from '../../data/placementProvider.js'; import * as Cesium from 'cesium'; -// 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. +// The layer must let the scene idle: it asks for frames for forced passes, +// for vessels that move, and one trailing frame after the camera stops, and +// never schedules a frame from a pass nothing caused. const noop = () => {}; @@ -252,4 +252,106 @@ test('a vessel moving visibly asks for its next frame as soon as it moves half a assert.ok(waitAfterStep(0.0001) <= 16); // About 1 cm in a frame: half a pixel takes seconds, so once a second. assert.equal(waitAfterStep(0.0000001), 1000); + // A 2D view 40 km across in 1,000 px: 11 m is over a quarter pixel, so the + // next frame comes within two frames. + camera.frustum = { left: -20_000, right: 20_000 }; + layer.testing._setVesselStateForTest({ + viewer: { camera, scene: {}, canvas: { clientWidth: 1000 } }, + records: [record], + }); + layer.testing._prepareVesselVisualForTest(record); + assert.ok(waitAfterStep(0.0001) <= 32); +}); + +test('following the clock moves drawn vessels in place each world time, prepares missing ones and leaves hidden ones be', (t) => { + let mono = 0; + t.mock.method(performance, 'now', () => mono); + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + const { layer, camera, timers, advance } = setup(); + camera.positionWC = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 20_000); + camera.frustum = { fovy: 1 }; + const motion = { + fromLat: 51.93, + fromLon: 4.05, + fromMs: 0, + toLat: 51.94, + toLon: 4.05, + toMs: 60_000, + }; + const vessel = (mmsi, show) => ({ + mmsi, + lat: 51.94, + lon: 4.05, + motion: { ...motion }, + billboard: { show, position: null }, + }); + const drawn = vessel('111', true); + const hidden = vessel('222', false); + const owned = vessel('333', false); + const missing = vessel('444', true); + layer.testing._setVesselStateForTest({ + viewer: { camera, scene: {}, canvas: { clientHeight: 1000 } }, + records: [drawn, hidden, owned, missing], + }); + const visuals = [drawn, hidden, owned].map((record) => + layer.testing._prepareVesselVisualForTest(record), + ); + visuals[2].modelOwnsVisual = true; + const before = visuals.map((visual) => ({ ...visual.position })); + const positions = visuals.map((visual) => visual.position); + + // The first pass also moves hidden vessels, so the next stays within + // HIDDEN_FOLLOW_MS of it. + layer.testing._runClockPassForTest(10_000); + mono += 16; + layer.testing._runClockPassForTest(20_000); + assert.equal(visuals[0].position, positions[0], 'written in place'); + assert.notDeepEqual({ ...visuals[0].position }, before[0]); + assert.notDeepEqual({ ...visuals[2].position }, before[2], 'model moves'); + const hiddenAt = { ...visuals[1].position }; + assert.ok(missing.billboard.position, 'a missing visual is prepared'); + assert.equal(timers.size, 1, 'moving vessels ask for a frame'); + + // The hidden vessel waits for HIDDEN_FOLLOW_MS. + mono += 16; + layer.testing._runClockPassForTest(30_000); + assert.deepEqual({ ...visuals[1].position }, hiddenAt); + mono += 250; + layer.testing._runClockPassForTest(40_000); + assert.notDeepEqual({ ...visuals[1].position }, hiddenAt); + + // The same world time again moves nothing and asks for no frame. + advance(1_000); + assert.equal(timers.size, 0); + const still = { ...visuals[0].position }; + mono += 16; + layer.testing._runClockPassForTest(40_000); + assert.deepEqual({ ...visuals[0].position }, still); + assert.equal(timers.size, 0); + + // Scrubbed back, a vessel moves back. + mono += 16; + layer.testing._runClockPassForTest(20_000); + assert.notDeepEqual({ ...visuals[0].position }, still); + + // Each vessel moves once per world time: new fixes at the same world time + // are drawn when the snapshot that brings them prepares the visual. + const backAt = { ...visuals[0].position }; + drawn.motion = { ...motion, toLat: 52 }; + mono += 16; + layer.testing._runClockPassForTest(20_000); + assert.deepEqual({ ...visuals[0].position }, backAt); + + // Past the newest fix, vessels hold still and ask for nothing. + mono += 16; + layer.testing._runClockPassForTest(70_000); + advance(1_000); + mono += 16; + layer.testing._runClockPassForTest(80_000); + assert.equal(timers.size, 0); }); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 7dba03e11..ec123cc8f 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -37,6 +37,9 @@ import { isAidToNavigation } from '../../data/vesselTypes.js'; const VESSEL_STEP_PX = 0.5; /** A frame is asked for at least this often while any drawn vessel moves. */ const PLACEMENT_FRAME_MS = 1000; +/** How often vessels not drawn move on as the world clock plays (wall ms), + * so one that comes into view is judged where it is. */ +const HIDDEN_FOLLOW_MS = 250; /** How often the vessels near enough for a model are gathered again (wall ms). */ const MODEL_CANDIDATES_MS = 500; @@ -87,10 +90,8 @@ export function createRendering({ visual.position, new Cesium.Cartesian3(), ); - if (services.placement) { - visual.drawnLat = lat; - visual.drawnLon = lon; - } + visual.drawnLat = lat; + visual.drawnLon = lon; return visual; } @@ -429,39 +430,51 @@ export function createRendering({ modelCandidatesAtMs = -Infinity; } - // 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 + // The layer does not hold the continuous render loop. Its passes run in + // preRender, which idle mode fires only for rendered frames: moving vessels + // ask for the frame that next moves them visibly (askMotionFrame), 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(() => { - if (followsClock(vesselState)) followClockPositions(); - else if (services.placement && state.feed.enabled) followPlacements(); + if (state.feed.enabled) { + if (followsClock(vesselState)) followClockPositions(); + else if (services.placement) followPlacements(); + } updateVisibility(); if (state.feed.enabled) updateModels(); }); } - // As the world clock plays, vessels between two fixes move on along them, - // every frame. - let followedAtMs = null; + // As the world clock plays, vessels between two fixes move on along them: + // drawn ones every frame, each once per world time, hidden ones (which may + // come into view) every HIDDEN_FOLLOW_MS. + let hiddenFollowedAtWallMs = -Infinity; function followClockPositions() { const atMs = vesselState.worldNow(); - if (atMs === followedAtMs) return; - followedAtMs = atMs; const motion = startMotionPass(); + const moveHidden = + motion.wallMs - hiddenFollowedAtWallMs >= HIDDEN_FOLLOW_MS; + if (moveHidden) hiddenFollowedAtWallMs = motion.wallMs; for (const record of state.records.all) { if (!record.motion) continue; let visual = visualRecords.get(record); - if (visual?.position) { + if (!visual?.position) { + visual = prepareRecordVisual(record); + visual.followedAtMs = atMs; + } else { + const drawn = visual.billboard?.show || visual.modelOwnsVisual; + if (visual.followedAtMs === atMs || (!drawn && !moveHidden)) continue; + visual.followedAtMs = atMs; const { lat, lon } = vesselPositionAt(record, atMs); + // At either end of its fixes a vessel holds still. + if (lat === visual.drawnLat && lon === visual.drawnLon) continue; moveDrawnVisual(visual, lat, lon, motion); - } else visual = prepareRecordVisual(record); + } if (visual.billboard) visual.billboard.position = visual.position; } askMotionFrame(motion); @@ -490,18 +503,27 @@ export function createRendering({ askMotionFrame(motion); } - // One pass of moving vessels: whether any drawn vessel is moving, the - // largest angle one moved through as seen from the camera (radians), and - // the wall time since the pass before. + // One pass of moving vessels: whether any drawn vessel is moving, how far + // the fastest moved (as an angle seen from the camera, radians, and in + // metres), the pass's wall time and the wall time since the pass before. let motionPassWallMs = null; - const motionPass = { camera: null, sinceMs: 0, moving: false, angle: 0 }; + const motionPass = { + camera: null, + wallMs: 0, + sinceMs: 0, + moving: false, + angle: 0, + metres: 0, + }; function startMotionPass() { const wallMs = performance.now(); motionPass.camera = state.viewer?.camera ?? null; + motionPass.wallMs = wallMs; motionPass.sinceMs = motionPassWallMs === null ? 0 : wallMs - motionPassWallMs; motionPass.moving = false; motionPass.angle = 0; + motionPass.metres = 0; motionPassWallMs = wallMs; return motionPass; } @@ -512,14 +534,28 @@ export function createRendering({ moveRecordVisual(visual, lat, lon); if (!visual.billboard?.show && !visual.modelOwnsVisual) return; motion.moving = true; + const metres = Cesium.Cartesian3.distance(from, visual.position); + motion.metres = Math.max(motion.metres, metres); const eye = motion.camera?.positionWC; if (!eye) return; const distance = Cesium.Cartesian3.distance(eye, from); - if (distance > 0) - motion.angle = Math.max( - motion.angle, - Cesium.Cartesian3.distance(from, visual.position) / distance, + if (distance > 0) motion.angle = Math.max(motion.angle, metres / distance); + } + + /** How far the fastest drawn vessel moved on screen in a pass (pixels): + * through the perspective view's angle, or across the 2D view's width. */ + function movedPixels(motion) { + const canvas = state.viewer?.canvas; + const frustum = motion.camera?.frustum; + if (!canvas || !frustum) return 0; + if (frustum.fovy > 0 && canvas.clientHeight > 0) + return ( + (motion.angle * canvas.clientHeight) / (2 * Math.tan(frustum.fovy / 2)) ); + const width = frustum.right - frustum.left; + if (!(frustum.fovy > 0) && width > 0 && canvas.clientWidth > 0) + return (motion.metres * canvas.clientWidth) / width; + return 0; } // The timer for the next vessel frame while one is asked for, and when it @@ -534,17 +570,9 @@ export function createRendering({ function askMotionFrame(motion) { if (!motion.moving) return; let waitMs = PLACEMENT_FRAME_MS; - const canvasHeight = state.viewer?.canvas?.clientHeight; - const fovy = motion.camera?.frustum?.fovy; - if ( - canvasHeight > 0 && - fovy > 0 && - motion.angle > 0 && - motion.sinceMs > 0 - ) { - const movedPx = (motion.angle * canvasHeight) / (2 * Math.tan(fovy / 2)); + const movedPx = movedPixels(motion); + if (movedPx > 0 && motion.sinceMs > 0) waitMs = Math.min(waitMs, (VESSEL_STEP_PX * motion.sinceMs) / movedPx); - } // A frame already asked for no later than this one stands. const runtime = vesselState._aisRuntime; const dueMs = runtime.now() + waitMs; @@ -943,6 +971,7 @@ export function createRendering({ cancelTrailingVisibilityPass, cancelPlacementFrame, followPlacements, + followClockPositions, applyVesselFocusDeemphasis, makeOccluder, isVisible, diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 6c8355bae..197d41b5e 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -174,6 +174,13 @@ export function createTesting({ components.rendering.followPlacements(); } + /** Run one world-clock pass at world time `atMs`. Test-only seam. */ + + function _runClockPassForTest(atMs) { + vesselState.worldNow = () => atMs; + components.rendering.followClockPositions(); + } + /** True while a trailing visibility frame is scheduled. Test-only seam. */ function _hasTrailingVisibilityPassForTest() { @@ -208,6 +215,7 @@ export function createTesting({ _runVisibilityPassForTest, _prepareVesselVisualForTest, _runPlacementPassForTest, + _runClockPassForTest, _hasTrailingVisibilityPassForTest, }; } diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index 5e3906557..4f2c127bf 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -73,11 +73,10 @@ export function createTracking({ heightM, ); if (visual.billboard) visual.billboard.position = visual.position; - // A placed vessel is drawn again at its placement on the next pass. - if (services.placement) { - visual.drawnLat = NaN; - visual.drawnLon = NaN; - } + // A moving vessel is drawn again where it is on the next pass. + visual.drawnLat = NaN; + visual.drawnLon = NaN; + visual.followedAtMs = undefined; } }