@babylonjs/loaders 9.26.0 → 9.26.2

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 (129) hide show
  1. package/FBX/fbxConstraintBehavior.d.ts +188 -0
  2. package/FBX/fbxConstraintBehavior.js +532 -0
  3. package/FBX/fbxConstraintBehavior.js.map +1 -0
  4. package/FBX/fbxFileLoader.pure.d.ts +175 -6
  5. package/FBX/fbxFileLoader.pure.js +1521 -286
  6. package/FBX/fbxFileLoader.pure.js.map +1 -1
  7. package/FBX/index.d.ts +4 -1
  8. package/FBX/index.js +1 -0
  9. package/FBX/index.js.map +1 -1
  10. package/FBX/interpreter/animation.d.ts +73 -33
  11. package/FBX/interpreter/animation.js +620 -223
  12. package/FBX/interpreter/animation.js.map +1 -1
  13. package/FBX/interpreter/animationCurve.d.ts +91 -0
  14. package/FBX/interpreter/animationCurve.js +623 -0
  15. package/FBX/interpreter/animationCurve.js.map +1 -0
  16. package/FBX/interpreter/blendShapes.js +9 -18
  17. package/FBX/interpreter/blendShapes.js.map +1 -1
  18. package/FBX/interpreter/connections.d.ts +1 -1
  19. package/FBX/interpreter/connections.js +173 -34
  20. package/FBX/interpreter/connections.js.map +1 -1
  21. package/FBX/interpreter/constraints.d.ts +68 -0
  22. package/FBX/interpreter/constraints.js +143 -0
  23. package/FBX/interpreter/constraints.js.map +1 -0
  24. package/FBX/interpreter/fbxInterpreter.d.ts +89 -4
  25. package/FBX/interpreter/fbxInterpreter.js +474 -122
  26. package/FBX/interpreter/fbxInterpreter.js.map +1 -1
  27. package/FBX/interpreter/geometry.d.ts +1 -1
  28. package/FBX/interpreter/geometry.js +150 -92
  29. package/FBX/interpreter/geometry.js.map +1 -1
  30. package/FBX/interpreter/legacyDocument.d.ts +11 -0
  31. package/FBX/interpreter/legacyDocument.js +425 -0
  32. package/FBX/interpreter/legacyDocument.js.map +1 -0
  33. package/FBX/interpreter/materialModel.d.ts +71 -0
  34. package/FBX/interpreter/materialModel.js +622 -0
  35. package/FBX/interpreter/materialModel.js.map +1 -0
  36. package/FBX/interpreter/materials.d.ts +13 -1
  37. package/FBX/interpreter/materials.js +88 -5
  38. package/FBX/interpreter/materials.js.map +1 -1
  39. package/FBX/interpreter/nodeTransform.d.ts +32 -0
  40. package/FBX/interpreter/nodeTransform.js +54 -0
  41. package/FBX/interpreter/nodeTransform.js.map +1 -0
  42. package/FBX/interpreter/nurbs.d.ts +99 -0
  43. package/FBX/interpreter/nurbs.js +627 -0
  44. package/FBX/interpreter/nurbs.js.map +1 -0
  45. package/FBX/interpreter/propertyTemplates.d.ts +23 -0
  46. package/FBX/interpreter/propertyTemplates.js +109 -2
  47. package/FBX/interpreter/propertyTemplates.js.map +1 -1
  48. package/FBX/interpreter/rig.js +16 -0
  49. package/FBX/interpreter/rig.js.map +1 -1
  50. package/FBX/interpreter/sceneDiagnostics.js +28 -3
  51. package/FBX/interpreter/sceneDiagnostics.js.map +1 -1
  52. package/FBX/interpreter/skeleton.d.ts +4 -18
  53. package/FBX/interpreter/skeleton.js +16 -94
  54. package/FBX/interpreter/skeleton.js.map +1 -1
  55. package/FBX/parsers/fbxAsciiParser.js +119 -9
  56. package/FBX/parsers/fbxAsciiParser.js.map +1 -1
  57. package/FBX/parsers/fbxBinaryParser.js +74 -22
  58. package/FBX/parsers/fbxBinaryParser.js.map +1 -1
  59. package/FBX/pure.d.ts +1 -0
  60. package/FBX/pure.js +1 -0
  61. package/FBX/pure.js.map +1 -1
  62. package/FBX/types/fbxTypes.d.ts +18 -2
  63. package/FBX/types/fbxTypes.js +40 -12
  64. package/FBX/types/fbxTypes.js.map +1 -1
  65. package/SPLAT/gaussianSplattingStream.d.ts +79 -25
  66. package/SPLAT/gaussianSplattingStream.js +320 -95
  67. package/SPLAT/gaussianSplattingStream.js.map +1 -1
  68. package/USD/usdCommandProtocol.d.ts +20 -4
  69. package/USD/usdCommandProtocol.js +25 -3
  70. package/USD/usdCommandProtocol.js.map +1 -1
  71. package/USD/usdFileLoader.pure.js +2 -0
  72. package/USD/usdFileLoader.pure.js.map +1 -1
  73. package/USD/usdSceneMaterializer.js +430 -92
  74. package/USD/usdSceneMaterializer.js.map +1 -1
  75. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.d.ts +110 -3
  76. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.js +468 -74
  77. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.js.map +1 -1
  78. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphEventReferenceBlock.d.ts +47 -0
  79. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphEventReferenceBlock.js +68 -0
  80. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphEventReferenceBlock.js.map +1 -0
  81. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphGLTFDataProvider.d.ts +2 -2
  82. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphGLTFDataProvider.js +1 -1
  83. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphGLTFDataProvider.js.map +1 -1
  84. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphObjectReferenceBlock.d.ts +24 -0
  85. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphObjectReferenceBlock.js +38 -0
  86. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphObjectReferenceBlock.js.map +1 -0
  87. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphUnsupportedInteractivityBlock.d.ts +41 -0
  88. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphUnsupportedInteractivityBlock.js +63 -0
  89. package/glTF/2.0/Extensions/KHR_interactivity/flowGraphUnsupportedInteractivityBlock.js.map +1 -0
  90. package/glTF/2.0/Extensions/KHR_interactivity/index.d.ts +5 -0
  91. package/glTF/2.0/Extensions/KHR_interactivity/index.js +5 -0
  92. package/glTF/2.0/Extensions/KHR_interactivity/index.js.map +1 -1
  93. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphModel.d.ts +112 -0
  94. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphModel.js +773 -0
  95. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphModel.js.map +1 -0
  96. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.d.ts +18 -10
  97. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.js +299 -54
  98. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.js.map +1 -1
  99. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.d.ts +2 -1
  100. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.js +7 -2
  101. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.js.map +1 -1
  102. package/glTF/2.0/Extensions/KHR_interactivity/interactivityNodeState.d.ts +24 -0
  103. package/glTF/2.0/Extensions/KHR_interactivity/interactivityNodeState.js +89 -0
  104. package/glTF/2.0/Extensions/KHR_interactivity/interactivityNodeState.js.map +1 -0
  105. package/glTF/2.0/Extensions/KHR_interactivity/pure.d.ts +5 -0
  106. package/glTF/2.0/Extensions/KHR_interactivity/pure.js +5 -0
  107. package/glTF/2.0/Extensions/KHR_interactivity/pure.js.map +1 -1
  108. package/glTF/2.0/Extensions/KHR_interactivity.pure.d.ts +55 -1
  109. package/glTF/2.0/Extensions/KHR_interactivity.pure.js +124 -76
  110. package/glTF/2.0/Extensions/KHR_interactivity.pure.js.map +1 -1
  111. package/glTF/2.0/Extensions/KHR_interactivity.types.d.ts +18 -1
  112. package/glTF/2.0/Extensions/KHR_interactivity.types.js.map +1 -1
  113. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.d.ts +2 -0
  114. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js +114 -56
  115. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js.map +1 -1
  116. package/glTF/2.0/Extensions/KHR_node_selectability.pure.d.ts +2 -0
  117. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js +80 -35
  118. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js.map +1 -1
  119. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.js +3 -2
  120. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.js.map +1 -1
  121. package/glTF/2.0/Extensions/objectModelMapping.d.ts +4 -2
  122. package/glTF/2.0/Extensions/objectModelMapping.js +33 -4
  123. package/glTF/2.0/Extensions/objectModelMapping.js.map +1 -1
  124. package/glTF/2.0/glTFLoader.pure.d.ts +1 -1
  125. package/glTF/2.0/glTFLoader.pure.js +9 -4
  126. package/glTF/2.0/glTFLoader.pure.js.map +1 -1
  127. package/glTF/2.0/glTFLoaderExtension.d.ts +2 -1
  128. package/glTF/2.0/glTFLoaderExtension.js.map +1 -1
  129. package/package.json +3 -3
