three-realtime-rt 0.6.1 → 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
@@ -138,7 +138,9 @@ A checklist for dropping the tracer into a scene you already have:
138
138
  scene with **no meshes yet** is a **no-op** (it warns once and keeps any
139
139
  previously compiled scene), so "construct the tracer, then add meshes" is a
140
140
  valid order — until meshes are added and recompiled, `rt.render()` falls back
141
- to plain rasterization (no crash, no black screen).
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`).
142
144
  3. **Declare movers** — pass moving meshes to
143
145
  `rt.compileScene(scene, { dynamicMeshes: [...] })`, then call
144
146
  `rt.updateDynamic()` each frame after you move them (e.g. after a physics
@@ -317,6 +319,71 @@ allocation, plus the usual O(dynamic tris) BVH refit and normal upload. Budget
317
319
  ≈ 1.7k source verts and skins in ≈ 0.3 ms. As with any dynamic mesh, keep the
318
320
  skinned tris low and there is no static-world cost.
319
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
+
320
387
  ## Live lighting & sky
321
388
 
322
389
  Lights can be toggled, moved, and recoloured every frame without recompiling:
@@ -372,14 +439,16 @@ material of a group* row).
372
439
  | `transmission` (Physical) | ✅ | Glass: Fresnel reflection (with analytic-light glints) + two-interface refraction. |
373
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`. |
374
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. |
375
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). |
376
444
  | `roughnessMap` | ✅ | `roughness × roughnessMap.g` (three.js convention) — sampled in the G-buffer. |
377
445
  | `metalnessMap` | ✅ | `metalness × metalnessMap.b` (a packed ORM texture works — G = roughness, B = metalness). |
378
446
  | `normalMap` | ✅ | Perturbs the shading normal via a screen-space cotangent frame (no tangent attribute needed); respects `material.normalScale`. |
379
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. |
380
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). |
381
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**. |
382
- | 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)*.) |
383
452
 
384
453
  ### Lights
385
454
 
@@ -405,10 +474,26 @@ material of a group* row).
405
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.
406
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.
407
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
+
408
493
  ### Geometry & occlusion
409
494
 
410
495
  - Every non-excluded visible mesh is **merged into one static BVH at compile time**. Add / remove geometry → recompile.
411
- - 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)*.
412
497
  - **Transparent materials never occlude** (by design — a glass case shouldn't cast an opaque shadow). They still rasterize normally.
413
498
  - **`alphaTest` cut-outs** (`transparent: false`) *do* occlude — but as **full triangles**, not per-texel, so their shadows are blocky.
414
499
  - `mesh.userData.rtExclude = true` removes a mesh from the BVH entirely (it still rasterizes and gets lit) — handy for water / translucent surfaces.
@@ -420,6 +505,10 @@ material of a group* row).
420
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.
421
506
  - **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
422
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`).
423
512
 
424
513
  ### Platform
425
514
 
@@ -434,6 +523,9 @@ material of a group* row).
434
523
  | `renderScale` | `0.5` | Lighting resolution vs. the G-buffer. `1.0` = max quality. |
435
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*. |
436
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. |
437
529
  | `denoiseIterations` | `2` | À-trous denoise passes; the governor raises this as it lowers resolution. |
438
530
  | `taa` | `true` | Temporal anti-aliasing (jitter + neighbourhood clamp). |
439
531
  | `denoise` | `true` | Edge-aware à-trous denoiser. |
@@ -551,6 +643,79 @@ starting point or take manual control:
551
643
  pixel per frame instead of one per light — the biggest ray-count lever for
552
644
  many-light scenes and mobile GPUs.
553
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
+
554
719
  WebGPU: not used as a backend (this is a WebGL2 library); a WGSL compute
555
720
  backend is on the roadmap.
556
721
 
@@ -609,6 +774,75 @@ JSON line to the console every 2s for automated scraping. The overscan control
609
774
  is **feature-detected** — it appears only when the loaded build exposes an
610
775
  `overscan` property.
611
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
+
612
846
  ## Render self-test
613
847
 
614
848
  The renderer can pass every compile and framebuffer check and still draw a black
@@ -636,7 +870,7 @@ if (!rt.status.ok) {
636
870
 
637
871
  - **`rt.compileError`** (`string | null`) — first / most-severe failure summary
638
872
  (`"rt:<pass>: <driver log>"`), or `null` while every pass compiles clean.
639
- - **`rt.status`** (`{ ok, disabled, coreFailure }`) — `ok` is `true` on the healthy
873
+ - **`rt.status`** (`{ ok, disabled, coreFailure, warnings }`) — `ok` is `true` on the healthy
640
874
  path and `false` once any `rt:*` pass fails to link. **Optional** features whose
641
875
  pass failed are **auto-disabled** so the image stays lit (`restir`, `restirGI`,
642
876
  `denoise`, `volumetric`, `taa`, `specular`), each listed in `disabled` as
@@ -645,6 +879,13 @@ if (!rt.status.ok) {
645
879
  and the image is black-but-diagnosed. This lets an integrator render an honest
646
880
  `raster (reason)` fallback instead of guessing. (When `supported` is `false` the
647
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.)
648
889
 
649
890
  **In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
650
891
  full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
@@ -653,17 +894,22 @@ the drawing buffer back and emits one JSON line to the console (`[selftest] …`
653
894
  and into a hidden `#selftest-verdict` DOM node:
