Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2107,6 +2107,16 @@ traced pixel that the fast mean's buffer had free:
locked lettering of a still sign comes back from a motion's blur by the blend alone (the 0.2 on Kabukichō's
signs); a lamp that comes on arrives over a quarter of a second.

**The measurements into the repo (2026-11-13, after the cleanup).** What the window's quality work of these weeks was
measured by lived in scratch scripts; now `tools/bench-window.ts` (the still frames, the Bistro slice, Tokyo at dusk,
Tokyo turning and orbiting as the play window's left drag does, each against offline), `tools/render-identity.ts`
(the byte gate for changes that must not change a picture) and an orbit move in `bench-ghosting` (reported, not yet
guarded). On the way: the moving lamps test (`upscale/lamps-in-motion-live`) never exercised the motion clamp. Its
lamps float before the black sky, a sliding camera does not move the sky, and a lamp no sample hit takes the
velocity of what is behind it: the upscaler saw no motion. Before a black wall 1 cm behind, the lamps keep 0.045 of
their 0.1 in motion as merged (no clamp at all: 0.105; before the lock: 0.014). The test changes with the fix of its
cause (a missed lamp reprojected by its own depth), so that main does not carry a failing test meanwhile.

**Cleanup: experiments and switches that outlived their measurement (2026-11-13, after it).** Removed, each off by
default, so nothing a scene shows changes:

Expand Down
11 changes: 9 additions & 2 deletions docs/WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,17 @@ highest tier any of its changes needs.
|---|---|---|
| **D** docs | only `docs/`, READMEs, comments | CI |
| **T** tools / TypeScript | `tools/`, `tests/` harness, `ue5/` scripts, `packages/*` without effect on what the renderer draws | `npm run typecheck`, `npm test`; `npm run test:render` if `packages/scene`, `packages/gameplay` or `tests/render` changed |
| **R1** renderer, existing scenes unchanged | new kernel / live code behind a feature bit, new DSL features, new passes that only run when a scene uses them | T, plus `cargo test --release`, `test:render` (no new failures against main), byte-identity of the catalog scenes (offline, software traversal), **perf smoke**, crops of the new feature |
| **R2** renderer, existing scenes change | anything that changes what existing scenes render, or the live passes they run (denoising, temporal, reflections, post) | R1 without byte-identity, plus `node tools/bench-ghosting.ts --quick --check` (Bistro) and `--scene kabukicho` (wet signs: the reflection stress case), `test:layer-b`, crops before / after |
| **R1** renderer, existing scenes unchanged | new kernel / live code behind a feature bit, new DSL features, new passes that only run when a scene uses them | T, plus `cargo test --release`, `test:render` (no new failures against main), byte-identity of the catalog scenes (offline, software traversal) and of every `test:render` output (`node tools/render-identity.ts --a <main> --b <branch>`: live tests included), **perf smoke**, crops of the new feature |
| **R2** renderer, existing scenes change | anything that changes what existing scenes render, or the live passes they run (denoising, temporal, reflections, post) | R1 without byte-identity, plus `node tools/bench-ghosting.ts --quick --check` (Bistro) and `--scene kabukicho` (wet signs: the reflection stress case), `test:layer-b`, crops before / after; a change to the live passes also `node tools/bench-window.ts` on main and the branch (the play window against offline: still, the Bistro slice, Tokyo at dusk and in motion) |
| **R3** hot path or perf claim | the PR claims a speed-up, or changes the cost of work existing scenes do every frame (trace loop, light sampling, filter passes, resolution control) | R2, plus **full A/B timing** (Bistro night still and moving and the 3 × 3 Bistro district along its camera path at the 0.5 floor, main and branch alternated: `node tools/bench-district.ts`) |

- **render-identity** (R1, and any change that says it changes no picture): test-render whole, twice on each build,
every HDR output compared byte for byte. A test one build does not repeat to the byte (asynchronous builds,
physics, the radiance cache's training order: about 18 of 190) passes when the builds differ by no more than twice
their own run-to-run spread. `--env THREERT_MERGED=1` runs the browser's binding layout natively.
- **bench-window** (R2 for the live passes): the shots the window's quality work of 2026-11 was measured by, each
against an offline render of the same moment (cached in `out/cache/window-truth`), or for the dusk against the
same moment held. The Tokyo shots need the city (`tools/tokyo`); without it they are reported as not run.
- **Perf smoke** (R1, R2):
- Bistro night (`scenes/bistro.ts`) live at the fixed 0.5 floor, after warm-up: about 15 s still at its camera
and 15 s on a scripted move (2.5 m down the street with 12° of yaw, ping-pong), GPU timestamps. Until 2026-11-08
Expand Down
25 changes: 20 additions & 5 deletions tools/bench-ghosting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
// history reprojects exactly, so what is left is the history's own blur, lag or doubling;
// strafe: 2.5 m to the left (3 m to the right) in 0.4 s, hold, back (parallax, disocclusion, reflections moving
// against their surface);
// stop: 8 m/s forward for 0.5 s, then still (what the history drags into the first frames after a sudden stop).
// stop: 8 m/s forward for 0.5 s, then still (what the history drags into the first frames after a sudden stop);
// orbit: the play window's left drag (view.rs `Orbit`: about a point ahead of the camera as far as the camera is
// from the origin, held to 1-50 m), 27.5 deg in 0.4 s, hold, back: rotation and translation at once, a
// degree a frame, where a feature finer than a pixel that the samples miss reprojects with what is behind
// it (a second copy of a lamp, 2026-11-13). Reported, not yet guarded.
// Shots: each move's midpoint going out (vs the converged midpoint); the start pose 0.1 / 0.5 / 2 s after returning (counted in frames at 60 fps, not wall-clock time);
// the stop pose 0 / 0.1 / 0.3 s after stopping. Per region (the wet street, where reflections are; the sign band;
// the facades): mean dE, p95, p99, trail_px (p95 over rows of the longest horizontal run of pixels off by dE > 10) and
Expand Down Expand Up @@ -144,6 +148,17 @@ function pose(yaw: number, side = 0, fwd = 0) {
return { pos, target: pos.map((p, i) => p + dir[i] * 10) as V3, tan: view.tan };
}
const turn = (f: number) => pose(view.turn * f);
/** The play window's orbit (view.rs `Orbit::from_camera`): about the point `dist` along the start pose's view, `dist`
* the start position's distance from the origin held to 1..50 m, `ORBIT_DEG` degrees at f = 1. */
const ORBIT_DEG = 27.5;
function orbit(f: number) {
const p = pose(0);
const dir = p.target.map((t, i) => (t - p.pos[i]) / 10);
const n = Math.hypot(...dir), dist = Math.min(Math.max(Math.hypot(...view.p0), 1), 50);
const c = p.pos.map((x, i) => x + (dir[i] / n) * dist) as V3;
const a = (ORBIT_DEG * f * Math.PI) / 180, ox = p.pos[0] - c[0], oz = p.pos[2] - c[2];
return { pos: [c[0] + ox * Math.cos(a) + oz * Math.sin(a), p.pos[1], c[2] - ox * Math.sin(a) + oz * Math.cos(a)] as V3, target: c, tan: view.tan };
}
const strafe = (f: number) => pose(0, view.strafe * f);
const dolly = (f: number) => pose(0, 0, 4 * f);

Expand Down Expand Up @@ -190,7 +205,7 @@ async function start(s: string) {

// References: converged frames (history reset, then accumulated) at every pose a shot is taken at.
const refFile = (n: string) => join(refs, `${n}.png`);
const refPoses: Record<string, ReturnType<typeof pose>> = { start: pose(0), "turn-mid": turn(0.5), "strafe-mid": strafe(0.5), stop: dolly(1) };
const refPoses: Record<string, ReturnType<typeof pose>> = { start: pose(0), "turn-mid": turn(0.5), "strafe-mid": strafe(0.5), "orbit-mid": orbit(0.5), stop: dolly(1) };
if (!Object.keys(refPoses).every((n) => existsSync(refFile(n)))) {
console.log(`rendering converged references (${refFrames} frames each at ${refScale})…`);
const live = await start(refScale);
Expand Down Expand Up @@ -227,7 +242,7 @@ async function sweep(at: (f: number) => ReturnType<typeof pose>, f0: number, f1:
for (rep = 0; rep < repeats; rep++) {
if (rep === 0) await shot("settled", "start", 240, pose(0));
else await live.step(240, pose(0));
for (const [name, at, n] of [["turn", turn, 30], ["strafe", strafe, 24]] as const) {
for (const [name, at, n] of [["turn", turn, 30], ["strafe", strafe, 24], ["orbit", orbit, 24]] as const) {
await sweep(at, 0, 1, n, `${name}-mid`);
await live.frames(30);
await sweep(at, 1, 0, n);
Expand Down Expand Up @@ -299,11 +314,11 @@ const tag = (sceneName === "kabukicho" ? "" : `${sceneName}-`) + (quick ? "ghost
summary(`${tag} turn-mid ground excess`, value["turn-mid/ground"].mean, { unit: " dE" });
summary(`${tag} turn-mid ground trail`, value["turn-mid/ground"].trail, { unit: " px", digits: 0 });
summary(`${tag} strafe-return+0.5s excess`, value["strafe-return+0.5s/all"].mean, { unit: " dE" });
for (const m of ["turn", "strafe"]) {
for (const m of ["turn", "strafe", "orbit"]) {
execFileSync(bin, ["montage", join(out, `${m}.jpg`), "--width", "640", join(out, `${m}-mid.png`), refFile(`${m}-mid`), join(out, `${m}-return+0.1s.png`), refFile("start")]);
}
execFileSync(bin, ["montage", join(out, "stop.jpg"), "--width", "640", join(out, "stop+0.png"), join(out, "stop+0.1s.png"), refFile("stop")]);
console.log(`${out}/{turn,strafe,stop}.jpg (shot | converged)`);
console.log(`${out}/{turn,strafe,orbit,stop}.jpg (shot | converged)`);
if (args.includes("--check") && failed) {
console.error(`ghosting: ${failed} guarded value(s) over the limit`);
process.exit(1);
Expand Down
Loading