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;
@@ -66,6 +67,7 @@ uniform bool uReflEnabled; // traced reflections on metallic surfaces
66
67
  uniform bool uRefrEnabled; // traced refraction on transmissive surfaces
67
68
  uniform bool uBlendEnabled; // straight-through view continuation on blend surfaces
68
69
  uniform float uIor; // index of refraction for transmissive materials
70
+ uniform float uDispersion; // chromatic dispersion strength for glass (0 = off)
69
71
  uniform bool uLightStochastic; // 1 direct shadow ray/pixel/frame instead of 1/light
70
72
  uniform bool uRestirEnabled; // shade the reservoir winner instead of sampling
71
73
  uniform bool uGIHalfRate; // GI ray on alternating checkerboard, doubled
@@ -476,7 +478,8 @@ vec3 sampleOneAny(vec3 P, vec3 N) {
476
478
  // Incoming radiance along rd: trace, shade the hit with direct + NEE lighting,
477
479
  // sky/env on a miss. Specular rays keep emitter emission on hit (NEE at the ray
478
480
  // origin cannot cover a specular path); diffuse GI rays drop it for NEE-listed
479
- // (static) emitters so that light isn't counted twice.
481
+ // emitters (static AND dynamic — dynamic emitters now join the NEE table, their
482
+ // rows refreshed each frame) so that light isn't counted twice.
480
483
  vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
481
484
  uvec4 fi; vec3 bary; float dist; bool isDyn;
482
485
  if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
@@ -493,7 +496,7 @@ vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
493
496
  if (dot(hN, rd) > 0.0) hN = -hN;
494
497
  vec3 hP = ro + rd * dist;
495
498
  vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
496
- vec3 hLe = (!specular && uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
499
+ vec3 hLe = (!specular && uEmissiveCount > 0) ? vec3(0.0) : hEmissive;
497
500
  return hLe + hAlbedo * Ld * (1.0 / PI);
498
501
  }
499
502
 
@@ -553,16 +556,66 @@ vec3 analyticGlint(vec3 P, vec3 refl) {
553
556
 
554
557
  // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
555
558
  // refraction (enter at P, march to the exit surface, refract again).
559
+ //
560
+ // CHROMATIC DISPERSION (stochastic spectral sampling). Real glass has a
561
+ // wavelength-dependent ior, so white light splits into a spectrum (a diamond
562
+ // throws a rainbow). Tracing one refraction path per colour would cost three
563
+ // traceRadiance calls, but the Metal call-site budget (see the note at the
564
+ // unified secondary-ray site) forbids a fourth traceRadiance anywhere in this
565
+ // shader. Instead, when uDispersion > 0 each frame this pixel picks ONE colour
566
+ // channel c in R,G,B uniformly and traces the SAME single refraction path with
567
+ // a channel-shifted ior. The refracted radiance is then isolated to channel c
568
+ // and multiplied by 3 (to compensate the 1-of-3 pick); the temporal EMA
569
+ // averages the three per-channel estimates into a full-spectrum, dispersed
570
+ // refraction — zero extra rays, zero new call sites, unbiased in the mean. It
571
+ // therefore shimmers slightly while converging.
572
+ //
573
+ // THE MIX SPLIT. The return is mix(refrRad, reflRad, fres) = refrRad*(1-fres)
574
+ // + reflRad*fres. Only the TRANSMITTED half (refrRad) carries the channel
575
+ // mask; the reflection half (reflRad) is NOT dispersed and stays full colour
576
+ // EVERY frame. To keep the reflection deterministic frame-to-frame, the
577
+ // Fresnel weight is taken from the BASE ior (constant), not the channel-shifted
578
+ // ior — only the refracted ray DIRECTION disperses, so the reflection term
579
+ // reflRad*fres is identical every frame while refrRad*mask*3 is the spectral
580
+ // estimator.
581
+ //
582
+ // OFF-PATH IDENTITY. uDispersion == 0 skips the channel pick entirely: it
583
+ // consumes NO rand() (so the RNG stream does not shift), leaves iorC == ior and
584
+ // chanMask == vec3(1), and the whole function reduces byte-for-byte to the
585
+ // pre-dispersion path.
556
586
  vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
557
587
  vec3 refl = glossyReflect(V, N, rough);
558
588
  vec3 reflRad = dot(refl, N) > 0.0
559
589
  ? traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl)
560
590
  : vec3(0.0);
561
591
 
562
- float eta = 1.0 / ior;
592
+ // Per-frame spectral channel pick for the transmitted term (guarded so the
593
+ // off path consumes no rand()).
594
+ vec3 chanMask = vec3(1.0); // full colour (un-masked) when dispersion is off
595
+ float iorC = ior;
596
+ if (uDispersion > 0.0) {
597
+ int c = min(int(rand() * 3.0), 2); // uniform channel: 0 = R, 1 = G, 2 = B
598
+ // Normal dispersion: BLUE has the higher refractive index and bends most,
599
+ // red least. shift = (-1.0, 0.0, +1.0) * 0.5, indexed by channel:
600
+ // R = -0.5, G = 0, B = +0.5. uDispersion (0..0.5) scales the ior spread.
601
+ // (The original spec vector had the R/B signs reversed — audit-corrected.)
602
+ float shift = c == 0 ? -0.5 : (c == 2 ? 0.5 : 0.0);
603
+ iorC = ior * (1.0 + uDispersion * shift);
604
+ // Isolate channel c and weight x3: vec3(3,0,0) / (0,3,0) / (0,0,3). The
605
+ // mean over the three equally-likely picks is (1/3)(3,0,0)+... = (1,1,1),
606
+ // so E[masked refrRad] == refrRad. The OTHER channels are zero this frame.
607
+ chanMask = c == 0 ? vec3(3.0, 0.0, 0.0)
608
+ : c == 1 ? vec3(0.0, 3.0, 0.0)
609
+ : vec3(0.0, 0.0, 3.0);
610
+ }
611
+
612
+ float eta = 1.0 / iorC; // channel-shifted: drives the refraction bend
563
613
  vec3 rd = refract(V, N, eta);
564
- if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
565
- float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
614
+ if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
615
+ // Fresnel from the BASE ior so the reflection/refraction split is the same
616
+ // every frame (reflection stays full colour and un-dispersed). Equal to the
617
+ // original schlick(..., eta) when uDispersion == 0 (iorC == ior).
618
+ float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), 1.0 / ior);
566
619
 
