@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.
@@ -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, but throttles
299
- * the expensive LOD re-evaluation (optimal-LOD computation, budget balancing, desired-LOD application
300
- * and interval rebuild) to run at most every {@link _lodUpdateInterval} frames and only after the camera
301
- * has moved far enough, so continuous camera motion no longer rebuilds the interval set every frame. A
302
- * budget change forces a single immediate update regardless of the throttle.
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(true).invertToRef(TmpInvWorld);
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._splatPositions.set(pack.positions.subarray(0, range.count * 4), range.offset * 4);
696
- this._updateBounds(pack.positions, range.count);
697
- this._notifyWorkerNewData();
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._splatPositions.set(pack.positions.subarray(0, count * 4), base * 4);
741
- this._updateBounds(pack.positions, count);
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, but throttles
827
- * the expensive LOD re-evaluation (optimal-LOD computation, budget balancing, desired-LOD application
828
- * and interval rebuild) to run at most every {@link _lodUpdateInterval} frames and only after the camera
829
- * has moved far enough, so continuous camera motion no longer rebuilds the interval set every frame. A
830
- * budget change forces a single immediate update regardless of the throttle.
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
- const forced = this._forceLodUpdate;
844
- if (!forced) {
845
- if (++this._framesSinceLodUpdate < this._lodUpdateInterval) {
846
- return;
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
- if (camera) {
850
- const threshold = this._lodUpdateDistance;
851
- if (Vector3.DistanceSquared(camera.globalPosition, this._lastLodCamPos) < threshold * threshold) {
852
- return;
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
- this._lastLodCamPos.copyFrom(camera.globalPosition);
1000
+ runLodEval = true;
855
1001
  }
856
1002
  }
857
- this._forceLodUpdate = false;
858
- this._framesSinceLodUpdate = 0;
859
- this.evaluateOptimalLods(this._scene.activeCamera);
860
- this._computeTargetLevels();
861
- if (this._applyDesiredLods()) {
862
- this._refreshActiveRanges();
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.