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;