rayzee 7.31.1 → 8.1.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 (53) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6728 -4735
  3. package/dist/rayzee.umd.js +121 -73
  4. package/package.json +2 -2
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +5 -0
  7. package/src/PathTracerApp.js +151 -25
  8. package/src/Processor/ArchiveReader.js +538 -0
  9. package/src/Processor/AssetLoader.js +265 -23
  10. package/src/Processor/BVHBuilder.js +48 -32
  11. package/src/Processor/BVHRefitter.js +244 -99
  12. package/src/Processor/ChunkedRecords.js +302 -0
  13. package/src/Processor/EmissiveTriangleBuilder.js +48 -21
  14. package/src/Processor/GeometryExtractor.js +580 -93
  15. package/src/Processor/HostMemory.js +222 -0
  16. package/src/Processor/InstanceTable.js +548 -97
  17. package/src/Processor/PBRT/EqualAreaOctahedral.js +32 -8
  18. package/src/Processor/PBRT/PBRTCurves.js +249 -0
  19. package/src/Processor/PBRT/PBRTMath.js +19 -0
  20. package/src/Processor/PBRT/PBRTParser.js +187 -41
  21. package/src/Processor/PBRT/PBRTSceneBuilder.js +586 -42
  22. package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
  23. package/src/Processor/PBRT/index.js +257 -50
  24. package/src/Processor/PackedRayBuffer.js +29 -18
  25. package/src/Processor/ParallelBVHBuilder.js +9 -8
  26. package/src/Processor/QueueManager.js +5 -3
  27. package/src/Processor/SceneProcessor.js +1046 -365
  28. package/src/Processor/TLASBuilder.js +546 -201
  29. package/src/Processor/TextureCreator.js +11 -9
  30. package/src/Processor/VRAMTracker.js +12 -3
  31. package/src/Processor/Workers/BVHRefitWorker.js +16 -11
  32. package/src/Processor/Workers/BVHWorker.js +12 -12
  33. package/src/Processor/Workers/TLASWorker.js +37 -0
  34. package/src/Processor/utils.js +8 -20
  35. package/src/Stages/NormalDepth.js +6 -2
  36. package/src/Stages/PathTracer.js +5 -5
  37. package/src/Stages/PathTracerStage.js +163 -41
  38. package/src/TSL/BVHTraversal.js +208 -90
  39. package/src/TSL/Common.js +135 -2
  40. package/src/TSL/Displacement.js +21 -11
  41. package/src/TSL/EmissiveSampling.js +49 -25
  42. package/src/TSL/ExtendKernel.js +3 -2
  43. package/src/TSL/LightBVHSampling.js +7 -4
  44. package/src/TSL/LightsDirect.js +30 -29
  45. package/src/TSL/ShadeKernel.js +8 -6
  46. package/src/TSL/SortGlobalKernels.js +3 -4
  47. package/src/TSL/Struct.js +3 -0
  48. package/src/TSL/TextureSampling.js +24 -9
  49. package/src/TSL/patches.js +149 -84
  50. package/src/index.js +3 -0
  51. package/src/managers/AnimationManager.js +86 -76
  52. package/src/managers/MaterialDataManager.js +29 -23
  53. package/src/managers/TransformManager.js +9 -192
