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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2c919bcbf..32c73a58d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3615,6 +3615,42 @@ 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 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, +> 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, 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, +> 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..83c668725 --- /dev/null +++ b/src/data/detectionOcclusion.js @@ -0,0 +1,466 @@ +/** + * 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; +/** 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, sinElevation = 0) { + const distance = Number(contactDistance); + if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M; + 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); +} + +/** + * 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. + * @param {number} [sinElevation] See `surfaceOcclusionMargin`. + * @returns {boolean} + */ +export function surfaceHidesContact( + contactDistance, + surfaceDistance, + sinElevation = 0, +) { + if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance)) + return false; + return ( + surfaceDistance < + contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation) + ); +} + +/** + * 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. + * + * 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 + * @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, + ); + // 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, sinElevation) / 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.firstAnswerMs = SURFACE_FIRST_ANSWER_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); + // 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; + } + + /** + * 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 || 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: 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, + 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 < this.firstAnswerMs); + 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..5aadc7c92 --- /dev/null +++ b/src/data/detectionOcclusion.test.mjs @@ -0,0 +1,851 @@ +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('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'); + 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('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( + 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); +}); + +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 new file mode 100644 index 000000000..fd337dcae --- /dev/null +++ b/src/data/fixtures/fakeDepthScene.mjs @@ -0,0 +1,351 @@ +/** + * 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, + /** Whether shaders compile: a boolean, or a function of the shader type. */ + compiles: true, + }; + const counts = { + readPixels: 0, + queries: 0, + draws: 0, + resultReads: 0, + 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; + 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(type) { + const shader = { type }; + liveShaders.add(shader); + return shader; + }, + shaderSource() {}, + compileShader() {}, + getShaderParameter: (shader) => + typeof state.compiles === 'function' + ? state.compiles(shader.type) + : state.compiles, + deleteShader(shader) { + liveShaders.delete(shader); + }, + 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..772bc638e --- /dev/null +++ b/src/data/sceneOcclusionProbe.js @@ -0,0 +1,796 @@ +/** + * 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`). Anything but a boolean there is "no depth". + * + * 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; + // 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 { + gl, + webgl2: context.webgl2 === true, + width, + height, + framebuffers, + textures, + frustums, + logDepth, + 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; + /** A context the probe's shader failed to build on; never retried. */ + this._unsupportedGl = 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; + this._unsupportedGl = 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.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(); + 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) { + 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); + gl.linkProgram(program); + gl.deleteShader(vertex); + 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); + 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; + // 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++; + } + 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..713546295 --- /dev/null +++ b/src/data/sceneOcclusionProbe.test.mjs @@ -0,0 +1,688 @@ +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; + })(), + // 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', { + 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'); + assert.notEqual(entry.answered, true, 'and not a first answer either'); + 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); + assert.equal(entry.answered, true); + + // 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(); + // 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(); + 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