From 2a943be9183c8f36e3532503e9c2a73840dd9136 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 01:38:07 +0800 Subject: [PATCH 01/10] Benchmark: lamp-rooms, many shadow-casting lamps behind walls scenes/lamp-rooms.ts (4 x 3 rooms, 96 spot and point lamps with ranges, no sky) and lamp-rooms-wide.ts (12 x 8, 768), and their bench-window shots still-rooms / still-rooms-wide: the case the other shots lack, where a light sampler has to find the lamps a point can see. Co-Authored-By: Claude Fable 5.2 --- scenes/lamp-rooms-wide.ts | 6 +++ scenes/lamp-rooms.ts | 96 +++++++++++++++++++++++++++++++++++++++ tools/bench-window.ts | 13 ++++-- 3 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 scenes/lamp-rooms-wide.ts create mode 100644 scenes/lamp-rooms.ts diff --git a/scenes/lamp-rooms-wide.ts b/scenes/lamp-rooms-wide.ts new file mode 100644 index 0000000..bf06ec1 --- /dev/null +++ b/scenes/lamp-rooms-wide.ts @@ -0,0 +1,6 @@ +// scenes/lamp-rooms.ts with 12 x 8 rooms: 768 lamps, the same view. What a sampler's noise does as the lamps multiply. +import { lampRooms } from "./lamp-rooms.ts"; + +export default function build() { + return lampRooms(12, 8, "lamp-rooms-wide"); +} diff --git a/scenes/lamp-rooms.ts b/scenes/lamp-rooms.ts new file mode 100644 index 0000000..94b6831 --- /dev/null +++ b/scenes/lamp-rooms.ts @@ -0,0 +1,96 @@ +// Many shadow-casting local lights, most of them behind walls: a floor of rooms (4 x 3 here; lamp-rooms-wide.ts: +// 12 x 8) with doorways between them, eight lamps a room (four ceiling spots, four point lamps: 96 in all, each with +// a range that reaches into the rooms next door), no sky. What a light sampler has to find here is which lamps a point can see. +import { BoxGeometry, Color, Mesh, MeshStandardMaterial, PerspectiveCamera, PointLight, Scene, SkyColor, SpotLight } from "@threert/scene"; + +const S = 5, H = 3, T = 0.2, DOOR = 1.2, DOOR_H = 2.2; +const TINTS = [0xffd9a8, 0xbcd4ff, 0xffb38a, 0xc8ffd0, 0xffe6c4, 0xd9c2ff]; + +/** A floor of `NX` x `NZ` rooms (8 lamps each). */ +export function lampRooms(NX: number, NZ: number, name: string) { + const scene = new Scene(); + scene.environment = new SkyColor(0xffffff, 0); + const plaster = new MeshStandardMaterial({ name: "plaster", color: 0xd6d1c8, roughness: 0.9 }); + const floorMat = new MeshStandardMaterial({ name: "floor", color: 0x9a9da3, roughness: 0.3 }); + const wood = new MeshStandardMaterial({ name: "wood", color: 0x7a5533, roughness: 0.6 }); + const metal = new MeshStandardMaterial({ name: "steel", color: new Color().setRGB(0.6, 0.62, 0.65), metalness: 1, roughness: 0.35 }); + const box = (name: string, w: number, h: number, d: number, x: number, y: number, z: number, mat: MeshStandardMaterial) => { + const m = new Mesh(new BoxGeometry(w, h, d), mat); + m.name = name; + m.position.set(x, y, z); + scene.add(m); + }; + const P = S + T, W = NX * P + T, D = NZ * P + T; + const cx = (i: number) => (i - (NX - 1) / 2) * P, cz = (j: number) => (j - (NZ - 1) / 2) * P; + box("floor", W, T, D, 0, -T / 2, 0, floorMat); + box("ceiling", W, T, D, 0, H + T / 2, 0, plaster); + // Walls on the grid's lines: solid outside, a doorway in each room's side inside. Posts where the lines cross. + for (let i = 0; i <= NX; i++) { + for (let j = 0; j < NZ; j++) { + const x = cx(i) - P / 2, z = cz(j), n = `wall_x${i}_${j}`; + if (i === 0 || i === NX) { + box(n, T, H, S, x, H / 2, z, plaster); + } else { + const side = (S - DOOR) / 2; + box(`${n}_a`, T, H, side, x, H / 2, z - (DOOR + side) / 2, plaster); + box(`${n}_b`, T, H, side, x, H / 2, z + (DOOR + side) / 2, plaster); + box(`${n}_top`, T, H - DOOR_H, DOOR, x, (H + DOOR_H) / 2, z, plaster); + } + } + } + for (let j = 0; j <= NZ; j++) { + for (let i = 0; i < NX; i++) { + const z = cz(j) - P / 2, x = cx(i), n = `wall_z${i}_${j}`; + if (j === 0 || j === NZ) { + box(n, S, H, T, x, H / 2, z, plaster); + } else { + const side = (S - DOOR) / 2; + box(`${n}_a`, side, H, T, x - (DOOR + side) / 2, H / 2, z, plaster); + box(`${n}_b`, side, H, T, x + (DOOR + side) / 2, H / 2, z, plaster); + box(`${n}_top`, DOOR, H - DOOR_H, T, x, (H + DOOR_H) / 2, z, plaster); + } + } + } + for (let i = 0; i <= NX; i++) for (let j = 0; j <= NZ; j++) box(`post_${i}_${j}`, T, H, T, cx(i) - P / 2, H / 2, cz(j) - P / 2, plaster); + + for (let i = 0; i < NX; i++) { + for (let j = 0; j < NZ; j++) { + const x = cx(i), z = cz(j), k = i * NZ + j, tint = TINTS[k % TINTS.length], n = `r${i}_${j}`; + // Furniture: a table, a shelf against a wall, a steel crate; placed by the room's number. + const sx = k % 2 ? 1 : -1, sz = k % 3 ? 1 : -1; + box(`${n}_table`, 1.3, 0.06, 0.8, x + 0.9 * sx, 0.74, z - 0.7 * sz, wood); + for (const [dx, dz] of [[-0.55, -0.3], [0.55, -0.3], [-0.55, 0.3], [0.55, 0.3]]) box(`${n}_leg${dx}${dz}`, 0.06, 0.71, 0.06, x + 0.9 * sx + dx, 0.355, z - 0.7 * sz + dz, wood); + box(`${n}_shelf`, 0.4, 1.9, 1.4, x - (S / 2 - 0.25) * sx, 0.95, z + 0.9 * sz, wood); + box(`${n}_crate`, 0.7, 0.7, 0.7, x - 1.1 * sx, 0.35, z - 1.4 * sz, metal); + const add = (l: PointLight | SpotLight, name: string, px: number, py: number, pz: number) => { + l.name = `${n}_${name}`; + l.position.set(px, py, pz); + l.radius = 0.03; + scene.add(l); + }; + for (const [q, [dx, dz]] of [[-1.25, -1.25], [1.25, -1.25], [-1.25, 1.25], [1.25, 1.25]].entries()) { + const s = new SpotLight(tint, 1800, 7, (52 * Math.PI) / 180, 0.4); + add(s, `spot${q}`, x + dx, H - 0.12, z + dz); + s.target!.position.set(x + dx, 0, z + dz); + scene.add(s.target!); + } + add(new PointLight(tint, 320, 6), "table_lamp", x + 0.9 * sx, 1.05, z - 0.7 * sz); + add(new PointLight(tint, 260, 6), "shelf_lamp", x - (S / 2 - 0.55) * sx, 2.05, z + 0.9 * sz); + add(new PointLight(0xffffff, 240, 6), "sconce_a", x + (S / 2 - 0.4) * sx, 2.0, z + 1.5 * sz); + add(new PointLight(0xffffff, 240, 6), "sconce_b", x - 1.5 * sx, 2.0, z - (S / 2 - 0.4) * sz); + } + } + + // From a corner of an inner room, across it and through its doorways into the rooms beyond. + const camera = new PerspectiveCamera(72, 16 / 9, 0.05, 100); + camera.name = "camera"; + camera.position.set(cx(1) - 1.9, 1.5, cz(1) + 1.9); + camera.lookAt(cx(2) + 0.6, 1.1, cz(0) + 1.2); + camera.setManualExposure(8.2); + scene.add(camera); + return { scene, camera, options: { name, width: 1280, height: 720 } }; +} + +export default function build() { + return lampRooms(4, 3, "lamp-rooms"); +} diff --git a/tools/bench-window.ts b/tools/bench-window.ts index b81a2d5..c88b256 100644 --- a/tools/bench-window.ts +++ b/tools/bench-window.ts @@ -3,13 +3,16 @@ // 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,slice,tokyo-dusk,tokyo-turn,tokyo-orbit] +// node tools/bench-window.ts [--shots still-bistro,still-kabukicho,still-rooms,slice,tokyo-dusk,tokyo-turn,tokyo-orbit] // [--tag NAME] [--fresh-truth] // // Shots (the bin is THREERT_BIN, else the release build): // still-bistro, still-kabukicho the scene held still at 2560 x 1440 (render scale 0.5, 0.375), animation frozen, // 300 frames, then two consecutive frames: detail (the gradients' regression on offline's: 1 keeps every edge), // ΔE, SSIM against offline (512 samples), and the RMS change of luma from one frame to the next (flicker). +// still-rooms, still-rooms-wide the same for scenes/lamp-rooms.ts and lamp-rooms-wide.ts (96 and 768 shadow-casting +// 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. // 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 @@ -31,7 +34,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", "slice", "tokyo-dusk", "tokyo-turn", "tokyo-orbit"]; +const ALL = ["still-bistro", "still-kabukicho", "still-rooms", "still-rooms-wide", "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"); @@ -108,11 +111,11 @@ async function session(b: string, o: { width: number; height: number; scale: str const r3 = (x: number) => Math.round(x * 1000) / 1000; /** Held still: 300 frames, then two consecutive frames, against offline. */ -async function still(name: string, src: string, scale: string, env: Record) { +async function still(name: string, src: string, scale: string, env: Record, spp = 512) { const dir = join(out, name); mkdirSync(dir, { recursive: true }); const b = await bundle(src); - const ref = truth(b, { width: 2560, height: 1440, spp: 512 }); + const ref = truth(b, { width: 2560, height: 1440, spp }); const live = await session(b, { width: 2560, height: 1440, scale, env: { THREERT_FREEZE_TIME: "1", ...env } }); const [a, c] = [join(dir, "a.png"), join(dir, "b.png")]; await frames(live, 300, a); @@ -202,6 +205,8 @@ for (const s of shots) { } if (s === "still-bistro") await still(s, "scenes/bistro.ts", "0.5", {}); 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 === "slice") await slice(); else if (s === "tokyo-dusk") await tokyoDusk(); else if (s === "tokyo-turn") await tokyoMove(s, false); From 3d6e06cb15b70932c0cf038f8f6966ad148295a7 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 02:18:10 +0800 Subject: [PATCH 02/10] Live: local lights guided by light tiles Each camera pixel reports the local light its resampled shadow ray found; a pass makes each 8 x 8 tile's reports a list; the next frame's paths take 4 candidates from their tile's list (2 past the camera hit) beside the uniform ones, which then stand for the lights the list lacks. Unbiased whatever a list holds. Off in scenes whose local lights are summed exactly. still-rooms dE / flicker 0.67 / 0.138 -> 0.63 / 0.068; still-rooms-wide (768 lamps) 1.01 / 0.126 -> 0.58 / 0.081; Bistro and Kabukicho unchanged. Co-Authored-By: Claude Fable 5.2 --- renderer/shaders/live.wgsl | 46 ++++++++++++++++++ renderer/shaders/pathtrace.wgsl | 83 +++++++++++++++++++++++++++++---- renderer/src/gpu.rs | 6 +++ renderer/src/lib.rs | 2 + renderer/src/live/frame.rs | 19 +++++++- renderer/src/live/layout.rs | 16 +++++++ renderer/src/live/load.rs | 1 + renderer/src/live/mod.rs | 15 +++++- renderer/src/live/targets.rs | 5 +- 9 files changed, 180 insertions(+), 13 deletions(-) diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index e999c25..fd385d0 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -32,6 +32,7 @@ struct LiveParams { dof: vec4, // depth of field: CoC radius of infinity (output px; 0 = off), focus distance (m), max radius (px), - jit: vec4, // this frame's sub-pixel jitter (every traced sample's: x, y in 0 .. 1 of a traced pixel), -, - state: vec4, // which half of the lasting buffers is this frame's (0 / 1; `this_half`), -, -, - + lt: vec4, // the light tiles (`light_tiles`): this frame's grid offset x, y (traced pixels), the lists' first vec4 in `light_rw`, tiles a row }; // color = diffuse lighting + first-hit distance, emit = directly seen emission / sky + roughness, spec = specular lighting // + reflection hit distance, normal.w = the media before the first hit (`medium_tr`, `medium_share`; see pathtrace.wgsl). @@ -107,6 +108,9 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec // (`compose` adds it as it is, UE lights directly). @group(0) @binding(26) var direct_in: array>; @group(0) @binding(27) var lock_fwd: array>; +// The kernel's direct buffer again, to write (`light_tiles` alone): a vec4 a traced pixel (w: the local light its +// camera hit's resampled shadow ray found; pathtrace.wgsl "Light tiles"), then the tiles' lists. +@group(0) @binding(28) var light_rw: array>; // Element i of this frame's half of a lasting buffer, and of the previous frame's. fn this_half(i: u32) -> u32 { return L.state.x * L.size.x * L.size.y + i; } @@ -240,6 +244,48 @@ const RESOLVE_N: u32 = 8u; // w: the reflection's hit distance, weighted the same way (0: none), for the temporal pass's mirror-image // reprojection. A pixel's own ray is one random direction of the lobe (or none, when it took the diffuse lobe), so // its distance alone put the mirror image anywhere along the ray. +// ---- The light tiles: each 8 x 8 tile's pixels' reports made its list (pathtrace.wgsl "Light tiles": the lights +// reported, each once: index, reports; ascending; the 64th vec4 holds their number, 63 at most). A workgroup is a +// tile: the grid is the traced one shifted by L.lt.xy, another offset each frame, so tile borders do not stay put. +const LT_NONE: u32 = 0xf0000000u; +var lt_keys: array; +var lt_first: array; + +@compute @workgroup_size(8, 8, 1) +fn light_tiles(@builtin(global_invocation_id) gid: vec3, @builtin(local_invocation_index) li: u32, @builtin(workgroup_id) wg: vec3) { + let px = vec2(gid.xy) - vec2(L.lt.xy); + var key = LT_NONE; + if (px.x >= 0 && px.y >= 0 && px.x < i32(L.isz.x) && px.y < i32(L.isz.y)) { + key = min(bitcast(light_rw[u32(px.y) * L.isz.x + u32(px.x)].w), LT_NONE); + } + lt_keys[li] = key; + workgroupBarrier(); + // Whether it is the first of the tile's reports of its light, and how many there are (four reports a step). + var same = vec4(0u); + var before = vec4(0u); + let k0 = vec4(key); + for (var j = 0u; j < 64u; j += 4u) { + let eq = vec4(lt_keys[j], lt_keys[j + 1u], lt_keys[j + 2u], lt_keys[j + 3u]) == k0; + same += vec4(eq); + before += vec4(eq & (vec4(j, j + 1u, j + 2u, j + 3u) < vec4(li))); + } + let first = key != LT_NONE && before.x + before.y + before.z + before.w == 0u; + lt_first[li] = u32(first); + workgroupBarrier(); + // The first reports are the list: each after those of smaller keys. + var less = vec4(0u); + var all = vec4(0u); + for (var j = 0u; j < 64u; j += 4u) { + let f = vec4(lt_first[j], lt_first[j + 1u], lt_first[j + 2u], lt_first[j + 3u]); + less += f * vec4(vec4(lt_keys[j], lt_keys[j + 1u], lt_keys[j + 2u], lt_keys[j + 3u]) < k0); + all += f; + } + let list = L.lt.z + (wg.y * L.lt.w + wg.x) * 64u; + let at = less.x + less.y + less.z + less.w; + if (first && at < 63u) { light_rw[list + at] = vec4(bitcast(key), bitcast(same.x + same.y + same.z + same.w), 0.0, 0.0); } + if (li == 0u) { light_rw[list + 63u] = vec4(bitcast(min(all.x + all.y + all.z + all.w, 63u)), 0.0, 0.0, 0.0); } +} + @compute @workgroup_size(8, 8, 1) fn refl(@builtin(global_invocation_id) gid: vec3) { let iw = L.isz.x; diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 93a2a1b..0b67836 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -107,6 +107,8 @@ struct Params { procs: vec4, // procedural geometry (F_PROC): its data's first word in env_cdf, object count, first record in `instances`, - aerial: vec4, // the sky's aerial perspective (F_AERIAL): density multiple, Rayleigh scale, Mie scale, - 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, -, - }; struct TriAttr { n0: vec4, n1: vec4, n2: vec4, uv: vec4, t: vec4, x: vec4 }; @@ -1684,6 +1686,47 @@ fn local_light(li: Light, x: vec3, u: vec2) -> LS { // ones too when there are few (one shadow ray each). Otherwise: resampled importance sampling (Talbot 2005) of RIS_M // uniform candidates by unshadowed contribution, then one shadow ray. `vol` = in-scattering in the fog (phase // function) instead of the surface BSDF. Surface results add their specular part to `dl_spec`. +// Light tiles (live; what guides MegaLights' draws and RTXPT's): each camera pixel reports the local light its +// 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. +const LT_TILE: u32 = 8u; +const LT_LIST: u32 = 64u; +const LT_NONE: u32 = 0xf0000000u; +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) { + 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 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; } + lt_list_n = min(bitcast(direct_load(lt_list + LT_LIST - 1u).x), LT_LIST - 1u); +} + +// Whether the pixel's list holds light `i`. +fn lt_in_list(i: u32) -> bool { + var lo = 0u; + var hi = lt_list_n; + for (var k = 0u; k < 7u; k++) { + if (lo >= hi) { break; } + let mid = (lo + hi) / 2u; + let km = bitcast(direct_load(lt_list + mid).x); + if (km == i) { return true; } + if (km < i) { lo = mid + 1u; } else { hi = mid; } + } + return false; +} + fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, vol: bool, d: vec3, rd: u32) -> vec3 { let nd = P.counts2.x; let nl = P.counts.x - nd; @@ -1733,19 +1776,39 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, } // Target: unshadowed intensity x cosine (cheap; the full BSDF / phase is applied to the chosen light only). let m_cand = select(3u, RIS_M, seen_directly()); + // With the pixel's list (light tiles), `nt` candidates more come from it, one in each `step` of its lights, and + // the uniform ones keep the lights it lacks; each weighed by how many lights it stands for. + var nt = 0u; + if (!vol && lt_list_n > 0u) { nt = min(select(P.ltile.w / 2u, P.ltile.w, seen_directly()), lt_list_n); } + let step = f32(lt_list_n) / f32(max(nt, 1u)); + let each = select(1.0, f32(count) / f32(m_cand), nt > 0u); + var at = 0.0; + if (nt > 0u) { at = rng1(rng_sub(rd ^ 0x80000000u, 0x2000u)); } var wsum = 0.0; var pick = 0u; var pick_ph = 0.0; var pick_ls: LS; - for (var k = 0u; k < m_cand; k++) { - let r4 = rng4(rng_sub(rd ^ 0x80000000u, k)); - let idx = first + min(u32(r4.x * f32(count)), count - 1u); + for (var k = 0u; k < m_cand + nt; k++) { + var r4: vec4; + var idx: u32; + var stands = each; + if (k < m_cand) { + r4 = rng4(rng_sub(rd ^ 0x80000000u, k)); + idx = first + min(u32(r4.x * f32(count)), count - 1u); + } else { + r4 = rng4(rng_sub(rd ^ 0x80000000u, 0x1000u + k)); + idx = bitcast(direct_load(lt_list + min(u32((f32(k - m_cand) + at) * step), lt_list_n - 1u)).x); + if (idx < first || idx >= first + count) { continue; } + stands = step; + } let ls = local_light(light_at(idx), x, r4.yz); var ph = luminance(ls.c); if (vol) { ph *= light_at(idx).x.x; } else { ph *= max(dot(s.n, ls.l), 0.0) + 0.05 * max(dot(s.ng, ls.l), 0.0); } if (ph <= 0.0) { continue; } - wsum += ph; - if (r4.w * wsum < ph) { + if (nt > 0u && k < m_cand && lt_in_list(idx)) { continue; } + let w = ph * stands; + wsum += w; + if (r4.w * wsum < w) { pick = idx; pick_ph = ph; pick_ls = ls; @@ -1758,9 +1821,12 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, if (any(c > vec3(0.0))) { let vis = light_vis(light_at(pick).ext.w >= 0.5, o, pick_ls.sl, pick_ls.dist * 0.999, rng_sub(rd, 0xffffffffu)); if (any(vis > vec3(0.0))) { - let r = c * gtint(vis) * (wsum * f32(count) / (f32(m_cand) * pick_ph)); + var r = c * gtint(vis) * (wsum * f32(count) / (f32(m_cand) * pick_ph)); + if (nt > 0u) { r = c * gtint(vis) * (wsum / pick_ph); } sum += r; if (!vol) { dl_spec += r * split_frac; } + // The light tiles' report: the light the camera hit's ray found. + if (!vol && cur_depth == 0u) { lt_fb = pick; } } } } @@ -3091,7 +3157,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let pix = gid.y * w + gid.x; // 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)); } + if (LIVE && P.misc.w < 0.5 && direct_len() >= w * h) { direct_store(pix, vec4(0.0, 0.0, 0.0, bitcast(LT_NONE))); } + lt_list_start(gid.xy); rng_start(gid.xy, P.size.z, P.size.w, 0u); cur_depth = 0u; let cam = rng4(rng_dim(D_CAMERA)); @@ -3394,7 +3461,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { if (LIVE && depth == 0u && direct_len() >= P.size.x * P.size.y && !non_finite(dl_det) && !non_finite(dl_det_spec)) { radiance -= thr * dl_det; dl_spec = max(dl_spec - dl_det_spec, vec3(0.0)); - if (P.misc.w < 0.5) { direct_store(gid.y * P.size.x + gid.x, vec4(thr * dl_det, 0.0)); } + if (P.misc.w < 0.5) { direct_store(gid.y * P.size.x + gid.x, vec4(thr * dl_det, bitcast(lt_fb))); } } let l = sample_bsdf(s, lb, v); diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index e892022..6075582 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -115,6 +115,12 @@ pub struct Params { pub aerial: [f32; 4], /// three.js `scene.background` (F_BACKGROUND): its radiance, 1. pub background: [f32; 4], + /// The live light tiles (pathtrace.wgsl "Light tiles"; live/frame.rs): the lists' first vec4 in the direct + /// buffer (0: none this frame), tiles a row, the grid's offset when they were built (x | y << 16), candidates a + /// vertex the camera sees takes from its list beside the uniform ones (`live::list_candidates`; half past it). + pub ltile: [u32; 4], + /// The traced size the lists were built at, -, -. + pub ltile2: [u32; 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 d31dbdc..c37d540 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -743,6 +743,8 @@ pub fn prepare_as(hwrt: bool, units: bool, sc: &mut scene::Scene, timings: &mut lod_view: [0.0; 4], ltree: ltree_param, procs, + ltile: [0; 4], + ltile2: [0; 4], }; let mut params = params; let mut first = 0u32; diff --git a/renderer/src/live/frame.rs b/renderer/src/live/frame.rs index b927b38..803b1b1 100644 --- a/renderer/src/live/frame.rs +++ b/renderer/src/live/frame.rs @@ -351,8 +351,21 @@ impl LiveCore { let phases = ((8.0 * (w as f32 / iw as f32) * (h as f32 / ih as f32)).round() as u32).clamp(8, 512); let quant = |v: f32| (v * 4095.0).round() / 4095.0; let jitter = (quant(halton(self.frame % phases + 1, 2)), quant(halton(self.frame % phases + 1, 3))); + // The light tiles (pathtrace.wgsl "Light tiles"), in a scene whose local lights are resampled (more than the + // kernel's RIS_EXACT, 8): the lists the last frame built, for this frame's paths (none after a reset: its + // reports were another view's), and this frame's grid offset, a pixel of 8 x 8. + let tiles_on = self.sc.base.counts[0] - self.sc.base.counts2[0] > 8; + if self.reset || !tiles_on { + self.light_tiles = None; + } + let lists = (self.targets.kernel.direct.size / 16) as u32 - layout::light_tiles(w) * layout::light_tiles(h) * 64; + 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 { + (p.ltile[0], p.ltile[1], p.ltile[2]) = (lists, row, at); + p.ltile2 = [size.0, size.1, 0, 0]; + } // 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 { (p.motion[2], p.motion[3]) = (f32::from_bits(u.layout.top_first), f32::from_bits(u.count)); @@ -503,6 +516,7 @@ impl LiveCore { dof: dof_params(self.sc.dof.as_ref(), cam.tan_half_fov, w, h), jit: [jitter.0, jitter.1, 0.0, 0.0], state: [1 - self.frame % 2, 0, 0, 0], + lt: [tile_at.0, tile_at.1, lists, layout::light_tiles(iw)], }; g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp)); // Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion @@ -556,7 +570,7 @@ impl LiveCore { for (k, (name, grid, pipe)) in self.passes.pipes.iter().enumerate() { let q = (k as u32 + 1) * 2; let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(name), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); - if self.view_mode != 0 || !layout::VIEW_MODE_PASSES.contains(name) { + if (self.view_mode != 0 || !layout::VIEW_MODE_PASSES.contains(name)) && (tiles_on || *name != layout::LIGHT_TILES_PASS) { pass.set_pipeline(pipe); pass.set_bind_group(0, &self.sc.bgs[k], &[]); let (gw, gh) = grid.size((w, h), (iw, ih), self.prev_isz); @@ -578,7 +592,7 @@ impl LiveCore { let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 2), end_of_pass_write_index: Some(slot as u32 * 8 + 3) }); let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live post"), timestamp_writes: tw }); for (k, (name, grid, pipe)) in self.passes.pipes.iter().enumerate() { - if self.view_mode == 0 && layout::VIEW_MODE_PASSES.contains(name) { + if (self.view_mode == 0 && layout::VIEW_MODE_PASSES.contains(name)) || (!tiles_on && *name == layout::LIGHT_TILES_PASS) { continue; } pass.set_pipeline(pipe); @@ -663,6 +677,7 @@ 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))); self.reset = false; self.frame = self.frame.wrapping_add(1); } diff --git a/renderer/src/live/layout.rs b/renderer/src/live/layout.rs index 18e2f26..894fcf1 100644 --- a/renderer/src/live/layout.rs +++ b/renderer/src/live/layout.rs @@ -36,6 +36,8 @@ pub(super) enum Res { KernelRefl, KernelMotion, KernelDirect, + /// The kernel's direct buffer, to write (`light_tiles`: the light tiles' lists after its per-pixel data). + KernelLights, /// The tonemap LUT and its sampler. Lut, LutSampler, @@ -83,6 +85,7 @@ pub(super) const RESOURCES: &[(&str, Res)] = &[ ("refl_res", Res::Own(Count::Pixels(1))), ("motion_in", Res::KernelMotion), ("direct_in", Res::KernelDirect), + ("light_rw", Res::KernelLights), ("lock_fwd", Res::Own(Count::Pixels(1))), ]; @@ -99,12 +102,15 @@ pub(super) enum Grid { Quarter, /// Its 16 x 16 tiles. Tiles16, + /// The light tiles' entries (`light_tiles`: a workgroup a tile, of a grid one tile wider than the traced one). + LightTiles, /// One workgroup. One, } /// The passes, in the order a frame runs them (live.wgsl's buffer table says which share a buffer when). pub(super) const PASSES: &[(&str, Grid)] = &[ + ("light_tiles", Grid::LightTiles), ("refl", Grid::Traced), ("dtemporal", Grid::Traced), ("atrous0", Grid::Traced), @@ -155,6 +161,9 @@ pub(super) const PASSES: &[(&str, Grid)] = &[ /// per-pass profile times an empty pass for each, to keep its timestamps in `PASSES`' order). pub(super) const VIEW_MODE_PASSES: &[&str] = &["view_raw", "view_hdr", "view_sample"]; +/// The light tiles' pass: run only in a scene whose local lights are resampled (more than the kernel sums exactly). +pub(super) const LIGHT_TILES_PASS: &str = "light_tiles"; + impl Grid { /// Invocations, for an output of `out`, a traced grid of `traced` and the previous frame's `prev`. pub(super) fn size(self, out: (u32, u32), traced: (u32, u32), prev: (u32, u32)) -> (u32, u32) { @@ -164,11 +173,17 @@ impl Grid { Grid::Output => out, Grid::Quarter => (out.0.div_ceil(4), out.1.div_ceil(4)), Grid::Tiles16 => (out.0.div_ceil(16), out.1.div_ceil(16)), + Grid::LightTiles => (light_tiles(traced.0) * 8, light_tiles(traced.1) * 8), Grid::One => (8, 8), } } } +/// Light tiles across `n` traced pixels: 8 pixels each, and one more for the grid's offset. +pub(super) fn light_tiles(n: u32) -> u32 { + n.div_ceil(8) + 1 +} + /// The binding live.wgsl gives `name`. pub(super) fn binding(name: &str) -> &'static Binding { BINDINGS.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("live.wgsl binds no `{name}`")) @@ -203,6 +218,7 @@ pub(super) fn check() { let fits = match (res, &b.kind) { (Res::Params, Kind::Uniform) | (Res::Lut, Kind::Texture(_)) | (Res::LutSampler, Kind::Sampler) => true, (Res::Samples | Res::KernelRefl | Res::KernelMotion | Res::KernelDirect, Kind::Storage { read_only, .. }) => *read_only, + (Res::KernelLights, Kind::Storage { read_only, .. }) => !*read_only, (Res::Own(Count::Declared), Kind::Storage { len, .. }) => *len > 0, (Res::Own(_), Kind::Storage { len, .. }) => *len == 0, _ => false, diff --git a/renderer/src/live/load.rs b/renderer/src/live/load.rs index c6c520b..105256e 100644 --- a/renderer/src/live/load.rs +++ b/renderer/src/live/load.rs @@ -241,6 +241,7 @@ pub(super) fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &P if base.ltree[3] > 0.5 { base.ltree[3] = crate::ltree::live_candidates() as f32; } + base.ltile[3] = super::list_candidates(); base.cam_fwd[3] = w as f32 / h as f32; let bgs = targets.bind_groups(&g.device, passes, &up.accum); let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); diff --git a/renderer/src/live/mod.rs b/renderer/src/live/mod.rs index 1b1366f..1f8b8b0 100644 --- a/renderer/src/live/mod.rs +++ b/renderer/src/live/mod.rs @@ -131,6 +131,15 @@ struct LiveParams { jit: [f32; 4], /// Which half of the lasting buffers is this frame's (live.wgsl `this_half`; the other the previous frame's), -. state: [u32; 4], + /// The light tiles (live.wgsl `light_tiles`): this frame's grid offset x, y, the lists' first vec4 in the kernel's + /// direct buffer, tiles a row. + lt: [u32; 4], +} + +/// Resampling candidates a vertex the camera sees takes from its pixel's light tile, beside the kernel's RIS_M +/// uniform ones (`Params::ltile.w`; THREERT_LT_LIST overrides: A/B, 0 = none). +pub(super) fn list_candidates() -> u32 { + std::env::var("THREERT_LT_LIST").ok().and_then(|v| v.parse().ok()).unwrap_or(4) } /// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). @@ -344,6 +353,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))>, /// 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, @@ -536,7 +549,7 @@ impl LiveCore { // brings it, `gpu::loop_guard_of`.) let guard_read = (gpu::loop_guard_of(!g.hwrt).is_some() && targets.kernel.guard.is_some()) .then(|| (g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("loop guard read"), size: 4, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::new(AtomicU32::new(0)))); - let mut core = LiveCore { particle_spawns: Default::default(), grid_sources: Default::default(), g, passes, targets, sc, presets: presets.to_path_buf(), assets: assets.to_path_buf(), w, h, seq: 0, aspect: w as f32 / h as f32, cam, prev_cam: cam, frame: 0, history_len: 0.0, reset: true, bloom: ([0.0; 6], [0.0; 6]), vignette: 0.0, post: [0.0; 4], blur: (0.0, 0.0), inflight: Default::default(), gpu_ms: 0.0, gpu_frame: 0, gpu_trace_ms: 0.0, edit_ms: 0.0, instanced_ms: 0.0, edit_split: EditSplit::default(), rebuild_ms: 0.0, fallback: None, fallbacks: 0, bounces: 4, rays: true, view_mode: std::env::var("THREERT_VIEW_MODE").ok().and_then(|v| v.parse().ok()).unwrap_or(0), fixed_dt: None, motion_blur_off: false, triangles, scene_name, time: 0.0, frame_dt: 1.0 / 60.0, last_frame: Instant::now(), post_key: String::new(), render_scale: 1.0, dynamic_res: false, min_scale: MIN_SCALE, display_scale: 1.0, over_budget: (0, 0), born: Instant::now(), governed: Instant::now(), profile, timer, raster, cache, kernel_bg, prev_span: None, reposed: false, emitters_moved: false, cold_frames: 0, pose_span: None, pose_reposed: false, inst_moving: vec![], inst_moved: false, prev_isz: (0, 0), lit_changed: 0, spark_lights: false, shown_spp: 1, cpu_skin_ms: 0.0, cpu_upload_ms: 0.0, gpu_blas_ms: 0.0, cpu_wait_ms: 0.0, sky_cover: 0.0, guard_trips: guard_read.as_ref().map(|_| 0), guard_read }; + let mut core = LiveCore { particle_spawns: Default::default(), grid_sources: Default::default(), g, passes, targets, sc, presets: presets.to_path_buf(), assets: assets.to_path_buf(), w, h, seq: 0, aspect: w as f32 / h as f32, cam, prev_cam: cam, frame: 0, history_len: 0.0, reset: true, bloom: ([0.0; 6], [0.0; 6]), vignette: 0.0, post: [0.0; 4], blur: (0.0, 0.0), inflight: Default::default(), gpu_ms: 0.0, gpu_frame: 0, gpu_trace_ms: 0.0, edit_ms: 0.0, instanced_ms: 0.0, edit_split: EditSplit::default(), rebuild_ms: 0.0, fallback: None, fallbacks: 0, bounces: 4, rays: true, view_mode: std::env::var("THREERT_VIEW_MODE").ok().and_then(|v| v.parse().ok()).unwrap_or(0), fixed_dt: None, motion_blur_off: false, triangles, scene_name, time: 0.0, frame_dt: 1.0 / 60.0, last_frame: Instant::now(), post_key: String::new(), render_scale: 1.0, dynamic_res: false, min_scale: MIN_SCALE, display_scale: 1.0, over_budget: (0, 0), born: Instant::now(), governed: Instant::now(), profile, timer, raster, cache, kernel_bg, prev_span: None, reposed: false, emitters_moved: false, cold_frames: 0, pose_span: None, pose_reposed: false, inst_moving: vec![], inst_moved: false, prev_isz: (0, 0), light_tiles: None, lit_changed: 0, spark_lights: false, shown_spp: 1, cpu_skin_ms: 0.0, cpu_upload_ms: 0.0, gpu_blas_ms: 0.0, cpu_wait_ms: 0.0, sky_cover: 0.0, guard_trips: guard_read.as_ref().map(|_| 0), guard_read }; core.spark_lights = core.sc.tables.emitter_tris.iter().any(|e| e.nominal); core.sky_cover = core.sc.tables.cloud_cover; core.apply_post(&pp); diff --git a/renderer/src/live/targets.rs b/renderer/src/live/targets.rs index ea3535e..09297b2 100644 --- a/renderer/src/live/targets.rs +++ b/renderer/src/live/targets.rs @@ -166,7 +166,8 @@ impl Targets { params: d.create_buffer(&wgpu::BufferDescriptor { label: Some("live params"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), bufs, out, - kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, n * 16), + // (The direct buffer: a vec4 an output pixel, then the light tiles' lists, 64 vec4s a tile.) + kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, (n + (layout::light_tiles(w) * layout::light_tiles(h)) as u64 * 64) * 16), lut_view: lut_tex.create_view(&Default::default()), lut_tex, sampler: d.create_sampler(&wgpu::SamplerDescriptor { mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }), @@ -182,7 +183,7 @@ impl Targets { match layout::resource(b) { layout::Res::KernelRefl => &self.kernel.refl, layout::Res::KernelMotion => &self.kernel.motion, - layout::Res::KernelDirect => &self.kernel.direct, + layout::Res::KernelDirect | layout::Res::KernelLights => &self.kernel.direct, layout::Res::Own(_) => &self.bufs.iter().find(|x| x.0 == b.binding).expect("an own buffer").1, r => panic!("`{}` is bound to {r:?}, not a buffer of the passes'", b.name), } From 21bbb8246eae67b92747402da7743e067ae1ba16 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 02:44:41 +0800 Subject: [PATCH 03/10] Light tiles behind F_LISTS; a mask for the list lookups; a test The lists' kernel code is compiled only for scenes with more than 8 local lights (FEAT bit F_LISTS): others get main's kernel (Bistro trace 8.50 / 8.50 ms). Each list carries a 64-bit mask of its lights' low index bits, so most uniform candidates skip the search. 2 list candidates by default. light/many-shadowed(-live): 24 shadowed point lights, 10 hidden, analytic. Co-Authored-By: Claude Fable 5.2 --- renderer/shaders/live.wgsl | 13 ++++++++--- renderer/shaders/pathtrace.wgsl | 26 +++++++++++++--------- renderer/src/gpu.rs | 7 ++++-- renderer/src/live/frame.rs | 8 +++---- renderer/src/live/mod.rs | 2 +- tests/render/light-many-shadowed-live.ts | 7 ++++++ tests/render/light-many-shadowed.ts | 28 ++++++++++++++++++++++++ 7 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 tests/render/light-many-shadowed-live.ts create mode 100644 tests/render/light-many-shadowed.ts diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index fd385d0..d9c7be0 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -245,8 +245,9 @@ const RESOLVE_N: u32 = 8u; // reprojection. A pixel's own ray is one random direction of the lobe (or none, when it took the diffuse lobe), so // its distance alone put the mirror image anywhere along the ray. // ---- The light tiles: each 8 x 8 tile's pixels' reports made its list (pathtrace.wgsl "Light tiles": the lights -// reported, each once: index, reports; ascending; the 64th vec4 holds their number, 63 at most). A workgroup is a -// tile: the grid is the traced one shifted by L.lt.xy, another offset each frame, so tile borders do not stay put. +// reported, each once: index, reports; ascending; the 64th vec4 holds their number, 63 at most, and a 64-bit mask of +// their indices' low 6 bits). A workgroup is a tile: the grid is the traced one shifted by L.lt.xy, another offset +// each frame, so tile borders do not stay put. const LT_NONE: u32 = 0xf0000000u; var lt_keys: array; var lt_first: array; @@ -283,7 +284,13 @@ fn light_tiles(@builtin(global_invocation_id) gid: vec3, @builtin(local_inv let list = L.lt.z + (wg.y * L.lt.w + wg.x) * 64u; let at = less.x + less.y + less.z + less.w; if (first && at < 63u) { light_rw[list + at] = vec4(bitcast(key), bitcast(same.x + same.y + same.z + same.w), 0.0, 0.0); } - if (li == 0u) { light_rw[list + 63u] = vec4(bitcast(min(all.x + all.y + all.z + all.w, 63u)), 0.0, 0.0, 0.0); } + if (li == 0u) { + var mask = vec2(0u); + for (var j = 0u; j < 64u; j++) { + if (lt_first[j] != 0u) { mask[(lt_keys[j] >> 5u) & 1u] |= 1u << (lt_keys[j] & 31u); } + } + light_rw[list + 63u] = vec4(bitcast(min(all.x + all.y + all.z + all.w, 63u)), bitcast(mask.x), bitcast(mask.y), 0.0); + } } @compute @workgroup_size(8, 8, 1) diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 0b67836..15d8062 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -78,6 +78,7 @@ const F_UNLIT: u32 = 1048576u; // MeshBasicMaterial (MAT_UNLIT) const F_BACKGROUND: u32 = 2097152u; // scene.background (`sky_mis`) const F_VCOLOR: u32 = 4194304u; // vertex colours (`vcolor`) const F_GRID: u32 = 33554432u; // density grids (`vgrid`) +const F_LISTS: u32 = 134217728u; // light tiles (`lt_list_start`): a scene whose local lights are resampled fn has(f: u32) -> bool { return (FEAT & f) != 0u; } struct Params { @@ -1689,9 +1690,10 @@ fn local_light(li: Light, x: vec3, u: vec2) -> LS { // Light tiles (live; what guides MegaLights' draws and RTXPT's): each camera pixel reports the local light its // 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: +// index, reports; ascending; the last vec4 their number and a mask of their indices' low 6 bits), 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`). Compiled in for scenes whose local lights are resampled (F_LISTS). 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. const LT_TILE: u32 = 8u; @@ -1699,6 +1701,7 @@ const LT_LIST: u32 = 64u; const LT_NONE: u32 = 0xf0000000u; var lt_list: u32; var lt_list_n: u32; +var lt_mask: vec2; var lt_fb: u32; // The list of the tile pixel `px` was in when the lists were built, and how many lights it holds. @@ -1710,11 +1713,14 @@ fn lt_list_start(px: vec2) { 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; } - lt_list_n = min(bitcast(direct_load(lt_list + LT_LIST - 1u).x), LT_LIST - 1u); + let last = direct_load(lt_list + LT_LIST - 1u); + lt_list_n = min(bitcast(last.x), LT_LIST - 1u); + lt_mask = vec2(bitcast(last.y), bitcast(last.z)); } -// Whether the pixel's list holds light `i`. +// Whether the pixel's list holds light `i` (the mask says no for most that it does not, without a search). fn lt_in_list(i: u32) -> bool { + if (((lt_mask[(i >> 5u) & 1u] >> (i & 31u)) & 1u) == 0u) { return false; } var lo = 0u; var hi = lt_list_n; for (var k = 0u; k < 7u; k++) { @@ -1779,7 +1785,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, // With the pixel's list (light tiles), `nt` candidates more come from it, one in each `step` of its lights, and // the uniform ones keep the lights it lacks; each weighed by how many lights it stands for. var nt = 0u; - if (!vol && lt_list_n > 0u) { nt = min(select(P.ltile.w / 2u, P.ltile.w, seen_directly()), lt_list_n); } + if (has(F_LISTS) && !vol && lt_list_n > 0u) { nt = min(select(P.ltile.w / 2u, P.ltile.w, seen_directly()), lt_list_n); } let step = f32(lt_list_n) / f32(max(nt, 1u)); let each = select(1.0, f32(count) / f32(m_cand), nt > 0u); var at = 0.0; @@ -1826,7 +1832,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, sum += r; if (!vol) { dl_spec += r * split_frac; } // The light tiles' report: the light the camera hit's ray found. - if (!vol && cur_depth == 0u) { lt_fb = pick; } + if (has(F_LISTS) && !vol && cur_depth == 0u) { lt_fb = pick; } } } } @@ -3157,8 +3163,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let pix = gid.y * w + gid.x; // 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, bitcast(LT_NONE))); } - lt_list_start(gid.xy); + if (LIVE && P.misc.w < 0.5 && direct_len() >= w * h) { direct_store(pix, vec4(0.0, 0.0, 0.0, select(0.0, bitcast(LT_NONE), has(F_LISTS)))); } + if (has(F_LISTS)) { lt_list_start(gid.xy); } rng_start(gid.xy, P.size.z, P.size.w, 0u); cur_depth = 0u; let cam = rng4(rng_dim(D_CAMERA)); @@ -3461,7 +3467,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { if (LIVE && depth == 0u && direct_len() >= P.size.x * P.size.y && !non_finite(dl_det) && !non_finite(dl_det_spec)) { radiance -= thr * dl_det; dl_spec = max(dl_spec - dl_det_spec, vec3(0.0)); - if (P.misc.w < 0.5) { direct_store(gid.y * P.size.x + gid.x, vec4(thr * dl_det, bitcast(lt_fb))); } + if (P.misc.w < 0.5) { direct_store(gid.y * P.size.x + gid.x, vec4(thr * dl_det, select(0.0, bitcast(lt_fb), has(F_LISTS)))); } } let l = sample_bsdf(s, lb, v); diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index 6075582..10b5f1d 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -1359,6 +1359,9 @@ pub const F_VCOLOR: u32 = 1 << 22; /// Density grids (`scene::GridBuild`): the grid atlas (binding 19) and `vgrid` for volume functions. (Bit 24 is /// F_AERIAL's.) pub const F_GRID: u32 = 1 << 25; +/// More local lights than the kernel sums exactly (pathtrace.wgsl RIS_EXACT): they are resampled, guided live by the +/// light tiles (pathtrace.wgsl "Light tiles"; the live view's `light_tiles` pass runs for such a scene). +pub const F_LISTS: u32 = 1 << 27; /// The features that change the kernel's source (an `@if` flag) rather than switch code in it (`FEAT`). pub const F_SOURCE: u32 = F_UNITS; /// Software traversal's units (units.rs; shaders/trace_units.wgsl): the second level's walk, spliced into the @@ -1394,7 +1397,7 @@ pub fn instance_features(scene: &Scene) -> u32 { // (F_GRID also for a volume function that calls `vgrid` on a node without a grid: it reads zeros.) let grid = !scene.grids.is_empty() || (!scene.media.is_empty() && scene.volume_fns.iter().any(|(_, code, helpers)| code.contains("vgrid(") || helpers.contains("vgrid("))); let media = if scene.media.is_empty() { 0 } else { F_MEDIA } | if scene.media.iter().any(|m| m.a[2] != 0) { F_HMEDIA } else { 0 } | if grid { F_GRID } else { 0 }; - let particles = if scene.particles.iter().any(|p| !p.translucent) || !scene.gpu_particles.is_empty() { F_PEMIT } else { 0 } | if scene.procs.is_empty() { 0 } else { F_PROC } | if scene.tinted { F_TINT } else { 0 } | if scene.background.is_some() { F_BACKGROUND } else { 0 } | if scene.gpu_vcolors.is_empty() { 0 } else { F_VCOLOR }; + let particles = if scene.particles.iter().any(|p| !p.translucent) || !scene.gpu_particles.is_empty() { F_PEMIT } else { 0 } | if scene.procs.is_empty() { 0 } else { F_PROC } | if scene.tinted { F_TINT } else { 0 } | if scene.background.is_some() { F_BACKGROUND } else { 0 } | if scene.gpu_vcolors.is_empty() { 0 } else { F_VCOLOR } | if scene.lights.iter().filter(|l| l.pos[3] != 0.0).count() > 8 { F_LISTS } else { 0 }; if scene.gpu_instances.is_empty() { return media | particles; } @@ -3367,7 +3370,7 @@ mod tests { ("F_MASKED", F_MASKED), ("F_FOG", F_FOG), ("F_VOLUME", F_VOLUME), ("F_TEX", F_TEX), ("F_FUNC", F_FUNC), ("F_GLASS", F_GLASS), ("F_COAT", F_COAT), ("F_HUGE", F_HUGE), ("F_INST", F_INST), ("F_MEDIA", F_MEDIA), ("F_PEMIT", F_PEMIT), ("F_HMEDIA", F_HMEDIA), ("F_PROC", F_PROC), ("F_TINT", F_TINT), ("F_UNLIT", F_UNLIT), ("F_BACKGROUND", F_BACKGROUND), - ("F_VCOLOR", F_VCOLOR), ("F_AERIAL", F_AERIAL), ("F_GRID", F_GRID), + ("F_VCOLOR", F_VCOLOR), ("F_AERIAL", F_AERIAL), ("F_GRID", F_GRID), ("F_LISTS", F_LISTS), ]; let mut seen = 0u32; for (name, bit) in all { diff --git a/renderer/src/live/frame.rs b/renderer/src/live/frame.rs index 803b1b1..175c9c4 100644 --- a/renderer/src/live/frame.rs +++ b/renderer/src/live/frame.rs @@ -351,10 +351,10 @@ impl LiveCore { let phases = ((8.0 * (w as f32 / iw as f32) * (h as f32 / ih as f32)).round() as u32).clamp(8, 512); let quant = |v: f32| (v * 4095.0).round() / 4095.0; let jitter = (quant(halton(self.frame % phases + 1, 2)), quant(halton(self.frame % phases + 1, 3))); - // The light tiles (pathtrace.wgsl "Light tiles"), in a scene whose local lights are resampled (more than the - // kernel's RIS_EXACT, 8): the lists the last frame built, for this frame's paths (none after a reset: its - // reports were another view's), and this frame's grid offset, a pixel of 8 x 8. - let tiles_on = self.sc.base.counts[0] - self.sc.base.counts2[0] > 8; + // The light tiles (pathtrace.wgsl "Light tiles"), in a scene whose local lights are resampled (gpu::F_LISTS): + // the lists the last frame built, for this frame's paths (none after a reset: its reports were another + // view's), and this frame's grid offset, a pixel of 8 x 8. + let tiles_on = self.sc.up.kernel.features & gpu::F_LISTS != 0; if self.reset || !tiles_on { self.light_tiles = None; } diff --git a/renderer/src/live/mod.rs b/renderer/src/live/mod.rs index 1f8b8b0..00f109b 100644 --- a/renderer/src/live/mod.rs +++ b/renderer/src/live/mod.rs @@ -139,7 +139,7 @@ struct LiveParams { /// Resampling candidates a vertex the camera sees takes from its pixel's light tile, beside the kernel's RIS_M /// uniform ones (`Params::ltile.w`; THREERT_LT_LIST overrides: A/B, 0 = none). pub(super) fn list_candidates() -> u32 { - std::env::var("THREERT_LT_LIST").ok().and_then(|v| v.parse().ok()).unwrap_or(4) + std::env::var("THREERT_LT_LIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2) } /// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). diff --git a/tests/render/light-many-shadowed-live.ts b/tests/render/light-many-shadowed-live.ts new file mode 100644 index 0000000..a5d55ed --- /dev/null +++ b/tests/render/light-many-shadowed-live.ts @@ -0,0 +1,7 @@ +// light/many-shadowed in the real-time renderer: the resampled lights guided by the light tiles. +import def, { test as base } from "./light-many-shadowed.ts"; +import type { TestMeta } from "./lib.ts"; + +export const test: TestMeta = { ...base, id: "light/many-shadowed-live", temporal: true, live: 120 }; + +export default def; diff --git a/tests/render/light-many-shadowed.ts b/tests/render/light-many-shadowed.ts new file mode 100644 index 0000000..50928f3 --- /dev/null +++ b/tests/render/light-many-shadowed.ts @@ -0,0 +1,28 @@ +// More shadow-casting local lights than the kernel sums exactly (8), so they are resampled: 24 point lights of 300 cd +// on a ring of radius 2 m, 2 m above an albedo-0.5 Lambert plane, and a black slab at 1 m over x >= 0.3 that hides +// the 10 of them with x >= 0.6 from the centre. The other 14 give L = 0.5 / pi x 14 x 300 x cos / d^2 there +// (d^2 = 8, cos = 2 / sqrt 8). Live, the lights are guided by the light tiles (pathtrace.wgsl "Light tiles"). +import { BoxGeometry, Mesh, PointLight } from "@threert/scene"; +import { camera, lambert, plane, result, sceneWithSky, type TestMeta } from "./lib.ts"; + +export const test: TestMeta = { + id: "light/many-shadowed", features: ["point-light", "lambert", "shadows"], reference: "analytic", temporal: false, + metric: { kind: "radiance", region: [0.48, 0.48, 0.52, 0.52], expected: (0.5 / Math.PI) * 14 * 300 * (2 / Math.sqrt(8)) / 8, tolerance: 0.03 }, + note: "24 shadow-casting point lights, 10 hidden by a slab: the 14 others' sum at the centre", +}; + +export default function () { + const scene = sceneWithSky(0); + const lights = Array.from({ length: 24 }, (_, k) => { + const a = ((k + 0.5) * 15 * Math.PI) / 180; + const l = new PointLight(0xffffff, 300, 0); + l.name = `lamp-${k}`; + l.radius = 0.001; + l.position.set(2 * Math.cos(a), 2, 2 * Math.sin(a)); + return l; + }); + const slab = new Mesh(new BoxGeometry(6, 0.05, 12), lambert(0, "slab")); + slab.name = "slab"; + slab.position.set(3.3, 1, 0); + return result(scene, camera(scene, [0, 1.5, 3], [0, 0, 0]), [...lights, slab, plane(20, lambert(0.5))]); +} From 1687d02961f7c63745c3c0e90ae76c26962ce2d6 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 03:03:47 +0800 Subject: [PATCH 04/10] F_LISTS by the shadow-casting lights; the lookup mask out Lists are fed by the camera hit's resampling, which covers shadow-casting lights only: a scene without more than 8 of them (Kabukicho: none) keeps main's kernel. The mask saved nothing in the trace and cost the pass 0.11 ms. Co-Authored-By: Claude Fable 5.2 --- renderer/shaders/live.wgsl | 13 +++---------- renderer/shaders/pathtrace.wgsl | 21 ++++++++------------- renderer/src/gpu.rs | 7 ++++--- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index d9c7be0..fd385d0 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -245,9 +245,8 @@ const RESOLVE_N: u32 = 8u; // reprojection. A pixel's own ray is one random direction of the lobe (or none, when it took the diffuse lobe), so // its distance alone put the mirror image anywhere along the ray. // ---- The light tiles: each 8 x 8 tile's pixels' reports made its list (pathtrace.wgsl "Light tiles": the lights -// reported, each once: index, reports; ascending; the 64th vec4 holds their number, 63 at most, and a 64-bit mask of -// their indices' low 6 bits). A workgroup is a tile: the grid is the traced one shifted by L.lt.xy, another offset -// each frame, so tile borders do not stay put. +// reported, each once: index, reports; ascending; the 64th vec4 holds their number, 63 at most). A workgroup is a +// tile: the grid is the traced one shifted by L.lt.xy, another offset each frame, so tile borders do not stay put. const LT_NONE: u32 = 0xf0000000u; var lt_keys: array; var lt_first: array; @@ -284,13 +283,7 @@ fn light_tiles(@builtin(global_invocation_id) gid: vec3, @builtin(local_inv let list = L.lt.z + (wg.y * L.lt.w + wg.x) * 64u; let at = less.x + less.y + less.z + less.w; if (first && at < 63u) { light_rw[list + at] = vec4(bitcast(key), bitcast(same.x + same.y + same.z + same.w), 0.0, 0.0); } - if (li == 0u) { - var mask = vec2(0u); - for (var j = 0u; j < 64u; j++) { - if (lt_first[j] != 0u) { mask[(lt_keys[j] >> 5u) & 1u] |= 1u << (lt_keys[j] & 31u); } - } - light_rw[list + 63u] = vec4(bitcast(min(all.x + all.y + all.z + all.w, 63u)), bitcast(mask.x), bitcast(mask.y), 0.0); - } + if (li == 0u) { light_rw[list + 63u] = vec4(bitcast(min(all.x + all.y + all.z + all.w, 63u)), 0.0, 0.0, 0.0); } } @compute @workgroup_size(8, 8, 1) diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 15d8062..4da5c9d 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -78,7 +78,7 @@ const F_UNLIT: u32 = 1048576u; // MeshBasicMaterial (MAT_UNLIT) const F_BACKGROUND: u32 = 2097152u; // scene.background (`sky_mis`) const F_VCOLOR: u32 = 4194304u; // vertex colours (`vcolor`) const F_GRID: u32 = 33554432u; // density grids (`vgrid`) -const F_LISTS: u32 = 134217728u; // light tiles (`lt_list_start`): a scene whose local lights are resampled +const F_LISTS: u32 = 134217728u; // light tiles (`lt_list_start`): a scene whose shadow-casting local lights are resampled fn has(f: u32) -> bool { return (FEAT & f) != 0u; } struct Params { @@ -1690,18 +1690,16 @@ fn local_light(li: Light, x: vec3, u: vec2) -> LS { // Light tiles (live; what guides MegaLights' draws and RTXPT's): each camera pixel reports the local light its // 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 a mask of their indices' low 6 bits), 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`). Compiled in for scenes whose local lights are resampled (F_LISTS). 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. +// 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). const LT_TILE: u32 = 8u; const LT_LIST: u32 = 64u; const LT_NONE: u32 = 0xf0000000u; var lt_list: u32; var lt_list_n: u32; -var lt_mask: vec2; var lt_fb: u32; // The list of the tile pixel `px` was in when the lists were built, and how many lights it holds. @@ -1713,14 +1711,11 @@ fn lt_list_start(px: vec2) { 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; } - let last = direct_load(lt_list + LT_LIST - 1u); - lt_list_n = min(bitcast(last.x), LT_LIST - 1u); - lt_mask = vec2(bitcast(last.y), bitcast(last.z)); + lt_list_n = min(bitcast(direct_load(lt_list + LT_LIST - 1u).x), LT_LIST - 1u); } -// Whether the pixel's list holds light `i` (the mask says no for most that it does not, without a search). +// Whether the pixel's list holds light `i`. fn lt_in_list(i: u32) -> bool { - if (((lt_mask[(i >> 5u) & 1u] >> (i & 31u)) & 1u) == 0u) { return false; } var lo = 0u; var hi = lt_list_n; for (var k = 0u; k < 7u; k++) { diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index 10b5f1d..8436c87 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -1359,8 +1359,9 @@ pub const F_VCOLOR: u32 = 1 << 22; /// Density grids (`scene::GridBuild`): the grid atlas (binding 19) and `vgrid` for volume functions. (Bit 24 is /// F_AERIAL's.) pub const F_GRID: u32 = 1 << 25; -/// More local lights than the kernel sums exactly (pathtrace.wgsl RIS_EXACT): they are resampled, guided live by the -/// light tiles (pathtrace.wgsl "Light tiles"; the live view's `light_tiles` pass runs for such a scene). +/// More shadow-casting local lights than the kernel sums exactly at a camera hit (pathtrace.wgsl RIS_EXACT): they +/// are resampled, guided live by the light tiles (pathtrace.wgsl "Light tiles"; the live view's `light_tiles` pass +/// runs for such a scene). pub const F_LISTS: u32 = 1 << 27; /// The features that change the kernel's source (an `@if` flag) rather than switch code in it (`FEAT`). pub const F_SOURCE: u32 = F_UNITS; @@ -1397,7 +1398,7 @@ pub fn instance_features(scene: &Scene) -> u32 { // (F_GRID also for a volume function that calls `vgrid` on a node without a grid: it reads zeros.) let grid = !scene.grids.is_empty() || (!scene.media.is_empty() && scene.volume_fns.iter().any(|(_, code, helpers)| code.contains("vgrid(") || helpers.contains("vgrid("))); let media = if scene.media.is_empty() { 0 } else { F_MEDIA } | if scene.media.iter().any(|m| m.a[2] != 0) { F_HMEDIA } else { 0 } | if grid { F_GRID } else { 0 }; - let particles = if scene.particles.iter().any(|p| !p.translucent) || !scene.gpu_particles.is_empty() { F_PEMIT } else { 0 } | if scene.procs.is_empty() { 0 } else { F_PROC } | if scene.tinted { F_TINT } else { 0 } | if scene.background.is_some() { F_BACKGROUND } else { 0 } | if scene.gpu_vcolors.is_empty() { 0 } else { F_VCOLOR } | if scene.lights.iter().filter(|l| l.pos[3] != 0.0).count() > 8 { F_LISTS } else { 0 }; + let particles = if scene.particles.iter().any(|p| !p.translucent) || !scene.gpu_particles.is_empty() { F_PEMIT } else { 0 } | if scene.procs.is_empty() { 0 } else { F_PROC } | if scene.tinted { F_TINT } else { 0 } | if scene.background.is_some() { F_BACKGROUND } else { 0 } | if scene.gpu_vcolors.is_empty() { 0 } else { F_VCOLOR } | if scene.lights.iter().filter(|l| l.pos[3] != 0.0 && l.ext[3] >= 0.5).count() > 8 { F_LISTS } else { 0 }; if scene.gpu_instances.is_empty() { return media | particles; } From fd7f659e8b4d379b132126e1fe1a2f8b7427931d Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 03:04:17 +0800 Subject: [PATCH 05/10] Docs: light tiles Co-Authored-By: Claude Fable 5.2 --- docs/ROADMAP.md | 10 ++++++++++ docs/RUNTIME.md | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e935d4e..135b6b5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2325,6 +2325,16 @@ Cornell box, Light Room and Bistro, means within 0.01 %. Live only the camera pa (as RTXPT; all-Sobol cost 2.1 ms of an 8.4 ms trace): cost and the live shots (bench-window) unchanged. The loop guard now skips counted loops. Next: blue-noise pixel distribution (Cycles 4.2), its own PR. +**Local lights guided by light tiles (2026-11-15, after it).** The live kernel chose among shadow-casting local +lights uniformly: with many of them, most behind walls, few candidates found a light that reaches the point. Now each +camera pixel reports the light its shadow ray found, a pass makes each 8 x 8 tile's reports a list, and the next +frame's paths take candidates from their tile's list beside the uniform ones (docs/RUNTIME.md "Light tiles"; the +scheme MegaLights and RTXPT guide their draws by). Unbiased whatever a list holds. Measured on two new shots +(`scenes/lamp-rooms.ts`, 96 lamps in 12 rooms; `lamp-rooms-wide.ts`, 768; bench-window `still-rooms`, +`still-rooms-wide`), dE / flicker: 0.67 / 0.138 -> 0.63 / 0.069 and 1.01 / 0.126 -> 0.58 / 0.081, for 0.65 ms of +trace and a 0.22 ms pass (of 9.2 ms a frame). Scenes without more than 8 shadow-casting local lights compile and +run as before (Bistro, Kabukichō: picture and cost unchanged). + ## 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 1cc2f9c..70eaa9b 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -1279,6 +1279,19 @@ offline, the frame live; 16 bits), ids are uncorrelated. shader against itself on the GPU. - The loop guard leaves counted loops (`for (var i = N; i < M; i++)`, literals) alone, as Tint does. +## Light tiles + +Live, a scene with more than 8 shadow-casting local lights (`gpu::F_LISTS`) guides their resampling by what the last +frame found (pathtrace.wgsl "Light tiles", live.wgsl `light_tiles`): +- A camera hit's resampled shadow ray that reaches its light reports the light (the direct buffer's fourth + 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. +- THREERT_LT_LIST=n: the list candidates (0: none), for A/B. + ## What the kernel trusts The kernel follows indices it reads from buffers and loops over counts it is given; it checks none of them (a From b6831e2ff9eddd4643c5328ec1c0288c51a94691 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 03:18:06 +0800 Subject: [PATCH 06/10] Light tiles: one formula, one constant, no switch, a named place - The resampling's weight is one expression with or without a list (a list's candidate weighed relative to a uniform one's). - RIS_EXACT is a constant in gpu.rs, held to the shader's by a test. - THREERT_LT_LIST is gone (LIST_CANDIDATES). - The lists' place in the direct buffer comes from layout::light_lists. - light/many-shadowed at 1024 spp (64 left ~2 % of noise in its patch). Co-Authored-By: Claude Fable 5.2 --- docs/RUNTIME.md | 1 - renderer/shaders/pathtrace.wgsl | 12 ++++++------ renderer/src/gpu.rs | 13 ++++++++----- renderer/src/live/frame.rs | 2 +- renderer/src/live/layout.rs | 6 ++++++ renderer/src/live/load.rs | 2 +- renderer/src/live/mod.rs | 6 ++---- renderer/src/live/targets.rs | 4 ++-- tests/render/light-many-shadowed-live.ts | 5 +++-- tests/render/light-many-shadowed.ts | 17 +++++++++++------ 10 files changed, 40 insertions(+), 28 deletions(-) diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 70eaa9b..182685f 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -1290,7 +1290,6 @@ frame found (pathtrace.wgsl "Light tiles", live.wgsl `light_tiles`): - 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. -- THREERT_LT_LIST=n: the list candidates (0: none), for A/B. ## What the kernel trusts diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 4da5c9d..d9f087c 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -1778,11 +1778,12 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, // Target: unshadowed intensity x cosine (cheap; the full BSDF / phase is applied to the chosen light only). let m_cand = select(3u, RIS_M, seen_directly()); // With the pixel's list (light tiles), `nt` candidates more come from it, one in each `step` of its lights, and - // the uniform ones keep the lights it lacks; each weighed by how many lights it stands for. + // the uniform ones keep the lights it lacks. A uniform candidate stands for count / m_cand lights (weight 1 here, + // the factor applied at the end, as without a list), one of the list's for `step` (`listed`, relative to that). var nt = 0u; if (has(F_LISTS) && !vol && lt_list_n > 0u) { nt = min(select(P.ltile.w / 2u, P.ltile.w, seen_directly()), lt_list_n); } let step = f32(lt_list_n) / f32(max(nt, 1u)); - let each = select(1.0, f32(count) / f32(m_cand), nt > 0u); + let listed = step * f32(m_cand) / f32(count); var at = 0.0; if (nt > 0u) { at = rng1(rng_sub(rd ^ 0x80000000u, 0x2000u)); } var wsum = 0.0; @@ -1792,7 +1793,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, for (var k = 0u; k < m_cand + nt; k++) { var r4: vec4; var idx: u32; - var stands = each; + var stands = 1.0; if (k < m_cand) { r4 = rng4(rng_sub(rd ^ 0x80000000u, k)); idx = first + min(u32(r4.x * f32(count)), count - 1u); @@ -1800,7 +1801,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, r4 = rng4(rng_sub(rd ^ 0x80000000u, 0x1000u + k)); idx = bitcast(direct_load(lt_list + min(u32((f32(k - m_cand) + at) * step), lt_list_n - 1u)).x); if (idx < first || idx >= first + count) { continue; } - stands = step; + stands = listed; } let ls = local_light(light_at(idx), x, r4.yz); var ph = luminance(ls.c); @@ -1822,8 +1823,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, if (any(c > vec3(0.0))) { let vis = light_vis(light_at(pick).ext.w >= 0.5, o, pick_ls.sl, pick_ls.dist * 0.999, rng_sub(rd, 0xffffffffu)); if (any(vis > vec3(0.0))) { - var r = c * gtint(vis) * (wsum * f32(count) / (f32(m_cand) * pick_ph)); - if (nt > 0u) { r = c * gtint(vis) * (wsum / pick_ph); } + let r = c * gtint(vis) * (wsum * f32(count) / (f32(m_cand) * pick_ph)); sum += r; if (!vol) { dl_spec += r * split_frac; } // The light tiles' report: the light the camera hit's ray found. diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index 8436c87..ab5509f 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -117,7 +117,7 @@ pub struct Params { pub background: [f32; 4], /// The live light tiles (pathtrace.wgsl "Light tiles"; live/frame.rs): the lists' first vec4 in the direct /// buffer (0: none this frame), tiles a row, the grid's offset when they were built (x | y << 16), candidates a - /// vertex the camera sees takes from its list beside the uniform ones (`live::list_candidates`; half past it). + /// vertex the camera sees takes from its list beside the uniform ones (`live::LIST_CANDIDATES`; half past it). pub ltile: [u32; 4], /// The traced size the lists were built at, -, -. pub ltile2: [u32; 4], @@ -1359,10 +1359,12 @@ pub const F_VCOLOR: u32 = 1 << 22; /// Density grids (`scene::GridBuild`): the grid atlas (binding 19) and `vgrid` for volume functions. (Bit 24 is /// F_AERIAL's.) pub const F_GRID: u32 = 1 << 25; -/// More shadow-casting local lights than the kernel sums exactly at a camera hit (pathtrace.wgsl RIS_EXACT): they -/// are resampled, guided live by the light tiles (pathtrace.wgsl "Light tiles"; the live view's `light_tiles` pass -/// runs for such a scene). +/// More shadow-casting local lights than the kernel sums exactly at a camera hit (`RIS_EXACT`): they are resampled, +/// guided live by the light tiles (pathtrace.wgsl "Light tiles"; the live view's `light_tiles` pass runs for such a +/// scene). pub const F_LISTS: u32 = 1 << 27; +/// Local lights the kernel sums exactly (pathtrace.wgsl `RIS_EXACT`; the test below holds the two together). +pub const RIS_EXACT: usize = 8; /// The features that change the kernel's source (an `@if` flag) rather than switch code in it (`FEAT`). pub const F_SOURCE: u32 = F_UNITS; /// Software traversal's units (units.rs; shaders/trace_units.wgsl): the second level's walk, spliced into the @@ -1398,7 +1400,7 @@ pub fn instance_features(scene: &Scene) -> u32 { // (F_GRID also for a volume function that calls `vgrid` on a node without a grid: it reads zeros.) let grid = !scene.grids.is_empty() || (!scene.media.is_empty() && scene.volume_fns.iter().any(|(_, code, helpers)| code.contains("vgrid(") || helpers.contains("vgrid("))); let media = if scene.media.is_empty() { 0 } else { F_MEDIA } | if scene.media.iter().any(|m| m.a[2] != 0) { F_HMEDIA } else { 0 } | if grid { F_GRID } else { 0 }; - let particles = if scene.particles.iter().any(|p| !p.translucent) || !scene.gpu_particles.is_empty() { F_PEMIT } else { 0 } | if scene.procs.is_empty() { 0 } else { F_PROC } | if scene.tinted { F_TINT } else { 0 } | if scene.background.is_some() { F_BACKGROUND } else { 0 } | if scene.gpu_vcolors.is_empty() { 0 } else { F_VCOLOR } | if scene.lights.iter().filter(|l| l.pos[3] != 0.0 && l.ext[3] >= 0.5).count() > 8 { F_LISTS } else { 0 }; + let particles = if scene.particles.iter().any(|p| !p.translucent) || !scene.gpu_particles.is_empty() { F_PEMIT } else { 0 } | if scene.procs.is_empty() { 0 } else { F_PROC } | if scene.tinted { F_TINT } else { 0 } | if scene.background.is_some() { F_BACKGROUND } else { 0 } | if scene.gpu_vcolors.is_empty() { 0 } else { F_VCOLOR } | if scene.lights.iter().filter(|l| l.pos[3] != 0.0 && l.ext[3] >= 0.5).count() > RIS_EXACT { F_LISTS } else { 0 }; if scene.gpu_instances.is_empty() { return media | particles; } @@ -3379,6 +3381,7 @@ mod tests { seen |= bit; } let wgsl = include_str!("../shaders/pathtrace.wgsl"); + assert!(wgsl.contains(&format!("const RIS_EXACT: u32 = {RIS_EXACT}u;")), "RIS_EXACT: pathtrace.wgsl and gpu.rs differ"); for line in wgsl.lines().filter(|l| l.starts_with("const F_")) { let name = &line[6..line.find(':').unwrap()]; let value: u32 = line[line.find('=').unwrap() + 1..line.find("u;").unwrap()].trim().parse().unwrap(); diff --git a/renderer/src/live/frame.rs b/renderer/src/live/frame.rs index 175c9c4..bd1d3a6 100644 --- a/renderer/src/live/frame.rs +++ b/renderer/src/live/frame.rs @@ -358,7 +358,7 @@ impl LiveCore { if self.reset || !tiles_on { self.light_tiles = None; } - let lists = (self.targets.kernel.direct.size / 16) as u32 - layout::light_tiles(w) * layout::light_tiles(h) * 64; + let lists = layout::light_lists(w, h).0; 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; diff --git a/renderer/src/live/layout.rs b/renderer/src/live/layout.rs index 894fcf1..5f45139 100644 --- a/renderer/src/live/layout.rs +++ b/renderer/src/live/layout.rs @@ -184,6 +184,12 @@ pub(super) fn light_tiles(n: u32) -> u32 { n.div_ceil(8) + 1 } +/// The light tiles' lists in the kernel's direct buffer, for an output of `w` x `h`: after its vec4 a pixel, 64 vec4s +/// a tile. Their first vec4 and how many they are. +pub(super) fn light_lists(w: u32, h: u32) -> (u32, u32) { + (w * h, light_tiles(w) * light_tiles(h) * 64) +} + /// The binding live.wgsl gives `name`. pub(super) fn binding(name: &str) -> &'static Binding { BINDINGS.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("live.wgsl binds no `{name}`")) diff --git a/renderer/src/live/load.rs b/renderer/src/live/load.rs index 105256e..1b2c862 100644 --- a/renderer/src/live/load.rs +++ b/renderer/src/live/load.rs @@ -241,7 +241,7 @@ pub(super) fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &P if base.ltree[3] > 0.5 { base.ltree[3] = crate::ltree::live_candidates() as f32; } - base.ltile[3] = super::list_candidates(); + base.ltile[3] = super::LIST_CANDIDATES; base.cam_fwd[3] = w as f32 / h as f32; let bgs = targets.bind_groups(&g.device, passes, &up.accum); let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); diff --git a/renderer/src/live/mod.rs b/renderer/src/live/mod.rs index 00f109b..3d6518f 100644 --- a/renderer/src/live/mod.rs +++ b/renderer/src/live/mod.rs @@ -137,10 +137,8 @@ struct LiveParams { } /// Resampling candidates a vertex the camera sees takes from its pixel's light tile, beside the kernel's RIS_M -/// uniform ones (`Params::ltile.w`; THREERT_LT_LIST overrides: A/B, 0 = none). -pub(super) fn list_candidates() -> u32 { - std::env::var("THREERT_LT_LIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2) -} +/// uniform ones (`Params::ltile.w`; half past the camera hit). 2 read as 4, 8 and 16 on the lamp-rooms shots. +pub(super) const LIST_CANDIDATES: u32 = 2; /// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). #[derive(Clone, Copy, Default)] diff --git a/renderer/src/live/targets.rs b/renderer/src/live/targets.rs index 09297b2..3b255c2 100644 --- a/renderer/src/live/targets.rs +++ b/renderer/src/live/targets.rs @@ -166,8 +166,8 @@ impl Targets { params: d.create_buffer(&wgpu::BufferDescriptor { label: Some("live params"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), bufs, out, - // (The direct buffer: a vec4 an output pixel, then the light tiles' lists, 64 vec4s a tile.) - kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, (n + (layout::light_tiles(w) * layout::light_tiles(h)) as u64 * 64) * 16), + // (The direct buffer: a vec4 an output pixel, then the light tiles' lists: `layout::light_lists`.) + kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, (n + layout::light_lists(w, h).1 as u64) * 16), lut_view: lut_tex.create_view(&Default::default()), lut_tex, sampler: d.create_sampler(&wgpu::SamplerDescriptor { mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }), diff --git a/tests/render/light-many-shadowed-live.ts b/tests/render/light-many-shadowed-live.ts index a5d55ed..acaa86d 100644 --- a/tests/render/light-many-shadowed-live.ts +++ b/tests/render/light-many-shadowed-live.ts @@ -1,7 +1,8 @@ -// light/many-shadowed in the real-time renderer: the resampled lights guided by the light tiles. +// light/many-shadowed in the real-time renderer: the resampled lights guided by the light tiles. (3 %: its readings +// spread 0.8 % either way with the seed; without the guiding it read 2 % low, light lost to the samples' clamp.) import def, { test as base } from "./light-many-shadowed.ts"; import type { TestMeta } from "./lib.ts"; -export const test: TestMeta = { ...base, id: "light/many-shadowed-live", temporal: true, live: 120 }; +export const test: TestMeta = { ...base, id: "light/many-shadowed-live", metric: { ...base.metric, tolerance: 0.03 }, temporal: true, live: 120 }; export default def; diff --git a/tests/render/light-many-shadowed.ts b/tests/render/light-many-shadowed.ts index 50928f3..78d6fc9 100644 --- a/tests/render/light-many-shadowed.ts +++ b/tests/render/light-many-shadowed.ts @@ -1,20 +1,25 @@ // More shadow-casting local lights than the kernel sums exactly (8), so they are resampled: 24 point lights of 300 cd -// on a ring of radius 2 m, 2 m above an albedo-0.5 Lambert plane, and a black slab at 1 m over x >= 0.3 that hides -// the 10 of them with x >= 0.6 from the centre. The other 14 give L = 0.5 / pi x 14 x 300 x cos / d^2 there -// (d^2 = 8, cos = 2 / sqrt 8). Live, the lights are guided by the light tiles (pathtrace.wgsl "Light tiles"). +// 2 m above an albedo-0.5 Lambert plane, each 2 m from the axis: 14 on the -x side, 10 on the +x side behind a +// black slab at 1 m over x >= 0.3, which hides them from the centre. The 14 give L = 0.5 / pi x 14 x 300 x cos / d^2 +// there (d^2 = 8, cos = 2 / sqrt 8; the patch's mean is 0.02 % above its centre). The slab's shadow edges are half a +// metre from the patch. 1024 samples: resampled lights leave ~2 % of noise in the patch at the tests' 64. Live, the +// light tiles guide the resampling (pathtrace.wgsl "Light tiles"). import { BoxGeometry, Mesh, PointLight } from "@threert/scene"; import { camera, lambert, plane, result, sceneWithSky, type TestMeta } from "./lib.ts"; export const test: TestMeta = { id: "light/many-shadowed", features: ["point-light", "lambert", "shadows"], reference: "analytic", temporal: false, - metric: { kind: "radiance", region: [0.48, 0.48, 0.52, 0.52], expected: (0.5 / Math.PI) * 14 * 300 * (2 / Math.sqrt(8)) / 8, tolerance: 0.03 }, + metric: { kind: "radiance", region: [0.48, 0.48, 0.52, 0.52], expected: (0.5 / Math.PI) * 14 * 300 * (2 / Math.sqrt(8)) / 8, tolerance: 0.02 }, + spp: 1024, note: "24 shadow-casting point lights, 10 hidden by a slab: the 14 others' sum at the centre", }; export default function () { const scene = sceneWithSky(0); - const lights = Array.from({ length: 24 }, (_, k) => { - const a = ((k + 0.5) * 15 * Math.PI) / 180; + // Degrees around the axis: 100 .. 260 (x <= -0.35: seen), -54 .. 54 (x >= 1.18: hidden). + const angles = [...Array.from({ length: 14 }, (_, k) => 100 + (k * 160) / 13), ...Array.from({ length: 10 }, (_, k) => -54 + k * 12)]; + const lights = angles.map((deg, k) => { + const a = (deg * Math.PI) / 180; const l = new PointLight(0xffffff, 300, 0); l.name = `lamp-${k}`; l.radius = 0.001; From 68ff7743731414b3297a344b9211ef880988d103 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 03:37:00 +0800 Subject: [PATCH 07/10] Light tiles: a camera hit reads the list of the tile it was seen in The lists were read at the pixel's own position: with the camera turning they belonged to another part of the scene. Now the camera hit's point, moved back by its motion, goes through the camera the lists were built under (Params lt_cam); off screen, the nearest tile's. bench-window rooms-turn (lamp-rooms-wide, 2 degrees a frame), last moving frame, dE / p99: main 2.00 / 8.57, lists at the pixel 0.86 / 4.82, this 0.80 / 3.54. Co-Authored-By: Claude Fable 5.2 --- docs/RUNTIME.md | 7 ++++--- renderer/shaders/pathtrace.wgsl | 26 +++++++++++++++--------- renderer/src/gpu.rs | 3 +++ renderer/src/lib.rs | 1 + renderer/src/live/frame.rs | 6 ++++-- renderer/src/live/mod.rs | 8 ++++---- tools/bench-window.ts | 36 +++++++++++++++++++++++++++++++-- 7 files changed, 67 insertions(+), 20 deletions(-) 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 d9f087c..b441449 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; const LT_NONE: u32 = 0xf0000000u; @@ -1702,12 +1704,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; } @@ -3159,7 +3166,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, bitcast(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)); @@ -3373,6 +3380,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 c88b256..ecdfb43 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): @@ -13,6 +14,8 @@ // still-rooms, still-rooms-wide the same for scenes/lamp-rooms.ts and lamp-rooms-wide.ts (96 and 768 shadow-casting // 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. +// 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 @@ -34,7 +37,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"); @@ -125,6 +128,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")); @@ -207,6 +238,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); From f5848596a093fa10db0b16f895881bd02a8138a9 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 03:40:58 +0800 Subject: [PATCH 08/10] Light tiles: indices and counts as float values, not integers' bits A small integer's bits are a denormal float, which a driver may flush to zero (the lists would read empty and the guiding stop, silently). Found in review. Co-Authored-By: Claude Fable 5.2 --- renderer/shaders/live.wgsl | 9 +++++---- renderer/shaders/pathtrace.wgsl | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index fd385d0..4b5010e 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -247,7 +247,8 @@ const RESOLVE_N: u32 = 8u; // ---- The light tiles: each 8 x 8 tile's pixels' reports made its list (pathtrace.wgsl "Light tiles": the lights // reported, each once: index, reports; ascending; the 64th vec4 holds their number, 63 at most). A workgroup is a // tile: the grid is the traced one shifted by L.lt.xy, another offset each frame, so tile borders do not stay put. -const LT_NONE: u32 = 0xf0000000u; +// (Float values, not integers' bits: pathtrace.wgsl LT_NONE says why.) +const LT_NONE: u32 = 16777216u; var lt_keys: array; var lt_first: array; @@ -256,7 +257,7 @@ fn light_tiles(@builtin(global_invocation_id) gid: vec3, @builtin(local_inv let px = vec2(gid.xy) - vec2(L.lt.xy); var key = LT_NONE; if (px.x >= 0 && px.y >= 0 && px.x < i32(L.isz.x) && px.y < i32(L.isz.y)) { - key = min(bitcast(light_rw[u32(px.y) * L.isz.x + u32(px.x)].w), LT_NONE); + key = min(u32(light_rw[u32(px.y) * L.isz.x + u32(px.x)].w), LT_NONE); } lt_keys[li] = key; workgroupBarrier(); @@ -282,8 +283,8 @@ fn light_tiles(@builtin(global_invocation_id) gid: vec3, @builtin(local_inv } let list = L.lt.z + (wg.y * L.lt.w + wg.x) * 64u; let at = less.x + less.y + less.z + less.w; - if (first && at < 63u) { light_rw[list + at] = vec4(bitcast(key), bitcast(same.x + same.y + same.z + same.w), 0.0, 0.0); } - if (li == 0u) { light_rw[list + 63u] = vec4(bitcast(min(all.x + all.y + all.z + all.w, 63u)), 0.0, 0.0, 0.0); } + if (first && at < 63u) { light_rw[list + at] = vec4(f32(key), f32(same.x + same.y + same.z + same.w), 0.0, 0.0); } + if (li == 0u) { light_rw[list + 63u] = vec4(f32(min(all.x + all.y + all.z + all.w, 63u)), 0.0, 0.0, 0.0); } } @compute @workgroup_size(8, 8, 1) diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index d9f087c..157fe94 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -1697,7 +1697,9 @@ fn local_light(li: Light, x: vec3, u: vec2) -> LS { // on) 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; -const LT_NONE: u32 = 0xf0000000u; +// (Indices and counts are stored as float values, not as their bits: a small integer's bits are a denormal float, +// which a driver may flush to zero. So a light-table index stays below LT_NONE, 2^24.) +const LT_NONE: u32 = 16777216u; var lt_list: u32; var lt_list_n: u32; var lt_fb: u32; @@ -1711,7 +1713,7 @@ fn lt_list_start(px: vec2) { 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; } - lt_list_n = min(bitcast(direct_load(lt_list + LT_LIST - 1u).x), LT_LIST - 1u); + lt_list_n = min(u32(direct_load(lt_list + LT_LIST - 1u).x), LT_LIST - 1u); } // Whether the pixel's list holds light `i`. @@ -1721,7 +1723,7 @@ fn lt_in_list(i: u32) -> bool { for (var k = 0u; k < 7u; k++) { if (lo >= hi) { break; } let mid = (lo + hi) / 2u; - let km = bitcast(direct_load(lt_list + mid).x); + let km = u32(direct_load(lt_list + mid).x); if (km == i) { return true; } if (km < i) { lo = mid + 1u; } else { hi = mid; } } @@ -1799,7 +1801,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, idx = first + min(u32(r4.x * f32(count)), count - 1u); } else { r4 = rng4(rng_sub(rd ^ 0x80000000u, 0x1000u + k)); - idx = bitcast(direct_load(lt_list + min(u32((f32(k - m_cand) + at) * step), lt_list_n - 1u)).x); + idx = u32(direct_load(lt_list + min(u32((f32(k - m_cand) + at) * step), lt_list_n - 1u)).x); if (idx < first || idx >= first + count) { continue; } stands = listed; } @@ -3158,7 +3160,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let pix = gid.y * w + gid.x; // 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, bitcast(LT_NONE), has(F_LISTS)))); } + 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); } rng_start(gid.xy, P.size.z, P.size.w, 0u); cur_depth = 0u; @@ -3462,7 +3464,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { if (LIVE && depth == 0u && direct_len() >= P.size.x * P.size.y && !non_finite(dl_det) && !non_finite(dl_det_spec)) { radiance -= thr * dl_det; dl_spec = max(dl_spec - dl_det_spec, vec3(0.0)); - if (P.misc.w < 0.5) { direct_store(gid.y * P.size.x + gid.x, vec4(thr * dl_det, select(0.0, bitcast(lt_fb), has(F_LISTS)))); } + if (P.misc.w < 0.5) { direct_store(gid.y * P.size.x + gid.x, vec4(thr * dl_det, select(0.0, f32(lt_fb), has(F_LISTS)))); } } let l = sample_bsdf(s, lb, v); From d418e3c21f7c4d6f9b3eeb3e4a1bffcfaf8c3a01 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 03:44:14 +0800 Subject: [PATCH 09/10] Docs: which reference the wide shot's numbers are against Co-Authored-By: Claude Fable 5.2 --- docs/ROADMAP.md | 6 ++++-- tools/bench-window.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 135b6b5..510adcc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2332,8 +2332,10 @@ frame's paths take candidates from their tile's list beside the uniform ones (do scheme MegaLights and RTXPT guide their draws by). Unbiased whatever a list holds. Measured on two new shots (`scenes/lamp-rooms.ts`, 96 lamps in 12 rooms; `lamp-rooms-wide.ts`, 768; bench-window `still-rooms`, `still-rooms-wide`), dE / flicker: 0.67 / 0.138 -> 0.63 / 0.069 and 1.01 / 0.126 -> 0.58 / 0.081, for 0.65 ms of -trace and a 0.22 ms pass (of 9.2 ms a frame). Scenes without more than 8 shadow-casting local lights compile and -run as before (Bistro, Kabukichō: picture and cost unchanged). +trace and a 0.22 ms pass (of 9.2 ms a frame). (The wide shot's reference was rendered with a light tree, on another +branch: main's offline chooses among the 768 lamps uniformly and is noisy at 4096 samples. Against main's own: +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). ## 2. Temporal fixes diff --git a/tools/bench-window.ts b/tools/bench-window.ts index c88b256..6c3296c 100644 --- a/tools/bench-window.ts +++ b/tools/bench-window.ts @@ -12,7 +12,8 @@ // ΔE, SSIM against offline (512 samples), and the RMS change of luma from one frame to the next (flicker). // still-rooms, still-rooms-wide the same for scenes/lamp-rooms.ts and lamp-rooms-wide.ts (96 and 768 shadow-casting // 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. +// 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.) // 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 From f587c005351ccd97400d13e6aae0efbc95c98dd1 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Sun, 15 Nov 2026 04:03:36 +0800 Subject: [PATCH 10/10] Docs: the lists follow the camera Co-Authored-By: Claude Fable 5.2 --- docs/ROADMAP.md | 6 ++++++ 1 file changed, 6 insertions(+) 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