three-realtime-rt 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { shaderStructs, shaderIntersectFunction } from "three-mesh-bvh";
3
3
  import { MAX_LIGHTS } from "./SceneCompiler.js";
4
4
  import { SKY_GLSL } from "./sky.glsl.js";
5
5
  import { BVH_ANY_HIT_GLSL } from "./bvhAnyHit.glsl.js";
6
+ import { makeMRT } from "./mrtCompat.js";
6
7
 
7
8
  const fullscreenVert = /* glsl */ `
8
9
  out vec2 vUv;
@@ -39,7 +40,8 @@ ${SKY_GLSL}
39
40
  #define MAX_LIGHTS ${MAX_LIGHTS}
40
41
  #define PI 3.14159265358979
41
42
 
42
- // MRT: [0] reservoir hit position + M (fp32), [1] reservoir radiance + W,
43
+ // MRT: [0] reservoir hit position + packed(M, oct-normal) (fp32),
44
+ // [1] reservoir radiance + W,
43
45
  // [2] resolved demodulated GI irradiance (consumed by the denoise add).
44
46
  layout(location = 0) out vec4 outResPos;
45
47
  layout(location = 1) out vec4 outResRad;
@@ -78,6 +80,23 @@ uniform float uFrame;
78
80
  uniform float uEps;
79
81
  uniform float uFireflyClamp;
80
82
  uniform float uMCap; // temporal M-cap (staleness limit)
83
+ uniform int uSpatialTaps; // spatial reuse taps after the temporal merge (0 = v1)
84
+ uniform int uValidateInterval; // reservoir-sample validation period (0 = off, e.g. 8)
85
+
86
+ // Validation tuning (see the reservoir-sample-validation block in main()).
87
+ // VAL_NEE_SAMPLES: NEE samples averaged when RE-SHADING the stored hit. A single
88
+ // NEE sample is black whenever its random light point is occluded (~30% of the
89
+ // time even for a fully-lit hit), so a 1-sample re-shade cannot tell "light off"
90
+ // from "unlucky shadow ray"; averaging a few de-noises the kill decision. Costs
91
+ // a few extra SHADOW rays on only the ~1/uValidateInterval validating pixels (no
92
+ // extra bounce rays -- the single candidate trace is reused).
93
+ // VAL_DARK_FRAC: kill the reservoir when the (multi-sampled) re-shaded target
94
+ // falls below this fraction of the stored one. Kept LOW so the kill fires on a
95
+ // real collapse to near-black (a switched-off light drives it to ~0), not on
96
+ // residual shadow noise -- false kills reset pixels to low confidence, where the
97
+ // pre-existing anti-firefly clamp tightens and would darken bright GI.
98
+ #define VAL_NEE_SAMPLES 8
99
+ #define VAL_DARK_FRAC 0.02
81
100
 
82
101
  uniform vec3 uEnvColor;
83
102
  uniform float uEnvIntensity;
@@ -115,7 +134,51 @@ vec4 fetchBlueNoise() {
115
134
  return fract(bn + shift);
116
135
  }
117
136
 
118
- float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
137
+ // Named rtLum, NOT luminance: three r166+ prepends its own rtLum(vec3)
138
+ // to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
139
+ // (vec3) body as a redefinition — the whole program fails to compile.
140
+ float rtLum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
141
+
142
+ // ---------- reservoir .w bit-packing: M (8 bit) + oct-normal (12+12 bit) ------
143
+ // The RGBA32F reservoir-position attachment is at the pass's hard 16-sampler
144
+ // ceiling, so the reconnection normal n_s (needed by the spatial Jacobian) is
145
+ // bit-packed into the SAME .w channel that holds M. fp32 + NEAREST round-trips
146
+ // the bits exactly. Layout: (uint(M)&0xFF)<<24 | octX12<<12 | octY12. M caps at
147
+ // 255 (clamped on write). 12 bits/axis is ample for a cosine-weighted normal.
148
+ vec2 signNotZero(vec2 v) {
149
+ return vec2(v.x >= 0.0 ? 1.0 : -1.0, v.y >= 0.0 ? 1.0 : -1.0);
150
+ }
151
+ void octEncode12(vec3 n, out uint ox, out uint oy) {
152
+ n /= (abs(n.x) + abs(n.y) + abs(n.z));
153
+ vec2 e = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero(n.xy);
154
+ vec2 u = clamp(e * 0.5 + 0.5, 0.0, 1.0);
155
+ ox = uint(u.x * 4095.0 + 0.5) & 0xFFFu;
156
+ oy = uint(u.y * 4095.0 + 0.5) & 0xFFFu;
157
+ }
158
+ vec3 octDecode12(uint ox, uint oy) {
159
+ vec2 e = vec2(float(ox), float(oy)) / 4095.0 * 2.0 - 1.0;
160
+ vec3 v = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
161
+ if (v.z < 0.0) v.xy = (1.0 - abs(v.yx)) * signNotZero(v.xy);
162
+ return normalize(v);
163
+ }
164
+ // Pack M (clamped to [0,126]) + hit normal into a single fp32 word.
165
+ // M's ceiling is 126, NOT 255: bits 30..23 of the packed word are the float's
166
+ // exponent field, and if they are ever all-ones (M lower bits all 1 AND
167
+ // octX >= 2048) the word is a NaN/Inf bit pattern — some GPUs CANONICALIZE
168
+ // NaNs on texture write, silently destroying the payload. M <= 126 keeps at
169
+ // least one exponent bit zero, so the pattern is unrepresentable by
170
+ // construction. (126 is far above any practical restirGIMCap.)
171
+ float packMN(float M, vec3 n) {
172
+ uint ox, oy;
173
+ octEncode12(n, ox, oy);
174
+ uint mi = uint(clamp(M, 0.0, 126.0)) & 0xFFu;
175
+ return uintBitsToFloat((mi << 24) | (ox << 12) | oy);
176
+ }
177
+ void unpackMN(float w, out float M, out vec3 n) {
178
+ uint packed = floatBitsToUint(w);
179
+ M = float((packed >> 24) & 0xFFu);
180
+ n = octDecode12((packed >> 12) & 0xFFFu, packed & 0xFFFu);
181
+ }
119
182
 
120
183
  void orthoBasis(vec3 n, out vec3 t, out vec3 b) {
121
184
  float s = n.z >= 0.0 ? 1.0 : -1.0;
@@ -268,10 +331,21 @@ vec3 sampleOneAny(vec3 P, vec3 N) {
268
331
  // inline path), plus the world-space hit position so temporal reuse can
269
332
  // recompute the geometry term at the reprojected (same) surface. On a miss the
270
333
  // "hit" is a far point along the ray, so the reused direction is recoverable.
271
- vec3 traceRadianceGI(vec3 ro, vec3 rd, out vec3 hitPos) {
334
+ // nLight = number of averaged NEE samples at the bounce hit. The fresh candidate
335
+ // passes 1 (byte-identical to the inline path). The reservoir-sample VALIDATION
336
+ // passes a small number > 1: a single NEE sample is black whenever its random
337
+ // light point is occluded, which happens ~30% of the time even for a fully-lit
338
+ // hit, so a 1-sample re-shade cannot reliably tell "light switched off" from
339
+ // "unlucky shadow sample". Averaging a few NEE samples de-noises pHatNew enough to
340
+ // make the validation kill decision robust, at the cost of a few extra shadow rays
341
+ // on only the ~1/uValidateInterval validating pixels (no extra BOUNCE rays).
342
+ vec3 traceRadianceGI(vec3 ro, vec3 rd, int nLight, out vec3 hitPos, out vec3 hitNormal) {
272
343
  uvec4 fi; vec3 bary; float dist; bool isDyn;
273
344
  if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
274
345
  hitPos = ro + rd * 1.0e4;
346
+ // Sky "hit" has no surface; face the normal back along the ray so a
347
+ // neighbour reconnecting to this point sees a sane, positive cosPhi.
348
+ hitNormal = -rd;
275
349
  return uSkyEnabled
276
350
  ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
277
351
  : uEnvColor * uEnvIntensity;
@@ -285,7 +359,13 @@ vec3 traceRadianceGI(vec3 ro, vec3 rd, out vec3 hitPos) {
285
359
  if (dot(hN, rd) > 0.0) hN = -hN;
286
360
  vec3 hP = ro + rd * dist;
287
361
  hitPos = hP;
288
- vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
362
+ hitNormal = hN;
363
+ vec3 Ld = vec3(0.0);
364
+ for (int s = 0; s < 8; s++) {
365
+ if (s >= nLight) break;
366
+ Ld += sampleOneAny(hP + hN * uEps, hN);
367
+ }
368
+ Ld /= float(max(nLight, 1));
289
369
  // Diffuse GI drops NEE-listed (static) emitter emission so it isn't double
290
370
  // counted (same rule as RTLightingPass.traceRadiance with specular=false).
291
371
  vec3 hLe = (uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
@@ -336,90 +416,384 @@ void main() {
336
416
  // so this GI never re-enters (and double-counts through) that history.
337
417
  // ================================================================
338
418
 
339
- // --- fresh candidate: one cosine-hemisphere GI bounce, shaded like inline ---
340
- vec3 wi = cosineSampleHemisphere(N, rand2());
419
+ // Reprojected UV of this pixel's primary point into the previous frame; shared
420
+ // by the reservoir-sample validation, the temporal merge and the spatial taps.
421
+ vec4 clip = uPrevViewProj * vec4(P, 1.0);
422
+ vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
423
+ bool haveReproj = clip.w > 0.0 &&
424
+ prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0;
425
+ // Plane-distance tolerance the temporal validation and the spatial taps share.
426
+ float tol = 0.005 * distance(P, uCameraPos) + 20.0 * uEps;
427
+
428
+ // --- fetch this pixel's TEMPORAL-history reservoir ONCE. Both the reservoir-
429
+ // sample validation (below) and the temporal merge read it. Texture reads
430
+ // only, so this consumes no RNG: the fresh-candidate random stream stays
431
+ // aligned with the validation-off path (uValidateInterval==0 is byte-identical
432
+ // to before this feature). ---
433
+ bool histValid = false;
434
+ float Mprev = 0.0;
435
+ float Wprev = 0.0;
436
+ vec3 radPrev = vec3(0.0);
437
+ vec3 hitPrev = vec3(0.0);
438
+ vec3 nPrev = N;
439
+ if (haveReproj) {
440
+ vec4 pPos = texture(uPrevGWorldPos, prevUv);
441
+ if (pPos.w > 0.5 && abs(dot(P - pPos.xyz, N)) < tol) {
442
+ vec4 hp = texture(uPrevResPos, prevUv); // hitPos.xyz + packed(M, n_s)
443
+ vec4 hr = texture(uPrevResRad, prevUv); // radiance.rgb + W
444
+ unpackMN(hp.w, Mprev, nPrev);
445
+ Wprev = hr.w;
446
+ if (Mprev > 0.0 && Wprev > 0.0) {
447
+ histValid = true;
448
+ radPrev = hr.rgb;
449
+ hitPrev = hp.xyz;
450
+ }
451
+ }
452
+ }
453
+
454
+ // --- RESERVOIR-SAMPLE VALIDATION (the fix for stale bounce light). On a
455
+ // rotating 1-in-uValidateInterval subset of pixels — selected by a per-pixel
456
+ // hash added to uFrame so the validating set is decorrelated in space AND
457
+ // changes every frame — spend this frame's ONE candidate ray re-tracing the
458
+ // STORED reservoir hit instead of a fresh cosine bounce. This costs ZERO extra
459
+ // rays (it REPLACES the fresh candidate on those pixels) and is what stops a
460
+ // switched-off light from haunting the reservoir: the stale bright sample gets
461
+ // re-shaded (now dark) or, if its geometry moved, dropped. Direction selection
462
+ // happens HERE, before the single trace below, so the trace call site is shared. ---
463
+ uint pixHash = pcgHash(uint(px.x) * 2654435761u + uint(px.y) * 40503u + 1u);
464
+ bool validateFrame = uValidateInterval > 0 &&
465
+ ((uint(uFrame) + pixHash) % uint(uValidateInterval)) == 0u;
466
+
467
+ vec3 wi;
468
+ bool doValidate = false;
469
+ float expectDist = 0.0;
470
+ if (validateFrame && histValid) {
471
+ vec3 dpv = hitPrev - P;
472
+ expectDist = length(dpv);
473
+ if (expectDist > 1e-5) {
474
+ wi = dpv / expectDist; // aim the candidate AT the stored hit
475
+ doValidate = true;
476
+ }
477
+ }
478
+ if (!doValidate) {
479
+ wi = cosineSampleHemisphere(N, rand2()); // fresh cosine-hemisphere GI bounce
480
+ }
481
+
482
+ // --- the SINGLE candidate trace. Validation reuses this exact call site (no
483
+ // second trace is added to the shader); the trace already shades the hit. ---
341
484
  vec3 hitPos;
342
- vec3 rad = traceRadianceGI(P + N * uEps, wi, hitPos);
485
+ vec3 hitNormal;
486
+ int nLight = doValidate ? VAL_NEE_SAMPLES : 1;
487
+ vec3 rad = traceRadianceGI(P + N * uEps, wi, nLight, hitPos, hitNormal);
343
488
  // Match the inline firefly clamp, which is applied to indirect (= L_i) so
344
489
  // the biased mean of the two paths agrees.
345
- float rl = luminance(rad);
490
+ float rl = rtLum(rad);
346
491
  if (rl > uFireflyClamp) rad *= uFireflyClamp / rl;
347
492
 
348
493
  float cosT = max(dot(N, wi), 0.0);
349
- float pHatFresh = luminance(rad) * cosT;
350
- // w = p_hat / p_source = p_hat / (cos/PI). cosT cancels; guard cosT==0.
351
- float wFresh = cosT > 0.0 ? pHatFresh * PI / cosT : 0.0;
352
494
 
353
- float wSum = wFresh;
354
- float M = 1.0;
355
- vec3 selRad = rad;
356
- vec3 selPos = hitPos;
495
+ float wSum;
496
+ float M;
497
+ vec3 selRad;
498
+ vec3 selPos;
499
+ vec3 selNormal; // n_s of the selected sample (packed into .w)
500
+ bool killStore = false; // validation flags the STORED reservoir for reset
501
+
502
+ if (doValidate) {
503
+ // On a validation pixel there is NO fresh exploration candidate this frame
504
+ // (the ray was spent re-tracing the stored hit); the reservoir for THIS frame
505
+ // is the temporal history alone (the merge below re-adds it, histValid stays
506
+ // true so a valid pixel keeps showing its GI — no dropout, no darkening).
507
+ // Documented ~1/uValidateInterval exploration reduction (12.5% at interval 8).
508
+ wSum = 0.0;
509
+ M = 0.0;
510
+ selRad = vec3(0.0);
511
+ selPos = P + N;
512
+ selNormal = N;
513
+
514
+ // Validation is KILL-only, and the kill hits only the STORED reservoir (next
515
+ // frame), NOT this frame's displayed estimate. Re-shade the stored hit and, if
516
+ // it is stale, mark the stored reservoir for reset so this pixel's fresh
517
+ // candidate takes over next frame and the estimate tracks the current lighting.
518
+ // Two staleness signals:
519
+ // (1) GEOMETRY changed — the re-traced hit distance no longer matches the
520
+ // stored one (a nearer occluder appeared, geometry moved, or the ray
521
+ // missed; a miss puts hitPos far down the ray, so it trips this too).
522
+ // (2) the target WENT DARK — the re-shaded radiance collapsed below a small
523
+ // fraction (VAL_DARK_FRAC) of the stored one (a light switched off). This
524
+ // is THE fix for stale bounce light.
525
+ // Why kill-only, and why store-deferred: the reservoir stores a RIS-selected
526
+ // sample whose radiance is BRIGHT-biased with a compensating small W
527
+ // (gi_luminance = selRad*selCos/PI*W = wSum/(M*PI), so W is a purely geometric
528
+ // 1/pdf term). Overwriting that bright radiance with a single average re-shade
529
+ // sample, or rescaling W by the noisy luminance ratio, provably DARKENS the
530
+ // mean (bright-selected pHatOld in the denominator) — the originally-specified
531
+ // "refresh radiance + W *= clamp(pHatOld/pHatNew, 0.25, 4)" path was measured
532
+ // to darken static GI ~25% at interval 8, so it is NOT used. Killing (dropping
533
+ // the stale term so fresh candidates rebuild) is unbiased; deferring the kill
534
+ // to the STORE keeps the displayed frame equal to validation-off, so even a
535
+ // false kill from single-sample noise costs a little variance, not brightness.
536
+ float valTol = max(0.02 * expectDist, 4.0 * uEps);
537
+ float hitDist = length(hitPos - P);
538
+ bool geomChanged = abs(hitDist - expectDist) > valTol;
539
+ float pHatOld = rtLum(radPrev) * cosT; // stored target at this pixel
540
+ float pHatNew = rtLum(rad) * cosT; // re-shaded target (current light)
541
+ bool wentDark = pHatNew < VAL_DARK_FRAC * pHatOld;
542
+ // KILL (drop the stale temporal term so this pixel's fresh candidates rebuild
543
+ // from the current scene) on geometry change OR a collapse to near-black; leave
544
+ // a still-valid sample UNTOUCHED so a static scene does not drift. A switched-
545
+ // off light drives pHatNew -> 0 and trips wentDark. The kill only marks the
546
+ // STORE (killStore); the displayed frame still uses the merged history.
547
+ killStore = geomChanged || wentDark;
548
+ } else {
549
+ // --- normal fresh candidate: one cosine-hemisphere GI bounce, shaded inline.
550
+ float pHatFresh = rtLum(rad) * cosT;
551
+ // w = p_hat / p_source = p_hat / (cos/PI). cosT cancels; guard cosT==0.
552
+ float wFresh = cosT > 0.0 ? pHatFresh * PI / cosT : 0.0;
553
+ wSum = wFresh;
554
+ M = 1.0;
555
+ selRad = rad;
556
+ selPos = hitPos;
557
+ selNormal = hitNormal;
558
+ }
357
559
 
358
- // --- temporal reuse: reproject P, validate the SAME surface, merge history ---
359
- vec4 clip = uPrevViewProj * vec4(P, 1.0);
360
- if (clip.w > 0.0) {
361
- vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
362
- if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
363
- vec4 pPos = texture(uPrevGWorldPos, prevUv);
364
- float tol = 0.005 * distance(P, uCameraPos) + 20.0 * uEps;
365
- if (pPos.w > 0.5 && abs(dot(P - pPos.xyz, N)) < tol) {
366
- vec4 hp = texture(uPrevResPos, prevUv); // hitPos.xyz + M
367
- vec4 hr = texture(uPrevResRad, prevUv); // radiance.rgb + W
368
- float Mprev = hp.w;
369
- float Wprev = hr.w;
370
- if (Mprev > 0.0 && Wprev > 0.0) {
371
- vec3 radPrev = hr.rgb;
372
- vec3 hitPrev = hp.xyz;
373
- // Re-evaluate the target at the CURRENT surface (reconnect at the
374
- // stored hit point). Same world point P (validated), so no Jacobian.
375
- vec3 dp = hitPrev - P;
376
- float dl = length(dp);
377
- float cosPrev = dl > 1e-5 ? max(dot(N, dp / dl), 0.0) : 0.0;
378
- float pHatPrev = luminance(radPrev) * cosPrev;
379
- float Mc = min(Mprev, uMCap);
380
- // Combine reservoirs: w = p_hat_current(sample) * W_prev * M_prev.
381
- float w = pHatPrev * Wprev * Mc;
382
- wSum += w;
383
- M += Mc;
384
- if (w > 0.0 && rand() * wSum < w) {
385
- selRad = radPrev;
386
- selPos = hitPrev;
387
- }
388
- }
560
+ // --- temporal reuse: merge the (possibly radiance-refreshed, or killed)
561
+ // history. When the validation above KILLED the sample, histValid is false and
562
+ // the merge is skipped -> the reservoir stays empty this frame. ---
563
+ // emaPrevGi: last frame's resolved GI, RECONSTRUCTED from the previous
564
+ // reservoir (all inputs already bound no extra sampler). Reservoirs persist
565
+ // WHICH sample matters, not a variance average: near emitters many samples
566
+ // are legitimately bright and the per-frame selection churn reads as
567
+ // flickering fireflies (the inline GI path hid the same variance inside the
568
+ // lighting pass's deep EMA). The resolve below blends against this
569
+ // reconstruction to restore that smoothing.
570
+ vec3 emaPrevGi = vec3(0.0);
571
+ bool emaPrevOk = false;
572
+ if (histValid) {
573
+ // Re-evaluate the target at the CURRENT surface (reconnect at the stored hit
574
+ // point). Same world point P (validated), so no Jacobian.
575
+ vec3 dp = hitPrev - P;
576
+ float dl = length(dp);
577
+ float cosPrev = dl > 1e-5 ? max(dot(N, dp / dl), 0.0) : 0.0;
578
+ float pHatPrev = rtLum(radPrev) * cosPrev;
579
+ float Mc = min(Mprev, uMCap);
580
+ // Combine reservoirs: w = p_hat_current(sample) * W_prev * M_prev.
581
+ float w = pHatPrev * Wprev * Mc;
582
+ wSum += w;
583
+ M += Mc;
584
+ if (w > 0.0 && rand() * wSum < w) {
585
+ selRad = radPrev;
586
+ selPos = hitPrev;
587
+ selNormal = nPrev;
588
+ }
589
+ // Reconstruct last frame's resolve from this same sample (same W cap
590
+ // and clamp as the live resolve, for a like-for-like EMA partner).
591
+ vec3 pg = radPrev * (cosPrev / PI) * min(Wprev, 32.0);
592
+ float pgl = rtLum(pg);
593
+ if (pgl > uFireflyClamp) pg *= uFireflyClamp / pgl;
594
+ if (!any(isnan(pg)) && !any(isinf(pg))) {
595
+ emaPrevGi = pg;
596
+ emaPrevOk = true;
597
+ }
598
+ }
599
+
600
+ // Snapshot the TEMPORAL-only reservoir. This — not the spatially-merged one — is
601
+ // what gets STORED as history, exactly as v1 did. Spatial reuse below is terminal
602
+ // (it only sharpens THIS frame's resolved GI output); it is deliberately NOT fed
603
+ // back into the stored reservoir. The reconnection shift carries a small target
604
+ // -function bias, and the high default M-cap's temporal feedback would amplify it
605
+ // by ~1/(1-M/(M+1)) ≈ (M+1)x, so storing the merged reservoir makes the GI drift
606
+ // frame-over-frame (dark in grazing views, blown out in steep ones). Keeping the
607
+ // history temporal-only — the pattern the shipped direct-light ReSTIR uses, where
608
+ // "history feeds back from the TEMPORAL stage only" — keeps it stable and
609
+ // unbiased while still delivering the per-frame spatial variance reduction into
610
+ // the denoiser. taps==0 leaves selRad/M/wSum untouched, so this is a no-op there.
611
+ vec3 selRadT = selRad; vec3 selPosT = selPos; vec3 selNormalT = selNormal;
612
+ float wSumT = wSum; float MT = M;
613
+
614
+ // --- spatial reuse (v2): fused spatiotemporal, streamed RIS over K taps of the
615
+ // PREVIOUS frame's reservoir textures around the reprojected UV. Each adopted
616
+ // neighbour sample S = (hit x_s, hit normal n_s, radiance L_s) is reweighted by
617
+ // the reconnection Jacobian |J| = (cosPhi_q/cosPhi_r)*(d_r^2/d_q^2). x_q is this
618
+ // pixel's primary point; x_r is the NEIGHBOUR's primary point read from the
619
+ // previous frame's gWorldPos. A final visibility ray (below) prevents leaks. ---
620
+ if (haveReproj && uSpatialTaps > 0) {
621
+ vec2 texel = 1.0 / vec2(textureSize(uPrevResPos, 0));
622
+ for (int k = 0; k < 4; k++) {
623
+ if (k >= uSpatialTaps) break;
624
+ // Offset: radius uniform in [4, 20] lighting-res pixels, angle from RNG,
625
+ // decorrelated per frame (gSeed carries uFrame; blue noise is frame-shifted).
626
+ float ang = rand() * 2.0 * PI;
627
+ float rad_px = mix(4.0, 20.0, rand());
628
+ vec2 nUv = prevUv + vec2(cos(ang), sin(ang)) * rad_px * texel;
629
+ // (a) neighbour uv in [0,1].
630
+ if (nUv.x < 0.0 || nUv.x > 1.0 || nUv.y < 0.0 || nUv.y > 1.0) continue;
631
+ // (b) plane-distance validation of the neighbour's PREVIOUS primary point
632
+ // against q's plane (same tolerance as the temporal validation).
633
+ vec4 nPrimary = texture(uPrevGWorldPos, nUv); // x_r + validFlag
634
+ if (nPrimary.w < 0.5 || abs(dot(P - nPrimary.xyz, N)) >= tol) continue;
635
+ vec4 nhp = texture(uPrevResPos, nUv); // x_s + packed(M_r, n_s)
636
+ vec4 nhr = texture(uPrevResRad, nUv); // L_s + W_r
637
+ float Mr; vec3 nS;
638
+ unpackMN(nhp.w, Mr, nS);
639
+ float Wr = nhr.w;
640
+ // (c) skip reservoirs with M == 0 or W <= 0.
641
+ if (Mr <= 0.0 || Wr <= 0.0) continue;
642
+ vec3 xS = nhp.xyz;
643
+ vec3 Ls = nhr.rgb;
644
+ vec3 xR = nPrimary.xyz;
645
+
646
+ // Reconnection Jacobian for q adopting the neighbour's sample.
647
+ float dq = length(xS - P);
648
+ float dr = length(xS - xR);
649
+ if (dq < 1e-5 || dr < 1e-5) continue;
650
+ float cosPhiQ = max(dot(nS, normalize(P - xS)), 1e-4);
651
+ float cosPhiR = max(dot(nS, normalize(xR - xS)), 1e-4);
652
+ float J = (cosPhiQ / cosPhiR) * (dr * dr) / (dq * dq);
653
+ J = clamp(J, 0.1, 10.0); // grazing-angle firefly guard
654
+
655
+ // Target function at q (same shape the pass already uses).
656
+ float cosQ = max(dot(N, normalize(xS - P)), 0.0);
657
+ float pHatQ = rtLum(Ls) * cosQ;
658
+ // Invalid-shift reject: if the neighbour's hit x_s lies below q's shading
659
+ // hemisphere (cosQ == 0) or carries no radiance, the reconnected target is
660
+ // zero — the shift could never have produced this sample at q, so it must
661
+ // NOT add confidence weight. Skipping the whole tap (not just its w) keeps
662
+ // the M normalization honest; adding Mc here while w == 0 would inflate M
663
+ // without wSum and systematically darken the GI. (The temporal path never
664
+ // trips this — same surface point, always a valid, non-zero target.)
665
+ if (pHatQ <= 0.0) continue;
666
+ float Mc = min(Mr, uMCap);
667
+ float w = pHatQ * J * Wr * Mc;
668
+ wSum += w;
669
+ M += Mc;
670
+ if (w > 0.0 && rand() * wSum < w) {
671
+ selRad = Ls;
672
+ selPos = xS;
673
+ selNormal = nS;
389
674
  }
390
675
  }
391
676
  }
392
677
 
393
- // --- finalize: recompute p_hat(selected) at this surface, form W, resolve ---
678
+ // --- finalize OUTPUT: recompute p_hat(selected) at this surface from the
679
+ // SPATIALLY-merged reservoir, form W, resolve the GI for this frame. ---
394
680
  vec3 sd = selPos - P;
395
681
  float sl = length(sd);
396
682
  float selCos = sl > 1e-5 ? max(dot(N, sd / sl), 0.0) : 0.0;
397
- float pHatSel = luminance(selRad) * selCos;
683
+ float pHatSel = rtLum(selRad) * selCos;
398
684
  float W = (M > 0.0 && pHatSel > 0.0) ? wSum / (M * pHatSel) : 0.0;
399
685
 
400
- vec3 gi = selRad * (selCos / PI) * W; // demodulated indirect irradiance
401
- float gil = luminance(gi);
402
- if (gil > uFireflyClamp) gi *= uFireflyClamp / gil; // matches inline safety net
686
+ // --- final visibility (mandatory): ONE any-hit occlusion ray from x_q toward
687
+ // x_s. If the reconnection point is blocked, drop THIS FRAME's OUTPUT estimate
688
+ // (Wout=0) this is what stops reused samples leaking light through walls.
689
+ // occluded() already trims 2*eps off maxDist to avoid self-intersecting the far
690
+ // surface. The STORED reservoir keeps the un-occluded W: the sample is real and
691
+ // may be visible to a neighbour, so each pixel re-tests visibility from its own
692
+ // position. Storing the zeroed W instead would bleed energy out of the reservoir
693
+ // over frames (spatial samples fail visibility more often than temporal ones,
694
+ // and the zero would propagate to neighbours), darkening the GI. ---
695
+ // Gated on uSpatialTaps > 0: the temporal-only path reuses at the SAME surface
696
+ // point, whose sample is visible by construction, so v1 (taps==0) needs no
697
+ // occlusion ray and stays byte-identical. Spatial reconnections to a neighbour's
698
+ // hit point are the ones that can pierce a wall, so they get the visibility test.
699
+ float Wout = W;
700
+ if (uSpatialTaps > 0 && Wout > 0.0 && sl > 1e-5) {
701
+ if (occluded(P + N * uEps, sd / sl, sl)) Wout = 0.0;
702
+ }
703
+ // W cap: W ~ pi/cos for the cosine source pdf, so values beyond ~32 mean the
704
+ // recomputed p_hat(selected) collapsed this frame (grazing cos after a camera
705
+ // or normal change) while wSum still carries past-frame magnitudes — the
706
+ // classic reservoir firefly. The inline GI path hid the same spikes inside
707
+ // its deep temporal EMA; this resolve has no EMA downstream, so the spike
708
+ // would live on screen for a whole frame (visibly on Metal/iOS). Capping W
709
+ // trusts reconnection angles down to cos ~ 0.1 and slightly darkens grazing
710
+ // GI beyond that — the standard ReSTIR trade.
711
+ Wout = min(Wout, 32.0);
712
+
713
+ vec3 gi = selRad * (selCos / PI) * Wout; // demodulated indirect irradiance
714
+ // Confidence-weighted firefly clamp: a young reservoir (M small — fresh
715
+ // pixels under camera motion, where the resolve EMA has no partner yet) is
716
+ // one raw sample, and at the full clamp it reads as motion sparkle. Tighten
717
+ // the cap for low-M pixels and relax it to the inline-path clamp as
718
+ // confidence grows; converged pixels are untouched. Trades a few frames of
719
+ // slightly dim GI on freshly revealed surfaces for a steady image in motion.
720
+ float conf = clamp(M / uMCap, 0.0, 1.0);
721
+ float cap = uFireflyClamp * mix(0.3, 1.0, conf);
722
+ float gil = rtLum(gi);
723
+ if (gil > cap) gi *= cap / gil;
403
724
  if (any(isnan(gi)) || any(isinf(gi))) gi = vec3(0.0);
404
- if (any(isnan(selRad)) || any(isinf(selRad))) { selRad = vec3(0.0); W = 0.0; }
725
+ // Resolve EMA (see the emaPrevGi note above): ~5-frame effective average.
726
+ // Cuts selection-churn flicker near emitters ~5x for ~5 frames of lag.
727
+ if (emaPrevOk) gi = mix(emaPrevGi, gi, 0.15);
728
+
729
+ // --- STORE the TEMPORAL-only reservoir as history (see snapshot note above).
730
+ // Resolve its own W from the temporal-merged wSum/M so the stored W is valid for
731
+ // next frame's temporal AND spatial reuse. For taps==0 this is exactly the v1
732
+ // reservoir. A NaN sample is scrubbed so it can't poison the history. ---
733
+ vec3 sdT = selPosT - P;
734
+ float slT = length(sdT);
735
+ float selCosT = slT > 1e-5 ? max(dot(N, sdT / slT), 0.0) : 0.0;
736
+ float pHatSelT = rtLum(selRadT) * selCosT;
737
+ float WT = (MT > 0.0 && pHatSelT > 0.0) ? wSumT / (MT * pHatSelT) : 0.0;
738
+ if (any(isnan(selRadT)) || any(isinf(selRadT))) { selRadT = vec3(0.0); WT = 0.0; }
739
+
740
+ // Validation store policy. The DISPLAYED gi above always used the merged history
741
+ // (no dropout). The STORED reservoir, however, must be handled so validation
742
+ // does not shift the temporal fixed point:
743
+ // - KEEP (valid sample): pass the previous reservoir through UNCHANGED. A
744
+ // validation frame carries no fresh candidate, so RE-DERIVING and re-storing
745
+ // the merged reservoir (M -> min(Mprev,cap), W recomputed) perturbs the
746
+ // recursion and was measured to darken the static estimate ~13-16%. Writing
747
+ // back the exact (hitPrev, radPrev, Wprev, Mprev) leaves the fixed point
748
+ // identical to validation-off, so a static scene does not drift.
749
+ // - KILL (stale sample): reset to empty so next frame's fresh cosine candidate
750
+ // rebuilds and the estimate tracks the current lighting.
751
+ if (doValidate) {
752
+ if (killStore) {
753
+ selPosT = P + N; selRadT = vec3(0.0); selNormalT = N; MT = 0.0; WT = 0.0;
754
+ } else {
755
+ // KEEP: write the previous reservoir back verbatim (hitPrev, radPrev, nPrev,
756
+ // Mprev, Wprev). A validation frame adds no fresh candidate, so re-deriving
757
+ // and re-storing the merged reservoir (M -> min(Mprev,cap), W recomputed)
758
+ // perturbs the temporal recursion and was measured to darken the static
759
+ // estimate ~13-16%; a verbatim write-back keeps the fixed point identical to
760
+ // validation-off.
761
+ selPosT = hitPrev; selRadT = radPrev; selNormalT = nPrev; MT = Mprev; WT = Wprev;
762
+ }
763
+ }
405
764
 
406
- outResPos = vec4(selPos, M);
407
- outResRad = vec4(selRad, W);
765
+ outResPos = vec4(selPosT, packMN(MT, selNormalT));
766
+ outResRad = vec4(selRadT, WT);
408
767
  outGI = vec4(gi, 1.0);
409
768
  }
410
769
  `;
