three-realtime-rt 0.3.2 → 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 +187 -19
- package/package.json +1 -1
- package/src/CompositePass.js +59 -21
- package/src/DenoisePass.js +16 -2
- package/src/GBufferPass.js +84 -22
- package/src/RTLightingPass.js +411 -17
- package/src/RealtimeRaytracer.js +288 -16
- package/src/RestirPass.js +20 -3
- package/src/SceneCompiler.js +127 -32
- package/src/TAAPass.js +20 -4
- package/src/index.d.ts +87 -4
package/src/RealtimeRaytracer.js
CHANGED
|
@@ -79,7 +79,135 @@ export class RealtimeRaytracer {
|
|
|
79
79
|
adaptiveQuality: true,
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
// High: full per-light direct shadows (stochasticLights pinned false).
|
|
83
|
+
// Pinned explicitly because the constructor default is now `true` (the
|
|
84
|
+
// conservative-default change) — high must keep its original behaviour.
|
|
85
|
+
return { renderScale: 0.5, denoiseIterations: 3, stochasticLights: false, adaptiveQuality: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* GPU tier probe. An OPTIONAL, async companion to {@link detectTier}: when the
|
|
90
|
+
* browser exposes WebGPU it inspects the real adapter limits, otherwise it
|
|
91
|
+
* falls back to the WebGL heuristic. Returns
|
|
92
|
+
* `{ tier: "none"|"mid"|"high", source: "webgpu"|"webgl"|"fallback", details }`.
|
|
93
|
+
*
|
|
94
|
+
* const probe = await RealtimeRaytracer.probeGPUTier();
|
|
95
|
+
* const rt = new RealtimeRaytracer(renderer, RealtimeRaytracer.recommendedOptions(probe.tier));
|
|
96
|
+
*
|
|
97
|
+
* HONEST-HEURISTIC CAVEAT: WebGPU does NOT expose VRAM. `adapter.limits`
|
|
98
|
+
* advertises binding/allocation ceilings (`maxBufferSize` etc.), which are a
|
|
99
|
+
* driver-reported proxy for "how beefy is this GPU", not a memory size — a
|
|
100
|
+
* card with 8GB and a card with 24GB can report the identical 2GB
|
|
101
|
+
* `maxBufferSize`. `adapter.info` (vendor/architecture/description) is masked
|
|
102
|
+
* on many browsers for fingerprinting reasons, so it is treated as a hint
|
|
103
|
+
* only. The classification is therefore deliberately coarse.
|
|
104
|
+
*
|
|
105
|
+
* Classification (WebGPU path), all thresholds echoed back in `details`:
|
|
106
|
+
* - Software signature in adapter.info (swiftshader / llvmpipe / "basic
|
|
107
|
+
* render" / paravirtual) → `"none"`.
|
|
108
|
+
* - "strong" = `maxBufferSize >= 2GiB` AND `maxTextureDimension2D >= 16384`
|
|
109
|
+
* (integrated GPUs typically report an 8192 texture limit and a smaller
|
|
110
|
+
* buffer ceiling).
|
|
111
|
+
* - Screen-demand factor: `screenPixels = screen.width * screen.height *
|
|
112
|
+
* min(devicePixelRatio, 2)` (DPR clamped so a 3× phone doesn't read as a
|
|
113
|
+
* workstation). A 4K-class panel (`screenPixels >= 6e6`) has to fill ~4×
|
|
114
|
+
* the pixels of 1080p for the same lighting resolution, so a "strong" GPU
|
|
115
|
+
* is only called `"high"` on such a screen when it ALSO clears a 4GiB
|
|
116
|
+
* `maxBufferSize` bar; otherwise it is demoted to `"mid"`.
|
|
117
|
+
* - strong (clearing the screen bar) → `"high"`, else → `"mid"`.
|
|
118
|
+
*
|
|
119
|
+
* No WebGPU (or requestAdapter fails) → the existing {@link detectTier} WebGL
|
|
120
|
+
* heuristic, `source: "webgl"` when a renderer was supplied to probe, or
|
|
121
|
+
* `source: "fallback"` when the tier is a pure user-agent guess (no context).
|
|
122
|
+
*/
|
|
123
|
+
static async probeGPUTier(renderer) {
|
|
124
|
+
const GiB = 1024 * 1024 * 1024;
|
|
125
|
+
const details = {};
|
|
126
|
+
|
|
127
|
+
// Screen-demand factor (used by every path). DPR clamped to 2 so a phone's
|
|
128
|
+
// 3×/4× ratio doesn't inflate demand past what its tiny CSS area needs.
|
|
129
|
+
const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
|
|
130
|
+
const scr =
|
|
131
|
+
typeof window !== "undefined" && window.screen
|
|
132
|
+
? window.screen
|
|
133
|
+
: { width: 1920, height: 1080 };
|
|
134
|
+
const screenPixels = Math.round(scr.width * scr.height * Math.min(dpr, 2));
|
|
135
|
+
const demanding = screenPixels >= 6e6; // 4K-class panel
|
|
136
|
+
details.screenPixels = screenPixels;
|
|
137
|
+
details.demanding = demanding;
|
|
138
|
+
|
|
139
|
+
if (typeof navigator !== "undefined" && navigator.gpu) {
|
|
140
|
+
try {
|
|
141
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
142
|
+
if (adapter) {
|
|
143
|
+
const L = adapter.limits || {};
|
|
144
|
+
const maxBufferSize = Number(L.maxBufferSize || 0);
|
|
145
|
+
const maxStorageBufferBindingSize = Number(L.maxStorageBufferBindingSize || 0);
|
|
146
|
+
const maxTextureDimension2D = Number(L.maxTextureDimension2D || 0);
|
|
147
|
+
const maxComputeWorkgroupStorageSize = Number(L.maxComputeWorkgroupStorageSize || 0);
|
|
148
|
+
Object.assign(details, {
|
|
149
|
+
maxBufferSize,
|
|
150
|
+
maxStorageBufferBindingSize,
|
|
151
|
+
maxTextureDimension2D,
|
|
152
|
+
maxComputeWorkgroupStorageSize,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// adapter.info is a plain property in newer specs; older Chromium
|
|
156
|
+
// exposed it via the async requestAdapterInfo(). Both are masked on
|
|
157
|
+
// some browsers — treat any of it as an optional hint.
|
|
158
|
+
let info = {};
|
|
159
|
+
try {
|
|
160
|
+
info =
|
|
161
|
+
adapter.info ||
|
|
162
|
+
(adapter.requestAdapterInfo ? await adapter.requestAdapterInfo() : {}) ||
|
|
163
|
+
{};
|
|
164
|
+
} catch {
|
|
165
|
+
info = {};
|
|
166
|
+
}
|
|
167
|
+
details.vendor = info.vendor || null;
|
|
168
|
+
details.architecture = info.architecture || null;
|
|
169
|
+
details.description = info.description || null;
|
|
170
|
+
const infoStr = `${info.vendor || ""} ${info.architecture || ""} ${
|
|
171
|
+
info.description || ""
|
|
172
|
+
} ${info.device || ""}`.toLowerCase();
|
|
173
|
+
|
|
174
|
+
if (/swiftshader|llvmpipe|software|basic render|microsoft basic|paravirtual/.test(infoStr)) {
|
|
175
|
+
details.reason = "software renderer signature in adapter.info";
|
|
176
|
+
return { tier: "none", source: "webgpu", details };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const strong = maxBufferSize >= 2 * GiB && maxTextureDimension2D >= 16384;
|
|
180
|
+
const hugeBuffer = maxBufferSize >= 4 * GiB;
|
|
181
|
+
let tier;
|
|
182
|
+
if (strong && (!demanding || hugeBuffer)) {
|
|
183
|
+
tier = "high";
|
|
184
|
+
details.reason =
|
|
185
|
+
demanding && hugeBuffer
|
|
186
|
+
? "strong limits + >=4GiB buffer clears 4K-class screen demand -> high"
|
|
187
|
+
: "large buffer + textures -> high";
|
|
188
|
+
} else if (strong && demanding) {
|
|
189
|
+
tier = "mid";
|
|
190
|
+
details.reason =
|
|
191
|
+
"strong limits but 4K-class screen without a >=4GiB buffer budget -> mid";
|
|
192
|
+
} else {
|
|
193
|
+
tier = "mid";
|
|
194
|
+
details.reason = "modest adapter limits -> mid";
|
|
195
|
+
}
|
|
196
|
+
return { tier, source: "webgpu", details };
|
|
197
|
+
}
|
|
198
|
+
details.reason = "navigator.gpu present but requestAdapter returned no adapter";
|
|
199
|
+
} catch (err) {
|
|
200
|
+
details.error = String((err && err.message) || err);
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
details.reason = "no navigator.gpu (WebGPU unavailable)";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// WebGL heuristic fallback. With a renderer we actually probe the GL
|
|
207
|
+
// context (source "webgl"); without one the tier is a pure user-agent guess
|
|
208
|
+
// (source "fallback").
|
|
209
|
+
const tier = RealtimeRaytracer.detectTier(renderer);
|
|
210
|
+
return { tier, source: renderer ? "webgl" : "fallback", details };
|
|
83
211
|
}
|
|
84
212
|
|
|
85
213
|
/**
|
|
@@ -164,14 +292,34 @@ export class RealtimeRaytracer {
|
|
|
164
292
|
|
|
165
293
|
const size = renderer.getSize(new THREE.Vector2());
|
|
166
294
|
const pr = renderer.getPixelRatio();
|
|
167
|
-
|
|
168
|
-
|
|
295
|
+
// Canvas (on-screen) drawing-buffer size. Everything internal renders at the
|
|
296
|
+
// OVERSCAN-padded size derived from this; the final on-screen draw crops the
|
|
297
|
+
// centre back to it.
|
|
298
|
+
this._canvasW = Math.floor(size.x * pr);
|
|
299
|
+
this._canvasH = Math.floor(size.y * pr);
|
|
300
|
+
/**
|
|
301
|
+
* Overscan: render internally at a padded resolution with a proportionally
|
|
302
|
+
* widened field of view, then crop the centre to the canvas on the final
|
|
303
|
+
* draw. Disocclusion pixels at the leading screen edge during camera motion
|
|
304
|
+
* are then born OFF-screen, hiding their several-frame temporal-convergence
|
|
305
|
+
* noise. Fraction of padding PER EDGE (clamped 0–0.25); 0.1 on a 1000×600
|
|
306
|
+
* canvas renders 1200×720 internally (1.44× the pixels). 0 disables it.
|
|
307
|
+
*/
|
|
308
|
+
this._overscan = Math.min(0.25, Math.max(0, options.overscan ?? 0));
|
|
169
309
|
/**
|
|
170
310
|
* Resolution scale for the ray traced lighting (G-buffer and final image
|
|
171
311
|
* stay full res). 0.5 traces 4x fewer rays; the bilateral upsample +
|
|
172
312
|
* denoiser reconstruct the difference. Set 1.0 for maximum quality.
|
|
173
313
|
*/
|
|
174
314
|
this._renderScale = options.renderScale ?? 0.5;
|
|
315
|
+
// Padded internal render size (canvas × the overscan factor). All passes
|
|
316
|
+
// work here; _crop maps it back to the canvas on the final draw.
|
|
317
|
+
this._width = Math.round(this._canvasW * this._padFactor);
|
|
318
|
+
this._height = Math.round(this._canvasH * this._padFactor);
|
|
319
|
+
// Central-crop UV transform (scale.xy, offset.zw): padded → canvas. Identity
|
|
320
|
+
// when overscan is 0. Recomputed by _updateCrop() on every size/overscan change.
|
|
321
|
+
this._crop = new THREE.Vector4(1, 1, 0, 0);
|
|
322
|
+
this._updateCrop();
|
|
175
323
|
|
|
176
324
|
const mixedPrecision = RealtimeRaytracer._mixedMrtSupported(renderer.getContext());
|
|
177
325
|
if (!mixedPrecision) {
|
|
@@ -180,6 +328,11 @@ export class RealtimeRaytracer {
|
|
|
180
328
|
this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
|
|
181
329
|
this.rtPass = new RTLightingPass(this._scaledW, this._scaledH);
|
|
182
330
|
this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
|
|
331
|
+
// Separate à-trous instance for the specular buffer (its own ping-pong
|
|
332
|
+
// targets, so the specular denoise cannot clobber the irradiance result).
|
|
333
|
+
this.specDenoisePass = new DenoisePass(this._scaledW, this._scaledH, {
|
|
334
|
+
blendIsSpec: true, // blend pixels here hold the behind-the-pane image
|
|
335
|
+
});
|
|
183
336
|
this.composite = new CompositePass();
|
|
184
337
|
this.taaPass = new TAAPass(this._width, this._height);
|
|
185
338
|
this._sceneColor = this._makeColorTarget(this._width, this._height);
|
|
@@ -224,28 +377,53 @@ export class RealtimeRaytracer {
|
|
|
224
377
|
* emitters gain direct lighting + shadows. Off = legacy hit-only behaviour.
|
|
225
378
|
*/
|
|
226
379
|
this.emissiveNEE = options.emissiveNEE ?? true;
|
|
380
|
+
/**
|
|
381
|
+
* Importance-sample WHICH emissive triangle NEE shoots at, proportional to
|
|
382
|
+
* area x emitted luminance (a compile-time power CDF), instead of a uniform
|
|
383
|
+
* 1-of-N pick. Same mean, far less sparkle in scenes whose emitters differ
|
|
384
|
+
* in size/brightness. Off = legacy uniform pick (A/B comparison hook).
|
|
385
|
+
*/
|
|
386
|
+
this.emissiveImportance = options.emissiveImportance ?? true;
|
|
387
|
+
/**
|
|
388
|
+
* PBR direct specular: Cook-Torrance GGX highlights for all surfaces, in a
|
|
389
|
+
* separate specular buffer added without the albedo multiply (dielectric
|
|
390
|
+
* highlights are white, F0 ~= 0.04). Off = the old Lambert-only look.
|
|
391
|
+
*/
|
|
392
|
+
this.specular = options.specular ?? true;
|
|
227
393
|
/** Traced mirror/glossy reflections on metallic surfaces. */
|
|
228
394
|
this.reflections = options.reflections ?? true;
|
|
229
395
|
/** Traced refraction for transmissive (MeshPhysicalMaterial.transmission) surfaces. */
|
|
230
396
|
this.refraction = options.refraction ?? true;
|
|
397
|
+
/**
|
|
398
|
+
* Alpha-blended transparency: a `transparent: true` mesh is primary-visible
|
|
399
|
+
* but kept out of the BVH, and the lighting pass traces a straight-through
|
|
400
|
+
* ray to composite the geometry behind it (weighted by `opacity`, tinted by
|
|
401
|
+
* the pane's albedo). Default ON — it fixes silently-wrong behaviour and
|
|
402
|
+
* costs only on blend pixels. Off = blend surfaces render fully opaque.
|
|
403
|
+
*/
|
|
404
|
+
this.transparency = options.transparency ?? true;
|
|
231
405
|
/** Index of refraction used for transmissive surfaces. */
|
|
232
406
|
this.ior = options.ior ?? 1.5;
|
|
233
407
|
/**
|
|
234
408
|
* One stochastic direct shadow ray per pixel per frame (source picked at
|
|
235
409
|
* random) instead of one per light — the biggest ray-count lever for
|
|
236
410
|
* many-light scenes and mobile GPUs. Slightly noisier moving shadows;
|
|
237
|
-
* temporal accumulation + the denoiser absorb it.
|
|
411
|
+
* temporal accumulation + the denoiser absorb it. Defaults ON so the
|
|
412
|
+
* zero-config renderer stays cheap on weak GPUs; the governor turns it off
|
|
413
|
+
* again once it has scaled resolution up on a strong machine.
|
|
238
414
|
*/
|
|
239
|
-
this.stochasticLights = options.stochasticLights ??
|
|
415
|
+
this.stochasticLights = options.stochasticLights ?? true;
|
|
240
416
|
/**
|
|
241
417
|
* Adaptive quality governor: watches the app's real frame time and walks
|
|
242
418
|
* QUALITY_LADDER — degrading when frames run long, cautiously probing a
|
|
243
419
|
* better level when there is headroom (reverting if the probe fails). The
|
|
244
420
|
* portable way to "work well" on unknown hardware. Drives renderScale,
|
|
245
421
|
* denoiseIterations and stochasticLights; setting those manually while
|
|
246
|
-
* enabled will be overridden — turn this off for manual control.
|
|
422
|
+
* enabled will be overridden — turn this off for manual control. Defaults
|
|
423
|
+
* ON so the conservative default resolution scales UP toward targetFps on
|
|
424
|
+
* capable hardware instead of being stuck low.
|
|
247
425
|
*/
|
|
248
|
-
this.adaptiveQuality = options.adaptiveQuality ??
|
|
426
|
+
this.adaptiveQuality = options.adaptiveQuality ?? true;
|
|
249
427
|
/** Frame-rate target for the adaptive governor. */
|
|
250
428
|
this.targetFps = options.targetFps ?? 55;
|
|
251
429
|
/**
|
|
@@ -278,8 +456,12 @@ export class RealtimeRaytracer {
|
|
|
278
456
|
this._canvasLevelIdx = 0;
|
|
279
457
|
/** Edge-aware à-trous denoise on the irradiance buffer. */
|
|
280
458
|
this.denoise = options.denoise ?? true;
|
|
281
|
-
/**
|
|
282
|
-
|
|
459
|
+
/**
|
|
460
|
+
* À-trous iterations (steps 1, 2, 4, ...). Defaults to a lean 2; the
|
|
461
|
+
* adaptive governor raises it (via _qualityFor) as it lowers resolution,
|
|
462
|
+
* where the extra passes run at lighting res and are nearly free.
|
|
463
|
+
*/
|
|
464
|
+
this.denoiseIterations = options.denoiseIterations ?? 2;
|
|
283
465
|
|
|
284
466
|
/**
|
|
285
467
|
* Temporal anti-aliasing: sub-pixel projection jitter + a full-res history
|
|
@@ -423,6 +605,17 @@ export class RealtimeRaytracer {
|
|
|
423
605
|
if (!this.supported) return null;
|
|
424
606
|
if (this.compiled) this.compiled.dispose();
|
|
425
607
|
this.compiled = compileScene(scene, options);
|
|
608
|
+
// Emissive area lights are the noisiest direct-light path: one triangle
|
|
609
|
+
// sample per pixel per frame, and the 1/dist^2 term spikes near a small
|
|
610
|
+
// emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
|
|
611
|
+
// a scene relies on emissive NEE without them. (fireflyClamp and the
|
|
612
|
+
// denoiser absorb the rest; see the README's emissive caveats.)
|
|
613
|
+
if (this.compiled.emissiveTriCount > 0 && this.emissiveNEE && !this.restir) {
|
|
614
|
+
console.info(
|
|
615
|
+
"[three-realtime-rt] this scene has emissive area lights but restir is off — " +
|
|
616
|
+
"emissive NEE alone is the noisiest sampling path; enable restir for a large noise win."
|
|
617
|
+
);
|
|
618
|
+
}
|
|
426
619
|
if (this._autoEps) {
|
|
427
620
|
// ~1/1000 of the scene diagonal, floored at the classic 1e-3.
|
|
428
621
|
this.eps = Math.min(Math.max(1e-3, this.compiled.sceneDiagonal * 1.2e-3), 0.05);
|
|
@@ -463,6 +656,23 @@ export class RealtimeRaytracer {
|
|
|
463
656
|
if (this.taaPass) this.taaPass.reset();
|
|
464
657
|
}
|
|
465
658
|
|
|
659
|
+
// Padded/canvas size ratio per axis: 1 + 2×overscan (padding is per edge).
|
|
660
|
+
get _padFactor() {
|
|
661
|
+
return 1 + 2 * this._overscan;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Recompute the central-crop transform that maps the padded internal image
|
|
665
|
+
// back to the canvas on the final draw. UV: canvas_uv × scale + offset →
|
|
666
|
+
// padded_uv, sampling the central canvas-sized region. Identity at overscan 0.
|
|
667
|
+
_updateCrop() {
|
|
668
|
+
this._crop.set(
|
|
669
|
+
this._canvasW / this._width,
|
|
670
|
+
this._canvasH / this._height,
|
|
671
|
+
(this._width - this._canvasW) * 0.5 / this._width,
|
|
672
|
+
(this._height - this._canvasH) * 0.5 / this._height
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
466
676
|
get _scaledW() {
|
|
467
677
|
return Math.max(1, Math.floor(this._width * this._renderScale));
|
|
468
678
|
}
|
|
@@ -481,7 +691,20 @@ export class RealtimeRaytracer {
|
|
|
481
691
|
}
|
|
482
692
|
set renderScale(v) {
|
|
483
693
|
this._renderScale = v;
|
|
484
|
-
this.setSize(this.
|
|
694
|
+
this.setSize(this._canvasW, this._canvasH);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
get overscan() {
|
|
698
|
+
return this._overscan;
|
|
699
|
+
}
|
|
700
|
+
// Live-assignable like renderScale. Changing the padded size reallocates every
|
|
701
|
+
// pass, so this hard-resets accumulation (a settings-time knob, not per-frame).
|
|
702
|
+
set overscan(v) {
|
|
703
|
+
const c = Math.min(0.25, Math.max(0, v || 0));
|
|
704
|
+
if (c === this._overscan) return;
|
|
705
|
+
this._overscan = c;
|
|
706
|
+
this.setSize(this._canvasW, this._canvasH);
|
|
707
|
+
this.resetAccumulation();
|
|
485
708
|
}
|
|
486
709
|
|
|
487
710
|
// Resize (or re-scale) the pipeline WITHOUT dumping temporal history. A
|
|
@@ -493,8 +716,14 @@ export class RealtimeRaytracer {
|
|
|
493
716
|
// updated (the renderScale setter changes it before calling us).
|
|
494
717
|
setSize(width, height) {
|
|
495
718
|
if (!this.supported) return;
|
|
496
|
-
|
|
497
|
-
|
|
719
|
+
// Arguments are the CANVAS (on-screen) size; the internal targets are the
|
|
720
|
+
// overscan-padded size derived from it. The only place the two diverge is
|
|
721
|
+
// the final on-screen crop (see _crop / _updateCrop).
|
|
722
|
+
this._canvasW = Math.floor(width);
|
|
723
|
+
this._canvasH = Math.floor(height);
|
|
724
|
+
this._width = Math.round(this._canvasW * this._padFactor);
|
|
725
|
+
this._height = Math.round(this._canvasH * this._padFactor);
|
|
726
|
+
this._updateCrop();
|
|
498
727
|
|
|
499
728
|
const sw = this._scaledW;
|
|
500
729
|
const sh = this._scaledH;
|
|
@@ -516,6 +745,7 @@ export class RealtimeRaytracer {
|
|
|
516
745
|
RealtimeRaytracer.HISTORY_CARRY_FRAMES
|
|
517
746
|
);
|
|
518
747
|
this.denoisePass.setSize(sw, sh); // display-only, no temporal state
|
|
748
|
+
this.specDenoisePass.setSize(sw, sh); // ditto; spec history lives in rtPass
|
|
519
749
|
// ReSTIR reservoirs store packed id·64+M encodings — invalid to linearly
|
|
520
750
|
// resample — but they reconverge in a few frames, so just reallocate and
|
|
521
751
|
// clear them.
|
|
@@ -646,8 +876,22 @@ export class RealtimeRaytracer {
|
|
|
646
876
|
// samples slightly different positions; the TAA resolve averages them into
|
|
647
877
|
// supersampled edges. Restored after the frame so callers see a clean matrix.
|
|
648
878
|
const proj = camera.projectionMatrix;
|
|
879
|
+
const savedProj0 = proj.elements[0];
|
|
880
|
+
const savedProj5 = proj.elements[5];
|
|
649
881
|
const savedProj8 = proj.elements[8];
|
|
650
882
|
const savedProj9 = proj.elements[9];
|
|
883
|
+
// Overscan: widen the frustum so the padded image covers proportionally
|
|
884
|
+
// more FOV. Scaling elements[0]/[5] (the x/y projection scale) by 1/padFactor
|
|
885
|
+
// makes each axis see (1 + 2·overscan)× as much — the extra content lands in
|
|
886
|
+
// the padding that the final crop discards. Applied like the TAA jitter
|
|
887
|
+
// (temporary; the caller's matrix is restored at frame end), and BEFORE the
|
|
888
|
+
// jitter so the two compose. Previous-frame matrices captured below are the
|
|
889
|
+
// widened ones, keeping temporal reprojection consistent in padded space.
|
|
890
|
+
if (this._overscan > 0) {
|
|
891
|
+
const inv = 1 / this._padFactor;
|
|
892
|
+
proj.elements[0] *= inv;
|
|
893
|
+
proj.elements[5] *= inv;
|
|
894
|
+
}
|
|
651
895
|
// Debug views (outputMode != 0) bypass the TAA resolve, so skip the jitter
|
|
652
896
|
// too — otherwise the raw buffers visibly shake.
|
|
653
897
|
if (this.taa && this.outputMode === 0) {
|
|
@@ -692,8 +936,10 @@ export class RealtimeRaytracer {
|
|
|
692
936
|
rtU.uGIEnabled.value = this.gi;
|
|
693
937
|
rtU.uGIHalfRate.value = this.giHalfRate;
|
|
694
938
|
rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
|
|
939
|
+
rtU.uEmissiveCDF.value = this.emissiveImportance;
|
|
695
940
|
rtU.uReflEnabled.value = this.reflections;
|
|
696
941
|
rtU.uRefrEnabled.value = this.refraction;
|
|
942
|
+
rtU.uBlendEnabled.value = this.transparency;
|
|
697
943
|
rtU.uIor.value = this.ior;
|
|
698
944
|
rtU.uLightStochastic.value = this.stochasticLights;
|
|
699
945
|
rtU.uSkyEnabled.value = this.sky.enabled;
|
|
@@ -722,7 +968,7 @@ export class RealtimeRaytracer {
|
|
|
722
968
|
this.eps
|
|
723
969
|
);
|
|
724
970
|
}
|
|
725
|
-
let irradiance = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
|
|
971
|
+
let { irradiance, specular } = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
|
|
726
972
|
|
|
727
973
|
// 3. denoise (display-only: history keeps accumulating raw samples)
|
|
728
974
|
if (this.denoise && this.denoiseIterations > 0) {
|
|
@@ -736,6 +982,21 @@ export class RealtimeRaytracer {
|
|
|
736
982
|
);
|
|
737
983
|
}
|
|
738
984
|
|
|
985
|
+
// 3a. light denoise on the specular buffer. specKeep (DenoisePass) already
|
|
986
|
+
// spares near-mirror pixels, so reflections stay crisp; capped at 2 passes
|
|
987
|
+
// to avoid washing out sharp dielectric highlights.
|
|
988
|
+
let specularTex = this.specular ? specular : null;
|
|
989
|
+
if (specularTex && this.denoise && this.denoiseIterations > 0) {
|
|
990
|
+
specularTex = this.specDenoisePass.render(
|
|
991
|
+
this.renderer,
|
|
992
|
+
specularTex,
|
|
993
|
+
this.gbuffer,
|
|
994
|
+
this._camWorldPos,
|
|
995
|
+
this.eps,
|
|
996
|
+
Math.min(this.denoiseIterations, 2)
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
739
1000
|
// 3b. volumetric single-scatter (optional): one BVH-shadowed light sample
|
|
740
1001
|
// per lighting pixel along the camera ray, accumulated temporally. The
|
|
741
1002
|
// composite adds the result before fog and tonemap.
|
|
@@ -783,12 +1044,17 @@ export class RealtimeRaytracer {
|
|
|
783
1044
|
cU.uVolumetric.value = volumetricTex;
|
|
784
1045
|
cU.uVolEnabled.value = volumetricTex !== null;
|
|
785
1046
|
cU.uVolTexelSize.value.set(1 / this._volW, 1 / this._volH);
|
|
1047
|
+
// With TAA on we composite into the padded offscreen target (no crop — the
|
|
1048
|
+
// TAA copy crops on its way to screen). Without TAA the composite IS the
|
|
1049
|
+
// on-screen draw, so it applies the central crop itself.
|
|
786
1050
|
this.composite.render(
|
|
787
1051
|
this.renderer,
|
|
788
1052
|
irradiance,
|
|
789
1053
|
this.gbuffer,
|
|
790
1054
|
scene.background,
|
|
791
|
-
useTaa ? this._sceneColor : null
|
|
1055
|
+
useTaa ? this._sceneColor : null,
|
|
1056
|
+
specularTex,
|
|
1057
|
+
useTaa ? null : this._crop
|
|
792
1058
|
);
|
|
793
1059
|
|
|
794
1060
|
// 5. temporal anti-aliasing resolve (jitter + neighbourhood-clamped history).
|
|
@@ -800,7 +1066,9 @@ export class RealtimeRaytracer {
|
|
|
800
1066
|
this._prevViewProj, // last frame's jittered VP
|
|
801
1067
|
this._jitterUv,
|
|
802
1068
|
this._prevJitterUv,
|
|
803
|
-
this.taaBlend
|
|
1069
|
+
this.taaBlend,
|
|
1070
|
+
null, // outputTarget: null = screen (the final on-screen draw)
|
|
1071
|
+
this._crop // central-crop the padded resolve onto the canvas
|
|
804
1072
|
);
|
|
805
1073
|
} else if (this.taa) {
|
|
806
1074
|
// In a debug view: keep history fresh so switching back doesn't ghost.
|
|
@@ -809,7 +1077,10 @@ export class RealtimeRaytracer {
|
|
|
809
1077
|
|
|
810
1078
|
this.renderer.autoClear = prevAutoClear;
|
|
811
1079
|
|
|
812
|
-
// Restore the caller's projection matrix (remove this frame's jitter
|
|
1080
|
+
// Restore the caller's projection matrix (remove this frame's jitter + the
|
|
1081
|
+
// overscan widening) so callers always see their own clean matrix.
|
|
1082
|
+
proj.elements[0] = savedProj0;
|
|
1083
|
+
proj.elements[5] = savedProj5;
|
|
813
1084
|
proj.elements[8] = savedProj8;
|
|
814
1085
|
proj.elements[9] = savedProj9;
|
|
815
1086
|
|
|
@@ -823,6 +1094,7 @@ export class RealtimeRaytracer {
|
|
|
823
1094
|
this.gbuffer.dispose();
|
|
824
1095
|
this.rtPass.dispose();
|
|
825
1096
|
this.denoisePass.dispose();
|
|
1097
|
+
this.specDenoisePass.dispose();
|
|
826
1098
|
this.composite.dispose();
|
|
827
1099
|
this.taaPass.dispose();
|
|
828
1100
|
this.volumetricPass.dispose();
|
package/src/RestirPass.js
CHANGED
|
@@ -54,6 +54,21 @@ vec4 fetchBlueNoise() {
|
|
|
54
54
|
|
|
55
55
|
float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
|
|
56
56
|
|
|
57
|
+
// Primary-surface roughness, set per pixel in main(). Drives the cheap specular
|
|
58
|
+
// lobe below so reservoirs favour lights that land on a highlight.
|
|
59
|
+
float gRestirRough;
|
|
60
|
+
|
|
61
|
+
// Cheap Blinn-Phong specular lobe for the target pdf ONLY (never shaded with
|
|
62
|
+
// this). Deliberately approximate — no Fresnel or geometry term, one pow() per
|
|
63
|
+
// candidate — since it just biases which light each reservoir keeps. wi points
|
|
64
|
+
// from the surface toward the light. Returns a multiplier boost in [0, ~0.8].
|
|
65
|
+
float restirSpecBoost(vec3 N, vec3 wi, vec3 P) {
|
|
66
|
+
vec3 V = normalize(uCameraPos - P);
|
|
67
|
+
vec3 H = normalize(wi + V);
|
|
68
|
+
float shin = mix(4.0, 256.0, 1.0 - gRestirRough);
|
|
69
|
+
return 0.8 * pow(max(dot(N, H), 0.0), shin);
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
// Unshadowed contribution of candidate (id, uv) at surface (P, N).
|
|
58
73
|
vec3 candidateContribution(float id, vec2 uv, vec3 P, vec3 N) {
|
|
59
74
|
if (id < float(MAX_LIGHTS)) {
|
|
@@ -73,11 +88,11 @@ vec3 candidateContribution(float id, vec2 uv, vec3 P, vec3 N) {
|
|
|
73
88
|
cone = smoothstep(dc.w, posType.w - 2.0, dot(dc.xyz, -d / dl));
|
|
74
89
|
if (cone <= 0.0) return vec3(0.0);
|
|
75
90
|
}
|
|
76
|
-
return colRad.rgb * (cone * NdotL / (dl * dl));
|
|
91
|
+
return colRad.rgb * (cone * NdotL / (dl * dl)) * (1.0 + restirSpecBoost(N, d / dl, P));
|
|
77
92
|
}
|
|
78
93
|
float NdotL = dot(N, -posType.xyz);
|
|
79
94
|
if (NdotL <= 0.0) return vec3(0.0);
|
|
80
|
-
return colRad.rgb * NdotL;
|
|
95
|
+
return colRad.rgb * NdotL * (1.0 + restirSpecBoost(N, -posType.xyz, P));
|
|
81
96
|
}
|
|
82
97
|
int t = (int(id) - MAX_LIGHTS) * 4;
|
|
83
98
|
vec4 t0 = texelFetch(uMaterialsTex, ivec2(t, 1), 0);
|
|
@@ -95,7 +110,7 @@ vec3 candidateContribution(float id, vec2 uv, vec3 P, vec3 N) {
|
|
|
95
110
|
if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
|
|
96
111
|
// Uniform pick within the emissive set happens at candidate level, so the
|
|
97
112
|
// per-triangle contribution uses area only (count folds into pick pdf).
|
|
98
|
-
return vec3(t1.w, t2.w, t3.w) * (cosS * cosL * t0.w / max(d2, 1e-6));
|
|
113
|
+
return vec3(t1.w, t2.w, t3.w) * (cosS * cosL * t0.w / max(d2, 1e-6)) * (1.0 + restirSpecBoost(N, wi, P));
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
// v3: reservoirs select TRIANGLES, not points. The selection target is the
|
|
@@ -137,6 +152,7 @@ void main() {
|
|
|
137
152
|
}
|
|
138
153
|
vec3 P = wp.xyz;
|
|
139
154
|
vec3 N = normalize(texture(uGNormalMetal, vUv).xyz);
|
|
155
|
+
gRestirRough = clamp(wp.w - 1.0, 0.0, 1.0);
|
|
140
156
|
|
|
141
157
|
ivec2 px = ivec2(gl_FragCoord.xy);
|
|
142
158
|
gSeed = uint(px.x) * 3079u + uint(px.y) * 9277u + uint(uFrame) * 26699u;
|
|
@@ -223,6 +239,7 @@ void main() {
|
|
|
223
239
|
}
|
|
224
240
|
vec3 P = wp.xyz;
|
|
225
241
|
vec3 N = normalize(texture(uGNormalMetal, vUv).xyz);
|
|
242
|
+
gRestirRough = clamp(wp.w - 1.0, 0.0, 1.0);
|
|
226
243
|
|
|
227
244
|
ivec2 px = ivec2(gl_FragCoord.xy);
|
|
228
245
|
gSeed = uint(px.x) * 5417u + uint(px.y) * 7907u + uint(uFrame) * 15731u;
|