rayzee 11.2.1 → 11.4.0

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