rayzee 6.2.0 → 6.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.
@@ -1,18 +1,25 @@
1
1
  import { Fn, vec3, vec4, float, int, uint, ivec2, uvec2, uniform,
2
- If, dot, max, min, abs, mix,
2
+ If, dot, max, min, abs, mix, pow, exp,
3
3
  textureLoad, textureStore, workgroupArray, workgroupBarrier, localId, workgroupId } from 'three/tsl';
4
4
  import { RenderTarget, TextureNode, StorageTexture } from 'three/webgpu';
5
5
  import { HalfFloatType, FloatType, RGBAFormat, NearestFilter, LinearFilter } from 'three';
6
6
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
7
7
  import { luminance } from '../TSL/Common.js';
8
+ import { ALBEDO_EPS } from '../EngineDefaults.js';
8
9
 
9
10
  /**
10
- * WebGPU ASVGF Stage — temporal denoising via motion-vector reprojection
11
- * + 3×3 colour-distance disocclusion rejection. Ping-pong StorageTextures.
11
+ * ASVGF — SVGF temporal + spatial denoising with albedo demodulation.
12
12
  *
13
- * Events: asvgf:reset, asvgf:setTemporal, asvgf:updateParameters
14
- * Publishes: asvgf:output, asvgf:temporalColor
15
- * Reads: pathtracer:color, motionVector:screenSpace
13
+ * Adaptive-α infrastructure (gradient compute, prev-color cache,
14
+ * gradientStrength uniform) is in place but disabled by default —
15
+ * gradientStrength=0 makes adaptiveBoost=0 so effectiveAlpha is the pure
16
+ * EMA 1/(history+1). The fixed-noise-floor implementation misfires on
17
+ * 1-SPP raw input; a per-pixel variance-aware floor is the proper fix.
18
+ *
19
+ * Reads: pathtracer:color, pathtracer:albedo, pathtracer:normalDepth,
20
+ * pathtracer:prevNormalDepth, motionVector:screenSpace
21
+ * Publishes: asvgf:output (modulated), asvgf:demodulated (lighting + history),
22
+ * asvgf:gradient
16
23
  */
