@babylonjs/loaders 9.22.2 → 9.23.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 (40) hide show
  1. package/OBJ/objFileLoader.metadata.d.ts +5 -1
  2. package/OBJ/objFileLoader.metadata.js +4 -1
  3. package/OBJ/objFileLoader.metadata.js.map +1 -1
  4. package/OBJ/objFileLoader.pure.d.ts +12 -3
  5. package/OBJ/objFileLoader.pure.js +30 -2
  6. package/OBJ/objFileLoader.pure.js.map +1 -1
  7. package/OBJ/objLoadingOptions.d.ts +6 -0
  8. package/OBJ/objLoadingOptions.js.map +1 -1
  9. package/SPLAT/gaussianSplattingStream.d.ts +162 -15
  10. package/SPLAT/gaussianSplattingStream.js +523 -96
  11. package/SPLAT/gaussianSplattingStream.js.map +1 -1
  12. package/dynamic.js +16 -6
  13. package/dynamic.js.map +1 -1
  14. package/glTF/2.0/Extensions/KHR_interactivity.pure.d.ts +5 -0
  15. package/glTF/2.0/Extensions/KHR_interactivity.pure.js +15 -4
  16. package/glTF/2.0/Extensions/KHR_interactivity.pure.js.map +1 -1
  17. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.d.ts +5 -0
  18. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js +19 -6
  19. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js.map +1 -1
  20. package/glTF/2.0/Extensions/KHR_node_selectability.pure.d.ts +5 -0
  21. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js +19 -6
  22. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js.map +1 -1
  23. package/glTF/2.0/Extensions/KHR_node_visibility.pure.d.ts +5 -0
  24. package/glTF/2.0/Extensions/KHR_node_visibility.pure.js +19 -6
  25. package/glTF/2.0/Extensions/KHR_node_visibility.pure.js.map +1 -1
  26. package/glTF/2.0/Extensions/dynamic.js +54 -41
  27. package/glTF/2.0/Extensions/dynamic.js.map +1 -1
  28. package/glTF/2.0/glTFLoader.pure.d.ts +1 -1
  29. package/glTF/2.0/glTFLoader.pure.js +4 -3
  30. package/glTF/2.0/glTFLoader.pure.js.map +1 -1
  31. package/glTF/2.0/glTFLoaderAnimation.d.ts +1 -42
  32. package/glTF/2.0/glTFLoaderAnimation.js +3 -79
  33. package/glTF/2.0/glTFLoaderAnimation.js.map +1 -1
  34. package/glTF/2.0/glTFLoaderAnimation.pure.d.ts +47 -0
  35. package/glTF/2.0/glTFLoaderAnimation.pure.js +91 -0
  36. package/glTF/2.0/glTFLoaderAnimation.pure.js.map +1 -0
  37. package/glTF/2.0/pure.d.ts +1 -0
  38. package/glTF/2.0/pure.js +1 -0
  39. package/glTF/2.0/pure.js.map +1 -1
  40. package/package.json +3 -3
@@ -108,6 +108,13 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
108
108
  this._lodUpdateInterval = 4;
109
109
  this._lodUpdateDistance = 0.5;
110
110
  this._maxDetailLod = 0;
111
+ // Budget-driven LOD. The stream's own resolved cap lives in the inherited protected `_splatBudget`
112
+ // (0 = disabled; the option's "auto" is resolved to a device-tiered default in the constructor). `_lodPixelThreshold`
113
+ // is the converged pixel-size threshold that selects each node's level. `_hostBudgetAllocation` is
114
+ // the compound's apportioned slice when this stream is a coordinated budget participant (null = not coordinated,
115
+ // so the stream uses its own `_splatBudget`).
116
+ this._lodPixelThreshold = 1;
117
+ this._hostBudgetAllocation = null;
111
118
  // Frustum LOD bias: when enabled, nodes outside the camera frustum are rendered at their coarsest LOD.
112
119
  this._frustumCulling = true;
113
120
  // Reused world-space frustum planes and view-projection scratch matrix (avoids per-frame allocation).
@@ -120,6 +127,8 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
120
127
  new Plane(0, 0, 0, 0),
121
128
  ];
122
129
  this._cullViewProj = new Matrix();
130
+ // Reused per-leaf "inside any active camera's frustum" accumulator for the union frustum test (avoids per-frame allocation).
131
+ this._frustumScratch = [];
123
132
  // GPU work buffer holding all decoded splats; created once the total capacity is known.
124
133
  this._workBuffer = null;
125
134
  this._streamShDegree = 0;
@@ -172,9 +181,14 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
172
181
  // Per-frame LOD streaming loop; installed once the base layer is ready.
173
182
  this._lodObserver = null;
174
183
  this._baseLayerReady = false;
175
- // Throttling state for the per-frame LOD loop.
184
+ // Throttling state for the per-frame LOD loop: each active camera's world position at the last LOD evaluation, so
185
+ // a re-eval is gated on the camera set changing or any camera translating past `_lodUpdateDistance`.
176
186
  this._framesSinceLodUpdate = 0;
177
- this._lastLodCamPos = new Vector3(Infinity, Infinity, Infinity);
187
+ this._lastLodCamPositions = [];
188
+ // Signature of the discrete projected-size inputs (per-camera identity/FOV/FOV-mode/viewport + render size) at the
189
+ // last LOD evaluation. Camera translation is tracked separately (above); this catches the rest — a colocated
190
+ // camera swap, a viewport resize, or an FOV change — which would otherwise leave the budget LODs stale.
191
+ this._lastLodSignature = "";
178
192
  // Forces the next LOD update to run regardless of the throttle (e.g. after a budget change).
179
193
  this._forceLodUpdate = false;
180
194
  // Running local-space bounds of all decoded splat centers (for frustum culling / picking).
@@ -256,6 +270,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
256
270
  if (options.maxDetailLod !== undefined) {
257
271
  this._maxDetailLod = Math.max(0, Math.floor(options.maxDetailLod));
258
272
  }
273
+ this._splatBudget = this._resolveSplatBudget(options.splatBudget);
259
274
  if (options.frustumCulling !== undefined) {
260
275
  this._frustumCulling = options.frustumCulling;
261
276
  }
@@ -488,6 +503,134 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
488
503
  // Re-evaluate LODs on the next frame regardless of the movement throttle so the change is immediate.
