three-realtime-rt 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +559 -394
- package/package.json +52 -52
- package/src/CompositePass.js +261 -214
- package/src/CopyPass.js +75 -0
- package/src/DenoisePass.js +221 -202
- package/src/GBufferPass.js +290 -228
- package/src/RTLightingPass.js +1124 -680
- package/src/RealtimeRaytracer.js +1106 -730
- package/src/RestirPass.js +31 -4
- package/src/SceneCompiler.js +542 -424
- package/src/TAAPass.js +271 -230
- package/src/VolumetricPass.js +398 -329
- package/src/blueNoise.js +17 -17
- package/src/index.d.ts +107 -6
package/README.md
CHANGED
|
@@ -1,394 +1,559 @@
|
|
|
1
|
-
# three-realtime-rt
|
|
2
|
-
|
|
3
|
-
**Turn-on ray traced lighting for three.js.** Build your scene with ordinary
|
|
4
|
-
three.js — meshes, `MeshStandardMaterial`, `PointLight` / `DirectionalLight` —
|
|
5
|
-
then swap one render call and get BVH-traced **soft shadows**, **one-bounce
|
|
6
|
-
global illumination**, **emissive-mesh area lights**, **mirror/glossy
|
|
7
|
-
reflections**, **glass refraction**, **volumetric god rays** (BVH-shadowed
|
|
8
|
-
single scatter, not a screen-space trick), a **procedural sky** that lights
|
|
9
|
-
the scene, **ReSTIR many-light sampling** (flat cost in light count),
|
|
10
|
-
**blue-noise sampling**, and real-time **temporal denoising + anti-aliasing**.
|
|
11
|
-
Runs on plain WebGL2.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
##
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
|
289
|
-
|
|
290
|
-
| `
|
|
291
|
-
| `
|
|
292
|
-
| `
|
|
293
|
-
| `
|
|
294
|
-
| `
|
|
295
|
-
| `
|
|
296
|
-
| `
|
|
297
|
-
| `
|
|
298
|
-
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
`
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
|
370
|
-
|
|
371
|
-
|
|
|
372
|
-
|
|
|
373
|
-
|
|
|
374
|
-
|
|
|
375
|
-
|
|
|
376
|
-
|
|
|
377
|
-
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1
|
+
# three-realtime-rt
|
|
2
|
+
|
|
3
|
+
**Turn-on ray traced lighting for three.js.** Build your scene with ordinary
|
|
4
|
+
three.js — meshes, `MeshStandardMaterial`, `PointLight` / `DirectionalLight` —
|
|
5
|
+
then swap one render call and get BVH-traced **soft shadows**, **one-bounce
|
|
6
|
+
global illumination**, **emissive-mesh area lights**, **mirror/glossy
|
|
7
|
+
reflections**, **glass refraction**, **volumetric god rays** (BVH-shadowed
|
|
8
|
+
single scatter, not a screen-space trick), a **procedural sky** that lights
|
|
9
|
+
the scene, **ReSTIR many-light sampling** (flat cost in light count),
|
|
10
|
+
**blue-noise sampling**, and real-time **temporal denoising + anti-aliasing**.
|
|
11
|
+
Runs on plain WebGL2.
|
|
12
|
+
|
|
13
|
+
This round adds **GGX PBR specular** — Cook-Torrance dielectric highlights in a
|
|
14
|
+
separate specular buffer, so `roughness` finally matters on non-metals — plus
|
|
15
|
+
**normal / roughness / metalness maps**, **alpha-blended transparency**
|
|
16
|
+
(single-layer deferred blend), **deforming dynamic meshes** (a mirror-water pool
|
|
17
|
+
whose traced reflections follow the live wave surface), **overscan** to hide
|
|
18
|
+
leading-edge convergence noise while the camera turns, and **emissive importance
|
|
19
|
+
sampling** (area × luminance) for calmer area lights. The zero-config renderer
|
|
20
|
+
now ships **conservative, self-scaling defaults** — it starts low-but-ray-traced
|
|
21
|
+
and an on-by-default governor scales quality up toward `targetFps`; an optional
|
|
22
|
+
async **GPU tier probe** reads real WebGPU adapter limits for a smarter starting
|
|
23
|
+
point.
|
|
24
|
+
|
|
25
|
+
The library ships as **untranspiled ES modules** (the `src/` folder) — it has no
|
|
26
|
+
build step of its own, so you consume it through your bundler (Vite, webpack,
|
|
27
|
+
esbuild, …) or a browser import map that resolves the bare `three` /
|
|
28
|
+
`three-mesh-bvh` specifiers. MIT licensed.
|
|
29
|
+
[On npm](https://www.npmjs.com/package/three-realtime-rt): `npm i three-realtime-rt three three-mesh-bvh`.
|
|
30
|
+
|
|
31
|
+
### ▶ [Live demo](https://goldwinxs.github.io/three-realtime-rt/) — drag to orbit, drop the pile, toggle every feature.
|
|
32
|
+
|
|
33
|
+
> **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.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
Same scene, same camera, same lights — plain three.js (shadow maps + ACES) on
|
|
38
|
+
the left, `rt.render` on the right:
|
|
39
|
+
|
|
40
|
+
| Rasterized three.js | three-realtime-rt |
|
|
41
|
+
|---|---|
|
|
42
|
+
|  |  |
|
|
43
|
+
|
|
44
|
+
## Getting started
|
|
45
|
+
|
|
46
|
+
Install the library plus its two **peer dependencies** — you bring your own copy
|
|
47
|
+
of `three` and `three-mesh-bvh`:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm i three-realtime-rt three three-mesh-bvh
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
No bundler? [`standalone.html`](standalone.html) is a single copy-paste file
|
|
54
|
+
that runs the raytracer via CDN import maps — open it from any static server.
|
|
55
|
+
|
|
56
|
+
A complete, copy-pasteable minimal app — a lit sphere on a floor, one point light:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import * as THREE from "three";
|
|
60
|
+
import { RealtimeRaytracer } from "three-realtime-rt";
|
|
61
|
+
|
|
62
|
+
// 1. An ordinary three.js renderer. Size it BEFORE constructing the raytracer —
|
|
63
|
+
// it reads the drawing-buffer size at construction.
|
|
64
|
+
const renderer = new THREE.WebGLRenderer({ antialias: false });
|
|
65
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
66
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
67
|
+
document.body.appendChild(renderer.domElement);
|
|
68
|
+
|
|
69
|
+
// 2. An ordinary scene: meshes with MeshStandardMaterial + a real light.
|
|
70
|
+
const scene = new THREE.Scene();
|
|
71
|
+
const camera = new THREE.PerspectiveCamera(
|
|
72
|
+
60, window.innerWidth / window.innerHeight, 0.1, 100
|
|
73
|
+
);
|
|
74
|
+
camera.position.set(0, 2, 6);
|
|
75
|
+
|
|
76
|
+
scene.add(new THREE.Mesh(
|
|
77
|
+
new THREE.SphereGeometry(1, 48, 48),
|
|
78
|
+
new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.4, metalness: 0.0 })
|
|
79
|
+
));
|
|
80
|
+
const floor = new THREE.Mesh(
|
|
81
|
+
new THREE.PlaneGeometry(20, 20),
|
|
82
|
+
new THREE.MeshStandardMaterial({ color: 0x808080, roughness: 1.0 })
|
|
83
|
+
);
|
|
84
|
+
floor.rotation.x = -Math.PI / 2;
|
|
85
|
+
floor.position.y = -1;
|
|
86
|
+
scene.add(floor);
|
|
87
|
+
|
|
88
|
+
const light = new THREE.PointLight(0xffffff, 40); // Point / Spot / Directional (up to 32)
|
|
89
|
+
light.position.set(3, 5, 2);
|
|
90
|
+
scene.add(light);
|
|
91
|
+
|
|
92
|
+
// 3. Turn on ray tracing.
|
|
93
|
+
const rt = new RealtimeRaytracer(renderer);
|
|
94
|
+
rt.compileScene(scene); // builds the BVH + material/light tables
|
|
95
|
+
|
|
96
|
+
// 4. Resize: pass DRAWING-BUFFER (device) pixels, not CSS pixels.
|
|
97
|
+
addEventListener("resize", () => {
|
|
98
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
99
|
+
camera.updateProjectionMatrix();
|
|
100
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
101
|
+
const db = renderer.getDrawingBufferSize(new THREE.Vector2());
|
|
102
|
+
rt.setSize(db.x, db.y);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 5. Render loop — replace renderer.render(scene, camera) with rt.render.
|
|
106
|
+
function loop() {
|
|
107
|
+
requestAnimationFrame(loop);
|
|
108
|
+
rt.render(scene, camera);
|
|
109
|
+
}
|
|
110
|
+
loop();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
On hardware that can't trace, `rt.render` transparently falls back to
|
|
114
|
+
`renderer.render` — no capability branch needed (see [Running everywhere](#running-everywhere-capability-tiers)).
|
|
115
|
+
|
|
116
|
+
> **Defaults are conservative.** Zero-config construction
|
|
117
|
+
> (`new RealtimeRaytracer(renderer)`) starts *low but still ray traced* — half
|
|
118
|
+
> lighting resolution, stochastic direct light, a lean denoise — so it runs
|
|
119
|
+
> acceptably on weak discrete and integrated GPUs out of the box. The adaptive
|
|
120
|
+
> governor is **on by default** and scales quality **up** toward `targetFps`
|
|
121
|
+
> when it measures headroom (a strong desktop climbs to full-resolution
|
|
122
|
+
> lighting within a couple of seconds). Want to start higher, or pin a level?
|
|
123
|
+
> Pass `RealtimeRaytracer.recommendedOptions(RealtimeRaytracer.detectTier(renderer))`
|
|
124
|
+
> (or `probeGPUTier()` — see [Running everywhere](#running-everywhere-capability-tiers)),
|
|
125
|
+
> or explicit options.
|
|
126
|
+
|
|
127
|
+
### Integrating into an existing app
|
|
128
|
+
|
|
129
|
+
A checklist for dropping the tracer into a scene you already have:
|
|
130
|
+
|
|
131
|
+
1. **Swap the render call** — `renderer.render(scene, camera)` →
|
|
132
|
+
`rt.render(scene, camera)`. Construct the `RealtimeRaytracer` once, *after*
|
|
133
|
+
the renderer has its final size.
|
|
134
|
+
2. **Compile once; recompile after structural changes** — `rt.compileScene(scene)`
|
|
135
|
+
bakes geometry into a static BVH and snapshots materials + emissive area
|
|
136
|
+
lights. Call it again after you add/remove meshes, swap geometry, or change a
|
|
137
|
+
material's `emissive` / `color` / `roughness` / `metalness`.
|
|
138
|
+
3. **Declare movers** — pass moving meshes to
|
|
139
|
+
`rt.compileScene(scene, { dynamicMeshes: [...] })`, then call
|
|
140
|
+
`rt.updateDynamic()` each frame after you move them (e.g. after a physics
|
|
141
|
+
step). Skip it on frames where nothing moved.
|
|
142
|
+
4. **Update lights when they change** — after moving, toggling (`.visible`),
|
|
143
|
+
recolouring or dimming a light, call `rt.updateLights(scene)`. No recompile.
|
|
144
|
+
5. **Resize** — in your resize handler, after `renderer.setSize(...)`, call
|
|
145
|
+
`rt.setSize(width, height)` with **drawing-buffer (device) pixels**
|
|
146
|
+
(`renderer.getDrawingBufferSize(...)`).
|
|
147
|
+
6. **Reset after jumps** — call `rt.resetAccumulation()` after a camera teleport
|
|
148
|
+
or a scene cut, so stale temporal history doesn't ghost.
|
|
149
|
+
|
|
150
|
+
That's the whole integration. Everything below is optional.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Why "hybrid deferred" (the "RTX on" model)
|
|
155
|
+
|
|
156
|
+
Primary visibility is **rasterized** by three.js into a G-buffer — free, fast,
|
|
157
|
+
and pixel-perfect on materials and textures. Only the *lighting* is ray traced,
|
|
158
|
+
in a fragment shader, against a GPU BVH ([three-mesh-bvh]):
|
|
159
|
+
|
|
160
|
+
1. **G-buffer pass** — MRT: albedo+roughness, world normal+metalness, world
|
|
161
|
+
position, emissive.
|
|
162
|
+
2. **RT lighting pass** — per pixel: soft shadow rays to each light (area
|
|
163
|
+
sampled) + a 1-bounce cosine-weighted GI ray with next-event estimation. GI
|
|
164
|
+
rays that escape sample the **procedural sky**, so the sky is a soft area
|
|
165
|
+
light. **Emissive meshes are real area lights**: their triangles are sampled
|
|
166
|
+
directly (NEE with the area→solid-angle pdf), so a glowing panel casts soft
|
|
167
|
+
light and shadows instead of waiting for a lucky GI ray to hit it. Metallic
|
|
168
|
+
pixels trace a **mirror/glossy reflection ray**; transmissive pixels trace a
|
|
169
|
+
Fresnel-weighted **reflection + two-interface refraction**. Output is
|
|
170
|
+
*demodulated irradiance* (albedo divided out) so it denoises cleanly while
|
|
171
|
+
textures stay sharp.
|
|
172
|
+
3. **Temporal reprojection** — motion-validated history keeps samples alive as
|
|
173
|
+
the camera and objects move.
|
|
174
|
+
4. **À-trous denoise** — an edge-avoiding (SVGF-lite) wavelet filter guided by
|
|
175
|
+
the G-buffer, so 1 sample/pixel looks converged.
|
|
176
|
+
5. **Composite** — `albedo × irradiance + emissive`, distance fog, ACES tonemap.
|
|
177
|
+
6. **TAA** — sub-pixel jitter + a neighbourhood-clamped history resolve:
|
|
178
|
+
supersampled anti-aliasing that also clears disocclusion speckles. This is
|
|
179
|
+
the analytic (FSR2 / TAAU) approach, not a learned upscaler.
|
|
180
|
+
|
|
181
|
+
Lighting is traced at half resolution by default and reconstructed by a joint
|
|
182
|
+
bilateral upsample + the denoiser + TAA — the same "render few pixels, rebuild
|
|
183
|
+
temporally" idea DLSS uses, done with hand-written math.
|
|
184
|
+
|
|
185
|
+
## Moving objects (dynamic BVH)
|
|
186
|
+
|
|
187
|
+
Mark meshes as dynamic and their motion casts **correct ray traced shadows** —
|
|
188
|
+
the demo drops 40 rigid bodies (Rapier physics) that shadow each other and the
|
|
189
|
+
ground in real time:
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
rt.compileScene(scene, { dynamicMeshes: crates }); // meshes that will move
|
|
193
|
+
|
|
194
|
+
// each frame, after you move them (e.g. after a physics step):
|
|
195
|
+
rt.updateDynamic(); // re-bakes them into the BVH (refit) — cheap
|
|
196
|
+
rt.render(scene, camera);
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Under the hood this is a **two-level BVH**: static geometry lives in one BVH
|
|
200
|
+
uploaded to the GPU once at compile time, dynamic meshes in a second small BVH
|
|
201
|
+
that is re-baked and refit per frame. `updateDynamic()` therefore costs
|
|
202
|
+
~1 ms for dozens of moving objects *regardless of how big the static world is* —
|
|
203
|
+
skip it entirely on frames where nothing moved.
|
|
204
|
+
|
|
205
|
+
### Deforming meshes (water, cloth)
|
|
206
|
+
|
|
207
|
+
By default a dynamic mesh is **rigid**: its vertices are snapshotted at compile
|
|
208
|
+
time and only re-transformed by `mesh.matrixWorld` each frame — so CPU edits to
|
|
209
|
+
its `position` attribute show in the rasterized image but the *traced* rays
|
|
210
|
+
(shadows, GI, reflections) still hit the original shape. For a mesh whose
|
|
211
|
+
vertices actually move on the CPU (a water surface, cloth, a morph target), set
|
|
212
|
+
`userData.rtDeforming` so the raytracer re-reads its live geometry every frame:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
water.userData.rtDeforming = true; // opt in
|
|
216
|
+
rt.compileScene(scene, { dynamicMeshes: [water, ...crates] });
|
|
217
|
+
|
|
218
|
+
// each frame:
|
|
219
|
+
deformWaterVertices(water.geometry, t); // your CPU wave/cloth solver
|
|
220
|
+
water.geometry.attributes.position.needsUpdate = true;
|
|
221
|
+
water.geometry.computeVertexNormals(); // REQUIRED — see below
|
|
222
|
+
rt.updateDynamic(); // re-reads the live vertices
|
|
223
|
+
rt.render(scene, camera);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Two requirements:
|
|
227
|
+
|
|
228
|
+
- **You own the normals.** The tracer reads the mesh's live `normal` attribute
|
|
229
|
+
for deforming segments — it does not recompute them. Call
|
|
230
|
+
`geometry.computeVertexNormals()` (or update the attribute yourself) after
|
|
231
|
+
moving the vertices, or the shading/reflections will track the old silhouette.
|
|
232
|
+
- **The vertex count is fixed at compile time.** Deforming an existing surface is
|
|
233
|
+
free; changing its topology (adding/removing vertices) is not — `updateDynamic()`
|
|
234
|
+
throws with a clear message telling you to `compileScene()` again.
|
|
235
|
+
|
|
236
|
+
**Cost model.** A deforming segment is re-baked from its live vertices every
|
|
237
|
+
frame and its normals are re-uploaded every frame (rigid movers amortize the
|
|
238
|
+
normal upload over 8 frames). Both are O(dynamic triangles), and the cheap
|
|
239
|
+
per-frame BVH `refit()` (kept for surfaces that stay roughly in place, like a
|
|
240
|
+
water plane) is O(dynamic tris) too — so **keep deforming meshes low-poly**. A
|
|
241
|
+
`48×48` plane is ≈ 4.6k triangles, which refits in well under a millisecond; a
|
|
242
|
+
`256×256` plane (~131k tris) will dominate the frame. The demo's mirror-water
|
|
243
|
+
pool is a `48×48` plane.
|
|
244
|
+
|
|
245
|
+
## Live lighting & sky
|
|
246
|
+
|
|
247
|
+
Lights can be toggled, moved, and recoloured every frame without recompiling:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
warmLight.visible = false; // or change .color / .intensity / .position
|
|
251
|
+
rt.updateLights(scene); // re-reads the scene's lights
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The procedural sky doubles as the ambient light source:
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
const rt = new RealtimeRaytracer(renderer, {
|
|
258
|
+
sky: {
|
|
259
|
+
enabled: true,
|
|
260
|
+
sunDir: new THREE.Vector3(0.55, 0.62, 0.55).normalize(), // toward the sun
|
|
261
|
+
sunColor: new THREE.Color(1.0, 0.92, 0.78),
|
|
262
|
+
zenith: new THREE.Color(0.20, 0.40, 0.72),
|
|
263
|
+
horizon: new THREE.Color(0.78, 0.85, 0.92),
|
|
264
|
+
intensity: 1.0,
|
|
265
|
+
},
|
|
266
|
+
fog: { enabled: true, color: new THREE.Color(0.72, 0.8, 0.88), density: 0.03 },
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+

|
|
271
|
+
|
|
272
|
+
## What is and isn't supported
|
|
273
|
+
|
|
274
|
+
Primary visibility is rasterized into a G-buffer, so **whatever three.js draws,
|
|
275
|
+
you still see** — the ray tracer computes only the *lighting*, reading a
|
|
276
|
+
deliberately small, fixed slice of the material and light model. The one place
|
|
277
|
+
the G-buffer diverges from a plain three.js draw is transparency: it is a
|
|
278
|
+
**single-layer deferred blend** (see the `transparent` row below and the
|
|
279
|
+
Rendering-model notes), not three.js's per-fragment sorted over-blend. This is
|
|
280
|
+
the honest map of what actually feeds the traced lighting.
|
|
281
|
+
|
|
282
|
+
### Materials
|
|
283
|
+
|
|
284
|
+
Lighting reads the **first material only** on a multi-material mesh, pulling the
|
|
285
|
+
scalar fields of `MeshStandardMaterial` / `MeshPhysicalMaterial` (Basic / Lambert
|
|
286
|
+
/ Phong contribute whatever of those fields they have).
|
|
287
|
+
|
|
288
|
+
| Property | Feeds lighting? | Notes |
|
|
289
|
+
|----------|-----------------|-------|
|
|
290
|
+
| `color` + `map` | ✅ | Albedo = `color × map.rgb`. Textures stay sharp (irradiance is demodulated, then re-multiplied). |
|
|
291
|
+
| `roughness` | ✅ | Drives shadow / GI softness, reflection sharpness **and the GGX specular lobe width** (see *dielectric specular* below). |
|
|
292
|
+
| `metalness` | ✅ | Metallic pixels trace a reflection ray whose analytic-light glints are shadowed; `F0 = mix(0.04, albedo, metalness)`. |
|
|
293
|
+
| `emissive` | ✅ | A *static* emissive mesh becomes a real **area light** (NEE) — casts soft light + shadows, including a GGX highlight. |
|
|
294
|
+
| `emissiveMap` | ⚠️ visible only | A map-masked emissive **glows on screen** but the map **zeroes its area-light table** — it lights nothing. Use a flat `emissive` colour (no map) for an emitter that should illuminate. |
|
|
295
|
+
| `transmission` (Physical) | ✅ | Glass: Fresnel reflection (with analytic-light glints) + two-interface refraction. |
|
|
296
|
+
| `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`. |
|
|
297
|
+
| `opacity` on an opaque material | ❌ | Only read when `transparent: true`; an opaque material always writes at full coverage. |
|
|
298
|
+
| **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). |
|
|
299
|
+
| `roughnessMap` | ✅ | `roughness × roughnessMap.g` (three.js convention) — sampled in the G-buffer. |
|
|
300
|
+
| `metalnessMap` | ✅ | `metalness × metalnessMap.b` (a packed ORM texture works — G = roughness, B = metalness). |
|
|
301
|
+
| `normalMap` | ✅ | Perturbs the shading normal via a screen-space cotangent frame (no tangent attribute needed); respects `material.normalScale`. |
|
|
302
|
+
| `clearcoat`, `sheen`, `iridescence` | ❌ | Not modelled. |
|
|
303
|
+
| vertex colors | ❌ | Not read into albedo. |
|
|
304
|
+
| per-material `ior` | ❌ | Refraction uses the single **global** `rt.ior` (default 1.5), never `material.ior`. |
|
|
305
|
+
| 2nd+ material of a group | ❌ | Only `material[0]` of a multi-material mesh reaches the G-buffer and BVH. |
|
|
306
|
+
|
|
307
|
+
### Lights
|
|
308
|
+
|
|
309
|
+
| Light | Supported | Notes |
|
|
310
|
+
|-------|-----------|-------|
|
|
311
|
+
| `PointLight` | ✅ | `light.userData.rtRadius` (default `0.15`) sets soft-shadow size. |
|
|
312
|
+
| `DirectionalLight` | ✅ | `light.userData.rtRadius` (default `0.02`) sets sun softness; keep its direction in sync with `sky.sunDir`. |
|
|
313
|
+
| Emissive meshes | ✅ static | Sampled directly as area lights. **Dynamic** emitters are *not* in the NEE list — they light only via GI-ray hits. |
|
|
314
|
+
| `SpotLight` | ✅ | Cone + penumbra respected; soft shadows via `rtRadius`; visible light cones in volumetric fog. |
|
|
315
|
+
| `RectAreaLight` | ❌ | Use an emissive mesh instead. |
|
|
316
|
+
| `HemisphereLight` / `AmbientLight` | ❌ | Ignored — the procedural `sky` (or `envColor`) provides ambient. |
|
|
317
|
+
|
|
318
|
+
- **Emissive noise caveat:** emissive NEE is the noisiest direct-light path — one
|
|
319
|
+
uniformly-picked triangle sample per pixel per frame, with a `1/dist²` term that
|
|
320
|
+
sparkles into fireflies near small, close emitters. **Keep `restir: true` in any
|
|
321
|
+
scene that leans on emissive lighting** (the reservoirs converge each pixel onto
|
|
322
|
+
the emitter that matters; the library logs a hint if you compile emissive
|
|
323
|
+
geometry with ReSTIR off). Prefer larger/dimmer emitter surfaces over tiny
|
|
324
|
+
bright ones, and let `fireflyClamp` do its job.
|
|
325
|
+
- Up to **32** point/directional lights (`MAX_LIGHTS`); further lights are dropped.
|
|
326
|
+
- Moving, toggling, recolouring or dimming a light → `rt.updateLights(scene)` (cheap, no recompile).
|
|
327
|
+
- Changing a mesh's **emissive** (it's an area light baked at compile time) → `rt.compileScene(...)` again.
|
|
328
|
+
- Emissive area lights are capped at **256 triangles** (largest by area kept, with a console warning) — prefer low-poly emitter meshes.
|
|
329
|
+
|
|
330
|
+
### Geometry & occlusion
|
|
331
|
+
|
|
332
|
+
- Every non-excluded visible mesh is **merged into one static BVH at compile time**. Add / remove geometry → recompile.
|
|
333
|
+
- 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.
|
|
334
|
+
- **Transparent materials never occlude** (by design — a glass case shouldn't cast an opaque shadow). They still rasterize normally.
|
|
335
|
+
- **`alphaTest` cut-outs** (`transparent: false`) *do* occlude — but as **full triangles**, not per-texel, so their shadows are blocky.
|
|
336
|
+
- `mesh.userData.rtExclude = true` removes a mesh from the BVH entirely (it still rasterizes and gets lit) — handy for water / translucent surfaces.
|
|
337
|
+
|
|
338
|
+
### Rendering model
|
|
339
|
+
|
|
340
|
+
- **1-bounce GI + direct light.** No multi-bounce diffuse, no caustics, no specular-chain paths.
|
|
341
|
+
- **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.
|
|
342
|
+
- **Refraction** is two-interface (front + back) with a single global IOR — convincing glass, not a spectral / dispersive renderer.
|
|
343
|
+
- **Volumetric** is **single-scatter** god rays, not multiple-scattering fog.
|
|
344
|
+
- **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).
|
|
345
|
+
|
|
346
|
+
### Platform
|
|
347
|
+
|
|
348
|
+
- Requires **WebGL2 + `EXT_color_buffer_float`**. Software rasterizers (SwiftShader / llvmpipe) are treated as unsupported.
|
|
349
|
+
- 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.
|
|
350
|
+
- WebGL2 only; no WebGPU backend (on the roadmap).
|
|
351
|
+
|
|
352
|
+
## Options
|
|
353
|
+
|
|
354
|
+
| Option | Default | What |
|
|
355
|
+
|--------|---------|------|
|
|
356
|
+
| `renderScale` | `0.5` | Lighting resolution vs. the G-buffer. `1.0` = max quality. |
|
|
357
|
+
| `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*. |
|
|
358
|
+
| `adaptiveQuality` | `true` | Governor that steers `renderScale` / `denoiseIterations` / `stochasticLights` toward `targetFps` — scales **up** on strong hardware, **down** on weak. Turn off for manual control. |
|
|
359
|
+
| `denoiseIterations` | `2` | À-trous denoise passes; the governor raises this as it lowers resolution. |
|
|
360
|
+
| `taa` | `true` | Temporal anti-aliasing (jitter + neighbourhood clamp). |
|
|
361
|
+
| `denoise` | `true` | Edge-aware à-trous denoiser. |
|
|
362
|
+
| `gi` | `true` | 1-bounce global illumination (vs. direct-only). |
|
|
363
|
+
| `emissiveNEE` | `true` | Sample static emissive meshes as area lights (next-event estimation). Off = emitters only light via lucky GI rays. |
|
|
364
|
+
| `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. |
|
|
365
|
+
| `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`. |
|
|
366
|
+
| `reflections` | `true` | Traced mirror/glossy reflections on metallic surfaces (sharpest at `renderScale: 1`). |
|
|
367
|
+
| `refraction` | `true` | Traced two-interface refraction for `MeshPhysicalMaterial.transmission` surfaces. |
|
|
368
|
+
| `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. |
|
|
369
|
+
| `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. |
|
|
370
|
+
| `ior` | `1.5` | Index of refraction used by `refraction`. |
|
|
371
|
+
| `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`. |
|
|
372
|
+
| `stochasticLights` | `true` | One direct shadow ray per pixel per frame (random source) instead of one per light. The governor turns it off once it has scaled resolution up on strong hardware. |
|
|
373
|
+
| `temporalReprojection` | `true` | Keep samples across camera/object motion. |
|
|
374
|
+
| `maxHistory` | `128` | History cap — higher is smoother, slower to react. |
|
|
375
|
+
| `fireflyClamp` | `4.0` | Clamp on indirect luminance to suppress fireflies. |
|
|
376
|
+
| `sky` | *off* | Procedural sky as background + GI ambient (see above). |
|
|
377
|
+
| `fog` | *off* | Distance fog, composited before tonemap. |
|
|
378
|
+
|
|
379
|
+
Per-light: set `light.userData.rtRadius` for soft-shadow size. Set
|
|
380
|
+
`mesh.userData.rtExclude = true` to keep a mesh out of the BVH (it still
|
|
381
|
+
rasterizes and gets lit — useful for water / translucent surfaces).
|
|
382
|
+
Transparent materials never act as occluders (a glass case shouldn't cast an
|
|
383
|
+
opaque shadow); `alphaTest` cut-outs still do.
|
|
384
|
+
|
|
385
|
+
## Edge convergence and overscan
|
|
386
|
+
|
|
387
|
+
Lighting is accumulated over time (temporal reprojection). When the camera
|
|
388
|
+
moves, pixels newly revealed at the **leading screen edge** have no history to
|
|
389
|
+
reproject from, so they start from a single noisy sample and take several frames
|
|
390
|
+
to converge — a shimmering band that rides the edge you are turning toward.
|
|
391
|
+
|
|
392
|
+
`overscan` hides it by rendering *bigger than the screen*. Every internal pass
|
|
393
|
+
(G-buffer, lighting, denoise, volumetric, composite, TAA history) runs at a
|
|
394
|
+
padded resolution with a proportionally **widened field of view**, and only the
|
|
395
|
+
final on-screen draw crops the central canvas-sized region out. Disoccluded
|
|
396
|
+
pixels are then born in the padding — off-screen — and have already converged by
|
|
397
|
+
the time the camera turns far enough to bring them into view.
|
|
398
|
+
|
|
399
|
+
```js
|
|
400
|
+
const rt = new RealtimeRaytracer(renderer, { overscan: 0.1 });
|
|
401
|
+
// or live: rt.overscan = 0.05; (reallocates targets; resets accumulation)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`overscan` is the padding fraction **per edge**. `0.1` on a 1000×600 canvas
|
|
405
|
+
renders 1200×720 internally and crops the central 1000×600 — both axes pad by
|
|
406
|
+
the same fraction, so aspect ratio is preserved and the widened frustum stays
|
|
407
|
+
centred on your camera's. The cost is purely the extra pixels: `1 + 2·overscan`
|
|
408
|
+
per axis, so **0.1 → 1.44×** the work of every pass. **0.05–0.1** is the useful
|
|
409
|
+
range; more just spends pixels on padding you will rarely turn fast enough to
|
|
410
|
+
need. Changing it live reallocates the targets and resets accumulation, so treat
|
|
411
|
+
it as a settings-time knob rather than a per-frame one. The camera you pass to
|
|
412
|
+
`render()` is never mutated — the widened projection is applied and restored
|
|
413
|
+
internally each frame, exactly like the TAA jitter.
|
|
414
|
+
|
|
415
|
+
## Running everywhere (capability tiers)
|
|
416
|
+
|
|
417
|
+
The zero-config defaults are **conservative and self-scaling**: construction
|
|
418
|
+
starts low-but-ray-traced and the adaptive governor (on by default) walks
|
|
419
|
+
quality up or down toward `targetFps`. The knobs below let you set a smarter
|
|
420
|
+
starting point or take manual control:
|
|
421
|
+
|
|
422
|
+
- **No usable GPU** (missing WebGL2 float targets, or a software rasterizer
|
|
423
|
+
like SwiftShader): the library logs one console warning and `rt.render()`
|
|
424
|
+
silently falls back to plain `renderer.render()`. Your app runs everywhere
|
|
425
|
+
with zero capability checks; query `rt.supported` or the static
|
|
426
|
+
`RealtimeRaytracer.isSupported(renderer)` if you want to branch yourself.
|
|
427
|
+
- **Tier presets**: `RealtimeRaytracer.detectTier(renderer)` returns
|
|
428
|
+
`"none" | "mid" | "high"`, and `recommendedOptions(tier)` gives matching
|
|
429
|
+
constructor options — spread them, then override what you like:
|
|
430
|
+
|
|
431
|
+
```js
|
|
432
|
+
const tier = RealtimeRaytracer.detectTier(renderer);
|
|
433
|
+
const rt = new RealtimeRaytracer(renderer, {
|
|
434
|
+
...RealtimeRaytracer.recommendedOptions(tier),
|
|
435
|
+
targetFps: 55,
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
- **GPU probe** (`await RealtimeRaytracer.probeGPUTier(renderer?)`): an optional,
|
|
440
|
+
async, more-informed alternative to `detectTier`. When the browser exposes
|
|
441
|
+
**WebGPU** it inspects the real adapter limits; otherwise it falls back to the
|
|
442
|
+
WebGL heuristic. Returns `{ tier, source: "webgpu"|"webgl"|"fallback", details }`.
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
const probe = await RealtimeRaytracer.probeGPUTier(renderer);
|
|
446
|
+
const rt = new RealtimeRaytracer(renderer, RealtimeRaytracer.recommendedOptions(probe.tier));
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Honest heuristic — WebGPU does NOT expose VRAM.** There is no API for actual
|
|
450
|
+
video memory, so the probe uses `adapter.limits` (`maxBufferSize`,
|
|
451
|
+
`maxTextureDimension2D`, …) as a *proxy* for GPU class — two cards with wildly
|
|
452
|
+
different VRAM can report the same limits. `adapter.info`
|
|
453
|
+
(vendor/architecture/description) is masked on many browsers, so it is a hint
|
|
454
|
+
only. Screen resolution is factored in: `screenPixels = screen.width *
|
|
455
|
+
screen.height * min(devicePixelRatio, 2)`; a 4K-class panel (`>= 6e6`) has to
|
|
456
|
+
fill ~4× the pixels of 1080p, so a GPU is only rated `"high"` on such a screen
|
|
457
|
+
when `maxBufferSize >= 4GiB` (otherwise it is demoted to `"mid"`). Software
|
|
458
|
+
renderers (SwiftShader / llvmpipe) rate `"none"`. Every threshold and the raw
|
|
459
|
+
values are echoed back in `details`. The constructor stays synchronous — this
|
|
460
|
+
is a pre-construction, opt-in call.
|
|
461
|
+
|
|
462
|
+
- **Adaptive quality** (`adaptiveQuality: true`, default **true**): continuous
|
|
463
|
+
dynamic resolution scaling. Watches real frame time and steers the lighting
|
|
464
|
+
resolution smoothly toward `targetFps` (in 5% steps with a cooldown), pairing
|
|
465
|
+
low resolutions with MORE denoise passes (they run at lighting res, so
|
|
466
|
+
they're nearly free exactly where they're needed) and stochastic direct
|
|
467
|
+
light. While enabled it drives `renderScale` / `stochasticLights` /
|
|
468
|
+
`denoiseIterations`; turn it off for manual control.
|
|
469
|
+
- **`stochasticLights: true`** (default false): one direct shadow ray per
|
|
470
|
+
pixel per frame instead of one per light — the biggest ray-count lever for
|
|
471
|
+
many-light scenes and mobile GPUs.
|
|
472
|
+
|
|
473
|
+
WebGPU: not used as a backend (this is a WebGL2 library); a WGSL compute
|
|
474
|
+
backend is on the roadmap.
|
|
475
|
+
|
|
476
|
+
## Running the demo
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
npm install
|
|
480
|
+
npm run dev # http://localhost:8115
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
The demo ([`examples/`](examples/)) is a **panoramic museum gallery** — a
|
|
484
|
+
Cornell-style room (saturated red/teal side walls for obvious colour bleed, open
|
|
485
|
+
top) staged as an exhibit where every renderer feature gets its own vignette: a
|
|
486
|
+
**deforming mirror-water pool** under the emissive gallery light, the
|
|
487
|
+
DamagedHelmet hero on a pedestal under a toggleable **spotlight** (normal /
|
|
488
|
+
roughness maps + analytic-light glints), a glossy teapot showing **GGX dielectric
|
|
489
|
+
specular** against the teal wall, a gold torus knot and mirror sphere (traced
|
|
490
|
+
reflections), a glass sphere (refraction), a roughness ramp on plinths, and the
|
|
491
|
+
**duck in a glass vitrine** (alpha-blended transparency that casts no shadow onto
|
|
492
|
+
the exhibit). An always-on **fps readout** sits top-left and a **collapsible
|
|
493
|
+
control panel** (starts collapsed on phones) toggles every feature, drives a
|
|
494
|
+
clerestory-window light slider, and spawns the 40-body physics pile. See
|
|
495
|
+
[`examples/main.js`](examples/main.js) for the full, commented integration
|
|
496
|
+
(scene → physics → compile → render loop). `npm run deploy` builds and publishes
|
|
497
|
+
it to GitHub Pages.
|
|
498
|
+
|
|
499
|
+
## Gallery & benchmarks
|
|
500
|
+
|
|
501
|
+
[`gallery.html`](gallery.html) opens on a built-in **Cornell box** (the classic
|
|
502
|
+
GI reference — coloured walls, an emissive ceiling panel) and drops the raytracer
|
|
503
|
+
into **stock glTF scenes it was never authored for** — Littlest Tokyo, Lantern,
|
|
504
|
+
Damaged Helmet, Antique Camera, BoomBox, Corset, Water Bottle, Toy Car,
|
|
505
|
+
Iridescence Lamp, Mosquito in Amber, and Fox — streamed straight from their
|
|
506
|
+
public hosts (no assets committed). A one-button toggle A/Bs ray tracing against
|
|
507
|
+
plain rasterized three.js with an fps + triangle readout, and a compact options
|
|
508
|
+
strip exposes GI / emissive NEE / reflections / refraction / ReSTIR / denoise /
|
|
509
|
+
TAA / volumetric plus lighting-resolution and auto-quality controls.
|
|
510
|
+
|
|
511
|
+
[`bench.html?autorun=1`](bench.html) runs a matrix of feature configs with
|
|
512
|
+
GPU-**fence-timed** frame costs and a temporal **ghosting metric**, writing each
|
|
513
|
+
run's results to [`bench-results/`](bench-results/) for tracking regressions.
|
|
514
|
+
|
|
515
|
+
### Movement-artifact harness
|
|
516
|
+
|
|
517
|
+
[`harness.html`](harness.html) makes the **edge-of-screen convergence noise seen
|
|
518
|
+
while the camera moves** measurable and eyeball-able. It drives the demo scene
|
|
519
|
+
along a deterministic path (`strafe` — sinusoidal side-to-side — or `orbit`;
|
|
520
|
+
pose is a pure function of sim-time) and, every couple of frames, reads three
|
|
521
|
+
vertical bands off the drawing buffer — the outer 10% at each edge and the
|
|
522
|
+
central 10% — tracking **per-pixel temporal luminance variance** over a sliding
|
|
523
|
+
window. The HUD reports the three mean-variance numbers; the headline is the
|
|
524
|
+
**edge-vs-center ratio** (>1 = edges noisier than the middle — the artifact the
|
|
525
|
+
overscan feature targets). A magnified side-by-side inset shows a left-edge strip
|
|
526
|
+
next to a center strip for human comparison, and the metric triple is logged as a
|
|
527
|
+
JSON line to the console every 2s for automated scraping. The overscan control
|
|
528
|
+
is **feature-detected** — it appears only when the loaded build exposes an
|
|
529
|
+
`overscan` property.
|
|
530
|
+
|
|
531
|
+
## Roadmap
|
|
532
|
+
|
|
533
|
+
| Stage | Status | What |
|
|
534
|
+
|-------|--------|------|
|
|
535
|
+
| 1. Core | ✅ | Scene→GPU sync, BVH, G-buffer, traced shadows + 1-bounce GI, accumulation |
|
|
536
|
+
| 2. Reprojection | ✅ | Motion-validated history — samples survive camera motion |
|
|
537
|
+
| 3. Denoiser | ✅ | Edge-avoiding à-trous (SVGF-lite) → clean 1spp |
|
|
538
|
+
| 4. TAA | ✅ | Sub-pixel jitter + neighbourhood-clamped resolve → AA, no speckles |
|
|
539
|
+
| 4b. Sky | ✅ | Procedural sky as background + GI ambient light source |
|
|
540
|
+
| 5. Two-level BVH | ✅ | Static BVH uploaded once; movers in a small per-frame BVH → dynamic shadows at ~1 ms |
|
|
541
|
+
| 5b. Area lights | ✅ | Emissive meshes sampled directly (NEE) — glowing panels cast soft light + shadows |
|
|
542
|
+
| 6. Specular | ✅ | Mirror/glossy reflections on metals + two-interface glass refraction |
|
|
543
|
+
| 6b. Sampling | ✅ | Blue-noise sampling + ReSTIR direct lighting (temporal + spatial reuse) |
|
|
544
|
+
| 6c. Any-hit shadows | ✅ | Unordered early-out BVH traversal for occlusion rays — same image, up to ~2× cheaper shadows |
|
|
545
|
+
| 6d. PBR materials | ✅ | Cook-Torrance GGX dielectric specular + normal/roughness/metalness maps, alpha-blended transparency, deforming (water) meshes, overscan |
|
|
546
|
+
| 7. Next | — | Skinned-mesh (animated) shadows; DDGI irradiance probes; ReSTIR GI (indirect reservoir reuse); WGSL / WebGPU backend; per-material IOR |
|
|
547
|
+
|
|
548
|
+
## Credits
|
|
549
|
+
|
|
550
|
+
- [three-mesh-bvh] by Garrett Johnson — the GPU BVH this is built on.
|
|
551
|
+
- Inspired by [Erich Loftis'][erichlof] `THREE.js-PathTracing-Renderer`.
|
|
552
|
+
- Demo models: Khronos glTF sample assets (Damaged Helmet, Duck).
|
|
553
|
+
|
|
554
|
+
## License
|
|
555
|
+
|
|
556
|
+
MIT © Goldwin Stewart
|
|
557
|
+
|
|
558
|
+
[three-mesh-bvh]: https://github.com/gkjohnson/three-mesh-bvh
|
|
559
|
+
[erichlof]: https://github.com/erichlof/THREE.js-PathTracing-Renderer
|