diff --git a/packages/sdk-browser/src/lighting/direct/shadowFactorWgsl.ts b/packages/sdk-browser/src/lighting/direct/shadowFactorWgsl.ts index 7b0cd666ea..9c03819740 100644 --- a/packages/sdk-browser/src/lighting/direct/shadowFactorWgsl.ts +++ b/packages/sdk-browser/src/lighting/direct/shadowFactorWgsl.ts @@ -25,7 +25,8 @@ export const SHADOW_DEPTH_ROUNDING = 2 * 2 ** -23; * as the texture streamer falls back to a coarser tile. The scheduler keeps the last level under * every page a receiver reads mapped and drawn in the frame (`admit.ts`), so the far-shadow ray * of a sun and the unshadowed answer of a lamp past their last level only answer before a light's - * first request report. + * first request report — and, for a sun, past the scene's box, where no caster lies and the floor + * is asked only by the report (`sunLevels.ts` floorReach). * * The receiver's plane must not shade itself over the PCF's reach: its depth's slope across the * map is covered, in texels of the level read, by a depth margin up to a slope of 1 diff --git a/packages/sdk-browser/src/visibility/shader/spriteShadowCut.test.ts b/packages/sdk-browser/src/visibility/shader/spriteShadowCut.test.ts index e000026bec..ecf96daa5c 100644 --- a/packages/sdk-browser/src/visibility/shader/spriteShadowCut.test.ts +++ b/packages/sdk-browser/src/visibility/shader/spriteShadowCut.test.ts @@ -73,3 +73,14 @@ test('the shadow scene box leaves every sprite root out', () => { }); assert.deepEqual([...min, ...max], [-1, -1, -1, 1, 1, 1]); }); + +test('the shadow scene box follows a pose the engine moved, with no table change', () => { + const sceneBox = createShadowSceneBox(), + worldBox = Float64Array.of(-1, -1, -1, 1, 1, 1), + layout = { selectionRoots: [{ worldBox }], rows: { tableEpoch: 0 } }; + sceneBox(layout, 0); + // A placement or engine pose moves the root's box and bumps the scene revision alone. + worldBox.set([9, -1, -1, 11, 1, 1]); + const { min, max } = sceneBox(layout, 1); + assert.deepEqual([...min, ...max], [9, -1, -1, 11, 1, 1]); +}); diff --git a/packages/sdk-browser/src/webgpu/pages/render/encodeShadows.ts b/packages/sdk-browser/src/webgpu/pages/render/encodeShadows.ts index 73c04507a9..e65fc50d34 100644 --- a/packages/sdk-browser/src/webgpu/pages/render/encodeShadows.ts +++ b/packages/sdk-browser/src/webgpu/pages/render/encodeShadows.ts @@ -89,7 +89,7 @@ export function planShadowRegions( // The light cuts measure their error at the camera's threshold. lights.shadowPixelError = followLightThreshold(lights, rt.run.gate.pixelError); const view = shadowViewpointOf(cam, rt.gpu.targetSize[1]); - const box = lights.sceneBox(rt.layout); + const box = lights.sceneBox(rt.layout, rt.run.gate.revisions.scene); ensureStaticLayer(rt); redrawShortPages(rt, frame, nowMs, residencyMoved); const count = plan.plan(store, view, box.min, box.max, frame, nowMs); diff --git a/packages/sdk-browser/src/webgpu/pages/state/lights.ts b/packages/sdk-browser/src/webgpu/pages/state/lights.ts index 146cf9f33a..f99bc954e9 100644 --- a/packages/sdk-browser/src/webgpu/pages/state/lights.ts +++ b/packages/sdk-browser/src/webgpu/pages/state/lights.ts @@ -50,7 +50,7 @@ export interface WebgpuLightState { /** The static layer's page pyramids and the test of the moving casters against them. */ pageHiz: ShadowPageHiz | undefined; occlusion: ShadowOcclusion | undefined; - /** The scene's world box, what a sun's depth range spans (`../../shadow/sceneBox.ts`). */ + /** The scene's world box, what a sun's depth range and floor span (`../../shadow/sceneBox.ts`). */ sceneBox: ReturnType; /** Per-page cull and the world spheres it reads; absent while the pool does not exist. */ cull: GpuShadowCull | undefined; diff --git a/packages/sdk-browser/src/webgpu/shadow/sceneBox.ts b/packages/sdk-browser/src/webgpu/shadow/sceneBox.ts index 5c650c9498..5950460862 100644 --- a/packages/sdk-browser/src/webgpu/shadow/sceneBox.ts +++ b/packages/sdk-browser/src/webgpu/shadow/sceneBox.ts @@ -7,10 +7,13 @@ interface SceneRoots { } /** - * The world box of every opaque primitive the scene draws: what a sun's clipmap spans along its - * axis, so every caster lies inside its depth range. A sprite root (`ClusterRoot.sprite`) is left - * out: a sprite casts no shadow, and its box would only spread the range. Rebuilt only when a pose moved or the scene - * changed — the row-table epoch and the root list say so —, from boxes the engine already holds. + * The world box of every primitive the scene draws: what a sun's clipmap spans along + * its axis, so every caster lies inside its depth range, and the rectangle its floor pages cover + * on its plane (`sunLevels.ts` floorReach). A sprite root (`ClusterRoot.sprite`) is left out: a + * sprite casts no shadow, and its box would only spread the range. Rebuilt only when a pose moved + * or the scene changed — the scene revision, the row-table epoch and the root list say so: an + * engine pose or placement move bumps the scene revision alone —, from boxes the engine already + * holds. */ export function createShadowSceneBox() { const box = new Float64Array(6), @@ -18,11 +21,13 @@ export function createShadowSceneBox() { max = box.subarray(3, 6), read = { min, max }; let epoch = -1, + revision = -1, roots: unknown = undefined; - return (layout: SceneRoots) => { + return (layout: SceneRoots, sceneRevision = 0) => { const { selectionRoots, rows } = layout; - if (rows.tableEpoch !== epoch || selectionRoots !== roots) { + if (rows.tableEpoch !== epoch || sceneRevision !== revision || selectionRoots !== roots) { epoch = rows.tableEpoch; + revision = sceneRevision; roots = selectionRoots; boxEmpty(box, 0); for (const root of selectionRoots) diff --git a/packages/sdk-core/src/scene/light-shadow/dollyRefetch.test.ts b/packages/sdk-core/src/scene/light-shadow/dollyRefetch.test.ts new file mode 100644 index 0000000000..f19444745a --- /dev/null +++ b/packages/sdk-core/src/scene/light-shadow/dollyRefetch.test.ts @@ -0,0 +1,61 @@ +// #525: a camera over a small scene, with a far distance much wider than the scene, rocks back and +// forth over the pages it reads. What a frame reads fits the pool many times over, so no page may be +// evicted and asked for again. The sun's floor pages were every last-level page within the view's +// far distance, receivers or not: over empty ground they pinned most of the pool, and the pages the +// camera came back to had been evicted meanwhile. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSceneLightStore } from '../light/store.ts'; +import { createShadowPlan } from './plan.ts'; +import { SUN, VIEW, report, sunPages } from './lightShadow.fixture.ts'; +import { PAGE_VALID, sunEntry, sunFloorLevel, sunPageMetres } from './virtual.ts'; + +/** A scene twenty metres wide, under a view that sees two hundred metres around. */ +const BOX_MIN = [-10, 0, -10], + BOX_MAX = [10, 5, 10]; +const near = 0.001; +const viewAt = (x: number) => ({ + ...VIEW, + position: [x, 5, 0] as [number, number, number], + near, + far: 120, + pixelNear: (near * 2 * Math.tan(VIEW.halfFovY)) / 720, +}); + +test('a dolly over a small scene whose reads fit the pool evicts nothing it reads again', () => { + const store = createSceneLightStore(); + const plan = createShadowPlan(32); + store.add(SUN); + for (let frame = 0; frame < 60; frame++) { + // The camera rocks over two metres, and reads a block of fine pages under it. + const x = Math.abs((frame % 20) - 10) * 0.2, + view = viewAt(x); + plan.plan(store, view, BOX_MIN, BOX_MAX, frame, frame * 16); + plan.commit(); + const slice = store.sliceOf(0), + level = plan.sun.finest[slice] + 9, + // The sun stands overhead: a page's light-plane column is `-x` over its metres. + first = Math.floor(-x / sunPageMetres(level)) - 6, + block: number[][] = []; + for (let ay = -6; ay < 6; ay++) + for (let ax = first; ax < first + 12; ax++) block.push([ax, ay]); + report(plan, store, frame, sunPages(plan, slice, level, block)); + } + const floor = sunFloorLevel(plan.sun.finest[store.sliceOf(0)]); + let floors = 0; + for (let page = 0; page < plan.pool.pages; page++) + if (plan.pool.owner[page] >= 0 && plan.pool.view[page] === floor) floors++; + // The box's twenty metres in the floor's four-metre pages, one page around: 8 × 8, where the + // view's reach held 880. + assert.ok(floors <= 64, `the floor holds the scene's pages, not the view's reach: ${floors}`); + // No receiver loses its floor: the box is symmetric, so its pages are the same whichever way the + // plane's axes point. + const page = sunPageMetres(floor), + slice = store.sliceOf(0); + for (let ay = Math.floor(-10 / page); ay <= Math.floor(10 / page); ay++) + for (let ax = Math.floor(-10 / page); ax <= Math.floor(10 / page); ax++) { + const entry = plan.table.baseOf(slice) + sunEntry(floor, ax, ay); + assert.ok(plan.table.words[entry] & PAGE_VALID, `floor page ${ax},${ay} over the box`); + } + assert.equal(plan.pool.refetched, 0, 'no page evicted and asked for again'); +}); diff --git a/packages/sdk-core/src/scene/light-shadow/invalidate.ts b/packages/sdk-core/src/scene/light-shadow/invalidate.ts index 9752e5dd2f..904b3a2e09 100644 --- a/packages/sdk-core/src/scene/light-shadow/invalidate.ts +++ b/packages/sdk-core/src/scene/light-shadow/invalidate.ts @@ -1,6 +1,7 @@ import { LIGHT_KIND, type SceneLight } from '../light/contracts.ts'; import type { createShadowChanges } from './changes.ts'; import { writeFace } from './faces.ts'; +import { sunBoxRect } from './math.ts'; import type { ShadowPool } from './pool.ts'; import type { ShadowTable } from './table.ts'; import type { SunLevels } from './sunLevels.ts'; @@ -55,33 +56,14 @@ function lampPageMeets(face: number, mip: number, x: number, y: number) { ); } -/** Writes the light-plane rectangle of a world box under the sun of `slice` into `rects[0..4)`. */ -function sunRect(sun: SunLevels, slice: number, min: ArrayLike, max: ArrayLike) { - const f = slice * 9, - frame = sun.frame; - rects[0] = rects[2] = Infinity; - rects[1] = rects[3] = -Infinity; - for (let corner = 0; corner < 8; corner++) { - const x = corner & 1 ? max[0] : min[0], - y = corner & 2 ? max[1] : min[1], - z = corner & 4 ? max[2] : min[2]; - const u = frame[f] * x + frame[f + 1] * y + frame[f + 2] * z, - v = frame[f + 3] * x + frame[f + 4] * y + frame[f + 5] * z; - rects[0] = Math.min(rects[0], u); - rects[1] = Math.max(rects[1], u); - rects[2] = Math.min(rects[2], v); - rects[3] = Math.max(rects[3], v); - } -} - /** True when sun page `(level, ax, ay)` — rows down the `up` axis — meets `rects[0..4)`. */ function sunPageMeets(level: number, ax: number, ay: number) { const page = sunPageMetres(level); return ( rects[1] >= ax * page && rects[0] <= (ax + 1) * page && - -rects[2] >= ay * page && - -rects[3] <= (ay + 1) * page + rects[3] >= ay * page && + rects[2] <= (ay + 1) * page ); } @@ -129,7 +111,7 @@ export function invalidateLightPages( const read = whole ? undefined : changes.read(box), moved = byPage ? read : undefined, wrong = !read || (!read.detail && !read.moving); - if (moved && isSun) sunRect(sun, slice, moved.min, moved.max); + if (moved && isSun) sunBoxRect(sun.frame, slice * 9, moved.min, moved.max, rects, 0); if (moved && !isSun) for (let face = 0; face < faces; face++) faceRect(face, moved.min, moved.max); for (let page = 0; page < pool.pages; page++) { diff --git a/packages/sdk-core/src/scene/light-shadow/math.ts b/packages/sdk-core/src/scene/light-shadow/math.ts index 78e2d69999..254acf9dd6 100644 --- a/packages/sdk-core/src/scene/light-shadow/math.ts +++ b/packages/sdk-core/src/scene/light-shadow/math.ts @@ -154,3 +154,31 @@ export function composeFace( multiplyMatrix4(faceScratch, faceProjection, faceView); copyMatrix4(out, faceScratch, base); } + +/** + * The rectangle a world box covers on a sun's light plane, `u0, u1, v0, v1` in metres from `out[o]`: + * `u` along `right` (`frame[f..f+3)`), `v` DOWN `up` (`frame[f+3..f+6)`), as sun pages count their + * rows (`sunLevels.ts`). + */ +export function sunBoxRect( + frame: ArrayLike, + f: number, + min: ArrayLike, + max: ArrayLike, + out: Float64Array, + o: number, +) { + out[o] = out[o + 2] = Infinity; + out[o + 1] = out[o + 3] = -Infinity; + for (let corner = 0; corner < 8; corner++) { + const x = corner & 1 ? max[0] : min[0], + y = corner & 2 ? max[1] : min[1], + z = corner & 4 ? max[2] : min[2]; + const u = frame[f] * x + frame[f + 1] * y + frame[f + 2] * z, + v = -(frame[f + 3] * x + frame[f + 4] * y + frame[f + 5] * z); + out[o] = Math.min(out[o], u); + out[o + 1] = Math.max(out[o + 1], u); + out[o + 2] = Math.min(out[o + 2], v); + out[o + 3] = Math.max(out[o + 3], v); + } +} diff --git a/packages/sdk-core/src/scene/light-shadow/requests.ts b/packages/sdk-core/src/scene/light-shadow/requests.ts index 77b0266f85..79ababd7b2 100644 --- a/packages/sdk-core/src/scene/light-shadow/requests.ts +++ b/packages/sdk-core/src/scene/light-shadow/requests.ts @@ -46,7 +46,8 @@ const CAP: number = LIGHT_SETTINGS.shadowRequestCap; * never evicted while anything above it is read; like every page named, it is drawn in the frame it * goes stale (`admit.ts`). The * floor covers all the light reaches, so it needs no report to know what the view will read: a - * sun asks every frame for the floor pages its view reaches (`floors`), and a new, moved or + * sun asks every frame for the floor pages its view reaches over the scene's box (`floors`) — past + * it no caster lies, and a receiver there asks through the report —, and a new, moved or * reshaped lamp for each face's until a report written at its pose is read — a report from a past * pose names only the pages that pose's receivers read. * @@ -159,9 +160,10 @@ export function createShadowRequests( needs.allocate(reportFrame, nowMs, frame, counts); }, /** Asks, as if the latest report named them, for the floor pages a reader may need that no - * report names yet: every sun's within the view's far distance (`sun.floorReach`), whatever - * moved, and each face's of a lamp posed after that report — new, moved or reshaped: what it - * named was read at a past pose. Evicts only what it did not name; the next may evict it. */ + * report names yet: every sun's over the scene within the view's far distance + * (`sun.floorReach`), whatever moved, and each face's of a lamp posed after that report — new, + * moved or reshaped: what it named was read at a past pose. Evicts only what it did not name; + * the next may evict it. */ floors(posed: ArrayLike, view: ShadowViewpoint, nowMs: number, frame: number) { reportFrame = counts.latest; for (let slice = 0; slice < posed.length; slice++) { diff --git a/packages/sdk-core/src/scene/light-shadow/sunLevels.test.ts b/packages/sdk-core/src/scene/light-shadow/sunLevels.test.ts index 5e7717c2a7..3b386a2056 100644 --- a/packages/sdk-core/src/scene/light-shadow/sunLevels.test.ts +++ b/packages/sdk-core/src/scene/light-shadow/sunLevels.test.ts @@ -60,3 +60,31 @@ test('a step smaller than a page moves no extent; a step of one finest page move assert.equal(movedNow()[0], true); assert.equal(movedNow()[15], false, 'the coarsest extent holds'); }); + +test('a box that bounds nothing leaves the floor the whole view reach', () => { + const sun = createSunLevels(), + bounded = new Int32Array(4), + reach = new Int32Array(4); + // No box yet, a plane unbounded along x, a box unbounded along the axis: none bounds the floor. + for (const [min, max] of [ + [ + [Infinity, Infinity, Infinity], + [-Infinity, -Infinity, -Infinity], + ], + [ + [-Infinity, 0, -10], + [Infinity, 0, 10], + ], + // Unbounded down the axis only: the near end is finite, the far one is not. + [ + [-10, -Infinity, -10], + [10, 0, 10], + ], + ]) { + sun.update(0, AXIS, at(0), min, max, 1); + sun.floorReach(0, at(0), reach); + sun.update(0, AXIS, at(0), [-1e9, -1, -1e9], [1e9, 1, 1e9], 1); + sun.floorReach(0, at(0), bounded); + assert.deepEqual(Array.from(reach), Array.from(bounded), `box ${min} … ${max}`); + } +}); diff --git a/packages/sdk-core/src/scene/light-shadow/sunLevels.ts b/packages/sdk-core/src/scene/light-shadow/sunLevels.ts index 36828e4b50..1c17639dac 100644 --- a/packages/sdk-core/src/scene/light-shadow/sunLevels.ts +++ b/packages/sdk-core/src/scene/light-shadow/sunLevels.ts @@ -1,6 +1,6 @@ import { dotVector3 } from '../../math/primitives/vector.ts'; import { MAX_SHADOW_SLICES, type ShadowViewpoint } from '../light/contracts.ts'; -import { faceFrame } from './math.ts'; +import { faceFrame, sunBoxRect } from './math.ts'; import { SUN_LEVELS, SUN_LEVEL_ENTRIES, @@ -14,6 +14,8 @@ import { /** Frames of layout kept to read a request report back: deeper than any readback lag. */ const HISTORY = 8; const LEVEL_WORDS = SUN_LEVELS * 2; +/** The light-plane rectangle of no box: it bounds nothing. */ +const UNBOUNDED = [-Infinity, Infinity, -Infinity, Infinity]; /** * THE CLIPMAP OF EACH SUN: its light-plane frame, the depth range its maps span, its finest @@ -32,6 +34,8 @@ const LEVEL_WORDS = SUN_LEVELS * 2; export function createSunLevels() { const frame = new Float64Array(MAX_SHADOW_SLICES * 9), depth = new Float64Array(MAX_SHADOW_SLICES * 2), + /** The scene box's rectangle on the light plane, `u0, u1, v0, v1` in metres (`sunBoxRect`). */ + boxRect = new Float64Array(MAX_SHADOW_SLICES * 4), finest = new Int32Array(MAX_SHADOW_SLICES), origins = new Int32Array(MAX_SHADOW_SLICES * LEVEL_WORDS), /** Clipmap slots whose extent moved at the last update, one bit per slot. */ @@ -83,7 +87,10 @@ export function createSunLevels() { low = Math.min(low, z); high = Math.max(high, z); } - if (Number.isFinite(low) && Number.isFinite(high)) { + // An empty or unbounded box bounds neither the depth range nor the floor. + if (!Number.isFinite(low) || !Number.isFinite(high)) boxRect.set(UNBOUNDED, slice * 4); + else { + sunBoxRect(frame, f, boxMin, boxMax, boxRect, slice * 4); const grid = 2 ** Math.ceil(Math.log2(Math.max(high - low, 1e-6))); const zNear = Math.floor(low / grid) * grid, zFar = Math.max(zNear + grid, Math.ceil(high / grid) * grid); @@ -127,9 +134,14 @@ export function createSunLevels() { ay - origins[at + 1] < SUN_WINDOW ); }, - /** The floor pages the view can read, whatever it looks at — every page of the last level - * within its far distance of the camera, in this frame's clipmap —: `x0, y0, x1, y1` into - * `out`, inclusive. */ + /** + * The floor pages the view can read, whatever it looks at — every last-level page within its + * far distance and over the scene's box, one page around it for the normal offset and the PCF, + * in this frame's clipmap —: `x0, y0, x1, y1` into `out`, inclusive. No caster lies past the + * box: a receiver there is lit, and its floor pages held nothing but half the pool over a small + * scene (#525). A sprite, left out of the box, reads the far-shadow ray there until the page it + * asks for is drawn: lit too. + */ floorReach(slice: number, view: ShadowViewpoint, out: Int32Array) { const level = sunFloorLevel(finest[slice]), page = sunPageMetres(level), @@ -137,10 +149,16 @@ export function createSunLevels() { at = slice * LEVEL_WORDS + ringOf(level, SUN_LEVELS) * 2; const u = dotVector3(view.position, frame, 0, f), v = -dotVector3(view.position, frame, 0, f + 3); - out[0] = Math.max(origins[at], Math.floor((u - view.far) / page)); - out[1] = Math.max(origins[at + 1], Math.floor((v - view.far) / page)); - out[2] = Math.min(origins[at] + SUN_WINDOW - 1, Math.floor((u + view.far) / page)); - out[3] = Math.min(origins[at + 1] + SUN_WINDOW - 1, Math.floor((v + view.far) / page)); + for (let k = 0; k < 2; k++) { + const c = k ? v : u, + o = origins[at + k], + e = slice * 4 + 2 * k; + out[k] = Math.max(o, Math.floor(Math.max(c - view.far, boxRect[e] - page) / page)); + out[k + 2] = Math.min( + o + SUN_WINDOW - 1, + Math.floor(Math.min(c + view.far, boxRect[e + 1] + page) / page), + ); + } return out; }, /**