@needle-tools/gltf-progressive 4.0.0-alpha → 4.0.0-alpha.2

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 (56) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/LICENSE +21 -0
  3. package/README.md +47 -11
  4. package/examples/modelviewer-multiple.html +4 -4
  5. package/examples/modelviewer.html +4 -4
  6. package/examples/offscreen/index.html +15 -0
  7. package/examples/offscreen/main.js +98 -0
  8. package/examples/react-three-fiber/index.html +2 -2
  9. package/examples/react-three-fiber/package-lock.json +482 -484
  10. package/examples/react-three-fiber/package.json +4 -4
  11. package/examples/react-three-fiber/src/App.tsx +76 -21
  12. package/examples/react-three-fiber/src/styles.css +2 -4
  13. package/examples/react-three-fiber/vite.config.js +2 -2
  14. package/examples/shared/example-utils.js +297 -0
  15. package/examples/shared/example.css +44 -0
  16. package/examples/shared/runtime.js +34 -0
  17. package/examples/threejs/index.html +6 -10
  18. package/examples/threejs/main.js +5 -23
  19. package/examples/webgpu/index.html +15 -0
  20. package/examples/webgpu/main.js +105 -0
  21. package/examples/worker-rendering/index.html +15 -0
  22. package/examples/worker-rendering/main.js +109 -0
  23. package/examples/worker-rendering/worker.js +166 -0
  24. package/gltf-progressive.js +1924 -0
  25. package/gltf-progressive.min.js +10 -0
  26. package/gltf-progressive.umd.cjs +10 -0
  27. package/lib/extension.d.ts +208 -0
  28. package/lib/extension.js +1373 -0
  29. package/lib/extension.model.d.ts +33 -0
  30. package/lib/extension.model.js +1 -0
  31. package/lib/index.d.ts +22 -0
  32. package/lib/index.js +87 -0
  33. package/lib/loaders.d.ts +41 -0
  34. package/lib/loaders.js +176 -0
  35. package/lib/lods.debug.d.ts +4 -0
  36. package/lib/lods.debug.js +43 -0
  37. package/lib/lods.manager.d.ts +243 -0
  38. package/lib/lods.manager.js +923 -0
  39. package/lib/lods.promise.d.ts +68 -0
  40. package/lib/lods.promise.js +108 -0
  41. package/lib/plugins/index.d.ts +2 -0
  42. package/lib/plugins/index.js +1 -0
  43. package/lib/plugins/modelviewer.d.ts +1 -0
  44. package/lib/plugins/modelviewer.js +223 -0
  45. package/lib/plugins/plugin.d.ts +23 -0
  46. package/lib/plugins/plugin.js +5 -0
  47. package/lib/utils.d.ts +30 -0
  48. package/lib/utils.internal.d.ts +68 -0
  49. package/lib/utils.internal.js +253 -0
  50. package/lib/utils.js +82 -0
  51. package/lib/version.d.ts +1 -0
  52. package/lib/version.js +4 -0
  53. package/lib/worker/gltf-progressive.worker.js +165 -0
  54. package/lib/worker/loader.mainthread.d.ts +45 -0
  55. package/lib/worker/loader.mainthread.js +195 -0
  56. package/package.json +13 -19
