three-realtime-rt 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -14
- package/package.json +2 -1
- package/src/CompositePass.js +6 -2
- package/src/DenoisePass.js +58 -6
- package/src/GBufferPass.js +132 -46
- package/src/GIReservoirPass.js +946 -0
- package/src/RTLightingPass.js +123 -10
- package/src/RealtimeRaytracer.js +143 -6
- package/src/SceneCompiler.js +386 -51
- package/src/bvhAnyHit.glsl.js +15 -0
- package/src/index.d.ts +105 -5
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
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|----------|-----------------|-------|
|
|
@@ -291,7 +364,7 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
|
|
|
291
364
|
| `roughness` | ✅ | Drives shadow / GI softness, reflection sharpness **and the GGX specular lobe width** (see *dielectric specular* below). |
|
|
292
365
|
| `metalness` | ✅ | Metallic pixels trace a reflection ray whose analytic-light glints are shadowed; `F0 = mix(0.04, albedo, metalness)`. |
|
|
293
366
|
| `emissive` | ✅ | A *static* emissive mesh becomes a real **area light** (NEE) — casts soft light + shadows, including a GGX highlight. |
|
|
294
|
-
| `emissiveMap` |
|
|
367
|
+
| `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. |
|
|
295
368
|
| `transmission` (Physical) | ✅ | Glass: Fresnel reflection (with analytic-light glints) + two-interface refraction. |
|
|
296
369
|
| `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`. |
|
|
297
370
|
| `opacity` on an opaque material | ❌ | Only read when `transparent: true`; an opaque material always writes at full coverage. |
|
|
@@ -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` | ❌ |
|
|
303
|
-
| vertex colors |
|
|
304
|
-
| per-material `ior` |
|
|
305
|
-
| 2nd+ material of a group |
|
|
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
|
|
|
@@ -310,7 +383,7 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
|
|
|
310
383
|
|-------|-----------|-------|
|
|
311
384
|
| `PointLight` | ✅ | `light.userData.rtRadius` (default `0.15`) sets soft-shadow size. |
|
|
312
385
|
| `DirectionalLight` | ✅ | `light.userData.rtRadius` (default `0.02`) sets sun softness; keep its direction in sync with `sky.sunDir`. |
|
|
313
|
-
| Emissive meshes | ✅ static | Sampled directly as area lights. **Dynamic** emitters
|
|
386
|
+
| 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. |
|
|
314
387
|
| `SpotLight` | ✅ | Cone + penumbra respected; soft shadows via `rtRadius`; visible light cones in volumetric fog. |
|
|
315
388
|
| `RectAreaLight` | ❌ | Use an emissive mesh instead. |
|
|
316
389
|
| `HemisphereLight` / `AmbientLight` | ❌ | Ignored — the procedural `sky` (or `envColor`) provides ambient. |
|
|
@@ -324,8 +397,9 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
|
|
|
324
397
|
bright ones, and let `fireflyClamp` do its job.
|
|
325
398
|
- Up to **32** point/directional lights (`MAX_LIGHTS`); further lights are dropped.
|
|
326
399
|
- Moving, toggling, recolouring or dimming a light → `rt.updateLights(scene)` (cheap, no recompile).
|
|
327
|
-
-
|
|
328
|
-
-
|
|
400
|
+
- **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.
|
|
401
|
+
- **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.
|
|
402
|
+
- 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.
|
|
329
403
|
|
|
330
404
|
### Geometry & occlusion
|
|
331
405
|
|
|
@@ -339,7 +413,7 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
|
|
|
339
413
|
|
|
340
414
|
- **1-bounce GI + direct light.** No multi-bounce diffuse, no caustics, no specular-chain paths.
|
|
341
415
|
- **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)
|
|
416
|
+
- **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.
|
|
343
417
|
- **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
|
|
344
418
|
- **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
419
|
|
|
@@ -367,12 +441,15 @@ scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lamber
|
|
|
367
441
|
| `refraction` | `true` | Traced two-interface refraction for `MeshPhysicalMaterial.transmission` surfaces. |
|
|
368
442
|
| `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
443
|
| `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
|
-
| `
|
|
444
|
+
| `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. |
|
|
445
|
+
| `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. |
|
|
446
|
+
| `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. |
|
|
371
447
|
| `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
448
|
| `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
449
|
| `temporalReprojection` | `true` | Keep samples across camera/object motion. |
|
|
374
450
|
| `maxHistory` | `128` | History cap — higher is smoother, slower to react. |
|
|
375
451
|
| `fireflyClamp` | `4.0` | Clamp on indirect luminance to suppress fireflies. |
|
|
452
|
+
| `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
453
|
| `sky` | *off* | Procedural sky as background + GI ambient (see above). |
|
|
377
454
|
| `fog` | *off* | Distance fog, composited before tonemap. |
|
|
378
455
|
|
|
@@ -528,6 +605,69 @@ JSON line to the console every 2s for automated scraping. The overscan control
|
|
|
528
605
|
is **feature-detected** — it appears only when the loaded build exposes an
|
|
529
606
|
`overscan` property.
|
|
530
607
|
|
|
608
|
+
## Render self-test
|
|
609
|
+
|
|
610
|
+
The renderer can pass every compile and framebuffer check and still draw a black
|
|
611
|
+
screen — that is exactly what shipped in 0.4.0 on iOS (WebKit's GLSL-to-Metal
|
|
612
|
+
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.
|
|
615
|
+
|
|
616
|
+
**In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
|
|
617
|
+
full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
|
|
618
|
+
50%), renders the normal gallery scene, and after **90 rendered frames** reads
|
|
619
|
+
the drawing buffer back and emits one JSON line to the console (`[selftest] …`)
|
|
620
|
+
and into a hidden `#selftest-verdict` DOM node:
|
|
621
|
+
|
|
622
|
+
```json
|
|
623
|
+
{ "pass": true, "meanLum": 139.08, "irrLum": 169.73, "glErrors": 0,
|
|
624
|
+
"specMRT": true, "supported": true, "frames": 91, "ua": "…" }
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
|
|
628
|
+
the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
|
|
629
|
+
`glErrors == 0` and `supported == true`.
|
|
630
|
+
|
|
631
|
+
- `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
|
|
632
|
+
composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
|
|
633
|
+
irradiance readback proves the **lighting** is alive, not just emissive geometry
|
|
634
|
+
surviving the composite (0.4.0's black image still showed emitters). A near-zero
|
|
635
|
+
reading is the black-screen class; the pass gate wants a lit mid-range value.
|
|
636
|
+
- `glErrors` — count of nonzero `gl.getError()` samples (any nonzero fails).
|
|
637
|
+
- `specMRT` / `supported` — the two capability fallbacks, for triage.
|
|
638
|
+
|
|
639
|
+
The page keeps rendering after the verdict so a human can watch. This mode builds
|
|
640
|
+
the renderer with `preserveDrawingBuffer: true` so the canvas can be read back;
|
|
641
|
+
normal runs keep the cheaper default.
|
|
642
|
+
|
|
643
|
+
**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
|
|
645
|
+
**chromium, firefox and webkit**, printing a pass/fail/skip table and exiting
|
|
646
|
+
nonzero on any real failure (a documented environmental *skip* does not fail the
|
|
647
|
+
suite). Playwright is loaded from a sibling checkout — see the top of the script.
|
|
648
|
+
|
|
649
|
+
Machine-specific note (Windows + NVIDIA, the current dev box): the chromium leg
|
|
650
|
+
runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
|
|
651
|
+
never finishes compiling the BVH megakernel here — headless chromium,
|
|
652
|
+
headed+`--use-angle=d3d11` and system Chrome all freeze at ~3 frames with silent
|
|
653
|
+
`VALIDATE_STATUS=false` storms — whereas the native NVIDIA GL backend compiles
|
|
654
|
+
it in ~137ms. A visible chromium window on the desktop during the run is
|
|
655
|
+
expected. On this box **firefox** and **webkit** come back `skip`: firefox
|
|
656
|
+
renders through that same stalling ANGLE-D3D11 backend and exposes no native-GL
|
|
657
|
+
switch, and Playwright's Windows webkit has no usable WebGL2 (context lost). Both
|
|
658
|
+
would actually run on a real-GPU Linux runner with native GL; only chromium is
|
|
659
|
+
required to pass here.
|
|
660
|
+
|
|
661
|
+
**What this catches, and what it does not.** The matrix catches API / JavaScript
|
|
662
|
+
/ GLSL-frontend divergence between engines, and any regression that blackens or
|
|
663
|
+
errors the image on the engines it runs. It does **not** catch the original iOS
|
|
664
|
+
bug: Playwright's `webkit` on Windows is the WPE/GTK WebKit build, **not Apple's
|
|
665
|
+
Metal stack**, so it never exercises the GLSL-to-Metal code generator that
|
|
666
|
+
actually failed. **Real-device iOS testing stays manual.** The field kit for that
|
|
667
|
+
is on-device URL flags: `?diag=1` mirrors console errors onto the page (so a
|
|
668
|
+
photo of an iPad is a usable bug report) and `?nospecmrt=1` forces the
|
|
669
|
+
single-attachment WebKit fallback on any machine.
|
|
670
|
+
|
|
531
671
|
## Roadmap
|
|
532
672
|
|
|
533
673
|
| Stage | Status | What |
|
|
@@ -543,7 +683,10 @@ is **feature-detected** — it appears only when the loaded build exposes an
|
|
|
543
683
|
| 6b. Sampling | ✅ | Blue-noise sampling + ReSTIR direct lighting (temporal + spatial reuse) |
|
|
544
684
|
| 6c. Any-hit shadows | ✅ | Unordered early-out BVH traversal for occlusion rays — same image, up to ~2× cheaper shadows |
|
|
545
685
|
| 6d. PBR materials | ✅ | Cook-Torrance GGX dielectric specular + normal/roughness/metalness maps, alpha-blended transparency, deforming (water) meshes, overscan |
|
|
546
|
-
|
|
|
686
|
+
| 6e. Skinned meshes | ✅ | Animated characters CPU-skinned into the dynamic BVH — moving traced shadows + animated raster pose |
|
|
687
|
+
| 6f. Material completeness | ✅ | Vertex colors, per-material IOR, multi-material groups (clearcoat/sheen/iridescence documented as out of G-buffer budget) |
|
|
688
|
+
| 6g. ReSTIR GI | 🧪 | **Experimental** (`restirGI`, off by default): temporal-only reservoir reuse of the 1-bounce indirect sample (v1 — no spatial reuse yet) |
|
|
689
|
+
| 7. Next | — | DDGI irradiance probes; ReSTIR GI **spatial** reuse + sample validation; WGSL / WebGPU backend |
|
|
547
690
|
|
|
548
691
|
## Credits
|
|
549
692
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "three-realtime-rt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": [
|
package/src/CompositePass.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/DenoisePass.js
CHANGED
|
@@ -25,12 +25,48 @@ 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
|
+
// METAL DIFFUSE WEIGHT: the GI add is DIFFUSE indirect irradiance. Metals have
|
|
44
|
+
// essentially no diffuse response — their indirect light rides the traced
|
|
45
|
+
// REFLECTION path — so their diffuse weight is (1 - metalness). RTLightingPass
|
|
46
|
+
// applies this implicitly to the inline GI: sampleIrr = mix(direct + indirect,
|
|
47
|
+
// reflRad, metal), scaling inline indirect by (1 - metal) on metals. The
|
|
48
|
+
// external ReSTIR GI add is injected HERE, downstream of that mix, so it never
|
|
49
|
+
// picked up the weight — a metalness-0.85 surface (the gold torus knot) received
|
|
50
|
+
// full-strength diffuse GI, ~6.6x too much. That excess is not just too bright:
|
|
51
|
+
// it is the ReSTIR resolve's residual per-pixel variance at full amplitude,
|
|
52
|
+
// which reads as bright gold speckles on the curved metal (worst on iOS/Metal,
|
|
53
|
+
// where the firefly stack has the least headroom). Re-apply the same
|
|
54
|
+
// (1 - metalness) diffuse weight to the add so the two GI paths are energy-
|
|
55
|
+
// consistent on metals and the speckle amplitude drops with the mean. Packed
|
|
56
|
+
// metal word (GBufferPass): metalness lives in [0,1]; glass [2,4) and alpha
|
|
57
|
+
// blend [4,5] are non-metal (weight 1, unchanged from before).
|
|
58
|
+
vec4 sampleIrr(vec2 uv) {
|
|
59
|
+
vec4 c = texture(uIrradiance, uv);
|
|
60
|
+
if (uHasAdd) {
|
|
61
|
+
float mw = texture(uGNormalMetal, uv).w;
|
|
62
|
+
float metalT = mw < 2.0 ? clamp(mw, 0.0, 1.0) : 0.0;
|
|
63
|
+
c.rgb += texture(uAddTex, uv).rgb * (1.0 - metalT);
|
|
64
|
+
}
|
|
65
|
+
return c;
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
void main() {
|
|
33
|
-
vec4 center =
|
|
69
|
+
vec4 center = sampleIrr(vUv);
|
|
34
70
|
vec4 wp = texture(uGWorldPos, vUv);
|
|
35
71
|
if (wp.w < 0.5) {
|
|
36
72
|
outColor = center;
|
|
@@ -73,7 +109,12 @@ void main() {
|
|
|
73
109
|
// business being brighter than its entire neighbourhood — clamp its
|
|
74
110
|
// luminance to the brightest neighbour. Converged pixels are exempt, so
|
|
75
111
|
// real small highlights survive.
|
|
76
|
-
|
|
112
|
+
// With an additive GI input (uHasAdd) the despeckle must ALWAYS run on the
|
|
113
|
+
// first iteration: count is the LIGHTING buffer's history depth and says
|
|
114
|
+
// nothing about the GI term, which is re-resolved fresh every frame — a GI
|
|
115
|
+
// firefly at a "converged" pixel would otherwise skip this clamp entirely
|
|
116
|
+
// and survive to the screen (observed as white speckles on iOS).
|
|
117
|
+
if (uStep < 1.5 && (count < 8.0 || uHasAdd)) {
|
|
77
118
|
float maxL = 0.0;
|
|
78
119
|
float found = 0.0;
|
|
79
120
|
for (int dy = -1; dy <= 1; dy++) {
|
|
@@ -82,7 +123,7 @@ void main() {
|
|
|
82
123
|
vec2 tuv = vUv + vec2(float(dx), float(dy)) * uTexelSize;
|
|
83
124
|
if (tuv.x < 0.0 || tuv.x > 1.0 || tuv.y < 0.0 || tuv.y > 1.0) continue;
|
|
84
125
|
if (texture(uGWorldPos, tuv).w < 0.5) continue;
|
|
85
|
-
maxL = max(maxL, luminance(
|
|
126
|
+
maxL = max(maxL, luminance(sampleIrr(tuv).rgb));
|
|
86
127
|
found = 1.0;
|
|
87
128
|
}
|
|
88
129
|
}
|
|
@@ -104,7 +145,7 @@ void main() {
|
|
|
104
145
|
|
|
105
146
|
vec4 g = texture(uGWorldPos, tuv);
|
|
106
147
|
if (g.w < 0.5) continue;
|
|
107
|
-
vec4 s =
|
|
148
|
+
vec4 s = sampleIrr(tuv);
|
|
108
149
|
vec3 Nt = normalize(texture(uGNormalMetal, tuv).xyz);
|
|
109
150
|
|
|
110
151
|
float k = (dx == 0 || dy == 0) ? 2.0 : 1.0;
|
|
@@ -154,6 +195,8 @@ export class DenoisePass {
|
|
|
154
195
|
uEps: { value: 1e-3 },
|
|
155
196
|
uLumSigma: { value: 0.25 },
|
|
156
197
|
uBlendIsSpec: { value: blendIsSpec },
|
|
198
|
+
uAddTex: { value: null },
|
|
199
|
+
uHasAdd: { value: false },
|
|
157
200
|
},
|
|
158
201
|
depthTest: false,
|
|
159
202
|
depthWrite: false,
|
|
@@ -189,20 +232,29 @@ export class DenoisePass {
|
|
|
189
232
|
this.targetB.setSize(width, height);
|
|
190
233
|
}
|
|
191
234
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Runs `iterations` à-trous passes; returns the final filtered texture.
|
|
237
|
+
* `addTexture` (EXPERIMENTAL ReSTIR GI) is added to the input on the FIRST
|
|
238
|
+
* iteration only, so the filter smooths input + GI together; pass null to
|
|
239
|
+
* leave the filter byte-identical to before.
|
|
240
|
+
*/
|
|
241
|
+
render(renderer, inputTexture, gbuffer, cameraPos, eps, iterations = 3, addTexture = null) {
|
|
194
242
|
const u = this.material.uniforms;
|
|
195
243
|
u.uGWorldPos.value = gbuffer.worldPos;
|
|
196
244
|
u.uGNormalMetal.value = gbuffer.normalMetal;
|
|
197
245
|
u.uTexelSize.value.set(1 / this._width, 1 / this._height);
|
|
198
246
|
u.uCameraPos.value.copy(cameraPos);
|
|
199
247
|
u.uEps.value = eps;
|
|
248
|
+
u.uAddTex.value = addTexture;
|
|
200
249
|
|
|
201
250
|
let read = inputTexture;
|
|
202
251
|
let write = this.targetA;
|
|
203
252
|
for (let i = 0; i < iterations; i++) {
|
|
204
253
|
u.uIrradiance.value = read;
|
|
205
254
|
u.uStep.value = 1 << i;
|
|
255
|
+
// The GI add is applied once, on iteration 0 — later iterations read the
|
|
256
|
+
// already-summed intermediate, so adding again would double it.
|
|
257
|
+
u.uHasAdd.value = addTexture !== null && i === 0;
|
|
206
258
|
renderer.setRenderTarget(write);
|
|
207
259
|
renderer.render(this.scene, this.camera);
|
|
208
260
|
read = write.texture;
|