mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Merge branch 'main' into feat/viewport-context-decision-upstream
This commit is contained in:
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).
|
||||
@@ -3605,6 +3605,42 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
||||
> eye level before detection sees it, so ground-backed labels only reappear
|
||||
> once the camera clears the ellipsoid. Rendered proof:
|
||||
> `scripts/qa-cockpit-plates.mjs`.
|
||||
> - **Street vehicles behind geometry lose their bracket and callout
|
||||
> (2026-11-09):** a contact a layer marks `depthTested: true` (only Street
|
||||
> Traffic does) is tested against the depth the scene drew, and is left out
|
||||
> of the overlay while a building, 3D tile, terrain or any other drawn
|
||||
> surface stands in front of it by more than a margin: 3 m, or what a 2 m
|
||||
> rise above the vehicle on its line of sight takes when the camera looks
|
||||
> down on it, and above 3 m never more than 2% of its range (2% applies
|
||||
> outright at grazing angles or when the camera looks up). A flat
|
||||
> 2% let walls, awnings and trees 10 to 20 m in front of a street 1 km away
|
||||
> pass as the road. That includes vehicles the road data routes through
|
||||
> tunnels. Every other layer
|
||||
> is untouched: aircraft, vessels and satellites draw their markers over the
|
||||
> scene on purpose and keep their labels over it. The test runs on the GPU
|
||||
> (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact,
|
||||
> repeated every 100 to 200 ms, answered a frame or two later, with nothing
|
||||
> read back from the GPU and so no stall. A contact hides as soon as a
|
||||
> "hidden" answer arrives and returns 600 ms after its last one, which keeps
|
||||
> a contact on a silhouette edge from blinking; a callout whose contact goes
|
||||
> behind something is dropped at once rather than faded out on the facade.
|
||||
> A contact new to the view, or back in it after longer than a retest
|
||||
> interval, is held back until its first answer (at most 400 ms, longer
|
||||
> when a crowded view stretches the retest pace), so orbiting a city does
|
||||
> not flash a bracket for each vehicle that swings into frame behind a
|
||||
> building. A callout placed beside a visible vehicle can still overlap a
|
||||
> facade; that is where the callout was placed, not the vehicle being
|
||||
> behind it. These times run on painted frames,
|
||||
> so a hidden tab or an idle scene does not expire them.
|
||||
> Do not swap this for `scene.pickPosition` per contact or for a depth
|
||||
> read-back: both wait on the GPU process (measured 0.2 to 1.4 ms per pick,
|
||||
> and 1.5 to 25 ms per `getBufferSubData` even behind a signalled sync
|
||||
> object). With no pick depth (2D or Columbus view, depth picking off) or
|
||||
> after a failed self-check, nothing is hidden and
|
||||
> `getDetectionDiagnostics().surfaceDepthMode` reads `off`; a WebGL 1
|
||||
> context reads depth back synchronously about 8 times a second and reads
|
||||
> `sync`. The renderer internals this relies on are listed, with the engine
|
||||
> version they were checked against, in `readSceneDepthSource`.
|
||||
> - **Required attribution has two named keep-out rules (2026-08-20):** the
|
||||
> Google/Cesium credit line must stay visible in every state, and below 900px
|
||||
> two surfaces used to paint over it — the command dock's popover tray (any
|
||||
|
||||
@@ -2599,8 +2599,10 @@
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionDraw.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/detectionOcclusion.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/sceneOcclusionProbe.js",
|
||||
"src/data/earthquakes.js",
|
||||
"src/data/fireAnchors.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
@@ -4083,6 +4085,8 @@
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/detectionOcclusion.js",
|
||||
"src/data/sceneOcclusionProbe.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
|
||||
+85
-1
@@ -50,6 +50,8 @@ import {
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
||||
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarFrameDelay,
|
||||
@@ -268,6 +270,22 @@ let _allocationStrategy = ALLOCATION_ELASTIC;
|
||||
let _lastNonOffMode = MODE_BALANCED;
|
||||
/** @type {LabelArbiter} Shared stable callout selector. */
|
||||
const _labelArbiter = new LabelArbiter();
|
||||
/**
|
||||
* Hides the bracket and callout of a depth-tested contact (a street vehicle)
|
||||
* that the scene drew something in front of. `_occlusionProbe` asks the GPU
|
||||
* about each such contact without waiting for the answer; `_surfaceOcclusion`
|
||||
* remembers the answers and decides, every frame, who is drawn.
|
||||
*/
|
||||
const _occlusionProbe = new SceneOcclusionProbe();
|
||||
const _surfaceOcclusion = new SurfaceOcclusion(_occlusionProbe);
|
||||
/**
|
||||
* Keys of callouts the label arbiter still holds for contacts last seen
|
||||
* hidden. A key stays here until its contact is seen drawn again or the
|
||||
* arbiter lets the callout go, so the callout cannot come back at its old
|
||||
* place just because its contact has since left the view.
|
||||
*/
|
||||
const _surfaceSuppressedKeys = new Set();
|
||||
let _surfaceDepthFallbackReported = false;
|
||||
let _lastLabelSolveAt = 0;
|
||||
let _labelSolveDirty = true;
|
||||
let _lastDiagnostics = null;
|
||||
@@ -372,6 +390,9 @@ export function destroyDetection() {
|
||||
_suspended = false;
|
||||
_suspendReason = '';
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.destroy();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastDiagnostics = null;
|
||||
@@ -697,6 +718,9 @@ function _applyModeState() {
|
||||
_syncSurfaceVisibility();
|
||||
_setLanesActive(false);
|
||||
_labelArbiter.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.reset();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = {
|
||||
@@ -1337,6 +1361,9 @@ function _drawOverlay(frame) {
|
||||
// every host lane. Detection retains the manual scalar projection below.
|
||||
const occluder = frame.occluder;
|
||||
const camPos = frame.cameraPosition;
|
||||
// Answers to the occlusion tests started on earlier frames land here.
|
||||
_surfaceOcclusion.beginFrame(now);
|
||||
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
||||
const keyhole = frame.keyhole;
|
||||
// Read the OUTSIDE setting ONCE per paint, from the same module state the
|
||||
// host's own keyhole alpha comes from, so a bracket and its callout can never
|
||||
@@ -1447,6 +1474,29 @@ function _drawOverlay(frame) {
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
// Something the scene drew in front of a depth-tested contact hides its
|
||||
// bracket and callout too, on every frame, not only on solves.
|
||||
if (obj.depthTested === true) {
|
||||
const testedLayerId = obj._layerId || 'unknown';
|
||||
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
||||
const hidden = _surfaceOcclusion.isHidden(
|
||||
testedLayerId,
|
||||
testedSourceId,
|
||||
obj.position,
|
||||
);
|
||||
// If the arbiter holds a callout for this contact it would keep painting
|
||||
// it until the next solve, and then fade it out where it last stood;
|
||||
// see `_surfaceSuppressedKeys`. Only held contacts cost a key.
|
||||
if (
|
||||
(hidden || _surfaceSuppressedKeys.size > 0) &&
|
||||
renderIdentities.get(testedLayerId)?.has(testedSourceId)
|
||||
) {
|
||||
const key = _detectionKey(testedLayerId, testedSourceId);
|
||||
if (hidden) _surfaceSuppressedKeys.add(key);
|
||||
else _surfaceSuppressedKeys.delete(key);
|
||||
}
|
||||
if (hidden) continue;
|
||||
}
|
||||
const sonarFactor =
|
||||
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
||||
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
||||
@@ -1547,6 +1597,8 @@ function _drawOverlay(frame) {
|
||||
candidateMap.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
// Start this frame's occlusion tests; nothing here waits for them.
|
||||
_occlusionProbe.endFrame();
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
@@ -1557,6 +1609,13 @@ function _drawOverlay(frame) {
|
||||
let didSolve = false;
|
||||
let solveMs = 0;
|
||||
if (shouldSolve) {
|
||||
_surfaceOcclusion.prune();
|
||||
if (_occlusionProbe.mode === 'sync' && !_surfaceDepthFallbackReported) {
|
||||
_surfaceDepthFallbackReported = true;
|
||||
console.info(
|
||||
'[detection] No occlusion queries here (WebGL 1); street-vehicle label occlusion reads the frame depth back synchronously, about 8 times a second.',
|
||||
);
|
||||
}
|
||||
const quotas = allocateLayerQuotas(
|
||||
demandByLayer,
|
||||
ambientBudget,
|
||||
@@ -1643,7 +1702,25 @@ function _drawOverlay(frame) {
|
||||
_ctx.lineWidth = bracketWidth;
|
||||
}
|
||||
|
||||
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
let renderEntries = _labelArbiter.renderEntries(candidateMap, now);
|
||||
// A callout whose contact has gone behind something goes with it at once:
|
||||
// no carry-over to the next solve and no fade painted on the facade.
|
||||
if (_surfaceSuppressedKeys.size > 0) {
|
||||
const allEntries = renderEntries;
|
||||
renderEntries = allEntries.filter(
|
||||
(entry) => !_surfaceSuppressedKeys.has(entry.candidate.key),
|
||||
);
|
||||
// The arbiter has let some of them go: forget those.
|
||||
if (
|
||||
allEntries.length - renderEntries.length !==
|
||||
_surfaceSuppressedKeys.size
|
||||
) {
|
||||
const held = new Set();
|
||||
for (const entry of allEntries) held.add(entry.candidate.key);
|
||||
for (const key of _surfaceSuppressedKeys)
|
||||
if (!held.has(key)) _surfaceSuppressedKeys.delete(key);
|
||||
}
|
||||
}
|
||||
const fadingCount = countFadingRenderEntries(renderEntries);
|
||||
// Demand counts fades in BOTH directions; `fadingCount` above stays the
|
||||
// fade-OUT tail because that is what the published diagnostics have always
|
||||
@@ -1698,6 +1775,13 @@ function _drawOverlay(frame) {
|
||||
keyholeRadius: keyhole.radius,
|
||||
keyholeFeatherPx: keyhole.featherPx,
|
||||
calloutOcclusionCount: calloutOcclusionRects.length,
|
||||
surfaceTestedCount: _surfaceOcclusion.testedCount,
|
||||
surfaceHiddenCount: _surfaceOcclusion.hiddenCount,
|
||||
surfaceDepthMode: _occlusionProbe.mode,
|
||||
// Tests started on the last frame, and what collecting answers and
|
||||
// drawing them cost it.
|
||||
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
||||
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
||||
};
|
||||
} else {
|
||||
const selectedCount = _labelArbiter.selectedKeys.size;
|
||||
|
||||
@@ -36,6 +36,12 @@ import {
|
||||
setAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
import { composeLabel, resolveTier } from './detectionDraw.js';
|
||||
import {
|
||||
createFakeDepthScene,
|
||||
createFakeGl,
|
||||
depthImage,
|
||||
viewDistanceToDepth,
|
||||
} from './fixtures/fakeDepthScene.mjs';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
@@ -1369,3 +1375,289 @@ test('transit contacts take the vehicle tier, not the civil-air one', () => {
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
});
|
||||
|
||||
/**
|
||||
* Give the host environment a scene that keeps frame depth: the camera 10,000
|
||||
* km up the +Z axis looking down it, and one depth range in which the whole
|
||||
* frame is a surface `surfaceDistance()` metres in front of the camera.
|
||||
*/
|
||||
function installDepthScene(env, surfaceDistance, { webgl2 = true } = {}) {
|
||||
const near = 1;
|
||||
const far = 100_000_000;
|
||||
const gl = createFakeGl({ webgl2 });
|
||||
const camera = env.viewer.camera;
|
||||
camera.viewMatrix = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, -10_000_000));
|
||||
camera.frustum.fovy = Math.PI / 2;
|
||||
camera.frustum.aspectRatio = 800 / 600;
|
||||
const paint = () =>
|
||||
depthImage(8, 6, () => viewDistanceToDepth(surfaceDistance(), near, far, true));
|
||||
const image = paint();
|
||||
const scene = createFakeDepthScene({ gl, ranges: [{ near, far, image }] });
|
||||
Object.assign(env.viewer.scene, scene, { camera });
|
||||
return {
|
||||
gl,
|
||||
/** Draw the next frame's depth. */
|
||||
redraw() {
|
||||
image.data.set(paint().data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Street contacts 3,643 km and 2,000 km below the camera, and a flight. */
|
||||
function occlusionLayers() {
|
||||
return [
|
||||
{
|
||||
id: 'traffic',
|
||||
getDetectableObjects: () => [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0, 6_356_752), id: 'VEH-0001', type: 'VEH', depthTested: true },
|
||||
{ position: new Cesium.Cartesian3(0.4, 0, 8_000_000), id: 'VEH-0002', type: 'VEH', depthTested: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'flights',
|
||||
// The same nearer surface is in front of it, but it is not depth-tested.
|
||||
getDetectableObjects: () => [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0.3, 6_356_752), sourceId: 'f1', id: 'AIR1', metric: 'FL300', type: 'AIR' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function paintedText(env) {
|
||||
return env.ctx.calls.filter(([name]) => name === 'fillText').map(([, text]) => text);
|
||||
}
|
||||
|
||||
/** One rendered frame 16 ms on; `settleFrame` is one 250 ms on (a solve). */
|
||||
function nextFrame(env) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
}
|
||||
|
||||
test('a depth-tested contact behind drawn geometry loses its bracket and callout, tested on the GPU without anything read back', () => {
|
||||
const env = installEnvironment();
|
||||
// A roof 3,000 km below the camera: in front of VEH-0001, behind VEH-0002.
|
||||
let surface = 3_000_000;
|
||||
const depth = installDepthScene(env, () => surface);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
let diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'async');
|
||||
assert.equal(diagnostics.surfaceTestedCount, 2, 'only the depth-tested contacts');
|
||||
assert.equal(diagnostics.surfaceProbeTests, 2);
|
||||
// New to the view, so held back for the frame or two the first answer
|
||||
// takes: neither is drawn yet. The flight is.
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 2, 'no answer has arrived yet');
|
||||
assert.equal(diagnostics.visibleCount, 1);
|
||||
assert.equal(depth.gl.counts.queries, 3, 'a control point and one query per contact');
|
||||
|
||||
// Frames before the GPU answers start nothing new and wait for nothing.
|
||||
nextFrame(env);
|
||||
nextFrame(env);
|
||||
assert.equal(depth.gl.counts.queries, 3);
|
||||
assert.equal(depth.gl.counts.resultReads, 0);
|
||||
|
||||
// The answers land. The contact above the roof is drawn from then on; the
|
||||
// one under it never is.
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'));
|
||||
assert.ok(paintedText(env).includes('VEH-0002'));
|
||||
|
||||
// It stays gone, frame after frame and solve after solve.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
|
||||
}
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 1);
|
||||
assert.equal(diagnostics.visibleCount, 2, 'the hidden contact has no bracket');
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0002'));
|
||||
assert.ok(!diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
assert.ok(diagnostics.labeledKeys.includes('flights:f1'));
|
||||
assert.ok(paintedText(env).includes('AIR1'));
|
||||
assert.equal(depth.gl.counts.readPixels, 0, 'depth never left the GPU');
|
||||
|
||||
// The roof goes away: the bracket returns once visible answers have kept
|
||||
// coming for the reappear time, and the callout once a solve re-admits it.
|
||||
surface = 5_000_000;
|
||||
depth.redraw();
|
||||
for (let i = 0; i < 80; i++) {
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
}
|
||||
diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a labelled contact that goes behind something loses its callout at once, and does not get it back by leaving the view', () => {
|
||||
const env = installEnvironment();
|
||||
// To begin with the drawn surface is below both vehicles.
|
||||
let surface = 5_000_000;
|
||||
const depth = installDepthScene(env, () => surface);
|
||||
const layers = occlusionLayers();
|
||||
const everyVehicle = layers[0].getDetectableObjects;
|
||||
const answerAndSolve = (count) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
}
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, layers, () => {});
|
||||
setMode('DENSE');
|
||||
answerAndSolve(6);
|
||||
assert.ok(getDetectionDiagnostics().labeledKeys.includes('traffic:VEH-0001'));
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(paintedText(env).includes('VEH-0001'));
|
||||
|
||||
// A roof is drawn over VEH-0001. Its callout goes on the frame the answer
|
||||
// lands, which is not a solve: no carry-over, no fade.
|
||||
surface = 3_000_000;
|
||||
depth.redraw();
|
||||
let gone = -1;
|
||||
for (let i = 0; i < 30 && gone < 0; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
if (!paintedText(env).includes('VEH-0001')) gone = i;
|
||||
}
|
||||
assert.ok(gone >= 0, 'the callout went');
|
||||
// The layer stops reporting it (it drove out of view). The arbiter still
|
||||
// remembers where its callout was; it must not paint it there again.
|
||||
layers[0].getDetectableObjects = () => everyVehicle().slice(1);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
depth.gl.finishGpu();
|
||||
env.ctx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.ok(!paintedText(env).includes('VEH-0001'), `frame ${i}`);
|
||||
}
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('without scene depth every contact keeps its bracket and callout', () => {
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'off');
|
||||
assert.equal(diagnostics.surfaceTestedCount, 2);
|
||||
assert.equal(diagnostics.surfaceProbeTests, 0);
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.ok(diagnostics.labeledKeys.includes('traffic:VEH-0001'));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scene depth that goes away puts hidden contacts back', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
depth.gl.finishGpu();
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
|
||||
// The scene stops keeping pick depth (a 2D view, depth picking turned off).
|
||||
depth.gl.finishGpu();
|
||||
env.viewer.scene.useDepthPicking = false;
|
||||
// The hidden time runs on painted frames, so paint a second's worth.
|
||||
for (let i = 0; i < 70; i++) nextFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'off');
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 0);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning detection off drops every test in flight; tearing it down frees the GPU objects', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(depth.gl.liveQueries.size, 3);
|
||||
// Off keeps the GPU objects (the shader is not compiled again on the next
|
||||
// on) but the answers in flight are no longer wanted.
|
||||
setMode('OFF');
|
||||
assert.equal(depth.gl.counts.deleted, 0);
|
||||
depth.gl.finishGpu();
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(depth.gl.counts.queries, 6, 'both contacts are tested afresh');
|
||||
assert.equal(depth.gl.liveQueries.size, 3, 'on the same queries, reused');
|
||||
depth.gl.finishGpu();
|
||||
nextFrame(env);
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().surfaceHiddenCount, 1);
|
||||
assert.equal(getDetectionDiagnostics().visibleCount, 2);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
// Tearing detection down releases everything.
|
||||
assert.equal(depth.gl.liveQueries.size, 0);
|
||||
assert.equal(depth.gl.counts.deleted, 5);
|
||||
});
|
||||
|
||||
test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
|
||||
const env = installEnvironment();
|
||||
const depth = installDepthScene(env, () => 3_000_000, { webgl2: false });
|
||||
const originalInfo = console.info;
|
||||
const reported = [];
|
||||
console.info = (...args) => reported.push(args.join(' '));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, occlusionLayers(), () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 30; i++) nextFrame(env);
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.surfaceDepthMode, 'sync');
|
||||
assert.equal(diagnostics.surfaceHiddenCount, 1);
|
||||
assert.equal(diagnostics.visibleCount, 2);
|
||||
// Half a second of frames: a read about every eighth of one, not one per
|
||||
// frame and not one per contact.
|
||||
assert.ok(depth.gl.counts.readPixels >= 3 && depth.gl.counts.readPixels <= 6, `${depth.gl.counts.readPixels} reads`);
|
||||
assert.equal(reported.length, 1);
|
||||
assert.match(reported[0], /synchronously/);
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
Vendored
+3
@@ -299,6 +299,9 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
position: pos,
|
||||
id: `VEH-${String(layerState._dots[i].id).padStart(4, '0')}`,
|
||||
type: 'VEH',
|
||||
// The dots are depth-tested, so the overlay hides a contact's
|
||||
// bracket and callout wherever the scene hides its dot.
|
||||
depthTested: true,
|
||||
};
|
||||
// Live mode: the detection bracket carries the congestion signal —
|
||||
// its canvas sits ABOVE the post-FX chain, so tier colors survive
|
||||
|
||||
Reference in new issue
Block a user