567
620
  vec3 ro = P - N * (2.0 * uEps);
568
621
  vec3 refrRad;
@@ -575,7 +628,7 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
575
628
  vec3 xN = normalize(attr.xyz);
576
629
  if (dot(xN, rd) > 0.0) xN = -xN;
577
630
  vec3 xP = ro + rd * dist;
578
- vec3 rd2 = refract(rd, xN, ior);
631
+ vec3 rd2 = refract(rd, xN, iorC); // same channel-shifted ior on exit
579
632
  if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
580
633
  refrRad = traceRadiance(xP - xN * uEps, rd2, true);
581
634
  } else {
@@ -583,7 +636,9 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
583
636
  ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
584
637
  : uEnvColor * uEnvIntensity;
585
638
  }
586
- return mix(refrRad, reflRad, fres);
639
+ // Mask ONLY the transmitted term to the chosen channel (full colour when
640
+ // dispersion is off); the reflection term is never masked.
641
+ return mix(refrRad * chanMask, reflRad, fres);
587
642
  }
588
643
 
589
644
  // Compact cold->hot ramp for the BVH-cost heatmap. Piecewise mix of five
@@ -941,6 +996,10 @@ export class RTLightingPass {
941
996
  this.specB = specMRT ? this._makeSpecTarget(width, height) : null;
942
997
 
943
998
  this.material = new THREE.ShaderMaterial({
999
+ // Stable program name for compile-failure self-diagnosis: this is the
1000
+ // CORE lighting megakernel — a link failure here has no fallback (see
1001
+ // RealtimeRaytracer._passClass -> coreFailure).
1002
+ name: "rt:lighting",
944
1003
  glslVersion: THREE.GLSL3,
945
1004
  vertexShader: fullscreenVert,
946
1005
  fragmentShader: specMRT
@@ -978,6 +1037,7 @@ export class RTLightingPass {
978
1037
  uRefrEnabled: { value: true },
979
1038
  uBlendEnabled: { value: true },
980
1039
  uIor: { value: 1.5 },
1040
+ uDispersion: { value: 0 },
981
1041
  uLightStochastic: { value: false },
982
1042
  uGIHalfRate: { value: false },
983
1043
  uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
@@ -1002,6 +1062,9 @@ export class RTLightingPass {
1002
1062
  // Specular temporal accumulation program (its own sampler budget — well
1003
1063
  // clear of the lighting pass's 16-sampler ceiling).
1004
1064
  this.specMaterial = new THREE.ShaderMaterial({
1065
+ // Optional additive specular buffer — a link failure degrades to the
1066
+ // Lambert-only look (RealtimeRaytracer disables `specular`), image stays lit.
1067
+ name: "rt:specular",
1005
1068
  glslVersion: THREE.GLSL3,
1006
1069
  vertexShader: fullscreenVert,
1007
1070
  fragmentShader: specAccumFrag,
@@ -1026,6 +1089,9 @@ export class RTLightingPass {
1026
1089
  // buffers. In single-target fallback the second output collapses the same
1027
1090
  // way as the lighting shader's.
1028
1091
  this.carryMaterial = new THREE.ShaderMaterial({
1092
+ // Resize-only history-carry blit; a link failure is non-fatal (history is
1093
+ // not carried across a resolution step) so it classifies as auxiliary.
1094
+ name: "rt:history-carry",
1029
1095
  glslVersion: THREE.GLSL3,
1030
1096
  vertexShader: fullscreenVert,
1031
1097
  fragmentShader: specMRT
@@ -1065,7 +1131,7 @@ export class RTLightingPass {
1065
1131
  t.texture.generateMipmaps = false;
1066
1132
  return t;
1067
1133
  }
1068
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, opts);
1134
+ const t = makeMRT(width, height, 2, opts);
1069
1135
  for (const tex of t.texture) tex.generateMipmaps = false;
1070
1136
  return t;
1071
1137
  }
@@ -9,6 +9,7 @@ import { VolumetricPass } from "./VolumetricPass.js";
9
9
  import { RestirPass } from "./RestirPass.js";
10
10
  import { GIReservoirPass } from "./GIReservoirPass.js";
11
11
  import { CopyPass } from "./CopyPass.js";
12
+ import { makeMRT } from "./mrtCompat.js";
12
13
 
13
14
  // Van der Corput / Halton radical inverse — deterministic low-discrepancy
14
15
  // sub-pixel offsets for temporal jitter.
@@ -256,7 +257,7 @@ export class RealtimeRaytracer {
256
257
  let mrt, out, mat, copy, quad, scene2, cam;
257
258
  const prevTarget = renderer.getRenderTarget();
258
259
  try {
259
- mrt = new THREE.WebGLMultipleRenderTargets(2, 2, 2, {
260
+ mrt = makeMRT(2, 2, 2, {
260
261
  format: THREE.RGBAFormat,
261
262
  type: THREE.HalfFloatType,
262
263
  depthBuffer: false,
@@ -337,6 +338,17 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
337
338
  // frames of confidence". See RTLightingPass.resizeCarry.
338
339
  static HISTORY_CARRY_FRAMES = 8;
339
340
 
341
+ // Compile-failure diagnosis polling window (see _scanPrograms). three checks
342
+ // link status lazily at a program's first USE, and under
343
+ // KHR_parallel_shader_compile it may hold a mesh back for a few frames until
344
+ // its program is ready — so a single frame-1 scan can miss a failure. Poll
345
+ // from frame 1 up to DIAG_WINDOW_FRAMES, and early-out once the set of rt:*
346
+ // programs has been stable (no new programs, no unhandled failures) for
347
+ // DIAG_STABLE_FRAMES past a DIAG_MIN_FRAMES warmup floor.
348
+ static DIAG_MIN_FRAMES = 8;
349
+ static DIAG_STABLE_FRAMES = 4;
350
+ static DIAG_WINDOW_FRAMES = 45;
351
+
340
352
  constructor(renderer, options = {}) {
341
353
  this.renderer = renderer;
342
354
 
@@ -354,6 +366,12 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
354
366
  );
355
367
  this.compiled = null;
356
368
  this.frame = 0;
369
+ // Status surface (see the supported path for the shapes). Unsupported =
370
+ // the RT pipeline is not operational at all; `supported` is the primary
371
+ // signal, but status is kept consistent so integrators can read one field.
372
+ this.compileError = null;
373
+ this.status = { ok: false, disabled: [], coreFailure: null };
374
+ this._diagDone = true;
357
375
  return;
358
376
  }
359
377
 
@@ -492,6 +510,17 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
492
510
  this.transparency = options.transparency ?? true;
493
511
  /** Index of refraction used for transmissive surfaces. */
494
512
  this.ior = options.ior ?? 1.5;
513
+ /**
514
+ * Chromatic dispersion strength for glass (0..0.5, clamped on upload,
515
+ * default 0 = off). Splits refracted white light into a spectrum: each
516
+ * frame every glass pixel estimates ONE colour channel through a
517
+ * channel-shifted ior and the temporal accumulator blends the three into a
518
+ * rainbow (stochastic spectral sampling — no extra rays). It shimmers
519
+ * slightly while converging, so it needs temporal accumulation to settle.
520
+ * Global control only for now (there is no free G-buffer channel for a
521
+ * per-material MeshPhysicalMaterial.dispersion).
522
+ */
523
+ this.dispersion = options.dispersion ?? 0;
495
524
  /**
496
525
  * One stochastic direct shadow ray per pixel per frame (source picked at
497
526
  * random) instead of one per light — the biggest ray-count lever for
@@ -610,6 +639,29 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
610
639
  this.restirGI = options.restirGI ?? false;
611
640
  /** Temporal M-cap for the ReSTIR GI reservoir (staleness limit). */
612
641
  this.restirGIMCap = options.restirGIMCap ?? 20;
642
+ /**
643
+ * ReSTIR GI (v2) spatial-reuse taps per frame, taken after the temporal
644
+ * merge from the previous frame's reservoirs (reconnection-Jacobian
645
+ * reweighted, with a final visibility ray). Clamped to 0..4; `0` reproduces
646
+ * the v1 temporal-only behaviour. Default 2.
647
+ */
648
+ // Default 1 (not 2): each reconnection tap adds its own estimator noise;
649
+ // one tap keeps most of the disocclusion win at half the artifact surface
650
+ // (tuned on-device — raise it for scenes with heavy camera motion).
651
+ this.restirGISpatialTaps = options.restirGISpatialTaps ?? 1;
652
+ /**
653
+ * EXPERIMENTAL — ReSTIR GI reservoir-sample validation period. Every frame a
654
+ * rotating 1-in-N subset of pixels (decorrelated by a per-pixel hash) re-aims
655
+ * its ONE candidate ray at the reservoir's STORED hit instead of a fresh
656
+ * cosine bounce and re-shades it; the reservoir is killed (so fresh candidates
657
+ * rebuild) when the geometry moved or the re-shaded target collapsed to
658
+ * near-black (a light switched off), and left untouched otherwise. This reuses
659
+ * the existing candidate trace (no extra bounce rays) and is the fix for stale
660
+ * bounce light: a switched-off light stops haunting the reservoir instead of
661
+ * fading slowly, while a static scene does not drift. `0` disables it
662
+ * (byte-identical to before the feature); default 8.
663
+ */
664
+ this.restirGIValidate = options.restirGIValidate ?? 8;
613
665
  this.giReservoirPass = new GIReservoirPass(this._scaledW, this._scaledH);
614
666
  this._giMissWarned = false;
615
667
 
@@ -656,6 +708,150 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
656
708
  );
657
709
  this._renderScale = 0.375;
658
710
  }
711
+
712
+ // ---- compile-failure status surface -----------------------------------
713
+ // The pipeline is a stack of ShaderMaterial passes; a program that fails to
714
+ // LINK renders black with no exception (three logs to the console and sets
715
+ // program.diagnostics.runnable=false, but rendering proceeds). Before this,
716
+ // a broken pass looked identical to `supported:false` from the outside — the
717
+ // failure that shipped the r166+ black image. These two fields let an
718
+ // integrator render honestly ("raster (reason)") instead of guessing:
719
+ //
720
+ // compileError : string | null
721
+ // First/most-severe failure summary ("rt:lighting: <driver log>"), or
722
+ // null while every rt:* pass is compiling clean.
723
+ // status : { ok, disabled, coreFailure }
724
+ // ok false once ANY rt:* pass failed to link (a core pass, or
725
+ // a feature that was auto-disabled). true = pipeline is
726
+ // running as intended.
727
+ // disabled [{ pass, feature, reason }] — optional features turned
728
+ // off to keep the image lit (e.g. taa, denoise, restir).
729
+ // coreFailure string | null — a core pass (gbuffer/lighting/composite)
730
+ // failed and has no fallback; the image is black-but-diagnosed.
731
+ this.compileError = null;
732
+ this.status = { ok: true, disabled: [], coreFailure: null };
733
+ this._diagDone = false; // set once the polling window settles
734
+ this._diagFrames = 0; // rendered frames scanned so far
735
+ this._diagStable = 0; // consecutive scans with an unchanged rt:* program set
736
+ this._diagSig = ""; // signature of the rt:* program-name set last scan
737
+ this._diagHandled = new Set(); // rt:* names already acted on (warn-once)
738
+ this._compileErrSev = -1; // severity behind the current compileError (2/1/0)
739
+ }
740
+
741
+ // Classify an rt:* pass program by how a LINK failure degrades. CORE passes
742
+ // have no fallback (record and keep rendering the black result so it is
743
+ // DIAGNOSED, not silent). Optional passes map to the EXACT runtime toggle that
744
+ // already gates them, so disabling one keeps the image lit. Unknown rt:* names
745
+ // (history-carry blits) are auxiliary: non-fatal, warn only. Returns one of
746
+ // { core:true } | { feature, disable } | { aux:true }.
747
+ _passClass(name) {
748
+ switch (name) {
749
+ case "rt:gbuffer":
750
+ case "rt:lighting":
751
+ case "rt:composite":
752
+ return { core: true };
753
+ case "rt:restir-temporal":
754
+ case "rt:restir-spatial":
755
+ return { feature: "restir", disable: () => { this.restir = false; } };
756
+ case "rt:gi-reservoir":
757
+ return { feature: "restirGI", disable: () => { this.restirGI = false; } };
758
+ case "rt:denoise":
759
+ return { feature: "denoise", disable: () => { this.denoise = false; } };
760
+ case "rt:volumetric":
761
+ return { feature: "volumetric", disable: () => { this.volumetric.enabled = false; } };
762
+ case "rt:taa":
763
+ case "rt:taa-copy":
764
+ return { feature: "taa", disable: () => { this.taa = false; } };
765
+ case "rt:specular":
766
+ return { feature: "specular", disable: () => { this.specular = false; } };
767
+ default:
768
+ return { aux: true };
769
+ }
770
+ }
771
+
772
+ // Compact one-line driver message from three's program.diagnostics. The
773
+ // GLSL-frontend error lives in the fragment (or vertex) shader log; programLog
774
+ // is the linker fallback. First line, capped, so it fits a console.warn / a UI.
775
+ _diagLog(diag) {
776
+ const pick = [
777
+ diag && diag.fragmentShader && diag.fragmentShader.log,
778
+ diag && diag.vertexShader && diag.vertexShader.log,
779
+ diag && diag.programLog,
780
+ ].find((s) => s && s.trim());
781
+ return (pick || "(no driver log)").trim().split("\n")[0].slice(0, 200);
782
+ }
783
+
784
+ // Keep compileError at the FIRST failure of the HIGHEST severity seen
785
+ // (core 2 > feature 1 > aux 0): a later core failure overrides an earlier
786
+ // feature summary, but two failures of equal severity keep the first.
787
+ _noteCompileError(summary, severity) {
788
+ if (severity > this._compileErrSev) {
789
+ this.compileError = summary;
790
+ this._compileErrSev = severity;
791
+ }
792
+ }
793
+
794
+ // Act on one failed rt:* program (called at most once per pass name).
795
+ _handleFailedProgram(name, diag) {
796
+ const log = this._diagLog(diag);
797
+ const cls = this._passClass(name);
798
+ const summary = `${name}: ${log}`;
799
+ this.status.ok = false;
800
+ if (cls.core) {
801
+ if (!this.status.coreFailure) this.status.coreFailure = summary;
802
+ this._noteCompileError(summary, 2);
803
+ console.warn(
804
+ `three-realtime-rt: core pass ${name} failed to link — the image will ` +
805
+ `be black (no fallback for a core pass). Driver log: ${log}`
806
+ );
807
+ } else if (cls.feature) {
808
+ cls.disable();
809
+ this.status.disabled.push({ pass: name, feature: cls.feature, reason: log });
810
+ this._noteCompileError(summary, 1);
811
+ console.warn(
812
+ `three-realtime-rt: pass ${name} failed to link — auto-disabled ` +
813
+ `"${cls.feature}" to keep the image lit. Driver log: ${log}`
814
+ );
815
+ } else {
816
+ this._noteCompileError(summary, 0);
817
+ console.warn(
818
+ `three-realtime-rt: auxiliary pass ${name} failed to link (non-fatal — ` +
819
+ `resize history is not carried). Driver log: ${log}`
820
+ );
821
+ }
822
+ }
823
+
824
+ // Scan renderer.info.programs for failed rt:* pass programs. Called each frame
825
+ // until the window settles (see the DIAG_* constants). Cheap: ~a dozen entries,
826
+ // string prefix check, warn-once via _diagHandled.
827
+ _scanPrograms() {
828
+ if (this._diagDone) return;
829
+ const programs = this.renderer.info && this.renderer.info.programs;
830
+ if (!programs) { this._diagDone = true; return; }
831
+ this._diagFrames++;
832
+ let names = "";
833
+ for (const p of programs) {
834
+ const name = p && p.name;
835
+ if (!name || name.slice(0, 3) !== "rt:") continue;
836
+ names += name + "|";
837
+ const diag = p.diagnostics; // set at first USE; runnable:false = link failed
838
+ if (diag && diag.runnable === false && !this._diagHandled.has(name)) {
839
+ this._diagHandled.add(name);
840
+ this._handleFailedProgram(name, diag);
841
+ }
842
+ }
843
+ // Early-out: the rt:* program set has stopped growing and nothing new failed
844
+ // for DIAG_STABLE_FRAMES past the warmup floor → every active pass has been
845
+ // seen and validated. Otherwise stop at the hard window.
846
+ if (names === this._diagSig) this._diagStable++;
847
+ else { this._diagStable = 0; this._diagSig = names; }
848
+ if (
849
+ (this._diagFrames >= RealtimeRaytracer.DIAG_MIN_FRAMES &&
850
+ this._diagStable >= RealtimeRaytracer.DIAG_STABLE_FRAMES) ||
851
+ this._diagFrames >= RealtimeRaytracer.DIAG_WINDOW_FRAMES
852
+ ) {
853
+ this._diagDone = true;
854
+ }
659
855
  }
660
856
 
661
857
  // Consecutive catastrophic frames mean the GPU is drowning — cut quality
@@ -714,8 +910,31 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
714
910
  */
715
911
  compileScene(scene, options) {
716
912
  if (!this.supported) return null;
913
+ // "Construct the tracer, then add meshes" is a natural call order, so a
914
+ // scene with no traceable meshes is a NO-OP, not a throw: warn once and keep
915
+ // any previously compiled scene. Compile the new scene BEFORE disposing the
916
+ // old one so an empty-scene call never destroys a good scene. Only the
917
+ // SceneCompiler's specific "no meshes" signal is swallowed here; every other
918
+ // compile error (bad geometry, oversized attribute) still propagates.
919
+ let compiled;
920
+ try {
921
+ compiled = compileScene(scene, options);
922
+ } catch (err) {
923
+ if (/no meshes found/.test(String(err && err.message))) {
924
+ if (!this._emptyWarned) {
925
+ console.warn(
926
+ "three-realtime-rt: compileScene() called on a scene with no traceable " +
927
+ "meshes — keeping the current scene. Until meshes are added and " +
928
+ "recompiled, render() falls back to plain rasterization (no crash, no black)."
929
+ );
930
+ this._emptyWarned = true;
931
+ }
932
+ return this.compiled; // unchanged (may still be null)
933
+ }
934
+ throw err;
935
+ }
717
936
  if (this.compiled) this.compiled.dispose();
718
- this.compiled = compileScene(scene, options);
937
+ this.compiled = compiled;
719
938
  // Emissive area lights are the noisiest direct-light path: one triangle
720
939
  // sample per pixel per frame, and the 1/dist^2 term spikes near a small
721
940
  // emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
@@ -985,6 +1204,13 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
985
1204
  if (this.adaptiveQuality) this._adaptQuality();
986
1205
  if (this.overloadProtection) this._overloadBrake();
987
1206
  if (!this.compiled) this.compileScene(scene);
1207
+ // Still nothing to trace (empty scene — tracer built before meshes were
1208
+ // added). Show the user's raster scene rather than crashing or rendering
1209
+ // black; the pipeline picks up automatically once compileScene() succeeds.
1210
+ if (!this.compiled) {
1211
+ this.renderer.render(scene, camera);
1212
+ return;
1213
+ }
988
1214
 
989
1215
  this.frame += 1;
990
1216
  camera.updateMatrixWorld();
@@ -1084,6 +1310,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1084
1310
  rtU.uRefrEnabled.value = this.refraction;
1085
1311
  rtU.uBlendEnabled.value = this.transparency;
1086
1312
  rtU.uIor.value = this.ior;
1313
+ rtU.uDispersion.value = Math.min(0.5, Math.max(0, this.dispersion));
1087
1314
  rtU.uLightStochastic.value = this.stochasticLights;
1088
1315
  rtU.uSkyEnabled.value = this.sky.enabled;
1089
1316
  rtU.uSunDir.value.copy(this.sky.sunDir);
@@ -1128,6 +1355,8 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1128
1355
  {
1129
1356
  fireflyClamp: this.fireflyClamp > 0 ? this.fireflyClamp : 1e6,
1130
1357
  mCap: this.restirGIMCap,
1358
+ spatialTaps: Math.max(0, Math.min(4, this.restirGISpatialTaps | 0)),
1359
+ validateInterval: Math.max(0, this.restirGIValidate | 0),
1131
1360
  emissiveCDF: this.emissiveImportance,
1132
1361
  envColor: this.envColor,
1133
1362
  envIntensity: this.envIntensity,
@@ -1266,6 +1495,12 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1266
1495
  // Record this frame's (jittered) view-projection + jitter for next frame.
1267
1496
  this._prevViewProj.copy(this._jitteredViewProj);
1268
1497
  this._prevJitterUv.copy(this._jitterUv);
1498
+
1499
+ // Compile-failure diagnosis: every pass program used this frame has now had
1500
+ // its link status checked by three (diagnostics populated on first use), so
1501
+ // scan for failures. Runs at frame END (downstream of the passes) and only
1502
+ // until the polling window settles — a no-op on the healthy steady state.
1503
+ if (!this._diagDone) this._scanPrograms();
1269
1504
  }
1270
1505
 
1271
1506
  dispose() {
package/src/RestirPass.js CHANGED
@@ -52,7 +52,10 @@ vec4 fetchBlueNoise() {
52
52
  return fract(bn + shift);
53
53
  }
54
54
 
55
- float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
55
+ // Named rtLum, NOT luminance: three r166+ prepends its own luminance(vec3)
56
+ // to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
57
+ // (vec3) body as a redefinition — the whole program fails to compile.
58
+ float rtLum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
56
59
 
57
60
  // Primary-surface roughness, set per pixel in main(). Drives the cheap specular
58
61
  // lobe below so reservoirs favour lights that land on a highlight.
@@ -121,7 +124,7 @@ vec3 candidateContribution(float id, vec2 uv, vec3 P, vec3 N) {
121
124
  // (Known approximation: a triangle whose centroid contributes zero but whose
122
125
  // far corner doesn't can be under-selected at grazing setups.)
123
126
  float phatOf(float id, vec3 P, vec3 N) {
124
- return luminance(candidateContribution(id, vec2(1.0 / 3.0), P, N));
127
+ return rtLum(candidateContribution(id, vec2(1.0 / 3.0), P, N));
125
128
  }
126
129
  `;
127
130
 
@@ -297,8 +300,12 @@ export class RestirPass {
297
300
  this.targetB = this._makeTarget(width, height);
298
301
  this.spatialTarget = this._makeTarget(width, height);
299
302
 
300
- const mkMaterial = (frag) =>
303
+ const mkMaterial = (frag, name) =>
301
304
  new THREE.ShaderMaterial({
305
+ // Stable program name for compile-failure self-diagnosis; a link failure
306
+ // in either reservoir stage disables `restir` (falls back to the
307
+ // non-reservoir per-light direct sampling path — image stays lit).
308
+ name,
302
309
  glslVersion: THREE.GLSL3,
303
310
  vertexShader: fullscreenVert,
304
311
  fragmentShader: frag,
@@ -329,8 +336,8 @@ export class RestirPass {
329
336
  depthWrite: false,
330
337
  });
331
338
 
332
- this.material = mkMaterial(temporalFrag);
333
- this.spatialMaterial = mkMaterial(spatialFrag);
339
+ this.material = mkMaterial(temporalFrag, "rt:restir-temporal");
340
+ this.spatialMaterial = mkMaterial(spatialFrag, "rt:restir-spatial");
334
341
 
335
342
  this.scene = new THREE.Scene();
336
343
  this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);