@babylonjs/loaders 9.19.1 → 9.20.1

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 (52) hide show
  1. package/SPLAT/gaussianSplattingStream.d.ts +170 -1
  2. package/SPLAT/gaussianSplattingStream.js +516 -40
  3. package/SPLAT/gaussianSplattingStream.js.map +1 -1
  4. package/SPLAT/gaussianSplattingWorkBuffer.d.ts +163 -4
  5. package/SPLAT/gaussianSplattingWorkBuffer.js +683 -35
  6. package/SPLAT/gaussianSplattingWorkBuffer.js.map +1 -1
  7. package/SPLAT/gaussianSplattingWorkBufferShaders.d.ts +70 -2
  8. package/SPLAT/gaussianSplattingWorkBufferShaders.js +465 -4
  9. package/SPLAT/gaussianSplattingWorkBufferShaders.js.map +1 -1
  10. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.d.ts +16 -1
  11. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.js +232 -25
  12. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.js.map +1 -1
  13. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.d.ts +52 -2
  14. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.js +108 -7
  15. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.js.map +1 -1
  16. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.d.ts +37 -0
  17. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.js +69 -0
  18. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.js.map +1 -0
  19. package/glTF/2.0/Extensions/KHR_interactivity/interactivityReferences.d.ts +44 -0
  20. package/glTF/2.0/Extensions/KHR_interactivity/interactivityReferences.js +51 -0
  21. package/glTF/2.0/Extensions/KHR_interactivity/interactivityReferences.js.map +1 -0
  22. package/glTF/2.0/Extensions/KHR_interactivity.pure.d.ts +1 -0
  23. package/glTF/2.0/Extensions/KHR_interactivity.pure.js +143 -12
  24. package/glTF/2.0/Extensions/KHR_interactivity.pure.js.map +1 -1
  25. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js +6 -0
  26. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js.map +1 -1
  27. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js +4 -0
  28. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js.map +1 -1
  29. package/glTF/2.0/Extensions/KHR_node_visibility.pure.js +11 -0
  30. package/glTF/2.0/Extensions/KHR_node_visibility.pure.js.map +1 -1
  31. package/glTF/2.0/Extensions/babylonScenePathToObjectConverter.d.ts +126 -0
  32. package/glTF/2.0/Extensions/babylonScenePathToObjectConverter.js +251 -0
  33. package/glTF/2.0/Extensions/babylonScenePathToObjectConverter.js.map +1 -0
  34. package/glTF/2.0/Extensions/compositePathToObjectConverter.d.ts +51 -0
  35. package/glTF/2.0/Extensions/compositePathToObjectConverter.js +51 -0
  36. package/glTF/2.0/Extensions/compositePathToObjectConverter.js.map +1 -0
  37. package/glTF/2.0/Extensions/gltfPathToObjectConverter.js +69 -7
  38. package/glTF/2.0/Extensions/gltfPathToObjectConverter.js.map +1 -1
  39. package/glTF/2.0/Extensions/interactivityAssetPathToObjectConverter.d.ts +41 -0
  40. package/glTF/2.0/Extensions/interactivityAssetPathToObjectConverter.js +108 -0
  41. package/glTF/2.0/Extensions/interactivityAssetPathToObjectConverter.js.map +1 -0
  42. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.d.ts +28 -0
  43. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.js +71 -0
  44. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.js.map +1 -0
  45. package/glTF/2.0/Extensions/objectModelMapping.d.ts +118 -30
  46. package/glTF/2.0/Extensions/objectModelMapping.js +472 -70
  47. package/glTF/2.0/Extensions/objectModelMapping.js.map +1 -1
  48. package/glTF/2.0/Extensions/transmissionHelper.js +4 -0
  49. package/glTF/2.0/Extensions/transmissionHelper.js.map +1 -1
  50. package/glTF/2.0/glTFLoader.pure.js +2 -1
  51. package/glTF/2.0/glTFLoader.pure.js.map +1 -1
  52. package/package.json +3 -3
@@ -1,7 +1,7 @@
1
1
  import { GaussianSplattingMesh } from "@babylonjs/core/Meshes/GaussianSplatting/gaussianSplattingMesh.js";
2
2
  import { Logger } from "@babylonjs/core/Misc/logger.js";
3
3
  import { Tools } from "@babylonjs/core/Misc/tools.js";
4
- import { Vector3, Matrix } from "@babylonjs/core/Maths/math.vector.js";
4
+ import { Vector3, Matrix, Quaternion } from "@babylonjs/core/Maths/math.vector.js";
5
5
  import { Color4 } from "@babylonjs/core/Maths/math.color.js";
6
6
  import { Frustum } from "@babylonjs/core/Maths/math.frustum.js";
7
7
  import { Plane } from "@babylonjs/core/Maths/math.plane.js";
@@ -18,8 +18,8 @@ const RefTanHalfFov = Math.tan((22.5 * Math.PI) / 180);
18
18
  // Sentinel "file" ids for the residency controller's pinned (never-evicted) allocations.
19
19
  const PaddingFileId = -2;
20
20
  const EnvironmentFileId = -1;
21
- // Approximate bytes per resident splat used to convert a memory budget (MB) to a splat budget: the four
22
- // work-buffer textures cost 16+16+16+4 = 52 bytes on the GPU, plus ~32 bytes of CPU position/sort data.
21
+ // Core bytes per resident splat: the four work-buffer textures cost 16+16+16+4 = 52 bytes on the GPU, plus ~32
22
+ // bytes of CPU position/sort data. `_resolveResidentBudget` adds the SH and rotation/scale texture cost on top.
23
23
  const BytesPerResidentSplat = 84;
24
24
  // Scratch objects reused by the per-frame optimal-LOD evaluation (avoids per-call allocations).
25
25
  const TmpInvWorld = new Matrix();
@@ -122,6 +122,10 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
122
122
  this._cullViewProj = new Matrix();
123
123
  // GPU work buffer holding all decoded splats; created once the total capacity is known.
124
124
  this._workBuffer = null;
125
+ this._streamShDegree = 0;
126
+ this._shTextureCount = 0;
127
+ // Rotation/scale for voxel-IBL shadows. Enabled via options.needsRotationScale.
128
+ this._needsRotationScale = false;
125
129
  // True once GPU position readback has been validated against a CPU decode (see _probeReadbackAsync). While
