@babylonjs/loaders 9.22.2 → 9.24.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.
- package/OBJ/objFileLoader.metadata.d.ts +5 -1
- package/OBJ/objFileLoader.metadata.js +4 -1
- package/OBJ/objFileLoader.metadata.js.map +1 -1
- package/OBJ/objFileLoader.pure.d.ts +12 -3
- package/OBJ/objFileLoader.pure.js +30 -2
- package/OBJ/objFileLoader.pure.js.map +1 -1
- package/OBJ/objLoadingOptions.d.ts +6 -0
- package/OBJ/objLoadingOptions.js.map +1 -1
- package/SPLAT/gaussianSplattingStream.d.ts +162 -15
- package/SPLAT/gaussianSplattingStream.js +523 -96
- package/SPLAT/gaussianSplattingStream.js.map +1 -1
- package/dynamic.js +16 -6
- package/dynamic.js.map +1 -1
- package/glTF/1.0/glTFBinaryExtension.d.ts +4 -14
- package/glTF/1.0/glTFBinaryExtension.js +6 -62
- package/glTF/1.0/glTFBinaryExtension.js.map +1 -1
- package/glTF/1.0/glTFBinaryExtension.pure.d.ts +21 -0
- package/glTF/1.0/glTFBinaryExtension.pure.js +76 -0
- package/glTF/1.0/glTFBinaryExtension.pure.js.map +1 -0
- package/glTF/1.0/glTFLoader.d.ts +3 -142
- package/glTF/1.0/glTFLoader.js +7 -1840
- package/glTF/1.0/glTFLoader.js.map +1 -1
- package/glTF/1.0/glTFLoader.pure.d.ts +149 -0
- package/glTF/1.0/glTFLoader.pure.js +1854 -0
- package/glTF/1.0/glTFLoader.pure.js.map +1 -0
- package/glTF/1.0/glTFLoaderUtils.d.ts +2 -2
- package/glTF/1.0/glTFLoaderUtils.js +5 -5
- package/glTF/1.0/glTFLoaderUtils.js.map +1 -1
- package/glTF/1.0/glTFMaterialsCommonExtension.d.ts +4 -11
- package/glTF/1.0/glTFMaterialsCommonExtension.js +6 -128
- package/glTF/1.0/glTFMaterialsCommonExtension.js.map +1 -1
- package/glTF/1.0/glTFMaterialsCommonExtension.pure.d.ts +18 -0
- package/glTF/1.0/glTFMaterialsCommonExtension.pure.js +142 -0
- package/glTF/1.0/glTFMaterialsCommonExtension.pure.js.map +1 -0
- package/glTF/1.0/pure.d.ts +3 -0
- package/glTF/1.0/pure.js +3 -0
- package/glTF/1.0/pure.js.map +1 -1
- package/glTF/2.0/Extensions/KHR_animation_pointer.d.ts +0 -1
- package/glTF/2.0/Extensions/KHR_animation_pointer.data.d.ts +1 -1
- package/glTF/2.0/Extensions/KHR_animation_pointer.data.js +3 -251
- package/glTF/2.0/Extensions/KHR_animation_pointer.data.js.map +1 -1
- package/glTF/2.0/Extensions/KHR_animation_pointer.data.pure.d.ts +5 -0
- package/glTF/2.0/Extensions/KHR_animation_pointer.data.pure.js +267 -0
- package/glTF/2.0/Extensions/KHR_animation_pointer.data.pure.js.map +1 -0
- package/glTF/2.0/Extensions/KHR_animation_pointer.js +2 -1
- package/glTF/2.0/Extensions/KHR_animation_pointer.js.map +1 -1
- package/glTF/2.0/Extensions/KHR_interactivity.pure.d.ts +5 -0
- package/glTF/2.0/Extensions/KHR_interactivity.pure.js +15 -4
- package/glTF/2.0/Extensions/KHR_interactivity.pure.js.map +1 -1
- package/glTF/2.0/Extensions/KHR_node_hoverability.pure.d.ts +5 -0
- package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js +19 -6
- package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js.map +1 -1
- package/glTF/2.0/Extensions/KHR_node_selectability.pure.d.ts +5 -0
- package/glTF/2.0/Extensions/KHR_node_selectability.pure.js +19 -6
- package/glTF/2.0/Extensions/KHR_node_selectability.pure.js.map +1 -1
- package/glTF/2.0/Extensions/KHR_node_visibility.pure.d.ts +5 -0
- package/glTF/2.0/Extensions/KHR_node_visibility.pure.js +19 -6
- package/glTF/2.0/Extensions/KHR_node_visibility.pure.js.map +1 -1
- package/glTF/2.0/Extensions/dynamic.js +59 -42
- package/glTF/2.0/Extensions/dynamic.js.map +1 -1
- package/glTF/2.0/Extensions/pure.d.ts +1 -0
- package/glTF/2.0/Extensions/pure.js +1 -0
- package/glTF/2.0/Extensions/pure.js.map +1 -1
- package/glTF/2.0/glTFLoader.pure.d.ts +2 -2
- package/glTF/2.0/glTFLoader.pure.js +6 -5
- package/glTF/2.0/glTFLoader.pure.js.map +1 -1
- package/glTF/2.0/glTFLoaderAnimation.d.ts +1 -42
- package/glTF/2.0/glTFLoaderAnimation.js +3 -79
- package/glTF/2.0/glTFLoaderAnimation.js.map +1 -1
- package/glTF/2.0/glTFLoaderAnimation.pure.d.ts +47 -0
- package/glTF/2.0/glTFLoaderAnimation.pure.js +91 -0
- package/glTF/2.0/glTFLoaderAnimation.pure.js.map +1 -0
- package/glTF/2.0/pure.d.ts +1 -0
- package/glTF/2.0/pure.js +1 -0
- package/glTF/2.0/pure.js.map +1 -1
- package/glTF/glTFFileLoader.pure.d.ts +8 -4
- package/glTF/glTFFileLoader.pure.js +13 -6
- package/glTF/glTFFileLoader.pure.js.map +1 -1
- package/package.json +3 -4
- /package/{bvh → BVH}/license.md +0 -0
|
@@ -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.
|
|
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
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
|
|
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 =
|
|
621
|
-
|
|
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
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
camera
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
659
|
-
const
|
|
660
|
-
const
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
const
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
let
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
693
|
-
// level instead of being hidden. They stay in the render/sort set (
|
|
694
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
866
|
-
|
|
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(
|
|
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}
|
|
1592
|
-
*
|
|
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
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
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
|
|
2132
|
+
const cameras = this._getActiveLodCameras();
|
|
1754
2133
|
const threshold = this._lodUpdateDistance;
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
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
|
|
2193
|
+
const cameras = this._getActiveLodCameras();
|
|
1781
2194
|
let changed = false;
|
|
1782
|
-
if (!this._frustumCulling ||
|
|
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
|
-
|
|
1792
|
-
|
|
1793
|
-
//
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
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
|