@needle-tools/gltf-progressive 3.4.0-beta → 3.4.0-beta.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.
package/lib/extension.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BufferGeometry, Mesh, Texture, TextureLoader } from "three";
2
2
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
3
3
  import { addDracoAndKTX2Loaders } from "./loaders.js";
4
- import { getParam, PromiseQueue, resolveUrl } from "./utils.internal.js";
4
+ import { determineTextureMemoryInBytes, getParam, PromiseQueue, resolveUrl } from "./utils.internal.js";
5
5
  import { getRaycastMesh, registerRaycastMesh } from "./utils.js";
6
6
  // All of this has to be removed
7
7
  // import { getRaycastMesh, setRaycastMesh } from "../../engine_physics.js";
@@ -11,6 +11,7 @@ import { debug } from "./lods.debug.js";
11
11
  import { getWorker } from "./worker/loader.mainthread.js";
12
12
  const useWorker = getParam("gltf-progressive-worker");
13
13
  const reduceMipmaps = getParam("gltf-progressive-reduce-mipmaps");
14
+ const debugGC = getParam("gltf-progressive-gc");
14
15
  const $progressiveTextureExtension = Symbol("needle-progressive-texture");
15
16
  export const EXTENSION_NAME = "NEEDLE_progressive";
16
17
  // #region EXT
@@ -309,6 +310,16 @@ export class NEEDLE_progressive {
309
310
  }
310
311
  return Promise.resolve(null);
311
312
  }
313
+ /**
314
+ * Set the maximum number of concurrent loading tasks for LOD resources. This limits how many LOD resources (meshes or textures) can be loaded at the same time to prevent overloading the network or GPU. If the limit is reached, additional loading requests will be queued and processed as previous ones finish.
315
+ * @default 50
316
+ */
317
+ static set maxConcurrentLoadingTasks(value) {
318
+ NEEDLE_progressive.queue.maxConcurrent = value;
319
+ }
320
+ static get maxConcurrentLoadingTasks() {
321
+ return NEEDLE_progressive.queue.maxConcurrent;
322
+ }
312
323
  // #region INTERNAL
