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
@@ -11,10 +11,12 @@ import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils
11
11
  import { clone as cloneWithSkeletons } from 'three/addons/utils/SkeletonUtils.js';
12
12
  import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
13
13
  import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
14
+ import {
15
+ detectArchiveKind, readTarGz, readTar, elementFilter, listArchiveElements, openTar } from './ArchiveReader.js';
14
16
  import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
15
17
  import { BuildTimer } from './BuildTimer.js';
16
18
  import { getAssetConfig } from '../AssetConfig.js';
17
- import { loadPBRTScene, pickEntryPath, listEntryPaths } from './PBRT/index.js';
19
+ import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
18
20
  import { extractSceneMetadata } from './SceneMetadata.js';
19
21
  import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
20
22
  import { getRenderProfile } from '../EngineDefaults.js';
@@ -32,7 +34,10 @@ const SUPPORTED_FORMATS = {
32
34
  'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' }, 'exr': { type: 'environment', name: 'EXR (OpenEXR)' },
33
35
  'png': { type: 'image', name: 'PNG' }, 'jpg': { type: 'image', name: 'JPEG' },
34
36
  'jpeg': { type: 'image', name: 'JPEG' }, 'webp': { type: 'image', name: 'WebP' },
35
- 'zip': { type: 'archive', name: 'ZIP Archive' }
37
+ 'zip': { type: 'archive', name: 'ZIP Archive' },
38
+ 'gz': { type: 'archive', name: 'Gzipped TAR Archive' },
39
+ 'tgz': { type: 'archive', name: 'Gzipped TAR Archive' },
40
+ 'tar': { type: 'archive', name: 'TAR Archive' }
36
41
  };
37
42
 
38
43
  // Loose USD layers inside a ZIP compose into one scene; these pick out the
