rayzee 7.31.1 → 8.1.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 (53) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6728 -4735
  3. package/dist/rayzee.umd.js +121 -73
  4. package/package.json +2 -2
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +5 -0
  7. package/src/PathTracerApp.js +151 -25
  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 +580 -93
  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/Processor/utils.js +8 -20
  35. package/src/Stages/NormalDepth.js +6 -2
  36. package/src/Stages/PathTracer.js +5 -5
  37. package/src/Stages/PathTracerStage.js +163 -41
  38. package/src/TSL/BVHTraversal.js +208 -90
  39. package/src/TSL/Common.js +135 -2
  40. package/src/TSL/Displacement.js +21 -11
  41. package/src/TSL/EmissiveSampling.js +49 -25
  42. package/src/TSL/ExtendKernel.js +3 -2
  43. package/src/TSL/LightBVHSampling.js +7 -4
  44. package/src/TSL/LightsDirect.js +30 -29
  45. package/src/TSL/ShadeKernel.js +8 -6
  46. package/src/TSL/SortGlobalKernels.js +3 -4
  47. package/src/TSL/Struct.js +3 -0
  48. package/src/TSL/TextureSampling.js +24 -9
  49. package/src/TSL/patches.js +149 -84
  50. package/src/index.js +3 -0
  51. package/src/managers/AnimationManager.js +86 -76
  52. package/src/managers/MaterialDataManager.js +29 -23
  53. package/src/managers/TransformManager.js +9 -192
@@ -3,18 +3,29 @@ import { BVHBuilder } from './BVHBuilder.js';
3
3
  import { BVHRefitter } from './BVHRefitter.js';
4
4
  import { buildBVHParallel, shouldUseParallelBuild } from './ParallelBVHBuilder.js';
5
5
  import { TLASBuilder } from './TLASBuilder.js';
6
- import { InstanceTable } from './InstanceTable.js';
6
+ import { InstanceTable, isIdentity, multiplyAffine } from './InstanceTable.js';
7
+ import { ChunkedRecords, SHARED_MEMORY_AVAILABLE, setChunkObserver } from './ChunkedRecords.js';
8
+ import {
9
+ MemoryLedger, estimateSceneBytes, probeAddressSpace,
10
+ PREFLIGHT_MIN_BYTES, PREFLIGHT_SAFETY, SAFE_SCENE_BYTES, MAX_SCENE_BYTES,
11
+ } from './HostMemory.js';
7
12
  import { TextureCreator } from './TextureCreator.js';
8
- import { GeometryExtractor } from './GeometryExtractor.js';
13
+ import { GeometryExtractor, geometryBytesOf } from './GeometryExtractor.js';
9
14
  import { EmissiveTriangleBuilder } from './EmissiveTriangleBuilder.js';
10
15
  import { updateLoading } from '../Processor/utils.js';
11
16
  import { BuildTimer } from './BuildTimer.js';
12
17
  import { createLogger, fmt, workerLogLevel } from '../utils/Logger.js';
13
18
  import { SRGBColorSpace } from 'three';
14
- import { TRIANGLE_DATA_LAYOUT, TEXTURE_CONSTANTS, getTextureBucketId, packTextureIndex, planTextureBuckets } from '../EngineDefaults.js';
19
+ import {
20
+ TRIANGLE_DATA_LAYOUT, TEXTURE_CONSTANTS, getTextureBucketId, packTextureIndex, planTextureBuckets,
21
+ packNormalOct, BVH_LEAF_MARKERS, assertBVHIndexFits, bvhIndexView, TLAS_PLACEMENT_MASK } from '../EngineDefaults.js';
15
22
  import { ISSUE_CODES } from '../EngineIssues.js';
16
23
  import BVHWorker from './Workers/BVHWorker.js?worker&inline';
17
24
  import BVHRefitWorker from './Workers/BVHRefitWorker.js?worker&inline';
25
+ import TLASWorker from './Workers/TLASWorker.js?worker&inline';
26
+
27
+ // Under this the TLAS build is a few ms; the worker round trip would cost more than it saves.
28
+ const TLAS_WORKER_MIN_ENTRIES = 50_000;
18
29
 
19
30
  const log = createLogger( 'scene' );
20
31
 
