three-realtime-rt 0.4.2 → 0.6.0

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.
@@ -66,6 +66,7 @@ uniform bool uReflEnabled; // traced reflections on metallic surfaces
66
66
  uniform bool uRefrEnabled; // traced refraction on transmissive surfaces
67
67
  uniform bool uBlendEnabled; // straight-through view continuation on blend surfaces
68
68
  uniform float uIor; // index of refraction for transmissive materials
69
+ uniform float uDispersion; // chromatic dispersion strength for glass (0 = off)
69
70
  uniform bool uLightStochastic; // 1 direct shadow ray/pixel/frame instead of 1/light
70
71
  uniform bool uRestirEnabled; // shade the reservoir winner instead of sampling
71
72
  uniform bool uGIHalfRate; // GI ray on alternating checkerboard, doubled
@@ -75,6 +76,18 @@ uniform float uEnvIntensity;
75
76
  uniform float uFrame;
76
77
  uniform float uEps;
77
78
  uniform bool uGIEnabled;
79
+ // EXPERIMENTAL: when an external ReSTIR GI pass supplies the 1-bounce indirect
80
+ // (added downstream at the denoise stage), skip the inline GI trace so it isn't
81
+ // counted twice. A uniform, NOT a sampler — the lighting pass is already at the
82
+ // WebGL2 16-sampler minimum and cannot take another.
83
+ uniform bool uExternalGI;
84
+
85
+ // BVH traversal-cost heatmap (outputMode 7). When uCostView is on, main() writes
86
+ // the per-pixel shadow-ray node-visit count (gBvhVisits, from bvhAnyHit.glsl.js)
87
+ // through costPalette() into the irradiance attachment INSTEAD of the accumulated
88
+ // lighting — bypassing temporal blending — so the debug view reads the raw cost.
89
+ uniform bool uCostView;
90
+ uniform float uCostScale; // multiplies the visit count before the palette (default 1/96)
78
91
 
79
92
  // Procedural sky (when enabled, replaces the flat env colour as the "miss" term
80
93
  // for GI rays — this is what gives natural outdoor bounce light).
