rayzee 7.15.1 → 7.15.2

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.15.1",
3
+ "version": "7.15.2",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -838,6 +838,10 @@ export class PathTracerApp extends EventDispatcher {
838
838
  this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
839
839
  this.stages.pathTracer.setMeshVisibilityData( this.stages.pathTracer._meshRefs );
840
840
 
841
+ // Drop authored-hidden meshes' triangles from the emissive-NEE structure
842
+ // (runs before setupMaterial so the kernels compile against the final buffer)
843
+ this._refreshEmissiveForVisibility();
844
+
841
845
  timer.end( 'GPU data transfer' );
842
846
 
843
847
  // Compile shaders
@@ -882,7 +886,10 @@ export class PathTracerApp extends EventDispatcher {
882
886
  // Runs before applyAll()/SceneRebuild so the uniform and UI both pick up the new value.
883
887
  if ( ! this._emissiveSamplingUserSet ) {
884
888
 
885
- const hasEmissive = ( this._sdf?.emissiveTriangleCount ?? 0 ) > 0;
889
+ // Keyed on the canonical (unfiltered) emissive set — emissiveTriangleCount
890
+ // reflects only the visible subset, and hidden emitters can be shown later.
891
+ const hasEmissive = ( this._sdf?.emissiveTriangleBuilder?.emissiveTriangles?.length
892
+ ?? this._sdf?.emissiveTriangleCount ?? 0 ) > 0;
886
893
  this.settings.set( 'enableEmissiveTriangleSampling', hasEmissive, { reset: false } );
887
894
 
888
895
  }
@@ -1504,27 +1511,60 @@ export class PathTracerApp extends EventDispatcher {
1504
1511
 
1505
1512
  this.stages.pathTracer?.materialData.updateMaterialProperty( materialIndex, property, value );
1506
1513
 
1514
+ // Keep the emissive-NEE structure in sync unconditionally (not gated on the
1515
+ // sampling toggle) so edits made while NEE is off aren't lost on re-enable.
1507
1516
  const emissiveAffectingProps = [ 'emissive', 'emissiveIntensity' ];
1508
- if ( emissiveAffectingProps.includes( property )
1509
- && this.stages.pathTracer?.enableEmissiveTriangleSampling?.value ) {
1517
+ if ( emissiveAffectingProps.includes( property ) && this._sdf ) {
1510
1518
 
1511
- const result = this._sdf.updateMaterialEmissive( materialIndex, property, value );
1512
- if ( result ) {
1519
+ this._uploadEmissivePayload( this._sdf.updateMaterialEmissive( materialIndex, property, value ) );
1513
1520
 
1514
- this.stages.pathTracer.setEmissiveTriangleData(
1515
- result.rawData, result.emissiveCount, result.totalPower, result.bitTrailMap,
1516
- );
1517
- if ( result.lightBVHNodeData ) {
1521
+ }
1518
1522
 
1519
- this.stages.pathTracer.setLightBVHData( result.lightBVHNodeData, result.lightBVHNodeCount );
1523
+ this.reset();
1520
1524
 
1521
- }
1525
+ }
1522
1526
 
1523
- }
1527
+ /**
1528
+ * Upload a rebuilt emissive-NEE payload (sorted emissive data + Light BVH +
1529
+ * bit-trail map) to the path tracer stage. No-op for a null payload.
1530
+ * @param {object|null} payload
1531
+ * @private
1532
+ */
1533
+ _uploadEmissivePayload( payload ) {
1534
+
1535
+ if ( ! payload || ! this.stages.pathTracer ) return;
1536
+
1537
+ this.stages.pathTracer.setEmissiveTriangleData(
1538
+ payload.rawData, payload.emissiveCount, payload.totalPower, payload.bitTrailMap,
1539
+ );
1540
+ if ( payload.lightBVHNodeData ) {
1541
+
1542
+ this.stages.pathTracer.setLightBVHData( payload.lightBVHNodeData, payload.lightBVHNodeCount );
1524
1543
 
1525
1544
  }
1526
1545
 
1527
- this.reset();
1546
+ }
1547
+
1548
+ /**
1549
+ * Re-derive the emissive-NEE sampled set from current per-mesh world-visibility
1550
+ * (InstanceTable is the source of truth — patched by the stage's visibility API).
1551
+ * Hidden meshes' triangles are dropped from the Light BVH so they stop casting
1552
+ * NEE light; the shadow ray can't do it (a hidden mesh no longer self-occludes).
1553
+ * @private
1554
+ */
1555
+ _refreshEmissiveForVisibility() {
1556
+
1557
+ const entries = this._sdf?.instanceTable?.entries;
1558
+ if ( ! entries ) return;
1559
+
1560
+ const hidden = new Set();
1561
+ for ( const entry of entries ) {
1562
+
1563
+ if ( entry && entry.visible === false ) hidden.add( entry.meshIndex );
1564
+
1565
+ }
1566
+
1567
+ this._uploadEmissivePayload( this._sdf.rebuildEmissiveForVisibility( hidden ) );
1528
1568
 
1529
1569
  }
1530
1570
 
@@ -1542,11 +1582,13 @@ export class PathTracerApp extends EventDispatcher {
1542
1582
  }
1543
1583
 
1544
1584
  /**
1545
- * @returns {boolean} True if the current scene contains emissive geometry.
1585
+ * @returns {boolean} True if the current scene contains emissive geometry
1586
+ * (regardless of per-mesh visibility).
1546
1587
  */
1547
1588
  hasEmissiveGeometry() {
1548
1589
 
1549
- return ( this._sdf?.emissiveTriangleCount ?? 0 ) > 0;
1590
+ return ( this._sdf?.emissiveTriangleBuilder?.emissiveTriangles?.length
1591
+ ?? this._sdf?.emissiveTriangleCount ?? 0 ) > 0;
1550
1592
 
1551
1593
  }
1552
1594
 
@@ -1559,6 +1601,7 @@ export class PathTracerApp extends EventDispatcher {
1559
1601
  setMeshVisibility( meshIndex, visible ) {
1560
1602
 
1561
1603
  this.stages.pathTracer?.updateMeshVisibility( meshIndex, visible );
1604
+ this._refreshEmissiveForVisibility();
1562
1605
  this.reset();
1563
1606
 
1564
1607
  }
@@ -1570,6 +1613,7 @@ export class PathTracerApp extends EventDispatcher {
1570
1613
  updateAllMeshVisibility() {
1571
1614
 
1572
1615
  this.stages.pathTracer?.updateAllMeshVisibility();
1616
+ this._refreshEmissiveForVisibility();
1573
1617
  this.reset();
1574
1618
 
1575
1619
  }
@@ -26,6 +26,10 @@ export class EmissiveTriangleBuilder {
26
26
  // triangleIndex, -1 for non-emissive. Lets the GPU re-walk the descent pdf for MIS.
27
27
  this.emissiveBitTrailMap = null;
28
28
  this._totalTriangleCount = 0;
29
+ // Mesh indices whose emissive triangles are excluded from the sampled set
30
+ // (per-mesh visibility). Canonical `emissiveTriangles` is never filtered —
31
+ // the GPU-facing arrays are rebuilt from the visible subset in buildLightBVH().
32
+ this._hiddenMeshes = new Set();
29
33
 
30
34
  }
31
35
 
@@ -45,11 +49,13 @@ export class EmissiveTriangleBuilder {
45
49
 
46
50
  const FLOATS_PER_TRIANGLE = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
47
51
  const MATERIAL_INDEX_OFFSET = TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 2; // materialIndex within vec4
52
+ const MESH_INDEX_OFFSET = TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 3; // meshIndex within vec4
48
53
 
49
54
  for ( let i = 0; i < triangleCount; i ++ ) {
50
55
 
51
56
  const baseOffset = i * FLOATS_PER_TRIANGLE;
52
57
  const materialIndex = Math.floor( triangleData[ baseOffset + MATERIAL_INDEX_OFFSET ] );
58
+ const meshIndex = Math.floor( triangleData[ baseOffset + MESH_INDEX_OFFSET ] );
53
59
 
54
60
  // Get material
55
61
  const material = materials[ materialIndex ];
@@ -111,6 +117,7 @@ export class EmissiveTriangleBuilder {
111
117
  this.emissiveTriangles.push( {
112
118
  triangleIndex: i,
113
119
  materialIndex: materialIndex,
120
+ meshIndex: meshIndex,
114
121
  power: power,
115
122
  area: area,
116
123
  emissive: { r: emissive.r, g: emissive.g, b: emissive.b },
@@ -443,40 +450,81 @@ export class EmissiveTriangleBuilder {
443
450
  // If not emissive before and not now, nothing to do
444
451
  if ( ! isNowEmissive ) return false;
445
452
 
446
- // Fast path: just update power + CDF for affected entries.
453
+ // Fast path: update the canonical entries only. Derived arrays, CDF and
454
+ // totals are rebuilt by the buildLightBVH() call that always follows.
447
455
  // Rec.709 luma — must match the build path + shader MIS weighting.
448
456
  const luma = 0.2126 * emissive.r + 0.7152 * emissive.g + 0.0722 * emissive.b;
449
457
 
450
- this.totalEmissivePower = 0;
451
- for ( let i = 0; i < this.emissiveCount; i ++ ) {
458
+ for ( const tri of this.emissiveTriangles ) {
452
459
 
453
- const tri = this.emissiveTriangles[ i ];
454
460
  if ( tri.materialIndex === materialIndex ) {
455
461
 
456
462
  tri.power = luma * emissiveIntensity * tri.area;
457
463
  tri.emissive = { r: emissive.r, g: emissive.g, b: emissive.b };
458
464
  tri.emissiveIntensity = emissiveIntensity;
459
- this.emissivePowerArray[ i ] = tri.power;
460
465
 
461
466
  }
462
467
 
463
- this.totalEmissivePower += this.emissiveTriangles[ i ].power;
464
-
465
468
  }
466
469
 
467
- this._buildCDF();
468
470
  return true;
469
471
 
470
472
  }
471
473
 
472
474
  /**
473
- * Build a Light BVH over the current emissive triangles.
474
- * Reorders emissiveTriangleData so that leaf node start/count indices are valid.
475
+ * Set the meshes whose emissive triangles must be excluded from sampling
476
+ * (per-mesh visibility). Indices that don't own emissive triangles are
477
+ * ignored so non-emissive visibility toggles are no-ops.
478
+ * @param {Set<number>|Iterable<number>} hiddenMeshIndices
479
+ * @returns {boolean} true if the effective hidden set changed (caller should rebuild)
480
+ */
481
+ setHiddenMeshes( hiddenMeshIndices ) {
482
+
483
+ const emissiveMeshes = new Set();
484
+ for ( const tri of this.emissiveTriangles ) emissiveMeshes.add( tri.meshIndex );
485
+
486
+ const next = new Set();
487
+ for ( const m of hiddenMeshIndices ) if ( emissiveMeshes.has( m ) ) next.add( m );
488
+
489
+ const prev = this._hiddenMeshes;
490
+ let changed = next.size !== prev.size;
491
+ if ( ! changed ) {
492
+
493
+ for ( const m of next ) {
494
+
495
+ if ( ! prev.has( m ) ) {
496
+
497
+ changed = true;
498
+ break;
499
+
500
+ }
501
+
502
+ }
503
+
504
+ }
505
+
506
+ this._hiddenMeshes = next;
507
+ return changed;
508
+
509
+ }
510
+
511
+ /**
512
+ * Build a Light BVH over the VISIBLE emissive triangles (canonical set minus
513
+ * hidden meshes). Authoritative for the GPU-facing emissiveCount /
514
+ * totalEmissivePower / sorted raw data / bit-trail map.
475
515
  * @returns {number} nodeCount
476
516
  */
477
517
  buildLightBVH() {
478
518
 
479
- if ( this.emissiveCount === 0 ) {
519
+ const active = this._hiddenMeshes.size === 0
520
+ ? this.emissiveTriangles
521
+ : this.emissiveTriangles.filter( t => ! this._hiddenMeshes.has( t.meshIndex ) );
522
+
523
+ this.emissiveCount = active.length;
524
+ this.totalEmissivePower = 0;
525
+ for ( const tri of active ) this.totalEmissivePower += tri.power;
526
+
527
+ if ( active.length === 0 ) {
480
528
 
481
529
  // Dummy single leaf node (whole-sphere cone)
482
530
  this.lightBVHNodeData = new Float32Array( 16 );
@@ -485,23 +533,27 @@ export class EmissiveTriangleBuilder {
485
533
  this.lightBVHNodeData[ 15 ] = - 1.0; // cosThetaO = whole sphere
486
534
  this.lightBVHNodeCount = 1;
487
535
  this.emissiveBitTrailMap = new Float32Array( Math.max( this._totalTriangleCount, 1 ) ).fill( - 1 );
536
+ this.emissiveIndicesArray = new Int32Array( 0 );
537
+ this.emissivePowerArray = new Float32Array( 0 );
538
+ this._buildCDF();
539
+ this.emissiveTriangleData = new Float32Array( 8 ); // 2 dummy vec4s
488
540
  return 1;
489
541
 
490
542
  }
491
543
 
492
544
  const builder = new LightBVHBuilder();
493
- const { nodeData, nodeCount, sortedPerm, bitTrails } = builder.build( this.emissiveTriangles );
545
+ const { nodeData, nodeCount, sortedPerm, bitTrails } = builder.build( active );
494
546
  this.lightBVHNodeData = nodeData;
495
547
  this.lightBVHNodeCount = nodeCount;
496
548
 
497
549
  // Rebuild emissive raw data in sorted leaf order so node start/count refs are valid
498
- this._rebuildSortedEmissiveData( sortedPerm );
550
+ this._rebuildSortedEmissiveData( sortedPerm, active );
499
551
 
500
- // Build the per-triangle bit-trail map (indexed by absolute triangleIndex; -1 = non-emissive)
552
+ // Build the per-triangle bit-trail map (indexed by absolute triangleIndex; -1 = non-emissive/hidden)
501
553
  this.emissiveBitTrailMap = new Float32Array( Math.max( this._totalTriangleCount, 1 ) ).fill( - 1 );
502
554
  for ( let i = 0; i < sortedPerm.length; i ++ ) {
503
555
 
504
- const triIndex = this.emissiveTriangles[ sortedPerm[ i ] ].triangleIndex;
556
+ const triIndex = active[ sortedPerm[ i ] ].triangleIndex;
505
557
  this.emissiveBitTrailMap[ triIndex ] = bitTrails[ i ];
506
558
 
507
559
  }
@@ -512,10 +564,11 @@ export class EmissiveTriangleBuilder {
512
564
 
513
565
  /**
514
566
  * Rebuild emissive arrays and raw GPU data in the sorted leaf order given by sortedPerm.
515
- * @param {Int32Array} sortedPerm - sortedPerm[i] = original emissiveTriangles index for position i
567
+ * @param {Int32Array} sortedPerm - sortedPerm[i] = index into `tris` for position i
568
+ * @param {Array} tris - The (visible) triangle set the Light BVH was built over
516
569
  * @private
517
570
  */
518
- _rebuildSortedEmissiveData( sortedPerm ) {
571
+ _rebuildSortedEmissiveData( sortedPerm, tris ) {
519
572
 
520
573
  const n = sortedPerm.length;
521
574
 
@@ -526,8 +579,8 @@ export class EmissiveTriangleBuilder {
526
579
  for ( let i = 0; i < n; i ++ ) {
527
580
 
528
581
  const origIdx = sortedPerm[ i ];
529
- this.emissiveIndicesArray[ i ] = this.emissiveTriangles[ origIdx ].triangleIndex;
530
- this.emissivePowerArray[ i ] = this.emissiveTriangles[ origIdx ].power;
582
+ this.emissiveIndicesArray[ i ] = tris[ origIdx ].triangleIndex;
583
+ this.emissivePowerArray[ i ] = tris[ origIdx ].power;
531
584
 
532
585
  }
533
586
 
@@ -540,7 +593,7 @@ export class EmissiveTriangleBuilder {
540
593
  for ( let i = 0; i < n; i ++ ) {
541
594
 
542
595
  const origIdx = sortedPerm[ i ];
543
- const tri = this.emissiveTriangles[ origIdx ];
596
+ const tri = tris[ origIdx ];
544
597
  const offset = i * 8;
545
598
 
546
599
  // vec4[0]: triangleIndex, power, cdf, selectionPdf
@@ -579,6 +632,7 @@ export class EmissiveTriangleBuilder {
579
632
  // triangleIndex, -1 for non-emissive. Lets the GPU re-walk the descent pdf for MIS.
580
633
  this.emissiveBitTrailMap = null;
581
634
  this._totalTriangleCount = 0;
635
+ this._hiddenMeshes = new Set();
582
636
 
583
637
  }
584
638
 
@@ -979,6 +979,9 @@ export class SceneProcessor {
979
979
  this.emissiveTriangleData = this.emissiveTriangleBuilder.emissiveTriangleData || this.emissiveTriangleData;
980
980
  // Per-triangle bit-trail map for the bounce-hit MIS re-walk
981
981
  this.emissiveBitTrailMap = this.emissiveTriangleBuilder.emissiveBitTrailMap;
982
+ // buildLightBVH is authoritative for the sampled (visible-subset) count/power
983
+ this.emissiveTriangleCount = this.emissiveTriangleBuilder.emissiveCount;
984
+ this.emissiveTotalPower = this.emissiveTriangleBuilder.totalEmissivePower;
982
985
 
983
986
  }
984
987
 
@@ -1581,8 +1584,35 @@ export class SceneProcessor {
1581
1584
 
1582
1585
  if ( ! changed ) return null;
1583
1586
 
1584
- // Rebuild the Light BVH + sorted emissive data + bit-trail map so the stochastic descent and
1585
- // the bounce-hit MIS re-walk stay consistent after powers / the emissive set change.
1587
+ return this._collectEmissivePayload();
1588
+
1589
+ }
1590
+
1591
+ /**
1592
+ * Re-derive the sampled emissive set from per-mesh visibility.
1593
+ * @param {Set<number>|Iterable<number>} hiddenMeshIndices - meshIndex values that are world-hidden
1594
+ * @param {boolean} force - rebuild even if the effective hidden set is unchanged
1595
+ * @returns {object|null} GPU upload payload, or null when nothing changed
1596
+ */
1597
+ rebuildEmissiveForVisibility( hiddenMeshIndices, force = false ) {
1598
+
1599
+ if ( ! this.emissiveTriangleBuilder ) return null;
1600
+
1601
+ const changed = this.emissiveTriangleBuilder.setHiddenMeshes( hiddenMeshIndices );
1602
+ if ( ! changed && ! force ) return null;
1603
+
1604
+ return this._collectEmissivePayload();
1605
+
1606
+ }
1607
+
1608
+ /**
1609
+ * Rebuild the Light BVH + sorted emissive data + bit-trail map (over the visible
1610
+ * subset) so the stochastic descent and the bounce-hit MIS re-walk stay consistent,
1611
+ * then sync the processor fields and return the GPU upload payload.
1612
+ * @private
1613
+ */
1614
+ _collectEmissivePayload() {
1615
+
1586
1616
  this.emissiveTriangleBuilder.buildLightBVH();
1587
1617
  this.lightBVHNodeData = this.emissiveTriangleBuilder.lightBVHNodeData;
1588
1618
  this.lightBVHNodeCount = this.emissiveTriangleBuilder.lightBVHNodeCount;
@@ -1592,9 +1622,9 @@ export class SceneProcessor {
1592
1622
  this.emissiveTotalPower = this.emissiveTriangleBuilder.totalEmissivePower;
1593
1623
 
1594
1624
  return {
1595
- rawData: this.emissiveTriangleBuilder.emissiveTriangleData,
1596
- emissiveCount: this.emissiveTriangleBuilder.emissiveCount,
1597
- totalPower: this.emissiveTriangleBuilder.totalEmissivePower,
1625
+ rawData: this.emissiveTriangleData,
1626
+ emissiveCount: this.emissiveTriangleCount,
1627
+ totalPower: this.emissiveTotalPower,
1598
1628
  bitTrailMap: this.emissiveBitTrailMap,
1599
1629
  lightBVHNodeData: this.lightBVHNodeData,
1600
1630
  lightBVHNodeCount: this.lightBVHNodeCount,
@@ -199,6 +199,17 @@ export class PathTracer extends PathTracerStage {
199
199
  // Kernels not built yet (first frame / mid-resize) — skip until ready.
200
200
  if ( ! this.isReady || ! this._wavefrontReady ) return;
201
201
 
202
+ // The packed light buffer was grow-reallocated at runtime (emissive set grew) —
203
+ // the compiled kernels still bind the old attribute, so rebuild before rendering.
204
+ if ( this._lightBufferRealloc ) {
205
+
206
+ if ( this._kernelManager ) this._kernelManager.dispose();
207
+ this._wavefrontReady = false;
208
+ this._buildWavefrontKernels();
209
+ if ( ! this._wavefrontReady ) return;
210
+
211
+ }
212
+
202
213
  if ( this.isComplete || this.frameCount >= this.completionThreshold || this._isConvergedComplete() ) {
203
214
 
204
215
  if ( ! this.isComplete ) this.isComplete = true;
@@ -685,6 +696,9 @@ export class PathTracer extends PathTracerStage {
685
696
  const texNodes = this.shaderBuilder.getSceneTextureNodes();
686
697
  if ( ! texNodes ) return;
687
698
 
699
+ // A fresh build binds the current lightStorageAttr — any pending realloc is covered.
700
+ this._lightBufferRealloc = false;
701
+
688
702
  const w = this.storageTextures.renderWidth;
689
703
  const h = this.storageTextures.renderHeight;
690
704
  const maxRays = w * h;
@@ -197,6 +197,9 @@ export class PathTracerStage extends RenderStage {
197
197
  // Initialized with dummy data so TSL compilation never sees null.
198
198
  this.lightStorageAttr = new StorageInstancedBufferAttribute( new Float32Array( 16 ), 4 );
199
199
  this.lightStorageNode = storage( this.lightStorageAttr, 'vec4', 1 ).toReadOnly();
200
+ // Set when _rebuildLightBuffer had to grow-reallocate the attribute: compiled
201
+ // kernels still bind the old one and must be rebuilt to see the new data.
202
+ this._lightBufferRealloc = false;
200
203
 
201
204
  // Cached CPU-side data — rebuilt into the packed buffer whenever any source changes.
202
205
  this._lbvhDataCache = null;
@@ -1279,6 +1282,12 @@ export class PathTracerStage extends RenderStage {
1279
1282
  * Rebuild the packed light buffer from cached lightBVH + emissive data.
1280
1283
  * Layout: [ lightBVH (LBVH_STRIDE vec4s per node) | emissive (EMISSIVE_STRIDE vec4s per entry) ].
1281
1284
  * Also updates `emissiveVec4Offset` uniform (in vec4 elements).
1285
+ *
1286
+ * The compiled wavefront kernels bind the attribute that existed at kernel-build
1287
+ * time and NEVER pick up a `lightStorageNode.value` reassignment — so runtime
1288
+ * updates MUST write in-place into the bound attribute (+ needsUpdate). Only
1289
+ * grow-reallocate when the data outgrows capacity, and flag it so the kernels
1290
+ * get rebuilt against the new attribute.
1282
1291
  * @private
1283
1292
  */
1284
1293
  _rebuildLightBuffer() {
@@ -1294,14 +1303,33 @@ export class PathTracerStage extends RenderStage {
1294
1303
 
1295
1304
  // Ensure at least a minimal non-empty buffer so GPU allocation remains valid.
1296
1305
  const totalLen = Math.max( lbvhLen + emisLen + trailPadded, 4 );
1297
- const combined = new Float32Array( totalLen );
1298
- if ( lbvh ) combined.set( lbvh, 0 );
1299
- if ( emis ) combined.set( emis, lbvhLen );
1300
- if ( trail ) combined.set( trail, lbvhLen + emisLen );
1301
-
1302
- this.lightStorageAttr = new StorageInstancedBufferAttribute( combined, 4 );
1303
- this.lightStorageNode.value = this.lightStorageAttr;
1304
- this.lightStorageNode.bufferCount = combined.length / 4;
1306
+
1307
+ if ( this.lightStorageAttr && totalLen <= this.lightStorageAttr.array.length ) {
1308
+
1309
+ // In-place update of the bound attribute. Stale floats past the new data
1310
+ // are unreachable — all reads are gated by the count/offset uniforms.
1311
+ const arr = this.lightStorageAttr.array;
1312
+ if ( lbvh ) arr.set( lbvh, 0 );
1313
+ if ( emis ) arr.set( emis, lbvhLen );
1314
+ if ( trail ) arr.set( trail, lbvhLen + emisLen );
1315
+ this.lightStorageAttr.needsUpdate = true;
1316
+
1317
+ } else {
1318
+
1319
+ const combined = new Float32Array( totalLen );
1320
+ if ( lbvh ) combined.set( lbvh, 0 );
1321
+ if ( emis ) combined.set( emis, lbvhLen );
1322
+ if ( trail ) combined.set( trail, lbvhLen + emisLen );
1323
+
1324
+ this.lightStorageAttr = new StorageInstancedBufferAttribute( combined, 4 );
1325
+ this.lightStorageNode.value = this.lightStorageAttr;
1326
+ this.lightStorageNode.bufferCount = combined.length / 4;
1327
+
1328
+ // Already-compiled kernels still bind the old attribute — request a rebuild.
1329
+ // (During scene load this is a no-op: setupMaterial rebuilds kernels anyway.)
1330
+ this._lightBufferRealloc = true;
1331
+
1332
+ }
1305
1333
 
1306
1334
  // Offset (in vec4 elements) where emissive data starts.
1307
1335
  this.emissiveVec4Offset.value = ( this.lightBVHNodeCount.value || 0 ) * LBVH_STRIDE;
@@ -96,7 +96,9 @@ export const sphericalTriangleSolidAngle = Fn( ( [ v0, v1, v2, p ] ) => {
96
96
 
97
97
  } );
98
98
 
99
- // Heuristic: use spherical sampling when triangle is close/large (Blender Cycles approach)
99
+ // Heuristic: use spherical sampling when triangle is close/large (Blender Cycles approach).
100
+ // Shared by the samplers AND the MIS pdf re-walks (EmissiveSampling + LightBVHSampling) —
101
+ // both sides MUST take the same branch or MIS partition-of-unity breaks.
100
102
  export const useSphericalSampling = Fn( ( [ v0, v1, v2, hitPoint ] ) => {
101
103
 
102
104
  const e0 = v1.sub( v0 );
@@ -114,7 +116,14 @@ export const useSphericalSampling = Fn( ( [ v0, v1, v2, hitPoint ] ) => {
114
116
  const d = dot( triNormal, hitPoint.sub( v0 ) );
115
117
  // planeDist² = d² / |triNormal|²
116
118
  const planeDistSq = d.mul( d ).div( triNormalLenSq );
117
- result.assign( longestEdgeSq.greaterThan( planeDistSq ) );
119
+
120
+ // Sliver guard: Arvo spherical-triangle sampling is numerically unstable on
121
+ // high-aspect slivers (NaN/garbage → colored fireflies). |triNormal|² = (2A)²
122
+ // and 2A = longestEdge·height, so longestEdgeSq²/triNormalLenSq = (edge/height)².
123
+ // Require aspect < 8 (64 = 8²), else fall back to stable area sampling.
124
+ const notSliver = longestEdgeSq.mul( longestEdgeSq ).lessThan( triNormalLenSq.mul( 64.0 ) );
125
+
126
+ result.assign( longestEdgeSq.greaterThan( planeDistSq ).and( notSliver ) );
118
127
 
119
128
  } );
120
129