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.
@@ -79,7 +79,135 @@ export class RealtimeRaytracer {
79
79
  adaptiveQuality: true,
80
80
  };
81
81
  }
82
- return { renderScale: 0.5, denoiseIterations: 3, adaptiveQuality: true };
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
- this._width = Math.floor(size.x * pr);
168
- this._height = Math.floor(size.y * pr);
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 ?? false;
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 ?? false;
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
- /** À-trous iterations (steps 1, 2, 4, ...). */
282
- this.denoiseIterations = options.denoiseIterations ?? 3;
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._width, this._height);
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
- this._width = Math.floor(width);
497
- this._height = Math.floor(height);
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;