rayzee 9.3.2 → 9.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.
@@ -2,15 +2,112 @@ let canvas, ctx;
2
2
 
3
3
  // Memory limits and chunking configuration
4
4
  const MEMORY_LIMITS = {
5
- MAX_BYTES_PER_TEXTURE: 256 * 1024 * 1024, // 256MB per texture array
6
5
  MAX_TEXTURE_DIMENSION: 8192, // Hardware ceiling (WebGPU maxTextureDimension2D guaranteed min); the per-scene maxTextureSize setting is the actual knob
7
6
  CHUNK_SIZE: 8, // Optimized: Process textures in chunks of 8 for better memory locality
8
7
  ADAPTIVE_CHUNK_SIZE: true, // Enable adaptive chunk sizing based on texture dimensions
9
8
  MEMORY_SAFETY_FACTOR: 0.8 // Use only 80% of estimated available memory
10
9
  };
11
10
 
11
+ let stream = null;
12
+
13
+ function handleStream( msg ) {
14
+
15
+ if ( msg.stream === 'begin' ) {
16
+
17
+ const { width, height, depth } = msg;
18
+ const target = new OffscreenCanvas( width, height );
19
+ const context = target.getContext( '2d', { willReadFrequently: true, alpha: true, desynchronized: true } );
20
+ stream = { width, height, depth, context, data: new Uint8Array( width * height * depth * 4 ) };
21
+ return;
22
+
23
+ }
24
+
25
+ const { width, height, depth, context, data } = stream;
26
+
27
+ if ( msg.stream === 'layer' ) {
28
+
29
+ context.clearRect( 0, 0, width, height );
30
+ context.drawImage( msg.bitmap, 0, 0 );
31
+ msg.bitmap.close();
32
+ data.set( context.getImageData( 0, 0, width, height ).data, width * height * 4 * msg.index );
33
+ self.postMessage( { stream: 'ack', index: msg.index } );
34
+ return;
35
+
36
+ }
37
+
38
+ stream = null;
39
+ self.postMessage( { data: data.buffer, width, height, depth }, [ data.buffer ] );
40
+
41
+ }
42
+
43
+ async function processIntoShared( { shared, width, height, layers } ) {
44
+
45
+ if ( canvas.width !== width || canvas.height !== height ) {
46
+
47
+ canvas.width = width;
48
+ canvas.height = height;
49
+
50
+ }
51
+
52
+ ctx.imageSmoothingEnabled = true;
53
+ ctx.imageSmoothingQuality = 'high';
54
+
55
+ const data = new Uint8Array( shared );
56
+ const bytes = width * height * 4;
57
+
58
+ for ( const { index, texture } of layers ) {
59
+
60
+ try {
61
+
62
+ await processSingleTextureOptimized( texture, data, index * bytes, width, height );
63
+
64
+ } catch ( error ) {
65
+
66
+ console.warn( `Failed to process texture ${index}:`, error );
67
+ data.fill( 0, index * bytes, ( index + 1 ) * bytes );
68
+
69
+ }
70
+
71
+ }
72
+
73
+ }
74
+
12
75
  self.onmessage = async function ( e ) {
13
76
 
77
+ if ( e.data.shared ) {
78
+
79
+ try {
80
+
81
+ if ( ! canvas ) initializeWorker( e.data.maxTextureSize );
82
+ await processIntoShared( e.data );
83
+ self.postMessage( { done: true } );
84
+
85
+ } catch ( error ) {
86
+
87
+ self.postMessage( { error: error.message } );
88
+
89
+ }
90
+
91
+ return;
92
+
93
+ }
94
+
95
+ if ( e.data.stream ) {
96
+
97
+ try {
98
+
99
+ handleStream( e.data );
100
+
101
+ } catch ( error ) {
102
+
103
+ self.postMessage( { error: error.message } );
104
+
105
+ }
106
+
107
+ return;
108
+
109
+ }
110
+
14
111
  const { textures, maxTextureSize, method = 'direct-transfer' } = e.data;
15
112
 
16
113
  try {
@@ -51,17 +148,6 @@ function initializeWorker( maxTextureSize ) {
51
148
 
52
149
  async function processTextures( textures, maxTextureSize, method ) {
53
150
 
54
- // Check if we need to use chunked processing
55
- const dimensions = calculateOptimalDimensions( textures, maxTextureSize );
56
- const estimatedBytes = dimensions.maxWidth * dimensions.maxHeight * textures.length * 4;
57
-
58
- if ( estimatedBytes > MEMORY_LIMITS.MAX_BYTES_PER_TEXTURE ) {
59
-
60
- console.log( `Large texture array detected (${( estimatedBytes / 1024 / 1024 ).toFixed( 2 )}MB), using chunked processing` );
61
- return await processTexturesInChunks( textures, maxTextureSize, method );
62
-
63
- }
64
-
65
151
  switch ( method ) {
66
152
 
67
153
  case 'direct-transfer':
@@ -118,6 +118,7 @@ export class PathTracer extends PathTracerStage {
118
118
  // so when one lands cannot change the image. { due, read, apply } while one is in flight.
119
119
  this._lockstep = false;
120
120
  this._lockstepRead = null;
121
+ this._accumulationLockstep = false;
121
122
 
122
123
  // Tier-2: last settled active-pixel count (maxRays − frozen), sizes next frame's bounce-0 grid.
123
124
  // 0 until a settled readback lands (and on reset/camera-move/resize) → grid stays full-size until then.
@@ -240,8 +241,8 @@ export class PathTracer extends PathTracerStage {
240
241
  // Kernels not built yet (first frame / mid-resize) — skip until ready.
241
242
  if ( ! this.isReady || ! this._wavefrontReady ) return;
242
243
 
243
- // The packed light buffer was grow-reallocated at runtime (emissive set grew) —
244
- // the compiled kernels still bind the old attribute, so rebuild before rendering.
244
+ // The packed light buffer was grow-reallocated at runtime (emissive set grew) or a light list grew —
245
+ // the compiled kernels still bind the old one, so rebuild before rendering.
245
246
  if ( this._lightBufferRealloc ) {
246
247
 
247
248
  if ( this._kernelManager ) this._kernelManager.dispose();
@@ -320,6 +321,7 @@ export class PathTracer extends PathTracerStage {
320
321
  this.frame.value = frameValue;
321
322
  this.seedFrame.value = this._seedTick ++;
322
323
  this.tracedFrames ++;
324
+ if ( ! this._lockstep ) this._accumulationLockstep = false;
323
325
 
324
326
  this._setWfDispatch();
325
327
 
@@ -661,6 +663,8 @@ export class PathTracer extends PathTracerStage {
661
663
  this._convergedFraction = state.convergedFraction ?? 0;
662
664
  this._convergedGeometryFraction = state.convergedGeometryFraction ?? 0;
663
665
  this.isComplete = false;
666
+ // The survivor curve is not in a checkpoint, so a continuation is not the lockstep render.
667
+ this._accumulationLockstep = false;
664
668
 
665
669
  }
666
670
 
@@ -711,6 +715,13 @@ export class PathTracer extends PathTracerStage {
711
715
 
712
716
  }
713
717
 
718
+ /** Whether the current image was traced in lockstep from a reset made in it; with no sample yet, the setting. */
719
+ get accumulationLockstep() {
720
+
721
+ return this.frameCount > 0 ? this._accumulationLockstep : this._lockstep;
722
+
723
+ }
724
+
714
725
  /**
715
726
  * While a lockstep readback is due and has not landed, render() traces nothing: await this
716
727
  * before the next frame. Null otherwise.
@@ -10,7 +10,7 @@ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
10
10
  import { CameraOptimizer } from '../Processor/CameraOptimizer.js';
11
11
  import { createPerformanceMonitor, calculateAccumulationAlpha, updateCompletionThreshold } from '../Processor/utils.js';
12
12
  import { StorageTexturePool } from '../Processor/StorageTexturePool.js';
13
- import { UniformManager } from '../managers/UniformManager.js';
13
+ import { UniformManager, LIGHT_FLOATS, lightListCapacity } from '../managers/UniformManager.js';
14
14
  import { MaterialDataManager } from '../managers/MaterialDataManager.js';
15
15
  import { EnvironmentManager } from '../managers/EnvironmentManager.js';
16
16
  import { ShaderBuilder } from '../Processor/ShaderBuilder.js';
@@ -218,8 +218,8 @@ export class PathTracerStage extends RenderStage {
218
218
  // Initialized with dummy data so TSL compilation never sees null.
219
219
  this.lightStorageAttr = new StorageInstancedBufferAttribute( new Float32Array( 16 ), 4 );
220
220
  this.lightStorageNode = storage( this.lightStorageAttr, 'vec4', 1 ).toReadOnly();
221
- // Set when _rebuildLightBuffer had to grow-reallocate the attribute: compiled
222
- // kernels still bind the old one and must be rebuilt to see the new data.
221
+ // Set when _rebuildLightBuffer had to grow-reallocate the attribute, or a light list grew
222
+ // (_writeLightList): compiled kernels still bind the old one and must be rebuilt to see the new data.
223
223
  this._lightBufferRealloc = false;
224
224
 
225
225
  // Cached CPU-side data — rebuilt into the packed buffer whenever any source changes.
@@ -492,53 +492,36 @@ export class PathTracerStage extends RenderStage {
492
492
  */
493
493
  _updateLightBufferNodes() {
494
494
 
495
- // Directional lights (12 floats per light — 8 light fields + gobo {index, signed intensity, scale, pad})
496
- if ( this.directionalLightsData && this.directionalLightsData.length > 0 ) {
495
+ this._writeLightList( 'directional', this.directionalLightsData, this.numDirectionalLights );
496
+ this._writeLightList( 'area', this.areaLightsData, this.numAreaLights );
497
+ this._writeLightList( 'point', this.pointLightsData, this.numPointLights );
498
+ this._writeLightList( 'spot', this.spotLightsData, this.numSpotLights );
497
499
 
498
- this.directionalLightsBufferNode.array = Array.from( this.directionalLightsData );
499
- this.numDirectionalLights.value = Math.floor( this.directionalLightsData.length / 12 );
500
-
501
- } else {
502
-
503
- this.numDirectionalLights.value = 0;
504
-
505
- }
506
-
507
- // Area lights (16 floats per light — 13 base + normalize/spread/shape)
508
- if ( this.areaLightsData && this.areaLightsData.length > 0 ) {
509
-
510
- this.areaLightsBufferNode.array = Array.from( this.areaLightsData );
511
- this.numAreaLights.value = Math.floor( this.areaLightsData.length / 16 );
512
-
513
- } else {
514
-
515
- this.numAreaLights.value = 0;
500
+ }
516
501
 
517
- }
502
+ /**
503
+ * In place, because the shader bakes the list's length: sized per scene it compiled a program per
504
+ * light count, and three.js uploads only what fits the length it was built with, so a light added
505
+ * after a build was dropped. A list grows only past its capacity, and that rebuilds the kernels.
506
+ * @private
507
+ */
508
+ _writeLightList( type, data, count ) {
518
509
 
519
- // Point lights (9 floats per light)
520
- if ( this.pointLightsData && this.pointLightsData.length > 0 ) {
510
+ const floats = LIGHT_FLOATS[ type ];
511
+ const node = this[ `${type}LightsBufferNode` ];
512
+ const lights = data ? Math.floor( data.length / floats ) : 0;
513
+ const used = lights * floats;
521
514
 
522
- this.pointLightsBufferNode.array = Array.from( this.pointLightsData );
523
- this.numPointLights.value = Math.floor( this.pointLightsData.length / 9 );
515
+ if ( used > node.array.length ) {
524
516
 
525
- } else {
526
-
527
- this.numPointLights.value = 0;
517
+ node.array = new Float32Array( lightListCapacity( type, lights ) * floats );
518
+ this._lightBufferRealloc = true;
528
519
 
529
520
  }
530
521
 
531
- // Spot lights (20 floats per light — 14 light fields + gobo {idx, signed intensity} + IES {idx, intensity} + 2 reserved)
532
- if ( this.spotLightsData && this.spotLightsData.length > 0 ) {
533
-
534
- this.spotLightsBufferNode.array = Array.from( this.spotLightsData );
535
- this.numSpotLights.value = Math.floor( this.spotLightsData.length / 20 );
536
-
537
- } else {
538
-
539
- this.numSpotLights.value = 0;
540
-
541
- }
522
+ if ( used > 0 ) node.array.set( data.subarray( 0, used ) );
523
+ node.array.fill( 0, used );
524
+ count.value = lights;
542
525
 
543
526
  }
544
527
 
@@ -565,6 +548,7 @@ export class PathTracerStage extends RenderStage {
565
548
 
566
549
  // Only deterministic mode and lockstep readbacks rewind the seed axis; see _pinSeedToFrame.
567
550
  if ( this._pinSeedToFrame || this._lockstep ) this._seedTick = 0;
551
+ this._accumulationLockstep = !! this._lockstep;
568
552
 
569
553
  }
570
554
 
@@ -1102,17 +1086,34 @@ export class PathTracerStage extends RenderStage {
1102
1086
 
1103
1087
  }
1104
1088
 
1105
- /** Update BVH node data in the existing GPU buffer (full). */
1106
- updateBVHData( bvhData ) {
1089
+ /**
1090
+ * Update BVH node data in the existing GPU buffer.
1091
+ * @param {Object} [bvhData]
1092
+ * @param {Array<{offset:number,count:number}>} [ranges] - only these, in floats; every node when omitted
1093
+ */
1094
+ updateBVHData( bvhData, ranges ) {
1107
1095
 
1108
1096
  if ( this._bvhRecords ) {
1109
1097
 
1110
- uploadStorageChunks( this.renderer, this.bvhStorageAttr, this._bvhRecords.chunks );
1098
+ if ( bvhData?.chunks?.length > 1 ) this._bvhRecords = bvhData;
1099
+ if ( ranges ) this.updateBufferRanges( [], ranges );
1100
+ else uploadStorageChunks( this.renderer, this.bvhStorageAttr, this._bvhRecords.chunks );
1101
+ return;
1102
+
1103
+ }
1104
+
1105
+ const flat = bvhData?.chunks ? bvhData.chunks[ 0 ] : bvhData;
1106
+ if ( ! ranges ) {
1107
+
1108
+ this._updateStorageBuffer( this.bvhStorageAttr, flat );
1111
1109
  return;
1112
1110
 
1113
1111
  }
1114
1112
 
1115
- this._updateStorageBuffer( this.bvhStorageAttr, bvhData?.chunks ? bvhData.chunks[ 0 ] : bvhData );
1113
+ // The attribute keeps its own array once the refit has moved the nodes into shared memory.
1114
+ const own = this.bvhStorageAttr?.array;
1115
+ if ( flat && own && flat !== own ) for ( const r of ranges ) own.set( flat.subarray( r.offset, r.offset + r.count ), r.offset );
1116
+ this.updateBufferRanges( [], ranges );
1116
1117
 
1117
1118
  }
1118
1119
 
@@ -1271,6 +1272,7 @@ export class PathTracerStage extends RenderStage {
1271
1272
 
1272
1273
  this.createStorageTextures( width, height );
1273
1274
  this.frameCount = 0;
1275
+ this._accumulationLockstep = false;
1274
1276
 
1275
1277
  }
1276
1278
 
@@ -17,6 +17,8 @@ const MAX_DEFAULT_BUDGET = 100 * 1024 ** 3;
17
17
  const FREE_MARGIN = 64 * 1024 ** 2;
18
18
  const STREAM_BATCH = 8 * 1024 ** 2;
19
19
  const STREAM_WINDOW = 4;
20
+ // Other tabs write too, so the cache total kept in memory is listed again at most this often.
21
+ const CACHE_TOTAL_TTL_MS = 30_000;
20
22
 
21
23
  const encoder = new TextEncoder();
22
24
 
@@ -152,6 +154,7 @@ export class EntryWriter {
152
154
  this._sizes = { ...sizes };
153
155
  this._append = append;
154
156
  this._done = false;
157
+ this._committedBytes = append ? Object.values( sizes ).reduce( ( n, b ) => n + b, 0 ) : 0;
155
158
 
156
159
  }
157
160
 
@@ -313,6 +316,7 @@ export class EntryWriter {
313
316
 
314
317
  const { args, list } = transferable( JSON.stringify( meta ), { copy: false } );
315
318
  await this._transport.call( 'writeFile', { path: this._path, name: META, ...args }, list );
319
+ if ( this.area.kind === STORAGE_KIND.CACHE ) this.area.manager._countCacheBytes( meta.bytes - this._committedBytes );
316
320
  this.area.manager._changed( this.area.name );
317
321
  return meta;
318
322
 
@@ -700,6 +704,7 @@ export class StorageManager extends Emitter {
700
704
  this._transport = transport;
701
705
  this._areas = new Map();
702
706
  this._budget = null;
707
+ this._cacheTotal = null;
703
708
 
704
709
  for ( const name of Object.values( ENGINE_AREAS ) ) this.defineArea( name, { kind: name === ENGINE_AREAS.SPILL ? STORAGE_KIND.SCRATCH : STORAGE_KIND.CACHE } );
705
710
 
@@ -775,6 +780,40 @@ export class StorageManager extends Emitter {
775
780
 
776
781
  }
777
782
 
783
+ /**
784
+ * What cache areas hold together. Every new download asks, and a listing reads every entry's
785
+ * metadata, so it is listed once and kept current by this manager's own commits.
786
+ */
787
+ async _cacheBytes() {
788
+
789
+ let total = this._cacheTotal;
790
+ if ( ! total || Date.now() - total.at > CACHE_TOTAL_TTL_MS ) {
791
+
792
+ total = this._cacheTotal = { at: Date.now(), bytes: 0, ready: null };
793
+ total.ready = this._cacheEntries().then( ( entries ) => {
794
+
795
+ total.bytes += entries.reduce( ( n, e ) => n + ( e.meta.bytes ?? 0 ), 0 );
796
+
797
+ } );
798
+ total.ready.catch( () => {
799
+
800
+ if ( this._cacheTotal === total ) this._cacheTotal = null;
801
+
802
+ } );
803
+
804
+ }
805
+
806
+ await total.ready;
807
+ return total.bytes;
808
+
809
+ }
810
+
811
+ _countCacheBytes( bytes ) {
812
+
813
+ if ( this._cacheTotal ) this._cacheTotal.bytes += bytes;
814
+
815
+ }
816
+
778
817
  async _cacheEntries() {
779
818
 
780
819
  const out = [];
@@ -816,8 +855,8 @@ export class StorageManager extends Emitter {
816
855
 
817
856
  const entries = await this._cacheEntries();
818
857
  const budget = await this.budget();
819
- let cacheBytes = entries.reduce( ( n, e ) => n + ( e.meta.bytes ?? 0 ), 0 );
820
- const target = freeBytes ?? Math.max( 0, cacheBytes - budget );
858
+ const total = this._cacheTotal = { at: Date.now(), bytes: entries.reduce( ( n, e ) => n + ( e.meta.bytes ?? 0 ), 0 ), ready: null };
859
+ const target = freeBytes ?? Math.max( 0, total.bytes - budget );
821
860
  if ( target <= 0 ) return 0;
822
861
 
823
862
  const held = await heldLockNames();
@@ -832,7 +871,7 @@ export class StorageManager extends Emitter {
832
871
  if ( await area.removeById( meta.id ) ) {
833
872
 
834
873
  freed += meta.bytes ?? 0;
835
- cacheBytes -= meta.bytes ?? 0;
874
+ total.bytes -= meta.bytes ?? 0;
836
875
 
837
876
  }
838
877
 
@@ -855,8 +894,7 @@ export class StorageManager extends Emitter {
855
894
  const free = bytes + FREE_MARGIN - ( quota - usage );
856
895
  if ( kind !== STORAGE_KIND.CACHE ) return { free, budget: 0 };
857
896
 
858
- const cacheBytes = ( await this._cacheEntries() ).reduce( ( n, e ) => n + ( e.meta.bytes ?? 0 ), 0 );
859
- return { free, budget: cacheBytes + bytes - await this.budget() };
897
+ return { free, budget: await this._cacheBytes() + bytes - await this.budget() };
860
898
 
861
899
  };
862
900
 
@@ -10,6 +10,23 @@
10
10
  import { AnimationMixer, EventDispatcher, Timer, Vector3, LoopRepeat, LoopOnce, PropertyBinding } from 'three';
11
11
  import { EngineEvents } from '../EngineEvents.js';
12
12
 
13
+ /** The nodes of `nodes` with no ancestor in it, or null when updating each would cost more than one pass over `root`. */
14
+ function subtreeRoots( nodes, root ) {
15
+
16
+ const roots = [ ...nodes ].filter( node => {
17
+
18
+ for ( let o = node.parent; o; o = o.parent ) if ( nodes.has( o ) ) return false;
19
+ return true;
20
+
21
+ } );
22
+
23
+ let total = 0, covered = 0;
24
+ root.traverse?.( () => total ++ );
25
+ for ( const node of roots ) node.traverse?.( () => covered ++ );
26
+ return covered + roots.length / 2 < total ? roots : null;
27
+
28
+ }
29
+
13
30
  export class AnimationManager extends EventDispatcher {
14
31
 
15
32
  constructor() {
@@ -162,11 +179,19 @@ export class AnimationManager extends EventDispatcher {
162
179
 
163
180
  const animated = new Set();
164
181
  let visibility = false;
182
+ let unresolved = false;
165
183
  for ( const track of clip.tracks ) {
166
184
 
167
185
  const { nodeName, propertyName } = PropertyBinding.parseTrackName( track.name );
168
- const node = byName.get( nodeName );
169
- if ( ! node ) continue;
186
+ // As PropertyBinding.findNode: no node name binds the root itself.
187
+ const node = nodeName === '' || nodeName === '.' ? root : byName.get( nodeName );
188
+ if ( ! node ) {
189
+
190
+ unresolved = true;
191
+ continue;
192
+
193
+ }
194
+
170
195
  animated.add( node );
171
196
  if ( propertyName === 'visible' ) visibility = true;
172
197
 
@@ -191,7 +216,7 @@ export class AnimationManager extends EventDispatcher {
191
216
  const cameras = new Set();
192
217
  for ( const node of animated ) node.traverse( o => o.isCamera && cameras.add( o ) );
193
218
 
194
- return { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ] };
219
+ return { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ], nodes: unresolved ? null : animated, roots: unresolved ? null : subtreeRoots( animated, root ) };
195
220
 
196
221
  } );
197
222
 
@@ -218,15 +243,18 @@ export class AnimationManager extends EventDispatcher {
218
243
 
219
244
  const meshes = new Set(), cameras = new Set();
220
245
  let visibility = false;
246
+ let nodes = new Set();
221
247
  for ( const plan of this._plans ) {
222
248
 
223
249
  plan.meshes.forEach( m => meshes.add( m ) );
224
250
  plan.cameras.forEach( c => cameras.add( c ) );
225
251
  visibility ||= plan.visibility;
252
+ if ( nodes && plan.nodes ) plan.nodes.forEach( n => nodes.add( n ) );
253
+ else nodes = null;
226
254
 
227
255
  }
228
256
 
229
- this._allPlan = { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ] };
257
+ this._allPlan = { meshes: Int32Array.from( meshes ), visibility, cameras: [ ...cameras ], nodes, roots: nodes ? subtreeRoots( nodes, this._mixerRoot ) : null };
230
258
 
231
259
  }
232
260
 
@@ -491,7 +519,10 @@ export class AnimationManager extends EventDispatcher {
491
519
 
492
520
  // Bones live outside mesh subtrees so per-mesh updateMatrixWorld() misses them. Doing it
493
521
  // once here, on mixerRoot rather than the scene, keeps unrelated static objects out of it.
494
- this._mixerRoot.updateMatrixWorld( true );
522
+ // A rigid clip moves only what its tracks name, so only those subtrees are updated.
523
+ const roots = this._rigid ? this._planFor( this._activeClip )?.roots : null;
524
+ if ( roots ) for ( const node of roots ) node.updateMatrixWorld( true );
525
+ else this._mixerRoot.updateMatrixWorld( true );
495
526
 
496
527
  if ( this._rigid ) {
497
528
 
@@ -71,6 +71,7 @@ export class GoboManager {
71
71
  cnv.width = width;
72
72
  cnv.height = height;
73
73
  const ctx = cnv.getContext( '2d', { willReadFrequently: true } );
74
+ let lastYield = performance.now();
74
75
 
75
76
  for ( let i = 0; i < depth; i ++ ) {
76
77
 
@@ -79,6 +80,14 @@ export class GoboManager {
79
80
  const img = ctx.getImageData( 0, 0, width, height );
80
81
  data.set( img.data, i * width * height * 4 );
81
82
 
83
+ // Each first draw decodes its image, so the library packs in short tasks.
84
+ if ( performance.now() - lastYield > 16 ) {
85
+
86
+ await new Promise( resolve => setTimeout( resolve, 0 ) );
87
+ lastYield = performance.now();
88
+
89
+ }
90
+
82
91
  }
83
92
 
84
93
  const tex = new DataArrayTexture( data, width, height, depth );
@@ -19,6 +19,31 @@ const SHADER_NAMES = {
19
19
  cameraProjectionMatrix: 'ptCameraProjectionMatrix',
20
20
  };
21
21
 
22
+ /** Floats per light in each type's list (see LightSerializer). */
23
+ export const LIGHT_FLOATS = Object.freeze( {
24
+ directional: 12, // 8 light fields + gobo { index, signed intensity, scale, pad }
25
+ area: 16, // 13 base + normalize, spread, shape
26
+ point: 9,
27
+ spot: 20, // 14 light fields + gobo { index, signed intensity } + IES { index, intensity } + 2 reserved
28
+ } );
29
+
30
+ /** Lights a list holds before it has to grow. */
31
+ export const LIGHT_LIST_STEP = 16;
32
+
33
+ // A uniform array gives each float its own vec4; WebGPU guarantees 64 KiB a binding.
34
+ const MAX_UNIFORM_ELEMENTS = 65536 / 16;
35
+
36
+ /**
37
+ * Lights a list of this type must hold for `lights`: a whole number of steps, but never past what
38
+ * fits a binding unless `lights` alone already does — a count that did not fit never did.
39
+ */
40
+ export function lightListCapacity( type, lights ) {
41
+
42
+ const stepped = Math.ceil( lights / LIGHT_LIST_STEP ) * LIGHT_LIST_STEP;
43
+ return Math.max( lights, Math.min( stepped, Math.floor( MAX_UNIFORM_ELEMENTS / LIGHT_FLOATS[ type ] ) ) );
44
+
45
+ }
46
+
22
47
  export class UniformManager {
23
48
 
24
49
  constructor( width = 1920, height = 1080 ) {
@@ -266,13 +291,11 @@ export class UniformManager {
266
291
  u( 'numPointLights', 0, 'int' );
267
292
  u( 'numSpotLights', 0, 'int' );
268
293
 
269
- // Light buffer nodes - pre-allocate for up to 16 lights per type (shader hard cap)
270
- this._lightBuffers = {
271
- directional: uniformArray( new Float32Array( 12 * 16 ), 'float' ),
272
- area: uniformArray( new Float32Array( 16 * 16 ), 'float' ),
273
- point: uniformArray( new Float32Array( 9 * 16 ), 'float' ),
274
- spot: uniformArray( new Float32Array( 20 * 16 ), 'float' ),
275
- };
294
+ // The shader bakes each list's length, so lights are written into these in place and a list is
295
+ // only replaced when a scene has more lights than it holds (PathTracerStage._writeLightList).
296
+ this._lightBuffers = Object.fromEntries( Object.entries( LIGHT_FLOATS ).map(
297
+ ( [ type, floats ] ) => [ type, uniformArray( new Float32Array( floats * LIGHT_LIST_STEP ), 'float' ) ]
298
+ ) );
276
299
 
277
300
  // Camera matrices
278
301
  u( 'cameraWorldMatrix', new Matrix4(), 'mat4' );