# three-realtime-rt

**Turn-on ray traced lighting for three.js.** Build your scene with ordinary
three.js — meshes, `MeshStandardMaterial`, `PointLight` / `DirectionalLight` —
then swap one render call and get BVH-traced **soft shadows**, **one-bounce
global illumination**, **emissive-mesh area lights**, **mirror/glossy
reflections**, **glass refraction**, **volumetric god rays** (BVH-shadowed
single scatter, not a screen-space trick), a **procedural sky** that lights
the scene, **ReSTIR many-light sampling** (flat cost in light count),
**blue-noise sampling**, and real-time **temporal denoising + anti-aliasing**.
Runs on plain WebGL2.

This round adds **GGX PBR specular** — Cook-Torrance dielectric highlights in a
separate specular buffer, so `roughness` finally matters on non-metals — plus
**normal / roughness / metalness maps**, **alpha-blended transparency**
(single-layer deferred blend), **deforming dynamic meshes** (a mirror-water pool
whose traced reflections follow the live wave surface), **overscan** to hide
leading-edge convergence noise while the camera turns, and **emissive importance
sampling** (area × luminance) for calmer area lights. The zero-config renderer
now ships **conservative, self-scaling defaults** — it starts low-but-ray-traced
and an on-by-default governor scales quality up toward `targetFps`; an optional
async **GPU tier probe** reads real WebGPU adapter limits for a smarter starting
point.

