streaming-gltf 1.0.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.
@@ -0,0 +1,253 @@
1
+ // DeferredLoadQueue — priority-based async LOD loading with concurrent throttling.
2
+ //
3
+ // Responsibilities:
4
+ // - Queue LOD load requests with distance-based priority scoring.
5
+ // - Limit concurrent asset fetches to a configurable max (typically 2).
6
+ // - Track pending and loaded LODs per asset to avoid re-queuing.
7
+ // - Expose queue stats for HUD display (pending count, load time, etc).
8
+ //
9
+ // Design:
10
+ // - Each request is { asset, lod, priority, entity, timestamp }.
11
+ // - Priority = negative distance (closer = higher priority).
12
+ // - Two heaps: PENDING (sorted by priority) and IN_FLIGHT (tracks active loads).
13
+ // - Throttle: when a load completes, pop the next item from PENDING.
14
+
15
+ export class DeferredLoadQueue {
16
+ constructor(maxConcurrent = 2, maxQueueSize = 50, requestTimeoutMs = 5000) {
17
+ this.maxConcurrent = maxConcurrent;
18
+ this.maxQueueSize = maxQueueSize;
19
+ this.requestTimeoutMs = requestTimeoutMs;
20
+ this._inFlight = 0;
21
+ this._pending = []; // min-heap of { asset, meshDescIdx, lodIdx, priority, entity, timestamp, abortController }
22
+ this._pending_set = new Set(); // dedup key: `assetUrl:meshDescIdx:lodIdx`
23
+ this._loading = new Map(); // meshDescIdx:lodIdx -> Promise
24
+ this._loadedLods = new Map(); // assetUrl -> Set of loaded lodIdxs per meshDescIdx
25
+ this._timeoutHandles = new Map(); // key -> timeoutId
26
+ this._stats = {
27
+ queued: 0,
28
+ inFlight: 0,
29
+ totalLoaded: 0,
30
+ avgLoadTimeMs: 0,
31
+ loadTimes: [], // rolling window of recent load times
32
+ dropped: 0, // dropped due to timeout or queue overflow
33
+ };
34
+ }
35
+
36
+ // Enqueue a LOD for loading. Returns true if queued, false if already loaded or pending.
37
+ queueLoad(asset, meshDescIdx, lodIdx, priority = 0, entity = null) {
38
+ if (!asset || meshDescIdx == null || lodIdx == null) return false;
39
+
40
+ const key = `${asset.url}:${meshDescIdx}:${lodIdx}`;
41
+
42
+ // Check if already loaded
43
+ const assetLoads = this._loadedLods.get(asset.url);
44
+ if (assetLoads?.has(key)) return false;
45
+
46
+ // Check if already in pending queue
47
+ if (this._pending_set.has(key)) return false;
48
+
49
+ // Check if actively loading
50
+ if (this._loading.has(key)) return false;
51
+
52
+ // If queue is too large, drop oldest low-priority requests
53
+ if (this._pending.length >= this.maxQueueSize) {
54
+ const dropped = this._pending.pop(); // remove lowest priority (heap tail)
55
+ this._pending_set.delete(dropped.key);
56
+ clearTimeout(this._timeoutHandles.get(dropped.key));
57
+ this._timeoutHandles.delete(dropped.key);
58
+ this._stats.dropped++;
59
+ console.warn(`[deferred-queue] Queue size exceeded ${this.maxQueueSize}, dropped lowest-priority LOD: ${dropped.key}`);
60
+ if (this._pending.length > 0) this._bubbleDown(0); // maintain heap after removal
61
+ }
62
+
63
+ // Add to pending queue
64
+ const item = {
65
+ asset,
66
+ meshDescIdx,
67
+ lodIdx,
68
+ priority, // negative distance (closer = higher priority)
69
+ entity,
70
+ timestamp: performance.now(),
71
+ key,
72
+ };
73
+ this._pending.push(item);
74
+ this._pending_set.add(key);
75
+ this._stats.queued = this._pending.length;
76
+
77
+ // Re-heapify to maintain min-heap property (higher priority at root)
78
+ this._bubbleUp(this._pending.length - 1);
79
+
80
+ // Set timeout to drop stale request
81
+ const timeoutId = setTimeout(() => {
82
+ this._removeRequest(key);
83
+ console.warn(`[deferred-queue] LOD request timed out after ${this.requestTimeoutMs}ms: ${key}`);
84
+ }, this.requestTimeoutMs);
85
+ this._timeoutHandles.set(key, timeoutId);
86
+
87
+ // Try to load immediately if under concurrent limit
88
+ this._processNext();
89
+
90
+ return true;
91
+ }
92
+
93
+ // Remove a request from the queue (called by timeout or cleanup)
94
+ _removeRequest(key) {
95
+ if (!this._pending_set.has(key)) return;
96
+
97
+ this._pending_set.delete(key);
98
+ this._stats.dropped++;
99
+
100
+ // Find and remove from heap
101
+ const idx = this._pending.findIndex(item => item.key === key);
102
+ if (idx >= 0) {
103
+ this._pending.splice(idx, 1);
104
+ if (idx < this._pending.length) this._bubbleDown(idx);
105
+ }
106
+
107
+ clearTimeout(this._timeoutHandles.get(key));
108
+ this._timeoutHandles.delete(key);
109
+ this._stats.queued = this._pending.length;
110
+ }
111
+
112
+ // Process next pending load if under concurrent limit
113
+ _processNext() {
114
+ if (this._inFlight >= this.maxConcurrent || !this._pending.length) return;
115
+
116
+ const item = this._popHighestPriority();
117
+ if (!item) return;
118
+
119
+ this._inFlight++;
120
+ this._stats.inFlight = this._inFlight;
121
+ const tLoad0 = performance.now();
122
+ const key = item.key;
123
+
124
+ // Clear timeout since load is starting
125
+ clearTimeout(this._timeoutHandles.get(key));
126
+ this._timeoutHandles.delete(key);
127
+
128
+ // Track this load in flight
129
+ const promise = item.asset.ensureMeshLod(item.meshDescIdx, item.lodIdx)
130
+ .then((geo) => {
131
+ const tLoad1 = performance.now();
132
+ const loadTime = tLoad1 - item.timestamp;
133
+
134
+ // Record load time for stats
135
+ this._stats.loadTimes.push(loadTime);
136
+ if (this._stats.loadTimes.length > 20) this._stats.loadTimes.shift(); // rolling window
137
+ this._stats.avgLoadTimeMs = this._stats.loadTimes.reduce((a, b) => a + b, 0) / this._stats.loadTimes.length;
138
+ this._stats.totalLoaded++;
139
+
140
+ // Mark as loaded
141
+ if (!this._loadedLods.has(item.asset.url)) {
142
+ this._loadedLods.set(item.asset.url, new Set());
143
+ }
144
+ this._loadedLods.get(item.asset.url).add(key);
145
+
146
+ return geo;
147
+ })
148
+ .finally(() => {
149
+ this._inFlight--;
150
+ this._stats.inFlight = this._inFlight;
151
+ this._loading.delete(key);
152
+ // Process next pending load
153
+ this._processNext();
154
+ });
155
+
156
+ this._loading.set(key, promise);
157
+ }
158
+
159
+ // Min-heap: bubble up (used when inserting)
160
+ _bubbleUp(idx) {
161
+ if (idx <= 0) return;
162
+ const parent = Math.floor((idx - 1) / 2);
163
+ if (this._pending[idx].priority > this._pending[parent].priority) {
164
+ [this._pending[idx], this._pending[parent]] = [this._pending[parent], this._pending[idx]];
165
+ this._bubbleUp(parent);
166
+ }
167
+ }
168
+
169
+ // Min-heap: bubble down (used when removing root)
170
+ _bubbleDown(idx) {
171
+ const left = 2 * idx + 1;
172
+ const right = 2 * idx + 2;
173
+ let smallest = idx;
174
+
175
+ if (left < this._pending.length && this._pending[left].priority > this._pending[smallest].priority) {
176
+ smallest = left;
177
+ }
178
+ if (right < this._pending.length && this._pending[right].priority > this._pending[smallest].priority) {
179
+ smallest = right;
180
+ }
181
+
182
+ if (smallest !== idx) {
183
+ [this._pending[idx], this._pending[smallest]] = [this._pending[smallest], this._pending[idx]];
184
+ this._bubbleDown(smallest);
185
+ }
186
+ }
187
+
188
+ // Extract and return the highest-priority item
189
+ _popHighestPriority() {
190
+ if (!this._pending.length) return null;
191
+
192
+ const root = this._pending[0];
193
+ this._pending_set.delete(root.key);
194
+ this._pending.splice(0, 1);
195
+ if (this._pending.length > 0) this._bubbleDown(0);
196
+ this._stats.queued = this._pending.length;
197
+
198
+ return root;
199
+ }
200
+
201
+ // Get the list of loaded LODs for an asset
202
+ getLoadedLods(asset) {
203
+ return this._loadedLods.get(asset.url) || new Set();
204
+ }
205
+
206
+ // Check if a specific LOD is loaded
207
+ isLodLoaded(assetUrl, meshDescIdx, lodIdx) {
208
+ const key = `${assetUrl}:${meshDescIdx}:${lodIdx}`;
209
+ return this._loadedLods.get(assetUrl)?.has(key) ?? false;
210
+ }
211
+
212
+ // Unload a LOD from memory (called by unload manager)
213
+ unloadLod(asset, meshDescIdx, lodIdx) {
214
+ const key = `${asset.url}:${meshDescIdx}:${lodIdx}`;
215
+ const loads = this._loadedLods.get(asset.url);
216
+ if (loads) {
217
+ loads.delete(key);
218
+ }
219
+ // Geometry disposal is handled by the Asset itself via evictMeshLod
220
+ }
221
+
222
+ // Get current queue stats
223
+ getStats() {
224
+ return {
225
+ queued: this._pending.length,
226
+ inFlight: this._inFlight,
227
+ totalLoaded: this._stats.totalLoaded,
228
+ avgLoadTimeMs: this._stats.avgLoadTimeMs.toFixed(1),
229
+ concurrency: this._inFlight,
230
+ maxConcurrency: this.maxConcurrent,
231
+ dropped: this._stats.dropped,
232
+ };
233
+ }
234
+
235
+ // Update priorities for pending items based on new entity positions
236
+ // Called when entity moves or camera changes to re-sort the queue
237
+ updatePriorities(entities) {
238
+ if (!this._pending.length) return;
239
+
240
+ // Recalculate priorities based on current entity positions
241
+ for (const item of this._pending) {
242
+ if (item.entity) {
243
+ const dist = item.entity._currentDistance ?? Infinity;
244
+ item.priority = -dist; // negative distance (closer = higher priority)
245
+ }
246
+ }
247
+
248
+ // Re-heapify entire array (simpler than selective bubbling)
249
+ for (let i = Math.floor(this._pending.length / 2) - 1; i >= 0; i--) {
250
+ this._bubbleDown(i);
251
+ }
252
+ }
253
+ }