rayzee 7.31.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6366 -4361
  3. package/dist/rayzee.umd.js +122 -73
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +5 -0
  7. package/src/PathTracerApp.js +120 -19
  8. package/src/Processor/ArchiveReader.js +538 -0
  9. package/src/Processor/AssetLoader.js +265 -23
  10. package/src/Processor/BVHBuilder.js +48 -32
  11. package/src/Processor/BVHRefitter.js +244 -99
  12. package/src/Processor/ChunkedRecords.js +302 -0
  13. package/src/Processor/EmissiveTriangleBuilder.js +48 -21
  14. package/src/Processor/GeometryExtractor.js +577 -92
  15. package/src/Processor/HostMemory.js +222 -0
  16. package/src/Processor/InstanceTable.js +548 -97
  17. package/src/Processor/PBRT/EqualAreaOctahedral.js +32 -8
  18. package/src/Processor/PBRT/PBRTCurves.js +249 -0
  19. package/src/Processor/PBRT/PBRTMath.js +19 -0
  20. package/src/Processor/PBRT/PBRTParser.js +187 -41
  21. package/src/Processor/PBRT/PBRTSceneBuilder.js +586 -42
  22. package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
  23. package/src/Processor/PBRT/index.js +257 -50
  24. package/src/Processor/PackedRayBuffer.js +29 -18
  25. package/src/Processor/ParallelBVHBuilder.js +9 -8
  26. package/src/Processor/QueueManager.js +5 -3
  27. package/src/Processor/SceneProcessor.js +1046 -365
  28. package/src/Processor/TLASBuilder.js +546 -201
  29. package/src/Processor/TextureCreator.js +11 -9
  30. package/src/Processor/VRAMTracker.js +12 -3
  31. package/src/Processor/Workers/BVHRefitWorker.js +16 -11
  32. package/src/Processor/Workers/BVHWorker.js +12 -12
  33. package/src/Processor/Workers/TLASWorker.js +37 -0
  34. package/src/Stages/NormalDepth.js +6 -2
  35. package/src/Stages/PathTracer.js +5 -5
  36. package/src/Stages/PathTracerStage.js +163 -41
  37. package/src/TSL/BVHTraversal.js +208 -90
  38. package/src/TSL/Common.js +135 -2
  39. package/src/TSL/Displacement.js +21 -11
  40. package/src/TSL/EmissiveSampling.js +49 -25
  41. package/src/TSL/ExtendKernel.js +3 -2
  42. package/src/TSL/LightBVHSampling.js +7 -4
  43. package/src/TSL/LightsDirect.js +30 -29
  44. package/src/TSL/ShadeKernel.js +8 -6
  45. package/src/TSL/Struct.js +3 -0
  46. package/src/TSL/TextureSampling.js +24 -9
  47. package/src/TSL/patches.js +128 -3
  48. package/src/index.js +3 -0
  49. package/src/managers/AnimationManager.js +86 -76
  50. package/src/managers/MaterialDataManager.js +29 -23
  51. package/src/managers/TransformManager.js +9 -192
@@ -3,161 +3,467 @@
3
3
  *
4
4
  * Produces a small SAH BVH where leaves are BLAS-pointer nodes (marker -2)
5
5
  * that reference per-mesh BLAS root indices in the combined BVH buffer.
6
+ *
7
+ * Single pass: entry indices are partitioned in place inside one Int32Array and nodes are
8
+ * written straight into the final flat buffer. There is no intermediate node tree — the
9
+ * previous build allocated one JS object per node plus two fresh index arrays per split and
10
+ * then walked the result three times, which at 5M placements cost 20.8 s and 7.7 GB of
11
+ * transient heap and was the scene's hard ceiling, well before triangles or VRAM.
12
+ *
13
+ * Every leaf holds exactly one entry, so a subtree over k entries occupies exactly 2k-1
14
+ * consecutive pre-order nodes. That makes both the total node count and each child's index
15
+ * known before its subtree is built, which is what lets the write be single-pass.
6
16
  */
7
17
 
8
- import { BVH_LEAF_MARKERS } from '../EngineDefaults.js';
18
+ import {
19
+ BVH_LEAF_MARKERS, assertBVHIndexFits, bvhIndexView, TLAS_LEAF_IDENTITY, TLAS_PLACEMENT_MASK
20
+ } from '../EngineDefaults.js';
21
+ import { invertAffineInto, isIdentityAt } from './InstanceTable.js';
9
22
 
10
23
  const FLOATS_PER_NODE = 16;
24
+ // 16 mis-split near the root: +42 % SAH cost, 3.5 % of frame time. 24 and up match an exact sweep.
25
+ const SAH_BINS = 32;
26
+ // Most nodes in a deep tree hold a handful of entries, and clearing 3x32 bins then sweeping
27
+ // them costs far more than binning four AABBs. Bin count follows the range instead.
28
+ const binsFor = count => ( count < SAH_BINS ? count : SAH_BINS );
29
+ // Below this, clearing bins and sweeping them costs more than the split is worth; a median on
30
+ // the widest centroid axis is a rounding error apart in tree quality at this size.
31
+ const SMALL_NODE = 8;
32
+ // Recursing into the smaller half and looping on the larger keeps depth at O(log n), so a
33
+ // pathological split chain cannot grow the stack to n frames.
34
+ const INITIAL_STACK_FRAMES = 64;
35
+ // One affine inverse is live at a time while leaves are filled.
36
+ const _inverseScratch = new Float64Array( 16 );
11
37
 
