three-realtime-rt 0.3.2 → 0.4.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.
- 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 +472 -23
- package/src/RealtimeRaytracer.js +370 -18
- package/src/RestirPass.js +20 -3
- package/src/SceneCompiler.js +127 -32
- package/src/TAAPass.js +20 -4
- package/src/index.d.ts +93 -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
|
/**
|
|
@@ -114,6 +242,72 @@ export class RealtimeRaytracer {
|
|
|
114
242
|
}
|
|
115
243
|
}
|
|
116
244
|
|
|
245
|
+
/**
|
|
246
|
+
* FUNCTIONAL probe: can this context actually DRAW into a 2-attachment
|
|
247
|
+
* half-float MRT? A checkFramebufferStatus probe is not enough — WebKit
|
|
248
|
+
* (every iOS browser) reports the framebuffer complete and then renders
|
|
249
|
+
* black, which killed the whole lighting pass on iPhone/iPad in 0.4.0.
|
|
250
|
+
* So: render one 2-output quad into a tiny fp16 MRT, resolve attachment 0
|
|
251
|
+
* into an RGBA8 target through a sampler, and read the pixel back. Only a
|
|
252
|
+
* round-trip that returns the written value counts as support.
|
|
253
|
+
*/
|
|
254
|
+
static _specMrtSupported(renderer) {
|
|
255
|
+
let mrt, out, mat, copy, quad, scene2, cam;
|
|
256
|
+
const prevTarget = renderer.getRenderTarget();
|
|
257
|
+
try {
|
|
258
|
+
mrt = new THREE.WebGLMultipleRenderTargets(2, 2, 2, {
|
|
259
|
+
format: THREE.RGBAFormat,
|
|
260
|
+
type: THREE.HalfFloatType,
|
|
261
|
+
depthBuffer: false,
|
|
262
|
+
stencilBuffer: false,
|
|
263
|
+
});
|
|
264
|
+
for (const tex of mrt.texture) tex.generateMipmaps = false;
|
|
265
|
+
out = new THREE.WebGLRenderTarget(2, 2, { depthBuffer: false, stencilBuffer: false });
|
|
266
|
+
const vert = `out vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
|
|
267
|
+
mat = new THREE.ShaderMaterial({
|
|
268
|
+
glslVersion: THREE.GLSL3,
|
|
269
|
+
vertexShader: vert,
|
|
270
|
+
fragmentShader: `precision highp float;
|
|
271
|
+
layout(location = 0) out vec4 o0; layout(location = 1) out vec4 o1;
|
|
272
|
+
void main(){ o0 = vec4(0.5, 0.25, 0.75, 1.0); o1 = vec4(0.125); }`,
|
|
273
|
+
depthTest: false,
|
|
274
|
+
depthWrite: false,
|
|
275
|
+
});
|
|
276
|
+
copy = new THREE.ShaderMaterial({
|
|
277
|
+
glslVersion: THREE.GLSL3,
|
|
278
|
+
vertexShader: vert,
|
|
279
|
+
fragmentShader: `precision highp float; in vec2 vUv; out vec4 outColor;
|
|
280
|
+
uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
281
|
+
uniforms: { uTex: { value: mrt.texture[0] } },
|
|
282
|
+
depthTest: false,
|
|
283
|
+
depthWrite: false,
|
|
284
|
+
});
|
|
285
|
+
scene2 = new THREE.Scene();
|
|
286
|
+
cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
287
|
+
quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
|
|
288
|
+
quad.frustumCulled = false;
|
|
289
|
+
scene2.add(quad);
|
|
290
|
+
renderer.setRenderTarget(mrt);
|
|
291
|
+
renderer.render(scene2, cam);
|
|
292
|
+
quad.material = copy;
|
|
293
|
+
renderer.setRenderTarget(out);
|
|
294
|
+
renderer.render(scene2, cam);
|
|
295
|
+
const px = new Uint8Array(4);
|
|
296
|
+
renderer.readRenderTargetPixels(out, 0, 0, 1, 1, px);
|
|
297
|
+
// 0.5 -> ~128; accept a generous tolerance (fp16 + dithering).
|
|
298
|
+
return Math.abs(px[0] - 128) < 24 && Math.abs(px[1] - 64) < 24;
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
} finally {
|
|
302
|
+
renderer.setRenderTarget(prevTarget);
|
|
303
|
+
if (quad) quad.geometry.dispose();
|
|
304
|
+
if (mat) mat.dispose();
|
|
305
|
+
if (copy) copy.dispose();
|
|
306
|
+
if (mrt) mrt.dispose();
|
|
307
|
+
if (out) out.dispose();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
117
311
|
/**
|
|
118
312
|
* Companion settings for a given lighting resolution. LOW resolution wants
|
|
119
313
|
* MORE denoise passes, not fewer — the filter runs at lighting res so extra
|
|
@@ -164,22 +358,61 @@ export class RealtimeRaytracer {
|
|
|
164
358
|
|
|
165
359
|
const size = renderer.getSize(new THREE.Vector2());
|
|
166
360
|
const pr = renderer.getPixelRatio();
|
|
167
|
-
|
|
168
|
-
|
|
361
|
+
// Canvas (on-screen) drawing-buffer size. Everything internal renders at the
|
|
362
|
+
// OVERSCAN-padded size derived from this; the final on-screen draw crops the
|
|
363
|
+
// centre back to it.
|
|
364
|
+
this._canvasW = Math.floor(size.x * pr);
|
|
365
|
+
this._canvasH = Math.floor(size.y * pr);
|
|
366
|
+
/**
|
|
367
|
+
* Overscan: render internally at a padded resolution with a proportionally
|
|
368
|
+
* widened field of view, then crop the centre to the canvas on the final
|
|
369
|
+
* draw. Disocclusion pixels at the leading screen edge during camera motion
|
|
370
|
+
* are then born OFF-screen, hiding their several-frame temporal-convergence
|
|
371
|
+
* noise. Fraction of padding PER EDGE (clamped 0–0.25); 0.1 on a 1000×600
|
|
372
|
+
* canvas renders 1200×720 internally (1.44× the pixels). 0 disables it.
|
|
373
|
+
*/
|
|
374
|
+
this._overscan = Math.min(0.25, Math.max(0, options.overscan ?? 0));
|
|
169
375
|
/**
|
|
170
376
|
* Resolution scale for the ray traced lighting (G-buffer and final image
|
|
171
377
|
* stay full res). 0.5 traces 4x fewer rays; the bilateral upsample +
|
|
172
378
|
* denoiser reconstruct the difference. Set 1.0 for maximum quality.
|
|
173
379
|
*/
|
|
174
380
|
this._renderScale = options.renderScale ?? 0.5;
|
|
381
|
+
// Padded internal render size (canvas × the overscan factor). All passes
|
|
382
|
+
// work here; _crop maps it back to the canvas on the final draw.
|
|
383
|
+
this._width = Math.round(this._canvasW * this._padFactor);
|
|
384
|
+
this._height = Math.round(this._canvasH * this._padFactor);
|
|
385
|
+
// Central-crop UV transform (scale.xy, offset.zw): padded → canvas. Identity
|
|
386
|
+
// when overscan is 0. Recomputed by _updateCrop() on every size/overscan change.
|
|
387
|
+
this._crop = new THREE.Vector4(1, 1, 0, 0);
|
|
388
|
+
this._updateCrop();
|
|
175
389
|
|
|
176
390
|
const mixedPrecision = RealtimeRaytracer._mixedMrtSupported(renderer.getContext());
|
|
177
391
|
if (!mixedPrecision) {
|
|
178
392
|
console.info("three-realtime-rt: mixed fp16/fp32 G-buffer not supported here — using fp32 for all targets.");
|
|
179
393
|
}
|
|
394
|
+
// Functional probe, not just a status check: WebKit (all iOS browsers)
|
|
395
|
+
// reports the 2-attachment fp16 MRT complete but silently renders black,
|
|
396
|
+
// which blanks the whole lighting pass. On such devices the lighting pass
|
|
397
|
+
// runs single-attachment (0.3.x layout): the specular buffer is disabled
|
|
398
|
+
// and blend surfaces degrade to opaque — everything else keeps working.
|
|
399
|
+
this.specMRTSupported = RealtimeRaytracer._specMrtSupported(renderer);
|
|
400
|
+
if (!this.specMRTSupported) {
|
|
401
|
+
console.info(
|
|
402
|
+
"three-realtime-rt: multi-attachment lighting buffer failed the draw probe here " +
|
|
403
|
+
"(WebKit/iOS) — specular buffer disabled, alpha-blend surfaces render opaque."
|
|
404
|
+
);
|
|
405
|
+
}
|
|
180
406
|
this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
|
|
181
|
-
this.rtPass = new RTLightingPass(this._scaledW, this._scaledH
|
|
407
|
+
this.rtPass = new RTLightingPass(this._scaledW, this._scaledH, {
|
|
408
|
+
specMRT: this.specMRTSupported,
|
|
409
|
+
});
|
|
182
410
|
this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
|
|
411
|
+
// Separate à-trous instance for the specular buffer (its own ping-pong
|
|
412
|
+
// targets, so the specular denoise cannot clobber the irradiance result).
|
|
413
|
+
this.specDenoisePass = new DenoisePass(this._scaledW, this._scaledH, {
|
|
414
|
+
blendIsSpec: true, // blend pixels here hold the behind-the-pane image
|
|
415
|
+
});
|
|
183
416
|
this.composite = new CompositePass();
|
|
184
417
|
this.taaPass = new TAAPass(this._width, this._height);
|
|
185
418
|
this._sceneColor = this._makeColorTarget(this._width, this._height);
|
|
@@ -190,7 +423,7 @@ export class RealtimeRaytracer {
|
|
|
190
423
|
this.compiled = null;
|
|
191
424
|
this.frame = 0;
|
|
192
425
|
|
|
193
|
-
/** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive */
|
|
426
|
+
/** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular */
|
|
194
427
|
this.outputMode = 0;
|
|
195
428
|
/** Environment (sky) color used for GI rays that miss + composite background. */
|
|
196
429
|
this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
|
|
@@ -224,28 +457,53 @@ export class RealtimeRaytracer {
|
|
|
224
457
|
* emitters gain direct lighting + shadows. Off = legacy hit-only behaviour.
|
|
225
458
|
*/
|
|
226
459
|
this.emissiveNEE = options.emissiveNEE ?? true;
|
|
460
|
+
/**
|
|
461
|
+
* Importance-sample WHICH emissive triangle NEE shoots at, proportional to
|
|
462
|
+
* area x emitted luminance (a compile-time power CDF), instead of a uniform
|
|
463
|
+
* 1-of-N pick. Same mean, far less sparkle in scenes whose emitters differ
|
|
464
|
+
* in size/brightness. Off = legacy uniform pick (A/B comparison hook).
|
|
465
|
+
*/
|
|
466
|
+
this.emissiveImportance = options.emissiveImportance ?? true;
|
|
467
|
+
/**
|
|
468
|
+
* PBR direct specular: Cook-Torrance GGX highlights for all surfaces, in a
|
|
469
|
+
* separate specular buffer added without the albedo multiply (dielectric
|
|
470
|
+
* highlights are white, F0 ~= 0.04). Off = the old Lambert-only look.
|
|
471
|
+
*/
|
|
472
|
+
this.specular = options.specular ?? true;
|
|
227
473
|
/** Traced mirror/glossy reflections on metallic surfaces. */
|
|
228
474
|
this.reflections = options.reflections ?? true;
|
|
229
475
|
/** Traced refraction for transmissive (MeshPhysicalMaterial.transmission) surfaces. */
|
|
230
476
|
this.refraction = options.refraction ?? true;
|
|
477
|
+
/**
|
|
478
|
+
* Alpha-blended transparency: a `transparent: true` mesh is primary-visible
|
|
479
|
+
* but kept out of the BVH, and the lighting pass traces a straight-through
|
|
480
|
+
* ray to composite the geometry behind it (weighted by `opacity`, tinted by
|
|
481
|
+
* the pane's albedo). Default ON — it fixes silently-wrong behaviour and
|
|
482
|
+
* costs only on blend pixels. Off = blend surfaces render fully opaque.
|
|
483
|
+
*/
|
|
484
|
+
this.transparency = options.transparency ?? true;
|
|
231
485
|
/** Index of refraction used for transmissive surfaces. */
|
|
232
486
|
this.ior = options.ior ?? 1.5;
|
|
233
487
|
/**
|
|
234
488
|
* One stochastic direct shadow ray per pixel per frame (source picked at
|
|
235
489
|
* random) instead of one per light — the biggest ray-count lever for
|
|
236
490
|
* many-light scenes and mobile GPUs. Slightly noisier moving shadows;
|
|
237
|
-
* temporal accumulation + the denoiser absorb it.
|
|
491
|
+
* temporal accumulation + the denoiser absorb it. Defaults ON so the
|
|
492
|
+
* zero-config renderer stays cheap on weak GPUs; the governor turns it off
|
|
493
|
+
* again once it has scaled resolution up on a strong machine.
|
|
238
494
|
*/
|
|
239
|
-
this.stochasticLights = options.stochasticLights ??
|
|
495
|
+
this.stochasticLights = options.stochasticLights ?? true;
|
|
240
496
|
/**
|
|
241
497
|
* Adaptive quality governor: watches the app's real frame time and walks
|
|
242
498
|
* QUALITY_LADDER — degrading when frames run long, cautiously probing a
|
|
243
499
|
* better level when there is headroom (reverting if the probe fails). The
|
|
244
500
|
* portable way to "work well" on unknown hardware. Drives renderScale,
|
|
245
501
|
* denoiseIterations and stochasticLights; setting those manually while
|
|
246
|
-
* enabled will be overridden — turn this off for manual control.
|
|
502
|
+
* enabled will be overridden — turn this off for manual control. Defaults
|
|
503
|
+
* ON so the conservative default resolution scales UP toward targetFps on
|
|
504
|
+
* capable hardware instead of being stuck low.
|
|
247
505
|
*/
|
|
248
|
-
this.adaptiveQuality = options.adaptiveQuality ??
|
|
506
|
+
this.adaptiveQuality = options.adaptiveQuality ?? true;
|
|
249
507
|
/** Frame-rate target for the adaptive governor. */
|
|
250
508
|
this.targetFps = options.targetFps ?? 55;
|
|
251
509
|
/**
|
|
@@ -278,8 +536,12 @@ export class RealtimeRaytracer {
|
|
|
278
536
|
this._canvasLevelIdx = 0;
|
|
279
537
|
/** Edge-aware à-trous denoise on the irradiance buffer. */
|
|
280
538
|
this.denoise = options.denoise ?? true;
|
|
281
|
-
/**
|
|
282
|
-
|
|
539
|
+
/**
|
|
540
|
+
* À-trous iterations (steps 1, 2, 4, ...). Defaults to a lean 2; the
|
|
541
|
+
* adaptive governor raises it (via _qualityFor) as it lowers resolution,
|
|
542
|
+
* where the extra passes run at lighting res and are nearly free.
|
|
543
|
+
*/
|
|
544
|
+
this.denoiseIterations = options.denoiseIterations ?? 2;
|
|
283
545
|
|
|
284
546
|
/**
|
|
285
547
|
* Temporal anti-aliasing: sub-pixel projection jitter + a full-res history
|
|
@@ -423,6 +685,17 @@ export class RealtimeRaytracer {
|
|
|
423
685
|
if (!this.supported) return null;
|
|
424
686
|
if (this.compiled) this.compiled.dispose();
|
|
425
687
|
this.compiled = compileScene(scene, options);
|
|
688
|
+
// Emissive area lights are the noisiest direct-light path: one triangle
|
|
689
|
+
// sample per pixel per frame, and the 1/dist^2 term spikes near a small
|
|
690
|
+
// emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
|
|
691
|
+
// a scene relies on emissive NEE without them. (fireflyClamp and the
|
|
692
|
+
// denoiser absorb the rest; see the README's emissive caveats.)
|
|
693
|
+
if (this.compiled.emissiveTriCount > 0 && this.emissiveNEE && !this.restir) {
|
|
694
|
+
console.info(
|
|
695
|
+
"[three-realtime-rt] this scene has emissive area lights but restir is off — " +
|
|
696
|
+
"emissive NEE alone is the noisiest sampling path; enable restir for a large noise win."
|
|
697
|
+
);
|
|
698
|
+
}
|
|
426
699
|
if (this._autoEps) {
|
|
427
700
|
// ~1/1000 of the scene diagonal, floored at the classic 1e-3.
|
|
428
701
|
this.eps = Math.min(Math.max(1e-3, this.compiled.sceneDiagonal * 1.2e-3), 0.05);
|
|
@@ -463,6 +736,23 @@ export class RealtimeRaytracer {
|
|
|
463
736
|
if (this.taaPass) this.taaPass.reset();
|
|
464
737
|
}
|
|
465
738
|
|
|
739
|
+
// Padded/canvas size ratio per axis: 1 + 2×overscan (padding is per edge).
|
|
740
|
+
get _padFactor() {
|
|
741
|
+
return 1 + 2 * this._overscan;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Recompute the central-crop transform that maps the padded internal image
|
|
745
|
+
// back to the canvas on the final draw. UV: canvas_uv × scale + offset →
|
|
746
|
+
// padded_uv, sampling the central canvas-sized region. Identity at overscan 0.
|
|
747
|
+
_updateCrop() {
|
|
748
|
+
this._crop.set(
|
|
749
|
+
this._canvasW / this._width,
|
|
750
|
+
this._canvasH / this._height,
|
|
751
|
+
(this._width - this._canvasW) * 0.5 / this._width,
|
|
752
|
+
(this._height - this._canvasH) * 0.5 / this._height
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
466
756
|
get _scaledW() {
|
|
467
757
|
return Math.max(1, Math.floor(this._width * this._renderScale));
|
|
468
758
|
}
|
|
@@ -481,7 +771,20 @@ export class RealtimeRaytracer {
|
|
|
481
771
|
}
|
|
482
772
|
set renderScale(v) {
|
|
483
773
|
this._renderScale = v;
|
|
484
|
-
this.setSize(this.
|
|
774
|
+
this.setSize(this._canvasW, this._canvasH);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
get overscan() {
|
|
778
|
+
return this._overscan;
|
|
779
|
+
}
|
|
780
|
+
// Live-assignable like renderScale. Changing the padded size reallocates every
|
|
781
|
+
// pass, so this hard-resets accumulation (a settings-time knob, not per-frame).
|
|
782
|
+
set overscan(v) {
|
|
783
|
+
const c = Math.min(0.25, Math.max(0, v || 0));
|
|
784
|
+
if (c === this._overscan) return;
|
|
785
|
+
this._overscan = c;
|
|
786
|
+
this.setSize(this._canvasW, this._canvasH);
|
|
787
|
+
this.resetAccumulation();
|
|
485
788
|
}
|
|
486
789
|
|
|
487
790
|
// Resize (or re-scale) the pipeline WITHOUT dumping temporal history. A
|
|
@@ -493,8 +796,14 @@ export class RealtimeRaytracer {
|
|
|
493
796
|
// updated (the renderScale setter changes it before calling us).
|
|
494
797
|
setSize(width, height) {
|
|
495
798
|
if (!this.supported) return;
|
|
496
|
-
|
|
497
|
-
|
|
799
|
+
// Arguments are the CANVAS (on-screen) size; the internal targets are the
|
|
800
|
+
// overscan-padded size derived from it. The only place the two diverge is
|
|
801
|
+
// the final on-screen crop (see _crop / _updateCrop).
|
|
802
|
+
this._canvasW = Math.floor(width);
|
|
803
|
+
this._canvasH = Math.floor(height);
|
|
804
|
+
this._width = Math.round(this._canvasW * this._padFactor);
|
|
805
|
+
this._height = Math.round(this._canvasH * this._padFactor);
|
|
806
|
+
this._updateCrop();
|
|
498
807
|
|
|
499
808
|
const sw = this._scaledW;
|
|
500
809
|
const sh = this._scaledH;
|
|
@@ -516,6 +825,7 @@ export class RealtimeRaytracer {
|
|
|
516
825
|
RealtimeRaytracer.HISTORY_CARRY_FRAMES
|
|
517
826
|
);
|
|
518
827
|
this.denoisePass.setSize(sw, sh); // display-only, no temporal state
|
|
828
|
+
this.specDenoisePass.setSize(sw, sh); // ditto; spec history lives in rtPass
|
|
519
829
|
// ReSTIR reservoirs store packed id·64+M encodings — invalid to linearly
|
|
520
830
|
// resample — but they reconverge in a few frames, so just reallocate and
|
|
521
831
|
// clear them.
|
|
@@ -646,8 +956,22 @@ export class RealtimeRaytracer {
|
|
|
646
956
|
// samples slightly different positions; the TAA resolve averages them into
|
|
647
957
|
// supersampled edges. Restored after the frame so callers see a clean matrix.
|
|
648
958
|
const proj = camera.projectionMatrix;
|
|
959
|
+
const savedProj0 = proj.elements[0];
|
|
960
|
+
const savedProj5 = proj.elements[5];
|
|
649
961
|
const savedProj8 = proj.elements[8];
|
|
650
962
|
const savedProj9 = proj.elements[9];
|
|
963
|
+
// Overscan: widen the frustum so the padded image covers proportionally
|
|
964
|
+
// more FOV. Scaling elements[0]/[5] (the x/y projection scale) by 1/padFactor
|
|
965
|
+
// makes each axis see (1 + 2·overscan)× as much — the extra content lands in
|
|
966
|
+
// the padding that the final crop discards. Applied like the TAA jitter
|
|
967
|
+
// (temporary; the caller's matrix is restored at frame end), and BEFORE the
|
|
968
|
+
// jitter so the two compose. Previous-frame matrices captured below are the
|
|
969
|
+
// widened ones, keeping temporal reprojection consistent in padded space.
|
|
970
|
+
if (this._overscan > 0) {
|
|
971
|
+
const inv = 1 / this._padFactor;
|
|
972
|
+
proj.elements[0] *= inv;
|
|
973
|
+
proj.elements[5] *= inv;
|
|
974
|
+
}
|
|
651
975
|
// Debug views (outputMode != 0) bypass the TAA resolve, so skip the jitter
|
|
652
976
|
// too — otherwise the raw buffers visibly shake.
|
|
653
977
|
if (this.taa && this.outputMode === 0) {
|
|
@@ -692,8 +1016,10 @@ export class RealtimeRaytracer {
|
|
|
692
1016
|
rtU.uGIEnabled.value = this.gi;
|
|
693
1017
|
rtU.uGIHalfRate.value = this.giHalfRate;
|
|
694
1018
|
rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
|
|
1019
|
+
rtU.uEmissiveCDF.value = this.emissiveImportance;
|
|
695
1020
|
rtU.uReflEnabled.value = this.reflections;
|
|
696
1021
|
rtU.uRefrEnabled.value = this.refraction;
|
|
1022
|
+
rtU.uBlendEnabled.value = this.transparency;
|
|
697
1023
|
rtU.uIor.value = this.ior;
|
|
698
1024
|
rtU.uLightStochastic.value = this.stochasticLights;
|
|
699
1025
|
rtU.uSkyEnabled.value = this.sky.enabled;
|
|
@@ -722,7 +1048,7 @@ export class RealtimeRaytracer {
|
|
|
722
1048
|
this.eps
|
|
723
1049
|
);
|
|
724
1050
|
}
|
|
725
|
-
let irradiance = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
|
|
1051
|
+
let { irradiance, specular } = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
|
|
726
1052
|
|
|
727
1053
|
// 3. denoise (display-only: history keeps accumulating raw samples)
|
|
728
1054
|
if (this.denoise && this.denoiseIterations > 0) {
|
|
@@ -736,6 +1062,21 @@ export class RealtimeRaytracer {
|
|
|
736
1062
|
);
|
|
737
1063
|
}
|
|
738
1064
|
|
|
1065
|
+
// 3a. light denoise on the specular buffer. specKeep (DenoisePass) already
|
|
1066
|
+
// spares near-mirror pixels, so reflections stay crisp; capped at 2 passes
|
|
1067
|
+
// to avoid washing out sharp dielectric highlights.
|
|
1068
|
+
let specularTex = this.specular ? specular : null;
|
|
1069
|
+
if (specularTex && this.denoise && this.denoiseIterations > 0) {
|
|
1070
|
+
specularTex = this.specDenoisePass.render(
|
|
1071
|
+
this.renderer,
|
|
1072
|
+
specularTex,
|
|
1073
|
+
this.gbuffer,
|
|
1074
|
+
this._camWorldPos,
|
|
1075
|
+
this.eps,
|
|
1076
|
+
Math.min(this.denoiseIterations, 2)
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
739
1080
|
// 3b. volumetric single-scatter (optional): one BVH-shadowed light sample
|
|
740
1081
|
// per lighting pixel along the camera ray, accumulated temporally. The
|
|
741
1082
|
// composite adds the result before fog and tonemap.
|
|
@@ -783,12 +1124,17 @@ export class RealtimeRaytracer {
|
|
|
783
1124
|
cU.uVolumetric.value = volumetricTex;
|
|
784
1125
|
cU.uVolEnabled.value = volumetricTex !== null;
|
|
785
1126
|
cU.uVolTexelSize.value.set(1 / this._volW, 1 / this._volH);
|
|
1127
|
+
// With TAA on we composite into the padded offscreen target (no crop — the
|
|
1128
|
+
// TAA copy crops on its way to screen). Without TAA the composite IS the
|
|
1129
|
+
// on-screen draw, so it applies the central crop itself.
|
|
786
1130
|
this.composite.render(
|
|
787
1131
|
this.renderer,
|
|
788
1132
|
irradiance,
|
|
789
1133
|
this.gbuffer,
|
|
790
1134
|
scene.background,
|
|
791
|
-
useTaa ? this._sceneColor : null
|
|
1135
|
+
useTaa ? this._sceneColor : null,
|
|
1136
|
+
specularTex,
|
|
1137
|
+
useTaa ? null : this._crop
|
|
792
1138
|
);
|
|
793
1139
|
|
|
794
1140
|
// 5. temporal anti-aliasing resolve (jitter + neighbourhood-clamped history).
|
|
@@ -800,7 +1146,9 @@ export class RealtimeRaytracer {
|
|
|
800
1146
|
this._prevViewProj, // last frame's jittered VP
|
|
801
1147
|
this._jitterUv,
|
|
802
1148
|
this._prevJitterUv,
|
|
803
|
-
this.taaBlend
|
|
1149
|
+
this.taaBlend,
|
|
1150
|
+
null, // outputTarget: null = screen (the final on-screen draw)
|
|
1151
|
+
this._crop // central-crop the padded resolve onto the canvas
|
|
804
1152
|
);
|
|
805
1153
|
} else if (this.taa) {
|
|
806
1154
|
// In a debug view: keep history fresh so switching back doesn't ghost.
|
|
@@ -809,7 +1157,10 @@ export class RealtimeRaytracer {
|
|
|
809
1157
|
|
|
810
1158
|
this.renderer.autoClear = prevAutoClear;
|
|
811
1159
|
|
|
812
|
-
// Restore the caller's projection matrix (remove this frame's jitter
|
|
1160
|
+
// Restore the caller's projection matrix (remove this frame's jitter + the
|
|
1161
|
+
// overscan widening) so callers always see their own clean matrix.
|
|
1162
|
+
proj.elements[0] = savedProj0;
|
|
1163
|
+
proj.elements[5] = savedProj5;
|
|
813
1164
|
proj.elements[8] = savedProj8;
|
|
814
1165
|
proj.elements[9] = savedProj9;
|
|
815
1166
|
|
|
@@ -823,6 +1174,7 @@ export class RealtimeRaytracer {
|
|
|
823
1174
|
this.gbuffer.dispose();
|
|
824
1175
|
this.rtPass.dispose();
|
|
825
1176
|
this.denoisePass.dispose();
|
|
1177
|
+
this.specDenoisePass.dispose();
|
|
826
1178
|
this.composite.dispose();
|
|
827
1179
|
this.taaPass.dispose();
|
|
828
1180
|
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;
|