126
130
  // false, positions are decoded on the CPU from the means images; once validated, every SOG image uses the
127
131
  // fast direct upload and positions are read back from the work buffer (non-blocking).
@@ -150,6 +154,10 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
150
154
  // Eviction streaming config: enabled only when a budget smaller than the full dataset is configured.
151
155
  this._evictionEnabled = false;
152
156
  this._residentBudget = 0;
157
+ // Raw budget options; the final `_residentBudget` is resolved from these once the SH/rotation byte cost is known
158
+ // (after the metadata pre-pass), so the memory budget accounts for the extra baked SH and rotation textures.
159
+ this._maxResidentSplats = 0;
160
+ this._memoryBudgetMb = 0;
153
161
  this._evictionCooldownFrames = 100;
154
162
  // Serializes the allocate -> decode -> readback critical section so a defrag relayout (which runs inside it)
155
163
  // never overlaps another file's decode writing the work buffer, which would corrupt the moved data.
@@ -182,9 +190,39 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
182
190
  // Signature of the per-leaf displayed LOD levels, used to skip rebuilding unchanged debug geometry.
183
191
  this._debugSignature = 0;
184
192
  this._disposed = false;
193
+ // Hosted mode: when set, the stream decodes/sorts into a reserved region of a compound mesh instead of
194
+ // rendering itself. `_host` is the reserved-part handle (resolved once the total capacity is known),
195
+ // `_positionBase` is the region's first splat index in the compound's shared position buffer.
196
+ this._hostCompound = null;
197
+ this._host = null;
198
+ this._positionBase = 0;
199
+ // Unsubscribe functions for the host's atlas-rebuild hooks (backup/restore the region across a grow).
200
+ this._unsubBeforeRebuild = null;
201
+ this._unsubAfterRebuild = null;
202
+ // Unsubscribe functions binding this controller's lifetime to its host compound: removing the part or disposing
203
+ // the compound disposes this stream, even mid-load. Registered at reservation so the window is never open.
204
+ this._hostUnsubRemove = null;
205
+ this._hostUnsubDispose = null;
206
+ // True once the host has released this stream's part (removePart, or the compound is being disposed), so dispose()
207
+ // must NOT call back into the compound to remove the part again.
208
+ this._partReleasedByHost = false;
209
+ // CPU snapshot of this region's shared `_splatPositions` taken before an atlas grow and restored after it —
210
+ // the grow rebuilds `_splatPositions` from CPU part sources, and a streamed region has none, so without this
211
+ // its sort-worker positions would be zeroed (the streamed splats would collapse to the origin).
212
+ this._positionSnapshot = null;
213
+ // Hosted mode: resolves once the reserved part exists AND its base layer has decoded (proxy bounds are
214
+ // real); rejects if streaming fails/disposes before that. Lets AddGaussianSplattingStreamPartAsync hand
215
+ // back a ready part proxy, replacing the standalone waitForEnabled/waitForStreamedBounds handshake.
216
+ this._partReadyPromise = null;
217
+ this._partReadyResolve = null;
218
+ this._partReadyReject = null;
219
+ this._partReadySettled = false;
185
220
  this._metadata = metadata;
186
221
  this._rootUrl = rootUrl;
187
222
  this._streamOptions = options;
223
+ this._hostCompound = options.hostCompound ?? null;
224
+ this._decodeSh = options.decodeSh ?? true;
225
+ this._needsRotationScale = options.needsRotationScale ?? false;
188
226
  // LOD heuristic parameters: take the provided values, otherwise keep the PlayCanvas-aligned defaults.
189
227
  const maxLod = Math.max(0, metadata.lodLevels - 1);
190
228
  this._lodRangeMax = maxLod;
@@ -227,37 +265,130 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
227
265
  if (options.evictionCooldownFrames !== undefined) {
228
266
  this._evictionCooldownFrames = Math.max(0, Math.floor(options.evictionCooldownFrames));
229
267
  }
230
- // Resolve the resident-splat budget from the splat-count and/or memory-size options (smaller wins).
231
- let budget = 0;
268
+ // Capture the raw budget options; `_residentBudget` is resolved in _streamAllAsync once the SH/rotation
269
+ // per-splat cost is known (a memory budget must count the extra baked SH and rotation textures, not just core).
232
270
  if (options.maxResidentSplats !== undefined && options.maxResidentSplats > 0) {
233
- budget = Math.floor(options.maxResidentSplats);
271
+ this._maxResidentSplats = Math.floor(options.maxResidentSplats);
234
272
  }
235
273
  if (options.memoryBudgetMb !== undefined && options.memoryBudgetMb > 0) {
236
- const fromMB = Math.floor((options.memoryBudgetMb * 1024 * 1024) / BytesPerResidentSplat);
237
- budget = budget > 0 ? Math.min(budget, fromMB) : fromMB;
274
+ this._memoryBudgetMb = options.memoryBudgetMb;
238
275
  }
239
- this._residentBudget = budget;
240
276
  this._downloadManager = new GaussianSplattingDownloadManager({
241
277
  maxConcurrent: options.maxConcurrentDownloads,
242
278
  maxRetries: options.maxDownloadRetries,
243
279
  });
244
- // PlayCanvas SOG data is authored with a flipped Y; match the standard SOG loader.
245
- this.scaling.y *= -1;
246
- // PlayCanvas SOG LOD scenes are authored Z-up; rotate into Babylon's Y-up convention.
247
- this.rotation.x = -Math.PI / 2;
280
+ // PlayCanvas SOG data is authored with a flipped Y and Z-up. Standalone: bake the orientation into this
281
+ // mesh's transform. Hosted: this mesh does not render — the orientation is applied to the reserved part's
282
+ // proxy transform instead (see _streamAllAsync), so it composes with the compound's per-part world matrix.
283
+ if (!this._hostCompound) {
284
+ this.scaling.y *= -1;
285
+ this.rotation.x = -Math.PI / 2;
286
+ }
287
+ else {
288
+ // Hidden controller: never rendered/picked/serialized; the compound renders the streamed splats.
289
+ this.setEnabled(false);
290
+ this.isPickable = false;
291
+ this.doNotSerialize = true;
292
+ // Created before _streamAllAsync is kicked off (below) so there is no resolve-before-await race.
293
+ this._partReadyPromise = new Promise((resolve, reject) => {
294
+ this._partReadyResolve = resolve;
295
+ this._partReadyReject = reject;
296
+ });
297
+ // Attach a no-op rejection handler so a caller that never awaits whenPartReadyAsync() (e.g. the synchronous
298
+ // AddGaussianSplattingStreamPart) does not produce an unhandled promise rejection on failure; real
299
+ // consumers still observe the rejection through their own await.
300
+ // eslint-disable-next-line github/no-then
301
+ this._partReadyPromise.catch(() => { });
302
+ // Bind to the host's disposal FROM CONSTRUCTION (not just from reservation): the metadata pre-pass in
303
+ // _streamAllAsync runs before the region is reserved, so a compound disposed during that download would
304
+ // otherwise be missed and the controller would reserve into a disposed host. dispose() -> _disposed, so
305
+ // _streamAllAsync's post-download check bails before reserving.
306
+ const disposeObserver = this._hostCompound.onDisposeObservable.add(() => {
307
+ if (!this._disposed) {
308
+ this._partReleasedByHost = true;
309
+ this.dispose();
310
+ }
311
+ });
312
+ this._hostUnsubDispose = () => this._hostCompound.onDisposeObservable.remove(disposeObserver);
313
+ }
248
314
  this._collectLodEntries(metadata.tree);
