rayzee 11.2.1 → 11.3.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 (62) hide show
  1. package/README.md +58 -29
  2. package/dist/ArchiveImporter-D735xX-4.js +7860 -0
  3. package/dist/BidirectionalIntegrator-M89c0AlP.js +954 -0
  4. package/dist/{BufferLayout-D_IBo2ci.js → BufferLayout-xPRH9jYD.js} +41 -5
  5. package/dist/{NeuralRetouch-Cr-otSW8.js → NeuralRetouch-DiBH63kz.js} +1 -1
  6. package/dist/{NeuralSuperRes-B7E3_M0t.js → NeuralSuperRes-CZNQP13W.js} +1 -1
  7. package/dist/{PathTracer-D6nbknj5.js → PathTracer-BXCCVrPK.js} +4371 -4612
  8. package/dist/{PhysicalSky-Dq5VSiCQ.js → PhysicalSky-DqmaS0lb.js} +2 -2
  9. package/dist/{RayzeeRenderer-DbjqO6v-.js → RayzeeRenderer-DUSMFhmc.js} +426 -363
  10. package/dist/SolarLimb-bZE6y63S.js +762 -0
  11. package/dist/addons/archives.es.js +1 -1
  12. package/dist/addons/bidirectional.es.js +1 -1
  13. package/dist/addons/physical-sky.es.js +1 -1
  14. package/dist/rayzee-core.es.js +7 -7
  15. package/dist/rayzee.es.js +472 -468
  16. package/dist/rayzee.umd.js +106 -105
  17. package/dist/sceneCachePolicy-BAib5fCp.js +1602 -0
  18. package/package.json +1 -1
  19. package/src/PathTracerApp.js +4 -3
  20. package/src/Processor/ArchiveImporter.js +173 -94
  21. package/src/Processor/ArchiveReader.js +20 -23
  22. package/src/Processor/AssetLoader.js +48 -1
  23. package/src/Processor/BVHRefitter.js +109 -13
  24. package/src/Processor/BufferLayout.js +115 -0
  25. package/src/Processor/ChunkedRecords.js +3 -2
  26. package/src/Processor/EmissiveTriangleBuilder.js +6 -6
  27. package/src/Processor/GeometryExtractor.js +189 -79
  28. package/src/Processor/HostMemory.js +39 -9
  29. package/src/Processor/InstanceTable.js +714 -42
  30. package/src/Processor/PBRT/PBRTCurves.js +224 -6
  31. package/src/Processor/PBRT/PBRTMaterials.js +3 -0
  32. package/src/Processor/PBRT/PBRTSceneBuilder.js +82 -25
  33. package/src/Processor/PackedRayBuffer.js +5 -2
  34. package/src/Processor/SceneProcessor.js +747 -141
  35. package/src/Processor/TLASBuilder.js +32 -17
  36. package/src/Processor/USD/USDLayer.js +1149 -0
  37. package/src/Processor/USD/USDScene.js +1655 -0
  38. package/src/Processor/USD/USDStage.js +682 -0
  39. package/src/Processor/USD/USDText.js +674 -0
  40. package/src/Processor/USD/index.js +138 -0
  41. package/src/Processor/Workers/BVHRefitWorker.js +3 -3
  42. package/src/RayzeeRenderer.js +167 -26
  43. package/src/Stages/NormalDepth.js +7 -12
  44. package/src/Stages/PathTracer.js +12 -17
  45. package/src/Stages/PathTracerStage.js +273 -96
  46. package/src/Storage/GeometrySpill.js +46 -14
  47. package/src/Storage/SceneGraphCodec.js +17 -3
  48. package/src/Storage/SpillStore.js +9 -0
  49. package/src/TSL/BVHTraversal.js +183 -7
  50. package/src/TSL/Common.js +57 -13
  51. package/src/TSL/ShadeKernel.js +3 -2
  52. package/src/TSL/TextureSampling.js +11 -8
  53. package/src/TSL/patches.js +84 -0
  54. package/src/integrators/BidirectionalIntegrator.js +20 -2
  55. package/src/managers/DenoisingManager.js +4 -4
  56. package/src/managers/InteractionManager.js +19 -6
  57. package/src/managers/MaterialDataManager.js +22 -5
  58. package/src/managers/UniformManager.js +3 -0
  59. package/dist/ArchiveImporter-D_x863yu.js +0 -5198
  60. package/dist/BidirectionalIntegrator-DmkxxEOR.js +0 -946
  61. package/dist/SolarLimb-CPaeZVwz.js +0 -721
  62. package/dist/sceneCachePolicy-C4-4qwt6.js +0 -485
@@ -1,5 +1,5 @@
1
1
  import { storage } from 'three/tsl';
2
- import { gpuOnlyStorageAttribute, uploadStorageChunkRange, uploadStorageChunks } from '../TSL/patches.js';
2
+ import { StorageParts } from '../TSL/patches.js';
3
3
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
4
4
  import { Vector2, Matrix4 } from 'three';
5
5
 
@@ -21,8 +21,8 @@ import { LightSerializer } from '../Processor/LightSerializer';
21
21
 
22
22
  // Constants
23
23
  import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
24
- import { TRIANGLE_DATA_LAYOUT } from '../Processor/BufferLayout.js';
25
- import { TRI_GEO_ROWS, TRI_SHADE_ROWS } from '../TSL/Common.js';
24
+ import { TRIANGLE_DATA_LAYOUT, CLUSTER_COPY_HIDDEN, bvhIndexView } from '../Processor/BufferLayout.js';
25
+ import { TRI_GEO_ROWS, TRI_SHADE_ROWS, splitStorage } from '../TSL/Common.js';
26
26
  import { createLogger, fmt } from '../utils/Logger.js';
