rayzee 7.20.3 → 7.22.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.
Files changed (42) hide show
  1. package/README.md +75 -4
  2. package/dist/rayzee.es.js +2821 -2854
  3. package/dist/rayzee.umd.js +60 -58
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +9 -2
  6. package/src/PathTracerApp.js +83 -0
  7. package/src/Pipeline/BindingAudit.js +178 -0
  8. package/src/Pipeline/RenderPipeline.js +5 -2
  9. package/src/Processor/BVHBuilder.js +1 -4
  10. package/src/Processor/CameraOptimizer.js +0 -5
  11. package/src/Processor/KernelManager.js +17 -70
  12. package/src/Processor/PackedRayBuffer.js +33 -8
  13. package/src/Processor/QueueManager.js +4 -3
  14. package/src/Processor/SceneProcessor.js +10 -4
  15. package/src/Processor/TreeletOptimizer.js +0 -7
  16. package/src/RenderSettings.js +41 -6
  17. package/src/Stages/ASVGF.js +10 -10
  18. package/src/Stages/BilateralFilter.js +70 -11
  19. package/src/Stages/EdgeFilter.js +44 -6
  20. package/src/Stages/NormalDepth.js +26 -15
  21. package/src/Stages/PathTracer.js +36 -34
  22. package/src/Stages/PathTracerStage.js +15 -46
  23. package/src/TSL/BVHTraversal.js +1 -67
  24. package/src/TSL/CameraRay.js +141 -0
  25. package/src/TSL/Common.js +1 -47
  26. package/src/TSL/DebugKernel.js +4 -5
  27. package/src/TSL/EmissiveSampling.js +40 -44
  28. package/src/TSL/ExtendKernel.js +11 -3
  29. package/src/TSL/FinalWriteKernel.js +1 -14
  30. package/src/TSL/GenerateKernel.js +11 -8
  31. package/src/TSL/LightsCore.js +3 -0
  32. package/src/TSL/LightsDirect.js +4 -4
  33. package/src/TSL/LightsSampling.js +138 -327
  34. package/src/TSL/PathTracerCore.js +2 -2
  35. package/src/TSL/Random.js +2 -2
  36. package/src/TSL/ShadeKernel.js +39 -17
  37. package/src/TSL/SortGlobalKernels.js +52 -7
  38. package/src/TSL/Struct.js +0 -10
  39. package/src/index.js +5 -0
  40. package/src/managers/DenoisingManager.js +13 -0
  41. package/src/managers/EnvironmentManager.js +6 -8
  42. package/src/managers/UniformManager.js +12 -2
@@ -1,4 +1,4 @@
1
- import { EventDispatcher, Color } from 'three';
1
+ import { EventDispatcher, Color, Vector2, MathUtils } from 'three';
2
2
  import { ENGINE_DEFAULTS } from './EngineDefaults.js';
3
3
  import { EngineEvents } from './EngineEvents.js';
4
4
 