@@ -464,7 +477,8 @@ vec3 sampleOneAny(vec3 P, vec3 N) {
464
477
  // Incoming radiance along rd: trace, shade the hit with direct + NEE lighting,
465
478
  // sky/env on a miss. Specular rays keep emitter emission on hit (NEE at the ray
466
479
  // origin cannot cover a specular path); diffuse GI rays drop it for NEE-listed
467
- // (static) emitters so that light isn't counted twice.
480
+ // emitters (static AND dynamic — dynamic emitters now join the NEE table, their
481
+ // rows refreshed each frame) so that light isn't counted twice.
468
482
  vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
469
483
  uvec4 fi; vec3 bary; float dist; bool isDyn;
470
484
  if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
@@ -481,7 +495,7 @@ vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
481
495
  if (dot(hN, rd) > 0.0) hN = -hN;
482
496
  vec3 hP = ro + rd * dist;
483
497
  vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
484
- vec3 hLe = (!specular && uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
498
+ vec3 hLe = (!specular && uEmissiveCount > 0) ? vec3(0.0) : hEmissive;
485
499
  return hLe + hAlbedo * Ld * (1.0 / PI);
486
500
  }
487
501
 
@@ -541,16 +555,66 @@ vec3 analyticGlint(vec3 P, vec3 refl) {
541
555
 
542
556
  // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
543
557
  // refraction (enter at P, march to the exit surface, refract again).
544
- vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
558
+ //
559
+ // CHROMATIC DISPERSION (stochastic spectral sampling). Real glass has a
560
+ // wavelength-dependent ior, so white light splits into a spectrum (a diamond
561
+ // throws a rainbow). Tracing one refraction path per colour would cost three
562
+ // traceRadiance calls, but the Metal call-site budget (see the note at the
563
+ // unified secondary-ray site) forbids a fourth traceRadiance anywhere in this
564
+ // shader. Instead, when uDispersion > 0 each frame this pixel picks ONE colour
565
+ // channel c in R,G,B uniformly and traces the SAME single refraction path with
566
+ // a channel-shifted ior. The refracted radiance is then isolated to channel c
567
+ // and multiplied by 3 (to compensate the 1-of-3 pick); the temporal EMA
568
+ // averages the three per-channel estimates into a full-spectrum, dispersed
569
+ // refraction — zero extra rays, zero new call sites, unbiased in the mean. It
570
+ // therefore shimmers slightly while converging.
571
+ //
572
+ // THE MIX SPLIT. The return is mix(refrRad, reflRad, fres) = refrRad*(1-fres)
573
+ // + reflRad*fres. Only the TRANSMITTED half (refrRad) carries the channel
574
+ // mask; the reflection half (reflRad) is NOT dispersed and stays full colour
575
+ // EVERY frame. To keep the reflection deterministic frame-to-frame, the
576
+ // Fresnel weight is taken from the BASE ior (constant), not the channel-shifted
577
+ // ior — only the refracted ray DIRECTION disperses, so the reflection term
578
+ // reflRad*fres is identical every frame while refrRad*mask*3 is the spectral
579
+ // estimator.
580
+ //
581
+ // OFF-PATH IDENTITY. uDispersion == 0 skips the channel pick entirely: it
582
+ // consumes NO rand() (so the RNG stream does not shift), leaves iorC == ior and
583
+ // chanMask == vec3(1), and the whole function reduces byte-for-byte to the
584
+ // pre-dispersion path.
585
+ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
545
586
  vec3 refl = glossyReflect(V, N, rough);
546
587
  vec3 reflRad = dot(refl, N) > 0.0
547
588
  ? traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl)
548
589
  : vec3(0.0);
549
590
 
550
- float eta = 1.0 / uIor;
591
+ // Per-frame spectral channel pick for the transmitted term (guarded so the
592
+ // off path consumes no rand()).
593
+ vec3 chanMask = vec3(1.0); // full colour (un-masked) when dispersion is off
594
+ float iorC = ior;
595
+ if (uDispersion > 0.0) {
596
+ int c = min(int(rand() * 3.0), 2); // uniform channel: 0 = R, 1 = G, 2 = B
597
+ // Normal dispersion: BLUE has the higher refractive index and bends most,
598
+ // red least. shift = (-1.0, 0.0, +1.0) * 0.5, indexed by channel:
599
+ // R = -0.5, G = 0, B = +0.5. uDispersion (0..0.5) scales the ior spread.
600
+ // (The original spec vector had the R/B signs reversed — audit-corrected.)
601
+ float shift = c == 0 ? -0.5 : (c == 2 ? 0.5 : 0.0);
602
+ iorC = ior * (1.0 + uDispersion * shift);
603
+ // Isolate channel c and weight x3: vec3(3,0,0) / (0,3,0) / (0,0,3). The
604
+ // mean over the three equally-likely picks is (1/3)(3,0,0)+... = (1,1,1),
605
+ // so E[masked refrRad] == refrRad. The OTHER channels are zero this frame.
606
+ chanMask = c == 0 ? vec3(3.0, 0.0, 0.0)
607
+ : c == 1 ? vec3(0.0, 3.0, 0.0)
608
+ : vec3(0.0, 0.0, 3.0);
609
+ }
610
+
611
+ float eta = 1.0 / iorC; // channel-shifted: drives the refraction bend
551
612
  vec3 rd = refract(V, N, eta);
552
- if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
553
- float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
613
+ if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
614
+ // Fresnel from the BASE ior so the reflection/refraction split is the same
615
+ // every frame (reflection stays full colour and un-dispersed). Equal to the
616
+ // original schlick(..., eta) when uDispersion == 0 (iorC == ior).
617
+ float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), 1.0 / ior);
554
618
 
555
619
  vec3 ro = P - N * (2.0 * uEps);
556
620
  vec3 refrRad;
@@ -563,7 +627,7 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
563
627
  vec3 xN = normalize(attr.xyz);
564
628
  if (dot(xN, rd) > 0.0) xN = -xN;
565
629
  vec3 xP = ro + rd * dist;
566
- vec3 rd2 = refract(rd, xN, uIor);
630
+ vec3 rd2 = refract(rd, xN, iorC); // same channel-shifted ior on exit
567
631
  if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
568
632
  refrRad = traceRadiance(xP - xN * uEps, rd2, true);
569
633
  } else {
@@ -571,7 +635,29 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
571
635
  ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
572
636
  : uEnvColor * uEnvIntensity;
573
637
  }
