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,655 @@
|
|
|
1
|
+
// Stress demo for ModelPool tier system. Spawns unique-asset entities across
|
|
2
|
+
// a wide grid, runs orbiting camera, shows live perf + tier counts.
|
|
3
|
+
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import { ModelPool } from './model-pool.js';
|
|
6
|
+
import { enableDrawCallBatching } from './draw-call-batching.js';
|
|
7
|
+
|
|
8
|
+
// Asset source. By default the baked models are loaded CROSS-ORIGIN from the
|
|
9
|
+
// public assets host (its own GitHub Pages site) so this repo ships code only —
|
|
10
|
+
// no model bytes, no LFS. Override with ?assets=<baseUrl>, or ?assets=local to
|
|
11
|
+
// use the dev server's generated /assets-list.json + relative output_* paths
|
|
12
|
+
// (npm run demo:local). ASSET_DIRS ends up holding FULLY-RESOLVED .glb URLs.
|
|
13
|
+
const _assetsParam = new URLSearchParams(location.search).get('assets');
|
|
14
|
+
const ASSET_HOST_DEFAULT = 'https://anentrypoint.github.io/assets/';
|
|
15
|
+
const ASSET_BASE = (!_assetsParam || _assetsParam === 'remote')
|
|
16
|
+
? ASSET_HOST_DEFAULT
|
|
17
|
+
: (_assetsParam === 'local' ? null : (_assetsParam.endsWith('/') ? _assetsParam : _assetsParam + '/'));
|
|
18
|
+
|
|
19
|
+
let ASSET_DIRS = []; // fully-resolved model.progressive.glb URLs
|
|
20
|
+
const ASSET_DIRS_READY = (ASSET_BASE === null
|
|
21
|
+
// LOCAL DEV: dynamic /assets-list.json from serve.mjs -> relative glb paths.
|
|
22
|
+
? fetch('/assets-list.json').then((r) => r.json())
|
|
23
|
+
.then((list) => list.map((dir) => `${dir}/model.progressive.glb`))
|
|
24
|
+
// REMOTE: the assets host's manifest.baked.json (category -> [{baked,...}]).
|
|
25
|
+
// Flatten and resolve each `baked` (streaming/output_xxx/model.progressive.glb)
|
|
26
|
+
// against ASSET_BASE so models stream cross-origin.
|
|
27
|
+
: fetch(`${ASSET_BASE}manifest.baked.json`).then((r) => r.json())
|
|
28
|
+
.then((manifest) => Object.values(manifest).flat()
|
|
29
|
+
.map((e) => e && e.baked).filter(Boolean)
|
|
30
|
+
.map((baked) => ASSET_BASE + baked)))
|
|
31
|
+
.then((urls) => { ASSET_DIRS = urls; console.log(`[stress] ${urls.length} assets discovered (${ASSET_BASE || 'local'})`); return urls; })
|
|
32
|
+
.catch((e) => { console.error('[stress] asset list fetch failed', e); ASSET_DIRS = []; });
|
|
33
|
+
|
|
34
|
+
const canvas = document.getElementById('c');
|
|
35
|
+
const hud = document.getElementById('hud');
|
|
36
|
+
|
|
37
|
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
|
|
38
|
+
renderer.setPixelRatio(1);
|
|
39
|
+
// Opaque-only scene: skip THREE's per-frame transparency depth-sort of the
|
|
40
|
+
// render list. Also stop auto-resetting renderer.info every render (we read it
|
|
41
|
+
// from the HUD; reset manually once per frame in tick()).
|
|
42
|
+
renderer.sortObjects = false;
|
|
43
|
+
renderer.info.autoReset = false;
|
|
44
|
+
const scene = new THREE.Scene();
|
|
45
|
+
// All scene roots here are static (entities self-manage their matrices via the
|
|
46
|
+
// pool); disable the per-frame matrix-world traversal recompute on the scene
|
|
47
|
+
// and camera roots.
|
|
48
|
+
scene.matrixAutoUpdate = false;
|
|
49
|
+
scene.background = new THREE.Color(0x181820);
|
|
50
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x222233, 1.0));
|
|
51
|
+
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
52
|
+
dir.position.set(20, 30, 20);
|
|
53
|
+
scene.add(dir);
|
|
54
|
+
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
|
|
55
|
+
camera.position.set(30, 18, 30);
|
|
56
|
+
camera.lookAt(0, 1, 0);
|
|
57
|
+
|
|
58
|
+
function resize() {
|
|
59
|
+
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
60
|
+
renderer.setSize(w, h, false);
|
|
61
|
+
camera.aspect = w / h;
|
|
62
|
+
camera.updateProjectionMatrix();
|
|
63
|
+
}
|
|
64
|
+
window.addEventListener('resize', resize);
|
|
65
|
+
resize();
|
|
66
|
+
|
|
67
|
+
const pool = new ModelPool({
|
|
68
|
+
scene, renderer, camera,
|
|
69
|
+
targetFps: 60,
|
|
70
|
+
byteBudget: 256 * 1024 * 1024,
|
|
71
|
+
maxConcurrentFetches: 32, // Increased from 6 to maximize asset loading throughput
|
|
72
|
+
// FAR tier via one shared THREE.BatchedMesh — collapses all distinct far-asset
|
|
73
|
+
// draws into ~6 (was 742). After far decimation the scene is draw-call-bound,
|
|
74
|
+
// so this is now a real win: measured 68-70 FPS median (vs 63 baseline) at 500
|
|
75
|
+
// distinct, max peaks 140+ (vs ~92). Renders correctly — an earlier "renders
|
|
76
|
+
// off-screen" reading was a coverage-metric artifact at a far camera (498 tiny
|
|
77
|
+
// dots = ~2% coverage with OR without batching; identical coverage confirmed
|
|
78
|
+
// at matched cameras, and HIGHER close-up: 0.53 vs 0.47).
|
|
79
|
+
useBatchedFarTier: true,
|
|
80
|
+
});
|
|
81
|
+
window.__pool = pool;
|
|
82
|
+
|
|
83
|
+
// Draw-call batching (per-asset InstancedBatch) is superseded by the BatchedMesh
|
|
84
|
+
// FAR tier when that's on; only enable the old path otherwise.
|
|
85
|
+
if (!pool._useBatchedFarTier) enableDrawCallBatching(pool);
|
|
86
|
+
|
|
87
|
+
const proxies = new Set();
|
|
88
|
+
|
|
89
|
+
async function spawnUnique(n) {
|
|
90
|
+
await ASSET_DIRS_READY;
|
|
91
|
+
if (!ASSET_DIRS.length) {
|
|
92
|
+
console.error('[stress] no assets available to spawn');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Distribute entities across a square grid; each entity picks an asset
|
|
96
|
+
// from the full ASSET_DIRS list (modulo), so up to len(ASSET_DIRS) of
|
|
97
|
+
// them are unique.
|
|
98
|
+
const side = Math.ceil(Math.sqrt(n));
|
|
99
|
+
const spacing = 1.5;
|
|
100
|
+
let count = 0;
|
|
101
|
+
const batchSize = 10;
|
|
102
|
+
|
|
103
|
+
async function spawnBatch() {
|
|
104
|
+
let batchCount = 0;
|
|
105
|
+
for (let row = 0; row < side && count < n; row++) {
|
|
106
|
+
for (let col = 0; col < side && count < n; col++) {
|
|
107
|
+
const x = (col - side / 2) * spacing;
|
|
108
|
+
const z = (row - side / 2) * spacing;
|
|
109
|
+
const assetUrl = ASSET_DIRS[count % ASSET_DIRS.length];
|
|
110
|
+
const proxy = pool.spawn(assetUrl, {
|
|
111
|
+
position: [x, 0, z],
|
|
112
|
+
rotation: [0, (count * 0.137) % (Math.PI * 2), 0],
|
|
113
|
+
static: true,
|
|
114
|
+
});
|
|
115
|
+
scene.add(proxy.root);
|
|
116
|
+
proxies.add(proxy);
|
|
117
|
+
count++;
|
|
118
|
+
batchCount++;
|
|
119
|
+
|
|
120
|
+
// Yield to browser after every batchSize entities
|
|
121
|
+
if (batchCount >= batchSize) {
|
|
122
|
+
batchCount = 0;
|
|
123
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await spawnBatch();
|
|
130
|
+
console.log(`[stress] spawned ${count} entities`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
document.querySelectorAll('#panel button[data-n]').forEach((btn) => {
|
|
134
|
+
btn.addEventListener('click', () => spawnUnique(+btn.dataset.n));
|
|
135
|
+
});
|
|
136
|
+
// Spawn every distinct model exactly once. Because spawnUnique picks
|
|
137
|
+
// ASSET_DIRS[count % len], spawning exactly len entities yields one of each
|
|
138
|
+
// distinct asset. Raise the byte budget first so the resident set can hold the
|
|
139
|
+
// full variety instead of evicting most of it (which would hide models).
|
|
140
|
+
async function spawnAll() {
|
|
141
|
+
await ASSET_DIRS_READY;
|
|
142
|
+
const n = ASSET_DIRS.length;
|
|
143
|
+
if (!n) { console.error('[stress] no assets to spawn'); return 0; }
|
|
144
|
+
// ~3MB resident headroom per distinct model at low LOD; clamp to a sane max.
|
|
145
|
+
const wantBudgetMB = Math.min(4096, Math.max(256, Math.ceil(n * 3)));
|
|
146
|
+
pool.byteBudget = wantBudgetMB * 1024 * 1024;
|
|
147
|
+
const bb = document.getElementById('byte-budget');
|
|
148
|
+
if (bb) bb.value = wantBudgetMB;
|
|
149
|
+
// The LOD unload manager has its OWN budget (default 200MB) and evicts LODs
|
|
150
|
+
// above ~0.85*budget. 954 distinct models exceed 200MB, so without raising it
|
|
151
|
+
// the manager removes models shortly after they spawn ("added then removed,
|
|
152
|
+
// leaving a small group"). Raise it to match the byte budget. (Deliberately
|
|
153
|
+
// do NOT disable deferred streaming — that prevents geometry from loading.)
|
|
154
|
+
if (pool._lodUnloadManager) {
|
|
155
|
+
pool._lodUnloadManager.vramBudgetMB = wantBudgetMB;
|
|
156
|
+
pool._lodUnloadManager.vramBudgetBytes = wantBudgetMB * 1024 * 1024;
|
|
157
|
+
}
|
|
158
|
+
console.log(`[stress] spawning ALL ${n} distinct models, byteBudget=${wantBudgetMB}MB (unload budget raised)`);
|
|
159
|
+
await spawnUnique(n);
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
162
|
+
document.getElementById('spawn-all').addEventListener('click', spawnAll);
|
|
163
|
+
|
|
164
|
+
// --- Debug surface -------------------------------------------------------
|
|
165
|
+
// window.__debug gives one-call visibility into every part of the pipeline so
|
|
166
|
+
// the blank-models / LOD-vs-camera / mass-removal issues are directly
|
|
167
|
+
// observable from a single page.evaluate() call (no source spelunking needed).
|
|
168
|
+
function _entityResolved(e) {
|
|
169
|
+
// An entity is "resolved" (should draw something) if any tracked mesh has a
|
|
170
|
+
// real instanced slot OR an own mesh with non-empty geometry.
|
|
171
|
+
if (!e || e._disposed) return false;
|
|
172
|
+
for (const tm of e.trackedMeshes || []) {
|
|
173
|
+
if (tm._instancedSlot && tm._instancedSlotIdx >= 0) return true;
|
|
174
|
+
const g = tm.mesh && tm.mesh.geometry;
|
|
175
|
+
if (tm.mesh && tm.mesh.visible !== false && g && g.attributes && g.attributes.position && g.attributes.position.count > 0) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
window.THREE = THREE; // expose for debug/perf probes (BatchedMesh tests etc.)
|
|
180
|
+
window.__debug = {
|
|
181
|
+
pool, scene, camera, renderer, proxies, THREE,
|
|
182
|
+
spawnAll, clear: () => { for (const p of proxies) p.dispose(); proxies.clear(); },
|
|
183
|
+
spawn: (n) => spawnUnique(n),
|
|
184
|
+
setCamera(x, y, z, tx = 0, ty = 0, tz = 0) {
|
|
185
|
+
camera.position.set(x, y, z); camera.lookAt(tx, ty, tz); camera.updateMatrixWorld();
|
|
186
|
+
return { pos: [x, y, z], target: [tx, ty, tz] };
|
|
187
|
+
},
|
|
188
|
+
// currentLod histogram across all live entities' tracked meshes.
|
|
189
|
+
lodHistogram() {
|
|
190
|
+
const h = {};
|
|
191
|
+
for (const e of pool._entities) for (const tm of e.trackedMeshes || []) {
|
|
192
|
+
const k = tm._instancedSlot ? `lod${tm.currentLod}(inst)` : `lod${tm.currentLod}`;
|
|
193
|
+
h[k] = (h[k] || 0) + 1;
|
|
194
|
+
}
|
|
195
|
+
return h;
|
|
196
|
+
},
|
|
197
|
+
tierBreakdown() {
|
|
198
|
+
let hero = 0, mid = 0, far = 0, none = 0;
|
|
199
|
+
for (const e of pool._entities) {
|
|
200
|
+
const t = e._assignedTier;
|
|
201
|
+
if (t === 'hero') hero++; else if (t === 'mid') mid++; else if (t === 'far') far++; else none++;
|
|
202
|
+
}
|
|
203
|
+
return { hero, mid, far, unassigned: none };
|
|
204
|
+
},
|
|
205
|
+
// Entities the renderer thinks are visible but that have nothing to draw —
|
|
206
|
+
// the white/disappearing models.
|
|
207
|
+
blankEntities() {
|
|
208
|
+
const blanks = [];
|
|
209
|
+
for (const e of pool._entities) {
|
|
210
|
+
if (e._disposed) continue;
|
|
211
|
+
if (e.root.visible && !_entityResolved(e)) {
|
|
212
|
+
blanks.push({ id: e.id, url: e.asset && e.asset.url, lod: (e.trackedMeshes[0] || {}).currentLod, dist: +(e._currentDistance || 0).toFixed(1) });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { count: blanks.length, sample: blanks.slice(0, 10) };
|
|
216
|
+
},
|
|
217
|
+
assetLoadState() {
|
|
218
|
+
let cachedGeo = 0, assets = 0;
|
|
219
|
+
for (const a of pool._assets.values()) { assets++; cachedGeo += (a.geoCache ? a.geoCache.size : 0); }
|
|
220
|
+
return { assets, cachedGeometries: cachedGeo, deferredQueue: pool._deferredLoadQueue ? pool._deferredLoadQueue.getStats() : null };
|
|
221
|
+
},
|
|
222
|
+
snapshot() {
|
|
223
|
+
const s = pool.getStats();
|
|
224
|
+
let resolved = 0, blank = 0, totalEntities = 0;
|
|
225
|
+
for (const e of pool._entities) {
|
|
226
|
+
if (e._disposed) continue; totalEntities++;
|
|
227
|
+
if (_entityResolved(e)) resolved++; else if (e.root.visible) blank++;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
entities: totalEntities, distinctAssets: s.assets, visible: s.visible,
|
|
231
|
+
resolved, blank, hero: s.hero, mid: s.mid, far: s.far,
|
|
232
|
+
drawCalls: s.drawCalls, fps: Math.round(s.fps),
|
|
233
|
+
ceilingLod: pool._currentCeilingLod, midPx: +pool.midPx.toFixed(0),
|
|
234
|
+
totalMB: +(pool._totalBytes / 1048576).toFixed(0), budgetMB: +(pool.byteBudget / 1048576).toFixed(0),
|
|
235
|
+
estVramMB: pool._estimatedVramMB,
|
|
236
|
+
vramRatio: pool._vramRatioMonitor ? +pool._vramRatioMonitor.currentRatio.toFixed(2) : null,
|
|
237
|
+
camPos: [camera.position.x, camera.position.y, camera.position.z].map((v) => +v.toFixed(1)),
|
|
238
|
+
lodHistogram: this.lodHistogram(),
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
// Toggle the global material pool (FAR vertex-color grouping) on/off live.
|
|
242
|
+
materialPool(on) { pool._globalMaterialPool._useGlobalMaterialPool = !!on; return on; },
|
|
243
|
+
// Inspect one entity in detail: per-trackedMesh LOD, material kind, whether it
|
|
244
|
+
// has a texture map, its color-attr range, and the mesh's world rotation —
|
|
245
|
+
// used to chase "some LODs change orientation / lose color".
|
|
246
|
+
inspect(i = 0) {
|
|
247
|
+
const ents = [...pool._entities].filter((e) => !e._disposed);
|
|
248
|
+
const e = ents[i]; if (!e) return { err: 'no entity ' + i, total: ents.length };
|
|
249
|
+
const tms = (e.trackedMeshes || []).map((tm) => {
|
|
250
|
+
const m = tm.mesh && tm.mesh.material;
|
|
251
|
+
const g = tm.mesh && tm.mesh.geometry;
|
|
252
|
+
const col = g && g.attributes && g.attributes.color;
|
|
253
|
+
let colMax = null;
|
|
254
|
+
if (col) { const a = col.array; let mx = 0; for (let j = 0; j < Math.min(a.length, 900); j++) if (a[j] > mx) mx = a[j]; colMax = +mx.toFixed(2); }
|
|
255
|
+
const q = tm.mesh ? tm.mesh.getWorldQuaternion(new THREE.Quaternion()) : null;
|
|
256
|
+
return {
|
|
257
|
+
currentLod: tm.currentLod, instanced: !!tm._instancedSlot,
|
|
258
|
+
matType: m && m.type, hasMap: !!(m && m.map), vertexColors: !!(m && m.vertexColors),
|
|
259
|
+
colorAttr: col ? { itemSize: col.itemSize, normalized: col.normalized, max: colMax } : null,
|
|
260
|
+
worldQuat: q ? [q.x, q.y, q.z, q.w].map((v) => +v.toFixed(3)) : null,
|
|
261
|
+
meshVisible: tm.mesh ? tm.mesh.visible : null,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
return { id: e.id, url: e.asset && e.asset.url, rootRot: [e.root.rotation.x, e.root.rotation.y, e.root.rotation.z].map((v) => +v.toFixed(3)), dist: +(e._currentDistance || 0).toFixed(1), trackedMeshes: tms };
|
|
265
|
+
},
|
|
266
|
+
// Pin/unpin the LOD ceiling so a single LOD level can be inspected in isolation
|
|
267
|
+
// (lets us see whether a SPECIFIC lod changes orientation/loses color).
|
|
268
|
+
pinLod(n) { pool._currentCeilingLod = n; pool.ceilingLod = n; return n; },
|
|
269
|
+
unpinLod() { pool._currentCeilingLod = null; return null; },
|
|
270
|
+
// Compare every cached LOD geometry of the asset behind entity i: bbox center
|
|
271
|
+
// + half-extents + sign of (v - bboxCenter) for the same vertex index. If two
|
|
272
|
+
// LODs disagree on the SIGN of an axis they are mirrored relative to each
|
|
273
|
+
// other -> the on-screen orientation flip. Loads all sibling LODs first.
|
|
274
|
+
async compareLods(i = 0) {
|
|
275
|
+
const ents = [...pool._entities].filter((e) => !e._disposed);
|
|
276
|
+
const e = ents[i]; if (!e) return { err: 'no entity ' + i };
|
|
277
|
+
const asset = e.asset;
|
|
278
|
+
const out = [];
|
|
279
|
+
for (let md = 0; md < asset.meshLodDescs.length; md++) {
|
|
280
|
+
const desc = asset.meshLodDescs[md];
|
|
281
|
+
for (let li = 0; li < desc.lods.length; li++) {
|
|
282
|
+
let geo = null;
|
|
283
|
+
try { geo = await asset.ensureMeshLod(md, li); } catch (err) { out.push({ md, li, err: String(err) }); continue; }
|
|
284
|
+
if (!geo) { out.push({ md, li, inline: !!desc.lods[li].inline, geo: null }); continue; }
|
|
285
|
+
if (!geo.boundingBox) geo.computeBoundingBox();
|
|
286
|
+
const bb = geo.boundingBox;
|
|
287
|
+
const c = bb.getCenter(new THREE.Vector3());
|
|
288
|
+
const sz = bb.getSize(new THREE.Vector3());
|
|
289
|
+
const p = geo.attributes.position;
|
|
290
|
+
// sign pattern of first 4 verts relative to bbox center (orientation fingerprint)
|
|
291
|
+
const sig = [];
|
|
292
|
+
for (let k = 0; k < Math.min(4, p.count); k++) {
|
|
293
|
+
sig.push([Math.sign(+(p.getX(k) - c.x).toFixed(4)), Math.sign(+(p.getY(k) - c.y).toFixed(4)), Math.sign(+(p.getZ(k) - c.z).toFixed(4))]);
|
|
294
|
+
}
|
|
295
|
+
out.push({
|
|
296
|
+
md, li, inline: !!desc.lods[li].inline, kind: desc.lods[li].kind,
|
|
297
|
+
center: [c.x, c.y, c.z].map((v) => +v.toFixed(3)),
|
|
298
|
+
size: [sz.x, sz.y, sz.z].map((v) => +v.toFixed(3)),
|
|
299
|
+
v0: [p.getX(0), p.getY(0), p.getZ(0)].map((v) => +v.toFixed(3)),
|
|
300
|
+
sig,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { url: asset.url, lods: out };
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
console.log('[stress] window.__debug ready — snapshot() inspect(i) pinLod(n) materialPool(bool) setCamera(...)');
|
|
308
|
+
document.getElementById('clear').addEventListener('click', () => {
|
|
309
|
+
for (const p of proxies) p.dispose();
|
|
310
|
+
proxies.clear();
|
|
311
|
+
});
|
|
312
|
+
document.getElementById('target-fps').addEventListener('change', (e) => {
|
|
313
|
+
pool.targetFps = +e.target.value;
|
|
314
|
+
});
|
|
315
|
+
document.getElementById('byte-budget').addEventListener('change', (e) => {
|
|
316
|
+
pool.byteBudget = +e.target.value * 1024 * 1024;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Phase 5: Interactive knob controls
|
|
320
|
+
// 3-LOD system: slider maps [0, 1, 2] to [null, 2, 4] (representing LODs [0, 2, 4])
|
|
321
|
+
// Value 0 = no ceiling (null), Value 1 = ceiling LOD 2, Value 2 = ceiling LOD 4
|
|
322
|
+
document.getElementById('ceiling-lod').addEventListener('input', (e) => {
|
|
323
|
+
const sliderVal = +e.target.value;
|
|
324
|
+
// Map slider value to actual LOD ceiling in 3-LOD system
|
|
325
|
+
const lodMap = [null, 2, 4]; // slider 0 -> null (no ceiling), 1 -> LOD 2, 2 -> LOD 4
|
|
326
|
+
pool.ceilingLod = lodMap[sliderVal];
|
|
327
|
+
|
|
328
|
+
// Display LOD label instead of numeric value
|
|
329
|
+
const lodLabels = ['unlimited', 'LOD 0/2', 'LOD 0'];
|
|
330
|
+
document.getElementById('ceiling-value').textContent = lodLabels[sliderVal];
|
|
331
|
+
});
|
|
332
|
+
document.getElementById('mid-px').addEventListener('input', (e) => {
|
|
333
|
+
const val = +e.target.value;
|
|
334
|
+
pool.midPx = val;
|
|
335
|
+
document.getElementById('mid-px-value').textContent = val;
|
|
336
|
+
});
|
|
337
|
+
document.getElementById('hero-cap').addEventListener('input', (e) => {
|
|
338
|
+
const val = +e.target.value;
|
|
339
|
+
pool.heroCap = val;
|
|
340
|
+
document.getElementById('hero-cap-value').textContent = val;
|
|
341
|
+
});
|
|
342
|
+
document.getElementById('frustum-interval').addEventListener('input', (e) => {
|
|
343
|
+
const val = +e.target.value;
|
|
344
|
+
if (val === 0) {
|
|
345
|
+
pool.frustumCheckInterval = 0; // Enable automatic dynamic calculation
|
|
346
|
+
document.getElementById('frustum-interval-value').textContent = 'auto';
|
|
347
|
+
} else {
|
|
348
|
+
// Force fixed interval for testing (1-10 frames)
|
|
349
|
+
pool._frustumCheckInterval = val;
|
|
350
|
+
pool._dynamicFrustumCheckInterval = val;
|
|
351
|
+
document.getElementById('frustum-interval-value').textContent = val;
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Feature toggles
|
|
356
|
+
document.getElementById('frustum-cull').addEventListener('change', (e) => {
|
|
357
|
+
pool._enableFrustumCulling = e.target.checked;
|
|
358
|
+
});
|
|
359
|
+
document.getElementById('texture-lod').addEventListener('change', (e) => {
|
|
360
|
+
pool._enableTextureLod = e.target.checked;
|
|
361
|
+
});
|
|
362
|
+
document.getElementById('anim-throttle').addEventListener('change', (e) => {
|
|
363
|
+
pool._enableAnimThrottle = e.target.checked;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Material Grouping Optimization toggle
|
|
367
|
+
const materialPoolToggle = document.getElementById('material-pool');
|
|
368
|
+
if (materialPoolToggle) {
|
|
369
|
+
materialPoolToggle.addEventListener('change', (e) => {
|
|
370
|
+
pool._globalMaterialPool._useGlobalMaterialPool = e.target.checked;
|
|
371
|
+
console.log('[Material Pool]', e.target.checked ? 'enabled' : 'disabled');
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Asset Streaming: Deferred loading toggle
|
|
376
|
+
const deferredStreamingToggle = document.getElementById('deferred-streaming');
|
|
377
|
+
if (deferredStreamingToggle) {
|
|
378
|
+
deferredStreamingToggle.addEventListener('change', (e) => {
|
|
379
|
+
pool._enableDeferredStreaming = e.target.checked;
|
|
380
|
+
console.log('[Deferred Streaming]', e.target.checked ? 'enabled' : 'disabled');
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Multi-draw optimization toggle
|
|
385
|
+
const multiDrawToggle = document.getElementById('multi-draw');
|
|
386
|
+
if (multiDrawToggle) {
|
|
387
|
+
multiDrawToggle.addEventListener('change', (e) => {
|
|
388
|
+
pool._enableMultiDraw = e.target.checked;
|
|
389
|
+
console.log('[Multi-Draw]', e.target.checked ? 'enabled' : 'disabled');
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Frame-time breakdown chart
|
|
394
|
+
const frameHistory = [];
|
|
395
|
+
const maxFrameHistory = 60;
|
|
396
|
+
const frameCanvas = document.getElementById('frame-canvas');
|
|
397
|
+
const ctx = frameCanvas.getContext('2d');
|
|
398
|
+
let recordingTrace = false;
|
|
399
|
+
let traceData = [];
|
|
400
|
+
let traceStartTime = 0;
|
|
401
|
+
|
|
402
|
+
function drawFrameChart() {
|
|
403
|
+
const w = frameCanvas.width;
|
|
404
|
+
const h = frameCanvas.height;
|
|
405
|
+
const barW = Math.max(2, Math.floor(w / maxFrameHistory));
|
|
406
|
+
const padding = 2;
|
|
407
|
+
|
|
408
|
+
ctx.fillStyle = '#1a1a20';
|
|
409
|
+
ctx.fillRect(0, 0, w, h);
|
|
410
|
+
|
|
411
|
+
// Draw grid
|
|
412
|
+
ctx.strokeStyle = '#333';
|
|
413
|
+
ctx.lineWidth = 1;
|
|
414
|
+
ctx.beginPath();
|
|
415
|
+
ctx.moveTo(0, h * 0.5);
|
|
416
|
+
ctx.lineTo(w, h * 0.5);
|
|
417
|
+
ctx.stroke();
|
|
418
|
+
|
|
419
|
+
// Draw frame bars
|
|
420
|
+
let maxMs = 16.7;
|
|
421
|
+
for (const frame of frameHistory) {
|
|
422
|
+
maxMs = Math.max(maxMs, frame.total);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (let i = 0; i < frameHistory.length; i++) {
|
|
426
|
+
const frame = frameHistory[i];
|
|
427
|
+
const x = i * (barW + padding);
|
|
428
|
+
const scale = h / maxMs;
|
|
429
|
+
|
|
430
|
+
let y = h;
|
|
431
|
+
// Frustum time (red)
|
|
432
|
+
const frustumH = frame.frustum * scale;
|
|
433
|
+
ctx.fillStyle = '#ff6b6b';
|
|
434
|
+
ctx.fillRect(x, y - frustumH, barW, frustumH);
|
|
435
|
+
y -= frustumH;
|
|
436
|
+
|
|
437
|
+
// Entities time (yellow)
|
|
438
|
+
const entitiesH = frame.entities * scale;
|
|
439
|
+
ctx.fillStyle = '#ffd93d';
|
|
440
|
+
ctx.fillRect(x, y - entitiesH, barW, entitiesH);
|
|
441
|
+
y -= entitiesH;
|
|
442
|
+
|
|
443
|
+
// Budget time (green)
|
|
444
|
+
const budgetH = frame.budget * scale;
|
|
445
|
+
ctx.fillStyle = '#6bcf7f';
|
|
446
|
+
ctx.fillRect(x, y - budgetH, barW, budgetH);
|
|
447
|
+
|
|
448
|
+
// Over-budget indicator
|
|
449
|
+
if (frame.total > 16.7) {
|
|
450
|
+
ctx.fillStyle = '#ff3333';
|
|
451
|
+
ctx.fillRect(x, 0, barW, 2);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Labels
|
|
456
|
+
ctx.fillStyle = '#999';
|
|
457
|
+
ctx.font = '10px sans-serif';
|
|
458
|
+
ctx.fillText(`${maxMs.toFixed(1)}ms`, 2, 10);
|
|
459
|
+
ctx.fillText('16.7ms', 2, h - 5);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
document.getElementById('export-btn').addEventListener('click', () => {
|
|
463
|
+
if (recordingTrace) {
|
|
464
|
+
// Stop recording and download
|
|
465
|
+
recordingTrace = false;
|
|
466
|
+
const csv = ['timestamp,fps,frustum,entities,budget,total,ceiling,midPx,heroCap,memory,visible'];
|
|
467
|
+
for (const row of traceData) {
|
|
468
|
+
csv.push(Object.values(row).join(','));
|
|
469
|
+
}
|
|
470
|
+
const blob = new Blob([csv.join('\n')], { type: 'text/csv' });
|
|
471
|
+
const url = URL.createObjectURL(blob);
|
|
472
|
+
const a = document.createElement('a');
|
|
473
|
+
a.href = url;
|
|
474
|
+
a.download = `profile-${new Date().toISOString().slice(0, 19)}.csv`;
|
|
475
|
+
a.click();
|
|
476
|
+
URL.revokeObjectURL(url);
|
|
477
|
+
document.getElementById('export-btn').textContent = 'export trace (30s)';
|
|
478
|
+
document.getElementById('export-btn').style.background = '#445';
|
|
479
|
+
} else {
|
|
480
|
+
// Start recording
|
|
481
|
+
recordingTrace = true;
|
|
482
|
+
traceData = [];
|
|
483
|
+
traceStartTime = performance.now();
|
|
484
|
+
document.getElementById('export-btn').textContent = 'stop & export (REC)';
|
|
485
|
+
document.getElementById('export-btn').style.background = '#ff5555';
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Retarget ~3% of entities per frame to a fresh random nearby position over a
|
|
490
|
+
// 600-1400ms ease. Touching only a sparse subset each frame is the whole point:
|
|
491
|
+
// CPU work is O(subset) while ALL in-flight entities keep lerping on the GPU/CPU
|
|
492
|
+
// active set inside the pool. Demonstrates "fully capable of position updates".
|
|
493
|
+
let _moverScratch = [];
|
|
494
|
+
function _driveMovers() {
|
|
495
|
+
const ents = _moverScratch;
|
|
496
|
+
ents.length = 0;
|
|
497
|
+
for (const e of pool._entities) { if (!e._disposed) ents.push(e); }
|
|
498
|
+
if (!ents.length) return;
|
|
499
|
+
const n = Math.max(1, Math.round(ents.length * 0.03));
|
|
500
|
+
for (let i = 0; i < n; i++) {
|
|
501
|
+
const e = ents[(Math.random() * ents.length) | 0];
|
|
502
|
+
const base = e.root.position;
|
|
503
|
+
const tx = base.x + (Math.random() - 0.5) * 4;
|
|
504
|
+
const ty = Math.max(0, base.y + (Math.random() - 0.5) * 1.5);
|
|
505
|
+
const tz = base.z + (Math.random() - 0.5) * 4;
|
|
506
|
+
pool.setTarget(e, tx, ty, tz, 600 + Math.random() * 800);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let orbitT = 0;
|
|
511
|
+
let zoomPhase = 0;
|
|
512
|
+
let _prevCamX = Infinity, _prevCamY = 0, _prevCamZ = 0;
|
|
513
|
+
let _poolUpdateCounter = 0;
|
|
514
|
+
function tick() {
|
|
515
|
+
if (document.getElementById('orbit-cam').checked) {
|
|
516
|
+
orbitT += 0.003;
|
|
517
|
+
// Fly-through path: when zoom-cycle is on we pulse from far (r=60) to
|
|
518
|
+
// very close (r=3, INSIDE the crowd) so all three tiers get exercised.
|
|
519
|
+
// The HERO tier needs r<10 to see entities at >200 screen-px.
|
|
520
|
+
let r = 30;
|
|
521
|
+
if (document.getElementById('zoom-cycle').checked) {
|
|
522
|
+
zoomPhase += 0.008;
|
|
523
|
+
// 3..60 — sweep through the crowd, getting up close mid-cycle.
|
|
524
|
+
r = 30 + Math.cos(zoomPhase) * 27;
|
|
525
|
+
}
|
|
526
|
+
camera.position.x = Math.cos(orbitT) * r;
|
|
527
|
+
camera.position.z = Math.sin(orbitT) * r;
|
|
528
|
+
camera.position.y = 6 + Math.sin(orbitT * 0.7) * 4;
|
|
529
|
+
camera.lookAt(0, 1, 0);
|
|
530
|
+
}
|
|
531
|
+
// Throttle pool.update() (LOD/tier/frustum reevaluation) to every 3rd frame
|
|
532
|
+
// for a static scene — but ALWAYS run it the frame the camera moved so LOD
|
|
533
|
+
// still reacts to the view. renderer.render() stays every frame; instance
|
|
534
|
+
// matrices persist between updates, so nothing visually freezes beyond a LOD
|
|
535
|
+
// reevaluation cadence of ~20Hz.
|
|
536
|
+
const cp = camera.position;
|
|
537
|
+
const camMoved = Math.abs(cp.x - _prevCamX) > 1e-3 || Math.abs(cp.y - _prevCamY) > 1e-3 || Math.abs(cp.z - _prevCamZ) > 1e-3;
|
|
538
|
+
_prevCamX = cp.x; _prevCamY = cp.y; _prevCamZ = cp.z;
|
|
539
|
+
// Position-update demo: each frame retarget a SMALL random subset of entities
|
|
540
|
+
// (proving O(updated) CPU cost) while the pool lerps every active mover on its
|
|
541
|
+
// side. The interpolation makes them drift smoothly to new targets.
|
|
542
|
+
const moversOn = document.getElementById('movers') && document.getElementById('movers').checked;
|
|
543
|
+
if (moversOn) _driveMovers();
|
|
544
|
+
// Movers (or a moving camera) need update() every frame for smooth motion;
|
|
545
|
+
// a fully static scene can keep the 3rd-frame throttle.
|
|
546
|
+
if (camMoved || moversOn || (_poolUpdateCounter++ % 3) === 0) {
|
|
547
|
+
pool.update();
|
|
548
|
+
}
|
|
549
|
+
renderer.render(scene, camera);
|
|
550
|
+
// renderer.info.autoReset is off; reset once per frame after rendering so the
|
|
551
|
+
// HUD's draw-call/triangle counts reflect exactly one frame.
|
|
552
|
+
renderer.info.reset();
|
|
553
|
+
// HUD + frame-chart are diagnostics only — rebuilding the big innerHTML string
|
|
554
|
+
// and redrawing the chart every frame causes layout/paint churn. Throttle to
|
|
555
|
+
// ~10Hz (every 6th frame); rendering itself stays at full rate.
|
|
556
|
+
if (!window.__hudCounter) window.__hudCounter = 0;
|
|
557
|
+
if (window.__hudCounter++ < 6) { requestAnimationFrame(tick); return; }
|
|
558
|
+
window.__hudCounter = 0;
|
|
559
|
+
const s = pool.getStats();
|
|
560
|
+
const memoryMB = s.bytes / 1024 / 1024;
|
|
561
|
+
const estimatedVramMB = pool._estimatedVramMB;
|
|
562
|
+
const memoryRatio = (s.bytes / (estimatedVramMB * 1024 * 1024)) * 100;
|
|
563
|
+
// Color coding: green <50%, yellow 50-70%, red >70%
|
|
564
|
+
const memoryColor = memoryRatio > 70 ? '#ff6b6b' : memoryRatio > 50 ? '#ffd93d' : '#6bcf7f';
|
|
565
|
+
const memoryStatus = memoryRatio > 70 ? 'CRITICAL' : memoryRatio > 50 ? 'WARNING' : 'SAFE';
|
|
566
|
+
// VRAM gauge: visual representation
|
|
567
|
+
const gaugeWidth = 150;
|
|
568
|
+
const gaugeFillWidth = Math.min(gaugeWidth, Math.max(0, (memoryRatio / 100) * gaugeWidth));
|
|
569
|
+
const gaugeHTML = `<div style="display:inline-block;width:${gaugeWidth}px;height:12px;border:1px solid #666;background:#222;position:relative;vertical-align:middle;margin:0 4px;">
|
|
570
|
+
<div style="width:${gaugeFillWidth}px;height:100%;background:${memoryColor};transition:width 0.2s;"></div>
|
|
571
|
+
<div style="position:absolute;left:5px;top:0;color:#aaa;font-size:9px;line-height:12px;z-index:10;">${memoryRatio.toFixed(0)}%</div>
|
|
572
|
+
</div>`;
|
|
573
|
+
|
|
574
|
+
// Track frame metrics for chart
|
|
575
|
+
const frameData = {
|
|
576
|
+
frustum: s.msFrustum || 0,
|
|
577
|
+
entities: s.msEntities || 0,
|
|
578
|
+
budget: s.msBudget || 0,
|
|
579
|
+
total: (s.msTotal || 0),
|
|
580
|
+
};
|
|
581
|
+
frameHistory.push(frameData);
|
|
582
|
+
if (frameHistory.length > maxFrameHistory) frameHistory.shift();
|
|
583
|
+
drawFrameChart();
|
|
584
|
+
|
|
585
|
+
// Record profiling data if tracing
|
|
586
|
+
if (recordingTrace) {
|
|
587
|
+
const elapsed = (performance.now() - traceStartTime) / 1000;
|
|
588
|
+
if (elapsed < 30) {
|
|
589
|
+
traceData.push({
|
|
590
|
+
timestamp: elapsed.toFixed(2),
|
|
591
|
+
fps: s.fps.toFixed(1),
|
|
592
|
+
frustum: (s.msFrustum || 0).toFixed(3),
|
|
593
|
+
entities: (s.msEntities || 0).toFixed(3),
|
|
594
|
+
budget: (s.msBudget || 0).toFixed(3),
|
|
595
|
+
total: (s.msTotal || 0).toFixed(3),
|
|
596
|
+
ceiling: pool.ceilingLod ?? 'null',
|
|
597
|
+
midPx: pool.midPx.toFixed(0),
|
|
598
|
+
heroCap: pool.heroCap,
|
|
599
|
+
memory: memoryRatio.toFixed(1),
|
|
600
|
+
visible: s.entities,
|
|
601
|
+
});
|
|
602
|
+
} else {
|
|
603
|
+
recordingTrace = false;
|
|
604
|
+
const csv = ['timestamp,fps,frustum,entities,budget,total,ceiling,midPx,heroCap,memory,visible'];
|
|
605
|
+
for (const row of traceData) {
|
|
606
|
+
csv.push(Object.values(row).join(','));
|
|
607
|
+
}
|
|
608
|
+
const blob = new Blob([csv.join('\n')], { type: 'text/csv' });
|
|
609
|
+
const url = URL.createObjectURL(blob);
|
|
610
|
+
const a = document.createElement('a');
|
|
611
|
+
a.href = url;
|
|
612
|
+
a.download = `profile-${new Date().toISOString().slice(0, 19)}.csv`;
|
|
613
|
+
a.click();
|
|
614
|
+
URL.revokeObjectURL(url);
|
|
615
|
+
document.getElementById('export-btn').textContent = 'export trace (30s)';
|
|
616
|
+
document.getElementById('export-btn').style.background = '#445';
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Asset Streaming stats
|
|
621
|
+
let deferredStats = '';
|
|
622
|
+
if (pool._enableDeferredStreaming && s.deferredLoading) {
|
|
623
|
+
const dl = s.deferredLoading;
|
|
624
|
+
deferredStats = `<b>Deferred</b> queued ${dl.queued} inFlight ${dl.inFlight} loaded ${dl.totalLoaded} (${dl.avgLoadTimeMs}ms avg)<br>`;
|
|
625
|
+
}
|
|
626
|
+
if (pool._enableDeferredStreaming && s.unloadManager) {
|
|
627
|
+
const um = s.unloadManager;
|
|
628
|
+
deferredStats += `<b>Unload</b> visible ${um.visibleEntities} invisible ${um.invisibleEntities} VRAM ${um.estimatedVramMB}/${um.vramBudgetMB}MB<br>`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Multi-draw status
|
|
632
|
+
let multiDrawStatus = '';
|
|
633
|
+
if (pool._multiDrawOptimizer) {
|
|
634
|
+
const md = s.multiDraw;
|
|
635
|
+
const mdMethod = md.method === 'ANGLE_multi_draw' ? 'ANGLE' : md.method === 'OES_draw_elements_base_vertex' ? 'BaseVtx' : 'fallback';
|
|
636
|
+
multiDrawStatus = `<b>multi-draw</b> ${mdMethod} reduced ${md.drawCallsReduced||0} calls<br>`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
hud.innerHTML = `
|
|
640
|
+
<b>FPS</b> ${s.fps.toFixed(1)} (target ${pool.targetFps})<br>
|
|
641
|
+
<b>entities</b> ${s.entities} <span class="tier">HERO ${s.hero||0} MID ${s.mid||0} FAR ${s.far||0}</span><br>
|
|
642
|
+
<b>HERO budget</b> ${s.heroBudgetMs||0}ms/${pool._heroBudgetMs.toFixed(1)}ms <b>HERO dist</b> ${s.heroDist||0}m<br>
|
|
643
|
+
<b>MID budget</b> ${s.midBudgetMs||0}ms/${pool._midBudgetMs.toFixed(1)}ms <b>MID dist</b> ${s.midDist||0}m<br>
|
|
644
|
+
${deferredStats}
|
|
645
|
+
${multiDrawStatus}
|
|
646
|
+
<b>draws</b> ${s.drawCalls} <b>ceiling</b> ${s.ceilingLod ?? 'none'} (3-LOD: 0/2/4) <b>midPx</b> ${pool.midPx.toFixed(0)} <b>heroCap</b> ${pool.heroCap}<br>
|
|
647
|
+
<b>VRAM</b> ${gaugeHTML} <span style="color:${memoryColor}"><b>${memoryStatus}</b> ${memoryMB.toFixed(1)}/${estimatedVramMB.toFixed(0)} MB (${memoryRatio.toFixed(0)}%)</span><br>
|
|
648
|
+
<b>assets</b> ${s.assets} <b>inFlight</b> ${s.inFlight}<br>
|
|
649
|
+
<b>tri</b> ${(renderer.info.render.triangles/1000).toFixed(1)}k<br>
|
|
650
|
+
<b>frustum interval</b> ${pool._dynamicFrustumCheckInterval} frames (${pool._lastFrameMovingCount} moving entities)<br>
|
|
651
|
+
<b>pool.update</b> ${(s.msTotal||0).toFixed(2)}ms (frustum ${(s.msFrustum||0).toFixed(2)} entities ${(s.msEntities||0).toFixed(2)} budget ${(s.msBudget||0).toFixed(2)})
|
|
652
|
+
`;
|
|
653
|
+
requestAnimationFrame(tick);
|
|
654
|
+
}
|
|
655
|
+
tick();
|