diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e935d4e..510adcc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2325,6 +2325,18 @@ 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). (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 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..182685f 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -1279,6 +1279,18 @@ 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. + ## 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 diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index e999c25..4b5010e 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,49 @@ 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. +// (Float values, not integers' bits: pathtrace.wgsl LT_NONE says why.) +const LT_NONE: u32 = 16777216u; +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(u32(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(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) 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..157fe94 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 shadow-casting local lights are resampled fn has(f: u32) -> bool { return (FEAT & f) != 0u; } struct Params { @@ -107,6 +108,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 +1687,49 @@ 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. Compiled in for scenes whose shadow-casting local lights are resampled (F_LISTS). +const LT_TILE: u32 = 8u; +const LT_LIST: u32 = 64u; +// (Indices and counts are stored as float values, not as their bits: a small integer's bits are a denormal float, +// 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; + +// 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(u32(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 = u32(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 +1779,40 @@ 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. 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 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; 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 = 1.0; + 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 = 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; + } 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; @@ -1761,6 +1828,8 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, 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. + if (has(F_LISTS) && !vol && cur_depth == 0u) { lt_fb = pick; } } } } @@ -3091,7 +3160,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, 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; let cam = rng4(rng_dim(D_CAMERA)); @@ -3394,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, 0.0)); } + 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); diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index e892022..ab5509f 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: @@ -1353,6 +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 (`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 @@ -1388,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 }; + 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; } @@ -3361,7 +3373,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 { @@ -3369,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/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..bd1d3a6 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 (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; + } + 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; + 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..5f45139 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,23 @@ 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 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}`")) @@ -203,6 +224,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..1b2c862 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..3d6518f 100644 --- a/renderer/src/live/mod.rs +++ b/renderer/src/live/mod.rs @@ -131,8 +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`; 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)] struct ExposureSettings { @@ -344,6 +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))>, /// 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 +547,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..3b255c2 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: `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() }), @@ -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), } 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/tests/render/light-many-shadowed-live.ts b/tests/render/light-many-shadowed-live.ts new file mode 100644 index 0000000..acaa86d --- /dev/null +++ b/tests/render/light-many-shadowed-live.ts @@ -0,0 +1,8 @@ +// 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", 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 new file mode 100644 index 0000000..78d6fc9 --- /dev/null +++ b/tests/render/light-many-shadowed.ts @@ -0,0 +1,33 @@ +// More shadow-casting local lights than the kernel sums exactly (8), so they are resampled: 24 point lights of 300 cd +// 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.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); + // 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; + 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))]); +} diff --git a/tools/bench-window.ts b/tools/bench-window.ts index b81a2d5..6c3296c 100644 --- a/tools/bench-window.ts +++ b/tools/bench-window.ts @@ -3,13 +3,17 @@ // 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. (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 @@ -31,7 +35,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 +112,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 +206,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);