From 2fe9641d1306d65deab98898c5c77dc0bc33b364 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 15 Jul 2026 12:35:40 +0200 Subject: [PATCH] fix: PT-9 - seed SVGF disocclusions from converged neighbours; ReSTIR on A true disocclusion started from the raw 1-spp sample with variance EXACTLY zero (m2 - m1 squared of a single value), so freshly streamed-in pixels - the whole viewport periphery whenever the camera translates - showed raw path noise until history rebuilt. Indoors (bounce-dominated light, the shooter house) that read as a salt-and-pepper ring around a clean centre, worst at low frame rates where history rebuilds slowest. A newborn pixel usually sits next to texels holding converged history for the very same surface: the wall streaming in at the screen edge continues inward. accum[] and moments[] are the previous frame buffers, race-free to read at any texel, so the temporal kernel now borrows from depth-consistent, converged (n >= 4) neighbours in a 5x5 window, weighted by history length, and inherits half their history (capped at 8) - the canonical 1/N blend then folds real fresh samples in over the next frames. A pixel with no depth-compatible neighbour (a genuinely new surface rounding a doorway) keeps the honest raw start. Realtime mode only; progressive accumulation is untouched. ReSTIR DI (PT-4) flips to ON by default: interiors carry 7+ point lights and uniform 1-of-N NEE picks the light that matters for a pixel ~6% of the time; the indoor A/B showed a visible win at no measurable frame cost (24 vs 25 fps). BLOOM_PT_RESTIR=0 restores plain NEE. Verified: golden_pt_progressive + golden_pt_realtime_motion both pass; panning indoor capture (shooter INDOORCAM harness) shows the disocclusion band gone at unchanged fps; outdoor RT full-run smoke clean. Claude-Session: https://claude.ai/code/session_018574BdCfSjdpNK3WLgx1hP --- native/shared/src/renderer/mod.rs | 13 ++++--- native/shared/src/renderer/shaders/pt.rs | 49 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 7cab18d..13f935d 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -5526,12 +5526,15 @@ impl Renderer { .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); - // PT-4 — ReSTIR DI stays behind an env flag until the many-light - // content that justifies it exists (roadmap: with ≤16 analytic - // lights plain NEE is nearly as good). + // PT-4/PT-9 — ReSTIR DI is ON by default now: interior scenes + // (the shooter's house) carry 7+ point lights and uniform 1-of-N + // NEE picks the light that matters for a pixel ~6% of the time — + // the indoor A/B showed ReSTIR shrinks disocclusion noise at no + // measurable frame cost. BLOOM_PT_RESTIR=0 restores plain NEE + // for comparison runs. let pt_restir = std::env::var("BLOOM_PT_RESTIR") - .map(|v| v == "1") - .unwrap_or(false); + .map(|v| v != "0") + .unwrap_or(true); // --- Ticket 014 V3: SW SDF sphere-trace pipeline --- // Always built. At dispatch time we pick SDF over Hi-Z when diff --git a/native/shared/src/renderer/shaders/pt.rs b/native/shared/src/renderer/shaders/pt.rs index c222692..c3290a3 100644 --- a/native/shared/src/renderer/shaders/pt.rs +++ b/native/shared/src/renderer/shaders/pt.rs @@ -1406,6 +1406,55 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { hist_m1 /= wsum; hist_m2 /= wsum; n_hist = hist_n / wsum; + } else { + // PT-9 — disocclusion seeding. A true disocclusion used to start + // from the raw 1-spp sample with variance EXACTLY zero (m2 - m1² + // of a single value), so freshly streamed-in pixels — the whole + // viewport periphery whenever the camera translates — showed raw + // path noise until history rebuilt. Indoors (bounce-dominated + // light) that read as a salt-and-pepper ring around a clean + // centre. But a newborn's NEIGHBOURS usually hold converged + // history for the very same surface (the wall streaming in at + // the screen edge continues inward), and accum[]/moments[] are + // the previous frame's buffers — race-free to read at any texel. + // Borrow from depth-consistent, converged (n ≥ 4) neighbours, + // weighted by their history length; inherit HALF their history + // (capped at 8) so the canonical 1/N blend still folds real + // fresh samples in quickly. A pixel with no depth-compatible + // neighbour (a genuinely new surface, e.g. rounding a doorway) + // keeps the honest raw start — there is nothing to borrow. + let zl_here = lin_depth(depth); + let btol = 0.1 * zl_here + 0.02; + var seed_rgb = vec3(0.0); + var seed_m1 = 0.0; + var seed_m2 = 0.0; + var seed_n = 0.0; + var seed_w = 0.0; + for (var by = -2; by <= 2; by = by + 1) { + for (var bx = -2; bx <= 2; bx = bx + 1) { + if (bx == 0 && by == 0) { continue; } + let q = vec2(i32(gid.x) + bx, i32(gid.y) + by); + if (q.x < 0 || q.y < 0 || q.x >= i32(u.size.x) || q.y >= i32(u.size.y)) { + continue; + } + let qidx = u32(q.y) * u.size.x + u32(q.x); + let m = moments[qidx]; + if (m.w >= 0.9999999 || m.z < 4.0) { continue; } + if (abs(lin_depth(m.w) - zl_here) > btol) { continue; } + let wt = m.z; + seed_rgb += accum[qidx].rgb * wt; + seed_m1 += m.x * wt; + seed_m2 += m.y * wt; + seed_n += m.z * wt; + seed_w += wt; + } + } + if (seed_w > 0.0) { + hist_rgb = seed_rgb / seed_w; + hist_m1 = seed_m1 / seed_w; + hist_m2 = seed_m2 / seed_w; + n_hist = min((seed_n / seed_w) * 0.5, 8.0); + } } // Canonical blend: cumulative average while the history is // young (alpha = 1/N), settling to EMA. The floor is 0.1