rayzee 7.31.1 → 8.0.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 (51) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6366 -4361
  3. package/dist/rayzee.umd.js +122 -73
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +5 -0
  7. package/src/PathTracerApp.js +120 -19
  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 +577 -92
  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/Stages/NormalDepth.js +6 -2
  35. package/src/Stages/PathTracer.js +5 -5
  36. package/src/Stages/PathTracerStage.js +163 -41
  37. package/src/TSL/BVHTraversal.js +208 -90
  38. package/src/TSL/Common.js +135 -2
  39. package/src/TSL/Displacement.js +21 -11
  40. package/src/TSL/EmissiveSampling.js +49 -25
  41. package/src/TSL/ExtendKernel.js +3 -2
  42. package/src/TSL/LightBVHSampling.js +7 -4
  43. package/src/TSL/LightsDirect.js +30 -29
  44. package/src/TSL/ShadeKernel.js +8 -6
  45. package/src/TSL/Struct.js +3 -0
  46. package/src/TSL/TextureSampling.js +24 -9
  47. package/src/TSL/patches.js +128 -3
  48. package/src/index.js +3 -0
  49. package/src/managers/AnimationManager.js +86 -76
  50. package/src/managers/MaterialDataManager.js +29 -23
  51. package/src/managers/TransformManager.js +9 -192
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.31.1",
3
+ "version": "8.0.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -533,25 +533,122 @@ export const AF_DEFAULTS = {
533
533
  SNAP_THRESHOLD: 0.5,
534
534
  };
535
535
 
536
- // Triangle data layout constants - shared between GeometryExtractor and TextureCreator
536
+ /**
537
+ * Triangle record: 5 uvec4 lanes (80 bytes). The buffer is declared `uvec4` so packed lanes
538
+ * keep their exact bit pattern — a packSnorm2x16 result can land in the f32 NaN range, and an
539
+ * f32 lane may canonicalise it (the same reason the G-buffer is uvec4). Positions and UVs are
540
+ * written as f32 through a Float32Array view of the same memory and read back with
541
+ * `uintBitsToFloat`, so they carry full precision; only normals are compressed.
542
+ *
543
+ * Was 32 floats (128 B) with 4 dead padding lanes. At 128 B the 2 GB V8 ArrayBuffer cap put a
544
+ * hard ceiling of 16.7M triangles on the scene — 80 B lifts that to 26.8M and cuts geometry
545
+ * VRAM by the same 37.5%. Each normal now rides in its position's spare .w lane, so the three
546
+ * vec4 loads the intersection test already does carry the normals with them.
547
+ */
537
548
  export const TRIANGLE_DATA_LAYOUT = {
538
- FLOATS_PER_TRIANGLE: 32,
549
+ FLOATS_PER_TRIANGLE: 20,
539
550
 
540
- // Positions (3 vec4s = 12 floats)
541
- POSITION_A_OFFSET: 0,
551
+ POSITION_A_OFFSET: 0, // f32 xyz + packed normal A
542
552
  POSITION_B_OFFSET: 4,
543
553
  POSITION_C_OFFSET: 8,
544
554
 
545
- // Normals (3 vec4s = 12 floats)
546
- NORMAL_A_OFFSET: 12,
547
- NORMAL_B_OFFSET: 16,
548
- NORMAL_C_OFFSET: 20,
555
+ NORMAL_A_PACKED_OFFSET: 3, // oct16 in each position's .w lane
556
+ NORMAL_B_PACKED_OFFSET: 7,
557
+ NORMAL_C_PACKED_OFFSET: 11,
549
558
 
550
- // UVs and Material (2 vec4s = 8 floats)
551
- UV_AB_OFFSET: 24,
552
- UV_C_MAT_OFFSET: 28
559
+ UV_AB_OFFSET: 12, // f32 uvA.xy, uvB.xy
560
+ UV_C_OFFSET: 16, // f32 uvC.xy
561
+ MATERIAL_FLAGS_OFFSET: 18, // materialIndex | side << 24 | shadowBlockerBits << 26 (two bits)
562
+ MESH_INDEX_OFFSET: 19
553
563
  };
