streaming-gltf 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 };