654
895
 
655
896
  ```json
656
- { "pass": true, "meanLum": 139.08, "irrLum": 169.73, "glErrors": 0,
897
+ { "pass": true, "meanLum": 139.80, "irrLum": 170.53, "glErrors": 0,
657
898
  "specMRT": true, "supported": true, "statusOk": true, "rtPrograms": 15,
658
- "compileError": null, "disabled": [], "frames": 91, "ua": "…" }
899
+ "compileError": null, "disabled": [], "warnings": 0, "warningCodes": [],
900
+ "frames": 91, "ua": "…" }
659
901
  ```
660
902
 
661
903
  The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
662
904
  the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
663
- `glErrors == 0`, `supported == true`, and `statusOk == true` the last asserts
664
- the compile-failure surface above stayed clean (`rt.status.ok`, `compileError`
665
- null) *and* that the named pass programs were actually discoverable
666
- (`rtPrograms > 0`), so a broken diagnosis can't read as a false pass.
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.
667
913
 
668
914
  - `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
669
915
  composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
@@ -691,8 +937,13 @@ tested a newer three, so **chromium runs twice**: once against the pinned three
691
937
  class of regression, and **the pass gate requires both chromium legs**. A fourth
692
938
  chromium load of `?selftest=empty` asserts the [empty-scene no-op](#empty-scene)
693
939
  (`compileScene` on a scene with no meshes is a no-op and `render()` falls back to
694
- plain raster instead of crashing). firefox/webkit stay single-leg against the
695
- default three (both are environmental skips here — see below).
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).
696
947
 
697
948
  Machine-specific note (Windows + NVIDIA, the current dev box): each chromium leg
698
949
  runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-realtime-rt",
3
- "version": "0.6.1",
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",
@@ -86,6 +86,20 @@ uniform bool uHasMetalnessMap;
86
86
  uniform bool uBlend;
87
87
  uniform float uOpacity;
88
88
 
89
+ // World-space 3D-texture albedo ("volumetric surface albedo"), compiled in ONLY
90
+ // when a scene registers a material with userData.rtVolumeAlbedo (the whole block
91
+ // is behind the RT_VOLUME_ALBEDO define, so a scene without the feature builds a
92
+ // byte-identical G-buffer program with no extra sampler). Primary visibility can
93
+ // afford this sampler3D — the raster pass has ample sampler headroom, unlike the
94
+ // lighting megakernel. The value is gated per-mesh by uHasVolume, so non-volume
95
+ // meshes sharing this program write exactly the same albedo they did before.
96
+ #ifdef RT_VOLUME_ALBEDO
97
+ uniform highp sampler3D uVolumeTex;
98
+ uniform vec3 uVolumeOrigin;
99
+ uniform vec3 uVolumeSize;
100
+ uniform bool uHasVolume;
101
+ #endif
102
+
89
103
  // Screen-space cotangent frame (Mikkelsen 2010): reconstruct a tangent basis
90
104
  // from derivatives of world position and uv, so tangent-space normal maps work
91
105
  // without a per-vertex tangent attribute (none is uploaded to the BVH/G-buffer).
@@ -106,6 +120,17 @@ void main() {
106
120
  albedo *= texture(uMap, vUvCoord).rgb;
107
121
  }
108
122
  albedo *= vColor; // vertex colours (white when the mesh has no color attribute)
123
+ // Volumetric surface albedo: sample a world-space 3D texture at this fragment's
124
+ // world position and use it as the base colour, replacing color x map x vColor.
125
+ // uvw = clamp((p - origin) / size, 0, 1); the ClampToEdge sampler + this clamp
126
+ // keep hits just outside the volume reading the boundary colour instead of
127
+ // wrapping. Gated so only rtVolumeAlbedo meshes are affected.
128
+ #ifdef RT_VOLUME_ALBEDO
129
+ if (uHasVolume) {
130
+ vec3 uvw = clamp((vWorldPos - uVolumeOrigin) / uVolumeSize, 0.0, 1.0);
131
+ albedo = texture(uVolumeTex, uvw).rgb;
132
+ }
133
+ #endif
109
134
  vec3 emissive = uEmissive;
110
135
  if (uHasEmissiveMap) {
111
136
  emissive *= texture(uEmissiveMap, vUvCoord).rgb;
@@ -187,7 +212,44 @@ export class GBufferPass {
187
212
 
188
213
  this._materialCache = new WeakMap(); // mesh -> gbuffer ShaderMaterial
189
214
  this._swapped = []; // [mesh, originalMaterial] pairs during render
215
+ this._hidden = []; // objects temporarily hidden for the G-buffer draw
190
216
  this._normalMat3 = new THREE.Matrix3();
217
+ // World-space 3D-texture albedo: off unless a scene registers a material with
218
+ // userData.rtVolumeAlbedo (see setVolume). When off, the gbuffer program is
219
+ // compiled WITHOUT the RT_VOLUME_ALBEDO define — no sampler3D, byte-identical.
220
+ this._volumeEnabled = false;
221
+ this._dummyVolumeTex = null; // 1x1x1 fallback bound to non-volume meshes when on
222
+ }
223
+
224
+ // A valid 1x1x1 3D texture to bind on gbuffer materials whose mesh is NOT a
225
+ // volume material while the feature is compiled in — keeps every declared
226
+ // sampler3D pointing at a complete texture (its branch is never taken, so the
227
+ // value is irrelevant; it just must not be an unbound/incomplete sampler).
228
+ _dummyVolume() {
229
+ if (!this._dummyVolumeTex) {
230
+ const t = new THREE.Data3DTexture(new Uint8Array([255, 255, 255, 255]), 1, 1, 1);
231
+ t.format = THREE.RGBAFormat;
232
+ t.type = THREE.UnsignedByteType;
233
+ t.minFilter = THREE.LinearFilter;
234
+ t.magFilter = THREE.LinearFilter;
235
+ t.needsUpdate = true;
236
+ this._dummyVolumeTex = t;
237
+ }
238
+ return this._dummyVolumeTex;
239
+ }
240
+
241
+ /**
242
+ * Enable/disable the world-space 3D-texture albedo path for the primary
243
+ * (G-buffer) visibility. Compiles the RT_VOLUME_ALBEDO define into the per-mesh
244
+ * gbuffer program when on. Toggling clears the material cache so the programs
245
+ * recompile with/without the define. Called by RealtimeRaytracer after each
246
+ * compile with whether the scene registered any rtVolumeAlbedo material.
247
+ */
248
+ setVolume(enabled) {
249
+ const on = !!enabled;
250
+ if (on === this._volumeEnabled) return;
251
+ this._volumeEnabled = on;
252
+ this._materialCache = new WeakMap(); // force recompile with the new define
191
253
  }
192
254
 
193
255
  _makeTarget(width, height) {
@@ -246,6 +308,10 @@ export class GBufferPass {
246
308
  // cache key, so they all surface as the single core pass "rt:gbuffer".
247
309
  name: "rt:gbuffer",
248
310
  glslVersion: THREE.GLSL3,
311
+ // RT_VOLUME_ALBEDO is present only while a scene uses the volumetric-albedo
312
+ // feature (see setVolume); absent, the compiled program is identical to the
313
+ // pre-feature G-buffer (no sampler3D, no volume branch).
314
+ defines: this._volumeEnabled ? { RT_VOLUME_ALBEDO: "1" } : {},
249
315
  vertexShader: gbufferVert,
250
316
  fragmentShader: gbufferFrag,
251
317
  uniforms: {
@@ -269,6 +335,16 @@ export class GBufferPass {
269
335
  uHasMetalnessMap: { value: false },
270
336
  uBlend: { value: false },
271
337
  uOpacity: { value: 1.0 },
338
+ // Volume-albedo uniforms are always present in the JS uniform object
339
+ // (harmless when the define is off — three uploads only uniforms that
340
+ // exist in the compiled program, so a non-volume scene never touches
341
+ // these). While the define is on, _syncGbufferMaterial binds either the
342
+ // material's own volume texture or the shared dummy, so a non-volume mesh
343
+ // never leaves an incomplete sampler3D bound.
344
+ uVolumeTex: { value: null },
345
+ uVolumeOrigin: { value: new THREE.Vector3() },
346
+ uVolumeSize: { value: new THREE.Vector3(1, 1, 1) },
347
+ uHasVolume: { value: false },
272
348
  },
273
349
  side: THREE.FrontSide,
274
350
  });
@@ -314,6 +390,23 @@ export class GBufferPass {
314
390
  // lighting pass. opacity 1 renders opaque, matching the old force-opaque path.
315
391
  u.uBlend.value = !!src.transparent;
316
392
  u.uOpacity.value = src.opacity ?? 1.0;
393
+ // World-space 3D-texture albedo. Only meshes whose material opted in via
394
+ // userData.rtVolumeAlbedo get uHasVolume=true; every other mesh keeps the
395
+ // dummy sampler (branch never taken) so its albedo is byte-identical. This is
396
+ // per-mesh, so DISTINCT volumes each render correctly in primary visibility.
397
+ if (this._volumeEnabled) {
398
+ const vol = src.userData && src.userData.rtVolumeAlbedo;
399
+ if (vol && vol.texture) {
400
+ u.uHasVolume.value = true;
401
+ u.uVolumeTex.value = vol.texture;
402
+ u.uVolumeOrigin.value.copy(vol.origin ?? { x: 0, y: 0, z: 0 });
403
+ const s = vol.size ?? { x: 1, y: 1, z: 1 };
404
+ u.uVolumeSize.value.set(s.x || 1, s.y || 1, s.z || 1);
405
+ } else {
406
+ u.uHasVolume.value = false;
407
+ u.uVolumeTex.value = this._dummyVolume();
408
+ }
409
+ }
317
410
  u.uNormalMatrixWorld.value.getNormalMatrix(mesh.matrixWorld);
318
411
  material.side = src.side ?? THREE.FrontSide;
319
412
  }
@@ -353,11 +446,27 @@ export class GBufferPass {
353
446
  // material word + gEmissive.a, and the lighting pass blends them against the
354
447
  // geometry behind. opacity 1 writes fully opaque, so alpha-textured cases
355
448
  // (LittlestTokyo's glass) look exactly as before.
449
+ //
450
+ // Objects that are NOT meshes (Sprite / Line / Points) keep their OWN
451
+ // material here, and those materials write a single gl_FragColor. Rendering
452
+ // one into this 4-attachment MRT framebuffer is a GL_INVALID_OPERATION (an
453
+ // ESSL1 fragment shader cannot feed multiple draw buffers) and the object is
454
+ // not traceable geometry either — the BVH compiler skips it. So they are
455
+ // HIDDEN for the duration of the G-buffer draw and restored right after;
456
+ // draw them yourself in an overlay pass on top of rt.render() if you need
457
+ // them on screen. compileScene() warns once naming them.
356
458
  this._swapped.length = 0;
459
+ this._hidden.length = 0;
357
460
  scene.traverse((obj) => {
358
- if (obj.isMesh && obj.geometry && obj.visible) {
461
+ if (!obj.visible) return;
462
+ if (obj.isMesh && obj.geometry) {
359
463
  this._swapped.push([obj, obj.material]);
360
464
  obj.material = this._gbufferMaterialFor(obj);
465
+ return;
466
+ }
467
+ if (obj.isSprite || obj.isLine || obj.isPoints) {
468
+ obj.visible = false;
469
+ this._hidden.push(obj);
361
470
  }
362
471
  });
363
472
 
@@ -373,9 +482,14 @@ export class GBufferPass {
373
482
  scene.background = prevBackground;
374
483
  for (const [mesh, mat] of this._swapped) mesh.material = mat;
375
484
  this._swapped.length = 0;
485
+ // Restore visibility exactly once per object (each was pushed once, and only
486
+ // if it was visible when we hid it).
487
+ for (const obj of this._hidden) obj.visible = true;
488
+ this._hidden.length = 0;
376
489
  }
377
490
 
378
491
  dispose() {
379
492
  for (const t of this._targets) t.dispose();
493
+ if (this._dummyVolumeTex) this._dummyVolumeTex.dispose();
380
494
  }
381
495
  }