rayzee 7.0.0 → 7.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.0.0",
3
+ "version": "7.2.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -127,6 +127,9 @@ export const ENGINE_DEFAULTS = {
127
127
  asvgfPhiDepth: 1.0,
128
128
  asvgfVarianceBoost: 1.0,
129
129
  asvgfMaxAccumFrames: 32,
130
+ asvgfGradientStrength: 0.0,
131
+ asvgfGradientSigmaScale: 2.0,
132
+ asvgfGradientNoiseFloor: 0.0,
130
133
  asvgfDebugMode: 0,
131
134
  asvgfQualityPreset: 'medium',
132
135
  showAsvgfHeatmap: false,
@@ -158,11 +161,15 @@ export const MAX_STORAGE_TEXTURE_SIZE = 2048;
158
161
 
159
162
  export const ASVGF_QUALITY_PRESETS = {
160
163
  // phiColor / phiDepth are RELATIVE tolerances (fractions). Bigger = more
161
- // permissive. gradientStrength = 0 keeps the adaptive-α boost off; the
162
- // fixed-floor gradient misfires on 1-SPP noise. Pure SVGF temporal runs.
164
+ // permissive. The adaptive temporal gradient (gradientStrength > 0) is always
165
+ // on: it measures real change in units of noise σ (gradientSigmaScale), so a
166
+ // static scene reads ~0 (no convergence penalty) and only moving lights / anim
167
+ // / disocclusion drop history. See ASVGF._buildGradientCompute.
163
168
  low: {
164
169
  temporalAlpha: 0.1,
165
- gradientStrength: 0.0,
170
+ gradientStrength: 0.8,
171
+ gradientSigmaScale: 2.5,
172
+ gradientNoiseFloor: 0.05,
166
173
  atrousIterations: 3,
167
174
  phiColor: 1.0,
168
175
  phiNormal: 64.0,
@@ -173,7 +180,9 @@ export const ASVGF_QUALITY_PRESETS = {
173
180
  },
174
181
  medium: {
175
182
  temporalAlpha: 0.03,
176
- gradientStrength: 0.0,
183
+ gradientStrength: 1.0,
184
+ gradientSigmaScale: 2.5,
185
+ gradientNoiseFloor: 0.05,
177
186
  atrousIterations: 4,
178
187
  phiColor: 0.5,
179
188
  phiNormal: 128.0,
@@ -184,7 +193,9 @@ export const ASVGF_QUALITY_PRESETS = {
184
193
  },
185
194
  high: {
186
195
  temporalAlpha: 0.0,
187
- gradientStrength: 0.0,
196
+ gradientStrength: 1.0,
197
+ gradientSigmaScale: 2.5,
198
+ gradientNoiseFloor: 0.05,
188
199
  atrousIterations: 6,
189
200
  phiColor: 0.3,
190
201
  phiNormal: 256.0,
@@ -310,12 +310,19 @@ export class PathTracerApp extends EventDispatcher {
310
310
  }
311
311
 
312
312
  this._ensureVRAMWiring();
313
- const mem = this.stages.pathTracer?.vramTracker?.measure();
313
+ // VRAM is monotonic and only changes on allocation events (scene/env
314
+ // load, resize — each re-measures via _ensureVRAMWiring). Within an
315
+ // accumulation burst nothing reallocates, so re-walking every stage's
316
+ // textures each frame is wasted. Measure at burst start (catches any
317
+ // reset-triggered allocation) + a periodic backstop; read cached otherwise.
318
+ const tracker = this.stages.pathTracer?.vramTracker;
319
+ const frame = this.stages.pathTracer?.frameCount ?? 0;
320
+ if ( tracker && ( frame <= 1 || frame % 30 === 0 ) ) tracker.measure();
314
321
  updateStats( {
315
322
  timeElapsed: this.completion.timeElapsed,
316
323
  samples: getDisplaySamples( this.stages.pathTracer ),
317
- memoryUsed: mem?.current ?? 0,
318
- memoryPeak: mem?.peak ?? 0,
324
+ memoryUsed: tracker?.current ?? 0,
325
+ memoryPeak: tracker?.peak ?? 0,
319
326
  } );
320
327
 
321
328
  // Check time limit
@@ -11,10 +11,13 @@ import { StorageInstancedBufferAttribute } from 'three/webgpu';
11
11
 
12
12
  export const RAY_STRIDE = 7;
13
13
  export const HIT_STRIDE = 2;
14
- // Per-pixel G-buffer (first-hit MRT staging): 1 uvec4/pixel, half-precision packed (pack2x16, no f32 bitcast).
15
- // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg) .w=packUnorm2x16(albedo.b, 0)
16
- // Separate buffer from RAY (per-pixel, not per-ray×S) — written by Generate/Shade bounce-0, read only by FinalWrite.
17
- export const GBUFFER_STRIDE = 1;
14
+ // Per-pixel G-buffer (first-hit MRT staging): 2 uvec4/pixel (AoS, element p*GBUFFER_STRIDE + lane).
15
+ // lane 0 — half-packed normal/depth/albedo (pack2x16, no f32 bitcast); read by FinalWrite:
16
+ // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg) .w=packUnorm2x16(albedo.b, 0)
17
+ // lane 1 primary-hit surface ID for A-SVGF correlated-gradient re-projection (Tier 1); written at the
18
+ // bounce-0 hit, valid=0 on miss (Generate inits): .x=triIndex .y=meshIndex .z=packUnorm2x16(bary.u,bary.v) .w=valid
19
+ // Separate buffer from RAY (per-pixel, not per-ray×S) — written by Generate/Shade bounce-0.
20
+ export const GBUFFER_STRIDE = 2;
18
21
 
19
22
  export const RAY = {
20
23
  ORIGIN_META: 0, // vec4(origin.xyz, uintBitsToFloat(perRayBounces | sssSteps<<8)); pixelIndex+sampleIndex derived from rayID
@@ -146,17 +149,40 @@ export const readRayPdf = ( buf, id ) =>
146
149
  export const readRayRadiance = ( buf, id ) =>
147
150
  buf.element( soa( id, RAY.RADIANCE_ALPHA ) );
148
151
 
149
- // ── Per-pixel G-buffer (first-hit MRT). 1 uvec4/pixel (element p), pack2x16 lanes. ──
152
+ // ── Per-pixel G-buffer (first-hit MRT). 2 uvec4/pixel (AoS), pack2x16 lanes. ──
150
153
  // normal: raw unit vec3; depth: linear [0,1]; albedo: vec3 [0,1]. Packed values live in u32 lanes
151
154
  // verbatim (no f32 bitcast) so NaN-range bit patterns (snorm ±1 → 0x7FFF) survive store/load intact.
155
+ // gbLane resolves the AoS slot for a pixel (lane 0 = MRT, lane 1 = surface ID).
156
+ const gbLane = ( pixelIndex, lane ) => {
157
+
158
+ const base = uint( pixelIndex ).mul( GBUFFER_STRIDE );
159
+ return lane === 0 ? base : base.add( lane );
160
+
161
+ };
162
+
152
163
  export const writeGBuffer = ( buf, pixelIndex, normal, depth, albedo ) =>
153
- buf.element( pixelIndex ).assign( uvec4(
164
+ buf.element( gbLane( pixelIndex, 0 ) ).assign( uvec4(
154
165
  packSnorm2x16( vec2( normal.x, normal.y ) ),
155
166
  packSnorm2x16( vec2( normal.z, depth ) ),
156
167
  packUnorm2x16( vec2( albedo.x, albedo.y ) ),
157
168
  packUnorm2x16( vec2( albedo.z, 0.0 ) ),
158
169
  ) );
159
- export const readGBuffer = ( buf, pixelIndex ) => buf.element( pixelIndex );
170
+ export const readGBuffer = ( buf, pixelIndex ) => buf.element( gbLane( pixelIndex, 0 ) );
171
+
172
+ // Lane 1 — primary-hit surface ID for A-SVGF correlated gradient re-projection (Tier 1).
173
+ // valid=0 marks a miss (no primary surface); bary packed unorm (both in [0,1]).
174
+ export const writeGBufferSurfaceID = ( buf, pixelIndex, triIndex, meshIndex, baryU, baryV, valid ) =>
175
+ buf.element( gbLane( pixelIndex, 1 ) ).assign( uvec4(
176
+ uint( triIndex ), uint( meshIndex ), packUnorm2x16( vec2( baryU, baryV ) ), uint( valid ),
177
+ ) );
178
+ export const readGBufferSurfaceID = ( buf, pixelIndex ) => {
179
+
180
+ const p = buf.element( gbLane( pixelIndex, 1 ) );
181
+ const bary = unpackUnorm2x16( p.z );
182
+ return { triIndex: p.x, meshIndex: p.y, baryU: bary.x, baryV: bary.y, valid: p.w };
183
+
184
+ };
185
+
160
186
  // Decode for FinalWrite. normalDepth.xyz matches the prior path (normal*0.5+0.5), .w = raw depth.
161
187
  export const gbDecodeNormalDepth = ( packed ) => {
162
188
 
@@ -1,5 +1,5 @@
1
1
  import { Fn, vec3, vec4, float, int, uint, ivec2, uvec2, uniform,
2
- If, dot, max, min, abs, mix, pow, exp,
2
+ If, dot, max, min, abs, mix, pow, exp, sqrt, select,
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, Box2, Vector2 } from 'three';
@@ -7,14 +7,21 @@ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
7
7
  import { luminance } from '../TSL/Common.js';
8
8
  import { ALBEDO_EPS, MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
9
9
 
10
+ // Replace NaN/±Inf with a bounded value so one firefly can't permanently poison the
11
+ // temporal EMA (mix() propagates NaN forever). Per-channel: NaN (x!=x) → 0, ±Inf → [0,1e7].
12
+ const sanitize1 = ( x ) => select( x.equal( x ), x, float( 0.0 ) ).clamp( 0.0, 1e7 );
13
+ const sanitizeRGB = ( c ) => vec3( sanitize1( c.x ), sanitize1( c.y ), sanitize1( c.z ) );
14
+
10
15
  /**
11
- * ASVGF — SVGF temporal + spatial denoising with albedo demodulation.
16
+ * ASVGF — SVGF temporal denoising with albedo demodulation + adaptive-α.
12
17
  *
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
+ * Adaptive temporal gradient (the "A"): the gradient kernel compares this
19
+ * frame's demodulated lighting against the reprojected accumulated history,
20
+ * floored by a per-pixel σ (max(Δ k·σ, 0)), max-normalised and squared
21
+ * (Q2RTX get_gradient). gradientStrength scales it into effectiveAlpha =
22
+ * mix(baseAlpha, 1, gradient·gradientStrength) high change drop history
23
+ * (anti-lag). All quality presets enable it (gradientStrength > 0); a static
24
+ * scene reads gradient ≈ 0, so convergence is unaffected.
18
25
  *
19
26
  * Reads: pathtracer:color, pathtracer:albedo, pathtracer:normalDepth,
20
27
  * pathtracer:prevNormalDepth, motionVector:screenSpace
@@ -34,16 +41,21 @@ export class ASVGF extends RenderStage {
34
41
 
35
42
  this.temporalAlpha = uniform( options.temporalAlpha ?? 0.0 );
36
43
  this.gradientStrength = uniform( options.gradientStrength ?? 0.0 );
37
- this.gradientNoiseFloor = uniform( options.gradientNoiseFloor ?? 0.15 );
44
+ // σ multiplier for the per-pixel noise floor (NRD luminanceSigmaScale 2).
45
+ this.gradientSigmaScale = uniform( options.gradientSigmaScale ?? 2.0 );
46
+ // Secondary relative floor on the normalised gradient (0 = rely on σ alone).
47
+ this.gradientNoiseFloor = uniform( options.gradientNoiseFloor ?? 0.0 );
38
48
  this.maxAccumFrames = uniform( options.maxAccumFrames ?? 32.0 );
39
49
 
40
50
  this.resW = uniform( options.width || 1 );
41
51
  this.resH = uniform( options.height || 1 );
42
52
 
43
53
  this.temporalEnabledU = uniform( 1.0 );
54
+ // 1.0 for one frame after asvgf:reset → forces a fresh sample so stale pre-reset
55
+ // (wrong-scene) history isn't blended in (mirrors Variance's _needsWarmReset).
56
+ this.forceResetU = uniform( 0.0 );
44
57
 
45
58
  this._colorTexNode = new TextureNode();
46
- this._prevColorTexNode = new TextureNode();
47
59
  this._albedoTexNode = new TextureNode();
48
60
  this._motionTexNode = new TextureNode();
49
61
  this._normalDepthTexNode = new TextureNode();
@@ -112,20 +124,9 @@ export class ASVGF extends RenderStage {
112
124
  stencilBuffer: false
113
125
  } );
114
126
 
115
- // FloatType to match pathtracer:color (PT MRT). copyTextureToTexture
116
- // requires identical formats.
117
- this._prevColorRT = new RenderTarget( w, h, {
118
- type: FloatType,
119
- format: RGBAFormat,
120
- minFilter: NearestFilter,
121
- magFilter: NearestFilter,
122
- depthBuffer: false,
123
- stencilBuffer: false
124
- } );
125
- this._prevColorReady = false;
126
-
127
127
  this.currentMoments = 0; // 0 = write A, read B; 1 = write B, read A
128
128
  this._compiled = false;
129
+ this._needsWarmReset = false;
129
130
 
130
131
  this._dispatchX = Math.ceil( w / 8 );
131
132
  this._dispatchY = Math.ceil( h / 8 );
@@ -162,17 +163,24 @@ export class ASVGF extends RenderStage {
162
163
 
163
164
  }
164
165
 
165
- // Per-pixel adaptive-α signal: 5×5 spatial average of |currentLum − prevLum|
166
- // / meanLum, both raw single-SPP (noise-comparable), with noise-floor
167
- // subtraction. Currently gated off by gradientStrength=0 kept compiled
168
- // to drive heatmap mode 5 and as scaffolding for a proper variance-aware
169
- // implementation.
166
+ // Adaptive temporal gradient: per pixel, compare this frame's DEMODULATED
167
+ // lighting against the motion-reprojected accumulated history (low-noise),
168
+ // floored by a per-pixel σ from the 5×5 spatial neighbourhood. The σ floor
169
+ // is what the old fixed 0.15 constant couldn't give — on a static scene the
170
+ // 1-SPP sample sits within ±k·σ of the converged estimate so the gradient
171
+ // reads ~0 (no convergence penalty); only real change (moving light, anim,
172
+ // disocclusion) exceeds it. Demodulation (vs raw color) keeps albedo/texture
173
+ // edges from false-firing. Output .x feeds effectiveAlpha in the temporal pass.
170
174
  _buildGradientCompute() {
171
175
 
172
176
  const colorTex = this._colorTexNode;
173
- const prevColorTex = this._prevColorTexNode;
177
+ const albedoTex = this._albedoTexNode;
174
178
  const motionTex = this._motionTexNode;
179
+ // Accumulated history (demodulated lighting in .xyz, history count in .w).
180
+ // Same node the temporal pass reads; set to readTemporal before dispatch.
181
+ const histTex = this._readTemporalTexNode;
175
182
  const noiseFloor = this.gradientNoiseFloor;
183
+ const sigmaScale = this.gradientSigmaScale;
176
184
  const gradientStorageTex = this._gradientStorageTex;
177
185
  const resW = this.resW;
178
186
  const resH = this.resH;
@@ -185,7 +193,7 @@ export class ASVGF extends RenderStage {
185
193
  const WG_THREADS = WG_SIZE * WG_SIZE; // 64
186
194
 
187
195
  const sharedCurLum = workgroupArray( 'float', TILE_TOTAL );
188
- const sharedPrevLum = workgroupArray( 'float', TILE_TOTAL );
196
+ const sharedHistLum = workgroupArray( 'float', TILE_TOTAL );
189
197
 
190
198
  const computeFn = Fn( () => {
191
199
 
@@ -204,20 +212,28 @@ export class ASVGF extends RenderStage {
204
212
  const gxL = tileOriginX.add( int( sx ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
205
213
  const gyL = tileOriginY.add( int( sy ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
206
214
 
215
+ // Demodulated current lighting luminance (matches the temporal pass:
216
+ // safeAlbedo = max(albedo, ALBEDO_EPS) keeps sky/dark-material round-trip).
207
217
  const curColor = textureLoad( colorTex, ivec2( gxL, gyL ) ).xyz;
208
- sharedCurLum.element( k ).assign( luminance( curColor ) );
209
-
218
+ const curAlbedo = textureLoad( albedoTex, ivec2( gxL, gyL ) ).xyz;
219
+ const curLighting = sanitizeRGB( curColor.div( max( curAlbedo, vec3( ALBEDO_EPS ) ) ) );
220
+ const curLum = luminance( curLighting ).toVar();
221
+ sharedCurLum.element( k ).assign( curLum );
222
+
223
+ // Reproject the accumulated history to this pixel; read its lighting
224
+ // luminance. Invalid motion → mirror current so the delta is 0
225
+ // (disocclusion handled by the temporal pass's geometric gate).
210
226
  const motion = textureLoad( motionTex, ivec2( gxL, gyL ) );
211
227
  const prevXf = float( gxL ).sub( motion.x.mul( resW ) );
212
228
  const prevYf = float( gyL ).sub( motion.y.mul( resH ) );
213
- const prevX = int( prevXf ).clamp( int( 0 ), int( resW ).sub( 1 ) );
214
- const prevY = int( prevYf ).clamp( int( 0 ), int( resH ).sub( 1 ) );
215
- // Invalid prev mirror current so the diff contributes 0;
216
- // disocclusion is handled by the geometric gate downstream.
229
+ // Round-to-nearest (not truncate) the gradient does a nearest-neighbour
230
+ // history lookup; +0.5 removes the half-pixel floor bias vs the temporal tap.
231
+ const prevX = int( prevXf.add( 0.5 ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
232
+ const prevY = int( prevYf.add( 0.5 ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
217
233
  const motionValid = motion.w.greaterThan( 0.5 );
218
- const prevColor = textureLoad( prevColorTex, ivec2( prevX, prevY ) ).xyz;
219
- const prevLum = motionValid.select( luminance( prevColor ), luminance( curColor ) );
220
- sharedPrevLum.element( k ).assign( prevLum );
234
+ const histLighting = textureLoad( histTex, ivec2( prevX, prevY ) ).xyz;
235
+ const histLum = motionValid.select( luminance( histLighting ), curLum );
236
+ sharedHistLum.element( k ).assign( histLum );
221
237
 
222
238
  };
223
239
 
@@ -245,8 +261,33 @@ export class ASVGF extends RenderStage {
245
261
 
246
262
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
247
263
 
248
- const sumDiff = float( 0.0 ).toVar();
249
- const sumMean = float( 0.0 ).toVar();
264
+ // Pass 1 per-pixel noise σ from the 5×5 (demodulated) neighbourhood.
265
+ const sumLum = float( 0.0 ).toVar();
266
+ const sumLumSq = float( 0.0 ).toVar();
267
+
268
+ for ( let dy = - TILE_BORDER; dy <= TILE_BORDER; dy ++ ) {
269
+
270
+ for ( let dx = - TILE_BORDER; dx <= TILE_BORDER; dx ++ ) {
271
+
272
+ const idx = ly.add( uint( TILE_BORDER + dy ) )
273
+ .mul( uint( TILE_W ) )
274
+ .add( lx.add( uint( TILE_BORDER + dx ) ) );
275
+ const cL = sharedCurLum.element( idx );
276
+ sumLum.addAssign( cL );
277
+ sumLumSq.addAssign( cL.mul( cL ) );
278
+
279
+ }
280
+
281
+ }
282
+
283
+ const meanLum = sumLum.div( 25.0 );
284
+ const variance = max( sumLumSq.div( 25.0 ).sub( meanLum.mul( meanLum ) ), float( 0.0 ) );
285
+ const sigmaFloor = sqrt( variance ).mul( sigmaScale ).toVar();
286
+
287
+ // Pass 2 — 5×5 average of the squared, σ-floored, max-normalised
288
+ // temporal change. Squaring (Q2RTX get_gradient) suppresses residual
289
+ // MC noise while staying reactive in high contrast.
290
+ const sumG = float( 0.0 ).toVar();
250
291
 
251
292
  for ( let dy = - TILE_BORDER; dy <= TILE_BORDER; dy ++ ) {
252
293
 
@@ -256,26 +297,35 @@ export class ASVGF extends RenderStage {
256
297
  .mul( uint( TILE_W ) )
257
298
  .add( lx.add( uint( TILE_BORDER + dx ) ) );
258
299
  const cL = sharedCurLum.element( idx );
259
- const pL = sharedPrevLum.element( idx );
260
- sumDiff.addAssign( abs( cL.sub( pL ) ) );
261
- sumMean.addAssign( cL.add( pL ).mul( 0.5 ) );
300
+ const hL = sharedHistLum.element( idx );
301
+ const floored = max( abs( cL.sub( hL ) ).sub( sigmaFloor ), float( 0.0 ) );
302
+ const g = floored.div( max( max( cL, hL ), float( 0.001 ) ) );
303
+ // Optional secondary relative floor (noiseFloor=0 → no-op).
304
+ const gf = max( g.sub( noiseFloor ), float( 0.0 ) )
305
+ .div( max( float( 1.0 ).sub( noiseFloor ), float( 0.0001 ) ) );
306
+ sumG.addAssign( gf.mul( gf ) );
262
307
 
263
308
  }
264
309
 
265
310
  }
266
311
 
267
- const rawGradient = sumDiff
268
- .div( max( sumMean, float( 0.001 ) ) )
269
- .clamp( 0.0, 1.0 );
270
- const oneMinusFloor = max( float( 1.0 ).sub( noiseFloor ), float( 0.0001 ) );
271
- const gradient = max( rawGradient.sub( noiseFloor ), float( 0.0 ) )
272
- .div( oneMinusFloor )
273
- .clamp( 0.0, 1.0 );
312
+ const gradientRaw = sumG.div( 25.0 ).clamp( 0.0, 1.0 ).toVar();
313
+
314
+ // Trust the gradient only once enough history has accumulated at the
315
+ // reprojected centre early frames have noisy history false fires.
316
+ const cMotion = textureLoad( motionTex, ivec2( gx, gy ) );
317
+ const cPrevX = int( float( gx ).sub( cMotion.x.mul( resW ) ).add( 0.5 ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
318
+ const cPrevY = int( float( gy ).sub( cMotion.y.mul( resH ) ).add( 0.5 ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
319
+ const histLen = cMotion.w.greaterThan( 0.5 )
320
+ .select( textureLoad( histTex, ivec2( cPrevX, cPrevY ) ).w, float( 0.0 ) );
321
+ const confidence = histLen.div( 4.0 ).clamp( 0.0, 1.0 );
322
+
323
+ const gradient = gradientRaw.mul( confidence );
274
324
 
275
325
  textureStore(
276
326
  gradientStorageTex,
277
327
  uvec2( uint( gx ), uint( gy ) ),
278
- vec4( gradient, rawGradient, sumMean.div( 25.0 ), 1.0 )
328
+ vec4( gradient, gradientRaw, sigmaFloor, 1.0 )
279
329
  ).toWriteOnly();
280
330
 
281
331
  } );
@@ -316,6 +366,7 @@ export class ASVGF extends RenderStage {
316
366
  const temporalAlphaMin = this.temporalAlpha;
317
367
  const gradientStrength = this.gradientStrength;
318
368
  const temporalEnabledU = this.temporalEnabledU;
369
+ const forceResetU = this.forceResetU;
319
370
  const resW = this.resW;
320
371
  const resH = this.resH;
321
372
 
@@ -335,23 +386,28 @@ export class ASVGF extends RenderStage {
335
386
  // Same safeAlbedo on both demod and re-mod sides → exact
336
387
  // round-trip for sky/miss rays where albedo=0.
337
388
  const safeAlbedo = max( currentAlbedo, vec3( ALBEDO_EPS ) );
338
- const currentLighting = currentColor.div( safeAlbedo );
389
+ const currentLighting = sanitizeRGB( currentColor.div( safeAlbedo ) );
339
390
 
340
391
  // Defaults = fresh sample (no temporal blend).
341
392
  const demodResult = vec4( currentLighting, 1.0 ).toVar();
342
393
  const modulatedResult = vec4( currentColor, 1.0 ).toVar();
343
394
 
344
- If( temporalEnabledU.greaterThan( 0.5 ), () => {
395
+ // forceResetU skips the blend for one frame after asvgf:reset → re-anchors
396
+ // history to the current (post-reset) scene instead of the stale ping-pong.
397
+ If( temporalEnabledU.greaterThan( 0.5 ).and( forceResetU.lessThan( 0.5 ) ), () => {
345
398
 
346
399
  const motion = textureLoad( motionTex, coord );
347
400
  const motionValid = motion.w.greaterThan( 0.5 );
348
401
 
349
402
  const prevXf = float( gx ).sub( motion.x.mul( resW ) );
350
403
  const prevYf = float( gy ).sub( motion.y.mul( resH ) );
404
+ // Upper bound is the inclusive last pixel (< res, not < res-1): the 2×2 taps
405
+ // already clamp to [0,res-1] and wSum gates bad taps, so res-1 wrongly
406
+ // rejected the trailing column/row. Matches MotionVector's inclusive UV.
351
407
  const prevOnScreen = prevXf.greaterThanEqual( 0.0 )
352
- .and( prevXf.lessThan( float( resW ).sub( 1.0 ) ) )
408
+ .and( prevXf.lessThan( float( resW ) ) )
353
409
  .and( prevYf.greaterThanEqual( 0.0 ) )
354
- .and( prevYf.lessThan( float( resH ).sub( 1.0 ) ) );
410
+ .and( prevYf.lessThan( float( resH ) ) );
355
411
 
356
412
  If( motionValid.and( prevOnScreen ), () => {
357
413
 
@@ -419,7 +475,7 @@ export class ASVGF extends RenderStage {
419
475
  .add( p11.w.mul( v11 ) )
420
476
  .mul( invWSum );
421
477
 
422
- // adaptive α — disabled by default (gradientStrength=0).
478
+ // adaptive α — gradient·gradientStrength boosts toward 1 on change.
423
479
  const gradient = textureLoad( gradientTex, coord ).x;
424
480
  const adaptiveBoost = gradient.mul( gradientStrength ).clamp( 0.0, 1.0 );
425
481
 
@@ -429,7 +485,9 @@ export class ASVGF extends RenderStage {
429
485
  );
430
486
  const effectiveAlpha = mix( baseAlpha, float( 1.0 ), adaptiveBoost );
431
487
 
432
- const blendedLighting = mix( prevLighting, currentLighting, effectiveAlpha );
488
+ // Sanitize the blend too: a NaN already baked into prevLighting
489
+ // would otherwise survive mix() and re-poison the write target.
490
+ const blendedLighting = sanitizeRGB( mix( prevLighting, currentLighting, effectiveAlpha ) );
433
491
  const newHistory = min( prevHistory.add( 1.0 ), maxAccumFrames );
434
492
 
435
493
  demodResult.assign( vec4( blendedLighting, newHistory ) );
@@ -635,12 +693,6 @@ export class ASVGF extends RenderStage {
635
693
  const writeTemporal = this.currentMoments === 0
636
694
  ? this._temporalTexA : this._temporalTexB;
637
695
 
638
- // Before first copy seeds the cache, alias current so the gradient
639
- // sees zero diff (no false boost).
640
- this._prevColorTexNode.value = this._prevColorReady
641
- ? this._prevColorRT.texture
642
- : colorTex;
643
-
644
696
  // First-frame compile while StorageTexture-typed nodes still hold
645
697
  // EmptyTexture, so textureLoad codegen emits the required `level`
646
698
  // parameter. Binding StorageTextures only AFTER compile keeps the
@@ -668,16 +720,11 @@ export class ASVGF extends RenderStage {
668
720
 
669
721
  }
670
722
 
723
+ // One-shot fresh re-anchor after asvgf:reset, threaded through the SINGLE
724
+ // writeNode dispatch (a dual-node warmup would alias the read target — see setSize).
725
+ this.forceResetU.value = this._needsWarmReset ? 1.0 : 0.0;
671
726
  this.renderer.compute( writeNode );
672
-
673
- // Cache this frame's pathtracer:color for next frame's gradient if it's
674
- // active. Copy AFTER reads so we don't clobber the prev view.
675
- if ( needsGradient ) {
676
-
677
- this.renderer.copyTextureToTexture( colorTex, this._prevColorRT.texture );
678
- this._prevColorReady = true;
679
-
680
- }
727
+ this._needsWarmReset = false;
681
728
 
682
729
  // Copy active region out of the over-allocated StorageTextures into
683
730
  // right-sized RTs; downstream stages UV-sample these.
@@ -733,6 +780,7 @@ export class ASVGF extends RenderStage {
733
780
  if ( ! params ) return;
734
781
  if ( params.temporalAlpha !== undefined ) this.temporalAlpha.value = params.temporalAlpha;
735
782
  if ( params.gradientStrength !== undefined ) this.gradientStrength.value = params.gradientStrength;
783
+ if ( params.gradientSigmaScale !== undefined ) this.gradientSigmaScale.value = params.gradientSigmaScale;
736
784
  if ( params.gradientNoiseFloor !== undefined ) this.gradientNoiseFloor.value = params.gradientNoiseFloor;
737
785
  if ( params.maxAccumFrames !== undefined ) this.maxAccumFrames.value = params.maxAccumFrames;
738
786
  if ( params.debugMode !== undefined ) this.debugMode.value = params.debugMode;
@@ -742,8 +790,9 @@ export class ASVGF extends RenderStage {
742
790
  resetTemporalData() {
743
791
 
744
792
  this.currentMoments = 0;
745
- // Drop cache so post-reset frames don't see pre-reset prev color.
746
- this._prevColorReady = false;
793
+ // Re-anchor history on the next frame the ping-pong textures still hold
794
+ // pre-reset (wrong-scene) lighting + history count, which would otherwise blend in.
795
+ this._needsWarmReset = true;
747
796
 
748
797
  }
749
798
 
@@ -756,8 +805,6 @@ export class ASVGF extends RenderStage {
756
805
  this._outputRT.texture.needsUpdate = true;
757
806
  this._gradientRT.setSize( width, height );
758
807
  this._gradientRT.texture.needsUpdate = true;
759
- this._prevColorRT.setSize( width, height );
760
- this._prevColorRT.texture.needsUpdate = true;
761
808
  this.heatmapTarget.setSize( width, height );
762
809
  this.heatmapTarget.texture.needsUpdate = true;
763
810
  this.resW.value = width;
@@ -775,8 +822,6 @@ export class ASVGF extends RenderStage {
775
822
  // would dispatch both temporal ping-pong nodes while _readTemporalTexNode still
776
823
  // aliases one node's write target, producing a "write-only storage +
777
824
  // TextureBinding in same synchronization scope" validation error.
778
- // Only the size-dependent prev-color cache needs re-seeding.
779
- this._prevColorReady = false;
780
825
 
781
826
  }
782
827
 
@@ -799,13 +844,11 @@ export class ASVGF extends RenderStage {
799
844
  this._demodulatedRT?.dispose();
800
845
  this._outputRT?.dispose();
801
846
  this._gradientRT?.dispose();
802
- this._prevColorRT?.dispose();
803
847
  this._heatmapComputeNode?.dispose();
804
848
  this._heatmapStorageTex?.dispose();
805
849
  this.heatmapTarget?.dispose();
806
850
 
807
851
  this._colorTexNode?.dispose();
808
- this._prevColorTexNode?.dispose();
809
852
  this._albedoTexNode?.dispose();
810
853
  this._motionTexNode?.dispose();
811
854
  this._normalDepthTexNode?.dispose();
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, vec3, vec4, float, int, uint, ivec2, uvec2, uniform, If, max, sqrt,
1
+ import { Fn, wgslFn, vec3, vec4, float, int, uint, ivec2, uvec2, uniform, If, max, mix, sqrt,
2
2
  textureLoad, textureStore, localId, workgroupId } from 'three/tsl';
3
3
  import { RenderTarget, TextureNode, StorageTexture } from 'three/webgpu';
4
4
  import { HalfFloatType, RGBAFormat, LinearFilter, Box2, Vector2 } from 'three';
@@ -59,6 +59,9 @@ export class BilateralFilter extends RenderStage {
59
59
  this.renderer = renderer;
60
60
  this.inputTextureName = options.inputTextureName || 'asvgf:demodulated';
61
61
  this.normalDepthTextureName = options.normalDepthTextureName || 'pathtracer:normalDepth';
62
+ // Mapped (normal/bump-perturbed) normal for the normal edge-stop — geometric
63
+ // normals are flat across a normal-mapped surface so normW can't preserve bump detail.
64
+ this.shadingNormalTextureName = options.shadingNormalTextureName || 'pathtracer:shadingNormal';
62
65
  this.albedoTextureName = options.albedoTextureName || 'pathtracer:albedo';
63
66
  this.varianceTextureName = options.varianceTextureName || 'variance:output';
64
67
  this.iterations = options.iterations ?? 4;
@@ -69,6 +72,10 @@ export class BilateralFilter extends RenderStage {
69
72
  this.phiNormal = uniform( options.phiNormal ?? 128.0 );
70
73
  this.phiDepth = uniform( options.phiDepth ?? 0.05 );
71
74
  this.phiLuminance = uniform( options.phiLuminance ?? 4.0 );
75
+ // Blend Variance's spatial-variance channel into sigma_l (1 = max(temporal, spatial),
76
+ // 0 = temporal-only). Widens the luminance gate where history is thin but the
77
+ // neighbourhood is noisy (disocclusion). Default on — validated −1.7% RMSE @4spp.
78
+ this.spatialVarianceWeight = uniform( options.spatialVarianceWeight ?? 1.0 );
72
79
  this.stepSizeU = uniform( 1, 'int' );
73
80
  // 1 on the final iteration → multiply by albedo to remodulate.
74
81
  this.isLastIterationU = uniform( 0, 'int' );
@@ -77,6 +84,7 @@ export class BilateralFilter extends RenderStage {
77
84
 
78
85
  this._readTexNode = new TextureNode();
79
86
  this._normalDepthTexNode = new TextureNode();
87
+ this._shadingNormalTexNode = new TextureNode();
80
88
  this._albedoTexNode = new TextureNode();
81
89
  this._varianceTexNode = new TextureNode();
82
90
 
@@ -133,12 +141,14 @@ export class BilateralFilter extends RenderStage {
133
141
 
134
142
  const readTexNode = this._readTexNode;
135
143
  const ndTexNode = this._normalDepthTexNode;
144
+ const snTexNode = this._shadingNormalTexNode;
136
145
  const albedoTexNode = this._albedoTexNode;
137
146
  const varTexNode = this._varianceTexNode;
138
147
  const phiColor = this.phiColor;
139
148
  const phiNormal = this.phiNormal;
140
149
  const phiDepth = this.phiDepth;
141
150
  const phiLuminance = this.phiLuminance;
151
+ const spatialVarianceWeight = this.spatialVarianceWeight;
142
152
  const stepSize = this.stepSizeU;
143
153
  const isLastIterationU = this.isLastIterationU;
144
154
  const resW = this.resW;
@@ -165,7 +175,8 @@ export class BilateralFilter extends RenderStage {
165
175
  const coord = ivec2( gx, gy );
166
176
  const centerColor = textureLoad( readTexNode, coord ).xyz;
167
177
  const centerND = textureLoad( ndTexNode, coord );
168
- const centerNormal = centerND.xyz.mul( 2.0 ).sub( 1.0 );
178
+ // Normal edge-stop reads the mapped (shading) normal; depth gate stays geometric.
179
+ const centerNormal = textureLoad( snTexNode, coord ).xyz.mul( 2.0 ).sub( 1.0 );
169
180
  const centerDepth = centerND.w;
170
181
  const centerLum = luminance( centerColor );
171
182
  const centerSafeAlbedo = max( textureLoad( albedoTexNode, coord ).xyz, vec3( ALBEDO_EPS ) );
@@ -176,7 +187,10 @@ export class BilateralFilter extends RenderStage {
176
187
  // from demodulation — otherwise dark materials get an
177
188
  // under-estimated sigma → over-strict luminance gate → no
178
189
  // blending → silhouette dark-outline artifact.
179
- const variance = textureLoad( varTexNode, coord ).z;
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 );
180
194
  const sigmaL = phiLuminance
181
195
  .mul( sqrt( max( variance, float( 0.0 ) ) ) )
182
196
  .div( centerAlbedoLum )
@@ -201,7 +215,7 @@ export class BilateralFilter extends RenderStage {
201
215
 
202
216
  const sColor = textureLoad( readTexNode, ivec2( sx, sy ) ).xyz;
203
217
  const sND = textureLoad( ndTexNode, ivec2( sx, sy ) );
204
- const sNormal = sND.xyz.mul( 2.0 ).sub( 1.0 );
218
+ const sNormal = textureLoad( snTexNode, ivec2( sx, sy ) ).xyz.mul( 2.0 ).sub( 1.0 );
205
219
  const sDepth = sND.w;
206
220
  const sLum = luminance( sColor );
207
221
 
@@ -253,6 +267,8 @@ export class BilateralFilter extends RenderStage {
253
267
  || context.getTexture( 'asvgf:output' )
254
268
  || context.getTexture( 'pathtracer:color' );
255
269
  const ndTex = context.getTexture( this.normalDepthTextureName );
270
+ // Fall back to geometric normalDepth if the mapped normal isn't published.
271
+ const snTex = context.getTexture( this.shadingNormalTextureName ) || ndTex;
256
272
  const albedoTex = context.getTexture( this.albedoTextureName );
257
273
  const varTex = context.getTexture( this.varianceTextureName );
258
274
 
@@ -273,6 +289,7 @@ export class BilateralFilter extends RenderStage {
273
289
 
274
290
  // RenderTarget textures — safe to bind before first-compile.
275
291
  if ( ndTex ) this._normalDepthTexNode.value = ndTex;
292
+ if ( snTex ) this._shadingNormalTexNode.value = snTex;
276
293
  if ( albedoTex ) this._albedoTexNode.value = albedoTex;
277
294
 
278
295
  // First-frame compile while StorageTexture-typed nodes still hold
@@ -329,6 +346,7 @@ export class BilateralFilter extends RenderStage {
329
346
  if ( params.phiNormal !== undefined ) this.phiNormal.value = params.phiNormal;
330
347
  if ( params.phiDepth !== undefined ) this.phiDepth.value = params.phiDepth;
331
348
  if ( params.phiLuminance !== undefined ) this.phiLuminance.value = params.phiLuminance;
349
+ if ( params.spatialVarianceWeight !== undefined ) this.spatialVarianceWeight.value = params.spatialVarianceWeight;
332
350
  if ( params.atrousIterations !== undefined ) this.iterations = params.atrousIterations;
333
351
 
334
352
  }
@@ -364,6 +382,7 @@ export class BilateralFilter extends RenderStage {
364
382
  this._outputTarget?.dispose();
365
383
  this._readTexNode?.dispose();
366
384
  this._normalDepthTexNode?.dispose();
385
+ this._shadingNormalTexNode?.dispose();
367
386
  this._albedoTexNode?.dispose();
368
387
  this._varianceTexNode?.dispose();
369
388