From 630e1b1998a3b7aed4658d8d907ab1ac818c5541 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 18:11:30 -0800 Subject: [PATCH 1/3] Hide street-vehicle brackets and labels behind buildings and terrain The detection overlay only hid a contact behind the Earth's curve, so a street vehicle behind or under a building, a 3D tile or a hill still had its bracket and "VEH-nnnn" callout painted over that geometry, while its own dot was depth-tested and correctly hidden. A contact a layer marks `depthTested: true` (only Street Traffic does) is now tested against the depth the scene drew and left out of the overlay while a drawn surface stands in front of it by more than max(3 m, 2% of its range). Other layers are untouched: their markers are drawn over the scene on purpose. The test runs on the GPU and never waits for it. Each tested contact is one point drawn inside an occlusion query; the vertex shader looks up the frame's packed pick depth at the contact's pixel, decodes it and drops the point when the surface is nearer than the contact's threshold. Query results are collected on later frames. Reading depth back was measured and rejected: `scene.pickPosition` per contact costs 0.2 to 1.4 ms each, and Chromium answers `getBufferSubData` with a round trip to its GPU process even behind a signalled sync object (1.5 ms on a quiet machine, 15 to 25 ms on a busy one). - Contacts are retested every 100 to 200 ms, spread across frames, at most 256 a frame; with more in view the pace stretches so none starves. - Hiding is immediate. A hidden contact returns 600 ms after its last "hidden" answer, so one on a silhouette edge does not blink, and a contact new to the view is held back until its first answer (400 ms at most), so orbiting does not flash brackets behind buildings. - A callout whose contact goes behind something is dropped at once, not carried to the next solve and not faded out on the facade. - These times run on painted frames, so a hidden tab does not expire them. - A control point in every batch must draw; if it does not, the batch is ignored and after three the probe stands down for ten seconds. With no pick depth (2D, Columbus view, depth picking off) nothing is hidden. A WebGL 1 context reads depth back synchronously about 8 times a second. The renderer internals this depends on are read in one function, `readSceneDepthSource`, with the engine version they were checked against. --- docs/CURRENT-STATE.md | 27 + scripts/package-boundaries.json | 4 + src/data/detection.js | 86 ++- src/data/detectionHost.test.mjs | 292 ++++++++++ src/data/detectionOcclusion.js | 410 ++++++++++++++ src/data/detectionOcclusion.test.mjs | 618 ++++++++++++++++++++ src/data/fixtures/fakeDepthScene.mjs | 339 +++++++++++ src/data/sceneOcclusionProbe.js | 773 ++++++++++++++++++++++++++ src/data/sceneOcclusionProbe.test.mjs | 664 ++++++++++++++++++++++ src/layers/traffic/controls.js | 3 + 10 files changed, 3215 insertions(+), 1 deletion(-) create mode 100644 src/data/detectionOcclusion.js create mode 100644 src/data/detectionOcclusion.test.mjs create mode 100644 src/data/fixtures/fakeDepthScene.mjs create mode 100644 src/data/sceneOcclusionProbe.js create mode 100644 src/data/sceneOcclusionProbe.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0204c9e8..602abf037 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3605,6 +3605,33 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > eye level before detection sees it, so ground-backed labels only reappear > once the camera clears the ellipsoid. Rendered proof: > `scripts/qa-cockpit-plates.mjs`. +> - **Street vehicles behind geometry lose their bracket and callout +> (2026-11-09):** a contact a layer marks `depthTested: true` (only Street +> Traffic does) is tested against the depth the scene drew, and is left out +> of the overlay while a building, 3D tile, terrain or any other drawn +> surface stands in front of it by more than max(3 m, 2% of its range). That +> includes vehicles the road data routes through tunnels. Every other layer +> is untouched: aircraft, vessels and satellites draw their markers over the +> scene on purpose and keep their labels over it. The test runs on the GPU +> (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact, +> repeated every 100 to 200 ms, answered a frame or two later, with nothing +> read back from the GPU and so no stall. A contact hides as soon as a +> "hidden" answer arrives and returns 600 ms after its last one, which keeps +> a contact on a silhouette edge from blinking; a callout whose contact goes +> behind something is dropped at once rather than faded out on the facade. +> A contact new to the view is held back until its first answer (at most +> 400 ms), so orbiting a city does not flash a bracket for each vehicle that +> swings into frame behind a building. These times run on painted frames, +> so a hidden tab or an idle scene does not expire them. +> Do not swap this for `scene.pickPosition` per contact or for a depth +> read-back: both wait on the GPU process (measured 0.2 to 1.4 ms per pick, +> and 1.5 to 25 ms per `getBufferSubData` even behind a signalled sync +> object). With no pick depth (2D or Columbus view, depth picking off) or +> after a failed self-check, nothing is hidden and +> `getDetectionDiagnostics().surfaceDepthMode` reads `off`; a WebGL 1 +> context reads depth back synchronously about 8 times a second and reads +> `sync`. The renderer internals this relies on are listed, with the engine +> version they were checked against, in `readSceneDepthSource`. > - **Required attribution has two named keep-out rules (2026-08-20):** the > Google/Cesium credit line must stay visible in every state, and below 900px > two surfaces used to paint over it — the command dock's popover tray (any diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 220269c82..7d41b8def 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2599,8 +2599,10 @@ "src/data/detectionCohort.js", "src/data/detectionDraw.js", "src/data/detectionPolicy.js", + "src/data/detectionOcclusion.js", "src/data/detectionPresentation.js", "src/data/detectionRenderDemand.js", + "src/data/sceneOcclusionProbe.js", "src/data/earthquakes.js", "src/data/fireAnchors.js", "src/data/firmsAdapt.js", @@ -4083,6 +4085,8 @@ "src/data/detection.js", "src/data/detectionRenderDemand.js", "src/data/detectionPresentation.js", + "src/data/detectionOcclusion.js", + "src/data/sceneOcclusionProbe.js", "src/data/modelEligibility.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", diff --git a/src/data/detection.js b/src/data/detection.js index 0ec9e9ba2..770b63fef 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -50,6 +50,8 @@ import { viewScaleForAltitude, } from './detectionPolicy.js'; import { detectionBracketOpacity } from './detectionPresentation.js'; +import { SurfaceOcclusion } from './detectionOcclusion.js'; +import { SceneOcclusionProbe } from './sceneOcclusionProbe.js'; import { createCyberSonarSampler, cyberSonarFrameDelay, @@ -268,6 +270,22 @@ let _allocationStrategy = ALLOCATION_ELASTIC; let _lastNonOffMode = MODE_BALANCED; /** @type {LabelArbiter} Shared stable callout selector. */ const _labelArbiter = new LabelArbiter(); +/** + * Hides the bracket and callout of a depth-tested contact (a street vehicle) + * that the scene drew something in front of. `_occlusionProbe` asks the GPU + * about each such contact without waiting for the answer; `_surfaceOcclusion` + * remembers the answers and decides, every frame, who is drawn. + */ +const _occlusionProbe = new SceneOcclusionProbe(); +const _surfaceOcclusion = new SurfaceOcclusion(_occlusionProbe); +/** + * Keys of callouts the label arbiter still holds for contacts last seen + * hidden. A key stays here until its contact is seen drawn again or the + * arbiter lets the callout go, so the callout cannot come back at its old + * place just because its contact has since left the view. + */ +const _surfaceSuppressedKeys = new Set(); +let _surfaceDepthFallbackReported = false; let _lastLabelSolveAt = 0; let _labelSolveDirty = true; let _lastDiagnostics = null; @@ -372,6 +390,9 @@ export function destroyDetection() { _suspended = false; _suspendReason = ''; _labelArbiter.clear(); + _surfaceOcclusion.clear(); + _surfaceSuppressedKeys.clear(); + _occlusionProbe.destroy(); _lastLabelSolveAt = 0; _labelSolveDirty = true; _lastDiagnostics = null; @@ -697,6 +718,9 @@ function _applyModeState() { _syncSurfaceVisibility(); _setLanesActive(false); _labelArbiter.clear(); + _surfaceOcclusion.clear(); + _surfaceSuppressedKeys.clear(); + _occlusionProbe.reset(); _lastLabelSolveAt = 0; _labelSolveDirty = true; _lastSolveSnapshot = { @@ -1337,6 +1361,9 @@ function _drawOverlay(frame) { // every host lane. Detection retains the manual scalar projection below. const occluder = frame.occluder; const camPos = frame.cameraPosition; + // Answers to the occlusion tests started on earlier frames land here. + _surfaceOcclusion.beginFrame(now); + _occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now); const keyhole = frame.keyhole; // Read the OUTSIDE setting ONCE per paint, from the same module state the // host's own keyhole alpha comes from, so a bracket and its callout can never @@ -1447,6 +1474,29 @@ function _drawOverlay(frame) { // The bracket follows the same linear radial keyhole fade as its callout. const color = colorFor(resolveTier(obj)); const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole); + // Something the scene drew in front of a depth-tested contact hides its + // bracket and callout too, on every frame, not only on solves. + if (obj.depthTested === true) { + const testedLayerId = obj._layerId || 'unknown'; + const testedSourceId = obj.sourceId ?? obj.id ?? i; + const hidden = _surfaceOcclusion.isHidden( + testedLayerId, + testedSourceId, + obj.position, + ); + // If the arbiter holds a callout for this contact it would keep painting + // it until the next solve, and then fade it out where it last stood; + // see `_surfaceSuppressedKeys`. Only held contacts cost a key. + if ( + (hidden || _surfaceSuppressedKeys.size > 0) && + renderIdentities.get(testedLayerId)?.has(testedSourceId) + ) { + const key = _detectionKey(testedLayerId, testedSourceId); + if (hidden) _surfaceSuppressedKeys.add(key); + else _surfaceSuppressedKeys.delete(key); + } + if (hidden) continue; + } const sonarFactor = sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1; const sonarLabelFactor = sonarSampler.label(sonarFactor); @@ -1547,6 +1597,8 @@ function _drawOverlay(frame) { candidateMap.set(candidate.key, candidate); } } + // Start this frame's occlusion tests; nothing here waits for them. + _occlusionProbe.endFrame(); const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9; const collectiveBudget = labelBudgetFor(altitude, _densityPct); @@ -1557,6 +1609,13 @@ function _drawOverlay(frame) { let didSolve = false; let solveMs = 0; if (shouldSolve) { + _surfaceOcclusion.prune(); + if (_occlusionProbe.mode === 'sync' && !_surfaceDepthFallbackReported) { + _surfaceDepthFallbackReported = true; + console.info( + '[detection] No occlusion queries here (WebGL 1); street-vehicle label occlusion reads the frame depth back synchronously, about 8 times a second.', + ); + } const quotas = allocateLayerQuotas( demandByLayer, ambientBudget, @@ -1643,7 +1702,25 @@ function _drawOverlay(frame) { _ctx.lineWidth = bracketWidth; } - const renderEntries = _labelArbiter.renderEntries(candidateMap, now); + let renderEntries = _labelArbiter.renderEntries(candidateMap, now); + // A callout whose contact has gone behind something goes with it at once: + // no carry-over to the next solve and no fade painted on the facade. + if (_surfaceSuppressedKeys.size > 0) { + const allEntries = renderEntries; + renderEntries = allEntries.filter( + (entry) => !_surfaceSuppressedKeys.has(entry.candidate.key), + ); + // The arbiter has let some of them go: forget those. + if ( + allEntries.length - renderEntries.length !== + _surfaceSuppressedKeys.size + ) { + const held = new Set(); + for (const entry of allEntries) held.add(entry.candidate.key); + for (const key of _surfaceSuppressedKeys) + if (!held.has(key)) _surfaceSuppressedKeys.delete(key); + } + } const fadingCount = countFadingRenderEntries(renderEntries); // Demand counts fades in BOTH directions; `fadingCount` above stays the // fade-OUT tail because that is what the published diagnostics have always @@ -1698,6 +1775,13 @@ function _drawOverlay(frame) { keyholeRadius: keyhole.radius, keyholeFeatherPx: keyhole.featherPx, calloutOcclusionCount: calloutOcclusionRects.length, + surfaceTestedCount: _surfaceOcclusion.testedCount, + surfaceHiddenCount: _surfaceOcclusion.hiddenCount, + surfaceDepthMode: _occlusionProbe.mode, + // Tests started on the last frame, and what collecting answers and + // drawing them cost it. + surfaceProbeTests: _occlusionProbe.lastFrameTests, + surfaceProbeMs: _occlusionProbe.lastFrameMs, }; } else { const selectedCount = _labelArbiter.selectedKeys.size; diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index db01ed18a..c60255993 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -36,6 +36,12 @@ import { setAwarenessEchoField, } from '../awarenessEcho.js'; import { composeLabel, resolveTier } from './detectionDraw.js'; +import { + createFakeDepthScene, + createFakeGl, + depthImage, + viewDistanceToDepth, +} from './fixtures/fakeDepthScene.mjs'; test('detection diagnostics count rendered fading rows instead of absent selected identities', () => { assert.equal(countFadingRenderEntries([ @@ -1369,3 +1375,289 @@ test('transit contacts take the vehicle tier, not the civil-air one', () => { assert.equal(resolveTier({ type: 'AIR' }), 'civil'); assert.equal(resolveTier({ type: 'SEA' }), 'sea'); }); + +/** + * Give the host environment a scene that keeps frame depth: the camera 10,000 + * km up the +Z axis looking down it, and one depth range in which the whole + * frame is a surface `surfaceDistance()` metres in front of the camera. + */ +function installDepthScene(env, surfaceDistance, { webgl2 = true } = {}) { + const near = 1; + const far = 100_000_000; + const gl = createFakeGl({ webgl2 }); + const camera = env.viewer.camera; + camera.viewMatrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, -10_000_000)); + camera.frustum.fovy = Math.PI / 2; + camera.frustum.aspectRatio = 800 / 600; + const paint = () => + depthImage(8, 6, () => viewDistanceToDepth(surfaceDistance(), near, far, true)); + const image = paint(); + const scene = createFakeDepthScene({ gl, ranges: [{ near, far, image }] }); + Object.assign(env.viewer.scene, scene, { camera }); + return { + gl, + /** Draw the next frame's depth. */ + redraw() { + image.data.set(paint().data); + }, + }; +} + +/** Street contacts 3,643 km and 2,000 km below the camera, and a flight. */ +function occlusionLayers() { + return [ + { + id: 'traffic', + getDetectableObjects: () => [ + { position: new Cesium.Cartesian3(-0.4, 0, 6_356_752), id: 'VEH-0001', type: 'VEH', depthTested: true }, + { position: new Cesium.Cartesian3(0.4, 0, 8_000_000), id: 'VEH-0002', type: 'VEH', depthTested: true }, + ], + }, + { + id: 'flights', + // The same nearer surface is in front of it, but it is not depth-tested. + getDetectableObjects: () => [ + { position: new Cesium.Cartesian3(-0.4, 0.3, 6_356_752), sourceId: 'f1', id: 'AIR1', metric: 'FL300', type: 'AIR' }, + ], + }, + ]; +} + +function paintedText(env) { + return env.ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text); +} + +/** One rendered frame 16 ms on; `settleFrame` is one 250 ms on (a solve). */ +function nextFrame(env) { + env.advance(16); + env.postRender.raise(); +} + +test('a depth-tested contact behind drawn geometry loses its bracket and callout, tested on the GPU without anything read back', () => { + const env = installEnvironment(); + // A roof 3,000 km below the camera: in front of VEH-0001, behind VEH-0002. + let surface = 3_000_000; + const depth = installDepthScene(env, () => surface); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, occlusionLayers(), () => {}); + setMode('DENSE'); + settleFrame(env); + let diagnostics = getDetectionDiagnostics(); + assert.equal(diagnostics.surfaceDepthMode, 'async'); + assert.equal(diagnostics.surfaceTestedCount, 2, 'only the depth-tested contacts'); + assert.equal(diagnostics.surfaceProbeTests, 2); + // New to the view, so held back for the frame or two the first answer + // takes: neither is drawn yet. The flight is. + assert.equal(diagnostics.surfaceHiddenCount, 2, 'no answer has arrived yet'); + assert.equal(diagnostics.visibleCount, 1); + assert.equal(depth.gl.counts.queries, 3, 'a control point and one query per contact'); + + // Frames before the GPU answers start nothing new and wait for nothing. + nextFrame(env); + nextFrame(env); + assert.equal(depth.gl.counts.queries, 3); + assert.equal(depth.gl.counts.resultReads, 0); + + // The answers land. The contact above the roof is drawn from then on; the + // one under it never is. + depth.gl.finishGpu(); + nextFrame(env); + depth.gl.finishGpu(); + settleFrame(env); + env.ctx.calls.length = 0; + nextFrame(env); + assert.ok(!paintedText(env).includes('VEH-0001')); + assert.ok(paintedText(env).includes('VEH-0002')); + + // It stays gone, frame after frame and solve after solve. + for (let i = 0; i < 40; i++) { + depth.gl.finishGpu(); + env.ctx.calls.length = 0; + nextFrame(env); + assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`); + } + depth.gl.finishGpu(); + settleFrame(env); + diagnostics = getDetectionDiagnostics(); + assert.equal(diagnostics.surfaceHiddenCount, 1); + assert.equal(diagnostics.visibleCount, 2, 'the hidden contact has no bracket'); + assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0002')); + assert.ok(!diagnostics.labeledKeys.includes('traffic:VEH-0001')); + assert.ok(diagnostics.labeledKeys.includes('flights:f1')); + assert.ok(paintedText(env).includes('AIR1')); + assert.equal(depth.gl.counts.readPixels, 0, 'depth never left the GPU'); + + // The roof goes away: the bracket returns once visible answers have kept + // coming for the reappear time, and the callout once a solve re-admits it. + surface = 5_000_000; + depth.redraw(); + for (let i = 0; i < 80; i++) { + depth.gl.finishGpu(); + nextFrame(env); + } + for (let i = 0; i < 8; i++) { + depth.gl.finishGpu(); + settleFrame(env); + } + diagnostics = getDetectionDiagnostics(); + assert.equal(diagnostics.surfaceHiddenCount, 0); + assert.equal(diagnostics.visibleCount, 3); + assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001')); + } finally { + env.cleanup(); + } +}); + +test('a labelled contact that goes behind something loses its callout at once, and does not get it back by leaving the view', () => { + const env = installEnvironment(); + // To begin with the drawn surface is below both vehicles. + let surface = 5_000_000; + const depth = installDepthScene(env, () => surface); + const layers = occlusionLayers(); + const everyVehicle = layers[0].getDetectableObjects; + const answerAndSolve = (count) => { + for (let i = 0; i < count; i++) { + depth.gl.finishGpu(); + settleFrame(env); + } + }; + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, layers, () => {}); + setMode('DENSE'); + answerAndSolve(6); + assert.ok(getDetectionDiagnostics().labeledKeys.includes('traffic:VEH-0001')); + env.ctx.calls.length = 0; + nextFrame(env); + assert.ok(paintedText(env).includes('VEH-0001')); + + // A roof is drawn over VEH-0001. Its callout goes on the frame the answer + // lands, which is not a solve: no carry-over, no fade. + surface = 3_000_000; + depth.redraw(); + let gone = -1; + for (let i = 0; i < 30 && gone < 0; i++) { + depth.gl.finishGpu(); + env.ctx.calls.length = 0; + nextFrame(env); + if (!paintedText(env).includes('VEH-0001')) gone = i; + } + assert.ok(gone >= 0, 'the callout went'); + // The layer stops reporting it (it drove out of view). The arbiter still + // remembers where its callout was; it must not paint it there again. + layers[0].getDetectableObjects = () => everyVehicle().slice(1); + for (let i = 0; i < 12; i++) { + depth.gl.finishGpu(); + env.ctx.calls.length = 0; + nextFrame(env); + assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`); + } + } finally { + env.cleanup(); + } +}); + +test('without scene depth every contact keeps its bracket and callout', () => { + const env = installEnvironment(); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, occlusionLayers(), () => {}); + setMode('DENSE'); + settleFrame(env); + settleFrame(env); + const diagnostics = getDetectionDiagnostics(); + assert.equal(diagnostics.surfaceDepthMode, 'off'); + assert.equal(diagnostics.surfaceTestedCount, 2); + assert.equal(diagnostics.surfaceProbeTests, 0); + assert.equal(diagnostics.surfaceHiddenCount, 0); + assert.equal(diagnostics.visibleCount, 3); + assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001')); + } finally { + env.cleanup(); + } +}); + +test('scene depth that goes away puts hidden contacts back', () => { + const env = installEnvironment(); + const depth = installDepthScene(env, () => 3_000_000); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, occlusionLayers(), () => {}); + setMode('DENSE'); + settleFrame(env); + depth.gl.finishGpu(); + settleFrame(env); + assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1); + // The scene stops keeping pick depth (a 2D view, depth picking turned off). + depth.gl.finishGpu(); + env.viewer.scene.useDepthPicking = false; + // The hidden time runs on painted frames, so paint a second's worth. + for (let i = 0; i < 70; i++) nextFrame(env); + settleFrame(env); + const diagnostics = getDetectionDiagnostics(); + assert.equal(diagnostics.surfaceDepthMode, 'off'); + assert.equal(diagnostics.surfaceHiddenCount, 0); + assert.equal(diagnostics.visibleCount, 3); + } finally { + env.cleanup(); + } +}); + +test('turning detection off drops every test in flight; tearing it down frees the GPU objects', () => { + const env = installEnvironment(); + const depth = installDepthScene(env, () => 3_000_000); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, occlusionLayers(), () => {}); + setMode('DENSE'); + settleFrame(env); + assert.equal(depth.gl.liveQueries.size, 3); + // Off keeps the GPU objects (the shader is not compiled again on the next + // on) but the answers in flight are no longer wanted. + setMode('OFF'); + assert.equal(depth.gl.counts.deleted, 0); + depth.gl.finishGpu(); + setMode('DENSE'); + settleFrame(env); + assert.equal(depth.gl.counts.queries, 6, 'both contacts are tested afresh'); + assert.equal(depth.gl.liveQueries.size, 3, 'on the same queries, reused'); + depth.gl.finishGpu(); + nextFrame(env); + settleFrame(env); + assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1); + assert.equal(getDetectionDiagnostics().visibleCount, 2); + } finally { + env.cleanup(); + } + // Tearing detection down releases everything. + assert.equal(depth.gl.liveQueries.size, 0); + assert.equal(depth.gl.counts.deleted, 5); +}); + +test('a WebGL 1 scene reads depth synchronously and reports it once', () => { + const env = installEnvironment(); + const depth = installDepthScene(env, () => 3_000_000, { webgl2: false }); + const originalInfo = console.info; + const reported = []; + console.info = (...args) => reported.push(args.join(' ')); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, occlusionLayers(), () => {}); + setMode('DENSE'); + for (let i = 0; i < 30; i++) nextFrame(env); + settleFrame(env); + const diagnostics = getDetectionDiagnostics(); + assert.equal(diagnostics.surfaceDepthMode, 'sync'); + assert.equal(diagnostics.surfaceHiddenCount, 1); + assert.equal(diagnostics.visibleCount, 2); + // Half a second of frames: a read about every eighth of one, not one per + // frame and not one per contact. + assert.ok(depth.gl.counts.readPixels >= 3 && depth.gl.counts.readPixels <= 6, `${depth.gl.counts.readPixels} reads`); + assert.equal(reported.length, 1); + assert.match(reported[0], /synchronously/); + } finally { + console.info = originalInfo; + env.cleanup(); + } +}); diff --git a/src/data/detectionOcclusion.js b/src/data/detectionOcclusion.js new file mode 100644 index 000000000..22c8d2ac4 --- /dev/null +++ b/src/data/detectionOcclusion.js @@ -0,0 +1,410 @@ +/** + * Scene-geometry occlusion for detection contacts. + * + * The overlay's own horizon test only knows about the Earth's curve. A contact + * whose marker the scene depth-tests (a street vehicle's dot) can still sit + * behind a building, a 3D tile or a hill, and painting its bracket and callout + * there puts a label on the facade in front of it. + * + * The fix compares each such contact with what the renderer actually drew: if + * the drawn surface along the contact's ray is nearer than the contact by more + * than a small margin, something stands in front of it and its bracket and + * callout are not drawn. `sceneOcclusionProbe.js` runs that comparison on the + * GPU, against the frame's own depth, and hands back a yes or no per contact a + * frame or two later without ever waiting on the GPU. This file holds the + * arithmetic both sides share and the per-contact memory of the answers. + * + * Only contacts that opt in with `depthTested: true` are tested: the ones whose + * marker the scene itself hides behind geometry. Everything else keeps its + * label exactly as before, which covers contacts high above the ground + * (aircraft, satellites): their markers are drawn on top of the scene by + * design, their 3D models can be scaled up to a minimum pixel size and so fill + * the very pixel being read, and a label that hid while its marker stayed on + * screen would be the worse mismatch. + * + * This file is plain arithmetic; it never touches the renderer. + */ + +/** Never less than this: covers a dot drawn a little above or into its road. */ +export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3; +/** Grows with range so depth-buffer precision never reads as an occluder. */ +export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02; + +/** + * How much nearer than the contact the drawn surface must be before it counts + * as standing in front of it. + * @param {number} contactDistance Camera-to-contact distance in metres. + * @returns {number} Margin in metres. + */ +export function surfaceOcclusionMargin(contactDistance) { + const distance = Number(contactDistance); + if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M; + return Math.max( + SURFACE_OCCLUSION_MIN_MARGIN_M, + distance * SURFACE_OCCLUSION_MARGIN_FRACTION, + ); +} + +/** + * Whether a drawn surface hides a contact. + * @param {number} contactDistance Camera-to-contact distance in metres. + * @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance + * along the contact's ray; undefined or non-finite when nothing was drawn. + * @returns {boolean} + */ +export function surfaceHidesContact(contactDistance, surfaceDistance) { + if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance)) + return false; + return ( + surfaceDistance < contactDistance - surfaceOcclusionMargin(contactDistance) + ); +} + +/** + * Unpack a depth the renderer stored across the four bytes of an RGBA8 texel + * (most significant byte first, each byte a base-255 digit). + * @param {ArrayLike} bytes + * @param {number} offset Index of the texel's red byte. + * @returns {number} Window-space depth in [0, 1]. + */ +export function unpackDepth(bytes, offset) { + return ( + bytes[offset] / 255 + + bytes[offset + 1] / 65025 + + bytes[offset + 2] / 16581375 + + bytes[offset + 3] / 4228250625 + ); +} + +/** + * Convert a window-space depth to the distance in front of the camera, measured + * along the view direction, for one depth range ("frustum") of the frame. + * + * Logarithmic depth stores `log2(z - near + 1) / log2(far - near + 1)`. + * Standard perspective depth stores `far * (z - near) / (z * (far - near))`. + * + * @param {number} depth Window-space depth in [0, 1]. + * @param {number} near Near plane of the range, metres. + * @param {number} far Far plane of the range, metres. + * @param {boolean} logDepth Whether the frame was drawn with logarithmic depth. + * @returns {number} Metres along the view direction, or NaN where the range + * drew nothing (depth at or outside the ends of [0, 1]). + */ +export function depthToViewDistance(depth, near, far, logDepth) { + if (!(depth > 0) || !(depth < 1)) return Number.NaN; + if (!(near > 0) || !(far > near)) return Number.NaN; + if (logDepth) + return near + Math.pow(2, depth * Math.log2(far - near + 1)) - 1; + return (near * far) / (far - depth * (far - near)); +} + +/** + * Where a world position falls in a frame, and how near a drawn surface has to + * be there to hide it. + * + * A surface hides the contact when its distance along the contact's ray is + * under the contact's own distance less the margin. Along one ray, distance + * and view distance (depth in front of the camera) are in a fixed ratio, so + * the same rule reads: the surface's view distance is under + * `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`, + * and comparing a decoded depth with it is all the GPU has to do. + * + * @param {ArrayLike} view World-to-camera matrix, column-major, the + * camera looking down its own -Z axis. + * @param {number} tanHalfFovX + * @param {number} tanHalfFovY + * @param {{x: number, y: number, z: number}} position + * @param {{u: number, v: number, threshold: number}} out `u`, `v` in [0, 1) + * from the bottom-left corner of the frame. + * @returns {boolean} False when the position is behind the camera or outside + * the frame; `out` is then untouched. + */ +export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) { + const { x, y, z } = position; + const viewDistance = -(view[2] * x + view[6] * y + view[10] * z + view[14]); + if (!(viewDistance > 0)) return false; + const ex = view[0] * x + view[4] * y + view[8] * z + view[12]; + const ey = view[1] * x + view[5] * y + view[9] * z + view[13]; + const u = (ex / (viewDistance * tanHalfFovX)) * 0.5 + 0.5; + const v = (ey / (viewDistance * tanHalfFovY)) * 0.5 + 0.5; + if (!(u >= 0 && u < 1 && v >= 0 && v < 1)) return false; + const contactDistance = Math.sqrt( + ex * ex + ey * ey + viewDistance * viewDistance, + ); + out.u = u; + out.v = v; + out.threshold = + viewDistance * + (1 - surfaceOcclusionMargin(contactDistance) / contactDistance); + return true; +} + +/** + * One frame of depth read back to memory, with the camera it was drawn from. + * Only the synchronous fallback uses this (see `sceneOcclusionProbe.js`); the + * normal path never copies depth off the GPU. + * + * `bytes` holds `frustums.length` stacked images of `width` x `height` RGBA8 + * texels, nearest depth range first, rows bottom to top (the order the + * graphics API reads them back in). + */ +export class DepthSnapshot { + /** + * @param {{ + * width: number, height: number, bytes: Uint8Array, + * frustums: Array<{near: number, far: number}>, logDepth: boolean, + * view: ArrayLike, tanHalfFovX: number, tanHalfFovY: number, + * }} fields + */ + constructor(fields) { + this.width = fields.width; + this.height = fields.height; + this.bytes = fields.bytes; + this.frustums = fields.frustums; + this.logDepth = fields.logDepth === true; + this.view = fields.view; + this.tanHalfFovX = fields.tanHalfFovX; + this.tanHalfFovY = fields.tanHalfFovY; + this._projected = { u: 0, v: 0, threshold: 0 }; + } + + /** + * Distance along the view direction to the nearest surface drawn at a texel. + * @param {number} column + * @param {number} row Counted from the bottom of the frame. + * @returns {number} Metres, or NaN where nothing was drawn. + */ + surfaceViewDistance(column, row) { + const { width, height, bytes, frustums, logDepth } = this; + const texel = (row * width + column) * 4; + const layer = width * height * 4; + for (let i = 0; i < frustums.length; i++) { + const distance = depthToViewDistance( + unpackDepth(bytes, i * layer + texel), + frustums[i].near, + frustums[i].far, + logDepth, + ); + if (distance === distance) return distance; + } + return Number.NaN; + } + + /** + * Whether the frame drew something in front of a world position. + * @param {{x: number, y: number, z: number}} position + * @returns {boolean|null} Null when the position is outside the frame. + */ + hides(position) { + const projected = this._projected; + if ( + !projectContact( + this.view, + this.tanHalfFovX, + this.tanHalfFovY, + position, + projected, + ) + ) + return null; + return ( + this.surfaceViewDistance( + Math.floor(projected.u * this.width), + Math.floor(projected.v * this.height), + ) < projected.threshold + ); + } +} + +/** + * A contact is tested again at most this long, and at least half this long, + * after its last test was started, so a view full of contacts spreads its + * tests over about ten frames instead of repeating all of them on every one. + * With more contacts in view than the probe's per-frame budget can serve at + * that pace, the interval stretches (see `SurfaceOcclusion.beginFrame`). + */ +export const SURFACE_RETEST_MS = 200; + +/** + * A contact stays hidden at least this long after the last test that found it + * hidden, and never less than three retest intervals. + * + * Going behind something takes effect as soon as the answer arrives. Coming + * back takes several visible answers in a row, so a contact on a silhouette + * edge, which tests either way from one frame to the next while the camera + * orbits, stays off instead of blinking. It is also the clean failure: when + * answers stop arriving (no depth to test against) every contact is drawn + * again within this time, exactly as before this test existed. + */ +export const SURFACE_REAPPEAR_MS = 600; + +/** + * A contact that has never had an answer is held back this long, at most, + * while a working probe gets it one. + * + * A contact new to the view would otherwise be drawn for the frame or two its + * first test takes, and then vanish if it turned out to be behind something: + * a camera orbiting a city would flash a bracket for every vehicle that + * swings into the frame behind a building. With no working probe nothing is + * held back, and past this time the contact is drawn whatever happens. + */ +export const SURFACE_FIRST_ANSWER_MS = 400; + +/** + * The most one frame advances the occlusion clock. + * + * Every interval above is measured on a clock that only runs while the + * overlay is painting: each painted frame moves it on by the time since the + * last one, but never by more than this. Answers only arrive while frames are + * painted, so on a wall clock a pause (a hidden tab, a scene with nothing to + * redraw) would run out every "hidden" at once and the first frame afterwards + * would paint every label over the buildings again until fresh answers came. + * On this clock a pause of any length counts as one slow frame. + */ +export const SURFACE_MAX_FRAME_STEP_MS = 50; + +/** A contact not drawn for this long is forgotten. */ +export const SURFACE_FORGET_MS = 2000; + +/** + * What is remembered about one contact. + * @typedef {{lastHiddenAt: number, hiddenUntil: number, appliedHiddenAt: number, + * queuedAt: number, pending: boolean, answered: boolean, firstSeenAt: number, + * seenAt: number}} SurfaceContact Times are on the occlusion clock. + */ + +/** + * Per-contact occlusion answers, and the schedule that keeps them fresh. + * + * `probe` is whatever tests a contact against the drawn scene. It has + * `add(contact, position)`, which starts a test (setting `contact.pending`) + * and returns false when it cannot take one now, and it reports back by + * clearing `contact.pending`, setting `contact.answered` and, when the + * contact was hidden, setting `contact.lastHiddenAt`. `probe.maxPerFrame`, if + * set, is how many tests it takes in one frame; `probe.mode` reads `'async'` + * while answers come a frame or two after the test. + */ +export class SurfaceOcclusion { + /** + * @param {{add: (contact: SurfaceContact, position: object) => boolean}} probe + */ + constructor(probe) { + this.probe = probe; + /** @type {Map>} Layer id -> source id. */ + this.contacts = new Map(); + /** + * The occlusion clock, in milliseconds of painted time (see + * `SURFACE_MAX_FRAME_STEP_MS`). The probe must date its answers by it. + */ + this.now = 0; + this._wall = Number.NEGATIVE_INFINITY; + /** Depth-tested contacts drawn or hidden this frame. */ + this.testedCount = 0; + this.hiddenCount = 0; + this.retestMs = SURFACE_RETEST_MS; + this.reappearMs = SURFACE_REAPPEAR_MS; + this._phase = 0; + } + + /** + * Open a frame and move the occlusion clock on. + * @param {number} wallNow Monotonic milliseconds. + */ + beginFrame(wallNow) { + // Contacts arrive in the same order every frame and the probe takes only + // so many, so if more fell due per frame than it takes, the ones at the + // end of the order would never be tested. Pace the retests to what the + // last frame showed the probe can keep up with: all of the contacts, once + // per interval, with a third of the budget to spare. + const elapsed = wallNow - this._wall; + const frameMs = + elapsed > 0 ? Math.min(SURFACE_MAX_FRAME_STEP_MS, elapsed) : 0; + this._wall = wallNow; + const budget = Number(this.probe.maxPerFrame) || Number.POSITIVE_INFINITY; + this.retestMs = Math.max( + SURFACE_RETEST_MS, + (this.testedCount / budget) * frameMs * 2, + ); + this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3); + this.now += frameMs; + this.testedCount = 0; + this.hiddenCount = 0; + } + + /** + * Whether a contact's bracket and callout should be left out of this frame. + * Starts a fresh test of the contact when one is due. + * @param {string} layerId + * @param {*} sourceId + * @param {{x: number, y: number, z: number}} position + * @returns {boolean} + */ + isHidden(layerId, sourceId, position) { + const now = this.now; + let layer = this.contacts.get(layerId); + if (!layer) { + layer = new Map(); + this.contacts.set(layerId, layer); + } + let contact = layer.get(sourceId); + if (!contact) { + contact = { + lastHiddenAt: Number.NEGATIVE_INFINITY, + hiddenUntil: Number.NEGATIVE_INFINITY, + appliedHiddenAt: Number.NEGATIVE_INFINITY, + queuedAt: Number.NEGATIVE_INFINITY, + pending: false, + answered: false, + firstSeenAt: now, + seenAt: now, + }; + layer.set(sourceId, contact); + } + contact.seenAt = now; + this.testedCount++; + if ( + !contact.pending && + now - contact.queuedAt >= this.retestMs && + this.probe.add(contact, position) + ) { + // Contacts that came into view together would otherwise all fall due on + // the same frames for ever. Each test backdates itself by a different + // part of half the interval, which spreads them out within a second. + contact.queuedAt = now - this.retestMs * 0.5 * this._phase; + this._phase = (this._phase + 0.618034) % 1; + } + // A new "hidden" answer sets when the contact may come back, once, with + // the pace in force now. A later change of pace must not bring back a + // "hidden" that has already run out. + if (contact.lastHiddenAt !== contact.appliedHiddenAt) { + contact.appliedHiddenAt = contact.lastHiddenAt; + contact.hiddenUntil = contact.lastHiddenAt + this.reappearMs; + } + const hidden = + now < contact.hiddenUntil || + // Not yet answered: held back while a working probe finds out. + (!contact.answered && + this.probe.mode === 'async' && + now - contact.firstSeenAt < SURFACE_FIRST_ANSWER_MS); + if (hidden) this.hiddenCount++; + return hidden; + } + + /** Forget contacts that are no longer drawn. */ + prune() { + for (const [layerId, layer] of this.contacts) { + for (const [sourceId, contact] of layer) { + if (this.now - contact.seenAt > SURFACE_FORGET_MS) + layer.delete(sourceId); + } + if (layer.size === 0) this.contacts.delete(layerId); + } + } + + /** Forget every contact (mode off, viewer swap). */ + clear() { + this.contacts.clear(); + this.testedCount = 0; + this.hiddenCount = 0; + } +} diff --git a/src/data/detectionOcclusion.test.mjs b/src/data/detectionOcclusion.test.mjs new file mode 100644 index 000000000..fbd1fdd57 --- /dev/null +++ b/src/data/detectionOcclusion.test.mjs @@ -0,0 +1,618 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DepthSnapshot, + SURFACE_FIRST_ANSWER_MS, + SURFACE_FORGET_MS, + SURFACE_MAX_FRAME_STEP_MS, + SURFACE_OCCLUSION_MIN_MARGIN_M, + SURFACE_REAPPEAR_MS, + SURFACE_RETEST_MS, + SurfaceOcclusion, + depthToViewDistance, + projectContact, + surfaceHidesContact, + surfaceOcclusionMargin, + unpackDepth, +} from './detectionOcclusion.js'; +import { + depthImage, + packDepth, + viewDistanceToDepth, +} from './fixtures/fakeDepthScene.mjs'; + +test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () => { + assert.equal(surfaceOcclusionMargin(10), SURFACE_OCCLUSION_MIN_MARGIN_M); + assert.equal(surfaceOcclusionMargin(150), 3); + assert.equal(surfaceOcclusionMargin(500), 10); + assert.equal(surfaceOcclusionMargin(5000), 100); + assert.equal(surfaceOcclusionMargin(0), 3); + assert.equal(surfaceOcclusionMargin(Number.NaN), 3); +}); + +test('a surface hides a contact only when nearer by more than the margin', () => { + // 500 m away: margin 10 m. + assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front'); + assert.equal(surfaceHidesContact(500, 491), false, 'its own road or dot'); + assert.equal(surfaceHidesContact(500, 500), false); + assert.equal(surfaceHidesContact(500, 520), false, 'the ground behind it'); + // Close range keeps the 3 m floor. + assert.equal(surfaceHidesContact(60, 56), true); + assert.equal(surfaceHidesContact(60, 58), false); +}); + +test('nothing drawn, or no distance, never hides a contact', () => { + assert.equal(surfaceHidesContact(500, undefined), false); + assert.equal(surfaceHidesContact(500, Number.NaN), false); + assert.equal(surfaceHidesContact(Number.NaN, 10), false); +}); + +test('packed depth unpacks most significant byte first', () => { + assert.equal(unpackDepth([0, 0, 0, 0], 0), 0); + assert.equal(unpackDepth([255, 0, 0, 0], 0), 1); + assert.equal(unpackDepth([9, 9, 51, 0, 0, 0], 2), 0.2); + for (const depth of [0.000123, 0.25, 0.5, 0.987654321]) { + const round = unpackDepth(packDepth(depth), 0); + assert.ok(Math.abs(round - depth) < 1e-9, `${depth} -> ${round}`); + } +}); + +test('logarithmic depth decodes to metres in front of the camera', () => { + const near = 0.1; + const far = 10_000_000; + // depth = log2(z - near + 1) / log2(far - near + 1) + for (const z of [0.5, 12, 480, 25_000, 3_000_000]) { + const depth = Math.log2(z - near + 1) / Math.log2(far - near + 1); + const decoded = depthToViewDistance(depth, near, far, true); + assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`); + } +}); + +test('standard perspective depth decodes to metres in front of the camera', () => { + const near = 1; + const far = 5000; + // depth = far * (z - near) / (z * (far - near)) + for (const z of [1.5, 40, 900, 4999]) { + const depth = (far * (z - near)) / (z * (far - near)); + const decoded = depthToViewDistance(depth, near, far, false); + assert.ok(Math.abs(decoded - z) / z < 1e-9, `${z} -> ${decoded}`); + } + assert.ok( + Math.abs(depthToViewDistance(0.5, 1, 5000, false) - 10000 / 5001) < 1e-9, + 'half depth sits just past twice the near plane', + ); +}); + +test('the ends of the depth range, and a bad range, mean nothing was drawn', () => { + for (const log of [true, false]) { + assert.ok(Number.isNaN(depthToViewDistance(0, 1, 100, log))); + assert.ok(Number.isNaN(depthToViewDistance(1, 1, 100, log))); + assert.ok(Number.isNaN(depthToViewDistance(Number.NaN, 1, 100, log))); + assert.ok(Number.isNaN(depthToViewDistance(0.5, 0, 100, log))); + assert.ok(Number.isNaN(depthToViewDistance(0.5, 100, 100, log))); + } +}); + +const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +/** A point `distance` metres ahead, `side` of the way to the frame edge. */ +const ahead = (distance, side = 0, up = 0) => ({ + x: side * distance, + y: up * distance, + z: -distance, +}); + +test('a contact projects to its place in the frame, from the bottom-left corner', () => { + const out = { u: -1, v: -1, threshold: -1 }; + // 90 degree field of view: the frame edge is as far out as the point is ahead. + assert.equal(projectContact(IDENTITY, 1, 1, ahead(500), out), true); + assert.deepEqual([out.u, out.v], [0.5, 0.5]); + assert.equal( + projectContact(IDENTITY, 1, 1, ahead(500, -0.5, 0.5), out), + true, + ); + assert.deepEqual([out.u, out.v], [0.25, 0.75]); + // A wider horizontal field of view pulls the same point towards the middle. + assert.equal( + projectContact(IDENTITY, 2, 1, ahead(500, -0.5, 0.5), out), + true, + ); + assert.deepEqual([out.u, out.v], [0.375, 0.75]); +}); + +test('the threshold is the margin rule restated as a view distance', () => { + const out = { u: 0, v: 0, threshold: 0 }; + // Straight ahead, distance and view distance are the same thing. + projectContact(IDENTITY, 1, 1, ahead(500), out); + assert.ok(Math.abs(out.threshold - 490) < 1e-9, '500 m less 2%'); + projectContact(IDENTITY, 1, 1, ahead(60), out); + assert.ok(Math.abs(out.threshold - 57) < 1e-9, '60 m less the 3 m floor'); + // Off axis the ray is longer than the view distance; the rule holds along + // the ray, so a surface at view distance d sits d * range / viewDistance out. + const corner = ahead(400, -0.9, 0.9); + const range = Math.hypot(corner.x, corner.y, corner.z); + projectContact(IDENTITY, 1, 1, corner, out); + for (const surfaceView of [out.threshold - 0.01, out.threshold + 0.01]) { + const alongRay = (surfaceView * range) / 400; + assert.equal( + surfaceHidesContact(range, alongRay), + surfaceView < out.threshold, + ); + } + assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9); +}); + +test('behind the camera or outside the frame there is nothing to test', () => { + const out = { u: 7, v: 7, threshold: 7 }; + assert.equal( + projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 500 }, out), + false, + ); + assert.equal( + projectContact(IDENTITY, 1, 1, { x: 0, y: 0, z: 0 }, out), + false, + ); + assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, -1.2), out), false); + assert.equal(projectContact(IDENTITY, 1, 1, ahead(500, 0, 1.01), out), false); + assert.equal( + projectContact(IDENTITY, 1, 1, ahead(500, 1), out), + false, + 'u = 1', + ); + assert.deepEqual(out, { u: 7, v: 7, threshold: 7 }, 'left untouched'); +}); + +test('a contact is projected with the camera it is given', () => { + // Camera 1000 m up the +Z axis looking down -Z: view = translate(0, 0, -1000). + const view = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -1000, 1]; + const out = { u: 0, v: 0, threshold: 0 }; + assert.equal( + projectContact(view, 1, 1, { x: -250, y: 0, z: 500 }, out), + true, + ); + assert.equal(out.u, 0.25); + assert.equal(projectContact(view, 1, 1, { x: 0, y: 0, z: 1500 }, out), false); +}); + +/** + * A snapshot from a camera at the origin looking down -Z with a 90 degree + * field of view. By default one depth range in which the left half of the + * frame is a wall `wall` metres away and the right half is ground `ground` + * metres away. + */ +function snapshotWithWall({ + wall = 400, + ground = 500, + logDepth = true, + size = 8, + frustums, + images, +} = {}) { + const ranges = frustums || [{ near: 1, far: 100_000 }]; + const layers = images || [ + depthImage(size, size, (column) => + viewDistanceToDepth( + column < size / 2 ? wall : ground, + ranges[0].near, + ranges[0].far, + logDepth, + ), + ), + ]; + const bytes = new Uint8Array(size * size * 4 * layers.length); + layers.forEach((image, i) => bytes.set(image.data, i * size * size * 4)); + return new DepthSnapshot({ + width: size, + height: size, + bytes, + frustums: ranges, + logDepth, + view: IDENTITY, + tanHalfFovX: 1, + tanHalfFovY: 1, + }); +} + +test('a contact behind a drawn wall is hidden; one on drawn ground, or in front of the wall, is not', () => { + for (const logDepth of [true, false]) { + const snapshot = snapshotWithWall({ logDepth }); + assert.equal(snapshot.hides(ahead(500, -0.5)), true); + assert.equal(snapshot.hides(ahead(500, 0.5)), false); + assert.equal(snapshot.hides(ahead(300, -0.5)), false); + // 2% of 405 m is 8.1 m: 5 m behind the wall is still the wall's own face. + assert.equal(snapshot.hides(ahead(405, -0.5)), false); + assert.equal(snapshot.hides(ahead(412, -0.5)), true); + assert.equal(snapshot.hides(ahead(500, -1.5)), null, 'outside the frame'); + } +}); + +test('rows are read bottom to top', () => { + const size = 8; + const image = depthImage(size, size, (_column, row) => + viewDistanceToDepth(row < size / 2 ? 400 : 500, 1, 100_000, true), + ); + const snapshot = snapshotWithWall({ images: [image] }); + assert.equal(snapshot.hides(ahead(500, 0, -0.5)), true, 'below centre'); + assert.equal(snapshot.hides(ahead(500, 0, 0.5)), false, 'above centre'); +}); + +test('the nearest depth range that drew something answers', () => { + const size = 4; + const frustums = [ + { near: 1, far: 1000 }, + { near: 999, far: 1_000_000 }, + ]; + // Near range: a wall 400 m away on the left, nothing on the right. + const nearImage = depthImage(size, size, (column) => + column < 2 ? viewDistanceToDepth(400, 1, 1000, true) : 1, + ); + // Far range: ground 20 km away everywhere. + const farImage = depthImage(size, size, () => + viewDistanceToDepth(20_000, 999, 1_000_000, true), + ); + const snapshot = snapshotWithWall({ + size, + frustums, + images: [nearImage, farImage], + }); + assert.ok(Math.abs(snapshot.surfaceViewDistance(0, 0) - 400) < 0.01); + assert.ok(Math.abs(snapshot.surfaceViewDistance(3, 0) - 20_000) < 1); + assert.equal(snapshot.hides(ahead(15_000, -0.5)), true); + assert.equal(snapshot.hides(ahead(15_000, 0.5)), false); + assert.equal(snapshot.hides(ahead(30_000, 0.5)), true); +}); + +test('where nothing was drawn a contact is visible', () => { + const size = 4; + const snapshot = snapshotWithWall({ + size, + images: [depthImage(size, size, () => 1)], + }); + assert.ok(Number.isNaN(snapshot.surfaceViewDistance(1, 1))); + assert.equal(snapshot.hides(ahead(500)), false); +}); + +/** + * A probe that answers when told to. `hidden` decides each test's answer at + * the time it is resolved. + */ +function manualProbe() { + const inFlight = []; + return { + accepting: true, + started: 0, + inFlight, + add(contact) { + if (!this.accepting) return false; + this.started++; + contact.pending = true; + inFlight.push(contact); + return true; + }, + /** Deliver every answer in flight. */ + answer(hidden, now) { + for (const contact of inFlight.splice(0)) { + contact.pending = false; + contact.answered = true; + if (hidden) contact.lastHiddenAt = now; + } + }, + }; +} + +const anywhere = { x: 0, y: 0, z: -500 }; + +test('a contact is drawn until an answer says it is hidden, then hidden at once', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(1000); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false); + assert.equal(probe.started, 1, 'a new contact is tested straight away'); + occlusion.beginFrame(1016); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false); + assert.equal(probe.started, 1, 'one test in flight per contact'); + probe.answer(true, occlusion.now); + occlusion.beginFrame(1032); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + assert.equal(occlusion.testedCount, 1); + assert.equal(occlusion.hiddenCount, 1); + // The same source id on another layer is another contact. + assert.equal(occlusion.isHidden('transit', 'a', anywhere), false); +}); + +test('with a working probe a new contact is held back until its first answer, and no longer than the limit', () => { + const probe = manualProbe(); + probe.mode = 'async'; + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(0); + // New to the view: tested at once, and not drawn while the answer is out. + assert.equal(occlusion.isHidden('traffic', 'street', anywhere), true); + assert.equal(occlusion.isHidden('traffic', 'roofed', anywhere), true); + assert.equal(occlusion.isHidden('traffic', 'unanswered', anywhere), true); + assert.equal(probe.started, 3); + const [street, roofed] = probe.inFlight.splice(0, 2); + street.pending = false; + street.answered = true; + roofed.pending = false; + roofed.answered = true; + roofed.lastHiddenAt = occlusion.now; + occlusion.beginFrame(16); + assert.equal( + occlusion.isHidden('traffic', 'street', anywhere), + false, + 'drawn from its first answer', + ); + assert.equal( + occlusion.isHidden('traffic', 'roofed', anywhere), + true, + 'never drawn at all', + ); + // The third answer never comes. It is drawn once the limit has passed. + const firstSeen = occlusion.now - 16; + const drawn = []; + for (let wall = 32; wall < 1000; wall += 16) { + occlusion.beginFrame(wall); + drawn.push([ + occlusion.now - firstSeen, + !occlusion.isHidden('traffic', 'unanswered', anywhere), + ]); + } + for (const [age, shown] of drawn) + assert.equal(shown, age >= SURFACE_FIRST_ANSWER_MS, `at ${age} ms`); + + // No working probe: a new contact is drawn at once, as before. + for (const mode of ['off', 'sync', undefined]) { + const idle = manualProbe(); + idle.mode = mode; + const plain = new SurfaceOcclusion(idle); + plain.beginFrame(0); + assert.equal(plain.isHidden('traffic', 'a', anywhere), false, String(mode)); + } +}); + +test('a contact is retested at most every retest interval and at least every half of it', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + const starts = []; + let before = 0; + for (let now = 0; now <= 2000; now += 4) { + probe.answer(false, now); + occlusion.beginFrame(now); + occlusion.isHidden('traffic', 'a', anywhere); + if (probe.started !== before) { + starts.push(now); + before = probe.started; + } + } + const gaps = starts.slice(1).map((at, i) => at - starts[i]); + assert.ok(gaps.length >= 9); + assert.ok( + gaps.every((gap) => gap <= SURFACE_RETEST_MS), + `${gaps}`, + ); + assert.ok( + gaps.every((gap) => gap >= SURFACE_RETEST_MS / 2), + `${gaps}`, + ); + assert.ok(new Set(gaps).size > 3, 'not all on the same beat'); +}); + +test('contacts that appear together drift apart instead of all testing on the same frames', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + const perFrame = []; + for (let frame = 0; frame < 240; frame++) { + const now = frame * 16; + probe.answer(false, now); + occlusion.beginFrame(now); + const before = probe.started; + for (let id = 0; id < 600; id++) + occlusion.isHidden('traffic', id, anywhere); + perFrame.push(probe.started - before); + } + assert.equal(perFrame[0], 600, 'every new contact is tested at once'); + const settled = perFrame.slice(120); + assert.ok(Math.max(...settled) < 200, `peak ${Math.max(...settled)}`); + assert.ok(Math.min(...settled) > 0); +}); + +test('with more contacts than the probe takes per frame, the retests slow down and none is starved', () => { + // A probe that takes 256 tests a frame, 2,400 contacts in view, 30 fps. + const inFlight = []; + let taken = 0; + const probe = { + maxPerFrame: 256, + add(contact) { + if (taken >= 256) return false; + taken++; + contact.pending = true; + inFlight.push(contact); + return true; + }, + }; + const occlusion = new SurfaceOcclusion(probe); + const tests = new Array(2400).fill(0); + const lastStart = new Array(2400).fill(0); + let longestGap = 0; + for (let frame = 0; frame < 300; frame++) { + const now = frame * 33; + for (const contact of inFlight.splice(0)) contact.pending = false; + taken = 0; + occlusion.beginFrame(now); + for (let id = 0; id < 2400; id++) { + const before = taken; + occlusion.isHidden('traffic', id, anywhere); + if (taken !== before) { + tests[id]++; + if (frame > 60) longestGap = Math.max(longestGap, now - lastStart[id]); + lastStart[id] = now; + } + } + } + assert.ok( + Math.min(...tests) >= 8, + `fewest tests for one contact: ${Math.min(...tests)}`, + ); + assert.ok(occlusion.retestMs > SURFACE_RETEST_MS); + assert.ok(longestGap <= occlusion.retestMs + 33, `longest gap ${longestGap}`); + assert.ok(occlusion.reappearMs >= 3 * occlusion.retestMs); + // A view the budget covers easily keeps the normal pace. + const few = new SurfaceOcclusion(probe); + for (let frame = 0; frame < 10; frame++) { + taken = 0; + few.beginFrame(frame * 16); + for (let id = 0; id < 300; id++) few.isHidden('traffic', id, anywhere); + } + assert.equal(few.retestMs, SURFACE_RETEST_MS); + assert.equal(few.reappearMs, SURFACE_REAPPEAR_MS); +}); + +test('a hidden contact comes back only after the reappear time without a hidden answer', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(0); + occlusion.isHidden('traffic', 'a', anywhere); + const hiddenAt = occlusion.now; + probe.answer(true, hiddenAt); + // Visible answers from now on; the contact still waits out the time. + let reappeared = null; + for (let wall = 16; wall < 2000 && reappeared === null; wall += 16) { + probe.answer(false, occlusion.now); + occlusion.beginFrame(wall); + if (!occlusion.isHidden('traffic', 'a', anywhere)) + reappeared = occlusion.now; + } + assert.ok(reappeared - hiddenAt >= SURFACE_REAPPEAR_MS); + assert.ok(reappeared - hiddenAt < SURFACE_REAPPEAR_MS + 17); +}); + +test('a contact on an edge, answering either way in turn, stays hidden', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + const drawn = []; + let answers = 0; + for (let wall = 0; wall < 4000; wall += 16) { + if (probe.inFlight.length > 0) + probe.answer(answers++ % 2 === 0, occlusion.now); + occlusion.beginFrame(wall); + drawn.push(!occlusion.isHidden('traffic', 'a', anywhere)); + } + // Drawn only until the first answer arrives. + assert.equal(drawn.lastIndexOf(true), 0); +}); + +test('when the probe stops answering, hidden contacts are drawn again and stay drawn', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(0); + occlusion.isHidden('traffic', 'a', anywhere); + const hiddenAt = occlusion.now; + probe.answer(true, hiddenAt); + // No depth any more: the probe takes no tests and answers nothing. + probe.accepting = false; + const drawn = []; + for (let wall = 16; wall < 3000; wall += 16) { + occlusion.beginFrame(wall); + drawn.push([ + occlusion.now - hiddenAt, + !occlusion.isHidden('traffic', 'a', anywhere), + ]); + } + for (const [age, shown] of drawn) + assert.equal(shown, age >= SURFACE_REAPPEAR_MS, `at ${age} ms`); + // A probe that never accepted anything hides nothing. + const dead = new SurfaceOcclusion({ add: () => false }); + dead.beginFrame(0); + assert.equal(dead.isHidden('traffic', 'a', anywhere), false); +}); + +test('a change of pace does not bring back a hidden that has run out', () => { + // 2,400 contacts with a 256-a-frame probe: the hide time is longer at 33 ms + // frames than at 16 ms ones. + const inFlight = []; + let taken = 0; + const probe = { + maxPerFrame: 256, + add(contact) { + if (taken >= 256) return false; + taken++; + contact.pending = true; + inFlight.push(contact); + return true; + }, + }; + const occlusion = new SurfaceOcclusion(probe); + let wall = 0; + const paint = (step) => { + wall += step; + for (const contact of inFlight.splice(0)) contact.pending = false; + taken = 0; + occlusion.beginFrame(wall); + let watched = false; + for (let id = 0; id < 2400; id++) { + const hidden = occlusion.isHidden('traffic', id, anywhere); + if (id === 0) watched = hidden; + } + return watched; + }; + for (let i = 0; i < 30; i++) paint(16); + // One hidden answer for contact 0, then visible answers only. + occlusion.contacts.get('traffic').get(0).lastHiddenAt = occlusion.now; + const states = []; + for (let i = 0; i < 80; i++) states.push(paint(16)); + assert.equal(states[0], true); + assert.equal(states.at(-1), false, 'ran out at the fast pace'); + // The frames slow down, which lengthens the hide time for new answers. + const slow = []; + for (let i = 0; i < 40; i++) slow.push(paint(33)); + assert.ok(occlusion.reappearMs > SURFACE_REAPPEAR_MS); + assert.ok( + slow.every((hidden) => hidden === false), + 'and stays out', + ); +}); + +test('a pause in painting does not run the hidden time out', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(0); + occlusion.isHidden('traffic', 'a', anywhere); + probe.answer(true, occlusion.now); + occlusion.beginFrame(16); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + // Nothing is painted for a minute (a hidden tab, an idle scene). On the + // first frame back the contact is still hidden: no answer could have come. + const before = occlusion.now; + occlusion.beginFrame(60_016); + assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + // A clock that jumps backwards moves nothing. + occlusion.beginFrame(100); + assert.equal(occlusion.now - before, SURFACE_MAX_FRAME_STEP_MS); +}); + +test('prune forgets contacts that are no longer drawn; clear forgets everything', () => { + const probe = manualProbe(); + const occlusion = new SurfaceOcclusion(probe); + let wall = 0; + /** Paint frames for `ms` of painted time, drawing the given contacts. */ + const paint = (ms, ids) => { + for (const end = occlusion.now + ms; occlusion.now < end;) { + wall += 50; + occlusion.beginFrame(wall); + for (const id of ids) occlusion.isHidden('traffic', id, anywhere); + } + }; + paint(100, ['gone', 'here']); + paint(SURFACE_FORGET_MS + 50, ['here']); + occlusion.prune(); + assert.deepEqual([...occlusion.contacts.get('traffic').keys()], ['here']); + paint(SURFACE_FORGET_MS + 50, []); + occlusion.prune(); + assert.equal(occlusion.contacts.size, 0); + + occlusion.isHidden('traffic', 'again', anywhere); + occlusion.clear(); + assert.equal(occlusion.contacts.size, 0); + assert.equal(occlusion.testedCount, 0); +}); diff --git a/src/data/fixtures/fakeDepthScene.mjs b/src/data/fixtures/fakeDepthScene.mjs new file mode 100644 index 000000000..47e969717 --- /dev/null +++ b/src/data/fixtures/fakeDepthScene.mjs @@ -0,0 +1,339 @@ +/** + * A stand-in scene and WebGL context for tests of the occlusion probe: just + * the members `sceneOcclusionProbe.js` uses, with depth images the test + * controls. + * + * The fake context cannot run GLSL. When the probe draws a point inside a + * query, the fake decides whether the point draws by applying the same rule + * to the same inputs the vertex shader gets (the uploaded attributes, the + * bound depth textures and the uniforms), using the CPU decode from + * `detectionOcclusion.js`. That checks everything around the shader; the + * shader itself is checked against `scene.pickPosition` in a real browser. + */ +import * as Cesium from 'cesium'; +import { depthToViewDistance, unpackDepth } from '../detectionOcclusion.js'; + +/** Inverse of `unpackDepth`: four base-255 digits, most significant first. */ +export function packDepth(depth) { + const bytes = []; + let rest = Math.min(Math.max(depth, 0), 1); + for (let i = 0; i < 4; i++) { + rest *= 255; + const digit = Math.min(255, Math.floor(rest)); + bytes.push(digit); + rest -= digit; + } + return bytes; +} + +/** Inverse of `depthToViewDistance`. */ +export function viewDistanceToDepth(distance, near, far, logDepth) { + if (logDepth) + return Math.log2(distance - near + 1) / Math.log2(far - near + 1); + return (far * (distance - near)) / (distance * (far - near)); +} + +/** + * An RGBA8 depth image filled from a function of the texel. + * @param {number} width + * @param {number} height + * @param {(column: number, row: number) => number} depthAt Window depth; rows + * count from the bottom. + */ +export function depthImage(width, height, depthAt) { + const data = new Uint8Array(width * height * 4); + for (let row = 0; row < height; row++) + for (let column = 0; column < width; column++) + data.set(packDepth(depthAt(column, row)), (row * width + column) * 4); + return { width, height, data }; +} + +const GL = { + FRAMEBUFFER: 0x8d40, + READ_FRAMEBUFFER: 0x8ca8, + DRAW_FRAMEBUFFER: 0x8ca9, + FRAMEBUFFER_BINDING: 0x8ca6, + DRAW_FRAMEBUFFER_BINDING: 0x8ca6, + READ_FRAMEBUFFER_BINDING: 0x8caa, + TEXTURE_BINDING_2D: 0x8069, + TEXTURE_2D: 0x0de1, + TEXTURE0: 0x84c0, + ACTIVE_TEXTURE: 0x84e0, + COLOR_ATTACHMENT0: 0x8ce0, + FRAMEBUFFER_COMPLETE: 0x8cd5, + ARRAY_BUFFER: 0x8892, + ARRAY_BUFFER_BINDING: 0x8894, + VERTEX_ARRAY_BINDING: 0x85b5, + CURRENT_PROGRAM: 0x8b8d, + STREAM_DRAW: 0x88e0, + SCISSOR_TEST: 0x0c11, + VIEWPORT: 0x0ba2, + POINTS: 0x0000, + FLOAT: 0x1406, + RGBA: 0x1908, + RGBA8: 0x8058, + UNSIGNED_BYTE: 0x1401, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + ANY_SAMPLES_PASSED_CONSERVATIVE: 0x8d6a, + QUERY_RESULT: 0x8866, + QUERY_RESULT_AVAILABLE: 0x8867, +}; + +/** + * A recording WebGL context. `webgl2: false` leaves out queries, vertex + * arrays and immutable textures, as a WebGL 1 context does. Query results + * stay unavailable until `finishGpu()`. + */ +export function createFakeGl({ webgl2 = true } = {}) { + const state = { + read: null, + draw: null, + units: [null, null, null, null, null, null, null, null], + unit: 0, + array: null, + vertexArray: null, + program: null, + scissor: false, + viewport: [0, 0, 1920, 1080], + lost: false, + /** Whether a point that should draw does (false: a broken draw path). */ + drawsLand: true, + compiles: true, + }; + const counts = { + readPixels: 0, + queries: 0, + draws: 0, + resultReads: 0, + deleted: 0, + }; + const liveQueries = new Set(); + let activeQuery = null; + const gl = { + ...GL, + state, + counts, + liveQueries, + isContextLost: () => state.lost, + getParameter(name) { + if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read; + if (name === GL.DRAW_FRAMEBUFFER_BINDING) return state.draw; + if (name === GL.TEXTURE_BINDING_2D) return state.units[state.unit]; + if (name === GL.ACTIVE_TEXTURE) return GL.TEXTURE0 + state.unit; + if (name === GL.ARRAY_BUFFER_BINDING) return state.array; + if (name === GL.VERTEX_ARRAY_BINDING) return state.vertexArray; + if (name === GL.CURRENT_PROGRAM) return state.program; + if (name === GL.VIEWPORT) return Int32Array.from(state.viewport); + return null; + }, + isEnabled: (cap) => cap === GL.SCISSOR_TEST && state.scissor, + enable(cap) { + if (cap === GL.SCISSOR_TEST) state.scissor = true; + }, + disable(cap) { + if (cap === GL.SCISSOR_TEST) state.scissor = false; + }, + createFramebuffer: () => ({ image: null }), + createTexture: () => ({ image: null }), + createBuffer: () => ({ data: null }), + deleteFramebuffer() { + counts.deleted++; + }, + deleteTexture() { + counts.deleted++; + }, + deleteBuffer() { + counts.deleted++; + }, + activeTexture(unit) { + state.unit = unit - GL.TEXTURE0; + }, + bindTexture(_target, texture) { + state.units[state.unit] = texture; + }, + bindFramebuffer(target, framebuffer) { + if (target !== GL.DRAW_FRAMEBUFFER) state.read = framebuffer; + if (target !== GL.READ_FRAMEBUFFER) state.draw = framebuffer; + }, + bindBuffer(_target, buffer) { + state.array = buffer; + }, + readPixels(x, y, width, height, _format, _type, destination) { + counts.readPixels++; + const image = state.read.image; + for (let row = 0; row < height; row++) { + const from = ((y + row) * image.width + x) * 4; + destination.set( + image.data.subarray(from, from + width * 4), + row * width * 4, + ); + } + }, + }; + if (webgl2) { + Object.assign(gl, { + texStorage2D(_target, _levels, _format, width, height) { + state.units[state.unit].image = { + width, + height, + data: new Uint8Array(width * height * 4), + }; + }, + framebufferTexture2D(_target, _attachment, _kind, texture) { + state.draw.image = texture.image; + }, + checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE, + createShader: () => ({}), + shaderSource() {}, + compileShader() {}, + getShaderParameter: () => state.compiles, + deleteShader() {}, + createProgram: () => ({ uniforms: {} }), + attachShader() {}, + linkProgram() {}, + getProgramParameter: () => true, + deleteProgram() { + counts.deleted++; + }, + getUniformLocation: (_program, name) => name, + useProgram(program) { + state.program = program; + }, + viewport(x, y, width, height) { + state.viewport = [x, y, width, height]; + }, + uniform1i(name, value) { + state.program.uniforms[name] = value; + }, + uniform4f(name, x, y, z, w) { + state.program.uniforms[name] = [x, y, z, w]; + }, + createVertexArray: () => ({ buffer: null }), + deleteVertexArray() { + counts.deleted++; + }, + bindVertexArray(vertexArray) { + state.vertexArray = vertexArray; + }, + enableVertexAttribArray() {}, + vertexAttribPointer() { + state.vertexArray.buffer = state.array; + }, + bufferData(_target, data) { + state.array.data = Float32Array.from(data); + }, + createQuery() { + const query = { drew: false, available: false, ended: false }; + liveQueries.add(query); + return query; + }, + deleteQuery(query) { + liveQueries.delete(query); + }, + beginQuery(_target, query) { + counts.queries++; + query.drew = false; + query.available = false; + query.ended = false; + activeQuery = query; + }, + endQuery() { + activeQuery.ended = true; + activeQuery = null; + }, + /** The vertex shader's rule, applied to what the shader would be given. */ + drawArrays(_mode, first) { + counts.draws++; + const uniforms = state.program.uniforms; + const data = state.vertexArray.buffer.data; + const u = data[first * 3]; + const v = data[first * 3 + 1]; + const threshold = data[first * 3 + 2]; + let surface = -1; + for (let i = 0; i < uniforms.u_count && surface < 0; i++) { + const image = state.units[uniforms[`u_depth${i}`]].image; + const column = Math.min(image.width - 1, Math.floor(u * image.width)); + const row = Math.min(image.height - 1, Math.floor(v * image.height)); + const distance = depthToViewDistance( + unpackDepth(image.data, (row * image.width + column) * 4), + uniforms.u_near[i], + uniforms.u_far[i], + uniforms.u_logDepth === 1, + ); + if (distance === distance) surface = distance; + } + const hidden = surface > 0 && surface < threshold; + // A point at the middle of the viewport lands only if that is the + // target's one pixel and the scissor test is not in the way. A + // sampler left on a unit with no texture fails the whole draw. + const samplersBound = [0, 1, 2, 3].every( + (i) => state.units[uniforms[`u_depth${i}`]]?.image, + ); + const lands = + state.drawsLand && + samplersBound && + !state.scissor && + state.draw?.image?.width === 1 && + state.viewport.join() === '0,0,1,1'; + if (activeQuery && !hidden && lands) activeQuery.drew = true; + }, + getQueryParameter(query, name) { + if (name === GL.QUERY_RESULT_AVAILABLE) return query.available; + counts.resultReads++; + return query.available && query.drew ? 1 : 0; + }, + }); + } + /** Let every query issued so far finish. */ + gl.finishGpu = () => { + for (const query of liveQueries) if (query.ended) query.available = true; + }; + return gl; +} + +/** + * A scene drawn in the given depth ranges, nearest first. + * @param {{ + * gl?: object, + * ranges: Array<{near: number, far: number, image: object}>, + * logDepth?: boolean, fovy?: number, aspectRatio?: number, + * viewMatrix?: Cesium.Matrix4, + * }} options + */ +export function createFakeDepthScene({ + gl = createFakeGl(), + ranges, + logDepth = true, + fovy = Math.PI / 2, + aspectRatio = 1, + viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), +}) { + return { + mode: Cesium.SceneMode.SCENE3D, + useDepthPicking: true, + opaqueFrustumNearOffset: 0.9999, + context: { _gl: gl, depthTexture: true, webgl2: !!gl.createQuery }, + _environmentState: { useGlobeDepthFramebuffer: true }, + frameState: { useLogDepth: logDepth }, + camera: { frustum: { fovy, aspectRatio }, viewMatrix }, + view: { + frustumCommandsList: ranges.map(({ near, far }) => ({ near, far })), + pickDepths: ranges.map(({ image }) => { + const texture = { image }; + return { + framebuffer: { + _framebuffer: { image }, + getColorTexture: () => ({ + width: image.width, + height: image.height, + _texture: texture, + }), + }, + }; + }), + }, + }; +} diff --git a/src/data/sceneOcclusionProbe.js b/src/data/sceneOcclusionProbe.js new file mode 100644 index 000000000..070d5fe9f --- /dev/null +++ b/src/data/sceneOcclusionProbe.js @@ -0,0 +1,773 @@ +/** + * Asks the GPU, per contact, whether the scene drew something in front of it, + * without waiting for the answer. + * + * The scene already keeps the depth of the frame it has just drawn (for + * picking). `scene.pickPosition` reads one texel of it back and waits, a + * fraction of a millisecond to over a millisecond per call; asking once per + * labelled contact turns a 2 ms frame into a 40 ms one. Copying the whole + * depth image back instead is no better where it matters: Chromium answers + * `getBufferSubData` with a round trip to its GPU process even after a sync + * object has signalled, measured here at 1.5 ms on a quiet machine and 15 to + * 25 ms on a busy one. + * + * So nothing is read back at all. Each tested contact becomes one point drawn + * into a private 1x1 target, inside an occlusion query. The vertex shader looks + * up the frame's depth at the contact's pixel, decodes it, and throws the point + * away when the drawn surface is nearer than the contact's threshold (see + * `projectContact`). The query's "did anything draw" bit is the answer, and + * asking whether a query result is in is the one question WebGL 2 lets a page + * put to the GPU without waiting (Chromium publishes results to the page + * through shared memory between frames): `_collect` just looks. + * + * - `beginFrame(scene, now)` collects the answers that have arrived. + * - `add(contact, position)` queues a contact for testing against this frame. + * - `endFrame()` draws the queued points. Answers land a frame or two later. + * + * A WebGL 1 context has no queries. There the probe falls back to reading the + * frame's depth back synchronously, at most every `SYNC_READ_INTERVAL_MS`, and + * answering on the CPU; `mode` then reads `'sync'`. Where the scene keeps no + * pick depth, or is not a 3D perspective view, `mode` reads `'off'`, nothing + * is tested and callers carry on as if no contact were hidden. + * + * Every read of renderer internals is in `readSceneDepthSource` below; the + * rest of the file is plain WebGL on the handles that function returns. + */ +import * as Cesium from 'cesium'; +import { DepthSnapshot, projectContact } from './detectionOcclusion.js'; + +/** + * Depth ranges tested, nearest first. Logarithmic depth draws nearly every + * view in one or two; anything past the fourth is beyond where depth-tested + * contacts are labelled. + */ +export const PROBE_MAX_FRUSTUMS = 4; + +/** Contacts tested per frame; the rest wait for the next one. */ +export const PROBE_MAX_PER_FRAME = 256; + +/** Painted frames a batch of queries may stay unanswered before it is dropped. */ +export const PROBE_MAX_PENDING_FRAMES = 120; + +/** Batches in a row whose control point failed before the probe stands down. */ +export const PROBE_MAX_CONTROL_FAILURES = 3; + +/** How long the probe stands down after a failure before trying again. */ +export const PROBE_RETRY_AFTER_MS = 10_000; + +/** Shortest gap between two synchronous depth reads (WebGL 1 fallback). */ +export const SYNC_READ_INTERVAL_MS = 125; + +/** + * Everything this module takes from the renderer, in one place. + * + * RENDERER INTERNALS. Checked against cesium 1.138.0 (@cesium/engine 22.3.0); + * re-check on an engine upgrade. Undocumented members used, and what each is + * assumed to be: + * + * - `scene.context._gl`: the WebGL context. `scene.context.webgl2` says which. + * - `scene.view.frustumCommandsList[i].near` / `.far`: the depth ranges the + * last frame was drawn in, nearest first. + * - `scene.view.pickDepths[i].framebuffer`: one framebuffer per depth range, + * refreshed every frame while `scene.useDepthPicking` is on and + * `scene._environmentState.useGlobeDepthFramebuffer` is true, whose first + * colour attachment is an RGBA8 texture holding window-space depth packed + * four bytes to a texel (`czm_packDepth`; see `unpackDepth`). + * `framebuffer._framebuffer` is the raw WebGL framebuffer, and + * `framebuffer.getColorTexture(0)` the texture (`._texture` its raw handle). + * - Opaque geometry in every range but the nearest is drawn with its near + * plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as + * `scene.pickPosition` assumes when it decodes the same textures. + * - `scene.frameState.useLogDepth`: whether depth is logarithmic (see + * `depthToViewDistance`). + * + * Any of these missing or shaped differently returns null, which callers treat + * as "no depth". + * + * @param {Cesium.Scene} scene + * @param {Float64Array} [viewScratch] Sixteen numbers to write the view matrix + * into instead of allocating; the result's `view` is then this array. + * @returns {null | { + * gl: WebGLRenderingContext|WebGL2RenderingContext, webgl2: boolean, + * width: number, height: number, framebuffers: WebGLFramebuffer[], + * textures: WebGLTexture[], + * frustums: Array<{near: number, far: number}>, logDepth: boolean, + * view: Float64Array, tanHalfFovX: number, tanHalfFovY: number, + * }} + */ +export function readSceneDepthSource(scene, viewScratch) { + try { + if (!scene || scene.mode !== Cesium.SceneMode.SCENE3D) return null; + if (scene.useDepthPicking !== true) return null; + const context = scene.context; + const gl = context?._gl; + if (!gl || context.depthTexture !== true) return null; + if (scene._environmentState?.useGlobeDepthFramebuffer !== true) return null; + + const camera = scene.camera; + const frustum = camera?.frustum; + const fovy = frustum?.fovy; + const aspectRatio = frustum?.aspectRatio; + if (!(fovy > 0 && fovy < Math.PI) || !(aspectRatio > 0)) return null; + if (frustum.xOffset || frustum.yOffset) return null; + + const view = scene.view; + const ranges = view?.frustumCommandsList; + const pickDepths = view?.pickDepths; + if (!ranges?.length || !pickDepths?.length) return null; + const count = Math.min( + ranges.length, + pickDepths.length, + PROBE_MAX_FRUSTUMS, + ); + const nearOffset = Number(scene.opaqueFrustumNearOffset) || 1; + const framebuffers = []; + const textures = []; + const frustums = []; + let width = 0; + let height = 0; + for (let i = 0; i < count; i++) { + const framebuffer = pickDepths[i]?.framebuffer; + const raw = framebuffer?._framebuffer; + const texture = framebuffer?.getColorTexture?.(0); + const near = ranges[i]?.near * (i === 0 ? 1 : nearOffset); + const far = ranges[i]?.far; + if (!raw || !texture?._texture || !(near > 0) || !(far > near)) + return null; + if (i === 0) { + width = texture.width; + height = texture.height; + } else if (texture.width !== width || texture.height !== height) { + return null; + } + framebuffers.push(raw); + textures.push(texture._texture); + frustums.push({ near, far }); + } + if (!(width > 0) || !(height > 0)) return null; + + const tanHalfFovY = Math.tan(fovy / 2); + return { + gl, + webgl2: context.webgl2 === true, + width, + height, + framebuffers, + textures, + frustums, + logDepth: scene.frameState?.useLogDepth === true, + view: Cesium.Matrix4.toArray( + camera.viewMatrix, + viewScratch || new Float64Array(16), + ), + tanHalfFovX: tanHalfFovY * aspectRatio, + tanHalfFovY, + }; + } catch { + return null; + } +} + +/** + * One point per contact. `a_contact` is (u, v, threshold): where the contact + * is in the frame, and the view distance a drawn surface must be under to hide + * it. A hidden contact's point is moved outside the clip volume, so nothing is + * drawn and its query reports no samples. A visible one lands on the single + * pixel of the 1x1 target (the viewport is set to match while these draw). + */ +export const PROBE_VERTEX_SHADER = `#version 300 es +precision highp float; +precision highp sampler2D; +layout(location = 0) in vec3 a_contact; +uniform sampler2D u_depth0; +uniform sampler2D u_depth1; +uniform sampler2D u_depth2; +uniform sampler2D u_depth3; +uniform int u_count; +uniform bool u_logDepth; +uniform vec4 u_near; +uniform vec4 u_far; + +float viewDistance(sampler2D depthTexture, float near, float far) { + ivec2 size = textureSize(depthTexture, 0); + ivec2 texel = min(ivec2(a_contact.xy * vec2(size)), size - 1); + float depth = dot( + texelFetch(depthTexture, texel, 0), + vec4(1.0, 1.0 / 255.0, 1.0 / 65025.0, 1.0 / 16581375.0)); + if (depth <= 0.0 || depth >= 1.0) return -1.0; + if (u_logDepth) return near + exp2(depth * log2(far - near + 1.0)) - 1.0; + return near * far / (far - depth * (far - near)); +} + +void main() { + float surface = viewDistance(u_depth0, u_near.x, u_far.x); + if (surface < 0.0 && u_count > 1) surface = viewDistance(u_depth1, u_near.y, u_far.y); + if (surface < 0.0 && u_count > 2) surface = viewDistance(u_depth2, u_near.z, u_far.z); + if (surface < 0.0 && u_count > 3) surface = viewDistance(u_depth3, u_near.w, u_far.w); + bool hidden = surface > 0.0 && surface < a_contact.z; + gl_Position = hidden ? vec4(2.0, 2.0, 2.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0); + gl_PointSize = 1.0; +} +`; + +const FRAGMENT_SHADER = `#version 300 es +precision mediump float; +out vec4 color; +void main() { color = vec4(1.0); } +`; + +function canQuery(gl) { + return ( + typeof gl.createQuery === 'function' && + typeof gl.beginQuery === 'function' && + typeof gl.getQueryParameter === 'function' && + typeof gl.createVertexArray === 'function' + ); +} + +function compile(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + gl.deleteShader(shader); + return null; + } + return shader; +} + +/** Owns the GPU objects and the query batches in flight. */ +export class SceneOcclusionProbe { + /** + * @param {{ + * now?: () => number, + * readSource?: typeof readSceneDepthSource, + * }} [options] + */ + constructor({ + now = () => performance.now(), + readSource = readSceneDepthSource, + } = {}) { + this._clock = now; + this._readSource = readSource; + /** Most tests one frame takes; read by whoever schedules them. */ + this.maxPerFrame = PROBE_MAX_PER_FRAME; + /** `'async'` (queries), `'sync'` (blocking read), or `'off'`. */ + this.mode = 'off'; + /** Milliseconds the last frame spent collecting answers and drawing. */ + this.lastFrameMs = 0; + /** Contact tests started in the last frame. */ + this.lastFrameTests = 0; + this._scene = null; + this._now = 0; + this._source = undefined; + this._gl = null; + this._resources = null; + this._queryPool = []; + this._batches = []; + this._attributes = new Float32Array((PROBE_MAX_PER_FRAME + 1) * 3); + this._queued = []; + this._projected = { u: 0, v: 0, threshold: 0 }; + this._controlFailures = 0; + this._retryAt = Number.NEGATIVE_INFINITY; + this._frame = 0; + this._view = new Float64Array(16); + this._snapshot = null; + this._snapshotAt = Number.NEGATIVE_INFINITY; + this._spent = 0; + } + + /** + * Open a frame: take in the answers that have arrived. Call after the scene + * has rendered (a post-render listener), before `add`. + * @param {Cesium.Scene} scene + * @param {number} now The caller's clock, in milliseconds. Answers are + * dated by it, so it must be the clock the caller judges their age on. + */ + beginFrame(scene, now) { + const started = this._clock(); + this._scene = scene; + this._now = now; + this._frame++; + this._source = undefined; + // A frame that never reached `endFrame` leaves its contacts untested. + this._release(this._queued); + this._queued.length = 0; + this.lastFrameTests = 0; + this._collect(); + this._spent = this._clock() - started; + } + + /** + * Queue a contact for testing against the frame just drawn. + * @param {{lastHiddenAt: number, pending: boolean, answered: boolean}} + * contact Updated when the answer arrives: `pending` cleared, `answered` + * set and, if the contact was hidden, `lastHiddenAt` set to the time of + * the frame it was tested against. + * @param {{x: number, y: number, z: number}} position + * @returns {boolean} False when the contact cannot be tested now. + */ + add(contact, position) { + if (this._source === undefined) this._openSource(); + const source = this._source; + if (!source) return false; + if (this.mode === 'sync') { + const hidden = this._snapshot?.hides(position); + if (hidden === null || hidden === undefined) return false; + contact.answered = true; + if (hidden) contact.lastHiddenAt = this._now; + this.lastFrameTests++; + return true; + } + const queued = this._queued; + if (queued.length >= PROBE_MAX_PER_FRAME) return false; + const projected = this._projected; + if ( + !projectContact( + source.view, + source.tanHalfFovX, + source.tanHalfFovY, + position, + projected, + ) + ) + return false; + // Slot 0 is the control point; contacts start at 1. + const at = (queued.length + 1) * 3; + const attributes = this._attributes; + attributes[at] = projected.u; + attributes[at + 1] = projected.v; + attributes[at + 2] = projected.threshold; + queued.push(contact); + contact.pending = true; + this.lastFrameTests++; + return true; + } + + /** Close the frame: draw the queued contacts' queries. */ + endFrame() { + const started = this._clock(); + if (this._queued.length > 0 && this._source && this.mode === 'async') { + try { + this._draw(this._source); + } catch { + this._release(this._queued); + this._fail(); + } + } + this._queued.length = 0; + this._source = undefined; + this.lastFrameMs = this._spent + (this._clock() - started); + } + + /** + * Drop every test in flight but keep the GPU objects, so that switching the + * overlay off and on again does not compile the shader a second time. + */ + reset() { + const gl = this._gl; + for (const batch of this._batches) this._release(batch.contacts); + this._release(this._queued); + try { + if (gl && !gl.isContextLost?.()) { + for (const batch of this._batches) + for (let i = batch.cursor; i < batch.queries.length; i++) + this._queryPool.push(batch.queries[i]); + } + } catch { + // A context that is going away has already released them. + } + this._batches.length = 0; + this._queued.length = 0; + this._source = undefined; + this._snapshot = null; + this._snapshotAt = Number.NEGATIVE_INFINITY; + this._controlFailures = 0; + this._retryAt = Number.NEGATIVE_INFINITY; + this.mode = 'off'; + } + + /** Delete the GPU objects and drop every test in flight. */ + destroy() { + this.reset(); + const gl = this._gl; + try { + if (gl && !gl.isContextLost?.()) { + for (const query of this._queryPool) gl.deleteQuery(query); + const resources = this._resources; + if (resources) { + gl.deleteProgram(resources.program); + gl.deleteVertexArray(resources.vertexArray); + gl.deleteBuffer(resources.buffer); + gl.deleteFramebuffer(resources.framebuffer); + gl.deleteTexture(resources.texture); + } + } + } catch { + // A context that is going away has already released them. + } + this._queryPool.length = 0; + this._resources = null; + this._gl = null; + } + + /** Tests that will never be answered stop counting as in flight. */ + _release(contacts) { + for (const contact of contacts) if (contact) contact.pending = false; + } + + /** + * Something in the query path broke: stand down for a while and keep + * everything drawn, then try again in case the cause was passing. + */ + _fail() { + this._retryAt = this._now + PROBE_RETRY_AFTER_MS; + this._controlFailures = 0; + this.mode = 'off'; + } + + /** Read this frame's depth source, once, when the first contact asks. */ + _openSource() { + this._source = null; + if (this._now < this._retryAt) return; + const source = this._readSource(this._scene, this._view); + if (!source) { + this.mode = 'off'; + return; + } + if (source.gl !== this._gl) { + // A different context (a rebuilt viewer) orphans whatever was in flight. + this.destroy(); + this._gl = source.gl; + } + try { + if (source.gl.isContextLost?.()) { + // Everything made on the lost context went with it. + this.reset(); + this._queryPool.length = 0; + this._resources = null; + return; + } + if (source.webgl2 && canQuery(source.gl)) { + if (!this._resources && !this._createResources(source.gl)) { + this._fail(); + return; + } + this.mode = 'async'; + } else { + this.mode = 'sync'; + if (this._now - this._snapshotAt >= SYNC_READ_INTERVAL_MS) { + this._snapshot = this._readSync(source); + this._snapshotAt = this._now; + } + } + this._source = source; + } catch { + this._fail(); + } + } + + /** + * Compile the shader and make the 1x1 target. This is the one place the + * probe does wait on the GPU process (compile, link and framebuffer status + * are answered synchronously): once per WebGL context, on the first frame a + * depth-tested contact is in view. `reset` keeps the result. + */ + _createResources(gl) { + const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER); + const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); + if (!vertex || !fragment) return false; + const program = gl.createProgram(); + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + gl.deleteShader(vertex); + gl.deleteShader(fragment); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + gl.deleteProgram(program); + return false; + } + const uniform = (name) => gl.getUniformLocation(program, name); + const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING); + const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING); + const previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D); + const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING); + + const vertexArray = gl.createVertexArray(); + const buffer = gl.createBuffer(); + gl.bindVertexArray(vertexArray); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(previousArray); + gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer); + + const texture = gl.createTexture(); + const framebuffer = gl.createFramebuffer(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1); + gl.bindTexture(gl.TEXTURE_2D, previousTexture); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D( + gl.DRAW_FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + texture, + 0, + ); + const complete = + gl.checkFramebufferStatus(gl.DRAW_FRAMEBUFFER) === + gl.FRAMEBUFFER_COMPLETE; + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw); + if (!complete) { + // Leave nothing half-made behind, so a later retry starts from scratch. + gl.deleteProgram(program); + gl.deleteVertexArray(vertexArray); + gl.deleteBuffer(buffer); + gl.deleteFramebuffer(framebuffer); + gl.deleteTexture(texture); + return false; + } + + this._resources = { + program, + vertexArray, + buffer, + texture, + framebuffer, + depth: [0, 1, 2, 3].map((i) => uniform(`u_depth${i}`)), + count: uniform('u_count'), + logDepth: uniform('u_logDepth'), + near: uniform('u_near'), + far: uniform('u_far'), + }; + return true; + } + + /** + * Draw one query per queued contact, preceded by a control point that must + * always draw. The renderer caches the framebuffer, viewport and scissor + * state it last set, so those are put back exactly as found, along with + * every binding touched here. Depth, stencil, blending and the colour mask + * need no care: the target has no depth or stencil attachment, and a query + * counts samples whether or not they are written. + */ + _draw(source) { + const gl = source.gl; + const resources = this._resources; + const contacts = this._queued.slice(); + const count = contacts.length + 1; + const attributes = this._attributes; + // The control point: mid-frame, with a threshold nothing can be under. + attributes[0] = 0.5; + attributes[1] = 0.5; + attributes[2] = -1; + const layers = source.textures.length; + const frustums = source.frustums; + + const previousProgram = gl.getParameter(gl.CURRENT_PROGRAM); + const previousArray = gl.getParameter(gl.VERTEX_ARRAY_BINDING); + const previousBuffer = gl.getParameter(gl.ARRAY_BUFFER_BINDING); + const previousDraw = gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING); + const previousUnit = gl.getParameter(gl.ACTIVE_TEXTURE); + const previousViewport = gl.getParameter(gl.VIEWPORT); + const previousTextures = []; + const scissor = gl.isEnabled(gl.SCISSOR_TEST); + const queries = []; + const pool = this._queryPool; + let open = false; + try { + for (let i = 0; i < layers; i++) { + gl.activeTexture(gl.TEXTURE0 + i); + previousTextures.push(gl.getParameter(gl.TEXTURE_BINDING_2D)); + gl.bindTexture(gl.TEXTURE_2D, source.textures[i]); + } + if (scissor) gl.disable(gl.SCISSOR_TEST); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resources.framebuffer); + gl.viewport(0, 0, 1, 1); + gl.useProgram(resources.program); + // Samplers past the ranges in use point at the first range's texture: + // every sampler a program declares must be drawable, read or not. + for (let i = 0; i < PROBE_MAX_FRUSTUMS; i++) + gl.uniform1i(resources.depth[i], i < layers ? i : 0); + gl.uniform1i(resources.count, layers); + gl.uniform1i(resources.logDepth, source.logDepth ? 1 : 0); + gl.uniform4f( + resources.near, + frustums[0].near, + frustums[1]?.near ?? 1, + frustums[2]?.near ?? 1, + frustums[3]?.near ?? 1, + ); + gl.uniform4f( + resources.far, + frustums[0].far, + frustums[1]?.far ?? 2, + frustums[2]?.far ?? 2, + frustums[3]?.far ?? 2, + ); + gl.bindVertexArray(resources.vertexArray); + gl.bindBuffer(gl.ARRAY_BUFFER, resources.buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + attributes.subarray(0, count * 3), + gl.STREAM_DRAW, + ); + for (let i = 0; i < count; i++) { + const query = pool.pop() || gl.createQuery(); + queries.push(query); + gl.beginQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE, query); + open = true; + gl.drawArrays(gl.POINTS, i, 1); + gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE); + open = false; + } + } catch (error) { + // Leave no query open and none unaccounted for; the caller stands down. + try { + if (open) gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE); + for (const query of queries) gl.deleteQuery(query); + } catch { + // Nothing more can be done for them. + } + throw error; + } finally { + gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer); + gl.bindVertexArray(previousArray); + gl.useProgram(previousProgram); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, previousDraw); + if (previousViewport) + gl.viewport( + previousViewport[0], + previousViewport[1], + previousViewport[2], + previousViewport[3], + ); + if (scissor) gl.enable(gl.SCISSOR_TEST); + for (let i = previousTextures.length - 1; i >= 0; i--) { + gl.activeTexture(gl.TEXTURE0 + i); + gl.bindTexture(gl.TEXTURE_2D, previousTextures[i]); + } + gl.activeTexture(previousUnit); + } + + this._batches.push({ + queries, + contacts, + at: this._now, + frame: this._frame, + cursor: 0, + control: false, + }); + } + + /** + * Take in whatever answers have arrived, oldest batch first, without + * waiting for any that have not. An answer is dated to the frame its test + * was drawn on, so one that arrives late (the overlay was not painting for + * a while) is as stale as it is old. + */ + _collect() { + const batches = this._batches; + if (batches.length === 0) return; + const gl = this._gl; + try { + if (gl.isContextLost?.()) { + // Queries made on a lost context are gone with it. + this.reset(); + this._queryPool.length = 0; + this._resources = null; + return; + } + while (batches.length > 0) { + const batch = batches[0]; + const { queries, contacts } = batch; + while (batch.cursor < queries.length) { + const query = queries[batch.cursor]; + if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break; + const drew = !!gl.getQueryParameter(query, gl.QUERY_RESULT); + if (batch.cursor === 0) { + batch.control = drew; + } else { + const contact = contacts[batch.cursor - 1]; + contact.pending = false; + contact.answered = true; + // Without the control point's yes, a no means nothing. + if (!drew && batch.control && batch.at > contact.lastHiddenAt) + contact.lastHiddenAt = batch.at; + } + this._queryPool.push(query); + batch.cursor++; + } + if (batch.cursor < queries.length) break; + batches.shift(); + if (batch.control) { + this._controlFailures = 0; + } else if (++this._controlFailures >= PROBE_MAX_CONTROL_FAILURES) { + this._fail(); + } + } + // Whatever is still unanswered after too many frames is dropped, + // wherever it stands in the queue, so its contacts can be tested again. + const overdue = this._frame - PROBE_MAX_PENDING_FRAMES; + for (let i = batches.length - 1; i >= 0; i--) { + const batch = batches[i]; + if (batch.frame >= overdue) continue; + for (let k = batch.cursor; k < batch.queries.length; k++) + gl.deleteQuery(batch.queries[k]); + for ( + let k = Math.max(0, batch.cursor - 1); + k < batch.contacts.length; + k++ + ) + batch.contacts[k].pending = false; + batches.splice(i, 1); + } + } catch { + for (const batch of batches) this._release(batch.contacts); + batches.length = 0; + this._fail(); + } + } + + /** + * No queries (WebGL 1): read each depth range at full size and wait for it. + * Costs milliseconds per read; used only where nothing better exists. + */ + _readSync(source) { + const gl = source.gl; + const { width, height } = source; + const layers = source.framebuffers.length; + const layerBytes = width * height * 4; + const byteLength = layerBytes * layers; + const bytes = + this._snapshot?.bytes.length === byteLength + ? this._snapshot.bytes + : new Uint8Array(byteLength); + const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING); + for (let i = 0; i < layers; i++) { + gl.bindFramebuffer(gl.FRAMEBUFFER, source.framebuffers[i]); + gl.readPixels( + 0, + 0, + width, + height, + gl.RGBA, + gl.UNSIGNED_BYTE, + bytes.subarray(i * layerBytes, (i + 1) * layerBytes), + ); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, previous); + return new DepthSnapshot({ + width, + height, + bytes, + frustums: source.frustums, + logDepth: source.logDepth, + // The source's matrix is a scratch array the next frame overwrites. + view: Float64Array.from(source.view), + tanHalfFovX: source.tanHalfFovX, + tanHalfFovY: source.tanHalfFovY, + }); + } +} diff --git a/src/data/sceneOcclusionProbe.test.mjs b/src/data/sceneOcclusionProbe.test.mjs new file mode 100644 index 000000000..d4ba75e1e --- /dev/null +++ b/src/data/sceneOcclusionProbe.test.mjs @@ -0,0 +1,664 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + PROBE_MAX_CONTROL_FAILURES, + PROBE_MAX_FRUSTUMS, + PROBE_MAX_PENDING_FRAMES, + PROBE_MAX_PER_FRAME, + PROBE_RETRY_AFTER_MS, + PROBE_VERTEX_SHADER, + SYNC_READ_INTERVAL_MS, + SceneOcclusionProbe, + readSceneDepthSource, +} from './sceneOcclusionProbe.js'; +import { + createFakeDepthScene, + createFakeGl, + depthImage, + viewDistanceToDepth, +} from './fixtures/fakeDepthScene.mjs'; + +const NEAR = 1; +const FAR = 100_000; + +/** Left half a wall 400 m away, right half ground 500 m away. */ +function wallImage(width, height, logDepth = true, wall = 400) { + return depthImage(width, height, (column) => + viewDistanceToDepth(column < width / 2 ? wall : 500, NEAR, FAR, logDepth), + ); +} + +function wallScene({ width = 64, height = 32, logDepth = true, gl } = {}) { + return createFakeDepthScene({ + gl, + logDepth, + aspectRatio: width / height, + ranges: [ + { near: NEAR, far: FAR, image: wallImage(width, height, logDepth) }, + ], + }); +} + +const behindWall = { x: -250, y: 0, z: -500 }; +const onStreet = { x: 250, y: 0, z: -500 }; +const contact = () => ({ + lastHiddenAt: Number.NEGATIVE_INFINITY, + pending: false, +}); + +/** One frame: collect, queue the given contacts, draw. */ +function frame(probe, scene, now, tests = []) { + probe.beginFrame(scene, now); + const accepted = tests.map(([entry, position]) => probe.add(entry, position)); + probe.endFrame(); + return accepted; +} + +test('a test is drawn at once and answered on a later frame, without anything being read back', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const hidden = contact(); + const shown = contact(); + assert.equal(probe.mode, 'off'); + assert.deepEqual( + frame(probe, scene, 1000, [ + [hidden, behindWall], + [shown, onStreet], + ]), + [true, true], + ); + assert.equal(probe.mode, 'async'); + assert.equal(probe.lastFrameTests, 2); + assert.equal(hidden.pending, true); + assert.equal( + gl.counts.queries, + 3, + 'the control point and one query per contact', + ); + assert.equal(gl.counts.draws, 3); + + // Frames pass before the GPU has answered: nothing changes, nothing waits. + frame(probe, scene, 1016); + frame(probe, scene, 1032); + assert.equal(hidden.pending, true); + assert.equal(hidden.lastHiddenAt, Number.NEGATIVE_INFINITY); + assert.equal(gl.counts.resultReads, 0); + assert.equal(gl.counts.readPixels, 0); + + gl.finishGpu(); + frame(probe, scene, 1048); + assert.equal(hidden.pending, false); + assert.equal(shown.pending, false); + assert.equal(hidden.answered, true); + assert.equal(shown.answered, true); + assert.equal(hidden.lastHiddenAt, 1000, 'dated to its test frame'); + assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY); + assert.equal(gl.counts.readPixels, 0, 'depth never leaves the GPU'); +}); + +test('queries are reused, and every depth range is tested nearest first', () => { + const width = 64; + const height = 32; + const farNear = 900; + const farFar = 5_000_000; + const scene = createFakeDepthScene({ + aspectRatio: 2, + ranges: [ + // Near range: the wall on the left, nothing drawn on the right. + { + near: NEAR, + far: 1000, + image: depthImage(width, height, (column) => + column < width / 2 ? viewDistanceToDepth(400, NEAR, 1000, true) : 1, + ), + }, + // Far range: ground 20 km away, drawn with its near plane pulled in. + { + near: farNear, + far: farFar, + image: depthImage(width, height, () => + viewDistanceToDepth(20_000, farNear * 0.9999, farFar, true), + ), + }, + ], + }); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const cases = [ + [contact(), { x: -7500, y: 0, z: -15_000 }, true], // behind the near wall + [contact(), { x: 7500, y: 0, z: -15_000 }, false], // above the far ground + [contact(), { x: 15_000, y: 0, z: -30_000 }, true], // under the far ground + [contact(), { x: -150, y: 0, z: -300 }, false], // in front of the wall + ]; + for (let round = 0; round < 3; round++) { + frame( + probe, + scene, + round * 100, + cases.map(([entry, position]) => [entry, position]), + ); + gl.finishGpu(); + frame(probe, scene, round * 100 + 16); + for (const [entry, , hidden] of cases) { + assert.equal(entry.pending, false); + assert.equal(entry.lastHiddenAt === round * 100, hidden); + } + } + assert.equal(gl.liveQueries.size, 5, 'five queries serve every round'); + const source = readSceneDepthSource(scene); + assert.equal(source.frustums[1].near, farNear * 0.9999); +}); + +test('standard depth scenes are tested too', () => { + const scene = wallScene({ logDepth: false }); + const probe = new SceneOcclusionProbe(); + const hidden = contact(); + const shown = contact(); + frame(probe, scene, 0, [ + [hidden, behindWall], + [shown, onStreet], + ]); + scene.context._gl.finishGpu(); + frame(probe, scene, 16); + assert.equal(hidden.lastHiddenAt, 0); + assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY); +}); + +test('every binding the renderer relies on is left as found', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const found = { + read: { image: null }, + draw: { image: null }, + units: [{}, {}, {}, {}, {}, {}, {}, {}], + unit: 5, + array: {}, + vertexArray: {}, + program: { uniforms: {} }, + scissor: true, + viewport: [12, 34, 640, 480], + }; + Object.assign(gl.state, found, { units: [...found.units] }); + const probe = new SceneOcclusionProbe(); + const hidden = contact(); + frame(probe, scene, 0, [[hidden, behindWall]]); + for (const key of [ + 'read', + 'draw', + 'unit', + 'array', + 'vertexArray', + 'program', + 'scissor', + ]) + assert.equal(gl.state[key], found[key], key); + assert.deepEqual(gl.state.units, found.units); + assert.deepEqual(gl.state.viewport, [12, 34, 640, 480]); + // The scissor test and the offset viewport would both have kept the points + // off the target, so neither was in force for them: the control point drew, + // and the answer counts. + gl.finishGpu(); + frame(probe, scene, 16); + assert.equal(hidden.lastHiddenAt, 0); + assert.equal(gl.state.scissor, true); +}); + +test('a contact outside the frame, or past the frame budget, is not taken', () => { + const scene = wallScene(); + const probe = new SceneOcclusionProbe(); + probe.beginFrame(scene, 0); + const outside = contact(); + assert.equal(probe.add(outside, { x: -1100, y: 0, z: -500 }), false); + assert.equal(probe.add(outside, { x: 0, y: 0, z: 500 }), false); + assert.equal(outside.pending, false); + const taken = []; + for (let i = 0; i < PROBE_MAX_PER_FRAME + 10; i++) + taken.push(probe.add(contact(), onStreet)); + probe.endFrame(); + assert.equal(taken.filter(Boolean).length, PROBE_MAX_PER_FRAME); + assert.equal(taken.lastIndexOf(true), PROBE_MAX_PER_FRAME - 1); + assert.equal(scene.context._gl.counts.queries, PROBE_MAX_PER_FRAME + 1); +}); + +test('a frame with nothing to test touches nothing', () => { + let reads = 0; + const probe = new SceneOcclusionProbe({ + readSource: () => { + reads++; + return null; + }, + }); + frame(probe, {}, 0); + frame(probe, {}, 16); + assert.equal(reads, 0); + assert.equal(probe.lastFrameTests, 0); +}); + +test('no depth source: nothing is tested and nothing is in flight', () => { + const broken = [ + null, + {}, + { ...wallScene(), mode: Cesium.SceneMode.SCENE2D }, + { ...wallScene(), mode: Cesium.SceneMode.COLUMBUS_VIEW }, + { ...wallScene(), useDepthPicking: false }, + { ...wallScene(), _environmentState: { useGlobeDepthFramebuffer: false } }, + { ...wallScene(), view: { frustumCommandsList: [], pickDepths: [] } }, + { ...wallScene(), view: undefined }, + (() => { + const scene = wallScene(); + scene.context.depthTexture = false; + return scene; + })(), + (() => { + const scene = wallScene(); + scene.camera.frustum = { width: 1000, aspectRatio: 2 }; // orthographic + return scene; + })(), + (() => { + const scene = wallScene(); + scene.camera.frustum.xOffset = 10; + return scene; + })(), + (() => { + const scene = wallScene(); + scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet + return scene; + })(), + (() => { + const scene = wallScene(); + Object.defineProperty(scene, 'view', { + get() { + throw new Error('renderer changed shape'); + }, + }); + return scene; + })(), + ]; + for (const scene of broken) { + assert.equal(readSceneDepthSource(scene), null); + const probe = new SceneOcclusionProbe(); + const entry = contact(); + assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]); + assert.equal(probe.mode, 'off'); + assert.equal(entry.pending, false); + } +}); + +test('depth that comes and goes is picked up frame by frame', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + scene.useDepthPicking = false; + assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]); + assert.equal(probe.mode, 'off'); + scene.useDepthPicking = true; + assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [true]); + assert.equal(probe.mode, 'async'); + gl.finishGpu(); + frame(probe, scene, 32); + assert.equal(entry.lastHiddenAt, 16); +}); + +test('depth ranges past the limit are left out, nearest kept', () => { + const image = wallImage(8, 8); + const ranges = []; + for (let i = 0; i < PROBE_MAX_FRUSTUMS + 2; i++) + ranges.push({ near: 10 ** i, far: 10 ** (i + 1), image }); + const source = readSceneDepthSource(createFakeDepthScene({ ranges })); + assert.equal(source.frustums.length, PROBE_MAX_FRUSTUMS); + assert.equal(source.textures.length, PROBE_MAX_FRUSTUMS); + assert.equal(source.frustums[0].near, 1); +}); + +test('a control point that does not draw voids its batch; a run of them stands the probe down for a while', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + // Something outside the probe's reach stops every point from drawing, so + // every query reads "nothing drew", which would otherwise mean "hidden". + gl.state.drawsLand = false; + let now = 0; + for (let i = 0; i < PROBE_MAX_CONTROL_FAILURES; i++) { + const entry = contact(); + assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [true]); + gl.finishGpu(); + now += 16; + frame(probe, scene, now); + assert.equal(entry.pending, false); + assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed'); + now += 16; + } + assert.equal(probe.mode, 'off'); + const stoodDownAt = now - 16; + assert.deepEqual(frame(probe, scene, now, [[contact(), behindWall]]), [ + false, + ]); + assert.deepEqual( + frame(probe, scene, stoodDownAt + PROBE_RETRY_AFTER_MS - 1, [ + [contact(), behindWall], + ]), + [false], + ); + // The cause has passed: the probe picks up where it left off. + gl.state.drawsLand = true; + const entry = contact(); + const retryAt = stoodDownAt + PROBE_RETRY_AFTER_MS; + assert.deepEqual(frame(probe, scene, retryAt, [[entry, behindWall]]), [true]); + assert.equal(probe.mode, 'async'); + gl.finishGpu(); + frame(probe, scene, retryAt + 16); + assert.equal(entry.lastHiddenAt, retryAt); + + // One good batch in between resets the count. + const healthy = new SceneOcclusionProbe(); + const other = wallScene(); + for (let i = 0; i < 6; i++) { + other.context._gl.state.drawsLand = i % 2 === 1; + frame(healthy, other, i * 32, [[contact(), onStreet]]); + other.context._gl.finishGpu(); + frame(healthy, other, i * 32 + 16); + } + assert.equal(healthy.mode, 'async'); +}); + +test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => { + let scene = wallScene(); + scene.context._gl.state.compiles = false; + let probe = new SceneOcclusionProbe(); + let entry = contact(); + assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]); + assert.equal(probe.mode, 'off'); + assert.equal(entry.pending, false); + + // A draw that throws part-way: bindings restored, no query left behind. + scene = wallScene(); + let gl = scene.context._gl; + const found = { + draw: { image: null }, + program: { uniforms: {} }, + viewport: [0, 0, 800, 600], + }; + Object.assign(gl.state, found, { scissor: true }); + let draws = 0; + const drawArrays = gl.drawArrays; + gl.drawArrays = (...args) => { + if (++draws === 2) throw new Error('driver'); + return drawArrays(...args); + }; + probe = new SceneOcclusionProbe(); + entry = contact(); + const other = contact(); + assert.deepEqual( + frame(probe, scene, 0, [ + [entry, behindWall], + [other, onStreet], + ]), + [true, true], + ); + assert.equal(entry.pending, false, 'released when the draw failed'); + assert.equal(other.pending, false); + assert.equal(probe.mode, 'off'); + assert.equal(gl.state.draw, found.draw); + assert.equal(gl.state.program, found.program); + assert.deepEqual(gl.state.viewport, found.viewport); + assert.equal(gl.state.scissor, true); + assert.equal(gl.liveQueries.size, 0); + assert.deepEqual(frame(probe, scene, 16, [[entry, behindWall]]), [false]); + + scene = wallScene(); + gl = scene.context._gl; + probe = new SceneOcclusionProbe(); + entry = contact(); + frame(probe, scene, 0, [[entry, behindWall]]); + gl.getQueryParameter = () => { + throw new Error('driver'); + }; + frame(probe, scene, 16); + assert.equal(entry.pending, false); + assert.equal(probe.mode, 'off'); +}); + +test('a frame that never reaches its end leaves no contact waiting for an answer', () => { + const scene = wallScene(); + const probe = new SceneOcclusionProbe(); + const entry = contact(); + probe.beginFrame(scene, 0); + assert.equal(probe.add(entry, behindWall), true); + assert.equal(entry.pending, true); + // The painter threw before endFrame; the next frame starts clean. + probe.beginFrame(scene, 16); + assert.equal(entry.pending, false); + assert.equal(probe.add(entry, behindWall), true); + probe.endFrame(); + assert.equal( + scene.context._gl.counts.queries, + 2, + 'only the second frame drew', + ); +}); + +test('an answer collected late is dated to its test, not to its arrival', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + frame(probe, scene, 1000, [[entry, behindWall]]); + gl.finishGpu(); + // The overlay paints nothing for 20 seconds, then resumes. + frame(probe, scene, 21_000); + assert.equal(entry.pending, false); + assert.equal(entry.lastHiddenAt, 1000); + // An older answer never overwrites a newer one. + entry.lastHiddenAt = 30_000; + frame(probe, scene, 22_000, [[entry, behindWall]]); + gl.finishGpu(); + frame(probe, scene, 22_016); + assert.equal(entry.lastHiddenAt, 30_000); +}); + +test('samplers for depth ranges not in use point at a texture that is bound', () => { + const scene = wallScene(); + const gl = scene.context._gl; + // Whatever the renderer left on the other units is not the probe's to read. + gl.state.units = [null, null, null, null, null, null, null, null]; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + frame(probe, scene, 0, [[entry, behindWall]]); + gl.finishGpu(); + frame(probe, scene, 16); + assert.equal( + entry.lastHiddenAt, + 0, + 'the draw was valid, so the answer counts', + ); + assert.deepEqual(gl.state.units, [ + null, + null, + null, + null, + null, + null, + null, + null, + ]); +}); + +test('reset drops tests in flight but keeps the GPU objects for next time', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + frame(probe, scene, 0, [[entry, behindWall]]); + probe.reset(); + assert.equal(entry.pending, false); + assert.equal(probe.mode, 'off'); + assert.equal(gl.counts.deleted, 0); + gl.finishGpu(); + frame(probe, scene, 16); + assert.equal( + entry.lastHiddenAt, + Number.NEGATIVE_INFINITY, + 'the dropped answer is not applied', + ); + frame(probe, scene, 32, [[entry, behindWall]]); + assert.equal(probe.mode, 'async'); + assert.equal(gl.liveQueries.size, 2, 'queries are reused, not recreated'); +}); + +test('a lost context drops what was in flight and starts over when it returns', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + frame(probe, scene, 0, [[entry, behindWall]]); + gl.state.lost = true; + assert.deepEqual(frame(probe, scene, 16, [[contact(), behindWall]]), [false]); + assert.equal(entry.pending, false); + assert.equal(probe.mode, 'off'); + gl.state.lost = false; + assert.deepEqual(frame(probe, scene, 32, [[entry, behindWall]]), [true]); + gl.finishGpu(); + frame(probe, scene, 48); + assert.equal(entry.lastHiddenAt, 32); +}); + +test('queries that are never answered are abandoned', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + frame(probe, scene, 0, [[entry, behindWall]]); + for (let i = 1; i <= PROBE_MAX_PENDING_FRAMES; i++) + frame(probe, scene, i * 16); + assert.equal(entry.pending, true); + frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + 1) * 16); + assert.equal(entry.pending, false); + assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY); + assert.equal(gl.liveQueries.size, 0); + assert.equal(probe.mode, 'async', 'and the next contact is tested as usual'); +}); + +test('every batch times out on its own, however many are queued ahead of it', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const probe = new SceneOcclusionProbe(); + const entries = []; + // Ten frames each start a batch; the GPU answers none of them. + for (let i = 0; i < 10; i++) { + const entry = contact(); + entries.push(entry); + frame(probe, scene, i * 16, [[entry, behindWall]]); + } + for (let i = 10; i <= PROBE_MAX_PENDING_FRAMES; i++) + frame(probe, scene, i * 16); + assert.ok(entries.every((entry) => entry.pending)); + // Ten more frames and all ten have been dropped, not one every 120 frames. + for (let i = 1; i <= 10; i++) + frame(probe, scene, (PROBE_MAX_PENDING_FRAMES + i) * 16); + assert.ok(entries.every((entry) => !entry.pending)); + assert.equal(gl.liveQueries.size, 0); +}); + +test('a target that cannot be made leaves nothing behind, and the next try starts from scratch', () => { + const scene = wallScene(); + const gl = scene.context._gl; + const complete = gl.checkFramebufferStatus; + gl.checkFramebufferStatus = () => 0; + const probe = new SceneOcclusionProbe(); + const entry = contact(); + assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]); + assert.equal(probe.mode, 'off'); + assert.equal(gl.counts.deleted, 5, 'program, array, buffer, target'); + // Memory pressure passes; after the stand-down the probe builds it all again. + gl.checkFramebufferStatus = complete; + assert.deepEqual( + frame(probe, scene, PROBE_RETRY_AFTER_MS, [[entry, behindWall]]), + [true], + ); + assert.equal(probe.mode, 'async'); + gl.finishGpu(); + frame(probe, scene, PROBE_RETRY_AFTER_MS + 16); + assert.equal(entry.lastHiddenAt, PROBE_RETRY_AFTER_MS); +}); + +test('a new WebGL context, or destroy, releases everything', () => { + const probe = new SceneOcclusionProbe(); + const first = wallScene(); + const entry = contact(); + frame(probe, first, 0, [[entry, behindWall]]); + const second = wallScene(); + const again = contact(); + assert.deepEqual(frame(probe, second, 16, [[again, behindWall]]), [true]); + assert.equal(entry.pending, false, 'the first context will never answer'); + assert.equal(first.context._gl.liveQueries.size, 0); + assert.equal( + first.context._gl.counts.deleted, + 5, + 'program, array, buffer, target', + ); + probe.destroy(); + assert.equal(again.pending, false); + assert.equal(second.context._gl.liveQueries.size, 0); + assert.equal(probe.mode, 'off'); +}); + +test('without queries (WebGL 1) depth is read back synchronously, at most every interval, and the mode says so', () => { + const gl = createFakeGl({ webgl2: false }); + const scene = wallScene({ gl }); + assert.equal(scene.context.webgl2, false); + const sceneFramebuffer = { image: null }; + gl.state.read = sceneFramebuffer; + gl.state.draw = sceneFramebuffer; + const probe = new SceneOcclusionProbe(); + const hidden = contact(); + const shown = contact(); + assert.deepEqual( + frame(probe, scene, 1000, [ + [hidden, behindWall], + [shown, onStreet], + [contact(), { x: -1100, y: 0, z: -500 }], + ]), + [true, true, false], + ); + assert.equal(probe.mode, 'sync'); + assert.equal(gl.counts.readPixels, 1); + assert.equal(gl.state.read, sceneFramebuffer); + assert.equal(gl.state.draw, sceneFramebuffer); + assert.equal(hidden.lastHiddenAt, 1000, 'answered on the spot'); + assert.equal(hidden.pending, false); + assert.equal(shown.answered, true); + assert.equal(shown.lastHiddenAt, Number.NEGATIVE_INFINITY); + // Frames inside the interval answer from the read already made. + frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS - 1, [[hidden, behindWall]]); + assert.equal(gl.counts.readPixels, 1); + assert.equal(hidden.lastHiddenAt, 1000 + SYNC_READ_INTERVAL_MS - 1); + frame(probe, scene, 1000 + SYNC_READ_INTERVAL_MS, [[hidden, behindWall]]); + assert.equal(gl.counts.readPixels, 2); +}); + +test('the shader decodes depth the way the CPU decode does', () => { + // The fake context stands in for the shader, so pin the parts of the source + // the two must agree on: the unpack weights, both depth formulas, the + // nearest-range-first order and the comparison. + const source = PROBE_VERTEX_SHADER.replace(/\s+/g, ' '); + assert.match( + source, + /vec4\(1\.0, 1\.0 \/ 255\.0, 1\.0 \/ 65025\.0, 1\.0 \/ 16581375\.0\)/, + ); + assert.match(source, /if \(depth <= 0\.0 \|\| depth >= 1\.0\) return -1\.0;/); + assert.match( + source, + /near \+ exp2\(depth \* log2\(far - near \+ 1\.0\)\) - 1\.0/, + ); + assert.match(source, /near \* far \/ \(far - depth \* \(far - near\)\)/); + assert.match( + source, + /bool hidden = surface > 0\.0 && surface < a_contact\.z;/, + ); + assert.ok( + source.indexOf('u_depth0, u_near.x') < source.indexOf('u_depth1, u_near.y'), + ); +}); diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index 403aaaa88..0e50ac9fb 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -299,6 +299,9 @@ export function createControls({ state: layerState, services, parts, source }) { position: pos, id: `VEH-${String(layerState._dots[i].id).padStart(4, '0')}`, type: 'VEH', + // The dots are depth-tested, so the overlay hides a contact's + // bracket and callout wherever the scene hides its dot. + depthTested: true, }; // Live mode: the detection bracket carries the congestion signal — // its canvas sits ABOVE the post-FX chain, so tier colors survive From 076d2649823ddea45fc329625232e2f87e574daa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 23:03:20 -0800 Subject: [PATCH 2/3] Hide street vehicles behind low walls and trees seen from above In a dense downtown view from about 400 m up, the occlusion margin of max(3 m, 2% of the range) came to 14 to 20 m, so a wall, awning, tree or lower facade standing 10 to 20 m in front of a street was read as the road itself: the vehicle's dot was hidden by the scene, but its bracket and callout were still drawn. Checked against scene.pickPosition in one such view, 18 of 470 vehicles in frame fell in that band. The margin now depends on how steeply the camera looks down on the contact. A point on the line of sight s metres in front of a vehicle stands s * sin(elevation) above it, so a surface there hides the vehicle once that rise passes 2 m. The margin stays at least 3 m and at most 2% of the range, so grazing and street-level views are unchanged. In the same views no vehicle with the drawn surface within 3 m in front of it (its own road) is newly hidden. From the review of the first change: - A contact back in view after longer than a retest interval is held back and retested like a new one, instead of being drawn from an answer given before it left the view. It gets a fresh record, so a test still in flight from before cannot end the hold-back either. - The hold-back for a contact's first answer stretches with the retest pace when a crowded view slows it. - A batch whose control point failed no longer counts as a first answer. - An unknown log-depth flag reads as "no depth" rather than as standard depth, which would have put every surface in front of every contact. - A shader that fails to build leaves no shader behind and is not built again on the same context. --- docs/CURRENT-STATE.md | 19 ++- src/data/detectionOcclusion.js | 84 ++++++++-- src/data/detectionOcclusion.test.mjs | 233 ++++++++++++++++++++++++++ src/data/fixtures/fakeDepthScene.mjs | 18 +- src/data/sceneOcclusionProbe.js | 37 +++- src/data/sceneOcclusionProbe.test.mjs | 28 +++- 6 files changed, 388 insertions(+), 31 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 602abf037..7ad2b6e95 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3609,8 +3609,13 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > (2026-11-09):** a contact a layer marks `depthTested: true` (only Street > Traffic does) is tested against the depth the scene drew, and is left out > of the overlay while a building, 3D tile, terrain or any other drawn -> surface stands in front of it by more than max(3 m, 2% of its range). That -> includes vehicles the road data routes through tunnels. Every other layer +> surface stands in front of it by more than a margin: 3 m, or what a 2 m +> rise above the vehicle on its line of sight takes when the camera looks +> down on it, and above 3 m never more than 2% of its range (2% applies +> outright at grazing angles or when the camera looks up). A flat +> 2% let walls, awnings and trees 10 to 20 m in front of a street 1 km away +> pass as the road. That includes vehicles the road data routes through +> tunnels. Every other layer > is untouched: aircraft, vessels and satellites draw their markers over the > scene on purpose and keep their labels over it. The test runs on the GPU > (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact, @@ -3619,9 +3624,13 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > "hidden" answer arrives and returns 600 ms after its last one, which keeps > a contact on a silhouette edge from blinking; a callout whose contact goes > behind something is dropped at once rather than faded out on the facade. -> A contact new to the view is held back until its first answer (at most -> 400 ms), so orbiting a city does not flash a bracket for each vehicle that -> swings into frame behind a building. These times run on painted frames, +> A contact new to the view, or back in it after longer than a retest +> interval, is held back until its first answer (at most 400 ms, longer +> when a crowded view stretches the retest pace), so orbiting a city does +> not flash a bracket for each vehicle that swings into frame behind a +> building. A callout placed beside a visible vehicle can still overlap a +> facade; that is where the callout was placed, not the vehicle being +> behind it. These times run on painted frames, > so a hidden tab or an idle scene does not expire them. > Do not swap this for `scene.pickPosition` per contact or for a depth > read-back: both wait on the GPU process (measured 0.2 to 1.4 ms per pick, diff --git a/src/data/detectionOcclusion.js b/src/data/detectionOcclusion.js index 22c8d2ac4..83c668725 100644 --- a/src/data/detectionOcclusion.js +++ b/src/data/detectionOcclusion.js @@ -27,22 +27,43 @@ /** Never less than this: covers a dot drawn a little above or into its road. */ export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3; -/** Grows with range so depth-buffer precision never reads as an occluder. */ +/** The most the margin grows with range: 2% of it. */ export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02; +/** + * How far above a contact a drawn surface on its line of sight must stand to + * count as in front of it, when the camera looks down on the contact. + */ +export const SURFACE_OCCLUSION_RISE_M = 2; /** * How much nearer than the contact the drawn surface must be before it counts * as standing in front of it. + * + * The margin absorbs a contact placed a little above or below the drawn + * ground: a vehicle's position and the scene's road surface are not the same + * height to the metre. Looking down at the contact, a point on its line of + * sight `s` metres in front of it stands `s * sinElevation` metres above it, + * so a surface there only hides the contact once `s` passes + * `SURFACE_OCCLUSION_RISE_M / sinElevation`. That keeps the margin to a few + * metres from above, where a fixed share of the range would let a wall, + * awning or tree 10 to 20 m in front of a street at 1 km pass as "the road". + * At grazing angles, or looking up, the margin is 2% of the range at most + * (never under 3 m); depth precision is far finer than that at every range + * the overlay labels. + * * @param {number} contactDistance Camera-to-contact distance in metres. + * @param {number} [sinElevation] Sine of the camera's elevation seen from the + * contact (1 straight overhead, 0 level, negative below); unknown when + * omitted. * @returns {number} Margin in metres. */ -export function surfaceOcclusionMargin(contactDistance) { +export function surfaceOcclusionMargin(contactDistance, sinElevation = 0) { const distance = Number(contactDistance); if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M; - return Math.max( - SURFACE_OCCLUSION_MIN_MARGIN_M, - distance * SURFACE_OCCLUSION_MARGIN_FRACTION, - ); + let margin = distance * SURFACE_OCCLUSION_MARGIN_FRACTION; + const rise = Number(sinElevation); + if (rise > 0) margin = Math.min(margin, SURFACE_OCCLUSION_RISE_M / rise); + return Math.max(SURFACE_OCCLUSION_MIN_MARGIN_M, margin); } /** @@ -50,13 +71,19 @@ export function surfaceOcclusionMargin(contactDistance) { * @param {number} contactDistance Camera-to-contact distance in metres. * @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance * along the contact's ray; undefined or non-finite when nothing was drawn. + * @param {number} [sinElevation] See `surfaceOcclusionMargin`. * @returns {boolean} */ -export function surfaceHidesContact(contactDistance, surfaceDistance) { +export function surfaceHidesContact( + contactDistance, + surfaceDistance, + sinElevation = 0, +) { if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance)) return false; return ( - surfaceDistance < contactDistance - surfaceOcclusionMargin(contactDistance) + surfaceDistance < + contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation) ); } @@ -109,6 +136,12 @@ export function depthToViewDistance(depth, near, far, logDepth) { * `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`, * and comparing a decoded depth with it is all the GPU has to do. * + * The margin depends on how steeply the camera looks down on the contact + * (see `surfaceOcclusionMargin`). "Up" at the contact is taken as the + * direction from the world origin to it, which is the Earth's centre in the + * Earth-fixed frame the scene is drawn in; it differs from the true vertical + * by a fraction of a degree at most. + * * @param {ArrayLike} view World-to-camera matrix, column-major, the * camera looking down its own -Z axis. * @param {number} tanHalfFovX @@ -131,11 +164,22 @@ export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) { const contactDistance = Math.sqrt( ex * ex + ey * ey + viewDistance * viewDistance, ); + // Up at the contact, turned into camera axes, against the direction from + // the contact to the camera (-ex, -ey, viewDistance in camera axes). + const radius = Math.sqrt(x * x + y * y + z * z); + let sinElevation = 0; + if (radius > 0) { + const ux = (view[0] * x + view[4] * y + view[8] * z) / radius; + const uy = (view[1] * x + view[5] * y + view[9] * z) / radius; + const uz = (view[2] * x + view[6] * y + view[10] * z) / radius; + sinElevation = (-ux * ex - uy * ey + uz * viewDistance) / contactDistance; + } out.u = u; out.v = v; out.threshold = viewDistance * - (1 - surfaceOcclusionMargin(contactDistance) / contactDistance); + (1 - + surfaceOcclusionMargin(contactDistance, sinElevation) / contactDistance); return true; } @@ -303,6 +347,7 @@ export class SurfaceOcclusion { this.hiddenCount = 0; this.retestMs = SURFACE_RETEST_MS; this.reappearMs = SURFACE_REAPPEAR_MS; + this.firstAnswerMs = SURFACE_FIRST_ANSWER_MS; this._phase = 0; } @@ -326,6 +371,9 @@ export class SurfaceOcclusion { (this.testedCount / budget) * frameMs * 2, ); this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3); + // At a stretched pace a crowd of new contacts takes longer to get its + // first answers; the hold-back stretches with it. + this.firstAnswerMs = Math.max(SURFACE_FIRST_ANSWER_MS, this.retestMs * 2); this.now += frameMs; this.testedCount = 0; this.hiddenCount = 0; @@ -347,11 +395,19 @@ export class SurfaceOcclusion { this.contacts.set(layerId, layer); } let contact = layer.get(sourceId); - if (!contact) { + if (!contact || now - contact.seenAt > this.retestMs) { + // New, or back in view after longer than a retest interval. A returning + // contact's last answer is older than any contact that stayed in view + // runs on, and the view has probably turned since: it is held back + // like a new one until a fresh answer comes, or orbiting flashes it + // behind the building it went behind while out of view. It gets a new + // record, so a test still in flight from before answers the old one + // and cannot end the hold-back with what the previous view showed. A + // "hidden" it already had keeps running. contact = { - lastHiddenAt: Number.NEGATIVE_INFINITY, - hiddenUntil: Number.NEGATIVE_INFINITY, - appliedHiddenAt: Number.NEGATIVE_INFINITY, + lastHiddenAt: contact?.lastHiddenAt ?? Number.NEGATIVE_INFINITY, + hiddenUntil: contact?.hiddenUntil ?? Number.NEGATIVE_INFINITY, + appliedHiddenAt: contact?.appliedHiddenAt ?? Number.NEGATIVE_INFINITY, queuedAt: Number.NEGATIVE_INFINITY, pending: false, answered: false, @@ -385,7 +441,7 @@ export class SurfaceOcclusion { // Not yet answered: held back while a working probe finds out. (!contact.answered && this.probe.mode === 'async' && - now - contact.firstSeenAt < SURFACE_FIRST_ANSWER_MS); + now - contact.firstSeenAt < this.firstAnswerMs); if (hidden) this.hiddenCount++; return hidden; } diff --git a/src/data/detectionOcclusion.test.mjs b/src/data/detectionOcclusion.test.mjs index fbd1fdd57..5aadc7c92 100644 --- a/src/data/detectionOcclusion.test.mjs +++ b/src/data/detectionOcclusion.test.mjs @@ -30,6 +30,25 @@ test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () => assert.equal(surfaceOcclusionMargin(Number.NaN), 3); }); +test('looking down on a contact, the margin is what a 2 m rise on its line of sight takes', () => { + // 1 km away, seen from 30 degrees up: 2 m of rise is 4 m along the ray. + assert.ok(Math.abs(surfaceOcclusionMargin(1000, 0.5) - 4) < 1e-9); + // Straight down: the 3 m floor. + assert.equal(surfaceOcclusionMargin(1000, 1), SURFACE_OCCLUSION_MIN_MARGIN_M); + // Grazing, level or from below: never more than 2% of the range. + assert.equal(surfaceOcclusionMargin(1000, 0.01), 20); + assert.equal(surfaceOcclusionMargin(1000, 0), 20); + assert.equal(surfaceOcclusionMargin(1000, -0.3), 20); + assert.equal(surfaceOcclusionMargin(1000, Number.NaN), 20); + assert.equal(surfaceOcclusionMargin(100, 0.05), 3); + // A low wall 15 m in front of a street 1 km off, seen from 25 degrees up, + // stands about 6 m above the vehicle's line of sight: it hides it. + const sin25 = Math.sin((25 * Math.PI) / 180); + assert.equal(surfaceHidesContact(1000, 985, sin25), true); + assert.equal(surfaceHidesContact(1000, 985), false, 'unknown angle: 2%'); + assert.equal(surfaceHidesContact(1000, 996, sin25), false, 'its own road'); +}); + test('a surface hides a contact only when nearer by more than the margin', () => { // 500 m away: margin 10 m. assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front'); @@ -142,6 +161,134 @@ test('the threshold is the margin rule restated as a view distance', () => { assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9); }); +test('the threshold uses how steeply the camera looks down on the contact', () => { + // A street on a sphere the size of the Earth, under a camera 1 km up and + // 1 km back along the surface, looking straight at it: 45 degrees down. + const R = 6_371_000; + const street = { x: 0, y: 0, z: R }; + const camera = { x: 0, y: -1000, z: R + 1000 }; + // Camera axes: x along world x, looking along (0, 1, -1) / sqrt 2. + const s = Math.SQRT1_2; + const forward = [0, s, -s]; + const up = [0, s, s]; + const right = [1, 0, 0]; + const t = (axis) => + -(axis[0] * camera.x + axis[1] * camera.y + axis[2] * camera.z); + // Rows: right, up, -forward (the camera looks down its -Z). + const back = forward.map((c) => -c); + const view = [ + right[0], + up[0], + back[0], + 0, + right[1], + up[1], + back[1], + 0, + right[2], + up[2], + back[2], + 0, + t(right), + t(up), + t(back), + 1, + ]; + const out = { u: 0, v: 0, threshold: 0 }; + assert.equal(projectContact(view, 1, 1, street, out), true); + assert.ok(Math.abs(out.u - 0.5) < 1e-9 && Math.abs(out.v - 0.5) < 1e-9); + const range = Math.SQRT2 * 1000; + // 2 m of rise at 45 degrees is 2.83 m along the ray: the 3 m floor, not + // 2% of 1414 m (28 m). + assert.ok(Math.abs(out.threshold - (range - 3)) < 1e-6, `${out.threshold}`); +}); + +test('off the middle of the frame, with the camera turned, the threshold matches the world-space rule', () => { + // Rotations about the three axes, then a camera placed and turned freely; + // the threshold must equal the margin rule worked out in world space. + const rot = (ax, ay, az) => { + const [cx, sx, cy, sy, cz, sz] = [ + Math.cos(ax), + Math.sin(ax), + Math.cos(ay), + Math.sin(ay), + Math.cos(az), + Math.sin(az), + ]; + // Rows of Rz * Ry * Rx. + return [ + [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx], + [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx], + [-sy, cy * sx, cy * cx], + ]; + }; + const R = 6_371_000; + let checked = 0; + for (let k = 0; k < 200; k++) { + // A deterministic spread of poses. + const a = (n) => Math.sin(k * 12.9898 + n * 78.233) * 3; + const m = rot(a(1), a(2), a(3)); // world-to-camera rotation + const lat = a(4) / 3; + const lon = a(5); + const contact = { + x: R * Math.cos(lat) * Math.cos(lon), + y: R * Math.cos(lat) * Math.sin(lon), + z: R * Math.sin(lat), + }; + // Camera 300 to 2300 m from the contact, in a direction above or below it. + const d = [a(6), a(7), a(8)]; + const dl = Math.hypot(...d) || 1; + const dist = 1300 + 1000 * Math.sin(k); + const cam = { + x: contact.x + (d[0] / dl) * dist, + y: contact.y + (d[1] / dl) * dist, + z: contact.z + (d[2] / dl) * dist, + }; + const t = m.map( + (row) => -(row[0] * cam.x + row[1] * cam.y + row[2] * cam.z), + ); + const view = [ + m[0][0], + m[1][0], + m[2][0], + 0, + m[0][1], + m[1][1], + m[2][1], + 0, + m[0][2], + m[1][2], + m[2][2], + 0, + t[0], + t[1], + t[2], + 1, + ]; + const out = { u: 0, v: 0, threshold: 0 }; + if (!projectContact(view, 2, 2, contact, out)) continue; + checked++; + const toCam = [cam.x - contact.x, cam.y - contact.y, cam.z - contact.z]; + const range = Math.hypot(...toCam); + const sinElevation = + (toCam[0] * contact.x + toCam[1] * contact.y + toCam[2] * contact.z) / + (range * R); + const viewDistance = -( + m[2][0] * contact.x + + m[2][1] * contact.y + + m[2][2] * contact.z + + t[2] + ); + const expected = + viewDistance * (1 - surfaceOcclusionMargin(range, sinElevation) / range); + assert.ok( + Math.abs(out.threshold - expected) < 1e-6, + `pose ${k}: ${out.threshold} vs ${expected}`, + ); + } + assert.ok(checked > 20, `${checked} poses in frame`); +}); + test('behind the camera or outside the frame there is nothing to test', () => { const out = { u: 7, v: 7, threshold: 7 }; assert.equal( @@ -616,3 +763,89 @@ test('prune forgets contacts that are no longer drawn; clear forgets everything' assert.equal(occlusion.contacts.size, 0); assert.equal(occlusion.testedCount, 0); }); + +test('a contact back in view after a while waits for a fresh answer, like a new one', () => { + const probe = manualProbe(); + probe.mode = 'async'; + const occlusion = new SurfaceOcclusion(probe); + let wall = 0; + const step = () => occlusion.beginFrame((wall += 16)); + step(); + occlusion.isHidden('traffic', 'a', anywhere); + probe.answer(false, occlusion.now); + step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false); + // Out of view for a moment shorter than a retest: drawn straight away. + for (let i = 0; i < 5; i++) step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false); + // Out of view for longer (the camera swung away and back): the old + // "visible" may now be behind a building. Held back, and tested at once. + const started = probe.started; + for (let i = 0; i < 30; i++) step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + assert.equal(probe.started, started + 1); + probe.answer(true, occlusion.now); + step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + + // It leaves with a test in flight, and comes back. That test's answer was + // about the previous view: when it lands, it must not end the hold-back. + const other = new SurfaceOcclusion(probe); + probe.answer(false, 0); + other.beginFrame((wall += 16)); + other.isHidden('traffic', 'b', anywhere); + assert.equal(probe.inFlight.length, 1, 'tested, unanswered'); + for (let i = 0; i < 30; i++) other.beginFrame((wall += 16)); + const before = probe.started; + assert.equal(other.isHidden('traffic', 'b', anywhere), true); + assert.equal(probe.started, before + 1, 'tested afresh at once'); + const [stale] = probe.inFlight.splice(0, 1); + stale.pending = false; + stale.answered = true; + other.beginFrame((wall += 16)); + assert.equal( + other.isHidden('traffic', 'b', anywhere), + true, + 'the old answer did not end the hold-back', + ); + probe.answer(false, other.now); + other.beginFrame((wall += 16)); + assert.equal(other.isHidden('traffic', 'b', anywhere), false); +}); + +test('at a stretched retest pace, new contacts are held back longer for their first answer', () => { + const probe = manualProbe(); + probe.mode = 'async'; + probe.maxPerFrame = 100; + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(0); + for (let id = 0; id < 2000; id++) occlusion.isHidden('traffic', id, anywhere); + occlusion.beginFrame(33); + // 2000 contacts at 100 a frame and 33 ms frames: a retest every 1.3 s. + assert.ok(occlusion.retestMs > 1000, `${occlusion.retestMs}`); + assert.equal(occlusion.firstAnswerMs, occlusion.retestMs * 2); + assert.ok(occlusion.firstAnswerMs > SURFACE_FIRST_ANSWER_MS); + // A contact the probe has not reached yet is still held back past the + // usual limit, and drawn once the stretched one has run out. + probe.accepting = false; + let wall = 33; + const ages = []; + const firstSeen = occlusion.now; + occlusion.isHidden('traffic', 'late', anywhere); + while (occlusion.now - firstSeen < occlusion.firstAnswerMs + 100) { + occlusion.beginFrame((wall += 33)); + for (let id = 0; id < 2000; id++) + occlusion.isHidden('traffic', id, anywhere); + ages.push([ + occlusion.now - firstSeen, + occlusion.isHidden('traffic', 'late', anywhere), + ]); + } + const limit = occlusion.firstAnswerMs; + assert.ok( + ages.some(([age, hidden]) => age > SURFACE_FIRST_ANSWER_MS + 50 && hidden), + 'held past 400 ms', + ); + for (const [age, hidden] of ages) + if (age >= limit) assert.equal(hidden, false, `at ${age} ms`); +}); diff --git a/src/data/fixtures/fakeDepthScene.mjs b/src/data/fixtures/fakeDepthScene.mjs index 47e969717..fd337dcae 100644 --- a/src/data/fixtures/fakeDepthScene.mjs +++ b/src/data/fixtures/fakeDepthScene.mjs @@ -101,6 +101,7 @@ export function createFakeGl({ webgl2 = true } = {}) { lost: false, /** Whether a point that should draw does (false: a broken draw path). */ drawsLand: true, + /** Whether shaders compile: a boolean, or a function of the shader type. */ compiles: true, }; const counts = { @@ -111,12 +112,14 @@ export function createFakeGl({ webgl2 = true } = {}) { deleted: 0, }; const liveQueries = new Set(); + const liveShaders = new Set(); let activeQuery = null; const gl = { ...GL, state, counts, liveQueries, + liveShaders, isContextLost: () => state.lost, getParameter(name) { if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read; @@ -186,11 +189,20 @@ export function createFakeGl({ webgl2 = true } = {}) { state.draw.image = texture.image; }, checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE, - createShader: () => ({}), + createShader(type) { + const shader = { type }; + liveShaders.add(shader); + return shader; + }, shaderSource() {}, compileShader() {}, - getShaderParameter: () => state.compiles, - deleteShader() {}, + getShaderParameter: (shader) => + typeof state.compiles === 'function' + ? state.compiles(shader.type) + : state.compiles, + deleteShader(shader) { + liveShaders.delete(shader); + }, createProgram: () => ({ uniforms: {} }), attachShader() {}, linkProgram() {}, diff --git a/src/data/sceneOcclusionProbe.js b/src/data/sceneOcclusionProbe.js index 070d5fe9f..772bc638e 100644 --- a/src/data/sceneOcclusionProbe.js +++ b/src/data/sceneOcclusionProbe.js @@ -79,7 +79,7 @@ export const SYNC_READ_INTERVAL_MS = 125; * plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as * `scene.pickPosition` assumes when it decodes the same textures. * - `scene.frameState.useLogDepth`: whether depth is logarithmic (see - * `depthToViewDistance`). + * `depthToViewDistance`). Anything but a boolean there is "no depth". * * Any of these missing or shaped differently returns null, which callers treat * as "no depth". @@ -145,6 +145,10 @@ export function readSceneDepthSource(scene, viewScratch) { frustums.push({ near, far }); } if (!(width > 0) || !(height > 0)) return null; + // Read as standard depth, logarithmic depth puts every surface a few + // metres from the camera and would hide every contact: unknown is "no". + const logDepth = scene.frameState?.useLogDepth; + if (typeof logDepth !== 'boolean') return null; const tanHalfFovY = Math.tan(fovy / 2); return { @@ -155,7 +159,7 @@ export function readSceneDepthSource(scene, viewScratch) { framebuffers, textures, frustums, - logDepth: scene.frameState?.useLogDepth === true, + logDepth, view: Cesium.Matrix4.toArray( camera.viewMatrix, viewScratch || new Float64Array(16), @@ -262,6 +266,8 @@ export class SceneOcclusionProbe { this._now = 0; this._source = undefined; this._gl = null; + /** A context the probe's shader failed to build on; never retried. */ + this._unsupportedGl = null; this._resources = null; this._queryPool = []; this._batches = []; @@ -409,6 +415,7 @@ export class SceneOcclusionProbe { this._queryPool.length = 0; this._resources = null; this._gl = null; + this._unsupportedGl = null; } /** Tests that will never be answered stop counting as in flight. */ @@ -448,6 +455,11 @@ export class SceneOcclusionProbe { this._resources = null; return; } + if (source.gl === this._unsupportedGl) { + // The shader did not build on this context: nothing is tested here. + this.mode = 'off'; + return; + } if (source.webgl2 && canQuery(source.gl)) { if (!this._resources && !this._createResources(source.gl)) { this._fail(); @@ -476,7 +488,13 @@ export class SceneOcclusionProbe { _createResources(gl) { const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); - if (!vertex || !fragment) return false; + if (!vertex || !fragment) { + if (vertex) gl.deleteShader(vertex); + if (fragment) gl.deleteShader(fragment); + // The same source will not compile on this context next time either. + this._unsupportedGl = gl; + return false; + } const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); @@ -485,6 +503,7 @@ export class SceneOcclusionProbe { gl.deleteShader(fragment); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { gl.deleteProgram(program); + this._unsupportedGl = gl; return false; } const uniform = (name) => gl.getUniformLocation(program, name); @@ -691,10 +710,14 @@ export class SceneOcclusionProbe { } else { const contact = contacts[batch.cursor - 1]; contact.pending = false; - contact.answered = true; - // Without the control point's yes, a no means nothing. - if (!drew && batch.control && batch.at > contact.lastHiddenAt) - contact.lastHiddenAt = batch.at; + // Without the control point's yes the batch means nothing: not a + // "hidden", and not a first answer that ends a new contact's + // hold-back either. + if (batch.control) { + contact.answered = true; + if (!drew && batch.at > contact.lastHiddenAt) + contact.lastHiddenAt = batch.at; + } } this._queryPool.push(query); batch.cursor++; diff --git a/src/data/sceneOcclusionProbe.test.mjs b/src/data/sceneOcclusionProbe.test.mjs index d4ba75e1e..713546295 100644 --- a/src/data/sceneOcclusionProbe.test.mjs +++ b/src/data/sceneOcclusionProbe.test.mjs @@ -266,6 +266,11 @@ test('no depth source: nothing is tested and nothing is in flight', () => { scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet return scene; })(), + // Whether depth is logarithmic, unknown: decoding it the wrong way would + // put every surface in front of every contact. + { ...wallScene(), frameState: {} }, + { ...wallScene(), frameState: { useLogDepth: 1 } }, + { ...wallScene(), frameState: undefined }, (() => { const scene = wallScene(); Object.defineProperty(scene, 'view', { @@ -329,6 +334,7 @@ test('a control point that does not draw voids its batch; a run of them stands t frame(probe, scene, now); assert.equal(entry.pending, false); assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed'); + assert.notEqual(entry.answered, true, 'and not a first answer either'); now += 16; } assert.equal(probe.mode, 'off'); @@ -351,6 +357,7 @@ test('a control point that does not draw voids its batch; a run of them stands t gl.finishGpu(); frame(probe, scene, retryAt + 16); assert.equal(entry.lastHiddenAt, retryAt); + assert.equal(entry.answered, true); // One good batch in between resets the count. const healthy = new SceneOcclusionProbe(); @@ -366,16 +373,33 @@ test('a control point that does not draw voids its batch; a run of them stands t test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => { let scene = wallScene(); - scene.context._gl.state.compiles = false; + // Only the fragment shader fails: the vertex shader that did compile must + // not be left behind. + scene.context._gl.state.compiles = (type) => + type !== scene.context._gl.FRAGMENT_SHADER; let probe = new SceneOcclusionProbe(); let entry = contact(); assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]); assert.equal(probe.mode, 'off'); assert.equal(entry.pending, false); + // Nothing half-made is kept, and the same context is never asked again: + // compiling is a wait on the GPU process and would fail the same way. + let gl = scene.context._gl; + assert.equal(gl.liveShaders.size, 0, 'no shader left behind'); + let shaderCalls = 0; + const createShader = gl.createShader; + gl.createShader = (...args) => { + shaderCalls++; + return createShader(...args); + }; + for (const now of [PROBE_RETRY_AFTER_MS + 16, 5 * PROBE_RETRY_AFTER_MS]) + assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [false]); + assert.equal(shaderCalls, 0); + assert.equal(probe.mode, 'off'); // A draw that throws part-way: bindings restored, no query left behind. scene = wallScene(); - let gl = scene.context._gl; + gl = scene.context._gl; const found = { draw: { image: null }, program: { uniforms: {} }, From 85f42f6cd824a2315c669737cd075a3a949c970e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 10 Nov 2026 15:28:44 -0800 Subject: [PATCH 3/3] Changelog fragment for #1659 --- changelog.d/1659-street-vehicle-occlusion.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1659-street-vehicle-occlusion.md diff --git a/changelog.d/1659-street-vehicle-occlusion.md b/changelog.d/1659-street-vehicle-occlusion.md new file mode 100644 index 000000000..438b64556 --- /dev/null +++ b/changelog.d/1659-street-vehicle-occlusion.md @@ -0,0 +1,5 @@ +- Hide street-vehicle brackets and callouts behind buildings and terrain. A + Street Traffic contact behind a building, a 3D tile, a hill or a tunnel roof + now loses its bracket and "VEH-nnnn" callout along with its dot, instead of + having them painted over the facade; aircraft, vessels and satellites keep + their labels over the scene as before (Sameh Khamis, @samehkhamis, #1659).