574
- return mix(refrRad, reflRad, fres);
638
+ // Mask ONLY the transmitted term to the chosen channel (full colour when
639
+ // dispersion is off); the reflection term is never masked.
640
+ return mix(refrRad * chanMask, reflRad, fres);
641
+ }
642
+
643
+ // Compact cold->hot ramp for the BVH-cost heatmap. Piecewise mix of five
644
+ // anchors (deep blue -> green -> yellow -> red -> white) over four equal
645
+ // segments — cheap, no textures, no extra samplers. t is the normalised cost
646
+ // (visit count * uCostScale), clamped to [0,1]; saturating at white = the most
647
+ // expensive pixels.
648
+ vec3 costPalette(float t) {
649
+ t = clamp(t, 0.0, 1.0);
650
+ const vec3 c0 = vec3(0.02, 0.05, 0.45); // cold: cheap (few boxes)
651
+ const vec3 c1 = vec3(0.05, 0.55, 0.25); // green
652
+ const vec3 c2 = vec3(0.95, 0.85, 0.10); // yellow
653
+ const vec3 c3 = vec3(0.90, 0.10, 0.05); // red
654
+ const vec3 c4 = vec3(1.00, 1.00, 1.00); // hot: expensive (many boxes)
655
+ float s = t * 4.0;
656
+ vec3 col = mix(c0, c1, clamp(s, 0.0, 1.0));
657
+ col = mix(col, c2, clamp(s - 1.0, 0.0, 1.0));
658
+ col = mix(col, c3, clamp(s - 2.0, 0.0, 1.0));
659
+ col = mix(col, c4, clamp(s - 3.0, 0.0, 1.0));
660
+ return col;
575
661
  }
576
662
 
