rayzee 11.2.1 → 11.4.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 (76) hide show
  1. package/README.md +91 -34
  2. package/dist/ArchiveImporter-Cgyx5AF7.js +7860 -0
  3. package/dist/BidirectionalIntegrator-BKF-cwH2.js +954 -0
  4. package/dist/{BufferLayout-D_IBo2ci.js → BufferLayout-xPRH9jYD.js} +41 -5
  5. package/dist/{NeuralRetouch-Cr-otSW8.js → NeuralRetouch-C3Q2XqbF.js} +4 -3
  6. package/dist/{NeuralSuperRes-B7E3_M0t.js → NeuralSuperRes-DV5TtURP.js} +11 -5
  7. package/dist/{PathTracer-D6nbknj5.js → PathTracer-D3MvEaZK.js} +4384 -4618
  8. package/dist/{PhysicalSky-Dq5VSiCQ.js → PhysicalSky-CY4jiz6a.js} +2 -2
  9. package/dist/{RayzeeRenderer-DbjqO6v-.js → RayzeeRenderer-D23m6NqG.js} +486 -391
  10. package/dist/SolarLimb-bZE6y63S.js +762 -0
  11. package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-CTDBvmmS.js} +31 -22
  12. package/dist/addons/archives.es.js +1 -1
  13. package/dist/addons/bidirectional.es.js +1 -1
  14. package/dist/addons/physical-sky.es.js +1 -1
  15. package/dist/rayzee-core.es.js +7 -7
  16. package/dist/rayzee.es.js +1327 -838
  17. package/dist/rayzee.umd.js +146 -134
  18. package/dist/sceneCachePolicy-AKOEu2lf.js +1602 -0
  19. package/package.json +1 -1
  20. package/src/EngineDefaults.js +2 -2
  21. package/src/Passes/AIUpscaler.js +2 -0
  22. package/src/PathTracerApp.js +126 -19
  23. package/src/Pipeline/CompletionTracker.js +7 -3
  24. package/src/Processor/ArchiveImporter.js +173 -94
  25. package/src/Processor/ArchiveReader.js +20 -23
  26. package/src/Processor/AssetLoader.js +48 -1
  27. package/src/Processor/BVHRefitter.js +109 -13
  28. package/src/Processor/BufferLayout.js +115 -0
  29. package/src/Processor/ChunkedRecords.js +3 -2
  30. package/src/Processor/EmissiveTriangleBuilder.js +6 -6
  31. package/src/Processor/GeometryExtractor.js +189 -79
  32. package/src/Processor/HostMemory.js +39 -9
  33. package/src/Processor/InstanceTable.js +714 -42
  34. package/src/Processor/PBRT/PBRTCurves.js +224 -6
  35. package/src/Processor/PBRT/PBRTMaterials.js +3 -0
  36. package/src/Processor/PBRT/PBRTSceneBuilder.js +82 -25
  37. package/src/Processor/PackedRayBuffer.js +5 -2
  38. package/src/Processor/SceneProcessor.js +747 -141
  39. package/src/Processor/TLASBuilder.js +32 -17
  40. package/src/Processor/ToneMapCPU.js +16 -4
  41. package/src/Processor/ToneMapGPU.js +30 -14
  42. package/src/Processor/USD/USDLayer.js +1149 -0
  43. package/src/Processor/USD/USDScene.js +1655 -0
  44. package/src/Processor/USD/USDStage.js +682 -0
  45. package/src/Processor/USD/USDText.js +674 -0
  46. package/src/Processor/USD/index.js +138 -0
  47. package/src/Processor/Workers/BVHRefitWorker.js +3 -3
  48. package/src/RayzeeRenderer.js +221 -33
  49. package/src/RenderSettings.js +4 -2
  50. package/src/Stages/AutoExposure.js +484 -494
  51. package/src/Stages/Compositor.js +27 -6
  52. package/src/Stages/LocalExposure.js +546 -0
  53. package/src/Stages/NormalDepth.js +7 -12
  54. package/src/Stages/PathTracer.js +18 -21
  55. package/src/Stages/PathTracerStage.js +282 -103
  56. package/src/Storage/GeometrySpill.js +46 -14
  57. package/src/Storage/SceneGraphCodec.js +17 -3
  58. package/src/Storage/SpillStore.js +9 -0
  59. package/src/TSL/BVHTraversal.js +183 -7
  60. package/src/TSL/Common.js +57 -13
  61. package/src/TSL/ShadeKernel.js +3 -2
  62. package/src/TSL/TextureSampling.js +11 -8
  63. package/src/TSL/patches.js +84 -0
  64. package/src/index.js +1 -0
  65. package/src/integrators/BidirectionalIntegrator.js +20 -2
  66. package/src/managers/DenoisingManager.js +15 -19
  67. package/src/managers/InteractionManager.js +19 -6
  68. package/src/managers/MaterialDataManager.js +22 -5
  69. package/src/managers/UniformManager.js +3 -0
  70. package/src/managers/VideoRenderManager.js +30 -4
  71. package/src/neural/NeuralRetouch.js +1 -0
  72. package/src/neural/NeuralSuperRes.js +6 -1
  73. package/dist/ArchiveImporter-D_x863yu.js +0 -5198
  74. package/dist/BidirectionalIntegrator-DmkxxEOR.js +0 -946
  75. package/dist/SolarLimb-CPaeZVwz.js +0 -721
  76. package/dist/sceneCachePolicy-C4-4qwt6.js +0 -485
@@ -3,22 +3,22 @@ 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, isIdentity, multiplyAffine } from './InstanceTable.js';
6
+ import { InstanceTable, IN_PLACE, isIdentity, multiplyAffine } from './InstanceTable.js';
7
7
  import { ChunkedRecords, SHARED_MEMORY_AVAILABLE, setChunkObserver, DEFAULT_CHUNK_BYTES } from './ChunkedRecords.js';
8
8
  import {
9
- MemoryLedger, estimateSceneBytes, probeAddressSpace,
9
+ MemoryLedger, estimateSceneBytes, spillingPeakBytes, probeAddressSpace,
10
10
  PREFLIGHT_MIN_BYTES, PREFLIGHT_SAFETY, SAFE_SCENE_BYTES, MAX_SCENE_BYTES,
11
11
  } from './HostMemory.js';
12
12
  import { TextureCreator } from './TextureCreator.js';
13
- import { GeometryExtractor, geometryBytesOf } from './GeometryExtractor.js';
13
+ import { GeometryExtractor, geometryBytesOf, usesTextureCoordinates } from './GeometryExtractor.js';
14
14
  import { EmissiveTriangleBuilder } from './EmissiveTriangleBuilder.js';
15
15
  import { updateLoading } from '../Processor/utils.js';
16
16
  import { BuildTimer } from './BuildTimer.js';
17
17
  import { createLogger, fmt, workerLogLevel } from '../utils/Logger.js';
18
- import { SRGBColorSpace } from 'three';
18
+ import { Box3, Matrix4, SRGBColorSpace, Sphere, Vector3 } from 'three';
19
19
  import {
20
20
  TRIANGLE_DATA_LAYOUT, packNormalOct, TRI_MATERIAL_MASK, BVH_LEAF_MARKERS, BVH_FOLDED_FIRST_LIMIT,
21
- assertBVHIndexFits, TLAS_PLACEMENT_MASK,
21
+ assertBVHIndexFits, TLAS_PLACEMENT_MASK, RECORD_VEC4, CLUSTER_FIRST_MASK, TLAS_LEAF_IDENTITY,
22
22
  } from './BufferLayout.js';
23
23
  import { TEXTURE_CONSTANTS, getTextureBucketId, packTextureIndex, planTextureBuckets } from './TextureBuckets.js';
24
24
  import { ISSUE_CODES } from '../EngineIssues.js';
@@ -47,8 +47,17 @@ const SPLIT_PIECE_TRIANGLES = 1 << 19;
47
47
  // Past this many stored triangles, small leaves are folded into their parents: the BVH halves,
48
48
  // at up to 4 % of traversal time, so only a scene whose BVH nears 2 GB pays it.
49
49
  const FOLD_LEAVES_TRIANGLES = 40_000_000;
50
+ // Past this many TLAS entries the TLAS is built over copy clusters (BufferLayout CLUSTER_LEAF): 128 → ~80 B a copy on the
51
+ // GPU, and a quarter of the TLAS build's memory. Below it the old leaves stay, and with them the old code.
52
+ const CLUSTER_MIN_ENTRIES = 1_000_000;
50
53
  // A streamed build's extraction waits while more triangle records than this are in memory.
51
- const STREAM_RESIDENT_BYTES = 1.5 * 1024 ** 3;
54
+ const STREAM_RESIDENT_BYTES = 1024 ** 3;
55
+ // Less three.js geometry than this comes back at the end of a spilling build: left on disk it costs picking and the
56
+ // selection outline, and 24155522.glb's 140 MB was not worth that.
57
+ const GEOMETRY_ON_DISK_BYTES = 1024 ** 3;
58
+ // A spilled build's order maps go to disk in writes of this size and come back in reads of the other.
59
+ const ORDER_PACK_BYTES = 32 * 1024 * 1024;
60
+ const ORDER_WINDOW_BYTES = 64 * 1024 * 1024;
52
61
  // Extraction waits while more three.js geometry than this is queued for disk.
53
62
  const GEOMETRY_QUEUE_BYTES = 256 * 1024 * 1024;
54
63
 
