Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2337,6 +2337,12 @@ branch: main's offline chooses among the 768 lamps uniformly and is noisy at 409
dE 0.90 -> 0.71.) Scenes without more than 8 shadow-casting local lights compile and run as before (Bistro,
Kabukichō: picture and cost unchanged).

**The lists follow the camera (2026-11-15, after it).** A camera hit read the list at its own pixel: with the camera
turning, another part of the scene's. Now it reads the list of the tile it was seen in when the lists were built
(its point, moved back by its motion, through that frame's camera). bench-window `rooms-turn` (lamp-rooms-wide, the
camera turning 2 degrees a frame), the last moving frame, dE / p99: 2.00 / 8.57 without lists, 0.86 / 4.82 with
them at the pixel, 0.80 / 3.54 now; 0.19 ms of a list scene's trace (3.57 -> 3.76 on lamp-rooms).

## 2. Temporal fixes

Targets (2026-11-08): bench-temporal, bench-ghosting and their UE MRQ references move to Bistro night (§ 0 step 7); the
Expand Down
7 changes: 4 additions & 3 deletions docs/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -1287,9 +1287,10 @@ frame found (pathtrace.wgsl "Light tiles", live.wgsl `light_tiles`):
component).
- `light_tiles`, the live passes' first, makes each 8 x 8 tile's reports a list: the lights reported, each once,
ascending, after the direct buffer's pixels (64 vec4s a tile). The tile grid shifts by a pixel offset each frame.
- The next frame's paths take 2 candidates from their pixel's list at a vertex the camera sees and 1 past it, spread
over the list without repeats, beside the uniform candidates (8 and 3), which then stand for the lights the list
lacks. Each candidate is weighed by the lights it stands for: the estimate is the same sum whatever a list holds.
- The next frame's paths read the list of the tile their camera hit was seen in then (its point, moved back by its
motion, through that frame's camera; the nearest tile's when it was off screen). They take 2 candidates from it at
a vertex the camera sees and 1 past it, spread over the list without repeats, beside the uniform candidates (8 and
3), which then stand for the lights the list lacks. Each candidate is weighed by the lights it stands for: the estimate is the same sum whatever a list holds.

## What the kernel trusts

Expand Down
26 changes: 17 additions & 9 deletions renderer/shaders/pathtrace.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ struct Params {
background: vec4<f32>, // three.js scene.background (radiance), 1 (F_BACKGROUND)
ltile: vec4<u32>, // live light tiles (`lt_list_start`): the lists' first vec4 in direct_out (0: none), tiles a row, the grid's offset when they were built (x | y << 16), candidates a vertex the camera sees takes from its list
ltile2: vec4<u32>, // the traced size they were built at, -, -
lt_cam: array<vec4<f32>, 4>, // the camera they were built under: position (w = tan(fov/2)), forward (w = aspect), right, up
};

