mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Hide street vehicles behind low walls and trees seen from above
In a dense downtown view from about 400 m up, the occlusion margin of max(3 m, 2% of the range) came to 14 to 20 m, so a wall, awning, tree or lower facade standing 10 to 20 m in front of a street was read as the road itself: the vehicle's dot was hidden by the scene, but its bracket and callout were still drawn. Checked against scene.pickPosition in one such view, 18 of 470 vehicles in frame fell in that band. The margin now depends on how steeply the camera looks down on the contact. A point on the line of sight s metres in front of a vehicle stands s * sin(elevation) above it, so a surface there hides the vehicle once that rise passes 2 m. The margin stays at least 3 m and at most 2% of the range, so grazing and street-level views are unchanged. In the same views no vehicle with the drawn surface within 3 m in front of it (its own road) is newly hidden. From the review of the first change: - A contact back in view after longer than a retest interval is held back and retested like a new one, instead of being drawn from an answer given before it left the view. It gets a fresh record, so a test still in flight from before cannot end the hold-back either. - The hold-back for a contact's first answer stretches with the retest pace when a crowded view slows it. - A batch whose control point failed no longer counts as a first answer. - An unknown log-depth flag reads as "no depth" rather than as standard depth, which would have put every surface in front of every contact. - A shader that fails to build leaves no shader behind and is not built again on the same context.
This commit is contained in:
1 parent
630e1b1998
commit
076d264982
6 files changed
+388
-31
No files matched your search
+14
-5
@@ -3609,8 +3609,13 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
|||||||
> (2026-11-09):** a contact a layer marks `depthTested: true` (only Street
|
> (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
|
> 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
|
> of the overlay while a building, 3D tile, terrain or any other drawn
|
||||||
> surface stands in front of it by more than max(3 m, 2% of its range). That
|
> surface stands in front of it by more than a margin: 3 m, or what a 2 m
|
||||||
> includes vehicles the road data routes through tunnels. Every other layer
|
> 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
|
> 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
|
> scene on purpose and keep their labels over it. The test runs on the GPU
|
||||||
> (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact,
|
> (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact,
|
||||||
@@ -3619,9 +3624,13 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
|||||||
> "hidden" answer arrives and returns 600 ms after its last one, which keeps
|
> "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
|
> 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.
|
> behind something is dropped at once rather than faded out on the facade.
|
||||||
> A contact new to the view is held back until its first answer (at most
|
> A contact new to the view, or back in it after longer than a retest
|
||||||
> 400 ms), so orbiting a city does not flash a bracket for each vehicle that
|
> interval, is held back until its first answer (at most 400 ms, longer
|
||||||
> swings into frame behind a building. These times run on painted frames,
|
> 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.
|
> 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
|
> 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,
|
> read-back: both wait on the GPU process (measured 0.2 to 1.4 ms per pick,
|
||||||
|
|||||||
@@ -27,22 +27,43 @@
|
|||||||
|
|
||||||
/** Never less than this: covers a dot drawn a little above or into its road. */
|
/** Never less than this: covers a dot drawn a little above or into its road. */
|
||||||
export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3;
|
export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3;
|
||||||
/** Grows with range so depth-buffer precision never reads as an occluder. */
|
/** The most the margin grows with range: 2% of it. */
|
||||||
export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02;
|
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
|
* How much nearer than the contact the drawn surface must be before it counts
|
||||||
* as standing in front of it.
|
* 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} 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.
|
* @returns {number} Margin in metres.
|
||||||
*/
|
*/
|
||||||
export function surfaceOcclusionMargin(contactDistance) {
|
export function surfaceOcclusionMargin(contactDistance, sinElevation = 0) {
|
||||||
const distance = Number(contactDistance);
|
const distance = Number(contactDistance);
|
||||||
if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M;
|
if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M;
|
||||||
return Math.max(
|
let margin = distance * SURFACE_OCCLUSION_MARGIN_FRACTION;
|
||||||
SURFACE_OCCLUSION_MIN_MARGIN_M,
|
const rise = Number(sinElevation);
|
||||||
distance * SURFACE_OCCLUSION_MARGIN_FRACTION,
|
if (rise > 0) margin = Math.min(margin, SURFACE_OCCLUSION_RISE_M / rise);
|
||||||
);
|
return Math.max(SURFACE_OCCLUSION_MIN_MARGIN_M, margin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,13 +71,19 @@ export function surfaceOcclusionMargin(contactDistance) {
|
|||||||
* @param {number} contactDistance Camera-to-contact distance in metres.
|
* @param {number} contactDistance Camera-to-contact distance in metres.
|
||||||
* @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance
|
* @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance
|
||||||
* along the contact's ray; undefined or non-finite when nothing was drawn.
|
* along the contact's ray; undefined or non-finite when nothing was drawn.
|
||||||
|
* @param {number} [sinElevation] See `surfaceOcclusionMargin`.
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function surfaceHidesContact(contactDistance, surfaceDistance) {
|
export function surfaceHidesContact(
|
||||||
|
contactDistance,
|
||||||
|
surfaceDistance,
|
||||||
|
sinElevation = 0,
|
||||||
|
) {
|
||||||
if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance))
|
if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance))
|
||||||
return false;
|
return false;
|
||||||
return (
|
return (
|
||||||
surfaceDistance < contactDistance - surfaceOcclusionMargin(contactDistance)
|
surfaceDistance <
|
||||||
|
contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +136,12 @@ export function depthToViewDistance(depth, near, far, logDepth) {
|
|||||||
* `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`,
|
* `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`,
|
||||||
* and comparing a decoded depth with it is all the GPU has to do.
|
* 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
|
* @param {ArrayLike<number>} view World-to-camera matrix, column-major, the
|
||||||
* camera looking down its own -Z axis.
|
* camera looking down its own -Z axis.
|
||||||
* @param {number} tanHalfFovX
|
* @param {number} tanHalfFovX
|
||||||
@@ -131,11 +164,22 @@ export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) {
|
|||||||
const contactDistance = Math.sqrt(
|
const contactDistance = Math.sqrt(
|
||||||
ex * ex + ey * ey + viewDistance * viewDistance,
|
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.u = u;
|
||||||
out.v = v;
|
out.v = v;
|
||||||
out.threshold =
|
out.threshold =
|
||||||
viewDistance *
|
viewDistance *
|
||||||
(1 - surfaceOcclusionMargin(contactDistance) / contactDistance);
|
(1 -
|
||||||
|
surfaceOcclusionMargin(contactDistance, sinElevation) / contactDistance);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +347,7 @@ export class SurfaceOcclusion {
|
|||||||
this.hiddenCount = 0;
|
this.hiddenCount = 0;
|
||||||
this.retestMs = SURFACE_RETEST_MS;
|
this.retestMs = SURFACE_RETEST_MS;
|
||||||
this.reappearMs = SURFACE_REAPPEAR_MS;
|
this.reappearMs = SURFACE_REAPPEAR_MS;
|
||||||
|
this.firstAnswerMs = SURFACE_FIRST_ANSWER_MS;
|
||||||
this._phase = 0;
|
this._phase = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +371,9 @@ export class SurfaceOcclusion {
|
|||||||
(this.testedCount / budget) * frameMs * 2,
|
(this.testedCount / budget) * frameMs * 2,
|
||||||
);
|
);
|
||||||
this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3);
|
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.now += frameMs;
|
||||||
this.testedCount = 0;
|
this.testedCount = 0;
|
||||||
this.hiddenCount = 0;
|
this.hiddenCount = 0;
|
||||||
@@ -347,11 +395,19 @@ export class SurfaceOcclusion {
|
|||||||
this.contacts.set(layerId, layer);
|
this.contacts.set(layerId, layer);
|
||||||
}
|
}
|
||||||
let contact = layer.get(sourceId);
|
let contact = layer.get(sourceId);
|
||||||
if (!contact) {
|
if (!contact || now - contact.seenAt > this.retestMs) {
|
||||||
|
// New, or back in view after longer than a retest interval. A returning
|
||||||
|
// contact's last answer is older than any contact that stayed in view
|
||||||
|
// runs on, and the view has probably turned since: it is held back
|
||||||
|
// like a new one until a fresh answer comes, or orbiting flashes it
|
||||||
|
// behind the building it went behind while out of view. It gets a new
|
||||||
|
// record, so a test still in flight from before answers the old one
|
||||||
|
// and cannot end the hold-back with what the previous view showed. A
|
||||||
|
// "hidden" it already had keeps running.
|
||||||
contact = {
|
contact = {
|
||||||
lastHiddenAt: Number.NEGATIVE_INFINITY,
|
lastHiddenAt: contact?.lastHiddenAt ?? Number.NEGATIVE_INFINITY,
|
||||||
hiddenUntil: Number.NEGATIVE_INFINITY,
|
hiddenUntil: contact?.hiddenUntil ?? Number.NEGATIVE_INFINITY,
|
||||||
appliedHiddenAt: Number.NEGATIVE_INFINITY,
|
appliedHiddenAt: contact?.appliedHiddenAt ?? Number.NEGATIVE_INFINITY,
|
||||||
queuedAt: Number.NEGATIVE_INFINITY,
|
queuedAt: Number.NEGATIVE_INFINITY,
|
||||||
pending: false,
|
pending: false,
|
||||||
answered: false,
|
answered: false,
|
||||||
@@ -385,7 +441,7 @@ export class SurfaceOcclusion {
|
|||||||
// Not yet answered: held back while a working probe finds out.
|
// Not yet answered: held back while a working probe finds out.
|
||||||
(!contact.answered &&
|
(!contact.answered &&
|
||||||
this.probe.mode === 'async' &&
|
this.probe.mode === 'async' &&
|
||||||
now - contact.firstSeenAt < SURFACE_FIRST_ANSWER_MS);
|
now - contact.firstSeenAt < this.firstAnswerMs);
|
||||||
if (hidden) this.hiddenCount++;
|
if (hidden) this.hiddenCount++;
|
||||||
return hidden;
|
return hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () =>
|
|||||||
assert.equal(surfaceOcclusionMargin(Number.NaN), 3);
|
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', () => {
|
test('a surface hides a contact only when nearer by more than the margin', () => {
|
||||||
// 500 m away: margin 10 m.
|
// 500 m away: margin 10 m.
|
||||||
assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front');
|
assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front');
|
||||||
@@ -142,6 +161,134 @@ test('the threshold is the margin rule restated as a view distance', () => {
|
|||||||
assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9);
|
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', () => {
|
test('behind the camera or outside the frame there is nothing to test', () => {
|
||||||
const out = { u: 7, v: 7, threshold: 7 };
|
const out = { u: 7, v: 7, threshold: 7 };
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -616,3 +763,89 @@ test('prune forgets contacts that are no longer drawn; clear forgets everything'
|
|||||||
assert.equal(occlusion.contacts.size, 0);
|
assert.equal(occlusion.contacts.size, 0);
|
||||||
assert.equal(occlusion.testedCount, 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`);
|
||||||
|
});
|
||||||
@@ -101,6 +101,7 @@ export function createFakeGl({ webgl2 = true } = {}) {
|
|||||||
lost: false,
|
lost: false,
|
||||||
/** Whether a point that should draw does (false: a broken draw path). */
|
/** Whether a point that should draw does (false: a broken draw path). */
|
||||||
drawsLand: true,
|
drawsLand: true,
|
||||||
|
/** Whether shaders compile: a boolean, or a function of the shader type. */
|
||||||
compiles: true,
|
compiles: true,
|
||||||
};
|
};
|
||||||
const counts = {
|
const counts = {
|
||||||
@@ -111,12 +112,14 @@ export function createFakeGl({ webgl2 = true } = {}) {
|
|||||||
deleted: 0,
|
deleted: 0,
|
||||||
};
|
};
|
||||||
const liveQueries = new Set();
|
const liveQueries = new Set();
|
||||||
|
const liveShaders = new Set();
|
||||||
let activeQuery = null;
|
let activeQuery = null;
|
||||||
const gl = {
|
const gl = {
|
||||||
...GL,
|
...GL,
|
||||||
state,
|
state,
|
||||||
counts,
|
counts,
|
||||||
liveQueries,
|
liveQueries,
|
||||||
|
liveShaders,
|
||||||
isContextLost: () => state.lost,
|
isContextLost: () => state.lost,
|
||||||
getParameter(name) {
|
getParameter(name) {
|
||||||
if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read;
|
if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read;
|
||||||
@@ -186,11 +189,20 @@ export function createFakeGl({ webgl2 = true } = {}) {
|
|||||||
state.draw.image = texture.image;
|
state.draw.image = texture.image;
|
||||||
},
|
},
|
||||||
checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE,
|
checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE,
|
||||||
createShader: () => ({}),
|
createShader(type) {
|
||||||
|
const shader = { type };
|
||||||
|
liveShaders.add(shader);
|
||||||
|
return shader;
|
||||||
|
},
|
||||||
shaderSource() {},
|
shaderSource() {},
|
||||||
compileShader() {},
|
compileShader() {},
|
||||||
getShaderParameter: () => state.compiles,
|
getShaderParameter: (shader) =>
|
||||||
deleteShader() {},
|
typeof state.compiles === 'function'
|
||||||
|
? state.compiles(shader.type)
|
||||||
|
: state.compiles,
|
||||||
|
deleteShader(shader) {
|
||||||
|
liveShaders.delete(shader);
|
||||||
|
},
|
||||||
createProgram: () => ({ uniforms: {} }),
|
createProgram: () => ({ uniforms: {} }),
|
||||||
attachShader() {},
|
attachShader() {},
|
||||||
linkProgram() {},
|
linkProgram() {},
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const SYNC_READ_INTERVAL_MS = 125;
|
|||||||
* plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as
|
* plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as
|
||||||
* `scene.pickPosition` assumes when it decodes the same textures.
|
* `scene.pickPosition` assumes when it decodes the same textures.
|
||||||
* - `scene.frameState.useLogDepth`: whether depth is logarithmic (see
|
* - `scene.frameState.useLogDepth`: whether depth is logarithmic (see
|
||||||
* `depthToViewDistance`).
|
* `depthToViewDistance`). Anything but a boolean there is "no depth".
|
||||||
*
|
*
|
||||||
* Any of these missing or shaped differently returns null, which callers treat
|
* Any of these missing or shaped differently returns null, which callers treat
|
||||||
* as "no depth".
|
* as "no depth".
|
||||||
@@ -145,6 +145,10 @@ export function readSceneDepthSource(scene, viewScratch) {
|
|||||||
frustums.push({ near, far });
|
frustums.push({ near, far });
|
||||||
}
|
}
|
||||||
if (!(width > 0) || !(height > 0)) return null;
|
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);
|
const tanHalfFovY = Math.tan(fovy / 2);
|
||||||
return {
|
return {
|
||||||
@@ -155,7 +159,7 @@ export function readSceneDepthSource(scene, viewScratch) {
|
|||||||
framebuffers,
|
framebuffers,
|
||||||
textures,
|
textures,
|
||||||
frustums,
|
frustums,
|
||||||
logDepth: scene.frameState?.useLogDepth === true,
|
logDepth,
|
||||||
view: Cesium.Matrix4.toArray(
|
view: Cesium.Matrix4.toArray(
|
||||||
camera.viewMatrix,
|
camera.viewMatrix,
|
||||||
viewScratch || new Float64Array(16),
|
viewScratch || new Float64Array(16),
|
||||||
@@ -262,6 +266,8 @@ export class SceneOcclusionProbe {
|
|||||||
this._now = 0;
|
this._now = 0;
|
||||||
this._source = undefined;
|
this._source = undefined;
|
||||||
this._gl = null;
|
this._gl = null;
|
||||||
|
/** A context the probe's shader failed to build on; never retried. */
|
||||||
|
this._unsupportedGl = null;
|
||||||
this._resources = null;
|
this._resources = null;
|
||||||
this._queryPool = [];
|
this._queryPool = [];
|
||||||
this._batches = [];
|
this._batches = [];
|
||||||
@@ -409,6 +415,7 @@ export class SceneOcclusionProbe {
|
|||||||
this._queryPool.length = 0;
|
this._queryPool.length = 0;
|
||||||
this._resources = null;
|
this._resources = null;
|
||||||
this._gl = null;
|
this._gl = null;
|
||||||
|
this._unsupportedGl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tests that will never be answered stop counting as in flight. */
|
/** Tests that will never be answered stop counting as in flight. */
|
||||||
@@ -448,6 +455,11 @@ export class SceneOcclusionProbe {
|
|||||||
this._resources = null;
|
this._resources = null;
|
||||||
return;
|
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 (source.webgl2 && canQuery(source.gl)) {
|
||||||
if (!this._resources && !this._createResources(source.gl)) {
|
if (!this._resources && !this._createResources(source.gl)) {
|
||||||
this._fail();
|
this._fail();
|
||||||
@@ -476,7 +488,13 @@ export class SceneOcclusionProbe {
|
|||||||
_createResources(gl) {
|
_createResources(gl) {
|
||||||
const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER);
|
const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER);
|
||||||
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||||
if (!vertex || !fragment) return false;
|
if (!vertex || !fragment) {
|
||||||
|
if (vertex) gl.deleteShader(vertex);
|
||||||
|
if (fragment) gl.deleteShader(fragment);
|
||||||
|
// The same source will not compile on this context next time either.
|
||||||
|
this._unsupportedGl = gl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const program = gl.createProgram();
|
const program = gl.createProgram();
|
||||||
gl.attachShader(program, vertex);
|
gl.attachShader(program, vertex);
|
||||||
gl.attachShader(program, fragment);
|
gl.attachShader(program, fragment);
|
||||||
@@ -485,6 +503,7 @@ export class SceneOcclusionProbe {
|
|||||||
gl.deleteShader(fragment);
|
gl.deleteShader(fragment);
|
||||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
gl.deleteProgram(program);
|
gl.deleteProgram(program);
|
||||||
|
this._unsupportedGl = gl;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const uniform = (name) => gl.getUniformLocation(program, name);
|
const uniform = (name) => gl.getUniformLocation(program, name);
|
||||||
@@ -691,10 +710,14 @@ export class SceneOcclusionProbe {
|
|||||||
} else {
|
} else {
|
||||||
const contact = contacts[batch.cursor - 1];
|
const contact = contacts[batch.cursor - 1];
|
||||||
contact.pending = false;
|
contact.pending = false;
|
||||||
contact.answered = true;
|
// Without the control point's yes the batch means nothing: not a
|
||||||
// Without the control point's yes, a no means nothing.
|
// "hidden", and not a first answer that ends a new contact's
|
||||||
if (!drew && batch.control && batch.at > contact.lastHiddenAt)
|
// hold-back either.
|
||||||
contact.lastHiddenAt = batch.at;
|
if (batch.control) {
|
||||||
|
contact.answered = true;
|
||||||
|
if (!drew && batch.at > contact.lastHiddenAt)
|
||||||
|
contact.lastHiddenAt = batch.at;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._queryPool.push(query);
|
this._queryPool.push(query);
|
||||||
batch.cursor++;
|
batch.cursor++;
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ test('no depth source: nothing is tested and nothing is in flight', () => {
|
|||||||
scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet
|
scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet
|
||||||
return scene;
|
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();
|
const scene = wallScene();
|
||||||
Object.defineProperty(scene, 'view', {
|
Object.defineProperty(scene, 'view', {
|
||||||
@@ -329,6 +334,7 @@ test('a control point that does not draw voids its batch; a run of them stands t
|
|||||||
frame(probe, scene, now);
|
frame(probe, scene, now);
|
||||||
assert.equal(entry.pending, false);
|
assert.equal(entry.pending, false);
|
||||||
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed');
|
assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed');
|
||||||
|
assert.notEqual(entry.answered, true, 'and not a first answer either');
|
||||||
now += 16;
|
now += 16;
|
||||||
}
|
}
|
||||||
assert.equal(probe.mode, 'off');
|
assert.equal(probe.mode, 'off');
|
||||||
@@ -351,6 +357,7 @@ test('a control point that does not draw voids its batch; a run of them stands t
|
|||||||
gl.finishGpu();
|
gl.finishGpu();
|
||||||
frame(probe, scene, retryAt + 16);
|
frame(probe, scene, retryAt + 16);
|
||||||
assert.equal(entry.lastHiddenAt, retryAt);
|
assert.equal(entry.lastHiddenAt, retryAt);
|
||||||
|
assert.equal(entry.answered, true);
|
||||||
|
|
||||||
// One good batch in between resets the count.
|
// One good batch in between resets the count.
|
||||||
const healthy = new SceneOcclusionProbe();
|
const healthy = new SceneOcclusionProbe();
|
||||||
@@ -366,16 +373,33 @@ test('a control point that does not draw voids its batch; a run of them stands t
|
|||||||
|
|
||||||
test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => {
|
test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => {
|
||||||
let scene = wallScene();
|
let scene = wallScene();
|
||||||
scene.context._gl.state.compiles = false;
|
// Only the fragment shader fails: the vertex shader that did compile must
|
||||||
|
// not be left behind.
|
||||||
|
scene.context._gl.state.compiles = (type) =>
|
||||||
|
type !== scene.context._gl.FRAGMENT_SHADER;
|
||||||
let probe = new SceneOcclusionProbe();
|
let probe = new SceneOcclusionProbe();
|
||||||
let entry = contact();
|
let entry = contact();
|
||||||
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]);
|
||||||
assert.equal(probe.mode, 'off');
|
assert.equal(probe.mode, 'off');
|
||||||
assert.equal(entry.pending, false);
|
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.
|
// A draw that throws part-way: bindings restored, no query left behind.
|
||||||
scene = wallScene();
|
scene = wallScene();
|
||||||
let gl = scene.context._gl;
|
gl = scene.context._gl;
|
||||||
const found = {
|
const found = {
|
||||||
draw: { image: null },
|
draw: { image: null },
|
||||||
program: { uniforms: {} },
|
program: { uniforms: {} },
|
||||||
|
|||||||
Reference in new issue
Block a user