rayzee 7.1.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.1.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",
@@ -161,11 +161,15 @@ export const MAX_STORAGE_TEXTURE_SIZE = 2048;
161
161
 
162
162
  export const ASVGF_QUALITY_PRESETS = {
163
163
  // phiColor / phiDepth are RELATIVE tolerances (fractions). Bigger = more
164
- // permissive. gradientStrength = 0 keeps the adaptive-α boost off; the
165
- // 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.
166
168
  low: {
167
169
  temporalAlpha: 0.1,
168
- gradientStrength: 0.0,
170
+ gradientStrength: 0.8,
171
+ gradientSigmaScale: 2.5,
172
+ gradientNoiseFloor: 0.05,
169
173
  atrousIterations: 3,
170
174
  phiColor: 1.0,
171
175
  phiNormal: 64.0,
@@ -176,7 +180,9 @@ export const ASVGF_QUALITY_PRESETS = {
176
180
  },
177
181
  medium: {
178
182
  temporalAlpha: 0.03,
179
- gradientStrength: 0.0,
183
+ gradientStrength: 1.0,
184
+ gradientSigmaScale: 2.5,
185
+ gradientNoiseFloor: 0.05,
180
186
  atrousIterations: 4,
181
187
  phiColor: 0.5,
182
188
  phiNormal: 128.0,
@@ -187,7 +193,9 @@ export const ASVGF_QUALITY_PRESETS = {
187
193
  },
188
194
  high: {
189
195
  temporalAlpha: 0.0,
190
- gradientStrength: 0.0,
196
+ gradientStrength: 1.0,
197
+ gradientSigmaScale: 2.5,
198
+ gradientNoiseFloor: 0.05,
191
199
  atrousIterations: 6,
192
200
  phiColor: 0.3,
193
201
  phiNormal: 256.0,
@@ -198,17 +206,6 @@ export const ASVGF_QUALITY_PRESETS = {
198
206
  }
199
207
  };
200
208
 
201
- // Adaptive variants — same SVGF quality as the base preset plus the temporal-
202
- // gradient anti-lag enabled (gradientStrength > 0). The gradient measures real
203
- // change in units of noise σ (gradientSigmaScale), so a static scene reads ~0
204
- // and convergence is unaffected; only moving lights/anim/disocclusion drop
205
- // history. Mutating the exported object is fine (const binding, mutable object);
206
- // spreading lets each inherit its base. See ASVGF._buildGradientCompute.
207
- const ADAPTIVE_GRADIENT = { gradientSigmaScale: 2.5, gradientNoiseFloor: 0.05 };
208
- ASVGF_QUALITY_PRESETS.low_adaptive = { ...ASVGF_QUALITY_PRESETS.low, gradientStrength: 0.8, ...ADAPTIVE_GRADIENT };
209
- ASVGF_QUALITY_PRESETS.medium_adaptive = { ...ASVGF_QUALITY_PRESETS.medium, gradientStrength: 1.0, ...ADAPTIVE_GRADIENT };
210
- ASVGF_QUALITY_PRESETS.high_adaptive = { ...ASVGF_QUALITY_PRESETS.high, gradientStrength: 1.0, ...ADAPTIVE_GRADIENT };
211
-
212
209
  export const CAMERA_RANGES = {
213
210
  fov: {
214
211
  min: 10,
@@ -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
@@ -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, sqrt,
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,6 +7,11 @@ 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
16
  * ASVGF — SVGF temporal denoising with albedo demodulation + adaptive-α.
12
17
  *
@@ -15,8 +20,8 @@ import { ALBEDO_EPS, MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
15
20
  * floored by a per-pixel σ (max(Δ − k·σ, 0)), max-normalised and squared
16
21
  * (Q2RTX get_gradient). gradientStrength scales it into effectiveAlpha =
17
22
  * mix(baseAlpha, 1, gradient·gradientStrength) — high change ⇒ drop history
18
- * (anti-lag). gradientStrength=0 (base presets) keeps pure EMA; the
19
- * *_adaptive presets enable it.
23
+ * (anti-lag). All quality presets enable it (gradientStrength > 0); a static
24
+ * scene reads gradient ≈ 0, so convergence is unaffected.
20
25
  *
21
26
  * Reads: pathtracer:color, pathtracer:albedo, pathtracer:normalDepth,
22
27
  * pathtracer:prevNormalDepth, motionVector:screenSpace
@@ -46,6 +51,9 @@ export class ASVGF extends RenderStage {
46
51
  this.resH = uniform( options.height || 1 );
47
52
 
48
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 );
49
57
 
50
58
  this._colorTexNode = new TextureNode();
51
59
  this._albedoTexNode = new TextureNode();
@@ -118,6 +126,7 @@ export class ASVGF extends RenderStage {
118
126
 
119
127
  this.currentMoments = 0; // 0 = write A, read B; 1 = write B, read A
120
128
  this._compiled = false;
129
+ this._needsWarmReset = false;
121
130
 
122
131
  this._dispatchX = Math.ceil( w / 8 );
123
132
  this._dispatchY = Math.ceil( h / 8 );
@@ -207,7 +216,7 @@ export class ASVGF extends RenderStage {
207
216
  // safeAlbedo = max(albedo, ALBEDO_EPS) keeps sky/dark-material round-trip).
208
217
  const curColor = textureLoad( colorTex, ivec2( gxL, gyL ) ).xyz;
209
218
  const curAlbedo = textureLoad( albedoTex, ivec2( gxL, gyL ) ).xyz;
210
- const curLighting = curColor.div( max( curAlbedo, vec3( ALBEDO_EPS ) ) );
219
+ const curLighting = sanitizeRGB( curColor.div( max( curAlbedo, vec3( ALBEDO_EPS ) ) ) );
211
220
  const curLum = luminance( curLighting ).toVar();
212
221
  sharedCurLum.element( k ).assign( curLum );
213
222
 
@@ -217,8 +226,10 @@ export class ASVGF extends RenderStage {
217
226
  const motion = textureLoad( motionTex, ivec2( gxL, gyL ) );
218
227
  const prevXf = float( gxL ).sub( motion.x.mul( resW ) );
219
228
  const prevYf = float( gyL ).sub( motion.y.mul( resH ) );
220
- const prevX = int( prevXf ).clamp( int( 0 ), int( resW ).sub( 1 ) );
221
- const prevY = int( prevYf ).clamp( int( 0 ), int( resH ).sub( 1 ) );
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 ) );
222
233
  const motionValid = motion.w.greaterThan( 0.5 );
223
234
  const histLighting = textureLoad( histTex, ivec2( prevX, prevY ) ).xyz;
224
235
  const histLum = motionValid.select( luminance( histLighting ), curLum );
@@ -303,8 +314,8 @@ export class ASVGF extends RenderStage {
303
314
  // Trust the gradient only once enough history has accumulated at the
304
315
  // reprojected centre — early frames have noisy history → false fires.
305
316
  const cMotion = textureLoad( motionTex, ivec2( gx, gy ) );
306
- const cPrevX = int( float( gx ).sub( cMotion.x.mul( resW ) ) ).clamp( int( 0 ), int( resW ).sub( 1 ) );
307
- const cPrevY = int( float( gy ).sub( cMotion.y.mul( resH ) ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
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 ) );
308
319
  const histLen = cMotion.w.greaterThan( 0.5 )
309
320
  .select( textureLoad( histTex, ivec2( cPrevX, cPrevY ) ).w, float( 0.0 ) );
310
321
  const confidence = histLen.div( 4.0 ).clamp( 0.0, 1.0 );
@@ -355,6 +366,7 @@ export class ASVGF extends RenderStage {
355
366
  const temporalAlphaMin = this.temporalAlpha;
356
367
  const gradientStrength = this.gradientStrength;
357
368
  const temporalEnabledU = this.temporalEnabledU;
369
+ const forceResetU = this.forceResetU;
358
370
  const resW = this.resW;
359
371
  const resH = this.resH;
360
372
 
@@ -374,23 +386,28 @@ export class ASVGF extends RenderStage {
374
386
  // Same safeAlbedo on both demod and re-mod sides → exact
375
387
  // round-trip for sky/miss rays where albedo=0.
376
388
  const safeAlbedo = max( currentAlbedo, vec3( ALBEDO_EPS ) );
377
- const currentLighting = currentColor.div( safeAlbedo );
389
+ const currentLighting = sanitizeRGB( currentColor.div( safeAlbedo ) );
378
390
 
379
391
  // Defaults = fresh sample (no temporal blend).
380
392
  const demodResult = vec4( currentLighting, 1.0 ).toVar();
381
393
  const modulatedResult = vec4( currentColor, 1.0 ).toVar();
382
394
 
383
- 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 ) ), () => {
384
398
 
385
399
  const motion = textureLoad( motionTex, coord );
386
400
  const motionValid = motion.w.greaterThan( 0.5 );
387
401
 
388
402
  const prevXf = float( gx ).sub( motion.x.mul( resW ) );
389
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.
390
407
  const prevOnScreen = prevXf.greaterThanEqual( 0.0 )
391
- .and( prevXf.lessThan( float( resW ).sub( 1.0 ) ) )
408
+ .and( prevXf.lessThan( float( resW ) ) )
392
409
  .and( prevYf.greaterThanEqual( 0.0 ) )
393
- .and( prevYf.lessThan( float( resH ).sub( 1.0 ) ) );
410
+ .and( prevYf.lessThan( float( resH ) ) );
394
411
 
395
412
  If( motionValid.and( prevOnScreen ), () => {
396
413
 
@@ -458,7 +475,7 @@ export class ASVGF extends RenderStage {
458
475
  .add( p11.w.mul( v11 ) )
459
476
  .mul( invWSum );
460
477
 
461
- // adaptive α — disabled by default (gradientStrength=0).
478
+ // adaptive α — gradient·gradientStrength boosts toward 1 on change.
462
479
  const gradient = textureLoad( gradientTex, coord ).x;
463
480
  const adaptiveBoost = gradient.mul( gradientStrength ).clamp( 0.0, 1.0 );
464
481
 
@@ -468,7 +485,9 @@ export class ASVGF extends RenderStage {
468
485
  );
469
486
  const effectiveAlpha = mix( baseAlpha, float( 1.0 ), adaptiveBoost );
470
487
 
471
- 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 ) );
472
491
  const newHistory = min( prevHistory.add( 1.0 ), maxAccumFrames );
473
492
 
474
493
  demodResult.assign( vec4( blendedLighting, newHistory ) );
@@ -701,7 +720,11 @@ export class ASVGF extends RenderStage {
701
720
 
702
721
  }
703
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;
704
726
  this.renderer.compute( writeNode );
727
+ this._needsWarmReset = false;
705
728
 
706
729
  // Copy active region out of the over-allocated StorageTextures into
707
730
  // right-sized RTs; downstream stages UV-sample these.
@@ -767,6 +790,9 @@ export class ASVGF extends RenderStage {
767
790
  resetTemporalData() {
768
791
 
769
792
  this.currentMoments = 0;
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;
770
796
 
771
797
  }
772
798
 
@@ -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
 
@@ -1,11 +1,13 @@
1
1
  import { Fn, vec3, vec4, float, int, uint, uvec2, uniform, normalize, mat3, storage, If,
2
- textureStore, workgroupId, localId } from 'three/tsl';
2
+ texture, textureStore, workgroupId, localId } from 'three/tsl';
3
3
  import { RenderTarget, StorageTexture } from 'three/webgpu';
4
- import { HalfFloatType, RGBAFormat, NearestFilter, Matrix4, Box2, Vector2 } from 'three';
4
+ import { HalfFloatType, RGBAFormat, NearestFilter, LinearFilter, DataArrayTexture, Matrix4, Box2, Vector2 } from 'three';
5
5
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
6
6
  import { MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
7
- import { Ray, HitInfo } from '../TSL/Struct.js';
7
+ import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
8
8
  import { traverseBVH } from '../TSL/BVHTraversal.js';
9
+ import { getMaterial } from '../TSL/Common.js';
10
+ import { computeUVCache, processNormal, processBump } from '../TSL/TextureSampling.js';
9
11
 
10
12
  /**
11
13
  * NormalDepth — primary-ray G-buffer for SVGF gates.
@@ -21,7 +23,13 @@ import { traverseBVH } from '../TSL/BVHTraversal.js';
21
23
  * aliases current — without that aliasing prev would point at older data
22
24
  * while this frame's motion vector reflects zero motion → false rejection.
23
25
  *
24
- * Publishes: pathtracer:normalDepth, pathtracer:prevNormalDepth
26
+ * Also emits a SHADING normal (geometric normal perturbed by the normal/bump
27
+ * map, recomputed from the SAME deterministic hit — no extra ray) so the
28
+ * spatial denoiser's edge-stop can see normal-map detail the flat geometric
29
+ * normal hides. Deterministic ⇒ jitter-free, so it's safe for the gates.
30
+ *
31
+ * Publishes: pathtracer:normalDepth, pathtracer:prevNormalDepth,
32
+ * pathtracer:shadingNormal
25
33
  */
26
34
  export class NormalDepth extends RenderStage {
27
35
 
@@ -52,6 +60,22 @@ export class NormalDepth extends RenderStage {
52
60
  this._outputStorageTex.minFilter = NearestFilter;
53
61
  this._outputStorageTex.magFilter = NearestFilter;
54
62
 
63
+ // Shading-normal output (geometric normal perturbed by normal/bump map).
64
+ // Single buffer — only the spatial filter (current frame) consumes it.
65
+ this._shadingStorageTex = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
66
+ this._shadingStorageTex.type = HalfFloatType;
67
+ this._shadingStorageTex.format = RGBAFormat;
68
+ this._shadingStorageTex.minFilter = NearestFilter;
69
+ this._shadingStorageTex.magFilter = NearestFilter;
70
+ this._shadingRT = new RenderTarget( w, h, {
71
+ type: HalfFloatType,
72
+ format: RGBAFormat,
73
+ minFilter: NearestFilter,
74
+ magFilter: NearestFilter,
75
+ depthBuffer: false,
76
+ stencilBuffer: false
77
+ } );
78
+
55
79
  this._srcRegion = new Box2( new Vector2( 0, 0 ), new Vector2( 0, 0 ) );
56
80
 
57
81
  // Ping-pong RTs share format with the StorageTexture so copyTextureToTexture works.
@@ -73,11 +97,30 @@ export class NormalDepth extends RenderStage {
73
97
 
74
98
  this._triStorageNode = null;
75
99
  this._bvhStorageNode = null;
100
+ this._matStorageNode = null;
76
101
  this._lastTriAttr = null;
77
102
  this._lastBvhAttr = null;
103
+ this._lastMatAttr = null;
78
104
  this._computeNode = null;
79
105
  this._computeBuilt = false;
80
106
 
107
+ // Normal/bump map array nodes — persistent placeholders, value swapped to
108
+ // the real DataArrayTextures on model load. processNormal/processBump
109
+ // runtime-guard on map indices, so the placeholder is never sampled.
110
+ this._normalMapsTex = texture( this._makePlaceholderArray() );
111
+ this._bumpMapsTex = texture( this._makePlaceholderArray() );
112
+
113
+ }
114
+
115
+ _makePlaceholderArray() {
116
+
117
+ const t = new DataArrayTexture( new Uint8Array( [ 128, 128, 255, 255 ] ), 1, 1, 1 );
118
+ t.minFilter = LinearFilter;
119
+ t.magFilter = LinearFilter;
120
+ t.generateMipmaps = false;
121
+ t.needsUpdate = true;
122
+ return t;
123
+
81
124
  }
82
125
 
83
126
  setupEventListeners() {
@@ -102,10 +145,12 @@ export class NormalDepth extends RenderStage {
102
145
  const pt = this.pathTracer;
103
146
  if ( ! pt ) return false;
104
147
 
148
+ const matAttr = pt.materialData?.materialStorageAttr;
105
149
  const triSwapped = pt.triangleStorageAttr && pt.triangleStorageAttr !== this._lastTriAttr;
106
150
  const bvhSwapped = pt.bvhStorageAttr && pt.bvhStorageAttr !== this._lastBvhAttr;
151
+ const matSwapped = matAttr && matAttr !== this._lastMatAttr;
107
152
 
108
- if ( triSwapped || bvhSwapped ) {
153
+ if ( triSwapped || bvhSwapped || matSwapped ) {
109
154
 
110
155
  // Buffer identity changed → compute's bind group is stale; rebuild.
111
156
  this._computeNode?.dispose?.();
@@ -113,6 +158,7 @@ export class NormalDepth extends RenderStage {
113
158
  this._computeBuilt = false;
114
159
  this._triStorageNode = null;
115
160
  this._bvhStorageNode = null;
161
+ this._matStorageNode = null;
116
162
  this._dirty = true;
117
163
 
118
164
  }
@@ -133,10 +179,22 @@ export class NormalDepth extends RenderStage {
133
179
 
134
180
  }
135
181
 
182
+ if ( matAttr && ! this._matStorageNode ) {
183
+
184
+ this._matStorageNode = storage( matAttr, 'vec4', matAttr.count ).toReadOnly();
185
+
186
+ }
187
+
188
+ // In-place map swaps (model change) — graph closes over the node, only .value changes.
189
+ const md = pt.materialData;
190
+ if ( md?.normalMaps ) this._normalMapsTex.value = md.normalMaps;
191
+ if ( md?.bumpMaps ) this._bumpMapsTex.value = md.bumpMaps;
192
+
136
193
  this._lastTriAttr = pt.triangleStorageAttr || this._lastTriAttr;
137
194
  this._lastBvhAttr = pt.bvhStorageAttr || this._lastBvhAttr;
195
+ this._lastMatAttr = matAttr || this._lastMatAttr;
138
196
 
139
- return !! ( this._triStorageNode && this._bvhStorageNode );
197
+ return !! ( this._triStorageNode && this._bvhStorageNode && this._matStorageNode );
140
198
 
141
199
  }
142
200
 
@@ -144,11 +202,15 @@ export class NormalDepth extends RenderStage {
144
202
 
145
203
  const triStorage = this._triStorageNode;
146
204
  const bvhStorage = this._bvhStorageNode;
205
+ const matStorage = this._matStorageNode;
206
+ const normalMaps = this._normalMapsTex;
207
+ const bumpMaps = this._bumpMapsTex;
147
208
  const camWorld = this.cameraWorldMatrix;
148
209
  const camProjInv = this.cameraProjectionMatrixInverse;
149
210
  const resW = this.resolutionWidth;
150
211
  const resH = this.resolutionHeight;
151
212
  const outputTex = this._outputStorageTex;
213
+ const shadingTex = this._shadingStorageTex;
152
214
 
153
215
  const WG_SIZE = 8;
154
216
 
@@ -195,6 +257,31 @@ export class NormalDepth extends RenderStage {
195
257
  result
196
258
  ).toWriteOnly();
197
259
 
260
+ // Shading normal: perturb the geometric normal by the normal/bump map
261
+ // from the SAME hit (deterministic UV → jitter-free). Miss → geo default.
262
+ const shadingNormal = hit.normal.toVar();
263
+ If( hit.didHit, () => {
264
+
265
+ const material = RayTracingMaterial.wrap(
266
+ getMaterial( hit.materialIndex, matStorage )
267
+ ).toVar();
268
+ const uvCache = UVCache.wrap( computeUVCache( hit.uv, material ) ).toVar();
269
+ const mapped = processNormal( normalMaps, hit.normal, material, uvCache ).toVar();
270
+ shadingNormal.assign( processBump( bumpMaps, mapped, material, uvCache ) );
271
+
272
+ } );
273
+
274
+ const shadingResult = hit.didHit.select(
275
+ vec4( shadingNormal.mul( 0.5 ).add( 0.5 ), depth ),
276
+ vec4( 0.0, 0.0, 0.0, float( 1e6 ) )
277
+ );
278
+
279
+ textureStore(
280
+ shadingTex,
281
+ uvec2( uint( gx ), uint( gy ) ),
282
+ shadingResult
283
+ ).toWriteOnly();
284
+
198
285
  } );
199
286
 
200
287
  } );
@@ -233,6 +320,7 @@ export class NormalDepth extends RenderStage {
233
320
  const currentRT = this._currentIdx === 0 ? this._rtA : this._rtB;
234
321
  context.setTexture( 'pathtracer:normalDepth', currentRT.texture );
235
322
  context.setTexture( 'pathtracer:prevNormalDepth', currentRT.texture );
323
+ context.setTexture( 'pathtracer:shadingNormal', this._shadingRT.texture );
236
324
  return;
237
325
 
238
326
  }
@@ -257,9 +345,10 @@ export class NormalDepth extends RenderStage {
257
345
 
258
346
  this.renderer.compute( this._computeNode );
259
347
 
260
- // Copy only the active region out of the over-allocated StorageTexture.
348
+ // Copy only the active region out of the over-allocated StorageTextures.
261
349
  this._srcRegion.max.set( writeRT.width, writeRT.height );
262
350
  this.renderer.copyTextureToTexture( this._outputStorageTex, writeRT.texture, this._srcRegion );
351
+ this.renderer.copyTextureToTexture( this._shadingStorageTex, this._shadingRT.texture, this._srcRegion );
263
352
 
264
353
  // First dispatch: seed prev from current so ASVGF doesn't see false
265
354
  // disocclusion on frame 1.
@@ -272,6 +361,7 @@ export class NormalDepth extends RenderStage {
272
361
 
273
362
  context.setTexture( 'pathtracer:normalDepth', writeRT.texture );
274
363
  context.setTexture( 'pathtracer:prevNormalDepth', prevRT.texture );
364
+ context.setTexture( 'pathtracer:shadingNormal', this._shadingRT.texture );
275
365
 
276
366
  this._dirty = false;
277
367
 
@@ -294,6 +384,8 @@ export class NormalDepth extends RenderStage {
294
384
  this._rtA.texture.needsUpdate = true;
295
385
  this._rtB.setSize( width, height );
296
386
  this._rtB.texture.needsUpdate = true;
387
+ this._shadingRT.setSize( width, height );
388
+ this._shadingRT.texture.needsUpdate = true;
297
389
  this._hasHistory = false;
298
390
  this.resolutionWidth.value = width;
299
391
  this.resolutionHeight.value = height;
@@ -314,6 +406,8 @@ export class NormalDepth extends RenderStage {
314
406
 
315
407
  this._computeNode?.dispose();
316
408
  this._outputStorageTex?.dispose();
409
+ this._shadingStorageTex?.dispose();
410
+ this._shadingRT?.dispose();
317
411
  this._rtA?.dispose();
318
412
  this._rtB?.dispose();
319
413
 
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, float, int, uint, ivec2, uvec2, uniform, If, max,
1
+ import { Fn, wgslFn, float, int, uint, ivec2, uvec2, uniform, If, max, select,
2
2
  textureLoad, textureStore, workgroupArray, workgroupBarrier, localId, workgroupId } from 'three/tsl';
3
3
  import { RenderTarget, TextureNode, StorageTexture } from 'three/webgpu';
4
4
  import { FloatType, RGBAFormat, LinearFilter, Box2, Vector2 } from 'three';
@@ -6,6 +6,10 @@ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
6
6
  import { luminance } from '../TSL/Common.js';
7
7
  import { MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
8
8
 
9
+ // NaN/±Inf guard: a poisoned luminance would otherwise corrupt the moment EMA forever
10
+ // (mean/meanSq → variance → BilateralFilter sigmaL). NaN (x!=x) → 0, ±Inf → [0,1e7].
11
+ const sanitizeLum = ( x ) => select( x.equal( x ), x, float( 0.0 ) ).clamp( 0.0, 1e7 );
12
+
9
13
  // ── wgslFn helpers ──────────────────────────────────────────
10
14
 
11
15
  /**
@@ -203,7 +207,7 @@ export class Variance extends RenderStage {
203
207
  const gy1 = tileOriginY.add( int( sy1 ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
204
208
 
205
209
  const sColor1 = textureLoad( colorTex, ivec2( gx1, gy1 ) ).xyz;
206
- sharedLum.element( linearIdx ).assign( luminance( sColor1 ) );
210
+ sharedLum.element( linearIdx ).assign( sanitizeLum( luminance( sColor1 ) ) );
207
211
 
208
212
  // Load #2: threads 0-35 load positions 64-99
209
213
  If( linearIdx.lessThan( uint( EXTRA_LOAD ) ), () => {
@@ -215,7 +219,7 @@ export class Variance extends RenderStage {
215
219
  const gy2 = tileOriginY.add( int( sy2 ) ).clamp( int( 0 ), int( resH ).sub( 1 ) );
216
220
 
217
221
  const sColor2 = textureLoad( colorTex, ivec2( gx2, gy2 ) ).xyz;
218
- sharedLum.element( idx2 ).assign( luminance( sColor2 ) );
222
+ sharedLum.element( idx2 ).assign( sanitizeLum( luminance( sColor2 ) ) );
219
223
 
220
224
  } );
221
225
 
@@ -42,6 +42,7 @@ export class CameraManager extends EventDispatcher {
42
42
  this._lastValidFocusDistance = null;
43
43
  this._smoothedFocusDistance = null;
44
44
  this._afPointDirty = false;
45
+ this._afSuspended = false;
45
46
 
46
47
  // Saved state for default camera when switching to model cameras
47
48
  this._defaultCameraState = null;
@@ -273,6 +274,24 @@ export class CameraManager extends EventDispatcher {
273
274
 
274
275
  if ( this.autoFocusMode === 'manual' ) return;
275
276
 
277
+ // Depth-of-field is the only consumer of the auto-focus distance. With DOF
278
+ // off (the default) the per-frame scene raycast is pure waste, so skip it.
279
+ // Re-snap on the frame DOF turns back on so focus is correct immediately
280
+ // rather than racking from a stale smoothed value.
281
+ if ( ! pathTracer?.enableDOF?.value ) {
282
+
283
+ this._afSuspended = true;
284
+ return;
285
+
286
+ }
287
+
288
+ if ( this._afSuspended ) {
289
+
290
+ this._afSuspended = false;
291
+ this._smoothedFocusDistance = null;
292
+
293
+ }
294
+
276
295
  // Lock focus during active tiled final rendering
277
296
  const stage = pathTracer;
278
297
  if ( stage?.isReady