@@ -32,6 +32,7 @@ const SETTING_ROUTES = {
32
32
  enableGroundCatcher: { uniform: 'enableGroundCatcher', reset: true },
33
33
  groundCatcherHeight: { uniform: 'groundCatcherHeight', reset: true },
34
34
  globalIlluminationIntensity: { uniform: 'globalIlluminationIntensity', reset: true },
35
+ panoramaLevelHorizon: { uniform: 'panoLevelHorizon', reset: true },
35
36
  enableDOF: { uniform: 'enableDOF', reset: true },
36
37
  focusDistance: { uniform: 'focusDistance', reset: false },
37
38
  focalLength: { uniform: 'focalLength', reset: true },
@@ -56,6 +57,9 @@ const SETTING_ROUTES = {
56
57
 
57
58
  // ── Multi-stage / special handling ────────────────────────────
58
59
 
60
+ cameraProjection: { handler: 'handleCameraProjection', reset: true },
61
+ panoramaLonRange: { handler: 'handlePanoramaLonRange', reset: true },
62
+ panoramaLatRange: { handler: 'handlePanoramaLatRange', reset: true },
59
63
  interactionModeEnabled: { handler: 'handleInteractionModeEnabled', reset: false },
60
64
  maxSamples: { handler: 'handleMaxSamples', reset: false },
61
65
  transparentBackground: { handler: 'handleTransparentBackground' },
@@ -122,13 +126,15 @@ export class RenderSettings extends EventDispatcher {
122
126
  * @param {Object} params.stages - Pipeline stages { pathTracer, compositor, autoExposure, ... }
123
127
  * @param {Function} params.resetCallback - Called to reset accumulation
124
128
  * @param {Function} [params.reconcileCompletion] - Called when completion limits change
129
+ * @param {Object} [params.denoisingManager] - Needed to force ASVGF off under panorama
130
+ * @param {Object} [params.cameraManager] - Needed to force auto-focus manual under panorama
125
131
  */
126
- bind( { stages, renderer, resetCallback, reconcileCompletion } ) {
132
+ bind( params ) {
127
133
 
128
- this._pathTracer = stages.pathTracer;
129
- this._resetCallback = resetCallback;
134
+ this._pathTracer = params.stages.pathTracer;
135
+ this._resetCallback = params.resetCallback;
130
136
  this._delegates = {};
131
- this._handlers = this._buildHandlers( stages, renderer, reconcileCompletion );
137
+ this._handlers = this._buildHandlers( params );
132
138
 
133
139
  }
134
140
 
@@ -136,10 +142,39 @@ export class RenderSettings extends EventDispatcher {
136
142
  * Builds handler functions for multi-stage settings that can't
137
143
  * be routed with a simple uniform forward.
138
144
  */
139
- _buildHandlers( stages, renderer, reconcileCompletion ) {
145
+ _buildHandlers( { stages, renderer, reconcileCompletion, denoisingManager, cameraManager } ) {
146
+
147
+ // UniformManager copies into the existing node, so one scratch vector serves every write.
148
+ const panoScratch = new Vector2();
149
+ const setPanoRange = ( uniform, value ) => {
150
+
151
+ panoScratch.set( value?.[ 0 ] ?? 0, value?.[ 1 ] ?? 0 ).multiplyScalar( MathUtils.DEG2RAD );
152
+ stages.pathTracer?.setUniform( uniform, panoScratch );
153
+
154
+ };
140
155
 
141
156
  return {
142
157
 
158
+ handleCameraProjection: ( value ) => {
159
+
160
+ const isPanorama = value === 'equirectangular';
161
+ stages.pathTracer?.setUniform( 'cameraProjection', isPanorama ? 1 : 0 );
162
+
163
+ if ( ! isPanorama ) return;
164
+
165
+ // ASVGF is driven entirely by MotionVector, which unprojects through
166
+ // projectionMatrixInverse — meaningless once every pixel is its own direction.
167
+ // Fall back to the spatial-only denoiser rather than leaving no strategy.
168
+ if ( denoisingManager?.denoiserStrategy === 'asvgf' ) denoisingManager.setDenoiserStrategy( 'edgeaware' );
169
+ // Auto-focus raycasts via Raycaster.setFromCamera, which only knows the frustum.
170
+ cameraManager?.setAutoFocusMode( 'manual' );
171
+
172
+ },
173
+
174
+ handlePanoramaLonRange: ( value ) => setPanoRange( 'panoLonRange', value ),
175
+
176
+ handlePanoramaLatRange: ( value ) => setPanoRange( 'panoLatRange', value ),
177
+
143
178
  handleTransparentBackground: ( value ) => {
144
179
 
145
180
  stages.pathTracer?.setUniform( 'transparentBackground', value );
@@ -40,7 +40,8 @@ export class ASVGF extends RenderStage {
40
40
  this.renderer = renderer;
41
41
 
42
42
  this.temporalAlpha = uniform( options.temporalAlpha ?? 0.0 );
43
- this.gradientStrength = uniform( options.gradientStrength ?? 0.0 );
43
+ // > 0 is required for firefly rejection, not just anti-lag — see ENGINE_DEFAULTS.
44
+ this.gradientStrength = uniform( options.gradientStrength ?? 1.0 );
44
45
  // σ multiplier for the per-pixel noise floor (NRD luminanceSigmaScale ≈ 2).
45
46
  this.gradientSigmaScale = uniform( options.gradientSigmaScale ?? 2.0 );
46
47
  // Secondary relative floor on the normalised gradient (0 = rely on σ alone).
@@ -380,7 +381,12 @@ export class ASVGF extends RenderStage {
380
381
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
381
382
 
382
383
  const coord = ivec2( gx, gy );
383
- const currentColor = textureLoad( colorTex, coord ).xyz;
384
+ const currentSample = textureLoad( colorTex, coord );
385
+ const currentColor = currentSample.xyz;
386
+ // Coverage from the path tracer — 0 on background rays when
387
+ // transparentBackground is on. Forcing 1.0 here made the whole frame opaque,
388
+ // so a transparent render composited as black.
389
+ const currentAlpha = currentSample.w;
384
390
  const currentAlbedo = textureLoad( albedoTex, coord ).xyz;
385
391
 
386
392
  // Same safeAlbedo on both demod and re-mod sides → exact
@@ -390,7 +396,7 @@ export class ASVGF extends RenderStage {
390
396
 
391
397
  // Defaults = fresh sample (no temporal blend).
392
398
  const demodResult = vec4( currentLighting, 1.0 ).toVar();
393
- const modulatedResult = vec4( currentColor, 1.0 ).toVar();
399
+ const modulatedResult = vec4( currentColor, currentAlpha ).toVar();
394
400
 
395
401
  // forceResetU skips the blend for one frame after asvgf:reset → re-anchors
396
402
  // history to the current (post-reset) scene instead of the stale ping-pong.
@@ -491,7 +497,7 @@ export class ASVGF extends RenderStage {
491
497
  const newHistory = min( prevHistory.add( 1.0 ), maxAccumFrames );
492
498
 
493
499
  demodResult.assign( vec4( blendedLighting, newHistory ) );
494
- modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ), 1.0 ) );
500
+ modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ), currentAlpha ) );
495
501
 
496
502
  } );
497
503
 
@@ -643,12 +649,6 @@ export class ASVGF extends RenderStage {
643
649
 
644
650
  this.on( 'asvgf:reset', () => this.resetTemporalData() );
645
651
 
646
- this.on( 'asvgf:setTemporal', ( data ) => {
647
-
648
- if ( data && data.enabled !== undefined ) this.setTemporalEnabled( data.enabled );
649
-
650
- } );
651
-
652
652
  this.on( 'asvgf:updateParameters', ( data ) => this.updateParameters( data ) );
653
653
 
654
654
  }
@@ -24,7 +24,9 @@ const bilateralWeight = /*@__PURE__*/ wgslFn( `
24
24
  let lumW = exp( -abs( centerLum - sLum ) / sigmaL );
25
25
  // clamp dot to [0,1]: miss-ray normals decode to (-1,-1,-1) with
26
26
  // dot=3 → pow saturates to +inf → inf*0 = NaN. See project_tsl_pitfalls.
27
- let normW = pow( clamp( dot( centerNormal, sNormal ), 0.0, 1.0 ), phiNorm );
27
+ // max(phiNorm, 1e-6): pow(0.0, 0.0) is undefined in WGSL and returned NaN on ~12%
28
+ // of pixels when phiNormal was set to 0.
29
+ let normW = pow( clamp( dot( centerNormal, sNormal ), 0.0, 1.0 ), max( phiNorm, 1e-6 ) );
28
30
  let depW = exp( -abs( centerDepth - sDepth ) / max( centerDepth * phiDep, 0.001 ) );
29
31
  let maxDiff = max( max( abs( centerColor.x - sColor.x ),
30
32
  abs( centerColor.y - sColor.y ) ),
@@ -79,6 +81,8 @@ export class BilateralFilter extends RenderStage {
79
81
  this.stepSizeU = uniform( 1, 'int' );
80
82
  // 1 on the final iteration → multiply by albedo to remodulate.
81
83
  this.isLastIterationU = uniform( 0, 'int' );
84
+ // 1 on pass 0 → seed variance from variance:output; later passes read the carried value.
85
+ this.isFirstIterationU = uniform( 1, 'int' );
82
86
  this.resW = uniform( options.width || 1 );
83
87
  this.resH = uniform( options.height || 1 );
84
88
 
@@ -87,6 +91,8 @@ export class BilateralFilter extends RenderStage {
87
91
  this._shadingNormalTexNode = new TextureNode();
88
92
  this._albedoTexNode = new TextureNode();
89
93
  this._varianceTexNode = new TextureNode();
94
+ // pathtracer:color, read only for its alpha (coverage) on the final pass.
95
+ this._srcAlphaTexNode = new TextureNode();
90
96
 
91
97
  const w = options.width || 1;
92
98
  const h = options.height || 1;
@@ -144,6 +150,7 @@ export class BilateralFilter extends RenderStage {
144
150
  const snTexNode = this._shadingNormalTexNode;
145
151
  const albedoTexNode = this._albedoTexNode;
146
152
  const varTexNode = this._varianceTexNode;
153
+ const srcAlphaTexNode = this._srcAlphaTexNode;
147
154
  const phiColor = this.phiColor;
148
155
  const phiNormal = this.phiNormal;
149
156
  const phiDepth = this.phiDepth;
@@ -151,6 +158,7 @@ export class BilateralFilter extends RenderStage {
151
158
  const spatialVarianceWeight = this.spatialVarianceWeight;
152
159
  const stepSize = this.stepSizeU;
153
160
  const isLastIterationU = this.isLastIterationU;
161
+ const isFirstIterationU = this.isFirstIterationU;
154
162
  const resW = this.resW;
155
163
  const resH = this.resH;
156
164
 
@@ -173,7 +181,8 @@ export class BilateralFilter extends RenderStage {
173
181
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
174
182
 
175
183
  const coord = ivec2( gx, gy );
176
- const centerColor = textureLoad( readTexNode, coord ).xyz;
184
+ const centerSample = textureLoad( readTexNode, coord );
185
+ const centerColor = centerSample.xyz;
177
186
  const centerND = textureLoad( ndTexNode, coord );
178
187
  // Normal edge-stop reads the mapped (shading) normal; depth gate stays geometric.
179
188
  const centerNormal = textureLoad( snTexNode, coord ).xyz.mul( 2.0 ).sub( 1.0 );
@@ -182,22 +191,43 @@ export class BilateralFilter extends RenderStage {
182
191
  const centerSafeAlbedo = max( textureLoad( albedoTexNode, coord ).xyz, vec3( ALBEDO_EPS ) );
183
192
  const centerAlbedoLum = max( luminance( centerSafeAlbedo ), float( ALBEDO_EPS ) ).toVar();
184
193
 
194
+ // Variance is filtered alongside colour and carried between passes in the
195
+ // ping-pong's spare alpha, so sigma_l tightens as the signal smooths. Without
196
+ // it every pass re-read the unfiltered variance:output and filter strength
197
+ // never backed off as the render converged.
198
+ //
199
+ // Stored as the ROOT: converged variance reaches ~1e-6, a half-float subnormal
200
+ // that flushes to zero on some drivers. select() rather than If() — an If
201
+ // around a texture read lets the inactive branch contaminate the result.
202
+ // .z = temporal, .w = spatial 3×3; blending toward max widens sigma_l where
203
+ // history is thin.
204
+ const isFirst = isFirstIterationU.equal( int( 1 ) );
205
+ const seedVariance = ( at ) => {
206
+
207
+ const v = textureLoad( varTexNode, at );
208
+ return mix( v.z, max( v.z, v.w ), spatialVarianceWeight );
209
+
210
+ };
211
+
212
+ const centerVariance = isFirst.select(
213
+ seedVariance( coord ),
214
+ centerSample.w.mul( centerSample.w )
215
+ ).toVar();
216
+
185
217
  // sigma_l = phiLum * √variance / albedoLum + ε. Dividing by
186
218
  // albedoLum compensates for the 1/albedo noise amplification
187
219
  // from demodulation — otherwise dark materials get an
188
220
  // under-estimated sigma → over-strict luminance gate → no
189
221
  // blending → silhouette dark-outline artifact.
190
- // .z = temporal variance, .w = spatial (3×3) variance. Blend toward
191
- // max(temporal, spatial) so disoccluded/low-history pixels widen sigma_l.
192
- const vSample = textureLoad( varTexNode, coord );
193
- const variance = mix( vSample.z, max( vSample.z, vSample.w ), spatialVarianceWeight );
194
222
  const sigmaL = phiLuminance
195
- .mul( sqrt( max( variance, float( 0.0 ) ) ) )
223
+ .mul( sqrt( max( centerVariance, float( 0.0 ) ) ) )
196
224
  .div( centerAlbedoLum )
197
225
  .add( float( 0.0001 ) );
198
226
 
199
227
  const colorSum = vec3( 0.0 ).toVar();
200
228
  const weightSum = float( 0.0 ).toVar();
229
+ // Squared weights, normalised by weightSum² — the variance of a weighted mean.
230
+ const varianceSum = float( 0.0 ).toVar();
201
231
 
202
232
  // 5×5 à-trous kernel (Gaussian-approx, Σ=1)
203
233
  for ( let iy = 0; iy < 5; iy ++ ) {
@@ -213,7 +243,8 @@ export class BilateralFilter extends RenderStage {
213
243
  const sy = gy.add( stepSize.mul( dy ) )
214
244
  .clamp( int( 0 ), int( resH ).sub( 1 ) );
215
245
 
216
- const sColor = textureLoad( readTexNode, ivec2( sx, sy ) ).xyz;
246
+ const sSample = textureLoad( readTexNode, ivec2( sx, sy ) );
247
+ const sColor = sSample.xyz;
217
248
  const sND = textureLoad( ndTexNode, ivec2( sx, sy ) );
218
249
  const sNormal = textureLoad( snTexNode, ivec2( sx, sy ) ).xyz.mul( 2.0 ).sub( 1.0 );
219
250
  const sDepth = sND.w;
@@ -228,24 +259,39 @@ export class BilateralFilter extends RenderStage {
228
259
  sigmaL, phiNormal, phiDepth, phiColor
229
260
  );
230
261
 
262
+ const sVariance = isFirst.select(
263
+ seedVariance( ivec2( sx, sy ) ),
264
+ sSample.w.mul( sSample.w )
265
+ );
266
+
231
267
  colorSum.addAssign( sColor.mul( w ) );
232
268
  weightSum.addAssign( w );
269
+ varianceSum.addAssign( w.mul( w ).mul( max( sVariance, float( 0.0 ) ) ) );
233
270
 
234
271
  }
235
272
 
236
273
  }
237
274
 
238
275
  const filtered = colorSum.div( max( weightSum, float( 0.0001 ) ) );
276
+ const filteredVariance = varianceSum
277
+ .div( max( weightSum.mul( weightSum ), float( 1e-8 ) ) );
239
278
 
240
279
  // Remodulate by albedo only on the final iteration so the
241
280
  // inner ping-pong stays in demodulated space.
242
281
  const isLast = isLastIterationU.equal( int( 1 ) );
243
282
  const output = isLast.select( filtered.mul( centerSafeAlbedo ), filtered );
244
283
 
284
+ // The last pass restores the path tracer's coverage — Compositor reads .w as
285
+ // image alpha when transparentBackground is on.
286
+ const carriedAlpha = isLast.select(
287
+ textureLoad( srcAlphaTexNode, coord ).w,
288
+ sqrt( max( filteredVariance, float( 0.0 ) ) )
289
+ );
290
+
245
291
  textureStore(
246
292
  writeStorageTex,
247
293
  uvec2( uint( gx ), uint( gy ) ),
248
- vec4( output, 1.0 )
294
+ vec4( output, carriedAlpha )
249
295
  ).toWriteOnly();
250
296
 
251
297
  } );
@@ -271,6 +317,7 @@ export class BilateralFilter extends RenderStage {
271
317
  const snTex = context.getTexture( this.shadingNormalTextureName ) || ndTex;
272
318
  const albedoTex = context.getTexture( this.albedoTextureName );
273
319
  const varTex = context.getTexture( this.varianceTextureName );
320
+ const srcAlphaTex = context.getTexture( 'pathtracer:color' );
274
321
 
275
322
  if ( ! inputTex ) return;
276
323
 
@@ -291,6 +338,18 @@ export class BilateralFilter extends RenderStage {
291
338
  if ( ndTex ) this._normalDepthTexNode.value = ndTex;
292
339
  if ( snTex ) this._shadingNormalTexNode.value = snTex;
293
340
  if ( albedoTex ) this._albedoTexNode.value = albedoTex;
341
+ // Bind these BEFORE compile too: two TextureNodes still holding the default empty
342
+ // texture when the node builder runs share one GPU binding, which then resolves to
343
+ // whichever is assigned last. That aliased varTexNode onto the colour input.
344
+ //
345
+ // Safe only because inputTex is a RenderTarget texture, which produces the same
346
+ // `textureLoad(t, coord, level)` codegen as EmptyTexture — the form the StorageTexture
347
+ // ping-pong needs on passes 1+. Binding an actual StorageTexture here would emit the
348
+ // no-level form and later reads would return zero. EdgeFilter must NOT do this: its
349
+ // pass-0 input IS a StorageTexture.
350
+ this._readTexNode.value = inputTex;
351
+ if ( varTex ) this._varianceTexNode.value = varTex;
352
+ if ( srcAlphaTex ) this._srcAlphaTexNode.value = srcAlphaTex;
294
353
 
295
354
  // First-frame compile while StorageTexture-typed nodes still hold
296
355
  // EmptyTexture — codegen then emits textureLoad with the level
@@ -303,8 +362,6 @@ export class BilateralFilter extends RenderStage {
303
362
 
304
363
  }
305
364
 
306
- if ( varTex ) this._varianceTexNode.value = varTex;
307
-
308
365
  // À-trous iterations: step size 2^i, ping-pong write direction.
309
366
  // Last iteration multiplies by albedo to remodulate.
310
367
  let readTex = inputTex;
@@ -316,6 +373,7 @@ export class BilateralFilter extends RenderStage {
316
373
  this.stepSizeU.value = 1 << i;
317
374
  this._readTexNode.value = readTex;
318
375
  this.isLastIterationU.value = ( i === this.iterations - 1 ) ? 1 : 0;
376
+ this.isFirstIterationU.value = i === 0 ? 1 : 0;
319
377
 
320
378
  this.renderer.compute( writeNode );
321
379
 
@@ -420,6 +478,7 @@ export class BilateralFilter extends RenderStage {
420
478
  this._shadingNormalTexNode?.dispose();
421
479
  this._albedoTexNode?.dispose();
422
480
  this._varianceTexNode?.dispose();
481
+ this._srcAlphaTexNode?.dispose();
423
482
 
424
483
  }
425
484
 
@@ -73,6 +73,8 @@ export class EdgeFilter extends RenderStage {
73
73
 
74
74
  this.stepSizeU = uniform( 1, 'int' );
75
75
  this.isLastIterationU = uniform( 0, 'int' );
76
+ // 1 on pass 0 → seed variance from variance:output; later passes read the carried value.
77
+ this.isFirstIterationU = uniform( 1, 'int' );
76
78
  this.resW = uniform( options.width || 1 );
77
79
  this.resH = uniform( options.height || 1 );
78
80
 
@@ -202,6 +204,7 @@ export class EdgeFilter extends RenderStage {
202
204
  const phiDepth = this.phiDepth;
203
205
  const stepSize = this.stepSizeU;
204
206
  const isLastIterationU = this.isLastIterationU;
207
+ const isFirstIterationU = this.isFirstIterationU;
205
208
  const resW = this.resW;
206
209
  const resH = this.resH;
207
210
 
@@ -216,7 +219,8 @@ export class EdgeFilter extends RenderStage {
216
219
 
217
220
  const coord = ivec2( gx, gy );
218
221
 
219
- const centerLighting = textureLoad( readTex, coord ).xyz;
222
+ const centerSample = textureLoad( readTex, coord );
223
+ const centerLighting = centerSample.xyz;
220
224
  const centerLum = luminance( centerLighting );
221
225
  const centerND = textureLoad( ndTex, coord );
222
226
  const centerDepth = centerND.w;
@@ -233,13 +237,30 @@ export class EdgeFilter extends RenderStage {
233
237
  // max() falls back to the spatial estimate as temporal collapses on a
234
238
  // converging accumulation buffer. Dividing by albedoLum rescales the
235
239
  // modulated-space variance into demodulated space (std ∝ 1/albedo).
236
- const vSample = textureLoad( varTex, coord );
237
- const variance = max( vSample.z, vSample.w );
238
- const sigmaL = phiLuminance.mul( sqrt( max( variance, float( 0.0 ) ) ) )
240
+ //
241
+ // Filtered alongside colour and carried between passes as √variance in the
242
+ // ping-pong's spare alpha, so sigma_l tightens as the signal smooths. Stored as
243
+ // the root because converged variance (~1e-6) is a half-float subnormal.
244
+ const isFirst = isFirstIterationU.equal( int( 1 ) );
245
+ const seedVariance = ( at ) => {
246
+
247
+ const v = textureLoad( varTex, at );
248
+ return max( v.z, v.w );
249
+
250
+ };
251
+
252
+ const centerVariance = isFirst.select(
253
+ seedVariance( coord ),
254
+ centerSample.w.mul( centerSample.w )
255
+ ).toVar();
256
+
257
+ const sigmaL = phiLuminance.mul( sqrt( max( centerVariance, float( 0.0 ) ) ) )
239
258
  .div( centerAlbedoLum ).add( float( 0.0001 ) );
240
259
 
241
260
  const colorSum = vec3( 0.0 ).toVar();
242
261
  const weightSum = float( 0.0 ).toVar();
262
+ // Squared weights, normalised by weightSum² — the variance of a weighted mean.
263
+ const varianceSum = float( 0.0 ).toVar();
243
264
 
244
265
  for ( let iy = 0; iy < 5; iy ++ ) {
245
266
 
@@ -253,7 +274,8 @@ export class EdgeFilter extends RenderStage {
253
274
  const sy = gy.add( stepSize.mul( dy ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
254
275
  const sCoord = ivec2( sx, sy );
255
276
 
256
- const sLighting = textureLoad( readTex, sCoord ).xyz;
277
+ const sSample = textureLoad( readTex, sCoord );
278
+ const sLighting = sSample.xyz;
257
279
  const sLum = luminance( sLighting );
258
280
  const sND = textureLoad( ndTex, sCoord );
259
281
  const sDepth = sND.w;
@@ -276,14 +298,22 @@ export class EdgeFilter extends RenderStage {
276
298
  const geomW = mix( float( 1.0 ), normW.mul( depW ), bothHit );
277
299
  const w = float( kw ).mul( lumW ).mul( geomW ).mul( sameKind );
278
300
 
301
+ const sVariance = isFirst.select(
302
+ seedVariance( sCoord ),
303
+ sSample.w.mul( sSample.w )
304
+ );
305
+
279
306
  colorSum.addAssign( sLighting.mul( w ) );
280
307
  weightSum.addAssign( w );
308
+ varianceSum.addAssign( w.mul( w ).mul( max( sVariance, float( 0.0 ) ) ) );
281
309
 
282
310
  }
283
311
 
284
312
  }
285
313
 
286
314
  const filtered = colorSum.div( max( weightSum, float( 0.0001 ) ) );
315
+ const filteredVariance = varianceSum
316
+ .div( max( weightSum.mul( weightSum ), float( 1e-8 ) ) );
287
317
 
288
318
  // Final pass: remodulate by albedo and blend against the raw color by
289
319
  // filterStrength. Inner passes stay in demodulated space.
@@ -293,7 +323,14 @@ export class EdgeFilter extends RenderStage {
293
323
  const finalModulated = mix( rawColor, remodded, filterStrength );
294
324
  const output = isLast.select( finalModulated, filtered );
295
325
 
296
- textureStore( writeStorageTex, uvec2( uint( gx ), uint( gy ) ), vec4( output, 1.0 ) ).toWriteOnly();
326
+ // The last pass restores the path tracer's coverage — Compositor reads .w as
327
+ // image alpha when transparentBackground is on.
328
+ const carriedAlpha = isLast.select(
329
+ textureLoad( colorTex, coord ).w,
330
+ sqrt( max( filteredVariance, float( 0.0 ) ) )
331
+ );
332
+
333
+ textureStore( writeStorageTex, uvec2( uint( gx ), uint( gy ) ), vec4( output, carriedAlpha ) ).toWriteOnly();
297
334
 
298
335
  } );
299
336
 
@@ -378,6 +415,7 @@ export class EdgeFilter extends RenderStage {
378
415
 
379
416
  this.stepSizeU.value = 1 << i;
380
417
  this.isLastIterationU.value = ( i === this.iterations - 1 ) ? 1 : 0;
418
+ this.isFirstIterationU.value = i === 0 ? 1 : 0;
381
419
  this._readTexNode.value = readTex;
382
420
 
383
421
  this.renderer.compute( writeNode );
@@ -1,4 +1,4 @@
1
- import { Fn, vec3, vec4, float, int, uint, uvec2, uniform, normalize, mat3, min, storage, If,
1
+ import { Fn, vec2, vec3, vec4, float, int, uint, uvec2, uniform, min, storage, If,
2
2
  textureStore, workgroupId, localId } from 'three/tsl';
3
3
  import { RenderTarget, StorageTexture } from 'three/webgpu';
4
4
  import { HalfFloatType, RGBAFormat, NearestFilter, Matrix4, Box2, Vector2 } from 'three';
@@ -6,6 +6,7 @@ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
6
6
  import { MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
7
7
  import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
8
8
  import { traverseBVH } from '../TSL/BVHTraversal.js';
9
+ import { cameraRayDirection } from '../TSL/CameraRay.js';
9
10
  import { getMaterial } from '../TSL/Common.js';
10
11
  import { computeUVCache, processNormal, processBump, buildBucketTextureNodes, refreshBucketTextureNodes, setMaterialBucketTextures } from '../TSL/TextureSampling.js';
11
12
 
@@ -48,6 +49,12 @@ export class NormalDepth extends RenderStage {
48
49
 
49
50
  this.cameraWorldMatrix = uniform( new Matrix4(), 'mat4' );
50
51
  this.cameraProjectionMatrixInverse = uniform( new Matrix4(), 'mat4' );
52
+ // Mirrored from the path tracer each frame — these rays must use the same camera model
53
+ // as the colour buffer or the denoiser's normal/depth edge-stops fight the image.
54
+ this.cameraProjection = uniform( 0, 'int' );
55
+ this.panoLonRange = uniform( new Vector2(), 'vec2' );
56
+ this.panoLatRange = uniform( new Vector2(), 'vec2' );
57
+ this.panoLevelHorizon = uniform( 1, 'int' );
51
58
  this.resolutionWidth = uniform( options.width || 1 );
52
59
  this.resolutionHeight = uniform( options.height || 1 );
53
60
 
@@ -122,8 +129,11 @@ export class NormalDepth extends RenderStage {
122
129
 
123
130
  this.on( 'pipeline:reset', () => {
124
131
 
132
+ // _hasHistory is deliberately NOT cleared, mirroring MotionVector.reset(): the
133
+ // prev-frame G-buffer tracks camera motion, not accumulation, and a camera move
134
+ // resets accumulation every frame of a drag. Clearing it made render() overwrite
135
+ // prevRT with the current frame, so ASVGF's temporal gate could never reject.
125
136
  this._dirty = true;
126
- this._hasHistory = false;
127
137
 
128
138
  } );
129
139
 
@@ -149,6 +159,8 @@ export class NormalDepth extends RenderStage {
149
159
  this._bvhStorageNode = null;
150
160
  this._matStorageNode = null;
151
161
  this._dirty = true;
162
+ // New geometry — the previous frame describes a scene that is no longer there.
163
+ this._hasHistory = false;
152
164
 
153
165
  }
154
166
 
@@ -213,20 +225,12 @@ export class NormalDepth extends RenderStage {
213
225
 
214
226
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
215
227
 
216
- // Pixel center → NDC, Y negated for Three.js WebGPU.
217
- const ndcX = float( gx ).add( 0.5 ).div( resW ).mul( 2.0 ).sub( 1.0 );
218
- const ndcY = float( gy ).add( 0.5 ).div( resH ).mul( 2.0 ).sub( 1.0 ).negate();
219
- const ndcPos = vec3( ndcX, ndcY, 1.0 );
220
-
221
228
  // No jitter — deterministic per-pixel ray so the temporal gate
222
229
  // sees stable per-pixel normals across frames.
223
- const rayDirCS = camProjInvMat.mul( vec4( ndcPos, 1.0 ) );
224
- const rayDirWorld = normalize(
225
- mat3(
226
- camWorldMat[ 0 ].xyz,
227
- camWorldMat[ 1 ].xyz,
228
- camWorldMat[ 2 ].xyz
229
- ).mul( rayDirCS.xyz.div( rayDirCS.w ) )
230
+ const uv = vec2( float( gx ).add( 0.5 ).div( resW ), float( gy ).add( 0.5 ).div( resH ) );
231
+ const rayDirWorld = cameraRayDirection(
232
+ uv, camWorldMat, camProjInvMat,
233
+ this.cameraProjection, this.panoLonRange, this.panoLatRange, this.panoLevelHorizon
230
234
  );
231
235
  const rayOrigin = vec3( camWorldMat[ 3 ] );
232
236
 
@@ -302,6 +306,10 @@ export class NormalDepth extends RenderStage {
302
306
 
303
307
  this.cameraWorldMatrix.value.copy( pt.uniforms.get( 'cameraWorldMatrix' ).value );
304
308
  this.cameraProjectionMatrixInverse.value.copy( pt.uniforms.get( 'cameraProjectionMatrixInverse' ).value );
309
+ this.cameraProjection.value = pt.uniforms.get( 'cameraProjection' ).value;
310
+ this.panoLonRange.value.copy( pt.uniforms.get( 'panoLonRange' ).value );
311
+ this.panoLatRange.value.copy( pt.uniforms.get( 'panoLatRange' ).value );
312
+ this.panoLevelHorizon.value = pt.uniforms.get( 'panoLevelHorizon' ).value;
305
313
 
306
314
  }
307
315
 
@@ -367,13 +375,16 @@ export class NormalDepth extends RenderStage {
367
375
  this._outputStorageTex?.dispose();
368
376
  this._shadingStorageTex?.dispose();
369
377
  this.reset();
378
+ // The textures backing the ping-pong are gone, so the prev-frame G-buffer is too.
379
+ this._hasHistory = false;
370
380
 
371
381
  }
372
382
 
383
+ // Accumulation reset only — _hasHistory survives on purpose (see the pipeline:reset
384
+ // listener). It is invalidated by setSize(), a geometry swap, and releaseGPUMemory().
373
385
  reset() {
374
386
 
375
387
  this._dirty = true;
376
- this._hasHistory = false;
377
388
 
378
389
  }
379
390