mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04308e45d | ||
|
|
9582f2f28b |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- ALPR camera badges no longer take depth samples while the overlay paints, or
|
||||
again each time the layer is switched back on. On a terrain globe their
|
||||
height comes from the loaded terrain and is remembered per camera; some
|
||||
badges move a few metres onto the drawn surface (mazy1998, #1706).
|
||||
@@ -1513,10 +1513,14 @@ tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
||||
badge whose cell has not resolved clamps until it does, then moves in place.
|
||||
Overlay anchors start on the same floor and write marker properties only on
|
||||
change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||
replaced once by the rendered surface under it (two depth samples per paint,
|
||||
accepted only within -30/+40 m of the floor cell), so badges do not float or
|
||||
sink on slopes; wedge corners follow their own floor cells instead of staying
|
||||
level with the apex. Unknown bearings never acquire a wedge. The selected
|
||||
replaced once by the rendered surface under it (accepted only within -30/+40 m
|
||||
of the floor cell), so badges do not float or sink on slopes; wedge corners
|
||||
follow their own floor cells instead of staying level with the apex. The
|
||||
surface is read outside the overlay paint, one camera per frame: a terrain
|
||||
globe answers from its loaded mesh (`globe.getHeight`) and only photoreal
|
||||
tiles are depth-sampled (`scene.sampleHeight`). Answers are kept per camera
|
||||
until the map source changes, so a refresh or turning the layer off and on
|
||||
samples nothing again. Unknown bearings never acquire a wedge. The selected
|
||||
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
|
||||
of 359.5° or more reads `0°` rather than `360°`. The row reads
|
||||
`N nearby · M on screen`; when every loaded camera is outside the view it adds
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
# Slowdown audit, November 2026
|
||||
|
||||
A sweep of `main` (`c02ede3bb`, 13 November 2026) for bugs that freeze the page
|
||||
or hold the frame rate down. This page lists what was found, why it happens and
|
||||
how to fix it, and names the pull request that carries each fix.
|
||||
|
||||
[PERFORMANCE.md](PERFORMANCE.md) is the measured baseline of a healthy build;
|
||||
this page is the list of things that fall outside it.
|
||||
|
||||
## How it was measured
|
||||
|
||||
- Keyless dev server, headless Chrome at 1440 x 900 on Apple silicon (Metal
|
||||
through ANGLE), default globe map.
|
||||
- Per scenario: load, a spell at rest, scripted drags and wheel zooms, then a
|
||||
settle. Long tasks were counted with a `PerformanceObserver`, the hot function
|
||||
named from a CPU profile, and `globe.getHeight`, `scene.sampleHeight`,
|
||||
`scene.pick` and friends wrapped to count calls and time.
|
||||
- A long task is one main-thread task over 50 ms. With no layers on, the
|
||||
baseline is two at load (longest about 240 ms) and none afterwards, with zero
|
||||
frames rendered while the camera is still.
|
||||
- Four profiling sessions ran on one machine at the same time, so absolute
|
||||
timings carry some noise. Ratios against the baseline are what to read.
|
||||
|
||||
Nothing here was measured on Google Photorealistic 3D Tiles, on a discrete or
|
||||
integrated PC GPU, or with keyed feeds (AISStream, FIRMS, TomTom). Those rows
|
||||
are marked "read in code".
|
||||
|
||||
## Findings and fixes
|
||||
|
||||
Worst first. "Fix" names the pull request that carries the fix.
|
||||
|
||||
### Measured
|
||||
|
||||
| # | Where | What happens | Fix |
|
||||
| ---: | --- | --- | --- |
|
||||
| 1 | Bike Share, local data layers, Transit, Rail | 18 s freeze switching Bike Share on over New York (2,519 stations); 2.7 s on Data Centers; multi-second freezes with Transit while zooming | #1688, #1689 |
|
||||
| 2 | Shipwrecks | 3 to 5 s freeze when the markers are built over loaded terrain; a share link with the layer on held the loading screen for 22.9 s in one run | #1703 |
|
||||
| 3 | Camera | Rendering stops until reload: at a whole-globe view looking straight down, pan a few times, then scroll one notch | #1704 |
|
||||
| 4 | Street Traffic | Zooming at 250 m over Manhattan: 25 long tasks in 24 s, longest 746 ms, 6.6 s in total | #1705 |
|
||||
| 5 | ALPR cameras | Depth samples inside the overlay paint, repeated on every re-enable: 240 samples and about 1 s across eight toggles. A first-sample freeze of up to 1.9 s was seen in the sweep but not on a second day | #1706 |
|
||||
| 6 | Wind | First zoom after load: up to 14 long tasks (longest 414 ms). Re-measured as one-off shader links, not a standing cost: see below | none proposed |
|
||||
| 7 | Submarine cables | About 2 s of long tasks on load, heap briefly at 1 GB, about 0.5 s again on each re-enable | #1707 (first step), #1722 (batched primitives) |
|
||||
| 8 | Live Flights at whole-globe view with detection labels | Fine on the test machine; at 4x CPU throttle, 8 fps with 143 long tasks in 15 s | #1708 (fleet pass), #1719 (overlay) |
|
||||
| 9 | Radio | 7 to 10 long tasks when the directory loads, longest 447 ms | #1709 |
|
||||
| 10 | Pointer pick | Not reproduced on a second look: see below | none needed |
|
||||
| 11 | CCTV | A still-image camera holds continuous rendering at 60 frames a second although its picture changes about every 10 s | #1710 |
|
||||
|
||||
### Read in code
|
||||
|
||||
| # | Where | What happens | Fix |
|
||||
| ---: | --- | --- | --- |
|
||||
| 12 | `/api/firms` | The whole fire set is filtered, serialised and sent uncompressed on every request; 15 to 55 MB estimated in a heavy season | #1711 |
|
||||
| 13 | AIS store | Both vessel maps are walked on every position message, and sorted whole once past the cap | #1712 |
|
||||
| 14 | Satellite clouds on the 3D Tiles map | A CPU pass over a 4096 x 2048 image, 90 to 180 ms measured on a synthetic canvas, twice in a row | #1713 (canvas path), #1721 (shader) |
|
||||
| 15 | NVG and FLIR styles | Over 100 texture reads per pixel, and 60 renders a second while the style is on. Measured afterwards: the reads cost 0.4 ms a frame at 1440 x 900 and 1.2 ms at 2560 x 1440 | #1718 |
|
||||
| 16 | Wind server | Full 0.25 degree GRIB fields decoded on the event loop. Measured afterwards: 68 ms blocked per warm refresh, about 250 ms on the first, about 400 ms when all six keys refresh together | #1717 |
|
||||
| 17 | Imported geo files and other per-feature ground clamps | Measured afterwards: imported files do not freeze; see below | none needed |
|
||||
|
||||
## Cause and fix, per finding
|
||||
|
||||
### 1. Terrain height reads
|
||||
|
||||
`globe.getHeight` casts a ray against the mesh of the tile under the point, and
|
||||
`scene.sampleHeight` renders a depth pass. Transit asked for every trail fix of
|
||||
every vehicle several times per poll; Bike Share sampled every station in one
|
||||
go. The fix keeps heights per point in one shared reader with a read budget
|
||||
(`src/services/terrainSurface.js`) and spreads the depth samples across frames.
|
||||
|
||||
### 2. Shipwrecks
|
||||
|
||||
`rebuild()` in `src/layers/shipwrecks/index.js` adds 7,958 billboards with
|
||||
`heightReference: CLAMP_TO_GROUND`. Each constructor runs
|
||||
`Billboard._updateClamping`, which intersects a ray with the terrain mesh,
|
||||
synchronously: 4.8 of the 4.9 s. The cost is only paid when terrain is loaded
|
||||
at the time the markers are built, so a cold start with the layer in the URL
|
||||
sometimes escapes it.
|
||||
|
||||
Fix: do not clamp. Wrecks are at sea and the markers already ignore the depth
|
||||
test, so place them on the ellipsoid with `HeightReference.NONE`.
|
||||
|
||||
### 3. Camera: rendering stops after pan then zoom
|
||||
|
||||
Pure Cesium; no application code is involved. Above 1,000 km,
|
||||
`ScreenSpaceCameraController` `handleZoom` rotates the camera about the cross
|
||||
product of the picked point's normal and the screen centre's normal whenever
|
||||
their dot product is between 0 and 1. With the pointer on the exact centre pixel
|
||||
the two are the same vector, yet `n · n` often rounds to `0.9999999999999999`,
|
||||
so the check passes with a zero axis. `Camera.rotate` then throws
|
||||
`DeveloperError: normalized result is not a number` from
|
||||
`Scene.initializeFrame`, outside the scene's own try/catch, so
|
||||
`scene.renderError` never fires and Cesium stops its render loop for good. The
|
||||
pans in the first reproduction only moved the camera to a pose where the
|
||||
rounding goes that way.
|
||||
|
||||
Fix: make `camera.rotate` ignore a zero or non-finite axis, and recover from any
|
||||
stopped loop by restoring the last drawn camera pose and restarting it a bounded
|
||||
number of times before showing Cesium's error panel.
|
||||
|
||||
### 4. Street Traffic road loading
|
||||
|
||||
Three costs in one task after the detail tiles arrive, in
|
||||
`src/layers/traffic/source.js`:
|
||||
|
||||
- `outsideDetailTiles` clips every coarse z12 road against every loaded detail
|
||||
tile (up to 16) for four regions.
|
||||
- The composed set is then handed on with `replace: true`, so
|
||||
`ingestion.js` parses every road again although each tile was parsed as it
|
||||
streamed.
|
||||
- `parseRoads` (`model.js`) asks `cachedGroundFloor` once per coordinate, and
|
||||
each call builds several string keys.
|
||||
|
||||
Fix: clip once per coarse tile against the detail tiles' union box and slice the
|
||||
work across frames (or do it in the vector-tile worker); reuse the roads parsed
|
||||
while streaming and parse only the clipped coarse remainder; look the floor up
|
||||
once per floor cell within a parse.
|
||||
|
||||
This is separate from the one-viewport-tile-at-a-time loading recorded in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
|
||||
|
||||
### 5. ALPR cameras
|
||||
|
||||
`refineAnchors` and `anchorFor` in `src/layers/alpr/overlay.js` call
|
||||
`scene.sampleHeight` from the overlay's paint, four per paint. Each is a depth
|
||||
render and a `readPixels`; the first one also compiles the pick shader. Results
|
||||
are kept on the entity, so a rebuild or a re-enable samples everything again.
|
||||
|
||||
Fix: on a terrain globe read `globe.getHeight` through the shared terrain reader
|
||||
instead of sampling; keep the answers in a map keyed by camera that outlives the
|
||||
entities; move what sampling remains out of the paint into a queue of one per
|
||||
frame.
|
||||
|
||||
### 6. Wind
|
||||
|
||||
The first reading blamed the layer's 916,476 vertices for slow depth reads
|
||||
during zoom. Measuring again did not support that: depth reads cost the same
|
||||
with and without Wind, hiding every wind primitive left pick time unchanged, and
|
||||
the stalls appear on the first zoom only, as shader programs link (seven
|
||||
programs, about 0.5 s, long tasks up to 166 ms). Later zooms had none.
|
||||
|
||||
The roughly 30 renders a second are the animation itself. It is already capped
|
||||
at 30 Hz and stops when paused, hidden, out of view or under reduced motion;
|
||||
lowering the rate would change how the layer looks. `disable()` releases the
|
||||
baked geometry on purpose, at the price of one task of about 130 ms on
|
||||
re-enable.
|
||||
|
||||
A third look, timing every program link on a preview build, settled it: Wind
|
||||
owns two shader programs, both link during load in 9 to 18 ms, and none ever
|
||||
linked during a zoom. The programs that do link on a zoom are Cesium's own
|
||||
globe-surface variants, with Wind on or off, and Cesium links them
|
||||
synchronously. No fix is proposed.
|
||||
|
||||
### 7. Submarine cables
|
||||
|
||||
`src/layers/submarineCables/ingestion.js` loads both collections through
|
||||
`Cesium.GeoJsonDataSource` with `clampToGround`. Switching the layer on is one
|
||||
task of about 1.4 s, and 1.2 s of it is Cesium reading the terrain height for
|
||||
the 1,917 ground-clamped landing markers, all created in the first frame,
|
||||
including the ones behind the globe.
|
||||
|
||||
Fix, first step: start the landing markers hidden and let the existing horizon
|
||||
sweep show the visible ones a few dozen per frame.
|
||||
|
||||
Fix, second step: draw from batched primitives in place of the entity data
|
||||
sources: one ground polyline primitive for the lines, one billboard collection
|
||||
for the landing markers, one point collection and one static polyline primitive
|
||||
for the reference stems, all kept while the layer is off. That removes the
|
||||
visualiser tasks, the stall on switch-off and on re-enable, and about 900 MB of
|
||||
heap. The first switch-on still links shaders and builds the ground-line
|
||||
geometry once.
|
||||
|
||||
### 8. Live Flights at whole-globe view
|
||||
|
||||
Two costs that scale with the 7,500 aircraft of a full poll:
|
||||
|
||||
- The fleet pass (`src/layers/flights/rendering.js`, every 80 ms) dead-reckons
|
||||
each visible aircraft and asks `cachedGroundFloor` for it, including aircraft
|
||||
at cruise altitude that can never touch the floor. Each lookup formats numbers
|
||||
to strings several times and rewrites a cache entry: 40% of the busy time.
|
||||
- The detection overlay (`src/data/detection.js` `_drawOverlay`) collects every
|
||||
detectable object and strokes every bracket on every frame.
|
||||
|
||||
Fix for the first: skip the floor path for a contact clearly above any terrain,
|
||||
make the floor-cell key a number, and stop promoting the cache entry on every
|
||||
read.
|
||||
|
||||
The second turned out smaller than it looked. The bracket strokes are already
|
||||
one path per colour, and collecting the objects is about 3 ms of a 56 ms frame
|
||||
at 4x throttle once the first fix is in. What costs is the per-object loop in
|
||||
`_drawOverlay` (projection, occlusion, alpha, twelve path calls per bracket).
|
||||
|
||||
Fix for the second: keep what a sweep built and reuse it on the next paint when
|
||||
the view and every contact are exactly what it was built from, compared value
|
||||
by value so no layer has to announce a change. Between two fleet passes most
|
||||
paints qualify, which cuts frame work by a fifth to a third on a still camera
|
||||
at full speed. At 4x throttle a frame outlasts a fleet pass, no paint
|
||||
qualifies, and the gain is a few milliseconds; the fleet pass is what
|
||||
dominates there.
|
||||
|
||||
### 9. Radio
|
||||
|
||||
`stationTags()` in `src/layers/radio/categories.js` splits and normalises a
|
||||
station's tags on every `hasTag` call, and `buildRadioCategories` runs that for
|
||||
every station against every category, twice.
|
||||
|
||||
Fix: normalise once per station at ingest and count categories from that.
|
||||
|
||||
### 10. Pointer pick
|
||||
|
||||
Three sessions saw a single `scene.pick` of 207 to 342 ms during a gesture and
|
||||
attributed it to `pickPointer` in `src/voice/pointerPick.js`. Timing every pick
|
||||
across pointer rests, drags and wheel bursts did not bear that out: of 78 picks
|
||||
with CCTV on over London all but two took 3 to 8 ms, and with five heavy layers
|
||||
on, 81 picks took 5 to 30 ms apart from the first (135 ms). `pickPointer` picks
|
||||
at most once per pointer rest.
|
||||
|
||||
The slow pick is the first pick after a layer appears, once per page load, most
|
||||
likely the pick shader compiling, and its stack is CCTV's own hover pick
|
||||
(`src/layers/cctv/hover.js`). No fix is proposed: removing the dwell pick would
|
||||
only move the one-off cost to the next click.
|
||||
|
||||
### 11. CCTV still-image cameras
|
||||
|
||||
`src/layers/cctv/projection.js` holds continuous rendering for as long as any
|
||||
projected camera is active and redraws the projection every frame.
|
||||
|
||||
Fix: hold only for video. For a still image, request one render when a new image
|
||||
lands or while a focus fade runs.
|
||||
|
||||
### 12. `/api/firms`
|
||||
|
||||
`server/providers/firms.js` builds the payload and calls `JSON.stringify` per
|
||||
request, with no kept body and no compression, and the client refetches every
|
||||
ten minutes.
|
||||
|
||||
Fix: keep the response bytes and their gzip and serve those, as the OpenSky
|
||||
provider does. The route filters to the trailing 24 hours at serve time, so the
|
||||
bytes can only be reused while that filter keeps the same records (about a
|
||||
minute at a time): bursts and several clients gain most, and every response is
|
||||
about seven times smaller.
|
||||
|
||||
### 13. AIS store
|
||||
|
||||
`server/providers/vessels/ais-store.js` calls `pruneAisStreamCache()` for every
|
||||
message; the static-data map is never pruned.
|
||||
|
||||
Fix: prune on a timer, evict in insertion order, and drop a vessel's static
|
||||
entry with the vessel.
|
||||
|
||||
### 14. Satellite clouds on the 3D Tiles map
|
||||
|
||||
`src/layers/weather/infraredImage.js` runs `getImageData`, a per-pixel power
|
||||
curve and `putImageData` over the whole canvas.
|
||||
|
||||
Fix: a 256-entry lookup table applied in place for the canvas path, which
|
||||
halves it; then, for the shell on the 3D Tiles map, the same curve in its
|
||||
fragment shader from a strength uniform, so the image is never read back and a
|
||||
strength change builds no new image.
|
||||
|
||||
### 15. NVG and FLIR styles
|
||||
|
||||
`src/styles/surveillance.js` loops 11 x 11 texture reads per pixel and
|
||||
`src/styles/thermal.js` totals 106. Timed on the GPU, those loops cost about
|
||||
0.4 ms a frame at 1440 x 900 and 1.2 ms at 2560 x 1440 on the test machine:
|
||||
real, but small there. The standing cost is that a style with animated grain
|
||||
holds continuous rendering at 60 frames a second.
|
||||
|
||||
Fix: split the bloom into a row pass and a column pass, which gives the same
|
||||
picture from a fifth to a half of the reads, and step the grain's clock at
|
||||
15 Hz, asking for one frame per step instead of holding the render loop. The
|
||||
grain then changes 15 times a second, which is visible in motion.
|
||||
|
||||
Switching on any style stage at all costs more than the bloom did (1.8 ms a
|
||||
frame at 1440 x 900 for the one-line noir style). That was not looked into.
|
||||
|
||||
### 16. Wind server
|
||||
|
||||
`server/providers/wind/decode.js` decodes about a million doubles per message
|
||||
synchronously, hourly, for six keys.
|
||||
|
||||
Fix: decode on a worker thread, and keep the sampled U and V of the last
|
||||
forecast file per model so that choosing an overlay fetches only its field.
|
||||
|
||||
### 17. Per-feature ground clamps
|
||||
|
||||
`heightReference: CLAMP_TO_GROUND` per feature is also set in
|
||||
`installations/rendering.js`, `alpr/presentation.js`, `ghostMaps/index.js`,
|
||||
`earthquakes/index.js`, `airQuality/index.js`, `news/index.js` and
|
||||
`osmPlaces/index.js`. Their counts were small in these runs (30 to 1,500) and
|
||||
none froze.
|
||||
|
||||
Imported geo files were the one unbounded case, and measuring them cleared
|
||||
them: 20,000 generated points (the import cap) finish in about 1.5 s with a
|
||||
longest task near 0.5 s, because `settleGroundPins` in
|
||||
`src/data/geoFileImport.js` already places pins with `HeightReference.NONE` and
|
||||
resolves heights 256 at a time with a yield between batches. No fix is needed.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
- Start-up of a production build: viewer ready in about 0.5 s, loading screen
|
||||
gone at 1.6 s, three long tasks totalling about 300 ms.
|
||||
- Idle: no frames rendered with no layers on; every render hold was released
|
||||
when its layer went off.
|
||||
- Leaks: all 41 layers toggled three times and ten of them eight times; no
|
||||
timer, scene listener, primitive or data source grew per cycle. The heap
|
||||
levelled off near 320 MB over 7.5 minutes of toggling eleven layers.
|
||||
- At most one long task after load: Military, Satellites (including 11,903
|
||||
Starlink points), Earthquakes, News, Wildlife, GNSS, Drone Airspace, Military
|
||||
Installations, Fire Perimeters, rain radar, lightning, and satellite clouds on
|
||||
the globe map.
|
||||
- Live Flights at city scale with the same 7,500 aircraft loaded: 60 fps, no
|
||||
long tasks.
|
||||
|
||||
## Not covered
|
||||
|
||||
- Google Photorealistic 3D Tiles: nothing was run there. Depth sampling is the
|
||||
only height source on that map, so findings 4, 5 and 17 may be worse.
|
||||
- Live Vessels, FIRMS with data, TomTom traffic, Local ADS-B.
|
||||
- Frame rate on anything but one Apple silicon machine.
|
||||
- One all-layers run over Manhattan grew the heap from 346 to 638 MB in 60 s;
|
||||
whether that is retained was not established.
|
||||
- One Satellites load showed a 5.2 s long task once and never again in four more
|
||||
loads.
|
||||
@@ -6,9 +6,6 @@ specification and should not be used to predict performance on untested systems.
|
||||
The original capture artifacts are not included here, so this page records
|
||||
results rather than defining a runnable benchmark.
|
||||
|
||||
For slowdowns found since, with their causes and fixes, see
|
||||
[PERFORMANCE-AUDIT-2026-11.md](PERFORMANCE-AUDIT-2026-11.md).
|
||||
|
||||
## Test context
|
||||
|
||||
The baseline was captured on 22 August 2026 with these conditions:
|
||||
|
||||
+139
-57
@@ -4,10 +4,10 @@ import {
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
ANCHOR_SAMPLES_PER_PAINT,
|
||||
REMEMBERED_ANCHORS,
|
||||
SURFACE_BELOW_FLOOR_M,
|
||||
SURFACE_ABOVE_FLOOR_M,
|
||||
SURFACE_SAMPLES_PER_PAINT,
|
||||
SURFACE_SAMPLES_PER_FRAME,
|
||||
} from './policy.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
@@ -19,11 +19,17 @@ import {
|
||||
validAlprGroundHeight,
|
||||
} from './visuals.js';
|
||||
|
||||
const nextAnimationFrame = (step) =>
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame(step)
|
||||
: setTimeout(step, 16);
|
||||
|
||||
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
|
||||
export function createAlprOverlay({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange = () => {},
|
||||
requestFrame = nextAnimationFrame,
|
||||
}) {
|
||||
let lane,
|
||||
removeMapListener,
|
||||
@@ -33,7 +39,12 @@ export function createAlprOverlay({
|
||||
hits = [],
|
||||
images = [],
|
||||
destroyed = false,
|
||||
lastCamera = null;
|
||||
lastCamera = null,
|
||||
sampling = false;
|
||||
// Rendered-surface answers by camera id. Entities are rebuilt by a refresh
|
||||
// and by turning the layer off and on; keeping the answers on them meant
|
||||
// every rebuild sampled the same cameras again.
|
||||
const surfaceAnswers = new Map();
|
||||
const requestPaint = () => {
|
||||
if (!destroyed && state.enabled) lane?.requestPaint();
|
||||
};
|
||||
@@ -92,38 +103,72 @@ export function createAlprOverlay({
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
nativeVisible(entity, true);
|
||||
}
|
||||
surfaceAnswers.clear();
|
||||
state.lastAnchorSampleAt = 0;
|
||||
retryUntil = Date.now() + 15000;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
function anchorFor(record, entity, budget) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
function rememberSurface(record, height, checked) {
|
||||
surfaceAnswers.delete(record.id);
|
||||
surfaceAnswers.set(record.id, {
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
height,
|
||||
checked,
|
||||
});
|
||||
if (surfaceAnswers.size > REMEMBERED_ANCHORS)
|
||||
surfaceAnswers.delete(surfaceAnswers.keys().next().value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered surface height under a camera. A terrain globe answers from its
|
||||
* loaded mesh; only photoreal tiles need `sampleHeight`, which renders depth
|
||||
* and compiles its shaders on first use (a freeze of up to 1.9 s at load).
|
||||
*/
|
||||
function surfaceHeight(record, entity) {
|
||||
const scene = state.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
// A floor-placed marker anchors where its native badge sits; only a
|
||||
// marker still waiting for its floor samples the rendered surface, and
|
||||
// each paint samples a few at most (each sample is a depth render).
|
||||
if (scene.globe?.show) return scene.globe.getHeight?.(location);
|
||||
if (!scene.sampleHeightSupported) return undefined;
|
||||
try {
|
||||
return scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The anchor a paint can have without sampling, or null while it waits for one. */
|
||||
function anchorFor(record, entity) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
const scene = state.viewer.scene;
|
||||
const answer = surfaceAnswers.get(record.id);
|
||||
if (
|
||||
answer?.latitude === record.latitude &&
|
||||
answer.longitude === record.longitude
|
||||
) {
|
||||
setAnchor(record, entity, answer.height);
|
||||
if (answer.checked)
|
||||
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
|
||||
return entity.gevAlprCanvasPosition;
|
||||
}
|
||||
// A floor-placed marker anchors where its native badge sits. A marker
|
||||
// still waiting for its floor reads the terrain globe, or on photoreal
|
||||
// tiles waits for sampleAnchors().
|
||||
let height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
|
||||
else if (scene.sampleHeightSupported) {
|
||||
if (budget.samples <= 0) return null;
|
||||
budget.samples -= 1;
|
||||
try {
|
||||
height = scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
else if (scene.globe.show)
|
||||
height = scene.globe.getHeight?.(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
);
|
||||
if (!validAlprGroundHeight(height)) return null;
|
||||
setAnchor(record, entity, height);
|
||||
return entity.gevAlprCanvasPosition;
|
||||
@@ -158,14 +203,19 @@ export function createAlprOverlay({
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace floor anchors with the rendered surface under each overlay camera.
|
||||
* Floors come from ~111 m cells, so on a slope a badge could float or sink
|
||||
* by tens of metres. Runs only while the camera is still and tiles have
|
||||
* settled, a few depth samples per paint, and each anchor at most once;
|
||||
* samples far outside the floor prior (a roof or a streaming tile) are
|
||||
* rejected. Returns whether anchors remain to check.
|
||||
* Sample the rendered surface for the overlay cameras, outside the paint.
|
||||
*
|
||||
* A camera with no floor yet gets its anchor from a sample. An anchored one
|
||||
* is checked against the surface once: floors come from ~111 m cells, so on
|
||||
* a slope a badge could float or sink by tens of metres. Checks run only
|
||||
* while the camera is still and tiles have settled, and samples far outside
|
||||
* the floor prior (a roof or a streaming tile) are rejected. Each frame
|
||||
* takes SURFACE_SAMPLES_PER_FRAME samples, so no frame carries the whole
|
||||
* cohort; sampling inside paint() put up to six depth renders in one frame.
|
||||
*/
|
||||
function refineAnchors(budget) {
|
||||
function sampleAnchors() {
|
||||
sampling = false;
|
||||
if (destroyed || !state.enabled) return;
|
||||
const camera = state.viewer.camera;
|
||||
const scene = state.viewer.scene;
|
||||
const still =
|
||||
@@ -188,27 +238,41 @@ export function createAlprOverlay({
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
if (!scene.sampleHeightSupported) return false;
|
||||
let pending = false;
|
||||
const canSample = scene.globe?.show || scene.sampleHeightSupported;
|
||||
let samples = SURFACE_SAMPLES_PER_FRAME,
|
||||
pending = false,
|
||||
progressed = false,
|
||||
moved = false;
|
||||
// Cameras with no anchor come first: they are not on the overlay yet.
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity || entity.gevAlprCanvasPosition) continue;
|
||||
// The floor may have resolved since the paint that found none.
|
||||
if (anchorFor(record, entity)) moved = true;
|
||||
else if (!canSample || samples <= 0) pending = true;
|
||||
else {
|
||||
samples -= 1;
|
||||
const sample = surfaceHeight(record, entity);
|
||||
if (validAlprGroundHeight(sample)) {
|
||||
setAnchor(record, entity, sample);
|
||||
rememberSurface(record, sample, false);
|
||||
moved = progressed = true;
|
||||
} else pending = true;
|
||||
}
|
||||
}
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
const anchor = entity?.gevAlprCanvasPosition;
|
||||
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
||||
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
||||
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
|
||||
continue;
|
||||
if (!still || !surfaceSettled(scene) || samples <= 0) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
budget.samples -= 1;
|
||||
let sample;
|
||||
try {
|
||||
sample = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
[entity],
|
||||
);
|
||||
} catch {
|
||||
/* streaming tiles */
|
||||
}
|
||||
const current = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
samples -= 1;
|
||||
progressed = true;
|
||||
const sample = surfaceHeight(record, entity);
|
||||
let height = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
@@ -218,21 +282,43 @@ export function createAlprOverlay({
|
||||
(!validAlprGroundHeight(floor) ||
|
||||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
|
||||
sample <= floor + SURFACE_ABOVE_FLOOR_M));
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
|
||||
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
|
||||
height = sample + MARKER_FLOOR_LIFT_M;
|
||||
setAnchor(record, entity, height);
|
||||
moved = true;
|
||||
}
|
||||
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
|
||||
// A surface that did not answer is asked again after a rebuild.
|
||||
if (validAlprGroundHeight(sample)) rememberSurface(record, height, true);
|
||||
if (entity.id === state.selectedId)
|
||||
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
||||
}
|
||||
return pending;
|
||||
if (moved) requestPaint();
|
||||
if (!pending || Date.now() >= retryUntil) return;
|
||||
// Take the next ration on the next frame; when nothing could be sampled
|
||||
// (camera moving, tiles streaming, no floor yet) look again in 250 ms.
|
||||
if (progressed) scheduleSampling();
|
||||
else if (!retryTimer)
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
scheduleSampling();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function scheduleSampling() {
|
||||
if (sampling || destroyed) return;
|
||||
sampling = true;
|
||||
requestFrame(sampleAnchors);
|
||||
}
|
||||
|
||||
function paint({ ctx, width, height, keyhole, occluder }) {
|
||||
hits = [];
|
||||
if (!state.enabled || destroyed) return;
|
||||
let unresolved = false;
|
||||
let waiting = false;
|
||||
const painted = [];
|
||||
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
||||
const canSample =
|
||||
state.viewer.scene.globe?.show ||
|
||||
state.viewer.scene.sampleHeightSupported;
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity) continue;
|
||||
@@ -246,12 +332,14 @@ export function createAlprOverlay({
|
||||
nativeVisible(entity, true);
|
||||
continue;
|
||||
}
|
||||
const anchor = anchorFor(record, entity, budget);
|
||||
const anchor = anchorFor(record, entity);
|
||||
if (!anchor) {
|
||||
nativeVisible(entity, true);
|
||||
unresolved = true;
|
||||
waiting = true;
|
||||
continue;
|
||||
}
|
||||
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
|
||||
waiting = true;
|
||||
canvasOwned(entity, true);
|
||||
if (occluder && !occluder.isPointVisible(anchor)) {
|
||||
setShown(entity, false);
|
||||
@@ -335,15 +423,9 @@ export function createAlprOverlay({
|
||||
ctx.restore();
|
||||
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
|
||||
}
|
||||
const refining = refineAnchors({
|
||||
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
|
||||
});
|
||||
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
requestPaint();
|
||||
}, 250);
|
||||
}
|
||||
// Anchors still to find or to check are sampled after this paint, a few
|
||||
// per frame, never inside it.
|
||||
if (waiting && !retryTimer && Date.now() < retryUntil) scheduleSampling();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,8 +45,10 @@ function harness(t, count = 2, { floor = null } = {}) {
|
||||
active,
|
||||
removed = 0,
|
||||
paints = 0;
|
||||
const frames = [];
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
requestFrame: (step) => frames.push(step),
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
@@ -106,6 +108,12 @@ function harness(t, count = 2, { floor = null } = {}) {
|
||||
entities,
|
||||
draw,
|
||||
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
|
||||
/** Run the sampling step queued for the next animation frame, if any. */
|
||||
frame() {
|
||||
const step = frames.shift();
|
||||
step?.();
|
||||
return Boolean(step);
|
||||
},
|
||||
mapChanged() {
|
||||
mapListener({ detail: { status: 'ready' } });
|
||||
},
|
||||
@@ -243,41 +251,149 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
|
||||
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
|
||||
function photoreal(h, heightAt = () => 172) {
|
||||
const scene = h.state.viewer.scene;
|
||||
const camera = h.state.viewer.camera;
|
||||
camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.globe.show = false;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
let samples = 0;
|
||||
const sampled = [];
|
||||
scene.sampleHeight = (carto) => {
|
||||
samples++;
|
||||
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
|
||||
sampled.push(carto);
|
||||
return heightAt(Cesium.Math.toDegrees(carto.latitude));
|
||||
};
|
||||
return sampled;
|
||||
}
|
||||
|
||||
const anchorHeight = (h, id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
|
||||
/** Run sampling frames until none is queued, waiting out the 250 ms look-again. */
|
||||
function settle(t, h) {
|
||||
for (let i = 0; i < 400; i++) if (!h.frame()) t.mock.timers.tick(250);
|
||||
}
|
||||
|
||||
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
|
||||
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
const sampled = photoreal(h, (latitude) => (latitude > 30.00005 ? 400 : 172));
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const height = (id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
assert.ok(
|
||||
Math.abs(anchorHeight(h, 'camera:0') - 151.5) < 0.01,
|
||||
'floor anchor first',
|
||||
);
|
||||
assert.equal(h.frame(), true, 'the paint queued a sampling frame');
|
||||
assert.equal(
|
||||
samples,
|
||||
sampled.length,
|
||||
0,
|
||||
'no depth samples while the camera may still be moving',
|
||||
);
|
||||
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:0') - 173.5) < 0.01,
|
||||
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
|
||||
'rendered surface once still',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:1') - 151.5) < 0.01,
|
||||
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
const taken = samples;
|
||||
assert.equal(sampled.length, 2);
|
||||
h.paint();
|
||||
assert.equal(samples, taken, 'each anchor is checked once');
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 2, 'each anchor is checked once');
|
||||
});
|
||||
|
||||
test('a paint never samples the surface; frames take one sample each', (t) => {
|
||||
// Sampling inside paint() put up to six depth renders into one frame, and
|
||||
// the first of them compiles shaders: a freeze of 0.8 to 1.9 s at load.
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 8);
|
||||
const sampled = photoreal(h);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
h.paint();
|
||||
assert.equal(sampled.length, 0, 'no sample inside a paint');
|
||||
assert.equal(h.draw.length, 0, 'unanchored cameras keep their native badge');
|
||||
for (let taken = 1; taken <= 8; taken++) {
|
||||
assert.equal(h.frame(), true);
|
||||
assert.equal(sampled.length, taken, 'one sample per frame');
|
||||
}
|
||||
for (const record of h.records)
|
||||
assert.ok(Math.abs(anchorHeight(h, record.id) - 172) < 0.01);
|
||||
h.paint();
|
||||
assert.ok(h.draw.length > 0, 'anchored cameras paint on the overlay');
|
||||
});
|
||||
|
||||
test('a terrain globe is read from its mesh, never depth-sampled', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 3, { floor: 150 });
|
||||
const sampled = photoreal(h);
|
||||
const scene = h.state.viewer.scene;
|
||||
scene.globe.show = true;
|
||||
scene.globe.tilesLoaded = true;
|
||||
let reads = 0;
|
||||
scene.globe.getHeight = () => {
|
||||
reads++;
|
||||
return 172;
|
||||
};
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 0);
|
||||
assert.equal(reads, 3);
|
||||
// The same anchor the depth sample gave: the surface plus the marker lift.
|
||||
for (const record of h.records)
|
||||
assert.ok(Math.abs(anchorHeight(h, record.id) - 173.5) < 0.01);
|
||||
});
|
||||
|
||||
test('surface answers outlive rebuilt markers and a re-enable, until the map changes', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const h = harness(t, 4, { floor: 150 });
|
||||
const sampled = photoreal(h);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 4);
|
||||
const before = h.records.map((record) => anchorHeight(h, record.id));
|
||||
|
||||
// A refresh or a toggle removes the entities and builds new ones.
|
||||
const rebuild = () => {
|
||||
h.overlay.clear();
|
||||
h.entities.removeAll();
|
||||
for (const record of h.records)
|
||||
h.entities.add({
|
||||
id: record.id,
|
||||
billboard: { image: 'fallback', show: true },
|
||||
polyline: { show: true },
|
||||
polygon: { show: true },
|
||||
});
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
};
|
||||
rebuild();
|
||||
h.state.enabled = false;
|
||||
h.overlay.clear();
|
||||
h.state.enabled = true;
|
||||
rebuild();
|
||||
assert.equal(sampled.length, 4, 'no camera is sampled again');
|
||||
assert.deepEqual(
|
||||
h.records.map((record) => anchorHeight(h, record.id)),
|
||||
before,
|
||||
);
|
||||
|
||||
// A camera that moved is a different question.
|
||||
h.records[0] = { ...h.records[0], latitude: h.records[0].latitude + 0.001 };
|
||||
rebuild();
|
||||
assert.equal(sampled.length, 5);
|
||||
|
||||
h.mapChanged();
|
||||
h.paint();
|
||||
settle(t, h);
|
||||
assert.equal(sampled.length, 9, 'a new map surface is sampled afresh');
|
||||
});
|
||||
@@ -66,14 +66,15 @@ export const MAX_CANVAS_FRUSTUMS = 64;
|
||||
/** Metres a floor-placed marker sits above its resolved ground cell. */
|
||||
export const MARKER_FLOOR_LIFT_M = 1.5;
|
||||
|
||||
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
|
||||
export const ANCHOR_SAMPLES_PER_PAINT = 4;
|
||||
/** Rendered-surface samples one frame may take (on photoreal tiles each is a
|
||||
* depth render of several milliseconds, so they are spread over frames). */
|
||||
export const SURFACE_SAMPLES_PER_FRAME = 1;
|
||||
/** Surface answers kept by camera id, so a refresh or re-enable asks nothing. */
|
||||
export const REMEMBERED_ANCHORS = 2048;
|
||||
|
||||
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
|
||||
* sits within it; a rooftop or an unstreamed tile usually does not). */
|
||||
export const SURFACE_BELOW_FLOOR_M = 30;
|
||||
/** Rendered-surface refinements per paint while the camera is still. */
|
||||
export const SURFACE_SAMPLES_PER_PAINT = 2;
|
||||
export const SURFACE_ABOVE_FLOOR_M = 40;
|
||||
|
||||
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
|
||||
|
||||
Reference in new issue
Block a user