489
504
  this._forceLodUpdate = true;
490
505
  }
506
+ /**
507
+ * This stream's own budget-driven LOD cap in splats (see {@link IGaussianSplattingStreamOptions.splatBudget}).
508
+ * `0` disables the budget (pure distance LOD). Setting it caps the rendered splat count, taking effect on the
509
+ * next frame. When this stream is hosted in a compound whose own budget is set, that shared budget overrides
510
+ * this value — read the actual runtime cap from {@link effectiveSplatBudget}, not this getter (which always
511
+ * reports the configured own cap).
512
+ * @experimental
513
+ */
514
+ get splatBudget() {
515
+ return this._splatBudget;
516
+ }
517
+ set splatBudget(value) {
518
+ const budget = value > 0 ? Math.floor(value) : 0;
519
+ if (budget === this._splatBudget) {
520
+ return;
521
+ }
522
+ this._splatBudget = budget;
523
+ // Re-evaluate LODs on the next frame regardless of the movement throttle so the change is immediate.
524
+ this._forceLodUpdate = true;
525
+ }
526
+ /**
527
+ * The splat cap actually in force this frame: a hosting compound's apportioned allocation when this stream is
528
+ * coordinated, otherwise this stream's own {@link splatBudget}, clamped to what can be kept resident. `0` means
529
+ * no cap (pure distance LOD). Unlike {@link splatBudget}, this reflects the compound override, so it is the value
530
+ * to display or reason about at runtime.
531
+ * @experimental
532
+ */
533
+ get effectiveSplatBudget() {
534
+ return this._effectiveSplatBudget();
535
+ }
536
+ /**
537
+ * Resolves the raw {@link splatBudget} option to a concrete cap: `undefined` ⇒ 0 (disabled), `"auto"` ⇒ a
538
+ * device-tiered default, a positive number ⇒ itself (floored).
539
+ * @param option the raw option value
540
+ * @returns the resolved splat cap (0 = disabled)
541
+ */
542
+ _resolveSplatBudget(option) {
543
+ if (option === undefined) {
544
+ return 0;
545
+ }
546
+ if (option === "auto") {
547
+ return this._computeDefaultSplatBudget();
548
+ }
549
+ return option > 0 ? Math.floor(option) : 0;
550
+ }
551
+ /**
552
+ * Device-tiered default splat budget for {@link splatBudget} `"auto"`: desktop 2.5M, iOS 1.5M, other mobile
553
+ * (incl. Android/XR) 1M. XR is folded into the mobile tier (no reliable at-construction detection).
554
+ * @returns the default splat cap for this device
555
+ */
556
+ _computeDefaultSplatBudget() {
557
+ const isMobile = !!this._scene.getEngine().hostInformation?.isMobile;
558
+ if (!isMobile) {
559
+ return 2500000;
560
+ }
561
+ if (typeof navigator !== "undefined" && navigator.userAgent && /iPad|iPhone|iPod/.test(navigator.userAgent)) {
562
+ return 1500000;
563
+ }
564
+ return 1000000;
565
+ }
566
+ /**
567
+ * The splat budget this stream converges against this frame: the compound's apportioned allocation when hosted
568
+ * and coordinated, else this stream's own resolved budget. Clamped to the resident budget so the stream never
569
+ * targets more splats than can be kept resident. `0` means the budget is disabled.
570
+ * @returns the effective splat cap (0 = disabled)
571
+ */
572
+ _effectiveSplatBudget() {
573
+ if (this._hostBudgetAllocation !== null) {
574
+ // Coordinated by the host: even a 0 allocation (static parts consumed the whole budget) keeps the budget
575
+ // path active so the stream converges to its coarsest level — it must never revert to unbounded LOD, or
576
+ // the compound total could exceed the cap.
577
+ const alloc = Math.max(1, this._hostBudgetAllocation);
578
+ return this._residentBudget > 0 ? Math.min(alloc, this._residentBudget) : alloc;
579
+ }
580
+ if (this._splatBudget <= 0) {
581
+ return 0;
582
+ }
583
+ return this._residentBudget > 0 ? Math.min(this._splatBudget, this._residentBudget) : this._splatBudget;
584
+ }
585
+ /**
586
+ * Whether budget-driven LOD is active this frame.
587
+ * @returns true when a positive effective budget is in force
588
+ */
589
+ _splatBudgetEnabled() {
590
+ return this._effectiveSplatBudget() > 0;
591
+ }
592
+ /**
593
+ * {@link IGaussianSplattingLodBudgetParticipant}: the splats this stream would render at full (distance-optimal)
594
+ * detail — its demand on a host compound's shared budget. Computed from the current per-node distance-optimal
595
+ * levels (no pixel threshold), so it does not depend on the allocation it is helping to compute.
596
+ * @returns the full-detail rendered splat count (0 before the base layer is ready)
597
+ */
598
+ getBudgetDemand() {
599
+ if (!this._baseLayerReady) {
600
+ return 0;
601
+ }
602
+ // Include the always-rendered environment as a fixed cost so the host allocates enough for it; the leaf
603
+ // convergence reserves the same amount (see _computeTargetLevels).
604
+ let sum = this._environmentSplatCount();
605
+ for (const node of this._leafNodes) {
606
+ const desired = node.optimalLod ?? node.baseLod;
607
+ sum += this._countAtLevel(node, this._cappedLevelForNode(node, desired));
608
+ }
609
+ return sum;
610
+ }
611
+ /**
612
+ * {@link IGaussianSplattingLodBudgetParticipant}: sets the compound's apportioned share of the shared budget.
613
+ * `null` releases coordination (revert to this stream's own {@link splatBudget}); a number (incl. 0, meaning
614
+ * "coordinated at the coarsest level") drives the pixel-threshold convergence. Any change to the (already integer)
615
+ * allocation forces a next-frame re-eval: a decrease may put the current selection over the new cap, and even a
616
+ * small increase can unlock a finer level that a stationary camera would otherwise never re-evaluate to. The
617
+ * apportioned demand is allocation-independent, so this settles in one step and does not churn frame to frame.
618
+ * @param splats the apportioned allocation, or null to release coordination
619
+ */
620
+ setBudgetAllocation(splats) {
621
+ if (splats === null) {
622
+ if (this._hostBudgetAllocation !== null) {
623
+ this._hostBudgetAllocation = null;
624
+ this._forceLodUpdate = true;
625
+ }
626
+ return;
627
+ }
628
+ const prev = this._hostBudgetAllocation;
629
+ this._hostBudgetAllocation = splats;
630
+ if (prev === null || splats !== prev) {
631
+ this._forceLodUpdate = true;
632
+ }
633
+ }
491
634
  /**
492
635
  * Coarsest LOD level index in the scene (number of LOD levels minus one). Useful as the upper bound
493
636
  * for {@link maxDetailLod}.
@@ -566,6 +709,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
566
709
  // region (tombstone). Reclaiming the rows is a separate compaction — cheap disposal here so tearing down N
567
710
  // parts doesn't trigger N atlas rebuilds; the caller/host reclaims when appropriate (a failed load compacts
568
711
  // once, see the _streamAllAsync handler). Skipped when the host removed the part (it owns that policy).
712
+ if (this._hostCompound && !this._hostCompound.isDisposed()) {
713
+ this._hostCompound.unregisterLodBudgetParticipant(this);
714
+ }
569
715
  if (this._host && this._hostCompound && !this._partReleasedByHost && !this._hostCompound.isDisposed()) {
570
716
  this._hostCompound.removePart(this._host.partIndex);
571
717
  }
@@ -612,13 +758,24 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
612
758
  return this.computeWorldMatrix(force);
613
759
  }
614
760
  /**
615
- * Re-evaluates the optimal LOD for every node based on the camera position. The result is stored in
616
- * each node's `optimalLod`. Rendering is unaffected; this currently drives only diagnostics and the
617
- * debug wireframe display.
618
- * @param camera camera to evaluate against (defaults to the scene's active camera)
761
+ * The cameras the LOD should serve: `scene.activeCameras` when set (split-view / multi-view), else the single
762
+ * `scene.activeCamera`. Mirrors the sort path's multi-camera handling.
763
+ * @returns the non-null active cameras (may be empty)
764
+ */
765
+ _getActiveLodCameras() {
766
+ const cameras = this._scene.activeCameras?.length ? this._scene.activeCameras : this._scene.activeCamera ? [this._scene.activeCamera] : [];
767
+ return cameras.filter((camera) => !!camera);
768
+ }
769
+ /**
770
+ * Re-evaluates the optimal LOD for every node from the active cameras. Each node takes the finest level and the
771
+ * largest projected pixel size any active camera demands (so every pane of a split view is served), and the
772
+ * frustum bias uses the union of the frusta. Selection is view-direction-independent, so single- and multi-camera
773
+ * rendering are consistent. The results are stored in each node's `optimalLod` / `pixelSize`.
774
+ * @param camera when provided, evaluate against just this camera; otherwise use the active-camera set
619
775
  */
