three-realtime-rt 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -182,6 +182,35 @@ Lighting is traced at half resolution by default and reconstructed by a joint
182
182
  bilateral upsample + the denoiser + TAA — the same "render few pixels, rebuild
183
183
  temporally" idea DLSS uses, done with hand-written math.
184
184
 
185
+ ### Debug views
186
+
187
+ Set `rt.outputMode` (the demo's **view** dropdown) to inspect a single stage
188
+ instead of the composited image:
189
+
190
+ | Mode | View | Shows |
191
+ |------|------|-------|
192
+ | `0` | composite | Final tonemapped image (default). |
193
+ | `1` | albedo | G-buffer base colour. |
194
+ | `2` | normals | World-space normals, `×0.5 + 0.5`. |
195
+ | `3` | irradiance | Demodulated diffuse lighting (direct + GI), pre-albedo. |
196
+ | `4` | world pos | World position, `fract(p × 0.1)`. |
197
+ | `5` | emissive | G-buffer emissive. |
198
+ | `6` | specular | The dielectric specular buffer. |
199
+ | `7` | **bvh cost** | Heatmap of how many BVH nodes this pixel's shadow rays visit. |
200
+
201
+ **Reading the BVH-cost heatmap.** Mode 7 counts, per pixel, the total BVH nodes
202
+ visited by every shadow ray the lighting pass casts that frame (the ReSTIR
203
+ winner / stochastic / per-light rays, plus reflection and glass occlusion rays),
204
+ and maps the count through a cold→hot palette: **blue is cheap** (few boxes),
205
+ through green and yellow, to **red and white for the most expensive** pixels. Hot
206
+ regions mean many box tests per shadow ray — dense or overlapping geometry, long
207
+ thin triangles whose bounding boxes overlap wastefully, or rays skimming almost
208
+ parallel to a surface (they thread a long corridor of the tree before they
209
+ either hit or escape). It bypasses temporal blending and the denoiser, so it is a
210
+ raw per-frame snapshot. `rt.costScale` sets the mapping (default `1/96`, so ~96
211
+ visits saturate to white); the demo's **cost scale** slider drives it as
212
+ "visits-to-saturate" so you can rescale the range to your scene.
213
+
185
214
  ## Moving objects (dynamic BVH)
186
215
 
187
216
  Mark meshes as dynamic and their motion casts **correct ray traced shadows** —
@@ -242,6 +271,48 @@ water plane) is O(dynamic tris) too — so **keep deforming meshes low-poly**. A
242
271
  `256×256` plane (~131k tris) will dominate the frame. The demo's mirror-water
243
272
  pool is a `48×48` plane.
244
273
 