554
564
 
565
+ export const TRI_MATERIAL_MASK = 0xffffff;
566
+ export const TRI_SIDE_SHIFT = 24; // 0 front, 1 back, 2 double
567
+ export const TRI_BLOCKER_SHIFT = 26; // 1 = blocks shadow rays whatever the settings
568
+ export const TRI_BLOCKER_ALPHA_SHIFT = 27; // 1 = blocks them unless alpha-cutout shadows are on
569
+
570
+ /**
571
+ * How a shadow ray settles on this material without fetching it, mirroring traceShadowRay:
572
+ * bit 0 set = always a blocker; bit 1 set = a blocker while alpha-cutout shadows are off
573
+ * (MASK/BLEND with nothing else letting light through); 0 = light may pass, so the shadow
574
+ * traversal has to find the nearest such surface.
575
+ */
576
+ export function shadowBlockerBits( material ) {
577
+
578
+ if ( ! material ) return 0;
579
+ const solid = ( material.transmission || 0 ) === 0
580
+ && ( ( material.transparent | 0 ) === 0 || ( material.opacity ?? 1 ) >= 1 );
581
+ if ( ! solid ) return 0;
582
+ return ( material.alphaMode | 0 ) === 0 ? 1 : 2;
583
+
584
+ }
585
+
586
+ /**
587
+ * Material index plus the per-triangle flags the shader reads without touching the
588
+ * material buffer: `side` for inline culling and the two shadow-blocker bits.
589
+ */
590
+ export function packTriangleFlags( materialIndex, material ) {
591
+
592
+ return ( ( materialIndex & TRI_MATERIAL_MASK )
593
+ | ( ( material?.side ?? 0 ) << TRI_SIDE_SHIFT )
594
+ | ( shadowBlockerBits( material ) << TRI_BLOCKER_SHIFT ) ) >>> 0;
595
+
596
+ }
597
+
598
+ /**
599
+ * Octahedral-encode a unit normal into one u32 (two snorm16). Worst-case error is ~0.03°,
600
+ * well under what normal maps and barycentric interpolation already contribute.
601
+ * A degenerate (zero-length) normal encodes as +Z rather than NaN.
602
+ */
603
+ export function packNormalOct( x, y, z ) {
604
+
605
+ const len = Math.sqrt( x * x + y * y + z * z );
606
+ if ( len > 0 ) {
607
+
608
+ x /= len; y /= len; z /= len;
609
+
610
+ } else {
611
+
612
+ x = 0; y = 0; z = 1;
613
+
614
+ }
615
+
616
+ const sum = Math.abs( x ) + Math.abs( y ) + Math.abs( z );
617
+ let u = x / sum, v = y / sum;
618
+ if ( z < 0 ) {
619
+
620
+ const au = u, av = v;
621
+ u = ( 1 - Math.abs( av ) ) * ( au >= 0 ? 1 : - 1 );
622
+ v = ( 1 - Math.abs( au ) ) * ( av >= 0 ? 1 : - 1 );
623
+
624
+ }
625
+
626
+ const qu = Math.round( Math.min( 1, Math.max( - 1, u ) ) * 32767 ) & 0xffff;
627
+ const qv = Math.round( Math.min( 1, Math.max( - 1, v ) ) * 32767 ) & 0xffff;
628
+ return ( ( qv << 16 ) | qu ) >>> 0;
629
+
630
+ }
631
+
632
+ /** Inverse of packNormalOct; writes into `out` (length >= 3) and returns it. */
633
+ export function unpackNormalOct( packed, out ) {
634
+
635
+ const u = ( ( packed << 16 ) >> 16 ) / 32767;
636
+ const v = ( packed >> 16 ) / 32767;
637
+ let x = u, y = v, z = 1 - Math.abs( u ) - Math.abs( v );
638
+ if ( z < 0 ) {
639
+
640
+ const ax = x, ay = y;
641
+ x = ( 1 - Math.abs( ay ) ) * ( ax >= 0 ? 1 : - 1 );
642
+ y = ( 1 - Math.abs( ax ) ) * ( ay >= 0 ? 1 : - 1 );
643
+
644
+ }
645
+
646
+ const len = Math.sqrt( x * x + y * y + z * z ) || 1;
647
+ out[ 0 ] = x / len; out[ 1 ] = y / len; out[ 2 ] = z / len;
648
+ return out;
649
+
650
+ }
651
+
555
652
  // Material data layout constants — single source of truth for material buffer offsets.
