Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 888662749a docs(changelog): name the shared terrain fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:39 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 0f4dda5cc6 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 <[email protected]>
2026-11-12 23:05:24 +01:00
22 changed files with 1257 additions and 263 deletions

No files matched your search

@@ -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, #1689).
+32 -16
View File
@@ -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 settings and screen position rather than observed brightness. Selected-trail
diagnostics report every condition even when selection fails. diagnostics report every condition even when selection fails.
On a terrain globe Transit reads each vehicle's and trail point's height from Heights of the terrain globe's loaded surface come from one shared reader,
the loaded surface (`globe.getHeight`, a ray cast against the drawn tile's `src/services/terrainSurface.js`, which the ground service hands to every layer
mesh) and keeps the answer per point, about a metre apart (`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
(`src/layers/transit/height.js`, constants in `policy.js`). While the camera drawn tile's mesh, so the reader keeps each answer per point, about a metre
moves or tiles load a kept height is read again after 2 s; a height read once apart. While the camera moves or tiles load a kept height is read again after
both have been still for 5 s is kept until either changes. Reads are budgeted 2 s; a height read once both have been still for 5 s is kept until either
at 16 ms per 200 ms window, 12 ms of it for re-reads, longest-kept first. A changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
vehicle's own position is always read; the path its marker is drawn along of it for re-reads, longest-kept first. A request ranks as a marker (always
comes next; the undrawn trail history of unselected vehicles may use a quarter read), drawn geometry, or background (a quarter of each budget). Past the
of each budget. Past the budget a kept point keeps its last height, a new budget a kept point keeps its last height and a new point takes the last
point takes the last height read in its ~111 m floor cell or waits, and the height read in its ~111 m floor cell or waits.
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 - Transit asks for every vehicle and trail fix: vehicle positions as markers,
for up to 19 s at a time over a 650-vehicle fleet while zooming. Verified the path a marker is drawn along next, the undrawn trail history of
against Boston's MBTA feed on a keyless terrain globe only; no steeper city unselected vehicles as background, and its reread cycle continues until no
has a keyless feed to check against. 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 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 stroke over 3.25 px dark backing. Other contact themes retain their existing
+1
View File
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer | | `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | | `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | | `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)`, Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and `update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
+8 -1
View File
@@ -11,7 +11,8 @@
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js" "src/sources/infrastructureData.js",
"src/services/terrainSurface.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -2056,6 +2057,7 @@
"src/layers/bikeshare/state.js", "src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js", "src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js", "src/layers/bikeshare/viewport.js",
"src/services/terrainSurface.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/i18n/index.js", "src/i18n/index.js",
@@ -3003,6 +3005,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
@@ -4034,6 +4037,7 @@
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/scopeMask.js", "src/scopeMask.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/data/pointerOwnerNames.js", "src/data/pointerOwnerNames.js",
@@ -4378,6 +4382,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
@@ -5038,6 +5043,7 @@
"src/renderGovernor.js", "src/renderGovernor.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
"src/services/terrainHeights.js", "src/services/terrainHeights.js",
@@ -5082,6 +5088,7 @@
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/services/terrainSurface.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
+5 -2
View File
@@ -168,7 +168,7 @@ export function createApplicationCatalog({
createApplicationCctv({ surface, source: sources.cctv }), createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }), createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }), createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }), createApplicationBikeshare({ surface, source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }), createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }), createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(), createApplicationOsmPlaces(),
@@ -198,7 +198,10 @@ export function createApplicationCatalog({
}), }),
createApplicationCyclones({ feed: sources.cyclones }), createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(), ...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices), ...createInfrastructureLayers({
...localGeoJsonServices,
terrainSurface: surface.groundFloor.terrainSurface,
}),
createApplicationCables({ source: sources.cables }), createApplicationCables({ source: sources.cables }),
createApplicationRail({ surface, source: sources.rail }), createApplicationRail({ surface, source: sources.rail }),
createApplicationFirms({ createApplicationFirms({
+9 -2
View File
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
/** Construct one layer using the application scene owners and a supplied source. */ /** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationBikeshare({ source }) { export function createApplicationBikeshare({ source, surface }) {
return createBikeshareLayer({ return createBikeshareLayer({
source, 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 },
},
}); });
} }
+1
View File
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
'infrastructureOverlayEntry.js', 'infrastructureOverlayEntry.js',
'localGeojsonCore.js', 'localGeojsonCore.js',
'localGeojsonLod.js', 'localGeojsonLod.js',
'terrainSurface.js',
]); ]);
assert.deepEqual( assert.deepEqual(
entry.imports, entry.imports,
+185 -5
View File
@@ -23,9 +23,10 @@ import {
} from '../renderGovernor.js'; } from '../renderGovernor.js';
/** The catalog's services, with the overlay host each test supplies. */ /** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) => const createLocalGeoJsonLayer = (options, services = {}) =>
createLayer(options, { createLayer(options, {
...localGeoJsonServices, ...localGeoJsonServices,
...services,
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
}); });
@@ -58,10 +59,15 @@ class MockLayerEvent {
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability. * @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior * @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
* (default: throws like a scene whose tiles are not sampleable yet). * (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({ async function createRealLocalLayerHarness({
sampleHeightSupported = false, sampleHeightSupported = false,
sampleHeight = () => { throw new Error('tiles not sampleable yet'); }, sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
points = null,
services = {},
} = {}) { } = {}) {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
globalThis.fetch = async () => ({ globalThis.fetch = async () => ({
ok: true, ok: true,
status: 200, 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', type: 'Feature',
id: 'real-dam', id: 'real-dam',
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } }, properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
setInputAction() {}, setInputAction() {},
destroy() {}, destroy() {},
}), }),
}); }, services);
try { try {
await layer.enable(viewer); await layer.enable(viewer);
} finally { } finally {
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle'); assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
globe.tilesLoaded = true; 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(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor'); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
terrain = 110; terrain = 110;
clock.advance(500); clock.advance(2_100);
env.preRender.raise(); env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry'); assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
terrain = 140; 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'); 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);
});
+134 -15
View File
@@ -11,6 +11,10 @@ import {
applyInfraEvictionGrace, applyInfraEvictionGrace,
shouldRecomputeInfraLod, shouldRecomputeInfraLod,
} from './localGeojsonLod.js'; } from './localGeojsonLod.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../services/terrainSurface.js';
const DEFAULT_LABEL_MAX = 900; const DEFAULT_LABEL_MAX = 900;
const DEFAULT_LABEL_GRID_PX = 132; 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. * 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
*/ */
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; 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. */ /** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5; export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
@@ -324,8 +337,20 @@ export function createLocalGeoJsonLayer(
governorRequestRender, governorRequestRender,
showOsmCredit, showOsmCredit,
hideOsmCredit, 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 _dataSource = null;
let _enabled = false; let _enabled = false;
let _clickHandler = null; let _clickHandler = null;
@@ -426,6 +451,42 @@ export function createLocalGeoJsonLayer(
clearTimeout(_groundRetryTimer); clearTimeout(_groundRetryTimer);
_groundRetryTimer = null; _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({ const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
sourceId: id, sourceId: id,
host: overlayHost, host: overlayHost,
@@ -442,6 +503,7 @@ export function createLocalGeoJsonLayer(
const source = _dataSource; const source = _dataSource;
_dataSource = null; _dataSource = null;
_stemRecords = []; _stemRecords = [];
_ground.queued = [];
_stemGeometryDirty = true; _stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY; _lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
removeEntityContextsForLayer(id); removeEntityContextsForLayer(id);
@@ -689,6 +751,8 @@ export function createLocalGeoJsonLayer(
// Store references for bounded stem scaling and native picking. // Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto; feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base; feature.__localBaseCartesian = base;
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
registerEntityContext(feature, { registerEntityContext(feature, {
id: `${id}:${recordId}`, id: `${id}:${recordId}`,
layerId: id, layerId: id,
@@ -739,7 +803,11 @@ export function createLocalGeoJsonLayer(
stemPositionBuffers, stemPositionBuffers,
stemPositionBufferIndex: 0, stemPositionBufferIndex: 0,
groundHeight, groundHeight,
// Degrees, as the terrain surface takes them.
latDeg,
lonDeg,
groundSampled: false, groundSampled: false,
groundSampleQueued: false,
lastGroundSampleMs: 0, lastGroundSampleMs: 0,
priority, priority,
entry: labels entry: labels
@@ -860,7 +928,13 @@ export function createLocalGeoJsonLayer(
_preRenderRemover = viewer.scene.preRender.addEventListener(() => { _preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (!_enabled || !_dataSource) return; if (!_enabled || !_dataSource) return;
const now = performance.now(); 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; _lastVisibilityUpdate = now;
const cameraPos = viewer.camera.positionWC; const cameraPos = viewer.camera.positionWC;
@@ -911,6 +985,9 @@ export function createLocalGeoJsonLayer(
_lastGroundSampleCapability = canSampleGround; _lastGroundSampleCapability = canSampleGround;
let groundRetryPending = false; let groundRetryPending = false;
let groundSampleProgress = 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. --- // --- Globe-LOD: recompute the active-stem set on camera moves. ---
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the // `_stemGeometryDirty` is set by moveEnd, the first enable, and the
@@ -972,9 +1049,10 @@ export function createLocalGeoJsonLayer(
const terrainFloorChanged = refreshLocalTerrainFloor( const terrainFloorChanged = refreshLocalTerrainFloor(
viewer, viewer,
record, record,
_ground,
); );
if (refreshStemGeometry || terrainFloorChanged) { if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now); updateLocalStemGeometry(viewer, record, now, _ground);
} else if ( } else if (
canSampleGround && canSampleGround &&
!record.groundSampled && !record.groundSampled &&
@@ -988,9 +1066,9 @@ export function createLocalGeoJsonLayer(
); );
if ( if (
distance < GROUND_SAMPLE_MAX_DISTANCE_M && 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) if (!wasGroundSampled && record.groundSampled)
@@ -1002,16 +1080,19 @@ export function createLocalGeoJsonLayer(
// the user happens to move. Schedule the frame the retry needs. // the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range, in-view records only, // Gated on a sampleable scene and in-range, in-view records only,
// so a far camera (or a keyless scene) stays fully idle; the // 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 ( if (
canSampleGround && canSampleGround &&
!record.groundSampled && !record.groundSampled &&
!record.groundSampleQueued &&
!groundRetryPending && !groundRetryPending &&
Cesium.Cartesian3.distance( Cesium.Cartesian3.distance(
viewer.camera.positionWC, viewer.camera.positionWC,
record.base, record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M && ) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record) groundSampleInView(viewer, record, _ground)
) { ) {
groundRetryPending = true; groundRetryPending = true;
} }
@@ -1022,6 +1103,7 @@ export function createLocalGeoJsonLayer(
// so the records still waiting get their own bounded run of retries. // so the records still waiting get their own bounded run of retries.
if (groundSampleProgress) _groundRetryArms = 0; if (groundSampleProgress) _groundRetryArms = 0;
if (groundRetryPending) scheduleGroundRetryRender(viewer); if (groundRetryPending) scheduleGroundRetryRender(viewer);
requestQueuedGroundFrame();
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort( const cohort = selectLocalInfrastructureOverlayCohort(
@@ -1108,7 +1190,7 @@ function insertLocalCellContender(contenders, record) {
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
} }
function refreshLocalTerrainFloor(viewer, record) { function refreshLocalTerrainFloor(viewer, record, ground) {
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
return false; return false;
@@ -1120,7 +1202,15 @@ function refreshLocalTerrainFloor(viewer, record) {
// Camera motion can refine terrain after the first successful depth sample. // 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 // 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. // 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 ( if (
!Number.isFinite(height) || !Number.isFinite(height) ||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || 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 * 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 * mistaken for an unseen base. Unseen ground is sampled on the first walk that
* sees it. * 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 camera = viewer.camera;
const globe = viewer.scene.globe; 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 = const known =
Number.isFinite(terrain) && Number.isFinite(terrain) &&
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; 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 (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) 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; record.lastGroundSampleMs = now;
const globe = viewer.scene.globe; const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false; if (globe?.show && globe.tilesLoaded === false) return false;
let sampled; let sampled;
const startedAt = performance.now();
try { try {
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
} catch { } catch {
return false; // tiles not ready; retry on a later bounded update return false; // tiles not ready; retry on a later bounded update
} finally {
ground.sampleMs += performance.now() - startedAt;
} }
if ( if (
!Number.isFinite(sampled) || !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 // 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 // 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 // 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 const terrainHeight = globe?.show
? globe.getHeight?.(record.carto) ? globe.getHeight?.(record.carto)
: undefined; : undefined;
@@ -1256,12 +1369,18 @@ function setLocalGroundHeight(record, height) {
record.entity.__localBaseCartesian = record.base; 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) const distance = Number.isFinite(knownDistance)
? knownDistance ? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) 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 // 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. // minimum made a marker hundreds of metres tall beside a nearby building.
const effectiveDistance = Math.max(distance, 1); const effectiveDistance = Math.max(distance, 1);
@@ -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']);
});
+3
View File
@@ -44,6 +44,7 @@ export function createLifecycle({
layerState._inFlightInfo = new Map(); layerState._inFlightInfo = new Map();
layerState._inFlightStatus = new Map(); layerState._inFlightStatus = new Map();
layerState._stationRenderMap = new Map(); layerState._stationRenderMap = new Map();
parts.rendering?.stopHeightRefinement?.();
layerState._clickHandler = null; layerState._clickHandler = null;
layerState._selectedKey = null; layerState._selectedKey = null;
layerState._selectedEntity = null; layerState._selectedEntity = null;
@@ -134,6 +135,7 @@ export function createLifecycle({
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities(); parts.viewport.deactivateAllCities();
parts.rendering?.stopHeightRefinement?.();
layerState._cityRuntime.clear(); layerState._cityRuntime.clear();
layerState._pointCollection.show = false; layerState._pointCollection.show = false;
layerState._count = 0; layerState._count = 0;
@@ -165,6 +167,7 @@ export function createLifecycle({
layerState._cameraChangedAttached = false; layerState._cameraChangedAttached = false;
} }
parts.ingestion.abortAllInFlight(); parts.ingestion.abortAllInFlight();
parts.rendering?.stopHeightRefinement?.();
if (layerState._pointCollection) { if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection); viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null; layerState._pointCollection = null;
+11
View File
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
export const POINT_HEIGHT_OFFSET_M = 2.0; 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. */ /** Hard cap on total rendered station points across all cities. */
export const MAX_TOTAL_POINTS = 8000; export const MAX_TOTAL_POINTS = 8000;
+106 -12
View File
@@ -11,7 +11,13 @@ import {
POINT_HEIGHT_OFFSET_M, POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS, MAX_TOTAL_POINTS,
COLOR_OUTLINE, COLOR_OUTLINE,
HEIGHT_REFINE_BUDGET_MS,
HEIGHT_REFINE_INTERVAL_MS,
} from './policy.js'; } from './policy.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../../services/terrainSurface.js';
export function createRendering({ export function createRendering({
state: layerState, state: layerState,
@@ -20,6 +26,14 @@ export function createRendering({
source, source,
}) { }) {
const { governorRequestRender } = services.render; 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. * 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. * Create a Cartesian3 position for a station at the fallback height. Its
* Falls back to a fixed small height offset if terrain sampling is unsupported. * 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. * @param {Object} station - Station info object with lat/lon.
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid. * @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 lon = Number(station?.lon);
const lat = Number(station?.lat); const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; 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 * The ground height under a station, or undefined when the scene cannot say.
if (layerState._viewer?.scene?.sampleHeightSupported) { * A terrain globe answers from its loaded surface, which is cheap. Where it
const carto = Cesium.Cartographic.fromDegrees(lon, lat); * cannot (the globe is hidden under photorealistic tiles, or draws no tile
const sampled = layerState._viewer.scene.sampleHeight(carto); * at the point) the scene renders the depth there, at a millisecond or more
if (Number.isFinite(sampled)) { * a point.
height = sampled + POINT_HEIGHT_OFFSET_M; */
}
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, id: key,
}); });
layerState._stationRenderMap.set(key, { const record = {
key, key,
cityId, cityId,
stationId: station.stationId, stationId: station.stationId,
@@ -191,12 +267,19 @@ export function createRendering({
isInstalled: station.isInstalled, isInstalled: station.isInstalled,
isRenting: station.isRenting, isRenting: station.isRenting,
isReturning: station.isReturning, isReturning: station.isReturning,
};
layerState._stationRenderMap.set(key, record);
heightQueue.push({
record,
lat: Number(station.lat),
lon: Number(station.lon),
}); });
runtime.stationKeys.add(key); runtime.stationKeys.add(key);
} }
layerState._count = layerState._stationRenderMap.size; layerState._count = layerState._stationRenderMap.size;
scheduleHeightRefinement();
} }
/** /**
@@ -224,6 +307,12 @@ export function createRendering({
layerState._stationRenderMap.delete(key); 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._cityRuntime.delete(cityId);
layerState._count = layerState._stationRenderMap.size; layerState._count = layerState._stationRenderMap.size;
} }
@@ -280,5 +369,10 @@ export function createRendering({
ensureCityPoints, ensureCityPoints,
removeCityPoints, removeCityPoints,
applyStatusToPoints, applyStatusToPoints,
stopHeightRefinement,
/** Stations still waiting for their ground height. */
pendingHeightCount: () => heightQueue.length,
/** Whether a refinement step is waiting on a timer. */
heightRefinementScheduled: () => heightTimer !== null,
}; };
} }
+25 -3
View File
@@ -1,6 +1,10 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js'; import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js'; import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
import {
TERRAIN_NEED,
createTerrainSurface,
} from '../../services/terrainSurface.js';
import { import {
COLD_CELL_FLUSH_MS, COLD_CELL_FLUSH_MS,
COLD_CELL_RETRY_MS, COLD_CELL_RETRY_MS,
@@ -28,6 +32,11 @@ import {
import { placeOnPath } from './pathBinding.js'; import { placeOnPath } from './pathBinding.js';
const SPRITE_PX = 64; 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. */ /** Billboard tints; the setter copies, so sharing one instance is safe. */
const FULL_TINT = Cesium.Color.WHITE; const FULL_TINT = Cesium.Color.WHITE;
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA); const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
@@ -101,8 +110,11 @@ export function createRendering({ state, services, parts }) {
neighborFloorM = null, neighborFloorM = null,
coarseFloorCoord = null, coarseFloorCoord = null,
} = services.ground; } = 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 sprites = new Map();
const terrainPoint = new Cesium.Cartographic();
const scratchPosition = new Cesium.Cartesian3(); const scratchPosition = new Cesium.Cartesian3();
/** Scratch for the on-line position; place() runs every frame. */ /** Scratch for the on-line position; place() runs every frame. */
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN }; 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) { function floorHeight(lat, lon, entry = null, stop = false) {
const globe = state._viewer?.scene?.globe; const globe = state._viewer?.scene?.globe;
if (globe?.show === true) { if (globe?.show === true) {
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); // A moving train is asked about every frame, at a new point each
const rendered = globe.getHeight?.(terrainPoint); // 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)) if (Number.isFinite(rendered))
return answer(rendered + GROUND_FLOOR_LIFT_M, 3); return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
} }
+15 -161
View File
@@ -5,15 +5,12 @@ import {
FLOOR_WARM_PER_POLL, FLOOR_WARM_PER_POLL,
TRANSIT_POLL_MS, TRANSIT_POLL_MS,
HEIGHT_SAMPLE_MAX_ALTITUDE_M, 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, withinViewBounds,
} from './policy.js'; } from './policy.js';
import {
TERRAIN_NEED,
createTerrainSurface,
} from '../../services/terrainSurface.js';
/** Surface preparation off the render path, shared by markers and history. */ /** Surface preparation off the render path, shared by markers and history. */
export function createHeight({ state, services, parts }) { export function createHeight({ state, services, parts }) {
@@ -54,152 +51,11 @@ export function createHeight({ state, services, parts }) {
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M; return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
} }
// --- Loaded-terrain heights --- // Loaded-terrain heights come from the application's shared reader, which
// `globe.getHeight` casts a ray against the mesh of the tile drawn under the // keeps them per point and rations the reads (services/terrainSurface.js).
// point. The fleet asks for every fix of every trail on each reread, so the // A layer built without one keeps its own.
// answers are kept per point and re-read on a time budget; see policy.js. const sharedSurface = services.ground.terrainSurface;
const terrainPoint = new Cesium.Cartographic(); const terrainSurface = sharedSurface || createTerrainSurface();
/** 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;
}
/** /**
* The floor under a vehicle, and the best fallback if its own cell is cold. * 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 Cesium.EllipsoidTerrainProvider
) )
return { cell, height: GROUND_FLOOR_LIFT_M, prior: null }; return { cell, height: GROUND_FLOOR_LIFT_M, prior: null };
const rendered = globe.getHeight const rendered = terrainSurface.height(state._viewer, lat, lon, need);
? terrainHeight(globe, lat, lon, need)
: undefined;
if (Number.isFinite(rendered)) if (Number.isFinite(rendered))
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null }; return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
} }
@@ -261,7 +115,7 @@ export function createHeight({ state, services, parts }) {
function rereadFloors() { function rereadFloors() {
state._floorTimer = null; state._floorTimer = null;
if (!state._enabled) return; if (!state._enabled) return;
terrainWaiting = 0; const waitingBefore = terrainSurface.waitingCount();
const onGround = nearGround(), const onGround = nearGround(),
candidates = []; candidates = [];
const entries = [...state._vehicles.values()].filter( 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 // 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 // comes back for the rest. Each pass reads its budget, and points stop
// falling due once the terrain settles, so this ends. // falling due once the terrain settles, so this ends.
const waiting = terrainWaiting; const waiting = terrainSurface.waitingCount() - waitingBefore;
terrainWaiting = 0;
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0) if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0)
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS); state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
} }
@@ -430,7 +283,8 @@ export function createHeight({ state, services, parts }) {
} }
state._floorAttempts = 0; state._floorAttempts = 0;
state._heightDirty.clear(); state._heightDirty.clear();
clearTerrainHeights(); // The shared reader's heights belong to every layer still showing.
if (!sharedSurface) terrainSurface.clear();
} }
return { return {
@@ -441,6 +295,6 @@ export function createHeight({ state, services, parts }) {
rereadFloors, rereadFloors,
clear, clear,
/** Terrain read counters since the layer was created, for checks. */ /** Terrain read counters since the layer was created, for checks. */
terrainStats: () => ({ ...terrainStats, held: terrainHeights.size }), terrainStats: () => terrainSurface.stats(),
}; };
} }
+4 -5
View File
@@ -2,9 +2,8 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createHeight } from './height.js'; import { createHeight } from './height.js';
import { FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_MS } from './policy.js';
import { import {
FLOOR_REREAD_ATTEMPTS,
FLOOR_REREAD_MS,
TERRAIN_HEIGHT_CACHE_MAX, TERRAIN_HEIGHT_CACHE_MAX,
TERRAIN_HEIGHT_FRESH_MS, TERRAIN_HEIGHT_FRESH_MS,
TERRAIN_NEED, TERRAIN_NEED,
@@ -12,7 +11,7 @@ import {
TERRAIN_READ_WINDOW_MS, TERRAIN_READ_WINDOW_MS,
TERRAIN_REFRESH_BUDGET_MS, TERRAIN_REFRESH_BUDGET_MS,
TERRAIN_SETTLE_MS, TERRAIN_SETTLE_MS,
} from './policy.js'; } from '../../services/terrainSurface.js';
const LIFT = 1.5; 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. // New points, one per floor cell: history first, as a pass offers them.
let lon = -71; let lon = -71;
const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height; 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; const read = history.filter((h) => h !== null).length;
assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget'); 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)); 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(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(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read');
assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1); assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1);
}); });
+1 -1
View File
@@ -12,8 +12,8 @@ import {
isStaleVehicleFix, isStaleVehicleFix,
transitVehicleKey, transitVehicleKey,
vehicleReportTimeMs, vehicleReportTimeMs,
TERRAIN_NEED,
} from './policy.js'; } from './policy.js';
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
import { import {
transitModeFor, transitModeFor,
transitModeResolved, transitModeResolved,
-38
View File
@@ -77,44 +77,6 @@ export const FLOOR_WARM_PER_POLL = 300;
export const FLOOR_REREAD_MS = 900; export const FLOOR_REREAD_MS = 900;
/** Re-reads before a poll's cycle gives up and waits for the next poll. */ /** Re-reads before a poll's cycle gives up and waits for the next poll. */
export const FLOOR_REREAD_ATTEMPTS = 6; 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 --- // --- Rendering ---
/** On-screen glyph size in CSS px at the near end of the distance ramp. */ /** On-screen glyph size in CSS px at the near end of the distance ramp. */
export const MARKER_PIXEL_SIZE = 20; export const MARKER_PIXEL_SIZE = 20;
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from '../../data/contactPlayback.js'; } from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js'; import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.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'; import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = { const MODE_COLOR = {
@@ -119,7 +119,7 @@ export function createTrails({ state, services, parts, source }) {
a, a,
entry === selected || i >= activeIndex entry === selected || i >= activeIndex
? TERRAIN_NEED.PATH ? TERRAIN_NEED.PATH
: TERRAIN_NEED.HISTORY, : TERRAIN_NEED.BACKGROUND,
); );
if (h !== null) correctHeight(entry.track, a.seq, h); if (h !== null) correctHeight(entry.track, a.seq, h);
} }
+3
View File
@@ -1,3 +1,4 @@
import { createTerrainSurface } from './terrainSurface.js';
/** Construct an instance-owned groundFloor service with explicit dependencies. */ /** Construct an instance-owned groundFloor service with explicit dependencies. */
export function createGroundFloor({ terrain, signal }) { export function createGroundFloor({ terrain, signal }) {
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain; const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
@@ -698,6 +699,8 @@ export function createGroundFloor({ terrain, signal }) {
{ once: true }, { once: true },
); );
return { return {
// The terrain globe's loaded surface, read once per point for every layer.
terrainSurface: createTerrainSurface(),
GROUND_FLOOR_LIFT_M, GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M,
+254
View File
@@ -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,
};
}
+125
View File
@@ -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: {} });
});