313
324
  static assignTextureLODForSlot(current, level, material, slot) {
314
325
  if (current?.isTexture !== true) {
@@ -334,29 +345,37 @@ export class NEEDLE_progressive {
334
345
  if (assignedLOD && assignedLOD?.level < level) {
335
346
  if (debug === "verbose")
336
347
  console.warn("Assigned texture level is already higher: ", assignedLOD.level, level, material, assigned, tex);
348
+ // Dispose the newly loaded texture since we're not using it
349
+ // (the assigned texture is higher quality, so we reject the new one)
350
+ // Note: We dispose directly here (not via untrackTextureUsage) because this texture
351
+ // was never tracked/used - it was rejected immediately upon loading
352
+ if (tex && tex !== assigned) {
353
+ if (debug || debugGC) {
354
+ console.log(`[gltf-progressive] Disposing rejected lower-quality texture LOD ${level} (assigned is ${assignedLOD.level})`, tex.uuid);
355
+ }
356
+ tex.dispose();
357
+ }
337
358
  return null;
338
359
  }
339
360
  // assigned.dispose();
340
361
  }
341
- // Since we're switching LOD level for the texture based on distance we can avoid uploading all the mipmaps
342
- if (reduceMipmaps && tex.mipmaps) {
343
- const prevCount = tex.mipmaps.length;
344
- tex.mipmaps.length = Math.min(tex.mipmaps.length, 3);
345
- if (prevCount !== tex.mipmaps.length) {
346
- if (debug)
347
- console.debug(`Reduced mipmap count from ${prevCount} to ${tex.mipmaps.length} for ${tex.uuid}: ${tex.image?.width}x${tex.image?.height}.`);
362
+ // Track reference count for new texture
363
+ this.trackTextureUsage(tex);
364
+ // Untrack the old texture (may dispose if ref count hits 0)
365
+ // This prevents accumulation of GPU VRAM while waiting for garbage collection
366
+ if (assigned && assigned !== tex) {
367
+ const wasDisposed = this.untrackTextureUsage(assigned);
368
+ if (wasDisposed && (debug || debugGC)) {
369
+ const assignedLOD = this.getAssignedLODInformation(assigned);
370
+ console.log(`[gltf-progressive] Disposed old texture LOD ${assignedLOD?.level ?? '?'} → ${level} for ${material.name || material.type}.${slot}`, assigned.uuid);
348
371
  }
349
372
  }
350
373
  material[slot] = tex;
351
374
  }
352
- // check if the old texture is still used by other objects
353
- // if not we dispose it...
354
- // this could also be handled elsewhere and not be done immediately
355
- // const users = getResourceUserCount(current);
356
- // if (!users) {
357
- // if (debug) console.log("Progressive: Dispose texture", current.name, current.source.data, current.uuid);
358
- // current?.dispose();
359
- // }
375
+ // Note: We use reference counting above to track texture usage across multiple materials.
376
+ // When the reference count hits zero, GPU memory (VRAM) is freed immediately via gl.deleteTexture(),
377
+ // not waiting for JavaScript garbage collection which may take seconds/minutes.
378
+ // This handles cases where the same texture is shared across multiple materials/objects.
360
379
  }
361
380
  // this.onProgressiveLoadEnd(info);
362
381
  return tex;
@@ -531,6 +550,7 @@ export class NEEDLE_progressive {
531
550
  * Dispose cached resources to free memory.
532
551
  * Call this when a model is removed from the scene to allow garbage collection of its LOD resources.
533
552
  * Calls three.js `.dispose()` on cached Textures and BufferGeometries to free GPU memory.
553
+ * Also clears reference counts for disposed textures.
534
554
  * @param guid Optional GUID to dispose resources for a specific model. If omitted, all cached resources are cleared.
535
555
  */
536
556
  static dispose(guid) {
@@ -542,7 +562,9 @@ export class NEEDLE_progressive {
542
562
  const lowres = lowresRef.deref();
543
563
  if (lowres) {
544
564
  if (lowres.isTexture) {
545
- lowres.dispose();
565
+ const tex = lowres;
566
+ this.textureRefCounts.delete(tex.uuid); // Clear ref count
567
+ tex.dispose();
546
568
  }
547
569
  else if (Array.isArray(lowres)) {
548
570
  for (const geo of lowres)
@@ -565,7 +587,9 @@ export class NEEDLE_progressive {
565
587
  const entry = entryRef.deref();
566
588
  if (entry) {
567
589
  if (entry.isTexture) {
568
- entry.dispose();
590
+ const tex = entry;
591
+ this.textureRefCounts.delete(tex.uuid); // Clear ref count
592
+ tex.dispose();
569
593
  }
570
594
  else if (Array.isArray(entry)) {
571
595
  for (const geo of entry)
@@ -578,6 +602,8 @@ export class NEEDLE_progressive {
578
602
  this._disposeCacheEntry(entry);
579
603
  }
580
604
  this.cache.clear();
605
+ // Clear all texture reference counts when disposing everything
606
+ this.textureRefCounts.clear();
581
607
  }
582
608
  }
583
609
  /** Dispose a single cache entry's three.js resource(s) to free GPU memory. */
@@ -585,7 +611,13 @@ export class NEEDLE_progressive {
585
611
  if (entry instanceof WeakRef) {
586
612
  // Single resource — deref and dispose if still alive
587
613
  const resource = entry.deref();
588
- resource?.dispose();
614
+ if (resource) {
615
+ // Clear ref count for textures
616
+ if (resource.isTexture) {
617
+ this.textureRefCounts.delete(resource.uuid);
618
+ }
619
+ resource.dispose();
620
+ }
589
621
  }
590
622
  else {
591
623
  // Promise — may be in-flight or already resolved.
@@ -597,6 +629,10 @@ export class NEEDLE_progressive {
597
629
  geo.dispose();
598
630
  }
599
631
  else {
632
+ // Clear ref count for textures
633
+ if (resource.isTexture) {
634
+ this.textureRefCounts.delete(resource.uuid);
635
+ }
600
636
  resource.dispose();
601
637
  }
602
638
  }
@@ -609,6 +645,8 @@ export class NEEDLE_progressive {
609
645
  static cache = new Map();
610
646
  /** this contains the geometry/textures that were originally loaded. Uses WeakRef to allow garbage collection when unused. */
611
647
  static lowresCache = new Map();
648
+ /** Reference counting for textures to track usage across multiple materials/objects */
649
+ static textureRefCounts = new Map();
612
650
  /**
613
651
  * FinalizationRegistry to automatically clean up `previouslyLoaded` cache entries
614
652
  * when their associated three.js resources are garbage collected by the browser.
@@ -616,18 +654,72 @@ export class NEEDLE_progressive {
616
654
  */
617
655
  static _resourceRegistry = new FinalizationRegistry((cacheKey) => {
618
656
  const entry = NEEDLE_progressive.cache.get(cacheKey);
619
- if (debug)
620
- console.debug(`[gltf-progressive] Resource GC'd\n${cacheKey}`);
657
+ if (debug || debugGC)
658
+ console.debug(`[gltf-progressive] Memory: Resource GC'd\n${cacheKey}`);
621
659
  // Only delete if the entry is still a WeakRef and the resource is gone
622
660
  if (entry instanceof WeakRef) {
623
661
  const derefed = entry.deref();
624
662
  if (!derefed) {
625
663
  NEEDLE_progressive.cache.delete(cacheKey);
626
- if (debug)
627
- console.log(`[gltf-progressive] Cache entry auto-cleaned (GC'd): ${cacheKey}`);
664
+ if (debug || debugGC)
665
+ console.log(`[gltf-progressive] Cache entry deleted (GC)`);
628
666
  }
629
667
  }
630
668
  });
669
+ /**
670
+ * Track texture usage by incrementing reference count
671
+ */
672
+ static trackTextureUsage(texture) {
673
+ const uuid = texture.uuid;
674
+ const count = this.textureRefCounts.get(uuid) || 0;
675
+ this.textureRefCounts.set(uuid, count + 1);
676
+ if (debug === "verbose") {
677
+ console.log(`[gltf-progressive] Track texture ${uuid}, refCount: ${count} → ${count + 1}`);
678
+ }
679
+ }
680
+ /**
681
+ * Untrack texture usage by decrementing reference count.
682
+ * Automatically disposes the texture when reference count reaches zero.
683
+ * @returns true if the texture was disposed, false otherwise
684
+ */
685
+ static untrackTextureUsage(texture) {
686
+ const uuid = texture.uuid;
687
+ const count = this.textureRefCounts.get(uuid);
688
+ if (!count) {
689
+ // Texture wasn't tracked, dispose immediately (safe fallback)
690
+ if (debug === "verbose" || debugGC) {
691
+ logDebugInfo(`[gltf-progressive] Memory: Untrack untracked texture (dispose immediately)`, 0);
692
+ }
693
+ texture.dispose();
694
+ return true;
695
+ }
696
+ const newCount = count - 1;
697
+ if (newCount <= 0) {
698
+ this.textureRefCounts.delete(uuid);
699
+ if (debug || debugGC) {
700
+ logDebugInfo(`[gltf-progressive] Memory: Dispose texture`, newCount);
701
+ }
702
+ texture.dispose();
703
+ return true;
704
+ }
705
+ else {
706
+ this.textureRefCounts.set(uuid, newCount);
707
+ if (debug === "verbose") {
708
+ logDebugInfo(`[gltf-progressive] Memory: Untrack texture`, newCount);
709
+ }
710
+ return false;
711
+ }
712
+ function logDebugInfo(prefix, newCount) {
713
+ let width = texture.image?.width || texture.source?.data?.width || 0;
714
+ let height = texture.image?.height || texture.source?.data?.height || 0;
715
+ const textureSize = width && height ? `${width}x${height}` : "N/A";
716
+ let memorySize = "N/A";
717
+ if (width && height) {
718
+ memorySize = `~${(determineTextureMemoryInBytes(texture) / (1024 * 1024)).toFixed(2)} MB`;
719
+ }
720
+ console.log(`${prefix} — ${texture.name} ${textureSize} (${memorySize}), refCount: ${count} → ${newCount}\n${uuid}`);
721
+ }
722
+ }
631
723
  static workers = [];
632
724
  static _workersIndex = 0;
633
725
  static async getOrLoadLOD(current, level) {
@@ -964,8 +1056,7 @@ export class NEEDLE_progressive {
964
1056
  }
965
1057
  return null;
966
1058
  }
967
- static maxConcurrent = 50;
968
- static queue = new PromiseQueue(NEEDLE_progressive.maxConcurrent, { debug: debug != false });
1059
+ static queue = new PromiseQueue(50, { debug: debug != false });
969
1060
  static assignLODInformation(url, res, key, level, index) {
970
1061
  if (!res)
971
1062
  return;
@@ -1,3 +1,4 @@
1
+ import { Texture } from "three";
1
2
  export declare function isDebugMode(): string | boolean;
2
3
  export declare function getParam(name: string): boolean | string;
3
4
  export declare function resolveUrl(source: string | undefined, uri: string): string;
@@ -18,11 +19,11 @@ export type SlotReturnValue<T = any> = {
18
19
  * Use the `slot` method to request a slot for a promise with a specific key. The returned promise resolves to an object with a `use` method that can be called to add the promise to the queue.
19
20
  */
20
21
  export declare class PromiseQueue<T = any> {
21
- readonly maxConcurrent: number;
22
+ maxConcurrent: number;
22
23
  private readonly _running;
23
24
  private readonly _queue;
24
25
  debug: boolean;
25
- constructor(maxConcurrent?: number, opts?: {
26
+ constructor(maxConcurrent: number, opts?: {
26
27
  debug?: boolean;
27
28
  });
28
29
  private tick;
@@ -33,3 +34,8 @@ export declare class PromiseQueue<T = any> {
33
34
  private add;
34
35
  private internalUpdate;
35
36
  }
37
+ export declare function determineTextureMemoryInBytes(texture: Texture): number;
38
+ /**
39
+ * Detect the GPU memory of the current device. This is a very rough estimate based on the renderer information, and may not be accurate. It returns the estimated memory in MB, or `undefined` if it cannot be detected.
40
+ */
41
+ export declare function detectGPUMemory(): number | undefined;
@@ -71,7 +71,7 @@ export class PromiseQueue {
71
71
  _running = new Map();
72
72
  _queue = [];
73
73
  debug = false;
74
- constructor(maxConcurrent = 100, opts = {}) {
74
+ constructor(maxConcurrent, opts = {}) {
75
75
  this.maxConcurrent = maxConcurrent;
76
76
  this.debug = opts.debug ?? false;
77
77
  window.requestAnimationFrame(this.tick);
@@ -115,3 +115,95 @@ export class PromiseQueue {
115
115
  }
116
116
  }
117
117
  }
118
+ // #region Texture Memory
119
+ export function determineTextureMemoryInBytes(texture) {
120
+ const width = texture.image?.width ?? 0;
121
+ const height = texture.image?.height ?? 0;
122
+ const depth = texture.image?.depth ?? 1;
123
+ const mipLevels = Math.floor(Math.log2(Math.max(width, height, depth))) + 1;
124
+ const bytesPerPixel = getBytesPerPixel(texture);
125
+ const totalBytes = (width * height * depth * bytesPerPixel * (1 - Math.pow(0.25, mipLevels))) / (1 - 0.25);
126
+ return totalBytes;
127
+ }
128
+ function getBytesPerPixel(texture) {
129
+ // Determine channel count from format
130
+ let channels = 4; // Default RGBA
131
+ const format = texture.format;
132
+ if (format === 1024)
133
+ channels = 1; // RedFormat
134
+ else if (format === 1025)
135
+ channels = 1; // RedIntegerFormat
136
+ else if (format === 1026)
137
+ channels = 2; // RGFormat
138
+ else if (format === 1027)
139
+ channels = 2; // RGIntegerFormat
140
+ else if (format === 1022)
141
+ channels = 3; // RGBFormat
142
+ else if (format === 1029)
143
+ channels = 3; // RGBIntegerFormat
144
+ else if (format === 1023)
145
+ channels = 4; // RGBAFormat
146
+ else if (format === 1033)
147
+ channels = 4; // RGBAIntegerFormat
148
+ // Determine bytes per channel from type
149
+ let bytesPerChannel = 1; // UnsignedByteType default
150
+ const type = texture.type;
151
+ if (type === 1009)
152
+ bytesPerChannel = 1; // UnsignedByteType
153
+ else if (type === 1010)
154
+ bytesPerChannel = 1; // ByteType
155
+ else if (type === 1011)
156
+ bytesPerChannel = 2; // ShortType
157
+ else if (type === 1012)
158
+ bytesPerChannel = 2; // UnsignedShortType
159
+ else if (type === 1013)
160
+ bytesPerChannel = 4; // IntType
161
+ else if (type === 1014)
162
+ bytesPerChannel = 4; // UnsignedIntType
163
+ else if (type === 1015)
164
+ bytesPerChannel = 4; // FloatType
165
+ else if (type === 1016)
166
+ bytesPerChannel = 2; // HalfFloatType
167
+ const bytesPerPixel = channels * bytesPerChannel;
168
+ return bytesPerPixel;
169
+ }
170
+ // #region GPU
171
+ let rendererInfo;
172
+ /**
173
+ * Detect the GPU memory of the current device. This is a very rough estimate based on the renderer information, and may not be accurate. It returns the estimated memory in MB, or `undefined` if it cannot be detected.
174
+ */
175
+ export function detectGPUMemory() {
176
+ if (rendererInfo !== undefined) {
177
+ return rendererInfo?.estimatedMemory;
178
+ }
179
+ const canvas = document.createElement('canvas');
180
+ const powerPreference = "high-performance";
181
+ const gl = canvas.getContext('webgl', { powerPreference }) || canvas.getContext('experimental-webgl', { powerPreference });
182
+ if (!gl) {
183
+ return undefined;
184
+ }
185
+ if ("getExtension" in gl) {
186
+ const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
187
+ if (debugInfo) {
188
+ const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
189
+ const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
190
+ // Estimate memory based on renderer information (this is a very rough estimate)
191
+ let estimatedMemory = 512;
192
+ if (/NVIDIA/i.test(renderer)) {
193
+ estimatedMemory = 2048;
194
+ }
195
+ else if (/AMD/i.test(renderer)) {
196
+ estimatedMemory = 1024;
197
+ }
198
+ else if (/Intel/i.test(renderer)) {
199
+ estimatedMemory = 512;
200
+ }
201
+ rendererInfo = { vendor, renderer, estimatedMemory };
202
+ return estimatedMemory;
203
+ }
204
+ }
205
+ else {
206
+ rendererInfo = null;
207
+ }
208
+ return undefined;
209
+ }
package/lib/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // replaced at build time
2
- export const version = "3.4.0-beta";
2
+ export const version = "3.4.0-beta.2";
3
3
  globalThis["GLTF_PROGRESSIVE_VERSION"] = version;
4
4
  console.debug(`[gltf-progressive] version ${version || "-"}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-progressive",
3
- "version": "3.4.0-beta",
3
+ "version": "3.4.0-beta.2",
4
4
  "description": "three.js support for loading glTF or GLB files that contain progressive loading data",
5
5
  "homepage": "https://needle.tools",
6
6
  "author": {