diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 602abf037..7ad2b6e95 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3609,8 +3609,13 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > (2026-11-09):** a contact a layer marks `depthTested: true` (only Street > Traffic does) is tested against the depth the scene drew, and is left out > of the overlay while a building, 3D tile, terrain or any other drawn -> surface stands in front of it by more than max(3 m, 2% of its range). That -> includes vehicles the road data routes through tunnels. Every other layer +> surface stands in front of it by more than a margin: 3 m, or what a 2 m +> rise above the vehicle on its line of sight takes when the camera looks +> down on it, and above 3 m never more than 2% of its range (2% applies +> outright at grazing angles or when the camera looks up). A flat +> 2% let walls, awnings and trees 10 to 20 m in front of a street 1 km away +> pass as the road. That includes vehicles the road data routes through +> tunnels. Every other layer > is untouched: aircraft, vessels and satellites draw their markers over the > scene on purpose and keep their labels over it. The test runs on the GPU > (`src/data/sceneOcclusionProbe.js`): one occlusion query per contact, @@ -3619,9 +3624,13 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > "hidden" answer arrives and returns 600 ms after its last one, which keeps > a contact on a silhouette edge from blinking; a callout whose contact goes > behind something is dropped at once rather than faded out on the facade. -> A contact new to the view is held back until its first answer (at most -> 400 ms), so orbiting a city does not flash a bracket for each vehicle that -> swings into frame behind a building. These times run on painted frames, +> A contact new to the view, or back in it after longer than a retest +> interval, is held back until its first answer (at most 400 ms, longer +> when a crowded view stretches the retest pace), so orbiting a city does +> not flash a bracket for each vehicle that swings into frame behind a +> building. A callout placed beside a visible vehicle can still overlap a +> facade; that is where the callout was placed, not the vehicle being +> behind it. These times run on painted frames, > so a hidden tab or an idle scene does not expire them. > Do not swap this for `scene.pickPosition` per contact or for a depth > read-back: both wait on the GPU process (measured 0.2 to 1.4 ms per pick, diff --git a/src/data/detectionOcclusion.js b/src/data/detectionOcclusion.js index 22c8d2ac4..83c668725 100644 --- a/src/data/detectionOcclusion.js +++ b/src/data/detectionOcclusion.js @@ -27,22 +27,43 @@ /** Never less than this: covers a dot drawn a little above or into its road. */ export const SURFACE_OCCLUSION_MIN_MARGIN_M = 3; -/** Grows with range so depth-buffer precision never reads as an occluder. */ +/** The most the margin grows with range: 2% of it. */ export const SURFACE_OCCLUSION_MARGIN_FRACTION = 0.02; +/** + * How far above a contact a drawn surface on its line of sight must stand to + * count as in front of it, when the camera looks down on the contact. + */ +export const SURFACE_OCCLUSION_RISE_M = 2; /** * How much nearer than the contact the drawn surface must be before it counts * as standing in front of it. + * + * The margin absorbs a contact placed a little above or below the drawn + * ground: a vehicle's position and the scene's road surface are not the same + * height to the metre. Looking down at the contact, a point on its line of + * sight `s` metres in front of it stands `s * sinElevation` metres above it, + * so a surface there only hides the contact once `s` passes + * `SURFACE_OCCLUSION_RISE_M / sinElevation`. That keeps the margin to a few + * metres from above, where a fixed share of the range would let a wall, + * awning or tree 10 to 20 m in front of a street at 1 km pass as "the road". + * At grazing angles, or looking up, the margin is 2% of the range at most + * (never under 3 m); depth precision is far finer than that at every range + * the overlay labels. + * * @param {number} contactDistance Camera-to-contact distance in metres. + * @param {number} [sinElevation] Sine of the camera's elevation seen from the + * contact (1 straight overhead, 0 level, negative below); unknown when + * omitted. * @returns {number} Margin in metres. */ -export function surfaceOcclusionMargin(contactDistance) { +export function surfaceOcclusionMargin(contactDistance, sinElevation = 0) { const distance = Number(contactDistance); if (!(distance > 0)) return SURFACE_OCCLUSION_MIN_MARGIN_M; - return Math.max( - SURFACE_OCCLUSION_MIN_MARGIN_M, - distance * SURFACE_OCCLUSION_MARGIN_FRACTION, - ); + let margin = distance * SURFACE_OCCLUSION_MARGIN_FRACTION; + const rise = Number(sinElevation); + if (rise > 0) margin = Math.min(margin, SURFACE_OCCLUSION_RISE_M / rise); + return Math.max(SURFACE_OCCLUSION_MIN_MARGIN_M, margin); } /** @@ -50,13 +71,19 @@ export function surfaceOcclusionMargin(contactDistance) { * @param {number} contactDistance Camera-to-contact distance in metres. * @param {number|undefined} surfaceDistance Camera-to-drawn-surface distance * along the contact's ray; undefined or non-finite when nothing was drawn. + * @param {number} [sinElevation] See `surfaceOcclusionMargin`. * @returns {boolean} */ -export function surfaceHidesContact(contactDistance, surfaceDistance) { +export function surfaceHidesContact( + contactDistance, + surfaceDistance, + sinElevation = 0, +) { if (!Number.isFinite(contactDistance) || !Number.isFinite(surfaceDistance)) return false; return ( - surfaceDistance < contactDistance - surfaceOcclusionMargin(contactDistance) + surfaceDistance < + contactDistance - surfaceOcclusionMargin(contactDistance, sinElevation) ); } @@ -109,6 +136,12 @@ export function depthToViewDistance(depth, near, far, logDepth) { * `viewDistance * (1 - margin / contactDistance)`. That number is `threshold`, * and comparing a decoded depth with it is all the GPU has to do. * + * The margin depends on how steeply the camera looks down on the contact + * (see `surfaceOcclusionMargin`). "Up" at the contact is taken as the + * direction from the world origin to it, which is the Earth's centre in the + * Earth-fixed frame the scene is drawn in; it differs from the true vertical + * by a fraction of a degree at most. + * * @param {ArrayLike} view World-to-camera matrix, column-major, the * camera looking down its own -Z axis. * @param {number} tanHalfFovX @@ -131,11 +164,22 @@ export function projectContact(view, tanHalfFovX, tanHalfFovY, position, out) { const contactDistance = Math.sqrt( ex * ex + ey * ey + viewDistance * viewDistance, ); + // Up at the contact, turned into camera axes, against the direction from + // the contact to the camera (-ex, -ey, viewDistance in camera axes). + const radius = Math.sqrt(x * x + y * y + z * z); + let sinElevation = 0; + if (radius > 0) { + const ux = (view[0] * x + view[4] * y + view[8] * z) / radius; + const uy = (view[1] * x + view[5] * y + view[9] * z) / radius; + const uz = (view[2] * x + view[6] * y + view[10] * z) / radius; + sinElevation = (-ux * ex - uy * ey + uz * viewDistance) / contactDistance; + } out.u = u; out.v = v; out.threshold = viewDistance * - (1 - surfaceOcclusionMargin(contactDistance) / contactDistance); + (1 - + surfaceOcclusionMargin(contactDistance, sinElevation) / contactDistance); return true; } @@ -303,6 +347,7 @@ export class SurfaceOcclusion { this.hiddenCount = 0; this.retestMs = SURFACE_RETEST_MS; this.reappearMs = SURFACE_REAPPEAR_MS; + this.firstAnswerMs = SURFACE_FIRST_ANSWER_MS; this._phase = 0; } @@ -326,6 +371,9 @@ export class SurfaceOcclusion { (this.testedCount / budget) * frameMs * 2, ); this.reappearMs = Math.max(SURFACE_REAPPEAR_MS, this.retestMs * 3); + // At a stretched pace a crowd of new contacts takes longer to get its + // first answers; the hold-back stretches with it. + this.firstAnswerMs = Math.max(SURFACE_FIRST_ANSWER_MS, this.retestMs * 2); this.now += frameMs; this.testedCount = 0; this.hiddenCount = 0; @@ -347,11 +395,19 @@ export class SurfaceOcclusion { this.contacts.set(layerId, layer); } let contact = layer.get(sourceId); - if (!contact) { + if (!contact || now - contact.seenAt > this.retestMs) { + // New, or back in view after longer than a retest interval. A returning + // contact's last answer is older than any contact that stayed in view + // runs on, and the view has probably turned since: it is held back + // like a new one until a fresh answer comes, or orbiting flashes it + // behind the building it went behind while out of view. It gets a new + // record, so a test still in flight from before answers the old one + // and cannot end the hold-back with what the previous view showed. A + // "hidden" it already had keeps running. contact = { - lastHiddenAt: Number.NEGATIVE_INFINITY, - hiddenUntil: Number.NEGATIVE_INFINITY, - appliedHiddenAt: Number.NEGATIVE_INFINITY, + lastHiddenAt: contact?.lastHiddenAt ?? Number.NEGATIVE_INFINITY, + hiddenUntil: contact?.hiddenUntil ?? Number.NEGATIVE_INFINITY, + appliedHiddenAt: contact?.appliedHiddenAt ?? Number.NEGATIVE_INFINITY, queuedAt: Number.NEGATIVE_INFINITY, pending: false, answered: false, @@ -385,7 +441,7 @@ export class SurfaceOcclusion { // Not yet answered: held back while a working probe finds out. (!contact.answered && this.probe.mode === 'async' && - now - contact.firstSeenAt < SURFACE_FIRST_ANSWER_MS); + now - contact.firstSeenAt < this.firstAnswerMs); if (hidden) this.hiddenCount++; return hidden; } diff --git a/src/data/detectionOcclusion.test.mjs b/src/data/detectionOcclusion.test.mjs index fbd1fdd57..5aadc7c92 100644 --- a/src/data/detectionOcclusion.test.mjs +++ b/src/data/detectionOcclusion.test.mjs @@ -30,6 +30,25 @@ test('the margin is 3 m near the camera and 2% of the range beyond 150 m', () => assert.equal(surfaceOcclusionMargin(Number.NaN), 3); }); +test('looking down on a contact, the margin is what a 2 m rise on its line of sight takes', () => { + // 1 km away, seen from 30 degrees up: 2 m of rise is 4 m along the ray. + assert.ok(Math.abs(surfaceOcclusionMargin(1000, 0.5) - 4) < 1e-9); + // Straight down: the 3 m floor. + assert.equal(surfaceOcclusionMargin(1000, 1), SURFACE_OCCLUSION_MIN_MARGIN_M); + // Grazing, level or from below: never more than 2% of the range. + assert.equal(surfaceOcclusionMargin(1000, 0.01), 20); + assert.equal(surfaceOcclusionMargin(1000, 0), 20); + assert.equal(surfaceOcclusionMargin(1000, -0.3), 20); + assert.equal(surfaceOcclusionMargin(1000, Number.NaN), 20); + assert.equal(surfaceOcclusionMargin(100, 0.05), 3); + // A low wall 15 m in front of a street 1 km off, seen from 25 degrees up, + // stands about 6 m above the vehicle's line of sight: it hides it. + const sin25 = Math.sin((25 * Math.PI) / 180); + assert.equal(surfaceHidesContact(1000, 985, sin25), true); + assert.equal(surfaceHidesContact(1000, 985), false, 'unknown angle: 2%'); + assert.equal(surfaceHidesContact(1000, 996, sin25), false, 'its own road'); +}); + test('a surface hides a contact only when nearer by more than the margin', () => { // 500 m away: margin 10 m. assert.equal(surfaceHidesContact(500, 489), true, 'a facade 11 m in front'); @@ -142,6 +161,134 @@ test('the threshold is the margin rule restated as a view distance', () => { assert.ok(Math.abs(out.threshold - 400 * 0.98) < 1e-9); }); +test('the threshold uses how steeply the camera looks down on the contact', () => { + // A street on a sphere the size of the Earth, under a camera 1 km up and + // 1 km back along the surface, looking straight at it: 45 degrees down. + const R = 6_371_000; + const street = { x: 0, y: 0, z: R }; + const camera = { x: 0, y: -1000, z: R + 1000 }; + // Camera axes: x along world x, looking along (0, 1, -1) / sqrt 2. + const s = Math.SQRT1_2; + const forward = [0, s, -s]; + const up = [0, s, s]; + const right = [1, 0, 0]; + const t = (axis) => + -(axis[0] * camera.x + axis[1] * camera.y + axis[2] * camera.z); + // Rows: right, up, -forward (the camera looks down its -Z). + const back = forward.map((c) => -c); + const view = [ + right[0], + up[0], + back[0], + 0, + right[1], + up[1], + back[1], + 0, + right[2], + up[2], + back[2], + 0, + t(right), + t(up), + t(back), + 1, + ]; + const out = { u: 0, v: 0, threshold: 0 }; + assert.equal(projectContact(view, 1, 1, street, out), true); + assert.ok(Math.abs(out.u - 0.5) < 1e-9 && Math.abs(out.v - 0.5) < 1e-9); + const range = Math.SQRT2 * 1000; + // 2 m of rise at 45 degrees is 2.83 m along the ray: the 3 m floor, not + // 2% of 1414 m (28 m). + assert.ok(Math.abs(out.threshold - (range - 3)) < 1e-6, `${out.threshold}`); +}); + +test('off the middle of the frame, with the camera turned, the threshold matches the world-space rule', () => { + // Rotations about the three axes, then a camera placed and turned freely; + // the threshold must equal the margin rule worked out in world space. + const rot = (ax, ay, az) => { + const [cx, sx, cy, sy, cz, sz] = [ + Math.cos(ax), + Math.sin(ax), + Math.cos(ay), + Math.sin(ay), + Math.cos(az), + Math.sin(az), + ]; + // Rows of Rz * Ry * Rx. + return [ + [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx], + [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx], + [-sy, cy * sx, cy * cx], + ]; + }; + const R = 6_371_000; + let checked = 0; + for (let k = 0; k < 200; k++) { + // A deterministic spread of poses. + const a = (n) => Math.sin(k * 12.9898 + n * 78.233) * 3; + const m = rot(a(1), a(2), a(3)); // world-to-camera rotation + const lat = a(4) / 3; + const lon = a(5); + const contact = { + x: R * Math.cos(lat) * Math.cos(lon), + y: R * Math.cos(lat) * Math.sin(lon), + z: R * Math.sin(lat), + }; + // Camera 300 to 2300 m from the contact, in a direction above or below it. + const d = [a(6), a(7), a(8)]; + const dl = Math.hypot(...d) || 1; + const dist = 1300 + 1000 * Math.sin(k); + const cam = { + x: contact.x + (d[0] / dl) * dist, + y: contact.y + (d[1] / dl) * dist, + z: contact.z + (d[2] / dl) * dist, + }; + const t = m.map( + (row) => -(row[0] * cam.x + row[1] * cam.y + row[2] * cam.z), + ); + const view = [ + m[0][0], + m[1][0], + m[2][0], + 0, + m[0][1], + m[1][1], + m[2][1], + 0, + m[0][2], + m[1][2], + m[2][2], + 0, + t[0], + t[1], + t[2], + 1, + ]; + const out = { u: 0, v: 0, threshold: 0 }; + if (!projectContact(view, 2, 2, contact, out)) continue; + checked++; + const toCam = [cam.x - contact.x, cam.y - contact.y, cam.z - contact.z]; + const range = Math.hypot(...toCam); + const sinElevation = + (toCam[0] * contact.x + toCam[1] * contact.y + toCam[2] * contact.z) / + (range * R); + const viewDistance = -( + m[2][0] * contact.x + + m[2][1] * contact.y + + m[2][2] * contact.z + + t[2] + ); + const expected = + viewDistance * (1 - surfaceOcclusionMargin(range, sinElevation) / range); + assert.ok( + Math.abs(out.threshold - expected) < 1e-6, + `pose ${k}: ${out.threshold} vs ${expected}`, + ); + } + assert.ok(checked > 20, `${checked} poses in frame`); +}); + test('behind the camera or outside the frame there is nothing to test', () => { const out = { u: 7, v: 7, threshold: 7 }; assert.equal( @@ -616,3 +763,89 @@ test('prune forgets contacts that are no longer drawn; clear forgets everything' assert.equal(occlusion.contacts.size, 0); assert.equal(occlusion.testedCount, 0); }); + +test('a contact back in view after a while waits for a fresh answer, like a new one', () => { + const probe = manualProbe(); + probe.mode = 'async'; + const occlusion = new SurfaceOcclusion(probe); + let wall = 0; + const step = () => occlusion.beginFrame((wall += 16)); + step(); + occlusion.isHidden('traffic', 'a', anywhere); + probe.answer(false, occlusion.now); + step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false); + // Out of view for a moment shorter than a retest: drawn straight away. + for (let i = 0; i < 5; i++) step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), false); + // Out of view for longer (the camera swung away and back): the old + // "visible" may now be behind a building. Held back, and tested at once. + const started = probe.started; + for (let i = 0; i < 30; i++) step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + assert.equal(probe.started, started + 1); + probe.answer(true, occlusion.now); + step(); + assert.equal(occlusion.isHidden('traffic', 'a', anywhere), true); + + // It leaves with a test in flight, and comes back. That test's answer was + // about the previous view: when it lands, it must not end the hold-back. + const other = new SurfaceOcclusion(probe); + probe.answer(false, 0); + other.beginFrame((wall += 16)); + other.isHidden('traffic', 'b', anywhere); + assert.equal(probe.inFlight.length, 1, 'tested, unanswered'); + for (let i = 0; i < 30; i++) other.beginFrame((wall += 16)); + const before = probe.started; + assert.equal(other.isHidden('traffic', 'b', anywhere), true); + assert.equal(probe.started, before + 1, 'tested afresh at once'); + const [stale] = probe.inFlight.splice(0, 1); + stale.pending = false; + stale.answered = true; + other.beginFrame((wall += 16)); + assert.equal( + other.isHidden('traffic', 'b', anywhere), + true, + 'the old answer did not end the hold-back', + ); + probe.answer(false, other.now); + other.beginFrame((wall += 16)); + assert.equal(other.isHidden('traffic', 'b', anywhere), false); +}); + +test('at a stretched retest pace, new contacts are held back longer for their first answer', () => { + const probe = manualProbe(); + probe.mode = 'async'; + probe.maxPerFrame = 100; + const occlusion = new SurfaceOcclusion(probe); + occlusion.beginFrame(0); + for (let id = 0; id < 2000; id++) occlusion.isHidden('traffic', id, anywhere); + occlusion.beginFrame(33); + // 2000 contacts at 100 a frame and 33 ms frames: a retest every 1.3 s. + assert.ok(occlusion.retestMs > 1000, `${occlusion.retestMs}`); + assert.equal(occlusion.firstAnswerMs, occlusion.retestMs * 2); + assert.ok(occlusion.firstAnswerMs > SURFACE_FIRST_ANSWER_MS); + // A contact the probe has not reached yet is still held back past the + // usual limit, and drawn once the stretched one has run out. + probe.accepting = false; + let wall = 33; + const ages = []; + const firstSeen = occlusion.now; + occlusion.isHidden('traffic', 'late', anywhere); + while (occlusion.now - firstSeen < occlusion.firstAnswerMs + 100) { + occlusion.beginFrame((wall += 33)); + for (let id = 0; id < 2000; id++) + occlusion.isHidden('traffic', id, anywhere); + ages.push([ + occlusion.now - firstSeen, + occlusion.isHidden('traffic', 'late', anywhere), + ]); + } + const limit = occlusion.firstAnswerMs; + assert.ok( + ages.some(([age, hidden]) => age > SURFACE_FIRST_ANSWER_MS + 50 && hidden), + 'held past 400 ms', + ); + for (const [age, hidden] of ages) + if (age >= limit) assert.equal(hidden, false, `at ${age} ms`); +}); diff --git a/src/data/fixtures/fakeDepthScene.mjs b/src/data/fixtures/fakeDepthScene.mjs index 47e969717..fd337dcae 100644 --- a/src/data/fixtures/fakeDepthScene.mjs +++ b/src/data/fixtures/fakeDepthScene.mjs @@ -101,6 +101,7 @@ export function createFakeGl({ webgl2 = true } = {}) { lost: false, /** Whether a point that should draw does (false: a broken draw path). */ drawsLand: true, + /** Whether shaders compile: a boolean, or a function of the shader type. */ compiles: true, }; const counts = { @@ -111,12 +112,14 @@ export function createFakeGl({ webgl2 = true } = {}) { deleted: 0, }; const liveQueries = new Set(); + const liveShaders = new Set(); let activeQuery = null; const gl = { ...GL, state, counts, liveQueries, + liveShaders, isContextLost: () => state.lost, getParameter(name) { if (name === GL.READ_FRAMEBUFFER_BINDING) return state.read; @@ -186,11 +189,20 @@ export function createFakeGl({ webgl2 = true } = {}) { state.draw.image = texture.image; }, checkFramebufferStatus: () => GL.FRAMEBUFFER_COMPLETE, - createShader: () => ({}), + createShader(type) { + const shader = { type }; + liveShaders.add(shader); + return shader; + }, shaderSource() {}, compileShader() {}, - getShaderParameter: () => state.compiles, - deleteShader() {}, + getShaderParameter: (shader) => + typeof state.compiles === 'function' + ? state.compiles(shader.type) + : state.compiles, + deleteShader(shader) { + liveShaders.delete(shader); + }, createProgram: () => ({ uniforms: {} }), attachShader() {}, linkProgram() {}, diff --git a/src/data/sceneOcclusionProbe.js b/src/data/sceneOcclusionProbe.js index 070d5fe9f..772bc638e 100644 --- a/src/data/sceneOcclusionProbe.js +++ b/src/data/sceneOcclusionProbe.js @@ -79,7 +79,7 @@ export const SYNC_READ_INTERVAL_MS = 125; * plane pulled in by `scene.opaqueFrustumNearOffset` (public), exactly as * `scene.pickPosition` assumes when it decodes the same textures. * - `scene.frameState.useLogDepth`: whether depth is logarithmic (see - * `depthToViewDistance`). + * `depthToViewDistance`). Anything but a boolean there is "no depth". * * Any of these missing or shaped differently returns null, which callers treat * as "no depth". @@ -145,6 +145,10 @@ export function readSceneDepthSource(scene, viewScratch) { frustums.push({ near, far }); } if (!(width > 0) || !(height > 0)) return null; + // Read as standard depth, logarithmic depth puts every surface a few + // metres from the camera and would hide every contact: unknown is "no". + const logDepth = scene.frameState?.useLogDepth; + if (typeof logDepth !== 'boolean') return null; const tanHalfFovY = Math.tan(fovy / 2); return { @@ -155,7 +159,7 @@ export function readSceneDepthSource(scene, viewScratch) { framebuffers, textures, frustums, - logDepth: scene.frameState?.useLogDepth === true, + logDepth, view: Cesium.Matrix4.toArray( camera.viewMatrix, viewScratch || new Float64Array(16), @@ -262,6 +266,8 @@ export class SceneOcclusionProbe { this._now = 0; this._source = undefined; this._gl = null; + /** A context the probe's shader failed to build on; never retried. */ + this._unsupportedGl = null; this._resources = null; this._queryPool = []; this._batches = []; @@ -409,6 +415,7 @@ export class SceneOcclusionProbe { this._queryPool.length = 0; this._resources = null; this._gl = null; + this._unsupportedGl = null; } /** Tests that will never be answered stop counting as in flight. */ @@ -448,6 +455,11 @@ export class SceneOcclusionProbe { this._resources = null; return; } + if (source.gl === this._unsupportedGl) { + // The shader did not build on this context: nothing is tested here. + this.mode = 'off'; + return; + } if (source.webgl2 && canQuery(source.gl)) { if (!this._resources && !this._createResources(source.gl)) { this._fail(); @@ -476,7 +488,13 @@ export class SceneOcclusionProbe { _createResources(gl) { const vertex = compile(gl, gl.VERTEX_SHADER, PROBE_VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); - if (!vertex || !fragment) return false; + if (!vertex || !fragment) { + if (vertex) gl.deleteShader(vertex); + if (fragment) gl.deleteShader(fragment); + // The same source will not compile on this context next time either. + this._unsupportedGl = gl; + return false; + } const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); @@ -485,6 +503,7 @@ export class SceneOcclusionProbe { gl.deleteShader(fragment); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { gl.deleteProgram(program); + this._unsupportedGl = gl; return false; } const uniform = (name) => gl.getUniformLocation(program, name); @@ -691,10 +710,14 @@ export class SceneOcclusionProbe { } else { const contact = contacts[batch.cursor - 1]; contact.pending = false; - contact.answered = true; - // Without the control point's yes, a no means nothing. - if (!drew && batch.control && batch.at > contact.lastHiddenAt) - contact.lastHiddenAt = batch.at; + // Without the control point's yes the batch means nothing: not a + // "hidden", and not a first answer that ends a new contact's + // hold-back either. + if (batch.control) { + contact.answered = true; + if (!drew && batch.at > contact.lastHiddenAt) + contact.lastHiddenAt = batch.at; + } } this._queryPool.push(query); batch.cursor++; diff --git a/src/data/sceneOcclusionProbe.test.mjs b/src/data/sceneOcclusionProbe.test.mjs index d4ba75e1e..713546295 100644 --- a/src/data/sceneOcclusionProbe.test.mjs +++ b/src/data/sceneOcclusionProbe.test.mjs @@ -266,6 +266,11 @@ test('no depth source: nothing is tested and nothing is in flight', () => { scene.view.pickDepths[0].framebuffer = undefined; // not drawn yet return scene; })(), + // Whether depth is logarithmic, unknown: decoding it the wrong way would + // put every surface in front of every contact. + { ...wallScene(), frameState: {} }, + { ...wallScene(), frameState: { useLogDepth: 1 } }, + { ...wallScene(), frameState: undefined }, (() => { const scene = wallScene(); Object.defineProperty(scene, 'view', { @@ -329,6 +334,7 @@ test('a control point that does not draw voids its batch; a run of them stands t frame(probe, scene, now); assert.equal(entry.pending, false); assert.equal(entry.lastHiddenAt, Number.NEGATIVE_INFINITY, 'not believed'); + assert.notEqual(entry.answered, true, 'and not a first answer either'); now += 16; } assert.equal(probe.mode, 'off'); @@ -351,6 +357,7 @@ test('a control point that does not draw voids its batch; a run of them stands t gl.finishGpu(); frame(probe, scene, retryAt + 16); assert.equal(entry.lastHiddenAt, retryAt); + assert.equal(entry.answered, true); // One good batch in between resets the count. const healthy = new SceneOcclusionProbe(); @@ -366,16 +373,33 @@ test('a control point that does not draw voids its batch; a run of them stands t test('a shader that does not compile, or a WebGL call that throws, stands the probe down cleanly', () => { let scene = wallScene(); - scene.context._gl.state.compiles = false; + // Only the fragment shader fails: the vertex shader that did compile must + // not be left behind. + scene.context._gl.state.compiles = (type) => + type !== scene.context._gl.FRAGMENT_SHADER; let probe = new SceneOcclusionProbe(); let entry = contact(); assert.deepEqual(frame(probe, scene, 0, [[entry, behindWall]]), [false]); assert.equal(probe.mode, 'off'); assert.equal(entry.pending, false); + // Nothing half-made is kept, and the same context is never asked again: + // compiling is a wait on the GPU process and would fail the same way. + let gl = scene.context._gl; + assert.equal(gl.liveShaders.size, 0, 'no shader left behind'); + let shaderCalls = 0; + const createShader = gl.createShader; + gl.createShader = (...args) => { + shaderCalls++; + return createShader(...args); + }; + for (const now of [PROBE_RETRY_AFTER_MS + 16, 5 * PROBE_RETRY_AFTER_MS]) + assert.deepEqual(frame(probe, scene, now, [[entry, behindWall]]), [false]); + assert.equal(shaderCalls, 0); + assert.equal(probe.mode, 'off'); // A draw that throws part-way: bindings restored, no query left behind. scene = wallScene(); - let gl = scene.context._gl; + gl = scene.context._gl; const found = { draw: { image: null }, program: { uniforms: {} },