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 +263 -12
- package/package.json +1 -1
- package/src/GBufferPass.js +115 -1
- package/src/RTLightingPass.js +69 -0
- package/src/RealtimeRaytracer.js +210 -4
- package/src/SceneCompiler.js +212 -1
- package/src/index.d.ts +100 -1
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
|
|
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.
|
|
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": [], "
|
|
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`,
|
|
664
|
-
the compile-failure surface above stayed clean (`rt.status.ok`,
|
|
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)
|
|
695
|
-
|
|
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.
|
|
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",
|
package/src/GBufferPass.js
CHANGED
|
@@ -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.
|
|
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
|
}
|