struct TriAttr { n0: vec4<f32>, n1: vec4<f32>, n2: vec4<f32>, uv: vec4<f32>, t: vec4<f32>, x: vec4<f32> };
Expand Down Expand Up @@ -1691,10 +1692,11 @@ fn local_light(li: Light, x: vec3<f32>, u: vec2<f32>) -> LS {
// resampled shadow ray found (direct_out's w: `lt_fb`, a light-table index; LT_NONE: none), live.wgsl `light_tiles`
// makes each 8 x 8 tile's reports a list after the kernel's own data in direct_out (the lights found, each once:
// index, reports; ascending; the last vec4 their number), and the next frame's paths take resampling candidates
// from their pixel's tile's list as well, spread over it without repeats (all of it when it is short). The
// candidates drawn uniformly, as before, then stand for the lights the list lacks (`lt_in_list`). A list is a
// guide: whatever it holds the estimate is the same sum, so one gone stale (the table reordered, the camera moved
// on) costs noise, not light. Compiled in for scenes whose shadow-casting local lights are resampled (F_LISTS).
// from the list of the tile their camera hit was seen in then (`lt_list_start`: where the point was, through that
// frame's camera), spread over it without repeats (all of it when it is short). The candidates drawn uniformly, as
// before, then stand for the lights the list lacks (`lt_in_list`). A list is a guide: whatever it holds the estimate
// is the same sum, so one gone stale (the table reordered, a surface newly in view) costs noise, not light.
// Compiled in for scenes whose shadow-casting local lights are resampled (F_LISTS).
const LT_TILE: u32 = 8u;
const LT_LIST: u32 = 64u;
// (Indices and counts are stored as float values, not as their bits: a small integer's bits are a denormal float,
Expand All @@ -1704,12 +1706,17 @@ var<private> lt_list: u32;
var<private> lt_list_n: u32;
var<private> lt_fb: u32;

// The list of the tile pixel `px` was in when the lists were built, and how many lights it holds.
fn lt_list_start(px: vec2<u32>) {
// The list of the tile that point `p` (the camera hit, where it was when the lists were built) was seen in then, and
// how many lights it holds: the nearest tile's when it was off screen, none when it was behind the camera.
fn lt_list_start(p: vec3<f32>) {
lt_list_n = 0u;
lt_fb = LT_NONE;
if (!LIVE || P.ltile.x == 0u) { return; }
let q = min(vec2<u32>(vec2<f32>(px) * vec2<f32>(P.ltile2.xy) / vec2<f32>(P.size.xy)), P.ltile2.xy - 1u);
let d = p - P.lt_cam[0].xyz;
let z = dot(d, P.lt_cam[1].xyz);
if (z <= 1e-4) { return; }
let ndc = vec2<f32>(dot(d, P.lt_cam[2].xyz) / (z * P.lt_cam[0].w * P.lt_cam[1].w), dot(d, P.lt_cam[3].xyz) / (z * P.lt_cam[0].w));
let uv = clamp(vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), vec2<f32>(0.0), vec2<f32>(1.0));
let q = min(vec2<u32>(uv * vec2<f32>(P.ltile2.xy)), P.ltile2.xy - 1u);
let c = q + vec2<u32>(P.ltile.z & 0xffffu, P.ltile.z >> 16u);
lt_list = P.ltile.x + ((c.y / LT_TILE) * P.ltile.y + c.x / LT_TILE) * LT_LIST;
if (lt_list + LT_LIST > direct_len()) { return; }
Expand Down Expand Up @@ -3161,7 +3168,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// Every pixel's deterministic direct light is written each frame (0 unless the camera hits a surface): a pixel that
// turned to sky must not keep what a surface left there.
if (LIVE && P.misc.w < 0.5 && direct_len() >= w * h) { direct_store(pix, vec4<f32>(0.0, 0.0, 0.0, select(0.0, f32(LT_NONE), has(F_LISTS)))); }
if (has(F_LISTS)) { lt_list_start(gid.xy); }
if (has(F_LISTS)) { lt_fb = LT_NONE; }
rng_start(gid.xy, P.size.z, P.size.w, 0u);
cur_depth = 0u;
let cam = rng4(rng_dim(D_CAMERA));
Expand Down Expand Up @@ -3375,6 +3382,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let pp = prev_verts[kp].xyz + prev_verts[kp + 1u].xyz * hit.u + prev_verts[kp + 2u].xyz * hit.v;
mv = vec4<f32>(pp - s.p, 1.0);
}
if (has(F_LISTS)) { lt_list_start(s.p + mv.xyz); }
albedo = select(s.base, s.emissive / max(max(s.emissive.r, max(s.emissive.g, s.emissive.b)), 1e-6), s.card);
normal = s.n;
anim = s.anim;
Expand Down
3 changes: 3 additions & 0 deletions renderer/src/gpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ pub struct Params {
pub ltile: [u32; 4],
/// The traced size the lists were built at, -, -.
pub ltile2: [u32; 4],
/// The camera the lists were built under (the last frame's): position (w = tan(fov / 2)), forward (w = aspect),
/// right, up. A camera hit reads the list of the tile it was seen in then.
pub lt_cam: [[f32; 4]; 4],
}

/// Room the live view's in-place geometry edits grow into (live/edits.rs `edit_geometry`), past the scene's own data:
Expand Down
1 change: 1 addition & 0 deletions renderer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ pub fn prepare_as(hwrt: bool, units: bool, sc: &mut scene::Scene, timings: &mut
procs,
ltile: [0; 4],
ltile2: [0; 4],
lt_cam: [[0.0; 4]; 4],
};
let mut params = params;
let mut first = 0u32;
Expand Down
6 changes: 4 additions & 2 deletions renderer/src/live/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,10 @@ impl LiveCore {
let tile_at = (((halton(self.frame % 64 + 1, 2) * 8.0) as u32).min(7), ((halton(self.frame % 64 + 1, 3) * 8.0) as u32).min(7));
for k in 0..spp.max(1) {
let mut p = self.sc.base;
if let Some((row, at, size)) = self.light_tiles {
if let Some((row, at, size, cam)) = self.light_tiles {
(p.ltile[0], p.ltile[1], p.ltile[2]) = (lists, row, at);
p.ltile2 = [size.0, size.1, 0, 0];
p.lt_cam = cam;
}
// Software traversal's units as edits left them: the top tree's root and how many it holds (trace_units.wgsl).
if let Some(u) = &self.sc.up.units {
Expand Down Expand Up @@ -677,7 +678,8 @@ impl LiveCore {
self.prev_cam = cam;
self.shown_spp = spp.max(1);
self.prev_isz = (iw, ih);
self.light_tiles = tiles_on.then_some((layout::light_tiles(iw), tile_at.0 | tile_at.1 << 16, (iw, ih)));
let cu = cam.uniform(self.aspect);
self.light_tiles = tiles_on.then_some((layout::light_tiles(iw), tile_at.0 | tile_at.1 << 16, (iw, ih), [cu.pos, cu.fwd, cu.right, cu.up]));
self.reset = false;
self.frame = self.frame.wrapping_add(1);
}
Expand Down
8 changes: 4 additions & 4 deletions renderer/src/live/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,10 @@ pub struct LiveCore {
inst_moved: bool,
/// The previous frame's traced size (its traced-grid diffuse history; 0 before the first frame).
prev_isz: (u32, u32),
/// The light tiles' lists as the last frame built them (pathtrace.wgsl `Params::ltile`'s y, z and `ltile2`'s
/// size): what this frame's paths take candidates from. None: no lists (the first frame, after a reset, a scene
/// whose local lights are summed exactly).
light_tiles: Option<(u32, u32, (u32, u32))>,
/// The light tiles' lists as the last frame built them (pathtrace.wgsl `Params::ltile`'s y, z, `ltile2`'s size
/// and `lt_cam`, that frame's camera): what this frame's paths take candidates from. None: no lists (the first
/// frame, after a reset, a scene whose local lights are summed exactly).
light_tiles: Option<(u32, u32, (u32, u32), [[f32; 4]; 4])>,
/// Frame of the last lighting edit (power, light / material parameters, environment): the temporal pass looks
/// for changed lighting (anti-lag) only shortly after one.
lit_changed: u32,
Expand Down
36 changes: 34 additions & 2 deletions tools/bench-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// its frames with an offline render of the same moment (rendered once and kept: out/cache/window-truth, keyed by the
// bundle and the shot; --fresh-truth renders them again), or, for the dusk, with the same moment held.
//
// node tools/bench-window.ts [--shots still-bistro,still-kabukicho,still-rooms,slice,tokyo-dusk,tokyo-turn,tokyo-orbit]
// node tools/bench-window.ts [--shots still-bistro,still-kabukicho,still-rooms,still-rooms-wide,rooms-turn,slice,
// tokyo-dusk,tokyo-turn,tokyo-orbit]
// [--tag NAME] [--fresh-truth]
//
// Shots (the bin is THREERT_BIN, else the release build):
Expand All @@ -14,6 +15,8 @@
// lamps, most behind walls; scale 0.5; offline at 4096 samples): where a light sampler has to find the lamps a
// point can see, and what its noise does as they multiply. (The offline render chooses among the lamps
// uniformly: with 768 its 4096 samples are still noisy, a floor under every reading of the wide shot.)
// rooms-turn lamp-rooms-wide, the camera turning 2 degrees a frame for 24 frames into the scene's view, then still:
// the last moving frame and 1, 6, 30 and 150 frames after it against the same offline render.
// slice the Bistro slice half a second after its explosion (40.5 s, from the start in lockstep, scale 0.375, 1440p):
// the same four numbers against offline at 40.5 s.
// tokyo-dusk the Tokyo time-of-day reel's room (16:00 to 17:42 in 10.5 s), the camera still, at 6, 7, 8 s: the frame
Expand All @@ -35,7 +38,7 @@ import { startLive, type Build, type Live } from "./lib/live.ts";

const args = process.argv.slice(2);
const flag = (k: string, d: string) => (args.includes(k) ? args[args.indexOf(k) + 1] : d);
const ALL = ["still-bistro", "still-kabukicho", "still-rooms", "still-rooms-wide", "slice", "tokyo-dusk", "tokyo-turn", "tokyo-orbit"];
const ALL = ["still-bistro", "still-kabukicho", "still-rooms", "still-rooms-wide", "rooms-turn", "slice", "tokyo-dusk", "tokyo-turn", "tokyo-orbit"];
const shots = flag("--shots", ALL.join(",")).split(",");
const tag = flag("--tag", new Date().toISOString().replace(/[:.]/g, "-"));
const freshTruth = args.includes("--fresh-truth");
Expand Down Expand Up @@ -126,6 +129,34 @@ async function still(name: string, src: string, scale: string, env: Record<strin
rows.push({ shot: name, frame: "held", region: "all", detail: r3(await detail(ref, a)), dE: m.mean_delta_e, p99: m.p99_delta_e, ssim: m.ssim, lum: m.luminance_ratio, flicker: r3(await flicker(a, c)) });
}

/** lamp-rooms-wide, the camera turning into the scene's own view, then still: what guides a light sampler by the last
* frame has to follow the picture. (The view: scenes/lamp-rooms.ts's camera for 12 x 8 rooms of 5.2 m.) */
async function roomsTurn(name: string) {
const dir = join(out, name);
mkdirSync(dir, { recursive: true });
const b = await bundle("scenes/lamp-rooms-wide.ts");
const ref = truth(b, { width: 2560, height: 1440, spp: 4096 });
const P = 5.2, cx = (i: number) => (i - 5.5) * P, cz = (j: number) => (j - 3.5) * P;
const pos = [cx(1) - 1.9, 1.5, cz(1) + 1.9], D = [cx(2) + 0.6 - pos[0], 1.1 - pos[1], cz(0) + 1.2 - pos[2]];
const cam = (deg: number) => {
const c = Math.cos((deg * Math.PI) / 180), s = Math.sin((deg * Math.PI) / 180);
return { pos, target: [pos[0] + D[0] * c + D[2] * s, pos[1] + D[1], pos[2] - D[0] * s + D[2] * c], tan: 0 };
};
const live = await session(b, { width: 2560, height: 1440, scale: "0.5", env: { THREERT_FREEZE_TIME: "1" } });
const f = (n: string) => join(dir, `${n}.png`);
await frames(live, 120, undefined, cam(-48));
for (let k = 1; k <= 24; k++) await frames(live, 1, k === 24 ? f("last") : undefined, cam(-48 + 2 * k));
await frames(live, 1, f("stop1"));
await frames(live, 5, f("stop6"));
await frames(live, 24, f("stop30"));
await frames(live, 120, f("settled"));
await live.quit();
for (const n of ["last", "stop1", "stop6", "stop30", "settled"]) {
const m = compare(ref, f(n));
rows.push({ shot: name, frame: n, region: "all", dE: m.mean_delta_e, p99: m.p99_delta_e, ssim: m.ssim, lum: m.luminance_ratio });
}
}

const TOKYO = "games/tokyo/tokyo-tod.game.ts", TOKYO_ROOM = "shiba-15.6-17.7-10.5-r1600", TOKYO_SETTLE = 2;
const tokyoMissing = () => !existsSync(join(repoRoot, "assets/city/glb/shiba"));

Expand Down Expand Up @@ -208,6 +239,7 @@ for (const s of shots) {
else if (s === "still-kabukicho") await still(s, "games/kabukicho/kabukicho.game.ts", "0.375", { THREERT_NO_GRAIN: "1" });
else if (s === "still-rooms") await still(s, "scenes/lamp-rooms.ts", "0.5", {}, 4096);
else if (s === "still-rooms-wide") await still(s, "scenes/lamp-rooms-wide.ts", "0.5", {}, 4096);
else if (s === "rooms-turn") await roomsTurn(s);
else if (s === "slice") await slice();
else if (s === "tokyo-dusk") await tokyoDusk();
else if (s === "tokyo-turn") await tokyoMove(s, false);
Expand Down