diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fba56cc..e935d4e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2318,6 +2318,13 @@ materials and the background were `@if` flags: each combination another kernel s merged, uniform tables, guarded and not): 0 of 190 changed; web-hello passes; Bistro's trace the same natively and merged. +**The sampler: Owen-scrambled Sobol (2026-11-14, after it).** The kernel drew white noise from one PCG stream, its +draws' order set by the path's branches. Now shuffled, Owen-scrambled Sobol (Burley 2020, as Cycles), each decision a +dimension id (docs/RUNTIME.md "Random numbers"). Offline every draw is Sobol: RMSE x0.29-0.46 at 16-256 spp on the +Cornell box, Light Room and Bistro, means within 0.01 %. Live only the camera path's first vertex, a hash elsewhere +(as RTXPT; all-Sobol cost 2.1 ms of an 8.4 ms trace): cost and the live shots (bench-window) unchanged. The loop guard +now skips counted loops. Next: blue-noise pixel distribution (Cycles 4.2), its own PR. + ## 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 3e68924..4f88b11 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -1265,6 +1265,20 @@ too is an override, from F_TINT). - The repository's Rust is pinned (`rust-toolchain.toml`, 1.100.0): wesl-rs 0.6 asks for 1.97.1 or newer, and the determinism checks' recorded hashes are of one compiler. +## Random numbers + +`shaders/sample.wgsl`: shuffled, Owen-scrambled Sobol (Burley 2020; Cycles' Sobol-Burley), four dimensions padded by +dimension ids. Each decision at a path vertex has an id (`rng_dim`: path, vertex, slot `D_*`; `rng_sub`: a loop's +iteration or a light), which seeds the scramble: one id's samples are stratified over the sample index (the sample +offline, the frame live; 16 bits), ids are uncorrelated. +- Offline: Sobol for every draw. Live: only the camera path's first vertex; loops, later vertices and the training + paths use a hash, as NVIDIA's RTXPT (all-Sobol cost 2.1 ms of Bistro's 8.4 ms live trace; this costs nothing + measurable). +- Offline RMSE against the other sampler at 4096 spp (320 x 180, no denoiser): x0.29-0.46 at 16-256 spp (Cornell + box, Light Room, Bistro), x1 at 1 spp; means within 0.01 %. `sample.rs` checks the (0, m, 2)-net property and the + shader against itself on the GPU. +- The loop guard leaves counted loops (`for (var i = N; i < M; i++)`, literals) alone, as Tint does. + ## What the kernel trusts The kernel follows indices it reads from buffers and loops over counts it is given; it checks none of them (a diff --git a/renderer/shaders/pathtrace.wgsl b/renderer/shaders/pathtrace.wgsl index f87dc92..7592070 100644 --- a/renderer/shaders/pathtrace.wgsl +++ b/renderer/shaders/pathtrace.wgsl @@ -5,7 +5,7 @@ // wetness / puddles / ripples / grime, procedural facade windows, emissive animation, RIS many-light sampling, height // fog with volumetric single scattering. Each is compiled out when the scene does not use it (FEAT). // -// The kernel is this file and trace_units.wgsl, trace_hw.wgsl and translucent.wgsl after it (gpu.rs `KERNEL_FILES`), +// The kernel is this file and sample.wgsl, trace_units.wgsl, trace_hw.wgsl and translucent.wgsl after it (gpu.rs `KERNEL_FILES`), // compiled for a device and a scene by WESL's conditional translation (`@if`, gpu.rs `kernel_wgsl`): a declaration // or a statement under `@if(x)` is there only where x holds. The flags: // hwrt hardware traversal (ray queries) in the software BVH walk's place, and the translucency pass @@ -234,7 +234,6 @@ fn inst_attr(r: Instance, a: TriAttr) -> TriAttr { // ------------------------------------------------------------------------------------------------ // RNG -var rng_state: u32; // Path vertex index of the current shading point (cheaper procedural detail / fewer candidates past the first hit). var cur_depth: u32; // Live "ray tracing off" (P.raster.w, LiveCore::rays): set once the camera's own ray has found its surface. From then @@ -274,10 +273,6 @@ fn pcg(v: u32) -> u32 { return (w >> 22u) ^ w; } -fn rnd() -> f32 { - rng_state = pcg(rng_state); - return f32(rng_state >> 8u) * (1.0 / 16777216.0); -} // Buckets of the emitter CDF's guide table (lib.rs EMITTER_GUIDE). const EMITTER_GUIDE: u32 = 65536u; @@ -289,10 +284,6 @@ fn emitter_guide(k: u32) -> u32 { // One emitter CDF entry: the first whose cumulative weight is not below u. The guide table bounds the binary search to // u's bucket (u * EMITTER_GUIDE is exact for the 24-bit u), which holds the same entry a search over the whole CDF // finds. Requires counts.y > 0. -fn emitter_pick() -> vec4 { - return emitter_pick_u(rnd()); -} - fn emitter_pick_u(u: f32) -> vec4 { let k = min(u32(u * f32(EMITTER_GUIDE)), EMITTER_GUIDE - 1u); var lo = emitter_guide(k); @@ -343,10 +334,10 @@ fn lt_imp(nd: vec4, x: vec3, n: vec3) -> f32 { } // A CDF entry by power within entries [lo, hi) (cumulative p0 .. p1): one guide-table lookup. -fn emitter_pick_range(lo: u32, hi: u32) -> vec4 { +fn emitter_pick_range(lo: u32, hi: u32, r: f32) -> vec4 { let p0 = select(0.0, emitter_at(max(lo, 1u) - 1u).y, lo > 0u); let p1 = emitter_at(hi - 1u).y; - let u = min(p0 + rnd() * (p1 - p0), p1); + let u = min(p0 + r * (p1 - p0), p1); let k = min(u32(u * f32(EMITTER_GUIDE)), EMITTER_GUIDE - 1u); var l = emitter_guide(k); var h = emitter_guide(k + 1u); @@ -363,16 +354,18 @@ fn emitter_pick_range(lo: u32, hi: u32) -> vec4 { // total). struct EPick { e: vec4, p: f32, w: f32 }; -fn emitter_draw(x: vec3, n: vec3) -> EPick { +fn emitter_draw(x: vec3, n: vec3, rd: u32) -> EPick { + let r3 = rng3(rd); if (!lt_on()) { - let e = emitter_pick(); + let e = emitter_pick_u(r3.x); return EPick(e, e.z / P.misc.y, e.z); } - if (rnd() < P.ltree.z) { - let e = emitter_pick_range(bitcast(P.ltree.y), P.counts.y); + if (r3.x < P.ltree.z) { + let e = emitter_pick_range(bitcast(P.ltree.y), P.counts.y, r3.y); return EPick(e, e.z / P.misc.y, e.z); } var p = 1.0 - P.ltree.z; + var u = r3.y; var w = bitcast(lt_node(0u).w); for (var k = 0u; k <= LT_PATH_BITS && (w & LT_LEAF) == 0u; k++) { let l = lt_node(w); @@ -381,14 +374,15 @@ fn emitter_draw(x: vec3, n: vec3) -> EPick { let ir = lt_imp(r, x, n); if (!(il + ir > 0.0)) { return EPick(vec4(0.0), 0.0, 0.0); } let pl = il / (il + ir); - if (rnd() < pl) { w = bitcast(l.w); p *= pl; } else { w = bitcast(r.w); p *= 1.0 - pl; } + if (u < pl) { w = bitcast(l.w); p *= pl; u = u / pl; } else { w = bitcast(r.w); p *= 1.0 - pl; u = (u - pl) / (1.0 - pl); } + u = clamp(u, 0.0, 0.99999994); } if ((w & LT_LEAF) == 0u) { return EPick(vec4(0.0), 0.0, 0.0); } let lo = w & 0x1fffffffu; let hi = lo + ((w >> 29u) & 3u) + 1u; var wsum = 0.0; for (var j = lo; j < hi; j++) { wsum += emitter_at(j).z; } - var t = rnd() * wsum; + var t = r3.z * wsum; var e = emitter_at(hi - 1u); for (var j = lo; j < hi; j++) { let c = emitter_at(j); @@ -713,14 +707,14 @@ fn tri_hit(ti: u32, o: vec3, d: vec3) -> Hit { // Shadow rays of the analytic lights: objects that cast no shadow (castShadow = false, glass) let them through. -fn occluded(o: vec3, d: vec3, tmax: f32) -> bool { +fn occluded(o: vec3, d: vec3, tmax: f32, rd: u32) -> bool { shadow_tint = vec3(1.0); if (trace(o, d, tmax, TRACE_SHADOW).tri != 0xffffffffu) { return true; } - if (!LIVE && has(F_GLASS) && glass_blocks(o, d, tmax, false)) { return true; } + if (!LIVE && has(F_GLASS) && glass_blocks(o, d, tmax, false, rd)) { return true; } // Through media: blocked with probability 1 - transmittance (an unbiased estimate of it, ratio tracking). - if (media_shadows()) { return rnd() >= media_tr(o, d, tmax); } + if (media_shadows()) { return rng1(rd) >= media_tr(o, d, tmax, rd); } // Live: homogeneous media (water) analytically. - return LIVE && has(F_HMEDIA) && rnd() >= media_homogeneous(o, d, tmax, false).tr; + return LIVE && has(F_HMEDIA) && rng1(rd) >= media_homogeneous(o, d, tmax, false, rd).tr; } // Offline: what the glass a shadow ray crosses lets through (it has passed every opaque caster). At each glass surface @@ -755,10 +749,10 @@ fn glass_tr(o_in: vec3, d: vec3, tmax: f32, sky: bool) -> vec3 { // The colour a shadow ray's light keeps past glass (glass_tr), drawn as a single decision: blocked with probability // 1 - p, p its largest channel; passing, `shadow_tint` = the colour / p (unbiased; light_vis hands it to the caller). var shadow_tint: vec3 = vec3(1.0); -fn glass_blocks(o: vec3, d: vec3, tmax: f32, sky: bool) -> bool { +fn glass_blocks(o: vec3, d: vec3, tmax: f32, sky: bool, rd: u32) -> bool { let t = glass_tr(o, d, tmax, sky); let p = max(t.r, max(t.g, t.b)); - if (rnd() >= p) { return true; } + if (rng1(rng_sub(rd, 0x67u)) >= p) { return true; } shadow_tint = t / p; return false; } @@ -772,9 +766,9 @@ fn gtint(v: vec3) -> vec3 { // What reaches a lit point from a light along a shadow ray: 0 when occluded, else 1 or the tint of the glass it passed // (offline). `shadows` false (a light that casts none): 1, no ray. -fn light_vis(shadows: bool, o: vec3, d: vec3, tmax: f32) -> vec3 { +fn light_vis(shadows: bool, o: vec3, d: vec3, tmax: f32, rd: u32) -> vec3 { if (!shadows) { return vec3(1.0); } - if (occluded(o, d, tmax)) { return vec3(0.0); } + if (occluded(o, d, tmax, rd)) { return vec3(0.0); } return shadow_tint; } @@ -830,12 +824,12 @@ fn glass_at(h: Hit, d: vec3, t: f32) -> GlassAt { // Shadow rays toward the environment: as `occluded`, but glass that bends the ray (an odd number of glass surfaces: // a water surface, the shading point inside a glass solid) blocks it. The sky there is the refracted paths' (the // path loop counts it in full past such glass), which find it where it is (Snell's window from under water). -fn occluded_sky(o: vec3, d: vec3) -> bool { +fn occluded_sky(o: vec3, d: vec3, rd: u32) -> bool { shadow_tint = vec3(1.0); if (trace(o, d, INF, TRACE_SHADOW).tri != 0xffffffffu) { return true; } - if (!LIVE && has(F_GLASS) && glass_blocks(o, d, INF, true)) { return true; } - if (media_shadows()) { return rnd() >= media_tr(o, d, INF); } - return LIVE && has(F_HMEDIA) && rnd() >= media_homogeneous(o, d, INF, false).tr; + if (!LIVE && has(F_GLASS) && glass_blocks(o, d, INF, true, rd)) { return true; } + if (media_shadows()) { return rng1(rd) >= media_tr(o, d, INF, rd); } + return LIVE && has(F_HMEDIA) && rng1(rd) >= media_homogeneous(o, d, INF, false, rd).tr; } // Live homogeneous media (VolumeMesh `homogeneous`: constant coefficients, Coeff = albedo rgb + extinction, Emission rgb, @@ -843,7 +837,7 @@ fn occluded_sky(o: vec3, d: vec3) -> bool { // `light` the emission and the in-scattering from the volume voxels at each crossing's middle. The live megakernel's // only media code: water and other constant media attenuate reflection, refraction and shadow rays; other media are // the camera rays' alone (media_composite). -fn media_homogeneous(o: vec3, d: vec3, tmax: f32, light: bool) -> March { +fn media_homogeneous(o: vec3, d: vec3, tmax: f32, light: bool, rd: u32) -> March { var r = March(1.0, vec3(0.0), vec2(1.0, 0.0)); let n = min(u32(P.time.w), 32u); if (!light) { @@ -891,8 +885,9 @@ fn media_homogeneous(o: vec3, d: vec3, tmax: f32, light: bool) -> Marc // the first, ...): one lookup in the middle lit a deep crossing with its dimmer depth. var lin = vec3(0.0); if (cache_on()) { + let u2 = rng2(rng_sub(rd, 0x100u + s)); for (var j = 0u; j < 2u; j++) { - let q = (f32(j) + rnd()) * 0.5 * (1.0 - tr); + let q = (f32(j) + u2[j]) * 0.5 * (1.0 - tr); let tt = select(-log(1.0 - q) / st, (f32(j) + 0.5) * 0.5 * len, st <= 1e-6); lin += 0.5 * cache_point(o + d * (t + tt)).rgb; } @@ -924,16 +919,16 @@ fn media_shadows() -> bool { return (!LIVE || media_voxels) && has(F_MEDIA) && P // image under foliage ~1.7 % of its light (static Bistro at 25 s along its path; layer B Bistro ΔE 3.23 -> 3.30). // Removing the bias without the cost (an opacity micromap, or these rays compacted into a pass of their own) is a // follow-up item (ROADMAP § 0). Offline rendering always tests. -fn occluded_emitter(o: vec3, d: vec3, tmax: f32) -> bool { +fn occluded_emitter(o: vec3, d: vec3, tmax: f32, rd: u32) -> bool { if (trace(o, d, tmax, select(TRACE_ANY, TRACE_ANY_OPAQUE, LIVE)).tri != 0xffffffffu) { return true; } - if (media_shadows()) { return rnd() >= media_tr(o, d, tmax); } - return LIVE && has(F_HMEDIA) && rnd() >= media_homogeneous(o, d, tmax, false).tr; + if (media_shadows()) { return rng1(rd) >= media_tr(o, d, tmax, rd); } + return LIVE && has(F_HMEDIA) && rng1(rd) >= media_homogeneous(o, d, tmax, false, rd).tr; } -fn occluded_any(o: vec3, d: vec3, tmax: f32) -> bool { +fn occluded_any(o: vec3, d: vec3, tmax: f32, rd: u32) -> bool { if (trace(o, d, tmax, TRACE_ANY).tri != 0xffffffffu) { return true; } - if (media_shadows()) { return rnd() >= media_tr(o, d, tmax); } - return LIVE && has(F_HMEDIA) && rnd() >= media_homogeneous(o, d, tmax, false).tr; + if (media_shadows()) { return rng1(rd) >= media_tr(o, d, tmax, rd); } + return LIVE && has(F_HMEDIA) && rng1(rd) >= media_homogeneous(o, d, tmax, false, rd).tr; } // ------------------------------------------------------------------------------------------------ @@ -1437,32 +1432,33 @@ fn sample_vndf(v: vec3, tbn: mat3x3, alpha: f32, u1: f32, u2: f32) -> fn sample_bsdf(s: Surface, lb: Lobes, v: vec3) -> vec3 { let tbn = basis(s.n); + let r = rng4(rng_dim(D_BSDF)); sampled_trans = false; if (has(F_COAT) || has(F_GLASS)) { - let u = rnd(); + let u = r.x; if (u < lb.p_coat) { sampled_spec = true; - return reflect(-v, sample_vndf(v, tbn, lb.coat_alpha, rnd(), rnd())); + return reflect(-v, sample_vndf(v, tbn, lb.coat_alpha, r.z, r.w)); } if (u < lb.p_coat + lb.p_trans) { // Refraction through a microfacet normal (Walter et al. 2007; smooth glass: the shading normal). sampled_trans = true; sampled_spec = false; var h = s.n; - if (s.roughness > 0.05) { h = sample_vndf(v, tbn, s.roughness * s.roughness, rnd(), rnd()); } + if (s.roughness > 0.05) { h = sample_vndf(v, tbn, s.roughness * s.roughness, r.z, r.w); } let t = refract(-v, h, select(s.ior, 1.0 / s.ior, s.front)); // Total internal reflection: the ray stays inside. return select(t, reflect(-v, h), all(t == vec3(0.0))); } } - sampled_spec = rnd() < lb.p_spec; + sampled_spec = r.y < lb.p_spec; if (sampled_spec) { - let h = sample_vndf(v, tbn, s.roughness * s.roughness, rnd(), rnd()); + let h = sample_vndf(v, tbn, s.roughness * s.roughness, r.z, r.w); return reflect(-v, h); } - let r = sqrt(rnd()); - let phi = 2.0 * PI * rnd(); - return tbn * vec3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - r * r))); + let rr = sqrt(r.z); + let phi = 2.0 * PI * r.w; + return tbn * vec3(rr * cos(phi), rr * sin(phi), sqrt(max(0.0, 1.0 - rr * rr))); } // ------------------------------------------------------------------------------------------------ @@ -1504,18 +1500,19 @@ fn lower_bound(start: u32, count: u32, x: f32) -> u32 { return min(lo, count - 1u); } -fn sample_env() -> vec3 { +fn sample_env(rd: u32) -> vec3 { + let u4 = rng4(rd); if (P.env.x < 0.5) { - let z = 1.0 - 2.0 * rnd(); + let z = 1.0 - 2.0 * u4.x; let r = sqrt(max(0.0, 1.0 - z * z)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * u4.y; return vec3(r * cos(phi), z, r * sin(phi)); } let w = u32(P.env.y); let h = u32(P.env.z); - let y = lower_bound(0u, h, rnd()); - let x = lower_bound(h + y * w, w, rnd()); - let u = (f32(x) + rnd()) / f32(w); - let v = (f32(y) + rnd()) / f32(h); + let y = lower_bound(0u, h, u4.x); + let x = lower_bound(h + y * w, w, u4.y); + let u = (f32(x) + u4.z) / f32(w); + let v = (f32(y) + u4.w) / f32(h); let theta = v * PI; let phi = (u - 0.5) * 2.0 * PI; return vec3(sin(theta) * sin(phi), cos(theta), -sin(theta) * cos(phi)); @@ -1629,14 +1626,14 @@ struct LS { c: vec3, l: vec3, sl: vec3, dist: f32 }; // Point / spot light seen from x: punctual intensity x attenuation (unshadowed), direction to the light centre `l`, // and a shadow ray toward a point sampled on the light's sphere (soft shadows). -fn local_light(li: Light, x: vec3) -> LS { +fn local_light(li: Light, x: vec3, u: vec2) -> LS { if (u32(li.pos.w) == 3u) { // Rect (UE RectLight): uniform point on the rectangle; luminance x area x cos_light / d^2 (Lambertian), // cut beyond the barn-door angle. let n = li.dir.xyz; let ux = li.ext.xyz; let uy = normalize(cross(n, ux)) * li.dir.w; - let lp = li.pos.xyz + ux * (2.0 * rnd() - 1.0) + uy * (2.0 * rnd() - 1.0); + let lp = li.pos.xyz + ux * (2.0 * u.x - 1.0) + uy * (2.0 * u.y - 1.0); let dl = lp - x; let d2 = max(dot(dl, dl), 1e-8); let dist = sqrt(d2); @@ -1658,9 +1655,9 @@ fn local_light(li: Light, x: vec3) -> LS { var lp = li.pos.xyz; let radius = li.dir.w; if (radius > 0.0) { - let z = 1.0 - 2.0 * rnd(); + let z = 1.0 - 2.0 * u.x; let r = sqrt(max(0.0, 1.0 - z * z)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * u.y; lp += radius * vec3(r * cos(phi), r * sin(phi), z); } let dl = li.pos.xyz - x; @@ -1687,7 +1684,7 @@ fn local_light(li: Light, x: vec3) -> LS { // ones too when there are few (one shadow ray each). Otherwise: resampled importance sampling (Talbot 2005) of RIS_M // uniform candidates by unshadowed contribution, then one shadow ray. `vol` = in-scattering in the fog (phase // function) instead of the surface BSDF. Surface results add their specular part to `dl_spec`. -fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, vol: bool, d: vec3) -> vec3 { +fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, vol: bool, d: vec3, rd: u32) -> vec3 { let nd = P.counts2.x; let nl = P.counts.x - nd; if (nl == 0u) { return vec3(0.0); } @@ -1718,11 +1715,11 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, let reach = li.color.w + select(li.dir.w, length(li.ext.xyz) + li.dir.w, u32(li.pos.w) == 3u); if (li.color.w > 0.0 && dot(dc, dc) > reach * reach) { continue; } if (u32(li.pos.w) != 3u && dot(dc, s.ng) < -li.dir.w) { continue; } // eval_bsdf rejects it anyway - let ls = local_light(li, x); + let ls = local_light(li, x, rng2(rng_sub(rd, i))); if (all(ls.c <= vec3(0.0))) { continue; } let f = eval_bsdf_k(s, lb, v, ls.l, li.x.y); if (any(f > vec3(0.0))) { - let vis = light_vis(li.ext.w >= 0.5, o, ls.sl, ls.dist * 0.999); + let vis = light_vis(li.ext.w >= 0.5, o, ls.sl, ls.dist * 0.999, rng_sub(rd ^ 0x40000000u, i)); if (any(vis > vec3(0.0))) { sum += ls.c * f * gtint(vis); dl_spec += ls.c * f * gtint(vis) * split_frac; @@ -1741,13 +1738,14 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, var pick_ph = 0.0; var pick_ls: LS; for (var k = 0u; k < m_cand; k++) { - let idx = first + min(u32(rnd() * f32(count)), count - 1u); - let ls = local_light(light_at(idx), x); + let r4 = rng4(rng_sub(rd ^ 0x80000000u, k)); + let idx = first + min(u32(r4.x * f32(count)), count - 1u); + let ls = local_light(light_at(idx), x, r4.yz); var ph = luminance(ls.c); if (vol) { ph *= light_at(idx).x.x; } else { ph *= max(dot(s.n, ls.l), 0.0) + 0.05 * max(dot(s.ng, ls.l), 0.0); } if (ph <= 0.0) { continue; } wsum += ph; - if (rnd() * wsum < ph) { + if (r4.w * wsum < ph) { pick = idx; pick_ph = ph; pick_ls = ls; @@ -1758,7 +1756,7 @@ fn local_lights(s: Surface, lb: Lobes, v: vec3, o: vec3, x: vec3, let lk = light_at(pick).x; if (vol) { c = pick_ls.c * hg_phase(P.vol.y, dot(pick_ls.l, d)) * lk.x; } else { c = pick_ls.c * eval_bsdf_k(s, lb, v, pick_ls.l, lk.y); } if (any(c > vec3(0.0))) { - let vis = light_vis(light_at(pick).ext.w >= 0.5, o, pick_ls.sl, pick_ls.dist * 0.999); + let vis = light_vis(light_at(pick).ext.w >= 0.5, o, pick_ls.sl, pick_ls.dist * 0.999, rng_sub(rd, 0xffffffffu)); if (any(vis > vec3(0.0))) { let r = c * gtint(vis) * (wsum * f32(count) / (f32(m_cand) * pick_ph)); sum += r; @@ -1801,7 +1799,7 @@ fn emitter_radiance(ti: u32, b1: f32, b2: f32, l: vec3) -> vec3 { // same with or without light LOD. With light LOD every surface vertex samples this way (deeper ones with 2 // candidates): a single draw from the LOD'd CDF would make far emitters rare and heavy, which the radiance cache's // clamps cut. -fn sample_emitter_ris(x: vec3, n: vec3, m: u32, sf: Surface, lb: Lobes, v: vec3) -> ES { +fn sample_emitter_ris(x: vec3, n: vec3, m: u32, sf: Surface, lb: Lobes, v: vec3, rd: u32) -> ES { var r = ES(vec3(0.0), vec3(0.0, 1.0, 0.0), 0.0, 0.0, 0.0); var wsum = 0.0; var pick_ti = 0u; @@ -1810,10 +1808,12 @@ fn sample_emitter_ris(x: vec3, n: vec3, m: u32, sf: Surface, lb: Lobes var pick_e = vec4(0.0); var pick_sel = 0.0; for (var k = 0u; k < m; k++) { - let pk = emitter_draw(x, n); + let dk = rng_sub(rd, k); + let pk = emitter_draw(x, n, dk); let e = pk.e; - var b1 = rnd(); - var b2 = rnd(); + let bb = rng3(rng_sub(dk, 1u)); + var b1 = bb.x; + var b2 = bb.y; if (pk.p <= 0.0) { continue; } if (b1 + b2 > 1.0) { b1 = 1.0 - b1; b2 = 1.0 - b2; } let ti = bitcast(e.x); @@ -1839,7 +1839,7 @@ fn sample_emitter_ris(x: vec3, n: vec3, m: u32, sf: Surface, lb: Lobes } if (w <= 0.0) { continue; } wsum += w; - if (rnd() * wsum < w) { + if (bb.z * wsum < w) { pick_ti = ti; pick_b = vec2(b1, b2); pick_ph = pk.w / (abs(e.w) * lk) * g; @@ -1872,17 +1872,18 @@ fn sample_emitter_ris(x: vec3, n: vec3, m: u32, sf: Surface, lb: Lobes // One emissive triangle point by the emitter CDF (area x emissive-factor luminance); radiance includes the emissive // map (coarse mip) and animation, so signs spill their own colours. -fn sample_emitter(x: vec3, n: vec3) -> ES { +fn sample_emitter(x: vec3, n: vec3, rd: u32) -> ES { var r = ES(vec3(0.0), vec3(0.0, 1.0, 0.0), 0.0, 0.0, 0.0); - let pk = emitter_draw(x, n); + let pk = emitter_draw(x, n, rd); let e = pk.e; if (pk.p <= 0.0) { return r; } // switched off (power 0) let ti = bitcast(e.x); let v0 = tri_verts[ti * 3u].xyz; let e1 = tri_verts[ti * 3u + 1u].xyz; let e2 = tri_verts[ti * 3u + 2u].xyz; - var b1 = rnd(); - var b2 = rnd(); + let bb = rng2(rng_sub(rd, 1u)); + var b1 = bb.x; + var b2 = bb.y; if (b1 + b2 > 1.0) { b1 = 1.0 - b1; b2 = 1.0 - b2; } let lp = v0 + e1 * b1 + e2 * b2; let cr = cross(e1, e2); @@ -1927,17 +1928,17 @@ fn direct_light(s: Surface, lb: Lobes, v: vec3) -> vec3 { let li = light_at(i); let cos_max = li.dir.w; let to_light = -li.dir.xyz; - let cos_t = 1.0 - rnd() * (1.0 - cos_max); + let u = rng2(rng_sub(rng_dim(D_SUN), i)); + let cos_t = 1.0 - u.x * (1.0 - cos_max); let sin_t = sqrt(max(0.0, 1.0 - cos_t * cos_t)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * u.y; let tb = basis(to_light); let l = tb * vec3(sin_t * cos(phi), sin_t * sin(phi), cos_t); - // A light gone black (a moon that has set, the sun at night) costs neither its BSDF nor its shadow ray. (After - // the cone's random numbers: every other light sees the same ones as before.) + // A light gone black (a moon that has set, the sun at night) costs neither its BSDF nor its shadow ray. if (all(li.color.rgb <= vec3(0.0))) { continue; } let f = eval_bsdf_k(s, lb, v, l, li.x.y); if (any(f > vec3(0.0))) { - let vis = light_vis(li.ext.w >= 0.5, o, l, INF); + let vis = light_vis(li.ext.w >= 0.5, o, l, INF, rng_sub(rng_dim(D_SUN_VIS), i)); if (any(vis > vec3(0.0))) { let pdf_l = 1.0 / (2.0 * PI * (1.0 - cos_max)); let frac = split_frac; @@ -1962,7 +1963,7 @@ fn direct_light(s: Surface, lb: Lobes, v: vec3) -> vec3 { @if(light_pick) var pick_w = 1.0; @if(light_pick) let kinds = u32(has_local) + u32(has_emit) + u32(has_env); @if(light_pick) if (cur_depth > 0u && kinds > 1u) { - var k = min(u32(rnd() * f32(kinds)), kinds - 1u); + var k = min(u32(rng1(rng_dim(D_PICK)) * f32(kinds)), kinds - 1u); take_local = has_local && k == 0u; if (has_local) { k -= 1u; } take_emit = has_emit && k == 0u; @@ -1972,8 +1973,8 @@ fn direct_light(s: Surface, lb: Lobes, v: vec3) -> vec3 { } @if(light_pick) let sum_sun = sum; @if(light_pick) let spec_sun = dl_spec; - @if(!light_pick) sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0)); - @if(light_pick) if (take_local) { sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0)); } + @if(!light_pick) sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0), rng_dim(D_LOCAL)); + @if(light_pick) if (take_local) { sum += local_lights(s, lb, v, o, s.p, false, vec3(0.0), rng_dim(D_LOCAL)); } // Emissive triangles (incl. rect lights) mis_x = o; mis_n = s.n; @@ -1982,10 +1983,10 @@ fn direct_light(s: Surface, lb: Lobes, v: vec3) -> vec3 { if (emitters > 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); } + if (lt_on()) { es = sample_emitter_ris(o, s.n, select(1u, u32(P.ltree.w), cur_depth == 0u), s, lb, v, rng_dim(D_EMIT)); } 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, rng_dim(D_EMIT)); } else if (P.light_lod.w > 0.0) { es = sample_emitter_ris(o, s.n, 2u, s, lb, v, rng_dim(D_EMIT)); } else { es = sample_emitter(o, s.n, rng_dim(D_EMIT)); } if (es.pdf_w > 0.0) { let f = eval_bsdf(s, lb, v, es.l); - if (any(f > vec3(0.0)) && !occluded_emitter(o, es.l, es.dist * 0.999)) { + if (any(f > vec3(0.0)) && !occluded_emitter(o, es.l, es.dist * 0.999, rng_dim(D_EMIT_VIS))) { let frac = split_frac; let wgt = power_heuristic(es.pdf_w, pdf_bsdf(s, lb, v, es.l)); sum += es.le * f * wgt * es.w; @@ -1997,11 +1998,11 @@ fn direct_light(s: Surface, lb: Lobes, v: vec3) -> vec3 { var env_on = P.counts2.y != 0u && (P.env.x > 0.5 || luminance(P.sky_color.rgb) > 0.0); @if(light_pick) env_on = take_env && has_env; if (env_on) { - let l = sample_env(); + let l = sample_env(rng_dim(D_ENV)); let f = eval_bsdf(s, lb, v, l); if (any(f > vec3(0.0))) { let pdf_e = env_pdf(l); - if (pdf_e > 0.0 && !occluded_sky(o, l)) { + if (pdf_e > 0.0 && !occluded_sky(o, l, rng_dim(D_ENV_VIS))) { let frac = split_frac; let wgt = power_heuristic(pdf_e, pdf_bsdf(s, lb, v, l)); sum += env_radiance(l) * f * wgt / pdf_e * gtint(shadow_tint); @@ -2061,10 +2062,11 @@ fn fog_depth(o: vec3, d: vec3, t0: f32, t1: f32) -> f32 { // Single scattering along a camera ray segment: one distance sampled by optical depth, then lights (RIS), one // emissive point and the directional lights, each with a shadow ray. `tr` = segment transmittance. -fn fog_scatter(o: vec3, d: vec3, t0: f32, t1: f32, tr: f32) -> vec3 { +fn fog_scatter(o: vec3, d: vec3, t0: f32, t1: f32, tr: f32, rd: u32) -> vec3 { + let r2 = rng2(rd); let pe = 1.0 - tr; if (pe < 1e-4) { return vec3(0.0); } - let tau = -log(max(1.0 - rnd() * pe, 1e-8)); + let tau = -log(max(1.0 - r2.x * pe, 1e-8)); let a = fog_base(o, d, t0) * max(P.vol.z, 1e-3); let k = P.fog.y * d.y; var t = tau / max(a, 1e-12); @@ -2075,13 +2077,13 @@ fn fog_scatter(o: vec3, d: vec3, t0: f32, t1: f32, tr: f32) -> vec3(0.0))) { continue; } // black: no shadow ray (direct_light) - if (li.ext.w < 0.5 || !occluded(x, l, INF)) { ls += li.color.rgb * hg_phase(P.vol.y, dot(l, d)) * li.x.x; } + if (li.ext.w < 0.5 || !occluded(x, l, INF, rng_sub(rd, 0x30u + i))) { ls += li.color.rgb * hg_phase(P.vol.y, dot(l, d)) * li.x.x; } } // One shadow ray: either the local lights (RIS) or one emissive point, chosen with equal odds when both exist. let has_l = P.counts.x > P.counts2.x; let has_e = P.counts.y > 0u; let both = has_l && has_e; - let use_e = has_e && (!has_l || rnd() < 0.5); + let use_e = has_e && (!has_l || r2.y < 0.5); let wk = select(1.0, 2.0, both); if (use_e) { // Emitters picked by global power are mostly far away from a fog point: resample 4 candidates by their @@ -2092,24 +2094,24 @@ fn fog_scatter(o: vec3, d: vec3, t0: f32, t1: f32, tr: f32) -> vec3(0.0); - let es = sample_emitter(x, vec3(0.0)); + let es = sample_emitter(x, vec3(0.0), rng_sub(rd, 0x10u + c)); if (es.pdf_w <= 0.0) { continue; } let t = luminance(es.le) * hg_phase(P.vol.y, dot(es.l, d)); let w = t / es.pdf_w; if (w <= 0.0) { continue; } wsum += w; - if (rnd() * wsum < w) { + if (rng1(rng_sub(rd, 0x20u + c)) * wsum < w) { pick = es; pick_t = t; } } - if (pick_t > 0.0 && !occluded_any(x, pick.l, pick.dist * 0.999)) { + if (pick_t > 0.0 && !occluded_any(x, pick.l, pick.dist * 0.999, rng_sub(rd, 0x40u))) { ls += wk * pick.le * hg_phase(P.vol.y, dot(pick.l, d)) * (wsum / (4.0 * pick_t)); } } else if (has_l) { var dummy_s: Surface; var dummy_l: Lobes; - ls += wk * local_lights(dummy_s, dummy_l, -d, x, x, true, d); + ls += wk * local_lights(dummy_s, dummy_l, -d, x, x, true, d, rng_sub(rd, 0x50u)); } return P.vol.x * P.vol2.rgb * pe * ls; } @@ -2193,7 +2195,8 @@ struct Track { t: f32, albedo: vec3, g: f32, emission: vec3 }; // majorants and their coefficients summed (media may overlap). A collision is one of theirs, by extinction. // (Until 2026-11-13 each medium was tracked whole, in the order the ray entered them: a collision in the first hid // what a medium inside it emitted or scattered before that point: a fire inside smoke was drawn behind the smoke.) -fn media_track(o: vec3, d: vec3, tmax: f32) -> Track { +fn media_track(o: vec3, d: vec3, tmax: f32, rd: u32) -> Track { + var stp = 0u; var r = Track(-1.0, vec3(1.0), 0.0, vec3(0.0)); let n = min(u32(P.time.w), 32u); var t = 0.0; @@ -2216,7 +2219,9 @@ fn media_track(o: vec3, d: vec3, tmax: f32) -> Track { } if (mask != 0u && mj > 0.0) { loop { - t -= log(1.0 - rnd()) / mj; + let r3 = rng_hash3(rng_sub(rd, stp)); + stp++; + t -= log(1.0 - r3.x) / mj; if (t >= end) { break; } var st = 0.0; var em = vec3(0.0); @@ -2226,13 +2231,13 @@ fn media_track(o: vec3, d: vec3, tmax: f32) -> Track { st += v.extinction; em += v.emission; // The medium a collision here is in: each by its extinction (the first without a draw). - if (v.extinction > 0.0 && (st <= v.extinction || rnd() * st < v.extinction)) { + if (v.extinction > 0.0 && (st <= v.extinction || rng_hash(rng_sub(rng_sub(rd, stp), k), 0u) * st < v.extinction)) { r.albedo = v.albedo; r.g = medium_at(k).b.y; } } r.emission += em / mj; - if (rnd() * mj < st) { + if (r3.z * mj < st) { r.t = t; return r; } @@ -2247,7 +2252,7 @@ fn media_track(o: vec3, d: vec3, tmax: f32) -> Track { } // Transmittance through the media along o + d t, t < tmax (ratio tracking; an unbiased estimate). -fn media_tr(o: vec3, d: vec3, tmax: f32) -> f32 { +fn media_tr(o: vec3, d: vec3, tmax: f32, rd: u32) -> f32 { var tr = 1.0; let n = min(u32(P.time.w), 32u); for (var k = 0u; k < n; k++) { @@ -2256,8 +2261,10 @@ fn media_tr(o: vec3, d: vec3, tmax: f32) -> f32 { let mj = medium_at(k).b.x; if (max(sp.x, 0.0) >= b || mj <= 0.0) { continue; } var t = max(sp.x, 0.0); + var step = 0u; loop { - t -= log(1.0 - rnd()) / mj; + t -= log(1.0 - rng_hash(rng_sub(rd, (k << 24u) + step), 0u)) / mj; + step++; if (t >= b) { break; } tr *= 1.0 - medium_eval(k, o + d * t).extinction / mj; if (tr < 1e-4) { return 0.0; } @@ -2278,10 +2285,10 @@ const MEDIA_TRAIN_POINTS: u32 = 4096u; // time alone (their transmittance, their light's luminance). struct March { tr: f32, light: vec3, moving: vec2 }; -fn media_march(o: vec3, d: vec3, tmax: f32, camera: bool) -> March { +fn media_march(o: vec3, d: vec3, tmax: f32, camera: bool, rd: u32) -> March { var r = March(1.0, vec3(0.0), vec2(1.0, 0.0)); let n = min(u32(P.time.w), 32u); - let u = rnd(); + let u = rng1(rd); let per = f32(select(MEDIA_STEPS_INDIRECT, MEDIA_STEPS, camera)); let lw = vec3(0.2126, 0.7152, 0.0722); // A stretch at a time, between the points where the ray enters or leaves a medium (as `media_track`): the media @@ -2343,7 +2350,7 @@ fn media_march(o: vec3, d: vec3, tmax: f32, camera: bool) -> March { // then the voxel of the point itself. Near the boundary that lost light, at every scattering order // through the training's feedback (volume/furnace-live 0.931 of 1 without the emission clamp). let vs = exp2(floor(log2(max(length(x - P.cam_pos.xyz) * CACHE_ANGLE, CACHE_MIN_VOXEL))) + VOLUME_FINE); - let jv = cache_point(x + (vec3(rnd(), rnd(), rnd()) - 0.5) * vs); + let jv = cache_point(x + (rng3(rng_sub(rd, (s << 16u) + j)) - 0.5) * vs); lin = jv.rgb; if (jv.w < CACHE_WARM) { lin = cache_point(x).rgb; } } @@ -2360,31 +2367,33 @@ fn media_march(o: vec3, d: vec3, tmax: f32, camera: bool) -> March { // A direction scattered from `d` by the Henyey-Greenstein phase function (its pdf: hg_phase(g, dot(d, result))). fn sample_hg(d: vec3, g: f32) -> vec3 { - let u = rnd(); + let r = rng2(rng_dim(D_PHASE)); + let u = r.x; var ct = 1.0 - 2.0 * u; if (abs(g) > 1e-3) { let q = (1.0 - g * g) / (1.0 - g + 2.0 * g * u); ct = (1.0 + g * g - q * q) / (2.0 * g); } let st = sqrt(max(0.0, 1.0 - ct * ct)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * r.y; return basis(d) * vec3(st * cos(phi), st * sin(phi), ct); } // Direct light scattered at x along a path travelling in direction d (phase function HG g; shadow rays through the // media), MIS against phase sampling where the light can also be hit: directional disks, emitters, the environment. -fn phase_direct(x: vec3, d: vec3, g: f32) -> vec3 { +fn phase_direct(x: vec3, d: vec3, g: f32, rd: u32) -> vec3 { var sum = vec3(0.0); for (var i = 0u; i < P.counts2.x; i++) { let li = light_at(i); let cos_max = li.dir.w; - let cos_t = 1.0 - rnd() * (1.0 - cos_max); + let u = rng2(rng_sub(rd, i)); + let cos_t = 1.0 - u.x * (1.0 - cos_max); let sin_t = sqrt(max(0.0, 1.0 - cos_t * cos_t)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * u.y; let l = basis(-li.dir.xyz) * vec3(sin_t * cos(phi), sin_t * sin(phi), cos_t); if (all(li.color.rgb <= vec3(0.0))) { continue; } // black: no shadow ray (direct_light) let ph = hg_phase(g, dot(l, d)); - let vis = light_vis(li.ext.w >= 0.5, x, l, INF); + let vis = light_vis(li.ext.w >= 0.5, x, l, INF, rng_sub(rd, 0x100u + i)); if (any(vis > vec3(0.0))) { sum += li.color.rgb * gtint(vis) * ph * power_heuristic(1.0 / (2.0 * PI * (1.0 - cos_max)), ph); } @@ -2397,35 +2406,36 @@ fn phase_direct(x: vec3, d: vec3, g: f32) -> vec3 { var pick_w = 0.0; var pick_ls: LS; for (var k = 0u; k < 3u; k++) { - let idx = nd + min(u32(rnd() * f32(nl)), nl - 1u); - let ls = local_light(light_at(idx), x); + let r4 = rng4(rng_sub(rd, 0x200u + k)); + let idx = nd + min(u32(r4.x * f32(nl)), nl - 1u); + let ls = local_light(light_at(idx), x, r4.yz); let wk = luminance(ls.c) * hg_phase(g, dot(ls.l, d)); if (wk <= 0.0) { continue; } wsum += wk; - if (rnd() * wsum < wk) { + if (r4.w * wsum < wk) { pick = idx; pick_w = wk; pick_ls = ls; } } if (pick_w > 0.0) { - let vis = light_vis(light_at(pick).ext.w >= 0.5, x, pick_ls.sl, pick_ls.dist * 0.999); + let vis = light_vis(light_at(pick).ext.w >= 0.5, x, pick_ls.sl, pick_ls.dist * 0.999, rng_sub(rd, 0x300u)); if (any(vis > vec3(0.0))) { sum += pick_ls.c * gtint(vis) * hg_phase(g, dot(pick_ls.l, d)) * (wsum * f32(nl) / (3.0 * pick_w)); } } } if (P.counts.y > 0u) { mis_x = x; mis_n = vec3(0.0); - let es = sample_emitter(x, vec3(0.0)); - if (es.pdf_w > 0.0 && !occluded_any(x, es.l, es.dist * 0.999)) { + let es = sample_emitter(x, vec3(0.0), rng_sub(rd, 0x400u)); + if (es.pdf_w > 0.0 && !occluded_any(x, es.l, es.dist * 0.999, rng_sub(rd, 0x401u))) { let ph = hg_phase(g, dot(es.l, d)); sum += es.le * ph * power_heuristic(es.pdf_w, ph) * es.w; } } if (P.counts2.y != 0u && (P.env.x > 0.5 || luminance(P.sky_color.rgb) > 0.0)) { - let l = sample_env(); + let l = sample_env(rng_sub(rd, 0x500u)); let pdf_e = env_pdf(l); - if (pdf_e > 0.0 && !occluded_sky(x, l)) { + if (pdf_e > 0.0 && !occluded_sky(x, l, rng_sub(rd, 0x501u))) { let ph = hg_phase(g, dot(l, d)); sum += env_radiance(l) * ph * power_heuristic(pdf_e, ph) / pdf_e * gtint(shadow_tint); } @@ -2469,18 +2479,19 @@ fn cache_point(p: vec3) -> vec4 { // Direct light at a point for an isotropic receiver (phase 1 / 4π): the directional lights (cone), the local lights // and the sampled emitters (each resampled from a few candidates, one shadow ray). The environment and emission that // no light sampling covers come from the sphere ray of `point_radiance`. -fn point_direct(x: vec3) -> vec3 { +fn point_direct(x: vec3, rd: u32) -> vec3 { let ph = 1.0 / (4.0 * PI); var sum = vec3(0.0); for (var i = 0u; i < P.counts2.x; i++) { let li = light_at(i); let cos_max = li.dir.w; - let cos_t = 1.0 - rnd() * (1.0 - cos_max); + let u = rng2(rng_sub(rd, i)); + let cos_t = 1.0 - u.x * (1.0 - cos_max); let sin_t = sqrt(max(0.0, 1.0 - cos_t * cos_t)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * u.y; let l = basis(-li.dir.xyz) * vec3(sin_t * cos(phi), sin_t * sin(phi), cos_t); if (all(li.color.rgb <= vec3(0.0))) { continue; } // black: no shadow ray (direct_light) - if (li.ext.w < 0.5 || !occluded(x, l, INF)) { sum += li.color.rgb * ph; } + if (li.ext.w < 0.5 || !occluded(x, l, INF, rng_sub(rd, 0x100u + i))) { sum += li.color.rgb * ph; } } let nd = P.counts2.x; let nl = P.counts.x - nd; @@ -2490,18 +2501,19 @@ fn point_direct(x: vec3) -> vec3 { var pick_w = 0.0; var pick_ls: LS; for (var k = 0u; k < 3u; k++) { - let idx = nd + min(u32(rnd() * f32(nl)), nl - 1u); - let ls = local_light(light_at(idx), x); + let r4 = rng4(rng_sub(rd, 0x200u + k)); + let idx = nd + min(u32(r4.x * f32(nl)), nl - 1u); + let ls = local_light(light_at(idx), x, r4.yz); let wk = luminance(ls.c); if (wk <= 0.0) { continue; } wsum += wk; - if (rnd() * wsum < wk) { + if (r4.w * wsum < wk) { pick = idx; pick_w = wk; pick_ls = ls; } } - if (pick_w > 0.0 && (light_at(pick).ext.w < 0.5 || !occluded(x, pick_ls.sl, pick_ls.dist * 0.999))) { + if (pick_w > 0.0 && (light_at(pick).ext.w < 0.5 || !occluded(x, pick_ls.sl, pick_ls.dist * 0.999, rng_sub(rd, 0x300u)))) { sum += pick_ls.c * ph * (wsum * f32(nl) / (3.0 * pick_w)); } } @@ -2512,18 +2524,18 @@ fn point_direct(x: vec3) -> vec3 { for (var c = 0u; c < 4u; c++) { mis_x = x; mis_n = vec3(0.0); - let es = sample_emitter(x, vec3(0.0)); + let es = sample_emitter(x, vec3(0.0), rng_sub(rd, 0x400u + c)); if (es.pdf_w <= 0.0) { continue; } let t = luminance(es.le); let w = t / es.pdf_w; if (w <= 0.0) { continue; } wsum += w; - if (rnd() * wsum < w) { + if (rng1(rng_sub(rd, 0x410u + c)) * wsum < w) { pick = es; pick_t = t; } } - if (pick_t > 0.0 && !occluded_any(x, pick.l, pick.dist * 0.999)) { + if (pick_t > 0.0 && !occluded_any(x, pick.l, pick.dist * 0.999, rng_sub(rd, 0x420u))) { sum += pick.le * ph * (wsum / (4.0 * pick_t)); } } @@ -2535,7 +2547,7 @@ fn point_direct(x: vec3) -> vec3 { // that surface's cached irradiance when warm, else its direct light). fn point_radiance(x: vec3) -> vec3 { cur_depth = 2u; - let e = point_direct(x); + let e = point_direct(x, rng_dim(D_VOL_LIGHT)); return e + point_sphere(x, false); } @@ -2544,7 +2556,7 @@ fn point_radiance(x: vec3) -> vec3 { // (volume/box-sun-scatter-live: 1000 lux at EV100 0 read 21 % low). fn point_radiance_train(x: vec3, clamp_v: f32) -> vec3 { cur_depth = 2u; - let e = point_direct(x); + let e = point_direct(x, rng_dim(D_VOL_LIGHT)); // Scattering orders past the first only when the scene allows bounces (max bounces 0: direct light only). var r = point_sphere(x, media_voxels && P.counts.z > 0u); let lum = luminance(r); @@ -2557,9 +2569,10 @@ fn point_radiance_train(x: vec3, clamp_v: f32) -> vec3 { // scattering order, so the voxels converge to multiple scattering (a lamp inside a sphere of albedo-0.9 medium read 22 % // of the offline reference with single scattering alone). fn point_sphere(x: vec3, media: bool) -> vec3 { - let z = 1.0 - 2.0 * rnd(); + let r2 = rng2(rng_dim(D_VOXEL)); + let z = 1.0 - 2.0 * r2.x; let r = sqrt(max(0.0, 1.0 - z * z)); - let phi = 2.0 * PI * rnd(); + let phi = 2.0 * PI * r2.y; var l = vec3(r * cos(phi), r * sin(phi), z); var o = x; var light = vec3(0.0); @@ -2567,7 +2580,7 @@ fn point_sphere(x: vec3, media: bool) -> vec3 { for (var k = 0u; k < 4u; k++) { let hit = trace(o, l, INF, TRACE_CLOSEST); if (media) { - let mm = media_march(o, l, select(hit.t, 1.0e30, hit.tri == 0xffffffffu), true); + let mm = media_march(o, l, select(hit.t, 1.0e30, hit.tri == 0xffffffffu), true, rng_sub(rng_dim(D_VOXEL), 0x20u + k)); light += tr * mm.light; tr *= mm.tr; } @@ -2578,7 +2591,7 @@ fn point_sphere(x: vec3, media: bool) -> vec3 { let g = glass_hit(hit, l, hit.t); if (g.share > 0.0) { let p = o + l * hit.t; - if (g.tir || rnd() >= g.share) { l = reflect(l, g.ng); } + if (g.tir || rng1(rng_sub(rng_dim(D_VOXEL), 0x10u + k)) >= g.share) { l = reflect(l, g.ng); } o = offset_origin(p, select(g.ng, -g.ng, dot(g.ng, l) < 0.0)); continue; } @@ -2794,15 +2807,15 @@ fn cache_entry(k: vec2, indirect: bool) -> vec4 { } // Fixed point: 2^21 units per firefly clamp, so 255 samples of up to 4 clamps per channel fit 32 bits. -fn cache_fixed(e: vec3) -> vec3 { - return vec3(min(e, vec3(4.0 * P.misc.x)) * (2097152.0 / P.misc.x) + vec3(rnd(), rnd(), rnd())); +fn cache_fixed(e: vec3, rd: u32) -> vec3 { + return vec3(min(e, vec3(4.0 * P.misc.x)) * (2097152.0 / P.misc.x) + rng3(rd)); } fn cache_add(i: u32, total: vec3, indirect: vec3) { if (i == 0xffffffffu || non_finite(total) || non_finite(indirect)) { return; } if (cache_acc_add(i * 8u + 6u, 1u) >= CACHE_FRAME_CAP) { return; } - let a = cache_fixed(total); - let b = cache_fixed(indirect); + let a = cache_fixed(total, rng_sub(rng_dim(D_CACHE), i * 2u)); + let b = cache_fixed(indirect, rng_sub(rng_dim(D_CACHE), i * 2u + 1u)); cache_acc_add(i * 8u, a.x); cache_acc_add(i * 8u + 1u, a.y); cache_acc_add(i * 8u + 2u, a.z); @@ -2844,6 +2857,7 @@ fn cache_train(s0: Surface, d0: vec3, cone0: vec2, depth: u32, insert: var gather: array, CACHE_DEPTH>; var refl: array, CACHE_DEPTH>; let wl = white_lobes(); + RNG_PATH = 1u; var s = s0; var d = d0; var cone = cone0; @@ -2878,7 +2892,7 @@ fn cache_train(s0: Surface, d0: vec3, cone0: vec2, depth: u32, insert: // Homogeneous media (water) on the segment, as on the paths: without them the voxels under water gathered // their surroundings unattenuated (a canal bed read 2.4x the offline image). if (LIVE && has(F_HMEDIA)) { - let mh = media_homogeneous(o, l, select(hit.t, 1.0e30, miss), true); + let mh = media_homogeneous(o, l, select(hit.t, 1.0e30, miss), true, rng_sub(rng_dim(D_MEDIA), 1u)); g += tr * mh.light; tr *= mh.tr; } @@ -2930,13 +2944,15 @@ fn cache_train(s0: Surface, d0: vec3, cone0: vec2, depth: u32, insert: fn media_train(@builtin(global_invocation_id) gid: vec3) { if (!has(F_MEDIA) || !cache_on() || P.time.w < 0.5 || gid.x >= MEDIA_TRAIN_POINTS) { return; } media_voxels = true; // the voxels' light is shadowed by the media - rng_state = pcg(gid.x ^ pcg(P.size.z * 6151u + P.size.w * 3079u + 0x2545f491u)); + rng_start(vec2(gid.x, 0u), P.size.z, P.size.w ^ 0x2545f491u, 2u); + cur_depth = 0u; + let r4 = rng4(rng_dim(D_CAMERA)); mirror_path = false; let w = P.size.x; let h = P.size.y; - let px = min(u32(rnd() * f32(w)), w - 1u); - let py = min(u32(rnd() * f32(h)), h - 1u); - let ndc = vec2((f32(px) + rnd()) / f32(w) * 2.0 - 1.0, 1.0 - (f32(py) + rnd()) / f32(h) * 2.0); + let px = min(u32(r4.x * f32(w)), w - 1u); + let py = min(u32(r4.y * f32(h)), h - 1u); + let ndc = vec2((f32(px) + r4.z) / f32(w) * 2.0 - 1.0, 1.0 - (f32(py) + r4.w) / f32(h) * 2.0); let d = normalize(P.cam_fwd.xyz + P.cam_right.xyz * (ndc.x * P.cam_pos.w * P.cam_fwd.w) + P.cam_up.xyz * (ndc.y * P.cam_pos.w)); let o = P.cam_pos.xyz; let fd = accum[py * w + px].color.w; @@ -2956,7 +2972,7 @@ fn media_train(@builtin(global_invocation_id) gid: vec3) { total += max(min(sp.y, select(tmax, tmax_h, medium_at(k).a.z != 0u)) - max(sp.x, 0.0), 0.0); } if (total <= 0.0) { return; } - var u = rnd() * total; + var u = rng1(rng_dim(D_MEDIA)) * total; var x = o; for (var k = 0u; k < n; k++) { let sp = medium_span(medium_at(k), o, d); @@ -2989,13 +3005,14 @@ fn media_composite(@builtin(global_invocation_id) gid: vec3) { let h = P.size.y; if (!has(F_MEDIA) || P.time.w < 0.5 || gid.x >= w || gid.y >= h) { return; } let pix = gid.y * w + gid.x; - rng_state = pcg(pix ^ pcg(P.size.z * 9781u + P.size.w * 6271u + 0x51ed27u)); + rng_start(gid.xy, P.size.z, P.size.w ^ 0x51ed27u, 3u); + cur_depth = 0u; mirror_path = false; var px = accum[pix]; let ndc = vec2((f32(gid.x) + P.raster.y) / f32(w) * 2.0 - 1.0, 1.0 - (f32(gid.y) + P.raster.z) / f32(h) * 2.0); let d = normalize(P.cam_fwd.xyz + P.cam_right.xyz * (ndc.x * P.cam_pos.w * P.cam_fwd.w) + P.cam_up.xyz * (ndc.y * P.cam_pos.w)); let ft = px.color.w; - let mm = media_march(P.cam_pos.xyz, d, select(1.0e30, ft, ft > 0.0), true); + let mm = media_march(P.cam_pos.xyz, d, select(1.0e30, ft, ft > 0.0), true, rng_dim(D_MEDIA)); if (mm.tr >= 1.0 && all(mm.light == vec3(0.0))) { return; } let samples = P.misc.w + 1.0; // this dispatch runs with the frame's last sample's parameters let lw = vec3(0.2126, 0.7152, 0.0722); @@ -3048,14 +3065,16 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // Every pixel's deterministic direct light is written each frame (0 unless the camera hits a surface): a pixel that // turned to sky must not keep what a surface left there. if (LIVE && P.misc.w < 0.5 && direct_len() >= w * h) { direct_store(pix, vec4(0.0)); } - rng_state = pcg(pix ^ pcg(P.size.z * 9781u + P.size.w * 6271u)); + rng_start(gid.xy, P.size.z, P.size.w, 0u); + cur_depth = 0u; + let cam = rng4(rng_dim(D_CAMERA)); // 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/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(); + var jx = cam.x; + var jy = cam.y; if (LIVE) { jx = P.raster.y; jy = P.raster.z; @@ -3065,8 +3084,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { var o = P.cam_pos.xyz; if (P.cam_right.w > 0.0) { let focus = o + d * (P.cam_up.w / dot(d, P.cam_fwd.xyz)); - let r = sqrt(rnd()) * P.cam_right.w; - let phi = 2.0 * PI * rnd(); + let r = sqrt(cam.z) * P.cam_right.w; + let phi = 2.0 * PI * cam.w; o += P.cam_right.xyz * (r * cos(phi)) + P.cam_up.xyz * (r * sin(phi)); d = normalize(focus - o); } @@ -3155,7 +3174,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // a quarter of the pixels at 4x weight; the low-frequency fog converges in the accumulation. if (has(F_VOLUME) && depth == 0u && P.vol.x > 0.0 && (((gid.x >> 3u) + 2u * (gid.y >> 2u) + pcg(P.size.z)) & VOL_RATE_MASK) == 0u) { // Firefly clamp: a fog point next to a bright sign is rare and huge; haze is a few nits. - var fs = f32(VOL_RATE_MASK + 1u) * fog_scatter(o, d, t0, max(tv, t0), trv); + var fs = f32(VOL_RATE_MASK + 1u) * fog_scatter(o, d, t0, max(tv, t0), trv, rng_dim(D_FOG)); let fl = luminance(fs); let fmax = 0.02 * clamp_v; if (fl > fmax) { fs *= fmax / fl; } @@ -3176,13 +3195,13 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // Live media are composited after the trace (media_composite); past the camera ray, homogeneous ones (water) // attenuate and light the path here. Offline paths track every medium. if (LIVE && has(F_HMEDIA) && depth > 0u) { - let mh = media_homogeneous(o, d, select(hit.t, 1.0e30, hit.tri == 0xffffffffu), true); + let mh = media_homogeneous(o, d, select(hit.t, 1.0e30, hit.tri == 0xffffffffu), true, rng_sub(rng_dim(D_MEDIA), 1u)); radiance += thr * mh.light; thr *= mh.tr; } if (!LIVE && has(F_MEDIA) && P.time.w > 0.5) { // Media on this segment: emission on the way; a real collision scatters the path there. - let tk = media_track(o, d, select(hit.t, 1.0e30, hit.tri == 0xffffffffu)); + let tk = media_track(o, d, select(hit.t, 1.0e30, hit.tri == 0xffffffffu), rng_dim(D_MEDIA)); radiance += thr * tk.emission; if (tk.t >= 0.0) { let x = o + d * tk.t; @@ -3192,7 +3211,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { albedo = tk.albedo; normal = -d; } - var c = thr * phase_direct(x, d, tk.g); + var c = thr * phase_direct(x, d, tk.g, rng_dim(D_VOL_LIGHT)); if (depth > 0u && !straight) { let lum = luminance(c); if (lum > clamp_v) { c *= clamp_v / lum; } @@ -3210,7 +3229,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { cone_a += 2.0; if (depth >= P.counts.w) { let q = min(max(thr.x, max(thr.y, thr.z)), 0.95); - if (rnd() > q) { break; } + if (rng1(rng_dim(D_RR)) > q) { break; } thr /= q; } o = x; @@ -3309,6 +3328,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { if (train || e.w < CACHE_WARM) { // Training tiles trace deep (coherent: whole SIMD groups); scattered cold pixels one vertex. let fresh = cache_train(s, d, vec2(width, cone_a), select(1u, CACHE_DEPTH, train), train); + RNG_PATH = 0u; if (e.w < CACHE_WARM) { e = vec4(select(fresh.total, fresh.total - fresh.direct, hit_light), 1.0); } } cur_depth = depth; @@ -3390,7 +3410,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { mirror_path = (depth == 0u || mirror_path) && s.roughness < 0.3 && (sampled_spec || through); if (depth >= P.counts.w) { let q = min(max(thr.x, max(thr.y, thr.z)), 0.95); - if (rnd() > q) { break; } + if (rng1(rng_dim(D_RR)) > q) { break; } thr /= q; } o = offset_origin(s.p, select(s.ng, -s.ng, dot(l, s.ng) < 0.0)); diff --git a/renderer/shaders/sample.wgsl b/renderer/shaders/sample.wgsl new file mode 100644 index 0000000..8d0446c --- /dev/null +++ b/renderer/shaders/sample.wgsl @@ -0,0 +1,176 @@ +// The kernel's random numbers: shuffled, Owen-scrambled Sobol (Burley 2020; Cycles' Sobol-Burley). Four dimensions, +// more by padding: each decision has a dimension id that seeds the scramble. renderer/src/sample.rs: the same on the CPU. + +// Sobol dimensions 1-3 (Joe & Kuo, bit-reversed) of a 16-bit bit-reversed index, unrolled. Dimension 0: reverseBits. +fn sobol_d1(x: u32) -> u32 { + var r = 0u; + r ^= select(0u, 0x00000001u, (x & 0x80000000u) != 0u); + r ^= select(0u, 0x00000003u, (x & 0x40000000u) != 0u); + r ^= select(0u, 0x00000005u, (x & 0x20000000u) != 0u); + r ^= select(0u, 0x0000000fu, (x & 0x10000000u) != 0u); + r ^= select(0u, 0x00000011u, (x & 0x08000000u) != 0u); + r ^= select(0u, 0x00000033u, (x & 0x04000000u) != 0u); + r ^= select(0u, 0x00000055u, (x & 0x02000000u) != 0u); + r ^= select(0u, 0x000000ffu, (x & 0x01000000u) != 0u); + r ^= select(0u, 0x00000101u, (x & 0x00800000u) != 0u); + r ^= select(0u, 0x00000303u, (x & 0x00400000u) != 0u); + r ^= select(0u, 0x00000505u, (x & 0x00200000u) != 0u); + r ^= select(0u, 0x00000f0fu, (x & 0x00100000u) != 0u); + r ^= select(0u, 0x00001111u, (x & 0x00080000u) != 0u); + r ^= select(0u, 0x00003333u, (x & 0x00040000u) != 0u); + r ^= select(0u, 0x00005555u, (x & 0x00020000u) != 0u); + r ^= select(0u, 0x0000ffffu, (x & 0x00010000u) != 0u); + return r; +} +fn sobol_d2(x: u32) -> u32 { + var r = 0u; + r ^= select(0u, 0x00000001u, (x & 0x80000000u) != 0u); + r ^= select(0u, 0x00000003u, (x & 0x40000000u) != 0u); + r ^= select(0u, 0x00000006u, (x & 0x20000000u) != 0u); + r ^= select(0u, 0x00000009u, (x & 0x10000000u) != 0u); + r ^= select(0u, 0x00000017u, (x & 0x08000000u) != 0u); + r ^= select(0u, 0x0000003au, (x & 0x04000000u) != 0u); + r ^= select(0u, 0x00000071u, (x & 0x02000000u) != 0u); + r ^= select(0u, 0x000000a3u, (x & 0x01000000u) != 0u); + r ^= select(0u, 0x00000116u, (x & 0x00800000u) != 0u); + r ^= select(0u, 0x00000339u, (x & 0x00400000u) != 0u); + r ^= select(0u, 0x00000677u, (x & 0x00200000u) != 0u); + r ^= select(0u, 0x000009aau, (x & 0x00100000u) != 0u); + r ^= select(0u, 0x00001601u, (x & 0x00080000u) != 0u); + r ^= select(0u, 0x00003903u, (x & 0x00040000u) != 0u); + r ^= select(0u, 0x00007706u, (x & 0x00020000u) != 0u); + r ^= select(0u, 0x0000aa09u, (x & 0x00010000u) != 0u); + return r; +} +fn sobol_d3(x: u32) -> u32 { + var r = 0u; + r ^= select(0u, 0x00000001u, (x & 0x80000000u) != 0u); + r ^= select(0u, 0x00000003u, (x & 0x40000000u) != 0u); + r ^= select(0u, 0x00000004u, (x & 0x20000000u) != 0u); + r ^= select(0u, 0x0000000au, (x & 0x10000000u) != 0u); + r ^= select(0u, 0x0000001fu, (x & 0x08000000u) != 0u); + r ^= select(0u, 0x0000002eu, (x & 0x04000000u) != 0u); + r ^= select(0u, 0x00000045u, (x & 0x02000000u) != 0u); + r ^= select(0u, 0x000000c9u, (x & 0x01000000u) != 0u); + r ^= select(0u, 0x0000011bu, (x & 0x00800000u) != 0u); + r ^= select(0u, 0x000002a4u, (x & 0x00400000u) != 0u); + r ^= select(0u, 0x0000079au, (x & 0x00200000u) != 0u); + r ^= select(0u, 0x00000b67u, (x & 0x00100000u) != 0u); + r ^= select(0u, 0x0000101eu, (x & 0x00080000u) != 0u); + r ^= select(0u, 0x0000302du, (x & 0x00040000u) != 0u); + r ^= select(0u, 0x00004041u, (x & 0x00020000u) != 0u); + r ^= select(0u, 0x0000a0c3u, (x & 0x00010000u) != 0u); + return r; +} + +// Owen scrambling of a bit-reversed integer (Vegdahl 2021's hash, a better Laine-Karras permutation). +fn owen_rev(n_in: u32, seed: u32) -> u32 { + var n = n_in; + n ^= n * 0x3d20adeau; + n += seed; + n *= (seed >> 16u) | 1u; + n ^= n * 0x05526c56u; + n ^= n * 0x53a22864u; + return n; +} + +fn hash_hp(x: u32) -> u32 { + var i = x; + i ^= i >> 16u; + i *= 0x21f0aaadu; + i ^= i >> 15u; + i *= 0xd35a2d97u; + i ^= i >> 15u; + return i ^ 0xe6fe3bebu; +} + +// One dimension (0-3) of the Sobol point at a bit-reversed index, Owen-scrambled. +fn sobol_owen(rev_index: u32, dim: u32, seed: u32) -> f32 { + var r = 0u; + if (dim == 0u) { + r = reverseBits(rev_index); + } else if (dim == 1u) { + r = sobol_d1(rev_index); + } else if (dim == 2u) { + r = sobol_d2(rev_index); + } else { + r = sobol_d3(rev_index); + } + r = reverseBits(owen_rev(r, seed)); + return f32(r) * (1.0 / 4294967808.0); +} + +// A path's sample: its index in the pixel's sequence, the pixel's seed, which path of the invocation it is. +var RNG_INDEX: u32; +var RNG_SEED: u32; +var RNG_PATH: u32; + +// The id of decision `slot` at the path's current vertex (`cur_depth`); `rng_sub` the k-th part of decision `d` (a +// loop's iteration, a light). +fn rng_dim(slot: u32) -> u32 { return (RNG_PATH << 28u) ^ (cur_depth << 20u) ^ slot; } +fn rng_sub(d: u32, k: u32) -> u32 { return hash_hp(d ^ hash_hp(k + 0x9e3779b9u)) | 0x80000000u; } + +// A path vertex's decisions (`rng_dim`). +const D_CAMERA: u32 = 0u; // pixel jitter, lens (4D) +const D_BSDF: u32 = 1u; // lobe, specular or not, direction (4D) +const D_RR: u32 = 2u; // Russian roulette +const D_PICK: u32 = 3u; // light_pick's kind of light +const D_SUN: u32 = 4u; // a directional light's cone (2D a light) +const D_SUN_VIS: u32 = 5u; // its shadow ray +const D_LOCAL: u32 = 6u; // local lights (`local_lights`) +const D_EMIT: u32 = 7u; // emitters (`sample_emitter_ris`, `sample_emitter`) +const D_EMIT_VIS: u32 = 8u; +const D_ENV: u32 = 9u; // the environment map (4D) +const D_ENV_VIS: u32 = 10u; +const D_FOG: u32 = 11u; // height fog's in-scattering (`fog_scatter`) +const D_MEDIA: u32 = 12u; // bounded media (`media_track`, `media_march`) +const D_PHASE: u32 = 13u; // a medium's scattered direction (`sample_hg`) +const D_VOL_LIGHT: u32 = 14u; // lights at a point in a medium (`phase_direct`, `point_direct`) +const D_VOXEL: u32 = 15u; // the radiance cache's volume points (`point_sphere`) +const D_CACHE: u32 = 16u; // the cache's fixed-point dither + +// Live: Sobol only for the camera path's first vertex (not a loop's parts: `rng_sub` sets the top bit), a hash +// elsewhere (as NVIDIA's RTXPT; all-Sobol cost 2 ms of Bistro's trace). Offline: Sobol everywhere (as Cycles). +fn rng_white(d: u32) -> bool { return LIVE && (cur_depth > 0u || RNG_PATH != 0u || d >= 0x80000000u); } +fn white(d: u32, c: u32) -> f32 { return f32(hash_hp(rng_seed(d) ^ (RNG_INDEX * 0x9e3779b9u + c * 0x85ebca6bu)) >> 8u) * (1.0 / 16777216.0); } + +// A hash, always: for loops of no fixed length (delta tracking's steps), where a new Sobol dimension a step is QMC's +// worst case (as pbrt-v4, which draws its majorant tracking from a hashed RNG). +fn rng_hash(d: u32, c: u32) -> f32 { return white(d, c); } +fn rng_hash3(d: u32) -> vec3 { return vec3(white(d, 0u), white(d, 1u), white(d, 2u)); } + +// Dimension id `d`'s scramble seed; the shuffled index, 16 bits (65,536 samples, then it repeats; Cycles' index mask). +fn rng_seed(d: u32) -> u32 { return RNG_SEED ^ hash_hp(d); } +fn rng_index(salt: u32) -> u32 { return owen_rev(reverseBits(RNG_INDEX), salt) & 0xffff0000u; } + +fn rng1(d: u32) -> f32 { + if (rng_white(d)) { return white(d, 0u); } + let s = rng_seed(d); + return sobol_owen(rng_index(s ^ 0xbff95bfeu), 0u, s ^ 0x635c77bdu); +} +fn rng2(d: u32) -> vec2 { + if (rng_white(d)) { return vec2(white(d, 0u), white(d, 1u)); } + let s = rng_seed(d); + let i = rng_index(s ^ 0xf8ade99au); + return vec2(sobol_owen(i, 0u, s ^ 0xe0aaaf76u), sobol_owen(i, 1u, s ^ 0x94964d4eu)); +} +fn rng3(d: u32) -> vec3 { + if (rng_white(d)) { return vec3(white(d, 0u), white(d, 1u), white(d, 2u)); } + let s = rng_seed(d); + let i = rng_index(s ^ 0xcaa726acu); + return vec3(sobol_owen(i, 0u, s ^ 0x9e78e391u), sobol_owen(i, 1u, s ^ 0x67c33241u), sobol_owen(i, 2u, s ^ 0x78c395c5u)); +} +fn rng4(d: u32) -> vec4 { + if (rng_white(d)) { return vec4(white(d, 0u), white(d, 1u), white(d, 2u), white(d, 3u)); } + let s = rng_seed(d); + let i = rng_index(s ^ 0xc2c1a055u); + return vec4(sobol_owen(i, 0u, s ^ 0x39468210u), sobol_owen(i, 1u, s ^ 0xe9d8a845u), sobol_owen(i, 2u, s ^ 0x5f32b482u), sobol_owen(i, 3u, s ^ 0x1524cc56u)); +} + +// Starts sample `index` of a pixel (the sample offline, the frame live) on path `path`; `seed`: the render's +// (THREERT_SEED offline) and the stream's. +fn rng_start(pix: vec2, index: u32, seed: u32, path: u32) { + RNG_SEED = hash_hp(pix.x ^ hash_hp(pix.y ^ hash_hp(seed))); + RNG_INDEX = index; + RNG_PATH = path; +} diff --git a/renderer/shaders/translucent.wgsl b/renderer/shaders/translucent.wgsl index fc9e522..3c47d32 100644 --- a/renderer/shaders/translucent.wgsl +++ b/renderer/shaders/translucent.wgsl @@ -84,15 +84,16 @@ fn translucent(@builtin(global_invocation_id) gid: vec3) { let h = select(TPS.size.y, TPS.view.y, low); if (gid.x >= w || gid.y >= h) { return; } let pix = gid.y * w + gid.x; - rng_state = pcg(pix ^ pcg(P.size.z * 9781u + P.size.w * 6271u + 0x5bd1e995u)); + rng_start(gid.xy, P.size.z, P.size.w ^ 0x5bd1e995u, 4u); cur_depth = 0u; + let cam = rng2(rng_dim(D_CAMERA)); mirror_path = false; // Live: the pixel centre (stable streaks; the image has passed the temporal filter). Offline: a random point in it. var jx = 0.5; var jy = 0.5; if (!live) { - jx = rnd(); - jy = rnd(); + jx = cam.x; + jy = cam.y; } let ndc = vec2((f32(gid.x) + jx) / f32(w) * 2.0 - 1.0, 1.0 - (f32(gid.y) + jy) / f32(h) * 2.0); let d = normalize(P.cam_fwd.xyz + P.cam_right.xyz * (ndc.x * P.cam_pos.w * P.cam_fwd.w) + P.cam_up.xyz * (ndc.y * P.cam_pos.w)); @@ -140,7 +141,7 @@ fn translucent(@builtin(global_invocation_id) gid: vec3) { // Front to back. var col = vec3(0.0); var tr = 1.0; - var train = live && rnd() < TPS.misc.x; + var train = live && rng1(rng_dim(D_CACHE)) < TPS.misc.x; let foot = 2.0 * P.cam_pos.w / f32(h); for (var k = 0u; k < n; k++) { let ti = TPS.size.z + lp[k]; @@ -203,7 +204,7 @@ fn translucent_up(@builtin(global_invocation_id) gid: vec3) { fn volume_train(@builtin(global_invocation_id) gid: vec3) { let k = gid.x; if (k >= min(atomicLoad(&vcount[0]), TPS.view.z)) { return; } - rng_state = pcg(k ^ pcg(P.size.z * 7919u + P.size.w * 104729u + 0x68e31da4u)); + rng_start(vec2(k, 0u), P.size.z, P.size.w ^ 0x68e31da4u, 5u); mirror_path = false; let x = vpts[k].xyz; let e = point_radiance_train(x, P.misc.x); diff --git a/renderer/src/gpu.rs b/renderer/src/gpu.rs index 8230466..a291c91 100644 --- a/renderer/src/gpu.rs +++ b/renderer/src/gpu.rs @@ -698,8 +698,9 @@ pub fn instance_mask(cast_shadow: bool, hidden: bool) -> u8 { } /// The kernel's files, one module in this order (pathtrace.wgsl's head says what each holds and names the `@if` flags). -const KERNEL_FILES: [&str; 4] = [ +const KERNEL_FILES: [&str; 5] = [ include_str!("../shaders/pathtrace.wgsl"), + include_str!("../shaders/sample.wgsl"), include_str!("../shaders/trace_units.wgsl"), include_str!("../shaders/trace_hw.wgsl"), include_str!("../shaders/translucent.wgsl"), @@ -821,6 +822,34 @@ impl wesl::resolver::Resolver for KernelResolver<'_> { /// scene's functions' too, begins with `if (loop_guard()) { break; }` (pathtrace.wgsl declares `loop_guard`, under /// `@if(guard)`). A pass over the variant's syntax tree, as a compiler instruments code: the tree knows what a loop /// and its body are, so nothing the text says (comments, strings, a loop without braces) can mislead it. +/// A `for` loop that ends by its own count: `for (var i = N; i < M; i++)` (or `<=`), N and M integer literals, whose +/// body does not assign `i`. The guard leaves it out, as Tint's PreventInfiniteLoops leaves loops it can prove finite. +fn counted(f: &wesl::syntax::ForStatement) -> bool { + let (Some(init), Some(cond), Some(update)) = (&f.initializer, &f.condition, &f.update) else { return false }; + let lit = |t: &str| { + let t = t.trim().trim_end_matches(['u', 'i']); + !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) + }; + let init = init.to_string(); + let Some((name, start)) = init.trim().trim_end_matches(';').strip_prefix("var ").and_then(|r| r.split_once('=')) else { return false }; + let name = name.split(':').next().unwrap_or("").trim(); + let cond = cond.to_string(); + let bound = cond.trim().trim_start_matches('(').trim_end_matches(')').strip_prefix(name).map(str::trim_start).and_then(|r| r.strip_prefix("<=").or_else(|| r.strip_prefix('<'))); + if name.is_empty() || !lit(start) || !bound.is_some_and(lit) || update.to_string().trim().trim_end_matches(';') != format!("{name}++") { + return false; + } + // Not assigned in the body: `name` as an identifier followed by `=` (not `==`), `op=`, `++` or `--`. + let b = f.body.to_string(); + let bytes = b.as_bytes(); + let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_'; + !b.match_indices(name).any(|(k, _)| { + let before = k.checked_sub(1).map(|p| bytes[p]); + let after = b[k + name.len()..].trim_start(); + before.is_none_or(|c| !ident(c) && c != b'.') && !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') + && (after.starts_with("++") || after.starts_with("--") || (after.starts_with('=') && !after.starts_with("==")) || ["+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>="].iter().any(|op| after.starts_with(op))) + }) +} + pub fn guard_loops(tu: &mut wesl::syntax::TranslationUnit) { use wesl::syntax::{CompoundStatement, GlobalDeclaration, Statement, StatementNode}; fn body(b: &mut CompoundStatement, guard: &StatementNode) { @@ -851,6 +880,7 @@ pub fn guard_loops(tu: &mut wesl::syntax::TranslationUnit) { body(&mut c.body, guard); } } + Statement::For(f) if counted(f) => body(&mut f.body, guard), Statement::For(f) => opened(&mut f.body, guard), Statement::While(w) => opened(&mut w.body, guard), _ => {} diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index 367c70d..d31dbdc 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -23,6 +23,7 @@ pub mod scene; #[cfg(not(target_arch = "wasm32"))] pub mod script; pub mod skin; +pub mod sample; pub mod sky; pub mod tables; pub mod units; diff --git a/renderer/src/merged.rs b/renderer/src/merged.rs index e3efbb8..3f3723c 100644 --- a/renderer/src/merged.rs +++ b/renderer/src/merged.rs @@ -255,29 +255,43 @@ mod tests { assert_eq!(table_cap(2, 512), 1024); } - /// The loop guard opens every loop of the kernel, the scene's functions' too, in the layout as written and in the - /// merged one (its count a buffer of its own, or in `MergedLive`), and what comes out is valid WGSL. + /// The loop guard opens every loop of the kernel but counted ones (`for (var i = N; i < M; i++)`, literals), the + /// scene's functions' too, in the layout as written and in the merged one (its count a buffer of its own, or in + /// `MergedLive`), and what comes out is valid WGSL. #[test] fn the_guard_bounds_every_loop() { + let counted = |s: &str| { + s.lines() + .filter_map(|l| l.trim().strip_prefix("for (var ")) + .filter(|l| { + let parts: Vec<&str> = l.split(';').map(str::trim).collect(); + let name = parts[0].split([' ', ':']).next().unwrap_or(""); + let lit = |t: &str| t.trim_end_matches(['u', 'i']).bytes().all(|c| c.is_ascii_digit()); + parts.len() == 3 && parts[0].rsplit('=').next().is_some_and(|v| lit(v.trim())) && parts[1].strip_prefix(name).is_some_and(|r| lit(r.trim().trim_start_matches(['<', '=']).trim())) && parts[2].starts_with(&format!("{name}++")) + }) + .count() + }; let loops = |s: &str| s.matches("loop {").count() + s.matches("for (").count() + s.matches("while (").count(); for merged in [None, Some(caps())] { let v = KernelVariant { merged, ..KernelVariant::new(false, Default::default(), crate::gpu::F_SOURCE) }; let plain = kernel_wgsl(&kernel_template(), &v); let g = kernel_wgsl(&kernel_template(), &KernelVariant { guard: Some(1 << 16), ..v }); - assert!(loops(&plain) > 40, "the kernel's loops"); - assert_eq!(g.matches("if (loop_guard()) {").count(), loops(&plain)); + assert!(loops(&plain) > 40 && counted(&plain) > 3, "the kernel's loops: {} counted of {}", counted(&plain), loops(&plain)); + assert_eq!(g.matches("if (loop_guard()) {").count(), loops(&plain) - counted(&plain)); 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_eq!(declared as u32, if merged.is_some() { STORAGE_BUFFERS } else { 20 }, "{used:?}"); } - // The pass on its own: nested loops, a `continuing` block, comments and braces in them, a loop in an `if`. - let mut tu = crate::gpu::wesl_wgsl("fn f() {\n for (var i = 0u; i < 2u; i++) { // {\n loop { if (a) { break; } continuing { while (b) { } } }\n }\n if (c) { while (d) { } } else { x(); }\n}\n", &[], &[]); + // The pass on its own: nested loops, a `continuing` block, comments and braces in them, a loop in an `if`; a + // counted loop left alone, one bounded by a variable or assigning its counter guarded. + let mut tu = crate::gpu::wesl_wgsl("fn f() {\n for (var i = 0u; i < n; i++) { // {\n loop { if (a) { break; } continuing { while (b) { } } }\n }\n if (c) { while (d) { } } else { x(); }\n for (var k = 0u; k < 16u; k++) { if (e) { break; } y(); }\n for (var j = 0u; j <= 3u; j++) { j += 2u; }\n}\n", &[], &[]); crate::gpu::guard_loops(&mut tu); let g = tu.to_string(); - assert_eq!(g.matches("if (loop_guard()) {").count(), 4, "{g}"); + assert_eq!(g.matches("if (loop_guard()) {").count(), 5, "{g}"); let first = |w: &str| g[g.find(w).unwrap()..].split('\n').nth(1).unwrap().trim().to_string(); - assert_eq!([first("for ("), first("loop {"), first("while (b)"), first("while (d)")], ["if (loop_guard()) {"; 4], "{g}"); + assert_eq!([first("for (var i"), first("loop {"), first("while (b)"), first("while (d)"), first("for (var j")], ["if (loop_guard()) {"; 5], "{g}"); + assert_eq!(first("for (var k"), "if (e) {", "{g}"); } /// The constants a variant imports are the ones it is given, by name: a merged kernel's lengths, a guard's limit. diff --git a/renderer/src/sample.rs b/renderer/src/sample.rs new file mode 100644 index 0000000..7d6ed1d --- /dev/null +++ b/renderer/src/sample.rs @@ -0,0 +1,180 @@ +//! The kernel's sampler on the CPU (shaders/sample.wgsl, the same numbers): shuffled, Owen-scrambled Sobol (Burley +//! 2020). For tests: the sequence's properties here, and the shader held to this. + +/// Direction numbers of Sobol dimensions 1-3 (Joe & Kuo), bit-reversed: `SOBOL_REV` in sample.wgsl. +const SOBOL_REV: [[u32; 32]; 3] = { + const fn dirs(s: usize, a: u32, m0: [u32; 3]) -> [u32; 32] { + let mut m = [0u32; 32]; + let mut i = 0; + while i < 32 { + m[i] = if i < s { + m0[i] + } else { + let mut v = m[i - s] ^ (m[i - s] << s); + let mut k = 1; + while k < s { + v ^= ((a >> (s - 1 - k)) & 1) * (m[i - k] << k); + k += 1; + } + v + }; + i += 1; + } + let mut out = [0u32; 32]; + let mut i = 0; + while i < 32 { + out[i] = (m[i] << (31 - i)).reverse_bits(); + i += 1; + } + out + } + [dirs(1, 0, [1, 0, 0]), dirs(2, 1, [1, 3, 0]), dirs(3, 1, [1, 3, 1])] +}; + +pub fn owen_rev(mut n: u32, seed: u32) -> u32 { + n ^= n.wrapping_mul(0x3d20adea); + n = n.wrapping_add(seed); + n = n.wrapping_mul((seed >> 16) | 1); + n ^= n.wrapping_mul(0x05526c56); + n ^= n.wrapping_mul(0x53a22864); + n +} + +pub fn hash_hp(mut i: u32) -> u32 { + i ^= i >> 16; + i = i.wrapping_mul(0x21f0aaad); + i ^= i >> 15; + i = i.wrapping_mul(0xd35a2d97); + i ^= i >> 15; + i ^ 0xe6fe3beb +} + +pub fn sobol_owen(rev_index: u32, dim: usize, seed: u32) -> f32 { + let mut r = 0; + if dim == 0 { + r = rev_index.reverse_bits(); + } else { + let (mut x, mut i) = (rev_index, 0usize); + for _ in 0..16 { + if x == 0 { + break; + } + let j = x.leading_zeros() as usize; + r ^= SOBOL_REV[dim - 1][i + j]; + i += j + 1; + x = (x << j) << 1; + } + } + owen_rev(r, seed).reverse_bits() as f32 * (1.0 / 4294967808.0) +} + +/// `rng2` of sample.wgsl for a pixel seed (`RNG_SEED`), a sample index and a dimension id. +pub fn rng2(pixel_seed: u32, index: u32, d: u32) -> [f32; 2] { + let s = pixel_seed ^ hash_hp(d); + let i = owen_rev(index.reverse_bits(), s ^ 0xf8ade99a) & 0xffff0000; + [sobol_owen(i, 0, s ^ 0xe0aaaf76), sobol_owen(i, 1, s ^ 0x94964d4e)] +} + +/// `rng4` of sample.wgsl. +pub fn rng4(pixel_seed: u32, index: u32, d: u32) -> [f32; 4] { + let s = pixel_seed ^ hash_hp(d); + let i = owen_rev(index.reverse_bits(), s ^ 0xc2c1a055) & 0xffff0000; + [sobol_owen(i, 0, s ^ 0x39468210), sobol_owen(i, 1, s ^ 0xe9d8a845), sobol_owen(i, 2, s ^ 0x5f32b482), sobol_owen(i, 3, s ^ 0x1524cc56)] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The table is sample.wgsl's, and Cycles' (`sobol_burley_table`, its last entries). + #[test] + fn the_directions_are_the_shaders() { + let wgsl = include_str!("../shaders/sample.wgsl"); + for row in SOBOL_REV { + for v in &row[..16] { + assert!(wgsl.contains(&format!("0x{v:08x}u")), "{v:#010x}"); + } + } + assert_eq!([SOBOL_REV[0][31], SOBOL_REV[1][31], SOBOL_REV[2][31]], [0xffffffff, 0xaaaa00a3, 0xc900a00a]); + } + + /// Each 2D pair of a 4D point set of 2^m samples is a (0, m, 2)-net: every elementary interval of area 2^-m holds + /// exactly one point (dimensions 0-1 of Sobol are; the shuffle and Owen scrambling keep it). And 1D strata. + #[test] + fn the_first_2_to_the_m_points_are_a_net() { + for (seed, d) in [(0u32, 0u32), (0x1234_5678, 7), (hash_hp(99), 0xdead)] { + for m in [4u32, 6, 8] { + let n = 1u32 << m; + let pts: Vec<[f32; 4]> = (0..n).map(|i| rng4(seed, i, d)).collect(); + let cell = |v: f32, k: u32| ((v * (1u64 << k) as f32) as u32).min((1 << k) - 1); + for a in 0..=m { + let mut seen = vec![false; n as usize]; + for p in &pts { + let c = (cell(p[0], a) << (m - a)) | cell(p[1], m - a); + assert!(!std::mem::replace(&mut seen[c as usize], true), "seed {seed:#x} d {d} m {m}: interval {a}x{} twice", m - a); + } + } + for k in 0..4 { + let mut strata: Vec = pts.iter().map(|p| cell(p[k], m)).collect(); + strata.sort(); + assert_eq!(strata, (0..n).collect::>(), "dimension {k}"); + } + assert!(pts.iter().flatten().all(|v| (0.0..1.0).contains(v))); + } + } + } + + /// sample.wgsl on the GPU gives these numbers, offline (`LIVE` false; to the last place: a u32's conversion to f32 + /// may round either way). (Skipped without a GPU.) + #[test] + fn the_shader_is_this() { + use wgpu::util::DeviceExt; + let g = match crate::gpu::Gpu::new_with(crate::gpu::GpuOptions::default()) { + Ok(g) => g, + Err(e) => return eprintln!("skipped: {e}"), + }; + let d = &g.device; + let seeds = [0u32, 0x1234_5678, hash_hp(99)]; + let src = format!("{}\noverride LIVE: bool = false;\nvar cur_depth: u32;\n@group(0) @binding(0) var out: array;\nconst SEEDS = array({}u, {}u, {}u);\n@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) {{\n let i = id.x;\n RNG_SEED = SEEDS[i % 3u];\n RNG_INDEX = i;\n let r = rng4(7u + i % 5u);\n for (var k = 0u; k < 4u; k++) {{ out[i * 4u + k] = bitcast(r[k]); }}\n}}\n", include_str!("../shaders/sample.wgsl"), seeds[0], seeds[1], seeds[2]); + let module = d.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) }); + let pipeline = d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: None, layout: None, module: &module, entry_point: Some("main"), compilation_options: Default::default(), cache: None }); + let n = 256u32; + let buf = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &vec![0u8; n as usize * 16], usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC }); + let read = d.create_buffer(&wgpu::BufferDescriptor { label: None, size: n as u64 * 16, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let bg = d.create_bind_group(&wgpu::BindGroupDescriptor { label: None, layout: &pipeline.get_bind_group_layout(0), entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }] }); + let mut enc = d.create_command_encoder(&Default::default()); + { + let mut pass = enc.begin_compute_pass(&Default::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(n / 64, 1, 1); + } + enc.copy_buffer_to_buffer(&buf, 0, &read, 0, n as u64 * 16); + g.queue.submit([enc.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + d.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let words: Vec = bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec(); + for i in 0..n { + let want = rng4(seeds[(i % 3) as usize], i, 7 + i % 5); + for k in 0..4 { + let got = f32::from_bits(words[(i * 4 + k) as usize]); + assert!((got - want[k as usize]).abs() <= 1.2e-7, "index {i} component {k}: {got} on the GPU, {} here", want[k as usize]); + } + } + } + + /// Different dimension ids and seeds give different (uncorrelated) sequences. + #[test] + fn ids_and_seeds_decorrelate() { + let a: Vec<[f32; 2]> = (0..64).map(|i| rng2(5, i, 1)).collect(); + let b: Vec<[f32; 2]> = (0..64).map(|i| rng2(5, i, 2)).collect(); + let c: Vec<[f32; 2]> = (0..64).map(|i| rng2(6, i, 1)).collect(); + let corr = |x: &[[f32; 2]], y: &[[f32; 2]]| { + let (mx, my) = (x.iter().map(|p| p[0]).sum::() / 64.0, y.iter().map(|p| p[0]).sum::() / 64.0); + let cov: f32 = x.iter().zip(y).map(|(p, q)| (p[0] - mx) * (q[0] - my)).sum(); + let (vx, vy): (f32, f32) = (x.iter().map(|p| (p[0] - mx).powi(2)).sum(), y.iter().map(|q| (q[0] - my).powi(2)).sum()); + cov / (vx * vy).sqrt() + }; + assert!(corr(&a, &b).abs() < 0.35 && corr(&a, &c).abs() < 0.35, "{} {}", corr(&a, &b), corr(&a, &c)); + } +} diff --git a/tests/render/volume-lamp-inside.ts b/tests/render/volume-lamp-inside.ts index a19749c..9262419 100644 --- a/tests/render/volume-lamp-inside.ts +++ b/tests/render/volume-lamp-inside.ts @@ -1,6 +1,7 @@ // A 60 cd lamp at the centre of a sphere of medium (extinction 2 / m, albedo 0.9, radius 1.2 m): light reaches the -// camera mostly through multiple scattering. The pixel whose ray passes 0.6 m above the lamp reads 6.607 offline (full -// multiple scattering, 64 bounces; this test guards it). +// camera mostly through multiple scattering. The pixel whose ray passes 0.6 m above the lamp reads 6.72 offline (full +// multiple scattering, 64 bounces; this test guards it). Converged at 32768 spp, 2026-11-14: 6.70-6.74 (white noise +// and Sobol); the 6.607 it had came unconverged from the original repository (its first build here reads 6.697). import { PointLight, VolumeMesh } from "@threert/scene"; import { UNIFORM } from "./volume-box-transmittance.ts"; import { camera, result, sceneWithSky, type TestMeta } from "./lib.ts"; @@ -9,7 +10,7 @@ const yv = 0.5 - 0.12 / Math.tan((15 * Math.PI) / 180) / 2; export const test: TestMeta = { id: "volume/lamp-inside", features: ["volume", "point-light"], reference: "analytic", temporal: false, - metric: { kind: "radiance", region: [0.48, yv - 0.02, 0.52, yv + 0.02], expected: 6.607, tolerance: 0.03 }, + metric: { kind: "radiance", region: [0.48, yv - 0.02, 0.52, yv + 0.02], expected: 6.72, tolerance: 0.03 }, spp: 4096, note: "the offline multiple-scattering reference (no analytic value)", };