@@ -40,6 +45,16 @@ const SUPPORTED_FORMATS = {
40
45
  const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
41
46
  const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
42
47
  const MTL_TEXTURE_TIMEOUT_MS = 30000;
48
+ /**
49
+ * Unpacked size past which a multi-part scene archive asks which parts to load rather than
50
+ * taking all of them. Not a hard limit — picking every part is a valid answer.
51
+ *
52
+ * Measured on Moana: a 7.5 GB archive of 15 parts parses to 40M triangles and ~7.3 GB of CPU
53
+ * memory, which loads on a freshly started browser and fails on one that has been up a while.
54
+ * Anything near that is worth a question, because the failure past it kills the tab rather than
55
+ * throwing. Override per load with `promptBytes`.
56
+ */
57
+ export const ARCHIVE_ELEMENT_PROMPT_BYTES = 4_000_000_000;
43
58
  // A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
44
59
  // reorders and disposes what it is given, but never writes the attributes.
45
60
  function standInForSplit( source ) {
@@ -275,7 +290,7 @@ export class AssetLoader extends EventDispatcher {
275
290
  }
276
291
 
277
292
  // Asset loading methods
278
- async loadAssetFromFile( file ) {
293
+ async loadAssetFromFile( file, options = {} ) {
279
294
 
280
295
  const filename = file.name;
281
296
  const format = this.getFileFormat( filename );
@@ -290,7 +305,7 @@ export class AssetLoader extends EventDispatcher {
290
305
  case 'model': result = await this.loadModelFromFile( file, filename ); break;
291
306
  case 'environment':
292
307
  case 'image': result = await this.loadEnvironmentFromFile( file, filename ); break;
293
- case 'archive': result = await this.loadArchiveFromFile( file, filename ); break;
308
+ case 'archive': result = await this.loadArchiveFromFile( file, filename, options ); break;
294
309
  default: throw new Error( `Unknown asset type: ${format.type}` );
295
310
 
296
311
  }
@@ -483,43 +498,258 @@ export class AssetLoader extends EventDispatcher {
483
498
  }
484
499
 
485
500
  // Archive handling
501
+ /** First bytes of a file, without pulling the whole thing into memory. */
502
+ async _readHead( file, bytes = 512 ) {
503
+
504
+ if ( file instanceof Uint8Array ) return file.subarray( 0, bytes );
505
+ if ( typeof file.slice === 'function' && typeof file.arrayBuffer === 'function' ) {
506
+
507
+ return new Uint8Array( await file.slice( 0, bytes ).arrayBuffer() );
508
+
509
+ }
510
+
511
+ return new Uint8Array( ( await this.readFileAsArrayBuffer( file ) ).slice( 0, bytes ) );
512
+
513
+ }
514
+
515
+ /**
516
+ * Enumerates a TAR/TAR.GZ archive without keeping any of it, so a caller can offer a
517
+ * choice before committing memory. ZIP archives are random-access and report directly.
518
+ * @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
519
+ */
520
+ async inspectArchive( file ) {
521
+
522
+ const kind = detectArchiveKind( await this._readHead( file ) );
523
+
524
+ if ( kind === 'gzip' || kind === 'tar' ) {
525
+
526
+ const read = kind === 'gzip' ? readTarGz : readTar;
527
+ const { listing } = await read( file, {
528
+ filter: () => false,
529
+ onProgress: p => updateLoading( {
530
+ isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
531
+ } )
532
+ } );
533
+ const { root, elements } = listArchiveElements( listing );
534
+ return {
535
+ kind, root, elements,
536
+ entryCount: listing.length,
537
+ totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
538
+ };
539
+
540
+ }
541
+
542
+ const zip = unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
543
+ const listing = Object.keys( zip ).map( path => ( { path, size: zip[ path ].length } ) );
544
+ const { root, elements } = listArchiveElements( listing );
545
+ return {
546
+ kind: 'zip', root, elements,
547
+ entryCount: listing.length,
548
+ totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
549
+ };
550
+
551
+ }
552
+
486
553
  /**
487
554
  * @param {File|Blob} file
488
555
  * @param {string} filename
489
- * @param {{pbrtEntry?: string}} [options] - `pbrtEntry` names which .pbrt to load when the
490
- * archive holds several independent scenes; the issue log lists what else was available.
556
+ * @param {object} [options]
557
+ * @param {string} [options.pbrtEntry] - which .pbrt to load when the archive holds several
558
+ * independent scenes; the issue log lists what else was available.
559
+ * @param {string|string[]} [options.element] - path prefix of one subtree to load on its own,
560
+ * or several to load together, as reported by `inspectArchive()`. Everything above them
561
+ * (scene file, materials, textures) comes along; subtrees left out are skipped without ever
562
+ * being held in memory, and the Includes that point at them only warn.
563
+ * @param {number} [options.byteBudget] - cap on retained bytes when no element is chosen.
564
+ * @param {number} [options.promptBytes] - size past which a multi-part archive asks which
565
+ * parts to load instead of taking all of them.
566
+ * @param {number} [options.maxTriangles] - stop past this many STORED triangles.
567
+ * @param {number} [options.maxPlacements] - stop past this many instance placements.
568
+ * @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
569
+ * @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
570
+ * @param {number} [options.curveSides] - 1 ribbon, 2 crossed ribbons, >=3 closed tube.
491
571
  */
492
- async loadArchiveFromFile( file, filename, { pbrtEntry = null } = {} ) {
572
+ async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
493
573
 
494
574
  try {
495
575
 
496
- const arrayBuffer = await this.readFileAsArrayBuffer( file );
497
- const zip = unzipSync( new Uint8Array( arrayBuffer ) );
576
+ const kind = detectArchiveKind( await this._readHead( file ) );
577
+
578
+ // An uncompressed tar over a File is seekable, so it is indexed rather than read:
579
+ // entries are pulled out as the scene asks for them and never all held at once.
580
+ // That is the difference between a 7 GB archive being refused and loading.
581
+ if ( kind === 'tar' ) {
582
+
583
+ const source = await this._openSeekableArchive( file, filename, element, promptBytes );
584
+ if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
585
+
586
+ return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
587
+
588
+ }
589
+
590
+ // Not a pbrt scene: fall back to materialising it, which those paths still expect.
591
+ for ( const e of source.listing ) source.entries[ e.path ] ??= await source.read( e.path );
592
+ return await this._loadNonPBRTArchive( source.entries, filename );
593
+
594
+ }
595
+
596
+ const entries = kind === 'gzip'
597
+ ? await this._readStreamedArchive( file, filename, kind, element, byteBudget )
598
+ : unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
498
599
 
499
600
  // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
500
- if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename, pbrtEntry );
601
+ if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
501
602
 
502
- const result = await this.processObjMtlPairsInZip( zip, filename );
503
- if ( result ) return result;
504
- return await this.findAndLoadModelFromZip( zip, filename );
603
+ return await this._loadNonPBRTArchive( entries, filename );
505
604
 
506
605
  } catch ( error ) {
507
606
 
508
- console.error( 'Error loading ZIP archive:', error );
607
+ if ( error?.code !== 'ARCHIVE_NEEDS_ELEMENT' ) console.error( 'Error loading archive:', error );
509
608
  throw error;
510
609
 
511
610
  }
512
611
 
513
612
  }
514
613
 
614
+ async _loadNonPBRTArchive( entries, filename ) {
615
+
616
+ const result = await this.processObjMtlPairsInZip( entries, filename );
617
+ if ( result ) return result;
618
+ return await this.findAndLoadModelFromZip( entries, filename );
619
+
620
+ }
621
+
622
+ /**
623
+ * Index an uncompressed tar without retaining it. Entries are read back from the File on
624
+ * demand, so residency is the open include chain rather than the whole archive.
625
+ *
626
+ * Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
627
+ * the scene that comes out is what runs the tab out of memory. So a large multi-part archive
628
+ * stops here and asks which parts to load, the same as the streamed path does. The index is
629
+ * already built at that point, so the question costs nothing.
630
+ * @private
631
+ */
632
+ async _openSeekableArchive( file, filename, element, promptBytes ) {
633
+
634
+ const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
635
+
636
+ const source = await openTar( file, {
637
+ filter: chosen.length ? elementFilter( chosen ) : null,
638
+ retain: () => false,
639
+ } );
640
+
641
+ const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
642
+
643
+ if ( chosen.length === 0 ) this._requireElementChoice( filename, source.listing, totalBytes, promptBytes );
644
+
645
+ console.info(
646
+ `Archive "${filename}": indexed ${source.indexed} entries, ` +
647
+ `${( totalBytes / 1e9 ).toFixed( 1 )} GB, none resident` +
648
+ ( chosen.length ? `, ${chosen.length} part${chosen.length > 1 ? 's' : ''} selected.` : '.' )
649
+ );
650
+
651
+ return source;
652
+
653
+ }
654
+
655
+ /**
656
+ * Stop and ask which parts to load, when the archive is big enough that the answer matters
657
+ * and it actually has parts to choose between. Not a capability limit — selecting every part
658
+ * is a valid answer and loads the whole scene.
659
+ * @private
660
+ */
661
+ _requireElementChoice( filename, listing, totalBytes, promptBytes = ARCHIVE_ELEMENT_PROMPT_BYTES ) {
662
+
663
+ if ( totalBytes < promptBytes ) return;
664
+
665
+ const { root, elements } = listArchiveElements( listing );
666
+ if ( elements.length < 2 ) return;
667
+
668
+ const error = new Error(
669
+ `"${filename}" holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts. ` +
670
+ 'Choose which to load — loading all of them at once may exhaust memory.'
671
+ );
672
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
673
+ error.root = root;
674
+ error.elements = elements;
675
+ error.totalBytes = totalBytes;
676
+
677
+ // Logged as a warning on purpose: recording an ERROR makes a strict host throw an
678
+ // EngineIssueError from inside record(), and the caller never learns which parts it
679
+ // could have chosen. The typed throw below is the refusal, and it carries the list.
680
+ this._issues?.record(
681
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
682
+ `archive holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts; choose which to load`,
683
+ { root, elements: elements.map( e => e.prefix ), totalBytes },
684
+ ISSUE_SEVERITY.WARNING
685
+ );
686
+
687
+ throw error;
688
+
689
+ }
690
+
691
+ async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
692
+
693
+ const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
694
+ const read = kind === 'gzip' ? readTarGz : readTar;
695
+ const { entries, listing, retainedBytes, truncated } = await read( file, {
696
+ filter: chosen.length ? elementFilter( chosen ) : null,
697
+ ...( byteBudget === undefined ? {} : { byteBudget } ),
698
+ onProgress: p => updateLoading( {
699
+ isLoading: true,
700
+ status: `Reading archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`,
701
+ progress: 4
702
+ } )
703
+ } );
704
+
705
+ // Choosing every part is a valid answer, so a selection is not refused again for being
706
+ // large: the read budget exists to stop an unasked-for whole-archive load. Past this
707
+ // point the scene's own memory preflight is what refuses, with a figure to act on.
708
+ if ( truncated && chosen.length === 0 ) {
709
+
710
+ const { root, elements } = listArchiveElements( listing );
711
+ const total = listing.reduce( ( n, e ) => n + e.size, 0 );
712
+ const error = new Error(
713
+ `"${filename}" unpacks to ${( total / 1e9 ).toFixed( 1 )} GB, past the load budget. ` +
714
+ `Load one of its ${elements.length} parts instead.`
715
+ );
716
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
717
+ error.root = root;
718
+ error.elements = elements;
719
+ error.totalBytes = total;
720
+ // Warning, not error: a strict host's throw from record() would replace the typed
721
+ // error below and take the part list with it.
722
+ this._issues?.record(
723
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
724
+ `archive unpacks to ${( total / 1e9 ).toFixed( 1 )} GB; choose one of ${elements.length} parts`,
725
+ { root, elements: elements.map( e => e.prefix ), totalBytes: total },
726
+ ISSUE_SEVERITY.WARNING
727
+ );
728
+ throw error;
729
+
730
+ }
731
+
732
+ if ( chosen.length ) {
733
+
734
+ console.info(
735
+ `Archive "${filename}": loaded ${chosen.map( c => `"${c}"` ).join( ', ' )} — ` +
736
+ `${Object.keys( entries ).length} of ${listing.length} files, ${( retainedBytes / 1048576 ).toFixed( 1 )} MB.`
737
+ );
738
+
739
+ }
740
+
741
+ return entries;
742
+
743
+ }
744
+
515
745
  /**
516
746
  * Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
517
747
  * (following Include/Import), builds a THREE.Group, sets the infinite light
518
748
  * as the scene environment, and runs the standard onModelLoad pipeline.
519
- * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
749
+ * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes); consumed, entry by entry
520
750
  * @param {string} filename - original archive name (for display/events)
521
751
  */
522
- async loadPBRTFromZip( zip, filename, entryPath = null ) {
752
+ async loadPBRTFromZip( zip, filename, entryPath = null, options = {}, source = null ) {
523
753
 
524
754
  updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
525
755
 
@@ -553,17 +783,28 @@ export class AssetLoader extends EventDispatcher {
553
783
 
554
784
  };
555
785
 
556
- const requested = entryPath && listEntryPaths( zip ).includes( entryPath ) ? entryPath : null;
557
- if ( entryPath && ! requested ) console.warn( `PBRT entry "${entryPath}" is not a scene in this archive — auto-detecting instead` );
558
-
559
- const { group, environment, report, warnings, entryPath: loadedEntry } = await loadPBRTScene( {
560
- vfs: zip, entryPath: requested, plyParser, imageFromBytes, envFromBytes
786
+ const pbrtStart = performance.now();
787
+ const { group, environment, report, warnings, meshCount, entryPath: loadedEntry, candidates,
788
+ parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
789
+ droppedNoTemplate } = await loadPBRTScene( {
790
+ vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes,
791
+ maxTriangles: options.maxTriangles,
792
+ maxPlacements: options.maxPlacements,
793
+ mergeShapesAbove: options.mergeShapesAbove,
794
+ curveSteps: options.curveSteps,
795
+ curveSides: options.curveSides
561
796
  } );
562
797
 
798
+ // Phase breakdown for scaling work; the engine's own build timings live in
799
+ // SceneProcessor.performanceMetrics.
800
+ this.lastPBRTStats = {
801
+ parseMs, buildMs, loaderMs: performance.now() - pbrtStart,
802
+ triangleCount, placementCount, mergedShapes, skippedForBudget, droppedNoTemplate, meshCount
803
+ };
804
+
563
805
  // An archive can hold several independent scenes (transparent-machines ships five
564
806
  // animation frames). Only one is loaded, so name it and the alternatives rather
565
807
  // than leave the user comparing against a reference of a different scene.
566
- const candidates = listEntryPaths( zip );
567
808
  if ( candidates.length > 1 ) {
568
809
 
569
810
  const others = candidates.filter( p => p !== loadedEntry );
@@ -580,7 +821,8 @@ export class AssetLoader extends EventDispatcher {
580
821
  // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
581
822
  if ( report && report.length && typeof console.table === 'function' ) {
582
823
 
583
- console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${loadedEntry}"` );
824
+ const shown = report.length < meshCount ? ` (first ${report.length} of ${meshCount})` : '';
825
+ console.groupCollapsed( `PBRT loader: ${meshCount} mesh(es) from "${loadedEntry}"${shown}` );
584
826
  console.table( report );
585
827
  console.groupEnd();
586
828
 
@@ -5,6 +5,21 @@ import { createLogger, fmt } from '../utils/Logger.js';
5
5
 
6
6
  const log = createLogger( 'bvh' );
7
7
 
8
+ // Inline copies of EngineDefaults.BVH_LEAF_MARKERS / bvhIndexView — this module also runs inside
9
+ // a worker, where importing Constants is not safe. Keep in step with EngineDefaults.js.
10
+ // Index fields are u32 BIT PATTERNS, never float values: as floats they round past 2^24.
11
+ const TRIANGLE_LEAF = 0x40000000;
12
+ const FRONTIER_LEAF = 0x40000002;
13
+ const indexView = f32 => new Uint32Array( f32.buffer, f32.byteOffset, f32.length );
14
+ const BVH_MAX_INDEX = 0x40000000;
15
+
16
+ function assertFits( count, what ) {
17
+
18
+ if ( count >= BVH_MAX_INDEX ) throw new RangeError( `${what} is ${count}, at or past the BVH index limit of ${BVH_MAX_INDEX}` );
19
+ return count;
20
+
21
+ }
22
+
8
23
  // Injected, not imported: this module also runs inside the worker, and `?worker&inline`
9
24
  // would embed a second copy of BVHWorker's source there.
10
25
  let createBVHWorker = null;
@@ -19,19 +34,18 @@ export function setBVHWorkerFactory( factory ) {
19
34
  // Cannot import Constants.js because BVHBuilder runs inside BVHWorker
20
35
  // where `window` (used elsewhere in Constants.js) is not defined.
21
36
  const TRIANGLE_DATA_LAYOUT = {
22
- FLOATS_PER_TRIANGLE: 32,
37
+ FLOATS_PER_TRIANGLE: 20,
23
38
  POSITION_A_OFFSET: 0,
24
39
  POSITION_B_OFFSET: 4,
25
- POSITION_C_OFFSET: 8,
26
- NORMAL_A_OFFSET: 12,
27
- NORMAL_B_OFFSET: 16,
28
- NORMAL_C_OFFSET: 20,
29
- UV_AB_OFFSET: 24,
30
- UV_C_MAT_OFFSET: 28 // vec4: uvC.x, uvC.y, materialIndex, meshIndex
40
+ POSITION_C_OFFSET: 8
31
41
  };
32
42
 
33
43
  const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
34
44
 
45
+ // The record buffer is uint; positions are f32 in it. Views share the memory, no copy.
46
+ const floatView = ( data ) => data instanceof Float32Array
47
+ ? data : new Float32Array( data.buffer, data.byteOffset, data.length );
48
+
35
49
  class BVHNode {
36
50
 
37
51
  constructor() {
@@ -215,7 +229,7 @@ export class BVHBuilder {
215
229
  initializeTriangleArrays() {
216
230
 
217
231
  const n = this.totalTriangles;
218
- const src = this.triangles;
232
+ const src = floatView( this.triangles );
219
233
  const PA = TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET;
220
234
  const PB = TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET;
221
235
  const PC = TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET;
@@ -679,7 +693,7 @@ export class BVHBuilder {
679
693
  // Phase 6: Create reordered triangle data from final index order
680
694
  const reorderStart = performance.now();
681
695
  const triSrc = this.triangles;
682
- const reordered = reorderTarget || new Float32Array( n * FPT );
696
+ const reordered = reorderTarget || new triSrc.constructor( n * FPT );
683
697
  for ( let i = 0; i < n; i ++ ) {
684
698
 
685
699
  const srcOff = this.indices[ i ] * FPT;
@@ -1580,7 +1594,9 @@ export class BVHBuilder {
1580
1594
  // Inner: [leftMin.xyz, leftChild] [leftMax.xyz, rightChild] [rightMin.xyz, 0] [rightMax.xyz, 0]
1581
1595
  // Leaf: [triOffset, triCount, 0, -1] [0,0,0,0] [0,0,0,0] [0,0,0,0]
1582
1596
  const FLOATS_PER_NODE = 16;
1597
+ assertFits( nodes.length, 'BLAS node count' );
1583
1598
  const data = new Float32Array( nodes.length * FLOATS_PER_NODE );
1599
+ const idx = indexView( data );
1584
1600
 
1585
1601
  for ( let i = 0; i < nodes.length; i ++ ) {
1586
1602
 
@@ -1596,12 +1612,12 @@ export class BVHBuilder {
1596
1612
  data[ o ] = left.minX;
1597
1613
  data[ o + 1 ] = left.minY;
1598
1614
  data[ o + 2 ] = left.minZ;
1599
- data[ o + 3 ] = left._flatIndex;
1615
+ idx[ o + 3 ] = left._flatIndex;
1600
1616
 
1601
1617
  data[ o + 4 ] = left.maxX;
1602
1618
  data[ o + 5 ] = left.maxY;
1603
1619
  data[ o + 6 ] = left.maxZ;
1604
- data[ o + 7 ] = right._flatIndex;
1620
+ idx[ o + 7 ] = right._flatIndex;
1605
1621
 
1606
1622
  data[ o + 8 ] = right.minX;
1607
1623
  data[ o + 9 ] = right.minY;
@@ -1615,11 +1631,10 @@ export class BVHBuilder {
1615
1631
 
1616
1632
  } else {
1617
1633
 
1618
- // Leaf node: triOffset, triCount in vec4(0), marked by leftChild = -1
1619
- data[ o ] = node.triangleOffset;
1620
- data[ o + 1 ] = node.triangleCount;
1634
+ idx[ o ] = node.triangleOffset;
1635
+ idx[ o + 1 ] = node.triangleCount;
1621
1636
  // data[o+2] = 0 (padding)
1622
- data[ o + 3 ] = - 1; // Leaf marker
1637
+ idx[ o + 3 ] = TRIANGLE_LEAF;
1623
1638
 
1624
1639
  }
1625
1640
 
@@ -1630,7 +1645,7 @@ export class BVHBuilder {
1630
1645
  }
1631
1646
 
1632
1647
  /**
1633
- * Flatten BVH tree marking frontier leaves with -2 sentinel.
1648
+ * Flatten BVH tree marking frontier leaves with the frontier tag.
1634
1649
  * Returns the flat data and a frontier map for assembly.
1635
1650
  * @param {BVHNode} root
1636
1651
  * @returns {{ flatData: Float32Array, frontierMap: Array<{taskId: number, flatIndex: number}> }}
@@ -1653,8 +1668,10 @@ export class BVHBuilder {
1653
1668
  }
1654
1669
 
1655
1670
  // Second pass: write flat data
1671
+ assertFits( nodes.length, 'BLAS node count' );
1656
1672
  const data = new Float32Array( nodes.length * FLOATS_PER_NODE );
1657
1673
  const frontierMap = [];
1674
+ const idx = indexView( data );
1658
1675
 
1659
1676
  for ( let i = 0; i < nodes.length; i ++ ) {
1660
1677
 
@@ -1670,12 +1687,12 @@ export class BVHBuilder {
1670
1687
  data[ o ] = left.minX;
1671
1688
  data[ o + 1 ] = left.minY;
1672
1689
  data[ o + 2 ] = left.minZ;
1673
- data[ o + 3 ] = left._flatIndex;
1690
+ idx[ o + 3 ] = left._flatIndex;
1674
1691
 
1675
1692
  data[ o + 4 ] = left.maxX;
1676
1693
  data[ o + 5 ] = left.maxY;
1677
1694
  data[ o + 6 ] = left.maxZ;
1678
- data[ o + 7 ] = right._flatIndex;
1695
+ idx[ o + 7 ] = right._flatIndex;
1679
1696
 
1680
1697
  data[ o + 8 ] = right.minX;
1681
1698
  data[ o + 9 ] = right.minY;
@@ -1687,21 +1704,19 @@ export class BVHBuilder {
1687
1704
 
1688
1705
  } else if ( node.isFrontier ) {
1689
1706
 
1690
- // Frontier leaf: mark with -2 sentinel, use the taskId stored on the node
1691
1707
  const taskId = node.frontierTaskId;
1692
- data[ o ] = node.triangleOffset;
1693
- data[ o + 1 ] = node.triangleCount;
1694
- data[ o + 2 ] = taskId;
1695
- data[ o + 3 ] = - 2; // Frontier sentinel
1708
+ idx[ o ] = node.triangleOffset;
1709
+ idx[ o + 1 ] = node.triangleCount;
1710
+ idx[ o + 2 ] = taskId;
1711
+ idx[ o + 3 ] = FRONTIER_LEAF;
1696
1712
 
1697
1713
  frontierMap.push( { taskId, flatIndex: i } );
1698
1714
 
1699
1715
  } else {
1700
1716
 
1701
- // Regular leaf
1702
- data[ o ] = node.triangleOffset;
1703
- data[ o + 1 ] = node.triangleCount;
1704
- data[ o + 3 ] = - 1; // Leaf marker
1717
+ idx[ o ] = node.triangleOffset;
1718
+ idx[ o + 1 ] = node.triangleCount;
1719
+ idx[ o + 3 ] = TRIANGLE_LEAF;
1705
1720
 
1706
1721
  }
1707
1722
 
@@ -1735,7 +1750,9 @@ export class BVHBuilder {
1735
1750
  }
1736
1751
 
1737
1752
  // Allocate final array
1753
+ assertFits( totalNodes, 'assembled BLAS node count' );
1738
1754
  const finalData = new Float32Array( totalNodes * FLOATS_PER_NODE );
1755
+ const finalIdx = indexView( finalData );
1739
1756
 
1740
1757
  // Copy top-level data
1741
1758
  finalData.set( topFlatData );
@@ -1766,12 +1783,11 @@ export class BVHBuilder {
1766
1783
 
1767
1784
  const o = destOffset + j * FLOATS_PER_NODE;
1768
1785
 
1769
- // Check if inner node (not a leaf: leaf has -1 at o+3)
1770
- if ( finalData[ o + 3 ] !== - 1 ) {
1786
+ // Leaf tags sit above every valid index, so one unsigned compare separates them.
1787
+ if ( finalIdx[ o + 3 ] < TRIANGLE_LEAF ) {
1771
1788
 
1772
- // Adjust leftChildIndex and rightChildIndex
1773
- finalData[ o + 3 ] += globalOffset;
1774
- finalData[ o + 7 ] += globalOffset;
1789
+ finalIdx[ o + 3 ] += globalOffset;
1790
+ finalIdx[ o + 7 ] += globalOffset;
1775
1791
 
1776
1792
  }
1777
1793