rayzee 8.8.2 → 9.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 (104) hide show
  1. package/README.md +210 -20
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-Dc6jRYke.js → NeuralSuperRes-CbNvQjCw.js} +5 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10795 -5324
  6. package/dist/rayzee.umd.js +279 -268
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +60 -76
  10. package/src/EngineEvents.js +7 -0
  11. package/src/EngineIssues.js +6 -0
  12. package/src/Headless.js +3 -1
  13. package/src/Passes/AIUpscaler.js +2 -1
  14. package/src/Passes/OIDNDenoiser.js +3 -6
  15. package/src/Passes/OIDNTemporalHistory.js +25 -7
  16. package/src/PathTracerApp.js +313 -25
  17. package/src/Processor/ArchiveCache.js +120 -0
  18. package/src/Processor/ArchiveReader.js +179 -32
  19. package/src/Processor/AssetLoader.js +523 -76
  20. package/src/Processor/AtmosphereModel.js +465 -0
  21. package/src/Processor/BVHBuilder.js +4 -1
  22. package/src/Processor/BVHLeafFold.js +100 -0
  23. package/src/Processor/BVHRefitter.js +98 -214
  24. package/src/Processor/ChunkedRecords.js +189 -4
  25. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  26. package/src/Processor/GeometryExtractor.js +238 -40
  27. package/src/Processor/InstanceTable.js +55 -37
  28. package/src/Processor/PBRT/PBRTCurves.js +104 -27
  29. package/src/Processor/PBRT/PBRTParser.js +132 -22
  30. package/src/Processor/PBRT/PBRTSceneBuilder.js +210 -55
  31. package/src/Processor/PBRT/buffers.js +27 -0
  32. package/src/Processor/PBRT/index.js +55 -15
  33. package/src/Processor/PackedRayBuffer.js +10 -13
  34. package/src/Processor/ParallelBVHBuilder.js +36 -15
  35. package/src/Processor/PhysicalSky.js +308 -0
  36. package/src/Processor/QueueManager.js +1 -0
  37. package/src/Processor/SceneProcessor.js +1132 -181
  38. package/src/Processor/SplitBLAS.js +249 -0
  39. package/src/Processor/SunPosition.js +41 -0
  40. package/src/Processor/TextureCreator.js +21 -0
  41. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  42. package/src/Processor/Workers/BVHWorker.js +17 -13
  43. package/src/Processor/ZipReader.js +207 -0
  44. package/src/RenderSettings.js +49 -8
  45. package/src/SceneState/SceneState.js +367 -0
  46. package/src/SceneState/portable.js +52 -0
  47. package/src/Stages/MotionVector.js +8 -42
  48. package/src/Stages/NRD.js +30 -21
  49. package/src/Stages/NormalDepth.js +12 -23
  50. package/src/Stages/PathTracer.js +114 -6
  51. package/src/Stages/PathTracerStage.js +251 -104
  52. package/src/Storage/BLASCache.js +190 -0
  53. package/src/Storage/CDFCache.js +77 -0
  54. package/src/Storage/DownloadCache.js +266 -0
  55. package/src/Storage/GeometrySpill.js +217 -0
  56. package/src/Storage/SceneGraphCodec.js +503 -0
  57. package/src/Storage/SpillStore.js +112 -0
  58. package/src/Storage/StorageManager.js +934 -0
  59. package/src/Storage/StorageOps.js +137 -0
  60. package/src/Storage/StorageWorker.js +37 -0
  61. package/src/Storage/events.js +29 -0
  62. package/src/Storage/identity.js +77 -0
  63. package/src/Storage/index.js +9 -0
  64. package/src/Storage/inlineTransport.js +24 -0
  65. package/src/Storage/locks.js +99 -0
  66. package/src/Storage/openStorage.js +87 -0
  67. package/src/Storage/sceneCachePolicy.js +12 -0
  68. package/src/Storage/shared.js +9 -0
  69. package/src/Storage/transport.js +62 -0
  70. package/src/TSL/Atmosphere.js +684 -0
  71. package/src/TSL/BVHTraversal.js +288 -239
  72. package/src/TSL/CameraRay.js +99 -14
  73. package/src/TSL/Common.js +11 -2
  74. package/src/TSL/DebugKernel.js +2 -2
  75. package/src/TSL/Displacement.js +6 -6
  76. package/src/TSL/EmissiveSampling.js +5 -5
  77. package/src/TSL/EnvironmentCDF.js +176 -0
  78. package/src/TSL/GenerateKernel.js +5 -5
  79. package/src/TSL/HitFacet.js +4 -4
  80. package/src/TSL/LightsDirect.js +8 -8
  81. package/src/TSL/LightsSampling.js +55 -37
  82. package/src/TSL/Random.js +1 -0
  83. package/src/TSL/ShadeKernel.js +75 -19
  84. package/src/TSL/Sun.js +40 -0
  85. package/src/TSL/TextureSampling.js +6 -7
  86. package/src/index.js +22 -0
  87. package/src/managers/CameraManager.js +412 -56
  88. package/src/managers/DenoisingManager.js +4 -3
  89. package/src/managers/EnvironmentManager.js +263 -33
  90. package/src/managers/GoboManager.js +19 -8
  91. package/src/managers/IESManager.js +3 -3
  92. package/src/managers/InteractionManager.js +30 -2
  93. package/src/managers/LightManager.js +98 -5
  94. package/src/managers/MaterialDataManager.js +22 -2
  95. package/src/managers/TransformManager.js +26 -0
  96. package/src/managers/UniformManager.js +11 -7
  97. package/src/managers/VideoRenderManager.js +38 -17
  98. package/src/managers/ViewCamera.js +77 -0
  99. package/src/managers/WalkControls.js +262 -0
  100. package/src/managers/helpers/OutlineHelper.js +22 -2
  101. package/src/managers/timeline/CameraTrack.js +235 -0
  102. package/src/managers/timeline/TimelineManager.js +146 -0
  103. package/src/version.js +3 -0
  104. package/src/Processor/ProceduralSky.js +0 -291
@@ -4,7 +4,7 @@ import { BVHRefitter } from './BVHRefitter.js';
4
4
  import { buildBVHParallel, shouldUseParallelBuild } from './ParallelBVHBuilder.js';
5
5
  import { TLASBuilder } from './TLASBuilder.js';
6
6
  import { InstanceTable, isIdentity, multiplyAffine } from './InstanceTable.js';
7
- import { ChunkedRecords, SHARED_MEMORY_AVAILABLE, setChunkObserver } from './ChunkedRecords.js';
7
+ import { ChunkedRecords, SHARED_MEMORY_AVAILABLE, setChunkObserver, DEFAULT_CHUNK_BYTES } from './ChunkedRecords.js';
8
8
  import {
9
9
  MemoryLedger, estimateSceneBytes, probeAddressSpace,
10
10
  PREFLIGHT_MIN_BYTES, PREFLIGHT_SAFETY, SAFE_SCENE_BYTES, MAX_SCENE_BYTES,
@@ -18,8 +18,17 @@ import { createLogger, fmt, workerLogLevel } from '../utils/Logger.js';
18
18
  import { SRGBColorSpace } from 'three';
19
19
  import {
20
20
  TRIANGLE_DATA_LAYOUT, TEXTURE_CONSTANTS, getTextureBucketId, packTextureIndex, planTextureBuckets,
21
- packNormalOct, BVH_LEAF_MARKERS, assertBVHIndexFits, bvhIndexView, TLAS_PLACEMENT_MASK } from '../EngineDefaults.js';
21
+ packNormalOct, BVH_LEAF_MARKERS, BVH_FOLDED_FIRST_LIMIT, assertBVHIndexFits, bvhIndexView, TLAS_PLACEMENT_MASK, TRI_MATERIAL_MASK } from '../EngineDefaults.js';
22
22
  import { ISSUE_CODES } from '../EngineIssues.js';
23
+ import { BLAS_CACHE_FORMAT, openBLASCache, saveBLASCache, templateChecksums, readNodesInto, readOrder } from '../Storage/BLASCache.js';
24
+ import { sharedStorage } from '../Storage/shared.js';
25
+ import { SpillStore } from '../Storage/SpillStore.js';
26
+ import { GeometrySpill } from '../Storage/GeometrySpill.js';
27
+ import { partitionRange, joinPieces } from './SplitBLAS.js';
28
+ import { rebaseNodes } from './BVHLeafFold.js';
29
+ import { getAssetConfig } from '../AssetConfig.js';
30
+ import { VERSION } from '../version.js';
31
+ import { SCENE_CACHE_MIN_BUILD_MS } from '../Storage/sceneCachePolicy.js';
23
32
  import BVHWorker from './Workers/BVHWorker.js?worker&inline';
24
33
  import BVHRefitWorker from './Workers/BVHRefitWorker.js?worker&inline';
25
34
  import TLASWorker from './Workers/TLASWorker.js?worker&inline';
@@ -27,8 +36,50 @@ import TLASWorker from './Workers/TLASWorker.js?worker&inline';
27
36
  // Under this the TLAS build is a few ms; the worker round trip would cost more than it saves.
28
37
  const TLAS_WORKER_MIN_ENTRIES = 50_000;
29
38
 
39
+ // Meshes past this build across every core, one at a time.
40
+ const LARGE_MESH_TRIANGLES = 200000;
41
+ // Past this a mesh is built as spatial pieces of at most SPLIT_PIECE_TRIANGLES, joined into one BLAS.
42
+ const SPLIT_MESH_TRIANGLES = 2_000_000;
43
+ const SPLIT_PIECE_TRIANGLES = 1 << 19;
44
+ // Past this many stored triangles, small leaves are folded into their parents: the BVH halves,
45
+ // at up to 4 % of traversal time, so only a scene whose BVH nears 2 GB pays it.
46
+ const FOLD_LEAVES_TRIANGLES = 40_000_000;
47
+ // A streamed build's extraction waits while more triangle records than this are in memory.
48
+ const STREAM_RESIDENT_BYTES = 1.5 * 1024 ** 3;
49
+ // Extraction waits while more three.js geometry than this is queued for disk.
50
+ const GEOMETRY_QUEUE_BYTES = 256 * 1024 * 1024;
51
+
30
52
  const log = createLogger( 'scene' );
31
53
 
