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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.1.0",
3
+ "version": "7.2.1",
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
@@ -1183,8 +1190,13 @@ export class PathTracerApp extends EventDispatcher {
1183
1190
  if ( result ) {
1184
1191
 
1185
1192
  this.stages.pathTracer.setEmissiveTriangleData(
1186
- result.rawData, result.emissiveCount, result.totalPower,
1193
+ result.rawData, result.emissiveCount, result.totalPower, result.bitTrailMap,
1187
1194
  );
1195
+ if ( result.lightBVHNodeData ) {
1196
+
1197
+ this.stages.pathTracer.setLightBVHData( result.lightBVHNodeData, result.lightBVHNodeCount );
1198
+
1199
+ }
1188
1200
 
1189
1201
  }
1190
1202
 
@@ -22,6 +22,10 @@ export class EmissiveTriangleBuilder {
22
22
  this.cdfArray = null;
23
23
  this.lightBVHNodeData = null;
24
24
  this.lightBVHNodeCount = 0;
25
+ // Per-triangle bit-trail (root→leaf path through the Light BVH), indexed by absolute
26
+ // triangleIndex, -1 for non-emissive. Lets the GPU re-walk the descent pdf for MIS.
27
+ this.emissiveBitTrailMap = null;
28
+ this._totalTriangleCount = 0;
25
29
 
26
30
  }
27
31
 
@@ -37,6 +41,7 @@ export class EmissiveTriangleBuilder {
37
41
 
38
42
  this.emissiveTriangles = [];
39
43
  this.totalEmissivePower = 0;
44
+ this._totalTriangleCount = triangleCount;
40
45
 
41
46
  const FLOATS_PER_TRIANGLE = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
42
47
  const MATERIAL_INDEX_OFFSET = TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 2; // materialIndex within vec4
@@ -74,9 +79,21 @@ export class EmissiveTriangleBuilder {
74
79
 
75
80
  const area = this._calculateTriangleArea( v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z );
76
81
 
77
- // Calculate emissive power (luminance * intensity * area)
78
- const avgEmissive = ( emissive.r + emissive.g + emissive.b ) / 3.0;
79
- const power = avgEmissive * emissiveIntensity * area;
82
+ // Calculate emissive power (Rec.709 luminance * intensity * area) — must match the
83
+ // shader's calculateEmissiveLightPdf luma weighting for MIS consistency.
84
+ const luma = 0.2126 * emissive.r + 0.7152 * emissive.g + 0.0722 * emissive.b;
85
+ const power = luma * emissiveIntensity * area;
86
+
87
+ // Geometric normal (emission cone axis). BackSide flips it; DoubleSide → two-sided.
88
+ let nx = ( v1y - v0y ) * ( v2z - v0z ) - ( v1z - v0z ) * ( v2y - v0y );
89
+ let ny = ( v1z - v0z ) * ( v2x - v0x ) - ( v1x - v0x ) * ( v2z - v0z );
90
+ let nz = ( v1x - v0x ) * ( v2y - v0y ) - ( v1y - v0y ) * ( v2x - v0x );
91
+ const nl = Math.sqrt( nx * nx + ny * ny + nz * nz ) || 1;
92
+ const sideSign = material.side === 1 ? - 1 : 1; // THREE.BackSide flips emission normal
93
+ nx = nx / nl * sideSign;
94
+ ny = ny / nl * sideSign;
95
+ nz = nz / nl * sideSign;
96
+ const twoSided = material.side === 2; // THREE.DoubleSide
80
97
 
81
98
  // Centroid for BVH split decisions
82
99
  const cx = ( v0x + v1x + v2x ) / 3;
@@ -100,6 +117,7 @@ export class EmissiveTriangleBuilder {
100
117
  emissiveIntensity: emissiveIntensity,
101
118
  cx, cy, cz,
102
119
  bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ,
120
+ nx, ny, nz, twoSided,
103
121
  } );
104
122
 
105
123
  this.totalEmissivePower += power;
@@ -459,22 +477,34 @@ export class EmissiveTriangleBuilder {
459
477
 
460
478
  if ( this.emissiveCount === 0 ) {
461
479
 
462
- // Dummy single leaf node
480
+ // Dummy single leaf node (whole-sphere cone)
463
481
  this.lightBVHNodeData = new Float32Array( 16 );
464
482
  this.lightBVHNodeData[ 7 ] = 1.0; // isLeaf
483
+ this.lightBVHNodeData[ 14 ] = 1.0; // cone axis z
484
+ this.lightBVHNodeData[ 15 ] = - 1.0; // cosThetaO = whole sphere
465
485
  this.lightBVHNodeCount = 1;
486
+ this.emissiveBitTrailMap = new Float32Array( Math.max( this._totalTriangleCount, 1 ) ).fill( - 1 );
466
487
  return 1;
467
488
 
468
489
  }
469
490
 
470
491
  const builder = new LightBVHBuilder();
471
- const { nodeData, nodeCount, sortedPerm } = builder.build( this.emissiveTriangles );
492
+ const { nodeData, nodeCount, sortedPerm, bitTrails } = builder.build( this.emissiveTriangles );
472
493
  this.lightBVHNodeData = nodeData;
473
494
  this.lightBVHNodeCount = nodeCount;
474
495
 
475
496
  // Rebuild emissive raw data in sorted leaf order so node start/count refs are valid
476
497
  this._rebuildSortedEmissiveData( sortedPerm );
477
498
 
499
+ // Build the per-triangle bit-trail map (indexed by absolute triangleIndex; -1 = non-emissive)
500
+ this.emissiveBitTrailMap = new Float32Array( Math.max( this._totalTriangleCount, 1 ) ).fill( - 1 );
501
+ for ( let i = 0; i < sortedPerm.length; i ++ ) {
502
+
503
+ const triIndex = this.emissiveTriangles[ sortedPerm[ i ] ].triangleIndex;
504
+ this.emissiveBitTrailMap[ triIndex ] = bitTrails[ i ];
505
+
506
+ }
507
+
478
508
  return nodeCount;
479
509
 
480
510
  }
@@ -544,6 +574,10 @@ export class EmissiveTriangleBuilder {
544
574
  this.cdfArray = null;
545
575
  this.lightBVHNodeData = null;
546
576
  this.lightBVHNodeCount = 0;
577
+ // Per-triangle bit-trail (root→leaf path through the Light BVH), indexed by absolute
578
+ // triangleIndex, -1 for non-emissive. Lets the GPU re-walk the descent pdf for MIS.
579
+ this.emissiveBitTrailMap = null;
580
+ this._totalTriangleCount = 0;
547
581
 
548
582
  }
549
583
 
@@ -4,14 +4,20 @@
4
4
  * CPU-side BVH builder over emissive triangles for spatially-aware light sampling.
5
5
  * Each node occupies 4 vec4s (16 floats, BVH_STRIDE=4):
6
6
  *
7
- * vec4[0]: [aabb.minX, aabb.minY, aabb.minZ, totalPower]
8
- * vec4[1]: [aabb.maxX, aabb.maxY, aabb.maxZ, isLeaf] // isLeaf: 0.0=inner, 1.0=leaf
7
+ * vec4[0]: [aabb.minX, aabb.minY, aabb.minZ, totalPower] // power = Rec.709 luma-weighted
8
+ * vec4[1]: [aabb.maxX, aabb.maxY, aabb.maxZ, isLeaf] // isLeaf: 0.0=inner, 1.0=leaf
9
9
  * vec4[2]: inner → [leftChildIdx, rightChildIdx, 0, 0]
10
10
  * leaf → [emissiveStart, emissiveCount, 0, 0]
11
- * vec4[3]: [0, 0, 0, 0]
11
+ * vec4[3]: [coneAxisX, coneAxisY, coneAxisZ, cosThetaO] // emission orientation cone (Conty-Kulla)
12
+ *
13
+ * The orientation cone bounds the emission directions of the cluster (θ_o = spread of emitter
14
+ * normals; θ_e = π/2 assumed for diffuse emitters, applied at sample time). cosThetaO = -1 means
15
+ * "whole sphere" (mixed/two-sided cluster → never culled by orientation).
12
16
  *
13
17
  * Build algorithm: median split on longest centroid AABB axis, maxLeafSize=8.
14
- * Output: pre-order flattened array with right child pushed first (so left is processed first).
18
+ * Output: pre-order flattened array with left child immediately after parent.
19
+ * Also emits a per-(sorted)-triangle bit-trail: the sequence of left(0)/right(1) child choices
20
+ * from the root to that triangle's leaf, so the GPU can re-walk the exact descent for MIS.
15
21
  */
16
22
  export class LightBVHBuilder {
17
23
 
@@ -26,19 +32,22 @@ export class LightBVHBuilder {
26
32
  *
27
33
  * @param {Array} emissiveTriangles - Array of objects:
28
34
  * { triangleIndex, power, area, emissive, emissiveIntensity, cx, cy, cz,
29
- * bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ }
30
- * @returns {{ nodeData: Float32Array, nodeCount: number, sortedPerm: Int32Array }}
35
+ * bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ, nx, ny, nz, twoSided }
36
+ * @returns {{ nodeData: Float32Array, nodeCount: number, sortedPerm: Int32Array, bitTrails: Float32Array }}
31
37
  * sortedPerm[i] = original index in emissiveTriangles for position i in sorted leaf order
38
+ * bitTrails[i] = root→leaf bit-trail for the triangle now at sorted position i (as a float-encoded int)
32
39
  */
33
40
  build( emissiveTriangles ) {
34
41
 
35
42
  const n = emissiveTriangles.length;
36
43
  if ( n === 0 ) {
37
44
 
38
- // Dummy leaf node
45
+ // Dummy leaf node — whole-sphere cone so importance never culls it
39
46
  const nodeData = new Float32Array( 16 );
40
47
  nodeData[ 7 ] = 1.0; // isLeaf
41
- return { nodeData, nodeCount: 1, sortedPerm: new Int32Array( 0 ) };
48
+ nodeData[ 14 ] = 1.0; // cone axis z
49
+ nodeData[ 15 ] = - 1.0; // cosThetaO = whole sphere
50
+ return { nodeData, nodeCount: 1, sortedPerm: new Int32Array( 0 ), bitTrails: new Float32Array( 0 ) };
42
51
 
43
52
  }
44
53
 
@@ -52,8 +61,11 @@ export class LightBVHBuilder {
52
61
  const nodeData = new Float32Array( maxNodes * 16 );
53
62
  let nodeCount = 0;
54
63
 
55
- // Recursively build; returns node index
56
- const buildRecursive = ( start, end ) => {
64
+ // bit-trail per sorted position (filled at leaves). 24-bit safe → tree depth < 24 (≈16M leaf clusters).
65
+ const bitTrails = new Float32Array( n );
66
+
67
+ // Recursively build; returns { nodeIndex, cone }. `trail`/`depth` accumulate the root→leaf path.
68
+ const buildRecursive = ( start, end, trail, depth ) => {
57
69
 
58
70
  const nodeIndex = nodeCount ++;
59
71
  const nodeOffset = nodeIndex * 16;
@@ -95,16 +107,11 @@ export class LightBVHBuilder {
95
107
  nodeData[ nodeOffset + 4 ] = maxX;
96
108
  nodeData[ nodeOffset + 5 ] = maxY;
97
109
  nodeData[ nodeOffset + 6 ] = maxZ;
98
- // nodeData[nodeOffset + 7] = isLeaf — set below
99
-
100
- // vec4[3]: zeros (reserved)
101
- nodeData[ nodeOffset + 12 ] = 0;
102
- nodeData[ nodeOffset + 13 ] = 0;
103
- nodeData[ nodeOffset + 14 ] = 0;
104
- nodeData[ nodeOffset + 15 ] = 0;
105
110
 
106
111
  const count = end - start;
107
112
 
113
+ let cone;
114
+
108
115
  if ( count <= this.maxLeafSize ) {
109
116
 
110
117
  // LEAF NODE
@@ -114,6 +121,18 @@ export class LightBVHBuilder {
114
121
  nodeData[ nodeOffset + 10 ] = 0;
115
122
  nodeData[ nodeOffset + 11 ] = 0;
116
123
 
124
+ // Cone = union of this leaf's triangle emission cones; write this leaf's bit-trail
125
+ cone = null;
126
+ for ( let i = start; i < end; i ++ ) {
127
+
128
+ const tri = emissiveTriangles[ indices[ i ] ];
129
+ cone = coneUnion( cone, triangleCone( tri ) );
130
+ bitTrails[ i ] = trail;
131
+
132
+ }
133
+
134
+ if ( ! cone ) cone = { ax: 0, ay: 0, az: 1, cosO: - 1 };
135
+
117
136
  } else {
118
137
 
119
138
  // INNER NODE — find longest centroid axis and split at median
@@ -135,24 +154,31 @@ export class LightBVHBuilder {
135
154
 
136
155
  nodeData[ nodeOffset + 7 ] = 0.0; // isLeaf = false (inner)
137
156
 
138
- // Build children (right first so left is processed first in pre-order)
139
- // We need left index first but must build in correct pre-order order
140
- // Build left child immediately after this node
141
- const leftChildIdx = buildRecursive( start, mid );
142
- const rightChildIdx = buildRecursive( mid, end );
157
+ // Build left child immediately after this node (pre-order), then right.
158
+ // Trail bit at `depth`: 0 for left, 1 for right.
159
+ const left = buildRecursive( start, mid, trail, depth + 1 );
160
+ const right = buildRecursive( mid, end, trail + Math.pow( 2, depth ), depth + 1 );
143
161
 
144
- nodeData[ nodeOffset + 8 ] = leftChildIdx;
145
- nodeData[ nodeOffset + 9 ] = rightChildIdx;
162
+ nodeData[ nodeOffset + 8 ] = left.nodeIndex;
163
+ nodeData[ nodeOffset + 9 ] = right.nodeIndex;
146
164
  nodeData[ nodeOffset + 10 ] = 0;
147
165
  nodeData[ nodeOffset + 11 ] = 0;
148
166
 
167
+ cone = coneUnion( left.cone, right.cone );
168
+
149
169
  }
150
170
 
151
- return nodeIndex;
171
+ // vec4[3]: [coneAxisX, coneAxisY, coneAxisZ, cosThetaO]
172
+ nodeData[ nodeOffset + 12 ] = cone.ax;
173
+ nodeData[ nodeOffset + 13 ] = cone.ay;
174
+ nodeData[ nodeOffset + 14 ] = cone.az;
175
+ nodeData[ nodeOffset + 15 ] = cone.cosO;
176
+
177
+ return { nodeIndex, cone };
152
178
 
153
179
  };
154
180
 
155
- buildRecursive( 0, n );
181
+ buildRecursive( 0, n, 0, 0 );
156
182
 
157
183
  // sortedPerm: the rearranged indices array (leaf order)
158
184
  const sortedPerm = new Int32Array( n );
@@ -164,7 +190,7 @@ export class LightBVHBuilder {
164
190
 
165
191
  console.log( `[LightBVHBuilder] Built BVH: ${nodeCount} nodes for ${n} emissive triangles` );
166
192
 
167
- return { nodeData: trimmedData, nodeCount, sortedPerm };
193
+ return { nodeData: trimmedData, nodeCount, sortedPerm, bitTrails };
168
194
 
169
195
  }
170
196
 
@@ -217,3 +243,63 @@ export class LightBVHBuilder {
217
243
  }
218
244
 
219
245
  }
246
+
247
+ // ================================================================================
248
+ // ORIENTATION CONE HELPERS (Conty-Estevez & Kulla 2018 / PBRT-v4 DirectionCone)
249
+ // ================================================================================
250
+
251
+ // Per-triangle emission cone: axis = geometric normal, θ_o = 0 (single direction).
252
+ // Two-sided emitters emit into both hemispheres → whole sphere (cosO = -1).
253
+ function triangleCone( tri ) {
254
+
255
+ if ( tri.twoSided ) return { ax: tri.nx, ay: tri.ny, az: tri.nz, cosO: - 1 };
256
+ return { ax: tri.nx, ay: tri.ny, az: tri.nz, cosO: 1 };
257
+
258
+ }
259
+
260
+ // Union of two direction cones (PBRT-v4 DirectionCone::Union). cosO === -1 ⇒ whole sphere.
261
+ function coneUnion( a, b ) {
262
+
263
+ if ( ! a ) return b;
264
+ if ( ! b ) return a;
265
+ if ( a.cosO <= - 1 ) return a; // a is already whole sphere
266
+ if ( b.cosO <= - 1 ) return b;
267
+
268
+ const cosA = Math.min( Math.max( a.cosO, - 1 ), 1 );
269
+ const cosB = Math.min( Math.max( b.cosO, - 1 ), 1 );
270
+ const thetaA = Math.acos( cosA );
271
+ const thetaB = Math.acos( cosB );
272
+ const dotAB = Math.min( Math.max( a.ax * b.ax + a.ay * b.ay + a.az * b.az, - 1 ), 1 );
273
+ const thetaD = Math.acos( dotAB );
274
+
275
+ // One cone already contains the other
276
+ if ( Math.min( thetaD + thetaB, Math.PI ) <= thetaA ) return a;
277
+ if ( Math.min( thetaD + thetaA, Math.PI ) <= thetaB ) return b;
278
+
279
+ const thetaO = ( thetaA + thetaB + thetaD ) * 0.5;
280
+ if ( thetaO >= Math.PI ) return { ax: a.ax, ay: a.ay, az: a.az, cosO: - 1 };
281
+
282
+ const thetaR = thetaO - thetaA;
283
+
284
+ // Rotation axis = normalize(cross(a, b))
285
+ let wx = a.ay * b.az - a.az * b.ay;
286
+ let wy = a.az * b.ax - a.ax * b.az;
287
+ let wz = a.ax * b.ay - a.ay * b.ax;
288
+ const wl = Math.sqrt( wx * wx + wy * wy + wz * wz );
289
+ if ( wl < 1e-8 ) return { ax: a.ax, ay: a.ay, az: a.az, cosO: - 1 }; // (anti)parallel → whole sphere
290
+ wx /= wl; wy /= wl; wz /= wl;
291
+
292
+ // Rodrigues: rotate a.axis around (wx,wy,wz) by thetaR
293
+ const c = Math.cos( thetaR ), s = Math.sin( thetaR );
294
+ const kdotv = wx * a.ax + wy * a.ay + wz * a.az;
295
+ const cx = wy * a.az - wz * a.ay;
296
+ const cy = wz * a.ax - wx * a.az;
297
+ const cz = wx * a.ay - wy * a.ax;
298
+ const ax = a.ax * c + cx * s + wx * kdotv * ( 1 - c );
299
+ const ay = a.ay * c + cy * s + wy * kdotv * ( 1 - c );
300
+ const az = a.az * c + cz * s + wz * kdotv * ( 1 - c );
301
+ const al = Math.sqrt( ax * ax + ay * ay + az * az ) || 1;
302
+
303
+ return { ax: ax / al, ay: ay / al, az: az / al, cosO: Math.cos( thetaO ) };
304
+
305
+ }
@@ -90,6 +90,7 @@ export class SceneProcessor {
90
90
  this.emissiveTriangleCount = 0;
91
91
  this.lightBVHNodeData = null;
92
92
  this.lightBVHNodeCount = 0;
93
+ this.emissiveBitTrailMap = null;
93
94
 
94
95
  // Initialize processing components
95
96
  this._initProcessors();
@@ -818,6 +819,8 @@ export class SceneProcessor {
818
819
  this.lightBVHNodeCount = this.emissiveTriangleBuilder.lightBVHNodeCount;
819
820
  // Replace emissiveTriangleData with sorted version (LBVH reorders it)
820
821
  this.emissiveTriangleData = this.emissiveTriangleBuilder.emissiveTriangleData || this.emissiveTriangleData;
822
+ // Per-triangle bit-trail map for the bounce-hit MIS re-walk
823
+ this.emissiveBitTrailMap = this.emissiveTriangleBuilder.emissiveBitTrailMap;
821
824
 
822
825
  }
823
826
 
@@ -872,6 +875,7 @@ export class SceneProcessor {
872
875
  this.instanceTable = null;
873
876
  this.lightBVHNodeData = null;
874
877
  this.lightBVHNodeCount = 0;
878
+ this.emissiveBitTrailMap = null;
875
879
 
876
880
  // Reset performance metrics
877
881
  this.performanceMetrics = {
@@ -1406,6 +1410,7 @@ export class SceneProcessor {
1406
1410
  this.emissiveTriangleData,
1407
1411
  this.emissiveTriangleCount,
1408
1412
  this.emissiveTotalPower,
1413
+ this.emissiveBitTrailMap,
1409
1414
  );
1410
1415
 
1411
1416
  }
@@ -1450,10 +1455,23 @@ export class SceneProcessor {
1450
1455
 
1451
1456
  if ( ! changed ) return null;
1452
1457
 
1458
+ // Rebuild the Light BVH + sorted emissive data + bit-trail map so the stochastic descent and
1459
+ // the bounce-hit MIS re-walk stay consistent after powers / the emissive set change.
1460
+ this.emissiveTriangleBuilder.buildLightBVH();
1461
+ this.lightBVHNodeData = this.emissiveTriangleBuilder.lightBVHNodeData;
1462
+ this.lightBVHNodeCount = this.emissiveTriangleBuilder.lightBVHNodeCount;
1463
+ this.emissiveTriangleData = this.emissiveTriangleBuilder.emissiveTriangleData;
1464
+ this.emissiveBitTrailMap = this.emissiveTriangleBuilder.emissiveBitTrailMap;
1465
+ this.emissiveTriangleCount = this.emissiveTriangleBuilder.emissiveCount;
1466
+ this.emissiveTotalPower = this.emissiveTriangleBuilder.totalEmissivePower;
1467
+
1453
1468
  return {
1454
- rawData: this.emissiveTriangleBuilder.createEmissiveRawData(),
1469
+ rawData: this.emissiveTriangleBuilder.emissiveTriangleData,
1455
1470
  emissiveCount: this.emissiveTriangleBuilder.emissiveCount,
1456
1471
  totalPower: this.emissiveTriangleBuilder.totalEmissivePower,
1472
+ bitTrailMap: this.emissiveBitTrailMap,
1473
+ lightBVHNodeData: this.lightBVHNodeData,
1474
+ lightBVHNodeCount: this.lightBVHNodeCount,
1457
1475
  };
1458
1476
 
1459
1477
  }
@@ -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