Merge remote-tracking branch 'upstream/main' into pr1277

This commit is contained in:
Said Turksever committed 2026-11-10 23:54:40 +00:00
commit b90b5e30e0
11 files changed
+3577 -1

No files matched your search

@@ -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).
+36
View File
@@ -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 > eye level before detection sees it, so ground-backed labels only reappear
> once the camera clears the ellipsoid. Rendered proof: > once the camera clears the ellipsoid. Rendered proof:
> `scripts/qa-cockpit-plates.mjs`. > `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 > - **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 > 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 > two surfaces used to paint over it — the command dock's popover tray (any
+4
View File
@@ -2599,8 +2599,10 @@
"src/data/detectionCohort.js", "src/data/detectionCohort.js",
"src/data/detectionDraw.js", "src/data/detectionDraw.js",
"src/data/detectionPolicy.js", "src/data/detectionPolicy.js",
"src/data/detectionOcclusion.js",
"src/data/detectionPresentation.js", "src/data/detectionPresentation.js",
"src/data/detectionRenderDemand.js", "src/data/detectionRenderDemand.js",
"src/data/sceneOcclusionProbe.js",
"src/data/earthquakes.js", "src/data/earthquakes.js",
"src/data/fireAnchors.js", "src/data/fireAnchors.js",
"src/data/firmsAdapt.js", "src/data/firmsAdapt.js",
@@ -4083,6 +4085,8 @@
"src/data/detection.js", "src/data/detection.js",
"src/data/detectionRenderDemand.js", "src/data/detectionRenderDemand.js",
"src/data/detectionPresentation.js", "src/data/detectionPresentation.js",
"src/data/detectionOcclusion.js",
"src/data/sceneOcclusionProbe.js",
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/app/layers/recentImagery.js", "src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js", "src/layers/recentImagery/index.js",
+85 -1
View File
@@ -50,6 +50,8 @@ import {
viewScaleForAltitude, viewScaleForAltitude,
} from './detectionPolicy.js'; } from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js'; import { detectionBracketOpacity } from './detectionPresentation.js';
import { SurfaceOcclusion } from './detectionOcclusion.js';
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
import { import {
createCyberSonarSampler, createCyberSonarSampler,
cyberSonarFrameDelay, cyberSonarFrameDelay,
@@ -268,6 +270,22 @@ let _allocationStrategy = ALLOCATION_ELASTIC;
let _lastNonOffMode = MODE_BALANCED; let _lastNonOffMode = MODE_BALANCED;
/** @type {LabelArbiter} Shared stable callout selector. */ /** @type {LabelArbiter} Shared stable callout selector. */
const _labelArbiter = new LabelArbiter(); 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 _lastLabelSolveAt = 0;
let _labelSolveDirty = true; let _labelSolveDirty = true;
let _lastDiagnostics = null; let _lastDiagnostics = null;
@@ -372,6 +390,9 @@ export function destroyDetection() {
_suspended = false; _suspended = false;
_suspendReason = ''; _suspendReason = '';
_labelArbiter.clear(); _labelArbiter.clear();
_surfaceOcclusion.clear();
_surfaceSuppressedKeys.clear();
_occlusionProbe.destroy();
_lastLabelSolveAt = 0; _lastLabelSolveAt = 0;
_labelSolveDirty = true; _labelSolveDirty = true;
_lastDiagnostics = null; _lastDiagnostics = null;
@@ -697,6 +718,9 @@ function _applyModeState() {
_syncSurfaceVisibility(); _syncSurfaceVisibility();
_setLanesActive(false); _setLanesActive(false);
_labelArbiter.clear(); _labelArbiter.clear();
_surfaceOcclusion.clear();
_surfaceSuppressedKeys.clear();
_occlusionProbe.reset();
_lastLabelSolveAt = 0; _lastLabelSolveAt = 0;
_labelSolveDirty = true; _labelSolveDirty = true;
_lastSolveSnapshot = { _lastSolveSnapshot = {
@@ -1337,6 +1361,9 @@ function _drawOverlay(frame) {
// every host lane. Detection retains the manual scalar projection below. // every host lane. Detection retains the manual scalar projection below.
const occluder = frame.occluder; const occluder = frame.occluder;
const camPos = frame.cameraPosition; 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; const keyhole = frame.keyhole;
// Read the OUTSIDE setting ONCE per paint, from the same module state the // 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 // 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. // The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj)); const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole); 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 = const sonarFactor =
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1; sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
const sonarLabelFactor = sonarSampler.label(sonarFactor); const sonarLabelFactor = sonarSampler.label(sonarFactor);
@@ -1547,6 +1597,8 @@ function _drawOverlay(frame) {
candidateMap.set(candidate.key, candidate); 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 altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct); const collectiveBudget = labelBudgetFor(altitude, _densityPct);
@@ -1557,6 +1609,13 @@ function _drawOverlay(frame) {
let didSolve = false; let didSolve = false;
let solveMs = 0; let solveMs = 0;
if (shouldSolve) { 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( const quotas = allocateLayerQuotas(
demandByLayer, demandByLayer,
ambientBudget, ambientBudget,
@@ -1643,7 +1702,25 @@ function _drawOverlay(frame) {
_ctx.lineWidth = bracketWidth; _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); const fadingCount = countFadingRenderEntries(renderEntries);
// Demand counts fades in BOTH directions; `fadingCount` above stays the // Demand counts fades in BOTH directions; `fadingCount` above stays the
// fade-OUT tail because that is what the published diagnostics have always // fade-OUT tail because that is what the published diagnostics have always
@@ -1698,6 +1775,13 @@ function _drawOverlay(frame) {
keyholeRadius: keyhole.radius, keyholeRadius: keyhole.radius,
keyholeFeatherPx: keyhole.featherPx, keyholeFeatherPx: keyhole.featherPx,
calloutOcclusionCount: calloutOcclusionRects.length, 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 { } else {
const selectedCount = _labelArbiter.selectedKeys.size; const selectedCount = _labelArbiter.selectedKeys.size;
+292
View File
@@ -36,6 +36,12 @@ import {
setAwarenessEchoField, setAwarenessEchoField,
} from '../awarenessEcho.js'; } from '../awarenessEcho.js';
import { composeLabel, resolveTier } from './detectionDraw.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', () => { test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
assert.equal(countFadingRenderEntries([ 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: 'AIR' }), 'civil');
assert.equal(resolveTier({ type: 'SEA' }), 'sea'); 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();
}
});
+466
View File
@@ -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<number>} 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<number>} 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<number>, 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<string, Map<*, SurfaceContact>>} 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;
}
}
+851
View File
@@ -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`);
});
+351
View File
@@ -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,
}),
},
};
}),
},
};
}
+796
View File
@@ -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,
});
}
}
+688
View File
@@ -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'),
);
});
+3
View File
@@ -299,6 +299,9 @@ export function createControls({ state: layerState, services, parts, source }) {
position: pos, position: pos,
id: `VEH-${String(layerState._dots[i].id).padStart(4, '0')}`, id: `VEH-${String(layerState._dots[i].id).padStart(4, '0')}`,
type: 'VEH', 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 — // Live mode: the detection bracket carries the congestion signal —
// its canvas sits ABOVE the post-FX chain, so tier colors survive // its canvas sits ABOVE the post-FX chain, so tier colors survive