rayzee 9.3.3 → 9.4.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "9.3.3",
3
+ "version": "9.4.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -32,6 +32,7 @@ export const ISSUE_CODES = Object.freeze( {
32
32
  STORAGE_CACHE_MISMATCH: 'storage.cache_mismatch',
33
33
  DENOISER_UNAVAILABLE: 'denoiser.unavailable',
34
34
  OUTPUT_SOURCE_FALLBACK: 'output.source_fallback',
35
+ OUTPUT_TONEMAP_FALLBACK: 'output.tonemap_fallback',
35
36
  LIGHT_PLACEHOLDER_SKIPPED: 'light.placeholder_skipped',
36
37
  } );
37
38
 
@@ -24,7 +24,7 @@ import { TextureReadback } from './Processor/TextureReadback.js';
24
24
  import { createLogger, fmt } from './utils/Logger.js';
25
25
  import { InteractionManager } from './managers/InteractionManager.js';
26
26
  import { EngineEvents, LEGACY_EVENT_NAMES } from './EngineEvents.js';
27
- import { IssueLog, ISSUE_CODES } from './EngineIssues.js';
27
+ import { IssueLog, ISSUE_CODES, EngineIssueError } from './EngineIssues.js';
28
28
  import { getAssetConfig, isAssetConfigured } from './AssetConfig.js';
29
29
  import { StorageManager } from './Storage/StorageManager.js';
30
30
  import { acquireSharedStorage } from './Storage/openStorage.js';
@@ -2903,7 +2903,7 @@ export class PathTracerApp extends EventDispatcher {
2903
2903
  headless: this._headless,
2904
2904
  strict: this._issues.strict,
2905
2905
  deterministic: this.isDeterministic,
2906
- lockstep: !! stage?.lockstepReadbacks,
2906
+ lockstep: !! stage?.accumulationLockstep,
2907
2907
  },
2908
2908
  render: stage ? {
2909
2909
  width: stage.width,
@@ -2998,8 +2998,9 @@ export class PathTracerApp extends EventDispatcher {
2998
2998
  * viewport is showing — denoised when a denoiser has run, without bloom — instead of the raw
2999
2999
  * accumulation
3000
3000
  * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string,
3001
- * source: string}>} `source` is what was read: `'accumulation'`, or the denoiser that published
3002
- * it (`'oidn'`, `'asvgf'`, `'nrd'`, `'edgeFiltering'`, `'bilateralFiltering'`)
3001
+ * source: string, toneMappedOn?: 'gpu'|'cpu'}>} `source` is what was read: `'accumulation'`, or the
3002
+ * denoiser that published it (`'oidn'`, `'asvgf'`, `'nrd'`, `'edgeFiltering'`, `'bilateralFiltering'`).
3003
+ * `toneMappedOn` is set for `'srgb'` only.
3003
3004
  */
3004
3005
  async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false, source = 'accumulation' } = {} ) {
3005
3006
 
@@ -3029,11 +3030,11 @@ export class PathTracerApp extends EventDispatcher {
3029
3030
  preserveAlpha,
3030
3031
  };
3031
3032
 
3032
- const data = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone )
3033
- ?? toneMapToRGBA8( shown
3034
- ? await this._readTexture( shown.texture, width, height )
3035
- : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), tone );
3036
- return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation' };
3033
+ const gpu = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone );
3034
+ const data = gpu ?? toneMapToRGBA8( shown
3035
+ ? await this._readTexture( shown.texture, width, height )
3036
+ : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), tone );
3037
+ return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation', toneMappedOn: gpu ? 'gpu' : 'cpu' };
3037
3038
 
3038
3039
  }
3039
3040
 
