@babylonjs/loaders 9.12.0 → 9.12.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.
- package/FBX/fbxFileLoader.d.ts +194 -0
- package/FBX/fbxFileLoader.js +2440 -0
- package/FBX/fbxFileLoader.js.map +1 -0
- package/FBX/fbxFileLoader.metadata.d.ts +11 -0
- package/FBX/fbxFileLoader.metadata.js +11 -0
- package/FBX/fbxFileLoader.metadata.js.map +1 -0
- package/FBX/index.d.ts +3 -0
- package/FBX/index.js +3 -0
- package/FBX/index.js.map +1 -0
- package/FBX/interpreter/animation.d.ts +122 -0
- package/FBX/interpreter/animation.js +648 -0
- package/FBX/interpreter/animation.js.map +1 -0
- package/FBX/interpreter/blendShapes.d.ts +44 -0
- package/FBX/interpreter/blendShapes.js +192 -0
- package/FBX/interpreter/blendShapes.js.map +1 -0
- package/FBX/interpreter/connections.d.ts +95 -0
- package/FBX/interpreter/connections.js +233 -0
- package/FBX/interpreter/connections.js.map +1 -0
- package/FBX/interpreter/fbxInterpreter.d.ts +149 -0
- package/FBX/interpreter/fbxInterpreter.js +496 -0
- package/FBX/interpreter/fbxInterpreter.js.map +1 -0
- package/FBX/interpreter/geometry.d.ts +55 -0
- package/FBX/interpreter/geometry.js +573 -0
- package/FBX/interpreter/geometry.js.map +1 -0
- package/FBX/interpreter/materials.d.ts +50 -0
- package/FBX/interpreter/materials.js +144 -0
- package/FBX/interpreter/materials.js.map +1 -0
- package/FBX/interpreter/propertyTemplates.d.ts +22 -0
- package/FBX/interpreter/propertyTemplates.js +125 -0
- package/FBX/interpreter/propertyTemplates.js.map +1 -0
- package/FBX/interpreter/rig.d.ts +20 -0
- package/FBX/interpreter/rig.js +259 -0
- package/FBX/interpreter/rig.js.map +1 -0
- package/FBX/interpreter/sceneDiagnostics.d.ts +14 -0
- package/FBX/interpreter/sceneDiagnostics.js +55 -0
- package/FBX/interpreter/sceneDiagnostics.js.map +1 -0
- package/FBX/interpreter/skeleton.d.ts +93 -0
- package/FBX/interpreter/skeleton.js +515 -0
- package/FBX/interpreter/skeleton.js.map +1 -0
- package/FBX/interpreter/transform.d.ts +21 -0
- package/FBX/interpreter/transform.js +92 -0
- package/FBX/interpreter/transform.js.map +1 -0
- package/FBX/parsers/fbxAsciiParser.d.ts +5 -0
- package/FBX/parsers/fbxAsciiParser.js +330 -0
- package/FBX/parsers/fbxAsciiParser.js.map +1 -0
- package/FBX/parsers/fbxBinaryParser.d.ts +6 -0
- package/FBX/parsers/fbxBinaryParser.js +255 -0
- package/FBX/parsers/fbxBinaryParser.js.map +1 -0
- package/FBX/parsers/zlibInflate.d.ts +7 -0
- package/FBX/parsers/zlibInflate.js +350 -0
- package/FBX/parsers/zlibInflate.js.map +1 -0
- package/FBX/types/fbxTypes.d.ts +54 -0
- package/FBX/types/fbxTypes.js +66 -0
- package/FBX/types/fbxTypes.js.map +1 -0
- package/SPLAT/gaussianSplattingStream.d.ts +341 -0
- package/SPLAT/gaussianSplattingStream.js +976 -0
- package/SPLAT/gaussianSplattingStream.js.map +1 -0
- package/SPLAT/gaussianSplattingWorkBuffer.d.ts +51 -0
- package/SPLAT/gaussianSplattingWorkBuffer.js +159 -0
- package/SPLAT/gaussianSplattingWorkBuffer.js.map +1 -0
- package/SPLAT/gaussianSplattingWorkBufferShaders.d.ts +25 -0
- package/SPLAT/gaussianSplattingWorkBufferShaders.js +255 -0
- package/SPLAT/gaussianSplattingWorkBufferShaders.js.map +1 -0
- package/SPLAT/index.d.ts +1 -0
- package/SPLAT/index.js +1 -0
- package/SPLAT/index.js.map +1 -1
- package/SPLAT/sog.js +18 -16
- package/SPLAT/sog.js.map +1 -1
- package/SPLAT/splatFileLoader.d.ts +8 -0
- package/SPLAT/splatFileLoader.js +49 -0
- package/SPLAT/splatFileLoader.js.map +1 -1
- package/dynamic.js +9 -0
- package/dynamic.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
import { GaussianSplattingMesh } from "@babylonjs/core/Meshes/GaussianSplatting/gaussianSplattingMesh.js";
|
|
2
|
+
import { Logger } from "@babylonjs/core/Misc/logger.js";
|
|
3
|
+
import { Tools } from "@babylonjs/core/Misc/tools.js";
|
|
4
|
+
import { Vector3, Matrix } from "@babylonjs/core/Maths/math.vector.js";
|
|
5
|
+
import { Color4 } from "@babylonjs/core/Maths/math.color.js";
|
|
6
|
+
import { Camera } from "@babylonjs/core/Cameras/camera.js";
|
|
7
|
+
import { BoundingInfo } from "@babylonjs/core/Culling/boundingInfo.js";
|
|
8
|
+
import { CreateLineSystem } from "@babylonjs/core/Meshes/Builders/linesBuilder.js";
|
|
9
|
+
import { VertexBuffer } from "@babylonjs/core/Buffers/buffer.js";
|
|
10
|
+
import { ParseSogMetaAsTextures } from "./sog.js";
|
|
11
|
+
import { GaussianSplattingWorkBuffer } from "./gaussianSplattingWorkBuffer.js";
|
|
12
|
+
// tan(22.5deg): reference half-FOV for a 45-degree vertical FOV, used for FOV compensation (matches PlayCanvas).
|
|
13
|
+
const RefTanHalfFov = Math.tan((22.5 * Math.PI) / 180);
|
|
14
|
+
// Scratch objects reused by the per-frame optimal-LOD evaluation (avoids per-call allocations).
|
|
15
|
+
const TmpInvWorld = new Matrix();
|
|
16
|
+
const TmpLocalCamera = new Vector3();
|
|
17
|
+
const TmpLocalForward = new Vector3();
|
|
18
|
+
const TmpWorldForward = new Vector3();
|
|
19
|
+
// Camera-local forward axis (+Z) used to derive the world-space view direction.
|
|
20
|
+
const LocalForwardAxis = new Vector3(0, 0, 1);
|
|
21
|
+
// The 12 edges of a box, as index pairs into its 8 corners. 12 edges x 2 endpoints = 24 vertices per box.
|
|
22
|
+
const BoxEdges = [
|
|
23
|
+
[0, 1],
|
|
24
|
+
[1, 2],
|
|
25
|
+
[2, 3],
|
|
26
|
+
[3, 0],
|
|
27
|
+
[4, 5],
|
|
28
|
+
[5, 6],
|
|
29
|
+
[6, 7],
|
|
30
|
+
[7, 4],
|
|
31
|
+
[0, 4],
|
|
32
|
+
[1, 5],
|
|
33
|
+
[2, 6],
|
|
34
|
+
[3, 7],
|
|
35
|
+
];
|
|
36
|
+
// Vertices generated per leaf box (BoxEdges.length * 2).
|
|
37
|
+
const VerticesPerBox = BoxEdges.length * 2;
|
|
38
|
+
/**
|
|
39
|
+
* Wireframe colors per LOD level (cycled by `node.activeLod`).
|
|
40
|
+
*/
|
|
41
|
+
const GsLodDebugColors = [
|
|
42
|
+
new Color4(1.0, 0.2, 0.2, 1.0), // LOD 0 - red
|
|
43
|
+
new Color4(1.0, 0.6, 0.1, 1.0), // LOD 1 - orange
|
|
44
|
+
new Color4(1.0, 1.0, 0.2, 1.0), // LOD 2 - yellow
|
|
45
|
+
new Color4(0.3, 1.0, 0.3, 1.0), // LOD 3 - green
|
|
46
|
+
new Color4(0.2, 1.0, 1.0, 1.0), // LOD 4 - cyan
|
|
47
|
+
new Color4(0.4, 0.5, 1.0, 1.0), // LOD 5 - blue
|
|
48
|
+
new Color4(0.9, 0.4, 1.0, 1.0), // LOD 6 - magenta
|
|
49
|
+
new Color4(1.0, 1.0, 1.0, 1.0), // LOD 7 - white
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* Streams a PlayCanvas-style SOG LOD scene (`lod-meta.json`) into a single Gaussian Splatting mesh.
|
|
53
|
+
*
|
|
54
|
+
* Each selected SOG file (plus the environment) is loaded directly as GPU textures and decoded on the
|
|
55
|
+
* GPU into one unified, PlayCanvas-style square work buffer (no CPU splat decode or `updateData`). Only
|
|
56
|
+
* the splats of each node's currently-selected LOD are rendered/sorted via the mesh's interval filter.
|
|
57
|
+
*
|
|
58
|
+
* The coarsest (least-detail) LOD of every node is streamed first as a permanent base layer so the whole
|
|
59
|
+
* scene is visible quickly with no holes. A distance-based "optimal" LOD is then computed per node (see
|
|
60
|
+
* {@link evaluateOptimalLods}); finer LOD source files are streamed on demand and a node only switches to
|
|
61
|
+
* a finer LOD once that file is decoded, so transitions never flash or leave gaps.
|
|
62
|
+
*
|
|
63
|
+
* @experimental
|
|
64
|
+
*/
|
|
65
|
+
export class GaussianSplattingStream extends GaussianSplattingMesh {
|
|
66
|
+
/**
|
|
67
|
+
* Returns true when the parsed JSON looks like a PlayCanvas-style `lod-meta.json` payload.
|
|
68
|
+
* @param data parsed JSON
|
|
69
|
+
* @returns whether the data is SOG LOD metadata
|
|
70
|
+
*/
|
|
71
|
+
static IsLODMetadata(data) {
|
|
72
|
+
if (typeof data !== "object" || data === null) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const meta = data;
|
|
76
|
+
return typeof meta.lodLevels === "number" && Array.isArray(meta.filenames) && typeof meta.tree === "object" && meta.tree !== null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Creates a new SOG LOD streaming mesh and immediately starts streaming (non-blocking).
|
|
80
|
+
* @param name mesh name
|
|
81
|
+
* @param metadata parsed `lod-meta.json`
|
|
82
|
+
* @param rootUrl base URL the metadata's relative paths resolve against
|
|
83
|
+
* @param scene hosting scene
|
|
84
|
+
* @param options streaming options
|
|
85
|
+
*/
|
|
86
|
+
constructor(name, metadata, rootUrl, scene, options = {}) {
|
|
87
|
+
super(name, null, scene, false);
|
|
88
|
+
// Flat list of leaf nodes that carry renderable LOD entries (used by the LOD heuristic and debug).
|
|
89
|
+
this._leafNodes = [];
|
|
90
|
+
// LOD heuristic parameters (PlayCanvas-aligned defaults).
|
|
91
|
+
this._lodBaseDistance = 5;
|
|
92
|
+
this._lodMultiplier = 3;
|
|
93
|
+
this._lodBehindPenalty = 1;
|
|
94
|
+
this._lodRangeMin = 0;
|
|
95
|
+
this._maxDecodesPerFrame = 1;
|
|
96
|
+
this._lodCooldownFrames = 10;
|
|
97
|
+
// Minimum frames between LOD re-evaluations, and minimum camera movement (world units) to re-evaluate.
|
|
98
|
+
this._lodUpdateInterval = 4;
|
|
99
|
+
this._lodUpdateDistance = 0.5;
|
|
100
|
+
this._maxDetailLod = 0;
|
|
101
|
+
// GPU work buffer holding all decoded splats; created once the total capacity is known.
|
|
102
|
+
this._workBuffer = null;
|
|
103
|
+
// Global splat offset where each source file begins in the work buffer (fixed for all files up front).
|
|
104
|
+
this._fileBaseSplat = new Map();
|
|
105
|
+
// Splat count of each source file (learned from its metadata before allocation).
|
|
106
|
+
this._fileCounts = new Map();
|
|
107
|
+
// Cached SOG metadata per file so on-demand decodes don't refetch the meta.json.
|
|
108
|
+
this._fileMeta = new Map();
|
|
109
|
+
// Files whose splats have been GPU-decoded into the work buffer.
|
|
110
|
+
this._decodedFiles = new Set();
|
|
111
|
+
// Files whose decode is currently in flight (dedupes concurrent requests).
|
|
112
|
+
this._loadingFiles = new Set();
|
|
113
|
+
// FIFO of file ids waiting to be decoded (drained under a per-frame budget).
|
|
114
|
+
this._decodeQueue = [];
|
|
115
|
+
// Global range covered by the environment file (always rendered), or null until it loads.
|
|
116
|
+
this._environmentRange = null;
|
|
117
|
+
// Unzipped environment bundle contents, retained between count-gathering and decode.
|
|
118
|
+
this._environmentFiles = null;
|
|
119
|
+
// Per-frame LOD streaming loop; installed once the base layer is ready.
|
|
120
|
+
this._lodObserver = null;
|
|
121
|
+
this._baseLayerReady = false;
|
|
122
|
+
// Throttling state for the per-frame LOD loop.
|
|
123
|
+
this._framesSinceLodUpdate = 0;
|
|
124
|
+
this._lastLodCamPos = new Vector3(Infinity, Infinity, Infinity);
|
|
125
|
+
// Forces the next LOD update to run regardless of the throttle (e.g. after a budget change).
|
|
126
|
+
this._forceLodUpdate = false;
|
|
127
|
+
// Running local-space bounds of all decoded splat centers (for frustum culling / picking).
|
|
128
|
+
this._boundsMin = new Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
|
|
129
|
+
this._boundsMax = new Vector3(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE);
|
|
130
|
+
// Debug LOD-node wireframe display.
|
|
131
|
+
this._debugDisplay = false;
|
|
132
|
+
this._debugLodSource = "optimal";
|
|
133
|
+
this._debugMesh = null;
|
|
134
|
+
this._debugObserver = null;
|
|
135
|
+
// Per-vertex RGBA color buffer mirror, updated in place when LOD colors change (avoids mesh rebuild flicker).
|
|
136
|
+
this._debugColorData = null;
|
|
137
|
+
// Signature of the per-leaf displayed LOD levels, used to skip rebuilding unchanged debug geometry.
|
|
138
|
+
this._debugSignature = 0;
|
|
139
|
+
this._disposed = false;
|
|
140
|
+
this._metadata = metadata;
|
|
141
|
+
this._rootUrl = rootUrl;
|
|
142
|
+
this._streamOptions = options;
|
|
143
|
+
// LOD heuristic parameters: take the provided values, otherwise keep the PlayCanvas-aligned defaults.
|
|
144
|
+
const maxLod = Math.max(0, metadata.lodLevels - 1);
|
|
145
|
+
this._lodRangeMax = maxLod;
|
|
146
|
+
if (options.lodBaseDistance !== undefined) {
|
|
147
|
+
this._lodBaseDistance = Math.max(0.1, options.lodBaseDistance);
|
|
148
|
+
}
|
|
149
|
+
if (options.lodMultiplier !== undefined) {
|
|
150
|
+
this._lodMultiplier = Math.max(1.2, options.lodMultiplier);
|
|
151
|
+
}
|
|
152
|
+
if (options.lodBehindPenalty !== undefined) {
|
|
153
|
+
this._lodBehindPenalty = Math.max(1, options.lodBehindPenalty);
|
|
154
|
+
}
|
|
155
|
+
if (options.lodRangeMin !== undefined) {
|
|
156
|
+
this._lodRangeMin = Math.max(0, Math.min(options.lodRangeMin, maxLod));
|
|
157
|
+
}
|
|
158
|
+
if (options.lodRangeMax !== undefined) {
|
|
159
|
+
this._lodRangeMax = Math.max(this._lodRangeMin, Math.min(options.lodRangeMax, maxLod));
|
|
160
|
+
}
|
|
161
|
+
if (options.maxDecodesPerFrame !== undefined) {
|
|
162
|
+
this._maxDecodesPerFrame = Math.max(1, options.maxDecodesPerFrame);
|
|
163
|
+
}
|
|
164
|
+
if (options.lodCooldownFrames !== undefined) {
|
|
165
|
+
this._lodCooldownFrames = Math.max(0, options.lodCooldownFrames);
|
|
166
|
+
}
|
|
167
|
+
if (options.lodUpdateInterval !== undefined) {
|
|
168
|
+
this._lodUpdateInterval = Math.max(1, options.lodUpdateInterval);
|
|
169
|
+
}
|
|
170
|
+
if (options.lodUpdateDistance !== undefined) {
|
|
171
|
+
this._lodUpdateDistance = Math.max(0, options.lodUpdateDistance);
|
|
172
|
+
}
|
|
173
|
+
if (options.maxDetailLod !== undefined) {
|
|
174
|
+
this._maxDetailLod = Math.max(0, Math.floor(options.maxDetailLod));
|
|
175
|
+
}
|
|
176
|
+
if (options.debugLodSource) {
|
|
177
|
+
this._debugLodSource = options.debugLodSource;
|
|
178
|
+
}
|
|
179
|
+
// PlayCanvas SOG data is authored with a flipped Y; match the standard SOG loader.
|
|
180
|
+
this.scaling.y *= -1;
|
|
181
|
+
// PlayCanvas SOG LOD scenes are authored Z-up; rotate into Babylon's Y-up convention.
|
|
182
|
+
this.rotation.x = -Math.PI / 2;
|
|
183
|
+
this._collectLodEntries(metadata.tree);
|
|
184
|
+
if (options.debugDisplay) {
|
|
185
|
+
this.debugDisplay = true;
|
|
186
|
+
}
|
|
187
|
+
// Kick off streaming without blocking the caller or the render loop.
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
|
|
189
|
+
this._streamAllAsync().catch((e) => {
|
|
190
|
+
Logger.Error("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
getClassName() {
|
|
194
|
+
return "GaussianSplattingStream";
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Finest (most detailed) LOD level any node is allowed to render. `0` allows full detail (level 0);
|
|
198
|
+
* `1` caps detail at the next-coarser level, and so on. Nodes already coarser than this cap (by
|
|
199
|
+
* distance) are unaffected. Changes take effect in real time.
|
|
200
|
+
*/
|
|
201
|
+
get maxDetailLod() {
|
|
202
|
+
return this._maxDetailLod;
|
|
203
|
+
}
|
|
204
|
+
set maxDetailLod(value) {
|
|
205
|
+
const level = Math.max(0, Math.floor(value));
|
|
206
|
+
if (this._maxDetailLod === level) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this._maxDetailLod = level;
|
|
210
|
+
// Re-evaluate LODs on the next frame regardless of the movement throttle so the change is immediate.
|
|
211
|
+
this._forceLodUpdate = true;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Coarsest LOD level index in the scene (number of LOD levels minus one). Useful as the upper bound
|
|
215
|
+
* for {@link maxDetailLod}.
|
|
216
|
+
*/
|
|
217
|
+
get maxLodLevel() {
|
|
218
|
+
return Math.max(0, this._metadata.lodLevels - 1);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* When true, renders a wireframe box per LOD node, colored by the LOD level selected by {@link debugLodSource}.
|
|
222
|
+
*/
|
|
223
|
+
get debugDisplay() {
|
|
224
|
+
return this._debugDisplay;
|
|
225
|
+
}
|
|
226
|
+
set debugDisplay(value) {
|
|
227
|
+
if (this._debugDisplay === value) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this._debugDisplay = value;
|
|
231
|
+
if (value) {
|
|
232
|
+
this._refreshDebugDisplay();
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
this._clearDebugDisplay();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Selects which LOD value drives the debug wireframe colors: the distance-based `"optimal"` LOD
|
|
240
|
+
* (default, recomputed as the camera moves) or the `"current"` streamed/rendered LOD.
|
|
241
|
+
*/
|
|
242
|
+
get debugLodSource() {
|
|
243
|
+
return this._debugLodSource;
|
|
244
|
+
}
|
|
245
|
+
set debugLodSource(value) {
|
|
246
|
+
if (this._debugLodSource === value) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this._debugLodSource = value;
|
|
250
|
+
if (this._debugDisplay) {
|
|
251
|
+
this._refreshDebugDisplay();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
dispose(doNotRecurse) {
|
|
255
|
+
this._disposed = true;
|
|
256
|
+
if (this._lodObserver) {
|
|
257
|
+
this._scene.onBeforeRenderObservable.remove(this._lodObserver);
|
|
258
|
+
this._lodObserver = null;
|
|
259
|
+
}
|
|
260
|
+
this._clearDebugDisplay();
|
|
261
|
+
this._workBuffer?.dispose();
|
|
262
|
+
this._workBuffer = null;
|
|
263
|
+
super.dispose(doNotRecurse);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Re-evaluates the optimal LOD for every node based on the camera position. The result is stored in
|
|
267
|
+
* each node's `optimalLod`. Rendering is unaffected; this currently drives only diagnostics and the
|
|
268
|
+
* debug wireframe display.
|
|
269
|
+
* @param camera camera to evaluate against (defaults to the scene's active camera)
|
|
270
|
+
*/
|
|
271
|
+
evaluateOptimalLods(camera = this._scene.activeCamera) {
|
|
272
|
+
if (!camera || this._leafNodes.length === 0) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const maxLod = Math.max(0, this._metadata.lodLevels - 1);
|
|
276
|
+
const base = this._lodBaseDistance;
|
|
277
|
+
const mult = this._lodMultiplier;
|
|
278
|
+
const behindPenalty = this._lodBehindPenalty;
|
|
279
|
+
const rangeMin = this._lodRangeMin;
|
|
280
|
+
const rangeMax = this._lodRangeMax;
|
|
281
|
+
// FOV compensation: use min(tanHalfV, tanHalfH) so transitions stay perceptually uniform (matches PlayCanvas).
|
|
282
|
+
const aspect = this._scene.getEngine().getAspectRatio(camera) || 1;
|
|
283
|
+
let tanHalfV = Math.tan(camera.fov * 0.5);
|
|
284
|
+
if (camera.fovMode === Camera.FOVMODE_HORIZONTAL_FIXED) {
|
|
285
|
+
tanHalfV /= aspect;
|
|
286
|
+
}
|
|
287
|
+
const tanHalfH = tanHalfV * aspect;
|
|
288
|
+
const fovScale = Math.min(tanHalfV, tanHalfH) / RefTanHalfFov;
|
|
289
|
+
// Transform the camera into the mesh's local space (where the node bounds live).
|
|
290
|
+
this.computeWorldMatrix(true).invertToRef(TmpInvWorld);
|
|
291
|
+
const localCamera = Vector3.TransformCoordinatesToRef(camera.globalPosition, TmpInvWorld, TmpLocalCamera);
|
|
292
|
+
const px = localCamera.x;
|
|
293
|
+
const py = localCamera.y;
|
|
294
|
+
const pz = localCamera.z;
|
|
295
|
+
let fwx = 0;
|
|
296
|
+
let fwy = 0;
|
|
297
|
+
let fwz = 0;
|
|
298
|
+
if (behindPenalty > 1) {
|
|
299
|
+
camera.getDirectionToRef(LocalForwardAxis, TmpWorldForward);
|
|
300
|
+
const localForward = Vector3.TransformNormalToRef(TmpWorldForward, TmpInvWorld, TmpLocalForward);
|
|
301
|
+
localForward.normalize();
|
|
302
|
+
fwx = localForward.x;
|
|
303
|
+
fwy = localForward.y;
|
|
304
|
+
fwz = localForward.z;
|
|
305
|
+
}
|
|
306
|
+
for (const node of this._leafNodes) {
|
|
307
|
+
const mn = node.bound.min;
|
|
308
|
+
const mx = node.bound.max;
|
|
309
|
+
// Distance from the camera to the closest point on this node's AABB (local space).
|
|
310
|
+
const qx = px < mn[0] ? mn[0] : px > mx[0] ? mx[0] : px;
|
|
311
|
+
const qy = py < mn[1] ? mn[1] : py > mx[1] ? mx[1] : py;
|
|
312
|
+
const qz = pz < mn[2] ? mn[2] : pz > mx[2] ? mx[2] : pz;
|
|
313
|
+
const dx = qx - px;
|
|
314
|
+
const dy = qy - py;
|
|
315
|
+
const dz = qz - pz;
|
|
316
|
+
const actualDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
317
|
+
// Push nodes behind the camera toward coarser LODs when a penalty is configured.
|
|
318
|
+
let penalizedDistance = actualDistance;
|
|
319
|
+
if (behindPenalty > 1 && actualDistance > 0.01) {
|
|
320
|
+
const dotOverDistance = (fwx * dx + fwy * dy + fwz * dz) / actualDistance;
|
|
321
|
+
if (dotOverDistance < 0) {
|
|
322
|
+
penalizedDistance = actualDistance * (1 + -dotOverDistance * (behindPenalty - 1));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Geometric LOD bands: threshold[k] = base * mult^(k-1).
|
|
326
|
+
const fovAdjustedDistance = penalizedDistance * fovScale;
|
|
327
|
+
let optimalLod;
|
|
328
|
+
if (maxLod === 0 || fovAdjustedDistance < base) {
|
|
329
|
+
optimalLod = 0;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
optimalLod = maxLod;
|
|
333
|
+
while (optimalLod > 1 && fovAdjustedDistance < base * Math.pow(mult, optimalLod - 1)) {
|
|
334
|
+
optimalLod--;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (optimalLod < rangeMin) {
|
|
338
|
+
optimalLod = rangeMin;
|
|
339
|
+
}
|
|
340
|
+
else if (optimalLod > rangeMax) {
|
|
341
|
+
optimalLod = rangeMax;
|
|
342
|
+
}
|
|
343
|
+
node.optimalLod = optimalLod;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The LOD level used to color a node's debug box, per {@link debugLodSource}.
|
|
348
|
+
* @param node leaf node
|
|
349
|
+
* @returns the displayed LOD level
|
|
350
|
+
*/
|
|
351
|
+
_displayedLodLevel(node) {
|
|
352
|
+
if (this._debugLodSource === "optimal") {
|
|
353
|
+
return node.optimalLod ?? node.activeLod ?? 0;
|
|
354
|
+
}
|
|
355
|
+
return node.activeLod ?? 0;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Rebuilds the debug wireframe (evaluating the optimal LOD first when needed) and wires up the per-frame
|
|
359
|
+
* recolor observer. The observer runs for both LOD sources: "optimal" colors track the camera, and
|
|
360
|
+
* "current" colors track LOD levels as they stream in/out.
|
|
361
|
+
*/
|
|
362
|
+
_refreshDebugDisplay() {
|
|
363
|
+
if (this._debugLodSource === "optimal") {
|
|
364
|
+
this.evaluateOptimalLods();
|
|
365
|
+
}
|
|
366
|
+
this._buildDebugMesh();
|
|
367
|
+
const needsObserver = this._debugDisplay;
|
|
368
|
+
if (needsObserver && !this._debugObserver) {
|
|
369
|
+
this._debugObserver = this._scene.onBeforeRenderObservable.add(() => this._onDebugFrame());
|
|
370
|
+
}
|
|
371
|
+
else if (!needsObserver && this._debugObserver) {
|
|
372
|
+
this._scene.onBeforeRenderObservable.remove(this._debugObserver);
|
|
373
|
+
this._debugObserver = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Per-frame debug update: recolors the existing wireframe in place whenever the displayed LOD levels
|
|
378
|
+
* change. For the "optimal" source the optimal LOD is recomputed first (it tracks the camera); for the
|
|
379
|
+
* "current" source the levels are driven by the streaming loop, so no recomputation is needed here. The
|
|
380
|
+
* geometry is never rebuilt, which avoids the dispose/recreate flicker while the camera moves.
|
|
381
|
+
*/
|
|
382
|
+
_onDebugFrame() {
|
|
383
|
+
if (this._debugLodSource === "optimal") {
|
|
384
|
+
this.evaluateOptimalLods();
|
|
385
|
+
}
|
|
386
|
+
if (this._computeDebugSignature() !== this._debugSignature) {
|
|
387
|
+
this._updateDebugColors();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Builds the LOD-node wireframe boxes once (one box per leaf node), colored by the displayed LOD level.
|
|
392
|
+
* The color vertex buffer is created updatable so subsequent recolors can happen in place.
|
|
393
|
+
*/
|
|
394
|
+
_buildDebugMesh() {
|
|
395
|
+
if (this._debugMesh) {
|
|
396
|
+
this._debugMesh.dispose();
|
|
397
|
+
this._debugMesh = null;
|
|
398
|
+
}
|
|
399
|
+
this._debugColorData = null;
|
|
400
|
+
const lines = [];
|
|
401
|
+
const colors = [];
|
|
402
|
+
for (const node of this._leafNodes) {
|
|
403
|
+
const color = GsLodDebugColors[this._displayedLodLevel(node) % GsLodDebugColors.length];
|
|
404
|
+
const mn = node.bound.min;
|
|
405
|
+
const mx = node.bound.max;
|
|
406
|
+
const corners = [
|
|
407
|
+
new Vector3(mn[0], mn[1], mn[2]),
|
|
408
|
+
new Vector3(mx[0], mn[1], mn[2]),
|
|
409
|
+
new Vector3(mx[0], mx[1], mn[2]),
|
|
410
|
+
new Vector3(mn[0], mx[1], mn[2]),
|
|
411
|
+
new Vector3(mn[0], mn[1], mx[2]),
|
|
412
|
+
new Vector3(mx[0], mn[1], mx[2]),
|
|
413
|
+
new Vector3(mx[0], mx[1], mx[2]),
|
|
414
|
+
new Vector3(mn[0], mx[1], mx[2]),
|
|
415
|
+
];
|
|
416
|
+
for (const edge of BoxEdges) {
|
|
417
|
+
lines.push([corners[edge[0]], corners[edge[1]]]);
|
|
418
|
+
colors.push([color, color]);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
this._debugSignature = this._computeDebugSignature();
|
|
422
|
+
if (lines.length === 0) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const mesh = CreateLineSystem(this.name + "_lodDebug", { lines, colors, updatable: true, useVertexAlpha: false }, this._scene);
|
|
426
|
+
mesh.parent = this;
|
|
427
|
+
mesh.isPickable = false;
|
|
428
|
+
mesh.doNotSerialize = true;
|
|
429
|
+
mesh.reservedDataStore = { hidden: true };
|
|
430
|
+
this._debugMesh = mesh;
|
|
431
|
+
this._debugColorData = new Float32Array(this._leafNodes.length * VerticesPerBox * 4);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Recolors the existing wireframe in place from the current displayed LOD levels, without rebuilding geometry.
|
|
435
|
+
*/
|
|
436
|
+
_updateDebugColors() {
|
|
437
|
+
if (!this._debugMesh || !this._debugColorData) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const data = this._debugColorData;
|
|
441
|
+
let offset = 0;
|
|
442
|
+
for (const node of this._leafNodes) {
|
|
443
|
+
const color = GsLodDebugColors[this._displayedLodLevel(node) % GsLodDebugColors.length];
|
|
444
|
+
for (let v = 0; v < VerticesPerBox; v++) {
|
|
445
|
+
data[offset++] = color.r;
|
|
446
|
+
data[offset++] = color.g;
|
|
447
|
+
data[offset++] = color.b;
|
|
448
|
+
data[offset++] = color.a;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
this._debugMesh.updateVerticesData(VertexBuffer.ColorKind, data);
|
|
452
|
+
this._debugSignature = this._computeDebugSignature();
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Computes a cheap 32-bit rolling hash of every leaf's displayed LOD level, used to detect when the
|
|
456
|
+
* debug wireframe needs recoloring. Avoids per-frame string allocation in the render loop.
|
|
457
|
+
* @returns a numeric signature of the current displayed LOD levels
|
|
458
|
+
*/
|
|
459
|
+
_computeDebugSignature() {
|
|
460
|
+
let hash = 0;
|
|
461
|
+
for (const node of this._leafNodes) {
|
|
462
|
+
hash = (hash * 31 + this._displayedLodLevel(node)) | 0;
|
|
463
|
+
}
|
|
464
|
+
return hash;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Disposes the LOD-node wireframe boxes and stops live debug updates.
|
|
468
|
+
*/
|
|
469
|
+
_clearDebugDisplay() {
|
|
470
|
+
if (this._debugObserver) {
|
|
471
|
+
this._scene.onBeforeRenderObservable.remove(this._debugObserver);
|
|
472
|
+
this._debugObserver = null;
|
|
473
|
+
}
|
|
474
|
+
if (this._debugMesh) {
|
|
475
|
+
this._debugMesh.dispose();
|
|
476
|
+
this._debugMesh = null;
|
|
477
|
+
}
|
|
478
|
+
this._debugColorData = null;
|
|
479
|
+
this._debugSignature = 0;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Walks the LOD tree and records every leaf that carries renderable LOD entries, capturing the set of
|
|
483
|
+
* available levels and the coarsest (base) level for each.
|
|
484
|
+
* @param node current tree node
|
|
485
|
+
*/
|
|
486
|
+
_collectLodEntries(node) {
|
|
487
|
+
if (node.children) {
|
|
488
|
+
for (const child of node.children) {
|
|
489
|
+
this._collectLodEntries(child);
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (!node.lods) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Collect all levels that hold splats (PlayCanvas convention: level 0 is the finest, higher = coarser).
|
|
497
|
+
const levels = [];
|
|
498
|
+
for (const key of Object.keys(node.lods)) {
|
|
499
|
+
const level = Number(key);
|
|
500
|
+
const entry = node.lods[key];
|
|
501
|
+
if (Number.isFinite(level) && entry && entry.count > 0) {
|
|
502
|
+
levels.push(level);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (levels.length === 0) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
levels.sort((a, b) => a - b);
|
|
509
|
+
node.availableLevels = levels;
|
|
510
|
+
node.baseLod = levels[levels.length - 1];
|
|
511
|
+
node.activeLod = undefined;
|
|
512
|
+
node.lodCooldown = 0;
|
|
513
|
+
this._leafNodes.push(node);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Streams the scene: learns every source file's splat count, allocates one unified GPU work buffer
|
|
517
|
+
* sized for all LOD files, decodes the environment and the coarsest LOD of every node as a permanent
|
|
518
|
+
* base layer, then installs the per-frame loop that streams finer LODs on demand.
|
|
519
|
+
*/
|
|
520
|
+
async _streamAllAsync() {
|
|
521
|
+
// Phase 1: learn splat counts for the environment and every referenced LOD file (cheap meta only).
|
|
522
|
+
const fileIds = this._collectAllFileIds();
|
|
523
|
+
const envCount = await this._gatherCountsAsync(fileIds);
|
|
524
|
+
if (this._disposed) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// Phase 2: assign fixed work-buffer offsets (environment first, then every file) and allocate.
|
|
528
|
+
// Index 0 is reserved as a never-decoded padding splat: the sort worker and index buffer pad unused
|
|
529
|
+
// slots with index 0, and leaving that slot zeroed (center.w = 0 => zero covariance, alpha 0) makes
|
|
530
|
+
// the padding invisible instead of ghosting a copy of the first real splat.
|
|
531
|
+
let capacity = 1;
|
|
532
|
+
if (envCount > 0) {
|
|
533
|
+
this._environmentRange = { offset: capacity, count: envCount };
|
|
534
|
+
capacity += envCount;
|
|
535
|
+
}
|
|
536
|
+
for (const fileId of fileIds) {
|
|
537
|
+
const count = this._fileCounts.get(fileId);
|
|
538
|
+
if (count === undefined || count <= 0) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
this._fileBaseSplat.set(fileId, capacity);
|
|
542
|
+
capacity += count;
|
|
543
|
+
}
|
|
544
|
+
if (capacity <= 1) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, capacity);
|
|
548
|
+
const splatPositions = new Float32Array(capacity * 4);
|
|
549
|
+
const textures = this._workBuffer.textures;
|
|
550
|
+
this._setExternalWorkBuffer(textures[0], textures[1], textures[2], textures[3], splatPositions, capacity);
|
|
551
|
+
// Nothing is active until at least one resource has been decoded.
|
|
552
|
+
this.setSplatIndexRanges([]);
|
|
553
|
+
this.setEnabled(true);
|
|
554
|
+
// Phase 3: decode the environment, then every node's coarsest LOD as the permanent base layer.
|
|
555
|
+
if (this._environmentRange && this._environmentFiles) {
|
|
556
|
+
await this._decodeEnvironmentAsync();
|
|
557
|
+
}
|
|
558
|
+
this._environmentFiles = null;
|
|
559
|
+
const baseFiles = new Set();
|
|
560
|
+
for (const node of this._leafNodes) {
|
|
561
|
+
const entry = node.lods[String(node.baseLod)];
|
|
562
|
+
if (entry && this._fileBaseSplat.has(entry.file)) {
|
|
563
|
+
baseFiles.add(entry.file);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const fileId of Array.from(baseFiles)) {
|
|
567
|
+
if (this._disposed) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// eslint-disable-next-line no-await-in-loop
|
|
571
|
+
await this._decodeFileAsync(fileId);
|
|
572
|
+
}
|
|
573
|
+
if (this._disposed) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Phase 4: hand off to the per-frame LOD streaming loop.
|
|
577
|
+
this._baseLayerReady = true;
|
|
578
|
+
if (!this._lodObserver) {
|
|
579
|
+
this._lodObserver = this._scene.onBeforeRenderObservable.add(() => this._onLodFrame());
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Collects the unique set of source file indices referenced by any LOD of any leaf, sorted ascending.
|
|
584
|
+
* @returns sorted unique file indices
|
|
585
|
+
*/
|
|
586
|
+
_collectAllFileIds() {
|
|
587
|
+
const ids = new Set();
|
|
588
|
+
for (const node of this._leafNodes) {
|
|
589
|
+
for (const level of node.availableLevels) {
|
|
590
|
+
const entry = node.lods[String(level)];
|
|
591
|
+
if (entry) {
|
|
592
|
+
ids.add(entry.file);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return Array.from(ids).sort((a, b) => a - b);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Fetches the environment bundle and every referenced file's metadata to learn splat counts, caching
|
|
600
|
+
* each file's parsed metadata for the later on-demand decode. Metadata fetches run in parallel.
|
|
601
|
+
* @param fileIds file indices to fetch metadata for
|
|
602
|
+
* @returns the environment splat count (0 when there is no environment)
|
|
603
|
+
*/
|
|
604
|
+
async _gatherCountsAsync(fileIds) {
|
|
605
|
+
let envCount = 0;
|
|
606
|
+
if (this._metadata.environment) {
|
|
607
|
+
try {
|
|
608
|
+
const url = this._rootUrl + this._metadata.environment;
|
|
609
|
+
const buffer = (await Tools.LoadFileAsync(url, true));
|
|
610
|
+
const files = await this._unzipAsync(new Uint8Array(buffer));
|
|
611
|
+
const metaBytes = files.get("meta.json");
|
|
612
|
+
if (metaBytes) {
|
|
613
|
+
const meta = JSON.parse(new TextDecoder().decode(metaBytes));
|
|
614
|
+
envCount = GaussianSplattingStream._GetSplatCount(meta);
|
|
615
|
+
this._environmentFiles = files;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
// The environment is non-essential — keep streaming the LOD tree even if it fails.
|
|
620
|
+
Logger.Warn("GaussianSplattingStream: failed to load environment: " + (e?.message ?? e));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
await Promise.all(fileIds.map(async (fileId) => {
|
|
624
|
+
const relativePath = this._metadata.filenames[fileId];
|
|
625
|
+
if (!relativePath) {
|
|
626
|
+
Logger.Warn(`GaussianSplattingStream: missing filename for file index ${fileId}.`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const metaUrl = this._rootUrl + relativePath;
|
|
631
|
+
const subRootUrl = metaUrl.substring(0, metaUrl.lastIndexOf("/") + 1);
|
|
632
|
+
const metaText = (await Tools.LoadFileAsync(metaUrl, false));
|
|
633
|
+
const sogData = JSON.parse(metaText);
|
|
634
|
+
this._fileCounts.set(fileId, GaussianSplattingStream._GetSplatCount(sogData));
|
|
635
|
+
this._fileMeta.set(fileId, { sogData, subRootUrl });
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
Logger.Warn(`GaussianSplattingStream: failed to load metadata for ${relativePath}: ${e?.message ?? e}`);
|
|
639
|
+
}
|
|
640
|
+
}));
|
|
641
|
+
return envCount;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Queues a file for on-demand decode if it isn't already decoded, in flight, or already queued.
|
|
645
|
+
* @param fileId file index to decode
|
|
646
|
+
*/
|
|
647
|
+
_enqueueDecode(fileId) {
|
|
648
|
+
if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId) || !this._fileMeta.has(fileId)) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (this._decodeQueue.indexOf(fileId) === -1) {
|
|
652
|
+
this._decodeQueue.push(fileId);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Starts up to {@link _maxDecodesPerFrame} queued decodes for this frame. Decodes run asynchronously
|
|
657
|
+
* and promote any waiting nodes once they complete.
|
|
658
|
+
*/
|
|
659
|
+
_pumpDecodeQueue() {
|
|
660
|
+
let started = 0;
|
|
661
|
+
while (this._decodeQueue.length > 0 && started < this._maxDecodesPerFrame) {
|
|
662
|
+
const fileId = this._decodeQueue.shift();
|
|
663
|
+
if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId)) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
started++;
|
|
667
|
+
// eslint-disable-next-line github/no-then
|
|
668
|
+
this._decodeFileAsync(fileId).catch((e) => {
|
|
669
|
+
Logger.Warn("GaussianSplattingStream: decode failed: " + (e?.message ?? e));
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Decodes the always-on environment bundle into its work-buffer block and activates its range.
|
|
675
|
+
*/
|
|
676
|
+
async _decodeEnvironmentAsync() {
|
|
677
|
+
if (!this._environmentRange || !this._environmentFiles || !this._workBuffer) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const range = this._environmentRange;
|
|
681
|
+
try {
|
|
682
|
+
const parsed = await ParseSogMetaAsTextures(this._environmentFiles, "", this._scene);
|
|
683
|
+
const pack = parsed.sogTextures;
|
|
684
|
+
if (!pack) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
if (this._disposed || !this._workBuffer) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
await this._workBuffer.decodeAsync(pack, range.offset);
|
|
692
|
+
if (this._disposed) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
this._splatPositions.set(pack.positions.subarray(0, range.count * 4), range.offset * 4);
|
|
696
|
+
this._updateBounds(pack.positions, range.count);
|
|
697
|
+
this._notifyWorkerNewData();
|
|
698
|
+
this._refreshActiveRanges();
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
// Always release the GPU source textures (the decode pass is the only consumer).
|
|
702
|
+
GaussianSplattingStream._DisposePack(pack);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (e) {
|
|
706
|
+
Logger.Warn("GaussianSplattingStream: failed to decode environment: " + (e?.message ?? e));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Loads one LOD source file as GPU textures, decodes it into its fixed work-buffer block, records its
|
|
711
|
+
* CPU centers for sorting, frees the source textures, then promotes any nodes that were waiting for it.
|
|
712
|
+
* Concurrent or repeat requests for the same file are ignored.
|
|
713
|
+
* @param fileId file index to decode
|
|
714
|
+
*/
|
|
715
|
+
async _decodeFileAsync(fileId) {
|
|
716
|
+
if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId)) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const meta = this._fileMeta.get(fileId);
|
|
720
|
+
const base = this._fileBaseSplat.get(fileId);
|
|
721
|
+
const count = this._fileCounts.get(fileId);
|
|
722
|
+
if (!meta || base === undefined || count === undefined) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
this._loadingFiles.add(fileId);
|
|
726
|
+
try {
|
|
727
|
+
const parsed = await ParseSogMetaAsTextures(meta.sogData, meta.subRootUrl, this._scene);
|
|
728
|
+
const pack = parsed.sogTextures;
|
|
729
|
+
if (!pack) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
if (this._disposed || !this._workBuffer) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
await this._workBuffer.decodeAsync(pack, base);
|
|
737
|
+
if (this._disposed) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
this._splatPositions.set(pack.positions.subarray(0, count * 4), base * 4);
|
|
741
|
+
this._updateBounds(pack.positions, count);
|
|
742
|
+
this._decodedFiles.add(fileId);
|
|
743
|
+
this._notifyWorkerNewData();
|
|
744
|
+
// Promote any nodes that can now reach their desired LOD via this newly decoded file.
|
|
745
|
+
if (this._applyDesiredLods()) {
|
|
746
|
+
this._refreshActiveRanges();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
finally {
|
|
750
|
+
// Always release the GPU source textures (the decode pass is the only consumer).
|
|
751
|
+
GaussianSplattingStream._DisposePack(pack);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
this._loadingFiles.delete(fileId);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Snaps a desired LOD level to the nearest level the node provides, while never selecting a level finer
|
|
760
|
+
* than {@link maxDetailLod} (i.e. with an index below the cap). Ties prefer the finer allowed level. If
|
|
761
|
+
* the node has no level at or coarser than the cap, its coarsest available level is used.
|
|
762
|
+
* @param node leaf node
|
|
763
|
+
* @param desired desired LOD level
|
|
764
|
+
* @returns the chosen available level
|
|
765
|
+
*/
|
|
766
|
+
_cappedLevelForNode(node, desired) {
|
|
767
|
+
const levels = node.availableLevels;
|
|
768
|
+
const floor = this._maxDetailLod;
|
|
769
|
+
let best = -1;
|
|
770
|
+
let bestDiff = Number.POSITIVE_INFINITY;
|
|
771
|
+
for (const level of levels) {
|
|
772
|
+
if (level < floor) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const diff = Math.abs(level - desired);
|
|
776
|
+
if (diff < bestDiff) {
|
|
777
|
+
best = level;
|
|
778
|
+
bestDiff = diff;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// No level is coarse enough to satisfy the cap: fall back to the coarsest the node has.
|
|
782
|
+
return best < 0 ? node.baseLod : best;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Computes each node's {@link ISOGLODNode.targetLevel}: the distance-based optimal level snapped to an
|
|
786
|
+
* available level, capped so no node renders finer (more detailed) than {@link maxDetailLod}.
|
|
787
|
+
*/
|
|
788
|
+
_computeTargetLevels() {
|
|
789
|
+
for (const node of this._leafNodes) {
|
|
790
|
+
const desired = node.optimalLod ?? node.baseLod;
|
|
791
|
+
node.targetLevel = this._cappedLevelForNode(node, desired);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Applies each node's {@link ISOGLODNode.targetLevel}: switches a node to its target level when that
|
|
796
|
+
* level's file is already decoded, otherwise queues the file and leaves the node on its current LOD (so
|
|
797
|
+
* nothing ever disappears). Nodes within their post-switch cooldown are left untouched to damp oscillation.
|
|
798
|
+
* @returns true when at least one node changed LOD (callers should refresh the active ranges)
|
|
799
|
+
*/
|
|
800
|
+
_applyDesiredLods() {
|
|
801
|
+
let dirty = false;
|
|
802
|
+
for (const node of this._leafNodes) {
|
|
803
|
+
if (node.lodCooldown && node.lodCooldown > 0) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const desired = node.targetLevel ?? node.baseLod;
|
|
807
|
+
if (desired === node.activeLod) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const entry = node.lods[String(desired)];
|
|
811
|
+
if (!entry) {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (this._decodedFiles.has(entry.file)) {
|
|
815
|
+
node.activeLod = desired;
|
|
816
|
+
node.lodCooldown = this._lodCooldownFrames;
|
|
817
|
+
dirty = true;
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
this._enqueueDecode(entry.file);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return dirty;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
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.
|
|
831
|
+
*/
|
|
832
|
+
_onLodFrame() {
|
|
833
|
+
if (this._disposed || !this._baseLayerReady) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
for (const node of this._leafNodes) {
|
|
837
|
+
if (node.lodCooldown && node.lodCooldown > 0) {
|
|
838
|
+
node.lodCooldown--;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// In-flight/queued decodes still progress every frame.
|
|
842
|
+
this._pumpDecodeQueue();
|
|
843
|
+
const forced = this._forceLodUpdate;
|
|
844
|
+
if (!forced) {
|
|
845
|
+
if (++this._framesSinceLodUpdate < this._lodUpdateInterval) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
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;
|
|
853
|
+
}
|
|
854
|
+
this._lastLodCamPos.copyFrom(camera.globalPosition);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
this._forceLodUpdate = false;
|
|
858
|
+
this._framesSinceLodUpdate = 0;
|
|
859
|
+
this.evaluateOptimalLods(this._scene.activeCamera);
|
|
860
|
+
this._computeTargetLevels();
|
|
861
|
+
if (this._applyDesiredLods()) {
|
|
862
|
+
this._refreshActiveRanges();
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Reads the splat count from SOG metadata.
|
|
867
|
+
* @param data SOG metadata
|
|
868
|
+
* @returns the splat count
|
|
869
|
+
*/
|
|
870
|
+
static _GetSplatCount(data) {
|
|
871
|
+
return data.count ?? (Array.isArray(data.means.shape) ? data.means.shape[0] : 0);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Disposes all GPU source textures of a SOG pack (they are only needed for the one decode pass).
|
|
875
|
+
* @param pack the SOG texture pack
|
|
876
|
+
*/
|
|
877
|
+
static _DisposePack(pack) {
|
|
878
|
+
pack.meansTextureL.dispose();
|
|
879
|
+
pack.meansTextureU.dispose();
|
|
880
|
+
pack.scalesTexture.dispose();
|
|
881
|
+
pack.quatsTexture.dispose();
|
|
882
|
+
pack.sh0Texture.dispose();
|
|
883
|
+
pack.shCentroidsTexture?.dispose();
|
|
884
|
+
pack.shLabelsTexture?.dispose();
|
|
885
|
+
pack.codebookTexture?.dispose();
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Expands the running splat-center bounds with a newly decoded file's centers and updates the
|
|
889
|
+
* mesh bounding info so the GS is correctly frustum-culled and pickable.
|
|
890
|
+
* @param positions stride-4 splat centers for the new file
|
|
891
|
+
* @param count number of splats
|
|
892
|
+
*/
|
|
893
|
+
_updateBounds(positions, count) {
|
|
894
|
+
const min = this._boundsMin;
|
|
895
|
+
const max = this._boundsMax;
|
|
896
|
+
for (let i = 0; i < count; i++) {
|
|
897
|
+
const x = positions[i * 4 + 0];
|
|
898
|
+
const y = positions[i * 4 + 1];
|
|
899
|
+
const z = positions[i * 4 + 2];
|
|
900
|
+
min.minimizeInPlaceFromFloats(x, y, z);
|
|
901
|
+
max.maximizeInPlaceFromFloats(x, y, z);
|
|
902
|
+
}
|
|
903
|
+
this.setBoundingInfo(new BoundingInfo(min, max));
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Rebuilds the active interval set from the environment plus each node's currently-selected LOD entry,
|
|
907
|
+
* coalesces adjacent ranges, and pushes the result to the sort worker.
|
|
908
|
+
*/
|
|
909
|
+
_refreshActiveRanges() {
|
|
910
|
+
const ranges = [];
|
|
911
|
+
if (this._environmentRange) {
|
|
912
|
+
ranges.push({ offset: this._environmentRange.offset, count: this._environmentRange.count });
|
|
913
|
+
}
|
|
914
|
+
for (const node of this._leafNodes) {
|
|
915
|
+
if (node.activeLod === undefined) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const entry = node.lods[String(node.activeLod)];
|
|
919
|
+
if (!entry) {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const base = this._fileBaseSplat.get(entry.file);
|
|
923
|
+
if (base === undefined) {
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
ranges.push({ offset: base + entry.offset, count: entry.count });
|
|
927
|
+
}
|
|
928
|
+
this.setSplatIndexRanges(GaussianSplattingStream._CoalesceRanges(ranges));
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Sorts and merges adjacent/overlapping ranges to keep the interval list compact.
|
|
932
|
+
* @param ranges raw ranges
|
|
933
|
+
* @returns coalesced ranges
|
|
934
|
+
*/
|
|
935
|
+
static _CoalesceRanges(ranges) {
|
|
936
|
+
if (ranges.length <= 1) {
|
|
937
|
+
return ranges;
|
|
938
|
+
}
|
|
939
|
+
const sorted = ranges.slice().sort((a, b) => a.offset - b.offset);
|
|
940
|
+
const merged = [{ offset: sorted[0].offset, count: sorted[0].count }];
|
|
941
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
942
|
+
const last = merged[merged.length - 1];
|
|
943
|
+
const range = sorted[i];
|
|
944
|
+
const lastEnd = last.offset + last.count;
|
|
945
|
+
if (range.offset <= lastEnd) {
|
|
946
|
+
const end = Math.max(lastEnd, range.offset + range.count);
|
|
947
|
+
last.count = end - last.offset;
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
merged.push({ offset: range.offset, count: range.count });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return merged;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Unzips a `.sog` bundle into a name -> bytes map, loading fflate on demand.
|
|
957
|
+
* @param data zipped bytes
|
|
958
|
+
* @returns map of entry name to bytes
|
|
959
|
+
*/
|
|
960
|
+
async _unzipAsync(data) {
|
|
961
|
+
let fflateModule = this._streamOptions.fflate;
|
|
962
|
+
if (!fflateModule) {
|
|
963
|
+
if (typeof window.fflate === "undefined") {
|
|
964
|
+
await Tools.LoadScriptAsync(this._streamOptions.deflateURL ?? "https://unpkg.com/fflate/umd/index.js");
|
|
965
|
+
}
|
|
966
|
+
fflateModule = window.fflate;
|
|
967
|
+
}
|
|
968
|
+
const unzipped = fflateModule.unzipSync(data);
|
|
969
|
+
const files = new Map();
|
|
970
|
+
for (const [name, content] of Object.entries(unzipped)) {
|
|
971
|
+
files.set(name, content);
|
|
972
|
+
}
|
|
973
|
+
return files;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
//# sourceMappingURL=gaussianSplattingStream.js.map
|