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,297 @@
|
|
|
1
|
+
// Web Worker: fetches a sibling LOD GLB, parses it with GLTFLoader +
|
|
2
|
+
// MeshoptDecoder, decodes any meshopt quantization, runs the same
|
|
3
|
+
// _bakeQuantizeDecode logic the main-thread path used, and posts back
|
|
4
|
+
// transferable typed arrays. Main thread rebuilds the BufferGeometry from
|
|
5
|
+
// the payload — that step is O(slot allocations), no heavy work.
|
|
6
|
+
//
|
|
7
|
+
// The worker is a MODULE worker (`type: 'module'`) so we can `import`
|
|
8
|
+
// three.js + GLTFLoader from the same CDN versions the page uses.
|
|
9
|
+
//
|
|
10
|
+
// NOTE: static top-level `import` from cross-origin CDN URLs in a module
|
|
11
|
+
// worker silently fails in some Chromium versions (the error event arrives
|
|
12
|
+
// with `message: ''` and no `error` object — completely undiagnosable from
|
|
13
|
+
// the parent page). We side-step that by doing DYNAMIC `import()` inside a
|
|
14
|
+
// try/catch so we can post the real error back to the main thread before
|
|
15
|
+
// the worker dies.
|
|
16
|
+
|
|
17
|
+
let THREE = null;
|
|
18
|
+
let GLTFLoader = null;
|
|
19
|
+
let MeshoptDecoder = null;
|
|
20
|
+
let loader = null;
|
|
21
|
+
let readyResolve;
|
|
22
|
+
const readyPromise = new Promise((r) => { readyResolve = r; });
|
|
23
|
+
|
|
24
|
+
(async () => {
|
|
25
|
+
try {
|
|
26
|
+
// Use esm.sh which rewrites the bare specifier `three` (used inside
|
|
27
|
+
// GLTFLoader / meshopt_decoder) into a real URL — module workers do
|
|
28
|
+
// NOT inherit the page's <script type="importmap">, so vanilla
|
|
29
|
+
// cdn.jsdelivr.net URLs fail with "Failed to resolve module specifier 'three'".
|
|
30
|
+
// The ?deps pin keeps every import on the same three.js version so we
|
|
31
|
+
// don't end up with two THREE.* runtimes in the worker.
|
|
32
|
+
const threeMod = await import('https://esm.sh/three@0.170.0');
|
|
33
|
+
THREE = threeMod;
|
|
34
|
+
const gltfMod = await import('https://esm.sh/three@0.170.0/examples/jsm/loaders/GLTFLoader.js?deps=three@0.170.0');
|
|
35
|
+
GLTFLoader = gltfMod.GLTFLoader;
|
|
36
|
+
const meshoptMod = await import('https://esm.sh/three@0.170.0/examples/jsm/libs/meshopt_decoder.module.js?deps=three@0.170.0');
|
|
37
|
+
MeshoptDecoder = meshoptMod.MeshoptDecoder;
|
|
38
|
+
loader = new GLTFLoader();
|
|
39
|
+
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
40
|
+
readyResolve(true);
|
|
41
|
+
self.postMessage({ id: 0, ok: true, ready: true });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
self.postMessage({ id: 0, ok: false, ready: true, error: 'worker init: ' + String(e && (e.stack || e.message || e)) });
|
|
44
|
+
readyResolve(false);
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
|
|
48
|
+
self.addEventListener('error', (e) => {
|
|
49
|
+
try {
|
|
50
|
+
self.postMessage({ id: 0, ok: false, ready: true, error: 'worker self.error: ' + (e.message || '') + ' @ ' + (e.filename || '') + ':' + (e.lineno || '') });
|
|
51
|
+
} catch {}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function _bakeQuantizeDecode(geo, matrix, decodeAABB) {
|
|
55
|
+
// AABB-remap is preferred — it dequantizes into mesh-LOCAL space, matching
|
|
56
|
+
// the inline (baseline) LOD's coordinate convention. Baking matrixWorld in
|
|
57
|
+
// would double-transform when the receiving mesh applies its own world
|
|
58
|
+
// matrix at render time. Matrix path remains for legacy/no-AABB bakes.
|
|
59
|
+
const m = matrix;
|
|
60
|
+
const isIdentity = !decodeAABB && (
|
|
61
|
+
m.elements[0] === 1 && m.elements[5] === 1 && m.elements[10] === 1 &&
|
|
62
|
+
m.elements[12] === 0 && m.elements[13] === 0 && m.elements[14] === 0 &&
|
|
63
|
+
m.elements[1] === 0 && m.elements[2] === 0 && m.elements[4] === 0 &&
|
|
64
|
+
m.elements[6] === 0 && m.elements[8] === 0 && m.elements[9] === 0
|
|
65
|
+
);
|
|
66
|
+
if (!decodeAABB && !isIdentity) {
|
|
67
|
+
for (const semKey of ['position', 'normal', 'tangent']) {
|
|
68
|
+
const a = geo.attributes[semKey];
|
|
69
|
+
if (!a) continue;
|
|
70
|
+
const out = new Float32Array(a.count * a.itemSize);
|
|
71
|
+
for (let i = 0; i < a.count; i++) {
|
|
72
|
+
if (a.itemSize >= 1) out[i * a.itemSize + 0] = a.getX(i);
|
|
73
|
+
if (a.itemSize >= 2) out[i * a.itemSize + 1] = a.getY(i);
|
|
74
|
+
if (a.itemSize >= 3) out[i * a.itemSize + 2] = a.getZ(i);
|
|
75
|
+
if (a.itemSize >= 4) out[i * a.itemSize + 3] = a.getW(i);
|
|
76
|
+
}
|
|
77
|
+
geo.setAttribute(semKey, new THREE.BufferAttribute(out, a.itemSize, false));
|
|
78
|
+
}
|
|
79
|
+
geo.applyMatrix4(m);
|
|
80
|
+
} else if (decodeAABB) {
|
|
81
|
+
const { min, max } = decodeAABB;
|
|
82
|
+
const pos = geo.attributes.position;
|
|
83
|
+
if (pos) {
|
|
84
|
+
let smnX = Infinity, smxX = -Infinity;
|
|
85
|
+
let smnY = Infinity, smxY = -Infinity;
|
|
86
|
+
let smnZ = Infinity, smxZ = -Infinity;
|
|
87
|
+
for (let i = 0; i < pos.count; i++) {
|
|
88
|
+
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
|
|
89
|
+
if (x < smnX) smnX = x; if (x > smxX) smxX = x;
|
|
90
|
+
if (y < smnY) smnY = y; if (y > smxY) smxY = y;
|
|
91
|
+
if (z < smnZ) smnZ = z; if (z > smxZ) smxZ = z;
|
|
92
|
+
}
|
|
93
|
+
const r = (a, b) => (b - a < 1e-9 ? 1 : b - a);
|
|
94
|
+
const sx = (max[0] - min[0]) / r(smnX, smxX);
|
|
95
|
+
const sy = (max[1] - min[1]) / r(smnY, smxY);
|
|
96
|
+
const sz = (max[2] - min[2]) / r(smnZ, smxZ);
|
|
97
|
+
const out = new Float32Array(pos.count * 3);
|
|
98
|
+
for (let i = 0; i < pos.count; i++) {
|
|
99
|
+
out[i * 3 + 0] = (pos.getX(i) - smnX) * sx + min[0];
|
|
100
|
+
out[i * 3 + 1] = (pos.getY(i) - smnY) * sy + min[1];
|
|
101
|
+
out[i * 3 + 2] = (pos.getZ(i) - smnZ) * sz + min[2];
|
|
102
|
+
}
|
|
103
|
+
geo.setAttribute('position', new THREE.BufferAttribute(out, 3, false));
|
|
104
|
+
for (const semKey of ['normal', 'tangent']) {
|
|
105
|
+
const a = geo.attributes[semKey];
|
|
106
|
+
if (!a) continue;
|
|
107
|
+
const o = new Float32Array(a.count * a.itemSize);
|
|
108
|
+
for (let i = 0; i < a.count; i++) {
|
|
109
|
+
if (a.itemSize >= 1) o[i * a.itemSize + 0] = a.getX(i);
|
|
110
|
+
if (a.itemSize >= 2) o[i * a.itemSize + 1] = a.getY(i);
|
|
111
|
+
if (a.itemSize >= 3) o[i * a.itemSize + 2] = a.getZ(i);
|
|
112
|
+
if (a.itemSize >= 4) o[i * a.itemSize + 3] = a.getW(i);
|
|
113
|
+
}
|
|
114
|
+
geo.setAttribute(semKey, new THREE.BufferAttribute(o, a.itemSize, false));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
geo.computeBoundingSphere();
|
|
119
|
+
geo.computeBoundingBox();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Dependency-free far-LOD decimation by spatial-grid vertex clustering.
|
|
123
|
+
// Snaps positions to a grid of `res` cells per axis, keeps one representative
|
|
124
|
+
// vertex per occupied cell, remaps triangles, drops degenerate (collapsed)
|
|
125
|
+
// triangles. Increases `res` until the triangle count is at/under `triCap`
|
|
126
|
+
// (or a max resolution is hit). Rewrites geo.index in place and rebuilds a
|
|
127
|
+
// compact position/normal/color set referencing only kept vertices. Coarse but
|
|
128
|
+
// perfectly adequate for a distant instanced dot, and needs no wasm/library.
|
|
129
|
+
function _clusterDecimate(geo, triCap) {
|
|
130
|
+
const pos = geo.attributes.position;
|
|
131
|
+
if (!pos) return;
|
|
132
|
+
// Non-indexed geometry: synthesize a sequential index so it still decimates
|
|
133
|
+
// (some far LODs arrive non-indexed; the old !ix guard left them full-res).
|
|
134
|
+
let ix = geo.index;
|
|
135
|
+
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 }; }
|
|
136
|
+
const triCount = ix.count / 3;
|
|
137
|
+
if (triCount <= triCap) return;
|
|
138
|
+
const idx = ix.array;
|
|
139
|
+
const px = pos.array, pStride = pos.itemSize;
|
|
140
|
+
// bbox
|
|
141
|
+
let mnx = Infinity, mny = Infinity, mnz = Infinity, mxx = -Infinity, mxy = -Infinity, mxz = -Infinity;
|
|
142
|
+
for (let i = 0; i < pos.count; i++) {
|
|
143
|
+
const x = px[i * pStride], y = px[i * pStride + 1], z = px[i * pStride + 2];
|
|
144
|
+
if (x < mnx) mnx = x; if (x > mxx) mxx = x;
|
|
145
|
+
if (y < mny) mny = y; if (y > mxy) mxy = y;
|
|
146
|
+
if (z < mnz) mnz = z; if (z > mxz) mxz = z;
|
|
147
|
+
}
|
|
148
|
+
const sx = (mxx - mnx) || 1, sy = (mxy - mny) || 1, sz = (mxz - mnz) || 1;
|
|
149
|
+
const nrm = geo.attributes.normal, col = geo.attributes.color;
|
|
150
|
+
// Try increasing grid resolutions until triangle count <= cap.
|
|
151
|
+
for (let res = 6; res <= 64; res *= 2) {
|
|
152
|
+
const cellOf = new Int32Array(pos.count); // vertex -> kept-vertex index
|
|
153
|
+
const cellMap = new Map(); // gridKey -> kept index
|
|
154
|
+
let kept = 0;
|
|
155
|
+
for (let i = 0; i < pos.count; i++) {
|
|
156
|
+
const gx = Math.min(res - 1, ((px[i * pStride] - mnx) / sx * res) | 0);
|
|
157
|
+
const gy = Math.min(res - 1, ((px[i * pStride + 1] - mny) / sy * res) | 0);
|
|
158
|
+
const gz = Math.min(res - 1, ((px[i * pStride + 2] - mnz) / sz * res) | 0);
|
|
159
|
+
const key = (gx * res + gy) * res + gz;
|
|
160
|
+
let rep = cellMap.get(key);
|
|
161
|
+
if (rep === undefined) { rep = kept++; cellMap.set(key, rep); }
|
|
162
|
+
cellOf[i] = rep;
|
|
163
|
+
}
|
|
164
|
+
// Build remapped index, drop degenerates, count tris.
|
|
165
|
+
const out = [];
|
|
166
|
+
for (let t = 0; t < idx.length; t += 3) {
|
|
167
|
+
const a = cellOf[idx[t]], b = cellOf[idx[t + 1]], c = cellOf[idx[t + 2]];
|
|
168
|
+
if (a !== b && b !== c && a !== c) { out.push(a, b, c); }
|
|
169
|
+
}
|
|
170
|
+
const outTris = out.length / 3;
|
|
171
|
+
if (outTris <= triCap || res === 64) {
|
|
172
|
+
if (outTris < 1) return; // never produce empty geometry
|
|
173
|
+
// Gather one source vertex per kept cell (first seen).
|
|
174
|
+
const srcOf = new Int32Array(kept).fill(-1);
|
|
175
|
+
for (let i = 0; i < pos.count; i++) { const r = cellOf[i]; if (srcOf[r] === -1) srcOf[r] = i; }
|
|
176
|
+
const newPos = new Float32Array(kept * 3);
|
|
177
|
+
const ct = col ? col.itemSize : 0;
|
|
178
|
+
const newNrm = nrm ? new Float32Array(kept * 3) : null;
|
|
179
|
+
const newCol = col ? new Float32Array(kept * ct) : null;
|
|
180
|
+
for (let r = 0; r < kept; r++) {
|
|
181
|
+
const s = srcOf[r];
|
|
182
|
+
newPos[r * 3] = pos.getX(s); newPos[r * 3 + 1] = pos.getY(s); newPos[r * 3 + 2] = pos.getZ(s);
|
|
183
|
+
// Use the BufferAttribute getters so NORMALIZED source attrs (normals
|
|
184
|
+
// are Int8-normalized, colors may be Uint8-normalized) are denormalized
|
|
185
|
+
// to plain 0..1/-1..1 floats — a raw .array copy left 0..255 values that
|
|
186
|
+
// rendered far models WHITE (washed-out vColor).
|
|
187
|
+
if (newNrm) { newNrm[r * 3] = nrm.getX(s); newNrm[r * 3 + 1] = nrm.getY(s); newNrm[r * 3 + 2] = nrm.getZ(s); }
|
|
188
|
+
if (newCol) {
|
|
189
|
+
newCol[r * ct] = col.getX(s);
|
|
190
|
+
if (ct >= 2) newCol[r * ct + 1] = col.getY(s);
|
|
191
|
+
if (ct >= 3) newCol[r * ct + 2] = col.getZ(s);
|
|
192
|
+
if (ct >= 4) newCol[r * ct + 3] = col.getW(s);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
geo.setAttribute('position', new THREE.BufferAttribute(newPos, 3, false));
|
|
196
|
+
if (newNrm) geo.setAttribute('normal', new THREE.BufferAttribute(newNrm, 3, false));
|
|
197
|
+
if (newCol) geo.setAttribute('color', new THREE.BufferAttribute(newCol, ct, false));
|
|
198
|
+
geo.setIndex(new THREE.BufferAttribute(kept > 65535 ? new Uint32Array(out) : new Uint16Array(out), 1));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Extract attributes from a geometry into a serializable payload with
|
|
205
|
+
// transferable typed-array buffers.
|
|
206
|
+
function extractGeometry(geo) {
|
|
207
|
+
const attrs = {};
|
|
208
|
+
for (const k of Object.keys(geo.attributes)) {
|
|
209
|
+
const a = geo.attributes[k];
|
|
210
|
+
// Force into a flat Float32Array — _bakeQuantizeDecode already did this
|
|
211
|
+
// for position/normal/tangent. For color/uv/skinWeight/skinIndex we may
|
|
212
|
+
// still have other types — copy them out flat too so the main thread
|
|
213
|
+
// doesn't need attribute-type knowledge.
|
|
214
|
+
let arr;
|
|
215
|
+
let normalized = a.normalized;
|
|
216
|
+
if (k === 'normal' && a.itemSize === 3) {
|
|
217
|
+
// Quantize normals to Int8-normalized (1 byte/component vs 4). Normals are
|
|
218
|
+
// unit-length directions in [-1,1], interpolated in the fragment shader, so
|
|
219
|
+
// ~1/127 precision is imperceptible. Cuts the normal buffer 4x (bandwidth +
|
|
220
|
+
// VRAM). The main thread keeps normalized:true so THREE rescales /127.
|
|
221
|
+
arr = new Int8Array(a.count * 3);
|
|
222
|
+
for (let i = 0; i < a.count; i++) {
|
|
223
|
+
arr[i * 3 + 0] = Math.max(-127, Math.min(127, Math.round(a.getX(i) * 127)));
|
|
224
|
+
arr[i * 3 + 1] = Math.max(-127, Math.min(127, Math.round(a.getY(i) * 127)));
|
|
225
|
+
arr[i * 3 + 2] = Math.max(-127, Math.min(127, Math.round(a.getZ(i) * 127)));
|
|
226
|
+
}
|
|
227
|
+
normalized = true;
|
|
228
|
+
} else if (a.isInterleavedBufferAttribute || !(a.array instanceof Float32Array)) {
|
|
229
|
+
arr = new Float32Array(a.count * a.itemSize);
|
|
230
|
+
for (let i = 0; i < a.count; i++) {
|
|
231
|
+
if (a.itemSize >= 1) arr[i * a.itemSize + 0] = a.getX(i);
|
|
232
|
+
if (a.itemSize >= 2) arr[i * a.itemSize + 1] = a.getY(i);
|
|
233
|
+
if (a.itemSize >= 3) arr[i * a.itemSize + 2] = a.getZ(i);
|
|
234
|
+
if (a.itemSize >= 4) arr[i * a.itemSize + 3] = a.getW(i);
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
arr = new Float32Array(a.array.buffer.slice(a.array.byteOffset, a.array.byteOffset + a.array.byteLength));
|
|
238
|
+
}
|
|
239
|
+
attrs[k] = { array: arr, itemSize: a.itemSize, normalized };
|
|
240
|
+
}
|
|
241
|
+
let index = null;
|
|
242
|
+
if (geo.index) {
|
|
243
|
+
const ia = geo.index.array;
|
|
244
|
+
// Copy to a fresh buffer so we can transfer it without worrying about
|
|
245
|
+
// shared underlying ArrayBuffers (meshopt sometimes interleaves).
|
|
246
|
+
if (ia instanceof Uint32Array) index = new Uint32Array(ia);
|
|
247
|
+
else if (ia instanceof Uint16Array) index = new Uint16Array(ia);
|
|
248
|
+
else index = new Uint32Array(ia);
|
|
249
|
+
}
|
|
250
|
+
const bs = geo.boundingSphere;
|
|
251
|
+
const bb = geo.boundingBox;
|
|
252
|
+
return {
|
|
253
|
+
attrs,
|
|
254
|
+
index,
|
|
255
|
+
boundingSphere: bs ? { center: [bs.center.x, bs.center.y, bs.center.z], radius: bs.radius } : null,
|
|
256
|
+
boundingBox: bb ? { min: [bb.min.x, bb.min.y, bb.min.z], max: [bb.max.x, bb.max.y, bb.max.z] } : null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function payloadTransferables(payload) {
|
|
261
|
+
const list = [];
|
|
262
|
+
for (const k of Object.keys(payload.attrs)) list.push(payload.attrs[k].array.buffer);
|
|
263
|
+
if (payload.index) list.push(payload.index.buffer);
|
|
264
|
+
return list;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
self.addEventListener('message', async (ev) => {
|
|
268
|
+
const { id, url, decodeAABB, sloppyCap } = ev.data;
|
|
269
|
+
try {
|
|
270
|
+
const ok = await readyPromise;
|
|
271
|
+
if (!ok || !loader) throw new Error('worker not initialized');
|
|
272
|
+
const res = await fetch(url);
|
|
273
|
+
if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
|
|
274
|
+
const buf = await res.arrayBuffer();
|
|
275
|
+
const gltf = await new Promise((resolve, reject) => {
|
|
276
|
+
loader.parse(buf, '', resolve, reject);
|
|
277
|
+
});
|
|
278
|
+
let srcMesh = null;
|
|
279
|
+
gltf.scene.updateMatrixWorld(true);
|
|
280
|
+
gltf.scene.traverse((c) => { if (c.isMesh && !srcMesh) srcMesh = c; });
|
|
281
|
+
if (!srcMesh) throw new Error('no mesh in LOD sibling');
|
|
282
|
+
_bakeQuantizeDecode(srcMesh.geometry, srcMesh.matrixWorld, decodeAABB);
|
|
283
|
+
// Decimate the far/unskinned LOD toward ~sloppyCap triangles at load time
|
|
284
|
+
// (no re-bake needed; shipped far LODs are ~6500 tris = the dominant cost).
|
|
285
|
+
// Dependency-free spatial-grid vertex clustering: snap vertices to a coarse
|
|
286
|
+
// grid, remap triangles to cluster representatives, drop degenerates. Coarse
|
|
287
|
+
// but invisible on a distant instanced dot.
|
|
288
|
+
if (sloppyCap) {
|
|
289
|
+
try { _clusterDecimate(srcMesh.geometry, sloppyCap); } catch (e) { /* keep full-res */ }
|
|
290
|
+
}
|
|
291
|
+
const payload = extractGeometry(srcMesh.geometry);
|
|
292
|
+
payload.bytes = buf.byteLength;
|
|
293
|
+
self.postMessage({ id, ok: true, payload }, payloadTransferables(payload));
|
|
294
|
+
} catch (e) {
|
|
295
|
+
self.postMessage({ id, ok: false, error: String(e && e.message || e) });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// GlobalMaterialPool — Tier-based material consolidation for FPS optimization.
|
|
2
|
+
//
|
|
3
|
+
// MOTIVATION (Material Grouping Optimization):
|
|
4
|
+
// The baseline system creates 8-12 unique materials per asset, leading to:
|
|
5
|
+
// - 20+ compiled shader programs
|
|
6
|
+
// - 40-50 WebGL state changes per frame
|
|
7
|
+
// - Material setup overhead (~1-2ms per frame on 1000 entities)
|
|
8
|
+
//
|
|
9
|
+
// This pool consolidates all per-LOD materials into 3 tier-based materials:
|
|
10
|
+
// - HERO tier: high-detail textured material (per-entity SkinnedMesh)
|
|
11
|
+
// - MID tier: mid-quality textured material (decimated geometry)
|
|
12
|
+
// - FAR tier: vertex-color material (unskinned, instanced LODs)
|
|
13
|
+
//
|
|
14
|
+
// Result: 3 materials, 3 shader programs, ~3 state changes per frame
|
|
15
|
+
// Expected FPS gain: +4-6 FPS on 1000-entity stress test.
|
|
16
|
+
//
|
|
17
|
+
// Design:
|
|
18
|
+
// - Materials are shared across ALL entities and assets at the same tier
|
|
19
|
+
// - Texture management still respects per-entity LOD; only the material
|
|
20
|
+
// base is unified
|
|
21
|
+
// - Feature flag: _useGlobalMaterialPool = true/false for A/B testing
|
|
22
|
+
// - Backward compatible: entities can still use per-mesh materials if needed
|
|
23
|
+
|
|
24
|
+
import * as THREE from 'three';
|
|
25
|
+
|
|
26
|
+
export class GlobalMaterialPool {
|
|
27
|
+
constructor(renderer, opts = {}) {
|
|
28
|
+
this.renderer = renderer;
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
|
|
31
|
+
// Feature flag for easy toggling (A/B test)
|
|
32
|
+
this._useGlobalMaterialPool = true;
|
|
33
|
+
|
|
34
|
+
// The 3 tier materials (created once, reused by all entities)
|
|
35
|
+
this._heroMaterial = null;
|
|
36
|
+
this._midMaterial = null;
|
|
37
|
+
this._farMaterial = null;
|
|
38
|
+
|
|
39
|
+
// Track shader uniforms per material (shared across instances)
|
|
40
|
+
this._heroUniforms = null;
|
|
41
|
+
this._midUniforms = null;
|
|
42
|
+
this._farUniforms = null;
|
|
43
|
+
|
|
44
|
+
// Initialize the materials
|
|
45
|
+
this._initializeMaterials();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_initializeMaterials() {
|
|
49
|
+
// HERO tier: highest quality textured material for close-up entities
|
|
50
|
+
// Used for per-entity SkinnedMesh draws with full textures and quality
|
|
51
|
+
{
|
|
52
|
+
this._heroMaterial = new THREE.MeshStandardMaterial({
|
|
53
|
+
metalness: 0.0,
|
|
54
|
+
roughness: 0.8,
|
|
55
|
+
side: THREE.FrontSide,
|
|
56
|
+
shadowSide: THREE.FrontSide,
|
|
57
|
+
});
|
|
58
|
+
this._heroMaterial.name = 'HERO-tier-material';
|
|
59
|
+
|
|
60
|
+
// Patch for per-instance culling in batched scenarios (if applicable)
|
|
61
|
+
this._heroUniforms = {
|
|
62
|
+
projViewMatrix: { value: new THREE.Matrix4() },
|
|
63
|
+
};
|
|
64
|
+
_patchMaterialForTier(this._heroMaterial, this._heroUniforms, 'hero');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// MID tier: balanced quality textured material for mid-distance entities
|
|
68
|
+
// Same shader as HERO but may be applied to lower-resolution geometry
|
|
69
|
+
{
|
|
70
|
+
this._midMaterial = new THREE.MeshStandardMaterial({
|
|
71
|
+
metalness: 0.0,
|
|
72
|
+
roughness: 0.8,
|
|
73
|
+
side: THREE.FrontSide,
|
|
74
|
+
shadowSide: THREE.FrontSide,
|
|
75
|
+
});
|
|
76
|
+
this._midMaterial.name = 'MID-tier-material';
|
|
77
|
+
|
|
78
|
+
this._midUniforms = {
|
|
79
|
+
projViewMatrix: { value: new THREE.Matrix4() },
|
|
80
|
+
};
|
|
81
|
+
_patchMaterialForTier(this._midMaterial, this._midUniforms, 'mid');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// FAR tier: vertex-color material for instanced unskinned LODs
|
|
85
|
+
// Lightweight, supports per-instance culling via GPU frustum test
|
|
86
|
+
{
|
|
87
|
+
this._farMaterial = new THREE.MeshLambertMaterial({ vertexColors: true });
|
|
88
|
+
this._farMaterial.name = 'FAR-tier-material';
|
|
89
|
+
|
|
90
|
+
this._farUniforms = {
|
|
91
|
+
projViewMatrix: { value: new THREE.Matrix4() },
|
|
92
|
+
};
|
|
93
|
+
_patchMaterialForTier(this._farMaterial, this._farUniforms, 'far');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Get the material for a given tier
|
|
98
|
+
getMaterialForTier(tier) {
|
|
99
|
+
if (!this._useGlobalMaterialPool) return null;
|
|
100
|
+
|
|
101
|
+
switch (tier) {
|
|
102
|
+
case 'hero':
|
|
103
|
+
return this._heroMaterial;
|
|
104
|
+
case 'mid':
|
|
105
|
+
return this._midMaterial;
|
|
106
|
+
case 'far':
|
|
107
|
+
return this._farMaterial;
|
|
108
|
+
default:
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Get the material for a given LOD index
|
|
114
|
+
// Maps LOD 0-5 to tier materials based on screen-space density thresholds
|
|
115
|
+
getMaterialForLod(lodIndex) {
|
|
116
|
+
if (!this._useGlobalMaterialPool) return null;
|
|
117
|
+
|
|
118
|
+
// LOD thresholds: map LOD index to tier
|
|
119
|
+
// LOD 0-1: HERO tier (closest, highest detail)
|
|
120
|
+
// LOD 2-3: MID tier (medium distance)
|
|
121
|
+
// LOD 4-5: FAR tier (far, instanced)
|
|
122
|
+
if (lodIndex <= 1) return this._heroMaterial;
|
|
123
|
+
if (lodIndex <= 3) return this._midMaterial;
|
|
124
|
+
return this._farMaterial;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Validate that all materials are properly initialized
|
|
128
|
+
validateMaterials() {
|
|
129
|
+
const errors = [];
|
|
130
|
+
|
|
131
|
+
if (!this._heroMaterial) errors.push('HERO material not initialized');
|
|
132
|
+
if (!this._midMaterial) errors.push('MID material not initialized');
|
|
133
|
+
if (!this._farMaterial) errors.push('FAR material not initialized');
|
|
134
|
+
|
|
135
|
+
if (errors.length > 0) {
|
|
136
|
+
console.error('[GlobalMaterialPool] Validation failed:', errors);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Update uniforms for all tier materials (called once per frame)
|
|
144
|
+
updateUniforms(projViewMatrix, cameraPos, viewport) {
|
|
145
|
+
if (!this._useGlobalMaterialPool) return;
|
|
146
|
+
|
|
147
|
+
for (const uniforms of [this._heroUniforms, this._midUniforms, this._farUniforms]) {
|
|
148
|
+
if (uniforms?.projViewMatrix) {
|
|
149
|
+
uniforms.projViewMatrix.value.copy(projViewMatrix);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Dispose of materials and cleanup
|
|
155
|
+
dispose() {
|
|
156
|
+
if (this._heroMaterial) this._heroMaterial.dispose();
|
|
157
|
+
if (this._midMaterial) this._midMaterial.dispose();
|
|
158
|
+
if (this._farMaterial) this._farMaterial.dispose();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Get stats about material pooling
|
|
162
|
+
getStats() {
|
|
163
|
+
if (!this._useGlobalMaterialPool) return { enabled: false };
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
enabled: true,
|
|
167
|
+
materials: 3,
|
|
168
|
+
tiers: ['hero', 'mid', 'far'],
|
|
169
|
+
heroMaterial: this._heroMaterial?.name || 'none',
|
|
170
|
+
midMaterial: this._midMaterial?.name || 'none',
|
|
171
|
+
farMaterial: this._farMaterial?.name || 'none',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Helper: patch a tier material to support per-instance frustum culling and LOD selection.
|
|
177
|
+
// For FAR tier (instanced), this adds GPU frustum culling.
|
|
178
|
+
// For HERO/MID (per-entity), this is minimal overhead.
|
|
179
|
+
function _patchMaterialForTier(material, uniforms, tier) {
|
|
180
|
+
const prev = material.onBeforeCompile;
|
|
181
|
+
|
|
182
|
+
material.onBeforeCompile = (shader) => {
|
|
183
|
+
if (prev) prev(shader);
|
|
184
|
+
|
|
185
|
+
// Add uniforms
|
|
186
|
+
shader.uniforms.projViewMatrix = uniforms.projViewMatrix;
|
|
187
|
+
|
|
188
|
+
// For FAR tier, add per-instance attributes and GPU frustum culling
|
|
189
|
+
if (tier === 'far') {
|
|
190
|
+
// Vertex-color gamma: the baked color attribute is sRGB-encoded. Without
|
|
191
|
+
// this decode, dark colors wash out to near-white under the scene lights
|
|
192
|
+
// (the per-entity vcMaterial applies the same pow(vColor,2.2) — the shared
|
|
193
|
+
// FAR pool material was missing it, which is why most instanced models
|
|
194
|
+
// rendered white). Matches model-pool.js vcMaterial onBeforeCompile.
|
|
195
|
+
shader.fragmentShader = shader.fragmentShader.replace(
|
|
196
|
+
'#include <color_fragment>',
|
|
197
|
+
`#if defined( USE_COLOR_ALPHA )
|
|
198
|
+
diffuseColor.rgb *= pow(vColor.rgb, vec3(2.2));
|
|
199
|
+
diffuseColor.a *= vColor.a;
|
|
200
|
+
#elif defined( USE_COLOR )
|
|
201
|
+
diffuseColor.rgb *= pow(vColor, vec3(2.2));
|
|
202
|
+
#endif`
|
|
203
|
+
);
|
|
204
|
+
shader.uniforms.cameraPos = { value: new THREE.Vector3() };
|
|
205
|
+
shader.uniforms.lodThresholds = { value: new THREE.Vector4(80, 200, 400, 800) };
|
|
206
|
+
shader.uniforms.fovTanHalf = { value: 0.5 };
|
|
207
|
+
shader.uniforms.viewportHeight = { value: 1080 };
|
|
208
|
+
|
|
209
|
+
shader.vertexShader = shader.vertexShader
|
|
210
|
+
.replace(
|
|
211
|
+
'#include <common>',
|
|
212
|
+
`#include <common>
|
|
213
|
+
attribute vec4 instanceBoundSphere;
|
|
214
|
+
uniform mat4 projViewMatrix;
|
|
215
|
+
uniform vec3 cameraPos;
|
|
216
|
+
uniform vec4 lodThresholds;
|
|
217
|
+
uniform float fovTanHalf;
|
|
218
|
+
uniform float viewportHeight;
|
|
219
|
+
varying float vLodIndex;`
|
|
220
|
+
)
|
|
221
|
+
.replace(
|
|
222
|
+
'#include <project_vertex>',
|
|
223
|
+
`#include <project_vertex>
|
|
224
|
+
// (Removed the per-vertex projViewMatrix-derived GPU frustum cull that lived
|
|
225
|
+
// here. It collapsed instances to NaN when projViewMatrix was stale/identity on
|
|
226
|
+
// this shared pool material — which over-culled most FAR models off-screen
|
|
227
|
+
// ("only a small group visible"). CPU-side frustum culling (root.visible) plus
|
|
228
|
+
// the instanced bound-sphere path already handle culling correctly; this
|
|
229
|
+
// per-vertex pass was both redundant and buggy. Witnessed: removing the cull
|
|
230
|
+
// restores the full field of models.)
|
|
231
|
+
vLodIndex = 0.0;`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
// HERO/MID tiers: no per-instance attributes needed (per-entity draws)
|
|
235
|
+
// The existing vertex shader is sufficient
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
material.needsUpdate = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default { GlobalMaterialPool };
|