@@ -87,6 +96,35 @@ function largestMeshTriangles( object ) {
87
96
  * SceneProcessor - Processes scene geometry into GPU-ready data:
88
97
  * BVH acceleration, texture atlas, material buffers.
89
98
  */
99
+ /** Fills a TLAS child's bounds left NaN from the box its parent stores for it (slot: its min at 0–2, max at 4–6). */
100
+ // A move's TLAS upload: touched nodes this close share a range, and past MAX_TLAS_RANGES ranges one spans them all.
101
+ const TLAS_RANGE_GAP = 1024;
102
+ const MAX_TLAS_RANGES = 64;
103
+
104
+ /** Float ranges of the BVH covering `nodes`, for an upload. */
105
+ function nodeRanges( nodes ) {
106
+
107
+ const sorted = Int32Array.from( nodes ).sort();
108
+ const out = [];
109
+ let lo = sorted[ 0 ], hi = lo;
110
+ for ( let k = 1; k <= sorted.length; k ++ ) {
111
+
112
+ if ( k < sorted.length && sorted[ k ] - hi <= TLAS_RANGE_GAP ) {
113
+
114
+ hi = sorted[ k ];
115
+ continue;
116
+
117
+ }
118
+
119
+ out.push( { offset: lo * 16, count: ( hi - lo + 1 ) * 16 } );
120
+ lo = hi = sorted[ k ];
121
+
122
+ }
123
+
124
+ return out.length > MAX_TLAS_RANGES ? [ { offset: sorted[ 0 ] * 16, count: ( sorted.at( - 1 ) - sorted[ 0 ] + 1 ) * 16 } ] : out;
125
+
126
+ }
127
+
90
128
  export class SceneProcessor {
91
129
 
92
130
  /**
@@ -257,6 +295,20 @@ export class SceneProcessor {
257
295
 
258
296
  }
259
297
 
298
+ /** Whether a scene built in memory would pass SAFE_SCENE_BYTES, so `memorySpill: 'auto'` builds it through disk. */
299
+ needsSpill( object ) {
300
+
301
+ return estimateSceneBytes( this.geometryExtractor.surveyScene( object ) ).total > SAFE_SCENE_BYTES;
302
+
303
+ }
304
+
305
+ /** Whether the current scene goes to disk: built to spill (see {@link buildBVH}'s `progressive`) and over a chunk. */
306
+ get spilling() {
307
+
308
+ return !! this._progressive && ( this.triangles?.chunks?.length ?? 0 ) > 1;
309
+
310
+ }
311
+
260
312
  /**
261
313
  * Price the scene before the build starts spending, and say so if it is over what a healthy
262
314
  * session can place.
@@ -276,15 +328,17 @@ export class SceneProcessor {
276
328
  const maxBytes = this.config.maxSceneBytes ?? MAX_SCENE_BYTES;
277
329
  const survey = this.geometryExtractor.surveyScene( object );
278
330
  const estimate = estimateSceneBytes( survey );
279
- // Spilled as it is built, the BVH is never resident as a whole.
280
- if ( this._progressive ) estimate.total -= estimate.bvh;
281
- // Streamed, neither are the triangles: what is held is the resident cap plus the largest
282
- // mesh in the parallel builder — its copy and 44 B a triangle of scratch, the store's own
283
- // chunks for it let go meanwhile.
331
+ // Streamed, the triangles held are the resident cap plus the largest mesh in the parallel builder — its copy
332
+ // and 44 B a triangle of scratch, the store's own chunks for it let go meanwhile — and the peak is one phase.
284
333
  if ( this._streaming ) {
285
334
 
286
335
  const resident = STREAM_RESIDENT_BYTES + largestMeshTriangles( object ) * ( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE * 4 + 44 );
287
- estimate.total -= Math.max( 0, estimate.triangles - resident );
336
+ estimate.total = spillingPeakBytes( estimate, survey.placements, Math.min( estimate.triangles, resident ) );
337
+
338
+ } else if ( this._progressive ) {
339
+
340
+ // Spilled as it is built, the BVH is never resident as a whole.
341
+ estimate.total -= estimate.bvh;
288
342
 
289
343
  }
290
344
 
@@ -379,7 +433,7 @@ export class SceneProcessor {
379
433
  triangles: this.triangles?.byteLength ?? 0,
380
434
  bvh: this.bvh?.byteLength ?? 0,
381
435
  geometry: this._geometryBytes ?? this.memoryPreflight?.geometryBytes ?? 0,
382
- placements: table?.world?.byteLength ?? 0,
436
+ placements: table?.matrixBytes ?? 0,
383
437
  blasScratch: 0,
384
438
  orderMaps: 0,
385
439
  };
@@ -410,6 +464,7 @@ export class SceneProcessor {
410
464
  sceneBounds() {
411
465
 
412
466
  if ( ! this.bvh || this.bvh.recordCount === 0 ) return null;
467
+ if ( ! this.bvh.isResident( 0, 1 ) ) return this._spilledRootBounds ?? null;
413
468
 
414
469
  const chunk = this.bvh.chunkFor( 0 );
415
470
  const base = this.bvh.baseOf( 0 );
@@ -482,6 +537,9 @@ export class SceneProcessor {
482
537
 
483
538
  try {
484
539
 
540
+ // A rebuild of the same model reads its geometry and matrix lists back first: _reset drops the files.
541
+ if ( this._geometryOwner === object ) await Promise.all( [ this._restoreGeometry( object ), this.ensureMovable() ] );
542
+
485
543
  // Reset state before beginning
486
544
  this._reset();
487
545
  this._sceneKey = sceneKey;
@@ -492,6 +550,8 @@ export class SceneProcessor {
492
550
  // A spilling build of more than one chunk extracts and builds its BLASes together.
493
551
  this._streaming = !! this._progressive && stored > Math.floor( DEFAULT_CHUNK_BYTES / ( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE * 4 ) );
494
552
  this._foldLeaves = stored > FOLD_LEAVES_TRIANGLES;
553
+ // No material samples a texture: the GPU keeps no texture coordinates (24 B a triangle).
554
+ this.textureCoordinates = usesTextureCoordinates( object );
495
555
  this.bvhBuilder.foldLeaves = this._foldLeaves;
496
556
  this._log( 'Starting scene processing' );
497
557
 
@@ -570,7 +630,12 @@ export class SceneProcessor {
570
630
  timer.print();
571
631
 
572
632
  this.processingStage = 'complete';
573
- await this._restoreGeometry( object );
633
+ // A spilling build leaves a large model's geometry on disk until something reads it (ensureGeometryResident).
634
+ if ( this._streaming && this._geometrySpill?.bytes >= GEOMETRY_ON_DISK_BYTES ) {
635
+
636
+ this._geometryOwner = object;
637
+
638
+ } else await this._restoreGeometry( object );
574
639
  updateLoading( { status: "Scene data ready", progress: 85 } );
575
640
  if ( this._blasKey && ! this._blasRestored && this.performanceMetrics.totalProcessingTime >= this.config.sceneCacheMinBuildMs ) {
576
641
 
@@ -594,7 +659,7 @@ export class SceneProcessor {
594
659
  } finally {
595
660
 
596
661
  // A failed build's model is discarded, so its geometry is not read back.
597
- this._disposeGeometrySpill();
662
+ if ( ! this._geometryOwner ) this._disposeGeometrySpill();
598
663
  setChunkObserver( null );
599
664
  this._sampleMemory( `at ${this.processingStage}` );
600
665
  this._logMemoryReport();
@@ -661,7 +726,7 @@ export class SceneProcessor {
661
726
  */
662
727
  async _extractStreaming( object ) {
663
728
 
664
- updateLoading( { isLoading: true, title: 'Processing', status: 'Extracting geometry and building BVHs...', progress: 15 } );
729
+ updateLoading( { isLoading: true, title: 'Processing', status: 'Large scene: building through disk to save memory...', progress: 15 } );
665
730
  await new Promise( r => setTimeout( r, 0 ) );
666
731
  const startTime = performance.now();
667
732
 
@@ -756,7 +821,7 @@ export class SceneProcessor {
756
821
 
757
822
  tri = store;
758
823
  this._setTriangleData( store );
759
- state.uploadTriangles = uploader( store, 'triangles' );
824
+ state.uploadTriangles = uploader( store, 'triangles', { textureCoordinates: this.textureCoordinates } );
760
825
 
761
826
  },
762
827
 
@@ -836,8 +901,9 @@ export class SceneProcessor {
836
901
  // once per placement; storage counts it once.
837
902
  this.expandedTriangleCount = extractedData.expandedTriangleCount ?? extractedData.triangleCount;
838
903
  this.instanceSource = extractedData.instanceSource || null;
839
- this.instanceMatrices = extractedData.instanceMatrices || null;
904
+ this.matrixRuns = extractedData.matrixRuns || null;
840
905
  this.instanceCount = extractedData.instanceCount || 0;
906
+ this.instanceGroups = extractedData.instanceGroups ?? [];
841
907
  this.bakeInverse = extractedData.bakeInverse || null;
842
908
 
843
909
  this._log( `Using Float32Array format: ${this.triangleCount} triangles, ${( this.triangles.byteLength / ( 1024 * 1024 ) ).toFixed( 2 )}MB` );
@@ -910,12 +976,17 @@ export class SceneProcessor {
910
976
  const pooled = this.instanceCount > 0;
911
977
  const meshCount = pooled ? this.instanceCount : ranges.length;
912
978
  const instSource = this.instanceSource;
913
- const worldPool = pooled ? this.instanceMatrices : null;
979
+ const worldPool = pooled ? this.matrixRuns : null;
914
980
  const sourceOf = m => ( pooled ? instSource[ m ] : m );
915
981
 
916
982
  this.instanceTable = new InstanceTable();
917
983
  this.instanceTable.allocate( meshCount, ranges.length, worldPool, pooled ? instSource : null );
918
984
  this.instanceTable.tplBakeInverse = this.bakeInverse;
985
+ this.instanceTable.setGroups( pooled ? this.instanceGroups : null );
986
+ const clustered = this._clusterCopies( this.instanceTable );
987
+ if ( clustered ) this.instanceTable.planClusters();
988
+ const tlasEntries = clustered ? this.instanceTable.clusterCount : this.instanceTable.entryCount;
989
+ const blasBase = TLASBuilder.nodeCountFor( tlasEntries ) + this.instanceTable.groupNodeCount;
919
990
 
920
991
  // Built while the geometry was extracted (the streaming path): only adopt the results.
921
992
  const streamed = this._streamed;
@@ -927,8 +998,9 @@ export class SceneProcessor {
927
998
  const streamedTasks = [];
928
999
 
929
1000
  // Placements that reuse another's triangles reuse its BLAS too — the whole point of
930
- // storing geometry in object space. Only the first placement of a range is built.
931
- const aliasOf = new Map();
1001
+ // storing geometry in object space. Only the first placement of a range is built. A column, not a Map: a Map
1002
+ // holds at most 2^24 entries, and the Moana island has 51M placements.
1003
+ const aliasOf = new Int32Array( meshCount ).fill( - 1 );
932
1004
  const ownerOfRange = new Map();
933
1005
 
934
1006
  for ( let m = 0; m < meshCount; m ++ ) {
@@ -939,7 +1011,7 @@ export class SceneProcessor {
939
1011
  const owner = ownerOfRange.get( range.start );
940
1012
  if ( owner !== undefined ) {
941
1013
 
942
- aliasOf.set( m, owner );
1014
+ aliasOf[ m ] = owner;
943
1015
  continue;
944
1016
 
945
1017
  }
@@ -969,7 +1041,7 @@ export class SceneProcessor {
969
1041
  const owners = [ ...poolTasks, ...parallelTasks ]
970
1042
  .map( ( { m, range } ) => ( { m, range, t: sourceOf( m ), triOffset: range.start, triCount: range.count } ) )
971
1043
  .sort( ( a, b ) => a.t - b.t );
972
- const restored = streamed ? null : await this._restoreBLASes( owners, TLASBuilder.nodeCountFor( meshCount ), workerOpts );
1044
+ const restored = streamed ? null : await this._restoreBLASes( owners, blasBase, workerOpts );
973
1045
  const spill = streamed ? streamed.state
974
1046
  : ! restored && this._progressive && this.triangles.chunks.length > 1 ? await this._beginProgressiveSpill( owners ) : null;
975
1047
  const onBuilt = spill && ! streamed ? ( m, range, result ) => this._onBLASBuilt( spill, sourceOf( m ), range, result ) : null;
@@ -1043,22 +1115,22 @@ export class SceneProcessor {
1043
1115
  triCount: range.count,
1044
1116
  originalToBvhMap: result.originalToBvh || null,
1045
1117
  bvhData: result.bvhData,
1046
- matrixWorld: pooled ? worldPool : ( this.meshes?.[ m ]?.matrixWorld?.elements ?? null ),
1047
- matrixOffset: pooled ? m * 16 : 0,
1118
+ matrixWorld: pooled ? IN_PLACE : ( this.meshes?.[ m ]?.matrixWorld?.elements ?? null ),
1048
1119
  expandedStart: range.expandedStart,
1049
1120
  sourceMesh: sourceOf( m ),
1050
1121
  } );
1051
1122
 
1052
1123
  }
1053
1124
 
1054
- for ( const [ m, owner ] of aliasOf ) {
1125
+ for ( let m = 0; m < meshCount; m ++ ) {
1055
1126
 
1127
+ const owner = aliasOf[ m ];
1128
+ if ( owner < 0 ) continue;
1056
1129
  this.instanceTable.setAlias(
1057
1130
  m, owner,
1058
- pooled ? worldPool : ( this.meshes?.[ m ]?.matrixWorld?.elements ?? null ),
1131
+ pooled ? IN_PLACE : ( this.meshes?.[ m ]?.matrixWorld?.elements ?? null ),
1059
1132
  ranges[ sourceOf( m ) ].expandedStart,
1060
- sourceOf( m ),
1061
- pooled ? m * 16 : 0
1133
+ sourceOf( m )
1062
1134
  );
1063
1135
 
1064
1136
  }
@@ -1081,13 +1153,35 @@ export class SceneProcessor {
1081
1153
  // overhead (one extra leaf fetch per ray) is negligible and eliminates
1082
1154
  // a dedicated visibility storage buffer binding.
1083
1155
  this.instanceTable.computeAABBs( this.triangles, { owners: ! spill } );
1156
+ this.instanceTable.computeGroupAABBs();
1157
+ if ( clustered ) {
1158
+
1159
+ // Each entry's world box picks its cluster and sizes its byte box; dropped once the leaves are written.
1160
+ table.clusterWorld = new Float32Array( table.entryCount * 6 );
1161
+ table.writeEntryWorldAABBs( table.clusterWorld );
1162
+ table.clusterBounds = table.formClusters( table.clusterWorld );
1163
+
1164
+ }
1084
1165
 
1085
1166
  // Node count is exact up front (every leaf holds one entry), so BLAS offsets can be
1086
1167
  // assigned before the build and the TLAS is written in a single pass.
1087
- this.instanceTable.assignOffsets( TLASBuilder.nodeCountFor( table.count ) );
1168
+ this.instanceTable.assignOffsets( TLASBuilder.nodeCountFor( tlasEntries ) );
1088
1169
  const totalNodes = this.instanceTable.totalNodeCount;
1089
1170
 
1090
1171
  let tlasData = await this._buildTLAS( table );
1172
+ if ( this._streaming && clustered ) {
1173
+
1174
+ const world = table.clusterWorld;
1175
+ this._spillMeshBounds = this._instancedMeshBounds( ( e, out ) => {
1176
+
1177
+ for ( let k = 0; k < 6; k ++ ) out[ k ] = world[ e * 6 + k ];
1178
+
1179
+ } );
1180
+
1181
+ }
1182
+
1183
+ table.clusterWorld = null;
1184
+ table.clusterBounds = null;
1091
1185
  this.performanceMetrics.tlasBuildTime = performance.now() - tlasStart;
1092
1186
 
1093
1187
  // Assemble combined buffer: [TLAS][BLAS_0][BLAS_1]...[BLAS_M]
@@ -1099,8 +1193,9 @@ export class SceneProcessor {
1099
1193
  // reaches them and each BLAS is released as it lands, never both fully resident.
1100
1194
  if ( ! spill ) this._checkAssemblyHeadroom( totalNodes );
1101
1195
  this._setBVHData( ChunkedRecords.lazy( totalNodes, 16, Float32Array, undefined, SHARED_MEMORY_AVAILABLE ) );
1102
- const flushBVH = spill ? await this._beginBVHSpill( spill, table.tlasNodeCount ) : null;
1196
+ const flushBVH = spill ? await this._beginBVHSpill( spill, table.blasBase ) : null;
1103
1197
  this.bvh.setRecords( 0, tlasData );
1198
+ if ( table.groupNodeCount ) this.bvh.setRecords( table.groupNodeStart, table.groupNodeBlock() );
1104
1199
  // Hundreds of megabytes at millions of placements, and the chunks below need the room
1105
1200
  // more than a later refit needs the cache.
1106
1201
  tlasData = null;
@@ -1117,7 +1212,7 @@ export class SceneProcessor {
1117
1212
  try {
1118
1213
 
1119
1214
  const readStart = performance.now();
1120
- await readNodesInto( restored.nodes, this.bvh, table.tlasNodeCount, totalNodes - table.tlasNodeCount );
1215
+ await readNodesInto( restored.nodes, this.bvh, table.blasBase, table.totalBLASNodes );
1121
1216
  this.performanceMetrics.blasRestore.readMs = performance.now() - readStart;
1122
1217
 
1123
1218
  } finally {
@@ -1153,11 +1248,13 @@ export class SceneProcessor {
1153
1248
 
1154
1249
  }
1155
1250
 
1251
+ if ( clustered ) await this._writeCopyRecords( flushBVH );
1156
1252
  await flushBVH?.( totalNodes, true );
1157
1253
  if ( spill ) await spill.blasStore.dispose();
1158
1254
 
1159
1255
  }
1160
1256
 
1257
+ if ( clustered && restored ) await this._writeCopyRecords( null );
1161
1258
  this._setBVHData( this.bvh.materializeAll() );
1162
1259
 
1163
1260
  if ( restored ) for ( const [ t, map ] of restored.orders ) table.bvhToOriginal.set( t, map );
@@ -1307,7 +1404,7 @@ export class SceneProcessor {
1307
1404
  * @returns {Promise<?{results: Array, orders: Map<number, Uint32Array>, nodes: File, release: function(): void}>}
1308
1405
  * @private
1309
1406
  */
1310
- async _restoreBLASes( owners, tlasNodeCount, workerOpts ) {
1407
+ async _restoreBLASes( owners, blasBase, workerOpts ) {
1311
1408
 
1312
1409
  if ( ! this._sceneKey || this.config.sceneCache === false ) return null;
1313
1410
  const storage = sharedStorage( getAssetConfig().cacheNamespace );
@@ -1326,9 +1423,10 @@ export class SceneProcessor {
1326
1423
  };
1327
1424
 
1328
1425
  const { index } = cached;
1329
- if ( index.tlasNodeCount !== tlasNodeCount || index.templates.length !== owners.length ) return miss( 'layout changed' );
1426
+ // `tlasNodeCount` is where the stored BLASes start: past the TLAS and any group trees.
1427
+ if ( index.tlasNodeCount !== blasBase || index.templates.length !== owners.length ) return miss( 'layout changed' );
1330
1428
 
1331
- let offset = tlasNodeCount;
1429
+ let offset = blasBase;
1332
1430
  for ( let i = 0; i < owners.length; i ++ ) {
1333
1431
 
1334
1432
  const [ t, triOffset, triCount, nodeCount, blasOffset ] = index.templates[ i ];
@@ -1479,7 +1577,7 @@ export class SceneProcessor {
1479
1577
 
1480
1578
  } );
1481
1579
 
1482
- const index = { v: BLAS_CACHE_FORMAT, tlasNodeCount: table.tlasNodeCount, totalNodes: table.totalNodeCount, templates };
1580
+ const index = { v: BLAS_CACHE_FORMAT, tlasNodeCount: table.blasBase, totalNodes: table.treeNodeCount, templates };
1483
1581
  const stored = await saveBLASCache( storage, key, { index, bvh: this.bvh, orders, isStale, label } );
1484
1582
  if ( stored ) log.debug( `stored ${fmt.n( owners.length )} BLASes for the next load` );
1485
1583
  return stored;
@@ -1524,7 +1622,7 @@ export class SceneProcessor {
1524
1622
  return ( m?.emissiveIntensity ?? 0 ) > 0 && !! m.emissive && ( m.emissive.r > 0 || m.emissive.g > 0 || m.emissive.b > 0 );
1525
1623
 
1526
1624
  },
1527
- uploadTriangles: uploader( tri, 'triangles' ),
1625
+ uploadTriangles: uploader( tri, 'triangles', { textureCoordinates: this.textureCoordinates } ),
1528
1626
  chain: Promise.resolve(),
1529
1627
  error: null,
1530
1628
  };
@@ -1589,7 +1687,7 @@ export class SceneProcessor {
1589
1687
  const tri = this.triangles;
1590
1688
  const chunk = tri.chunks[ k ];
1591
1689
  if ( ! chunk ) return;
1592
- state.uploadTriangles( k );
1690
+ await state.uploadTriangles( k );
1593
1691
 
1594
1692
  const lanes = tri.lanesPerRecord;
1595
1693
  for ( let o = TRIANGLE_DATA_LAYOUT.MATERIAL_FLAGS_OFFSET; o < chunk.length; o += lanes ) {
@@ -1636,6 +1734,7 @@ export class SceneProcessor {
1636
1734
  const upload = uploader( bvh );
1637
1735
  const tlasChunks = Math.ceil( tlasNodeCount / bvh.recordsPerChunk );
1638
1736
  if ( bvhStore ) this._spillStores.push( bvhStore );
1737
+ this._bvhSpillStore = bvhStore;
1639
1738
  let next = tlasChunks;
1640
1739
 
1641
1740
  return async ( filledTo, last = false ) => {
@@ -1643,7 +1742,7 @@ export class SceneProcessor {
1643
1742
  while ( next < bvh.chunks.length && ( last || ( next + 1 ) * bvh.recordsPerChunk <= filledTo ) ) {
1644
1743
 
1645
1744
  if ( ! bvh.chunks[ next ] ) bvh._materialize( next );
1646
- upload( next );
1745
+ await upload( next );
1647
1746
  if ( bvhStore ) await bvh.spillChunk( next, bvhStore );
1648
1747
  next ++;
1649
1748
 
@@ -1652,7 +1751,7 @@ export class SceneProcessor {
1652
1751
  if ( last ) for ( let k = 0; k < Math.min( tlasChunks, bvh.chunks.length ); k ++ ) {
1653
1752
 
1654
1753
  if ( ! bvh.chunks[ k ] ) bvh._materialize( k );
1655
- upload( k );
1754
+ await upload( k );
1656
1755
 
1657
1756
  }
1658
1757
 
@@ -2217,6 +2316,10 @@ export class SceneProcessor {
2217
2316
  this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2218
2317
  this._disposeSpill();
2219
2318
  this._disposeGeometrySpill();
2319
+ this._disposeMatrixSpill();
2320
+ this._orderSpill?.store.dispose().catch( () => {} );
2321
+ this._orderSpill = null;
2322
+ this._spilledAfterLoad = false;
2220
2323
 
2221
2324
  // First dispose any existing resources
2222
2325
  this._disposeTextures();
@@ -2569,7 +2672,6 @@ export class SceneProcessor {
2569
2672
  async refitBVH( newPositions, newNormals ) {
2570
2673
 
2571
2674
  this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2572
- this._tlasLeafBoxesOf = null;
2573
2675
 
2574
2676
  if ( ! this.bvh || ! this.triangles || ! this.instanceTable ) {
2575
2677
 
@@ -2673,7 +2775,11 @@ export class SceneProcessor {
2673
2775
  };
2674
2776
 
2675
2777
  // Signal worker — no data transfer needed, everything is in shared memory
2676
- this._refitWorker.postMessage( { type: 'refit', blasRanges: partial ? blasRanges : null, tlasNodeCount } );
2778
+ this._refitWorker.postMessage( {
2779
+ type: 'refit', blasRanges: partial ? blasRanges : null, tlasNodeCount,
2780
+ groupRange: table.groupNodeCount ? [ table.groupNodeStart, table.groupNodeCount ] : null,
2781
+ treeNodeCount: table.treeNodeCount, recordNodeStart: table.clusterCount ? table.recordNodeStart : - 1,
2782
+ } );
2677
2783
 
2678
2784
  } );
2679
2785
 
@@ -2793,7 +2899,8 @@ export class SceneProcessor {
2793
2899
 
2794
2900
  }
2795
2901
 
2796
- // Step 2: Refit TLAS AABBs in-place (O(tlasNodeCount), no SAH rebuild)
2902
+ // Step 2: group trees over the refit BLASes, then the TLAS AABBs in place (no SAH rebuild)
2903
+ this._writeGroupNodes();
2797
2904
  this._refitTLAS();
2798
2905
 
2799
2906
  return { refitTimeMs: performance.now() - start };
@@ -2823,11 +2930,9 @@ export class SceneProcessor {
2823
2930
  const start = performance.now();
2824
2931
  const table = this.instanceTable;
2825
2932
  const composed = this._transformScratch ??= new Float32Array( 16 );
2826
- if ( this._movedPlacements?.length !== table.count ) this._movedPlacements = new Uint8Array( table.count );
2827
- const movedMask = this._movedPlacements;
2828
- let moved = 0;
2933
+ const leaves = [];
2829
2934
 
2830
- for ( const meshIndex of meshIndices ) {
2935
+ for ( const meshIndex of this._withGroupSiblings( meshIndices ) ) {
2831
2936
 
2832
2937
  const mesh = this.meshes?.[ meshIndex ];
2833
2938
  const run = table.placementRunOf( meshIndex );
@@ -2835,7 +2940,7 @@ export class SceneProcessor {
2835
2940
 
2836
2941
  mesh.updateMatrixWorld( true );
2837
2942
  const world = mesh.matrixWorld.elements;
2838
- const instances = mesh.isInstancedMesh ? this._ownInstanceMatrices( mesh ) : null;
2943
+ const instances = mesh.isInstancedMesh ? mesh.instanceMatrix?.array ?? null : null;
2839
2944
  // Baked triangles hold the pose they were extracted at; the leaf only carries the delta.
2840
2945
  const bakeInverse = table.tplBakeInverse?.get( table.sourceMesh[ run.start ] ) ?? null;
2841
2946
 
@@ -2861,61 +2966,159 @@ export class SceneProcessor {
2861
2966
  }
2862
2967
 
2863
2968
  this._writeLeafMatrix( p );
2864
- movedMask[ p ] = 1;
2865
- moved ++;
2969
+ const leaf = table.leafOfPlacement( p );
2970
+ if ( leaf >= 0 ) leaves.push( leaf );
2866
2971
 
2867
2972
  }
2868
2973
 
2869
2974
  }
2870
2975
 
2871
- this._refitTLAS( movedMask );
2872
- movedMask.fill( 0 );
2976
+ this._refitTLAS( leaves );
2873
2977
 
2874
- return { refitTimeMs: performance.now() - start, placements: moved };
2978
+ return { refitTimeMs: performance.now() - start, placements: leaves.length };
2875
2979
 
2876
2980
  }
2877
2981
 
2878
2982
  /**
2879
- * An InstancedMesh's own instance matrices, split off the placement pool if they still alias it.
2880
- *
2881
- * The extractor points a host-at-origin InstancedMesh straight at the pool, since world and
2882
- * instance matrices hold identical bytes there. Composing a moved host back into the pool would
2883
- * overwrite the matrices it just read, so the next move would compose onto its own result.
2983
+ * A grouped mesh's parts are traced as one object, so a part moved alone takes its siblings with it: each sibling
2984
+ * is given the first moved member's world transform, and all of them are moved.
2884
2985
  * @private
2885
2986
  */
2886
- _ownInstanceMatrices( mesh ) {
2987
+ _withGroupSiblings( meshIndices ) {
2988
+
2989
+ const table = this.instanceTable;
2990
+ if ( ! table.groups.length ) return meshIndices;
2991
+
2992
+ const out = new Set( meshIndices );
2993
+ const synced = new Set();
2994
+ const parentInverse = this._parentInverse ??= new Matrix4();
2995
+
2996
+ for ( const meshIndex of meshIndices ) {
2997
+
2998
+ const g = table.groupOfTemplate[ meshIndex ] ?? - 1;
2999
+ if ( g < 0 || synced.has( g ) ) continue;
3000
+ synced.add( g );
2887
3001
 
2888
- const attr = mesh.instanceMatrix;
2889
- if ( ! attr?.array ) return null;
3002
+ const mover = this.meshes?.[ meshIndex ];
3003
+ if ( ! mover ) continue;
3004
+ mover.updateMatrixWorld( true );
2890
3005
 
2891
- if ( attr.array.buffer === this.instanceTable.world.buffer ) {
3006
+ for ( const sibling of table.groups[ g ].members ) {
2892
3007
 
2893
- attr.array = attr.array.slice();
2894
- attr.needsUpdate = true;
3008
+ out.add( sibling );
3009
+ const mesh = this.meshes[ sibling ];
3010
+ if ( ! mesh || mesh === mover ) continue;
3011
+ if ( mesh.parent ) mesh.parent.updateMatrixWorld( true );
3012
+ mesh.matrix.copy( mover.matrixWorld );
3013
+ if ( mesh.parent ) mesh.matrix.premultiply( parentInverse.copy( mesh.parent.matrixWorld ).invert() );
3014
+ mesh.matrix.decompose( mesh.position, mesh.quaternion, mesh.scale );
3015
+ mesh.updateMatrixWorld( true );
3016
+
3017
+ }
2895
3018
 
2896
3019
  }
2897
3020
 
2898
- return attr.array;
3021
+ return out;
2899
3022
 
2900
3023
  }
2901
3024
 
2902
- /** Refresh one TLAS leaf's world-to-object matrix from the table. @private */
3025
+ /** Refresh one TLAS leaf's world-to-object matrix from the table — its copy record, when clustered. @private */
2903
3026
  _writeLeafMatrix( placement ) {
2904
3027
 
2905
- const node = this.instanceTable.tlasLeafIndex[ placement ];
3028
+ const table = this.instanceTable;
3029
+ const node = table.tlasLeafIndex[ placement ];
2906
3030
  if ( node < 0 ) return;
2907
3031
 
3032
+ if ( table.clusterCount ) {
3033
+
3034
+ this._writeCopyRecord( node, table.repOf( table.entryOf( placement ) ) );
3035
+ return;
3036
+
3037
+ }
3038
+
2908
3039
  TLASBuilder.writeLeafMatrix(
2909
3040
  this.bvh.chunkFor( node ), this.bvhIndexChunks.chunkFor( node ), this.bvh.baseOf( node ),
2910
- this.instanceTable.world, placement
3041
+ table, placement, table.entryOf( placement )
2911
3042
  );
2912
3043
 
2913
3044
  }
2914
3045
 
2915
- /** The TLAS occupies the front of the BVH buffer and is the only part a move rewrites. */
3046
+ /**
3047
+ * Rewrites copy record `r` from placement `p`'s transform, and its identity bit (a moved baked placement stops being
3048
+ * identity); records the range for the next upload. @private
3049
+ */
3050
+ _writeCopyRecord( r, p ) {
3051
+
3052
+ const table = this.instanceTable;
3053
+ const rows = this._recordScratch ??= new Float32Array( 12 );
3054
+ table.writeRecord( rows, 0, p );
3055
+ const lane = table.recordNodeStart * 16 + r * RECORD_VEC4 * 4;
3056
+ for ( let i = 0; i < 12; i ++ ) {
3057
+
3058
+ const node = ( lane + i ) >> 4;
3059
+ this.bvh.chunkFor( node )[ this.bvh.baseOf( node ) + ( ( lane + i ) & 15 ) ] = rows[ i ];
3060
+
3061
+ }
3062
+
3063
+ const leaf = table.clusterLeaf[ table.clusterOf( r ) ];
3064
+ const idx = this.bvhIndexChunks.chunkFor( leaf ), o = this.bvh.baseOf( leaf ) + table.copyWordSlot( r );
3065
+ const identity = isIdentity( table.matrixWorldOf( p ) );
3066
+ idx[ o ] = ( ( idx[ o ] & ~ TLAS_LEAF_IDENTITY ) | ( identity ? TLAS_LEAF_IDENTITY : 0 ) ) >>> 0;
3067
+
3068
+ // One range a chunk: a mesh's copies sit all over the records, and a spilled scene has only their chunks back.
3069
+ const moved = this._movedRecords ??= new Map();
3070
+ const lanesPerChunk = this.bvh.recordsPerChunk * 16;
3071
+ for ( let at = lane, end = lane + 12; at < end; ) {
3072
+
3073
+ const k = Math.floor( at / lanesPerChunk ), stop = Math.min( end, ( k + 1 ) * lanesPerChunk );
3074
+ const range = moved.get( k );
3075
+ if ( range ) {
3076
+
3077
+ range.lo = Math.min( range.lo, at );
3078
+ range.hi = Math.max( range.hi, stop );
3079
+
3080
+ } else moved.set( k, { lo: at, hi: stop } );
3081
+ at = stop;
3082
+
3083
+ }
3084
+
3085
+ }
3086
+
3087
+ /**
3088
+ * What the last {@link updateMeshTransforms} rewrote, as float ranges of the BVH for an upload: the TLAS nodes its
3089
+ * refit touched (all of them after a full refit) and the copy records.
3090
+ */
3091
+ takeMoveRanges() {
3092
+
3093
+ const tlas = this._tlasRanges ?? [ this.computeTLASDirtyRange() ];
3094
+ this._tlasRanges = null;
3095
+ return [ ...tlas, ...this.takeMovedRecordRanges() ];
3096
+
3097
+ }
3098
+
3099
+ /** The copy records rewritten since the last call, as float ranges of the BVH for an upload, one a chunk. */
3100
+ takeMovedRecordRanges() {
3101
+
3102
+ const moved = this._movedRecords;
3103
+ this._movedRecords = null;
3104
+ return moved ? [ ...moved.values() ].map( r => ( { offset: r.lo, count: r.hi - r.lo } ) ) : [];
3105
+
3106
+ }
3107
+
3108
+ /** Rewrites the group trees from the members' current bounds and visibility. @private */
3109
+ _writeGroupNodes() {
3110
+
3111
+ const table = this.instanceTable;
3112
+ if ( ! table?.groupNodeCount || ! this.bvh ) return;
3113
+ table.computeGroupAABBs();
3114
+ this.bvh.setRecords( table.groupNodeStart, table.groupNodeBlock() );
3115
+
3116
+ }
3117
+
3118
+ /** The TLAS and the group trees occupy the front of the BVH buffer, the only part a move rewrites. */
2916
3119
  computeTLASDirtyRange() {
2917
3120
 
2918
- return { offset: 0, count: this.instanceTable.tlasNodeCount * 16 };
3121
+ return { offset: 0, count: this.instanceTable.blasBase * 16 };
2919
3122
 
2920
3123
  }
2921
3124
 
@@ -2944,8 +3147,8 @@ export class SceneProcessor {
2944
3147
 
2945
3148
  }
2946
3149
 
2947
- // Always include TLAS range (rebuilt on every refit)
2948
- bvhRanges.push( { offset: 0, count: this.instanceTable.tlasNodeCount * FPN } );
3150
+ // Always include the TLAS and group trees (rebuilt on every refit)
3151
+ bvhRanges.push( { offset: 0, count: this.instanceTable.blasBase * FPN } );
2949
3152
 
2950
3153
  return { triRanges, bvhRanges };
2951
3154
 
@@ -2962,6 +3165,24 @@ export class SceneProcessor {
2962
3165
  * @param {{keepUserLights?: boolean}} [options] - an incremental rebuild keeps the lights a host added
2963
3166
  * @returns {boolean} false if critical data is missing
2964
3167
  */
3168
+ /**
3169
+ * Uploads the triangle and BVH stores chunk by chunk, each awaited ({@link PathTracerStage#drainUploads}), so
3170
+ * {@link uploadToPathTracer} adopts them rather than send a scene's worth at once. A store of one chunk, or one a
3171
+ * spilling build already uploaded, is left to it.
3172
+ * @param {function(Object, string=, Object=): function(number): Promise<void>} uploader
3173
+ */
3174
+ async uploadChunks( uploader ) {
3175
+
3176
+ for ( const [ records, kind ] of [[ this.triangles, 'triangles' ], [ this.bvh, 'bvh' ]] ) {
3177
+
3178
+ if ( ! records?.chunks || records.chunks.length < 2 || records._gpuUpload ) continue;
3179
+ const upload = uploader( records, kind, { textureCoordinates: this.textureCoordinates } );
3180
+ for ( let k = 0; k < records.chunks.length; k ++ ) if ( records.chunks[ k ] ) await upload( k );
3181
+
3182
+ }
3183
+
3184
+ }
3185
+
2965
3186
  uploadToPathTracer( pathTracer, lightManager, meshScene, environmentTexture, { keepUserLights = false } = {} ) {
2966
3187
 
2967
3188
  if ( ! this.triangles ) {
@@ -2971,7 +3192,7 @@ export class SceneProcessor {
2971
3192
 
2972
3193
  }
2973
3194
 
2974
- pathTracer.setTriangleData( this.triangles, this.triangleCount );
3195
+ pathTracer.setTriangleData( this.triangles, this.triangleCount, { textureCoordinates: this.textureCoordinates } );
2975
3196
 
2976
3197
  if ( ! this.bvh ) {
2977
3198
 
@@ -3160,7 +3381,7 @@ export class SceneProcessor {
3160
3381
  /** Whether any triangle records or BLAS nodes are on disk (see {@link spillToDisk}). */
3161
3382
  get spilled() {
3162
3383
 
3163
- return !! ( this.triangles?.spilledChunks || this.bvh?.spilledChunks );
3384
+ return !! ( this.triangles?.spilledChunks || this.bvh?.spilledChunks || this._orderSpill );
3164
3385
 
3165
3386
  }
3166
3387
 
@@ -3194,7 +3415,7 @@ export class SceneProcessor {
3194
3415
  }
3195
3416
 
3196
3417
  this._spillStores = [ triStore, bvhStore ];
3197
- const tlasChunks = Math.ceil( ( this.instanceTable?.tlasNodeCount ?? 1 ) / bvh.recordsPerChunk );
3418
+ const tlasChunks = Math.ceil( ( this.instanceTable?.blasBase ?? 1 ) / bvh.recordsPerChunk );
3198
3419
 
3199
3420
  let bytes = await triangles.spill( triStore );
3200
3421
  bytes += await bvh.spill( bvhStore, { keep: k => k < tlasChunks } );
@@ -3208,13 +3429,14 @@ export class SceneProcessor {
3208
3429
 
3209
3430
  }
3210
3431
 
3211
- /** Reads every spilled triangle record and BLAS node back, then drops the scratch files. */
3432
+ /** Reads every spilled triangle record, BLAS node and order map back, then drops the scratch files. */
3212
3433
  ensureResident() {
3213
3434
 
3214
3435
  this._paging ??= ( async () => {
3215
3436
 
3216
3437
  await this.triangles?.ensureResident?.();
3217
3438
  await this.bvh?.ensureResident?.();
3439
+ await this._restoreOrderMaps();
3218
3440
  if ( ! this.spilled ) this._disposeSpill();
3219
3441
 
3220
3442
  } )().finally( () => {
@@ -3227,12 +3449,350 @@ export class SceneProcessor {
3227
3449
 
3228
3450
  }
3229
3451
 
3452
+ /** Whether the model's three.js geometry is on disk: its arrays are empty until {@link ensureGeometryResident}. */
3453
+ get geometryOnDisk() {
3454
+
3455
+ return !! this._geometryOwner;
3456
+
3457
+ }
3458
+
3459
+ /** Reads back the three.js geometry and matrix lists a spilling build left on disk. */
3460
+ ensureGeometryResident() {
3461
+
3462
+ const geometry = this._geometryOwner ? this._geometryReading ??= this._restoreGeometry( this._geometryOwner ).finally( () => {
3463
+
3464
+ this._geometryReading = null;
3465
+
3466
+ } ) : null;
3467
+ return Promise.all( [ geometry, this.ensureMovable() ] );
3468
+
3469
+ }
3470
+
3471
+ /** Whether the InstancedMesh matrix lists the instance table reads are on disk (a move reads them back). */
3472
+ get matricesOnDisk() {
3473
+
3474
+ return !! this._matrixSpill;
3475
+
3476
+ }
3477
+
3478
+ /**
3479
+ * Reads back what moving `meshIndices` needs that a spilling build left on disk: the matrix lists, the TLAS and the
3480
+ * copy records of those meshes. Without meshes, the matrix lists alone.
3481
+ */
3482
+ async ensureMovable( meshIndices = [] ) {
3483
+
3484
+ this._movePageIns = ( this._movePageIns ?? 0 ) + 1;
3485
+ try {
3486
+
3487
+ await this._afterLoadSpill;
3488
+ const spill = this._matrixSpill;
3489
+ if ( spill ) await ( this._matrixReading ??= spill.restore().then( () => this._disposeMatrixSpill() ).finally( () => {
3490
+
3491
+ this._matrixReading = null;
3492
+
3493
+ } ) );
3494
+
3495
+ const table = this.instanceTable, bvh = this.bvh;
3496
+ if ( ! meshIndices.length || ! table || ! bvh?.spilledChunks ) return;
3497
+ await bvh.ensureResident( 0, table.blasBase );
3498
+ for ( const k of this._recordChunksOf( meshIndices ) ) await bvh.ensureResident( k * bvh.recordsPerChunk, 1 );
3499
+
3500
+ } finally {
3501
+
3502
+ this._movePageIns --;
3503
+
3504
+ }
3505
+
3506
+ }
3507
+
3508
+ /**
3509
+ * A spilling build's matrix lists go to disk as well — 2.4 GB on the Moana island; only a move reads them. Each
3510
+ * InstancedMesh gets its bounds from its copies' boxes first, so three.js never derives them from the empty list;
3511
+ * `again`, only one without bounds yet.
3512
+ * @private
3513
+ */
3514
+ async _spillMatrices( again = false ) {
3515
+
3516
+ const table = this.instanceTable;
3517
+ const lists = table?.adoptedMatrices() ?? [];
3518
+ const bounds = this._spillMeshBounds;
3519
+ this._spillMeshBounds = null;
3520
+ if ( ! lists.length || ! this._progressive?.storage ) return;
3521
+
3522
+ const spill = await GeometrySpill.create( this._progressive.storage, `spill:${Date.now().toString( 36 )}:${Math.random().toString( 36 ).slice( 2 )}:mat` );
3523
+ if ( ! spill ) return;
3524
+
3525
+ for ( const [ mesh, b ] of bounds ?? this._instancedMeshBounds( ( e, out ) => table.writeEntryWorldAABB( e, out, 0 ), again ) ) {
3526
+
3527
+ mesh.boundingBox = new Box3( new Vector3( b[ 0 ], b[ 1 ], b[ 2 ] ), new Vector3( b[ 3 ], b[ 4 ], b[ 5 ] ) );
3528
+ mesh.boundingSphere = mesh.boundingBox.getBoundingSphere( new Sphere() );
3529
+
3530
+ }
3531
+
3532
+ const bytes = lists.reduce( ( n, a ) => n + a.array.byteLength, 0 );
3533
+ spill.addAttributes( lists );
3534
+ this._matrixSpill = spill;
3535
+ log.info( `${fmt.mb( bytes )} of instance matrices left for disk` );
3536
+
3537
+ }
3538
+
3539
+ /**
3540
+ * A spilling build's TLAS goes to disk too when copies are clustered — 1.3 GB on the Moana island — and any chunk a
3541
+ * move read back (its copy records), keeping only the chunks over the group trees, which a visibility change
3542
+ * rewrites. An edit reads them back first (the stage's `_whenTLASResident`, {@link ensureMovable}).
3543
+ * @private
3544
+ */
3545
+ async _spillTLAS() {
3546
+
3547
+ const table = this.instanceTable, bvh = this.bvh, store = this._bvhSpillStore;
3548
+ if ( ! store || ! table?.clusterCount ) return;
3549
+ const per = bvh.recordsPerChunk;
3550
+ const groupFirst = ( table.groupNodeStart / per ) | 0;
3551
+ const groupLast = ( ( table.groupNodeStart + Math.max( 1, table.groupNodeCount ) - 1 ) / per ) | 0;
3552
+ this._spilledRootBounds = this.sceneBounds();
3553
+ const bytes = await bvh.spill( store, { keep: k => table.groupNodeCount > 0 && k >= groupFirst && k <= groupLast } );
3554
+ log.info( `spilled ${fmt.mb( bytes )} of TLAS to disk` );
3555
+
3556
+ }
3557
+
3558
+ /**
3559
+ * A spilling build's triangle order maps go to disk too (0.43 GB on the Moana island): only refits and BLAS swaps
3560
+ * read them, after {@link ensureResident}, which reads them back. Packed into 32 MB writes.
3561
+ * @private
3562
+ */
3563
+ async _spillOrderMaps() {
3564
+
3565
+ const table = this.instanceTable, storage = this._progressive?.storage;
3566
+ if ( ! table?.bvhToOriginal.size || ! storage ) return;
3567
+ let bytes = 0;
3568
+ for ( const map of table.bvhToOriginal.values() ) bytes += map.byteLength;
3569
+ const store = await SpillStore.create( storage, `spill:${Date.now().toString( 36 )}:${Math.random().toString( 36 ).slice( 2 )}:order`, 0, { label: 'Triangle order maps', expectedBytes: bytes } );
3570
+ if ( ! store ) return;
3571
+
3572
+ const index = [], pack = new Uint8Array( ORDER_PACK_BYTES );
3573
+ let at = 0, packed = 0;
3574
+ const flush = async () => {
3575
+
3576
+ if ( packed ) await store.writeAt( at - packed, pack.subarray( 0, packed ) );
3577
+ packed = 0;
3578
+
3579
+ };
3580
+
3581
+ for ( const [ t, map ] of table.bvhToOriginal ) {
3582
+
3583
+ const data = new Uint8Array( map.buffer, map.byteOffset, map.byteLength );
3584
+ index.push( t, at, map.length );
3585
+ if ( packed + data.length > pack.length ) await flush();
3586
+ if ( data.length > pack.length ) await store.writeAt( at, data );
3587
+ else {
3588
+
3589
+ pack.set( data, packed );
3590
+ packed += data.length;
3591
+
3592
+ }
3593
+
3594
+ at += data.length;
3595
+
3596
+ }
3597
+
3598
+ await flush();
3599
+ await store.flush();
3600
+ table.bvhToOriginal.clear();
3601
+ this._orderSpill = { store, index, bytes };
3602
+
3603
+ }
3604
+
3605
+ /** Reads the order maps {@link _spillOrderMaps} moved to disk back, in 64 MB windows. @private */
3606
+ async _restoreOrderMaps() {
3607
+
3608
+ const spill = this._orderSpill;
3609
+ if ( ! spill ) return;
3610
+ this._orderSpill = null;
3611
+ const { store, index, bytes } = spill, maps = this.instanceTable.bvhToOriginal;
3612
+ let windowAt = 0, window = null;
3613
+ for ( let i = 0; i < index.length; i += 3 ) {
3614
+
3615
+ const t = index[ i ], at = index[ i + 1 ], length = index[ i + 2 ] * 4;
3616
+ if ( length > ORDER_WINDOW_BYTES ) {
3617
+
3618
+ maps.set( t, new Uint32Array( await store.readAt( at, length ) ) );
3619
+ continue;
3620
+
3621
+ }
3622
+
3623
+ if ( ! window || at + length > windowAt + window.byteLength ) {
3624
+
3625
+ windowAt = at;
3626
+ window = await store.readAt( at, Math.min( ORDER_WINDOW_BYTES, bytes - at ) );
3627
+
3628
+ }
3629
+
3630
+ maps.set( t, new Uint32Array( window.slice( at - windowAt, at - windowAt + length ) ) );
3631
+
3632
+ }
3633
+
3634
+ await store.dispose();
3635
+
3636
+ }
3637
+
3638
+ /**
3639
+ * Once the load no longer needs them (initial visibility, emitters), a spilling build's matrix lists, TLAS and order
3640
+ * maps go to disk. Edits wait for it ({@link whenTLASEditable}, {@link ensureMovable}).
3641
+ */
3642
+ spillAfterLoad() {
3643
+
3644
+ if ( ! this._geometryOwner || this._spilledAfterLoad ) return this._afterLoadSpill ?? Promise.resolve();
3645
+ this._spilledAfterLoad = true;
3646
+ return this._spillUnused( false );
3647
+
3648
+ }
3649
+
3650
+ /**
3651
+ * Puts back on disk what edits have read back since ({@link ensureMovable}, {@link whenTLASEditable}) — 3.7 GB after a
3652
+ * move on the Moana island. The renderer calls it once edits have settled; skipped while one is reading.
3653
+ */
3654
+ respill() {
3655
+
3656
+ if ( ! this._geometryOwner || ! this._spilledAfterLoad || this._afterLoadSpill ) return this._afterLoadSpill ?? Promise.resolve();
3657
+ if ( this._matrixReading || this._tlasReading || this._movePageIns ) return Promise.resolve();
3658
+ return this._spillUnused( true );
3659
+
3660
+ }
3661
+
3662
+ /** @private */
3663
+ _spillUnused( again ) {
3664
+
3665
+ this._afterLoadSpill = ( async () => {
3666
+
3667
+ await this._spillMatrices( again );
3668
+ await this._spillTLAS();
3669
+ await this._spillOrderMaps();
3670
+
3671
+ } )().finally( () => {
3672
+
3673
+ this._afterLoadSpill = null;
3674
+
3675
+ } );
3676
+ return this._afterLoadSpill;
3677
+
3678
+ }
3679
+
3680
+ /** Null when the TLAS can be edited in memory now, else a promise for when it can: it is being spilled, or is on disk. */
3681
+ whenTLASEditable() {
3682
+
3683
+ if ( this._afterLoadSpill ) return this._afterLoadSpill.then( () => this.whenTLASEditable() );
3684
+ const bvh = this.bvh, table = this.instanceTable;
3685
+ if ( ! bvh || ! table || bvh.isResident( 0, table.blasBase ) ) return null;
3686
+ return this._tlasReading ??= bvh.ensureResident( 0, table.blasBase ).finally( () => {
3687
+
3688
+ this._tlasReading = null;
3689
+
3690
+ } );
3691
+
3692
+ }
3693
+
3694
+ /** Whether moving `meshIndices` has to read anything back from disk first ({@link ensureMovable}). */
3695
+ needsPageInForMove( meshIndices ) {
3696
+
3697
+ if ( this._matrixSpill || this._afterLoadSpill ) return true;
3698
+ const table = this.instanceTable, bvh = this.bvh;
3699
+ if ( ! table || ! bvh?.spilledChunks ) return false;
3700
+ if ( ! bvh.isResident( 0, table.blasBase ) ) return true;
3701
+ for ( const k of this._recordChunksOf( meshIndices ) ) if ( ! bvh.isResident( k * bvh.recordsPerChunk, 1 ) ) return true;
3702
+ return false;
3703
+
3704
+ }
3705
+
3706
+ /** The BVH chunks holding the copy records of `meshIndices` and their group siblings. @private */
3707
+ _recordChunksOf( meshIndices ) {
3708
+
3709
+ const table = this.instanceTable, per = this.bvh.recordsPerChunk, chunks = new Set();
3710
+ if ( ! table.clusterCount ) return chunks;
3711
+ const meshes = new Set( meshIndices );
3712
+ for ( const m of meshIndices ) {
3713
+
3714
+ const g = table.groupOfTemplate?.[ m ] ?? - 1;
3715
+ if ( g >= 0 ) for ( const member of table.groups[ g ].members ) meshes.add( member );
3716
+
3717
+ }
3718
+
3719
+ for ( const m of meshes ) {
3720
+
3721
+ const run = table.placementRunOf( m );
3722
+ if ( ! run ) continue;
3723
+ for ( let p = run.start; p < run.start + run.count; p ++ ) {
3724
+
3725
+ const r = table.tlasLeafIndex[ p ];
3726
+ if ( r < 0 ) continue;
3727
+ const lane = table.recordNodeStart * 16 + r * RECORD_VEC4 * 4;
3728
+ chunks.add( Math.floor( lane / 16 / per ) );
3729
+ chunks.add( Math.floor( ( lane + 11 ) / 16 / per ) );
3730
+
3731
+ }
3732
+
3733
+ }
3734
+
3735
+ return chunks;
3736
+
3737
+ }
3738
+
3739
+ /**
3740
+ * Each InstancedMesh the table reads in place, with its world bounds: the union of its copies' boxes, `boxOf( entry,
3741
+ * out )`. Its host is at the origin, so world is its own space. `missingOnly` skips a mesh that has bounds.
3742
+ * @private
3743
+ */
3744
+ _instancedMeshBounds( boxOf, missingOnly = false ) {
3745
+
3746
+ const table = this.instanceTable;
3747
+ const adopted = new Set( table.adoptedMatrices() );
3748
+ const out = new Map();
3749
+ const box = new Float64Array( 6 );
3750
+ ( this.meshes ?? [] ).forEach( ( mesh, m ) => {
3751
+
3752
+ if ( ! mesh?.isInstancedMesh || ! adopted.has( mesh.instanceMatrix ) || ( missingOnly && mesh.boundingBox ) ) return;
3753
+ const run = table.placementRunOf( m );
3754
+ if ( ! run ) return;
3755
+ const b = [ Infinity, Infinity, Infinity, - Infinity, - Infinity, - Infinity ];
3756
+ for ( let p = run.start; p < run.start + run.count; p ++ ) {
3757
+
3758
+ boxOf( table.entryOf( p ), box );
3759
+ for ( let a = 0; a < 3; a ++ ) {
3760
+
3761
+ if ( box[ a ] < b[ a ] ) b[ a ] = box[ a ];
3762
+ if ( box[ 3 + a ] > b[ 3 + a ] ) b[ 3 + a ] = box[ 3 + a ];
3763
+
3764
+ }
3765
+
3766
+ }
3767
+
3768
+ out.set( mesh, b );
3769
+
3770
+ } );
3771
+ return out;
3772
+
3773
+ }
3774
+
3775
+ /** @private */
3776
+ _disposeMatrixSpill() {
3777
+
3778
+ const spill = this._matrixSpill;
3779
+ this._matrixSpill = null;
3780
+ spill?.dispose().catch( () => {} );
3781
+
3782
+ }
3783
+
3230
3784
  /** Reads the three.js geometry a streamed build moved to disk back into place. @private */
3231
3785
  async _restoreGeometry( object ) {
3232
3786
 
3233
3787
  const spill = this._geometrySpill;
3234
- if ( ! spill?.bytes ) return;
3235
- updateLoading( { status: 'Reading scene geometry back...', progress: 84 } );
3788
+ if ( ! spill?.bytes ) {
3789
+
3790
+ this._geometryOwner = null;
3791
+ return;
3792
+
3793
+ }
3794
+
3795
+ if ( ! this._geometryOwner ) updateLoading( { status: 'Reading scene geometry back...', progress: 84 } );
3236
3796
  const started = performance.now();
3237
3797
  const bytes = spill.bytes;
3238
3798
  await spill.restore();
@@ -3248,6 +3808,7 @@ export class SceneProcessor {
3248
3808
 
3249
3809
  const spill = this._geometrySpill;
3250
3810
  this._geometrySpill = null;
3811
+ this._geometryOwner = null;
3251
3812
  spill?.dispose().catch( () => {} );
3252
3813
 
3253
3814
  }
@@ -3281,9 +3842,43 @@ export class SceneProcessor {
3281
3842
  * never a scene.
3282
3843
  * @private
3283
3844
  */
3845
+ /** Whether to build the TLAS over copy clusters: `config.clusterCopies`, or by default past CLUSTER_MIN_ENTRIES. @private */
3846
+ _clusterCopies( table ) {
3847
+
3848
+ const choice = this.config.clusterCopies ?? 'auto';
3849
+ return choice === 'auto' ? table.entryCount >= CLUSTER_MIN_ENTRIES : choice === true;
3850
+
3851
+ }
3852
+
3853
+ /**
3854
+ * The copy records behind the BLASes: each entry's world-to-object rows in cluster order, four records to three
3855
+ * nodes, written a batch at a time and handed to the spill as the fill passes its chunks. @private
3856
+ */
3857
+ async _writeCopyRecords( flushBVH ) {
3858
+
3859
+ const table = this.instanceTable;
3860
+ const n = table.entryCount;
3861
+ const batch = 4096; // records, a multiple of 4
3862
+ const nodes = batch * RECORD_VEC4 / 4;
3863
+ const stage = new Float32Array( nodes * 16 );
3864
+ for ( let r = 0; r < n; r += batch ) {
3865
+
3866
+ const count = Math.min( batch, n - r );
3867
+ stage.fill( 0 );
3868
+ for ( let i = 0; i < count; i ++ ) table.writeRecord( stage, i * RECORD_VEC4 * 4, table.repOf( table.recordEntry[ r + i ] ) );
3869
+ const at = table.recordNodeStart + r * RECORD_VEC4 / 4;
3870
+ const used = Math.ceil( count * RECORD_VEC4 / 4 );
3871
+ this.bvh.setRecords( at, used === nodes ? stage : stage.subarray( 0, used * 16 ) );
3872
+ await flushBVH?.( at + used );
3873
+
3874
+ }
3875
+
3876
+ }
3877
+
3284
3878
  async _buildTLAS( table ) {
3285
3879
 
3286
- const n = table.count;
3880
+ const clustered = table.clusterCount > 0;
3881
+ const n = clustered ? table.clusterCount : table.entryCount;
3287
3882
 
3288
3883
  // Below this the build is a few ms and the round trip costs more than it saves.
3289
3884
  if ( n >= TLAS_WORKER_MIN_ENTRIES && ! this._tlasWorkerFailed ) {
@@ -3291,8 +3886,8 @@ export class SceneProcessor {
3291
3886
  try {
3292
3887
 
3293
3888
  // The worker needs a buffer it can own, and world bounds are derived anyway.
3294
- const aabbs = new Float64Array( n * 6 );
3295
- table.writeWorldAABBs( aabbs );
3889
+ const aabbs = clustered ? table.clusterBounds : new Float64Array( n * 6 );
3890
+ if ( ! clustered ) table.writeEntryWorldAABBs( aabbs );
3296
3891
  const { tlasData, nodeCount } = await this._runTLASWorker( aabbs, n );
3297
3892
  TLASBuilder.fillLeaves( tlasData, nodeCount, table );
3298
3893
  return tlasData;
@@ -3350,16 +3945,15 @@ export class SceneProcessor {
3350
3945
  /** Keep the chunked BVH and its u32 index view in step. @private */
3351
3946
  _setBVHData( data ) {
3352
3947
 
3353
- this._tlasLeafBoxesOf = null;
3354
-
3355
3948
  const records = ! data ? null
3356
3949
  : ( data instanceof ChunkedRecords
3357
3950
  ? data
3358
3951
  : ChunkedRecords.adopt( [ data ], data.length / 16, 16, data.length / 16 ) );
3359
3952
 
3360
3953
  this.bvh = records;
3361
- // Read by the traversal when it is built: only a folded BVH gets that code.
3954
+ // Read by the traversal when it is built: only a folded BVH gets that code, only a clustered one the copy records.
3362
3955
  if ( records && this._foldLeaves ) records.foldedLeaves = true;
3956
+ if ( records && this.instanceTable?.clusterCount ) records.copyRecordBase = this.instanceTable.recordNodeStart * 4;
3363
3957
  this.bvhIndexChunks = records ? records.viewAs( Uint32Array ) : null;
3364
3958
  // Null once the BVH needs more than one chunk; hot paths use `bvh` / `bvhIndexChunks`.
3365
3959
  this.bvhData = records ? records.single : null;
@@ -3531,83 +4125,95 @@ export class SceneProcessor {
3531
4125
  }
3532
4126
 
3533
4127
  /**
3534
- * Refit TLAS AABBs in-place without rebuilding the tree structure.
3535
- * O(tlasNodeCount) bottom-up pass — much faster than full SAH rebuild.
3536
- * @param {Uint8Array} [moved] - by placement: only these leaves' world boxes are recomputed, the
3537
- * rest kept from the last pass. Every leaf when omitted, or when nothing valid was kept.
4128
+ * Refit the TLAS boxes in place, top down. A node keeps each child's box in its own slots, so a subtree holding none
4129
+ * of `leaves` is skipped: a move costs its leaves' paths to the root, not the tree (moving 69,856 copies on the Moana
4130
+ * island, 20M nodes: 11.6 s → 93 ms). Every leaf when `leaves` is omitted.
4131
+ * @param {ArrayLike<number>} [leaves] - TLAS leaf nodes whose boxes changed, in any order
3538
4132
  * @private
3539
4133
  */
3540
- _refitTLAS( moved = null ) {
4134
+ _refitTLAS( leaves = null ) {
3541
4135
 
3542
- const tlasNodeCount = this.instanceTable.tlasNodeCount;
4136
+ const table = this.instanceTable;
4137
+ const count = table.tlasNodeCount;
4138
+ const marks = leaves ? Int32Array.from( leaves ).sort() : null;
4139
+ this._tlasRanges = marks ? [] : null;
4140
+ if ( ! count || marks?.length === 0 ) return;
4141
+ const touched = marks ? Array.from( marks ) : null;
3543
4142
 
3544
- // Grow-only bounds buffer for TLAS refit
3545
- if ( ! this._tlasBounds || this._tlasBounds.length < tlasNodeCount * 6 ) {
4143
+ // Frames of node, end of its subtree (pre-order: [node, end)), parent slot (parent * 2 + side, -1 at the root),
4144
+ // and whether its children are done.
4145
+ let stack = this._refitStack ??= new Int32Array( 256 );
4146
+ let top = 0, cursor = 0;
4147
+ const box = this._refitBox ??= new Float32Array( 6 );
4148
+ const push = ( node, end, slot, done ) => {
3546
4149
 
3547
- this._tlasBounds = new Float32Array( tlasNodeCount * 6 );
3548
- this._tlasLeafBoxesOf = null;
4150
+ if ( top + 4 > stack.length ) {
3549
4151
 
3550
- }
4152
+ const grown = new Int32Array( stack.length * 2 );
4153
+ grown.set( stack );
4154
+ stack = this._refitStack = grown;
3551
4155
 
3552
- const table = this.instanceTable;
3553
- const keep = moved && this._tlasLeafBoxesOf === table;
4156
+ }
4157
+
4158
+ stack[ top ++ ] = node; stack[ top ++ ] = end; stack[ top ++ ] = slot; stack[ top ++ ] = done;
4159
+
4160
+ };
4161
+
4162
+ push( 0, count, - 1, 0 );
4163
+ while ( top ) {
4164
+
4165
+ const done = stack[ -- top ], slot = stack[ -- top ], end = stack[ -- top ], i = stack[ -- top ];
4166
+ const f = this.bvh.chunkFor( i ), o = this.bvh.baseOf( i );
3554
4167
 
3555
- // Bottom-up pass: reverse iteration over TLAS nodes
3556
- for ( let i = tlasNodeCount - 1; i >= 0; i -- ) {
4168
+ if ( done ) {
3557
4169
 
3558
- const idxChunk = this.bvhIndexChunks.chunkFor( i );
3559
- const fChunk = this.bvh.chunkFor( i );
3560
- const o = this.bvh.baseOf( i );
3561
- const marker = idxChunk[ o + 3 ];
4170
+ box[ 0 ] = Math.min( f[ o ], f[ o + 8 ] ); box[ 1 ] = Math.min( f[ o + 1 ], f[ o + 9 ] ); box[ 2 ] = Math.min( f[ o + 2 ], f[ o + 10 ] );
4171
+ box[ 3 ] = Math.max( f[ o + 4 ], f[ o + 12 ] ); box[ 4 ] = Math.max( f[ o + 5 ], f[ o + 13 ] ); box[ 5 ] = Math.max( f[ o + 6 ], f[ o + 14 ] );
3562
4172
 
3563
- if ( marker === BVH_LEAF_MARKERS.BLAS_POINTER_LEAF ) {
4173
+ } else {
3564
4174
 
3565
- // Slot [1] is this leaf's own placement. Keying off slot [0] instead collapsed
3566
- // every placement of a shared geometry onto one box, so all but one copy sat
3567
- // outside its own bounds and rays walked straight past it.
3568
- const entryIndex = idxChunk[ o + 1 ] & TLAS_PLACEMENT_MASK;
3569
- if ( entryIndex < table.count && ! ( keep && ! moved[ entryIndex ] ) ) {
4175
+ if ( marks ) {
3570
4176
 
3571
- table.writeWorldAABB( entryIndex, this._tlasBounds, i * 6 );
4177
+ while ( cursor < marks.length && marks[ cursor ] < i ) cursor ++;
4178
+ if ( cursor === marks.length || marks[ cursor ] >= end ) continue;
3572
4179
 
3573
4180
  }
3574
4181
 
3575
- } else if ( marker < BVH_LEAF_MARKERS.TRIANGLE_LEAF ) {
3576
-
3577
- // Inner node: union of children bounds, update bvhData in-place
3578
- const leftIdx = marker;
3579
- const rightIdx = idxChunk[ o + 7 ];
3580
- const lb = leftIdx * 6;
3581
- const rb = rightIdx * 6;
3582
- const bounds = this._tlasBounds;
3583
-
3584
- fChunk[ o ] = bounds[ lb ];
3585
- fChunk[ o + 1 ] = bounds[ lb + 1 ];
3586
- fChunk[ o + 2 ] = bounds[ lb + 2 ];
3587
- fChunk[ o + 4 ] = bounds[ lb + 3 ];
3588
- fChunk[ o + 5 ] = bounds[ lb + 4 ];
3589
- fChunk[ o + 6 ] = bounds[ lb + 5 ];
3590
-
3591
- fChunk[ o + 8 ] = bounds[ rb ];
3592
- fChunk[ o + 9 ] = bounds[ rb + 1 ];
3593
- fChunk[ o + 10 ] = bounds[ rb + 2 ];
3594
- fChunk[ o + 12 ] = bounds[ rb + 3 ];
3595
- fChunk[ o + 13 ] = bounds[ rb + 4 ];
3596
- fChunk[ o + 14 ] = bounds[ rb + 5 ];
3597
-
3598
- const b = i * 6;
3599
- bounds[ b ] = Math.min( bounds[ lb ], bounds[ rb ] );
3600
- bounds[ b + 1 ] = Math.min( bounds[ lb + 1 ], bounds[ rb + 1 ] );
3601
- bounds[ b + 2 ] = Math.min( bounds[ lb + 2 ], bounds[ rb + 2 ] );
3602
- bounds[ b + 3 ] = Math.max( bounds[ lb + 3 ], bounds[ rb + 3 ] );
3603
- bounds[ b + 4 ] = Math.max( bounds[ lb + 4 ], bounds[ rb + 4 ] );
3604
- bounds[ b + 5 ] = Math.max( bounds[ lb + 5 ], bounds[ rb + 5 ] );
4182
+ const idx = this.bvhIndexChunks.chunkFor( i );
4183
+ const marker = idx[ o + 3 ];
4184
+ if ( marker === BVH_LEAF_MARKERS.BLAS_POINTER_LEAF ) {
4185
+
4186
+ // Slot [1] is this leaf's own entry. Keying off slot [0] instead collapsed every placement of a shared
4187
+ // geometry onto one box, so all but one copy sat outside its own bounds and rays walked past it.
4188
+ const entry = idx[ o + 1 ] & TLAS_PLACEMENT_MASK;
4189
+ if ( entry >= table.entryCount ) continue;
4190
+ table.writeEntryWorldAABB( entry, box, 0 );
4191
+
4192
+ } else if ( marker === BVH_LEAF_MARKERS.CLUSTER_LEAF ) {
4193
+
4194
+ table.encodeClusterBoxes( f, idx, o, table.clusterOf( idx[ o ] & CLUSTER_FIRST_MASK ), null, box, 0 );
4195
+
4196
+ } else if ( marker < BVH_LEAF_MARKERS.TRIANGLE_LEAF ) {
4197
+
4198
+ const right = idx[ o + 7 ];
4199
+ push( i, end, slot, 1 );
4200
+ push( right, end, i * 2 + 1, 0 );
4201
+ push( marker, right, i * 2, 0 );
4202
+ continue;
4203
+
4204
+ } else continue;
3605
4205
 
3606
4206
  }
3607
4207
 
4208
+ if ( slot < 0 ) continue;
4209
+ const p = slot >> 1, pf = this.bvh.chunkFor( p ), po = this.bvh.baseOf( p ) + ( slot & 1 ) * 8;
4210
+ pf[ po ] = box[ 0 ]; pf[ po + 1 ] = box[ 1 ]; pf[ po + 2 ] = box[ 2 ];
4211
+ pf[ po + 4 ] = box[ 3 ]; pf[ po + 5 ] = box[ 4 ]; pf[ po + 6 ] = box[ 5 ];
4212
+ touched?.push( p );
4213
+
3608
4214
  }
3609
4215
 
3610
- this._tlasLeafBoxesOf = table;
4216
+ if ( touched ) this._tlasRanges = nodeRanges( touched );
3611
4217
 
3612
4218
  }
3613
4219