620
- evaluateOptimalLods(camera = this._scene.activeCamera) {
621
- if (!camera || this._leafNodes.length === 0) {
776
+ evaluateOptimalLods(camera = null) {
777
+ const cameras = camera ? [camera] : this._getActiveLodCameras();
778
+ if (cameras.length === 0 || this._leafNodes.length === 0) {
622
779
  return;
623
780
  }
624
781
  const maxLod = Math.max(0, this._metadata.lodLevels - 1);
@@ -627,60 +784,92 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
627
784
  const behindPenalty = this._lodBehindPenalty;
628
785
  const rangeMin = this._lodRangeMin;
629
786
  const rangeMax = this._lodRangeMax;
630
- // FOV compensation: use min(tanHalfV, tanHalfH) so transitions stay perceptually uniform (matches PlayCanvas).
631
- const aspect = this._scene.getEngine().getAspectRatio(camera) || 1;
632
- let tanHalfV = Math.tan(camera.fov * 0.5);
633
- if (camera.fovMode === Camera.FOVMODE_HORIZONTAL_FIXED) {
634
- tanHalfV /= aspect;
635
- }
636
- const tanHalfH = tanHalfV * aspect;
637
- const fovScale = Math.min(tanHalfV, tanHalfH) / RefTanHalfFov;
638
- // Transform the camera into the mesh's local space (where the node bounds live).
787
+ const budgetEnabled = this._splatBudgetEnabled();
788
+ const renderHeight = this._scene.getEngine().getRenderHeight() || 1;
789
+ // Precompute each camera in the mesh's local space (where the node bounds live). The local forward is only
790
+ // needed for the behind-camera penalty. FOV compensation uses min(tanHalfV, tanHalfH) (matches PlayCanvas).
791
+ const engine = this._scene.getEngine();
639
792
  this._getEffectiveWorldMatrix(false).invertToRef(TmpInvWorld);
640
- const localCamera = Vector3.TransformCoordinatesToRef(camera.globalPosition, TmpInvWorld, TmpLocalCamera);
641
- const px = localCamera.x;
642
- const py = localCamera.y;
643
- const pz = localCamera.z;
644
- let fwx = 0;
645
- let fwy = 0;
646
- let fwz = 0;
647
- if (behindPenalty > 1) {
648
- camera.getDirectionToRef(LocalForwardAxis, TmpWorldForward);
649
- const localForward = Vector3.TransformNormalToRef(TmpWorldForward, TmpInvWorld, TmpLocalForward);
650
- localForward.normalize();
651
- fwx = localForward.x;
652
- fwy = localForward.y;
653
- fwz = localForward.z;
654
- }
793
+ const camInfos = cameras.map((cam) => {
794
+ const aspect = engine.getAspectRatio(cam) || 1;
795
+ let tanHalfV = Math.tan(cam.fov * 0.5);
796
+ if (cam.fovMode === Camera.FOVMODE_HORIZONTAL_FIXED) {
797
+ tanHalfV /= aspect;
798
+ }
799
+ const tanHalfH = tanHalfV * aspect;
800
+ const fovScale = Math.min(tanHalfV, tanHalfH) / RefTanHalfFov;
801
+ // Vertical pixel extent of THIS camera's viewport (split-view panes render to a fraction of the canvas),
802
+ // so a node's projected size is measured against the pixels the camera actually draws into.
803
+ const pixelHeight = renderHeight * (cam.viewport ? cam.viewport.height : 1);
804
+ const localCamera = Vector3.TransformCoordinatesToRef(cam.globalPosition, TmpInvWorld, TmpLocalCamera);
805
+ const info = { px: localCamera.x, py: localCamera.y, pz: localCamera.z, fwx: 0, fwy: 0, fwz: 0, fovScale, tanHalfV, pixelHeight };
806
+ if (behindPenalty > 1) {
807
+ cam.getDirectionToRef(LocalForwardAxis, TmpWorldForward);
808
+ const localForward = Vector3.TransformNormalToRef(TmpWorldForward, TmpInvWorld, TmpLocalForward);
809
+ localForward.normalize();
810
+ info.fwx = localForward.x;
811
+ info.fwy = localForward.y;
812
+ info.fwz = localForward.z;
813
+ }
814
+ return info;
815
+ });
655
816
  for (const node of this._leafNodes) {
656
817
  const mn = node.bound.min;
657
818
  const mx = node.bound.max;
658
- // Distance from the camera to the closest point on this node's AABB (local space).
659
- const qx = px < mn[0] ? mn[0] : px > mx[0] ? mx[0] : px;
660
- const qy = py < mn[1] ? mn[1] : py > mx[1] ? mx[1] : py;
661
- const qz = pz < mn[2] ? mn[2] : pz > mx[2] ? mx[2] : pz;
662
- const dx = qx - px;
663
- const dy = qy - py;
664
- const dz = qz - pz;
665
- const actualDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
666
- // Push nodes behind the camera toward coarser LODs when a penalty is configured.
667
- let penalizedDistance = actualDistance;
668
- if (behindPenalty > 1 && actualDistance > 0.01) {
669
- const dotOverDistance = (fwx * dx + fwy * dy + fwz * dz) / actualDistance;
670
- if (dotOverDistance < 0) {
671
- penalizedDistance = actualDistance * (1 + -dotOverDistance * (behindPenalty - 1));
819
+ const cx = (mn[0] + mx[0]) * 0.5;
820
+ const cy = (mn[1] + mx[1]) * 0.5;
821
+ const cz = (mn[2] + mx[2]) * 0.5;
822
+ const hx = mx[0] - mn[0];
823
+ const hy = mx[1] - mn[1];
824
+ const hz = mx[2] - mn[2];
825
+ const radius = 0.5 * Math.sqrt(hx * hx + hy * hy + hz * hz);
826
+ // Aggregate across cameras: the finest level and the largest projected size any active camera demands.
827
+ let optimalLod = Number.POSITIVE_INFINITY;
828
+ let pixelSize = 0;
829
+ for (const cam of camInfos) {
830
+ // Distance from the camera to the closest point on this node's AABB (local space).
831
+ const qx = cam.px < mn[0] ? mn[0] : cam.px > mx[0] ? mx[0] : cam.px;
832
+ const qy = cam.py < mn[1] ? mn[1] : cam.py > mx[1] ? mx[1] : cam.py;
833
+ const qz = cam.pz < mn[2] ? mn[2] : cam.pz > mx[2] ? mx[2] : cam.pz;
834
+ const dx = qx - cam.px;
835
+ const dy = qy - cam.py;
836
+ const dz = qz - cam.pz;
837
+ const actualDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
838
+ // Push nodes behind the camera toward coarser LODs when a penalty is configured.
839
+ let penalizedDistance = actualDistance;
840
+ if (behindPenalty > 1 && actualDistance > 0.01) {
841
+ const dotOverDistance = (cam.fwx * dx + cam.fwy * dy + cam.fwz * dz) / actualDistance;
842
+ if (dotOverDistance < 0) {
843
+ penalizedDistance = actualDistance * (1 + -dotOverDistance * (behindPenalty - 1));
844
+ }
672
845
  }
673
- }
674
- // Geometric LOD bands: threshold[k] = base * mult^(k-1).
675
- const fovAdjustedDistance = penalizedDistance * fovScale;
676
- let optimalLod;
677
- if (maxLod === 0 || fovAdjustedDistance < base) {
678
- optimalLod = 0;
679
- }
680
- else {
681
- optimalLod = maxLod;
682
- while (optimalLod > 1 && fovAdjustedDistance < base * Math.pow(mult, optimalLod - 1)) {
683
- optimalLod--;
846
+ // Geometric LOD bands: threshold[k] = base * mult^(k-1). Keep the finest (min) any camera wants.
847
+ const fovAdjustedDistance = penalizedDistance * cam.fovScale;
848
+ let lod;
849
+ if (maxLod === 0 || fovAdjustedDistance < base) {
850
+ lod = 0;
851
+ }
852
+ else {
853
+ lod = maxLod;
854
+ while (lod > 1 && fovAdjustedDistance < base * Math.pow(mult, lod - 1)) {
855
+ lod--;
856
+ }
857
+ }
858
+ if (lod < optimalLod) {
859
+ optimalLod = lod;
860
+ }
861
+ // Budget-driven LOD: raw projected pixel size (node diameter in pixels), largest across cameras.
862
+ // Uses local radius/distance (invariant under uniform world scale); every camera weighs the same and
863
+ // view direction is ignored, so single- and multi-camera rendering stay consistent.
864
+ if (budgetEnabled) {
865
+ const rdx = cx - cam.px;
866
+ const rdy = cy - cam.py;
867
+ const rdz = cz - cam.pz;
868
+ const centerDist = Math.sqrt(rdx * rdx + rdy * rdy + rdz * rdz);
869
+ const pixels = (radius * cam.pixelHeight) / (Math.max(centerDist, 1e-4) * cam.tanHalfV);
870
+ if (pixels > pixelSize) {
871
+ pixelSize = pixels;
872
+ }
684
873
  }
685
874
  }
686
875
  if (optimalLod < rangeMin) {
@@ -689,14 +878,16 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
689
878
  else if (optimalLod > rangeMax) {
690
879
  optimalLod = rangeMax;
691
880
  }
692
- // Frustum-based LOD bias: nodes outside the camera frustum are pushed to the coarsest allowed
693
- // level instead of being hidden. They stay in the render/sort set (their splats are off-screen
694
- // and clipped anyway), so when the camera turns to include them they are already present at low
695
- // detail with no invisible frames, then refine to the distance-optimal level.
881
+ // Frustum-based LOD bias: nodes outside EVERY active camera's frustum (see _updateNodeFrustum) are pushed
882
+ // to the coarsest allowed level instead of being hidden. They stay in the render/sort set (off-screen and
883
+ // clipped anyway), so turning a camera toward them shows low detail immediately, then refines.
696
884
  if (this._frustumCulling && node.inFrustum === false) {
697
885
  optimalLod = rangeMax;
698
886
  }
699
887
  node.optimalLod = optimalLod;
888
+ if (budgetEnabled) {
889
+ node.pixelSize = pixelSize;
890
+ }
700
891
  }
701
892
  }
702
893
  /**
@@ -849,12 +1040,39 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
849
1040
  if (!node.lods) {
850
1041
  return;
851
1042
  }
1043
+ // Malformed bounds break distance, projected-size, and frustum math. Check the coordinates are present and
1044
+ // finite, then the DERIVED diagonal is finite and positive: finite-but-huge coordinates (e.g. ±1e308) yield an
1045
+ // infinite span/radius/pixel-size and thus an infinite budget threshold that defeats the cap, while a zero
1046
+ // (degenerate) span projects to zero pixels and never coarsens. Either way, skip such a node entirely.
1047
+ const bmin = node.bound?.min;
1048
+ const bmax = node.bound?.max;
1049
+ if (!bmin || !bmax || bmin.length < 3 || bmax.length < 3 || !bmin.every((v) => Number.isFinite(v)) || !bmax.every((v) => Number.isFinite(v))) {
1050
+ return;
1051
+ }
1052
+ const spanX = bmax[0] - bmin[0];
1053
+ const spanY = bmax[1] - bmin[1];
1054
+ const spanZ = bmax[2] - bmin[2];
1055
+ const diagonal = Math.sqrt(spanX * spanX + spanY * spanY + spanZ * spanZ);
1056
+ if (!Number.isFinite(diagonal) || diagonal <= 0) {
1057
+ return;
1058
+ }
1059
+ // Declared LOD range: keys must resolve to a canonical, in-range, integer level so later `node.lods[String(level)]`
1060
+ // lookups always hit and an out-of-range fine level can't slip past the convergence (which only coarsens within
1061
+ // the declared level count) and defeat the cap.
1062
+ const declaredLevels = Math.floor(Number(this._metadata.lodLevels));
1063
+ const maxLevel = Number.isFinite(declaredLevels) && declaredLevels > 0 ? declaredLevels - 1 : 0;
852
1064
  // Collect all levels that hold splats (PlayCanvas convention: level 0 is the finest, higher = coarser).
1065
+ // Normalize each entry's count to a finite positive integer HERE (metadata is untrusted and only shallowly
1066
+ // validated, so `count` may be a string): all downstream arithmetic — budget demand/convergence — then adds
1067
+ // numbers instead of concatenating strings. Require `String(level) === key` so a non-canonical key like "01"
1068
+ // (which `Number` maps to 1, but `String(1)` can't retrieve) is rejected rather than crashing a later lookup.
853
1069
  const levels = [];
854
1070
  for (const key of Object.keys(node.lods)) {
855
1071
  const level = Number(key);
856
1072
  const entry = node.lods[key];
857
- if (Number.isFinite(level) && entry && entry.count > 0) {
1073
+ const count = entry ? Math.floor(Number(entry.count)) : NaN;
1074
+ if (Number.isInteger(level) && String(level) === key && level >= 0 && level <= maxLevel && entry && Number.isFinite(count) && count > 0) {
1075
+ entry.count = count;
858
1076
  levels.push(level);
859
1077
  }
860
1078
  }
@@ -862,13 +1080,27 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
862
1080
  return;
863
1081
  }
864
1082
  levels.sort((a, b) => a - b);
865
- node.availableLevels = levels;
866
- node.baseLod = levels[levels.length - 1];
1083
+ // Enforce non-increasing count as the level index increases (a coarser level must not cost MORE than a finer
1084
+ // one). Keep every level whose count is at or below the running minimum: equal-count adjacent levels are valid
1085
+ // (same splat count, different geometry) and preserved, while a malformed "coarser but larger" level is dropped
1086
+ // so the budget can never pick more splats than a cheaper available level and the coarsest kept level (the base)
1087
+ // is the true minimum — which the convergence relies on for its guaranteed cap.
1088
+ const usable = [];
1089
+ let minCount = Infinity;
1090
+ for (const level of levels) {
1091
+ const count = node.lods[String(level)].count;
1092
+ if (count <= minCount) {
1093
+ usable.push(level);
1094
+ minCount = count;
1095
+ }
1096
+ }
1097
+ node.availableLevels = usable;
1098
+ node.baseLod = usable[usable.length - 1];
867
1099
  node.activeLod = undefined;
868
1100
  node.lodCooldown = 0;
869
1101
  node.inFrustum = true;
870
1102
  // Local-space bounds for the per-node frustum test; the mesh world matrix is applied per evaluation.
871
- node.cullBounds = new BoundingInfo(Vector3.FromArray(node.bound.min), Vector3.FromArray(node.bound.max));
1103
+ node.cullBounds = new BoundingInfo(Vector3.FromArray(bmin), Vector3.FromArray(bmax));
872
1104
  this._leafNodes.push(node);
873
1105
  }
874
1106
  /**
@@ -930,6 +1162,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
930
1162
  const host = this._hostCompound.reserveStreamingPart(capacity, sogWorld, this.name + "_part", this._shTextureCount, this._streamShDegree, this._needsRotationScale);
931
1163
  this._host = host;
932
1164
  this._positionBase = host.base;
1165
+ // Join the compound's shared splat budget (if any): the compound apportions its cap across all hosted
1166
+ // streams. Harmless when the compound has no budget set (registration just tracks the participant).
1167
+ this._hostCompound.registerLodBudgetParticipant(this);
933
1168
  // Bind this controller's lifetime to its part FROM RESERVATION (not after readiness): removing the part or
934
1169
  // disposing the compound — even while still downloading/decoding — disposes this stream so it stops writing
935
1170
  // into the compound's borrowed textures. `_partReleasedByHost` stops dispose() from removing the part again.
@@ -1588,14 +1823,142 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1588
1823
  return best < 0 ? node.baseLod : best;
1589
1824
  }
1590
1825
  /**
1591
- * Computes each node's {@link ISOGLODNode.targetLevel}: the distance-based optimal level snapped to an
1592
- * available level, capped so no node renders finer (more detailed) than {@link maxDetailLod}.
1826
+ * Computes each node's {@link ISOGLODNode.targetLevel}. With the splat budget disabled this is the
1827
+ * distance-optimal level snapped to an available level (capped by {@link maxDetailLod}) — unchanged prior
1828
+ * behavior. With the budget enabled it converges a global pixel-size threshold to the budget and coarsens each
1829
+ * node from its distance-optimal ceiling by how far its projected size falls below that threshold.
1593
1830
  */
1594
1831
  _computeTargetLevels() {
1832
+ const budget = this._effectiveSplatBudget();
1833
+ if (budget <= 0) {
1834
+ for (const node of this._leafNodes) {
1835
+ const desired = node.optimalLod ?? node.baseLod;
1836
+ node.targetLevel = this._cappedLevelForNode(node, desired);
1837
+ }
1838
+ return;
1839
+ }
1840
+ // The environment is always rendered on top of the leaves, so reserve it as a fixed cost: the leaves
1841
+ // converge against the remainder, keeping the stream's total (env + leaves) within the budget.
1842
+ const leafBudget = Math.max(0, budget - this._environmentSplatCount());
1843
+ this._convergePixelThreshold(leafBudget);
1844
+ const t = this._lodPixelThreshold;
1595
1845
  for (const node of this._leafNodes) {
1596
- const desired = node.optimalLod ?? node.baseLod;
1597
- node.targetLevel = this._cappedLevelForNode(node, desired);
1846
+ node.targetLevel = this._budgetedLevel(node, t);
1847
+ }
1848
+ }
1849
+ /**
1850
+ * Splat count of the always-rendered environment layer (0 when none), coerced to a finite non-negative integer.
1851
+ * Counted as a fixed cost in both the budget demand and the leaf convergence so it is never over-drawn.
1852
+ * @returns the environment's rendered splat count
1853
+ */
1854
+ _environmentSplatCount() {
1855
+ const count = this._environmentRange ? Math.floor(Number(this._environmentRange.count)) : 0;
1856
+ return Number.isFinite(count) && count > 0 ? count : 0;
1857
+ }
1858
+ /**
1859
+ * The level a node renders at for a given pixel-size threshold `t`: its distance-optimal ceiling, coarsened by
1860
+ * `round(log_mult(t / pixelSize))` geometric steps when its projected size is below `t`. Snapped to
1861
+ * an available level honoring {@link maxDetailLod}.
1862
+ * @param node leaf node
1863
+ * @param t pixel-size threshold (pixels)
1864
+ * @returns the chosen available LOD level
1865
+ */
1866
+ _budgetedLevel(node, t) {
1867
+ const ceiling = node.optimalLod ?? node.baseLod;
1868
+ const ps = node.pixelSize ?? 0;
1869
+ let steps = 0;
1870
+ if (ps > 0 && t > ps) {
1871
+ steps = Math.max(0, Math.round(Math.log(t / ps) / Math.log(this._lodMultiplier)));
1598
1872
  }
1873
+ return this._cappedLevelForNode(node, ceiling + steps);
1874
+ }
1875
+ /**
1876
+ * Splat count of a node's file at a given (already snapped) available level.
1877
+ * @param node leaf node
1878
+ * @param level available LOD level
1879
+ * @returns the level's splat count (0 if absent)
1880
+ */
1881
+ _countAtLevel(node, level) {
1882
+ const entry = node.lods[String(level)];
1883
+ return entry ? entry.count : 0;
1884
+ }
1885
+ /**
1886
+ * Sum of every leaf's rendered splat count at pixel-size threshold `t`.
1887
+ * @param t pixel-size threshold (pixels)
1888
+ * @returns total rendered splats
1889
+ */
1890
+ _totalSplatsAtThreshold(t) {
1891
+ let sum = 0;
1892
+ for (const node of this._leafNodes) {
1893
+ sum += this._countAtLevel(node, this._budgetedLevel(node, t));
1894
+ }
1895
+ return sum;
1896
+ }
1897
+ /**
1898
+ * Converges the global pixel-size threshold {@link _lodPixelThreshold} to the largest detail whose total rendered
1899
+ * splats is still within `budget` (the pixel-scale cut, floored at the true sub-pixel limit of 1 px). The
1900
+ * total is monotonically non-increasing in the threshold, so a bisection on `[floorT, ceilT]` — where `ceilT`
1901
+ * coarsens every node to its coarsest available level — gives a GUARANTEED result at or under the cap. When even
1902
+ * that coarsest state exceeds the budget (the budget is below the pinned minimum detail), the threshold is set to
1903
+ * `ceilT` so every node renders at minimum detail — the best achievable; the cap is then unavoidable. O(leaves)
1904
+ * per iteration; no decode.
1905
+ * @param budget target maximum rendered splat count
1906
+ */
1907
+ _convergePixelThreshold(budget) {
1908
+ const floorT = 1; // sub-pixel limit, in pixels
1909
+ // At the finest allowed (threshold floor) we are already within budget: no coarsening needed.
1910
+ if (this._totalSplatsAtThreshold(floorT) <= budget) {
1911
+ this._lodPixelThreshold = floorT;
1912
+ return;
1913
+ }
1914
+ // A threshold large enough to coarsen every node to its coarsest available level: a node coarsens by
1915
+ // round(log_mult(t / pixelSize)) steps, so t >= maxPixelSize · mult^(lodLevels + 1) saturates them all.
1916
+ let maxPixelSize = floorT;
1917
+ for (const node of this._leafNodes) {
1918
+ if (node.pixelSize && node.pixelSize > maxPixelSize) {
1919
+ maxPixelSize = node.pixelSize;
1920
+ }
1921
+ }
1922
+ // Cap the exponent (real LOD trees are shallow) so untrusted metadata can't push ceilT to Infinity, which
1923
+ // would skip the bisection below and force every node to minimum detail.
1924
+ const lodLevels = Math.min(32, Math.max(1, this._metadata.lodLevels));
1925
+ const ceilT = maxPixelSize * Math.pow(this._lodMultiplier, lodLevels + 1);
1926
+ if (this._totalSplatsAtThreshold(ceilT) > budget) {
1927
+ // Even the coarsest level exceeds the budget (pinned base layers alone are over): render minimum detail.
1928
+ this._lodPixelThreshold = ceilT;
1929
+ return;
1930
+ }
1931
+ // Bisection: `hi` always satisfies the cap (starts at ceilT), `lo` never does, so the result is guaranteed
1932
+ // within budget while keeping the finest detail the budget allows. Deterministic => temporally stable.
1933
+ let lo = floorT;
1934
+ let hi = ceilT;
1935
+ for (let i = 0; i < 40 && hi - lo > 1e-3 * hi; i++) {
1936
+ const mid = 0.5 * (lo + hi);
1937
+ if (this._totalSplatsAtThreshold(mid) <= budget) {
1938
+ hi = mid;
1939
+ }
1940
+ else {
1941
+ lo = mid;
1942
+ }
1943
+ }
1944
+ this._lodPixelThreshold = hi;
1945
+ }
1946
+ /**
1947
+ * The finest already-decoded level of a node that is at least as coarse as `level` (index >= level). Used to
1948
+ * enforce the budget cap immediately without waiting for a download. Returns `null` when no such level is resident
1949
+ * — including when eviction has freed the base layer — so the caller keeps the currently-visible (resident) file
1950
+ * rather than switching to a non-resident one, which would render a hole.
1951
+ * @param node leaf node
1952
+ * @param level the minimum coarseness (level index) required
1953
+ * @returns a resident level index >= level, or null when none is resident
1954
+ */
1955
+ _residentLevelAtLeast(node, level) {
1956
+ for (const lvl of node.availableLevels) {
1957
+ if (lvl >= level && this._decodedFiles.has(node.lods[String(lvl)].file)) {
1958
+ return lvl;
1959
+ }
1960
+ }
1961
+ return null;
1599
1962
  }
1600
1963
  /**
1601
1964
  * Applies each node's {@link ISOGLODNode.targetLevel}: switches a node to its target level when that
@@ -1611,11 +1974,27 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1611
1974
  _applyDesiredLods() {
1612
1975
  let dirty = false;
1613
1976
  for (const node of this._leafNodes) {
1614
- // Nodes in cooldown keep their current LOD and their existing pending request untouched.
1977
+ const desired = node.targetLevel ?? node.baseLod;
1978
+ // Budget cap enforcement (only when a budget is in force — otherwise ordinary distance/frustum coarsening
1979
+ // must keep going through the cooldown path below, unchanged from pre-budget behavior). If the node renders
1980
+ // FINER than its target (activeLod index < target) it exceeds the cap, so coarsen immediately — bypassing
1981
+ // the oscillation cooldown and any not-yet-downloaded finer target — to the finest already-resident level at
1982
+ // least as coarse as the target. When nothing coarser is resident (e.g. eviction freed the base layer) keep
1983
+ // the current visible file and let the normal path below queue the coarse download, so nothing disappears.
1984
+ if (this._splatBudgetEnabled() && node.activeLod !== undefined && node.activeLod < desired) {
1985
+ const coarse = this._residentLevelAtLeast(node, desired);
1986
+ if (coarse !== null && coarse !== node.activeLod) {
1987
+ this._switchActiveFile(node, node.lods[String(coarse)].file);
1988
+ node.activeLod = coarse;
1989
+ node.lodCooldown = this._lodCooldownFrames;
1990
+ dirty = true;
1991
+ }
1992
+ }
1993
+ // Nodes in cooldown keep their current LOD and their existing pending request untouched (the cap
1994
+ // coarsening above already ran, so this only gates refinement toward the target).
1615
1995
  if (node.lodCooldown && node.lodCooldown > 0) {
1616
1996
  continue;
1617
1997
  }
1618
- const desired = node.targetLevel ?? node.baseLod;
1619
1998
  let newPending;
1620
1999
  if (desired !== node.activeLod) {
1621
2000
  const entry = node.lods[String(desired)];
@@ -1750,19 +2129,35 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1750
2129
  const frustumChanged = this._updateNodeFrustum();
1751
2130
  let runLodEval = this._forceLodUpdate || frustumChanged || cooldownExpiredWithPendingSwitch;
1752
2131
  if (!runLodEval && ++this._framesSinceLodUpdate >= this._lodUpdateInterval) {
1753
- const camera = this._scene.activeCamera;
2132
+ const cameras = this._getActiveLodCameras();
1754
2133
  const threshold = this._lodUpdateDistance;
1755
- if (!camera || Vector3.DistanceSquared(camera.globalPosition, this._lastLodCamPos) >= threshold * threshold) {
1756
- if (camera) {
1757
- this._lastLodCamPos.copyFrom(camera.globalPosition);
1758
- }
2134
+ // Re-evaluate when the active-camera set changes, when ANY active camera translated past the threshold, or
2135
+ // when a discrete projected-size input changed (identity / FOV / viewport / render size). LOD selection is
2136
+ // view-direction-independent, so rotation alone doesn't need a re-eval (the frustum test above tracks it).
2137
+ if (cameras.length !== this._lastLodCamPositions.length || this._computeLodSignature(cameras) !== this._lastLodSignature) {
1759
2138
  runLodEval = true;
1760
2139
  }
2140
+ else {
2141
+ for (let i = 0; i < cameras.length; i++) {
2142
+ if (Vector3.DistanceSquared(cameras[i].globalPosition, this._lastLodCamPositions[i]) >= threshold * threshold) {
2143
+ runLodEval = true;
2144
+ break;
2145
+ }
2146
+ }
2147
+ }
1761
2148
  }
1762
2149
  if (runLodEval) {
1763
2150
  this._forceLodUpdate = false;
1764
2151
  this._framesSinceLodUpdate = 0;
1765
- this.evaluateOptimalLods(this._scene.activeCamera);
2152
+ // Snapshot each active camera's position and the projected-size signature so the next throttled check
2153
+ // measures movement/input changes against them.
2154
+ const cameras = this._getActiveLodCameras();
2155
+ this._lastLodCamPositions.length = 0;
2156
+ for (const cam of cameras) {
2157
+ this._lastLodCamPositions.push(cam.globalPosition.clone());
2158
+ }
2159
+ this._lastLodSignature = this._computeLodSignature(cameras);
2160
+ this.evaluateOptimalLods();
1766
2161
  this._computeTargetLevels();
1767
2162
  if (this._applyDesiredLods()) {
1768
2163
  this._refreshActiveRanges();
@@ -1770,16 +2165,34 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1770
2165
  }
1771
2166
  }
1772
2167
  /**
1773
- * Updates each leaf node's {@link ISOGLODNode.inFrustum} flag from a per-node frustum test against the
1774
- * active camera. When {@link frustumCulling} is disabled (or there is no camera) every node is marked
1775
- * in-frustum. Bounds are static (from the LOD tree), so flags are valid for all nodes regardless of
1776
- * decode state. Returns true when any node's in-frustum state changed (so the LOD bias must be re-applied).
2168
+ * A cheap signature of the discrete inputs (besides camera translation, tracked separately) that affect projected
2169
+ * pixel size: each camera's identity, FOV, FOV mode and viewport, plus the engine render size and the effective
2170
+ * world matrix revision. A change invalidates the throttled budget LOD so a colocated camera swap / viewport
2171
+ * resize / FOV change or the stream (or its hosted proxy) being moved or scaled while still in frustum, which
2172
+ * shifts every node's distance and projected size — can't leave it stale.
2173
+ * @param cameras the active cameras
2174
+ * @returns the signature string
2175
+ */
2176
+ _computeLodSignature(cameras) {
2177
+ const engine = this._scene.getEngine();
2178
+ let sig = `${engine.getRenderWidth()}x${engine.getRenderHeight()}#${this._getEffectiveWorldMatrix(false).updateFlag}`;
2179
+ for (const cam of cameras) {
2180
+ const vp = cam.viewport;
2181
+ sig += `|${cam.uniqueId}:${cam.fov}:${cam.fovMode}:${vp.x},${vp.y},${vp.width},${vp.height}`;
2182
+ }
2183
+ return sig;
2184
+ }
2185
+ /**
2186
+ * Updates each leaf node's {@link ISOGLODNode.inFrustum} flag: a node is in-frustum if it is inside ANY
2187
+ * active camera's frustum (the union). When {@link frustumCulling} is disabled (or there are no cameras)
2188
+ * every node is marked in-frustum. Bounds are static (from the LOD tree), so flags are valid for all nodes
2189
+ * regardless of decode state. Returns true when any node's in-frustum state changed (so the LOD bias must be re-applied).
1777
2190
  * @returns whether any node's in-frustum state changed
1778
2191
  */
1779
2192
  _updateNodeFrustum() {
1780
- const camera = this._scene.activeCamera;
2193
+ const cameras = this._getActiveLodCameras();
1781
2194
  let changed = false;
1782
- if (!this._frustumCulling || !camera) {
2195
+ if (!this._frustumCulling || cameras.length === 0) {
1783
2196
  for (const node of this._leafNodes) {
1784
2197
  if (node.inFrustum === false) {
1785
2198
  node.inFrustum = true;
@@ -1788,29 +2201,43 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1788
2201
  }
1789
2202
  return changed;
1790
2203
  }
1791
- // World-space frustum planes from the current view-projection, tested against each node's world AABB.
1792
- // force=false uses the renderId/sync fast-path (still recomputes when the transform actually changed),
1793
- // avoiding a full world-matrix recompute every frame for the per-node frustum test.
2204
+ const nodes = this._leafNodes;
2205
+ const inAny = this._frustumScratch;
2206
+ // Update each node's world AABB once (force=false uses the renderId/sync fast-path, avoiding a full
2207
+ // world-matrix recompute), then seed the union accumulator to false.
1794
2208
  const world = this._getEffectiveWorldMatrix(false);
1795
- camera.getViewMatrix().multiplyToRef(camera.getProjectionMatrix(), this._cullViewProj);
1796
- Frustum.GetPlanesToRef(this._cullViewProj, this._frustumPlanes);
1797
- for (const node of this._leafNodes) {
1798
- node.cullBounds.update(world);
1799
- const inFrustum = node.cullBounds.isInFrustum(this._frustumPlanes);
1800
- if (inFrustum !== node.inFrustum) {
1801
- node.inFrustum = inFrustum;
2209
+ for (let i = 0; i < nodes.length; i++) {
2210
+ nodes[i].cullBounds.update(world);
2211
+ inAny[i] = false;
2212
+ }
2213
+ // A node is in-frustum if inside ANY active camera's frustum: OR each camera's test into the accumulator.
2214
+ for (const cam of cameras) {
2215
+ cam.getViewMatrix().multiplyToRef(cam.getProjectionMatrix(), this._cullViewProj);
2216
+ Frustum.GetPlanesToRef(this._cullViewProj, this._frustumPlanes);
2217
+ for (let i = 0; i < nodes.length; i++) {
2218
+ if (!inAny[i] && nodes[i].cullBounds.isInFrustum(this._frustumPlanes)) {
2219
+ inAny[i] = true;
2220
+ }
2221
+ }
2222
+ }
2223
+ for (let i = 0; i < nodes.length; i++) {
2224
+ if (inAny[i] !== nodes[i].inFrustum) {
2225
+ nodes[i].inFrustum = inAny[i];
1802
2226
  changed = true;
1803
2227
  }
1804
2228
  }
1805
2229
  return changed;
1806
2230
  }
1807
2231
  /**
1808
- * Reads the splat count from SOG metadata.
2232
+ * Reads the splat count from SOG metadata, coerced to a finite non-negative integer (metadata is untrusted, so
2233
+ * `count` / `shape[0]` may be a string or malformed — a non-numeric value must not leak into count arithmetic).
1809
2234
  * @param data SOG metadata
1810
- * @returns the splat count
2235
+ * @returns the splat count (0 when absent/invalid)
1811
2236
  */
1812
2237
  static _GetSplatCount(data) {
1813
- return data.count ?? (Array.isArray(data.means.shape) ? data.means.shape[0] : 0);
2238
+ const raw = data.count ?? (Array.isArray(data.means.shape) ? data.means.shape[0] : 0);
2239
+ const count = Math.floor(Number(raw));
2240
+ return Number.isFinite(count) && count > 0 ? count : 0;
1814
2241
  }
1815
2242
  /**
1816
2243
  * Reads a SOG file's higher-order SH degree and coefficient count from its metadata, mirroring