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,2961 @@
1
+ // ModelPool — managed LOD streaming for skinned + morph-target meshes.
2
+ //
3
+ // Responsibilities:
4
+ // - Load a baked progressive asset bundle (root GLB + sibling LOD files +
5
+ // LOCAL_progressive extras blob) at most ONCE per source URL.
6
+ // - Share BufferGeometry and Texture instances across every Entity that
7
+ // spawns from the same asset, so 1000 LANMOWERs reuse one geometry per LOD
8
+ // rather than allocating 1000 copies.
9
+ // - Spawn lightweight Entity handles (SkinnedMesh / Mesh wrappers) wired to
10
+ // the right shared resources for their current LOD.
11
+ // - Run one per-frame update that walks every live Entity, picks an LOD by
12
+ // screen-space density + global ceiling, evicts/fetches as needed.
13
+ // - Emit events ('ready', 'lod-changed', 'evicted', 'budget-pressure',
14
+ // 'fps') so application code can react without polling.
15
+ //
16
+ // Phase A scope: load-once, instance-share, event-driven Entity API.
17
+ // Phases B & C bolt onto this without changing the public surface.
18
+
19
+ import * as THREE from 'three';
20
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
21
+ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
22
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
23
+ import { VRMLoaderPlugin } from '@pixiv/three-vrm';
24
+ import { GlobalMaterialPool } from './material-pool.js';
25
+ import { DeferredLoadQueue } from './deferred-load-queue.js';
26
+ import { LodUnloadManager } from './lod-unload-manager.js';
27
+ import { CachedFrustumPlanes } from './frustum-cache.js';
28
+ import { MultiDrawOptimizer } from './multi-draw-optimizer.js';
29
+ import { BatchedFarTier } from './batched-far-tier.js';
30
+ // Phase 3 Quick-Wins optimizations
31
+ import { VertexCompressionOptimizer } from './vertex-compression.js';
32
+ import { DrawCallSorter, buildDrawCallDescriptors, applyDrawCallSort } from './draw-call-sorter.js';
33
+ import { InstanceBufferPool } from './buffer-pool.js';
34
+
35
+ const _sharedDracoLoader = new DRACOLoader();
36
+ _sharedDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/');
37
+
38
+ // --- scratch objects (per-frame; never alloc in hot path) -----------------
39
+ const _tmpV3 = new THREE.Vector3();
40
+ const _tmpV3b = new THREE.Vector3();
41
+ const _tmpSphere = new THREE.Sphere();
42
+ const _zeroMatrix = new THREE.Matrix4().set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);
43
+ const _identityMatrix = new THREE.Matrix4(); // default = identity
44
+
45
+ // --- shared GLTFLoaders ---------------------------------------------------
46
+ // Two flavors: one with the VRM plugin (root loads), one without (sibling
47
+ // LOD loads — the siblings carry no VRM extension blob, and the plugin's
48
+ // MToon prep has side effects on attribute layout we don't want).
49
+ function _makeLoader(includeVrm) {
50
+ const l = new GLTFLoader();
51
+ l.setMeshoptDecoder(MeshoptDecoder);
52
+ l.setDRACOLoader(_sharedDracoLoader);
53
+ if (includeVrm) l.register((parser) => new VRMLoaderPlugin(parser));
54
+ return l;
55
+ }
56
+
57
+ // --- tiny EventEmitter ----------------------------------------------------
58
+ class Emitter {
59
+ constructor() { this._listeners = new Map(); }
60
+ on(ev, fn) {
61
+ let s = this._listeners.get(ev);
62
+ if (!s) { s = new Set(); this._listeners.set(ev, s); }
63
+ s.add(fn);
64
+ return () => s.delete(fn);
65
+ }
66
+ emit(ev, payload) {
67
+ const s = this._listeners.get(ev);
68
+ if (!s) return;
69
+ for (const fn of s) {
70
+ try { fn(payload); } catch (e) { console.error(`[ModelPool] listener for ${ev} threw`, e); }
71
+ }
72
+ }
73
+ }
74
+
75
+ // --- InstancedPool: one shared InstancedMesh per (asset, lod) -------------
76
+ // For the unskinned LOD tier we don't need per-entity skeletons or per-entity
77
+ // SkinnedMesh shells; the mesh is in bind pose and only its TRANSFORM differs
78
+ // across entities. Wrapping them all in one InstancedMesh collapses N draw
79
+ // calls into 1, which is the only realistic path to 1000+ entities on
80
+ // commodity hardware.
81
+ class InstancedSlot {
82
+ constructor(pool, asset, meshDescIdx, lodIdx, geo, material) {
83
+ this.pool = pool;
84
+ this.asset = asset;
85
+ this.meshDescIdx = meshDescIdx;
86
+ this.lodIdx = lodIdx;
87
+ this.geometry = geo;
88
+ this.material = material;
89
+ this.capacity = 32; // grow as needed
90
+ // Per-frame uniform — ModelPool.update writes the camera's
91
+ // projection*view matrix into here so the vertex shader can do GPU
92
+ // frustum culling without a CPU sphere test per entity.
93
+ this._uniforms = { projViewMatrix: { value: new THREE.Matrix4() } };
94
+ // Initialize frustum plane cache (shared across all slots in this pool)
95
+ if (!pool._frustumCache) pool._frustumCache = new CachedFrustumPlanes();
96
+ this._uniforms.frustumPlanes = { value: pool._frustumCache.getPlaneUniforms() };
97
+ // GPU-driven per-instance transform: a float DataTexture holds each
98
+ // instance's model matrix as 4 RGBA texels (one mat4 column per texel),
99
+ // instance i -> texels [i*4 .. i*4+3]. The vertex shader rebuilds the
100
+ // matrix from gl_InstanceID, so JS never re-uploads a full instance buffer
101
+ // per frame; a single model move is one 4-texel write + a dirty flag.
102
+ // Each slot needs its OWN instanceTex uniform, so when the GPU path is on
103
+ // the slot must use a PER-SLOT material (the shared global FAR material
104
+ // could only bind one slot's texture). Each slot is already its own
105
+ // InstancedMesh = its own draw, so cloning the material adds no draw call.
106
+ this._gpuInstanceTex = pool._enableGpuInstanceTex !== false;
107
+ if (this._gpuInstanceTex) {
108
+ material = material.clone();
109
+ this._initInstanceTexture(this.capacity);
110
+ }
111
+ _patchInstancedSlotMaterial(material, this._uniforms);
112
+ this.material = material;
113
+ this.mesh = new THREE.InstancedMesh(geo, material, this.capacity);
114
+ this.mesh.frustumCulled = false; // GPU vertex-shader handles culling
115
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
116
+ // Per-instance world-space bounding sphere (cx, cy, cz, r). Set on
117
+ // slot acquire / update; the vertex shader reads this and collapses
118
+ // out-of-frustum instances to NaN.
119
+ this._boundArray = new Float32Array(this.capacity * 4);
120
+ this._boundAttr = new THREE.InstancedBufferAttribute(this._boundArray, 4);
121
+ this._boundAttr.setUsage(THREE.DynamicDrawUsage);
122
+ this.mesh.geometry.setAttribute('instanceBoundSphere', this._boundAttr);
123
+ // Zero out all instance matrices initially so unused slots draw nothing
124
+ // visible (zero matrix collapses to origin point).
125
+ const zero = new THREE.Matrix4().set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);
126
+ for (let i = 0; i < this.capacity; i++) this.mesh.setMatrixAt(i, zero);
127
+ this.mesh.count = 0;
128
+ this.mesh.instanceMatrix.needsUpdate = true;
129
+ this.slots = new Map(); // entity -> slot index
130
+ this.freeSlots = []; // recycled indices
131
+ this.nextSlot = 0;
132
+ this._dirtySlots = new Set(); // tracks which slot indices need GPU upload
133
+ }
134
+
135
+ acquireSlot(entity) {
136
+ let idx;
137
+ if (this.freeSlots.length) idx = this.freeSlots.pop();
138
+ else {
139
+ if (this.nextSlot >= this.capacity) this._grow(this.capacity * 2);
140
+ idx = this.nextSlot++;
141
+ }
142
+ this.slots.set(entity, idx);
143
+ if (idx + 1 > this.mesh.count) this.mesh.count = idx + 1;
144
+ return idx;
145
+ }
146
+ releaseSlot(entity) {
147
+ const idx = this.slots.get(entity);
148
+ if (idx == null) return;
149
+ this.slots.delete(entity);
150
+ this.freeSlots.push(idx);
151
+ // Zero its matrix so it stops drawing (collapses to origin / degenerate).
152
+ const zero = _zeroMatrix;
153
+ if (this._gpuInstanceTex) {
154
+ this.setInstanceTransform(idx, zero);
155
+ } else {
156
+ this.mesh.setMatrixAt(idx, zero);
157
+ this._dirtySlots.add(idx);
158
+ }
159
+ // Zero the bound-sphere radius so the shader treats this slot as
160
+ // "no bound info" → also drawn at origin (zero matrix). Belt+braces.
161
+ const o = idx * 4;
162
+ this._boundArray[o] = 0; this._boundArray[o+1] = 0; this._boundArray[o+2] = 0; this._boundArray[o+3] = 0;
163
+ this._boundAttr.needsUpdate = true;
164
+ }
165
+ setMatrixForSlot(idx, matrix) {
166
+ if (this._gpuInstanceTex) {
167
+ // GPU path: write the matrix into the instance data texture. The shader
168
+ // reads it by gl_InstanceID; we do not touch the instanceMatrix attribute.
169
+ this.setInstanceTransform(idx, matrix);
170
+ return;
171
+ }
172
+ this.mesh.setMatrixAt(idx, matrix);
173
+ this._dirtySlots.add(idx);
174
+ }
175
+ // Optimization 2: Deferred matrix buffer uploads
176
+ // Only mark needsUpdate if dirty slots exceed threshold (5-10% of capacity)
177
+ // This reduces GPU buffer sync stalls by batching updates across multiple frames
178
+ flushMatrixUpdates() {
179
+ if (this._gpuInstanceTex) { this.flushInstanceTexture(); return; }
180
+ if (this._dirtySlots.size > 0) {
181
+ // ALWAYS flush when there are dirty slots. The old 5%-of-capacity gate
182
+ // skipped the GPU upload for small dirty counts but cleared _dirtySlots
183
+ // anyway, so a released/moved instance's matrix sat un-uploaded in the CPU
184
+ // buffer for frames — producing ghost models that pop in/out (most visible
185
+ // when the zoom-cycle camera transitions a few entities' LOD at a time).
186
+ this.mesh.instanceMatrix.needsUpdate = true;
187
+ this._dirtySlots.clear();
188
+ }
189
+ }
190
+ setBoundSphereForSlot(idx, cx, cy, cz, r) {
191
+ const o = idx * 4;
192
+ this._boundArray[o] = cx;
193
+ this._boundArray[o+1] = cy;
194
+ this._boundArray[o+2] = cz;
195
+ this._boundArray[o+3] = r;
196
+ this._boundAttr.needsUpdate = true;
197
+ }
198
+ // --- GPU instance transform texture --------------------------------------
199
+ // Texture layout: width = capacity*4 texels (4 per instance = a mat4's four
200
+ // columns), height = 1. RGBA32F. instance i occupies texels [i*4 .. i*4+3].
201
+ _initInstanceTexture(capacity) {
202
+ const texelsPerInstance = 4;
203
+ this._instTexWidth = capacity * texelsPerInstance;
204
+ this._instTexData = new Float32Array(this._instTexWidth * 4);
205
+ const tex = new THREE.DataTexture(this._instTexData, this._instTexWidth, 1, THREE.RGBAFormat, THREE.FloatType);
206
+ // NearestFilter: exact texel reads + avoids OES_texture_float_linear
207
+ // requirement (linear-filtering a float texture raises GL_INVALID_OPERATION).
208
+ tex.minFilter = THREE.NearestFilter;
209
+ tex.magFilter = THREE.NearestFilter;
210
+ tex.generateMipmaps = false;
211
+ tex.needsUpdate = true;
212
+ this._instTex = tex;
213
+ // Reuse existing uniform objects on regrow so the material's captured
214
+ // references (from onBeforeCompile) stay valid — only swap their .value.
215
+ if (this._uniforms.instanceTex) {
216
+ this._uniforms.instanceTex.value = tex;
217
+ this._uniforms.instanceTexWidth.value = this._instTexWidth;
218
+ } else {
219
+ this._uniforms.instanceTex = { value: tex };
220
+ this._uniforms.instanceTexWidth = { value: this._instTexWidth };
221
+ }
222
+ // Dirty-range tracking for partial uploads (min..max texel column touched).
223
+ this._instTexDirtyLo = Infinity;
224
+ this._instTexDirtyHi = -1;
225
+ }
226
+ // Write one instance's mat4 into its 4 texels. Marks only that instance's
227
+ // column range dirty — a single model move costs one 4-texel write here.
228
+ setInstanceTransform(idx, matrix) {
229
+ const e = matrix.elements; // column-major 16 floats
230
+ const base = idx * 4 * 4; // 4 texels * 4 channels
231
+ // column c -> texel (idx*4 + c) -> data[base + c*4 .. +3]
232
+ for (let c = 0; c < 4; c++) {
233
+ const o = base + c * 4;
234
+ const m = c * 4;
235
+ this._instTexData[o] = e[m];
236
+ this._instTexData[o + 1] = e[m + 1];
237
+ this._instTexData[o + 2] = e[m + 2];
238
+ this._instTexData[o + 3] = e[m + 3];
239
+ }
240
+ const loCol = idx * 4, hiCol = idx * 4 + 3;
241
+ if (loCol < this._instTexDirtyLo) this._instTexDirtyLo = loCol;
242
+ if (hiCol > this._instTexDirtyHi) this._instTexDirtyHi = hiCol;
243
+ }
244
+ // Upload only the touched texel columns. THREE's DataTexture lacks a public
245
+ // partial-upload API on all paths, so we flag needsUpdate (full re-upload of
246
+ // a width*1 row — cheap: capacity*4 texels) only when something actually
247
+ // changed this frame. Static frames upload nothing.
248
+ flushInstanceTexture() {
249
+ if (this._instTexDirtyHi >= 0) {
250
+ this._instTex.needsUpdate = true;
251
+ this._instTexDirtyLo = Infinity;
252
+ this._instTexDirtyHi = -1;
253
+ }
254
+ }
255
+ _grow(newCap) {
256
+ const old = this.mesh;
257
+ const next = new THREE.InstancedMesh(this.geometry, this.material, newCap);
258
+ next.frustumCulled = false;
259
+ next.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
260
+ if (this._gpuInstanceTex) {
261
+ // Grow the instance data texture, preserving existing instance matrices.
262
+ const oldData = this._instTexData;
263
+ this._initInstanceTexture(newCap);
264
+ this._instTexData.set(oldData); // copy old texels into the front of the new buffer
265
+ this._instTex.needsUpdate = true;
266
+ // Re-point the shader uniform at the new texture (same uniform object the
267
+ // material's onBeforeCompile captured, so just swap its .value).
268
+ this._uniforms.instanceTex.value = this._instTex;
269
+ this._uniforms.instanceTexWidth.value = this._instTexWidth;
270
+ } else {
271
+ const m = new THREE.Matrix4();
272
+ for (let i = 0; i < this.nextSlot; i++) {
273
+ old.getMatrixAt(i, m);
274
+ next.setMatrixAt(i, m);
275
+ }
276
+ next.instanceMatrix.needsUpdate = true;
277
+ }
278
+ next.count = old.count;
279
+ // Grow + carry the per-instance bound-sphere attribute.
280
+ const newBounds = new Float32Array(newCap * 4);
281
+ newBounds.set(this._boundArray);
282
+ this._boundArray = newBounds;
283
+ this._boundAttr = new THREE.InstancedBufferAttribute(newBounds, 4);
284
+ this._boundAttr.setUsage(THREE.DynamicDrawUsage);
285
+ next.geometry.setAttribute('instanceBoundSphere', this._boundAttr);
286
+ const parent = old.parent;
287
+ if (parent) {
288
+ parent.remove(old);
289
+ parent.add(next);
290
+ }
291
+ old.dispose();
292
+ this.mesh = next;
293
+ this.capacity = newCap;
294
+ this._dirtySlots = new Set();
295
+ }
296
+ }
297
+
298
+ // Patch a material so its vertex shader receives a per-instance bound-sphere
299
+ // attribute and a per-frame projViewMatrix uniform, then collapses any
300
+ // instance outside the camera frustum to a NaN clip-space position so the GPU
301
+ // early-rejects it. Frustum planes are pre-normalized on the CPU, so the cull
302
+ // is a branch-free dot product per plane with no per-vertex sqrt/divide.
303
+ // Wraps any existing onBeforeCompile so the vertex-color gamma patch on the
304
+ // fragment side still runs.
305
+ function _patchInstancedSlotMaterial(material, uniforms) {
306
+ const prev = material.onBeforeCompile;
307
+ material.onBeforeCompile = (shader) => {
308
+ if (prev) prev(shader);
309
+ shader.uniforms.projViewMatrix = uniforms.projViewMatrix;
310
+ // 6 pre-computed, unit-normalized frustum planes (normal.xyz + constant.w),
311
+ // updated once per frame on the CPU. The vertex shader uses them directly.
312
+ shader.uniforms.frustumPlanes = uniforms.frustumPlanes;
313
+ // GPU instance transform texture (per-instance mat4 as 4 RGBA texels).
314
+ if (uniforms.instanceTex) {
315
+ shader.uniforms.instanceTex = uniforms.instanceTex;
316
+ shader.uniforms.instanceTexWidth = uniforms.instanceTexWidth;
317
+ shader.defines = shader.defines || {};
318
+ shader.defines.USE_GPU_INSTANCE_TEX = '';
319
+ }
320
+ shader.vertexShader = shader.vertexShader
321
+ .replace(
322
+ '#include <common>',
323
+ `#include <common>
324
+ attribute vec4 instanceBoundSphere;
325
+ uniform mat4 projViewMatrix;
326
+ uniform vec4 frustumPlanes[6];
327
+ #ifdef USE_GPU_INSTANCE_TEX
328
+ uniform sampler2D instanceTex;
329
+ uniform float instanceTexWidth;
330
+ mat4 readInstanceMatrix(int id) {
331
+ // 4 texels per instance; fetch by pixel center. height = 1.
332
+ float base = float(id) * 4.0;
333
+ vec4 c0 = texture2D(instanceTex, vec2((base + 0.5) / instanceTexWidth, 0.5));
334
+ vec4 c1 = texture2D(instanceTex, vec2((base + 1.5) / instanceTexWidth, 0.5));
335
+ vec4 c2 = texture2D(instanceTex, vec2((base + 2.5) / instanceTexWidth, 0.5));
336
+ vec4 c3 = texture2D(instanceTex, vec2((base + 3.5) / instanceTexWidth, 0.5));
337
+ return mat4(c0, c1, c2, c3);
338
+ }
339
+ #endif`
340
+ )
341
+ .replace(
342
+ '#include <project_vertex>',
343
+ `#ifdef USE_GPU_INSTANCE_TEX
344
+ // GPU-driven transform: rebuild this instance's model matrix from the
345
+ // instance data texture (by gl_InstanceID) instead of the instanceMatrix
346
+ // attribute. mvPosition is declared at outer scope (exactly like the stock
347
+ // <project_vertex> chunk) so downstream chunks that read it still compile.
348
+ vec4 mvPosition = modelViewMatrix * readInstanceMatrix(gl_InstanceID) * vec4(transformed, 1.0);
349
+ gl_Position = projectionMatrix * mvPosition;
350
+ #else
351
+ #include <project_vertex>
352
+ #endif
353
+ {
354
+ // GPU per-instance frustum cull.
355
+ // frustumPlanes are pre-normalized CPU-side (THREE.Frustum emits unit
356
+ // normals), so the plane equation reduces to dot(n, c) + w >= -r with no
357
+ // per-vertex sqrt/divide. (Removed the old length()/division — it was
358
+ // normalizing an already-unit vector. Also removed the dead lodLutTexture
359
+ // fetch + vLodIndex varying: LOD selection happens CPU-side, the varying
360
+ // was written but never read by any fragment shader.)
361
+ if (instanceBoundSphere.w > 0.0) {
362
+ vec3 c = instanceBoundSphere.xyz;
363
+ float r = instanceBoundSphere.w;
364
+ bool outside = false;
365
+ for (int i = 0; i < 6; i++) {
366
+ vec4 p = frustumPlanes[i];
367
+ if (dot(p.xyz, c) + p.w < -r) { outside = true; break; }
368
+ }
369
+ if (outside) {
370
+ gl_Position = vec4(0.0/0.0, 0.0/0.0, 0.0/0.0, 0.0/0.0) * 0.0;
371
+ return;
372
+ }
373
+ }
374
+ }`
375
+ );
376
+ };
377
+ material.needsUpdate = true;
378
+ }
379
+
380
+ // --- Asset: shared resources for one source URL ---------------------------
381
+ // Loaded once, referenced by N Entity instances.
382
+ class Asset {
383
+ constructor(pool, url) {
384
+ this.pool = pool;
385
+ this.url = url;
386
+ this.state = 'pending'; // 'pending' | 'loading' | 'ready' | 'error'
387
+ this.error = null;
388
+ // baseDir is the directory of the root model.progressive.glb so we can
389
+ // resolve sibling LOD relative paths against it.
390
+ this.baseDir = url.endsWith('/') ? url : url.replace(/[^/]+$/, '');
391
+ // Per-mesh LOD descriptors from the LOCAL_progressive extras blob, sorted
392
+ // by quality ascending (idx 0 = lowest, idx N-1 = highest).
393
+ this.meshLodDescs = []; // [{ meshIndex, primIndex, lods: [...] }]
394
+ this.texLodDescs = []; // [{ textureIndex, name, lods: [...] }]
395
+ // Cached shared geometries: key `${meshIndex}:${primIndex}:${lodIdx}` -> BufferGeometry
396
+ this.geoCache = new Map();
397
+ // Cached shared texture bitmaps: key `${textureIndex}:${lodIdx}` -> ImageBitmap
398
+ this.texCache = new Map();
399
+ // The original gltf payload from the root load — used to clone scenes
400
+ // per-entity. Held as the parsed three.js Object3D plus parser.json.
401
+ this.rootGltf = null;
402
+ // VRM extension blob (if present) so spawned entities can re-bind to a
403
+ // matching three-vrm runtime per entity.
404
+ this.hasVRM = false;
405
+ // Track bytes-loaded per LOD for the budget system in Phase C.
406
+ this.byteWeights = new Map(); // key -> bytes
407
+ // Loaders need to know whether this asset is VRM-bearing (root) or a
408
+ // plain sibling LOD (no VRM).
409
+ this._rootLoader = _makeLoader(true);
410
+ this._lodLoader = _makeLoader(false);
411
+ // Promise that resolves when the root is parsed.
412
+ this.ready = this._load();
413
+ }
414
+
415
+ async _fetchBytes(url) {
416
+ const res = await fetch(url);
417
+ if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
418
+ const buf = new Uint8Array(await res.arrayBuffer());
419
+ this.pool._trackBytes(this.url, url, buf.byteLength);
420
+ return buf;
421
+ }
422
+
423
+ async _load() {
424
+ this.state = 'loading';
425
+ try {
426
+ const rootBytes = await this._fetchBytes(this.url);
427
+ const gltf = await new Promise((resolve, reject) => {
428
+ this._rootLoader.parse(rootBytes.buffer, '', resolve, reject);
429
+ });
430
+ this.rootGltf = gltf;
431
+ this.hasVRM = !!gltf.userData?.vrm;
432
+ const ext = gltf.parser.json?.extras?.LOCAL_progressive;
433
+ if (ext) {
434
+ const kindRank = { unskinned: 0, vertcolor: 1, textured: 2 };
435
+ for (const m of ext.meshes) {
436
+ const sorted = [...m.lods].sort((a, b) => {
437
+ const ra = kindRank[a.kind || 'textured'] ?? 2;
438
+ const rb = kindRank[b.kind || 'textured'] ?? 2;
439
+ if (ra !== rb) return ra - rb;
440
+ return (a.ratio || 0) - (b.ratio || 0);
441
+ });
442
+ this.meshLodDescs.push({ meshIndex: m.meshIndex, primIndex: m.primIndex, lods: sorted });
443
+ }
444
+ for (const t of ext.textures) {
445
+ const sortedT = [...t.lods].sort((a, b) => a.width - b.width);
446
+ this.texLodDescs.push({ textureIndex: t.textureIndex, name: t.name, lods: sortedT });
447
+ }
448
+ }
449
+ // Pre-cache the inline (lowest-textured) geometry per primitive AND the
450
+ // smallest textures from the root — they're already in the parsed
451
+ // gltf scene; no second fetch needed.
452
+ let meshIdx = 0;
453
+ // ALL LODs are kept in mesh-LOCAL space (sibling LODs via the decodeAABB
454
+ // remap in _bakeQuantizeDecode; the receiving tm.mesh / instanced slot
455
+ // applies the world transform at render). The inline (LOD0) geometry must
456
+ // follow the SAME convention — DO NOT bake matrixWorld into it, or assets
457
+ // whose mesh node has a non-identity transform end up double/differently
458
+ // transformed and visibly FLIP orientation when switching to a sibling LOD.
459
+ gltf.scene.traverse((c) => {
460
+ if (c.isMesh) {
461
+ const desc = this.meshLodDescs[meshIdx];
462
+ if (desc) {
463
+ const inlineLodIdx = desc.lods.findIndex((l) => l.inline);
464
+ if (inlineLodIdx >= 0) {
465
+ this.geoCache.set(`${desc.meshIndex}:${desc.primIndex}:${inlineLodIdx}`, c.geometry);
466
+ }
467
+ }
468
+ meshIdx++;
469
+ }
470
+ });
471
+ // Cache the inline-sized texture bitmaps from the parsed scene.
472
+ gltf.scene.traverse((c) => {
473
+ if (!c.isMesh || !c.material) return;
474
+ const slots = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap'];
475
+ for (const s of slots) {
476
+ const tex = c.material[s];
477
+ if (!tex || !tex.image) continue;
478
+ // Find which descriptor this texture belongs to (name match).
479
+ const desc = this.texLodDescs.find((d) => d.name === tex.name);
480
+ if (desc) {
481
+ const inlineIdx = desc.lods.findIndex((l) => l.inline);
482
+ if (inlineIdx >= 0) {
483
+ this.texCache.set(`${desc.textureIndex}:${inlineIdx}`, tex.image);
484
+ }
485
+ }
486
+ }
487
+ });
488
+ this.state = 'ready';
489
+ } catch (e) {
490
+ this.state = 'error';
491
+ this.error = e;
492
+ throw e;
493
+ }
494
+ }
495
+
496
+ // Fetch a mesh LOD's shared geometry. Returns a Promise<BufferGeometry>.
497
+ // Triggers the pool's per-asset request queue.
498
+ async ensureMeshLod(meshDescIdx, lodIdx) {
499
+ const desc = this.meshLodDescs[meshDescIdx];
500
+ if (!desc) return null;
501
+ const target = desc.lods[lodIdx];
502
+ if (!target) return null;
503
+ const key = `${desc.meshIndex}:${desc.primIndex}:${lodIdx}`;
504
+ const cached = this.geoCache.get(key);
505
+ if (cached) return cached;
506
+ if (target.inline) return null; // should already be cached from root load
507
+ // De-dupe in-flight requests through the pool's load queue (Phase C).
508
+ return this.pool._enqueue(`${this.url}#${key}`, async () => {
509
+ const stillCached = this.geoCache.get(key);
510
+ if (stillCached) return stillCached;
511
+ const fullUrl = this.baseDir + target.path;
512
+ // Worker path: fetch + parse + bake-decode all happen off-thread; we
513
+ // get back a payload of transferable typed arrays and a small bbox
514
+ // record. Main thread only allocates a BufferGeometry shell wiring
515
+ // those arrays as attributes — no per-vertex JS loops.
516
+ if (this.pool._workers.length) {
517
+ try {
518
+ // Far/unskinned LOD: ask the worker to sloppy-decimate to ~400 tris
519
+ // at load (the shipped far LODs are ~6500 tris each = the dominant
520
+ // triangle cost; no re-bake of the 953 assets needed).
521
+ const sloppyCap = (target.kind === 'unskinned') ? (this.pool._farTriCap ?? 400) : 0;
522
+ const payload = await this.pool._workerFetchLod(fullUrl, target.decodeAABB, sloppyCap);
523
+ this.pool._trackBytes(this.url, fullUrl, payload.bytes);
524
+ let geo = ModelPool._buildGeometryFromPayload(payload);
525
+ // Phase 3 QW1: Apply vertex compression (vec4 → vec3)
526
+ geo = this.pool._compressGeometryAttributes(geo);
527
+ // Phase 3 QW5: Apply attribute deinterleaving
528
+ geo = this.pool._deinterleaveGeometryAttributes(geo);
529
+ // Guard: ensure the far/unskinned LOD is capped even if the worker's
530
+ // decimation didn't apply (stale worker, thrown pass, etc.). Idempotent
531
+ // (returns early when already <= cap). This is the single choke point.
532
+ if (target.kind === 'unskinned') _clusterDecimate(geo, this.pool._farTriCap ?? 400);
533
+ this.geoCache.set(key, geo);
534
+ this.byteWeights.set(key, payload.bytes);
535
+ return geo;
536
+ } catch (e) {
537
+ // Fall through to main-thread path on worker failure.
538
+ console.warn('[asset] worker decode failed, fallback main thread', e);
539
+ }
540
+ }
541
+ // Main-thread fallback (or workerCount: 0).
542
+ try {
543
+ const bytes = await this._fetchBytes(fullUrl);
544
+ const gltf = await new Promise((resolve, reject) => {
545
+ this._lodLoader.parse(bytes.buffer, '', resolve, reject);
546
+ });
547
+ let srcMesh = null;
548
+ gltf.scene.updateMatrixWorld(true);
549
+ gltf.scene.traverse((c) => { if (c.isMesh && !srcMesh) srcMesh = c; });
550
+ let geo = srcMesh?.geometry;
551
+ if (geo) {
552
+ _bakeQuantizeDecode(geo, srcMesh.matrixWorld, target.decodeAABB);
553
+ // Phase 3 QW1: Apply vertex compression (vec4 → vec3)
554
+ geo = this.pool._compressGeometryAttributes(geo);
555
+ // Phase 3 QW5: Apply attribute deinterleaving
556
+ geo = this.pool._deinterleaveGeometryAttributes(geo);
557
+ // Cap the far/unskinned LOD AFTER compress/deinterleave so nothing
558
+ // downstream can restore the full-res index (single choke point,
559
+ // matches the worker path's guard).
560
+ if (target.kind === 'unskinned') _clusterDecimate(geo, this.pool._farTriCap ?? 400);
561
+ this.geoCache.set(key, geo);
562
+ this.byteWeights.set(key, bytes.byteLength);
563
+ }
564
+ return geo;
565
+ } catch (e) {
566
+ console.warn(`[asset] LOD mesh ${key} failed to load (${e.message}), using previous LOD`);
567
+ // Graceful fallback: return cached LOD from lower detail level
568
+ if (lodIdx > 0) {
569
+ return await this.ensureMeshLod(meshDescIdx, lodIdx - 1);
570
+ }
571
+ return null; // no fallback available at LOD 0
572
+ }
573
+ });
574
+ }
575
+
576
+ async ensureTexLod(texDescIdx, lodIdx) {
577
+ const desc = this.texLodDescs[texDescIdx];
578
+ if (!desc) return null;
579
+ const target = desc.lods[lodIdx];
580
+ if (!target) return null;
581
+ const key = `${desc.textureIndex}:${lodIdx}`;
582
+ const cached = this.texCache.get(key);
583
+ if (cached) return cached;
584
+ if (target.inline) return null;
585
+ return this.pool._enqueue(`${this.url}#tex:${key}`, async () => {
586
+ const stillCached = this.texCache.get(key);
587
+ if (stillCached) return stillCached;
588
+ const bytes = await this._fetchBytes(this.baseDir + target.path);
589
+ const blob = new Blob([bytes], { type: target.mime || 'image/webp' });
590
+ const bmp = await createImageBitmap(blob, { colorSpaceConversion: 'none' });
591
+ this.texCache.set(key, bmp);
592
+ this.byteWeights.set(`tex:${key}`, bytes.byteLength);
593
+ return bmp;
594
+ });
595
+ }
596
+
597
+ // Evict a LOD's cached resource (Phase C). Called by the pool.
598
+ evictMeshLod(meshDescIdx, lodIdx) {
599
+ const desc = this.meshLodDescs[meshDescIdx];
600
+ if (!desc) return false;
601
+ const key = `${desc.meshIndex}:${desc.primIndex}:${lodIdx}`;
602
+ const target = desc.lods[lodIdx];
603
+ if (target?.inline) return false; // never evict inline geometry
604
+ const geo = this.geoCache.get(key);
605
+ if (!geo) return false;
606
+ geo.dispose();
607
+ this.geoCache.delete(key);
608
+ this.byteWeights.delete(key);
609
+ return true;
610
+ }
611
+ evictTexLod(texDescIdx, lodIdx) {
612
+ const desc = this.texLodDescs[texDescIdx];
613
+ if (!desc) return false;
614
+ const key = `${desc.textureIndex}:${lodIdx}`;
615
+ const target = desc.lods[lodIdx];
616
+ if (target?.inline) return false;
617
+ const bmp = this.texCache.get(key);
618
+ if (!bmp) return false;
619
+ if (bmp.close) bmp.close();
620
+ this.texCache.delete(key);
621
+ this.byteWeights.delete(`tex:${key}`);
622
+ return true;
623
+ }
624
+ }
625
+
626
+ // Repack interleaved attributes into standalone Float32 and bake the source
627
+ // mesh's local matrix (which carries the dequantize transform for plain
628
+ // Meshes loaded via GLTFLoader+KHR_mesh_quantization) into vertex data.
629
+ // When the matrix is identity, fall back to scanning the actual post-
630
+ // dequantize range and remapping into the per-LOD decodeAABB captured at
631
+ // bake time. This is the same logic the inline demo used; lifted into a
632
+ // helper so the pool and any direct consumers can share it.
633
+ // Dependency-free far-LOD decimation by spatial-grid vertex clustering (main-
634
+ // thread copy of the worker's _clusterDecimate, for the fallback load path).
635
+ // Caps the far/unskinned LOD to ~triCap triangles — invisible on a distant dot.
636
+ function _clusterDecimate(geo, triCap) {
637
+ const pos = geo.attributes.position;
638
+ if (!pos) return;
639
+ // Handle non-indexed geometry by synthesizing a sequential index (this was a
640
+ // straggler cause — some far LODs arrive non-indexed and the old !ix guard
641
+ // skipped them, leaving 100k+ tris).
642
+ let ix = geo.index;
643
+ if (!ix) { const seq = new Uint32Array(pos.count); for (let i = 0; i < pos.count; i++) seq[i] = i; ix = { array: seq, count: pos.count }; }
644
+ if (ix.count / 3 <= triCap) return;
645
+ const idx = ix.array, px = pos.array, pStride = pos.itemSize;
646
+ let mnx = Infinity, mny = Infinity, mnz = Infinity, mxx = -Infinity, mxy = -Infinity, mxz = -Infinity;
647
+ for (let i = 0; i < pos.count; i++) {
648
+ const x = px[i * pStride], y = px[i * pStride + 1], z = px[i * pStride + 2];
649
+ if (x < mnx) mnx = x; if (x > mxx) mxx = x; if (y < mny) mny = y; if (y > mxy) mxy = y; if (z < mnz) mnz = z; if (z > mxz) mxz = z;
650
+ }
651
+ const sx = (mxx - mnx) || 1, sy = (mxy - mny) || 1, sz = (mxz - mnz) || 1;
652
+ const nrm = geo.attributes.normal, col = geo.attributes.color;
653
+ // Try FINE -> COARSE (high res first). Higher res = more cells = MORE kept
654
+ // verts/tris; lower res = coarser = fewer. We want the FINEST grid that still
655
+ // lands at/under the cap, so we descend and accept the first that fits, with
656
+ // res=2 (8 cells, ~12 tris max) as the guaranteed-tiny floor. (The earlier
657
+ // coarse->fine ascent was backwards: it left dense meshes stuck above the cap
658
+ // and then accepted the worst, fine result — that was the 56-straggler bug.)
659
+ for (let res = 48; res >= 2; res = (res > 8 ? res >> 1 : res - 2)) {
660
+ const cellOf = new Int32Array(pos.count);
661
+ const cellMap = new Map();
662
+ let kept = 0;
663
+ for (let i = 0; i < pos.count; i++) {
664
+ const gx = Math.min(res - 1, ((px[i * pStride] - mnx) / sx * res) | 0);
665
+ const gy = Math.min(res - 1, ((px[i * pStride + 1] - mny) / sy * res) | 0);
666
+ const gz = Math.min(res - 1, ((px[i * pStride + 2] - mnz) / sz * res) | 0);
667
+ const key = (gx * res + gy) * res + gz;
668
+ let rep = cellMap.get(key);
669
+ if (rep === undefined) { rep = kept++; cellMap.set(key, rep); }
670
+ cellOf[i] = rep;
671
+ }
672
+ const out = [];
673
+ for (let t = 0; t < idx.length; t += 3) {
674
+ const a = cellOf[idx[t]], b = cellOf[idx[t + 1]], c = cellOf[idx[t + 2]];
675
+ if (a !== b && b !== c && a !== c) out.push(a, b, c);
676
+ }
677
+ const outTris = out.length / 3;
678
+ if (outTris <= triCap || res <= 2) {
679
+ if (outTris < 1) continue; // too coarse (all degenerate) — try next finer? no, we descend; guard below
680
+ const srcOf = new Int32Array(kept).fill(-1);
681
+ for (let i = 0; i < pos.count; i++) { const r = cellOf[i]; if (srcOf[r] === -1) srcOf[r] = i; }
682
+ const newPos = new Float32Array(kept * 3);
683
+ const ct = col ? col.itemSize : 0;
684
+ const newNrm = nrm ? new Float32Array(kept * 3) : null;
685
+ const newCol = col ? new Float32Array(kept * ct) : null;
686
+ for (let r = 0; r < kept; r++) {
687
+ const s = srcOf[r];
688
+ newPos[r * 3] = pos.getX(s); newPos[r * 3 + 1] = pos.getY(s); newPos[r * 3 + 2] = pos.getZ(s);
689
+ // Getters denormalize normalized source attrs (Int8 normals, Uint8
690
+ // colors) to floats — a raw .array copy left 0..255 colors -> WHITE.
691
+ if (newNrm) { newNrm[r * 3] = nrm.getX(s); newNrm[r * 3 + 1] = nrm.getY(s); newNrm[r * 3 + 2] = nrm.getZ(s); }
692
+ if (newCol) {
693
+ newCol[r * ct] = col.getX(s);
694
+ if (ct >= 2) newCol[r * ct + 1] = col.getY(s);
695
+ if (ct >= 3) newCol[r * ct + 2] = col.getZ(s);
696
+ if (ct >= 4) newCol[r * ct + 3] = col.getW(s);
697
+ }
698
+ }
699
+ geo.setAttribute('position', new THREE.BufferAttribute(newPos, 3, false));
700
+ if (newNrm) geo.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3, false));
701
+ if (newCol) geo.setAttribute('color', new THREE.BufferAttribute(newCol, ct, false));
702
+ geo.setIndex(new THREE.BufferAttribute(kept > 65535 ? new Uint32Array(out) : new Uint16Array(out), 1));
703
+ geo.computeBoundingSphere(); geo.computeBoundingBox();
704
+ return;
705
+ }
706
+ }
707
+ }
708
+
709
+ function _bakeQuantizeDecode(geo, matrix, decodeAABB) {
710
+ // Prefer the AABB-remap path: it places vertices in mesh-LOCAL space, the
711
+ // same coordinate convention the inline (baseline) LOD uses. Applying the
712
+ // sibling LOD's matrixWorld here would double-transform when the receiving
713
+ // tm.mesh later composes its own (non-identity) world matrix during render.
714
+ // The matrix path is a fallback for legacy bakes without decodeAABB.
715
+ const m = matrix;
716
+ const isIdentity = !decodeAABB && (
717
+ m.elements[0] === 1 && m.elements[5] === 1 && m.elements[10] === 1 &&
718
+ m.elements[12] === 0 && m.elements[13] === 0 && m.elements[14] === 0 &&
719
+ m.elements[1] === 0 && m.elements[2] === 0 && m.elements[4] === 0 &&
720
+ m.elements[6] === 0 && m.elements[8] === 0 && m.elements[9] === 0
721
+ );
722
+ if (!decodeAABB && !isIdentity) {
723
+ for (const semKey of ['position', 'normal', 'tangent']) {
724
+ const a = geo.attributes[semKey];
725
+ if (!a) continue;
726
+ const out = new Float32Array(a.count * a.itemSize);
727
+ for (let i = 0; i < a.count; i++) {
728
+ if (a.itemSize >= 1) out[i * a.itemSize + 0] = a.getX(i);
729
+ if (a.itemSize >= 2) out[i * a.itemSize + 1] = a.getY(i);
730
+ if (a.itemSize >= 3) out[i * a.itemSize + 2] = a.getZ(i);
731
+ if (a.itemSize >= 4) out[i * a.itemSize + 3] = a.getW(i);
732
+ }
733
+ geo.setAttribute(semKey, new THREE.BufferAttribute(out, a.itemSize, false));
734
+ }
735
+ geo.applyMatrix4(m);
736
+ } else if (decodeAABB) {
737
+ const { min, max } = decodeAABB;
738
+ const pos = geo.attributes.position;
739
+ if (pos) {
740
+ let smnX = Infinity, smxX = -Infinity;
741
+ let smnY = Infinity, smxY = -Infinity;
742
+ let smnZ = Infinity, smxZ = -Infinity;
743
+ for (let i = 0; i < pos.count; i++) {
744
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
745
+ if (x < smnX) smnX = x; if (x > smxX) smxX = x;
746
+ if (y < smnY) smnY = y; if (y > smxY) smxY = y;
747
+ if (z < smnZ) smnZ = z; if (z > smxZ) smxZ = z;
748
+ }
749
+ const r = (a, b) => (b - a < 1e-9 ? 1 : b - a);
750
+ const sx = (max[0] - min[0]) / r(smnX, smxX);
751
+ const sy = (max[1] - min[1]) / r(smnY, smxY);
752
+ const sz = (max[2] - min[2]) / r(smnZ, smxZ);
753
+ const out = new Float32Array(pos.count * 3);
754
+ for (let i = 0; i < pos.count; i++) {
755
+ out[i * 3 + 0] = (pos.getX(i) - smnX) * sx + min[0];
756
+ out[i * 3 + 1] = (pos.getY(i) - smnY) * sy + min[1];
757
+ out[i * 3 + 2] = (pos.getZ(i) - smnZ) * sz + min[2];
758
+ }
759
+ geo.setAttribute('position', new THREE.BufferAttribute(out, 3, false));
760
+ for (const semKey of ['normal', 'tangent']) {
761
+ const a = geo.attributes[semKey];
762
+ if (!a) continue;
763
+ const o = new Float32Array(a.count * a.itemSize);
764
+ for (let i = 0; i < a.count; i++) {
765
+ if (a.itemSize >= 1) o[i * a.itemSize + 0] = a.getX(i);
766
+ if (a.itemSize >= 2) o[i * a.itemSize + 1] = a.getY(i);
767
+ if (a.itemSize >= 3) o[i * a.itemSize + 2] = a.getZ(i);
768
+ if (a.itemSize >= 4) o[i * a.itemSize + 3] = a.getW(i);
769
+ }
770
+ geo.setAttribute(semKey, new THREE.BufferAttribute(o, a.itemSize, false));
771
+ }
772
+ }
773
+ }
774
+ geo.computeBoundingSphere();
775
+ geo.computeBoundingBox();
776
+ }
777
+
778
+ // --- Entity: one live instance --------------------------------------------
779
+ // Encapsulates the per-instance THREE.Object3D tree (root Object3D containing
780
+ // SkinnedMesh / Mesh + skeleton clone), per-mesh LOD state, and per-frame
781
+ // update logic.
782
+ class Entity extends Emitter {
783
+ constructor(pool, asset, opts) {
784
+ super();
785
+ this.pool = pool;
786
+ this.asset = asset;
787
+ this.id = ++pool._nextEntityId;
788
+ this.opts = opts || {};
789
+ // Root container — application code can `.add()` it to a scene, set
790
+ // position/rotation/scale on it, etc.
791
+ this.root = new THREE.Object3D();
792
+ this.root.name = `entity_${this.id}_${asset.url.split('/').pop()}`;
793
+ if (opts.position) this.root.position.fromArray(opts.position);
794
+ if (opts.rotation) this.root.quaternion.setFromEuler(new THREE.Euler().fromArray(opts.rotation));
795
+ if (opts.scale) this.root.scale.setScalar(opts.scale);
796
+ // Caller can pass `static: true` for entities that never move after
797
+ // spawn — we then disable auto matrix updates after composing position
798
+ // into matrix once. Subsequent frames skip the matrix recompute walk.
799
+ // Critical: matrixAutoUpdate=false means three.js's render-time
800
+ // updateMatrixWorld skips updateMatrix(), so we must call it manually
801
+ // here while position/quaternion/scale are still being read into matrix.
802
+ if (opts.static) {
803
+ this.root.updateMatrix(); // compose position/quat/scale into matrix
804
+ this.root.matrixAutoUpdate = false;
805
+ this.root.matrixWorldNeedsUpdate = true; // force one world recompute
806
+ }
807
+ // Per-mesh tracking: each tracks current LOD, the live SkinnedMesh/Mesh,
808
+ // and per-tex current LOD.
809
+ this.trackedMeshes = []; // [{ meshDescIdx, currentLod, mesh, texState: [{currentLod}, ...], baseSkeleton, baseMaterial, sharedTextures }]
810
+ // Animation state.
811
+ this.animationMixer = null;
812
+ this.animationClips = [];
813
+ this.animationAction = null;
814
+ // VRM runtime (shared across LODs of one entity).
815
+ this.vrm = null;
816
+ // Frustum culling cached state.
817
+ this._lastInFrustum = true;
818
+ this._cachedFrustumVisible = true; // Boolean cache: entity is in frustum (OPTIMIZATION)
819
+ this._frustumCheckInterval = 0; // Frame counter: test frustum every 2-3 frames or when entity moves
820
+ this._firstFrustumTest = true; // Force frustum test on first update to avoid "disappear" bug
821
+ // Screen-space pixel size cache for tier allocation (updated only when entity moves)
822
+ this._lastScreenPx = null;
823
+ // Cached flag: whether all tracked meshes are in instanced slots.
824
+ this._allInstanced = false;
825
+ // Disposed flag — stop touching this entity after dispose().
826
+ this._disposed = false;
827
+ // Scene-parent tracking: when ALL tracked meshes are routed through
828
+ // InstancedMesh slots, we detach `root` from its scene parent so three.js
829
+ // stops walking it during updateMatrixWorld/render-list construction. The
830
+ // instanced matrix in the per-asset InstancedMesh is the only state the
831
+ // renderer needs. We remember the parent so we can re-attach when the
832
+ // entity gets closer and needs its per-entity SkinnedMesh tree again.
833
+ this._sceneParent = null;
834
+ this._detached = false;
835
+ // Ready promise resolves once the first LOD has been applied and the
836
+ // root contains a renderable mesh.
837
+ this.ready = this._bootstrap();
838
+ }
839
+
840
+ async _bootstrap() {
841
+ try {
842
+ await this.asset.ready;
843
+ if (this._disposed) return;
844
+ // Clone the root gltf scene per-entity so each has its own skeleton +
845
+ // mesh objects (geometries are shared via the asset cache).
846
+ const sourceScene = this.asset.rootGltf.scene;
847
+ const cloned = _cloneSkinned(sourceScene);
848
+ // Find the cloned VRM if present.
849
+ this.vrm = this.asset.rootGltf.userData?.vrm || null;
850
+ this.root.add(cloned);
851
+ // Compose world matrices so we can capture each mesh node's transform
852
+ // RELATIVE to the entity root (root.matrixWorld⁻¹ × mesh.matrixWorld).
853
+ // This relative is what the per-entity textured tier applies but the
854
+ // instanced FAR tier historically dropped (it used bare root.matrixWorld),
855
+ // causing the on-LOD-switch orientation flip. Captured once — the glTF
856
+ // node hierarchy is static per entity.
857
+ this.root.updateMatrixWorld(true);
858
+ const _rootInv = new THREE.Matrix4().copy(this.root.matrixWorld).invert();
859
+ // Discover tracked meshes by descriptor.
860
+ const meshOrder = [];
861
+ cloned.traverse((c) => { if (c.isMesh) meshOrder.push(c); });
862
+ for (let i = 0; i < this.asset.meshLodDescs.length; i++) {
863
+ const desc = this.asset.meshLodDescs[i];
864
+ const mesh = meshOrder[i] || meshOrder[0];
865
+ if (!mesh) continue;
866
+ const inlineLodIdx = desc.lods.findIndex((l) => l.inline);
867
+ // Capture mesh-node transform relative to root; null when identity so
868
+ // the per-frame slot-matrix path takes the cheap root-only fast path.
869
+ mesh.updateWorldMatrix(true, false);
870
+ const relToRoot = new THREE.Matrix4().multiplyMatrices(_rootInv, mesh.matrixWorld);
871
+ const isRelIdentity = relToRoot.equals(_identityMatrix);
872
+ this.trackedMeshes.push({
873
+ meshDescIdx: i,
874
+ currentLod: inlineLodIdx >= 0 ? inlineLodIdx : 0,
875
+ mesh,
876
+ _meshLocalToRoot: isRelIdentity ? null : relToRoot,
877
+ baseIsSkinnedMesh: !!mesh.isSkinnedMesh,
878
+ baseMaterial: mesh.material,
879
+ baseSkeleton: mesh.skeleton || null,
880
+ parent: mesh.parent,
881
+ texState: this.asset.texLodDescs.map(() => ({ currentLod: 0 })),
882
+ vcMaterial: null,
883
+ _instancedSlot: null,
884
+ _instancedSlotIdx: -1,
885
+ _instancedBoundRadius: null,
886
+ _matrixNeedsUpdate: false,
887
+ _precomputedTexLods: null, // Cached texture LODs for current mesh LOD
888
+ });
889
+ }
890
+ // Animation: build mixer if source has clips.
891
+ const animations = this.asset.rootGltf.animations || [];
892
+ if (animations.length) {
893
+ this.animationClips = animations;
894
+ this.animationMixer = new THREE.AnimationMixer(cloned);
895
+ const desiredIdx = Math.min(this.opts.animationIndex ?? 0, animations.length - 1);
896
+ this.animationAction = this.animationMixer.clipAction(animations[desiredIdx]);
897
+ this.animationAction.setLoop(THREE.LoopRepeat).play();
898
+ }
899
+ this.emit('ready', this);
900
+ } catch (e) {
901
+ this.emit('error', e);
902
+ }
903
+ }
904
+
905
+ // World matrix the instanced slot must use for `tm`. Equals the mesh node's
906
+ // world transform = root.matrixWorld × (mesh-local-relative-to-root). The
907
+ // relative part is captured once at bootstrap (tm._meshLocalToRoot) because
908
+ // the glTF node hierarchy is static per entity; only root.matrixWorld changes
909
+ // per frame. Falls back to root.matrixWorld when the relative is identity.
910
+ _slotWorldMatrix(tm) {
911
+ const rel = tm._meshLocalToRoot;
912
+ if (!rel) return this.root.matrixWorld;
913
+ if (!this._slotMtx) this._slotMtx = new THREE.Matrix4();
914
+ return this._slotMtx.multiplyMatrices(this.root.matrixWorld, rel);
915
+ }
916
+
917
+ // Update one tracked mesh to the desired LOD index. Materializes the
918
+ // shell-type change (SkinnedMesh ↔ Mesh) and material swap as needed.
919
+ async _applyLod(tm, wantIdx, screenPx = 0) {
920
+ if (this._disposed) return;
921
+ if (wantIdx === tm.currentLod) return;
922
+ const desc = this.asset.meshLodDescs[tm.meshDescIdx];
923
+ if (!desc) return;
924
+ const target = desc.lods[wantIdx];
925
+ if (!target) return;
926
+ // Fetch geometry (cached or on-demand).
927
+ let geo;
928
+ if (target.inline) {
929
+ geo = this.asset.geoCache.get(`${desc.meshIndex}:${desc.primIndex}:${wantIdx}`);
930
+ } else {
931
+ // Use the cached geometry immediately if we already have it (the common
932
+ // case once an asset has streamed in). Only defer when it is genuinely not
933
+ // yet resident — otherwise re-queuing an already-loaded LOD 0 on every
934
+ // zoom-out made entities WAIT in the 2-wide deferred queue and pop back in
935
+ // in waves ("disappear on LOD change, reappear all at once").
936
+ const cachedGeo = this.asset.geoCache.get(`${desc.meshIndex}:${desc.primIndex}:${wantIdx}`);
937
+ if (cachedGeo) {
938
+ geo = cachedGeo;
939
+ } else {
940
+ // NOT resident. Register the want with the frame-budgeted warm loader
941
+ // (network-lazy, GPU-eager, piecemeal) so we don't pay fetch+decode on
942
+ // the switch frame, AND record _lodWantIdx so the picker doesn't re-fire
943
+ // every frame for the same unresident target (that was the 11k churn).
944
+ tm._lodWantIdx = wantIdx;
945
+ this.pool._enqueueLodWarm(this.asset, tm.meshDescIdx, wantIdx, this._currentDistance);
946
+ // BUT do not strand the entity at an expensive LOD: if we currently have
947
+ // NO renderable geometry, or the target is a CHEAPER (lower) LOD than
948
+ // current (e.g. dropping to the far/unskinned tier to recover FPS), fall
949
+ // back to awaiting this load now — letting an entity stay stuck at a
950
+ // costly LOD because a cheaper one isn't cached yet deadlocks FPS.
951
+ const droppingToCheaper = wantIdx < tm.currentLod;
952
+ const haveRenderable = tm.mesh && tm.mesh.geometry && tm.mesh.geometry.attributes.position;
953
+ if (droppingToCheaper || !haveRenderable) {
954
+ geo = await this.asset.ensureMeshLod(tm.meshDescIdx, wantIdx);
955
+ if (this._disposed || !geo) return;
956
+ tm._lodWantIdx = -1;
957
+ } else {
958
+ return; // promoting to a richer LOD can wait for the warm loader
959
+ }
960
+ }
961
+ }
962
+ if (this._disposed || !geo) return;
963
+ if (wantIdx === tm.currentLod) return; // raced
964
+ const kind = target.kind || 'textured';
965
+ const wantSkinned = kind !== 'unskinned' && tm.baseIsSkinnedMesh;
966
+ const haveSkinned = !!tm.mesh.isSkinnedMesh;
967
+
968
+ // Instanced mode: when the target LOD is unskinned, route this entity
969
+ // through a shared InstancedMesh slot instead of keeping its own mesh
970
+ // tree. This collapses N entities into 1 draw call at the lowest LOD.
971
+ const wantInstanced = kind === 'unskinned';
972
+ const haveInstanced = tm._instancedSlot != null;
973
+ if (wantInstanced) {
974
+ const slot = this.pool._getInstancedSlot(this.asset, tm.meshDescIdx, wantIdx);
975
+ if (slot) {
976
+ // Hide the entity's own mesh from the renderer.
977
+ tm.mesh.visible = false;
978
+ // Acquire a slot index (re-acquire if changing LOD within unskinned tier).
979
+ if (haveInstanced && (tm._instancedSlot !== slot)) {
980
+ tm._instancedSlot.releaseSlot(this);
981
+ }
982
+ if (!haveInstanced || tm._instancedSlot !== slot) {
983
+ tm._instancedSlot = slot;
984
+ tm._instancedSlotIdx = slot.acquireSlot(this);
985
+ tm._matrixNeedsUpdate = true;
986
+ }
987
+ // Seed the per-instance world-space bound sphere for GPU culling.
988
+ // Recomputed once here (entity transform is stable for typical
989
+ // static spawns; movers refresh it in _update). For animated
990
+ // entities this is fine — far-tier instanced LODs are unskinned
991
+ // bind-pose, so the sphere envelope is constant.
992
+ {
993
+ this.root.updateMatrixWorld(true);
994
+ const worldMat = this._slotWorldMatrix(tm);
995
+ // Write the instance transform IMMEDIATELY at slot acquisition. This
996
+ // is essential for the GPU data-texture path: _applyLod is async (it
997
+ // awaits geometry load), so the _update() call that triggered it has
998
+ // already run its later matrix-write block by the time we get here —
999
+ // the write would otherwise be deferred to next frame, and if the
1000
+ // entity is static + camera still, the static fast-skip returns early
1001
+ // and the texel never gets written -> instance stays at the zero
1002
+ // matrix -> invisible (the "lowest LODs disappear" bug). Writing here
1003
+ // makes the texel correct the instant the slot is acquired.
1004
+ slot.setMatrixForSlot(tm._instancedSlotIdx, worldMat);
1005
+ tm._matrixNeedsUpdate = false;
1006
+ const sphere = geo.boundingSphere;
1007
+ if (sphere) {
1008
+ const me = worldMat.elements;
1009
+ const scale = this.root.scale.length() / Math.SQRT2;
1010
+ tm._instancedBoundRadius = sphere.radius;
1011
+ slot.setBoundSphereForSlot(tm._instancedSlotIdx, me[12], me[13], me[14], sphere.radius * scale);
1012
+ }
1013
+ }
1014
+ tm.currentLod = wantIdx;
1015
+ // Pre-compute texture LODs for this mesh LOD at the current screen-pixel size.
1016
+ tm._precomputedTexLods = _precomputeAllTexLods(this.asset, screenPx);
1017
+ this.emit('lod-changed', { entity: this, meshDescIdx: tm.meshDescIdx, lod: wantIdx, kind, instanced: true });
1018
+ return;
1019
+ }
1020
+ // Slot unavailable (geo not yet loaded for instancing) → fall through to non-instanced path.
1021
+ } else if (haveInstanced) {
1022
+ // Leaving the instanced tier — release the slot and re-show our own mesh.
1023
+ tm._instancedSlot.releaseSlot(this);
1024
+ tm._instancedSlot = null;
1025
+ tm._instancedSlotIdx = -1;
1026
+ tm.mesh.visible = true;
1027
+ }
1028
+ // Material selection per kind.
1029
+ // MATERIAL GROUPING OPTIMIZATION: Use global tier materials if enabled
1030
+ let mat;
1031
+ if (kind === 'textured') {
1032
+ // Textured (HERO/MID) tier MUST use the entity's OWN baseMaterial, which
1033
+ // carries that asset's textures (map/normalMap/...). The shared global
1034
+ // tier material cannot hold N different per-asset textures, so routing
1035
+ // textured LODs through it rendered them WHITE (only vertex colors showed).
1036
+ // _applyTexLod swaps texture LODs on tm.baseMaterial, gated by
1037
+ // `mat === tm.baseMaterial` — so the base material is also required for
1038
+ // textures to update at all. (Global pool grouping still applies to the
1039
+ // FAR/vertex-color tier where it's correct.)
1040
+ mat = tm.baseMaterial;
1041
+ } else {
1042
+ if (!tm.vcMaterial) {
1043
+ const m = new THREE.MeshLambertMaterial({ vertexColors: true });
1044
+ m.onBeforeCompile = (shader) => {
1045
+ // sRGB->linear decode per-VERTEX (not per-fragment): see batched-far-tier.
1046
+ shader.vertexShader = shader.vertexShader.replace(
1047
+ '#include <color_vertex>',
1048
+ `#include <color_vertex>
1049
+ #if defined( USE_COLOR_ALPHA )
1050
+ vColor.rgb = pow(vColor.rgb, vec3(2.2));
1051
+ #elif defined( USE_COLOR )
1052
+ vColor = pow(vColor, vec3(2.2));
1053
+ #endif`
1054
+ );
1055
+ };
1056
+ tm.vcMaterial = m;
1057
+ }
1058
+ mat = tm.vcMaterial;
1059
+ }
1060
+ if (wantSkinned === haveSkinned) {
1061
+ tm.mesh.geometry = geo;
1062
+ tm.mesh.material = mat;
1063
+ } else {
1064
+ const parent = tm.mesh.parent || tm.parent;
1065
+ let next;
1066
+ if (wantSkinned) {
1067
+ next = new THREE.SkinnedMesh(geo, mat);
1068
+ if (tm.baseSkeleton) next.bind(tm.baseSkeleton);
1069
+ } else {
1070
+ next = new THREE.Mesh(geo, mat);
1071
+ }
1072
+ next.frustumCulled = false;
1073
+ // Copy local transform. LODs may have different mesh-local transforms
1074
+ // due to baking differences, but since mesh is a direct child of root,
1075
+ // we use the same relative positioning.
1076
+ next.position.copy(tm.mesh.position);
1077
+ next.quaternion.copy(tm.mesh.quaternion);
1078
+ next.scale.copy(tm.mesh.scale);
1079
+ next.name = tm.mesh.name;
1080
+ if (parent) {
1081
+ parent.remove(tm.mesh);
1082
+ parent.add(next);
1083
+ }
1084
+ tm.mesh = next;
1085
+ }
1086
+ tm.currentLod = wantIdx;
1087
+ // Pre-compute texture LODs for this mesh LOD at the current screen-pixel size.
1088
+ tm._precomputedTexLods = _precomputeAllTexLods(this.asset, screenPx);
1089
+ this.emit('lod-changed', { entity: this, meshDescIdx: tm.meshDescIdx, lod: wantIdx, kind });
1090
+ }
1091
+
1092
+ async _applyTexLod(tm, tdIdx, wantIdx) {
1093
+ if (this._disposed) return;
1094
+ const tState = tm.texState[tdIdx];
1095
+ if (!tState || wantIdx === tState.currentLod) return;
1096
+ const desc = this.asset.texLodDescs[tdIdx];
1097
+ if (!desc) return;
1098
+ let bmp;
1099
+ const target = desc.lods[wantIdx];
1100
+ if (target.inline) {
1101
+ bmp = this.asset.texCache.get(`${desc.textureIndex}:${wantIdx}`);
1102
+ } else {
1103
+ bmp = await this.asset.ensureTexLod(tdIdx, wantIdx);
1104
+ }
1105
+ if (this._disposed || !bmp) return;
1106
+ if (wantIdx === tState.currentLod) return;
1107
+ // Apply to matching slot(s) on tm.mesh.material — but ONLY when the
1108
+ // current material is the textured baseMaterial. Vertex-color LODs
1109
+ // ignore textures.
1110
+ const mat = tm.mesh.material;
1111
+ if (mat === tm.baseMaterial) {
1112
+ const targets = _findMaterialSlots(mat, desc);
1113
+ // Far texture LODs (idx>=3) get cheap linear/no-mipmap filtering — at that
1114
+ // distance the mip chain isn't worth the upload/bandwidth. These run on the
1115
+ // per-entity baseMaterial (not the shared pool material), so it's safe.
1116
+ const farLod = wantIdx >= 3;
1117
+ // Anisotropic filtering on the close, mipmapped tiers (wantIdx < 3) keeps
1118
+ // oblique surfaces sharp at near-zero cost on a draws~0 GPU-bound scene.
1119
+ // Cached once off the renderer; capped at 8 so we never overpay on GPUs
1120
+ // that advertise 16x. Far LODs skip it (no mipmaps, linear-only).
1121
+ const aniso = this.pool._maxAnisotropy ??
1122
+ (this.pool._maxAnisotropy = Math.min(8, this.pool.renderer?.capabilities?.getMaxAnisotropy?.() ?? 1));
1123
+ for (const tex of targets) {
1124
+ tex.dispose();
1125
+ tex.image = bmp;
1126
+ if (farLod) {
1127
+ tex.minFilter = THREE.LinearFilter;
1128
+ tex.magFilter = THREE.LinearFilter;
1129
+ tex.generateMipmaps = false;
1130
+ tex.anisotropy = 1;
1131
+ } else {
1132
+ tex.anisotropy = aniso;
1133
+ }
1134
+ tex.needsUpdate = true;
1135
+ }
1136
+ }
1137
+ tState.currentLod = wantIdx;
1138
+ }
1139
+
1140
+ // Detach our root from the scene graph when every tracked mesh is routed
1141
+ // through an InstancedMesh slot. three.js then skips this whole subtree
1142
+ // during updateMatrixWorld and render-list construction. The instanced-mesh
1143
+ // matrix is the only renderer-visible state we still need to push.
1144
+ _maybeDetach() {
1145
+ if (this._detached || this._disposed) return;
1146
+ if (!this.trackedMeshes.length) return;
1147
+ for (const tm of this.trackedMeshes) {
1148
+ if (!tm._instancedSlot) {
1149
+ this._allInstanced = false;
1150
+ return; // at least one mesh still needs per-entity draw
1151
+ }
1152
+ }
1153
+ this._allInstanced = true;
1154
+ const parent = this.root.parent;
1155
+ if (!parent) return;
1156
+ this._sceneParent = parent;
1157
+ parent.remove(this.root);
1158
+ this._detached = true;
1159
+ }
1160
+ _maybeReattach() {
1161
+ if (!this._detached || this._disposed) return;
1162
+ // Re-attach as soon as ANY tracked mesh leaves the instanced tier.
1163
+ let allInstanced = true;
1164
+ for (const tm of this.trackedMeshes) {
1165
+ if (!tm._instancedSlot) { allInstanced = false; break; }
1166
+ }
1167
+ if (allInstanced) return;
1168
+ this._allInstanced = false;
1169
+ if (this._sceneParent) {
1170
+ this._sceneParent.add(this.root);
1171
+ // Force a matrixWorld recompute on next frame since the subtree was
1172
+ // detached and possibly skipped updates.
1173
+ this.root.matrixWorldNeedsUpdate = true;
1174
+ }
1175
+ this._detached = false;
1176
+ }
1177
+
1178
+ // Per-frame update called by the pool. Returns {distance, screenPx, tier} for tier allocation.
1179
+ // Used to track per-frame budget consumption across HERO/MID/FAR tiers.
1180
+ _update(camera, viewportHeight, dt, globalCeilingLod, frustum, animationThrottleDistance) {
1181
+ if (this._disposed) return { distance: Infinity, screenPx: 0, tier: 'far' };
1182
+ let primaryMesh = this.trackedMeshes[0]?.mesh;
1183
+ if (!primaryMesh) return { distance: Infinity, screenPx: 0, tier: 'far' };
1184
+ // FAST SKIP (static + camera still + fully GPU-instanced): nothing this
1185
+ // entity contributes can change this frame. Its world matrix is already
1186
+ // uploaded to the instance buffer, the GPU vertex shader culls it against
1187
+ // the per-frame frustum uniform, and its LOD can't change without camera
1188
+ // motion (distance is constant). So we return the cached result without
1189
+ // touching distance/frustum/LOD/matrix math. This is the bulk of the
1190
+ // ~25ms/frame JS entity-loop cost at 500 static entities — it should fall
1191
+ // toward zero for a still camera. Any of: entity movable, camera moved this
1192
+ // frame, not-yet-fully-instanced, ceiling changed, or no cached result yet
1193
+ // forces the full path. (globalCeilingLod change must re-pick LODs.)
1194
+ const _movable = this.root.matrixAutoUpdate || this._boundDirty;
1195
+ if (!_movable && !this.pool._cameraMoved && this._allInstanced && this._lastUpdateResult
1196
+ && this._lastCeilingLod === globalCeilingLod) {
1197
+ return this._lastUpdateResult;
1198
+ }
1199
+ // Remember scene parent the first time we see it so we can re-attach
1200
+ // later even if the root was detached for being fully instanced.
1201
+ if (!this._sceneParent && this.root.parent) this._sceneParent = this.root.parent;
1202
+ // Lazy matrix update: only invalidated when root.position/rotation/scale
1203
+ // changed since last tick. The autoUpdate flag controls this.
1204
+ // When detached, this is a no-op walk over an unparented root — still
1205
+ // cheap, and keeps matrixWorld current for instance-slot writes.
1206
+ if (this.root.matrixWorldNeedsUpdate || this.root.matrixAutoUpdate) {
1207
+ this.root.updateMatrixWorld(true);
1208
+ }
1209
+ const sphere = primaryMesh.geometry?.boundingSphere;
1210
+ if (!sphere) return { distance: Infinity, screenPx: 0, tier: 'far' };
1211
+ const world = _tmpV3.setFromMatrixPosition(primaryMesh.matrixWorld);
1212
+ // (Removed an unused `world.distanceTo(camera.position)` here — it computed
1213
+ // a sqrt for every entity every frame and its result was never read; the
1214
+ // distance actually used is computed once below, after the frustum test.)
1215
+ const scaleLen = this.root.scale.length();
1216
+ const radius = sphere.radius * scaleLen / Math.SQRT2;
1217
+ // Skip frustum check + LOD if we're MUCH closer than necessary OR much
1218
+ // farther than we can resolve. The frustum test itself is moderately
1219
+ // expensive (matrix-vs-sphere per plane).
1220
+ // If any tracked mesh is currently in an instanced slot, the GPU
1221
+ // vertex shader handles frustum culling for us — skip the CPU sphere
1222
+ // test entirely. Per-entity meshes (HERO/MID) still need it.
1223
+ // Optimize: test frustum using dynamic interval based on scene staticness.
1224
+ // Static entities: 8-10 frame interval. Moving entities: check every frame (interval = 0).
1225
+ const movable = this.root.matrixAutoUpdate || this._boundDirty;
1226
+ let inFrustum = this._lastInFrustum; // Use cached result by default
1227
+ if (this._allInstanced) {
1228
+ inFrustum = true;
1229
+ } else if (this.pool._enableFrustumCulling) {
1230
+ // For moving entities, always test (interval = 0).
1231
+ // For static entities, use pool's dynamic interval (5/8/10 depending on scene staticness).
1232
+ const effectiveInterval = movable ? 0 : this.pool._dynamicFrustumCheckInterval;
1233
+ if (this._firstFrustumTest || this._frustumCheckInterval <= 0 || movable) {
1234
+ // Test frustum if first update, interval expired, or entity is moving
1235
+ _tmpSphere.set(world, radius);
1236
+ inFrustum = frustum ? frustum.intersectsSphere(_tmpSphere) : true;
1237
+ this._frustumCheckInterval = effectiveInterval;
1238
+ this._lastInFrustum = inFrustum;
1239
+ this._cachedFrustumVisible = inFrustum; // Update visibility cache
1240
+ this._firstFrustumTest = false;
1241
+ } else {
1242
+ // Use cached frustum result, just decrement interval
1243
+ this._frustumCheckInterval--;
1244
+ }
1245
+ } else {
1246
+ // Frustum culling disabled: always show
1247
+ inFrustum = true;
1248
+ }
1249
+ if (this.root.visible !== inFrustum) this.root.visible = inFrustum;
1250
+ if (!inFrustum) {
1251
+ // OPTIMIZATION: For static entities that are out-of-frustum, skip expensive
1252
+ // per-frame work (matrix writes, LOD selection, screenPx calculation, animation).
1253
+ // Only movable entities (moving or flagged for matrix update) need matrix refresh.
1254
+ // Static out-of-frustum entities can skip nearly all work until they move or
1255
+ // camera swings back (next interval test).
1256
+ if (!movable) {
1257
+ // EARLY RETURN: Static entity out-of-frustum → skip everything
1258
+ // Just update interval counter and return (no matrix, no LOD, no animation)
1259
+ this._cachedFrustumVisible = false;
1260
+ this._maybeReattach();
1261
+ this._maybeDetach();
1262
+ return { distance: Infinity, screenPx: 0, tier: 'far' };
1263
+ }
1264
+ // Movable entity out-of-frustum: still push instance matrix in case a slot
1265
+ // is active — we want the matrix valid when the camera swings back. Note: we
1266
+ // zero it via visible:false on the root, but the InstancedMesh ignores root
1267
+ // visibility (it's a sibling in the scene tree). Set the slot matrix to a
1268
+ // far-away point so it's outside the camera regardless.
1269
+ for (const tm of this.trackedMeshes) {
1270
+ if (tm._instancedSlot && tm._instancedSlotIdx >= 0 && (movable || tm._matrixNeedsUpdate)) {
1271
+ // Use the MESH NODE's world matrix, not the bare root matrix. The
1272
+ // glTF mesh node may carry a non-identity local transform inside the
1273
+ // cloned scene hierarchy (root -> ...cloned chain... -> mesh). The
1274
+ // per-entity textured tier renders through that full chain, so the
1275
+ // instanced FAR tier must compose the same matrix or the model FLIPS
1276
+ // orientation/position on every tier switch (see _slotWorldMatrix).
1277
+ tm._instancedSlot.setMatrixForSlot(tm._instancedSlotIdx, this._slotWorldMatrix(tm));
1278
+ tm._matrixNeedsUpdate = false;
1279
+ }
1280
+ }
1281
+ this._maybeReattach();
1282
+ this._maybeDetach();
1283
+ return { distance: Infinity, screenPx: 0, tier: 'far' };
1284
+ }
1285
+ const dist = camera.position.distanceTo(world);
1286
+ // GPU-optimized LOD selection: screenPx calculation is now done in the
1287
+ // vertex shader (_patchInstancedSlotMaterial), reducing CPU overhead from
1288
+ // ~1.2µs per entity (1000 entities = 1.2ms) to ~0.1µs (frustum test only).
1289
+ // LOD decisions are made per-instance in the GPU, passed via vLodIndex.
1290
+ // CPU-side LOD updates only happen when distance crosses significant thresholds.
1291
+ // For non-instanced entities (HERO/MID tiers), we still need estimated screenPx
1292
+ // for tier allocation, but we can use a cached/estimated value that updates
1293
+ // only when the entity actually moves.
1294
+ let screenPx = 0;
1295
+ // Recompute screenPx when the entity moved OR the CAMERA moved this frame.
1296
+ // Static entities (matrixAutoUpdate=false) used to freeze _lastScreenPx at
1297
+ // spawn distance, so their LOD never changed as the camera orbited/zoomed.
1298
+ // pool._cameraMoved is set once per frame in update().
1299
+ if (this._lastScreenPx != null && !movable && !this.pool._cameraMoved) {
1300
+ // Use cached screenPx (entity static AND camera static this frame).
1301
+ screenPx = this._lastScreenPx;
1302
+ } else {
1303
+ // Compute screenPx only for moving entities or first update.
1304
+ // fovTanHalf = tan(fov/2) is camera-constant for the frame; the pool
1305
+ // computes it once per update() (this.pool._fovTanHalf) so we avoid a
1306
+ // per-entity degToRad + tan (a transcendental call) every frame.
1307
+ const fovTanHalf = this.pool._fovTanHalf || Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
1308
+ const halfWorld = fovTanHalf * (dist > 0.0001 ? dist : 0.0001);
1309
+ screenPx = (radius / halfWorld) * viewportHeight;
1310
+ this._lastScreenPx = screenPx;
1311
+ }
1312
+ // Tier routing: HERO/MID/FAR are all decided by which LOD the screen-
1313
+ // space picker selects. FAR-distance entities resolve to the unskinned
1314
+ // (idx 0) LOD which the pool routes through a shared per-asset
1315
+ // InstancedMesh — real 3D geometry, just decimated, so shape survives
1316
+ // for non-character meshes (terrain chunks, props, anything).
1317
+ const tinyOnScreen = screenPx < 4;
1318
+ // SUB-PIXEL CULL: below ~2px a model occupies <~4 screen pixels — not worth
1319
+ // rasterizing at all. Skip drawing it entirely (zero its instanced slot
1320
+ // matrix so the shared InstancedMesh draws nothing for it; hide a per-entity
1321
+ // mesh). This removes vertex+fill work for the densest part of a big scene
1322
+ // (most models are tiny dots at a far camera). Restores the instant it grows
1323
+ // back above the threshold. Hysteresis (2px cull / 3px restore) avoids
1324
+ // flicker at the boundary.
1325
+ // Threshold is tunable (pool._subPixelCullPx, default 2px) with +1px restore
1326
+ // hysteresis. Default is conservative so only truly invisible dots are cut;
1327
+ // raise it to trade a little far-detail for FPS in dense scenes.
1328
+ const base = this.pool._subPixelCullPx ?? 2;
1329
+ const cullPx = this._subPixelCulled ? base + 1 : base;
1330
+ const wantSubPixelCull = screenPx > 0 && screenPx < cullPx;
1331
+ if (wantSubPixelCull !== this._subPixelCulled) {
1332
+ this._subPixelCulled = wantSubPixelCull;
1333
+ for (const tm of this.trackedMeshes) {
1334
+ if (tm._instancedSlot && tm._instancedSlotIdx >= 0) {
1335
+ tm._instancedSlot.setMatrixForSlot(tm._instancedSlotIdx, wantSubPixelCull ? _zeroMatrix : this._slotWorldMatrix(tm));
1336
+ } else if (tm.mesh) {
1337
+ tm.mesh.visible = !wantSubPixelCull;
1338
+ }
1339
+ }
1340
+ }
1341
+ {
1342
+ // Re-pick LOD only when something that affects the choice changed since
1343
+ // this entity last picked: the pool bumps _lodEpoch when the camera moved
1344
+ // or _lodDistanceScale shifted beyond a quantum. A still camera + stable
1345
+ // scale => epoch unchanged => no re-pick, no _applyLod churn. This is the
1346
+ // core stutter fix: previously every visible textured-tier entity re-ran
1347
+ // the picker every frame, and any boundary jitter (scale-hunting) flipped
1348
+ // it, producing hundreds of switches/sec even with a still camera.
1349
+ const lodEpochChanged = this._lodPickEpoch !== this.pool._lodEpoch;
1350
+ if (lodEpochChanged) this._lodPickEpoch = this.pool._lodEpoch;
1351
+ if (!tinyOnScreen && !this._subPixelCulled && lodEpochChanged) {
1352
+ for (const tm of this.trackedMeshes) {
1353
+ const desc = this.asset.meshLodDescs[tm.meshDescIdx];
1354
+ if (!desc) continue;
1355
+ // Pick the LOD for the current screen size — ALSO for entities already
1356
+ // in an instanced slot. Previously this was gated behind
1357
+ // `if (!tm._instancedSlot)` with the (false) assumption the GPU does
1358
+ // LOD selection; the GPU only culls. That left instanced/FAR entities
1359
+ // STUCK at their entry LOD forever, so LOD never changed as the camera
1360
+ // moved closer. _applyLod handles the instanced<->per-entity transition
1361
+ // (wantInstanced/haveInstanced), so calling it here lets a FAR entity
1362
+ // promote to a higher textured LOD when the camera approaches and demote
1363
+ // back when it recedes.
1364
+ const targetIdx = _pickMeshLod(desc.lods, screenPx, globalCeilingLod, this.pool._use3LodSystem, this.pool._lodDistanceScale, tm.currentLod);
1365
+ // HYSTERESIS + IN-FLIGHT GUARD. Without these the entity re-fires
1366
+ // _applyLod every frame it sits near a LOD threshold (the picker has no
1367
+ // dead-band) AND re-fires every frame while an async ensureMeshLod is
1368
+ // still loading (the deferred path returns without setting currentLod),
1369
+ // producing tens of thousands of switches and continuous stutter as
1370
+ // the camera moves across the vertcolor<->textured band. Fix: only
1371
+ // commit a switch after the SAME target has been requested for a few
1372
+ // consecutive evaluations (debounce ping-pong), and never re-issue
1373
+ // while a switch is already in flight.
1374
+ // If we've already registered a want for this exact target and its geo
1375
+ // is still not resident, don't re-invoke _applyLod — the warm loader
1376
+ // owns it. This stops the per-frame cache-miss re-fire (was 11k/dolly).
1377
+ const wantKey = `${desc.meshIndex}:${desc.primIndex}:${targetIdx}`;
1378
+ const targetResident = desc.lods[targetIdx] && (desc.lods[targetIdx].inline || this.asset.geoCache.has(wantKey));
1379
+ if (tm._lodWantIdx === targetIdx && !targetResident) {
1380
+ // pending in the warm loader — leave it; it'll apply when resident.
1381
+ } else if (targetIdx !== tm.currentLod && !tm._lodPending) {
1382
+ if (tm._pendingLodTarget === targetIdx) {
1383
+ tm._lodConfirm = (tm._lodConfirm || 0) + 1;
1384
+ } else {
1385
+ tm._pendingLodTarget = targetIdx;
1386
+ tm._lodConfirm = 1;
1387
+ }
1388
+ const needed = this.pool._lodSwitchConfirmFrames ?? 4;
1389
+ if (tm._lodConfirm >= needed) {
1390
+ tm._lodConfirm = 0;
1391
+ tm._pendingLodTarget = -1;
1392
+ tm._lodPending = true;
1393
+ const px = screenPx;
1394
+ this._applyLod(tm, targetIdx, px).finally(() => { tm._lodPending = false; });
1395
+ }
1396
+ } else if (targetIdx === tm.currentLod) {
1397
+ tm._lodWantIdx = -1;
1398
+ // Settled at target — reset the debounce so a future change starts fresh.
1399
+ tm._pendingLodTarget = -1; tm._lodConfirm = 0;
1400
+ }
1401
+ // Use pre-computed texture LODs (only meaningful for the non-instanced,
1402
+ // textured tiers; instanced FAR uses vertex color, no textures).
1403
+ if (!tm._instancedSlot && this.pool._enableTextureLod && tm._precomputedTexLods) {
1404
+ for (let ti = 0; ti < tm.texState.length; ti++) {
1405
+ const tWant = tm._precomputedTexLods[ti];
1406
+ if (tWant != null && tWant !== tm.texState[ti].currentLod) {
1407
+ this._applyTexLod(tm, ti, tWant);
1408
+ }
1409
+ }
1410
+ }
1411
+ }
1412
+ }
1413
+ }
1414
+ // Push instance matrices for instanced-tier tracked meshes. Also
1415
+ // refresh the per-instance world-space bound-sphere center for GPU
1416
+ // frustum culling — only when the entity actually moves (root has
1417
+ // auto-update on, or is flagged for one-shot rebuild).
1418
+ for (const tm of this.trackedMeshes) {
1419
+ if (tm._instancedSlot && tm._instancedSlotIdx >= 0) {
1420
+ // Compute the slot's world matrix at most ONCE per frame: both the matrix
1421
+ // push and the bound-sphere refresh below read the same transform, and
1422
+ // _slotWorldMatrix does a matrix multiply (and, for non-fully-instanced
1423
+ // meshes, an allocation) on every call. Cache it lazily here.
1424
+ let slotWM = null;
1425
+ // Sub-pixel-culled entities keep their zeroed slot matrix — don't push
1426
+ // the real transform back (that would un-cull them every frame).
1427
+ if (!this._subPixelCulled && (movable || tm._matrixNeedsUpdate)) {
1428
+ slotWM = this._slotWorldMatrix(tm);
1429
+ tm._instancedSlot.setMatrixForSlot(tm._instancedSlotIdx, slotWM);
1430
+ tm._matrixNeedsUpdate = false;
1431
+ }
1432
+ if (!this._subPixelCulled && movable && tm._instancedBoundRadius != null) {
1433
+ const me = (slotWM || this._slotWorldMatrix(tm)).elements;
1434
+ const scale = this.root.scale.length() / Math.SQRT2;
1435
+ tm._instancedSlot.setBoundSphereForSlot(
1436
+ tm._instancedSlotIdx, me[12], me[13], me[14],
1437
+ tm._instancedBoundRadius * scale,
1438
+ );
1439
+ }
1440
+ }
1441
+ }
1442
+ if (movable) {
1443
+ // Invalidate cached screenPx when entity moves so it gets recalculated next frame
1444
+ this._lastScreenPx = null;
1445
+ }
1446
+ this._boundDirty = false;
1447
+ // If every tracked mesh is now instanced, detach root from the scene
1448
+ // graph so three.js stops paying traversal cost on it. Re-attach as soon
1449
+ // as any tracked mesh leaves the instanced tier.
1450
+ this._maybeReattach();
1451
+ this._maybeDetach();
1452
+ // Optimization 3: Aggressive animation throttling based on distance tiers
1453
+ // <10m: Full animation (1x time-skip)
1454
+ // 10-20m: Half-rate (every 2 frames, 2x skip)
1455
+ // 20-40m: Quarter-rate (every 4 frames, 4x skip)
1456
+ // >40m: Disabled (bind pose only)
1457
+ // Skip entirely when the entity is instanced (bind pose only).
1458
+ // Only probe instanced state when there's actually an animation consumer —
1459
+ // entities with no mixer and no VRM (the common FAR-tier case) skip the
1460
+ // trackedMeshes walk entirely.
1461
+ const hasAnimConsumer = !!this.animationMixer || !!this.vrm?.update;
1462
+ const anyInstanced = hasAnimConsumer
1463
+ ? (this._allInstanced || this.trackedMeshes.some((tm) => !!tm._instancedSlot))
1464
+ : false;
1465
+ if (this.animationMixer && !anyInstanced) {
1466
+ if (this.pool._enableAnimThrottle) {
1467
+ if (dist > 40) {
1468
+ // >40m: skip animation entirely (bind pose only)
1469
+ // no-op: don't call mixer.update()
1470
+ } else if (dist > 20) {
1471
+ // 20-40m: quarter-rate (every 4 frames)
1472
+ if ((this._animTickCounter = ((this._animTickCounter || 0) + 1)) % 4 === 0) {
1473
+ this.animationMixer.update(dt * 4);
1474
+ }
1475
+ } else if (dist > 10) {
1476
+ // 10-20m: half-rate (every 2 frames)
1477
+ if ((this._animTickCounter = ((this._animTickCounter || 0) + 1)) % 2 === 0) {
1478
+ this.animationMixer.update(dt * 2);
1479
+ }
1480
+ } else {
1481
+ // <10m: full animation (1x)
1482
+ this.animationMixer.update(dt);
1483
+ }
1484
+ } else {
1485
+ // Throttling disabled: always full rate
1486
+ this.animationMixer.update(dt);
1487
+ }
1488
+ }
1489
+ if (this.vrm?.update && dist < animationThrottleDistance && !anyInstanced) this.vrm.update(dt);
1490
+ // Return distance and screenPx for tier allocation by the pool. The result
1491
+ // is read synchronously by the pool before the next _update call, so a
1492
+ // single reused object avoids a per-entity allocation in the hot path.
1493
+ this._currentDistance = dist;
1494
+ const r = this._updateResult || (this._updateResult = { distance: 0, screenPx: 0, tier: 'unassigned' });
1495
+ r.distance = dist; r.screenPx = screenPx; r.tier = 'unassigned';
1496
+ // Cache for the static-fast-skip path at the top of _update. Safe to return
1497
+ // this same reused object next frame because a static entity's distance and
1498
+ // screenPx are unchanged while the camera is still. _lastCeilingLod guards
1499
+ // against a ceiling change forcing a re-pick.
1500
+ this._lastUpdateResult = r;
1501
+ this._lastCeilingLod = globalCeilingLod;
1502
+ return r;
1503
+ }
1504
+
1505
+ dispose() {
1506
+ if (this._disposed) return;
1507
+ this._disposed = true;
1508
+ this.root.parent?.remove(this.root);
1509
+ // If we were detached for being fully instanced, the root has no parent
1510
+ // but _sceneParent still holds the original — nothing to remove there
1511
+ // (we already removed ourselves at detach time). Clear the reference.
1512
+ this._sceneParent = null;
1513
+ this._detached = false;
1514
+ // Stop animation.
1515
+ if (this.animationAction) this.animationAction.stop();
1516
+ this.animationMixer = null;
1517
+ this.animationAction = null;
1518
+ // Release any instanced-mesh slots we held.
1519
+ for (const tm of this.trackedMeshes) {
1520
+ if (tm._instancedSlot) tm._instancedSlot.releaseSlot(this);
1521
+ if (tm.vcMaterial) tm.vcMaterial.dispose();
1522
+ }
1523
+ this.trackedMeshes = [];
1524
+ this.pool._entities.delete(this);
1525
+ this.emit('disposed', this);
1526
+ }
1527
+ }
1528
+
1529
+ // Helper: clone a three.js scene with SkinnedMesh skeletons re-bound to the
1530
+ // cloned bone tree. Crucial: MATERIALS AND GEOMETRIES are shared with the
1531
+ // source — only the Object3D scene-graph topology and per-mesh skeleton
1532
+ // objects are unique per entity. Without this every spawned entity allocates
1533
+ // its own Texture/Material clones, leaking 100s of GPU textures with N=500
1534
+ // even though they're never used.
1535
+ function _cloneSkinned(source) {
1536
+ const sourceToClone = new Map();
1537
+ const cloneRoot = _cloneObject3D(source, sourceToClone);
1538
+ // For every SkinnedMesh, build a fresh skeleton with cloned bones.
1539
+ cloneRoot.traverse((cm) => {
1540
+ if (!cm.isSkinnedMesh) return;
1541
+ let sourceSm = null;
1542
+ for (const [s, c] of sourceToClone) {
1543
+ if (c === cm) { sourceSm = s; break; }
1544
+ }
1545
+ if (!sourceSm) return;
1546
+ const srcSkel = sourceSm.skeleton;
1547
+ if (!srcSkel) return;
1548
+ const newBones = srcSkel.bones.map((b) => sourceToClone.get(b) || b);
1549
+ const newSkel = new THREE.Skeleton(newBones, srcSkel.boneInverses);
1550
+ cm.bind(newSkel, cm.bindMatrix);
1551
+ });
1552
+ return cloneRoot;
1553
+ }
1554
+
1555
+ // Selective clone: preserves shared materials/geometries, copies transforms.
1556
+ function _cloneObject3D(src, sourceToClone) {
1557
+ let copy;
1558
+ if (src.isSkinnedMesh) {
1559
+ // Share geometry + material; per-entity skeleton is rebuilt above.
1560
+ copy = new THREE.SkinnedMesh(src.geometry, src.material);
1561
+ copy.bindMode = src.bindMode;
1562
+ copy.bindMatrix.copy(src.bindMatrix);
1563
+ copy.bindMatrixInverse.copy(src.bindMatrixInverse);
1564
+ } else if (src.isMesh) {
1565
+ copy = new THREE.Mesh(src.geometry, src.material);
1566
+ } else if (src.isBone) {
1567
+ copy = new THREE.Bone();
1568
+ } else {
1569
+ copy = new THREE.Object3D();
1570
+ }
1571
+ copy.name = src.name;
1572
+ copy.position.copy(src.position);
1573
+ copy.quaternion.copy(src.quaternion);
1574
+ copy.scale.copy(src.scale);
1575
+ copy.matrixAutoUpdate = src.matrixAutoUpdate;
1576
+ copy.visible = src.visible;
1577
+ copy.frustumCulled = src.frustumCulled;
1578
+ sourceToClone.set(src, copy);
1579
+ for (const child of src.children) {
1580
+ copy.add(_cloneObject3D(child, sourceToClone));
1581
+ }
1582
+ return copy;
1583
+ }
1584
+
1585
+ // LOD picker — same logic as the inline demo, plus a ceiling clamp.
1586
+ // For 3-LOD system: uses thresholds [50px, 25px, 10px] for LODs [0, 2, 4]
1587
+ // For 5-LOD system: uses thresholds [80, 200, 400, 800, 1400] for LODs [0, 1, 2, 3, 4]
1588
+ function _pickMeshLod(lods, screenPx, ceilingIdx, use3LodSystem = false, lodScale = 1, curLod = -1) {
1589
+ let thresholds, lodIndices;
1590
+ if (use3LodSystem && lods.length >= 5) {
1591
+ // 3-LOD system: only use LODs [0, 2, 4] with thresholds [50px, 25px, 10px]
1592
+ // This skips intermediate LODs 1 and 3, saving VRAM and reducing memory churn
1593
+ thresholds = [50, 25, 10]; // Adjusted for 3-LOD: LOD0@50px, LOD2@25px, LOD4@10px
1594
+ lodIndices = [0, 2, 4];
1595
+ } else {
1596
+ // 5-LOD system (default): all LODs [0, 1, 2, 3, 4]
1597
+ thresholds = [80, 200, 400, 800, 1400];
1598
+ lodIndices = [0, 1, 2, 3, 4];
1599
+ }
1600
+
1601
+ // lodScale is the continuous FPS/VRAM control knob: scale the effective
1602
+ // on-screen size so the SAME picker chooses lower LODs sooner when the
1603
+ // controller wants cheaper frames (lodScale<1) or higher LODs when there's
1604
+ // headroom (lodScale>1).
1605
+ const effPx = screenPx * lodScale;
1606
+
1607
+ // HYSTERESIS: thresholds are descending (more px = more detail). The ladder
1608
+ // index `i` counts how many thresholds effPx exceeds. Without a dead-band an
1609
+ // entity sitting right at a threshold (or whose effPx jitters as lodScale and
1610
+ // screenPx wobble) flips between adjacent LODs every frame — the continuous
1611
+ // vertcolor<->textured stutter, and the low<->high ping-pong. We bias the
1612
+ // comparison by the entity's CURRENT ladder index: to move UP a level effPx
1613
+ // must clear the threshold by +margin; to drop DOWN it must fall below by
1614
+ // -margin. Inside the band the current level is kept, so a still or slowly
1615
+ // moving entity stays put.
1616
+ const HYST = 0.18; // ±18% dead-band around each threshold
1617
+ // current ladder index (position in lodIndices) for the entity's curLod
1618
+ let curIdx = -1;
1619
+ if (curLod >= 0) { const p = lodIndices.indexOf(curLod); if (p >= 0) curIdx = p; }
1620
+ let i = 0;
1621
+ for (let t = 0; t < thresholds.length; t++) {
1622
+ const thr = thresholds[t];
1623
+ // crossing from below (gaining detail, going to ladder index t+1) needs +margin;
1624
+ // staying/falling uses -margin. Bias depends on whether we're currently above
1625
+ // this boundary already.
1626
+ const goingUpBoundary = curIdx <= t; // we'd be increasing past this threshold
1627
+ const eff = goingUpBoundary ? thr * (1 + HYST) : thr * (1 - HYST);
1628
+ if (effPx > eff) i++; else break;
1629
+ }
1630
+ if (ceilingIdx != null) i = Math.min(i, ceilingIdx);
1631
+ const clampedIdx = Math.min(i, lodIndices.length - 1);
1632
+ return use3LodSystem ? lodIndices[clampedIdx] : clampedIdx;
1633
+ }
1634
+ function _pickTexLod(lods, screenPx) {
1635
+ const target = Math.max(64, screenPx);
1636
+ let bestIdx = 0;
1637
+ for (let i = 0; i < lods.length; i++) {
1638
+ if (lods[i].width <= target * 2) bestIdx = i;
1639
+ }
1640
+ return bestIdx;
1641
+ }
1642
+
1643
+ // Pre-compute optimal texture LODs for all textures at a given screen-space size.
1644
+ // Called once when mesh transitions to a new LOD, replaces 1000+ per-frame _pickTexLod() calls.
1645
+ function _precomputeAllTexLods(asset, screenPx) {
1646
+ const result = {};
1647
+ for (let tdIdx = 0; tdIdx < asset.texLodDescs.length; tdIdx++) {
1648
+ const desc = asset.texLodDescs[tdIdx];
1649
+ if (desc) result[tdIdx] = _pickTexLod(desc.lods, screenPx);
1650
+ }
1651
+ return result;
1652
+ }
1653
+
1654
+ // Same texture-slot resolver as the inline demo.
1655
+ function _findMaterialSlots(mat, texEntry) {
1656
+ if (!mat) return [];
1657
+ const slots = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap'];
1658
+ const out = new Set();
1659
+ for (const slot of slots) {
1660
+ const t = mat[slot];
1661
+ if (!t) continue;
1662
+ const tname = t.name || '';
1663
+ if (tname && texEntry.name && tname === texEntry.name) out.add(t);
1664
+ }
1665
+ if (out.size) return [...out];
1666
+ const nm = (texEntry.name || '').toLowerCase();
1667
+ if (nm.includes('normal') && mat.normalMap) out.add(mat.normalMap);
1668
+ if ((nm.includes('metallic') || nm.includes('roughness'))) {
1669
+ if (mat.roughnessMap) out.add(mat.roughnessMap);
1670
+ if (mat.metalnessMap) out.add(mat.metalnessMap);
1671
+ }
1672
+ if (!out.size && mat.map) out.add(mat.map);
1673
+ return [...out];
1674
+ }
1675
+
1676
+ // --- GPU VRAM Detection ---------------------------------------------------
1677
+ function _detectAvailableVRAM() {
1678
+ // Try to detect available GPU VRAM using multiple heuristics.
1679
+ // Returns estimated VRAM in MB.
1680
+
1681
+ // 1. Check navigator.deviceMemory if available (total system RAM in GB).
1682
+ if (typeof navigator !== 'undefined' && navigator.deviceMemory) {
1683
+ const systemRamGB = navigator.deviceMemory;
1684
+ // Heuristic: 50% of system RAM as GPU estimate (conservative).
1685
+ return Math.floor(systemRamGB * 512);
1686
+ }
1687
+
1688
+ // 2. Try WebGL info queries if renderer available.
1689
+ // This is best-effort; most browsers don't expose exact VRAM.
1690
+ // Heuristic based on GPU vendor and market analysis:
1691
+ // - Desktop NVIDIA/AMD: 2-4 GB typical
1692
+ // - Integrated (Intel): 256-512 MB shared
1693
+ // - Mobile: 256-512 MB
1694
+ try {
1695
+ if (typeof window !== 'undefined' && window.navigator) {
1696
+ const ua = window.navigator.userAgent.toLowerCase();
1697
+ if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
1698
+ return 512; // Mobile device
1699
+ }
1700
+ // Desktop: assume integrated GPU (conservative).
1701
+ return 2048; // 2 GB
1702
+ }
1703
+ } catch (e) {
1704
+ // Ignore errors, fall through to default.
1705
+ }
1706
+
1707
+ // 3. Default conservative estimate.
1708
+ return 1024; // 1 GB
1709
+ }
1710
+
1711
+ // --- ModelPool: the public facade -----------------------------------------
1712
+ export class ModelPool extends Emitter {
1713
+ constructor(opts = {}) {
1714
+ super();
1715
+ this.scene = opts.scene;
1716
+ this.renderer = opts.renderer;
1717
+ this.camera = opts.camera;
1718
+ this.targetFps = opts.targetFps ?? 50;
1719
+ // Material Grouping Optimization: Initialize global material pool for tier-based consolidation
1720
+ this._globalMaterialPool = new GlobalMaterialPool(this.renderer, opts);
1721
+ this._globalMaterialPool._useGlobalMaterialPool = opts.useGlobalMaterialPool !== false; // default enabled
1722
+ // BatchedMesh FAR tier: collapse all distinct far-asset draws into ~1.
1723
+ // Opt-in (default off) until witnessed at scale; enable via opts or
1724
+ // pool._useBatchedFarTier = true. When on, _getInstancedSlot returns a
1725
+ // shared-BatchedMesh adapter instead of per-asset InstancedBatch slots.
1726
+ this._useBatchedFarTier = opts.useBatchedFarTier === true;
1727
+ this._batchedFarTier = null;
1728
+ // GPU instance-transform texture: OPT-IN (default off). It was FPS-neutral
1729
+ // (static transforms upload once either way) and at 500 distinct it placed
1730
+ // instances off-screen/degenerate — the screen went ~empty (1.2% coverage)
1731
+ // while stats still reported ~497 "visible". The proven instanceMatrix
1732
+ // attribute path (default) renders correctly (64% coverage). Re-enable only
1733
+ // with opts.enableGpuInstanceTex once the at-scale texel correctness is fixed.
1734
+ this._enableGpuInstanceTex = opts.enableGpuInstanceTex === true;
1735
+ // Continuous FPS-control knob: multiplier on per-entity screen size in the
1736
+ // LOD picker. <1 = models drop to lower LODs sooner (cheaper); >1 = detail
1737
+ // extends farther. Driven by the closed-loop controller in update().
1738
+ this._lodDistanceScale = 1;
1739
+ // Frame-budgeted LOD warm loader: LODs the picker wants but that aren't yet
1740
+ // GPU-resident are registered here and processed PIECEMEAL, only when FPS
1741
+ // has headroom, so fetch+decode+GPU-upload never lands on a switch frame.
1742
+ // Network stays lazy (fetch on demand); GPU upload is eager once decoded.
1743
+ this._lodWarmQueue = new Map(); // key -> { asset, meshDescIdx, lodIdx, dist }
1744
+ this._lodWarmInFlight = 0;
1745
+ this._lodWarmMaxInFlight = opts.lodWarmMaxInFlight ?? 3; // concurrent decodes
1746
+ this._lodWarmPerFrame = opts.lodWarmPerFrame ?? 2; // starts per frame (headroom-gated)
1747
+ this._gpuWarmPending = []; // decoded geos awaiting GPU upload, 1/frame
1748
+ // Asset Streaming: Deferred load queue + unload manager
1749
+ this._deferredLoadQueue = new DeferredLoadQueue(opts.maxConcurrentDefers ?? 2);
1750
+ this._lodUnloadManager = new LodUnloadManager(opts.vramBudgetMB ?? 200);
1751
+ this._enableDeferredStreaming = opts.enableDeferredStreaming !== false; // Re-enabled with proper timeout handling
1752
+ // Detect available GPU VRAM and estimate safe budget (60-70% of available).
1753
+ this._estimatedVramMB = _detectAvailableVRAM();
1754
+ const safeByteBudget = Math.floor((this._estimatedVramMB * 0.65) * 1024 * 1024);
1755
+ this.byteBudget = opts.byteBudget ?? safeByteBudget;
1756
+ this._budgetAdjustmentCooldown = 0; // prevent too-frequent budget changes
1757
+ // VRAM-aware dynamic pool sizing: monitor actual GPU memory ratio
1758
+ this._vramRatioMonitor = {
1759
+ currentRatio: 0, // current usage ratio (0-1)
1760
+ peakRatio: 0, // peak ratio this session
1761
+ lastAdjustmentRatio: 0, // last ratio at which we adjusted
1762
+ adjustmentCooldown: 0, // frames since last dynamic adjustment
1763
+ };
1764
+ // Wide gap between critical (lower ceiling) and safe (relax ceiling) so the
1765
+ // VRAM monitor does not cycle critical<->safe and toggle LOD ceilings, which
1766
+ // makes models pop in/out. Old 0.70/0.40 with a 30-frame cooldown oscillated;
1767
+ // 0.85/0.45 with longer cooldowns (set at the adjust sites) is stable.
1768
+ this._vramThresholdWarning = 0.78; // warn at 78%
1769
+ this._vramThresholdCritical = 0.85; // adjust LOD ceiling at 85%
1770
+ this._vramThresholdSafe = 0.45; // allow relaxation at 45%
1771
+ this.maxConcurrentFetches = opts.maxConcurrentFetches ?? 6;
1772
+ this.animationThrottleDistance = opts.animationThrottleDistance ?? 15; // More aggressive animation throttling for better FPS
1773
+ this._assets = new Map(); // url -> Asset
1774
+ this._entities = new Set();
1775
+ // Position-update / lerp system. Only entities with an ACTIVE target live in
1776
+ // this map, so the per-frame cost is O(moving entities), not O(all). Idle
1777
+ // entities are never touched. Each record interpolates root.position from
1778
+ // (x0,y0,z0) -> (x1,y1,z1) over [start, start+dur]; on completion the entity
1779
+ // is removed from the map and its matrix is left resting at the target.
1780
+ this._movers = new Map(); // entity -> { x0,y0,z0, x1,y1,z1, start, dur }
1781
+ // Far-tier GPU-lerp records (entity -> same shape). Written only on setTarget
1782
+ // (O(1), never per-frame); used solely to sample current position for
1783
+ // continuous mid-flight retargets. The actual interpolation is on the GPU.
1784
+ this._farLerpState = new Map();
1785
+ this._nextEntityId = 0;
1786
+ this._totalBytes = 0;
1787
+ this._byteLog = new Map(); // assetUrl -> { url -> bytes }
1788
+ this._loadQueue = new Map(); // dedupe key -> Promise
1789
+ this._inFlight = 0;
1790
+ this._pending = []; // queued tasks { key, run, resolve, reject }
1791
+ this._fpsEma = 60;
1792
+ this._lastTick = performance.now();
1793
+ this._fpsGoodFrames = 0; // counter for ceiling relaxation (requires 5 consecutive frames above target)
1794
+ this._budgetLowFrames = 0; // counter for budget increase (requires 10 frames below 40% utilization)
1795
+ this._currentCeilingLod = 4; // Start at LOD 4 for maximum GPU instancing (all entities FAR tier)
1796
+ this._frustum = new THREE.Frustum();
1797
+ this._tmpMatrix = new THREE.Matrix4();
1798
+ // Dynamic frustum caching: track scene staticness and adjust check interval
1799
+ this._frustumCheckInterval = 5; // Default 5-frame interval for static entities
1800
+ this._dynamicFrustumCheckInterval = 5; // Will be updated based on scene staticness
1801
+ this._lastFrameMovingCount = 0; // Number of entities that moved last frame
1802
+ // Distance-coordinated bucket system: per-category per-frame budgets in milliseconds
1803
+ this._heroBudgetMs = 2.0; // HERO tier: fully animated entities, ~2ms budget
1804
+ this._midBudgetMs = 4.0; // MID tier: mixed quality, ~4ms budget
1805
+ this._heroDist = 20; // Distance threshold for HERO tier (meters)
1806
+ this._midDist = 60; // Distance threshold for MID tier (meters)
1807
+ this._heroFrameTimeMs = 0; // Accumulated per-frame time for HERO tier
1808
+ this._midFrameTimeMs = 0; // Accumulated per-frame time for MID tier
1809
+ this._entityDistances = []; // Cache: [{ entity, distance, screenPx }] sorted by distance
1810
+ // Stats snapshot — refreshed each tick, exposed via getStats().
1811
+ this._stats = { fps: 0, entities: 0, drawCalls: 0, ceilingLod: null, bytes: 0, assets: 0, inFlight: 0, hero: 0, mid: 0, far: 0, heroBudgetMs: 0, midBudgetMs: 0 };
1812
+ // Shared InstancedMesh slots: key `${assetUrl}|${meshDescIdx}|${lodIdx}` -> InstancedSlot.
1813
+ this._instancedSlots = new Map();
1814
+ // Tier thresholds (in screen pixels of the entity's bounding sphere).
1815
+ // The three tiers are entirely a function of which LOD the picker
1816
+ // selects, NOT a separate routing layer:
1817
+ // HERO: top-of-ladder textured LODs (per-entity SkinnedMesh draws).
1818
+ // MID: middle textured / vertcolor LODs (still per-entity draws, but
1819
+ // the geometry is decimated and the material is cheaper).
1820
+ // FAR: unskinned LOD (routes through per-asset InstancedMesh; real 3D
1821
+ // geometry preserved — works for terrain chunks, props, anything).
1822
+ // We track these in the HUD only for visibility; routing happens via the
1823
+ // standard LOD picker.
1824
+ this.heroPx = opts.heroPx ?? 200;
1825
+ this.midPx = opts.midPx ?? 120; // Aggressive default: push more to FAR tier for speed
1826
+ this.heroCap = opts.heroCap ?? 3; // Maximum 3 HERO entities to prioritize GPU instancing
1827
+ // Feature toggles for Phase 5 validation
1828
+ this._enableFrustumCulling = true;
1829
+ this._enableTextureLod = true;
1830
+ this._enableAnimThrottle = true;
1831
+ // 3-LOD Simplification: use only LODs [0, 2, 4], skip intermediate LODs 1 and 3
1832
+ // This saves ~40% VRAM and reduces memory allocation churn (default enabled)
1833
+ // 5-LOD ladder by default now. The 3-LOD system used only LODs [0,2,4],
1834
+ // which made entities jump straight between the lowest (unskinned) and
1835
+ // highest (textured) tiers with nothing in between — the "swaps straight
1836
+ // from lowest to highest instead of a good range" symptom. The full
1837
+ // [0,1,2,3,4] ladder gives a smooth gradient; epoch-gated picking + picker
1838
+ // hysteresis keep it from churning. Opt back into 3-LOD via opts.use3LodSystem.
1839
+ this._use3LodSystem = opts.use3LodSystem === true; // default false (5-LOD)
1840
+ // ANGLE_multi_draw Optimizer: Batches 120+ FAR-tier draws into 1-3 submissions (+6-10 FPS)
1841
+ // Initialized lazily after draw call batching is enabled
1842
+ this._multiDrawOptimizer = null;
1843
+ this._enableMultiDraw = opts.enableMultiDraw !== false; // default enabled
1844
+ // Worker pool for sibling-LOD fetch + decode. Defaults to 4 workers
1845
+ // (more = more concurrent decodes; each holds one three.js instance so
1846
+ // memory grows linearly). Set to 0 to disable and fall back to
1847
+ // main-thread decode.
1848
+ // Scale decode-worker parallelism to the machine (leave one core for the
1849
+ // main thread), clamped to a sane 2..8. Explicit opts.workerCount wins.
1850
+ this._workerCount = opts.workerCount ?? Math.max(2, Math.min(8, (typeof navigator !== 'undefined' && navigator.hardwareConcurrency ? navigator.hardwareConcurrency : 4) - 1));
1851
+ this._workers = [];
1852
+ this._workerRR = 0; // round-robin cursor
1853
+ this._workerPending = new Map(); // id -> {resolve, reject}
1854
+ this._workerNextId = 0;
1855
+ if (this._workerCount > 0 && typeof Worker !== 'undefined') {
1856
+ try {
1857
+ for (let i = 0; i < this._workerCount; i++) {
1858
+ const workerUrl = new URL('./lod-worker.js', import.meta.url);
1859
+ const w = new Worker(workerUrl, { type: 'module' });
1860
+ w.addEventListener('message', (ev) => {
1861
+ const m = ev.data;
1862
+ if (m && m.id === 0 && m.ready) {
1863
+ if (!m.ok) console.error('[pool] worker init failed:', m.error);
1864
+ else console.log('[pool] worker ready');
1865
+ return;
1866
+ }
1867
+ this._onWorkerMessage(m);
1868
+ });
1869
+ w.addEventListener('error', (e) => {
1870
+ const detail = {
1871
+ message: e.message || '(no message)',
1872
+ filename: e.filename || '(no filename)',
1873
+ lineno: e.lineno,
1874
+ colno: e.colno,
1875
+ error: e.error ? (e.error.stack || String(e.error)) : '(no error obj)',
1876
+ workerUrl: String(workerUrl),
1877
+ };
1878
+ console.error('[pool] worker error', JSON.stringify(detail));
1879
+ });
1880
+ w.addEventListener('messageerror', (e) => console.error('[pool] worker messageerror', String(e)));
1881
+ this._workers.push(w);
1882
+ }
1883
+ } catch (e) {
1884
+ console.warn('[pool] worker init failed, falling back to main-thread decode', e);
1885
+ this._workers = [];
1886
+ }
1887
+ }
1888
+
1889
+ // Phase 3 Quick-Wins Optimizations
1890
+ // QW1: Vertex Attribute Compression (pack vec4 → vec3)
1891
+ this._vertexCompressionOptimizer = opts.enableVertexCompression !== false
1892
+ ? new VertexCompressionOptimizer()
1893
+ : null;
1894
+
1895
+ // QW2: Draw Call Ordering (sort by material, distance, LOD)
1896
+ this._drawCallSorter = opts.enableDrawCallSorting !== false
1897
+ ? new DrawCallSorter()
1898
+ : null;
1899
+
1900
+ // QW3: Instance Buffer Pool (pre-allocate 20 buffer chunks)
1901
+ this._instanceBufferPool = opts.enableBufferPool !== false
1902
+ ? new InstanceBufferPool({
1903
+ minCapacity: opts.poolMinCapacity ?? 32,
1904
+ maxCapacity: opts.poolMaxCapacity ?? 2048,
1905
+ chunkCount: opts.poolChunkCount ?? 20,
1906
+ })
1907
+ : null;
1908
+
1909
+ // QW4: Instance Reuse Across Assets (hash LOD geometry, reuse InstancedSlots)
1910
+ this._enableInstanceReuse = opts.enableInstanceReuse !== false; // default enabled
1911
+ this._lodGeometryHash = new Map(); // geometry content hash → LOD info
1912
+
1913
+ // QW5: Attribute Deinterleaving (separate position/normal/uv buffers)
1914
+ this._enableDeinterleaving = opts.enableDeinterleaving !== false; // default enabled
1915
+ }
1916
+
1917
+ // Phase 5: Public getter/setter for interactive UI control
1918
+ get ceilingLod() {
1919
+ return this._currentCeilingLod;
1920
+ }
1921
+ set ceilingLod(val) {
1922
+ this._currentCeilingLod = val === 5 ? null : (val != null ? val : null);
1923
+ }
1924
+
1925
+ // Public getter/setter for frustum check interval override (for UI slider)
1926
+ get frustumCheckInterval() {
1927
+ return this._frustumCheckInterval;
1928
+ }
1929
+ set frustumCheckInterval(val) {
1930
+ // Allow manual override: if set to 0, use automatic dynamic calculation
1931
+ // Otherwise, force fixed interval for testing/tuning
1932
+ this._frustumCheckInterval = val;
1933
+ if (val === 0) {
1934
+ // Re-enable dynamic calculation
1935
+ this._dynamicFrustumCheckInterval = 5;
1936
+ }
1937
+ }
1938
+
1939
+ _onWorkerMessage(msg) {
1940
+ const pend = this._workerPending.get(msg.id);
1941
+ if (!pend) return;
1942
+ this._workerPending.delete(msg.id);
1943
+ if (msg.ok) pend.resolve(msg.payload);
1944
+ else pend.reject(new Error(msg.error || 'worker decode failed'));
1945
+ }
1946
+
1947
+ // Fetch + decode a sibling LOD GLB in a worker; returns a Promise<payload>
1948
+ // where payload is { attrs, index, boundingSphere, boundingBox, bytes }.
1949
+ _workerFetchLod(url, decodeAABB, sloppyCap = 0) {
1950
+ if (!this._workers.length) return null;
1951
+ const id = ++this._workerNextId;
1952
+ const w = this._workers[this._workerRR];
1953
+ this._workerRR = (this._workerRR + 1) % this._workers.length;
1954
+ return new Promise((resolve, reject) => {
1955
+ this._workerPending.set(id, { resolve, reject });
1956
+ w.postMessage({ id, url, decodeAABB, sloppyCap });
1957
+ });
1958
+ }
1959
+
1960
+ // Rebuild a BufferGeometry on the main thread from a worker payload.
1961
+ // No heavy work here — typed arrays were transferred, so this is just
1962
+ // attribute wiring.
1963
+ static _buildGeometryFromPayload(payload) {
1964
+ const geo = new THREE.BufferGeometry();
1965
+ for (const k of Object.keys(payload.attrs)) {
1966
+ const a = payload.attrs[k];
1967
+ // The worker denormalizes every attribute to 0..1 Float32 via getX/getY/getZ,
1968
+ // but ships the SOURCE `normalized` flag. If we pass normalized:true with a
1969
+ // Float32Array, THREE re-normalizes already-0..1 values and corrupts them —
1970
+ // this is why vertex colors rendered white/black. A Float32Array is never
1971
+ // a normalized integer buffer, so force normalized:false for float arrays.
1972
+ const isFloat = a.array instanceof Float32Array;
1973
+ const normalized = isFloat ? false : !!a.normalized;
1974
+ geo.setAttribute(k, new THREE.BufferAttribute(a.array, a.itemSize, normalized));
1975
+ }
1976
+ if (payload.index) {
1977
+ geo.setIndex(new THREE.BufferAttribute(payload.index, 1));
1978
+ }
1979
+ if (payload.boundingSphere) {
1980
+ geo.boundingSphere = new THREE.Sphere(
1981
+ new THREE.Vector3().fromArray(payload.boundingSphere.center),
1982
+ payload.boundingSphere.radius
1983
+ );
1984
+ }
1985
+ if (payload.boundingBox) {
1986
+ geo.boundingBox = new THREE.Box3(
1987
+ new THREE.Vector3().fromArray(payload.boundingBox.min),
1988
+ new THREE.Vector3().fromArray(payload.boundingBox.max)
1989
+ );
1990
+ }
1991
+ return geo;
1992
+ }
1993
+
1994
+ // QW1: Apply vertex attribute compression (vec4 → vec3) if enabled
1995
+ _compressGeometryAttributes(geometry) {
1996
+ if (!this._vertexCompressionOptimizer) return geometry;
1997
+ return this._vertexCompressionOptimizer.compressGeometry(geometry);
1998
+ }
1999
+
2000
+ // QW4: Generate a hash of LOD geometry for reuse detection.
2001
+ // Hash is based on vertex count, index count, and bounding sphere.
2002
+ // Geometries with identical hashes can share InstancedSlots.
2003
+ _hashLodGeometry(geometry) {
2004
+ if (!geometry) return null;
2005
+ const posAttr = geometry.getAttribute('position');
2006
+ const indexAttr = geometry.getIndex();
2007
+ const bounds = geometry.boundingSphere;
2008
+ if (!posAttr) return null;
2009
+
2010
+ // Simple hash: vertex count + index count + radius
2011
+ // In production, could use a cryptographic hash of actual vertex data
2012
+ const vertCount = posAttr.count;
2013
+ const indexCount = indexAttr ? indexAttr.count : 0;
2014
+ const radius = bounds ? bounds.radius.toFixed(2) : '0';
2015
+ return `${vertCount}:${indexCount}:${radius}`;
2016
+ }
2017
+
2018
+ // QW4: Check if a geometry hash already has a reusable InstancedSlot.
2019
+ // If yes, returns the slot; otherwise returns null.
2020
+ _findReusableSlotByGeometryHash(geometry) {
2021
+ if (!this._enableInstanceReuse) return null;
2022
+ const hash = this._hashLodGeometry(geometry);
2023
+ if (!hash) return null;
2024
+ const info = this._lodGeometryHash.get(hash);
2025
+ return info ? info.slot : null;
2026
+ }
2027
+
2028
+ // QW4: Register a geometry hash → slot mapping for future reuse.
2029
+ _registerGeometryHashSlot(geometry, slot) {
2030
+ if (!this._enableInstanceReuse) return;
2031
+ const hash = this._hashLodGeometry(geometry);
2032
+ if (!hash) return;
2033
+ const info = { slot, geometry, refCount: 1 };
2034
+ this._lodGeometryHash.set(hash, info);
2035
+ }
2036
+
2037
+ // QW5: Deinterleave vertex buffer layout.
2038
+ // Converts interleaved (pos, norm, uv, pos, norm, uv, ...)
2039
+ // to separate (pos..., norm..., uv...)
2040
+ // This improves L1/L2 cache hit rate for vertex shader.
2041
+ _deinterleaveGeometryAttributes(geometry) {
2042
+ if (!this._enableDeinterleaving) return geometry;
2043
+
2044
+ const posAttr = geometry.getAttribute('position');
2045
+ const normAttr = geometry.getAttribute('normal');
2046
+ const uvAttr = geometry.getAttribute('uv');
2047
+
2048
+ if (!posAttr) return geometry; // No position = can't deinterleave
2049
+
2050
+ const vertCount = posAttr.count;
2051
+
2052
+ // If already deinterleaved (separate buffers), skip
2053
+ if (posAttr.buffer !== normAttr?.buffer || posAttr.buffer !== uvAttr?.buffer) {
2054
+ return geometry; // Already deinterleaved or not interleaved
2055
+ }
2056
+
2057
+ // Allocate separate buffers
2058
+ const newPosArray = new Float32Array(vertCount * 3);
2059
+ const newNormArray = normAttr ? new Float32Array(vertCount * 3) : null;
2060
+ const newUvArray = uvAttr ? new Float32Array(vertCount * 2) : null;
2061
+
2062
+ // Copy data into deinterleaved layout
2063
+ for (let i = 0; i < vertCount; i++) {
2064
+ newPosArray[i * 3 + 0] = posAttr.getX(i);
2065
+ newPosArray[i * 3 + 1] = posAttr.getY(i);
2066
+ newPosArray[i * 3 + 2] = posAttr.getZ(i);
2067
+
2068
+ if (newNormArray && normAttr) {
2069
+ newNormArray[i * 3 + 0] = normAttr.getX(i);
2070
+ newNormArray[i * 3 + 1] = normAttr.getY(i);
2071
+ newNormArray[i * 3 + 2] = normAttr.getZ(i);
2072
+ }
2073
+
2074
+ if (newUvArray && uvAttr) {
2075
+ newUvArray[i * 2 + 0] = uvAttr.getX(i);
2076
+ newUvArray[i * 2 + 1] = uvAttr.getY(i);
2077
+ }
2078
+ }
2079
+
2080
+ // Create new deinterleaved attributes
2081
+ const newGeo = geometry.clone();
2082
+ newGeo.setAttribute('position', new THREE.BufferAttribute(newPosArray, 3));
2083
+ if (newNormArray) newGeo.setAttribute('normal', new THREE.BufferAttribute(newNormArray, 3));
2084
+ if (newUvArray) newGeo.setAttribute('uv', new THREE.BufferAttribute(newUvArray, 2));
2085
+
2086
+ return newGeo;
2087
+ }
2088
+
2089
+ // Register a wanted-but-not-resident LOD for the frame-budgeted warm loader.
2090
+ // Cheap + idempotent: just records the want (nearest distance wins priority).
2091
+ _enqueueLodWarm(asset, meshDescIdx, lodIdx, dist) {
2092
+ const desc = asset.meshLodDescs[meshDescIdx];
2093
+ if (!desc) return;
2094
+ const t = desc.lods[lodIdx];
2095
+ if (!t || t.inline) return; // inline LODs are always resident
2096
+ const key = `${asset.url}#${desc.meshIndex}:${desc.primIndex}:${lodIdx}`;
2097
+ if (asset.geoCache.has(`${desc.meshIndex}:${desc.primIndex}:${lodIdx}`)) return; // already resident
2098
+ const ex = this._lodWarmQueue.get(key);
2099
+ if (ex) { if (dist < ex.dist) ex.dist = dist; return; }
2100
+ this._lodWarmQueue.set(key, { asset, meshDescIdx, lodIdx, dist });
2101
+ }
2102
+
2103
+ // ---- Position-update API: GPU-eager, CPU-O(moving) ---------------------
2104
+ // Move an entity toward a target over durationMs. The CPU only records the
2105
+ // target here; the per-frame interpolation runs in _drainMovers() over just
2106
+ // the active set. A retarget mid-flight is continuous: the current
2107
+ // interpolated position becomes the new start. durationMs<=0 snaps instantly.
2108
+ setTarget(entity, x, y, z, durationMs = 300, nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now())) {
2109
+ if (!entity || entity._disposed) return;
2110
+ // Determine the start position = current rendered position (continuous
2111
+ // retarget). If a mover is already in flight, sample it at `now`; else use
2112
+ // the entity's resting root.position.
2113
+ let sx, sy, sz;
2114
+ const cur = this._movers.get(entity);
2115
+ const farCur = this._farLerpState && this._farLerpState.get(entity);
2116
+ if (cur) {
2117
+ const t = cur.dur > 0 ? Math.min(1, Math.max(0, (nowMs - cur.start) / cur.dur)) : 1;
2118
+ sx = cur.x0 + (cur.x1 - cur.x0) * t;
2119
+ sy = cur.y0 + (cur.y1 - cur.y0) * t;
2120
+ sz = cur.z0 + (cur.z1 - cur.z0) * t;
2121
+ } else if (farCur) {
2122
+ // Continuous retarget for a far entity mid-GPU-lerp: sample its current
2123
+ // interpolated position (mirror of the shader math) as the new start.
2124
+ const t = farCur.dur > 0 ? Math.min(1, Math.max(0, (nowMs - farCur.start) / farCur.dur)) : 1;
2125
+ sx = farCur.x0 + (farCur.x1 - farCur.x0) * t;
2126
+ sy = farCur.y0 + (farCur.y1 - farCur.y0) * t;
2127
+ sz = farCur.z0 + (farCur.z1 - farCur.z0) * t;
2128
+ } else {
2129
+ const p = entity.root.position;
2130
+ sx = p.x; sy = p.y; sz = p.z;
2131
+ }
2132
+ if (durationMs <= 0) {
2133
+ // Instant: write position now, no mover record needed.
2134
+ this._movers.delete(entity);
2135
+ if (this._farLerpState) this._farLerpState.delete(entity);
2136
+ this._clearFarLerp(entity);
2137
+ this._applyEntityPosition(entity, x, y, z);
2138
+ return;
2139
+ }
2140
+ // GPU-LERP FAST PATH: if every tracked mesh of this entity lives on the
2141
+ // BatchedMesh far tier, push the interpolation onto the GPU (write 2 texels)
2142
+ // and DO NOT register a CPU mover — the vertex shader interpolates each
2143
+ // frame, so the CPU writes nothing per-frame for this entity (only this
2144
+ // sparse texel write + the once-per-frame uNow uniform). Falls through to the
2145
+ // CPU mover for hero/mid tiers the shader lerp can't reach.
2146
+ if (this._setFarLerp(entity, sx, sy, sz, x, y, z, nowMs / 1000, durationMs / 1000)) {
2147
+ this._movers.delete(entity); // ensure no stale CPU mover double-drives it
2148
+ // Record the lerp CPU-side (O(1), only on retarget — NOT per frame) so a
2149
+ // mid-flight retarget can sample the true current position for continuity.
2150
+ this._farLerpState.set(entity, { x0: sx, y0: sy, z0: sz, x1: x, y1: y, z1: z, start: nowMs, dur: durationMs });
2151
+ // Set root.position to the current START (not the target): distance/LOD
2152
+ // read where the entity actually is now; it will settle toward the target
2153
+ // as the GPU lerps. (One write, not per-frame.)
2154
+ entity.root.position.set(sx, sy, sz);
2155
+ return;
2156
+ }
2157
+ this._movers.set(entity, { x0: sx, y0: sy, z0: sz, x1: x, y1: y, z1: z, start: nowMs, dur: durationMs });
2158
+ }
2159
+
2160
+ // Try to drive an entity's interpolation entirely on the GPU via the far tier.
2161
+ // Returns true only if the entity is fully resident on the BatchedFarTier (all
2162
+ // tracked meshes batched-far with a valid instance id), so the CPU can skip it.
2163
+ _setFarLerp(entity, x0, y0, z0, x1, y1, z1, startSec, durSec) {
2164
+ const tier = this._batchedFarTier;
2165
+ if (!tier) return false;
2166
+ if (!entity.trackedMeshes || entity.trackedMeshes.length === 0) return false;
2167
+ const id = tier.instanceIdFor(entity);
2168
+ if (id < 0) return false;
2169
+ // Every tracked mesh must be on the far tier (else part of the entity would
2170
+ // not interpolate). In practice the far tier carries the whole entity.
2171
+ for (const tm of entity.trackedMeshes) {
2172
+ if (!tm._instancedSlot || !tm._instancedSlot._batchedFar) return false;
2173
+ }
2174
+ tier.setLerpTarget(id, x0, y0, z0, x1, y1, z1, startSec, durSec);
2175
+ return true;
2176
+ }
2177
+
2178
+ _clearFarLerp(entity) {
2179
+ if (this._farLerpState) this._farLerpState.delete(entity);
2180
+ const tier = this._batchedFarTier;
2181
+ if (!tier) return;
2182
+ const id = tier.instanceIdFor(entity);
2183
+ if (id >= 0) tier.clearLerp(id);
2184
+ }
2185
+
2186
+ // Write an absolute position onto an entity and push it to whatever slot(s)
2187
+ // its tracked meshes currently occupy (far BatchedMesh, mid InstancedMesh, or
2188
+ // hero). Backend-agnostic: both tiers route through setMatrixForSlot.
2189
+ _applyEntityPosition(entity, x, y, z) {
2190
+ entity.root.position.set(x, y, z);
2191
+ // Static entities have matrixAutoUpdate off; refresh the world matrix once.
2192
+ entity.root.updateMatrix();
2193
+ entity.root.updateMatrixWorld(true);
2194
+ for (const tm of entity.trackedMeshes) {
2195
+ if (tm._instancedSlot && tm._instancedSlotIdx != null && tm._instancedSlotIdx >= 0) {
2196
+ tm._instancedSlot.setMatrixForSlot(tm._instancedSlotIdx, entity._slotWorldMatrix(tm));
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ // Per-frame: interpolate only entities with an active target. O(active set).
2202
+ // Completed movers are removed and left resting at their target. Called once
2203
+ // per frame from update().
2204
+ _drainMovers(nowMs) {
2205
+ if (this._movers.size === 0) return;
2206
+ for (const [entity, m] of this._movers) {
2207
+ if (entity._disposed) { this._movers.delete(entity); continue; }
2208
+ const t = m.dur > 0 ? Math.min(1, Math.max(0, (nowMs - m.start) / m.dur)) : 1;
2209
+ const x = m.x0 + (m.x1 - m.x0) * t;
2210
+ const y = m.y0 + (m.y1 - m.y0) * t;
2211
+ const z = m.z0 + (m.z1 - m.z0) * t;
2212
+ this._applyEntityPosition(entity, x, y, z);
2213
+ if (t >= 1) this._movers.delete(entity); // settled: stop touching it
2214
+ }
2215
+ }
2216
+
2217
+ // Process the warm queue PIECEMEAL: only when FPS has headroom, start at most
2218
+ // _lodWarmPerFrame decodes/frame and cap concurrent in-flight. Each item
2219
+ // fetches (lazy) + decodes (worker) via ensureMeshLod, then GPU-warms the
2220
+ // resulting geometry so the eventual LOD switch pays no first-use upload.
2221
+ // Nearest-first. Called once per frame from update().
2222
+ _drainLodWarm() {
2223
+ if (this._lodWarmQueue.size === 0) return;
2224
+ // Adaptive budget: more starts when FPS has headroom, but NEVER zero —
2225
+ // gating warming off entirely below target deadlocks (entities that need a
2226
+ // cheaper far LOD to recover FPS can never get it, so FPS stays low forever
2227
+ // and the queue never drains). Always allow at least 1 decode/frame so the
2228
+ // scene can progress toward its resting LODs; ramp up with headroom.
2229
+ const target = this.targetFps || 60;
2230
+ let starts;
2231
+ if (this._fpsEma >= target + 5) starts = this._lodWarmPerFrame * 2;
2232
+ else if (this._fpsEma >= target - 8) starts = this._lodWarmPerFrame;
2233
+ else starts = 1; // struggling — minimum forward progress, no zero-deadlock
2234
+ // Pick nearest-first without sorting the whole map every frame: a cheap
2235
+ // single linear min-scan per start (queue is small in practice).
2236
+ while (starts-- > 0 && this._lodWarmInFlight < this._lodWarmMaxInFlight && this._lodWarmQueue.size) {
2237
+ let bestKey = null, bestDist = Infinity;
2238
+ for (const [k, v] of this._lodWarmQueue) { if (v.dist < bestDist) { bestDist = v.dist; bestKey = k; } }
2239
+ if (bestKey == null) break;
2240
+ const item = this._lodWarmQueue.get(bestKey);
2241
+ this._lodWarmQueue.delete(bestKey);
2242
+ this._lodWarmInFlight++;
2243
+ Promise.resolve(item.asset.ensureMeshLod(item.meshDescIdx, item.lodIdx))
2244
+ .then((geo) => { if (geo && !geo.__gpuWarmed) this._gpuWarmPending.push(geo); })
2245
+ .catch(() => {})
2246
+ .finally(() => { this._lodWarmInFlight--; });
2247
+ }
2248
+ }
2249
+
2250
+ // Synchronous GPU warm (a renderer.render() into a 1x1 target) is the one
2251
+ // spiky part of warming: several decode promises can resolve in the same
2252
+ // frame and, done inline, fire N back-to-back renders → a frame-time burst
2253
+ // that shows up as the min-FPS dip during streaming. Decouple decode (kept
2254
+ // parallel) from upload (drained here at most ONE per frame) so the cost is
2255
+ // spread across frames instead of bunched. Called once per frame in update().
2256
+ _drainGpuWarm() {
2257
+ if (!this._gpuWarmPending.length) return;
2258
+ // Adaptive per-frame upload budget: spread uploads to avoid the burst that
2259
+ // caused the min-FPS dip, but NEVER throttle so hard that a large scene
2260
+ // can't reach its resting LODs. With headroom, drain aggressively (the
2261
+ // backlog clears fast during initial streaming); near/below target, drip.
2262
+ const target = this.targetFps || 60;
2263
+ let budget;
2264
+ if (this._fpsEma >= target + 5) budget = 8;
2265
+ else if (this._fpsEma >= target - 8) budget = 3;
2266
+ else budget = 1; // struggling — one upload/frame, still forward progress
2267
+ while (budget-- > 0 && this._gpuWarmPending.length) {
2268
+ const geo = this._gpuWarmPending.shift();
2269
+ if (geo && !geo.__gpuWarmed) this._gpuWarmGeometry(geo);
2270
+ }
2271
+ }
2272
+
2273
+ // Eagerly upload a geometry's buffers to the GPU so a later draw with it does
2274
+ // not stall on first use. THREE uploads attributes lazily at first render;
2275
+ // renderer.initGeometry (r0.16x+ via WebGLAttributes) isn't public, so we use
2276
+ // the documented path: renderer.compile won't upload geometry, but rendering
2277
+ // it once into the current target does. To avoid a visible flash we draw it
2278
+ // through an off-screen 1x1 scratch with a tiny ortho cam. Cheap (1 tri batch
2279
+ // of already-decimated geo) and one-shot per geometry.
2280
+ _gpuWarmGeometry(geo) {
2281
+ if (!geo || geo.__gpuWarmed) return;
2282
+ try {
2283
+ if (!this._warmScene) {
2284
+ this._warmScene = new THREE.Scene();
2285
+ this._warmCam = new THREE.Camera();
2286
+ this._warmMat = new THREE.MeshBasicMaterial();
2287
+ this._warmMesh = new THREE.Mesh(undefined, this._warmMat);
2288
+ this._warmMesh.frustumCulled = false;
2289
+ this._warmScene.add(this._warmMesh);
2290
+ this._warmTarget = new THREE.WebGLRenderTarget(1, 1);
2291
+ }
2292
+ const prevTarget = this.renderer.getRenderTarget();
2293
+ this._warmMesh.geometry = geo;
2294
+ this.renderer.setRenderTarget(this._warmTarget);
2295
+ this.renderer.render(this._warmScene, this._warmCam); // forces buffer upload
2296
+ this.renderer.setRenderTarget(prevTarget);
2297
+ this._warmMesh.geometry = undefined;
2298
+ geo.__gpuWarmed = true;
2299
+ } catch (e) { /* warming is best-effort; a cold first-use is the fallback */ }
2300
+ }
2301
+
2302
+ // Get-or-create an InstancedSlot for an (asset, meshDescIdx, lod) tuple.
2303
+ // Returns null if the LOD isn't suitable for instancing (currently only
2304
+ // 'unskinned' LODs qualify — they have no per-instance bone state).
2305
+ //
2306
+ // MATERIAL GROUPING OPTIMIZATION: Uses global material pool for FAR tier
2307
+ // instead of creating per-LOD materials. This reduces material count from
2308
+ // 8-12 to 3 (one per tier), reducing shader programs and state changes.
2309
+ _getInstancedSlot(asset, meshDescIdx, lodIdx) {
2310
+ const desc = asset.meshLodDescs[meshDescIdx];
2311
+ if (!desc) return null;
2312
+ const lod = desc.lods[lodIdx];
2313
+ if (!lod || (lod.kind || 'textured') !== 'unskinned') return null;
2314
+ const key = `${asset.url}|${meshDescIdx}|${lodIdx}`;
2315
+ let slot = this._instancedSlots.get(key);
2316
+ if (slot) return slot;
2317
+ const geo = asset.geoCache.get(`${desc.meshIndex}:${desc.primIndex}:${lodIdx}`);
2318
+ if (!geo) return null; // not loaded yet
2319
+
2320
+ // BatchedMesh FAR tier: one shared BatchedMesh draws ALL distinct far
2321
+ // geometries in ~1 draw call (vs one InstancedBatch/draw per distinct
2322
+ // asset). Return a per-(asset,lod) adapter that delegates to the shared
2323
+ // tier; the entity code uses it exactly like an instanced slot. LOD changes
2324
+ // within the far tier become synchronous setGeometryIdAt swaps.
2325
+ if (this._useBatchedFarTier) {
2326
+ if (!this._batchedFarTier) {
2327
+ this._batchedFarTier = new BatchedFarTier(this);
2328
+ this.scene.add(this._batchedFarTier.mesh);
2329
+ }
2330
+ const adapter = this._batchedFarTier.slotAdapter(asset, meshDescIdx, lodIdx, geo);
2331
+ this._instancedSlots.set(key, adapter);
2332
+ return adapter;
2333
+ }
2334
+
2335
+ // Phase 3 QW4: Check if this geometry can reuse an existing InstancedSlot
2336
+ // from another asset (geometry-hash based reuse)
2337
+ const reusableSlot = this._findReusableSlotByGeometryHash(geo);
2338
+ if (reusableSlot) {
2339
+ this._instancedSlots.set(key, reusableSlot);
2340
+ return reusableSlot; // Reuse existing slot instead of creating new one
2341
+ }
2342
+
2343
+ // Material Grouping: Use global FAR-tier material if enabled
2344
+ let mat;
2345
+ if (this._globalMaterialPool._useGlobalMaterialPool) {
2346
+ mat = this._globalMaterialPool.getMaterialForTier('far');
2347
+ } else {
2348
+ // Fallback: create per-LOD material (baseline behavior)
2349
+ mat = new THREE.MeshLambertMaterial({ vertexColors: true });
2350
+ mat.onBeforeCompile = (shader) => {
2351
+ shader.fragmentShader = shader.fragmentShader.replace(
2352
+ '#include <color_fragment>',
2353
+ `#if defined( USE_COLOR_ALPHA )
2354
+ diffuseColor.rgb *= pow(vColor.rgb, vec3(2.2));
2355
+ diffuseColor.a *= vColor.a;
2356
+ #elif defined( USE_COLOR )
2357
+ diffuseColor.rgb *= pow(vColor, vec3(2.2));
2358
+ #endif`
2359
+ );
2360
+ };
2361
+ }
2362
+
2363
+ slot = new InstancedSlot(this, asset, meshDescIdx, lodIdx, geo, mat);
2364
+ this._instancedSlots.set(key, slot);
2365
+
2366
+ // Phase 3 QW4: Register this geometry for potential future reuse
2367
+ this._registerGeometryHashSlot(geo, slot);
2368
+
2369
+ // Attach the instanced mesh to the same scene as entities.
2370
+ this.scene.add(slot.mesh);
2371
+ return slot;
2372
+ }
2373
+
2374
+ // Get-or-load an asset; idempotent.
2375
+ async _resolveAsset(url) {
2376
+ let a = this._assets.get(url);
2377
+ if (!a) {
2378
+ a = new Asset(this, url);
2379
+ this._assets.set(url, a);
2380
+ a.ready.then(() => this.emit('asset-ready', a)).catch((e) => this.emit('asset-error', { asset: a, error: e }));
2381
+ }
2382
+ return a;
2383
+ }
2384
+
2385
+ // Spawn an entity from a URL. Returns an Entity handle synchronously; the
2386
+ // Entity emits 'ready' once loading completes.
2387
+ spawn(url, opts = {}) {
2388
+ if (!url) throw new Error('spawn(): url required');
2389
+ const assetPromise = this._resolveAsset(url);
2390
+ // Build a placeholder entity tied to a yet-to-resolve asset.
2391
+ // We attach to the entity once the asset is ready inside Entity._bootstrap.
2392
+ const placeholder = { _disposed: false };
2393
+ let actualEntity = null;
2394
+ // Wrap into a lightweight proxy so caller can listen on events even
2395
+ // before bootstrap finishes.
2396
+ const proxy = new Emitter();
2397
+ proxy.root = new THREE.Object3D();
2398
+ proxy.root.name = `pending_${++this._nextEntityId}`;
2399
+ proxy.dispose = () => {
2400
+ placeholder._disposed = true;
2401
+ if (actualEntity) actualEntity.dispose();
2402
+ else proxy.root.parent?.remove(proxy.root);
2403
+ };
2404
+ assetPromise.then((asset) => {
2405
+ if (placeholder._disposed) return;
2406
+ actualEntity = new Entity(this, asset, opts);
2407
+ this._entities.add(actualEntity);
2408
+ // Stitch: replace proxy.root with actualEntity.root in the parent.
2409
+ const parent = proxy.root.parent;
2410
+ if (parent) {
2411
+ parent.add(actualEntity.root);
2412
+ parent.remove(proxy.root);
2413
+ }
2414
+ // Re-forward events.
2415
+ actualEntity.on('ready', (e) => proxy.emit('ready', e));
2416
+ actualEntity.on('lod-changed', (e) => proxy.emit('lod-changed', e));
2417
+ actualEntity.on('disposed', (e) => proxy.emit('disposed', e));
2418
+ actualEntity.on('error', (e) => proxy.emit('error', e));
2419
+ // Expose useful entity props on the proxy.
2420
+ proxy.actualEntity = actualEntity;
2421
+ proxy.root = actualEntity.root;
2422
+ }).catch((e) => proxy.emit('error', e));
2423
+ return proxy;
2424
+ }
2425
+
2426
+ // Per-frame update: call from your render loop AFTER advancing camera.
2427
+ update() {
2428
+ const tUpdate0 = performance.now();
2429
+ const now = tUpdate0;
2430
+ const dt = (now - this._lastTick) / 1000;
2431
+ this._lastTick = now;
2432
+ // EMA FPS over ~500ms (faster feedback than 1s).
2433
+ const instFps = dt > 0 ? 1 / dt : 60;
2434
+ this._fpsEma = this._fpsEma * 0.85 + instFps * 0.15;
2435
+
2436
+ // Push the monotonic clock to the GPU-lerp shader once per frame — the ONLY
2437
+ // per-frame CPU->GPU write for far entities interpolating on the GPU. (The
2438
+ // far tier reads it via the uNow uniform; entities in flight need no matrix
2439
+ // write at all.)
2440
+ if (this._batchedFarTier) this._batchedFarTier.updateNow(now / 1000);
2441
+
2442
+ // Interpolate active position targets BEFORE the entity/LOD pass so lerped
2443
+ // positions feed this frame's distance + LOD picks. O(moving entities).
2444
+ // (CPU fallback path for hero/mid tiers; far entities use the GPU lerp above.)
2445
+ this._drainMovers(now);
2446
+
2447
+ // Staticness count is folded into the single entity pass below to avoid a
2448
+ // second full walk of every entity per frame (was a separate loop). The
2449
+ // frustum-check interval is driven by LAST frame's count — a one-frame lag
2450
+ // on a smoothing heuristic, harmless and already implicit in the EMA.
2451
+ const totalEntities = this._entities.size;
2452
+ const prevMoving = this._lastFrameMovingCount || 0;
2453
+ const sceneStaticnessPercent = totalEntities > 0 ? ((totalEntities - prevMoving) / totalEntities) * 100 : 100;
2454
+ this._dynamicFrustumCheckInterval = prevMoving === 0 ? 10 : (sceneStaticnessPercent >= 95 ? 8 : 5);
2455
+ let movingCount = 0; // accumulated in the entity pass below
2456
+ // Adaptive budget control: react to sustained low FPS.
2457
+ const target = this.targetFps;
2458
+ const entityCount = this._entities.size;
2459
+ // Frequency scales with load: many entities → faster response (more aggressive for 140 FPS target)
2460
+ const adjustFreq = entityCount > 500 ? 6 : entityCount > 200 ? 10 : 20;
2461
+ // Knob magnitude also scales: more entities = more aggressive tightening
2462
+ const knobStep = entityCount > 500 ? 30 : 20;
2463
+ const midPxMax = entityCount > 500 ? 250 : 200;
2464
+
2465
+ // Closed-loop LOD-DISTANCE controller. Instead of clamping a global LOD
2466
+ // ceiling (which banged many entities across discrete tiers at once and
2467
+ // oscillated), the controller continuously scales _lodDistanceScale — a
2468
+ // multiplier on the per-entity screen-size used by _pickMeshLod. FPS below
2469
+ // target shrinks the scale so every model drops to its next-lower LOD a bit
2470
+ // sooner (as if slightly farther); FPS above target grows it so detail
2471
+ // extends out. Because it's a smooth continuous knob applied per entity by
2472
+ // distance, there's no tier stampede — each model crosses its own threshold
2473
+ // independently and gradually. The ceiling is left fixed (no FPS clamp).
2474
+ //
2475
+ // Stability: a dead-band around the target stops adjustment once FPS is
2476
+ // close, and the per-frame step is small + rate-limited so the scene eases
2477
+ // to a resting scale rather than hunting.
2478
+ if (this._lodDistanceScale == null) this._lodDistanceScale = 1;
2479
+ const lowBand = target - Math.max(6, target * 0.06);
2480
+ const highBand = target + Math.max(8, target * 0.10);
2481
+ const SCALE_MIN = 0.15, SCALE_MAX = 1.6;
2482
+ // Small proportional step per adjust tick, scaled by how far off we are.
2483
+ if (!this._lodAdjustCountdown) this._lodAdjustCountdown = 4;
2484
+ if (--this._lodAdjustCountdown <= 0) {
2485
+ this._lodAdjustCountdown = 4;
2486
+ if (this._fpsEma < lowBand && this._lodDistanceScale > SCALE_MIN) {
2487
+ // Below target → pull LODs nearer (cheaper). Step grows with the deficit.
2488
+ const deficit = Math.min(1, (lowBand - this._fpsEma) / Math.max(1, lowBand));
2489
+ this._lodDistanceScale = Math.max(SCALE_MIN, this._lodDistanceScale * (1 - 0.06 - 0.1 * deficit));
2490
+ this.emit('budget-adjust', { reason: 'fps-low-loddist', lodDistanceScale: this._lodDistanceScale, fps: this._fpsEma });
2491
+ } else if (this._fpsEma > highBand && this._lodDistanceScale < SCALE_MAX) {
2492
+ // Above target with headroom → push LODs out (more detail), gently.
2493
+ this._lodDistanceScale = Math.min(SCALE_MAX, this._lodDistanceScale * 1.04);
2494
+ this.emit('budget-adjust', { reason: 'fps-high-loddist', lodDistanceScale: this._lodDistanceScale, fps: this._fpsEma });
2495
+ }
2496
+ }
2497
+
2498
+ // Interval-based adjustments for midPx and heroCap (smooth, gradual changes).
2499
+ if (!this._fpsAdjustCountdown) this._fpsAdjustCountdown = adjustFreq;
2500
+ this._fpsAdjustCountdown--;
2501
+ if (this._fpsAdjustCountdown <= 0) {
2502
+ this._fpsAdjustCountdown = adjustFreq;
2503
+ if (this._fpsEma < target - 5) {
2504
+ // Sustained low FPS. Tighten midPx/heroCap with load-aware aggressiveness.
2505
+ let changed = false;
2506
+ if (this.midPx < midPxMax) {
2507
+ this.midPx = Math.min(midPxMax, this.midPx + knobStep);
2508
+ changed = true;
2509
+ } else if (this.heroCap > 5) {
2510
+ this.heroCap = Math.max(5, this.heroCap - 5);
2511
+ changed = true;
2512
+ }
2513
+ if (changed) this.emit('budget-adjust', {
2514
+ reason: 'fps-sustained-low', midPx: this.midPx, heroCap: this.heroCap, fps: this._fpsEma,
2515
+ });
2516
+ } else if (this._fpsEma > target + 5) {
2517
+ // Headroom — relax knobs in reverse priority.
2518
+ let changed = false;
2519
+ if (this.heroCap < 20) {
2520
+ this.heroCap = Math.min(20, this.heroCap + 5);
2521
+ changed = true;
2522
+ } else if (this.midPx > 50) {
2523
+ this.midPx = Math.max(50, this.midPx - 10);
2524
+ changed = true;
2525
+ }
2526
+ if (changed) this.emit('budget-adjust', {
2527
+ reason: 'fps-headroom', midPx: this.midPx, heroCap: this.heroCap, fps: this._fpsEma,
2528
+ });
2529
+ }
2530
+ }
2531
+ // Build frustum once per frame.
2532
+ const tFrustum0 = performance.now();
2533
+ this.camera.updateMatrixWorld();
2534
+ this._tmpMatrix.multiplyMatrices(this.camera.projectionMatrix, this.camera.matrixWorldInverse);
2535
+ this._frustum.setFromProjectionMatrix(this._tmpMatrix);
2536
+ // PHASE 2 OPTIMIZATION: Update cached frustum planes once per frame
2537
+ // This eliminates per-vertex plane extraction (16 instructions per vertex).
2538
+ if (this._frustumCache) {
2539
+ this._frustumCache.updatePlanes(this.camera);
2540
+ }
2541
+ // Publish projView to every InstancedSlot's shader uniform so the GPU
2542
+ // can run the per-instance frustum cull pass.
2543
+ // Cache tan(fov/2) once per frame; Entity._update reads this instead of
2544
+ // recomputing degToRad+tan per entity.
2545
+ this._fovTanHalf = Math.tan(THREE.MathUtils.degToRad(this.camera.fov) / 2);
2546
+ // Detect camera movement so static entities recompute screenPx/LOD when the
2547
+ // view changes (orbit, zoom, fly-through). Compare position + fov to last
2548
+ // frame with a small epsilon to avoid thrash when the camera is still.
2549
+ {
2550
+ const cp = this.camera.position;
2551
+ const lp = this._lastCamPos || (this._lastCamPos = { x: Infinity, y: 0, z: 0, fov: 0 });
2552
+ const moved = Math.abs(cp.x - lp.x) > 1e-3 || Math.abs(cp.y - lp.y) > 1e-3 || Math.abs(cp.z - lp.z) > 1e-3 || this.camera.fov !== lp.fov;
2553
+ this._cameraMoved = moved;
2554
+ if (moved) { lp.x = cp.x; lp.y = cp.y; lp.z = cp.z; lp.fov = this.camera.fov; }
2555
+ // Bump the LOD-evaluation epoch when the camera moved OR the global LOD
2556
+ // distance-scale shifted beyond a quantum since last frame. Entities only
2557
+ // re-pick their LOD when the epoch changes (see Entity._update), so a
2558
+ // still camera + stable scale produces zero per-frame LOD churn. Quantize
2559
+ // the scale comparison so the controller's tiny proportional steps don't
2560
+ // count as a change (that scale-hunting was the still-camera stutter).
2561
+ if (this._lodEpoch == null) { this._lodEpoch = 1; this._lastLodScale = this._lodDistanceScale; this._cameraMoved = true; }
2562
+ const scaleNow = this._lodDistanceScale || 1;
2563
+ const scaleChanged = Math.abs(scaleNow - (this._lastLodScale ?? scaleNow)) > 0.04;
2564
+ if (moved || scaleChanged) { this._lodEpoch++; this._lastLodScale = scaleNow; }
2565
+ }
2566
+ const vh = this.renderer.domElement.clientHeight;
2567
+ for (const slot of this._instancedSlots.values()) {
2568
+ // BatchedFarTier adapters have no projViewMatrix uniform — BatchedMesh
2569
+ // does its own transform + per-instance cull internally.
2570
+ if (slot._uniforms) slot._uniforms.projViewMatrix.value.copy(this._tmpMatrix);
2571
+ }
2572
+ const tFrustum1 = performance.now();
2573
+
2574
+ // VRAM-aware dynamic pool sizing: monitor GPU memory usage and adjust dynamically
2575
+ const estimatedVramBytes = this._estimatedVramMB * 1024 * 1024;
2576
+ this._vramRatioMonitor.currentRatio = estimatedVramBytes > 0 ? this._totalBytes / estimatedVramBytes : 0;
2577
+ this._vramRatioMonitor.peakRatio = Math.max(this._vramRatioMonitor.peakRatio, this._vramRatioMonitor.currentRatio);
2578
+
2579
+ // Decrement cooldown timer for dynamic VRAM adjustments
2580
+ if (this._vramRatioMonitor.adjustmentCooldown > 0) {
2581
+ this._vramRatioMonitor.adjustmentCooldown--;
2582
+ }
2583
+
2584
+ // Critical path: if VRAM ratio exceeds 70%, aggressively reduce LOD ceiling and entity count
2585
+ if (this._vramRatioMonitor.currentRatio > this._vramThresholdCritical) {
2586
+ if (this._vramRatioMonitor.adjustmentCooldown === 0) {
2587
+ // Reduce LOD ceiling to force more entities to FAR tier (lower quality, but GPU-instanced)
2588
+ const nextCeil = (this._currentCeilingLod ?? 5) - 1;
2589
+ if (nextCeil >= 0 && this._currentCeilingLod !== nextCeil) {
2590
+ this._currentCeilingLod = nextCeil;
2591
+ console.warn(`[VRAM] ratio ${(this._vramRatioMonitor.currentRatio * 100).toFixed(1)}% > ${(this._vramThresholdCritical * 100).toFixed(0)}% — reduced LOD ceiling to ${nextCeil}`);
2592
+ this.emit('vram-critical', {
2593
+ ratio: this._vramRatioMonitor.currentRatio,
2594
+ bytes: this._totalBytes,
2595
+ estimatedVramMB: this._estimatedVramMB,
2596
+ action: 'reduce-lod-ceiling',
2597
+ });
2598
+ }
2599
+ // Also aggressively increase midPx to push more entities to FAR tier
2600
+ if (this.midPx < 250) {
2601
+ const oldMidPx = this.midPx;
2602
+ this.midPx = Math.min(250, this.midPx + 20);
2603
+ console.warn(`[VRAM] increased midPx from ${oldMidPx} to ${this.midPx} to reduce VRAM pressure`);
2604
+ }
2605
+ this._vramRatioMonitor.adjustmentCooldown = 120; // ~2s at 60 FPS — long cooldown avoids ceiling oscillation
2606
+ this._vramRatioMonitor.lastAdjustmentRatio = this._vramRatioMonitor.currentRatio;
2607
+ }
2608
+ }
2609
+ // Warning path: if VRAM ratio exceeds 65%, start warming user
2610
+ else if (this._vramRatioMonitor.currentRatio > this._vramThresholdWarning && this._vramRatioMonitor.currentRatio <= this._vramThresholdCritical) {
2611
+ if (this._vramRatioMonitor.adjustmentCooldown === 0) {
2612
+ console.warn(`[VRAM] warning: ratio ${(this._vramRatioMonitor.currentRatio * 100).toFixed(1)}% — approaching critical threshold`);
2613
+ this.emit('vram-warning', {
2614
+ ratio: this._vramRatioMonitor.currentRatio,
2615
+ bytes: this._totalBytes,
2616
+ estimatedVramMB: this._estimatedVramMB,
2617
+ });
2618
+ this._vramRatioMonitor.adjustmentCooldown = 60;
2619
+ }
2620
+ }
2621
+ // Safe path: VRAM no longer AUTO-RELAXES the ceiling. This was the ~2s LOD-pop
2622
+ // root cause: lowering the ceiling pushed entities to the cheap FAR/instanced
2623
+ // tier, which dropped _totalBytes below the safe threshold; the safe path then
2624
+ // raised the ceiling back up, textured LODs reloaded, bytes climbed back over
2625
+ // critical, and the cycle repeated with the ~120-frame (~2s) cooldown setting
2626
+ // the period. The VRAM action fed straight back into its own trigger — a limit
2627
+ // cycle no cooldown can break. Fix: VRAM is a one-way RATCHET. It only clamps
2628
+ // the ceiling DOWN under genuine memory pressure; relaxation is owned solely by
2629
+ // the FPS controller above (which has proper sustained-headroom hysteresis and
2630
+ // will raise the ceiling when FPS proves there's room). The two controllers no
2631
+ // longer write the ceiling in opposite directions, so they can't tug-of-war.
2632
+
2633
+ // Reset budget tracking for this frame
2634
+ this._heroFrameTimeMs = 0;
2635
+ this._midFrameTimeMs = 0;
2636
+ let visible = 0;
2637
+
2638
+ // First pass: update every entity ONCE and, in the same walk:
2639
+ // - accumulate movingCount (staticness — was its own loop)
2640
+ // - mark unload-manager visibility (was its own loop after tiering)
2641
+ // - collect visible entities into REUSED parallel arrays (no per-frame
2642
+ // object-literal allocation — the old code pushed {entity,distance,...}
2643
+ // objects every frame, churning the GC in the hot path).
2644
+ // Parallel arrays grow once and are reused; only `_distCount` resets/frame.
2645
+ let ents = this._distEntities;
2646
+ let dists = this._distValues;
2647
+ let order = this._distOrder;
2648
+ const cap = this._entities.size;
2649
+ if (!ents || ents.length < cap) {
2650
+ ents = this._distEntities = new Array(cap);
2651
+ dists = this._distValues = new Float64Array(cap);
2652
+ order = this._distOrder = new Int32Array(cap);
2653
+ }
2654
+ let n = 0;
2655
+ const stream = this._enableDeferredStreaming;
2656
+ if (stream) this._lodUnloadManager.resetVisibility();
2657
+ for (const e of this._entities) {
2658
+ if (e._disposed) continue;
2659
+ if (e.root.matrixAutoUpdate || e._boundDirty) movingCount++;
2660
+ const result = e._update(this.camera, vh, dt, this._currentCeilingLod, this._frustum, this.animationThrottleDistance);
2661
+ const { distance, screenPx } = result;
2662
+ if (screenPx > 0) visible++;
2663
+ if (distance < Infinity) {
2664
+ ents[n] = e; dists[n] = distance; n++;
2665
+ }
2666
+ if (stream) {
2667
+ if (e.root.visible) this._lodUnloadManager.markVisible(e);
2668
+ else this._lodUnloadManager.markInvisible(e);
2669
+ }
2670
+ }
2671
+ this._distCount = n;
2672
+ this._lastFrameMovingCount = movingCount; // drives next frame's staticness interval
2673
+
2674
+ // Sort the index array by distance (closest first) — comparator reads the
2675
+ // typed dists[] rather than dereferencing wrapper objects. Tier allocation
2676
+ // is greedy-by-proximity, so closest-first order must be preserved.
2677
+ // SKIP the O(N log N) sort when nothing that affects ordering changed this
2678
+ // frame: camera still, no movers, same visible set. Distances are constant
2679
+ // for static entities under a still camera, so last frame's order holds.
2680
+ const sortOrder = order.subarray(0, n);
2681
+ const orderStable = !this._cameraMoved && movingCount === 0 && n === this._lastSortN;
2682
+ if (!orderStable) {
2683
+ for (let i = 0; i < n; i++) order[i] = i; // reset to identity, then sort
2684
+ sortOrder.sort((a, b) => dists[a] - dists[b]);
2685
+ this._lastSortN = n;
2686
+ }
2687
+
2688
+ // Second pass: allocate entities to tiers by distance + per-frame budgets.
2689
+ let hero = 0, mid = 0, far = 0;
2690
+ for (let k = 0; k < n; k++) {
2691
+ const idx = sortOrder[k];
2692
+ const entity = ents[idx];
2693
+ const distance = dists[idx];
2694
+ let assignedTier;
2695
+ if (distance < this._heroDist && this._heroFrameTimeMs < this._heroBudgetMs) {
2696
+ assignedTier = 'hero';
2697
+ this._heroFrameTimeMs += 0.15; // ~0.15ms per entity (animation + per-entity draw)
2698
+ hero++;
2699
+ } else if (distance < this._midDist && this._midFrameTimeMs < this._midBudgetMs) {
2700
+ assignedTier = 'mid';
2701
+ this._midFrameTimeMs += 0.08; // ~0.08ms per entity (decimated geometry, cheaper material)
2702
+ mid++;
2703
+ } else {
2704
+ assignedTier = 'far';
2705
+ far++;
2706
+ }
2707
+ entity._assignedTier = assignedTier;
2708
+ }
2709
+
2710
+ // Third pass: dynamic distance threshold rebalancing based on budget utilization
2711
+ // This ensures budgets stay within target range while allowing expansion when headroom exists
2712
+ const heroUtilization = this._heroBudgetMs > 0 ? this._heroFrameTimeMs / this._heroBudgetMs : 0;
2713
+ const midUtilization = this._midBudgetMs > 0 ? this._midFrameTimeMs / this._midBudgetMs : 0;
2714
+
2715
+ // HERO tier rebalancing
2716
+ if (heroUtilization > 1.1) {
2717
+ // Budget exceeded by >10%: aggressively shrink HERO distance to reduce HERO tier population
2718
+ this._heroDist = Math.max(5, this._heroDist - 2);
2719
+ } else if (heroUtilization < 0.5 && hero > 0) {
2720
+ // Budget underutilized (<50%): expand HERO distance to allow more entities in HERO tier
2721
+ this._heroDist = Math.min(50, this._heroDist + 1);
2722
+ }
2723
+
2724
+ // MID tier rebalancing
2725
+ if (midUtilization > 1.1) {
2726
+ // Budget exceeded by >10%: shrink MID distance to reduce MID tier population
2727
+ this._midDist = Math.max(20, this._midDist - 3);
2728
+ } else if (midUtilization < 0.5 && mid > 0) {
2729
+ // Budget underutilized (<50%): expand MID distance to allow more entities in MID tier
2730
+ this._midDist = Math.min(100, this._midDist + 2);
2731
+ }
2732
+ // Flush all dirty instance matrices to GPU once per frame.
2733
+ for (const slot of this._instancedSlots.values()) {
2734
+ slot.flushMatrixUpdates();
2735
+ }
2736
+ const tEntities1 = performance.now();
2737
+
2738
+ // Asset Streaming: visibility was already marked in the single entity pass
2739
+ // above (resetVisibility + markVisible/markInvisible). Here we only run the
2740
+ // periodic unload scan.
2741
+ if (this._enableDeferredStreaming) {
2742
+ // Periodically scan for unloads (every 5 frames to avoid overhead)
2743
+ if (!this._unloadScanCounter) this._unloadScanCounter = 5;
2744
+ this._unloadScanCounter--;
2745
+ if (this._unloadScanCounter <= 0) {
2746
+ this._lodUnloadManager.scanForUnload(this._assets, this._totalBytes);
2747
+ this._unloadScanCounter = 5;
2748
+ }
2749
+ }
2750
+
2751
+ // Update tier distribution stats. Raw integer counts every frame (cheap).
2752
+ this._stats.hero = hero;
2753
+ this._stats.mid = mid;
2754
+ this._stats.far = far;
2755
+ this._stats.heroBudgetTarget = this._heroBudgetMs;
2756
+ this._stats.midBudgetTarget = this._midBudgetMs;
2757
+
2758
+ // Cosmetic stats (toFixed/parseFloat conversions + the tierSummary template
2759
+ // literal) are HUD-only and don't need per-frame precision. Rebuild them at
2760
+ // most every 6 frames to keep ~8 string<->number conversions + a template
2761
+ // literal out of the per-frame hot path. Raw numeric fields above stay live.
2762
+ this._statsCosmeticCountdown = (this._statsCosmeticCountdown || 0) - 1;
2763
+ if (this._statsCosmeticCountdown <= 0) {
2764
+ this._statsCosmeticCountdown = 6;
2765
+ this._stats.heroBudgetMs = parseFloat(this._heroFrameTimeMs.toFixed(2));
2766
+ this._stats.midBudgetMs = parseFloat(this._midFrameTimeMs.toFixed(2));
2767
+ this._stats.heroDist = parseFloat(this._heroDist.toFixed(1));
2768
+ this._stats.midDist = parseFloat(this._midDist.toFixed(1));
2769
+ this._stats.tierSummary = `HERO: ${hero}/${this._heroCap} (${this._heroFrameTimeMs.toFixed(1)}ms), MID: ${mid} (${this._midFrameTimeMs.toFixed(1)}ms), FAR: ${far} (0ms)`;
2770
+ }
2771
+
2772
+ this._stats.msFrustum = tFrustum1 - tFrustum0;
2773
+ this._stats.msEntities = tEntities1 - tFrustum1;
2774
+ // Maintain byte budget. If still over budget after eviction (because
2775
+ // active LODs can't be evicted), tighten midPx so more entities drop
2776
+ // to the unskinned tier — that releases higher-LOD references and
2777
+ // unlocks eviction next sweep. Geometry shape is still preserved
2778
+ // because the unskinned tier is a real (decimated) mesh, not a sprite.
2779
+ const tBudget0 = performance.now();
2780
+ this._enforceBudget();
2781
+ const tBudget1 = performance.now();
2782
+ // Frame-budgeted, headroom-gated warm loading of wanted-but-unresident LODs
2783
+ // (network-lazy, GPU-eager, piecemeal) so LOD switches never pay a fetch /
2784
+ // decode / first-use upload on the switch frame.
2785
+ this._drainLodWarm();
2786
+ this._drainGpuWarm(); // at most one synchronous GPU upload per frame
2787
+ if (this._totalBytes > this.byteBudget && this.midPx < 200) {
2788
+ // Larger midPx → wider FAR catch (entities up to midPx screen-size).
2789
+ this.midPx = Math.min(200, this.midPx + 5);
2790
+ this.emit('budget-adjust', { reason: 'over-budget', midPx: this.midPx, bytes: this._totalBytes, budget: this.byteBudget });
2791
+ }
2792
+ // Additional stats tracking
2793
+ this._stats.fps = this._fpsEma;
2794
+ this._stats.entities = this._entities.size;
2795
+ this._stats.visible = visible;
2796
+ this._stats.drawCalls = this.renderer.info?.render?.calls ?? 0;
2797
+ this._stats.ceilingLod = this._currentCeilingLod;
2798
+ this._stats.bytes = this._totalBytes;
2799
+ this._stats.assets = this._assets.size;
2800
+ this._stats.inFlight = this._inFlight;
2801
+ this._stats.msBudget = tBudget1 - tBudget0;
2802
+ this._stats.msTotal = performance.now() - tUpdate0;
2803
+ this._stats.midPx = this.midPx;
2804
+ this._stats.heroCap = this.heroCap;
2805
+ // VRAM stats for HUD display
2806
+ this._stats.vram = {
2807
+ currentRatio: this._vramRatioMonitor.currentRatio,
2808
+ peakRatio: this._vramRatioMonitor.peakRatio,
2809
+ estimatedVramMB: this._estimatedVramMB,
2810
+ usedMB: this._totalBytes / (1024 * 1024),
2811
+ };
2812
+ this.emit('fps', this._stats);
2813
+ }
2814
+
2815
+ // Initialize multi-draw optimizer (called after batching is enabled)
2816
+ _initializeMultiDraw() {
2817
+ if (!this._enableMultiDraw || this._multiDrawOptimizer) return;
2818
+ try {
2819
+ this._multiDrawOptimizer = new MultiDrawOptimizer(this.renderer, { verbose: false });
2820
+ console.log('[pool] Multi-draw optimizer initialized:', this._multiDrawOptimizer.getStatusString());
2821
+ } catch (e) {
2822
+ console.warn('[pool] Failed to initialize multi-draw optimizer', e);
2823
+ this._multiDrawOptimizer = null;
2824
+ }
2825
+ }
2826
+
2827
+ // Public stats accessor (cheap, no allocation).
2828
+ getStats() {
2829
+ const stats = this._stats;
2830
+ // The four sub-stats (each allocates a fresh object) are HUD diagnostics that
2831
+ // change slowly; refresh them at most every 6th call instead of every call to
2832
+ // avoid per-call allocation churn. The base this._stats fields are live.
2833
+ if ((this._subStatsCountdown = (this._subStatsCountdown || 0) - 1) <= 0) {
2834
+ this._subStatsCountdown = 6;
2835
+ if (this._globalMaterialPool) stats.materialPooling = this._globalMaterialPool.getStats();
2836
+ if (this._deferredLoadQueue) stats.deferredLoading = this._deferredLoadQueue.getStats();
2837
+ if (this._lodUnloadManager) stats.unloadManager = this._lodUnloadManager.getStats();
2838
+ if (this._multiDrawOptimizer) stats.multiDraw = this._multiDrawOptimizer.getStats();
2839
+ }
2840
+ return stats;
2841
+ }
2842
+
2843
+ // --- Byte tracking + budget ---------------------------------------------
2844
+ _trackBytes(assetUrl, url, bytes) {
2845
+ this._totalBytes += bytes;
2846
+ let log = this._byteLog.get(assetUrl);
2847
+ if (!log) { log = new Map(); this._byteLog.set(assetUrl, log); }
2848
+ log.set(url, bytes);
2849
+ }
2850
+ _untrackBytes(assetUrl, url) {
2851
+ const log = this._byteLog.get(assetUrl);
2852
+ if (!log) return;
2853
+ const b = log.get(url) || 0;
2854
+ this._totalBytes -= b;
2855
+ log.delete(url);
2856
+ }
2857
+ _enforceBudget() {
2858
+ if (this._totalBytes <= this.byteBudget) return;
2859
+ // Find evict candidates: LODs no current entity is using AND not inline.
2860
+ // Walk every asset's cached non-inline geo/tex; drop any whose key isn't
2861
+ // currently active on any entity.
2862
+ const inUse = new Set();
2863
+ for (const e of this._entities) {
2864
+ for (const tm of e.trackedMeshes) {
2865
+ const d = e.asset.meshLodDescs[tm.meshDescIdx];
2866
+ if (d) inUse.add(`${e.asset.url}|${d.meshIndex}:${d.primIndex}:${tm.currentLod}`);
2867
+ for (let ti = 0; ti < tm.texState.length; ti++) {
2868
+ const td = e.asset.texLodDescs[ti];
2869
+ if (td) inUse.add(`${e.asset.url}|tex:${td.textureIndex}:${tm.texState[ti].currentLod}`);
2870
+ }
2871
+ }
2872
+ }
2873
+ let evicted = 0;
2874
+ for (const asset of this._assets.values()) {
2875
+ for (const desc of asset.meshLodDescs) {
2876
+ for (let li = 0; li < desc.lods.length; li++) {
2877
+ if (desc.lods[li].inline) continue;
2878
+ const key = `${asset.url}|${desc.meshIndex}:${desc.primIndex}:${li}`;
2879
+ if (!inUse.has(key)) {
2880
+ if (asset.evictMeshLod(asset.meshLodDescs.indexOf(desc), li)) {
2881
+ this._totalBytes -= (desc.lods[li].bytes || 0);
2882
+ evicted++;
2883
+ }
2884
+ }
2885
+ }
2886
+ }
2887
+ for (const desc of asset.texLodDescs) {
2888
+ for (let li = 0; li < desc.lods.length; li++) {
2889
+ if (desc.lods[li].inline) continue;
2890
+ const key = `${asset.url}|tex:${desc.textureIndex}:${li}`;
2891
+ if (!inUse.has(key)) {
2892
+ if (asset.evictTexLod(asset.texLodDescs.indexOf(desc), li)) {
2893
+ this._totalBytes -= (desc.lods[li].bytes || 0);
2894
+ evicted++;
2895
+ }
2896
+ }
2897
+ }
2898
+ }
2899
+ if (this._totalBytes <= this.byteBudget) break;
2900
+ }
2901
+ if (evicted) this.emit('budget-pressure', { evicted, total: this._totalBytes, budget: this.byteBudget });
2902
+
2903
+ // Dynamic budget adjustment: if memory ratio is unsafe, reduce budget; if safe for sustained duration, increase.
2904
+ const ratio = this._totalBytes / (this._estimatedVramMB * 1024 * 1024);
2905
+ if (ratio > 0.7) {
2906
+ // Memory pressure: reduce budget by 10% to stay safe.
2907
+ this.byteBudget = Math.floor(this.byteBudget * 0.9);
2908
+ this._budgetAdjustmentCooldown = 60; // prevent oscillation (60 frames = ~1 second at 60 FPS)
2909
+ this.emit('budget-warning', { ratio, newBudget: this.byteBudget, estimatedVramMB: this._estimatedVramMB });
2910
+ } else if (ratio < 0.4 && this._budgetAdjustmentCooldown === 0) {
2911
+ // Low utilization: accumulate frames with safe margin.
2912
+ this._budgetLowFrames++;
2913
+ if (this._budgetLowFrames >= 10) {
2914
+ // 10 consecutive frames below 40% utilization: safe to increase budget by 5%.
2915
+ const safeByteBudget = Math.floor((this._estimatedVramMB * 0.65) * 1024 * 1024);
2916
+ if (this.byteBudget < safeByteBudget) {
2917
+ this.byteBudget = Math.min(this.byteBudget * 1.05, safeByteBudget);
2918
+ this._budgetAdjustmentCooldown = 60;
2919
+ this._budgetLowFrames = 0;
2920
+ this.emit('budget-relaxed', { ratio, newBudget: this.byteBudget });
2921
+ }
2922
+ }
2923
+ } else {
2924
+ // Ratio back in safe zone: reset low-frame counter.
2925
+ this._budgetLowFrames = 0;
2926
+ }
2927
+
2928
+ // Decrement cooldown timer.
2929
+ if (this._budgetAdjustmentCooldown > 0) {
2930
+ this._budgetAdjustmentCooldown--;
2931
+ }
2932
+ }
2933
+
2934
+ // --- Bounded concurrent fetch queue --------------------------------------
2935
+ _enqueue(key, run) {
2936
+ const existing = this._loadQueue.get(key);
2937
+ if (existing) return existing;
2938
+ const p = new Promise((resolve, reject) => {
2939
+ const task = { key, run, resolve, reject };
2940
+ if (this._inFlight < this.maxConcurrentFetches) this._runTask(task);
2941
+ else this._pending.push(task);
2942
+ });
2943
+ this._loadQueue.set(key, p);
2944
+ p.finally(() => this._loadQueue.delete(key));
2945
+ return p;
2946
+ }
2947
+ async _runTask(task) {
2948
+ this._inFlight++;
2949
+ try {
2950
+ const r = await task.run();
2951
+ task.resolve(r);
2952
+ } catch (e) {
2953
+ task.reject(e);
2954
+ } finally {
2955
+ this._inFlight--;
2956
+ if (this._pending.length && this._inFlight < this.maxConcurrentFetches) {
2957
+ this._runTask(this._pending.shift());
2958
+ }
2959
+ }
2960
+ }
2961
+ }