three-realtime-rt 0.6.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.
package/README.md CHANGED
@@ -134,7 +134,11 @@ A checklist for dropping the tracer into a scene you already have:
134
134
  2. **Compile once; recompile after structural changes** — `rt.compileScene(scene)`
135
135
  bakes geometry into a static BVH and snapshots materials + emissive area
136
136
  lights. Call it again after you add/remove meshes, swap geometry, or change a
137
- material's `emissive` / `color` / `roughness` / `metalness`.
137
+ material's `emissive` / `color` / `roughness` / `metalness`. <a id="empty-scene"></a>Calling it on a
138
+ scene with **no meshes yet** is a **no-op** (it warns once and keeps any
139
+ previously compiled scene), so "construct the tracer, then add meshes" is a
140
+ valid order — until meshes are added and recompiled, `rt.render()` falls back
141
+ to plain rasterization (no crash, no black screen).
138
142
  3. **Declare movers** — pass moving meshes to
139
143
  `rt.compileScene(scene, { dynamicMeshes: [...] })`, then call
140
144
  `rt.updateDynamic()` each frame after you move them (e.g. after a physics
@@ -610,8 +614,37 @@ is **feature-detected** — it appears only when the loaded build exposes an
610
614
  The renderer can pass every compile and framebuffer check and still draw a black
611
615
  screen — that is exactly what shipped in 0.4.0 on iOS (WebKit's GLSL-to-Metal
612
616
  translation silently broke at a 4th `traceRadiance` call site; clean compile, no
613
- console error, black output). The only defence against that class of failure is
614
- to **look at the pixels**, so the demo has a headless-friendly self-test.
617
+ console error, black output), and again on three r166+ when three's injected
618
+ `luminance` helper collided with the library's own and the affected pass programs
619
+ failed to link. The only defence against that class of failure is to **look at
620
+ the pixels**, so the demo has a headless-friendly self-test.
621
+
622
+ **Programmatic signal (`rt.compileError` / `rt.status`).** A pass whose program
623
+ fails to *link* renders black without throwing — three logs to the console and
624
+ sets `program.diagnostics.runnable = false`, but rendering proceeds. So over the
625
+ first several rendered frames the renderer inspects `renderer.info.programs` for
626
+ its own (stably named `rt:*`) pass programs and reports what it finds:
627
+
628
+ ```js
629
+ rt.render(scene, camera); // ...for a few frames
630
+ if (!rt.status.ok) {
631
+ // rt.compileError → "rt:lighting: 'luminance' : function already has a body"
632
+ if (rt.status.coreFailure) showRaster(`raster (${rt.compileError})`);
633
+ else console.warn("degraded:", rt.status.disabled); // e.g. [{pass, feature, reason}]
634
+ }
635
+ ```
636
+
637
+ - **`rt.compileError`** (`string | null`) — first / most-severe failure summary
638
+ (`"rt:<pass>: <driver log>"`), or `null` while every pass compiles clean.
639
+ - **`rt.status`** (`{ ok, disabled, coreFailure }`) — `ok` is `true` on the healthy
640
+ path and `false` once any `rt:*` pass fails to link. **Optional** features whose
641
+ pass failed are **auto-disabled** so the image stays lit (`restir`, `restirGI`,
642
+ `denoise`, `volumetric`, `taa`, `specular`), each listed in `disabled` as
643
+ `{ pass, feature, reason }` with a one-line driver log. A **core** pass
644
+ (`gbuffer` / `lighting` / `composite`) has no fallback: `coreFailure` names it
645
+ and the image is black-but-diagnosed. This lets an integrator render an honest
646
+ `raster (reason)` fallback instead of guessing. (When `supported` is `false` the
647
+ RT pipeline never runs, so `status.ok` is `false` too — check `supported` first.)
615
648
 
616
649
  **In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
617
650
  full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
@@ -621,12 +654,16 @@ and into a hidden `#selftest-verdict` DOM node:
621
654
 
622
655
  ```json
623
656
  { "pass": true, "meanLum": 139.08, "irrLum": 169.73, "glErrors": 0,
624
- "specMRT": true, "supported": true, "frames": 91, "ua": "…" }
657
+ "specMRT": true, "supported": true, "statusOk": true, "rtPrograms": 15,
658
+ "compileError": null, "disabled": [], "frames": 91, "ua": "…" }
625
659
  ```