411
770
 
412
771
  /**
413
- * EXPERIMENTAL ReSTIR GI (v1, temporal-only). Per-pixel reservoirs reuse the
414
- * 1-bounce GI sample across frames at the reprojected same-surface point. Runs
415
- * at lighting resolution with its OWN sampler budget (16: 8 BVH + 2 attr + 1
416
- * scene-data + gWorldPos + gNormalMetal + prevGWorldPos + 2 reservoir history),
772
+ * EXPERIMENTAL ReSTIR GI (v2, fused spatiotemporal). Per-pixel reservoirs reuse
773
+ * the 1-bounce GI sample across frames at the reprojected same-surface point,
774
+ * then take K spatial taps (`restirGISpatialTaps`, default 2; 0 = exact v1
775
+ * temporal-only behaviour) of the previous frame's reservoirs around that UV.
776
+ * Runs at lighting resolution with its OWN sampler budget (16: 8 BVH + 2 attr +
777
+ * 1 scene-data + gWorldPos + gNormalMetal + prevGWorldPos + 2 reservoir history),
417
778
  * independent of the lighting pass. render() returns a resolved, demodulated GI
418
779
  * irradiance texture whose mean matches the inline GI path (see main()).
419
780
  *
420
- * NO spatial reuse in v1: spatial reuse needs the solid-angle->area Jacobian and
421
- * is where implementations go subtly wrong. Temporal reuse at the same surface
422
- * point does not.
781
+ * Spatial reuse applies the reconnection solid-angle->area Jacobian per adopted
782
+ * neighbour and a final visibility ray at the reconnection point (anti-leak); the
783
+ * hit normal n_s it needs is bit-packed into the reservoir-position .w alongside
784
+ * M (the pass is at the 16-sampler ceiling and can add none). Taps are gated by
785
+ * uv bounds, a plane-distance check of the neighbour's previous primary point,
786
+ * and a non-empty reservoir, so it stays unbiased and does not leak.
787
+ *
788
+ * `restirGIValidate` (default 8, 0 = off) adds reservoir-sample validation: a
789
+ * rotating 1-in-N subset of pixels re-aims its single candidate ray AT the stored
790
+ * reservoir hit (instead of a fresh cosine bounce) and re-shades it; the reservoir
791
+ * is KILLED (so fresh candidates rebuild) when the geometry moved or the re-shaded
792
+ * target collapsed to near-black (a light switched off), and left untouched
793
+ * otherwise, with the kill deferred to the store so a valid pixel never drops out.
794
+ * It reuses the existing candidate trace (no extra bounce rays, no extra samplers)
795
+ * and is the fix for stale bounce light: a switched-off light stops haunting the
796
+ * reservoir instead of fading slowly. 0 is byte-identical to the pre-feature path.
423
797
  */