27
27
 
28
28
  // Triangles converted per staging pass: 256K is 12 + 8 MB of staging, kept for the stage's life.
@@ -30,6 +30,25 @@ const TRIANGLE_UPLOAD_SLICE = 1 << 18;
30
30
 
31
31
  const log = createLogger( 'pathtracer' );
32
32
 
33
+
34
+ // Storage buffers the shading kernel binds besides the BVH and triangle parts: rays, hits (with the RNG state and the
35
+ // G-buffer), counters, active indices, and the scene data (materials, then lights). Measured on the compiled WGSL.
36
+ const SHADE_OTHER_BINDINGS = 5;
37
+
38
+ /** Read-only storage nodes over `parts`, one node when it is a single buffer. */
39
+ function readParts( parts, type ) {
40
+
41
+ return splitStorage( parts.attrs.map( ( attr ) => storage( attr, type, attr.count ).toReadOnly() ), parts.elementsPerPart, type );
42
+
43
+ }
44
+
45
+ /** The `{ geo, shade }` pair kernels read triangles through (TSL/Common.js `triangleRow`). */
46
+ function triangleNodes( geo, shade ) {
47
+
48
+ return { geo: readParts( geo, 'uvec4' ), shade: readParts( shade, shade.withoutUV ? 'uvec2' : 'uvec4' ), withoutUV: shade.withoutUV === true };
49
+
50
+ }
51
+
33
52
  // How long after the last move the view still counts as moving. Matches CameraOptimizer's own
34
53
  // settle delay, so the answer does not change when interaction mode is switched off.
35
54
  const VIEW_SETTLE_MS = 100;