577
663
  void main() {
@@ -599,6 +685,10 @@ void main() {
599
685
  float transmission = (matW >= 2.0 && matW < 4.0) ? clamp(matW - 2.0, 0.0, 1.0) : 0.0;
600
686
  float metal = matW < 2.0 ? matW : 0.0;
601
687
  float rough = clamp(wp.w - 1.0, 0.0, 1.0);
688
+ // Per-material IOR rides the [3,4) glass sub-band (full-transmission glass, see
689
+ // GBufferPass). Below 3 (partial glass) or non-glass, fall back to the global
690
+ // rt.ior uniform. material.ior wins whenever it was encoded. (Task 2)
691
+ float ior = (matW >= 3.0 && matW < 4.0) ? (1.0 + (matW - 3.0)) : uIor;
602
692
 
603
693
  // Cook-Torrance specular state for this primary surface. gWantSpec gates the
604
694
  // GGX term to PRIMARY direct lighting only (GI-bounce direct light, below,
@@ -608,6 +698,11 @@ void main() {
608
698
  gSpecRough = rough;
609
699
  gWantSpec = true;
610
700
 
701
+ // Reset the shadow-ray traversal-cost counter for this pixel. It accumulates
702
+ // across every occluded() call below (direct, GI, reflection, glass) and is
703
+ // read once at the end when uCostView is on (see the cost-heatmap branch).
704
+ gBvhVisits = 0;
705
+
611
706
  // --- direct lighting ---
612
707
  // ReSTIR: shade the reservoir's winner with one visibility ray (flat cost in
613
708
  // light count). Stochastic: one blind random sample. Full: one shadow ray
@@ -646,7 +741,10 @@ void main() {
646
741
  vec3 indirect = vec3(0.0);
647
742
  vec3 blendBehind = vec3(0.0);
648
743
  bool wantBehind = uBlendEnabled && blend;
649
- bool wantGI = uGIEnabled && !wantBehind
744
+ // uExternalGI (experimental ReSTIR GI): the GIReservoirPass supplies the
745
+ // bounce, so the inline GI ray is skipped — but the blend continuation is
746
+ // NOT GI and must keep tracing regardless.
747
+ bool wantGI = uGIEnabled && !uExternalGI && !wantBehind
650
748
  && (!uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0));
651
749
  if (wantBehind || wantGI) {
652
750
  vec3 Vv = normalize(P - uCameraPos);
@@ -685,7 +783,7 @@ void main() {
685
783
  // --- traced glass: Fresnel reflection + two-interface refraction ---
686
784
  if (uRefrEnabled && transmission > 0.001) {
687
785
  vec3 V = normalize(P - uCameraPos);
688
- sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough), transmission);
786
+ sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough, ior), transmission);
689
787
  }
690
788
 
691
789
  // --- alpha blend: straight-through view continuation ---
@@ -772,6 +870,17 @@ void main() {
772
870
  // the fresh sample is used as-is.
773
871
  vec3 blended = mix(history, sampleIrr, 1.0 / count);
774
872
  outIrradiance = vec4(blended, count);
873
+
874
+ // BVH traversal-cost heatmap (outputMode 7). Overwrite the accumulated
875
+ // lighting with the palette-mapped shadow-ray node-visit count for this pixel.
876
+ // Alpha is forced to 1.0 so temporal history never builds on the cost image
877
+ // (each frame is a fresh snapshot), and the specular attachment is cleared so
878
+ // the composite's cost branch shows the palette alone. Uniform branch: when
879
+ // uCostView is off this is skipped and the writes above stand unchanged.
880
+ if (uCostView) {
881
+ outIrradiance = vec4(costPalette(float(gBvhVisits) * uCostScale), 1.0);
882
+ outSpecular = vec4(0.0);
883
+ }
775
884
  }
776
885
  `;
777
886
 
@@ -923,6 +1032,7 @@ export class RTLightingPass {
923
1032
  uRefrEnabled: { value: true },
924
1033
  uBlendEnabled: { value: true },
925
1034
  uIor: { value: 1.5 },
1035
+ uDispersion: { value: 0 },
926
1036
  uLightStochastic: { value: false },
927
1037
  uGIHalfRate: { value: false },
928
1038
  uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
@@ -930,6 +1040,9 @@ export class RTLightingPass {
930
1040
  uFrame: { value: 0 },
931
1041
  uEps: { value: 1e-3 },
932
1042
  uGIEnabled: { value: true },
1043
+ uExternalGI: { value: false },
1044
+ uCostView: { value: false },
1045
+ uCostScale: { value: 1 / 96 },
933
1046
  uSkyEnabled: { value: false },
934
1047
  uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
935
1048
  uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
@@ -7,6 +7,7 @@ import { CompositePass } from "./CompositePass.js";
7
7
  import { TAAPass } from "./TAAPass.js";
8
8
  import { VolumetricPass } from "./VolumetricPass.js";
9
9
  import { RestirPass } from "./RestirPass.js";
10
+ import { GIReservoirPass } from "./GIReservoirPass.js";
10
11
  import { CopyPass } from "./CopyPass.js";
11
12
 
12
13
  // Van der Corput / Halton radical inverse — deterministic low-discrepancy
@@ -423,8 +424,15 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
423
424
  this.compiled = null;
424
425
  this.frame = 0;
425
426
 
426
- /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular */
427
+ /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular, 7 bvh cost */
427
428
  this.outputMode = 0;
429
+ /**
430
+ * BVH-cost heatmap scale (outputMode 7): the per-pixel shadow-ray node-visit
431
+ * count is multiplied by this before the palette, so 1/costScale visits map
432
+ * to the hot (white) end. Default 1/96 — ~96 visits saturate. Live-tunable
433
+ * (the demo's "cost scale" slider drives it).
434
+ */
435
+ this.costScale = options.costScale ?? 1 / 96;
428
436
  /** Environment (sky) color used for GI rays that miss + composite background. */
429
437
  this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
430
438
  this.envIntensity = options.envIntensity ?? 1.0;
@@ -484,6 +492,17 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
484
492
  this.transparency = options.transparency ?? true;
485
493
  /** Index of refraction used for transmissive surfaces. */
486
494
  this.ior = options.ior ?? 1.5;
495
+ /**
496
+ * Chromatic dispersion strength for glass (0..0.5, clamped on upload,
497
+ * default 0 = off). Splits refracted white light into a spectrum: each
498
+ * frame every glass pixel estimates ONE colour channel through a
499
+ * channel-shifted ior and the temporal accumulator blends the three into a
500
+ * rainbow (stochastic spectral sampling — no extra rays). It shimmers
501
+ * slightly while converging, so it needs temporal accumulation to settle.
502
+ * Global control only for now (there is no free G-buffer channel for a
503
+ * per-material MeshPhysicalMaterial.dispersion).
504
+ */
505
+ this.dispersion = options.dispersion ?? 0;
487
506
  /**
488
507
  * One stochastic direct shadow ray per pixel per frame (source picked at
489
508
  * random) instead of one per light — the biggest ray-count lever for
@@ -552,6 +571,13 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
552
571
  this.taa = options.taa ?? true;
553
572
  /** Fresh-sample weight in the TAA blend (lower = smoother/more AA, more lag). */
554
573
  this.taaBlend = options.taaBlend ?? 0.1;
574
+ /**
575
+ * Scales the TAA sub-pixel jitter amplitude. Set this to your canvas scale
576
+ * when you render a reduced drawing buffer CSS-stretched to the screen
577
+ * (canvasScaleHook), so the jitter stays constant in SCREEN pixels instead
578
+ * of being magnified by the stretch (visible wobble at low quality).
579
+ */
580
+ this.taaJitterScale = options.taaJitterScale ?? 1;
555
581
 
556
582
  /**
557
583
  * Volumetric lighting — real "god rays": single-scatter fog integrated
@@ -582,6 +608,45 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
582
608
  this.restir = options.restir ?? true;
583
609
  this.restirPass = new RestirPass(this._scaledW, this._scaledH);
584
610
 
611
+ /**
612
+ * EXPERIMENTAL — ReSTIR GI (v1, temporal-only): per-pixel reservoirs reuse
613
+ * the 1-bounce global-illumination sample across frames (at the reprojected
614
+ * same-surface point, no spatial reuse / no Jacobian). Runs in a standalone
615
+ * pass with its own sampler budget; when on, the lighting pass skips its
616
+ * inline GI trace and this pass's resolved GI is added at the denoise stage.
617
+ * Only meaningful when `gi` is on, and injected via the à-trous denoise, so
618
+ * it requires `denoise` (denoiseIterations >= 1). Default OFF. Its mean
619
+ * matches the inline GI path — see GIReservoirPass. Live-toggleable.
620
+ */
621
+ this.restirGI = options.restirGI ?? false;
622
+ /** Temporal M-cap for the ReSTIR GI reservoir (staleness limit). */
623
+ this.restirGIMCap = options.restirGIMCap ?? 20;
624
+ /**
625
+ * ReSTIR GI (v2) spatial-reuse taps per frame, taken after the temporal
626
+ * merge from the previous frame's reservoirs (reconnection-Jacobian
627
+ * reweighted, with a final visibility ray). Clamped to 0..4; `0` reproduces
628
+ * the v1 temporal-only behaviour. Default 2.
629
+ */
630
+ // Default 1 (not 2): each reconnection tap adds its own estimator noise;
631
+ // one tap keeps most of the disocclusion win at half the artifact surface
632
+ // (tuned on-device — raise it for scenes with heavy camera motion).
633
+ this.restirGISpatialTaps = options.restirGISpatialTaps ?? 1;
634
+ /**
635
+ * EXPERIMENTAL — ReSTIR GI reservoir-sample validation period. Every frame a
636
+ * rotating 1-in-N subset of pixels (decorrelated by a per-pixel hash) re-aims
637
+ * its ONE candidate ray at the reservoir's STORED hit instead of a fresh
638
+ * cosine bounce and re-shades it; the reservoir is killed (so fresh candidates
639
+ * rebuild) when the geometry moved or the re-shaded target collapsed to
640
+ * near-black (a light switched off), and left untouched otherwise. This reuses
641
+ * the existing candidate trace (no extra bounce rays) and is the fix for stale
642
+ * bounce light: a switched-off light stops haunting the reservoir instead of
643
+ * fading slowly, while a static scene does not drift. `0` disables it
644
+ * (byte-identical to before the feature); default 8.
645
+ */
646
+ this.restirGIValidate = options.restirGIValidate ?? 8;
647
+ this.giReservoirPass = new GIReservoirPass(this._scaledW, this._scaledH);
648
+ this._giMissWarned = false;
649
+
585
650
  /** Distance fog (composited in linear space before tonemap). */
586
651
  this.fog = {
587
652
  enabled: options.fog?.enabled ?? false,
@@ -703,6 +768,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
703
768
  this.rtPass.setCompiledScene(this.compiled);
704
769
  this.volumetricPass.setCompiledScene(this.compiled);
705
770
  this.restirPass.setCompiledScene(this.compiled);
771
+ this.giReservoirPass.setCompiledScene(this.compiled);
706
772
  this.resetAccumulation();
707
773
  return this.compiled;
708
774
  }
@@ -728,6 +794,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
728
794
  this.rtPass.setCompiledScene(this.compiled);
729
795
  this.volumetricPass.setCompiledScene(this.compiled);
730
796
  this.restirPass.setCompiledScene(this.compiled);
797
+ this.giReservoirPass.setCompiledScene(this.compiled);
731
798
  }
732
799
 
733
800
  resetAccumulation() {
@@ -831,6 +898,11 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
831
898
  // clear them.
832
899
  this.restirPass.setSize(sw, sh);
833
900
  this.restirPass.clearHistory(this.renderer);
901
+ // Reservoir GI history is packed (hit position + M + radiance + W) —
902
+ // invalid to linearly resample — but reconverges in a few frames, so
903
+ // reallocate and clear like the DI reservoirs.
904
+ this.giReservoirPass.setSize(sw, sh);
905
+ this.giReservoirPass.clearHistory(this.renderer);
834
906
  }
835
907
 
836
908
  // Full-res / canvas-res targets: only touched on a real canvas resize (a
@@ -976,8 +1048,15 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
976
1048
  // too — otherwise the raw buffers visibly shake.
977
1049
  if (this.taa && this.outputMode === 0) {
978
1050
  this._jitterIndex = (this._jitterIndex + 1) % 16;
979
- const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 / this._width;
980
- const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 / this._height;
1051
+ // taaJitterScale: when the app renders a REDUCED drawing buffer and CSS-
1052
+ // stretches it to the screen (the canvas-scale ladder), a half-buffer-pixel
1053
+ // jitter is magnified by the stretch and reads as visible screen wobble.
1054
+ // The app sets this to its canvas scale (see canvasScaleHook) so the
1055
+ // jitter stays constant in SCREEN pixels — slightly less sub-pixel AA
1056
+ // coverage at low canvas scales, in exchange for a steady image.
1057
+ const js = this.taaJitterScale;
1058
+ const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 * js / this._width;
1059
+ const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 * js / this._height;
981
1060
  proj.elements[8] += jx;
982
1061
  proj.elements[9] += jy;
983
1062
  // Where this jitter moves the image, in UV space: elements[8/9] multiply
@@ -999,6 +1078,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
999
1078
  this.rtPass.clearHistory(this.renderer);
1000
1079
  this.volumetricPass.clearHistory(this.renderer);
1001
1080
  this.restirPass.clearHistory(this.renderer);
1081
+ this.giReservoirPass.clearHistory(this.renderer);
1002
1082
  this._needsClear = false;
1003
1083
  }
1004
1084
 
@@ -1010,17 +1090,35 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1010
1090
  rtU.uEnvColor.value.copy(this.envColor);
1011
1091
  rtU.uEnvIntensity.value = this.envIntensity;
1012
1092
  rtU.uEps.value = this.eps;
1093
+ rtU.uCostView.value = this.outputMode === 7;
1094
+ rtU.uCostScale.value = this.costScale;
1013
1095
  rtU.uTemporalReprojection.value = this.temporalReprojection;
1014
1096
  rtU.uMaxHistory.value = this.maxHistory;
1015
1097
  rtU.uFireflyClamp.value = this.fireflyClamp > 0 ? this.fireflyClamp : 1e6;
1016
1098
  rtU.uGIEnabled.value = this.gi;
1017
1099
  rtU.uGIHalfRate.value = this.giHalfRate;
1100
+ // ReSTIR GI (experimental) supplies the 1-bounce indirect externally when
1101
+ // on; the lighting pass then skips its inline GI trace so it isn't double
1102
+ // counted. It's injected at the denoise stage, so it needs denoise on.
1103
+ const giExternal =
1104
+ this.restirGI && this.gi && this.denoise && this.denoiseIterations > 0;
1105
+ rtU.uExternalGI.value = giExternal;
1106
+ if (this.restirGI && this.gi && !giExternal && !this._giMissWarned) {
1107
+ console.info(
1108
+ "[three-realtime-rt] restirGI is on but denoise is off — ReSTIR GI is " +
1109
+ "injected during the à-trous denoise, so enable denoise " +
1110
+ "(denoiseIterations >= 1) to see its contribution."
1111
+ );
1112
+ this._giMissWarned = true;
1113
+ }
1114
+ if (giExternal) this._giMissWarned = false;
1018
1115
  rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
1019
1116
  rtU.uEmissiveCDF.value = this.emissiveImportance;
1020
1117
  rtU.uReflEnabled.value = this.reflections;
1021
1118
  rtU.uRefrEnabled.value = this.refraction;
1022
1119
  rtU.uBlendEnabled.value = this.transparency;
1023
1120
  rtU.uIor.value = this.ior;
1121
+ rtU.uDispersion.value = Math.min(0.5, Math.max(0, this.dispersion));
1024
1122
  rtU.uLightStochastic.value = this.stochasticLights;
1025
1123
  rtU.uSkyEnabled.value = this.sky.enabled;
1026
1124
  rtU.uSunDir.value.copy(this.sky.sunDir);
@@ -1048,17 +1146,55 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1048
1146
  this.eps
1049
1147
  );
1050
1148
  }
1149
+ // 2b. ReSTIR GI reservoirs (experimental). Runs after the lighting pass's
1150
+ // inline GI is skipped (uExternalGI); the resolved GI is added at denoise.
1151
+ let giTex = null;
1152
+ if (giExternal) {
1153
+ this.giReservoirPass.setEmissiveCount(
1154
+ this.emissiveNEE ? this.compiled.emissiveTriCount : 0
1155
+ );
1156
+ giTex = this.giReservoirPass.render(
1157
+ this.renderer,
1158
+ this.gbuffer,
1159
+ this._prevViewProj,
1160
+ this._camWorldPos,
1161
+ this.frame,
1162
+ this.eps,
1163
+ {
1164
+ fireflyClamp: this.fireflyClamp > 0 ? this.fireflyClamp : 1e6,
1165
+ mCap: this.restirGIMCap,
1166
+ spatialTaps: Math.max(0, Math.min(4, this.restirGISpatialTaps | 0)),
1167
+ validateInterval: Math.max(0, this.restirGIValidate | 0),
1168
+ emissiveCDF: this.emissiveImportance,
1169
+ envColor: this.envColor,
1170
+ envIntensity: this.envIntensity,
1171
+ skyEnabled: this.sky.enabled,
1172
+ sunDir: this.sky.sunDir,
1173
+ sunColor: this.sky.sunColor,
1174
+ skyZenith: this.sky.zenith,
1175
+ skyHorizon: this.sky.horizon,
1176
+ skyIntensity: this.sky.intensity,
1177
+ }
1178
+ );
1179
+ }
1180
+
1051
1181
  let { irradiance, specular } = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
1052
1182
 
1053
- // 3. denoise (display-only: history keeps accumulating raw samples)
1054
- if (this.denoise && this.denoiseIterations > 0) {
1183
+ // 3. denoise (display-only: history keeps accumulating raw samples). The
1184
+ // experimental ReSTIR GI (giTex) is added on the first à-trous iteration —
1185
+ // downstream of the lighting pass's temporal history, so it never
1186
+ // double-counts through it. The bvh-cost heatmap (mode 7) is a per-pixel
1187
+ // debug signal, not lighting — the edge-aware blur would smear its bands,
1188
+ // so it bypasses the denoiser (which also keeps the GI add out of mode 7).
1189
+ if (this.denoise && this.denoiseIterations > 0 && this.outputMode !== 7) {
1055
1190
  irradiance = this.denoisePass.render(
1056
1191
  this.renderer,
1057
1192
  irradiance,
1058
1193
  this.gbuffer,
1059
1194
  this._camWorldPos,
1060
1195
  this.eps,
1061
- this.denoiseIterations
1196
+ this.denoiseIterations,
1197
+ giTex
1062
1198
  );
1063
1199
  }
1064
1200
 
@@ -1179,6 +1315,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1179
1315
  this.taaPass.dispose();
1180
1316
  this.volumetricPass.dispose();
1181
1317
  this.restirPass.dispose();
1318
+ this.giReservoirPass.dispose();
1182
1319
  this._sceneColor.dispose();
1183
1320
  this._copyPass.dispose();
1184
1321
  if (this.compiled) this.compiled.dispose();