@babylonjs/loaders 9.12.1 → 9.13.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/SPLAT/gaussianSplattingStream.d.ts +68 -5
- package/SPLAT/gaussianSplattingStream.js +213 -30
- package/SPLAT/gaussianSplattingStream.js.map +1 -1
- package/SPLAT/gaussianSplattingWorkBuffer.d.ts +20 -0
- package/SPLAT/gaussianSplattingWorkBuffer.js +90 -0
- package/SPLAT/gaussianSplattingWorkBuffer.js.map +1 -1
- package/SPLAT/sog.d.ts +6 -1
- package/SPLAT/sog.js +104 -20
- package/SPLAT/sog.js.map +1 -1
- package/package.json +3 -3
|
@@ -2,6 +2,7 @@ import { GaussianSplattingMesh } from "@babylonjs/core/Meshes/GaussianSplatting/
|
|
|
2
2
|
import { type Scene } from "@babylonjs/core/scene.js";
|
|
3
3
|
import { type Nullable } from "@babylonjs/core/types.js";
|
|
4
4
|
import { Camera } from "@babylonjs/core/Cameras/camera.js";
|
|
5
|
+
import { BoundingInfo } from "@babylonjs/core/Culling/boundingInfo.js";
|
|
5
6
|
/**
|
|
6
7
|
* A single LOD variant of a tree node: a contiguous splat range inside one streamed SOG file.
|
|
7
8
|
*/
|
|
@@ -37,6 +38,11 @@ interface ISOGLODNode {
|
|
|
37
38
|
targetLevel?: number;
|
|
38
39
|
/** Frames remaining before this node may switch LOD again (oscillation damping). */
|
|
39
40
|
lodCooldown?: number;
|
|
41
|
+
/** True when the node's bounding box currently intersects the camera frustum. Drives the LOD bias that
|
|
42
|
+
* pushes off-screen nodes to the coarsest level (they stay rendered, not hidden). */
|
|
43
|
+
inFrustum?: boolean;
|
|
44
|
+
/** Cached local-space bounding info used for the per-node frustum test (created once per leaf). */
|
|
45
|
+
cullBounds?: BoundingInfo;
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* Parsed contents of a PlayCanvas-style `lod-meta.json` file.
|
|
@@ -90,6 +96,12 @@ export interface IGaussianSplattingStreamOptions {
|
|
|
90
96
|
* `1` caps detail at the next-coarser level, and so on. Higher values force a coarser maximum detail.
|
|
91
97
|
*/
|
|
92
98
|
maxDetailLod?: number;
|
|
99
|
+
/**
|
|
100
|
+
* When true (default), LOD nodes outside the camera frustum are biased to their coarsest LOD rather than
|
|
101
|
+
* rendered at full detail. They stay in the sort/render set so they appear instantly (at low detail) when
|
|
102
|
+
* the camera turns toward them, then refine. Set to `false` to render every node at its distance LOD.
|
|
103
|
+
*/
|
|
104
|
+
frustumCulling?: boolean;
|
|
93
105
|
}
|
|
94
106
|
/**
|
|
95
107
|
* Streams a PlayCanvas-style SOG LOD scene (`lod-meta.json`) into a single Gaussian Splatting mesh.
|
|
@@ -120,7 +132,13 @@ export declare class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
120
132
|
private _lodUpdateInterval;
|
|
121
133
|
private _lodUpdateDistance;
|
|
122
134
|
private _maxDetailLod;
|
|
135
|
+
private _frustumCulling;
|
|
136
|
+
private readonly _frustumPlanes;
|
|
137
|
+
private readonly _cullViewProj;
|
|
123
138
|
private _workBuffer;
|
|
139
|
+
private _useGpuPositionReadback;
|
|
140
|
+
private _readbackCandidate;
|
|
141
|
+
private _readbackProbed;
|
|
124
142
|
private readonly _fileBaseSplat;
|
|
125
143
|
private readonly _fileCounts;
|
|
126
144
|
private readonly _fileMeta;
|
|
@@ -171,6 +189,14 @@ export declare class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
171
189
|
* for {@link maxDetailLod}.
|
|
172
190
|
*/
|
|
173
191
|
get maxLodLevel(): number;
|
|
192
|
+
/**
|
|
193
|
+
* When true (default), nodes whose bounding box is outside the camera frustum are biased to the coarsest
|
|
194
|
+
* LOD instead of being hidden. They stay in the sort/render set (their off-screen splats are clipped), so
|
|
195
|
+
* turning the camera toward them shows low detail immediately with no invisible frames, then refines.
|
|
196
|
+
* Changes take effect in real time.
|
|
197
|
+
*/
|
|
198
|
+
get frustumCulling(): boolean;
|
|
199
|
+
set frustumCulling(value: boolean);
|
|
174
200
|
/**
|
|
175
201
|
* When true, renders a wireframe box per LOD node, colored by the LOD level selected by {@link debugLodSource}.
|
|
176
202
|
*/
|
|
@@ -262,6 +288,35 @@ export declare class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
262
288
|
* and promote any waiting nodes once they complete.
|
|
263
289
|
*/
|
|
264
290
|
private _pumpDecodeQueue;
|
|
291
|
+
/**
|
|
292
|
+
* Writes a decoded splat range's positions into the shared buffer, expands the bounds, and incrementally
|
|
293
|
+
* patches the sort worker.
|
|
294
|
+
* @param positions stride-4 positions for the range
|
|
295
|
+
* @param base first splat index of the range in the work buffer
|
|
296
|
+
* @param count number of splats in the range
|
|
297
|
+
*/
|
|
298
|
+
private _applyPositions;
|
|
299
|
+
/**
|
|
300
|
+
* One-time validation of GPU position readback: reads a sample of the just-decoded range back from the work
|
|
301
|
+
* buffer and compares it to the CPU-decoded positions. Enables {@link _useGpuPositionReadback} only on an
|
|
302
|
+
* exact (within float tolerance) match, so an unsupported or incorrect readback (e.g. a backend without the
|
|
303
|
+
* required texture usage, or an orientation mismatch) safely keeps the CPU decode path.
|
|
304
|
+
* @param base first splat index of the validated range
|
|
305
|
+
* @param count number of splats in the range
|
|
306
|
+
* @param cpuPositions the CPU-decoded stride-4 positions for the range (ground truth)
|
|
307
|
+
*/
|
|
308
|
+
private _probeReadbackAsync;
|
|
309
|
+
/**
|
|
310
|
+
* Resolves the decoded positions for a splat range and applies them. Once GPU readback has been validated,
|
|
311
|
+
* positions are read back from the work buffer (non-blocking) and `pack.positions` is empty; otherwise the
|
|
312
|
+
* CPU-decoded `pack.positions` are used, and — on the first such decode — the GPU readback is validated
|
|
313
|
+
* against them so subsequent decodes can use the fast path.
|
|
314
|
+
* @param pack the parsed SOG pack (its `positions` is populated only on the CPU path)
|
|
315
|
+
* @param base first splat index of the range in the work buffer
|
|
316
|
+
* @param count number of splats in the range
|
|
317
|
+
* @returns whether positions were applied
|
|
318
|
+
*/
|
|
319
|
+
private _applyDecodedPositionsAsync;
|
|
265
320
|
/**
|
|
266
321
|
* Decodes the always-on environment bundle into its work-buffer block and activates its range.
|
|
267
322
|
*/
|
|
@@ -295,13 +350,21 @@ export declare class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
295
350
|
*/
|
|
296
351
|
private _applyDesiredLods;
|
|
297
352
|
/**
|
|
298
|
-
* Per-frame LOD streaming loop. Ticks cooldowns and pumps the decode queue every frame,
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
353
|
+
* Per-frame LOD streaming loop. Ticks cooldowns and pumps the decode queue every frame, and runs the
|
|
354
|
+
* cheap per-node frustum test every frame so the off-screen LOD bias tracks camera rotation. The LOD
|
|
355
|
+
* re-evaluation is throttled to at most every {@link _lodUpdateInterval} frames once the camera has
|
|
356
|
+
* translated far enough, but also runs immediately whenever a node enters/leaves the frustum (so its
|
|
357
|
+
* detail upgrades/downgrades promptly) or a cap change forces it. Active ranges rebuild on any LOD change.
|
|
303
358
|
*/
|
|
304
359
|
private _onLodFrame;
|
|
360
|
+
/**
|
|
361
|
+
* Updates each leaf node's {@link ISOGLODNode.inFrustum} flag from a per-node frustum test against the
|
|
362
|
+
* active camera. When {@link frustumCulling} is disabled (or there is no camera) every node is marked
|
|
363
|
+
* in-frustum. Bounds are static (from the LOD tree), so flags are valid for all nodes regardless of
|
|
364
|
+
* decode state. Returns true when any node's in-frustum state changed (so the LOD bias must be re-applied).
|
|
365
|
+
* @returns whether any node's in-frustum state changed
|
|
366
|
+
*/
|
|
367
|
+
private _updateNodeFrustum;
|
|
305
368
|
/**
|
|
306
369
|
* Reads the splat count from SOG metadata.
|
|
307
370
|
* @param data SOG metadata
|
|
@@ -3,6 +3,8 @@ import { Logger } from "@babylonjs/core/Misc/logger.js";
|
|
|
3
3
|
import { Tools } from "@babylonjs/core/Misc/tools.js";
|
|
4
4
|
import { Vector3, Matrix } from "@babylonjs/core/Maths/math.vector.js";
|
|
5
5
|
import { Color4 } from "@babylonjs/core/Maths/math.color.js";
|
|
6
|
+
import { Frustum } from "@babylonjs/core/Maths/math.frustum.js";
|
|
7
|
+
import { Plane } from "@babylonjs/core/Maths/math.plane.js";
|
|
6
8
|
import { Camera } from "@babylonjs/core/Cameras/camera.js";
|
|
7
9
|
import { BoundingInfo } from "@babylonjs/core/Culling/boundingInfo.js";
|
|
8
10
|
import { CreateLineSystem } from "@babylonjs/core/Meshes/Builders/linesBuilder.js";
|
|
@@ -98,8 +100,28 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
98
100
|
this._lodUpdateInterval = 4;
|
|
99
101
|
this._lodUpdateDistance = 0.5;
|
|
100
102
|
this._maxDetailLod = 0;
|
|
103
|
+
// Frustum LOD bias: when enabled, nodes outside the camera frustum are rendered at their coarsest LOD.
|
|
104
|
+
this._frustumCulling = true;
|
|
105
|
+
// Reused world-space frustum planes and view-projection scratch matrix (avoids per-frame allocation).
|
|
106
|
+
this._frustumPlanes = [
|
|
107
|
+
new Plane(0, 0, 0, 0),
|
|
108
|
+
new Plane(0, 0, 0, 0),
|
|
109
|
+
new Plane(0, 0, 0, 0),
|
|
110
|
+
new Plane(0, 0, 0, 0),
|
|
111
|
+
new Plane(0, 0, 0, 0),
|
|
112
|
+
new Plane(0, 0, 0, 0),
|
|
113
|
+
];
|
|
114
|
+
this._cullViewProj = new Matrix();
|
|
101
115
|
// GPU work buffer holding all decoded splats; created once the total capacity is known.
|
|
102
116
|
this._workBuffer = null;
|
|
117
|
+
// True once GPU position readback has been validated against a CPU decode (see _probeReadbackAsync). While
|
|
118
|
+
// false, positions are decoded on the CPU from the means images; once validated, every SOG image uses the
|
|
119
|
+
// fast direct upload and positions are read back from the work buffer (non-blocking).
|
|
120
|
+
this._useGpuPositionReadback = false;
|
|
121
|
+
// Whether the engine reports GPU readback support (candidate to validate on the first decode).
|
|
122
|
+
this._readbackCandidate = false;
|
|
123
|
+
// Set once the one-time readback validation has run (success or failure).
|
|
124
|
+
this._readbackProbed = false;
|
|
103
125
|
// Global splat offset where each source file begins in the work buffer (fixed for all files up front).
|
|
104
126
|
this._fileBaseSplat = new Map();
|
|
105
127
|
// Splat count of each source file (learned from its metadata before allocation).
|
|
@@ -173,6 +195,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
173
195
|
if (options.maxDetailLod !== undefined) {
|
|
174
196
|
this._maxDetailLod = Math.max(0, Math.floor(options.maxDetailLod));
|
|
175
197
|
}
|
|
198
|
+
if (options.frustumCulling !== undefined) {
|
|
199
|
+
this._frustumCulling = options.frustumCulling;
|
|
200
|
+
}
|
|
176
201
|
if (options.debugLodSource) {
|
|
177
202
|
this._debugLodSource = options.debugLodSource;
|
|
178
203
|
}
|
|
@@ -217,6 +242,23 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
217
242
|
get maxLodLevel() {
|
|
218
243
|
return Math.max(0, this._metadata.lodLevels - 1);
|
|
219
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* When true (default), nodes whose bounding box is outside the camera frustum are biased to the coarsest
|
|
247
|
+
* LOD instead of being hidden. They stay in the sort/render set (their off-screen splats are clipped), so
|
|
248
|
+
* turning the camera toward them shows low detail immediately with no invisible frames, then refines.
|
|
249
|
+
* Changes take effect in real time.
|
|
250
|
+
*/
|
|
251
|
+
get frustumCulling() {
|
|
252
|
+
return this._frustumCulling;
|
|
253
|
+
}
|
|
254
|
+
set frustumCulling(value) {
|
|
255
|
+
if (this._frustumCulling === value) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this._frustumCulling = value;
|
|
259
|
+
// Re-evaluate LODs next frame so the off-screen bias is applied/removed immediately.
|
|
260
|
+
this._forceLodUpdate = true;
|
|
261
|
+
}
|
|
220
262
|
/**
|
|
221
263
|
* When true, renders a wireframe box per LOD node, colored by the LOD level selected by {@link debugLodSource}.
|
|
222
264
|
*/
|
|
@@ -287,7 +329,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
287
329
|
const tanHalfH = tanHalfV * aspect;
|
|
288
330
|
const fovScale = Math.min(tanHalfV, tanHalfH) / RefTanHalfFov;
|
|
289
331
|
// Transform the camera into the mesh's local space (where the node bounds live).
|
|
290
|
-
this.computeWorldMatrix(
|
|
332
|
+
this.computeWorldMatrix(false).invertToRef(TmpInvWorld);
|
|
291
333
|
const localCamera = Vector3.TransformCoordinatesToRef(camera.globalPosition, TmpInvWorld, TmpLocalCamera);
|
|
292
334
|
const px = localCamera.x;
|
|
293
335
|
const py = localCamera.y;
|
|
@@ -340,6 +382,13 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
340
382
|
else if (optimalLod > rangeMax) {
|
|
341
383
|
optimalLod = rangeMax;
|
|
342
384
|
}
|
|
385
|
+
// Frustum-based LOD bias: nodes outside the camera frustum are pushed to the coarsest allowed
|
|
386
|
+
// level instead of being hidden. They stay in the render/sort set (their splats are off-screen
|
|
387
|
+
// and clipped anyway), so when the camera turns to include them they are already present at low
|
|
388
|
+
// detail with no invisible frames, then refine to the distance-optimal level.
|
|
389
|
+
if (this._frustumCulling && node.inFrustum === false) {
|
|
390
|
+
optimalLod = rangeMax;
|
|
391
|
+
}
|
|
343
392
|
node.optimalLod = optimalLod;
|
|
344
393
|
}
|
|
345
394
|
}
|
|
@@ -510,6 +559,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
510
559
|
node.baseLod = levels[levels.length - 1];
|
|
511
560
|
node.activeLod = undefined;
|
|
512
561
|
node.lodCooldown = 0;
|
|
562
|
+
node.inFrustum = true;
|
|
563
|
+
// Local-space bounds for the per-node frustum test; the mesh world matrix is applied per evaluation.
|
|
564
|
+
node.cullBounds = new BoundingInfo(Vector3.FromArray(node.bound.min), Vector3.FromArray(node.bound.max));
|
|
513
565
|
this._leafNodes.push(node);
|
|
514
566
|
}
|
|
515
567
|
/**
|
|
@@ -545,6 +597,9 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
545
597
|
return;
|
|
546
598
|
}
|
|
547
599
|
this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, capacity);
|
|
600
|
+
// GPU readback is only enabled after it is validated against a CPU decode on the first file (see
|
|
601
|
+
// _probeReadbackAsync); until then positions are decoded on the CPU so there is always a correct result.
|
|
602
|
+
this._readbackCandidate = this._workBuffer.supportsAsyncCentersReadback;
|
|
548
603
|
const splatPositions = new Float32Array(capacity * 4);
|
|
549
604
|
const textures = this._workBuffer.textures;
|
|
550
605
|
this._setExternalWorkBuffer(textures[0], textures[1], textures[2], textures[3], splatPositions, capacity);
|
|
@@ -670,6 +725,95 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
670
725
|
});
|
|
671
726
|
}
|
|
672
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Writes a decoded splat range's positions into the shared buffer, expands the bounds, and incrementally
|
|
730
|
+
* patches the sort worker.
|
|
731
|
+
* @param positions stride-4 positions for the range
|
|
732
|
+
* @param base first splat index of the range in the work buffer
|
|
733
|
+
* @param count number of splats in the range
|
|
734
|
+
*/
|
|
735
|
+
_applyPositions(positions, base, count) {
|
|
736
|
+
this._splatPositions.set(positions, base * 4);
|
|
737
|
+
this._updateBounds(positions, count);
|
|
738
|
+
// Incrementally patch only this range in the sort worker (avoids the full position-buffer re-copy).
|
|
739
|
+
this._postWorkerPositionsRange(base, count);
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* One-time validation of GPU position readback: reads a sample of the just-decoded range back from the work
|
|
743
|
+
* buffer and compares it to the CPU-decoded positions. Enables {@link _useGpuPositionReadback} only on an
|
|
744
|
+
* exact (within float tolerance) match, so an unsupported or incorrect readback (e.g. a backend without the
|
|
745
|
+
* required texture usage, or an orientation mismatch) safely keeps the CPU decode path.
|
|
746
|
+
* @param base first splat index of the validated range
|
|
747
|
+
* @param count number of splats in the range
|
|
748
|
+
* @param cpuPositions the CPU-decoded stride-4 positions for the range (ground truth)
|
|
749
|
+
*/
|
|
750
|
+
async _probeReadbackAsync(base, count, cpuPositions) {
|
|
751
|
+
this._readbackProbed = true;
|
|
752
|
+
if (!this._workBuffer) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const sampleCount = Math.min(count, 1024);
|
|
756
|
+
let ok = false;
|
|
757
|
+
try {
|
|
758
|
+
const gpu = await this._workBuffer.readCentersRangeAsync(base, sampleCount);
|
|
759
|
+
if (this._disposed) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (gpu && gpu.length >= sampleCount * 4) {
|
|
763
|
+
ok = true;
|
|
764
|
+
for (let i = 0; i < sampleCount && ok; i++) {
|
|
765
|
+
for (let j = 0; j < 3; j++) {
|
|
766
|
+
const a = gpu[i * 4 + j];
|
|
767
|
+
const b = cpuPositions[i * 4 + j];
|
|
768
|
+
if (Math.abs(a - b) > 1e-2 * (1 + Math.abs(b))) {
|
|
769
|
+
ok = false;
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
ok = false;
|
|
778
|
+
}
|
|
779
|
+
this._useGpuPositionReadback = ok;
|
|
780
|
+
Logger.Log(ok
|
|
781
|
+
? "GaussianSplattingStream: GPU position readback validated; streamed LOD positions are read back from the GPU."
|
|
782
|
+
: "GaussianSplattingStream: GPU position readback unavailable; decoding LOD positions on the CPU.");
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Resolves the decoded positions for a splat range and applies them. Once GPU readback has been validated,
|
|
786
|
+
* positions are read back from the work buffer (non-blocking) and `pack.positions` is empty; otherwise the
|
|
787
|
+
* CPU-decoded `pack.positions` are used, and — on the first such decode — the GPU readback is validated
|
|
788
|
+
* against them so subsequent decodes can use the fast path.
|
|
789
|
+
* @param pack the parsed SOG pack (its `positions` is populated only on the CPU path)
|
|
790
|
+
* @param base first splat index of the range in the work buffer
|
|
791
|
+
* @param count number of splats in the range
|
|
792
|
+
* @returns whether positions were applied
|
|
793
|
+
*/
|
|
794
|
+
async _applyDecodedPositionsAsync(pack, base, count) {
|
|
795
|
+
if (this._useGpuPositionReadback && this._workBuffer) {
|
|
796
|
+
const positions = await this._workBuffer.readCentersRangeAsync(base, count);
|
|
797
|
+
if (this._disposed) {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
if (positions && this._splatPositions) {
|
|
801
|
+
this._applyPositions(positions, base, count);
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
// Validated readback unexpectedly returned nothing; fall through to the (likely empty) CPU positions.
|
|
805
|
+
}
|
|
806
|
+
const cpu = pack.positions.length >= count * 4 ? pack.positions.subarray(0, count * 4) : null;
|
|
807
|
+
if (!cpu || !this._splatPositions) {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
this._applyPositions(cpu, base, count);
|
|
811
|
+
// First CPU decode while readback is a candidate: validate it so later decodes can use the fast path.
|
|
812
|
+
if (!this._readbackProbed && this._readbackCandidate) {
|
|
813
|
+
await this._probeReadbackAsync(base, count, cpu);
|
|
814
|
+
}
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
673
817
|
/**
|
|
674
818
|
* Decodes the always-on environment bundle into its work-buffer block and activates its range.
|
|
675
819
|
*/
|
|
@@ -679,7 +823,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
679
823
|
}
|
|
680
824
|
const range = this._environmentRange;
|
|
681
825
|
try {
|
|
682
|
-
const parsed = await ParseSogMetaAsTextures(this._environmentFiles, "", this._scene);
|
|
826
|
+
const parsed = await ParseSogMetaAsTextures(this._environmentFiles, "", this._scene, !this._useGpuPositionReadback);
|
|
683
827
|
const pack = parsed.sogTextures;
|
|
684
828
|
if (!pack) {
|
|
685
829
|
return;
|
|
@@ -692,9 +836,10 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
692
836
|
if (this._disposed) {
|
|
693
837
|
return;
|
|
694
838
|
}
|
|
695
|
-
this.
|
|
696
|
-
this.
|
|
697
|
-
|
|
839
|
+
await this._applyDecodedPositionsAsync(pack, range.offset, range.count);
|
|
840
|
+
if (this._disposed) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
698
843
|
this._refreshActiveRanges();
|
|
699
844
|
}
|
|
700
845
|
finally {
|
|
@@ -724,7 +869,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
724
869
|
}
|
|
725
870
|
this._loadingFiles.add(fileId);
|
|
726
871
|
try {
|
|
727
|
-
const parsed = await ParseSogMetaAsTextures(meta.sogData, meta.subRootUrl, this._scene);
|
|
872
|
+
const parsed = await ParseSogMetaAsTextures(meta.sogData, meta.subRootUrl, this._scene, !this._useGpuPositionReadback);
|
|
728
873
|
const pack = parsed.sogTextures;
|
|
729
874
|
if (!pack) {
|
|
730
875
|
return;
|
|
@@ -737,10 +882,11 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
737
882
|
if (this._disposed) {
|
|
738
883
|
return;
|
|
739
884
|
}
|
|
740
|
-
this.
|
|
741
|
-
this.
|
|
885
|
+
await this._applyDecodedPositionsAsync(pack, base, count);
|
|
886
|
+
if (this._disposed) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
742
889
|
this._decodedFiles.add(fileId);
|
|
743
|
-
this._notifyWorkerNewData();
|
|
744
890
|
// Promote any nodes that can now reach their desired LOD via this newly decoded file.
|
|
745
891
|
if (this._applyDesiredLods()) {
|
|
746
892
|
this._refreshActiveRanges();
|
|
@@ -823,11 +969,11 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
823
969
|
return dirty;
|
|
824
970
|
}
|
|
825
971
|
/**
|
|
826
|
-
* Per-frame LOD streaming loop. Ticks cooldowns and pumps the decode queue every frame,
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
972
|
+
* Per-frame LOD streaming loop. Ticks cooldowns and pumps the decode queue every frame, and runs the
|
|
973
|
+
* cheap per-node frustum test every frame so the off-screen LOD bias tracks camera rotation. The LOD
|
|
974
|
+
* re-evaluation is throttled to at most every {@link _lodUpdateInterval} frames once the camera has
|
|
975
|
+
* translated far enough, but also runs immediately whenever a node enters/leaves the frustum (so its
|
|
976
|
+
* detail upgrades/downgrades promptly) or a cap change forces it. Active ranges rebuild on any LOD change.
|
|
831
977
|
*/
|
|
832
978
|
_onLodFrame() {
|
|
833
979
|
if (this._disposed || !this._baseLayerReady) {
|
|
@@ -840,27 +986,64 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
|
840
986
|
}
|
|
841
987
|
// In-flight/queued decodes still progress every frame.
|
|
842
988
|
this._pumpDecodeQueue();
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
989
|
+
// Per-node frustum test runs every frame (cheap) so the off-screen LOD bias tracks camera rotation,
|
|
990
|
+
// not just the translation that gates the throttled LOD re-evaluation below.
|
|
991
|
+
const frustumChanged = this._updateNodeFrustum();
|
|
992
|
+
let runLodEval = this._forceLodUpdate || frustumChanged;
|
|
993
|
+
if (!runLodEval && ++this._framesSinceLodUpdate >= this._lodUpdateInterval) {
|
|
848
994
|
const camera = this._scene.activeCamera;
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (
|
|
852
|
-
|
|
995
|
+
const threshold = this._lodUpdateDistance;
|
|
996
|
+
if (!camera || Vector3.DistanceSquared(camera.globalPosition, this._lastLodCamPos) >= threshold * threshold) {
|
|
997
|
+
if (camera) {
|
|
998
|
+
this._lastLodCamPos.copyFrom(camera.globalPosition);
|
|
853
999
|
}
|
|
854
|
-
|
|
1000
|
+
runLodEval = true;
|
|
855
1001
|
}
|
|
856
1002
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
this.
|
|
1003
|
+
if (runLodEval) {
|
|
1004
|
+
this._forceLodUpdate = false;
|
|
1005
|
+
this._framesSinceLodUpdate = 0;
|
|
1006
|
+
this.evaluateOptimalLods(this._scene.activeCamera);
|
|
1007
|
+
this._computeTargetLevels();
|
|
1008
|
+
if (this._applyDesiredLods()) {
|
|
1009
|
+
this._refreshActiveRanges();
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Updates each leaf node's {@link ISOGLODNode.inFrustum} flag from a per-node frustum test against the
|
|
1015
|
+
* active camera. When {@link frustumCulling} is disabled (or there is no camera) every node is marked
|
|
1016
|
+
* in-frustum. Bounds are static (from the LOD tree), so flags are valid for all nodes regardless of
|
|
1017
|
+
* decode state. Returns true when any node's in-frustum state changed (so the LOD bias must be re-applied).
|
|
1018
|
+
* @returns whether any node's in-frustum state changed
|
|
1019
|
+
*/
|
|
1020
|
+
_updateNodeFrustum() {
|
|
1021
|
+
const camera = this._scene.activeCamera;
|
|
1022
|
+
let changed = false;
|
|
1023
|
+
if (!this._frustumCulling || !camera) {
|
|
1024
|
+
for (const node of this._leafNodes) {
|
|
1025
|
+
if (node.inFrustum === false) {
|
|
1026
|
+
node.inFrustum = true;
|
|
1027
|
+
changed = true;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return changed;
|
|
1031
|
+
}
|
|
1032
|
+
// World-space frustum planes from the current view-projection, tested against each node's world AABB.
|
|
1033
|
+
// force=false uses the renderId/sync fast-path (still recomputes when the transform actually changed),
|
|
1034
|
+
// avoiding a full world-matrix recompute every frame for the per-node frustum test.
|
|
1035
|
+
const world = this.computeWorldMatrix(false);
|
|
1036
|
+
camera.getViewMatrix().multiplyToRef(camera.getProjectionMatrix(), this._cullViewProj);
|
|
1037
|
+
Frustum.GetPlanesToRef(this._cullViewProj, this._frustumPlanes);
|
|
1038
|
+
for (const node of this._leafNodes) {
|
|
1039
|
+
node.cullBounds.update(world);
|
|
1040
|
+
const inFrustum = node.cullBounds.isInFrustum(this._frustumPlanes);
|
|
1041
|
+
if (inFrustum !== node.inFrustum) {
|
|
1042
|
+
node.inFrustum = inFrustum;
|
|
1043
|
+
changed = true;
|
|
1044
|
+
}
|
|
863
1045
|
}
|
|
1046
|
+
return changed;
|
|
864
1047
|
}
|
|
865
1048
|
/**
|
|
866
1049
|
* Reads the splat count from SOG metadata.
|