249
315
  if (options.debugDisplay) {
250
316
  this.debugDisplay = true;
251
317
  }
252
- // Kick off streaming without blocking the caller or the render loop.
253
- // eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
254
- this._streamAllAsync().catch((e) => {
318
+ // Kick off streaming without blocking the caller or the render loop. In hosted mode settle the part-ready
319
+ // deferred: _streamAllAsync resolves it once the base layer has decoded. If it finishes WITHOUT the part ever
320
+ // becoming ready (empty stream) or throws, dispose the controller so a hosted stream doesn't leave its work
321
+ // buffer and reserved region allocated — the synchronous AddGaussianSplattingStreamPart never awaits, so it
322
+ // can't clean up itself. `_partReadySettled` distinguishes a genuine success (leave it running) from a
323
+ // finished-but-never-ready result (dispose).
324
+ // eslint-disable-next-line github/no-then
325
+ void this._streamAllAsync().then(() => {
326
+ const becameReady = this._partReadySettled;
327
+ this._rejectPartReady("GaussianSplattingStream: stream produced no splats.");
328
+ if (!becameReady && this._hostCompound && !this._disposed) {
329
+ this._disposeAndReclaim();
330
+ }
331
+ }, (e) => {
255
332
  Logger.Error("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
333
+ this._rejectPartReady("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
334
+ if (this._hostCompound && !this._disposed) {
335
+ this._disposeAndReclaim();
336
+ }
256
337
  });
257
338
  }
258
339
  getClassName() {
259
340
  return "GaussianSplattingStream";
260
341
  }
342
+ /**
343
+ * When `_hostCompound` is set (i.e. this stream was created via {@link AddGaussianSplattingStreamPart}
344
+ * to drive a reserved region of another compound mesh, rather than rendering itself), this instance is
345
+ * disabled and never drawn — so it never runs its own depth-sort worker and the base class's readiness
346
+ * check (which waits for one) would never pass. Report ready unconditionally in that case; the host
347
+ * compound is the one actually rendering, and its own `isReady()` already covers real sort completion.
348
+ * @param completeCheck defines if a complete check (including materials and lights) has to be done (false by default)
349
+ * @returns true when ready
350
+ */
351
+ isReady(completeCheck = false) {
352
+ if (this._hostCompound) {
353
+ return true;
354
+ }
355
+ return super.isReady(completeCheck);
356
+ }
357
+ /**
358
+ * Hosted mode only: the compound part proxy this stream drives (world transform + visibility of the
359
+ * reserved region), or null before the part has been reserved (or when running standalone).
360
+ */
361
+ get streamingPartProxy() {
362
+ return this._host?.proxy ?? null;
363
+ }
364
+ /**
365
+ * Hosted mode only: resolves once the reserved part exists and its base layer has decoded (so the proxy's
366
+ * bounds are real and the part is ready to be placed/framed), or rejects if streaming fails/disposes first.
367
+ * Resolves immediately for a standalone stream. Used by {@link AddGaussianSplattingStreamPartAsync}.
368
+ * @returns a promise that settles when the hosted part is ready to use
369
+ */
370
+ async whenPartReadyAsync() {
371
+ await (this._partReadyPromise ?? Promise.resolve());
372
+ }
373
+ /** Resolves the part-ready deferred (hosted mode); no-op if already settled or standalone. */
374
+ _resolvePartReady() {
375
+ if (this._partReadySettled) {
376
+ return;
377
+ }
378
+ this._partReadySettled = true;
379
+ this._partReadyResolve?.();
380
+ }
381
+ /**
382
+ * Rejects the part-ready deferred (hosted mode); no-op if already settled or standalone.
383
+ * @param message failure reason surfaced to the awaiter
384
+ */
385
+ _rejectPartReady(message) {
386
+ if (this._partReadySettled) {
387
+ return;
388
+ }
389
+ this._partReadySettled = true;
390
+ this._partReadyReject?.(new Error(message));
391
+ }
261
392
  /**
262
393
  * Resolves once the scene is fully streamed and displayed for the current camera: a LOD re-evaluation has
263
394
  * run for the current point of view, every reachable LOD file has finished downloading and decoding (no
@@ -284,7 +415,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
284
415
  const scene = this._scene;
285
416
  let stable = 0;
286
417
  const isSettled = () => {
287
- if (this._isLoadingIdle() && this._isDepthSortSettled) {
418
+ if (this._isLoadingIdle() && this._sinkIsDepthSortSettled) {
288
419
  return ++stable >= required;
289
420
  }
290
421
  stable = 0;
@@ -416,7 +547,29 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
416
547
  }
417
548
  }
418
549
  dispose(doNotRecurse) {
550
+ if (this._disposed) {
551
+ // Idempotent: a failed load disposes from its own _streamAllAsync handler, and the awaiter's catch may
552
+ // dispose again — don't re-fire cleanup/observables (and super.dispose) a second time.
553
+ return;
554
+ }
419
555
  this._disposed = true;
556
+ this._rejectPartReady("GaussianSplattingStream: disposed before the part was ready.");
557
+ this._unsubBeforeRebuild?.();
558
+ this._unsubAfterRebuild?.();
559
+ this._unsubBeforeRebuild = null;
560
+ this._unsubAfterRebuild = null;
561
+ this._hostUnsubRemove?.();
562
+ this._hostUnsubDispose?.();
563
+ this._hostUnsubRemove = null;
564
+ this._hostUnsubDispose = null;
565
+ // If this stream disposes on its own rather than because the host removed its part, release the reserved
566
+ // region (tombstone). Reclaiming the rows is a separate compaction — cheap disposal here so tearing down N
567
+ // parts doesn't trigger N atlas rebuilds; the caller/host reclaims when appropriate (a failed load compacts
568
+ // once, see the _streamAllAsync handler). Skipped when the host removed the part (it owns that policy).
569
+ if (this._host && this._hostCompound && !this._partReleasedByHost && !this._hostCompound.isDisposed()) {
570
+ this._hostCompound.removePart(this._host.partIndex);
571
+ }
572
+ this._host = null;
420
573
  if (this._lodObserver) {
421
574
  this._scene.onBeforeRenderObservable.remove(this._lodObserver);
422
575
  this._lodObserver = null;
@@ -429,6 +582,35 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
429
582
  this._workBuffer = null;
430
583
  super.dispose(doNotRecurse);
431
584
  }
585
+ /**
586
+ * Disposes this stream (which tombstones its region) and then compacts the host once to actually reclaim the
587
+ * reserved rows. Used on a definitive load failure / empty result — a discrete, one-off reclaim, versus a bare
588
+ * {@link dispose} that only tombstones so tearing down several parts doesn't rebuild the atlas repeatedly.
589
+ */
590
+ _disposeAndReclaim() {
591
+ const compound = this._hostCompound;
592
+ const hadPart = !!this._host && !this._partReleasedByHost;
593
+ this.dispose();
594
+ if (hadPart && compound && !compound.isDisposed()) {
595
+ compound.compactAtlas();
596
+ }
597
+ }
598
+ /**
599
+ * The world matrix that actually places this stream's splats, used to map the camera into the space the
600
+ * node bounds live in (for LOD distance) and to build per-node world AABBs (for frustum culling). Standalone:
601
+ * this controller mesh carries the transform. Hosted: this controller is a hidden, unplaced node — the splats
602
+ * are placed by the reserved part's proxy (SOG up-axis basis composed with the host's placement), so LOD and
603
+ * culling MUST use the proxy's world matrix or they compute distances/frustum tests in the wrong space
604
+ * (producing wrong per-chunk LODs, i.e. holes, whenever the host applies a non-identity transform).
605
+ * @param force when true, forces a full world-matrix recompute (else uses the renderId/sync fast-path)
606
+ * @returns the effective world matrix for LOD/culling
607
+ */
608
+ _getEffectiveWorldMatrix(force) {
609
+ if (this._host) {
610
+ return this._host.proxy.computeWorldMatrix(force);
611
+ }
612
+ return this.computeWorldMatrix(force);
613
+ }
432
614
  /**
433
615
  * Re-evaluates the optimal LOD for every node based on the camera position. The result is stored in
434
616
  * each node's `optimalLod`. Rendering is unaffected; this currently drives only diagnostics and the
@@ -454,7 +636,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
454
636
  const tanHalfH = tanHalfV * aspect;
455
637
  const fovScale = Math.min(tanHalfV, tanHalfH) / RefTanHalfFov;
456
638
  // Transform the camera into the mesh's local space (where the node bounds live).
457
- this.computeWorldMatrix(false).invertToRef(TmpInvWorld);
639
+ this._getEffectiveWorldMatrix(false).invertToRef(TmpInvWorld);
458
640
  const localCamera = Vector3.TransformCoordinatesToRef(camera.globalPosition, TmpInvWorld, TmpLocalCamera);
459
641
  const px = localCamera.x;
460
642
  const py = localCamera.y;
@@ -695,13 +877,15 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
695
877
  * base layer, then installs the per-frame loop that streams finer LODs on demand.
696
878
  */
697
879
  async _streamAllAsync() {
698
- // Phase 1: learn splat counts for the environment and every referenced LOD file (cheap meta only).
880
+ // Step 1: learn splat counts for the environment and every referenced LOD file (cheap meta only). This also
881
+ // resolves the max SH degree, so the resident-splat budget can now be sized with the SH/rotation byte cost.
699
882
  const fileIds = this._collectAllFileIds();
700
883
  const envCount = await this._gatherCountsAsync(fileIds);
701
884
  if (this._disposed) {
702
885
  return;
703
886
  }
704
- // Phase 2: learn the full dataset size (padding + environment + every LOD file). The work buffer is
887
+ this._resolveResidentBudget();
888
+ // Step 2: learn the full dataset size (padding + environment + every LOD file). The work buffer is
705
889
  // sized to this unless a smaller budget enables eviction-based streaming.
706
890
  // Index 0 is reserved as a never-decoded padding splat: the sort worker and index buffer pad unused
707
891
  // slots with index 0, and leaving that slot zeroed (center.w = 0 => zero covariance, alpha 0) makes
@@ -735,17 +919,103 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
735
919
  this._environmentFiles = null;
736
920
  }
737
921
  }
738
- this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, capacity);
739
- // GPU readback is only enabled after it is validated against a CPU decode on the first file (see
740
- // _probeReadbackAsync); until then positions are decoded on the CPU so there is always a correct result.
741
- this._readbackCandidate = this._workBuffer.supportsAsyncCentersReadback;
742
- const splatPositions = new Float32Array(capacity * 4);
743
- const textures = this._workBuffer.textures;
744
- this._setExternalWorkBuffer(textures[0], textures[1], textures[2], textures[3], splatPositions, capacity);
745
- // Nothing is active until at least one resource has been decoded.
746
- this.setSplatIndexRanges([]);
747
- this.setEnabled(true);
748
- // Phase 3: decode the environment, then every node's coarsest LOD as the permanent base layer.
922
+ if (this._hostCompound) {
923
+ // Hosted: reserve a region of the compound sized to the work buffer, orient the part's proxy for the
924
+ // SOG up-axis, and decode straight into the compound's shared atlas so the streamed splats sort/draw
925
+ // in one pass with the compound's other parts. The compound owns the worker/render; this mesh stays
926
+ // a hidden controller.
927
+ const sogWorld = Matrix.Compose(new Vector3(1, -1, 1), Quaternion.RotationYawPitchRoll(0, -Math.PI / 2, 0), Vector3.ZeroReadOnly);
928
+ // Reserve with SH so the compound converts its SH textures to shared render-targetable integer MRTs and
929
+ // sets its SH degree; the hosted work buffer bakes into those shared targets at the region base offset.
930
+ const host = this._hostCompound.reserveStreamingPart(capacity, sogWorld, this.name + "_part", this._shTextureCount, this._streamShDegree, this._needsRotationScale);
931
+ this._host = host;
932
+ this._positionBase = host.base;
933
+ // Bind this controller's lifetime to its part FROM RESERVATION (not after readiness): removing the part or
934
+ // disposing the compound — even while still downloading/decoding — disposes this stream so it stops writing
935
+ // into the compound's borrowed textures. `_partReleasedByHost` stops dispose() from removing the part again.
936
+ const compound = this._hostCompound;
937
+ // The remove observer needs the assigned part index, so it is registered here (at reservation); the
938
+ // compound-disposal observer was already registered at construction (see the ctor) to cover the pre-pass.
939
+ const removeObserver = compound.onPartRemovedObservable.add((removedIndex) => {
940
+ if (!this._disposed && this._host && removedIndex === this._host.partIndex) {
941
+ this._partReleasedByHost = true;
942
+ this.dispose();
943
+ }
944
+ });
945
+ this._hostUnsubRemove = () => compound.onPartRemovedObservable.remove(removeObserver);
946
+ const shExternal = this._shTextureCount > 0 && host.shMrtAtlas ? { textureCount: this._shTextureCount, externalMrts: host.shMrtAtlas } : undefined;
947
+ const rotExternal = this._needsRotationScale && host.rotMrtAtlas ? { externalMrt: host.rotMrtAtlas } : undefined;
948
+ // Use the region's ROW-ALIGNED capacity (host.capacity), not the raw stream capacity: backup/restore/
949
+ // relayout scope to whole atlas rows, so an unaligned capacity would drop the region's partial final row.
950
+ this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, host.capacity, {
951
+ mrt: host.mrtAtlas,
952
+ width: host.atlasWidth,
953
+ baseOffset: host.base,
954
+ }, shExternal, rotExternal);
955
+ this._readbackCandidate = this._workBuffer.supportsAsyncCentersReadback;
956
+ // Write decoded centers directly into the compound's shared position buffer (offset by the region base).
957
+ this._splatPositions = host.splatPositions;
958
+ this._vertexCount = capacity;
959
+ // Preserve this region's GPU-only data when the compound grows its atlas (adding a part / another
960
+ // stream): back it up before the old atlas is disposed, then rebind + restore into the new atlas.
961
+ const wb = this._workBuffer;
962
+ this._unsubBeforeRebuild = host.onBeforeAtlasRebuild(() => {
963
+ // Back up the region's atlas texels, and snapshot its CPU positions: the grow reallocates the shared
964
+ // `_splatPositions` and rebuilds it from CPU part sources, but this region has none, so its positions
965
+ // would be lost. `this._splatPositions` is still the pre-grow array and holds the real positions.
966
+ wb.backupRegion();
967
+ this._positionSnapshot = this._splatPositions ? this._splatPositions.slice(this._positionBase * 4, (this._positionBase + this._vertexCount) * 4) : null;
968
+ });
969
+ this._unsubAfterRebuild = host.onAfterAtlasRebuild(() => {
970
+ if (host.mrtAtlas) {
971
+ wb.rebindAtlas(host.mrtAtlas);
972
+ }
973
+ // Rebind to the recreated shared SH and rotation atlases; restoreRegion() writes the backups into them.
974
+ wb.rebindShAtlas(host.shMrtAtlas);
975
+ wb.rebindRotAtlas(host.rotMrtAtlas);
976
+ // A plain grow keeps `host.base`; a compaction relocates the region to a new base. Update the base
977
+ // before restoring so the region's texels and positions land there.
978
+ this._positionBase = host.base;
979
+ wb.setBaseOffset(host.base);
980
+ wb.restoreRegion();
981
+ // Re-cache the reallocated shared array and restore the region's CPU positions at the (new) base.
982
+ this._splatPositions = host.splatPositions;
983
+ if (this._positionSnapshot && this._splatPositions) {
984
+ this._splatPositions.set(this._positionSnapshot, this._positionBase * 4);
985
+ this._positionSnapshot = null;
986
+ }
987
+ });
988
+ // Nothing active until a resource is decoded (as a range on the reserved part).
989
+ host.setActiveRanges([]);
990
+ }
991
+ else {
992
+ // Bake higher-order SH when requested and present: the work buffer owns `_shTextureCount` integer SH
993
+ // targets and the draw path lights the decoded splats with them (SH degree = max across files).
994
+ const sh = this._shTextureCount > 0 ? { textureCount: this._shTextureCount } : undefined;
995
+ // Decode rotation/scale into an owned 3-attachment half-float target when voxel-IBL shadows are requested.
996
+ const rot = this._needsRotationScale ? {} : undefined;
997
+ this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, capacity, undefined, sh, rot);
998
+ // GPU readback is only enabled after it is validated against a CPU decode on the first file (see
999
+ // _probeReadbackAsync); until then positions are decoded on the CPU so there is always a correct result.
1000
+ this._readbackCandidate = this._workBuffer.supportsAsyncCentersReadback;
1001
+ const splatPositions = new Float32Array(capacity * 4);
1002
+ const textures = this._workBuffer.textures;
1003
+ const shTextures = sh ? this._workBuffer.shTextures : undefined;
1004
+ const rotTextures = rot ? this._workBuffer.rotationTextures : undefined;
1005
+ this._setExternalWorkBuffer(textures[0], textures[1], textures[2], textures[3], splatPositions, capacity, shTextures, this._streamShDegree, rotTextures);
1006
+ // Nothing is active until at least one resource has been decoded.
1007
+ this.setSplatIndexRanges([]);
1008
+ this.setEnabled(true);
1009
+ }
1010
+ // Hosted only: compile the region's backup/restore copy shaders BEFORE decoding any data, so a later
1011
+ // grow/compaction (which synchronously backs this region up) can never race shader compilation and lose it.
1012
+ if (this._host && this._workBuffer) {
1013
+ await this._waitForCanBackupAsync(this._workBuffer);
1014
+ if (this._disposed) {
1015
+ return;
1016
+ }
1017
+ }
1018
+ // Step 3: decode the environment, then every node's coarsest LOD as the permanent base layer.
749
1019
  if (this._environmentRange && this._environmentFiles) {
750
1020
  await this._decodeEnvironmentAsync();
751
1021
  }
