Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5cf5e0f
fix(shadows): a light cut asks for each caster once a frame, whatever…
pasquelin Sep 25, 2026
d448385
fix(shadows): a batch that dropped work keeps its view limit until th…
pasquelin Sep 25, 2026
57bdea4
Merge remote-tracking branch 'origin/develop' into 525-shadow-pixel-cost
pasquelin Sep 25, 2026
f1cf1fe
refactor(shadows): a light cut keeps each page's best request of the …
pasquelin Sep 25, 2026
1732759
fix(shadows): page zero at priority zero is listed once too; the best…
pasquelin Sep 25, 2026
3e60674
refactor(shadows): the best-request pass reads the page with the WGSL…
pasquelin Sep 25, 2026
0e1fe42
fix(shadows): a light cut's asked words carry the frame's stamp, clea…
pasquelin Sep 25, 2026
ffac647
test(shadows): the largest stamp fills the asked word; the layout say…
pasquelin Sep 25, 2026
df3d250
test(shadows): the fixture reads the stamp write of the work buffer i…
pasquelin Sep 25, 2026
6f16bdd
fix(shadows): the stamp's wrap clear is submitted before the stamp, s…
pasquelin Sep 25, 2026
bb25ff4
Merge remote-tracking branch 'origin/develop' into 525-shadow-pixel-cost
pasquelin Sep 25, 2026
59c8ea0
Merge remote-tracking branch 'origin/develop' into 525-shadow-pixel-cost
pasquelin Sep 26, 2026
ab5d188
fix(shadows): a sun's floor holds the pages over the scene, not the v…
pasquelin Sep 26, 2026
0101f64
refactor(shadows): one light-plane rectangle for the floor's reach an…
pasquelin Sep 26, 2026
7da2202
test(shadows): the floor holds every page over the box, and no box bo…
pasquelin Sep 26, 2026
381e421
fix(shadows): the scene box follows engine pose moves, and a box unbo…
pasquelin Sep 26, 2026
2e66ba6
Merge remote-tracking branch 'origin/develop' into 525-shadow-pixel-cost
pasquelin Sep 26, 2026
9049717
Merge branch 'develop' into 525-shadow-pixel-cost
pasquelin Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/sdk-browser/src/lighting/direct/shadowFactorWgsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk-browser/src/visibility/shader/spriteShadowCut.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-browser/src/webgpu/pages/state/lights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createShadowSceneBox>;
/** Per-page cull and the world spheres it reads; absent while the pool does not exist. */
cull: GpuShadowCull | undefined;
Expand Down
17 changes: 11 additions & 6 deletions packages/sdk-browser/src/webgpu/shadow/sceneBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,27 @@ 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),
min = box.subarray(0, 3),
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)
Expand Down
61 changes: 61 additions & 0 deletions packages/sdk-core/src/scene/light-shadow/dollyRefetch.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
26 changes: 4 additions & 22 deletions packages/sdk-core/src/scene/light-shadow/invalidate.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<number>, max: ArrayLike<number>) {
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
);
}

Expand Down Expand Up @@ -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++) {
Expand Down
28 changes: 28 additions & 0 deletions packages/sdk-core/src/scene/light-shadow/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>,
f: number,
min: ArrayLike<number>,
max: ArrayLike<number>,
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);
}
}
10 changes: 6 additions & 4 deletions packages/sdk-core/src/scene/light-shadow/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<number>, view: ShadowViewpoint, nowMs: number, frame: number) {
reportFrame = counts.latest;
for (let slice = 0; slice < posed.length; slice++) {
Expand Down
28 changes: 28 additions & 0 deletions packages/sdk-core/src/scene/light-shadow/sunLevels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
});
36 changes: 27 additions & 9 deletions packages/sdk-core/src/scene/light-shadow/sunLevels.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -127,20 +134,31 @@ 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),
f = slice * 9,
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;
},
/**
Expand Down
Loading