@@ -35,6 +46,9 @@ export class SceneProcessor {
35
46
  * @param {string} [options.textureQuality='adaptive'] - Texture quality mode
36
47
  * @param {boolean} [options.enableTextureCache=true] - Enable texture caching
37
48
  * @param {import('../EngineIssues.js').IssueLog} [options.issues] - forwarded to TextureCreator
49
+ * @param {number} [options.maxSceneBytes] - refuse a scene estimated above this many CPU
50
+ * bytes. Defaults to {@link MAX_SCENE_BYTES}; past it the renderer process dies rather
51
+ * than throwing, so there is nothing to catch. Raise it to try a bigger scene anyway.
38
52
  */
39
53
  constructor( options = {} ) {
40
54
 
@@ -49,6 +63,7 @@ export class SceneProcessor {
49
63
  maxTextureSize: TEXTURE_CONSTANTS.DEFAULT_MAX_TEXTURE_SIZE, // longest-edge cap for material textures
50
64
  enableTextureCache: true,
51
65
  maxConcurrentTextureTasks: Math.min( navigator.hardwareConcurrency || 4, 6 ),
66
+ maxSceneBytes: MAX_SCENE_BYTES,
52
67
  // Treelet optimization configuration
53
68
  // Keep: `_buildBVH` sends `enabled: value !== false`, so undefined re-enables treelets.
54
69
  enableTreeletOptimization: false,
@@ -61,7 +76,8 @@ export class SceneProcessor {
61
76
  };
62
77
 
63
78
  // Initialize geometry data containers
64
- this.triangleData = null; // Efficient format (Float32Array)
79
+ this.triangleData = null; // uint lanes; this.triangleFloats views the same memory
80
+ this.triangleFloats = null;
65
81
  this.triangleCount = 0; // Number of triangles
66
82
  this.materials = [];
67
83
  this.maps = [];
@@ -87,12 +103,14 @@ export class SceneProcessor {
87
103
  this.bvhRoot = null;
88
104
 
89
105
  // Raw data for storage buffers
106
+ this.bvh = null;
90
107
  this.bvhData = null;
108
+ this.bvhIndexChunks = null;
109
+ this.bvhIndex = null;
91
110
  this.materialData = null;
92
111
 
93
112
  // Two-level BVH (TLAS/BLAS) support
94
113
  this.instanceTable = null; // Per-mesh BLAS metadata
95
- this.originalToBvhMap = null; // Uint32Array: original tri index → BVH-order index (global, for legacy compat)
96
114
  this._refitWorker = null;
97
115
  this._refitSharedBuffers = null; // SharedArrayBuffer refs for zero-copy refit
98
116
  this._rebuildGeneration = 0; // Monotonic counter to discard stale background rebuilds
@@ -118,11 +136,18 @@ export class SceneProcessor {
118
136
  this.isProcessing = false;
119
137
  this.processingStage = null;
120
138
 
139
+ // What the build spent, by phase, and what the preflight expected it to spend.
140
+ this.memory = new MemoryLedger();
141
+ this.memoryPreflight = null;
142
+
121
143
  // Performance tracking
122
144
  this.performanceMetrics = {
123
145
  textureCreationTime: 0,
124
146
  geometryExtractionTime: 0,
125
147
  bvhBuildTime: 0,
148
+ blasBuildTime: 0,
149
+ tlasBuildTime: 0,
150
+ bvhAssembleTime: 0,
126
151
  totalProcessingTime: 0
127
152
  };
128
153
 
@@ -170,6 +195,8 @@ export class SceneProcessor {
170
195
 
171
196
  // Create TLAS builder for two-level BVH
172
197
  this.tlasBuilder = new TLASBuilder();
198
+ this._tlasWorker = null;
199
+ this._tlasWorkerFailed = false;
173
200
 
174
201
  }
175
202
 
@@ -185,6 +212,191 @@ export class SceneProcessor {
185
212
 
186
213
  }
187
214
 
215
+ /**
216
+ * Price the scene before the build starts spending, and say so if it is over what a healthy
217
+ * session can place.
218
+ *
219
+ * Estimate only — no allocation. Probing the whole scene up front was measurably worse than
220
+ * not checking: the probe's peak lands on top of the parse's, and a 9 GB probe taken while
221
+ * the parser still held 3.6 GB doubled a 40M load from 135 s to 268 s. The allocation test
222
+ * belongs at the step that actually runs out, which is
223
+ * {@link SceneProcessor#_checkAssemblyHeadroom}.
224
+ *
225
+ * The geometry figure reads before `_compressAttributes` halves normals and colours, so it
226
+ * runs ~7% high on a typical scene. That direction is the safe one.
227
+ * @private
228
+ */
229
+ _preflightMemory( object ) {
230
+
231
+ const maxBytes = this.config.maxSceneBytes ?? MAX_SCENE_BYTES;
232
+ const survey = this.geometryExtractor.surveyScene( object );
233
+ const estimate = estimateSceneBytes( survey );
234
+ const report = {
235
+ ...survey, estimate,
236
+ safeBytes: SAFE_SCENE_BYTES, maxBytes,
237
+ fits: estimate.total <= SAFE_SCENE_BYTES,
238
+ };
239
+ this.memoryPreflight = report;
240
+
241
+ if ( estimate.total < PREFLIGHT_MIN_BYTES ) return report;
242
+
243
+ log.debug( fmt.list( [
244
+ `memory preflight: ${fmt.mb( estimate.total )} estimated`,
245
+ `triangles ${fmt.mb( estimate.triangles )} · bvh ${fmt.mb( estimate.bvh )} · geometry ${fmt.mb( estimate.geometry )}`,
246
+ `${fmt.n( survey.triangles )} tris · ${fmt.n( survey.placements )} placements`,
247
+ ] ) );
248
+
249
+ // Past the hard line the renderer process dies rather than throwing, so there is nothing
250
+ // to catch and nothing to degrade to. Refusing with a reason is the only useful answer.
251
+ if ( estimate.total > maxBytes ) {
252
+
253
+ const message = `Scene needs about ${fmt.mb( estimate.total )} of CPU memory, past the ${fmt.mb( maxBytes )} `
254
+ + 'a browser tab can hold — loading it would crash the tab rather than fail. '
255
+ + `Reduce the scene (it has ${fmt.n( survey.triangles )} triangles), or raise maxSceneBytes to try anyway.`;
256
+
257
+ this.config.issues?.record(
258
+ ISSUE_CODES.SCENE_MEMORY_BUDGET, message, { ...survey, estimate, maxBytes }
259
+ );
260
+ throw new Error( message );
261
+
262
+ }
263
+
264
+ if ( ! report.fits ) {
265
+
266
+ this.config.issues?.warn(
267
+ ISSUE_CODES.SCENE_MEMORY_BUDGET,
268
+ `Scene needs about ${fmt.mb( estimate.total )} of CPU memory, above the ${fmt.mb( SAFE_SCENE_BYTES )} a fresh browser `
269
+ + 'session can usually place. It may still load, but a long-running session will probably fail during BVH assembly; '
270
+ + 'restarting the browser recovers the address space.',
271
+ { ...survey, estimate, safeBytes: SAFE_SCENE_BYTES }
272
+ );
273
+
274
+ }
275
+
276
+ return report;
277
+
278
+ }
279
+
280
+ /**
281
+ * Test the one allocation that has failed every time: the combined BVH.
282
+ *
283
+ * By this point the archive and the parser's scratch are gone and the BLASes are about to be
284
+ * handed over chunk by chunk, so the probe is small (the BVH's own size plus headroom, ~2 GB
285
+ * at 40M rather than the whole scene's 9 GB) and it is asking the question at the moment the
286
+ * answer matters. A failure here is still only a warning — the build is allowed to try.
287
+ * @private
288
+ */
289
+ _checkAssemblyHeadroom( totalNodes ) {
290
+
291
+ const need = totalNodes * 64;
292
+ if ( need < PREFLIGHT_MIN_BYTES ) return true;
293
+
294
+ // Same flavour the store uses: shared and non-shared buffers need not come from one pool.
295
+ const probe = probeAddressSpace( Math.ceil( need * PREFLIGHT_SAFETY ), { shared: SHARED_MEMORY_AVAILABLE } );
296
+ if ( this.memoryPreflight ) this.memoryPreflight.assemblyProbed = probe.placed;
297
+
298
+ if ( probe.exhausted ) {
299
+
300
+ this.config.issues?.warn(
301
+ ISSUE_CODES.SCENE_MEMORY_BUDGET,
302
+ `BVH assembly needs ${fmt.mb( need )} but only ${fmt.mb( probe.placed )} could be allocated. `
303
+ + 'Restarting the browser usually recovers the address space.',
304
+ { totalNodes, need, placeable: probe.placed }
305
+ );
306
+
307
+ }
308
+
309
+ return ! probe.exhausted;
310
+
311
+ }
312
+
313
+ /**
314
+ * Byte sizes of the CPU structures alive right now. Anything already released is absent,
315
+ * which is the point: this is what the peak is made of, not what was ever allocated.
316
+ * @private
317
+ */
318
+ _liveMemoryParts() {
319
+
320
+ const table = this.instanceTable;
321
+ const parts = {
322
+ triangles: this.triangles?.byteLength ?? 0,
323
+ bvh: this.bvh?.byteLength ?? 0,
324
+ geometry: this._geometryBytes ?? this.memoryPreflight?.geometryBytes ?? 0,
325
+ placements: table?.world?.byteLength ?? 0,
326
+ blasScratch: 0,
327
+ orderMaps: 0,
328
+ };
329
+
330
+ if ( table ) {
331
+
332
+ for ( const blas of table.blasData.values() ) parts.blasScratch += blas?.byteLength ?? 0;
333
+ for ( const m of table.bvhToOriginal.values() ) parts.orderMaps += m?.byteLength ?? 0;
334
+ for ( const m of table.originalToBvhMap.values() ) parts.orderMaps += m?.byteLength ?? 0;
335
+
336
+ }
337
+
338
+ return parts;
339
+
340
+ }
341
+
342
+ /**
343
+ * World-space bounds of everything in the BVH, as `{ min: [x,y,z], max: [x,y,z] }`.
344
+ *
345
+ * ⚠️ Node 0 is not "the root's box". A BVH inner node stores its two CHILDREN's boxes —
346
+ * `[Amin, Aidx, Amax, _, Bmin, Bidx, Bmax, _]` — so the first six floats are one subtree,
347
+ * not the scene. Reading them as the scene box is wrong and looks plausible: the two halves
348
+ * legitimately rebalance between a build and a refit, so the number moves while the actual
349
+ * bounds are unchanged. Verified against the union of all 3.7M placement AABBs at 40M.
350
+ *
351
+ * @returns {?{min: number[], max: number[]}} null before a BVH is built
352
+ */
353
+ sceneBounds() {
354
+
355
+ if ( ! this.bvh || this.bvh.recordCount === 0 ) return null;
356
+
357
+ const chunk = this.bvh.chunkFor( 0 );
358
+ const base = this.bvh.baseOf( 0 );
359
+ const min = [], max = [];
360
+
361
+ for ( let k = 0; k < 3; k ++ ) {
362
+
363
+ min.push( Math.min( chunk[ base + k ], chunk[ base + 8 + k ] ) );
364
+ max.push( Math.max( chunk[ base + 4 + k ], chunk[ base + 12 + k ] ) );
365
+
366
+ }
367
+
368
+ return { min, max };
369
+
370
+ }
371
+
372
+ /** @private */
373
+ _sampleMemory( label ) {
374
+
375
+ this.memory.sample( label, this._liveMemoryParts() );
376
+
377
+ }
378
+
379
+ /** One line on what the build actually cost, and whether the preflight called it right. @private */
380
+ _logMemoryReport() {
381
+
382
+ const { allocatedBytes, peakLiveBytes, byPhase } = this.memory;
383
+ if ( allocatedBytes === 0 ) return;
384
+
385
+ const phases = Object.keys( byPhase )
386
+ .filter( name => byPhase[ name ].allocated > 0 )
387
+ .map( name => `${name} ${fmt.mb( byPhase[ name ].allocated )}` );
388
+
389
+ const predicted = this.memoryPreflight?.estimate?.total;
390
+
391
+ log.debug( fmt.list( [
392
+ `memory: peak live ${fmt.mb( peakLiveBytes )}`,
393
+ `allocated ${fmt.mb( allocatedBytes )}`,
394
+ predicted ? `predicted ${fmt.mb( predicted )}` : null,
395
+ phases.join( ' · ' ),
396
+ ] ) );
397
+
398
+ }
399
+
188
400
  /**
189
401
  * Build the BVH from a 3D object/scene
190
402
  * @param {Object3D} object - Three.js object to process
@@ -200,6 +412,10 @@ export class SceneProcessor {
200
412
 
201
413
  this.isProcessing = true;
202
414
  this.processingStage = 'init';
415
+ this.memory.reset();
416
+ this._geometryBytes = 0;
417
+ // Module-global by design: one build at a time, and `isProcessing` above enforces it.
418
+ setChunkObserver( bytes => this.memory.alloc( bytes ) );
203
419
 
204
420
  const timer = new BuildTimer( object.name ?? '', { namespace: 'scene' } );
205
421
 
@@ -209,12 +425,21 @@ export class SceneProcessor {
209
425
  this._reset();
210
426
  this._log( 'Starting scene processing' );
211
427
 
428
+ // Step 0: will this scene fit in the address space this process has left?
429
+ this.memory.mark( 'preflight' );
430
+ this._preflightMemory( object );
431
+
212
432
  // Step 1: Extract geometry (0-20%)
213
433
  this.processingStage = 'extraction';
434
+ this.memory.mark( 'extraction' );
214
435
  timer.start( 'Geometry extraction' );
215
436
  await this._extractGeometry( object );
216
437
  timer.end( 'Geometry extraction' );
217
438
  this.performanceMetrics.geometryExtractionTime = timer.getDuration( 'Geometry extraction' );
439
+ // The preflight figure was taken before `_compressAttributes` halved normals and
440
+ // colours; every sample from here on should use what the mirror actually holds.
441
+ this._geometryBytes = geometryBytesOf( object );
442
+ this._sampleMemory( 'after extraction' );
218
443
 
219
444
  // Step 2: BVH + textures in parallel (20-95%)
220
445
  // Texture creation only needs GeometryExtractor output (materials + texture maps)
@@ -257,9 +482,9 @@ export class SceneProcessor {
257
482
  // Only fall back to main-thread flattening if bvhData wasn't produced.
258
483
  this.processingStage = 'finalize';
259
484
  timer.start( 'BVH data packing' );
260
- if ( this.bvhRoot && ! this.bvhData ) {
485
+ if ( this.bvhRoot && ! this.bvh ) {
261
486
 
262
- this.bvhData = this.textureCreator.createBVHRawData( this.bvhRoot );
487
+ this._setBVHData( this.textureCreator.createBVHRawData( this.bvhRoot ) );
263
488
 
264
489
  }
265
490
 
@@ -283,12 +508,16 @@ export class SceneProcessor {
283
508
  log.error( 'processing failed:', error );
284
509
  updateLoading( {
285
510
  status: `Error: ${error.message}`,
511
+ failed: true,
286
512
  progress: 100
287
513
  } );
288
514
  throw error;
289
515
 
290
516
  } finally {
291
517
 
518
+ setChunkObserver( null );
519
+ this._sampleMemory( `at ${this.processingStage}` );
520
+ this._logMemoryReport();
292
521
  this.isProcessing = false;
293
522
 
294
523
  }
@@ -319,10 +548,17 @@ export class SceneProcessor {
319
548
  // Extract geometry data
320
549
  const extractedData = this.geometryExtractor.extract( object );
321
550
 
322
- this.triangleData = extractedData.triangleData;
323
- this.triangleCount = extractedData.triangleCount;
551
+ this._setTriangleData( extractedData.triangleData );
552
+ this.triangleCount = assertBVHIndexFits( extractedData.triangleCount, 'triangle count' );
553
+ // Callers build refit buffers by walking meshes, which counts a shared geometry
554
+ // once per placement; storage counts it once.
555
+ this.expandedTriangleCount = extractedData.expandedTriangleCount ?? extractedData.triangleCount;
556
+ this.instanceSource = extractedData.instanceSource || null;
557
+ this.instanceMatrices = extractedData.instanceMatrices || null;
558
+ this.instanceCount = extractedData.instanceCount || 0;
559
+ this.bakeInverse = extractedData.bakeInverse || null;
324
560
 
325
- this._log( `Using Float32Array format: ${this.triangleCount} triangles, ${( this.triangleData.byteLength / ( 1024 * 1024 ) ).toFixed( 2 )}MB` );
561
+ this._log( `Using Float32Array format: ${this.triangleCount} triangles, ${( this.triangles.byteLength / ( 1024 * 1024 ) ).toFixed( 2 )}MB` );
326
562
 
327
563
  // Store other extracted data
328
564
  this.materials = extractedData.materials;
@@ -365,6 +601,7 @@ export class SceneProcessor {
365
601
  log.error( 'geometry extraction failed:', error );
366
602
  updateLoading( {
367
603
  status: `Extraction error: ${error.message}`,
604
+ failed: true,
368
605
  progress: 25
369
606
  } );
370
607
  throw error;
@@ -391,11 +628,11 @@ export class SceneProcessor {
391
628
  }
392
629
 
393
630
  this._log( 'Building two-level BVH (TLAS/BLAS)' );
631
+ this.memory.mark( 'blas' );
394
632
  const startTime = performance.now();
395
633
 
396
634
  try {
397
635
 
398
- const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
399
636
  const ranges = this.meshTriangleRanges;
400
637
 
401
638
  if ( ! ranges || ranges.length === 0 ) {
@@ -406,9 +643,18 @@ export class SceneProcessor {
406
643
 
407
644
  // ── Step 1: Build per-mesh BLASes ──
408
645
 
646
+ // One entry per PLACEMENT, not per object: an InstancedMesh contributes a matrix per
647
+ // instance while the scene graph still holds one object. The extractor hands over the
648
+ // transforms as one contiguous pool, which the table adopts rather than copying.
649
+ const pooled = this.instanceCount > 0;
650
+ const meshCount = pooled ? this.instanceCount : ranges.length;
651
+ const instSource = this.instanceSource;
652
+ const worldPool = pooled ? this.instanceMatrices : null;
653
+ const sourceOf = m => ( pooled ? instSource[ m ] : m );
654
+
409
655
  this.instanceTable = new InstanceTable();
410
- this.instanceTable.allocate( ranges.length );
411
- const meshCount = ranges.length;
656
+ this.instanceTable.allocate( meshCount, ranges.length, worldPool, pooled ? instSource : null );
657
+ this.instanceTable.tplBakeInverse = this.bakeInverse;
412
658
 
413
659
  const originalTreeletEnabled = this.config.enableTreeletOptimization;
414
660
  const LARGE_MESH_THRESHOLD = 200000;
@@ -417,10 +663,25 @@ export class SceneProcessor {
417
663
  const poolTasks = [];
418
664
  const parallelTasks = [];
419
665
 
666
+ // Placements that reuse another's triangles reuse its BLAS too — the whole point of
667
+ // storing geometry in object space. Only the first placement of a range is built.
668
+ const aliasOf = new Map();
669
+ const ownerOfRange = new Map();
670
+
420
671
  for ( let m = 0; m < meshCount; m ++ ) {
421
672
 
422
- const range = ranges[ m ];
423
- if ( range.count === 0 ) continue;
673
+ const range = ranges[ sourceOf( m ) ];
674
+ if ( ! range || range.count === 0 ) continue;
675
+
676
+ const owner = ownerOfRange.get( range.start );
677
+ if ( owner !== undefined ) {
678
+
679
+ aliasOf.set( m, owner );
680
+ continue;
681
+
682
+ }
683
+
684
+ ownerOfRange.set( range.start, m );
424
685
 
425
686
  if ( range.count >= LARGE_MESH_THRESHOLD && shouldUseParallelBuild( range.count ) ) {
426
687
 
@@ -459,52 +720,58 @@ export class SceneProcessor {
459
720
  };
460
721
 
461
722
  const totalTasks = poolTasks.length + parallelTasks.length;
723
+ let doneTasks = 0;
724
+ const reportBLASProgress = () => {
462
725
 
463
- // Build all meshes via bounded worker pool (main thread stays free)
464
- const poolPromise = this._buildBLASesWithPool( poolTasks, workerOpts, ( done ) => {
465
-
726
+ doneTasks ++;
466
727
  updateLoading( {
467
- status: `Building BLAS ${done + parallelTasks.length}/${totalTasks}...`,
468
- progress: 25 + Math.floor( ( done / totalTasks ) * 45 )
728
+ status: `Building BLAS ${doneTasks}/${totalTasks}...`,
729
+ progress: 25 + Math.floor( ( doneTasks / totalTasks ) * 45 )
469
730
  } );
470
731
 
471
- } );
732
+ };
472
733
 
473
- // Very large meshes use multi-worker parallel builder concurrently
474
- const parallelPromises = parallelTasks.map( ( { m, range } ) => {
734
+ // Build all meshes via bounded worker pool (main thread stays free)
735
+ const poolPromise = this._buildBLASesWithPool( poolTasks, workerOpts, reportBLASProgress );
475
736
 
476
- const meshTriData = this.triangleData.slice(
477
- range.start * FPT,
478
- ( range.start + range.count ) * FPT
479
- );
737
+ // One at a time: each build already spreads over every core and pins ~200 bytes per
738
+ // triangle of SharedArrayBuffer until it finishes. This is a memory bound, not a core one.
739
+ const parallelResults = [];
740
+ const parallelPromise = ( async () => {
480
741
 
481
- return buildBVHParallel( meshTriData, this.config.bvhDepth, null, {
482
- maxLeafSize: this.bvhBuilder.maxLeafSize,
483
- numBins: this.bvhBuilder.numBins,
484
- maxBins: this.bvhBuilder.maxBins,
485
- minBins: this.bvhBuilder.minBins,
486
- ...workerOpts
487
- } ).then( result => ( { m, range, result } ) );
742
+ for ( const { m, range } of parallelTasks ) {
488
743
 
489
- } );
744
+ const meshTriData = this.triangles.copyOf( range.start, range.count );
490
745
 
491
- // Await both paths concurrently
492
- const [ poolResults, parallelResults ] = await Promise.all( [
493
- poolPromise,
494
- Promise.all( parallelPromises )
495
- ] );
746
+ const result = await buildBVHParallel( meshTriData, this.config.bvhDepth, null, {
747
+ maxLeafSize: this.bvhBuilder.maxLeafSize,
748
+ numBins: this.bvhBuilder.numBins,
749
+ maxBins: this.bvhBuilder.maxBins,
750
+ minBins: this.bvhBuilder.minBins,
751
+ ...workerOpts
752
+ } );
496
753
 
497
- // Store all results, summing per-mesh split stats for one aggregate BVH line
498
- const blasStats = { sah: 0, objMed: 0, spatMed: 0, failed: 0, treeletsImproved: 0, treeletsProcessed: 0 };
754
+ if ( result.reorderedTriangles ) {
499
755
 
500
- for ( const { m, range, result } of [ ...poolResults, ...parallelResults ] ) {
756
+ this.triangles.setRecords( range.start, result.reorderedTriangles );
757
+ delete result.reorderedTriangles;
501
758
 
502
- if ( result.reorderedTriangles ) {
759
+ }
503
760
 
504
- this.triangleData.set( result.reorderedTriangles, range.start * FPT );
761
+ parallelResults.push( { m, range, result } );
762
+ reportBLASProgress();
505
763
 
506
764
  }
507
765
 
766
+ } )();
767
+
768
+ const [ poolResults ] = await Promise.all( [ poolPromise, parallelPromise ] );
769
+
770
+ // Store all results, summing per-mesh split stats for one aggregate BVH line
771
+ const blasStats = { sah: 0, objMed: 0, spatMed: 0, failed: 0, treeletsImproved: 0, treeletsProcessed: 0 };
772
+
773
+ for ( const { m, range, result } of [ ...poolResults, ...parallelResults ] ) {
774
+
508
775
  const st = result.splitStats;
509
776
  if ( st ) {
510
777
 
@@ -524,50 +791,94 @@ export class SceneProcessor {
524
791
  triCount: range.count,
525
792
  originalToBvhMap: result.originalToBvh || null,
526
793
  bvhData: result.bvhData,
794
+ matrixWorld: pooled ? worldPool : ( this.meshes?.[ m ]?.matrixWorld?.elements ?? null ),
795
+ matrixOffset: pooled ? m * 16 : 0,
796
+ expandedStart: range.expandedStart,
797
+ sourceMesh: sourceOf( m ),
527
798
  } );
528
799
 
529
800
  }
530
801
 
802
+ for ( const [ m, owner ] of aliasOf ) {
803
+
804
+ this.instanceTable.setAlias(
805
+ m, owner,
806
+ pooled ? worldPool : ( this.meshes?.[ m ]?.matrixWorld?.elements ?? null ),
807
+ ranges[ sourceOf( m ) ].expandedStart,
808
+ sourceOf( m ),
809
+ pooled ? m * 16 : 0
810
+ );
811
+
812
+ }
813
+
531
814
  updateLoading( { status: 'Built all BLASes', progress: 70 } );
532
815
 
816
+ this.performanceMetrics.blasBuildTime = performance.now() - startTime;
817
+
533
818
  // ── Step 2: Assemble BVH buffer ──
534
819
 
535
820
  updateLoading( { status: "Building TLAS...", progress: 72 } );
821
+ this._sampleMemory( 'BLASes built' );
822
+ this.memory.mark( 'tlas' );
823
+ const tlasStart = performance.now();
536
824
 
537
- const validEntries = this.instanceTable.entries.filter( e => e !== null );
825
+ const table = this.instanceTable;
538
826
 
539
827
  // Always build a TLAS — even for a single mesh — so the BLAS-pointer leaf
540
828
  // carries packed per-mesh visibility in its slot [2]. The 1-node TLAS
541
829
  // overhead (one extra leaf fetch per ray) is negligible and eliminates
542
830
  // a dedicated visibility storage buffer binding.
543
- this.instanceTable.computeAABBs( this.triangleData );
544
- const { root: tlasRoot, nodeCount: tlasNodeCount } = this.tlasBuilder.build( validEntries );
831
+ this.instanceTable.computeAABBs( this.triangles );
545
832
 
546
- this.instanceTable.assignOffsets( tlasNodeCount );
833
+ // Node count is exact up front (every leaf holds one entry), so BLAS offsets can be
834
+ // assigned before the build and the TLAS is written in a single pass.
835
+ this.instanceTable.assignOffsets( TLASBuilder.nodeCountFor( table.count ) );
547
836
  const totalNodes = this.instanceTable.totalNodeCount;
548
837
 
549
- const tlasData = this.tlasBuilder.flatten( tlasRoot, validEntries );
838
+ let tlasData = await this._buildTLAS( table );
839
+ this.performanceMetrics.tlasBuildTime = performance.now() - tlasStart;
550
840
 
551
841
  // Assemble combined buffer: [TLAS][BLAS_0][BLAS_1]...[BLAS_M]
552
- this.bvhData = new Float32Array( totalNodes * 16 );
553
- this.bvhData.set( tlasData );
554
-
555
- for ( const entry of validEntries ) {
556
-
557
- const destOffset = entry.blasOffset * 16;
558
- this.bvhData.set( entry.bvhData, destOffset );
559
- this._offsetBLASInPlace( destOffset, entry.bvhData.length / 16, entry.blasOffset, entry.triOffset );
842
+ this.memory.mark( 'assemble' );
843
+ const assembleStart = performance.now();
844
+ // Chunked for the same reason triangles are: 64 B a node puts the 2 GB array cap at
845
+ // 33.4M nodes, which a large instanced scene reaches well before the GPU's 4 GB.
846
+ // Lazy, and walked in template order so offsets ascend: chunks are allocated as the fill
847
+ // reaches them and each BLAS is released as it lands, never both fully resident.
848
+ this._checkAssemblyHeadroom( totalNodes );
849
+ this._setBVHData( ChunkedRecords.lazy( totalNodes, 16, Float32Array, undefined, SHARED_MEMORY_AVAILABLE ) );
850
+ this.bvh.setRecords( 0, tlasData );
851
+ // Hundreds of megabytes at millions of placements, and the chunks below need the room
852
+ // more than a later refit needs the cache.
853
+ tlasData = null;
854
+ this.tlasBuilder.releaseFlattenBuffer();
855
+
856
+ // Sample ~32 times across the fill rather than per template: this is where every
857
+ // allocation failure so far has landed, and the live total moves in both directions
858
+ // as chunks are taken and BLASes released.
859
+ const sampleEvery = Math.max( 1, Math.ceil( table.templateCount / 32 ) );
860
+
861
+ for ( let t = 0; t < table.templateCount; t ++ ) {
862
+
863
+ const blas = table.blasData.get( t ); // only owning templates hold one
864
+ if ( ! blas ) continue;
865
+
866
+ const blasOffset = table.tplBlasOffset[ t ];
867
+ this.bvh.setRecords( blasOffset, blas );
868
+ this._offsetBLASInPlace( blasOffset, blas.length / 16, blasOffset, table.tplTriOffset[ t ] );
869
+ table.blasData.delete( t );
870
+
871
+ if ( t % sampleEvery === 0 ) this._sampleMemory( `assembling ${t}/${table.templateCount}` );
560
872
 
561
873
  }
562
874
 
563
- this._buildGlobalOriginalToBvhMap();
564
-
565
- for ( const entry of validEntries ) {
875
+ this._setBVHData( this.bvh.materializeAll() );
566
876
 
567
- entry.originalToBvhMap = null;
568
- entry.bvhData = null;
877
+ this._buildBvhToOriginalMaps();
878
+ this.performanceMetrics.bvhAssembleTime = performance.now() - assembleStart;
569
879
 
570
- }
880
+ table.originalToBvhMap.clear();
881
+ table.blasData.clear();
571
882
 
572
883
  this.bvhRoot = true;
573
884
  this._disposeRefitWorker();
@@ -576,8 +887,8 @@ export class SceneProcessor {
576
887
  // One aggregate line for the whole two-level build; the workers' per-mesh
577
888
  // detail sits a level below at `debug`.
578
889
  log.debug( fmt.list( [
579
- `${fmt.n( validEntries.length )} BLASes + TLAS`,
580
- `${fmt.n( this.bvhData.length / 16 )} nodes`,
890
+ `${fmt.n( table.setCount )} BLASes + TLAS`,
891
+ `${fmt.n( this.bvh.recordCount )} nodes`,
581
892
  `SAH ${fmt.n( blasStats.sah )} · objMed ${blasStats.objMed} · spatMed ${blasStats.spatMed} · failed ${blasStats.failed}`,
582
893
  blasStats.treeletsProcessed ? `treelets ${blasStats.treeletsImproved}/${blasStats.treeletsProcessed} improved` : null,
583
894
  fmt.ms( duration ),
@@ -593,6 +904,7 @@ export class SceneProcessor {
593
904
  log.error( 'BVH build failed:', error );
594
905
  updateLoading( {
595
906
  status: `BVH error: ${error.message}`,
907
+ failed: true,
596
908
  progress: 75
597
909
  } );
598
910
  throw error;
@@ -605,20 +917,24 @@ export class SceneProcessor {
605
917
  * Adjust BLAS node indices in-place within the combined bvhData buffer.
606
918
  * @private
607
919
  */
608
- _offsetBLASInPlace( destFloat, nodeCount, nodeOffset, triOffset ) {
920
+ _offsetBLASInPlace( startNode, nodeCount, nodeOffset, triOffset ) {
921
+
922
+ const idx = this.bvhIndexChunks;
609
923
 
610
924
  for ( let i = 0; i < nodeCount; i ++ ) {
611
925
 
612
- const o = destFloat + i * 16;
926
+ const n = startNode + i;
927
+ const chunk = idx.chunkFor( n );
928
+ const o = idx.baseOf( n );
613
929
 
614
- if ( this.bvhData[ o + 3 ] === - 1 ) {
930
+ if ( chunk[ o + 3 ] === BVH_LEAF_MARKERS.TRIANGLE_LEAF ) {
615
931
 
616
- this.bvhData[ o ] += triOffset;
932
+ chunk[ o ] += triOffset;
617
933
 
618
934
  } else {
619
935
 
620
- this.bvhData[ o + 3 ] += nodeOffset;
621
- this.bvhData[ o + 7 ] += nodeOffset;
936
+ chunk[ o + 3 ] += nodeOffset;
937
+ chunk[ o + 7 ] += nodeOffset;
622
938
 
623
939
  }
624
940
 
@@ -640,7 +956,6 @@ export class SceneProcessor {
640
956
 
641
957
  if ( tasks.length === 0 ) return Promise.resolve( [] );
642
958
 
643
- const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
644
959
  const poolSize = Math.min( tasks.length, this.config.maxConcurrentTextureTasks || 4 );
645
960
  const results = [];
646
961
  let nextTask = 0;
@@ -663,10 +978,7 @@ export class SceneProcessor {
663
978
  }
664
979
 
665
980
  const { m, range } = tasks[ nextTask ++ ];
666
- const meshTriData = this.triangleData.slice(
667
- range.start * FPT,
668
- ( range.start + range.count ) * FPT
669
- );
981
+ const meshTriData = this.triangles.copyOf( range.start, range.count );
670
982
 
671
983
  // Disable treelet for tiny meshes
672
984
  const triCount = range.count;
@@ -709,12 +1021,16 @@ export class SceneProcessor {
709
1021
  if ( data.progress !== undefined ) return; // Ignore progress messages
710
1022
 
711
1023
  const { m, range } = worker._currentTask;
1024
+
1025
+ // Write back now: holding one copy per mesh until the pool drains is the whole
1026
+ // triangle buffer over again, ~3 GB at 40M.
1027
+ if ( data.triangles ) this.triangles.setRecords( range.start, data.triangles );
1028
+
712
1029
  results.push( {
713
1030
  m,
714
1031
  range,
715
1032
  result: {
716
1033
  bvhData: data.bvhData,
717
- reorderedTriangles: data.triangles || null,
718
1034
  originalToBvh: data.originalToBvh || null,
719
1035
  splitStats: data.treeletStats || null,
720
1036
  }
@@ -764,43 +1080,38 @@ export class SceneProcessor {
764
1080
  }
765
1081
 
766
1082
  /**
767
- * Build global originalToBvhMap and per-mesh bvhToOriginal maps.
768
- * The inverse map enables cache-friendly sequential writes during position updates.
1083
+ * Build the per-template bvhToOriginal maps: stored-order index → the caller's triangle index.
1084
+ * Only this direction is kept — every reader walks stored order, so the forward map was a
1085
+ * second copy of the same permutation (114 MB at 30M triangles) that nothing needed.
769
1086
  * @private
770
1087
  */
771
- _buildGlobalOriginalToBvhMap() {
1088
+ _buildBvhToOriginalMaps() {
772
1089
 
773
- this.originalToBvhMap = new Uint32Array( this.triangleCount );
1090
+ const table = this.instanceTable;
1091
+ for ( let m = 0; m < table.count; m ++ ) {
774
1092
 
775
- for ( const entry of this.instanceTable.entries ) {
1093
+ // Aliases read the owner's map straight out of the table; nothing to store.
1094
+ if ( ! table.isSet[ m ] || ! table.isOwner( m ) ) continue;
776
1095
 
777
- if ( ! entry ) continue;
1096
+ const t = table.sourceMesh[ m ];
1097
+ const triCount = table.tplTriCount[ t ];
1098
+ const originalToBvh = table.originalToBvhMap.get( t );
778
1099
 
779
1100
  // Build per-mesh bvhToOriginal (inverse map for sequential writes)
780
- const bvhToOrig = new Uint32Array( entry.triCount );
1101
+ const bvhToOrig = new Uint32Array( triCount );
781
1102
 
782
- if ( entry.originalToBvhMap ) {
1103
+ if ( originalToBvh ) {
783
1104
 
784
- for ( let i = 0; i < entry.triCount; i ++ ) {
785
-
786
- const bvhLocal = entry.originalToBvhMap[ i ];
787
- this.originalToBvhMap[ entry.triOffset + i ] = entry.triOffset + bvhLocal;
788
- bvhToOrig[ bvhLocal ] = i;
789
-
790
- }
1105
+ for ( let i = 0; i < triCount; i ++ ) bvhToOrig[ originalToBvh[ i ] ] = i;
791
1106
 
792
1107
  } else {
793
1108
 
794
- for ( let i = 0; i < entry.triCount; i ++ ) {
795
-
796
- this.originalToBvhMap[ entry.triOffset + i ] = entry.triOffset + i;
797
- bvhToOrig[ i ] = i;
798
-
799
- }
1109
+ for ( let i = 0; i < triCount; i ++ ) bvhToOrig[ i ] = i;
800
1110
 
801
1111
  }
802
1112
 
803
- entry.bvhToOriginal = bvhToOrig;
1113
+ table.bvhToOriginal.set( t, bvhToOrig );
1114
+ table.originalToBvhMap.delete( t ); // consumed; the caller clears the rest anyway
804
1115
 
805
1116
  }
806
1117
 
@@ -1049,9 +1360,10 @@ export class SceneProcessor {
1049
1360
  _buildEmissiveData() {
1050
1361
 
1051
1362
  this.emissiveTriangleCount = this.emissiveTriangleBuilder.extractEmissiveTriangles(
1052
- this.triangleData,
1363
+ this.triangles,
1053
1364
  this.materials,
1054
- this.triangleCount
1365
+ this.triangleCount,
1366
+ this.instanceTable ?? null
1055
1367
  );
1056
1368
 
1057
1369
  this.emissiveTriangleData = this.emissiveTriangleBuilder.createEmissiveRawData();
@@ -1104,7 +1416,7 @@ export class SceneProcessor {
1104
1416
 
1105
1417
  // Reset all containers
1106
1418
  this.triangles = [];
1107
- this.triangleData = null;
1419
+ this._setTriangleData( null );
1108
1420
  this.triangleCount = 0;
1109
1421
  this.materials = [];
1110
1422
  this.meshTriangleRanges = null;
@@ -1129,7 +1441,10 @@ export class SceneProcessor {
1129
1441
  this.cameras = [];
1130
1442
  this.spheres = [];
1131
1443
  this.bvhRoot = null;
1444
+ this.bvh = null;
1132
1445
  this.bvhData = null;
1446
+ this.bvhIndexChunks = null;
1447
+ this.bvhIndex = null;
1133
1448
  this.instanceTable = null;
1134
1449
  this.lightBVHNodeData = null;
1135
1450
  this.lightBVHNodeCount = 0;
@@ -1140,6 +1455,9 @@ export class SceneProcessor {
1140
1455
  textureCreationTime: 0,
1141
1456
  geometryExtractionTime: 0,
1142
1457
  bvhBuildTime: 0,
1458
+ blasBuildTime: 0,
1459
+ tlasBuildTime: 0,
1460
+ bvhAssembleTime: 0,
1143
1461
  totalProcessingTime: 0
1144
1462
  };
1145
1463
 
@@ -1286,9 +1604,9 @@ export class SceneProcessor {
1286
1604
  cameraCount: this.cameras.length,
1287
1605
  processingComplete: this.processingStage === 'complete',
1288
1606
  hasBVH: !! this.bvhRoot,
1289
- hasTextures: !! this.materialData && !! this.bvhData,
1607
+ hasTextures: !! this.materialData && !! this.bvh,
1290
1608
  useFloat32Array: this.config.useFloat32Array,
1291
- triangleDataSize: this.triangleData ? ( this.triangleData.byteLength / ( 1024 * 1024 ) ).toFixed( 2 ) + 'MB' : '0MB'
1609
+ triangleDataSize: this.triangles ? ( this.triangles.byteLength / ( 1024 * 1024 ) ).toFixed( 2 ) + 'MB' : '0MB'
1292
1610
  };
1293
1611
 
1294
1612
  // Add performance metrics
@@ -1349,45 +1667,115 @@ export class SceneProcessor {
1349
1667
  // ===== BVH REFIT (Animation Support) =====
1350
1668
 
1351
1669
  /**
1352
- * Refit BVH with updated vertex positions (same topology — no triangle add/remove).
1353
- * O(N) bottom-up AABB update instead of full O(N log N) SAH rebuild.
1354
- *
1355
- * @param {Float32Array} newPositions - 9 floats per triangle (ax,ay,az, bx,by,bz, cx,cy,cz) in original mesh order
1356
- * @returns {Promise<{ refitTimeMs: number }>}
1670
+ * Make sure a store's chunks live in shared memory. Already-shared chunks are left alone; a
1671
+ * page without cross-origin isolation falls back to copying, which is the old cost.
1672
+ * @private
1357
1673
  */
1358
- async refitBVH( newPositions, newNormals ) {
1674
+ _adoptSharedChunks( field, LaneType ) {
1359
1675
 
1360
- if ( ! this.bvhData || ! this.triangleData || ! this.originalToBvhMap ) {
1676
+ const records = this[ field ];
1677
+ if ( records.shared ) return;
1361
1678
 
1362
- throw new Error( 'No BVH data available for refit. Run buildBVH() first.' );
1679
+ const chunks = records.chunks.map( c => {
1363
1680
 
1364
- }
1681
+ const view = new LaneType( new SharedArrayBuffer( c.byteLength ) );
1682
+ view.set( c );
1683
+ return view;
1365
1684
 
1366
- // The worker reads triCount * 9 floats unconditionally, and the shared position buffer
1367
- // is sized from the FIRST call's argument — so a short array reads past its end and
1368
- // writes NaN into triangleData and every AABB above it, with no error anywhere. A
1369
- // caller that misses a mesh (the hidden ground disk is easy to miss) sees the scene
1370
- // silently vanish instead of a thrown exception, so check the length up front.
1371
- const expectedFloats = this.triangleCount * 9;
1685
+ } );
1372
1686
 
1373
- if ( newPositions?.length !== expectedFloats ) {
1687
+ const adopted = ChunkedRecords.adopt(
1688
+ chunks, records.recordCount, records.lanesPerRecord, records.recordsPerChunk
1689
+ );
1374
1690
 
1375
- throw new Error(
1376
- `SceneProcessor.refitBVH: expected ${expectedFloats} position floats ` +
1377
- `(${this.triangleCount} triangles × 9), got ${newPositions?.length ?? 'none'}. ` +
1378
- 'Positions must cover every triangle in the scene, meshes in this.meshes order.'
1379
- );
1691
+ if ( field === 'bvh' ) this._setBVHData( adopted );
1692
+ else this._setTriangleData( adopted );
1693
+
1694
+ }
1695
+
1696
+ /**
1697
+ * Normalize a caller's positions or normals into a per-mesh reader. A full-scene array is
1698
+ * handed back as views into itself, so the old contract costs nothing extra; a callback lets a
1699
+ * caller build one mesh at a time and never hold 9 floats × every triangle at once — 1,030 MB
1700
+ * at 30M triangles, which is past what a renderer can allocate.
1701
+ * @private
1702
+ */
1703
+ _meshSource( source, what ) {
1704
+
1705
+ if ( source == null ) return null;
1706
+
1707
+ const table = this.instanceTable;
1708
+
1709
+ if ( typeof source === 'function' ) {
1710
+
1711
+ // The table is keyed by placement and one instanced mesh holds several, so a raw
1712
+ // placement index is not something the caller can look up. Hand back the mesh.
1713
+ return i => {
1714
+
1715
+ const mesh = table.sourceMesh[ i ];
1716
+ const got = source( mesh, table.triCountOf( i ) );
1717
+ const want = table.triCountOf( i ) * 9;
1718
+ if ( got && got.length !== want ) {
1719
+
1720
+ throw new Error(
1721
+ `SceneProcessor: ${what}s for mesh ${mesh} must be ${want} floats ` +
1722
+ `(${table.triCountOf( i )} triangles × 9), got ${got.length}.`
1723
+ );
1724
+
1725
+ }
1726
+
1727
+ return got ?? null;
1728
+
1729
+ };
1380
1730
 
1381
1731
  }
1382
1732
 
1383
- if ( newNormals && newNormals.length !== expectedFloats ) {
1733
+ // A short array reads past its end and writes NaN through every AABB above it with no
1734
+ // error anywhere, so the scene just vanishes. Check once here instead.
1735
+ const expected = ( this.expandedTriangleCount ?? this.triangleCount ) * 9;
1736
+ if ( source.length !== expected ) {
1384
1737
 
1385
1738
  throw new Error(
1386
- `SceneProcessor.refitBVH: expected ${expectedFloats} normal floats, got ${newNormals.length}.`
1739
+ `SceneProcessor: expected ${expected} ${what} floats (${expected / 9} triangles × 9, ` +
1740
+ `full scene), got ${source.length}. Pass a per-mesh callback instead to avoid ` +
1741
+ 'building one array for the whole scene.'
1387
1742
  );
1388
1743
 
1389
1744
  }
1390
1745
 
1746
+ return i => {
1747
+
1748
+ const from = table.expandedStartOf( i ) * 9;
1749
+ return source.subarray( from, from + table.triCountOf( i ) * 9 );
1750
+
1751
+ };
1752
+
1753
+ }
1754
+
1755
+ /**
1756
+ * Refit BVH with updated vertex positions (same topology — no triangle add/remove).
1757
+ * O(N) bottom-up AABB update instead of full O(N log N) SAH rebuild.
1758
+ *
1759
+ * @param {Float32Array|function(number, number): Float32Array} newPositions - either 9 floats
1760
+ * per triangle for the whole scene (meshes in `this.meshes` order), or a callback given a
1761
+ * mesh index and its triangle count that returns just that mesh's 9-floats-per-triangle
1762
+ * slice. Prefer the callback: the scene-wide form is 1,030 MB at 30M triangles.
1763
+ * @param {Float32Array|function(number, number): Float32Array} [newNormals] - same two shapes
1764
+ * @returns {Promise<{ refitTimeMs: number }>}
1765
+ */
1766
+ async refitBVH( newPositions, newNormals ) {
1767
+
1768
+ if ( ! this.bvh || ! this.triangles || ! this.instanceTable ) {
1769
+
1770
+ throw new Error( 'No BVH data available for refit. Run buildBVH() first.' );
1771
+
1772
+ }
1773
+
1774
+ const positionsFor = this._meshSource( newPositions, 'position' );
1775
+ const normalsFor = this._meshSource( newNormals, 'normal' );
1776
+
1777
+ if ( ! positionsFor ) throw new Error( 'SceneProcessor.refitBVH: positions are required.' );
1778
+
1391
1779
  // Lazy-create worker
1392
1780
  if ( ! this._refitWorker ) {
1393
1781
 
@@ -1395,55 +1783,55 @@ export class SceneProcessor {
1395
1783
 
1396
1784
  }
1397
1785
 
1398
- // First call: set up SharedArrayBuffers for zero-copy communication.
1399
- // Worker writes into shared bvh/tri data; main thread reads them for GPU upload.
1400
- // Race-free because _animRefitInFlight guard prevents overlapping calls.
1786
+ // First call: move triangles and nodes into shared memory so the worker refits them in
1787
+ // place. Positions never cross the boundary — they are scattered into the triangle records
1788
+ // below, one mesh at a time, so nothing scene-sized is allocated on either side.
1401
1789
  if ( ! this._refitSharedBuffers ) {
1402
1790
 
1403
- const sharedBvhBuf = new SharedArrayBuffer( this.bvhData.byteLength );
1404
- const sharedTriBuf = new SharedArrayBuffer( this.triangleData.byteLength );
1405
- const sharedPosBuf = new SharedArrayBuffer( newPositions.byteLength );
1791
+ // Both stores are built on SharedArrayBuffer when the page allows it, so the worker
1792
+ // takes them as they are. Copying instead would duplicate the two largest structures
1793
+ // in the scene — 3,968 MB at 30M triangles, which is where this used to fail.
1794
+ this._adoptSharedChunks( 'bvh', Float32Array );
1795
+ this._adoptSharedChunks( 'triangles', Uint32Array );
1406
1796
 
1407
- const sharedBvhData = new Float32Array( sharedBvhBuf );
1408
- const sharedTriData = new Float32Array( sharedTriBuf );
1797
+ const sharedBvhBufs = this.bvh.chunks.map( c => c.buffer );
1798
+ const sharedTriBufs = this.triangles.chunks.map( c => c.buffer );
1799
+ this._refitSharedBuffers = { bvhBufs: sharedBvhBufs, triBufs: sharedTriBufs };
1409
1800
 
1410
- sharedBvhData.set( this.bvhData );
1411
- sharedTriData.set( this.triangleData );
1801
+ this._refitWorker.postMessage( {
1802
+ type: 'init',
1803
+ sharedBvhBufs,
1804
+ bvhRecordCount: this.bvh.recordCount,
1805
+ bvhRecordsPerChunk: this.bvh.recordsPerChunk,
1806
+ sharedTriBufs,
1807
+ triRecordCount: this.triangles.recordCount,
1808
+ triLanesPerRecord: this.triangles.lanesPerRecord,
1809
+ triRecordsPerChunk: this.triangles.recordsPerChunk,
1810
+ } );
1412
1811
 
1413
- // Replace local refs with shared views
1414
- this.bvhData = sharedBvhData;
1415
- this.triangleData = sharedTriData;
1812
+ }
1416
1813
 
1417
- // Build bvhToOriginal map (inverse of originalToBvh) for cache-friendly
1418
- // sequential writes in the worker's updateTrianglePositions.
1419
- const triCount = this.originalToBvhMap.length;
1420
- const bvhToOriginal = new Uint32Array( triCount );
1421
- for ( let i = 0; i < triCount; i ++ ) {
1814
+ // Scatter straight into the shared triangle records, mesh by mesh, so the caller's slice
1815
+ // can be released as soon as it is read. Callers hand over world space; triangles are
1816
+ // stored per instance, so each mesh comes back through its own inverse.
1817
+ const table = this.instanceTable;
1818
+ for ( let i = 0; i < table.count; i ++ ) {
1422
1819
 
1423
- bvhToOriginal[ this.originalToBvhMap[ i ] ] = i;
1820
+ if ( ! table.isSet[ i ] || ! table.isOwner( i ) ) continue;
1424
1821
 
1425
- }
1822
+ const p = positionsFor( i );
1823
+ if ( ! p ) continue;
1824
+ this._updateMeshTrianglePositions( i, p );
1426
1825
 
1427
- this._refitSharedBuffers = {
1428
- bvhBuf: sharedBvhBuf,
1429
- triBuf: sharedTriBuf,
1430
- posBuf: sharedPosBuf,
1431
- posView: new Float32Array( sharedPosBuf ),
1432
- };
1826
+ // Smooth normals overwrite the face normals just computed, so they follow per mesh.
1827
+ if ( normalsFor ) {
1433
1828
 
1434
- // Send shared buffers + immutable index map to worker (cached there)
1435
- this._refitWorker.postMessage( {
1436
- type: 'init',
1437
- sharedBvhBuf,
1438
- sharedTriBuf,
1439
- sharedPosBuf,
1440
- bvhToOriginal,
1441
- }, [ bvhToOriginal.buffer ] );
1829
+ const n = normalsFor( i );
1830
+ if ( n ) this._patchMeshSmoothNormals( i, n );
1442
1831
 
1443
- }
1832
+ }
1444
1833
 
1445
- // Write new positions into shared buffer (main thread → worker, zero-copy)
1446
- this._refitSharedBuffers.posView.set( newPositions );
1834
+ }
1447
1835
 
1448
1836
  return new Promise( ( resolve, reject ) => {
1449
1837
 
@@ -1452,14 +1840,6 @@ export class SceneProcessor {
1452
1840
  const msg = e.data;
1453
1841
  if ( msg.type === 'refitComplete' ) {
1454
1842
 
1455
- // bvhData/triangleData already updated via shared memory.
1456
- // If smooth normals provided, overwrite the face normals the worker computed.
1457
- if ( newNormals ) {
1458
-
1459
- this._patchSmoothNormals( newNormals );
1460
-
1461
- }
1462
-
1463
1843
  resolve( { refitTimeMs: msg.refitTimeMs } );
1464
1844
 
1465
1845
  } else if ( msg.type === 'error' ) {
@@ -1481,9 +1861,18 @@ export class SceneProcessor {
1481
1861
  * Overwrite face normals in triangleData with smooth vertex normals (full scene).
1482
1862
  * @private
1483
1863
  */
1484
- _patchSmoothNormals( normals ) {
1864
+ _patchSmoothNormals( normalsFor ) {
1865
+
1866
+ const table = this.instanceTable;
1867
+ if ( ! table ) return;
1485
1868
 
1486
- this._patchNormalsRange( normals, 0, this.originalToBvhMap.length );
1869
+ for ( let i = 0; i < table.count; i ++ ) {
1870
+
1871
+ if ( ! table.isSet[ i ] || ! table.isOwner( i ) ) continue;
1872
+ const n = normalsFor( i );
1873
+ if ( n ) this._patchMeshSmoothNormals( i, n );
1874
+
1875
+ }
1487
1876
 
1488
1877
  }
1489
1878
 
@@ -1491,39 +1880,38 @@ export class SceneProcessor {
1491
1880
  * Refit specific BLASes and rebuild TLAS after object transform or per-mesh animation.
1492
1881
  * Runs on the main thread (fast for per-mesh updates).
1493
1882
  *
1494
- * @param {number[]} affectedMeshIndices - Indices into meshTriangleRanges / instanceTable.entries
1883
+ * @param {number[]} affectedMeshIndices - Indices into `this.meshes`, the same space
1884
+ * {@link updateMeshTransforms} takes
1495
1885
  * @param {Float32Array} newPositions - 9 floats per triangle in original mesh order (full scene)
1496
1886
  * @param {Float32Array} [newNormals] - Optional smooth normals (9 floats per tri)
1497
1887
  * @returns {{ refitTimeMs: number }}
1498
1888
  */
1499
- refitBLASes( affectedMeshIndices, newPositions, newNormals ) {
1500
-
1501
- if ( ! this.instanceTable || ! this.bvhData || ! this.triangleData ) {
1889
+ /**
1890
+ * The placement holding a mesh's triangles. The instance table is keyed by placement and one
1891
+ * instanced mesh contributes several, so every entry point taking mesh indices converts here.
1892
+ *
1893
+ * @param {number} meshIndex
1894
+ * @returns {number} placement index, or -1 when the mesh placed nothing
1895
+ * @private
1896
+ */
1897
+ _placementOf( meshIndex ) {
1502
1898
 
1503
- throw new Error( 'No TLAS/BLAS data available. Run buildBVH() first.' );
1899
+ return this.instanceTable?.placementRunOf( meshIndex )?.start ?? - 1;
1504
1900
 
1505
- }
1901
+ }
1506
1902
 
1507
- // Indexed by absolute triangle, so a short buffer reads undefined → NaN bounds for the
1508
- // affected meshes with no error. Same silent-corruption trap as refitBVH.
1509
- const expectedFloats = this.triangleCount * 9;
1903
+ refitBLASes( affectedMeshIndices, newPositions, newNormals ) {
1510
1904
 
1511
- if ( newPositions?.length !== expectedFloats ) {
1905
+ if ( ! this.instanceTable || ! this.bvh || ! this.triangles ) {
1512
1906
 
1513
- throw new Error(
1514
- `SceneProcessor.refitBLASes: expected ${expectedFloats} position floats ` +
1515
- `(${this.triangleCount} triangles × 9, full scene), got ${newPositions?.length ?? 'none'}.`
1516
- );
1907
+ throw new Error( 'No TLAS/BLAS data available. Run buildBVH() first.' );
1517
1908
 
1518
1909
  }
1519
1910
 
1520
- if ( newNormals && newNormals.length !== expectedFloats ) {
1911
+ const positionsFor = this._meshSource( newPositions, 'position' );
1912
+ const normalsFor = this._meshSource( newNormals, 'normal' );
1521
1913
 
1522
- throw new Error(
1523
- `SceneProcessor.refitBLASes: expected ${expectedFloats} normal floats, got ${newNormals.length}.`
1524
- );
1525
-
1526
- }
1914
+ if ( ! positionsFor ) throw new Error( 'SceneProcessor.refitBLASes: positions are required.' );
1527
1915
 
1528
1916
  const start = performance.now();
1529
1917
 
@@ -1535,31 +1923,49 @@ export class SceneProcessor {
1535
1923
  }
1536
1924
 
1537
1925
  // Step 1: Update triangle positions and refit each affected BLAS
1538
- for ( const meshIdx of affectedMeshIndices ) {
1926
+ const table = this.instanceTable;
1927
+
1928
+ for ( const meshIndex of affectedMeshIndices ) {
1929
+
1930
+ const placement = this._placementOf( meshIndex );
1931
+ if ( placement < 0 || ! table.isSet[ placement ] ) continue;
1932
+
1933
+ // Triangles are shared between placements of one geometry, so writing this mesh's
1934
+ // vertices would move every other copy with them. Anything that deforms is extracted
1935
+ // with triangles of its own, so reaching here means the wrong mesh was handed over.
1936
+ if ( ! table.isOwner( placement ) ) {
1937
+
1938
+ const owner = table.tplOwner[ table.sourceMesh[ placement ] ];
1939
+ this.config.issues?.record(
1940
+ ISSUE_CODES.REFIT_SHARED_GEOMETRY,
1941
+ `mesh ${meshIndex} shares its triangles with another placement; deforming it would move every copy, so it was skipped`,
1942
+ { meshIndex, owner: table.sourceMesh[ owner ] ?? owner }
1943
+ );
1944
+ continue;
1539
1945
 
1540
- const entry = this.instanceTable.entries[ meshIdx ];
1541
- if ( ! entry ) continue;
1946
+ }
1542
1947
 
1543
- // Update triangle positions within this mesh's range
1544
- this._updateMeshTrianglePositions( entry, newPositions );
1948
+ const p = positionsFor( placement );
1949
+ if ( ! p ) continue;
1950
+ this._updateMeshTrianglePositions( placement, p );
1545
1951
 
1546
- // Patch smooth normals for this mesh if provided
1547
- if ( newNormals ) {
1952
+ if ( normalsFor ) {
1548
1953
 
1549
- this._patchMeshSmoothNormals( entry, newNormals );
1954
+ const n = normalsFor( placement );
1955
+ if ( n ) this._patchMeshSmoothNormals( placement, n );
1550
1956
 
1551
1957
  }
1552
1958
 
1553
1959
  // Refit this BLAS's nodes
1554
1960
  this._blasRefitter.refitRange(
1555
- this.bvhData,
1556
- this.triangleData,
1557
- entry.blasOffset,
1558
- entry.blasNodeCount
1961
+ this.bvh,
1962
+ this.triangles,
1963
+ table.blasOffsetOf( placement ),
1964
+ table.blasNodeCountOf( placement )
1559
1965
  );
1560
1966
 
1561
1967
  // Recompute this mesh's AABB for TLAS rebuild
1562
- this.instanceTable.recomputeAABB( meshIdx, this.bvhData, this.triangleData );
1968
+ this.instanceTable.recomputeAABB( placement, this.bvh, this.triangles );
1563
1969
 
1564
1970
  }
1565
1971
 
@@ -1570,6 +1976,121 @@ export class SceneProcessor {
1570
1976
 
1571
1977
  }
1572
1978
 
1979
+ /**
1980
+ * Move objects without touching their geometry: the placements a mesh contributed get new
1981
+ * transforms, the TLAS leaves get the matching world-to-object matrices, and the TLAS boxes
1982
+ * are refit. Triangles, BLASes and the GPU triangle buffer are all left alone.
1983
+ *
1984
+ * This is what a gizmo drag wants. {@link refitBLASes} exists for geometry that actually
1985
+ * deformed; used for a rigid move it would bake the new world positions into triangles that
1986
+ * a second placement of the same geometry may be sharing, and drag that one along too.
1987
+ *
1988
+ * @param {number[]} meshIndices - indices into `this.meshes`
1989
+ * @returns {{ refitTimeMs: number, placements: number }}
1990
+ */
1991
+ updateMeshTransforms( meshIndices ) {
1992
+
1993
+ if ( ! this.instanceTable || ! this.bvh ) {
1994
+
1995
+ throw new Error( 'No TLAS/BLAS data available. Run buildBVH() first.' );
1996
+
1997
+ }
1998
+
1999
+ const start = performance.now();
2000
+ const table = this.instanceTable;
2001
+ const composed = this._transformScratch ??= new Float32Array( 16 );
2002
+ let moved = 0;
2003
+
2004
+ for ( const meshIndex of meshIndices ) {
2005
+
2006
+ const mesh = this.meshes?.[ meshIndex ];
2007
+ const run = table.placementRunOf( meshIndex );
2008
+ if ( ! mesh || ! run ) continue;
2009
+
2010
+ mesh.updateMatrixWorld( true );
2011
+ const world = mesh.matrixWorld.elements;
2012
+ const instances = mesh.isInstancedMesh ? this._ownInstanceMatrices( mesh ) : null;
2013
+ // Baked triangles hold the pose they were extracted at; the leaf only carries the delta.
2014
+ const bakeInverse = table.tplBakeInverse?.get( table.sourceMesh[ run.start ] ) ?? null;
2015
+
2016
+ for ( let k = 0; k < run.count; k ++ ) {
2017
+
2018
+ const p = run.start + k;
2019
+ if ( ! table.isSet[ p ] ) continue;
2020
+
2021
+ if ( instances ) {
2022
+
2023
+ multiplyAffine( world, instances, k * 16, composed );
2024
+ table.setPlacementMatrix( p, composed );
2025
+
2026
+ } else if ( bakeInverse ) {
2027
+
2028
+ multiplyAffine( world, bakeInverse, 0, composed );
2029
+ table.setPlacementMatrix( p, composed );
2030
+
2031
+ } else {
2032
+
2033
+ table.setPlacementMatrix( p, world );
2034
+
2035
+ }
2036
+
2037
+ this._writeLeafMatrix( p );
2038
+ moved ++;
2039
+
2040
+ }
2041
+
2042
+ }
2043
+
2044
+ this._refitTLAS();
2045
+
2046
+ return { refitTimeMs: performance.now() - start, placements: moved };
2047
+
2048
+ }
2049
+
2050
+ /**
2051
+ * An InstancedMesh's own instance matrices, split off the placement pool if they still alias it.
2052
+ *
2053
+ * The extractor points a host-at-origin InstancedMesh straight at the pool, since world and
2054
+ * instance matrices hold identical bytes there. Composing a moved host back into the pool would
2055
+ * overwrite the matrices it just read, so the next move would compose onto its own result.
2056
+ * @private
2057
+ */
2058
+ _ownInstanceMatrices( mesh ) {
2059
+
2060
+ const attr = mesh.instanceMatrix;
2061
+ if ( ! attr?.array ) return null;
2062
+
2063
+ if ( attr.array.buffer === this.instanceTable.world.buffer ) {
2064
+
2065
+ attr.array = attr.array.slice();
2066
+ attr.needsUpdate = true;
2067
+
2068
+ }
2069
+
2070
+ return attr.array;
2071
+
2072
+ }
2073
+
2074
+ /** Refresh one TLAS leaf's world-to-object matrix from the table. @private */
2075
+ _writeLeafMatrix( placement ) {
2076
+
2077
+ const node = this.instanceTable.tlasLeafIndex[ placement ];
2078
+ if ( node < 0 ) return;
2079
+
2080
+ TLASBuilder.writeLeafMatrix(
2081
+ this.bvh.chunkFor( node ), this.bvhIndexChunks.chunkFor( node ), this.bvh.baseOf( node ),
2082
+ this.instanceTable.world, placement
2083
+ );
2084
+
2085
+ }
2086
+
2087
+ /** The TLAS occupies the front of the BVH buffer and is the only part a move rewrites. */
2088
+ computeTLASDirtyRange() {
2089
+
2090
+ return { offset: 0, count: this.instanceTable.tlasNodeCount * 16 };
2091
+
2092
+ }
2093
+
1573
2094
  /**
1574
2095
  * Computes the dirty buffer ranges for a set of affected mesh BLASes.
1575
2096
  * Used for partial GPU upload after per-mesh refit instead of full buffer copy.
@@ -1584,13 +2105,14 @@ export class SceneProcessor {
1584
2105
  const triRanges = [];
1585
2106
  const bvhRanges = [];
1586
2107
 
1587
- for ( const meshIdx of affectedMeshIndices ) {
2108
+ for ( const meshIndex of affectedMeshIndices ) {
1588
2109
 
1589
- const entry = this.instanceTable.entries[ meshIdx ];
1590
- if ( ! entry ) continue;
2110
+ const table = this.instanceTable;
2111
+ const placement = this._placementOf( meshIndex );
2112
+ if ( placement < 0 || ! table.isSet[ placement ] ) continue;
1591
2113
 
1592
- triRanges.push( { offset: entry.triOffset * FPT, count: entry.triCount * FPT } );
1593
- bvhRanges.push( { offset: entry.blasOffset * FPN, count: entry.blasNodeCount * FPN } );
2114
+ triRanges.push( { offset: table.triOffsetOf( placement ) * FPT, count: table.triCountOf( placement ) * FPT } );
2115
+ bvhRanges.push( { offset: table.blasOffsetOf( placement ) * FPN, count: table.blasNodeCountOf( placement ) * FPN } );
1594
2116
 
1595
2117
  }
1596
2118
 
@@ -1613,23 +2135,23 @@ export class SceneProcessor {
1613
2135
  */
1614
2136
  uploadToPathTracer( pathTracer, lightManager, meshScene, environmentTexture ) {
1615
2137
 
1616
- if ( ! this.triangleData ) {
2138
+ if ( ! this.triangles ) {
1617
2139
 
1618
2140
  log.error( 'failed to get triangle data' );
1619
2141
  return false;
1620
2142
 
1621
2143
  }
1622
2144
 
1623
- pathTracer.setTriangleData( this.triangleData, this.triangleCount );
2145
+ pathTracer.setTriangleData( this.triangles, this.triangleCount );
1624
2146
 
1625
- if ( ! this.bvhData ) {
2147
+ if ( ! this.bvh ) {
1626
2148
 
1627
2149
  log.error( 'failed to get BVH data' );
1628
2150
  return false;
1629
2151
 
1630
2152
  }
1631
2153
 
1632
- pathTracer.setBVHData( this.bvhData );
2154
+ pathTracer.setBVHData( this.bvh );
1633
2155
  pathTracer.setInstanceTable( this.instanceTable );
1634
2156
 
1635
2157
  if ( this.materialData ) {
@@ -1702,7 +2224,7 @@ export class SceneProcessor {
1702
2224
 
1703
2225
  const changed = this.emissiveTriangleBuilder.updateMaterialEmissive(
1704
2226
  materialIndex, mat,
1705
- this.triangleData, this.materials, this.triangleCount,
2227
+ this.triangles, this.materials, this.triangleCount,
1706
2228
  );
1707
2229
 
1708
2230
  if ( ! changed ) return null;
@@ -1755,64 +2277,194 @@ export class SceneProcessor {
1755
2277
 
1756
2278
  }
1757
2279
 
2280
+ /**
2281
+ * Copy world-space positions into `dst`, each mesh's range carried into its own space.
2282
+ * @private
2283
+ */
2284
+ /**
2285
+ * Build the TLAS, off the main thread when a worker is available.
2286
+ *
2287
+ * The worker returns structure only; leaf payloads are filled here. On any failure the
2288
+ * synchronous path runs instead, so a blocked or unavailable worker costs responsiveness,
2289
+ * never a scene.
2290
+ * @private
2291
+ */
2292
+ async _buildTLAS( table ) {
2293
+
2294
+ const n = table.count;
2295
+
2296
+ // Below this the build is a few ms and the round trip costs more than it saves.
2297
+ if ( n >= TLAS_WORKER_MIN_ENTRIES && ! this._tlasWorkerFailed ) {
2298
+
2299
+ try {
2300
+
2301
+ // The worker needs a buffer it can own, and world bounds are derived anyway.
2302
+ const aabbs = new Float64Array( n * 6 );
2303
+ table.writeWorldAABBs( aabbs );
2304
+ const { tlasData, nodeCount } = await this._runTLASWorker( aabbs, n );
2305
+ TLASBuilder.fillLeaves( tlasData, nodeCount, table );
2306
+ return tlasData;
2307
+
2308
+ } catch ( error ) {
2309
+
2310
+ this._tlasWorkerFailed = true;
2311
+ log.warn( `TLAS worker unavailable (${error.message}); building on the main thread` );
2312
+
2313
+ }
2314
+
2315
+ }
2316
+
2317
+ return this.tlasBuilder.build( table ).data;
2318
+
2319
+ }
2320
+
2321
+ /** @private */
2322
+ _runTLASWorker( aabbs, count ) {
2323
+
2324
+ if ( ! this._tlasWorker ) this._tlasWorker = new TLASWorker();
2325
+ const worker = this._tlasWorker;
2326
+
2327
+ return new Promise( ( resolve, reject ) => {
2328
+
2329
+ const done = ( event ) => {
2330
+
2331
+ worker.removeEventListener( 'message', done );
2332
+ worker.removeEventListener( 'error', failed );
2333
+ if ( event.data?.error ) reject( new Error( event.data.error ) );
2334
+ else resolve( event.data );
2335
+
2336
+ };
2337
+
2338
+ const failed = ( event ) => {
2339
+
2340
+ worker.removeEventListener( 'message', done );
2341
+ worker.removeEventListener( 'error', failed );
2342
+ reject( new Error( event.message || 'worker error' ) );
2343
+
2344
+ };
2345
+
2346
+ worker.addEventListener( 'message', done );
2347
+ worker.addEventListener( 'error', failed );
2348
+ worker.postMessage(
2349
+ { aabbs: aabbs.buffer, count, logLevel: workerLogLevel() },
2350
+ [ aabbs.buffer ]
2351
+ );
2352
+
2353
+ } );
2354
+
2355
+ }
2356
+
2357
+ /** Keep the f32 view in step with the uint record buffer. @private */
2358
+ /** Keep the chunked BVH and its u32 index view in step. @private */
2359
+ _setBVHData( data ) {
2360
+
2361
+ const records = ! data ? null
2362
+ : ( data instanceof ChunkedRecords
2363
+ ? data
2364
+ : ChunkedRecords.adopt( [ data ], data.length / 16, 16, data.length / 16 ) );
2365
+
2366
+ this.bvh = records;
2367
+ this.bvhIndexChunks = records ? records.viewAs( Uint32Array ) : null;
2368
+ // Null once the BVH needs more than one chunk; hot paths use `bvh` / `bvhIndexChunks`.
2369
+ this.bvhData = records ? records.single : null;
2370
+ this.bvhIndex = this.bvhIndexChunks ? this.bvhIndexChunks.single : null;
2371
+
2372
+ }
2373
+
2374
+ _setTriangleData( data ) {
2375
+
2376
+ const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
2377
+ const records = ! data ? null
2378
+ : ( data instanceof ChunkedRecords
2379
+ ? data
2380
+ : ChunkedRecords.adopt( [ data ], data.length / FPT, FPT, data.length / FPT ) );
2381
+
2382
+ this.triangles = records;
2383
+ this.triangleFloatChunks = records ? records.viewAs( Float32Array ) : null;
2384
+ // Null once the scene needs more than one chunk; everything hot goes through
2385
+ // `triangles` / `triangleFloatChunks` instead.
2386
+ this.triangleData = records ? records.single : null;
2387
+ this.triangleFloats = this.triangleFloatChunks ? this.triangleFloatChunks.single : null;
2388
+
2389
+ }
2390
+
1758
2391
  /**
1759
2392
  * Update triangle positions for a single mesh entry.
1760
2393
  * Iterates in BVH order for sequential writes (cache-friendly), random reads from newPositions.
1761
2394
  * @private
1762
2395
  */
1763
- _updateMeshTrianglePositions( entry, newPositions ) {
2396
+ _updateMeshTrianglePositions( i, meshPositions ) {
1764
2397
 
1765
- const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
1766
2398
  const PA = TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET;
1767
2399
  const PB = TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET;
1768
2400
  const PC = TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET;
1769
- const NA = TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET;
1770
- const NB = TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET;
1771
- const NC = TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET;
1772
-
1773
- const bvhToOrig = entry.bvhToOriginal;
1774
-
1775
- for ( let bvhLocal = 0; bvhLocal < entry.triCount; bvhLocal ++ ) {
1776
-
1777
- const origLocal = bvhToOrig[ bvhLocal ];
1778
- const dst = ( entry.triOffset + bvhLocal ) * FPT;
1779
- const src = ( entry.triOffset + origLocal ) * 9;
1780
-
1781
- const ax = newPositions[ src ];
1782
- const ay = newPositions[ src + 1 ];
1783
- const az = newPositions[ src + 2 ];
1784
- const bx = newPositions[ src + 3 ];
1785
- const by = newPositions[ src + 4 ];
1786
- const bz = newPositions[ src + 5 ];
1787
- const cx = newPositions[ src + 6 ];
1788
- const cy = newPositions[ src + 7 ];
1789
- const cz = newPositions[ src + 8 ];
1790
-
1791
- this.triangleData[ dst + PA ] = ax;
1792
- this.triangleData[ dst + PA + 1 ] = ay;
1793
- this.triangleData[ dst + PA + 2 ] = az;
1794
- this.triangleData[ dst + PB ] = bx;
1795
- this.triangleData[ dst + PB + 1 ] = by;
1796
- this.triangleData[ dst + PB + 2 ] = bz;
1797
- this.triangleData[ dst + PC ] = cx;
1798
- this.triangleData[ dst + PC + 1 ] = cy;
1799
- this.triangleData[ dst + PC + 2 ] = cz;
2401
+ const NA = TRIANGLE_DATA_LAYOUT.NORMAL_A_PACKED_OFFSET;
2402
+ const NB = TRIANGLE_DATA_LAYOUT.NORMAL_B_PACKED_OFFSET;
2403
+ const NC = TRIANGLE_DATA_LAYOUT.NORMAL_C_PACKED_OFFSET;
2404
+ const tri = this.triangles, triF = this.triangleFloatChunks;
2405
+ const table = this.instanceTable;
2406
+
2407
+ const bvhToOrig = table.bvhToOriginalOf( i );
2408
+ const triOffset = table.triOffsetOf( i ), triCount = table.triCountOf( i );
2409
+
2410
+ // Callers hand over world-space positions, which is the only space they can build
2411
+ // one in; triangles are stored per instance, so they come back through the inverse.
2412
+ const m = table.matrixInverseOf( i );
2413
+ const identity = isIdentity( m );
2414
+
2415
+ for ( let bvhLocal = 0; bvhLocal < triCount; bvhLocal ++ ) {
2416
+
2417
+ const origLocal = bvhToOrig ? bvhToOrig[ bvhLocal ] : bvhLocal;
2418
+ const t = triOffset + bvhLocal;
2419
+ const f = triF.chunkFor( t ), u = tri.chunkFor( t );
2420
+ const dst = tri.baseOf( t );
2421
+ const src = origLocal * 9;
2422
+
2423
+ let ax = meshPositions[ src ];
2424
+ let ay = meshPositions[ src + 1 ];
2425
+ let az = meshPositions[ src + 2 ];
2426
+ let bx = meshPositions[ src + 3 ];
2427
+ let by = meshPositions[ src + 4 ];
2428
+ let bz = meshPositions[ src + 5 ];
2429
+ let cx = meshPositions[ src + 6 ];
2430
+ let cy = meshPositions[ src + 7 ];
2431
+ let cz = meshPositions[ src + 8 ];
2432
+
2433
+ if ( ! identity ) {
2434
+
2435
+ const px = ( x, y, z ) => m[ 0 ] * x + m[ 4 ] * y + m[ 8 ] * z + m[ 12 ];
2436
+ const py = ( x, y, z ) => m[ 1 ] * x + m[ 5 ] * y + m[ 9 ] * z + m[ 13 ];
2437
+ const pz = ( x, y, z ) => m[ 2 ] * x + m[ 6 ] * y + m[ 10 ] * z + m[ 14 ];
2438
+ const a = [ px( ax, ay, az ), py( ax, ay, az ), pz( ax, ay, az ) ];
2439
+ const b = [ px( bx, by, bz ), py( bx, by, bz ), pz( bx, by, bz ) ];
2440
+ const c = [ px( cx, cy, cz ), py( cx, cy, cz ), pz( cx, cy, cz ) ];
2441
+ ax = a[ 0 ]; ay = a[ 1 ]; az = a[ 2 ];
2442
+ bx = b[ 0 ]; by = b[ 1 ]; bz = b[ 2 ];
2443
+ cx = c[ 0 ]; cy = c[ 1 ]; cz = c[ 2 ];
2444
+
2445
+ }
2446
+
2447
+ f[ dst + PA ] = ax;
2448
+ f[ dst + PA + 1 ] = ay;
2449
+ f[ dst + PA + 2 ] = az;
2450
+ f[ dst + PB ] = bx;
2451
+ f[ dst + PB + 1 ] = by;
2452
+ f[ dst + PB + 2 ] = bz;
2453
+ f[ dst + PC ] = cx;
2454
+ f[ dst + PC + 1 ] = cy;
2455
+ f[ dst + PC + 2 ] = cz;
1800
2456
 
1801
2457
  const abx = bx - ax, aby = by - ay, abz = bz - az;
1802
2458
  const acx = cx - ax, acy = cy - ay, acz = cz - az;
1803
- const nx = aby * acz - abz * acy;
1804
- const ny = abz * acx - abx * acz;
1805
- const nz = abx * acy - aby * acx;
1806
-
1807
- this.triangleData[ dst + NA ] = nx;
1808
- this.triangleData[ dst + NA + 1 ] = ny;
1809
- this.triangleData[ dst + NA + 2 ] = nz;
1810
- this.triangleData[ dst + NB ] = nx;
1811
- this.triangleData[ dst + NB + 1 ] = ny;
1812
- this.triangleData[ dst + NB + 2 ] = nz;
1813
- this.triangleData[ dst + NC ] = nx;
1814
- this.triangleData[ dst + NC + 1 ] = ny;
1815
- this.triangleData[ dst + NC + 2 ] = nz;
2459
+ const packed = packNormalOct(
2460
+ aby * acz - abz * acy,
2461
+ abz * acx - abx * acz,
2462
+ abx * acy - aby * acx
2463
+ );
2464
+
2465
+ u[ dst + NA ] = packed;
2466
+ u[ dst + NB ] = packed;
2467
+ u[ dst + NC ] = packed;
1816
2468
 
1817
2469
  }
1818
2470
 
@@ -1822,9 +2474,13 @@ export class SceneProcessor {
1822
2474
  * Patch smooth normals for a single mesh's triangles.
1823
2475
  * @private
1824
2476
  */
1825
- _patchMeshSmoothNormals( entry, normals ) {
2477
+ _patchMeshSmoothNormals( i, meshNormals ) {
1826
2478
 
1827
- this._patchNormalsRange( normals, entry.triOffset, entry.triCount );
2479
+ const table = this.instanceTable;
2480
+ this._patchNormalsRange(
2481
+ meshNormals, table.triOffsetOf( i ), table.triCountOf( i ),
2482
+ table.matrixWorldOf( i ), table.bvhToOriginalOf( i )
2483
+ );
1828
2484
 
1829
2485
  }
1830
2486
 
@@ -1832,29 +2488,47 @@ export class SceneProcessor {
1832
2488
  * Shared normal-patching loop for a range of triangles.
1833
2489
  * @private
1834
2490
  */
1835
- _patchNormalsRange( normals, startOrig, count ) {
2491
+ _patchNormalsRange( normals, triOffset, count, matrixWorld, bvhToOrig ) {
1836
2492
 
1837
- const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
1838
- const NA = TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET;
1839
- const NB = TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET;
1840
- const NC = TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET;
1841
-
1842
- for ( let i = 0; i < count; i ++ ) {
1843
-
1844
- const orig = startOrig + i;
1845
- const bvhIdx = this.originalToBvhMap[ orig ];
1846
- const dst = bvhIdx * FPT;
1847
- const src = orig * 9;
1848
-
1849
- this.triangleData[ dst + NA ] = normals[ src ];
1850
- this.triangleData[ dst + NA + 1 ] = normals[ src + 1 ];
1851
- this.triangleData[ dst + NA + 2 ] = normals[ src + 2 ];
1852
- this.triangleData[ dst + NB ] = normals[ src + 3 ];
1853
- this.triangleData[ dst + NB + 1 ] = normals[ src + 4 ];
1854
- this.triangleData[ dst + NB + 2 ] = normals[ src + 5 ];
1855
- this.triangleData[ dst + NC ] = normals[ src + 6 ];
1856
- this.triangleData[ dst + NC + 1 ] = normals[ src + 7 ];
1857
- this.triangleData[ dst + NC + 2 ] = normals[ src + 8 ];
2493
+ const NA = TRIANGLE_DATA_LAYOUT.NORMAL_A_PACKED_OFFSET;
2494
+ const NB = TRIANGLE_DATA_LAYOUT.NORMAL_B_PACKED_OFFSET;
2495
+ const NC = TRIANGLE_DATA_LAYOUT.NORMAL_C_PACKED_OFFSET;
2496
+
2497
+ // Callers supply world-space normals. Traversal takes object normals out through the
2498
+ // transpose of world-to-object, so coming the other way is the transpose of
2499
+ // object-to-world — not its inverse, which is what a position would use.
2500
+ const m = matrixWorld;
2501
+ const plain = ! m || isIdentity( m );
2502
+ const slots = [ NA, NB, NC ];
2503
+
2504
+ // Walked in stored order so the writes stay sequential; the inverse map is the only
2505
+ // permutation kept, so the read is the scattered side.
2506
+ for ( let k = 0; k < count; k ++ ) {
2507
+
2508
+ const bvhIdx = triOffset + k;
2509
+ const u = this.triangles.chunkFor( bvhIdx );
2510
+ const dst = this.triangles.baseOf( bvhIdx );
2511
+ const src = ( bvhToOrig ? bvhToOrig[ k ] : k ) * 9;
2512
+
2513
+ for ( let v = 0; v < 3; v ++ ) {
2514
+
2515
+ const s = src + v * 3;
2516
+ const nx = normals[ s ], ny = normals[ s + 1 ], nz = normals[ s + 2 ];
2517
+ let ox = nx, oy = ny, oz = nz;
2518
+
2519
+ if ( ! plain ) {
2520
+
2521
+ ox = m[ 0 ] * nx + m[ 1 ] * ny + m[ 2 ] * nz;
2522
+ oy = m[ 4 ] * nx + m[ 5 ] * ny + m[ 6 ] * nz;
2523
+ oz = m[ 8 ] * nx + m[ 9 ] * ny + m[ 10 ] * nz;
2524
+ const len = Math.hypot( ox, oy, oz ) || 1;
2525
+ ox /= len; oy /= len; oz /= len;
2526
+
2527
+ }
2528
+
2529
+ u[ dst + slots[ v ] ] = packNormalOct( ox, oy, oz );
2530
+
2531
+ }
1858
2532
 
1859
2533
  }
1860
2534
 
@@ -1877,66 +2551,50 @@ export class SceneProcessor {
1877
2551
 
1878
2552
  }
1879
2553
 
1880
- // Build blasOffset → entry lookup (avoids O(M) .find() per leaf)
1881
- if ( ! this._blasOffsetMap ) {
1882
-
1883
- this._blasOffsetMap = new Map();
1884
-
1885
- }
1886
-
1887
- this._blasOffsetMap.clear();
1888
- for ( const entry of this.instanceTable.entries ) {
1889
-
1890
- if ( ! entry ) continue;
1891
- this._blasOffsetMap.set( entry.blasOffset, entry );
1892
-
1893
- }
2554
+ const table = this.instanceTable;
1894
2555
 
1895
2556
  // Bottom-up pass: reverse iteration over TLAS nodes
1896
2557
  for ( let i = tlasNodeCount - 1; i >= 0; i -- ) {
1897
2558
 
1898
- const o = i * FPN;
1899
- const marker = this.bvhData[ o + 3 ];
2559
+ const idxChunk = this.bvhIndexChunks.chunkFor( i );
2560
+ const fChunk = this.bvh.chunkFor( i );
2561
+ const o = this.bvh.baseOf( i );
2562
+ const marker = idxChunk[ o + 3 ];
1900
2563
 
1901
- if ( marker === - 2 ) {
2564
+ if ( marker === BVH_LEAF_MARKERS.BLAS_POINTER_LEAF ) {
1902
2565
 
1903
- // BLAS-pointer leaf: read AABB from instance table
1904
- const blasRoot = this.bvhData[ o ];
1905
- const entry = this._blasOffsetMap.get( blasRoot );
1906
- if ( entry && entry.worldAABB ) {
2566
+ // Slot [1] is this leaf's own placement. Keying off slot [0] instead collapsed
2567
+ // every placement of a shared geometry onto one box, so all but one copy sat
2568
+ // outside its own bounds and rays walked straight past it.
2569
+ const entryIndex = idxChunk[ o + 1 ] & TLAS_PLACEMENT_MASK;
2570
+ if ( entryIndex < table.count ) {
1907
2571
 
1908
- const b = i * 6;
1909
- this._tlasBounds[ b ] = entry.worldAABB.minX;
1910
- this._tlasBounds[ b + 1 ] = entry.worldAABB.minY;
1911
- this._tlasBounds[ b + 2 ] = entry.worldAABB.minZ;
1912
- this._tlasBounds[ b + 3 ] = entry.worldAABB.maxX;
1913
- this._tlasBounds[ b + 4 ] = entry.worldAABB.maxY;
1914
- this._tlasBounds[ b + 5 ] = entry.worldAABB.maxZ;
2572
+ table.writeWorldAABB( entryIndex, this._tlasBounds, i * 6 );
1915
2573
 
1916
2574
  }
1917
2575
 
1918
- } else if ( marker >= 0 ) {
2576
+ } else if ( marker < BVH_LEAF_MARKERS.TRIANGLE_LEAF ) {
1919
2577
 
1920
2578
  // Inner node: union of children bounds, update bvhData in-place
1921
- const leftIdx = this.bvhData[ o + 3 ];
1922
- const rightIdx = this.bvhData[ o + 7 ];
2579
+ const leftIdx = marker;
2580
+ const rightIdx = idxChunk[ o + 7 ];
1923
2581
  const lb = leftIdx * 6;
1924
2582
  const rb = rightIdx * 6;
1925
2583
  const bounds = this._tlasBounds;
1926
2584
 
1927
- this.bvhData[ o ] = bounds[ lb ];
1928
- this.bvhData[ o + 1 ] = bounds[ lb + 1 ];
1929
- this.bvhData[ o + 2 ] = bounds[ lb + 2 ];
1930
- this.bvhData[ o + 4 ] = bounds[ lb + 3 ];
1931
- this.bvhData[ o + 5 ] = bounds[ lb + 4 ];
1932
- this.bvhData[ o + 6 ] = bounds[ lb + 5 ];
2585
+ fChunk[ o ] = bounds[ lb ];
2586
+ fChunk[ o + 1 ] = bounds[ lb + 1 ];
2587
+ fChunk[ o + 2 ] = bounds[ lb + 2 ];
2588
+ fChunk[ o + 4 ] = bounds[ lb + 3 ];
2589
+ fChunk[ o + 5 ] = bounds[ lb + 4 ];
2590
+ fChunk[ o + 6 ] = bounds[ lb + 5 ];
1933
2591
 
1934
- this.bvhData[ o + 8 ] = bounds[ rb ];
1935
- this.bvhData[ o + 9 ] = bounds[ rb + 1 ];
1936
- this.bvhData[ o + 10 ] = bounds[ rb + 2 ];
1937
- this.bvhData[ o + 12 ] = bounds[ rb + 3 ];
1938
- this.bvhData[ o + 13 ] = bounds[ rb + 4 ];
1939
- this.bvhData[ o + 14 ] = bounds[ rb + 5 ];
2592
+ fChunk[ o + 8 ] = bounds[ rb ];
2593
+ fChunk[ o + 9 ] = bounds[ rb + 1 ];
2594
+ fChunk[ o + 10 ] = bounds[ rb + 2 ];
2595
+ fChunk[ o + 12 ] = bounds[ rb + 3 ];
2596
+ fChunk[ o + 13 ] = bounds[ rb + 4 ];
2597
+ fChunk[ o + 14 ] = bounds[ rb + 5 ];
1940
2598
 
1941
2599
  const b = i * 6;
1942
2600
  bounds[ b ] = Math.min( bounds[ lb ], bounds[ rb ] );
@@ -1962,18 +2620,14 @@ export class SceneProcessor {
1962
2620
  */
1963
2621
  scheduleBackgroundRebuild( meshIndices, onSwap ) {
1964
2622
 
1965
- if ( ! this.instanceTable || ! this.triangleData ) return;
2623
+ if ( ! this.instanceTable || ! this.triangles ) return;
1966
2624
 
1967
- const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
1968
2625
  this._rebuildGeneration ++;
1969
2626
  const generation = this._rebuildGeneration;
1970
2627
 
1971
2628
  const dispatchRebuild = ( meshIdx, entry, worker ) => {
1972
2629
 
1973
- const meshTriData = this.triangleData.slice(
1974
- entry.triOffset * FPT,
1975
- ( entry.triOffset + entry.triCount ) * FPT
1976
- );
2630
+ const meshTriData = this.triangles.copyOf( entry.triOffset, entry.triCount );
1977
2631
 
1978
2632
  this._pendingRebuilds.set( meshIdx, worker );
1979
2633
 
@@ -2014,6 +2668,13 @@ export class SceneProcessor {
2014
2668
  triangleByteLength: meshTriData.byteLength,
2015
2669
  triangleCount: entry.triCount,
2016
2670
  depth: this.config.bvhDepth,
2671
+ // Without these the worker builds at its own defaults (leaf size 8 against the
2672
+ // scene's 4), producing a tree of a different size that the swap below rejects —
2673
+ // so every rebuild was thrown away.
2674
+ maxLeafSize: this.bvhBuilder.maxLeafSize,
2675
+ numBins: this.bvhBuilder.numBins,
2676
+ maxBins: this.bvhBuilder.maxBins,
2677
+ minBins: this.bvhBuilder.minBins,
2017
2678
  reportProgress: false,
2018
2679
  sharedReorderBuffer: null,
2019
2680
  treeletOptimization: {
@@ -2032,16 +2693,20 @@ export class SceneProcessor {
2032
2693
 
2033
2694
  };
2034
2695
 
2035
- for ( const meshIdx of meshIndices ) {
2696
+ for ( const meshIndex of meshIndices ) {
2036
2697
 
2037
- const entry = this.instanceTable.entries[ meshIdx ];
2038
- if ( ! entry ) continue;
2698
+ const placement = this._placementOf( meshIndex );
2699
+ if ( placement < 0 ) continue;
2700
+ const entry = this.instanceTable.entryAt( placement );
2701
+ // A placement that borrows another's BLAS must not rebuild it: the work is the
2702
+ // owner's, and two placements would dispatch the same rebuild twice.
2703
+ if ( ! entry || ! this.instanceTable.isOwner( placement ) ) continue;
2039
2704
 
2040
2705
  // Cancel any in-flight rebuild for this mesh
2041
- const existing = this._pendingRebuilds.get( meshIdx );
2706
+ const existing = this._pendingRebuilds.get( placement );
2042
2707
  if ( existing ) existing.terminate();
2043
2708
 
2044
- dispatchRebuild( meshIdx, entry, new BVHWorker() );
2709
+ dispatchRebuild( placement, entry, new BVHWorker() );
2045
2710
 
2046
2711
  }
2047
2712
 
@@ -2057,59 +2722,62 @@ export class SceneProcessor {
2057
2722
  const newBvhData = workerData.bvhData;
2058
2723
  const newNodeCount = newBvhData.length / FPN;
2059
2724
 
2060
- // Node count must match — refit doesn't change topology, rebuild shouldn't either
2061
- // for the same triangle set. If it differs, the buffer layout is invalid.
2062
- if ( newNodeCount !== entry.blasNodeCount ) {
2725
+ // The rebuilt tree only has to fit the range the build reserved. Demanding an exact match
2726
+ // was too strict: the triangles have moved, so the splits land differently and the count
2727
+ // legitimately drifts. Anything past the reserved range would overwrite the next BLAS.
2728
+ if ( newNodeCount > entry.blasNodeCount ) {
2063
2729
 
2064
- log.warn( `background rebuild node count mismatch for mesh ${meshIdx} (${newNodeCount} vs ${entry.blasNodeCount}), skipping swap` );
2730
+ log.warn( `background rebuild does not fit for mesh ${meshIdx} (${newNodeCount} nodes vs ${entry.blasNodeCount} reserved), skipping swap` );
2065
2731
  return;
2066
2732
 
2067
2733
  }
2068
2734
 
2069
2735
  // Write rebuilt BLAS nodes into the combined buffer at the entry's offset
2070
- const destOffset = entry.blasOffset * FPN;
2071
- this.bvhData.set( newBvhData, destOffset );
2072
- this._offsetBLASInPlace( destOffset, newNodeCount, entry.blasOffset, entry.triOffset );
2736
+ this.bvh.setRecords( entry.blasOffset, newBvhData );
2737
+ this._offsetBLASInPlace( entry.blasOffset, newNodeCount, entry.blasOffset, entry.triOffset );
2073
2738
 
2074
2739
  // Write reordered triangles back into global array
2075
- const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
2076
2740
  const reorderedTris = workerData.triangles;
2077
2741
  if ( reorderedTris ) {
2078
2742
 
2079
- this.triangleData.set( reorderedTris, entry.triOffset * FPT );
2743
+ this.triangles.setRecords( entry.triOffset, reorderedTris );
2080
2744
 
2081
2745
  }
2082
2746
 
2083
- // Update per-mesh maps
2747
+ // The live tree is whatever the rebuild produced; any reserved nodes past it are dead and
2748
+ // nothing reaches them, but a later refit would walk them as garbage.
2749
+ this.instanceTable.setBlasNodeCount( meshIdx, newNodeCount );
2750
+
2751
+ // The rebuild reordered the triangles, so the map from stored order back to the caller's
2752
+ // order has to follow. `entryAt()` hands back a snapshot, so writing it there was lost and
2753
+ // the next refit scattered every position through the previous permutation.
2754
+ //
2755
+ // The worker was handed the triangles already in stored order, so its permutation is
2756
+ // relative to that, not to the caller's order. Overwriting rather than composing would
2757
+ // discard how the original build shuffled them.
2084
2758
  const newOrigToBvh = workerData.originalToBvh;
2085
2759
  if ( newOrigToBvh ) {
2086
2760
 
2087
- // Update global originalToBvhMap for this mesh's range
2088
- for ( let i = 0; i < entry.triCount; i ++ ) {
2089
-
2090
- this.originalToBvhMap[ entry.triOffset + i ] = entry.triOffset + newOrigToBvh[ i ];
2091
-
2092
- }
2093
-
2094
- // Update per-mesh bvhToOriginal
2761
+ const prev = this.instanceTable.bvhToOriginalOf( meshIdx );
2095
2762
  const bvhToOrig = new Uint32Array( entry.triCount );
2096
2763
  for ( let i = 0; i < entry.triCount; i ++ ) {
2097
2764
 
2098
- bvhToOrig[ newOrigToBvh[ i ] ] = i;
2765
+ bvhToOrig[ newOrigToBvh[ i ] ] = prev ? prev[ i ] : i;
2099
2766
 
2100
2767
  }
2101
2768
 
2102
- entry.bvhToOriginal = bvhToOrig;
2769
+ this.instanceTable.setBvhToOriginal( meshIdx, bvhToOrig );
2103
2770
 
2104
2771
  }
2105
2772
 
2106
2773
  // Recompute AABB and refit TLAS
2107
- this.instanceTable.recomputeAABB( meshIdx, this.bvhData, this.triangleData );
2774
+ this.instanceTable.recomputeAABB( meshIdx, this.bvh, this.triangles );
2108
2775
  this._refitTLAS();
2109
2776
 
2110
2777
  this._log( `Background BLAS rebuild complete for mesh ${meshIdx}` );
2111
2778
 
2112
- onSwap?.();
2779
+ // The caller asked in mesh indices and gets one back, not the placement used here.
2780
+ onSwap?.( this.instanceTable.sourceMesh[ meshIdx ] ?? meshIdx );
2113
2781
 
2114
2782
  }
2115
2783
 
@@ -2146,6 +2814,18 @@ export class SceneProcessor {
2146
2814
 
2147
2815
  }
2148
2816
 
2817
+ /** @private */
2818
+ _disposeTLASWorker() {
2819
+
2820
+ if ( this._tlasWorker ) {
2821
+
2822
+ this._tlasWorker.terminate();
2823
+ this._tlasWorker = null;
2824
+
2825
+ }
2826
+
2827
+ }
2828
+
2149
2829
  /**
2150
2830
  * Completely dispose of all resources
2151
2831
  * Call this when the instance is no longer needed
@@ -2154,8 +2834,9 @@ export class SceneProcessor {
2154
2834
 
2155
2835
  this._log( 'Disposing resources' );
2156
2836
 
2157
- // Dispose refit worker
2837
+ // Dispose workers
2158
2838
  this._disposeRefitWorker();
2839
+ this._disposeTLASWorker();
2159
2840
 
2160
2841
  // Dispose textures
2161
2842
  this._disposeTextures();