274
+ ### Skinned meshes (animated characters)
275
+
276
+ A `SkinnedMesh` is **auto-detected** — just list it in `dynamicMeshes` (no
277
+ `userData` flag) and it is CPU-skinned into the dynamic BVH every frame, so an
278
+ animated character casts a **traced shadow that moves with its gait** and
279
+ rasterizes in its animated pose (not bind pose) in the G-buffer:
280
+
281
+ ```js
282
+ rt.compileScene(scene, { dynamicMeshes: [...crates, foxMesh] }); // foxMesh.isSkinnedMesh
283
+
284
+ // each frame:
285
+ mixer.update(dt); // advance the AnimationMixer
286
+ foxRoot.updateMatrixWorld(true); // pose the skeleton (bones -> world matrices) NOW
287
+ rt.updateDynamic(); // CPU-skins the live pose into the BVH
288
+ rt.render(scene, camera);
289
+ ```
290
+
291
+ - **Skin the pose before `updateDynamic()`.** The CPU skinning reads each bone's
292
+ `matrixWorld` (via three's `SkinnedMesh.applyBoneTransform` / `getVertexPosition`,
293
+ which apply the bind matrix, bone weights and bone matrices), so the skeleton
294
+ must be posed for this frame first. `mixer.update(dt)` then a forced
295
+ `updateMatrixWorld` on the character root does that — otherwise the traced
296
+ shadow lags the raster by a frame. (three r160's `applyBoneTransform` returns
297
+ the vertex in the mesh's **local/bind-relative** space; the tracer applies
298
+ `matrixWorld` itself, exactly like a rigid mover.)
299
+ - **Two sampler-friendly shortcuts.** Skinning is done for the mesh's *unique*
300
+ source vertices once per frame (shared triangle-soup slots reuse the result),
301
+ and secondary-ray **normals are per-face** — recomputed from the skinned
302
+ triangle positions rather than CPU-skinning the normal attribute. Flat-shaded
303
+ secondary rays are indistinguishable for shadows/GI, and **primary visibility
304
+ still gets smooth normals from the raster path** (the G-buffer skins the normal
305
+ properly via three's own `skinnormal_vertex` chunk). If your character's
306
+ geometry ships without a `normal` attribute (some glTF do — e.g. the Khronos
307
+ Fox), call `geometry.computeVertexNormals()` once after load so the raster path
308
+ has bind-pose normals to skin.
309
+
310
+ **Cost model.** CPU skinning is O(source verts × 4 bones) with zero per-vertex
311
+ allocation, plus the usual O(dynamic tris) BVH refit and normal upload. Budget
312
+ ~**10–20k total skinned source vertices to stay sub-2 ms**; the demo's Fox is
313
+ ≈ 1.7k source verts and skins in ≈ 0.3 ms. As with any dynamic mesh, keep the
314
+ skinned tris low and there is no static-world cost.
315
+
245
316
  ## Live lighting & sky
246
317
 
247
318
  Lights can be toggled, moved, and recoloured every frame without recompiling:
@@ -281,9 +352,11 @@ the honest map of what actually feeds the traced lighting.
281
352
 
282
353
  ### Materials
283
354
 
284
- Lighting reads the **first material only** on a multi-material mesh, pulling the
285
- scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lambert
286
- / Phong contribute whatever of those fields they have).
355
+ Lighting reads the scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial`
356
+ (Basic / Lambert / Phong contribute whatever of those fields they have). A
357
+ **multi-material mesh** (`mesh.material` is an array + `geometry.groups`) now feeds
358
+ **every group's** material into both the G-buffer and the BVH (see the *2nd+
359
+ material of a group* row).
287
360
 
288
361
  | Property | Feeds lighting? | Notes |
289
362
  |----------|-----------------|-------|
@@ -299,10 +372,10 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
299
372
  | `roughnessMap` | ✅ | `roughness × roughnessMap.g` (three.js convention) — sampled in the G-buffer. |
300
373
  | `metalnessMap` | ✅ | `metalness × metalnessMap.b` (a packed ORM texture works — G = roughness, B = metalness). |
301
374
  | `normalMap` | ✅ | Perturbs the shading normal via a screen-space cotangent frame (no tangent attribute needed); respects `material.normalScale`. |
302
- | `clearcoat`, `sheen`, `iridescence` | ❌ | Not modelled. |
303
- | vertex colors | | Not read into albedo. |
304
- | per-material `ior` | | Refraction uses the single **global** `rt.ior` (default 1.5), never `material.ior`. |
305
- | 2nd+ material of a group | | Only `material[0]` of a multi-material mesh reaches the G-buffer and BVH. |
375
+ | `clearcoat`, `sheen`, `iridescence` | ❌ | Per-pixel lobe parameters have **no remaining G-buffer channel** — the 4-MRT WebGL2 guarantee is fully packed (see the `GBufferPass` layout comment), so these stay unmodelled rather than risk corrupting the packing; revisit if a WebGPU backend lands. |
376
+ | vertex colors | | Geometry `color` attribute (3- or 4-component; `.rgb` used) multiplies into G-buffer albedo, gated so meshes without one render byte-identically. **Caveat:** secondary GI/reflection rays see the flat `material.color` (same as texture maps). |
377
+ | per-material `ior` | | `MeshPhysicalMaterial.ior` refracts per material, encoded in the packed material word for fully-transmissive glass. Supported range **[1.0, 1.98]** (values clamp; the tight ceiling keeps the packed word clear of the alpha-blend boundary). `rt.ior` is the global fallback (partial-transmission glass + the default); **`material.ior` wins when present**. |
378
+ | 2nd+ material of a group | | Each group material of a multi-material mesh (`mesh.material` array + `geometry.groups`) is registered separately in the G-buffer **and** the BVH, with per-vertex material indices; emissive group materials also join the NEE area-light list. **Limits:** opaque groups only (a transparent group throws — split it out); not supported on CPU-deforming (`rtDeforming`) meshes (throws). |
306
379
 
307
380
  ### Lights
308
381
 
@@ -339,7 +412,7 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
339
412
 
340
413
  - **1-bounce GI + direct light.** No multi-bounce diffuse, no caustics, no specular-chain paths.
341
414
  - **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.
342
- - **Refraction** is two-interface (front + back) with a single global IOR convincing glass, not a spectral / dispersive renderer.
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.
343
416
  - **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
344
417
  - **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).
345
418
 
@@ -367,12 +440,14 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
367
440
  | `refraction` | `true` | Traced two-interface refraction for `MeshPhysicalMaterial.transmission` surfaces. |
368
441
  | `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. |
