diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 510adcc..62af41d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 182685f..7977c2a 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -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 diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 157fe94..9b68239 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -110,6 +110,7 @@ struct Params { background: vec4, // three.js scene.background (radiance), 1 (F_BACKGROUND) ltile: vec4, // 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, // the traced size they were built at, -, - + lt_cam: array, 4>, // the camera they were built under: position (w = tan(fov/2)), forward (w = aspect), right, up }; struct TriAttr { n0: vec4, n1: vec4, n2: vec4, uv: vec4, t: vec4, x: vec4 }; @@ -1691,10 +1692,11 @@ fn local_light(li: Light, x: vec3, u: vec2) -> 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, @@ -1704,12 +1706,17 @@ var lt_list: u32; var lt_list_n: u32; var 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) { +// 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) { lt_list_n = 0u; - lt_fb = LT_NONE; if (!LIVE || P.ltile.x == 0u) { return; } - let q = min(vec2(vec2(px) * vec2(P.ltile2.xy) / vec2(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(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(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), vec2(0.0), vec2(1.0)); + let q = min(vec2(uv * vec2(P.ltile2.xy)), P.ltile2.xy - 1u); let c = q + vec2(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; } @@ -3161,7 +3168,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // 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(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)); @@ -3375,6 +3382,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let pp = prev_verts[kp].xyz + prev_verts[kp + 1u].xyz * hit.u + prev_verts[kp + 2u].xyz * hit.v; mv = vec4(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; diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index ab5509f..d205815 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -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: diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index c37d540..2588d8a 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -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; diff --git a/renderer/src/live/frame.rs b/renderer/src/live/frame.rs index bd1d3a6..f2ae0f0 100644 --- a/renderer/src/live/frame.rs +++ b/renderer/src/live/frame.rs @@ -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 { @@ -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); } diff --git a/renderer/src/live/mod.rs b/renderer/src/live/mod.rs index 3d6518f..962ee43 100644 --- a/renderer/src/live/mod.rs +++ b/renderer/src/live/mod.rs @@ -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, diff --git a/tools/bench-window.ts b/tools/bench-window.ts index 6c3296c..6499de7 100644 --- a/tools/bench-window.ts +++ b/tools/bench-window.ts @@ -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): @@ -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 @@ -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"); @@ -126,6 +129,34 @@ async function still(name: string, src: string, scale: string, env: Record (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")); @@ -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);