424
798
  export class GIReservoirPass {
425
799
  constructor(width, height) {
@@ -427,6 +801,9 @@ export class GIReservoirPass {
427
801
  this.targetB = this._makeTarget(width, height);
428
802
 
429
803
  this.material = new THREE.ShaderMaterial({
804
+ // Stable program name for compile-failure self-diagnosis; a link failure
805
+ // disables the experimental `restirGI` feature (falls back to inline GI).
806
+ name: "rt:gi-reservoir",
430
807
  glslVersion: THREE.GLSL3,
431
808
  vertexShader: fullscreenVert,
432
809
  fragmentShader: giFrag,
@@ -454,6 +831,8 @@ export class GIReservoirPass {
454
831
  uEps: { value: 1e-3 },
455
832
  uFireflyClamp: { value: 4.0 },
456
833
  uMCap: { value: 20 },
834
+ uSpatialTaps: { value: 2 },
835
+ uValidateInterval: { value: 8 },
457
836
  uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
458
837
  uEnvIntensity: { value: 1.0 },
459
838
  uSkyEnabled: { value: false },
@@ -479,7 +858,7 @@ export class GIReservoirPass {
479
858
  // (needs fp32) + M + radiance + W, which must never be interpolated. All fp32
480
859
  // (rather than a mixed fp32/fp16 layout) sidesteps drivers that reject mixed
481
860
  // MRT precision; the resolved GI (attachment 2) is read 1:1 by the denoise.
482
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 3, {
861
+ const t = makeMRT(width, height, 3, {
483
862
  minFilter: THREE.NearestFilter,
484
863
  magFilter: THREE.NearestFilter,
485
864
  format: THREE.RGBAFormat,
@@ -544,6 +923,8 @@ export class GIReservoirPass {
544
923
  u.uEps.value = eps;
545
924
  u.uFireflyClamp.value = params.fireflyClamp;
546
925
  u.uMCap.value = params.mCap;
926
+ u.uSpatialTaps.value = params.spatialTaps;
927
+ u.uValidateInterval.value = params.validateInterval;
547
928
  u.uEmissiveCDF.value = params.emissiveCDF;
548
929
  u.uEnvColor.value.copy(params.envColor);
549
930
  u.uEnvIntensity.value = params.envIntensity;