556
653
  // Shared between CPU writers (TextureCreator, MaterialDataManager) and GPU readers (Common.js getMaterial).
557
654
  export const MATERIAL_DATA_LAYOUT = {
@@ -652,11 +749,69 @@ export const MATERIAL_DATA_LAYOUT = {
652
749
  export const normalizeAttenuationDistance = d => ( Number.isFinite( d ) && d > 0 ? d : 0 );
653
750
 
654
751
  // BVH node leaf markers
752
+ /**
753
+ * Node tags, written into slot [3] of a BVH node as a raw u32 bit pattern.
754
+ *
755
+ * Node indices, triangle offsets and counts are integers living inside a Float32Array. Written as
756
+ * float *values* they round silently past 2^24 (16,777,216): in a 24M-node scene half of every
757
+ * BLAS pointer landed on a neighbouring node and that geometry vanished from the render with no
758
+ * error at all. Every one of those fields is written as a u32 bit pattern instead, exact to 2^30.
759
+ *
760
+ * Tags sit above {@link BVH_MAX_INDEX} so a single unsigned compare separates a leaf from an inner
761
+ * node's left-child index. All three are ordinary finite floats — nothing lands in the NaN range,
762
+ * which an f32 storage buffer is free to canonicalise.
763
+ */
764
+ export const BVH_MAX_INDEX = 0x40000000; // 2^30
765
+
766
+ /**
767
+ * Slot [1] of a BLAS-pointer leaf holds its placement index, which {@link BVH_MAX_INDEX} keeps
768
+ * below 2^30. Bit 30 is therefore free to say the leaf's matrix is identity: geometry no other
769
+ * placement shares is baked to world space at extraction, and a ray reaching it needs no
770
+ * transform at all. Mask the bit off before using the slot as an index.
771
+ */
772
+ export const TLAS_LEAF_IDENTITY = 0x40000000;
773
+ export const TLAS_PLACEMENT_MASK = 0x3fffffff;
774
+
655
775
  export const BVH_LEAF_MARKERS = {
656
- TRIANGLE_LEAF: - 1, // Leaf containing triangle references
657
- BLAS_POINTER_LEAF: - 2, // TLAS leaf pointing to a BLAS root node
776
+ TRIANGLE_LEAF: 0x40000000, // leaf containing triangle references
777
+ BLAS_POINTER_LEAF: 0x40000001, // TLAS leaf pointing to a BLAS root node
778
+ FRONTIER: 0x40000002, // parallel-build placeholder, overwritten during assembly
658
779
  };
659
780
 
781
+ /** A u32 view over a float buffer, for writing index fields as exact bit patterns. */
782
+ export function bvhIndexView( f32 ) {
783
+
784
+ return new Uint32Array( f32.buffer, f32.byteOffset, f32.length );
785
+
786
+ }
787
+
788
+ /**
789
+ * Refuse to build a BVH whose indices would collide with the leaf tags.
790
+ *
791
+ * Throws rather than degrades: the failure this replaces was a scene that rendered with half its
792
+ * geometry silently missing, which is far worse than a scene that refuses to load. Guarding the
793
+ * totals covers every individual write, since no index can exceed the count it indexes into.
794
+ *
795
+ * @param {number} count - node or triangle total about to be indexed
796
+ * @param {string} what - what the count is, for the message
797
+ * @throws {RangeError}
798
+ */
799
+ export function assertBVHIndexFits( count, what ) {
800
+
801
+ if ( count >= BVH_MAX_INDEX ) {
802
+
803
+ throw new RangeError(
804
+ `${what} is ${count.toLocaleString()}, at or past the BVH index limit of ` +
805
+ `${BVH_MAX_INDEX.toLocaleString()}. Node indices are stored as u32 bit patterns and the ` +
806
+ 'leaf tags occupy everything above that.'
807
+ );
808
+
809
+ }
810
+
811
+ return count;
812
+
813
+ }
814
+
660
815
  // Texture processing constants
661
816
  export const TEXTURE_CONSTANTS = {
662
817
  PIXELS_PER_MATERIAL: 30,
@@ -8,6 +8,8 @@ export const ISSUE_CODES = Object.freeze( {
8
8
  ADAPTER_SOFTWARE: 'adapter.software',
9
9
  ASSET_UNREACHABLE: 'asset.unreachable',
10
10
  ASSET_AMBIGUOUS_ENTRY: 'asset.ambiguous_entry',
11
+ ASSET_ARCHIVE_TOO_LARGE: 'asset.archive_too_large',
12
+ ASSET_ENTRY_TOO_LARGE: 'asset.entry_too_large',
11
13
  TEXTURE_BUILD_FAILED: 'texture.build_failed',
12
14
  TEXTURE_PROCESSING_FALLBACK: 'texture.processing_fallback',
13
15
  TEXTURE_LIMIT_EXCEEDED: 'texture.limit_exceeded',
@@ -16,6 +18,9 @@ export const ISSUE_CODES = Object.freeze( {
16
18
  RENDER_SIZE_DECLINED: 'render.size_declined',
17
19
  STAGE_RENDER_FAILED: 'stage.render_failed',
18
20
  RENDER_RESERVE_CAPPED: 'render.reserve_capped',
21
+ SCENE_MEMORY_BUDGET: 'scene.memory_budget',
22
+ EMISSIVE_INSTANCES_COLLAPSED: 'emissive.instances_collapsed',
23
+ REFIT_SHARED_GEOMETRY: 'refit.shared_geometry',
19
24
  } );
20
25
 
21
26
  /** Strict throws on ERROR only. */
@@ -122,6 +122,9 @@ export class PathTracerApp extends EventDispatcher {
122
122
  * rendering a plausible wrong image. See EngineIssues.js; read `app.issues` when off.
123
123
  * @param {string} [options.profile='viewer'] - Which tuning to apply where the viewer's
124
124
  * product decisions differ from the physical answer. See RENDER_PROFILES.
125
+ * @param {number} [options.maxSceneBytes] - refuse a scene whose estimated host memory is
126
+ * above this. The default refuses where the renderer process would be killed instead of
127
+ * throwing; raise it deliberately, on a fresh browser. See HostMemory.js.
125
128
  *
126
129
  * The engine dispatches `EngineEvents.FRAME` after each animate() iteration so hosts can
127
130
  * tick external instrumentation (e.g. a stats panel) without coupling the engine to it.
@@ -144,6 +147,8 @@ export class PathTracerApp extends EventDispatcher {
144
147
 
145
148
  this.canvas = canvas;
146
149
  this._autoResize = options.autoResize !== false;
150
+ // A scene budget the host may raise; read where SceneProcessor is built, well after this.
151
+ this._maxSceneBytes = options.maxSceneBytes;
147
152
  this._container = options.container || null;
148
153
  // Apply the environment authored into a model file's metadata on load. See _beginSceneMetadataEnvironment().
149
154
  this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
@@ -796,6 +801,18 @@ export class PathTracerApp extends EventDispatcher {
796
801
 
797
802
  }
798
803
 
804
+ /**
805
+ * Lists the independently loadable parts of an archive without unpacking it, so a host
806
+ * can offer a choice for a scene too large to load whole.
807
+ * @param {File} file
808
+ * @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
809
+ */
810
+ async inspectArchive( file ) {
811
+
812
+ return await this.assetLoader.inspectArchive( file );
813
+
814
+ }
815
+
799
816
  /**
800
817
  * Loads a user-supplied File (drag-drop, file picker) — model, archive, or environment
801
818
  * map, dispatched by extension.
@@ -808,9 +825,11 @@ export class PathTracerApp extends EventDispatcher {
808
825
  * LOAD_IN_PROGRESS before anything is touched.
809
826
  *
810
827
  * @param {File} file
828
+ * @param {object} [options] - forwarded to the archive loader: `element` to load one
829
+ * subtree of a multi-part scene, `pbrtEntry` to choose among several .pbrt scenes.
811
830
  * @returns {Promise<void>}
812
831
  */
813
- async loadFile( file ) {
832
+ async loadFile( file, options = {} ) {
814
833
 
815
834
  const format = this.assetLoader?.getFileFormat( file?.name || '' );
816
835
  if ( ! format ) throw new Error( `Unsupported file format: ${file?.name}` );
@@ -818,7 +837,7 @@ export class PathTracerApp extends EventDispatcher {
818
837
  if ( format.type !== 'environment' && format.type !== 'image' ) {
819
838
 
820
839
  await this._loadWithSceneRebuild(
821
- () => this.assetLoader.loadAssetFromFile( file ),
840
+ () => this.assetLoader.loadAssetFromFile( file, options ),
822
841
  { type: 'ModelLoaded', filename: file.name }
823
842
  );
824
843
  return;
@@ -904,7 +923,7 @@ export class PathTracerApp extends EventDispatcher {
904
923
  }
905
924
 
906
925
  // Reprocess the loaded scene so the new cap takes effect immediately.
907
- if ( reprocess && this._maxTextureSize !== prev && this._sdf?.triangleData && ! this._loadingInProgress ) {
926
+ if ( reprocess && this._maxTextureSize !== prev && this._sdf?.triangles && ! this._loadingInProgress ) {
908
927
 
909
928
  this._loadingInProgress = true;
910
929
  try {
@@ -948,6 +967,14 @@ export class PathTracerApp extends EventDispatcher {
948
967
  this.dispatchEvent( eventPayload );
949
968
  this._dispatchCamerasUpdated();
950
969
 
970
+ } catch ( error ) {
971
+
972
+ // loadFn released the previous model before this one was known to be loadable, so
973
+ // there is nothing to fall back to. Leaving it half-built would keep the last
974
+ // frame's buffers on screen under a scene that no longer exists.
975
+ if ( ! error || error.code !== 'LOAD_IN_PROGRESS' ) this._discardFailedLoad();
976
+ throw error;
977
+
951
978
  } finally {
952
979
 
953
980
  this._loadingInProgress = false;
@@ -956,6 +983,27 @@ export class PathTracerApp extends EventDispatcher {
956
983
 
957
984
  }
958
985
 
986
+ /**
987
+ * Put the engine back to an empty scene after a load failed part-way. The failure itself is
988
+ * rethrown for the host to report; this only makes sure what is on screen matches it.
989
+ * @private
990
+ */
991
+ _discardFailedLoad() {
992
+
993
+ try {
994
+
995
+ this._clearAppendedModels();
996
+ this.assetLoader?.releaseTargetModel();
997
+ this.reset();
998
+
999
+ } catch ( cleanupError ) {
1000
+
1001
+ console.warn( 'PathTracerApp: could not clear the scene after a failed load', cleanupError );
1002
+
1003
+ }
1004
+
1005
+ }
1006
+
959
1007
  /**
960
1008
  * Scene-level authoring metadata carried by the current model file (glTF `extras`),
961
1009
  * or null when the file has none. See {@link module:Processor/SceneMetadata}.
@@ -1198,7 +1246,7 @@ export class PathTracerApp extends EventDispatcher {
1198
1246
  _sceneSummaryParts() {
1199
1247
 
1200
1248
  const pt = this.stages.pathTracer;
1201
- const meshes = this._sdf?.instanceTable?.entries?.filter( Boolean ).length ?? 0;
1249
+ const meshes = this._sdf?.instanceTable?.setCount ?? 0;
1202
1250
  const maps = this._sdf?.geometryExtractor?.maps?.length ?? 0;
1203
1251
 
1204
1252
  return [
@@ -1497,16 +1545,22 @@ export class PathTracerApp extends EventDispatcher {
1497
1545
  * Topology must stay the same (same triangle count and connectivity).
1498
1546
  * Call this per-frame for skeletal/morph-target animation.
1499
1547
  *
1500
- * @param {Float32Array} newPositions - 9 floats per triangle (ax,ay,az, bx,by,bz, cx,cy,cz) for every triangle in the scene, meshes in {@link sceneMeshes} order and triangles in index order
1501
- * @param {Float32Array} [newNormals] - Optional 9 floats per triangle smooth normals. If omitted, face normals are computed from positions.
1548
+ * Positions come in one of two shapes. A **callback** `(meshIndex, triCount) => Float32Array`
1549
+ * is asked for one mesh at a time and may return the same scratch buffer each call — prefer it,
1550
+ * since it never holds more than one mesh. A **scene-wide Float32Array** of 9 floats per
1551
+ * triangle for every triangle (meshes in {@link sceneMeshes} order, triangles in index order)
1552
+ * still works, but is 1,030 MB at 30M triangles and will not allocate at that size.
1553
+ *
1554
+ * @param {Float32Array|function(number, number): Float32Array} newPositions - (ax,ay,az, bx,by,bz, cx,cy,cz) per triangle, world space
1555
+ * @param {Float32Array|function(number, number): Float32Array} [newNormals] - Optional smooth normals, same two shapes. If omitted, face normals are computed from positions.
1502
1556
  * @returns {Promise<{ refitTimeMs: number }>}
1503
1557
  */
1504
1558
  async refitBVH( newPositions, newNormals ) {
1505
1559
 
1506
1560
  const result = await this._sdf.refitBVH( newPositions, newNormals );
1507
1561
 
1508
- this.stages.pathTracer.updateTriangleData( this._sdf.triangleData );
1509
- this.stages.pathTracer.updateBVHData( this._sdf.bvhData );
1562
+ this.stages.pathTracer.updateTriangleData( this._sdf.triangles );
1563
+ this.stages.pathTracer.updateBVHData( this._sdf.bvh );
1510
1564
  this.reset();
1511
1565
 
1512
1566
  return result;
@@ -1518,8 +1572,9 @@ export class PathTracerApp extends EventDispatcher {
1518
1572
  * Faster than refitBVH for single-object transforms in multi-mesh scenes.
1519
1573
  *
1520
1574
  * @param {number[]} affectedMeshIndices - Mesh indices to refit
1521
- * @param {Float32Array} newPositions - 9 floats per triangle in original mesh order
1522
- * @param {Float32Array} [newNormals] - Optional smooth normals
1575
+ * @param {Float32Array|function(number, number): Float32Array} newPositions - the same two shapes
1576
+ * {@link refitBVH} takes; only the affected meshes are asked for
1577
+ * @param {Float32Array|function(number, number): Float32Array} [newNormals] - Optional smooth normals
1523
1578
  * @returns {{ refitTimeMs: number }}
1524
1579
  */
1525
1580
  refitBLASes( affectedMeshIndices, newPositions, newNormals ) {
@@ -1531,11 +1586,12 @@ export class PathTracerApp extends EventDispatcher {
1531
1586
  this.reset();
1532
1587
 
1533
1588
  // Kick off background rebuild for optimal SAH quality
1534
- this._sdf.scheduleBackgroundRebuild( affectedMeshIndices, () => {
1589
+ this._sdf.scheduleBackgroundRebuild( affectedMeshIndices, ( meshIndex ) => {
1535
1590
 
1536
- // Swap complete — upload updated buffers and restart accumulation
1537
- this.stages.pathTracer.updateTriangleData( this._sdf.triangleData );
1538
- this.stages.pathTracer.updateBVHData( this._sdf.bvhData );
1591
+ // Swap complete — upload just that mesh's triangles and nodes, plus the TLAS. A whole
1592
+ // re-upload here is gigabytes on a large scene, for one mesh's worth of change.
1593
+ const dirty = this._sdf.computeBLASDirtyRanges( [ meshIndex ] );
1594
+ this.stages.pathTracer.updateBufferRanges( dirty.triRanges, dirty.bvhRanges );
1539
1595
  this.reset();
1540
1596
 
1541
1597
  } );
@@ -1544,6 +1600,28 @@ export class PathTracerApp extends EventDispatcher {
1544
1600
 
1545
1601
  }
1546
1602
 
1603
+ /**
1604
+ * Apply new transforms for objects that moved, without touching their geometry.
1605
+ *
1606
+ * This is the right call for a gizmo drag or any other rigid move: triangles are stored in
1607
+ * each object's own space, so only the placement matrix changes. {@link refitBLASes} is for
1608
+ * geometry that actually deformed — on a rigid move it rewrites vertices needlessly, and
1609
+ * drags along any other object sharing the same geometry.
1610
+ *
1611
+ * @param {number[]} meshIndices - indices into {@link sceneMeshes}
1612
+ * @returns {{ refitTimeMs: number, placements: number }}
1613
+ */
1614
+ updateMeshTransforms( meshIndices ) {
1615
+
1616
+ const result = this._sdf.updateMeshTransforms( meshIndices );
1617
+
1618
+ this.stages.pathTracer.updateBufferRanges( [], [ this._sdf.computeTLASDirtyRange() ] );
1619
+ this.reset();
1620
+
1621
+ return result;
1622
+
1623
+ }
1624
+
1547
1625
  // ═══════════════════════════════════════════════════════════════
1548
1626
  // Resize
1549
1627
  // ═══════════════════════════════════════════════════════════════
@@ -2503,6 +2581,25 @@ export class PathTracerApp extends EventDispatcher {
2503
2581
 
2504
2582
  }
2505
2583
 
2584
+ /**
2585
+ * CPU-side memory for the last scene build: what the preflight predicted, what each phase
2586
+ * allocated, and what was live at each phase boundary.
2587
+ *
2588
+ * Note that `performance.memory.usedJSHeapSize` does NOT count SharedArrayBuffer, and the
2589
+ * triangle and BVH stores are SAB-backed — so the browser's own heap reading under-reports
2590
+ * a large scene by several gigabytes and this is the figure to trust.
2591
+ *
2592
+ * @returns {?{preflight: ?Object, allocatedBytes: number, peakLiveBytes: number,
2593
+ * byPhase: Object, samples: Object[]}} null before a scene is built
2594
+ */
2595
+ getHostMemoryInfo() {
2596
+
2597
+ const sp = this._sdf;
2598
+ if ( ! sp?.memory ) return null;
2599
+ return { preflight: sp.memoryPreflight, ...sp.memory.report };
2600
+
2601
+ }
2602
+
2506
2603
  // Idempotent: registers the cross-stage texture provider and re-measures on
2507
2604
  // allocation events (scene/env load, resize) so peak is caught even while idle.
2508
2605
  _ensureVRAMWiring() {
@@ -2631,13 +2728,13 @@ export class PathTracerApp extends EventDispatcher {
2631
2728
  */
2632
2729
  _refreshEmissiveForVisibility() {
2633
2730
 
2634
- const entries = this._sdf?.instanceTable?.entries;
2635
- if ( ! entries ) return;
2731
+ const table = this._sdf?.instanceTable;
2732
+ if ( ! table ) return;
2636
2733
 
2637
2734
  const hidden = new Set();
2638
- for ( const entry of entries ) {
2735
+ for ( let i = 0; i < table.count; i ++ ) {
2639
2736
 
2640
- if ( entry && entry.visible === false ) hidden.add( entry.meshIndex );
2737
+ if ( table.isSet[ i ] && ! table.visible[ i ] ) hidden.add( i );
2641
2738
 
2642
2739
  }
2643
2740
 
@@ -2856,7 +2953,11 @@ export class PathTracerApp extends EventDispatcher {
2856
2953
 
2857
2954
  _initAssetPipeline() {
2858
2955
 
2859
- this._sdf = new SceneProcessor( { issues: this._issues } );
2956
+ this._sdf = new SceneProcessor( {
2957
+ issues: this._issues,
2958
+ // Spread into defaults, so only pass it when the host actually set one.
2959
+ ...( this._maxSceneBytes === undefined ? {} : { maxSceneBytes: this._maxSceneBytes } ),
2960
+ } );
2860
2961
  this.assetLoader = new AssetLoader(
2861
2962
  this.meshScene, this.cameraManager.camera, this.cameraManager.controls,
2862
2963
  { issues: this._issues, profile: this._profile }