@@ -113,6 +132,7 @@ export class PathTracerStage extends RenderStage {
113
132
  // Initialize material data manager
114
133
  this.materialData = new MaterialDataManager( this.sdfs );
115
134
  this.materialData.callbacks.onReset = () => this.reset();
135
+ this.materialData.callbacks.adoptMaterials = ( data ) => this._adoptMaterials( data );
116
136
  // A material edit can switch the diffuse transmission lobe on or off, which the shade kernel compiles in.
117
137
  this.materialData.callbacks.onMaterialFeaturesChanged = () => {
118
138
 
@@ -625,7 +645,7 @@ export class PathTracerStage extends RenderStage {
625
645
  * @param {Uint32Array|import('../Processor/ChunkedRecords.js').ChunkedRecords} triangleData
626
646
  * @param {number} triangleCount - Number of triangles
627
647
  */
628
- setTriangleData( triangleData, triangleCount ) {
648
+ setTriangleData( triangleData, triangleCount, { textureCoordinates = true } = {} ) {
629
649
 
630
650
  if ( ! triangleData ) return;
631
651
 
@@ -633,45 +653,57 @@ export class PathTracerStage extends RenderStage {
633
653
  const flat = chunked ? null : ( triangleData.chunks ? triangleData.chunks[ 0 ] : triangleData );
634
654
  const records = chunked ? chunked.recordCount : Math.ceil( flat.length / TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
635
655
  const pre = chunked?._gpuUpload?.triangles ? chunked._gpuUpload : null;
636
- const { geo, shade } = pre ?? this._allocateTriangleBuffers( records );
656
+ const { geo, shade } = pre ?? this._allocateTriangleBuffers( records, { textureCoordinates } );
657
+ const withoutUV = shade.withoutUV === true;
658
+ const node = this.triangleStorageNode;
637
659
 
638
- if ( this.triangleStorageNode ) {
660
+ if ( node && geo.attrs.length === 1 && shade.attrs.length === 1 && ! node.geo.isSplitStorage && ! node.shade.isSplitStorage && !! node.withoutUV === withoutUV ) {
639
661
 
640
- this.triangleStorageNode.geo.value = geo;
641
- this.triangleStorageNode.geo.bufferCount = geo.count;
642
- this.triangleStorageNode.shade.value = shade;
643
- this.triangleStorageNode.shade.bufferCount = shade.count;
662
+ node.geo.value = geo.attrs[ 0 ];
663
+ node.geo.bufferCount = geo.attrs[ 0 ].count;
664
+ node.shade.value = shade.attrs[ 0 ];
665
+ node.shade.bufferCount = shade.attrs[ 0 ].count;
644
666
 
645
667
  } else {
646
668
 
647
- this.triangleStorageNode = {
648
- geo: storage( geo, 'uvec4', geo.count ).toReadOnly(),
649
- shade: storage( shade, 'uvec4', shade.count ).toReadOnly(),
650
- };
669
+ // Kernels rebuild with the scene (setupMaterial), so a new layout takes new nodes.
670
+ this.triangleStorageNode = triangleNodes( geo, shade );
651
671
 
652
672
  }
653
673
 
654
- this.triangleGeoAttr = geo;
655
- this.triangleShadeAttr = shade;
674
+ this._geoParts = geo;
675
+ this._shadeParts = shade;
676
+ this.triangleGeoAttr = geo.attrs[ 0 ];
677
+ this.triangleShadeAttr = shade.attrs[ 0 ];
656
678
  this._triangleRecords = chunked;
657
679
  this._triangleFlat = flat;
658
680
  this._triangleRecordCount = records;
659
681
  this.triangleCount = triangleCount;
660
682
  this._uploadTriangles( 0, records, { skipChunks: pre?.uploaded } );
683
+ // Adopted once: a later call with these records uploads them again, in the layout it asks for.
684
+ if ( pre ) chunked._gpuUpload = null;
661
685
 
662
686
  log.debug( `${fmt.n( this.triangleCount )} triangles (storage buffer)` );
663
687
 
664
688
  }
665
689
 
666
- /** GPU-only geo and shade buffers for `records` triangles, created now. @private */
667
- _allocateTriangleBuffers( records ) {
690
+ /**
691
+ * GPU-only geo and shade buffers for `records` triangles, in as many parts as one binding's limit needs. Without
692
+ * texture coordinates the shade buffer keeps only flags and mesh index, a uvec2 a triangle (TSL/Common.js
693
+ * `triangleRow`): 24 B a triangle less, 2.65 GB on the whole Moana island, which samples no texture. @private
694
+ */
695
+ _allocateTriangleBuffers( records, { textureCoordinates = true } = {} ) {
668
696
 
669
697
  const n = Math.max( 1, records );
670
- this._assertFitsGPU( n * TRI_GEO_ROWS * 16 );
671
- const geo = gpuOnlyStorageAttribute( n * TRI_GEO_ROWS, 4, Uint32Array );
672
- const shade = gpuOnlyStorageAttribute( n * TRI_SHADE_ROWS, 4, Uint32Array );
673
- this.renderer.backend.createStorageAttribute( geo );
674
- this.renderer.backend.createStorageAttribute( shade );
698
+ const max = this._maxPartBytes();
699
+ const geo = new StorageParts( n * TRI_GEO_ROWS * 4, TRI_GEO_ROWS * 4, max, Uint32Array );
700
+ const shade = textureCoordinates
701
+ ? new StorageParts( n * TRI_SHADE_ROWS * 4, TRI_SHADE_ROWS * 4, max, Uint32Array )
702
+ : new StorageParts( n * 2, 2, max, Uint32Array, 2 );
703
+ shade.withoutUV = ! textureCoordinates;
704
+ this._assertBindings( { geo: geo.attrs.length, shade: shade.attrs.length, bvh: 1 } );
705
+ geo.create( this.renderer.backend );
706
+ shade.create( this.renderer.backend );
675
707
  return { geo, shade };
676
708
 
677
709
  }
@@ -685,21 +717,22 @@ export class PathTracerStage extends RenderStage {
685
717
  * `source` writes those records instead of reading the stage's own (a chunk being handed over).
686
718
  * @private
687
719
  */
688
- _uploadTriangles( start, count, { geo = true, shade = true, skipChunks = null, source = null, sourceStart = 0, geoAttr = this.triangleGeoAttr, shadeAttr = this.triangleShadeAttr } = {} ) {
720
+ _uploadTriangles( start, count, { geo = true, shade = true, skipChunks = null, source = null, sourceStart = 0, geoParts = this._geoParts, shadeParts = this._shadeParts } = {} ) {
689
721
 
690
- if ( count <= 0 || ! geoAttr ) return;
722
+ if ( count <= 0 || ! geoParts ) return;
691
723
 
692
724
  const backend = this.renderer.backend;
693
- const geoBuffer = backend.get( geoAttr ).buffer;
694
- const shadeBuffer = backend.get( shadeAttr ).buffer;
695
725
  const LANES = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
696
- const GEO = TRI_GEO_ROWS * 4, SHADE = TRI_SHADE_ROWS * 4;
726
+ const GEO = TRI_GEO_ROWS * 4;
727
+ // Without texture coordinates only the flags and mesh index lanes go up.
728
+ const SHADE = shadeParts.withoutUV ? 2 : TRI_SHADE_ROWS * 4;
729
+ const SHADE_FROM = LANES - SHADE;
697
730
  const records = this._triangleRecords;
698
731
  const perChunk = source ? count : records ? records.recordsPerChunk : this._triangleRecordCount;
699
732
  // One staging pair for every upload: a fresh one per chunk was churn that failed to
700
733
  // allocate on a scene already at the edge of the address space.
701
734
  const slice = TRIANGLE_UPLOAD_SLICE;
702
- this._triangleStaging ??= { geo: new Uint32Array( slice * GEO ), shade: new Uint32Array( slice * SHADE ) };
735
+ this._triangleStaging ??= { geo: new Uint32Array( slice * GEO ), shade: new Uint32Array( slice * TRI_SHADE_ROWS * 4 ) };
703
736
  const geoStage = this._triangleStaging.geo;
704
737
  const shadeStage = this._triangleStaging.shade;
705
738
 
@@ -723,12 +756,12 @@ export class PathTracerStage extends RenderStage {
723
756
  for ( let i = 0, g = 0, h = 0; i < n; i ++, s += LANES ) {
724
757
 
725
758
  if ( geo ) for ( let l = 0; l < GEO; l ++ ) geoStage[ g ++ ] = src[ s + l ];
726
- if ( shade ) for ( let l = GEO; l < LANES; l ++ ) shadeStage[ h ++ ] = src[ s + l ];
759
+ if ( shade ) for ( let l = SHADE_FROM; l < LANES; l ++ ) shadeStage[ h ++ ] = src[ s + l ];
727
760
 
728
761
  }
729
762
 
730
- if ( geo ) backend.device.queue.writeBuffer( geoBuffer, at * GEO * 4, geoStage, 0, n * GEO );
731
- if ( shade ) backend.device.queue.writeBuffer( shadeBuffer, at * SHADE * 4, shadeStage, 0, n * SHADE );
763
+ if ( geo ) geoParts.write( backend, at * GEO, geoStage, 0, n * GEO );
764
+ if ( shade ) shadeParts.write( backend, at * SHADE, shadeStage, 0, n * SHADE );
732
765
  at += n;
733
766
 
734
767
  }
@@ -740,38 +773,48 @@ export class PathTracerStage extends RenderStage {
740
773
  * chunk into it, so a build can hand chunks over as it finishes them and let them go (the
741
774
  * memory spill). setTriangleData / setBVHData then adopt that buffer rather than upload again.
742
775
  * @param {import('../Processor/ChunkedRecords.js').ChunkedRecords} records
743
- * @returns {function(number): void} uploads chunk k from `records.chunks[ k ]`
776
+ * @returns {function(number): Promise<void>} uploads chunk k from `records.chunks[ k ]`; resolves with {@link drainUploads}
744
777
  */
745
778
  createChunkUploader( records ) {
746
779
 
747
- this._assertFitsGPU( records.recordCount * records.lanesPerRecord * records.LaneType.BYTES_PER_ELEMENT );
748
- const attr = gpuOnlyStorageAttribute( records.recordCount * records.lanesPerRecord / 4, 4, records.LaneType );
780
+ const parts = this._allocateBVHParts( records.recordCount * records.lanesPerRecord );
749
781
  const backend = this.renderer.backend;
750
- backend.createStorageAttribute( attr );
751
- const buffer = backend.get( attr ).buffer;
752
- const stride = records.recordsPerChunk * records.lanesPerRecord * records.LaneType.BYTES_PER_ELEMENT;
782
+ const lanesPerChunk = records.recordsPerChunk * records.lanesPerRecord;
753
783
  const uploaded = new Set();
754
- records._gpuUpload = { attr, uploaded };
784
+ records._gpuUpload = { parts, uploaded };
755
785
 
756
786
  return k => {
757
787
 
758
788
  const chunk = records.chunks[ k ];
759
- backend.device.queue.writeBuffer( buffer, k * stride, chunk, 0, chunk.length );
789
+ parts.write( backend, k * lanesPerChunk, chunk, 0, chunk.length );
760
790
  uploaded.add( k );
791
+ return this.drainUploads();
761
792
 
762
793
  };
763
794
 
764
795
  }
765
796
 
797
+ /**
798
+ * Resolves once the GPU has taken every upload so far. Chrome passes each upload through a shared memory pool that
799
+ * grows to the most ever sent before the GPU caught up and is kept until the page closes (2.2 GB on the Moana
800
+ * island); awaiting this after each chunk keeps it to one. Measured: 1 GB in 64 MB writes left 1 GB behind, paced
801
+ * 64 MB, in the same time.
802
+ */
803
+ drainUploads() {
804
+
805
+ return this.renderer.backend.device.queue.onSubmittedWorkDone();
806
+
807
+ }
808
+
766
809
  /**
767
810
  * createChunkUploader for triangle records: both GPU buffers are allocated now and each
768
811
  * chunk is split into them as it is handed over.
769
812
  * @param {import('../Processor/ChunkedRecords.js').ChunkedRecords} records
770
- * @returns {function(number): void}
813
+ * @returns {function(number): Promise<void>}
771
814
  */
772
- createTriangleChunkUploader( records ) {
815
+ createTriangleChunkUploader( records, { textureCoordinates = true } = {} ) {
773
816
 
774
- const { geo, shade } = this._allocateTriangleBuffers( records.recordCount );
817
+ const { geo, shade } = this._allocateTriangleBuffers( records.recordCount, { textureCoordinates } );
775
818
  const uploaded = new Set();
776
819
  records._gpuUpload = { triangles: true, geo, shade, uploaded };
777
820
  const perChunk = records.recordsPerChunk;
@@ -780,46 +823,79 @@ export class PathTracerStage extends RenderStage {
780
823
 
781
824
  const first = k * perChunk;
782
825
  const count = Math.min( perChunk, records.recordCount - first );
783
- this._uploadTriangles( first, count, { source: records.chunks[ k ], geoAttr: geo, shadeAttr: shade } );
826
+ this._uploadTriangles( first, count, { source: records.chunks[ k ], geoParts: geo, shadeParts: shade } );
784
827
  uploaded.add( k );
828
+ return this.drainUploads();
785
829
 
786
830
  };
787
831
 
788
832
  }
789
833
 
834
+ /** Fresh read-only nodes over the scene's BVH and triangles, for a stage that builds a kernel of its own. */
835
+ sceneStorageNodes() {
836
+
837
+ if ( ! this.bvhStorageAttr || ! this._geoParts ) return null;
838
+ const bvh = this._bvhParts ? readParts( this._bvhParts, 'vec4' ) : storage( this.bvhStorageAttr, 'vec4', this.bvhStorageAttr.count ).toReadOnly();
839
+ return { bvh, triangles: triangleNodes( this._geoParts, this._shadeParts ) };
840
+
841
+ }
842
+
790
843
  /**
791
- * One storage buffer holds all of a scene's triangles, and one its BVH. Past the device's
792
- * limit WebGPU hands back an invalid buffer and every write to it fails quietly.
844
+ * Most bytes one storage buffer may hold: past it WebGPU hands back an invalid buffer and every write to it fails
845
+ * quietly, so a larger store is split into parts. `bufferPartBytes` lowers it, for tests to force several parts.
793
846
  * @private
794
847
  */
795
- _assertFitsGPU( bytes ) {
848
+ _maxPartBytes() {
796
849
 
797
850
  const limits = this.renderer?.backend?.device?.limits;
798
851
  const max = Math.min( limits?.maxBufferSize ?? Infinity, limits?.maxStorageBufferBindingSize ?? Infinity );
799
- if ( bytes > max ) throw new Error( `the scene needs a ${fmt.mb( bytes )} GPU buffer, past the ${fmt.mb( max )} this GPU allows for one — load fewer triangles` );
852
+ return Math.min( max, this.bufferPartBytes ?? Infinity );
800
853
 
801
854
  }
802
855
 
803
- /** Uploads chunked records, or — for a buffer a build already filled — whatever it has not. @private */
804
- _uploadChunked( attr, records ) {
856
+ /**
857
+ * Every part of the tree and triangle stores is a binding of its own, and the shading kernel binds them all beside
858
+ * SHADE_OTHER_BINDINGS others, so past the device's per-stage limit no kernel could be built: refuse before upload.
859
+ * @private
860
+ */
861
+ _assertBindings( counts ) {
862
+
863
+ // The latest allocation of each: a build allocates its triangles before its BVH, and the previous scene's parts
864
+ // must not count against the next one.
865
+ this._partCounts = { ...( this._partCounts ?? { bvh: 1, geo: 1, shade: 1 } ), ...counts };
866
+ const { bvh, geo, shade } = this._partCounts;
867
+ const limit = this.renderer?.backend?.device?.limits?.maxStorageBuffersPerShaderStage ?? 10;
868
+ if ( SHADE_OTHER_BINDINGS + bvh + geo + shade <= limit ) return;
869
+ throw new Error(
870
+ `the scene's BVH and triangles need ${bvh + geo + shade} GPU buffers (BVH ${bvh}, triangles ${geo} + ${shade}), ` +
871
+ `more than the ${limit - SHADE_OTHER_BINDINGS} one GPU program can bind beside the rest — load fewer triangles or copies`
872
+ );
873
+
874
+ }
805
875
 
806
- const pre = records._gpuUpload;
807
- if ( ! pre || pre.attr !== attr ) {
876
+ /** GPU-only BVH buffers for `lanes` floats, in as many parts as one binding's limit needs. @private */
877
+ _allocateBVHParts( lanes ) {
808
878
 
809
- uploadStorageChunks( this.renderer, attr, records.chunks );
810
- return;
879
+ const parts = new StorageParts( lanes, 16, this._maxPartBytes(), Float32Array );
880
+ this._assertBindings( { bvh: parts.attrs.length } );
881
+ parts.create( this.renderer.backend );
882
+ return parts;
811
883
 
812
- }
884
+ }
885
+
886
+ /** Uploads chunked records into `parts`, or — for parts a build already filled — whatever it has not. @private */
887
+ _uploadChunked( parts, records ) {
813
888
 
814
- const buffer = this.renderer.backend.get( attr ).buffer;
815
- const stride = records.recordsPerChunk * records.lanesPerRecord * records.LaneType.BYTES_PER_ELEMENT;
889
+ const pre = records._gpuUpload?.parts === parts ? records._gpuUpload : null;
890
+ const lanesPerChunk = records.recordsPerChunk * records.lanesPerRecord;
891
+ const backend = this.renderer.backend;
816
892
  for ( let k = 0; k < records.chunks.length; k ++ ) {
817
893
 
818
- if ( pre.uploaded.has( k ) ) continue;
894
+ if ( pre?.uploaded.has( k ) ) continue;
819
895
  const chunk = records.chunks[ k ];
820
896
  if ( ! chunk ) throw new Error( `chunk ${k} is on disk and was never uploaded` );
821
- this.renderer.backend.device.queue.writeBuffer( buffer, k * stride, chunk, 0, chunk.length );
822
- pre.uploaded.add( k );
897
+ parts.write( backend, k * lanesPerChunk, chunk, 0, chunk.length );
898
+ pre?.uploaded.add( k );
823
899
 
824
900
  }
825
901
 
@@ -839,28 +915,31 @@ export class PathTracerStage extends RenderStage {
839
915
  const flat = chunked ? null : ( bvhImageData.chunks ? bvhImageData.chunks[ 0 ] : bvhImageData );
840
916
  const lanes = chunked ? chunked.recordCount * chunked.lanesPerRecord : flat.length;
841
917
  const vec4Count = lanes / 4;
842
- this._assertFitsGPU( lanes * 4 );
843
918
 
844
- const makeAttr = () => chunked
845
- ? chunked._gpuUpload?.attr ?? gpuOnlyStorageAttribute( vec4Count, 4, Float32Array )
846
- : new StorageInstancedBufferAttribute( flat, 4 );
919
+ // A flat array keeps a CPU-backed attribute (patches go up through its update ranges); chunked records go to
920
+ // GPU-only parts, as many as one binding's limit needs.
921
+ const parts = chunked ? chunked._gpuUpload?.parts ?? this._allocateBVHParts( lanes ) : null;
922
+ const attrs = parts ? parts.attrs : [ new StorageInstancedBufferAttribute( flat, 4 ) ];
923
+ if ( ! parts ) this._assertBindings( { bvh: 1 } );
847
924
 
848
- if ( this.bvhStorageNode ) {
925
+ if ( this.bvhStorageNode && attrs.length === 1 && ! this.bvhStorageNode.isSplitStorage ) {
849
926
 
850
- this.bvhStorageAttr = makeAttr();
851
- this.bvhStorageNode.value = this.bvhStorageAttr;
927
+ this.bvhStorageNode.value = attrs[ 0 ];
852
928
  this.bvhStorageNode.bufferCount = vec4Count;
853
929
 
854
930
  } else {
855
931
 
856
- this.bvhStorageAttr = makeAttr();
857
- this.bvhStorageNode = storage( this.bvhStorageAttr, 'vec4', vec4Count ).toReadOnly();
932
+ this.bvhStorageNode = parts ? readParts( parts, 'vec4' ) : storage( attrs[ 0 ], 'vec4', vec4Count ).toReadOnly();
858
933
 
859
934
  }
860
935
 
936
+ this.bvhStorageAttr = attrs[ 0 ];
861
937
  this.bvhStorageAttr.foldedLeaves = bvhImageData.foldedLeaves === true;
938
+ this.bvhStorageAttr.copyRecordBase = bvhImageData.copyRecordBase;
939
+ this._bvhParts = parts;
862
940
  this._bvhRecords = chunked;
863
- if ( chunked ) this._uploadChunked( this.bvhStorageAttr, chunked );
941
+ if ( chunked ) this._uploadChunked( parts, chunked );
942
+ if ( chunked ) chunked._gpuUpload = null;
864
943
 
865
944
  this.bvhNodeCount = Math.floor( vec4Count / BVH_VEC4_PER_NODE );
866
945
  log.debug( `${fmt.n( this.bvhNodeCount )} BVH nodes (storage buffer)` );
@@ -913,6 +992,29 @@ export class PathTracerStage extends RenderStage {
913
992
 
914
993
  }
915
994
 
995
+ this._patchGroupNodes();
996
+
997
+ }
998
+
999
+ /**
1000
+ * Rewrites the group trees: a hidden member's child points at the empty leaf. Every copy of a group shares its
1001
+ * tree, as every instance of an InstancedMesh shares its visibility.
1002
+ * @private
1003
+ */
1004
+ _patchGroupNodes() {
1005
+
1006
+ const table = this._instanceTable;
1007
+ if ( ! table?.groupNodeCount || ! this.bvhStorageAttr ) return;
1008
+
1009
+ table.computeGroupAABBs();
1010
+ const block = table.groupNodeBlock();
1011
+ const start = table.groupNodeStart;
1012
+ if ( this._bvhRecords ) this._bvhRecords.setRecords( start, block );
1013
+ else this.bvhStorageAttr.array.set( block, start * 16 );
1014
+
1015
+ this._dirtyBVHLeaves ??= new Set();
1016
+ for ( let n = 0; n < table.groupNodeCount; n ++ ) this._dirtyBVHLeaves.add( start + n );
1017
+
916
1018
  }
917
1019
 
918
1020
  /**
@@ -922,8 +1024,13 @@ export class PathTracerStage extends RenderStage {
922
1024
  */
923
1025
  updateMeshVisibility( meshIndex, visible ) {
924
1026
 
925
- if ( ! this._patchTLASLeafVisibility( meshIndex, visible ) ) return;
926
- this._flushBVHEdits();
1027
+ this._whenTLASResident( () => {
1028
+
1029
+ if ( ! this._patchTLASLeafVisibility( meshIndex, visible ) ) return;
1030
+ this._patchGroupNodes();
1031
+ this._flushBVHEdits();
1032
+
1033
+ } );
927
1034
 
928
1035
  }
929
1036
 
@@ -935,8 +1042,30 @@ export class PathTracerStage extends RenderStage {
935
1042
 
936
1043
  if ( ! this._meshRefs || ! this._instanceTable ) return;
937
1044
 
938
- this._patchVisibilityFromMeshes( this._meshRefs );
939
- this._flushBVHEdits();
1045
+ this._whenTLASResident( () => {
1046
+
1047
+ this._patchVisibilityFromMeshes( this._meshRefs );
1048
+ this._flushBVHEdits();
1049
+
1050
+ } );
1051
+
1052
+ }
1053
+
1054
+ /**
1055
+ * Runs `edit` once the TLAS is in memory: a spilling build of clustered copies leaves it on disk (SceneProcessor
1056
+ * `_spillTLAS`), and the first edit reads it back, where it then stays.
1057
+ * @private
1058
+ */
1059
+ _whenTLASResident( edit ) {
1060
+
1061
+ const records = this._bvhRecords, table = this._instanceTable;
1062
+ if ( ! records || ! table || records.isResident( 0, table.blasBase ) ) return edit();
1063
+ this._tlasReading ??= records.ensureResident( 0, table.blasBase ).finally( () => {
1064
+
1065
+ this._tlasReading = null;
1066
+
1067
+ } );
1068
+ this._tlasReading.then( edit );
940
1069
 
941
1070
  }
942
1071
 
@@ -950,14 +1079,25 @@ export class PathTracerStage extends RenderStage {
950
1079
  const table = this._instanceTable;
951
1080
  if ( ! table || ! table.isSet?.[ meshIndex ] || ! this.bvhStorageAttr ) return false;
952
1081
 
953
- const leaf = table.tlasLeafIndex[ meshIndex ];
1082
+ const leaf = table.leafOfPlacement( meshIndex );
954
1083
  if ( leaf < 0 ) return false;
955
1084
 
956
1085
  table.visible[ meshIndex ] = visible ? 1 : 0;
1086
+ // A group copy's leaf is shared by its members: it stays in while any of them is visible.
1087
+ const shown = table.entryVisible( table.entryOf( meshIndex ) );
957
1088
 
958
1089
  // A chunked attribute owns no CPU array; write into the chunk that holds this leaf.
959
- if ( this._bvhRecords ) this._bvhRecords.chunkFor( leaf )[ this._bvhRecords.baseOf( leaf ) + 2 ] = visible ? 1.0 : 0.0;
960
- else this.bvhStorageAttr.array[ leaf * 16 + 2 ] = visible ? 1.0 : 0.0;
1090
+ const f = this._bvhRecords ? this._bvhRecords.chunkFor( leaf ) : this.bvhStorageAttr.array;
1091
+ const o = this._bvhRecords ? this._bvhRecords.baseOf( leaf ) : leaf * 16;
1092
+ if ( table.clusterCount ) {
1093
+
1094
+ const views = this._indexViews ??= new WeakMap();
1095
+ let idx = views.get( f );
1096
+ if ( ! idx ) views.set( f, idx = bvhIndexView( f ) );
1097
+ const w = o + table.copyWordSlot( table.tlasLeafIndex[ meshIndex ] );
1098
+ idx[ w ] = shown ? idx[ w ] & ~ CLUSTER_COPY_HIDDEN : ( idx[ w ] | CLUSTER_COPY_HIDDEN ) >>> 0;
1099
+
1100
+ } else f[ o + 2 ] = shown ? 1.0 : 0.0;
961
1101
 
962
1102
  this._dirtyBVHLeaves ??= new Set();
963
1103
  this._dirtyBVHLeaves.add( leaf );
@@ -999,7 +1139,7 @@ export class PathTracerStage extends RenderStage {
999
1139
 
1000
1140
  for ( const leaf of this._dirtyBVHLeaves ?? [] ) {
1001
1141
 
1002
- uploadStorageChunkRange( this.renderer, this.bvhStorageAttr, this._bvhRecords, leaf * 16, 16 );
1142
+ this._bvhParts.writeRecords( this.renderer.backend, this._bvhRecords, leaf * 16, 16 );
1003
1143
 
1004
1144
  }
1005
1145
 
@@ -1094,7 +1234,7 @@ export class PathTracerStage extends RenderStage {
1094
1234
 
1095
1235
  if ( bvhData?.chunks?.length > 1 ) this._bvhRecords = bvhData;
1096
1236
  if ( ranges ) this.updateBufferRanges( [], ranges );
1097
- else uploadStorageChunks( this.renderer, this.bvhStorageAttr, this._bvhRecords.chunks );
1237
+ else this._bvhParts.writeRecords( this.renderer.backend, this._bvhRecords, 0, this._bvhRecords.recordCount * this._bvhRecords.lanesPerRecord );
1098
1238
  return;
1099
1239
 
1100
1240
  }
@@ -1142,7 +1282,7 @@ export class PathTracerStage extends RenderStage {
1142
1282
 
1143
1283
  for ( const r of bvhRanges ) {
1144
1284
 
1145
- uploadStorageChunkRange( this.renderer, this.bvhStorageAttr, this._bvhRecords, r.offset, r.count );
1285
+ this._bvhParts.writeRecords( this.renderer.backend, this._bvhRecords, r.offset, r.count );
1146
1286
 
1147
1287
  }
1148
1288
 
@@ -1468,15 +1608,28 @@ export class PathTracerStage extends RenderStage {
1468
1608
  setBlueNoiseTexture() {}
1469
1609
 
1470
1610
  /**
1471
- * Rebuild the packed light buffer from cached lightBVH + emissive data.
1472
- * Layout: [ lightBVH (LBVH_STRIDE vec4s per node) | emissive (EMISSIVE_STRIDE vec4s per entry) ].
1473
- * Also updates `emissiveVec4Offset` uniform (in vec4 elements).
1611
+ * Takes the material block into the scene data buffer, in front of the light data, and hands the buffer back as
1612
+ * the material storage. @private
1613
+ */
1614
+ _adoptMaterials( data ) {
1615
+
1616
+ this._materialFloats = data.length;
1617
+ this._pendingMaterials = data;
1618
+ this._rebuildLightBuffer();
1619
+ return { attr: this.lightStorageAttr, node: this.lightStorageNode };
1620
+
1621
+ }
1622
+
1623
+ /**
1624
+ * Rebuild the scene data buffer: [ materials | lightBVH (LBVH_STRIDE vec4s per node) | emissive (EMISSIVE_STRIDE
1625
+ * vec4s per entry) | bit-trail map ]. One binding for materials and lights: Shade's ten were all spoken for. The
1626
+ * light offsets are within the light data, which starts at `lightVec4Offset`.
1474
1627
  *
1475
1628
  * The compiled wavefront kernels bind the attribute that existed at kernel-build
1476
1629
  * time and NEVER pick up a `lightStorageNode.value` reassignment — so runtime
1477
- * updates MUST write in-place into the bound attribute (+ needsUpdate). Only
1478
- * grow-reallocate when the data outgrows capacity, and flag it so the kernels
1479
- * get rebuilt against the new attribute.
1630
+ * updates MUST write in-place into the bound attribute. Only grow-reallocate when the data outgrows capacity, and
1631
+ * flag it so the kernels get rebuilt against the new attribute. Materials are edited in place (MaterialDataManager)
1632
+ * and carried over as they stand; only a new material block replaces them.
1480
1633
  * @private
1481
1634
  */
1482
1635
  _rebuildLightBuffer() {
@@ -1484,9 +1637,12 @@ export class PathTracerStage extends RenderStage {
1484
1637
  const LBVH_STRIDE = 4; // vec4s per LBVH node — must match LightBVHSampling.js
1485
1638
  const lbvh = this._lbvhDataCache;
1486
1639
  const emis = this._emissiveDataCache;
1640
+ const held = this.lightStorageAttr?.array;
1641
+ const matLen = this._materialFloats ?? 0;
1642
+ const materials = this._pendingMaterials ?? ( held && matLen ? held.subarray( 0, matLen ) : null );
1643
+ this._pendingMaterials = null;
1487
1644
  // The bit-trail map (4 B a triangle) lives only in the light buffer: without a new one, the
1488
1645
  // current one is carried over from there rather than kept as a second copy.
1489
- const held = this.lightStorageAttr?.array;
1490
1646
  const trail = this._bitTrailMapCache
1491
1647
  ?? ( held && this._bitTrailLength ? held.slice( this._bitTrailOffset, this._bitTrailOffset + this._bitTrailLength ) : null );
1492
1648
  this._bitTrailMapCache = null;
@@ -1496,28 +1652,36 @@ export class PathTracerStage extends RenderStage {
1496
1652
  const trailPadded = trail ? Math.ceil( trail.length / 4 ) * 4 : 0;
1497
1653
 
1498
1654
  // Ensure at least a minimal non-empty buffer so GPU allocation remains valid.
1499
- const totalLen = Math.max( lbvhLen + emisLen + trailPadded, 4 );
1655
+ const totalLen = Math.max( matLen + lbvhLen + emisLen + trailPadded, 4 );
1656
+ const reuse = this.lightStorageAttr && totalLen <= this.lightStorageAttr.array.length && this._builtMaterialFloats === matLen;
1500
1657
 
1501
- if ( this.lightStorageAttr && totalLen <= this.lightStorageAttr.array.length ) {
1658
+ if ( reuse ) {
1502
1659
 
1503
1660
  // In-place update of the bound attribute. Stale floats past the new data
1504
1661
  // are unreachable — all reads are gated by the count/offset uniforms.
1505
1662
  const arr = this.lightStorageAttr.array;
1506
- if ( lbvh ) arr.set( lbvh, 0 );
1507
- if ( emis ) arr.set( emis, lbvhLen );
1508
- if ( trail ) arr.set( trail, lbvhLen + emisLen );
1663
+ const newMaterials = materials && materials.buffer !== arr.buffer;
1664
+ if ( newMaterials ) arr.set( materials, 0 );
1665
+ if ( lbvh ) arr.set( lbvh, matLen );
1666
+ if ( emis ) arr.set( emis, matLen + lbvhLen );
1667
+ if ( trail ) arr.set( trail, matLen + lbvhLen + emisLen );
1668
+ // A range, not the whole buffer: a material edit's own range may be pending beside it (pitfall 13).
1669
+ const from = newMaterials ? 0 : matLen;
1670
+ this.lightStorageAttr.addUpdateRange( from, totalLen - from );
1509
1671
  this.lightStorageAttr.needsUpdate = true;
1510
1672
 
1511
1673
  } else {
1512
1674
 
1513
1675
  const combined = new Float32Array( totalLen );
1514
- if ( lbvh ) combined.set( lbvh, 0 );
1515
- if ( emis ) combined.set( emis, lbvhLen );
1516
- if ( trail ) combined.set( trail, lbvhLen + emisLen );
1676
+ if ( materials ) combined.set( materials, 0 );
1677
+ if ( lbvh ) combined.set( lbvh, matLen );
1678
+ if ( emis ) combined.set( emis, matLen + lbvhLen );
1679
+ if ( trail ) combined.set( trail, matLen + lbvhLen + emisLen );
1517
1680
 
1518
1681
  this.lightStorageAttr = new StorageInstancedBufferAttribute( combined, 4 );
1519
1682
  this.lightStorageNode.value = this.lightStorageAttr;
1520
1683
  this.lightStorageNode.bufferCount = combined.length / 4;
1684
+ if ( this.materialData && this.materialData.materialStorageNode === this.lightStorageNode ) this.materialData.materialStorageAttr = this.lightStorageAttr;
1521
1685
 
1522
1686
  // Already-compiled kernels still bind the old attribute — request a rebuild.
1523
1687
  // (During scene load this is a no-op: setupMaterial rebuilds kernels anyway.)
@@ -1525,16 +1689,29 @@ export class PathTracerStage extends RenderStage {
1525
1689
 
1526
1690
  }
1527
1691
 
1692
+ this._builtMaterialFloats = matLen;
1693
+ this.lightVec4Offset.value = matLen / 4;
1528
1694
  // Offset (in vec4 elements) where emissive data starts.
1529
1695
  this.emissiveVec4Offset.value = ( this.lightBVHNodeCount.value || 0 ) * LBVH_STRIDE;
1530
1696
  // Offset (in vec4 elements) where the bit-trail map starts (lbvhLen + emisLen are float
1531
1697
  // counts, both multiples of 4, so this divides cleanly).
1532
1698
  this.reverseMapVec4Offset.value = ( lbvhLen + emisLen ) / 4;
1533
- this._bitTrailOffset = lbvhLen + emisLen;
1699
+ this._bitTrailOffset = matLen + lbvhLen + emisLen;
1534
1700
  this._bitTrailLength = trail ? trail.length : 0;
1535
1701
 
1536
1702
  }
1537
1703
 
1704
+ /**
1705
+ * The light data as kernels read it: the scene data buffer behind the materials. One binding with the materials,
1706
+ * whichever of the two a kernel reads.
1707
+ */
1708
+ get lightDataNode() {
1709
+
1710
+ const node = this.lightStorageNode, base = this.lightVec4Offset;
1711
+ return { isLightData: true, node, element: ( index ) => node.element( base.add( index ) ) };
1712
+
1713
+ }
1714
+
1538
1715
  setEmissiveTriangleData( emissiveData, count, totalPower = 0, bitTrailMap = null ) {
1539
1716
 
1540
1717
  if ( ! emissiveData ) return;