@@ -21,6 +21,11 @@ const EnvironmentFileId = -1;
21
21
  // Core bytes per resident splat: the four work-buffer textures cost 16+16+16+4 = 52 bytes on the GPU, plus ~32
22
22
  // bytes of CPU position/sort data. `_resolveResidentBudget` adds the SH and rotation/scale texture cost on top.
23
23
  const BytesPerResidentSplat = 84;
24
+ const MaxSupportedShDegree = 4;
25
+ const MaxSupportedShTextureCount = 5;
26
+ const ProgressiveMetadataFileThreshold = 32;
27
+ const DesktopAutomaticResidentMemoryMb = 512;
28
+ const MobileAutomaticResidentMemoryMb = 256;
24
29
  // Scratch objects reused by the per-frame optimal-LOD evaluation (avoids per-call allocations).
25
30
  const TmpInvWorld = new Matrix();
26
31
  const TmpLocalCamera = new Vector3();
@@ -94,6 +99,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
94
99
  * @param options streaming options
95
100
  */
96
101
  constructor(name, metadata, rootUrl, scene, options = {}) {
102
+ // Validate before super registers the mesh with the scene, so invalid options leave no partial entity.
103
+ const maxResidentSplats = GaussianSplattingStream._NormalizeResidentLimit(options.maxResidentSplats ?? 0, "maxResidentSplats", true);
104
+ const memoryBudgetMb = GaussianSplattingStream._NormalizeResidentLimit(options.memoryBudgetMb ?? 0, "memoryBudgetMb", false);
97
105
  super(name, null, scene, false);
98
106
  // Flat list of leaf nodes that carry renderable LOD entries (used by the LOD heuristic and debug).
99
107
  this._leafNodes = [];
@@ -118,7 +126,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
118
126
  this._hostBudgetAllocation = null;
119
127
  // Frustum LOD bias: when enabled, nodes outside the camera frustum are rendered at their coarsest LOD.
120
128
  this._frustumCulling = true;
121
- // Reused world-space frustum planes and view-projection scratch matrix (avoids per-frame allocation).
129
+ // Reused local-space frustum planes and local-to-clip scratch matrix (avoids per-frame allocation).
122
130
  this._frustumPlanes = [
123
131
  new Plane(0, 0, 0, 0),
124
132
  new Plane(0, 0, 0, 0),
@@ -127,6 +135,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
127
135
  new Plane(0, 0, 0, 0),
128
136
  new Plane(0, 0, 0, 0),
129
137
  ];
138
+ this._cullCameraViewProj = new Matrix();
130
139
  this._cullViewProj = new Matrix();
131
140
  // Reused per-leaf "inside any active camera's frustum" accumulator for the union frustum test (avoids per-frame allocation).
132
141
  this._frustumScratch = [];
@@ -150,6 +159,8 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
150
159
  this._fileCounts = new Map();
151
160
  // Cached SOG metadata per file so on-demand decodes don't refetch the meta.json.
152
161
  this._fileMeta = new Map();
162
+ // Unique source files required by the coarsest entry of at least one leaf.
163
+ this._baseFileIds = new Set();
153
164
  // Files whose splats have been fully GPU-decoded into the work buffer (render-safe).
154
165
  this._decodedFiles = new Set();
155
166
  // Files whose decode is currently in flight (dedupes concurrent requests).
@@ -161,9 +172,11 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
161
172
  this._fileRefs = new Map();
162
173
  // Files whose in-flight decode was cancelled; checked at decode checkpoints to bail out cooperatively.
163
174
  this._cancelledDecodes = new Set();
164
- // Eviction streaming config: enabled only when a budget smaller than the full dataset is configured.
175
+ // Eviction is enabled for progressive metadata or capacity below the complete source size.
165
176
  this._evictionEnabled = false;
166
177
  this._residentBudget = 0;
178
+ // Padding + environment + unique whole coarse source files. Zero until coarse metadata has resolved.
179
+ this._minimumResidentSplats = 0;
167
180
  // Raw budget options; the final `_residentBudget` is resolved from these once the SH/rotation byte cost is known
168
181
  // (after the metadata pre-pass), so the memory budget accounts for the extra baked SH and rotation textures.
169
182
  this._maxResidentSplats = 0;
@@ -182,6 +195,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
182
195
  // Per-frame LOD streaming loop; installed once the base layer is ready.
183
196
  this._lodObserver = null;
184
197
  this._baseLayerReady = false;
198
+ this._metadataReady = false;
185
199
  // Throttling state for the per-frame LOD loop: each active camera's world position at the last LOD evaluation, so
186
200
  // a re-eval is gated on the camera set changing or any camera translating past `_lodUpdateDistance`.
187
201
  this._framesSinceLodUpdate = 0;
@@ -281,14 +295,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
281
295
  if (options.evictionCooldownFrames !== undefined) {
282
296
  this._evictionCooldownFrames = Math.max(0, Math.floor(options.evictionCooldownFrames));
283
297
  }
284
- // Capture the raw budget options; `_residentBudget` is resolved in _streamAllAsync once the SH/rotation
285
- // per-splat cost is known (a memory budget must count the extra baked SH and rotation textures, not just core).
286
- if (options.maxResidentSplats !== undefined && options.maxResidentSplats > 0) {
287
- this._maxResidentSplats = Math.floor(options.maxResidentSplats);
288
- }
289
- if (options.memoryBudgetMb !== undefined && options.memoryBudgetMb > 0) {
290
- this._memoryBudgetMb = options.memoryBudgetMb;
291
- }
298
+ // Resolve the capacity after coarse metadata and the fixed SH/rotation layout are known.
299
+ this._maxResidentSplats = maxResidentSplats;
300
+ this._memoryBudgetMb = memoryBudgetMb;
292
301
  this._downloadManager = new GaussianSplattingDownloadManager({
293
302
  maxConcurrent: options.maxConcurrentDownloads,
294
303
  maxRetries: options.maxDownloadRetries,
@@ -335,7 +344,8 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
335
344
  // deferred: _streamAllAsync resolves it once the base layer has decoded. If it finishes WITHOUT the part ever
336
345
  // becoming ready (empty stream) or throws, dispose the controller so a hosted stream doesn't leave its work
337
346
  // buffer and reserved region allocated — the synchronous AddGaussianSplattingStreamPart never awaits, so it
338
- // can't clean up itself. `_partReadySettled` distinguishes a genuine success (leave it running) from a
347
+ // can't clean up itself. Standalone startup failures also dispose their partial resources.
348
+ // `_partReadySettled` distinguishes a genuine success (leave it running) from a
339
349
  // finished-but-never-ready result (dispose).
340
350
  // eslint-disable-next-line github/no-then
341
351
  void this._streamAllAsync().then(() => {
@@ -348,7 +358,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
348
358
  this._lod0SplatCount = Object.freeze({ status: "unavailable" });
349
359
  Logger.Error("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
350
360
  this._rejectPartReady("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
351
- if (this._hostCompound && !this._disposed) {
361
+ if (!this._disposed) {
352
362
  this._disposeAndReclaim();
353
363
  }
354
364
  });
@@ -486,7 +496,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
486
496
  * @returns true when no loading work remains
487
497
  */
488
498
  _isLoadingIdle() {
489
- return this._baseLayerReady && this._decodeQueue.length === 0 && this._loadingFiles.size === 0 && this._downloadManager.isIdle;
499
+ return this._baseLayerReady && this._metadataReady && this._decodeQueue.length === 0 && this._loadingFiles.size === 0 && this._downloadManager.isIdle;
490
500
  }
491
501
  /**
492
502
  * Finest (most detailed) LOD level any node is allowed to render. `0` allows full detail (level 0);
@@ -538,12 +548,23 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
538
548
  /**
539
549
  * The resolved maximum number of splats kept resident in the work buffer. This combines
540
550
  * {@link IGaussianSplattingStreamOptions.maxResidentSplats} and {@link IGaussianSplattingStreamOptions.memoryBudgetMb},
541
- * taking the smaller limit when both are configured. `0` means the resident budget is disabled.
551
+ * taking the smaller positive limit and raising it to {@link minimumResidentSplats}. With neither limit set,
552
+ * the complete source size is retained when known; otherwise a bounded device-tiered default is used.
553
+ * The fixed capacity is capped at the device texture limit. `0` means initial capacity has not been resolved.
542
554
  * @experimental
543
555
  */
544
556
  get residentSplatBudget() {
545
557
  return this._residentBudget;
546
558
  }
559
+ /**
560
+ * Minimum fixed work-buffer capacity required for the complete coarse representation: one invisible padding
561
+ * splat, the included environment, and every unique whole coarse source file. It is `0` until the required
562
+ * coarse metadata has resolved. Initial residency options below this value are raised to it.
563
+ * @experimental
564
+ */
565
+ get minimumResidentSplats() {
566
+ return this._minimumResidentSplats;
567
+ }
547
568
  /**
548
569
  * The total number of splats represented by valid level-0 leaf entries. This remains pending while source
549
570
  * metadata is loading and is unavailable when no applicable level-0 entries exist or required metadata fails.
@@ -748,8 +769,8 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
748
769
  super.dispose(doNotRecurse);
749
770
  }
750
771
  /**
751
- * Disposes this stream (which tombstones its region) and then compacts the host once to actually reclaim the
752
- * reserved rows. Used on a definitive load failure / empty result — a discrete, one-off reclaim, versus a bare
772
+ * Disposes this stream and, when hosted, compacts the host once to reclaim its tombstoned region's reserved
773
+ * rows. Used on a definitive load failure / empty result — a discrete, one-off reclaim, versus a bare
753
774
  * {@link dispose} that only tombstones so tearing down several parts doesn't rebuild the atlas repeatedly.
754
775
  */
755
776
  _disposeAndReclaim() {
@@ -762,7 +783,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
762
783
  }
763
784
  /**
764
785
  * The world matrix that actually places this stream's splats, used to map the camera into the space the
765
- * node bounds live in (for LOD distance) and to build per-node world AABBs (for frustum culling). Standalone:
786
+ * node bounds live in (for LOD distance) and to build camera-local frusta (for frustum culling). Standalone:
766
787
  * this controller mesh carries the transform. Hosted: this controller is a hidden, unplaced node — the splats
767
788
  * are placed by the reserved part's proxy (SOG up-axis basis composed with the host's placement), so LOD and
768
789
  * culling MUST use the proxy's world matrix or they compute distances/frustum tests in the wrong space
@@ -1118,51 +1139,80 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1118
1139
  node.activeLod = undefined;
1119
1140
  node.lodCooldown = 0;
1120
1141
  node.inFrustum = true;
1121
- // Local-space bounds for the per-node frustum test; the mesh world matrix is applied per evaluation.
1142
+ // Static local bounds; each camera's frustum is transformed into this space for evaluation.
1122
1143
  node.cullBounds = new BoundingInfo(Vector3.FromArray(bmin), Vector3.FromArray(bmax));
1123
1144
  this._leafNodes.push(node);
1124
1145
  }
1125
1146
  /**
1126
- * Streams the scene: learns every source file's splat count, allocates one unified GPU work buffer
1127
- * sized for all LOD files, decodes the environment and the coarsest LOD of every node as a permanent
1128
- * base layer, then installs the per-frame loop that streams finer LODs on demand.
1147
+ * Streams the scene. Large streams fetch only required coarse metadata before allocating and decoding the
1148
+ * complete coarse layer; finer metadata starts afterwards so it cannot occupy the download queue ahead of
1149
+ * visible geometry. Small streams and streams containing only coarse files retain an exact all-file capacity upper bound.
1129
1150
  */
1130
1151
  async _streamAllAsync() {
1131
- // Step 1: learn splat counts for the environment and every referenced LOD file (cheap meta only). This also
1132
- // resolves the max SH degree, so the resident-splat budget can now be sized with the SH/rotation byte cost.
1133
1152
  const fileIds = this._collectAllFileIds();
1134
- const envCount = await this._gatherCountsAsync(fileIds);
1135
- this._resolveLod0SplatCount();
1153
+ const baseFileIds = this._collectBaseFileIds();
1154
+ this._baseFileIds.clear();
1155
+ for (const fileId of baseFileIds) {
1156
+ this._baseFileIds.add(fileId);
1157
+ }
1158
+ if (this._leafNodes.length > 0 && baseFileIds.length === 0) {
1159
+ throw new Error("GaussianSplattingStream: no valid coarse source files were referenced.");
1160
+ }
1161
+ const progressiveMetadata = fileIds.length > ProgressiveMetadataFileThreshold && baseFileIds.length < fileIds.length;
1162
+ if (progressiveMetadata && this._decodeSh) {
1163
+ // Finer metadata is deliberately unknown during allocation. Reserve the renderer's complete supported
1164
+ // degree-4 layout so a later file can never require a fixed-atlas resize or silently lose its SH.
1165
+ this._streamShDegree = MaxSupportedShDegree;
1166
+ this._shTextureCount = MaxSupportedShTextureCount;
1167
+ }
1168
+ // The environment keeps its existing initial ordering. On large streams only coarse source metadata follows
1169
+ // it, leaving the FIFO download manager free for coarse textures before the hundreds of finer metadata files.
1170
+ const initialFileIds = progressiveMetadata ? baseFileIds : fileIds;
1171
+ const envCount = await this._gatherCountsAsync(initialFileIds);
1136
1172
  if (this._disposed) {
1137
1173
  return;
1138
1174
  }
1139
- this._resolveResidentBudget();
1140
- // Step 2: learn the full dataset size (padding + environment + every LOD file). The work buffer is
1141
- // sized to this unless a smaller budget enables eviction-based streaming.
1175
+ for (const fileId of baseFileIds) {
1176
+ const count = this._fileCounts.get(fileId);
1177
+ if (!this._fileMeta.has(fileId) || count === undefined || count <= 0) {
1178
+ throw new Error(`GaussianSplattingStream: required coarse metadata for file ${fileId} is unavailable or invalid.`);
1179
+ }
1180
+ }
1181
+ this._resolveMinimumResidentSplats(baseFileIds, envCount);
1182
+ if (!progressiveMetadata) {
1183
+ this._resolveLod0SplatCount();
1184
+ this._metadataReady = true;
1185
+ }
1142
1186
  // Index 0 is reserved as a never-decoded padding splat: the sort worker and index buffer pad unused
1143
1187
  // slots with index 0, and leaving that slot zeroed (center.w = 0 => zero covariance, alpha 0) makes
1144
1188
  // the padding invisible instead of ghosting a copy of the first real splat.
1145
- let fullCapacity = 1;
1146
- if (envCount > 0) {
1147
- fullCapacity += envCount;
1148
- }
1149
- for (const fileId of fileIds) {
1150
- const count = this._fileCounts.get(fileId);
1151
- if (count !== undefined && count > 0) {
1152
- fullCapacity += count;
1189
+ let fullCapacity = null;
1190
+ if (!progressiveMetadata) {
1191
+ fullCapacity = 1 + envCount;
1192
+ for (const fileId of fileIds) {
1193
+ const count = this._fileCounts.get(fileId);
1194
+ if (count !== undefined && count > 0) {
1195
+ fullCapacity = GaussianSplattingStream._SafeAddCounts(fullCapacity, count, "stream capacity");
1196
+ }
1153
1197
  }
1154
1198
  }
1155
- if (fullCapacity <= 1) {
1156
- return;
1199
+ if (this._minimumResidentSplats <= 1) {
1200
+ throw new Error("GaussianSplattingStream: stream produced no splats.");
1201
+ }
1202
+ let largestBaseFileCount = 0;
1203
+ for (const fileId of baseFileIds) {
1204
+ largestBaseFileCount = Math.max(largestBaseFileCount, this._fileCounts.get(fileId));
1157
1205
  }
1158
- // Eviction streams the dataset through a fixed budget; only enabled when that budget is below the full set.
1159
- this._evictionEnabled = this._residentBudget > 0 && this._residentBudget < fullCapacity;
1160
- const capacity = this._evictionEnabled ? Math.max(this._residentBudget, 1) : fullCapacity;
1206
+ const capacity = this._resolveResidentBudget(fullCapacity, largestBaseFileCount);
1207
+ // Progressive metadata always implies that additional source files may need to replace one another. For an
1208
+ // exact small stream, eviction is necessary only when its chosen capacity is below the full source set.
1209
+ this._evictionEnabled = progressiveMetadata || (fullCapacity !== null && capacity < fullCapacity);
1161
1210
  this._residency = new GaussianSplattingResidencyController(capacity, this._evictionCooldownFrames, (file) => this._onFileEvicted(file));
1162
- // Pin splat 0 as the invisible padding splat, then the environment (always rendered) — neither is evicted.
1211
+ // Padding is always pinned. Reserve the environment now, but pin it only after a successful decode so
1212
+ // failed environment data can release its allocation before coarse/fine files need that space.
1163
1213
  this._residency.pin(PaddingFileId, 1);
1164
1214
  if (envCount > 0) {
1165
- const envOffset = this._residency.pin(EnvironmentFileId, envCount);
1215
+ const envOffset = this._residency.allocate(EnvironmentFileId, envCount);
1166
1216
  if (envOffset !== null) {
1167
1217
  this._environmentRange = { offset: envOffset, count: envCount };
1168
1218
  }
@@ -1270,35 +1320,48 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1270
1320
  return;
1271
1321
  }
1272
1322
  }
1273
- // Step 3: decode the environment, then every node's coarsest LOD as the permanent base layer.
1323
+ // Decode the environment, then every unique coarse source. Each completed coarse file immediately
1324
+ // promotes its leaves and publishes their ranges; incomplete essential coarse data fails the stream.
1274
1325
  if (this._environmentRange && this._environmentFiles) {
1275
1326
  await this._decodeEnvironmentAsync();
1276
1327
  }
1277
1328
  this._environmentFiles = null;
1278
- const baseFiles = new Set();
1279
- for (const node of this._leafNodes) {
1280
- const entry = node.lods[String(node.baseLod)];
1281
- if (entry && this._fileCounts.has(entry.file)) {
1282
- baseFiles.add(entry.file);
1283
- }
1284
- }
1285
- for (const fileId of Array.from(baseFiles)) {
1329
+ for (const fileId of baseFileIds) {
1286
1330
  if (this._disposed) {
1287
1331
  return;
1288
1332
  }
1289
1333
  // eslint-disable-next-line no-await-in-loop
1290
- await this._decodeFileAsync(fileId);
1334
+ const decoded = await this._decodeFileAsync(fileId);
1335
+ if (!decoded) {
1336
+ throw new Error(`GaussianSplattingStream: required coarse file ${fileId} failed to decode.`);
1337
+ }
1291
1338
  }
1292
1339
  if (this._disposed) {
1293
1340
  return;
1294
1341
  }
1295
- // Step 4: hand off to the per-frame LOD streaming loop.
1342
+ for (const node of this._leafNodes) {
1343
+ if (node.activeLod !== node.baseLod || node.activeFile === undefined) {
1344
+ throw new Error("GaussianSplattingStream: the complete coarse layer could not be activated.");
1345
+ }
1346
+ }
1347
+ // Coarse data is now complete and visible. Hosted callers can place/frame the real bounds immediately,
1348
+ // while large streams fetch finer metadata in the background.
1296
1349
  this._baseLayerReady = true;
1350
+ this._resolvePartReady();
1351
+ if (progressiveMetadata) {
1352
+ const fineFileIds = fileIds.filter((fileId) => !this._baseFileIds.has(fileId));
1353
+ await this._gatherCountsAsync(fineFileIds, false);
1354
+ this._resolveLod0SplatCount();
1355
+ this._metadataReady = true;
1356
+ if (this._disposed) {
1357
+ return;
1358
+ }
1359
+ }
1360
+ // Only after complete coarse coverage and the finer metadata phase may the per-frame loop request fine
1361
+ // geometry. The fixed work buffer continues to retain every pinned coarse source as fallback.
1297
1362
  if (!this._lodObserver) {
1298
1363
  this._lodObserver = this._scene.onBeforeRenderObservable.add(() => this._onLodFrame());
1299
1364
  }
1300
- // Hosted: the reserved part now exists with a decoded base layer and real bounds — release awaiters.
1301
- this._resolvePartReady();
1302
1365
  }
1303
1366
  /**
1304
1367
  * Waits (up to a frame cap) until the work buffer's backup/restore copy shaders are compiled, so a later
@@ -1321,23 +1384,67 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1321
1384
  }
1322
1385
  }
1323
1386
  /**
1324
- * Resolves the resident-splat budget from the raw options, sizing a memory (MB) budget with the actual per-splat
1325
- * GPU+CPU cost — core data plus the baked SH textures and rotation/scale textures when enabled — so SH/rotation
1326
- * assets don't silently consume up to double the configured budget. Requires the SH degree (from the metadata
1327
- * pre-pass) to be known. The smaller of the splat-count and memory budgets wins.
1387
+ * Resolves the fixed initial work-buffer capacity. Explicit count/MB limits are combined by taking the smaller
1388
+ * and then raised to the complete coarse minimum. Without an explicit limit, a known complete source retains
1389
+ * legacy full residency. When finer metadata is deferred and the complete size is unknown, the device-tiered
1390
+ * memory estimate is given at least one largest-coarse-file of headroom so one replacement file can refine
1391
+ * while all coarse fallback files remain pinned.
1392
+ * @param fullCapacity exact complete source capacity, or null when unavailable
1393
+ * @param largestBaseFileCount largest whole coarse source file, used as modest replacement headroom
1394
+ * @returns the fixed initial work-buffer capacity
1328
1395
  */
1329
- _resolveResidentBudget() {
1330
- let budget = this._maxResidentSplats;
1396
+ _resolveResidentBudget(fullCapacity = null, largestBaseFileCount = 0) {
1397
+ const hasExplicitLimit = this._maxResidentSplats > 0 || this._memoryBudgetMb > 0;
1398
+ let budget = this._maxResidentSplats > 0 ? this._maxResidentSplats : Number.POSITIVE_INFINITY;
1399
+ const bytesPerSplat = this._bytesPerResidentSplat();
1331
1400
  if (this._memoryBudgetMb > 0) {
1332
- // Per resident splat: core 84 B, + 16 B per packed-u32 SH texture, + the 3 RGBA rotation textures. The
1333
- // work buffer uses half-float rotation textures (8 B each = 24 B) when the engine can render to them,
1334
- // else full float (16 B each = 48 B) — match that so fallback devices aren't under-budgeted.
1335
- const rotBytes = this._scene.getEngine().getCaps().textureHalfFloatRender ? 24 : 48;
1336
- const bytesPerSplat = BytesPerResidentSplat + this._shTextureCount * 16 + (this._needsRotationScale ? rotBytes : 0);
1337
1401
  const fromMB = Math.floor((this._memoryBudgetMb * 1024 * 1024) / bytesPerSplat);
1338
- budget = budget > 0 ? Math.min(budget, fromMB) : fromMB;
1402
+ budget = Math.min(budget, fromMB);
1403
+ }
1404
+ if (!hasExplicitLimit && fullCapacity !== null) {
1405
+ budget = fullCapacity;
1406
+ }
1407
+ else if (!hasExplicitLimit) {
1408
+ const automaticMb = this._scene.getEngine().hostInformation?.isMobile ? MobileAutomaticResidentMemoryMb : DesktopAutomaticResidentMemoryMb;
1409
+ const automaticCount = Math.floor((automaticMb * 1024 * 1024) / bytesPerSplat);
1410
+ const refinementHeadroom = Math.max(0, Math.floor(largestBaseFileCount));
1411
+ const coarseWithHeadroom = GaussianSplattingStream._SafeAddCounts(this._minimumResidentSplats, refinementHeadroom, "automatic resident capacity");
1412
+ budget = Math.max(automaticCount, coarseWithHeadroom);
1413
+ }
1414
+ else if (budget < this._minimumResidentSplats) {
1415
+ Logger.Warn(`GaussianSplattingStream: the requested initial residency (${budget} splats) is below the complete coarse minimum (${this._minimumResidentSplats}); using the minimum.`);
1416
+ }
1417
+ budget = Math.max(this._minimumResidentSplats, Math.floor(budget));
1418
+ if (fullCapacity !== null) {
1419
+ budget = Math.min(budget, fullCapacity);
1420
+ }
1421
+ if (fullCapacity !== null || this._minimumResidentSplats > 0) {
1422
+ const maxTextureSize = Math.floor(this._scene.getEngine().getCaps().maxTextureSize);
1423
+ const maxCapacity = maxTextureSize * maxTextureSize;
1424
+ if (!Number.isSafeInteger(maxCapacity) || this._minimumResidentSplats > maxCapacity) {
1425
+ throw new Error(`GaussianSplattingStream: minimum resident splat count ${this._minimumResidentSplats} exceeds the device texture capacity ${maxCapacity}.`);
1426
+ }
1427
+ if (budget > maxCapacity) {
1428
+ budget = maxCapacity;
1429
+ }
1430
+ if (budget < this._minimumResidentSplats) {
1431
+ throw new Error(`GaussianSplattingStream: minimum resident splat count ${this._minimumResidentSplats} exceeds the device texture capacity ${maxCapacity}.`);
1432
+ }
1433
+ }
1434
+ if (!Number.isSafeInteger(budget) || budget < this._minimumResidentSplats || budget < 1) {
1435
+ throw new Error("GaussianSplattingStream: resolved resident capacity is invalid.");
1339
1436
  }
1340
1437
  this._residentBudget = budget;
1438
+ return budget;
1439
+ }
1440
+ /**
1441
+ * Returns the estimated combined GPU/CPU bytes occupied by one resident splat.
1442
+ * @returns estimated bytes per resident splat
1443
+ */
1444
+ _bytesPerResidentSplat() {
1445
+ // The three rotation textures use half floats (8 B each) when supported, otherwise full floats (16 B each).
1446
+ const rotBytes = this._scene.getEngine().getCaps().textureHalfFloatRender ? 24 : 48;
1447
+ return BytesPerResidentSplat + this._shTextureCount * 16 + (this._needsRotationScale ? rotBytes : 0);
1341
1448
  }
1342
1449
  /**
1343
1450
  * Collects the unique set of source file indices referenced by any LOD of any leaf, sorted ascending.
@@ -1355,6 +1462,36 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1355
1462
  }
1356
1463
  return Array.from(ids).sort((a, b) => a - b);
1357
1464
  }
1465
+ /**
1466
+ * Collects unique whole source files required by the coarsest entry of every valid leaf.
1467
+ * @returns sorted unique coarse file indices
1468
+ */
1469
+ _collectBaseFileIds() {
1470
+ const ids = new Set();
1471
+ for (const node of this._leafNodes) {
1472
+ const entry = node.lods?.[String(node.baseLod)];
1473
+ if (entry) {
1474
+ ids.add(entry.file);
1475
+ }
1476
+ }
1477
+ return Array.from(ids).sort((a, b) => a - b);
1478
+ }
1479
+ /**
1480
+ * Resolves the immutable minimum initial capacity from padding, environment, and unique whole coarse files.
1481
+ * @param baseFileIds unique coarse source file indices
1482
+ * @param environmentCount included environment splat count
1483
+ */
1484
+ _resolveMinimumResidentSplats(baseFileIds, environmentCount) {
1485
+ let minimum = GaussianSplattingStream._SafeAddCounts(1, Math.max(0, Math.floor(environmentCount)), "minimum resident splats");
1486
+ for (const fileId of baseFileIds) {
1487
+ const count = this._fileCounts.get(fileId);
1488
+ if (count === undefined || !Number.isSafeInteger(count) || count <= 0) {
1489
+ throw new Error(`GaussianSplattingStream: required coarse metadata for file ${fileId} is unavailable or invalid.`);
1490
+ }
1491
+ minimum = GaussianSplattingStream._SafeAddCounts(minimum, count, "minimum resident splats");
1492
+ }
1493
+ this._minimumResidentSplats = minimum;
1494
+ }
1358
1495
  /**
1359
1496
  * Settles the level-0 diagnostic from normalized renderable leaf entries after their source metadata resolves.
1360
1497
  * A file may back several leaf ranges, so its source count is deliberately not used in the total.
@@ -1385,12 +1522,13 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1385
1522
  this._lod0SplatCount = hasLod0Entry ? Object.freeze({ status: "available", count: total }) : Object.freeze({ status: "unavailable" });
1386
1523
  }
1387
1524
  /**
1388
- * Fetches the environment bundle and every referenced file's metadata to learn splat counts, caching
1389
- * each file's parsed metadata for the later on-demand decode. Metadata fetches run in parallel.
1525
+ * Fetches the environment bundle and the supplied referenced files' metadata to learn splat counts, caching
1526
+ * each file's parsed metadata for the later on-demand decode. File metadata fetches run in parallel.
1390
1527
  * @param fileIds file indices to fetch metadata for
1528
+ * @param includeEnvironment whether to fetch the optional environment bundle in this phase
1391
1529
  * @returns the environment splat count (0 when there is no environment)
1392
1530
  */
1393
- async _gatherCountsAsync(fileIds) {
1531
+ async _gatherCountsAsync(fileIds, includeEnvironment = true) {
1394
1532
  let envCount = 0;
1395
1533
  // Track the max SH degree/coeffs across every streamed file (+ environment): the baked SH atlas is sized
1396
1534
  // for the max once, up front, so no mid-stream resize — lower-degree files neutral-fill their higher bands.
@@ -1405,7 +1543,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1405
1543
  maxCoeffs = info.coeffs;
1406
1544
  }
1407
1545
  };
1408
- if (this._metadata.environment) {
1546
+ if (includeEnvironment && this._metadata.environment) {
1409
1547
  try {
1410
1548
  const url = this._rootUrl + this._metadata.environment;
1411
1549
  const buffer = await this._downloadManager.loadFileAsync(url);
@@ -1447,9 +1585,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1447
1585
  }
1448
1586
  // Resolve the stream's baked-SH configuration: enabled only when requested AND the data carries shN.
1449
1587
  if (this._decodeSh && maxShDegree > 0 && maxCoeffs > 0) {
1450
- this._streamShDegree = maxShDegree;
1588
+ this._streamShDegree = Math.max(this._streamShDegree, maxShDegree);
1451
1589
  // Packed-u32 SH textures: 16 SH scalar-bytes per texel, 3 channels per coefficient (matches ParseSogDatas).
1452
- this._shTextureCount = Math.ceil((maxCoeffs * 3) / 16);
1590
+ this._shTextureCount = Math.max(this._shTextureCount, Math.ceil((maxCoeffs * 3) / 16));
1453
1591
  }
1454
1592
  return envCount;
1455
1593
  }
@@ -1620,10 +1758,12 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1620
1758
  return;
1621
1759
  }
1622
1760
  const range = this._environmentRange;
1761
+ let decoded = false;
1623
1762
  try {
1624
1763
  const parsed = await ParseSogMetaAsTextures(this._environmentFiles, "", this._scene, !this._useGpuPositionReadback, this._downloadManager);
1625
1764
  const pack = parsed.sogTextures;
1626
1765
  if (!pack) {
1766
+ Logger.Warn("GaussianSplattingStream: environment decoding produced no texture data.");
1627
1767
  return;
1628
1768
  }
1629
1769
  try {
@@ -1634,10 +1774,18 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1634
1774
  if (this._disposed) {
1635
1775
  return;
1636
1776
  }
1637
- await this._applyDecodedPositionsAsync(pack, range.offset, range.count);
1777
+ const positionsApplied = await this._applyDecodedPositionsAsync(pack, range.offset, range.count);
1638
1778
  if (this._disposed) {
1639
1779
  return;
1640
1780
  }
1781
+ if (!positionsApplied) {
1782
+ Logger.Warn("GaussianSplattingStream: decoded positions could not be applied for the environment.");
1783
+ return;
1784
+ }
1785
+ if (this._residency?.pin(EnvironmentFileId, range.count) !== range.offset) {
1786
+ throw new Error("GaussianSplattingStream: decoded environment could not be pinned.");
1787
+ }
1788
+ decoded = true;
1641
1789
  this._refreshActiveRanges();
1642
1790
  }
1643
1791
  finally {
@@ -1648,6 +1796,12 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1648
1796
  catch (e) {
1649
1797
  Logger.Warn("GaussianSplattingStream: failed to decode environment: " + (e?.message ?? e));
1650
1798
  }
1799
+ finally {
1800
+ if (!decoded) {
1801
+ this._residency?.free(EnvironmentFileId);
1802
+ this._environmentRange = null;
1803
+ }
1804
+ }
1651
1805
  }
1652
1806
  /**
1653
1807
  * Loads one LOD source file as GPU textures, decodes it into its fixed work-buffer block, records its
@@ -1655,15 +1809,19 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1655
1809
  * Concurrent or repeat requests for the same file are ignored. If the file is cancelled mid-flight
1656
1810
  * (because every node that wanted it retargeted), the decode bails cooperatively at the next checkpoint.
1657
1811
  * @param fileId file index to decode
1812
+ * @returns whether the file was decoded and published successfully
1658
1813
  */
1659
1814
  async _decodeFileAsync(fileId) {
1660
- if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId) || !this._residency) {
1661
- return;
1815
+ if (this._decodedFiles.has(fileId)) {
1816
+ return true;
1817
+ }
1818
+ if (this._loadingFiles.has(fileId) || !this._residency) {
1819
+ return false;
1662
1820
  }
1663
1821
  const meta = this._fileMeta.get(fileId);
1664
1822
  const count = this._fileCounts.get(fileId);
1665
1823
  if (!meta || count === undefined) {
1666
- return;
1824
+ return false;
1667
1825
  }
1668
1826
  this._loadingFiles.add(fileId);
1669
1827
  this._cancelledDecodes.delete(fileId);
@@ -1672,16 +1830,20 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1672
1830
  const parsed = await ParseSogMetaAsTextures(meta.sogData, meta.subRootUrl, this._scene, !this._useGpuPositionReadback, this._downloadManager, fileId);
1673
1831
  const pack = parsed.sogTextures;
1674
1832
  if (!pack) {
1675
- return;
1833
+ return false;
1676
1834
  }
1677
1835
  // Serialize the allocate -> decode -> readback section: a relayout runs only inside it (see
1678
1836
  // _relayoutAndAllocateAsync), so it never moves a file whose decode has not finished writing.
1679
1837
  const release = await this._acquireDecodeGateAsync();
1680
1838
  try {
1681
1839
  if (this._disposed || !this._workBuffer || this._cancelledDecodes.has(fileId)) {
1682
- return;
1840
+ return false;
1841
+ }
1842
+ const residency = this._residency;
1843
+ if (!residency) {
1844
+ return false;
1683
1845
  }
1684
- let base = this._residency.allocate(fileId, count);
1846
+ let base = residency.allocate(fileId, count);
1685
1847
  if (base === null) {
1686
1848
  // Defragment the work buffer to reclaim fragmented free space, then retry.
1687
1849
  base = await this._relayoutAndAllocateAsync(fileId, count);
@@ -1692,25 +1854,35 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1692
1854
  if (!this._cancelledDecodes.has(fileId)) {
1693
1855
  Logger.Warn(`GaussianSplattingStream: resident memory budget full; skipping LOD file ${fileId}.`);
1694
1856
  }
1695
- return;
1857
+ return false;
1696
1858
  }
1697
1859
  allocated = true;
1698
1860
  if (this._disposed || !this._workBuffer || this._cancelledDecodes.has(fileId)) {
1699
- return;
1861
+ return false;
1700
1862
  }
1701
1863
  await this._workBuffer.decodeAsync(pack, base);
1702
1864
  if (this._disposed || this._cancelledDecodes.has(fileId)) {
1703
- return;
1865
+ return false;
1704
1866
  }
1705
- await this._applyDecodedPositionsAsync(pack, base, count);
1867
+ const positionsApplied = await this._applyDecodedPositionsAsync(pack, base, count);
1706
1868
  if (this._disposed) {
1707
- return;
1869
+ return false;
1870
+ }
1871
+ if (!positionsApplied) {
1872
+ Logger.Warn(`GaussianSplattingStream: decoded positions could not be applied for LOD file ${fileId}.`);
1873
+ return false;
1874
+ }
1875
+ if (this._baseFileIds.has(fileId)) {
1876
+ if (residency.pin(fileId, count) !== base) {
1877
+ throw new Error(`GaussianSplattingStream: required coarse file ${fileId} could not be pinned.`);
1878
+ }
1708
1879
  }
1709
1880
  this._decodedFiles.add(fileId);
1710
1881
  // Promote any nodes that can now reach their desired LOD via this newly decoded file.
1711
1882
  if (this._applyDesiredLods()) {
1712
1883
  this._refreshActiveRanges();
1713
1884
  }
1885
+ return true;
1714
1886
  }
1715
1887
  finally {
1716
1888
  GaussianSplattingStream._DisposePack(pack);
@@ -1722,11 +1894,12 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1722
1894
  if (!this._cancelledDecodes.has(fileId)) {
1723
1895
  throw e;
1724
1896
  }
1897
+ return false;
1725
1898
  }
1726
1899
  finally {
1727
1900
  // If a slot was allocated but the decode did not complete (cancelled/disposed), release it.
1728
1901
  if (allocated && !this._decodedFiles.has(fileId)) {
1729
- this._residency.free(fileId);
1902
+ this._residency?.free(fileId);
1730
1903
  }
1731
1904
  this._loadingFiles.delete(fileId);
1732
1905
  this._cancelledDecodes.delete(fileId);
@@ -2252,17 +2425,36 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
2252
2425
  }
2253
2426
  const nodes = this._leafNodes;
2254
2427
  const inAny = this._frustumScratch;
2255
- // Update each node's world AABB once (force=false uses the renderId/sync fast-path, avoiding a full
2256
- // world-matrix recompute), then seed the union accumulator to false.
2428
+ // Node bounds remain immutable in stream-local space. Compose that space to clip once per camera instead
2429
+ // of transforming every leaf's box and sphere into world space every frame.
2257
2430
  const world = this._getEffectiveWorldMatrix(false);
2258
2431
  for (let i = 0; i < nodes.length; i++) {
2259
- nodes[i].cullBounds.update(world);
2260
2432
  inAny[i] = false;
2261
2433
  }
2262
2434
  // A node is in-frustum if inside ANY active camera's frustum: OR each camera's test into the accumulator.
2263
2435
  for (const cam of cameras) {
2264
- cam.getViewMatrix().multiplyToRef(cam.getProjectionMatrix(), this._cullViewProj);
2436
+ cam.getViewMatrix().multiplyToRef(cam.getProjectionMatrix(), this._cullCameraViewProj);
2437
+ world.multiplyToRef(this._cullCameraViewProj, this._cullViewProj);
2265
2438
  Frustum.GetPlanesToRef(this._cullViewProj, this._frustumPlanes);
2439
+ let validFrustum = true;
2440
+ for (const plane of this._frustumPlanes) {
2441
+ if (!Number.isFinite(plane.normal.x) ||
2442
+ !Number.isFinite(plane.normal.y) ||
2443
+ !Number.isFinite(plane.normal.z) ||
2444
+ !Number.isFinite(plane.d) ||
2445
+ plane.normal.lengthSquared() === 0) {
2446
+ validFrustum = false;
2447
+ break;
2448
+ }
2449
+ }
2450
+ if (!validFrustum) {
2451
+ // Degenerate transforms/projections cannot safely reject local volumes. Treat this camera as seeing
2452
+ // every node, preserving conservative rendering rather than turning malformed planes into holes.
2453
+ for (let i = 0; i < nodes.length; i++) {
2454
+ inAny[i] = true;
2455
+ }
2456
+ continue;
2457
+ }
2266
2458
  for (let i = 0; i < nodes.length; i++) {
2267
2459
  if (!inAny[i] && nodes[i].cullBounds.isInFrustum(this._frustumPlanes)) {
2268
2460
  inAny[i] = true;
@@ -2277,6 +2469,40 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
2277
2469
  }
2278
2470
  return changed;
2279
2471
  }
2472
+ /**
2473
+ * Normalizes an initial residency option, preserving finite non-positive values as an unset limit.
2474
+ * @param value option value
2475
+ * @param name option name used in errors
2476
+ * @param integer whether the normalized value must be an integer splat count
2477
+ * @returns normalized value
2478
+ */
2479
+ static _NormalizeResidentLimit(value, name, integer) {
2480
+ if (!Number.isFinite(value)) {
2481
+ throw new RangeError(`GaussianSplattingStream: ${name} must be finite.`);
2482
+ }
2483
+ if (value <= 0) {
2484
+ return 0;
2485
+ }
2486
+ const normalized = integer ? Math.max(1, Math.floor(value)) : value;
2487
+ if (normalized > Number.MAX_SAFE_INTEGER) {
2488
+ throw new RangeError(`GaussianSplattingStream: ${name} is outside the supported range.`);
2489
+ }
2490
+ return normalized;
2491
+ }
2492
+ /**
2493
+ * Adds trusted non-negative integer counts without allowing unsafe capacity arithmetic.
2494
+ * @param left first count
2495
+ * @param right second count
2496
+ * @param label capacity name used in errors
2497
+ * @returns the safe integer sum
2498
+ */
2499
+ static _SafeAddCounts(left, right, label) {
2500
+ const sum = left + right;
2501
+ if (!Number.isSafeInteger(left) || left < 0 || !Number.isSafeInteger(right) || right < 0 || !Number.isSafeInteger(sum)) {
2502
+ throw new Error(`GaussianSplattingStream: ${label} exceeds the supported integer range.`);
2503
+ }
2504
+ return sum;
2505
+ }
2280
2506
  /**
2281
2507
  * Reads the splat count from SOG metadata, coerced to a finite non-negative integer (metadata is untrusted, so
2282
2508
  * `count` / `shape[0]` may be a string or malformed — a non-numeric value must not leak into count arithmetic).
@@ -2301,7 +2527,6 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
2301
2527
  // Derive the SH degree from remote (untrusted) metadata, then validate/clamp it: the degree drives the SH
2302
2528
  // render-target count and decode-pass count, so a bogus (huge / non-finite / negative) `bands` or `shape`
2303
2529
  // must not be able to demand unbounded allocation. The draw path supports shTexture0..4, i.e. degree <= 4.
2304
- const maxDegree = 4;
2305
2530
  let degree = 0;
2306
2531
  const bands = data.shN.bands;
2307
2532
  if (typeof bands === "number" && Number.isFinite(bands) && bands > 0) {
@@ -2314,9 +2539,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
2314
2539
  if (!(degree > 0)) {
2315
2540
  return { degree: 0, coeffs: 0 };
2316
2541
  }
2317
- if (degree > maxDegree) {
2318
- Logger.Warn(`GaussianSplattingStream: SH degree ${degree} exceeds the maximum supported (${maxDegree}); clamping.`);
2319
- degree = maxDegree;
2542
+ if (degree > MaxSupportedShDegree) {
2543
+ Logger.Warn(`GaussianSplattingStream: SH degree ${degree} exceeds the maximum supported (${MaxSupportedShDegree}); clamping.`);
2544
+ degree = MaxSupportedShDegree;
2320
2545
  }
2321
2546
  return { degree, coeffs: (degree + 1) ** 2 - 1 };
2322
2547
  }