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.
@@ -194,6 +194,29 @@ function triangleBounds( acc, first, count, out, off ) {
194
194
 
195
195
  const folded = new Float32Array( 12 );
196
196
 
197
+ /** A node's own box from what it stores — its two child boxes, or a leaf's triangles — into out[ 0 .. 6 ). */
198
+ function storedBounds( nodes, n, acc, out ) {
199
+
200
+ const f = nodeF( nodes, n );
201
+ const idx = nodeIdx( nodes, n );
202
+ const o = nodeBase( nodes, n );
203
+
204
+ if ( idx[ o + 3 ] === LEAF_MARKER ) {
205
+
206
+ triangleBounds( acc, idx[ o ], idx[ o + 1 ], out, 0 );
207
+ return;
208
+
209
+ }
210
+
211
+ for ( let a = 0; a < 3; a ++ ) {
212
+
213
+ out[ a ] = Math.min( f[ o + a ], f[ o + 8 + a ] );
214
+ out[ 3 + a ] = Math.max( f[ o + 4 + a ], f[ o + 12 + a ] );
215
+
216
+ }
217
+
218
+ }
219
+
197
220
  /**
198
221
  * One node of a bottom-up refit: its own bounds into `bounds[ ( i - base ) * 6 ]`, and an inner
199
222
  * node's two child boxes rewritten from theirs. A leaf folded into its parent takes its box from
@@ -264,6 +287,8 @@ export class BVHRefitter {
264
287
  // Resized only when nodeCount changes (i.e., new scene loaded).
265
288
  this._bounds = null;
266
289
  this._boundsNodeCount = 0;
290
+ this._tlasBounds = null;
291
+ this._rootBox = null;
267
292
 
268
293
  }
269
294
 
@@ -355,6 +380,45 @@ export class BVHRefitter {
355
380
 
356
381
  }
357
382
 
383
+ /**
384
+ * Refit the given BLAS node ranges, then the TLAS from every BLAS root's stored box. The same
385
+ * nodes as refit() when every other BLAS is as the last refit left it.
386
+ *
387
+ * @param {Float32Array} bvhData - Combined BVH array (TLAS + all BLASes)
388
+ * @param {Uint32Array} triangleData - Global triangle records
389
+ * @param {ArrayLike<number>} blasRanges - flat [ startNode, nodeCount, ... ]
390
+ * @param {number} tlasNodeCount - the TLAS occupies nodes [0, tlasNodeCount)
391
+ */
392
+ refitPartial( bvhData, triangleData, blasRanges, tlasNodeCount ) {
393
+
394
+ for ( let r = 0; r < blasRanges.length; r += 2 ) this.refitRange( bvhData, triangleData, blasRanges[ r ], blasRanges[ r + 1 ] );
395
+
396
+ const acc = triAccess( triangleData );
397
+ const nodes = nodeAccess( bvhData );
398
+ if ( ! this._tlasBounds || this._tlasBounds.length < tlasNodeCount * 6 ) this._tlasBounds = new Float32Array( tlasNodeCount * 6 );
399
+ const bounds = this._tlasBounds;
400
+ const rootBox = this._rootBox ||= new Float32Array( 6 );
401
+
402
+ for ( let i = tlasNodeCount - 1; i >= 0; i -- ) {
403
+
404
+ const idx = nodeIdx( nodes, i );
405
+ const o = nodeBase( nodes, i );
406
+
407
+ if ( idx[ o + 3 ] === BLAS_POINTER_MARKER ) {
408
+
409
+ storedBounds( nodes, idx[ o ], acc, rootBox );
410
+ transformBoundsToWorld( nodeF( nodes, i ), o, rootBox, 0, bounds, i * 6 );
411
+
412
+ } else {
413
+
414
+ refitNode( nodes, i, acc, bounds, 0 );
415
+
416
+ }
417
+
418
+ }
419
+
420
+ }
421
+
358
422
  /**
359
423
  * Bottom-up refit of all BVH node AABBs.
360
424
  * Reverse pre-order iteration gives valid bottom-up order (children have higher
@@ -2597,6 +2597,7 @@ export class SceneProcessor {
2597
2597
  async refitBVH( newPositions, newNormals ) {
2598
2598
 
2599
2599
  this._geometryVersion = ( this._geometryVersion ?? 0 ) + 1;
2600
+ this._tlasLeafBoxesOf = null;
2600
2601
 
2601
2602
  if ( ! this.bvh || ! this.triangles || ! this.instanceTable ) {
2602
2603
 
@@ -2650,13 +2651,17 @@ export class SceneProcessor {
2650
2651
  const table = this.instanceTable;
2651
2652
  const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
2652
2653
  const triRanges = [];
2654
+ const blasRanges = [];
2655
+ let owners = 0;
2653
2656
  for ( let i = 0; i < table.count; i ++ ) {
2654
2657
 
2655
2658
  if ( ! table.isSet[ i ] || ! table.isOwner( i ) ) continue;
2659
+ owners ++;
2656
2660
 
2657
2661
  const p = positionsFor( i );
2658
2662
  if ( ! p ) continue;
2659
2663
  this._updateMeshTrianglePositions( i, p );
2664
+ blasRanges.push( table.blasOffsetOf( i ), table.blasNodeCountOf( i ) );
2660
2665
 
2661
2666
  // Smooth normals overwrite the face normals just computed, so they follow per mesh.
2662
2667
  if ( normalsFor ) {
@@ -2673,6 +2678,11 @@ export class SceneProcessor {
2673
2678
 
2674
2679
  }
2675
2680
 
2681
+ const tlasNodeCount = table.tlasNodeCount;
2682
+ const partial = tlasNodeCount > 0 && blasRanges.length / 2 < owners;
2683
+ const bvhRanges = partial ? [ this.computeTLASDirtyRange() ] : null;
2684
+ for ( let r = 0; partial && r < blasRanges.length; r += 2 ) bvhRanges.push( { offset: blasRanges[ r ] * 16, count: blasRanges[ r + 1 ] * 16 } );
2685
+
2676
2686
  return new Promise( ( resolve, reject ) => {
2677
2687
 
2678
2688
  this._refitWorker.onmessage = ( e ) => {
@@ -2680,7 +2690,7 @@ export class SceneProcessor {
2680
2690
  const msg = e.data;
2681
2691
  if ( msg.type === 'refitComplete' ) {
2682
2692
 
2683
- resolve( { refitTimeMs: msg.refitTimeMs, triRanges } );
2693
+ resolve( { refitTimeMs: msg.refitTimeMs, triRanges, bvhRanges } );
2684
2694
 
2685
2695
  } else if ( msg.type === 'error' ) {
2686
2696
 
@@ -2691,7 +2701,7 @@ export class SceneProcessor {
2691
2701
  };
2692
2702
 
2693
2703
  // Signal worker — no data transfer needed, everything is in shared memory
2694
- this._refitWorker.postMessage( { type: 'refit' } );
2704
+ this._refitWorker.postMessage( { type: 'refit', blasRanges: partial ? blasRanges : null, tlasNodeCount } );
2695
2705
 
2696
2706
  } );
2697
2707
 
@@ -2841,6 +2851,8 @@ export class SceneProcessor {
2841
2851
  const start = performance.now();
2842
2852
  const table = this.instanceTable;
2843
2853
  const composed = this._transformScratch ??= new Float32Array( 16 );
2854
+ if ( this._movedPlacements?.length !== table.count ) this._movedPlacements = new Uint8Array( table.count );
2855
+ const movedMask = this._movedPlacements;
2844
2856
  let moved = 0;
2845
2857
 
2846
2858
  for ( const meshIndex of meshIndices ) {
@@ -2877,13 +2889,15 @@ export class SceneProcessor {
2877
2889
  }
2878
2890
 
2879
2891
  this._writeLeafMatrix( p );
2892
+ movedMask[ p ] = 1;
2880
2893
  moved ++;
2881
2894
 
2882
2895
  }
2883
2896
 
2884
2897
  }
2885
2898
 
2886
- this._refitTLAS();
2899
+ this._refitTLAS( movedMask );
2900
+ movedMask.fill( 0 );
2887
2901
 
2888
2902
  return { refitTimeMs: performance.now() - start, placements: moved };
2889
2903
 
@@ -3364,6 +3378,8 @@ export class SceneProcessor {
3364
3378
  /** Keep the chunked BVH and its u32 index view in step. @private */
3365
3379
  _setBVHData( data ) {
3366
3380
 
3381
+ this._tlasLeafBoxesOf = null;
3382
+
3367
3383
  const records = ! data ? null
3368
3384
  : ( data instanceof ChunkedRecords
3369
3385
  ? data
@@ -3545,9 +3561,11 @@ export class SceneProcessor {
3545
3561
  /**
3546
3562
  * Refit TLAS AABBs in-place without rebuilding the tree structure.
3547
3563
  * O(tlasNodeCount) bottom-up pass — much faster than full SAH rebuild.
3564
+ * @param {Uint8Array} [moved] - by placement: only these leaves' world boxes are recomputed, the
3565
+ * rest kept from the last pass. Every leaf when omitted, or when nothing valid was kept.
3548
3566
  * @private
3549
3567
  */
3550
- _refitTLAS() {
3568
+ _refitTLAS( moved = null ) {
3551
3569
 
3552
3570
  const tlasNodeCount = this.instanceTable.tlasNodeCount;
3553
3571
 
@@ -3555,10 +3573,12 @@ export class SceneProcessor {
3555
3573
  if ( ! this._tlasBounds || this._tlasBounds.length < tlasNodeCount * 6 ) {
3556
3574
 
3557
3575
  this._tlasBounds = new Float32Array( tlasNodeCount * 6 );
3576
+ this._tlasLeafBoxesOf = null;
3558
3577
 
3559
3578
  }
3560
3579
 
3561
3580
  const table = this.instanceTable;
3581
+ const keep = moved && this._tlasLeafBoxesOf === table;
3562
3582
 
3563
3583
  // Bottom-up pass: reverse iteration over TLAS nodes
3564
3584
  for ( let i = tlasNodeCount - 1; i >= 0; i -- ) {
@@ -3574,7 +3594,7 @@ export class SceneProcessor {
3574
3594
  // every placement of a shared geometry onto one box, so all but one copy sat
3575
3595
  // outside its own bounds and rays walked straight past it.
3576
3596
  const entryIndex = idxChunk[ o + 1 ] & TLAS_PLACEMENT_MASK;
3577
- if ( entryIndex < table.count ) {
3597
+ if ( entryIndex < table.count && ! ( keep && ! moved[ entryIndex ] ) ) {
3578
3598
 
3579
3599
  table.writeWorldAABB( entryIndex, this._tlasBounds, i * 6 );
3580
3600
 
@@ -3615,6 +3635,8 @@ export class SceneProcessor {
3615
3635
 
3616
3636
  }
3617
3637
 
3638
+ this._tlasLeafBoxesOf = table;
3639
+
3618
3640
  }
3619
3641
 
3620
3642
  /**
@@ -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();
@@ -56,7 +56,39 @@ fn rayzee_source( x: u32, y: u32 ) -> vec4<f32> {
56
56
  }`,
57
57
  };
58
58
 
59
- const toneMapPassWGSL = ( transformWGSL, input ) => /* wgsl */ `
59
+ // RGBA bytes for a picture, or four float planes (r, g, b, a) for a network that wants the curve
60
+ // unquantized. The planes take a 2.2 power, not the sRGB curve: what the AI upscaler has always fed
61
+ // its network, so its output does not move.
62
+ const OUTPUTS = {
63
+ rgba8: /* wgsl */ `
64
+ @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
65
+
66
+ fn rayzee_store( i: u32, linear: vec4<f32>, mapped: vec3<f32> ) {
67
+ let b8 = rayzee_to_u8( rayzee_encode( mapped, params.mode ) );
68
+ var a8 = 255u;
69
+ if ( params.alpha == 1u ) { a8 = rayzee_to_u8( vec3<f32>( linear.a ) ).x; }
70
+ dst[ i ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( a8 << 24u );
71
+ }`,
72
+ planar: /* wgsl */ `
73
+ @group(0) @binding(1) var<storage, read_write> dst: array<f32>;
74
+
75
+ fn rayzee_store( i: u32, linear: vec4<f32>, mapped: vec3<f32> ) {
76
+ var c = clamp( mapped, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
77
+ if ( ! rayzee_output_encoded( params.mode ) ) {
78
+ let m = max( mapped, vec3<f32>( 0.0 ) );
79
+ c = select( pow( m, vec3<f32>( 1.0 / 2.2 ) ), vec3<f32>( 0.0 ), m <= vec3<f32>( 0.0 ) );
80
+ }
81
+ let n = params.width * params.height;
82
+ dst[ i ] = c.r;
83
+ dst[ n + i ] = c.g;
84
+ dst[ 2u * n + i ] = c.b;
85
+ dst[ 3u * n + i ] = linear.a;
86
+ }`,
87
+ };
88
+
89
+ const BYTES_PER_PIXEL = { rgba8: 4, planar: 16 };
90
+
91
+ const toneMapPassWGSL = ( transformWGSL, input, output ) => /* wgsl */ `
60
92
  struct Params {
61
93
  width: u32,
62
94
  height: u32,
@@ -67,11 +99,11 @@ struct Params {
67
99
  alpha: u32,
68
100
  };
69
101
 
70
- @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
71
102
  @group(0) @binding(2) var<uniform> params: Params;
72
103
  ${SOURCES[ input ]}
73
104
 
74
105
  ${transformWGSL}
106
+ ${OUTPUTS[ output ]}
75
107
 
76
108
  @compute @workgroup_size(8, 8)
77
109
  fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
@@ -81,11 +113,7 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
81
113
  if ( params.flipY == 1u ) { srcY = params.height - 1u - gid.y; }
82
114
 
83
115
  let linear = rayzee_source( gid.x, srcY );
84
- let mapped = rayzee_tone_map( linear.rgb, params.mode, params.exposure, params.saturation );
85
- let b8 = rayzee_to_u8( rayzee_encode( mapped, params.mode ) );
86
- var a8 = 255u;
87
- if ( params.alpha == 1u ) { a8 = rayzee_to_u8( vec3<f32>( linear.a ) ).x; }
88
- dst[ gid.y * params.width + gid.x ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( a8 << 24u );
116
+ rayzee_store( gid.y * params.width + gid.x, linear, rayzee_tone_map( linear.rgb, params.mode, params.exposure, params.saturation ) );
89
117
  }
90
118
  `;
91
119
 
@@ -94,17 +122,18 @@ const PARAMS_BYTES = 32;
94
122
 
95
123
  /**
96
124
  * Tone-maps a tightly packed rgba16float buffer (two `u32` per pixel), or with `input: 'texture'` a
97
- * float texture read by pixel, into RGBA bytes.
125
+ * float texture read by pixel, into RGBA bytes — or with `output: 'planar'` into float planes.
98
126
  *
99
127
  * Bound to one device and one image size; `ensureSize()` reallocates when the size changes.
100
128
  */
101
129
  export class PackedToneMapper {
102
130
 
103
- constructor( device, label = 'rayzee:tonemap', { input = 'packed' } = {} ) {
131
+ constructor( device, label = 'rayzee:tonemap', { input = 'packed', output = 'rgba8' } = {} ) {
104
132
 
105
133
  this.device = device;
106
134
  this.label = label;
107
135
  this.input = input;
136
+ this.output = output;
108
137
  this.width = 0;
109
138
  this.height = 0;
110
139
  this._pipeline = null;
@@ -139,7 +168,7 @@ export class PackedToneMapper {
139
168
  label: this.label,
140
169
  layout: 'auto',
141
170
  compute: {
142
- module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input ) } ),
171
+ module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input, this.output ) } ),
143
172
  entryPoint: 'main',
144
173
  },
145
174
  } );
@@ -202,7 +231,7 @@ export class PackedToneMapper {
202
231
 
203
232
  this._releaseBuffers();
204
233
 
205
- const bytes = width * height * 4;
234
+ const bytes = width * height * BYTES_PER_PIXEL[ this.output ];
206
235
  this._storage = this.device.createBuffer( {
207
236
  label: `${this.label}-out`,
208
237
  size: bytes,
@@ -235,7 +264,26 @@ export class PackedToneMapper {
235
264
  * @param {boolean} [tone.preserveAlpha=false] - the source's alpha, rounded as the CPU rounds it; else 255
236
265
  * @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
237
266
  */
238
- async toRGBA8( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
267
+ async toRGBA8( src, tone ) {
268
+
269
+ return new Uint8ClampedArray( await this._run( src, tone ) );
270
+
271
+ }
272
+
273
+ /**
274
+ * The same tone map into four float planes — r, g, b, then the source's alpha — each `width * height`.
275
+ * Needs `output: 'planar'`.
276
+ * @param {GPUBuffer|GPUTexture} src - as for toRGBA8
277
+ * @param {object} tone - as for toRGBA8; `preserveAlpha` does not apply
278
+ * @returns {Promise<Float32Array>}
279
+ */
280
+ async toPlanar( src, tone ) {
281
+
282
+ return new Float32Array( await this._run( src, tone ) );
283
+
284
+ }
285
+
286
+ async _run( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
239
287
 
240
288
  if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
241
289
  if ( ! this._storage ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
@@ -246,6 +294,7 @@ export class PackedToneMapper {
246
294
  this._ensureTables();
247
295
 
248
296
  const { width, height } = this;
297
+ const bytes = width * height * BYTES_PER_PIXEL[ this.output ];
249
298
 
250
299
  this._paramU32[ 0 ] = width;
251
300
  this._paramU32[ 1 ] = height;
@@ -275,13 +324,13 @@ export class PackedToneMapper {
275
324
  pass.setBindGroup( 0, group );
276
325
  pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) );
277
326
  pass.end();
278
- encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, width * height * 4 );
327
+ encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, bytes );
279
328
  this.device.queue.submit( [ encoder.finish() ] );
280
329
 
281
330
  await this._map.mapAsync( GPUMapMode.READ );
282
- const bytes = new Uint8ClampedArray( this._map.getMappedRange().slice( 0 ) );
331
+ const out = this._map.getMappedRange().slice( 0 );
283
332
  this._map.unmap();
284
- return bytes;
333
+ return out;
285
334
 
286
335
  }
287
336
 
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Protocol:
5
5
  * 'init' → receives the shared triangle and node buffers (once per scene)
6
- * 'refit' → recomputes every node's AABB from the triangles already written there
6
+ * 'refit' → recomputes every node's AABB from the triangles already written there, or with
7
+ * `blasRanges` only those BLASes and the TLAS
7
8
  *
8
9
  * Positions never reach here: the main thread scatters each mesh into the shared triangle
9
10
  * records as it reads it, so no buffer the size of the scene exists on either side.
@@ -45,7 +46,9 @@ self.onmessage = function ( e ) {
45
46
 
46
47
  const startTime = performance.now();
47
48
 
48
- refitter.refit( bvhData, triData, nodeCount );
49
+ const { blasRanges, tlasNodeCount } = e.data;
50
+ if ( blasRanges ) refitter.refitPartial( bvhData, triData, blasRanges, tlasNodeCount );
51
+ else refitter.refit( bvhData, triData, nodeCount );
49
52
 
50
53
  self.postMessage( {
51
54
  type: 'refitComplete',