369
442
  | `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. |
370
- | `ior` | `1.5` | Index of refraction used by `refraction`. |
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. |
444
+ | `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. |
371
445
  | `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`. |
372
446
  | `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. |
373
447
  | `temporalReprojection` | `true` | Keep samples across camera/object motion. |
374
448
  | `maxHistory` | `128` | History cap — higher is smoother, slower to react. |
375
449
  | `fireflyClamp` | `4.0` | Clamp on indirect luminance to suppress fireflies. |
450
+ | `costScale` | `1/96` | BVH-cost heatmap scale for the `outputMode: 7` debug view (shadow-ray node-visit count × this, mapped through a cold→hot palette). See *Debug views*. |
376
451
  | `sky` | *off* | Procedural sky as background + GI ambient (see above). |
377
452
  | `fog` | *off* | Distance fog, composited before tonemap. |
378
453
 
@@ -528,6 +603,69 @@ JSON line to the console every 2s for automated scraping. The overscan control
528
603
  is **feature-detected** — it appears only when the loaded build exposes an
529
604
  `overscan` property.
530
605
 
606
+ ## Render self-test
607
+
608
+ The renderer can pass every compile and framebuffer check and still draw a black
609
+ screen — that is exactly what shipped in 0.4.0 on iOS (WebKit's GLSL-to-Metal
610
+ 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.
613
+
614
+ **In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
615
+ full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
616
+ 50%), renders the normal gallery scene, and after **90 rendered frames** reads
617
+ the drawing buffer back and emits one JSON line to the console (`[selftest] …`)
618
+ and into a hidden `#selftest-verdict` DOM node:
619
+
620
+ ```json
621
+ { "pass": true, "meanLum": 139.08, "irrLum": 169.73, "glErrors": 0,
622
+ "specMRT": true, "supported": true, "frames": 91, "ua": "…" }
623
+ ```
624
+
625
+ The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
626
+ the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
627
+ `glErrors == 0` and `supported == true`.
628
+
629
+ - `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
630
+ composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
631
+ irradiance readback proves the **lighting** is alive, not just emissive geometry
632
+ surviving the composite (0.4.0's black image still showed emitters). A near-zero
633
+ reading is the black-screen class; the pass gate wants a lit mid-range value.
634
+ - `glErrors` — count of nonzero `gl.getError()` samples (any nonzero fails).
635
+ - `specMRT` / `supported` — the two capability fallbacks, for triage.
636
+
637
+ The page keeps rendering after the verdict so a human can watch. This mode builds
638
+ the renderer with `preserveDrawingBuffer: true` so the canvas can be read back;
639
+ normal runs keep the cheaper default.
640
+
641
+ **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
643
+ **chromium, firefox and webkit**, printing a pass/fail/skip table and exiting
644
+ nonzero on any real failure (a documented environmental *skip* does not fail the
645
+ suite). Playwright is loaded from a sibling checkout — see the top of the script.
646
+
647
+ Machine-specific note (Windows + NVIDIA, the current dev box): the chromium leg
648
+ runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
649
+ never finishes compiling the BVH megakernel here — headless chromium,
650
+ headed+`--use-angle=d3d11` and system Chrome all freeze at ~3 frames with silent
651
+ `VALIDATE_STATUS=false` storms — whereas the native NVIDIA GL backend compiles
652
+ it in ~137ms. A visible chromium window on the desktop during the run is
653
+ expected. On this box **firefox** and **webkit** come back `skip`: firefox
654
+ renders through that same stalling ANGLE-D3D11 backend and exposes no native-GL
655
+ switch, and Playwright's Windows webkit has no usable WebGL2 (context lost). Both
656
+ would actually run on a real-GPU Linux runner with native GL; only chromium is
657
+ required to pass here.
658
+
659
+ **What this catches, and what it does not.** The matrix catches API / JavaScript
660
+ / GLSL-frontend divergence between engines, and any regression that blackens or
661
+ errors the image on the engines it runs. It does **not** catch the original iOS
662
+ bug: Playwright's `webkit` on Windows is the WPE/GTK WebKit build, **not Apple's
663
+ Metal stack**, so it never exercises the GLSL-to-Metal code generator that
664
+ actually failed. **Real-device iOS testing stays manual.** The field kit for that
665
+ is on-device URL flags: `?diag=1` mirrors console errors onto the page (so a
666
+ photo of an iPad is a usable bug report) and `?nospecmrt=1` forces the
667
+ single-attachment WebKit fallback on any machine.
668
+
531
669
  ## Roadmap
532
670
 
533
671
  | Stage | Status | What |
@@ -543,7 +681,10 @@ is **feature-detected** — it appears only when the loaded build exposes an
543
681
  | 6b. Sampling | ✅ | Blue-noise sampling + ReSTIR direct lighting (temporal + spatial reuse) |
544
682
  | 6c. Any-hit shadows | ✅ | Unordered early-out BVH traversal for occlusion rays — same image, up to ~2× cheaper shadows |
545
683
  | 6d. PBR materials | ✅ | Cook-Torrance GGX dielectric specular + normal/roughness/metalness maps, alpha-blended transparency, deforming (water) meshes, overscan |
546
- | 7. Next | | Skinned-mesh (animated) shadows; DDGI irradiance probes; ReSTIR GI (indirect reservoir reuse); WGSL / WebGPU backend; per-material IOR |
684
+ | 6e. Skinned meshes | | Animated characters CPU-skinned into the dynamic BVH moving traced shadows + animated raster pose |
685
+ | 6f. Material completeness | ✅ | Vertex colors, per-material IOR, multi-material groups (clearcoat/sheen/iridescence documented as out of G-buffer budget) |
686
+ | 6g. ReSTIR GI | 🧪 | **Experimental** (`restirGI`, off by default): temporal-only reservoir reuse of the 1-bounce indirect sample (v1 — no spatial reuse yet) |
687
+ | 7. Next | — | DDGI irradiance probes; ReSTIR GI **spatial** reuse + sample validation; WGSL / WebGPU backend |
547
688
 
548
689
  ## Credits
549
690
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-realtime-rt",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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",
@@ -17,6 +17,7 @@
17
17
  "dev": "vite",
18
18
  "build": "vite build",
19
19
  "preview": "vite preview",
20
+ "test:render": "node scripts/selftest.mjs",
20
21
  "deploy": "vite build && gh-pages -d dist -t"
21
22
  },
22
23
  "keywords": [
@@ -30,7 +30,9 @@ uniform vec2 uVolTexelSize;
30
30
  uniform bool uVolEnabled;
31
31
  uniform vec3 uBackgroundColor;
32
32
  // 0 composite, 1 albedo, 2 normal, 3 irradiance (direct+GI), 4 worldPos,
33
- // 5 emissive, 6 specular
33
+ // 5 emissive, 6 specular, 7 bvh cost (heatmap of shadow-ray node visits — the
34
+ // lighting pass wrote the palette into the irradiance buffer, so it shares the
35
+ // mode-3 display path)
34
36
  uniform int uOutputMode;
35
37
 
36
38
  // joint bilateral upsample (lighting may be rendered below full resolution)
@@ -177,7 +179,9 @@ void main() {
177
179
 
178
180
  if (uOutputMode == 1) color = albedoRough.rgb;
179
181
  else if (uOutputMode == 2) color = N * 0.5 + 0.5;
180
- else if (uOutputMode == 3) color = irradiance;
182
+ // 3 = irradiance, 7 = bvh cost heatmap: both live in the irradiance buffer
183
+ // (the lighting pass wrote the cost palette there when uCostView was on).
184
+ else if (uOutputMode == 3 || uOutputMode == 7) color = irradiance;
181
185
  else if (uOutputMode == 4) color = fract(wp.xyz * 0.1);
182
186
  else if (uOutputMode == 5) color = emissive;
183
187
  else if (uOutputMode == 6) color = specular;
@@ -25,12 +25,29 @@ uniform float uEps;
25
25
  uniform float uLumSigma;
26
26
  uniform bool uBlendIsSpec; // this instance filters the specular buffer
27
27
 
28
+ // Optional additive input (EXPERIMENTAL ReSTIR GI): when uHasAdd is set, this
29
+ // texture's rgb is ADDED to every irradiance tap so the à-trous filter smooths
30
+ // the sum (the GI is injected here, downstream of the lighting pass's own
31
+ // temporal history, so it never double-counts through that history). The add is
32
+ // gated to the FIRST iteration by the caller (uStep == 1) — later iterations
33
+ // read the already-summed result. When uHasAdd is false this is byte-identical
34
+ // to the original filter (the alpha/history-count channel is never touched).
35
+ uniform sampler2D uAddTex;
36
+ uniform bool uHasAdd;
37
+
28
38
  float luminance(vec3 c) {
29
39
  return dot(c, vec3(0.299, 0.587, 0.114));
30
40
  }
31
41
 
42
+ // Irradiance tap with the optional GI add folded into rgb (alpha untouched).
43
+ vec4 sampleIrr(vec2 uv) {
44
+ vec4 c = texture(uIrradiance, uv);
45
+ if (uHasAdd) c.rgb += texture(uAddTex, uv).rgb;
46
+ return c;
47
+ }
48
+
32
49
  void main() {
33
- vec4 center = texture(uIrradiance, vUv);
50
+ vec4 center = sampleIrr(vUv);
34
51
  vec4 wp = texture(uGWorldPos, vUv);
35
52
  if (wp.w < 0.5) {
36
53
  outColor = center;
@@ -82,7 +99,7 @@ void main() {
82
99
  vec2 tuv = vUv + vec2(float(dx), float(dy)) * uTexelSize;
83
100
  if (tuv.x < 0.0 || tuv.x > 1.0 || tuv.y < 0.0 || tuv.y > 1.0) continue;
84
101
  if (texture(uGWorldPos, tuv).w < 0.5) continue;
85
- maxL = max(maxL, luminance(texture(uIrradiance, tuv).rgb));
102
+ maxL = max(maxL, luminance(sampleIrr(tuv).rgb));
86
103
  found = 1.0;
87
104
  }
88
105
  }
@@ -104,7 +121,7 @@ void main() {
104
121
 
105
122
  vec4 g = texture(uGWorldPos, tuv);
106
123
  if (g.w < 0.5) continue;
107
- vec4 s = texture(uIrradiance, tuv);
124
+ vec4 s = sampleIrr(tuv);
108
125
  vec3 Nt = normalize(texture(uGNormalMetal, tuv).xyz);
109
126
 
110
127
  float k = (dx == 0 || dy == 0) ? 2.0 : 1.0;
@@ -154,6 +171,8 @@ export class DenoisePass {
154
171
  uEps: { value: 1e-3 },
155
172
  uLumSigma: { value: 0.25 },
156
173
  uBlendIsSpec: { value: blendIsSpec },
174
+ uAddTex: { value: null },
175
+ uHasAdd: { value: false },
157
176
  },
158
177
  depthTest: false,
159
178
  depthWrite: false,
@@ -189,20 +208,29 @@ export class DenoisePass {
189
208
  this.targetB.setSize(width, height);
190
209
  }
191
210
 
192
- /** Runs `iterations` à-trous passes; returns the final filtered texture. */
193
- render(renderer, inputTexture, gbuffer, cameraPos, eps, iterations = 3) {
211
+ /**
212
+ * Runs `iterations` à-trous passes; returns the final filtered texture.
213
+ * `addTexture` (EXPERIMENTAL ReSTIR GI) is added to the input on the FIRST
214
+ * iteration only, so the filter smooths input + GI together; pass null to
215
+ * leave the filter byte-identical to before.
216
+ */
217
+ render(renderer, inputTexture, gbuffer, cameraPos, eps, iterations = 3, addTexture = null) {
194
218
  const u = this.material.uniforms;
195
219
  u.uGWorldPos.value = gbuffer.worldPos;
196
220
  u.uGNormalMetal.value = gbuffer.normalMetal;
197
221
  u.uTexelSize.value.set(1 / this._width, 1 / this._height);
198
222
  u.uCameraPos.value.copy(cameraPos);
199
223
  u.uEps.value = eps;
224
+ u.uAddTex.value = addTexture;
200
225
 
201
226
  let read = inputTexture;
202
227
  let write = this.targetA;
203
228
  for (let i = 0; i < iterations; i++) {
204
229
  u.uIrradiance.value = read;
205
230
  u.uStep.value = 1 << i;
231
+ // The GI add is applied once, on iteration 0 — later iterations read the
232
+ // already-summed intermediate, so adding again would double it.
233
+ u.uHasAdd.value = addTexture !== null && i === 0;
206
234
  renderer.setRenderTarget(write);
207
235
  renderer.render(this.scene, this.camera);
208
236
  read = write.texture;
@@ -1,17 +1,50 @@
1
1
  import * as THREE from "three";
2
2
 
3
+ // three defines USE_SKINNING and supplies the bindMatrix / bindMatrixInverse /
4
+ // boneTexture uniforms + skinIndex / skinWeight attributes automatically when the
5
+ // rendered object is a SkinnedMesh (this is a ShaderMaterial, not a
6
+ // RawShaderMaterial, so it inherits three's default vertex prefix and #include
7
+ // resolution). For a non-skinned mesh USE_SKINNING is undefined and every chunk
8
+ // below collapses to nothing — the shader source is identical in both cases.
9
+ // The chunks operate on `transformed` (position) and `objectNormal` (normal) in
10
+ // the mesh's LOCAL/bind space, so we map our own variables in and out. The
11
+ // skinned local position/normal then go through modelMatrix / uNormalMatrixWorld
12
+ // exactly like the un-skinned path — matching the CPU BVH skinning in
13
+ // SceneCompiler (both skin to local, then transform to world).
3
14
  const gbufferVert = /* glsl */ `