@@ -0,0 +1,923 @@
1
+ import * as THREE from "three";
2
+ import { Box3, Color, Matrix4, Mesh, Sphere, Vector3 } from "three";
3
+ import { NEEDLE_progressive } from "./extension.js";
4
+ import { createLoaders } from "./loaders.js";
5
+ import { getParam, isDevelopmentServer, isMobileDevice } from "./utils.internal.js";
6
+ import { plugins } from "./plugins/plugin.js";
7
+ import { getRaycastMesh } from "./utils.js";
8
+ import { applyDebugSettings, debug, debug_OverrideLodLevel } from "./lods.debug.js";
9
+ import { PromiseGroup } from "./lods.promise.js";
10
+ const debugProgressiveLoading = getParam("debugprogressive");
11
+ const debugProgressiveLODColors = debugProgressiveLoading === "colors";
12
+ const suppressProgressiveLoading = getParam("noprogressive");
13
+ const $lodsManager = Symbol("Needle:LODSManager");
14
+ const $lodstate = Symbol("Needle:LODState");
15
+ const $currentLOD = Symbol("Needle:CurrentLOD");
16
+ const ThreeRuntime = THREE;
17
+ const levels = { mesh_lod: -1, texture_lod: -1 };
18
+ const debugLODColor = new Color();
19
+ export const lodDebugColors = [
20
+ 0x35d05f,
21
+ 0xa8d83a,
22
+ 0xf3d13b,
23
+ 0xf29332,
24
+ 0xf0523b,
25
+ 0xa856f0,
26
+ 0x49a7f2,
27
+ 0x32d7c4,
28
+ 0xff6b9d,
29
+ 0x6f7df7,
30
+ 0xd66fd2,
31
+ 0x35a853,
32
+ 0xb7a51f,
33
+ 0xe05d2f,
34
+ 0x3c78d8,
35
+ 0x00a6a6,
36
+ 0xd7263d,
37
+ 0x7f52ff,
38
+ 0x46b450,
39
+ 0xf7a531,
40
+ 0x2f9be0,
41
+ 0xb84592,
42
+ 0x8a9a2a,
43
+ 0x1f6f8b,
44
+ 0xf05a9d,
45
+ 0x9b5de5,
46
+ 0x00bbf9,
47
+ 0x00f5d4,
48
+ 0xfee440,
49
+ 0xf15bb5,
50
+ 0x4d908e,
51
+ 0x555555,
52
+ ];
53
+ function createLODTimer() {
54
+ const Timer = ThreeRuntime.Timer || ThreeRuntime.Clock;
55
+ return new Timer();
56
+ }
57
+ const _meshLODWorldBox = new Box3();
58
+ const _meshLODProjectedBox = new Box3();
59
+ const _meshLODCameraSpaceBox = new Box3();
60
+ const _meshLODBoxSize = new Vector3();
61
+ const _meshLODCameraSpaceBoxSize = new Vector3();
62
+ const _meshLODProjectionInverse = new Matrix4();
63
+ const _meshLODCorner0 = new Vector3();
64
+ const _meshLODCorner1 = new Vector3();
65
+ const _meshLODCorner2 = new Vector3();
66
+ const _meshLODCorner3 = new Vector3();
67
+ function isInsideProjectedBox(box, projectionScreenMatrix) {
68
+ const min = box.min;
69
+ const max = box.max;
70
+ const centerx = (min.x + max.x) * 0.5;
71
+ const centery = (min.y + max.y) * 0.5;
72
+ const point = _meshLODCorner0.set(centerx, centery, min.z).applyMatrix4(projectionScreenMatrix);
73
+ return point.z < 0;
74
+ }
75
+ export function calculateMeshLODLevel(options) {
76
+ const { geometry, matrixWorld, camera, projectionScreenMatrix, desiredDensity, canvasHeight = 0, currentLevel = -1, xrEnabled = false, debugDrawLine, warnMissingPrimitiveDensities = false, } = options;
77
+ const meshLods = NEEDLE_progressive.getMeshLODExtension(geometry)?.lods;
78
+ const primitiveIndex = NEEDLE_progressive.getPrimitiveIndex(geometry);
79
+ const result = options.target ?? {
80
+ level: currentLevel,
81
+ primitiveIndex,
82
+ screenCoverage: 0,
83
+ screenspaceVolume: new Vector3(),
84
+ centrality: 1,
85
+ };
86
+ result.level = currentLevel;
87
+ result.primitiveIndex = primitiveIndex;
88
+ result.screenCoverage = 0;
89
+ result.screenspaceVolume.set(0, 0, 0);
90
+ result.centrality = 1;
91
+ // Note: we intentionally do NOT early-return when there are no mesh LODs.
92
+ // The screen coverage / screenspace volume computed below is also consumed by
93
+ // the texture LOD selection, which must keep working for meshes that only have
94
+ // texture LODs (no mesh LODs). Only the mesh LOD-level selection loop is skipped.
95
+ let boundingBox = options.boundingBox ?? geometry.boundingBox;
96
+ if (!boundingBox) {
97
+ geometry.computeBoundingBox();
98
+ boundingBox = geometry.boundingBox;
99
+ }
100
+ if (!boundingBox)
101
+ return result;
102
+ _meshLODWorldBox.copy(boundingBox).applyMatrix4(matrixWorld);
103
+ if (camera.isPerspectiveCamera && isInsideProjectedBox(_meshLODWorldBox, projectionScreenMatrix)) {
104
+ result.level = 0;
105
+ result.screenCoverage = Infinity;
106
+ result.screenspaceVolume.set(Infinity, Infinity, Infinity);
107
+ return result;
108
+ }
109
+ _meshLODProjectedBox.copy(_meshLODWorldBox).applyMatrix4(projectionScreenMatrix);
110
+ if (xrEnabled && camera.isPerspectiveCamera && camera.fov > 70) {
111
+ const min = _meshLODProjectedBox.min;
112
+ const max = _meshLODProjectedBox.max;
113
+ let minX = min.x;
114
+ let minY = min.y;
115
+ let maxX = max.x;
116
+ let maxY = max.y;
117
+ const enlargementFactor = 2.0;
118
+ const centerBoost = 1.5;
119
+ const centerX = (min.x + max.x) * 0.5;
120
+ const centerY = (min.y + max.y) * 0.5;
121
+ minX = (minX - centerX) * enlargementFactor + centerX;
122
+ minY = (minY - centerY) * enlargementFactor + centerY;
123
+ maxX = (maxX - centerX) * enlargementFactor + centerX;
124
+ maxY = (maxY - centerY) * enlargementFactor + centerY;
125
+ const xCentrality = minX < 0 && maxX > 0 ? 0 : Math.min(Math.abs(min.x), Math.abs(max.x));
126
+ const yCentrality = minY < 0 && maxY > 0 ? 0 : Math.min(Math.abs(min.y), Math.abs(max.y));
127
+ const centrality = Math.max(xCentrality, yCentrality);
128
+ result.centrality = (centerBoost - centrality) * (centerBoost - centrality) * (centerBoost - centrality);
129
+ }
130
+ const boxSize = _meshLODProjectedBox.getSize(_meshLODBoxSize);
131
+ boxSize.multiplyScalar(0.5);
132
+ if (globalThis.screen?.availHeight > 0 && canvasHeight > 0) {
133
+ boxSize.multiplyScalar(canvasHeight / globalThis.screen.availHeight);
134
+ }
135
+ if (camera.isPerspectiveCamera) {
136
+ boxSize.x *= camera.aspect;
137
+ }
138
+ _meshLODCameraSpaceBox.copy(boundingBox).applyMatrix4(matrixWorld).applyMatrix4(camera.matrixWorldInverse);
139
+ const cameraSpaceSize = _meshLODCameraSpaceBox.getSize(_meshLODCameraSpaceBoxSize);
140
+ const screenMax = Math.max(boxSize.x, boxSize.y);
141
+ const cameraSpaceMax = Math.max(cameraSpaceSize.x, cameraSpaceSize.y);
142
+ if (screenMax !== 0 && cameraSpaceMax !== 0) {
143
+ boxSize.z = cameraSpaceSize.z / cameraSpaceMax * screenMax;
144
+ }
145
+ const screenCoverage = Math.max(boxSize.x, boxSize.y, boxSize.z) * result.centrality;
146
+ result.screenCoverage = screenCoverage;
147
+ result.screenspaceVolume.copy(boxSize);
148
+ if (screenCoverage <= 0)
149
+ return result;
150
+ if (debugDrawLine) {
151
+ const mat = _meshLODProjectionInverse.copy(projectionScreenMatrix);
152
+ mat.invert();
153
+ _meshLODCorner0.copy(_meshLODProjectedBox.min);
154
+ _meshLODCorner1.copy(_meshLODProjectedBox.max);
155
+ _meshLODCorner1.x = _meshLODCorner0.x;
156
+ _meshLODCorner2.copy(_meshLODProjectedBox.max);
157
+ _meshLODCorner2.y = _meshLODCorner0.y;
158
+ _meshLODCorner3.copy(_meshLODProjectedBox.max);
159
+ const z = (_meshLODCorner0.z + _meshLODCorner3.z) * 0.5;
160
+ _meshLODCorner0.z = _meshLODCorner1.z = _meshLODCorner2.z = _meshLODCorner3.z = z;
161
+ _meshLODCorner0.applyMatrix4(mat);
162
+ _meshLODCorner1.applyMatrix4(mat);
163
+ _meshLODCorner2.applyMatrix4(mat);
164
+ _meshLODCorner3.applyMatrix4(mat);
165
+ debugDrawLine(_meshLODCorner0, _meshLODCorner1, 0x0000ff);
166
+ debugDrawLine(_meshLODCorner0, _meshLODCorner2, 0x0000ff);
167
+ debugDrawLine(_meshLODCorner1, _meshLODCorner3, 0x0000ff);
168
+ debugDrawLine(_meshLODCorner2, _meshLODCorner3, 0x0000ff);
169
+ }
170
+ if (meshLods?.length) {
171
+ for (let i = 0; i < meshLods.length; i++) {
172
+ const lod = meshLods[i];
173
+ const density = lod.densities?.[primitiveIndex] || lod.density || .00001;
174
+ if (primitiveIndex > 0 && warnMissingPrimitiveDensities && isDevelopmentServer() && !lod.densities && !globalThis["NEEDLE:MISSING_LOD_PRIMITIVE_DENSITIES"]) {
175
+ globalThis["NEEDLE:MISSING_LOD_PRIMITIVE_DENSITIES"] = true;
176
+ console.warn(`[Needle Progressive] Detected usage of mesh without primitive densities. This might cause incorrect LOD level selection: Consider re-optimizing your model by updating your Needle Integration, Needle glTF Pipeline or running optimization again on Needle Cloud.`);
177
+ }
178
+ if (density / screenCoverage < desiredDensity) {
179
+ result.level = i;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+ /**
187
+ * The LODsManager class is responsible for managing the LODs and progressive assets in the scene. It will automatically update the LODs based on the camera position, screen coverage and mesh density of the objects.
188
+ * It must be enabled by calling the `enable` method.
189
+ *
190
+ * Instead of using the LODs manager directly you can also call `useNeedleProgressive` to enable progressive loading for a GLTFLoader
191
+ *
192
+ * ### Plugins
193
+ * Use {@link LODsManager.addPlugin} to add a plugin to the LODsManager. A plugin can be used to hook into the LOD update process and modify the LOD levels or perform other actions.
194
+ *
195
+ * @example Adding a LODsManager to a Three.js scene:
196
+ * ```ts
197
+ * import { LODsManager } from "@needle-tools/gltf-progressive";
198
+ * import { WebGLRenderer, Scene, Camera, Mesh } from "three";
199
+ *
200
+ * const renderer = new WebGLRenderer();
201
+ * const lodsManager = LODsManager.get(renderer);
202
+ * lodsManager.enable();
203
+ * ```
204
+ *
205
+ * @example Using the LODsManager with a GLTFLoader:
206
+ * ```ts
207
+ * import { useNeedleProgressive } from "@needle-tools/gltf-progressive";
208
+ * import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
209
+ *
210
+ * const url = 'https://yourdomain.com/yourmodel.glb';
211
+ * const loader = new GLTFLoader();
212
+ * const lodsManager = useNeedleProgressive(url, renderer, loader);
213
+ * ```
214
+ */
215
+ export class LODsManager {
216
+ /**
217
+ * Assign a function to draw debug lines for the LODs. This function will be called with the start and end position of the line and the color of the line when the `debugprogressive` query parameter is set.
218
+ */
219
+ static debugDrawLine;
220
+ /** @internal */
221
+ static getObjectLODState(object) {
222
+ return object[$lodstate];
223
+ }
224
+ static addPlugin(plugin) {
225
+ plugins.push(plugin);
226
+ }
227
+ static removePlugin(plugin) {
228
+ const index = plugins.indexOf(plugin);
229
+ if (index >= 0)
230
+ plugins.splice(index, 1);
231
+ }
232
+ /** Read-only snapshot of the currently registered plugins, for inspection. Use {@link addPlugin} / {@link removePlugin} to modify the registry. */
233
+ static getPlugins() {
234
+ return plugins;
235
+ }
236
+ /**
237
+ * Gets the LODsManager for the given renderer. If the LODsManager does not exist yet, it will be created.
238
+ * @param renderer The renderer to get the LODsManager for.
239
+ * @returns The LODsManager instance.
240
+ */
241
+ static get(renderer, context) {
242
+ if (renderer[$lodsManager]) {
243
+ console.debug("[gltf-progressive] LODsManager already exists for this renderer");
244
+ return renderer[$lodsManager];
245
+ }
246
+ const lodsManager = new LODsManager(renderer, {
247
+ engine: "unknown",
248
+ ...context,
249
+ });
250
+ renderer[$lodsManager] = lodsManager;
251
+ return lodsManager;
252
+ }
253
+ renderer;
254
+ context;
255
+ projectionScreenMatrix = new Matrix4();
256
+ /** @deprecated use static `LODsManager.addPlugin()` method. This getter will be removed in later versions */
257
+ get plugins() { return plugins; }
258
+ /**
259
+ * Force override the LOD level for all objects (meshes + textures) rendered in the scene
260
+ * @default undefined automatically calculate LOD level
261
+ */
262
+ overrideLodLevel = undefined;
263
+ /**
264
+ * The target triangle density is the desired max amount of triangles on screen when the mesh is filling the screen.
265
+ * @default 200_000
266
+ */
267
+ targetTriangleDensity = 200_000;
268
+ /**
269
+ * The interval in frames to automatically update the bounds of skinned meshes.
270
+ * Set to 0 or a negative value to disable automatic bounds updates.
271
+ * @default 30
272
+ */
273
+ skinnedMeshAutoUpdateBoundsInterval = 30;
274
+ /**
275
+ * The update interval in frames. If set to 0, the LODs will be updated every frame. If set to 2, the LODs will be updated every second frame, etc.
276
+ * @default "auto"
277
+ */
278
+ updateInterval = "auto";
279
+ #updateInterval = 1;
280
+ /**
281
+ * If set to true, the LODsManager will not update the LODs.
282
+ * @default false
283
+ */
284
+ pause = false;
285
+ /**
286
+ * When set to true the LODsManager will not update the LODs. This can be used to manually update the LODs using the `update` method.
287
+ * Otherwise the LODs will be updated automatically when the renderer renders the scene.
288
+ * @default false
289
+ */
290
+ manual = false;
291
+ _newPromiseGroups = [];
292
+ _promiseGroupIds = 0;
293
+ /**
294
+ * Returns a promise that resolves once all LOD requests initiated during the next render cycles have finished loading.
295
+ * This is useful for hiding low-resolution placeholders (e.g. with a loading overlay or CSS blur) until high-quality assets are ready.
296
+ *
297
+ * By default, the returned promise captures LOD loading requests for 2 frames and resolves when all of them complete.
298
+ * Use `waitForFirstCapture` if no LOD requests may happen immediately (e.g. after a scene switch).
299
+ *
300
+ * @param opts - Optional configuration for how long to capture and what to wait for. See {@link PromiseGroupOptions}.
301
+ * @returns A promise that resolves with `{ cancelled, awaited_count, resolved_count }` once all captured LOD loads complete (or the signal aborts).
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * // Wait for initial LODs to finish loading, then remove a blur overlay
306
+ * const result = await lodsManager.awaitLoading({
307
+ * frames: 5,
308
+ * signal: AbortSignal.timeout(10_000),
309
+ * });
310
+ * console.log(`Loaded ${result.resolved_count} of ${result.awaited_count} LODs`);
311
+ * document.querySelector('.blur-overlay')?.remove();
312
+ * ```
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * // Wait until at least one LOD starts loading before resolving
317
+ * await lodsManager.awaitLoading({ waitForFirstCapture: true });
318
+ * ```
319
+ */
320
+ awaitLoading(opts) {
321
+ const id = this._promiseGroupIds++;
322
+ const newGroup = new PromiseGroup(this.#frame, { ...opts, });
323
+ this._newPromiseGroups.push(newGroup);
324
+ const start = performance.now();
325
+ newGroup.ready.finally(() => {
326
+ const index = this._newPromiseGroups.indexOf(newGroup);
327
+ if (index >= 0) {
328
+ this._newPromiseGroups.splice(index, 1);
329
+ if (isDevelopmentServer())
330
+ performance.measure("LODsManager:awaitLoading", {
331
+ start,
332
+ detail: { id, name: opts?.name, awaited: newGroup.awaitedCount, resolved: newGroup.resolvedCount }
333
+ });
334
+ }
335
+ });
336
+ return newGroup.ready;
337
+ }
338
+ /** Track LOD work started outside this manager so {@link awaitLoading} waits for it too. */
339
+ trackLoadingPromise(type, object, promise) {
340
+ PromiseGroup.addPromise(type, object, promise, this._newPromiseGroups);
341
+ return promise;
342
+ }
343
+ _postprocessPromiseGroups() {
344
+ if (this._newPromiseGroups.length === 0)
345
+ return;
346
+ for (let i = this._newPromiseGroups.length - 1; i >= 0; i--) {
347
+ const group = this._newPromiseGroups[i];
348
+ group.update(this.#frame);
349
+ }
350
+ }
351
+ _lodchangedlisteners = [];
352
+ /**
353
+ * Register a listener that is called whenever a mesh or texture LOD level has finished loading and has been applied.
354
+ * The listener receives the type of asset (`"mesh"` or `"texture"`), the new LOD level, and the affected object.
355
+ *
356
+ * @param evt - The event type. Currently only `"changed"` is supported.
357
+ * @param listener - Callback invoked after a LOD swap completes.
358
+ * @return A function to unregister the listener.
359
+ *
360
+ * @example
361
+ * ```ts
362
+ * lodsManager.addEventListener("changed", ({ type, level, object }) => {
363
+ * console.log(`${type} LOD changed to level ${level}`, object);
364
+ * });
365
+ * ```
366
+ */
367
+ addEventListener(evt, listener) {
368
+ if (evt === "changed") {
369
+ this._lodchangedlisteners.push(listener);
370
+ return () => {
371
+ this.removeEventListener(evt, listener);
372
+ };
373
+ }
374
+ return () => { };
375
+ }
376
+ /**
377
+ * Remove a previously registered `"changed"` event listener.
378
+ * @param evt - The event type (`"changed"`).
379
+ * @param listener - The listener to remove.
380
+ * @return `true` if the listener was found and removed, `false` otherwise.
381
+ */
382
+ removeEventListener(evt, listener) {
383
+ let removed = false;
384
+ if (evt === "changed") {
385
+ const index = this._lodchangedlisteners.indexOf(listener);
386
+ if (index >= 0) {
387
+ this._lodchangedlisteners.splice(index, 1);
388
+ removed = true;
389
+ }
390
+ }
391
+ return removed;
392
+ }
393
+ // readonly plugins: NEEDLE_progressive_plugin[] = [];
394
+ constructor(renderer, context) {
395
+ this.renderer = renderer;
396
+ this.context = { ...context };
397
+ // createGLTFLoaderWorker().then(res => {
398
+ // res.load("https://cloud.needle.tools/-/assets/Z23hmXBZ20RjNk-Z20RjNk-optimized/file").then(res2 => {
399
+ // console.log("DONE", res2);
400
+ // })
401
+ // res.load("https://cloud.needle.tools/-/assets/Z23hmXBZ20RjNk-Z20RjNk-world/file").then(res2 => {
402
+ // console.log("DONE2", res2);
403
+ // })
404
+ // })
405
+ }
406
+ #originalRender;
407
+ #frame = 0;
408
+ #delta = 0;
409
+ #time = 0;
410
+ #fps = 0;
411
+ #clock = createLODTimer();
412
+ _fpsBuffer = [60, 60, 60, 60, 60];
413
+ /**
414
+ * Enable the LODsManager. This will replace the render method of the renderer with a method that updates the LODs.
415
+ */
416
+ enable() {
417
+ if (this.#originalRender)
418
+ return;
419
+ console.debug("[gltf-progressive] Enabling LODsManager for renderer");
420
+ let stack = 0;
421
+ // Save the original render method
422
+ this.#originalRender = this.renderer.render;
423
+ const self = this;
424
+ createLoaders(this.renderer);
425
+ this.renderer.render = function (scene, camera) {
426
+ // check if this render call is rendering to a texture or the canvas
427
+ // if it's rendering to a texture we don't want to update the LODs
428
+ // This might need to be loosened later - e.g. we might want to update LODs for a render texture - but then we need to store the last LOD level differently and we also might not want to perform all the plugin calls?
429
+ const renderTarget = self.renderer.getRenderTarget();
430
+ if (renderTarget == null || ("isXRRenderTarget" in renderTarget && renderTarget.isXRRenderTarget)) {
431
+ stack = 0;
432
+ self.#frame += 1;
433
+ self.#clock.update?.();
434
+ self.#delta = Math.max(self.#clock.getDelta(), 1 / 1000);
435
+ self.#time += self.#delta;
436
+ self._fpsBuffer.shift();
437
+ self._fpsBuffer.push(1 / self.#delta);
438
+ self.#fps = self._fpsBuffer.reduce((a, b) => a + b) / self._fpsBuffer.length;
439
+ if (debugProgressiveLoading && self.#frame % 200 === 0)
440
+ console.log("FPS", Math.round(self.#fps), "Interval:", self.#updateInterval);
441
+ }
442
+ const stack_level = stack++;
443
+ self.#originalRender.call(this, scene, camera);
444
+ self.onAfterRender(scene, camera, stack_level);
445
+ };
446
+ }
447
+ disable() {
448
+ if (!this.#originalRender)
449
+ return;
450
+ console.debug("[gltf-progressive] Disabling LODsManager for renderer");
451
+ this.renderer.render = this.#originalRender;
452
+ this.#originalRender = undefined;
453
+ }
454
+ /**
455
+ * Manually trigger a LOD update for a scene and camera.
456
+ * Only needed when {@link manual} is set to `true` — otherwise LOD updates happen automatically on each render call.
457
+ *
458
+ * @param scene - The scene containing objects with progressive LODs.
459
+ * @param camera - The camera used to determine screen coverage and LOD levels.
460
+ *
461
+ * @example
462
+ * ```ts
463
+ * const lodsManager = LODsManager.get(renderer);
464
+ * lodsManager.manual = true;
465
+ * // ... later, trigger an update at a specific point:
466
+ * lodsManager.update(scene, camera);
467
+ * ```
468
+ */
469
+ update(scene, camera) {
470
+ this.internalUpdate(scene, camera);
471
+ }
472
+ onAfterRender(scene, camera, _stack) {
473
+ if (this.pause)
474
+ return;
475
+ const renderList = this.getRenderList(scene, camera, _stack);
476
+ if (!renderList)
477
+ return;
478
+ const opaque = renderList.opaque;
479
+ let updateLODs = true;
480
+ // check if we're rendering a postprocessing pass
481
+ if (opaque.length === 1) {
482
+ const material = opaque[0].material;
483
+ // pmndrs postprocessing
484
+ if (material.name === "EffectMaterial") {
485
+ updateLODs = false;
486
+ }
487
+ // builtin three postprocessing
488
+ else if (material.name === "CopyShader") {
489
+ updateLODs = false;
490
+ }
491
+ }
492
+ // don't update LODs for cube map rendering cameras
493
+ if (camera.parent && camera.parent.type === "CubeCamera") {
494
+ updateLODs = false;
495
+ }
496
+ else if (_stack >= 1) {
497
+ // don't update LODs if we're e.g. rendering a shadow map
498
+ if (camera.type === "OrthographicCamera") {
499
+ updateLODs = false;
500
+ }
501
+ }
502
+ if (updateLODs) {
503
+ if (suppressProgressiveLoading)
504
+ return;
505
+ // If the update interval is set to auto then we check the FPS and adjust the update interval accordingly
506
+ // If performance is low we increase the update interval to reduce the amount of LOD updates
507
+ if (this.updateInterval === "auto") {
508
+ if (this.#fps < 40 && this.#updateInterval < 10) {
509
+ this.#updateInterval += 1;
510
+ if (debugProgressiveLoading)
511
+ console.warn("↓ Reducing LOD updates", this.#updateInterval, this.#fps.toFixed(0));
512
+ }
513
+ else if (this.#fps >= 60 && this.#updateInterval > 1) {
514
+ this.#updateInterval -= 1;
515
+ if (debugProgressiveLoading)
516
+ console.warn("↑ Increasing LOD updates", this.#updateInterval, this.#fps.toFixed(0));
517
+ }
518
+ }
519
+ else {
520
+ this.#updateInterval = this.updateInterval;
521
+ }
522
+ // Check if we should update LODs this frame
523
+ if (this.#updateInterval > 0 && this.#frame % this.#updateInterval != 0) {
524
+ return;
525
+ }
526
+ this.internalUpdate(scene, camera);
527
+ this._postprocessPromiseGroups();
528
+ }
529
+ }
530
+ /**
531
+ * Update LODs in a scene
532
+ */
533
+ internalUpdate(scene, camera) {
534
+ const renderList = this.getRenderList(scene, camera, 0);
535
+ if (!renderList)
536
+ return;
537
+ const opaque = renderList.opaque;
538
+ this.projectionScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
539
+ const desiredDensity = this.targetTriangleDensity;
540
+ for (const entry of opaque) {
541
+ if (entry.material && (entry.geometry?.type === "BoxGeometry" || entry.geometry?.type === "BufferGeometry")) {
542
+ // Ignore the skybox
543
+ if (entry.material.name === "SphericalGaussianBlur" || entry.material.name == "BackgroundCubeMaterial" || entry.material.name === "CubemapFromEquirect" || entry.material.name === "EquirectangularToCubeUV") {
544
+ if (debugProgressiveLoading) {
545
+ if (!entry.material["NEEDLE_PROGRESSIVE:IGNORE-WARNING"]) {
546
+ entry.material["NEEDLE_PROGRESSIVE:IGNORE-WARNING"] = true;
547
+ console.warn("Ignoring skybox or BLIT object", entry, entry.material.name, entry.material.type);
548
+ }
549
+ }
550
+ continue;
551
+ }
552
+ }
553
+ switch (entry.material.type) {
554
+ case "LineBasicMaterial":
555
+ case "LineDashedMaterial":
556
+ case "PointsMaterial":
557
+ case "ShadowMaterial":
558
+ case "MeshDistanceMaterial":
559
+ case "MeshDepthMaterial":
560
+ continue;
561
+ }
562
+ const object = entry.object;
563
+ if (object instanceof Mesh || (object.isMesh)) {
564
+ this.updateLODs(scene, camera, object, desiredDensity);
565
+ }
566
+ }
567
+ const transparent = renderList.transparent;
568
+ for (const entry of transparent) {
569
+ const object = entry.object;
570
+ if (object instanceof Mesh || (object.isMesh)) {
571
+ this.updateLODs(scene, camera, object, desiredDensity);
572
+ }
573
+ }
574
+ const transmissive = renderList.transmissive;
575
+ for (const entry of transmissive) {
576
+ const object = entry.object;
577
+ if (object instanceof Mesh || (object.isMesh)) {
578
+ this.updateLODs(scene, camera, object, desiredDensity);
579
+ }
580
+ }
581
+ }
582
+ getRenderList(scene, camera, stack) {
583
+ const renderer = this.renderer;
584
+ let renderList = null;
585
+ if (renderer.isWebGPURenderer === true) {
586
+ const renderLists = renderer._renderLists;
587
+ if (!renderLists)
588
+ return null;
589
+ renderList = renderLists.get(scene, camera);
590
+ }
591
+ else if (renderer.isWebGLRenderer === true) {
592
+ const renderLists = renderer.renderLists;
593
+ if (!renderLists)
594
+ return null;
595
+ renderList = renderLists.get(scene, stack);
596
+ }
597
+ if (!renderList)
598
+ return null;
599
+ return {
600
+ opaque: renderList.opaque || [],
601
+ transparent: renderList.transparent || [],
602
+ transmissive: renderList.transmissive || renderList.transparentDoublePass || [],
603
+ transparentDoublePass: renderList.transparentDoublePass || [],
604
+ };
605
+ }
606
+ /** Update the LOD levels for the renderer. */
607
+ updateLODs(scene, camera, object, desiredDensity) {
608
+ if (!object.userData) {
609
+ object.userData = {};
610
+ }
611
+ let state = object[$lodstate];
612
+ if (!state) {
613
+ state = new LOD_state();
614
+ object[$lodstate] = state;
615
+ }
616
+ // Wait a few frames before updating the LODs to make sure the object is loaded, matrices are updated, etc.
617
+ if (state.frames++ < 2) {
618
+ return;
619
+ }
620
+ for (const plugin of plugins) {
621
+ plugin.onBeforeUpdateLOD?.(this.renderer, scene, camera, object);
622
+ }
623
+ const debugLodLevel = this.overrideLodLevel !== undefined ? this.overrideLodLevel : debug_OverrideLodLevel;
624
+ if (debugLodLevel >= 0) {
625
+ levels.mesh_lod = debugLodLevel;
626
+ levels.texture_lod = debugLodLevel;
627
+ }
628
+ else {
629
+ this.calculateLodLevel(camera, object, state, desiredDensity, levels);
630
+ levels.mesh_lod = Math.round(levels.mesh_lod);
631
+ levels.texture_lod = Math.round(levels.texture_lod);
632
+ }
633
+ // we currently only support auto LOD changes for meshes
634
+ if (levels.mesh_lod >= 0) {
635
+ this.loadProgressiveMeshes(object, levels.mesh_lod);
636
+ }
637
+ // TODO: we currently can not switch texture lods because we need better caching for the textures internally (see copySettings in progressive + NE-4431)
638
+ if (object.material && levels.texture_lod >= 0) {
639
+ this.loadProgressiveTextures(object.material, levels.texture_lod, debugLodLevel);
640
+ }
641
+ if (debug && object.material && !object["isGizmo"]) {
642
+ applyDebugSettings(object.material);
643
+ }
644
+ if (debugProgressiveLODColors && object.material && !object["isGizmo"] && !object["isBatchedMesh"]) {
645
+ applyLODColor(object.material, levels.mesh_lod);
646
+ }
647
+ for (const plugin of plugins) {
648
+ plugin.onAfterUpdatedLOD?.(this.renderer, scene, camera, object, levels);
649
+ }
650
+ state.lastLodLevel_Mesh = levels.mesh_lod;
651
+ state.lastLodLevel_Texture = levels.texture_lod;
652
+ }
653
+ /** Load progressive textures for the given material
654
+ * @param material the material to load the textures for
655
+ * @param level the LOD level to load. Level 0 is the best quality, higher levels are lower quality
656
+ * @returns Promise with true if the LOD was loaded, false if not
657
+ */
658
+ loadProgressiveTextures(material, level, overrideLodLevel) {
659
+ if (!material)
660
+ return;
661
+ if (Array.isArray(material)) {
662
+ for (const mat of material) {
663
+ this.loadProgressiveTextures(mat, level, overrideLodLevel);
664
+ }
665
+ return;
666
+ }
667
+ // Check if the material LOD was already updated to a certain level
668
+ // We don't use the userData here because we want to re-run assigning textures if the material has been cloned
669
+ let update = false;
670
+ if (material[$currentLOD] === undefined) {
671
+ update = true;
672
+ }
673
+ else if (level < material[$currentLOD]) {
674
+ update = true;
675
+ }
676
+ const forceExactTextureLOD = overrideLodLevel !== undefined && overrideLodLevel >= 0;
677
+ if (forceExactTextureLOD) {
678
+ update = material[$currentLOD] != overrideLodLevel;
679
+ level = overrideLodLevel;
680
+ }
681
+ if (update) {
682
+ material[$currentLOD] = level;
683
+ const options = forceExactTextureLOD ? { force: true } : undefined;
684
+ const promise = NEEDLE_progressive.assignTextureLOD(material, level, options).then(_ => {
685
+ this._lodchangedlisteners.forEach(l => l({ type: "texture", level, object: material }));
686
+ });
687
+ PromiseGroup.addPromise("texture", material, promise, this._newPromiseGroups);
688
+ }
689
+ }
690
+ /** Load progressive meshes for the given mesh
691
+ * @param mesh the mesh to load the LOD for
692
+ * @param index the index of the mesh if it's part of a group
693
+ * @param level the LOD level to load. Level 0 is the best quality, higher levels are lower quality
694
+ * @returns Promise with true if the LOD was loaded, false if not
695
+ */
696
+ loadProgressiveMeshes(mesh, level) {
697
+ if (!mesh)
698
+ return Promise.resolve(null);
699
+ let update = mesh[$currentLOD] !== level;
700
+ const debugLevel = mesh["DEBUG:LOD"];
701
+ if (debugLevel != undefined) {
702
+ update = mesh[$currentLOD] != debugLevel;
703
+ level = debugLevel;
704
+ }
705
+ if (update) {
706
+ mesh[$currentLOD] = level;
707
+ const originalGeometry = mesh.geometry;
708
+ const promise = NEEDLE_progressive.assignMeshLOD(mesh, level).then(res => {
709
+ if (res && mesh[$currentLOD] == level && originalGeometry != mesh.geometry) {
710
+ this._lodchangedlisteners.forEach(l => l({ type: "mesh", level, object: mesh }));
711
+ }
712
+ return res;
713
+ });
714
+ PromiseGroup.addPromise("mesh", mesh, promise, this._newPromiseGroups);
715
+ return promise;
716
+ }
717
+ return Promise.resolve(null);
718
+ }
719
+ // private testIfLODLevelsAreAvailable() {
720
+ _sphere = new Sphere();
721
+ _tempWorldPosition = new Vector3();
722
+ static skinnedMeshBoundsFrameOffsetCounter = 0;
723
+ static $skinnedMeshBoundsOffset = Symbol("gltf-progressive-skinnedMeshBoundsOffset");
724
+ // #region calculateLodLevel
725
+ calculateLodLevel(camera, mesh, state, desiredDensity, result) {
726
+ if (!mesh) {
727
+ result.mesh_lod = -1;
728
+ result.texture_lod = -1;
729
+ return;
730
+ }
731
+ if (!camera) {
732
+ result.mesh_lod = -1;
733
+ result.texture_lod = -1;
734
+ return;
735
+ }
736
+ // if this is using instancing we always load level 0
737
+ // if (this.isInstancingActive) return 0;
738
+ /** rough measure of "triangles on quadratic screen" – we're switching LODs based on this metric. */
739
+ /** highest LOD level we'd ever expect to be generated */
740
+ const maxLevel = 10;
741
+ let mesh_level = maxLevel + 1;
742
+ let mesh_level_calculated = false;
743
+ if (debugProgressiveLoading && mesh["DEBUG:LOD"] != undefined) {
744
+ return mesh["DEBUG:LOD"];
745
+ }
746
+ // The mesh info contains also the density for all available LOD level so we can use this for selecting which level to show
747
+ const mesh_lods = NEEDLE_progressive.getMeshLODExtension(mesh.geometry)?.lods;
748
+ const primitive_index = NEEDLE_progressive.getPrimitiveIndex(mesh.geometry);
749
+ const has_mesh_lods = mesh_lods && mesh_lods.length > 0;
750
+ const texture_lods_minmax = NEEDLE_progressive.getMaterialMinMaxLODsCount(mesh.material);
751
+ const has_texture_lods = texture_lods_minmax.min_count !== Infinity && texture_lods_minmax.min_count >= 0 && texture_lods_minmax.max_count >= 0;
752
+ // We can skip all this if we dont have any LOD information
753
+ if (!has_mesh_lods && !has_texture_lods) {
754
+ result.mesh_lod = 0;
755
+ result.texture_lod = 0;
756
+ return;
757
+ }
758
+ if (!has_mesh_lods) {
759
+ mesh_level_calculated = true;
760
+ mesh_level = 0;
761
+ }
762
+ const canvasHeight = this.renderer.domElement.clientHeight || this.renderer.domElement.height;
763
+ let boundingBox = mesh.geometry.boundingBox;
764
+ if (mesh.type === "SkinnedMesh") {
765
+ const skinnedMesh = mesh;
766
+ if (!skinnedMesh.boundingBox) {
767
+ skinnedMesh.computeBoundingBox();
768
+ }
769
+ // Fix: https://linear.app/needle/issue/NE-5264
770
+ else if (this.skinnedMeshAutoUpdateBoundsInterval > 0) {
771
+ // Save a frame offset per object to stagger updates of skinned meshes across multiple frames
772
+ // This isn't a perfect solution to improve perf impact of skinned mesh updates (e.g. large skinned meshes would still be costly)
773
+ // But for many smaller meshes it helps to avoid spikes in performance
774
+ if (!skinnedMesh[LODsManager.$skinnedMeshBoundsOffset]) {
775
+ const offset = LODsManager.skinnedMeshBoundsFrameOffsetCounter++;
776
+ skinnedMesh[LODsManager.$skinnedMeshBoundsOffset] = offset;
777
+ }
778
+ const frameOffset = skinnedMesh[LODsManager.$skinnedMeshBoundsOffset];
779
+ if ((state.frames + frameOffset) % this.skinnedMeshAutoUpdateBoundsInterval === 0) {
780
+ // use lowres geometry for bounding box calculation
781
+ const raycastmesh = getRaycastMesh(skinnedMesh);
782
+ const originalGeometry = skinnedMesh.geometry;
783
+ if (raycastmesh) {
784
+ skinnedMesh.geometry = raycastmesh;
785
+ }
786
+ skinnedMesh.computeBoundingBox();
787
+ skinnedMesh.geometry = originalGeometry;
788
+ }
789
+ }
790
+ boundingBox = skinnedMesh.boundingBox;
791
+ }
792
+ if (boundingBox) {
793
+ // hack: if the mesh has vertex colors, has less than 100 vertices we always select the highest LOD
794
+ if (mesh.geometry.attributes.color && mesh.geometry.attributes.color.count < 100) {
795
+ if (mesh.geometry.boundingSphere) {
796
+ this._sphere.copy(mesh.geometry.boundingSphere);
797
+ this._sphere.applyMatrix4(mesh.matrixWorld);
798
+ const worldPosition = camera.getWorldPosition(this._tempWorldPosition);
799
+ if (this._sphere.containsPoint(worldPosition)) {
800
+ result.mesh_lod = 0;
801
+ result.texture_lod = 0;
802
+ return;
803
+ }
804
+ }
805
+ }
806
+ const selection = calculateMeshLODLevel({
807
+ geometry: mesh.geometry,
808
+ matrixWorld: mesh.matrixWorld,
809
+ camera,
810
+ projectionScreenMatrix: this.projectionScreenMatrix,
811
+ desiredDensity,
812
+ canvasHeight,
813
+ currentLevel: state.lastLodLevel_Mesh,
814
+ boundingBox,
815
+ xrEnabled: this.renderer.xr.enabled,
816
+ debugDrawLine: debugProgressiveLoading ? LODsManager.debugDrawLine : undefined,
817
+ warnMissingPrimitiveDensities: true,
818
+ });
819
+ state.lastCentrality = selection.centrality;
820
+ state.lastScreenCoverage = selection.screenCoverage;
821
+ state.lastScreenspaceVolume.copy(selection.screenspaceVolume);
822
+ if (selection.screenCoverage === Infinity) {
823
+ result.mesh_lod = 0;
824
+ result.texture_lod = 0;
825
+ return;
826
+ }
827
+ const isLowerLod = selection.level >= 0 && selection.level < mesh_level;
828
+ if (isLowerLod) {
829
+ mesh_level = selection.level;
830
+ mesh_level_calculated = true;
831
+ }
832
+ }
833
+ if (mesh_level_calculated) {
834
+ result.mesh_lod = mesh_level;
835
+ }
836
+ else {
837
+ result.mesh_lod = state.lastLodLevel_Mesh;
838
+ }
839
+ if (debugProgressiveLoading) {
840
+ const changed = result.mesh_lod != state.lastLodLevel_Mesh;
841
+ if (changed) {
842
+ const level = mesh_lods?.[result.mesh_lod];
843
+ if (level) {
844
+ console.log(`Mesh LOD changed: ${state.lastLodLevel_Mesh} → ${result.mesh_lod} (density: ${level.densities?.[primitive_index].toFixed(0)}) | ${mesh.name}`);
845
+ }
846
+ }
847
+ }
848
+ if (has_texture_lods) {
849
+ const saveDataEnabled = "saveData" in globalThis.navigator && globalThis.navigator.saveData === true;
850
+ // If this is the first time a texture LOD is requested we want to get the highest LOD to not display the minimal resolution that the root glTF contains as long while we wait for loading of e.g. the 8k LOD 0 texture
851
+ if (state.lastLodLevel_Texture < 0) {
852
+ result.texture_lod = texture_lods_minmax.max_count - 1;
853
+ if (debugProgressiveLoading) {
854
+ const level = texture_lods_minmax.lods[texture_lods_minmax.max_count - 1];
855
+ if (debugProgressiveLoading)
856
+ console.log(`First Texture LOD ${result.texture_lod} (${level.max_height}px) - ${mesh.name}`);
857
+ }
858
+ }
859
+ else {
860
+ // TODO: should we use the volume as a factor instead?
861
+ const volume = state.lastScreenspaceVolume.x + state.lastScreenspaceVolume.y + state.lastScreenspaceVolume.z;
862
+ let factor = state.lastScreenCoverage * 4;
863
+ if (this.context?.engine === "model-viewer") {
864
+ factor *= 1.5;
865
+ }
866
+ const devicePixelRatio = this.renderer.getPixelRatio?.() || globalThis.devicePixelRatio || 1;
867
+ const screenSize = canvasHeight / devicePixelRatio;
868
+ const pixelSizeOnScreen = screenSize * factor;
869
+ let foundLod = false;
870
+ for (let i = texture_lods_minmax.lods.length - 1; i >= 0; i--) {
871
+ const lod = texture_lods_minmax.lods[i];
872
+ if (saveDataEnabled && lod.max_height >= 2048) {
873
+ continue; // skip 2k textures when saveData is enabled
874
+ }
875
+ if (isMobileDevice() && lod.max_height > 4096)
876
+ continue; // skip 8k textures on mobile devices (for now)
877
+ if (lod.max_height > pixelSizeOnScreen || (!foundLod && i === 0)) {
878
+ foundLod = true;
879
+ result.texture_lod = i;
880
+ if (debugProgressiveLoading) {
881
+ if (result.texture_lod < state.lastLodLevel_Texture) {
882
+ const lod_pixel_height = lod.max_height;
883
+ console.log(`Texture LOD changed: ${state.lastLodLevel_Texture} → ${result.texture_lod} = ${lod_pixel_height}px \nScreensize: ${pixelSizeOnScreen.toFixed(0)}px, Coverage: ${(100 * state.lastScreenCoverage).toFixed(2)}%, Volume ${volume.toFixed(1)} \n${mesh.name}`);
884
+ }
885
+ }
886
+ break;
887
+ }
888
+ }
889
+ // const t = Math.min(1, Math.max(0, state.lastScreenCoverage * 1.1));
890
+ // result.texture_lod = lerp(texture_lods_minmax.max_count, 0, t);
891
+ }
892
+ }
893
+ else {
894
+ result.texture_lod = 0;
895
+ }
896
+ }
897
+ }
898
+ class LOD_state {
899
+ frames = 0;
900
+ lastLodLevel_Mesh = -1;
901
+ lastLodLevel_Texture = -1;
902
+ lastScreenCoverage = 0;
903
+ lastScreenspaceVolume = new Vector3();
904
+ lastCentrality = 0;
905
+ }
906
+ function applyLODColor(material, level) {
907
+ if (level < 0)
908
+ return;
909
+ if (Array.isArray(material)) {
910
+ for (const mat of material) {
911
+ applyLODColor(mat, level);
912
+ }
913
+ return;
914
+ }
915
+ if ("color" in material && material.color instanceof Color) {
916
+ material.color.copy(getLODColor(level, debugLODColor));
917
+ material.needsUpdate = true;
918
+ }
919
+ }
920
+ export function getLODColor(level, target) {
921
+ const index = Math.max(0, Math.min(lodDebugColors.length - 1, Math.floor(level)));
922
+ return target.setHex(lodDebugColors[index]);
923
+ }