54
+ // A BLAS's nodes: one array, or a split mesh's parts in order — never one array that large.
55
+ const nodeParts = nodes => ( Array.isArray( nodes ) ? nodes : [ nodes ] );
56
+ const nodeBytes = nodes => nodeParts( nodes ).reduce( ( sum, part ) => sum + part.byteLength, 0 );
57
+ const READ_PART_BYTES = 64 * 1024 * 1024;
58
+
59
+ async function writeNodes( store, at, nodes ) {
60
+
61
+ for ( const part of nodeParts( nodes ) ) {
62
+
63
+ await store.writeAt( at, part );
64
+ at += part.byteLength;
65
+
66
+ }
67
+
68
+ }
69
+
70
+ function largestMeshTriangles( object ) {
71
+
72
+ let largest = 0;
73
+ object.traverse( node => {
74
+
75
+ const g = node.isMesh ? node.geometry : null;
76
+ if ( g ) largest = Math.max( largest, Math.ceil( ( g.index ? g.index.count : ( g.attributes.position?.count ?? 0 ) ) / 3 ) );
77
+
78
+ } );
79
+ return largest;
80
+
81
+ }
82
+
32
83
  /**
33
84
  * SceneProcessor - Processes scene geometry into GPU-ready data:
34
85
  * BVH acceleration, texture atlas, material buffers.
@@ -72,6 +123,9 @@ export class SceneProcessor {
72
123
  treeletMinImprovement: 0.01, // Minimum SAH improvement threshold
73
124
  // Above this triangle count the builder drops treelets to size 3.
74
125
  treeletComplexityThreshold: 50000,
126
+ // Store the per-mesh BVHs of a scene whose build took at least this long (needs a sceneKey).
127
+ sceneCache: true,
128
+ sceneCacheMinBuildMs: SCENE_CACHE_MIN_BUILD_MS,
75
129
  ...options
76
130
  };
77
131
 
@@ -234,6 +288,18 @@ export class SceneProcessor {
234
288
  const maxBytes = this.config.maxSceneBytes ?? MAX_SCENE_BYTES;
235
289
  const survey = this.geometryExtractor.surveyScene( object );
236
290
  const estimate = estimateSceneBytes( survey );
291
+ // Spilled as it is built, the BVH is never resident as a whole.
292
+ if ( this._progressive ) estimate.total -= estimate.bvh;
293
+ // Streamed, neither are the triangles: what is held is the resident cap plus the largest
294
+ // mesh in the parallel builder — its copy and 44 B a triangle of scratch, the store's own
295
+ // chunks for it let go meanwhile.
296
+ if ( this._streaming ) {
297
+
298
+ const resident = STREAM_RESIDENT_BYTES + largestMeshTriangles( object ) * ( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE * 4 + 44 );
299
+ estimate.total -= Math.max( 0, estimate.triangles - resident );
300
+
301
+ }
302
+
237
303
  const report = {
238
304
  ...survey, estimate,
239
305
  safeBytes: SAFE_SCENE_BYTES, maxBytes,
@@ -332,7 +398,7 @@ export class SceneProcessor {
332
398
 
333
399
  if ( table ) {
334
400
 
335
- for ( const blas of table.blasData.values() ) parts.blasScratch += blas?.byteLength ?? 0;
401
+ for ( const blas of table.blasData.values() ) parts.blasScratch += blas ? nodeBytes( blas ) : 0;
336
402
  for ( const m of table.bvhToOriginal.values() ) parts.orderMaps += m?.byteLength ?? 0;
337
403
  for ( const m of table.originalToBvhMap.values() ) parts.orderMaps += m?.byteLength ?? 0;
338
404
 
@@ -405,7 +471,11 @@ export class SceneProcessor {
405
471
  * @param {Object3D} object - Three.js object to process
406
472
  * @returns {Promise<SceneProcessor>} - This instance (for chaining)
407
473
  */