@@ -767,11 +1037,52 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
767
1037
  if (this._disposed) {
768
1038
  return;
769
1039
  }
770
- // Phase 4: hand off to the per-frame LOD streaming loop.
1040
+ // Step 4: hand off to the per-frame LOD streaming loop.
771
1041
  this._baseLayerReady = true;
772
1042
  if (!this._lodObserver) {
773
1043
  this._lodObserver = this._scene.onBeforeRenderObservable.add(() => this._onLodFrame());
774
1044
  }
1045
+ // Hosted: the reserved part now exists with a decoded base layer and real bounds — release awaiters.
1046
+ this._resolvePartReady();
1047
+ }
1048
+ /**
1049
+ * Waits (up to a frame cap) until the work buffer's backup/restore copy shaders are compiled, so a later
1050
+ * grow/compaction can preserve this hosted region (see {@link GaussianSplattingWorkBuffer.backupRegion}).
1051
+ * Polls per rendered frame: shader readiness here depends on the render loop (and the shared atlas can be
1052
+ * rebuilt concurrently), so this stays synchronized with the render-driven decode and always makes progress.
1053
+ * On timeout it proceeds best-effort — a subsequent grow/compaction then warns rather than blocking decode.
1054
+ * @param wb the hosted work buffer to wait on
1055
+ */
1056
+ async _waitForCanBackupAsync(wb) {
1057
+ for (let frame = 0; frame < 600 && !this._disposed; frame++) {
1058
+ if (wb.canBackup) {
1059
+ return;
1060
+ }
1061
+ // eslint-disable-next-line no-await-in-loop
1062
+ await new Promise((resolve) => this._scene.onBeforeRenderObservable.addOnce(() => resolve()));
1063
+ }
1064
+ if (!this._disposed && !wb.canBackup) {
1065
+ Logger.Warn("GaussianSplattingStream: backup/restore copy shaders did not compile in time; a grow/compaction before they are ready may drop streamed data.");
1066
+ }
1067
+ }
1068
+ /**
1069
+ * Resolves the resident-splat budget from the raw options, sizing a memory (MB) budget with the actual per-splat
1070
+ * GPU+CPU cost — core data plus the baked SH textures and rotation/scale textures when enabled — so SH/rotation
1071
+ * assets don't silently consume up to double the configured budget. Requires the SH degree (from the metadata
1072
+ * pre-pass) to be known. The smaller of the splat-count and memory budgets wins.
1073
+ */
1074
+ _resolveResidentBudget() {
1075
+ let budget = this._maxResidentSplats;
1076
+ if (this._memoryBudgetMb > 0) {
1077
+ // Per resident splat: core 84 B, + 16 B per packed-u32 SH texture, + the 3 RGBA rotation textures. The
1078
+ // work buffer uses half-float rotation textures (8 B each = 24 B) when the engine can render to them,
1079
+ // else full float (16 B each = 48 B) — match that so fallback devices aren't under-budgeted.
1080
+ const rotBytes = this._scene.getEngine().getCaps().textureHalfFloatRender ? 24 : 48;
1081
+ const bytesPerSplat = BytesPerResidentSplat + this._shTextureCount * 16 + (this._needsRotationScale ? rotBytes : 0);
1082
+ const fromMB = Math.floor((this._memoryBudgetMb * 1024 * 1024) / bytesPerSplat);
1083
+ budget = budget > 0 ? Math.min(budget, fromMB) : fromMB;
1084
+ }
1085
+ this._residentBudget = budget;
775
1086
  }
