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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/examples/local-progressive/batched-far-tier.js +296 -0
- package/examples/local-progressive/buffer-pool.js +182 -0
- package/examples/local-progressive/deferred-load-queue.js +253 -0
- package/examples/local-progressive/draw-call-batching.js +615 -0
- package/examples/local-progressive/draw-call-sorter.js +146 -0
- package/examples/local-progressive/frustum-cache.js +104 -0
- package/examples/local-progressive/lod-unload-manager.js +162 -0
- package/examples/local-progressive/lod-worker.js +297 -0
- package/examples/local-progressive/material-pool.js +241 -0
- package/examples/local-progressive/model-pool.js +2961 -0
- package/examples/local-progressive/multi-draw-optimizer.js +347 -0
- package/examples/local-progressive/multi-draw-utils.js +199 -0
- package/examples/local-progressive/stress.js +655 -0
- package/examples/local-progressive/vertex-compression.js +128 -0
- package/index.js +23 -0
- package/package.json +48 -0
- package/tools/bake-all.mjs +126 -0
- package/tools/bake-progressive.mjs +663 -0
- package/tools/bake-streaming.mjs +453 -0
|
@@ -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
|
+
}
|