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.
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
@@ -364,7 +368,7 @@ material of a group* row).
364
368
  | `roughness` | ✅ | Drives shadow / GI softness, reflection sharpness **and the GGX specular lobe width** (see *dielectric specular* below). |
365
369
  | `metalness` | ✅ | Metallic pixels trace a reflection ray whose analytic-light glints are shadowed; `F0 = mix(0.04, albedo, metalness)`. |
366
370
  | `emissive` | ✅ | A *static* emissive mesh becomes a real **area light** (NEE) — casts soft light + shadows, including a GGX highlight. |
367
- | `emissiveMap` | ⚠️ visible only | A map-masked emissive **glows on screen** but the map **zeroes its area-light table** it lights nothing. Use a flat `emissive` colour (no map) for an emitter that should illuminate. |
371
+ | `emissiveMap` | average-colour approximation | A map-masked emissive **glows on screen** with its full per-pixel pattern (G-buffer, untouched) **and now also casts light**: the CPU averages the map (`avg(map) × emissive × emissiveIntensity`) and feeds that single colour into the NEE area-light table. Needs a non-black `emissive` (white keeps the cast hue equal to the map average) and a readable image (a CORS-tainted / not-yet-decoded map falls back to visible-only, with a one-time `console.info`). A map that averages to **near-black** (e.g. a model's mostly-dark emissiveMap with a few tiny glowing texels) casts nothing treated as visible-only so it doesn't flood the NEE list with a whole high-poly mesh. Texel-accurate (spatially-varying) emission is future work. |
368
372
  | `transmission` (Physical) | ✅ | Glass: Fresnel reflection (with analytic-light glints) + two-interface refraction. |
369
373
  | `transparent` + `opacity` | ✅ | Alpha blend: the surface is composited over the geometry behind it (a straight-through traced ray), weighted by scalar `opacity` and **tinted by `color`/`map`**. The behind-radiance rides the **specular buffer** and the opacity blend happens at **composite** (where the pane's albedo lives), so **needs `specular: true`** — with the specular buffer off, blend surfaces degrade to opaque. Single layer — nearest transparent surface wins, overlapping panes don't inter-sort. Kept out of the BVH, so it casts no shadow. Toggle with `transparency`. |
370
374
  | `opacity` on an opaque material | ❌ | Only read when `transparent: true`; an opaque material always writes at full coverage. |
@@ -383,7 +387,7 @@ material of a group* row).
383
387
  |-------|-----------|-------|
384
388
  | `PointLight` | ✅ | `light.userData.rtRadius` (default `0.15`) sets soft-shadow size. |
385
389
  | `DirectionalLight` | ✅ | `light.userData.rtRadius` (default `0.02`) sets sun softness; keep its direction in sync with `sky.sunDir`. |
386
- | Emissive meshes | ✅ static | Sampled directly as area lights. **Dynamic** emitters are *not* in the NEE listthey light only via GI-ray hits. |
390
+ | Emissive meshes | ✅ static + dynamic | Sampled directly as area lights (NEE). **Dynamic** emitters (a mesh in `dynamicMeshes`) are refreshed every `updateDynamic()`: their world-space triangles are re-derived from the freshly baked/skinned/deformed positions and their NEE rows + power CDF rewritten a moving glowing orb sweeps real light across the floor. Keep them **low-poly** (refreshed per frame) and note the 256-tri cap is shared with static emitters. |
387
391
  | `SpotLight` | ✅ | Cone + penumbra respected; soft shadows via `rtRadius`; visible light cones in volumetric fog. |
388
392
  | `RectAreaLight` | ❌ | Use an emissive mesh instead. |
389
393
  | `HemisphereLight` / `AmbientLight` | ❌ | Ignored — the procedural `sky` (or `envColor`) provides ambient. |
@@ -397,8 +401,9 @@ material of a group* row).
397
401
  bright ones, and let `fireflyClamp` do its job.
398
402
  - Up to **32** point/directional lights (`MAX_LIGHTS`); further lights are dropped.
399
403
  - Moving, toggling, recolouring or dimming a light → `rt.updateLights(scene)` (cheap, no recompile).
