From e4718cdfbc707b95e4eaec037d6b59de58038dbd Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Fri, 13 Nov 2026 22:21:12 +0800 Subject: [PATCH 1/2] The live passes' buffers by what they hold: lasting state a buffer each (two halves), the transients in one table What lasts from one frame to the next has a buffer of its own, named for what it holds, in two halves (this frame's and the previous frame's, alternated by LiveParams.state instead of by swapping bind groups): diffuse_hist, diffuse_mom, diffuse_fast, spec_hist, gbuffer, up_state, color_hist. Before: a pair of buffers named alb held the diffuse fast mean, the upscaler's flicker and lock, and judge's findings over one another; the output colour history sat behind the traced grid's diffuse history in one buffer; velocity had a ping-pong partner nothing read; the g-buffer was allocated twice what it holds; the Rust names (alb, direct_hist, spec_a / spec_b) said nothing of the contents. judge's findings use up_state's previous half (judged_slot), as they used the old partner; the transients that share a buffer (fa, fb, sb, spec_pp, lumq) are listed in one table at the top of live.wgsl, with who uses each when. up_state's z has a half free (a lock's depth) and w is free. Storage bindings 28 to 24; one bind group; 16 bytes a pixel less (59 MB at 1440p). Merged layout: 21 buffers. Checks: render-identity, native and THREERT_MERGED=1, two runs a side and four on what it flagged: nothing changed. bench-window (still-bistro, tokyo-turn): within a run's spread with the radiance cache on; with it off 0.03 dE between frames against main's 0.01 to itself (a shader compiled from other source rounds differently). Live passes 6.27 to 6.13 ms (Bistro, 1440p at 0.375). render-identity now runs what it flags alone, four more times a side (--confirm), before calling it changed: two runs a side under-read a few tests' spread. Co-Authored-By: Claude Opus 5.5 --- renderer/shaders/live.wgsl | 232 +++++++++++++++++++------------------ renderer/src/live.rs | 162 +++++++++++++------------- renderer/src/merged.rs | 55 +++++---- tools/render-identity.ts | 70 ++++++----- 4 files changed, 270 insertions(+), 249 deletions(-) diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index 35a349c..55aee21 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -31,6 +31,7 @@ struct LiveParams { bt2: vec4, // bloom stage 4, 5 tint, -, - 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`), -, -, - }; // 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). @@ -38,20 +39,42 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec @group(0) @binding(0) var L: LiveParams; @group(0) @binding(1) var sample_px: array; // color.w = first-hit distance (0 = sky) -// Diffuse irradiance history (rgb, frames) on the traced grid (`dtemporal`; `mom_in` / `mom_out` hold its moments), -// then the output grid's colour history (rgb, samples; `out_hist`, `upscale`). -@group(0) @binding(2) var hist_in: array>; -@group(0) @binding(3) var hist_out: array>; -// The traced grid's g-buffer (`stemporal`): octahedral normal, view depth (0 = sky), roughness (`GB`). -@group(0) @binding(4) var gb_in: array>; -@group(0) @binding(5) var gb_out: array>; -// fa: the irradiance filter's ping-pong on the traced grid, then the image on the output grid (what the post chain -// reads). fb: its other half, then the composed colour of the traced grid (`compose`; w: first-hit distance), then -// depth of field's output when motion blur follows. -@group(0) @binding(6) var fa: array>; -@group(0) @binding(7) var fb: array>; -@group(0) @binding(8) var bloom_a: array>; // bloom pyramid levels, then stage sums -@group(0) @binding(9) var bloom_b: array>; // horizontal blur scratch + +// ---- The live passes' buffers, by what they hold and how long. +// +// What lasts from one frame to the next has a buffer of its own, in two halves: this frame's, which the passes write +// (`this_half`), and the previous frame's, which they read (`last_half`); live.rs alternates them (`L.state.x`). A half +// is L.size.x * L.size.y elements, indexed by traced sample (by output pixel: `color_hist`). +// diffuse_hist the diffuse irradiance's history: rgb, frames (`dtemporal`; the à-trous passes read its length) +// diffuse_mom its luminance moments: mean, mean square, the fast mean's luminance, 1 where written (`dtemporal`) +// diffuse_fast its fast mean in colour, f16 rgb (`dtemporal`; `fast_load`: ReBLUR's fast history) +// spec_hist the specular history: rgb, frames (`stemporal`) +// gbuffer octahedral normal, view depth (0 = sky), roughness (`stemporal`; `GB`) +// color_hist the output grid's colour history: rgb, samples (`upscale`) +// up_state the upscaler's state per traced sample (`compose`): x, y `Flicker`; z `Lock` (its life, f16; the +// other half free); w free +// What lives within a frame shares buffers whose uses do not overlap (the passes run in live.rs `Passes` order): +// sample_vel per traced sample: screen velocity (output pixels this frame), view depth, animated shading +// (`compose`; `judge`, `upscale`, depth of field and motion blur read it) +// judged `judge`'s findings per traced sample (`Judged`), in up_state's previous half: `compose` has read it +// by then, and the next frame writes the other (`judged_slot`) +// fa, fb the irradiance filter's ping-pong on the traced grid; then fb the composed colour of the traced grid +// (`compose`; w: first-hit distance) and fa the image on the output grid (what the post chain reads); +// fb depth of field's output when motion blur follows +// sb the traced surfaces for the diffuse filter (`dtemporal` writes, the à-trous passes read), then the +// upscaled image when depth of field / motion blur follow (w: the circle of confusion) +// spec_pp the specular filter's ping-pong (`stemporal` -> `sfilt0..2`: two f16 slots, the result in slot 1), +// then in slot 0 `reject`'s verdict +// lumq depth of field's tiles (`dof_tiles`, `dof`), then the quarter-resolution log2 luminance +// (`lum_down`, `lum_blur`) +@group(0) @binding(2) var diffuse_hist: array>; +@group(0) @binding(3) var color_hist: array>; +@group(0) @binding(4) var gbuffer: array>; +@group(0) @binding(5) var spec_hist: array>; +@group(0) @binding(6) var diffuse_mom: array>; +@group(0) @binding(7) var diffuse_fast: array>; +@group(0) @binding(8) var up_state: array>; +@group(0) @binding(9) var sample_vel: array>; @group(0) @binding(10) var out_px: array; @group(0) @binding(11) var lut: texture_3d; @group(0) @binding(12) var lut_samp: sampler; @@ -59,35 +82,29 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec @group(0) @binding(14) var ae_state: array, 2>; // exposure, target, mean log2 lum, valid @group(0) @binding(15) var lumq: array; // quarter res log2 luminance (unexposed) @group(0) @binding(16) var lumb: array; // edge-aware blur of lumq -// Per traced sample, what `judge` finds of the output grid's colour history (`Judged`, f16), in `ae_out`. Until then -// `ae_out` holds what the previous frame wrote to `ae_in`, its ping-pong partner: `dtemporal` the diffuse fast mean -// in colour (`fast_load`: x, y), `compose` how much the composed sample flickers (`Flicker`: z, w). -@group(0) @binding(17) var ae_in: array>; -@group(0) @binding(18) var ae_out: array>; -// Specular history on the traced grid (rgb + history length), diffuse luminance moments (mean, mean square, fast mean, -). -@group(0) @binding(19) var shist_in: array>; -@group(0) @binding(20) var shist_out: array>; -@group(0) @binding(21) var mom_in: array>; -@group(0) @binding(22) var mom_out: array>; -// The specular denoiser's ping-pong on the traced grid (`stemporal` -> `sfilt0` -> `sfilt1` -> `sfilt2`): (rgb, history -// length) as f16, two slots (the passes are bandwidth-bound: 8 bytes per pixel instead of 16). The result is slot 1; -// slot 0 then holds `reject`'s verdict. -@group(0) @binding(23) var spec_pp: array>; -// Scratch: the traced surfaces for the diffuse denoiser (`dtemporal`), then the upscaled image when depth of field / -// motion blur follow (they read it; w: the circle of confusion). -@group(0) @binding(24) var sb: array>; -// Reflection resolve: the trace kernel's specular-lobe rays, 2 per traced pixel ((radiance back, lobe pdf; 0 = none), -// (specular albedo E, octahedral direction)), and the resolved specular reflection per traced pixel. -@group(0) @binding(25) var refl_in: array>; -@group(0) @binding(26) var refl_res: array>; +@group(0) @binding(17) var fa: array>; +@group(0) @binding(18) var fb: array>; +@group(0) @binding(19) var bloom_a: array>; // bloom pyramid levels, then stage sums +@group(0) @binding(20) var bloom_b: array>; // horizontal blur scratch +@group(0) @binding(21) var spec_pp: array>; +@group(0) @binding(22) var sb: array>; +// What the trace leaves (the kernel's group 1, live.rs `Targets::kernel`). Reflection resolve: the specular-lobe rays, +// 2 per traced pixel ((radiance back, lobe pdf; 0 = none), (specular albedo E, octahedral direction)), and the +// resolved specular reflection per traced pixel (`refl`). +@group(0) @binding(23) var refl_in: array>; +@group(0) @binding(24) var refl_res: array>; // Per traced pixel: where its surface point was the previous frame, as a world-space offset (w = 1: a moving object). -@group(0) @binding(27) var motion_in: array>; +@group(0) @binding(25) var motion_in: array>; // Directional lights' direct light (the sun: nearly deterministic) at camera hits per traced pixel: no denoiser -// (`compose` adds it as it is, UE lights directly). `xhist_out`: per traced sample, its screen velocity (output pixels -// this frame), view depth and whether its shading is animated (`compose`). (`xhist_in`: its ping-pong partner, not read.) -@group(0) @binding(28) var direct_in: array>; -@group(0) @binding(29) var xhist_in: array>; -@group(0) @binding(30) var xhist_out: array>; +// (`compose` adds it as it is, UE lights directly). +@group(0) @binding(26) var direct_in: 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; } +fn last_half(i: u32) -> u32 { return (1u - L.state.x) * L.size.x * L.size.y + i; } +// Where `judge`'s findings for traced sample q are (see `judged` above). +fn judged_slot(q: u32) -> u32 { return last_half(q); } + fn spec_load(slot: u32, i: u32) -> vec4 { let v = spec_pp[slot * L.size.x * L.size.y + i]; return vec4(unpack2x16float(v.x), unpack2x16float(v.y)); @@ -180,7 +197,7 @@ fn oct_enc(n: vec3) -> vec2 { return (1.0 - abs(p.yx)) * select(vec2(-1.0), vec2(1.0), p >= vec2(0.0)); } -// The output-grid g-buffer (`gb_out`, next frame's `gb_in`), 8 bytes: octahedral normal (snorm16 x 2), view depth +// The g-buffer (`gbuffer`), 8 bytes: octahedral normal (snorm16 x 2), view depth // (0 = sky) and roughness (f16). The surface tests compare depths relatively (2-4 %), far coarser than f16. One fetch // per tap for the specular filter and the history tests (it was 16 bytes, and roughness a second 16-byte fetch from the // albedo / emission history): those passes are bandwidth-bound. @@ -293,10 +310,9 @@ fn medium_share(q: u32) -> f32 { } // The diffuse irradiance's fast mean in colour (f16 rgb; a quarter of each new sample, as its luminance in the -// moments), per traced sample: `dtemporal` reads the previous frame's from `ae_out` (before `judge` writes its own -// there) and writes this frame's to `ae_in`, the next frame's `ae_out`. +// moments), per traced sample (`diffuse_fast`): `dtemporal` reads the previous frame's and writes this frame's. fn fast_load(j: u32) -> vec3 { - let v = ae_out[j]; + let v = diffuse_fast[last_half(j)]; return vec3(unpack2x16float(v.x), unpack2x16float(v.y).x); } @@ -317,8 +333,6 @@ fn sample_pos(qx: i32, qy: i32) -> vec2 { return (vec2(f32(qx), f32(qy)) + L.jit.xy) * vec2(f32(L.size.x) / f32(L.isz.x), f32(L.size.y) / f32(L.isz.y)); } -// The output grid's colour history follows the traced grid's diffuse history in hist_in / hist_out (sized for both). -fn out_hist(i: u32) -> u32 { return L.size.x * L.size.y + i; } // Previous-frame pixel position of world point p (x < 0: behind the previous camera). fn prev_pixel(p: vec3) -> vec3 { @@ -341,7 +355,7 @@ fn same_surface(tx: i32, ty: i32, z: f32, n: vec3, t: f32) -> bool { // same_surface with the normals' least cosine `ncos`. fn same_surface_n(tx: i32, ty: i32, z: f32, n: vec3, t: f32, ncos: f32) -> bool { - let g = gb_unpack(gb_in[u32(ty) * L.isz.z + u32(tx)]); + let g = gb_unpack(gbuffer[last_half(u32(ty) * L.isz.z + u32(tx))]); if (t > 0.0) { return g.depth > 0.0 && abs(g.depth - z) < 0.04 * z + 0.002 && dot(g.n, n) > ncos; } return g.depth == 0.0; } @@ -407,7 +421,7 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { // Holding still the centre lands on one history pixel exactly, whose stored normal is another jittered // sample's: on normal-mapped stones and facade detail it often missed 0.8 and the history restarted // (Bistro's street and facades at 256 frames held 8-30). The centre fetch takes 0.5 (depth unchanged). - let centred = hist_in[u32(jc.y * ps.x + jc.x)].w >= HIST_CENTRE; + let centred = diffuse_hist[last_half(u32(jc.y * ps.x + jc.x))].w >= HIST_CENTRE; if (centred) { tc = tcc; } let x0 = i32(floor(tc.x)); let y0 = i32(floor(tc.y)); @@ -419,8 +433,8 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { if (tx < 0 || ty < 0 || tx >= ps.x || ty >= ps.y || !same_surface_n(tx, ty, pp.z, n, t, select(0.8, 0.5, centred))) { continue; } let bw = select(1.0 - f.x, f.x, (k & 1) == 1) * select(1.0 - f.y, f.y, (k >> 1u) == 1); let j = u32(ty * ps.x + tx); - hd += hist_in[j] * bw; - hm += mom_in[j] * bw; + hd += diffuse_hist[last_half(j)] * bw; + hm += diffuse_mom[last_half(j)] * bw; hf += fast_load(j) * bw; wsum += bw; } @@ -447,9 +461,9 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { let ty = i32(round(tc.y)) + k / 3 - 1; if (tx < 0 || ty < 0 || tx >= ps.x || ty >= ps.y || !same_surface(tx, ty, pp.z, n, t)) { continue; } let j = u32(ty * ps.x + tx); - let m = mom_in[j]; + let m = diffuse_mom[last_half(j)]; if (m.w <= 0.0 || non_finite(m.xyz)) { continue; } - slow_c += hist_in[j].rgb; + slow_c += diffuse_hist[last_half(j)].rgb; fast_c += fast_load(j); acc += vec2(m.x, m.z); acc2 += m.z * m.z; @@ -537,9 +551,9 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { let mom = vec4(mix(m1, l, a), mix(m2, l * l, a), fast, 1.0); let d = mix(hist, irr, a); let fc = select(irr, mix(hf, irr, 0.25), hd.w > 0.0); - ae_in[q] = vec4(pack2x16float(min(fc.rg, vec2(6.0e4))), pack2x16float(vec2(min(fc.b, 6.0e4), 0.0)), 0u, 0u); - hist_out[q] = vec4(d, count); - mom_out[q] = mom; + diffuse_fast[this_half(q)] = vec2(pack2x16float(min(fc.rg, vec2(6.0e4))), pack2x16float(vec2(min(fc.b, 6.0e4), 0.0))); + diffuse_hist[this_half(q)] = vec4(d, count); + diffuse_mom[this_half(q)] = mom; // Denoiser input; w = variance of the accumulated mean luminance (its noise estimate). fb[q] = vec4(max(d, vec3(0.0)), max(mom.y - mom.x * mom.x, 0.0) / max(count, 1.0)); } @@ -575,7 +589,7 @@ fn atrous_gated(gid: vec3, stage: u32, step: i32, max_noise: f32) { if (gid.x >= w || gid.y >= h) { return; } let i = gid.y * w + gid.x; let c = read_stage(stage, i); - let count = hist_out[i].w; + let count = diffuse_hist[this_half(i)].w; let strength = L.misc2.x / sqrt(max(count, 1.0)); // Animated emitters (signs, screens) show their emission, which bypasses the denoiser: skipping them keeps whole // 8x8 groups on the cheap path once the rest has converged. @@ -653,7 +667,7 @@ fn atrous_gated(gid: vec3, stage: u32, step: i32, max_noise: f32) { // ------------------------------------------------------------------------------------------------ render resolution // Everything noisy is accumulated and filtered on the traced grid (the render resolution), composed there into one // colour, and one temporal upscaler takes that to the output grid (`upscale`): the structure of UE's TSR after Lumen -// and of the vendor upscalers after a denoiser. `gb_out` is the traced grid's g-buffer. +// and of the vendor upscalers after a denoiser. `gbuffer` is the traced grid's g-buffer. // Specular history at previous-frame position pp (prev_pixel: output pixels, view depth) on the previous traced grid: // bilinear over the taps that show this surface; `virt`: through a reflection (the reflecting surface's normal and @@ -671,7 +685,7 @@ fn spec_history(pp: vec3, n: vec3, t: f32, rough: f32, virt: bool) -> let ty = y0 + (k >> 1u); if (tx < 0 || ty < 0 || tx >= ps.x || ty >= ps.y) { continue; } let j = u32(ty * ps.x + tx); - let g = gb_unpack(gb_in[j]); + let g = gb_unpack(gbuffer[last_half(j)]); var ok = false; if (virt) { ok = g.depth > 0.0 && dot(g.n, n) > 0.95 && abs(g.rough - rough) < 0.1; @@ -682,7 +696,7 @@ fn spec_history(pp: vec3, n: vec3, t: f32, rough: f32, virt: bool) -> } if (!ok) { continue; } let bw = select(1.0 - f.x, f.x, (k & 1) == 1) * select(1.0 - f.y, f.y, (k >> 1u) == 1); - h += shist_in[j] * bw; + h += spec_hist[last_half(j)] * bw; wsum += bw; } if (wsum <= 1e-3) { return vec4(0.0); } @@ -791,9 +805,9 @@ fn stemporal(@builtin(global_invocation_id) gid: vec3) { s_out = mix(max(ycocg_rgb(hy), vec3(0.0)), cur, clamp(1.0 / max(count_s, 1e-4), 0.0, 1.0)); } s_out = select(max(s_out, vec3(0.0)), vec3(0.0), non_finite(s_out)); - shist_out[q] = vec4(s_out, count_s); + spec_hist[this_half(q)] = vec4(s_out, count_s); spec_store(0u, q, vec4(s_out, count_s)); - gb_out[q] = gb_pack(n, depth, rough); + gbuffer[this_half(q)] = gb_pack(n, depth, rough); } // The specular filter (`spec_filter`'s kernel) on the traced grid: three passes over `spec_pp`, slot 0 -> 1 -> 0 -> 1. @@ -804,7 +818,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { let i = gid.y * w + gid.x; let src = select(1u, 0u, from_a); let c = spec_load(src, i); - let g = gb_unpack(gb_out[i]); + let g = gb_unpack(gbuffer[this_half(i)]); let rough = g.rough; let fresh = c.w < 4.0; let reach = select(4.0 * clamp(rough * 8.0, 0.0, 1.0), 4.0, fresh); @@ -832,7 +846,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { let qy = i32(gid.y) + dy * step; if (qx < 0 || qy < 0 || qx >= i32(w) || qy >= i32(h)) { continue; } let q = u32(qy) * w + u32(qx); - let gq = gb_unpack(gb_out[q]); + let gq = gb_unpack(gbuffer[this_half(q)]); let sq = spec_load(src, q).rgb; var wt = kern[abs(dx)] * kern[abs(dy)]; wt *= pow(max(dot(g.n, gq.n), 0.0), npow); @@ -856,7 +870,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { // The frame at render resolution: each traced sample's colour (its own albedo times the denoised irradiance, the // denoised specular, both through the media before the surface, what the camera ray saw directly and the deterministic -// direct light) into fb (w: first-hit distance), and its motion into `xhist_out`: screen velocity (output pixels this +// direct light) into fb (w: first-hit distance), and its motion into `sample_vel`: screen velocity (output pixels this // frame; camera and object), view depth, and the upscaler's reactive mask, as FSR 2 and UE's responsive AA take one // (what changes there has no motion vector): 1 where the surface's shading is animated (signs, screens), and up to 1 // with the share of the pixel that is a medium changing with time (fire, simulated smoke). @@ -898,7 +912,7 @@ fn compose(@builtin(global_invocation_id) gid: vec3) { let pc = vec2(floor((sp - vel) * vec2(ps) / vec2(f32(L.size.x), f32(L.size.y)))); if (pc.x >= 0 && pc.y >= 0 && pc.x < ps.x && pc.y < ps.y) { f = flicker_at(u32(pc.y * ps.x + pc.x)); - lock_was = unpack2x16float(ae_out[u32(pc.y * ps.x + pc.x)].y).y; + lock_was = unpack2x16float(up_state[last_half(u32(pc.y * ps.x + pc.x))].z).x; } } let still = 1.0 - clamp(length(vel) / UP_SPEED, 0.0, 1.0); @@ -928,10 +942,9 @@ fn compose(@builtin(global_invocation_id) gid: vec3) { let thin = across && (like & 27u) != 27u && (like & 54u) != 54u && (like & 216u) != 216u && (like & 432u) != 432u; // (None on animated shading: it shows this frame's picture, and its history is not kept for it to stand by.) let reactive = max(select(0.0, 1.0, px.albedo.w > 1.5), clamp(medium_share(q) / UP_MEDIUM, 0.0, 1.0)); - ae_in[q].y = (ae_in[q].y & 0xffffu) | (pack2x16float(vec2(0.0, select(max(lock_was - 1.0 / jitter_phases(), 0.0), 1.0, thin) * (1.0 - reactive))) & 0xffff0000u); - ae_in[q].z = pack2x16float(vec2(now, select(now, f.last.x, f.n >= 1.0))); - ae_in[q].w = pack2x16float(vec2(sd * still, min(max(f.n + still, 1.0), 9.0))); - xhist_out[q] = vec4(vel, depth, reactive); + let life = select(max(lock_was - 1.0 / jitter_phases(), 0.0), 1.0, thin) * (1.0 - reactive); + up_state[this_half(q)] = vec4(pack2x16float(vec2(now, select(now, f.last.x, f.n >= 1.0))), pack2x16float(vec2(sd * still, min(max(f.n + still, 1.0), 9.0))), pack2x16float(vec2(life, 0.0)), 0u); + sample_vel[q] = vec4(vel, depth, reactive); } // The upscaler works on linear radiance and its means are plain ones. (Weighted by 1 / (1 + luminance x exposure), so @@ -992,11 +1005,10 @@ const UP_STALE: f32 = 0.05; // traced sample at this place changes from one frame to the next under the jitter, which is what a temporal // upscaler's flicker history is for (UE's TSR keeps the input's luminance over time for the same decision). -// `Flicker`, per traced sample, in the words `dtemporal` leaves free in `ae_in` (f16; `compose` keeps it): the -// composed sample's luminance of the last frame and of the one before, the deviation of its frame-to-frame change as -// a share of its level, and of how many frames that is (0: none; the deviation is then 1: unknown, as much as the -// level), all read where the sample's surface was a frame ago (in motion the place on screen would read the motion -// as flicker). +// `Flicker`, per traced sample, in `up_state`'s x and y (f16; `compose` keeps it): the composed sample's luminance of +// the last frame and of the one before, the deviation of its frame-to-frame change as a share of its level, and of how +// many frames that is (0: none; the deviation is then 1: unknown, as much as the level), all read where the sample's +// surface was a frame ago (in motion the place on screen would read the motion as flicker). // - The deviation is of the second difference (this frame's, less twice the last, plus the one before: of // independent samples its mean square is six variances), which a steady rise or fall does not have: a sky dimming // 4 % a frame is no flicker. A running mean over the first eight frames, an exponential one after. @@ -1008,20 +1020,20 @@ const UP_STALE: f32 = 0.05; // every frame), so the deviation is added to by how still the pixel is and kept by the same, none from UP_SPEED on // (TSR fades its flicker rule out with velocity too): a pixel that moved has none and earns it again at rest, // while its history, cut to UP_MOVING samples by the motion, grows back. -// `Lock`, per traced sample, in the half word the fast mean leaves free in `ae_in`'s y (f16): how much longer the -// picture here counts as a thin feature, 1 when a sample showed one, a jitter sequence later 0 (FSR 2's pixel locks). A -// thin feature is a sample no 2 x 2 block of the nine around it is like (within THIN_LIKE of its luminance: FSR 2's -// rule) and that is above both or below both of some pair of opposite neighbours: a lamp or a lit window finer than a -// traced pixel, which most frames' samples miss, and the dark sample between two of them, which is this frame's miss. -// (FSR 2 asks for a sample brighter or darker than all those unlike it, a single line's rule: of lamps strung a pixel -// apart, in several colours, most have a brighter neighbour, and Tokyo Tower's legs went unlocked. A slope is above one -// neighbour and below the other in every direction and stays out.) The rules that hold the history to this frame's -// samples stand back for a locked pixel: its history is clamped into the nine samples in motion only in part (`upscale`, -// LOCK_RELAX: the lamp no sample hit this frame is still there; Tokyo Tower in a turn lost a quarter of its light and -// its legs), and its sample's distance from the history's range is no finding (`judge`: a hit lies above the range, a -// miss within it, and the corrections went one way); nor is a lamp the samples miss this frame something gone from in -// front (`judge`'s depth test). `compose` carries the lock with the surface. UE's TSR loosens its clamp the same way -// where it detects thin geometry. +// `Lock`, per traced sample, in `up_state`'s z (f16; the other half free): how much longer the picture here counts as a +// thin feature, 1 when a sample showed one, a jitter sequence later 0 (FSR 2's pixel locks). A thin feature is a sample +// no 2 x 2 block of the nine around it is like (within THIN_LIKE of its luminance: FSR 2's rule) and that is above both +// or below both of some pair of opposite neighbours: a lamp or a lit window finer than a traced pixel, which most +// frames' samples miss, and the dark sample between two of them, which is this frame's miss. (FSR 2 asks for a sample +// brighter or darker than all those unlike it, a single line's rule: of lamps strung a pixel apart, in several colours, +// most have a brighter neighbour, and Tokyo Tower's legs went unlocked. A slope is above one neighbour and below the +// other in every direction and stays out.) The rules that hold the history to this frame's samples stand back for a +// locked pixel: its history is clamped into the nine samples in motion only in part (`upscale`, LOCK_RELAX: the lamp no +// sample hit this frame is still there; Tokyo Tower in a turn lost a quarter of its light and its legs), and its +// sample's distance from the history's range is no finding (`judge`: a hit lies above the range, a miss within it, and +// the corrections went one way); nor is a lamp the samples miss this frame something gone from in front (`judge`'s +// depth test). `compose` carries the lock with the surface. UE's TSR loosens its clamp the same way where it detects +// thin geometry. // The test runs on what the camera ray saw directly (the sample's emission: lamps, windows, signs), which has no noise: // FSR 2's test is made for a rasterised frame, and on the composed sample of a traced one (short histories in motion, // reflections without a spatial filter: more than 5 % from sample to sample) it called most of Kabukichō thin; the @@ -1041,22 +1053,22 @@ fn jitter_phases() -> f32 { return clamp(round(8.0 * f32(L.size.x) * f32(L.size.y) / (f32(L.isz.x) * f32(L.isz.y))), 8.0, 512.0); } // (In full for the first half of its life, fading over the second.) -fn locked(q: u32) -> f32 { return clamp(2.0 * unpack2x16float(ae_in[q].y).y, 0.0, 1.0); } +fn locked(q: u32) -> f32 { return clamp(2.0 * unpack2x16float(up_state[this_half(q)].z).x, 0.0, 1.0); } struct Flicker { last: vec2, sd: f32, n: f32 }; fn flicker_at(q: u32) -> Flicker { - let v = ae_out[q]; - let b = unpack2x16float(v.w); - return Flicker(unpack2x16float(v.z), b.x, b.y); + let v = up_state[last_half(q)]; + let b = unpack2x16float(v.y); + return Flicker(unpack2x16float(v.x), b.x, b.y); } -// `judge`: per traced sample, into `ae_out` (f16): that distance (linear RGB), the sample's luminance (< 0: no +// `judge`: per traced sample, into `judged_slot` (f16): that distance (linear RGB), the sample's luminance (< 0: no // history there), the motion its output pixels reproject by: the surface nearest the camera among the nine samples // around (the sky the farthest), since an edge moves with what is in front of it; and whether its history is of // something that is gone. struct Judged { d: vec3, lum: f32, vel: vec2, gone: bool }; fn judged_at(q: u32) -> Judged { - let v = ae_out[q]; + let v = up_state[judged_slot(q)]; let b = unpack2x16float(v.y); return Judged(vec3(unpack2x16float(v.x), b.x), b.y, unpack2x16float(v.z), v.w != 0u); } @@ -1073,7 +1085,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { let qx = i32(gid.x) + k % 3 - 1; let qy = i32(gid.y) + k / 3 - 1; if (qx < 0 || qy < 0 || qx >= iw || qy >= ih) { continue; } - let mv = xhist_out[u32(qy * iw + qx)]; + let mv = sample_vel[u32(qy * iw + qx)]; let z = select(1.0e29, mv.z, mv.z > 0.0); if (z < near) { near = z; @@ -1096,7 +1108,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { let x = pp.x + k % 3 - 1; let y = pp.y + k / 3 - 1; if (x < 0 || y < 0 || x >= i32(L.size.x) || y >= i32(L.size.y)) { continue; } - let hv = hist_in[out_hist(u32(y) * L.size.x + u32(x))]; + let hv = color_hist[last_half(u32(y) * L.size.x + u32(x))]; if (hv.w <= 0.0 || non_finite(hv.rgb)) { continue; } lo = min(lo, hv.rgb); hi = max(hi, hv.rgb); @@ -1107,7 +1119,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { // (The sample is a point and the history's pixels are means: UP_FLICKER deviations of its own flicker // outside their range are the jitter's.) let c = fb[q].rgb; - let slack = UP_FLICKER * unpack2x16float(ae_in[q].w).x * c; + let slack = UP_FLICKER * unpack2x16float(up_state[this_half(q)].y).x * c; d = clamp(c - clamp(c, lo - slack, hi + slack), vec3(-6.0e4), vec3(6.0e4)); l = min(lum(c), 6.0e4); // Gone from in front: the previous frame's nearest depth around where this point was (its traced grid's @@ -1116,7 +1128,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { if (ps.x > 0) { var zr = 1.0; let t = fb[q].w; - let z = xhist_out[q].z; + let z = sample_vel[q].z; if (t > 0.0 && z > 0.0) { let sp = sample_pos(i32(gid.x), i32(gid.y)); let mv = motion_in[q]; @@ -1129,7 +1141,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { let x = pc.x + k % 3 - 1; let y = pc.y + k / 3 - 1; if (x < 0 || y < 0 || x >= ps.x || y >= ps.y) { continue; } - let zq = gb_unpack(gb_in[u32(y * ps.x + x)]).depth; + let zq = gb_unpack(gbuffer[last_half(u32(y * ps.x + x))]).depth; was_near = min(was_near, select(1.0e29, zq, zq > 0.0)); } let now_near = select(near * zr, 1.0e29, near >= 1.0e29); @@ -1157,7 +1169,7 @@ fn judge(@builtin(global_invocation_id) gid: vec3) { // A locked sample's distance from the history's range is no finding (a lamp finer than a pixel lies above it // whenever a sample hits it). d *= 1.0 - locked(q); - ae_out[q] = vec4(pack2x16float(d.xy), pack2x16float(vec2(d.z, l)), pack2x16float(vel), u32(gone)); + up_state[judged_slot(q)] = vec4(pack2x16float(d.xy), pack2x16float(vec2(d.z, l)), pack2x16float(vel), u32(gone)); } // `reject`: per traced pixel, into `spec_pp`'s slot 0 (free once the specular filter is done; f16): the correction @@ -1237,7 +1249,7 @@ fn up_history(fx: f32, fy: f32) -> vec4 { let qy = u32(clamp(i32(y1) + j - 1, 0, i32(L.size.y) - 1)); for (var i = 0; i < 4; i++) { let qx = u32(clamp(i32(x1) + i - 1, 0, i32(L.size.x) - 1)); - let v = hist_in[out_hist(qy * L.size.x + qx)]; + let v = color_hist[last_half(qy * L.size.x + qx)]; h += v * (wx[i] * wy[j]); if ((i == 1 || i == 2) && (j == 1 || j == 2)) { lo = min(lo, v); @@ -1304,7 +1316,7 @@ fn upscale(@builtin(global_invocation_id) gid: vec3) { if (L.size.w == 0u) { let hp = vec2(f32(gid.x), f32(gid.y)) - vel; if (hp.x > -0.5 && hp.y > -0.5 && hp.x < f32(w) - 0.5 && hp.y < f32(h) - 0.5) { - if (vel.x == 0.0 && vel.y == 0.0) { hist = hist_in[out_hist(i)]; } else { hist = up_history(hp.x, hp.y); } + if (vel.x == 0.0 && vel.y == 0.0) { hist = color_hist[last_half(i)]; } else { hist = up_history(hp.x, hp.y); } } if (non_finite(hist.rgb) || non_finite(vec3(hist.w))) { hist = vec4(0.0); } } @@ -1322,15 +1334,15 @@ fn upscale(@builtin(global_invocation_id) gid: vec3) { if (kept > 0.0) { var hc = max(hist.rgb + verdict.xyz, vec3(0.0)); hc = mix(hc, clamp(hc, xlo, xhi), moving * (1.0 - LOCK_RELAX * lock)); - let most = min(mix(UP_SAMPLES, UP_MOVING, moving), mix(UP_SAMPLES, UP_REACTIVE, clamp(xhist_out[u32(bc.y * iw + bc.x)].w, 0.0, 1.0))); + let most = min(mix(UP_SAMPLES, UP_MOVING, moving), mix(UP_SAMPLES, UP_REACTIVE, clamp(sample_vel[u32(bc.y * iw + bc.x)].w, 0.0, 1.0))); count = min(kept + conf, most); let a = clamp(conf / max(count, 1e-4), 0.0, 1.0); outc = mix(hc, cur, a); } let lin = finite_or_zero(max(outc, vec3(0.0))); - hist_out[out_hist(i)] = vec4(lin, count); + color_hist[this_half(i)] = vec4(lin, count); // The image: into fa, or into sb for depth of field / motion blur to read (w: the CoC of the nearest sample's depth). - let img = vec4(lin, dof_coc(xhist_out[u32(bc.y * iw + bc.x)].z)); + let img = vec4(lin, dof_coc(sample_vel[u32(bc.y * iw + bc.x)].z)); if (L.misc2.z > 0.0 || L.dof.x > 0.0) { sb[i] = img; } else { fa[i] = img; } } @@ -1677,7 +1689,7 @@ fn mb_src(q: u32) -> vec3 { // Output pixel q's motion and depth: those of the traced sample under it (`compose`). fn motion_under(q: u32) -> vec4 { - return xhist_out[sample_under(q % L.size.x, q / L.size.x)]; + return sample_vel[sample_under(q % L.size.x, q / L.size.x)]; } fn velocity(q: u32) -> vec2 { @@ -1761,10 +1773,10 @@ fn post(@builtin(global_invocation_id) gid: vec3) { let a = clamp(sample_px[sample_under(gid.x, gid.y)].albedo.rgb, vec3(0.0), vec3(1.0)); d = select(pow((a + 0.055) / 1.055, vec3(1.0 / 2.4)) * 1.055 - 0.055, a * 12.92, a <= vec3(0.0031308)); } else if (mode == 2u) { - let g = gb_unpack(gb_out[sample_under(gid.x, gid.y)]); + let g = gb_unpack(gbuffer[this_half(sample_under(gid.x, gid.y))]); d = select(g.n * 0.5 + 0.5, vec3(0.0), g.depth == 0.0); } else { - let t = clamp(log2(max(hist_out[sample_under(gid.x, gid.y)].w, 1.0)) / 12.0, 0.0, 1.0); // 1 .. 4096 frames (diffuse) + let t = clamp(log2(max(diffuse_hist[this_half(sample_under(gid.x, gid.y))].w, 1.0)) / 12.0, 0.0, 1.0); // 1 .. 4096 frames (diffuse) d = clamp(vec3(1.5 - abs(4.0 * t - 3.0), 1.5 - abs(4.0 * t - 2.0), 1.5 - abs(4.0 * t - 1.0)), vec3(0.0), vec3(1.0)); } let q = vec3(clamp(d, vec3(0.0), vec3(1.0)) * 255.0 + 0.5); @@ -1776,7 +1788,7 @@ fn post(@builtin(global_invocation_id) gid: vec3) { // denoised signals apart: diffuse (6: the composed colour less the rest), specular (7), deterministic direct light (8). // (6 does not hold with depth of field and motion blur both on: the composed colour's buffer is theirs then.) let tq = sample_under(gid.x, gid.y); - if (mode == 3u) { src = (hist_out[tq].rgb * max(sample_px[tq].albedo.rgb, vec3(0.02)) + shist_out[tq].rgb) * medium_tr(tq) + sample_parts(tq)[2] + direct_in[tq].rgb; } + if (mode == 3u) { src = (diffuse_hist[this_half(tq)].rgb * max(sample_px[tq].albedo.rgb, vec3(0.02)) + spec_hist[this_half(tq)].rgb) * medium_tr(tq) + sample_parts(tq)[2] + direct_in[tq].rgb; } if (mode == 4u) { src = sample_color(tq); } if (mode == 6u) { src = max(fb[tq].rgb - spec_load(1u, tq).rgb * medium_tr(tq) - sample_parts(tq)[2] - direct_in[tq].rgb, vec3(0.0)); } if (mode == 8u) { src = direct_in[tq].rgb; } diff --git a/renderer/src/live.rs b/renderer/src/live.rs index db8f5a5..d168492 100644 --- a/renderer/src/live.rs +++ b/renderer/src/live.rs @@ -107,6 +107,8 @@ struct LiveParams { dof: [f32; 4], /// This frame's sub-pixel jitter (every camera ray's: x, y in 0 .. 1 of a traced pixel), -, -. 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], } /// Bloom pyramid level j (0..4: 1/4 .. 1/64 resolution) size; stage k (0..5) runs on level max(k - 1, 0). @@ -238,7 +240,7 @@ impl Passes { ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None, }]; - entries.extend([buf(1, true), buf(2, true), buf(3, false), buf(4, true), buf(5, false), buf(6, false), buf(7, false), buf(8, false), buf(9, false), buf(10, false)]); + entries.extend([buf(1, true), buf(2, false), buf(3, false), buf(4, false), buf(5, false), buf(6, false), buf(7, false), buf(8, false), buf(9, false), buf(10, false)]); entries.push(wgpu::BindGroupLayoutEntry { binding: 11, visibility: wgpu::ShaderStages::COMPUTE, @@ -247,7 +249,7 @@ impl Passes { }); entries.push(wgpu::BindGroupLayoutEntry { binding: 12, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None }); entries.extend([buf(13, false), buf(14, false), buf(15, false), buf(16, false), buf(17, false), buf(18, false)]); - entries.extend([buf(19, true), buf(20, false), buf(21, true), buf(22, false), buf(23, false), buf(24, false), buf(25, true), buf(26, false), buf(27, true), buf(28, true), buf(29, true), buf(30, false)]); + entries.extend([buf(19, false), buf(20, false), buf(21, false), buf(22, false), buf(23, true), buf(24, false), buf(25, true), buf(26, true)]); // The merged layout: the buffers that stay, the slabs, and the offsets' uniform (merged.rs). if g.merged { use crate::merged::{LIVE_KEPT, OFFSETS_BINDING, SLABS}; @@ -272,28 +274,29 @@ impl Passes { /// Screen-size targets shared across scene reloads. pub struct Targets { params: wgpu::Buffer, - /// The diffuse irradiance history of the traced grid, then the output grid's colour history (ping-pong). - hist: [Buf; 2], - /// The traced grid's g-buffer (ping-pong: this frame's, the previous frame's). - gb: [Buf; 2], - /// Ping-pong, per traced sample: what `judge` finds of the colour history (live.wgsl `Judged`) in the one a frame - /// writes, the diffuse fast mean in colour (`fast_load`) in the other, which the next frame reads before `judge`. - alb: [Buf; 2], - /// Specular history, diffuse luminance moments (ping-pong, traced grid), the specular filter's two slots, and the - /// scratch `dtemporal` and the upscaled image share. - shist: [Buf; 2], - mom: [Buf; 2], - sa: Buf, + /// What lasts from one frame to the next (live.wgsl, "The live passes' buffers"), two halves each: this frame's + /// and the previous frame's (`LiveParams::state`). Per traced sample: the diffuse irradiance's history, its + /// luminance moments and its fast mean in colour, the specular history, the g-buffer, the upscaler's state; per + /// output pixel: the colour history. + diffuse_hist: Buf, + diffuse_mom: Buf, + diffuse_fast: Buf, + spec_hist: Buf, + gbuffer: Buf, + up_state: Buf, + color_hist: Buf, + /// What lives within a frame (live.wgsl says who uses each when): per traced sample, screen velocity, view depth + /// and animated shading; the specular filter's two slots; the scratch the diffuse filter and the upscaled image + /// share; the irradiance filter's ping-pong, the composed colour and the image; bloom's pyramid and scratch. + sample_vel: Buf, + spec_pp: Buf, sb: Buf, - /// Reflection resolve: the resolved specular reflection per traced pixel (live.wgsl `refl`), from the trace - /// kernel's specular-lobe rays (2 vec4 per traced pixel, pathtrace.wgsl `refl`: `kernel.refl`). - refl_res: Buf, - /// Per traced sample: screen velocity, view depth, animated shading (live.wgsl `compose`); the frame's parity - /// picks which of the two is written, the other is not read. - xhist: [Buf; 2], fa: Buf, fb: Buf, bloom: [Buf; 2], + /// Reflection resolve: the resolved specular reflection per traced pixel (live.wgsl `refl`), from the trace + /// kernel's specular-lobe rays (2 vec4 per traced pixel, pathtrace.wgsl `refl`: `kernel.refl`). + refl_res: Buf, pub out: wgpu::Buffer, /// Auto exposure: 64 histogram buckets (fixed point), state (exposure, target, mean log2 luminance, valid). ae_bins: wgpu::Buffer, @@ -340,26 +343,23 @@ impl Targets { ); // The buffers, in the order of the fields below: a buffer each, or in the merged layout (merged.rs) a range of // the slab of its element type (each range starts at a multiple of 16 bytes: of every slab's element). - let specs: [(&str, u64, usize); 22] = [ - ("hist0", n * 32, SLAB_V4), // traced grid, then output grid (live.wgsl `out_hist`) - ("hist1", n * 32, SLAB_V4), - ("gb0", n * 16, SLAB_U2), - ("gb1", n * 16, SLAB_U2), - ("alb0", n * 16, SLAB_U4), - ("alb1", n * 16, SLAB_U4), - ("shist0", n * 16, SLAB_V4), - ("shist1", n * 16, SLAB_V4), - ("mom0", n * 16, SLAB_V4), - ("mom1", n * 16, SLAB_V4), - ("spec_a", n * 16, SLAB_U2), - ("spec_b", n * 16, SLAB_V4), - ("refl_res", n * 16, SLAB_V4), - ("direct_hist_a", n * 16, SLAB_V4), - ("direct_hist_b", n * 16, SLAB_V4), + // (The lasting ones are two halves of `n` elements: this frame's and the previous frame's.) + let specs: [(&str, u64, usize); 18] = [ + ("diffuse_hist", 2 * n * 16, SLAB_V4), + ("diffuse_mom", 2 * n * 16, SLAB_V4), + ("diffuse_fast", 2 * n * 8, SLAB_U2), + ("spec_hist", 2 * n * 16, SLAB_V4), + ("gbuffer", 2 * n * 8, SLAB_U2), + ("up_state", 2 * n * 16, SLAB_U4), + ("color_hist", 2 * n * 16, SLAB_V4), + ("sample_vel", n * 16, SLAB_V4), + ("spec_pp", n * 16, SLAB_U2), // two slots of `n` (vec2) + ("sb", n * 16, SLAB_V4), ("fa", n * 16, SLAB_V4), ("fb", n * 16, SLAB_V4), ("bloom_a", bloom_texels * 16, SLAB_V4), ("bloom_b", bn, SLAB_V4), + ("refl_res", n * 16, SLAB_V4), ("ae_state", 32, SLAB_V4), ("lumq", bn / 4, SLAB_F1), ("lumb", bn / 4, SLAB_F1), @@ -381,18 +381,20 @@ impl Targets { let mut b = || bufs.next().expect("a buffer of `specs`"); 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 }), - hist: [b(), b()], - gb: [b(), b()], - alb: [b(), b()], - shist: [b(), b()], - mom: [b(), b()], - sa: b(), + diffuse_hist: b(), + diffuse_mom: b(), + diffuse_fast: b(), + spec_hist: b(), + gbuffer: b(), + up_state: b(), + color_hist: b(), + sample_vel: b(), + spec_pp: b(), sb: b(), - refl_res: b(), - xhist: [b(), b()], fa: b(), fb: b(), bloom: [b(), b()], + refl_res: b(), out: sb("out", n * 4), ae_bins: sb("ae_bins", 64 * 4), ae_state: b(), @@ -406,43 +408,38 @@ impl Targets { } } - /// What live.wgsl's buffer bindings hold in the bind group of parity `a` (besides the uniforms at 0, the trace's - /// samples at 1, `out` at 10 and the histogram at 13): the history and the g-buffer swap roles with the parity. - fn buffers(&self, a: usize) -> [(u32, &Buf); 25] { - let b = 1 - a; + /// What live.wgsl's buffer bindings hold (besides the uniforms at 0, the trace's samples at 1, `out` at 10 and + /// the histogram at 13). + fn buffers(&self) -> [(u32, &Buf); 21] { [ - (2, &self.hist[a]), - (3, &self.hist[b]), - (4, &self.gb[a]), - (5, &self.gb[b]), - (6, &self.fa), - (7, &self.fb), - (8, &self.bloom[0]), - (9, &self.bloom[1]), + (2, &self.diffuse_hist), + (3, &self.color_hist), + (4, &self.gbuffer), + (5, &self.spec_hist), + (6, &self.diffuse_mom), + (7, &self.diffuse_fast), + (8, &self.up_state), + (9, &self.sample_vel), (14, &self.ae_state), (15, &self.lumq), (16, &self.lumb), - (17, &self.alb[a]), - (18, &self.alb[b]), - (19, &self.shist[a]), - (20, &self.shist[b]), - (21, &self.mom[a]), - (22, &self.mom[b]), - (23, &self.sa), - (24, &self.sb), - (25, &self.kernel.refl), - (26, &self.refl_res), - (27, &self.kernel.motion), - (28, &self.kernel.direct), - (29, &self.xhist[a]), - (30, &self.xhist[b]), + (17, &self.fa), + (18, &self.fb), + (19, &self.bloom[0]), + (20, &self.bloom[1]), + (21, &self.spec_pp), + (22, &self.sb), + (23, &self.kernel.refl), + (24, &self.refl_res), + (25, &self.kernel.motion), + (26, &self.kernel.direct), ] } - /// Two bind groups that swap history / g-buffer ping-pong roles on alternate frames. In the merged layout - /// (merged.rs) they bind the same slabs, and differ in the offsets the passes read (`LO`). - fn bind_groups(&self, d: &wgpu::Device, layout: &wgpu::BindGroupLayout, sample: &wgpu::Buffer) -> [wgpu::BindGroup; 2] { - let mk = |a: usize| { + /// The live passes' bind group. (The lasting buffers' halves alternate by `LiveParams::state`, not by binding.) In + /// the merged layout (merged.rs) it binds the slabs, and the offsets the passes read (`LO`). + fn bind_group(&self, d: &wgpu::Device, layout: &wgpu::BindGroupLayout, sample: &wgpu::Buffer) -> wgpu::BindGroup { + let mk = || { fn e(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() } } @@ -454,7 +451,7 @@ impl Targets { wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::Sampler(&self.sampler) }, e(13, &self.ae_bins), ]; - let buffers = self.buffers(a); + let buffers = self.buffers(); let offsets; match (&self.slabs, &self.kernel.merged) { (Some(slabs), Some((live, _))) => { @@ -482,7 +479,7 @@ impl Targets { } d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("live"), layout, entries: &entries }) }; - [mk(0), mk(1)] + mk() } } @@ -514,7 +511,7 @@ struct LoadedScene { triangles: usize, up: gpu::Uploaded, base: gpu::Params, - bgs: [wgpu::BindGroup; 2], + bg: wgpu::BindGroup, exposure: f32, exposure_settings: ExposureSettings, tan_half_fov: f32, @@ -1005,7 +1002,7 @@ fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32 base.ltree[3] = crate::ltree::live_candidates() as f32; } base.cam_fwd[3] = w as f32 / h as f32; - let bgs = targets.bind_groups(&g.device, &passes.layout, &up.accum); + let bg = targets.bind_group(&g.device, &passes.layout, &up.accum); let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); // Manual: 2^-EV100. Auto: a first guess for the firefly clamp (the GPU histogram takes over from frame 1). let exposure = match &sc.camera.exposure { @@ -1064,7 +1061,7 @@ fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32 inst_at_load: instances.len(), added: Default::default(), }; - Ok((LoadedScene { timings, triangles, tris, tri_nodes, tri_instances, pool_tris, meshes, instances, gpu_instances, procs, proc_of, proc_first, instanced, instance_features, name: sc.name.clone(), up, base, bgs, exposure, exposure_settings, tan_half_fov: c.tan_half_fov, scene_camera, dof: c.dof.clone(), tables, fixed_features, dynamic, edit, sky: None, emit, sky_inputs, mat_runs, tri_order }, sc.preset.post)) + Ok((LoadedScene { timings, triangles, tris, tri_nodes, tri_instances, pool_tris, meshes, instances, gpu_instances, procs, proc_of, proc_first, instanced, instance_features, name: sc.name.clone(), up, base, bg, exposure, exposure_settings, tan_half_fov: c.tan_half_fov, scene_camera, dof: c.dof.clone(), tables, fixed_features, dynamic, edit, sky: None, emit, sky_inputs, mat_runs, tri_order }, sc.preset.post)) } pub enum Cmd { @@ -3812,7 +3809,7 @@ impl LiveCore { } p.raster = [(self.raster.is_some() && !self.sc.instanced) as u32 as f32, jitter.0, jitter.1, 0.0]; p.raster[3] = if self.rays { 0.0 } else { 1.0 }; - if (!self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty()) { + if !self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty() { // The kernel finds the first dynamic triangle in Params::group_first (group 4), a u32. p.motion[0] = 1.0; } @@ -3955,9 +3952,10 @@ impl LiveCore { bt2: [t[4], t[5], 0.0, 0.0], 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], }; g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp)); - let bg = &self.sc.bgs[(self.frame % 2) as usize]; + let bg = &self.sc.bg; // Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion // blur follow, else fa: they have no motion vectors, so they are no part of the colour history; UE draws its // translucency after the upscaler the same way), then their volume voxels trained. @@ -4158,7 +4156,7 @@ impl LiveCore { }; let half = |b: u32, hi: bool| half::f16::from_bits(if hi { (b >> 16) as u16 } else { b as u16 }).to_f32(); let composed = read(&t.fb.buffer, t.fb.offset, tn * 16)?; - let spec = read(&t.sa.buffer, t.sa.offset + n * 8, tn * 8)?; // spec_pp slot 1: (rgb, history) as two pairs of f16 + let spec = read(&t.spec_pp.buffer, t.spec_pp.offset + n * 8, tn * 8)?; // spec_pp slot 1: (rgb, history) as two pairs of f16 let samples = read(&self.sc.up.accum, 0, tn * 80)?; // the kernel's samples (live.wgsl `Pixel`): emit at 48 let (composed, spec, samples): (&[[f32; 4]], &[[u32; 2]], &[[f32; 20]]) = (bytemuck::cast_slice(&composed), bytemuck::cast_slice(&spec), bytemuck::cast_slice(&samples)); let (mut dif, mut spe, mut emi) = (Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3)); diff --git a/renderer/src/merged.rs b/renderer/src/merged.rs index 80c9c8d..ea90e92 100644 --- a/renderer/src/merged.rs +++ b/renderer/src/merged.rs @@ -1,5 +1,5 @@ //! The merged binding layout: the engine's shaders with at most eight storage buffers a stage, WebGPU's default limit -//! (`maxStorageBuffersPerShaderStage`). The kernel binds 19 as it is written and the live passes 28: more than a +//! (`maxStorageBuffersPerShaderStage`). The kernel binds 19 as it is written and the live passes 24: more than a //! browser gives a page. There is one source: pathtrace.wgsl and live.wgsl, and the bindings gpu.rs and live.rs make for //! them, are what the native renderer runs, unchanged. This module writes the browser's from them, as text (as //! `gpu::kernel_source` patches the kernel for a scene's features), and gives the Rust side the numbers that go with @@ -15,8 +15,9 @@ //! - The kernel's six group-1 buffers (`KERNEL_LIVE`: the radiance cache, what the live passes read //! of a trace) become the arrays of `ML: MergedLive`, one buffer that they are ranges of (`gpu::KernelBufs`). //! - The live passes' buffers of one element type are ranges of one slab each (`SLABS`), indexed from an offset -//! the pass reads from a uniform (`LO`): the source does not change with the size of the view, and the two -//! bind groups of the history's ping-pong are two sets of offsets. `MK` is the kernel's `ML` buffer read as vec4s. +//! the pass reads from a uniform (`LO`): the source does not change with the size of the view. (The lasting +//! buffers' two halves alternate by a uniform, live.wgsl `this_half`.) `MK` is the kernel's `ML` buffer read as +//! vec4s. //! //! A use of a merged name the generator does not know (anything but `name[...]`, `&name[...]` and //! `arrayLength(&name)`), a declaration that is not found exactly once, a name the generator adds that the source @@ -146,32 +147,28 @@ pub struct LiveBuf { } /// In `MergedOffsets`' order (group 0, binding `OFFSETS_BINDING`: an (offset, length) pair of u32 each, in elements). -pub const LIVE_BUFS: [LiveBuf; 25] = [ - LiveBuf { name: "hist_in", binding: 2, slab: SLAB_V4, decl: "@group(0) @binding(2) var hist_in: array>;" }, - LiveBuf { name: "hist_out", binding: 3, slab: SLAB_V4, decl: "@group(0) @binding(3) var hist_out: array>;" }, - LiveBuf { name: "gb_in", binding: 4, slab: SLAB_U2, decl: "@group(0) @binding(4) var gb_in: array>;" }, - LiveBuf { name: "gb_out", binding: 5, slab: SLAB_U2, decl: "@group(0) @binding(5) var gb_out: array>;" }, - LiveBuf { name: "fa", binding: 6, slab: SLAB_V4, decl: "@group(0) @binding(6) var fa: array>;" }, - LiveBuf { name: "fb", binding: 7, slab: SLAB_V4, decl: "@group(0) @binding(7) var fb: array>;" }, - LiveBuf { name: "bloom_a", binding: 8, slab: SLAB_V4, decl: "@group(0) @binding(8) var bloom_a: array>;" }, - LiveBuf { name: "bloom_b", binding: 9, slab: SLAB_V4, decl: "@group(0) @binding(9) var bloom_b: array>;" }, +pub const LIVE_BUFS: [LiveBuf; 21] = [ + LiveBuf { name: "diffuse_hist", binding: 2, slab: SLAB_V4, decl: "@group(0) @binding(2) var diffuse_hist: array>;" }, + LiveBuf { name: "color_hist", binding: 3, slab: SLAB_V4, decl: "@group(0) @binding(3) var color_hist: array>;" }, + LiveBuf { name: "gbuffer", binding: 4, slab: SLAB_U2, decl: "@group(0) @binding(4) var gbuffer: array>;" }, + LiveBuf { name: "spec_hist", binding: 5, slab: SLAB_V4, decl: "@group(0) @binding(5) var spec_hist: array>;" }, + LiveBuf { name: "diffuse_mom", binding: 6, slab: SLAB_V4, decl: "@group(0) @binding(6) var diffuse_mom: array>;" }, + LiveBuf { name: "diffuse_fast", binding: 7, slab: SLAB_U2, decl: "@group(0) @binding(7) var diffuse_fast: array>;" }, + LiveBuf { name: "up_state", binding: 8, slab: SLAB_U4, decl: "@group(0) @binding(8) var up_state: array>;" }, + LiveBuf { name: "sample_vel", binding: 9, slab: SLAB_V4, decl: "@group(0) @binding(9) var sample_vel: array>;" }, LiveBuf { name: "ae_state", binding: 14, slab: SLAB_V4, decl: "@group(0) @binding(14) var ae_state: array, 2>;" }, LiveBuf { name: "lumq", binding: 15, slab: SLAB_F1, decl: "@group(0) @binding(15) var lumq: array;" }, LiveBuf { name: "lumb", binding: 16, slab: SLAB_F1, decl: "@group(0) @binding(16) var lumb: array;" }, - LiveBuf { name: "ae_in", binding: 17, slab: SLAB_U4, decl: "@group(0) @binding(17) var ae_in: array>;" }, - LiveBuf { name: "ae_out", binding: 18, slab: SLAB_U4, decl: "@group(0) @binding(18) var ae_out: array>;" }, - LiveBuf { name: "shist_in", binding: 19, slab: SLAB_V4, decl: "@group(0) @binding(19) var shist_in: array>;" }, - LiveBuf { name: "shist_out", binding: 20, slab: SLAB_V4, decl: "@group(0) @binding(20) var shist_out: array>;" }, - LiveBuf { name: "mom_in", binding: 21, slab: SLAB_V4, decl: "@group(0) @binding(21) var mom_in: array>;" }, - LiveBuf { name: "mom_out", binding: 22, slab: SLAB_V4, decl: "@group(0) @binding(22) var mom_out: array>;" }, - LiveBuf { name: "spec_pp", binding: 23, slab: SLAB_U2, decl: "@group(0) @binding(23) var spec_pp: array>;" }, - LiveBuf { name: "sb", binding: 24, slab: SLAB_V4, decl: "@group(0) @binding(24) var sb: array>;" }, - LiveBuf { name: "refl_in", binding: 25, slab: SLAB_K, decl: "@group(0) @binding(25) var refl_in: array>;" }, - LiveBuf { name: "refl_res", binding: 26, slab: SLAB_V4, decl: "@group(0) @binding(26) var refl_res: array>;" }, - LiveBuf { name: "motion_in", binding: 27, slab: SLAB_K, decl: "@group(0) @binding(27) var motion_in: array>;" }, - LiveBuf { name: "direct_in", binding: 28, slab: SLAB_K, decl: "@group(0) @binding(28) var direct_in: array>;" }, - LiveBuf { name: "xhist_in", binding: 29, slab: SLAB_V4, decl: "@group(0) @binding(29) var xhist_in: array>;" }, - LiveBuf { name: "xhist_out", binding: 30, slab: SLAB_V4, decl: "@group(0) @binding(30) var xhist_out: array>;" }, + LiveBuf { name: "fa", binding: 17, slab: SLAB_V4, decl: "@group(0) @binding(17) var fa: array>;" }, + LiveBuf { name: "fb", binding: 18, slab: SLAB_V4, decl: "@group(0) @binding(18) var fb: array>;" }, + LiveBuf { name: "bloom_a", binding: 19, slab: SLAB_V4, decl: "@group(0) @binding(19) var bloom_a: array>;" }, + LiveBuf { name: "bloom_b", binding: 20, slab: SLAB_V4, decl: "@group(0) @binding(20) var bloom_b: array>;" }, + LiveBuf { name: "spec_pp", binding: 21, slab: SLAB_U2, decl: "@group(0) @binding(21) var spec_pp: array>;" }, + LiveBuf { name: "sb", binding: 22, slab: SLAB_V4, decl: "@group(0) @binding(22) var sb: array>;" }, + LiveBuf { name: "refl_in", binding: 23, slab: SLAB_K, decl: "@group(0) @binding(23) var refl_in: array>;" }, + LiveBuf { name: "refl_res", binding: 24, slab: SLAB_V4, decl: "@group(0) @binding(24) var refl_res: array>;" }, + LiveBuf { name: "motion_in", binding: 25, slab: SLAB_K, decl: "@group(0) @binding(25) var motion_in: array>;" }, + LiveBuf { name: "direct_in", binding: 26, slab: SLAB_K, decl: "@group(0) @binding(26) var direct_in: array>;" }, ]; pub const OFFSETS_BINDING: u32 = 31; /// `MergedOffsets`' size: a pair for each of `LIVE_BUFS`, and one unused that makes it a multiple of 16 bytes. @@ -661,7 +658,7 @@ mod tests { #[test] fn the_merged_live_passes_fit_eight_storage_buffers() { let (m, info) = parse(LIVE); - assert_eq!(storage_buffers(&m, &info).1, 28, "the live passes as written"); + assert_eq!(storage_buffers(&m, &info).1, 24, "the live passes as written"); let passes = m.entry_points.len(); let (m, info) = parse(&live(LIVE)); let (used, declared) = storage_buffers(&m, &info); @@ -669,9 +666,9 @@ mod tests { assert!(used.iter().all(|u| u.1 as u32 <= STORAGE_BUFFERS), "{used:?}"); assert_eq!(used.len(), passes, "every pass is still there"); // The offsets' struct: a pair of u32 each, in order (what `live::Targets` writes). - assert_eq!(layout(&m, "MergedOffsets"), ((0..26).map(|k| k * 8).collect(), OFFSETS_BYTES as u64)); + assert_eq!(layout(&m, "MergedOffsets"), ((0..22).map(|k| k * 8).collect(), OFFSETS_BYTES as u64)); assert_eq!(OFFSETS_BYTES % 16, 0); - assert_eq!(LIVE_BUFS.len(), 25); + assert_eq!(LIVE_BUFS.len(), 21); } /// The Rust side's records are the size the merged structs give their elements. diff --git a/tools/render-identity.ts b/tools/render-identity.ts index 17a3486..2191254 100644 --- a/tools/render-identity.ts +++ b/tools/render-identity.ts @@ -5,10 +5,12 @@ // twice the larger of the two builds' own run-to-run differences (the largest relative difference of a value, under a // floor of 1e-3). `--tolerance r` lets a difference up to r pass as rounding (a shader compiled from other source can // round differently: 2e-6 is what removing unused kernel code did, 2026-11-13); it is reported apart. Exit 1 when a test -// differs beyond these or its output is missing on one side. +// differs beyond these or its output is missing on one side. Two runs a side under-read the spread of a few tests +// (2026-11-13: a skinning test that repeats itself twice and then differs by 2 %), so what is flagged is run again, +// alone, `--confirm` times a side (default 4), and judged on those runs. // -// node tools/render-identity.ts --a --b [--runs 2] [--tolerance 0] [--env K=V,K=V] -// [--filter id] [--out out/render-identity/] +// node tools/render-identity.ts --a --b [--runs 2] [--confirm 4] [--tolerance 0] +// [--env K=V,K=V] [--filter id] [--out out/render-identity/] // // `--env` goes to both builds (THREERT_MERGED=1: the browser's binding layout, natively). Each run is test-render with // an output directory of its own (it holds the GPU lock as it always does); the runs of one build follow each other. @@ -29,14 +31,16 @@ if (!isFile(flag("--a")) || !isFile(flag("--b"))) { const a = resolve(flag("--a")), b = resolve(flag("--b")); const runs = Number(flag("--runs", "2")); const tolerance = Number(flag("--tolerance", "0")); +const confirmRuns = Number(flag("--confirm", "4")); const extra = Object.fromEntries(flag("--env").split(",").filter(Boolean).map((kv) => kv.split("=") as [string, string])); const filter = flag("--filter"); const out = resolve(flag("--out", join(repoRoot, "out/render-identity", new Date().toISOString().replace(/[:.]/g, "-")))); -/** One test-render run on `bin`, its outputs in `dir` (its own: nothing else writes there). */ -function run(bin: string, dir: string) { +/** One test-render run on `bin` (the tests `only` names, else all / `--filter`), its outputs in `dir` (its own: + * nothing else writes there). */ +function run(bin: string, dir: string, only = filter) { rmSync(dir, { recursive: true, force: true }); try { - execFileSync("node", [join(repoRoot, "tools/test-render.ts"), ...(filter ? [filter] : []), "--out", dir], { env: { ...process.env, ...extra, THREERT_BIN: bin }, stdio: ["ignore", "ignore", "inherit"] }); + execFileSync("node", [join(repoRoot, "tools/test-render.ts"), ...(only ? [only] : []), "--out", dir], { env: { ...process.env, ...extra, THREERT_BIN: bin }, stdio: ["ignore", "ignore", "inherit"] }); } catch { // (Failing tests still leave their outputs; what is compared here is the pictures, not the verdicts.) } @@ -77,32 +81,42 @@ const spread = (ds: string[], f: string) => { return m; }; +/** One output across the runs: byte-identical, within the runs' spread, rounding (under --tolerance), or changed. */ +function judge(f: string, a: string[], b: string[]): { verdict: "same" | "within" | "rounding" | "changed"; why: string } { + const all = [...a, ...b]; + if (!all.every((d) => existsSync(join(d, f)))) return { verdict: "changed", why: `missing in ${all.filter((d) => !existsSync(join(d, f))).map((d) => d.split("/").pop()).join(", ")}` }; + const between = Math.max(...a.flatMap((x) => b.map((y) => diff(join(x, f), join(y, f))))); + if (between === 0) return { verdict: "same", why: "" }; + const noise = Math.max(spread(a, f), spread(b, f)); + if (noise > 0 && between <= 2 * noise) return { verdict: "within", why: `run to run ${noise.toExponential(2)}, between ${between.toExponential(2)}` }; + if (between <= tolerance) return { verdict: "rounding", why: `${between.toExponential(2)}, under --tolerance` }; + return { verdict: "changed", why: `${noise === 0 ? "repeatable, and" : `run to run ${noise.toExponential(2)},`} between the builds ${between.toExponential(2)}` }; +} + const names = new Set([...dirs.a, ...dirs.b].flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".raw")))); -let same = 0, within = 0, rounding = 0; +const count = { same: 0, within: 0, rounding: 0, changed: 0 }; const changed: string[] = []; for (const f of [...names].sort()) { - const all = [...dirs.a, ...dirs.b]; - if (!all.every((d) => existsSync(join(d, f)))) { - changed.push(`${f}: missing in ${all.filter((d) => !existsSync(join(d, f))).map((d) => d.split("/").pop()).join(", ")}`); - continue; - } - const between = Math.max(...dirs.a.flatMap((x) => dirs.b.map((y) => diff(join(x, f), join(y, f))))); - if (between === 0) { - same++; - continue; - } - const noise = Math.max(spread(dirs.a, f), spread(dirs.b, f)); - if (noise > 0 && between <= 2 * noise) { - within++; - continue; - } - if (between <= tolerance) { - rounding++; - console.log(` ${f}: ${between.toExponential(2)} (rounding, under --tolerance)`); - continue; + let j = judge(f, dirs.a, dirs.b); + if (j.verdict === "changed" && confirmRuns > 0) { + // Run that test alone, `confirmRuns` times a side, and judge on those runs. + const test = f.replace(/\.raw$/, ""); + console.log(`render-identity: ${f} flagged (${j.why}); ${confirmRuns} more runs a side of it alone`); + const again = { a: [] as string[], b: [] as string[] }; + for (const side of ["a", "b"] as const) { + for (let k = 0; k < confirmRuns; k++) { + const dir = join(out, "confirm", test, `${side}${k + 1}`); + run(side === "a" ? a : b, dir, test); + again[side].push(dir); + } + } + j = judge(f, again.a, again.b); + if (j.verdict !== "changed") j.why = `confirmed on ${confirmRuns} runs a side: ${j.why || "byte-identical"}`; } - changed.push(`${f}: ${noise === 0 ? "repeatable, and" : `run to run ${noise.toExponential(2)},`} between the builds ${between.toExponential(2)}`); + count[j.verdict]++; + if (j.verdict === "rounding" || (j.verdict === "within" && j.why.startsWith("confirmed"))) console.log(` ${f}: ${j.why}`); + if (j.verdict === "changed") changed.push(`${f}: ${j.why}`); } -console.log(`render-identity: ${names.size} outputs, ${same} byte-identical, ${within} within their run-to-run spread, ${rounding} within --tolerance, ${changed.length} changed (${out})`); +console.log(`render-identity: ${names.size} outputs, ${count.same} byte-identical, ${count.within} within their run-to-run spread, ${count.rounding} within --tolerance, ${count.changed} changed (${out})`); for (const c of changed) console.log(` ${c}`); process.exit(changed.length ? 1 : 0); From 8c3ae2a4c33085776119c9bf8afcb811a4537bc2 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Fri, 13 Nov 2026 22:38:18 +0800 Subject: [PATCH 2/2] live.rs in files: live/{mod, targets, load, edits, frame, session}.rs, no code changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderer/src/live.rs (4,931 lines) is now a directory: mod.rs keeps LiveCore itself (its settings, camera, resolution governor, readbacks) and what the others share; targets.rs the live passes' pipelines, screen-size buffers and bind group; load.rs a scene loaded and the workers of its sky and emitters; edits.rs the world's edits applied in place; frame.rs one frame (the trace, the live passes, the caches' training, the GPU timing); session.rs the headless runner (native only). The items moved as they were, made visible to the parent where it uses them; the references to live.rs in comments, docs and tests point at the file each item is in now. Checks: render-identity against the layout commit's build, native and THREERT_MERGED=1: nothing changed (190 outputs, 171 / 175 byte-identical, the rest within their run-to-run spread). cargo test, the wasm32 library and the web crate build, npm typecheck and test. render-identity: a confirming run that leaves no output is left out and counted, not an abort (geometry/grow-live's frame script sometimes runs past the script's time and writes no picture, on either build). ROADMAP § 1a records the step. Co-Authored-By: Claude Opus 5.5 --- docs/ROADMAP.md | 22 + docs/RUNTIME.md | 8 +- docs/VOXEL.md | 6 +- games/tokyo/tokyo-tod.game.ts | 2 +- games/voxel-level/level.game.ts | 2 +- renderer/shaders/live.wgsl | 12 +- renderer/shaders/pathtrace.wgsl | 6 +- renderer/src/gpu.rs | 18 +- renderer/src/lib.rs | 8 +- renderer/src/live.rs | 4931 ----------------- renderer/src/live/edits.rs | 2099 +++++++ renderer/src/live/frame.rs | 670 +++ renderer/src/live/load.rs | 304 + renderer/src/live/mod.rs | 1271 +++++ renderer/src/live/session.rs | 305 + renderer/src/live/targets.rs | 321 ++ renderer/src/main.rs | 2 +- renderer/src/merged.rs | 2 +- renderer/src/physics.rs | 2 +- renderer/src/scene.rs | 14 +- renderer/src/tables.rs | 6 +- renderer/src/units.rs | 6 +- renderer/src/voxel.rs | 2 +- renderer/src/world.rs | 24 +- tests/render/geometry-added-live.ts | 2 +- tests/render/geometry-edit-live.ts | 2 +- tests/render/geometry-edit-move-live.ts | 2 +- tests/render/geometry-emitter-added-live.ts | 2 +- tests/render/geometry-emitter-removed-live.ts | 2 +- tests/render/geometry-grow-live.ts | 2 +- tests/render/geometry-instanced-added-live.ts | 2 +- tests/render/geometry-instanced-live.ts | 4 +- .../render/geometry-instanced-removed-live.ts | 2 +- tests/render/geometry-model-added-live.ts | 2 +- tests/render/geometry-vertex-colors-live.ts | 2 +- tests/render/material-emitter-started-live.ts | 2 +- tools/bench-voxel.ts | 2 +- tools/render-identity.ts | 24 +- 38 files changed, 5082 insertions(+), 5013 deletions(-) delete mode 100644 renderer/src/live.rs create mode 100644 renderer/src/live/edits.rs create mode 100644 renderer/src/live/frame.rs create mode 100644 renderer/src/live/load.rs create mode 100644 renderer/src/live/mod.rs create mode 100644 renderer/src/live/session.rs create mode 100644 renderer/src/live/targets.rs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 116efd6..b20eb4b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2140,6 +2140,28 @@ Not done here: filters sized by hit distance, which is what would let a short hi Playground and the page still open a 1280 × 720 view; the live buffers are sized by the output (about 1.7 GB at 2560 × 1440), though the traced grid rarely fills it. +**The live passes' buffers by what they hold, and `live.rs` in files (2026-11-13, after it).** The live passes' state +had grown by reuse: a pair of buffers named `alb` held the diffuse fast mean, the upscaler's flicker and lock and +`judge`'s findings over one another; the output colour history sat behind the traced grid's diffuse history in one +buffer; velocity had a ping-pong partner nothing read; the g-buffer was allocated at twice what it holds; every pair +was swapped by binding the other of two bind groups. Now what lasts from one frame to the next has a buffer of its +own, named for what it holds, in two halves (this frame's and the last; `LiveParams.state` says which is which): +`diffuse_hist`, `diffuse_mom`, `diffuse_fast`, `spec_hist`, `gbuffer`, `up_state`, `color_hist`; the transients that +share a buffer are listed at the top of `live.wgsl` with who uses each when. The upscaler's state is one `vec4` +a pixel with half of z and all of w free: the lock's depth (the fix the moving lamps wait for, above) goes there. +Storage bindings 28 → 24, one bind group, 16 bytes a pixel less (59 MB at 1440p); the merged layout binds 21 live +buffers. `live.rs` (4,931 lines) is now `live/`: `mod.rs` (`LiveCore`, its settings, camera, governor, readbacks), +`targets.rs` (pipelines, buffers, bind group), `load.rs` (a scene loaded, the sky's and emitters' workers), +`edits.rs` (edits in place), `frame.rs` (one frame), `session.rs` (the headless runner); no code changed by the move. + +Checks: `render-identity`, native and `THREERT_MERGED=1`, two runs a side and four on what it flagged: nothing +changed, by the buffers or by the move. `bench-window` (still-bistro, tokyo-turn): within a run's spread with the +radiance cache on; with it off 0.03 ΔE between frames against main's 0.01 to itself (a shader compiled from other +source rounds differently). The live passes 6.27 → 6.13 ms (Bistro, 1440p at 0.375). On the way: `render-identity` +confirms what it flags on four more runs a side, and leaves out a confirming run without an output (the +`geometry/grow-live` test sometimes fails before its picture: its frame script, adding 1,100 objects, runs past the +script's time). + ## 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 3190106..66fba4d 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -46,7 +46,7 @@ runtime, and drives the engine directly, the way a UE game drives its actors: | Engine API | `packages/scene/src/engine.ts` (`Engine`), `renderer/src/script.rs` (`__engine` natives) | Handles (u32) for geometries (typed arrays), textures (asset path, encoded image or RGBA8 pixels), material-function code, materials, objects (group / mesh / model / light / camera) and their transform, visibility, power, light / camera parameters; the environment and render settings. Descriptors are JSON-shaped (`renderer/src/desc.rs`). | | Scene binding | `packages/scene/src/engine.ts` `SceneBinding` | Keeps the engine in step with a DSL scene. Objects report their own changes (core.ts change tracking: transforms, visibility, power, shadow flag, mesh geometry / material, add / remove), so a sync visits only what changed. Lights, cameras and materials are compared against what was last sent. | | World | `renderer/src/world.rs` | The engine's objects, resources, environment and settings, and what changed since the last commit: parameter changes (power, light / material parameters, environment, camera) or structural ones. Asset paths must stay inside the asset root (game code is untrusted on nodes). | -| Live commit | `renderer/src/live.rs` `LiveCore::commit`, `tables.rs` | Parameter changes rewrite GPU table entries in place (materials, lights, object power, emitter CDF, wetness / rain / fog, camera), and so does hiding or showing a volume, a light without a card or a particle system that is not light-sampled by its own `visible`. Anything structural rebuilds the GPU scene from the world, in memory; history survives, validated per pixel. | +| Live commit | `renderer/src/live/edits.rs` `LiveCore::commit`, `tables.rs` | Parameter changes rewrite GPU table entries in place (materials, lights, object power, emitter CDF, wetness / rain / fog, camera), and so does hiding or showing a volume, a light without a card or a particle system that is not light-sampled by its own `visible`. Anything structural rebuilds the GPU scene from the world, in memory; history survives, validated per pixel. | | Scene runtime | `renderer/src/script.rs` `ScriptHost` | QuickJS (rquickjs) with no I/O (no fs, network or timers; what needs the outside is asked of the app, "The host channel"). Loads a bundle (one script) or ES modules (`game.js` + `threert-runtime.js`, the cloud layout). Memory capped at 1 GiB. Time budgets: setup 120 s, event 1 s, frame 50 ms; the interrupt handler aborts overruns. Every entry ends by running the promise jobs it left, inside its budget ("Promises", below). The time the engine spends inside a voxel call (`voxelExplode`, `voxelEdit`, `voxelFrame`) is the engine's, not the script's: the deadline moves by it as measured, up to 250 ms an entry; past that it counts against the script, and a voxel call made after the deadline throws "interrupted" at once. A prelude adds `TextEncoder`, `structuredClone`, `atob` and `console`. | | Node host | `packages/gameplay/src/node-host.ts` | `globalThis.__threert`: `setup(room, seed)`, `event(type, payload, ts)` (local authority: execute + commit), `commit(writes, seq)` / `setState(state, seq)` (room authority), `state()`, `frame(dt, now)`, `hostMessages(list, ts)` (the app's messages), `sync()` (the engine follows the scene once more: after an entry's promise jobs), and the editor's `outline()`, `inspect(name)`, `edit(name, path, value)`. | | World handlers | `packages/gameplay/src/world.ts` | `on[type](world, ev)`: an event handled on this node, changing the scene at once. `WorldContext`: `scene`, `camera`, read-only `state`, `object(id)`, `time`, `now`, `random()`, `emit(type, payload)`. | @@ -421,7 +421,7 @@ The apps in this repository: | app | input | `net.*` | `launch` | HUD | | --- | --- | --- | --- | --- | | `threert-render play` (`view.rs`) | keys by physical position, mouse buttons, wheel; the pointer captured for `"look"` (a click; Esc lets go, Esc again quits) | WebSockets and HTTP on a thread each (`src/net.rs`: tungstenite and ureq on rustls; text frames of at most 1 MiB, bodies of at most 4 MiB, 16 sockets, 32 requests at once), any address | `--launch '{"room":"K7Q4MZ"}'` | not drawn; `--hud-log` prints each description | -| `threert-render live` (`live.rs`) | whatever the driver sends | none: the driver is the app | `--launch` | on stdout with every other post | +| `threert-render live` (`live/session.rs`) | whatever the driver sends | none: the driver is the app | `--launch` | on stdout with every other post | | the three.js view (`tools/three/view.ts`, `host.ts`, `hud.ts`) | keyboard, mouse, the Pointer Lock API | `WebSocket`, `fetch` (the browser's rules: same-origin policy, CORS) | the page's query (`?room=K7Q4MZ&name=Ada`) | DOM over the canvas | - `live` speaks the channel as JSON lines, so a game's use of it is testable without a window: on stdin @@ -918,7 +918,7 @@ procedural objects (`trace_procs`: the ray into the object's space, a tree of it step (the A/B). **Edits in place.** The edits that hardware traversal makes in place go through one type (`gpu::Top`: the TLAS and -its BLASes, or the units), so the engine's path is the same under both traversals (`live.rs` `move_instances`, +its BLASes, or the units), so the engine's path is the same under both traversals (`live/edits.rs` `move_instances`, `update_instanced`, `edit_geometry`): | edit | under software traversal with units | @@ -1245,7 +1245,7 @@ on the device", and differs where it says so. (a tenth of the rays an iPhone 15 Pro traces). This source does not reach it: the trace costs 0.42 to 0.65 µs a traced pixel at one bounce whatever the scene, which is 249 × 140 to 333 × 187 traced at 30 fps ("Checked on the device"). -- **Dynamic resolution by the clock** (`THREERT_GOVERN_CLOCK=1|0`, default on Android; `live.rs` +- **Dynamic resolution by the clock** (`THREERT_GOVERN_CLOCK=1|0`, default on Android; `live/mod.rs` `govern_resolution`). The render scale was governed in frames (a check every 30th, the urgent one after the first 120), which is twice a second and 2 s only at 60 frames a second. A scene opens at its full scale, on this GPU at about 6 frames a second, so it was a slide show for 25 s. With the clock it is at its frame rate 3 s after its diff --git a/docs/VOXEL.md b/docs/VOXEL.md index c18990c..9a2f7e2 100644 --- a/docs/VOXEL.md +++ b/docs/VOXEL.md @@ -38,7 +38,7 @@ What the research below sorts into each layer: (`renderer/src/gpu.rs:871`). - Incremental structure: adding or removing objects and replacing geometry without the full rebuild (`World::set_geometry` sets `structural`, `renderer/src/world.rs:242`; `LiveCore::rebuild` reloads the whole scene, - `renderer/src/live.rs:1548`; ~0.7 s on Kabukichō, ROADMAP § 4). + `renderer/src/live/mod.rs`; ~0.7 s on Kabukichō, ROADMAP § 4). - Physics and scene queries: rigid bodies, contacts, joints, ray / overlap / sweep queries, native (Rust). Done (ROADMAP § 6 step 7): `renderer/physics-core` on Rapier, the three.js-style surface in `packages/scene` (`mesh.physics`, joints as objects, `Raycaster.intersectPhysics`, `Shapecaster`). @@ -178,7 +178,7 @@ one run: 4 alternated rounds, an A/A of region: ### Measured: destruction (2026-11-10) ROADMAP § 6 step 2c-4: the level loaded by the native voxel objects (renderer/voxel-core, renderer/src/voxel.rs), -explosions applied in place (live.rs `edit_geometry`), pieces split by 4³ cell (8 voxels or more), at rest. +explosions applied in place (live/edits.rs `edit_geometry`), pieces split by 4³ cell (8 voxels or more), at rest. `tools/bench-destruction.ts`: M5 Pro, 0.5 floor, camera still at the path's start, rounds alternated with an A/A of each route, `THREERT_EDIT_RESERVE=4`. An explosion's frame: the worse of the frame that fires it and the one that applies its edit (interval, what a player sees). Three routes: @@ -773,7 +773,7 @@ above.) | The stand-in light | a point light at each burning fire, its intensity and colour from `FIRE_LIGHT`, measured against offline | live secondary and shadow rays have no media code: `pathtrace.wgsl` `media_shadows()` is false live (line 796), GI and reflection rays never meet a medium | offline the fire's light counts twice unless the game passes `lights: 0` (`level-destruction-fire` rendered offline has both); live, `media_train` adds the light to the radiance cache's voxels (`point_direct`, `point_sphere`): the fireball (and its smoke) is lit by it a second time; the table holds for the defaults (density, life scaled; measured by day over a floor and a wall, the fireball 0.6 of its size above it; past 8.8 m the 8.8 m values) | live rays see media: a medium's emission lights surfaces and shadows pass through it | `lights: 0`, then `tools/bench-voxel-fire.ts --only quality`: the street and the fireball's regions at least as close to offline as with the light; `voxel/fire-light` replaced by a live test of the fire's own light | | Idle fireballs parked | at y = −10,000, scale 1e-3, instead of hidden | `World::set_visible` (`world.rs` line 750): hiding anything but particles rebuilds the live scene | parked fireballs keep their slots among the kernel's 32 volumes, and every pixel's march still tests their boxes | **now** (2026-11-13: a volume or a light hidden by its own `visible` keeps its place in the build, in place; no media pass runs while every medium is hidden; `volume/hidden-in-place-live`, `light/hidden-in-place-live` and the two `shown`). What is left is `Explosions`' own | `visible = false` instead of parking; the bench's cost runs report no rebuild after an explosion | | Idle lights reach 1 mm | an idle light's `distance` is 1 mm where it is parked | `light_reach` (`lib.rs` line 211): a black light without a range reaches without limit, and the kernel does not skip black lights before the group test (`pathtrace.wgsl` line 1578) | parked lights 10 km away share a group of 16 with lit ones, and groups are not sorted again when lights move (`lib.rs` line 277): the group's sphere is about 5 km wide while any fire burns (the per-light range test still works) | **now** (2026-11-13: black lights are in no group's sphere and are skipped first; a hidden light's entry is black; the groups are sorted again when a local light moves or goes dark or lit). What is left is `Explosions`' own | distance 0 on idle lights; the fire route's GPU time before any explosion within 0.1 ms of the smoke route's | -| The smoke kept by never rebuilding | the pool's objects movable from the start; idle ones parked, not hidden (`Explosions` has no grid now: this keeps a game's own `SimulatedSmoke`) | `LiveCore::rebuild` (`live.rs` line 3474) → `load_scene` → `Gpu::upload` makes new grid textures (`gpu.rs` line 1664): the simulated state is not carried over | any other structural change in the game (an object added, a texture, an edit's fallback) wipes the smoke and the fire burning in it | **now** (2026-11-13: a rebuild copies fields A and B of every simulated grid that is the same grid still into the new scene's textures, `Gpu::carry_grids`; `volume/grid-rebuild-live`). What is left is `Explosions`' own | the bench's smoke check with a rebuild forced at 1 s (an object added): the smoke at 3 s as without it | +| The smoke kept by never rebuilding | the pool's objects movable from the start; idle ones parked, not hidden (`Explosions` has no grid now: this keeps a game's own `SimulatedSmoke`) | `LiveCore::rebuild` (`live/mod.rs`) → `load_scene` → `Gpu::upload` makes new grid textures (`gpu.rs` line 1664): the simulated state is not carried over | any other structural change in the game (an object added, a texture, an edit's fallback) wipes the smoke and the fire burning in it | **now** (2026-11-13: a rebuild copies fields A and B of every simulated grid that is the same grid still into the new scene's textures, `Gpu::carry_grids`; `volume/grid-rebuild-live`). What is left is `Explosions`' own | the bench's smoke check with a rebuild forced at 1 s (an object added): the smoke at 3 s as without it | | Volumes must not overlap | `Explosions` has no smoke grid (it refuses one); the smoke is each fireball's own. Two fires close together still make two fireballs that overlap: nothing keeps them apart (no distance rule) | `media_track` (offline) and `media_march` (live) in `pathtrace.wgsl` take the media whole, in the order a ray enters them (at most 32, a 32-bit mask), not their coefficients summed along the ray; hence the rule (`core.ts` `VolumeMesh`, MATERIAL_FUNCTIONS.md) | a fire inside smoke is drawn behind all of it (measured: not seen at all at 0.2 and 0.5 s, "Why volumes must not overlap"); neighbouring fireballs hide each other in the same way; no smoke grid around explosions | **now** (2026-11-13: the kernel takes the ray a stretch at a time and sums the coefficients of the media it is in, offline and live; `volume/overlap-emission`, `-live`). What is left is `Explosions`' own: taking a grid again, in a PR of the voxel line's | puff a `SimulatedSmoke` around fireballs again (`Explosions` taking a grid) and run the comparison that found it (`bench-voxel-fire` at d7b4e4a, `--only smoke --ages 0.2,0.5`): the fire seen in its smoke offline and live; two overlapping fireballs both seen | ### Where the engine works round its dependencies diff --git a/games/tokyo/tokyo-tod.game.ts b/games/tokyo/tokyo-tod.game.ts index 1197ac9..917eabe 100644 --- a/games/tokyo/tokyo-tod.game.ts +++ b/games/tokyo/tokyo-tod.game.ts @@ -160,7 +160,7 @@ export function sceneFor(id: string) { win.name = `windows ${t.x} ${t.z} ${g}`; scene.add(win); // A group switches on every tile in the same frame: any power change costs the live view's commit ~13 ms (the - // emitter CDF and the light tree rebuilt over all 1.4 M emitters, renderer/src/live.rs write_emitters), so fewer + // emitter CDF and the light tree rebuilt over all 1.4 M emitters, renderer/src/live/edits.rs write_emitters), so fewer // switching frames is better until that is incremental. lamps.push({ name: win.name, at: windowSwitchAt(g) }); } diff --git a/games/voxel-level/level.game.ts b/games/voxel-level/level.game.ts index a40657d..df607c8 100644 --- a/games/voxel-level/level.game.ts +++ b/games/voxel-level/level.game.ts @@ -30,7 +30,7 @@ function heightfield(phase: number): BufferGeometry { } /** - * `edits` (ROADMAP § 6 step 2c-4, live.rs `edit_geometry`): every half second, one object's geometry is replaced: with + * `edits` (ROADMAP § 6 step 2c-4, live/edits.rs `edit_geometry`): every half second, one object's geometry is replaced: with * box representations, the procedural tile under the camera's start swaps between its own voxels and its neighbour's; * with the mesh, a `dynamicGeometry` heightfield there swaps between two shapes. The frame log's `edit` / `rebuild` * time it (tools/bench-voxel.ts --edits). diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index 55aee21..b33f96e 100644 --- a/renderer/shaders/live.wgsl +++ b/renderer/shaders/live.wgsl @@ -18,7 +18,7 @@ struct LiveParams { size: vec4, // width, height, frame, reset history (1) misc: vec4, // exposure scale, max history, vignette intensity, bloom on (1) / off (0) misc2: vec4, // denoise strength, sample clamp (display units at the current exposure), -, samples this frame - mode: vec4, // view mode (0 lit, 1 base color, 2 world normal, 3 lit no denoise, 4 raw sample, 5 history), -, the scene's lighting is changing (live.rs `live_lighting`), - + mode: vec4, // view mode (0 lit, 1 base color, 2 world normal, 3 lit no denoise, 4 raw sample, 5 history), -, the scene's lighting is changing (live/mod.rs `live_lighting`), - post: vec4, // chromatic aberration (UE scene fringe), film grain, bloom threshold (<= 0 off), - ae: vec4, // auto exposure: low / high fraction, histogram log2 min / max ae2: vec4, // compensation (2^bias; manual: the exposure), min / max average luminance, mode (1 = auto) @@ -43,7 +43,7 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec // ---- The live passes' buffers, by what they hold and how long. // // What lasts from one frame to the next has a buffer of its own, in two halves: this frame's, which the passes write -// (`this_half`), and the previous frame's, which they read (`last_half`); live.rs alternates them (`L.state.x`). A half +// (`this_half`), and the previous frame's, which they read (`last_half`); live/frame.rs alternates them (`L.state.x`). A half // is L.size.x * L.size.y elements, indexed by traced sample (by output pixel: `color_hist`). // diffuse_hist the diffuse irradiance's history: rgb, frames (`dtemporal`; the à-trous passes read its length) // diffuse_mom its luminance moments: mean, mean square, the fast mean's luminance, 1 where written (`dtemporal`) @@ -53,7 +53,7 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec // color_hist the output grid's colour history: rgb, samples (`upscale`) // up_state the upscaler's state per traced sample (`compose`): x, y `Flicker`; z `Lock` (its life, f16; the // other half free); w free -// What lives within a frame shares buffers whose uses do not overlap (the passes run in live.rs `Passes` order): +// What lives within a frame shares buffers whose uses do not overlap (the passes run in live/targets.rs `Passes` order): // sample_vel per traced sample: screen velocity (output pixels this frame), view depth, animated shading // (`compose`; `judge`, `upscale`, depth of field and motion blur read it) // judged `judge`'s findings per traced sample (`Judged`), in up_state's previous half: `compose` has read it @@ -88,7 +88,7 @@ struct Pixel { color: vec4, albedo: vec4, normal: vec4, emit: vec @group(0) @binding(20) var bloom_b: array>; // horizontal blur scratch @group(0) @binding(21) var spec_pp: array>; @group(0) @binding(22) var sb: array>; -// What the trace leaves (the kernel's group 1, live.rs `Targets::kernel`). Reflection resolve: the specular-lobe rays, +// What the trace leaves (the kernel's group 1, live/targets.rs `Targets::kernel`). Reflection resolve: the specular-lobe rays, // 2 per traced pixel ((radiance back, lobe pdf; 0 = none), (specular albedo E, octahedral direction)), and the // resolved specular reflection per traced pixel (`refl`). @group(0) @binding(23) var refl_in: array>; @@ -479,7 +479,7 @@ fn dtemporal(@builtin(global_invocation_id) gid: vec3) { } } } - // How long the history may be at most is the frame's (`misc.y`: live.rs HISTORY_LIVE while the scene's lighting + // How long the history may be at most is the frame's (`misc.y`: live/mod.rs HISTORY_LIVE while the scene's lighting // changes, far longer once it has settled); within it, each pixel's own: less where it moves (until 2026-11-12 the // whole frame's was held to 48 while the camera moved), a few frames on animated shading, and anti-lag below. let cap = mix(select(L.misc.y, min(L.misc.y, 4.0), anim), min(L.misc.y, select(HIST_MOVING, 4.0, anim)), clamp(speed, 0.0, 1.0)); @@ -1048,7 +1048,7 @@ const THIN_LIKE: f32 = 1.05; // 0.5: 4.47 / 0.69; not spared (main) 5.06 / 0.71. In a slow turn (0.25 degrees a frame) 2.23 / 1.05, 2.17 / 0.80, // main 2.81 / 0.77. const LOCK_RELAX: f32 = 0.75; -// The frames of the jitter sequence (live.rs: 8 at full resolution, as many more as an output pixel has fewer samples). +// The frames of the jitter sequence (live/frame.rs: 8 at full resolution, as many more as an output pixel has fewer samples). fn jitter_phases() -> f32 { return clamp(round(8.0 * f32(L.size.x) * f32(L.size.y) / (f32(L.isz.x) * f32(L.isz.y))), 8.0, 512.0); } diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 23e224b..c5e2144 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -2503,7 +2503,7 @@ fn oct_enc(n: vec3) -> u32 { // bounce); each vertex's estimate is added. Sums are fixed point with stochastic rounding (`cache_acc`); // `cache_resolve` folds them into `cache_val` once per frame. -const CACHE_SIZE: u32 = 262144u; // entries, a power of two (live.rs CACHE_SIZE) +const CACHE_SIZE: u32 = 262144u; // entries, a power of two (gpu.rs CACHE_SIZE) const CACHE_PROBE: u32 = 8u; // linear probing window const CACHE_ANGLE: f32 = 0.02; // voxel size / distance to the camera const CACHE_MIN_VOXEL: f32 = 0.0625; // m @@ -2839,7 +2839,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // Raster primary: one visibility sample per pixel at the frame's jitter (lens sampling would need its own rays). let raster = LIVE && P.raster.x > 0.5 && P.cam_right.w <= 0.0; - // Live: one sub-pixel jitter a frame for the whole image (live.rs: Halton 2, 3), which the temporal upscaler + // Live: one sub-pixel jitter a frame for the whole image (live/frame.rs: Halton 2, 3), which the temporal upscaler // resolves (live.wgsl `upscale`) and the raster pass draws at. Offline, every sample draws its own. var jx = rnd(); var jy = rnd(); @@ -3072,7 +3072,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // Live: the path ends in the radiance cache at its first bounce. Surfaces seen in mirror-like reflections // (specular lobe below roughness 0.4, where UE traces reflections) get exact direct light, as UE's hit // lighting does, and the cache's indirect part; others reflect the cache's total. Rotating tiles train the - // cache. For the first frames after a reset or a camera cut (live.rs COLD_FALLBACK_FRAMES; flag 1 later), + // cache. For the first frames after a reset or a camera cut (live/mod.rs COLD_FALLBACK_FRAMES; flag 1 later), // pixels that find their voxel cold trace a fresh estimate and use it meanwhile, without inserting the voxel: // the low-sample entries they inserted kept later frames slower until evicted (the crowd's square ~2 ms). let cfl = u32(P.cache.w); diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index cc58c08..a92217a 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -118,7 +118,7 @@ pub struct Params { pub background: [f32; 4], } -/// Room the live view's in-place geometry edits grow into (live.rs `edit_geometry`), past the scene's own data: +/// Room the live view's in-place geometry edits grow into (live/edits.rs `edit_geometry`), past the scene's own data: /// triangles (verts, attrs, BLAS vertices), kernel records and TLAS instances, object-table entries, procedural words. /// Only scenes with editable geometry (procedural meshes, `own_blas` meshes) under hardware RT get any: a quarter of /// what they have, at least a minimum; `THREERT_EDIT_RESERVE` scales it (0: none). Past it an edit rebuilds the scene. @@ -582,7 +582,7 @@ pub struct Accel { pool_first: usize, /// The TLAS index of model instance 0 (the traversal groups' instances come first). pub inst_base: usize, - /// The build's triangle BLAS bytes per triangle (Metal's sizes): edits' memory estimates (live.rs `room_stats`). + /// The build's triangle BLAS bytes per triangle (Metal's sizes): edits' memory estimates (live/mod.rs `room_stats`). pub blas_per_tri: f64, /// The TLAS index of the first procedural object's instance (after the model instances; each has its own AABB /// BLAS, which its TLAS instance keeps). @@ -1062,7 +1062,7 @@ impl CacheBuffers { } /// The kernel's group-1 buffers: the radiance cache, and what the live passes read of a trace -/// (live.rs `Targets`): the specular-lobe rays, the motion vectors, the directional lights' direct light. In the +/// (live/targets.rs `Targets`): the specular-lobe rays, the motion vectors, the directional lights' direct light. In the /// merged layout they are ranges of one buffer, the arrays of the kernel's `MergedLive` (merged.rs `KERNEL_LIVE`). pub struct KernelBufs { pub cache: CacheBuffers, @@ -1839,7 +1839,7 @@ impl Gpu { // by the same figures.) let room = EditRoom::for_scene(scene, tri_verts.len() / 3, records.len(), self.hwrt, scene.units.is_some()); let st = wgpu::BufferUsages::STORAGE; - // (COPY_SRC: the live view's in-place edits grow these past their room by a copy, live.rs `edit_geometry`.) + // (COPY_SRC: the live view's in-place edits grow these past their room by a copy, live/edits.rs `edit_geometry`.) let edit = st | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC; let b_verts = buf_room("verts", bytemuck::cast_slice(tri_verts), room.tris * 48, edit | wgpu::BufferUsages::COPY_SRC); let dynamic_first: usize = groups[..4].iter().sum(); @@ -2769,7 +2769,7 @@ impl Gpu { /// Rebuilds the BLASes of the dynamic groups (posed models, after their vertices changed) and the TLAS. /// Rebuilds the TLAS alone (instance transforms changed, no triangles). /// `old`'s contents in a new buffer of `size` bytes (its usage and label), copied on the GPU and submitted now, so - /// writes queued after this land on the copy (live.rs `edit_geometry` growing past its room). + /// writes queued after this land on the copy (live/edits.rs `edit_geometry` growing past its room). pub fn grown(&self, old: &wgpu::Buffer, size: u64, label: &str) -> wgpu::Buffer { let b = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size, usage: old.usage(), mapped_at_creation: false }); let mut e = self.device.create_command_encoder(&Default::default()); @@ -2819,7 +2819,7 @@ impl Gpu { (blas, (parts, sizes)) } - /// A new instanced mesh over `count` triangles from GPU-order index `first` (in the edit room; live.rs + /// A new instanced mesh over `count` triangles from GPU-order index `first` (in the edit room; live/edits.rs /// `edit_geometry`), its first `opaque` ones opaque: its BLAS, which `rebuild_meshes` builds. Returns its index. pub fn add_mesh(&self, accel: &mut Accel, first: usize, count: u32, opaque: u32) -> u32 { // Built fast rather than traced fast (edits come every frame under destruction; the batches of pieces are rebuilt @@ -2835,7 +2835,7 @@ impl Gpu { &accel.meshes[k as usize] } - /// The AABB BLAS of a procedural geometry's boxes (live.rs `edit_geometry`): the one kept by its hash or built + /// The AABB BLAS of a procedural geometry's boxes (live/edits.rs `edit_geometry`): the one kept by its hash or built /// earlier in this edit (`fresh`), or a new one whose build goes into `e` and into `fresh`. The edit keeps `fresh` /// (`keep_proc_blases`) only once `e` is submitted: an edit that falls back never submits, and a BLAS never built /// must not be reused. @@ -2865,7 +2865,7 @@ impl Gpu { } } - /// The scene bind group again, from `up`'s buffers as they are now (a buffer that grew: live.rs `write_emitters`). + /// The scene bind group again, from `up`'s buffers as they are now (a buffer that grew: live/edits.rs `write_emitters`). pub fn rebind_scene(&self, up: &mut Uploaded) { fn e(binding: u32, b: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { wgpu::BindGroupEntry { binding, resource: b.as_entire_binding() } @@ -3019,7 +3019,7 @@ impl Gpu { /// The BLASes of instanced meshes `meshes` and, with `tlas`, the TLAS in the same build (the TLAS reads the BLASes' /// bounds through their resource ids, which Metal does not order a later build after: a TLAS built apart from a - /// BLAS it references, in the same encoder, could see it unbuilt and lose its instance; live.rs `edit_geometry`). + /// BLAS it references, in the same encoder, could see it unbuilt and lose its instance; live/edits.rs `edit_geometry`). pub fn build_meshes(&self, accel: &Accel, meshes: &[u32], e: &mut wgpu::CommandEncoder, tlas: bool) { self.build_edit(accel, meshes, &[], e, tlas); } diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index 1f027d8..c39ea82 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -74,13 +74,13 @@ pub struct Prepared { pub light_tree: Option, /// Material -> runs of its flattened static triangles (first, end in the traversal order, object), and the /// materials that also cover pool or dynamic triangles: a material that starts emitting gets emitter entries in - /// place from its runs (live.rs `add_material_emitters`), unless fixed. + /// place from its runs (live/edits.rs `add_material_emitters`), unless fixed. pub mat_runs: std::collections::HashMap>, pub fixed_mats: std::collections::HashSet, /// The flattened triangles' build indices in traversal order (`Scene::tris` order: positions for new entries). pub tri_order: Vec, /// The brightest sky texel (luminance) from which the environment is light-sampled (`Params::counts2[1]`): 0 when - /// there are no emitters. A dynamic sky re-decides with each bake (live.rs `DynamicSky`). + /// there are no emitters. A dynamic sky re-decides with each bake (live/load.rs `DynamicSky`). pub env_nee_floor: f32, } @@ -402,7 +402,7 @@ fn units_said(var: Option<&str>, live: bool) -> bool { } /// Whether `prepare_live` will keep the scene's instances as units (software traversal, and it has instances), asked -/// before it runs (live.rs `load_scene`: the light tree's part for emitters edits add). +/// before it runs (live/load.rs `load_scene`: the light tree's part for emitters edits add). pub fn units_wanted(hwrt: bool, sc: &scene::Scene) -> bool { !hwrt && units_on(true) && !sc.instances.is_empty() && sc.instances.len() <= units::MAX_UNITS } @@ -675,7 +675,7 @@ pub fn prepare_as(hwrt: bool, units: bool, sc: &mut scene::Scene, timings: &mut let procs = [env_cdf.len() as u32, sc.procs.len() as u32, sc.gpu_instances.len() as u32, sc.vcolor_base]; // Environment light sampling (one shadow ray per vertex) is skipped when the sky is negligible next to the emitters // (night streets): escaping BSDF rays still pick it up, with full MIS weight. The emitters as if all on: a - // time-of-day room loaded by day decides at dusk as one loaded at night does (live.rs `put_sky`). + // time-of-day room loaded by day decides at dusk as one loaded at night does (live/edits.rs `put_sky`). let env_max = env.texels.iter().map(|t| 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2]).fold(0.0f32, f32::max).max(0.2126 * sky_color.x + 0.7152 * sky_color.y + 0.0722 * sky_color.z); let em_area: f32 = emitters[..emitter_tris.len()].iter().map(|e| e[3]).sum(); let env_nee_floor = if emitter_tris.is_empty() { 0.0 } else { 0.01 * all_power / em_area.max(1e-6) }; diff --git a/renderer/src/live.rs b/renderer/src/live.rs deleted file mode 100644 index d168492..0000000 --- a/renderer/src/live.rs +++ /dev/null @@ -1,4931 +0,0 @@ -//! Real-time renderer (LiveCore), modelled on UE5's real-time path with hardware RT: every frame traces 1+ spp -//! (primary visibility, GI, shadows, reflections), reprojects and accumulates history (converging to the offline -//! reference while the camera holds still), denoises and applies the UE post chain. Drawn by the native window viewer -//! (`view`) and the playground client; `live` runs it headless at a fixed frame rate for benchmarks: -//! -//! threert-render live [--assets DIR] [--room id] [--width 1280 --height 720 --fps 60 --bounces 4 -//! --scale 1|0.6|auto --display-scale 1|2|3] [--frame-log frames.jsonl] -//! -//! `--scale` is the render scale (UE screen percentage): the frame is traced, denoised and composed at that fraction -//! of the output resolution and the temporal upscaler takes it to the output; `auto` = dynamic resolution. -//! `--display-scale`: the output is a display's physical pixels, that many a point (a Retina window: 2), as the -//! window host's is; dynamic resolution then starts from the points' resolution and goes down to half of it. -//! -//! stdin (JSON lines): {"cmd":"camera","pos":[x,y,z],"target":[x,y,z]} | {"cmd":"event","type":t,"payload":{..}} -//! | {"cmd":"click","u":u,"v":v} | {"cmd":"reset"} | {"cmd":"time","t":s} (set the scene time) -//! | {"cmd":"snapshot","path":p,"after":k (history length ≥ k),"frames":n (the n-th frame from now, default 1)} -//! (a path ending in .raw: the linear radiance, RGB f32 at output size; ending in .signals: -//! its diffuse / specular / emission apart, -{diffuse,specular,emission}.raw, as -//! `render --signals` writes them) -//! | {"cmd":"sync","id":s,"frames":n} (stderr "live: sync s" after the n-th frame from now) -//! | {"cmd":"lockstep","dt":s | "on":false} (render only the frames steps ask for, at frame time s) -//! | {"cmd":"camera",...,"frames":n,"sync":id | "snapshot":p} | {"cmd":"frames","n":n,"sync":id | "snapshot":p} -//! (a step: camera, frames, and the sync answer / snapshot at its last frame, in one command) -//! | {"cmd":"scale","scale":s (0 = auto)} | {"cmd":"quit"} - -use crate::gpu::Buf; -use crate::world::World; -use crate::{gpu, post, scene, tables}; -// (The headless runner below, native only, is what uses all of it.) -#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use anyhow::{anyhow, Result}; -use glam::Vec3; -#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use std::io::BufRead; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::mpsc; -use std::sync::Arc; -#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] -use std::time::Duration; -use web_time::Instant; - -pub struct LiveOpts { - pub width: u32, - pub height: u32, - pub fps: u32, - pub bounces: u32, - /// Render scale (traced / output resolution); None = dynamic resolution. - pub scale: Option, - /// Game bundle (game code + runtime) that builds and drives the scene in-process. - pub bundle: PathBuf, - /// Asset root its model / texture paths resolve against. - pub assets: PathBuf, - pub room: String, - /// JSON line per frame (wall interval, CPU work, GPU time, scale, history): hitch statistics (tools/bench-hitches.ts). - /// `interval` runs from the end of one frame's work to the next's, so it carries the variation of the work itself; - /// `start` runs from one frame's start to the next's, which the pacer sets: the frame rate a player sees. - pub frame_log: Option, - /// The output's pixels a point of its display (1: the output is in points). - pub display_scale: f32, - /// `--launch`: the `params` of the host channel's `launch` message (docs/RUNTIME.md "The host channel"). - pub launch: Option, -} - -/// Lowest dynamic-resolution scale (UE r.DynamicRes.MinScreenPercentage default) of a view whose pixels are the -/// display's points; a host whose view is in the display's physical pixels divides it by the display's scale factor -/// (`LiveCore::min_scale`), so the fewest rays a window traces do not depend on the display's density. -pub const MIN_SCALE: f32 = 0.5; -/// The lowest render scale any view takes (a view of 3 pixels a point at `MIN_SCALE`, rounded down). -pub const SCALE_FLOOR: f32 = 0.125; - -#[repr(C)] -#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] -struct CamU { - pos: [f32; 4], - fwd: [f32; 4], - right: [f32; 4], - up: [f32; 4], -} - -#[repr(C)] -#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] -struct LiveParams { - cur: CamU, - prev: CamU, - size: [u32; 4], - misc: [f32; 4], - misc2: [f32; 4], - mode: [u32; 4], - post: [f32; 4], - /// Auto exposure (UE histogram): low / high fraction, histogram log2 min / max. - ae: [f32; 4], - /// Compensation (2^bias), min / max average luminance, mode (0 manual, 1 auto). - ae2: [f32; 4], - /// Speed up / down (EV/s), frame time (s), snap to target (1). - ae3: [f32; 4], - /// Local exposure: highlight contrast, shadow contrast, blurred-luminance blend, detail strength. - lx: [f32; 4], - /// Traced (internal) width, height. - isz: [u32; 4], - /// Bloom stage radii (texels of each stage's level) and tints, stages 0..3 then 4, 5. - br: [f32; 4], - br2: [f32; 4], - bt: [f32; 4], - bt2: [f32; 4], - /// Depth of field: CoC radius of infinity (output px; 0 = off), focus distance (m), max radius (px), -. - dof: [f32; 4], - /// This frame's sub-pixel jitter (every camera ray's: x, y in 0 .. 1 of a traced pixel), -, -. - 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], -} - -/// Bloom pyramid level j (0..4: 1/4 .. 1/64 resolution) size; stage k (0..5) runs on level max(k - 1, 0). -fn bloom_dim(w: u32, h: u32, j: u32) -> (u32, u32) { - (w.div_ceil(4).div_ceil(1 << j), h.div_ceil(4).div_ceil(1 << j)) -} - -fn bloom_stage_level(k: u32) -> u32 { - k.max(1) - 1 -} - -/// UE Standard bloom per stage: Gaussian radius in texels of its level (BloomNSize % of the stage width * size scale -/// / 2, as `post::bloom`) and tint (BloomNTint * intensity / 6). -fn bloom_stages(pp: &crate::desc::PostPreset, w: u32, h: u32) -> ([f32; 6], [f32; 6]) { - let (mut r, mut t) = ([0f32; 6], [0f32; 6]); - for k in 0..6 { - let (lw, _) = bloom_dim(w, h, bloom_stage_level(k as u32)); - r[k] = lw as f32 * pp.bloom.sizes.get(k).copied().unwrap_or(0.0) * pp.bloom.size_scale * 0.01 * 0.5; - t[k] = pp.bloom.tints.get(k).copied().unwrap_or(0.0) * pp.bloom.intensity / 6.0; - } - (r, t) -} - -/// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). -#[derive(Clone, Copy, Default)] -struct ExposureSettings { - ae: [f32; 4], - ae2: [f32; 4], - speed: [f32; 2], - lx: [f32; 4], -} - -impl ExposureSettings { - fn new(exp: &crate::desc::Exposure, pp: &crate::desc::PostPreset) -> Self { - let a = &pp.auto_exposure; - let le = &pp.local_exposure; - let (ae2, comp) = match exp { - crate::desc::Exposure::Manual { ev100 } => ([2f32.powf(-ev100), 0.0, 0.0, 0.0], 2f32.powf(-ev100)), - crate::desc::Exposure::Auto { compensation, min_ev100, max_ev100 } => { - let c = 2f32.powf(*compensation); - ([c, 0.18 * 2f32.powf(*min_ev100), 0.18 * 2f32.powf(*max_ev100), 1.0], c) - } - }; - let _ = comp; - ExposureSettings { - ae: [a.low_percent.clamp(1.0, 99.0) / 100.0, a.high_percent.clamp(1.0, 99.0) / 100.0, a.histogram_log_min, a.histogram_log_max.max(a.histogram_log_min + 1.0)], - ae2, - speed: [a.speed_up.max(0.01), a.speed_down.max(0.01)], - lx: [le.highlight_contrast, le.shadow_contrast, le.blurred_luminance_blend, le.detail_strength], - } - } -} - -#[derive(Clone, Copy, PartialEq)] -pub struct Camera { - pub pos: Vec3, - pub fwd: Vec3, - pub up: Vec3, - pub right: Vec3, - pub tan_half_fov: f32, -} - -impl Camera { - pub fn look_at(pos: Vec3, target: Vec3, tan_half_fov: f32) -> Camera { - let fwd = (target - pos).normalize(); - let right = fwd.cross(Vec3::Y).normalize(); - Camera { pos, fwd, up: right.cross(fwd), right, tan_half_fov } - } - fn uniform(&self, aspect: f32) -> CamU { - CamU { - pos: [self.pos.x, self.pos.y, self.pos.z, self.tan_half_fov], - fwd: [self.fwd.x, self.fwd.y, self.fwd.z, aspect], - right: [self.right.x, self.right.y, self.right.z, 0.0], - up: [self.up.x, self.up.y, self.up.z, 0.0], - } - } -} - -const LOG_OFFSET: f32 = 0.0026883; // must match live.wgsl lin_to_log input offset -const LUT: u32 = 32; - -/// UE-style 32^3 LUT: log-encoded linear input -> display sRGB, built from the exact CPU port of the filmic tonemapper. -fn tonemap_lut(p: &crate::desc::PostPreset) -> Vec { - let tm = post::Tonemapper::new(p); - let mut out = Vec::with_capacity((LUT * LUT * LUT * 4) as usize); - let log_to_lin = |v: f32| (2f32.powf((v - 444.0 / 1023.0) * 14.0) * 0.18 - LOG_OFFSET).max(0.0); - for b in 0..LUT { - for g in 0..LUT { - for r in 0..LUT { - let enc = |k: u32| log_to_lin(k as f32 / (LUT - 1) as f32); - let d = tm.apply(Vec3::new(enc(r), enc(g), enc(b))); - for c in [d.x, d.y, d.z, 1.0] { - out.push(half::f16::from_f32(post::linear_to_srgb(c.clamp(0.0, 1.0))).to_bits()); - } - } - } - } - out -} - -/// The live passes' source: live.wgsl as it is, or in the merged layout its buffers merged into slabs (merged.rs). -pub(crate) fn live_source(merged: bool) -> std::borrow::Cow<'static, str> { - let src = include_str!("../shaders/live.wgsl"); - if merged { - crate::merged::live(src).into() - } else { - src.into() - } -} - -struct Passes { - layout: wgpu::BindGroupLayout, - pipes: Vec<(&'static str, wgpu::ComputePipeline)>, -} - -impl Passes { - fn new(g: &gpu::Gpu) -> Passes { - let d = &g.device; - let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("live"), source: wgpu::ShaderSource::Wgsl(live_source(g.merged).into()) }); - let buf = |binding: u32, read_only: bool| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None }, - count: None, - }; - let mut entries = vec![wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, - count: None, - }]; - entries.extend([buf(1, true), buf(2, false), buf(3, false), buf(4, false), buf(5, false), buf(6, false), buf(7, false), buf(8, false), buf(9, false), buf(10, false)]); - entries.push(wgpu::BindGroupLayoutEntry { - binding: 11, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false }, - count: None, - }); - entries.push(wgpu::BindGroupLayoutEntry { binding: 12, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None }); - entries.extend([buf(13, false), buf(14, false), buf(15, false), buf(16, false), buf(17, false), buf(18, false)]); - entries.extend([buf(19, false), buf(20, false), buf(21, false), buf(22, false), buf(23, true), buf(24, false), buf(25, true), buf(26, true)]); - // The merged layout: the buffers that stay, the slabs, and the offsets' uniform (merged.rs). - if g.merged { - use crate::merged::{LIVE_KEPT, OFFSETS_BINDING, SLABS}; - entries.retain(|e| !matches!(e.ty, wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { .. }, .. }) || LIVE_KEPT.iter().any(|k| k.0 == e.binding)); - entries.extend(SLABS.iter().map(|s| buf(s.binding, s.read_only))); - entries.push(wgpu::BindGroupLayoutEntry { binding: OFFSETS_BINDING, ..entries[0] }); - } - let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("live"), entries: &entries }); - let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); - let pipes = ["refl", "dtemporal", "atrous0", "atrous1", "atrous2", "atrous3", "atrous4", "stemporal", "sfilt0", "sfilt1", "sfilt2", "compose", "judge", "reject", "upscale", "dof_tiles", "dof", "mblur", "lum_down", "exposure", "lum_blur", "bloom_down", "bloom_dn1", "bloom_dn2", "bloom_dn3", "bloom_dn4", - "bloom_h5", "bloom_v5", "bloom_h4", "bloom_v4", "bloom_h3", "bloom_v3", "bloom_h2", "bloom_v2", "bloom_h1", "bloom_v1", "bloom_h0", "bloom_v0", "post"] - .into_iter() - .map(|name| { - (name, d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(name), layout: Some(&pl), module: &module, entry_point: Some(name), compilation_options: Default::default(), cache: g.cache() })) - }) - .collect(); - g.save_pipeline_cache(); - Passes { layout, pipes } - } -} - -/// Screen-size targets shared across scene reloads. -pub struct Targets { - params: wgpu::Buffer, - /// What lasts from one frame to the next (live.wgsl, "The live passes' buffers"), two halves each: this frame's - /// and the previous frame's (`LiveParams::state`). Per traced sample: the diffuse irradiance's history, its - /// luminance moments and its fast mean in colour, the specular history, the g-buffer, the upscaler's state; per - /// output pixel: the colour history. - diffuse_hist: Buf, - diffuse_mom: Buf, - diffuse_fast: Buf, - spec_hist: Buf, - gbuffer: Buf, - up_state: Buf, - color_hist: Buf, - /// What lives within a frame (live.wgsl says who uses each when): per traced sample, screen velocity, view depth - /// and animated shading; the specular filter's two slots; the scratch the diffuse filter and the upscaled image - /// share; the irradiance filter's ping-pong, the composed colour and the image; bloom's pyramid and scratch. - sample_vel: Buf, - spec_pp: Buf, - sb: Buf, - fa: Buf, - fb: Buf, - bloom: [Buf; 2], - /// Reflection resolve: the resolved specular reflection per traced pixel (live.wgsl `refl`), from the trace - /// kernel's specular-lobe rays (2 vec4 per traced pixel, pathtrace.wgsl `refl`: `kernel.refl`). - refl_res: Buf, - pub out: wgpu::Buffer, - /// Auto exposure: 64 histogram buckets (fixed point), state (exposure, target, mean log2 luminance, valid). - ae_bins: wgpu::Buffer, - ae_state: Buf, - /// Quarter-resolution log2 luminance and its edge-aware blur (local exposure base). - lumq: Buf, - lumb: Buf, - /// The kernel's group 1 (`gpu::KernelBufs`): the radiance cache, and what the passes here read of - /// a trace: the specular-lobe rays (`refl`), motion vectors per traced pixel (pathtrace.wgsl `motion_out`) and the - /// deterministic direct light per traced pixel (`direct_out`). - pub kernel: gpu::KernelBufs, - /// The merged layout (merged.rs): the slabs the buffers above are ranges of (`SLABS`' first four). - slabs: Option<[wgpu::Buffer; 4]>, - lut_tex: wgpu::Texture, - lut_view: wgpu::TextureView, - sampler: wgpu::Sampler, -} - -impl Targets { - /// For a view of `w` x `h` and a radiance cache of `cache` entries. - fn new(g: &gpu::Gpu, w: u32, h: u32, lut: &[u16], cache: u64) -> Targets { - use crate::merged::{SLABS, SLAB_F1, SLAB_U2, SLAB_U4, SLAB_V4}; - let d = &g.device; - let n = (w * h) as u64; - let sb = |label: &str, bytes: u64| d.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: bytes, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }); - let bn = (w.div_ceil(4) * h.div_ceil(4)) as u64 * 16; - let texels = |j: u32| bloom_dim(w, h, j).0 as u64 * bloom_dim(w, h, j).1 as u64; - let bloom_texels = (0..5).map(texels).sum::() + (0..6).map(|k| texels(bloom_stage_level(k))).sum::(); - let lut_tex = d.create_texture(&wgpu::TextureDescriptor { - label: Some("tonemap lut"), - size: wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D3, - format: wgpu::TextureFormat::Rgba16Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - g.queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - bytemuck::cast_slice(lut), - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, - wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, - ); - // The buffers, in the order of the fields below: a buffer each, or in the merged layout (merged.rs) a range of - // the slab of its element type (each range starts at a multiple of 16 bytes: of every slab's element). - // (The lasting ones are two halves of `n` elements: this frame's and the previous frame's.) - let specs: [(&str, u64, usize); 18] = [ - ("diffuse_hist", 2 * n * 16, SLAB_V4), - ("diffuse_mom", 2 * n * 16, SLAB_V4), - ("diffuse_fast", 2 * n * 8, SLAB_U2), - ("spec_hist", 2 * n * 16, SLAB_V4), - ("gbuffer", 2 * n * 8, SLAB_U2), - ("up_state", 2 * n * 16, SLAB_U4), - ("color_hist", 2 * n * 16, SLAB_V4), - ("sample_vel", n * 16, SLAB_V4), - ("spec_pp", n * 16, SLAB_U2), // two slots of `n` (vec2) - ("sb", n * 16, SLAB_V4), - ("fa", n * 16, SLAB_V4), - ("fb", n * 16, SLAB_V4), - ("bloom_a", bloom_texels * 16, SLAB_V4), - ("bloom_b", bn, SLAB_V4), - ("refl_res", n * 16, SLAB_V4), - ("ae_state", 32, SLAB_V4), - ("lumq", bn / 4, SLAB_F1), - ("lumb", bn / 4, SLAB_F1), - ]; - let slabs: Option<[wgpu::Buffer; 4]> = g.merged.then(|| std::array::from_fn(|k| sb(SLABS[k].name, specs.iter().filter(|s| s.2 == k).map(|s| s.1.next_multiple_of(16)).sum()))); - let mut at = [0u64; 4]; - let bufs: Vec = specs - .iter() - .map(|&(label, bytes, slab)| match &slabs { - None => Buf::whole(sb(label, bytes)), - Some(s) => { - let b = Buf { buffer: s[slab].clone(), offset: at[slab], size: bytes }; - at[slab] += bytes.next_multiple_of(16); - b - } - }) - .collect(); - let mut bufs = bufs.into_iter(); - let mut b = || bufs.next().expect("a buffer of `specs`"); - 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 }), - diffuse_hist: b(), - diffuse_mom: b(), - diffuse_fast: b(), - spec_hist: b(), - gbuffer: b(), - up_state: b(), - color_hist: b(), - sample_vel: b(), - spec_pp: b(), - sb: b(), - fa: b(), - fb: b(), - bloom: [b(), b()], - refl_res: b(), - out: sb("out", n * 4), - ae_bins: sb("ae_bins", 64 * 4), - ae_state: b(), - lumq: b(), - lumb: b(), - kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, n * 16), - slabs, - 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() }), - } - } - - /// What live.wgsl's buffer bindings hold (besides the uniforms at 0, the trace's samples at 1, `out` at 10 and - /// the histogram at 13). - fn buffers(&self) -> [(u32, &Buf); 21] { - [ - (2, &self.diffuse_hist), - (3, &self.color_hist), - (4, &self.gbuffer), - (5, &self.spec_hist), - (6, &self.diffuse_mom), - (7, &self.diffuse_fast), - (8, &self.up_state), - (9, &self.sample_vel), - (14, &self.ae_state), - (15, &self.lumq), - (16, &self.lumb), - (17, &self.fa), - (18, &self.fb), - (19, &self.bloom[0]), - (20, &self.bloom[1]), - (21, &self.spec_pp), - (22, &self.sb), - (23, &self.kernel.refl), - (24, &self.refl_res), - (25, &self.kernel.motion), - (26, &self.kernel.direct), - ] - } - - /// The live passes' bind group. (The lasting buffers' halves alternate by `LiveParams::state`, not by binding.) In - /// the merged layout (merged.rs) it binds the slabs, and the offsets the passes read (`LO`). - fn bind_group(&self, d: &wgpu::Device, layout: &wgpu::BindGroupLayout, sample: &wgpu::Buffer) -> wgpu::BindGroup { - let mk = || { - fn e(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { - wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() } - } - let mut entries = vec![ - e(0, &self.params), - e(1, sample), - e(10, &self.out), - wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.lut_view) }, - wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - e(13, &self.ae_bins), - ]; - let buffers = self.buffers(); - let offsets; - match (&self.slabs, &self.kernel.merged) { - (Some(slabs), Some((live, _))) => { - use crate::merged::{LIVE_BUFS, OFFSETS_BINDING, SLABS, SLAB_K}; - // Each merged buffer's (first element, length) in its slab, in `MergedOffsets`' order. - let lo: Vec<[u32; 2]> = LIVE_BUFS - .iter() - .map(|m| { - let buf = buffers.iter().find(|b| b.0 == m.binding).expect("a merged buffer's binding").1; - let stride = SLABS[m.slab].stride; - debug_assert!(buf.offset % stride == 0 && buf.size % stride == 0); - [(buf.offset / stride) as u32, (buf.size / stride) as u32] - }) - .collect(); - // (And the struct's unused last pair: `merged::OFFSETS_BYTES`.) - let mut lo = lo; - lo.push([0, 0]); - debug_assert_eq!(lo.len() * 8, crate::merged::OFFSETS_BYTES); - offsets = wgpu::util::DeviceExt::create_buffer_init(d, &wgpu::util::BufferInitDescriptor { label: Some("live offsets"), contents: bytemuck::cast_slice(&lo), usage: wgpu::BufferUsages::UNIFORM }); - entries.extend(slabs.iter().zip(&SLABS).map(|(s, m)| e(m.binding, s))); - entries.push(e(SLABS[SLAB_K].binding, live)); - entries.push(e(OFFSETS_BINDING, &offsets)); - } - _ => entries.extend(buffers.iter().map(|(binding, buf)| wgpu::BindGroupEntry { binding: *binding, resource: buf.binding() })), - } - d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("live"), layout, entries: &entries }) - }; - mk() - } -} - -struct LoadedScene { - /// Load phases (parse, bvh, sky, accel build, upload) in ms. - timings: Vec<(String, f64)>, - /// World-space triangles + per-node ranges, kept on the CPU for picking and selection outlines; instanced meshes - /// (model space) and their placements likewise. - tris: Vec<[glam::Vec3A; 3]>, - tri_nodes: Vec<(String, u32, u32)>, - /// Flattened instances of instanced objects: (first, end, instance index), for picking. - tri_instances: Vec<(u32, u32, u32)>, - pool_tris: Vec<[glam::Vec3A; 3]>, - meshes: Vec, - instances: Vec, - /// The instances' kernel records as uploaded (movable ones are rewritten when they move), then the procedural - /// objects' (from `proc_first`). - gpu_instances: Vec, - /// Procedural geometry (moves, picking), by node in `proc_of`; its records start at `proc_first`. - procs: Vec, - proc_of: std::collections::HashMap, - proc_first: usize, - /// The scene has instanced meshes or procedural geometry (the raster experiment draws only flattened triangles: - /// primary visibility is traced). - instanced: bool, - /// Kernel features of the instanced meshes (gpu::instance_features), which edits cannot change. - instance_features: u32, - name: String, - triangles: usize, - up: gpu::Uploaded, - base: gpu::Params, - bg: wgpu::BindGroup, - exposure: f32, - exposure_settings: ExposureSettings, - tan_half_fov: f32, - scene_camera: Camera, - /// The camera's depth of field (UE: off unless it sets a focus distance). - dof: Option, - /// GPU table copies that parameter edits rewrite in place (`commit`). - tables: tables::Tables, - /// CPU copy of the dynamic triangles (posed models: the tail of the GPU triangle order from `dynamic_first`): - /// vertices (kernel layout), shading attributes, BLAS vertices. - dynamic: (usize, Vec<[f32; 4]>, Vec, Vec<[f32; 3]>), - /// Kernel feature inputs edits cannot change: textures, material functions, traversal groups. - fixed_features: (bool, bool, [usize; crate::GROUPS]), - /// Where in-place geometry edits write next (`edit_geometry`); a rebuild starts it afresh. - edit: EditState, - /// The atmosphere's sky once its sun has moved in place (`move_sun` starts it), and what it bakes with: the - /// environment map's size, the viewer's altitude and the environment's light-sampling floor, as the load had them. - sky: Option, - /// Power edits' emitter rebuilds on a worker (`EmitterWorker`), for scenes with many emitters. - emit: Option, - sky_inputs: ((u32, u32), f32, f32), - /// For materials that start emitting (`add_material_emitters`): their flattened static triangles' runs (`prepare`) - /// and the flattened triangles' build indices in traversal order (positions in `tris`). - mat_runs: std::collections::HashMap>, - tri_order: Vec, -} - -/// Cores the sky's worker thread bakes with: the rest stay with the frame (scripts, uploads, the GPU's driver). -const SKY_THREADS: usize = 3; - -/// An atmosphere whose sun moves while the scene runs (time of day). The sun's light is rewritten in place at every -/// move, its direction and its colour through the atmosphere (`sky::sun_illuminance`, no bake needed), which carries -/// the direct light and the shadows. The sky's radiance (the environment map and its sampling CDF) is baked again on a -/// worker thread with `SKY_THREADS` cores, the newest sun first, and lands with a later frame (`take_sky`): the sky -/// trails the sun by one bake (~10–40 ms at `sky::DYNAMIC_SKY`). Lockstep runs wait for it, so they repeat. -struct DynamicSky { - atm: Arc, - /// (sun direction, sun illuminance above the atmosphere, cloud cover) - ask: mpsc::Sender<(glam::Vec3, glam::Vec3, f32)>, - done: mpsc::Receiver<(gpu::EnvMap, Vec, f32)>, - /// The brightest sky texel from which the environment is light-sampled (`Prepared::env_nee_floor`). - env_nee_floor: f32, -} - -impl DynamicSky { - fn new(world: &World, (w, h): (u32, u32), altitude: f32, env_nee_floor: f32) -> Option { - let crate::desc::Sky::Atmosphere { rayleigh_scale, mie_scale, mie_anisotropy, ground_albedo, glow, horizon_glow, sun: Some(_), .. } = &world.environment.sky else { - return None; - }; - let atm = Arc::new(crate::sky::Atmosphere::earth(*rayleigh_scale, *mie_scale, *mie_anisotropy, glam::Vec3::from(*ground_albedo))); - let glow = crate::sky::glow_of(glow, horizon_glow); - let (ask, asked) = mpsc::channel::<(glam::Vec3, glam::Vec3, f32)>(); - let (tx, done) = mpsc::channel(); - let a = atm.clone(); - std::thread::Builder::new() - .name("sky bake".into()) - .spawn(move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(SKY_THREADS).build().ok(); - let tables = match &pool { - Some(p) => p.install(|| crate::sky::SkyTables::new(&a)), - None => crate::sky::SkyTables::new(&a), - }; - let bake = |sun: (glam::Vec3, glam::Vec3, f32)| { - let b = crate::sky::bake_atmosphere_tables(&a, &tables, sun.0, sun.1, altitude, w, h, glow, sun.2); - let env = gpu::EnvMap { width: b.width, height: b.height, texels: b.texels }; - let cdf = crate::build_env_cdf(&env); - let max = env.texels.iter().map(|t| 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2]).fold(0.0f32, f32::max); - (env, cdf, max) - }; - // The newest sun only: bakes asked for while one ran are stale. - while let Ok(mut sun) = asked.recv() { - while let Ok(s) = asked.try_recv() { - sun = s; - } - let out = match &pool { - Some(p) => p.install(|| bake(sun)), - None => bake(sun), - }; - if tx.send(out).is_err() { - break; - } - } - }) - .ok()?; - Some(DynamicSky { atm, ask, done, env_nee_floor }) - } -} - -/// Scenes with at least this many emitters rebuild their emitter CDF for power edits on a worker (`EmitterWorker`). -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -const ASYNC_EMITTERS: usize = 100_000; - -/// Power edits in a scene with many emitters (a city's lamps and windows switching with the clock): the emitter CDF and -/// the light tree's node powers are rebuilt on a worker thread (~13 ms over 1.4 M emitters, which held up the frame in -/// `write_emitters`), and the edits land with them, in a later frame (`take_emitters`): the objects' powers, the CDF -/// and the tree's powers together, so sampling and shading switch at once. The worker writes the CDF and the tree's -/// nodes into a mapped staging buffer; the frame only copies it on the GPU (a 22 MB `write_buffer` cost it ~5 ms). -/// Lockstep frames wait for it, so they repeat. Scenes whose emitters change in place (added in place: the tree's slot; -/// moving emitters, whose nodes the frame refits) keep `write_emitters`; so does any other emitter edit (materials), -/// which first lands the edits held back. -struct EmitterWorker { - ask: mpsc::Sender, - done: mpsc::Receiver, - /// The emitters it was made for: a list that grew (added in place) makes another. - count: usize, - /// The edits of the job it is on (they land with its result). - flight: Option>, -} - -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -struct EmitterJob { - materials: Vec, - objects: Vec<[f32; 4]>, - lod: [f32; 4], - cull: crate::EmitterCull, -} - -struct EmitterBuilt { - /// The CDF then the tree's nodes, as the emitters buffer holds them (`put_emitters`), and their bytes. - staging: wgpu::Buffer, - bytes: u64, - /// The CDF's entries (its guide table included). - entries: usize, - total: f32, - power: f32, - /// The light tree's node powers (`nodes[i][2]`), for the frame's copy of the tree. - node_powers: Vec, -} - -impl EmitterWorker { - /// wasm32: none, and power edits rebuild in the frame as before (the browser's WebGPU device and buffers stay on - /// the thread that made them). - #[cfg(target_arch = "wasm32")] - fn new(_e: &tables::Tables, _device: &wgpu::Device) -> Option { - None - } - - #[cfg(not(target_arch = "wasm32"))] - fn new(e: &tables::Tables, device: &wgpu::Device) -> Option { - let changing = e.light_tree.as_ref().is_some_and(|t| t.dynamic.is_some() || t.moving_count > 0); - if e.emitter_tris.len() < ASYNC_EMITTERS || changing { - return None; - } - let device = device.clone(); - let (tris, mut tree, stride) = (e.emitter_tris.clone(), e.light_tree.clone(), e.stride); - let (ask, asked) = mpsc::channel::(); - let (tx, done) = mpsc::channel(); - std::thread::Builder::new() - .name("emitters".into()) - .spawn(move || { - while let Ok(j) = asked.recv() { - let (em, total, power) = crate::emitter_cdf(&tris, &j.materials, &j.objects, stride, j.lod, j.cull); - let node_powers = tree.as_mut().map_or(vec![], |t| { - let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], &j.materials, &j.objects, stride)).collect(); - t.refit(&|k| w[k]); - t.nodes.iter().map(|n| n[2]).collect() - }); - let nodes: &[[f32; 4]] = tree.as_ref().map_or(&[], |t| &t.nodes); - let bytes = ((em.len() + nodes.len()) * 16) as u64; - let staging = device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters staging"), size: bytes.max(16), usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: true }); - let at = em.len() as u64 * 16; - for (range, data) in [(0..at, bytemuck::cast_slice::<_, u8>(&em)), (at..bytes, bytemuck::cast_slice(nodes))] { - if !data.is_empty() { - let Ok(mut m) = staging.slice(range).get_mapped_range_mut() else { break }; - m.copy_from_slice(data); - } - } - staging.unmap(); - if tx.send(EmitterBuilt { staging, bytes, entries: em.len(), total, power, node_powers }).is_err() { - break; - } - } - }) - .ok()?; - Some(EmitterWorker { ask, done, count: e.emitter_tris.len(), flight: None }) - } -} - -/// Writes to one buffer gathered during an edit and sent merged: adjacent ranges as one `write_buffer` (an edit writes -/// hundreds of small records and meshes, contiguous in the room). Overlapping writes go in the order they came. -#[derive(Default)] -struct Gather { - parts: Vec<(u64, Vec)>, -} - -impl Gather { - fn put(&mut self, at: u64, bytes: &[u8]) { - self.parts.push((at, bytes.to_vec())); - } - - fn flush(self, q: &wgpu::Queue, b: &wgpu::Buffer) { - let mut parts = self.parts; - let mut sorted = parts.clone(); - sorted.sort_by_key(|p| p.0); - if sorted.windows(2).any(|w| w[0].0 + w[0].1.len() as u64 > w[1].0) { - for (at, bytes) in parts.drain(..) { - q.write_buffer(b, at, &bytes); - } - return; - } - let mut run: Option<(u64, Vec)> = None; - for (at, bytes) in sorted { - match &mut run { - Some((start, data)) if *start + data.len() as u64 == at => data.extend_from_slice(&bytes), - _ => { - if let Some((start, data)) = run.take() { - q.write_buffer(b, start, &data); - } - run = Some((at, bytes)); - } - } - } - if let Some((start, data)) = run { - q.write_buffer(b, start, &data); - } - } -} - -/// Where an in-place edit's CPU time goes (ms) and what it did: building the triangles' GPU form, uploading them, -/// creating and recording BLASes and the TLAS; meshes added and rewritten in place, objects added or removed. Frame log -/// `edit_split`. -#[derive(Default, Clone, Copy, Debug)] -pub struct EditSplit { - pub tris: f64, - pub upload: f64, - pub blas: f64, - pub added: usize, - pub deformed: usize, - pub objects: usize, -} - -impl EditSplit { - fn add(&mut self, o: &EditSplit) { - (self.tris, self.upload, self.blas) = (self.tris + o.tris, self.upload + o.upload, self.blas + o.blas); - (self.added, self.deformed, self.objects) = (self.added + o.added, self.deformed + o.deformed, self.objects + o.objects); - } -} - -/// The edit room in use (gpu.rs `EditRoom`): the next free triangle (GPU order), kernel record, object-table entry, -/// procedural word (counted from the region's start) and TLAS instance, and where they started. -#[derive(Default)] -struct EditState { - /// Triangles (GPU order), kernel records (a record's TLAS instance is `inst_base` + it) and object-table entries - /// edits can place: holes left by what they no longer draw, then the room, which grows past its end (ROADMAP § 6 - /// step 5). - tris: Holes, - records: Holes, - objects: Holes, - /// The next procedural word, and where the region (grown as needed) ends. - word: usize, - word_cap: usize, - /// The instanced mesh each object edits manage draws (`own_blas`: the build's and theirs), each mesh's objects, the - /// meshes edits may free (own-BLAS meshes: shared only by objects of one geometry and material), and the slots of - /// freed ones, reused by the next new mesh. - mesh_of: std::collections::HashMap, - users: std::collections::HashMap, - managed: std::collections::HashSet, - dead_meshes: Vec, - /// Times the room grew (frame log `grows`), and whether the scene bind group must be made again. - grows: u32, - rebind: bool, - /// The uniform tables (`gpu::Gpu::uniform_tables`): an edit placed entries past what their arrays hold, and it - /// was said (once a scene, as its upload says it of the scene's own). - uniform_said: bool, - /// (triangles, records, objects, words, TLAS instances) the scene itself has: the room starts there. - base: (usize, usize, usize, usize, usize), - /// The first pool triangle (instanced meshes' triangles are counted from it). - pool_first: usize, - /// Procedural blobs in the region by hash: their first word (the build's and those edits wrote). - blobs: std::collections::HashMap<[u8; 32], usize>, - /// Objects edits added: their (TLAS instance, record); instanced meshes edits added: their first record, capacity - /// and first object entry (contiguous, as `update_instanced` rewrites them). - placed: std::collections::HashMap, - instanced: std::collections::HashMap, - /// The build's model instances (records below are theirs, in `LoadedScene::instances` order). - inst_at_load: usize, - /// What clicks pick among the objects edits added (`LiveCore::pick`; the build's are `instances` and `procs`). - added: std::collections::BTreeMap, -} - -/// Ranges of one kind of the edit room: free holes (start -> length; first fit, neighbours merged) below `end`, the -/// part in use, which may reach `cap`, what the buffers hold (more by growing them). -#[derive(Default, Debug)] -struct Holes { - free: std::collections::BTreeMap, - end: usize, - cap: usize, - /// Where the room starts (the scene's own data before it). - base: usize, -} - -impl Holes { - fn new(end: usize, cap: usize) -> Holes { - Holes { free: Default::default(), end, cap, base: end } - } - - /// `n` contiguous: the first hole that fits, else at the end if within `cap`. - fn take(&mut self, n: usize) -> Option { - if let Some((&at, &len)) = self.free.iter().find(|(_, &len)| len >= n) { - self.free.remove(&at); - if len > n { - self.free.insert(at + n, len - n); - } - return Some(at); - } - (self.end + n <= self.cap).then(|| { - self.end += n; - self.end - n - }) - } - - /// `n` from `at` free again: merged with the holes beside it; at the end, the end moves back. - fn give(&mut self, at: usize, n: usize) { - if n == 0 { - return; - } - let (mut a, mut len) = (at, n); - if let Some((&pa, &pl)) = self.free.range(..at).next_back().filter(|(&pa, &pl)| pa + pl == at) { - self.free.remove(&pa); - (a, len) = (pa, len + pl); - } - if let Some(nl) = self.free.remove(&(at + n)) { - len += nl; - } - if a + len == self.end { - self.end = a; - } else { - self.free.insert(a, len); - } - } - - /// In use (below `end`, less the holes). - fn used(&self) -> usize { - self.end - self.free.values().sum::() - } -} - -/// An object an edit placed, as `LiveCore::pick` tests it. -enum Picked { - Mesh(scene::Instance), - Proc(scene::ProcObject), -} - -/// What an edit places at an object's slot: a procedural object's BLAS (hardware traversal), or instanced mesh `k`. -enum Placed { - Proc(wgpu::Blas), - Mesh(u32), -} - -/// One object fewer draws instanced mesh `k`: a mesh edits manage that none draws any more is freed (its triangles a -/// hole the next new mesh can take, its slot reused, its BLAS dropped with it). -fn drop_mesh_user(k: u32, edit: &mut EditState, tables: &mut tables::Tables, meshes: &[scene::InstMesh]) { - let n = edit.users.entry(k).or_default(); - *n = n.saturating_sub(1); - if *n == 0 && edit.managed.remove(&k) { - let im = meshes[k as usize]; - edit.tris.give(edit.pool_first + im.first as usize, im.count as usize); - tables.retained.own_mesh.retain(|_, v| *v != k); - edit.dead_meshes.push(k); - } -} - -/// The room's buffers past their end, doubled at least (ROADMAP § 6 step 5): new buffers, the old contents copied on -/// the GPU; the scene bind group is made again after the edit (`EditState::rebind`). Not with particles, whose pass -/// keeps a bind group of its own over these buffers. -struct Grow<'a> { - g: &'a gpu::Gpu, - ok: bool, -} - -impl Grow<'_> { - /// The room twice as large (the scene's own data before it stays as it is), at least `extra` more at the end. - fn cap(h: &Holes, extra: usize) -> usize { - h.base + ((h.cap - h.base) * 2).max(h.end + extra - h.base).max(1024) - } - - /// Whether buffers of these sizes (bytes) can be bound (the device's storage binding and buffer limits): past them - /// the edit rebuilds instead (Bugbot on #131). - fn fits(&self, sizes: &[u64]) -> bool { - let l = self.g.device.limits(); - let max = (l.max_storage_buffer_binding_size as u64).min(l.max_buffer_size); - sizes.iter().all(|&s| s <= max) - } - - /// Triangles: vertices, attributes, the BLASes' vertices, vertex colours (after the previous vertices). - fn tris(&self, h: &mut Holes, extra: usize, verts: &mut wgpu::Buffer, attrs: &mut wgpu::Buffer, prev: &mut wgpu::Buffer, vcolor_base: Option, accel: &mut gpu::Accel) -> bool { - if !self.ok { - return false; - } - let cap = Self::cap(h, extra); - let prev_bytes = vcolor_base.map_or(0, |base| (base + cap as u64 * 12).next_multiple_of(16)); - if !self.fits(&[(cap * 48) as u64, (cap * std::mem::size_of::()) as u64, (cap * 36) as u64, prev_bytes]) { - return false; - } - *verts = self.g.grown(verts, (cap * 48) as u64, "verts"); - *attrs = self.g.grown(attrs, (cap * std::mem::size_of::()) as u64, "attrs"); - self.g.grow_blas_verts(accel, (cap * 36) as u64); - if vcolor_base.is_some() { - *prev = self.g.grown(prev, prev_bytes, "prev verts + vertex colours"); - } - h.cap = cap; - true - } - - /// Kernel records and the TLAS (a record's instance is `inst_base` + it). - fn records(&self, h: &mut Holes, extra: usize, instances: &mut wgpu::Buffer, accel: &mut gpu::Accel) -> bool { - if !self.ok { - return false; - } - let cap = Self::cap(h, extra); - if !self.fits(&[(cap * std::mem::size_of::()) as u64]) { - return false; - } - *instances = self.g.grown(instances, (cap * std::mem::size_of::()) as u64, "instances"); - self.g.grow_tlas(accel, accel.inst_base + cap); - h.cap = cap; - true - } - - /// Object-table entries (`stride` vec4s each). - fn objects(&self, h: &mut Holes, extra: usize, objects: &mut wgpu::Buffer, stride: usize) -> bool { - if !self.ok { - return false; - } - let cap = Self::cap(h, extra); - if !self.fits(&[(cap * stride * 16) as u64]) { - return false; - } - *objects = self.g.grown(objects, (cap * stride * 16) as u64, "objects"); - h.cap = cap; - true - } - - /// Procedural words, after the environment's CDF (`cdf_words` of them). - fn words(&self, cap: &mut usize, need: usize, cdf: &mut wgpu::Buffer, cdf_words: usize) -> bool { - if !self.ok { - return false; - } - let new = (*cap * 2).max(need); - if !self.fits(&[((cdf_words + new) * 4) as u64]) { - return false; - } - *cap = new; - *cdf = self.g.grown(cdf, ((cdf_words + *cap) * 4) as u64, "env_cdf + procedural"); - true - } -} - -/// An object removed or hidden in place: no longer picked until the next rebuild (`LiveCore::pick`). -fn unpick(id: u32, edit: &mut EditState, tables: &tables::Tables, instances: &mut [scene::Instance], procs: &mut [scene::ProcObject], proc_of: &std::collections::HashMap) { - edit.added.remove(&id); - if let Some(i) = tables.retained.own_instance.get(&id).and_then(|&k| instances.get_mut(k as usize)) { - i.hidden = true; - } - if let Some(p) = proc_of.get(&id).and_then(|&j| procs.get_mut(j)) { - p.hidden = true; - } -} - -fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32, targets: &Targets, passes: &Passes) -> Result<(LoadedScene, crate::desc::PostPreset)> { - let t0 = Instant::now(); - let mut sc = scene::build(world, assets, presets, None)?; - sc.width = w; - sc.height = h; - // Emitters its edits add get a light-tree subtree of their own (ROADMAP § 6 step 4). - // (Software traversal with units edits in place as hardware traversal does: `units_wanted`.) - sc.emitter_slot = gpu::EditRoom::editable(&sc, g.hwrt || crate::units_wanted(g.hwrt, &sc)); - let mut timings = vec![("build_ms".to_string(), t0.elapsed().as_secs_f64() * 1000.0)]; - let mut prep = crate::prepare_live(g, &mut sc, &mut timings); - let mut tables = tables::Tables::new(&sc, std::mem::take(&mut prep.emitter_tris), prep.light_tree.take()); - // (THREERT_EDIT_REBUILD=1: every edit rebuilds, a material that starts emitting too: the A/B.) - tables.emit_in_place = std::env::var("THREERT_EDIT_REBUILD").map_or(true, |v| v != "1"); - tables.fixed_mats = std::mem::take(&mut prep.fixed_mats); - let emit = EmitterWorker::new(&tables, &g.device); - tables.defer_powers = emit.is_some(); - let (mat_runs, tri_order) = (std::mem::take(&mut prep.mat_runs), std::mem::take(&mut prep.tri_order)); - let fixed_features = (!sc.textures.is_empty(), !sc.shaders.is_empty(), prep.groups); - let mut up = g.upload(&sc, w, h, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, &mut timings)?; - // The merged layout: the kernel written for this view's group-1 buffers (merged.rs). - if let (Some(caps), Some((_, live))) = (&mut up.kernel.caps, &targets.kernel.merged) { - caps.live = *live; - } - let _ = g.pipeline(&up.kernel, true); // compile the scene's kernel variant now, not in the first frame - let sky_inputs = ((prep.env.width, prep.env.height), sc.camera.position.y.max(0.0), prep.env_nee_floor); - let mut base = prep.params; - // The live viewport resamples fewer light-tree candidates at camera hits than offline renders (ltree.rs). - if base.ltree[3] > 0.5 { - base.ltree[3] = crate::ltree::live_candidates() as f32; - } - base.cam_fwd[3] = w as f32 / h as f32; - let bg = targets.bind_group(&g.device, &passes.layout, &up.accum); - let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); - // Manual: 2^-EV100. Auto: a first guess for the firefly clamp (the GPU histogram takes over from frame 1). - let exposure = match &sc.camera.exposure { - crate::desc::Exposure::Manual { ev100 } => 2f32.powf(-ev100), - crate::desc::Exposure::Auto { compensation, max_ev100, .. } => 2f32.powf(*compensation) / 2f32.powf(*max_ev100), - }; - let c = &sc.camera; - let scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; - let (tris, tri_nodes, tri_instances) = (std::mem::take(&mut sc.tris), std::mem::take(&mut sc.tri_nodes), std::mem::take(&mut sc.tri_instances)); - let instanced = !sc.gpu_instances.is_empty() || !sc.procs.is_empty(); - let instance_features = gpu::instance_features(&sc); - let (pool_tris, meshes, instances) = (std::mem::take(&mut sc.pool_tris), std::mem::take(&mut sc.meshes), std::mem::take(&mut sc.instances)); - let mut gpu_instances = std::mem::take(&mut sc.gpu_instances); - let proc_first = gpu_instances.len(); - gpu_instances.extend_from_slice(&sc.proc_records); - let procs = std::mem::take(&mut sc.procs); - let proc_of = procs.iter().enumerate().filter(|(_, p)| p.sub.is_none()).map(|(j, p)| (p.node, j)).collect(); - let triangles = sc.stats.get("triangles").map_or(tris.len(), |t| *t as usize); - let first = prep.groups[..4].iter().sum::(); - // The kernel's first dynamic triangle (motion vectors, prev_verts) is group 4's first. - debug_assert_eq!(prep.params.group_first[1][0] as usize, first); - // The dynamic tail: groups 4..8 (the instanced pool follows them). - let end = prep.groups.iter().sum::(); - let blas = prep.tri_verts[first * 3..end * 3].chunks(3).flat_map(|t| [0, 1, 2].map(|k| if k == 0 { [t[0][0], t[0][1], t[0][2]] } else { [t[0][0] + t[k][0], t[0][1] + t[k][1], t[0][2] + t[k][2]] })).collect(); - let dynamic = (first, prep.tri_verts[first * 3..end * 3].to_vec(), sc.attrs[first..end].to_vec(), blas); - let blobs = sc.proc_layout.as_ref().map_or_else(Default::default, |l| l.blobs.iter().map(|(b, off)| (b.hash, l.head.len() + off)).collect()); - let slot = up.accel.as_ref().map_or(0, |a| a.proc_base + procs.len()); - let room_base = (prep.tri_verts.len() / 3, gpu_instances.len(), tables.objects.len() / tables.stride, up.region_words, slot); - // A record's TLAS instance is `inst_base` + it (model instances, then procedural objects, then the room's). - debug_assert!(up.accel.as_ref().is_none_or(|a| room_base.4 == a.inst_base + room_base.1)); - let room = up.room; - let mut users: std::collections::HashMap = Default::default(); - for i in &instances { - *users.entry(i.mesh).or_default() += 1; - } - let mesh_of = tables.retained.own_instance.iter().filter_map(|(&id, &k)| instances.get(k as usize).map(|i| (id, i.mesh))).collect(); - let managed = tables.retained.own_mesh.values().chain(tables.retained.emitter_mesh.values()).copied().collect(); - let edit = EditState { - tris: Holes::new(room_base.0, room_base.0 + room.tris), - records: Holes::new(room_base.1, room_base.1 + room.records), - objects: Holes::new(room_base.2, room_base.2 + room.objects), - word: room_base.3, - word_cap: room_base.3 + room.proc_words, - mesh_of, - users, - managed, - dead_meshes: vec![], - grows: 0, - rebind: false, - uniform_said: false, - base: room_base, - pool_first: prep.groups.iter().sum(), - blobs, - placed: Default::default(), - instanced: Default::default(), - inst_at_load: instances.len(), - added: Default::default(), - }; - Ok((LoadedScene { timings, triangles, tris, tri_nodes, tri_instances, pool_tris, meshes, instances, gpu_instances, procs, proc_of, proc_first, instanced, instance_features, name: sc.name.clone(), up, base, bg, exposure, exposure_settings, tan_half_fov: c.tan_half_fov, scene_camera, dof: c.dof.clone(), tables, fixed_features, dynamic, edit, sky: None, emit, sky_inputs, mat_runs, tri_order }, sc.preset.post)) -} - -pub enum Cmd { - Camera(Camera), - /// An event for the in-process game (type, payload). - Event(String, serde_json::Value), - /// A click at (u, v) for the in-process game (picks the node under it). - Click(f32, f32), - /// Messages of the host channel for the in-process game (`{"cmd":"host","messages":[{"t":...}, ...]}`: what an - /// app would send, its input among them; docs/RUNTIME.md "The host channel"). - Host(Vec), - /// Snapshot (path) of the first frame whose history length reaches `after` (None = any) and that is at least the - /// `frames`-th frame from now (None = the next). - Snapshot(PathBuf, Option, Option), - /// Answer `live: sync ` on stderr once the n-th frame from now is rendered (benchmarks step the camera frame by - /// frame, so what they measure does not depend on how many frames a busy machine renders per second). - Sync(String, u32), - /// Lockstep (benchmarks): with Some(frame time, s) the runner renders only the frames `Step`s ask for, at that frame - /// time (motion blur, exposure, frame script); None = real time again. - Lockstep(Option), - /// A step: an optional camera, `frames` frames to render (lockstep), and a sync answer / snapshot at the last of them, - /// registered with the camera in one command so neither can land a frame late. - Step { cam: Option, frames: u32, sync: Option, snapshot: Option }, - /// Drop all accumulated history (benchmarks measure convergence from here). - Reset, - /// Set the scene time, s (benchmarks: with lockstep, frame k from here renders at t + k x its frame time; frame - /// time 0 holds it there). - Time(f32), - /// View mode (see LiveCore::view_mode). - Mode(u32), - Bounces(u32), - /// Max samples per frame (adaptive below it). - SppCap(u32), - /// Viewer status text (e.g. the game score), shown in the window title. - Status(String), - /// Render scale (0 = dynamic resolution). - Scale(f32), - Quit, -} - -/// The real-time renderer: scene + screen targets + camera/history state. Records one frame at a time; the output -/// (sRGB BGRA8 per pixel) lands in `targets.out`. Used by the headless runner, the native window viewer and the playground. -pub struct LiveCore { - /// The game's particle spawns since the last frame (`World::particle_spawns`), for the GPU particle pass. - particle_spawns: std::collections::BTreeMap>, - /// The sources the game gave its density grids since the last frame (`World::grid_sources`), for the grid pass. - grid_sources: std::collections::BTreeMap>, - pub g: gpu::Gpu, - passes: Passes, - pub targets: Targets, - sc: LoadedScene, - presets: PathBuf, - /// Asset root (models, file textures). - assets: PathBuf, - pub w: u32, - pub h: u32, - /// Committed state sequence number the image shows (set by the game session). - pub seq: i64, - aspect: f32, - pub cam: Camera, - prev_cam: Camera, - pub frame: u32, - pub history_len: f32, - reset: bool, - /// Bloom stage radii / tints (`bloom_stages`). - bloom: ([f32; 6], [f32; 6]), - vignette: f32, - /// Chromatic aberration, film grain, bloom threshold, longest motion blur (output pixels). - post: [f32; 4], - /// Motion blur (UE MotionBlurAmount, MotionBlurTargetFPS; amount 0 = off). - blur: (f32, f32), - /// Last submission of each frame still on the GPU (at most two frames in flight). - inflight: std::collections::VecDeque, - pub gpu_ms: f32, - /// The frame `gpu_ms` timed (it lags by the frames in flight). - gpu_frame: u32, - /// That frame's trace pass alone (the path-tracing kernel: intersection and shading), ms; 0 without timestamps. - /// Frame log `trace`: the per-frame cost a geometry representation changes, without THREERT_PROFILE's stalls. - pub gpu_trace_ms: f32, - /// This frame's geometry edits applied in place (`edit_geometry`: CPU ms, its BLAS / TLAS builds submitted), and - /// scene rebuilds (ms) and, when edits fell back to one, why: summed over the frame's commits (a game event's and the - /// frame script's), reset when the frame is logged. Fallbacks since the viewport opened. - /// Frame log `edit`, `rebuild`, `fallback`, `fallbacks`. - pub edit_ms: f32, - /// CPU time of this frame's in-place instance updates (`update_instanced`), ms. - pub instanced_ms: f32, - pub edit_split: EditSplit, - pub rebuild_ms: f32, - pub fallback: Option, - pub fallbacks: u32, - /// Path-tracing bounces for real-time frames (the offline preset uses more). - pub bounces: u32, - /// Ray tracing (true). Off (`set_rays`), the kernel traces the camera ray of each pixel and no other: no shadows, - /// no bounce light, reflections of the sky only; analytic lights and emissive surfaces light unoccluded. The - /// fallback for GPUs that trace too slowly (no hardware ray queries), and the "what tracing adds" comparison on - /// those that do: the same scene, materials, exposure and post either way. - rays: bool, - /// 0 lit, 1 base color, 2 world normal, 3 lit without denoiser, 4 raw sample, 5 history length, 6 diffuse, - /// 7 specular, 8 deterministic direct light (THREERT_VIEW_MODE at start: e.g. bench-temporal per signal). - pub view_mode: u32, - /// Lockstep frame time (s; benchmarks): motion blur and exposure adaptation use it instead of the wall clock. - pub fixed_dt: Option, - /// A/B switch (playground key N): motion blur off whatever the scene's post settings say. - pub motion_blur_off: bool, - pub triangles: usize, - pub scene_name: String, - /// Scene time, s: what `advance_time` added up, the dt each frame script gets, so it is the game's clock (`f.time`) - /// and a burst fired at a game time starts then; lockstep runs repeat exactly. Animated shading, the particle pass - /// and power edits' time stamps use it (`shader_time`). - time: f32, - /// Time between rendered frames (auto exposure adaptation), s. - frame_dt: f32, - last_frame: Instant, - /// The post preset the LUT / bloom / grain settings were built from (scenes may override it). - post_key: String, - /// Traced / output resolution (UE screen percentage); the temporal pass upsamples below 1. - pub render_scale: f32, - /// Adjust `render_scale` to the frame budget (`govern_resolution`). - pub dynamic_res: bool, - /// Dynamic resolution's lowest scale: `MIN_SCALE` over the display's scale factor. - pub min_scale: f32, - /// The view's pixels a point of its display (`set_display_scale`): dynamic resolution's steps are a point's. - display_scale: f32, - /// Dynamic resolution: consecutive measured frames well over the frame time, and the first frame at the current - /// scale. - over_budget: (u32, u32), - /// When this view was made, and when its render scale was last checked (`govern_resolution` by the clock). - born: Instant, - governed: Instant, - /// THREERT_PROFILE=1: GPU time per pass (timestamp queries), printed every 2 s. - profile: Option, - /// GPU time per frame without stalls (timestamp queries; None: measured by draining the queue twice a second). - timer: Option, - /// Rasterized primary visibility (None: the kernel traces camera rays). - raster: Option, - /// World-space radiance cache (pathtrace.wgsl; None: THREERT_CACHE=0, paths trace every bounce). - cache: Option, - /// Kernel group 1: primary visibility + radiance cache + what the live passes read of a trace. - kernel_bg: wgpu::BindGroup, - /// Span (relative to the dynamic tail) re-transformed by the last in-place update whose previous-frame vertices - /// (`Uploaded::prev_verts`) still hold the frame before it: synced to the current ones next frame. - prev_span: Option<(usize, usize)>, - /// An in-place update happened this frame (it keeps `prev_verts` itself). - reposed: bool, - /// Dynamic triangles moved in this commit: the light tree's moving subtree is refit once at its end. - emitters_moved: bool, - /// Frames since the last reset or camera cut (`COLD_FALLBACK_FRAMES`). - cold_frames: u32, - /// Likewise for the deforming meshes of shared poses (`repose_groups`): the span they took (relative to the dynamic - /// tail, past its end) and whether this frame re-skinned any. - pose_span: Option<(usize, usize)>, - pose_reposed: bool, - /// Movable instances moved by the last update (their records' previous-frame rows differ), and whether it was this - /// frame's. - inst_moving: Vec, - inst_moved: bool, - /// The previous frame's traced size (its traced-grid diffuse history; 0 before the first frame). - prev_isz: (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, - /// The scene has light-sampled particles (emitter table entries with a nominal area). - spark_lights: bool, - /// Samples per pixel of the last frame rendered (the kernel's sample buffer holds their sums). - shown_spp: u32, - /// CPU time of the last commit (ms): skinning and re-transforming moving objects (`reskin`, rigid instance poses), and - /// uploading them with the dynamic BLAS / TLAS rebuild submitted (frame log `skin`, `upload`). - pub cpu_skin_ms: f32, - pub cpu_upload_ms: f32, - /// THREERT_PROFILE_BLAS=1: GPU time of the last dynamic BLAS / TLAS rebuild (ms; frame log `blas`), measured by draining - /// the queue before it and waiting for it after (it stalls the frame: measurement runs only). The pass profile has - /// no timestamps for it (Metal cannot write them between acceleration-structure builds). - pub gpu_blas_ms: f32, - /// CPU time the last `render` spent waiting for the oldest frame in flight (ms; frame log `wait`): GPU-bound frames. - pub cpu_wait_ms: f32, - /// The cloud cover the sky and the sun light were made with (`move_sun`). - sky_cover: f32, - /// The loop guard (merged.rs `guard`), if a kernel of this session may have it: the invocations it has stopped at - /// its limit since the view was made, as last read back (a frame or two behind, without waiting). Above zero: - /// frames with wrong pixels, a traversal that did not end on the scene's data. None without the guard. - pub guard_trips: Option, - /// Its readback: the buffer and its state (0 free, 1 mapping, 2 mapped), as the frame timer's. - guard_read: Option<(wgpu::Buffer, Arc)>, -} - -/// GPU time per frame from timestamp queries (start of the first trace pass .. end of the post pass), read back a few -/// frames later without waiting, so measuring never drains the pipeline. -struct FrameTimer { - queries: wgpu::QuerySet, - resolve: wgpu::Buffer, - /// Per slot: readback buffer and its state (0 free, 1 mapping, 2 mapped). - reads: Vec<(wgpu::Buffer, Arc)>, - /// Per slot: the frame it timed, and that frame's samples per pixel (its traces; only the first is timestamped). - frames: [u32; TIMER_SLOTS], - spps: [u32; TIMER_SLOTS], - slot: usize, -} - -const TIMER_SLOTS: usize = 4; - -/// Gap (ms) past which a slot's post pass is not its trace's frame's (`frame_gpu_ms`). -const TIMER_GAP_MS: f32 = 2.0; - -/// A frame's GPU time (ms) from its slot's timestamps (trace, post, raster, particle pass pairs; zero: not run): from -/// its first pass (particles, raster or trace) to the end of its post pass. The post pass follows the trace's command -/// buffer within ~0.2 ms, after the frame's further samples (`spp` - 1 more traces, untimed, each about as long as -/// the first). In 2–3 % of the crowd scene's frames (Metal) a slot read the post pair of the frame that used the slot -/// next, 4 frames on: the span then read 70–80 ms while frames came every 16.7 ms. A post pass that begins more than -/// TIMER_GAP_MS past where those traces would end (twice their length) is not this frame's: the frame is counted as -/// its passes, the further traces at the first one's length. -fn frame_gpu_ms(t: &[u64; 8], spp: u32, period_ns: f32) -> Option { - let start = [t[6], t[4], t[0]].into_iter().filter(|&v| v > 0).min()?; - if t[3] <= start { - return None; - } - let ms = |ticks: u64| ticks as f32 * period_ns * 1e-6; - let (trace, more) = (ms(t[1].saturating_sub(t[0])), spp.max(1) - 1); - if t[2] >= t[1] && ms(t[2] - t[1]) > TIMER_GAP_MS + 2.0 * more as f32 * trace { - return Some(ms(t[1] - start) + more as f32 * trace + ms(t[3].saturating_sub(t[2]))); - } - Some(ms(t[3] - start)) -} - -impl FrameTimer { - fn new(d: &wgpu::Device) -> FrameTimer { - let n = TIMER_SLOTS as u64; - FrameTimer { - // Per slot: trace pass begin / end, post pass begin / end, raster pass begin / end, particle pass begin / end - // (Metal writes a pass's timestamps in pairs). - queries: d.create_query_set(&wgpu::QuerySetDescriptor { label: Some("frame timer"), ty: wgpu::QueryType::Timestamp, count: (n * 8) as u32 }), - resolve: d.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - reads: (0..n).map(|_| (d.create_buffer(&wgpu::BufferDescriptor { label: None, size: 64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::default())).collect(), - frames: [0; TIMER_SLOTS], - spps: [1; TIMER_SLOTS], - slot: 0, - } - } - - /// Latest finished measurement (frame ms, the frame it timed, its trace pass ms), and a free slot for this frame - /// (None: all readbacks still pending, skip). - fn harvest(&mut self, period_ns: f32) -> (Option<(f32, u32, f32)>, Option) { - let mut latest: Option<(f32, u32, f32)> = None; - for (k, (buf, state)) in self.reads.iter().enumerate() { - if state.load(Ordering::Acquire) == 2 { - let t: [u64; 8] = bytemuck::pod_read_unaligned(&buf.slice(..).get_mapped_range().unwrap()[..64]); - buf.unmap(); - state.store(0, Ordering::Release); - let frame = self.frames[k]; - if let Some(ms) = frame_gpu_ms(&t, self.spps[k], period_ns).filter(|_| latest.is_none_or(|(_, f, _)| frame.wrapping_sub(f) as i32 > 0)) { - latest = Some((ms, frame, t[1].saturating_sub(t[0]) as f32 * period_ns * 1e-6)); - } - } - } - let slot = (0..TIMER_SLOTS).map(|k| (self.slot + k) % TIMER_SLOTS).find(|&k| self.reads[k].1.load(Ordering::Acquire) == 0); - if let Some(k) = slot { - self.slot = (k + 1) % TIMER_SLOTS; - } - (latest, slot) - } -} - -/// Primary visibility by rasterization (the decision experiment of docs/ROADMAP.md § 1): every triangle drawn into a -/// triangle-index target (+ depth) at the frame's sub-pixel jitter; the kernel starts each live path at that triangle -/// instead of tracing the camera ray. `live --raster` / THREERT_RASTER=1. -struct Raster { - pipeline: wgpu::RenderPipeline, - layout: wgpu::BindGroupLayout, - uniform: wgpu::Buffer, - vis: wgpu::TextureView, - depth: wgpu::TextureView, -} - -const RASTER_WGSL: &str = r#" -struct R { pos: vec4, fwd: vec4, right: vec4, up: vec4, jit: vec4 }; // pos.w = tan(fov/2), fwd.w = aspect, jit = ndc offset xy, near -@group(0) @binding(0) var r: R; -@group(0) @binding(1) var tri_verts: array>; // per triangle: a, b - a, c - a (kernel layout) -struct V { @builtin(position) p: vec4, @location(0) @interpolate(flat) id: u32 }; -@vertex fn vs(@builtin(vertex_index) vi: u32) -> V { - let ti = vi / 3u; - let k = vi % 3u; - var p = tri_verts[ti * 3u].xyz; - if (k != 0u) { p += tri_verts[ti * 3u + k].xyz; } - let d = p - r.pos.xyz; - let z = dot(d, r.fwd.xyz); - // Reversed infinite depth: near / z. - return V(vec4(dot(d, r.right.xyz) / (r.pos.w * r.fwd.w) + r.jit.x * z, dot(d, r.up.xyz) / r.pos.w + r.jit.y * z, r.jit.z, z), ti + 1u); -} -@fragment fn fs(v: V) -> @location(0) u32 { return v.id; } -"#; - -impl Raster { - fn new(g: &gpu::Gpu, w: u32, h: u32) -> Raster { - let d = &g.device; - let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("raster"), source: wgpu::ShaderSource::Wgsl(RASTER_WGSL.into()) }); - let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("raster"), - entries: &[ - wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, - wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, - ], - }); - let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); - let pipeline = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("raster vis"), - layout: Some(&pl), - vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, - fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs"), targets: &[Some(wgpu::TextureFormat::R32Uint.into())], compilation_options: Default::default() }), - primitive: wgpu::PrimitiveState { cull_mode: None, ..Default::default() }, - depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth32Float, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Greater), stencil: Default::default(), bias: Default::default() }), - multisample: Default::default(), - multiview_mask: None, - cache: g.cache(), - }); - g.save_pipeline_cache(); - let tex = |format, usage| { - d.create_texture(&wgpu::TextureDescriptor { label: Some("raster"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage, view_formats: &[] }) - .create_view(&Default::default()) - }; - let vis = tex(wgpu::TextureFormat::R32Uint, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING); - let depth = tex(wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT); - let uniform = d.create_buffer(&wgpu::BufferDescriptor { label: Some("raster"), size: 80, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - Raster { pipeline, layout, uniform, vis, depth } - } - - /// Records the visibility pass for `tris` triangles at internal size (iw, ih), sampling at jitter (jx, jy). - #[allow(clippy::too_many_arguments)] - fn record(&self, g: &gpu::Gpu, e: &mut wgpu::CommandEncoder, verts: &wgpu::Buffer, tris: u32, cam: &Camera, aspect: f32, (iw, ih): (u32, u32), (jx, jy): (f32, f32), timestamps: Option) { - let u: [[f32; 4]; 5] = [ - [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov], - [cam.fwd.x, cam.fwd.y, cam.fwd.z, aspect], - [cam.right.x, cam.right.y, cam.right.z, 0.0], - [cam.up.x, cam.up.y, cam.up.z, 0.0], - // Pixel centres sample at +0.5; shift the image so they sample at the jittered position instead. - [2.0 * (0.5 - jx) / iw as f32, -2.0 * (0.5 - jy) / ih as f32, 0.01, 0.0], - ]; - g.queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&u)); - let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &self.layout, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: verts.as_entire_binding() }], - }); - let mut pass = e.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("raster vis"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.vis, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store } })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Discard }), stencil_ops: None }), - timestamp_writes: timestamps, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_viewport(0.0, 0.0, iw as f32, ih as f32, 0.0, 1.0); - pass.set_scissor_rect(0, 0, iw, ih); - pass.set_pipeline(&self.pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.draw(0..tris * 3, 0..1); - } -} - -/// THREERT_CACHE_FLAGS: experiment switches of the radiance cache (pathtrace.wgsl `cfl`), 0 = defaults: 1 = no cold -/// fallback (cold voxels read as they are), 2 = no hit lighting (reflection hits use the cache's total), 4 = no -/// training tiles, 8 = the exact light loop at reflection hits. THREERT_CACHE=0 turns the cache off. -/// Frames after a reset or a camera cut during which pixels whose radiance-cache voxel is cold trace a fresh estimate -/// (pathtrace.wgsl, cache flag 1 off). Later the cold voxels left are mostly where bounce rays land off screen, -/// scattered, sparsely trained: their traces cost each SIMD group nearly a whole extra trace (Bistro's path 0.7-1.2 -/// ms, the crowd's square 2.3 ms) for ~1 % of the image (bench-crowd ΔE 13.1 -> 13.3). -const COLD_FALLBACK_FRAMES: u32 = 30; - -/// Ceiling for one live lighting sample, in display units at the current exposure (live.wgsl `sample_clamp`): fireflies -/// far above white. 64 until 2026-11-09, when it cut the light of small strong lights that the denoisers now keep: the -/// Bistro crowd in motion (lockstep at 10 s) diffuse 0.87 -> 0.90 of our offline render, specular 0.83 -> 0.89, the -/// pavement under the headlights 0.71 -> 0.77. 1024 adds nothing more. -const SAMPLE_CEILING: f32 = 256.0; - -/// THREERT_GOVERN_CLOCK=1|0 (default 1 on Android): dynamic resolution also checks by the clock, so a view whose -/// frames are far slower than 60 a second comes down to its frame rate in seconds (`LiveCore::govern_resolution`). -fn govern_by_clock() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_GOVERN_CLOCK").map(|v| v == "1").unwrap_or(cfg!(target_os = "android"))) -} - -fn cache_flags() -> u32 { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_CACHE_FLAGS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)) -} - -/// THREERT_FREEZE_TIME=1: animation holds still (shader time 0, the frame script gets dt 0: signs, screens, flicker groups), -/// so frames taken at different times compare (tools/bench-ghosting.ts). -fn freeze_time() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_FREEZE_TIME").is_ok_and(|v| v == "1")) -} - -/// Seconds of animated shading (sign flicker, screens, ripples) and particles: the scene time; 0 under THREERT_FREEZE_TIME. -fn shader_time(time: f32) -> f32 { - if freeze_time() { 0.0 } else { time } -} - -/// THREERT_EXPOSURE_SNAP=1: auto exposure jumps to its target every frame (no adaptation lag), so a frame taken while the -/// view moves has the exposure a settled view of that pose would have (tools/bench-ghosting.ts). -fn exposure_snap() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_EXPOSURE_SNAP").is_ok_and(|v| v == "1")) -} - -/// UE's physical CoC model (DiaphragmDOFUtils.cpp, FPhysicalCocModel::Compile): focal length from the sensor height -/// (sensor width / aspect) and the vertical FOV, the CoC of infinity on the sensor f^2 / (N (focus - f)), as a radius in -/// output pixels; max radius r.DOF.Kernel.Max{Fore,Back}groundRadius = 2.5 % of the width. Off without a focus distance. -fn dof_params(dof: Option<&crate::desc::Dof>, tan_half_fov: f32, w: u32, h: u32) -> [f32; 4] { - let Some(d) = dof.filter(|d| d.focus_distance > 0.0 && d.f_stop > 0.0 && d.sensor_width > 0.0) else { return [0.0; 4] }; - let aspect = w as f32 / h as f32; - let sensor_h = d.sensor_width / aspect; - let f = 0.5 * sensor_h / tan_half_fov; - let diameter = f * f / (d.f_stop * (d.focus_distance * 1000.0 - f).max(1e-3)); - [diameter * 0.5 / sensor_h / aspect * w as f32, d.focus_distance, 0.025 * w as f32, 0.0] -} - -/// Live passes that run on the traced grid (the rest on the output grid). -fn traced_pass(name: &str) -> bool { - name == "refl" || name == "dtemporal" || name.starts_with("atrous") || name == "stemporal" || name.starts_with("sfilt") || name == "compose" || name == "judge" || name == "reject" -} - - -/// Light LOD (`emitter_lod`): emitters within this distance of the camera keep their sampling weight, farther ones -/// lose it as (d0 / d)^2. -const LIGHT_LOD_D0: f32 = 10.0; - -/// Emitters farther than this from the light LOD's reference are not proposed (`emitter_lod`); BSDF rays bring their -/// light. -const EMITTER_CULL: f32 = 60.0; - -/// The cull's reach inside the view cone (`EmitterCull`), in cull radii: emitters ahead of the camera are kept out to -/// this. -const EMITTER_CULL_VIEW: f32 = 2.0; -/// The view cone: the frustum's half-diagonal plus this margin (degrees); the CDF is rebuilt when the camera turns by -/// LIGHT_LOD_TURN, so the frustum stays inside the cone between rebuilds. -const EMITTER_CULL_MARGIN: f32 = 20.0; -const LIGHT_LOD_TURN: f32 = 15.0; - - - -/// Halton(base) of index i (sub-pixel jitter sequence). -fn halton(mut i: u32, base: u32) -> f32 { - let (mut f, mut r) = (1.0f32, 0.0f32); - while i > 0 { - f /= base as f32; - r += f * (i % base) as f32; - i /= base; - } - r -} - -/// The live kernel's world-space radiance cache (docs/ROADMAP.md § 1, step 1): its buffers and per-frame resolve. -struct RadianceCache { - bufs: gpu::CacheBuffers, - resolve: wgpu::ComputePipeline, -} - -/// History caps of the cache: samples (noise), frames (lag; shorter for a while after a lighting edit). -const CACHE_SAMPLES: f32 = 256.0; -const CACHE_FRAMES: f32 = 32.0; -const CACHE_FRAMES_LIT: f32 = 4.0; -/// Share of the pixels showing translucent particles whose nearest hit trains the volume voxels (up to -/// gpu::VOLUME_TRAIN_CAP points per frame). -const VOLUME_TRAIN_SHARE: f32 = 1.0 / 8.0; -/// Media voxel training points per frame (pathtrace.wgsl MEDIA_TRAIN_POINTS). -const MEDIA_TRAIN_POINTS: u32 = 4096; -/// The most frames a pixel's history holds while the scene's lighting changes: short enough to follow the change by -/// itself (a real-time denoiser's bound: NRD's default is 30), so no edit has to cut it. Against main's rules (24 -/// frames for good in a scene with spark lights, every pixel cut to 16 at a lighting edit, an on / off anti-lag), dE -/// against the offline render or the converged frame: the Bistro slice half a second after its explosion 6.04 for -/// 6.58, at rest 2.82 for 2.79; Kabukichō 2 s and 4 s after its signs close 6.70 and 2.92 for 6.56 and 2.82. At 64 -/// frames the explosion read 5.72 and the signs 7.82 and 3.37. -const HISTORY_LIVE: f32 = 32.0; -/// The most once the scene has settled: holding still, the frame converges on the offline render. -const HISTORY_FRAMES: f32 = 4096.0; - -/// THREERT_TL_HIDE (diagnostics): the translucency pass trains the volume voxels but draws nothing, so frames show what is -/// behind translucent particles; `odd` hides them on odd frames only (rain on / off pairs from one run). -fn translucent_hidden(frame: u32) -> bool { - static F: std::sync::OnceLock> = std::sync::OnceLock::new(); - match F.get_or_init(|| std::env::var("THREERT_TL_HIDE").ok().map(|v| v == "odd")) { - None => false, - Some(false) => true, - Some(true) => frame % 2 == 1, - } -} - -struct Profile { - queries: wgpu::QuerySet, - resolve: wgpu::Buffer, - read: wgpu::Buffer, - sums: Vec, - frames: u32, - /// Timed apart from the slots above: the grid programs' two passes (`gridsim`, queries 0..4) and the media passes - /// (`media`, queries 4..6: the camera rays' march and, at one sample a frame, the volume voxels' training, which - /// are otherwise in the trace's pass). Their queries, the resolve and read buffers, the sums. - grid: (wgpu::QuerySet, wgpu::Buffer, wgpu::Buffer), - grid_sum: f64, - media_sum: f64, -} - -impl LiveCore { - /// Advances the scene time by a frame's dt (the frame script's: at most 0.1 s). - pub fn advance_time(&mut self, dt: f32) { - self.time += dt.clamp(0.0, 0.1); - } - - /// Sets the scene time: a game session's frame script clock (`GameSession::frame`). - pub fn set_time(&mut self, t: f32) { - self.time = t; - } - - /// The scene time animation sees (0 under THREERT_FREEZE_TIME). - pub fn scene_time(&self) -> f32 { - shader_time(self.time) - } - - /// A live view of the engine's world; asset files resolve against `assets`. - pub fn new(g: gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32) -> Result { - let passes = Passes::new(&g); - // The scene's post preset replaces this LUT below (apply_post). - let cache_on = !std::env::var("THREERT_CACHE").is_ok_and(|v| v == "0"); - // (Without the cache the kernel binds one of one entry.) - let targets = Targets::new(&g, w, h, &vec![0u16; (LUT * LUT * LUT * 4) as usize], if cache_on { gpu::CACHE_SIZE } else { 1 }); - let (sc, pp) = load_scene(&g, world, assets, presets, w, h, &targets, &passes)?; - let cam = sc.scene_camera; - let (triangles, scene_name) = (sc.triangles, sc.name.clone()); - let profile = (g.timestamps && std::env::var("THREERT_PROFILE").is_ok()).then(|| { - // trace, the post passes, cache resolve, then the particle pass, the BLAS rebuild, the translucency pass and - // the volume training. - let n = (passes.pipes.len() as u64 + 6) * 2; - Profile { - queries: g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile"), ty: wgpu::QueryType::Timestamp, count: n as u32 }), - resolve: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), - read: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - sums: vec![0.0; passes.pipes.len() + 6], - frames: 0, - grid: ( - g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile grid"), ty: wgpu::QueryType::Timestamp, count: 6 }), - g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 2 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), - g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 48, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), - ), - grid_sum: 0.0, - media_sum: 0.0, - } - }); - let timer = (g.timestamps && profile.is_none()).then(|| FrameTimer::new(&g.device)); - let raster = std::env::var("THREERT_RASTER").is_ok_and(|v| v == "1").then(|| Raster::new(&g, w, h)); - let cache = cache_on.then(|| RadianceCache { bufs: targets.kernel.cache.clone(), resolve: g.cache_resolve_pipeline(&targets.kernel) }); - let kernel_bg = gpu::vis_bind_group(&g.device, &g.vis_layout, raster.as_ref().map_or(&g.no_vis_view, |r| &r.vis), &targets.kernel); - // (Read whenever a kernel of this session may have the guard: under software traversal a scene's first unit - // 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 }; - 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); - Ok(core) - } - - pub fn scene_camera(&self) -> Camera { - self.sc.scene_camera - } - - /// Applies what scene code changed in the world since the last commit. Parameter changes (power, light / material - /// parameters, wetness / rain / fog, the camera) rewrite GPU table entries in place; anything structural rebuilds - /// the GPU scene from the world (history survives, validated per pixel by depth / normal). Returns true on a - /// rebuild. - pub fn commit(&mut self, world: &mut World) -> Result { - self.cpu_skin_ms = 0.0; - self.cpu_upload_ms = 0.0; - self.gpu_blas_ms = 0.0; - // The game's particle spawns, for the next frame's pass; the voxel occupancy grid's changes, uploaded. - for (id, v) in std::mem::take(&mut world.particle_spawns) { - self.particle_spawns.entry(id).or_default().extend(v); - } - if let Some(o) = &world.voxel_occupancy { - let mut o = o.write().unwrap(); - if !o.dirty.is_empty() { - self.g.upload_occupancy(&self.sc.up, &mut o); - } - } - // The density grids: the game's sources for the next frame's grid pass, and new data uploaded in place. - for (id, v) in std::mem::take(&mut world.grid_sources) { - self.grid_sources.entry(id).or_default().extend(v); - } - for id in std::mem::take(&mut world.grid_dirty) { - if let Some(d) = world.grids.get(&id).and_then(|g| g.data.as_ref()) { - self.g.upload_grid(&self.sc.up, id, d); - } - } - let mut changes = world.take_changes(); - if !changes.any() { - return Ok(false); - } - // Objects shown or hidden by their own `visible`: in place when the build has them (a rain curtain the weather - // turns off, a pool's idle fireball and its light cost nothing while off), else with the rebuild. - if !changes.structural { - for id in std::mem::take(&mut changes.shown) { - let hide = !world.shown(id); - let done = match world.objects.get(&id).map(|o| &o.kind) { - Some(crate::world::Kind::Particles { .. }) => self.g.set_particles_hidden(&self.sc.up, id, hide), - Some(crate::world::Kind::Volume { shape, .. }) => self.g.set_medium_hidden(&self.sc.up, id, *shape, hide), - // (Its table entry, black while hidden: `Tables::update_light`.) - Some(crate::world::Kind::Light(_)) if self.sc.tables.light_index.contains_key(&id) => { - changes.lights.insert(id); - true - } - _ => false, - }; - if !done { - changes.structural = true; - } - } - } - // New materials without textures: entries after the scene's, before the edits that place objects drawn with - // them (and the GPU's table with them, grown if it is full). - if !changes.new_materials.is_empty() && !changes.structural { - if self.sc.tables.add_materials(world, &changes.new_materials) { - self.upload_materials(); - // (A clear coat, an unlit material: the kernel variant with it, compiled once.) - let (e, (tex, func, groups)) = (&self.sc.tables, self.sc.fixed_features); - self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; - } else { - self.fallback = Some("a new material that cannot be added in place (it emits, is glass or a cutout)".into()); - self.fallbacks += 1; - changes.structural = true; - } - } - // Objects added in place are not in the move tables (their records follow the scene's): a move rebuilds. Not a - // mesh a body drives: it is an instance, and its record moves as the build's do (`placed_movable`). - if !changes.structural && changes.moved.iter().any(|id| self.sc.edit.placed.contains_key(id) && self.placed_movable(world, *id).is_none()) { - self.fallback = Some("an object added in place moved".into()); - self.fallbacks += 1; - changes.structural = true; - if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { - eprintln!("edit: rebuild (an object added in place moved)"); - } - } - // Instanced objects rewrite their instances in place (`update_instanced`); one that cannot rebuilds the scene. Before - // the geometry edits, whose TLAS build (always, or the rebuild they fall back to) then carries the instances too - // (a voxel scene's debris and batches: one build of a 20 k instance TLAS a frame, not two); else they build it. - // (The instances the last update moved, taken first: what this one moves, from here on, keeps its motion.) - let moved_before = std::mem::take(&mut self.inst_moving); - let mut instanced_early = false; - // (Not those added this commit: the geometry edits place them with their instances as they are.) - let early: Vec = std::mem::take(&mut changes.instanced).into_iter().filter(|id| !changes.added.contains(id)).collect(); - if !early.is_empty() && !changes.structural { - instanced_early = self.instanced_in_place(world, &early, !changes.edits(), &mut changes); - } - // Geometry edits in place when nothing else is structural; what they cannot apply rebuilds the scene. - if changes.edits() && !changes.structural { - let t = Instant::now(); - let r = self.edit_geometry(world, &changes); - if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { - eprintln!("edit: {} geometries, {} added, {} removed: {}", changes.geometries.len(), changes.added.len(), changes.removed.len(), r.as_ref().map_or_else(|e| format!("rebuild ({e})"), |_| "in place".into())); - } - match r { - Ok(()) => { - self.edit_ms += t.elapsed().as_secs_f32() * 1e3; - // As after a rebuild: history validated per pixel. - self.lit_changed = self.frame.max(1); - } - Err(why) => { - self.fallback = Some(why); - self.fallbacks += 1; - changes.structural = true; - } - } - } - if !changes.promoted_geometry.is_empty() { - eprintln!("live: {}: geometry changed, now with its own BLAS (this once rebuilds; set `dynamicGeometry` or the attribute's DynamicDrawUsage to avoid it)", changes.promoted_geometry.join(", ")); - self.fallback = Some("promote".into()); - self.fallbacks += 1; - } - if !changes.promoted.is_empty() { - eprintln!("live: {} moved, now movable (this once rebuilds; set `movable = true` in the scene code to avoid it)", changes.promoted.join(", ")); - } - // Moved movable subtrees: their drawables are re-transformed with the posed models, their lights rewritten in - // the light table. - let mut dynamic = changes.poses.clone(); - let mut placed = vec![]; - let mut procs_moved = vec![]; - let mut instanced_moved = vec![]; - let children = if changes.moved.is_empty() { Default::default() } else { world.children() }; - for id in &changes.moved { - for k in world.subtree_with(*id, &children) { - match &world.objects[&k].kind { - crate::world::Kind::Light(crate::desc::Light::Rect { visible: true, .. }) | crate::world::Kind::Mesh { .. } | crate::world::Kind::Model { .. } => { - if world.shown(k) { - // Movable models placed as instances move their transform (with a shared pose too); the rest - // re-transform triangles. - let r = &self.sc.tables.retained; - match r.instance_of.get(&k).copied().or_else(|| self.placed_movable(world, k)) { - Some(i) if !changes.poses.contains(&k) || r.pose_member.contains_key(&k) => placed.push((k, i)), - _ => { - dynamic.insert(k); - } - } - } - } - crate::world::Kind::Light(_) => { - changes.lights.insert(k); - } - // Particle functions place their particles around the node (`i.pivot`). - crate::world::Kind::Particles { .. } => { - let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); - self.g.set_particle_pivot(&self.sc.up, k, p.to_array()); - } - crate::world::Kind::GpuParticles { .. } => { - let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); - self.g.set_gpu_particle_pivot(&self.sc.up, k, p.to_array()); - } - // Media: the box / sphere follows the node. - crate::world::Kind::Volume { .. } => { - self.g.set_medium(&self.sc.up, k, Some(scene::medium_rows(world.world_matrix(k))), None); - } - crate::world::Kind::Procedural { .. } if world.shown(k) => procs_moved.push(k), - crate::world::Kind::Instanced { .. } => instanced_moved.push(k), - _ => {} - } - } - } - // Instanced objects under a moved ancestor (or movable themselves) rewrite their instances too, unless the update - // above did (with the same world matrices; twice would take the new rows for the previous frame's: no motion). - // The TLAS is built by whatever moves after them, or there. - let mut instanced_done = instanced_early; - instanced_moved.retain(|k| !early.contains(k)); - if !instanced_moved.is_empty() && !changes.structural { - let others = !placed.is_empty() || !dynamic.is_empty() || !procs_moved.is_empty(); - instanced_done |= self.instanced_in_place(world, &instanced_moved, !others, &mut changes); - } - // Posed rigid models move their nodes' instances (`repose_rigid`), not triangles. - let rigid: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.rigid.contains_key(k)).collect(); - for k in &rigid { - dynamic.remove(k); - } - // Models in a shared pose re-skin their group's mesh (`repose_groups`, its BLAS only); their moves place instances. - let shared: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.pose_member.contains_key(k)).collect(); - for k in &shared { - dynamic.remove(k); - } - let shared_ok = shared.is_empty() || (!changes.structural && self.repose_groups(world, &shared)?); - // Procedural geometry moves its records and TLAS transforms; the TLAS is built by whatever moves after it, or here. - if !procs_moved.is_empty() && !changes.structural { - self.move_procs(world, &procs_moved, placed.is_empty() && dynamic.is_empty() && rigid.is_empty() && shared.is_empty()); - } - let placed_ok = shared_ok && (placed.is_empty() || (!changes.structural && self.move_instances(world, &placed, dynamic.is_empty() && rigid.is_empty()))); - let t_rigid = Instant::now(); - let rigid_ok = placed_ok && (rigid.is_empty() || (!changes.structural && self.repose_rigid(world, &rigid, dynamic.is_empty())?)); - self.cpu_skin_ms += t_rigid.elapsed().as_secs_f32() * 1000.0; - // Instances the last update moved and this one does not: their motion ends, the previous-frame rows catch up - // (written merged: an instanced object's thousands of records are contiguous). - if moved_before != self.inst_moving { - let size = std::mem::size_of::() as u64; - let now_moving: std::collections::HashSet = self.inst_moving.iter().copied().collect(); - let mut catch_up = Gather::default(); - for i in moved_before.into_iter().filter(|i| !now_moving.contains(i)) { - if let Some(r) = self.sc.gpu_instances.get_mut(i as usize) { - r.p = r.m; - catch_up.put(i as u64 * size, bytemuck::bytes_of(r)); - } - } - catch_up.flush(&self.g.queue, &self.sc.up.instances); - } - if rigid_ok && (dynamic.is_empty() || (!changes.structural && self.repose(world, &dynamic)?)) { - // A new shared pose and nothing else that rebuilds the TLAS (no move): rebuild it for the meshes' new bounds. - if !shared.is_empty() && placed.is_empty() && rigid.is_empty() && dynamic.is_empty() { - if let Some(accel) = self.sc.up.accel.as_ref() { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - self.g.queue.submit([e.finish()]); - } - } - // Moved emitters: the light tree's moving subtree around them, once (after every in-place update). - if std::mem::take(&mut self.emitters_moved) { - self.refit_moving_tree(); - } - changes.poses.clear(); - changes.moved.clear(); - // Moving objects move their shadows and reflections over static surfaces: let anti-lag look for it. - if !dynamic.is_empty() || !placed.is_empty() || !rigid.is_empty() || !shared.is_empty() || !procs_moved.is_empty() || instanced_done { - self.lit_changed = self.frame.max(1); - } - } - // Particle nodes' customData: their particle function's `i.custom` (the table has no object for the node). - for id in &changes.customs { - if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Particles { .. })) { - self.g.set_particle_custom(&self.sc.up, *id, o.custom); - } - } - // Volume nodes' customData: their volume function's `i.custom` (the media table, not the objects table). - for id in &changes.customs { - if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Volume { .. })) { - self.g.set_medium(&self.sc.up, *id, None, Some(o.custom)); - } - } - let now = shader_time(self.time); - let applied = self.sc.tables.apply(world, &changes, now).ok().flatten(); - let Some(d) = applied else { - let t = Instant::now(); - self.rebuild(world)?; - self.rebuild_ms += t.elapsed().as_secs_f32() * 1e3; - if changes.camera { - self.cam = self.sc.scene_camera; - } - return Ok(true); - }; - if d.materials { - self.upload_materials(); - } - let (q, up, e) = (&self.g.queue, &self.sc.up, &mut self.sc.tables); - if d.lights && !e.lights.is_empty() { - if d.lights_moved { - e.regroup_lights(); - } - q.write_buffer(&up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); - } - if d.objects { - q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&e.objects)); - } - if d.materials || d.emitters { - self.add_material_emitters(); - self.write_emitters(); - } else if d.deferred { - self.send_powers(); - } - let (e, b) = (&mut self.sc.tables, &mut self.sc.base); - if d.environment { - b.time[1] = e.wetness; - b.time[2] = e.rain; - b.background = e.background.map_or([0.0; 4], |c| [c[0], c[1], c[2], 1.0]); - b.aerial = e.aerial; - [b.fog, b.fog_color, b.vol, b.vol2] = crate::fog_params(e.fog.as_ref()); - } - if d.materials || d.environment { - // e.g. fog appearing needs the fog kernel variant (compiled once, then cached) - let (tex, func, groups) = self.sc.fixed_features; - self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; - } - if let Some(c) = e.camera.take() { - self.sc.scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; - self.sc.tan_half_fov = c.tan_half_fov; - self.sc.dof = c.dof.clone(); - self.cam = self.sc.scene_camera; - } - // Lighting changed: for 45 frames the histories hold HISTORY_LIVE frames at most (`live_lighting`), and where the - // change is strong the temporal pass cuts the history locally (anti-lag). - if d.materials || d.lights || d.objects || d.environment { - self.lit_changed = self.frame.max(1); - } - // (A new cloud cover re-bakes the sky and dims the sun as a sun move does.) - if changes.sun || self.sc.tables.cloud_cover != self.sky_cover { - self.sky_cover = self.sc.tables.cloud_cover; - self.move_sun(world); - } - if changes.exposure { - self.apply_exposure(world); - } - Ok(false) - } - - /// The atmosphere's sun moved (time of day): its light entry is rewritten now (direction, and its colour through - /// the atmosphere), and the sky's re-bake is asked of the worker thread (`DynamicSky`, started here on the first - /// move). Lockstep frames wait for the bake, so a lockstep run sees the sky that goes with each sun. - fn move_sun(&mut self, world: &World) { - let Some((id, dir, lux)) = scene::atmosphere_sun(world) else { return }; - if self.sc.sky.is_none() { - let (size, alt, floor) = self.sc.sky_inputs; - self.sc.sky = DynamicSky::new(world, size, alt, floor); - } - let Some(sky) = self.sc.sky.as_ref() else { return }; - let cover = self.sc.tables.cloud_cover; - let colour = crate::sky::sun_illuminance(&sky.atm, dir, lux, cover); - let e = &mut self.sc.tables; - if let (Some(idx), Some(o)) = (e.light_index.get(&id), world.objects.get(&id)) { - if let crate::world::Kind::Light(l) = &o.kind { - if let Some(mut gl) = scene::gpu_light(l, world.world_matrix(id), o.power) { - // (The sun: hidden with a rebuild, `World::set_visible`.) - gl.color = [colour.x, colour.y, colour.z, 0.0]; - for &i in idx { - e.lights[i] = gl; - } - self.g.queue.write_buffer(&self.sc.up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); - } - } - } - let _ = sky.ask.send((dir, lux, cover)); - if self.fixed_dt.is_some() { - if let Ok(b) = sky.done.recv() { - self.put_sky(b); - } - } - self.lit_changed = self.frame.max(1); - } - - /// A sky the worker thread has baked since the last frame (the newest), into the environment map and its CDF. - fn take_sky(&mut self) { - let Some(sky) = self.sc.sky.as_ref() else { return }; - let mut last = None; - while let Ok(b) = sky.done.try_recv() { - last = Some(b); - } - if let Some(b) = last { - self.put_sky(b); - } - } - - fn put_sky(&mut self, (env, cdf, max): (gpu::EnvMap, Vec, f32)) { - let Some(sky) = self.sc.sky.as_ref() else { return }; - self.g.write_env(&self.sc.up, &env, &cdf); - // As the load decides it: the sky is light-sampled unless it is negligible next to the emitters (night), or a - // baked sky is not light-sampled at all (`env_nee_on`). - self.sc.base.counts2[1] = (max >= sky.env_nee_floor && crate::env_nee_on()) as u32; - self.lit_changed = self.frame.max(1); - } - - /// The camera's manual exposure changed value (an exposure that follows the time of day), in place: the post pass's - /// exposure and the kernel's firefly clamp, which is relative to it. - fn apply_exposure(&mut self, world: &World) { - let Some(cid) = world.settings.camera else { return }; - let Some(crate::world::Kind::Camera(c)) = world.objects.get(&cid).map(|o| &o.kind) else { return }; - let crate::desc::Exposure::Manual { ev100 } = c.exposure else { return }; - let e = 2f32.powf(-ev100); - if self.sc.exposure > 0.0 { - self.sc.base.misc[0] *= self.sc.exposure / e; - } - self.sc.exposure = e; - self.sc.exposure_settings.ae2 = [e, 0.0, 0.0, 0.0]; - } - - /// Moves movable instances: their kernel records (the old transform kept as the previous frame's, for motion - /// vectors), their object entries' pivots and their TLAS transforms (hardware traversal; with `build_tlas` it - /// rebuilds the TLAS, otherwise the dynamic rebuild that follows does) or their units and the top tree over them - /// (software traversal, units.rs). No triangles change. False when it cannot (software traversal without units, - /// or an instance that is no unit; a top tree that cannot be made): the caller rebuilds. - fn move_instances(&mut self, world: &World, placed: &[(u32, u32)], build_tlas: bool) -> bool { - // (Hardware traversal's TLAS instances, or software traversal's units; neither: the caller rebuilds.) - let Some(mut top) = gpu::Top::of(&mut self.sc.up.accel, &mut self.sc.up.units) else { return false }; - let size = std::mem::size_of::() as u64; - let q = &self.g.queue; - // (Many records, a rigid body world's thousands a frame, are written as the one range that holds them: the - // records between are as the GPU has them.) - let merged = placed.len() > 32; - let (mut lo, mut hi) = (usize::MAX, 0usize); - // (Instances the last update moved and this one does not catch up in `commit`.) - for &(node, i) in placed { - let w = world.world_matrix(node); - // (A mesh an edit added in place keeps its instance with the edit's, `placed_movable`; its record and TLAS - // instance are where the build's are.) - let inst = match self.sc.edit.added.get_mut(&node) { - Some(Picked::Mesh(inst)) => inst, - _ => &mut self.sc.instances[i as usize], - }; - // (Software traversal: an instance of a mesh that deforms is no unit; its triangles are among the still - // scene's, in the world, and a rebuild moves them.) - if !top.has_mesh(inst.mesh) { - return false; - } - let old = self.sc.gpu_instances[i as usize]; - inst.world = w; - let mut r = top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, inst.cast_shadow); - r.p = old.m; - self.sc.gpu_instances[i as usize] = r; - if merged { - (lo, hi) = (lo.min(i as usize), hi.max(i as usize)); - } else { - q.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); - } - let pivot = w.transform_point3(glam::Vec3::ZERO); - let st = self.sc.tables.stride; - let o = &mut self.sc.tables.objects[inst.object as usize * st]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - let slot = top.inst_base() + i as usize; - top.moved(slot, w, None); - self.inst_moving.push(i); - } - if merged { - q.write_buffer(&self.sc.up.instances, lo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[lo..=hi])); - } - q.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - self.inst_moved = true; - match top { - gpu::Top::Hw(accel) => { - if build_tlas { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - q.submit([e.finish()]); - } - true - } - // (The top tree is built here whoever else changes units this commit: it is CPU work and one write, and a - // tree that cannot be made must send the caller to a rebuild now.) - gpu::Top::Sw(units) => self.g.retop(units, &self.sc.up.bg_parts.nodes).map_err(|e| eprintln!("units: {e}: rebuilding the scene")).is_ok(), - } - } - - /// The material table to the GPU: written in place, or, with more materials than its buffer holds (materials added - /// while the game runs), into a buffer twice the size, the scene's bind group made over it. - fn upload_materials(&mut self) { - let bytes: &[u8] = bytemuck::cast_slice(&self.sc.tables.materials); - if bytes.len() as u64 > self.sc.up.materials.size() { - let usage = self.sc.up.materials.usage(); - self.sc.up.materials = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("materials"), size: (bytes.len() as u64 * 2).next_multiple_of(16), usage, mapped_at_creation: false }); - self.g.rebind_scene(&mut self.sc.up); - } - self.g.queue.write_buffer(&self.sc.up.materials, 0, bytes); - } - - /// The record of a mesh an edit added in place that moves as an instance does (`move_instances`): one a rigid body - /// drives, placed by its node's transform (an emitter mesh's triangles are in world space: it cannot). - fn placed_movable(&self, world: &World, id: u32) -> Option { - let &(_, record) = self.sc.edit.placed.get(&id)?; - let mesh = matches!(self.sc.edit.added.get(&id), Some(Picked::Mesh(_))); - (mesh && world.objects.get(&id).is_some_and(|o| o.driven) && !self.sc.tables.retained.emitter_mesh.contains_key(&id)).then_some(record as u32) - } - - /// `update_instanced` with its fallback (the reason in the frame log, the scene rebuilt) and its CPU time; whether it - /// changed anything. - fn instanced_in_place(&mut self, world: &World, ids: &[u32], build_tlas: bool, changes: &mut crate::world::Changes) -> bool { - let t = Instant::now(); - let r = self.update_instanced(world, ids, build_tlas); - self.instanced_ms += t.elapsed().as_secs_f32() * 1e3; - let log = std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1"); - match r { - Ok(n) => { - if log { - eprintln!("edit: {n} instanced objects in place"); - } - n > 0 - } - Err(why) => { - if log { - eprintln!("edit: rebuild ({why})"); - } - self.fallback = Some(why); - self.fallbacks += 1; - changes.structural = true; - false - } - } - } - - /// Emitter entries for the materials that started emitting (`Tables::started_emitting`): their flattened static - /// triangles (`mat_runs`), positions from `tris`, in the power-share part of the CDF (path code 0, as the light - /// tree's moving emitters; ROADMAP § 6 step 3). `write_emitters` uploads them. - fn add_material_emitters(&mut self) { - let sc = &mut self.sc; - for mat in std::mem::take(&mut sc.tables.started_emitting) { - for &[first, end, obj] in sc.mat_runs.get(&mat).map_or(&[][..], |v| v.as_slice()) { - for k in first..end { - let Some(&[a, b, c]) = sc.tri_order.get(k as usize).and_then(|&i| sc.tris.get(i as usize)) else { continue }; - let area = 0.5 * (b - a).cross(c - a).length(); - if area > 0.0 { - let centre = (a + b + c) / 3.0; - sc.tables.emitter_tris.push(crate::EmitterTri { tri: k, mat, obj, area, c: centre.to_array(), moves: false, nominal: false, in_tree: false }); - } - } - } - } - } - - /// The emitter CDF from `Tables::emitter_tris` (weights from materials and powers; entries added in place at its - /// end, removed ones at area 0), the light tree refit to the new weights and its nodes after the CDF, the CDF's - /// share of power, its count. With the tree's slot for emitters added in place (scenes that can edit geometry), their - /// subtree is grown again first and their entries go right after the static ones (ROADMAP § 6 step 4); without it - /// they stay at the end, by power share. The buffer grows (and the scene bind group is made again) when entries - /// were added past it: a scene that adds none keeps its layout. - fn write_emitters(&mut self) { - self.land_powers_now(); - let g = &self.g; - let LoadedScene { tables: e, base: b, up, pool_tris, tris: flat, tri_order, edit, .. } = &mut self.sc; - // Emitters added in place, with the tree's slot for them (`ltree::build_slotted`): its subtree grown again over - // those alive, their entries right after the static ones in the CDF (in its leaf order: each leaf's entry is its - // position) and their path codes in their triangles' attrs (written over the code 0 an edit gave them). - let mut perm: Option> = None; - if let Some(t) = e.light_tree.as_mut() { - if let Some((_, ns)) = t.dynamic { - let ns = ns as usize; - let (mats, objs, st, pf) = (&e.materials, &e.objects, e.stride, edit.pool_first); - let verts = |k: usize| if k >= pf { pool_tris.get(k - pf).copied() } else { tri_order.get(k).and_then(|&i| flat.get(i as usize)).copied() }; - let mut items = vec![]; - for (i, x) in e.emitter_tris.iter().enumerate().skip(ns) { - let w = crate::emitter_power(x, mats, objs, st); - let Some([va, vb, vc]) = verts(x.tri as usize).filter(|_| !x.in_tree && !x.nominal && !x.moves && w > 0.0) else { continue }; - items.push(crate::ltree::DynItem { e: i as u32, lo: va.min(vb).min(vc).to_array(), hi: va.max(vb).max(vc).to_array(), c: x.c, w }); - } - t.set_dynamic(items); - let at = std::mem::offset_of!(scene::GpuTriAttr, x) + 12; - let mut codes = Gather::default(); - let mut placed = vec![false; e.emitter_tris.len()]; - let mut p: Vec = (0..ns).collect(); - for k in ns..t.order.len() { - let i = t.order[k] as usize; - codes.put((e.emitter_tris[i].tri as usize * std::mem::size_of::() + at) as u64, bytemuck::bytes_of(&t.code[k])); - placed[i] = true; - p.push(i); - } - codes.flush(&g.queue, &up.attrs); - p.extend((ns..e.emitter_tris.len()).filter(|&i| !placed[i])); - perm = Some(p); - } - } - let permuted: Vec; - let list = match &perm { - Some(p) => { - permuted = p.iter().map(|&i| e.emitter_tris[i]).collect(); - &permuted[..] - } - None => &e.emitter_tris[..], - }; - let (em, total, power) = crate::emitter_cdf(list, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); - if let Some(t) = e.light_tree.as_mut() { - let (tris, mats, objs, st) = (&e.emitter_tris, &e.materials, &e.objects, e.stride); - let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], mats, objs, st)).collect(); - t.refit(&|k| w[k]); - } - self.put_emitters(&em, total, power); - // Emitters added in place since the worker was made: another, over them. - if self.sc.emit.as_ref().is_some_and(|w| w.count != self.sc.tables.emitter_tris.len()) { - self.sc.emit = EmitterWorker::new(&self.sc.tables, &self.g.device); - self.sc.tables.defer_powers = self.sc.emit.is_some(); - } - } - - /// The emitter CDF `em` (its total weight and the emitters' power), then the light tree's nodes, into the emitters - /// buffer (grown, and the scene bind group made again, when they no longer fit), and the kernel's parameters. - fn put_emitters(&mut self, em: &[[f32; 4]], total: f32, power: f32) { - let g = &self.g; - let LoadedScene { tables: e, base: b, up, .. } = &mut self.sc; - let nodes: &[[f32; 4]] = e.light_tree.as_ref().map_or(&[], |t| &t.nodes); - let need = ((em.len() + nodes.len()) * 16) as u64; - if need > up.emitters.size() { - // (The usage it was made with: the merged layout copies the tables each frame, `Gpu::sync_tables`.) - let usage = up.emitters.usage(); - up.emitters = g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters"), size: need + need / 2, usage, mapped_at_creation: false }); - g.rebind_scene(up); - } - if !em.is_empty() { - g.queue.write_buffer(&up.emitters, 0, bytemuck::cast_slice(em)); - } - if let Some(t) = e.light_tree.as_ref() { - g.queue.write_buffer(&up.emitters, em.len() as u64 * 16, bytemuck::cast_slice(&t.nodes)); - } - let _ = b; - self.emitter_params(em.len(), total, power); - } - - /// The kernel's emitter parameters for a CDF of `entries` (its total weight; the emitters' power) and the tree. - fn emitter_params(&mut self, entries: usize, total: f32, power: f32) { - let LoadedScene { tables: e, base: b, .. } = &mut self.sc; - b.emit_power[0] = power; - b.misc[1] = total; - if let Some(t) = e.light_tree.as_ref() { - b.ltree[0] = f32::from_bits(entries as u32); - // The tree's entries (with the slot: the static ones and those added in place), the rest by power share. - if t.dynamic.is_some() { - b.ltree[1] = f32::from_bits(t.order.len() as u32); - } - b.ltree[2] = if power > 0.0 { ((power - t.power) / power).max(0.0) } else { 0.0 }; - } - b.counts[1] = if total > 0.0 { e.emitter_tris.len() as u32 } else { 0 }; - } - - /// Power edits held back (`Tables::deferred`) to the emitter worker, unless it is on a job (they go with the next, - /// when that one lands). Lockstep frames wait for the result. - fn send_powers(&mut self) { - let Some(w) = self.sc.emit.as_mut() else { return }; - if w.flight.is_some() || self.sc.tables.deferred.is_empty() { - return; - } - let e = &mut self.sc.tables; - let powers = std::mem::take(&mut e.deferred); - let mut objects = e.objects.clone(); - for &(row, power, _) in &powers { - objects[row][3] = power; - } - let b = &self.sc.base; - let job = EmitterJob { materials: e.materials.clone(), objects, lod: b.light_lod, cull: crate::EmitterCull::of(b) }; - if w.ask.send(job).is_err() { - // (The worker is gone: write them now.) - e.put_powers(&powers); - self.sc.emit = None; - e.defer_powers = false; - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - self.write_emitters(); - return; - } - w.flight = Some(powers); - if self.fixed_dt.is_some() { - if let Ok(r) = self.sc.emit.as_ref().unwrap().done.recv() { - self.land_emitters(r); - } - } - } - - /// The emitter worker's result, if it has one: its edits land with it, and the edits held back since go out. - fn take_emitters(&mut self) { - let Some(r) = self.sc.emit.as_ref().and_then(|w| w.done.try_recv().ok()) else { return }; - self.land_emitters(r); - self.send_powers(); - } - - /// A job's edits and its emitter CDF and tree powers, together: the objects' powers, the CDF, the tree. - fn land_emitters(&mut self, r: EmitterBuilt) { - let Some(powers) = self.sc.emit.as_mut().and_then(|w| w.flight.take()) else { return }; - let e = &mut self.sc.tables; - e.put_powers(&powers); - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); - if let Some(t) = e.light_tree.as_mut() { - for (n, &p) in t.nodes.iter_mut().zip(&r.node_powers) { - n[2] = p; - } - t.power = t.nodes.first().map_or(0.0, |n| n[2]); - } - // (Same entries and nodes as the CDF the scene was loaded with: the buffer holds them.) - let mut enc = self.g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("emitters") }); - enc.copy_buffer_to_buffer(&r.staging, 0, &self.sc.up.emitters, 0, r.bytes.min(self.sc.up.emitters.size())); - self.g.queue.submit([enc.finish()]); - self.emitter_params(r.entries, r.total, r.power); - self.lit_changed = self.frame.max(1); - } - - /// Before another emitter edit (`write_emitters`, a light-LOD rebuild): the job in flight waited for and its result - /// dropped, its edits and those held back written into the objects (and uploaded), so the rebuild sees them all. - fn land_powers_now(&mut self) { - let Some(w) = self.sc.emit.as_mut() else { return }; - let mut powers = vec![]; - if let Some(f) = w.flight.take() { - let _ = w.done.recv(); - powers = f; - } - let e = &mut self.sc.tables; - powers.append(&mut e.deferred); - if !powers.is_empty() { - e.put_powers(&powers); - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); - // The lighting changed (as when a result lands): `live_lighting` for the next 45 frames. - self.lit_changed = self.frame.max(1); - } - } - - /// Instanced objects whose instances changed (`World::instanced`, the node's transform, power or customData): their - /// kernel records (the old transforms kept as the previous frame's, for motion vectors), object entries (pivot, - /// bounds, power, data, colour), TLAS transforms and masks (`scene::instance_world`: a hidden instance 0), each - /// object's records and entries written as one range; with `build_tlas` the TLAS is rebuilt here. No triangles or - /// BLASes change. Returns how many objects it updated; Err: why it cannot (software traversal, an object the build - /// flattened or did not place this way, an instance now mirrored), and the caller rebuilds. - /// `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). - fn update_instanced(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> std::result::Result { - use crate::world::Kind; - if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { - return Err("THREERT_EDIT_REBUILD=1".into()); - } - let size = std::mem::size_of::(); - let now = shader_time(self.time); - let empty = crate::world::InstanceTable::default(); - let sc = &mut self.sc; - let Some(mut top) = gpu::Top::of(&mut sc.up.accel, &mut sc.up.units) else { return Err("software traversal".into()) }; - let mut done = 0; - for &id in ids { - // (Removed or hidden: nothing is drawn; a structural change rebuilds.) - let Some(n) = world.objects.get(&id).filter(|_| world.shown(id)) else { continue }; - let Kind::Instanced { capacity, procedural, .. } = n.kind else { continue }; - let Some(&place) = sc.tables.retained.instanced.get(&id) else { return Err("an instanced mesh the build flattened".into()) }; - if place.capacity != capacity || place.procedural != procedural { - return Err("an instanced mesh the build placed otherwise".into()); - } - let t = world.instanced.get(&id).unwrap_or(&empty); - let m = world.world_matrix(id); - // Past both the old and the new count every instance is hidden and stays so: left as it is. - let (first, cap) = (place.first as usize, place.drawn.max(t.count).min(capacity) as usize); - // (A procedural one's hidden instances are out of hardware traversal by their TLAS masks; software - // traversal walks every record there is: the build leaves them out.) - if procedural && top.sw() { - return Err("a procedural instanced mesh (software traversal)".into()); - } - let (rec0, slot0) = if procedural { (sc.proc_first + first, top.proc_base(sc.proc_first) + first) } else { (first, top.inst_base() + first) }; - for i in 0..cap { - let (wm, hidden) = scene::instance_world(m, t, i); - if !procedural && !hidden && wm.determinant() < 0.0 { - return Err("an instance is mirrored".into()); - } - let old = sc.gpu_instances[rec0 + i]; - let (mut rec, entry) = if procedural { - let p = &mut sc.procs[first + i]; - (p.world, p.hidden) = (wm, hidden); - (p.gpu(old.info[0]), p.entry(n.power, t.custom(i, n.custom), t.color(i))) - } else { - let inst = &mut sc.instances[first + i]; - if !top.has_mesh(inst.mesh) { - return Err("an instanced mesh that is no unit (software traversal)".into()); - } - (inst.world, inst.hidden) = (wm, hidden); - let (lo, hi) = sc.meshes[inst.mesh as usize].placed_bounds(wm); - (top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, n.cast_shadow), scene::object_row(wm.transform_point3(glam::Vec3::ZERO), n.power, hi - lo, t.custom(i, n.custom), t.color(i))) - }; - rec.p = old.m; - sc.gpu_instances[rec0 + i] = rec; - // The time of the last power edit: now for a new power. - let st = sc.tables.stride; - let at = (place.object as usize + i) * st; - let e = &mut sc.tables.objects[at..at + st]; - let edited = if e[0][3] != entry[0][3] { now } else { e[1][3] }; - e.copy_from_slice(&entry[..st]); - e[1][3] = edited; - top.moved(slot0 + i, wm, Some((n.cast_shadow, hidden))); - } - let q = &self.g.queue; - q.write_buffer(&sc.up.instances, (rec0 * size) as u64, bytemuck::cast_slice(&sc.gpu_instances[rec0..rec0 + cap])); - let st = sc.tables.stride; - let at = place.object as usize * st; - q.write_buffer(&sc.up.objects, (at * 16) as u64, bytemuck::cast_slice(&sc.tables.objects[at..at + cap * st])); - self.inst_moving.extend(rec0 as u32..(rec0 + cap) as u32); - sc.tables.retained.instanced.get_mut(&id).unwrap().drawn = t.count; - done += 1; - } - if done > 0 { - self.inst_moved = true; - match top { - gpu::Top::Hw(accel) => { - if build_tlas { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - self.g.queue.submit([e.finish()]); - } - } - gpu::Top::Sw(units) => self.g.retop(units, &sc.up.bg_parts.nodes)?, - } - } - Ok(done) - } - - /// Moves procedural objects: their kernel records (the old transform kept as the previous frame's, for motion - /// vectors), object pivots and, with hardware RT, TLAS transforms (`build_tlas`: the TLAS rebuilt here). Software - /// traversal reads the records alone, so neither path rebuilds the scene. - fn move_procs(&mut self, world: &World, ids: &[u32], build_tlas: bool) { - let size = std::mem::size_of::() as u64; - for id in ids { - let Some(&j) = self.sc.proc_of.get(id) else { continue }; - let w = world.world_matrix(*id); - let p = &mut self.sc.procs[j]; - p.world = w; - let i = self.sc.proc_first + j; - let old = self.sc.gpu_instances[i]; - let mut r = p.gpu(old.info[0]); - r.p = old.m; - self.sc.gpu_instances[i] = r; - self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); - let pivot = w.transform_point3(glam::Vec3::ZERO); - let st = self.sc.tables.stride; - let o = &mut self.sc.tables.objects[p.object as usize * st]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - if let Some(accel) = self.sc.up.accel.as_mut() { - if let Some(t) = accel.tlas[accel.proc_base + j].as_mut() { - t.transform = gpu::tlas_rows(w); - } - } - self.inst_moving.push(i as u32); - } - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - if let (true, Some(accel)) = (build_tlas, self.sc.up.accel.as_ref()) { - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_tlas(accel, &mut e); - self.g.queue.submit([e.finish()]); - } - self.inst_moved = true; - } - - /// Posed rigid models (`scene::RigidModel`): each moving node's instance takes object x node transform from the clip - /// (previous rows kept for motion vectors) and their flattened triangles are re-transformed in place; then the dynamic - /// BLASes (if they have such triangles) and, when `build_tlas`, the TLAS are rebuilt (`repose` rebuilds both after - /// it otherwise). False when it cannot (no hardware RT, a model hidden or changed): the caller rebuilds. - fn repose_rigid(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> Result { - if self.sc.up.accel.is_none() { - return Ok(false); - } - let mut placements: Vec<(u32, glam::Mat4)> = vec![]; - let (mut lo, mut hi) = (usize::MAX, 0usize); - for id in ids { - let Some(o) = world.objects.get(id) else { return Ok(false) }; - let crate::world::Kind::Model { uri, animation, .. } = &o.kind else { return Ok(false) }; - if !world.shown(*id) { - return Ok(false); - } - let globals = scene::model_gltf(uri, &self.assets)?.node_globals(animation.as_ref())?; - let m = world.world_matrix(*id); - let r = &self.sc.tables.retained.rigid[id]; - placements.extend(r.instances.iter().map(|&(node, i)| (i, m * globals[node]))); - let d = &r.rest; - if d.finals.len() != d.mats.len() { - return Ok(false); - } - let (first, verts, attrs, blas) = &mut self.sc.dynamic; - for (j, (node, p, n, uv, tg)) in r.rest_local.iter().enumerate() { - let w = m * globals[*node]; - let nm = glam::Mat3::from_mat4(w).inverse().transpose(); - let pw = p.map(|v| w.transform_point3(v)); - let nw = n.map(|v| (nm * v).normalize_or_zero()); - let tw = tg.map(|(v, s)| (w.transform_vector3(v).normalize_or_zero(), s)); - let a = scene::tri_attr(pw, nw, *uv, tw, d.mats[j]); - let k = d.finals[j] as usize - *first; - let (e1, e2) = (pw[1] - pw[0], pw[2] - pw[0]); - verts[k * 3..k * 3 + 3].copy_from_slice(&[[pw[0].x, pw[0].y, pw[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; - blas[k * 3..k * 3 + 3].copy_from_slice(&pw.map(|v| v.to_array())); - self.sc.tris[(d.first + j as u32) as usize] = pw.map(glam::Vec3A::from); - (lo, hi) = (lo.min(k), hi.max(k + 1)); - } - let pivot = m.transform_point3(glam::Vec3::ZERO); - let o = &mut self.sc.tables.objects[d.object as usize * self.sc.tables.stride]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - } - // The instances: rows, previous rows (motion vectors), TLAS transforms. - let size = std::mem::size_of::() as u64; - let accel = self.sc.up.accel.as_mut().unwrap(); - let (mut ilo, mut ihi) = (usize::MAX, 0usize); - for &(i, w) in &placements { - let inst = &mut self.sc.instances[i as usize]; - let old = self.sc.gpu_instances[i as usize]; - inst.world = w; - let mut r = inst.gpu(old.info[0], old.info[3]); - r.p = old.m; - self.sc.gpu_instances[i as usize] = r; - (ilo, ihi) = (ilo.min(i as usize), ihi.max(i as usize + 1)); - let row = |k: usize| [w.x_axis[k], w.y_axis[k], w.z_axis[k], w.w_axis[k]]; - if let Some(t) = accel.tlas[accel.inst_base + i as usize].as_mut() { - t.transform = [row(0), row(1), row(2)].concat().try_into().unwrap(); - } - // (Rigid models' instances are theirs alone: no other update this commit placed them.) - self.inst_moving.push(i); - } - if lo < hi { - self.emitters_moved = true; - } - // A model's node instances are contiguous: one write. - if ilo < ihi { - self.g.queue.write_buffer(&self.sc.up.instances, ilo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[ilo..ihi])); - } - self.inst_moved = true; - self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - if lo < hi { - // Motion vectors of the flattened triangles: the previous frame's vertices, as `repose` keeps them. - let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); - self.keep_prev(both); - self.prev_span = Some((lo, hi)); - self.reposed = true; - let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); - let accel = up.accel.as_ref().unwrap(); - let k = (first + lo) as u64; - q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); - q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); - q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); - } - if build_tlas { - let accel = self.sc.up.accel.as_ref().unwrap(); - let mut e = self.g.device.create_command_encoder(&Default::default()); - if lo < hi { - self.g.rebuild_dynamic(accel, &mut e); - } else { - self.g.rebuild_tlas(accel, &mut e); - } - self.g.queue.submit([e.finish()]); - } - Ok(true) - } - - /// Models in a shared pose (`scene::PoseGroup`): each group whose pose changed is skinned once, in model space (the - /// groups in parallel), into its mesh's pool triangles, and their BLASes are rebuilt; the TLAS is left to the moves - /// that follow (or `commit`). False when a group's members no longer share one pose, or one is hidden or changed: - /// the caller rebuilds, which groups them again. (Deforming meshes get no motion vectors of their own yet: an - /// instance's are its transform's.) - fn repose_groups(&mut self, world: &World, members: &[u32]) -> Result { - use rayon::prelude::*; - if self.sc.up.accel.is_none() { - return Ok(false); - } - let r = &self.sc.tables.retained; - let mut touched: Vec = members.iter().map(|k| r.pose_member[k]).collect(); - touched.sort_unstable(); - touched.dedup(); - let mut jobs: Vec<(u32, crate::desc::Animation)> = vec![]; - for &g in &touched { - let pg = &r.pose_groups[g as usize]; - let mut pose: Option<&crate::desc::Animation> = None; - for (id, _) in &pg.members { - let Some(o) = world.objects.get(id) else { return Ok(false) }; - let crate::world::Kind::Model { uri, nodes, animation: Some(a), .. } = &o.kind else { return Ok(false) }; - if !world.shown(*id) || *uri != pg.uri || *nodes != pg.nodes || pose.is_some_and(|p| p != a) { - return Ok(false); - } - pose = Some(a); - } - if let Some(a) = pose.filter(|a| **a != pg.animation) { - jobs.push((g, a.clone())); - } - } - if jobs.is_empty() { - return Ok(true); - } - let t_skin = Instant::now(); - let assets = &self.assets; - type Skinned = (Vec<[f32; 4]>, Vec, Vec<[f32; 3]>, Vec<[glam::Vec3A; 3]>); - let skinned: Vec>> = jobs - .par_iter() - .map(|(g, a)| { - let pg = &r.pose_groups[*g as usize]; - let tris = scene::model_gltf(&pg.uri, assets)?.triangles(glam::Mat4::IDENTITY, pg.nodes.as_deref(), Some(a))?; - if tris.len() != pg.order.len() { - return Ok(None); - } - let n = pg.order.len(); - let (mut v, mut at, mut b, mut t) = (Vec::with_capacity(n * 3), Vec::with_capacity(n), Vec::with_capacity(n * 3), Vec::with_capacity(n)); - for &(k, mat) in &pg.order { - let q = &tris[k as usize]; - let (e1, e2) = (q.p[1] - q.p[0], q.p[2] - q.p[0]); - // The flag word keeps TRI_DYNAMIC: the kernel takes a deforming mesh's motion from `prev_verts`. - v.extend_from_slice(&[[q.p[0].x, q.p[0].y, q.p[0].z, f32::from_bits(scene::TRI_DYNAMIC)], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - at.push(scene::tri_attr(q.p, q.n, q.uv, q.tangent, mat)); - b.extend(q.p.map(|p| p.to_array())); - t.push(q.p.map(glam::Vec3A::from)); - } - Ok(Some((v, at, b, t))) - }) - .collect(); - self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; - let t_up = Instant::now(); - // Motion vectors: the GPU still holds the previous pose of these meshes (and of the last re-skin's, if others): - // keep it in `prev_verts` before uploading the new one. - let df = self.sc.dynamic.0; - let (mut lo, mut hi) = (usize::MAX, 0usize); - for (g, _) in &jobs { - let pg = &self.sc.tables.retained.pose_groups[*g as usize]; - let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as usize - df; - (lo, hi) = (lo.min(k), hi.max(k + pg.order.len())); - } - self.keep_prev(self.pose_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi)))); - self.pose_span = Some((lo, hi)); - self.pose_reposed = true; - let (q, up) = (&self.g.queue, &self.sc.up); - let accel = up.accel.as_ref().unwrap(); - let mut meshes = Vec::with_capacity(jobs.len()); - for ((g, a), res) in jobs.into_iter().zip(skinned) { - let Some((v, at, b, t)) = res? else { return Ok(false) }; - let pg = &mut self.sc.tables.retained.pose_groups[g as usize]; - // The mesh's first triangle in the GPU order: any member's record has it. - let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as u64; - q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&v)); - q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&at)); - q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&b)); - let m = self.sc.meshes[pg.mesh as usize]; - self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].copy_from_slice(&t); - pg.animation = a; - meshes.push(pg.mesh); - } - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_meshes(accel, &meshes, &mut e); - let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - } - let t_blas = Instant::now(); - q.submit([e.finish()]); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; - } - self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; - Ok(true) - } - - /// Re-skins posed models and re-transforms movable objects in place (hardware RT): their triangles, shading - /// attributes and dynamic BLASes, then the TLAS. Their directly seen surfaces keep a short history for a moment, - /// like a power edit (no per-object motion vectors). False when it cannot (not dynamic at build, no hardware RT, a - /// different triangle count): the caller rebuilds. - fn repose(&mut self, world: &World, ids: &std::collections::HashSet) -> Result { - if self.sc.up.accel.is_none() || ids.iter().any(|id| !self.sc.tables.retained.dynamic.contains_key(id)) { - return Ok(false); - } - let models = std::mem::take(&mut self.sc.tables.retained.dynamic); - let t_skin = Instant::now(); - let res = self.reskin(world, ids, &models); - self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; - self.sc.tables.retained.dynamic = models; - let Some((lo, hi)) = res? else { return Ok(false) }; - self.emitters_moved = true; - let t_up = Instant::now(); - // Motion vectors: the GPU still holds the previous frame's vertices for this span (and for last update's span, - // if different): keep them as `prev_verts` before uploading the new ones. - let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); - self.keep_prev(both); - self.prev_span = Some((lo, hi)); - self.reposed = true; - // Upload the touched span of the dynamic tail, then rebuild the dynamic BLASes and the TLAS. - let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); - let accel = up.accel.as_ref().unwrap(); - let k = (first + lo) as u64; - q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); - q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); - q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); - q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); - let mut e = self.g.device.create_command_encoder(&Default::default()); - self.g.rebuild_dynamic(accel, &mut e); - let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - } - let t_blas = Instant::now(); - q.submit([e.finish()]); - if profile_blas { - let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); - self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; - } - self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; - Ok(true) - } - - /// `guard_trips` for the frame log: None while the scene's kernel has no guard. Natively with nothing set that is - /// a scene without units (`gpu::loop_guard_of`): under software traversal the first unit brings the guard. - pub fn guard_count(&self) -> Option { - self.guard_trips.filter(|_| gpu::loop_guard_of(self.sc.up.kernel.features & gpu::F_UNITS != 0).is_some()) - } - - /// The edit room as the frame log reports it (`room`): triangles, records and object entries in use and held (GPU - /// order counts, holes not in use), its growths, and the GPU bytes of the buffers edits write and of the triangle - /// BLASes the live meshes need (estimated from the build's bytes per triangle), MB. - pub fn room_stats(&self) -> serde_json::Value { - let (e, up) = (&self.sc.edit, &self.sc.up); - // Software traversal's (it does not grow): the same counts, and of the node buffer the mesh trees' range (in - // use, held) and the units the top tree has against the most its range holds. - if let Some(u) = up.units.as_ref() { - return serde_json::json!({ - "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], - "holes": e.tris.free.len(), "grows": e.grows, "nodes": [u.layout.mesh_cap as usize - u.layout.free_nodes(), u.layout.mesh_cap], "units": [u.count, u.layout.max_units()], - }); - } - let Some(a) = up.accel.as_ref() else { return serde_json::Value::Null }; - let buffers = [&up.verts, &up.attrs, &up.prev_verts, &up.instances, &up.objects, &up.cdf, &up.emitters, &a.verts].iter().map(|b| b.size()).sum::(); - let live: u64 = self.sc.meshes.iter().enumerate().filter(|(k, _)| e.users.get(&(*k as u32)).copied().unwrap_or(0) > 0).map(|(_, m)| m.count as u64).sum::() + e.pool_first as u64; - let mb = |b: f64| (b / (1 << 20) as f64 * 10.0).round() / 10.0; - serde_json::json!({ - "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], - "holes": e.tris.free.len(), "grows": e.grows, "buffers_mb": mb(buffers as f64), "blas_mb": mb(live as f64 * a.blas_per_tri), - }) - } - - /// Software traversal's top tree so far (the frame log's `top`): how many times it was built, kept with leaves - /// taken out and put in, and kept with its boxes brought up to date, and the ms all of that took. Null without - /// units. - pub fn top_stats(&self) -> serde_json::Value { - self.sc.up.units.as_ref().map_or(serde_json::Value::Null, |u| serde_json::json!([u.kept.builds, u.kept.changes, u.kept.refits, (u.kept.ms * 1e3).round() / 1e3])) - } - - /// now (the CPU copy of the dynamic tail), its nodes and the root uploaded (`ltree::LightTree::refit_moving`). - fn refit_moving_tree(&mut self) { - let first = self.sc.dynamic.0; - let (verts, tables) = (&self.sc.dynamic.1, &mut self.sc.tables); - let tris = &tables.emitter_tris; - let Some(t) = tables.light_tree.as_mut().filter(|t| t.moving_count > 0) else { return }; - t.refit_moving(&|k| { - let r = (tris[k].tri as usize - first) * 3; - crate::ltree::tri_vertices(&verts[r..r + 3]) - }); - let (q, up, off) = (&self.g.queue, &self.sc.up, self.sc.base.ltree[0].to_bits() as u64); - q.write_buffer(&up.emitters, off * 16, bytemuck::cast_slice(&t.nodes[..1])); - q.write_buffer(&up.emitters, (off + 2) * 16, bytemuck::cast_slice(&t.nodes[2..3])); - q.write_buffer(&up.emitters, (off + t.moving_nodes as u64) * 16, bytemuck::cast_slice(&t.nodes[t.moving_nodes..])); - } - - /// Profile slots after the cache resolve that this scene writes: particle pass + BLAS rebuild (2; stateless or GPU - /// particles), translucency + volume training (2 more). - fn profile_extra(&self) -> u32 { - if self.sc.up.translucent.is_some() { - 4 - } else if self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some() { - 2 - } else { - 0 - } - } - - /// Copies the GPU's current vertices of a dynamic-tail span into `prev_verts` (submitted before any write queued - /// after it, so it reads what the last frame traced). - fn keep_prev(&self, (lo, hi): (usize, usize)) { - let up = &self.sc.up; - let first = self.sc.dynamic.0; - let mut e = self.g.device.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(&up.verts, ((first + lo) * 48) as u64, &up.prev_verts, (lo * 48) as u64, ((hi - lo) * 48) as u64); - self.g.queue.submit([e.finish()]); - } - - /// Skins / transforms the objects into the CPU copy of the dynamic tail; the touched span (relative to the tail), or - /// None when one cannot be updated in place. - fn reskin(&mut self, world: &World, ids: &std::collections::HashSet, models: &std::collections::HashMap) -> Result> { - use rayon::prelude::*; - if ids.iter().any(|id| !world.shown(*id)) { - return Ok(None); - } - // Objects whose triangles form one run of the dynamic tail (one traversal group) are skinned / re-transformed in - // parallel, one task per object, straight into their own spans of the tail and of the CPU triangles (one core - // took 17 ms for the crowd scene's 150 walkers, docs/ROADMAP.md § 0): ordered by span, the objects split the - // arrays into disjoint slices. The others (e.g. opaque and cutout materials in different groups) one by one. - let run = |d: &scene::DynamicModel| !d.finals.is_empty() && d.finals.iter().enumerate().all(|(j, &f)| f == d.finals[0] + j as u32); - let (mut runs, scattered): (Vec, Vec) = ids.iter().copied().partition(|id| run(&models[id])); - runs.sort_by_key(|id| models[id].finals[0]); - let assets = &self.assets; - let sc = &mut self.sc; - let (first, verts, attrs, blas) = &mut sc.dynamic; - let first = *first; - let (mut lo, mut hi) = (usize::MAX, 0); - // The CPU triangles in build order: each object's slice. - let mut by_build = runs.clone(); - by_build.sort_by_key(|id| models[id].first); - let mut tris_of: std::collections::HashMap = std::collections::HashMap::with_capacity(runs.len()); - let (mut tr, mut toff): (&mut [[glam::Vec3A; 3]], usize) = (&mut sc.tris[..], 0); - for id in &by_build { - let d = &models[id]; - let (t0, n) = (d.first as usize, d.finals.len()); - let (_, r) = std::mem::take(&mut tr).split_at_mut(t0 - toff); - let (t, r) = r.split_at_mut(n); - tr = r; - toff = t0 + n; - tris_of.insert(*id, t); - } - let (mut vr, mut ar, mut br): (&mut [[f32; 4]], &mut [scene::GpuTriAttr], &mut [[f32; 3]]) = (&mut verts[..], &mut attrs[..], &mut blas[..]); - let mut voff = 0usize; - let mut jobs = Vec::with_capacity(runs.len()); - for id in &runs { - let d = &models[id]; - let (k0, n) = (d.finals[0] as usize - first, d.finals.len()); - let (_, r) = std::mem::take(&mut vr).split_at_mut(k0 * 3 - voff * 3); - let (v, r) = r.split_at_mut(n * 3); - vr = r; - let (_, r) = std::mem::take(&mut ar).split_at_mut(k0 - voff); - let (a, r) = r.split_at_mut(n); - ar = r; - let (_, r) = std::mem::take(&mut br).split_at_mut(k0 * 3 - voff * 3); - let (b, r) = r.split_at_mut(n * 3); - br = r; - voff = k0 + n; - (lo, hi) = (lo.min(k0), hi.max(k0 + n)); - jobs.push((*id, v, a, b, tris_of.remove(id).unwrap())); - } - let ok: Vec> = jobs - .into_par_iter() - .map(|(id, v, a, b, t)| { - let d = &models[&id]; - let Some(tris) = scene::dynamic_tris(world, id, d, assets)? else { return Ok(false) }; - if tris.len() != d.finals.len() { - return Ok(false); - } - for (j, (p, at)) in tris.into_iter().enumerate() { - let (e1, e2) = (p[1] - p[0], p[2] - p[0]); - v[j * 3..j * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - // (x.w: an emitter's light-tree code, which stays with its triangle.) - a[j] = scene::GpuTriAttr { x: [at.x[0], f32::from_bits(d.object), at.x[2], a[j].x[3]], ..at }; - b[j * 3..j * 3 + 3].copy_from_slice(&p.map(|q| q.to_array())); - t[j] = p.map(glam::Vec3A::from); - } - Ok(true) - }) - .collect(); - for r in ok { - if !r? { - return Ok(None); - } - } - for id in &scattered { - let d = &models[id]; - let Some(tris) = scene::dynamic_tris(world, *id, d, assets)? else { return Ok(None) }; - if tris.len() != d.finals.len() { - return Ok(None); - } - for (j, (p, a)) in tris.into_iter().enumerate() { - let k = d.finals[j] as usize - first; - let (e1, e2) = (p[1] - p[0], p[2] - p[0]); - verts[k * 3..k * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); - attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; - blas[k * 3..k * 3 + 3].copy_from_slice(&p.map(|v| v.to_array())); - sc.tris[(d.first + j as u32) as usize] = p.map(glam::Vec3A::from); - (lo, hi) = (lo.min(k), hi.max(k + 1)); - } - } - for id in ids { - let d = &models[id]; - // Pivot (material functions' object position). Motion vectors carry its history. - let pivot = world.world_matrix(*id).transform_point3(glam::Vec3::ZERO); - let st = sc.tables.stride; - let o = &mut sc.tables.objects[d.object as usize * st]; - (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); - } - Ok((lo < hi).then_some((lo, hi))) - } - - /// Rebuilds the GPU scene from the world. - /// Applies geometry edits in place (`Changes::geometries`, `added`, `removed`; objects with their own BLAS: - /// procedural meshes and `own_blas` meshes): each touched object's data is written into the edit room - /// (gpu.rs `EditRoom`) as the build would make it (`scene::proc_object`, `scene::own_mesh_tris`), new BLASes are - /// built for new geometry only, its TLAS instance is set (cleared for a removed or hidden object) and the TLAS - /// rebuilt, one submit. Nothing is moved: what an edit replaces stays behind until the next rebuild. Err: why it - /// cannot (software traversal, the room is full, a kernel feature the scene lacks, an emitting / glass / function - /// material, ...); the caller rebuilds the scene then. `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). - fn edit_geometry(&mut self, world: &World, ch: &crate::world::Changes) -> std::result::Result<(), String> { - use crate::world::Kind; - if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { - return Err("THREERT_EDIT_REBUILD=1".into()); - } - let LoadedScene { up, tables, edit, meshes, pool_tris, instances, gpu_instances, procs, proc_of, proc_first, .. } = &mut self.sc; - let (g, assets) = (&self.g, &self.assets); - let features = up.kernel.features; - let grow = Grow { g: &self.g, ok: up.particles.is_none() && up.gpu_particles.is_none() }; - // (Hardware traversal's TLAS and BLASes, or software traversal's units: `gpu::Top`.) - let Some(mut top) = gpu::Top::of(&mut up.accel, &mut up.units) else { - // Software traversal without units. With units on, that is a scene without an instance yet: the first - // one brings the unit walk into the kernel (F_UNITS), as hardware traversal's first brings F_INST. - let first = crate::units_on(true) && ch.added.iter().any(|id| matches!(world.objects.get(id).map(|o| &o.kind), Some(Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. }))); - return Err(if first { "the scene's first instanced mesh (kernel feature)" } else { "software traversal" }.into()); - }; - // The objects to place again: those added, and those whose geometry was replaced. - let mut todo: std::collections::BTreeSet = ch.added.clone(); - if !ch.geometries.is_empty() { - for (id, o) in &world.objects { - match &o.kind { - Kind::Procedural { geometry, .. } | Kind::Mesh { geometry, own_blas: true, .. } if ch.geometries.contains(geometry) => { - todo.insert(*id); - } - Kind::Mesh { geometry, .. } if ch.geometries.contains(geometry) => return Err("a flattened mesh's geometry changed".into()), - // (One added this commit is placed with its geometry as it is.) - Kind::Instanced { geometry, .. } if ch.geometries.contains(geometry) && !ch.added.contains(id) => return Err("an instanced mesh's geometry changed".into()), - _ => {} - } - } - } - let (inst_base, proc_base, proc_first) = (top.inst_base(), top.proc_base(*proc_first), *proc_first); - let slot_of = |id: u32, edit: &EditState, tables: &tables::Tables, proc_of: &std::collections::HashMap| -> Option<(usize, usize)> { - if let Some(&v) = edit.placed.get(&id) { - Some(v) - } else if let Some(&k) = tables.retained.own_instance.get(&id) { - Some((inst_base + k as usize, k as usize)) - } else { - proc_of.get(&id).map(|&j| (proc_base + j, proc_first + j)) - } - }; - let q = &g.queue; - let mut e = g.device.create_command_encoder(&Default::default()); - // An emitter mesh's entries go (area 0: their weight; the light tree keeps its shape) when it is removed, hidden - // or placed again. - let mut emitters_changed = false; - let release = |id: u32, tables: &mut tables::Tables, meshes: &[scene::InstMesh], pool_first: usize| -> bool { - let Some(k) = tables.retained.emitter_mesh.remove(&id) else { return false }; - let im = meshes[k as usize]; - let (lo, hi) = ((pool_first + im.first as usize) as u32, (pool_first + (im.first + im.count) as usize) as u32); - for e in tables.emitter_tris.iter_mut().filter(|e| (lo..hi).contains(&e.tri)) { - e.area = 0.0; - } - true - }; - for id in &ch.removed { - // An instanced mesh an edit added: its instances, records and entries. - if let Some((r0, cap, o0)) = edit.instanced.remove(id) { - for r in r0..r0 + cap { - top.clear(inst_base + r); - if let Some(i) = instances.get_mut(r) { - i.hidden = true; - } - } - edit.records.give(r0, cap); - edit.objects.give(o0, cap); - tables.retained.instanced.remove(id); - tables.retained.objects.remove(id); - if let Some(k) = edit.mesh_of.remove(id) { - drop_mesh_user(k, edit, tables, meshes); - } - continue; - } - // An instanced mesh the build placed: its instances out (records and entries stay until the next rebuild). - if let Some(place) = tables.retained.instanced.get(id).filter(|p| !p.procedural) { - for r in place.first as usize..(place.first + place.capacity) as usize { - top.clear(inst_base + r); - if let Some(i) = instances.get_mut(r) { - i.hidden = true; - } - } - tables.retained.instanced.remove(id); - tables.retained.objects.remove(id); - continue; - } - emitters_changed |= release(*id, tables, meshes, edit.pool_first); - let at = slot_of(*id, edit, tables, proc_of); - // Placed by the build otherwise (a model, a procedural instanced mesh): a rebuild takes it out. - if at.is_none() && tables.retained.objects.contains_key(id) { - return Err("an object the build placed otherwise (a model, a procedural instanced mesh) removed".into()); - } - if let Some((slot, _)) = at { - top.clear(slot); - } - unpick(*id, edit, tables, instances, procs, proc_of); - // What it held goes back to the room (the build's records and entries stay until the next rebuild). - if let Some((_, r)) = edit.placed.remove(id) { - edit.records.give(r, 1); - } - if let Some(k) = edit.mesh_of.remove(id) { - drop_mesh_user(k, edit, tables, meshes); - } - tables.retained.own_instance.remove(id); - tables.retained.instance_of.remove(id); - if let Some(k) = tables.retained.objects.remove(id).filter(|&k| k as usize >= edit.base.2) { - edit.objects.give(k as usize, 1); - } - proc_of.remove(id); - } - let mut new_meshes = vec![]; - let mut fresh_blases = vec![]; - let mut pending_procs = vec![]; - let mut split = EditSplit::default(); - let (mut w_cdf, mut w_verts, mut w_attrs, mut w_blas, mut w_records, mut w_objects) = (Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default()); - // Vertex colours (F_VCOLOR), in `prev_verts` after the previous vertices. - let mut w_colors = Gather::default(); - let vcolor_base = up.vcolor_base; - // (geometry, material) -> the mesh built in this batch (objects sharing a replaced geometry share it again). - let mut fresh: std::collections::HashMap<(u32, u32), u32> = Default::default(); - let mask = |shadow: bool| if shadow { 1 } else { 2 }; - for id in todo { - let Some(o) = world.objects.get(&id) else { continue }; - let at = slot_of(id, edit, tables, proc_of); - emitters_changed |= release(id, tables, meshes, edit.pool_first); - if !world.shown(id) { - if let Some((slot, _)) = at { - top.clear(slot); - } - if edit.instanced.contains_key(&id) { - return Err("an instanced mesh added in place hidden".into()); - } - unpick(id, edit, tables, instances, procs, proc_of); - if let Some(k) = edit.mesh_of.remove(&id) { - drop_mesh_user(k, edit, tables, meshes); - } - continue; - } - // Drawn but not by a TLAS instance of its own: the build flattened it (movable, or a material instances skip). - if at.is_none() && tables.retained.objects.contains_key(&id) { - return Err("an object the build flattened".into()); - } - let m = world.world_matrix(id); - let object = match tables.retained.objects.get(&id) { - Some(&k) => k as usize, - None => match edit.objects.take(1) { - Some(k) => k, - None if grow.objects(&mut edit.objects, 1, &mut up.objects, tables.stride) => { - edit.grows += 1; - edit.rebind = true; - edit.objects.take(1).ok_or("edit room: objects")? - } - None => return Err("edit room: objects (particles, or the device's limits)".into()), - }, - }; - // Where its TLAS instance places it: the node, or identity for an emitter mesh (world-space triangles). - let mut placement = m; - let (record, gpu, blas, entry) = match &o.kind { - Kind::Procedural { .. } => { - // (Software traversal walks procedural objects by their count and their own trees in the region: - // the build lays those out.) - if top.sw() { - return Err("a procedural object (software traversal)".into()); - } - if features & gpu::F_PROC == 0 { - return Err("the scene's first procedural object (kernel feature)".into()); - } - let Some(p) = scene::proc_object(world, id, m, object as u32, &tables.retained.materials, assets).map_err(|e| format!("{e:#}"))? else { - if let Some((slot, _)) = at { - top.clear(slot); - } - unpick(id, edit, tables, instances, procs, proc_of); - continue; - }; - placement = m; - // Room for `used` words: the region grown if it lacks it. - let mut words_left = |used: usize, edit: &mut EditState| { - if used <= edit.word_cap { - return true; - } - let ok = grow.words(&mut edit.word_cap, used, &mut up.cdf, up.cdf_words); - if ok { - edit.grows += 1; - edit.rebind = true; - } - ok - }; - let boxes = match edit.blobs.get(&p.blob.hash) { - Some(&w) => w, - None => { - let w = edit.word; - if !words_left(w + p.blob.boxes.len() + p.blob.data.len(), edit) { - return Err("edit room: procedural words".into()); - } - let at = |word: usize| ((up.cdf_words + word) * 4) as u64; - w_cdf.put(at(w), bytemuck::cast_slice(&p.blob.boxes)); - w_cdf.put(at(w + p.blob.boxes.len()), bytemuck::cast_slice(&p.blob.data)); - edit.word += p.blob.boxes.len() + p.blob.data.len(); - edit.blobs.insert(p.blob.hash, w); - w - } - }; - let header = edit.word; - let words = p.header(header, boxes, (0, 0)); - if !words_left(header + words.len(), edit) { - return Err("edit room: procedural words".into()); - } - w_cdf.put(((up.cdf_words + header) * 4) as u64, bytemuck::cast_slice(&words)); - edit.word += words.len(); - let blas = g.proc_blas(&p.blob, &mut pending_procs, &mut fresh_blases); - let entry = p.object_entry(o); - let gpu = p.gpu(header as u32); - let record = at.map(|(_, r)| r); - match proc_of.get(&id) { - Some(&j) => procs[j] = p, - None => { - edit.added.insert(id, Picked::Proc(p)); - } - } - (record, gpu, Placed::Proc(blas), entry) - } - Kind::Mesh { materials, .. } if materials.len() > 1 => return Err("a mesh with several materials".into()), - Kind::Model { uri, nodes, animation, functions } => { - // A static model already in the scene: another instance of its mesh (one not in it brings materials - // and textures, which load with a rebuild). - if animation.is_some() { - return Err("an animated model".into()); - } - if !functions.is_empty() { - return Err("a model with material functions (its materials load with a rebuild)".into()); - } - if features & gpu::F_INST == 0 || m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { - return Err("a model the build would flatten (mirrored, instancing off, or the scene's first instance)".into()); - } - let mesh = *tables.retained.model_mesh.get(&(uri.clone(), nodes.clone())).ok_or("a model not in the scene (its materials and textures load with a rebuild)")?; - let im = meshes[mesh as usize]; - if !top.has_mesh(mesh) { - return Err("a model whose mesh is no unit (it deforms; software traversal flattens it)".into()); - } - *edit.users.entry(mesh).or_default() += 1; - if let Some(k) = edit.mesh_of.insert(id, mesh) { - drop_mesh_user(k, edit, tables, meshes); - } - let inst = scene::Instance { mesh, world: m, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; - let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); - let (lo, hi) = im.placed_bounds(m); - let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); - edit.added.insert(id, Picked::Mesh(inst)); - (at.map(|(_, r)| r), gpu, Placed::Mesh(mesh), entry) - } - Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. } => { - // An own-BLAS mesh, or (added in place) an instanced mesh: their mesh made alike. - let (geometry, material, capacity) = match &o.kind { - Kind::Mesh { geometry, material, .. } => (*geometry, *material, None), - Kind::Instanced { geometry, materials, capacity, .. } => (*geometry, *materials.first().ok_or("an instanced mesh without a material")?, Some(*capacity as usize)), - _ => unreachable!(), - }; - let (geometry, material) = (&geometry, &material); - if features & gpu::F_INST == 0 { - return Err("the scene's first instanced mesh (kernel feature)".into()); - } - // What the build flattens instead (scene.rs: mirrored, or instancing off). - if m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { - return Err("a mirrored mesh, or instancing off (the build flattens it)".into()); - } - let mat = *tables.retained.materials.get(material).ok_or("unknown material")?; - let gm = tables.materials[mat as usize]; - // An emitting material (no glass, no function): an emitter mesh, its triangles in world space and its - // own (never shared), its emitter entries added (scene.rs; ROADMAP § 6 step 3). - let emits = scene::emits_only(&gm); - let class = if emits { u32::from(gm.uv2[3] > 0.0) } else { scene::material_class(&gm) }; - if class == 2 || (emits && capacity.is_some()) { - return Err("a glass or function material (an instanced mesh: emitting too)".into()); - } - // (An emitter mesh's triangles are in world space: one a body moves is flattened by the build.) - if emits && o.driven { - return Err("an emitting mesh a rigid body drives (the build flattens it)".into()); - } - if class == 1 && features & gpu::F_MASKED == 0 { - return Err("the scene's first cutout (kernel feature)".into()); - } - let colored = world.materials.get(material).is_some_and(|m| m.vertex_colors) && world.geometries.get(geometry).is_some_and(|g| !g.colors.is_empty()); - if colored && vcolor_base.is_none() { - return Err("the scene's first vertex colours (kernel feature)".into()); - } - let key = if emits { (u32::MAX - id, mat) } else { (*geometry, mat) }; - let kept = (!emits && !ch.geometries.contains(geometry)).then(|| tables.retained.own_mesh.get(&key).copied()).flatten(); - let mesh = match fresh.get(&key).copied().or(kept) { - Some(k) => k, - None => { - let geo = world.geometries.get(geometry).ok_or("unknown geometry")?; - let t0 = Instant::now(); - let (mut t, mut a) = if emits { scene::emitter_mesh_tris(geo, m, mat, object as u32) } else { scene::own_mesh_tris(geo, mat) }; - if t.is_empty() { - if let Some((slot, _)) = at { - top.clear(slot); - } - unpick(id, edit, tables, instances, procs, proc_of); - continue; - } - // Software traversal: the mesh's tree, and its triangles (their attributes, further down their - // colours) in the tree's order, as `prepare` lays a unit's mesh out. - let tree = match &top { - gpu::Top::Hw(_) => None, - gpu::Top::Sw(_) => { - let tree = crate::units::mesh_tree(&t)?; - t = tree.order.iter().map(|&i| t[i as usize]).collect(); - a = tree.order.iter().map(|&i| a[i as usize]).collect(); - Some(tree) - } - }; - // (A unit's cutout triangles carry their flag; hardware traversal has them in a geometry apart.) - let flags = f32::from_bits(if tree.is_some() && class == 1 { scene::TRI_MASKED } else { 0 }); - // As `prepare` lays the pool out: (a, flags), e1, e2; attributes; the BLAS's vertices. - let verts: Vec<[f32; 4]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z, flags], [b.x - a.x, b.y - a.y, b.z - a.z, 0.0], [c.x - a.x, c.y - a.y, c.z - a.z, 0.0]]).collect(); - let blas_verts: Vec<[f32; 3]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z]]).collect(); - split.tris += t0.elapsed().as_secs_f64() * 1e3; - // The same triangle count as the mesh it replaces: rewritten where it is, its BLAS rebuilt (no - // room used; a geometry that moves every frame, three.js's dynamic draw usage). Otherwise new - // triangles in the room and a new BLAS. - let same = tables.retained.own_mesh.get(&key).copied().filter(|&k| meshes[k as usize].count as usize == t.len() && meshes[k as usize].emits == emits); - let (k, first, im) = match same { - Some(k) => { - let im = meshes[k as usize]; - split.deformed += 1; - // (Its tree again: the records of the objects that draw it, all placed again by - // this edit, take the root it has now.) - if let (gpu::Top::Sw(units), Some(tree)) = (&mut top, &tree) { - if let Some(old) = units.drop_tree(k) { - g.fill_tree(&up.bg_parts.nodes, old); - } - let root = units.add_tree(k, tree).ok_or("edit room: tree nodes")?; - g.write_tree(&up.bg_parts.nodes, root, tree); - } - (k, edit.pool_first + im.first as usize, im) - } - None => { - // A hole that fits, else the room's end (grown if it lacks room). - let first = match edit.tris.take(t.len()) { - Some(f) => f, - // (Software traversal's room does not grow: the rebuild makes a larger one.) - None if match &mut top { gpu::Top::Hw(accel) => grow.tris(&mut edit.tris, t.len(), &mut up.verts, &mut up.attrs, &mut up.prev_verts, vcolor_base, accel), gpu::Top::Sw(_) => false } => { - edit.grows += 1; - edit.rebind = true; - edit.tris.take(t.len()).ok_or("edit room: triangles")? - } - None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: triangles (software traversal's does not grow)" } else { "edit room: triangles (particles, or the device's limits)" }.into()), - }; - let mut im = scene::own_inst_mesh(&t, (first - edit.pool_first) as u32, class); - im.emits = emits; - let t1 = Instant::now(); - // A freed mesh's slot, else a new one. - let dead = edit.dead_meshes.pop(); - let k = match &mut top { - gpu::Top::Hw(accel) => match dead { - Some(k) => { - g.set_mesh(accel, k, first, im.count, im.opaque); - k - } - None => g.add_mesh(accel, first, im.count, im.opaque), - }, - // Its tree into the mesh trees' room (a freed mesh's slot gives its tree back first). - gpu::Top::Sw(units) => { - let k = dead.unwrap_or(meshes.len() as u32); - let tree = tree.as_ref().expect("software traversal made the tree"); - if let Some(old) = units.drop_tree(k) { - g.fill_tree(&up.bg_parts.nodes, old); - } - let Some(root) = units.add_tree(k, tree) else { - edit.dead_meshes.extend(dead); - edit.tris.give(first, t.len()); - return Err("edit room: tree nodes".into()); - }; - g.write_tree(&up.bg_parts.nodes, root, tree); - k - } - }; - if k as usize == meshes.len() { - meshes.push(im); - } else { - meshes[k as usize] = im; - } - edit.managed.insert(k); - split.blas += t1.elapsed().as_secs_f64() * 1e3; - split.added += 1; - (k, first, im) - } - }; - let t2 = Instant::now(); - w_verts.put((first * 48) as u64, bytemuck::cast_slice(&verts)); - w_attrs.put((first * std::mem::size_of::()) as u64, bytemuck::cast_slice(&a)); - if !top.sw() { - w_blas.put((first * 36) as u64, bytemuck::cast_slice(&blas_verts)); - } - if let Some(base) = vcolor_base { - // White where it has none: the room's slots may hold an earlier mesh's. - let mut c = if colored { scene::tri_colors(geo, false, None) } else { vec![scene::WHITE; t.len()] }; - if let (Some(tree), true) = (&tree, colored) { - c = tree.order.iter().map(|&i| c[i as usize]).collect(); - } - w_colors.put(base + (first * 12) as u64, bytemuck::cast_slice(&c)); - } - split.upload += t2.elapsed().as_secs_f64() * 1e3; - let rel = first - edit.pool_first; - if pool_tris.len() < rel + t.len() { - pool_tris.resize(rel + t.len(), [glam::Vec3A::ZERO; 3]); - } - pool_tris[rel..rel + t.len()].copy_from_slice(&t); - if emits { - // Its emitter entries, in the power-share part of the CDF (path code 0). Rewritten where - // it was (a mesh that moves every frame: flying pieces), it takes back the entries it had - // there (`release` left them at area 0), so the CDF does not grow frame by frame; those - // in the light tree stay out (their triangles now carry code 0). - let range = first as u32..(first + t.len()) as u32; - let mut free: Vec = if same.is_some() { tables.emitter_tris.iter().enumerate().filter(|(_, e)| !e.in_tree && range.contains(&e.tri)).map(|(i, _)| i).rev().collect() } else { vec![] }; - for (j, tri) in t.iter().enumerate() { - let [ta, tb, tc] = *tri; - let area = 0.5 * (tb - ta).cross(tc - ta).length(); - if area > 0.0 { - let e = crate::EmitterTri { tri: (first + j) as u32, mat, obj: object as u32, area, c: ((ta + tb + tc) / 3.0).to_array(), moves: false, nominal: false, in_tree: false }; - match free.pop() { - Some(i) => tables.emitter_tris[i] = e, - None => tables.emitter_tris.push(e), - } - } - } - tables.retained.emitter_mesh.insert(id, k); - tables.emissive.insert(mat); - emitters_changed = true; - } - let _ = im; - new_meshes.push(k); - fresh.insert(key, k); - tables.retained.own_mesh.insert(key, k); - k - } - }; - let im = meshes[mesh as usize]; - // It draws `mesh` now (and no longer the one it drew, if another). - *edit.users.entry(mesh).or_default() += 1; - if let Some(k) = edit.mesh_of.insert(id, mesh) { - drop_mesh_user(k, edit, tables, meshes); - } - if let Some(cap) = capacity { - // An instanced mesh added in place: `cap` records (their TLAS instances `inst_base` + them) and - // object entries, contiguous, as the build places one (`update_instanced` then rewrites them). - let empty = crate::world::InstanceTable::default(); - let t = world.instanced.get(&id).unwrap_or(&empty); - if t.colors.is_some() && features & gpu::F_TINT == 0 { - return Err("the scene's first instance colours (kernel feature)".into()); - } - let placed: Vec<(glam::Mat4, bool)> = (0..cap).map(|i| scene::instance_world(m, t, i)).collect(); - if placed.iter().any(|(wm, hidden)| !hidden && wm.determinant() < 0.0) { - return Err("a mirrored instance (the build flattens them)".into()); - } - if !tables.retained.objects.contains_key(&id) { - edit.objects.give(object, 1); - } - let r0 = match edit.records.take(cap) { - Some(r) => r, - None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, cap, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { - edit.grows += 1; - edit.rebind = true; - edit.records.take(cap).ok_or("edit room: records")? - } - None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), - }; - let o0 = match edit.objects.take(cap) { - Some(k) => k, - None if grow.objects(&mut edit.objects, cap, &mut up.objects, tables.stride) => { - edit.grows += 1; - edit.rebind = true; - edit.objects.take(cap).ok_or("edit room: objects")? - } - None => return Err("edit room: objects (particles, or the device's limits)".into()), - }; - let (st, size) = (tables.stride, std::mem::size_of::()); - if instances.len() < r0 + cap { - let filler = scene::Instance { mesh, world: glam::Mat4::IDENTITY, object: 0, cast_shadow: false, name: String::new(), sub: None, hidden: true }; - instances.resize(r0 + cap, filler); - } - if gpu_instances.len() < r0 + cap { - gpu_instances.resize(r0 + cap, scene::GpuInstance::default()); - } - if tables.objects.len() < (o0 + cap) * st { - tables.objects.resize((o0 + cap) * st, [0.0; 4]); - } - for (i, &(wm, hidden)) in placed.iter().enumerate() { - let inst = scene::Instance { mesh, world: wm, object: (o0 + i) as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: Some(i as u32), hidden }; - let rec = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); - w_records.put(((r0 + i) * size) as u64, bytemuck::bytes_of(&rec)); - gpu_instances[r0 + i] = rec; - top.place(inst_base + r0 + i, mesh, wm, gpu::INSTANCE_BIT | (r0 + i) as u32, o.cast_shadow, hidden); - let (lo, hi) = im.placed_bounds(wm); - let entry = scene::object_row(wm.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, t.custom(i, o.custom), t.color(i)); - w_objects.put(((o0 + i) * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); - tables.objects[(o0 + i) * st..(o0 + i + 1) * st].copy_from_slice(&entry[..st]); - instances[r0 + i] = inst; - } - tables.retained.instanced.insert(id, scene::InstancedPlace { first: r0 as u32, capacity: cap as u32, object: o0 as u32, procedural: false, drawn: t.count }); - tables.retained.objects.insert(id, o0 as u32); - edit.instanced.insert(id, (r0, cap, o0)); - continue; - } - if emits { - placement = glam::Mat4::IDENTITY; - } - let inst = scene::Instance { mesh, world: placement, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; - let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); - let (lo, hi) = im.placed_bounds(placement); - let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); - let record = at.map(|(_, r)| r); - match record.filter(|&r| r < edit.inst_at_load) { - Some(r) => instances[r] = inst, - None => { - edit.added.insert(id, Picked::Mesh(inst)); - } - } - (record, gpu, Placed::Mesh(mesh), entry) - } - _ => return Err("not an object with its own BLAS".into()), - }; - // Its record, TLAS instance and object entry: where it had them, or new ones from the room. - let (slot, record) = match (at, record) { - (Some((slot, _)), Some(r)) => (slot, r), - _ => { - let r = match edit.records.take(1) { - Some(r) => r, - None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, 1, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { - edit.grows += 1; - edit.rebind = true; - edit.records.take(1).ok_or("edit room: records")? - } - None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), - }; - let v = (inst_base + r, r); - edit.placed.insert(id, v); - v - } - }; - w_records.put((record * std::mem::size_of::()) as u64, bytemuck::bytes_of(&gpu)); - if record >= gpu_instances.len() { - gpu_instances.resize(record + 1, scene::GpuInstance::default()); - } - gpu_instances[record] = gpu; - let custom = if matches!(o.kind, Kind::Procedural { .. }) { record as u32 } else { gpu::INSTANCE_BIT | record as u32 }; - match blas { - Placed::Proc(blas) => top.place_blas(slot, &blas, placement, custom, mask(o.cast_shadow)), - Placed::Mesh(k) => top.place(slot, k, placement, custom, o.cast_shadow, false), - } - let st = tables.stride; - w_objects.put((object * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); - let at = object * st; - if at >= tables.objects.len() { - tables.objects.resize(at + st, [0.0; 4]); - } - tables.objects[at..at + st].copy_from_slice(&entry[..st]); - tables.retained.objects.insert(id, object as u32); - } - // The gathered writes, merged, before the builds that read them. - let tw = Instant::now(); - w_cdf.flush(q, &up.cdf); - w_verts.flush(q, &up.verts); - w_attrs.flush(q, &up.attrs); - if let gpu::Top::Hw(accel) = &top { - w_blas.flush(q, &accel.verts); - } - w_colors.flush(q, &up.prev_verts); - w_records.flush(q, &up.instances); - w_objects.flush(q, &up.objects); - split.upload += tw.elapsed().as_secs_f64() * 1e3; - let t3 = Instant::now(); - new_meshes.sort_unstable(); - new_meshes.dedup(); - // The new meshes' and procedural BLASes and the TLAS in one build (`Gpu::build_meshes`). - match &mut top { - gpu::Top::Hw(accel) => g.build_edit(accel, &new_meshes, &pending_procs, &mut e, true), - // The trees are written; the meshes this edit freed give theirs back; the top tree over the units as they - // are now (one that cannot be made: the scene is rebuilt). - gpu::Top::Sw(units) => { - for &k in &edit.dead_meshes { - if let Some(old) = units.drop_tree(k) { - g.fill_tree(&up.bg_parts.nodes, old); - } - } - g.retop(units, &up.bg_parts.nodes)?; - } - } - split.blas += t3.elapsed().as_secs_f64() * 1e3; - // THREERT_PROFILE_BLAS=1: the BLAS / TLAS builds' GPU time (the queue drained before and after; frame log - // `blas`), as for the dynamic rebuilds. - let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); - if profile_blas { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - } - let t = Instant::now(); - q.submit([e.finish()]); - g.keep_proc_blases(fresh_blases); - // Buffers that grew: the scene bind group over them. - if std::mem::take(&mut edit.rebind) { - g.rebind_scene(up); - } - if profile_blas { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - self.gpu_blas_ms += t.elapsed().as_secs_f32() * 1000.0; - } - // The uniform tables: entries the edits placed past what a uniform buffer holds read as its last one. - if g.uniform_tables && !edit.uniform_said { - let notes = gpu::uniform_overflow([0, (edit.objects.end * tables.stride) as u64, edit.records.end as u64, 0]); - for note in ¬es { - eprintln!("threert: {note}"); - } - edit.uniform_said = !notes.is_empty(); - } - split.objects = ch.added.len() + ch.removed.len(); - self.edit_split.add(&split); - if emitters_changed { - self.write_emitters(); - } - Ok(()) - } - - pub fn rebuild(&mut self, world: &World) -> Result<()> { - let (s, pp) = load_scene(&self.g, world, &self.assets, &self.presets, self.w, self.h, &self.targets, &self.passes)?; - self.apply_post(&pp); - self.triangles = s.triangles; - self.scene_name = s.name.clone(); - // The scene's camera changed (a new lens rebuilds, and a move in the same commit comes with it): the view takes it. - // Otherwise the view stays where it is. - if s.scene_camera != self.sc.scene_camera { - self.cam = s.scene_camera; - } - // What the grid programs had simulated goes with the grids that are still the same. - self.g.carry_grids(&self.sc.up, &s.up); - self.sc = s; - self.spark_lights = self.sc.tables.emitter_tris.iter().any(|e| e.nominal); - self.inst_moving.clear(); - self.prev_span = None; - self.pose_span = None; - self.emitters_moved = false; - self.lit_changed = self.frame.max(1); - Ok(()) - } - - /// Rebuilds the tonemap LUT and bloom / vignette / fringe / grain settings when the scene's post preset changed. - fn apply_post(&mut self, pp: &crate::desc::PostPreset) { - let key = format!("{pp:?}"); - if key == self.post_key { - return; - } - let lut = tonemap_lut(pp); - self.g.queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &self.targets.lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - bytemuck::cast_slice(&lut), - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, - wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, - ); - self.bloom = bloom_stages(pp, self.w, self.h); - self.vignette = pp.vignette; - // THREERT_NO_GRAIN=1: no film grain (a fresh random pattern every frame; comparisons between frames see it as error). - let grain = if std::env::var("THREERT_NO_GRAIN").is_ok_and(|v| v == "1") { 0.0 } else { pp.film_grain }; - // THREERT_MOTION_BLUR=1: UE's default motion blur whatever the preset says; =amount,max,targetFps: those. - let mb = match std::env::var("THREERT_MOTION_BLUR").ok().as_deref() { - Some("1") => crate::desc::MotionBlur::default(), - Some(v) => match v.split(',').map(|x| x.trim().parse::()).collect::, _>>().as_deref() { - Ok([amount, max, target_fps]) => crate::desc::MotionBlur { amount: *amount, max: *max, target_fps: *target_fps }, - _ => pp.motion_blur.clone(), - }, - None => pp.motion_blur.clone(), - }; - self.post = [pp.chromatic_aberration, grain, pp.bloom.threshold, mb.max / 100.0 * self.w as f32]; - self.blur = (if mb.max > 0.0 { mb.amount.max(0.0) } else { 0.0 }, mb.target_fps); - self.post_key = key; - } - - /// Switches to a different world: camera from it, history cleared. - pub fn open(&mut self, world: &World, assets: &Path) -> Result<()> { - self.assets = assets.to_path_buf(); - self.rebuild(world)?; - if let Some(c) = &self.cache { - let mut e = self.g.device.create_command_encoder(&Default::default()); - c.bufs.clear(&mut e); - self.g.queue.submit([e.finish()]); - } - self.cam = self.sc.scene_camera; - self.prev_cam = self.cam; - self.reset = true; - Ok(()) - } - - /// The view is a display's physical pixels, `k` a point: dynamic resolution, from the points' resolution (the rays - /// a view in points starts with) down to `MIN_SCALE` of it. A fixed scale is set after it (`set_scale`). - pub fn set_display_scale(&mut self, k: f32) { - let k = k.max(1.0); - self.display_scale = k; - self.min_scale = (MIN_SCALE / k).max(SCALE_FLOOR); - self.render_scale = 1.0 / k; - self.dynamic_res = true; - } - - /// The scene's lighting is changing: something moved or was edited in the last 45 frames, or the scene has spark - /// lights (light-sampled particles: they move every frame, and no edit says so). - fn live_lighting(&self) -> bool { - self.spark_lights || (self.lit_changed > 0 && self.frame.wrapping_sub(self.lit_changed) < 45) - } - - /// Fixed render scale (`s` in SCALE_FLOOR ..= 1), or dynamic resolution (`s` = 0). - pub fn set_scale(&mut self, s: f32) { - self.dynamic_res = s <= 0.0; - if s > 0.0 { - self.render_scale = s.clamp(SCALE_FLOOR, 1.0); - } - } - - pub fn reset_history(&mut self) { - self.reset = true; - } - - /// Ray tracing on or off (see `rays`). The history starts over: the two modes light a pixel differently. - pub fn set_rays(&mut self, on: bool) { - if self.rays != on { - self.rays = on; - self.reset = true; - } - } - - pub fn rays(&self) -> bool { - self.rays - } - - pub fn set_camera(&mut self, mut c: Camera) { - if c.tan_half_fov <= 0.0 { - c.tan_half_fov = self.sc.tan_half_fov; - } - self.cam = c; - } - - /// Renders one frame with `spp` samples per pixel; returns the command buffers to submit after the samples. - /// Path-trace dispatches are submitted inside (each needs its own uniform contents). - /// Traced resolution at the current render scale. - pub fn internal_size(&self) -> (u32, u32) { - let s = self.render_scale.clamp(SCALE_FLOOR, 1.0); - (((self.w as f32 * s).round() as u32).clamp(8, self.w), ((self.h as f32 * s).round() as u32).clamp(8, self.h)) - } - - /// Dynamic resolution (UE r.DynamicRes): twice a second, move the render scale toward the frame budget (traced - /// pixels ~ GPU time), within [`min_scale`, 1], in damped steps with hysteresis so it does not oscillate. - /// Two frames in a row well over the frame time (x 1.25) act at once (UE - /// r.DynamicRes.MaxConsecutiveOverbudgetGPUFrameCount = 2): turning from a light view at a raised scale toward a - /// heavy one otherwise cost up to 30 frames at 35-48 ms (the 3 x 3 district's drive, its far end). Just over the - /// frame time waits for the regular check, as before. After a change, only frames rendered at the new scale count: - /// GPU time lags the scale by the frames in flight, and acting on the old scale's time overshot to the floor. - /// - /// "Twice a second" and "the first 2 s" are counted in frames (every 30th, the first 120), which is the same thing - /// at 60 frames a second. Where frames are much slower (`govern_by_clock`: a phone's GPU, on which a scene opens - /// at its full scale at 6 frames a second) they are also read off the clock: counted in frames alone the first - /// check that may act at once came after 20 s and the regular one every 5 s, and a game opened as a slide show - /// for half a minute (Adreno 740, OpenStrikeRT: 25 s to its frame rate; 3 s with the clock). - pub fn govern_resolution(&mut self, fps: u32) { - if !self.dynamic_res || self.gpu_ms <= 0.0 { - return; - } - if (self.gpu_frame.wrapping_sub(self.over_budget.1) as i32) < 0 { - return; - } - let clock = govern_by_clock(); - // Not in the first 2 s: those frames run while caches fill (Kabukichō's first frames at 0.7 take ~47 ms), and - // the regular check settles the start as before. - let warm = self.frame >= 120 || (clock && self.born.elapsed().as_secs_f32() >= 2.0); - self.over_budget.0 = if warm && self.gpu_ms > 1250.0 / fps as f32 && self.render_scale > self.min_scale { self.over_budget.0 + 1 } else { 0 }; - let urgent = self.over_budget.0 >= 2; - // (By the clock only when 30 frames take longer than 0.75 s: at 40 frames a second and above the frame count - // comes first, as it always did.) - let due = self.frame % 30 == 1 || (clock && self.governed.elapsed().as_secs_f32() >= 0.75); - if !due && !urgent { - return; - } - self.governed = Instant::now(); - self.over_budget.0 = 0; - let before = self.render_scale; - let budget = 1000.0 / fps as f32 * 0.9; - let target = (self.render_scale * (budget / self.gpu_ms).sqrt()).clamp(self.min_scale, 1.0); - // The hysteresis (0.03) does not hold the scale just short of either end: 0.525 with the floor as the target - // kept the district's return drive at 21-22 ms. - let to_end = (target == 1.0 && self.render_scale < 1.0 && self.gpu_ms < budget * 0.8) || (target == self.min_scale && self.render_scale > self.min_scale); - // (The hysteresis, the steps and the grid of scales are a view in points'; on a display of k pixels a point the - // same share of the rays is 1 / k of the scale. Left absolute, a Retina view held 0.325 at 17.3 ms: its target, - // 0.30, was inside the hysteresis.) - let k = self.display_scale; - if (target - self.render_scale).abs() > 0.03 / k || to_end { - // Down fast when far over budget (a heavy scene just opened), up slowly; straight to the target when urgent. - let step = if urgent { target - self.render_scale } else { (target - self.render_scale).clamp(if self.gpu_ms > budget * 1.5 { -0.3 } else { -0.15 } / k, 0.1 / k) }; - self.render_scale = ((self.render_scale + step) * 40.0 * k).round() / (40.0 * k); - } - if self.render_scale != before { - // `frame` is already the next frame's index (render advanced it): the first one at the new scale. - self.over_budget.1 = self.frame; - } - } - - /// Light LOD: the emitter CDF weighted by distance to the camera (`emitter_lod`) and the emitter cull with its view - /// cone (`EmitterCull`), rebuilt when the camera is 15 m from where it was last built or has turned LIGHT_LOD_TURN - /// from that direction (the kernel's emitter pdfs read the same reference from the parameters). - fn update_light_lod(&mut self) { - let d0 = LIGHT_LOD_D0; - let (w, h) = (self.w, self.h); - let b = &mut self.sc.base; - // The light tree weighs emitters by their distance to each shading point itself: no light LOD, no cull. - if d0 <= 0.0 || self.sc.tables.emitter_tris.is_empty() || b.ltree[3] > 0.5 { - return; - } - let r = glam::Vec3::new(b.light_lod[0], b.light_lod[1], b.light_lod[2]); - let dir = glam::Vec3::new(b.lod_view[0], b.lod_view[1], b.lod_view[2]); - let turned = b.emit_power[2] > 0.0 && dir.dot(self.cam.fwd) < LIGHT_LOD_TURN.to_radians().cos(); - if b.light_lod[3] > 0.0 && r.distance(self.cam.pos) < 15.0 && !turned { - return; - } - b.light_lod = [self.cam.pos.x, self.cam.pos.y, self.cam.pos.z, d0]; - b.emit_power[1] = EMITTER_CULL; - b.emit_power[2] = EMITTER_CULL * EMITTER_CULL_VIEW; - // The frustum's half-diagonal: tan = tan(fov / 2) x sqrt(1 + aspect^2). - let half = (self.cam.tan_half_fov * (1.0 + (w as f32 / h as f32).powi(2)).sqrt()).atan() + EMITTER_CULL_MARGIN.to_radians(); - b.lod_view = [self.cam.fwd.x, self.cam.fwd.y, self.cam.fwd.z, half.min(std::f32::consts::PI).cos()]; - // Power edits held back for the emitter worker land first (its job in flight was built for the old reference: - // dropped), so the CDF built here has the powers the objects hold and the reference the kernel reads. - self.land_powers_now(); - let (e, b) = (&self.sc.tables, &mut self.sc.base); - let (em, total, power) = crate::emitter_cdf(&e.emitter_tris, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); - self.g.queue.write_buffer(&self.sc.up.emitters, 0, bytemuck::cast_slice(&em)); - b.misc[1] = total; - b.emit_power[0] = power; - } - - pub fn render(&mut self, spp: u32, extra: impl FnOnce(&mut wgpu::CommandEncoder, &wgpu::Buffer)) { - // A dynamic sky's bake that finished since the last frame (time of day). - self.take_sky(); - self.take_emitters(); - let (w, h) = (self.w, self.h); - let (iw, ih) = self.internal_size(); - let now = Instant::now(); - // Lockstep (benchmarks) renders at a fixed frame time, whatever the wall clock says. - self.frame_dt = self.fixed_dt.unwrap_or_else(|| now.duration_since(self.last_frame).as_secs_f32()); - self.last_frame = now; - let moved = self.cam != self.prev_cam; - self.update_light_lod(); - // A camera cut (a jump no camera makes in one frame: 2 m or 30 degrees) is not motion: no blur (UE: bCameraCut). - let cut = (self.cam.pos - self.prev_cam.pos).length() > 2.0 || self.cam.fwd.dot(self.prev_cam.fwd) < 0.866; - self.cold_frames = if self.reset || cut { 0 } else { self.cold_frames.saturating_add(1) }; - // (Objects moved this frame: re-posed triangles or moved instances, e.g. walkers in a shared pose.) - let blur = !self.motion_blur_off && self.blur.0 > 0.0 && (moved || self.reposed || self.pose_reposed || self.inst_moved) && !cut; - // Camera motion is reprojected exactly (disocclusions are rejected per pixel), so history survives motion; - // the cap only bounds smearing of view-dependent shading (glossy reflections). - // Diffuse lighting reprojects exactly with the camera; view-dependent specular is handled per pixel (temporal pass). - // The most frames a pixel's history holds: HISTORY_LIVE while the scene's lighting changes, HISTORY_FRAMES once - // it has settled. Within it the length is each pixel's own (live.wgsl `dtemporal`: less where it moves, anti-lag - // where its lighting changes, restarted where its surface is new). No edit cuts it: until 2026-11-12 a - // lighting edit left every pixel 16 frames and a geometry edit 4, the whole frame for a change in one corner. - let cap = if self.reset { 1.0 } else if self.live_lighting() { HISTORY_LIVE } else { HISTORY_FRAMES }; - self.history_len = if self.reset { 1.0 } else { (self.history_len + 1.0).min(cap) }; - let cam = self.cam; - let g = &self.g; - // At most two frames in flight: beyond that the CPU waits, so latency stays bounded when GPU-bound. - // Wait for the oldest frame only (waiting for all work would leave the GPU idle while this frame records). - let t_wait = Instant::now(); - while self.inflight.len() >= 2 { - let oldest = self.inflight.pop_front().unwrap(); - let _ = g.device.poll(wgpu::PollType::Wait { submission_index: Some(oldest), timeout: None }); - } - let waited = t_wait.elapsed().as_secs_f32() * 1000.0; - // The loop guard's count, read back since an earlier frame. - let mut guard_free = false; - if let Some((read, state)) = &self.guard_read { - if state.load(Ordering::Acquire) == 2 { - let n = u32::from_le_bytes(read.slice(..).get_mapped_range().unwrap()[..4].try_into().expect("four bytes")); - read.unmap(); - state.store(0, Ordering::Release); - if self.guard_trips != Some(n) { - eprintln!("live: the loop guard has stopped {n} of the kernel's invocations: frames with wrong pixels (a traversal that does not end on this scene's data)"); - } - self.guard_trips = Some(n); - } - guard_free = state.load(Ordering::Acquire) == 0; - } - // GPU time drives adaptive resolution / samples per frame: timestamps when available (no stall), else twice a - // second a frame timed in isolation (idle GPU -> submit -> wait). - let period = g.queue.get_timestamp_period(); - let timed = match &mut self.timer { - Some(t) => { - let (latest, slot) = t.harvest(period); - if let Some((ms, frame, trace)) = latest { - (self.gpu_ms, self.gpu_frame, self.gpu_trace_ms) = (ms, frame, trace); - } - if let Some(k) = slot { - t.frames[k] = self.frame; - t.spps[k] = spp.max(1); - } - slot - } - None => None, - }; - let measure = self.timer.is_none() && self.frame % 30 == 0; - if measure { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - } - // Nothing moved since the last in-place update: its span's previous-frame vertices become the current ones. - if let (false, Some(span)) = (self.reposed, self.prev_span) { - self.keep_prev(span); - self.prev_span = None; - } - self.reposed = false; - if let (false, Some(span)) = (self.pose_reposed, self.pose_span) { - self.keep_prev(span); - self.pose_span = None; - } - self.pose_reposed = false; - // Likewise the instances the last update moved: their previous-frame rows become the current ones. - if !self.inst_moved && !self.inst_moving.is_empty() { - let size = std::mem::size_of::() as u64; - for i in std::mem::take(&mut self.inst_moving) { - let r = &mut self.sc.gpu_instances[i as usize]; - r.p = r.m; - self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(r)); - } - } - self.inst_moved = false; - let g = &self.g; - let t_submit = Instant::now(); - let pipeline = g.pipeline(&self.sc.up.kernel, true); - // (No media pass while every medium is hidden: a pool of idle fireballs costs the frame nothing.) - let media = self.sc.up.kernel.features & gpu::F_MEDIA != 0 && self.sc.up.media_hidden.borrow().iter().any(|h| !h); - let media_train = (media && self.cache.is_some()).then(|| g.media_pipeline(&self.sc.up.kernel, "media_train")); - let media_composite = media.then(|| g.media_pipeline(&self.sc.up.kernel, "media_composite")); - // This frame's sub-pixel jitter (Halton 2, 3), every camera ray's and the raster visibility pass's: 8 phases at - // full resolution, as many more as an output pixel has fewer traced ones (8 / scale^2: the upscaler needs each - // output pixel visited), in the 12 bits a side the samples carry it in. - 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))); - for k in 0..spp.max(1) { - let mut p = self.sc.base; - // 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)); - } - p.raster = [(self.raster.is_some() && !self.sc.instanced) as u32 as f32, jitter.0, jitter.1, 0.0]; - p.raster[3] = if self.rays { 0.0 } else { 1.0 }; - if !self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty() { - // The kernel finds the first dynamic triangle in Params::group_first (group 4), a u32. - p.motion[0] = 1.0; - } - if self.cache.is_some() { - // Light-sampled particles (sparks: Particles emitterArea) move their light every frame: the cache and - // the temporal pass keep the short history of a lighting edit while the scene has them. - let lit = self.live_lighting(); - let cold = if self.cold_frames >= COLD_FALLBACK_FRAMES { 1 } else { 0 }; - p.cache = [1.0, CACHE_SAMPLES, if lit { CACHE_FRAMES_LIT } else { CACHE_FRAMES }, (cache_flags() | cold) as f32]; - } - p.time[0] = shader_time(self.time); - p.cam_pos = [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov]; - p.cam_fwd = [cam.fwd.x, cam.fwd.y, cam.fwd.z, self.aspect]; - p.cam_right = [cam.right.x, cam.right.y, cam.right.z, 0.0]; - p.cam_up = [cam.up.x, cam.up.y, cam.up.z, 1.0]; - p.size = [iw, ih, self.frame.wrapping_mul(16).wrapping_add(k), crate::rng_seed()]; - p.counts2[2] = if ih < h { h } else { 0 }; - p.misc[3] = k as f32; - p.counts[2] = p.counts[2].min(self.bounces); - p.counts[3] = p.counts[3].min(2); - g.queue.write_buffer(&self.sc.up.params, 0, bytemuck::bytes_of(&p)); - let mut e = g.device.create_command_encoder(&Default::default()); - // Particles: placed for this frame's time and camera, then the dynamic BLASes rebuilt (before the raster and - // the trace). - // The grid programs (density grids changed on the GPU), before anything reads the grids. - if k == 0 && gpu::Gpu::has_grid_sim(&self.sc.up) { - let sources = std::mem::take(&mut self.grid_sources); - g.run_grids(&self.sc.up, shader_time(self.time), self.frame_dt, &sources, &mut e, self.profile.as_ref().map(|p| (&p.grid.0, 0))); - if let Some(p) = &self.profile { - e.resolve_query_set(&p.grid.0, 0..4, &p.grid.1, 0); - e.copy_buffer_to_buffer(&p.grid.1, 0, &p.grid.2, 0, 32); - } - } - // Stateful GPU particles first (their emissions, the game's spawns): with stateless ones the pass below - // rebuilds the dynamic BLASes for both, else this one does. - if let (Some(_), 0) = (&self.sc.up.gpu_particles, k) { - let n = self.passes.pipes.len() as u32 + 2; - let stateless = self.sc.up.particles.is_some(); - let (pass_ts, blas_ts) = match (&self.profile, &self.timer, timed, stateless) { - (Some(p), _, _, false) => (Some((&p.queries, n * 2)), Some((&p.queries, n * 2 + 2))), - (None, Some(t), Some(slot), false) => (Some((&t.queries, slot as u32 * 8 + 6)), None), - _ => (None, None), - }; - let spawns = std::mem::take(&mut self.particle_spawns); - g.run_gpu_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, &spawns, !stateless, &mut e, pass_ts, blas_ts); - } - if let (Some(_), 0) = (&self.sc.up.particles, k) { - let b = &self.sc.base; - let n = self.passes.pipes.len() as u32 + 2; - let pass_ts = match (&self.profile, &self.timer, timed) { - (Some(p), _, _) => Some((&p.queries, n * 2)), - (None, Some(t), Some(slot)) => Some((&t.queries, slot as u32 * 8 + 6)), - _ => None, - }; - let blas_ts = self.profile.as_ref().map(|p| (&p.queries, n * 2 + 2)); - g.run_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, b.time[2], b.time[1], &mut e, pass_ts, blas_ts); - } - // (The merged layout and the uniform tables: the tables as this frame's edits and the particle passes above - // left them, where the kernel reads them; merged.rs.) - if k == 0 { - g.sync_tables(&self.sc.up, &mut e); - } - if let (Some(r), 0, false) = (&self.raster, k, self.sc.instanced) { - let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::RenderPassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 4), end_of_pass_write_index: Some(slot as u32 * 8 + 5) }); - r.record(g, &mut e, &self.sc.up.verts, self.sc.tris.len() as u32, &cam, self.aspect, (iw, ih), jitter, tw); - } - { - let tw = match (&self.profile, &self.timer, timed) { - (Some(p), _, _) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(0), end_of_pass_write_index: Some(1) }), - (None, Some(t), Some(slot)) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8), end_of_pass_write_index: Some(slot as u32 * 8 + 1) }), - _ => None, - }; - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live trace"), timestamp_writes: tw }); - pass.set_pipeline(&pipeline); - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); - // Participating media: their volume voxels trained after the trace (pathtrace.wgsl media_train), and - // camera rays through media once the frame's samples are traced (media_composite). In the trace's pass - // and timing; profiled, the frame's last sample's media passes are a pass of their own (`media`). - let last = k + 1 == spp.max(1); - if let (Some(p), true, true) = (&self.profile, last, media_composite.is_some()) { - drop(pass); - pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("media"), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.grid.0, beginning_of_pass_write_index: Some(4), end_of_pass_write_index: Some(5) }) }); - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - } - if let (Some(mt), 0) = (&media_train, k) { - pass.set_pipeline(mt); - pass.dispatch_workgroups(MEDIA_TRAIN_POINTS.div_ceil(64), 1, 1); - } - if let (Some(mc), true) = (&media_composite, last) { - pass.set_pipeline(mc); - pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); - } - } - if let (Some(p), true, true) = (&self.profile, k + 1 == spp.max(1), media_composite.is_some()) { - e.resolve_query_set(&p.grid.0, 4..6, &p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT); - e.copy_buffer_to_buffer(&p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, &p.grid.2, 32, 16); - } - // Each pair is resolved in the command buffer that wrote it (Metal reads zeros otherwise). - if let (None, Some(t), Some(slot), 0) = (&self.profile, &self.timer, timed, k) { - let q = slot as u32 * 8; - let at = slot as u64 * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; - e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); - e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 0, 16); - // Raster and particle pairs (pairs 2 and 3); zeros when the pass did not run. - for (pair, ran) in [(2u32, self.raster.is_some() && !self.sc.instanced), (3, self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some())] { - let at = at + pair as u64 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; - if ran { - e.resolve_query_set(&t.queries, q + pair * 2..q + pair * 2 + 2, &t.resolve, at); - } else { - e.clear_buffer(&t.resolve, at, Some(16)); - } - e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, pair as u64 * 16, 16); - } - } - g.queue.submit([e.finish()]); - } - let (r, t) = self.bloom; - let lp = LiveParams { - cur: cam.uniform(self.aspect), - prev: self.prev_cam.uniform(self.aspect), - size: [w, h, self.frame, self.reset as u32], - misc: [self.sc.exposure, cap, self.vignette, (self.bloom.1.iter().sum::() > 0.0) as u32 as f32], - // misc2.z: motion blur, this frame's motion (pixels) -> blur length. UE (PostProcessMotionBlur.cpp, - // UpdateMotionBlurTimeScale) blurs over +- amount / 2 x the motion x (1 / target FPS) / frame time, the - // actual frame time without a target. 0 while nothing moves (the pass then costs nothing). - misc2: [1.0, SAMPLE_CEILING, if !blur { 0.0 } else if self.blur.1 > 0.0 { self.blur.0 / (self.blur.1 * self.frame_dt.clamp(1.0 / 240.0, 0.25)) } else { self.blur.0 }, spp.max(1) as f32], - mode: [self.view_mode, 0, self.live_lighting() as u32, 0], - post: self.post, - ae: self.sc.exposure_settings.ae, - ae2: self.sc.exposure_settings.ae2, - ae3: [self.sc.exposure_settings.speed[0], self.sc.exposure_settings.speed[1], self.frame_dt.clamp(1.0 / 240.0, 0.25), (self.reset || self.frame == 0 || exposure_snap()) as u32 as f32], - lx: self.sc.exposure_settings.lx, - isz: [iw, ih, self.prev_isz.0, self.prev_isz.1], - br: [r[0], r[1], r[2], r[3]], - br2: [r[4], r[5], 0.0, 0.0], - bt: [t[0], t[1], t[2], t[3]], - bt2: [t[4], t[5], 0.0, 0.0], - 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], - }; - g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp)); - let bg = &self.sc.bg; - // Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion - // blur follow, else fa: they have no motion vectors, so they are no part of the colour history; UE draws its - // translucency after the upscaler the same way), then their volume voxels trained. - // (Nothing to draw while every translucent particle system is hidden in place.) - let tl = self.sc.up.translucent.as_ref().filter(|_| g.translucent_particles_live(&self.sc.up)).map(|t| { - let pipes = g.translucent_pipelines(&self.sc.up.kernel, true); - let target = if lp.misc2[2] > 0.0 || lp.dof[0] > 0.0 { &self.targets.sb.buffer } else { &self.targets.fa.buffer }; - // Below full resolution the particles are drawn per traced pixel, then laid over the output (`translucent_up`). - let low = (iw, ih) != (w, h); - let tp = gpu::TranslucentParams { size: [w, h, t.first, 1], view: [iw, ih, gpu::VOLUME_TRAIN_CAP, low as u32], misc: [VOLUME_TRAIN_SHARE, translucent_hidden(self.frame) as u32 as f32, 0.0, 0.0] }; - g.queue.write_buffer(&t.params, 0, bytemuck::bytes_of(&tp)); - (pipes, g.translucent_bind_group(t, target), &t.count) - }); - // The pass (train = false) or the volume training (train = true). - let tl_dispatch = |pass: &mut wgpu::ComputePass, train: bool| { - if let Some((pipes, tbg, _)) = &tl { - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - pass.set_bind_group(2, tbg, &[]); - if train { - pass.set_pipeline(&pipes.train); - pass.dispatch_workgroups(gpu::VOLUME_TRAIN_CAP / 64, 1, 1); - } else if (iw, ih) != (w, h) { - pass.set_pipeline(&pipes.draw); - pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); - pass.set_pipeline(&pipes.up); - pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); - } else { - pass.set_pipeline(&pipes.draw); - pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); - } - } - }; - let mut e = g.device.create_command_encoder(&Default::default()); - if let Some((_, _, count)) = &tl { - e.clear_buffer(count, 0, None); - } - if let Some(c) = &self.cache { - let n = self.passes.pipes.len() as u32 + 1; - let tw = self.profile.as_ref().map(|p| wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(n * 2), end_of_pass_write_index: Some(n * 2 + 1) }); - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cache resolve"), timestamp_writes: tw }); - pass.set_pipeline(&c.resolve); - pass.set_bind_group(0, &self.sc.up.bg, &[]); - pass.set_bind_group(1, &self.kernel_bg, &[]); - pass.dispatch_workgroups((gpu::CACHE_SIZE / 256) as u32, 1, 1); - } - if let Some(p) = &self.profile { - // One compute pass per stage, each timed. - for (k, (name, 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) }) }); - pass.set_pipeline(pipe); - pass.set_bind_group(0, bg, &[]); - let (gw, gh) = if *name == "exposure" { (8, 8) } else if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; - pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); - drop(pass); - if *name == "upscale" && tl.is_some() { - for (j, train) in [(0u32, false), (1, true)] { - let q = (self.passes.pipes.len() as u32 + 4 + j) * 2; - let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(if train { "vtrain" } else { "translucent" }), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); - tl_dispatch(&mut pass, train); - } - } - } - let n = self.passes.pipes.len() as u32 + 2 + self.profile_extra(); - e.resolve_query_set(&p.queries, 0..n * 2, &p.resolve, 0); - e.copy_buffer_to_buffer(&p.resolve, 0, &p.read, 0, p.read.size()); - } else { - 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 (name, pipe) in &self.passes.pipes { - pass.set_pipeline(pipe); - pass.set_bind_group(0, bg, &[]); - if *name == "exposure" { - pass.dispatch_workgroups(1, 1, 1); - continue; - } - let (gw, gh) = if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; - pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); - if *name == "upscale" { - tl_dispatch(&mut pass, false); - tl_dispatch(&mut pass, true); - } - } - drop(pass); - if let (Some(t), Some(slot)) = (&self.timer, timed) { - let q = slot as u32 * 8 + 2; - let at = (slot as u64 * 4 + 1) * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; - e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); - e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 16, 16); - } - } - if let (Some((read, _)), Some(count), true) = (&self.guard_read, &self.targets.kernel.guard, guard_free) { - e.copy_buffer_to_buffer(&count.buffer, count.offset, read, 0, 4); - } - extra(&mut e, &self.targets.out); - let done = g.queue.submit([e.finish()]); - self.inflight.push_back(done); - self.cpu_wait_ms = waited; - if let (Some((read, state)), true) = (&self.guard_read, guard_free) { - let state = state.clone(); - state.store(1, Ordering::Release); - read.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); - } - if let (Some(t), Some(slot)) = (&self.timer, timed) { - let state = t.reads[slot].1.clone(); - state.store(1, Ordering::Release); - t.reads[slot].0.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); - } - // Without timestamps: GPU time ~ submit -> completion (the queue is idle at submit when the frame fits the budget). - if measure { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - (self.gpu_ms, self.gpu_frame) = (t_submit.elapsed().as_secs_f32() * 1000.0, self.frame); - } - let extra = self.profile_extra() as usize; - if let Some(p) = &mut self.profile { - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - p.read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - let t: Vec = bytemuck::cast_slice::(&p.read.slice(..).get_mapped_range().unwrap()).to_vec(); - p.read.unmap(); - let period = g.queue.get_timestamp_period() as f64 * 1e-6; - // The particle, BLAS, translucency and volume training slots (the last four) are written only when the scene - // has particles / translucent ones. - let n = self.passes.pipes.len() + 2 + extra; - for (k, s) in p.sums.iter_mut().enumerate().take(n) { - *s += t[k * 2 + 1].saturating_sub(t[k * 2]) as f64 * period; - } - let (sim, media) = (gpu::Gpu::has_grid_sim(&self.sc.up), media_composite.is_some()); - if sim || media { - p.grid.2.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); - let t: Vec = bytemuck::cast_slice::(&p.grid.2.slice(..).get_mapped_range().unwrap()).to_vec(); - p.grid.2.unmap(); - if sim { - p.grid_sum += (t[1].saturating_sub(t[0]) + t[3].saturating_sub(t[2])) as f64 * period; - } - if media { - p.media_sum += t[5].saturating_sub(t[4]) as f64 * period; - } - } - p.frames += 1; - if p.frames == 120 { - let names: Vec<&str> = std::iter::once("trace").chain(self.passes.pipes.iter().map(|(n, _)| *n)).chain(["cache", "particles", "blas", "translucent", "vtrain"]).collect(); - let mut rows: Vec = names.iter().zip(&p.sums).map(|(n, s)| format!("{n} {:.2}", s / p.frames as f64)).collect(); - rows.push(format!("gridsim {:.2}", p.grid_sum / p.frames as f64)); - rows.push(format!("media {:.2}", p.media_sum / p.frames as f64)); - rows.retain(|r| !r.ends_with(" 0.00")); - eprintln!("profile ms/frame: total {:.2} | {}", (p.sums.iter().sum::() + p.grid_sum + p.media_sum) / p.frames as f64, rows.join(", ")); - (p.grid_sum, p.media_sum) = (0.0, 0.0); - p.sums.iter_mut().for_each(|s| *s = 0.0); - p.frames = 0; - } - } - self.prev_cam = cam; - self.shown_spp = spp.max(1); - self.prev_isz = (iw, ih); - self.reset = false; - self.frame = self.frame.wrapping_add(1); - } - - #[cfg(not(target_arch = "wasm32"))] - /// The last frame's linear HDR radiance (nits, before exposure and post), RGB per output pixel. Waits for the GPU. - pub fn read_hdr(&self) -> Result> { - let d = &self.g.device; - let size = self.w as u64 * self.h as u64 * 16; - let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("hdr read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(&self.targets.fa.buffer, self.targets.fa.offset, &read, 0, size); - self.g.queue.submit([e.finish()]); - read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely())?; - let px = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap()).iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); - Ok(px) - } - - #[cfg(not(target_arch = "wasm32"))] - /// The last frame's signals apart, linear RGB f32 per output pixel: diffuse (the composed colour less the rest, so - /// with the sun's direct light, which view mode 6 leaves out), specular (the specular denoiser's output: mode 7) and - /// emission (what the camera ray saw directly). They are the render resolution's (the frame as `compose` hands it to - /// the upscaler), each output pixel taking the traced sample under it; not with depth of field and motion blur both - /// on (the composed colour's buffer is theirs then). Waits for the GPU. - pub fn read_signals(&self) -> Result<[Vec; 3]> { - let d = &self.g.device; - let (w, h) = (self.w as usize, self.h as usize); - let (iw, ih) = (self.prev_isz.0 as usize, self.prev_isz.1 as usize); - let (n, tn) = ((w * h) as u64, (iw * ih) as u64); - let t = &self.targets; - let read = |buffer: &wgpu::Buffer, offset: u64, size: u64| -> Result> { - let r = d.create_buffer(&wgpu::BufferDescriptor { label: Some("signals read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(buffer, offset, &r, 0, size); - self.g.queue.submit([e.finish()]); - r.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely())?; - let v = r.slice(..).get_mapped_range()?.to_vec(); - Ok(v) - }; - let half = |b: u32, hi: bool| half::f16::from_bits(if hi { (b >> 16) as u16 } else { b as u16 }).to_f32(); - let composed = read(&t.fb.buffer, t.fb.offset, tn * 16)?; - let spec = read(&t.spec_pp.buffer, t.spec_pp.offset + n * 8, tn * 8)?; // spec_pp slot 1: (rgb, history) as two pairs of f16 - let samples = read(&self.sc.up.accum, 0, tn * 80)?; // the kernel's samples (live.wgsl `Pixel`): emit at 48 - let (composed, spec, samples): (&[[f32; 4]], &[[u32; 2]], &[[f32; 20]]) = (bytemuck::cast_slice(&composed), bytemuck::cast_slice(&spec), bytemuck::cast_slice(&samples)); - let (mut dif, mut spe, mut emi) = (Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3)); - let finite = |v: f32| if v.is_finite() { v } else { 0.0 }; - for y in 0..h { - for x in 0..w { - // live.wgsl `sample_under`. - let q = (((y as f32 + 0.5) * ih as f32 / h as f32) as usize).min(ih - 1) * iw + (((x as f32 + 0.5) * iw as f32 / w as f32) as usize).min(iw - 1); - // The media before the surface (live.wgsl `medium_tr`): the specular as the frame shows it. - let tr = (samples[q][11].fract() * 2.0).clamp(0.0, 1.0); - let s = [half(spec[q][0], false), half(spec[q][0], true), half(spec[q][1], false)].map(|v| v * tr); - for c in 0..3 { - let e = finite(samples[q][12 + c] / self.shown_spp as f32); - spe.push(s[c]); - emi.push(e); - dif.push((composed[q][c] - s[c] - e).max(0.0)); - } - } - } - Ok([dif, spe, emi]) - } - - #[cfg(not(target_arch = "wasm32"))] - /// The exposure the last frame was shown at, EV100 (auto exposure: its adapted value). - pub fn read_ev100(&self) -> Result { - if self.sc.exposure_settings.ae2[3] <= 0.5 { - return Ok(-self.sc.exposure.log2()); - } - let d = &self.g.device; - let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("ae read"), size: 16, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(&self.targets.ae_state.buffer, self.targets.ae_state.offset, &read, 0, 16); - self.g.queue.submit([e.finish()]); - read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely())?; - let s = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap())[0]; - Ok(-s.max(1e-30).log2()) - } - - /// Phases of the last scene (re)load, ms. - pub fn load_timings(&self) -> &[(String, f64)] { - &self.sc.timings - } - - pub fn aspect(&self) -> f32 { - self.aspect - } - - /// Primary ray through (u, v) of the image (0..1, v down), as the path tracer generates it. - pub fn ray(&self, u: f32, v: f32) -> (Vec3, Vec3) { - let c = &self.cam; - let d = c.fwd + c.right * ((u * 2.0 - 1.0) * c.tan_half_fov * self.aspect) + c.up * ((1.0 - v * 2.0) * c.tan_half_fov); - (c.pos, d.normalize()) - } - - /// Closest surface under (u, v): (node id, world-space hit point). CPU ray cast over the scene triangles, and over - /// each instance's model-space mesh with the ray in its space (same ray parameter t). - /// The object under view point (u, v): its name, the hit point, and for an instanced object the instance's index. - pub fn pick(&self, u: f32, v: f32) -> Option<(String, Vec3, Option)> { - let (o, d) = self.ray(u, v); - let (o, d) = (glam::Vec3A::from(o), glam::Vec3A::from(d)); - // Moller-Trumbore, two-sided: the ray parameter of the closest hit in `tris` beyond `near`. - let closest = |tris: &[[glam::Vec3A; 3]], o: glam::Vec3A, d: glam::Vec3A| { - let mut best = (f32::INFINITY, usize::MAX); - for (i, [a, b, c]) in tris.iter().enumerate() { - let (e1, e2) = (*b - *a, *c - *a); - let p = d.cross(e2); - let det = e1.dot(p); - if det.abs() < 1e-12 { - continue; - } - let inv = 1.0 / det; - let s = o - *a; - let bu = s.dot(p) * inv; - if !(0.0..=1.0).contains(&bu) { - continue; - } - let q = s.cross(e1); - let bv = d.dot(q) * inv; - if bv < 0.0 || bu + bv > 1.0 { - continue; - } - let t = e2.dot(q) * inv; - if t > 1e-4 && t < best.0 { - best = (t, i); - } - } - best - }; - let (mut t, i) = closest(&self.sc.tris, o, d); - let mut node = self.sc.tri_nodes.iter().find(|(_, a, b)| (*a as usize..*b as usize).contains(&i)).map(|n| n.0.as_str()); - let mut sub = self.sc.tri_instances.iter().find(|(a, b, _)| (*a as usize..*b as usize).contains(&i)).map(|r| r.2); - let added = |want_mesh: bool| self.sc.edit.added.values().filter_map(move |p| match p { - Picked::Mesh(i) if want_mesh => Some(Ok(i)), - Picked::Proc(q) if !want_mesh => Some(Err(q)), - _ => None, - }); - for inst in self.sc.instances.iter().filter(|i| !i.hidden).chain(added(true).filter_map(|x| x.ok())) { - let m = self.sc.meshes[inst.mesh as usize]; - let inv = inst.world.inverse(); - let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); - // Slab test against the mesh bounds before its triangles. - let (t0, t1) = ((m.lo - lo) / ld, (m.hi - lo) / ld); - let (near, far) = (t0.min(t1).max_element(), t0.max(t1).min_element()); - if far < near.max(0.0) || near > t { - continue; - } - let (ti, _) = closest(&self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize], lo, ld); - if ti < t { - t = ti; - node = Some(inst.name.as_str()); - sub = inst.sub; - } - } - // Procedural geometry: its boxes (the geometry function runs on the GPU only). - for p in self.sc.procs.iter().filter(|p| !p.hidden).chain(added(false).filter_map(|x| x.err())) { - let inv = p.world.inverse(); - let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); - let slab = |a: glam::Vec3A, b: glam::Vec3A| { - let (t0, t1) = ((a - lo) / ld, (b - lo) / ld); - let (near, far) = (t0.min(t1).max_element().max(1e-4), t0.max(t1).min_element()); - (near <= far).then_some(near) - }; - if slab(p.lo.into(), p.hi.into()).is_none_or(|near| near > t) { - continue; - } - for b in p.blob.boxes.chunks(6) { - if let Some(near) = slab(glam::Vec3A::new(b[0], b[1], b[2]), glam::Vec3A::new(b[3], b[4], b[5])).filter(|&near| near < t) { - t = near; - node = Some(p.name.as_str()); - sub = p.sub; - } - } - } - Some((node?.to_string(), Vec3::from(o + d * t), sub)) - } - - /// World-space triangles of a node (empty for lights without geometry, cameras, hidden nodes). - pub fn node_tris(&self, id: &str) -> Vec<[glam::Vec3A; 3]> { - // World-space triangles (an instance's glass / emissive part among them), then its instanced mesh. - let mut out: Vec<[glam::Vec3A; 3]> = scene::node_ranges(&self.sc.tri_nodes, id).flat_map(|r| self.sc.tris[r].iter().copied()).collect(); - // A model's first instance; every drawn instance of an instanced object. - let mut of_node = self.sc.instances.iter().filter(|i| i.name == id && !i.hidden); - let first = of_node.next(); - for inst in first.into_iter().chain(of_node.filter(|i| i.sub.is_some())) { - let m = self.sc.meshes[inst.mesh as usize]; - out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); - } - // Objects added in place (Bugbot on #131: `pick` saw them, this did not). - for p in self.sc.edit.added.values() { - if let Picked::Mesh(inst) = p { - if inst.name != id { - continue; - } - let m = self.sc.meshes[inst.mesh as usize]; - out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); - } - } - out - } - - /// Frame metadata (camera pose for picking, convergence, timings). - pub fn meta(&self) -> serde_json::Value { - let c = &self.cam; - serde_json::json!({ - "seq": self.seq, "frame": self.frame, "history": self.history_len, "gpu_ms": self.gpu_ms, - "scene": self.scene_name, "triangles": self.triangles, "bounces": self.bounces, "mode": self.view_mode, "width": self.w, "height": self.h, "scale": self.render_scale, - "cam": { "pos": c.pos.to_array(), "fwd": c.fwd.to_array(), "up": c.up.to_array(), "right": c.right.to_array(), "tan": c.tan_half_fov, "aspect": self.aspect }, - }) - } -} - -/// Adaptive samples per frame: fill ~75% of the frame budget with path tracing. -pub fn next_spp(spp: u32, gpu_ms: f32, fps: u32) -> u32 { - if gpu_ms <= 0.0 { - return spp; - } - let budget = 750.0 / fps as f32; - let per_sample = gpu_ms / spp as f32; - let target = (budget / per_sample).floor() as u32; - target.clamp(1, 16).min(spp + 1).max(spp.saturating_sub(2).max(1)) -} - -#[cfg(not(target_arch = "wasm32"))] -/// Headless runner: renders at a fixed frame rate and saves snapshots on request (tools/bench-live.ts). -pub fn run(presets: &Path, opts: LiveOpts) -> Result<()> { - let (w, h) = (opts.width, opts.height); - let (cmd_tx, cmd_rx) = mpsc::channel::(); - spawn_stdin_reader(cmd_tx); - let (mut game, mut core) = crate::script::GameSession::start(&opts.bundle, &opts.room, &opts.assets, gpu::Gpu::new()?, presets, w, h)?; - // The host channel: this runner is the app. How the game was opened, once after setup; what the game posts goes - // out on stdout (`emit_posts`), and whoever drives the runner answers with `{"cmd":"host"}`. - if let Err(e) = game.host_messages(&mut core, &[crate::host::launch_message(opts.launch.as_ref())]) { - eprintln!("live: launch: {e:#}"); - } - emit_posts(&mut game); - // Frame-script cost (script + commit), reported every 2 s. - let (mut fs_sum, mut fs_max, mut fs_n) = (0.0f32, 0.0f32, 0u32); - core.bounces = opts.bounces; - core.set_display_scale(opts.display_scale); - if let Some(s) = opts.scale { - core.set_scale(s); - } - eprintln!("live: load {}", serde_json::Value::Object(core.load_timings().iter().map(|(k, v)| (k.clone(), (*v).into())).collect())); - eprintln!("live: {}x{} @{} fps on {} ({})", w, h, opts.fps, core.g.adapter_info.name, if core.g.hwrt { "HW RT" } else { "BVH" }); - let staging = core.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("snapshot"), size: (w * h * 4) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let frame_dt = Duration::from_secs_f64(1.0 / opts.fps as f64); - let mut next = Instant::now(); - let mut snapshots: Vec<(PathBuf, u32, u32)> = vec![]; // path, min history, first eligible frame - let mut syncs: Vec<(String, u32)> = vec![]; // id, frame after which to answer - let mut spp = 1u32; - let mut last = Instant::now(); - let start = Instant::now(); - let mut log = opts.frame_log.as_ref().map(|p| std::fs::File::create(p).map(std::io::BufWriter::new)).transpose()?; - let mut last_frame = Instant::now(); - let mut prev_start = Instant::now(); - let mut lockstep: Option = None; - let mut budget = 0u32; // lockstep: frames the commands so far asked for and not yet rendered - loop { - let t_frame = Instant::now(); - let mut cmds: Vec = vec![]; - if lockstep.is_some() && budget == 0 { - match cmd_rx.recv() { - Ok(c) => cmds.push(c), - Err(_) => return Ok(()), - } - } - cmds.extend(std::iter::from_fn(|| cmd_rx.try_recv().ok())); - for c in cmds { - match c { - Cmd::Camera(c) => core.set_camera(c), - Cmd::Lockstep(dt) => { - lockstep = dt; - core.fixed_dt = dt; - budget = 0; - } - Cmd::Step { cam, frames, sync, snapshot } => { - if let Some(c) = cam { - core.set_camera(c); - } - let n = frames.max(1); - if lockstep.is_some() { - budget += n; - } - let last = core.frame + n - 1; - if let Some(p) = snapshot { - snapshots.push((p, 0, last)); - } - if let Some(id) = sync { - syncs.push((id, last)); - } - } - Cmd::Snapshot(path, after, frames) => snapshots.push((path, after.unwrap_or(0), core.frame + frames.unwrap_or(1).max(1) - 1)), - Cmd::Sync(id, frames) => syncs.push((id, core.frame + frames.max(1) - 1)), - Cmd::Reset => core.reset_history(), - Cmd::Time(t) => core.set_time(t), - Cmd::Mode(m) => core.view_mode = m, - Cmd::Bounces(b) => core.bounces = b, - Cmd::Scale(s) => core.set_scale(s), - Cmd::Event(kind, payload) => { - if let Err(e) = game.event(&mut core, &kind, payload) { - eprintln!("live: event {kind} failed: {e:#}"); - } - } - Cmd::Click(u, v) => { - if let Err(e) = game.click(&mut core, u, v) { - eprintln!("live: click failed: {e:#}"); - } - } - Cmd::Host(messages) => { - if let Err(e) = game.host_messages(&mut core, &messages) { - eprintln!("live: host messages failed: {e:#}"); - } - } - Cmd::SppCap(_) | Cmd::Status(_) => {} - Cmd::Quit => return Ok(()), - } - } - // The frame script runs on wall-clock time (frames slower than the target rate still animate at real speed). - if lockstep.is_some() && budget == 0 { - emit_posts(&mut game); - continue; // commands without frames (events, switches): wait for a step - } - let dt = if freeze_time() { 0.0 } else { lockstep.unwrap_or_else(|| last.elapsed().as_secs_f32().min(0.1)) }; - last = Instant::now(); - core.advance_time(dt); - if let Err(e) = game.frame(&mut core, dt) { - eprintln!("live: frame script: {e:#}"); - } - emit_posts(&mut game); - if game.host.has_frame() { - (fs_sum, fs_max, fs_n) = (fs_sum + game.frame_ms, fs_max.max(game.frame_ms), fs_n + 1); - if fs_n == opts.fps * 2 { - eprintln!("live: frame script {:.3} ms avg, {:.3} ms max (script + commit)", fs_sum / fs_n as f32, fs_max); - (fs_sum, fs_max, fs_n) = (0.0, 0.0, 0); - } - } - // Snapshot metadata is this frame's (history / spp at render time), so copy the output whenever one is pending. - let pending = !snapshots.is_empty(); - core.render(spp, |e, out| { - if pending { - e.copy_buffer_to_buffer(out, 0, &staging, 0, (w * h * 4) as u64); - } - }); - game.click_shown(); - let (hist, fno) = (core.history_len as u32, core.frame); - syncs.retain(|(id, from)| { - if fno > *from { - eprintln!("live: sync {id}"); - } - fno <= *from - }); - if snapshots.iter().any(|(_, after, from)| fno > *from && hist >= *after) { - staging.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - core.g.device.poll(wgpu::PollType::wait_indefinitely())?; - let px = staging.slice(..).get_mapped_range()?.to_vec(); - staging.unmap(); - let mut hdr: Option> = None; - snapshots.retain(|(path, after, from)| { - if fno > *from && hist >= *after { - if path.extension().is_some_and(|e| e == "signals") { - // The signals apart (`read_signals`), as `render --signals` writes them. - if let Ok([d, s, e]) = core.read_signals() { - let stem = path.with_extension(""); - for (name, v) in [("diffuse", d), ("specular", s), ("emission", e)] { - let _ = std::fs::write(format!("{}-{name}.raw", stem.display()), bytemuck::cast_slice(&v)); - } - } - } else if path.extension().is_some_and(|e| e == "raw") { - // Linear radiance (RGB f32, output size), as `serve` live jobs write it. - if hdr.is_none() { - hdr = core.read_hdr().ok(); - } - let _ = std::fs::write(path, bytemuck::cast_slice(hdr.as_deref().unwrap_or(&[]))); - } else { - save_bgra(path, &px, w, h); - } - eprintln!("live: snapshot {} at history {hist} ({spp} spp/frame, GPU {:.2} ms, scale {:.2})", path.display(), core.gpu_ms, core.render_scale); - false - } else { - true - } - }); - } - if let Some(l) = &mut log { - use std::io::Write; - let line = serde_json::json!({ - "t": start.elapsed().as_secs_f64() * 1e3, "interval": last_frame.elapsed().as_secs_f64() * 1e3, "start": t_frame.duration_since(prev_start).as_secs_f64() * 1e3, - "cpu": t_frame.elapsed().as_secs_f64() * 1e3, "script": game.frame_ms, "js": game.js_ms, "commit": game.commit_ms, "skin": core.cpu_skin_ms, "upload": core.cpu_upload_ms, "blas": core.gpu_blas_ms, "wait": core.cpu_wait_ms, "gpu": core.gpu_ms, "trace": core.gpu_trace_ms, "edit": core.edit_ms, "instanced": core.instanced_ms, "edit_split": [core.edit_split.tris, core.edit_split.upload, core.edit_split.blas, core.edit_split.added, core.edit_split.deformed, core.edit_split.objects], "rebuild": core.rebuild_ms, "fallback": core.fallback, "fallbacks": core.fallbacks, "room": core.room_stats(), "top": core.top_stats(), "voxel": std::mem::take(&mut game.host.voxels.borrow_mut().ticks), "physics": game.host.physics.borrow_mut().as_mut().map(|p| { let s = p.take_stats(); serde_json::json!({ "ticks": s.ticks, "step": s.step_ms, "sync": s.sync_ms, "bodies": s.bodies, "moved": s.moved, "kicks": s.kicks }) }), "scale": core.render_scale, "history": core.history_len, "guard": core.guard_count(), - }); - writeln!(l, "{line}")?; - } - // (Summed over the frame's commits, a game event's and the frame script's: reset once logged.) - (core.edit_ms, core.instanced_ms, core.edit_split, core.rebuild_ms, core.fallback) = (0.0, 0.0, EditSplit::default(), 0.0, None); - prev_start = t_frame; - last_frame = Instant::now(); - core.govern_resolution(opts.fps); - spp = if core.render_scale < 1.0 || lockstep.is_some() { 1 } else { next_spp(spp, core.gpu_ms, opts.fps) }; - let _ = core.g.device.poll(wgpu::PollType::Poll); - if lockstep.is_some() { - budget -= 1; - continue; // no pacing: frames as fast as the GPU renders them - } - // Pace to the frame rate; when the GPU is the bottleneck the two-frames-in-flight limit paces instead (sleeping - // after a frame that did not wait would alternate long and short frames). - next += frame_dt; - let now = Instant::now(); - if next > now && core.gpu_ms < frame_dt.as_secs_f32() * 1000.0 * 0.95 { - sleep_until(next); - } else { - next = now; - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -/// What the game posted on the host channel since the last call, as one line on stdout: `{"host":[{"t":...}, ...]}` -/// (nothing when it posted nothing). -fn emit_posts(game: &mut crate::script::GameSession) { - use std::io::Write; - let posts = game.take_posts(); - if !posts.is_empty() { - let mut out = std::io::stdout().lock(); - let _ = writeln!(out, "{}", serde_json::json!({ "host": posts })); - let _ = out.flush(); - } -} - -#[cfg(not(target_arch = "wasm32"))] -/// Sleeps until `t` without trusting the timer's precision: macOS gives a process that is not a foreground app timer -/// leeway of half the interval (a 16 ms sleep returned after 24, measured from a terminal-launched viewport and from -/// plain Python alike). Pacing sleeps of the headless viewport overslept by ~5 ms once the GPU fit the frame: Bistro's -/// camera path read frame interval p95 21.9 ms at GPU p95 16.0. Sleep 60 % of what is left, again, and yield through -/// the last 1.5 ms. -fn sleep_until(t: Instant) { - loop { - let now = Instant::now(); - if now >= t { - return; - } - let rem = t - now; - if rem > Duration::from_micros(1500) { - std::thread::sleep(rem.mul_f64(0.6)); - } else { - std::thread::yield_now(); - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn save_bgra(path: &Path, px: &[u8], w: u32, h: u32) { - let rgba: Vec = px.chunks(4).flat_map(|p| [p[2], p[1], p[0], 255]).collect(); - if let Err(e) = image::save_buffer(path, &rgba, w, h, image::ColorType::Rgba8) { - eprintln!("live: snapshot failed: {e}"); - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn spawn_stdin_reader(cmd_tx: mpsc::Sender) { - std::thread::spawn(move || { - for line in std::io::stdin().lock().lines() { - let Ok(line) = line else { break }; - let Some(cmd) = parse_cmd(&line) else { continue }; - if cmd_tx.send(cmd).is_err() { - break; - } - } - let _ = cmd_tx.send(Cmd::Quit); - }); -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn parse_cmd(line: &str) -> Option { - let v: serde_json::Value = serde_json::from_str(line).ok()?; - let vec = |k: &str| v[k].as_array().map(|a| Vec3::new(a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32)); - Some(match v["cmd"].as_str()? { - "camera" if v.get("sync").is_some() || v.get("snapshot").is_some() || v.get("frames").is_some() => Cmd::Step { - cam: Some(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), - frames: v["frames"].as_u64().unwrap_or(1) as u32, - sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), - snapshot: v["snapshot"].as_str().map(PathBuf::from), - }, - "camera" => Cmd::Camera(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), - "frames" => Cmd::Step { - cam: None, - frames: v["n"].as_u64().unwrap_or(1) as u32, - sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), - snapshot: v["snapshot"].as_str().map(PathBuf::from), - }, - "lockstep" => Cmd::Lockstep(if v["on"].as_bool().unwrap_or(true) { Some(v["dt"].as_f64().unwrap_or(1.0 / 60.0) as f32) } else { None }), - "event" => Cmd::Event(v["type"].as_str()?.to_string(), if v["payload"].is_null() { serde_json::json!({}) } else { v["payload"].clone() }), - "click" => Cmd::Click(v["u"].as_f64()? as f32, v["v"].as_f64()? as f32), - "host" => Cmd::Host(v["messages"].as_array()?.clone()), - "snapshot" => Cmd::Snapshot(PathBuf::from(v["path"].as_str().unwrap_or("snapshot.png")), v["after"].as_u64().map(|a| a as u32), v["frames"].as_u64().map(|a| a as u32)), - "reset" => Cmd::Reset, - "time" => Cmd::Time(v["t"].as_f64()? as f32), - "mode" => Cmd::Mode(v["mode"].as_u64()? as u32), - "bounces" => Cmd::Bounces(v["bounces"].as_u64()?.clamp(1, 16) as u32), - "spp" => Cmd::SppCap(v["max"].as_u64()?.clamp(1, 16) as u32), - "scale" => Cmd::Scale(v["scale"].as_f64()? as f32), - "sync" => Cmd::Sync(v["id"].as_str().map(str::to_string).unwrap_or_else(|| v["id"].to_string()), v["frames"].as_u64().unwrap_or(1) as u32), - "quit" => Cmd::Quit, - _ => return None, - }) -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn parse_opts(args: &[String]) -> Result { - let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); - let num = |name: &str, d: u32| -> Result { flag(name).map(|v| v.parse().map_err(|_| anyhow!("{name} must be a number"))).transpose().map(|v| v.unwrap_or(d)) }; - Ok(LiveOpts { - width: num("--width", 1280)?, - height: num("--height", 720)?, - fps: num("--fps", 60)?, - bounces: num("--bounces", 4)?, - // (Without `--scale`: full resolution, or dynamic resolution on a display's physical pixels.) - scale: parse_scale(flag("--scale").as_deref(), if flag("--display-scale").is_some() { None } else { Some(1.0) })?, - bundle: PathBuf::from(args.get(2).filter(|a| !a.starts_with("--")).ok_or_else(|| anyhow!("game bundle required"))?), - assets: PathBuf::from(flag("--assets").unwrap_or_else(|| ".".into())), - room: flag("--room").unwrap_or_else(|| "local".into()), - frame_log: flag("--frame-log").map(PathBuf::from), - display_scale: flag("--display-scale").map(|v| v.parse::()).transpose()?.unwrap_or(1.0), - launch: parse_launch(flag("--launch").as_deref())?, - }) -} - -#[cfg(not(target_arch = "wasm32"))] -/// `--launch '{"room":"K7Q4MZ","name":"Ada"}'`: the `params` of the host channel's `launch` message. -pub fn parse_launch(v: Option<&str>) -> Result> { - v.map(|t| serde_json::from_str::(t).ok().filter(|p| p.is_object()).ok_or_else(|| anyhow!("--launch must be a JSON object, as in --launch '{{\"room\":\"K7Q4MZ\"}}'"))).transpose() -} - -#[cfg(not(target_arch = "wasm32"))] -/// `--scale`: a fraction (0.25 .. 1) or `auto` (dynamic resolution). -pub fn parse_scale(v: Option<&str>, default: Option) -> Result> { - match v { - None => Ok(default), - Some("auto") => Ok(None), - Some(v) => v.parse::().map(|s| Some(s.clamp(SCALE_FLOOR, 1.0))).map_err(|_| anyhow!("--scale must be a number or auto")), - } -} - -#[cfg(test)] -mod timer_tests { - use super::*; - - #[test] - fn a_view_in_physical_pixels_starts_with_dynamic_resolution() { - let opts = |extra: &[&str]| parse_opts(&["threert-render", "live", "game.js"].iter().chain(extra).map(|a| a.to_string()).collect::>()).unwrap(); - assert_eq!(opts(&[]).scale, Some(1.0)); - assert_eq!(opts(&["--display-scale", "2"]).scale, None); - assert_eq!(opts(&["--display-scale", "2", "--scale", "0.2"]).scale, Some(0.2)); - assert_eq!(opts(&["--scale", "auto"]).scale, None); - } - - #[test] - fn a_post_pass_from_another_frame_counts_by_its_own_length() { - // Two frames of the crowd scene (ns, period 1): frame 453 as read, and frame 454, whose slot read frame 458's - // post pair (74.5 ms after its trace began). - let t453 = [35929577531333, 35929585495833, 35929585700583, 35929591610875, 0, 0, 0, 0]; - let t454 = [35929593765500, 35929601614666, 35929668312875, 35929673973041, 0, 0, 0, 0]; - let ms = |t: [u64; 8]| frame_gpu_ms(&t, 1, 1.0).unwrap(); - assert_eq!(ms(t453), (t453[3] - t453[0]) as f32 * 1e-6); - assert!((ms(t454) - (7.849166 + 5.660166)).abs() < 1e-3, "{}", ms(t454)); - // The first pass is the frame's start; nothing timed, no measurement. - assert_eq!(frame_gpu_ms(&[0, 0, 0, 0, 0, 0, 0, 0], 1, 1.0), None); - let particles_first = [2_000_000, 9_000_000, 9_200_000, 14_000_000, 0, 0, 1_000_000, 1_900_000]; - assert_eq!(frame_gpu_ms(&particles_first, 1, 1.0), Some(13.0)); - // 3 samples: two more traces (untimed) between the first and the post pass, a 16.2 ms gap: the span. The post - // pair of frame 458 under 3 samples: still out of place (trace 7.85 ms, gap 66.7 ms), counted as the passes. - let three = [1_000_000, 9_000_000, 25_200_000, 31_000_000, 0, 0, 0, 0]; - assert_eq!(frame_gpu_ms(&three, 3, 1.0), Some(30.0)); - assert!((frame_gpu_ms(&t454, 3, 1.0).unwrap() - (3.0 * 7.849166 + 5.660166)).abs() < 1e-3); - } -} - -#[cfg(test)] -mod instanced_tests { - use super::*; - use crate::world::{Geometry, Kind}; - use glam::Mat4; - - /// Clicks pick what is drawn (ROADMAP § 6 step 5): an object added in place is picked, one removed in place no - /// longer is, and a rebuild agrees. (Skipped without a GPU with hardware RT.) - #[test] - fn picks_follow_in_place_edits() { - let g = match gpu::Gpu::new() { - Ok(g) if g.hwrt => g, - other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), - }; - let mut w = World::default(); - w.set_material(1, serde_json::from_value(serde_json::json!({ "name": "m", "color": [0.5, 0.5, 0.5], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap()); - let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; - w.set_geometry(2, quad(1.0)).unwrap(); - w.set_geometry(3, quad(0.5)).unwrap(); - let mesh = |geometry| Kind::Mesh { geometry, material: 1, own_blas: true, materials: vec![] }; - w.set_object(10, "a".into(), None, mesh(2), true, false).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); - w.take_changes(); - let picked = |core: &LiveCore| core.pick(0.5, 0.5).map(|p| p.0); - assert_eq!(picked(&core).as_deref(), Some("a")); - // Added in place, in front of it: picked. - w.set_object(11, "b".into(), None, mesh(3), true, false).unwrap(); - w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "added in place"); - assert_eq!(picked(&core).as_deref(), Some("b")); - assert_eq!(core.node_tris("b").len(), 2, "its triangles under its name"); - // Removed in place: the one behind it is picked again; both removed: nothing. - w.remove(11); - assert!(!core.commit(&mut w).unwrap(), "removed in place"); - assert_eq!(picked(&core).as_deref(), Some("a")); - assert!(core.node_tris("b").is_empty(), "removed: none"); - w.remove(10); - assert!(!core.commit(&mut w).unwrap(), "removed in place"); - assert_eq!(picked(&core), None); - core.rebuild(&w).unwrap(); - assert_eq!(picked(&core), None, "a rebuild agrees"); - } - - /// A mesh a rigid body drives, added while the game runs with a material the scene did not have (ROADMAP § 6 step - /// 7): placed in place, and its body's moves are its instance's transform, with no rebuild; a rebuild places it the - /// same and it goes on moving in place. (Skipped without a GPU with hardware RT.) - #[test] - fn driven_meshes_added_in_place_move_as_instances() { - let g = match gpu::Gpu::new() { - Ok(g) if g.hwrt => g, - other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), - }; - let mut w = World::default(); - let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap(); - w.set_material(1, mat(0.5)); - let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; - w.set_geometry(2, quad(1.0)).unwrap(); - w.set_geometry(3, quad(0.5)).unwrap(); - let mesh = |geometry, material| Kind::Mesh { geometry, material, own_blas: true, materials: vec![] }; - w.set_object(10, "a".into(), None, mesh(2, 1), true, false).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); - w.take_changes(); - let picked = |core: &LiveCore, u: f32| core.pick(u, 0.5).map(|p| p.0); - // The box and its material arrive together, as scene code adds them; its body is told of it right after. - let materials = core.sc.tables.materials.len(); - w.set_material(5, mat(0.9)); - w.set_object(11, "b".into(), None, mesh(3, 5), true, false).unwrap(); - w.set_driven(11).unwrap(); - w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "added in place, its material too"); - assert_eq!(core.sc.tables.materials.len(), materials + 1); - assert_eq!(picked(&core, 0.5).as_deref(), Some("b")); - // Its body moves it 3 m to the side (u 0.95 of the image, 4 m from the camera): no rebuild, its record moved - // with the rows it had the frame before. - let record = core.sc.edit.placed[&11].1; - assert!(w.place_driven(11, Vec3::new(3.0, 0.0, 1.0), glam::Quat::IDENTITY)); - assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); - assert_eq!(core.fallbacks, 0); - assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); - let r = core.sc.gpu_instances[record]; - assert_eq!((r.m[0][3], r.p[0][3]), (3.0, 0.0), "moved: last frame's rows kept"); - // A rebuild places it as an instance too, and it moves on in place. - core.rebuild(&w).unwrap(); - assert!(core.sc.tables.retained.instance_of.contains_key(&11) && core.sc.edit.placed.is_empty()); - assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); - assert!(w.place_driven(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY)); - assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); - assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95)), (Some("b"), None)); - // Removed in place. - w.remove(11); - assert!(!core.commit(&mut w).unwrap(), "removed in place"); - assert_eq!(picked(&core, 0.5).as_deref(), Some("a")); - } - - /// An instanced mesh's moved instances keep their previous-frame rows (motion vectors) through the commit that moves - /// them, and catch up at the next commit that does not move them. (Skipped without a GPU with hardware RT.) - #[test] - fn moved_instances_keep_their_motion() { - let g = match gpu::Gpu::new() { - Ok(g) if g.hwrt => g, - other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), - }; - let mut w = World::default(); - let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": false })).unwrap(); - w.set_material(1, mat(0.5)); - w.set_geometry(2, Geometry { positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(3), uvs: vec![0.0; 6], indices: vec![0, 1, 2], groups: vec![], colors: vec![] }).unwrap(); - w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, false).unwrap(); - let at = |x: f32| [Mat4::IDENTITY.to_cols_array(), Mat4::from_translation(Vec3::new(x, 0.0, 0.0)).to_cols_array()].concat(); - w.set_instances(3, 2, Some(at(1.0)), None, None).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); - w.take_changes(); - let first = core.sc.tables.retained.instanced[&3].first as usize; - w.set_instances(3, 2, Some(at(2.0)), None, None).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "in place"); - let r = core.sc.gpu_instances[first + 1]; - assert_eq!((r.m[0][3], r.p[0][3]), (2.0, 1.0), "moved: last frame's rows kept"); - w.set_material(1, mat(0.6)); - core.commit(&mut w).unwrap(); - let r = core.sc.gpu_instances[first + 1]; - assert_eq!(r.p, r.m, "not moved since: caught up"); - // Movable, moved and given new instances in one commit: updated once (twice would lose the motion). - w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, true).unwrap(); - assert!(core.commit(&mut w).unwrap(), "made movable: rebuilt"); - let first = core.sc.tables.retained.instanced[&3].first as usize; - w.set_transform(3, Vec3::new(0.0, 1.0, 0.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_instances(3, 2, Some(at(3.0)), None, None).unwrap(); - let c = w.changes.clone(); - assert!(c.moved.contains(&3) && c.instanced.contains(&3), "{c:?}"); - assert!(!core.commit(&mut w).unwrap(), "in place"); - let r = core.sc.gpu_instances[first + 1]; - assert_eq!((r.m[0][3], r.m[1][3], r.p[0][3], r.p[1][3]), (3.0, 1.0, 2.0, 0.0), "moved: last frame's rows kept"); - } - - /// The uniform tables (`gpu::GpuOptions::uniform_tables`: what a device is opened with on Android), on this - /// machine's GPU: a lit scene with an instanced mesh renders offline to the default device's image, bit for bit; - /// live, the four uniform buffers hold what their tables hold when a frame is traced, and after an edit in place - /// what the tables hold then. (Skipped without a GPU.) - #[test] - fn the_uniform_tables_render_what_the_tables_do() { - let open = |uniform: bool| gpu::Gpu::new_with(gpu::GpuOptions { uniform_tables: Some(uniform), ..Default::default() }); - let (plain, uniform) = match (open(false), open(true)) { - (Ok(a), Ok(b)) => (a, b), - (Err(e), _) | (_, Err(e)) => return eprintln!("skipped: {e}"), - }; - if uniform.merged { - return eprintln!("skipped: the merged layout has no uniform tables"); - } - assert!(uniform.uniform_tables && !plain.uniform_tables); - let mut w = World::default(); - let mat = |color: f32, emissive: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [color, color * 0.5, color], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [1, 1, 1], "emissiveIntensity": emissive, "doubleSided": true })).unwrap(); - w.set_material(1, mat(0.8, 0.0)); - w.set_material(5, mat(0.0, 40.0)); - let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: [0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; - w.set_geometry(2, quad(1.0)).unwrap(); - w.set_geometry(3, quad(0.25)).unwrap(); - let mesh = |geometry, material, own_blas| Kind::Mesh { geometry, material, own_blas, materials: vec![] }; - // A wall, a small movable panel in front of it, and an emissive quad that lights both from the side. - w.set_object(10, "wall".into(), None, mesh(2, 1, false), true, false).unwrap(); - w.set_object(11, "panel".into(), None, mesh(3, 1, true), true, true).unwrap(); - w.set_transform(11, Vec3::new(-0.4, 0.0, 0.5), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_object(12, "lamp".into(), None, mesh(3, 5, false), true, false).unwrap(); - w.set_transform(12, Vec3::new(0.6, 0.3, 1.5), glam::Quat::from_rotation_y(2.6), Vec3::ONE).unwrap(); - let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); - w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); - w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); - let presets = crate::default_presets_dir(); - - // Offline: the same bytes. - let offline = |g: &gpu::Gpu| { - let mut sc = crate::scene::build(&w, Path::new("."), &presets, None).unwrap(); - (sc.width, sc.height) = (64, 36); - let prep = crate::prepare(g, &mut sc, &mut vec![]); - let frame = g.render(&sc, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, prep.params, 16, &mut vec![]).unwrap(); - frame.color.iter().flatten().map(|v| v.to_bits()).collect::>() - }; - let (a, b) = (offline(&plain), offline(&uniform)); - let lit = a.iter().filter(|v| f32::from_bits(**v) > 0.0).count(); - assert!(lit > a.len() / 16, "the scene is lit ({lit} of {} numbers)", a.len()); - let differ = a.iter().zip(&b).filter(|(x, y)| x != y).count(); - assert_eq!(differ, 0, "{differ} of {} numbers differ between the storage tables and the uniform ones", a.len()); - drop(plain); - - // Live: the uniform buffers follow the tables. - let mut core = LiveCore::new(uniform, &w, Path::new("."), &presets, 64, 36).unwrap(); - w.take_changes(); - let read = |core: &LiveCore, buf: &wgpu::Buffer, bytes: u64| -> Vec { - let d = &core.g.device; - let to = d.create_buffer(&wgpu::BufferDescriptor { label: None, size: bytes, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); - let mut e = d.create_command_encoder(&Default::default()); - e.copy_buffer_to_buffer(buf, 0, &to, 0, bytes); - core.g.queue.submit([e.finish()]); - to.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - d.poll(wgpu::PollType::wait_indefinitely()).unwrap(); - let bytes = to.slice(..).get_mapped_range().unwrap().to_vec(); - bytes - }; - // Each table's bytes and its uniform copy's, over what both hold. - let pairs = |core: &LiveCore| -> Vec<(Vec, Vec)> { - let up = &core.sc.up; - let copies = up.uniform.as_ref().expect("the uniform tables' buffers"); - [&up.bg_parts.texinfo, &up.objects, &up.instances, &up.media].into_iter().zip(copies).map(|(t, c)| { - let n = t.size().min(c.size()) & !3; - (read(core, t, n), read(core, c, n)) - }).collect() - }; - core.render(1, |_, _| {}); - let first = pairs(&core); - for (k, (table, copy)) in first.iter().enumerate() { - assert!(!table.is_empty() && table == copy, "table {k} and its uniform buffer differ after a frame"); - } - assert!(first[1].0.iter().any(|b| *b != 0), "the objects' table has the scene's objects"); - // The panel moves in place: its object's entry and its instance's record change, and the next frame's - // uniform buffers have them. - w.set_transform(11, Vec3::new(0.3, 0.2, 0.75), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); - assert!(!core.commit(&mut w).unwrap(), "moved in place"); - core.render(1, |_, _| {}); - let moved = pairs(&core); - for (k, (table, copy)) in moved.iter().enumerate() { - assert!(table == copy, "table {k} and its uniform buffer differ after an edit"); - } - assert!(moved[1].1 != first[1].1 || moved[2].1 != first[2].1, "the move reached the uniform buffers"); - } -} diff --git a/renderer/src/live/edits.rs b/renderer/src/live/edits.rs new file mode 100644 index 0000000..8fbf505 --- /dev/null +++ b/renderer/src/live/edits.rs @@ -0,0 +1,2099 @@ +//! The world's edits applied in place (`LiveCore::commit` and what it calls): moved instances and bodies, posed +//! skins, emitters, the sun and the sky, and geometry added, removed or changed (`edit_geometry`, `EditState`). + +use super::*; + +/// Writes to one buffer gathered during an edit and sent merged: adjacent ranges as one `write_buffer` (an edit writes +/// hundreds of small records and meshes, contiguous in the room). Overlapping writes go in the order they came. +#[derive(Default)] +pub(super) struct Gather { + pub(super) parts: Vec<(u64, Vec)>, +} + +impl Gather { + pub(super) fn put(&mut self, at: u64, bytes: &[u8]) { + self.parts.push((at, bytes.to_vec())); + } + + pub(super) fn flush(self, q: &wgpu::Queue, b: &wgpu::Buffer) { + let mut parts = self.parts; + let mut sorted = parts.clone(); + sorted.sort_by_key(|p| p.0); + if sorted.windows(2).any(|w| w[0].0 + w[0].1.len() as u64 > w[1].0) { + for (at, bytes) in parts.drain(..) { + q.write_buffer(b, at, &bytes); + } + return; + } + let mut run: Option<(u64, Vec)> = None; + for (at, bytes) in sorted { + match &mut run { + Some((start, data)) if *start + data.len() as u64 == at => data.extend_from_slice(&bytes), + _ => { + if let Some((start, data)) = run.take() { + q.write_buffer(b, start, &data); + } + run = Some((at, bytes)); + } + } + } + if let Some((start, data)) = run { + q.write_buffer(b, start, &data); + } + } +} + +/// Where an in-place edit's CPU time goes (ms) and what it did: building the triangles' GPU form, uploading them, +/// creating and recording BLASes and the TLAS; meshes added and rewritten in place, objects added or removed. Frame log +/// `edit_split`. +#[derive(Default, Clone, Copy, Debug)] +pub struct EditSplit { + pub tris: f64, + pub upload: f64, + pub blas: f64, + pub added: usize, + pub deformed: usize, + pub objects: usize, +} + +impl EditSplit { + pub(super) fn add(&mut self, o: &EditSplit) { + (self.tris, self.upload, self.blas) = (self.tris + o.tris, self.upload + o.upload, self.blas + o.blas); + (self.added, self.deformed, self.objects) = (self.added + o.added, self.deformed + o.deformed, self.objects + o.objects); + } +} + +/// The edit room in use (gpu.rs `EditRoom`): the next free triangle (GPU order), kernel record, object-table entry, +/// procedural word (counted from the region's start) and TLAS instance, and where they started. +#[derive(Default)] +pub(super) struct EditState { + /// Triangles (GPU order), kernel records (a record's TLAS instance is `inst_base` + it) and object-table entries + /// edits can place: holes left by what they no longer draw, then the room, which grows past its end (ROADMAP § 6 + /// step 5). + pub(super) tris: Holes, + pub(super) records: Holes, + pub(super) objects: Holes, + /// The next procedural word, and where the region (grown as needed) ends. + pub(super) word: usize, + pub(super) word_cap: usize, + /// The instanced mesh each object edits manage draws (`own_blas`: the build's and theirs), each mesh's objects, the + /// meshes edits may free (own-BLAS meshes: shared only by objects of one geometry and material), and the slots of + /// freed ones, reused by the next new mesh. + pub(super) mesh_of: std::collections::HashMap, + pub(super) users: std::collections::HashMap, + pub(super) managed: std::collections::HashSet, + pub(super) dead_meshes: Vec, + /// Times the room grew (frame log `grows`), and whether the scene bind group must be made again. + pub(super) grows: u32, + pub(super) rebind: bool, + /// The uniform tables (`gpu::Gpu::uniform_tables`): an edit placed entries past what their arrays hold, and it + /// was said (once a scene, as its upload says it of the scene's own). + pub(super) uniform_said: bool, + /// (triangles, records, objects, words, TLAS instances) the scene itself has: the room starts there. + pub(super) base: (usize, usize, usize, usize, usize), + /// The first pool triangle (instanced meshes' triangles are counted from it). + pub(super) pool_first: usize, + /// Procedural blobs in the region by hash: their first word (the build's and those edits wrote). + pub(super) blobs: std::collections::HashMap<[u8; 32], usize>, + /// Objects edits added: their (TLAS instance, record); instanced meshes edits added: their first record, capacity + /// and first object entry (contiguous, as `update_instanced` rewrites them). + pub(super) placed: std::collections::HashMap, + pub(super) instanced: std::collections::HashMap, + /// The build's model instances (records below are theirs, in `LoadedScene::instances` order). + pub(super) inst_at_load: usize, + /// What clicks pick among the objects edits added (`LiveCore::pick`; the build's are `instances` and `procs`). + pub(super) added: std::collections::BTreeMap, +} + +/// Ranges of one kind of the edit room: free holes (start -> length; first fit, neighbours merged) below `end`, the +/// part in use, which may reach `cap`, what the buffers hold (more by growing them). +#[derive(Default, Debug)] +pub(super) struct Holes { + pub(super) free: std::collections::BTreeMap, + pub(super) end: usize, + pub(super) cap: usize, + /// Where the room starts (the scene's own data before it). + pub(super) base: usize, +} + +impl Holes { + pub(super) fn new(end: usize, cap: usize) -> Holes { + Holes { free: Default::default(), end, cap, base: end } + } + + /// `n` contiguous: the first hole that fits, else at the end if within `cap`. + pub(super) fn take(&mut self, n: usize) -> Option { + if let Some((&at, &len)) = self.free.iter().find(|(_, &len)| len >= n) { + self.free.remove(&at); + if len > n { + self.free.insert(at + n, len - n); + } + return Some(at); + } + (self.end + n <= self.cap).then(|| { + self.end += n; + self.end - n + }) + } + + /// `n` from `at` free again: merged with the holes beside it; at the end, the end moves back. + pub(super) fn give(&mut self, at: usize, n: usize) { + if n == 0 { + return; + } + let (mut a, mut len) = (at, n); + if let Some((&pa, &pl)) = self.free.range(..at).next_back().filter(|(&pa, &pl)| pa + pl == at) { + self.free.remove(&pa); + (a, len) = (pa, len + pl); + } + if let Some(nl) = self.free.remove(&(at + n)) { + len += nl; + } + if a + len == self.end { + self.end = a; + } else { + self.free.insert(a, len); + } + } + + /// In use (below `end`, less the holes). + pub(super) fn used(&self) -> usize { + self.end - self.free.values().sum::() + } +} + +/// An object an edit placed, as `LiveCore::pick` tests it. +pub(super) enum Picked { + Mesh(scene::Instance), + Proc(scene::ProcObject), +} + +/// What an edit places at an object's slot: a procedural object's BLAS (hardware traversal), or instanced mesh `k`. +pub(super) enum Placed { + Proc(wgpu::Blas), + Mesh(u32), +} + +/// One object fewer draws instanced mesh `k`: a mesh edits manage that none draws any more is freed (its triangles a +/// hole the next new mesh can take, its slot reused, its BLAS dropped with it). +pub(super) fn drop_mesh_user(k: u32, edit: &mut EditState, tables: &mut tables::Tables, meshes: &[scene::InstMesh]) { + let n = edit.users.entry(k).or_default(); + *n = n.saturating_sub(1); + if *n == 0 && edit.managed.remove(&k) { + let im = meshes[k as usize]; + edit.tris.give(edit.pool_first + im.first as usize, im.count as usize); + tables.retained.own_mesh.retain(|_, v| *v != k); + edit.dead_meshes.push(k); + } +} + +/// The room's buffers past their end, doubled at least (ROADMAP § 6 step 5): new buffers, the old contents copied on +/// the GPU; the scene bind group is made again after the edit (`EditState::rebind`). Not with particles, whose pass +/// keeps a bind group of its own over these buffers. +pub(super) struct Grow<'a> { + pub(super) g: &'a gpu::Gpu, + pub(super) ok: bool, +} + +impl Grow<'_> { + /// The room twice as large (the scene's own data before it stays as it is), at least `extra` more at the end. + pub(super) fn cap(h: &Holes, extra: usize) -> usize { + h.base + ((h.cap - h.base) * 2).max(h.end + extra - h.base).max(1024) + } + + /// Whether buffers of these sizes (bytes) can be bound (the device's storage binding and buffer limits): past them + /// the edit rebuilds instead (Bugbot on #131). + pub(super) fn fits(&self, sizes: &[u64]) -> bool { + let l = self.g.device.limits(); + let max = (l.max_storage_buffer_binding_size as u64).min(l.max_buffer_size); + sizes.iter().all(|&s| s <= max) + } + + /// Triangles: vertices, attributes, the BLASes' vertices, vertex colours (after the previous vertices). + pub(super) fn tris(&self, h: &mut Holes, extra: usize, verts: &mut wgpu::Buffer, attrs: &mut wgpu::Buffer, prev: &mut wgpu::Buffer, vcolor_base: Option, accel: &mut gpu::Accel) -> bool { + if !self.ok { + return false; + } + let cap = Self::cap(h, extra); + let prev_bytes = vcolor_base.map_or(0, |base| (base + cap as u64 * 12).next_multiple_of(16)); + if !self.fits(&[(cap * 48) as u64, (cap * std::mem::size_of::()) as u64, (cap * 36) as u64, prev_bytes]) { + return false; + } + *verts = self.g.grown(verts, (cap * 48) as u64, "verts"); + *attrs = self.g.grown(attrs, (cap * std::mem::size_of::()) as u64, "attrs"); + self.g.grow_blas_verts(accel, (cap * 36) as u64); + if vcolor_base.is_some() { + *prev = self.g.grown(prev, prev_bytes, "prev verts + vertex colours"); + } + h.cap = cap; + true + } + + /// Kernel records and the TLAS (a record's instance is `inst_base` + it). + pub(super) fn records(&self, h: &mut Holes, extra: usize, instances: &mut wgpu::Buffer, accel: &mut gpu::Accel) -> bool { + if !self.ok { + return false; + } + let cap = Self::cap(h, extra); + if !self.fits(&[(cap * std::mem::size_of::()) as u64]) { + return false; + } + *instances = self.g.grown(instances, (cap * std::mem::size_of::()) as u64, "instances"); + self.g.grow_tlas(accel, accel.inst_base + cap); + h.cap = cap; + true + } + + /// Object-table entries (`stride` vec4s each). + pub(super) fn objects(&self, h: &mut Holes, extra: usize, objects: &mut wgpu::Buffer, stride: usize) -> bool { + if !self.ok { + return false; + } + let cap = Self::cap(h, extra); + if !self.fits(&[(cap * stride * 16) as u64]) { + return false; + } + *objects = self.g.grown(objects, (cap * stride * 16) as u64, "objects"); + h.cap = cap; + true + } + + /// Procedural words, after the environment's CDF (`cdf_words` of them). + pub(super) fn words(&self, cap: &mut usize, need: usize, cdf: &mut wgpu::Buffer, cdf_words: usize) -> bool { + if !self.ok { + return false; + } + let new = (*cap * 2).max(need); + if !self.fits(&[((cdf_words + new) * 4) as u64]) { + return false; + } + *cap = new; + *cdf = self.g.grown(cdf, ((cdf_words + *cap) * 4) as u64, "env_cdf + procedural"); + true + } +} + +/// An object removed or hidden in place: no longer picked until the next rebuild (`LiveCore::pick`). +pub(super) fn unpick(id: u32, edit: &mut EditState, tables: &tables::Tables, instances: &mut [scene::Instance], procs: &mut [scene::ProcObject], proc_of: &std::collections::HashMap) { + edit.added.remove(&id); + if let Some(i) = tables.retained.own_instance.get(&id).and_then(|&k| instances.get_mut(k as usize)) { + i.hidden = true; + } + if let Some(p) = proc_of.get(&id).and_then(|&j| procs.get_mut(j)) { + p.hidden = true; + } +} + +impl LiveCore { + /// Applies what scene code changed in the world since the last commit. Parameter changes (power, light / material + /// parameters, wetness / rain / fog, the camera) rewrite GPU table entries in place; anything structural rebuilds + /// the GPU scene from the world (history survives, validated per pixel by depth / normal). Returns true on a + /// rebuild. + pub fn commit(&mut self, world: &mut World) -> Result { + self.cpu_skin_ms = 0.0; + self.cpu_upload_ms = 0.0; + self.gpu_blas_ms = 0.0; + // The game's particle spawns, for the next frame's pass; the voxel occupancy grid's changes, uploaded. + for (id, v) in std::mem::take(&mut world.particle_spawns) { + self.particle_spawns.entry(id).or_default().extend(v); + } + if let Some(o) = &world.voxel_occupancy { + let mut o = o.write().unwrap(); + if !o.dirty.is_empty() { + self.g.upload_occupancy(&self.sc.up, &mut o); + } + } + // The density grids: the game's sources for the next frame's grid pass, and new data uploaded in place. + for (id, v) in std::mem::take(&mut world.grid_sources) { + self.grid_sources.entry(id).or_default().extend(v); + } + for id in std::mem::take(&mut world.grid_dirty) { + if let Some(d) = world.grids.get(&id).and_then(|g| g.data.as_ref()) { + self.g.upload_grid(&self.sc.up, id, d); + } + } + let mut changes = world.take_changes(); + if !changes.any() { + return Ok(false); + } + // Objects shown or hidden by their own `visible`: in place when the build has them (a rain curtain the weather + // turns off, a pool's idle fireball and its light cost nothing while off), else with the rebuild. + if !changes.structural { + for id in std::mem::take(&mut changes.shown) { + let hide = !world.shown(id); + let done = match world.objects.get(&id).map(|o| &o.kind) { + Some(crate::world::Kind::Particles { .. }) => self.g.set_particles_hidden(&self.sc.up, id, hide), + Some(crate::world::Kind::Volume { shape, .. }) => self.g.set_medium_hidden(&self.sc.up, id, *shape, hide), + // (Its table entry, black while hidden: `Tables::update_light`.) + Some(crate::world::Kind::Light(_)) if self.sc.tables.light_index.contains_key(&id) => { + changes.lights.insert(id); + true + } + _ => false, + }; + if !done { + changes.structural = true; + } + } + } + // New materials without textures: entries after the scene's, before the edits that place objects drawn with + // them (and the GPU's table with them, grown if it is full). + if !changes.new_materials.is_empty() && !changes.structural { + if self.sc.tables.add_materials(world, &changes.new_materials) { + self.upload_materials(); + // (A clear coat, an unlit material: the kernel variant with it, compiled once.) + let (e, (tex, func, groups)) = (&self.sc.tables, self.sc.fixed_features); + self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; + } else { + self.fallback = Some("a new material that cannot be added in place (it emits, is glass or a cutout)".into()); + self.fallbacks += 1; + changes.structural = true; + } + } + // Objects added in place are not in the move tables (their records follow the scene's): a move rebuilds. Not a + // mesh a body drives: it is an instance, and its record moves as the build's do (`placed_movable`). + if !changes.structural && changes.moved.iter().any(|id| self.sc.edit.placed.contains_key(id) && self.placed_movable(world, *id).is_none()) { + self.fallback = Some("an object added in place moved".into()); + self.fallbacks += 1; + changes.structural = true; + if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { + eprintln!("edit: rebuild (an object added in place moved)"); + } + } + // Instanced objects rewrite their instances in place (`update_instanced`); one that cannot rebuilds the scene. Before + // the geometry edits, whose TLAS build (always, or the rebuild they fall back to) then carries the instances too + // (a voxel scene's debris and batches: one build of a 20 k instance TLAS a frame, not two); else they build it. + // (The instances the last update moved, taken first: what this one moves, from here on, keeps its motion.) + let moved_before = std::mem::take(&mut self.inst_moving); + let mut instanced_early = false; + // (Not those added this commit: the geometry edits place them with their instances as they are.) + let early: Vec = std::mem::take(&mut changes.instanced).into_iter().filter(|id| !changes.added.contains(id)).collect(); + if !early.is_empty() && !changes.structural { + instanced_early = self.instanced_in_place(world, &early, !changes.edits(), &mut changes); + } + // Geometry edits in place when nothing else is structural; what they cannot apply rebuilds the scene. + if changes.edits() && !changes.structural { + let t = Instant::now(); + let r = self.edit_geometry(world, &changes); + if std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1") { + eprintln!("edit: {} geometries, {} added, {} removed: {}", changes.geometries.len(), changes.added.len(), changes.removed.len(), r.as_ref().map_or_else(|e| format!("rebuild ({e})"), |_| "in place".into())); + } + match r { + Ok(()) => { + self.edit_ms += t.elapsed().as_secs_f32() * 1e3; + // As after a rebuild: history validated per pixel. + self.lit_changed = self.frame.max(1); + } + Err(why) => { + self.fallback = Some(why); + self.fallbacks += 1; + changes.structural = true; + } + } + } + if !changes.promoted_geometry.is_empty() { + eprintln!("live: {}: geometry changed, now with its own BLAS (this once rebuilds; set `dynamicGeometry` or the attribute's DynamicDrawUsage to avoid it)", changes.promoted_geometry.join(", ")); + self.fallback = Some("promote".into()); + self.fallbacks += 1; + } + if !changes.promoted.is_empty() { + eprintln!("live: {} moved, now movable (this once rebuilds; set `movable = true` in the scene code to avoid it)", changes.promoted.join(", ")); + } + // Moved movable subtrees: their drawables are re-transformed with the posed models, their lights rewritten in + // the light table. + let mut dynamic = changes.poses.clone(); + let mut placed = vec![]; + let mut procs_moved = vec![]; + let mut instanced_moved = vec![]; + let children = if changes.moved.is_empty() { Default::default() } else { world.children() }; + for id in &changes.moved { + for k in world.subtree_with(*id, &children) { + match &world.objects[&k].kind { + crate::world::Kind::Light(crate::desc::Light::Rect { visible: true, .. }) | crate::world::Kind::Mesh { .. } | crate::world::Kind::Model { .. } => { + if world.shown(k) { + // Movable models placed as instances move their transform (with a shared pose too); the rest + // re-transform triangles. + let r = &self.sc.tables.retained; + match r.instance_of.get(&k).copied().or_else(|| self.placed_movable(world, k)) { + Some(i) if !changes.poses.contains(&k) || r.pose_member.contains_key(&k) => placed.push((k, i)), + _ => { + dynamic.insert(k); + } + } + } + } + crate::world::Kind::Light(_) => { + changes.lights.insert(k); + } + // Particle functions place their particles around the node (`i.pivot`). + crate::world::Kind::Particles { .. } => { + let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); + self.g.set_particle_pivot(&self.sc.up, k, p.to_array()); + } + crate::world::Kind::GpuParticles { .. } => { + let p = world.world_matrix(k).transform_point3(glam::Vec3::ZERO); + self.g.set_gpu_particle_pivot(&self.sc.up, k, p.to_array()); + } + // Media: the box / sphere follows the node. + crate::world::Kind::Volume { .. } => { + self.g.set_medium(&self.sc.up, k, Some(scene::medium_rows(world.world_matrix(k))), None); + } + crate::world::Kind::Procedural { .. } if world.shown(k) => procs_moved.push(k), + crate::world::Kind::Instanced { .. } => instanced_moved.push(k), + _ => {} + } + } + } + // Instanced objects under a moved ancestor (or movable themselves) rewrite their instances too, unless the update + // above did (with the same world matrices; twice would take the new rows for the previous frame's: no motion). + // The TLAS is built by whatever moves after them, or there. + let mut instanced_done = instanced_early; + instanced_moved.retain(|k| !early.contains(k)); + if !instanced_moved.is_empty() && !changes.structural { + let others = !placed.is_empty() || !dynamic.is_empty() || !procs_moved.is_empty(); + instanced_done |= self.instanced_in_place(world, &instanced_moved, !others, &mut changes); + } + // Posed rigid models move their nodes' instances (`repose_rigid`), not triangles. + let rigid: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.rigid.contains_key(k)).collect(); + for k in &rigid { + dynamic.remove(k); + } + // Models in a shared pose re-skin their group's mesh (`repose_groups`, its BLAS only); their moves place instances. + let shared: Vec = dynamic.iter().copied().filter(|k| self.sc.tables.retained.pose_member.contains_key(k)).collect(); + for k in &shared { + dynamic.remove(k); + } + let shared_ok = shared.is_empty() || (!changes.structural && self.repose_groups(world, &shared)?); + // Procedural geometry moves its records and TLAS transforms; the TLAS is built by whatever moves after it, or here. + if !procs_moved.is_empty() && !changes.structural { + self.move_procs(world, &procs_moved, placed.is_empty() && dynamic.is_empty() && rigid.is_empty() && shared.is_empty()); + } + let placed_ok = shared_ok && (placed.is_empty() || (!changes.structural && self.move_instances(world, &placed, dynamic.is_empty() && rigid.is_empty()))); + let t_rigid = Instant::now(); + let rigid_ok = placed_ok && (rigid.is_empty() || (!changes.structural && self.repose_rigid(world, &rigid, dynamic.is_empty())?)); + self.cpu_skin_ms += t_rigid.elapsed().as_secs_f32() * 1000.0; + // Instances the last update moved and this one does not: their motion ends, the previous-frame rows catch up + // (written merged: an instanced object's thousands of records are contiguous). + if moved_before != self.inst_moving { + let size = std::mem::size_of::() as u64; + let now_moving: std::collections::HashSet = self.inst_moving.iter().copied().collect(); + let mut catch_up = Gather::default(); + for i in moved_before.into_iter().filter(|i| !now_moving.contains(i)) { + if let Some(r) = self.sc.gpu_instances.get_mut(i as usize) { + r.p = r.m; + catch_up.put(i as u64 * size, bytemuck::bytes_of(r)); + } + } + catch_up.flush(&self.g.queue, &self.sc.up.instances); + } + if rigid_ok && (dynamic.is_empty() || (!changes.structural && self.repose(world, &dynamic)?)) { + // A new shared pose and nothing else that rebuilds the TLAS (no move): rebuild it for the meshes' new bounds. + if !shared.is_empty() && placed.is_empty() && rigid.is_empty() && dynamic.is_empty() { + if let Some(accel) = self.sc.up.accel.as_ref() { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + self.g.queue.submit([e.finish()]); + } + } + // Moved emitters: the light tree's moving subtree around them, once (after every in-place update). + if std::mem::take(&mut self.emitters_moved) { + self.refit_moving_tree(); + } + changes.poses.clear(); + changes.moved.clear(); + // Moving objects move their shadows and reflections over static surfaces: let anti-lag look for it. + if !dynamic.is_empty() || !placed.is_empty() || !rigid.is_empty() || !shared.is_empty() || !procs_moved.is_empty() || instanced_done { + self.lit_changed = self.frame.max(1); + } + } + // Particle nodes' customData: their particle function's `i.custom` (the table has no object for the node). + for id in &changes.customs { + if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Particles { .. })) { + self.g.set_particle_custom(&self.sc.up, *id, o.custom); + } + } + // Volume nodes' customData: their volume function's `i.custom` (the media table, not the objects table). + for id in &changes.customs { + if let Some(o) = world.objects.get(id).filter(|o| matches!(o.kind, crate::world::Kind::Volume { .. })) { + self.g.set_medium(&self.sc.up, *id, None, Some(o.custom)); + } + } + let now = shader_time(self.time); + let applied = self.sc.tables.apply(world, &changes, now).ok().flatten(); + let Some(d) = applied else { + let t = Instant::now(); + self.rebuild(world)?; + self.rebuild_ms += t.elapsed().as_secs_f32() * 1e3; + if changes.camera { + self.cam = self.sc.scene_camera; + } + return Ok(true); + }; + if d.materials { + self.upload_materials(); + } + let (q, up, e) = (&self.g.queue, &self.sc.up, &mut self.sc.tables); + if d.lights && !e.lights.is_empty() { + if d.lights_moved { + e.regroup_lights(); + } + q.write_buffer(&up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); + } + if d.objects { + q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&e.objects)); + } + if d.materials || d.emitters { + self.add_material_emitters(); + self.write_emitters(); + } else if d.deferred { + self.send_powers(); + } + let (e, b) = (&mut self.sc.tables, &mut self.sc.base); + if d.environment { + b.time[1] = e.wetness; + b.time[2] = e.rain; + b.background = e.background.map_or([0.0; 4], |c| [c[0], c[1], c[2], 1.0]); + b.aerial = e.aerial; + [b.fog, b.fog_color, b.vol, b.vol2] = crate::fog_params(e.fog.as_ref()); + } + if d.materials || d.environment { + // e.g. fog appearing needs the fog kernel variant (compiled once, then cached) + let (tex, func, groups) = self.sc.fixed_features; + self.sc.up.kernel.features = gpu::features_of(&e.materials, e.fog.as_ref(), tex, func, groups, self.sc.instance_features) | if e.aerial[0] > 0.0 { gpu::F_AERIAL } else { 0 }; + } + if let Some(c) = e.camera.take() { + self.sc.scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; + self.sc.tan_half_fov = c.tan_half_fov; + self.sc.dof = c.dof.clone(); + self.cam = self.sc.scene_camera; + } + // Lighting changed: for 45 frames the histories hold HISTORY_LIVE frames at most (`live_lighting`), and where the + // change is strong the temporal pass cuts the history locally (anti-lag). + if d.materials || d.lights || d.objects || d.environment { + self.lit_changed = self.frame.max(1); + } + // (A new cloud cover re-bakes the sky and dims the sun as a sun move does.) + if changes.sun || self.sc.tables.cloud_cover != self.sky_cover { + self.sky_cover = self.sc.tables.cloud_cover; + self.move_sun(world); + } + if changes.exposure { + self.apply_exposure(world); + } + Ok(false) + } + + /// The atmosphere's sun moved (time of day): its light entry is rewritten now (direction, and its colour through + /// the atmosphere), and the sky's re-bake is asked of the worker thread (`DynamicSky`, started here on the first + /// move). Lockstep frames wait for the bake, so a lockstep run sees the sky that goes with each sun. + pub(super) fn move_sun(&mut self, world: &World) { + let Some((id, dir, lux)) = scene::atmosphere_sun(world) else { return }; + if self.sc.sky.is_none() { + let (size, alt, floor) = self.sc.sky_inputs; + self.sc.sky = DynamicSky::new(world, size, alt, floor); + } + let Some(sky) = self.sc.sky.as_ref() else { return }; + let cover = self.sc.tables.cloud_cover; + let colour = crate::sky::sun_illuminance(&sky.atm, dir, lux, cover); + let e = &mut self.sc.tables; + if let (Some(idx), Some(o)) = (e.light_index.get(&id), world.objects.get(&id)) { + if let crate::world::Kind::Light(l) = &o.kind { + if let Some(mut gl) = scene::gpu_light(l, world.world_matrix(id), o.power) { + // (The sun: hidden with a rebuild, `World::set_visible`.) + gl.color = [colour.x, colour.y, colour.z, 0.0]; + for &i in idx { + e.lights[i] = gl; + } + self.g.queue.write_buffer(&self.sc.up.lights, 0, bytemuck::cast_slice(&crate::lights_with_groups(&e.lights))); + } + } + } + let _ = sky.ask.send((dir, lux, cover)); + if self.fixed_dt.is_some() { + if let Ok(b) = sky.done.recv() { + self.put_sky(b); + } + } + self.lit_changed = self.frame.max(1); + } + + /// A sky the worker thread has baked since the last frame (the newest), into the environment map and its CDF. + pub(super) fn take_sky(&mut self) { + let Some(sky) = self.sc.sky.as_ref() else { return }; + let mut last = None; + while let Ok(b) = sky.done.try_recv() { + last = Some(b); + } + if let Some(b) = last { + self.put_sky(b); + } + } + + pub(super) fn put_sky(&mut self, (env, cdf, max): (gpu::EnvMap, Vec, f32)) { + let Some(sky) = self.sc.sky.as_ref() else { return }; + self.g.write_env(&self.sc.up, &env, &cdf); + // As the load decides it: the sky is light-sampled unless it is negligible next to the emitters (night), or a + // baked sky is not light-sampled at all (`env_nee_on`). + self.sc.base.counts2[1] = (max >= sky.env_nee_floor && crate::env_nee_on()) as u32; + self.lit_changed = self.frame.max(1); + } + + /// The camera's manual exposure changed value (an exposure that follows the time of day), in place: the post pass's + /// exposure and the kernel's firefly clamp, which is relative to it. + pub(super) fn apply_exposure(&mut self, world: &World) { + let Some(cid) = world.settings.camera else { return }; + let Some(crate::world::Kind::Camera(c)) = world.objects.get(&cid).map(|o| &o.kind) else { return }; + let crate::desc::Exposure::Manual { ev100 } = c.exposure else { return }; + let e = 2f32.powf(-ev100); + if self.sc.exposure > 0.0 { + self.sc.base.misc[0] *= self.sc.exposure / e; + } + self.sc.exposure = e; + self.sc.exposure_settings.ae2 = [e, 0.0, 0.0, 0.0]; + } + + /// Moves movable instances: their kernel records (the old transform kept as the previous frame's, for motion + /// vectors), their object entries' pivots and their TLAS transforms (hardware traversal; with `build_tlas` it + /// rebuilds the TLAS, otherwise the dynamic rebuild that follows does) or their units and the top tree over them + /// (software traversal, units.rs). No triangles change. False when it cannot (software traversal without units, + /// or an instance that is no unit; a top tree that cannot be made): the caller rebuilds. + pub(super) fn move_instances(&mut self, world: &World, placed: &[(u32, u32)], build_tlas: bool) -> bool { + // (Hardware traversal's TLAS instances, or software traversal's units; neither: the caller rebuilds.) + let Some(mut top) = gpu::Top::of(&mut self.sc.up.accel, &mut self.sc.up.units) else { return false }; + let size = std::mem::size_of::() as u64; + let q = &self.g.queue; + // (Many records, a rigid body world's thousands a frame, are written as the one range that holds them: the + // records between are as the GPU has them.) + let merged = placed.len() > 32; + let (mut lo, mut hi) = (usize::MAX, 0usize); + // (Instances the last update moved and this one does not catch up in `commit`.) + for &(node, i) in placed { + let w = world.world_matrix(node); + // (A mesh an edit added in place keeps its instance with the edit's, `placed_movable`; its record and TLAS + // instance are where the build's are.) + let inst = match self.sc.edit.added.get_mut(&node) { + Some(Picked::Mesh(inst)) => inst, + _ => &mut self.sc.instances[i as usize], + }; + // (Software traversal: an instance of a mesh that deforms is no unit; its triangles are among the still + // scene's, in the world, and a rebuild moves them.) + if !top.has_mesh(inst.mesh) { + return false; + } + let old = self.sc.gpu_instances[i as usize]; + inst.world = w; + let mut r = top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, inst.cast_shadow); + r.p = old.m; + self.sc.gpu_instances[i as usize] = r; + if merged { + (lo, hi) = (lo.min(i as usize), hi.max(i as usize)); + } else { + q.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); + } + let pivot = w.transform_point3(glam::Vec3::ZERO); + let st = self.sc.tables.stride; + let o = &mut self.sc.tables.objects[inst.object as usize * st]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + let slot = top.inst_base() + i as usize; + top.moved(slot, w, None); + self.inst_moving.push(i); + } + if merged { + q.write_buffer(&self.sc.up.instances, lo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[lo..=hi])); + } + q.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + self.inst_moved = true; + match top { + gpu::Top::Hw(accel) => { + if build_tlas { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + q.submit([e.finish()]); + } + true + } + // (The top tree is built here whoever else changes units this commit: it is CPU work and one write, and a + // tree that cannot be made must send the caller to a rebuild now.) + gpu::Top::Sw(units) => self.g.retop(units, &self.sc.up.bg_parts.nodes).map_err(|e| eprintln!("units: {e}: rebuilding the scene")).is_ok(), + } + } + + /// The material table to the GPU: written in place, or, with more materials than its buffer holds (materials added + /// while the game runs), into a buffer twice the size, the scene's bind group made over it. + pub(super) fn upload_materials(&mut self) { + let bytes: &[u8] = bytemuck::cast_slice(&self.sc.tables.materials); + if bytes.len() as u64 > self.sc.up.materials.size() { + let usage = self.sc.up.materials.usage(); + self.sc.up.materials = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("materials"), size: (bytes.len() as u64 * 2).next_multiple_of(16), usage, mapped_at_creation: false }); + self.g.rebind_scene(&mut self.sc.up); + } + self.g.queue.write_buffer(&self.sc.up.materials, 0, bytes); + } + + /// The record of a mesh an edit added in place that moves as an instance does (`move_instances`): one a rigid body + /// drives, placed by its node's transform (an emitter mesh's triangles are in world space: it cannot). + pub(super) fn placed_movable(&self, world: &World, id: u32) -> Option { + let &(_, record) = self.sc.edit.placed.get(&id)?; + let mesh = matches!(self.sc.edit.added.get(&id), Some(Picked::Mesh(_))); + (mesh && world.objects.get(&id).is_some_and(|o| o.driven) && !self.sc.tables.retained.emitter_mesh.contains_key(&id)).then_some(record as u32) + } + + /// `update_instanced` with its fallback (the reason in the frame log, the scene rebuilt) and its CPU time; whether it + /// changed anything. + pub(super) fn instanced_in_place(&mut self, world: &World, ids: &[u32], build_tlas: bool, changes: &mut crate::world::Changes) -> bool { + let t = Instant::now(); + let r = self.update_instanced(world, ids, build_tlas); + self.instanced_ms += t.elapsed().as_secs_f32() * 1e3; + let log = std::env::var("THREERT_EDIT_LOG").is_ok_and(|v| v == "1"); + match r { + Ok(n) => { + if log { + eprintln!("edit: {n} instanced objects in place"); + } + n > 0 + } + Err(why) => { + if log { + eprintln!("edit: rebuild ({why})"); + } + self.fallback = Some(why); + self.fallbacks += 1; + changes.structural = true; + false + } + } + } + + /// Emitter entries for the materials that started emitting (`Tables::started_emitting`): their flattened static + /// triangles (`mat_runs`), positions from `tris`, in the power-share part of the CDF (path code 0, as the light + /// tree's moving emitters; ROADMAP § 6 step 3). `write_emitters` uploads them. + pub(super) fn add_material_emitters(&mut self) { + let sc = &mut self.sc; + for mat in std::mem::take(&mut sc.tables.started_emitting) { + for &[first, end, obj] in sc.mat_runs.get(&mat).map_or(&[][..], |v| v.as_slice()) { + for k in first..end { + let Some(&[a, b, c]) = sc.tri_order.get(k as usize).and_then(|&i| sc.tris.get(i as usize)) else { continue }; + let area = 0.5 * (b - a).cross(c - a).length(); + if area > 0.0 { + let centre = (a + b + c) / 3.0; + sc.tables.emitter_tris.push(crate::EmitterTri { tri: k, mat, obj, area, c: centre.to_array(), moves: false, nominal: false, in_tree: false }); + } + } + } + } + } + + /// The emitter CDF from `Tables::emitter_tris` (weights from materials and powers; entries added in place at its + /// end, removed ones at area 0), the light tree refit to the new weights and its nodes after the CDF, the CDF's + /// share of power, its count. With the tree's slot for emitters added in place (scenes that can edit geometry), their + /// subtree is grown again first and their entries go right after the static ones (ROADMAP § 6 step 4); without it + /// they stay at the end, by power share. The buffer grows (and the scene bind group is made again) when entries + /// were added past it: a scene that adds none keeps its layout. + pub(super) fn write_emitters(&mut self) { + self.land_powers_now(); + let g = &self.g; + let LoadedScene { tables: e, base: b, up, pool_tris, tris: flat, tri_order, edit, .. } = &mut self.sc; + // Emitters added in place, with the tree's slot for them (`ltree::build_slotted`): its subtree grown again over + // those alive, their entries right after the static ones in the CDF (in its leaf order: each leaf's entry is its + // position) and their path codes in their triangles' attrs (written over the code 0 an edit gave them). + let mut perm: Option> = None; + if let Some(t) = e.light_tree.as_mut() { + if let Some((_, ns)) = t.dynamic { + let ns = ns as usize; + let (mats, objs, st, pf) = (&e.materials, &e.objects, e.stride, edit.pool_first); + let verts = |k: usize| if k >= pf { pool_tris.get(k - pf).copied() } else { tri_order.get(k).and_then(|&i| flat.get(i as usize)).copied() }; + let mut items = vec![]; + for (i, x) in e.emitter_tris.iter().enumerate().skip(ns) { + let w = crate::emitter_power(x, mats, objs, st); + let Some([va, vb, vc]) = verts(x.tri as usize).filter(|_| !x.in_tree && !x.nominal && !x.moves && w > 0.0) else { continue }; + items.push(crate::ltree::DynItem { e: i as u32, lo: va.min(vb).min(vc).to_array(), hi: va.max(vb).max(vc).to_array(), c: x.c, w }); + } + t.set_dynamic(items); + let at = std::mem::offset_of!(scene::GpuTriAttr, x) + 12; + let mut codes = Gather::default(); + let mut placed = vec![false; e.emitter_tris.len()]; + let mut p: Vec = (0..ns).collect(); + for k in ns..t.order.len() { + let i = t.order[k] as usize; + codes.put((e.emitter_tris[i].tri as usize * std::mem::size_of::() + at) as u64, bytemuck::bytes_of(&t.code[k])); + placed[i] = true; + p.push(i); + } + codes.flush(&g.queue, &up.attrs); + p.extend((ns..e.emitter_tris.len()).filter(|&i| !placed[i])); + perm = Some(p); + } + } + let permuted: Vec; + let list = match &perm { + Some(p) => { + permuted = p.iter().map(|&i| e.emitter_tris[i]).collect(); + &permuted[..] + } + None => &e.emitter_tris[..], + }; + let (em, total, power) = crate::emitter_cdf(list, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); + if let Some(t) = e.light_tree.as_mut() { + let (tris, mats, objs, st) = (&e.emitter_tris, &e.materials, &e.objects, e.stride); + let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], mats, objs, st)).collect(); + t.refit(&|k| w[k]); + } + self.put_emitters(&em, total, power); + // Emitters added in place since the worker was made: another, over them. + if self.sc.emit.as_ref().is_some_and(|w| w.count != self.sc.tables.emitter_tris.len()) { + self.sc.emit = EmitterWorker::new(&self.sc.tables, &self.g.device); + self.sc.tables.defer_powers = self.sc.emit.is_some(); + } + } + + /// The emitter CDF `em` (its total weight and the emitters' power), then the light tree's nodes, into the emitters + /// buffer (grown, and the scene bind group made again, when they no longer fit), and the kernel's parameters. + pub(super) fn put_emitters(&mut self, em: &[[f32; 4]], total: f32, power: f32) { + let g = &self.g; + let LoadedScene { tables: e, base: b, up, .. } = &mut self.sc; + let nodes: &[[f32; 4]] = e.light_tree.as_ref().map_or(&[], |t| &t.nodes); + let need = ((em.len() + nodes.len()) * 16) as u64; + if need > up.emitters.size() { + // (The usage it was made with: the merged layout copies the tables each frame, `Gpu::sync_tables`.) + let usage = up.emitters.usage(); + up.emitters = g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters"), size: need + need / 2, usage, mapped_at_creation: false }); + g.rebind_scene(up); + } + if !em.is_empty() { + g.queue.write_buffer(&up.emitters, 0, bytemuck::cast_slice(em)); + } + if let Some(t) = e.light_tree.as_ref() { + g.queue.write_buffer(&up.emitters, em.len() as u64 * 16, bytemuck::cast_slice(&t.nodes)); + } + let _ = b; + self.emitter_params(em.len(), total, power); + } + + /// The kernel's emitter parameters for a CDF of `entries` (its total weight; the emitters' power) and the tree. + pub(super) fn emitter_params(&mut self, entries: usize, total: f32, power: f32) { + let LoadedScene { tables: e, base: b, .. } = &mut self.sc; + b.emit_power[0] = power; + b.misc[1] = total; + if let Some(t) = e.light_tree.as_ref() { + b.ltree[0] = f32::from_bits(entries as u32); + // The tree's entries (with the slot: the static ones and those added in place), the rest by power share. + if t.dynamic.is_some() { + b.ltree[1] = f32::from_bits(t.order.len() as u32); + } + b.ltree[2] = if power > 0.0 { ((power - t.power) / power).max(0.0) } else { 0.0 }; + } + b.counts[1] = if total > 0.0 { e.emitter_tris.len() as u32 } else { 0 }; + } + + /// Power edits held back (`Tables::deferred`) to the emitter worker, unless it is on a job (they go with the next, + /// when that one lands). Lockstep frames wait for the result. + pub(super) fn send_powers(&mut self) { + let Some(w) = self.sc.emit.as_mut() else { return }; + if w.flight.is_some() || self.sc.tables.deferred.is_empty() { + return; + } + let e = &mut self.sc.tables; + let powers = std::mem::take(&mut e.deferred); + let mut objects = e.objects.clone(); + for &(row, power, _) in &powers { + objects[row][3] = power; + } + let b = &self.sc.base; + let job = EmitterJob { materials: e.materials.clone(), objects, lod: b.light_lod, cull: crate::EmitterCull::of(b) }; + if w.ask.send(job).is_err() { + // (The worker is gone: write them now.) + e.put_powers(&powers); + self.sc.emit = None; + e.defer_powers = false; + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + self.write_emitters(); + return; + } + w.flight = Some(powers); + if self.fixed_dt.is_some() { + if let Ok(r) = self.sc.emit.as_ref().unwrap().done.recv() { + self.land_emitters(r); + } + } + } + + /// The emitter worker's result, if it has one: its edits land with it, and the edits held back since go out. + pub(super) fn take_emitters(&mut self) { + let Some(r) = self.sc.emit.as_ref().and_then(|w| w.done.try_recv().ok()) else { return }; + self.land_emitters(r); + self.send_powers(); + } + + /// A job's edits and its emitter CDF and tree powers, together: the objects' powers, the CDF, the tree. + pub(super) fn land_emitters(&mut self, r: EmitterBuilt) { + let Some(powers) = self.sc.emit.as_mut().and_then(|w| w.flight.take()) else { return }; + let e = &mut self.sc.tables; + e.put_powers(&powers); + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); + if let Some(t) = e.light_tree.as_mut() { + for (n, &p) in t.nodes.iter_mut().zip(&r.node_powers) { + n[2] = p; + } + t.power = t.nodes.first().map_or(0.0, |n| n[2]); + } + // (Same entries and nodes as the CDF the scene was loaded with: the buffer holds them.) + let mut enc = self.g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("emitters") }); + enc.copy_buffer_to_buffer(&r.staging, 0, &self.sc.up.emitters, 0, r.bytes.min(self.sc.up.emitters.size())); + self.g.queue.submit([enc.finish()]); + self.emitter_params(r.entries, r.total, r.power); + self.lit_changed = self.frame.max(1); + } + + /// Before another emitter edit (`write_emitters`, a light-LOD rebuild): the job in flight waited for and its result + /// dropped, its edits and those held back written into the objects (and uploaded), so the rebuild sees them all. + pub(super) fn land_powers_now(&mut self) { + let Some(w) = self.sc.emit.as_mut() else { return }; + let mut powers = vec![]; + if let Some(f) = w.flight.take() { + let _ = w.done.recv(); + powers = f; + } + let e = &mut self.sc.tables; + powers.append(&mut e.deferred); + if !powers.is_empty() { + e.put_powers(&powers); + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&e.objects)); + // The lighting changed (as when a result lands): `live_lighting` for the next 45 frames. + self.lit_changed = self.frame.max(1); + } + } + + /// Instanced objects whose instances changed (`World::instanced`, the node's transform, power or customData): their + /// kernel records (the old transforms kept as the previous frame's, for motion vectors), object entries (pivot, + /// bounds, power, data, colour), TLAS transforms and masks (`scene::instance_world`: a hidden instance 0), each + /// object's records and entries written as one range; with `build_tlas` the TLAS is rebuilt here. No triangles or + /// BLASes change. Returns how many objects it updated; Err: why it cannot (software traversal, an object the build + /// flattened or did not place this way, an instance now mirrored), and the caller rebuilds. + /// `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). + pub(super) fn update_instanced(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> std::result::Result { + use crate::world::Kind; + if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { + return Err("THREERT_EDIT_REBUILD=1".into()); + } + let size = std::mem::size_of::(); + let now = shader_time(self.time); + let empty = crate::world::InstanceTable::default(); + let sc = &mut self.sc; + let Some(mut top) = gpu::Top::of(&mut sc.up.accel, &mut sc.up.units) else { return Err("software traversal".into()) }; + let mut done = 0; + for &id in ids { + // (Removed or hidden: nothing is drawn; a structural change rebuilds.) + let Some(n) = world.objects.get(&id).filter(|_| world.shown(id)) else { continue }; + let Kind::Instanced { capacity, procedural, .. } = n.kind else { continue }; + let Some(&place) = sc.tables.retained.instanced.get(&id) else { return Err("an instanced mesh the build flattened".into()) }; + if place.capacity != capacity || place.procedural != procedural { + return Err("an instanced mesh the build placed otherwise".into()); + } + let t = world.instanced.get(&id).unwrap_or(&empty); + let m = world.world_matrix(id); + // Past both the old and the new count every instance is hidden and stays so: left as it is. + let (first, cap) = (place.first as usize, place.drawn.max(t.count).min(capacity) as usize); + // (A procedural one's hidden instances are out of hardware traversal by their TLAS masks; software + // traversal walks every record there is: the build leaves them out.) + if procedural && top.sw() { + return Err("a procedural instanced mesh (software traversal)".into()); + } + let (rec0, slot0) = if procedural { (sc.proc_first + first, top.proc_base(sc.proc_first) + first) } else { (first, top.inst_base() + first) }; + for i in 0..cap { + let (wm, hidden) = scene::instance_world(m, t, i); + if !procedural && !hidden && wm.determinant() < 0.0 { + return Err("an instance is mirrored".into()); + } + let old = sc.gpu_instances[rec0 + i]; + let (mut rec, entry) = if procedural { + let p = &mut sc.procs[first + i]; + (p.world, p.hidden) = (wm, hidden); + (p.gpu(old.info[0]), p.entry(n.power, t.custom(i, n.custom), t.color(i))) + } else { + let inst = &mut sc.instances[first + i]; + if !top.has_mesh(inst.mesh) { + return Err("an instanced mesh that is no unit (software traversal)".into()); + } + (inst.world, inst.hidden) = (wm, hidden); + let (lo, hi) = sc.meshes[inst.mesh as usize].placed_bounds(wm); + (top.record(inst.gpu(old.info[0], old.info[3]), inst.mesh, n.cast_shadow), scene::object_row(wm.transform_point3(glam::Vec3::ZERO), n.power, hi - lo, t.custom(i, n.custom), t.color(i))) + }; + rec.p = old.m; + sc.gpu_instances[rec0 + i] = rec; + // The time of the last power edit: now for a new power. + let st = sc.tables.stride; + let at = (place.object as usize + i) * st; + let e = &mut sc.tables.objects[at..at + st]; + let edited = if e[0][3] != entry[0][3] { now } else { e[1][3] }; + e.copy_from_slice(&entry[..st]); + e[1][3] = edited; + top.moved(slot0 + i, wm, Some((n.cast_shadow, hidden))); + } + let q = &self.g.queue; + q.write_buffer(&sc.up.instances, (rec0 * size) as u64, bytemuck::cast_slice(&sc.gpu_instances[rec0..rec0 + cap])); + let st = sc.tables.stride; + let at = place.object as usize * st; + q.write_buffer(&sc.up.objects, (at * 16) as u64, bytemuck::cast_slice(&sc.tables.objects[at..at + cap * st])); + self.inst_moving.extend(rec0 as u32..(rec0 + cap) as u32); + sc.tables.retained.instanced.get_mut(&id).unwrap().drawn = t.count; + done += 1; + } + if done > 0 { + self.inst_moved = true; + match top { + gpu::Top::Hw(accel) => { + if build_tlas { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + self.g.queue.submit([e.finish()]); + } + } + gpu::Top::Sw(units) => self.g.retop(units, &sc.up.bg_parts.nodes)?, + } + } + Ok(done) + } + + /// Moves procedural objects: their kernel records (the old transform kept as the previous frame's, for motion + /// vectors), object pivots and, with hardware RT, TLAS transforms (`build_tlas`: the TLAS rebuilt here). Software + /// traversal reads the records alone, so neither path rebuilds the scene. + pub(super) fn move_procs(&mut self, world: &World, ids: &[u32], build_tlas: bool) { + let size = std::mem::size_of::() as u64; + for id in ids { + let Some(&j) = self.sc.proc_of.get(id) else { continue }; + let w = world.world_matrix(*id); + let p = &mut self.sc.procs[j]; + p.world = w; + let i = self.sc.proc_first + j; + let old = self.sc.gpu_instances[i]; + let mut r = p.gpu(old.info[0]); + r.p = old.m; + self.sc.gpu_instances[i] = r; + self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(&r)); + let pivot = w.transform_point3(glam::Vec3::ZERO); + let st = self.sc.tables.stride; + let o = &mut self.sc.tables.objects[p.object as usize * st]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + if let Some(accel) = self.sc.up.accel.as_mut() { + if let Some(t) = accel.tlas[accel.proc_base + j].as_mut() { + t.transform = gpu::tlas_rows(w); + } + } + self.inst_moving.push(i as u32); + } + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + if let (true, Some(accel)) = (build_tlas, self.sc.up.accel.as_ref()) { + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_tlas(accel, &mut e); + self.g.queue.submit([e.finish()]); + } + self.inst_moved = true; + } + + /// Posed rigid models (`scene::RigidModel`): each moving node's instance takes object x node transform from the clip + /// (previous rows kept for motion vectors) and their flattened triangles are re-transformed in place; then the dynamic + /// BLASes (if they have such triangles) and, when `build_tlas`, the TLAS are rebuilt (`repose` rebuilds both after + /// it otherwise). False when it cannot (no hardware RT, a model hidden or changed): the caller rebuilds. + pub(super) fn repose_rigid(&mut self, world: &World, ids: &[u32], build_tlas: bool) -> Result { + if self.sc.up.accel.is_none() { + return Ok(false); + } + let mut placements: Vec<(u32, glam::Mat4)> = vec![]; + let (mut lo, mut hi) = (usize::MAX, 0usize); + for id in ids { + let Some(o) = world.objects.get(id) else { return Ok(false) }; + let crate::world::Kind::Model { uri, animation, .. } = &o.kind else { return Ok(false) }; + if !world.shown(*id) { + return Ok(false); + } + let globals = scene::model_gltf(uri, &self.assets)?.node_globals(animation.as_ref())?; + let m = world.world_matrix(*id); + let r = &self.sc.tables.retained.rigid[id]; + placements.extend(r.instances.iter().map(|&(node, i)| (i, m * globals[node]))); + let d = &r.rest; + if d.finals.len() != d.mats.len() { + return Ok(false); + } + let (first, verts, attrs, blas) = &mut self.sc.dynamic; + for (j, (node, p, n, uv, tg)) in r.rest_local.iter().enumerate() { + let w = m * globals[*node]; + let nm = glam::Mat3::from_mat4(w).inverse().transpose(); + let pw = p.map(|v| w.transform_point3(v)); + let nw = n.map(|v| (nm * v).normalize_or_zero()); + let tw = tg.map(|(v, s)| (w.transform_vector3(v).normalize_or_zero(), s)); + let a = scene::tri_attr(pw, nw, *uv, tw, d.mats[j]); + let k = d.finals[j] as usize - *first; + let (e1, e2) = (pw[1] - pw[0], pw[2] - pw[0]); + verts[k * 3..k * 3 + 3].copy_from_slice(&[[pw[0].x, pw[0].y, pw[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; + blas[k * 3..k * 3 + 3].copy_from_slice(&pw.map(|v| v.to_array())); + self.sc.tris[(d.first + j as u32) as usize] = pw.map(glam::Vec3A::from); + (lo, hi) = (lo.min(k), hi.max(k + 1)); + } + let pivot = m.transform_point3(glam::Vec3::ZERO); + let o = &mut self.sc.tables.objects[d.object as usize * self.sc.tables.stride]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + } + // The instances: rows, previous rows (motion vectors), TLAS transforms. + let size = std::mem::size_of::() as u64; + let accel = self.sc.up.accel.as_mut().unwrap(); + let (mut ilo, mut ihi) = (usize::MAX, 0usize); + for &(i, w) in &placements { + let inst = &mut self.sc.instances[i as usize]; + let old = self.sc.gpu_instances[i as usize]; + inst.world = w; + let mut r = inst.gpu(old.info[0], old.info[3]); + r.p = old.m; + self.sc.gpu_instances[i as usize] = r; + (ilo, ihi) = (ilo.min(i as usize), ihi.max(i as usize + 1)); + let row = |k: usize| [w.x_axis[k], w.y_axis[k], w.z_axis[k], w.w_axis[k]]; + if let Some(t) = accel.tlas[accel.inst_base + i as usize].as_mut() { + t.transform = [row(0), row(1), row(2)].concat().try_into().unwrap(); + } + // (Rigid models' instances are theirs alone: no other update this commit placed them.) + self.inst_moving.push(i); + } + if lo < hi { + self.emitters_moved = true; + } + // A model's node instances are contiguous: one write. + if ilo < ihi { + self.g.queue.write_buffer(&self.sc.up.instances, ilo as u64 * size, bytemuck::cast_slice(&self.sc.gpu_instances[ilo..ihi])); + } + self.inst_moved = true; + self.g.queue.write_buffer(&self.sc.up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + if lo < hi { + // Motion vectors of the flattened triangles: the previous frame's vertices, as `repose` keeps them. + let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); + self.keep_prev(both); + self.prev_span = Some((lo, hi)); + self.reposed = true; + let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); + let accel = up.accel.as_ref().unwrap(); + let k = (first + lo) as u64; + q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); + q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); + q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); + } + if build_tlas { + let accel = self.sc.up.accel.as_ref().unwrap(); + let mut e = self.g.device.create_command_encoder(&Default::default()); + if lo < hi { + self.g.rebuild_dynamic(accel, &mut e); + } else { + self.g.rebuild_tlas(accel, &mut e); + } + self.g.queue.submit([e.finish()]); + } + Ok(true) + } + + /// Models in a shared pose (`scene::PoseGroup`): each group whose pose changed is skinned once, in model space (the + /// groups in parallel), into its mesh's pool triangles, and their BLASes are rebuilt; the TLAS is left to the moves + /// that follow (or `commit`). False when a group's members no longer share one pose, or one is hidden or changed: + /// the caller rebuilds, which groups them again. (Deforming meshes get no motion vectors of their own yet: an + /// instance's are its transform's.) + pub(super) fn repose_groups(&mut self, world: &World, members: &[u32]) -> Result { + use rayon::prelude::*; + if self.sc.up.accel.is_none() { + return Ok(false); + } + let r = &self.sc.tables.retained; + let mut touched: Vec = members.iter().map(|k| r.pose_member[k]).collect(); + touched.sort_unstable(); + touched.dedup(); + let mut jobs: Vec<(u32, crate::desc::Animation)> = vec![]; + for &g in &touched { + let pg = &r.pose_groups[g as usize]; + let mut pose: Option<&crate::desc::Animation> = None; + for (id, _) in &pg.members { + let Some(o) = world.objects.get(id) else { return Ok(false) }; + let crate::world::Kind::Model { uri, nodes, animation: Some(a), .. } = &o.kind else { return Ok(false) }; + if !world.shown(*id) || *uri != pg.uri || *nodes != pg.nodes || pose.is_some_and(|p| p != a) { + return Ok(false); + } + pose = Some(a); + } + if let Some(a) = pose.filter(|a| **a != pg.animation) { + jobs.push((g, a.clone())); + } + } + if jobs.is_empty() { + return Ok(true); + } + let t_skin = Instant::now(); + let assets = &self.assets; + type Skinned = (Vec<[f32; 4]>, Vec, Vec<[f32; 3]>, Vec<[glam::Vec3A; 3]>); + let skinned: Vec>> = jobs + .par_iter() + .map(|(g, a)| { + let pg = &r.pose_groups[*g as usize]; + let tris = scene::model_gltf(&pg.uri, assets)?.triangles(glam::Mat4::IDENTITY, pg.nodes.as_deref(), Some(a))?; + if tris.len() != pg.order.len() { + return Ok(None); + } + let n = pg.order.len(); + let (mut v, mut at, mut b, mut t) = (Vec::with_capacity(n * 3), Vec::with_capacity(n), Vec::with_capacity(n * 3), Vec::with_capacity(n)); + for &(k, mat) in &pg.order { + let q = &tris[k as usize]; + let (e1, e2) = (q.p[1] - q.p[0], q.p[2] - q.p[0]); + // The flag word keeps TRI_DYNAMIC: the kernel takes a deforming mesh's motion from `prev_verts`. + v.extend_from_slice(&[[q.p[0].x, q.p[0].y, q.p[0].z, f32::from_bits(scene::TRI_DYNAMIC)], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + at.push(scene::tri_attr(q.p, q.n, q.uv, q.tangent, mat)); + b.extend(q.p.map(|p| p.to_array())); + t.push(q.p.map(glam::Vec3A::from)); + } + Ok(Some((v, at, b, t))) + }) + .collect(); + self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; + let t_up = Instant::now(); + // Motion vectors: the GPU still holds the previous pose of these meshes (and of the last re-skin's, if others): + // keep it in `prev_verts` before uploading the new one. + let df = self.sc.dynamic.0; + let (mut lo, mut hi) = (usize::MAX, 0usize); + for (g, _) in &jobs { + let pg = &self.sc.tables.retained.pose_groups[*g as usize]; + let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as usize - df; + (lo, hi) = (lo.min(k), hi.max(k + pg.order.len())); + } + self.keep_prev(self.pose_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi)))); + self.pose_span = Some((lo, hi)); + self.pose_reposed = true; + let (q, up) = (&self.g.queue, &self.sc.up); + let accel = up.accel.as_ref().unwrap(); + let mut meshes = Vec::with_capacity(jobs.len()); + for ((g, a), res) in jobs.into_iter().zip(skinned) { + let Some((v, at, b, t)) = res? else { return Ok(false) }; + let pg = &mut self.sc.tables.retained.pose_groups[g as usize]; + // The mesh's first triangle in the GPU order: any member's record has it. + let k = self.sc.gpu_instances[pg.members[0].1 as usize].info[0] as u64; + q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&v)); + q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&at)); + q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&b)); + let m = self.sc.meshes[pg.mesh as usize]; + self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].copy_from_slice(&t); + pg.animation = a; + meshes.push(pg.mesh); + } + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_meshes(accel, &meshes, &mut e); + let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + } + let t_blas = Instant::now(); + q.submit([e.finish()]); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; + } + self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; + Ok(true) + } + + /// Re-skins posed models and re-transforms movable objects in place (hardware RT): their triangles, shading + /// attributes and dynamic BLASes, then the TLAS. Their directly seen surfaces keep a short history for a moment, + /// like a power edit (no per-object motion vectors). False when it cannot (not dynamic at build, no hardware RT, a + /// different triangle count): the caller rebuilds. + pub(super) fn repose(&mut self, world: &World, ids: &std::collections::HashSet) -> Result { + if self.sc.up.accel.is_none() || ids.iter().any(|id| !self.sc.tables.retained.dynamic.contains_key(id)) { + return Ok(false); + } + let models = std::mem::take(&mut self.sc.tables.retained.dynamic); + let t_skin = Instant::now(); + let res = self.reskin(world, ids, &models); + self.cpu_skin_ms += t_skin.elapsed().as_secs_f32() * 1000.0; + self.sc.tables.retained.dynamic = models; + let Some((lo, hi)) = res? else { return Ok(false) }; + self.emitters_moved = true; + let t_up = Instant::now(); + // Motion vectors: the GPU still holds the previous frame's vertices for this span (and for last update's span, + // if different): keep them as `prev_verts` before uploading the new ones. + let both = self.prev_span.map_or((lo, hi), |(a, b)| (a.min(lo), b.max(hi))); + self.keep_prev(both); + self.prev_span = Some((lo, hi)); + self.reposed = true; + // Upload the touched span of the dynamic tail, then rebuild the dynamic BLASes and the TLAS. + let (q, up, (first, verts, attrs, blas)) = (&self.g.queue, &self.sc.up, &self.sc.dynamic); + let accel = up.accel.as_ref().unwrap(); + let k = (first + lo) as u64; + q.write_buffer(&up.verts, k * 48, bytemuck::cast_slice(&verts[lo * 3..hi * 3])); + q.write_buffer(&up.attrs, k * 96, bytemuck::cast_slice(&attrs[lo..hi])); + q.write_buffer(&accel.verts, k * 36, bytemuck::cast_slice(&blas[lo * 3..hi * 3])); + q.write_buffer(&up.objects, 0, bytemuck::cast_slice(&self.sc.tables.objects)); + let mut e = self.g.device.create_command_encoder(&Default::default()); + self.g.rebuild_dynamic(accel, &mut e); + let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + } + let t_blas = Instant::now(); + q.submit([e.finish()]); + if profile_blas { + let _ = self.g.device.poll(wgpu::PollType::wait_indefinitely()); + self.gpu_blas_ms += t_blas.elapsed().as_secs_f32() * 1000.0; + } + self.cpu_upload_ms += t_up.elapsed().as_secs_f32() * 1000.0; + Ok(true) + } + + /// now (the CPU copy of the dynamic tail), its nodes and the root uploaded (`ltree::LightTree::refit_moving`). + pub(super) fn refit_moving_tree(&mut self) { + let first = self.sc.dynamic.0; + let (verts, tables) = (&self.sc.dynamic.1, &mut self.sc.tables); + let tris = &tables.emitter_tris; + let Some(t) = tables.light_tree.as_mut().filter(|t| t.moving_count > 0) else { return }; + t.refit_moving(&|k| { + let r = (tris[k].tri as usize - first) * 3; + crate::ltree::tri_vertices(&verts[r..r + 3]) + }); + let (q, up, off) = (&self.g.queue, &self.sc.up, self.sc.base.ltree[0].to_bits() as u64); + q.write_buffer(&up.emitters, off * 16, bytemuck::cast_slice(&t.nodes[..1])); + q.write_buffer(&up.emitters, (off + 2) * 16, bytemuck::cast_slice(&t.nodes[2..3])); + q.write_buffer(&up.emitters, (off + t.moving_nodes as u64) * 16, bytemuck::cast_slice(&t.nodes[t.moving_nodes..])); + } + + /// Copies the GPU's current vertices of a dynamic-tail span into `prev_verts` (submitted before any write queued + /// after it, so it reads what the last frame traced). + pub(super) fn keep_prev(&self, (lo, hi): (usize, usize)) { + let up = &self.sc.up; + let first = self.sc.dynamic.0; + let mut e = self.g.device.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(&up.verts, ((first + lo) * 48) as u64, &up.prev_verts, (lo * 48) as u64, ((hi - lo) * 48) as u64); + self.g.queue.submit([e.finish()]); + } + + /// Skins / transforms the objects into the CPU copy of the dynamic tail; the touched span (relative to the tail), or + /// None when one cannot be updated in place. + pub(super) fn reskin(&mut self, world: &World, ids: &std::collections::HashSet, models: &std::collections::HashMap) -> Result> { + use rayon::prelude::*; + if ids.iter().any(|id| !world.shown(*id)) { + return Ok(None); + } + // Objects whose triangles form one run of the dynamic tail (one traversal group) are skinned / re-transformed in + // parallel, one task per object, straight into their own spans of the tail and of the CPU triangles (one core + // took 17 ms for the crowd scene's 150 walkers, docs/ROADMAP.md § 0): ordered by span, the objects split the + // arrays into disjoint slices. The others (e.g. opaque and cutout materials in different groups) one by one. + let run = |d: &scene::DynamicModel| !d.finals.is_empty() && d.finals.iter().enumerate().all(|(j, &f)| f == d.finals[0] + j as u32); + let (mut runs, scattered): (Vec, Vec) = ids.iter().copied().partition(|id| run(&models[id])); + runs.sort_by_key(|id| models[id].finals[0]); + let assets = &self.assets; + let sc = &mut self.sc; + let (first, verts, attrs, blas) = &mut sc.dynamic; + let first = *first; + let (mut lo, mut hi) = (usize::MAX, 0); + // The CPU triangles in build order: each object's slice. + let mut by_build = runs.clone(); + by_build.sort_by_key(|id| models[id].first); + let mut tris_of: std::collections::HashMap = std::collections::HashMap::with_capacity(runs.len()); + let (mut tr, mut toff): (&mut [[glam::Vec3A; 3]], usize) = (&mut sc.tris[..], 0); + for id in &by_build { + let d = &models[id]; + let (t0, n) = (d.first as usize, d.finals.len()); + let (_, r) = std::mem::take(&mut tr).split_at_mut(t0 - toff); + let (t, r) = r.split_at_mut(n); + tr = r; + toff = t0 + n; + tris_of.insert(*id, t); + } + let (mut vr, mut ar, mut br): (&mut [[f32; 4]], &mut [scene::GpuTriAttr], &mut [[f32; 3]]) = (&mut verts[..], &mut attrs[..], &mut blas[..]); + let mut voff = 0usize; + let mut jobs = Vec::with_capacity(runs.len()); + for id in &runs { + let d = &models[id]; + let (k0, n) = (d.finals[0] as usize - first, d.finals.len()); + let (_, r) = std::mem::take(&mut vr).split_at_mut(k0 * 3 - voff * 3); + let (v, r) = r.split_at_mut(n * 3); + vr = r; + let (_, r) = std::mem::take(&mut ar).split_at_mut(k0 - voff); + let (a, r) = r.split_at_mut(n); + ar = r; + let (_, r) = std::mem::take(&mut br).split_at_mut(k0 * 3 - voff * 3); + let (b, r) = r.split_at_mut(n * 3); + br = r; + voff = k0 + n; + (lo, hi) = (lo.min(k0), hi.max(k0 + n)); + jobs.push((*id, v, a, b, tris_of.remove(id).unwrap())); + } + let ok: Vec> = jobs + .into_par_iter() + .map(|(id, v, a, b, t)| { + let d = &models[&id]; + let Some(tris) = scene::dynamic_tris(world, id, d, assets)? else { return Ok(false) }; + if tris.len() != d.finals.len() { + return Ok(false); + } + for (j, (p, at)) in tris.into_iter().enumerate() { + let (e1, e2) = (p[1] - p[0], p[2] - p[0]); + v[j * 3..j * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + // (x.w: an emitter's light-tree code, which stays with its triangle.) + a[j] = scene::GpuTriAttr { x: [at.x[0], f32::from_bits(d.object), at.x[2], a[j].x[3]], ..at }; + b[j * 3..j * 3 + 3].copy_from_slice(&p.map(|q| q.to_array())); + t[j] = p.map(glam::Vec3A::from); + } + Ok(true) + }) + .collect(); + for r in ok { + if !r? { + return Ok(None); + } + } + for id in &scattered { + let d = &models[id]; + let Some(tris) = scene::dynamic_tris(world, *id, d, assets)? else { return Ok(None) }; + if tris.len() != d.finals.len() { + return Ok(None); + } + for (j, (p, a)) in tris.into_iter().enumerate() { + let k = d.finals[j] as usize - first; + let (e1, e2) = (p[1] - p[0], p[2] - p[0]); + verts[k * 3..k * 3 + 3].copy_from_slice(&[[p[0].x, p[0].y, p[0].z, f32::from_bits(d.flags[j])], [e1.x, e1.y, e1.z, 0.0], [e2.x, e2.y, e2.z, 0.0]]); + attrs[k] = scene::GpuTriAttr { x: [a.x[0], f32::from_bits(d.object), a.x[2], attrs[k].x[3]], ..a }; + blas[k * 3..k * 3 + 3].copy_from_slice(&p.map(|v| v.to_array())); + sc.tris[(d.first + j as u32) as usize] = p.map(glam::Vec3A::from); + (lo, hi) = (lo.min(k), hi.max(k + 1)); + } + } + for id in ids { + let d = &models[id]; + // Pivot (material functions' object position). Motion vectors carry its history. + let pivot = world.world_matrix(*id).transform_point3(glam::Vec3::ZERO); + let st = sc.tables.stride; + let o = &mut sc.tables.objects[d.object as usize * st]; + (o[0], o[1], o[2]) = (pivot.x, pivot.y, pivot.z); + } + Ok((lo < hi).then_some((lo, hi))) + } + + /// Rebuilds the GPU scene from the world. + /// Applies geometry edits in place (`Changes::geometries`, `added`, `removed`; objects with their own BLAS: + /// procedural meshes and `own_blas` meshes): each touched object's data is written into the edit room + /// (gpu.rs `EditRoom`) as the build would make it (`scene::proc_object`, `scene::own_mesh_tris`), new BLASes are + /// built for new geometry only, its TLAS instance is set (cleared for a removed or hidden object) and the TLAS + /// rebuilt, one submit. Nothing is moved: what an edit replaces stays behind until the next rebuild. Err: why it + /// cannot (software traversal, the room is full, a kernel feature the scene lacks, an emitting / glass / function + /// material, ...); the caller rebuilds the scene then. `THREERT_EDIT_REBUILD=1` always rebuilds (A/B). + pub(super) fn edit_geometry(&mut self, world: &World, ch: &crate::world::Changes) -> std::result::Result<(), String> { + use crate::world::Kind; + if std::env::var("THREERT_EDIT_REBUILD").is_ok_and(|v| v == "1") { + return Err("THREERT_EDIT_REBUILD=1".into()); + } + let LoadedScene { up, tables, edit, meshes, pool_tris, instances, gpu_instances, procs, proc_of, proc_first, .. } = &mut self.sc; + let (g, assets) = (&self.g, &self.assets); + let features = up.kernel.features; + let grow = Grow { g: &self.g, ok: up.particles.is_none() && up.gpu_particles.is_none() }; + // (Hardware traversal's TLAS and BLASes, or software traversal's units: `gpu::Top`.) + let Some(mut top) = gpu::Top::of(&mut up.accel, &mut up.units) else { + // Software traversal without units. With units on, that is a scene without an instance yet: the first + // one brings the unit walk into the kernel (F_UNITS), as hardware traversal's first brings F_INST. + let first = crate::units_on(true) && ch.added.iter().any(|id| matches!(world.objects.get(id).map(|o| &o.kind), Some(Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. }))); + return Err(if first { "the scene's first instanced mesh (kernel feature)" } else { "software traversal" }.into()); + }; + // The objects to place again: those added, and those whose geometry was replaced. + let mut todo: std::collections::BTreeSet = ch.added.clone(); + if !ch.geometries.is_empty() { + for (id, o) in &world.objects { + match &o.kind { + Kind::Procedural { geometry, .. } | Kind::Mesh { geometry, own_blas: true, .. } if ch.geometries.contains(geometry) => { + todo.insert(*id); + } + Kind::Mesh { geometry, .. } if ch.geometries.contains(geometry) => return Err("a flattened mesh's geometry changed".into()), + // (One added this commit is placed with its geometry as it is.) + Kind::Instanced { geometry, .. } if ch.geometries.contains(geometry) && !ch.added.contains(id) => return Err("an instanced mesh's geometry changed".into()), + _ => {} + } + } + } + let (inst_base, proc_base, proc_first) = (top.inst_base(), top.proc_base(*proc_first), *proc_first); + let slot_of = |id: u32, edit: &EditState, tables: &tables::Tables, proc_of: &std::collections::HashMap| -> Option<(usize, usize)> { + if let Some(&v) = edit.placed.get(&id) { + Some(v) + } else if let Some(&k) = tables.retained.own_instance.get(&id) { + Some((inst_base + k as usize, k as usize)) + } else { + proc_of.get(&id).map(|&j| (proc_base + j, proc_first + j)) + } + }; + let q = &g.queue; + let mut e = g.device.create_command_encoder(&Default::default()); + // An emitter mesh's entries go (area 0: their weight; the light tree keeps its shape) when it is removed, hidden + // or placed again. + let mut emitters_changed = false; + let release = |id: u32, tables: &mut tables::Tables, meshes: &[scene::InstMesh], pool_first: usize| -> bool { + let Some(k) = tables.retained.emitter_mesh.remove(&id) else { return false }; + let im = meshes[k as usize]; + let (lo, hi) = ((pool_first + im.first as usize) as u32, (pool_first + (im.first + im.count) as usize) as u32); + for e in tables.emitter_tris.iter_mut().filter(|e| (lo..hi).contains(&e.tri)) { + e.area = 0.0; + } + true + }; + for id in &ch.removed { + // An instanced mesh an edit added: its instances, records and entries. + if let Some((r0, cap, o0)) = edit.instanced.remove(id) { + for r in r0..r0 + cap { + top.clear(inst_base + r); + if let Some(i) = instances.get_mut(r) { + i.hidden = true; + } + } + edit.records.give(r0, cap); + edit.objects.give(o0, cap); + tables.retained.instanced.remove(id); + tables.retained.objects.remove(id); + if let Some(k) = edit.mesh_of.remove(id) { + drop_mesh_user(k, edit, tables, meshes); + } + continue; + } + // An instanced mesh the build placed: its instances out (records and entries stay until the next rebuild). + if let Some(place) = tables.retained.instanced.get(id).filter(|p| !p.procedural) { + for r in place.first as usize..(place.first + place.capacity) as usize { + top.clear(inst_base + r); + if let Some(i) = instances.get_mut(r) { + i.hidden = true; + } + } + tables.retained.instanced.remove(id); + tables.retained.objects.remove(id); + continue; + } + emitters_changed |= release(*id, tables, meshes, edit.pool_first); + let at = slot_of(*id, edit, tables, proc_of); + // Placed by the build otherwise (a model, a procedural instanced mesh): a rebuild takes it out. + if at.is_none() && tables.retained.objects.contains_key(id) { + return Err("an object the build placed otherwise (a model, a procedural instanced mesh) removed".into()); + } + if let Some((slot, _)) = at { + top.clear(slot); + } + unpick(*id, edit, tables, instances, procs, proc_of); + // What it held goes back to the room (the build's records and entries stay until the next rebuild). + if let Some((_, r)) = edit.placed.remove(id) { + edit.records.give(r, 1); + } + if let Some(k) = edit.mesh_of.remove(id) { + drop_mesh_user(k, edit, tables, meshes); + } + tables.retained.own_instance.remove(id); + tables.retained.instance_of.remove(id); + if let Some(k) = tables.retained.objects.remove(id).filter(|&k| k as usize >= edit.base.2) { + edit.objects.give(k as usize, 1); + } + proc_of.remove(id); + } + let mut new_meshes = vec![]; + let mut fresh_blases = vec![]; + let mut pending_procs = vec![]; + let mut split = EditSplit::default(); + let (mut w_cdf, mut w_verts, mut w_attrs, mut w_blas, mut w_records, mut w_objects) = (Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default(), Gather::default()); + // Vertex colours (F_VCOLOR), in `prev_verts` after the previous vertices. + let mut w_colors = Gather::default(); + let vcolor_base = up.vcolor_base; + // (geometry, material) -> the mesh built in this batch (objects sharing a replaced geometry share it again). + let mut fresh: std::collections::HashMap<(u32, u32), u32> = Default::default(); + let mask = |shadow: bool| if shadow { 1 } else { 2 }; + for id in todo { + let Some(o) = world.objects.get(&id) else { continue }; + let at = slot_of(id, edit, tables, proc_of); + emitters_changed |= release(id, tables, meshes, edit.pool_first); + if !world.shown(id) { + if let Some((slot, _)) = at { + top.clear(slot); + } + if edit.instanced.contains_key(&id) { + return Err("an instanced mesh added in place hidden".into()); + } + unpick(id, edit, tables, instances, procs, proc_of); + if let Some(k) = edit.mesh_of.remove(&id) { + drop_mesh_user(k, edit, tables, meshes); + } + continue; + } + // Drawn but not by a TLAS instance of its own: the build flattened it (movable, or a material instances skip). + if at.is_none() && tables.retained.objects.contains_key(&id) { + return Err("an object the build flattened".into()); + } + let m = world.world_matrix(id); + let object = match tables.retained.objects.get(&id) { + Some(&k) => k as usize, + None => match edit.objects.take(1) { + Some(k) => k, + None if grow.objects(&mut edit.objects, 1, &mut up.objects, tables.stride) => { + edit.grows += 1; + edit.rebind = true; + edit.objects.take(1).ok_or("edit room: objects")? + } + None => return Err("edit room: objects (particles, or the device's limits)".into()), + }, + }; + // Where its TLAS instance places it: the node, or identity for an emitter mesh (world-space triangles). + let mut placement = m; + let (record, gpu, blas, entry) = match &o.kind { + Kind::Procedural { .. } => { + // (Software traversal walks procedural objects by their count and their own trees in the region: + // the build lays those out.) + if top.sw() { + return Err("a procedural object (software traversal)".into()); + } + if features & gpu::F_PROC == 0 { + return Err("the scene's first procedural object (kernel feature)".into()); + } + let Some(p) = scene::proc_object(world, id, m, object as u32, &tables.retained.materials, assets).map_err(|e| format!("{e:#}"))? else { + if let Some((slot, _)) = at { + top.clear(slot); + } + unpick(id, edit, tables, instances, procs, proc_of); + continue; + }; + placement = m; + // Room for `used` words: the region grown if it lacks it. + let mut words_left = |used: usize, edit: &mut EditState| { + if used <= edit.word_cap { + return true; + } + let ok = grow.words(&mut edit.word_cap, used, &mut up.cdf, up.cdf_words); + if ok { + edit.grows += 1; + edit.rebind = true; + } + ok + }; + let boxes = match edit.blobs.get(&p.blob.hash) { + Some(&w) => w, + None => { + let w = edit.word; + if !words_left(w + p.blob.boxes.len() + p.blob.data.len(), edit) { + return Err("edit room: procedural words".into()); + } + let at = |word: usize| ((up.cdf_words + word) * 4) as u64; + w_cdf.put(at(w), bytemuck::cast_slice(&p.blob.boxes)); + w_cdf.put(at(w + p.blob.boxes.len()), bytemuck::cast_slice(&p.blob.data)); + edit.word += p.blob.boxes.len() + p.blob.data.len(); + edit.blobs.insert(p.blob.hash, w); + w + } + }; + let header = edit.word; + let words = p.header(header, boxes, (0, 0)); + if !words_left(header + words.len(), edit) { + return Err("edit room: procedural words".into()); + } + w_cdf.put(((up.cdf_words + header) * 4) as u64, bytemuck::cast_slice(&words)); + edit.word += words.len(); + let blas = g.proc_blas(&p.blob, &mut pending_procs, &mut fresh_blases); + let entry = p.object_entry(o); + let gpu = p.gpu(header as u32); + let record = at.map(|(_, r)| r); + match proc_of.get(&id) { + Some(&j) => procs[j] = p, + None => { + edit.added.insert(id, Picked::Proc(p)); + } + } + (record, gpu, Placed::Proc(blas), entry) + } + Kind::Mesh { materials, .. } if materials.len() > 1 => return Err("a mesh with several materials".into()), + Kind::Model { uri, nodes, animation, functions } => { + // A static model already in the scene: another instance of its mesh (one not in it brings materials + // and textures, which load with a rebuild). + if animation.is_some() { + return Err("an animated model".into()); + } + if !functions.is_empty() { + return Err("a model with material functions (its materials load with a rebuild)".into()); + } + if features & gpu::F_INST == 0 || m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { + return Err("a model the build would flatten (mirrored, instancing off, or the scene's first instance)".into()); + } + let mesh = *tables.retained.model_mesh.get(&(uri.clone(), nodes.clone())).ok_or("a model not in the scene (its materials and textures load with a rebuild)")?; + let im = meshes[mesh as usize]; + if !top.has_mesh(mesh) { + return Err("a model whose mesh is no unit (it deforms; software traversal flattens it)".into()); + } + *edit.users.entry(mesh).or_default() += 1; + if let Some(k) = edit.mesh_of.insert(id, mesh) { + drop_mesh_user(k, edit, tables, meshes); + } + let inst = scene::Instance { mesh, world: m, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; + let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); + let (lo, hi) = im.placed_bounds(m); + let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); + edit.added.insert(id, Picked::Mesh(inst)); + (at.map(|(_, r)| r), gpu, Placed::Mesh(mesh), entry) + } + Kind::Mesh { own_blas: true, .. } | Kind::Instanced { procedural: false, .. } => { + // An own-BLAS mesh, or (added in place) an instanced mesh: their mesh made alike. + let (geometry, material, capacity) = match &o.kind { + Kind::Mesh { geometry, material, .. } => (*geometry, *material, None), + Kind::Instanced { geometry, materials, capacity, .. } => (*geometry, *materials.first().ok_or("an instanced mesh without a material")?, Some(*capacity as usize)), + _ => unreachable!(), + }; + let (geometry, material) = (&geometry, &material); + if features & gpu::F_INST == 0 { + return Err("the scene's first instanced mesh (kernel feature)".into()); + } + // What the build flattens instead (scene.rs: mirrored, or instancing off). + if m.determinant() <= 0.0 || std::env::var("THREERT_INSTANCING").is_ok_and(|v| v == "0") { + return Err("a mirrored mesh, or instancing off (the build flattens it)".into()); + } + let mat = *tables.retained.materials.get(material).ok_or("unknown material")?; + let gm = tables.materials[mat as usize]; + // An emitting material (no glass, no function): an emitter mesh, its triangles in world space and its + // own (never shared), its emitter entries added (scene.rs; ROADMAP § 6 step 3). + let emits = scene::emits_only(&gm); + let class = if emits { u32::from(gm.uv2[3] > 0.0) } else { scene::material_class(&gm) }; + if class == 2 || (emits && capacity.is_some()) { + return Err("a glass or function material (an instanced mesh: emitting too)".into()); + } + // (An emitter mesh's triangles are in world space: one a body moves is flattened by the build.) + if emits && o.driven { + return Err("an emitting mesh a rigid body drives (the build flattens it)".into()); + } + if class == 1 && features & gpu::F_MASKED == 0 { + return Err("the scene's first cutout (kernel feature)".into()); + } + let colored = world.materials.get(material).is_some_and(|m| m.vertex_colors) && world.geometries.get(geometry).is_some_and(|g| !g.colors.is_empty()); + if colored && vcolor_base.is_none() { + return Err("the scene's first vertex colours (kernel feature)".into()); + } + let key = if emits { (u32::MAX - id, mat) } else { (*geometry, mat) }; + let kept = (!emits && !ch.geometries.contains(geometry)).then(|| tables.retained.own_mesh.get(&key).copied()).flatten(); + let mesh = match fresh.get(&key).copied().or(kept) { + Some(k) => k, + None => { + let geo = world.geometries.get(geometry).ok_or("unknown geometry")?; + let t0 = Instant::now(); + let (mut t, mut a) = if emits { scene::emitter_mesh_tris(geo, m, mat, object as u32) } else { scene::own_mesh_tris(geo, mat) }; + if t.is_empty() { + if let Some((slot, _)) = at { + top.clear(slot); + } + unpick(id, edit, tables, instances, procs, proc_of); + continue; + } + // Software traversal: the mesh's tree, and its triangles (their attributes, further down their + // colours) in the tree's order, as `prepare` lays a unit's mesh out. + let tree = match &top { + gpu::Top::Hw(_) => None, + gpu::Top::Sw(_) => { + let tree = crate::units::mesh_tree(&t)?; + t = tree.order.iter().map(|&i| t[i as usize]).collect(); + a = tree.order.iter().map(|&i| a[i as usize]).collect(); + Some(tree) + } + }; + // (A unit's cutout triangles carry their flag; hardware traversal has them in a geometry apart.) + let flags = f32::from_bits(if tree.is_some() && class == 1 { scene::TRI_MASKED } else { 0 }); + // As `prepare` lays the pool out: (a, flags), e1, e2; attributes; the BLAS's vertices. + let verts: Vec<[f32; 4]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z, flags], [b.x - a.x, b.y - a.y, b.z - a.z, 0.0], [c.x - a.x, c.y - a.y, c.z - a.z, 0.0]]).collect(); + let blas_verts: Vec<[f32; 3]> = t.iter().flat_map(|&[a, b, c]| [[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z]]).collect(); + split.tris += t0.elapsed().as_secs_f64() * 1e3; + // The same triangle count as the mesh it replaces: rewritten where it is, its BLAS rebuilt (no + // room used; a geometry that moves every frame, three.js's dynamic draw usage). Otherwise new + // triangles in the room and a new BLAS. + let same = tables.retained.own_mesh.get(&key).copied().filter(|&k| meshes[k as usize].count as usize == t.len() && meshes[k as usize].emits == emits); + let (k, first, im) = match same { + Some(k) => { + let im = meshes[k as usize]; + split.deformed += 1; + // (Its tree again: the records of the objects that draw it, all placed again by + // this edit, take the root it has now.) + if let (gpu::Top::Sw(units), Some(tree)) = (&mut top, &tree) { + if let Some(old) = units.drop_tree(k) { + g.fill_tree(&up.bg_parts.nodes, old); + } + let root = units.add_tree(k, tree).ok_or("edit room: tree nodes")?; + g.write_tree(&up.bg_parts.nodes, root, tree); + } + (k, edit.pool_first + im.first as usize, im) + } + None => { + // A hole that fits, else the room's end (grown if it lacks room). + let first = match edit.tris.take(t.len()) { + Some(f) => f, + // (Software traversal's room does not grow: the rebuild makes a larger one.) + None if match &mut top { gpu::Top::Hw(accel) => grow.tris(&mut edit.tris, t.len(), &mut up.verts, &mut up.attrs, &mut up.prev_verts, vcolor_base, accel), gpu::Top::Sw(_) => false } => { + edit.grows += 1; + edit.rebind = true; + edit.tris.take(t.len()).ok_or("edit room: triangles")? + } + None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: triangles (software traversal's does not grow)" } else { "edit room: triangles (particles, or the device's limits)" }.into()), + }; + let mut im = scene::own_inst_mesh(&t, (first - edit.pool_first) as u32, class); + im.emits = emits; + let t1 = Instant::now(); + // A freed mesh's slot, else a new one. + let dead = edit.dead_meshes.pop(); + let k = match &mut top { + gpu::Top::Hw(accel) => match dead { + Some(k) => { + g.set_mesh(accel, k, first, im.count, im.opaque); + k + } + None => g.add_mesh(accel, first, im.count, im.opaque), + }, + // Its tree into the mesh trees' room (a freed mesh's slot gives its tree back first). + gpu::Top::Sw(units) => { + let k = dead.unwrap_or(meshes.len() as u32); + let tree = tree.as_ref().expect("software traversal made the tree"); + if let Some(old) = units.drop_tree(k) { + g.fill_tree(&up.bg_parts.nodes, old); + } + let Some(root) = units.add_tree(k, tree) else { + edit.dead_meshes.extend(dead); + edit.tris.give(first, t.len()); + return Err("edit room: tree nodes".into()); + }; + g.write_tree(&up.bg_parts.nodes, root, tree); + k + } + }; + if k as usize == meshes.len() { + meshes.push(im); + } else { + meshes[k as usize] = im; + } + edit.managed.insert(k); + split.blas += t1.elapsed().as_secs_f64() * 1e3; + split.added += 1; + (k, first, im) + } + }; + let t2 = Instant::now(); + w_verts.put((first * 48) as u64, bytemuck::cast_slice(&verts)); + w_attrs.put((first * std::mem::size_of::()) as u64, bytemuck::cast_slice(&a)); + if !top.sw() { + w_blas.put((first * 36) as u64, bytemuck::cast_slice(&blas_verts)); + } + if let Some(base) = vcolor_base { + // White where it has none: the room's slots may hold an earlier mesh's. + let mut c = if colored { scene::tri_colors(geo, false, None) } else { vec![scene::WHITE; t.len()] }; + if let (Some(tree), true) = (&tree, colored) { + c = tree.order.iter().map(|&i| c[i as usize]).collect(); + } + w_colors.put(base + (first * 12) as u64, bytemuck::cast_slice(&c)); + } + split.upload += t2.elapsed().as_secs_f64() * 1e3; + let rel = first - edit.pool_first; + if pool_tris.len() < rel + t.len() { + pool_tris.resize(rel + t.len(), [glam::Vec3A::ZERO; 3]); + } + pool_tris[rel..rel + t.len()].copy_from_slice(&t); + if emits { + // Its emitter entries, in the power-share part of the CDF (path code 0). Rewritten where + // it was (a mesh that moves every frame: flying pieces), it takes back the entries it had + // there (`release` left them at area 0), so the CDF does not grow frame by frame; those + // in the light tree stay out (their triangles now carry code 0). + let range = first as u32..(first + t.len()) as u32; + let mut free: Vec = if same.is_some() { tables.emitter_tris.iter().enumerate().filter(|(_, e)| !e.in_tree && range.contains(&e.tri)).map(|(i, _)| i).rev().collect() } else { vec![] }; + for (j, tri) in t.iter().enumerate() { + let [ta, tb, tc] = *tri; + let area = 0.5 * (tb - ta).cross(tc - ta).length(); + if area > 0.0 { + let e = crate::EmitterTri { tri: (first + j) as u32, mat, obj: object as u32, area, c: ((ta + tb + tc) / 3.0).to_array(), moves: false, nominal: false, in_tree: false }; + match free.pop() { + Some(i) => tables.emitter_tris[i] = e, + None => tables.emitter_tris.push(e), + } + } + } + tables.retained.emitter_mesh.insert(id, k); + tables.emissive.insert(mat); + emitters_changed = true; + } + let _ = im; + new_meshes.push(k); + fresh.insert(key, k); + tables.retained.own_mesh.insert(key, k); + k + } + }; + let im = meshes[mesh as usize]; + // It draws `mesh` now (and no longer the one it drew, if another). + *edit.users.entry(mesh).or_default() += 1; + if let Some(k) = edit.mesh_of.insert(id, mesh) { + drop_mesh_user(k, edit, tables, meshes); + } + if let Some(cap) = capacity { + // An instanced mesh added in place: `cap` records (their TLAS instances `inst_base` + them) and + // object entries, contiguous, as the build places one (`update_instanced` then rewrites them). + let empty = crate::world::InstanceTable::default(); + let t = world.instanced.get(&id).unwrap_or(&empty); + if t.colors.is_some() && features & gpu::F_TINT == 0 { + return Err("the scene's first instance colours (kernel feature)".into()); + } + let placed: Vec<(glam::Mat4, bool)> = (0..cap).map(|i| scene::instance_world(m, t, i)).collect(); + if placed.iter().any(|(wm, hidden)| !hidden && wm.determinant() < 0.0) { + return Err("a mirrored instance (the build flattens them)".into()); + } + if !tables.retained.objects.contains_key(&id) { + edit.objects.give(object, 1); + } + let r0 = match edit.records.take(cap) { + Some(r) => r, + None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, cap, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { + edit.grows += 1; + edit.rebind = true; + edit.records.take(cap).ok_or("edit room: records")? + } + None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), + }; + let o0 = match edit.objects.take(cap) { + Some(k) => k, + None if grow.objects(&mut edit.objects, cap, &mut up.objects, tables.stride) => { + edit.grows += 1; + edit.rebind = true; + edit.objects.take(cap).ok_or("edit room: objects")? + } + None => return Err("edit room: objects (particles, or the device's limits)".into()), + }; + let (st, size) = (tables.stride, std::mem::size_of::()); + if instances.len() < r0 + cap { + let filler = scene::Instance { mesh, world: glam::Mat4::IDENTITY, object: 0, cast_shadow: false, name: String::new(), sub: None, hidden: true }; + instances.resize(r0 + cap, filler); + } + if gpu_instances.len() < r0 + cap { + gpu_instances.resize(r0 + cap, scene::GpuInstance::default()); + } + if tables.objects.len() < (o0 + cap) * st { + tables.objects.resize((o0 + cap) * st, [0.0; 4]); + } + for (i, &(wm, hidden)) in placed.iter().enumerate() { + let inst = scene::Instance { mesh, world: wm, object: (o0 + i) as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: Some(i as u32), hidden }; + let rec = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); + w_records.put(((r0 + i) * size) as u64, bytemuck::bytes_of(&rec)); + gpu_instances[r0 + i] = rec; + top.place(inst_base + r0 + i, mesh, wm, gpu::INSTANCE_BIT | (r0 + i) as u32, o.cast_shadow, hidden); + let (lo, hi) = im.placed_bounds(wm); + let entry = scene::object_row(wm.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, t.custom(i, o.custom), t.color(i)); + w_objects.put(((o0 + i) * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); + tables.objects[(o0 + i) * st..(o0 + i + 1) * st].copy_from_slice(&entry[..st]); + instances[r0 + i] = inst; + } + tables.retained.instanced.insert(id, scene::InstancedPlace { first: r0 as u32, capacity: cap as u32, object: o0 as u32, procedural: false, drawn: t.count }); + tables.retained.objects.insert(id, o0 as u32); + edit.instanced.insert(id, (r0, cap, o0)); + continue; + } + if emits { + placement = glam::Mat4::IDENTITY; + } + let inst = scene::Instance { mesh, world: placement, object: object as u32, cast_shadow: o.cast_shadow, name: o.name.clone(), sub: None, hidden: false }; + let gpu = top.record(inst.gpu((edit.pool_first as u32) + im.first, im.opaque), mesh, o.cast_shadow); + let (lo, hi) = im.placed_bounds(placement); + let entry = scene::object_row(m.transform_point3(glam::Vec3::ZERO), o.power, hi - lo, o.custom, [1.0; 3]); + let record = at.map(|(_, r)| r); + match record.filter(|&r| r < edit.inst_at_load) { + Some(r) => instances[r] = inst, + None => { + edit.added.insert(id, Picked::Mesh(inst)); + } + } + (record, gpu, Placed::Mesh(mesh), entry) + } + _ => return Err("not an object with its own BLAS".into()), + }; + // Its record, TLAS instance and object entry: where it had them, or new ones from the room. + let (slot, record) = match (at, record) { + (Some((slot, _)), Some(r)) => (slot, r), + _ => { + let r = match edit.records.take(1) { + Some(r) => r, + None if match &mut top { gpu::Top::Hw(accel) => grow.records(&mut edit.records, 1, &mut up.instances, accel), gpu::Top::Sw(_) => false } => { + edit.grows += 1; + edit.rebind = true; + edit.records.take(1).ok_or("edit room: records")? + } + None => return Err(if matches!(top, gpu::Top::Sw(_)) { "edit room: records (software traversal's does not grow)" } else { "edit room: records (particles, or the device's limits)" }.into()), + }; + let v = (inst_base + r, r); + edit.placed.insert(id, v); + v + } + }; + w_records.put((record * std::mem::size_of::()) as u64, bytemuck::bytes_of(&gpu)); + if record >= gpu_instances.len() { + gpu_instances.resize(record + 1, scene::GpuInstance::default()); + } + gpu_instances[record] = gpu; + let custom = if matches!(o.kind, Kind::Procedural { .. }) { record as u32 } else { gpu::INSTANCE_BIT | record as u32 }; + match blas { + Placed::Proc(blas) => top.place_blas(slot, &blas, placement, custom, mask(o.cast_shadow)), + Placed::Mesh(k) => top.place(slot, k, placement, custom, o.cast_shadow, false), + } + let st = tables.stride; + w_objects.put((object * st * 16) as u64, bytemuck::cast_slice(&entry[..st])); + let at = object * st; + if at >= tables.objects.len() { + tables.objects.resize(at + st, [0.0; 4]); + } + tables.objects[at..at + st].copy_from_slice(&entry[..st]); + tables.retained.objects.insert(id, object as u32); + } + // The gathered writes, merged, before the builds that read them. + let tw = Instant::now(); + w_cdf.flush(q, &up.cdf); + w_verts.flush(q, &up.verts); + w_attrs.flush(q, &up.attrs); + if let gpu::Top::Hw(accel) = &top { + w_blas.flush(q, &accel.verts); + } + w_colors.flush(q, &up.prev_verts); + w_records.flush(q, &up.instances); + w_objects.flush(q, &up.objects); + split.upload += tw.elapsed().as_secs_f64() * 1e3; + let t3 = Instant::now(); + new_meshes.sort_unstable(); + new_meshes.dedup(); + // The new meshes' and procedural BLASes and the TLAS in one build (`Gpu::build_meshes`). + match &mut top { + gpu::Top::Hw(accel) => g.build_edit(accel, &new_meshes, &pending_procs, &mut e, true), + // The trees are written; the meshes this edit freed give theirs back; the top tree over the units as they + // are now (one that cannot be made: the scene is rebuilt). + gpu::Top::Sw(units) => { + for &k in &edit.dead_meshes { + if let Some(old) = units.drop_tree(k) { + g.fill_tree(&up.bg_parts.nodes, old); + } + } + g.retop(units, &up.bg_parts.nodes)?; + } + } + split.blas += t3.elapsed().as_secs_f64() * 1e3; + // THREERT_PROFILE_BLAS=1: the BLAS / TLAS builds' GPU time (the queue drained before and after; frame log + // `blas`), as for the dynamic rebuilds. + let profile_blas = std::env::var("THREERT_PROFILE_BLAS").is_ok_and(|v| v == "1"); + if profile_blas { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + } + let t = Instant::now(); + q.submit([e.finish()]); + g.keep_proc_blases(fresh_blases); + // Buffers that grew: the scene bind group over them. + if std::mem::take(&mut edit.rebind) { + g.rebind_scene(up); + } + if profile_blas { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + self.gpu_blas_ms += t.elapsed().as_secs_f32() * 1000.0; + } + // The uniform tables: entries the edits placed past what a uniform buffer holds read as its last one. + if g.uniform_tables && !edit.uniform_said { + let notes = gpu::uniform_overflow([0, (edit.objects.end * tables.stride) as u64, edit.records.end as u64, 0]); + for note in ¬es { + eprintln!("threert: {note}"); + } + edit.uniform_said = !notes.is_empty(); + } + split.objects = ch.added.len() + ch.removed.len(); + self.edit_split.add(&split); + if emitters_changed { + self.write_emitters(); + } + Ok(()) + } + +} diff --git a/renderer/src/live/frame.rs b/renderer/src/live/frame.rs new file mode 100644 index 0000000..6e5d191 --- /dev/null +++ b/renderer/src/live/frame.rs @@ -0,0 +1,670 @@ +//! One frame of the live view (`LiveCore::render`): the trace, the live passes, the radiance cache's and the media's +//! training, the raster visibility pass, and the GPU timing and profile. + +use super::*; + +/// GPU time per frame from timestamp queries (start of the first trace pass .. end of the post pass), read back a few +/// frames later without waiting, so measuring never drains the pipeline. +pub(super) struct FrameTimer { + pub(super) queries: wgpu::QuerySet, + pub(super) resolve: wgpu::Buffer, + /// Per slot: readback buffer and its state (0 free, 1 mapping, 2 mapped). + pub(super) reads: Vec<(wgpu::Buffer, Arc)>, + /// Per slot: the frame it timed, and that frame's samples per pixel (its traces; only the first is timestamped). + pub(super) frames: [u32; TIMER_SLOTS], + pub(super) spps: [u32; TIMER_SLOTS], + pub(super) slot: usize, +} + +pub(super) const TIMER_SLOTS: usize = 4; + +/// Gap (ms) past which a slot's post pass is not its trace's frame's (`frame_gpu_ms`). +pub(super) const TIMER_GAP_MS: f32 = 2.0; + +/// A frame's GPU time (ms) from its slot's timestamps (trace, post, raster, particle pass pairs; zero: not run): from +/// its first pass (particles, raster or trace) to the end of its post pass. The post pass follows the trace's command +/// buffer within ~0.2 ms, after the frame's further samples (`spp` - 1 more traces, untimed, each about as long as +/// the first). In 2–3 % of the crowd scene's frames (Metal) a slot read the post pair of the frame that used the slot +/// next, 4 frames on: the span then read 70–80 ms while frames came every 16.7 ms. A post pass that begins more than +/// TIMER_GAP_MS past where those traces would end (twice their length) is not this frame's: the frame is counted as +/// its passes, the further traces at the first one's length. +pub(super) fn frame_gpu_ms(t: &[u64; 8], spp: u32, period_ns: f32) -> Option { + let start = [t[6], t[4], t[0]].into_iter().filter(|&v| v > 0).min()?; + if t[3] <= start { + return None; + } + let ms = |ticks: u64| ticks as f32 * period_ns * 1e-6; + let (trace, more) = (ms(t[1].saturating_sub(t[0])), spp.max(1) - 1); + if t[2] >= t[1] && ms(t[2] - t[1]) > TIMER_GAP_MS + 2.0 * more as f32 * trace { + return Some(ms(t[1] - start) + more as f32 * trace + ms(t[3].saturating_sub(t[2]))); + } + Some(ms(t[3] - start)) +} + +impl FrameTimer { + pub(super) fn new(d: &wgpu::Device) -> FrameTimer { + let n = TIMER_SLOTS as u64; + FrameTimer { + // Per slot: trace pass begin / end, post pass begin / end, raster pass begin / end, particle pass begin / end + // (Metal writes a pass's timestamps in pairs). + queries: d.create_query_set(&wgpu::QuerySetDescriptor { label: Some("frame timer"), ty: wgpu::QueryType::Timestamp, count: (n * 8) as u32 }), + resolve: d.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + reads: (0..n).map(|_| (d.create_buffer(&wgpu::BufferDescriptor { label: None, size: 64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::default())).collect(), + frames: [0; TIMER_SLOTS], + spps: [1; TIMER_SLOTS], + slot: 0, + } + } + + /// Latest finished measurement (frame ms, the frame it timed, its trace pass ms), and a free slot for this frame + /// (None: all readbacks still pending, skip). + pub(super) fn harvest(&mut self, period_ns: f32) -> (Option<(f32, u32, f32)>, Option) { + let mut latest: Option<(f32, u32, f32)> = None; + for (k, (buf, state)) in self.reads.iter().enumerate() { + if state.load(Ordering::Acquire) == 2 { + let t: [u64; 8] = bytemuck::pod_read_unaligned(&buf.slice(..).get_mapped_range().unwrap()[..64]); + buf.unmap(); + state.store(0, Ordering::Release); + let frame = self.frames[k]; + if let Some(ms) = frame_gpu_ms(&t, self.spps[k], period_ns).filter(|_| latest.is_none_or(|(_, f, _)| frame.wrapping_sub(f) as i32 > 0)) { + latest = Some((ms, frame, t[1].saturating_sub(t[0]) as f32 * period_ns * 1e-6)); + } + } + } + let slot = (0..TIMER_SLOTS).map(|k| (self.slot + k) % TIMER_SLOTS).find(|&k| self.reads[k].1.load(Ordering::Acquire) == 0); + if let Some(k) = slot { + self.slot = (k + 1) % TIMER_SLOTS; + } + (latest, slot) + } +} + +/// Primary visibility by rasterization (the decision experiment of docs/ROADMAP.md § 1): every triangle drawn into a +/// triangle-index target (+ depth) at the frame's sub-pixel jitter; the kernel starts each live path at that triangle +/// instead of tracing the camera ray. `live --raster` / THREERT_RASTER=1. +pub(super) struct Raster { + pub(super) pipeline: wgpu::RenderPipeline, + pub(super) layout: wgpu::BindGroupLayout, + pub(super) uniform: wgpu::Buffer, + pub(super) vis: wgpu::TextureView, + pub(super) depth: wgpu::TextureView, +} + +pub(super) const RASTER_WGSL: &str = r#" +struct R { pos: vec4, fwd: vec4, right: vec4, up: vec4, jit: vec4 }; // pos.w = tan(fov/2), fwd.w = aspect, jit = ndc offset xy, near +@group(0) @binding(0) var r: R; +@group(0) @binding(1) var tri_verts: array>; // per triangle: a, b - a, c - a (kernel layout) +struct V { @builtin(position) p: vec4, @location(0) @interpolate(flat) id: u32 }; +@vertex fn vs(@builtin(vertex_index) vi: u32) -> V { + let ti = vi / 3u; + let k = vi % 3u; + var p = tri_verts[ti * 3u].xyz; + if (k != 0u) { p += tri_verts[ti * 3u + k].xyz; } + let d = p - r.pos.xyz; + let z = dot(d, r.fwd.xyz); + // Reversed infinite depth: near / z. + return V(vec4(dot(d, r.right.xyz) / (r.pos.w * r.fwd.w) + r.jit.x * z, dot(d, r.up.xyz) / r.pos.w + r.jit.y * z, r.jit.z, z), ti + 1u); +} +@fragment fn fs(v: V) -> @location(0) u32 { return v.id; } +"#; + +impl Raster { + pub(super) fn new(g: &gpu::Gpu, w: u32, h: u32) -> Raster { + let d = &g.device; + let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("raster"), source: wgpu::ShaderSource::Wgsl(RASTER_WGSL.into()) }); + let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("raster"), + entries: &[ + wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, + ], + }); + let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipeline = d.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("raster vis"), + layout: Some(&pl), + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs"), targets: &[Some(wgpu::TextureFormat::R32Uint.into())], compilation_options: Default::default() }), + primitive: wgpu::PrimitiveState { cull_mode: None, ..Default::default() }, + depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth32Float, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Greater), stencil: Default::default(), bias: Default::default() }), + multisample: Default::default(), + multiview_mask: None, + cache: g.cache(), + }); + g.save_pipeline_cache(); + let tex = |format, usage| { + d.create_texture(&wgpu::TextureDescriptor { label: Some("raster"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage, view_formats: &[] }) + .create_view(&Default::default()) + }; + let vis = tex(wgpu::TextureFormat::R32Uint, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING); + let depth = tex(wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT); + let uniform = d.create_buffer(&wgpu::BufferDescriptor { label: Some("raster"), size: 80, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + Raster { pipeline, layout, uniform, vis, depth } + } + + /// Records the visibility pass for `tris` triangles at internal size (iw, ih), sampling at jitter (jx, jy). + #[allow(clippy::too_many_arguments)] + pub(super) fn record(&self, g: &gpu::Gpu, e: &mut wgpu::CommandEncoder, verts: &wgpu::Buffer, tris: u32, cam: &Camera, aspect: f32, (iw, ih): (u32, u32), (jx, jy): (f32, f32), timestamps: Option) { + let u: [[f32; 4]; 5] = [ + [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov], + [cam.fwd.x, cam.fwd.y, cam.fwd.z, aspect], + [cam.right.x, cam.right.y, cam.right.z, 0.0], + [cam.up.x, cam.up.y, cam.up.z, 0.0], + // Pixel centres sample at +0.5; shift the image so they sample at the jittered position instead. + [2.0 * (0.5 - jx) / iw as f32, -2.0 * (0.5 - jy) / ih as f32, 0.01, 0.0], + ]; + g.queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&u)); + let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &self.layout, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: verts.as_entire_binding() }], + }); + let mut pass = e.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("raster vis"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.vis, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store } })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Discard }), stencil_ops: None }), + timestamp_writes: timestamps, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_viewport(0.0, 0.0, iw as f32, ih as f32, 0.0, 1.0); + pass.set_scissor_rect(0, 0, iw, ih); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.draw(0..tris * 3, 0..1); + } +} + +/// The live kernel's world-space radiance cache (docs/ROADMAP.md § 1, step 1): its buffers and per-frame resolve. +pub(super) struct RadianceCache { + pub(super) bufs: gpu::CacheBuffers, + pub(super) resolve: wgpu::ComputePipeline, +} + +/// Share of the pixels showing translucent particles whose nearest hit trains the volume voxels (up to +/// gpu::VOLUME_TRAIN_CAP points per frame). +pub(super) const VOLUME_TRAIN_SHARE: f32 = 1.0 / 8.0; +/// Media voxel training points per frame (pathtrace.wgsl MEDIA_TRAIN_POINTS). +pub(super) const MEDIA_TRAIN_POINTS: u32 = 4096; +pub(super) struct Profile { + pub(super) queries: wgpu::QuerySet, + pub(super) resolve: wgpu::Buffer, + pub(super) read: wgpu::Buffer, + pub(super) sums: Vec, + pub(super) frames: u32, + /// Timed apart from the slots above: the grid programs' two passes (`gridsim`, queries 0..4) and the media passes + /// (`media`, queries 4..6: the camera rays' march and, at one sample a frame, the volume voxels' training, which + /// are otherwise in the trace's pass). Their queries, the resolve and read buffers, the sums. + pub(super) grid: (wgpu::QuerySet, wgpu::Buffer, wgpu::Buffer), + pub(super) grid_sum: f64, + pub(super) media_sum: f64, +} + +impl LiveCore { + /// Profile slots after the cache resolve that this scene writes: particle pass + BLAS rebuild (2; stateless or GPU + /// particles), translucency + volume training (2 more). + pub(super) fn profile_extra(&self) -> u32 { + if self.sc.up.translucent.is_some() { + 4 + } else if self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some() { + 2 + } else { + 0 + } + } + + /// Light LOD: the emitter CDF weighted by distance to the camera (`emitter_lod`) and the emitter cull with its view + /// cone (`EmitterCull`), rebuilt when the camera is 15 m from where it was last built or has turned LIGHT_LOD_TURN + /// from that direction (the kernel's emitter pdfs read the same reference from the parameters). + pub(super) fn update_light_lod(&mut self) { + let d0 = LIGHT_LOD_D0; + let (w, h) = (self.w, self.h); + let b = &mut self.sc.base; + // The light tree weighs emitters by their distance to each shading point itself: no light LOD, no cull. + if d0 <= 0.0 || self.sc.tables.emitter_tris.is_empty() || b.ltree[3] > 0.5 { + return; + } + let r = glam::Vec3::new(b.light_lod[0], b.light_lod[1], b.light_lod[2]); + let dir = glam::Vec3::new(b.lod_view[0], b.lod_view[1], b.lod_view[2]); + let turned = b.emit_power[2] > 0.0 && dir.dot(self.cam.fwd) < LIGHT_LOD_TURN.to_radians().cos(); + if b.light_lod[3] > 0.0 && r.distance(self.cam.pos) < 15.0 && !turned { + return; + } + b.light_lod = [self.cam.pos.x, self.cam.pos.y, self.cam.pos.z, d0]; + b.emit_power[1] = EMITTER_CULL; + b.emit_power[2] = EMITTER_CULL * EMITTER_CULL_VIEW; + // The frustum's half-diagonal: tan = tan(fov / 2) x sqrt(1 + aspect^2). + let half = (self.cam.tan_half_fov * (1.0 + (w as f32 / h as f32).powi(2)).sqrt()).atan() + EMITTER_CULL_MARGIN.to_radians(); + b.lod_view = [self.cam.fwd.x, self.cam.fwd.y, self.cam.fwd.z, half.min(std::f32::consts::PI).cos()]; + // Power edits held back for the emitter worker land first (its job in flight was built for the old reference: + // dropped), so the CDF built here has the powers the objects hold and the reference the kernel reads. + self.land_powers_now(); + let (e, b) = (&self.sc.tables, &mut self.sc.base); + let (em, total, power) = crate::emitter_cdf(&e.emitter_tris, &e.materials, &e.objects, e.stride, b.light_lod, crate::EmitterCull::of(b)); + self.g.queue.write_buffer(&self.sc.up.emitters, 0, bytemuck::cast_slice(&em)); + b.misc[1] = total; + b.emit_power[0] = power; + } + + pub fn render(&mut self, spp: u32, extra: impl FnOnce(&mut wgpu::CommandEncoder, &wgpu::Buffer)) { + // A dynamic sky's bake that finished since the last frame (time of day). + self.take_sky(); + self.take_emitters(); + let (w, h) = (self.w, self.h); + let (iw, ih) = self.internal_size(); + let now = Instant::now(); + // Lockstep (benchmarks) renders at a fixed frame time, whatever the wall clock says. + self.frame_dt = self.fixed_dt.unwrap_or_else(|| now.duration_since(self.last_frame).as_secs_f32()); + self.last_frame = now; + let moved = self.cam != self.prev_cam; + self.update_light_lod(); + // A camera cut (a jump no camera makes in one frame: 2 m or 30 degrees) is not motion: no blur (UE: bCameraCut). + let cut = (self.cam.pos - self.prev_cam.pos).length() > 2.0 || self.cam.fwd.dot(self.prev_cam.fwd) < 0.866; + self.cold_frames = if self.reset || cut { 0 } else { self.cold_frames.saturating_add(1) }; + // (Objects moved this frame: re-posed triangles or moved instances, e.g. walkers in a shared pose.) + let blur = !self.motion_blur_off && self.blur.0 > 0.0 && (moved || self.reposed || self.pose_reposed || self.inst_moved) && !cut; + // Camera motion is reprojected exactly (disocclusions are rejected per pixel), so history survives motion; + // the cap only bounds smearing of view-dependent shading (glossy reflections). + // Diffuse lighting reprojects exactly with the camera; view-dependent specular is handled per pixel (temporal pass). + // The most frames a pixel's history holds: HISTORY_LIVE while the scene's lighting changes, HISTORY_FRAMES once + // it has settled. Within it the length is each pixel's own (live.wgsl `dtemporal`: less where it moves, anti-lag + // where its lighting changes, restarted where its surface is new). No edit cuts it: until 2026-11-12 a + // lighting edit left every pixel 16 frames and a geometry edit 4, the whole frame for a change in one corner. + let cap = if self.reset { 1.0 } else if self.live_lighting() { HISTORY_LIVE } else { HISTORY_FRAMES }; + self.history_len = if self.reset { 1.0 } else { (self.history_len + 1.0).min(cap) }; + let cam = self.cam; + let g = &self.g; + // At most two frames in flight: beyond that the CPU waits, so latency stays bounded when GPU-bound. + // Wait for the oldest frame only (waiting for all work would leave the GPU idle while this frame records). + let t_wait = Instant::now(); + while self.inflight.len() >= 2 { + let oldest = self.inflight.pop_front().unwrap(); + let _ = g.device.poll(wgpu::PollType::Wait { submission_index: Some(oldest), timeout: None }); + } + let waited = t_wait.elapsed().as_secs_f32() * 1000.0; + // The loop guard's count, read back since an earlier frame. + let mut guard_free = false; + if let Some((read, state)) = &self.guard_read { + if state.load(Ordering::Acquire) == 2 { + let n = u32::from_le_bytes(read.slice(..).get_mapped_range().unwrap()[..4].try_into().expect("four bytes")); + read.unmap(); + state.store(0, Ordering::Release); + if self.guard_trips != Some(n) { + eprintln!("live: the loop guard has stopped {n} of the kernel's invocations: frames with wrong pixels (a traversal that does not end on this scene's data)"); + } + self.guard_trips = Some(n); + } + guard_free = state.load(Ordering::Acquire) == 0; + } + // GPU time drives adaptive resolution / samples per frame: timestamps when available (no stall), else twice a + // second a frame timed in isolation (idle GPU -> submit -> wait). + let period = g.queue.get_timestamp_period(); + let timed = match &mut self.timer { + Some(t) => { + let (latest, slot) = t.harvest(period); + if let Some((ms, frame, trace)) = latest { + (self.gpu_ms, self.gpu_frame, self.gpu_trace_ms) = (ms, frame, trace); + } + if let Some(k) = slot { + t.frames[k] = self.frame; + t.spps[k] = spp.max(1); + } + slot + } + None => None, + }; + let measure = self.timer.is_none() && self.frame % 30 == 0; + if measure { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + } + // Nothing moved since the last in-place update: its span's previous-frame vertices become the current ones. + if let (false, Some(span)) = (self.reposed, self.prev_span) { + self.keep_prev(span); + self.prev_span = None; + } + self.reposed = false; + if let (false, Some(span)) = (self.pose_reposed, self.pose_span) { + self.keep_prev(span); + self.pose_span = None; + } + self.pose_reposed = false; + // Likewise the instances the last update moved: their previous-frame rows become the current ones. + if !self.inst_moved && !self.inst_moving.is_empty() { + let size = std::mem::size_of::() as u64; + for i in std::mem::take(&mut self.inst_moving) { + let r = &mut self.sc.gpu_instances[i as usize]; + r.p = r.m; + self.g.queue.write_buffer(&self.sc.up.instances, i as u64 * size, bytemuck::bytes_of(r)); + } + } + self.inst_moved = false; + let g = &self.g; + let t_submit = Instant::now(); + let pipeline = g.pipeline(&self.sc.up.kernel, true); + // (No media pass while every medium is hidden: a pool of idle fireballs costs the frame nothing.) + let media = self.sc.up.kernel.features & gpu::F_MEDIA != 0 && self.sc.up.media_hidden.borrow().iter().any(|h| !h); + let media_train = (media && self.cache.is_some()).then(|| g.media_pipeline(&self.sc.up.kernel, "media_train")); + let media_composite = media.then(|| g.media_pipeline(&self.sc.up.kernel, "media_composite")); + // This frame's sub-pixel jitter (Halton 2, 3), every camera ray's and the raster visibility pass's: 8 phases at + // full resolution, as many more as an output pixel has fewer traced ones (8 / scale^2: the upscaler needs each + // output pixel visited), in the 12 bits a side the samples carry it in. + 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))); + for k in 0..spp.max(1) { + let mut p = self.sc.base; + // 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)); + } + p.raster = [(self.raster.is_some() && !self.sc.instanced) as u32 as f32, jitter.0, jitter.1, 0.0]; + p.raster[3] = if self.rays { 0.0 } else { 1.0 }; + if !self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty() { + // The kernel finds the first dynamic triangle in Params::group_first (group 4), a u32. + p.motion[0] = 1.0; + } + if self.cache.is_some() { + // Light-sampled particles (sparks: Particles emitterArea) move their light every frame: the cache and + // the temporal pass keep the short history of a lighting edit while the scene has them. + let lit = self.live_lighting(); + let cold = if self.cold_frames >= COLD_FALLBACK_FRAMES { 1 } else { 0 }; + p.cache = [1.0, CACHE_SAMPLES, if lit { CACHE_FRAMES_LIT } else { CACHE_FRAMES }, (cache_flags() | cold) as f32]; + } + p.time[0] = shader_time(self.time); + p.cam_pos = [cam.pos.x, cam.pos.y, cam.pos.z, cam.tan_half_fov]; + p.cam_fwd = [cam.fwd.x, cam.fwd.y, cam.fwd.z, self.aspect]; + p.cam_right = [cam.right.x, cam.right.y, cam.right.z, 0.0]; + p.cam_up = [cam.up.x, cam.up.y, cam.up.z, 1.0]; + p.size = [iw, ih, self.frame.wrapping_mul(16).wrapping_add(k), crate::rng_seed()]; + p.counts2[2] = if ih < h { h } else { 0 }; + p.misc[3] = k as f32; + p.counts[2] = p.counts[2].min(self.bounces); + p.counts[3] = p.counts[3].min(2); + g.queue.write_buffer(&self.sc.up.params, 0, bytemuck::bytes_of(&p)); + let mut e = g.device.create_command_encoder(&Default::default()); + // Particles: placed for this frame's time and camera, then the dynamic BLASes rebuilt (before the raster and + // the trace). + // The grid programs (density grids changed on the GPU), before anything reads the grids. + if k == 0 && gpu::Gpu::has_grid_sim(&self.sc.up) { + let sources = std::mem::take(&mut self.grid_sources); + g.run_grids(&self.sc.up, shader_time(self.time), self.frame_dt, &sources, &mut e, self.profile.as_ref().map(|p| (&p.grid.0, 0))); + if let Some(p) = &self.profile { + e.resolve_query_set(&p.grid.0, 0..4, &p.grid.1, 0); + e.copy_buffer_to_buffer(&p.grid.1, 0, &p.grid.2, 0, 32); + } + } + // Stateful GPU particles first (their emissions, the game's spawns): with stateless ones the pass below + // rebuilds the dynamic BLASes for both, else this one does. + if let (Some(_), 0) = (&self.sc.up.gpu_particles, k) { + let n = self.passes.pipes.len() as u32 + 2; + let stateless = self.sc.up.particles.is_some(); + let (pass_ts, blas_ts) = match (&self.profile, &self.timer, timed, stateless) { + (Some(p), _, _, false) => (Some((&p.queries, n * 2)), Some((&p.queries, n * 2 + 2))), + (None, Some(t), Some(slot), false) => (Some((&t.queries, slot as u32 * 8 + 6)), None), + _ => (None, None), + }; + let spawns = std::mem::take(&mut self.particle_spawns); + g.run_gpu_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, &spawns, !stateless, &mut e, pass_ts, blas_ts); + } + if let (Some(_), 0) = (&self.sc.up.particles, k) { + let b = &self.sc.base; + let n = self.passes.pipes.len() as u32 + 2; + let pass_ts = match (&self.profile, &self.timer, timed) { + (Some(p), _, _) => Some((&p.queries, n * 2)), + (None, Some(t), Some(slot)) => Some((&t.queries, slot as u32 * 8 + 6)), + _ => None, + }; + let blas_ts = self.profile.as_ref().map(|p| (&p.queries, n * 2 + 2)); + g.run_particles(&self.sc.up, cam.pos.to_array(), cam.fwd.to_array(), shader_time(self.time), self.frame_dt, b.time[2], b.time[1], &mut e, pass_ts, blas_ts); + } + // (The merged layout and the uniform tables: the tables as this frame's edits and the particle passes above + // left them, where the kernel reads them; merged.rs.) + if k == 0 { + g.sync_tables(&self.sc.up, &mut e); + } + if let (Some(r), 0, false) = (&self.raster, k, self.sc.instanced) { + let tw = self.timer.as_ref().zip(timed).map(|(t, slot)| wgpu::RenderPassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8 + 4), end_of_pass_write_index: Some(slot as u32 * 8 + 5) }); + r.record(g, &mut e, &self.sc.up.verts, self.sc.tris.len() as u32, &cam, self.aspect, (iw, ih), jitter, tw); + } + { + let tw = match (&self.profile, &self.timer, timed) { + (Some(p), _, _) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(0), end_of_pass_write_index: Some(1) }), + (None, Some(t), Some(slot)) if k == 0 => Some(wgpu::ComputePassTimestampWrites { query_set: &t.queries, beginning_of_pass_write_index: Some(slot as u32 * 8), end_of_pass_write_index: Some(slot as u32 * 8 + 1) }), + _ => None, + }; + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("live trace"), timestamp_writes: tw }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); + // Participating media: their volume voxels trained after the trace (pathtrace.wgsl media_train), and + // camera rays through media once the frame's samples are traced (media_composite). In the trace's pass + // and timing; profiled, the frame's last sample's media passes are a pass of their own (`media`). + let last = k + 1 == spp.max(1); + if let (Some(p), true, true) = (&self.profile, last, media_composite.is_some()) { + drop(pass); + pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("media"), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.grid.0, beginning_of_pass_write_index: Some(4), end_of_pass_write_index: Some(5) }) }); + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + } + if let (Some(mt), 0) = (&media_train, k) { + pass.set_pipeline(mt); + pass.dispatch_workgroups(MEDIA_TRAIN_POINTS.div_ceil(64), 1, 1); + } + if let (Some(mc), true) = (&media_composite, last) { + pass.set_pipeline(mc); + pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); + } + } + if let (Some(p), true, true) = (&self.profile, k + 1 == spp.max(1), media_composite.is_some()) { + e.resolve_query_set(&p.grid.0, 4..6, &p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT); + e.copy_buffer_to_buffer(&p.grid.1, wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, &p.grid.2, 32, 16); + } + // Each pair is resolved in the command buffer that wrote it (Metal reads zeros otherwise). + if let (None, Some(t), Some(slot), 0) = (&self.profile, &self.timer, timed, k) { + let q = slot as u32 * 8; + let at = slot as u64 * 4 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; + e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); + e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 0, 16); + // Raster and particle pairs (pairs 2 and 3); zeros when the pass did not run. + for (pair, ran) in [(2u32, self.raster.is_some() && !self.sc.instanced), (3, self.sc.up.particles.is_some() || self.sc.up.gpu_particles.is_some())] { + let at = at + pair as u64 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; + if ran { + e.resolve_query_set(&t.queries, q + pair * 2..q + pair * 2 + 2, &t.resolve, at); + } else { + e.clear_buffer(&t.resolve, at, Some(16)); + } + e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, pair as u64 * 16, 16); + } + } + g.queue.submit([e.finish()]); + } + let (r, t) = self.bloom; + let lp = LiveParams { + cur: cam.uniform(self.aspect), + prev: self.prev_cam.uniform(self.aspect), + size: [w, h, self.frame, self.reset as u32], + misc: [self.sc.exposure, cap, self.vignette, (self.bloom.1.iter().sum::() > 0.0) as u32 as f32], + // misc2.z: motion blur, this frame's motion (pixels) -> blur length. UE (PostProcessMotionBlur.cpp, + // UpdateMotionBlurTimeScale) blurs over +- amount / 2 x the motion x (1 / target FPS) / frame time, the + // actual frame time without a target. 0 while nothing moves (the pass then costs nothing). + misc2: [1.0, SAMPLE_CEILING, if !blur { 0.0 } else if self.blur.1 > 0.0 { self.blur.0 / (self.blur.1 * self.frame_dt.clamp(1.0 / 240.0, 0.25)) } else { self.blur.0 }, spp.max(1) as f32], + mode: [self.view_mode, 0, self.live_lighting() as u32, 0], + post: self.post, + ae: self.sc.exposure_settings.ae, + ae2: self.sc.exposure_settings.ae2, + ae3: [self.sc.exposure_settings.speed[0], self.sc.exposure_settings.speed[1], self.frame_dt.clamp(1.0 / 240.0, 0.25), (self.reset || self.frame == 0 || exposure_snap()) as u32 as f32], + lx: self.sc.exposure_settings.lx, + isz: [iw, ih, self.prev_isz.0, self.prev_isz.1], + br: [r[0], r[1], r[2], r[3]], + br2: [r[4], r[5], 0.0, 0.0], + bt: [t[0], t[1], t[2], t[3]], + bt2: [t[4], t[5], 0.0, 0.0], + 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], + }; + g.queue.write_buffer(&self.targets.params, 0, bytemuck::bytes_of(&lp)); + let bg = &self.sc.bg; + // Translucent particles: drawn over the upscaled image right after `upscale` (in sb when depth of field / motion + // blur follow, else fa: they have no motion vectors, so they are no part of the colour history; UE draws its + // translucency after the upscaler the same way), then their volume voxels trained. + // (Nothing to draw while every translucent particle system is hidden in place.) + let tl = self.sc.up.translucent.as_ref().filter(|_| g.translucent_particles_live(&self.sc.up)).map(|t| { + let pipes = g.translucent_pipelines(&self.sc.up.kernel, true); + let target = if lp.misc2[2] > 0.0 || lp.dof[0] > 0.0 { &self.targets.sb.buffer } else { &self.targets.fa.buffer }; + // Below full resolution the particles are drawn per traced pixel, then laid over the output (`translucent_up`). + let low = (iw, ih) != (w, h); + let tp = gpu::TranslucentParams { size: [w, h, t.first, 1], view: [iw, ih, gpu::VOLUME_TRAIN_CAP, low as u32], misc: [VOLUME_TRAIN_SHARE, translucent_hidden(self.frame) as u32 as f32, 0.0, 0.0] }; + g.queue.write_buffer(&t.params, 0, bytemuck::bytes_of(&tp)); + (pipes, g.translucent_bind_group(t, target), &t.count) + }); + // The pass (train = false) or the volume training (train = true). + let tl_dispatch = |pass: &mut wgpu::ComputePass, train: bool| { + if let Some((pipes, tbg, _)) = &tl { + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + pass.set_bind_group(2, tbg, &[]); + if train { + pass.set_pipeline(&pipes.train); + pass.dispatch_workgroups(gpu::VOLUME_TRAIN_CAP / 64, 1, 1); + } else if (iw, ih) != (w, h) { + pass.set_pipeline(&pipes.draw); + pass.dispatch_workgroups(iw.div_ceil(8), ih.div_ceil(8), 1); + pass.set_pipeline(&pipes.up); + pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); + } else { + pass.set_pipeline(&pipes.draw); + pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1); + } + } + }; + let mut e = g.device.create_command_encoder(&Default::default()); + if let Some((_, _, count)) = &tl { + e.clear_buffer(count, 0, None); + } + if let Some(c) = &self.cache { + let n = self.passes.pipes.len() as u32 + 1; + let tw = self.profile.as_ref().map(|p| wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(n * 2), end_of_pass_write_index: Some(n * 2 + 1) }); + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cache resolve"), timestamp_writes: tw }); + pass.set_pipeline(&c.resolve); + pass.set_bind_group(0, &self.sc.up.bg, &[]); + pass.set_bind_group(1, &self.kernel_bg, &[]); + pass.dispatch_workgroups((gpu::CACHE_SIZE / 256) as u32, 1, 1); + } + if let Some(p) = &self.profile { + // One compute pass per stage, each timed. + for (k, (name, 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) }) }); + pass.set_pipeline(pipe); + pass.set_bind_group(0, bg, &[]); + let (gw, gh) = if *name == "exposure" { (8, 8) } else if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; + pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); + drop(pass); + if *name == "upscale" && tl.is_some() { + for (j, train) in [(0u32, false), (1, true)] { + let q = (self.passes.pipes.len() as u32 + 4 + j) * 2; + let mut pass = e.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(if train { "vtrain" } else { "translucent" }), timestamp_writes: Some(wgpu::ComputePassTimestampWrites { query_set: &p.queries, beginning_of_pass_write_index: Some(q), end_of_pass_write_index: Some(q + 1) }) }); + tl_dispatch(&mut pass, train); + } + } + } + let n = self.passes.pipes.len() as u32 + 2 + self.profile_extra(); + e.resolve_query_set(&p.queries, 0..n * 2, &p.resolve, 0); + e.copy_buffer_to_buffer(&p.resolve, 0, &p.read, 0, p.read.size()); + } else { + 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 (name, pipe) in &self.passes.pipes { + pass.set_pipeline(pipe); + pass.set_bind_group(0, bg, &[]); + if *name == "exposure" { + pass.dispatch_workgroups(1, 1, 1); + continue; + } + let (gw, gh) = if *name == "dof_tiles" { (w.div_ceil(16), h.div_ceil(16)) } else if traced_pass(name) { (iw, ih) } else if name.starts_with("bloom") || name.starts_with("lum_") { (w.div_ceil(4), h.div_ceil(4)) } else { (w, h) }; + pass.dispatch_workgroups(gw.div_ceil(8), gh.div_ceil(8), 1); + if *name == "upscale" { + tl_dispatch(&mut pass, false); + tl_dispatch(&mut pass, true); + } + } + drop(pass); + if let (Some(t), Some(slot)) = (&self.timer, timed) { + let q = slot as u32 * 8 + 2; + let at = (slot as u64 * 4 + 1) * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT; + e.resolve_query_set(&t.queries, q..q + 2, &t.resolve, at); + e.copy_buffer_to_buffer(&t.resolve, at, &t.reads[slot].0, 16, 16); + } + } + if let (Some((read, _)), Some(count), true) = (&self.guard_read, &self.targets.kernel.guard, guard_free) { + e.copy_buffer_to_buffer(&count.buffer, count.offset, read, 0, 4); + } + extra(&mut e, &self.targets.out); + let done = g.queue.submit([e.finish()]); + self.inflight.push_back(done); + self.cpu_wait_ms = waited; + if let (Some((read, state)), true) = (&self.guard_read, guard_free) { + let state = state.clone(); + state.store(1, Ordering::Release); + read.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); + } + if let (Some(t), Some(slot)) = (&self.timer, timed) { + let state = t.reads[slot].1.clone(); + state.store(1, Ordering::Release); + t.reads[slot].0.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { 2 } else { 0 }, Ordering::Release)); + } + // Without timestamps: GPU time ~ submit -> completion (the queue is idle at submit when the frame fits the budget). + if measure { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + (self.gpu_ms, self.gpu_frame) = (t_submit.elapsed().as_secs_f32() * 1000.0, self.frame); + } + let extra = self.profile_extra() as usize; + if let Some(p) = &mut self.profile { + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + p.read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + let t: Vec = bytemuck::cast_slice::(&p.read.slice(..).get_mapped_range().unwrap()).to_vec(); + p.read.unmap(); + let period = g.queue.get_timestamp_period() as f64 * 1e-6; + // The particle, BLAS, translucency and volume training slots (the last four) are written only when the scene + // has particles / translucent ones. + let n = self.passes.pipes.len() + 2 + extra; + for (k, s) in p.sums.iter_mut().enumerate().take(n) { + *s += t[k * 2 + 1].saturating_sub(t[k * 2]) as f64 * period; + } + let (sim, media) = (gpu::Gpu::has_grid_sim(&self.sc.up), media_composite.is_some()); + if sim || media { + p.grid.2.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + let _ = g.device.poll(wgpu::PollType::wait_indefinitely()); + let t: Vec = bytemuck::cast_slice::(&p.grid.2.slice(..).get_mapped_range().unwrap()).to_vec(); + p.grid.2.unmap(); + if sim { + p.grid_sum += (t[1].saturating_sub(t[0]) + t[3].saturating_sub(t[2])) as f64 * period; + } + if media { + p.media_sum += t[5].saturating_sub(t[4]) as f64 * period; + } + } + p.frames += 1; + if p.frames == 120 { + let names: Vec<&str> = std::iter::once("trace").chain(self.passes.pipes.iter().map(|(n, _)| *n)).chain(["cache", "particles", "blas", "translucent", "vtrain"]).collect(); + let mut rows: Vec = names.iter().zip(&p.sums).map(|(n, s)| format!("{n} {:.2}", s / p.frames as f64)).collect(); + rows.push(format!("gridsim {:.2}", p.grid_sum / p.frames as f64)); + rows.push(format!("media {:.2}", p.media_sum / p.frames as f64)); + rows.retain(|r| !r.ends_with(" 0.00")); + eprintln!("profile ms/frame: total {:.2} | {}", (p.sums.iter().sum::() + p.grid_sum + p.media_sum) / p.frames as f64, rows.join(", ")); + (p.grid_sum, p.media_sum) = (0.0, 0.0); + p.sums.iter_mut().for_each(|s| *s = 0.0); + p.frames = 0; + } + } + self.prev_cam = cam; + self.shown_spp = spp.max(1); + self.prev_isz = (iw, ih); + self.reset = false; + self.frame = self.frame.wrapping_add(1); + } + +} diff --git a/renderer/src/live/load.rs b/renderer/src/live/load.rs new file mode 100644 index 0000000..7f7d562 --- /dev/null +++ b/renderer/src/live/load.rs @@ -0,0 +1,304 @@ +//! A scene loaded for the live view (`load_scene`, `LoadedScene`), and the worker threads its loading and its +//! dynamic sky and emitters use (`DynamicSky`, `EmitterWorker`). + +use super::*; + +pub(super) struct LoadedScene { + /// Load phases (parse, bvh, sky, accel build, upload) in ms. + pub(super) timings: Vec<(String, f64)>, + /// World-space triangles + per-node ranges, kept on the CPU for picking and selection outlines; instanced meshes + /// (model space) and their placements likewise. + pub(super) tris: Vec<[glam::Vec3A; 3]>, + pub(super) tri_nodes: Vec<(String, u32, u32)>, + /// Flattened instances of instanced objects: (first, end, instance index), for picking. + pub(super) tri_instances: Vec<(u32, u32, u32)>, + pub(super) pool_tris: Vec<[glam::Vec3A; 3]>, + pub(super) meshes: Vec, + pub(super) instances: Vec, + /// The instances' kernel records as uploaded (movable ones are rewritten when they move), then the procedural + /// objects' (from `proc_first`). + pub(super) gpu_instances: Vec, + /// Procedural geometry (moves, picking), by node in `proc_of`; its records start at `proc_first`. + pub(super) procs: Vec, + pub(super) proc_of: std::collections::HashMap, + pub(super) proc_first: usize, + /// The scene has instanced meshes or procedural geometry (the raster experiment draws only flattened triangles: + /// primary visibility is traced). + pub(super) instanced: bool, + /// Kernel features of the instanced meshes (gpu::instance_features), which edits cannot change. + pub(super) instance_features: u32, + pub(super) name: String, + pub(super) triangles: usize, + pub(super) up: gpu::Uploaded, + pub(super) base: gpu::Params, + pub(super) bg: wgpu::BindGroup, + pub(super) exposure: f32, + pub(super) exposure_settings: ExposureSettings, + pub(super) tan_half_fov: f32, + pub(super) scene_camera: Camera, + /// The camera's depth of field (UE: off unless it sets a focus distance). + pub(super) dof: Option, + /// GPU table copies that parameter edits rewrite in place (`commit`). + pub(super) tables: tables::Tables, + /// CPU copy of the dynamic triangles (posed models: the tail of the GPU triangle order from `dynamic_first`): + /// vertices (kernel layout), shading attributes, BLAS vertices. + pub(super) dynamic: (usize, Vec<[f32; 4]>, Vec, Vec<[f32; 3]>), + /// Kernel feature inputs edits cannot change: textures, material functions, traversal groups. + pub(super) fixed_features: (bool, bool, [usize; crate::GROUPS]), + /// Where in-place geometry edits write next (`edit_geometry`); a rebuild starts it afresh. + pub(super) edit: EditState, + /// The atmosphere's sky once its sun has moved in place (`move_sun` starts it), and what it bakes with: the + /// environment map's size, the viewer's altitude and the environment's light-sampling floor, as the load had them. + pub(super) sky: Option, + /// Power edits' emitter rebuilds on a worker (`EmitterWorker`), for scenes with many emitters. + pub(super) emit: Option, + pub(super) sky_inputs: ((u32, u32), f32, f32), + /// For materials that start emitting (`add_material_emitters`): their flattened static triangles' runs (`prepare`) + /// and the flattened triangles' build indices in traversal order (positions in `tris`). + pub(super) mat_runs: std::collections::HashMap>, + pub(super) tri_order: Vec, +} + +/// Cores the sky's worker thread bakes with: the rest stay with the frame (scripts, uploads, the GPU's driver). +pub(super) const SKY_THREADS: usize = 3; + +/// An atmosphere whose sun moves while the scene runs (time of day). The sun's light is rewritten in place at every +/// move, its direction and its colour through the atmosphere (`sky::sun_illuminance`, no bake needed), which carries +/// the direct light and the shadows. The sky's radiance (the environment map and its sampling CDF) is baked again on a +/// worker thread with `SKY_THREADS` cores, the newest sun first, and lands with a later frame (`take_sky`): the sky +/// trails the sun by one bake (~10–40 ms at `sky::DYNAMIC_SKY`). Lockstep runs wait for it, so they repeat. +pub(super) struct DynamicSky { + pub(super) atm: Arc, + /// (sun direction, sun illuminance above the atmosphere, cloud cover) + pub(super) ask: mpsc::Sender<(glam::Vec3, glam::Vec3, f32)>, + pub(super) done: mpsc::Receiver<(gpu::EnvMap, Vec, f32)>, + /// The brightest sky texel from which the environment is light-sampled (`Prepared::env_nee_floor`). + pub(super) env_nee_floor: f32, +} + +impl DynamicSky { + pub(super) fn new(world: &World, (w, h): (u32, u32), altitude: f32, env_nee_floor: f32) -> Option { + let crate::desc::Sky::Atmosphere { rayleigh_scale, mie_scale, mie_anisotropy, ground_albedo, glow, horizon_glow, sun: Some(_), .. } = &world.environment.sky else { + return None; + }; + let atm = Arc::new(crate::sky::Atmosphere::earth(*rayleigh_scale, *mie_scale, *mie_anisotropy, glam::Vec3::from(*ground_albedo))); + let glow = crate::sky::glow_of(glow, horizon_glow); + let (ask, asked) = mpsc::channel::<(glam::Vec3, glam::Vec3, f32)>(); + let (tx, done) = mpsc::channel(); + let a = atm.clone(); + std::thread::Builder::new() + .name("sky bake".into()) + .spawn(move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(SKY_THREADS).build().ok(); + let tables = match &pool { + Some(p) => p.install(|| crate::sky::SkyTables::new(&a)), + None => crate::sky::SkyTables::new(&a), + }; + let bake = |sun: (glam::Vec3, glam::Vec3, f32)| { + let b = crate::sky::bake_atmosphere_tables(&a, &tables, sun.0, sun.1, altitude, w, h, glow, sun.2); + let env = gpu::EnvMap { width: b.width, height: b.height, texels: b.texels }; + let cdf = crate::build_env_cdf(&env); + let max = env.texels.iter().map(|t| 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2]).fold(0.0f32, f32::max); + (env, cdf, max) + }; + // The newest sun only: bakes asked for while one ran are stale. + while let Ok(mut sun) = asked.recv() { + while let Ok(s) = asked.try_recv() { + sun = s; + } + let out = match &pool { + Some(p) => p.install(|| bake(sun)), + None => bake(sun), + }; + if tx.send(out).is_err() { + break; + } + } + }) + .ok()?; + Some(DynamicSky { atm, ask, done, env_nee_floor }) + } +} + +/// Scenes with at least this many emitters rebuild their emitter CDF for power edits on a worker (`EmitterWorker`). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(super) const ASYNC_EMITTERS: usize = 100_000; + +/// Power edits in a scene with many emitters (a city's lamps and windows switching with the clock): the emitter CDF and +/// the light tree's node powers are rebuilt on a worker thread (~13 ms over 1.4 M emitters, which held up the frame in +/// `write_emitters`), and the edits land with them, in a later frame (`take_emitters`): the objects' powers, the CDF +/// and the tree's powers together, so sampling and shading switch at once. The worker writes the CDF and the tree's +/// nodes into a mapped staging buffer; the frame only copies it on the GPU (a 22 MB `write_buffer` cost it ~5 ms). +/// Lockstep frames wait for it, so they repeat. Scenes whose emitters change in place (added in place: the tree's slot; +/// moving emitters, whose nodes the frame refits) keep `write_emitters`; so does any other emitter edit (materials), +/// which first lands the edits held back. +pub(super) struct EmitterWorker { + pub(super) ask: mpsc::Sender, + pub(super) done: mpsc::Receiver, + /// The emitters it was made for: a list that grew (added in place) makes another. + pub(super) count: usize, + /// The edits of the job it is on (they land with its result). + pub(super) flight: Option>, +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(super) struct EmitterJob { + pub(super) materials: Vec, + pub(super) objects: Vec<[f32; 4]>, + pub(super) lod: [f32; 4], + pub(super) cull: crate::EmitterCull, +} + +pub(super) struct EmitterBuilt { + /// The CDF then the tree's nodes, as the emitters buffer holds them (`put_emitters`), and their bytes. + pub(super) staging: wgpu::Buffer, + pub(super) bytes: u64, + /// The CDF's entries (its guide table included). + pub(super) entries: usize, + pub(super) total: f32, + pub(super) power: f32, + /// The light tree's node powers (`nodes[i][2]`), for the frame's copy of the tree. + pub(super) node_powers: Vec, +} + +impl EmitterWorker { + /// wasm32: none, and power edits rebuild in the frame as before (the browser's WebGPU device and buffers stay on + /// the thread that made them). + #[cfg(target_arch = "wasm32")] + pub(super) fn new(_e: &tables::Tables, _device: &wgpu::Device) -> Option { + None + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn new(e: &tables::Tables, device: &wgpu::Device) -> Option { + let changing = e.light_tree.as_ref().is_some_and(|t| t.dynamic.is_some() || t.moving_count > 0); + if e.emitter_tris.len() < ASYNC_EMITTERS || changing { + return None; + } + let device = device.clone(); + let (tris, mut tree, stride) = (e.emitter_tris.clone(), e.light_tree.clone(), e.stride); + let (ask, asked) = mpsc::channel::(); + let (tx, done) = mpsc::channel(); + std::thread::Builder::new() + .name("emitters".into()) + .spawn(move || { + while let Ok(j) = asked.recv() { + let (em, total, power) = crate::emitter_cdf(&tris, &j.materials, &j.objects, stride, j.lod, j.cull); + let node_powers = tree.as_mut().map_or(vec![], |t| { + let w: Vec = t.order.iter().map(|&i| crate::emitter_power(&tris[i as usize], &j.materials, &j.objects, stride)).collect(); + t.refit(&|k| w[k]); + t.nodes.iter().map(|n| n[2]).collect() + }); + let nodes: &[[f32; 4]] = tree.as_ref().map_or(&[], |t| &t.nodes); + let bytes = ((em.len() + nodes.len()) * 16) as u64; + let staging = device.create_buffer(&wgpu::BufferDescriptor { label: Some("emitters staging"), size: bytes.max(16), usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: true }); + let at = em.len() as u64 * 16; + for (range, data) in [(0..at, bytemuck::cast_slice::<_, u8>(&em)), (at..bytes, bytemuck::cast_slice(nodes))] { + if !data.is_empty() { + let Ok(mut m) = staging.slice(range).get_mapped_range_mut() else { break }; + m.copy_from_slice(data); + } + } + staging.unmap(); + if tx.send(EmitterBuilt { staging, bytes, entries: em.len(), total, power, node_powers }).is_err() { + break; + } + } + }) + .ok()?; + Some(EmitterWorker { ask, done, count: e.emitter_tris.len(), flight: None }) + } +} + +pub(super) fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32, targets: &Targets, passes: &Passes) -> Result<(LoadedScene, crate::desc::PostPreset)> { + let t0 = Instant::now(); + let mut sc = scene::build(world, assets, presets, None)?; + sc.width = w; + sc.height = h; + // Emitters its edits add get a light-tree subtree of their own (ROADMAP § 6 step 4). + // (Software traversal with units edits in place as hardware traversal does: `units_wanted`.) + sc.emitter_slot = gpu::EditRoom::editable(&sc, g.hwrt || crate::units_wanted(g.hwrt, &sc)); + let mut timings = vec![("build_ms".to_string(), t0.elapsed().as_secs_f64() * 1000.0)]; + let mut prep = crate::prepare_live(g, &mut sc, &mut timings); + let mut tables = tables::Tables::new(&sc, std::mem::take(&mut prep.emitter_tris), prep.light_tree.take()); + // (THREERT_EDIT_REBUILD=1: every edit rebuilds, a material that starts emitting too: the A/B.) + tables.emit_in_place = std::env::var("THREERT_EDIT_REBUILD").map_or(true, |v| v != "1"); + tables.fixed_mats = std::mem::take(&mut prep.fixed_mats); + let emit = EmitterWorker::new(&tables, &g.device); + tables.defer_powers = emit.is_some(); + let (mat_runs, tri_order) = (std::mem::take(&mut prep.mat_runs), std::mem::take(&mut prep.tri_order)); + let fixed_features = (!sc.textures.is_empty(), !sc.shaders.is_empty(), prep.groups); + let mut up = g.upload(&sc, w, h, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, &mut timings)?; + // The merged layout: the kernel written for this view's group-1 buffers (merged.rs). + if let (Some(caps), Some((_, live))) = (&mut up.kernel.caps, &targets.kernel.merged) { + caps.live = *live; + } + let _ = g.pipeline(&up.kernel, true); // compile the scene's kernel variant now, not in the first frame + let sky_inputs = ((prep.env.width, prep.env.height), sc.camera.position.y.max(0.0), prep.env_nee_floor); + let mut base = prep.params; + // The live viewport resamples fewer light-tree candidates at camera hits than offline renders (ltree.rs). + if base.ltree[3] > 0.5 { + base.ltree[3] = crate::ltree::live_candidates() as f32; + } + base.cam_fwd[3] = w as f32 / h as f32; + let bg = targets.bind_group(&g.device, &passes.layout, &up.accum); + let exposure_settings = ExposureSettings::new(&sc.camera.exposure, &sc.preset.post); + // Manual: 2^-EV100. Auto: a first guess for the firefly clamp (the GPU histogram takes over from frame 1). + let exposure = match &sc.camera.exposure { + crate::desc::Exposure::Manual { ev100 } => 2f32.powf(-ev100), + crate::desc::Exposure::Auto { compensation, max_ev100, .. } => 2f32.powf(*compensation) / 2f32.powf(*max_ev100), + }; + let c = &sc.camera; + let scene_camera = Camera { pos: c.position, fwd: c.forward, up: c.up, right: c.right, tan_half_fov: c.tan_half_fov }; + let (tris, tri_nodes, tri_instances) = (std::mem::take(&mut sc.tris), std::mem::take(&mut sc.tri_nodes), std::mem::take(&mut sc.tri_instances)); + let instanced = !sc.gpu_instances.is_empty() || !sc.procs.is_empty(); + let instance_features = gpu::instance_features(&sc); + let (pool_tris, meshes, instances) = (std::mem::take(&mut sc.pool_tris), std::mem::take(&mut sc.meshes), std::mem::take(&mut sc.instances)); + let mut gpu_instances = std::mem::take(&mut sc.gpu_instances); + let proc_first = gpu_instances.len(); + gpu_instances.extend_from_slice(&sc.proc_records); + let procs = std::mem::take(&mut sc.procs); + let proc_of = procs.iter().enumerate().filter(|(_, p)| p.sub.is_none()).map(|(j, p)| (p.node, j)).collect(); + let triangles = sc.stats.get("triangles").map_or(tris.len(), |t| *t as usize); + let first = prep.groups[..4].iter().sum::(); + // The kernel's first dynamic triangle (motion vectors, prev_verts) is group 4's first. + debug_assert_eq!(prep.params.group_first[1][0] as usize, first); + // The dynamic tail: groups 4..8 (the instanced pool follows them). + let end = prep.groups.iter().sum::(); + let blas = prep.tri_verts[first * 3..end * 3].chunks(3).flat_map(|t| [0, 1, 2].map(|k| if k == 0 { [t[0][0], t[0][1], t[0][2]] } else { [t[0][0] + t[k][0], t[0][1] + t[k][1], t[0][2] + t[k][2]] })).collect(); + let dynamic = (first, prep.tri_verts[first * 3..end * 3].to_vec(), sc.attrs[first..end].to_vec(), blas); + let blobs = sc.proc_layout.as_ref().map_or_else(Default::default, |l| l.blobs.iter().map(|(b, off)| (b.hash, l.head.len() + off)).collect()); + let slot = up.accel.as_ref().map_or(0, |a| a.proc_base + procs.len()); + let room_base = (prep.tri_verts.len() / 3, gpu_instances.len(), tables.objects.len() / tables.stride, up.region_words, slot); + // A record's TLAS instance is `inst_base` + it (model instances, then procedural objects, then the room's). + debug_assert!(up.accel.as_ref().is_none_or(|a| room_base.4 == a.inst_base + room_base.1)); + let room = up.room; + let mut users: std::collections::HashMap = Default::default(); + for i in &instances { + *users.entry(i.mesh).or_default() += 1; + } + let mesh_of = tables.retained.own_instance.iter().filter_map(|(&id, &k)| instances.get(k as usize).map(|i| (id, i.mesh))).collect(); + let managed = tables.retained.own_mesh.values().chain(tables.retained.emitter_mesh.values()).copied().collect(); + let edit = EditState { + tris: Holes::new(room_base.0, room_base.0 + room.tris), + records: Holes::new(room_base.1, room_base.1 + room.records), + objects: Holes::new(room_base.2, room_base.2 + room.objects), + word: room_base.3, + word_cap: room_base.3 + room.proc_words, + mesh_of, + users, + managed, + dead_meshes: vec![], + grows: 0, + rebind: false, + uniform_said: false, + base: room_base, + pool_first: prep.groups.iter().sum(), + blobs, + placed: Default::default(), + instanced: Default::default(), + inst_at_load: instances.len(), + added: Default::default(), + }; + Ok((LoadedScene { timings, triangles, tris, tri_nodes, tri_instances, pool_tris, meshes, instances, gpu_instances, procs, proc_of, proc_first, instanced, instance_features, name: sc.name.clone(), up, base, bg, exposure, exposure_settings, tan_half_fov: c.tan_half_fov, scene_camera, dof: c.dof.clone(), tables, fixed_features, dynamic, edit, sky: None, emit, sky_inputs, mat_runs, tri_order }, sc.preset.post)) +} diff --git a/renderer/src/live/mod.rs b/renderer/src/live/mod.rs new file mode 100644 index 0000000..c82e39c --- /dev/null +++ b/renderer/src/live/mod.rs @@ -0,0 +1,1271 @@ +//! Real-time renderer (LiveCore), modelled on UE5's real-time path with hardware RT: every frame traces 1+ spp +//! (primary visibility, GI, shadows, reflections), reprojects and accumulates history (converging to the offline +//! reference while the camera holds still), denoises and applies the UE post chain. Drawn by the native window viewer +//! (`view`) and the playground client; `live` runs it headless at a fixed frame rate for benchmarks: +//! +//! threert-render live [--assets DIR] [--room id] [--width 1280 --height 720 --fps 60 --bounces 4 +//! --scale 1|0.6|auto --display-scale 1|2|3] [--frame-log frames.jsonl] +//! +//! `--scale` is the render scale (UE screen percentage): the frame is traced, denoised and composed at that fraction +//! of the output resolution and the temporal upscaler takes it to the output; `auto` = dynamic resolution. +//! `--display-scale`: the output is a display's physical pixels, that many a point (a Retina window: 2), as the +//! window host's is; dynamic resolution then starts from the points' resolution and goes down to half of it. +//! +//! stdin (JSON lines): {"cmd":"camera","pos":[x,y,z],"target":[x,y,z]} | {"cmd":"event","type":t,"payload":{..}} +//! | {"cmd":"click","u":u,"v":v} | {"cmd":"reset"} | {"cmd":"time","t":s} (set the scene time) +//! | {"cmd":"snapshot","path":p,"after":k (history length ≥ k),"frames":n (the n-th frame from now, default 1)} +//! (a path ending in .raw: the linear radiance, RGB f32 at output size; ending in .signals: +//! its diffuse / specular / emission apart, -{diffuse,specular,emission}.raw, as +//! `render --signals` writes them) +//! | {"cmd":"sync","id":s,"frames":n} (stderr "live: sync s" after the n-th frame from now) +//! | {"cmd":"lockstep","dt":s | "on":false} (render only the frames steps ask for, at frame time s) +//! | {"cmd":"camera",...,"frames":n,"sync":id | "snapshot":p} | {"cmd":"frames","n":n,"sync":id | "snapshot":p} +//! (a step: camera, frames, and the sync answer / snapshot at its last frame, in one command) +//! | {"cmd":"scale","scale":s (0 = auto)} | {"cmd":"quit"} +//! +//! The files: this one holds `LiveCore` itself (its settings, camera, resolution governor and readbacks) and what the +//! others share; `targets` the live passes' pipelines, screen-size buffers and bind group; `load` a scene loaded and +//! the workers of its sky and emitters; `edits` the world's edits applied in place; `frame` one frame (the trace, the +//! live passes, the caches' training, the GPU timing); `session` the headless runner (native only). + +use crate::gpu::Buf; +use crate::world::World; +use crate::{gpu, post, scene, tables}; +// (The headless runner, `session`, native only, is what uses all of it.) +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] +use anyhow::{anyhow, Result}; +use glam::Vec3; +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] +use std::time::Duration; +use web_time::Instant; + +mod edits; +mod frame; +mod load; +#[cfg(not(target_arch = "wasm32"))] +mod session; +mod targets; +#[cfg(test)] +pub(crate) use targets::live_source; +pub(crate) use targets::Targets; +#[cfg(not(target_arch = "wasm32"))] +pub use session::{parse_cmd, parse_launch, parse_opts, run, save_bgra}; +use edits::*; +use frame::*; +use load::*; +use targets::*; + +pub struct LiveOpts { + pub width: u32, + pub height: u32, + pub fps: u32, + pub bounces: u32, + /// Render scale (traced / output resolution); None = dynamic resolution. + pub scale: Option, + /// Game bundle (game code + runtime) that builds and drives the scene in-process. + pub bundle: PathBuf, + /// Asset root its model / texture paths resolve against. + pub assets: PathBuf, + pub room: String, + /// JSON line per frame (wall interval, CPU work, GPU time, scale, history): hitch statistics (tools/bench-hitches.ts). + /// `interval` runs from the end of one frame's work to the next's, so it carries the variation of the work itself; + /// `start` runs from one frame's start to the next's, which the pacer sets: the frame rate a player sees. + pub frame_log: Option, + /// The output's pixels a point of its display (1: the output is in points). + pub display_scale: f32, + /// `--launch`: the `params` of the host channel's `launch` message (docs/RUNTIME.md "The host channel"). + pub launch: Option, +} + +/// Lowest dynamic-resolution scale (UE r.DynamicRes.MinScreenPercentage default) of a view whose pixels are the +/// display's points; a host whose view is in the display's physical pixels divides it by the display's scale factor +/// (`LiveCore::min_scale`), so the fewest rays a window traces do not depend on the display's density. +pub const MIN_SCALE: f32 = 0.5; +/// The lowest render scale any view takes (a view of 3 pixels a point at `MIN_SCALE`, rounded down). +pub const SCALE_FLOOR: f32 = 0.125; + +#[repr(C)] +#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] +struct CamU { + pos: [f32; 4], + fwd: [f32; 4], + right: [f32; 4], + up: [f32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] +struct LiveParams { + cur: CamU, + prev: CamU, + size: [u32; 4], + misc: [f32; 4], + misc2: [f32; 4], + mode: [u32; 4], + post: [f32; 4], + /// Auto exposure (UE histogram): low / high fraction, histogram log2 min / max. + ae: [f32; 4], + /// Compensation (2^bias), min / max average luminance, mode (0 manual, 1 auto). + ae2: [f32; 4], + /// Speed up / down (EV/s), frame time (s), snap to target (1). + ae3: [f32; 4], + /// Local exposure: highlight contrast, shadow contrast, blurred-luminance blend, detail strength. + lx: [f32; 4], + /// Traced (internal) width, height. + isz: [u32; 4], + /// Bloom stage radii (texels of each stage's level) and tints, stages 0..3 then 4, 5. + br: [f32; 4], + br2: [f32; 4], + bt: [f32; 4], + bt2: [f32; 4], + /// Depth of field: CoC radius of infinity (output px; 0 = off), focus distance (m), max radius (px), -. + dof: [f32; 4], + /// This frame's sub-pixel jitter (every camera ray's: x, y in 0 .. 1 of a traced pixel), -, -. + 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], +} + +/// Auto / local exposure settings of the loaded scene (UE PostProcessVolume semantics). +#[derive(Clone, Copy, Default)] +struct ExposureSettings { + ae: [f32; 4], + ae2: [f32; 4], + speed: [f32; 2], + lx: [f32; 4], +} + +impl ExposureSettings { + fn new(exp: &crate::desc::Exposure, pp: &crate::desc::PostPreset) -> Self { + let a = &pp.auto_exposure; + let le = &pp.local_exposure; + let (ae2, comp) = match exp { + crate::desc::Exposure::Manual { ev100 } => ([2f32.powf(-ev100), 0.0, 0.0, 0.0], 2f32.powf(-ev100)), + crate::desc::Exposure::Auto { compensation, min_ev100, max_ev100 } => { + let c = 2f32.powf(*compensation); + ([c, 0.18 * 2f32.powf(*min_ev100), 0.18 * 2f32.powf(*max_ev100), 1.0], c) + } + }; + let _ = comp; + ExposureSettings { + ae: [a.low_percent.clamp(1.0, 99.0) / 100.0, a.high_percent.clamp(1.0, 99.0) / 100.0, a.histogram_log_min, a.histogram_log_max.max(a.histogram_log_min + 1.0)], + ae2, + speed: [a.speed_up.max(0.01), a.speed_down.max(0.01)], + lx: [le.highlight_contrast, le.shadow_contrast, le.blurred_luminance_blend, le.detail_strength], + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub struct Camera { + pub pos: Vec3, + pub fwd: Vec3, + pub up: Vec3, + pub right: Vec3, + pub tan_half_fov: f32, +} + +impl Camera { + pub fn look_at(pos: Vec3, target: Vec3, tan_half_fov: f32) -> Camera { + let fwd = (target - pos).normalize(); + let right = fwd.cross(Vec3::Y).normalize(); + Camera { pos, fwd, up: right.cross(fwd), right, tan_half_fov } + } + fn uniform(&self, aspect: f32) -> CamU { + CamU { + pos: [self.pos.x, self.pos.y, self.pos.z, self.tan_half_fov], + fwd: [self.fwd.x, self.fwd.y, self.fwd.z, aspect], + right: [self.right.x, self.right.y, self.right.z, 0.0], + up: [self.up.x, self.up.y, self.up.z, 0.0], + } + } +} + +pub enum Cmd { + Camera(Camera), + /// An event for the in-process game (type, payload). + Event(String, serde_json::Value), + /// A click at (u, v) for the in-process game (picks the node under it). + Click(f32, f32), + /// Messages of the host channel for the in-process game (`{"cmd":"host","messages":[{"t":...}, ...]}`: what an + /// app would send, its input among them; docs/RUNTIME.md "The host channel"). + Host(Vec), + /// Snapshot (path) of the first frame whose history length reaches `after` (None = any) and that is at least the + /// `frames`-th frame from now (None = the next). + Snapshot(PathBuf, Option, Option), + /// Answer `live: sync ` on stderr once the n-th frame from now is rendered (benchmarks step the camera frame by + /// frame, so what they measure does not depend on how many frames a busy machine renders per second). + Sync(String, u32), + /// Lockstep (benchmarks): with Some(frame time, s) the runner renders only the frames `Step`s ask for, at that frame + /// time (motion blur, exposure, frame script); None = real time again. + Lockstep(Option), + /// A step: an optional camera, `frames` frames to render (lockstep), and a sync answer / snapshot at the last of them, + /// registered with the camera in one command so neither can land a frame late. + Step { cam: Option, frames: u32, sync: Option, snapshot: Option }, + /// Drop all accumulated history (benchmarks measure convergence from here). + Reset, + /// Set the scene time, s (benchmarks: with lockstep, frame k from here renders at t + k x its frame time; frame + /// time 0 holds it there). + Time(f32), + /// View mode (see LiveCore::view_mode). + Mode(u32), + Bounces(u32), + /// Max samples per frame (adaptive below it). + SppCap(u32), + /// Viewer status text (e.g. the game score), shown in the window title. + Status(String), + /// Render scale (0 = dynamic resolution). + Scale(f32), + Quit, +} + +/// The real-time renderer: scene + screen targets + camera/history state. Records one frame at a time; the output +/// (sRGB BGRA8 per pixel) lands in `targets.out`. Used by the headless runner, the native window viewer and the playground. +pub struct LiveCore { + /// The game's particle spawns since the last frame (`World::particle_spawns`), for the GPU particle pass. + particle_spawns: std::collections::BTreeMap>, + /// The sources the game gave its density grids since the last frame (`World::grid_sources`), for the grid pass. + grid_sources: std::collections::BTreeMap>, + pub g: gpu::Gpu, + passes: Passes, + pub targets: Targets, + sc: LoadedScene, + presets: PathBuf, + /// Asset root (models, file textures). + assets: PathBuf, + pub w: u32, + pub h: u32, + /// Committed state sequence number the image shows (set by the game session). + pub seq: i64, + aspect: f32, + pub cam: Camera, + prev_cam: Camera, + pub frame: u32, + pub history_len: f32, + reset: bool, + /// Bloom stage radii / tints (`bloom_stages`). + bloom: ([f32; 6], [f32; 6]), + vignette: f32, + /// Chromatic aberration, film grain, bloom threshold, longest motion blur (output pixels). + post: [f32; 4], + /// Motion blur (UE MotionBlurAmount, MotionBlurTargetFPS; amount 0 = off). + blur: (f32, f32), + /// Last submission of each frame still on the GPU (at most two frames in flight). + inflight: std::collections::VecDeque, + pub gpu_ms: f32, + /// The frame `gpu_ms` timed (it lags by the frames in flight). + gpu_frame: u32, + /// That frame's trace pass alone (the path-tracing kernel: intersection and shading), ms; 0 without timestamps. + /// Frame log `trace`: the per-frame cost a geometry representation changes, without THREERT_PROFILE's stalls. + pub gpu_trace_ms: f32, + /// This frame's geometry edits applied in place (`edit_geometry`: CPU ms, its BLAS / TLAS builds submitted), and + /// scene rebuilds (ms) and, when edits fell back to one, why: summed over the frame's commits (a game event's and the + /// frame script's), reset when the frame is logged. Fallbacks since the viewport opened. + /// Frame log `edit`, `rebuild`, `fallback`, `fallbacks`. + pub edit_ms: f32, + /// CPU time of this frame's in-place instance updates (`update_instanced`), ms. + pub instanced_ms: f32, + pub edit_split: EditSplit, + pub rebuild_ms: f32, + pub fallback: Option, + pub fallbacks: u32, + /// Path-tracing bounces for real-time frames (the offline preset uses more). + pub bounces: u32, + /// Ray tracing (true). Off (`set_rays`), the kernel traces the camera ray of each pixel and no other: no shadows, + /// no bounce light, reflections of the sky only; analytic lights and emissive surfaces light unoccluded. The + /// fallback for GPUs that trace too slowly (no hardware ray queries), and the "what tracing adds" comparison on + /// those that do: the same scene, materials, exposure and post either way. + rays: bool, + /// 0 lit, 1 base color, 2 world normal, 3 lit without denoiser, 4 raw sample, 5 history length, 6 diffuse, + /// 7 specular, 8 deterministic direct light (THREERT_VIEW_MODE at start: e.g. bench-temporal per signal). + pub view_mode: u32, + /// Lockstep frame time (s; benchmarks): motion blur and exposure adaptation use it instead of the wall clock. + pub fixed_dt: Option, + /// A/B switch (playground key N): motion blur off whatever the scene's post settings say. + pub motion_blur_off: bool, + pub triangles: usize, + pub scene_name: String, + /// Scene time, s: what `advance_time` added up, the dt each frame script gets, so it is the game's clock (`f.time`) + /// and a burst fired at a game time starts then; lockstep runs repeat exactly. Animated shading, the particle pass + /// and power edits' time stamps use it (`shader_time`). + time: f32, + /// Time between rendered frames (auto exposure adaptation), s. + frame_dt: f32, + last_frame: Instant, + /// The post preset the LUT / bloom / grain settings were built from (scenes may override it). + post_key: String, + /// Traced / output resolution (UE screen percentage); the temporal pass upsamples below 1. + pub render_scale: f32, + /// Adjust `render_scale` to the frame budget (`govern_resolution`). + pub dynamic_res: bool, + /// Dynamic resolution's lowest scale: `MIN_SCALE` over the display's scale factor. + pub min_scale: f32, + /// The view's pixels a point of its display (`set_display_scale`): dynamic resolution's steps are a point's. + display_scale: f32, + /// Dynamic resolution: consecutive measured frames well over the frame time, and the first frame at the current + /// scale. + over_budget: (u32, u32), + /// When this view was made, and when its render scale was last checked (`govern_resolution` by the clock). + born: Instant, + governed: Instant, + /// THREERT_PROFILE=1: GPU time per pass (timestamp queries), printed every 2 s. + profile: Option, + /// GPU time per frame without stalls (timestamp queries; None: measured by draining the queue twice a second). + timer: Option, + /// Rasterized primary visibility (None: the kernel traces camera rays). + raster: Option, + /// World-space radiance cache (pathtrace.wgsl; None: THREERT_CACHE=0, paths trace every bounce). + cache: Option, + /// Kernel group 1: primary visibility + radiance cache + what the live passes read of a trace. + kernel_bg: wgpu::BindGroup, + /// Span (relative to the dynamic tail) re-transformed by the last in-place update whose previous-frame vertices + /// (`Uploaded::prev_verts`) still hold the frame before it: synced to the current ones next frame. + prev_span: Option<(usize, usize)>, + /// An in-place update happened this frame (it keeps `prev_verts` itself). + reposed: bool, + /// Dynamic triangles moved in this commit: the light tree's moving subtree is refit once at its end. + emitters_moved: bool, + /// Frames since the last reset or camera cut (`COLD_FALLBACK_FRAMES`). + cold_frames: u32, + /// Likewise for the deforming meshes of shared poses (`repose_groups`): the span they took (relative to the dynamic + /// tail, past its end) and whether this frame re-skinned any. + pose_span: Option<(usize, usize)>, + pose_reposed: bool, + /// Movable instances moved by the last update (their records' previous-frame rows differ), and whether it was this + /// frame's. + inst_moving: Vec, + inst_moved: bool, + /// The previous frame's traced size (its traced-grid diffuse history; 0 before the first frame). + prev_isz: (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, + /// The scene has light-sampled particles (emitter table entries with a nominal area). + spark_lights: bool, + /// Samples per pixel of the last frame rendered (the kernel's sample buffer holds their sums). + shown_spp: u32, + /// CPU time of the last commit (ms): skinning and re-transforming moving objects (`reskin`, rigid instance poses), and + /// uploading them with the dynamic BLAS / TLAS rebuild submitted (frame log `skin`, `upload`). + pub cpu_skin_ms: f32, + pub cpu_upload_ms: f32, + /// THREERT_PROFILE_BLAS=1: GPU time of the last dynamic BLAS / TLAS rebuild (ms; frame log `blas`), measured by draining + /// the queue before it and waiting for it after (it stalls the frame: measurement runs only). The pass profile has + /// no timestamps for it (Metal cannot write them between acceleration-structure builds). + pub gpu_blas_ms: f32, + /// CPU time the last `render` spent waiting for the oldest frame in flight (ms; frame log `wait`): GPU-bound frames. + pub cpu_wait_ms: f32, + /// The cloud cover the sky and the sun light were made with (`move_sun`). + sky_cover: f32, + /// The loop guard (merged.rs `guard`), if a kernel of this session may have it: the invocations it has stopped at + /// its limit since the view was made, as last read back (a frame or two behind, without waiting). Above zero: + /// frames with wrong pixels, a traversal that did not end on the scene's data. None without the guard. + pub guard_trips: Option, + /// Its readback: the buffer and its state (0 free, 1 mapping, 2 mapped), as the frame timer's. + guard_read: Option<(wgpu::Buffer, Arc)>, +} + +/// THREERT_CACHE_FLAGS: experiment switches of the radiance cache (pathtrace.wgsl `cfl`), 0 = defaults: 1 = no cold +/// fallback (cold voxels read as they are), 2 = no hit lighting (reflection hits use the cache's total), 4 = no +/// training tiles, 8 = the exact light loop at reflection hits. THREERT_CACHE=0 turns the cache off. +/// Frames after a reset or a camera cut during which pixels whose radiance-cache voxel is cold trace a fresh estimate +/// (pathtrace.wgsl, cache flag 1 off). Later the cold voxels left are mostly where bounce rays land off screen, +/// scattered, sparsely trained: their traces cost each SIMD group nearly a whole extra trace (Bistro's path 0.7-1.2 +/// ms, the crowd's square 2.3 ms) for ~1 % of the image (bench-crowd ΔE 13.1 -> 13.3). +const COLD_FALLBACK_FRAMES: u32 = 30; + +/// Ceiling for one live lighting sample, in display units at the current exposure (live.wgsl `sample_clamp`): fireflies +/// far above white. 64 until 2026-11-09, when it cut the light of small strong lights that the denoisers now keep: the +/// Bistro crowd in motion (lockstep at 10 s) diffuse 0.87 -> 0.90 of our offline render, specular 0.83 -> 0.89, the +/// pavement under the headlights 0.71 -> 0.77. 1024 adds nothing more. +const SAMPLE_CEILING: f32 = 256.0; + +/// THREERT_GOVERN_CLOCK=1|0 (default 1 on Android): dynamic resolution also checks by the clock, so a view whose +/// frames are far slower than 60 a second comes down to its frame rate in seconds (`LiveCore::govern_resolution`). +fn govern_by_clock() -> bool { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_GOVERN_CLOCK").map(|v| v == "1").unwrap_or(cfg!(target_os = "android"))) +} + +fn cache_flags() -> u32 { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_CACHE_FLAGS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)) +} + +/// THREERT_FREEZE_TIME=1: animation holds still (shader time 0, the frame script gets dt 0: signs, screens, flicker groups), +/// so frames taken at different times compare (tools/bench-ghosting.ts). +fn freeze_time() -> bool { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_FREEZE_TIME").is_ok_and(|v| v == "1")) +} + +/// Seconds of animated shading (sign flicker, screens, ripples) and particles: the scene time; 0 under THREERT_FREEZE_TIME. +fn shader_time(time: f32) -> f32 { + if freeze_time() { 0.0 } else { time } +} + +/// THREERT_EXPOSURE_SNAP=1: auto exposure jumps to its target every frame (no adaptation lag), so a frame taken while the +/// view moves has the exposure a settled view of that pose would have (tools/bench-ghosting.ts). +fn exposure_snap() -> bool { + static F: std::sync::OnceLock = std::sync::OnceLock::new(); + *F.get_or_init(|| std::env::var("THREERT_EXPOSURE_SNAP").is_ok_and(|v| v == "1")) +} + +/// UE's physical CoC model (DiaphragmDOFUtils.cpp, FPhysicalCocModel::Compile): focal length from the sensor height +/// (sensor width / aspect) and the vertical FOV, the CoC of infinity on the sensor f^2 / (N (focus - f)), as a radius in +/// output pixels; max radius r.DOF.Kernel.Max{Fore,Back}groundRadius = 2.5 % of the width. Off without a focus distance. +fn dof_params(dof: Option<&crate::desc::Dof>, tan_half_fov: f32, w: u32, h: u32) -> [f32; 4] { + let Some(d) = dof.filter(|d| d.focus_distance > 0.0 && d.f_stop > 0.0 && d.sensor_width > 0.0) else { return [0.0; 4] }; + let aspect = w as f32 / h as f32; + let sensor_h = d.sensor_width / aspect; + let f = 0.5 * sensor_h / tan_half_fov; + let diameter = f * f / (d.f_stop * (d.focus_distance * 1000.0 - f).max(1e-3)); + [diameter * 0.5 / sensor_h / aspect * w as f32, d.focus_distance, 0.025 * w as f32, 0.0] +} + +/// Live passes that run on the traced grid (the rest on the output grid). +fn traced_pass(name: &str) -> bool { + name == "refl" || name == "dtemporal" || name.starts_with("atrous") || name == "stemporal" || name.starts_with("sfilt") || name == "compose" || name == "judge" || name == "reject" +} + + +/// Light LOD (`emitter_lod`): emitters within this distance of the camera keep their sampling weight, farther ones +/// lose it as (d0 / d)^2. +const LIGHT_LOD_D0: f32 = 10.0; + +/// Emitters farther than this from the light LOD's reference are not proposed (`emitter_lod`); BSDF rays bring their +/// light. +const EMITTER_CULL: f32 = 60.0; + +/// The cull's reach inside the view cone (`EmitterCull`), in cull radii: emitters ahead of the camera are kept out to +/// this. +const EMITTER_CULL_VIEW: f32 = 2.0; +/// The view cone: the frustum's half-diagonal plus this margin (degrees); the CDF is rebuilt when the camera turns by +/// LIGHT_LOD_TURN, so the frustum stays inside the cone between rebuilds. +const EMITTER_CULL_MARGIN: f32 = 20.0; +const LIGHT_LOD_TURN: f32 = 15.0; + + + +/// Halton(base) of index i (sub-pixel jitter sequence). +fn halton(mut i: u32, base: u32) -> f32 { + let (mut f, mut r) = (1.0f32, 0.0f32); + while i > 0 { + f /= base as f32; + r += f * (i % base) as f32; + i /= base; + } + r +} + +/// History caps of the cache: samples (noise), frames (lag; shorter for a while after a lighting edit). +const CACHE_SAMPLES: f32 = 256.0; +const CACHE_FRAMES: f32 = 32.0; +const CACHE_FRAMES_LIT: f32 = 4.0; +/// The most frames a pixel's history holds while the scene's lighting changes: short enough to follow the change by +/// itself (a real-time denoiser's bound: NRD's default is 30), so no edit has to cut it. Against main's rules (24 +/// frames for good in a scene with spark lights, every pixel cut to 16 at a lighting edit, an on / off anti-lag), dE +/// against the offline render or the converged frame: the Bistro slice half a second after its explosion 6.04 for +/// 6.58, at rest 2.82 for 2.79; Kabukichō 2 s and 4 s after its signs close 6.70 and 2.92 for 6.56 and 2.82. At 64 +/// frames the explosion read 5.72 and the signs 7.82 and 3.37. +const HISTORY_LIVE: f32 = 32.0; +/// The most once the scene has settled: holding still, the frame converges on the offline render. +const HISTORY_FRAMES: f32 = 4096.0; + +/// THREERT_TL_HIDE (diagnostics): the translucency pass trains the volume voxels but draws nothing, so frames show what is +/// behind translucent particles; `odd` hides them on odd frames only (rain on / off pairs from one run). +fn translucent_hidden(frame: u32) -> bool { + static F: std::sync::OnceLock> = std::sync::OnceLock::new(); + match F.get_or_init(|| std::env::var("THREERT_TL_HIDE").ok().map(|v| v == "odd")) { + None => false, + Some(false) => true, + Some(true) => frame % 2 == 1, + } +} + +impl LiveCore { + /// Advances the scene time by a frame's dt (the frame script's: at most 0.1 s). + pub fn advance_time(&mut self, dt: f32) { + self.time += dt.clamp(0.0, 0.1); + } + + /// Sets the scene time: a game session's frame script clock (`GameSession::frame`). + pub fn set_time(&mut self, t: f32) { + self.time = t; + } + + /// The scene time animation sees (0 under THREERT_FREEZE_TIME). + pub fn scene_time(&self) -> f32 { + shader_time(self.time) + } + + /// A live view of the engine's world; asset files resolve against `assets`. + pub fn new(g: gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32, h: u32) -> Result { + let passes = Passes::new(&g); + // The scene's post preset replaces this LUT below (apply_post). + let cache_on = !std::env::var("THREERT_CACHE").is_ok_and(|v| v == "0"); + // (Without the cache the kernel binds one of one entry.) + let targets = Targets::new(&g, w, h, &vec![0u16; (LUT * LUT * LUT * 4) as usize], if cache_on { gpu::CACHE_SIZE } else { 1 }); + let (sc, pp) = load_scene(&g, world, assets, presets, w, h, &targets, &passes)?; + let cam = sc.scene_camera; + let (triangles, scene_name) = (sc.triangles, sc.name.clone()); + let profile = (g.timestamps && std::env::var("THREERT_PROFILE").is_ok()).then(|| { + // trace, the post passes, cache resolve, then the particle pass, the BLAS rebuild, the translucency pass and + // the volume training. + let n = (passes.pipes.len() as u64 + 6) * 2; + Profile { + queries: g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile"), ty: wgpu::QueryType::Timestamp, count: n as u32 }), + resolve: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), + read: g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: n * 8, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + sums: vec![0.0; passes.pipes.len() + 6], + frames: 0, + grid: ( + g.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("profile grid"), ty: wgpu::QueryType::Timestamp, count: 6 }), + g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 2 * wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }), + g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 48, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), + ), + grid_sum: 0.0, + media_sum: 0.0, + } + }); + let timer = (g.timestamps && profile.is_none()).then(|| FrameTimer::new(&g.device)); + let raster = std::env::var("THREERT_RASTER").is_ok_and(|v| v == "1").then(|| Raster::new(&g, w, h)); + let cache = cache_on.then(|| RadianceCache { bufs: targets.kernel.cache.clone(), resolve: g.cache_resolve_pipeline(&targets.kernel) }); + let kernel_bg = gpu::vis_bind_group(&g.device, &g.vis_layout, raster.as_ref().map_or(&g.no_vis_view, |r| &r.vis), &targets.kernel); + // (Read whenever a kernel of this session may have the guard: under software traversal a scene's first unit + // 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 }; + 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); + Ok(core) + } + + pub fn scene_camera(&self) -> Camera { + self.sc.scene_camera + } + + /// `guard_trips` for the frame log: None while the scene's kernel has no guard. Natively with nothing set that is + /// a scene without units (`gpu::loop_guard_of`): under software traversal the first unit brings the guard. + pub fn guard_count(&self) -> Option { + self.guard_trips.filter(|_| gpu::loop_guard_of(self.sc.up.kernel.features & gpu::F_UNITS != 0).is_some()) + } + + /// The edit room as the frame log reports it (`room`): triangles, records and object entries in use and held (GPU + /// order counts, holes not in use), its growths, and the GPU bytes of the buffers edits write and of the triangle + /// BLASes the live meshes need (estimated from the build's bytes per triangle), MB. + pub fn room_stats(&self) -> serde_json::Value { + let (e, up) = (&self.sc.edit, &self.sc.up); + // Software traversal's (it does not grow): the same counts, and of the node buffer the mesh trees' range (in + // use, held) and the units the top tree has against the most its range holds. + if let Some(u) = up.units.as_ref() { + return serde_json::json!({ + "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], + "holes": e.tris.free.len(), "grows": e.grows, "nodes": [u.layout.mesh_cap as usize - u.layout.free_nodes(), u.layout.mesh_cap], "units": [u.count, u.layout.max_units()], + }); + } + let Some(a) = up.accel.as_ref() else { return serde_json::Value::Null }; + let buffers = [&up.verts, &up.attrs, &up.prev_verts, &up.instances, &up.objects, &up.cdf, &up.emitters, &a.verts].iter().map(|b| b.size()).sum::(); + let live: u64 = self.sc.meshes.iter().enumerate().filter(|(k, _)| e.users.get(&(*k as u32)).copied().unwrap_or(0) > 0).map(|(_, m)| m.count as u64).sum::() + e.pool_first as u64; + let mb = |b: f64| (b / (1 << 20) as f64 * 10.0).round() / 10.0; + serde_json::json!({ + "tris": [e.tris.used(), e.tris.cap], "records": [e.records.used(), e.records.cap], "objects": [e.objects.used(), e.objects.cap], + "holes": e.tris.free.len(), "grows": e.grows, "buffers_mb": mb(buffers as f64), "blas_mb": mb(live as f64 * a.blas_per_tri), + }) + } + + /// Software traversal's top tree so far (the frame log's `top`): how many times it was built, kept with leaves + /// taken out and put in, and kept with its boxes brought up to date, and the ms all of that took. Null without + /// units. + pub fn top_stats(&self) -> serde_json::Value { + self.sc.up.units.as_ref().map_or(serde_json::Value::Null, |u| serde_json::json!([u.kept.builds, u.kept.changes, u.kept.refits, (u.kept.ms * 1e3).round() / 1e3])) + } + + pub fn rebuild(&mut self, world: &World) -> Result<()> { + let (s, pp) = load_scene(&self.g, world, &self.assets, &self.presets, self.w, self.h, &self.targets, &self.passes)?; + self.apply_post(&pp); + self.triangles = s.triangles; + self.scene_name = s.name.clone(); + // The scene's camera changed (a new lens rebuilds, and a move in the same commit comes with it): the view takes it. + // Otherwise the view stays where it is. + if s.scene_camera != self.sc.scene_camera { + self.cam = s.scene_camera; + } + // What the grid programs had simulated goes with the grids that are still the same. + self.g.carry_grids(&self.sc.up, &s.up); + self.sc = s; + self.spark_lights = self.sc.tables.emitter_tris.iter().any(|e| e.nominal); + self.inst_moving.clear(); + self.prev_span = None; + self.pose_span = None; + self.emitters_moved = false; + self.lit_changed = self.frame.max(1); + Ok(()) + } + + /// Rebuilds the tonemap LUT and bloom / vignette / fringe / grain settings when the scene's post preset changed. + fn apply_post(&mut self, pp: &crate::desc::PostPreset) { + let key = format!("{pp:?}"); + if key == self.post_key { + return; + } + let lut = tonemap_lut(pp); + self.g.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &self.targets.lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(&lut), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, + wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, + ); + self.bloom = bloom_stages(pp, self.w, self.h); + self.vignette = pp.vignette; + // THREERT_NO_GRAIN=1: no film grain (a fresh random pattern every frame; comparisons between frames see it as error). + let grain = if std::env::var("THREERT_NO_GRAIN").is_ok_and(|v| v == "1") { 0.0 } else { pp.film_grain }; + // THREERT_MOTION_BLUR=1: UE's default motion blur whatever the preset says; =amount,max,targetFps: those. + let mb = match std::env::var("THREERT_MOTION_BLUR").ok().as_deref() { + Some("1") => crate::desc::MotionBlur::default(), + Some(v) => match v.split(',').map(|x| x.trim().parse::()).collect::, _>>().as_deref() { + Ok([amount, max, target_fps]) => crate::desc::MotionBlur { amount: *amount, max: *max, target_fps: *target_fps }, + _ => pp.motion_blur.clone(), + }, + None => pp.motion_blur.clone(), + }; + self.post = [pp.chromatic_aberration, grain, pp.bloom.threshold, mb.max / 100.0 * self.w as f32]; + self.blur = (if mb.max > 0.0 { mb.amount.max(0.0) } else { 0.0 }, mb.target_fps); + self.post_key = key; + } + + /// Switches to a different world: camera from it, history cleared. + pub fn open(&mut self, world: &World, assets: &Path) -> Result<()> { + self.assets = assets.to_path_buf(); + self.rebuild(world)?; + if let Some(c) = &self.cache { + let mut e = self.g.device.create_command_encoder(&Default::default()); + c.bufs.clear(&mut e); + self.g.queue.submit([e.finish()]); + } + self.cam = self.sc.scene_camera; + self.prev_cam = self.cam; + self.reset = true; + Ok(()) + } + + /// The view is a display's physical pixels, `k` a point: dynamic resolution, from the points' resolution (the rays + /// a view in points starts with) down to `MIN_SCALE` of it. A fixed scale is set after it (`set_scale`). + pub fn set_display_scale(&mut self, k: f32) { + let k = k.max(1.0); + self.display_scale = k; + self.min_scale = (MIN_SCALE / k).max(SCALE_FLOOR); + self.render_scale = 1.0 / k; + self.dynamic_res = true; + } + + /// The scene's lighting is changing: something moved or was edited in the last 45 frames, or the scene has spark + /// lights (light-sampled particles: they move every frame, and no edit says so). + fn live_lighting(&self) -> bool { + self.spark_lights || (self.lit_changed > 0 && self.frame.wrapping_sub(self.lit_changed) < 45) + } + + /// Fixed render scale (`s` in SCALE_FLOOR ..= 1), or dynamic resolution (`s` = 0). + pub fn set_scale(&mut self, s: f32) { + self.dynamic_res = s <= 0.0; + if s > 0.0 { + self.render_scale = s.clamp(SCALE_FLOOR, 1.0); + } + } + + pub fn reset_history(&mut self) { + self.reset = true; + } + + /// Ray tracing on or off (see `rays`). The history starts over: the two modes light a pixel differently. + pub fn set_rays(&mut self, on: bool) { + if self.rays != on { + self.rays = on; + self.reset = true; + } + } + + pub fn rays(&self) -> bool { + self.rays + } + + pub fn set_camera(&mut self, mut c: Camera) { + if c.tan_half_fov <= 0.0 { + c.tan_half_fov = self.sc.tan_half_fov; + } + self.cam = c; + } + + /// Renders one frame with `spp` samples per pixel; returns the command buffers to submit after the samples. + /// Path-trace dispatches are submitted inside (each needs its own uniform contents). + /// Traced resolution at the current render scale. + pub fn internal_size(&self) -> (u32, u32) { + let s = self.render_scale.clamp(SCALE_FLOOR, 1.0); + (((self.w as f32 * s).round() as u32).clamp(8, self.w), ((self.h as f32 * s).round() as u32).clamp(8, self.h)) + } + + /// Dynamic resolution (UE r.DynamicRes): twice a second, move the render scale toward the frame budget (traced + /// pixels ~ GPU time), within [`min_scale`, 1], in damped steps with hysteresis so it does not oscillate. + /// Two frames in a row well over the frame time (x 1.25) act at once (UE + /// r.DynamicRes.MaxConsecutiveOverbudgetGPUFrameCount = 2): turning from a light view at a raised scale toward a + /// heavy one otherwise cost up to 30 frames at 35-48 ms (the 3 x 3 district's drive, its far end). Just over the + /// frame time waits for the regular check, as before. After a change, only frames rendered at the new scale count: + /// GPU time lags the scale by the frames in flight, and acting on the old scale's time overshot to the floor. + /// + /// "Twice a second" and "the first 2 s" are counted in frames (every 30th, the first 120), which is the same thing + /// at 60 frames a second. Where frames are much slower (`govern_by_clock`: a phone's GPU, on which a scene opens + /// at its full scale at 6 frames a second) they are also read off the clock: counted in frames alone the first + /// check that may act at once came after 20 s and the regular one every 5 s, and a game opened as a slide show + /// for half a minute (Adreno 740, OpenStrikeRT: 25 s to its frame rate; 3 s with the clock). + pub fn govern_resolution(&mut self, fps: u32) { + if !self.dynamic_res || self.gpu_ms <= 0.0 { + return; + } + if (self.gpu_frame.wrapping_sub(self.over_budget.1) as i32) < 0 { + return; + } + let clock = govern_by_clock(); + // Not in the first 2 s: those frames run while caches fill (Kabukichō's first frames at 0.7 take ~47 ms), and + // the regular check settles the start as before. + let warm = self.frame >= 120 || (clock && self.born.elapsed().as_secs_f32() >= 2.0); + self.over_budget.0 = if warm && self.gpu_ms > 1250.0 / fps as f32 && self.render_scale > self.min_scale { self.over_budget.0 + 1 } else { 0 }; + let urgent = self.over_budget.0 >= 2; + // (By the clock only when 30 frames take longer than 0.75 s: at 40 frames a second and above the frame count + // comes first, as it always did.) + let due = self.frame % 30 == 1 || (clock && self.governed.elapsed().as_secs_f32() >= 0.75); + if !due && !urgent { + return; + } + self.governed = Instant::now(); + self.over_budget.0 = 0; + let before = self.render_scale; + let budget = 1000.0 / fps as f32 * 0.9; + let target = (self.render_scale * (budget / self.gpu_ms).sqrt()).clamp(self.min_scale, 1.0); + // The hysteresis (0.03) does not hold the scale just short of either end: 0.525 with the floor as the target + // kept the district's return drive at 21-22 ms. + let to_end = (target == 1.0 && self.render_scale < 1.0 && self.gpu_ms < budget * 0.8) || (target == self.min_scale && self.render_scale > self.min_scale); + // (The hysteresis, the steps and the grid of scales are a view in points'; on a display of k pixels a point the + // same share of the rays is 1 / k of the scale. Left absolute, a Retina view held 0.325 at 17.3 ms: its target, + // 0.30, was inside the hysteresis.) + let k = self.display_scale; + if (target - self.render_scale).abs() > 0.03 / k || to_end { + // Down fast when far over budget (a heavy scene just opened), up slowly; straight to the target when urgent. + let step = if urgent { target - self.render_scale } else { (target - self.render_scale).clamp(if self.gpu_ms > budget * 1.5 { -0.3 } else { -0.15 } / k, 0.1 / k) }; + self.render_scale = ((self.render_scale + step) * 40.0 * k).round() / (40.0 * k); + } + if self.render_scale != before { + // `frame` is already the next frame's index (render advanced it): the first one at the new scale. + self.over_budget.1 = self.frame; + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// The last frame's linear HDR radiance (nits, before exposure and post), RGB per output pixel. Waits for the GPU. + pub fn read_hdr(&self) -> Result> { + let d = &self.g.device; + let size = self.w as u64 * self.h as u64 * 16; + let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("hdr read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(&self.targets.fa.buffer, self.targets.fa.offset, &read, 0, size); + self.g.queue.submit([e.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely())?; + let px = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap()).iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + Ok(px) + } + + #[cfg(not(target_arch = "wasm32"))] + /// The last frame's signals apart, linear RGB f32 per output pixel: diffuse (the composed colour less the rest, so + /// with the sun's direct light, which view mode 6 leaves out), specular (the specular denoiser's output: mode 7) and + /// emission (what the camera ray saw directly). They are the render resolution's (the frame as `compose` hands it to + /// the upscaler), each output pixel taking the traced sample under it; not with depth of field and motion blur both + /// on (the composed colour's buffer is theirs then). Waits for the GPU. + pub fn read_signals(&self) -> Result<[Vec; 3]> { + let d = &self.g.device; + let (w, h) = (self.w as usize, self.h as usize); + let (iw, ih) = (self.prev_isz.0 as usize, self.prev_isz.1 as usize); + let (n, tn) = ((w * h) as u64, (iw * ih) as u64); + let t = &self.targets; + let read = |buffer: &wgpu::Buffer, offset: u64, size: u64| -> Result> { + let r = d.create_buffer(&wgpu::BufferDescriptor { label: Some("signals read"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(buffer, offset, &r, 0, size); + self.g.queue.submit([e.finish()]); + r.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely())?; + let v = r.slice(..).get_mapped_range()?.to_vec(); + Ok(v) + }; + let half = |b: u32, hi: bool| half::f16::from_bits(if hi { (b >> 16) as u16 } else { b as u16 }).to_f32(); + let composed = read(&t.fb.buffer, t.fb.offset, tn * 16)?; + let spec = read(&t.spec_pp.buffer, t.spec_pp.offset + n * 8, tn * 8)?; // spec_pp slot 1: (rgb, history) as two pairs of f16 + let samples = read(&self.sc.up.accum, 0, tn * 80)?; // the kernel's samples (live.wgsl `Pixel`): emit at 48 + let (composed, spec, samples): (&[[f32; 4]], &[[u32; 2]], &[[f32; 20]]) = (bytemuck::cast_slice(&composed), bytemuck::cast_slice(&spec), bytemuck::cast_slice(&samples)); + let (mut dif, mut spe, mut emi) = (Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3), Vec::with_capacity(w * h * 3)); + let finite = |v: f32| if v.is_finite() { v } else { 0.0 }; + for y in 0..h { + for x in 0..w { + // live.wgsl `sample_under`. + let q = (((y as f32 + 0.5) * ih as f32 / h as f32) as usize).min(ih - 1) * iw + (((x as f32 + 0.5) * iw as f32 / w as f32) as usize).min(iw - 1); + // The media before the surface (live.wgsl `medium_tr`): the specular as the frame shows it. + let tr = (samples[q][11].fract() * 2.0).clamp(0.0, 1.0); + let s = [half(spec[q][0], false), half(spec[q][0], true), half(spec[q][1], false)].map(|v| v * tr); + for c in 0..3 { + let e = finite(samples[q][12 + c] / self.shown_spp as f32); + spe.push(s[c]); + emi.push(e); + dif.push((composed[q][c] - s[c] - e).max(0.0)); + } + } + } + Ok([dif, spe, emi]) + } + + #[cfg(not(target_arch = "wasm32"))] + /// The exposure the last frame was shown at, EV100 (auto exposure: its adapted value). + pub fn read_ev100(&self) -> Result { + if self.sc.exposure_settings.ae2[3] <= 0.5 { + return Ok(-self.sc.exposure.log2()); + } + let d = &self.g.device; + let read = d.create_buffer(&wgpu::BufferDescriptor { label: Some("ae read"), size: 16, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(&self.targets.ae_state.buffer, self.targets.ae_state.offset, &read, 0, 16); + self.g.queue.submit([e.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely())?; + let s = bytemuck::cast_slice::(&read.slice(..).get_mapped_range().unwrap())[0]; + Ok(-s.max(1e-30).log2()) + } + + /// Phases of the last scene (re)load, ms. + pub fn load_timings(&self) -> &[(String, f64)] { + &self.sc.timings + } + + pub fn aspect(&self) -> f32 { + self.aspect + } + + /// Primary ray through (u, v) of the image (0..1, v down), as the path tracer generates it. + pub fn ray(&self, u: f32, v: f32) -> (Vec3, Vec3) { + let c = &self.cam; + let d = c.fwd + c.right * ((u * 2.0 - 1.0) * c.tan_half_fov * self.aspect) + c.up * ((1.0 - v * 2.0) * c.tan_half_fov); + (c.pos, d.normalize()) + } + + /// Closest surface under (u, v): (node id, world-space hit point). CPU ray cast over the scene triangles, and over + /// each instance's model-space mesh with the ray in its space (same ray parameter t). + /// The object under view point (u, v): its name, the hit point, and for an instanced object the instance's index. + pub fn pick(&self, u: f32, v: f32) -> Option<(String, Vec3, Option)> { + let (o, d) = self.ray(u, v); + let (o, d) = (glam::Vec3A::from(o), glam::Vec3A::from(d)); + // Moller-Trumbore, two-sided: the ray parameter of the closest hit in `tris` beyond `near`. + let closest = |tris: &[[glam::Vec3A; 3]], o: glam::Vec3A, d: glam::Vec3A| { + let mut best = (f32::INFINITY, usize::MAX); + for (i, [a, b, c]) in tris.iter().enumerate() { + let (e1, e2) = (*b - *a, *c - *a); + let p = d.cross(e2); + let det = e1.dot(p); + if det.abs() < 1e-12 { + continue; + } + let inv = 1.0 / det; + let s = o - *a; + let bu = s.dot(p) * inv; + if !(0.0..=1.0).contains(&bu) { + continue; + } + let q = s.cross(e1); + let bv = d.dot(q) * inv; + if bv < 0.0 || bu + bv > 1.0 { + continue; + } + let t = e2.dot(q) * inv; + if t > 1e-4 && t < best.0 { + best = (t, i); + } + } + best + }; + let (mut t, i) = closest(&self.sc.tris, o, d); + let mut node = self.sc.tri_nodes.iter().find(|(_, a, b)| (*a as usize..*b as usize).contains(&i)).map(|n| n.0.as_str()); + let mut sub = self.sc.tri_instances.iter().find(|(a, b, _)| (*a as usize..*b as usize).contains(&i)).map(|r| r.2); + let added = |want_mesh: bool| self.sc.edit.added.values().filter_map(move |p| match p { + Picked::Mesh(i) if want_mesh => Some(Ok(i)), + Picked::Proc(q) if !want_mesh => Some(Err(q)), + _ => None, + }); + for inst in self.sc.instances.iter().filter(|i| !i.hidden).chain(added(true).filter_map(|x| x.ok())) { + let m = self.sc.meshes[inst.mesh as usize]; + let inv = inst.world.inverse(); + let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); + // Slab test against the mesh bounds before its triangles. + let (t0, t1) = ((m.lo - lo) / ld, (m.hi - lo) / ld); + let (near, far) = (t0.min(t1).max_element(), t0.max(t1).min_element()); + if far < near.max(0.0) || near > t { + continue; + } + let (ti, _) = closest(&self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize], lo, ld); + if ti < t { + t = ti; + node = Some(inst.name.as_str()); + sub = inst.sub; + } + } + // Procedural geometry: its boxes (the geometry function runs on the GPU only). + for p in self.sc.procs.iter().filter(|p| !p.hidden).chain(added(false).filter_map(|x| x.err())) { + let inv = p.world.inverse(); + let (lo, ld) = (inv.transform_point3a(o), inv.transform_vector3a(d)); + let slab = |a: glam::Vec3A, b: glam::Vec3A| { + let (t0, t1) = ((a - lo) / ld, (b - lo) / ld); + let (near, far) = (t0.min(t1).max_element().max(1e-4), t0.max(t1).min_element()); + (near <= far).then_some(near) + }; + if slab(p.lo.into(), p.hi.into()).is_none_or(|near| near > t) { + continue; + } + for b in p.blob.boxes.chunks(6) { + if let Some(near) = slab(glam::Vec3A::new(b[0], b[1], b[2]), glam::Vec3A::new(b[3], b[4], b[5])).filter(|&near| near < t) { + t = near; + node = Some(p.name.as_str()); + sub = p.sub; + } + } + } + Some((node?.to_string(), Vec3::from(o + d * t), sub)) + } + + /// World-space triangles of a node (empty for lights without geometry, cameras, hidden nodes). + pub fn node_tris(&self, id: &str) -> Vec<[glam::Vec3A; 3]> { + // World-space triangles (an instance's glass / emissive part among them), then its instanced mesh. + let mut out: Vec<[glam::Vec3A; 3]> = scene::node_ranges(&self.sc.tri_nodes, id).flat_map(|r| self.sc.tris[r].iter().copied()).collect(); + // A model's first instance; every drawn instance of an instanced object. + let mut of_node = self.sc.instances.iter().filter(|i| i.name == id && !i.hidden); + let first = of_node.next(); + for inst in first.into_iter().chain(of_node.filter(|i| i.sub.is_some())) { + let m = self.sc.meshes[inst.mesh as usize]; + out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); + } + // Objects added in place (Bugbot on #131: `pick` saw them, this did not). + for p in self.sc.edit.added.values() { + if let Picked::Mesh(inst) = p { + if inst.name != id { + continue; + } + let m = self.sc.meshes[inst.mesh as usize]; + out.extend(self.sc.pool_tris[m.first as usize..(m.first + m.count) as usize].iter().map(|t| inst.tri(t))); + } + } + out + } + + /// Frame metadata (camera pose for picking, convergence, timings). + pub fn meta(&self) -> serde_json::Value { + let c = &self.cam; + serde_json::json!({ + "seq": self.seq, "frame": self.frame, "history": self.history_len, "gpu_ms": self.gpu_ms, + "scene": self.scene_name, "triangles": self.triangles, "bounces": self.bounces, "mode": self.view_mode, "width": self.w, "height": self.h, "scale": self.render_scale, + "cam": { "pos": c.pos.to_array(), "fwd": c.fwd.to_array(), "up": c.up.to_array(), "right": c.right.to_array(), "tan": c.tan_half_fov, "aspect": self.aspect }, + }) + } +} + +/// Adaptive samples per frame: fill ~75% of the frame budget with path tracing. +pub fn next_spp(spp: u32, gpu_ms: f32, fps: u32) -> u32 { + if gpu_ms <= 0.0 { + return spp; + } + let budget = 750.0 / fps as f32; + let per_sample = gpu_ms / spp as f32; + let target = (budget / per_sample).floor() as u32; + target.clamp(1, 16).min(spp + 1).max(spp.saturating_sub(2).max(1)) +} + +#[cfg(not(target_arch = "wasm32"))] +/// `--scale`: a fraction (0.25 .. 1) or `auto` (dynamic resolution). +pub fn parse_scale(v: Option<&str>, default: Option) -> Result> { + match v { + None => Ok(default), + Some("auto") => Ok(None), + Some(v) => v.parse::().map(|s| Some(s.clamp(SCALE_FLOOR, 1.0))).map_err(|_| anyhow!("--scale must be a number or auto")), + } +} + +#[cfg(test)] +mod timer_tests { + use super::*; + + #[test] + fn a_view_in_physical_pixels_starts_with_dynamic_resolution() { + let opts = |extra: &[&str]| parse_opts(&["threert-render", "live", "game.js"].iter().chain(extra).map(|a| a.to_string()).collect::>()).unwrap(); + assert_eq!(opts(&[]).scale, Some(1.0)); + assert_eq!(opts(&["--display-scale", "2"]).scale, None); + assert_eq!(opts(&["--display-scale", "2", "--scale", "0.2"]).scale, Some(0.2)); + assert_eq!(opts(&["--scale", "auto"]).scale, None); + } + + #[test] + fn a_post_pass_from_another_frame_counts_by_its_own_length() { + // Two frames of the crowd scene (ns, period 1): frame 453 as read, and frame 454, whose slot read frame 458's + // post pair (74.5 ms after its trace began). + let t453 = [35929577531333, 35929585495833, 35929585700583, 35929591610875, 0, 0, 0, 0]; + let t454 = [35929593765500, 35929601614666, 35929668312875, 35929673973041, 0, 0, 0, 0]; + let ms = |t: [u64; 8]| frame_gpu_ms(&t, 1, 1.0).unwrap(); + assert_eq!(ms(t453), (t453[3] - t453[0]) as f32 * 1e-6); + assert!((ms(t454) - (7.849166 + 5.660166)).abs() < 1e-3, "{}", ms(t454)); + // The first pass is the frame's start; nothing timed, no measurement. + assert_eq!(frame_gpu_ms(&[0, 0, 0, 0, 0, 0, 0, 0], 1, 1.0), None); + let particles_first = [2_000_000, 9_000_000, 9_200_000, 14_000_000, 0, 0, 1_000_000, 1_900_000]; + assert_eq!(frame_gpu_ms(&particles_first, 1, 1.0), Some(13.0)); + // 3 samples: two more traces (untimed) between the first and the post pass, a 16.2 ms gap: the span. The post + // pair of frame 458 under 3 samples: still out of place (trace 7.85 ms, gap 66.7 ms), counted as the passes. + let three = [1_000_000, 9_000_000, 25_200_000, 31_000_000, 0, 0, 0, 0]; + assert_eq!(frame_gpu_ms(&three, 3, 1.0), Some(30.0)); + assert!((frame_gpu_ms(&t454, 3, 1.0).unwrap() - (3.0 * 7.849166 + 5.660166)).abs() < 1e-3); + } +} + +#[cfg(test)] +mod instanced_tests { + use super::*; + use crate::world::{Geometry, Kind}; + use glam::Mat4; + + /// Clicks pick what is drawn (ROADMAP § 6 step 5): an object added in place is picked, one removed in place no + /// longer is, and a rebuild agrees. (Skipped without a GPU with hardware RT.) + #[test] + fn picks_follow_in_place_edits() { + let g = match gpu::Gpu::new() { + Ok(g) if g.hwrt => g, + other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), + }; + let mut w = World::default(); + w.set_material(1, serde_json::from_value(serde_json::json!({ "name": "m", "color": [0.5, 0.5, 0.5], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap()); + let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; + w.set_geometry(2, quad(1.0)).unwrap(); + w.set_geometry(3, quad(0.5)).unwrap(); + let mesh = |geometry| Kind::Mesh { geometry, material: 1, own_blas: true, materials: vec![] }; + w.set_object(10, "a".into(), None, mesh(2), true, false).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); + w.take_changes(); + let picked = |core: &LiveCore| core.pick(0.5, 0.5).map(|p| p.0); + assert_eq!(picked(&core).as_deref(), Some("a")); + // Added in place, in front of it: picked. + w.set_object(11, "b".into(), None, mesh(3), true, false).unwrap(); + w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "added in place"); + assert_eq!(picked(&core).as_deref(), Some("b")); + assert_eq!(core.node_tris("b").len(), 2, "its triangles under its name"); + // Removed in place: the one behind it is picked again; both removed: nothing. + w.remove(11); + assert!(!core.commit(&mut w).unwrap(), "removed in place"); + assert_eq!(picked(&core).as_deref(), Some("a")); + assert!(core.node_tris("b").is_empty(), "removed: none"); + w.remove(10); + assert!(!core.commit(&mut w).unwrap(), "removed in place"); + assert_eq!(picked(&core), None); + core.rebuild(&w).unwrap(); + assert_eq!(picked(&core), None, "a rebuild agrees"); + } + + /// A mesh a rigid body drives, added while the game runs with a material the scene did not have (ROADMAP § 6 step + /// 7): placed in place, and its body's moves are its instance's transform, with no rebuild; a rebuild places it the + /// same and it goes on moving in place. (Skipped without a GPU with hardware RT.) + #[test] + fn driven_meshes_added_in_place_move_as_instances() { + let g = match gpu::Gpu::new() { + Ok(g) if g.hwrt => g, + other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), + }; + let mut w = World::default(); + let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": true })).unwrap(); + w.set_material(1, mat(0.5)); + let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; + w.set_geometry(2, quad(1.0)).unwrap(); + w.set_geometry(3, quad(0.5)).unwrap(); + let mesh = |geometry, material| Kind::Mesh { geometry, material, own_blas: true, materials: vec![] }; + w.set_object(10, "a".into(), None, mesh(2, 1), true, false).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); + w.take_changes(); + let picked = |core: &LiveCore, u: f32| core.pick(u, 0.5).map(|p| p.0); + // The box and its material arrive together, as scene code adds them; its body is told of it right after. + let materials = core.sc.tables.materials.len(); + w.set_material(5, mat(0.9)); + w.set_object(11, "b".into(), None, mesh(3, 5), true, false).unwrap(); + w.set_driven(11).unwrap(); + w.set_transform(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "added in place, its material too"); + assert_eq!(core.sc.tables.materials.len(), materials + 1); + assert_eq!(picked(&core, 0.5).as_deref(), Some("b")); + // Its body moves it 3 m to the side (u 0.95 of the image, 4 m from the camera): no rebuild, its record moved + // with the rows it had the frame before. + let record = core.sc.edit.placed[&11].1; + assert!(w.place_driven(11, Vec3::new(3.0, 0.0, 1.0), glam::Quat::IDENTITY)); + assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); + assert_eq!(core.fallbacks, 0); + assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); + let r = core.sc.gpu_instances[record]; + assert_eq!((r.m[0][3], r.p[0][3]), (3.0, 0.0), "moved: last frame's rows kept"); + // A rebuild places it as an instance too, and it moves on in place. + core.rebuild(&w).unwrap(); + assert!(core.sc.tables.retained.instance_of.contains_key(&11) && core.sc.edit.placed.is_empty()); + assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95).as_deref()), (Some("a"), Some("b"))); + assert!(w.place_driven(11, Vec3::new(0.0, 0.0, 1.0), glam::Quat::IDENTITY)); + assert!(!core.commit(&mut w).unwrap(), "moved as an instance"); + assert_eq!((picked(&core, 0.5).as_deref(), picked(&core, 0.95)), (Some("b"), None)); + // Removed in place. + w.remove(11); + assert!(!core.commit(&mut w).unwrap(), "removed in place"); + assert_eq!(picked(&core, 0.5).as_deref(), Some("a")); + } + + /// An instanced mesh's moved instances keep their previous-frame rows (motion vectors) through the commit that moves + /// them, and catch up at the next commit that does not move them. (Skipped without a GPU with hardware RT.) + #[test] + fn moved_instances_keep_their_motion() { + let g = match gpu::Gpu::new() { + Ok(g) if g.hwrt => g, + other => return eprintln!("skipped: {}", other.map_or_else(|e| e.to_string(), |_| "no hardware RT".into())), + }; + let mut w = World::default(); + let mat = |r: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [r, r, r], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [0, 0, 0], "emissiveIntensity": 0, "doubleSided": false })).unwrap(); + w.set_material(1, mat(0.5)); + w.set_geometry(2, Geometry { positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], normals: vec![0.0, 0.0, 1.0].repeat(3), uvs: vec![0.0; 6], indices: vec![0, 1, 2], groups: vec![], colors: vec![] }).unwrap(); + w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, false).unwrap(); + let at = |x: f32| [Mat4::IDENTITY.to_cols_array(), Mat4::from_translation(Vec3::new(x, 0.0, 0.0)).to_cols_array()].concat(); + w.set_instances(3, 2, Some(at(1.0)), None, None).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let mut core = LiveCore::new(g, &w, Path::new("."), &crate::default_presets_dir(), 64, 36).unwrap(); + w.take_changes(); + let first = core.sc.tables.retained.instanced[&3].first as usize; + w.set_instances(3, 2, Some(at(2.0)), None, None).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "in place"); + let r = core.sc.gpu_instances[first + 1]; + assert_eq!((r.m[0][3], r.p[0][3]), (2.0, 1.0), "moved: last frame's rows kept"); + w.set_material(1, mat(0.6)); + core.commit(&mut w).unwrap(); + let r = core.sc.gpu_instances[first + 1]; + assert_eq!(r.p, r.m, "not moved since: caught up"); + // Movable, moved and given new instances in one commit: updated once (twice would lose the motion). + w.set_object(3, "copies".into(), None, Kind::Instanced { geometry: 2, procedural: false, materials: vec![1], capacity: 2 }, true, true).unwrap(); + assert!(core.commit(&mut w).unwrap(), "made movable: rebuilt"); + let first = core.sc.tables.retained.instanced[&3].first as usize; + w.set_transform(3, Vec3::new(0.0, 1.0, 0.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_instances(3, 2, Some(at(3.0)), None, None).unwrap(); + let c = w.changes.clone(); + assert!(c.moved.contains(&3) && c.instanced.contains(&3), "{c:?}"); + assert!(!core.commit(&mut w).unwrap(), "in place"); + let r = core.sc.gpu_instances[first + 1]; + assert_eq!((r.m[0][3], r.m[1][3], r.p[0][3], r.p[1][3]), (3.0, 1.0, 2.0, 0.0), "moved: last frame's rows kept"); + } + + /// The uniform tables (`gpu::GpuOptions::uniform_tables`: what a device is opened with on Android), on this + /// machine's GPU: a lit scene with an instanced mesh renders offline to the default device's image, bit for bit; + /// live, the four uniform buffers hold what their tables hold when a frame is traced, and after an edit in place + /// what the tables hold then. (Skipped without a GPU.) + #[test] + fn the_uniform_tables_render_what_the_tables_do() { + let open = |uniform: bool| gpu::Gpu::new_with(gpu::GpuOptions { uniform_tables: Some(uniform), ..Default::default() }); + let (plain, uniform) = match (open(false), open(true)) { + (Ok(a), Ok(b)) => (a, b), + (Err(e), _) | (_, Err(e)) => return eprintln!("skipped: {e}"), + }; + if uniform.merged { + return eprintln!("skipped: the merged layout has no uniform tables"); + } + assert!(uniform.uniform_tables && !plain.uniform_tables); + let mut w = World::default(); + let mat = |color: f32, emissive: f32| serde_json::from_value(serde_json::json!({ "name": "m", "color": [color, color * 0.5, color], "opacity": 1, "metalness": 0, "roughness": 1, "emissive": [1, 1, 1], "emissiveIntensity": emissive, "doubleSided": true })).unwrap(); + w.set_material(1, mat(0.8, 0.0)); + w.set_material(5, mat(0.0, 40.0)); + let quad = |s: f32| Geometry { positions: vec![-s, -s, 0.0, s, -s, 0.0, s, s, 0.0, -s, s, 0.0], normals: [0.0, 0.0, 1.0].repeat(4), uvs: vec![0.0; 8], indices: vec![0, 1, 2, 0, 2, 3], groups: vec![], colors: vec![] }; + w.set_geometry(2, quad(1.0)).unwrap(); + w.set_geometry(3, quad(0.25)).unwrap(); + let mesh = |geometry, material, own_blas| Kind::Mesh { geometry, material, own_blas, materials: vec![] }; + // A wall, a small movable panel in front of it, and an emissive quad that lights both from the side. + w.set_object(10, "wall".into(), None, mesh(2, 1, false), true, false).unwrap(); + w.set_object(11, "panel".into(), None, mesh(3, 1, true), true, true).unwrap(); + w.set_transform(11, Vec3::new(-0.4, 0.0, 0.5), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_object(12, "lamp".into(), None, mesh(3, 5, false), true, false).unwrap(); + w.set_transform(12, Vec3::new(0.6, 0.3, 1.5), glam::Quat::from_rotation_y(2.6), Vec3::ONE).unwrap(); + let cam = serde_json::from_value(serde_json::json!({ "fov": 50, "near": 0.1, "far": 100, "exposure": { "mode": "manual", "ev100": 0 } })).unwrap(); + w.set_object(4, "camera".into(), None, Kind::Camera(cam), true, false).unwrap(); + w.set_transform(4, Vec3::new(0.0, 0.0, 5.0), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + w.set_settings(crate::world::Settings { camera: Some(4), ..Default::default() }); + let presets = crate::default_presets_dir(); + + // Offline: the same bytes. + let offline = |g: &gpu::Gpu| { + let mut sc = crate::scene::build(&w, Path::new("."), &presets, None).unwrap(); + (sc.width, sc.height) = (64, 36); + let prep = crate::prepare(g, &mut sc, &mut vec![]); + let frame = g.render(&sc, &prep.tri_verts, prep.groups, bytemuck::cast_slice(&prep.nodes), &prep.emitters, &prep.env, &prep.env_cdf, &prep.energy, prep.params, 16, &mut vec![]).unwrap(); + frame.color.iter().flatten().map(|v| v.to_bits()).collect::>() + }; + let (a, b) = (offline(&plain), offline(&uniform)); + let lit = a.iter().filter(|v| f32::from_bits(**v) > 0.0).count(); + assert!(lit > a.len() / 16, "the scene is lit ({lit} of {} numbers)", a.len()); + let differ = a.iter().zip(&b).filter(|(x, y)| x != y).count(); + assert_eq!(differ, 0, "{differ} of {} numbers differ between the storage tables and the uniform ones", a.len()); + drop(plain); + + // Live: the uniform buffers follow the tables. + let mut core = LiveCore::new(uniform, &w, Path::new("."), &presets, 64, 36).unwrap(); + w.take_changes(); + let read = |core: &LiveCore, buf: &wgpu::Buffer, bytes: u64| -> Vec { + let d = &core.g.device; + let to = d.create_buffer(&wgpu::BufferDescriptor { label: None, size: bytes, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut e = d.create_command_encoder(&Default::default()); + e.copy_buffer_to_buffer(buf, 0, &to, 0, bytes); + core.g.queue.submit([e.finish()]); + to.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let bytes = to.slice(..).get_mapped_range().unwrap().to_vec(); + bytes + }; + // Each table's bytes and its uniform copy's, over what both hold. + let pairs = |core: &LiveCore| -> Vec<(Vec, Vec)> { + let up = &core.sc.up; + let copies = up.uniform.as_ref().expect("the uniform tables' buffers"); + [&up.bg_parts.texinfo, &up.objects, &up.instances, &up.media].into_iter().zip(copies).map(|(t, c)| { + let n = t.size().min(c.size()) & !3; + (read(core, t, n), read(core, c, n)) + }).collect() + }; + core.render(1, |_, _| {}); + let first = pairs(&core); + for (k, (table, copy)) in first.iter().enumerate() { + assert!(!table.is_empty() && table == copy, "table {k} and its uniform buffer differ after a frame"); + } + assert!(first[1].0.iter().any(|b| *b != 0), "the objects' table has the scene's objects"); + // The panel moves in place: its object's entry and its instance's record change, and the next frame's + // uniform buffers have them. + w.set_transform(11, Vec3::new(0.3, 0.2, 0.75), glam::Quat::IDENTITY, Vec3::ONE).unwrap(); + assert!(!core.commit(&mut w).unwrap(), "moved in place"); + core.render(1, |_, _| {}); + let moved = pairs(&core); + for (k, (table, copy)) in moved.iter().enumerate() { + assert!(table == copy, "table {k} and its uniform buffer differ after an edit"); + } + assert!(moved[1].1 != first[1].1 || moved[2].1 != first[2].1, "the move reached the uniform buffers"); + } +} diff --git a/renderer/src/live/session.rs b/renderer/src/live/session.rs new file mode 100644 index 0000000..54447ff --- /dev/null +++ b/renderer/src/live/session.rs @@ -0,0 +1,305 @@ +//! The headless runner (`threert-render live`): the frame loop at a fixed rate or in lockstep, the commands on stdin +//! and their parsing. + +use super::*; + +/// Headless runner: renders at a fixed frame rate and saves snapshots on request (tools/bench-live.ts). +pub fn run(presets: &Path, opts: LiveOpts) -> Result<()> { + let (w, h) = (opts.width, opts.height); + let (cmd_tx, cmd_rx) = mpsc::channel::(); + spawn_stdin_reader(cmd_tx); + let (mut game, mut core) = crate::script::GameSession::start(&opts.bundle, &opts.room, &opts.assets, gpu::Gpu::new()?, presets, w, h)?; + // The host channel: this runner is the app. How the game was opened, once after setup; what the game posts goes + // out on stdout (`emit_posts`), and whoever drives the runner answers with `{"cmd":"host"}`. + if let Err(e) = game.host_messages(&mut core, &[crate::host::launch_message(opts.launch.as_ref())]) { + eprintln!("live: launch: {e:#}"); + } + emit_posts(&mut game); + // Frame-script cost (script + commit), reported every 2 s. + let (mut fs_sum, mut fs_max, mut fs_n) = (0.0f32, 0.0f32, 0u32); + core.bounces = opts.bounces; + core.set_display_scale(opts.display_scale); + if let Some(s) = opts.scale { + core.set_scale(s); + } + eprintln!("live: load {}", serde_json::Value::Object(core.load_timings().iter().map(|(k, v)| (k.clone(), (*v).into())).collect())); + eprintln!("live: {}x{} @{} fps on {} ({})", w, h, opts.fps, core.g.adapter_info.name, if core.g.hwrt { "HW RT" } else { "BVH" }); + let staging = core.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("snapshot"), size: (w * h * 4) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let frame_dt = Duration::from_secs_f64(1.0 / opts.fps as f64); + let mut next = Instant::now(); + let mut snapshots: Vec<(PathBuf, u32, u32)> = vec![]; // path, min history, first eligible frame + let mut syncs: Vec<(String, u32)> = vec![]; // id, frame after which to answer + let mut spp = 1u32; + let mut last = Instant::now(); + let start = Instant::now(); + let mut log = opts.frame_log.as_ref().map(|p| std::fs::File::create(p).map(std::io::BufWriter::new)).transpose()?; + let mut last_frame = Instant::now(); + let mut prev_start = Instant::now(); + let mut lockstep: Option = None; + let mut budget = 0u32; // lockstep: frames the commands so far asked for and not yet rendered + loop { + let t_frame = Instant::now(); + let mut cmds: Vec = vec![]; + if lockstep.is_some() && budget == 0 { + match cmd_rx.recv() { + Ok(c) => cmds.push(c), + Err(_) => return Ok(()), + } + } + cmds.extend(std::iter::from_fn(|| cmd_rx.try_recv().ok())); + for c in cmds { + match c { + Cmd::Camera(c) => core.set_camera(c), + Cmd::Lockstep(dt) => { + lockstep = dt; + core.fixed_dt = dt; + budget = 0; + } + Cmd::Step { cam, frames, sync, snapshot } => { + if let Some(c) = cam { + core.set_camera(c); + } + let n = frames.max(1); + if lockstep.is_some() { + budget += n; + } + let last = core.frame + n - 1; + if let Some(p) = snapshot { + snapshots.push((p, 0, last)); + } + if let Some(id) = sync { + syncs.push((id, last)); + } + } + Cmd::Snapshot(path, after, frames) => snapshots.push((path, after.unwrap_or(0), core.frame + frames.unwrap_or(1).max(1) - 1)), + Cmd::Sync(id, frames) => syncs.push((id, core.frame + frames.max(1) - 1)), + Cmd::Reset => core.reset_history(), + Cmd::Time(t) => core.set_time(t), + Cmd::Mode(m) => core.view_mode = m, + Cmd::Bounces(b) => core.bounces = b, + Cmd::Scale(s) => core.set_scale(s), + Cmd::Event(kind, payload) => { + if let Err(e) = game.event(&mut core, &kind, payload) { + eprintln!("live: event {kind} failed: {e:#}"); + } + } + Cmd::Click(u, v) => { + if let Err(e) = game.click(&mut core, u, v) { + eprintln!("live: click failed: {e:#}"); + } + } + Cmd::Host(messages) => { + if let Err(e) = game.host_messages(&mut core, &messages) { + eprintln!("live: host messages failed: {e:#}"); + } + } + Cmd::SppCap(_) | Cmd::Status(_) => {} + Cmd::Quit => return Ok(()), + } + } + // The frame script runs on wall-clock time (frames slower than the target rate still animate at real speed). + if lockstep.is_some() && budget == 0 { + emit_posts(&mut game); + continue; // commands without frames (events, switches): wait for a step + } + let dt = if freeze_time() { 0.0 } else { lockstep.unwrap_or_else(|| last.elapsed().as_secs_f32().min(0.1)) }; + last = Instant::now(); + core.advance_time(dt); + if let Err(e) = game.frame(&mut core, dt) { + eprintln!("live: frame script: {e:#}"); + } + emit_posts(&mut game); + if game.host.has_frame() { + (fs_sum, fs_max, fs_n) = (fs_sum + game.frame_ms, fs_max.max(game.frame_ms), fs_n + 1); + if fs_n == opts.fps * 2 { + eprintln!("live: frame script {:.3} ms avg, {:.3} ms max (script + commit)", fs_sum / fs_n as f32, fs_max); + (fs_sum, fs_max, fs_n) = (0.0, 0.0, 0); + } + } + // Snapshot metadata is this frame's (history / spp at render time), so copy the output whenever one is pending. + let pending = !snapshots.is_empty(); + core.render(spp, |e, out| { + if pending { + e.copy_buffer_to_buffer(out, 0, &staging, 0, (w * h * 4) as u64); + } + }); + game.click_shown(); + let (hist, fno) = (core.history_len as u32, core.frame); + syncs.retain(|(id, from)| { + if fno > *from { + eprintln!("live: sync {id}"); + } + fno <= *from + }); + if snapshots.iter().any(|(_, after, from)| fno > *from && hist >= *after) { + staging.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + core.g.device.poll(wgpu::PollType::wait_indefinitely())?; + let px = staging.slice(..).get_mapped_range()?.to_vec(); + staging.unmap(); + let mut hdr: Option> = None; + snapshots.retain(|(path, after, from)| { + if fno > *from && hist >= *after { + if path.extension().is_some_and(|e| e == "signals") { + // The signals apart (`read_signals`), as `render --signals` writes them. + if let Ok([d, s, e]) = core.read_signals() { + let stem = path.with_extension(""); + for (name, v) in [("diffuse", d), ("specular", s), ("emission", e)] { + let _ = std::fs::write(format!("{}-{name}.raw", stem.display()), bytemuck::cast_slice(&v)); + } + } + } else if path.extension().is_some_and(|e| e == "raw") { + // Linear radiance (RGB f32, output size), as `serve` live jobs write it. + if hdr.is_none() { + hdr = core.read_hdr().ok(); + } + let _ = std::fs::write(path, bytemuck::cast_slice(hdr.as_deref().unwrap_or(&[]))); + } else { + save_bgra(path, &px, w, h); + } + eprintln!("live: snapshot {} at history {hist} ({spp} spp/frame, GPU {:.2} ms, scale {:.2})", path.display(), core.gpu_ms, core.render_scale); + false + } else { + true + } + }); + } + if let Some(l) = &mut log { + use std::io::Write; + let line = serde_json::json!({ + "t": start.elapsed().as_secs_f64() * 1e3, "interval": last_frame.elapsed().as_secs_f64() * 1e3, "start": t_frame.duration_since(prev_start).as_secs_f64() * 1e3, + "cpu": t_frame.elapsed().as_secs_f64() * 1e3, "script": game.frame_ms, "js": game.js_ms, "commit": game.commit_ms, "skin": core.cpu_skin_ms, "upload": core.cpu_upload_ms, "blas": core.gpu_blas_ms, "wait": core.cpu_wait_ms, "gpu": core.gpu_ms, "trace": core.gpu_trace_ms, "edit": core.edit_ms, "instanced": core.instanced_ms, "edit_split": [core.edit_split.tris, core.edit_split.upload, core.edit_split.blas, core.edit_split.added, core.edit_split.deformed, core.edit_split.objects], "rebuild": core.rebuild_ms, "fallback": core.fallback, "fallbacks": core.fallbacks, "room": core.room_stats(), "top": core.top_stats(), "voxel": std::mem::take(&mut game.host.voxels.borrow_mut().ticks), "physics": game.host.physics.borrow_mut().as_mut().map(|p| { let s = p.take_stats(); serde_json::json!({ "ticks": s.ticks, "step": s.step_ms, "sync": s.sync_ms, "bodies": s.bodies, "moved": s.moved, "kicks": s.kicks }) }), "scale": core.render_scale, "history": core.history_len, "guard": core.guard_count(), + }); + writeln!(l, "{line}")?; + } + // (Summed over the frame's commits, a game event's and the frame script's: reset once logged.) + (core.edit_ms, core.instanced_ms, core.edit_split, core.rebuild_ms, core.fallback) = (0.0, 0.0, EditSplit::default(), 0.0, None); + prev_start = t_frame; + last_frame = Instant::now(); + core.govern_resolution(opts.fps); + spp = if core.render_scale < 1.0 || lockstep.is_some() { 1 } else { next_spp(spp, core.gpu_ms, opts.fps) }; + let _ = core.g.device.poll(wgpu::PollType::Poll); + if lockstep.is_some() { + budget -= 1; + continue; // no pacing: frames as fast as the GPU renders them + } + // Pace to the frame rate; when the GPU is the bottleneck the two-frames-in-flight limit paces instead (sleeping + // after a frame that did not wait would alternate long and short frames). + next += frame_dt; + let now = Instant::now(); + if next > now && core.gpu_ms < frame_dt.as_secs_f32() * 1000.0 * 0.95 { + sleep_until(next); + } else { + next = now; + } + } +} + +/// What the game posted on the host channel since the last call, as one line on stdout: `{"host":[{"t":...}, ...]}` +/// (nothing when it posted nothing). +fn emit_posts(game: &mut crate::script::GameSession) { + use std::io::Write; + let posts = game.take_posts(); + if !posts.is_empty() { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{}", serde_json::json!({ "host": posts })); + let _ = out.flush(); + } +} + +/// Sleeps until `t` without trusting the timer's precision: macOS gives a process that is not a foreground app timer +/// leeway of half the interval (a 16 ms sleep returned after 24, measured from a terminal-launched viewport and from +/// plain Python alike). Pacing sleeps of the headless viewport overslept by ~5 ms once the GPU fit the frame: Bistro's +/// camera path read frame interval p95 21.9 ms at GPU p95 16.0. Sleep 60 % of what is left, again, and yield through +/// the last 1.5 ms. +fn sleep_until(t: Instant) { + loop { + let now = Instant::now(); + if now >= t { + return; + } + let rem = t - now; + if rem > Duration::from_micros(1500) { + std::thread::sleep(rem.mul_f64(0.6)); + } else { + std::thread::yield_now(); + } + } +} + +pub fn save_bgra(path: &Path, px: &[u8], w: u32, h: u32) { + let rgba: Vec = px.chunks(4).flat_map(|p| [p[2], p[1], p[0], 255]).collect(); + if let Err(e) = image::save_buffer(path, &rgba, w, h, image::ColorType::Rgba8) { + eprintln!("live: snapshot failed: {e}"); + } +} + +fn spawn_stdin_reader(cmd_tx: mpsc::Sender) { + std::thread::spawn(move || { + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let Some(cmd) = parse_cmd(&line) else { continue }; + if cmd_tx.send(cmd).is_err() { + break; + } + } + let _ = cmd_tx.send(Cmd::Quit); + }); +} + +pub fn parse_cmd(line: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(line).ok()?; + let vec = |k: &str| v[k].as_array().map(|a| Vec3::new(a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32)); + Some(match v["cmd"].as_str()? { + "camera" if v.get("sync").is_some() || v.get("snapshot").is_some() || v.get("frames").is_some() => Cmd::Step { + cam: Some(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), + frames: v["frames"].as_u64().unwrap_or(1) as u32, + sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), + snapshot: v["snapshot"].as_str().map(PathBuf::from), + }, + "camera" => Cmd::Camera(Camera::look_at(vec("pos")?, vec("target")?, v["tan"].as_f64().unwrap_or(0.0) as f32)), + "frames" => Cmd::Step { + cam: None, + frames: v["n"].as_u64().unwrap_or(1) as u32, + sync: v.get("sync").map(|s| s.as_str().map(str::to_string).unwrap_or_else(|| s.to_string())), + snapshot: v["snapshot"].as_str().map(PathBuf::from), + }, + "lockstep" => Cmd::Lockstep(if v["on"].as_bool().unwrap_or(true) { Some(v["dt"].as_f64().unwrap_or(1.0 / 60.0) as f32) } else { None }), + "event" => Cmd::Event(v["type"].as_str()?.to_string(), if v["payload"].is_null() { serde_json::json!({}) } else { v["payload"].clone() }), + "click" => Cmd::Click(v["u"].as_f64()? as f32, v["v"].as_f64()? as f32), + "host" => Cmd::Host(v["messages"].as_array()?.clone()), + "snapshot" => Cmd::Snapshot(PathBuf::from(v["path"].as_str().unwrap_or("snapshot.png")), v["after"].as_u64().map(|a| a as u32), v["frames"].as_u64().map(|a| a as u32)), + "reset" => Cmd::Reset, + "time" => Cmd::Time(v["t"].as_f64()? as f32), + "mode" => Cmd::Mode(v["mode"].as_u64()? as u32), + "bounces" => Cmd::Bounces(v["bounces"].as_u64()?.clamp(1, 16) as u32), + "spp" => Cmd::SppCap(v["max"].as_u64()?.clamp(1, 16) as u32), + "scale" => Cmd::Scale(v["scale"].as_f64()? as f32), + "sync" => Cmd::Sync(v["id"].as_str().map(str::to_string).unwrap_or_else(|| v["id"].to_string()), v["frames"].as_u64().unwrap_or(1) as u32), + "quit" => Cmd::Quit, + _ => return None, + }) +} + +pub fn parse_opts(args: &[String]) -> Result { + let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); + let num = |name: &str, d: u32| -> Result { flag(name).map(|v| v.parse().map_err(|_| anyhow!("{name} must be a number"))).transpose().map(|v| v.unwrap_or(d)) }; + Ok(LiveOpts { + width: num("--width", 1280)?, + height: num("--height", 720)?, + fps: num("--fps", 60)?, + bounces: num("--bounces", 4)?, + // (Without `--scale`: full resolution, or dynamic resolution on a display's physical pixels.) + scale: parse_scale(flag("--scale").as_deref(), if flag("--display-scale").is_some() { None } else { Some(1.0) })?, + bundle: PathBuf::from(args.get(2).filter(|a| !a.starts_with("--")).ok_or_else(|| anyhow!("game bundle required"))?), + assets: PathBuf::from(flag("--assets").unwrap_or_else(|| ".".into())), + room: flag("--room").unwrap_or_else(|| "local".into()), + frame_log: flag("--frame-log").map(PathBuf::from), + display_scale: flag("--display-scale").map(|v| v.parse::()).transpose()?.unwrap_or(1.0), + launch: parse_launch(flag("--launch").as_deref())?, + }) +} + +/// `--launch '{"room":"K7Q4MZ","name":"Ada"}'`: the `params` of the host channel's `launch` message. +pub fn parse_launch(v: Option<&str>) -> Result> { + v.map(|t| serde_json::from_str::(t).ok().filter(|p| p.is_object()).ok_or_else(|| anyhow!("--launch must be a JSON object, as in --launch '{{\"room\":\"K7Q4MZ\"}}'"))).transpose() +} diff --git a/renderer/src/live/targets.rs b/renderer/src/live/targets.rs new file mode 100644 index 0000000..3bed44c --- /dev/null +++ b/renderer/src/live/targets.rs @@ -0,0 +1,321 @@ +//! The live passes' GPU side: the shader module and its pipelines (`Passes`), the screen-size buffers and the bind +//! group (`Targets`), and the tonemap LUT and bloom pyramid they are sized for. + +use super::*; + +/// Bloom pyramid level j (0..4: 1/4 .. 1/64 resolution) size; stage k (0..5) runs on level max(k - 1, 0). +pub(super) fn bloom_dim(w: u32, h: u32, j: u32) -> (u32, u32) { + (w.div_ceil(4).div_ceil(1 << j), h.div_ceil(4).div_ceil(1 << j)) +} + +pub(super) fn bloom_stage_level(k: u32) -> u32 { + k.max(1) - 1 +} + +/// UE Standard bloom per stage: Gaussian radius in texels of its level (BloomNSize % of the stage width * size scale +/// / 2, as `post::bloom`) and tint (BloomNTint * intensity / 6). +pub(super) fn bloom_stages(pp: &crate::desc::PostPreset, w: u32, h: u32) -> ([f32; 6], [f32; 6]) { + let (mut r, mut t) = ([0f32; 6], [0f32; 6]); + for k in 0..6 { + let (lw, _) = bloom_dim(w, h, bloom_stage_level(k as u32)); + r[k] = lw as f32 * pp.bloom.sizes.get(k).copied().unwrap_or(0.0) * pp.bloom.size_scale * 0.01 * 0.5; + t[k] = pp.bloom.tints.get(k).copied().unwrap_or(0.0) * pp.bloom.intensity / 6.0; + } + (r, t) +} + +pub(super) const LOG_OFFSET: f32 = 0.0026883; // must match live.wgsl lin_to_log input offset +pub(super) const LUT: u32 = 32; + +/// UE-style 32^3 LUT: log-encoded linear input -> display sRGB, built from the exact CPU port of the filmic tonemapper. +pub(super) fn tonemap_lut(p: &crate::desc::PostPreset) -> Vec { + let tm = post::Tonemapper::new(p); + let mut out = Vec::with_capacity((LUT * LUT * LUT * 4) as usize); + let log_to_lin = |v: f32| (2f32.powf((v - 444.0 / 1023.0) * 14.0) * 0.18 - LOG_OFFSET).max(0.0); + for b in 0..LUT { + for g in 0..LUT { + for r in 0..LUT { + let enc = |k: u32| log_to_lin(k as f32 / (LUT - 1) as f32); + let d = tm.apply(Vec3::new(enc(r), enc(g), enc(b))); + for c in [d.x, d.y, d.z, 1.0] { + out.push(half::f16::from_f32(post::linear_to_srgb(c.clamp(0.0, 1.0))).to_bits()); + } + } + } + } + out +} + +/// The live passes' source: live.wgsl as it is, or in the merged layout its buffers merged into slabs (merged.rs). +pub(crate) fn live_source(merged: bool) -> std::borrow::Cow<'static, str> { + let src = include_str!("../../shaders/live.wgsl"); + if merged { + crate::merged::live(src).into() + } else { + src.into() + } +} + +pub(super) struct Passes { + pub(super) layout: wgpu::BindGroupLayout, + pub(super) pipes: Vec<(&'static str, wgpu::ComputePipeline)>, +} + +impl Passes { + pub(super) fn new(g: &gpu::Gpu) -> Passes { + let d = &g.device; + let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("live"), source: wgpu::ShaderSource::Wgsl(live_source(g.merged).into()) }); + let buf = |binding: u32, read_only: bool| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let mut entries = vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }]; + entries.extend([buf(1, true), buf(2, false), buf(3, false), buf(4, false), buf(5, false), buf(6, false), buf(7, false), buf(8, false), buf(9, false), buf(10, false)]); + entries.push(wgpu::BindGroupLayoutEntry { + binding: 11, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false }, + count: None, + }); + entries.push(wgpu::BindGroupLayoutEntry { binding: 12, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None }); + entries.extend([buf(13, false), buf(14, false), buf(15, false), buf(16, false), buf(17, false), buf(18, false)]); + entries.extend([buf(19, false), buf(20, false), buf(21, false), buf(22, false), buf(23, true), buf(24, false), buf(25, true), buf(26, true)]); + // The merged layout: the buffers that stay, the slabs, and the offsets' uniform (merged.rs). + if g.merged { + use crate::merged::{LIVE_KEPT, OFFSETS_BINDING, SLABS}; + entries.retain(|e| !matches!(e.ty, wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { .. }, .. }) || LIVE_KEPT.iter().any(|k| k.0 == e.binding)); + entries.extend(SLABS.iter().map(|s| buf(s.binding, s.read_only))); + entries.push(wgpu::BindGroupLayoutEntry { binding: OFFSETS_BINDING, ..entries[0] }); + } + let layout = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("live"), entries: &entries }); + let pl = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipes = ["refl", "dtemporal", "atrous0", "atrous1", "atrous2", "atrous3", "atrous4", "stemporal", "sfilt0", "sfilt1", "sfilt2", "compose", "judge", "reject", "upscale", "dof_tiles", "dof", "mblur", "lum_down", "exposure", "lum_blur", "bloom_down", "bloom_dn1", "bloom_dn2", "bloom_dn3", "bloom_dn4", + "bloom_h5", "bloom_v5", "bloom_h4", "bloom_v4", "bloom_h3", "bloom_v3", "bloom_h2", "bloom_v2", "bloom_h1", "bloom_v1", "bloom_h0", "bloom_v0", "post"] + .into_iter() + .map(|name| { + (name, d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(name), layout: Some(&pl), module: &module, entry_point: Some(name), compilation_options: Default::default(), cache: g.cache() })) + }) + .collect(); + g.save_pipeline_cache(); + Passes { layout, pipes } + } +} + +/// Screen-size targets shared across scene reloads. +pub struct Targets { + pub(super) params: wgpu::Buffer, + /// What lasts from one frame to the next (live.wgsl, "The live passes' buffers"), two halves each: this frame's + /// and the previous frame's (`LiveParams::state`). Per traced sample: the diffuse irradiance's history, its + /// luminance moments and its fast mean in colour, the specular history, the g-buffer, the upscaler's state; per + /// output pixel: the colour history. + pub(super) diffuse_hist: Buf, + pub(super) diffuse_mom: Buf, + pub(super) diffuse_fast: Buf, + pub(super) spec_hist: Buf, + pub(super) gbuffer: Buf, + pub(super) up_state: Buf, + pub(super) color_hist: Buf, + /// What lives within a frame (live.wgsl says who uses each when): per traced sample, screen velocity, view depth + /// and animated shading; the specular filter's two slots; the scratch the diffuse filter and the upscaled image + /// share; the irradiance filter's ping-pong, the composed colour and the image; bloom's pyramid and scratch. + pub(super) sample_vel: Buf, + pub(super) spec_pp: Buf, + pub(super) sb: Buf, + pub(super) fa: Buf, + pub(super) fb: Buf, + pub(super) bloom: [Buf; 2], + /// Reflection resolve: the resolved specular reflection per traced pixel (live.wgsl `refl`), from the trace + /// kernel's specular-lobe rays (2 vec4 per traced pixel, pathtrace.wgsl `refl`: `kernel.refl`). + pub(super) refl_res: Buf, + pub out: wgpu::Buffer, + /// Auto exposure: 64 histogram buckets (fixed point), state (exposure, target, mean log2 luminance, valid). + pub(super) ae_bins: wgpu::Buffer, + pub(super) ae_state: Buf, + /// Quarter-resolution log2 luminance and its edge-aware blur (local exposure base). + pub(super) lumq: Buf, + pub(super) lumb: Buf, + /// The kernel's group 1 (`gpu::KernelBufs`): the radiance cache, and what the passes here read of + /// a trace: the specular-lobe rays (`refl`), motion vectors per traced pixel (pathtrace.wgsl `motion_out`) and the + /// deterministic direct light per traced pixel (`direct_out`). + pub kernel: gpu::KernelBufs, + /// The merged layout (merged.rs): the slabs the buffers above are ranges of (`SLABS`' first four). + pub(super) slabs: Option<[wgpu::Buffer; 4]>, + pub(super) lut_tex: wgpu::Texture, + pub(super) lut_view: wgpu::TextureView, + pub(super) sampler: wgpu::Sampler, +} + +impl Targets { + /// For a view of `w` x `h` and a radiance cache of `cache` entries. + pub(super) fn new(g: &gpu::Gpu, w: u32, h: u32, lut: &[u16], cache: u64) -> Targets { + use crate::merged::{SLABS, SLAB_F1, SLAB_U2, SLAB_U4, SLAB_V4}; + let d = &g.device; + let n = (w * h) as u64; + let sb = |label: &str, bytes: u64| d.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: bytes, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }); + let bn = (w.div_ceil(4) * h.div_ceil(4)) as u64 * 16; + let texels = |j: u32| bloom_dim(w, h, j).0 as u64 * bloom_dim(w, h, j).1 as u64; + let bloom_texels = (0..5).map(texels).sum::() + (0..6).map(|k| texels(bloom_stage_level(k))).sum::(); + let lut_tex = d.create_texture(&wgpu::TextureDescriptor { + label: Some("tonemap lut"), + size: wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + g.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &lut_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(lut), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT * 8), rows_per_image: Some(LUT) }, + wgpu::Extent3d { width: LUT, height: LUT, depth_or_array_layers: LUT }, + ); + // The buffers, in the order of the fields below: a buffer each, or in the merged layout (merged.rs) a range of + // the slab of its element type (each range starts at a multiple of 16 bytes: of every slab's element). + // (The lasting ones are two halves of `n` elements: this frame's and the previous frame's.) + let specs: [(&str, u64, usize); 18] = [ + ("diffuse_hist", 2 * n * 16, SLAB_V4), + ("diffuse_mom", 2 * n * 16, SLAB_V4), + ("diffuse_fast", 2 * n * 8, SLAB_U2), + ("spec_hist", 2 * n * 16, SLAB_V4), + ("gbuffer", 2 * n * 8, SLAB_U2), + ("up_state", 2 * n * 16, SLAB_U4), + ("color_hist", 2 * n * 16, SLAB_V4), + ("sample_vel", n * 16, SLAB_V4), + ("spec_pp", n * 16, SLAB_U2), // two slots of `n` (vec2) + ("sb", n * 16, SLAB_V4), + ("fa", n * 16, SLAB_V4), + ("fb", n * 16, SLAB_V4), + ("bloom_a", bloom_texels * 16, SLAB_V4), + ("bloom_b", bn, SLAB_V4), + ("refl_res", n * 16, SLAB_V4), + ("ae_state", 32, SLAB_V4), + ("lumq", bn / 4, SLAB_F1), + ("lumb", bn / 4, SLAB_F1), + ]; + let slabs: Option<[wgpu::Buffer; 4]> = g.merged.then(|| std::array::from_fn(|k| sb(SLABS[k].name, specs.iter().filter(|s| s.2 == k).map(|s| s.1.next_multiple_of(16)).sum()))); + let mut at = [0u64; 4]; + let bufs: Vec = specs + .iter() + .map(|&(label, bytes, slab)| match &slabs { + None => Buf::whole(sb(label, bytes)), + Some(s) => { + let b = Buf { buffer: s[slab].clone(), offset: at[slab], size: bytes }; + at[slab] += bytes.next_multiple_of(16); + b + } + }) + .collect(); + let mut bufs = bufs.into_iter(); + let mut b = || bufs.next().expect("a buffer of `specs`"); + 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 }), + diffuse_hist: b(), + diffuse_mom: b(), + diffuse_fast: b(), + spec_hist: b(), + gbuffer: b(), + up_state: b(), + color_hist: b(), + sample_vel: b(), + spec_pp: b(), + sb: b(), + fa: b(), + fb: b(), + bloom: [b(), b()], + refl_res: b(), + out: sb("out", n * 4), + ae_bins: sb("ae_bins", 64 * 4), + ae_state: b(), + lumq: b(), + lumb: b(), + kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, n * 32, n * 16, n * 16), + slabs, + 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() }), + } + } + + /// What live.wgsl's buffer bindings hold (besides the uniforms at 0, the trace's samples at 1, `out` at 10 and + /// the histogram at 13). + pub(super) fn buffers(&self) -> [(u32, &Buf); 21] { + [ + (2, &self.diffuse_hist), + (3, &self.color_hist), + (4, &self.gbuffer), + (5, &self.spec_hist), + (6, &self.diffuse_mom), + (7, &self.diffuse_fast), + (8, &self.up_state), + (9, &self.sample_vel), + (14, &self.ae_state), + (15, &self.lumq), + (16, &self.lumb), + (17, &self.fa), + (18, &self.fb), + (19, &self.bloom[0]), + (20, &self.bloom[1]), + (21, &self.spec_pp), + (22, &self.sb), + (23, &self.kernel.refl), + (24, &self.refl_res), + (25, &self.kernel.motion), + (26, &self.kernel.direct), + ] + } + + /// The live passes' bind group. (The lasting buffers' halves alternate by `LiveParams::state`, not by binding.) In + /// the merged layout (merged.rs) it binds the slabs, and the offsets the passes read (`LO`). + pub(super) fn bind_group(&self, d: &wgpu::Device, layout: &wgpu::BindGroupLayout, sample: &wgpu::Buffer) -> wgpu::BindGroup { + let mk = || { + fn e(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> { + wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() } + } + let mut entries = vec![ + e(0, &self.params), + e(1, sample), + e(10, &self.out), + wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.lut_view) }, + wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + e(13, &self.ae_bins), + ]; + let buffers = self.buffers(); + let offsets; + match (&self.slabs, &self.kernel.merged) { + (Some(slabs), Some((live, _))) => { + use crate::merged::{LIVE_BUFS, OFFSETS_BINDING, SLABS, SLAB_K}; + // Each merged buffer's (first element, length) in its slab, in `MergedOffsets`' order. + let lo: Vec<[u32; 2]> = LIVE_BUFS + .iter() + .map(|m| { + let buf = buffers.iter().find(|b| b.0 == m.binding).expect("a merged buffer's binding").1; + let stride = SLABS[m.slab].stride; + debug_assert!(buf.offset % stride == 0 && buf.size % stride == 0); + [(buf.offset / stride) as u32, (buf.size / stride) as u32] + }) + .collect(); + // (And the struct's unused last pair: `merged::OFFSETS_BYTES`.) + let mut lo = lo; + lo.push([0, 0]); + debug_assert_eq!(lo.len() * 8, crate::merged::OFFSETS_BYTES); + offsets = wgpu::util::DeviceExt::create_buffer_init(d, &wgpu::util::BufferInitDescriptor { label: Some("live offsets"), contents: bytemuck::cast_slice(&lo), usage: wgpu::BufferUsages::UNIFORM }); + entries.extend(slabs.iter().zip(&SLABS).map(|(s, m)| e(m.binding, s))); + entries.push(e(SLABS[SLAB_K].binding, live)); + entries.push(e(OFFSETS_BINDING, &offsets)); + } + _ => entries.extend(buffers.iter().map(|(binding, buf)| wgpu::BindGroupEntry { binding: *binding, resource: buf.binding() })), + } + d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("live"), layout, entries: &entries }) + }; + mk() + } +} diff --git a/renderer/src/main.rs b/renderer/src/main.rs index dfcfce1..676b701 100644 --- a/renderer/src/main.rs +++ b/renderer/src/main.rs @@ -8,7 +8,7 @@ //! [--hdr out.raw] [--signals PREFIX] (linear RGB f32: the image; its diffuse / specular / //! emission apart, PREFIX-{diffuse,specular,emission}.raw) //! threert-render serve [--presets DIR] # JSON-lines jobs on stdin, keeps the GPU device and game runtimes warm -//! threert-render play | live # native window / headless real-time (view.rs, live.rs) +//! threert-render play | live # native window / headless real-time (view.rs, live/) //! threert-render setup # setup cost in the embedded runtime //! threert-render edit-check --events '[{"type":..,"payload":..}]' //! threert-render info # print adapter info diff --git a/renderer/src/merged.rs b/renderer/src/merged.rs index ea90e92..2d564f4 100644 --- a/renderer/src/merged.rs +++ b/renderer/src/merged.rs @@ -1,6 +1,6 @@ //! The merged binding layout: the engine's shaders with at most eight storage buffers a stage, WebGPU's default limit //! (`maxStorageBuffersPerShaderStage`). The kernel binds 19 as it is written and the live passes 24: more than a -//! browser gives a page. There is one source: pathtrace.wgsl and live.wgsl, and the bindings gpu.rs and live.rs make for +//! browser gives a page. There is one source: pathtrace.wgsl and live.wgsl, and the bindings gpu.rs and live/targets.rs make for //! them, are what the native renderer runs, unchanged. This module writes the browser's from them, as text (as //! `gpu::kernel_source` patches the kernel for a scene's features), and gives the Rust side the numbers that go with //! it. `THREERT_MERGED=1` runs the same layout natively (software traversal), so the tests that check the renderer diff --git a/renderer/src/physics.rs b/renderer/src/physics.rs index 952aebf..4b5582b 100644 --- a/renderer/src/physics.rs +++ b/renderer/src/physics.rs @@ -3,7 +3,7 @@ //! objects that bodies drive; it has no GPU in it and builds for wasm32); here is the scene it drives, the renderer's //! `World`, and the frame's timing. Scene code reaches it through `__engine.physics(op, args)` and reads the driven //! objects' poses through `__engine.physicsPoses()`; it never steps it. After a frame's ticks the bodies that moved -//! place their objects: a driven mesh or model is an instance, so a move is its TLAS transform, no triangle (live.rs +//! place their objects: a driven mesh or model is an instance, so a move is its TLAS transform, no triangle (live/edits.rs //! `move_instances`), and nothing of it passes through the script. A session that never makes a physics call has no //! world and pays nothing. diff --git a/renderer/src/scene.rs b/renderer/src/scene.rs index 5227909..47a7f7d 100644 --- a/renderer/src/scene.rs +++ b/renderer/src/scene.rs @@ -160,7 +160,7 @@ pub const PROC_HEADER: usize = 16; /// Procedural object `id` (a `Kind::Procedural`) as the build places it at world matrix `m` with object-table index /// `object`, or None when its geometry has no boxes (nothing drawn). The build and the live view's in-place edits -/// (live.rs `edit_geometry`) both make it here. Its geometry function's index is its place among the world's geometry +/// (live/edits.rs `edit_geometry`) both make it here. Its geometry function's index is its place among the world's geometry /// functions (the kernel's dispatch order). pub fn proc_object(w: &World, id: u32, m: Mat4, object: u32, materials: &HashMap, assets: &Path) -> Result> { let n = &w.objects[&id]; @@ -543,15 +543,15 @@ pub struct Retained { pub pose_groups: Vec, pub pose_member: HashMap, /// `own_blas` meshes placed as instances (hardware RT): object -> its instance; (geometry, GPU material) -> the - /// shared mesh (objects with the same geometry and material share its BLAS). live.rs `edit_geometry` keeps both. + /// shared mesh (objects with the same geometry and material share its BLAS). live/edits.rs `edit_geometry` keeps both. pub own_instance: HashMap, pub own_mesh: HashMap<(u32, u32), u32>, /// Emitting `own_blas` meshes (`InstMesh::emits`): object -> its mesh (its own, in world space). pub emitter_mesh: HashMap, - /// Instanced objects (`Kind::Instanced`) placed as instances -> where (live.rs `update_instanced`). + /// Instanced objects (`Kind::Instanced`) placed as instances -> where (live/edits.rs `update_instanced`). pub instanced: HashMap, /// Static models' instanced meshes by (uri, nodes), those with nothing flattened per placement: a model added in - /// place is another instance of one (live.rs `edit_geometry`). + /// place is another instance of one (live/edits.rs `edit_geometry`). pub model_mesh: HashMap<(String, Option>), u32>, } @@ -1141,7 +1141,7 @@ pub fn build(w: &World, assets: &Path, presets_dir: &Path, overrides: Option<&se if emits_only(&materials[mat as usize]) && !n.driven { // An emitting material: its own BLAS too, but its triangles in world space (placed at identity, // never shared), so they are light-sampled as flattened triangles are and can be edited in place - // (live.rs `edit_geometry`; ROADMAP § 6 step 3). + // (live/edits.rs `edit_geometry`; ROADMAP § 6 step 3). let oi = (objects.len() / stride) as u32; let (t, a) = emitter_mesh_tris(g, m, mat, oi); if !t.is_empty() { @@ -1171,7 +1171,7 @@ pub fn build(w: &World, assets: &Path, presets_dir: &Path, overrides: Option<&se } Kind::Instanced { geometry, procedural, materials: mats, capacity } if shown => { // Copies of one geometry: one shared mesh (one BLAS), an instance and an object entry per copy, the - // copies' records and entries contiguous (live.rs `update_instanced` rewrites them in place). + // copies' records and entries contiguous (live/edits.rs `update_instanced` rewrites them in place). // Glass, emissive and function materials and mirrored copies are flattened instead, each copy its // own triangles, as `own_blas` meshes are; changing their instances rebuilds. let empty = world::InstanceTable::default(); @@ -2053,7 +2053,7 @@ pub fn put_colors(dst: &mut Vec<[u32; 3]>, at: usize, c: Option>) } /// An `own_blas` mesh's triangles in model space and their attributes, as the pool holds them. The build and the live -/// view's in-place edits (live.rs `edit_geometry`) both make them here, so either path gives the same data. +/// view's in-place edits (live/edits.rs `edit_geometry`) both make them here, so either path gives the same data. pub fn own_mesh_tris(g: &world::Geometry, mat: u32) -> (Vec<[Vec3A; 3]>, Vec) { let (mut tris, mut flags, mut attrs) = (vec![], vec![], vec![]); append_arrays(g, Mat4::IDENTITY, mat, 0, None, &mut tris, &mut flags, &mut attrs); diff --git a/renderer/src/tables.rs b/renderer/src/tables.rs index 858c8a4..abf62aa 100644 --- a/renderer/src/tables.rs +++ b/renderer/src/tables.rs @@ -45,13 +45,13 @@ pub struct Tables { pub(crate) emissive: HashSet, pub wetness: f32, pub rain: f32, - /// Cloud cover (0..1): a change re-bakes the sky and dims the sun (live.rs `move_sun`). + /// Cloud cover (0..1): a change re-bakes the sky and dims the sun (live/edits.rs `move_sun`). pub cloud_cover: f32, pub fog: Option, /// three.js `scene.background` (`Params::background`). pub background: Option<[f32; 3]>, /// A material that starts emitting gets emitter entries in place (the live view sets these up, `prepare`'s - /// `mat_runs` / `fixed_mats`): it is queued in `started_emitting` for live.rs `add_material_emitters`, unless its + /// `mat_runs` / `fixed_mats`): it is queued in `started_emitting` for live/edits.rs `add_material_emitters`, unless its /// triangles include pool or dynamic ones (fixed: a rebuild flattens them). Off: such a material rebuilds. pub emit_in_place: bool, pub fixed_mats: HashSet, @@ -64,7 +64,7 @@ pub struct Tables { /// vec4s an object in `objects` (`scene::object_stride`). pub stride: usize, /// Power edits are held back in `deferred` instead of written to `objects` (the live view's emitter worker: a - /// scene with many emitters, live.rs `EmitterWorker`). + /// scene with many emitters, live/load.rs `EmitterWorker`). pub defer_powers: bool, /// Held-back power edits: (the object's first row in `objects`, its power, the time of the edit). pub deferred: Vec<(usize, f32, f32)>, diff --git a/renderer/src/units.rs b/renderer/src/units.rs index e4a89ad..cca2af8 100644 --- a/renderer/src/units.rs +++ b/renderer/src/units.rs @@ -1312,7 +1312,7 @@ mod tests { } /// A scene as the renderer keeps one under software traversal with units, small: the buffers the kernel reads and - /// the bookkeeping that writes them. Its operations are what an edit does (live.rs): a mesh's triangles and tree + /// the bookkeeping that writes them. Its operations are what an edit does (live/edits.rs): a mesh's triangles and tree /// into room, a record, the top tree again. struct Scene { nodes: Vec, @@ -1736,7 +1736,7 @@ mod tests { assert_eq!((none.nodes(), none.max_units()), (1, 1)); } - /// `Units` (what live.rs's edits keep): a unit names the tree its mesh has when its record is written; a top tree + /// `Units` (what live/edits.rs's edits keep): a unit names the tree its mesh has when its record is written; a top tree /// is refused, and nothing changed, while a shown unit names a tree that was freed or placed again elsewhere, or /// sits past the kernel's records; writing the record again (`moved`), hiding it or clearing it mends that. #[test] @@ -1807,7 +1807,7 @@ mod tests { assert_eq!((u.top().unwrap().len(), u.count), (0, 0)); } - /// A mesh written again where it was (a geometry that moves every frame: live.rs writes a mesh of the same + /// A mesh written again where it was (a geometry that moves every frame: live/edits.rs writes a mesh of the same /// triangle count over the one it replaces) has a new tree, and its unit's box in the top tree is the new tree's: /// the walk must reach triangles that left the old bounds. (Seen on the GPU, voxel/cut-falls-live: a part that /// fell 0.2 m was cut off at the box it had before.) diff --git a/renderer/src/voxel.rs b/renderer/src/voxel.rs index a9bef42..beafc8f 100644 --- a/renderer/src/voxel.rs +++ b/renderer/src/voxel.rs @@ -6,7 +6,7 @@ //! Two routes, one switch for the whole level: //! - meshes: each object's exposed faces greedy-meshed (`voxel_core::greedy_mesh`), one `own_blas` mesh per palette //! material; an edit meshes the touched objects again (and the terrain tiles beside them) and replaces their -//! geometry in place (live.rs `edit_geometry`); +//! geometry in place (live/edits.rs `edit_geometry`); //! - boxes: each object one procedural geometry with one box (`VOXEL_BRICKS`); an edit lays the touched objects out //! again and replaces their data in place. //! diff --git a/renderer/src/world.rs b/renderer/src/world.rs index 5697d23..8af46ad 100644 --- a/renderer/src/world.rs +++ b/renderer/src/world.rs @@ -123,7 +123,7 @@ pub struct Texture { pub enum Kind { Group, /// `own_blas` (scene code's `dynamicGeometry`): instanced with its own BLAS, so replacing its geometry, creating or - /// removing it is applied in place (live.rs `edit_geometry`) instead of rebuilding the scene; otherwise its + /// removing it is applied in place (live/edits.rs `edit_geometry`) instead of rebuilding the scene; otherwise its /// triangles are flattened into the shared groups. /// `materials`: one per geometry group (three.js's material array; empty: `material` for every triangle). Mesh { geometry: u32, material: u32, own_blas: bool, materials: Vec }, @@ -153,7 +153,7 @@ pub enum Kind { /// Copies of one geometry (scene code's `InstancedMesh`): triangle geometry `geometry` drawn with `materials[0]`, /// or procedural geometry (`procedural`) with `materials` as a `Procedural` has them. `capacity` instances, whose /// transforms (relative to the node), colours and per-instance data are its `World::instanced` table; one BLAS for - /// all, one TLAS instance each, so the table changes in place (live.rs `update_instanced`), never a rebuild. + /// all, one TLAS instance each, so the table changes in place (live/edits.rs `update_instanced`), never a rebuild. Instanced { geometry: u32, procedural: bool, materials: Vec, capacity: u32 }, } @@ -276,7 +276,7 @@ impl Default for Settings { #[derive(Default, Debug, Clone)] pub struct Changes { pub structural: bool, - /// Objects shown or hidden by their own `visible` whose build can take it in place (live.rs `commit`): particle + /// Objects shown or hidden by their own `visible` whose build can take it in place (live/edits.rs `commit`): particle /// systems that are not light-sampled (gpu.rs `set_particles_hidden`), volumes (their medium: `set_medium_hidden`) /// and lights without a card (their table entry goes black). A rebuild where the build left the object out. pub shown: HashSet, @@ -305,7 +305,7 @@ pub struct Changes { /// Objects created (or re-created) in this batch: the rebuild builds them where they are, so setting their /// transform is not a move. pub created: HashSet, - /// Geometry edits the live view can apply in place (live.rs `edit_geometry`), when nothing else is structural: + /// Geometry edits the live view can apply in place (live/edits.rs `edit_geometry`), when nothing else is structural: /// geometries replaced, objects created (or re-created with other geometry or materials) and objects removed, /// whose objects carry their own BLAS (procedural meshes, `own_blas` meshes). The live view falls back to a /// rebuild for what it cannot apply. @@ -313,12 +313,12 @@ pub struct Changes { pub added: BTreeSet, pub removed: BTreeSet, /// Instanced objects whose instances changed (table, node transform, power, customData): rewritten in place - /// (live.rs `update_instanced`). + /// (live/edits.rs `update_instanced`). pub instanced: HashSet, /// Density grids created in this batch (their volume objects may not exist yet: `World::set_grid` keeps them). pub grids_new: BTreeSet, /// Materials created in this batch that bring no texture and no material function: the live view adds them in - /// place (live.rs `add_materials`; a rebuild for one it cannot take), so an object added while the game runs may + /// place (live/edits.rs `add_materials`; a rebuild for one it cannot take), so an object added while the game runs may /// come with a material of its own. pub new_materials: BTreeSet, } @@ -538,7 +538,7 @@ impl World { // rebuilds. // (Movable as the build sees it: the object or any ancestor, `movable`.) let static_parent = parent.is_none_or(|p| !self.objects.contains_key(&p) || !self.movable(p)); - // A new instanced mesh or static model is placed in place too (ROADMAP § 6 step 5; live.rs `edit_geometry` + // A new instanced mesh or static model is placed in place too (ROADMAP § 6 step 5; live/edits.rs `edit_geometry` // says when it cannot); changing one still rebuilds. let placed_new = !self.objects.contains_key(&id) && matches!(kind, Kind::Instanced { procedural: false, .. } | Kind::Model { animation: None, .. }); let fresh = (own_blas(&kind) || placed_new) @@ -583,7 +583,7 @@ impl World { } /// A rigid body drives object `id` from now on: it is movable, built as an instance where it can be (a rebuild, - /// once; none for an object this batch adds in place, which is placed as one: live.rs `edit_geometry`). + /// once; none for an object this batch adds in place, which is placed as one: live/edits.rs `edit_geometry`). pub fn set_driven(&mut self, id: u32) -> Result<()> { let o = self.objects.get_mut(&id).ok_or_else(|| anyhow!("unknown object {id}"))?; if !o.driven { @@ -654,7 +654,7 @@ impl World { /// Records that an object's transform changed: in place where it can be, otherwise a rebuild. fn note_move(&mut self, id: u32) { - // An object added in place in this batch is placed where it is then (live.rs `edit_geometry`). + // An object added in place in this batch is placed where it is then (live/edits.rs `edit_geometry`). if self.changes.added.contains(&id) { return; } @@ -683,7 +683,7 @@ impl World { } else if light && !sun && self.leaf(id) { self.changes.lights.insert(id); } else if sun && self.leaf(id) { - // The atmosphere's sun (time of day): its light rewritten and the sky re-baked in place (live.rs `move_sun`). + // The atmosphere's sun (time of day): its light rewritten and the sky re-baked in place (live/edits.rs `move_sun`). self.changes.sun = true; } else { // Fixed geometry moved: from now on it is movable. This move rebuilds once, to set its triangles apart. @@ -818,7 +818,7 @@ impl World { /// An instanced object's instances: `count` drawn, `matrices` (16 numbers each), optionally `colors` (3 each) and /// per-instance data (4 each), all for its whole capacity; None keeps what it had. Game code is untrusted: sizes /// are checked, colours and data made finite (colours >= 0); a matrix that is not finite or not invertible hides - /// its instance (live.rs, scene.rs). Colours appearing or going rebuild (a kernel feature); the rest is in place. + /// its instance (live/edits.rs, scene.rs). Colours appearing or going rebuild (a kernel feature); the rest is in place. pub fn set_instances(&mut self, id: u32, count: u32, matrices: Option>, colors: Option>, custom: Option>) -> Result<()> { let o = self.objects.get(&id).ok_or_else(|| anyhow!("unknown object {id}"))?; let Kind::Instanced { capacity, .. } = o.kind else { return Err(anyhow!("object {}: not an instanced mesh", o.name)) }; @@ -929,7 +929,7 @@ impl World { k += 1; } // A lone object with its own BLAS, an instanced mesh or a static model is removed in place (or never added, if it - // came in this batch; live.rs `edit_geometry` rebuilds for what it cannot take out). + // came in this batch; live/edits.rs `edit_geometry` rebuilds for what it cannot take out). if let [only] = doomed[..] { if self.objects.get(&only).is_some_and(|o| own_blas(&o.kind) || matches!(o.kind, Kind::Instanced { procedural: false, .. } | Kind::Model { animation: None, .. })) { self.instanced.remove(&only); diff --git a/tests/render/geometry-added-live.ts b/tests/render/geometry-added-live.ts index 6c36a8f..74dfd52 100644 --- a/tests/render/geometry-added-live.ts +++ b/tests/render/geometry-added-live.ts @@ -1,4 +1,4 @@ -// An object added in place is drawn (live.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` +// An object added in place is drawn (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` // speck far away (its TLAS one instance) gets a `dynamicGeometry` plane across the centre at frame 3: Lambert 0.5 facing // a uniform 1-nit sky, so 0.5. Before, the plane's new BLAS and the TLAS were built apart in one encoder, and Metal, // which the TLAS reaches its BLASes by resource id through, could build the TLAS first: the plane was missing in most diff --git a/tests/render/geometry-edit-live.ts b/tests/render/geometry-edit-live.ts index 7c58d98..a5e4458 100644 --- a/tests/render/geometry-edit-live.ts +++ b/tests/render/geometry-edit-live.ts @@ -1,4 +1,4 @@ -// Geometry edits applied in place (live.rs `edit_geometry`; ROADMAP § 6 step 2c-4): after a few frames the frame script +// Geometry edits applied in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 2c-4): after a few frames the frame script // removes a procedural box that hid the centre, swaps a `dynamicGeometry` mesh's tiny plane for a 4 m one across the // centre, and adds a procedural box and a `dynamicGeometry` mesh behind it (with the materials already in the scene: a // new material rebuilds the scene). The centre then shows the swapped plane: diff --git a/tests/render/geometry-edit-move-live.ts b/tests/render/geometry-edit-move-live.ts index 6393f7d..8cd3855 100644 --- a/tests/render/geometry-edit-move-live.ts +++ b/tests/render/geometry-edit-move-live.ts @@ -1,4 +1,4 @@ -// An object added in place, then moved (live.rs `commit`: objects added in place are not in the move tables, so the +// An object added in place, then moved (live/edits.rs `commit`: objects added in place are not in the move tables, so the // move rebuilds the scene). After a few frames the frame script adds a procedural box beside the view, a few frames // later it moves the box across the centre: Lambert 0.5 facing the camera under a uniform sky of 1 nit, nothing else in // its hemisphere, so 0.5. Left where it was added, the centre would show the sky (1). diff --git a/tests/render/geometry-emitter-added-live.ts b/tests/render/geometry-emitter-added-live.ts index 13499de..5f0c948 100644 --- a/tests/render/geometry-emitter-added-live.ts +++ b/tests/render/geometry-emitter-added-live.ts @@ -1,4 +1,4 @@ -// Emitting geometry added in place (live.rs `edit_geometry`; ROADMAP § 6 step 3): after a few frames the frame script +// Emitting geometry added in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 3): after a few frames the frame script // adds a `dynamicGeometry` panel of an emissive material (1 nit, black) 1 m above a Lambert 0.5 floor, so large it // fills the floor's hemisphere. Its emitter entries come in place (no rebuild), and the floor reads 0.5 x 1 = 0.5. // The material is in the scene from the start (a speck far away): a new material rebuilds. diff --git a/tests/render/geometry-emitter-removed-live.ts b/tests/render/geometry-emitter-removed-live.ts index 747ece5..b23f755 100644 --- a/tests/render/geometry-emitter-removed-live.ts +++ b/tests/render/geometry-emitter-removed-live.ts @@ -1,4 +1,4 @@ -// Emitting geometry removed in place (live.rs `edit_geometry`; ROADMAP § 6 step 3): a `dynamicGeometry` panel of an +// Emitting geometry removed in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 3): a `dynamicGeometry` panel of an // emissive material lights a white Lambert floor; after a few frames the frame script removes it. Its emitter entries // go in place (no rebuild): the floor turns black, against an unlit patch of colour 1 beside it (MeshBasicMaterial, // lighting nothing): floor / patch = 0. diff --git a/tests/render/geometry-grow-live.ts b/tests/render/geometry-grow-live.ts index 10a8f96..1a3ff36 100644 --- a/tests/render/geometry-grow-live.ts +++ b/tests/render/geometry-grow-live.ts @@ -1,4 +1,4 @@ -// In-place edits past the room reserved at upload (live.rs `edit_geometry`; ROADMAP § 6 step 5): at frame 3 the frame +// In-place edits past the room reserved at upload (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): at frame 3 the frame // script adds 1,100 `dynamicGeometry` specks far away (more records and object entries than the room's 1,024) and a // 72,200-triangle plane across the centre (more triangles than its 65,536). The room grows (buffers and the TLAS // doubled, copied on the GPU), nothing rebuilds, and the plane reads 0.5: Lambert 0.5 under a uniform 1-nit sky. diff --git a/tests/render/geometry-instanced-added-live.ts b/tests/render/geometry-instanced-added-live.ts index a9b6b52..717fe57 100644 --- a/tests/render/geometry-instanced-added-live.ts +++ b/tests/render/geometry-instanced-added-live.ts @@ -1,4 +1,4 @@ -// An InstancedMesh added in place (live.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` +// An InstancedMesh added in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): a scene of one `dynamicGeometry` // speck gets an InstancedMesh of two planes at frame 3 (its records, TLAS instances and object entries from the room, // contiguous, as the build places one), one across the centre: Lambert 0.5 under a uniform 1-nit sky, so 0.5. A // later move of an instance (setMatrixAt) applies in place too. diff --git a/tests/render/geometry-instanced-live.ts b/tests/render/geometry-instanced-live.ts index c5958bb..237d6a8 100644 --- a/tests/render/geometry-instanced-live.ts +++ b/tests/render/geometry-instanced-live.ts @@ -1,8 +1,8 @@ -// InstancedMesh instances changed in place (live.rs `update_instanced`; ROADMAP § 6 step 2e): two copies of a dark +// InstancedMesh instances changed in place (live/edits.rs `update_instanced`; ROADMAP § 6 step 2e): two copies of a dark // cover hide the centre of a plane, a third waits behind it. After a few frames the frame script moves the first // cover behind the plane (`setMatrixAt`, `needsUpdate`), recolours it, and lowers `count` to hide the last one; the // second is hidden by a matrix scaled to 0. In the same frame a `dynamicGeometry` mesh off to the side gets a new -// geometry: the instances' TLAS changes then ride on that edit's TLAS build (live.rs `commit`). The centre then shows +// geometry: the instances' TLAS changes then ride on that edit's TLAS build (live/edits.rs `commit`). The centre then shows // the plane: Lambert, albedo 0.5, facing a uniform sky of 1 nit with nothing in its hemisphere, so 0.5. The same frames // with THREERT_EDIT_REBUILD=1 (every change rebuilds the scene) are byte-identical (the PR's A/B). import { Color, InstancedMesh, Matrix4, Mesh, PlaneGeometry } from "@threert/scene"; diff --git a/tests/render/geometry-instanced-removed-live.ts b/tests/render/geometry-instanced-removed-live.ts index db76842..75bd99f 100644 --- a/tests/render/geometry-instanced-removed-live.ts +++ b/tests/render/geometry-instanced-removed-live.ts @@ -1,4 +1,4 @@ -// An InstancedMesh removed in place (live.rs `edit_geometry`; ROADMAP § 6 step 5): its two planes (Lambert 0.2) hide a +// An InstancedMesh removed in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): its two planes (Lambert 0.2) hide a // `dynamicGeometry` plane (Lambert 0.5) behind them; at frame 3 the InstancedMesh is removed, its TLAS instances taken // out without a rebuild, and the centre shows the plane behind: 0.5 under a uniform 1-nit sky. import { InstancedMesh, Matrix4, Mesh, PlaneGeometry } from "@threert/scene"; diff --git a/tests/render/geometry-model-added-live.ts b/tests/render/geometry-model-added-live.ts index 0222ff5..8ed5a55 100644 --- a/tests/render/geometry-model-added-live.ts +++ b/tests/render/geometry-model-added-live.ts @@ -1,4 +1,4 @@ -// A glTF model added in place (live.rs `edit_geometry`; ROADMAP § 6 step 5): a static model already in the scene (a +// A glTF model added in place (live/edits.rs `edit_geometry`; ROADMAP § 6 step 5): a static model already in the scene (a // grey quad, tests/render/data/quad.glb, on the left) gets a second copy on the right at frame 3, another instance of // its mesh, without a rebuild. Both under a uniform 1-nit sky: the right reads as the left, ratio 1. import { GLTFModel } from "@threert/scene"; diff --git a/tests/render/geometry-vertex-colors-live.ts b/tests/render/geometry-vertex-colors-live.ts index 65993f2..c146afd 100644 --- a/tests/render/geometry-vertex-colors-live.ts +++ b/tests/render/geometry-vertex-colors-live.ts @@ -1,4 +1,4 @@ -// Vertex colours on geometry added in place (live.rs `edit_geometry`; F_VCOLOR): a scene with vertex colours from the +// Vertex colours on geometry added in place (live/edits.rs `edit_geometry`; F_VCOLOR): a scene with vertex colours from the // start (a coloured speck far away, `dynamicGeometry`) gets material/vertex-colors' quad at frame 3, in place; its // colours are written into the edit room with its triangles, and its centre reads 0.5 as when built. (A plain mesh far // below too: in a scene whose TLAS held one instance, an object added in place is not drawn; ROADMAP Known issues.) diff --git a/tests/render/material-emitter-started-live.ts b/tests/render/material-emitter-started-live.ts index d220f27..19ac99a 100644 --- a/tests/render/material-emitter-started-live.ts +++ b/tests/render/material-emitter-started-live.ts @@ -1,4 +1,4 @@ -// A material that starts emitting in place (tables.rs `can_emit`, live.rs `add_material_emitters`; ROADMAP § 6 +// A material that starts emitting in place (tables.rs `can_emit`, live/edits.rs `add_material_emitters`; ROADMAP § 6 // step 3): a panel 1 m above a Lambert 0.5 floor, filling its hemisphere, of a black material that emits nothing at // first; after a few frames the frame script sets its emission to 1 nit. Its triangles get emitter entries in place // (no rebuild), and the floor reads 0.5. diff --git a/tools/bench-voxel.ts b/tools/bench-voxel.ts index 70bc004..6ec8a6c 100644 --- a/tools/bench-voxel.ts +++ b/tools/bench-voxel.ts @@ -112,7 +112,7 @@ async function measure(configs: Config[], what: string) { } if (args.includes("--edits")) { - // One object's geometry replaced every half second (tools/perf/voxel-level*-edits.game.ts), in place (live.rs + // One object's geometry replaced every half second (tools/perf/voxel-level*-edits.game.ts), in place (live/edits.rs // `edit_geometry`) against a scene rebuild (THREERT_EDIT_REBUILD=1), alternated; camera still at the path's start. // Per edit frame from the frame log: the edit's CPU ms (in place) or the rebuild's, and that frame's interval. const b = branchBuild(rendererBin); diff --git a/tools/render-identity.ts b/tools/render-identity.ts index 2191254..b27fa2c 100644 --- a/tools/render-identity.ts +++ b/tools/render-identity.ts @@ -7,7 +7,9 @@ // round differently: 2e-6 is what removing unused kernel code did, 2026-11-13); it is reported apart. Exit 1 when a test // differs beyond these or its output is missing on one side. Two runs a side under-read the spread of a few tests // (2026-11-13: a skinning test that repeats itself twice and then differs by 2 %), so what is flagged is run again, -// alone, `--confirm` times a side (default 4), and judged on those runs. +// alone, `--confirm` times a side (default 4), and judged on those runs. A confirming run that leaves no output (a +// test that fails before its picture: geometry/grow-live's frame script sometimes runs past its time, 2026-11-13) is +// left out and counted; the test is judged on the runs that left one, and is changed if a side has none. // // node tools/render-identity.ts --a --b [--runs 2] [--confirm 4] [--tolerance 0] // [--env K=V,K=V] [--filter id] [--out out/render-identity/] @@ -36,8 +38,9 @@ const extra = Object.fromEntries(flag("--env").split(",").filter(Boolean).map((k const filter = flag("--filter"); const out = resolve(flag("--out", join(repoRoot, "out/render-identity", new Date().toISOString().replace(/[:.]/g, "-")))); /** One test-render run on `bin` (the tests `only` names, else all / `--filter`), its outputs in `dir` (its own: - * nothing else writes there). */ -function run(bin: string, dir: string, only = filter) { + * nothing else writes there). Whether it left an output; a run of the gate's own (not confirming) that leaves none + * is an error. */ +function run(bin: string, dir: string, only = filter, confirming = false): boolean { rmSync(dir, { recursive: true, force: true }); try { execFileSync("node", [join(repoRoot, "tools/test-render.ts"), ...(only ? [only] : []), "--out", dir], { env: { ...process.env, ...extra, THREERT_BIN: bin }, stdio: ["ignore", "ignore", "inherit"] }); @@ -45,10 +48,11 @@ function run(bin: string, dir: string, only = filter) { // (Failing tests still leave their outputs; what is compared here is the pictures, not the verdicts.) } const n = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".raw")).length : 0; - if (n === 0) { + if (n === 0 && !confirming) { console.error(`render-identity: test-render left no outputs in ${dir} (${bin})`); process.exit(2); } + return n > 0; } const dirs = { a: [] as string[], b: [] as string[] }; @@ -103,15 +107,19 @@ for (const f of [...names].sort()) { const test = f.replace(/\.raw$/, ""); console.log(`render-identity: ${f} flagged (${j.why}); ${confirmRuns} more runs a side of it alone`); const again = { a: [] as string[], b: [] as string[] }; + let lost = 0; for (const side of ["a", "b"] as const) { for (let k = 0; k < confirmRuns; k++) { const dir = join(out, "confirm", test, `${side}${k + 1}`); - run(side === "a" ? a : b, dir, test); - again[side].push(dir); + if (run(side === "a" ? a : b, dir, test, true)) again[side].push(dir); + else lost++; } } - j = judge(f, again.a, again.b); - if (j.verdict !== "changed") j.why = `confirmed on ${confirmRuns} runs a side: ${j.why || "byte-identical"}`; + const left = lost ? ` (${lost} of ${2 * confirmRuns} runs left no output)` : ""; + if (!again.a.length || !again.b.length) j = { verdict: "changed", why: `no output on side ${again.a.length ? "b" : "a"} in ${confirmRuns} runs` }; + else j = judge(f, again.a, again.b); + if (j.verdict !== "changed") j.why = `confirmed on ${again.a.length} / ${again.b.length} runs${left}: ${j.why || "byte-identical"}`; + else j.why += left; } count[j.verdict]++; if (j.verdict === "rounding" || (j.verdict === "within" && j.why.startsWith("confirmed"))) console.log(` ${f}: ${j.why}`);