626
660
 
627
661
  The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
628
662
  the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
629
- `glErrors == 0` and `supported == true`.
663
+ `glErrors == 0`, `supported == true`, and `statusOk == true` — the last asserts
664
+ the compile-failure surface above stayed clean (`rt.status.ok`, `compileError`
665
+ null) *and* that the named pass programs were actually discoverable
666
+ (`rtPrograms > 0`), so a broken diagnosis can't read as a false pass.
630
667
 
631
668
  - `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
632
669
  composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
@@ -641,12 +678,23 @@ the renderer with `preserveDrawingBuffer: true` so the canvas can be read back;
641
678
  normal runs keep the cheaper default.
642
679
 
643
680
  **In CI:** `npm run test:render` ([`scripts/selftest.mjs`](scripts/selftest.mjs))
644
- starts vite on a free port and drives `?selftest=1` through Playwright across
681
+ starts vite on free ports and drives `?selftest=1` through Playwright across
645
682
  **chromium, firefox and webkit**, printing a pass/fail/skip table and exiting
646
683
  nonzero on any real failure (a documented environmental *skip* does not fail the
647
684
  suite). Playwright is loaded from a sibling checkout — see the top of the script.
648
685
 
649
- Machine-specific note (Windows + NVIDIA, the current dev box): the chromium leg
686
+ **Three-version matrix.** The r166+ `luminance` break shipped because nothing
687
+ tested a newer three, so **chromium runs twice**: once against the pinned three
688
+ (`0.160.1`) and once against **`three@latest`** (a second vite with
689
+ `RT_THREE=latest`, which [`vite.config.js`](vite.config.js) aliases `three` to the
690
+ `three-latest` devDependency). The `chromium@3latest` row is the guard for that
691
+ class of regression, and **the pass gate requires both chromium legs**. A fourth
692
+ chromium load of `?selftest=empty` asserts the [empty-scene no-op](#empty-scene)
693
+ (`compileScene` on a scene with no meshes is a no-op and `render()` falls back to
694
+ plain raster instead of crashing). firefox/webkit stay single-leg against the
695
+ default three (both are environmental skips here — see below).
696
+
697
+ Machine-specific note (Windows + NVIDIA, the current dev box): each chromium leg
650
698
  runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
651
699
  never finishes compiling the BVH megakernel here — headless chromium,
652
700
  headed+`--use-angle=d3d11` and system Chrome all freeze at ~3 frames with silent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-realtime-rt",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Turn-on ray traced lighting for three.js: a hybrid deferred renderer with BVH-traced soft shadows, one-bounce global illumination, temporal reprojection, an a-trous denoiser and TAA.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -47,6 +47,7 @@
47
47
  "@dimforge/rapier3d-compat": "^0.14.0",
48
48
  "gh-pages": "^6.3.0",
49
49
  "three": "^0.160.1",
50
+ "three-latest": "npm:three@latest",
50
51
  "three-mesh-bvh": "^0.7.8",
51
52
  "vite": "^5.0.11"
52
53
  }
@@ -195,6 +195,9 @@ void main() {
195
195
  export class CompositePass {
196
196
  constructor() {
197
197
  this.material = new THREE.ShaderMaterial({
198
+ // Stable program name for compile-failure self-diagnosis: this is a CORE
199
+ // pass (the final tonemap/upsample) — a link failure has no fallback.
200
+ name: "rt:composite",
198
201
  glslVersion: THREE.GLSL3,
199
202
  vertexShader: fullscreenVert,
200
203
  fragmentShader: compositeFrag,
package/src/CopyPass.js CHANGED
@@ -37,6 +37,9 @@ void main() {
37
37
  export class CopyPass {
38
38
  constructor() {
39
39
  this.material = new THREE.ShaderMaterial({
40
+ // Generic fullscreen history-carry blit (resize only); a link failure is
41
+ // non-fatal, so it classifies as auxiliary in the self-diagnosis.
42
+ name: "rt:history-carry",
40
43
  glslVersion: THREE.GLSL3,
41
44
  vertexShader: fullscreenVert,
42
45
  fragmentShader: copyFrag,
@@ -35,7 +35,10 @@ uniform bool uBlendIsSpec; // this instance filters the specular buffer
35
35
  uniform sampler2D uAddTex;
36
36
  uniform bool uHasAdd;
37
37
 
38
- float luminance(vec3 c) {
38
+ // Named rtLum, NOT luminance: three r166+ prepends its own luminance(vec3)
39
+ // to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
40
+ // (vec3) body as a redefinition — the whole program fails to compile.
41
+ float rtLum(vec3 c) {
39
42
  return dot(c, vec3(0.299, 0.587, 0.114));
40
43
  }
41
44
 
@@ -123,16 +126,16 @@ void main() {
123
126
  vec2 tuv = vUv + vec2(float(dx), float(dy)) * uTexelSize;
124
127
  if (tuv.x < 0.0 || tuv.x > 1.0 || tuv.y < 0.0 || tuv.y > 1.0) continue;
125
128
  if (texture(uGWorldPos, tuv).w < 0.5) continue;
126
- maxL = max(maxL, luminance(sampleIrr(tuv).rgb));
129
+ maxL = max(maxL, rtLum(sampleIrr(tuv).rgb));
127
130
  found = 1.0;
128
131
  }
129
132
  }
130
133
  float cap = maxL * 1.25 + 1e-4;
131
- float l = luminance(center.rgb);
134
+ float l = rtLum(center.rgb);
132
135
  if (found > 0.5 && l > cap) center.rgb *= cap / l;
133
136
  }
134
137
 
135
- float lumC = luminance(center.rgb);
138
+ float lumC = rtLum(center.rgb);
136
139
 
137
140
  // 3x3 B-spline-ish kernel, edge-avoiding weights.
138
141
  vec3 sum = center.rgb * 4.0;
@@ -155,7 +158,7 @@ void main() {
155
158
  // flat floor has no geometric edge to protect it, so at high iteration
156
159
  // counts the wide passes would average it away ("floating" objects with
157
160
  // no contact shadow). Wide steps only get to blend near-equal luminance.
158
- float wL = exp(-abs(luminance(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
161
+ float wL = exp(-abs(rtLum(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
159
162
  float w = k * wN * wZ * wL * (1.0 - specKeep);
160
163
  sum += s.rgb * w;
161
164
  wsum += w;
@@ -182,6 +185,9 @@ export class DenoisePass {
182
185
  this.targetB = this._makeTarget(width, height);
183
186
 
184
187
  this.material = new THREE.ShaderMaterial({
188
+ // Stable program name for compile-failure self-diagnosis; a link failure
189
+ // disables the optional `denoise` feature (image stays lit, just noisier).
190
+ name: "rt:denoise",
185
191
  glslVersion: THREE.GLSL3,
186
192
  vertexShader: fullscreenVert,
187
193
  fragmentShader: atrousFrag,
@@ -1,4 +1,5 @@
1
1
  import * as THREE from "three";
2
+ import { makeMRT } from "./mrtCompat.js";
2
3
 
3
4
  // three defines USE_SKINNING and supplies the bindMatrix / bindMatrixInverse /
4
5
  // boneTexture uniforms + skinIndex / skinWeight attributes automatically when the
@@ -190,7 +191,7 @@ export class GBufferPass {
190
191
  }
191
192
 
192
193
  _makeTarget(width, height) {
193
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 4, {
194
+ const t = makeMRT(width, height, 4, {
194
195
  minFilter: THREE.NearestFilter,
195
196
  magFilter: THREE.NearestFilter,
196
197
  type: THREE.FloatType,
@@ -240,6 +241,10 @@ export class GBufferPass {
240
241
 
241
242
  _makeGbufferMaterial(mesh) {
242
243
  const material = new THREE.ShaderMaterial({
244
+ // Stable program name for the compile-failure self-diagnosis (see
245
+ // RealtimeRaytracer._scanPrograms). Per-mesh materials share one program
246
+ // cache key, so they all surface as the single core pass "rt:gbuffer".
247
+ name: "rt:gbuffer",
243
248
  glslVersion: THREE.GLSL3,
244
249
  vertexShader: gbufferVert,
245
250
  fragmentShader: gbufferFrag,
@@ -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;
@@ -133,7 +134,10 @@ vec4 fetchBlueNoise() {
133
134
  return fract(bn + shift);
134
135
  }
135
136
 
136
- 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)); }
137
141
 
138
142
  // ---------- reservoir .w bit-packing: M (8 bit) + oct-normal (12+12 bit) ------
139
143
  // The RGBA32F reservoir-position attachment is at the pass's hard 16-sampler
@@ -483,7 +487,7 @@ void main() {
483
487
  vec3 rad = traceRadianceGI(P + N * uEps, wi, nLight, hitPos, hitNormal);
484
488
  // Match the inline firefly clamp, which is applied to indirect (= L_i) so
485
489
  // the biased mean of the two paths agrees.
486
- float rl = luminance(rad);
490
+ float rl = rtLum(rad);
487
491
  if (rl > uFireflyClamp) rad *= uFireflyClamp / rl;
488
492
 
489
493
  float cosT = max(dot(N, wi), 0.0);
@@ -532,8 +536,8 @@ void main() {
532
536
  float valTol = max(0.02 * expectDist, 4.0 * uEps);
533
537
  float hitDist = length(hitPos - P);
534
538
  bool geomChanged = abs(hitDist - expectDist) > valTol;
535
- float pHatOld = luminance(radPrev) * cosT; // stored target at this pixel
536
- float pHatNew = luminance(rad) * cosT; // re-shaded target (current light)
539
+ float pHatOld = rtLum(radPrev) * cosT; // stored target at this pixel
540
+ float pHatNew = rtLum(rad) * cosT; // re-shaded target (current light)
537
541
  bool wentDark = pHatNew < VAL_DARK_FRAC * pHatOld;
538
542
  // KILL (drop the stale temporal term so this pixel's fresh candidates rebuild
539
543
  // from the current scene) on geometry change OR a collapse to near-black; leave
@@ -543,7 +547,7 @@ void main() {
543
547
  killStore = geomChanged || wentDark;
544
548
  } else {
545
549
  // --- normal fresh candidate: one cosine-hemisphere GI bounce, shaded inline.
546
- float pHatFresh = luminance(rad) * cosT;
550
+ float pHatFresh = rtLum(rad) * cosT;
547
551
  // w = p_hat / p_source = p_hat / (cos/PI). cosT cancels; guard cosT==0.
548
552
  float wFresh = cosT > 0.0 ? pHatFresh * PI / cosT : 0.0;
549
553
  wSum = wFresh;
@@ -571,7 +575,7 @@ void main() {
571
575
  vec3 dp = hitPrev - P;
572
576
  float dl = length(dp);
573
577
  float cosPrev = dl > 1e-5 ? max(dot(N, dp / dl), 0.0) : 0.0;
574
- float pHatPrev = luminance(radPrev) * cosPrev;
578
+ float pHatPrev = rtLum(radPrev) * cosPrev;
575
579
  float Mc = min(Mprev, uMCap);
576
580
  // Combine reservoirs: w = p_hat_current(sample) * W_prev * M_prev.
577
581
  float w = pHatPrev * Wprev * Mc;
@@ -585,7 +589,7 @@ void main() {
585
589
  // Reconstruct last frame's resolve from this same sample (same W cap
586
590
  // and clamp as the live resolve, for a like-for-like EMA partner).
587
591
  vec3 pg = radPrev * (cosPrev / PI) * min(Wprev, 32.0);
588
- float pgl = luminance(pg);
592
+ float pgl = rtLum(pg);
589
593
  if (pgl > uFireflyClamp) pg *= uFireflyClamp / pgl;
590
594
  if (!any(isnan(pg)) && !any(isinf(pg))) {
591
595
  emaPrevGi = pg;
@@ -650,7 +654,7 @@ void main() {
650
654
 
651
655
  // Target function at q (same shape the pass already uses).
652
656
  float cosQ = max(dot(N, normalize(xS - P)), 0.0);
653
- float pHatQ = luminance(Ls) * cosQ;
657
+ float pHatQ = rtLum(Ls) * cosQ;
654
658
  // Invalid-shift reject: if the neighbour's hit x_s lies below q's shading
655
659
  // hemisphere (cosQ == 0) or carries no radiance, the reconnected target is
656
660
  // zero — the shift could never have produced this sample at q, so it must
@@ -676,7 +680,7 @@ void main() {
676
680
  vec3 sd = selPos - P;
677
681
  float sl = length(sd);
678
682
  float selCos = sl > 1e-5 ? max(dot(N, sd / sl), 0.0) : 0.0;
679
- float pHatSel = luminance(selRad) * selCos;
683
+ float pHatSel = rtLum(selRad) * selCos;
680
684
  float W = (M > 0.0 && pHatSel > 0.0) ? wSum / (M * pHatSel) : 0.0;
681
685
 
682
686
  // --- final visibility (mandatory): ONE any-hit occlusion ray from x_q toward
@@ -715,7 +719,7 @@ void main() {
715
719
  // slightly dim GI on freshly revealed surfaces for a steady image in motion.
716
720
  float conf = clamp(M / uMCap, 0.0, 1.0);
717
721
  float cap = uFireflyClamp * mix(0.3, 1.0, conf);
718
- float gil = luminance(gi);
722
+ float gil = rtLum(gi);
719
723
  if (gil > cap) gi *= cap / gil;
720
724
  if (any(isnan(gi)) || any(isinf(gi))) gi = vec3(0.0);
721
725
  // Resolve EMA (see the emaPrevGi note above): ~5-frame effective average.
@@ -729,7 +733,7 @@ void main() {
729
733
  vec3 sdT = selPosT - P;
730
734
  float slT = length(sdT);
731
735
  float selCosT = slT > 1e-5 ? max(dot(N, sdT / slT), 0.0) : 0.0;
732
- float pHatSelT = luminance(selRadT) * selCosT;
736
+ float pHatSelT = rtLum(selRadT) * selCosT;
733
737
  float WT = (MT > 0.0 && pHatSelT > 0.0) ? wSumT / (MT * pHatSelT) : 0.0;
734
738
  if (any(isnan(selRadT)) || any(isinf(selRadT))) { selRadT = vec3(0.0); WT = 0.0; }
735
739
 
@@ -797,6 +801,9 @@ export class GIReservoirPass {
797
801
  this.targetB = this._makeTarget(width, height);
798
802
 
799
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",
800
807
  glslVersion: THREE.GLSL3,
801
808
  vertexShader: fullscreenVert,
802
809
  fragmentShader: giFrag,
@@ -851,7 +858,7 @@ export class GIReservoirPass {
851
858
  // (needs fp32) + M + radiance + W, which must never be interpolated. All fp32
852
859
  // (rather than a mixed fp32/fp16 layout) sidesteps drivers that reject mixed
853
860
  // MRT precision; the resolved GI (attachment 2) is read 1:1 by the denoise.
854
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 3, {
861
+ const t = makeMRT(width, height, 3, {
855
862
  minFilter: THREE.NearestFilter,
856
863
  magFilter: THREE.NearestFilter,
857
864
  format: THREE.RGBAFormat,
@@ -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;
@@ -995,6 +996,10 @@ export class RTLightingPass {
995
996
  this.specB = specMRT ? this._makeSpecTarget(width, height) : null;
996
997
 
997
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",
998
1003
  glslVersion: THREE.GLSL3,
999
1004
  vertexShader: fullscreenVert,
1000
1005
  fragmentShader: specMRT
@@ -1057,6 +1062,9 @@ export class RTLightingPass {
1057
1062
  // Specular temporal accumulation program (its own sampler budget — well
1058
1063
  // clear of the lighting pass's 16-sampler ceiling).
1059
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",
1060
1068
  glslVersion: THREE.GLSL3,
1061
1069
  vertexShader: fullscreenVert,
1062
1070
  fragmentShader: specAccumFrag,
@@ -1081,6 +1089,9 @@ export class RTLightingPass {
1081
1089
  // buffers. In single-target fallback the second output collapses the same
1082
1090
  // way as the lighting shader's.
1083
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",
1084
1095
  glslVersion: THREE.GLSL3,
1085
1096
  vertexShader: fullscreenVert,
1086
1097
  fragmentShader: specMRT
@@ -1120,7 +1131,7 @@ export class RTLightingPass {
1120
1131
  t.texture.generateMipmaps = false;
1121
1132
  return t;
1122
1133
  }
1123
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, opts);
1134
+ const t = makeMRT(width, height, 2, opts);
1124
1135
  for (const tex of t.texture) tex.generateMipmaps = false;
1125
1136
  return t;
1126
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
 
@@ -690,6 +708,150 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
690
708
  );
691
709
  this._renderScale = 0.375;
692
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
+ }
693
855
  }
694
856
 
695
857
  // Consecutive catastrophic frames mean the GPU is drowning — cut quality
@@ -748,8 +910,31 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
748
910
  */
749
911
  compileScene(scene, options) {
750
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
+ }
751
936
  if (this.compiled) this.compiled.dispose();
752
- this.compiled = compileScene(scene, options);
937
+ this.compiled = compiled;
753
938
  // Emissive area lights are the noisiest direct-light path: one triangle
754
939
  // sample per pixel per frame, and the 1/dist^2 term spikes near a small
755
940
  // emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
@@ -1019,6 +1204,13 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1019
1204
  if (this.adaptiveQuality) this._adaptQuality();
1020
1205
  if (this.overloadProtection) this._overloadBrake();
1021
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
+ }
1022
1214
 
1023
1215
  this.frame += 1;
1024
1216
  camera.updateMatrixWorld();
@@ -1303,6 +1495,12 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1303
1495
  // Record this frame's (jittered) view-projection + jitter for next frame.
1304
1496
  this._prevViewProj.copy(this._jitteredViewProj);
1305
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();
1306
1504
  }
1307
1505
 
1308
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);
package/src/TAAPass.js CHANGED
@@ -128,6 +128,9 @@ export class TAAPass {
128
128
  this._reset = true;
129
129
 
130
130
  this.material = new THREE.ShaderMaterial({
131
+ // Stable program name for compile-failure self-diagnosis; a link failure
132
+ // disables the optional `taa` feature (image stays lit, just aliased).
133
+ name: "rt:taa",
131
134
  glslVersion: THREE.GLSL3,
132
135
  vertexShader: fullscreenVert,
133
136
  fragmentShader: taaFrag,
@@ -147,6 +150,9 @@ export class TAAPass {
147
150
  });
148
151
 
149
152
  this.copyMaterial = new THREE.ShaderMaterial({
153
+ // The TAA resolve-to-screen / history-carry blit; folded into the `taa`
154
+ // feature classification (a failure makes TAA unusable → disable taa).
155
+ name: "rt:taa-copy",
150
156
  glslVersion: THREE.GLSL3,
151
157
  vertexShader: fullscreenVert,
152
158
  fragmentShader: copyFrag,
@@ -277,6 +277,9 @@ export class VolumetricPass {
277
277
  this.targetB = this._makeTarget(width, height);
278
278
 
279
279
  this.material = new THREE.ShaderMaterial({
280
+ // Stable program name for compile-failure self-diagnosis; a link failure
281
+ // disables the optional `volumetric` feature (image stays lit, no god rays).
282
+ name: "rt:volumetric",
280
283
  glslVersion: THREE.GLSL3,
281
284
  vertexShader: fullscreenVert,
282
285
  fragmentShader: volumetricFrag,
package/src/index.d.ts CHANGED
@@ -277,6 +277,45 @@ export interface VolumetricState {
277
277
  zones: FogZone[];
278
278
  }
279
279
 
280
+ /** One optional feature auto-disabled after its pass program failed to link. */
281
+ export interface DisabledPass {
282
+ /** The failed pass program's stable name (e.g. `"rt:taa"`, `"rt:denoise"`). */
283
+ pass: string;
284
+ /**
285
+ * The instance toggle that was set false to keep the image lit: one of
286
+ * `"restir"`, `"restirGI"`, `"denoise"`, `"volumetric"`, `"taa"`, `"specular"`.
287
+ */
288
+ feature: string;
289
+ /** First line of the driver's shader/link info log for the failure. */
290
+ reason: string;
291
+ }
292
+
293
+ /**
294
+ * Compile-health summary for the ray tracing pipeline, on
295
+ * {@link RealtimeRaytracer.status}. A pass whose program fails to LINK renders
296
+ * black without throwing (three logs and sets `program.diagnostics.runnable`
297
+ * false, but rendering proceeds), so this is how an integrator distinguishes a
298
+ * working RT image from a broken one — and renders an honest raster fallback
299
+ * with a reason instead of guessing. Populated over the first several rendered
300
+ * frames (link status is checked lazily / can be deferred by the driver).
301
+ */
302
+ export interface RaytracerStatus {
303
+ /**
304
+ * True while every ray tracing pass is running as intended. Flips to false the
305
+ * moment ANY `rt:*` pass program fails to link — whether a core pass (see
306
+ * `coreFailure`) or an optional feature that was auto-disabled (see `disabled`).
307
+ */
308
+ ok: boolean;
309
+ /** Optional features turned off to keep the image lit after their pass failed to link. */
310
+ disabled: DisabledPass[];
311
+ /**
312
+ * Set when a CORE pass (gbuffer / lighting / composite) failed to link. Such a
313
+ * pass has no fallback, so the image is black — but now diagnosed. The string
314
+ * is a `"rt:<pass>: <driver log>"` summary; null when no core pass has failed.
315
+ */
316
+ coreFailure: string | null;
317
+ }
318
+
280
319
  /** Options accepted by {@link RealtimeRaytracer.compileScene} and {@link compileScene}. */
281
320
  export interface CompileSceneOptions {
282
321
  /**
@@ -370,6 +409,22 @@ export class RealtimeRaytracer {
370
409
  specMRTSupported: boolean;
371
410
  /** The current compiled scene, or null before the first compile / when unsupported. */
372
411
  compiled: CompiledScene | null;
412
+ /**
413
+ * First/most-severe pass compile-failure summary (`"rt:<pass>: <driver log>"`),
414
+ * or null while every ray tracing pass compiles clean. A quick honest signal
415
+ * for "should I show the RT image?"; see {@link status} for the structured form.
416
+ * Core failures (gbuffer/lighting/composite) outrank auto-disabled features here.
417
+ */
418
+ compileError: string | null;
419
+ /**
420
+ * Structured compile-health of the pipeline (see {@link RaytracerStatus}).
421
+ * `status.ok` is true on the healthy path and false once any pass fails to
422
+ * link; `status.disabled` lists auto-disabled optional features; and
423
+ * `status.coreFailure` names an unrecoverable core-pass failure. Populated over
424
+ * the first several rendered frames. When `supported` is false, `status.ok` is
425
+ * false too (the RT pipeline is not operational).
426
+ */
427
+ status: RaytracerStatus;
373
428
  /** Accumulated frame counter. */
374
429
  frame: number;
375
430
  /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular, 7 bvh cost. */
@@ -0,0 +1,37 @@
1
+ import * as THREE from "three";
2
+
3
+ /**
4
+ * Multiple-render-target constructor that works across the three versions in
5
+ * this library's peer range (three >=0.155.0).
6
+ *
7
+ * three r172 removed `WebGLMultipleRenderTargets`. Its replacement is the base
8
+ * `WebGLRenderTarget` with a `count` option (added r162) for N color
9
+ * attachments, which it exposes as the ARRAY `.textures` — while `.texture`
10
+ * became a single texture (an alias for `textures[0]`). This library predates
11
+ * that split and indexes the attachment array as `.texture[i]` throughout the
12
+ * passes, so on new three we shadow the instance `.texture` accessor with the
13
+ * `.textures` array. That keeps every existing `.texture[i]` / `for (... of
14
+ * .texture)` call site correct with no per-site changes, and does not disturb
15
+ * three's WebGL backend (which binds/reads/reads-back via `.textures`, and only
16
+ * touches the single `.texture` on cube/PMREM targets, never on these 2D MRTs).
17
+ *
18
+ * On old three (<= r171) `WebGLMultipleRenderTargets` already IS an
19
+ * array-`.texture` MRT, so it is used unchanged and the result is byte-identical
20
+ * to before this shim existed.
21
+ *
22
+ * Signature matches the old constructor: (width, height, count, options).
23
+ */
24
+ export function makeMRT(width, height, count, options = {}) {
25
+ if (THREE.WebGLMultipleRenderTargets) {
26
+ return new THREE.WebGLMultipleRenderTargets(width, height, count, options);
27
+ }
28
+ const rt = new THREE.WebGLRenderTarget(width, height, { ...options, count });
29
+ // Re-point `.texture` at the attachment array (old-three semantics). Shadows
30
+ // the prototype get/set texture() accessor on this instance only.
31
+ Object.defineProperty(rt, "texture", {
32
+ value: rt.textures,
33
+ writable: true,
34
+ configurable: true,
35
+ });
36
+ return rt;
37
+ }