12
- class TLASNode {
38
+ export class TLASBuilder {
13
39
 
14
40
  constructor() {
15
41
 
16
- this.minX = 0; this.minY = 0; this.minZ = 0;
17
- this.maxX = 0; this.maxY = 0; this.maxZ = 0;
18
- this.leftChild = null;
19
- this.rightChild = null;
20
- this.entryIndex = - 1; // Index into InstanceTable.entries (leaf only)
42
+ // Cached flatten buffer — reused across rebuilds to avoid per-refit allocation.
43
+ this._flatBuffer = null;
44
+ this._flatBufferCapacity = 0;
45
+
46
+ // Binning scratch, reused across every node.
47
+ this._binCounts = new Int32Array( SAH_BINS * 3 ); // all three axes, binned in one pass
48
+ this._binBounds = new Float64Array( SAH_BINS * 6 * 3 );
49
+ this._binSuffix = new Float64Array( SAH_BINS );
50
+ this._binSuffixCount = new Int32Array( SAH_BINS );
51
+
52
+ this._order = null; // entry indices, partitioned in place
53
+ this._stackI = null; // nodeIndex, start, end per frame
54
+ this._stackB = null; // the frame's AABB, 6 doubles
55
+ this._stackFrames = 0;
56
+ this._sortScratch = [];
57
+ this._suffix = null;
58
+ this._bounds = new Float64Array( 12 ); // left half at 0, right half at 6
59
+ this._extent = new Float64Array( 3 );
60
+ this._cMin = new Float64Array( 3 );
21
61
 
22
62
  }
23
63
 
24
- }
64
+ /** Nodes a TLAS over `entryCount` entries will occupy. Exact: every leaf holds one entry. */
65
+ static nodeCountFor( entryCount ) {
25
66
 
26
- export class TLASBuilder {
67
+ return entryCount > 0 ? entryCount * 2 - 1 : 0;
27
68
 
28
- constructor() {
69
+ }
70
+
71
+ /** Drop the cached flatten buffer. Worth it when the scene is large enough that holding a
72
+ * spare copy of the TLAS costs more than rebuilding it does. */
73
+ releaseFlattenBuffer() {
29
74
 
30
- // Cached flatten buffer — reused across rebuilds to avoid per-refit allocation.
31
75
  this._flatBuffer = null;
32
76
  this._flatBufferCapacity = 0;
33
77
 
34
78
  }
35
79
 
36
80
  /**
37
- * Build TLAS from instance table entries.
81
+ * Build and flatten the TLAS in one pass.
82
+ *
83
+ * Inner nodes carry their children's AABBs and indices; leaves carry
84
+ * [blasRootNodeIndex, entryIndex, visibility, -2] followed by the instance's
85
+ * world-to-object matrix as three rows of four.
86
+ *
87
+ * Callers must assign BLAS offsets before calling — leaf payloads are written as the tree
88
+ * is built, so `entry.blasOffset` has to be final. Use {@link TLASBuilder.nodeCountFor} to
89
+ * get the node count the offsets depend on.
38
90
  *
39
- * @param {Array<{worldAABB: {minX,minY,minZ,maxX,maxY,maxZ}, blasOffset: number}>} entries
40
- * @returns {{ root: TLASNode, nodeCount: number }}
91
+ * Side effect: records each entry's flat leaf index on `entry.tlasLeafIndex` so visibility
92
+ * can later be patched in place (combinedBvhData[tlasLeafIndex*16 + 2]).
93
+ *
94
+ * @param {import('./InstanceTable.js').InstanceTable} table
95
+ * @returns {{ data: Float32Array, nodeCount: number }}
41
96
  */
42
- build( entries ) {
97
+ build( table ) {
98
+
99
+ const aabbs = new Float64Array( table.count * 6 );
100
+ table.writeWorldAABBs( aabbs );
101
+ const built = this.buildStructure( aabbs, table.count );
102
+ TLASBuilder.fillLeaves( built.data, built.nodeCount, table );
103
+ return built;
104
+
105
+ }
106
+
107
+ /**
108
+ * The expensive half: SAH tree over the AABBs alone, with no reference to the entry objects.
109
+ * Leaves are written as [0, entryIndex, 0, -2]; {@link TLASBuilder.fillLeaves} completes them.
110
+ * Split out so it can run in a worker — at 6M placements it is 25 s of frozen main thread.
111
+ *
112
+ * @param {ArrayLike<number>} aabb - 6 floats per entry (min xyz, max xyz)
113
+ * @param {number} n - entry count
114
+ * @returns {{ data: Float32Array, nodeCount: number }}
115
+ */
116
+ buildStructure( aabb, n ) {
117
+
118
+ const nodeCount = TLASBuilder.nodeCountFor( n );
119
+ if ( n === 0 ) return { data: new Float32Array( 0 ), nodeCount: 0 };
120
+ assertBVHIndexFits( nodeCount, 'TLAS node count' );
121
+
122
+ const required = nodeCount * FLOATS_PER_NODE;
123
+ if ( required > this._flatBufferCapacity ) {
124
+
125
+ this._flatBuffer = new Float32Array( required );
126
+ this._flatBufferCapacity = required;
127
+
128
+ }
129
+
130
+ const data = this._flatBuffer;
131
+ const idx = bvhIndexView( data ); // index fields are u32 bit patterns, never float values
132
+
133
+ if ( ! this._order || this._order.length < n ) this._order = new Int32Array( n );
134
+ const order = this._order;
135
+ for ( let i = 0; i < n; i ++ ) order[ i ] = i;
136
+
137
+ this._ensureStack( INITIAL_STACK_FRAMES );
138
+
139
+ let depth = 0;
140
+ let nodeIndex = 0, start = 0, end = n;
141
+ const bb = this._bounds;
142
+ this._rangeBounds( aabb, order, start, end, bb, 0 );
143
+ let minX = bb[ 0 ], minY = bb[ 1 ], minZ = bb[ 2 ];
144
+ let maxX = bb[ 3 ], maxY = bb[ 4 ], maxZ = bb[ 5 ];
145
+
146
+ for ( ;; ) {
147
+
148
+ const count = end - start;
149
+
150
+ if ( count === 1 ) {
151
+
152
+ const o = nodeIndex * FLOATS_PER_NODE;
153
+ idx[ o ] = 0;
154
+ idx[ o + 1 ] = order[ start ];
155
+ data[ o + 2 ] = 0;
156
+ idx[ o + 3 ] = BVH_LEAF_MARKERS.BLAS_POINTER_LEAF;
43
157
 
44
- if ( entries.length === 0 ) {
158
+ } else {
159
+
160
+ const mid = this._partition( aabb, order, start, end, minX, minY, minZ, maxX, maxY, maxZ );
161
+ const leftCount = mid - start;
162
+ const leftIndex = nodeIndex + 1;
163
+ const rightIndex = leftIndex + ( leftCount * 2 - 1 );
164
+
165
+ const o = nodeIndex * FLOATS_PER_NODE;
166
+ data[ o ] = bb[ 0 ]; data[ o + 1 ] = bb[ 1 ]; data[ o + 2 ] = bb[ 2 ]; idx[ o + 3 ] = leftIndex;
167
+ data[ o + 4 ] = bb[ 3 ]; data[ o + 5 ] = bb[ 4 ]; data[ o + 6 ] = bb[ 5 ]; idx[ o + 7 ] = rightIndex;
168
+ data[ o + 8 ] = bb[ 6 ]; data[ o + 9 ] = bb[ 7 ]; data[ o + 10 ] = bb[ 8 ]; data[ o + 11 ] = 0;
169
+ data[ o + 12 ] = bb[ 9 ]; data[ o + 13 ] = bb[ 10 ]; data[ o + 14 ] = bb[ 11 ]; data[ o + 15 ] = 0;
170
+
171
+ // Continue into the smaller half, stack the larger — bounds the stack at O(log n).
172
+ const keepLeft = leftCount <= end - mid;
173
+ const k = keepLeft ? 0 : 6, p = keepLeft ? 6 : 0;
174
+ depth = this._push(
175
+ depth, keepLeft ? rightIndex : leftIndex,
176
+ keepLeft ? mid : start, keepLeft ? end : mid, bb, p
177
+ );
178
+ nodeIndex = keepLeft ? leftIndex : rightIndex;
179
+ if ( keepLeft ) end = mid; else start = mid;
180
+ minX = bb[ k ]; minY = bb[ k + 1 ]; minZ = bb[ k + 2 ];
181
+ maxX = bb[ k + 3 ]; maxY = bb[ k + 4 ]; maxZ = bb[ k + 5 ];
182
+
183
+ continue;
184
+
185
+ }
186
+
187
+ if ( depth === 0 ) break;
45
188
 
46
- return { root: null, nodeCount: 0 };
189
+ depth --;
190
+ const fi = depth * 3, fb = depth * 6;
191
+ nodeIndex = this._stackI[ fi ]; start = this._stackI[ fi + 1 ]; end = this._stackI[ fi + 2 ];
192
+ minX = this._stackB[ fb ]; minY = this._stackB[ fb + 1 ]; minZ = this._stackB[ fb + 2 ];
193
+ maxX = this._stackB[ fb + 3 ]; maxY = this._stackB[ fb + 4 ]; maxZ = this._stackB[ fb + 5 ];
47
194
 
48
195
  }
49
196
 
50
- // Build array of indices for partitioning
51
- const indices = [];
52
- for ( let i = 0; i < entries.length; i ++ ) {
197
+ return { data: data.subarray( 0, required ), nodeCount };
53
198
 
54
- indices.push( i );
199
+ }
200
+
201
+ /** @private */
202
+ _push( depth, nodeIndex, start, end, b, off ) {
203
+
204
+ if ( depth >= this._stackFrames ) this._ensureStack( this._stackFrames * 2 );
205
+ const fi = depth * 3, fb = depth * 6;
206
+ this._stackI[ fi ] = nodeIndex; this._stackI[ fi + 1 ] = start; this._stackI[ fi + 2 ] = end;
207
+ for ( let i = 0; i < 6; i ++ ) this._stackB[ fb + i ] = b[ off + i ];
208
+ return depth + 1;
209
+
210
+ }
211
+
212
+ /** @private */
213
+ _ensureStack( frames ) {
214
+
215
+ if ( this._stackFrames >= frames ) return;
216
+ const grownI = new Int32Array( frames * 3 );
217
+ const grownB = new Float64Array( frames * 6 );
218
+ if ( this._stackI ) {
219
+
220
+ grownI.set( this._stackI );
221
+ grownB.set( this._stackB );
55
222
 
56
223
  }
57
224
 
58
- const root = this._buildRecursive( entries, indices );
59
- const nodeCount = this._countNodes( root );
225
+ this._stackI = grownI; this._stackB = grownB; this._stackFrames = frames;
226
+
227
+ }
228
+
229
+ /**
230
+ * Fill in each leaf's BLAS pointer, visibility and world-to-object matrix, and record where
231
+ * the leaf landed. One O(n) pass, so it stays on the main thread while the SAH tree does not.
232
+ *
233
+ * A node is 16 floats and the BLAS pointer needs 4, so the affine inverse fits in the
234
+ * remaining 12: the ray moves into object space with no second binding, which matters
235
+ * because Shade is already at the 10 storage buffers Metal allows. The inverse is derived
236
+ * here rather than stored: a column of it costs 64 bytes a placement.
237
+ */
238
+ static fillLeaves( data, nodeCount, table ) {
60
239
 
61
- return { root, nodeCount };
240
+ const world = table.world, src = table.sourceMesh;
241
+ const blasOffset = table.tplBlasOffset, visible = table.visible, leafOf = table.tlasLeafIndex;
242
+ const idx = bvhIndexView( data );
243
+
244
+ for ( let node = 0; node < nodeCount; node ++ ) {
245
+
246
+ const o = node * FLOATS_PER_NODE;
247
+ if ( idx[ o + 3 ] !== BVH_LEAF_MARKERS.BLAS_POINTER_LEAF ) continue;
248
+
249
+ const i = idx[ o + 1 ] & TLAS_PLACEMENT_MASK;
250
+ idx[ o ] = blasOffset[ src[ i ] ];
251
+ data[ o + 2 ] = visible[ i ] ? 1.0 : 0.0;
252
+
253
+ TLASBuilder.writeLeafMatrix( data, idx, o, world, i );
254
+
255
+ leafOf[ i ] = node;
256
+
257
+ }
258
+
259
+ }
260
+
261
+ /**
262
+ * Write one leaf's world-to-object rows, the twelve floats after its payload. Called again on
263
+ * its own whenever a placement moves, so a rigid transform costs a matrix rather than a
264
+ * rewrite of the geometry the placement may be sharing.
265
+ *
266
+ * Slot [1] is rewritten too: the placement index, with {@link TLAS_LEAF_IDENTITY} set when
267
+ * the matrix is identity so traversal can skip the ray transform for that leaf.
268
+ *
269
+ * @param {Float32Array} data - the node buffer, or one chunk of it
270
+ * @param {Uint32Array} idx - u32 view over the same memory as `data`
271
+ * @param {number} off - float offset of the leaf within `data`
272
+ * @param {Float32Array} world - the instance table's object-to-world column
273
+ * @param {number} placement
274
+ */
275
+ static writeLeafMatrix( data, idx, off, world, placement ) {
276
+
277
+ const inv = _inverseScratch;
278
+ invertAffineInto( world, placement * 16, inv );
279
+
280
+ data[ off + 4 ] = inv[ 0 ]; data[ off + 5 ] = inv[ 4 ]; data[ off + 6 ] = inv[ 8 ]; data[ off + 7 ] = inv[ 12 ];
281
+ data[ off + 8 ] = inv[ 1 ]; data[ off + 9 ] = inv[ 5 ]; data[ off + 10 ] = inv[ 9 ]; data[ off + 11 ] = inv[ 13 ];
282
+ data[ off + 12 ] = inv[ 2 ]; data[ off + 13 ] = inv[ 6 ]; data[ off + 14 ] = inv[ 10 ]; data[ off + 15 ] = inv[ 14 ];
283
+
284
+ idx[ off + 1 ] = ( placement | ( isIdentityAt( world, placement * 16 ) ? TLAS_LEAF_IDENTITY : 0 ) ) >>> 0;
62
285
 
63
286
  }
64
287
 
65
288
  /**
66
- * Recursive SAH-based TLAS build.
289
+ * Choose a split for order[start..end) and partition it in place.
290
+ * Always returns a mid strictly inside the range, so every leaf ends up holding one entry.
67
291
  * @private
68
292
  */
69
- _buildRecursive( entries, indices ) {
293
+ _partition( aabb, order, start, end, minX, minY, minZ, maxX, maxY, maxZ ) {
294
+
295
+ const count = end - start;
296
+ let mid = 0;
70
297
 
71
- const node = new TLASNode();
298
+ if ( count === 2 ) mid = start + 1;
299
+ else if ( count <= SMALL_NODE ) {
72
300
 
73
- if ( indices.length === 1 ) {
301
+ mid = start + ( count >> 1 );
302
+ this._insertionSortRange( aabb, order, start, end, this._widestCentroidAxis( aabb, order, start, end ) );
74
303
 
75
- // Leaf — single mesh
76
- const entry = entries[ indices[ 0 ] ];
77
- const aabb = entry.worldAABB;
78
- node.minX = aabb.minX; node.minY = aabb.minY; node.minZ = aabb.minZ;
79
- node.maxX = aabb.maxX; node.maxY = aabb.maxY; node.maxZ = aabb.maxZ;
80
- node.entryIndex = indices[ 0 ];
81
- return node;
304
+ } else {
305
+
306
+ // Binned SAH at every size, not just large ranges. Almost every node in a deep tree
307
+ // holds a handful of entries, so gating the sort-based exact sweep on "small range"
308
+ // ran it ~9.8M times at 5M placements — three sorts each, and the bulk of the build.
309
+ mid = this._binnedSplit( aabb, order, start, end, minX, minY, minZ, maxX, maxY, maxZ );
310
+ // The binned partition already accumulated both halves' bounds.
311
+ if ( mid > start && mid < end ) return mid;
312
+
313
+ mid = this._sweepSplit( aabb, order, start, end, minX, minY, minZ, maxX, maxY, maxZ );
314
+
315
+ if ( ! ( mid > start && mid < end ) ) {
316
+
317
+ // Median on the widest axis — degenerate AABBs, overflowed surface area, or
318
+ // coincident centroids all land here.
319
+ const dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ;
320
+ const axis = dy > dx && dy > dz ? 1 : dz > dx ? 2 : 0;
321
+ this._sortRange( aabb, order, start, end, axis );
322
+ mid = start + ( count >> 1 );
323
+
324
+ }
82
325
 
83
326
  }
84
327
 
85
- // Compute overall AABB
86
- let minX = Infinity, minY = Infinity, minZ = Infinity;
87
- let maxX = - Infinity, maxY = - Infinity, maxZ = - Infinity;
328
+ const bb = this._bounds;
329
+ this._rangeBounds( aabb, order, start, mid, bb, 0 );
330
+ this._rangeBounds( aabb, order, mid, end, bb, 6 );
331
+ return mid;
88
332
 
89
- for ( const idx of indices ) {
333
+ }
90
334
 
91
- const aabb = entries[ idx ].worldAABB;
92
- if ( aabb.minX < minX ) minX = aabb.minX;
93
- if ( aabb.minY < minY ) minY = aabb.minY;
94
- if ( aabb.minZ < minZ ) minZ = aabb.minZ;
95
- if ( aabb.maxX > maxX ) maxX = aabb.maxX;
96
- if ( aabb.maxY > maxY ) maxY = aabb.maxY;
97
- if ( aabb.maxZ > maxZ ) maxZ = aabb.maxZ;
335
+ /**
336
+ * Binned SAH — one pass to bin, one over the bin boundaries, then an in-place partition.
337
+ * A node costs O(n) instead of sorting three times over.
338
+ * Returns `start` when the centroids are degenerate on every axis.
339
+ * @private
340
+ */
341
+ _binnedSplit( aabb, order, start, end, minX, minY, minZ, maxX, maxY, maxZ ) {
342
+
343
+ const parentSA = this._surfaceArea( minX, minY, minZ, maxX, maxY, maxZ );
344
+ if ( ! ( parentSA > 0 ) || ! isFinite( parentSA ) ) return start;
345
+
346
+ // Centroid bounds decide the bin mapping; object bounds decide the cost.
347
+ let cMinX = Infinity, cMinY = Infinity, cMinZ = Infinity;
348
+ let cMaxX = - Infinity, cMaxY = - Infinity, cMaxZ = - Infinity;
349
+ for ( let i = start; i < end; i ++ ) {
350
+
351
+ const a = order[ i ] * 6;
352
+ const cx = ( aabb[ a ] + aabb[ a + 3 ] ) * 0.5;
353
+ const cy = ( aabb[ a + 1 ] + aabb[ a + 4 ] ) * 0.5;
354
+ const cz = ( aabb[ a + 2 ] + aabb[ a + 5 ] ) * 0.5;
355
+ if ( cx < cMinX ) cMinX = cx; if ( cx > cMaxX ) cMaxX = cx;
356
+ if ( cy < cMinY ) cMinY = cy; if ( cy > cMaxY ) cMaxY = cy;
357
+ if ( cz < cMinZ ) cMinZ = cz; if ( cz > cMaxZ ) cMaxZ = cz;
98
358
 
99
359
  }
100
360
 
101
- node.minX = minX; node.minY = minY; node.minZ = minZ;
102
- node.maxX = maxX; node.maxY = maxY; node.maxZ = maxZ;
361
+ // Reused, not fresh literals: at ~10M calls two small arrays per call is most of the
362
+ // collector's work.
363
+ const extent = this._extent, cMin = this._cMin;
364
+ extent[ 0 ] = cMaxX - cMinX; extent[ 1 ] = cMaxY - cMinY; extent[ 2 ] = cMaxZ - cMinZ;
365
+ cMin[ 0 ] = cMinX; cMin[ 1 ] = cMinY; cMin[ 2 ] = cMinZ;
366
+
367
+ let bestCost = Infinity, bestAxis = - 1, bestBin = - 1;
368
+ const counts = this._binCounts;
369
+ const bounds = this._binBounds;
370
+
371
+ const liveX = extent[ 0 ] > 1e-12, liveY = extent[ 1 ] > 1e-12, liveZ = extent[ 2 ] > 1e-12;
372
+ if ( ! liveX && ! liveY && ! liveZ ) return start;
373
+
374
+ const nBins = binsFor( end - start );
375
+ const scaleX = liveX ? nBins / extent[ 0 ] : 0;
376
+ const scaleY = liveY ? nBins / extent[ 1 ] : 0;
377
+ const scaleZ = liveZ ? nBins / extent[ 2 ] : 0;
103
378
 
104
- // If only 2 entries, split trivially
105
- if ( indices.length === 2 ) {
379
+ counts.fill( 0, 0, nBins * 3 );
380
+ for ( let b = 0; b < nBins * 3; b ++ ) {
106
381
 
107
- node.leftChild = this._buildRecursive( entries, [ indices[ 0 ] ] );
108
- node.rightChild = this._buildRecursive( entries, [ indices[ 1 ] ] );
109
- return node;
382
+ const o = b * 6;
383
+ bounds[ o ] = bounds[ o + 1 ] = bounds[ o + 2 ] = Infinity;
384
+ bounds[ o + 3 ] = bounds[ o + 4 ] = bounds[ o + 5 ] = - Infinity;
110
385
 
111
386
  }
112
387
 
113
- // SAH split: try all 3 axes, pick best
114
- const parentSA = this._surfaceArea( minX, minY, minZ, maxX, maxY, maxZ );
115
- let bestCost = Infinity;
116
- let bestAxis = 0;
117
- let bestSplit = 0;
388
+ // All three axes binned in ONE sweep. Three separate passes re-read the same range
389
+ // (and the same cache lines) three times for no extra information.
390
+ for ( let i = start; i < end; i ++ ) {
118
391
 
119
- // Only attempt SAH when surface area is finite and positive —
120
- // degenerate/overflow AABBs (meshes far from origin) produce NaN costs.
121
- if ( parentSA > 0 && isFinite( parentSA ) ) {
392
+ const a = order[ i ] * 6;
393
+ const aMinX = aabb[ a ], aMinY = aabb[ a + 1 ], aMinZ = aabb[ a + 2 ];
394
+ const aMaxX = aabb[ a + 3 ], aMaxY = aabb[ a + 4 ], aMaxZ = aabb[ a + 5 ];
122
395
 
123
396
  for ( let axis = 0; axis < 3; axis ++ ) {
124
397
 
125
- // Sort indices by centroid along axis
126
- const sorted = indices.slice().sort( ( a, b ) => {
398
+ const scale = axis === 0 ? scaleX : axis === 1 ? scaleY : scaleZ;
399
+ if ( scale === 0 ) continue;
400
+ const c = axis === 0 ? ( aMinX + aMaxX ) * 0.5
401
+ : axis === 1 ? ( aMinY + aMaxY ) * 0.5 : ( aMinZ + aMaxZ ) * 0.5;
402
+ let b = ( ( c - cMin[ axis ] ) * scale ) | 0;
403
+ if ( b < 0 ) b = 0; else if ( b >= nBins ) b = nBins - 1;
404
+
405
+ b += axis * nBins;
406
+ counts[ b ] ++;
407
+ const o = b * 6;
408
+ if ( aMinX < bounds[ o ] ) bounds[ o ] = aMinX;
409
+ if ( aMinY < bounds[ o + 1 ] ) bounds[ o + 1 ] = aMinY;
410
+ if ( aMinZ < bounds[ o + 2 ] ) bounds[ o + 2 ] = aMinZ;
411
+ if ( aMaxX > bounds[ o + 3 ] ) bounds[ o + 3 ] = aMaxX;
412
+ if ( aMaxY > bounds[ o + 4 ] ) bounds[ o + 4 ] = aMaxY;
413
+ if ( aMaxZ > bounds[ o + 5 ] ) bounds[ o + 5 ] = aMaxZ;
414
+
415
+ }
416
+
417
+ }
127
418
 
128
- const aabbA = entries[ a ].worldAABB;
129
- const aabbB = entries[ b ].worldAABB;
130
- const cA = this._centroid( aabbA, axis );
131
- const cB = this._centroid( aabbB, axis );
132
- return cA - cB;
419
+ for ( let axis = 0; axis < 3; axis ++ ) {
133
420
 
134
- } );
421
+ if ( ! ( extent[ axis ] > 1e-12 ) ) continue;
422
+ const binBase = axis * nBins;
135
423
 
136
- // Evaluate SAH for each split position
137
- for ( let i = 1; i < sorted.length; i ++ ) {
424
+ // Suffix pass over bin boundaries, then a prefix sweep — same shape as the exact
425
+ // version, but over the bins instead of n entries.
426
+ const rightSA = this._binSuffix;
427
+ const rightN = this._binSuffixCount;
428
+ let sMinX = Infinity, sMinY = Infinity, sMinZ = Infinity;
429
+ let sMaxX = - Infinity, sMaxY = - Infinity, sMaxZ = - Infinity, sCount = 0;
430
+
431
+ for ( let b = nBins - 1; b >= 1; b -- ) {
432
+
433
+ const o = ( binBase + b ) * 6;
434
+ if ( bounds[ o ] < sMinX ) sMinX = bounds[ o ];
435
+ if ( bounds[ o + 1 ] < sMinY ) sMinY = bounds[ o + 1 ];
436
+ if ( bounds[ o + 2 ] < sMinZ ) sMinZ = bounds[ o + 2 ];
437
+ if ( bounds[ o + 3 ] > sMaxX ) sMaxX = bounds[ o + 3 ];
438
+ if ( bounds[ o + 4 ] > sMaxY ) sMaxY = bounds[ o + 4 ];
439
+ if ( bounds[ o + 5 ] > sMaxZ ) sMaxZ = bounds[ o + 5 ];
440
+ sCount += counts[ binBase + b ];
441
+ rightSA[ b ] = sCount ? this._surfaceArea( sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ ) : 0;
442
+ rightN[ b ] = sCount;
443
+
444
+ }
138
445
 
139
- const leftAABB = this._computeGroupAABB( entries, sorted, 0, i );
140
- const rightAABB = this._computeGroupAABB( entries, sorted, i, sorted.length );
446
+ let lMinX = Infinity, lMinY = Infinity, lMinZ = Infinity;
447
+ let lMaxX = - Infinity, lMaxY = - Infinity, lMaxZ = - Infinity, lCount = 0;
141
448
 
142
- const leftSA = this._surfaceArea(
143
- leftAABB.minX, leftAABB.minY, leftAABB.minZ,
144
- leftAABB.maxX, leftAABB.maxY, leftAABB.maxZ
145
- );
146
- const rightSA = this._surfaceArea(
147
- rightAABB.minX, rightAABB.minY, rightAABB.minZ,
148
- rightAABB.maxX, rightAABB.maxY, rightAABB.maxZ
149
- );
449
+ for ( let b = 1; b < nBins; b ++ ) {
150
450
 
151
- // SAH cost: traversal + (leftSA/parentSA * leftCount + rightSA/parentSA * rightCount)
152
- const cost = 1.0 + ( leftSA * i + rightSA * ( sorted.length - i ) ) / parentSA;
451
+ const o = ( binBase + b - 1 ) * 6;
452
+ if ( bounds[ o ] < lMinX ) lMinX = bounds[ o ];
453
+ if ( bounds[ o + 1 ] < lMinY ) lMinY = bounds[ o + 1 ];
454
+ if ( bounds[ o + 2 ] < lMinZ ) lMinZ = bounds[ o + 2 ];
455
+ if ( bounds[ o + 3 ] > lMaxX ) lMaxX = bounds[ o + 3 ];
456
+ if ( bounds[ o + 4 ] > lMaxY ) lMaxY = bounds[ o + 4 ];
457
+ if ( bounds[ o + 5 ] > lMaxZ ) lMaxZ = bounds[ o + 5 ];
458
+ lCount += counts[ binBase + b - 1 ];
153
459
 
154
- if ( cost < bestCost ) {
460
+ if ( lCount === 0 || rightN[ b ] === 0 ) continue;
155
461
 
156
- bestCost = cost;
157
- bestAxis = axis;
158
- bestSplit = i;
462
+ const leftSA = this._surfaceArea( lMinX, lMinY, lMinZ, lMaxX, lMaxY, lMaxZ );
463
+ const cost = 1.0 + ( leftSA * lCount + rightSA[ b ] * rightN[ b ] ) / parentSA;
464
+ if ( cost < bestCost ) {
159
465
 
160
- }
466
+ bestCost = cost; bestAxis = axis; bestBin = b;
161
467
 
162
468
  }
163
469
 
@@ -165,182 +471,221 @@ export class TLASBuilder {
165
471
 
166
472
  }
167
473
 
168
- // Fallback to median split when SAH fails to find a valid partition
169
- // (degenerate AABB, overflow surface area, or coincident centroids).
170
- if ( bestSplit <= 0 || bestSplit >= indices.length ) {
474
+ if ( bestAxis < 0 ) return start;
171
475
 
172
- bestAxis = 0;
173
- const dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ;
174
- if ( dy > dx && dy > dz ) bestAxis = 1;
175
- else if ( dz > dx ) bestAxis = 2;
476
+ // Two-pointer partition in place: everything below the chosen bin moves left. Both
477
+ // halves' bounds fall out of the same sweep, so the caller needs no extra passes.
478
+ const scale = nBins / extent[ bestAxis ];
479
+ const base = cMin[ bestAxis ];
480
+ const bb = this._bounds;
481
+ bb[ 0 ] = bb[ 1 ] = bb[ 2 ] = bb[ 6 ] = bb[ 7 ] = bb[ 8 ] = Infinity;
482
+ bb[ 3 ] = bb[ 4 ] = bb[ 5 ] = bb[ 9 ] = bb[ 10 ] = bb[ 11 ] = - Infinity;
176
483
 
177
- bestSplit = indices.length >> 1;
484
+ let i = start, j = end - 1;
485
+ while ( i <= j ) {
178
486
 
179
- }
487
+ const a = order[ i ] * 6;
488
+ const c = ( aabb[ a + bestAxis ] + aabb[ a + 3 + bestAxis ] ) * 0.5;
489
+ let b = ( ( c - base ) * scale ) | 0;
490
+ if ( b < 0 ) b = 0; else if ( b >= nBins ) b = nBins - 1;
180
491
 
181
- // Sort along best axis and split
182
- const sorted = indices.slice().sort( ( a, b ) => {
492
+ const h = b < bestBin ? 0 : 6;
493
+ if ( aabb[ a ] < bb[ h ] ) bb[ h ] = aabb[ a ];
494
+ if ( aabb[ a + 1 ] < bb[ h + 1 ] ) bb[ h + 1 ] = aabb[ a + 1 ];
495
+ if ( aabb[ a + 2 ] < bb[ h + 2 ] ) bb[ h + 2 ] = aabb[ a + 2 ];
496
+ if ( aabb[ a + 3 ] > bb[ h + 3 ] ) bb[ h + 3 ] = aabb[ a + 3 ];
497
+ if ( aabb[ a + 4 ] > bb[ h + 4 ] ) bb[ h + 4 ] = aabb[ a + 4 ];
498
+ if ( aabb[ a + 5 ] > bb[ h + 5 ] ) bb[ h + 5 ] = aabb[ a + 5 ];
183
499
 
184
- return this._centroid( entries[ a ].worldAABB, bestAxis ) -
185
- this._centroid( entries[ b ].worldAABB, bestAxis );
500
+ if ( h === 0 ) i ++;
501
+ else {
186
502
 
187
- } );
503
+ const t = order[ i ]; order[ i ] = order[ j ]; order[ j ] = t;
504
+ j --;
188
505
 
189
- const leftIndices = sorted.slice( 0, bestSplit );
190
- const rightIndices = sorted.slice( bestSplit );
506
+ }
191
507
 
192
- node.leftChild = this._buildRecursive( entries, leftIndices );
193
- node.rightChild = this._buildRecursive( entries, rightIndices );
508
+ }
194
509
 
195
- return node;
510
+ return i;
196
511
 
197
512
  }
198
513
 
199
514
  /**
200
- * Flatten TLAS tree into Float32Array.
201
- * Inner nodes: same format as BVH.
202
- * Leaf nodes: [blasRootNodeIndex, meshIndex, visibility, -2] (BLAS-pointer marker).
203
- *
204
- * Side effect: records each entry's flat leaf index on `entry.tlasLeafIndex` so that
205
- * visibility can later be patched in place (combinedBvhData[tlasLeafIndex*16 + 2]).
206
- *
207
- * @param {TLASNode} root
208
- * @param {Array<{blasOffset: number, visible: boolean, tlasLeafIndex: number}>} entries
209
- * @returns {Float32Array}
515
+ * Exact SAH sweep over all three axes. Sorts the range once per axis into scratch, then
516
+ * leaves it sorted along the winning axis so the partition is already done.
517
+ * Returns `start` when no axis yields a valid split.
518
+ * @private
210
519
  */
211
- flatten( root, entries ) {
520
+ _sweepSplit( aabb, order, start, end, minX, minY, minZ, maxX, maxY, maxZ ) {
521
+
522
+ const n = end - start;
523
+ const parentSA = this._surfaceArea( minX, minY, minZ, maxX, maxY, maxZ );
524
+ if ( ! ( parentSA > 0 ) || ! isFinite( parentSA ) ) return start;
525
+ const suffix = this._suffixBuffer( n );
526
+ let bestCost = Infinity, bestAxis = - 1, bestSplit = - 1;
212
527
 
213
- if ( ! root ) return new Float32Array( 0 );
528
+ for ( let axis = 0; axis < 3; axis ++ ) {
214
529
 
215
- // Pre-order traversal to assign flat indices
216
- const nodes = [];
217
- const stack = [ root ];
218
- while ( stack.length > 0 ) {
530
+ this._sortRange( aabb, order, start, end, axis );
219
531
 
220
- const n = stack.pop();
221
- n._flatIndex = nodes.length;
222
- nodes.push( n );
223
- if ( n.rightChild ) stack.push( n.rightChild );
224
- if ( n.leftChild ) stack.push( n.leftChild );
532
+ // suffix[i] = bounds of order[start+i .. end-1]
533
+ let sMinX = Infinity, sMinY = Infinity, sMinZ = Infinity;
534
+ let sMaxX = - Infinity, sMaxY = - Infinity, sMaxZ = - Infinity;
535
+ for ( let i = n - 1; i >= 1; i -- ) {
225
536
 
226
- }
537
+ const a = order[ start + i ] * 6;
538
+ if ( aabb[ a ] < sMinX ) sMinX = aabb[ a ];
539
+ if ( aabb[ a + 1 ] < sMinY ) sMinY = aabb[ a + 1 ];
540
+ if ( aabb[ a + 2 ] < sMinZ ) sMinZ = aabb[ a + 2 ];
541
+ if ( aabb[ a + 3 ] > sMaxX ) sMaxX = aabb[ a + 3 ];
542
+ if ( aabb[ a + 4 ] > sMaxY ) sMaxY = aabb[ a + 4 ];
543
+ if ( aabb[ a + 5 ] > sMaxZ ) sMaxZ = aabb[ a + 5 ];
227
544
 
228
- // Reuse cached buffer (grow-only to avoid per-refit allocation)
229
- const requiredSize = nodes.length * FLOATS_PER_NODE;
230
- if ( requiredSize > this._flatBufferCapacity ) {
545
+ const o = i * 6;
546
+ suffix[ o ] = sMinX; suffix[ o + 1 ] = sMinY; suffix[ o + 2 ] = sMinZ;
547
+ suffix[ o + 3 ] = sMaxX; suffix[ o + 4 ] = sMaxY; suffix[ o + 5 ] = sMaxZ;
231
548
 
232
- this._flatBuffer = new Float32Array( requiredSize );
233
- this._flatBufferCapacity = requiredSize;
549
+ }
550
+
551
+ let lMinX = Infinity, lMinY = Infinity, lMinZ = Infinity;
552
+ let lMaxX = - Infinity, lMaxY = - Infinity, lMaxZ = - Infinity;
553
+
554
+ for ( let i = 1; i < n; i ++ ) {
555
+
556
+ const a = order[ start + i - 1 ] * 6;
557
+ if ( aabb[ a ] < lMinX ) lMinX = aabb[ a ];
558
+ if ( aabb[ a + 1 ] < lMinY ) lMinY = aabb[ a + 1 ];
559
+ if ( aabb[ a + 2 ] < lMinZ ) lMinZ = aabb[ a + 2 ];
560
+ if ( aabb[ a + 3 ] > lMaxX ) lMaxX = aabb[ a + 3 ];
561
+ if ( aabb[ a + 4 ] > lMaxY ) lMaxY = aabb[ a + 4 ];
562
+ if ( aabb[ a + 5 ] > lMaxZ ) lMaxZ = aabb[ a + 5 ];
563
+
564
+ const o = i * 6;
565
+ const leftSA = this._surfaceArea( lMinX, lMinY, lMinZ, lMaxX, lMaxY, lMaxZ );
566
+ const rightSA = this._surfaceArea(
567
+ suffix[ o ], suffix[ o + 1 ], suffix[ o + 2 ],
568
+ suffix[ o + 3 ], suffix[ o + 4 ], suffix[ o + 5 ]
569
+ );
570
+
571
+ const cost = 1.0 + ( leftSA * i + rightSA * ( n - i ) ) / parentSA;
572
+ if ( cost < bestCost ) {
573
+
574
+ bestCost = cost; bestAxis = axis; bestSplit = i;
575
+
576
+ }
577
+
578
+ }
234
579
 
235
580
  }
236
581
 
237
- const data = this._flatBuffer;
238
- data.fill( 0, 0, requiredSize ); // Clear stale data
582
+ if ( bestAxis < 0 || bestSplit <= 0 ) return start;
239
583
 
240
- for ( let i = 0; i < nodes.length; i ++ ) {
584
+ // The last axis swept is 2; re-sort only if the winner was a different one.
585
+ if ( bestAxis !== 2 ) this._sortRange( aabb, order, start, end, bestAxis );
586
+ return start + bestSplit;
241
587
 
242
- const n = nodes[ i ];
243
- const o = i * FLOATS_PER_NODE;
588
+ }
244
589
 
245
- if ( n.leftChild ) {
590
+ /** Widest centroid extent of order[start..end). @private */
591
+ _widestCentroidAxis( aabb, order, start, end ) {
246
592
 
247
- // Inner node — same format as BVH inner nodes
248
- const left = n.leftChild;
249
- const right = n.rightChild;
593
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
594
+ let maxX = - Infinity, maxY = - Infinity, maxZ = - Infinity;
595
+ for ( let i = start; i < end; i ++ ) {
250
596
 
251
- data[ o ] = left.minX;
252
- data[ o + 1 ] = left.minY;
253
- data[ o + 2 ] = left.minZ;
254
- data[ o + 3 ] = left._flatIndex;
597
+ const a = order[ i ] * 6;
598
+ const cx = ( aabb[ a ] + aabb[ a + 3 ] ) * 0.5;
599
+ const cy = ( aabb[ a + 1 ] + aabb[ a + 4 ] ) * 0.5;
600
+ const cz = ( aabb[ a + 2 ] + aabb[ a + 5 ] ) * 0.5;
601
+ if ( cx < minX ) minX = cx; if ( cx > maxX ) maxX = cx;
602
+ if ( cy < minY ) minY = cy; if ( cy > maxY ) maxY = cy;
603
+ if ( cz < minZ ) minZ = cz; if ( cz > maxZ ) maxZ = cz;
255
604
 
256
- data[ o + 4 ] = left.maxX;
257
- data[ o + 5 ] = left.maxY;
258
- data[ o + 6 ] = left.maxZ;
259
- data[ o + 7 ] = right._flatIndex;
605
+ }
606
+
607
+ const dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ;
608
+ return dy > dx && dy > dz ? 1 : dz > dx ? 2 : 0;
260
609
 
261
- data[ o + 8 ] = right.minX;
262
- data[ o + 9 ] = right.minY;
263
- data[ o + 10 ] = right.minZ;
610
+ }
264
611
 
265
- data[ o + 12 ] = right.maxX;
266
- data[ o + 13 ] = right.maxY;
267
- data[ o + 14 ] = right.maxZ;
612
+ /** Insertion sort by centroid along `axis`; allocation-free, for a handful of entries. @private */
613
+ _insertionSortRange( aabb, order, start, end, axis ) {
268
614
 
269
- } else {
615
+ const lo = axis, hi = 3 + axis;
616
+ for ( let i = start + 1; i < end; i ++ ) {
270
617
 
271
- // Leaf node — BLAS pointer
272
- const entry = entries[ n.entryIndex ];
273
- data[ o ] = entry.blasOffset; // Absolute node index of BLAS root in combined buffer
274
- data[ o + 1 ] = n.entryIndex; // meshIndex (kept for debug/ID — traversal uses slot [2])
275
- data[ o + 2 ] = entry.visible === false ? 0.0 : 1.0; // Per-mesh visibility (packed — frees a binding)
276
- data[ o + 3 ] = BVH_LEAF_MARKERS.BLAS_POINTER_LEAF; // -2 marker
618
+ const v = order[ i ];
619
+ const key = aabb[ v * 6 + lo ] + aabb[ v * 6 + hi ];
620
+ let j = i - 1;
621
+ while ( j >= start && ( aabb[ order[ j ] * 6 + lo ] + aabb[ order[ j ] * 6 + hi ] ) > key ) {
277
622
 
278
- entry.tlasLeafIndex = i;
623
+ order[ j + 1 ] = order[ j ];
624
+ j --;
279
625
 
280
626
  }
281
627
 
282
- }
628
+ order[ j + 1 ] = v;
283
629
 
284
- return data.subarray( 0, requiredSize );
630
+ }
285
631
 
286
632
  }
287
633
 
288
- // ── Helpers ──
634
+ /** Sort order[start..end) by centroid along `axis`, in place. @private */
635
+ _sortRange( aabb, order, start, end, axis ) {
289
636
 
290
- _centroid( aabb, axis ) {
637
+ const n = end - start;
638
+ const scratch = this._sortScratch;
639
+ scratch.length = n;
640
+ for ( let i = 0; i < n; i ++ ) scratch[ i ] = order[ start + i ];
291
641
 
292
- if ( axis === 0 ) return ( aabb.minX + aabb.maxX ) * 0.5;
293
- if ( axis === 1 ) return ( aabb.minY + aabb.maxY ) * 0.5;
294
- return ( aabb.minZ + aabb.maxZ ) * 0.5;
642
+ const lo = axis, hi = 3 + axis;
643
+ scratch.sort( ( a, b ) =>
644
+ ( aabb[ a * 6 + lo ] + aabb[ a * 6 + hi ] ) - ( aabb[ b * 6 + lo ] + aabb[ b * 6 + hi ] ) );
645
+ for ( let i = 0; i < n; i ++ ) order[ start + i ] = scratch[ i ];
295
646
 
296
647
  }
297
648
 
298
- _surfaceArea( minX, minY, minZ, maxX, maxY, maxZ ) {
649
+ /** Grow-only scratch for the split sweep; one buffer serves every node. @private */
650
+ _suffixBuffer( n ) {
299
651
 
300
- const dx = maxX - minX;
301
- const dy = maxY - minY;
302
- const dz = maxZ - minZ;
303
- return 2.0 * ( dx * dy + dy * dz + dz * dx );
652
+ const need = n * 6;
653
+ if ( ! this._suffix || this._suffix.length < need ) this._suffix = new Float64Array( need );
654
+ return this._suffix;
304
655
 
305
656
  }
306
657
 
307
- _computeGroupAABB( entries, sorted, from, to ) {
658
+ /** Writes 6 doubles (min xyz, max xyz) into `out` at `off`. @private */
659
+ _rangeBounds( aabb, order, start, end, out, off ) {
308
660
 
309
661
  let minX = Infinity, minY = Infinity, minZ = Infinity;
310
662
  let maxX = - Infinity, maxY = - Infinity, maxZ = - Infinity;
311
663
 
312
- for ( let i = from; i < to; i ++ ) {
664
+ for ( let i = start; i < end; i ++ ) {
313
665
 
314
- const aabb = entries[ sorted[ i ] ].worldAABB;
315
- if ( aabb.minX < minX ) minX = aabb.minX;
316
- if ( aabb.minY < minY ) minY = aabb.minY;
317
- if ( aabb.minZ < minZ ) minZ = aabb.minZ;
318
- if ( aabb.maxX > maxX ) maxX = aabb.maxX;
319
- if ( aabb.maxY > maxY ) maxY = aabb.maxY;
320
- if ( aabb.maxZ > maxZ ) maxZ = aabb.maxZ;
666
+ const a = order[ i ] * 6;
667
+ if ( aabb[ a ] < minX ) minX = aabb[ a ];
668
+ if ( aabb[ a + 1 ] < minY ) minY = aabb[ a + 1 ];
669
+ if ( aabb[ a + 2 ] < minZ ) minZ = aabb[ a + 2 ];
670
+ if ( aabb[ a + 3 ] > maxX ) maxX = aabb[ a + 3 ];
671
+ if ( aabb[ a + 4 ] > maxY ) maxY = aabb[ a + 4 ];
672
+ if ( aabb[ a + 5 ] > maxZ ) maxZ = aabb[ a + 5 ];
321
673
 
322
674
  }
323
675
 
324
- return { minX, minY, minZ, maxX, maxY, maxZ };
676
+ out[ off ] = minX; out[ off + 1 ] = minY; out[ off + 2 ] = minZ;
677
+ out[ off + 3 ] = maxX; out[ off + 4 ] = maxY; out[ off + 5 ] = maxZ;
325
678
 
326
679
  }
327
680
 
328
- _countNodes( root ) {
329
-
330
- if ( ! root ) return 0;
331
-
332
- let count = 0;
333
- const stack = [ root ];
334
- while ( stack.length > 0 ) {
335
-
336
- const node = stack.pop();
337
- count ++;
338
- if ( node.leftChild ) stack.push( node.leftChild );
339
- if ( node.rightChild ) stack.push( node.rightChild );
681
+ // ── Helpers ──
340
682
 
341
- }
683
+ _surfaceArea( minX, minY, minZ, maxX, maxY, maxZ ) {
342
684
 
343
- return count;
685
+ const dx = maxX - minX;
686
+ const dy = maxY - minY;
687
+ const dz = maxZ - minZ;
688
+ return 2.0 * ( dx * dy + dy * dz + dz * dx );
344
689
 
345
690
  }
346
691