rayzee 7.2.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.2.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",
@@ -1190,8 +1190,13 @@ export class PathTracerApp extends EventDispatcher {
1190
1190
  if ( result ) {
1191
1191
 
1192
1192
  this.stages.pathTracer.setEmissiveTriangleData(
1193
- result.rawData, result.emissiveCount, result.totalPower,
1193
+ result.rawData, result.emissiveCount, result.totalPower, result.bitTrailMap,
1194
1194
  );
1195
+ if ( result.lightBVHNodeData ) {
1196
+
1197
+ this.stages.pathTracer.setLightBVHData( result.lightBVHNodeData, result.lightBVHNodeCount );
1198
+
1199
+ }
1195
1200
 
1196
1201
  }
1197
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
  }
@@ -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();
@@ -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();