The library ships as **untranspiled ES modules** (the `src/` folder) — it has no
build step of its own, so you consume it through your bundler (Vite, webpack,
esbuild, …) or a browser import map that resolves the bare `three` /
`three-mesh-bvh` specifiers. MIT licensed.
[On npm](https://www.npmjs.com/package/three-realtime-rt): `npm i three-realtime-rt three three-mesh-bvh`.

### ▶ [Live demo](https://goldwinxs.github.io/three-realtime-rt/) — a three-stop tour: a Cornell box with a one-feature-at-a-time switcher, the museum room (drop the pile), then stock glTF models. PREV / NEXT walks it; one switch A/Bs ray tracing against plain three.js on every stop.

> **Support this project:** the [supporter pack on itch.io](https://goldwinxs.itch.io/three-realtime-rt-supporter-pack) gets you a ready-to-run starter template, all example scenes, and a 12-section deep-dive guide to how the whole pipeline works. The library itself is and stays MIT.

![Ray traced room: emissive area light, reflections, glass, volumetric haze](docs/hero.png)

Same scene, same camera, same lights — plain three.js (shadow maps + ACES) on
the left, `rt.render` on the right:

| Rasterized three.js | three-realtime-rt |
|---|---|
| ![raster](docs/compare-raster.jpg) | ![ray traced](docs/compare-rt.jpg) |

## Getting started

Install the library plus its two **peer dependencies** — you bring your own copy
of `three` and `three-mesh-bvh`:

```bash
npm i three-realtime-rt three three-mesh-bvh
```

No bundler? [`standalone.html`](standalone.html) is a single copy-paste file
that runs the raytracer via CDN import maps — open it from any static server.

A complete, copy-pasteable minimal app — a lit sphere on a floor, one point light:

```js
import * as THREE from "three";
import { RealtimeRaytracer } from "three-realtime-rt";

// 1. An ordinary three.js renderer. Size it BEFORE constructing the raytracer —
//    it reads the drawing-buffer size at construction.
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 2. An ordinary scene: meshes with MeshStandardMaterial + a real light.
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 2, 6);

scene.add(new THREE.Mesh(
  new THREE.SphereGeometry(1, 48, 48),
  new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.4, metalness: 0.0 })
));
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(20, 20),
  new THREE.MeshStandardMaterial({ color: 0x808080, roughness: 1.0 })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1;
scene.add(floor);

const light = new THREE.PointLight(0xffffff, 40);   // Point / Spot / Directional (up to 32)
light.position.set(3, 5, 2);
scene.add(light);

// 3. Turn on ray tracing.
const rt = new RealtimeRaytracer(renderer);
rt.compileScene(scene);              // builds the BVH + material/light tables

// 4. Resize: pass DRAWING-BUFFER (device) pixels, not CSS pixels.
addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  const db = renderer.getDrawingBufferSize(new THREE.Vector2());
  rt.setSize(db.x, db.y);
});

// 5. Render loop — replace renderer.render(scene, camera) with rt.render.
function loop() {
  requestAnimationFrame(loop);
  rt.render(scene, camera);
}
loop();
```

On hardware that can't trace, `rt.render` transparently falls back to
`renderer.render` — no capability branch needed (see [Running everywhere](#running-everywhere-capability-tiers)).

> **Defaults are conservative.** Zero-config construction
> (`new RealtimeRaytracer(renderer)`) starts *low but still ray traced* — half
> lighting resolution, stochastic direct light, a lean denoise — so it runs
> acceptably on weak discrete and integrated GPUs out of the box. The adaptive
> governor is **on by default** and scales quality **up** toward `targetFps`
> when it measures headroom (a strong desktop climbs to full-resolution
> lighting within a couple of seconds). Want to start higher, or pin a level?
> Pass `RealtimeRaytracer.recommendedOptions(RealtimeRaytracer.detectTier(renderer))`
> (or `probeGPUTier()` — see [Running everywhere](#running-everywhere-capability-tiers)),
> or explicit options.

### Integrating into an existing app

A checklist for dropping the tracer into a scene you already have:

1. **Swap the render call** — `renderer.render(scene, camera)` →
   `rt.render(scene, camera)`. Construct the `RealtimeRaytracer` once, *after*
   the renderer has its final size.
2. **Compile once; recompile after structural changes** — `rt.compileScene(scene)`
   bakes geometry into a static BVH and snapshots materials + emissive area
   lights. Call it again after you add/remove meshes, swap geometry, or change a
   material's `emissive` / `color` / `roughness` / `metalness`. <a id="empty-scene"></a>Calling it on a
   scene with **no meshes yet** is a **no-op** (it warns once and keeps any
   previously compiled scene), so "construct the tracer, then add meshes" is a
   valid order — until meshes are added and recompiled, `rt.render()` falls back
   to plain rasterization (no crash, no black screen). Call it **explicitly**: if
   `render()` finds no compiled scene it compiles one itself, with *no options*,
   so everything is static (it warns once — `implicit-compile`).
3. **Declare movers** — pass moving meshes to
   `rt.compileScene(scene, { dynamicMeshes: [...] })`, then call
   `rt.updateDynamic()` each frame after you move them (e.g. after a physics
   step). Skip it on frames where nothing moved.
4. **Update lights when they change** — after moving, toggling (`.visible`),
   recolouring or dimming a light, call `rt.updateLights(scene)`. No recompile.
5. **Resize** — in your resize handler, after `renderer.setSize(...)`, call
   `rt.setSize(width, height)` with **drawing-buffer (device) pixels**
   (`renderer.getDrawingBufferSize(...)`).
6. **Reset after jumps** — call `rt.resetAccumulation()` after a camera teleport
   or a scene cut, so stale temporal history doesn't ghost.

That's the whole integration. Everything below is optional.

---

## Why "hybrid deferred" (the "RTX on" model)

Primary visibility is **rasterized** by three.js into a G-buffer — free, fast,
and pixel-perfect on materials and textures. Only the *lighting* is ray traced,
in a fragment shader, against a GPU BVH ([three-mesh-bvh]):

1. **G-buffer pass** — MRT: albedo+roughness, world normal+metalness, world
   position, emissive.
2. **RT lighting pass** — per pixel: soft shadow rays to each light (area
   sampled) + a 1-bounce cosine-weighted GI ray with next-event estimation. GI
   rays that escape sample the **procedural sky**, so the sky is a soft area
   light. **Emissive meshes are real area lights**: their triangles are sampled
   directly (NEE with the area→solid-angle pdf), so a glowing panel casts soft
   light and shadows instead of waiting for a lucky GI ray to hit it. Metallic
   pixels trace a **mirror/glossy reflection ray**; transmissive pixels trace a
   Fresnel-weighted **reflection + two-interface refraction**. Output is
   *demodulated irradiance* (albedo divided out) so it denoises cleanly while
   textures stay sharp.
3. **Temporal reprojection** — motion-validated history keeps samples alive as
   the camera and objects move.
4. **À-trous denoise** — an edge-avoiding (SVGF-lite) wavelet filter guided by
   the G-buffer, so 1 sample/pixel looks converged.
5. **Composite** — `albedo × irradiance + emissive`, distance fog, ACES tonemap.
6. **TAA** — sub-pixel jitter + a neighbourhood-clamped history resolve:
   supersampled anti-aliasing that also clears disocclusion speckles. This is
   the analytic (FSR2 / TAAU) approach, not a learned upscaler.

Lighting is traced at half resolution by default and reconstructed by a joint
bilateral upsample + the denoiser + TAA — the same "render few pixels, rebuild
temporally" idea DLSS uses, done with hand-written math.

### Debug views

Set `rt.outputMode` (the demo's **view** dropdown) to inspect a single stage
instead of the composited image:

| Mode | View | Shows |
|------|------|-------|
| `0` | composite | Final tonemapped image (default). |
| `1` | albedo | G-buffer base colour. |
| `2` | normals | World-space normals, `×0.5 + 0.5`. |
| `3` | irradiance | Demodulated diffuse lighting (direct + GI), pre-albedo. |
| `4` | world pos | World position, `fract(p × 0.1)`. |
| `5` | emissive | G-buffer emissive. |
| `6` | specular | The dielectric specular buffer. |
| `7` | **bvh cost** | Heatmap of how many BVH nodes this pixel's shadow rays visit. |

**Reading the BVH-cost heatmap.** Mode 7 counts, per pixel, the total BVH nodes
visited by every shadow ray the lighting pass casts that frame (the ReSTIR
winner / stochastic / per-light rays, plus reflection and glass occlusion rays),
and maps the count through a cold→hot palette: **blue is cheap** (few boxes),
through green and yellow, to **red and white for the most expensive** pixels. Hot
regions mean many box tests per shadow ray — dense or overlapping geometry, long
thin triangles whose bounding boxes overlap wastefully, or rays skimming almost
parallel to a surface (they thread a long corridor of the tree before they
either hit or escape). It bypasses temporal blending and the denoiser, so it is a
raw per-frame snapshot. `rt.costScale` sets the mapping (default `1/96`, so ~96
visits saturate to white); the demo's **cost scale** slider drives it as
"visits-to-saturate" so you can rescale the range to your scene.

### Replacing the denoiser (`setDenoiserPlugin`)

The library ships one denoiser: the temporal EMA (`AccumulatePass`) followed by
the edge-aware a-trous blur (`DenoisePass`). If you have a better one - a
different filter, a learned denoiser, anything - you can drop it into that slot
instead of forking the renderer:

```js
rt.setDenoiserPlugin(myDenoiser);   // pass null to go back to the built-in one
```

While a plugin is attached, the frame runs the G-buffer and the lighting pass as
usual and then, instead of `AccumulatePass` + a-trous, calls

```js
plugin.render(renderer, rawIrradiance, rawSpecular, gbuffer, viewMatrix,
              { warp, proj, motion, frame })
```

and composites the `{ irradiance, specular }` textures it returns exactly where
the a-trous output would have gone. Everything downstream is unchanged: the
composite's guided upsample, fog, sky, the volumetric add, tonemapping and the
TAA resolve all still run.

| Argument | What it is |
|---|---|
| `rawIrradiance`, `rawSpecular` | This frame's **1-spp** lighting at lighting resolution, RGBA16F, straight out of `rtPass.renderRaw()`: no temporal history has been applied. |
| `gbuffer` | The live G-buffer (`albedoRough`, `normalMetal`, `worldPos`, `emissive`, and `motion` when active) at canvas resolution. Downsample it yourself if your filter runs at lighting resolution. |
| `viewMatrix` | `camera.matrixWorldInverse`. |
| `warp` | `prevViewProj * camera.matrixWorld`: takes a view-space position from this frame into the previous frame's clip space, which is what a temporal plugin needs to fetch its own history. `prevViewProj` is the jittered, overscan-widened matrix the previous frame actually rendered with. |
| `proj` | `[P00, P11, P02, P12]` of this frame's projection, TAA jitter and overscan included, so you can rebuild view-space position from depth on the exact grid the G-buffer was rasterized on. |
| `motion` | `gbuffer.motion` when per-object motion vectors are active (see [`motionVectors`](#options)), else `null`. Motion vectors answer "where did this surface come from", not "was it visible at all", so a temporal plugin still needs its own disocclusion test. |
| `frame` | The renderer's frame counter. |

The other three methods are lifecycle. `setSize(width, height)` is called with
the **lighting** resolution whenever it changes (a `renderScale` step or a canvas
resize); `resetHistory()` is called wherever every other temporal history in the
pipeline is dropped (`resetAccumulation()`: scene recompile, camera cut, mode
switch, resize); `dispose()` is called from `rt.dispose()`. Attaching a plugin
sizes it, drops its history and resets accumulation; detaching resets
accumulation. Swapping plugins does **not** dispose the outgoing one - you own
it and may re-attach it later.

```js
const myDenoiser = {
  render(renderer, rawIrr, rawSpec, gbuffer, viewMatrix, ctx) {
    // ... your passes ...
    return { irradiance: cleanIrrTexture, specular: cleanSpecTexture };
  },
  setSize(w, h) {},
  resetHistory() {},
  dispose() {},
};
rt.setDenoiserPlugin(myDenoiser);
```

The raw pair only exists on the split-accumulate MRT path
(`specMRTSupported && splitAccum`, the default wherever 2-attachment half-float
MRT is available). On a device without it the plugin is skipped and the built-in
denoiser runs, so check `rt.denoiserPluginActive` rather than assuming. For
allocating your own multi-attachment targets across the peer three range, the
package exports `makeMRT(width, height, count, options)` - the same shim the
library's passes use, whose result indexes attachments as `.texture[i]` on
every supported version.

To see what your plugin is being fed, set `rt.rawInputView = true`: the
composite then shows the raw 1-spp pair instead of the denoised output, with the
TAA resolve, its jitter and the guided upsample all bypassed so a lighting-res
noise pixel reaches the screen as a square. Your plugin still runs and still
advances its history while the view is on, so switching back shows the picture
it would have shown anyway.

**Declining a frame (0.16.6).** `render()` may return `false` (or nothing) while
your shaders are still compiling, or once you have decided this GPU cannot run
you: the built-in split-accumulate denoiser carries that frame from the same raw
pair, exactly as if no plugin were attached, and `rt.denoiserPluginRan` reads
`false` for it. Nothing flickers, nothing needs re-attaching: return the pair
again when you are ready.

**Preferences (0.16.6 / 0.16.7).** A plugin may carry a plain-data
`preferences` object, read when it is attached: `{ renderScale: { min, max,
preferred }, postHistoryFrames, postIterations }`. `renderScale.min/max` become
the adaptive governor's bounds (never wider than the app's own
[`renderScaleMax`](#options)) and `preferred` is where the scale starts if it
lies inside them, so a network trained at one lighting resolution can pin it
(`{ min: 0.5, max: 0.5, preferred: 0.5 }` = quarter-resolution rays under a
full-resolution G-buffer, and the governor keeps steering the canvas ladder and
the denoise budget). `postHistoryFrames` / `postIterations` fill the app's
defaults for the two post knobs below (a non-zero value the app set wins).
Detaching restores the app's bounds and clears what came from the plugin.

**Output resolution (0.16.7).** The pair you return may sit on any grid from the
lighting grid up to the G-buffer's own: a network that takes quarter-resolution
rays and the full-resolution G-buffer and writes full-resolution output simply
returns full-resolution textures, and the composite taps them at their own
texel size (skipping the guided upsample when they are already at canvas
resolution). `irradiance` and `specular` must share a size. `ctx.lightingSize`
and `ctx.gbufferSize` (`[w, h]`, reused arrays) tell you the two grids each
frame. The two post knobs apply only to output on the lighting grid.

`rt.denoiserPluginPostIterations = N` (0.16.4, default 0) runs the built-in
edge-aware a-trous N times on the plugin's OUTPUT irradiance, as a spatial
post-filter for a network that still flickers or leaves residual noise. At 0
nothing runs and the plugin path is byte-identical to 0.16.3. Live: assign at
any time. `rt.denoiserPluginPostHistory = N` (0.16.4, default 0) runs the
plugin's output through the split-accumulate EMA first (N frames of reprojected
history, the same pass the built-in pipeline runs on raw samples), so a network
that still flickers frame to frame settles; lag on moving lights grows with N.
Order: plugin -> temporal history -> a-trous.

## Moving objects (dynamic BVH)

Mark meshes as dynamic and their motion casts **correct ray traced shadows** —
the demo drops 40 rigid bodies (Rapier physics) that shadow each other and the
ground in real time:

```js
rt.compileScene(scene, { dynamicMeshes: crates });  // meshes that will move

// each frame, after you move them (e.g. after a physics step):
rt.updateDynamic();       // re-bakes them into the BVH (refit) — cheap
rt.render(scene, camera);
```

Under the hood this is a **two-level BVH**: static geometry lives in one BVH
uploaded to the GPU once at compile time, dynamic meshes in a second small BVH
that is re-baked and refit per frame. `updateDynamic()` therefore costs
~1 ms for dozens of moving objects *regardless of how big the static world is* —
skip it entirely on frames where nothing moved.

### Deforming meshes (water, cloth)

By default a dynamic mesh is **rigid**: its vertices are snapshotted at compile
time and only re-transformed by `mesh.matrixWorld` each frame — so CPU edits to
its `position` attribute show in the rasterized image but the *traced* rays
(shadows, GI, reflections) still hit the original shape. For a mesh whose
vertices actually move on the CPU (a water surface, cloth, a morph target), set
`userData.rtDeforming` so the raytracer re-reads its live geometry every frame:

```js
water.userData.rtDeforming = true;           // opt in
rt.compileScene(scene, { dynamicMeshes: [water, ...crates] });

// each frame:
deformWaterVertices(water.geometry, t);       // your CPU wave/cloth solver
water.geometry.attributes.position.needsUpdate = true;
water.geometry.computeVertexNormals();        // REQUIRED — see below
rt.updateDynamic();                            // re-reads the live vertices
rt.render(scene, camera);
```

Two requirements:

- **You own the normals.** The tracer reads the mesh's live `normal` attribute
  for deforming segments — it does not recompute them. Call
  `geometry.computeVertexNormals()` (or update the attribute yourself) after
  moving the vertices, or the shading/reflections will track the old silhouette.
- **The vertex count is fixed at compile time.** Deforming an existing surface is
  free; changing its topology (adding/removing vertices) is not — `updateDynamic()`
  throws with a clear message telling you to `compileScene()` again.

**Cost model.** A deforming segment is re-baked from its live vertices every
frame and its normals are re-uploaded every frame (rigid movers amortize the
normal upload over 8 frames). Both are O(dynamic triangles), and the cheap
per-frame BVH `refit()` (kept for surfaces that stay roughly in place, like a
water plane) is O(dynamic tris) too — so **keep deforming meshes low-poly**. A
`48×48` plane is ≈ 4.6k triangles, which refits in well under a millisecond; a
`256×256` plane (~131k tris) will dominate the frame. The demo's mirror-water
pool is a `48×48` plane.

### Skinned meshes (animated characters)

A `SkinnedMesh` is **auto-detected** — just list it in `dynamicMeshes` (no
`userData` flag) and it is CPU-skinned into the dynamic BVH every frame, so an
animated character casts a **traced shadow that moves with its gait** and
rasterizes in its animated pose (not bind pose) in the G-buffer:

```js
rt.compileScene(scene, { dynamicMeshes: [...crates, foxMesh] }); // foxMesh.isSkinnedMesh

// each frame:
mixer.update(dt);                 // advance the AnimationMixer
foxRoot.updateMatrixWorld(true);  // pose the skeleton (bones -> world matrices) NOW
rt.updateDynamic();               // CPU-skins the live pose into the BVH
rt.render(scene, camera);
```

- **Skin the pose before `updateDynamic()`.** The CPU skinning reads each bone's
  `matrixWorld` (via three's `SkinnedMesh.applyBoneTransform` / `getVertexPosition`,
  which apply the bind matrix, bone weights and bone matrices), so the skeleton
  must be posed for this frame first. `mixer.update(dt)` then a forced
  `updateMatrixWorld` on the character root does that — otherwise the traced
  shadow lags the raster by a frame. (three r160's `applyBoneTransform` returns
  the vertex in the mesh's **local/bind-relative** space; the tracer applies
  `matrixWorld` itself, exactly like a rigid mover.)
- **Two sampler-friendly shortcuts.** Skinning is done for the mesh's *unique*
  source vertices once per frame (shared triangle-soup slots reuse the result),
  and secondary-ray **normals are per-face** — recomputed from the skinned
  triangle positions rather than CPU-skinning the normal attribute. Flat-shaded
  secondary rays are indistinguishable for shadows/GI, and **primary visibility
  still gets smooth normals from the raster path** (the G-buffer skins the normal
  properly via three's own `skinnormal_vertex` chunk). If your character's
  geometry ships without a `normal` attribute (some glTF do — e.g. the Khronos
  Fox), call `geometry.computeVertexNormals()` once after load so the raster path
  has bind-pose normals to skin.

**Cost model.** CPU skinning is O(source verts × 4 bones) with zero per-vertex
allocation, plus the usual O(dynamic tris) BVH refit and normal upload. Budget
~**10–20k total skinned source vertices to stay sub-2 ms**; the demo's Fox is
≈ 1.7k source verts and skins in ≈ 0.3 ms. As with any dynamic mesh, keep the
skinned tris low and there is no static-world cost.

## Volumetric surface albedo (world-space 3D-texture colour)

Colour a surface by a **world-space 3D texture** sampled at the ray hit point,
instead of a flat colour or a 2D UV map. This is how you paint a **volumetric data
field** — stress, temperature, density, a distance field — onto a mesh in a path
tracer, where a custom fragment shader can't run. Opt a material in through
`userData`, and the tracer samples your (already colour-mapped) `Data3DTexture` for
that surface's albedo in **both** primary visibility (the G-buffer, so the
raster/hybrid view agrees) **and** the traced GI / reflection bounces (so the
field's colours bleed correctly through global illumination):

```js
import * as THREE from "three";

// An RGB(A) 3D texture you have ALREADY colour-mapped (the tracer samples .rgb —
// it contains no colormap logic). RGBA8 is fine; no float-filtering required.
const field = new THREE.Data3DTexture(rgbaBytes, N, N, N); // your data
field.format = THREE.RGBAFormat;
field.type = THREE.UnsignedByteType;
field.needsUpdate = true;

// Map the volume into world space: origin = world position of the texel-(0,0,0)
// corner, size = the world extent of the whole volume. A mesh's world AABB is the
// natural choice, so the field fills the mesh.
mesh.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(mesh);
mesh.material.userData.rtVolumeAlbedo = {
  texture: field,
  origin: box.min.clone(),
  size:   box.getSize(new THREE.Vector3()),
};

rt.compileScene(scene);   // detects the opt-in; recompile after adding/changing it
```

At the hit point `p`, the tracer computes `uvw = clamp((p - origin) / size, 0, 1)`
and samples the texture **trilinearly** (it sets `LinearFilter` + `ClampToEdge` on
your texture at compile time). The sampled RGB **replaces the base albedo**;
`roughness`, `metalness` and `emissive` still compose normally. Updating the
texture **data** later (an animated field) needs only `texture.needsUpdate = true`
— no recompile; changing which material carries the field, or its `origin` / `size`,
needs a `compileScene()`.

**Demo:** [`volumetric-albedo.html`](volumetric-albedo.html) — a torus knot coloured
by a procedural 3D noise field (turbo colormap) under an emissive area light, with
the field's colours bleeding onto white walls through GI.

**v1 is single-volume for the traced-bounce path.** Any number of materials may
carry distinct volumes and each renders correctly in **primary visibility**; the
GI / reflection **bounce** samples only the first registered volume. The reason is
the sampler budget: the lighting megakernel already binds the WebGL2-guaranteed
minimum of **16** fragment samplers, and this feature's bounce path adds one
`sampler3D` (the 17th). It is compiled in only when a scene uses the feature **and**
the GPU exposes ≥ 17 fragment texture units (`MAX_TEXTURE_IMAGE_UNITS` — most
desktop GPUs report 32). On a bare-minimum 16-unit device the bounces fall back to
the material's flat base colour (a one-time `console.info`), while primary
visibility still shows the full field. The whole feature is behind a compile-time
`RT_VOLUME_ALBEDO` define that is **off unless a volume material is registered**, so
scenes that don't use it compile byte-identical shaders. Multi-volume bounces are
future work — the `userData` API doesn't preclude them. One more edge: the
**experimental** `restirGI` path (off by default) resolves the indirect bounce in
its own kernel and does **not** yet sample the volume — a volume surface's
*indirect* contribution falls back to its flat colour while `restirGI` is on (the
default inline-GI path carries the field correctly).

## Coloured shadows (`absorptionShadows`)

A shadow ray that crosses an **absorbing** glass material is attenuated `exp(-σ·d)` per RGB channel over the distance it spends inside, instead of being blocked outright. Stained glass spills tinted light onto the floor; a lightbox behind stacked translucent bodies lights what is in front of them instead of turning them into a black silhouette; clear glass (a glass material with no `attenuationDistance`) stops casting a shadow at all, which is the physically right answer. It is the shadow-ray half of the [Beer-Lambert absorption](#materials) feature and uses the same per-material σ — set `attenuationColor` + a finite `attenuationDistance` (or `userData.rtAttenuation`) and it just works.

```js
const rt = new RealtimeRaytracer(renderer);      // absorptionShadows defaults to true
rt.absorptionShadows = false;                    // opt out (recompiles the lighting shader)
```

**How it works.** An ordered closest-hit march along the shadow ray with an explicit current-medium state, capped at 8 interface events. It is deliberately *not* the cheaper trick of an unordered any-hit pass that adds σ on front faces and subtracts it on back faces: real multi-body geometry (a 3D-print stack, any pair of touching solids) contains interfaces where only one of two coincident walls survives, so entry/exit events don't pair up and a signed sum produces **negative** optical depth — optical gain and bright halos. The march cannot, however unbalanced the interfaces are. Hitting the 8-event cap returns the transmittance accumulated so far, so the failure mode is a slightly-off tint, never a black silhouette.

**Where it does *not* act (v1).** Worth reading before you rely on it:

- **The ReSTIR visibility ray.** With `restir: true` (the default), primary direct lighting is shaded by the reservoir winner's own visibility ray, which stays **binary**. So on the default settings you will see coloured shadows in the GI bounce and through reflections/refractions, but *not* on the primary surface's direct light. Turn `restir` off to get them on direct lighting. This is a real energy inconsistency between the two paths, not a rounding difference.
- **The volumetric march.** `VolumetricPass` in-scatter samples still use a binary occlusion test, so god-rays through tinted glass are not tinted.
- **Refraction.** Shadow rays travel in a straight line; they are not bent at the interfaces. Standard approximation — the light would have to be re-solved through the bent path.
- **Partial transmission.** Any material with `transmission > 0` is treated as fully transmissive *to shadow rays* (it attenuates by σ only). A `transmission: 0.5` surface does not half-block.
- Interfaces are classified with the interpolated attribute normal rather than a true geometric normal, which can mis-class a segment inside a smooth surface's silhouette band.

**Cost — this one is not free.** Unlike the view-path absorption it extends, coloured shadows replace a *bounded, unordered, early-outing any-hit* traversal with an *unbounded ordered closest-hit* traversal on every next-event shadow ray, and that is a large constant. Measured on an RTX 3060, museum scene, 1280×720 canvas at `renderScale 0.5`, full stack (GI + emissive + reflections + refraction), fence-timed medians:

| | `restir: true` (default) | `restir: false` |
|---|---|---|
| absorbing scene, `absorptionShadows: false` | 68.8 ms | 79.9 ms |
| absorbing scene, `absorptionShadows: true` | 82.7 ms | 108.5 ms |
| **cost** | **+13.8 ms** | **+28.7 ms** |

The `restir: false` column is the honest one — it is the configuration in which the feature actually acts on primary direct light, and there it costs roughly a third of the frame. Profiling with the event cap forced to 1 attributes ~21 ms of that 28.7 ms to the any-hit → closest-hit swap alone and the remainder to the extra marching past glass interfaces; the cost therefore scales with how many shadow rays you cast, not with how much glass is on screen. Treat it as a setting a user can turn off (the demo has a **"tinted shadows"** sub-toggle next to **"tinted glass"** for exactly that A/B), not as something to leave on by default in a shipping frame budget you have not measured.

**Zero cost when unused, provably.** With no absorbing material in the compiled scene — or with `absorptionShadows: false` — the marked lines are stripped and the lighting megakernel's source is **byte-identical** to the build without the feature (verified both at the module level and live via `getShaderSource` against a master checkout; the measured frame-time difference is −0.03 ms, i.e. nothing). No new samplers (the pass sits at the WebGL2 16-sampler minimum), no new uniforms, no new `traceRadiance` call sites; the "is this glass" flag rides an already-allocated channel of the absorption row.

Regression rig: [`absorption.html`](absorption.html) — phase 2 puts two overlapping slabs under a high lamp and asserts the receiving floor shows each slab's `attenuationColor` where it alone is overhead and their **product** where they overlap (measured within 5% of analytic, product relation within 2.2%), with a phase-3 A/B that turns the feature off and requires the same columns to go black.

## Scattering — translucent solids (`kmScattering`)

Jade. Wax. Marble and alabaster. Soap, milky plastic, a leaf held up to the sun, a lampshade. These are the materials a real-time renderer normally cannot do honestly, because what makes them look the way they do is light that goes *into* the surface, bounces around, and comes back out — and every cheap approximation of that is an authored fake. The usual one is a hand-painted **thickness map**, which stops being right the moment the object deforms, gets sliced, or is seen from a new angle.

This computes it instead. Give a material an absorption coefficient **K** and a scattering coefficient **S**, and the renderer measures how far each view ray actually travels inside the real geometry and solves the **Kubelka-Munk two-flux** equations for that thickness. No thickness map. No per-object tuning. A sphere thins out correctly toward its silhouette, a shell wall reads brighter where it is seen obliquely, and a body that gets deformed or replaced keeps looking right, because nothing about the appearance was baked.

```js
const stone = new THREE.MeshPhysicalMaterial({
  color: 0xffffff,          // white — K and S carry the pigment (see below)
  transmission: 1.0,        // translucent to the tracer, NOT `transparent`
  roughness: 0.35,
  ior: 1.5,
});
// K, the absorption half: the colour that survives 25 cm of travel.
stone.userData.rtAttenuation = { color: [0.0002, 0.186, 0.0098], distance: 0.25 };
// S, the scattering half: 1/world-unit, directly...
stone.userData.rtScattering = { coefficient: 20 };
// ...or authored the same way as K — 14% of the flux still going straight after 10 cm.
stone.userData.rtScattering = { color: [0.14, 0.14, 0.14], distance: 0.1 };

const rt = new RealtimeRaytracer(renderer, { kmScattering: true }); // default false
```

**This is not volumetric lighting.** Fog, god-rays and atmospheric haze are light scattering *between* objects and live in [`volumetrics`](#options); this is light transport *inside* solid bodies. They are unrelated features and you can run either, both, or neither.

**Picking K and S.** The model is invertible, which is the whole point of a physical parameterization: for a body thick enough to hide whatever is behind it, the reflectance is `R_inf = 1/(a + b)` with `a = 1 + K/S` and `b = sqrt(a² − 1)`, which rearranges to **`K/S = (1 − R)² / (2R)`**. Decide what colour the material should be when thick, read off K/S per channel, pick S for how quickly it gets there, and the thin parts follow automatically. The maths is exported so you can do this at build time or in a tool:

```js
import { kmReflectance, kmReflectanceInfinite, kmStackRGB } from "three-realtime-rt";
kmReflectanceInfinite(6, 20);        // masstone of this channel
kmReflectance(6, 20, 0.004, 0.85);   // 4 mm of it over an 85%-reflective backing
```

**What it does.**

- **Front-lit reflectance.** The headline. Under absorption alone a pigmented translucent body can only ever *subtract* light, so it renders as dark murk; with scattering it looks like its colour, lit from the front, the way the real material does.
- **Transmitted light.** Shadow rays crossing a scattering body are attenuated by the two-flux transmittance instead of Beer-Lambert, so a lamp lights the table through its shade, dimmer and warmer than absorption alone predicts. White pigment stops getting the free ride it gets under absorption-only, where a zero σ means a perfectly clear shadow.
- **See-through.** What is behind the body comes through weighted by that same transmittance — which is why a bulb inside a shade makes the shade glow.

**Limitations in v1 — read these, they are the difference between this and subsurface scattering.**

- **No lateral bleed.** This is 1-D transport *along the ray*: light leaves where it entered. Real subsurface scattering spreads light sideways inside the material, which is what gives a thin edge its glow and skin its softness. You will not get that here. A thin edge is thin *along the view ray* and reads correctly bright, but light that entered somewhere else does not travel to it.
- **No in-scattering into shadow rays.** A lit body does not add light to its own shadow, and it does not glow into the volume around it.
- **`R` is used as the diffuse albedo under `N·L` lighting.** Kubelka-Munk derives `R` under *diffuse* illumination; using it as an albedo lit by point sources is the standard engineering approximation (it is what every KM-based paint and print pipeline does). Exact in the diffuse-ambient limit, slightly over-bright at grazing incidence.
- **One medium along the view path.** Layered stacks are composed correctly along *shadow* rays, but the view path evaluates a single entry-to-exit chord. See the note below for why, and `kmStackRGB` for the full layered composition on the CPU.
- **Requires `refraction: true`**, for the same structural reason.
- **Thin bodies.** The tracer starts its in-medium trace `2 × eps` inside the surface, so a body (or a shell wall) thinner than that cannot resolve its own exit face. `eps` auto-scales with scene size — roughly 7 cm in a 24 m room — so either keep bodies chunky or set `eps` explicitly **as a constructor option** (assigning `rt.eps` afterwards is silently overwritten on the next compile).
- **Base colour should be white.** `R` *is* the diffuse albedo, and the composite multiplies it by the material's `color`. A non-white colour tints the computed pigment on top of itself; the compiler warns.

**Why one medium on the view path.** The design this shipped from had a dedicated ordered march along the view ray, composing an arbitrary layered stack — the same machinery coloured shadows use. It works, and it cannot be compiled: NVIDIA's native-GL assembler rejects the megakernel with `error: too many temporaries`, the register-pressure sibling of the `C5041` failure that killed a shadow-march optimisation in 0.9.0. Bisected on the GPU: full feature **35 319** lines of generated assembly → fails; shadow-side maths removed, **33 403** → still fails; the march compiled but never called → links. The march's own BVH traversal is the blocker, and no amount of shrinking the surrounding arithmetic buys it back. Reusing the shadow march for the view ray is *worse* — it is already inlined at roughly eight effective sites, so a third explicit call adds a ninth traversal. The shipped version evaluates the layer where the shader already computes an in-medium view chord: no new traversal, no new sampler, no new `traceRadiance` call site.

**Cost.** Almost none, because the design forced by that compile budget also turned out to be the fast one: scattering adds no rays and no BVH traversals, only arithmetic on a chord the shader was already computing. Fence-timed medians on an RTX 3060, museum scene, 1280×720 at `renderScale 0.5`, full stack, one foregrounded page at a time:

| leg | `restir: true` | `restir: false` |
|---|---|---|
| feature branch, nothing scatters | 47.14 ms | 56.80 ms |
| scattering material present, `kmScattering: false` + coloured shadows | 59.13 ms | 81.50 ms |
| scattering material present, `kmScattering: true` | 59.38 ms | 81.56 ms |
| **isolated cost of scattering** | **+0.25 ms** | **+0.06 ms** |

The honest caveat is that `kmScattering: true` compiles a **superset** of coloured shadows — the two-flux transmittance is evaluated inside that very march — so if you are coming from a scene with `absorptionShadows: false`, turning scattering on hands you the [coloured-shadow cost](#coloured-shadows-absorptionshadows) (+7.6 ms / +19.9 ms here) along with it. That is the number to budget against; the scattering arithmetic itself is free.

**Zero cost when unused, provably.** With `kmScattering: false`, or with no material carrying `userData.rtScattering`, the marked lines are stripped and the lighting megakernel's source is **byte-identical** to the 0.9.0 build — SHA-256 checked against a `master` checkout by `npm run test:km`, which also runs 23 numeric checks on the analytic reference (the `S → 0` degrade to Beer-Lambert, the `t → ∞` approach to `R_inf` from both sides, the `coth` guard at tiny `b·S·t`, channel independence, a 1344-case finiteness sweep, and the equivalence of the closed form with the forward composition).

Validation rig: [`scattering.html`](scattering.html) renders pigments of known K and S and divides by a white Lambert patch under the same directional light — which cancels exposure and units exactly — then compares against the CPU reference. A 10/20/40/80/160 mm slab staircase agrees within **1.5%**, and a sphere probed centre-to-rim within **1–8%**, with the thickness taken from the geometry rather than authored anywhere.

## Texture maps and secondary rays *(since 0.11.0)*

A textured surface seen through glass, in a reflection, or via a GI bounce used to
collapse to its flat average colour — an emissive checkerboard viewed through a
biconvex glass lens rendered as a featureless beige disc. That changed in 0.11.0:
**secondary rays now sample the actual texel at the hit point's UV**, so the
checkerboard seen through the lens is inverted and magnified.

No new GPU resources were added. The lighting pass sits at the WebGL2 16-sampler
minimum — texture tiles ride the already-bound scene-data texture:

- **Per-material tile indices** at row 69: `[albedoTile, emissiveTile, 0, 0]`,
  tile index as float, `-1.0` = no map.
- **Tile block** starting at row 70: each unique texture image is resampled to
  128x128 RGBA (linear colour for sRGB sources) and written as 128 consecutive
  rows. One cache per image so a texture shared by several materials gets one
  tile.

**Tile budget.** Capped at **16 unique texture images** by default
(`textureTiles.max`). Past the cap, further materials keep the averaged-colour
behaviour and a one-time `console.warn` names the dropped textures. A
non-drawable image (cross-origin canvas taint, missing image data) also falls
back to average with a one-time warning.

**What works.** The traced refraction and reflection path in the lighting
megakernel, and GI bounce albedo in the ReSTIR GI reservoir pass — so colour
bleeding carries the pattern. The shadow-ray path does not need maps and stays
on the material constants.

**The NEE light table and CDF keep using averaged emissive.** Importance
sampling for emissive area lights does not need the per-texel pattern — it only
needs to know which triangles are bright and how to distribute samples among
them. The G-buffer (primary visibility) already renders the full per-pixel
emissive map, so the on-screen appearance is unchanged.

**Option `textureTiles`.** Pass `{ size: 128, max: 16 }` to `new RealtimeRaytracer()`
or `compileScene()`, or set `false` to disable entirely. When `false` or the
scene has no textured materials, the BVH attribute texture uses the original
stride-1 layout and the shader compiles **byte-identical** to the 0.10.0 build —
the new code lives entirely inside `RT_TEXTURE_TILES` source-splice markers. The
option is typed in `index.d.ts`.

**Probe page:** [`probe-secondary-textures.html`](probe-secondary-textures.html)
— a glass sphere centred in front of a checkerboard, two variants toggleable by
query param (`?mode=emissive` for an emissiveMap checkerboard, `?mode=albedo` for
a lit albedo-map checkerboard), plus a dynamic-mesh variant (`&dynamic=1`).

## Live lighting & sky

Lights can be toggled, moved, and recoloured every frame without recompiling:

```js
warmLight.visible = false;      // or change .color / .intensity / .position
rt.updateLights(scene);         // re-reads the scene's lights
```

The procedural sky doubles as the ambient light source:

```js
const rt = new RealtimeRaytracer(renderer, {
  sky: {
    enabled: true,
    sunDir: new THREE.Vector3(0.55, 0.62, 0.55).normalize(), // toward the sun
    sunColor: new THREE.Color(1.0, 0.92, 0.78),
    zenith:   new THREE.Color(0.20, 0.40, 0.72),
    horizon:  new THREE.Color(0.78, 0.85, 0.92),
    intensity: 1.0,
  },
  fog: { enabled: true, color: new THREE.Color(0.72, 0.8, 0.88), density: 0.03 },
});
```

![Physics: 40 rigid bodies with dynamic ray traced shadows](docs/physics.png)

## What is and isn't supported

Primary visibility is rasterized into a G-buffer, so **whatever three.js draws,
you still see** — the ray tracer computes only the *lighting*, reading a
deliberately small, fixed slice of the material and light model. The one place
the G-buffer diverges from a plain three.js draw is transparency: it is a
**single-layer deferred blend** (see the `transparent` row below and the
Rendering-model notes), not three.js's per-fragment sorted over-blend. This is
the honest map of what actually feeds the traced lighting.

### Materials

Lighting reads the scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial`
(Basic / Lambert / Phong contribute whatever of those fields they have). A
**multi-material mesh** (`mesh.material` is an array + `geometry.groups`) now feeds
**every group's** material into both the G-buffer and the BVH (see the *2nd+
material of a group* row).

| Property | Feeds lighting? | Notes |
|----------|-----------------|-------|
| `color` + `map` | ✅ | Albedo = `color × map.rgb`. Textures stay sharp (irradiance is demodulated, then re-multiplied). |
| `roughness` | ✅ | Drives shadow / GI softness, reflection sharpness **and the GGX specular lobe width** (see *dielectric specular* below). |
| `metalness` | ✅ | Metallic pixels trace a reflection ray whose analytic-light glints are shadowed; `F0 = mix(0.04, albedo, metalness)`. |
| `emissive` | ✅ | A *static* emissive mesh becomes a real **area light** (NEE) — casts soft light + shadows, including a GGX highlight. |
| `emissiveMap` | ✅ average-colour approximation | A map-masked emissive **glows on screen** with its full per-pixel pattern (G-buffer, untouched) **and now also casts light**: the CPU averages the map (`avg(map) × emissive × emissiveIntensity`) and feeds that single colour into the NEE area-light table. Needs a non-black `emissive` (white keeps the cast hue equal to the map average) and a readable image (a CORS-tainted / not-yet-decoded map falls back to visible-only, with a one-time `console.info`). A map that averages to **near-black** (e.g. a model's mostly-dark emissiveMap with a few tiny glowing texels) casts nothing — treated as visible-only so it doesn't flood the NEE list with a whole high-poly mesh. Texel-accurate (spatially-varying) emission is future work. |
| `transmission` (Physical) | ✅ | Glass: Fresnel reflection (with analytic-light glints) + two-interface refraction. |
| `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`. |
| `opacity` on an opaque material | ❌ | Only read when `transparent: true`; an opaque material always writes at full coverage. |
| `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. |
| **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). |
| `roughnessMap` | ✅ | `roughness × roughnessMap.g` (three.js convention) — sampled in the G-buffer. |
| `metalnessMap` | ✅ | `metalness × metalnessMap.b` (a packed ORM texture works — G = roughness, B = metalness). |
| `normalMap` | ✅ | Perturbs the shading normal via a screen-space cotangent frame (no tangent attribute needed); respects `material.normalScale`. |
| `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. |
| 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). |
| `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). |
| `attenuationColor` + `attenuationDistance` (Physical), or `userData.rtAttenuation` *(unreleased)* | ✅ | **Per-material Beer-Lambert absorption** — tinted glass done right. Light crossing a glass material's interior is attenuated `exp(−σ·d)` per channel over the **in-medium path length** (the refracted view ray's entry-to-exit chord), so a thick slab of the same glass tints deeper than a thin one and a **backlit** pane glows in the filtered colour (the glow rides the refracted view segment — free). Opt-in on a glass material (`transmission > 0`, `transparent: false`): a **finite, positive** `attenuationDistance` (three's default `Infinity` = off) with `attenuationColor` = the colour that survives one such distance (`σ = −ln(color)/distance`, channels floored at 1e-4); `userData.rtAttenuation = { color, distance }` is the same control for materials without the physical fields, and wins when both are set. Costs **exactly nothing when unused** — a scene with no absorbing material compiles the byte-identical pre-feature program — and an unmeasurable few ALU ops when used (no extra rays, samplers or uniforms; σ rides a new row of the existing scene-data texture). Recompile after changes (`compileScene()`), like any material edit. **Limits:** tinted transmission only — media do not scatter/diffuse; needs **closed** glass volumes (the medium is identified at the exit interface; open sheets simply don't attenuate); **one in-medium layer per view path** — the second body of a stacked/nested pair resolves through the single behind-trace as a lit surface, not a traced medium; media thinner than ~`2 × rt.eps` can't resolve an exit interface (the auto-scaled epsilon makes centimetre glass the practical floor in room-scale scenes). The same &sigma; also drives **coloured shadows** on the shadow rays — see `absorptionShadows` below. Showcase: the museum demo's **"tinted glass"** toggle (backlit cast-glass relief on the red wall); regression rig: [`absorption.html`](absorption.html). |
| `userData.rtScattering` *(unreleased)* | ✅ | **Kubelka-Munk scattering** — translucent SOLIDS (jade, wax, marble, foliage, lampshades) rather than tinted glass. Absorption alone only ever removes light, so a pigmented translucent body lit from the front is black murk; a scattering coefficient sends light back out, and the two-flux closed form turns (K, S, thickness) into both a reflectance and a transmittance. The thickness is **measured per view ray through the real geometry** — no authored thickness map, so a sphere thins correctly toward its silhouette and a deforming body stays right. Opt in on a translucent material (`transmission > 0`, `transparent: false`, white `color`) with `userData.rtScattering = { coefficient }` (S in 1/world-unit) or `{ color, distance }` (the same derivation as absorption); **K is the material's existing attenuation**, so colour is stated once. Needs `rt.kmScattering` (default off) and `refraction: true`. Costs **exactly nothing when unused** (byte-identical program). **Limits:** no lateral bleed — this is 1-D transport along the ray, light leaves where it entered, so it is not subsurface scattering; no in-scattering into shadow rays; `R` is used as a diffuse albedo under `N·L` (the standard approximation); ONE medium along the view path (stacks compose correctly along shadow rays); bodies thinner than ~`2 × rt.eps` cannot resolve an exit face. Showcase: the museum's **"scattering (Kubelka-Munk)"** toggle (the "Alabaster" reading lamp and the absorb-vs-scatter sphere pair); validation rig: [`scattering.html`](scattering.html). See *[Scattering](#scattering--translucent-solids-kmscattering)*. |
| 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**. |
| 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)*.) |

### Lights

| Light | Supported | Notes |
|-------|-----------|-------|
| `PointLight` | ✅ | `light.userData.rtRadius` (default `0.15`) sets soft-shadow size. |
| `DirectionalLight` | ✅ | `light.userData.rtRadius` (default `0.02`) sets sun softness; keep its direction in sync with `sky.sunDir`. |
| Emissive meshes | ✅ static + dynamic | Sampled directly as area lights (NEE). **Dynamic** emitters (a mesh in `dynamicMeshes`) are refreshed every `updateDynamic()`: their world-space triangles are re-derived from the freshly baked/skinned/deformed positions and their NEE rows + power CDF rewritten — a moving glowing orb sweeps real light across the floor. Keep them **low-poly** (refreshed per frame) and note the 256-tri cap is shared with static emitters. |
| `SpotLight` | ✅ | Cone + penumbra respected; soft shadows via `rtRadius`; visible light cones in volumetric fog. |
| `RectAreaLight` | ❌ | Use an emissive mesh instead. |
| `AmbientLight` | ✅ *(since 0.15.0)* | **Flat unoccluded ambient**, no ray and no shadow: the visible ones are summed into `colour × intensity` and added to the direct irradiance, so the composite multiplies it by albedo exactly as three does. Not GI — nothing occludes it and nothing carries colour between surfaces. Toggle with `ambient`. |
| `HemisphereLight` | ✅ *(since 0.15.0)* | **Hemispherical unoccluded ambient**, `mix(groundColor, color, 0.5·dot(N, up) + 0.5)`, where `up` is the light's own world position direction (three has no target for one; default `+Y`). Several combine as an intensity-weighted mean axis. Same caveats and the same `ambient` toggle as above. |

- **Emissive noise caveat:** emissive NEE is the noisiest direct-light path — one
  uniformly-picked triangle sample per pixel per frame, with a `1/dist²` term that
  sparkles into fireflies near small, close emitters. **Keep `restir: true` in any
  scene that leans on emissive lighting** (the reservoirs converge each pixel onto
  the emitter that matters; the library logs a hint if you compile emissive
  geometry with ReSTIR off). Prefer larger/dimmer emitter surfaces over tiny
  bright ones, and let `fireflyClamp` do its job.
- Up to **`maxLights`** point/spot/directional lights, **default 128, hard max 256** *(0.16.0; it was a fixed 32 before)*. Further lights are dropped in traversal order, with the lights that are already seated keeping their seats. The cap is a **constructor option** because it is compiled into every lighting shader and into the scene-data texture's width; assigning to `rt.maxLights` throws rather than lying.
- **What a light costs.** Nothing per frame under ReSTIR: the reservoir shades ONE winner per pixel however many lights the scene has, so 96 lights and 6 lights trace the same number of shadow rays. Two paths are O(N) in the light count and only those: the **exact** per-light loop (`restir: false`, or a pixel younger than `restirWarmAge`), which pays one shadow ray per light, and **`analyticGlint`** on reflective pixels. The volumetric pass is NOT one of them (it samples one light per march step). What a light does cost is 4 texels of the scene-data texture and, when it moves, one row upload plus a light-grid rebuild.
- Moving, toggling, recolouring or dimming a light → `rt.updateLights(scene)` (cheap, no recompile).
- **Textured emitters** (`emissiveMap`) cast their **average** colour (`avg(map) × emissive × emissiveIntensity`), not per-texel — the sign still *looks* patterned in the G-buffer but lights with one averaged hue. Texel-accurate emission is future work.
- **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.
- 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.

### Supported object types

What kind of `Object3D` the tracer can actually see. Anything it cannot trace is
also excluded from the rasterized G-buffer, so the traced frame stays consistent
with what the lighting was computed against — and the library **warns once**,
naming the object (see *[Diagnostics](#diagnostics-statuswarnings)*).

| Object | Supported | Notes |
|--------|-----------|-------|
| `Mesh` | ✅ | The normal case: merged into the static BVH, or into the per-frame dynamic BVH when listed in `dynamicMeshes`. |
| `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)*. |
| `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`. |
| `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. |
| `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". |
| `Group` / `Object3D` / `Bone` | ✅ | Pure transform nodes; traversed for their mesh children. |

### Geometry & occlusion

- Every non-excluded visible mesh is **merged into one static BVH at compile time**. Add / remove geometry → recompile.
- 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)*.
- **Transparent materials never occlude** (by design — a glass case shouldn't cast an opaque shadow). They still rasterize normally.
- **Refractive glass occludes fully — unless it absorbs.** A `transmission > 0` material is in the BVH and blocks shadow rays like any solid, *except* when it carries a Beer-Lambert &sigma; and `rt.absorptionShadows` is on: then shadow rays through it are **attenuated per channel** instead of blocked. See *[Coloured shadows](#coloured-shadows-absorptionshadows)*.
- **`alphaTest` cut-outs** (`transparent: false`) *do* occlude — but as **full triangles**, not per-texel, so their shadows are blocky.
- `mesh.userData.rtExclude = true` removes a mesh from the BVH entirely (it still rasterizes and gets lit) — handy for water / translucent surfaces.

### Rendering model

- **1-bounce GI + direct light.** No multi-bounce diffuse, no caustics, no specular-chain paths.
- **Reflections** are a single traced bounce into a **diffuse-shaded** view of the world — no recursive mirror-in-mirror; a metal shows its surroundings, not a second full render.
- **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. **Per-material Beer-Lambert absorption** *(unreleased)* tints the transmitted term by `exp(−σ·d)` over the same two-interface in-medium chord (`attenuationColor`/`attenuationDistance`, or `userData.rtAttenuation`) — see its row in the Materials matrix for the opt-in and the honest limits, and *[Coloured shadows](#coloured-shadows-absorptionshadows)* for the same σ applied to shadow rays.
- **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
- **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).
  - **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.
  - **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.
  - **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.
- **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`).

### Platform

- Requires **WebGL2 + `EXT_color_buffer_float`**. Software rasterizers (SwiftShader / llvmpipe) are treated as unsupported.
- On anything unsupported, `rt.supported === false` and `rt.render()` **falls back to `renderer.render()`** after one console warning — your app still runs everywhere. Branch yourself with `rt.supported` or `RealtimeRaytracer.isSupported(renderer)` if you want.
- WebGL2 only; no WebGPU backend (on the roadmap).

## Options

**The defaults, and why they are what they are** *(since 0.15.0)*. The rule this
release was built to: *"I would like the default settings for the library to
just work so that anyone can simply add the RT library to their three js
projects and see a beautiful result right away."* The split is by KIND rather
than by taste.

- **The algorithm being right is ON**, because it is cheap: the four ReSTIR
  correctness fixes (`restirDirectionalBypass`, `restirReprojectionRescue`,
  `restirCandidateImportance`, `restirClampRel`) and `motionVectors` all default
  on, and together they measured at **1.01x** the frame time of the 0.14.1
  defaults on the museum at 720p, over five back-to-back pairs — free.
- **What costs rays is OFF.** `gi` now defaults to `false`. It is one extra
  traced ray per pixel per frame, shaded with the full direct + NEE stack, and
  it is the one feature a scene can be authored around. Turning it off is the
  whole of the 0.15.0 speed-up: **0.72x** the frame time, measured on its own.
- **Material-gated features stay on**, because they cost nothing until a scene
  has such a material: `reflections`, `refraction`, `transparency`,
  `absorptionShadows`, `specular`.
- **`ambient` is the new safety net.** With `gi: false` there is no other
  unoccluded term, so an `AmbientLight` or `HemisphereLight` in your scene is
  now honoured (it was ignored before) and keeps surfaces no light faces from
  rendering pure black.

`RealtimeRaytracer.DEFAULTS` is this table as a frozen object, if you want a
"reset to defaults" button of your own.

| Option | Default | What |
|--------|---------|------|
| `renderScale` | `0.5` | Lighting resolution vs. the G-buffer. `1.0` = max quality. |
| `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*. |
| `adaptiveQuality` | `true` | Governor that steers quality toward `targetFps` — scales **up** on strong hardware, **down** on weak. *Since 0.15.0 the UP half actually works*: it judges headroom on **measured GPU milliseconds** (see `gpuTiming`) rather than on a wall clock that vsync pins at the refresh period, so a transient no longer costs quality permanently. Spends in one order: **free wins first** (`giHalfRate`, `restirGI`, `restirMCap` 16 — measured cheaper *and* no worse), then `renderScale` in 5% steps to `0.2`, then the canvas via `canvasScaleHook`; returns them in reverse. Also sets `denoiseIterations` (never above **3**) and `stochasticLights`. Turn off for manual control. |
| `targetFps` | `55` | Frame rate the governor steers toward. |
| `canvasScaleHook` | `null` | Callback `(scale) => void` letting the governor drive your **canvas scale** — its deepest, most valuable lever. See *[canvasScaleHook](#canvasscalehook-the-governors-deepest-lever)*. |
| `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. |
| `denoiseIterations` | `2` | À-trous denoise passes. Measured: rmse-vs-reference degrades monotonically past 2, and the filter's coarse lattice ("plaid") rises 4–5× between 2 and 4 passes — it peaks wherever the widest tap spacing reaches ~16 *screen* pixels, so it is pass 4 at `renderScale 0.5` and pass 3 at `0.25`. The governor therefore never sets more than **3**; higher values remain available by hand. |
| `taa` | `true` | Temporal anti-aliasing (jitter + neighbourhood clamp). |
| `denoise` | `true` | Edge-aware à-trous denoiser. |
| `gi` | `false` *(was `true` before 0.15.0)* | 1-bounce global illumination (vs. direct-only). **The most expensive thing in the renderer**: one extra traced ray per pixel per frame, whose hit is shaded with the full direct + NEE stack. Measured at **0.72x frame time when off** on the museum at 720p (median of five back-to-back pairs, spread 0.23). Turn it on for colour bleed and contact shading; `ambient` is what keeps the off state from being black. |
| `ambient` | `true` *(new in 0.15.0)* | Honour three's **`AmbientLight` and `HemisphereLight`** as an **unoccluded** ambient term. The compiler sums the visible ones and the lighting pass adds `flat + mix(ground, sky, 0.5*dot(N, up) + 0.5)` to the direct irradiance — three uniforms and a dot product, no ray, no shadow, no sampler. **This is not GI**: nothing occludes it, nothing carries colour between surfaces, and GI bounces do not pick it up; `gi: true` remains the real thing. `false` uploads zeros, so the result is bit-for-bit the pre-0.15 one. |
| `emissiveNEE` | `true` | Sample static emissive meshes as area lights (next-event estimation). Off = emitters only light via lucky GI rays. |
| `emissiveImportance` | `true` | Pick WHICH emissive triangle NEE samples proportional to **area × emitted luminance** (compile-time power CDF) instead of a uniform 1-of-N. Same mean, far less sparkle when emitters differ in size/brightness. Off = legacy uniform pick. |
| `specular` | `true` | Cook-Torrance **GGX** dielectric highlights for every surface, in a separate white (`F0 ≈ 0.04`) buffer the composite adds without the albedo multiply. Off = the old Lambert-only look. Also required by `transparency`. |
| `reflections` | `true` | Traced mirror/glossy reflections on metallic surfaces (sharpest at `renderScale: 1`). |
| `refraction` | `true` | Traced two-interface refraction for `MeshPhysicalMaterial.transmission` surfaces. |
| `absorptionShadows` | `true` | **Coloured shadows**: shadow rays crossing an *absorbing* glass material are attenuated `exp(-σ·d)` per channel instead of blocked. No effect (and no cost) unless the compiled scene has an absorbing material. Live-assignable, but it recompiles the lighting megakernel. Read the cost note in *[Coloured shadows](#coloured-shadows-absorptionshadows)* before leaving it on. |
| `kmScattering` | `false` | **Translucent solids** (jade, wax, marble, foliage, lampshades): Kubelka-Munk two-flux scattering over the thickness the view ray actually travels through the real geometry, instead of an authored thickness map. Opt in per material with `userData.rtScattering`; needs `refraction: true`. No effect (and no cost) otherwise. Live-assignable, but it recompiles the lighting megakernel. See *[Scattering](#scattering--translucent-solids-kmscattering)*. |
| `transparency` | `true` | Alpha-blended transparency: composite `transparent` meshes over the geometry behind them (single-layer, weighted by `opacity`, tinted by albedo). Needs the specular buffer (`specular: true`). Off = they render fully opaque. |
| `restir` | `true` | ReSTIR direct lighting: per-pixel reservoirs with temporal + spatial reuse, one visibility ray regardless of light count. Flat cost in light count; cuts emissive area-light noise. |
| `restirGI` | `false` | **Experimental.** ReSTIR GI (v3): per-pixel reservoirs reuse the 1-bounce indirect sample across frames at the reprojected same-surface point, then take `restirGISpatialTaps` spatial taps (default `2`, `0` = temporal-only) of the previous frame's reservoirs — each reweighted by the reconnection solid-angle→area Jacobian and validated by a final visibility ray so light does not leak through walls. Runs in a standalone pass with its own sampler budget; the lighting pass then skips its inline GI trace and the resolved GI is added at the à-trous denoise stage — so it only takes effect when `gi` **and** `denoise` are also on. Its mean matches the inline GI path. **The resolve's colour is a weighted mean, not a draw** (`restirGIChromaMean`, default on) — see [ReSTIR GI: why the colour is resolved as a mean](#restir-gi-why-the-colour-is-resolved-as-a-mean). `restirGIMCap` (default `20`) tunes the temporal M-cap. `restirGIValidate` (default `8`, `0` = off) sets the reservoir-sample validation period: each frame a rotating 1-in-N subset of pixels re-aims its single candidate ray at the reservoir's stored hit and re-shades it; if the geometry moved or the re-shaded target collapsed to near-black (a light switched off) the reservoir is killed so fresh candidates rebuild, otherwise it is left untouched. This reuses the existing candidate trace (no extra bounce rays) and is what makes a switched-off light stop haunting the reservoir instead of fading slowly, while a static scene stays put. |
| `restirGIChromaMean` | `true` | **Experimental**, `restirGI` only. Resolve the GI colour as the RIS-weighted **mean** chromaticity of the reservoir's candidates rather than the chromaticity of the one sample the reservoir happens to hold. `false` restores the pre-v3 path. See the section linked above for what it fixes and why nothing else could see it. |
| `restirGIVisFallback` | `true` | **Experimental**, `restirGI` only. Cast the final visibility ray only when a **spatially adopted** sample won the reservoir (a temporal one is visible by construction), and on a rejection fall back to the pixel's temporal-only estimate instead of zeroing the pixel for the frame. `false` restores the pre-v3 path. |
| `restirGIResolveAlpha` | `1` | **Experimental**, `restirGI` only. Weight of the current frame in the resolve EMA; `1` = no EMA, which is the default. Values below 1 blend against a reconstruction of the *previous* frame's temporal-only resolve, which measured as a variance **source**, not a sink. |
| `ior` | `1.5` | **Global fallback** index of refraction for `refraction`. A `MeshPhysicalMaterial.ior` overrides it per material (fully-transmissive glass, range [1.0, 1.98]); this value applies to partial-transmission glass and as the default. |
| `dispersion` | `0` | Chromatic dispersion for glass, `0..0.5` (clamped). Splits refracted white light into a spectrum — a diamond throws a rainbow. Uses **stochastic spectral sampling**: each frame every glass pixel estimates one colour channel (R/G/B) through a channel-shifted ior and traces the *same single* refraction path, so it costs **no extra rays** and adds no traced-ray call site (it fits the Metal call-site budget). The three per-channel estimates are blended by the temporal accumulator, so the rainbow **only converges with accumulation on** and shimmers slightly in motion. **Global control** for now: three r160's `MeshPhysicalMaterial.dispersion` is not yet read per-material (no free G-buffer channel) — per-material dispersion is future work. |
| `volumetric` | *off* | Physically-based god rays: single-scatter fog, one BVH-shadowed light sample per lighting pixel per frame, temporally accumulated. `{ enabled, density, maxDist, zones }`, where `zones` is an optional array of up to 8 AABBs `{ min:[x,y,z], max:[x,y,z], density }` that add localized fog on top of (or instead of) the global `density`. *Since 0.16.2* the march is skipped for rays that cross no zone when `density` is 0 (byte-identical output; 16.8 ms → 0.04 ms per frame at canvas 1.0 in a scene whose one zone is off screen), so localized zones cost only the pixels that see them. |
| `restirDirectionalBypass` | `true` *(new in 0.15.0)* | **Directional lights never enter the reservoir** and are shaded exactly instead. A reservoir scores candidates **unshadowed**, and a sun is bright on every surface facing it while being occluded on most interior ones — so the reservoir elects it again and again, spends its one visibility ray on the wall in between, and the pixel resolves to black with the odd frame's runner-up as a bright speck. Measured on a doorway turn, over the region stock breaks: error **18.67 → 10.90**, against **10.69** for the same scene with the sun's intensity zeroed. Costs one shadow ray per pixel per directional light (+8.7% there); unbiased either way. |
| `restirReprojectionRescue` | `true` *(new in 0.15.0)* | ReSTIR temporal reprojection that survives TAA jitter and thin geometry: the sub-texel correction the irradiance accumulator already applies, plus a four-neighbour rescue when the plane test fails. Without it a baluster's pixels reject their history **every frame** and their reservoirs restart from eight uniform candidates forever. Share of pixels that never warm up at a settled pose: **9.8–13.4% → 0.2–0.9%**; in a scene with no thin geometry the permanent 3.2–3.8% floor goes to **0.00%**. ALU only — at most four extra G-buffer fetches, still one reservoir fetch. |
| `restirCandidateImportance` | `true` *(new in 0.15.0)* | Draw reservoir candidates the way NEE draws them — pool by power, then that pool's own CDF — instead of uniformly over (lights + emissive triangles). Uniformly, **91% of the candidate budget went to a pool carrying 3.7% of the light** (98% for 2% in a smaller scene). Turn error at four capture frames: **14.24 / 13.09 / 8.29 / 4.90 → 7.00 / 5.62 / 5.41 / 2.06**, with a signed error of **−0.001 of 255** at convergence (the unbiasedness check). Measured **free**, and slightly cheaper: an 8-step binary search on 10% of candidates costs less than four texelFetches on 91% of them. |
| `maxLights` | `128` *(new in 0.16.0; was a fixed 32)* | **Light-table capacity**, 1–256, **constructor only**. The table used to live in three `vec4[32]` uniform arrays in four shaders: 32 seats already spent 96 of the 224 uniform vectors WebGL2 guarantees, which is what the cap was. In 0.16.0 it lives in a row of the scene-data texture (4 texels per seat, read through one accessor per pass), so a seat costs texels instead of uniform vectors. The lighting pass still binds **exactly 16 samplers**. Setting it after construction throws: it is a `#define` in four programs and the texture's width. |
| `restirLightGrid` | `true` *(new in 0.16.0)* | **Local candidates.** Draw the reservoir's analytic-light candidates from a per-cell distribution over a uniform grid on the scene's static bounds (RTXDI calls this a light grid) instead of one scene-wide power CDF. With eighty lights in a building, a global CDF puts about one candidate in thirty-two inside the pixel's own room and the reservoir spends its stream on lights behind walls; the grid weights each light by the inverse square of its distance to the pixel's own cell, so the candidates are lights that could actually light it. Built on the GPU in two small draws whenever the light set changes, and it is where the global CDF now lives too (row 0), so `false` is the exact 0.15.0 candidate stream rather than a different one. RIS stays unbiased: every active light keeps a non-zero probability. |
| `restirClampRel` | `2` *(new in 0.15.0)* | Firefly cap on the ReSTIR direct term as a **multiple of the pixel's own reservoir estimate** of the unshadowed light total, or the absolute `2 × fireflyClamp`, whichever is larger. `0` = the absolute cap alone (the pre-0.15 behaviour). One sample carries the *whole* light sum, so the term is bimodal — near the total when the winner is visible, zero when it is not — and an absolute cap clips the peaks while nothing lifts the zeros, so bright surfaces converge **dark**. Converged signed error: **−2.52 → −0.23** (gallery), **−4.75 → −3.80** (great hall). |
| `restirWarmAge` | `0` *(new in 0.15.0)* | **Cold-pixel exact fallback**, `0` = off. Frames of validated reservoir history a pixel must have before its reservoir may shade it; below that it is shaded by the exact per-light loop. It removes reveal speckle outright — a whole-screen reveal at one frame goes **51.8 → 11.5** error, landing on the ReSTIR-off curve — but the exact path is 5–6× a ReSTIR frame, and because the cold pixels are a fine **stipple** rather than a region the branch is paid at warp granularity: measured **2.2× the frame in motion** even after the reprojection rescue cut the cold fraction to under 1%. Off by default for that reason. |
| `restirSamples` | `1` *(new in 0.15.0)* | Reservoir winners shaded per pixel, each with its own visibility ray, averaged (`2`–`4` add 1–3 neighbouring pixels' winners; the spatial stage already wrote them, so only the extra shadow ray is paid for). Estimator noise 16.81 → 11.13 from 1 to 4 — real, but **sub-`1/√N`**, because neighbouring reservoirs were merged from overlapping taps one stage earlier. Through the shipped denoiser the win mostly disappears, so this is a lever for a weaker denoiser or a machine with headroom. |
| `restirSampleRadius` | `10` *(new in 0.15.0)* | Neighbour-tap radius ceiling for `restirSamples > 1`, in lighting-res texels. Larger taps decorrelate the extra samples more but fail validation more often. |
| `restirDynamicAccept` / `restirDynamicFreeze` | `false` / `false` *(new in 0.15.0)* | Two treatments for pixels on a **moving mesh**, whose reprojected history is rejected every frame. `Accept` skips the surface test and offers the co-located previous reservoir as a candidate (a wrong one loses on weight); `Freeze` stops a dynamic pixel overwriting the history the background behind it will need. Both work at the mechanism level (reservoir M on the mover 17.97 → 23.14, starved pixels 38% → 5%) and **neither moved the visible noise**, because the shimmer there is dominated by the irradiance EMA. Off, because a change with no measured visible benefit should not be on. |
| `gbufferMaterialPooling` | `true` *(new in 0.16.2)* | One shared G-buffer material per `(vertexColors, side)` key, source uniforms synced per draw in `onBeforeRender`, instead of one `ShaderMaterial` per mesh: fewer program switches in the raster G-buffer pass. Meshes with custom render callbacks and multi-material meshes keep per-mesh proxies. Raster output byte-identical. `false` = the per-mesh path. |
| `motionVectors` | `true` *(new in 0.15.0)* | Reproject temporal history through each fragment's **previous screen position** (a fifth `RG32F` G-buffer attachment) instead of through the camera alone. Camera-only reprojection is correct for static geometry and simply wrong for a moving mesh, whose points occupied different world space last frame. Residual on a moving mesh **5.39 → 4.70** mean, **8.69 → 6.57** p95. Consumed by the irradiance EMA and the ReSTIR reservoir; **TAA deliberately does not** (it measured as a clear regression on its own). For a static mesh the vector collapses exactly to camera-only reprojection, so a static scene renders byte-identically either way. Needs ≥ 5 draw buffers (WebGL2 guarantees 4); without them the option is ignored with a one-time warning — check `rt.motionVectorsSupported`. |
| `gpuTiming` | `"auto"` *(new in 0.15.0)* | GPU-cost timing for the governor (`EXT_disjoint_timer_query_webgl2`). It is what makes the quality ladder **two-way**: wall-clock frame time is pinned to the display's refresh period by vsync, so it can prove a frame is too slow but never that there is headroom to spend. `false` forces the speculative-probe fallback used where the extension is withheld (Safari, iOS). Read `rt.gpuCostMs`, `rt.gpuTimingSupported`, `rt.gpuTimingActive`. |
| `stochasticLights` | `false` *(was `true` before 0.15.0)* | One direct shadow ray per pixel per frame (random source) instead of one per light. It only ever applies when **ReSTIR is off**, and ReSTIR is the cheap many-light path and is on by default — so what the old default actually did was redefine `restir: false` to mean *one random light per pixel*, the noisiest estimator here, rather than the exact per-light loop. Off, `restir: false` is the exact path, which is what a reference is for. The governor still turns it on when it needs the rays back. |
| `temporalReprojection` | `true` | Keep samples across camera/object motion. |
| `maxHistory` | `48` | Irradiance-EMA history cap — higher is smoother, slower to react, and it is the **dominant** ghost carrier of the pipeline's three temporal stores (measured: 48 → 16 cuts in-motion error 9.13 → 5.29 and the post-motion P95 residual 13.9 → 8.3 on the Cornell scene). Left high because that trade is a *look* decision, not a bug; lower it if your camera moves a lot. |
| `restirMCap` | `16` | ReSTIR reservoir staleness cap — how much confidence a direct-lighting reservoir may accumulate before new candidates stop displacing it. **Lowered from 40 on measurement**: 16 was better on *every* metric in *both* measured scenes (rmse 5.39 → 4.92 / 5.13 → 4.70, in-motion error and post-motion ghost both down) for ~0.3 ms. |
| `motionAdaptive` | `false` | **Opt-in.** Lerp all three temporal stores toward their `*Moving` counterparts (`maxHistoryMoving` `6`, `taaBlendMoving` `0.4`, `restirMCapMoving` = `restirMCap`) by the measured camera motion, so history is short while moving and long again the instant it stops. Costs no fps and no still-frame quality, and measured −46% in-motion error / −33% post-motion P95 residual on the Cornell strafe path — a *lower bound*, since the gain scales with motion and the measured paths are gentle. Off by default because it changes the temporal look of every scene; with it off every uniform keeps its pre-feature value. |
| `denoiseWideDamp` | `0` | **Opt-in** (0 = off, 1 = full). Wavelet shrinkage of the *coarse* à-trous passes. Only relevant past 3 passes, where it recovers most of the accuracy the extra passes cost, at identical frame time and unchanged temporal noise (6 passes: rmse 5.82 → 5.48 Cornell, 6.13 → 5.77 museum; 2 passes is 5.43). It **attenuates** the lattice rather than removing it, which is why it is an option and not a fix — and why the governor, which caps itself at 3 passes, never needs it. |
| `denoiseMaxStep` / `denoiseStepJitter` | `0` / `0` | **Opt-in, both measured and REJECTED** as artifact fixes; kept only as A/B hooks. Capping the tap cascade makes it repeat its widest step and *reinforces* that period (period-32 energy 0.67 → 1.04); jittering the tap radius cuts the lattice but doubles still-frame temporal noise (0.137 → 0.271), trading a static artifact for shimmer. |
| `fireflyClamp` | `4.0` | Clamp on indirect luminance to suppress fireflies. |
| `costScale` | `1/96` | BVH-cost heatmap scale for the `outputMode: 7` debug view (shadow-ray node-visit count × this, mapped through a cold→hot palette). See *Debug views*. |
| `sky` | *off* | Procedural sky as background + GI ambient (see above). |
| `fog` | *off* | Distance fog, composited before tonemap. |

Per-light: set `light.userData.rtRadius` for soft-shadow size. Set
`mesh.userData.rtExclude = true` to keep a mesh out of the BVH (it still
rasterizes and gets lit — useful for water / translucent surfaces).
Transparent materials never act as occluders (a glass case shouldn't cast an
opaque shadow); `alphaTest` cut-outs still do.

## Quality presets *(since 0.12.0)*

A product or game that wants "quality" or "performance" without learning fifteen
sliders can pick a named preset. `RealtimeRaytracer.PRESETS` is a plain,
inspectable object; `rt.applyPreset(name)` applies one to a live instance at any
time.

| Preset | Intent | Knobs |
|--------|--------|-------|
| `quality` | Fidelity first | `renderScale` 0.75, `denoiseIterations` 2, `maxHistory` 256, TAA on, ReSTIR on, `giHalfRate` off, specular on |
| `balanced` | Today's defaults, captured explicitly | The preset-managed knob set at its constructor defaults. A no-op on a fresh instance, asserted in the render self-test. *Its `stochasticLights` moved to `false` in 0.15.0 for that reason — the preset mirrors the default, it does not choose* |
| `performance` | FPS first | `renderScale` 0.375, `denoiseIterations` 3, `giHalfRate` on, volumetric off, `stochasticLights` on |
| `motion` | Fast camera / gameplay | `maxHistory` 32, `fireflyClamp` 2.5, TAA on, ReSTIR on  -  short history and a tighter firefly clamp cut ghosting, accepting a little extra noise |

Every bundled knob is a live-tunable setting  -  **none of them needs a
recompile**. Knobs that swap the lighting megakernel's source or a recompile
(`absorptionShadows`, `kmScattering`, `textureTiles`) are deliberately excluded
from every bundle; the values shipped are the measured winners of the v0.12.0
evidence round (see `docs/dev/REPORT_PRESETS.md` for the bench table and the blind Gemini
video rankings).

Constructor: pass `preset` and it is applied as the BASE of the options, so an
explicit per-option value always wins over the preset:

```js
const rt = new RealtimeRaytracer(renderer, {
  preset: "performance",     // start from the performance bundle…
  renderScale: 0.5,          // …but override one knob
});
```

With **no** `preset` key the constructor is byte-identical to the build without
the feature (option values asserted in the render self-test).

At runtime, switching is a live call:

```js
rt.applyPreset("quality");   // mid-frame safe  -  no recompile, no scene reset
```

**Adaptive quality interplay.** A preset sets the BASELINE the adaptive governor
breathes around. Applying one re-arms the governor at that baseline (its EMA,
cooldown and free-win state reset), so it measures the new settings fresh. On a
machine where the governor is active, the preset's `renderScale` /
`denoiseIterations` / `stochasticLights` are starting points the governor then
moves; the preset's other knobs (`maxHistory`, `fireflyClamp`, `giHalfRate`,
`specular`, volumetric) are direct.

`rt.preset` returns the last preset name applied (constructor option or
`applyPreset()`), or `"custom"` when no named preset has been applied. It is
deliberately **last-applied-name only**: a knob the governor or a manual
assignment changes afterwards does not flip it back to `"custom"`.

Game-loop integration:

```js
const rt = new RealtimeRaytracer(renderer, { preset: "balanced" });
rt.compileScene(scene, { dynamicMeshes: movers });

// A settings menu maps a user's choice straight to a preset.
function applyQualitySetting(choice) {
  rt.applyPreset(choice === "low" ? "performance"
    : choice === "high" ? "quality"
    : choice === "motion" ? "motion"
    : "balanced");
}

function frame() {
  requestAnimationFrame(frame);
  rt.updateDynamic();       // only on frames where something moved
  rt.render(scene, camera);
}
frame();
```

## ReSTIR GI: why the colour is resolved as a mean

`restirGI` shipped measurably *faster* than the inline GI path at flat error, and
was still not worth turning on: the picture grew coloured blotches that no
number in the campaign could find. Writing the resolve out in full says why.

A reservoir holds one selected sample and an unbiased weight `W`. The resolve is
`gi = selRad * selCos/PI * W` with `W = wSum / (M * rtLum(selRad) * selCos)`.
Substitute and both `selCos` and the luminance cancel:

```
gi = chromaOf(selRad) * wSum / (PI * M)
```

So the resolve is two very different estimates multiplied together. Its
**luminance** is `wSum/(PI*M)` — a running mean over the reservoir's whole
M-frame history, well averaged, which is what ReSTIR is for. Its **colour** is
the chromaticity of the *one* sample the reservoir currently holds. In a Cornell
box that means every pixel is showing the colour of whichever wall its reservoir
picked: the raw resolve, read straight off the GPU before the denoiser, is a
red/green confetti field at 37% chromaticity spread per pixel.

Nothing in the measurement stack could see it. `rmse` is luminance-dominated and
the **mean** colour is correct — the estimator is unbiased, that is the whole
point of RIS — so rmse read "free". The à-trous denoiser's edge-stopping weights
are luminance-based too, so it cannot detect the error to stop on it; it
averages the confetti into coarse coloured patches instead, which is the thing
on screen. The campaign's grid statistic did twitch, but only as a second-order
luminance consequence of a first-order colour problem, which is why it looked
weak and unstable.

The fix costs one multiply-add per merge point. Accumulate the RIS-weighted sum
of the candidates' chromaticities beside `wSum`, and resolve the colour as
`chromaAcc / wSum` — the expectation of the very draw the reservoir makes.
That is Rao-Blackwellization: identical mean, strictly lower variance, and here
it removes nearly all of the colour variance because the weights *are* the
selection probabilities. `rtLum(chromaOf(x))` is 1 and `rtLum` is linear, so the
mean is itself a unit-luminance chromaticity and rescaling by the resolved
luminance leaves it bit for bit unchanged — every luminance-derived quantity in
the pass (`p_hat`, `W`, the merge weights, the validation test) is untouched.
The store writes the running chromaticity back into the reservoir radiance,
whose luminance is the only part ever read out again, which makes it recursive:
one term folds in the entire history at exactly the weight the history carries.

Measured on Cornell, restirGI on, everything else equal: raw-resolve
chromaticity spread **0.388 → 0.106**, and the coloured structure the fix
actually targets — the block structure of the on-minus-off difference field on
the two chromaticity planes, which contains no scene content at all —
**1.43 → 0.89**. Error improves rather than degrades (rmse 5.83 → 5.34, now
*better* than restirGI off at 5.39), still noise drops (0.194 → 0.178), post-
motion ghost residual drops (2.44 → 2.12), and the speed is unchanged: 9.57 →
9.63 ms against 11.20 ms with restirGI off.

The **speed win is intact**. On the 141k-triangle tokyo scene the feature's
headline stays where it was: 47.59 ms with `restirGI` off, 34.53 ms on —
**−27.4%**, slightly better than the pre-fix path's −26.0%, because the
visibility ray now fires on about half the pixels instead of all of them.

How visible the artifact was scales with **how coloured the bounce is**: the
Cornell box and the museum's red wall are the extremes, and a grey ground under
a blue sky barely shows it (raw-resolve chromaticity spread 0.929 for the museum
against 0.118 for the lantern scene, pre-fix).

One consequence worth knowing about: this **inverts** the reasoning that set
`restirGISpatialTaps` to 1. A tap used to *swap in* a different sample's colour,
so each one was a fresh chance to draw the wrong one. Now a tap is folded into
the mean by its own RIS weight, so taps are a variance **sink** — raw-resolve
chromaticity spread runs 0.089 / 0.062 / 0.051 / 0.045 for 1 / 2 / 3 / 4 taps.
The default is back to 2, where the curve flattens.

`restirGI` remains **experimental and off by default**, and the adaptive
governor still does not turn it on by itself.

## Edge convergence and overscan

Lighting is accumulated over time (temporal reprojection). When the camera
moves, pixels newly revealed at the **leading screen edge** have no history to
reproject from, so they start from a single noisy sample and take several frames
to converge — a shimmering band that rides the edge you are turning toward.

`overscan` hides it by rendering *bigger than the screen*. Every internal pass
(G-buffer, lighting, denoise, volumetric, composite, TAA history) runs at a
padded resolution with a proportionally **widened field of view**, and only the
final on-screen draw crops the central canvas-sized region out. Disoccluded
pixels are then born in the padding — off-screen — and have already converged by
the time the camera turns far enough to bring them into view.

```js
const rt = new RealtimeRaytracer(renderer, { overscan: 0.1 });
// or live: rt.overscan = 0.05;  (reallocates targets; resets accumulation)
```

`overscan` is the padding fraction **per edge**. `0.1` on a 1000×600 canvas
renders 1200×720 internally and crops the central 1000×600 — both axes pad by
the same fraction, so aspect ratio is preserved and the widened frustum stays
centred on your camera's. The cost is purely the extra pixels: `1 + 2·overscan`
per axis, so **0.1 → 1.44×** the work of every pass. **0.05–0.1** is the useful
range; more just spends pixels on padding you will rarely turn fast enough to
need. Changing it live reallocates the targets and resets accumulation, so treat
it as a settings-time knob rather than a per-frame one. The camera you pass to
`render()` is never mutated — the widened projection is applied and restored
internally each frame, exactly like the TAA jitter.

## Running everywhere (capability tiers)

The zero-config defaults are **conservative and self-scaling**: construction
starts low-but-ray-traced and the adaptive governor (on by default) walks
quality up or down toward `targetFps`. The knobs below let you set a smarter
starting point or take manual control:

- **No usable GPU** (missing WebGL2 float targets, or a software rasterizer
  like SwiftShader): the library logs one console warning and `rt.render()`
  silently falls back to plain `renderer.render()`. Your app runs everywhere
  with zero capability checks; query `rt.supported` or the static
  `RealtimeRaytracer.isSupported(renderer)` if you want to branch yourself.
- **Tier presets**: `RealtimeRaytracer.detectTier(renderer)` returns
  `"none" | "mid" | "high"`, and `recommendedOptions(tier)` gives matching
  constructor options — spread them, then override what you like:

  ```js
  const tier = RealtimeRaytracer.detectTier(renderer);
  const rt = new RealtimeRaytracer(renderer, {
    ...RealtimeRaytracer.recommendedOptions(tier),
    targetFps: 55,
  });
  ```

- **GPU probe** (`await RealtimeRaytracer.probeGPUTier(renderer?)`): an optional,
  async, more-informed alternative to `detectTier`. When the browser exposes
  **WebGPU** it inspects the real adapter limits; otherwise it falls back to the
  WebGL heuristic. Returns `{ tier, source: "webgpu"|"webgl"|"fallback", details }`.

  ```js
  const probe = await RealtimeRaytracer.probeGPUTier(renderer);
  const rt = new RealtimeRaytracer(renderer, RealtimeRaytracer.recommendedOptions(probe.tier));
  ```

  **Honest heuristic — WebGPU does NOT expose VRAM.** There is no API for actual
  video memory, so the probe uses `adapter.limits` (`maxBufferSize`,
  `maxTextureDimension2D`, …) as a *proxy* for GPU class — two cards with wildly
  different VRAM can report the same limits. `adapter.info`
  (vendor/architecture/description) is masked on many browsers, so it is a hint
  only. Screen resolution is factored in: `screenPixels = screen.width *
  screen.height * min(devicePixelRatio, 2)`; a 4K-class panel (`>= 6e6`) has to
  fill ~4× the pixels of 1080p, so a GPU is only rated `"high"` on such a screen
  when `maxBufferSize >= 4GiB` (otherwise it is demoted to `"mid"`). Software
  renderers (SwiftShader / llvmpipe) rate `"none"`. Every threshold and the raw
  values are echoed back in `details`. The constructor stays synchronous — this
  is a pre-construction, opt-in call.

- **Adaptive quality** (`adaptiveQuality: true`, default **true**): watches real
  frame time and spends quality in a fixed order, cheapest-first, toward
  `targetFps`:
  1. **free wins** — `giHalfRate` on, `restirGI` on (with denoise passes held at
     ≤ 3), `restirMCap` down to 16. Measured cheaper *and* no worse, so no
     resolution is given up until they are spent.
  2. **`renderScale`** in 5% steps down to `0.2`, with a cooldown.
  3. **canvas scale**, via `canvasScaleHook` — only once `renderScale` is at its
     floor. See below for why that order and not the reverse.
  On the way back up it returns them in the exact reverse order. While enabled it
  drives `renderScale` / `denoiseIterations` / `stochasticLights` / `giHalfRate` /
  `restirGI` / `restirMCap`; turn it off for manual control. It never raises
  `denoiseIterations` above **3** — past 2 passes accuracy degrades monotonically
  and the à-trous lattice becomes measurable (that is a *governor* policy; the
  option itself still takes any value you set).
- **`renderScaleMax`** (0.16.5, `0.2..1`, default `1`) and **`renderScaleMin`**
  (0.16.6, default `0.2`): the ceiling and floor the governor may steer
  `renderScale` between; both live. Pin the ceiling on a phone or tablet
  instead of turning the governor off: every rung up reallocates every pass at
  the bigger size, and on iOS Safari that memory spike is what loses the WebGL
  context minutes into a session, while the governor still steps DOWN freely.
  Lowering the ceiling below the current scale clamps the scale on the next
  frame. A denoiser plugin's `preferences.renderScale` narrows the two further
  (never wider than yours).
- **`stochasticLights: true`** (default false): one direct shadow ray per
  pixel per frame instead of one per light — the biggest ray-count lever for
  many-light scenes and mobile GPUs.

### canvasScaleHook (the governor's deepest lever)

`renderScale` only shrinks the *lighting* buffer. **Canvas scale** shrinks the
whole drawing buffer, so every pass — the raster G-buffer, lighting, denoise,
TAA, resolve — gets quadratically cheaper. That makes it the strongest lever the
governor has, and the one it reaches for **first when recovering** quality and
**last when cutting** it (only once `renderScale` has bottomed out at `0.2`).

Last, and by measurement: at **matched cost**, full canvas at `renderScale 0.2`
beats canvas `0.85` at `renderScale 0.2` by **14–22% rmse** and retains ~40% more
detail (sharpRatio 0.96 vs 0.65–0.69) on both real scenes. Shrinking the canvas
throws away the G-buffer's *edges* — the one part of the image the tracer gets
sharp for free from the rasterizer — so it is the last thing to give up, not the
first.

The canvas belongs to your app, not the library, so the governor cannot resize it
itself: it calls **`canvasScaleHook(scale)`** with the next value from
`RealtimeRaytracer.CANVAS_LEVELS` (`[1, 0.85, 0.75, 0.62, 0.5]`) and leaves the
implementation to you. The pattern is: render a **reduced drawing buffer**, then
**CSS-stretch** the canvas back to full size.

```js
let canvasScale = 1;

const applyCanvasSize = () => {
  // CSS size stays the full layout size; the BUFFER shrinks.
  renderer.domElement.style.width = `${innerWidth}px`;
  renderer.domElement.style.height = `${innerHeight}px`;
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2) * canvasScale);
  renderer.setSize(innerWidth, innerHeight, false);
};

const setCanvasScale = (s) => {
  canvasScale = s;
  applyCanvasSize();
  rt.setSize(...renderer.getDrawingBufferSize(new THREE.Vector2()).toArray());
  // Keep the TAA jitter constant in SCREEN pixels: at a reduced canvas scale the
  // CSS stretch magnifies buffer-pixel jitter into visible wobble.
  rt.taaJitterScale = s;
};
```

Two rules: call `rt.setSize(...)` with **drawing-buffer pixels** after the resize,
and set **`rt.taaJitterScale = s`**. Skipping the second is the usual cause of
"the image shimmers once auto-quality kicks in". The hook is optional — without
it the governor simply never uses the canvas lever. Live examples:
[`examples/main.js`](examples/main.js) and [`examples/gallery.js`](examples/gallery.js).

### Recommended integration

Tier detection for the starting point, the governor for everything after, and the
canvas hook so the governor has its deepest lever:

```js
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setSize(innerWidth, innerHeight);

const tier = RealtimeRaytracer.detectTier(renderer);   // "none" | "mid" | "high"
const rt = new RealtimeRaytracer(renderer, {
  ...RealtimeRaytracer.recommendedOptions(tier),       // sensible start for the GPU
  adaptiveQuality: true,                               // (already the default)
  targetFps: 55,
  canvasScaleHook: (s) => setCanvasScale(s),           // see above
});

rt.compileScene(scene, { dynamicMeshes: movers });     // explicit — see Diagnostics

function frame() {
  requestAnimationFrame(frame);
  rt.updateDynamic();                 // only on frames where something moved
  rt.render(scene, camera);
}
frame();
```

`detectTier` picks *where you start*; `adaptiveQuality` decides *where you end up*
on the machine that actually loaded the page; `canvasScaleHook` widens the range
it can move through. On a device too slow to trace at all, `rt.supported` is
`false` and `rt.render()` is a plain `renderer.render()` — no branch needed.

WebGPU: not used as a backend (this is a WebGL2 library); a WGSL compute
backend is on the roadmap.

## Running the demo

```bash
npm install
npm run dev       # http://localhost:8115
```

The demo ([`examples/`](examples/)) is a **three-stop guided tour**, each stop its
own page and its own deep link, all three wearing the same chrome: PREV / NEXT,
a prominent RT ON/OFF switch outside the panel, and the full shared
renderer/lighting panel ([`examples/panel.js`](examples/panel.js)) with a
per-room exhibit section below it. Renderer settings follow you from stop to stop.

1. [`index.html`](index.html) — a procedural **Cornell box**
   ([`examples/cornell.js`](examples/cornell.js)), the entry page and the fastest
   room in the repo. Its centre holds one exhibit at a time — diffuse blocks, a
   **textured duck pair** (the repo's Duck.glb as shipped vs. the same mesh with
   a normal + roughness map, i.e. the G-buffer's map paths), mirror sphere,
   glass sphere, tinted glass panes, emissive block, a
   Kubelka-Munk stone wedge — and selecting one switches on the feature it
   demonstrates (never overriding a toggle you moved by hand). Deep-linkable per
   exhibit: `index.html#glass`.
2. [`museum.html`](museum.html) — the museum room, below.
3. [`models.html`](models.html) — stock glTF with a model picker, sharing the
   gallery's catalogue ([`examples/gallery-scenes.js`](examples/gallery-scenes.js)).
   Deep-linkable per model: `models.html#tokyo`.

Every stop's chrome also links [`costs.html`](costs.html) — the **cost report**:
what each switch in the panel is worth in milliseconds and fps, per scene (86 /
50k / 141k triangles), rendered straight from the committed measurement matrix
([`quality-campaign/cost-matrix.json`](quality-campaign/cost-matrix.json)). It is
a report, not a benchmark: nothing on that page is measured in your browser. It
also spells out the three results whose sign is the opposite of what the feature
name suggests — `restir` is a *speed* feature (off = 34–39% slower in the
multi-light scenes), `restirGI` is too (−27% on the 141k scene, at slightly
better accuracy), and `giHalfRate` buys 10–22% of the frame for a rmse wash.

Stop 2 is a **panoramic museum gallery** — a
Cornell-style room (saturated red/teal side walls for obvious colour bleed, open
top) staged as an exhibit where every renderer feature gets its own vignette: a
**deforming mirror-water pool** under the emissive gallery light, the
DamagedHelmet hero on a pedestal under a toggleable **spotlight** (normal /
roughness maps + analytic-light glints), a glossy teapot showing **GGX dielectric
specular** against the teal wall, a gold torus knot and mirror sphere (traced
reflections), a glass sphere (refraction), a roughness ramp on plinths, and the
**duck in a glass vitrine** (alpha-blended transparency that casts no shadow onto
the exhibit). An always-on **fps readout** sits top-left and a **collapsible
control panel** (starts collapsed on phones) toggles every feature, drives a
clerestory-window light slider, and spawns the 40-body physics pile. See
[`examples/main.js`](examples/main.js) for the full, commented integration
(scene → physics → compile → render loop). `npm run deploy` builds and publishes
the whole tour to GitHub Pages.

## Gallery & benchmarks

[`gallery.html`](gallery.html) opens on a built-in **Cornell box** (the classic
GI reference — coloured walls, an emissive ceiling panel) and drops the raytracer
into **stock glTF scenes it was never authored for** — Littlest Tokyo, Lantern,
Damaged Helmet, Antique Camera, BoomBox, Corset, Water Bottle, Toy Car,
Iridescence Lamp, Mosquito in Amber, and Fox — streamed straight from their
public hosts (no assets committed). A one-button toggle A/Bs ray tracing against
plain rasterized three.js with an fps + triangle readout, and a compact options
strip exposes GI / emissive NEE / reflections / refraction / ReSTIR / denoise /
TAA / volumetric plus lighting-resolution and auto-quality controls.

[`bench.html?autorun=1`](bench.html) runs a matrix of feature configs with
GPU-**fence-timed** frame costs and a temporal **ghosting metric**, writing each
run's results to [`bench-results/`](bench-results/) for tracking regressions.
Scene keys carry a **date** (`museum-2026-07`), and each run records what each
key meant — the rooms behind those names get redesigned, and a comparison across
a redesign is not a regression. The files already in that directory predate the
museum and are **not** a time series; see
[`bench-results/README.md`](bench-results/README.md) before quoting any of them.

### Game-scene benchmark (`game-bench.html`) *(since 0.12.0)*

[`game-bench.html?scene=<name>`](game-bench.html) is the permanent regression
asset for the quality-presets round: presets are for GAMES, so they are tuned
and judged on game scenes, not gallery orbits. Three DETERMINISTIC scenes (no
`Math.random`, no physics engine, fixed waypoints and event timings  -  frame N
is identical across presets at the same wall-clock time), each a scripted
~20s loop:

- `chase`  -  third-person camera following a fast prop down a corridor with
  large occluders. The camera translates AND turns, so pixels are constantly
  disoccluded  -  the ghosting the `motion` preset exists for.
- `stealth`  -  Umbral-flavored dark room: two sweeping SpotLight cones, a
  player-proxy box sneaking between crates, one flickering emissive. Dark-scene
  noise is where viewers judge RT hardest.
- `arena`  -  combat chaos: 16 low-poly dynamic props, a mid-clip scatter
  impulse (the explode pattern), two emissive projectiles flying, a light
  toggling mid-clip. Stresses dynamic BVH re-bake, NEE churn and firefly
  control.

Modes: `?mode=bench` fence-times ms/frame at a fixed pose, runs a ghost probe
(reference at pose B, approach A→B, measure residual vs the reference after
1/5/10/20/40 frames) and a still-noise read, and POSTs a JSON row to
`/__bench` (saved under `bench-results/` with a `gameBench: true` flag).
`?mode=clip` runs the loop continuously for video capture, with the adaptive
governor ON and an fps badge so governor steps are visible in the recording.
`?preset=` selects a named preset (omitted = today's defaults);
`&tune=key:value,...` overrides individual knobs after the preset for A/B
tuning. The scene geometry lives in
[`examples/game-scenes.js`](examples/game-scenes.js).

### Movement-artifact harness

[`harness.html`](harness.html) makes the **edge-of-screen convergence noise seen
while the camera moves** measurable and eyeball-able. It drives the demo scene
along a deterministic path (`strafe` — sinusoidal side-to-side — or `orbit`;
pose is a pure function of sim-time) and, every couple of frames, reads three
vertical bands off the drawing buffer — the outer 10% at each edge and the
central 10% — tracking **per-pixel temporal luminance variance** over a sliding
window. The HUD reports the three mean-variance numbers; the headline is the
**edge-vs-center ratio** (>1 = edges noisier than the middle — the artifact the
overscan feature targets). A magnified side-by-side inset shows a left-edge strip
next to a center strip for human comparison, and the metric triple is logged as a
JSON line to the console every 2s for automated scraping. The overscan control
is **feature-detected** — it appears only when the loaded build exposes an
`overscan` property.

## Diagnostics (`status.warnings`)

*Since 0.7.0.* Most integration mistakes in a hybrid tracer are **silent**: the
image still renders, nothing throws, and the lighting is quietly computed against
a scene that is not the one on screen. So the library detects the common ones at
compile time (and on a cheap periodic scan while rendering), prints **one**
`console.warn` naming the object and the exact fix, and records the same thing on
`rt.status.warnings`:

```js
rt.render(scene, camera);
for (const w of rt.status.warnings) {
  console.log(w.code, w.message); // e.g. "stale-transform", "three-realtime-rt: ..."
}
```

`rt.status.warnings` is `{ code, message }[]`, deduplicated, and **never affects
`status.ok`** — these are scene-setup diagnostics, not pipeline failures (for
those see [`compileError` / `status.coreFailure`](#render-self-test)).

| Code | Fires when | Consequence |
|------|------------|-------------|
| `stale-geometry` | A **static** mesh's `position` buffer changed after `compileScene()`. | Traced lighting still uses the ORIGINAL shape. |
| `stale-transform` | A **static** mesh was moved after `compileScene()`. | Traced lighting still uses the ORIGINAL transform — its shadow stays behind. |
| `rtdeforming-not-dynamic` | `userData.rtDeforming` is set on a mesh that is **not** in `dynamicMeshes`. | The flag is ignored; the mesh compiles static. |
| `implicit-compile` | `render()` had to compile the scene itself. | Compiled with **no options** — everything is static. |
| `untraceable-object` | A visible `Sprite` / `Line` / `Points`. | Auto-hidden from the traced frame; render it in your own overlay pass. |
| `instanced-mesh` | An `InstancedMesh`. | Collapses to a single instance. |
| `transparent-dynamic` | A `transparent` mesh listed in `dynamicMeshes`. | That entry does nothing. |

The stale scan runs **every 30th frame**, compares one integer and one matrix per
static mesh, stops checking a mesh once it has reported, and caps at 8 reports —
so it costs effectively nothing in a correct scene. Set `userData.rtExclude` on
an object to say "this is intentional" and silence its warning.

### Troubleshooting

**"Black patches", "the shadow doesn't move", "rays still hit the original
shape".** All three are the same bug: a **stale BVH**. The tracer bakes geometry
into a BVH at `compileScene()` time. Move a mesh (or edit its vertices) after
that without declaring it, and the rasterized image shows the new pose while the
traced lighting still shadows, bounces and reflects off the **old** one — which
reads as a shadow left behind, a reflection of something that isn't there, or a
dark patch where the invisible old geometry still occludes.

The fixes, in order of how much you are asking for:

| Your object… | Do this |
|--------------|---------|
| never moves after setup | nothing — but call `compileScene(scene)` again after you add/remove/replace geometry. |
| moves rigidly every frame | `compileScene(scene, { dynamicMeshes: [mesh] })`, then `rt.updateDynamic()` each frame. |
| 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)*. |
| is an animated character | list its `SkinnedMesh` in `dynamicMeshes` — skinning is auto-detected. See *[Skinned meshes](#skinned-meshes-animated-characters)*. |
| changed structurally (mesh added/removed, material swapped, emission changed) | `compileScene()` again. |

You should not have to guess which one you hit: the `stale-geometry` /
`stale-transform` / `rtdeforming-not-dynamic` warnings above name the mesh and
the fix in the console. If a mesh is *deliberately* left out of the BVH, set
`userData.rtExclude = true`.

**"Nothing I do to `dynamicMeshes` has any effect."** Check for
`implicit-compile` in the console: if the first `rt.render()` ran before any
`compileScene()` call, the scene was compiled implicitly **with no options** and
every mesh is static. Call `compileScene(scene, options)` yourself first.

**"My HUD sprite / debug lines disappeared."** They are not traceable geometry
and cannot write the G-buffer, so they are hidden for the traced frame
(`untraceable-object`). Draw them in your own pass on top of `rt.render()`.

## Render self-test

The renderer can pass every compile and framebuffer check and still draw a black
screen — that is exactly what shipped in 0.4.0 on iOS (WebKit's GLSL-to-Metal
translation silently broke at a 4th `traceRadiance` call site; clean compile, no
console error, black output), and again on three r166+ when three's injected
`luminance` helper collided with the library's own and the affected pass programs
failed to link. The only defence against that class of failure is to **look at
the pixels**, so the demo has a headless-friendly self-test.

**Programmatic signal (`rt.compileError` / `rt.status`).** A pass whose program
fails to *link* renders black without throwing — three logs to the console and
sets `program.diagnostics.runnable = false`, but rendering proceeds. So over the
first several rendered frames the renderer inspects `renderer.info.programs` for
its own (stably named `rt:*`) pass programs and reports what it finds:

```js
rt.render(scene, camera); // ...for a few frames
if (!rt.status.ok) {
  // rt.compileError → "rt:lighting: 'luminance' : function already has a body"
  if (rt.status.coreFailure) showRaster(`raster (${rt.compileError})`);
  else console.warn("degraded:", rt.status.disabled); // e.g. [{pass, feature, reason}]
}
```

- **`rt.compileError`** (`string | null`) — first / most-severe failure summary
  (`"rt:<pass>: <driver log>"`), or `null` while every pass compiles clean.
- **`rt.status`** (`{ ok, disabled, coreFailure, warnings }`) — `ok` is `true` on the healthy
  path and `false` once any `rt:*` pass fails to link. **Optional** features whose
  pass failed are **auto-disabled** so the image stays lit (`restir`, `restirGI`,
  `denoise`, `volumetric`, `taa`, `specular`), each listed in `disabled` as
  `{ pass, feature, reason }` with a one-line driver log. A **core** pass
  (`gbuffer` / `lighting` / `composite`) has no fallback: `coreFailure` names it
  and the image is black-but-diagnosed. This lets an integrator render an honest
  `raster (reason)` fallback instead of guessing. (When `supported` is `false` the
  RT pipeline never runs, so `status.ok` is `false` too — check `supported` first.)
- **`rt.status.warnings`** (`{ code, message }[]`, **since 0.7.0**) — USAGE
  diagnostics: a flag being ignored, an object type that cannot be traced, a
  static mesh edited after `compileScene()`. Separate axis from the three fields
  above: the pipeline is healthy, the scene setup is not what you probably meant,
  so **warnings never change `status.ok`**. Full list in
  *[Diagnostics](#diagnostics-statuswarnings)*. (`compileError` and the base
  `status` surface are since 0.6.1.)

**In the browser:** load [`/?selftest=1`](examples/selftest.js). It forces the
full lighting stack on (GI + emissive NEE + reflections + refraction, lighting at
50%), renders the normal gallery scene, and after **90 rendered frames** reads
the drawing buffer back and emits one JSON line to the console (`[selftest] …`)
and into a hidden `#selftest-verdict` DOM node:

```json
{ "pass": true, "meanLum": 139.80, "irrLum": 170.53, "glErrors": 0,
  "specMRT": true, "supported": true, "statusOk": true, "rtPrograms": 15,
  "compileError": null, "disabled": [], "warnings": 0, "warningCodes": [],
  "frames": 91, "ua": "…" }
```

The pass gate wants `meanLum` in `[12, 230]` (calibrated: a healthy composite of
the gallery centre reads ~140 on desktop; a black screen reads ~0), `irrLum > 6`,
`glErrors == 0`, `supported == true`, `statusOk == true`, and `warnings == 0`.
`statusOk` asserts the compile-failure surface above stayed clean (`rt.status.ok`,
`compileError` null) *and* that the named pass programs were actually discoverable
(`rtPrograms > 0`), so a broken diagnosis can't read as a false pass. `warnings`
is `rt.status.warnings.length`: the demo is the reference integration, so the
healthy path must raise **none** — a nonzero count means either the demo really
did something wrong, or a [diagnostic](#diagnostics-statuswarnings) has started
firing on a correct scene.

- `meanLum` / `irrLum` — mean Rec.709 luma (0–255) of the **centre 25%** of the
  composite, and of the raw irradiance buffer (`outputMode 3`) for one frame. The
  irradiance readback proves the **lighting** is alive, not just emissive geometry
  surviving the composite (0.4.0's black image still showed emitters). A near-zero
  reading is the black-screen class; the pass gate wants a lit mid-range value.
- `glErrors` — count of nonzero `gl.getError()` samples (any nonzero fails).
- `specMRT` / `supported` — the two capability fallbacks, for triage.

The page keeps rendering after the verdict so a human can watch. This mode builds
the renderer with `preserveDrawingBuffer: true` so the canvas can be read back;
normal runs keep the cheaper default.

**In CI:** `npm run test:render` ([`scripts/selftest.mjs`](scripts/selftest.mjs))
starts vite on free ports and drives `?selftest=1` through Playwright across
**chromium, firefox and webkit**, printing a pass/fail/skip table and exiting
nonzero on any real failure (a documented environmental *skip* does not fail the
suite). Playwright is loaded from a sibling checkout — see the top of the script.

**Three-version matrix.** The r166+ `luminance` break shipped because nothing
tested a newer three, so **chromium runs twice**: once against the pinned three
(`0.160.1`) and once against **`three@latest`** (a second vite with
`RT_THREE=latest`, which [`vite.config.js`](vite.config.js) aliases `three` to the
`three-latest` devDependency). The `chromium@3latest` row is the guard for that
class of regression, and **the pass gate requires both chromium legs**. A fourth
chromium load of `?selftest=empty` asserts the [empty-scene no-op](#empty-scene)
(`compileScene` on a scene with no meshes is a no-op and `render()` falls back to
plain raster instead of crashing), and a fifth,
[`?selftest=warnings`](#diagnostics-statuswarnings), drives a deliberately
mis-configured scene and asserts every usage diagnostic fires **exactly once**,
lands on `status.warnings`, leaves `status.ok` true, and that a visible
`Sprite`/`Line` costs zero GL errors (it is hidden from the 4-attachment G-buffer
rather than drawn into it). Both are gating. firefox/webkit stay single-leg
against the default three (both are environmental skips here — see below).

Machine-specific note (Windows + NVIDIA, the current dev box): each chromium leg
runs **headed** with **`--use-angle=gl`**. ANGLE's default D3D11/FXC backend
never finishes compiling the BVH megakernel here — headless chromium,
headed+`--use-angle=d3d11` and system Chrome all freeze at ~3 frames with silent
`VALIDATE_STATUS=false` storms — whereas the native NVIDIA GL backend compiles
it in ~137ms. A visible chromium window on the desktop during the run is
expected. On this box **firefox** and **webkit** come back `skip`: firefox
renders through that same stalling ANGLE-D3D11 backend and exposes no native-GL
switch, and Playwright's Windows webkit has no usable WebGL2 (context lost). Both
would actually run on a real-GPU Linux runner with native GL; only chromium is
required to pass here.

**What this catches, and what it does not.** The matrix catches API / JavaScript
/ GLSL-frontend divergence between engines, and any regression that blackens or
errors the image on the engines it runs. It does **not** catch the original iOS
bug: Playwright's `webkit` on Windows is the WPE/GTK WebKit build, **not Apple's
Metal stack**, so it never exercises the GLSL-to-Metal code generator that
actually failed. **Real-device iOS testing stays manual.** The field kit for that
is on-device URL flags: `?diag=1` mirrors console errors onto the page (so a
photo of an iPad is a usable bug report) and `?nospecmrt=1` forces the
single-attachment WebKit fallback on any machine.

## Roadmap

| Stage | Status | What |
|-------|--------|------|
| 1. Core | ✅ | Scene→GPU sync, BVH, G-buffer, traced shadows + 1-bounce GI, accumulation |
| 2. Reprojection | ✅ | Motion-validated history — samples survive camera motion |
| 3. Denoiser | ✅ | Edge-avoiding à-trous (SVGF-lite) → clean 1spp |
| 4. TAA | ✅ | Sub-pixel jitter + neighbourhood-clamped resolve → AA, no speckles |
| 4b. Sky | ✅ | Procedural sky as background + GI ambient light source |
| 5. Two-level BVH | ✅ | Static BVH uploaded once; movers in a small per-frame BVH → dynamic shadows at ~1 ms |
| 5b. Area lights | ✅ | Emissive meshes sampled directly (NEE) — glowing panels cast soft light + shadows |
| 6. Specular | ✅ | Mirror/glossy reflections on metals + two-interface glass refraction |
| 6b. Sampling | ✅ | Blue-noise sampling + ReSTIR direct lighting (temporal + spatial reuse) |
| 6c. Any-hit shadows | ✅ | Unordered early-out BVH traversal for occlusion rays — same image, up to ~2× cheaper shadows |
| 6d. PBR materials | ✅ | Cook-Torrance GGX dielectric specular + normal/roughness/metalness maps, alpha-blended transparency, deforming (water) meshes, overscan |
| 6e. Skinned meshes | ✅ | Animated characters CPU-skinned into the dynamic BVH — moving traced shadows + animated raster pose |
| 6f. Material completeness | ✅ | Vertex colors, per-material IOR, multi-material groups (clearcoat/sheen/iridescence documented as out of G-buffer budget) |
| 6g. ReSTIR GI | 🧪 | **Experimental** (`restirGI`, off by default): reservoir reuse of the 1-bounce indirect sample — temporal at the reprojected surface point, plus Jacobian-reweighted spatial taps, with the resolve's [colour taken as a weighted mean rather than a draw](#restir-gi-why-the-colour-is-resolved-as-a-mean) |
| 6h. Tinted glass | ✅ | Per-material Beer-Lambert absorption on the view path, plus **coloured shadows** on the two next-event shadow rays (`absorptionShadows`) |
| 6i. Scattering | ✅ | **Kubelka-Munk two-flux** translucent solids (`kmScattering`) — jade, wax, marble, foliage, lampshades, with the thickness measured per view ray through the real geometry instead of an authored thickness map |
| 7. Next | — | A layered view-path march for scattering (needs register room the megakernel does not currently have — see *[Scattering](#scattering--translucent-solids-kmscattering)*); coloured shadows on the ReSTIR visibility ray + the volumetric march; DDGI irradiance probes; ReSTIR GI **spatial** reuse + sample validation; WGSL / WebGPU backend |

## Credits

- [three-mesh-bvh] by Garrett Johnson — the GPU BVH this is built on.
- Inspired by [Erich Loftis'][erichlof] `THREE.js-PathTracing-Renderer`.
- Demo models: Khronos glTF sample assets (Damaged Helmet, Duck).

## License

MIT © Goldwin Stewart

[three-mesh-bvh]: https://github.com/gkjohnson/three-mesh-bvh
[erichlof]: https://github.com/erichlof/THREE.js-PathTracing-Renderer