776
1087
  /**
777
1088
  * Collects the unique set of source file indices referenced by any LOD of any leaf, sorted ascending.
@@ -797,6 +1108,19 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
797
1108
  */
798
1109
  async _gatherCountsAsync(fileIds) {
799
1110
  let envCount = 0;
1111
+ // Track the max SH degree/coeffs across every streamed file (+ environment): the baked SH atlas is sized
1112
+ // for the max once, up front, so no mid-stream resize — lower-degree files neutral-fill their higher bands.
1113
+ let maxShDegree = 0;
1114
+ let maxCoeffs = 0;
1115
+ const foldSh = (data) => {
1116
+ const info = GaussianSplattingStream._GetShInfo(data);
1117
+ if (info.degree > maxShDegree) {
1118
+ maxShDegree = info.degree;
1119
+ }
1120
+ if (info.coeffs > maxCoeffs) {
1121
+ maxCoeffs = info.coeffs;
1122
+ }
1123
+ };
800
1124
  if (this._metadata.environment) {
801
1125
  try {
802
1126
  const url = this._rootUrl + this._metadata.environment;
@@ -806,6 +1130,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
806
1130
  if (metaBytes) {
807
1131
  const meta = JSON.parse(new TextDecoder().decode(metaBytes));
808
1132
  envCount = GaussianSplattingStream._GetSplatCount(meta);
1133
+ foldSh(meta);
809
1134
  this._environmentFiles = files;
810
1135
  }
811
1136
  }
@@ -832,6 +1157,16 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
832
1157
  Logger.Warn(`GaussianSplattingStream: failed to load metadata for ${relativePath}: ${e?.message ?? e}`);
833
1158
  }
834
1159
  }));
