From 9582f2f28b794824e6a2498bdeae58aecdbd0542 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Fri, 13 Nov 2026 16:03:18 +0100 Subject: [PATCH] perf(alpr): read anchors from the terrain globe and sample outside the paint The overlay took up to six scene.sampleHeight depth renders inside one paint, and kept the answers on entities, so every refresh and every re-enable sampled the same cameras again. On a terrain globe the anchor now comes from globe.getHeight; answers are kept per camera id until the map source changes; and what sampling remains runs after the paint, one per frame. Co-Authored-By: Claude Fable 5.2 --- docs/CURRENT-STATE.md | 12 +- src/layers/alpr/overlay.js | 196 ++++++++++++++++++++++--------- src/layers/alpr/overlay.test.mjs | 154 +++++++++++++++++++++--- src/layers/alpr/policy.js | 9 +- 4 files changed, 287 insertions(+), 84 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6073b5c84..2f9a3a0cf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js index 202a98ed7..5bd590835 100644 --- a/src/layers/alpr/overlay.js +++ b/src/layers/alpr/overlay.js @@ -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 { diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index c9aa8e5f5..2b2b96e0c 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -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'); }); diff --git a/src/layers/alpr/policy.js b/src/layers/alpr/policy.js index 4bcaf867f..61ad4a9ef 100644 --- a/src/layers/alpr/policy.js +++ b/src/layers/alpr/policy.js @@ -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. */