15
+ #include <skinning_pars_vertex>
16
+
4
17
  out vec3 vWorldPos;
5
18
  out vec3 vWorldNormal;
6
19
  out vec2 vUvCoord;
20
+ out vec3 vColor;
7
21
 
8
22
  uniform mat3 uNormalMatrixWorld;
9
23
 
10
24
  void main() {
11
- vec4 wp = modelMatrix * vec4(position, 1.0);
25
+ vec3 transformed = position;
26
+ vec3 objectNormal = normal;
27
+ #include <skinbase_vertex>
28
+ #include <skinnormal_vertex>
29
+ #include <skinning_vertex>
30
+
31
+ vec4 wp = modelMatrix * vec4(transformed, 1.0);
12
32
  vWorldPos = wp.xyz;
13
- vWorldNormal = normalize(uNormalMatrixWorld * normal);
33
+ vWorldNormal = normalize(uNormalMatrixWorld * objectNormal);
14
34
  vUvCoord = uv;
35
+ // Geometry vertex colours. three's shader prefix declares the built-in
36
+ // \`color\` attribute (vec3 or vec4) and sets USE_COLOR / USE_COLOR_ALPHA only
37
+ // when material.vertexColors is on — which we enable ONLY for meshes whose
38
+ // geometry actually carries a color attribute (see GBufferPass swap). A mesh
39
+ // without one compiles the else branch (white), so its albedo is byte-identical
40
+ // to before this varying existed. 4-component colours use .rgb.
41
+ #if defined( USE_COLOR_ALPHA )
42
+ vColor = color.rgb;
43
+ #elif defined( USE_COLOR )
44
+ vColor = color;
45
+ #else
46
+ vColor = vec3(1.0);
47
+ #endif
15
48
  gl_Position = projectionMatrix * viewMatrix * wp;
16
49
  }