1160
+ // Fold in every file's SH (done after the parallel fetch so _fileMeta is fully populated).
1161
+ for (const { sogData } of this._fileMeta.values()) {
1162
+ foldSh(sogData);
1163
+ }
1164
+ // Resolve the stream's baked-SH configuration: enabled only when requested AND the data carries shN.
1165
+ if (this._decodeSh && maxShDegree > 0 && maxCoeffs > 0) {
1166
+ this._streamShDegree = maxShDegree;
1167
+ // Packed-u32 SH textures: 16 SH scalar-bytes per texel, 3 channels per coefficient (matches ParseSogDatas).
1168
+ this._shTextureCount = Math.ceil((maxCoeffs * 3) / 16);
1169
+ }
835
1170
  return envCount;
836
1171
  }
837
1172
  /**
@@ -872,10 +1207,50 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
872
1207
  * @param count number of splats in the range
873
1208
  */
874
1209
  _applyPositions(positions, base, count) {
875
- this._splatPositions.set(positions, base * 4);
1210
+ // In hosted mode _splatPositions is the compound's shared buffer; the region starts at _positionBase.
1211
+ this._splatPositions.set(positions, (this._positionBase + base) * 4);
876
1212
  this._updateBounds(positions, count);
877
1213
  // Incrementally patch only this range in the sort worker (avoids the full position-buffer re-copy).
878
- this._postWorkerPositionsRange(base, count);
1214
+ this._sinkPostPositionsRange(base, count);
1215
+ }
1216
+ // ---- Sink routing: standalone drives this mesh; hosted drives the compound's reserved-part handle. ----
1217
+ /**
1218
+ * Sets the active source ranges (local to the stream's buffer) on the render sink.
1219
+ * @param localRanges active ranges in the stream's local index space
1220
+ */
1221
+ _sinkSetActiveRanges(localRanges) {
1222
+ if (this._host) {
1223
+ this._host.setActiveRanges(localRanges);
1224
+ }
1225
+ else {
1226
+ this.setSplatIndexRanges(localRanges);
1227
+ }
1228
+ }
1229
+ /**
1230
+ * Patches a decoded position range (local offset) into the render sink's sort worker.
1231
+ * @param base first splat index of the range, local to the stream's buffer
1232
+ * @param count number of splats in the range
1233
+ */
1234
+ _sinkPostPositionsRange(base, count) {
1235
+ if (this._host) {
1236
+ this._host.postPositionsRange(base, count);
1237
+ }
1238
+ else {
1239
+ this._postWorkerPositionsRange(base, count);
1240
+ }
1241
+ }
1242
+ /** Re-posts the full position/part set to the render sink's worker (after a relayout moved the region). */
1243
+ _sinkNotifyDataChanged() {
1244
+ if (this._host) {
1245
+ this._host.notifyDataChanged();
1246
+ }
1247
+ else {
1248
+ this._notifyWorkerNewData();
1249
+ }
1250
+ }
1251
+ /** Whether the render sink's depth sort is settled. */
1252
+ get _sinkIsDepthSortSettled() {
1253
+ return this._host ? this._host.isDepthSortSettled : this._isDepthSortSettled;
879
1254
  }