400
- - Changing a mesh's **emissive** (it's an area light baked at compile time) `rt.compileScene(...)` again.
401
- - Emissive area lights are capped at **256 triangles** (largest by area kept, with a console warning) — prefer low-poly emitter meshes.
404
+ - **Textured emitters** (`emissiveMap`) cast their **average** colour (`avg(map) × emissive × emissiveIntensity`), not per-texel the sign still *looks* patterned in the G-buffer but lights with one averaged hue. Texel-accurate emission is future work.
405
+ - **Moving** a dynamic emitter → `rt.updateDynamic()` refreshes its area-light rows + CDF from the new geometry. But an emitter's **emission itself** (its `emissive` colour / `emissiveIntensity`, or the map's average) is frozen at compile time: **changing what it emits static or dynamic — needs `rt.compileScene(...)` again** (`updateDynamic`/`updateLights` do not rescan emissive meshes). A dynamic emitter that never moves also needs a recompile to reflect an emission change.
406
+ - Emissive area lights are capped at **256 triangles** (shared across static + dynamic; largest by compile-time area kept, with a console warning) — prefer low-poly emitter meshes, especially dynamic ones.
402
407
 
403
408
  ### Geometry & occlusion
404
409
 
@@ -412,7 +417,7 @@ material of a group* row).
412
417
 
413
418
  - **1-bounce GI + direct light.** No multi-bounce diffuse, no caustics, no specular-chain paths.
414
419
  - **Reflections** are a single traced bounce into a **diffuse-shaded** view of the world — no recursive mirror-in-mirror; a metal shows its surroundings, not a second full render.
415
- - **Refraction** is two-interface (front + back). IOR is **per material** (`MeshPhysicalMaterial.ior`, encoded in the G-buffer for fully-transmissive glass, range [1.0, 1.98]); `rt.ior` is the global fallback. Convincing glass, not a spectral / dispersive renderer.
420
+ - **Refraction** is two-interface (front + back). IOR is **per material** (`MeshPhysicalMaterial.ior`, encoded in the G-buffer for fully-transmissive glass, range [1.0, 1.98]); `rt.ior` is the global fallback. Optional **chromatic dispersion** (`rt.dispersion`, off by default) splits the refracted term into a spectrum by stochastic spectral sampling (one channel per glass pixel per frame, blended by temporal accumulation — no extra rays); it is a **global** control, not yet per-material.
416
421
  - **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
417
422
  - **Transparency** is a **single-layer deferred blend**: a `transparent` surface writes as the nearest layer of the G-buffer and the lighting pass composites it over the geometry behind by tracing one straight-through ray. The behind-radiance is fully lit (direct + 1-bounce GI) and tinted by the pane's albedo. Overlapping transparent surfaces do **not** inter-sort (only the nearest is kept), and there is no per-pixel back-to-front over-blend of many layers as in raster three.js. Turn it off with `transparency: false` (blend surfaces then render fully opaque).
418
423
 
@@ -440,8 +445,9 @@ material of a group* row).
440
445
  | `refraction` | `true` | Traced two-interface refraction for `MeshPhysicalMaterial.transmission` surfaces. |
441
446
  | `transparency` | `true` | Alpha-blended transparency: composite `transparent` meshes over the geometry behind them (single-layer, weighted by `opacity`, tinted by albedo). Needs the specular buffer (`specular: true`). Off = they render fully opaque. |
442
447
  | `restir` | `true` | ReSTIR direct lighting: per-pixel reservoirs with temporal + spatial reuse, one visibility ray regardless of light count. Flat cost in light count; cuts emissive area-light noise. |
443
- | `restirGI` | `false` | **Experimental.** ReSTIR GI (v1, temporal-only): per-pixel reservoirs reuse the 1-bounce indirect sample across frames at the reprojected same-surface point (no spatial reuse). Runs in a standalone pass with its own sampler budget; the lighting pass then skips its inline GI trace and the resolved GI is added at the à-trous denoise stage — so it only takes effect when `gi` **and** `denoise` are also on. Its mean matches the inline GI path; convergence character differs. `restirGIMCap` (default `20`) tunes the temporal M-cap. |
448
+ | `restirGI` | `false` | **Experimental.** ReSTIR GI (v2, fused spatiotemporal): per-pixel reservoirs reuse the 1-bounce indirect sample across frames at the reprojected same-surface point, then take `restirGISpatialTaps` spatial taps (default `2`, `0` = v1 temporal-only) of the previous frame's reservoirs — each reweighted by the reconnection solid-angle→area Jacobian and validated by a final visibility ray so light does not leak through walls. Runs in a standalone pass with its own sampler budget; the lighting pass then skips its inline GI trace and the resolved GI is added at the à-trous denoise stage — so it only takes effect when `gi` **and** `denoise` are also on. Its mean matches the inline GI path; the spatial taps cut per-frame variance. `restirGIMCap` (default `20`) tunes the temporal M-cap. `restirGIValidate` (default `8`, `0` = off) sets the reservoir-sample validation period: each frame a rotating 1-in-N subset of pixels re-aims its single candidate ray at the reservoir's stored hit and re-shades it; if the geometry moved or the re-shaded target collapsed to near-black (a light switched off) the reservoir is killed so fresh candidates rebuild, otherwise it is left untouched. This reuses the existing candidate trace (no extra bounce rays) and is what makes a switched-off light stop haunting the reservoir instead of fading slowly, while a static scene stays put. |
444
449
  | `ior` | `1.5` | **Global fallback** index of refraction for `refraction`. A `MeshPhysicalMaterial.ior` overrides it per material (fully-transmissive glass, range [1.0, 1.98]); this value applies to partial-transmission glass and as the default. |