@@ -53,54 +53,176 @@ function joinPath( dir, rel ) {
53
53
  * Wraps the zip contents with tolerant, case-insensitive lookup that falls back
54
54
  * to a basename match — pbrt scenes are inconsistent about path roots.
55
55
  */
56
- class VirtualFS {
57
-
58
- constructor( entries ) {
59
-
60
- // entries: { path: Uint8Array }
61
- this.byPath = new Map(); // normalized lowercase -> { norm, bytes }
62
- this.byBase = new Map(); // basename lowercase -> [ { norm, bytes } ] (insertion order)
63
- for ( const key in entries ) {
56
+ export class VirtualFS {
57
+
58
+ /**
59
+ * @param {Object<string,Uint8Array>} entries - materialised bytes, possibly empty
60
+ * @param {object} [source] - seekable archive: `{ listing, read(path) }`. When present the
61
+ * records it lists are read on demand and never held, so a multi-gigabyte archive is
62
+ * indexed rather than resident.
63
+ */
64
+ constructor( entries, source = null ) {
65
+
66
+ // entries: { path: Uint8Array }. Held so a release can drop the caller's reference
67
+ // too — nulling only our own copy frees nothing while the archive object is alive,
68
+ // and that object is every byte of a 30 GB scene.
69
+ this.entries = entries;
70
+ this.source = source;
71
+ this.records = [];
72
+ this.byPath = new Map(); // normalized lowercase -> { key, norm, bytes }
73
+ this.byBase = new Map(); // basename lowercase -> [ { key, norm, bytes } ] (insertion order)
74
+ const add = ( key, bytes, lazy ) => {
64
75
 
65
76
  const norm = normalizePath( key ).toLowerCase();
66
- const rec = { norm, bytes: entries[ key ] };
77
+ if ( this.byPath.has( norm ) ) return;
78
+ const rec = { key, norm, bytes, lazy };
79
+ this.records.push( rec );
67
80
  this.byPath.set( norm, rec );
68
81
  const base = norm.split( '/' ).pop();
69
82
  const bucket = this.byBase.get( base );
70
83
  if ( bucket ) bucket.push( rec );
71
84
  else this.byBase.set( base, [ rec ] );
72
85
 
73
- }
86
+ };
87
+
88
+ for ( const key in entries ) add( key, entries[ key ], false );
89
+ for ( const e of source?.listing ?? [] ) if ( e.offset !== undefined ) add( e.path, null, true );
74
90
 
75
91
  }
76
92
 
77
- find( path ) {
93
+ /** Bytes for a record: already held, or read from the source on demand. */
94
+ async read( rec ) {
78
95
 
79
- const norm = normalizePath( path ).toLowerCase();
80
- if ( this.byPath.has( norm ) ) return this.byPath.get( norm ).bytes;
96
+ if ( ! rec ) return null;
97
+ if ( rec.bytes ) return rec.bytes;
98
+ if ( ! rec.lazy || ! this.source ) return null;
99
+ return this.source.read( rec.key );
100
+
101
+ }
102
+
103
+ /** Resolve and read in one step; `dir` is joined first, as Include does. */
104
+ async readPath( path, dir = null ) {
105
+
106
+ const rec = ( dir !== null && this.findRecord( joinPath( dir, path ) ) ) || this.findRecord( path );
107
+ return this.read( rec );
108
+
109
+ }
110
+
111
+ release( rec ) {
112
+
113
+ rec.bytes = null;
114
+ delete this.entries[ rec.key ];
115
+
116
+ }
117
+
118
+ releasePath( path, dir = null ) {
119
+
120
+ const rec = ( dir !== null && this.findRecord( joinPath( dir, path ) ) ) || this.findRecord( path );
121
+ // Only a lazy record, which can be read again. Releasing a resident one would drop a
122
+ // fragment that a later Include still needs — isHibiscusYoung pulls one in six times.
123
+ if ( rec?.lazy ) this.release( rec );
124
+
125
+ }
126
+
127
+ /** Drop every .pbrt entry's bytes. Safe only once parsing is complete. */
128
+ releaseScenes() {
129
+
130
+ for ( const rec of this.records ) if ( rec.norm.endsWith( '.pbrt' ) ) this.release( rec );
131
+
132
+ }
133
+
134
+ findRecord( path ) {
81
135
 
82
- // Resolve by basename (O(1)); among collisions prefer a path-suffix match,
83
- // else fall back to the first entry with that name. pbrt scenes are
84
- // inconsistent about path roots, so this tolerates relative/absolute drift.
136
+ const norm = normalizePath( path ).toLowerCase();
137
+ if ( this.byPath.has( norm ) ) return this.byPath.get( norm );
138
+
139
+ // Resolve by basename (O(1)); among collisions only a path-suffix match will do.
140
+ // The blind first-entry fallback is safe only when the name is unique: every Moana
141
+ // element ships its own `objects.pbrt`, and `Include "isPalmRig/objects.pbrt"` joined
142
+ // against isPalmRig/ makes a doubled path that matches no suffix — so loading several
143
+ // elements together silently handed each one the FIRST element's instance templates,
144
+ // and every placement that referenced a real template was then dropped.
85
145
  const bucket = this.byBase.get( norm.split( '/' ).pop() );
86
146
  if ( ! bucket ) return null;
87
147
  const suffixHit = bucket.find( rec => rec.norm.endsWith( '/' + norm ) );
88
- return ( suffixHit || bucket[ 0 ] ).bytes;
148
+ return suffixHit || ( bucket.length === 1 ? bucket[ 0 ] : null );
149
+
150
+ }
151
+
152
+ find( path ) {
153
+
154
+ return this.findRecord( path )?.bytes ?? null;
155
+
156
+ }
157
+
158
+ }
159
+
160
+ /** Byte offset of `needle` in `bytes` at or after `from`, or -1. */
161
+ function findBytes( bytes, needle, from = 0 ) {
162
+
163
+ const first = needle[ 0 ];
164
+ const last = bytes.length - needle.length;
165
+ outer: for ( let i = from; i <= last; i ++ ) {
166
+
167
+ if ( bytes[ i ] !== first ) continue;
168
+ for ( let j = 1; j < needle.length; j ++ ) if ( bytes[ i + j ] !== needle[ j ] ) continue outer;
169
+ return i;
170
+
171
+ }
172
+
173
+ return - 1;
174
+
175
+ }
176
+
177
+ const ASCII = s => Uint8Array.from( s, c => c.charCodeAt( 0 ) );
178
+ const WORLD_BEGIN = ASCII( 'WorldBegin' );
179
+ const INCLUDE_WORDS = [ ASCII( 'Include' ), ASCII( 'Import' ) ];
180
+
181
+ function startsLine( bytes, at ) {
182
+
183
+ for ( let i = at - 1; i >= 0; i -- ) {
184
+
185
+ const b = bytes[ i ];
186
+ if ( b === 10 ) return true;
187
+ if ( b !== 32 && b !== 9 ) return false;
188
+
189
+ }
190
+
191
+ return true;
192
+
193
+ }
194
+
195
+ /** True when the entry declares a world block, i.e. it is a scene and not a fragment. */
196
+ function hasWorldBegin( bytes ) {
197
+
198
+ for ( let at = findBytes( bytes, WORLD_BEGIN ); at >= 0; at = findBytes( bytes, WORLD_BEGIN, at + 1 ) ) {
199
+
200
+ if ( startsLine( bytes, at ) ) return true;
89
201
 
90
202
  }
91
203
 
204
+ return false;
205
+
92
206
  }
93
207
 
94
- /** Decoded text of a zip entry, or '' when the value is not decodable bytes. */
95
- function entryText( bytes ) {
208
+ /** Basenames this entry pulls in via Include/Import, lowercased. */
209
+ function includedBasenames( bytes, out ) {
96
210
 
97
- try {
211
+ for ( const word of INCLUDE_WORDS ) {
98
212
 
99
- return decoder.decode( bytes );
213
+ for ( let at = findBytes( bytes, word ); at >= 0; at = findBytes( bytes, word, at + 1 ) ) {
100
214
 
101
- } catch {
215
+ if ( ! startsLine( bytes, at ) ) continue;
216
+ let i = at + word.length;
217
+ while ( i < bytes.length && ( bytes[ i ] === 32 || bytes[ i ] === 9 ) ) i ++;
218
+ if ( bytes[ i ] !== 34 ) continue;
219
+ const start = ++ i;
220
+ while ( i < bytes.length && bytes[ i ] !== 34 && bytes[ i ] !== 10 ) i ++;
221
+ if ( bytes[ i ] !== 34 ) continue;
222
+ const path = decoder.decode( bytes.subarray( start, i ) );
223
+ out.add( normalizePath( path ).toLowerCase().split( '/' ).pop() );
102
224
 
103
- return '';
225
+ }
104
226
 
105
227
  }
106
228
 
@@ -122,18 +244,23 @@ export function listEntryPaths( entries ) {
122
244
  const pbrts = Object.keys( entries ).filter( k => k.toLowerCase().endsWith( '.pbrt' ) );
123
245
  if ( pbrts.length <= 1 ) return pbrts;
124
246
 
125
- const texts = new Map( pbrts.map( k => [ k, entryText( entries[ k ] ) ] ) );
126
-
247
+ // Scanned over bytes: a fragment can be gigabytes, past what a string would hold.
127
248
  const included = new Set();
128
- for ( const text of texts.values() ) {
249
+ const worlds = new Map();
250
+ for ( const k of pbrts ) {
129
251
 
130
- for ( const m of text.matchAll( /^[ \t]*(?:Include|Import)[ \t]+"([^"]+)"/gm ) ) {
252
+ const bytes = entries[ k ];
253
+ worlds.set( k, hasWorldBegin( bytes ) );
254
+ includedBasenames( bytes, included );
131
255
 
132
- included.add( normalizePath( m[ 1 ] ).toLowerCase().split( '/' ).pop() );
256
+ }
133
257
 
134
- }
258
+ return rankEntryPaths( pbrts, worlds, included );
135
259
 
136
- }
260
+ }
261
+
262
+ /** Shared ranking for the eager and lazy entry pickers. */
263
+ function rankEntryPaths( pbrts, worlds, included ) {
137
264
 
138
265
  const narrow = ( candidates, keep ) => {
139
266
 
@@ -142,7 +269,7 @@ export function listEntryPaths( entries ) {
142
269
 
143
270
  };
144
271
 
145
- let pool = narrow( pbrts, k => /^[ \t]*WorldBegin\b/m.test( texts.get( k ) ) );
272
+ let pool = narrow( pbrts, k => worlds.get( k ) );
146
273
  pool = narrow( pool, k => ! included.has( k.toLowerCase().split( '/' ).pop() ) );
147
274
  pool = narrow( pool, k => /(^|\/)(scene|main)\.pbrt$/i.test( k ) );
148
275
 
@@ -166,6 +293,42 @@ export function pickEntryPath( entries ) {
166
293
 
167
294
  }
168
295
 
296
+ /**
297
+ * Entry .pbrt for a VirtualFS, reading each candidate in turn and dropping it again so a
298
+ * lazily-indexed archive is never materialised just to pick a starting file.
299
+ */
300
+ export async function pickEntryPathFrom( vfs ) {
301
+
302
+ return ( await listEntryPathsFrom( vfs ) )[ 0 ] || null;
303
+
304
+ }
305
+
306
+ /**
307
+ * Candidate scene files for a VirtualFS, best first. Each candidate is read to test it and
308
+ * dropped again, so a lazily-indexed archive is never materialised just to rank its entries.
309
+ */
310
+ export async function listEntryPathsFrom( vfs ) {
311
+
312
+ const pbrts = vfs.records.filter( r => r.norm.endsWith( '.pbrt' ) );
313
+ if ( pbrts.length === 0 ) return [];
314
+ if ( pbrts.length === 1 ) return [ pbrts[ 0 ].key ];
315
+
316
+ const included = new Set();
317
+ const worlds = new Map();
318
+ for ( const rec of pbrts ) {
319
+
320
+ const bytes = await vfs.read( rec );
321
+ if ( ! bytes ) continue;
322
+ worlds.set( rec.key, hasWorldBegin( bytes ) );
323
+ includedBasenames( bytes, included );
324
+ if ( rec.lazy ) vfs.release( rec ); // read again later if it is actually used
325
+
326
+ }
327
+
328
+ return rankEntryPaths( pbrts.map( r => r.key ), worlds, included );
329
+
330
+ }
331
+
169
332
  /**
170
333
  * @param {object} args
171
334
  * @param {Object<string,Uint8Array>} args.vfs - zip entries (path → bytes)
@@ -178,56 +341,100 @@ export function pickEntryPath( entries ) {
178
341
  */
179
342
  export async function loadPBRTScene( args ) {
180
343
 
181
- const { vfs: rawEntries, plyParser, imageFromBytes, envFromBytes, convertHandedness } = args;
182
- const vfs = new VirtualFS( rawEntries );
344
+ const { vfs: rawEntries, source, plyParser, imageFromBytes, envFromBytes, convertHandedness } = args;
345
+ const vfs = new VirtualFS( rawEntries, source );
346
+ const warnings = [];
183
347
 
184
- const entryPath = args.entryPath || pickEntryPath( rawEntries );
185
- if ( ! entryPath ) throw new Error( 'PBRT loader: no .pbrt file found in archive' );
348
+ // Ranked once here so a caller does not have to read every .pbrt a second time to learn
349
+ // what else was in the archive.
350
+ const candidates = await listEntryPathsFrom( vfs );
351
+ if ( candidates.length === 0 ) throw new Error( 'PBRT loader: no .pbrt file found in archive' );
186
352
 
187
- const entryBytes = vfs.find( entryPath );
353
+ const requested = args.entryPath && candidates.includes( args.entryPath ) ? args.entryPath : null;
354
+ if ( args.entryPath && ! requested ) {
355
+
356
+ warnings.push( `PBRT entry "${args.entryPath}" is not a scene in this archive — auto-detecting instead` );
357
+
358
+ }
359
+
360
+ const entryPath = requested || candidates[ 0 ];
361
+ const entryBytes = await vfs.readPath( entryPath );
188
362
  if ( ! entryBytes ) throw new Error( `PBRT loader: entry "${entryPath}" not readable` );
189
363
 
190
364
  const baseDir = entryPath.includes( '/' ) ? entryPath.slice( 0, entryPath.lastIndexOf( '/' ) ) : '';
191
365
 
192
- // Parse (with Include resolution)
366
+ // Parse (with Include resolution). Bytes go straight to the lexer — a scene file can
367
+ // be larger than the longest string JavaScript will build.
193
368
  const parser = new PBRTParser( {
194
- resolveInclude: ( path, currentDir ) => {
369
+ resolveInclude: ( path, currentDir ) => vfs.readPath( path, currentDir ),
370
+ // Depth-first, so this keeps only the open include chain live rather than every
371
+ // scene file at once — the difference between 5.7 GB resident and a few MB.
372
+ releaseInclude: ( path, currentDir ) => vfs.releasePath( path, currentDir ),
373
+ maxPlacements: args.maxPlacements
374
+ } );
195
375
 
196
- const bytes = vfs.find( joinPath( currentDir, path ) ) || vfs.find( path );
197
- return bytes ? decoder.decode( bytes ) : null;
376
+ const parseStart = performance.now();
377
+ const ir = await parser.parse( entryBytes, baseDir );
378
+ const parseMs = performance.now() - parseStart;
198
379
 
199
- }
200
- } );
201
- const ir = parser.parse( decoder.decode( entryBytes ), baseDir );
380
+ // Scene text is dead once parsed, and it is the bulk of a big element — isIronwoodA1 is
381
+ // 6.9 GB of it. Freeing before the builder runs halves peak while geometry is allocated.
382
+ // Only after the whole parse: a file can be Included more than once (isHibiscusYoung
383
+ // pulls its xgBonsai scatter in six times, once per placement of the plant).
384
+ vfs.releaseScenes();
202
385
 
203
386
  // Build scene graph
204
- const sliceBuf = ( bytes ) => bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength );
387
+ const sliceBuf = ( bytes ) => (
388
+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
389
+ ? bytes.buffer
390
+ : bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength )
391
+ );
392
+
393
+ // Cached against the RESOLVED entry, not the spelling used, so a file is decoded once and
394
+ // its source bytes can go — isCoral's 8,596 .ply files are 2.3 GB of the load.
395
+ const plyCache = new Map();
396
+ const decodePly = async ( rec ) => {
397
+
398
+ const bytes = await vfs.read( rec );
399
+ if ( ! bytes ) return null;
400
+ vfs.release( rec );
401
+ return plyParser( sliceBuf( bytes ) );
402
+
403
+ };
404
+
205
405
  const builder = new PBRTSceneBuilder( {
206
406
  convertHandedness,
407
+ maxTriangles: args.maxTriangles,
408
+ maxPlacements: args.maxPlacements,
409
+ mergeShapesAbove: args.mergeShapesAbove,
410
+ curveSteps: args.curveSteps,
411
+ curveSides: args.curveSides,
207
412
  resolvePLY: async ( filename ) => {
208
413
 
209
- const bytes = vfs.find( filename );
210
- if ( ! bytes ) return null;
211
- return plyParser( sliceBuf( bytes ) );
414
+ const rec = vfs.findRecord( filename );
415
+ if ( ! rec ) return null;
416
+ if ( ! plyCache.has( rec.norm ) ) plyCache.set( rec.norm, await decodePly( rec ) );
417
+ return plyCache.get( rec.norm );
212
418
 
213
419
  },
214
420
  resolveImage: async ( filename ) => {
215
421
 
216
- const bytes = vfs.find( filename );
422
+ const bytes = await vfs.readPath( filename );
217
423
  if ( ! bytes ) return null;
218
424
  return imageFromBytes( bytes, filename );
219
425
 
220
426
  },
221
427
  resolveEnvironment: async ( filename ) => {
222
428
 
223
- const bytes = vfs.find( filename );
429
+ const bytes = await vfs.readPath( filename );
224
430
  if ( ! bytes ) return null;
225
431
  return ( envFromBytes || imageFromBytes )( bytes, filename );
226
432
 
227
433
  }
228
434
  } );
229
435
 
436
+ const buildStart = performance.now();
230
437
  const result = await builder.build( ir );
231
- return { ...result, entryPath };
438
+ return { ...result, entryPath, candidates, warnings: [ ...warnings, ...( result.warnings ?? [] ) ], parseMs, buildMs: performance.now() - buildStart };
232
439
 
233
440
  }
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * Packed buffer manager for wavefront path tracing — one storage buffer per data category.
3
3
  * RAY/HIT are SoA-within-a-buffer (field `slot` of element `id` lives at `id + slot*_cap`).
4
+ *
5
+ * All three are GPU-only: every lane is written by a kernel before anything reads it and none
6
+ * is ever read back, so they carry no CPU array (see gpuOnlyStorageAttribute).
4
7
  */
5
8
 
6
9
  import {
7
10
  storage, uintBitsToFloat, floatBitsToUint, vec2, vec3, vec4, uvec4, uint, int, float, clamp,
8
11
  packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16, select, floor, log2, exp2, max,
9
12
  } from 'three/tsl';
10
- import { StorageInstancedBufferAttribute } from 'three/webgpu';
13
+ import { gpuOnlyStorageAttribute } from '../TSL/patches.js';
14
+ import { packNormalOct, unpackTriangleNormal } from '../TSL/Common.js';
11
15
  import { createLogger, fmt } from '../utils/Logger.js';
12
16
 
13
17
  const log = createLogger( 'gpu' );
@@ -32,9 +36,12 @@ export const RAY = {
32
36
  SSS_SIGMA_S: 6, // vec4(sigmaS.xyz, g) — SSS scattering coeff + Henyey-Greenstein anisotropy (sigmaS==0 ⇒ glass)
33
37
  };
34
38
 
39
+ // uvec4 so packed lanes keep their exact bits — an f32 lane may canonicalise a NaN pattern.
35
40
  export const HIT = {
36
- DIST_TRI_BARY: 0, // vec4(distance, uintBitsToFloat(triIndex), bary.u, bary.v)
37
- NORMAL_MAT: 1, // vec4(geoNormal.xyz, uintBitsToFloat(matIndex | meshIndex<<16))
41
+ DIST_TRI_BARY: 0, // uvec4(bits(distance), triIndex, bits(bary.u), bits(bary.v))
42
+ // The geometric normal rides as one oct16 word (it was interpolated from oct16 triangle
43
+ // normals to begin with), which leaves the instance leaf a full 32-bit lane of its own.
44
+ NORMAL_MAT: 1, // uvec4(octNormal, matIndex, instanceLeaf + 1, 0)
38
45
  };
39
46
 
40
47
  // SoA region stride, baked into the shader graph at build time; single instance, rebuilt on resize.
@@ -82,14 +89,14 @@ export class PackedRayBuffer {
82
89
 
83
90
  // count=0 so StorageBufferNode.getHash() shares the buffer → RW and RO nodes bind the same GPU data.
84
91
  const rayCount = capacity * RAY_STRIDE;
85
- const rayAttr = new StorageInstancedBufferAttribute( new Float32Array( rayCount * 4 ), 4 );
92
+ const rayAttr = gpuOnlyStorageAttribute( rayCount, 4 );
86
93
  this._attrs.ray = rayAttr;
87
94
  this.rayBuffer = {
88
95
  rw: storage( rayAttr, 'vec4' ),
89
96
  ro: storage( rayAttr, 'vec4' ).toReadOnly(),
90
97
  };
91
98
 
92
- const rngAttr = new StorageInstancedBufferAttribute( new Uint32Array( capacity ), 1 );
99
+ const rngAttr = gpuOnlyStorageAttribute( capacity, 1, Uint32Array );
93
100
  this._attrs.rng = rngAttr;
94
101
  this.rngBuffer = {
95
102
  rw: storage( rngAttr, 'uint' ),
@@ -97,11 +104,11 @@ export class PackedRayBuffer {
97
104
  };
98
105
 
99
106
  const hitCount = capacity * HIT_STRIDE;
100
- const hitAttr = new StorageInstancedBufferAttribute( new Float32Array( hitCount * 4 ), 4 );
107
+ const hitAttr = gpuOnlyStorageAttribute( hitCount, 4, Uint32Array );
101
108
  this._attrs.hit = hitAttr;
102
109
  this.hitBuffer = {
103
- rw: storage( hitAttr, 'vec4' ),
104
- ro: storage( hitAttr, 'vec4' ).toReadOnly(),
110
+ rw: storage( hitAttr, 'uvec4' ),
111
+ ro: storage( hitAttr, 'uvec4' ).toReadOnly(),
105
112
  };
106
113
 
107
114
  // Kept for the [gpu] startup summary in PathTracer._buildWavefrontKernels.
@@ -253,29 +260,33 @@ export const writeRayRadiance = ( buf, id, radiance ) =>
253
260
  .assign( radiance );
254
261
 
255
262
  export const readHitDistance = ( buf, id ) =>
256
- buf.element( soa( id, HIT.DIST_TRI_BARY ) ).x;
263
+ uintBitsToFloat( buf.element( soa( id, HIT.DIST_TRI_BARY ) ).x );
257
264
 
258
265
  export const readHitTriangleIndex = ( buf, id ) =>
259
- floatBitsToUint( buf.element( soa( id, HIT.DIST_TRI_BARY ) ).y );
266
+ buf.element( soa( id, HIT.DIST_TRI_BARY ) ).y;
260
267
 
261
268
  export const readHitBarycentrics = ( buf, id ) =>
262
- buf.element( soa( id, HIT.DIST_TRI_BARY ) ).zw;
269
+ uintBitsToFloat( buf.element( soa( id, HIT.DIST_TRI_BARY ) ).zw );
263
270
 
264
271
  export const readHitNormal = ( buf, id ) =>
265
- buf.element( soa( id, HIT.NORMAL_MAT ) ).xyz;
272
+ unpackTriangleNormal( buf.element( soa( id, HIT.NORMAL_MAT ) ).x );
266
273
 
267
274
  export const readHitMaterialIndex = ( buf, id ) =>
268
- uint( floatBitsToUint( buf.element( soa( id, HIT.NORMAL_MAT ) ).w ).bitAnd( 0xFFFF ) );
275
+ uint( buf.element( soa( id, HIT.NORMAL_MAT ) ).y );
269
276
 
270
- export const readHitMeshIndex = ( buf, id ) =>
271
- floatBitsToUint( buf.element( soa( id, HIT.NORMAL_MAT ) ).w ).shiftRight( 16 );
277
+ /**
278
+ * TLAS leaf that owns the hit, biased by one so 0 reads as "no instance" — leaf 0 is a real
279
+ * node when the scene has a single mesh.
280
+ */
281
+ export const readHitInstanceLeaf = ( buf, id ) =>
282
+ int( buf.element( soa( id, HIT.NORMAL_MAT ) ).z ).sub( int( 1 ) );
272
283
 
273
- export const writeHitPacked = ( buf, id, distance, triIndex, baryU, baryV, normal, matIndex, meshIndex ) => {
284
+ export const writeHitPacked = ( buf, id, distance, triIndex, baryU, baryV, normal, matIndex, instanceLeaf ) => {
274
285
 
275
286
  buf.element( soa( id, HIT.DIST_TRI_BARY ) )
276
- .assign( vec4( distance, uintBitsToFloat( triIndex ), baryU, baryV ) );
287
+ .assign( uvec4( floatBitsToUint( distance ), triIndex, floatBitsToUint( baryU ), floatBitsToUint( baryV ) ) );
277
288
  buf.element( soa( id, HIT.NORMAL_MAT ) )
278
- .assign( vec4( normal, uintBitsToFloat( matIndex.bitOr( meshIndex.shiftLeft( 16 ) ) ) ) );
289
+ .assign( uvec4( packNormalOct( normal ), matIndex, instanceLeaf, uint( 0 ) ) );
279
290
 
280
291
  };
281
292
 
@@ -16,7 +16,7 @@ setBVHWorkerFactory( () => new BVHWorker() );
16
16
 
17
17
  const log = createLogger( 'bvh' );
18
18
 
19
- const FPT = 32; // FLOATS_PER_TRIANGLE
19
+ const FPT = 20; // FLOATS_PER_TRIANGLE — the record is uint lanes, not floats
20
20
  const PARALLEL_THRESHOLD = 50000;
21
21
  const MAX_PARALLEL_WORKERS = 8;
22
22
 
@@ -27,11 +27,11 @@ const MAX_PARALLEL_WORKERS = 8;
27
27
  * Phase 2: Subtree workers build independent subtrees in parallel.
28
28
  * Phase 3: Coordinator assembles final BVH and reorders triangles.
29
29
  *
30
- * @param {Float32Array} triangles - Triangle data (32 floats per triangle)
30
+ * @param {Uint32Array} triangles - Triangle records (20 lanes each)
31
31
  * @param {number} depth - Maximum BVH depth
32
32
  * @param {Function|null} progressCallback - Optional progress callback (0-100)
33
33
  * @param {Object} config - Builder config (maxLeafSize, numBins, treelet settings, etc.)
34
- * @returns {Promise<{bvhData: Float32Array, bvhRoot: true, reorderedTriangles: Float32Array, splitStats: Object}>}
34
+ * @returns {Promise<{bvhData: Float32Array, bvhRoot: true, reorderedTriangles: Uint32Array, splitStats: Object}>}
35
35
  */
36
36
  export function buildBVHParallel( triangles, depth, progressCallback, config ) {
37
37
 
@@ -47,7 +47,8 @@ export function buildBVHParallel( triangles, depth, progressCallback, config ) {
47
47
 
48
48
  // Allocate SharedArrayBuffers
49
49
  const sharedTriangleData = new SharedArrayBuffer( triangles.byteLength );
50
- new Float32Array( sharedTriangleData ).set( triangles );
50
+ new Uint32Array( sharedTriangleData ).set( triangles );
51
+ triangles = null; // shared copy is the only one needed; the fallback rebuilds from it
51
52
 
52
53
  const sharedCentroids = new SharedArrayBuffer( triangleCount * 3 * 4 );
53
54
  const sharedBMin = new SharedArrayBuffer( triangleCount * 3 * 4 );
@@ -97,8 +98,8 @@ export function buildBVHParallel( triangles, depth, progressCallback, config ) {
97
98
  cleanup();
98
99
  // Copy from SharedArrayBuffer to regular ArrayBuffer for transfer
99
100
  const restoredBuffer = new ArrayBuffer( sharedTriangleData.byteLength );
100
- new Float32Array( restoredBuffer ).set( new Float32Array( sharedTriangleData ) );
101
- const restoredTriangles = new Float32Array( restoredBuffer );
101
+ new Uint32Array( restoredBuffer ).set( new Uint32Array( sharedTriangleData ) );
102
+ const restoredTriangles = new Uint32Array( restoredBuffer );
102
103
  resolve( buildSingleWorker( restoredTriangles, depth, progressCallback, config ) );
103
104
 
104
105
  };
@@ -149,7 +150,7 @@ export function buildBVHParallel( triangles, depth, progressCallback, config ) {
149
150
 
150
151
  settled = true;
151
152
  cleanup();
152
- const reorderedTriangles = new Float32Array( sharedReorderBuffer );
153
+ const reorderedTriangles = new Uint32Array( sharedReorderBuffer );
153
154
  resolve( { bvhData: msg.bvhData, bvhRoot: true, reorderedTriangles, originalToBvh: msg.originalToBvh || null, splitStats: phase1Stats || {} } );
154
155
  return;
155
156
 
@@ -439,7 +440,7 @@ function buildSingleWorker( triangles, depth, progressCallback, config ) {
439
440
 
440
441
  worker.terminate();
441
442
  const reorderedTriangles = sharedReorderBuffer
442
- ? new Float32Array( sharedReorderBuffer )
443
+ ? new Uint32Array( sharedReorderBuffer )
443
444
  : transferredTriangles;
444
445
 
445
446
  resolve( { bvhData, bvhRoot: true, reorderedTriangles, originalToBvh: originalToBvh || null, splitStats: treeletStats || {} } );
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { storage } from 'three/tsl';
6
6
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
7
+ import { gpuOnlyStorageAttribute } from '../TSL/patches.js';
7
8
  import { freeStorageAttribute } from './PackedRayBuffer.js';
8
9
  import { createLogger, fmt } from '../utils/Logger.js';
9
10
 
@@ -94,8 +95,9 @@ export class QueueManager {
94
95
  );
95
96
  this.bounceCounts = storage( this._bounceCountsAttr, 'uint' );
96
97
 
97
- const attrA = new StorageInstancedBufferAttribute( new Uint32Array( capacity ), 1 );
98
- const attrB = new StorageInstancedBufferAttribute( new Uint32Array( capacity ), 1 );
98
+ // Index lists, not data: written wholesale by compaction before any read, never read back.
99
+ const attrA = gpuOnlyStorageAttribute( capacity, 1, Uint32Array );
100
+ const attrB = gpuOnlyStorageAttribute( capacity, 1, Uint32Array );
99
101
  this._attrA = attrA;
100
102
  this._attrB = attrB;
101
103
 
@@ -112,7 +114,7 @@ export class QueueManager {
112
114
 
113
115
  // Material-sort output: a material-reordered permutation of the active index
114
116
  // list, written by the global material sort and read by Shade in place of activeIndices.
115
- const sortAttr = new StorageInstancedBufferAttribute( new Uint32Array( capacity ), 1 );
117
+ const sortAttr = gpuOnlyStorageAttribute( capacity, 1, Uint32Array );
116
118
  this._sortAttr = sortAttr;
117
119
  this.sortedIndices = storage( sortAttr, 'uint' );
118
120
  this.sortedIndicesRO = storage( sortAttr, 'uint' ).toReadOnly();