@@ -3065,13 +3066,13 @@ export class PathTracerApp extends EventDispatcher {
3065
3066
  async _toneMapOnGPU( shown, accumulation, width, height, tone ) {
3066
3067
 
3067
3068
  const backend = this.renderer?.backend;
3068
- if ( ! backend?.device ) return null;
3069
+ if ( ! backend?.device ) return this._toneMapFallback( 'the renderer has no WebGPU device' );
3069
3070
 
3070
3071
  try {
3071
3072
 
3072
3073
  const texture = shown ? this._readbackPass().draw( shown, width, height ).texture : accumulation.textures[ 0 ];
3073
3074
  const gpuTexture = backend.get( texture ).texture;
3074
- if ( ! gpuTexture ) return null;
3075
+ if ( ! gpuTexture ) return this._toneMapFallback( 'the picture has no GPU texture yet' );
3075
3076
 
3076
3077
  this._readbackToneMapper ??= new PackedToneMapper( backend.device, 'rayzee:readback-tonemap', { input: 'texture' } );
3077
3078
  this._readbackToneMapper.ensureSize( width, height );
@@ -3079,8 +3080,8 @@ export class PathTracerApp extends EventDispatcher {
3079
3080
 
3080
3081
  } catch ( error ) {
3081
3082
 
3082
- log.warn( `renderToBuffer: tone mapping on the GPU failed, doing it on the CPU: ${error?.message ?? error}` );
3083
- return null;
3083
+ if ( error instanceof EngineIssueError ) throw error;
3084
+ return this._toneMapFallback( 'the GPU pass threw', error );
3084
3085
 
3085
3086
  } finally {
3086
3087
 
@@ -3091,6 +3092,17 @@ export class PathTracerApp extends EventDispatcher {
3091
3092
 
3092
3093
  }
3093
3094
 
3095
+ /** A warning, so strict keeps going: the CPU bytes match within a level, only slower. @private */
3096
+ _toneMapFallback( reason, error = null ) {
3097
+
3098
+ const cause = error ? String( error?.message ?? error ) : null;
3099
+ const message = `renderToBuffer: tone mapped on the CPU — ${reason}${cause ? `: ${cause}` : ''}`;
3100
+ log.warn( message );
3101
+ this._issues.warn( ISSUE_CODES.OUTPUT_TONEMAP_FALLBACK, message, { reason, cause } );
3102
+ return null;
3103
+
3104
+ }
3105
+
3094
3106
  /**
3095
3107
  * Enables WebGPU timestamp queries so {@link PathTracerApp#getGPUTimings} reports real
3096
3108
  * GPU time. Off by default because the queries themselves cost time. No-ops when the
@@ -15,6 +15,13 @@ const log = createLogger( 'textures' );
15
15
  // Below this a pack is cheaper than starting a worker for it.
16
16
  const WORKER_PACK_BYTES = 8 * 2 ** 20;
17
17
 
18
+ // Main-thread work allowed between yields while streaming layers to a worker.
19
+ const STREAM_YIELD_MS = 16;
20
+
21
+ const SHARED_PACK_WORKERS = 4;
22
+ // Below about one 8K source a bucket packs fast enough alone, and extra workers only compete with the BVH build.
23
+ const SHARED_PACK_MIN_PIXELS = 8192 * 8192;
24
+
18
25
  // Buckets pack concurrently; this keeps their workers within the cores, beside the BVH pool's.
19
26
  const packSlots = { busy: 0, waiting: [] };
20
27
 
@@ -835,6 +842,9 @@ export class TextureCreator {
835
842
  case 'worker-direct':
836
843
  result = await this.processWithWorkerDirect( normalized );
837
844
  break;
845
+ case 'worker-streaming':
846
+ result = await this.processInWorkerStreaming( normalized );
847
+ break;
838
848
  case 'main-batch':
839
849
  result = await this.processOnMainThreadBatch( normalized, strategy.batchSize );
840
850
  break;
@@ -892,10 +902,7 @@ export class TextureCreator {
892
902
 
893
903
  if ( this.capabilities.workers && estimatedMemory > MEMORY_CONSTANTS.MAX_TEXTURE_MEMORY ) {
894
904
 
895
- // Very large texture set: stream on the main thread (GC-yielding, builds the
896
- // full array correctly). The former 'worker-chunked' path combined its chunks
897
- // by returning only the first, silently dropping the rest — removed.
898
- return { method: 'main-streaming' };
905
+ return { method: 'worker-streaming' };
899
906
 
900
907
  } else if ( this.capabilities.workers && totalPixels > 2097152 ) {
901
908
 
@@ -931,14 +938,24 @@ export class TextureCreator {
931
938
  }
932
939
 
933
940
  this.activeWorkers ++;
934
-
935
- const worker = new TexturesWorker();
941
+ let slots = 1;
942
+ let worker = null;
936
943
 
937
944
  try {
938
945
 
939
946
  // Prepare textures for worker with direct transfer
940
947
  const texturesData = await this.prepareTexturesForWorkerDirect( textures );
941
948
 
949
+ const extra = this._sharedPackSlots( texturesData );
950
+ if ( extra > 0 ) {
951
+
952
+ slots += extra;
953
+ this.activeWorkers += extra;
954
+ return this.createDataArrayTextureFromResult( await this._packAcrossWorkers( texturesData, slots ) );
955
+
956
+ }
957
+
958
+ worker = new TexturesWorker();
942
959
  const result = await new Promise( ( resolve, reject ) => {
943
960
 
944
961
  worker.onmessage = ( e ) => {
@@ -983,6 +1000,153 @@ export class TextureCreator {
983
1000
 
984
1001
  return this.createDataArrayTextureFromResult( result );
985
1002
 
1003
+ } finally {
1004
+
1005
+ worker?.terminate();
1006
+ this.activeWorkers -= slots;
1007
+
1008
+ }
1009
+
1010
+ }
1011
+
1012
+ /** Extra worker slots a bucket may take now, beyond its own. */
1013
+ _sharedPackSlots( texturesData ) {
1014
+
1015
+ if ( typeof SharedArrayBuffer === 'undefined' || ! globalThis.crossOriginIsolated ) return 0;
1016
+ if ( texturesData.reduce( ( sum, t ) => sum + t.width * t.height, 0 ) < SHARED_PACK_MIN_PIXELS ) return 0;
1017
+ const free = this.maxConcurrentWorkers - this.activeWorkers;
1018
+ return Math.max( 0, Math.min( texturesData.length, SHARED_PACK_WORKERS, free + 1 ) - 1 );
1019
+
1020
+ }
1021
+
1022
+ /**
1023
+ * The worker's direct path split across `count` workers, each drawing its layers straight into
1024
+ * one shared array. Large sources dominate (their downscale), so they are dealt out first.
1025
+ */
1026
+ async _packAcrossWorkers( texturesData, count ) {
1027
+
1028
+ const { maxWidth: width, maxHeight: height } = this.calculateOptimalDimensions( texturesData.map( image => ( { image } ) ) );
1029
+ const depth = texturesData.length;
1030
+ const shared = new SharedArrayBuffer( width * height * depth * 4 );
1031
+
1032
+ const parts = Array.from( { length: count }, () => ( { cost: 0, layers: [], transfer: [] } ) );
1033
+ const order = texturesData.map( ( _, index ) => index );
1034
+ const cost = index => texturesData[ index ].width * texturesData[ index ].height;
1035
+ order.sort( ( a, b ) => cost( b ) - cost( a ) );
1036
+
1037
+ for ( const index of order ) {
1038
+
1039
+ const part = parts.reduce( ( least, p ) => ( p.cost < least.cost ? p : least ) );
1040
+ const texture = texturesData[ index ];
1041
+ part.layers.push( { index, texture } );
1042
+ part.cost += cost( index );
1043
+ if ( texture.data instanceof ArrayBuffer ) part.transfer.push( texture.data );
1044
+ else if ( texture.bitmap ) part.transfer.push( texture.bitmap );
1045
+
1046
+ }
1047
+
1048
+ const workers = [];
1049
+
1050
+ try {
1051
+
1052
+ await Promise.all( parts.filter( part => part.layers.length ).map( part => new Promise( ( resolve, reject ) => {
1053
+
1054
+ const worker = createWorker( TexturesWorker );
1055
+ workers.push( worker );
1056
+ worker.onmessage = ( { data } ) => ( data.error ? reject( new Error( data.error ) ) : resolve() );
1057
+ worker.onerror = ( event ) => reject( new Error( event.message || 'texture worker failed' ) );
1058
+ worker.postMessage( { shared, width, height, layers: part.layers, maxTextureSize: this.maxTextureSize }, part.transfer );
1059
+
1060
+ } ) ) );
1061
+
1062
+ } finally {
1063
+
1064
+ for ( const worker of workers ) worker.terminate();
1065
+
1066
+ }
1067
+
1068
+ return { data: shared, width, height, depth };
1069
+
1070
+ }
1071
+
1072
+ /**
1073
+ * `processOnMainThreadStreaming` with the draw and read moved to a worker: each layer is
1074
+ * resized here exactly as there, so the array is byte-for-byte the same.
1075
+ */
1076
+ async processInWorkerStreaming( textures ) {
1077
+
1078
+ const validTextures = textures.filter( tex => tex?.image );
1079
+ if ( validTextures.length === 0 ) return this.createFallbackTexture();
1080
+
1081
+ const { maxWidth, maxHeight } = this.calculateOptimalDimensions( validTextures );
1082
+ const depth = validTextures.length;
1083
+
1084
+ while ( this.activeWorkers >= this.maxConcurrentWorkers ) {
1085
+
1086
+ await new Promise( resolve => setTimeout( resolve, 10 ) );
1087
+
1088
+ }
1089
+
1090
+ this.activeWorkers ++;
1091
+ const worker = createWorker( TexturesWorker );
1092
+
1093
+ try {
1094
+
1095
+ let acked = 0;
1096
+ let wake = null;
1097
+ let settle;
1098
+ const finished = new Promise( ( resolve, reject ) => settle = { resolve, reject } );
1099
+ finished.catch( () => {} );
1100
+
1101
+ worker.onmessage = ( { data } ) => {
1102
+
1103
+ if ( data.error ) settle.reject( new Error( data.error ) );
1104
+ else if ( data.stream === 'ack' ) acked ++;
1105
+ else settle.resolve( data );
1106
+ wake?.();
1107
+
1108
+ };
1109
+
1110
+ worker.onerror = ( event ) => {
1111
+
1112
+ settle.reject( new Error( event.message || 'texture worker failed' ) );
1113
+ wake?.();
1114
+
1115
+ };
1116
+
1117
+ worker.postMessage( { stream: 'begin', width: maxWidth, height: maxHeight, depth } );
1118
+ let lastYield = performance.now();
1119
+
1120
+ for ( let i = 0; i < depth; i ++ ) {
1121
+
1122
+ while ( i - acked >= MEMORY_CONSTANTS.STREAM_BATCH_SIZE ) {
1123
+
1124
+ await Promise.race( [ new Promise( resolve => wake = resolve ), finished ] );
1125
+ lastYield = performance.now();
1126
+
1127
+ }
1128
+
1129
+ const texture = validTextures[ i ];
1130
+ const bitmap = await createImageBitmap( texture.image, {
1131
+ resizeWidth: maxWidth,
1132
+ resizeHeight: maxHeight,
1133
+ resizeQuality: 'high',
1134
+ imageOrientation: texture.flipY !== false ? 'flipY' : 'none',
1135
+ } );
1136
+ worker.postMessage( { stream: 'layer', index: i, bitmap }, [ bitmap ] );
1137
+
1138
+ if ( performance.now() - lastYield > STREAM_YIELD_MS ) {
1139
+
1140
+ await new Promise( resolve => setTimeout( resolve, 0 ) );
1141
+ lastYield = performance.now();
1142
+
1143
+ }
1144
+
1145
+ }
1146
+
1147
+ worker.postMessage( { stream: 'end' } );
1148
+ return this.createDataArrayTextureFromResult( await finished );
1149
+
986
1150
  } finally {
987
1151
 
988
1152
  worker.terminate();
@@ -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;
516
-
517
- }
500
+ }
518
501
 
519
- // Point lights (9 floats per light)
520
- if ( this.pointLightsData && this.pointLightsData.length > 0 ) {
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 ) {
521
509
 
522
- this.pointLightsBufferNode.array = Array.from( this.pointLightsData );
523
- this.numPointLights.value = Math.floor( this.pointLightsData.length / 9 );
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;
524
514
 
525
- } else {
515
+ if ( used > node.array.length ) {
526
516
 
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
 
@@ -1288,6 +1272,7 @@ export class PathTracerStage extends RenderStage {
1288
1272
 
1289
1273
  this.createStorageTextures( width, height );
1290
1274
  this.frameCount = 0;
1275
+ this._accumulationLockstep = false;
1291
1276
 
1292
1277
  }
1293
1278