17
24
  export class ASVGF extends RenderStage {
18
25
 
@@ -25,43 +32,67 @@ export class ASVGF extends RenderStage {
25
32
 
26
33
  this.renderer = renderer;
27
34
 
28
- this.temporalAlpha = uniform( options.temporalAlpha ?? 0.1 );
29
- this.phiColor = uniform( options.phiColor ?? 10.0 );
35
+ this.temporalAlpha = uniform( options.temporalAlpha ?? 0.0 );
36
+ this.gradientStrength = uniform( options.gradientStrength ?? 0.0 );
37
+ this.gradientNoiseFloor = uniform( options.gradientNoiseFloor ?? 0.15 );
30
38
  this.maxAccumFrames = uniform( options.maxAccumFrames ?? 32.0 );
31
- this.varianceClip = uniform( options.varianceClip ?? 1.0 );
32
39
 
33
40
  this.resW = uniform( options.width || 1 );
34
41
  this.resH = uniform( options.height || 1 );
35
42
 
36
- this.temporalEnabled = true;
37
43
  this.temporalEnabledU = uniform( 1.0 );
38
44
 
39
45
  this._colorTexNode = new TextureNode();
46
+ this._prevColorTexNode = new TextureNode();
47
+ this._albedoTexNode = new TextureNode();
40
48
  this._motionTexNode = new TextureNode();
49
+ this._normalDepthTexNode = new TextureNode();
50
+ this._prevNormalDepthTexNode = new TextureNode();
41
51
  this._readTemporalTexNode = new TextureNode();
52
+ this._gradientReadTexNode = new TextureNode();
42
53
 
43
54
  const w = options.width || 1;
44
55
  const h = options.height || 1;
45
56
 
46
- // LinearFilter required for textureLoad codegen.
57
+ // FloatType for ping-pong: demodulated lighting on dark materials
58
+ // (lighting ≈ color/0.01) exceeds HalfFloat's 65k cap on HDR.
59
+ // LinearFilter is required for textureLoad codegen on StorageTextures.
47
60
  this._temporalTexA = new StorageTexture( w, h );
48
- this._temporalTexA.type = HalfFloatType;
61
+ this._temporalTexA.type = FloatType;
49
62
  this._temporalTexA.format = RGBAFormat;
50
63
  this._temporalTexA.minFilter = LinearFilter;
51
64
  this._temporalTexA.magFilter = LinearFilter;
52
65
 
53
66
  this._temporalTexB = new StorageTexture( w, h );
54
- this._temporalTexB.type = HalfFloatType;
67
+ this._temporalTexB.type = FloatType;
55
68
  this._temporalTexB.format = RGBAFormat;
56
69
  this._temporalTexB.minFilter = LinearFilter;
57
70
  this._temporalTexB.magFilter = LinearFilter;
58
71
 
72
+ this._outputModulatedTex = new StorageTexture( w, h );
73
+ this._outputModulatedTex.type = FloatType;
74
+ this._outputModulatedTex.format = RGBAFormat;
75
+ this._outputModulatedTex.minFilter = LinearFilter;
76
+ this._outputModulatedTex.magFilter = LinearFilter;
77
+
59
78
  this._gradientStorageTex = new StorageTexture( w, h );
60
79
  this._gradientStorageTex.type = HalfFloatType;
61
80
  this._gradientStorageTex.format = RGBAFormat;
62
81
  this._gradientStorageTex.minFilter = LinearFilter;
63
82
  this._gradientStorageTex.magFilter = LinearFilter;
64
83
 
84
+ // FloatType to match pathtracer:color (PT MRT). copyTextureToTexture
85
+ // requires identical formats.
86
+ this._prevColorRT = new RenderTarget( w, h, {
87
+ type: FloatType,
88
+ format: RGBAFormat,
89
+ minFilter: NearestFilter,
90
+ magFilter: NearestFilter,
91
+ depthBuffer: false,
92
+ stencilBuffer: false
93
+ } );
94
+ this._prevColorReady = false;
95
+
65
96
  this.currentMoments = 0; // 0 = write A, read B; 1 = write B, read A
66
97
  this._compiled = false;
67
98
 
@@ -89,7 +120,6 @@ export class ASVGF extends RenderStage {
89
120
  stencilBuffer: false
90
121
  } );
91
122
 
92
- // Separate from temporal-pass nodes to avoid binding interference.
93
123
  this._heatmapRawColorTexNode = new TextureNode();
94
124
  this._heatmapColorTexNode = new TextureNode();
95
125
  this._heatmapTemporalTexNode = new TextureNode();
@@ -99,29 +129,32 @@ export class ASVGF extends RenderStage {
99
129
 
100
130
  this._buildHeatmapCompute();
101
131
 
102
- this.frameCount = 0;
103
-
104
132
  }
105
133
 
106
- // Temporal-luminance gradient (heatmap mode 5 only).
107
- // 10×10 shared tile; each thread does a 3×3 brightest search, reprojects,
108
- // and emits the normalized luminance delta against the previous frame.
134
+ // Per-pixel adaptive-α signal: 5×5 spatial average of |currentLum − prevLum|
135
+ // / meanLum, both raw single-SPP (noise-comparable), with noise-floor
136
+ // subtraction. Currently gated off by gradientStrength=0 kept compiled
137
+ // to drive heatmap mode 5 and as scaffolding for a proper variance-aware
138
+ // implementation.
109
139
  _buildGradientCompute() {
110
140
 
111
141
  const colorTex = this._colorTexNode;
142
+ const prevColorTex = this._prevColorTexNode;
112
143
  const motionTex = this._motionTexNode;
113
- const prevTemporalTex = this._readTemporalTexNode;
144
+ const noiseFloor = this.gradientNoiseFloor;
114
145
  const gradientStorageTex = this._gradientStorageTex;
115
146
  const resW = this.resW;
116
147
  const resH = this.resH;
117
148
 
118
- const TILE_W = 10;
119
- const TILE_TOTAL = TILE_W * TILE_W; // 100
149
+ // 12×12 tile = 8×8 workgroup + 2px border for the 5×5 stencil.
150
+ const TILE_W = 12;
151
+ const TILE_BORDER = 2;
152
+ const TILE_TOTAL = TILE_W * TILE_W; // 144
120
153
  const WG_SIZE = 8;
121
154
  const WG_THREADS = WG_SIZE * WG_SIZE; // 64
122
- const EXTRA_LOAD = TILE_TOTAL - WG_THREADS; // 36
123
155
 
124
- const sharedLum = workgroupArray( 'float', TILE_TOTAL );
156
+ const sharedCurLum = workgroupArray( 'float', TILE_TOTAL );
157
+ const sharedPrevLum = workgroupArray( 'float', TILE_TOTAL );
125
158
 
126
159
  const computeFn = Fn( () => {
127
160
 
@@ -129,26 +162,48 @@ export class ASVGF extends RenderStage {
129
162
  const ly = localId.y;
130
163
  const linearIdx = ly.mul( WG_SIZE ).add( lx );
131
164
 
132
- const tileOriginX = int( workgroupId.x ).mul( WG_SIZE ).sub( 1 );
133
- const tileOriginY = int( workgroupId.y ).mul( WG_SIZE ).sub( 1 );
165
+ // Hoisted outside loadEntry so all 3 load rounds reuse the same nodes.
166
+ const tileOriginX = int( workgroupId.x ).mul( WG_SIZE ).sub( TILE_BORDER ).toVar();
167
+ const tileOriginY = int( workgroupId.y ).mul( WG_SIZE ).sub( TILE_BORDER ).toVar();
134
168
 
135
- // All 64 threads load positions 0–63; threads 0–35 then load 64–99.
136
- const sx1 = linearIdx.mod( TILE_W );
137
- const sy1 = linearIdx.div( TILE_W );
138
- const gx1 = tileOriginX.add( int( sx1 ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
139
- const gy1 = tileOriginY.add( int( sy1 ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
140
- const sColor1 = textureLoad( colorTex, ivec2( gx1, gy1 ) ).xyz;
141
- sharedLum.element( linearIdx ).assign( luminance( sColor1 ) );
169
+ const loadEntry = ( k ) => {
170
+
171
+ const sx = k.mod( uint( TILE_W ) );
172
+ const sy = k.div( uint( TILE_W ) );
173
+ const gxL = tileOriginX.add( int( sx ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
174
+ const gyL = tileOriginY.add( int( sy ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
175
+
176
+ const curColor = textureLoad( colorTex, ivec2( gxL, gyL ) ).xyz;
177
+ sharedCurLum.element( k ).assign( luminance( curColor ) );
178
+
179
+ const motion = textureLoad( motionTex, ivec2( gxL, gyL ) );
180
+ const prevXf = float( gxL ).sub( motion.x.mul( resW ) );
181
+ const prevYf = float( gyL ).sub( motion.y.mul( resH ) );
182
+ const prevX = int( prevXf ).clamp( int( 0 ), int( resW ).sub( 1 ) );
183
+ const prevY = int( prevYf ).clamp( int( 0 ), int( resH ).sub( 1 ) );
184
+ // Invalid prev → mirror current so the diff contributes 0;
185
+ // disocclusion is handled by the geometric gate downstream.
186
+ const motionValid = motion.w.greaterThan( 0.5 );
187
+ const prevColor = textureLoad( prevColorTex, ivec2( prevX, prevY ) ).xyz;
188
+ const prevLum = motionValid.select( luminance( prevColor ), luminance( curColor ) );
189
+ sharedPrevLum.element( k ).assign( prevLum );
190
+
191
+ };
192
+
193
+ // 144 entries / 64 threads → 3 rounds, last partially populated.
194
+ loadEntry( linearIdx );
195
+
196
+ const idx2 = linearIdx.add( uint( WG_THREADS ) );
197
+ If( idx2.lessThan( uint( TILE_TOTAL ) ), () => {
198
+
199
+ loadEntry( idx2 );
200
+
201
+ } );
142
202
 
143
- If( linearIdx.lessThan( uint( EXTRA_LOAD ) ), () => {
203
+ const idx3 = linearIdx.add( uint( WG_THREADS * 2 ) );
204
+ If( idx3.lessThan( uint( TILE_TOTAL ) ), () => {
144
205
 
145
- const idx2 = linearIdx.add( uint( WG_THREADS ) );
146
- const sx2 = idx2.mod( TILE_W );
147
- const sy2 = idx2.div( TILE_W );
148
- const gx2 = tileOriginX.add( int( sx2 ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
149
- const gy2 = tileOriginY.add( int( sy2 ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
150
- const sColor2 = textureLoad( colorTex, ivec2( gx2, gy2 ) ).xyz;
151
- sharedLum.element( idx2 ).assign( luminance( sColor2 ) );
206
+ loadEntry( idx3 );
152
207
 
153
208
  } );
154
209
 
@@ -159,51 +214,37 @@ export class ASVGF extends RenderStage {
159
214
 
160
215
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
161
216
 
162
- const bestLum = float( - 1.0 ).toVar();
163
- const bestDx = int( 0 ).toVar();
164
- const bestDy = int( 0 ).toVar();
217
+ const sumDiff = float( 0.0 ).toVar();
218
+ const sumMean = float( 0.0 ).toVar();
165
219
 
166
- for ( let dy = - 1; dy <= 1; dy ++ ) {
220
+ for ( let dy = - TILE_BORDER; dy <= TILE_BORDER; dy ++ ) {
167
221
 
168
- for ( let dx = - 1; dx <= 1; dx ++ ) {
222
+ for ( let dx = - TILE_BORDER; dx <= TILE_BORDER; dx ++ ) {
169
223
 
170
- const val = sharedLum.element(
171
- ly.add( 1 + dy ).mul( TILE_W ).add( lx.add( 1 + dx ) )
172
- );
173
-
174
- If( val.greaterThan( bestLum ), () => {
175
-
176
- bestLum.assign( val );
177
- bestDx.assign( int( dx ) );
178
- bestDy.assign( int( dy ) );
179
-
180
- } );
224
+ const idx = ly.add( uint( TILE_BORDER + dy ) )
225
+ .mul( uint( TILE_W ) )
226
+ .add( lx.add( uint( TILE_BORDER + dx ) ) );
227
+ const cL = sharedCurLum.element( idx );
228
+ const pL = sharedPrevLum.element( idx );
229
+ sumDiff.addAssign( abs( cL.sub( pL ) ) );
230
+ sumMean.addAssign( cL.add( pL ).mul( 0.5 ) );
181
231
 
182
232
  }
183
233
 
184
234
  }
185
235
 
186
- const bestGx = gx.add( bestDx ).clamp( int( 0 ), int( resW ).sub( 1 ) );
187
- const bestGy = gy.add( bestDy ).clamp( int( 0 ), int( resH ).sub( 1 ) );
188
- const motion = textureLoad( motionTex, ivec2( bestGx, bestGy ) );
189
-
190
- // Reproject via motion vector (UV-space pixel coords).
191
- const prevXf = float( bestGx ).sub( motion.x.mul( resW ) );
192
- const prevYf = float( bestGy ).sub( motion.y.mul( resH ) );
193
- const prevX = int( prevXf ).clamp( int( 0 ), int( resW ).sub( 1 ) );
194
- const prevY = int( prevYf ).clamp( int( 0 ), int( resH ).sub( 1 ) );
195
-
196
- const prevColor = textureLoad( prevTemporalTex, ivec2( prevX, prevY ) ).xyz;
197
- const prevLum = luminance( prevColor );
198
-
199
- const gradient = abs( bestLum.sub( prevLum ) )
200
- .div( max( bestLum, float( 0.001 ) ) )
236
+ const rawGradient = sumDiff
237
+ .div( max( sumMean, float( 0.001 ) ) )
238
+ .clamp( 0.0, 1.0 );
239
+ const oneMinusFloor = max( float( 1.0 ).sub( noiseFloor ), float( 0.0001 ) );
240
+ const gradient = max( rawGradient.sub( noiseFloor ), float( 0.0 ) )
241
+ .div( oneMinusFloor )
201
242
  .clamp( 0.0, 1.0 );
202
243
 
203
244
  textureStore(
204
245
  gradientStorageTex,
205
246
  uvec2( uint( gx ), uint( gy ) ),
206
- vec4( gradient, bestLum, prevLum, 1.0 )
247
+ vec4( gradient, rawGradient, sumMean.div( 25.0 ), 1.0 )
207
248
  ).toWriteOnly();
208
249
 
209
250
  } );
@@ -227,13 +268,22 @@ export class ASVGF extends RenderStage {
227
268
 
228
269
  _buildTemporalForDirection( writeTemporalTex ) {
229
270
 
271
+ const NORMAL_POWER = 16.0; // pow(dot, p): smooth surfaces ≈ 1, real edges ≈ 0
272
+ const DEPTH_SIGMA = 0.05; // exp(-relDelta/σ)
273
+ const VALIDITY_THRESHOLD = 0.01; // wSum below → disocclusion → fresh sample
274
+
230
275
  const colorTex = this._colorTexNode;
276
+ const albedoTex = this._albedoTexNode;
231
277
  const motionTex = this._motionTexNode;
278
+ const ndTex = this._normalDepthTexNode;
279
+ const prevNDTex = this._prevNormalDepthTexNode;
232
280
  const prevTemporalTex = this._readTemporalTexNode;
281
+ const gradientTex = this._gradientReadTexNode;
282
+ const outputModulatedTex = this._outputModulatedTex;
233
283
 
234
- const temporalAlpha = this.temporalAlpha;
235
284
  const maxAccumFrames = this.maxAccumFrames;
236
- const varianceClipU = this.varianceClip;
285
+ const temporalAlphaMin = this.temporalAlpha;
286
+ const gradientStrength = this.gradientStrength;
237
287
  const temporalEnabledU = this.temporalEnabledU;
238
288
  const resW = this.resW;
239
289
  const resH = this.resH;
@@ -249,9 +299,16 @@ export class ASVGF extends RenderStage {
249
299
 
250
300
  const coord = ivec2( gx, gy );
251
301
  const currentColor = textureLoad( colorTex, coord ).xyz;
302
+ const currentAlbedo = textureLoad( albedoTex, coord ).xyz;
303
+
304
+ // Same safeAlbedo on both demod and re-mod sides → exact
305
+ // round-trip for sky/miss rays where albedo=0.
306
+ const safeAlbedo = max( currentAlbedo, vec3( ALBEDO_EPS ) );
307
+ const currentLighting = currentColor.div( safeAlbedo );
252
308
 
253
- // Default: history = 1, no blend (used when temporal off or reprojection invalid).
254
- const result = vec4( currentColor, 1.0 ).toVar();
309
+ // Defaults = fresh sample (no temporal blend).
310
+ const demodResult = vec4( currentLighting, 1.0 ).toVar();
311
+ const modulatedResult = vec4( currentColor, 1.0 ).toVar();
255
312
 
256
313
  If( temporalEnabledU.greaterThan( 0.5 ), () => {
257
314
 
@@ -261,70 +318,93 @@ export class ASVGF extends RenderStage {
261
318
  const prevXf = float( gx ).sub( motion.x.mul( resW ) );
262
319
  const prevYf = float( gy ).sub( motion.y.mul( resH ) );
263
320
  const prevOnScreen = prevXf.greaterThanEqual( 0.0 )
264
- .and( prevXf.lessThan( float( resW ) ) )
321
+ .and( prevXf.lessThan( float( resW ).sub( 1.0 ) ) )
265
322
  .and( prevYf.greaterThanEqual( 0.0 ) )
266
- .and( prevYf.lessThan( float( resH ) ) );
323
+ .and( prevYf.lessThan( float( resH ).sub( 1.0 ) ) );
267
324
 
268
325
  If( motionValid.and( prevOnScreen ), () => {
269
326
 
270
- const prevX = int( prevXf ).clamp( int( 0 ), int( resW ).sub( 1 ) );
271
- const prevY = int( prevYf ).clamp( int( 0 ), int( resH ).sub( 1 ) );
272
- const prevCoord = ivec2( prevX, prevY );
273
-
274
- const prevData = textureLoad( prevTemporalTex, prevCoord );
275
- const prevColor = prevData.xyz;
276
- const historyLength = prevData.w;
277
-
278
- // Euclidean colour-distance gate. Per-channel clipping
279
- // fails at silhouettes where wall/box colours overlap
280
- // in per-channel ranges. A normal/depth gate would be
281
- // stronger but shading normals jitter too much for it.
282
- const nMean = vec3( 0.0 ).toVar();
283
- const nMeanSq = vec3( 0.0 ).toVar();
284
-
285
- for ( let dy = - 1; dy <= 1; dy ++ ) {
286
-
287
- for ( let dx = - 1; dx <= 1; dx ++ ) {
288
-
289
- const sx = gx.add( dx ).clamp( int( 0 ), int( resW ).sub( 1 ) );
290
- const sy = gy.add( dy ).clamp( int( 0 ), int( resH ).sub( 1 ) );
291
- const s = textureLoad( colorTex, ivec2( sx, sy ) ).xyz;
292
- nMean.addAssign( s );
293
- nMeanSq.addAssign( s.mul( s ) );
294
-
295
- }
296
-
297
- }
298
-
299
- nMean.divAssign( 9.0 );
300
- nMeanSq.divAssign( 9.0 );
301
- const nVariance = max( nMeanSq.sub( nMean.mul( nMean ) ), vec3( 0.0 ) );
302
- const sigmaSq = dot( nVariance, vec3( 1.0 ) );
303
-
304
- // reject ∈ [0,1]: 0 = matches mean (keep history),
305
- // 1 = >k·σ away (force fresh sample). Squared form skips sqrt.
306
- const diff = prevColor.sub( nMean );
307
- const distSq = dot( diff, diff );
308
- const sigmaSqK = sigmaSq.mul( varianceClipU.mul( varianceClipU ) );
309
- const reject = distSq.div( max( sigmaSqK, float( 1e-6 ) ) ).clamp( 0.0, 1.0 );
310
-
311
- const baseAlpha = max(
312
- float( 1.0 ).div( historyLength.add( 1.0 ) ),
313
- temporalAlpha
314
- );
315
- const effectiveAlpha = mix( baseAlpha, float( 1.0 ), reject );
316
- const blended = mix( prevColor, currentColor, effectiveAlpha );
317
- const newHistory = mix(
318
- min( historyLength.add( 1.0 ), maxAccumFrames ),
319
- float( 1.0 ),
320
- reject
321
- );
327
+ const ndCurrent = textureLoad( ndTex, coord );
328
+ const nCurrent = ndCurrent.xyz.mul( 2.0 ).sub( 1.0 );
329
+ const depthCurrent = ndCurrent.w;
330
+
331
+ const x0 = int( prevXf );
332
+ const y0 = int( prevYf );
333
+ const x1 = x0.add( int( 1 ) );
334
+ const y1 = y0.add( int( 1 ) );
335
+ const fx = prevXf.sub( float( x0 ) );
336
+ const fy = prevYf.sub( float( y0 ) );
337
+
338
+ const x0c = x0.clamp( int( 0 ), int( resW ).sub( 1 ) );
339
+ const x1c = x1.clamp( int( 0 ), int( resW ).sub( 1 ) );
340
+ const y0c = y0.clamp( int( 0 ), int( resH ).sub( 1 ) );
341
+ const y1c = y1.clamp( int( 0 ), int( resH ).sub( 1 ) );
342
+
343
+ const w00 = float( 1.0 ).sub( fx ).mul( float( 1.0 ).sub( fy ) );
344
+ const w10 = fx.mul( float( 1.0 ).sub( fy ) );
345
+ const w01 = float( 1.0 ).sub( fx ).mul( fy );
346
+ const w11 = fx.mul( fy );
347
+
348
+ // SVGF soft per-tap weight: bilinear × normal × depth
349
+ // (prev-frame normalDepth, geometric normals — stable).
350
+ const tapValid = ( xi, yi, bilinearW ) => {
351
+
352
+ const prevND = textureLoad( prevNDTex, ivec2( xi, yi ) );
353
+ const nPrev = prevND.xyz.mul( 2.0 ).sub( 1.0 );
354
+ const depthPrev = prevND.w;
355
+ const normalDot = dot( nCurrent, nPrev ).clamp( 0.0, 1.0 );
356
+ const normalW = pow( normalDot, float( NORMAL_POWER ) );
357
+ const depthDelta = abs( depthCurrent.sub( depthPrev ) )
358
+ .div( max( depthCurrent, float( 0.001 ) ) );
359
+ const depthW = exp( depthDelta.div( float( DEPTH_SIGMA ) ).negate() );
360
+ return bilinearW.mul( normalW ).mul( depthW );
361
+
362
+ };
363
+
364
+ const v00 = tapValid( x0c, y0c, w00 );
365
+ const v10 = tapValid( x1c, y0c, w10 );
366
+ const v01 = tapValid( x0c, y1c, w01 );
367
+ const v11 = tapValid( x1c, y1c, w11 );
368
+
369
+ const wSum = v00.add( v10 ).add( v01 ).add( v11 );
370
+
371
+ If( wSum.greaterThan( float( VALIDITY_THRESHOLD ) ), () => {
372
+
373
+ const p00 = textureLoad( prevTemporalTex, ivec2( x0c, y0c ) );
374
+ const p10 = textureLoad( prevTemporalTex, ivec2( x1c, y0c ) );
375
+ const p01 = textureLoad( prevTemporalTex, ivec2( x0c, y1c ) );
376
+ const p11 = textureLoad( prevTemporalTex, ivec2( x1c, y1c ) );
377
+
378
+ const invWSum = float( 1.0 ).div( wSum );
379
+ const prevLighting = p00.xyz.mul( v00 )
380
+ .add( p10.xyz.mul( v10 ) )
381
+ .add( p01.xyz.mul( v01 ) )
382
+ .add( p11.xyz.mul( v11 ) )
383
+ .mul( invWSum );
384
+
385
+ const prevHistory = p00.w.mul( v00 )
386
+ .add( p10.w.mul( v10 ) )
387
+ .add( p01.w.mul( v01 ) )
388
+ .add( p11.w.mul( v11 ) )
389
+ .mul( invWSum );
390
+
391
+ // adaptive α — disabled by default (gradientStrength=0).
392
+ const gradient = textureLoad( gradientTex, coord ).x;
393
+ const adaptiveBoost = gradient.mul( gradientStrength ).clamp( 0.0, 1.0 );
394
+
395
+ const baseAlpha = max(
396
+ float( 1.0 ).div( prevHistory.add( 1.0 ) ),
397
+ temporalAlphaMin
398
+ );
399
+ const effectiveAlpha = mix( baseAlpha, float( 1.0 ), adaptiveBoost );
400
+
401
+ const blendedLighting = mix( prevLighting, currentLighting, effectiveAlpha );
402
+ const newHistory = min( prevHistory.add( 1.0 ), maxAccumFrames );
403
+
404
+ demodResult.assign( vec4( blendedLighting, newHistory ) );
405
+ modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ), 1.0 ) );
322
406
 
323
- result.assign( vec4( blended, newHistory ) );
324
-
325
- } ).Else( () => {
326
-
327
- result.assign( vec4( currentColor, 1.0 ) );
407
+ } );
328
408
 
329
409
  } );
330
410
 
@@ -333,7 +413,13 @@ export class ASVGF extends RenderStage {
333
413
  textureStore(
334
414
  writeTemporalTex,
335
415
  uvec2( uint( gx ), uint( gy ) ),
336
- result
416
+ demodResult
417
+ ).toWriteOnly();
418
+
419
+ textureStore(
420
+ outputModulatedTex,
421
+ uvec2( uint( gx ), uint( gy ) ),
422
+ modulatedResult
337
423
  ).toWriteOnly();
338
424
 
339
425
  } );
@@ -372,18 +458,17 @@ export class ASVGF extends RenderStage {
372
458
  const coord = ivec2( gx, gy );
373
459
  const result = vec4( 0.0, 0.0, 0.0, 1.0 ).toVar();
374
460
 
375
- // Must be chained If/ElseIf — separate If blocks let inactive-
376
- // branch texture samples contaminate the output.
377
-
378
- // 0: beauty
461
+ // Chained If/ElseIf — separate If blocks let inactive-branch
462
+ // texture samples contaminate the output.
379
463
  If( mode.equal( int( 0 ) ), () => {
380
464
 
465
+ // 0: beauty (modulated ASVGF output)
381
466
  const c = textureLoad( colorTex, coord ).xyz;
382
467
  result.assign( vec4( c, 1.0 ) );
383
468
 
384
469
  } ).ElseIf( mode.equal( int( 1 ) ), () => {
385
470
 
386
- // 1: 3×3 luminance variance of raw path-tracer input
471
+ // 1: 3×3 luminance variance of raw PT input
387
472
  const meanLum = float( 0.0 ).toVar();
388
473
  const meanLumSq = float( 0.0 ).toVar();
389
474
 
@@ -394,7 +479,7 @@ export class ASVGF extends RenderStage {
394
479
  const sx = gx.add( dx ).clamp( int( 0 ), int( resW ).sub( 1 ) );
395
480
  const sy = gy.add( dy ).clamp( int( 0 ), int( resH ).sub( 1 ) );
396
481
  const s = textureLoad( rawColorTex, ivec2( sx, sy ) ).xyz;
397
- const lum = dot( s, vec3( 0.2126, 0.7152, 0.0722 ) );
482
+ const lum = luminance( s );
398
483
  meanLum.addAssign( lum );
399
484
  meanLumSq.addAssign( lum.mul( lum ) );
400
485
 
@@ -409,7 +494,7 @@ export class ASVGF extends RenderStage {
409
494
  const relVar = variance.div( max( meanLum.mul( meanLum ), float( 0.0001 ) ) );
410
495
  const t = relVar.mul( 10.0 ).clamp( 0.0, 1.0 );
411
496
 
412
- // BlueCyanGreenYellowRed
497
+ // bluecyangreenyellowred
413
498
  const r = t.sub( 0.5 ).mul( 4.0 ).clamp( 0.0, 1.0 );
414
499
  const g = t.mul( 4.0 ).clamp( 0.0, 1.0 ).sub(
415
500
  t.sub( 0.75 ).mul( 4.0 ).clamp( 0.0, 1.0 )
@@ -471,22 +556,11 @@ export class ASVGF extends RenderStage {
471
556
 
472
557
  this.on( 'asvgf:setTemporal', ( data ) => {
473
558
 
474
- if ( data && data.enabled !== undefined ) {
475
-
476
- this.temporalEnabled = data.enabled;
477
- this.temporalEnabledU.value = data.enabled ? 1.0 : 0.0;
478
-
479
- }
559
+ if ( data && data.enabled !== undefined ) this.setTemporalEnabled( data.enabled );
480
560
 
481
561
  } );
482
562
 
483
- this.on( 'asvgf:updateParameters', ( data ) => {
484
-
485
- if ( ! data ) return;
486
- if ( data.temporalAlpha !== undefined ) this.temporalAlpha.value = data.temporalAlpha;
487
- if ( data.phiColor !== undefined ) this.phiColor.value = data.phiColor;
488
-
489
- } );
563
+ this.on( 'asvgf:updateParameters', ( data ) => this.updateParameters( data ) );
490
564
 
491
565
  }
492
566
 
@@ -495,7 +569,11 @@ export class ASVGF extends RenderStage {
495
569
  if ( ! this.enabled ) return;
496
570
 
497
571
  const colorTex = context.getTexture( 'pathtracer:color' );
572
+ const albedoTex = context.getTexture( 'pathtracer:albedo' );
498
573
  const normalDepthTex = context.getTexture( 'pathtracer:normalDepth' );
574
+ // First frame fallback — alias current ND.
575
+ const prevNormalDepthTex = context.getTexture( 'pathtracer:prevNormalDepth' )
576
+ || normalDepthTex;
499
577
  const motionTex = context.getTexture( 'motionVector:screenSpace' );
500
578
 
501
579
  if ( ! colorTex ) return;
@@ -513,10 +591,28 @@ export class ASVGF extends RenderStage {
513
591
  }
514
592
 
515
593
  this._colorTexNode.value = colorTex;
594
+ if ( albedoTex ) this._albedoTexNode.value = albedoTex;
516
595
  if ( motionTex ) this._motionTexNode.value = motionTex;
596
+ if ( normalDepthTex ) this._normalDepthTexNode.value = normalDepthTex;
597
+ if ( prevNormalDepthTex ) this._prevNormalDepthTexNode.value = prevNormalDepthTex;
598
+
599
+ const readTemporal = this.currentMoments === 0
600
+ ? this._temporalTexB : this._temporalTexA;
601
+ const writeNode = this.currentMoments === 0
602
+ ? this._temporalNodeA : this._temporalNodeB;
603
+ const writeTemporal = this.currentMoments === 0
604
+ ? this._temporalTexA : this._temporalTexB;
605
+
606
+ // Before first copy seeds the cache, alias current so the gradient
607
+ // sees zero diff (no false boost).
608
+ this._prevColorTexNode.value = this._prevColorReady
609
+ ? this._prevColorRT.texture
610
+ : colorTex;
517
611
 
518
- // Force first-frame compile while TextureNodes still hold EmptyTexture,
519
- // so textureLoad codegen emits the required `level` parameter.
612
+ // First-frame compile while StorageTexture-typed nodes still hold
613
+ // EmptyTexture, so textureLoad codegen emits the required `level`
614
+ // parameter. Binding StorageTextures only AFTER compile keeps the
615
+ // codegen path correct (otherwise reads would return zero at runtime).
520
616
  if ( ! this._compiled ) {
521
617
 
522
618
  this.renderer.compute( this._gradientNode );
@@ -526,28 +622,43 @@ export class ASVGF extends RenderStage {
526
622
 
527
623
  }
528
624
 
529
- // Ping-pong: read opposite, write current
530
- const readTemporal = this.currentMoments === 0
531
- ? this._temporalTexB : this._temporalTexA;
532
- const writeNode = this.currentMoments === 0
533
- ? this._temporalNodeA : this._temporalNodeB;
534
- const writeTemporal = this.currentMoments === 0
535
- ? this._temporalTexA : this._temporalTexB;
536
-
537
625
  this._readTemporalTexNode.value = readTemporal;
626
+ this._gradientReadTexNode.value = this._gradientStorageTex;
627
+
628
+ // Skip the gradient dispatch when nothing consumes it. The temporal
629
+ // pass reads gradientTex unconditionally but multiplies by
630
+ // gradientStrength=0 → the stale prior frame's gradient texture is
631
+ // fine (the result is zeroed out anyway).
632
+ const needsGradient = this.gradientStrength.value > 0 || this.showHeatmap;
633
+ if ( needsGradient ) {
634
+
635
+ this.renderer.compute( this._gradientNode );
636
+
637
+ }
638
+
538
639
  this.renderer.compute( writeNode );
539
640
 
540
- context.setTexture( 'asvgf:output', writeTemporal );
541
- context.setTexture( 'asvgf:temporalColor', writeTemporal );
641
+ // Cache this frame's pathtracer:color for next frame's gradient if it's
642
+ // active. Copy AFTER reads so we don't clobber the prev view.
643
+ if ( needsGradient ) {
644
+
645
+ this.renderer.copyTextureToTexture( colorTex, this._prevColorRT.texture );
646
+ this._prevColorReady = true;
647
+
648
+ }
649
+
650
+ context.setTexture( 'asvgf:demodulated', writeTemporal );
651
+ context.setTexture( 'asvgf:output', this._outputModulatedTex );
652
+ context.setTexture( 'asvgf:gradient', this._gradientStorageTex );
542
653
 
543
654
  this.currentMoments = 1 - this.currentMoments;
544
655
 
545
656
  if ( this.showHeatmap ) {
546
657
 
547
- this.renderer.compute( this._gradientNode );
548
-
658
+ // Mode 0 needs modulated for direct display; mode 2 needs the
659
+ // ping-pong's history length in alpha.
549
660
  this._heatmapRawColorTexNode.value = colorTex;
550
- this._heatmapColorTexNode.value = writeTemporal;
661
+ this._heatmapColorTexNode.value = this._outputModulatedTex;
551
662
  this._heatmapTemporalTexNode.value = writeTemporal;
552
663
  if ( normalDepthTex ) this._heatmapNDTexNode.value = normalDepthTex;
553
664
  if ( motionTex ) this._heatmapMotionTexNode.value = motionTex;
@@ -558,15 +669,8 @@ export class ASVGF extends RenderStage {
558
669
 
559
670
  }
560
671
 
561
- this.frameCount ++;
562
-
563
672
  }
564
673
 
565
- /**
566
- * Enable or disable the heatmap compute pass. When enabled, the heatmap
567
- * is rendered each frame to {@link this.heatmapTarget} (a public RenderTarget)
568
- * for the host to display however it wants.
569
- */
570
674
  setHeatmapEnabled( enabled ) {
571
675
 
572
676
  this.showHeatmap = enabled;
@@ -575,7 +679,7 @@ export class ASVGF extends RenderStage {
575
679
 
576
680
  setTemporalEnabled( enabled ) {
577
681
 
578
- this.temporalEnabled = enabled;
682
+ this.temporalEnabledU.value = enabled ? 1.0 : 0.0;
579
683
 
580
684
  }
581
685
 
@@ -583,15 +687,18 @@ export class ASVGF extends RenderStage {
583
687
 
584
688
  if ( ! params ) return;
585
689
  if ( params.temporalAlpha !== undefined ) this.temporalAlpha.value = params.temporalAlpha;
586
- if ( params.phiColor !== undefined ) this.phiColor.value = params.phiColor;
690
+ if ( params.gradientStrength !== undefined ) this.gradientStrength.value = params.gradientStrength;
691
+ if ( params.gradientNoiseFloor !== undefined ) this.gradientNoiseFloor.value = params.gradientNoiseFloor;
692
+ if ( params.maxAccumFrames !== undefined ) this.maxAccumFrames.value = params.maxAccumFrames;
587
693
  if ( params.debugMode !== undefined ) this.debugMode.value = params.debugMode;
588
694
 
589
695
  }
590
696
 
591
697
  resetTemporalData() {
592
698
 
593
- this.frameCount = 0;
594
699
  this.currentMoments = 0;
700
+ // Drop cache so post-reset frames don't see pre-reset prev color.
701
+ this._prevColorReady = false;
595
702
 
596
703
  }
597
704
 
@@ -599,10 +706,11 @@ export class ASVGF extends RenderStage {
599
706
 
600
707
  this._temporalTexA.setSize( width, height );
601
708
  this._temporalTexB.setSize( width, height );
709
+ this._outputModulatedTex.setSize( width, height );
602
710
  this._gradientStorageTex.setSize( width, height );
711
+ this._prevColorRT.setSize( width, height );
603
712
  this._heatmapStorageTex.setSize( width, height );
604
713
  this.heatmapTarget.setSize( width, height );
605
- this.heatmapTarget.texture.needsUpdate = true;
606
714
  this.resW.value = width;
607
715
  this.resH.value = height;
608
716
 
@@ -613,12 +721,16 @@ export class ASVGF extends RenderStage {
613
721
  this._temporalNodeB.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
614
722
  this._heatmapComputeNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
615
723
 
724
+ // Buffers reallocated → re-run first-frame compile and re-seed cache.
725
+ this._compiled = false;
726
+ this._prevColorReady = false;
727
+
616
728
  }
617
729
 
618
730
  reset() {
619
731
 
620
- // No-op: motion vectors handle camera moves; only explicit
621
- // 'asvgf:reset' (scene change, render-mode switch) clears history.
732
+ // No-op: motion vectors handle camera moves; explicit asvgf:reset
733
+ // clears history on scene/mode change.
622
734
 
623
735
  }
624
736
 
@@ -629,14 +741,21 @@ export class ASVGF extends RenderStage {
629
741
  this._temporalNodeB?.dispose();
630
742
  this._temporalTexA?.dispose();
631
743
  this._temporalTexB?.dispose();
744
+ this._outputModulatedTex?.dispose();
632
745
  this._gradientStorageTex?.dispose();
746
+ this._prevColorRT?.dispose();
633
747
  this._heatmapComputeNode?.dispose();
634
748
  this._heatmapStorageTex?.dispose();
635
749
  this.heatmapTarget?.dispose();
636
750
 
637
751
  this._colorTexNode?.dispose();
752
+ this._prevColorTexNode?.dispose();
753
+ this._albedoTexNode?.dispose();
638
754
  this._motionTexNode?.dispose();
755
+ this._normalDepthTexNode?.dispose();
756
+ this._prevNormalDepthTexNode?.dispose();
639
757
  this._readTemporalTexNode?.dispose();
758
+ this._gradientReadTexNode?.dispose();
640
759
 
641
760
  this._heatmapRawColorTexNode?.dispose();
642
761
  this._heatmapColorTexNode?.dispose();