rayzee 7.1.0 → 7.2.1

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,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
 
@@ -754,6 +754,7 @@ export class PathTracer extends PathTracerStage {
754
754
  totalTriangleCount: this.totalTriangleCount,
755
755
  enableEmissiveTriangleSampling: this.enableEmissiveTriangleSampling,
756
756
  lightBVHNodeCount: this.lightBVHNodeCount,
757
+ reverseMapVec4Offset: this.reverseMapVec4Offset,
757
758
  currentBounce: this._wfCurrentBounce,
758
759
  maxRayCount: this._wfMaxRayCount,
759
760
  } );
@@ -198,9 +198,12 @@ export class PathTracerStage extends RenderStage {
198
198
  this.lightStorageAttr = new StorageInstancedBufferAttribute( new Float32Array( 16 ), 4 );
199
199
  this.lightStorageNode = storage( this.lightStorageAttr, 'vec4', 1 ).toReadOnly();
200
200
 
201
- // Cached CPU-side data — rebuilt into the packed buffer whenever either source changes.
201
+ // Cached CPU-side data — rebuilt into the packed buffer whenever any source changes.
202
202
  this._lbvhDataCache = null;
203
203
  this._emissiveDataCache = null;
204
+ // Per-triangle bit-trail map (root→leaf Light BVH path, 1 float per triangleIndex); packed
205
+ // after the emissive entries so the bounce-hit MIS path can re-walk the descent pdf.
206
+ this._bitTrailMapCache = null;
204
207
 
205
208
  // Per-mesh visibility is packed into the TLAS BLAS-pointer leaf's slot [2]
206
209
  // (see TLASBuilder.flatten + BVHTraversal.js). The InstanceTable holds the
@@ -1296,14 +1299,18 @@ export class PathTracerStage extends RenderStage {
1296
1299
  const LBVH_STRIDE = 4; // vec4s per LBVH node — must match LightBVHSampling.js
1297
1300
  const lbvh = this._lbvhDataCache;
1298
1301
  const emis = this._emissiveDataCache;
1302
+ const trail = this._bitTrailMapCache;
1299
1303
  const lbvhLen = lbvh ? lbvh.length : 0;
1300
1304
  const emisLen = emis ? emis.length : 0;
1305
+ // Bit-trail map packs 4 trails per vec4 → pad to a vec4 boundary.
1306
+ const trailPadded = trail ? Math.ceil( trail.length / 4 ) * 4 : 0;
1301
1307
 
1302
1308
  // Ensure at least a minimal non-empty buffer so GPU allocation remains valid.
1303
- const totalLen = Math.max( lbvhLen + emisLen, 4 );
1309
+ const totalLen = Math.max( lbvhLen + emisLen + trailPadded, 4 );
1304
1310
  const combined = new Float32Array( totalLen );
1305
1311
  if ( lbvh ) combined.set( lbvh, 0 );
1306
1312
  if ( emis ) combined.set( emis, lbvhLen );
1313
+ if ( trail ) combined.set( trail, lbvhLen + emisLen );
1307
1314
 
1308
1315
  this.lightStorageAttr = new StorageInstancedBufferAttribute( combined, 4 );
1309
1316
  this.lightStorageNode.value = this.lightStorageAttr;
@@ -1311,14 +1318,18 @@ export class PathTracerStage extends RenderStage {
1311
1318
 
1312
1319
  // Offset (in vec4 elements) where emissive data starts.
1313
1320
  this.emissiveVec4Offset.value = ( this.lightBVHNodeCount.value || 0 ) * LBVH_STRIDE;
1321
+ // Offset (in vec4 elements) where the bit-trail map starts (lbvhLen + emisLen are float
1322
+ // counts, both multiples of 4, so this divides cleanly).
1323
+ this.reverseMapVec4Offset.value = ( lbvhLen + emisLen ) / 4;
1314
1324
 
1315
1325
  }
1316
1326
 
1317
- setEmissiveTriangleData( emissiveData, count, totalPower = 0 ) {
1327
+ setEmissiveTriangleData( emissiveData, count, totalPower = 0, bitTrailMap = null ) {
1318
1328
 
1319
1329
  if ( ! emissiveData ) return;
1320
1330
 
1321
1331
  this._emissiveDataCache = emissiveData;
1332
+ if ( bitTrailMap ) this._bitTrailMapCache = bitTrailMap;
1322
1333
  this.emissiveTriangleCount.value = count;
1323
1334
  this.emissiveTotalPower.value = totalPower;
1324
1335
  this._rebuildLightBuffer();
@@ -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
 
@@ -312,8 +312,9 @@ export const calculateEmissiveLightPdf = Fn( ( [
312
312
  // Targeted material read: only fetch emissive data (2 vec4s instead of full 27)
313
313
  const matData1 = getDatafromStorageBuffer( materialBuffer, triData.materialIndex, int( MATERIAL_SLOT.EMISSIVE_ROUGHNESS ), MATERIAL_SLOTS );
314
314
  const matData2 = getDatafromStorageBuffer( materialBuffer, triData.materialIndex, int( MATERIAL_SLOT.IOR_TRANSMISSION ), MATERIAL_SLOTS );
315
- const avgEmissive = matData1.x.add( matData1.y ).add( matData1.z ).div( 3.0 );
316
- const power = max( avgEmissive.mul( matData2.a ).mul( area ), float( 1e-10 ) );
315
+ // Rec.709 luma must match EmissiveTriangleBuilder's power weighting for MIS consistency.
316
+ const luma = matData1.x.mul( 0.2126 ).add( matData1.y.mul( 0.7152 ) ).add( matData1.z.mul( 0.0722 ) );
317
+ const power = max( luma.mul( matData2.a ).mul( area ), float( 1e-10 ) );
317
318
  const selectionPdf = power.div( max( emissiveTotalPower, float( 1e-10 ) ) );
318
319
 
319
320
  const result = float( 0.0 ).toVar();