three-realtime-rt 0.3.0 → 0.3.2

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.
@@ -1,730 +1,834 @@
1
- import * as THREE from "three";
2
- import { compileScene, syncLights } from "./SceneCompiler.js";
3
- import { GBufferPass } from "./GBufferPass.js";
4
- import { RTLightingPass } from "./RTLightingPass.js";
5
- import { DenoisePass } from "./DenoisePass.js";
6
- import { CompositePass } from "./CompositePass.js";
7
- import { TAAPass } from "./TAAPass.js";
8
- import { VolumetricPass } from "./VolumetricPass.js";
9
- import { RestirPass } from "./RestirPass.js";
10
-
11
- // Van der Corput / Halton radical inverse — deterministic low-discrepancy
12
- // sub-pixel offsets for temporal jitter.
13
- function halton(index, base) {
14
- let f = 1;
15
- let r = 0;
16
- let i = index;
17
- while (i > 0) {
18
- f /= base;
19
- r += f * (i % base);
20
- i = Math.floor(i / base);
21
- }
22
- return r;
23
- }
24
-
25
- /**
26
- * Drop-in ray traced renderer for three.js scenes.
27
- *
28
- * const rt = new RealtimeRaytracer(renderer);
29
- * rt.compileScene(scene); // once (static scenes, stage 1)
30
- * rt.render(scene, camera); // per frame, instead of renderer.render
31
- *
32
- * Hybrid deferred: rasterized G-buffer for primary visibility, BVH-traced
33
- * shadow rays + 1-bounce GI for lighting, progressive temporal accumulation.
34
- */
35
- export class RealtimeRaytracer {
36
- /**
37
- * Can this renderer run the ray tracing pipeline at all? Requires WebGL2
38
- * with float render targets on a hardware GPU (software rasterizers like
39
- * SwiftShader technically work but are unusably slow treated as no).
40
- */
41
- static isSupported(renderer) {
42
- try {
43
- const gl = renderer.getContext();
44
- if (typeof WebGL2RenderingContext === "undefined" || !(gl instanceof WebGL2RenderingContext)) return false;
45
- if (!gl.getExtension("EXT_color_buffer_float")) return false;
46
- const dbg = gl.getExtension("WEBGL_debug_renderer_info");
47
- if (dbg) {
48
- const r = String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || "");
49
- if (/swiftshader|llvmpipe|software/i.test(r)) return false;
50
- }
51
- return true;
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
- /**
58
- * Rough capability tier for choosing defaults: "none" (can't trace — see
59
- * isSupported), "mid" (phones/tablets), "high" (desktop-class). WebGPU
60
- * presence is not used as a backend (this library is WebGL2), only as a
61
- * modern-browser signal; a WGSL compute backend is roadmap.
62
- */
63
- static detectTier(renderer) {
64
- if (renderer && !RealtimeRaytracer.isSupported(renderer)) return "none";
65
- const nav = typeof navigator !== "undefined" ? navigator : {};
66
- const mobile =
67
- (nav.maxTouchPoints ?? 0) > 1 || /Android|iPhone|iPad|Mobile/i.test(nav.userAgent || "");
68
- return mobile ? "mid" : "high";
69
- }
70
-
71
- /** Sensible constructor options for a tier (spread them, then override). */
72
- static recommendedOptions(tier) {
73
- if (tier === "none") return {};
74
- if (tier === "mid") {
75
- return {
76
- renderScale: 0.375,
77
- ...RealtimeRaytracer._qualityFor(0.375),
78
- adaptiveQuality: true,
79
- };
80
- }
81
- return { renderScale: 0.5, denoiseIterations: 3, adaptiveQuality: true };
82
- }
83
-
84
- /**
85
- * Probe whether this context accepts a framebuffer with mixed fp16/fp32
86
- * color attachments (legal WebGL2; some drivers reject it anyway). Runs raw
87
- * GL before three renders anything, and restores null bindings after.
88
- */
89
- static _mixedMrtSupported(gl) {
90
- try {
91
- const fb = gl.createFramebuffer();
92
- gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
93
- const mk = (ifmt) => {
94
- const t = gl.createTexture();
95
- gl.bindTexture(gl.TEXTURE_2D, t);
96
- gl.texStorage2D(gl.TEXTURE_2D, 1, ifmt, 4, 4);
97
- return t;
98
- };
99
- const t0 = mk(gl.RGBA16F);
100
- const t1 = mk(gl.RGBA32F);
101
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t0, 0);
102
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, t1, 0);
103
- gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
104
- const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
105
- gl.deleteFramebuffer(fb);
106
- gl.deleteTexture(t0);
107
- gl.deleteTexture(t1);
108
- gl.bindTexture(gl.TEXTURE_2D, null);
109
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
110
- return ok;
111
- } catch {
112
- return false;
113
- }
114
- }
115
-
116
- /**
117
- * Companion settings for a given lighting resolution. LOW resolution wants
118
- * MORE denoise passes, not fewer the filter runs at lighting res so extra
119
- * iterations are nearly free there, and they're what makes 25% lighting look
120
- * good. Stochastic lights kick in once the budget is clearly constrained.
121
- */
122
- static _qualityFor(scale) {
123
- return {
124
- denoiseIterations: scale <= 0.3 ? 5 : scale <= 0.45 ? 4 : 3,
125
- stochasticLights: scale <= 0.55,
126
- };
127
- }
128
-
129
- // Canvas-scale ladder for the governor's deepest lever. Canvas scale shrinks
130
- // the drawing buffer, so EVERY pass (raster G-buffer, lighting, denoise, TAA,
131
- // resolve) gets quadratically cheaper unlike renderScale, which only touches
132
- // the lighting buffer. It's app-owned (the demo/gallery own the canvas + CSS
133
- // stretch), so the governor drives it through canvasScaleHook rather than
134
- // touching the renderer directly.
135
- static CANVAS_LEVELS = [1, 0.85, 0.75, 0.62, 0.5];
136
-
137
- constructor(renderer, options = {}) {
138
- this.renderer = renderer;
139
-
140
- /**
141
- * False when the platform can't run the tracerrender() then simply
142
- * forwards to renderer.render (plain rasterized three.js), so apps work
143
- * everywhere without their own capability checks.
144
- */
145
- this.supported = RealtimeRaytracer.isSupported(renderer);
146
- if (!this.supported) {
147
- console.warn(
148
- "three-realtime-rt: ray tracing unavailable on this system " +
149
- "(needs WebGL2 + EXT_color_buffer_float on a hardware GPU). " +
150
- "Falling back to plain three.js rendering."
151
- );
152
- this.compiled = null;
153
- this.frame = 0;
154
- return;
155
- }
156
-
157
- const size = renderer.getSize(new THREE.Vector2());
158
- const pr = renderer.getPixelRatio();
159
- this._width = Math.floor(size.x * pr);
160
- this._height = Math.floor(size.y * pr);
161
- /**
162
- * Resolution scale for the ray traced lighting (G-buffer and final image
163
- * stay full res). 0.5 traces 4x fewer rays; the bilateral upsample +
164
- * denoiser reconstruct the difference. Set 1.0 for maximum quality.
165
- */
166
- this._renderScale = options.renderScale ?? 0.5;
167
-
168
- const mixedPrecision = RealtimeRaytracer._mixedMrtSupported(renderer.getContext());
169
- if (!mixedPrecision) {
170
- console.info("three-realtime-rt: mixed fp16/fp32 G-buffer not supported here using fp32 for all targets.");
171
- }
172
- this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
173
- this.rtPass = new RTLightingPass(this._scaledW, this._scaledH);
174
- this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
175
- this.composite = new CompositePass();
176
- this.taaPass = new TAAPass(this._width, this._height);
177
- this._sceneColor = this._makeColorTarget(this._width, this._height);
178
-
179
- this.compiled = null;
180
- this.frame = 0;
181
-
182
- /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive */
183
- this.outputMode = 0;
184
- /** Environment (sky) color used for GI rays that miss + composite background. */
185
- this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
186
- this.envIntensity = options.envIntensity ?? 1.0;
187
- /**
188
- * Ray offset epsilon. When not set explicitly it is auto-scaled from the
189
- * scene's size at compile time (dense scenes need a larger offset or
190
- * shadow rays self-intersect). Set it manually if you see acne (raise) or
191
- * light leaking through thin walls (lower).
192
- */
193
- this.eps = options.eps ?? 1e-3;
194
- this._autoEps = options.eps == null;
195
- /** Reproject accumulated lighting through camera motion (stage 2). */
196
- this.temporalReprojection = options.temporalReprojection ?? true;
197
- /** History length cap: higher = smoother but slower to react. */
198
- this.maxHistory = options.maxHistory ?? 128;
199
- /** Clamp on indirect luminance to suppress fireflies. 0 disables. */
200
- this.fireflyClamp = options.fireflyClamp ?? 4.0;
201
- /** 1-bounce global illumination (traced indirect). Toggle for a direct-only look. */
202
- this.gi = options.gi ?? true;
203
- /**
204
- * Sample static emissive meshes as area lights (next-event estimation).
205
- * Dramatically less noise than waiting for GI rays to hit the emitter, and
206
- * emitters gain direct lighting + shadows. Off = legacy hit-only behaviour.
207
- */
208
- this.emissiveNEE = options.emissiveNEE ?? true;
209
- /** Traced mirror/glossy reflections on metallic surfaces. */
210
- this.reflections = options.reflections ?? true;
211
- /** Traced refraction for transmissive (MeshPhysicalMaterial.transmission) surfaces. */
212
- this.refraction = options.refraction ?? true;
213
- /** Index of refraction used for transmissive surfaces. */
214
- this.ior = options.ior ?? 1.5;
215
- /**
216
- * One stochastic direct shadow ray per pixel per frame (source picked at
217
- * random) instead of one per light the biggest ray-count lever for
218
- * many-light scenes and mobile GPUs. Slightly noisier moving shadows;
219
- * temporal accumulation + the denoiser absorb it.
220
- */
221
- this.stochasticLights = options.stochasticLights ?? false;
222
- /**
223
- * Adaptive quality governor: watches the app's real frame time and walks
224
- * QUALITY_LADDER degrading when frames run long, cautiously probing a
225
- * better level when there is headroom (reverting if the probe fails). The
226
- * portable way to "work well" on unknown hardware. Drives renderScale,
227
- * denoiseIterations and stochasticLights; setting those manually while
228
- * enabled will be overridden — turn this off for manual control.
229
- */
230
- this.adaptiveQuality = options.adaptiveQuality ?? false;
231
- /** Frame-rate target for the adaptive governor. */
232
- this.targetFps = options.targetFps ?? 55;
233
- /**
234
- * Emergency overload brake independent of adaptiveQuality and ON by
235
- * default. Two protections: an oversized first buffer gets its lighting
236
- * scale clamped (with a loud warning), and consecutive catastrophic
237
- * frames (>400ms) force quality down before the GPU driver gives up.
238
- * Weak GPUs fed high settings can otherwise hang the whole machine
239
- * (observed: full system crash on an Intel-Mac in Chrome). Set
240
- * overloadProtection: false to opt out.
241
- */
242
- this.overloadProtection = options.overloadProtection ?? true;
243
- this._overloadStrikes = 0;
244
- this._obLastT = null;
245
- this._qEma = null;
246
- this._qLastT = null;
247
- this._qLastChange = 0;
248
- /**
249
- * App-owned canvas-scale setter, driven by the governor as its deepest
250
- * lever once renderScale bottoms out. The app owns the canvas + CSS stretch,
251
- * so it must apply the buffer resize itself; null disables this level.
252
- */
253
- this.canvasScaleHook = options.canvasScaleHook ?? null;
254
- this._canvasLevelIdx = 0;
255
- /** Edge-aware à-trous denoise on the irradiance buffer. */
256
- this.denoise = options.denoise ?? true;
257
- /** À-trous iterations (steps 1, 2, 4, ...). */
258
- this.denoiseIterations = options.denoiseIterations ?? 3;
259
-
260
- /**
261
- * Temporal anti-aliasing: sub-pixel projection jitter + a full-res history
262
- * resolve with neighbourhood clamp. Supersamples silhouettes over time and
263
- * clears the bright disocclusion speckles at edges. Analytic (FSR2 / TAAU
264
- * family), not a learned upscaler.
265
- */
266
- this.taa = options.taa ?? true;
267
- /** Fresh-sample weight in the TAA blend (lower = smoother/more AA, more lag). */
268
- this.taaBlend = options.taaBlend ?? 0.1;
269
-
270
- /**
271
- * Volumetric lighting — real "god rays": single-scatter fog integrated
272
- * along each primary ray with one jittered, BVH-shadowed light sample per
273
- * pixel per frame, temporally accumulated like the surface lighting.
274
- * Shafts are carved by actual occluders and work for off-screen sources.
275
- * Off by default; costs roughly one extra shadow ray per lighting pixel.
276
- */
277
- this.volumetric = {
278
- enabled: options.volumetric?.enabled ?? false,
279
- density: options.volumetric?.density ?? 0.015,
280
- maxDist: options.volumetric?.maxDist ?? 40,
281
- };
282
- this.volumetricPass = new VolumetricPass(this._scaledW, this._scaledH);
283
-
284
- /**
285
- * ReSTIR direct lighting: per-pixel reservoirs converge onto the light
286
- * that matters most to each pixel (temporal reuse, one visibility ray at
287
- * shading). Cost is flat in light count; also greatly reduces emissive
288
- * area-light noise. On by default — turn off to compare estimators.
289
- */
290
- this.restir = options.restir ?? true;
291
- this.restirPass = new RestirPass(this._scaledW, this._scaledH);
292
-
293
- /** Distance fog (composited in linear space before tonemap). */
294
- this.fog = {
295
- enabled: options.fog?.enabled ?? false,
296
- color: options.fog?.color ?? new THREE.Color(0.5, 0.6, 0.7),
297
- density: options.fog?.density ?? 0.05,
298
- };
299
-
300
- /**
301
- * Procedural sky. When enabled it is BOTH the background and the ambient
302
- * light for GI rays that escape the scene — the core of natural outdoor
303
- * lighting. `sunDir` points toward the sun (keep it in sync with your
304
- * DirectionalLight for matching direct shadows).
305
- */
306
- this.sky = {
307
- enabled: options.sky?.enabled ?? false,
308
- sunDir: options.sky?.sunDir ?? new THREE.Vector3(0.4, 0.8, 0.45).normalize(),
309
- sunColor: options.sky?.sunColor ?? new THREE.Color(1.0, 0.9, 0.75),
310
- zenith: options.sky?.zenith ?? new THREE.Color(0.18, 0.34, 0.62),
311
- horizon: options.sky?.horizon ?? new THREE.Color(0.7, 0.8, 0.9),
312
- intensity: options.sky?.intensity ?? 1.0,
313
- };
314
- this._invViewProj = new THREE.Matrix4();
315
- this._jitterIndex = 0;
316
- this._jitteredViewProj = new THREE.Matrix4();
317
- this._jitterUv = new THREE.Vector2(); // this frame's jitter in UV space
318
- this._prevJitterUv = new THREE.Vector2();
319
-
320
- this._prevViewProj = new THREE.Matrix4();
321
- this._camWorldPos = new THREE.Vector3();
322
- this._needsClear = true;
323
-
324
- // First-frame guard: a 4K/5K drawing buffer at high lighting scale can
325
- // hang a weak GPU on the very first frame — before any frame-time
326
- // measurement can react. Start those safe; adaptiveQuality (or the app)
327
- // can raise quality once frames are proven survivable.
328
- if (this.overloadProtection && this._width * this._height > 3.2e6 && this._renderScale > 0.375) {
329
- console.warn(
330
- `three-realtime-rt: ${(this._width * this._height / 1e6).toFixed(1)}M-pixel drawing buffer — ` +
331
- `clamping lighting renderScale to 0.375 (overloadProtection). Raise renderScale manually, ` +
332
- `enable adaptiveQuality, or pass overloadProtection: false to opt out.`
333
- );
334
- this._renderScale = 0.375;
335
- }
336
- }
337
-
338
- // Consecutive catastrophic frames mean the GPU is drowning — cut quality
339
- // hard and loudly before the driver resets (or takes the machine with it).
340
- // Hidden tabs are exempt (browser throttling looks like huge frame times).
341
- _overloadBrake() {
342
- if (typeof document !== "undefined" && document.visibilityState === "hidden") {
343
- this._obLastT = null;
344
- return;
345
- }
346
- const now = performance.now();
347
- const dt = this._obLastT == null ? null : now - this._obLastT;
348
- this._obLastT = now;
349
- if (dt == null) return;
350
- if (dt > 400 && dt < 10000) this._overloadStrikes++;
351
- else if (dt < 200) this._overloadStrikes = 0;
352
- if (this._overloadStrikes < 3) return;
353
- this._overloadStrikes = 0;
354
-
355
- if (this._renderScale > 0.2) {
356
- this.denoiseIterations = Math.min(this.denoiseIterations, 3);
357
- this.stochasticLights = true;
358
- this.renderScale = Math.max(0.2, Math.round(this._renderScale * 0.5 * 20) / 20);
359
- console.warn(
360
- `three-realtime-rt: frames exceeding 400ms overload brake cut lighting to ` +
361
- `${Math.round(this._renderScale * 100)}%. Lower your canvas resolution or enable adaptiveQuality.`
362
- );
363
- } else if (this.volumetric.enabled || this.reflections || this.refraction) {
364
- this.volumetric.enabled = false;
365
- this.reflections = false;
366
- this.refraction = false;
367
- console.warn(
368
- "three-realtime-rt: still overloaded at minimum lighting scale" +
369
- "disabling volumetric/reflections/refraction."
370
- );
371
- }
372
- }
373
-
374
- _makeColorTarget(width, height) {
375
- const t = new THREE.WebGLRenderTarget(width, height, {
376
- minFilter: THREE.LinearFilter,
377
- magFilter: THREE.LinearFilter,
378
- format: THREE.RGBAFormat,
379
- type: THREE.HalfFloatType,
380
- depthBuffer: false,
381
- stencilBuffer: false,
382
- });
383
- t.texture.generateMipmaps = false;
384
- return t;
385
- }
386
-
387
- /**
388
- * Build/rebuild BVH + material and light tables from the scene. Call after
389
- * structural scene changes. Pass `{ dynamicMeshes: [...] }` to mark meshes
390
- * whose transforms will change every frame (drive them with updateDynamic()).
391
- */
392
- compileScene(scene, options) {
393
- if (!this.supported) return null;
394
- if (this.compiled) this.compiled.dispose();
395
- this.compiled = compileScene(scene, options);
396
- if (this._autoEps) {
397
- // ~1/1000 of the scene diagonal, floored at the classic 1e-3.
398
- this.eps = Math.min(Math.max(1e-3, this.compiled.sceneDiagonal * 1.2e-3), 0.05);
399
- }
400
- this.rtPass.setCompiledScene(this.compiled);
401
- this.volumetricPass.setCompiledScene(this.compiled);
402
- this.restirPass.setCompiledScene(this.compiled);
403
- this.resetAccumulation();
404
- return this.compiled;
405
- }
406
-
407
- /**
408
- * Re-bake moving (dynamic) meshes into the dynamic BVH level. Call each frame
409
- * after moving them. Only the dynamic level is touched — the static BVH was
410
- * uploaded once at compile time — so cost scales with the moving triangle
411
- * count, not the scene size.
412
- */
413
- updateDynamic() {
414
- if (this.compiled) this.compiled.updateDynamic();
415
- }
416
-
417
- /**
418
- * Refresh light positions/colors from the scene without a full recompile —
419
- * lets the demo toggle, move, and recolor lights live. Lights with intensity
420
- * 0 (or invisible) are dropped so they can be switched off.
421
- */
422
- updateLights(scene) {
423
- if (!this.supported || !this.compiled) return;
424
- syncLights(scene, this.compiled);
425
- this.rtPass.setCompiledScene(this.compiled);
426
- this.volumetricPass.setCompiledScene(this.compiled);
427
- this.restirPass.setCompiledScene(this.compiled);
428
- }
429
-
430
- resetAccumulation() {
431
- if (!this.supported) return;
432
- this._needsClear = true;
433
- if (this.taaPass) this.taaPass.reset();
434
- }
435
-
436
- get _scaledW() {
437
- return Math.max(1, Math.floor(this._width * this._renderScale));
438
- }
439
- get _scaledH() {
440
- return Math.max(1, Math.floor(this._height * this._renderScale));
441
- }
442
-
443
- get renderScale() {
444
- return this._renderScale;
445
- }
446
- set renderScale(v) {
447
- this._renderScale = v;
448
- this.setSize(this._width, this._height);
449
- }
450
-
451
- setSize(width, height) {
452
- if (!this.supported) return;
453
- this._width = Math.floor(width);
454
- this._height = Math.floor(height);
455
- this.gbuffer.setSize(this._width, this._height);
456
- this.rtPass.setSize(this._scaledW, this._scaledH);
457
- this.denoisePass.setSize(this._scaledW, this._scaledH);
458
- this.volumetricPass.setSize(this._scaledW, this._scaledH);
459
- this.restirPass.setSize(this._scaledW, this._scaledH);
460
- this.taaPass.setSize(this._width, this._height);
461
- this._sceneColor.setSize(this._width, this._height);
462
- this.resetAccumulation();
463
- }
464
-
465
- // ---- adaptive quality governor: continuous dynamic resolution scaling ----
466
- // Measures real call-to-call frame time (EMA) and steers renderScale
467
- // proportionally toward targetFps, in 0.05 steps with a cooldown so target
468
- // reallocation and accumulation resets stay rare. Lighting cost ≈ scale², so
469
- // the correction uses a damped power of the error. Limitation: under a vsync
470
- // cap the frame time can't reveal headroom, so upscaling only happens when
471
- // frames are measurably faster than the target — it never thrashes.
472
- _adaptQuality() {
473
- const now = performance.now();
474
- const dt = this._qLastT == null ? null : now - this._qLastT;
475
- this._qLastT = now;
476
- if (dt == null || dt > 100) return; // first frame or hidden-tab stall
477
- this._qEma = this._qEma == null ? dt : this._qEma * 0.9 + dt * 0.1;
478
- if (now - this._qLastChange < 2000) return;
479
-
480
- const ratio = this._qEma / (1000 / this.targetFps);
481
- if (ratio < 1.12 && ratio > 0.8) return; // comfortable — leave it alone
482
-
483
- let s = this._renderScale * Math.pow(1 / ratio, 0.35);
484
- s = Math.round(Math.min(1, Math.max(0.2, s)) * 20) / 20; // 0.05 steps
485
-
486
- // When we're fast, give back the deepest lever FIRST: restore canvas scale
487
- // one step before touching renderScale, since canvas is the coarsest/most
488
- // valuable resolution to recover and it's quadratic on every pass.
489
- if (ratio < 0.8 && this.canvasScaleHook && this._canvasLevelIdx > 0) {
490
- this._canvasLevelIdx--;
491
- this.canvasScaleHook(RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx]);
492
- this._qLastChange = now;
493
- this._qEma = null; // cost profile changed measure fresh
494
- console.info(
495
- `three-realtime-rt: adaptive quality → ${Math.round(
496
- RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx] * 100
497
- )}% canvas`
498
- );
499
- return;
500
- }
501
-
502
- // When we're slow and renderScale has already bottomed out (clamped to its
503
- // 0.2 floor while we're already near it), step DOWN the canvas ladder — the
504
- // deepest, quadratic-on-every-pass lever — instead of a no-op renderScale.
505
- if (
506
- ratio > 1.12 &&
507
- s <= 0.2 &&
508
- this._renderScale <= 0.25 &&
509
- this.canvasScaleHook &&
510
- this._canvasLevelIdx < RealtimeRaytracer.CANVAS_LEVELS.length - 1
511
- ) {
512
- this._canvasLevelIdx++;
513
- this.canvasScaleHook(RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx]);
514
- this._qLastChange = now;
515
- this._qEma = null; // cost profile changed — measure fresh
516
- console.info(
517
- `three-realtime-rt: adaptive quality → ${Math.round(
518
- RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx] * 100
519
- )}% canvas`
520
- );
521
- return;
522
- }
523
-
524
- if (Math.abs(s - this._renderScale) < 0.045) return;
525
-
526
- const q = RealtimeRaytracer._qualityFor(s);
527
- this.denoiseIterations = q.denoiseIterations;
528
- this.stochasticLights = q.stochasticLights;
529
- this.renderScale = s; // reallocates targets + resets accumulation
530
- this._qLastChange = now;
531
- this._qEma = null; // cost profile changed measure fresh
532
- console.info(
533
- `three-realtime-rt: adaptive quality → ${Math.round(s * 100)}% lighting, ` +
534
- `${q.denoiseIterations} denoise passes, ` +
535
- `${q.stochasticLights ? "stochastic" : "full"} direct light`
536
- );
537
- }
538
-
539
- render(scene, camera) {
540
- if (!this.supported) {
541
- this.renderer.render(scene, camera);
542
- return;
543
- }
544
- if (this.adaptiveQuality) this._adaptQuality();
545
- if (this.overloadProtection) this._overloadBrake();
546
- if (!this.compiled) this.compileScene(scene);
547
-
548
- this.frame += 1;
549
- camera.updateMatrixWorld();
550
-
551
- // --- sub-pixel jitter (TAA): offset the projection a fraction of a pixel
552
- // each frame so the whole pipeline (raster G-buffer + traced lighting)
553
- // samples slightly different positions; the TAA resolve averages them into
554
- // supersampled edges. Restored after the frame so callers see a clean matrix.
555
- const proj = camera.projectionMatrix;
556
- const savedProj8 = proj.elements[8];
557
- const savedProj9 = proj.elements[9];
558
- // Debug views (outputMode != 0) bypass the TAA resolve, so skip the jitter
559
- // too otherwise the raw buffers visibly shake.
560
- if (this.taa && this.outputMode === 0) {
561
- this._jitterIndex = (this._jitterIndex + 1) % 16;
562
- const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 / this._width;
563
- const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 / this._height;
564
- proj.elements[8] += jx;
565
- proj.elements[9] += jy;
566
- // Where this jitter moves the image, in UV space: elements[8/9] multiply
567
- // view-space z (= -w), so NDC shifts by -j → UV by -j/2. The TAA resolve
568
- // uses this to unjitter its input back onto a stable grid.
569
- this._jitterUv.set(-jx * 0.5, -jy * 0.5);
570
- } else {
571
- this._jitterUv.set(0, 0);
572
- }
573
- // View-projection actually used to render this frame (jittered if TAA on).
574
- this._jitteredViewProj
575
- .copy(proj)
576
- .multiply(camera.matrixWorldInverse);
577
-
578
- const prevAutoClear = this.renderer.autoClear;
579
- this.renderer.autoClear = false;
580
-
581
- if (this._needsClear) {
582
- this.rtPass.clearHistory(this.renderer);
583
- this.volumetricPass.clearHistory(this.renderer);
584
- this.restirPass.clearHistory(this.renderer);
585
- this._needsClear = false;
586
- }
587
-
588
- // 1. rasterize G-buffer (ping-pongs internally; previous frame kept)
589
- this.gbuffer.render(this.renderer, scene, camera);
590
-
591
- // 2. ray traced lighting with temporal reprojection
592
- const rtU = this.rtPass.material.uniforms;
593
- rtU.uEnvColor.value.copy(this.envColor);
594
- rtU.uEnvIntensity.value = this.envIntensity;
595
- rtU.uEps.value = this.eps;
596
- rtU.uTemporalReprojection.value = this.temporalReprojection;
597
- rtU.uMaxHistory.value = this.maxHistory;
598
- rtU.uFireflyClamp.value = this.fireflyClamp > 0 ? this.fireflyClamp : 1e6;
599
- rtU.uGIEnabled.value = this.gi;
600
- rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
601
- rtU.uReflEnabled.value = this.reflections;
602
- rtU.uRefrEnabled.value = this.refraction;
603
- rtU.uIor.value = this.ior;
604
- rtU.uLightStochastic.value = this.stochasticLights;
605
- rtU.uSkyEnabled.value = this.sky.enabled;
606
- rtU.uSunDir.value.copy(this.sky.sunDir);
607
- rtU.uSunColor.value.copy(this.sky.sunColor);
608
- rtU.uSkyZenith.value.copy(this.sky.zenith);
609
- rtU.uSkyHorizon.value.copy(this.sky.horizon);
610
- rtU.uSkyIntensity.value = this.sky.intensity;
611
- rtU.uPrevViewProj.value.copy(this._prevViewProj);
612
- rtU.uViewProj.value.copy(this._jitteredViewProj);
613
- rtU.uCameraPos.value.copy(camera.getWorldPosition(this._camWorldPos));
614
-
615
- // 2a. ReSTIR reservoirs (ALU-only, no rays)the lighting pass shades
616
- // each pixel's winner with a single visibility ray.
617
- let reservoirTex = null;
618
- if (this.restir) {
619
- // Emissive candidates follow the emissiveNEE toggle — without this the
620
- // reservoir keeps proposing panel samples the user has switched off.
621
- this.restirPass.setEmissiveCount(this.emissiveNEE ? this.compiled.emissiveTriCount : 0);
622
- reservoirTex = this.restirPass.render(
623
- this.renderer,
624
- this.gbuffer,
625
- this._prevViewProj,
626
- this._camWorldPos,
627
- this.frame,
628
- this.eps
629
- );
630
- }
631
- let irradiance = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
632
-
633
- // 3. denoise (display-only: history keeps accumulating raw samples)
634
- if (this.denoise && this.denoiseIterations > 0) {
635
- irradiance = this.denoisePass.render(
636
- this.renderer,
637
- irradiance,
638
- this.gbuffer,
639
- this._camWorldPos,
640
- this.eps,
641
- this.denoiseIterations
642
- );
643
- }
644
-
645
- // 3b. volumetric single-scatter (optional): one BVH-shadowed light sample
646
- // per lighting pixel along the camera ray, accumulated temporally. The
647
- // composite adds the result before fog and tonemap.
648
- let volumetricTex = null;
649
- if (this.volumetric.enabled && this.outputMode === 0) {
650
- volumetricTex = this.volumetricPass.render(
651
- this.renderer,
652
- this.gbuffer,
653
- this._prevViewProj,
654
- this._camWorldPos,
655
- this.frame,
656
- this.eps,
657
- this.volumetric.density,
658
- this.volumetric.maxDist
659
- );
660
- }
661
-
662
- // 4. composite (bilateral upsample if lighting is sub-res). With TAA on,
663
- // render to an offscreen colour target so the resolve can accumulate it;
664
- // otherwise straight to screen. Debug views bypass TAA (raw buffers).
665
- const useTaa = this.taa && this.outputMode === 0;
666
- const cU = this.composite.material.uniforms;
667
- cU.uOutputMode.value = this.outputMode;
668
- cU.uUpsample.value = this._renderScale < 1;
669
- cU.uIrrTexelSize.value.set(1 / this._scaledW, 1 / this._scaledH);
670
- cU.uCameraPos.value.copy(this._camWorldPos);
671
- cU.uFogEnabled.value = this.fog.enabled;
672
- cU.uFogColor.value.copy(this.fog.color);
673
- cU.uFogDensity.value = this.fog.density;
674
- cU.uSkyEnabled.value = this.sky.enabled;
675
- cU.uInvViewProj.value.copy(this._invViewProj.copy(this._jitteredViewProj).invert());
676
- cU.uSunDir.value.copy(this.sky.sunDir);
677
- cU.uSunColor.value.copy(this.sky.sunColor);
678
- cU.uSkyZenith.value.copy(this.sky.zenith);
679
- cU.uSkyHorizon.value.copy(this.sky.horizon);
680
- cU.uSkyIntensity.value = this.sky.intensity;
681
- cU.uVolumetric.value = volumetricTex;
682
- cU.uVolEnabled.value = volumetricTex !== null;
683
- this.composite.render(
684
- this.renderer,
685
- irradiance,
686
- this.gbuffer,
687
- scene.background,
688
- useTaa ? this._sceneColor : null
689
- );
690
-
691
- // 5. temporal anti-aliasing resolve (jitter + neighbourhood-clamped history).
692
- if (useTaa) {
693
- this.taaPass.render(
694
- this.renderer,
695
- this._sceneColor.texture,
696
- this.gbuffer,
697
- this._prevViewProj, // last frame's jittered VP
698
- this._jitterUv,
699
- this._prevJitterUv,
700
- this.taaBlend
701
- );
702
- } else if (this.taa) {
703
- // In a debug view: keep history fresh so switching back doesn't ghost.
704
- this.taaPass.reset();
705
- }
706
-
707
- this.renderer.autoClear = prevAutoClear;
708
-
709
- // Restore the caller's projection matrix (remove this frame's jitter).
710
- proj.elements[8] = savedProj8;
711
- proj.elements[9] = savedProj9;
712
-
713
- // Record this frame's (jittered) view-projection + jitter for next frame.
714
- this._prevViewProj.copy(this._jitteredViewProj);
715
- this._prevJitterUv.copy(this._jitterUv);
716
- }
717
-
718
- dispose() {
719
- if (!this.supported) return;
720
- this.gbuffer.dispose();
721
- this.rtPass.dispose();
722
- this.denoisePass.dispose();
723
- this.composite.dispose();
724
- this.taaPass.dispose();
725
- this.volumetricPass.dispose();
726
- this.restirPass.dispose();
727
- this._sceneColor.dispose();
728
- if (this.compiled) this.compiled.dispose();
729
- }
730
- }
1
+ import * as THREE from "three";
2
+ import { compileScene, syncLights } from "./SceneCompiler.js";
3
+ import { GBufferPass } from "./GBufferPass.js";
4
+ import { RTLightingPass } from "./RTLightingPass.js";
5
+ import { DenoisePass } from "./DenoisePass.js";
6
+ import { CompositePass } from "./CompositePass.js";
7
+ import { TAAPass } from "./TAAPass.js";
8
+ import { VolumetricPass } from "./VolumetricPass.js";
9
+ import { RestirPass } from "./RestirPass.js";
10
+ import { CopyPass } from "./CopyPass.js";
11
+
12
+ // Van der Corput / Halton radical inverse — deterministic low-discrepancy
13
+ // sub-pixel offsets for temporal jitter.
14
+ function halton(index, base) {
15
+ let f = 1;
16
+ let r = 0;
17
+ let i = index;
18
+ while (i > 0) {
19
+ f /= base;
20
+ r += f * (i % base);
21
+ i = Math.floor(i / base);
22
+ }
23
+ return r;
24
+ }
25
+
26
+ /**
27
+ * Drop-in ray traced renderer for three.js scenes.
28
+ *
29
+ * const rt = new RealtimeRaytracer(renderer);
30
+ * rt.compileScene(scene); // once (static scenes, stage 1)
31
+ * rt.render(scene, camera); // per frame, instead of renderer.render
32
+ *
33
+ * Hybrid deferred: rasterized G-buffer for primary visibility, BVH-traced
34
+ * shadow rays + 1-bounce GI for lighting, progressive temporal accumulation.
35
+ */
36
+ export class RealtimeRaytracer {
37
+ /**
38
+ * Can this renderer run the ray tracing pipeline at all? Requires WebGL2
39
+ * with float render targets on a hardware GPU (software rasterizers like
40
+ * SwiftShader technically work but are unusably slow — treated as no).
41
+ */
42
+ static isSupported(renderer) {
43
+ try {
44
+ const gl = renderer.getContext();
45
+ if (typeof WebGL2RenderingContext === "undefined" || !(gl instanceof WebGL2RenderingContext)) return false;
46
+ if (!gl.getExtension("EXT_color_buffer_float")) return false;
47
+ const dbg = gl.getExtension("WEBGL_debug_renderer_info");
48
+ if (dbg) {
49
+ const r = String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || "");
50
+ if (/swiftshader|llvmpipe|software/i.test(r)) return false;
51
+ }
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Rough capability tier for choosing defaults: "none" (can't trace — see
60
+ * isSupported), "mid" (phones/tablets), "high" (desktop-class). WebGPU
61
+ * presence is not used as a backend (this library is WebGL2), only as a
62
+ * modern-browser signal; a WGSL compute backend is roadmap.
63
+ */
64
+ static detectTier(renderer) {
65
+ if (renderer && !RealtimeRaytracer.isSupported(renderer)) return "none";
66
+ const nav = typeof navigator !== "undefined" ? navigator : {};
67
+ const mobile =
68
+ (nav.maxTouchPoints ?? 0) > 1 || /Android|iPhone|iPad|Mobile/i.test(nav.userAgent || "");
69
+ return mobile ? "mid" : "high";
70
+ }
71
+
72
+ /** Sensible constructor options for a tier (spread them, then override). */
73
+ static recommendedOptions(tier) {
74
+ if (tier === "none") return {};
75
+ if (tier === "mid") {
76
+ return {
77
+ renderScale: 0.375,
78
+ ...RealtimeRaytracer._qualityFor(0.375),
79
+ adaptiveQuality: true,
80
+ };
81
+ }
82
+ return { renderScale: 0.5, denoiseIterations: 3, adaptiveQuality: true };
83
+ }
84
+
85
+ /**
86
+ * Probe whether this context accepts a framebuffer with mixed fp16/fp32
87
+ * color attachments (legal WebGL2; some drivers reject it anyway). Runs raw
88
+ * GL before three renders anything, and restores null bindings after.
89
+ */
90
+ static _mixedMrtSupported(gl) {
91
+ try {
92
+ const fb = gl.createFramebuffer();
93
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
94
+ const mk = (ifmt) => {
95
+ const t = gl.createTexture();
96
+ gl.bindTexture(gl.TEXTURE_2D, t);
97
+ gl.texStorage2D(gl.TEXTURE_2D, 1, ifmt, 4, 4);
98
+ return t;
99
+ };
100
+ const t0 = mk(gl.RGBA16F);
101
+ const t1 = mk(gl.RGBA32F);
102
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t0, 0);
103
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, t1, 0);
104
+ gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
105
+ const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
106
+ gl.deleteFramebuffer(fb);
107
+ gl.deleteTexture(t0);
108
+ gl.deleteTexture(t1);
109
+ gl.bindTexture(gl.TEXTURE_2D, null);
110
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
111
+ return ok;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Companion settings for a given lighting resolution. LOW resolution wants
119
+ * MORE denoise passes, not fewer the filter runs at lighting res so extra
120
+ * iterations are nearly free there, and they're what makes 25% lighting look
121
+ * good. Stochastic lights kick in once the budget is clearly constrained.
122
+ */
123
+ static _qualityFor(scale) {
124
+ return {
125
+ denoiseIterations: scale <= 0.3 ? 5 : scale <= 0.45 ? 4 : 3,
126
+ stochasticLights: scale <= 0.55,
127
+ };
128
+ }
129
+
130
+ // Canvas-scale ladder for the governor's deepest lever. Canvas scale shrinks
131
+ // the drawing buffer, so EVERY pass (raster G-buffer, lighting, denoise, TAA,
132
+ // resolve) gets quadratically cheaper unlike renderScale, which only touches
133
+ // the lighting buffer. It's app-owned (the demo/gallery own the canvas + CSS
134
+ // stretch), so the governor drives it through canvasScaleHook rather than
135
+ // touching the renderer directly.
136
+ static CANVAS_LEVELS = [1, 0.85, 0.75, 0.62, 0.5];
137
+
138
+ // Sample count the carried-over irradiance history is clamped to when the
139
+ // targets are reallocated (renderScale step / canvas resize). Small enough
140
+ // that the EMA visibly reconverges onto the new-resolution samples, large
141
+ // enough that the image doesn't flash back to raw 1-spp noise "keep ~8
142
+ // frames of confidence". See RTLightingPass.resizeCarry.
143
+ static HISTORY_CARRY_FRAMES = 8;
144
+
145
+ constructor(renderer, options = {}) {
146
+ this.renderer = renderer;
147
+
148
+ /**
149
+ * False when the platform can't run the tracer — render() then simply
150
+ * forwards to renderer.render (plain rasterized three.js), so apps work
151
+ * everywhere without their own capability checks.
152
+ */
153
+ this.supported = RealtimeRaytracer.isSupported(renderer);
154
+ if (!this.supported) {
155
+ console.warn(
156
+ "three-realtime-rt: ray tracing unavailable on this system " +
157
+ "(needs WebGL2 + EXT_color_buffer_float on a hardware GPU). " +
158
+ "Falling back to plain three.js rendering."
159
+ );
160
+ this.compiled = null;
161
+ this.frame = 0;
162
+ return;
163
+ }
164
+
165
+ const size = renderer.getSize(new THREE.Vector2());
166
+ const pr = renderer.getPixelRatio();
167
+ this._width = Math.floor(size.x * pr);
168
+ this._height = Math.floor(size.y * pr);
169
+ /**
170
+ * Resolution scale for the ray traced lighting (G-buffer and final image
171
+ * stay full res). 0.5 traces 4x fewer rays; the bilateral upsample +
172
+ * denoiser reconstruct the difference. Set 1.0 for maximum quality.
173
+ */
174
+ this._renderScale = options.renderScale ?? 0.5;
175
+
176
+ const mixedPrecision = RealtimeRaytracer._mixedMrtSupported(renderer.getContext());
177
+ if (!mixedPrecision) {
178
+ console.info("three-realtime-rt: mixed fp16/fp32 G-buffer not supported here — using fp32 for all targets.");
179
+ }
180
+ this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
181
+ this.rtPass = new RTLightingPass(this._scaledW, this._scaledH);
182
+ this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
183
+ this.composite = new CompositePass();
184
+ this.taaPass = new TAAPass(this._width, this._height);
185
+ this._sceneColor = this._makeColorTarget(this._width, this._height);
186
+ // Fullscreen blit used to carry history buffers across target reallocation
187
+ // (renderScale steps / canvas resizes) instead of hard-clearing them.
188
+ this._copyPass = new CopyPass();
189
+
190
+ this.compiled = null;
191
+ this.frame = 0;
192
+
193
+ /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive */
194
+ this.outputMode = 0;
195
+ /** Environment (sky) color used for GI rays that miss + composite background. */
196
+ this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
197
+ this.envIntensity = options.envIntensity ?? 1.0;
198
+ /**
199
+ * Ray offset epsilon. When not set explicitly it is auto-scaled from the
200
+ * scene's size at compile time (dense scenes need a larger offset or
201
+ * shadow rays self-intersect). Set it manually if you see acne (raise) or
202
+ * light leaking through thin walls (lower).
203
+ */
204
+ this.eps = options.eps ?? 1e-3;
205
+ this._autoEps = options.eps == null;
206
+ /** Reproject accumulated lighting through camera motion (stage 2). */
207
+ this.temporalReprojection = options.temporalReprojection ?? true;
208
+ /** History length cap: higher = smoother but slower to react. */
209
+ this.maxHistory = options.maxHistory ?? 128;
210
+ /** Clamp on indirect luminance to suppress fireflies. 0 disables. */
211
+ this.fireflyClamp = options.fireflyClamp ?? 4.0;
212
+ /** 1-bounce global illumination (traced indirect). Toggle for a direct-only look. */
213
+ this.gi = options.gi ?? true;
214
+ /**
215
+ * Half-rate GI: trace the bounce on alternating checkerboard parity each
216
+ * frame (doubled unbiased, temporal accumulation converges to the same
217
+ * brightness). Halves GI's ray cost for a small convergence-speed hit;
218
+ * the cheapest way to keep GI "worth turning on" on weaker GPUs.
219
+ */
220
+ this.giHalfRate = options.giHalfRate ?? false;
221
+ /**
222
+ * Sample static emissive meshes as area lights (next-event estimation).
223
+ * Dramatically less noise than waiting for GI rays to hit the emitter, and
224
+ * emitters gain direct lighting + shadows. Off = legacy hit-only behaviour.
225
+ */
226
+ this.emissiveNEE = options.emissiveNEE ?? true;
227
+ /** Traced mirror/glossy reflections on metallic surfaces. */
228
+ this.reflections = options.reflections ?? true;
229
+ /** Traced refraction for transmissive (MeshPhysicalMaterial.transmission) surfaces. */
230
+ this.refraction = options.refraction ?? true;
231
+ /** Index of refraction used for transmissive surfaces. */
232
+ this.ior = options.ior ?? 1.5;
233
+ /**
234
+ * One stochastic direct shadow ray per pixel per frame (source picked at
235
+ * random) instead of one per light the biggest ray-count lever for
236
+ * many-light scenes and mobile GPUs. Slightly noisier moving shadows;
237
+ * temporal accumulation + the denoiser absorb it.
238
+ */
239
+ this.stochasticLights = options.stochasticLights ?? false;
240
+ /**
241
+ * Adaptive quality governor: watches the app's real frame time and walks
242
+ * QUALITY_LADDER degrading when frames run long, cautiously probing a
243
+ * better level when there is headroom (reverting if the probe fails). The
244
+ * portable way to "work well" on unknown hardware. Drives renderScale,
245
+ * denoiseIterations and stochasticLights; setting those manually while
246
+ * enabled will be overridden — turn this off for manual control.
247
+ */
248
+ this.adaptiveQuality = options.adaptiveQuality ?? false;
249
+ /** Frame-rate target for the adaptive governor. */
250
+ this.targetFps = options.targetFps ?? 55;
251
+ /**
252
+ * Emergency overload brake — independent of adaptiveQuality and ON by
253
+ * default. Two protections: an oversized first buffer gets its lighting
254
+ * scale clamped (with a loud warning), and consecutive catastrophic
255
+ * frames (>400ms) force quality down before the GPU driver gives up.
256
+ * Weak GPUs fed high settings can otherwise hang the whole machine
257
+ * (observed: full system crash on an Intel-Mac in Chrome). Set
258
+ * overloadProtection: false to opt out.
259
+ */
260
+ this.overloadProtection = options.overloadProtection ?? true;
261
+ this._overloadStrikes = 0;
262
+ this._obLastT = null;
263
+ this._qEma = null;
264
+ this._qLastT = null;
265
+ this._qLastChange = 0;
266
+ // Direction of the last committed quality change (+1 up / -1 down) and an
267
+ // oscillation flag: when two consecutive steps reverse direction the
268
+ // governor is hunting around the frame-time boundary, so it widens its
269
+ // deadband and lengthens its cooldown to settle down (see _adaptQuality).
270
+ this._qLastDir = 0;
271
+ this._qOscillating = false;
272
+ /**
273
+ * App-owned canvas-scale setter, driven by the governor as its deepest
274
+ * lever once renderScale bottoms out. The app owns the canvas + CSS stretch,
275
+ * so it must apply the buffer resize itself; null disables this level.
276
+ */
277
+ this.canvasScaleHook = options.canvasScaleHook ?? null;
278
+ this._canvasLevelIdx = 0;
279
+ /** Edge-aware à-trous denoise on the irradiance buffer. */
280
+ this.denoise = options.denoise ?? true;
281
+ /** À-trous iterations (steps 1, 2, 4, ...). */
282
+ this.denoiseIterations = options.denoiseIterations ?? 3;
283
+
284
+ /**
285
+ * Temporal anti-aliasing: sub-pixel projection jitter + a full-res history
286
+ * resolve with neighbourhood clamp. Supersamples silhouettes over time and
287
+ * clears the bright disocclusion speckles at edges. Analytic (FSR2 / TAAU
288
+ * family), not a learned upscaler.
289
+ */
290
+ this.taa = options.taa ?? true;
291
+ /** Fresh-sample weight in the TAA blend (lower = smoother/more AA, more lag). */
292
+ this.taaBlend = options.taaBlend ?? 0.1;
293
+
294
+ /**
295
+ * Volumetric lighting — real "god rays": single-scatter fog integrated
296
+ * along each primary ray with one jittered, BVH-shadowed light sample per
297
+ * pixel per frame, temporally accumulated like the surface lighting.
298
+ * Shafts are carved by actual occluders and work for off-screen sources.
299
+ * Off by default; costs roughly one extra shadow ray per lighting pixel.
300
+ */
301
+ this.volumetric = {
302
+ enabled: options.volumetric?.enabled ?? false,
303
+ density: options.volumetric?.density ?? 0.015,
304
+ maxDist: options.volumetric?.maxDist ?? 40,
305
+ // Localized fog: up to 8 AABBs whose densities ADD to the global term at
306
+ // any point they contain. Empty = global-only (original behavior).
307
+ zones: options.volumetric?.zones ?? [],
308
+ };
309
+ // Quarter CANVAS resolution, independent of renderScale: fog is
310
+ // low-frequency, so resolution buys nothing — the budget goes into
311
+ // multiple march steps per ray instead (see VolumetricPass).
312
+ this.volumetricPass = new VolumetricPass(this._volW, this._volH);
313
+
314
+ /**
315
+ * ReSTIR direct lighting: per-pixel reservoirs converge onto the light
316
+ * that matters most to each pixel (temporal reuse, one visibility ray at
317
+ * shading). Cost is flat in light count; also greatly reduces emissive
318
+ * area-light noise. On by default — turn off to compare estimators.
319
+ */
320
+ this.restir = options.restir ?? true;
321
+ this.restirPass = new RestirPass(this._scaledW, this._scaledH);
322
+
323
+ /** Distance fog (composited in linear space before tonemap). */
324
+ this.fog = {
325
+ enabled: options.fog?.enabled ?? false,
326
+ color: options.fog?.color ?? new THREE.Color(0.5, 0.6, 0.7),
327
+ density: options.fog?.density ?? 0.05,
328
+ };
329
+
330
+ /**
331
+ * Procedural sky. When enabled it is BOTH the background and the ambient
332
+ * light for GI rays that escape the scene — the core of natural outdoor
333
+ * lighting. `sunDir` points toward the sun (keep it in sync with your
334
+ * DirectionalLight for matching direct shadows).
335
+ */
336
+ this.sky = {
337
+ enabled: options.sky?.enabled ?? false,
338
+ sunDir: options.sky?.sunDir ?? new THREE.Vector3(0.4, 0.8, 0.45).normalize(),
339
+ sunColor: options.sky?.sunColor ?? new THREE.Color(1.0, 0.9, 0.75),
340
+ zenith: options.sky?.zenith ?? new THREE.Color(0.18, 0.34, 0.62),
341
+ horizon: options.sky?.horizon ?? new THREE.Color(0.7, 0.8, 0.9),
342
+ intensity: options.sky?.intensity ?? 1.0,
343
+ };
344
+ this._invViewProj = new THREE.Matrix4();
345
+ this._jitterIndex = 0;
346
+ this._jitteredViewProj = new THREE.Matrix4();
347
+ this._jitterUv = new THREE.Vector2(); // this frame's jitter in UV space
348
+ this._prevJitterUv = new THREE.Vector2();
349
+
350
+ this._prevViewProj = new THREE.Matrix4();
351
+ this._camWorldPos = new THREE.Vector3();
352
+ this._needsClear = true;
353
+
354
+ // First-frame guard: a 4K/5K drawing buffer at high lighting scale can
355
+ // hang a weak GPU on the very first frame — before any frame-time
356
+ // measurement can react. Start those safe; adaptiveQuality (or the app)
357
+ // can raise quality once frames are proven survivable.
358
+ if (this.overloadProtection && this._width * this._height > 3.2e6 && this._renderScale > 0.375) {
359
+ console.warn(
360
+ `three-realtime-rt: ${(this._width * this._height / 1e6).toFixed(1)}M-pixel drawing buffer ` +
361
+ `clamping lighting renderScale to 0.375 (overloadProtection). Raise renderScale manually, ` +
362
+ `enable adaptiveQuality, or pass overloadProtection: false to opt out.`
363
+ );
364
+ this._renderScale = 0.375;
365
+ }
366
+ }
367
+
368
+ // Consecutive catastrophic frames mean the GPU is drowning cut quality
369
+ // hard and loudly before the driver resets (or takes the machine with it).
370
+ // Hidden tabs are exempt (browser throttling looks like huge frame times).
371
+ _overloadBrake() {
372
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
373
+ this._obLastT = null;
374
+ return;
375
+ }
376
+ const now = performance.now();
377
+ const dt = this._obLastT == null ? null : now - this._obLastT;
378
+ this._obLastT = now;
379
+ if (dt == null) return;
380
+ if (dt > 400 && dt < 10000) this._overloadStrikes++;
381
+ else if (dt < 200) this._overloadStrikes = 0;
382
+ if (this._overloadStrikes < 3) return;
383
+ this._overloadStrikes = 0;
384
+
385
+ if (this._renderScale > 0.2) {
386
+ this.denoiseIterations = Math.min(this.denoiseIterations, 3);
387
+ this.stochasticLights = true;
388
+ this.renderScale = Math.max(0.2, Math.round(this._renderScale * 0.5 * 20) / 20);
389
+ console.warn(
390
+ `three-realtime-rt: frames exceeding 400ms overload brake cut lighting to ` +
391
+ `${Math.round(this._renderScale * 100)}%. Lower your canvas resolution or enable adaptiveQuality.`
392
+ );
393
+ } else if (this.volumetric.enabled || this.reflections || this.refraction) {
394
+ this.volumetric.enabled = false;
395
+ this.reflections = false;
396
+ this.refraction = false;
397
+ console.warn(
398
+ "three-realtime-rt: still overloaded at minimum lighting scale — " +
399
+ "disabling volumetric/reflections/refraction."
400
+ );
401
+ }
402
+ }
403
+
404
+ _makeColorTarget(width, height) {
405
+ const t = new THREE.WebGLRenderTarget(width, height, {
406
+ minFilter: THREE.LinearFilter,
407
+ magFilter: THREE.LinearFilter,
408
+ format: THREE.RGBAFormat,
409
+ type: THREE.HalfFloatType,
410
+ depthBuffer: false,
411
+ stencilBuffer: false,
412
+ });
413
+ t.texture.generateMipmaps = false;
414
+ return t;
415
+ }
416
+
417
+ /**
418
+ * Build/rebuild BVH + material and light tables from the scene. Call after
419
+ * structural scene changes. Pass `{ dynamicMeshes: [...] }` to mark meshes
420
+ * whose transforms will change every frame (drive them with updateDynamic()).
421
+ */
422
+ compileScene(scene, options) {
423
+ if (!this.supported) return null;
424
+ if (this.compiled) this.compiled.dispose();
425
+ this.compiled = compileScene(scene, options);
426
+ if (this._autoEps) {
427
+ // ~1/1000 of the scene diagonal, floored at the classic 1e-3.
428
+ this.eps = Math.min(Math.max(1e-3, this.compiled.sceneDiagonal * 1.2e-3), 0.05);
429
+ }
430
+ this.rtPass.setCompiledScene(this.compiled);
431
+ this.volumetricPass.setCompiledScene(this.compiled);
432
+ this.restirPass.setCompiledScene(this.compiled);
433
+ this.resetAccumulation();
434
+ return this.compiled;
435
+ }
436
+
437
+ /**
438
+ * Re-bake moving (dynamic) meshes into the dynamic BVH level. Call each frame
439
+ * after moving them. Only the dynamic level is touched — the static BVH was
440
+ * uploaded once at compile time — so cost scales with the moving triangle
441
+ * count, not the scene size.
442
+ */
443
+ updateDynamic() {
444
+ if (this.compiled) this.compiled.updateDynamic();
445
+ }
446
+
447
+ /**
448
+ * Refresh light positions/colors from the scene without a full recompile —
449
+ * lets the demo toggle, move, and recolor lights live. Lights with intensity
450
+ * 0 (or invisible) are dropped so they can be switched off.
451
+ */
452
+ updateLights(scene) {
453
+ if (!this.supported || !this.compiled) return;
454
+ syncLights(scene, this.compiled);
455
+ this.rtPass.setCompiledScene(this.compiled);
456
+ this.volumetricPass.setCompiledScene(this.compiled);
457
+ this.restirPass.setCompiledScene(this.compiled);
458
+ }
459
+
460
+ resetAccumulation() {
461
+ if (!this.supported) return;
462
+ this._needsClear = true;
463
+ if (this.taaPass) this.taaPass.reset();
464
+ }
465
+
466
+ get _scaledW() {
467
+ return Math.max(1, Math.floor(this._width * this._renderScale));
468
+ }
469
+ get _scaledH() {
470
+ return Math.max(1, Math.floor(this._height * this._renderScale));
471
+ }
472
+ get _volW() {
473
+ return Math.max(1, this._width >> 2);
474
+ }
475
+ get _volH() {
476
+ return Math.max(1, this._height >> 2);
477
+ }
478
+
479
+ get renderScale() {
480
+ return this._renderScale;
481
+ }
482
+ set renderScale(v) {
483
+ this._renderScale = v;
484
+ this.setSize(this._width, this._height);
485
+ }
486
+
487
+ // Resize (or re-scale) the pipeline WITHOUT dumping temporal history. A
488
+ // renderScale step only resizes the lighting-resolution targets; a genuine
489
+ // canvas resize also resizes the full-res ones. Each history-bearing buffer
490
+ // is carried over (resampled) rather than cleared — a hard reset here is what
491
+ // strobed the image on every governor tick. Compares desired vs currently
492
+ // allocated size per pass, so it is correct no matter when _renderScale was
493
+ // updated (the renderScale setter changes it before calling us).
494
+ setSize(width, height) {
495
+ if (!this.supported) return;
496
+ this._width = Math.floor(width);
497
+ this._height = Math.floor(height);
498
+
499
+ const sw = this._scaledW;
500
+ const sh = this._scaledH;
501
+ const scaledChanged =
502
+ this.rtPass.targetA.width !== sw || this.rtPass.targetA.height !== sh;
503
+ const canvasChanged =
504
+ this.taaPass.targetA.width !== this._width ||
505
+ this.taaPass.targetA.height !== this._height;
506
+
507
+ // Lighting-resolution targets (change on both a renderScale step and a
508
+ // canvas resize). Carry the irradiance history — the buffer whose reset
509
+ // causes the flash — and reallocate the rest.
510
+ if (scaledChanged) {
511
+ this.rtPass.resizeCarry(
512
+ this.renderer,
513
+ this._copyPass,
514
+ sw,
515
+ sh,
516
+ RealtimeRaytracer.HISTORY_CARRY_FRAMES
517
+ );
518
+ this.denoisePass.setSize(sw, sh); // display-only, no temporal state
519
+ // ReSTIR reservoirs store packed id·64+M encodings — invalid to linearly
520
+ // resample — but they reconverge in a few frames, so just reallocate and
521
+ // clear them.
522
+ this.restirPass.setSize(sw, sh);
523
+ this.restirPass.clearHistory(this.renderer);
524
+ }
525
+
526
+ // Full-res / canvas-res targets: only touched on a real canvas resize (a
527
+ // renderScale step leaves them alone, so TAA keeps its resolved history).
528
+ if (canvasChanged) {
529
+ this.gbuffer.setSize(this._width, this._height);
530
+ // Quarter-canvas fog: low-frequency and reconverges instantly, so a plain
531
+ // reallocation + clear is fine.
532
+ this.volumetricPass.setSize(this._volW, this._volH);
533
+ this.volumetricPass.clearHistory(this.renderer);
534
+ // TAA history is full-canvas-res: carry it across the resize (linear
535
+ // resample) so the ladder step doesn't reset AA.
536
+ this.taaPass.resizeCarry(this.renderer, this._copyPass, this._width, this._height);
537
+ this._sceneColor.setSize(this._width, this._height);
538
+ }
539
+ }
540
+
541
+ // ---- adaptive quality governor: continuous dynamic resolution scaling ----
542
+ // Measures real call-to-call frame time (EMA) and steers renderScale
543
+ // proportionally toward targetFps, in 0.05 steps with a cooldown so target
544
+ // reallocation and accumulation resets stay rare. Lighting cost ≈ scale², so
545
+ // the correction uses a damped power of the error. Limitation: under a vsync
546
+ // cap the frame time can't reveal headroom, so upscaling only happens when
547
+ // frames are measurably faster than the target — it never thrashes.
548
+ _adaptQuality() {
549
+ const now = performance.now();
550
+ const dt = this._qLastT == null ? null : now - this._qLastT;
551
+ this._qLastT = now;
552
+ if (dt == null || dt > 100) return; // first frame or hidden-tab stall
553
+ this._qEma = this._qEma == null ? dt : this._qEma * 0.9 + dt * 0.1;
554
+ // Calmness: normally 2s between changes. When the last two steps reversed
555
+ // direction the governor is hunting the boundary, so hold for 5s AND widen
556
+ // the "comfortable" deadband — both push it to commit to a level instead of
557
+ // ping-ponging (each ping-pong is a target reallocation).
558
+ const cooldown = this._qOscillating ? 5000 : 2000;
559
+ if (now - this._qLastChange < cooldown) return;
560
+
561
+ const ratio = this._qEma / (1000 / this.targetFps);
562
+ const dbLo = this._qOscillating ? 0.6 : 0.8;
563
+ const dbHi = this._qOscillating ? 1.24 : 1.12;
564
+ if (ratio < dbHi && ratio > dbLo) return; // comfortable — leave it alone
565
+
566
+ let s = this._renderScale * Math.pow(1 / ratio, 0.35);
567
+ s = Math.round(Math.min(1, Math.max(0.2, s)) * 20) / 20; // 0.05 steps
568
+
569
+ // When we're fast, give back the deepest lever FIRST: restore canvas scale
570
+ // one step before touching renderScale, since canvas is the coarsest/most
571
+ // valuable resolution to recover and it's quadratic on every pass.
572
+ if (ratio < dbLo && this.canvasScaleHook && this._canvasLevelIdx > 0) {
573
+ this._canvasLevelIdx--;
574
+ this.canvasScaleHook(RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx]);
575
+ this._recordChange(1, now); // restoring resolution = quality up
576
+ this._qEma = null; // cost profile changed — measure fresh
577
+ console.info(
578
+ `three-realtime-rt: adaptive quality → ${Math.round(
579
+ RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx] * 100
580
+ )}% canvas`
581
+ );
582
+ return;
583
+ }
584
+
585
+ // When we're slow and renderScale has already bottomed out (clamped to its
586
+ // 0.2 floor while we're already near it), step DOWN the canvas ladder — the
587
+ // deepest, quadratic-on-every-pass lever — instead of a no-op renderScale.
588
+ if (
589
+ ratio > dbHi &&
590
+ s <= 0.2 &&
591
+ this._renderScale <= 0.25 &&
592
+ this.canvasScaleHook &&
593
+ this._canvasLevelIdx < RealtimeRaytracer.CANVAS_LEVELS.length - 1
594
+ ) {
595
+ this._canvasLevelIdx++;
596
+ this.canvasScaleHook(RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx]);
597
+ this._recordChange(-1, now); // deeper downscale = quality down
598
+ this._qEma = null; // cost profile changed measure fresh
599
+ console.info(
600
+ `three-realtime-rt: adaptive quality ${Math.round(
601
+ RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx] * 100
602
+ )}% canvas`
603
+ );
604
+ return;
605
+ }
606
+
607
+ if (Math.abs(s - this._renderScale) < 0.045) return;
608
+
609
+ const dir = Math.sign(s - this._renderScale);
610
+ const q = RealtimeRaytracer._qualityFor(s);
611
+ this.denoiseIterations = q.denoiseIterations;
612
+ this.stochasticLights = q.stochasticLights;
613
+ this.renderScale = s; // reallocates targets, carrying history over (no reset)
614
+ this._recordChange(dir, now);
615
+ this._qEma = null; // cost profile changed measure fresh
616
+ console.info(
617
+ `three-realtime-rt: adaptive quality → ${Math.round(s * 100)}% lighting, ` +
618
+ `${q.denoiseIterations} denoise passes, ` +
619
+ `${q.stochasticLights ? "stochastic" : "full"} direct light`
620
+ );
621
+ }
622
+
623
+ // Record a committed quality step and update oscillation state: two
624
+ // consecutive steps in OPPOSITE directions mean the governor is hunting the
625
+ // frame-time boundary (drives the wider deadband + longer cooldown above).
626
+ _recordChange(dir, now) {
627
+ this._qOscillating = dir !== 0 && this._qLastDir !== 0 && dir !== this._qLastDir;
628
+ if (dir !== 0) this._qLastDir = dir;
629
+ this._qLastChange = now;
630
+ }
631
+
632
+ render(scene, camera) {
633
+ if (!this.supported) {
634
+ this.renderer.render(scene, camera);
635
+ return;
636
+ }
637
+ if (this.adaptiveQuality) this._adaptQuality();
638
+ if (this.overloadProtection) this._overloadBrake();
639
+ if (!this.compiled) this.compileScene(scene);
640
+
641
+ this.frame += 1;
642
+ camera.updateMatrixWorld();
643
+
644
+ // --- sub-pixel jitter (TAA): offset the projection a fraction of a pixel
645
+ // each frame so the whole pipeline (raster G-buffer + traced lighting)
646
+ // samples slightly different positions; the TAA resolve averages them into
647
+ // supersampled edges. Restored after the frame so callers see a clean matrix.
648
+ const proj = camera.projectionMatrix;
649
+ const savedProj8 = proj.elements[8];
650
+ const savedProj9 = proj.elements[9];
651
+ // Debug views (outputMode != 0) bypass the TAA resolve, so skip the jitter
652
+ // too — otherwise the raw buffers visibly shake.
653
+ if (this.taa && this.outputMode === 0) {
654
+ this._jitterIndex = (this._jitterIndex + 1) % 16;
655
+ const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 / this._width;
656
+ const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 / this._height;
657
+ proj.elements[8] += jx;
658
+ proj.elements[9] += jy;
659
+ // Where this jitter moves the image, in UV space: elements[8/9] multiply
660
+ // view-space z (= -w), so NDC shifts by -j → UV by -j/2. The TAA resolve
661
+ // uses this to unjitter its input back onto a stable grid.
662
+ this._jitterUv.set(-jx * 0.5, -jy * 0.5);
663
+ } else {
664
+ this._jitterUv.set(0, 0);
665
+ }
666
+ // View-projection actually used to render this frame (jittered if TAA on).
667
+ this._jitteredViewProj
668
+ .copy(proj)
669
+ .multiply(camera.matrixWorldInverse);
670
+
671
+ const prevAutoClear = this.renderer.autoClear;
672
+ this.renderer.autoClear = false;
673
+
674
+ if (this._needsClear) {
675
+ this.rtPass.clearHistory(this.renderer);
676
+ this.volumetricPass.clearHistory(this.renderer);
677
+ this.restirPass.clearHistory(this.renderer);
678
+ this._needsClear = false;
679
+ }
680
+
681
+ // 1. rasterize G-buffer (ping-pongs internally; previous frame kept)
682
+ this.gbuffer.render(this.renderer, scene, camera);
683
+
684
+ // 2. ray traced lighting with temporal reprojection
685
+ const rtU = this.rtPass.material.uniforms;
686
+ rtU.uEnvColor.value.copy(this.envColor);
687
+ rtU.uEnvIntensity.value = this.envIntensity;
688
+ rtU.uEps.value = this.eps;
689
+ rtU.uTemporalReprojection.value = this.temporalReprojection;
690
+ rtU.uMaxHistory.value = this.maxHistory;
691
+ rtU.uFireflyClamp.value = this.fireflyClamp > 0 ? this.fireflyClamp : 1e6;
692
+ rtU.uGIEnabled.value = this.gi;
693
+ rtU.uGIHalfRate.value = this.giHalfRate;
694
+ rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
695
+ rtU.uReflEnabled.value = this.reflections;
696
+ rtU.uRefrEnabled.value = this.refraction;
697
+ rtU.uIor.value = this.ior;
698
+ rtU.uLightStochastic.value = this.stochasticLights;
699
+ rtU.uSkyEnabled.value = this.sky.enabled;
700
+ rtU.uSunDir.value.copy(this.sky.sunDir);
701
+ rtU.uSunColor.value.copy(this.sky.sunColor);
702
+ rtU.uSkyZenith.value.copy(this.sky.zenith);
703
+ rtU.uSkyHorizon.value.copy(this.sky.horizon);
704
+ rtU.uSkyIntensity.value = this.sky.intensity;
705
+ rtU.uPrevViewProj.value.copy(this._prevViewProj);
706
+ rtU.uViewProj.value.copy(this._jitteredViewProj);
707
+ rtU.uCameraPos.value.copy(camera.getWorldPosition(this._camWorldPos));
708
+
709
+ // 2a. ReSTIR reservoirs (ALU-only, no rays) the lighting pass shades
710
+ // each pixel's winner with a single visibility ray.
711
+ let reservoirTex = null;
712
+ if (this.restir) {
713
+ // Emissive candidates follow the emissiveNEE toggle without this the
714
+ // reservoir keeps proposing panel samples the user has switched off.
715
+ this.restirPass.setEmissiveCount(this.emissiveNEE ? this.compiled.emissiveTriCount : 0);
716
+ reservoirTex = this.restirPass.render(
717
+ this.renderer,
718
+ this.gbuffer,
719
+ this._prevViewProj,
720
+ this._camWorldPos,
721
+ this.frame,
722
+ this.eps
723
+ );
724
+ }
725
+ let irradiance = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
726
+
727
+ // 3. denoise (display-only: history keeps accumulating raw samples)
728
+ if (this.denoise && this.denoiseIterations > 0) {
729
+ irradiance = this.denoisePass.render(
730
+ this.renderer,
731
+ irradiance,
732
+ this.gbuffer,
733
+ this._camWorldPos,
734
+ this.eps,
735
+ this.denoiseIterations
736
+ );
737
+ }
738
+
739
+ // 3b. volumetric single-scatter (optional): one BVH-shadowed light sample
740
+ // per lighting pixel along the camera ray, accumulated temporally. The
741
+ // composite adds the result before fog and tonemap.
742
+ let volumetricTex = null;
743
+ // Runs when a global density is set OR when localized zones are present
744
+ // (a zone can add fog even where the global term is 0).
745
+ const hasZones = this.volumetric.zones && this.volumetric.zones.length > 0;
746
+ if (
747
+ this.volumetric.enabled &&
748
+ this.outputMode === 0 &&
749
+ (this.volumetric.density > 0 || hasZones)
750
+ ) {
751
+ volumetricTex = this.volumetricPass.render(
752
+ this.renderer,
753
+ this.gbuffer,
754
+ this._prevViewProj,
755
+ this._camWorldPos,
756
+ this.frame,
757
+ this.eps,
758
+ this.volumetric.density,
759
+ this.volumetric.maxDist,
760
+ this.volumetric.zones
761
+ );
762
+ }
763
+
764
+ // 4. composite (bilateral upsample if lighting is sub-res). With TAA on,
765
+ // render to an offscreen colour target so the resolve can accumulate it;
766
+ // otherwise straight to screen. Debug views bypass TAA (raw buffers).
767
+ const useTaa = this.taa && this.outputMode === 0;
768
+ const cU = this.composite.material.uniforms;
769
+ cU.uOutputMode.value = this.outputMode;
770
+ cU.uUpsample.value = this._renderScale < 1;
771
+ cU.uIrrTexelSize.value.set(1 / this._scaledW, 1 / this._scaledH);
772
+ cU.uCameraPos.value.copy(this._camWorldPos);
773
+ cU.uFogEnabled.value = this.fog.enabled;
774
+ cU.uFogColor.value.copy(this.fog.color);
775
+ cU.uFogDensity.value = this.fog.density;
776
+ cU.uSkyEnabled.value = this.sky.enabled;
777
+ cU.uInvViewProj.value.copy(this._invViewProj.copy(this._jitteredViewProj).invert());
778
+ cU.uSunDir.value.copy(this.sky.sunDir);
779
+ cU.uSunColor.value.copy(this.sky.sunColor);
780
+ cU.uSkyZenith.value.copy(this.sky.zenith);
781
+ cU.uSkyHorizon.value.copy(this.sky.horizon);
782
+ cU.uSkyIntensity.value = this.sky.intensity;
783
+ cU.uVolumetric.value = volumetricTex;
784
+ cU.uVolEnabled.value = volumetricTex !== null;
785
+ cU.uVolTexelSize.value.set(1 / this._volW, 1 / this._volH);
786
+ this.composite.render(
787
+ this.renderer,
788
+ irradiance,
789
+ this.gbuffer,
790
+ scene.background,
791
+ useTaa ? this._sceneColor : null
792
+ );
793
+
794
+ // 5. temporal anti-aliasing resolve (jitter + neighbourhood-clamped history).
795
+ if (useTaa) {
796
+ this.taaPass.render(
797
+ this.renderer,
798
+ this._sceneColor.texture,
799
+ this.gbuffer,
800
+ this._prevViewProj, // last frame's jittered VP
801
+ this._jitterUv,
802
+ this._prevJitterUv,
803
+ this.taaBlend
804
+ );
805
+ } else if (this.taa) {
806
+ // In a debug view: keep history fresh so switching back doesn't ghost.
807
+ this.taaPass.reset();
808
+ }
809
+
810
+ this.renderer.autoClear = prevAutoClear;
811
+
812
+ // Restore the caller's projection matrix (remove this frame's jitter).
813
+ proj.elements[8] = savedProj8;
814
+ proj.elements[9] = savedProj9;
815
+
816
+ // Record this frame's (jittered) view-projection + jitter for next frame.
817
+ this._prevViewProj.copy(this._jitteredViewProj);
818
+ this._prevJitterUv.copy(this._jitterUv);
819
+ }
820
+
821
+ dispose() {
822
+ if (!this.supported) return;
823
+ this.gbuffer.dispose();
824
+ this.rtPass.dispose();
825
+ this.denoisePass.dispose();
826
+ this.composite.dispose();
827
+ this.taaPass.dispose();
828
+ this.volumetricPass.dispose();
829
+ this.restirPass.dispose();
830
+ this._sceneColor.dispose();
831
+ this._copyPass.dispose();
832
+ if (this.compiled) this.compiled.dispose();
833
+ }
834
+ }