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.
@@ -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
  /**
@@ -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
- this._width = Math.floor(size.x * pr);
168
- this._height = Math.floor(size.y * pr);
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 ?? false;
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 ?? false;
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
- /** À-trous iterations (steps 1, 2, 4, ...). */
282
- this.denoiseIterations = options.denoiseIterations ?? 3;
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._width, this._height);
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
- this._width = Math.floor(width);
497
- this._height = Math.floor(height);
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;