diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 626ef63..ef2e55f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2107,6 +2107,25 @@ traced pixel that the fast mean's buffer had free: locked lettering of a still sign comes back from a motion's blur by the blend alone (the 0.2 on Kabukichō's signs); a lamp that comes on arrives over a quarter of a second. +**Cleanup: experiments and switches that outlived their measurement (2026-11-13, after it).** Removed, each off by +default, so nothing a scene shows changes: + +- the temporal pass's two round-3 experiment switches (`THREERT_TEMPORAL_FLAGS` 1: the à-trous stages gated by + history length alone; 2: the specular filter on mirror-like surfaces too) and the flag word that carried them; +- the emitter-reservoir experiment (`THREERT_RESTIR`, ReSTIR DI at camera hits; measured above: k = 1 0.608 → 0.613, + motion +0.1 ΔE, +0.44 ms): its kernel code, the reservoir buffer (group 1, binding 4: the kernel binds 19 storage + buffers as written, the merged layout's `MergedLive` six arrays) and five vec4 of `Params`; +- `THREERT_NO_MV` (no per-object motion vectors), `THREERT_LT_DYNAMIC=0` (emitters added in place by power share; + `bench-voxel-light`'s third row goes with it), `THREERT_EMIT_SYNC`, and the overrides of three constants + (`THREERT_EMITTER_CULL`, `THREERT_EMITTER_CULL_VIEW`, `THREERT_LIGHT_LOD`). + +Kept: the switches the benches and tests use (`THREERT_PROFILE`, `_PROFILE_BLAS`, `_FREEZE_TIME`, `_EXPOSURE_SNAP`, +`_NO_GRAIN`, `_MOTION_BLUR`, `_EDIT_REBUILD`, `_INSTANCING`), the diagnostics (`_EDIT_LOG`, `_VIEW_MODE`, `_TL_HIDE`, +`_CACHE`, `_CACHE_FLAGS`), and two that are configuration (`_GOVERN_CLOCK`; `_RASTER`, the primary-visibility path +kept for devices without hardware ray tracing). Check: test-render, every output a run repeats to the byte, is +byte-identical to the build before; with `THREERT_MERGED=1` one volume test differs at a relative 2e-6 (the kernel +is compiled from a shorter source). + Not done here: filters sized by hit distance, which is what would let a short history stand without noise; the 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. diff --git a/renderer/shaders/live.wgsl b/renderer/shaders/live.wgsl index f969910..35a349c 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`), experiment flags + 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`), - 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) @@ -124,10 +124,6 @@ fn ycocg_rgb(y: vec3) -> vec3 { return vec3(y.x + y.y - y.z, y.x + y.z, y.x - y.y - y.z); } -// Experiment switches (THREERT_TEMPORAL_FLAGS): 1 = a-trous gated by history length (round 3), 2 = specular spatial -// filter on mirror-like surfaces too (round 3). -fn xflag(b: u32) -> bool { return (L.mode.w & b) != 0u; } - fn lum(c: vec3) -> f32 { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } @@ -592,7 +588,6 @@ fn atrous_gated(gid: vec3, stage: u32, step: i32, max_noise: f32) { // every noisy night pixel, +1.9 ms on Kabukicho at 60 %). let limits = array(256.0, 128.0, 32.0, 8.0, 8.0); var noisy = (count < 4.0 || sqrt(max(c.w, 0.0)) > max_noise * max(li, 1e-4)) && (stage < 2u || count < limits[stage]); - if (xflag(1u)) { noisy = count < limits[stage]; } if (strength < 0.02 || !noisy || anim) { write_stage(stage, i, c); return; } let g = traced_surface(i); // Noise of the accumulated mean: temporal deviation / sqrt(frames). Fresh history has no reliable moments: assume @@ -812,7 +807,7 @@ fn spec_filter_traced(gid: vec3, step: i32, from_a: bool) { let g = gb_unpack(gb_out[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, xflag(2u) || fresh); + let reach = select(4.0 * clamp(rough * 8.0, 0.0, 1.0), 4.0, fresh); let npow = select(64.0, 8.0, fresh); let strength = (0.25 + smoothstep(0.08, 0.5, rough)) / sqrt(max(c.w, 1.0)) * 4.0; var outv = c.rgb; diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index 5a7c159..23e224b 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -42,7 +42,6 @@ const F_FOG: u32 = 64u; const F_VOLUME: u32 = 128u; const F_TEX: u32 = 256u; const F_FUNC: u32 = 512u; -const F_RESTIR: u32 = 1024u; const F_GLASS: u32 = 2048u; const F_COAT: u32 = 4096u; const F_HUGE: u32 = 8192u; @@ -73,11 +72,6 @@ struct Params { raster: vec4, // live: primary visibility from the raster pass (1 = on), this frame's sub-pixel jitter x, y (every camera ray's); ray tracing off (1) cache: vec4, // live radiance cache (1 = on), history cap in samples, history cap in frames, - motion: vec4, // live motion vectors: 1 = on, -, -, - (first dynamic triangle: group_first[1].x) - prev_pos: vec4, // live: the previous frame's camera (emitter reservoir reprojection), as cam_* - prev_fwd: vec4, - prev_right: vec4, - prev_up: vec4, - restir: vec4, // live emitter reservoirs: 0 off, 1 on without history, 2 on; previous traced width, height; this frame's half (0 / 1) group_first: array, 2>, // first global triangle of each traversal group ([1].x = the first dynamic one) light_lod: vec4, // live light LOD (lib.rs emitter_lod): reference position, d0 (0 = off) emit_power: vec4, // x: the emitter CDF's total without light LOD (= misc.y when it is off); y: emitter cull radius (0 = off); z: its reach in the view cone @@ -1856,9 +1850,7 @@ fn direct_light(s: Surface, lb: Lobes, v: vec3) -> vec3 { // Emissive triangles (incl. rect lights) mis_x = o; mis_n = s.n; - if (P.counts.y > 0u && restir_here()) { - sum += restir_emitters(s, lb, v, o); - } else if (P.counts.y > 0u) { + if (P.counts.y > 0u) { var es: ES; // With the light tree its candidates are already near the point: P.ltree.y of them at camera hits, one elsewhere. if (lt_on()) { es = sample_emitter_ris(o, s.n, select(1u, u32(P.ltree.w), cur_depth == 0u), s, lb, v); } else if (seen_directly()) { es = sample_emitter_ris(o, s.n, select(EMIT_RIS_M / 2u, EMIT_RIS_M, cur_depth == 0u), s, lb, v); } else if (P.light_lod.w > 0.0) { es = sample_emitter_ris(o, s.n, 2u, s, lb, v); } else { es = sample_emitter(o, s.n); } @@ -2472,27 +2464,7 @@ fn sphere_hit(x: vec3, l: vec3, hit: Hit) -> vec3 { } // ------------------------------------------------------------------------------------------------ -// Emitter reservoirs at live camera hits (ReSTIR DI, Bitterli et al. 2020; docs/ROADMAP.md § 1, step 1: direct light -// from the many emissive surfaces as clean as the exact light loop). Each pixel resamples RESTIR_M emitter points by -// their unshadowed contribution, then the previous frame's reservoir at its reprojected position and RESTIR_SPATIAL -// neighbours of it (same surface by normal and distance), and shades the winner with one shadow ray; occluded winners -// are stored with weight 0. The target is cheap (the emitter's factor and animation, not its texture); the winner gets -// the full emitted radiance, and its MIS weight against BSDF sampling uses the emitter CDF's pdf, as before. Reuse -// without visibility or pairwise MIS: slightly biased at shadow and surface boundaries. An experiment (THREERT_RESTIR=1): -// on Kabukicho it changed neither the k = 1 disocclusion frame (no history there) nor motion error (+0.1 ΔE), for -// +0.44 ms at 60 %; reuse among this frame's reservoirs needs a pass of its own. - -const RESTIR_M: u32 = 4u; -const RESTIR_SPATIAL: u32 = 2u; -const RESTIR_RADIUS: f32 = 12.0; // traced pixels -const RESTIR_CAP: f32 = 80.0; // history, in candidates (20 x RESTIR_M) - -// Two halves (this frame's, the previous one's) of 2 entries per traced pixel: (triangle, barycentrics unorm16x2, -// weight W, count M), (octahedral normal snorm16x2, distance to the camera, -, -). -@group(1) @binding(4) var resv: array>; - -// The camera ray's pixel (traced grid) and whether this path vertex uses the reservoirs. -var cur_pix: u32; +// What a live trace leaves the live passes (live.wgsl). // Reflection resolve inputs (live; live.wgsl `refl`), 2 per traced pixel: the camera hit's specular-lobe ray // (radiance it brought back, its lobe pdf; pdf 0 = the pixel sampled another lobe), (specular albedo E at the hit, @@ -2515,160 +2487,13 @@ fn pdf_spec(s: Surface, lb: Lobes, v: vec3, l: vec3) -> f32 { return g1(lb.a2, nov) * d_ggx(lb.a2, noh) / (4.0 * nov); } -fn restir_here() -> bool { - return LIVE && has(F_RESTIR) && P.restir.x > 0.5 && cur_depth == 0u && P.misc.w < 0.5; -} - +// A unit vector, octahedral (snorm16x2). fn oct_enc(n: vec3) -> u32 { let p = n.xy / (abs(n.x) + abs(n.y) + abs(n.z)); let q = select(p, (1.0 - abs(p.yx)) * select(vec2(-1.0), vec2(1.0), p >= vec2(0.0)), n.z < 0.0); return pack2x16snorm(q); } -fn oct_dec(u: u32) -> vec3 { - let p = unpack2x16snorm(u); - var n = vec3(p, 1.0 - abs(p.x) - abs(p.y)); - let t = max(-n.z, 0.0); - n.x += select(t, -t, n.x >= 0.0); - n.y += select(t, -t, n.y >= 0.0); - return normalize(n); -} - -// An emitter point: triangle (0xffffffff: none), barycentrics, and its selection probability at the point that drew it -// (-1: unknown, a reused reservoir sample; emitter_eval asks the light tree then). -struct Cand { tri: u32, b: vec2, sel: f32 }; - -// One emitter point for x (normal n) by emitter_draw (the light tree or the CDF: area x emitted luminance). -fn emitter_point(x: vec3, n: vec3) -> Cand { - let pk = emitter_draw(x, n); - var b1 = rnd(); - var b2 = rnd(); - if (b1 + b2 > 1.0) { b1 = 1.0 - b1; b2 = 1.0 - b2; } - return Cand(select(0xffffffffu, bitcast(pk.e.x), pk.p > 0.0), vec2(b1, b2), pk.p); -} - -// Emitter point c seen from x on surface s, unshadowed: target (luminance of factor x animation x BSDF x geometry x -// MIS weight), the CDF's area pdf, direction, distance, the BSDF's specular share; with `full`, the contribution with -// the emitted radiance (texture) in `f`. -struct EmitEval { f: vec3, ph: f32, pdf_a: f32, l: vec3, dist: f32, frac: vec3 }; - -fn emitter_eval(s: Surface, lb: Lobes, v: vec3, x: vec3, c: Cand, full: bool) -> EmitEval { - var r: EmitEval; - if (c.tri == 0xffffffffu || c.tri >= arrayLength(&attrs)) { return r; } - let ti = c.tri; - let e1 = tri_verts[ti * 3u + 1u].xyz; - let e2 = tri_verts[ti * 3u + 2u].xyz; - let lp = tri_verts[ti * 3u].xyz + e1 * c.b.x + e2 * c.b.y; - let cr = cross(e1, e2); - let dl = lp - x; - let d2 = max(dot(dl, dl), 1e-8); - let dist = sqrt(d2); - let l = dl / dist; - let a = attrs[ti]; - let mi = bitcast(a.uv.w); - let mat = materials[mi]; - var cos_l = dot(normalize(cr), -l); - if (mat.tex1.z != MAT_EMITTER_CARD) { cos_l = abs(cos_l); } - if (cos_l <= 0.0) { return r; } - let f = eval_bsdf(s, lb, v, l); - if (all(f <= vec3(0.0))) { return r; } - r.frac = split_frac; - // Source pdf of the candidate (the CDF, light LOD included); MIS against the BSDF rays weighs by power alone (emit_mis). - var lum_p = luminance(mat.emissive.rgb) * obj_power(a); - // Particle emitters: nominal area (x.z) in the CDF, uniform over the actual one. - if (has(F_PEMIT) && a.x.z > 0.0) { lum_p *= a.x.z / max(0.5 * length(cr), 1e-12); } - var pdf_m = lum_p / P.emit_power.x; - if (lt_on()) { - // The light tree: its pmf at x per area, for the candidate's weight and for MIS (as emit_mis weighs). - var sel = c.sel; - if (sel < 0.0) { sel = emitter_sel(ti, x, s.n); } - r.pdf_a = sel / max(0.5 * length(cr), 1e-12); - pdf_m = r.pdf_a; - } else { - r.pdf_a = lum_p * emitter_lod(ti) / P.misc.y; - } - let m = power_heuristic(pdf_m * d2 / cos_l, pdf_bsdf(s, lb, v, l)); - let g = cos_l / d2 * m; - r.ph = luminance(mat.emissive.rgb) * obj_power(a) * luminance(f) * g; - if (full) { r.f = emitter_radiance(ti, c.b.x, c.b.y, l) * f * g; } - r.l = l; - r.dist = dist; - return r; -} - -fn restir_emitters(s: Surface, lb: Lobes, v: vec3, x: vec3) -> vec3 { - var pick = Cand(0xffffffffu, vec2(0.0), -1.0); - var pick_ph = 0.0; - var wsum = 0.0; - var m = 0.0; - for (var k = 0u; k < RESTIR_M; k++) { - let c = emitter_point(x, s.n); - m += 1.0; - let ev = emitter_eval(s, lb, v, x, c, false); - if (ev.ph <= 0.0 || ev.pdf_a <= 0.0) { continue; } - let w = ev.ph / ev.pdf_a; - wsum += w; - if (rnd() * wsum < w) { - pick = c; - pick_ph = ev.ph; - } - } - let half = arrayLength(&resv) / 4u; - if (P.restir.x > 1.5) { - // Previous frame's traced-grid position of this surface point. - let dp = s.p - P.prev_pos.xyz; - let z = dot(dp, P.prev_fwd.xyz); - let dist = length(dp); - if (z > 1e-4) { - let pw = P.restir.y; - let ph = P.restir.z; - let c0 = vec2((dot(dp, P.prev_right.xyz) / (z * P.prev_pos.w * P.prev_fwd.w) * 0.5 + 0.5) * pw, (0.5 - dot(dp, P.prev_up.xyz) / (z * P.prev_pos.w) * 0.5) * ph); - let prev = (1u - u32(P.restir.w)) * half; - for (var j = 0u; j <= RESTIR_SPATIAL; j++) { - var q = c0; - if (j > 0u) { - let r = RESTIR_RADIUS * sqrt(rnd()); - let a = 2.0 * PI * rnd(); - q += r * vec2(cos(a), sin(a)); - } - if (q.x < 0.0 || q.y < 0.0 || q.x >= pw || q.y >= ph) { continue; } - let qi = prev + u32(q.y) * u32(pw) + u32(q.x); - let e1 = resv[qi * 2u + 1u]; - if (dot(oct_dec(e1.x), s.n) < 0.9 || abs(bitcast(e1.y) - dist) > 0.1 * dist) { continue; } - let e0 = resv[qi * 2u]; - let mq = min(bitcast(e0.w), RESTIR_CAP); - let c = Cand(e0.x, unpack2x16unorm(e0.y), -1.0); - m += mq; - let ev = emitter_eval(s, lb, v, x, c, false); - let w = ev.ph * bitcast(e0.z) * mq; - if (!(w > 0.0)) { continue; } - wsum += w; - if (rnd() * wsum < w) { - pick = c; - pick_ph = ev.ph; - } - } - } - } - var wgt = 0.0; - var c = vec3(0.0); - if (pick_ph > 0.0) { - wgt = wsum / (m * pick_ph); - let ev = emitter_eval(s, lb, v, x, pick, true); - if (ev.ph > 0.0 && !occluded_any(x, ev.l, ev.dist * 0.999)) { - c = ev.f * wgt; - dl_spec += c * ev.frac; - } else { - wgt = 0.0; - } - } - if (non_finite(c)) { c = vec3(0.0); wgt = 0.0; } - let cur = u32(P.restir.w) * half + cur_pix; - resv[cur * 2u] = vec4(pick.tri, pack2x16unorm(pick.b), bitcast(wgt), bitcast(min(m, RESTIR_CAP))); - resv[cur * 2u + 1u] = vec4(oct_enc(s.n), bitcast(length(s.p - P.cam_pos.xyz)), 0u, 0u); - return c; -} - // ------------------------------------------------------------------------------------------------ // World-space radiance cache (live; docs/ROADMAP.md § 1, step 1). Spatially hashed voxels, sized by distance to the // camera (CACHE_ANGLE, rounded down to a power of two), x 6 normal directions. Each holds the cosine-weighted incident @@ -3007,7 +2832,6 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let h = P.size.y; if (gid.x >= w || gid.y >= h) { return; } let pix = gid.y * w + gid.x; - cur_pix = pix; // Every pixel's deterministic direct light is written each frame (0 unless the camera hits a surface): a pixel that // turned to sky must not keep what a surface left there. if (LIVE && P.misc.w < 0.5 && arrayLength(&direct_out) >= w * h) { direct_out[pix] = vec4(0.0); } diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index 7e53aba..cc58c08 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -35,8 +35,8 @@ pub struct Gpu { pipelines: std::sync::Mutex), wgpu::ComputePipeline>>, layout: wgpu::BindGroupLayout, /// Group 1, the live viewport's: primary visibility (texture_2d), the radiance cache (`CacheBuffers`) and - /// the emitter reservoirs (`reservoirs`). Offline renders bind `no_vis` (1x1 texture `no_vis_view`, one-entry - /// cache, one reservoir). + /// what the live passes read of a trace (`KernelBufs`). Offline renders bind `no_vis` (1x1 texture `no_vis_view`, + /// one-entry cache). pub vis_layout: wgpu::BindGroupLayout, pub no_vis: wgpu::BindGroup, /// `no_vis`'s buffers (the merged layout: an offline kernel's source is written for their lengths). @@ -94,11 +94,6 @@ pub struct Params { pub raster: [f32; 4], // live primary visibility from the raster pass (1 = on), sub-pixel jitter x, y; ray tracing off (1) pub cache: [f32; 4], // live radiance cache (1 = on), history cap in samples, history cap in frames, - pub motion: [f32; 4], // live motion vectors: 1 = on, -, -, - (first dynamic triangle: group_first[1][0]) - pub prev_pos: [f32; 4], // live: the previous frame's camera (emitter reservoir reprojection), as cam_* - pub prev_fwd: [f32; 4], - pub prev_right: [f32; 4], - pub prev_up: [f32; 4], - pub restir: [f32; 4], // live emitter reservoirs: 0 off, 1 on without history, 2 on; previous traced width, height; this frame's half /// First global triangle of each traversal group (`Prepared::groups`; group 4 = the first dynamic one). Hardware /// BLAS instances carry their group index, not this offset: instance custom data has only 24 bits (16.7 M). pub group_first: [[u32; 4]; 2], @@ -952,8 +947,8 @@ fn kernel_switches(src: String, k: &KernelSwitches, hwrt: bool) -> String { if k.light_pick { // One of the three kinds of light below the directional ones, drawn among those the scene has. patch( - " sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0));\n // Emissive triangles (incl. rect lights)\n mis_x = o;\n mis_n = s.n;\n if (P.counts.y > 0u && restir_here()) {\n", - " // gpu.rs `KernelSwitches::light_pick`.\n let has_local = P.counts.x > P.counts2.x;\n let has_emit = P.counts.y > 0u;\n let has_env = P.counts2.y != 0u && (P.env.x > 0.5 || luminance(P.sky_color.rgb) > 0.0);\n var take_local = true;\n var take_emit = true;\n var take_env = true;\n var pick_w = 1.0;\n let kinds = u32(has_local) + u32(has_emit) + u32(has_env);\n if (cur_depth > 0u && kinds > 1u) {\n var k = min(u32(rnd() * f32(kinds)), kinds - 1u);\n take_local = has_local && k == 0u;\n if (has_local) { k -= 1u; }\n take_emit = has_emit && k == 0u;\n if (has_emit) { k -= 1u; }\n take_env = has_env && k == 0u;\n pick_w = f32(kinds);\n }\n let sum_sun = sum;\n let spec_sun = dl_spec;\n if (take_local) { sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0)); }\n // Emissive triangles (incl. rect lights)\n mis_x = o;\n mis_n = s.n;\n if (!take_emit) {\n } else if (P.counts.y > 0u && restir_here()) {\n", + " sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0));\n // Emissive triangles (incl. rect lights)\n mis_x = o;\n mis_n = s.n;\n if (P.counts.y > 0u) {\n", + " // gpu.rs `KernelSwitches::light_pick`.\n let has_local = P.counts.x > P.counts2.x;\n let has_emit = P.counts.y > 0u;\n let has_env = P.counts2.y != 0u && (P.env.x > 0.5 || luminance(P.sky_color.rgb) > 0.0);\n var take_local = true;\n var take_emit = true;\n var take_env = true;\n var pick_w = 1.0;\n let kinds = u32(has_local) + u32(has_emit) + u32(has_env);\n if (cur_depth > 0u && kinds > 1u) {\n var k = min(u32(rnd() * f32(kinds)), kinds - 1u);\n take_local = has_local && k == 0u;\n if (has_local) { k -= 1u; }\n take_emit = has_emit && k == 0u;\n if (has_emit) { k -= 1u; }\n take_env = has_env && k == 0u;\n pick_w = f32(kinds);\n }\n let sum_sun = sum;\n let spec_sun = dl_spec;\n if (take_local) { sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0)); }\n // Emissive triangles (incl. rect lights)\n mis_x = o;\n mis_n = s.n;\n if (!take_emit) {\n } else if (P.counts.y > 0u) {\n", ); patch( " // Environment\n if (P.counts2.y != 0u && (P.env.x > 0.5 || luminance(P.sky_color.rgb) > 0.0)) {\n let l = sample_env();\n let f = eval_bsdf(s, lb, v, l);\n", @@ -1066,17 +1061,11 @@ impl CacheBuffers { } } -/// Emitter reservoirs of the live camera hits (pathtrace.wgsl `resv`) for up to `pixels` traced pixels. -pub fn reservoirs(d: &wgpu::Device, pixels: u64) -> wgpu::Buffer { - d.create_buffer(&wgpu::BufferDescriptor { label: Some("reservoirs"), size: pixels * 64, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }) -} - -/// The kernel's group-1 buffers: the radiance cache, the emitter reservoirs, and what the live passes read of a trace +/// 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 /// 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, - pub resv: Buf, pub refl: Buf, pub motion: Buf, pub direct: Buf, @@ -1084,28 +1073,28 @@ pub struct KernelBufs { /// the guard, and always in the merged layout (the last array of `MergedLive`). pub guard: Option, /// The merged layout: the one buffer, and its arrays' lengths. - pub merged: Option<(wgpu::Buffer, [u32; 7])>, + pub merged: Option<(wgpu::Buffer, [u32; 6])>, } impl KernelBufs { - /// For a cache of `entries`, reservoirs for `pixels` and the live passes' three of these bytes. + /// For a cache of `entries` and the live passes' three of these bytes. /// `guard`: the layout as written has the guard's count (`Gpu::guard_room`). - pub fn new(d: &wgpu::Device, merged: bool, guard: bool, entries: u64, pixels: u64, refl: u64, motion: u64, direct: u64) -> KernelBufs { + pub fn new(d: &wgpu::Device, merged: bool, guard: bool, entries: u64, refl: u64, motion: u64, direct: u64) -> KernelBufs { if !merged { let sb = |label: &str, size: u64| Buf::whole(d.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false })); let guard = guard.then(|| sb("loop guard", crate::merged::GUARD_WORDS as u64 * 4)); - return KernelBufs { cache: CacheBuffers::new(d, entries), resv: Buf::whole(reservoirs(d, pixels)), refl: sb("refl", refl), motion: sb("motion", motion), direct: sb("direct", direct), guard, merged: None }; + return KernelBufs { cache: CacheBuffers::new(d, entries), refl: sb("refl", refl), motion: sb("motion", motion), direct: sb("direct", direct), guard, merged: None }; } - let bytes = [entries * 4, entries * 32, entries * 32, pixels * 64, refl, motion, direct, crate::merged::GUARD_WORDS as u64 * 4]; - let caps: [u32; 7] = std::array::from_fn(|k| crate::merged::live_cap(k, bytes[k])); + let bytes = [entries * 4, entries * 32, entries * 32, refl, motion, direct, crate::merged::GUARD_WORDS as u64 * 4]; + let caps: [u32; 6] = std::array::from_fn(|k| crate::merged::live_cap(k, bytes[k])); let (at, size) = crate::merged::live_layout(&caps); let buffer = d.create_buffer(&wgpu::BufferDescriptor { label: Some("merged live"), size, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }); let b = |k: usize| Buf { buffer: buffer.clone(), offset: at[k], size: bytes[k] }; - KernelBufs { cache: CacheBuffers { keys: b(0), acc: b(1), val: b(2) }, resv: b(3), refl: b(4), motion: b(5), direct: b(6), guard: Some(b(7)), merged: Some((buffer, caps)) } + KernelBufs { cache: CacheBuffers { keys: b(0), acc: b(1), val: b(2) }, refl: b(3), motion: b(4), direct: b(5), guard: Some(b(6)), merged: Some((buffer, caps)) } } } -/// Group-1 bind group of the kernel: a primary-visibility texture, the radiance cache, the emitter reservoirs and the +/// Group-1 bind group of the kernel: a primary-visibility texture, the radiance cache and the /// reflection resolve's inputs (`KernelBufs`); in the merged layout, the texture and the one buffer. pub fn vis_bind_group(d: &wgpu::Device, layout: &wgpu::BindGroupLayout, view: &wgpu::TextureView, b: &KernelBufs) -> wgpu::BindGroup { fn e(binding: u32, b: &Buf) -> wgpu::BindGroupEntry<'_> { @@ -1115,7 +1104,7 @@ pub fn vis_bind_group(d: &wgpu::Device, layout: &wgpu::BindGroupLayout, view: &w match &b.merged { Some((buffer, _)) => d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("vis"), layout, entries: &[tex, wgpu::BindGroupEntry { binding: crate::merged::KERNEL_LIVE_BINDING, resource: buffer.as_entire_binding() }] }), None => { - let mut entries = vec![tex, e(1, &b.cache.keys), e(2, &b.cache.acc), e(3, &b.cache.val), e(4, &b.resv), e(5, &b.refl), e(6, &b.motion), e(7, &b.direct)]; + let mut entries = vec![tex, e(1, &b.cache.keys), e(2, &b.cache.acc), e(3, &b.cache.val), e(5, &b.refl), e(6, &b.motion), e(7, &b.direct)]; entries.extend(b.guard.as_ref().map(|g| e(crate::merged::GUARD_BINDING, g))); d.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("vis"), layout, entries: &entries }) } @@ -1258,8 +1247,7 @@ pub const F_FOG: u32 = 64; pub const F_VOLUME: u32 = 128; pub const F_TEX: u32 = 256; pub const F_FUNC: u32 = 512; -/// The emitter-reservoir experiment (THREERT_RESTIR=1): compiled out otherwise, its registers cost the kernel otherwise. -pub const F_RESTIR: u32 = 1024; +// (1024: the emitter-reservoir experiment, removed 2026-11-13.) pub const F_GLASS: u32 = 2048; pub const F_COAT: u32 = 4096; /// A traversal group starts beyond 2^24 triangles: hardware instances carry their group index instead of their first @@ -1370,9 +1358,6 @@ pub fn features_of(ms: &[crate::scene::GpuMaterial], fog: Option<&crate::desc::F f |= F_HUGE; } - if std::env::var("THREERT_RESTIR").is_ok_and(|v| v == "1") { - f |= F_RESTIR; - } if let Some(mask) = std::env::var("THREERT_FEATURES").ok().and_then(|v| v.parse::().ok()) { f &= mask | F_HUGE | F_INST | F_PROC | F_TINT; // not optional: they say what the instances' custom data / the object table holds } @@ -1566,12 +1551,11 @@ impl Gpu { storage(1, false), storage(2, false), storage(3, false), - storage(4, false), storage(5, false), storage(6, false), storage(7, false), ]; - // The merged layout: group 1's seven buffers are one (merged.rs `KERNEL_LIVE`), the loop guard's count in it. + // The merged layout: group 1's six buffers are one (merged.rs `KERNEL_LIVE`), the loop guard's count in it. // As written, a kernel with the guard has the count in a buffer of its own: the binding is there whenever a // kernel of this device may have the guard (`guard_room`; a kernel without it does not name the binding). if merged { @@ -1604,7 +1588,7 @@ impl Gpu { storage(6, true), ], }); - let no_vis_bufs = KernelBufs::new(&device, merged, guard_room(hwrt), 1, 1, 64, 64, 64); + let no_vis_bufs = KernelBufs::new(&device, merged, guard_room(hwrt), 1, 64, 64, 64); let no_vis = vis_bind_group(&device, &vis_layout, &no_vis_view, &no_vis_bufs); let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout), Some(&vis_layout)], immediate_size: 0 }); let (tl_layout, tl_pipeline_layout) = if hwrt { @@ -3279,7 +3263,7 @@ mod tests { #[test] fn feature_bits_are_distinct() { let all = [ - ("F_MASKED", F_MASKED), ("F_FOG", F_FOG), ("F_VOLUME", F_VOLUME), ("F_TEX", F_TEX), ("F_FUNC", F_FUNC), ("F_RESTIR", F_RESTIR), + ("F_MASKED", F_MASKED), ("F_FOG", F_FOG), ("F_VOLUME", F_VOLUME), ("F_TEX", F_TEX), ("F_FUNC", F_FUNC), ("F_GLASS", F_GLASS), ("F_COAT", F_COAT), ("F_HUGE", F_HUGE), ("F_INST", F_INST), ("F_MEDIA", F_MEDIA), ("F_PEMIT", F_PEMIT), ("F_HMEDIA", F_HMEDIA), ("F_PROC", F_PROC), ("F_TINT", F_TINT), ("F_UNLIT", F_UNLIT), ("F_BACKGROUND", F_BACKGROUND), ("F_VCOLOR", F_VCOLOR), ("F_AERIAL", F_AERIAL), ("F_GRID", F_GRID), diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index 58f3c78..1f027d8 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -730,11 +730,6 @@ pub fn prepare_as(hwrt: bool, units: bool, sc: &mut scene::Scene, timings: &mut // (z, w: software traversal's units: the top tree's root and their count, as bits; trace_units.wgsl. y is // `render --signals`'.) motion: sc.units.as_ref().map_or([0.0; 4], |u| [0.0, 0.0, f32::from_bits(u.layout.top_first), f32::from_bits(u.count)]), - prev_pos: [0.0; 4], - prev_fwd: [0.0; 4], - prev_right: [0.0; 4], - prev_up: [0.0; 4], - restir: [0.0; 4], group_first: [[0; 4]; 2], light_lod: [0.0; 4], emit_power: [total, 0.0, 0.0, 0.0], diff --git a/renderer/src/live.rs b/renderer/src/live.rs index cefa8a3..db8f5a5 100644 --- a/renderer/src/live.rs +++ b/renderer/src/live.rs @@ -301,7 +301,7 @@ pub struct Targets { /// 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, the reservoirs, and what the passes here read of + /// 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, @@ -313,8 +313,8 @@ pub struct Targets { } impl Targets { - /// For a view of `w` x `h`, a radiance cache of `cache` entries and reservoirs for `resv` pixels. - fn new(g: &gpu::Gpu, w: u32, h: u32, lut: &[u16], cache: u64, resv: u64) -> 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; @@ -398,7 +398,7 @@ impl Targets { ae_state: b(), lumq: b(), lumb: b(), - kernel: gpu::KernelBufs::new(d, g.merged, g.guard_room(), cache, resv, n * 32, n * 16, n * 16), + 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, @@ -614,7 +614,7 @@ const ASYNC_EMITTERS: usize = 100_000; /// 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. THREERT_EMIT_SYNC=1: never. +/// which first lands the edits held back. struct EmitterWorker { ask: mpsc::Sender, done: mpsc::Receiver, @@ -655,7 +655,7 @@ impl EmitterWorker { #[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 || std::env::var("THREERT_EMIT_SYNC").is_ok_and(|v| v == "1") { + if e.emitter_tris.len() < ASYNC_EMITTERS || changing { return None; } let device = device.clone(); @@ -979,10 +979,9 @@ fn load_scene(g: &gpu::Gpu, world: &World, assets: &Path, presets: &Path, w: u32 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; THREERT_LT_DYNAMIC=0: by power - // share, the A/B). + // 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)) && std::env::var("THREERT_LT_DYNAMIC").map_or(true, |v| v != "0"); + 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()); @@ -1203,11 +1202,7 @@ pub struct LiveCore { raster: Option, /// World-space radiance cache (pathtrace.wgsl; None: THREERT_CACHE=0, paths trace every bounce). cache: Option, - /// Emitter reservoirs at camera hits (pathtrace.wgsl; experiment, THREERT_RESTIR=1), and the previous frame's traced - /// size while its reservoirs are valid (not after a reset or a rebuild: triangle indices change). - restir: bool, - restir_prev: Option<(u32, u32)>, - /// Kernel group 1: primary visibility + radiance cache + emitter reservoirs. + /// 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. @@ -1490,48 +1485,24 @@ 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" } -/// Per-object motion vectors (THREERT_NO_MV=1: off, moving objects keep a short history instead). -fn motion_vectors() -> bool { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| !std::env::var("THREERT_NO_MV").is_ok_and(|v| v == "1")) -} /// Light LOD (`emitter_lod`): emitters within this distance of the camera keep their sampling weight, farther ones -/// lose it as (d0 / d)^2. THREERT_LIGHT_LOD = d0 in metres (0 = off). +/// 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. THREERT_EMITTER_CULL = metres (0 = off). +/// 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. THREERT_EMITTER_CULL_VIEW = the multiple (0 = no cone: the camera-centred cull alone, as before 2026-11-08). +/// 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; -fn emitter_cull_view() -> f32 { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_EMITTER_CULL_VIEW").ok().and_then(|v| v.parse().ok()).unwrap_or(EMITTER_CULL_VIEW)) -} - -fn emitter_cull() -> f32 { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_EMITTER_CULL").ok().and_then(|v| v.parse().ok()).unwrap_or(EMITTER_CULL)) -} - -fn light_lod_d0() -> f32 { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_LIGHT_LOD").ok().and_then(|v| v.parse().ok()).unwrap_or(LIGHT_LOD_D0)) -} -/// THREERT_TEMPORAL_FLAGS: experiment switches of the temporal pass (live.wgsl `xflag`), 0 = defaults. -fn temporal_flags() -> u32 { - static F: std::sync::OnceLock = std::sync::OnceLock::new(); - *F.get_or_init(|| std::env::var("THREERT_TEMPORAL_FLAGS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)) -} /// Halton(base) of index i (sub-pixel jitter sequence). fn halton(mut i: u32, base: u32) -> f32 { @@ -1615,9 +1586,8 @@ impl LiveCore { 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"); - let restir = std::env::var("THREERT_RESTIR").is_ok_and(|v| v == "1"); - // (Without the cache the kernel binds one of one entry; without ReSTIR, one reservoir.) - let targets = Targets::new(&g, w, h, &vec![0u16; (LUT * LUT * LUT * 4) as usize], if cache_on { gpu::CACHE_SIZE } else { 1 }, if restir { w as u64 * h as u64 } else { 1 }); + // (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()); @@ -1648,7 +1618,7 @@ impl LiveCore { // brings it, `gpu::loop_guard_of`.) let guard_read = (gpu::loop_guard_of(!g.hwrt).is_some() && targets.kernel.guard.is_some()) .then(|| (g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("loop guard read"), size: 4, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }), Arc::new(AtomicU32::new(0)))); - let mut core = LiveCore { particle_spawns: Default::default(), grid_sources: Default::default(), g, passes, targets, sc, presets: presets.to_path_buf(), assets: assets.to_path_buf(), w, h, seq: 0, aspect: w as f32 / h as f32, cam, prev_cam: cam, frame: 0, history_len: 0.0, reset: true, bloom: ([0.0; 6], [0.0; 6]), vignette: 0.0, post: [0.0; 4], blur: (0.0, 0.0), inflight: Default::default(), gpu_ms: 0.0, gpu_frame: 0, gpu_trace_ms: 0.0, edit_ms: 0.0, instanced_ms: 0.0, edit_split: EditSplit::default(), rebuild_ms: 0.0, fallback: None, fallbacks: 0, bounces: 4, rays: true, view_mode: std::env::var("THREERT_VIEW_MODE").ok().and_then(|v| v.parse().ok()).unwrap_or(0), fixed_dt: None, motion_blur_off: false, triangles, scene_name, time: 0.0, frame_dt: 1.0 / 60.0, last_frame: Instant::now(), post_key: String::new(), render_scale: 1.0, dynamic_res: false, min_scale: MIN_SCALE, display_scale: 1.0, over_budget: (0, 0), born: Instant::now(), governed: Instant::now(), profile, timer, raster, cache, restir, restir_prev: None, kernel_bg, prev_span: None, reposed: false, emitters_moved: false, cold_frames: 0, pose_span: None, pose_reposed: false, inst_moving: vec![], inst_moved: false, prev_isz: (0, 0), lit_changed: 0, spark_lights: false, shown_spp: 1, cpu_skin_ms: 0.0, cpu_upload_ms: 0.0, gpu_blas_ms: 0.0, cpu_wait_ms: 0.0, sky_cover: 0.0, guard_trips: guard_read.as_ref().map(|_| 0), guard_read }; + let mut core = LiveCore { particle_spawns: Default::default(), grid_sources: Default::default(), g, passes, targets, sc, presets: presets.to_path_buf(), assets: assets.to_path_buf(), w, h, seq: 0, aspect: w as f32 / h as f32, cam, prev_cam: cam, frame: 0, history_len: 0.0, reset: true, bloom: ([0.0; 6], [0.0; 6]), vignette: 0.0, post: [0.0; 4], blur: (0.0, 0.0), inflight: Default::default(), gpu_ms: 0.0, gpu_frame: 0, gpu_trace_ms: 0.0, edit_ms: 0.0, instanced_ms: 0.0, edit_split: EditSplit::default(), rebuild_ms: 0.0, fallback: None, fallbacks: 0, bounces: 4, rays: true, view_mode: std::env::var("THREERT_VIEW_MODE").ok().and_then(|v| v.parse().ok()).unwrap_or(0), fixed_dt: None, motion_blur_off: false, triangles, scene_name, time: 0.0, frame_dt: 1.0 / 60.0, last_frame: Instant::now(), post_key: String::new(), render_scale: 1.0, dynamic_res: false, min_scale: MIN_SCALE, display_scale: 1.0, over_budget: (0, 0), born: Instant::now(), governed: Instant::now(), profile, timer, raster, cache, kernel_bg, prev_span: None, reposed: false, emitters_moved: false, cold_frames: 0, pose_span: None, pose_reposed: false, inst_moving: vec![], inst_moved: false, prev_isz: (0, 0), 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); @@ -1755,8 +1725,7 @@ impl LiveCore { match r { Ok(()) => { self.edit_ms += t.elapsed().as_secs_f32() * 1e3; - // As after a rebuild: history validated per pixel, the reservoirs' triangle indices dropped. - self.restir_prev = None; + // As after a rebuild: history validated per pixel. self.lit_changed = self.frame.max(1); } Err(why) => { @@ -2069,10 +2038,6 @@ impl LiveCore { 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); - // Without motion vectors (THREERT_NO_MV=1) its surfaces keep a short history, as moved triangles do. - if !motion_vectors() { - self.sc.tables.objects[inst.object as usize * st + 1][3] = shader_time(self.time); - } let slot = top.inst_base() + i as usize; top.moved(slot, w, None); self.inst_moving.push(i); @@ -2355,7 +2320,7 @@ impl LiveCore { return Err("THREERT_EDIT_REBUILD=1".into()); } let size = std::mem::size_of::(); - let (now, mv) = (shader_time(self.time), motion_vectors()); + 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()) }; @@ -2399,12 +2364,11 @@ impl LiveCore { }; rec.p = old.m; sc.gpu_instances[rec0 + i] = rec; - // The time of the last power edit: now for a new power, or (without motion vectors, THREERT_NO_MV=1) - // for every move, as `move_instances` does. + // 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 !mv || e[0][3] != entry[0][3] { now } else { e[1][3] }; + 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))); @@ -2454,9 +2418,6 @@ impl LiveCore { 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 !motion_vectors() { - self.sc.tables.objects[p.object as usize * st + 1][3] = shader_time(self.time); - } 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); @@ -2683,10 +2644,9 @@ impl LiveCore { if self.sc.up.accel.is_none() || ids.iter().any(|id| !self.sc.tables.retained.dynamic.contains_key(id)) { return Ok(false); } - let now = shader_time(self.time); let models = std::mem::take(&mut self.sc.tables.retained.dynamic); let t_skin = Instant::now(); - let res = self.reskin(world, ids, &models, 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) }; @@ -2798,7 +2758,7 @@ impl LiveCore { /// 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, now: f32) -> Result> { + 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); @@ -2890,15 +2850,11 @@ impl LiveCore { } for id in ids { let d = &models[id]; - // Pivot (material functions' object position). Motion vectors carry its history; without them - // (THREERT_NO_MV=1) its surfaces keep a short history for a moment, like a power edit. + // 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); - if !motion_vectors() { - sc.tables.objects[d.object as usize * st + 1][3] = now; - } } Ok((lo < hi).then_some((lo, hi))) } @@ -3546,7 +3502,6 @@ impl LiveCore { 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.restir_prev = None; self.inst_moving.clear(); self.prev_span = None; self.pose_span = None; @@ -3715,7 +3670,7 @@ impl LiveCore { /// 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 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. @@ -3729,8 +3684,8 @@ impl LiveCore { 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(); + 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()]; @@ -3857,19 +3812,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.restir { - let pc = self.prev_cam; - p.prev_pos = [pc.pos.x, pc.pos.y, pc.pos.z, pc.tan_half_fov]; - p.prev_fwd = [pc.fwd.x, pc.fwd.y, pc.fwd.z, self.aspect]; - p.prev_right = [pc.right.x, pc.right.y, pc.right.z, 0.0]; - p.prev_up = [pc.up.x, pc.up.y, pc.up.z, 0.0]; - let (mode, (pw, ph)) = match self.restir_prev { - Some(prev) if !self.reset => (2.0, prev), - _ => (1.0, (iw, ih)), - }; - p.restir = [mode, pw as f32, ph as f32, (self.frame % 2) as f32]; - } - if (!self.sc.dynamic.1.is_empty() || !self.sc.tables.retained.instance_of.is_empty() || !self.sc.tables.retained.rigid.is_empty()) && motion_vectors() { + 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; } @@ -3999,7 +3942,7 @@ impl LiveCore { // 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, temporal_flags()], + 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, @@ -4171,7 +4114,6 @@ impl LiveCore { } self.prev_cam = cam; self.shown_spp = spp.max(1); - self.restir_prev = Some((iw, ih)); self.prev_isz = (iw, ih); self.reset = false; self.frame = self.frame.wrapping_add(1); diff --git a/renderer/src/merged.rs b/renderer/src/merged.rs index 19e8605..80c9c8d 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 20 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 28: 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 @@ -12,7 +12,7 @@ //! (a struct's arrays are sized), each a power of two no less than `TABLE_MIN`, so a scene that grows compiles a //! kernel again only when a table doubles. The renderer keeps writing its own buffers; each frame copies them into //! the struct's (`Gpu::sync_tables`; they are small). -//! - The kernel's seven group-1 buffers (`KERNEL_LIVE`: the radiance cache, the reservoirs, what the live passes read +//! - 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 @@ -53,11 +53,10 @@ pub const TABLES_BINDING: u32 = 4; pub const TABLE_MIN: [u32; 7] = [64, 64, 1024, 256, 1024, 64, 16]; /// The kernel's group-1 buffers, in `MergedLive`'s order (group 1, binding `KERNEL_LIVE_BINDING`). -pub const KERNEL_LIVE: [Member; 7] = [ +pub const KERNEL_LIVE: [Member; 6] = [ Member { name: "cache_keys", ty: "atomic", stride: 4, align: 4, decl: "@group(1) @binding(1) var cache_keys: array>;" }, Member { name: "cache_acc", ty: "atomic", stride: 4, align: 4, decl: "@group(1) @binding(2) var cache_acc: array>;" }, Member { name: "cache_val", ty: "vec4", stride: 16, align: 16, decl: "@group(1) @binding(3) var cache_val: array>;" }, - Member { name: "resv", ty: "vec4", stride: 16, align: 16, decl: "@group(1) @binding(4) var resv: array>;" }, Member { name: "refl", ty: "vec4", stride: 16, align: 16, decl: "@group(1) @binding(5) var refl: array>;" }, Member { name: "motion_out", ty: "vec4", stride: 16, align: 16, decl: "@group(1) @binding(6) var motion_out: array>;" }, Member { name: "direct_out", ty: "vec4", stride: 16, align: 16, decl: "@group(1) @binding(7) var direct_out: array>;" }, @@ -70,9 +69,9 @@ pub const GUARD_TRIPS: Member = Member { name: "loop_guard_trips", ty: "atomic (Vec, u64) { +pub fn live_layout(caps: &[u32; 6]) -> (Vec, u64) { let members: Vec<&Member> = KERNEL_LIVE.iter().chain([&GUARD_TRIPS]).collect(); let caps: Vec = caps.iter().copied().chain([GUARD_WORDS]).collect(); let (mut at, mut out) = (0u64, vec![]); @@ -88,7 +87,7 @@ pub fn live_layout(caps: &[u32; 7]) -> (Vec, u64) { #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub struct Caps { pub tables: [u32; 7], - pub live: [u32; 7], + pub live: [u32; 6], } /// A table's array length for a buffer of `bytes`. @@ -419,7 +418,7 @@ pub const UNIFORM_LEN: [u32; 4] = [4096, 4096, 409, 32]; /// - `emitters` (the emitter CDF and the light tree), `env_cdf` (the environment's CDF, then the procedural /// geometry's data), `tri_verts`, `attrs`, `nodes`, `prev_verts` (and the vertex colours after them): megabytes, /// which no uniform binding holds; -/// - `accum` and group 1 (the radiance cache, the reservoirs, the trace's outputs): written by the kernel. +/// - `accum` and group 1 (the radiance cache, the trace's outputs): written by the kernel. pub fn uniform_tables(src: &str) -> String { let mut s = src.to_string(); let mut rules = vec![]; @@ -555,7 +554,7 @@ mod tests { fn caps() -> Caps { // A cache of one entry, a view of 64 x 36: lengths that are not round. let n = 64 * 36; - Caps { tables: TABLE_MIN, live: [1, 8, 2, 4, 2 * n, n, n] } + Caps { tables: TABLE_MIN, live: [1, 8, 2, 2 * n, n, n] } } fn parse(src: &str) -> (naga::Module, naga::valid::ModuleInfo) { @@ -582,13 +581,13 @@ mod tests { /// Every kernel variant the source patches make, in the merged layout: valid WGSL, no entry point with more than /// eight storage buffers, none declared beyond them (a pipeline layout counts what is declared), and the merged - /// structs' arrays where `offsets` puts them. The source as written has 20, so the count is the generator's. + /// structs' arrays where `offsets` puts them. The source as written has 19, so the count is the generator's. #[test] fn the_merged_kernel_fits_eight_storage_buffers() { let (m, info) = parse(KERNEL); let (used, declared) = storage_buffers(&m, &info); - assert_eq!(declared, 20, "the kernel as written"); - assert_eq!(used.iter().map(|u| u.1).max(), Some(20), "{used:?}"); + assert_eq!(declared, 19, "the kernel as written"); + assert_eq!(used.iter().map(|u| u.1).max(), Some(19), "{used:?}"); let caps = caps(); for features in [0, crate::gpu::F_VCOLOR, crate::gpu::F_GRID, crate::gpu::F_TINT | crate::gpu::F_UNLIT | crate::gpu::F_BACKGROUND, crate::gpu::F_SOURCE] { let src = kernel(&crate::gpu::kernel_source(KERNEL, features), &caps); @@ -603,7 +602,7 @@ mod tests { } /// Every kernel variant with the uniform tables: valid WGSL; the four tables are uniform buffers at their own - /// bindings, sized as the Rust side makes their buffers; the other sixteen storage buffers are as written; and + /// bindings, sized as the Rust side makes their buffers; the other fifteen storage buffers are as written; and /// every index into the four is held to its array, so nothing is read outside a uniform buffer. #[test] fn the_uniform_tables_are_four_sized_uniform_arrays() { @@ -635,7 +634,7 @@ mod tests { let base = crate::gpu::kernel_source(KERNEL, features); let src = uniform_tables(&base); let (m, info) = parse(&src); - assert_eq!(storage_buffers(&m, &info).1, 16, "features {features:#x}"); + assert_eq!(storage_buffers(&m, &info).1, 15, "features {features:#x}"); let tables: Vec<(String, u32, u32)> = globals(&m, true).into_iter().filter(|g| g.0 != "P" && g.0 != "energy_lut").collect(); let want: Vec<(String, u32, u32)> = UNIFORM_TABLES.iter().zip(UNIFORM_LEN).zip([14, 15, 17, 18]).map(|((&k, len), binding)| (TABLES[k].name.to_string(), binding, (TABLES[k].stride * len as u64) as u32)).collect(); assert_eq!(tables, want, "features {features:#x}"); @@ -714,7 +713,7 @@ mod tests { let (m, info) = parse(&g); let (used, declared) = storage_buffers(&m, &info); // (As written: one buffer more, where the device has room. Merged: still eight.) - assert!(declared == 21 || declared as u32 == STORAGE_BUFFERS, "{declared}: {used:?}"); + assert!(declared == 20 || declared as u32 == STORAGE_BUFFERS, "{declared}: {used:?}"); } let g = guard("fn f() {\n for (var i = 0u; i < n(2u); i++) { x(); }\n loop { // {\n break;\n }\n while (a) { }\n}\n", 9, "T"); assert!(g.starts_with("fn f() {\n for (var i = 0u; i < n(2u); i++) { if (loop_guard()) { break; } x(); }\n loop { if (loop_guard()) { break; } // {\n break;\n }\n while (a) { if (loop_guard()) { break; } }\n}\n"), "{g}"); diff --git a/tools/bench-ghosting.ts b/tools/bench-ghosting.ts index 74e3e96..20113f1 100644 --- a/tools/bench-ghosting.ts +++ b/tools/bench-ghosting.ts @@ -120,7 +120,7 @@ const out = resolve(join(root, "out/bench-ghosting", sceneName === "kabukicho" ? const blur = args.includes("--mrq-blur") ? { THREERT_MOTION_BLUR: "0.5,5,60" } : args.includes("--with-motion-blur") ? {} : { THREERT_MOTION_BLUR: "0,5,30" }; const env = { ...process.env, THREERT_FREEZE_TIME: "1", THREERT_EXPOSURE_SNAP: "1", ...blur }; const ueDir = args.includes("--ue") ? resolve(flag("--ue", "")) : null; -const switches = Object.entries(process.env).filter(([k]) => k.startsWith("THREERT_") && !["THREERT_TEMPORAL_FLAGS", "THREERT_BIN", "THREERT_MOTION_BLUR", "THREERT_FREEZE_TIME", "THREERT_EXPOSURE_SNAP"].includes(k)).sort(); +const switches = Object.entries(process.env).filter(([k]) => k.startsWith("THREERT_") && !["THREERT_BIN", "THREERT_MOTION_BLUR", "THREERT_FREEZE_TIME", "THREERT_EXPOSURE_SNAP"].includes(k)).sort(); const key = createHash("sha256").update(rendererVersion(bin)).update(readFileSync(bundle)).update(JSON.stringify(switches)).update(`${refScale}/${refFrames}`).digest("hex").slice(0, 12); const refs = join(root, "out/bench-ghosting/refs", key); mkdirSync(out, { recursive: true }); diff --git a/tools/bench-motion.ts b/tools/bench-motion.ts index a55c5c7..100aaa0 100644 --- a/tools/bench-motion.ts +++ b/tools/bench-motion.ts @@ -1,7 +1,7 @@ // Temporal quality on moving objects (GTA gap 4, docs/ROADMAP.md § 5): the mannequin plaza with its walkers, jogger and // car moving; a frame taken right as everything freezes (its history built while moving) against the same pose // converged (1024 frames after the freeze). ΔE / SSIM over the whole frame and the error map show ghosting, smearing and -// noise on the movers. Run twice to compare (e.g. THREERT_NO_MV=1: no motion vectors). +// noise on the movers. Run twice to compare (two builds, THREERT_BIN). // // node tools/bench-motion.ts [--assets DIR] [--tag name] [--seconds 6] import { spawn, execFileSync } from "node:child_process"; diff --git a/tools/bench-temporal.ts b/tools/bench-temporal.ts index 413055d..9fe0998 100644 --- a/tools/bench-temporal.ts +++ b/tools/bench-temporal.ts @@ -1,7 +1,7 @@ // Temporal quality of the live viewport against converged references (same renderer, same pose, same state): // frames during camera motion (ghosting + noise), right after it stops, and after a lighting change (noise + lag). // References are the live renderer itself converged for 1024 frames at 100% scale, cached per renderer build, bundle -// and THREERT_* switch (out/bench-temporal/refs/; THREERT_TEMPORAL_FLAGS excluded: its switches act while moving), so +// and THREERT_* switch (out/bench-temporal/refs/), so // every build is measured against its own converged frames: comparing with another build's mixes the change in what it // converges to (bias: judge that against UE or the path tracer) into the temporal error. With UE // references (--refs-ue, default out/bench-temporal/refs-ue when present: UE 5.8 MRQ frames of the same shots at 60 fps, @@ -73,7 +73,7 @@ const scale = flag("--scale", "auto"); // test-run render scale (references are const out = resolve(flag("--out", join(root, "out/bench-temporal", tag))); // Without it, none (games may switch UE's default on; the UE references have r.MotionBlurQuality 0). process.env.THREERT_MOTION_BLUR = args.includes("--motion-blur") ? "0.5,5,60" : "0,5,30"; -const switches = Object.entries(process.env).filter(([k]) => k.startsWith("THREERT_") && !["THREERT_TEMPORAL_FLAGS", "THREERT_BIN", "THREERT_MOTION_BLUR"].includes(k)).sort(); +const switches = Object.entries(process.env).filter(([k]) => k.startsWith("THREERT_") && !["THREERT_BIN", "THREERT_MOTION_BLUR"].includes(k)).sort(); const refKey = createHash("sha256").update(rendererVersion(bin)).update(readFileSync(bundle)).update(JSON.stringify(switches)).digest("hex").slice(0, 12); const refs = resolve(flag("--refs", join(root, "out/bench-temporal/refs", refKey))); const refsUeArg = flag("--refs-ue", join(root, "out/bench-temporal", args.includes("--motion-blur") ? "refs-ue-mb" : "refs-ue")); diff --git a/tools/bench-voxel-light.ts b/tools/bench-voxel-light.ts index 89f9903..9c1187a 100644 --- a/tools/bench-voxel-light.ts +++ b/tools/bench-voxel-light.ts @@ -14,8 +14,8 @@ // above it around its foot. // subtree when emitters added in place get a light-tree subtree of their own (the supervisor's rule): the same lamp // in the static tree (its pole cut in setup), and after the same cut in place at frame 3 in the subtree of -// emitters added in place (ltree.rs `set_dynamic`) and, THREERT_LT_DYNAMIC=0, in the CDF's power-share part, -// at the same frames, against the offline render of that world over what it lights: mean ΔE of the +// emitters added in place (ltree.rs `set_dynamic`), at the same frames, against the offline render of that +// world over what it lights: mean ΔE of the // 64-frame shot, and the per-pixel variance of the luminance over `--repeat` such shots. // blast the lamp blown up live at 5 s (a 1 m explosion; its pieces fly, the glowing ones lighting what they pass, // and settle back after 3 s), `--rounds` runs: the explosion frame (the worse of its two), frames over 50 @@ -183,18 +183,18 @@ if (only.includes("subtree")) { const lb = await bundle("pole-live-0", { blast: "pole", when: 3 }); const t = truth("pole-setup-0", tb); const rows: Record[] = []; - for (const [tag, b, env] of [["static tree", tb, {}], ["in place, own subtree", lb, {}], ["in place, power share", lb, { THREERT_LT_DYNAMIC: "0" }]] as const) { - const shots = await liveShots(`subtree-${tag.replace(/[^a-z]+/g, "-")}`, b, repeat, { env }); + for (const [tag, b] of [["static tree", tb], ["in place, own subtree", lb]] as const) { + const shots = await liveShots(`subtree-${tag.replace(/[^a-z]+/g, "-")}`, b, repeat, {}); const de = shots.map((s) => compare(t.png, s, LIT)); const v = await variance(shots, LIT); rows.push({ tag, de: de.map((m) => m.mean_delta_e), de_mean: r3(de.reduce((a, m) => a + m.mean_delta_e, 0) / de.length), ssim_mean: r3(de.reduce((a, m) => a + m.ssim, 0) / de.length), lum_ratio: r3(de.reduce((a, m) => a + m.luminance_ratio, 0) / de.length), var_rel: v.rel, var: v.var }); } - const [s, d, p] = rows as { de_mean: number; var: number }[]; + const [s, d] = rows as { de_mean: number; var: number }[]; const over = (x: { de_mean: number; var: number }) => x.de_mean - s.de_mean > 0.5 || x.var > 1.5 * s.var; - result.subtree = { rows, own: { de_diff: r3(d.de_mean - s.de_mean), var_ratio: r3(d.var / s.var), over: over(d) }, power_share: { de_diff: r3(p.de_mean - s.de_mean), var_ratio: r3(p.var / s.var), over: over(p) } }; - console.log(`\nsubtree: the lamp's emitters in the static tree, and moved in place to the subtree of emitters added in place or to the CDF's power-share part; ${repeat} live shots of 64 frames each, over what it lights`); + result.subtree = { rows, own: { de_diff: r3(d.de_mean - s.de_mean), var_ratio: r3(d.var / s.var), over: over(d) } }; + console.log(`\nsubtree: the lamp's emitters in the static tree, and moved in place to the subtree of emitters added in place; ${repeat} live shots of 64 frames each, over what it lights`); for (const r of rows) console.log(` ${String(r.tag).padEnd(24)} ΔE ${r.de_mean} (${(r.de as number[]).map((x) => x.toFixed(2)).join(" ")}), SSIM ${r.ssim_mean}, luminance ${r.lum_ratio} of offline, variance ${(r.var as number).toExponential(3)} (relative ${(r.var_rel as number).toExponential(3)})`); - for (const [tag, x] of [["own subtree", d], ["power share", p]] as const) console.log(` ${tag}: ΔE ${x.de_mean - s.de_mean >= 0 ? "+" : ""}${r3(x.de_mean - s.de_mean)}, variance x${r3(x.var / s.var)} of the static tree's: ${over(x) ? "over the rule's line (ΔE +0.5 or variance +50 %)" : "within the rule's lines"}`); + for (const [tag, x] of [["own subtree", d]] as const) console.log(` ${tag}: ΔE ${x.de_mean - s.de_mean >= 0 ? "+" : ""}${r3(x.de_mean - s.de_mean)}, variance x${r3(x.var / s.var)} of the static tree's: ${over(x) ? "over the rule's line (ΔE +0.5 or variance +50 %)" : "within the rule's lines"}`); } if (only.includes("blast")) {