450
+ | `dispersion` | `0` | Chromatic dispersion for glass, `0..0.5` (clamped). Splits refracted white light into a spectrum — a diamond throws a rainbow. Uses **stochastic spectral sampling**: each frame every glass pixel estimates one colour channel (R/G/B) through a channel-shifted ior and traces the *same single* refraction path, so it costs **no extra rays** and adds no traced-ray call site (it fits the Metal call-site budget). The three per-channel estimates are blended by the temporal accumulator, so the rainbow **only converges with accumulation on** and shimmers slightly in motion. **Global control** for now: three r160's `MeshPhysicalMaterial.dispersion` is not yet read per-material (no free G-buffer channel) — per-material dispersion is future work. |
445
451
  | `volumetric` | *off* | Physically-based god rays: single-scatter fog, one BVH-shadowed light sample per lighting pixel per frame, temporally accumulated. `{ enabled, density, maxDist, zones }`, where `zones` is an optional array of up to 8 AABBs `{ min:[x,y,z], max:[x,y,z], density }` that add localized fog on top of (or instead of) the global `density`. |
446
452
  | `stochasticLights` | `true` | One direct shadow ray per pixel per frame (random source) instead of one per light. The governor turns it off once it has scaled resolution up on strong hardware. |
447
453
  | `temporalReprojection` | `true` | Keep samples across camera/object motion. |
@@ -608,8 +614,37 @@ is **feature-detected** — it appears only when the loaded build exposes an
608
614
  The renderer can pass every compile and framebuffer check and still draw a black
609
615
  screen — that is exactly what shipped in 0.4.0 on iOS (WebKit's GLSL-to-Metal
610
616
  translation silently broke at a 4th `traceRadiance` call site; clean compile, no
611
- console error, black output). The only defence against that class of failure is
612
- 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.)
613
648
 
614
649
  **In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
615
650
  full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
@@ -619,12 +654,16 @@ and into a hidden `#selftest-verdict` DOM node:
619
654
 
620
655
  ```json
621
656
  { "pass": true, "meanLum": 139.08, "irrLum": 169.73, "glErrors": 0,
622
- "specMRT": true, "supported": true, "frames": 91, "ua": "…" }
657
+ "specMRT": true, "supported": true, "statusOk": true, "rtPrograms": 15,
658
+ "compileError": null, "disabled": [], "frames": 91, "ua": "…" }
623
659
  ```
624
660
 
625
661
  The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
626
662
  the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
627
- `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.
628
667
 
629
668
  - `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
630
669
  composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
@@ -639,12 +678,23 @@ the renderer with `preserveDrawingBuffer: true` so the canvas can be read back;
639
678
  normal runs keep the cheaper default.
640
679
 
641
680
  **In CI:** `npm run test:render` ([`scripts/selftest.mjs`](scripts/selftest.mjs))
642
- 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
643
682
  **chromium, firefox and webkit**, printing a pass/fail/skip table and exiting
644
683
  nonzero on any real failure (a documented environmental *skip* does not fail the
645
684
  suite). Playwright is loaded from a sibling checkout — see the top of the script.
646
685
 
647
- 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
648
698
  runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
649
699
  never finishes compiling the BVH megakernel here — headless chromium,
650
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.5.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,14 +35,36 @@ 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
 
42
45
  // Irradiance tap with the optional GI add folded into rgb (alpha untouched).
