three-realtime-rt 0.6.0 → 0.7.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 +309 -10
- package/package.json +2 -1
- package/src/CompositePass.js +3 -0
- package/src/CopyPass.js +3 -0
- package/src/DenoisePass.js +11 -5
- package/src/GBufferPass.js +121 -2
- package/src/GIReservoirPass.js +19 -12
- package/src/RTLightingPass.js +81 -1
- package/src/RealtimeRaytracer.js +408 -4
- package/src/RestirPass.js +12 -5
- package/src/SceneCompiler.js +212 -1
- package/src/TAAPass.js +6 -0
- package/src/VolumetricPass.js +3 -0
- package/src/index.d.ts +154 -0
- package/src/mrtCompat.js +37 -0
package/README.md
CHANGED
|
@@ -134,7 +134,13 @@ 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). Call it **explicitly**: if
|
|
142
|
+
`render()` finds no compiled scene it compiles one itself, with *no options*,
|
|
143
|
+
so everything is static (it warns once — `implicit-compile`).
|
|
138
144
|
3. **Declare movers** — pass moving meshes to
|
|
139
145
|
`rt.compileScene(scene, { dynamicMeshes: [...] })`, then call
|
|
140
146
|
`rt.updateDynamic()` each frame after you move them (e.g. after a physics
|
|
@@ -313,6 +319,71 @@ allocation, plus the usual O(dynamic tris) BVH refit and normal upload. Budget
|
|
|
313
319
|
≈ 1.7k source verts and skins in ≈ 0.3 ms. As with any dynamic mesh, keep the
|
|
314
320
|
skinned tris low and there is no static-world cost.
|
|
315
321
|
|
|
322
|
+
## Volumetric surface albedo (world-space 3D-texture colour)
|
|
323
|
+
|
|
324
|
+
Colour a surface by a **world-space 3D texture** sampled at the ray hit point,
|
|
325
|
+
instead of a flat colour or a 2D UV map. This is how you paint a **volumetric data
|
|
326
|
+
field** — stress, temperature, density, a distance field — onto a mesh in a path
|
|
327
|
+
tracer, where a custom fragment shader can't run. Opt a material in through
|
|
328
|
+
`userData`, and the tracer samples your (already colour-mapped) `Data3DTexture` for
|
|
329
|
+
that surface's albedo in **both** primary visibility (the G-buffer, so the
|
|
330
|
+
raster/hybrid view agrees) **and** the traced GI / reflection bounces (so the
|
|
331
|
+
field's colours bleed correctly through global illumination):
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
import * as THREE from "three";
|
|
335
|
+
|
|
336
|
+
// An RGB(A) 3D texture you have ALREADY colour-mapped (the tracer samples .rgb —
|
|
337
|
+
// it contains no colormap logic). RGBA8 is fine; no float-filtering required.
|
|
338
|
+
const field = new THREE.Data3DTexture(rgbaBytes, N, N, N); // your data
|
|
339
|
+
field.format = THREE.RGBAFormat;
|
|
340
|
+
field.type = THREE.UnsignedByteType;
|
|
341
|
+
field.needsUpdate = true;
|
|
342
|
+
|
|
343
|
+
// Map the volume into world space: origin = world position of the texel-(0,0,0)
|
|
344
|
+
// corner, size = the world extent of the whole volume. A mesh's world AABB is the
|
|
345
|
+
// natural choice, so the field fills the mesh.
|
|
346
|
+
mesh.updateMatrixWorld(true);
|
|
347
|
+
const box = new THREE.Box3().setFromObject(mesh);
|
|
348
|
+
mesh.material.userData.rtVolumeAlbedo = {
|
|
349
|
+
texture: field,
|
|
350
|
+
origin: box.min.clone(),
|
|
351
|
+
size: box.getSize(new THREE.Vector3()),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
rt.compileScene(scene); // detects the opt-in; recompile after adding/changing it
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
At the hit point `p`, the tracer computes `uvw = clamp((p - origin) / size, 0, 1)`
|
|
358
|
+
and samples the texture **trilinearly** (it sets `LinearFilter` + `ClampToEdge` on
|
|
359
|
+
your texture at compile time). The sampled RGB **replaces the base albedo**;
|
|
360
|
+
`roughness`, `metalness` and `emissive` still compose normally. Updating the
|
|
361
|
+
texture **data** later (an animated field) needs only `texture.needsUpdate = true`
|
|
362
|
+
— no recompile; changing which material carries the field, or its `origin` / `size`,
|
|
363
|
+
needs a `compileScene()`.
|
|
364
|
+
|
|
365
|
+
**Demo:** [`volumetric-albedo.html`](volumetric-albedo.html) — a torus knot coloured
|
|
366
|
+
by a procedural 3D noise field (turbo colormap) under an emissive area light, with
|
|
367
|
+
the field's colours bleeding onto white walls through GI.
|
|
368
|
+
|
|
369
|
+
**v1 is single-volume for the traced-bounce path.** Any number of materials may
|
|
370
|
+
carry distinct volumes and each renders correctly in **primary visibility**; the
|
|
371
|
+
GI / reflection **bounce** samples only the first registered volume. The reason is
|
|
372
|
+
the sampler budget: the lighting megakernel already binds the WebGL2-guaranteed
|
|
373
|
+
minimum of **16** fragment samplers, and this feature's bounce path adds one
|
|
374
|
+
`sampler3D` (the 17th). It is compiled in only when a scene uses the feature **and**
|
|
375
|
+
the GPU exposes ≥ 17 fragment texture units (`MAX_TEXTURE_IMAGE_UNITS` — most
|
|
376
|
+
desktop GPUs report 32). On a bare-minimum 16-unit device the bounces fall back to
|
|
377
|
+
the material's flat base colour (a one-time `console.info`), while primary
|
|
378
|
+
visibility still shows the full field. The whole feature is behind a compile-time
|
|
379
|
+
`RT_VOLUME_ALBEDO` define that is **off unless a volume material is registered**, so
|
|
380
|
+
scenes that don't use it compile byte-identical shaders. Multi-volume bounces are
|
|
381
|
+
future work — the `userData` API doesn't preclude them. One more edge: the
|
|
382
|
+
**experimental** `restirGI` path (off by default) resolves the indirect bounce in
|
|
383
|
+
its own kernel and does **not** yet sample the volume — a volume surface's
|
|
384
|
+
*indirect* contribution falls back to its flat colour while `restirGI` is on (the
|
|
385
|
+
default inline-GI path carries the field correctly).
|
|
386
|
+
|
|
316
387
|
## Live lighting & sky
|
|
317
388
|
|
|
318
389
|
Lights can be toggled, moved, and recoloured every frame without recompiling:
|
|
@@ -368,14 +439,16 @@ material of a group* row).
|
|
|
368
439
|
| `transmission` (Physical) | ✅ | Glass: Fresnel reflection (with analytic-light glints) + two-interface refraction. |
|
|
369
440
|
| `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
441
|
| `opacity` on an opaque material | ❌ | Only read when `transparent: true`; an opaque material always writes at full coverage. |
|
|
442
|
+
| `alphaMap` | ❌ | There is **no per-pixel opacity route**: opacity is a **scalar per material**, packed into the material word the lighting pass reads. An `alphaMap` is ignored — a mesh carrying one blends at its uniform `opacity` (see the `transparent` + `opacity` row, the supported path), so a partly-cut-out texture reads as an evenly translucent surface. For hard cut-outs use `alphaTest` (`transparent: false`), which occludes as full triangles. |
|
|
371
443
|
| **dielectric specular** | ✅ | Cook-Torrance **GGX** direct highlights for *every* surface, in a separate white (`F0 ≈ 0.04`) specular buffer the composite adds without the albedo multiply. Toggle with `specular` (default on). |
|
|
372
444
|
| `roughnessMap` | ✅ | `roughness × roughnessMap.g` (three.js convention) — sampled in the G-buffer. |
|
|
373
445
|
| `metalnessMap` | ✅ | `metalness × metalnessMap.b` (a packed ORM texture works — G = roughness, B = metalness). |
|
|
374
446
|
| `normalMap` | ✅ | Perturbs the shading normal via a screen-space cotangent frame (no tangent attribute needed); respects `material.normalScale`. |
|
|
375
447
|
| `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
448
|
| 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). |
|
|
449
|
+
| `userData.rtVolumeAlbedo` *(since 0.7.0)* | ✅ | **World-space 3D-texture albedo** — sample a colour-mapped `Data3DTexture` at the world hit point for the surface's albedo, in primary visibility **and** GI/reflection bounces. For volumetric data fields (stress/temperature/density) on a surface. See *[Volumetric surface albedo](#volumetric-surface-albedo-world-space-3d-texture-colour)*. Single volume in the bounce path in v1 (needs a 17th fragment sampler; primary visibility is unlimited). |
|
|
377
450
|
| 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
|
|
451
|
+
| 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 a mesh that is **both** listed in `dynamicMeshes` **and** flagged `userData.rtDeforming` — that combination is what the per-frame live-geometry rebake can't express, and it throws. (`rtDeforming` *without* `dynamicMeshes` membership is inert: the flag is ignored, the mesh compiles static, and the library warns — see *[Supported object types](#supported-object-types)*.) |
|
|
379
452
|
|
|
380
453
|
### Lights
|
|
381
454
|
|
|
@@ -401,10 +474,26 @@ material of a group* row).
|
|
|
401
474
|
- **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
475
|
- 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.
|
|
403
476
|
|
|
477
|
+
### Supported object types
|
|
478
|
+
|
|
479
|
+
What kind of `Object3D` the tracer can actually see. Anything it cannot trace is
|
|
480
|
+
also excluded from the rasterized G-buffer, so the traced frame stays consistent
|
|
481
|
+
with what the lighting was computed against — and the library **warns once**,
|
|
482
|
+
naming the object (see *[Diagnostics](#diagnostics-statuswarnings)*).
|
|
483
|
+
|
|
484
|
+
| Object | Supported | Notes |
|
|
485
|
+
|--------|-----------|-------|
|
|
486
|
+
| `Mesh` | ✅ | The normal case: merged into the static BVH, or into the per-frame dynamic BVH when listed in `dynamicMeshes`. |
|
|
487
|
+
| `SkinnedMesh` | ✅ | Auto-detected (no flag). **CPU-skinned** into the dynamic BVH every frame from its live skeleton pose — list it in `dynamicMeshes`. See *[Skinned meshes](#skinned-meshes-animated-characters)*. |
|
|
488
|
+
| `InstancedMesh` | ❌ | **Instancing is not supported.** The per-instance matrices are a GPU attribute the compiler never reads, so it **collapses to a single instance** in the traced output *and* in the G-buffer. Warns (`instanced-mesh`). Expand it to individual meshes, or exclude it with `userData.rtExclude`. |
|
|
489
|
+
| `Sprite` / `Line` / `LineSegments` / `Points` | — | Not traceable geometry, and their materials write a single `gl_FragColor`, which cannot feed the 4-attachment G-buffer. They are **automatically hidden for the traced frame** (and restored right after), so they simply do not appear. Warns (`untraceable-object`). Draw them yourself in an **overlay pass on top of `rt.render()`**, or set `userData.rtExclude` on them to silence the warning. |
|
|
490
|
+
| `userData.rtExclude` | ✅ honored | Keeps a mesh out of the BVH entirely — it still rasterizes into the G-buffer and gets lit, it just never occludes or bounces light. Also suppresses the warnings above, so it is the way to say "yes, I meant that". |
|
|
491
|
+
| `Group` / `Object3D` / `Bone` | ✅ | Pure transform nodes; traversed for their mesh children. |
|
|
492
|
+
|
|
404
493
|
### Geometry & occlusion
|
|
405
494
|
|
|
406
495
|
- Every non-excluded visible mesh is **merged into one static BVH at compile time**. Add / remove geometry → recompile.
|
|
407
|
-
- Meshes that move must be declared via `dynamicMeshes` and driven with `updateDynamic()`. Anything not declared is treated as static — moving it on screen won't move its traced shadow.
|
|
496
|
+
- Meshes that move must be declared via `dynamicMeshes` and driven with `updateDynamic()`. Anything not declared is treated as static — moving it on screen won't move its traced shadow. The library **detects this and warns** (`stale-transform` / `stale-geometry`); see *[Diagnostics](#diagnostics-statuswarnings)*.
|
|
408
497
|
- **Transparent materials never occlude** (by design — a glass case shouldn't cast an opaque shadow). They still rasterize normally.
|
|
409
498
|
- **`alphaTest` cut-outs** (`transparent: false`) *do* occlude — but as **full triangles**, not per-texel, so their shadows are blocky.
|
|
410
499
|
- `mesh.userData.rtExclude = true` removes a mesh from the BVH entirely (it still rasterizes and gets lit) — handy for water / translucent surfaces.
|
|
@@ -416,6 +505,10 @@ material of a group* row).
|
|
|
416
505
|
- **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.
|
|
417
506
|
- **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
|
|
418
507
|
- **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).
|
|
508
|
+
- **What's behind the glass is a genuinely traced ray**, not a re-sorted draw of the same rasterized surfaces — so it is *stronger* than sorted transparency in the one way that matters: the surface seen through the pane is shaded with real traced direct light and GI at its own hit point, including things outside the frustum and things the raster pass never drew.
|
|
509
|
+
- **Only BVH geometry shows through.** The blend ray hits the compiled scene, so anything kept out of the BVH is invisible behind glass — including *other transparent surfaces* (they are never occluders). The nearest transparent surface wins, so an **intermediate translucent layer disappears the moment another transparent surface covers it**: two panes in a row read as one.
|
|
510
|
+
- **Behind-radiance bypasses the firefly / irradiance clamps.** It is composited as radiance rather than filtered demodulated irradiance, so a bright emitter **seen through glass reads hotter than the same emitter viewed directly** (which the clamps tame). That is a known asymmetry, not a bug in your scene — dim the emitter or thicken the pane's `opacity` if it bothers you.
|
|
511
|
+
- **Transparent meshes are never dynamic.** They are dropped before the dynamic registration, so listing one in `dynamicMeshes` does nothing at all (the library warns: `transparent-dynamic`).
|
|
419
512
|
|
|
420
513
|
### Platform
|
|
421
514
|
|
|
@@ -430,6 +523,9 @@ material of a group* row).
|
|
|
430
523
|
| `renderScale` | `0.5` | Lighting resolution vs. the G-buffer. `1.0` = max quality. |
|
|
431
524
|
| `overscan` | `0` | Render past the canvas edges and crop the centre back, so leading-edge disocclusion noise during camera motion is born off-screen. Padding fraction per edge (0–0.25); `0.1` costs 1.44× the pixels. See *Edge convergence and overscan*. |
|
|
432
525
|
| `adaptiveQuality` | `true` | Governor that steers `renderScale` / `denoiseIterations` / `stochasticLights` toward `targetFps` — scales **up** on strong hardware, **down** on weak. Turn off for manual control. |
|
|
526
|
+
| `targetFps` | `55` | Frame rate the governor steers toward. |
|
|
527
|
+
| `canvasScaleHook` | `null` | Callback `(scale) => void` letting the governor drive your **canvas scale** — its deepest, most valuable lever. See *[canvasScaleHook](#canvasscalehook-the-governors-deepest-lever)*. |
|
|
528
|
+
| `taaJitterScale` | `1` | Scales the sub-pixel TAA jitter. Set it to your current canvas scale when you CSS-stretch a reduced drawing buffer, so the jitter stays constant in *screen* pixels instead of wobbling. |
|
|
433
529
|
| `denoiseIterations` | `2` | À-trous denoise passes; the governor raises this as it lowers resolution. |
|
|
434
530
|
| `taa` | `true` | Temporal anti-aliasing (jitter + neighbourhood clamp). |
|
|
435
531
|
| `denoise` | `true` | Edge-aware à-trous denoiser. |
|
|
@@ -547,6 +643,79 @@ starting point or take manual control:
|
|
|
547
643
|
pixel per frame instead of one per light — the biggest ray-count lever for
|
|
548
644
|
many-light scenes and mobile GPUs.
|
|
549
645
|
|
|
646
|
+
### canvasScaleHook (the governor's deepest lever)
|
|
647
|
+
|
|
648
|
+
`renderScale` only shrinks the *lighting* buffer. **Canvas scale** shrinks the
|
|
649
|
+
whole drawing buffer, so every pass — the raster G-buffer, lighting, denoise,
|
|
650
|
+
TAA, resolve — gets quadratically cheaper. That makes it the strongest lever the
|
|
651
|
+
governor has, and the one it reaches for **first when recovering** quality and
|
|
652
|
+
**last when cutting** it (once `renderScale` has bottomed out at `0.2`).
|
|
653
|
+
|
|
654
|
+
The canvas belongs to your app, not the library, so the governor cannot resize it
|
|
655
|
+
itself: it calls **`canvasScaleHook(scale)`** with the next value from
|
|
656
|
+
`RealtimeRaytracer.CANVAS_LEVELS` (`[1, 0.85, 0.75, 0.62, 0.5]`) and leaves the
|
|
657
|
+
implementation to you. The pattern is: render a **reduced drawing buffer**, then
|
|
658
|
+
**CSS-stretch** the canvas back to full size.
|
|
659
|
+
|
|
660
|
+
```js
|
|
661
|
+
let canvasScale = 1;
|
|
662
|
+
|
|
663
|
+
const applyCanvasSize = () => {
|
|
664
|
+
// CSS size stays the full layout size; the BUFFER shrinks.
|
|
665
|
+
renderer.domElement.style.width = `${innerWidth}px`;
|
|
666
|
+
renderer.domElement.style.height = `${innerHeight}px`;
|
|
667
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2) * canvasScale);
|
|
668
|
+
renderer.setSize(innerWidth, innerHeight, false);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const setCanvasScale = (s) => {
|
|
672
|
+
canvasScale = s;
|
|
673
|
+
applyCanvasSize();
|
|
674
|
+
rt.setSize(...renderer.getDrawingBufferSize(new THREE.Vector2()).toArray());
|
|
675
|
+
// Keep the TAA jitter constant in SCREEN pixels: at a reduced canvas scale the
|
|
676
|
+
// CSS stretch magnifies buffer-pixel jitter into visible wobble.
|
|
677
|
+
rt.taaJitterScale = s;
|
|
678
|
+
};
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Two rules: call `rt.setSize(...)` with **drawing-buffer pixels** after the resize,
|
|
682
|
+
and set **`rt.taaJitterScale = s`**. Skipping the second is the usual cause of
|
|
683
|
+
"the image shimmers once auto-quality kicks in". The hook is optional — without
|
|
684
|
+
it the governor simply never uses the canvas lever. Live examples:
|
|
685
|
+
[`examples/main.js`](examples/main.js) and [`examples/gallery.js`](examples/gallery.js).
|
|
686
|
+
|
|
687
|
+
### Recommended integration
|
|
688
|
+
|
|
689
|
+
Tier detection for the starting point, the governor for everything after, and the
|
|
690
|
+
canvas hook so the governor has its deepest lever:
|
|
691
|
+
|
|
692
|
+
```js
|
|
693
|
+
const renderer = new THREE.WebGLRenderer({ antialias: false });
|
|
694
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
695
|
+
|
|
696
|
+
const tier = RealtimeRaytracer.detectTier(renderer); // "none" | "mid" | "high"
|
|
697
|
+
const rt = new RealtimeRaytracer(renderer, {
|
|
698
|
+
...RealtimeRaytracer.recommendedOptions(tier), // sensible start for the GPU
|
|
699
|
+
adaptiveQuality: true, // (already the default)
|
|
700
|
+
targetFps: 55,
|
|
701
|
+
canvasScaleHook: (s) => setCanvasScale(s), // see above
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
rt.compileScene(scene, { dynamicMeshes: movers }); // explicit — see Diagnostics
|
|
705
|
+
|
|
706
|
+
function frame() {
|
|
707
|
+
requestAnimationFrame(frame);
|
|
708
|
+
rt.updateDynamic(); // only on frames where something moved
|
|
709
|
+
rt.render(scene, camera);
|
|
710
|
+
}
|
|
711
|
+
frame();
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
`detectTier` picks *where you start*; `adaptiveQuality` decides *where you end up*
|
|
715
|
+
on the machine that actually loaded the page; `canvasScaleHook` widens the range
|
|
716
|
+
it can move through. On a device too slow to trace at all, `rt.supported` is
|
|
717
|
+
`false` and `rt.render()` is a plain `renderer.render()` — no branch needed.
|
|
718
|
+
|
|
550
719
|
WebGPU: not used as a backend (this is a WebGL2 library); a WGSL compute
|
|
551
720
|
backend is on the roadmap.
|
|
552
721
|
|
|
@@ -605,13 +774,118 @@ JSON line to the console every 2s for automated scraping. The overscan control
|
|
|
605
774
|
is **feature-detected** — it appears only when the loaded build exposes an
|
|
606
775
|
`overscan` property.
|
|
607
776
|
|
|
777
|
+
## Diagnostics (`status.warnings`)
|
|
778
|
+
|
|
779
|
+
*Since 0.7.0.* Most integration mistakes in a hybrid tracer are **silent**: the
|
|
780
|
+
image still renders, nothing throws, and the lighting is quietly computed against
|
|
781
|
+
a scene that is not the one on screen. So the library detects the common ones at
|
|
782
|
+
compile time (and on a cheap periodic scan while rendering), prints **one**
|
|
783
|
+
`console.warn` naming the object and the exact fix, and records the same thing on
|
|
784
|
+
`rt.status.warnings`:
|
|
785
|
+
|
|
786
|
+
```js
|
|
787
|
+
rt.render(scene, camera);
|
|
788
|
+
for (const w of rt.status.warnings) {
|
|
789
|
+
console.log(w.code, w.message); // e.g. "stale-transform", "three-realtime-rt: ..."
|
|
790
|
+
}
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
`rt.status.warnings` is `{ code, message }[]`, deduplicated, and **never affects
|
|
794
|
+
`status.ok`** — these are scene-setup diagnostics, not pipeline failures (for
|
|
795
|
+
those see [`compileError` / `status.coreFailure`](#render-self-test)).
|
|
796
|
+
|
|
797
|
+
| Code | Fires when | Consequence |
|
|
798
|
+
|------|------------|-------------|
|
|
799
|
+
| `stale-geometry` | A **static** mesh's `position` buffer changed after `compileScene()`. | Traced lighting still uses the ORIGINAL shape. |
|
|
800
|
+
| `stale-transform` | A **static** mesh was moved after `compileScene()`. | Traced lighting still uses the ORIGINAL transform — its shadow stays behind. |
|
|
801
|
+
| `rtdeforming-not-dynamic` | `userData.rtDeforming` is set on a mesh that is **not** in `dynamicMeshes`. | The flag is ignored; the mesh compiles static. |
|
|
802
|
+
| `implicit-compile` | `render()` had to compile the scene itself. | Compiled with **no options** — everything is static. |
|
|
803
|
+
| `untraceable-object` | A visible `Sprite` / `Line` / `Points`. | Auto-hidden from the traced frame; render it in your own overlay pass. |
|
|
804
|
+
| `instanced-mesh` | An `InstancedMesh`. | Collapses to a single instance. |
|
|
805
|
+
| `transparent-dynamic` | A `transparent` mesh listed in `dynamicMeshes`. | That entry does nothing. |
|
|
806
|
+
|
|
807
|
+
The stale scan runs **every 30th frame**, compares one integer and one matrix per
|
|
808
|
+
static mesh, stops checking a mesh once it has reported, and caps at 8 reports —
|
|
809
|
+
so it costs effectively nothing in a correct scene. Set `userData.rtExclude` on
|
|
810
|
+
an object to say "this is intentional" and silence its warning.
|
|
811
|
+
|
|
812
|
+
### Troubleshooting
|
|
813
|
+
|
|
814
|
+
**"Black patches", "the shadow doesn't move", "rays still hit the original
|
|
815
|
+
shape".** All three are the same bug: a **stale BVH**. The tracer bakes geometry
|
|
816
|
+
into a BVH at `compileScene()` time. Move a mesh (or edit its vertices) after
|
|
817
|
+
that without declaring it, and the rasterized image shows the new pose while the
|
|
818
|
+
traced lighting still shadows, bounces and reflects off the **old** one — which
|
|
819
|
+
reads as a shadow left behind, a reflection of something that isn't there, or a
|
|
820
|
+
dark patch where the invisible old geometry still occludes.
|
|
821
|
+
|
|
822
|
+
The fixes, in order of how much you are asking for:
|
|
823
|
+
|
|
824
|
+
| Your object… | Do this |
|
|
825
|
+
|--------------|---------|
|
|
826
|
+
| never moves after setup | nothing — but call `compileScene(scene)` again after you add/remove/replace geometry. |
|
|
827
|
+
| moves rigidly every frame | `compileScene(scene, { dynamicMeshes: [mesh] })`, then `rt.updateDynamic()` each frame. |
|
|
828
|
+
| has **vertices** that move on the CPU (water, cloth, morphs) | the above **plus** `mesh.userData.rtDeforming = true`, and keep its `normal` attribute current. See *[Deforming meshes](#deforming-meshes-water-cloth)*. |
|
|
829
|
+
| is an animated character | list its `SkinnedMesh` in `dynamicMeshes` — skinning is auto-detected. See *[Skinned meshes](#skinned-meshes-animated-characters)*. |
|
|
830
|
+
| changed structurally (mesh added/removed, material swapped, emission changed) | `compileScene()` again. |
|
|
831
|
+
|
|
832
|
+
You should not have to guess which one you hit: the `stale-geometry` /
|
|
833
|
+
`stale-transform` / `rtdeforming-not-dynamic` warnings above name the mesh and
|
|
834
|
+
the fix in the console. If a mesh is *deliberately* left out of the BVH, set
|
|
835
|
+
`userData.rtExclude = true`.
|
|
836
|
+
|
|
837
|
+
**"Nothing I do to `dynamicMeshes` has any effect."** Check for
|
|
838
|
+
`implicit-compile` in the console: if the first `rt.render()` ran before any
|
|
839
|
+
`compileScene()` call, the scene was compiled implicitly **with no options** and
|
|
840
|
+
every mesh is static. Call `compileScene(scene, options)` yourself first.
|
|
841
|
+
|
|
842
|
+
**"My HUD sprite / debug lines disappeared."** They are not traceable geometry
|
|
843
|
+
and cannot write the G-buffer, so they are hidden for the traced frame
|
|
844
|
+
(`untraceable-object`). Draw them in your own pass on top of `rt.render()`.
|
|
845
|
+
|
|
608
846
|
## Render self-test
|
|
609
847
|
|
|
610
848
|
The renderer can pass every compile and framebuffer check and still draw a black
|
|
611
849
|
screen — that is exactly what shipped in 0.4.0 on iOS (WebKit's GLSL-to-Metal
|
|
612
850
|
translation silently broke at a 4th `traceRadiance` call site; clean compile, no
|
|
613
|
-
console error, black output)
|
|
614
|
-
|
|
851
|
+
console error, black output), and again on three r166+ when three's injected
|
|
852
|
+
`luminance` helper collided with the library's own and the affected pass programs
|
|
853
|
+
failed to link. The only defence against that class of failure is to **look at
|
|
854
|
+
the pixels**, so the demo has a headless-friendly self-test.
|
|
855
|
+
|
|
856
|
+
**Programmatic signal (`rt.compileError` / `rt.status`).** A pass whose program
|
|
857
|
+
fails to *link* renders black without throwing — three logs to the console and
|
|
858
|
+
sets `program.diagnostics.runnable = false`, but rendering proceeds. So over the
|
|
859
|
+
first several rendered frames the renderer inspects `renderer.info.programs` for
|
|
860
|
+
its own (stably named `rt:*`) pass programs and reports what it finds:
|
|
861
|
+
|
|
862
|
+
```js
|
|
863
|
+
rt.render(scene, camera); // ...for a few frames
|
|
864
|
+
if (!rt.status.ok) {
|
|
865
|
+
// rt.compileError → "rt:lighting: 'luminance' : function already has a body"
|
|
866
|
+
if (rt.status.coreFailure) showRaster(`raster (${rt.compileError})`);
|
|
867
|
+
else console.warn("degraded:", rt.status.disabled); // e.g. [{pass, feature, reason}]
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
- **`rt.compileError`** (`string | null`) — first / most-severe failure summary
|
|
872
|
+
(`"rt:<pass>: <driver log>"`), or `null` while every pass compiles clean.
|
|
873
|
+
- **`rt.status`** (`{ ok, disabled, coreFailure, warnings }`) — `ok` is `true` on the healthy
|
|
874
|
+
path and `false` once any `rt:*` pass fails to link. **Optional** features whose
|
|
875
|
+
pass failed are **auto-disabled** so the image stays lit (`restir`, `restirGI`,
|
|
876
|
+
`denoise`, `volumetric`, `taa`, `specular`), each listed in `disabled` as
|
|
877
|
+
`{ pass, feature, reason }` with a one-line driver log. A **core** pass
|
|
878
|
+
(`gbuffer` / `lighting` / `composite`) has no fallback: `coreFailure` names it
|
|
879
|
+
and the image is black-but-diagnosed. This lets an integrator render an honest
|
|
880
|
+
`raster (reason)` fallback instead of guessing. (When `supported` is `false` the
|
|
881
|
+
RT pipeline never runs, so `status.ok` is `false` too — check `supported` first.)
|
|
882
|
+
- **`rt.status.warnings`** (`{ code, message }[]`, **since 0.7.0**) — USAGE
|
|
883
|
+
diagnostics: a flag being ignored, an object type that cannot be traced, a
|
|
884
|
+
static mesh edited after `compileScene()`. Separate axis from the three fields
|
|
885
|
+
above: the pipeline is healthy, the scene setup is not what you probably meant,
|
|
886
|
+
so **warnings never change `status.ok`**. Full list in
|
|
887
|
+
*[Diagnostics](#diagnostics-statuswarnings)*. (`compileError` and the base
|
|
888
|
+
`status` surface are since 0.6.1.)
|
|
615
889
|
|
|
616
890
|
**In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
|
|
617
891
|
full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
|
|
@@ -620,13 +894,22 @@ the drawing buffer back and emits one JSON line to the console (`[selftest] …`
|
|
|
620
894
|
and into a hidden `#selftest-verdict` DOM node:
|
|
621
895
|
|
|
622
896
|
```json
|
|
623
|
-
{ "pass": true, "meanLum": 139.
|
|
624
|
-
"specMRT": true, "supported": true, "
|
|
897
|
+
{ "pass": true, "meanLum": 139.80, "irrLum": 170.53, "glErrors": 0,
|
|
898
|
+
"specMRT": true, "supported": true, "statusOk": true, "rtPrograms": 15,
|
|
899
|
+
"compileError": null, "disabled": [], "warnings": 0, "warningCodes": [],
|
|
900
|
+
"frames": 91, "ua": "…" }
|
|
625
901
|
```
|
|
626
902
|
|
|
627
903
|
The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
|
|
628
904
|
the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
|
|
629
|
-
`glErrors == 0` and `
|
|
905
|
+
`glErrors == 0`, `supported == true`, `statusOk == true`, and `warnings == 0`.
|
|
906
|
+
`statusOk` asserts the compile-failure surface above stayed clean (`rt.status.ok`,
|
|
907
|
+
`compileError` null) *and* that the named pass programs were actually discoverable
|
|
908
|
+
(`rtPrograms > 0`), so a broken diagnosis can't read as a false pass. `warnings`
|
|
909
|
+
is `rt.status.warnings.length`: the demo is the reference integration, so the
|
|
910
|
+
healthy path must raise **none** — a nonzero count means either the demo really
|
|
911
|
+
did something wrong, or a [diagnostic](#diagnostics-statuswarnings) has started
|
|
912
|
+
firing on a correct scene.
|
|
630
913
|
|
|
631
914
|
- `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
|
|
632
915
|
composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
|
|
@@ -641,12 +924,28 @@ the renderer with `preserveDrawingBuffer: true` so the canvas can be read back;
|
|
|
641
924
|
normal runs keep the cheaper default.
|
|
642
925
|
|
|
643
926
|
**In CI:** `npm run test:render` ([`scripts/selftest.mjs`](scripts/selftest.mjs))
|
|
644
|
-
starts vite on
|
|
927
|
+
starts vite on free ports and drives `?selftest=1` through Playwright across
|
|
645
928
|
**chromium, firefox and webkit**, printing a pass/fail/skip table and exiting
|
|
646
929
|
nonzero on any real failure (a documented environmental *skip* does not fail the
|
|
647
930
|
suite). Playwright is loaded from a sibling checkout — see the top of the script.
|
|
648
931
|
|
|
649
|
-
|
|
932
|
+
**Three-version matrix.** The r166+ `luminance` break shipped because nothing
|
|
933
|
+
tested a newer three, so **chromium runs twice**: once against the pinned three
|
|
934
|
+
(`0.160.1`) and once against **`three@latest`** (a second vite with
|
|
935
|
+
`RT_THREE=latest`, which [`vite.config.js`](vite.config.js) aliases `three` to the
|
|
936
|
+
`three-latest` devDependency). The `chromium@3latest` row is the guard for that
|
|
937
|
+
class of regression, and **the pass gate requires both chromium legs**. A fourth
|
|
938
|
+
chromium load of `?selftest=empty` asserts the [empty-scene no-op](#empty-scene)
|
|
939
|
+
(`compileScene` on a scene with no meshes is a no-op and `render()` falls back to
|
|
940
|
+
plain raster instead of crashing), and a fifth,
|
|
941
|
+
[`?selftest=warnings`](#diagnostics-statuswarnings), drives a deliberately
|
|
942
|
+
mis-configured scene and asserts every usage diagnostic fires **exactly once**,
|
|
943
|
+
lands on `status.warnings`, leaves `status.ok` true, and that a visible
|
|
944
|
+
`Sprite`/`Line` costs zero GL errors (it is hidden from the 4-attachment G-buffer
|
|
945
|
+
rather than drawn into it). Both are gating. firefox/webkit stay single-leg
|
|
946
|
+
against the default three (both are environmental skips here — see below).
|
|
947
|
+
|
|
948
|
+
Machine-specific note (Windows + NVIDIA, the current dev box): each chromium leg
|
|
650
949
|
runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
|
|
651
950
|
never finishes compiling the BVH megakernel here — headless chromium,
|
|
652
951
|
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.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -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
|
}
|
package/src/CompositePass.js
CHANGED
|
@@ -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,
|
package/src/DenoisePass.js
CHANGED
|
@@ -35,7 +35,10 @@ uniform bool uBlendIsSpec; // this instance filters the specular buffer
|
|
|
35
35
|
uniform sampler2D uAddTex;
|
|
36
36
|
uniform bool uHasAdd;
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// Named rtLum, NOT luminance: three r166+ prepends its own luminance(vec3)
|
|
39
|
+
// to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
|
|
40
|
+
// (vec3) body as a redefinition — the whole program fails to compile.
|
|
41
|
+
float rtLum(vec3 c) {
|
|
39
42
|
return dot(c, vec3(0.299, 0.587, 0.114));
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -123,16 +126,16 @@ void main() {
|
|
|
123
126
|
vec2 tuv = vUv + vec2(float(dx), float(dy)) * uTexelSize;
|
|
124
127
|
if (tuv.x < 0.0 || tuv.x > 1.0 || tuv.y < 0.0 || tuv.y > 1.0) continue;
|
|
125
128
|
if (texture(uGWorldPos, tuv).w < 0.5) continue;
|
|
126
|
-
maxL = max(maxL,
|
|
129
|
+
maxL = max(maxL, rtLum(sampleIrr(tuv).rgb));
|
|
127
130
|
found = 1.0;
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
float cap = maxL * 1.25 + 1e-4;
|
|
131
|
-
float l =
|
|
134
|
+
float l = rtLum(center.rgb);
|
|
132
135
|
if (found > 0.5 && l > cap) center.rgb *= cap / l;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
|
-
float lumC =
|
|
138
|
+
float lumC = rtLum(center.rgb);
|
|
136
139
|
|
|
137
140
|
// 3x3 B-spline-ish kernel, edge-avoiding weights.
|
|
138
141
|
vec3 sum = center.rgb * 4.0;
|
|
@@ -155,7 +158,7 @@ void main() {
|
|
|
155
158
|
// flat floor has no geometric edge to protect it, so at high iteration
|
|
156
159
|
// counts the wide passes would average it away ("floating" objects with
|
|
157
160
|
// no contact shadow). Wide steps only get to blend near-equal luminance.
|
|
158
|
-
float wL = exp(-abs(
|
|
161
|
+
float wL = exp(-abs(rtLum(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
|
|
159
162
|
float w = k * wN * wZ * wL * (1.0 - specKeep);
|
|
160
163
|
sum += s.rgb * w;
|
|
161
164
|
wsum += w;
|
|
@@ -182,6 +185,9 @@ export class DenoisePass {
|
|
|
182
185
|
this.targetB = this._makeTarget(width, height);
|
|
183
186
|
|
|
184
187
|
this.material = new THREE.ShaderMaterial({
|
|
188
|
+
// Stable program name for compile-failure self-diagnosis; a link failure
|
|
189
|
+
// disables the optional `denoise` feature (image stays lit, just noisier).
|
|
190
|
+
name: "rt:denoise",
|
|
185
191
|
glslVersion: THREE.GLSL3,
|
|
186
192
|
vertexShader: fullscreenVert,
|
|
187
193
|
fragmentShader: atrousFrag,
|