880
1255
  /**
881
1256
  * One-time validation of GPU position readback: reads a sample of the just-decoded range back from the work
@@ -1157,13 +1532,15 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1157
1532
  this._workBuffer.relayoutSync(srcIndexByDst);
1158
1533
  // CPU positions: compaction only ever moves a block to a lower offset, so copying in place in ascending
1159
1534
  // new-offset order is safe (a block's source is never overwritten by an earlier move). This avoids a
1160
- // full capacity*4 scratch buffer.
1535
+ // full capacity*4 scratch buffer. Block offsets are region-local; in hosted mode `_splatPositions` is the
1536
+ // compound-wide buffer, so shift both source and destination by the region base (`_positionBase`, 0 standalone).
1161
1537
  const positions = this._splatPositions;
1538
+ const base = this._positionBase;
1162
1539
  resident.sort((a, b) => a.offset - b.offset);
1163
1540
  for (const block of resident) {
1164
1541
  const oldOffset = oldOffsets.get(block.file);
1165
1542
  if (oldOffset !== block.offset) {
1166
- positions.copyWithin(block.offset * 4, oldOffset * 4, (oldOffset + block.count) * 4);
1543
+ positions.copyWithin((base + block.offset) * 4, (base + oldOffset) * 4, (base + oldOffset + block.count) * 4);
1167
1544
  }
1168
1545
  }
1169
1546
  // Update the environment offset (it may have moved), re-post to the sort worker, and refresh ranges.
@@ -1173,7 +1550,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1173
1550
  this._environmentRange.offset = envOffset;
1174
1551
  }
1175
1552
  }
1176
- this._notifyWorkerNewData();
1553
+ this._sinkNotifyDataChanged();
1177
1554
  this._refreshActiveRanges();
1178
1555
  }
1179
1556
  /**
@@ -1414,7 +1791,7 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1414
1791
  // World-space frustum planes from the current view-projection, tested against each node's world AABB.
1415
1792
  // force=false uses the renderId/sync fast-path (still recomputes when the transform actually changed),
1416
1793
  // avoiding a full world-matrix recompute every frame for the per-node frustum test.
1417
- const world = this.computeWorldMatrix(false);
1794
+ const world = this._getEffectiveWorldMatrix(false);
1418
1795
  camera.getViewMatrix().multiplyToRef(camera.getProjectionMatrix(), this._cullViewProj);
1419
1796
  Frustum.GetPlanesToRef(this._cullViewProj, this._frustumPlanes);
1420
1797
  for (const node of this._leafNodes) {
@@ -1435,6 +1812,38 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1435
1812
  static _GetSplatCount(data) {
1436
1813
  return data.count ?? (Array.isArray(data.means.shape) ? data.means.shape[0] : 0);
1437
1814
  }
1815
+ /**
1816
+ * Reads a SOG file's higher-order SH degree and coefficient count from its metadata, mirroring
1817
+ * {@link ParseSogDatas}'s `coeffs`/`shDegree` derivation. Returns zeros when the file carries no `shN`.
1818
+ * @param data parsed SOG root metadata
1819
+ * @returns the SH degree and higher-order coefficient count (excludes the DC/SH0 term)
1820
+ */
1821
+ static _GetShInfo(data) {
1822
+ if (!data.shN) {
1823
+ return { degree: 0, coeffs: 0 };
1824
+ }
1825
+ // Derive the SH degree from remote (untrusted) metadata, then validate/clamp it: the degree drives the SH
1826
+ // render-target count and decode-pass count, so a bogus (huge / non-finite / negative) `bands` or `shape`
1827
+ // must not be able to demand unbounded allocation. The draw path supports shTexture0..4, i.e. degree <= 4.
1828
+ const maxDegree = 4;
1829
+ let degree = 0;
1830
+ const bands = data.shN.bands;
1831
+ if (typeof bands === "number" && Number.isFinite(bands) && bands > 0) {
1832
+ degree = Math.floor(bands);
1833
+ }
1834
+ else if (Array.isArray(data.shN.shape) && Number.isFinite(data.shN.shape[1]) && data.shN.shape[1] > 0) {
1835
+ const shapeCoeffs = Math.floor(data.shN.shape[1] / 3);
1836
+ degree = shapeCoeffs > 0 ? Math.round(Math.sqrt(shapeCoeffs + 1) - 1) : 0;
1837
+ }
1838
+ if (!(degree > 0)) {
1839
+ return { degree: 0, coeffs: 0 };
1840
+ }
1841
+ if (degree > maxDegree) {
1842
+ Logger.Warn(`GaussianSplattingStream: SH degree ${degree} exceeds the maximum supported (${maxDegree}); clamping.`);
1843
+ degree = maxDegree;
1844
+ }
1845
+ return { degree, coeffs: (degree + 1) ** 2 - 1 };
1846
+ }
1438
1847
  /**
1439
1848
  * Disposes all GPU source textures of a SOG pack (they are only needed for the one decode pass).
1440
1849
  * @param pack the SOG texture pack
@@ -1465,7 +1874,13 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1465
1874
  min.minimizeInPlaceFromFloats(x, y, z);
1466
1875
  max.maximizeInPlaceFromFloats(x, y, z);
1467
1876
  }
1468
- this.setBoundingInfo(new BoundingInfo(min, max));
1877
+ // Hosted: grow the reserved part's (and compound's) bounds. Standalone: set this mesh's bounds.
1878
+ if (this._host) {
1879
+ this._host.expandBounds(min, max);
1880
+ }
1881
+ else {
1882
+ this.setBoundingInfo(new BoundingInfo(min, max));
1883
+ }
1469
1884
  }
1470
1885
  /**
1471
1886
  * Rebuilds the active interval set from the environment plus each node's currently-selected LOD entry,
@@ -1490,7 +1905,8 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1490
1905
  }
1491
1906
  ranges.push({ offset: base + entry.offset, count: entry.count });
1492
1907
  }
1493
- this.setSplatIndexRanges(GaussianSplattingStream._CoalesceRanges(ranges));
1908
+ // Ranges are local to the stream's buffer; the sink (compound handle) offsets them by the region base.
1909
+ this._sinkSetActiveRanges(GaussianSplattingStream._CoalesceRanges(ranges));
1494
1910
  }
1495
1911
  /**
1496
1912
  * Sorts and merges adjacent/overlapping ranges to keep the interval list compact.
@@ -1538,4 +1954,64 @@ export class GaussianSplattingStream extends GaussianSplattingMesh {
1538
1954
  return files;
1539
1955
  }
1540
1956
  }
1957
+ /**
1958
+ * Adds a PlayCanvas-style SOG LOD stream as a part of a compound Gaussian Splatting mesh, so the streamed
1959
+ * splats are depth-sorted and rendered in ONE pass together with the compound's other (static) parts.
1960
+ *
1961
+ * The returned mesh is a hidden controller: it streams SOG LOD files, GPU-decodes them into a reserved region
1962
+ * of the compound's shared atlas, and drives which of its splats are active (LOD) — the compound owns the sort
1963
+ * and the single instanced draw. The SOG up-axis orientation is applied to the reserved part's proxy transform;
1964
+ * move/hide the part via the proxy (`streamController` exposes it once streaming has started).
1965
+ * @param compound the compound mesh to add the streamed part to
1966
+ * @param name name for the streaming controller / part
1967
+ * @param metadata parsed `lod-meta.json`
1968
+ * @param rootUrl base URL the metadata's relative paths resolve against
1969
+ * @param options streaming options
1970
+ * @returns the streaming controller mesh (hidden; drives the reserved compound part)
1971
+ * @experimental
1972
+ */
1973
+ export function AddGaussianSplattingStreamPart(compound, name, metadata, rootUrl, options = {}) {
1974
+ return new GaussianSplattingStream(name, metadata, rootUrl, compound.getScene(), { ...options, hostCompound: compound });
1975
+ }
1976
+ /**
1977
+ * Adds a PlayCanvas-style SOG LOD stream as a part of a compound Gaussian Splatting mesh and resolves once the
1978
+ * part is ready to use, returning its {@link GaussianSplattingPartProxyMesh} — the same handle
1979
+ * `GaussianSplattingCompoundMesh.addPart` returns for a static part. This lets a host application treat a
1980
+ * streamed splat exactly like any other compound part (place/frame/gizmo via the proxy, remove via
1981
+ * `compound.removePart(proxy.partIndex)`); the streaming controller lives behind the proxy and is disposed
1982
+ * automatically when the part is removed.
1983
+ *
1984
+ * Resolves after the reserved region exists and its base layer has decoded (so the proxy's bounds are real),
1985
+ * and rejects if streaming fails before that (the partially-constructed stream is disposed on rejection).
1986
+ *
1987
+ * NOTE: the base-layer decode runs on the GPU inside the scene's render loop, so this promise only resolves once
1988
+ * the scene is rendering. Do not `await` it before the render loop has started (it would never resolve) — start
1989
+ * rendering (e.g. `engine.runRenderLoop`) first, or `await` it concurrently with the first frames.
1990
+ * @param compound the compound mesh to add the streamed part to
1991
+ * @param name name for the streaming controller / part
1992
+ * @param metadata parsed `lod-meta.json`
1993
+ * @param rootUrl base URL the metadata's relative paths resolve against
1994
+ * @param options streaming options
1995
+ * @returns the part proxy driving the streamed region, ready to place/frame
1996
+ * @experimental
1997
+ */
1998
+ export async function AddGaussianSplattingStreamPartAsync(compound, name, metadata, rootUrl, options = {}) {
1999
+ const stream = new GaussianSplattingStream(name, metadata, rootUrl, compound.getScene(), { ...options, hostCompound: compound });
2000
+ try {
2001
+ await stream.whenPartReadyAsync();
2002
+ }
2003
+ catch (e) {
2004
+ stream.dispose();
2005
+ throw e;
2006
+ }
2007
+ const proxy = stream.streamingPartProxy;
2008
+ if (!proxy) {
2009
+ stream.dispose();
2010
+ throw new Error("GaussianSplattingStream: streaming part was not reserved.");
2011
+ }
2012
+ // The controller already binds its own lifetime to the part (removePart / compound disposal dispose the stream,
2013
+ // and stream disposal releases the reserved part) — wired at reservation inside the stream, so it also covers the
2014
+ // download/decode window before this promise resolves. Nothing to register here.
2015
+ return proxy;
2016
+ }
1541
2017
  //# sourceMappingURL=gaussianSplattingStream.js.map