408
- async buildBVH( object ) {
474
+ /**
475
+ * @param {Object3D} object
476
+ * @param {{sceneKey?: ?string}} [options] - what the scene was loaded from; enables the BLAS cache
477
+ */
478
+ async buildBVH( object, { sceneKey = null, progressive = null } = {} ) {
409
479
 
410
480
  if ( this.isProcessing ) {
411
481
 
@@ -426,6 +496,15 @@ export class SceneProcessor {
426
496
 
427
497
  // Reset state before beginning
428
498
  this._reset();
499
+ this._sceneKey = sceneKey;
500
+ this._blasKey = null;
501
+ this._blasRestored = false;
502
+ this._progressive = progressive?.storage && progressive.uploader ? progressive : null;
503
+ const stored = this.geometryExtractor._countStoredTriangles( object );
504
+ // A spilling build of more than one chunk extracts and builds its BLASes together.
505
+ this._streaming = !! this._progressive && stored > Math.floor( DEFAULT_CHUNK_BYTES / ( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE * 4 ) );
506
+ this._foldLeaves = stored > FOLD_LEAVES_TRIANGLES;
507
+ this.bvhBuilder.foldLeaves = this._foldLeaves;
429
508
  this._log( 'Starting scene processing' );
430
509
 
431
510
  // Step 0: will this scene fit in the address space this process has left?
@@ -436,7 +515,8 @@ export class SceneProcessor {
436
515
  this.processingStage = 'extraction';
437
516
  this.memory.mark( 'extraction' );
438
517
  timer.start( 'Geometry extraction' );
439
- await this._extractGeometry( object );
518
+ if ( this._streaming ) await this._extractStreaming( object );
519
+ else await this._extractGeometry( object );
440
520
  timer.end( 'Geometry extraction' );
441
521
  this.performanceMetrics.geometryExtractionTime = timer.getDuration( 'Geometry extraction' );
442
522
  // The preflight figure was taken before `_compressAttributes` halved normals and
@@ -502,7 +582,14 @@ export class SceneProcessor {
502
582
  timer.print();
503
583
 
504
584
  this.processingStage = 'complete';
585
+ await this._restoreGeometry( object );
505
586
  updateLoading( { status: "Scene data ready", progress: 85 } );
587
+ if ( this._blasKey && ! this._blasRestored && this.performanceMetrics.totalProcessingTime >= this.config.sceneCacheMinBuildMs ) {
588
+
589
+ this._blasStoring = this._storeBLASes( object.name ?? '' ).catch( ( error ) => log.debug( 'storing BLASes failed:', error ) );
590
+
591
+ }
592
+
506
593
  return this;
507
594
 
508
595
  } catch ( error ) {
@@ -518,6 +605,8 @@ export class SceneProcessor {
518
605
 
519
606
  } finally {
520
607
 
608
+ // A failed build's model is discarded, so its geometry is not read back.
609
+ this._disposeGeometrySpill();
521
610
  setChunkObserver( null );
522
611
  this._sampleMemory( `at ${this.processingStage}` );
523
612
  this._logMemoryReport();
@@ -548,46 +637,7 @@ export class SceneProcessor {
548
637
 
549
638
  try {
550
639
 
551
- // Extract geometry data
552
- const extractedData = this.geometryExtractor.extract( object );
553
-
554
- this._setTriangleData( extractedData.triangleData );
555
- this.triangleCount = assertBVHIndexFits( extractedData.triangleCount, 'triangle count' );
556
- // Callers build refit buffers by walking meshes, which counts a shared geometry
557
- // once per placement; storage counts it once.
558
- this.expandedTriangleCount = extractedData.expandedTriangleCount ?? extractedData.triangleCount;
559
- this.instanceSource = extractedData.instanceSource || null;
560
- this.instanceMatrices = extractedData.instanceMatrices || null;
561
- this.instanceCount = extractedData.instanceCount || 0;
562
- this.bakeInverse = extractedData.bakeInverse || null;
563
-
564
- this._log( `Using Float32Array format: ${this.triangleCount} triangles, ${( this.triangles.byteLength / ( 1024 * 1024 ) ).toFixed( 2 )}MB` );
565
-
566
- // Store other extracted data
567
- this.materials = extractedData.materials;
568
- this.materialTriangleCounts = extractedData.materialTriangleCounts; // Per-material tri count for sort-bin remap
569
- this.meshes = extractedData.meshes;
570
- this.meshTriangleRanges = extractedData.meshTriangleRanges; // Per-mesh { start, count } for TLAS/BLAS
571
- this.maps = extractedData.maps;
572
- this.normalMaps = extractedData.normalMaps;
573
- this.bumpMaps = extractedData.bumpMaps;
574
- this.roughnessMaps = extractedData.roughnessMaps;
575
- this.metalnessMaps = extractedData.metalnessMaps;
576
- this.emissiveMaps = extractedData.emissiveMaps;
577
- this.displacementMaps = extractedData.displacementMaps;
578
- this.anisotropyMaps = extractedData.anisotropyMaps;
579
- this.transmissionMaps = extractedData.transmissionMaps;
580
- this.clearcoatMaps = extractedData.clearcoatMaps;
581
- this.clearcoatRoughnessMaps = extractedData.clearcoatRoughnessMaps;
582
- this.sheenColorMaps = extractedData.sheenColorMaps;
583
- this.sheenRoughnessMaps = extractedData.sheenRoughnessMaps;
584
- this.iridescenceMaps = extractedData.iridescenceMaps;
585
- this.iridescenceThicknessMaps = extractedData.iridescenceThicknessMaps;
586
- this.specularIntensityMaps = extractedData.specularIntensityMaps;
587
- this.specularColorMaps = extractedData.specularColorMaps;
588
- this.directionalLights = extractedData.directionalLights;
589
- this.cameras = extractedData.cameras;
590
-
640
+ this._adoptExtraction( this.geometryExtractor.extract( object ) );
591
641
  const duration = performance.now() - startTime;
592
642
  this._log( `Geometry extraction complete (${duration.toFixed( 2 )}ms)`, {
593
643
  triangleCount: this.triangleCount,
@@ -613,6 +663,224 @@ export class SceneProcessor {
613
663
 
614
664
  }
615
665
 
666
+ /**
667
+ * Extraction and the BLAS builds together, for a spilling build of several chunks. Each
668
+ * stored range goes to a worker the moment it is written, each chunk whose BLASes are all
669
+ * built goes to the GPU and then to disk, and the extraction waits whenever more than
670
+ * STREAM_RESIDENT_BYTES of triangles are still in memory — so the records are never all
671
+ * resident at once, which they are at the start of the BLAS phase otherwise.
672
+ * @private
673
+ */
674
+ async _extractStreaming( object ) {
675
+
676
+ updateLoading( { isLoading: true, title: 'Processing', status: 'Extracting geometry and building BVHs...', progress: 15 } );
677
+ await new Promise( r => setTimeout( r, 0 ) );
678
+ const startTime = performance.now();
679
+
680
+ const extractor = this.geometryExtractor;
681
+ const { storage, uploader } = this._progressive;
682
+ const key = `spill:${Date.now().toString( 36 )}:${Math.random().toString( 36 ).slice( 2 )}`;
683
+ const capacity = extractor._countStoredTriangles( object );
684
+ const probe = ChunkedRecords.lazy( capacity, TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE, Uint32Array );
685
+ const stride = probe.recordsPerChunk * probe.lanesPerRecord * 4;
686
+
687
+ const triStore = await SpillStore.create( storage, `${key}:tri`, stride, { label: 'Triangle records', expectedBytes: capacity * stride / probe.recordsPerChunk } );
688
+ const blasStore = triStore && await SpillStore.create( storage, `${key}:blas`, 0, { label: 'BLAS nodes (while building)' } );
689
+ if ( ! blasStore ) {
690
+
691
+ await triStore?.dispose();
692
+ this._streaming = false;
693
+ return this._extractGeometry( object );
694
+
695
+ }
696
+
697
+ // The three.js geometry waits on disk from its last read until the build is done.
698
+ const geometry = await GeometrySpill.create( storage, `${key}:geo`, this.memoryPreflight?.geometryBytes ?? 0 );
699
+ this._geometrySpill = geometry;
700
+
701
+ const state = {
702
+ key, triStore, blasStore, bvhStore: null,
703
+ blasBytes: 0, blasAt: new Map(),
704
+ pending: [], extractedChunks: 0,
705
+ emitterChunks: new Set(),
706
+ isEmitter: materialIndex => extractor._emissiveMaterial( materialIndex ),
707
+ uploadTriangles: null,
708
+ chain: Promise.resolve(),
709
+ error: null,
710
+ };
711
+ this._spillStores = [ triStore ];
712
+ this._blasKey = null;
713
+
714
+ const templates = new Map();
715
+ const workerOpts = this._blasWorkerOpts();
716
+ let waiters = [];
717
+ const built = () => new Promise( resolve => waiters.push( resolve ) );
718
+ const wake = () => {
719
+
720
+ const w = waiters;
721
+ waiters = [];
722
+ for ( const resolve of w ) resolve();
723
+
724
+ };
725
+
726
+ let tri = null;
727
+ const onBuilt = ( t, range, result ) => {
728
+
729
+ const nodes = result.bvhData;
730
+ const aabb = new Float32Array( 6 );
731
+ InstanceTable.rootAABB( nodeParts( nodes )[ 0 ], tri, range.start, range.count, aabb, 0 );
732
+ templates.set( t, { result, aabb } );
733
+
734
+ const at = state.blasBytes;
735
+ state.blasBytes += nodeBytes( nodes );
736
+ state.blasAt.set( t, at );
737
+ result.nodeCount = nodeBytes( nodes ) / 64;
738
+ result.bvhData = null;
739
+ this._queueSpill( state, () => writeNodes( blasStore, at, nodes ) );
740
+
741
+ this._forEachChunk( tri, range.start, range.count, k => {
742
+
743
+ if ( -- state.pending[ k ] === 0 && k < state.extractedChunks ) this._finishTriangleChunk( state, k );
744
+
745
+ } );
746
+ wake();
747
+
748
+ };
749
+
750
+ let finished = 0;
751
+ const pool = this._blasPool( workerOpts, () => finished ++, ( t, range, result ) => onBuilt( t, range, result ) );
752
+ let large = Promise.resolve();
753
+ let largePending = 0;
754
+ const advance = ( written ) => {
755
+
756
+ const done = written >= tri.recordCount ? tri.chunks.length : Math.floor( written / tri.recordsPerChunk );
757
+ for ( let k = state.extractedChunks; k < done; k ++ ) if ( ! state.pending[ k ] ) this._finishTriangleChunk( state, k );
758
+ state.extractedChunks = Math.max( state.extractedChunks, done );
759
+
760
+ };
761
+
762
+ let extracted;
763
+ try {
764
+
765
+ extracted = await extractor.extractStreaming( object, {
766
+
767
+ onStore: store => {
768
+
769
+ tri = store;
770
+ this._setTriangleData( store );
771
+ state.uploadTriangles = uploader( store, 'triangles' );
772
+
773
+ },
774
+
775
+ onRange: ( t, range ) => {
776
+
777
+ this._forEachChunk( tri, range.start, range.count, k => state.pending[ k ] = ( state.pending[ k ] ?? 0 ) + 1 );
778
+ if ( this._isLargeMesh( range ) ) {
779
+
780
+ largePending ++;
781
+ large = large.then( async () => {
782
+
783
+ onBuilt( t, range, await this._buildLargeBLAS( range, workerOpts ) );
784
+ largePending --;
785
+
786
+ } );
787
+ large.catch( error => {
788
+
789
+ state.error ??= error;
790
+ wake();
791
+
792
+ } );
793
+
794
+ } else pool.submit( { m: t, range } );
795
+
796
+ },
797
+
798
+ releaseGeometry: geometry ? g => geometry.add( g ) : null,
799
+
800
+ pause: async ( written ) => {
801
+
802
+ advance( written );
803
+ if ( geometry?.queuedBytes > GEOMETRY_QUEUE_BYTES ) await geometry.written();
804
+ updateLoading( { status: `Extracted ${written.toLocaleString()} triangles, ${finished.toLocaleString()} BVHs built...`, progress: 15 + Math.floor( 20 * written / Math.max( 1, capacity ) ) } );
805
+ await new Promise( r => setTimeout( r, 0 ) );
806
+ // A timer, not the spill chain: an already-settled promise would spin this loop in
807
+ // microtasks and keep out the worker messages it is waiting for.
808
+ while ( tri.byteLength > STREAM_RESIDENT_BYTES && ( pool.pending > 0 || largePending > 0 ) && ! state.error && ! pool.error ) {
809
+
810
+ await Promise.race( [ built(), new Promise( r => setTimeout( r, 50 ) ) ] );
811
+
812
+ }
813
+
814
+ if ( state.error || pool.error ) throw state.error ?? pool.error;
815
+
816
+ },
817
+
818
+ } );
819
+
820
+ advance( Infinity );
821
+ await Promise.all( [ pool.close(), large ] );
822
+ await state.chain;
823
+ if ( state.error ) throw state.error;
824
+
825
+ } catch ( error ) {
826
+
827
+ pool.abort( error );
828
+ log.error( 'streamed extraction failed:', error );
829
+ updateLoading( { status: `Extraction error: ${error.message}`, failed: true, progress: 25 } );
830
+ throw error;
831
+
832
+ }
833
+
834
+ this._adoptExtraction( extracted );
835
+ this._streamed = { state, templates };
836
+ this._log( `Geometry extracted and ${templates.size} BLASes built together (${( performance.now() - startTime ).toFixed( 0 )}ms)` );
837
+
838
+ }
839
+
840
+ /** Takes over what the extractor produced. @private */
841
+ _adoptExtraction( extractedData ) {
842
+
843
+ this._setTriangleData( extractedData.triangleData );
844
+ this.geometryExtractor.releaseTriangles();
845
+ this.triangleCount = assertBVHIndexFits( extractedData.triangleCount, 'triangle count' );
846
+ if ( this._foldLeaves && this.triangleCount > BVH_FOLDED_FIRST_LIMIT ) throw new RangeError( `${this.triangleCount.toLocaleString()} triangles is past the ${BVH_FOLDED_FIRST_LIMIT.toLocaleString()} a folded BVH leaf can address` );
847
+ // Callers build refit buffers by walking meshes, which counts a shared geometry
848
+ // once per placement; storage counts it once.
849
+ this.expandedTriangleCount = extractedData.expandedTriangleCount ?? extractedData.triangleCount;
850
+ this.instanceSource = extractedData.instanceSource || null;
851
+ this.instanceMatrices = extractedData.instanceMatrices || null;
852
+ this.instanceCount = extractedData.instanceCount || 0;
853
+ this.bakeInverse = extractedData.bakeInverse || null;
854
+
855
+ this._log( `Using Float32Array format: ${this.triangleCount} triangles, ${( this.triangles.byteLength / ( 1024 * 1024 ) ).toFixed( 2 )}MB` );
856
+
857
+ // Store other extracted data
858
+ this.materials = extractedData.materials;
859
+ this.materialTriangleCounts = extractedData.materialTriangleCounts; // Per-material tri count for sort-bin remap
860
+ this.meshes = extractedData.meshes;
861
+ this.meshTriangleRanges = extractedData.meshTriangleRanges; // Per-mesh { start, count } for TLAS/BLAS
862
+ this.maps = extractedData.maps;
863
+ this.normalMaps = extractedData.normalMaps;
864
+ this.bumpMaps = extractedData.bumpMaps;
865
+ this.roughnessMaps = extractedData.roughnessMaps;
866
+ this.metalnessMaps = extractedData.metalnessMaps;
867
+ this.emissiveMaps = extractedData.emissiveMaps;
868
+ this.displacementMaps = extractedData.displacementMaps;
869
+ this.anisotropyMaps = extractedData.anisotropyMaps;
870
+ this.transmissionMaps = extractedData.transmissionMaps;
871
+ this.clearcoatMaps = extractedData.clearcoatMaps;
872
+ this.clearcoatRoughnessMaps = extractedData.clearcoatRoughnessMaps;
873
+ this.sheenColorMaps = extractedData.sheenColorMaps;
874
+ this.sheenRoughnessMaps = extractedData.sheenRoughnessMaps;
875
+ this.iridescenceMaps = extractedData.iridescenceMaps;
876
+ this.iridescenceThicknessMaps = extractedData.iridescenceThicknessMaps;
877
+ this.specularIntensityMaps = extractedData.specularIntensityMaps;
878
+ this.specularColorMaps = extractedData.specularColorMaps;
879
+ this.directionalLights = extractedData.directionalLights;
880
+ this.cameras = extractedData.cameras;
881
+
882
+ }
883
+
616
884
  /**
617
885
  * Build two-level BVH (TLAS/BLAS): one BLAS per mesh, one TLAS over mesh AABBs.
618
886
  * @private
@@ -659,12 +927,14 @@ export class SceneProcessor {
659
927
  this.instanceTable.allocate( meshCount, ranges.length, worldPool, pooled ? instSource : null );
660
928
  this.instanceTable.tplBakeInverse = this.bakeInverse;
661
929
 
662
- const originalTreeletEnabled = this.config.enableTreeletOptimization;
663
- const LARGE_MESH_THRESHOLD = 200000;
930
+ // Built while the geometry was extracted (the streaming path): only adopt the results.
931
+ const streamed = this._streamed;
932
+ this._streamed = null;
664
933
 
665
934
  // Separate into worker-pool tasks and multi-worker parallel tasks
666
935
  const poolTasks = [];
667
936
  const parallelTasks = [];
937
+ const streamedTasks = [];
668
938
 
669
939
  // Placements that reuse another's triangles reuse its BLAS too — the whole point of
670
940
  // storing geometry in object space. Only the first placement of a range is built.
@@ -686,41 +956,13 @@ export class SceneProcessor {
686
956
 
687
957
  ownerOfRange.set( range.start, m );
688
958
 
689
- if ( range.count >= LARGE_MESH_THRESHOLD && shouldUseParallelBuild( range.count ) ) {
690
-
691
- parallelTasks.push( { m, range } );
692
-
693
- } else {
694
-
695
- poolTasks.push( { m, range } );
696
-
697
- }
959
+ if ( streamed ) streamedTasks.push( { m, range } );
960
+ else if ( this._isLargeMesh( range ) ) parallelTasks.push( { m, range } );
961
+ else poolTasks.push( { m, range } );
698
962
 
699
963
  }
700
964
 
701
- // Worker config shared by all builds
702
- const workerOpts = {
703
- depth: this.config.bvhDepth,
704
- treeletOptimization: {
705
- enabled: originalTreeletEnabled !== false,
706
- size: this.config.treeletSize,
707
- passes: this.config.treeletOptimizationPasses,
708
- minImprovement: this.config.treeletMinImprovement,
709
- complexityThreshold: this.config.treeletComplexityThreshold
710
- },
711
- reinsertionOptimization: {
712
- enabled: this.bvhBuilder.enableReinsertionOptimization,
713
- batchSizeRatio: this.bvhBuilder.reinsertionBatchSizeRatio,
714
- maxIterations: this.bvhBuilder.reinsertionMaxIterations
715
- },
716
- // BVH build params — previously omitted, so the pool path built at the
717
- // BVHBuilder default (leaf 8) instead of the configured value.
718
- maxLeafSize: this.bvhBuilder.maxLeafSize,
719
- numBins: this.bvhBuilder.numBins,
720
- maxBins: this.bvhBuilder.maxBins,
721
- minBins: this.bvhBuilder.minBins,
722
- logLevel: workerLogLevel(),
723
- };
965
+ const workerOpts = this._blasWorkerOpts();
724
966
 
725
967
  const totalTasks = poolTasks.length + parallelTasks.length;
726
968
  let doneTasks = 0;
@@ -734,8 +976,35 @@ export class SceneProcessor {
734
976
 
735
977
  };
736
978
 
979
+ const owners = [ ...poolTasks, ...parallelTasks ]
980
+ .map( ( { m, range } ) => ( { m, range, t: sourceOf( m ), triOffset: range.start, triCount: range.count } ) )
981
+ .sort( ( a, b ) => a.t - b.t );
982
+ const restored = streamed ? null : await this._restoreBLASes( owners, TLASBuilder.nodeCountFor( meshCount ), workerOpts );
983
+ const spill = streamed ? streamed.state
984
+ : ! restored && this._progressive && this.triangles.chunks.length > 1 ? await this._beginProgressiveSpill( owners ) : null;
985
+ const onBuilt = spill && ! streamed ? ( m, range, result ) => this._onBLASBuilt( spill, sourceOf( m ), range, result ) : null;
986
+ if ( streamed ) {
987
+
988
+ for ( const [ t, built ] of streamed.templates ) {
989
+
990
+ this.instanceTable.tplObjectAABB.set( built.aabb, t * 6 );
991
+
992
+ }
993
+
994
+ }
995
+
996
+ if ( restored ) {
997
+
998
+ poolTasks.length = 0;
999
+ parallelTasks.length = 0;
1000
+ updateLoading( { status: 'Restored stored BVHs', progress: 68 } );
1001
+
1002
+ }
1003
+
737
1004
  // Build all meshes via bounded worker pool (main thread stays free)
738
- const poolPromise = this._buildBLASesWithPool( poolTasks, workerOpts, reportBLASProgress );
1005
+ const poolPromise = restored ? Promise.resolve( restored.results )
1006
+ : streamed ? Promise.resolve( streamedTasks.map( ( { m, range } ) => ( { m, range, result: streamed.templates.get( sourceOf( m ) ).result } ) ) )
1007
+ : this._buildBLASesWithPool( poolTasks, workerOpts, reportBLASProgress, onBuilt );
739
1008
 
740
1009
  // One at a time: each build already spreads over every core and pins ~200 bytes per
741
1010
  // triangle of SharedArrayBuffer until it finishes. This is a memory bound, not a core one.
@@ -744,23 +1013,8 @@ export class SceneProcessor {
744
1013
 
745
1014
  for ( const { m, range } of parallelTasks ) {
746
1015
 
747
- const meshTriData = this.triangles.copyOf( range.start, range.count );
748
-
749
- const result = await buildBVHParallel( meshTriData, this.config.bvhDepth, null, {
750
- maxLeafSize: this.bvhBuilder.maxLeafSize,
751
- numBins: this.bvhBuilder.numBins,
752
- maxBins: this.bvhBuilder.maxBins,
753
- minBins: this.bvhBuilder.minBins,
754
- ...workerOpts
755
- } );
756
-
757
- if ( result.reorderedTriangles ) {
758
-
759
- this.triangles.setRecords( range.start, result.reorderedTriangles );
760
- delete result.reorderedTriangles;
761
-
762
- }
763
-
1016
+ const result = await this._buildLargeBLAS( range, workerOpts );
1017
+ onBuilt?.( m, range, result );
764
1018
  parallelResults.push( { m, range, result } );
765
1019
  reportBLASProgress();
766
1020
 
@@ -769,6 +1023,12 @@ export class SceneProcessor {
769
1023
  } )();
770
1024
 
771
1025
  const [ poolResults ] = await Promise.all( [ poolPromise, parallelPromise ] );
1026
+ if ( spill ) {
1027
+
1028
+ await spill.chain;
1029
+ if ( spill.error ) throw spill.error;
1030
+
1031
+ }
772
1032
 
773
1033
  // Store all results, summing per-mesh split stats for one aggregate BVH line
774
1034
  const blasStats = { sah: 0, objMed: 0, spatMed: 0, failed: 0, treeletsImproved: 0, treeletsProcessed: 0 };
@@ -790,7 +1050,7 @@ export class SceneProcessor {
790
1050
 
791
1051
  this.instanceTable.setEntry( {
792
1052
  meshIndex: m,
793
- blasNodeCount: result.bvhData.length / 16,
1053
+ blasNodeCount: result.nodeCount ?? nodeBytes( result.bvhData ) / 64,
794
1054
  triOffset: range.start,
795
1055
  triCount: range.count,
796
1056
  originalToBvhMap: result.originalToBvh || null,
@@ -832,7 +1092,7 @@ export class SceneProcessor {
832
1092
  // carries packed per-mesh visibility in its slot [2]. The 1-node TLAS
833
1093
  // overhead (one extra leaf fetch per ray) is negligible and eliminates
834
1094
  // a dedicated visibility storage buffer binding.
835
- this.instanceTable.computeAABBs( this.triangles );
1095
+ this.instanceTable.computeAABBs( this.triangles, { owners: ! spill } );
836
1096
 
837
1097
  // Node count is exact up front (every leaf holds one entry), so BLAS offsets can be
838
1098
  // assigned before the build and the TLAS is written in a single pass.
@@ -849,8 +1109,9 @@ export class SceneProcessor {
849
1109
  // 33.4M nodes, which a large instanced scene reaches well before the GPU's 4 GB.
850
1110
  // Lazy, and walked in template order so offsets ascend: chunks are allocated as the fill
851
1111
  // reaches them and each BLAS is released as it lands, never both fully resident.
852
- this._checkAssemblyHeadroom( totalNodes );
1112
+ if ( ! spill ) this._checkAssemblyHeadroom( totalNodes );
853
1113
  this._setBVHData( ChunkedRecords.lazy( totalNodes, 16, Float32Array, undefined, SHARED_MEMORY_AVAILABLE ) );
1114
+ const flushBVH = spill ? await this._beginBVHSpill( spill, table.tlasNodeCount ) : null;
854
1115
  this.bvh.setRecords( 0, tlasData );
855
1116
  // Hundreds of megabytes at millions of placements, and the chunks below need the room
856
1117
  // more than a later refit needs the cache.
@@ -862,23 +1123,57 @@ export class SceneProcessor {
862
1123
  // as chunks are taken and BLASes released.
863
1124
  const sampleEvery = Math.max( 1, Math.ceil( table.templateCount / 32 ) );
864
1125
 
865
- for ( let t = 0; t < table.templateCount; t ++ ) {
1126
+ if ( restored ) {
1127
+
1128
+ // Stored already assembled: same template order, same offsets, indices already rebased.
1129
+ try {
866
1130
 
867
- const blas = table.blasData.get( t ); // only owning templates hold one
868
- if ( ! blas ) continue;
1131
+ const readStart = performance.now();
1132
+ await readNodesInto( restored.nodes, this.bvh, table.tlasNodeCount, totalNodes - table.tlasNodeCount );
1133
+ this.performanceMetrics.blasRestore.readMs = performance.now() - readStart;
869
1134
 
870
- const blasOffset = table.tplBlasOffset[ t ];
871
- this.bvh.setRecords( blasOffset, blas );
872
- this._offsetBLASInPlace( blasOffset, blas.length / 16, blasOffset, table.tplTriOffset[ t ] );
873
- table.blasData.delete( t );
1135
+ } finally {
874
1136
 
875
- if ( t % sampleEvery === 0 ) this._sampleMemory( `assembling ${t}/${table.templateCount}` );
1137
+ restored.release();
1138
+
1139
+ }
1140
+
1141
+ table.blasData.clear();
1142
+
1143
+ } else {
1144
+
1145
+ for ( let t = 0; t < table.templateCount; t ++ ) {
1146
+
1147
+ // Only owning templates hold one; spilled, they are read back one at a time.
1148
+ const blas = spill ? await this._readSpilledBLAS( spill, t ) : table.blasData.get( t );
1149
+ if ( ! blas ) continue;
1150
+
1151
+ const blasOffset = table.tplBlasOffset[ t ];
1152
+ let end = blasOffset;
1153
+ for ( const part of nodeParts( blas ) ) {
1154
+
1155
+ this.bvh.setRecords( end, part );
1156
+ end += part.length / 16;
1157
+
1158
+ }
1159
+
1160
+ this._offsetBLASInPlace( blasOffset, end - blasOffset, blasOffset, table.tplTriOffset[ t ] );
1161
+ table.blasData.delete( t );
1162
+ await flushBVH?.( end );
1163
+
1164
+ if ( t % sampleEvery === 0 ) this._sampleMemory( `assembling ${t}/${table.templateCount}` );
1165
+
1166
+ }
1167
+
1168
+ await flushBVH?.( totalNodes, true );
1169
+ if ( spill ) await spill.blasStore.dispose();
876
1170
 
877
1171
  }
878
1172
 
879
1173
  this._setBVHData( this.bvh.materializeAll() );
880
1174
 
881
- this._buildBvhToOriginalMaps();
1175
+ if ( restored ) for ( const [ t, map ] of restored.orders ) table.bvhToOriginal.set( t, map );
1176
+ else this._buildBvhToOriginalMaps();
882
1177
  this.performanceMetrics.bvhAssembleTime = performance.now() - assembleStart;
883
1178
 
884
1179
  table.originalToBvhMap.clear();
@@ -917,31 +1212,486 @@ export class SceneProcessor {
917
1212
 
918
1213
  }
919
1214
 
1215
+ /** Worker config shared by all BLAS builds. @private */
1216
+ _blasWorkerOpts() {
1217
+
1218
+ return {
1219
+ depth: this.config.bvhDepth,
1220
+ treeletOptimization: {
1221
+ enabled: this.config.enableTreeletOptimization !== false,
1222
+ size: this.config.treeletSize,
1223
+ passes: this.config.treeletOptimizationPasses,
1224
+ minImprovement: this.config.treeletMinImprovement,
1225
+ complexityThreshold: this.config.treeletComplexityThreshold
1226
+ },
1227
+ reinsertionOptimization: {
1228
+ enabled: this.bvhBuilder.enableReinsertionOptimization,
1229
+ batchSizeRatio: this.bvhBuilder.reinsertionBatchSizeRatio,
1230
+ maxIterations: this.bvhBuilder.reinsertionMaxIterations
1231
+ },
1232
+ // BVH build params — previously omitted, so the pool path built at the
1233
+ // BVHBuilder default (leaf 8) instead of the configured value.
1234
+ maxLeafSize: this.bvhBuilder.maxLeafSize,
1235
+ foldLeaves: this._foldLeaves === true,
1236
+ numBins: this.bvhBuilder.numBins,
1237
+ maxBins: this.bvhBuilder.maxBins,
1238
+ minBins: this.bvhBuilder.minBins,
1239
+ logLevel: workerLogLevel(),
1240
+ };
1241
+
1242
+ }
1243
+
1244
+ /** @private */
1245
+ _isLargeMesh( range ) {
1246
+
1247
+ return range.count >= LARGE_MESH_TRIANGLES && shouldUseParallelBuild( range.count );
1248
+
1249
+ }
1250
+
1251
+ /**
1252
+ * One mesh's BLAS across every core. The range is read straight into the SharedArrayBuffer
1253
+ * the builder works in, and its BVH order written back chunk by chunk, so the mesh is held
1254
+ * once rather than three times.
1255
+ * @private
1256
+ */
1257
+ async _buildLargeBLAS( range, workerOpts ) {
1258
+
1259
+ if ( range.count > SPLIT_MESH_TRIANGLES ) return this._buildSplitBLAS( range, workerOpts );
1260
+
1261
+ const meshTriData = new Uint32Array( new SharedArrayBuffer( range.count * this.triangles.lanesPerRecord * 4 ) );
1262
+ this.triangles.readRecords( range.start, range.count, meshTriData );
1263
+ // The builder has its own copy and the order comes back into every record of the range, so
1264
+ // the chunks wholly inside it can go meanwhile — 1 GB at the build's peak for a 15M mesh.
1265
+ this.triangles.release( range.start, range.count );
1266
+
1267
+ const result = await buildBVHParallel( meshTriData, this.config.bvhDepth, null, workerOpts );
1268
+
1269
+ if ( result.reorderedTriangles ) {
1270
+
1271
+ this.triangles.setRecords( range.start, result.reorderedTriangles );
1272
+ delete result.reorderedTriangles;
1273
+
1274
+ } else if ( result.order ) {
1275
+
1276
+ this._writeInOrder( range.start, result.triangles, result.order );
1277
+ delete result.order;
1278
+ delete result.triangles;
1279
+
1280
+ }
1281
+
1282
+ return result;
1283
+
1284
+ }
1285
+
1286
+ /**
1287
+ * A mesh too large to build in one piece: its records are sorted in place into spatial pieces,
1288
+ * each piece built by a pool worker like any mesh, and the pieces joined under the halving that
1289
+ * made them. Held once, in the store, with a piece's copy per worker — where one build across
1290
+ * every core held a second copy of the whole mesh and 44 B a triangle of shared scratch.
1291
+ * @private
1292
+ */
1293
+ async _buildSplitBLAS( range, workerOpts ) {
1294
+
1295
+ const tri = this.triangles;
1296
+ const { order, pieces, tree } = await partitionRange( tri, range.start, range.count, SPLIT_PIECE_TRIANGLES, { pause: () => new Promise( r => setTimeout( r, 0 ) ) } );
1297
+ const pool = this._blasPool( workerOpts, null, null, Math.min( navigator.hardwareConcurrency || 4, 8 ) );
1298
+ pieces.forEach( ( p, k ) => pool.submit( { m: k, range: { start: range.start + p.start, count: p.count } } ) );
1299
+
1300
+ const built = [];
1301
+ const splitStats = {};
1302
+ for ( const { m: k, range: r, result } of await pool.close() ) {
1303
+
1304
+ result.aabb = new Float32Array( 6 );
1305
+ InstanceTable.rootAABB( result.bvhData, tri, r.start, r.count, result.aabb, 0 );
1306
+ built[ k ] = result;
1307
+ for ( const [ key, value ] of Object.entries( result.splitStats ?? {} ) ) if ( typeof value === 'number' ) splitStats[ key ] = ( splitStats[ key ] ?? 0 ) + value;
1308
+
1309
+ }
1310
+
1311
+ return { ...joinPieces( tree, pieces, built, order ), splitStats };
1312
+
1313
+ }
1314
+
1315
+ _blasCacheKey( workerOpts ) {
1316
+
1317
+ const builder = { ...workerOpts };
1318
+ delete builder.logLevel;
1319
+ const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : 0;
1320
+ return `blas:${BLAS_CACHE_FORMAT}:${VERSION}:${JSON.stringify( builder )}:${cores}:${this._sceneKey}`;
1321
+
1322
+ }
1323
+
1324
+ /**
1325
+ * Reuses stored BLASes when every owning template matches what was stored — same ranges, same
1326
+ * node counts, same offsets, same triangles — and puts the triangles in their built order.
1327
+ * @returns {Promise<?{results: Array, orders: Map<number, Uint32Array>, nodes: File, release: function(): void}>}
1328
+ * @private
1329
+ */
1330
+ async _restoreBLASes( owners, tlasNodeCount, workerOpts ) {
1331
+
1332
+ if ( ! this._sceneKey || this.config.sceneCache === false ) return null;
1333
+ const storage = sharedStorage( getAssetConfig().cacheNamespace );
1334
+ if ( ! storage ) return null;
1335
+
1336
+ this._blasKey = this._blasCacheKey( workerOpts );
1337
+ const cached = await openBLASCache( storage, this._blasKey );
1338
+ if ( ! cached ) return null;
1339
+
1340
+ const miss = ( reason ) => {
1341
+
1342
+ log.debug( `stored BLASes not used: ${reason}` );
1343
+ cached.release();
1344
+ return null;
1345
+
1346
+ };
1347
+
1348
+ const { index } = cached;
1349
+ if ( index.tlasNodeCount !== tlasNodeCount || index.templates.length !== owners.length ) return miss( 'layout changed' );
1350
+
1351
+ let offset = tlasNodeCount;
1352
+ for ( let i = 0; i < owners.length; i ++ ) {
1353
+
1354
+ const [ t, triOffset, triCount, nodeCount, blasOffset ] = index.templates[ i ];
1355
+ const o = owners[ i ];
1356
+ if ( o.t !== t || o.triOffset !== triOffset || o.triCount !== triCount || blasOffset !== offset ) return miss( 'layout changed' );
1357
+ offset += nodeCount;
1358
+
1359
+ }
1360
+
1361
+ if ( offset !== index.totalNodes ) return miss( 'layout changed' );
1362
+
1363
+ const checkStart = performance.now();
1364
+ const sums = await templateChecksums( this.triangles, owners );
1365
+ for ( let i = 0; i < owners.length; i ++ ) if ( sums[ i ] !== index.templates[ i ][ 6 ] ) return miss( 'triangles changed' );
1366
+ const permuteStart = performance.now();
1367
+
1368
+ try {
1369
+
1370
+ const orders = new Map();
1371
+ const results = [];
1372
+ for ( let i = 0; i < owners.length; i ++ ) {
1373
+
1374
+ const [ t, triOffset, triCount, nodeCount, , orderOffset, , root ] = index.templates[ i ];
1375
+ const order = await readOrder( cached.order, orderOffset, triCount );
1376
+ this._permuteRange( triOffset, triCount, order );
1377
+ orders.set( t, order );
1378
+ results.push( {
1379
+ m: owners[ i ].m,
1380
+ range: owners[ i ].range,
1381
+ result: { bvhData: new Float32Array( Uint32Array.from( root ).buffer ), originalToBvh: null, nodeCount },
1382
+ } );
1383
+
1384
+ }
1385
+
1386
+ this._blasRestored = true;
1387
+ this.performanceMetrics.blasRestore = { checksumMs: permuteStart - checkStart, permuteMs: performance.now() - permuteStart };
1388
+ log.debug( `restored ${fmt.n( owners.length )} stored BLASes` );
1389
+ return { results, orders, nodes: cached.nodes, release: cached.release };
1390
+
1391
+ } catch ( error ) {
1392
+
1393
+ cached.release();
1394
+ throw error;
1395
+
1396
+ }
1397
+
1398
+ }
1399
+
1400
+ /** Puts records [start, start + count) in built order: slot j takes caller's triangle order[ j ]. @private */
1401
+ /** Record `start + j` of the store becomes record `order[ j ]` of `src`, chunk by chunk. @private */
1402
+ _writeInOrder( start, src, order ) {
1403
+
1404
+ const store = this.triangles;
1405
+ const lanes = store.lanesPerRecord;
1406
+ for ( let j = 0, n = order.length; j < n; ) {
1407
+
1408
+ const dst = store.chunkFor( start + j );
1409
+ const end = Math.min( n, ( ( ( ( start + j ) / store.recordsPerChunk ) | 0 ) + 1 ) * store.recordsPerChunk - start );
1410
+ for ( let d = store.baseOf( start + j ); j < end; j ++, d += lanes ) {
1411
+
1412
+ const s = order[ j ] * lanes;
1413
+ for ( let l = 0; l < lanes; l ++ ) dst[ d + l ] = src[ s + l ];
1414
+
1415
+ }
1416
+
1417
+ }
1418
+
1419
+ }
1420
+
1421
+ _permuteRange( start, count, order ) {
1422
+
1423
+ let identity = true;
1424
+ for ( let j = 0; j < count && identity; j ++ ) identity = order[ j ] === j;
1425
+ if ( identity ) return;
1426
+
1427
+ const lanes = this.triangles.lanesPerRecord;
1428
+ // A view when the range sits in one chunk: read from it, write a fresh buffer, copy back once.
1429
+ const src = this.triangles.slice( start, count );
1430
+ const out = new src.constructor( src.length );
1431
+ if ( lanes === 20 ) {
1432
+
1433
+ for ( let j = 0, d = 0; j < count; j ++, d += 20 ) {
1434
+
1435
+ const s = order[ j ] * 20;
1436
+ out[ d ] = src[ s ]; out[ d + 1 ] = src[ s + 1 ]; out[ d + 2 ] = src[ s + 2 ]; out[ d + 3 ] = src[ s + 3 ];
1437
+ out[ d + 4 ] = src[ s + 4 ]; out[ d + 5 ] = src[ s + 5 ]; out[ d + 6 ] = src[ s + 6 ]; out[ d + 7 ] = src[ s + 7 ];
1438
+ out[ d + 8 ] = src[ s + 8 ]; out[ d + 9 ] = src[ s + 9 ]; out[ d + 10 ] = src[ s + 10 ]; out[ d + 11 ] = src[ s + 11 ];
1439
+ out[ d + 12 ] = src[ s + 12 ]; out[ d + 13 ] = src[ s + 13 ]; out[ d + 14 ] = src[ s + 14 ]; out[ d + 15 ] = src[ s + 15 ];
1440
+ out[ d + 16 ] = src[ s + 16 ]; out[ d + 17 ] = src[ s + 17 ]; out[ d + 18 ] = src[ s + 18 ]; out[ d + 19 ] = src[ s + 19 ];
1441
+
1442
+ }
1443
+
1444
+ } else {
1445
+
1446
+ for ( let j = 0; j < count; j ++ ) {
1447
+
1448
+ const s = order[ j ] * lanes;
1449
+ const d = j * lanes;
1450
+ for ( let l = 0; l < lanes; l ++ ) out[ d + l ] = src[ s + l ];
1451
+
1452
+ }
1453
+
1454
+ }
1455
+
1456
+ this.triangles.setRecords( start, out );
1457
+
1458
+ }
1459
+
1460
+ /** Writes this build's BLASes to storage in the background; abandons it if the scene changes. @private */
1461
+ async _storeBLASes( label ) {
1462
+
1463
+ const storage = sharedStorage( getAssetConfig().cacheNamespace );
1464
+ const table = this.instanceTable;
1465
+ if ( ! storage || ! table || ! this.bvh ) return false;
1466
+
1467
+ const key = this._blasKey;
1468
+ const version = this._geometryVersion;
1469
+ const isStale = () => this._geometryVersion !== version || this._blasKey !== key;
1470
+
1471
+ const owners = [];
1472
+ for ( let t = 0; t < table.templateCount; t ++ ) {
1473
+
1474
+ const owner = table.tplOwner[ t ];
1475
+ if ( owner < 0 || table.sourceMesh[ owner ] !== t ) continue;
1476
+ owners.push( { t, triOffset: table.tplTriOffset[ t ], triCount: table.tplTriCount[ t ], nodeCount: table.tplNodeCount[ t ], blasOffset: table.tplBlasOffset[ t ] } );
1477
+
1478
+ }
1479
+
1480
+ const sums = await templateChecksums( this.triangles, owners );
1481
+ if ( isStale() ) return false;
1482
+
1483
+ const rootBits = ( n ) => {
1484
+
1485
+ const chunk = this.bvhIndexChunks.chunkFor( n );
1486
+ const base = this.bvhIndexChunks.baseOf( n );
1487
+ return Array.from( chunk.subarray( base, base + 16 ) );
1488
+
1489
+ };
1490
+
1491
+ let orderOffset = 0;
1492
+ const orders = [];
1493
+ const templates = owners.map( ( o, i ) => {
1494
+
1495
+ orders.push( table.bvhToOriginal.get( o.t ) ?? Uint32Array.from( { length: o.triCount }, ( _, j ) => j ) );
1496
+ const entry = [ o.t, o.triOffset, o.triCount, o.nodeCount, o.blasOffset, orderOffset, sums[ i ], rootBits( o.blasOffset ) ];
1497
+ orderOffset += o.triCount;
1498
+ return entry;
1499
+
1500
+ } );
1501
+
1502
+ const index = { v: BLAS_CACHE_FORMAT, tlasNodeCount: table.tlasNodeCount, totalNodes: table.totalNodeCount, templates };
1503
+ const stored = await saveBLASCache( storage, key, { index, bvh: this.bvh, orders, isStale, label } );
1504
+ if ( stored ) log.debug( `stored ${fmt.n( owners.length )} BLASes for the next load` );
1505
+ return stored;
1506
+
1507
+ }
1508
+
920
1509
  /**
921
1510
  * Adjust BLAS node indices in-place within the combined bvhData buffer.
922
1511
  * @private
923
1512
  */
924
- _offsetBLASInPlace( startNode, nodeCount, nodeOffset, triOffset ) {
1513
+ /**
1514
+ * Memory spill during the build (8c): each BLAS goes to a scratch file as it arrives, and each
1515
+ * triangle chunk goes to the GPU and then to disk once every BLAS over it is built — so the
1516
+ * triangles and the BVH are never all in memory at once. Chunks holding emitters stay: the
1517
+ * emissive pass reads them. Null when storage cannot take it, and the build carries on as usual.
1518
+ * @private
1519
+ */
1520
+ async _beginProgressiveSpill( owners ) {
925
1521
 
926
- const idx = this.bvhIndexChunks;
1522
+ const { storage, uploader } = this._progressive;
1523
+ const tri = this.triangles;
1524
+ const key = `spill:${Date.now().toString( 36 )}:${Math.random().toString( 36 ).slice( 2 )}`;
1525
+ const stride = tri.recordsPerChunk * tri.lanesPerRecord * tri.LaneType.BYTES_PER_ELEMENT;
927
1526
 
928
- for ( let i = 0; i < nodeCount; i ++ ) {
1527
+ const triStore = await SpillStore.create( storage, `${key}:tri`, stride, { label: 'Triangle records', expectedBytes: tri.byteLength } );
1528
+ const blasStore = triStore && await SpillStore.create( storage, `${key}:blas`, 0, { label: 'BLAS nodes (while building)' } );
1529
+ if ( ! blasStore ) {
929
1530
 
930
- const n = startNode + i;
931
- const chunk = idx.chunkFor( n );
932
- const o = idx.baseOf( n );
1531
+ await triStore?.dispose();
1532
+ return null;
1533
+
1534
+ }
933
1535
 
934
- if ( chunk[ o + 3 ] === BVH_LEAF_MARKERS.TRIANGLE_LEAF ) {
1536
+ const state = {
1537
+ key, triStore, blasStore, bvhStore: null,
1538
+ blasBytes: 0, blasAt: new Map(),
1539
+ pending: new Int32Array( tri.chunks.length ),
1540
+ emitterChunks: new Set(),
1541
+ isEmitter: materialIndex => {
935
1542
 
936
- chunk[ o ] += triOffset;
1543
+ const m = this.materials[ materialIndex ];
1544
+ return ( m?.emissiveIntensity ?? 0 ) > 0 && !! m.emissive && ( m.emissive.r > 0 || m.emissive.g > 0 || m.emissive.b > 0 );
937
1545
 
938
- } else {
1546
+ },
1547
+ uploadTriangles: uploader( tri, 'triangles' ),
1548
+ chain: Promise.resolve(),
1549
+ error: null,
1550
+ };
1551
+
1552
+ this._spillStores = [ triStore ];
1553
+ this._blasKey = null;
1554
+ for ( const { triOffset, triCount } of owners ) this._forEachChunk( tri, triOffset, triCount, k => state.pending[ k ] ++ );
1555
+ for ( let k = 0; k < tri.chunks.length; k ++ ) if ( state.pending[ k ] === 0 ) this._finishTriangleChunk( state, k );
1556
+ return state;
1557
+
1558
+ }
1559
+
1560
+ /** @private */
1561
+ _forEachChunk( records, start, count, fn ) {
1562
+
1563
+ if ( count <= 0 ) return;
1564
+ const last = ( ( start + count - 1 ) / records.recordsPerChunk ) | 0;
1565
+ for ( let k = ( start / records.recordsPerChunk ) | 0; k <= last; k ++ ) fn( k );
1566
+
1567
+ }
1568
+
1569
+ /** A BLAS has landed: its bounds are taken now, its nodes go to scratch. @private */
1570
+ _onBLASBuilt( state, t, range, result ) {
1571
+
1572
+ const table = this.instanceTable;
1573
+ const nodes = result.bvhData;
1574
+ table.tplTriOffset[ t ] = range.start;
1575
+ table.tplTriCount[ t ] = range.count;
1576
+ table._readRootAABB( nodeParts( nodes )[ 0 ], t, this.triangles, table.tplObjectAABB, t * 6 );
1577
+
1578
+ const at = state.blasBytes;
1579
+ state.blasBytes += nodeBytes( nodes );
1580
+ state.blasAt.set( t, at );
1581
+ result.nodeCount = nodeBytes( nodes ) / 64;
1582
+ result.bvhData = null;
1583
+
1584
+ this._queueSpill( state, () => writeNodes( state.blasStore, at, nodes ) );
1585
+ this._forEachChunk( this.triangles, range.start, range.count, k => {
1586
+
1587
+ if ( -- state.pending[ k ] === 0 ) this._finishTriangleChunk( state, k );
1588
+
1589
+ } );
1590
+
1591
+ }
1592
+
1593
+ /** @private */
1594
+ _queueSpill( state, step ) {
1595
+
1596
+ state.chain = state.chain.then( step ).catch( error => {
1597
+
1598
+ state.error ??= error;
1599
+
1600
+ } );
1601
+
1602
+ }
1603
+
1604
+ /** Every BLAS over chunk `k` is built: upload it, and let it go unless it holds emitters. @private */
1605
+ _finishTriangleChunk( state, k ) {
1606
+
1607
+ this._queueSpill( state, async () => {
1608
+
1609
+ const tri = this.triangles;
1610
+ const chunk = tri.chunks[ k ];
1611
+ if ( ! chunk ) return;
1612
+ state.uploadTriangles( k );
1613
+
1614
+ const lanes = tri.lanesPerRecord;
1615
+ for ( let o = TRIANGLE_DATA_LAYOUT.MATERIAL_FLAGS_OFFSET; o < chunk.length; o += lanes ) {
1616
+
1617
+ if ( state.isEmitter( chunk[ o ] & TRI_MATERIAL_MASK ) ) {
1618
+
1619
+ state.emitterChunks.add( k );
1620
+ return;
1621
+
1622
+ }
1623
+
1624
+ }
1625
+
1626
+ await tri.spillChunk( k, state.triStore );
1627
+
1628
+ } );
1629
+
1630
+ }
1631
+
1632
+ /** @private */
1633
+ async _readSpilledBLAS( state, t ) {
1634
+
1635
+ const at = state.blasAt.get( t );
1636
+ if ( at === undefined ) return null;
1637
+ const bytes = this.instanceTable.tplNodeCount[ t ] * 64;
1638
+ const parts = [];
1639
+ for ( let off = 0; off < bytes; off += READ_PART_BYTES ) parts.push( new Float32Array( await state.blasStore.readAt( at + off, Math.min( READ_PART_BYTES, bytes - off ) ) ) );
1640
+ return parts;
1641
+
1642
+ }
1643
+
1644
+ /**
1645
+ * The combined BVH is filled in ascending order, so every chunk the fill has passed is done:
1646
+ * upload it and let it go. The TLAS chunks stay, as they do after any spill.
1647
+ * @returns {Promise<?function(number, boolean=): Promise<void>>} call with the fill's end
1648
+ * @private
1649
+ */
1650
+ async _beginBVHSpill( state, tlasNodeCount ) {
1651
+
1652
+ const { storage, uploader } = this._progressive;
1653
+ const bvh = this.bvh;
1654
+ const stride = bvh.recordsPerChunk * bvh.lanesPerRecord * bvh.LaneType.BYTES_PER_ELEMENT;
1655
+ const bvhStore = await SpillStore.create( storage, `${state.key}:bvh`, stride, { label: 'BLAS nodes', expectedBytes: bvh.recordCount * 64 } );
1656
+ const upload = uploader( bvh );
1657
+ const tlasChunks = Math.ceil( tlasNodeCount / bvh.recordsPerChunk );
1658
+ if ( bvhStore ) this._spillStores.push( bvhStore );
1659
+ let next = tlasChunks;
1660
+
1661
+ return async ( filledTo, last = false ) => {
1662
+
1663
+ while ( next < bvh.chunks.length && ( last || ( next + 1 ) * bvh.recordsPerChunk <= filledTo ) ) {
1664
+
1665
+ if ( ! bvh.chunks[ next ] ) bvh._materialize( next );
1666
+ upload( next );
1667
+ if ( bvhStore ) await bvh.spillChunk( next, bvhStore );
1668
+ next ++;
1669
+
1670
+ }
939
1671
 
940
- chunk[ o + 3 ] += nodeOffset;
941
- chunk[ o + 7 ] += nodeOffset;
1672
+ if ( last ) for ( let k = 0; k < Math.min( tlasChunks, bvh.chunks.length ); k ++ ) {
1673
+
1674
+ if ( ! bvh.chunks[ k ] ) bvh._materialize( k );
1675
+ upload( k );
942
1676
 
943
1677
  }
944
1678
 
1679
+ };
1680
+
1681
+ }
1682
+
1683
+ _offsetBLASInPlace( startNode, nodeCount, nodeOffset, triOffset ) {
1684
+
1685
+ const idx = this.bvhIndexChunks;
1686
+
1687
+ for ( let n = startNode, end = startNode + nodeCount; n < end; ) {
1688
+
1689
+ const chunk = idx.chunkFor( n );
1690
+ const stop = Math.min( end, ( ( ( n / idx.recordsPerChunk ) | 0 ) + 1 ) * idx.recordsPerChunk );
1691
+ const from = idx.baseOf( n );
1692
+ rebaseNodes( chunk, nodeOffset, triOffset, from, from + ( stop - n ) * 16 );
1693
+ n = stop;
1694
+
945
1695
  }
946
1696
 
947
1697
  }
@@ -956,32 +1706,63 @@ export class SceneProcessor {
956
1706
  * @returns {Promise<Array<{m, range, result}>>}
957
1707
  * @private
958
1708
  */
959
- _buildBLASesWithPool( tasks, opts, onProgress ) {
1709
+ _buildBLASesWithPool( tasks, opts, onProgress, onResult = null ) {
960
1710
 
961
- if ( tasks.length === 0 ) return Promise.resolve( [] );
1711
+ const pool = this._blasPool( opts, onProgress, onResult );
1712
+ for ( const task of tasks ) pool.submit( task );
1713
+ return pool.close();
962
1714
 
963
- const poolSize = Math.min( tasks.length, this.config.maxConcurrentTextureTasks || 4 );
1715
+ }
1716
+
1717
+ /**
1718
+ * A BLAS worker pool that takes builds while it runs: `submit( { m, range } )` queues one,
1719
+ * `close()` resolves with every result once the queue has drained. Workers start as work
1720
+ * arrives, up to the pool size.
1721
+ * @private
1722
+ */
1723
+ _blasPool( opts, onProgress, onResult = null, poolSize = this.config.maxConcurrentTextureTasks || 4 ) {
1724
+
1725
+ const queue = [];
964
1726
  const results = [];
965
- let nextTask = 0;
966
- let completed = 0;
1727
+ const workers = [];
1728
+ const idle = [];
1729
+ let closed = false, running = 0, completed = 0, failure = null;
1730
+ let settle;
1731
+ const done = new Promise( ( resolve, reject ) => settle = { resolve, reject } );
1732
+ done.catch( () => {} );
967
1733
 
968
- return new Promise( ( resolve, reject ) => {
1734
+ const fail = ( error ) => {
1735
+
1736
+ if ( failure ) return;
1737
+ failure = error;
1738
+ workers.forEach( w => w.terminate() );
1739
+ settle.reject( error );
969
1740
 
970
- const workers = [];
1741
+ };
971
1742
 
972
- const dispatchNext = ( worker ) => {
1743
+ const finishIfDrained = () => {
973
1744
 
974
- if ( nextTask >= tasks.length ) {
1745
+ if ( failure || ! closed || queue.length > 0 || running > 0 ) return;
1746
+ workers.forEach( w => w.terminate() );
1747
+ settle.resolve( results );
975
1748
 
976
- // No more tasks — terminate this worker
977
- worker.terminate();
978
- workers.splice( workers.indexOf( worker ), 1 );
979
- if ( workers.length === 0 ) resolve( results );
980
- return;
1749
+ };
981
1750
 
982
- }
1751
+ const dispatch = ( worker ) => {
1752
+
1753
+ const task = queue.shift();
1754
+ if ( ! task ) {
1755
+
1756
+ idle.push( worker );
1757
+ finishIfDrained();
1758
+ return;
1759
+
1760
+ }
1761
+
1762
+ const { m, range } = task;
1763
+ running ++;
1764
+ try {
983
1765
 
984
- const { m, range } = tasks[ nextTask ++ ];
985
1766
  const meshTriData = this.triangles.copyOf( range.start, range.count );
986
1767
 
987
1768
  // Disable treelet for tiny meshes
@@ -1002,84 +1783,122 @@ export class SceneProcessor {
1002
1783
  treeletOptimization: treeletOpts,
1003
1784
  reinsertionOptimization: opts.reinsertionOptimization,
1004
1785
  maxLeafSize: opts.maxLeafSize,
1786
+ foldLeaves: opts.foldLeaves,
1005
1787
  numBins: opts.numBins,
1006
1788
  maxBins: opts.maxBins,
1007
1789
  minBins: opts.minBins,
1008
1790
  logLevel: opts.logLevel,
1009
1791
  }, [ meshTriData.buffer ] );
1010
1792
 
1011
- };
1793
+ } catch ( error ) {
1012
1794
 
1013
- const onWorkerMessage = ( worker, e ) => {
1795
+ fail( error );
1014
1796
 
1015
- const data = e.data;
1797
+ }
1016
1798
 
1017
- if ( data.error ) {
1799
+ };
1018
1800
 
1019
- workers.forEach( w => w.terminate() );
1020
- reject( new Error( data.error ) );
1021
- return;
1801
+ const onWorkerMessage = ( worker, e ) => {
1022
1802
 
1023
- }
1803
+ const data = e.data;
1024
1804
 
1025
- if ( data.progress !== undefined ) return; // Ignore progress messages
1805
+ if ( data.error ) {
1026
1806
 
1027
- const { m, range } = worker._currentTask;
1807
+ fail( new Error( data.error ) );
1808
+ return;
1809
+
1810
+ }
1811
+
1812
+ if ( data.progress !== undefined ) return; // Ignore progress messages
1813
+
1814
+ const { m, range } = worker._currentTask;
1815
+ running --;
1816
+
1817
+ try {
1028
1818
 
1029
1819
  // Write back now: holding one copy per mesh until the pool drains is the whole
1030
1820
  // triangle buffer over again, ~3 GB at 40M.
1031
1821
  if ( data.triangles ) this.triangles.setRecords( range.start, data.triangles );
1032
1822
 
1033
- results.push( {
1034
- m,
1035
- range,
1036
- result: {
1037
- bvhData: data.bvhData,
1038
- originalToBvh: data.originalToBvh || null,
1039
- splitStats: data.treeletStats || null,
1040
- }
1041
- } );
1823
+ const result = {
1824
+ bvhData: data.bvhData,
1825
+ originalToBvh: data.originalToBvh || null,
1826
+ splitStats: data.treeletStats || null,
1827
+ };
1828
+ onResult?.( m, range, result );
1829
+ results.push( { m, range, result } );
1042
1830
 
1043
- completed ++;
1044
- onProgress?.( completed );
1831
+ } catch ( error ) {
1045
1832
 
1046
- dispatchNext( worker );
1833
+ // Thrown here, it would stop the pool with the promise never settled.
1834
+ fail( error );
1835
+ return;
1047
1836
 
1048
- };
1837
+ }
1049
1838
 
1050
- // Spin up the pool
1051
- ( async () => {
1839
+ completed ++;
1840
+ onProgress?.( completed );
1841
+ dispatch( worker );
1052
1842
 
1053
- for ( let i = 0; i < poolSize; i ++ ) {
1843
+ };
1054
1844
 
1055
- let worker;
1056
- try {
1845
+ const spawn = () => {
1057
1846
 
1058
- worker = new BVHWorker();
1847
+ const worker = new BVHWorker();
1848
+ worker.onmessage = ( e ) => onWorkerMessage( worker, e );
1849
+ worker.onerror = ( err ) => fail( err );
1850
+ workers.push( worker );
1851
+ return worker;
1059
1852
 
1060
- } catch ( e ) {
1853
+ };
1061
1854
 
1062
- reject( e );
1063
- return;
1855
+ return {
1064
1856
 
1065
- }
1857
+ submit( task ) {
1066
1858
 
1067
- worker.onmessage = ( e ) => onWorkerMessage( worker, e );
1068
- worker.onerror = ( err ) => {
1859
+ if ( failure ) return;
1860
+ queue.push( task );
1861
+ try {
1069
1862
 
1070
- workers.forEach( w => w.terminate() );
1071
- reject( err );
1863
+ const worker = idle.pop() ?? ( workers.length < poolSize ? spawn() : null );
1864
+ if ( worker ) dispatch( worker );
1072
1865
 
1073
- };
1866
+ } catch ( error ) {
1074
1867
 
1075
- workers.push( worker );
1076
- dispatchNext( worker );
1868
+ fail( error );
1077
1869
 
1078
1870
  }
1079
1871
 
1080
- } )().catch( reject );
1872
+ },
1081
1873
 
1082
- } );
1874
+ close() {
1875
+
1876
+ closed = true;
1877
+ finishIfDrained();
1878
+ return done;
1879
+
1880
+ },
1881
+
1882
+ abort( error ) {
1883
+
1884
+ fail( error ?? new Error( 'BLAS pool aborted' ) );
1885
+
1886
+ },
1887
+
1888
+ /** Builds queued or running. */
1889
+ get pending() {
1890
+
1891
+ return queue.length + running;
1892
+
1893
+ },
1894
+
1895
+ get error() {
1896
+
1897
+ return failure;
1898
+
1899
+ },
1900
+
1901
+ };
1083
1902
 
1084
1903
  }
1085
1904
 
@@ -1415,6 +2234,10 @@ export class SceneProcessor {
1415
2234
  */
1416
2235
  _reset() {
1417
2236
 
2237
+ this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2238
+ this._disposeSpill();
2239
+ this._disposeGeometrySpill();
2240
+
1418
2241
  // First dispose any existing resources
1419
2242
  this._disposeTextures();
1420
2243
 
@@ -1772,6 +2595,8 @@ export class SceneProcessor {
1772
2595
  */
1773
2596
  async refitBVH( newPositions, newNormals ) {
1774
2597
 
2598
+ this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2599
+
1775
2600
  if ( ! this.bvh || ! this.triangles || ! this.instanceTable ) {
1776
2601
 
1777
2602
  throw new Error( 'No BVH data available for refit. Run buildBVH() first.' );
@@ -1909,6 +2734,8 @@ export class SceneProcessor {
1909
2734
 
1910
2735
  refitBLASes( affectedMeshIndices, newPositions, newNormals ) {
1911
2736
 
2737
+ this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2738
+
1912
2739
  if ( ! this.instanceTable || ! this.bvh || ! this.triangles ) {
1913
2740
 
1914
2741
  throw new Error( 'No TLAS/BLAS data available. Run buildBVH() first.' );
@@ -2138,9 +2965,10 @@ export class SceneProcessor {
2138
2965
  * @param {import('../managers/LightManager.js').LightManager} lightManager
2139
2966
  * @param {import('three').Scene} meshScene
2140
2967
  * @param {import('three').Texture|null} environmentTexture
2968
+ * @param {{keepUserLights?: boolean}} [options] - an incremental rebuild keeps the lights a host added
2141
2969
  * @returns {boolean} false if critical data is missing
2142
2970
  */
2143
- uploadToPathTracer( pathTracer, lightManager, meshScene, environmentTexture ) {
2971
+ uploadToPathTracer( pathTracer, lightManager, meshScene, environmentTexture, { keepUserLights = false } = {} ) {
2144
2972
 
2145
2973
  if ( ! this.triangles ) {
2146
2974
 
@@ -2193,6 +3021,8 @@ export class SceneProcessor {
2193
3021
  this.emissiveTotalPower,
2194
3022
  this.emissiveBitTrailMap,
2195
3023
  );
3024
+ // The light buffer holds the map from here; a rebuild makes a new one.
3025
+ this._releaseBitTrailMap();
2196
3026
 
2197
3027
  }
2198
3028
 
@@ -2205,7 +3035,7 @@ export class SceneProcessor {
2205
3035
 
2206
3036
  }
2207
3037
 
2208
- lightManager.transferSceneLights( meshScene );
3038
+ lightManager.transferSceneLights( meshScene, { keepUserLights } );
2209
3039
  return true;
2210
3040
 
2211
3041
  }
@@ -2319,17 +3149,132 @@ export class SceneProcessor {
2319
3149
  this.emissiveTriangleCount = this.emissiveTriangleBuilder.emissiveCount;
2320
3150
  this.emissiveTotalPower = this.emissiveTriangleBuilder.totalEmissivePower;
2321
3151
 
3152
+ const bitTrailMap = this.emissiveBitTrailMap;
3153
+ this._releaseBitTrailMap();
3154
+
2322
3155
  return {
2323
3156
  rawData: this.emissiveTriangleData,
2324
3157
  emissiveCount: this.emissiveTriangleCount,
2325
3158
  totalPower: this.emissiveTotalPower,
2326
- bitTrailMap: this.emissiveBitTrailMap,
3159
+ bitTrailMap,
2327
3160
  lightBVHNodeData: this.lightBVHNodeData,
2328
3161
  lightBVHNodeCount: this.lightBVHNodeCount,
2329
3162
  };
2330
3163
 
2331
3164
  }
2332
3165
 
3166
+ /** Whether any triangle records or BLAS nodes are on disk (see {@link spillToDisk}). */
3167
+ get spilled() {
3168
+
3169
+ return !! ( this.triangles?.spilledChunks || this.bvh?.spilledChunks );
3170
+
3171
+ }
3172
+
3173
+ /**
3174
+ * Experimental memory spill: once the scene is on the GPU, moves the triangle records and the
3175
+ * BLAS part of the BVH to disk. The TLAS stays, so visibility and rigid moves need nothing
3176
+ * back; anything that reads triangles or BLAS nodes awaits {@link ensureResident} first.
3177
+ * Multi-chunk scenes only — below a chunk the saving is small and the cost is not.
3178
+ * @param {import('../Storage/StorageManager.js').StorageManager} storage
3179
+ * @returns {Promise<?{bytes: number, ms: number}>} null when nothing was spilled
3180
+ */
3181
+ async spillToDisk( storage ) {
3182
+
3183
+ const triangles = this.triangles;
3184
+ const bvh = this.bvh;
3185
+ if ( ! storage || this.spilled || this._spillStores || ! triangles?.chunks || triangles.chunks.length < 2 || ! bvh?.chunks ) return null;
3186
+
3187
+ await this._blasStoring;
3188
+ const version = this._geometryVersion;
3189
+ const started = performance.now();
3190
+ const key = `spill:${Date.now().toString( 36 )}:${Math.random().toString( 36 ).slice( 2 )}`;
3191
+ const stride = records => records.recordsPerChunk * records.lanesPerRecord * records.LaneType.BYTES_PER_ELEMENT;
3192
+
3193
+ const triStore = await SpillStore.create( storage, `${key}:tri`, stride( triangles ), { label: 'Triangle records', expectedBytes: triangles.byteLength } );
3194
+ const bvhStore = triStore && await SpillStore.create( storage, `${key}:bvh`, stride( bvh ), { label: 'BLAS nodes', expectedBytes: bvh.byteLength } );
3195
+ if ( ! triStore || ! bvhStore ) {
3196
+
3197
+ await triStore?.dispose();
3198
+ return null;
3199
+
3200
+ }
3201
+
3202
+ this._spillStores = [ triStore, bvhStore ];
3203
+ const tlasChunks = Math.ceil( ( this.instanceTable?.tlasNodeCount ?? 1 ) / bvh.recordsPerChunk );
3204
+
3205
+ let bytes = await triangles.spill( triStore );
3206
+ bytes += await bvh.spill( bvhStore, { keep: k => k < tlasChunks } );
3207
+ await triStore.flush();
3208
+ await bvhStore.flush();
3209
+
3210
+ if ( version !== this._geometryVersion ) return null;
3211
+ const ms = performance.now() - started;
3212
+ log.info( `spilled ${( bytes / 1048576 ).toFixed( 0 )} MB of triangles and BLAS nodes to disk in ${ms.toFixed( 0 )} ms` );
3213
+ return { bytes, ms };
3214
+
3215
+ }
3216
+
3217
+ /** Reads every spilled triangle record and BLAS node back, then drops the scratch files. */
3218
+ ensureResident() {
3219
+
3220
+ this._paging ??= ( async () => {
3221
+
3222
+ await this.triangles?.ensureResident?.();
3223
+ await this.bvh?.ensureResident?.();
3224
+ if ( ! this.spilled ) this._disposeSpill();
3225
+
3226
+ } )().finally( () => {
3227
+
3228
+ this._paging = null;
3229
+
3230
+ } );
3231
+
3232
+ return this._paging;
3233
+
3234
+ }
3235
+
3236
+ /** Reads the three.js geometry a streamed build moved to disk back into place. @private */
3237
+ async _restoreGeometry( object ) {
3238
+
3239
+ const spill = this._geometrySpill;
3240
+ if ( ! spill?.bytes ) return;
3241
+ updateLoading( { status: 'Reading scene geometry back...', progress: 84 } );
3242
+ const started = performance.now();
3243
+ const bytes = spill.bytes;
3244
+ await spill.restore();
3245
+ this._disposeGeometrySpill();
3246
+ this._geometryBytes = geometryBytesOf( object );
3247
+ log.info( `read ${fmt.mb( bytes )} of scene geometry back from disk in ${fmt.ms( performance.now() - started )}` );
3248
+ if ( spill.error ) log.warn( 'some scene geometry stayed in memory, its spill write failed:', spill.error );
3249
+
3250
+ }
3251
+
3252
+ /** @private */
3253
+ _disposeGeometrySpill() {
3254
+
3255
+ const spill = this._geometrySpill;
3256
+ this._geometrySpill = null;
3257
+ spill?.dispose().catch( () => {} );
3258
+
3259
+ }
3260
+
3261
+ /** @private */
3262
+ _disposeSpill() {
3263
+
3264
+ const stores = this._spillStores;
3265
+ this._spillStores = null;
3266
+ for ( const store of stores ?? [] ) store.dispose().catch( () => {} );
3267
+
3268
+ }
3269
+
3270
+ /** The map goes to the path tracer's light buffer and is not kept here as well. */
3271
+ _releaseBitTrailMap() {
3272
+
3273
+ this.emissiveBitTrailMap = null;
3274
+ if ( this.emissiveTriangleBuilder ) this.emissiveTriangleBuilder.emissiveBitTrailMap = null;
3275
+
3276
+ }
3277
+
2333
3278
  /**
2334
3279
  * Copy world-space positions into `dst`, each mesh's range carried into its own space.
2335
3280
  * @private
@@ -2417,6 +3362,8 @@ export class SceneProcessor {
2417
3362
  : ChunkedRecords.adopt( [ data ], data.length / 16, 16, data.length / 16 ) );
2418
3363
 
2419
3364
  this.bvh = records;
3365
+ // Read by the traversal when it is built: only a folded BVH gets that code.
3366
+ if ( records && this._foldLeaves ) records.foldedLeaves = true;
2420
3367
  this.bvhIndexChunks = records ? records.viewAs( Uint32Array ) : null;
2421
3368
  // Null once the BVH needs more than one chunk; hot paths use `bvh` / `bvhIndexChunks`.
2422
3369
  this.bvhData = records ? records.single : null;
@@ -2771,6 +3718,8 @@ export class SceneProcessor {
2771
3718
  */
2772
3719
  _swapBLAS( meshIdx, entry, workerData, onSwap ) {
2773
3720
 
3721
+ this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
3722
+
2774
3723
  const FPN = 16;
2775
3724
  const newBvhData = workerData.bvhData;
2776
3725
  const newNodeCount = newBvhData.length / FPN;
@@ -2885,6 +3834,8 @@ export class SceneProcessor {
2885
3834
  */
2886
3835
  dispose() {
2887
3836
 
3837
+ this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
3838
+
2888
3839
  this._log( 'Disposing resources' );
2889
3840
 
2890
3841
  // Dispose workers