46
+ // METAL DIFFUSE WEIGHT: the GI add is DIFFUSE indirect irradiance. Metals have
47
+ // essentially no diffuse response — their indirect light rides the traced
48
+ // REFLECTION path — so their diffuse weight is (1 - metalness). RTLightingPass
49
+ // applies this implicitly to the inline GI: sampleIrr = mix(direct + indirect,
50
+ // reflRad, metal), scaling inline indirect by (1 - metal) on metals. The
51
+ // external ReSTIR GI add is injected HERE, downstream of that mix, so it never
52
+ // picked up the weight — a metalness-0.85 surface (the gold torus knot) received
53
+ // full-strength diffuse GI, ~6.6x too much. That excess is not just too bright:
54
+ // it is the ReSTIR resolve's residual per-pixel variance at full amplitude,
55
+ // which reads as bright gold speckles on the curved metal (worst on iOS/Metal,
56
+ // where the firefly stack has the least headroom). Re-apply the same
57
+ // (1 - metalness) diffuse weight to the add so the two GI paths are energy-
58
+ // consistent on metals and the speckle amplitude drops with the mean. Packed
59
+ // metal word (GBufferPass): metalness lives in [0,1]; glass [2,4) and alpha
60
+ // blend [4,5] are non-metal (weight 1, unchanged from before).
43
61
  vec4 sampleIrr(vec2 uv) {
44
62
  vec4 c = texture(uIrradiance, uv);
45
- if (uHasAdd) c.rgb += texture(uAddTex, uv).rgb;
63
+ if (uHasAdd) {
64
+ float mw = texture(uGNormalMetal, uv).w;
65
+ float metalT = mw < 2.0 ? clamp(mw, 0.0, 1.0) : 0.0;
66
+ c.rgb += texture(uAddTex, uv).rgb * (1.0 - metalT);
67
+ }
46
68
  return c;
47
69
  }
48
70
 
@@ -90,7 +112,12 @@ void main() {
90
112
  // business being brighter than its entire neighbourhood — clamp its
91
113
  // luminance to the brightest neighbour. Converged pixels are exempt, so
92
114
  // real small highlights survive.
93
- if (uStep < 1.5 && count < 8.0) {
115
+ // With an additive GI input (uHasAdd) the despeckle must ALWAYS run on the
116
+ // first iteration: count is the LIGHTING buffer's history depth and says
117
+ // nothing about the GI term, which is re-resolved fresh every frame — a GI
118
+ // firefly at a "converged" pixel would otherwise skip this clamp entirely
119
+ // and survive to the screen (observed as white speckles on iOS).
120
+ if (uStep < 1.5 && (count < 8.0 || uHasAdd)) {
94
121
  float maxL = 0.0;
95
122
  float found = 0.0;
96
123
  for (int dy = -1; dy <= 1; dy++) {
@@ -99,16 +126,16 @@ void main() {
99
126
  vec2 tuv = vUv + vec2(float(dx), float(dy)) * uTexelSize;
100
127
  if (tuv.x < 0.0 || tuv.x > 1.0 || tuv.y < 0.0 || tuv.y > 1.0) continue;
101
128
  if (texture(uGWorldPos, tuv).w < 0.5) continue;
102
- maxL = max(maxL, luminance(sampleIrr(tuv).rgb));
129
+ maxL = max(maxL, rtLum(sampleIrr(tuv).rgb));
103
130
  found = 1.0;
104
131
  }
105
132
  }
106
133
  float cap = maxL * 1.25 + 1e-4;
107
- float l = luminance(center.rgb);
134
+ float l = rtLum(center.rgb);
108
135
  if (found > 0.5 && l > cap) center.rgb *= cap / l;
109
136
  }
110
137
 
111
- float lumC = luminance(center.rgb);
138
+ float lumC = rtLum(center.rgb);
112
139
 
113
140
  // 3x3 B-spline-ish kernel, edge-avoiding weights.
114
141
  vec3 sum = center.rgb * 4.0;
@@ -131,7 +158,7 @@ void main() {
131
158
  // flat floor has no geometric edge to protect it, so at high iteration
132
159
  // counts the wide passes would average it away ("floating" objects with
133
160
  // no contact shadow). Wide steps only get to blend near-equal luminance.
134
- float wL = exp(-abs(luminance(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
161
+ float wL = exp(-abs(rtLum(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
135
162
  float w = k * wN * wZ * wL * (1.0 - specKeep);
136
163
  sum += s.rgb * w;
137
164
  wsum += w;
@@ -158,6 +185,9 @@ export class DenoisePass {
158
185
  this.targetB = this._makeTarget(width, height);
159
186
 
160
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",
161
191
  glslVersion: THREE.GLSL3,
162
192
  vertexShader: fullscreenVert,
163
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,