17
50
  `;
@@ -27,11 +60,13 @@ layout(location = 3) out vec4 gEmissive;
27
60
  in vec3 vWorldPos;
28
61
  in vec3 vWorldNormal;
29
62
  in vec2 vUvCoord;
63
+ in vec3 vColor;
30
64
 
31
65
  uniform vec3 uColor;
32
66
  uniform float uRoughness;
33
67
  uniform float uMetalness;
34
68
  uniform float uTransmission;
69
+ uniform float uIor;
35
70
  uniform vec3 uEmissive;
36
71
  uniform sampler2D uMap;
37
72
  uniform bool uHasMap;
@@ -69,6 +104,7 @@ void main() {
69
104
  if (uHasMap) {
70
105
  albedo *= texture(uMap, vUvCoord).rgb;
71
106
  }
107
+ albedo *= vColor; // vertex colours (white when the mesh has no color attribute)
72
108
  vec3 emissive = uEmissive;
73
109
  if (uHasEmissiveMap) {
74
110
  emissive *= texture(uEmissiveMap, vUvCoord).rgb;
@@ -87,16 +123,34 @@ void main() {
87
123
  float metalness = uMetalness;
88
124
  if (uHasMetalnessMap) metalness *= texture(uMetalnessMap, vUvCoord).b;
89
125
  gAlbedoRough = vec4(albedo, roughness);
90
- // .w is a packed material word in three disjoint ranges, so the lighting pass
91
- // reads specular/glass/blend properties without an extra G-buffer sampler (it
92
- // already sits at the WebGL2 16-sampler minimum):
93
- // [0,1] plain metalness [2,3] transmissive glass (w - 2 = transmission)
94
- // [4,5] alpha blend (w - 4 = opacity). Blend wins: a transparent surface is
95
- // kept out of the BVH and composited by the lighting pass, so it must never be
96
- // read as glass even if the material also carries a transmission value.
97
- float matWord = uBlend
98
- ? 4.0 + uOpacity
99
- : (uTransmission > 0.0 ? 2.0 + uTransmission : metalness);
126
+ // .w is a packed material word in disjoint ranges, so the lighting pass reads
127
+ // specular/glass/blend properties without an extra G-buffer sampler (it already
128
+ // sits at the WebGL2 16-sampler minimum — the reason per-material IOR rides
129
+ // here rather than in a third G-buffer texture the lighting pass would have to
130
+ // sample):
131
+ // [0,1] plain metalness
132
+ // (2,3] transmissive glass, PARTIAL: w - 2 = transmission (global rt.ior)
133
+ // [3,4) transmissive glass, FULL (transmission >= ~1): w - 3 = ior - 1
134
+ // [4,5] alpha blend: w - 4 = opacity
135
+ // Blend wins: a transparent surface is kept out of the BVH and composited by
136
+ // the lighting pass, so it must never be read as glass. Every EXISTING consumer
137
+ // decodes clamp(w - 2, 0, 1) as transmission, which saturates to 1.0 across the
138
+ // whole [3,4) band — so full glass keeps reading as fully transmissive there and
139
+ // only the lighting pass additionally recovers the per-material IOR (Task 2).
140
+ float matWord;
141
+ if (uBlend) {
142
+ matWord = 4.0 + uOpacity;
143
+ } else if (uTransmission > 0.0) {
144
+ if (uTransmission >= 0.99) {
145
+ // clamp (ior - 1) to [0, 0.98] so the word stays clear of the 4.0 blend
146
+ // boundary even after fp16 rounding of this channel; covers ior 1.0-1.98.
147
+ matWord = 3.0 + clamp(uIor - 1.0, 0.0, 0.98);
148
+ } else {
149
+ matWord = 2.0 + uTransmission; // partial glass: keep transmission, global ior
150
+ }
151
+ } else {
152
+ matWord = metalness;
153
+ }
100
154
  gNormalMetal = vec4(n, matWord);
101
155
  // .w packs the valid flag AND roughness: 0 = background, 1 + roughness
102
156
  // otherwise. Every consumer only tests w < 0.5, so this stays compatible.
@@ -184,46 +238,54 @@ export class GBufferPass {
184
238
  for (const t of this._targets) t.setSize(width, height);
185
239
  }
186
240
 
187
- _gbufferMaterialFor(mesh) {
188
- let material = this._materialCache.get(mesh);
189
- if (!material) {
190
- material = new THREE.ShaderMaterial({
191
- glslVersion: THREE.GLSL3,
192
- vertexShader: gbufferVert,
193
- fragmentShader: gbufferFrag,
194
- uniforms: {
195
- uNormalMatrixWorld: { value: new THREE.Matrix3() },
196
- uColor: { value: new THREE.Color(1, 1, 1) },
197
- uRoughness: { value: 1.0 },
198
- uMetalness: { value: 0.0 },
199
- uTransmission: { value: 0.0 },
200
- uEmissive: { value: new THREE.Color(0, 0, 0) },
201
- uMap: { value: null },
202
- uHasMap: { value: false },
203
- uEmissiveMap: { value: null },
204
- uHasEmissiveMap: { value: false },
205
- uNormalMap: { value: null },
206
- uHasNormalMap: { value: false },
207
- uNormalScale: { value: new THREE.Vector2(1, 1) },
208
- uRoughnessMap: { value: null },
209
- uHasRoughnessMap: { value: false },
210
- uMetalnessMap: { value: null },
211
- uHasMetalnessMap: { value: false },
212
- uBlend: { value: false },
213
- uOpacity: { value: 1.0 },
214
- },
215
- side: THREE.FrontSide,
216
- });
217
- this._materialCache.set(mesh, material);
218
- }
241
+ _makeGbufferMaterial(mesh) {
242
+ const material = new THREE.ShaderMaterial({
243
+ glslVersion: THREE.GLSL3,
244
+ vertexShader: gbufferVert,
245
+ fragmentShader: gbufferFrag,
246
+ uniforms: {
247
+ uNormalMatrixWorld: { value: new THREE.Matrix3() },
248
+ uColor: { value: new THREE.Color(1, 1, 1) },
249
+ uRoughness: { value: 1.0 },
250
+ uMetalness: { value: 0.0 },
251
+ uTransmission: { value: 0.0 },
252
+ uIor: { value: 1.5 },
253
+ uEmissive: { value: new THREE.Color(0, 0, 0) },
254
+ uMap: { value: null },
255
+ uHasMap: { value: false },
256
+ uEmissiveMap: { value: null },
257
+ uHasEmissiveMap: { value: false },
258
+ uNormalMap: { value: null },
259
+ uHasNormalMap: { value: false },
260
+ uNormalScale: { value: new THREE.Vector2(1, 1) },
261
+ uRoughnessMap: { value: null },
262
+ uHasRoughnessMap: { value: false },
263
+ uMetalnessMap: { value: null },
264
+ uHasMetalnessMap: { value: false },
265
+ uBlend: { value: false },
266
+ uOpacity: { value: 1.0 },
267
+ },
268
+ side: THREE.FrontSide,
269
+ });
270
+ // Enable the vertex-colour path ONLY when this mesh's geometry carries a
271
+ // color attribute. This drives three's USE_COLOR define (see gbufferVert), so
272
+ // a mesh without one writes byte-identical albedo. The material cache is
273
+ // per-mesh, so this per-mesh define variant is safe.
274
+ material.vertexColors = !!(mesh.geometry && mesh.geometry.getAttribute("color"));
275
+ return material;
276
+ }
219
277
 
220
- // Sync properties from the user's material every frame (cheap).
221
- const src = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
278
+ // Sync properties from one user material into one gbuffer material (cheap; run
279
+ // every frame). `mesh` supplies the shared world normal matrix + side.
280
+ _syncGbufferMaterial(material, src, mesh) {
222
281
  const u = material.uniforms;
223
282
  if (src.color) u.uColor.value.copy(src.color);
224
283
  u.uRoughness.value = src.roughness ?? 1.0;
225
284
  u.uMetalness.value = src.metalness ?? 0.0;
226
285
  u.uTransmission.value = src.transmission ?? 0.0; // MeshPhysicalMaterial
286
+ // Per-material IOR (MeshPhysicalMaterial.ior; default 1.5). Encoded into the
287
+ // packed material word for fully-transmissive glass (see gbufferFrag).
288
+ u.uIor.value = src.ior ?? 1.5;
227
289
 
228
290
  if (src.emissive) {
229
291
  u.uEmissive.value
@@ -249,6 +311,30 @@ export class GBufferPass {
249
311
  u.uOpacity.value = src.opacity ?? 1.0;
250
312
  u.uNormalMatrixWorld.value.getNormalMatrix(mesh.matrixWorld);
251
313
  material.side = src.side ?? THREE.FrontSide;
314
+ }
315
+
316
+ // Returns the gbuffer material(s) for a mesh: a single ShaderMaterial, or — for
317
+ // a multi-material mesh (mesh.material is an array + geometry.groups) — an ARRAY
318
+ // of them, one per source material, index-aligned so three renders each group
319
+ // with its own gbuffer material natively.
320
+ _gbufferMaterialFor(mesh) {
321
+ if (Array.isArray(mesh.material)) {
322
+ let cached = this._materialCache.get(mesh);
323
+ if (!Array.isArray(cached) || cached.length !== mesh.material.length) {
324
+ cached = mesh.material.map(() => this._makeGbufferMaterial(mesh));
325
+ this._materialCache.set(mesh, cached);
326
+ }
327
+ for (let i = 0; i < mesh.material.length; i++) {
328
+ this._syncGbufferMaterial(cached[i], mesh.material[i], mesh);
329
+ }
330
+ return cached;
331
+ }
332
+ let material = this._materialCache.get(mesh);
333
+ if (!material || Array.isArray(material)) {
334
+ material = this._makeGbufferMaterial(mesh);
335
+ this._materialCache.set(mesh, material);
336
+ }
337
+ this._syncGbufferMaterial(material, mesh.material, mesh);
252
338
  return material;
253
339
  }
254
340