From 0f4dda5cc66d157dfb7221a4ee9568bbd7410b65 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Thu, 12 Nov 2026 23:05:24 +0100 Subject: [PATCH] perf(terrain): one shared, cached and budgeted terrain height reader for Transit, Rail, Bike Share and local data globe.getHeight is a ray cast against the drawn tile's mesh and scene.sampleHeight a GPU depth render; several layers asked for them uncached and unbudgeted. The cache written for Transit moves to src/services/terrainSurface.js and is handed to every layer through the ground service, so layers share one cache and one read budget. - Rail reads each train on a ~4 m grid instead of every frame. - Local data layers read terrain floors through it and stop depth samples at 8 ms a frame. - Bike Share draws stations at once and finds their heights afterwards in 4 ms steps. Co-Authored-By: Claude Fable 5.2 --- changelog.d/0000-shared-terrain-surface.md | 5 + docs/CURRENT-STATE.md | 48 ++- docs/INFRASTRUCTURE-LAYERS.md | 1 + scripts/package-boundaries.json | 9 +- src/app/constructCatalog.js | 7 +- src/app/layers/bikeshare.js | 11 +- src/data/infrastructure.test.mjs | 1 + src/data/localGeojson.test.mjs | 190 +++++++++- src/data/localGeojsonCore.js | 149 +++++++- .../bikeshare/heightRefinement.test.mjs | 328 ++++++++++++++++++ src/layers/bikeshare/lifecycle.js | 3 + src/layers/bikeshare/policy.js | 11 + src/layers/bikeshare/rendering.js | 118 ++++++- src/layers/rail/rendering.js | 28 +- src/layers/transit/height.js | 176 +--------- src/layers/transit/height.test.mjs | 9 +- src/layers/transit/ingestion.js | 2 +- src/layers/transit/policy.js | 38 -- src/layers/transit/trails.js | 4 +- src/services/groundFloor.js | 3 + src/services/terrainSurface.js | 254 ++++++++++++++ src/services/terrainSurface.test.mjs | 125 +++++++ 22 files changed, 1257 insertions(+), 263 deletions(-) create mode 100644 changelog.d/0000-shared-terrain-surface.md create mode 100644 src/layers/bikeshare/heightRefinement.test.mjs create mode 100644 src/services/terrainSurface.js create mode 100644 src/services/terrainSurface.test.mjs diff --git a/changelog.d/0000-shared-terrain-surface.md b/changelog.d/0000-shared-terrain-surface.md new file mode 100644 index 000000000..bd7569b97 --- /dev/null +++ b/changelog.d/0000-shared-terrain-surface.md @@ -0,0 +1,5 @@ +- Read terrain heights through one shared, cached and budgeted reader + (`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local + data layers. Opening Data Centers no longer blocks the page for over two + seconds, opening Bike Share for most of a second, and Rail asks the terrain + about a twentieth as often (mazy1998). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 67c567edd..cc34353d4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5578,22 +5578,38 @@ Noir samples also exclude its deliberately vignetted outer field, using shader settings and screen position rather than observed brightness. Selected-trail diagnostics report every condition even when selection fails. -On a terrain globe Transit reads each vehicle's and trail point's height from -the loaded surface (`globe.getHeight`, a ray cast against the drawn tile's -mesh) and keeps the answer per point, about a metre apart -(`src/layers/transit/height.js`, constants in `policy.js`). While the camera -moves or tiles load a kept height is read again after 2 s; a height read once -both have been still for 5 s is kept until either changes. Reads are budgeted -at 16 ms per 200 ms window, 12 ms of it for re-reads, longest-kept first. A -vehicle's own position is always read; the path its marker is drawn along -comes next; the undrawn trail history of unselected vehicles may use a quarter -of each budget. Past the budget a kept point keeps its last height, a new -point takes the last height read in its ~111 m floor cell or waits, and the -reread cycle continues until none waits. Before this, every reread asked the -terrain for every trail fix of every vehicle in view, which blocked the page -for up to 19 s at a time over a 650-vehicle fleet while zooming. Verified -against Boston's MBTA feed on a keyless terrain globe only; no steeper city -has a keyless feed to check against. +Heights of the terrain globe's loaded surface come from one shared reader, +`src/services/terrainSurface.js`, which the ground service hands to every layer +(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the +drawn tile's mesh, so the reader keeps each answer per point, about a metre +apart. While the camera moves or tiles load a kept height is read again after +2 s; a height read once both have been still for 5 s is kept until either +changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms +of it for re-reads, longest-kept first. A request ranks as a marker (always +read), drawn geometry, or background (a quarter of each budget). Past the +budget a kept point keeps its last height and a new point takes the last +height read in its ~111 m floor cell or waits. + +- Transit asks for every vehicle and trail fix: vehicle positions as markers, + the path a marker is drawn along next, the undrawn trail history of + unselected vehicles as background, and its reread cycle continues until no + point waits. Before this, every reread cast a ray for every trail fix of + every vehicle in view, which blocked the page for up to 19 s at a time over + a 650-vehicle fleet while zooming. +- Rail asks for each train every frame on a grid about 4 m apart, as a marker, + so a moving train reads a few times a second and a standing one not at all. +- Local data layers (Data Centers, Dams) read terrain floors through it, and + their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a + frame per layer and continue on the next frames. +- Bike Share draws a city's stations at once at the fallback height and finds + their ground heights afterwards in 4 ms steps, from the terrain reader on a + terrain globe and from depth samples where the globe is hidden. + +Verified in the browser on a keyless terrain globe only: Boston for Transit, +Rail and Bike Share, northern Virginia for Data Centers. Not checked there: +photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams, +and any city steeper than Boston. Street Traffic keeps its own height cache +and ALPR cameras their own sample budget. Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour stroke over 3.25 px dark backing. Other contact themes retain their existing diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index aa8cd0558..db2e8164e 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({ | `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer | | `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | +| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own | Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, `update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a771cc26f..7d06084ac 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -11,7 +11,8 @@ "src/data/localGeojsonLod.js", "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js", - "src/sources/infrastructureData.js" + "src/sources/infrastructureData.js", + "src/services/terrainSurface.js" ], "external": ["cesium"] }, @@ -2056,6 +2057,7 @@ "src/layers/bikeshare/state.js", "src/layers/bikeshare/testing.js", "src/layers/bikeshare/viewport.js", + "src/services/terrainSurface.js", "src/data/inputOwnership.js", "src/data/cameraSensitivity.js", "src/i18n/index.js", @@ -3003,6 +3005,7 @@ "src/search/placeSearch.js", "src/services/application.js", "src/services/groundFloor.js", + "src/services/terrainSurface.js", "src/services/groundSnap.js", "src/services/meshFloorSampler.js", "src/services/requests.js", @@ -4034,6 +4037,7 @@ "src/scopeMaskPolicy.js", "src/scopeMask.js", "src/services/groundFloor.js", + "src/services/terrainSurface.js", "src/data/routeSteps.js", "src/layers/directions/index.js", "src/data/pointerOwnerNames.js", @@ -4378,6 +4382,7 @@ "src/search/placeSearch.js", "src/services/application.js", "src/services/groundFloor.js", + "src/services/terrainSurface.js", "src/services/groundSnap.js", "src/services/meshFloorSampler.js", "src/services/requests.js", @@ -5038,6 +5043,7 @@ "src/renderGovernor.js", "src/services/application.js", "src/services/groundFloor.js", + "src/services/terrainSurface.js", "src/services/meshFloorSampler.js", "src/services/requests.js", "src/services/terrainHeights.js", @@ -5082,6 +5088,7 @@ "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", "src/layers/transit/height.js", + "src/services/terrainSurface.js", "src/layers/transit/index.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index c3d6c6f40..779d41c06 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -168,7 +168,7 @@ export function createApplicationCatalog({ createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), createApplicationTransit({ surface, source: sources.transit }), - createApplicationBikeshare({ source: sources.bikeshare }), + createApplicationBikeshare({ surface, source: sources.bikeshare }), createApplicationDirections({ source: sources.directions }), createApplicationRecentImagery({ source: sources['recent-imagery'] }), createApplicationOsmPlaces(), @@ -198,7 +198,10 @@ export function createApplicationCatalog({ }), createApplicationCyclones({ feed: sources.cyclones }), ...createOsmOverlayLayers(), - ...createInfrastructureLayers(localGeoJsonServices), + ...createInfrastructureLayers({ + ...localGeoJsonServices, + terrainSurface: surface.groundFloor.terrainSurface, + }), createApplicationCables({ source: sources.cables }), createApplicationRail({ surface, source: sources.rail }), createApplicationFirms({ diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js index 590808a96..d094f7c52 100644 --- a/src/app/layers/bikeshare.js +++ b/src/app/layers/bikeshare.js @@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationBikeshare({ source }) { +export function createApplicationBikeshare({ source, surface }) { return createBikeshareLayer({ source, - services: { render, sprites, picking, overlays }, + services: { + render, + sprites, + picking, + overlays, + // Station heights come from the terrain surface every layer shares. + ground: { terrainSurface: surface?.groundFloor?.terrainSurface }, + }, }); } diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index c7f1b5a23..79a46a4df 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under 'infrastructureOverlayEntry.js', 'localGeojsonCore.js', 'localGeojsonLod.js', + 'terrainSurface.js', ]); assert.deepEqual( entry.imports, diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 6eae458cb..a1fe16953 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -23,9 +23,10 @@ import { } from '../renderGovernor.js'; /** The catalog's services, with the overlay host each test supplies. */ -const createLocalGeoJsonLayer = (options) => +const createLocalGeoJsonLayer = (options, services = {}) => createLayer(options, { ...localGeoJsonServices, + ...services, overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, }); @@ -58,10 +59,15 @@ class MockLayerEvent { * @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability. * @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior * (default: throws like a scene whose tiles are not sampleable yet). + * @param {number[][]} [options.points] `[lon, lat]` sites to load + * instead of the single dam polygon. + * @param {object} [options.services] Extra services for the layer. */ async function createRealLocalLayerHarness({ sampleHeightSupported = false, sampleHeight = () => { throw new Error('tiles not sampleable yet'); }, + points = null, + services = {}, } = {}) { const originalFetch = globalThis.fetch; const originalWindow = globalThis.window; @@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({ globalThis.fetch = async () => ({ ok: true, status: 200, - text: async () => JSON.stringify({ + text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({ + type: 'Feature', + id: `site-${index}`, + properties: { name: `Site ${index}` }, + // A small outline: the layer stems its centre. (Cesium draws a point + // feature as a canvas pin, which Node cannot make.) + geometry: { + type: 'Polygon', + coordinates: [[ + [lon - 0.001, lat - 0.001], + [lon + 0.001, lat - 0.001], + [lon, lat + 0.001], + [lon - 0.001, lat - 0.001], + ]], + }, + })).join('\n') : JSON.stringify({ type: 'Feature', id: 'real-dam', properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } }, @@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({ setInputAction() {}, destroy() {}, }), - }); + }, services); try { await layer.enable(viewer); } finally { @@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G env.preRender.raise(); assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle'); globe.tilesLoaded = true; - clock.advance(500); + // The terrain surface reuses a height for 2 s before reading the mesh again. + clock.advance(2_100); env.preRender.raise(); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor'); terrain = 110; - clock.advance(500); + clock.advance(2_100); env.preRender.raise(); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry'); terrain = 140; @@ -1493,3 +1515,161 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); }); + +/** A loaded terrain globe that counts its ray casts. */ +function countingTerrain(height) { + const globe = { + show: true, + tilesLoaded: true, + reads: 0, + getHeight: () => { globe.reads += 1; return height; }, + }; + return globe; +} + +/** Height of every stem base, in entity order. */ +function allBaseHeightsM(env) { + return env.dataSources[0].entities.values.map( + (entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height, + ); +} + +const SAMPLE_SITES = [ + [-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205], +]; + +test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + const globe = countingTerrain(117); + env.viewer.scene.globe = globe; + setCameraAltitude(env, 20_000); + for (let walk = 0; walk < 40; walk += 1) { + env.preRender.raise(); + clock.advance(500); + } + assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); + // 20 s parked: a few reads while the surface settles, then none. + assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`); + const settled = globe.reads; + for (let walk = 0; walk < 20; walk += 1) { + env.preRender.raise(); + clock.advance(500); + } + assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays'); +}); + +test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + const globe = countingTerrain(117); + env.viewer.scene.globe = globe; + setCameraAltitude(env, 20_000); + env.setBaseInView(false); + for (let walk = 0; walk < 40; walk += 1) { + env.preRender.raise(); + clock.advance(500); + } + assert.equal(env.sampleCalls.count, 0); + assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`); +}); + +test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => { + const asked = []; + const terrainSurface = { + height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; }, + }; + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + points: [[-97.695, 30.205]], + services: { terrainSurface }, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + const globe = countingTerrain(117); + env.viewer.scene.globe = globe; + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself'); + clock.advance(500); + env.preRender.raise(); + assert.ok(asked.length >= 2); + for (const call of asked) { + assert.equal(call.viewer, env.viewer); + assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002, + `asked at ${call.lat}, ${call.lon}`); + } + assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface'); + assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly'); +}); + +test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => { + const heights = new Map(); + let clock = null; + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, + points: SAMPLE_SITES, + sampleHeight: (carto) => { + clock.advance(15); // one GPU depth render + const height = 100 + heights.size; + heights.set(`${carto.longitude},${carto.latitude}`, height); + return height; + }, + }); + _resetRenderGovernorForTest(); + const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; + installRenderGovernor({ scene: governorScene }); + governorScene.renders = 0; // discard the governor's own install settling frame + clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + _resetRenderGovernorForTest(); + }); + setCameraAltitude(env, 20_000); + + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget'); + assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it'); + + // The following frames arrive well inside the 450 ms walk interval. + for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) { + clock.advance(16); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped'); + } + assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled'); + clock.advance(16); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice'); + + const expected = env.dataSources[0].entities.values.map((entity) => { + const carto = entity.__localBaseCarto; + return heights.get(`${carto.longitude},${carto.latitude}`); + }); + assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected); + for (const entity of env.dataSources[0].entities.values) { + const stem = entity.polyline.positions.getValue(); + assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base'); + } +}); + +test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, + points: SAMPLE_SITES, + sampleHeight: () => 117, + }); + installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, SAMPLE_SITES.length); + for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01); +}); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 811dfab57..1b6b73169 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -11,6 +11,10 @@ import { applyInfraEvictionGrace, shouldRecomputeInfraLod, } from './localGeojsonLod.js'; +import { + createTerrainSurface, + TERRAIN_NEED, +} from '../services/terrainSurface.js'; const DEFAULT_LABEL_MAX = 900; const DEFAULT_LABEL_GRID_PX = 132; @@ -38,6 +42,15 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000; * 30 × 2 s ≈ 60 s, far longer than a tile stream-in. */ export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; +/** + * Milliseconds of scene.sampleHeight one frame may spend. Each call is an + * offscreen depth render (about 15 ms measured), and a first walk near a dense + * cluster wants one per site in view: 168 of them held the main thread for + * 2.6 s. Past the budget the remaining sites queue, and each following frame + * takes as many as fit, so the sites reach the same heights a frame or a few + * apart instead of all inside one blocked frame. + */ +export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8; /** Ignore sub-metre camera-derived stem-tip noise at camera settle. */ export const LOCAL_STEM_TIP_EPSILON_M = 0.5; const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; @@ -324,8 +337,20 @@ export function createLocalGeoJsonLayer( governorRequestRender, showOsmCredit, hideOsmCredit, + terrainSurface, }, ) { + /** + * What ground sampling shares across a layer's records: the terrain + * surface (the application's, so every layer reads a point once and spends + * one budget, or this layer's own), the sample time spent this frame, and + * the records the frame budget left for a later frame. + */ + const _ground = { + terrain: terrainSurface || createTerrainSurface(), + sampleMs: 0, + queued: [], + }; let _dataSource = null; let _enabled = false; let _clickHandler = null; @@ -426,6 +451,42 @@ export function createLocalGeoJsonLayer( clearTimeout(_groundRetryTimer); _groundRetryTimer = null; } + + /** + * Sample the sites an earlier frame's budget left over, within this + * frame's. They were never attempted, so the 2 s retry spacing does not + * apply to them. + * @param {Cesium.Viewer} viewer + * @param {number} now + * @returns {void} + */ + function sampleQueuedGround(viewer, now) { + const queued = _ground.queued; + if (queued.length === 0) return; + _ground.queued = []; + for (const record of queued) { + record.groundSampleQueued = false; + // The camera may have left the site since it queued. + if (_lodComputed && !_activeLodIds.has(record.id)) continue; + const distance = Cesium.Cartesian3.distance( + viewer.camera.positionWC, + record.base, + ); + if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue; + // Past the budget this puts the record back in the queue. + if (sampleLocalGroundHeight(viewer, record, now, _ground)) { + updateLocalStemGeometry(viewer, record, now, _ground, distance); + _groundRetryArms = 0; + } + } + } + + /** Ask for the frame that samples what this one's budget left over. */ + function requestQueuedGroundFrame() { + if (_ground.queued.length > 0) + governorRequestRender(`local-ground-sample:${id}`); + } + const _overlayPublisher = createLocalInfrastructureOverlayPublisher({ sourceId: id, host: overlayHost, @@ -442,6 +503,7 @@ export function createLocalGeoJsonLayer( const source = _dataSource; _dataSource = null; _stemRecords = []; + _ground.queued = []; _stemGeometryDirty = true; _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; removeEntityContextsForLayer(id); @@ -689,6 +751,8 @@ export function createLocalGeoJsonLayer( // Store references for bounded stem scaling and native picking. feature.__localBaseCarto = carto; feature.__localBaseCartesian = base; + const latDeg = Cesium.Math.toDegrees(carto.latitude); + const lonDeg = Cesium.Math.toDegrees(carto.longitude); registerEntityContext(feature, { id: `${id}:${recordId}`, layerId: id, @@ -739,7 +803,11 @@ export function createLocalGeoJsonLayer( stemPositionBuffers, stemPositionBufferIndex: 0, groundHeight, + // Degrees, as the terrain surface takes them. + latDeg, + lonDeg, groundSampled: false, + groundSampleQueued: false, lastGroundSampleMs: 0, priority, entry: labels @@ -860,7 +928,13 @@ export function createLocalGeoJsonLayer( _preRenderRemover = viewer.scene.preRender.addEventListener(() => { if (!_enabled || !_dataSource) return; const now = performance.now(); - if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return; + // One sample budget per frame, shared by the queue and the walk. + _ground.sampleMs = 0; + sampleQueuedGround(viewer, now); + if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) { + requestQueuedGroundFrame(); + return; + } _lastVisibilityUpdate = now; const cameraPos = viewer.camera.positionWC; @@ -911,6 +985,9 @@ export function createLocalGeoJsonLayer( _lastGroundSampleCapability = canSampleGround; let groundRetryPending = false; let groundSampleProgress = false; + // This walk asks for no height while tiles load, so it shows the + // surface the load itself; see terrainSurface.js observe(). + _ground.terrain.observe?.(viewer); // --- Globe-LOD: recompute the active-stem set on camera moves. --- // `_stemGeometryDirty` is set by moveEnd, the first enable, and the @@ -972,9 +1049,10 @@ export function createLocalGeoJsonLayer( const terrainFloorChanged = refreshLocalTerrainFloor( viewer, record, + _ground, ); if (refreshStemGeometry || terrainFloorChanged) { - updateLocalStemGeometry(viewer, record, now); + updateLocalStemGeometry(viewer, record, now, _ground); } else if ( canSampleGround && !record.groundSampled && @@ -988,9 +1066,9 @@ export function createLocalGeoJsonLayer( ); if ( distance < GROUND_SAMPLE_MAX_DISTANCE_M && - sampleLocalGroundHeight(viewer, record, now) + sampleLocalGroundHeight(viewer, record, now, _ground) ) { - updateLocalStemGeometry(viewer, record, now, distance); + updateLocalStemGeometry(viewer, record, now, _ground, distance); } } if (!wasGroundSampled && record.groundSampled) @@ -1002,16 +1080,19 @@ export function createLocalGeoJsonLayer( // the user happens to move. Schedule the frame the retry needs. // Gated on a sampleable scene and in-range, in-view records only, // so a far camera (or a keyless scene) stays fully idle; the - // distance is only computed for still-unsampled stems. + // distance is only computed for still-unsampled stems. A record + // the frame budget queued is sampled by the frame the queue asks + // for, with no timer. if ( canSampleGround && !record.groundSampled && + !record.groundSampleQueued && !groundRetryPending && Cesium.Cartesian3.distance( viewer.camera.positionWC, record.base, ) < GROUND_SAMPLE_MAX_DISTANCE_M && - groundSampleInView(viewer, record) + groundSampleInView(viewer, record, _ground) ) { groundRetryPending = true; } @@ -1022,6 +1103,7 @@ export function createLocalGeoJsonLayer( // so the records still waiting get their own bounded run of retries. if (groundSampleProgress) _groundRetryArms = 0; if (groundRetryPending) scheduleGroundRetryRender(viewer); + requestQueuedGroundFrame(); const canvas = viewer.scene.canvas; const cohort = selectLocalInfrastructureOverlayCohort( @@ -1108,7 +1190,7 @@ function insertLocalCellContender(contenders, record) { contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; } -function refreshLocalTerrainFloor(viewer, record) { +function refreshLocalTerrainFloor(viewer, record, ground) { const globe = viewer.scene.globe; if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) return false; @@ -1120,7 +1202,15 @@ function refreshLocalTerrainFloor(viewer, record) { // Camera motion can refine terrain after the first successful depth sample. // Reuse the loaded mesh on existing bounded walks; never arm a timer or do // another GPU readback just to maintain this floor. Keep roof elevations. - const height = globe.getHeight?.(record.carto); + // The surface keeps the height per point and casts the ray again only after + // the camera moves or tiles load. MARKER: the answer is this point's own + // height, never a neighbour's, since a height taken here is kept for good. + const height = ground.terrain.height( + viewer, + record.latDeg, + record.lonDeg, + TERRAIN_NEED.MARKER, + ); if ( !Number.isFinite(height) || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || @@ -1163,11 +1253,20 @@ function groundSamplePoint(record, height, result) { * sample can accept, so high or below-ellipsoid ground in view is never * mistaken for an unseen base. Unseen ground is sampled on the first walk that * sees it. + * + * The terrain height comes from the shared surface, which keeps it per point: + * this runs for every unsampled site in range on every walk, and a ray cast + * each time was 17,733 casts and 1.3 s over one zoom. The view test needs the + * height only roughly, so a height the surface has not read yet, or tiles + * still loading, count as unknown ground. */ -function groundSampleInView(viewer, record) { +function groundSampleInView(viewer, record, ground) { const camera = viewer.camera; const globe = viewer.scene.globe; - const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; + const terrain = + globe?.show && globe.tilesLoaded !== false + ? ground.terrain.height(viewer, record.latDeg, record.lonDeg) + : undefined; const known = Number.isFinite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; @@ -1208,18 +1307,30 @@ function groundSampleInView(viewer, record) { ); } -function sampleLocalGroundHeight(viewer, record, now) { +function sampleLocalGroundHeight(viewer, record, now, ground) { if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; - if (!groundSampleInView(viewer, record)) return false; + // This frame's samples have used its budget: leave the record for the next + // frame, unattempted, so the retry spacing does not hold it back. + if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) { + if (!record.groundSampleQueued) { + record.groundSampleQueued = true; + ground.queued.push(record); + } + return false; + } + if (!groundSampleInView(viewer, record, ground)) return false; record.lastGroundSampleMs = now; const globe = viewer.scene.globe; if (globe?.show && globe.tilesLoaded === false) return false; let sampled; + const startedAt = performance.now(); try { sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); } catch { return false; // tiles not ready; retry on a later bounded update + } finally { + ground.sampleMs += performance.now() - startedAt; } if ( !Number.isFinite(sampled) || @@ -1229,7 +1340,9 @@ function sampleLocalGroundHeight(viewer, record, now) { // A coarse scene-depth sample can lie below the terrain already loaded by // the visible globe. Keep that terrain as a floor while allowing roofs and // valid below-sea-level elevations. Photoreal scenes hide the globe, so its - // inactive terrain must not constrain their geometry. + // inactive terrain must not constrain their geometry. Read from the globe + // itself, once per site: the floor under a sample must be the mesh that is + // loaded now, not a height kept from a moment before. const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined; @@ -1256,12 +1369,18 @@ function setLocalGroundHeight(record, height) { record.entity.__localBaseCartesian = record.base; } -function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { +function updateLocalStemGeometry( + viewer, + record, + now, + ground, + knownDistance = null, +) { const distance = Number.isFinite(knownDistance) ? knownDistance : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) - sampleLocalGroundHeight(viewer, record, now); + sampleLocalGroundHeight(viewer, record, now, ground); // Keep the intended screen-size scaling in close-up views too. A 5 km // minimum made a marker hundreds of metres tall beside a nearby building. const effectiveDistance = Math.max(distance, 1); diff --git a/src/layers/bikeshare/heightRefinement.test.mjs b/src/layers/bikeshare/heightRefinement.test.mjs new file mode 100644 index 000000000..7582979f6 --- /dev/null +++ b/src/layers/bikeshare/heightRefinement.test.mjs @@ -0,0 +1,328 @@ +// Station ground heights. A city's stations are all drawn at once at the +// fallback height; their ground heights are found afterwards a few +// milliseconds at a time, so a city of hundreds of docks never holds the main +// thread for the length of hundreds of depth renders. Each case drives the +// real rendering part (and the real lifecycle where it tears down) over a +// scene whose height reads are counted and timed. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createState } from './state.js'; +import { createModel } from './model.js'; +import { createRendering } from './rendering.js'; +import { createLifecycle } from './lifecycle.js'; +import { POINT_HEIGHT_OFFSET_M } from './policy.js'; +import { createTerrainSurface } from '../../services/terrainSurface.js'; + +globalThis.document ??= { + addEventListener() {}, + removeEventListener() {}, +}; + +/** Milliseconds one fake depth render holds the thread. */ +const SAMPLE_COST_MS = 1; +/** The ground under a point, as the fake scene and globe both report it. */ +const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10; + +function busyWait(ms) { + const until = performance.now() + ms; + while (performance.now() < until); +} + +/** `count` stations on a line through Boston. */ +function stations(count, prefix = 's') { + return { + data: { + stations: Array.from({ length: count }, (_, i) => ({ + station_id: `${prefix}${i}`, + name: `Dock ${i}`, + lat: 42.3 + i * 1e-4, + lon: -71.1 + i * 1e-4, + capacity: 10, + })), + }, + }; +} + +/** + * The rendering part over a scene that counts its height reads. + * @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options + * `terrainGlobe`: the globe is showing (else photorealistic tiles hide it); + * `shared`: the application's terrain surface is supplied; + * `unloaded`: latitudes whose terrain tile is not drawn. + */ +function makeLayer({ + terrainGlobe = false, + shared = false, + unloaded = () => false, +} = {}) { + const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 }; + /** sampleHeight calls per turn of the event loop. */ + const perTurn = [0]; + let ticking = true; + const tick = () => { + if (!ticking) return; + perTurn.push(0); + setImmediate(tick); + }; + setImmediate(tick); + + const degrees = (carto) => [ + Cesium.Math.toDegrees(carto.latitude), + Cesium.Math.toDegrees(carto.longitude), + ]; + const viewer = { + camera: { + positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000), + changed: { addEventListener() {}, removeEventListener() {} }, + }, + scene: { + sampleHeightSupported: true, + sampleHeight(carto) { + calls.sampleHeight += 1; + perTurn[perTurn.length - 1] += 1; + busyWait(SAMPLE_COST_MS); + return groundAt(...degrees(carto)); + }, + globe: { + show: terrainGlobe, + tilesLoaded: true, + getHeight(carto) { + calls.getHeight += 1; + const [lat, lon] = degrees(carto); + return unloaded(lat) ? undefined : groundAt(lat, lon); + }, + }, + primitives: { add() {}, remove() {} }, + }, + }; + + const surface = createTerrainSurface(); + const services = { + render: { + governorRequestRender() { + calls.render += 1; + }, + }, + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + }; + if (shared) { + services.ground = { + terrainSurface: { + ...surface, + height(...args) { + calls.sharedHeight += 1; + return surface.height(...args); + }, + }, + }; + } + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source: {} }; + parts.model = createModel(context); + parts.rendering = createRendering(context); + parts.selection = { + _installClickHandler() {}, + _clearSelection() {}, + _onKeyDown() {}, + }; + parts.ingestion = { abortAllInFlight() {} }; + // Leaves the points alone, so a test of disable() sees what disable() stops. + parts.viewport = { + onCameraChanged() {}, + deactivateAllCities() {}, + runProximityCheck: async () => {}, + }; + parts.lifecycle = createLifecycle(context); + parts.lifecycle.methods.init(viewer); + state._pointCollection = { + show: true, + add: (options) => ({ ...options }), + remove() {}, + }; + + const heightOf = (key) => + Cesium.Cartographic.fromCartesian( + state._stationRenderMap.get(key).point.position, + ).height; + + return { + state, + parts, + viewer, + calls, + rendering: parts.rendering, + lifecycle: parts.lifecycle.methods, + heightOf, + add(cityId, count) { + const info = parts.model.parseStationInformation(stations(count, cityId)); + parts.rendering.ensureCityPoints(cityId, info); + return info; + }, + /** Wait until every queued station has its height, or fail. */ + async refined() { + const deadline = performance.now() + 20_000; + while (parts.rendering.pendingHeightCount() > 0) { + assert.ok(performance.now() < deadline, 'refinement finished'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }, + /** The most sampleHeight calls made without yielding to the loop. */ + longestBurst() { + return Math.max(...perTurn); + }, + done() { + ticking = false; + parts.rendering.stopHeightRefinement(); + }, + }; +} + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +test('a city of stations is drawn at once without sampling heights', (t) => { + const layer = makeLayer(); + t.after(layer.done); + const started = performance.now(); + layer.add('boston', 300); + const spent = performance.now() - started; + + assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn'); + assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing'); + assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`); + assert.ok( + Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3, + 'drawn at the fallback height', + ); + assert.equal(layer.rendering.pendingHeightCount(), 300); +}); + +test('heights arrive afterwards in short steps and match a direct sample', async (t) => { + const layer = makeLayer(); + t.after(layer.done); + const info = layer.add('boston', 120); + const rendersBefore = layer.calls.render; + await layer.refined(); + + assert.equal(layer.calls.sampleHeight, 120, 'one sample per station'); + // 120 ms of samples in one go is the stall; a step is a few milliseconds. + assert.ok( + layer.longestBurst() <= 10, + `longest burst was ${layer.longestBurst()} samples`, + ); + for (const station of info.values()) { + const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M; + const actual = layer.heightOf(`boston:${station.stationId}`); + assert.ok( + Math.abs(actual - expected) < 1e-3, + `${station.stationId}: ${actual} vs ${expected}`, + ); + } + assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame'); +}); + +test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => { + const layer = makeLayer({ terrainGlobe: true, shared: true }); + t.after(layer.done); + const info = layer.add('boston', 200); + assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing'); + await layer.refined(); + + assert.equal(layer.calls.sampleHeight, 0); + assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface'); + assert.equal(layer.calls.getHeight, 200); + for (const station of info.values()) { + const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M; + const actual = layer.heightOf(`boston:${station.stationId}`); + assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId); + } +}); + +test('without a shared surface the layer reads the terrain globe itself', async (t) => { + const layer = makeLayer({ terrainGlobe: true }); + t.after(layer.done); + layer.add('boston', 50); + await layer.refined(); + assert.equal(layer.calls.sampleHeight, 0); + assert.equal(layer.calls.getHeight, 50); + assert.ok( + Math.abs( + layer.heightOf('boston:boston7') - + (groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M), + ) < 1e-3, + ); +}); + +test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => { + // Stations 10 and up lie on a tile the globe is not drawing. + const layer = makeLayer({ + terrainGlobe: true, + shared: true, + unloaded: (lat) => lat > 42.30095, + }); + t.after(layer.done); + layer.add('boston', 20); + await layer.refined(); + assert.equal(layer.calls.sampleHeight, 10); + assert.ok( + Math.abs( + layer.heightOf('boston:boston15') - + (groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M), + ) < 1e-3, + ); +}); + +test('removing a city stops its refinement and leaves the other running', async (t) => { + const layer = makeLayer(); + t.after(layer.done); + layer.add('boston', 40); + layer.add('toronto', 40); + layer.rendering.removeCityPoints('boston'); + assert.equal(layer.rendering.pendingHeightCount(), 40); + await layer.refined(); + assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city'); + assert.ok(layer.heightOf('toronto:toronto3') > 50); + + layer.add('boston', 40); + layer.rendering.removeCityPoints('boston'); + layer.rendering.removeCityPoints('toronto'); + assert.equal(layer.rendering.pendingHeightCount(), 0); + assert.equal(layer.rendering.heightRefinementScheduled(), false); + await pause(60); + assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal'); +}); + +test('disabling the layer stops refinement and leaves no timer', async (t) => { + const layer = makeLayer(); + t.after(layer.done); + layer.lifecycle.enable(layer.viewer); + layer.add('boston', 200); + await pause(30); + const partWay = layer.calls.sampleHeight; + assert.ok(partWay < 200, 'still refining'); + layer.lifecycle.disable(layer.viewer); + + assert.equal(layer.rendering.pendingHeightCount(), 0); + assert.equal(layer.rendering.heightRefinementScheduled(), false); + await pause(60); + assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable'); +}); + +test('a station selected before its height arrives is re-selected at it', async (t) => { + const layer = makeLayer(); + t.after(layer.done); + layer.add('boston', 3); + const reselected = []; + layer.state._selectedKey = 'boston:boston1'; + // The selection part's select path; here it records the key. + layer.parts.selection._selectStation = (key) => reselected.push(key); + await layer.refined(); + assert.deepEqual(reselected, ['boston:boston1']); +}); diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js index 050269bfd..243dde3e6 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/lifecycle.js @@ -44,6 +44,7 @@ export function createLifecycle({ layerState._inFlightInfo = new Map(); layerState._inFlightStatus = new Map(); layerState._stationRenderMap = new Map(); + parts.rendering?.stopHeightRefinement?.(); layerState._clickHandler = null; layerState._selectedKey = null; layerState._selectedEntity = null; @@ -134,6 +135,7 @@ export function createLifecycle({ parts.ingestion.abortAllInFlight(); parts.viewport.deactivateAllCities(); + parts.rendering?.stopHeightRefinement?.(); layerState._cityRuntime.clear(); layerState._pointCollection.show = false; layerState._count = 0; @@ -165,6 +167,7 @@ export function createLifecycle({ layerState._cameraChangedAttached = false; } parts.ingestion.abortAllInFlight(); + parts.rendering?.stopHeightRefinement?.(); if (layerState._pointCollection) { viewer.scene.primitives.remove(layerState._pointCollection); layerState._pointCollection = null; diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js index 1b9c1579f..1e85490a5 100644 --- a/src/layers/bikeshare/policy.js +++ b/src/layers/bikeshare/policy.js @@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15; export const POINT_HEIGHT_OFFSET_M = 2.0; +/** + * Milliseconds one step may spend finding station ground heights. A height + * the terrain globe cannot give is a depth render of a millisecond or more. + */ + +export const HEIGHT_REFINE_BUDGET_MS = 4; + +/** Milliseconds between those steps, about a frame. */ + +export const HEIGHT_REFINE_INTERVAL_MS = 16; + /** Hard cap on total rendered station points across all cities. */ export const MAX_TOTAL_POINTS = 8000; diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js index c75189d4a..7d87fa086 100644 --- a/src/layers/bikeshare/rendering.js +++ b/src/layers/bikeshare/rendering.js @@ -11,7 +11,13 @@ import { POINT_HEIGHT_OFFSET_M, MAX_TOTAL_POINTS, COLOR_OUTLINE, + HEIGHT_REFINE_BUDGET_MS, + HEIGHT_REFINE_INTERVAL_MS, } from './policy.js'; +import { + createTerrainSurface, + TERRAIN_NEED, +} from '../../services/terrainSurface.js'; export function createRendering({ state: layerState, @@ -20,6 +26,14 @@ export function createRendering({ source, }) { const { governorRequestRender } = services.render; + // The terrain globe's loaded surface: the application's shared reader, or + // the layer's own when it is built without one. + const terrainSurface = + services.ground?.terrainSurface || createTerrainSurface(); + /** Stations drawn at the fallback height, waiting for their ground height. */ + let heightQueue = []; + let heightTimer = null; + const heightPoint = new Cesium.Cartographic(); /** * Resolve the effective dock capacity for a station. @@ -87,8 +101,10 @@ export function createRendering({ } /** - * Create a Cartesian3 position for a station, terrain-clamped when possible. - * Falls back to a fixed small height offset if terrain sampling is unsupported. + * Create a Cartesian3 position for a station at the fallback height. Its + * ground height is found afterwards (refineHeights): a city's stations are + * drawn at once, and finding hundreds of heights in the same task held the + * main thread for most of a second. * @param {Object} station - Station info object with lat/lon. * @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid. */ @@ -97,18 +113,78 @@ export function createRendering({ const lon = Number(station?.lon); const lat = Number(station?.lat); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M); + } - let height = POINT_HEIGHT_OFFSET_M; - // Sample terrain height so points sit on ground rather than at ellipsoid level - if (layerState._viewer?.scene?.sampleHeightSupported) { - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const sampled = layerState._viewer.scene.sampleHeight(carto); - if (Number.isFinite(sampled)) { - height = sampled + POINT_HEIGHT_OFFSET_M; - } + /** + * The ground height under a station, or undefined when the scene cannot say. + * A terrain globe answers from its loaded surface, which is cheap. Where it + * cannot (the globe is hidden under photorealistic tiles, or draws no tile + * at the point) the scene renders the depth there, at a millisecond or more + * a point. + */ + + function sampleGroundHeight(lat, lon) { + const viewer = layerState._viewer; + const scene = viewer?.scene; + if (!scene) return undefined; + // A station is a marker: its one read is not left for a later window. + // The step's own time budget bounds what a pass spends. + const terrain = terrainSurface.height( + viewer, + lat, + lon, + TERRAIN_NEED.MARKER, + ); + if (Number.isFinite(terrain)) return terrain; + if (!scene.sampleHeightSupported) return undefined; + Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint); + const sampled = scene.sampleHeight(heightPoint); + return Number.isFinite(sampled) ? sampled : undefined; + } + + /** + * Move queued stations onto the ground for a few milliseconds, then leave + * the rest to the next step. A station the scene has no height for keeps + * the fallback height, as it did when heights were read while drawing. + */ + + function refineHeights() { + heightTimer = null; + const started = performance.now(); + let moved = false; + while ( + heightQueue.length > 0 && + performance.now() - started < HEIGHT_REFINE_BUDGET_MS + ) { + const { record, lat, lon } = heightQueue.pop(); + const ground = sampleGroundHeight(lat, lon); + if (ground === undefined) continue; + record.point.position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + ground + POINT_HEIGHT_OFFSET_M, + ); + moved = true; + // The highlight and card of a selected station copy its position. + if (layerState._selectedKey === record.key) + parts.selection._selectStation(record.key); } + if (moved) governorRequestRender('bikeshare-heights'); + scheduleHeightRefinement(); + } - return Cesium.Cartesian3.fromDegrees(lon, lat, height); + function scheduleHeightRefinement() { + if (heightTimer !== null || heightQueue.length === 0) return; + heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS); + } + + /** Drop every queued station and the pending step. */ + + function stopHeightRefinement() { + heightQueue = []; + clearTimeout(heightTimer); + heightTimer = null; } /** @@ -178,7 +254,7 @@ export function createRendering({ id: key, }); - layerState._stationRenderMap.set(key, { + const record = { key, cityId, stationId: station.stationId, @@ -191,12 +267,19 @@ export function createRendering({ isInstalled: station.isInstalled, isRenting: station.isRenting, isReturning: station.isReturning, + }; + layerState._stationRenderMap.set(key, record); + heightQueue.push({ + record, + lat: Number(station.lat), + lon: Number(station.lon), }); runtime.stationKeys.add(key); } layerState._count = layerState._stationRenderMap.size; + scheduleHeightRefinement(); } /** @@ -224,6 +307,12 @@ export function createRendering({ layerState._stationRenderMap.delete(key); } + // Its stations no longer wait for a height. + heightQueue = heightQueue.filter( + ({ record }) => layerState._stationRenderMap.get(record.key) === record, + ); + if (heightQueue.length === 0) stopHeightRefinement(); + layerState._cityRuntime.delete(cityId); layerState._count = layerState._stationRenderMap.size; } @@ -280,5 +369,10 @@ export function createRendering({ ensureCityPoints, removeCityPoints, applyStatusToPoints, + stopHeightRefinement, + /** Stations still waiting for their ground height. */ + pendingHeightCount: () => heightQueue.length, + /** Whether a refinement step is waiting on a timer. */ + heightRefinementScheduled: () => heightTimer !== null, }; } diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index eff1ace98..7a902bc7d 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -1,6 +1,10 @@ import * as Cesium from 'cesium'; import { applyDisplayCourse, updatePlayback } from '../transit/movement.js'; import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js'; +import { + TERRAIN_NEED, + createTerrainSurface, +} from '../../services/terrainSurface.js'; import { COLD_CELL_FLUSH_MS, COLD_CELL_RETRY_MS, @@ -28,6 +32,11 @@ import { import { placeOnPath } from './pathBinding.js'; const SPRITE_PX = 64; +/** + * Terrain reads per degree for a train's height: about 3.7 m apart. At 11 m + * a train on fine tiles stood up to a couple of metres off the surface. + */ +const TERRAIN_GRID = 3e4; /** Billboard tints; the setter copies, so sharing one instance is safe. */ const FULL_TINT = Cesium.Color.WHITE; const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA); @@ -101,8 +110,11 @@ export function createRendering({ state, services, parts }) { neighborFloorM = null, coarseFloorCoord = null, } = services.ground; + // The terrain globe's loaded surface: the application's shared reader, or + // the layer's own when it is built without one. + const terrainSurface = + services.ground.terrainSurface || createTerrainSurface(); const sprites = new Map(); - const terrainPoint = new Cesium.Cartographic(); const scratchPosition = new Cesium.Cartesian3(); /** Scratch for the on-line position; place() runs every frame. */ const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN }; @@ -379,8 +391,18 @@ export function createRendering({ state, services, parts }) { function floorHeight(lat, lon, entry = null, stop = false) { const globe = state._viewer?.scene?.globe; if (globe?.show === true) { - Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); - const rendered = globe.getHeight?.(terrainPoint); + // A moving train is asked about every frame, at a new point each + // time. On a ~4 m grid it asks the terrain a few times a second + // instead, and a standing train not at all; the drawn height is + // smoothed along the track anyway (displayHeightFor). Read as a + // marker, never rationed: a train that starts on a fallback floor only + // creeps to the terrain at a railway's grade. + const rendered = terrainSurface.height( + state._viewer, + Math.round(lat * TERRAIN_GRID) / TERRAIN_GRID, + Math.round(lon * TERRAIN_GRID) / TERRAIN_GRID, + TERRAIN_NEED.MARKER, + ); if (Number.isFinite(rendered)) return answer(rendered + GROUND_FLOOR_LIFT_M, 3); } diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index 6a7c7d8f4..d6d9ad7e4 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -5,15 +5,12 @@ import { FLOOR_WARM_PER_POLL, TRANSIT_POLL_MS, HEIGHT_SAMPLE_MAX_ALTITUDE_M, - TERRAIN_HEIGHT_CACHE_MAX, - TERRAIN_HEIGHT_FRESH_MS, - TERRAIN_NEED, - TERRAIN_READ_BUDGET_MS, - TERRAIN_READ_WINDOW_MS, - TERRAIN_REFRESH_BUDGET_MS, - TERRAIN_SETTLE_MS, withinViewBounds, } from './policy.js'; +import { + TERRAIN_NEED, + createTerrainSurface, +} from '../../services/terrainSurface.js'; /** Surface preparation off the render path, shared by markers and history. */ export function createHeight({ state, services, parts }) { @@ -54,152 +51,11 @@ export function createHeight({ state, services, parts }) { return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M; } - // --- Loaded-terrain heights --- - // `globe.getHeight` casts a ray against the mesh of the tile drawn under the - // point. The fleet asks for every fix of every trail on each reread, so the - // answers are kept per point and re-read on a time budget; see policy.js. - const terrainPoint = new Cesium.Cartographic(); - /** Point key → `{ h, at }`: the height read there and when. */ - const terrainHeights = new Map(); - /** Floor-cell key → the last height read anywhere in that cell. */ - const terrainCells = new Map(); - let terrainWindowAt = -Infinity; - let terrainReadMs = 0; - let terrainRefreshMs = 0; - // Per need (history, path): the oldest read among the points this window - // left for later, and the bound the next window re-reads up to. - const terrainDeferredAt = [Infinity, Infinity]; - const terrainRefreshUpTo = [Infinity, Infinity]; - /** Points a pass left unread or due a re-read; see rereadFloors. */ - let terrainWaiting = 0; - /** When the camera last moved or the globe was last loading tiles. */ - let terrainChangedAt = -Infinity; - let cameraLon = NaN, - cameraLat = NaN, - cameraHeight = NaN; - const terrainStats = { reads: 0, reused: 0, deferred: 0, borrowed: 0 }; - - /** A number unique to a point on a `scale`-per-degree grid. */ - function gridKey(lat, lon, scale) { - return ( - (Math.round(lat * scale) + 90 * scale) * (360 * scale + 1) + - Math.round(lon * scale) + - 180 * scale - ); - } - - function clearTerrainHeights() { - terrainHeights.clear(); - terrainCells.clear(); - terrainWindowAt = -Infinity; - terrainReadMs = terrainRefreshMs = 0; - terrainDeferredAt.fill(Infinity); - terrainRefreshUpTo.fill(Infinity); - terrainWaiting = 0; - terrainChangedAt = -Infinity; - cameraLon = cameraLat = cameraHeight = NaN; - } - - /** - * The loaded terrain's height at a point, or undefined when it is not - * known yet. A point read within the reuse time answers from memory, and - * so does one read after the camera and the globe's tiles had settled: the - * surface under it cannot have changed since. Past - * the window's budget a held point keeps its last height and a new point - * borrows its floor cell's. `need` (TERRAIN_NEED) ranks the request: a - * vehicle's own position is read regardless, since a marker without a - * height is not drawn; the path a marker is drawn along comes next; a trail - * nobody is looking at takes what is left. Requests come in the same order - * every pass, so re-reads go to the longest-held points first: otherwise - * the first few would be re-read forever and the rest never. - */ - function terrainHeight(globe, lat, lon, need) { - const now = performance.now(); - if (now - terrainWindowAt >= TERRAIN_READ_WINDOW_MS) { - for (let i = 0; i < terrainDeferredAt.length; i++) { - terrainRefreshUpTo[i] = terrainDeferredAt[i]; - terrainDeferredAt[i] = Infinity; - } - terrainWindowAt = now; - terrainReadMs = terrainRefreshMs = 0; - // The globe redraws a point from another tile only after the camera - // moves or a tile arrives. - const camera = state._viewer?.camera?.positionCartographic; - if ( - globe.tilesLoaded === false || - camera?.longitude !== cameraLon || - camera?.latitude !== cameraLat || - camera?.height !== cameraHeight - ) { - terrainChangedAt = now; - cameraLon = camera?.longitude; - cameraLat = camera?.latitude; - cameraHeight = camera?.height; - } - } - const key = gridKey(lat, lon, 1e5); - const held = terrainHeights.get(key); - if ( - held && - (now - held.at < TERRAIN_HEIGHT_FRESH_MS || - held.at >= terrainChangedAt + TERRAIN_SETTLE_MS) - ) { - terrainStats.reused++; - return held.h; - } - const cellKey = gridKey(lat, lon, 1e3); - const marker = need === TERRAIN_NEED.MARKER; - // History may use a quarter of each budget; the path the rest. - const share = need === TERRAIN_NEED.HISTORY ? 0.25 : 1; - const affordable = - marker && !held - ? true - : terrainReadMs < TERRAIN_READ_BUDGET_MS * (marker ? 1 : share) && - (!held || - marker || - (terrainRefreshMs < TERRAIN_REFRESH_BUDGET_MS * share && - held.at <= terrainRefreshUpTo[need])); - if (!affordable) { - terrainStats.deferred++; - if (held) { - if (!marker && held.at < terrainDeferredAt[need]) - terrainDeferredAt[need] = held.at; - terrainWaiting++; - return held.h; - } - const borrowed = terrainCells.get(cellKey); - if (borrowed !== undefined) { - terrainStats.borrowed++; - return borrowed; - } - terrainWaiting++; - return undefined; - } - Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); - const rendered = globe.getHeight(terrainPoint); - const spent = performance.now() - now; - terrainReadMs += spent; - if (held) terrainRefreshMs += spent; - terrainStats.reads++; - // A tile that left the globe keeps the height it gave. - if (!Number.isFinite(rendered)) return held?.h; - if (held) { - held.h = rendered; - held.at = now; - } else { - if (terrainHeights.size >= TERRAIN_HEIGHT_CACHE_MAX) { - let drop = TERRAIN_HEIGHT_CACHE_MAX >> 2; - for (const old of terrainHeights.keys()) { - terrainHeights.delete(old); - if (--drop <= 0) break; - } - terrainCells.clear(); - } - terrainHeights.set(key, { h: rendered, at: now }); - } - terrainCells.set(cellKey, rendered); - return rendered; - } + // Loaded-terrain heights come from the application's shared reader, which + // keeps them per point and rations the reads (services/terrainSurface.js). + // A layer built without one keeps its own. + const sharedSurface = services.ground.terrainSurface; + const terrainSurface = sharedSurface || createTerrainSurface(); /** * The floor under a vehicle, and the best fallback if its own cell is cold. @@ -225,9 +81,7 @@ export function createHeight({ state, services, parts }) { Cesium.EllipsoidTerrainProvider ) return { cell, height: GROUND_FLOOR_LIFT_M, prior: null }; - const rendered = globe.getHeight - ? terrainHeight(globe, lat, lon, need) - : undefined; + const rendered = terrainSurface.height(state._viewer, lat, lon, need); if (Number.isFinite(rendered)) return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null }; } @@ -261,7 +115,7 @@ export function createHeight({ state, services, parts }) { function rereadFloors() { state._floorTimer = null; if (!state._enabled) return; - terrainWaiting = 0; + const waitingBefore = terrainSurface.waitingCount(); const onGround = nearGround(), candidates = []; const entries = [...state._vehicles.values()].filter( @@ -397,8 +251,7 @@ export function createHeight({ state, services, parts }) { // A pass that ran out of terrain budget before reaching every point // comes back for the rest. Each pass reads its budget, and points stop // falling due once the terrain settles, so this ends. - const waiting = terrainWaiting; - terrainWaiting = 0; + const waiting = terrainSurface.waitingCount() - waitingBefore; if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0) state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS); } @@ -430,7 +283,8 @@ export function createHeight({ state, services, parts }) { } state._floorAttempts = 0; state._heightDirty.clear(); - clearTerrainHeights(); + // The shared reader's heights belong to every layer still showing. + if (!sharedSurface) terrainSurface.clear(); } return { @@ -441,6 +295,6 @@ export function createHeight({ state, services, parts }) { rereadFloors, clear, /** Terrain read counters since the layer was created, for checks. */ - terrainStats: () => ({ ...terrainStats, held: terrainHeights.size }), + terrainStats: () => terrainSurface.stats(), }; } diff --git a/src/layers/transit/height.test.mjs b/src/layers/transit/height.test.mjs index c9bff2ef0..6f166a27d 100644 --- a/src/layers/transit/height.test.mjs +++ b/src/layers/transit/height.test.mjs @@ -2,9 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createHeight } from './height.js'; +import { FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_MS } from './policy.js'; import { - FLOOR_REREAD_ATTEMPTS, - FLOOR_REREAD_MS, TERRAIN_HEIGHT_CACHE_MAX, TERRAIN_HEIGHT_FRESH_MS, TERRAIN_NEED, @@ -12,7 +11,7 @@ import { TERRAIN_READ_WINDOW_MS, TERRAIN_REFRESH_BUDGET_MS, TERRAIN_SETTLE_MS, -} from './policy.js'; +} from '../../services/terrainSurface.js'; const LIFT = 1.5; @@ -179,12 +178,12 @@ test('undrawn trail history leaves most of the budget to the paths markers are d // New points, one per floor cell: history first, as a pass offers them. let lon = -71; const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height; - const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.HISTORY)); + const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.BACKGROUND)); const read = history.filter((h) => h !== null).length; assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget'); const path = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.PATH)); assert.equal(path.filter((h) => h !== null).length, TERRAIN_READ_BUDGET_MS - read, 'the path takes the rest'); - assert.equal(ask(TERRAIN_NEED.HISTORY), null); + assert.equal(ask(TERRAIN_NEED.BACKGROUND), null); assert.equal(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read'); assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1); }); diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 036b75974..3691af86f 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -12,8 +12,8 @@ import { isStaleVehicleFix, transitVehicleKey, vehicleReportTimeMs, - TERRAIN_NEED, } from './policy.js'; +import { TERRAIN_NEED } from '../../services/terrainSurface.js'; import { transitModeFor, transitModeResolved, diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index de9e831ab..09f86f078 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -77,44 +77,6 @@ export const FLOOR_WARM_PER_POLL = 300; export const FLOOR_REREAD_MS = 900; /** Re-reads before a poll's cycle gives up and waits for the next poll. */ export const FLOOR_REREAD_ATTEMPTS = 6; -/** - * Terrain heights read from the globe's loaded surface are kept per point - * (1e-5°, about a metre): one read is a ray cast against the mesh of the tile - * drawn there, not a table lookup, and a fleet asks for every trail fix on - * every reread. While the camera moves or tiles load, a kept height is reused - * this long before the point is read again. - */ -export const TERRAIN_HEIGHT_FRESH_MS = 2_000; -/** - * A height read once the camera has been still and the globe's tiles loaded - * for this long is kept until either changes: the globe draws a point from - * another tile only after one of them does. - */ -export const TERRAIN_SETTLE_MS = 5_000; -/** Terrain read budgets are counted over windows of this length. */ -export const TERRAIN_READ_WINDOW_MS = 200; -/** - * Milliseconds of terrain reads in one window. Past it a point never read - * takes the last height read in its ~111 m floor cell, or waits for a later - * pass; a vehicle's own position is read regardless. - */ -export const TERRAIN_READ_BUDGET_MS = 16; -/** - * The share of that budget re-reads of kept points may use, the longest-kept - * first, leaving the rest for points never read. - */ -export const TERRAIN_REFRESH_BUDGET_MS = 12; -/** - * How much a terrain height matters to what is drawn, lowest first. HISTORY is - * a past fix of a vehicle that is not selected (its trail is not drawn) and - * may use a quarter of each budget; PATH is the stretch a marker is drawn - * along, or any fix of the selected vehicle; MARKER is a vehicle's own - * position, read even when the window's budget is spent. - */ -export const TERRAIN_NEED = Object.freeze({ HISTORY: 0, PATH: 1, MARKER: 2 }); -/** Points kept; the oldest quarter is dropped past it. */ -export const TERRAIN_HEIGHT_CACHE_MAX = 60_000; - // --- Rendering --- /** On-screen glyph size in CSS px at the near end of the distance ramp. */ export const MARKER_PIXEL_SIZE = 20; diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 8b315d9fb..ee99c4681 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -8,7 +8,7 @@ import { } from '../../data/contactPlayback.js'; import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js'; import { fixDistanceM } from './movement.js'; -import { TERRAIN_NEED } from './policy.js'; +import { TERRAIN_NEED } from '../../services/terrainSurface.js'; import { transitStyleProfile } from '../../data/transitPresetStyle.js'; const MODE_COLOR = { @@ -119,7 +119,7 @@ export function createTrails({ state, services, parts, source }) { a, entry === selected || i >= activeIndex ? TERRAIN_NEED.PATH - : TERRAIN_NEED.HISTORY, + : TERRAIN_NEED.BACKGROUND, ); if (h !== null) correctHeight(entry.track, a.seq, h); } diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 3b4939fc5..f9413dadd 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -1,3 +1,4 @@ +import { createTerrainSurface } from './terrainSurface.js'; /** Construct an instance-owned groundFloor service with explicit dependencies. */ export function createGroundFloor({ terrain, signal }) { const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain; @@ -698,6 +699,8 @@ export function createGroundFloor({ terrain, signal }) { { once: true }, ); return { + // The terrain globe's loaded surface, read once per point for every layer. + terrainSurface: createTerrainSurface(), GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, diff --git a/src/services/terrainSurface.js b/src/services/terrainSurface.js new file mode 100644 index 000000000..dbf8f3f9a --- /dev/null +++ b/src/services/terrainSurface.js @@ -0,0 +1,254 @@ +import * as Cesium from 'cesium'; + +/** + * The height of the terrain globe's loaded surface at a point, kept per point + * and read within a time budget. + * + * `globe.getHeight` casts a ray against the mesh of the tile drawn under the + * point: geometry work, not a table lookup, and dearer the finer the tile. A + * layer that asks for every vehicle, trail fix or site on every pass asks the + * same few thousand points over and over, so the answers are kept and the + * reads are rationed. One instance serves every layer of an application, so + * the layers share one budget instead of each spending its own. + */ + +/** + * While the camera moves or tiles load, a kept height is reused this long + * before the point is read again. + */ +export const TERRAIN_HEIGHT_FRESH_MS = 2_000; +/** + * A height read once the camera has been still and the globe's tiles loaded + * for this long is kept until either changes: the globe draws a point from + * another tile only after one of them does. + */ +export const TERRAIN_SETTLE_MS = 5_000; +/** Read budgets are counted over windows of this length. */ +export const TERRAIN_READ_WINDOW_MS = 200; +/** + * Milliseconds of terrain reads in one window. Past it a point never read + * takes the last height read in its ~111 m floor cell, or waits for a later + * request; a MARKER point is read regardless. + */ +export const TERRAIN_READ_BUDGET_MS = 16; +/** + * The share of that budget re-reads of kept points may use, the longest-kept + * first, leaving the rest for points never read. + */ +export const TERRAIN_REFRESH_BUDGET_MS = 12; +/** + * How much a height matters to what is drawn, lowest first. BACKGROUND is a + * point nobody is looking at (the undrawn trail history of a vehicle) and may + * use a quarter of each budget; PATH is drawn geometry; MARKER is a thing's + * own position, read even when the window's budget is spent, since a marker + * without a height is not drawn. + */ +export const TERRAIN_NEED = Object.freeze({ + BACKGROUND: 0, + PATH: 1, + MARKER: 2, +}); +/** Points kept; the oldest quarter is dropped past it. */ +export const TERRAIN_HEIGHT_CACHE_MAX = 60_000; + +/** A number unique to a point on a `scale`-per-degree grid. */ +function gridKey(lat, lon, scale) { + return ( + (Math.round(lat * scale) + 90 * scale) * (360 * scale + 1) + + Math.round(lon * scale) + + 180 * scale + ); +} + +/** + * @returns {{ + * height: (viewer: object, lat: number, lon: number, need?: number) => number|undefined, + * observe: (viewer: object) => void, + * waitingCount: () => number, + * stats: () => {reads: number, reused: number, deferred: number, borrowed: number, held: number}, + * clear: () => void, + * }} + */ +export function createTerrainSurface() { + const point = new Cesium.Cartographic(); + /** Point key → `{ h, at }`: the height read there and when. */ + const heights = new Map(); + /** Floor-cell key → the last height read anywhere in that cell. */ + const cells = new Map(); + let globeRead = null; + let windowAt = -Infinity; + let readMs = 0; + let refreshMs = 0; + // Per need (background, path): the oldest read among the points this window + // left for later, and the bound the next window re-reads up to. + const deferredAt = [Infinity, Infinity]; + const refreshUpTo = [Infinity, Infinity]; + let waiting = 0; + /** When the camera last moved or the globe was last loading tiles. */ + let changedAt = -Infinity; + let cameraLon = NaN, + cameraLat = NaN, + cameraHeight = NaN; + const counters = { reads: 0, reused: 0, deferred: 0, borrowed: 0 }; + + function clear() { + heights.clear(); + cells.clear(); + globeRead = null; + windowAt = -Infinity; + readMs = refreshMs = 0; + deferredAt.fill(Infinity); + refreshUpTo.fill(Infinity); + changedAt = -Infinity; + cameraLon = cameraLat = cameraHeight = NaN; + } + + /** + * The globe redraws a point from another tile only after the camera moves + * or a tile arrives: note when either was last seen. + */ + function noteChange(viewer, globe, now) { + const camera = viewer.camera?.positionCartographic; + if ( + globe.tilesLoaded === false || + camera?.longitude !== cameraLon || + camera?.latitude !== cameraLat || + camera?.height !== cameraHeight + ) { + changedAt = now; + cameraLon = camera?.longitude; + cameraLat = camera?.latitude; + cameraHeight = camera?.height; + } + } + + /** The viewer's terrain globe, the one whose heights are held, or null. */ + function terrainGlobe(viewer) { + const globe = viewer?.scene?.globe; + if (globe?.show !== true || typeof globe.getHeight !== 'function') + return null; + // Another globe's surface is another surface. + if (globe !== globeRead) { + clear(); + globeRead = globe; + } + return globe; + } + + /** + * Look at the camera and the globe's tile loading without asking for a + * height. The surface only sees a change when it is asked something, so a + * caller that asks nothing while tiles load (it waits for them) calls this + * on its own walk; otherwise the load would pass unseen and the heights + * kept from before it would never be read again. + * @param {object} viewer + */ + function observe(viewer) { + const globe = terrainGlobe(viewer); + if (globe) noteChange(viewer, globe, performance.now()); + } + + /** + * The loaded terrain's height at a point, in metres above the ellipsoid, or + * undefined when it is not known yet or the viewer has no terrain globe + * showing. A point read within the reuse time answers from memory, and so + * does one read after the camera and the globe's tiles had settled: the + * surface under it cannot have changed since. Past the window's budget a + * kept point keeps its last height and a new point borrows its floor + * cell's. Requests tend to come in the same order every pass, so re-reads + * go to the longest-kept points first: otherwise the first few would be + * re-read forever and the rest never. + * @param {object} viewer The Cesium viewer whose globe is read. + * @param {number} lat Degrees. + * @param {number} lon Degrees. + * @param {number} [need] How much the answer matters; see TERRAIN_NEED. + */ + function height(viewer, lat, lon, need = TERRAIN_NEED.PATH) { + const globe = terrainGlobe(viewer); + if (!globe) return undefined; + const now = performance.now(); + if (now - windowAt >= TERRAIN_READ_WINDOW_MS) { + for (let i = 0; i < deferredAt.length; i++) { + refreshUpTo[i] = deferredAt[i]; + deferredAt[i] = Infinity; + } + windowAt = now; + readMs = refreshMs = 0; + noteChange(viewer, globe, now); + } + const key = gridKey(lat, lon, 1e5); + const held = heights.get(key); + if ( + held && + (now - held.at < TERRAIN_HEIGHT_FRESH_MS || + held.at >= changedAt + TERRAIN_SETTLE_MS) + ) { + counters.reused++; + return held.h; + } + const cellKey = gridKey(lat, lon, 1e3); + const marker = need === TERRAIN_NEED.MARKER; + // Background may use a quarter of each budget; drawn geometry the rest. + const share = need === TERRAIN_NEED.BACKGROUND ? 0.25 : 1; + const affordable = + marker && !held + ? true + : readMs < TERRAIN_READ_BUDGET_MS * (marker ? 1 : share) && + (!held || + marker || + (refreshMs < TERRAIN_REFRESH_BUDGET_MS * share && + held.at <= refreshUpTo[need])); + if (!affordable) { + counters.deferred++; + if (held) { + if (!marker && held.at < deferredAt[need]) deferredAt[need] = held.at; + waiting++; + return held.h; + } + const borrowed = cells.get(cellKey); + if (borrowed !== undefined) { + counters.borrowed++; + return borrowed; + } + waiting++; + return undefined; + } + Cesium.Cartographic.fromDegrees(lon, lat, 0, point); + const rendered = globe.getHeight(point); + const spent = performance.now() - now; + readMs += spent; + if (held) refreshMs += spent; + counters.reads++; + // A tile that left the globe keeps the height it gave. + if (!Number.isFinite(rendered)) return held?.h; + if (held) { + held.h = rendered; + held.at = now; + } else { + if (heights.size >= TERRAIN_HEIGHT_CACHE_MAX) { + let drop = TERRAIN_HEIGHT_CACHE_MAX >> 2; + for (const old of heights.keys()) { + heights.delete(old); + if (--drop <= 0) break; + } + cells.clear(); + } + heights.set(key, { h: rendered, at: now }); + } + cells.set(cellKey, rendered); + return rendered; + } + + return { + height, + observe, + /** + * Requests so far that were left unread or due a re-read. A caller that + * reads it before and after its own pass learns whether to come back. + */ + waitingCount: () => waiting, + /** Read counters since creation, for checks. */ + stats: () => ({ ...counters, held: heights.size }), + clear, + }; +} diff --git a/src/services/terrainSurface.test.mjs b/src/services/terrainSurface.test.mjs new file mode 100644 index 000000000..21fe3f22e --- /dev/null +++ b/src/services/terrainSurface.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + TERRAIN_NEED, + TERRAIN_READ_BUDGET_MS, + TERRAIN_READ_WINDOW_MS, + TERRAIN_SETTLE_MS, + createTerrainSurface, +} from './terrainSurface.js'; + +/** A viewer over a terrain globe whose every read costs `costMs` of a clock the test owns. */ +function world(t, { costMs = 0, surface = () => 100 } = {}) { + const clock = { now: 5_000_000 }; + t.mock.method(performance, 'now', () => clock.now); + const reads = []; + const model = { surface, costMs }; + const globe = () => ({ + show: true, + getHeight(point) { + const lat = Cesium.Math.toDegrees(point.latitude); + const lon = Cesium.Math.toDegrees(point.longitude); + reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`); + clock.now += model.costMs; + return model.surface(lat, lon); + }, + }); + const viewer = { + scene: { globe: globe() }, + camera: { + positionCartographic: { longitude: 0, latitude: 0, height: 900 }, + }, + }; + return { viewer, clock, reads, model, globe }; +} + +test('a viewer without a terrain globe showing has no terrain height', (t) => { + const { viewer, reads } = world(t); + const surface = createTerrainSurface(); + viewer.scene.globe.show = false; + assert.equal(surface.height(viewer, 42, -71), undefined); + assert.equal(surface.height({ scene: {} }, 42, -71), undefined); + assert.equal(surface.height(null, 42, -71), undefined); + assert.equal(reads.length, 0); +}); + +test('every caller of one surface shares its heights and its budget', (t) => { + const costMs = 2; + const { viewer, reads, clock } = world(t, { costMs }); + const surface = createTerrainSurface(); + // Two layers asking about the same street: the second reads nothing. + const transit = () => surface.height(viewer, 42.36, -71.06); + const rail = () => surface.height(viewer, 42.36, -71.06); + assert.equal(transit(), 100); + assert.equal(rail(), 100); + assert.equal(reads.length, 1); + + // And what one spends in a window the other cannot spend again. + const affordable = TERRAIN_READ_BUDGET_MS / costMs; + let lon = -72; + const fresh = () => surface.height(viewer, 42, (lon -= 0.001)); + for (let i = 0; i < affordable - 1; i++) assert.equal(fresh(), 100); + const before = surface.waitingCount(); + assert.equal(fresh(), undefined, 'the window is spent, whoever asks'); + assert.equal(surface.waitingCount(), before + 1); + assert.equal( + surface.height(viewer, 43, -73, TERRAIN_NEED.MARKER), + 100, + 'a marker is still read', + ); + clock.now += TERRAIN_READ_WINDOW_MS; + assert.equal(fresh(), 100); + assert.deepEqual(surface.stats(), { + reads: affordable + 2, + reused: 1, + deferred: 1, + borrowed: 0, + held: affordable + 2, + }); +}); + +test("another globe's surface is read afresh", (t) => { + const { viewer, reads, clock, model, globe } = world(t, { + surface: () => 10, + }); + const surface = createTerrainSurface(); + assert.equal(surface.height(viewer, 42.36, -71.06), 10); + clock.now += TERRAIN_SETTLE_MS; + assert.equal(surface.height(viewer, 42.36, -71.06), 10); + clock.now += 60_000; + model.surface = () => 250; + assert.equal(surface.height(viewer, 42.36, -71.06), 10, 'settled: kept'); + // The map source changed and the viewer has a new globe. + viewer.scene.globe = globe(); + assert.equal(surface.height(viewer, 42.36, -71.06), 250); + assert.equal(reads.length, 3); + assert.equal(surface.stats().held, 1); +}); + +test('observe() lets a caller that asks nothing while tiles load report the load', (t) => { + const { viewer, reads, clock, model } = world(t, { surface: () => 10 }); + const surface = createTerrainSurface(); + const read = () => surface.height(viewer, 42.36, -71.06); + read(); + clock.now += TERRAIN_SETTLE_MS; + read(); + clock.now += 60_000; + assert.equal(reads.length, 2, 'settled and kept'); + + // Finer tiles load and finish while nobody asks for a height. + model.surface = () => 14; + viewer.scene.globe.tilesLoaded = false; + clock.now += 1_000; + surface.observe(viewer); + viewer.scene.globe.tilesLoaded = true; + clock.now += 3_000; + assert.equal( + read(), + 14, + 'the height kept from before the load is read again', + ); + assert.equal(reads.length, 3); + surface.observe(null); + surface.observe({ scene: {} }); +});