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 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 CPU-deforming (`rtDeforming`) meshes (throws). |
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). The only defence against that class of failure is
614
- to **look at the pixels**, so the demo has a headless-friendly self-test.
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.08, "irrLum": 169.73, "glErrors": 0,
624
- "specMRT": true, "supported": true, "frames": 91, "ua": "…" }
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 `supported == true`.
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 a free port and drives `?selftest=1` through Playwright across
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
- Machine-specific note (Windows + NVIDIA, the current dev box): the chromium leg
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.6.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
  }
@@ -195,6 +195,9 @@ void main() {
195
195
  export class CompositePass {
196
196
  constructor() {
197
197
  this.material = new THREE.ShaderMaterial({
198
+ // Stable program name for compile-failure self-diagnosis: this is a CORE
199
+ // pass (the final tonemap/upsample) — a link failure has no fallback.
200
+ name: "rt:composite",
198
201
  glslVersion: THREE.GLSL3,
199
202
  vertexShader: fullscreenVert,
200
203
  fragmentShader: compositeFrag,
package/src/CopyPass.js CHANGED
@@ -37,6 +37,9 @@ void main() {
37
37
  export class CopyPass {
38
38
  constructor() {
39
39
  this.material = new THREE.ShaderMaterial({
40
+ // Generic fullscreen history-carry blit (resize only); a link failure is
41
+ // non-fatal, so it classifies as auxiliary in the self-diagnosis.
42
+ name: "rt:history-carry",
40
43
  glslVersion: THREE.GLSL3,
41
44
  vertexShader: fullscreenVert,
42
45
  fragmentShader: copyFrag,
@@ -35,7 +35,10 @@ uniform bool uBlendIsSpec; // this instance filters the specular buffer
35
35
  uniform sampler2D uAddTex;
36
36
  uniform bool uHasAdd;
37
37
 
38
- float luminance(vec3 c) {
38
+ // Named rtLum, NOT luminance: three r166+ prepends its own luminance(vec3)
39
+ // to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
40
+ // (vec3) body as a redefinition — the whole program fails to compile.
41
+ float rtLum(vec3 c) {
39
42
  return dot(c, vec3(0.299, 0.587, 0.114));
40
43
  }
41
44
 
@@ -123,16 +126,16 @@ void main() {
123
126
  vec2 tuv = vUv + vec2(float(dx), float(dy)) * uTexelSize;
124
127
  if (tuv.x < 0.0 || tuv.x > 1.0 || tuv.y < 0.0 || tuv.y > 1.0) continue;
125
128
  if (texture(uGWorldPos, tuv).w < 0.5) continue;
126
- maxL = max(maxL, luminance(sampleIrr(tuv).rgb));
129
+ maxL = max(maxL, rtLum(sampleIrr(tuv).rgb));
127
130
  found = 1.0;
128
131
  }
129
132
  }
130
133
  float cap = maxL * 1.25 + 1e-4;
131
- float l = luminance(center.rgb);
134
+ float l = rtLum(center.rgb);
132
135
  if (found > 0.5 && l > cap) center.rgb *= cap / l;
133
136
  }
134
137
 
135
- float lumC = luminance(center.rgb);
138
+ float lumC = rtLum(center.rgb);
136
139
 
137
140
  // 3x3 B-spline-ish kernel, edge-avoiding weights.
138
141
  vec3 sum = center.rgb * 4.0;
@@ -155,7 +158,7 @@ void main() {
155
158
  // flat floor has no geometric edge to protect it, so at high iteration
156
159
  // counts the wide passes would average it away ("floating" objects with
157
160
  // no contact shadow). Wide steps only get to blend near-equal luminance.
158
- float wL = exp(-abs(luminance(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
161
+ float wL = exp(-abs(rtLum(s.rgb) - lumC) / (sigmaL * inversesqrt(uStep)));
159
162
  float w = k * wN * wZ * wL * (1.0 - specKeep);
160
163
  sum += s.rgb * w;
161
164
  wsum += w;
@@ -182,6 +185,9 @@ export class DenoisePass {
182
185
  this.targetB = this._makeTarget(width, height);
183
186
 
184
187
  this.material = new THREE.ShaderMaterial({
188
+ // Stable program name for compile-failure self-diagnosis; a link failure
189
+ // disables the optional `denoise` feature (image stays lit, just noisier).
190
+ name: "rt:denoise",
185
191
  glslVersion: THREE.GLSL3,
186
192
  vertexShader: fullscreenVert,
187
193
  fragmentShader: atrousFrag,