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,663 @@
1
+ #!/usr/bin/env node
2
+ // Local progressive LOD baker.
3
+ // Takes a GLB, decimates each primitive at multiple ratios using meshoptimizer,
4
+ // resizes each texture at multiple sizes via sharp,
5
+ // writes a small root GLB carrying the lowest LOD inline plus a LOCAL_progressive
6
+ // extension JSON that references sibling .glb / .webp files for higher LODs.
7
+
8
+ import { NodeIO } from '@gltf-transform/core';
9
+ import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
10
+ import { simplify, textureCompress, cloneDocument, prune, dedup, meshopt } from '@gltf-transform/functions';
11
+ import { MeshoptSimplifier, MeshoptEncoder, MeshoptDecoder } from 'meshoptimizer';
12
+ import draco3dgltf from 'draco3dgltf';
13
+ import sharp from 'sharp';
14
+ import { mkdir, writeFile, rm, stat, readFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ // Read a GLB and return its JSON chunk as a parsed object plus the original
19
+ // binary chunk bytes. Used to round-trip extensions gltf-transform doesn't
20
+ // know about (e.g. VRM 0.0 — the `VRM` extension is dropped on read).
21
+ async function readGlbParts(filePath) {
22
+ const buf = await readFile(filePath);
23
+ const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
24
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
25
+ if (dv.getUint32(0, true) !== 0x46546C67) throw new Error(`${filePath}: not a GLB`);
26
+ const jsonLen = dv.getUint32(12, true);
27
+ if (dv.getUint32(16, true) !== 0x4E4F534A) throw new Error(`${filePath}: expected JSON chunk first`);
28
+ const jsonBytes = u8.subarray(20, 20 + jsonLen);
29
+ const json = JSON.parse(new TextDecoder().decode(jsonBytes));
30
+ return { json, fullBytes: u8 };
31
+ }
32
+
33
+ // Per-channel sRGB → linear lookup. Vertex-color averaging runs in linear
34
+ // space so the math matches the renderer's lighting pipeline. We then
35
+ // re-encode the averaged linear value back to an sRGB byte for storage;
36
+ // the runtime applies a custom onBeforeCompile patch to gamma-decode the
37
+ // COLOR_0 attribute at vertex time so it enters the fragment as linear,
38
+ // matching how the baseColorTexture is interpreted. Without that, sRGB
39
+ // bytes in COLOR_0 get used as linear values directly and the result is
40
+ // visibly wrong (typically too saturated and too bright).
41
+ const SRGB_TO_LINEAR = new Float32Array(256);
42
+ const LINEAR_TO_SRGB_LUT = new Uint8Array(4096);
43
+ for (let i = 0; i < 256; i++) {
44
+ const c = i / 255;
45
+ SRGB_TO_LINEAR[i] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
46
+ }
47
+ for (let i = 0; i < 4096; i++) {
48
+ const lin = i / 4095;
49
+ const enc = lin <= 0.0031308 ? lin * 12.92 : 1.055 * Math.pow(lin, 1 / 2.4) - 0.055;
50
+ LINEAR_TO_SRGB_LUT[i] = Math.min(255, Math.max(0, Math.round(enc * 255)));
51
+ }
52
+ function linearToSrgbByte(lin) {
53
+ if (lin <= 0) return 0;
54
+ if (lin >= 1) return 255;
55
+ return LINEAR_TO_SRGB_LUT[Math.min(4095, Math.max(0, Math.round(lin * 4095)))];
56
+ }
57
+
58
+ // Sample a decoded RGBA texture buffer at a UV coordinate, returning the
59
+ // per-channel LINEAR value [0,1]. Texture bytes are assumed sRGB-encoded
60
+ // (the default for glTF baseColor maps).
61
+ function sampleLinear(rgbaPixels, width, height, u, v) {
62
+ let uu = u - Math.floor(u);
63
+ let vv = v - Math.floor(v);
64
+ const x = Math.min(width - 1, Math.max(0, Math.floor(uu * width)));
65
+ const y = Math.min(height - 1, Math.max(0, Math.floor(vv * height)));
66
+ const i = (y * width + x) * 4;
67
+ return [
68
+ SRGB_TO_LINEAR[rgbaPixels[i]],
69
+ SRGB_TO_LINEAR[rgbaPixels[i + 1]],
70
+ SRGB_TO_LINEAR[rgbaPixels[i + 2]],
71
+ ];
72
+ }
73
+
74
+ // Average each surviving vertex's color from the texture by walking the
75
+ // SIMPLIFIED mesh's incident triangles and area-weighted-averaging the texel
76
+ // samples in LINEAR space (then re-encoding to sRGB for storage). Averaging
77
+ // in sRGB space gives visibly wrong perceptual colors — darks too dark and
78
+ // midtones shifted. Linear math matches the renderer's lighting pipeline.
79
+ function buildAveragedVertexColors(simpPrim, baseRGBA, vertCount) {
80
+ const colorArr = new Uint8Array(vertCount * 4);
81
+ const idxAcc = simpPrim.getIndices();
82
+ const uvAcc = simpPrim.getAttribute('TEXCOORD_0');
83
+ const posAcc = simpPrim.getAttribute('POSITION');
84
+ if (!baseRGBA || !uvAcc) {
85
+ for (let v = 0; v < vertCount; v++) {
86
+ colorArr.set([180, 180, 180, 255], v * 4);
87
+ }
88
+ return colorArr;
89
+ }
90
+ const uv = uvAcc.getArray();
91
+ const pos = posAcc?.getArray();
92
+ // Per-vertex accumulator in LINEAR space: r, g, b, weight.
93
+ const acc = new Float64Array(vertCount * 4);
94
+ const idx = idxAcc?.getArray();
95
+ const triangleIter = (a, b, c) => {
96
+ const col = triangleAvgColorLinear(uv, pos, a, b, c, baseRGBA);
97
+ const w = col.area;
98
+ for (const vi of [a, b, c]) {
99
+ acc[vi * 4] += col.r * w;
100
+ acc[vi * 4 + 1] += col.g * w;
101
+ acc[vi * 4 + 2] += col.b * w;
102
+ acc[vi * 4 + 3] += w;
103
+ }
104
+ };
105
+ if (!idx) {
106
+ for (let v = 0; v < vertCount; v += 3) triangleIter(v, v + 1, v + 2);
107
+ } else {
108
+ for (let t = 0; t < idx.length; t += 3) triangleIter(idx[t], idx[t + 1], idx[t + 2]);
109
+ }
110
+ for (let v = 0; v < vertCount; v++) {
111
+ const w = acc[v * 4 + 3];
112
+ if (w > 1e-9) {
113
+ colorArr[v * 4] = linearToSrgbByte(acc[v * 4] / w);
114
+ colorArr[v * 4 + 1] = linearToSrgbByte(acc[v * 4 + 1] / w);
115
+ colorArr[v * 4 + 2] = linearToSrgbByte(acc[v * 4 + 2] / w);
116
+ } else {
117
+ // Isolated vertex: sample once at its own UV (single texel → no average).
118
+ const lin = sampleLinear(baseRGBA.data, baseRGBA.width, baseRGBA.height, uv[v * 2], uv[v * 2 + 1]);
119
+ colorArr[v * 4] = linearToSrgbByte(lin[0]);
120
+ colorArr[v * 4 + 1] = linearToSrgbByte(lin[1]);
121
+ colorArr[v * 4 + 2] = linearToSrgbByte(lin[2]);
122
+ }
123
+ colorArr[v * 4 + 3] = 255;
124
+ }
125
+ return colorArr;
126
+ }
127
+
128
+ // Average one triangle's texel colors in LINEAR space using a barycentric
129
+ // sample grid sized to the triangle's UV area. Small triangles (typical
130
+ // at high LOD) get a coarse grid; huge triangles (typical at low LOD
131
+ // where one survivor covers a big UV region) get many more samples so
132
+ // the average reflects the actual texture content.
133
+ function triangleAvgColorLinear(uvArr, posArr, ia, ib, ic, baseRGBA) {
134
+ const u0 = uvArr[ia * 2], v0 = uvArr[ia * 2 + 1];
135
+ const u1 = uvArr[ib * 2], v1 = uvArr[ib * 2 + 1];
136
+ const u2 = uvArr[ic * 2], v2 = uvArr[ic * 2 + 1];
137
+
138
+ // 3D area for weighting (so big triangles dominate per-vertex average).
139
+ let area3d = 1;
140
+ if (posArr) {
141
+ const ax = posArr[ia * 3], ay = posArr[ia * 3 + 1], az = posArr[ia * 3 + 2];
142
+ const bx = posArr[ib * 3], by = posArr[ib * 3 + 1], bz = posArr[ib * 3 + 2];
143
+ const cx = posArr[ic * 3], cy = posArr[ic * 3 + 1], cz = posArr[ic * 3 + 2];
144
+ const e1x = bx - ax, e1y = by - ay, e1z = bz - az;
145
+ const e2x = cx - ax, e2y = cy - ay, e2z = cz - az;
146
+ const nx = e1y * e2z - e1z * e2y;
147
+ const ny = e1z * e2x - e1x * e2z;
148
+ const nz = e1x * e2y - e1y * e2x;
149
+ area3d = Math.max(1e-9, 0.5 * Math.sqrt(nx * nx + ny * ny + nz * nz));
150
+ }
151
+
152
+ // Choose grid resolution N from triangle UV-area in texels: aim for at
153
+ // least 1 sample per ~4 texels so we approximate the true average rather
154
+ // than a sparse spot check. Clamp to [4, 32] so small triangles aren't
155
+ // overworked and huge ones don't blow up bake time.
156
+ const uvArea = Math.abs((u1 - u0) * (v2 - v0) - (u2 - u0) * (v1 - v0)) * 0.5;
157
+ const texelArea = uvArea * baseRGBA.width * baseRGBA.height;
158
+ const N = Math.max(4, Math.min(32, Math.ceil(Math.sqrt(texelArea / 4))));
159
+
160
+ let sr = 0, sg = 0, sb = 0, samples = 0;
161
+ for (let i = 0; i <= N; i++) {
162
+ for (let j = 0; j <= N - i; j++) {
163
+ const w0 = i / N;
164
+ const w1 = j / N;
165
+ const w2 = 1 - w0 - w1;
166
+ const u = u0 * w0 + u1 * w1 + u2 * w2;
167
+ const v = v0 * w0 + v1 * w1 + v2 * w2;
168
+ const lin = sampleLinear(baseRGBA.data, baseRGBA.width, baseRGBA.height, u, v);
169
+ sr += lin[0];
170
+ sg += lin[1];
171
+ sb += lin[2];
172
+ samples++;
173
+ }
174
+ }
175
+ return { r: sr / samples, g: sg / samples, b: sb / samples, area: area3d };
176
+ }
177
+
178
+ // Re-serialize a GLB with a replaced JSON chunk. The BIN chunk is kept byte-for-byte.
179
+ async function rewriteGlbJson(filePath, mutator) {
180
+ const buf = await readFile(filePath);
181
+ const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
182
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
183
+ const jsonLen = dv.getUint32(12, true);
184
+ const json = JSON.parse(new TextDecoder().decode(u8.subarray(20, 20 + jsonLen)));
185
+ await mutator(json);
186
+ const newJsonStr = JSON.stringify(json);
187
+ const newJsonBytes = new TextEncoder().encode(newJsonStr);
188
+ const jsonPad = (4 - (newJsonBytes.length % 4)) % 4;
189
+ const newJsonLen = newJsonBytes.length + jsonPad;
190
+ const binChunkStart = 20 + jsonLen;
191
+ const binLen = dv.getUint32(binChunkStart, true);
192
+ const binChunk = u8.subarray(binChunkStart, binChunkStart + 8 + binLen); // includes the 8-byte chunk header
193
+ const totalLen = 12 + 8 + newJsonLen + binChunk.byteLength;
194
+ const out = new Uint8Array(totalLen);
195
+ const odv = new DataView(out.buffer);
196
+ odv.setUint32(0, 0x46546C67, true);
197
+ odv.setUint32(4, 2, true);
198
+ odv.setUint32(8, totalLen, true);
199
+ odv.setUint32(12, newJsonLen, true);
200
+ odv.setUint32(16, 0x4E4F534A, true);
201
+ out.set(newJsonBytes, 20);
202
+ for (let i = 0; i < jsonPad; i++) out[20 + newJsonBytes.length + i] = 0x20;
203
+ out.set(binChunk, 20 + newJsonLen);
204
+ await writeFile(filePath, out);
205
+ }
206
+
207
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
208
+ const repoRoot = path.resolve(__dirname, '..');
209
+
210
+ const INPUT = process.argv[2] || path.join(repoRoot, 'model.glb');
211
+ // Default output dir is named after the input basename so multiple models
212
+ // can be baked into examples/local-progressive/ side by side.
213
+ const inputBase = path.basename(INPUT, path.extname(INPUT));
214
+ const DEFAULT_OUT = path.join(
215
+ repoRoot,
216
+ 'examples/local-progressive',
217
+ inputBase === 'model' ? 'output' : `output_${inputBase}`,
218
+ );
219
+ const OUT_DIR = process.argv[3] || DEFAULT_OUT;
220
+ const LODS_SUBDIR = 'lods';
221
+
222
+ // LOD recipes from highest to lowest detail. Each entry: { ratio, kind }
223
+ // kind: 'textured' - standard LOD: simplified mesh, textured material, skinned
224
+ // 'vertcolor' - baked per-vertex colors from baseColor texture, no material map, skinned
225
+ // 'unskinned' - baked per-vertex colors, skin attrs stripped, no longer a SkinnedMesh
226
+ // Ratio is the meshopt simplify ratio. The 'ratio' field doubles as the
227
+ // sort key in the runtime (ascending = lowest detail first).
228
+ const MESH_LOD_RATIOS = [1.0, 0.4, 0.15, 0.04];
229
+ const EXTRA_LOD_STAGES = [
230
+ { ratio: 0.04, kind: 'vertcolor' }, // very low, vertex-colored, still skinned
231
+ { ratio: 0.01, kind: 'unskinned' }, // bind-pose, vertex-colored, no skin
232
+ ];
233
+ const TEX_LOD_SIZES = [2048, 1024, 512, 256, 128];
234
+
235
+ async function main() {
236
+ console.log(`[bake] input : ${INPUT}`);
237
+ console.log(`[bake] output : ${OUT_DIR}`);
238
+
239
+ await rm(OUT_DIR, { recursive: true, force: true });
240
+ await mkdir(path.join(OUT_DIR, LODS_SUBDIR), { recursive: true });
241
+
242
+ // Snapshot the source's top-level extensions so we can splice VRM (or any
243
+ // other extension gltf-transform doesn't know) back into the baked output.
244
+ const sourceParts = await readGlbParts(INPUT);
245
+ const sourceExtensions = sourceParts.json.extensions || {};
246
+ const sourceExtensionsUsed = sourceParts.json.extensionsUsed || [];
247
+ const sourceExtensionsRequired = sourceParts.json.extensionsRequired || [];
248
+ const passthroughExtensionNames = ['VRM']; // VRM 0.0
249
+ const passthroughBlob = {};
250
+ for (const name of passthroughExtensionNames) {
251
+ if (sourceExtensions[name]) passthroughBlob[name] = sourceExtensions[name];
252
+ }
253
+ if (Object.keys(passthroughBlob).length) {
254
+ console.log(`[bake] preserving extensions: ${Object.keys(passthroughBlob).join(', ')}`);
255
+ }
256
+
257
+ await MeshoptSimplifier.ready;
258
+ await MeshoptEncoder.ready;
259
+ await MeshoptDecoder.ready;
260
+
261
+ const io = new NodeIO()
262
+ .registerExtensions(ALL_EXTENSIONS)
263
+ .registerDependencies({
264
+ 'meshopt.encoder': MeshoptEncoder,
265
+ 'meshopt.decoder': MeshoptDecoder,
266
+ 'draco3d.decoder': await draco3dgltf.createDecoderModule(),
267
+ 'draco3d.encoder': await draco3dgltf.createEncoderModule(),
268
+ });
269
+ const doc = await io.read(INPUT);
270
+ const root = doc.getRoot();
271
+
272
+ console.log(`[bake] meshes=${root.listMeshes().length} textures=${root.listTextures().length}`);
273
+
274
+ // Stage 1: bake mesh LODs.
275
+ // For each mesh primitive, produce ratios > lowest as sibling .glb chunks.
276
+ // The lowest ratio stays inline in the root document.
277
+ const meshLODs = []; // { meshIndex, primIndex, lods: [{ ratio, path, indexCount, vertexCount, bytes }] }
278
+
279
+ const meshes = root.listMeshes();
280
+ for (let mi = 0; mi < meshes.length; mi++) {
281
+ const mesh = meshes[mi];
282
+ const prims = mesh.listPrimitives();
283
+ for (let pi = 0; pi < prims.length; pi++) {
284
+ const baselinePrim = prims[pi];
285
+ const baselineIndices = baselinePrim.getIndices()?.getCount() ?? 0;
286
+ const baselineVerts = baselinePrim.getAttribute('POSITION')?.getCount() ?? 0;
287
+ const morphCount = baselinePrim.listTargets()?.length || 0;
288
+ console.log(`[bake] mesh ${mi} prim ${pi}: ${baselineVerts} verts, ${baselineIndices} indices, ${morphCount} morph targets`);
289
+
290
+ const lodEntries = [];
291
+
292
+ // gltf-transform's simplify() preserves morph target deltas alongside
293
+ // POSITION/NORMAL when MeshoptSimplifier is used (it runs the simplifier
294
+ // with attribute-aware vertex weighting). Keep mesh LODs even for
295
+ // morph-bearing primitives. The 'unskinned' EXTRA stage still drops
296
+ // morphs along with skin since the bind-pose-frozen target is the
297
+ // whole point of that LOD.
298
+ const skipMeshLod = false;
299
+ const ratios = [...MESH_LOD_RATIOS];
300
+ const lowestRatio = ratios[ratios.length - 1];
301
+
302
+ for (const ratio of ratios) {
303
+ const isLowest = ratio === lowestRatio;
304
+ // Clone full document to work on
305
+ const cloneDoc = cloneDocument(doc);
306
+ const cloneMesh = cloneDoc.getRoot().listMeshes()[mi];
307
+ // Replace mesh primitives with only the target one
308
+ const clonePrims = cloneMesh.listPrimitives();
309
+ // Strip other prims
310
+ clonePrims.forEach((p, idx) => { if (idx !== pi) cloneMesh.removePrimitive(p); });
311
+
312
+ if (ratio < 1.0) {
313
+ await cloneDoc.transform(
314
+ simplify({ simplifier: MeshoptSimplifier, ratio, error: 0.001, lockBorder: false })
315
+ );
316
+ }
317
+
318
+ // Strip everything except the geometry we need (kill textures inside LOD chunks - we ship textures separately)
319
+ const cleanRoot = cloneDoc.getRoot();
320
+ for (const m of cleanRoot.listMaterials()) m.dispose();
321
+ for (const t of cleanRoot.listTextures()) t.dispose();
322
+ for (const m of cleanRoot.listMeshes()) {
323
+ if (m !== cloneMesh) m.dispose();
324
+ }
325
+
326
+ const simplified = cloneMesh.listPrimitives()[0];
327
+ const newIndices = simplified?.getIndices()?.getCount() ?? 0;
328
+ const newVerts = simplified?.getAttribute('POSITION')?.getCount() ?? 0;
329
+
330
+ if (isLowest) {
331
+ // Lowest LOD will be folded into the root output as the inline geometry.
332
+ // We keep cloneDoc in memory and use it below.
333
+ lodEntries.push({ ratio, inline: true, indexCount: newIndices, vertexCount: newVerts, doc: cloneDoc });
334
+ } else {
335
+ // Capture the pre-quantize POSITION AABB so the runtime can rescale
336
+ // meshopt-decoded vertices (which land in [-1,1]) back into the
337
+ // original character-space coordinates. Without this the swapped
338
+ // geometry renders centered on origin instead of at its true
339
+ // position-space location.
340
+ const posPre = simplified.getAttribute('POSITION');
341
+ const decodeAABB = posPre ? {
342
+ min: posPre.getMin(new Array(3).fill(0)).slice(),
343
+ max: posPre.getMax(new Array(3).fill(0)).slice(),
344
+ } : null;
345
+ // Apply EXT_meshopt_compression on the sibling LOD before serializing
346
+ // — quantizes attributes and encodes for ~5-10x smaller payloads.
347
+ await cloneDoc.transform(
348
+ meshopt({ encoder: MeshoptEncoder, level: 'high' })
349
+ );
350
+ const fileName = `mesh_${mi}_${pi}_r${ratio.toString().replace('.', '')}.glb`;
351
+ const filePath = path.join(OUT_DIR, LODS_SUBDIR, fileName);
352
+ const bin = await io.writeBinary(cloneDoc);
353
+ await writeFile(filePath, bin);
354
+ const sz = (await stat(filePath)).size;
355
+ console.log(`[bake] ratio=${ratio} -> ${fileName} (${(sz/1024/1024).toFixed(2)} MB, idx=${newIndices})`);
356
+ lodEntries.push({
357
+ ratio,
358
+ path: `${LODS_SUBDIR}/${fileName}`,
359
+ indexCount: newIndices,
360
+ vertexCount: newVerts,
361
+ bytes: sz,
362
+ decodeAABB,
363
+ });
364
+ }
365
+ }
366
+
367
+ // Skip extra stages if this primitive carries morph targets — both new
368
+ // stages aggressively decimate, which would shred face blendshapes.
369
+ // SCHWEP3.vrm's face mesh (59 morphs) and LANMOWER mesh 1 (6 morphs)
370
+ // both fall into this bucket and ship only at full detail.
371
+ if (!skipMeshLod) {
372
+ // Decode source baseColor texture once for per-vertex sampling.
373
+ const matRef = baselinePrim.getMaterial();
374
+ const baseTex = matRef?.getBaseColorTexture?.();
375
+ let baseRGBA = null;
376
+ if (baseTex) {
377
+ const imgBytes = baseTex.getImage();
378
+ if (imgBytes) {
379
+ const decoded = await sharp(Buffer.from(imgBytes))
380
+ .ensureAlpha()
381
+ .raw()
382
+ .toBuffer({ resolveWithObject: true });
383
+ baseRGBA = {
384
+ data: decoded.data,
385
+ width: decoded.info.width,
386
+ height: decoded.info.height,
387
+ };
388
+ }
389
+ }
390
+
391
+ for (const stage of EXTRA_LOD_STAGES) {
392
+ const cloneDoc = cloneDocument(doc);
393
+ const cloneMesh = cloneDoc.getRoot().listMeshes()[mi];
394
+ const clonePrims = cloneMesh.listPrimitives();
395
+ clonePrims.forEach((p, idx) => { if (idx !== pi) cloneMesh.removePrimitive(p); });
396
+ // For the unskinned stage we want truly aggressive decimation — the
397
+ // mesh ships only at far distances and is rendered via shared
398
+ // InstancedMesh. Drop morph targets up front so the simplifier
399
+ // isn't constrained to preserve their topology, and crank the
400
+ // error tolerance way up so the simplifier doesn't bail early.
401
+ if (stage.kind === 'unskinned') {
402
+ const cmPrim = cloneMesh.listPrimitives()[0];
403
+ for (const t of cmPrim.listTargets()) cmPrim.removeTarget(t);
404
+ }
405
+ if (stage.kind === 'unskinned') {
406
+ // Unskinned = the farthest, instanced, distant-dot LOD and the
407
+ // dominant triangle cost (measured: topology-preserving simplify at
408
+ // ratio 0.01/error 0.1 bailed at ~6500 tris/model -> ~5M tris across
409
+ // 494 far models; the scene is triangle/fill-bound, not draw-bound).
410
+ // Topology-preserving simplify CAN'T go lower (seams/UV islands), so
411
+ // use simplifySloppy which ignores topology and actually reaches the
412
+ // target — geometric error is invisible on a distant instanced dot.
413
+ // Measured 6508 -> 282 tris (~23x). Applied directly to the index
414
+ // buffer of the (already meshopt-decoded) primitive.
415
+ await MeshoptSimplifier.ready;
416
+ const cmPrim = cloneMesh.listPrimitives()[0];
417
+ const idxAcc = cmPrim.getIndices();
418
+ const posAcc = cmPrim.getAttribute('POSITION');
419
+ if (idxAcc && posAcc) {
420
+ const u32 = new Uint32Array(idxAcc.getArray());
421
+ const f32 = new Float32Array(posAcc.getArray());
422
+ // Absolute triangle cap for the distant instanced dot (not a ratio
423
+ // of the full-res mesh, which leaves dense models still heavy).
424
+ // ~400 tris is plenty for a far dot; clamp to the mesh's own size.
425
+ const FAR_TRI_CAP = 400;
426
+ let target = Math.min(FAR_TRI_CAP * 3, u32.length);
427
+ target -= target % 3;
428
+ target = Math.max(96, target); // >=32 tris
429
+ const res = MeshoptSimplifier.simplifySloppy(u32, f32, 3, null, target, 1e9);
430
+ const out = Array.isArray(res) ? res[0] : res;
431
+ idxAcc.setArray(out instanceof Uint32Array ? out : new Uint32Array(out));
432
+ }
433
+ } else {
434
+ await cloneDoc.transform(
435
+ simplify({ simplifier: MeshoptSimplifier, ratio: stage.ratio, error: 0.005, lockBorder: false }),
436
+ );
437
+ }
438
+
439
+ // Strip materials, textures, sibling meshes — these stages don't
440
+ // sample a texture at render time, so we save bytes.
441
+ const cleanRoot2 = cloneDoc.getRoot();
442
+ for (const m of cleanRoot2.listMaterials()) m.dispose();
443
+ for (const t of cleanRoot2.listTextures()) t.dispose();
444
+ for (const m of cleanRoot2.listMeshes()) {
445
+ if (m !== cloneMesh) m.dispose();
446
+ }
447
+
448
+ const simp = cloneMesh.listPrimitives()[0];
449
+ const posAcc = simp.getAttribute('POSITION');
450
+ const uvAcc = simp.getAttribute('TEXCOORD_0');
451
+ const vertCount = posAcc?.getCount() ?? 0;
452
+
453
+ // Bake per-vertex colors by area-weighted averaging the texture
454
+ // over each surviving vertex's incident triangles. The 4×4
455
+ // barycentric sample grid acts as a box filter, capturing the
456
+ // mean color of the surface region each vertex now represents
457
+ // after decimation — much closer to the textured appearance than
458
+ // point-sampling a single UV.
459
+ if (vertCount > 0) {
460
+ const colorArr = buildAveragedVertexColors(simp, baseRGBA, vertCount);
461
+ const colorAccessor = cloneDoc
462
+ .createAccessor()
463
+ .setType('VEC4')
464
+ .setArray(colorArr)
465
+ .setNormalized(true)
466
+ .setBuffer(cleanRoot2.listBuffers()[0]);
467
+ simp.setAttribute('COLOR_0', colorAccessor);
468
+ }
469
+
470
+ // For the unskinned stage, drop skin attributes, the skin
471
+ // reference, AND morph targets — this LOD is meant for the lowest
472
+ // distance bucket where neither skin animation nor face
473
+ // blendshapes are visible. Done after simplify so the simplifier
474
+ // had full attribute info to make a sane edge-collapse choice.
475
+ if (stage.kind === 'unskinned') {
476
+ const ja = simp.getAttribute('JOINTS_0');
477
+ const wa = simp.getAttribute('WEIGHTS_0');
478
+ if (ja) simp.setAttribute('JOINTS_0', null);
479
+ if (wa) simp.setAttribute('WEIGHTS_0', null);
480
+ // Strip morph targets.
481
+ for (const t of simp.listTargets()) simp.removeTarget(t);
482
+ for (const node of cleanRoot2.listNodes()) {
483
+ if (node.getMesh() === cloneMesh) node.setSkin(null);
484
+ }
485
+ for (const s of cleanRoot2.listSkins()) s.dispose();
486
+ }
487
+
488
+ const posPreStage = simp.getAttribute('POSITION');
489
+ const decodeAABB = posPreStage ? {
490
+ min: posPreStage.getMin(new Array(3).fill(0)).slice(),
491
+ max: posPreStage.getMax(new Array(3).fill(0)).slice(),
492
+ } : null;
493
+ await cloneDoc.transform(
494
+ meshopt({ encoder: MeshoptEncoder, level: 'high' })
495
+ );
496
+ const fileName = `mesh_${mi}_${pi}_${stage.kind}.glb`;
497
+ const filePath = path.join(OUT_DIR, LODS_SUBDIR, fileName);
498
+ const bin = await io.writeBinary(cloneDoc);
499
+ await writeFile(filePath, bin);
500
+ const sz = (await stat(filePath)).size;
501
+ console.log(`[bake] ${stage.kind} r=${stage.ratio} -> ${fileName} (${(sz/1024).toFixed(1)} KB, idx=${simp.getIndices()?.getCount()})`);
502
+ lodEntries.push({
503
+ ratio: stage.ratio,
504
+ kind: stage.kind,
505
+ path: `${LODS_SUBDIR}/${fileName}`,
506
+ indexCount: simp.getIndices()?.getCount() ?? 0,
507
+ vertexCount: vertCount,
508
+ bytes: sz,
509
+ decodeAABB,
510
+ });
511
+ }
512
+ }
513
+
514
+ meshLODs.push({ meshIndex: mi, primIndex: pi, lods: lodEntries });
515
+ }
516
+ }
517
+
518
+ // Stage 2: bake texture LODs.
519
+ const texLODs = []; // { textureIndex, name, lods: [{ width, path, bytes }] }
520
+ const textures = root.listTextures();
521
+ for (let ti = 0; ti < textures.length; ti++) {
522
+ const tex = textures[ti];
523
+ const name = tex.getName() || `tex_${ti}`;
524
+ const srcImage = tex.getImage();
525
+ if (!srcImage) continue;
526
+
527
+ const meta = await sharp(Buffer.from(srcImage)).metadata();
528
+ console.log(`[bake] texture ${ti} (${name}): ${meta.width}x${meta.height} ${meta.format}`);
529
+
530
+ // Determine which sizes are <= source. Always include at least the smallest size.
531
+ const sizes = TEX_LOD_SIZES.filter((s) => s <= Math.max(meta.width, meta.height));
532
+ if (sizes.length === 0) sizes.push(Math.max(meta.width, meta.height));
533
+ // Smallest size will be inlined (replaces root texture), bigger sizes are sibling files.
534
+ const lodEntries = [];
535
+ for (const sz of sizes) {
536
+ const buf = await sharp(Buffer.from(srcImage))
537
+ .resize(sz, sz, { fit: 'inside', withoutEnlargement: true })
538
+ .webp({ quality: 82 })
539
+ .toBuffer();
540
+ const isSmallest = sz === sizes[sizes.length - 1];
541
+ if (isSmallest) {
542
+ lodEntries.push({ width: sz, inline: true, bytes: buf.length, buffer: buf });
543
+ } else {
544
+ const fileName = `tex_${ti}_${sz}.webp`;
545
+ const filePath = path.join(OUT_DIR, LODS_SUBDIR, fileName);
546
+ await writeFile(filePath, buf);
547
+ console.log(`[bake] size=${sz} -> ${fileName} (${(buf.length/1024).toFixed(1)} KB)`);
548
+ lodEntries.push({ width: sz, path: `${LODS_SUBDIR}/${fileName}`, bytes: buf.length });
549
+ }
550
+ }
551
+ texLODs.push({ textureIndex: ti, name, lods: lodEntries });
552
+ }
553
+
554
+ // Stage 3: build the root GLB.
555
+ // Strategy: start from the original doc, swap in lowest-LOD geometry per primitive,
556
+ // swap in the smallest texture variant, then attach a top-level LOCAL_progressive
557
+ // extras blob (we use `extras` rather than a registered extension to avoid
558
+ // GLTFLoader rejecting unknown extensionsRequired).
559
+ const rootDoc = cloneDocument(doc);
560
+ const rootRoot = rootDoc.getRoot();
561
+
562
+ // Replace primitive indices/attributes with lowest LOD by copying buffer data over.
563
+ for (const ml of meshLODs) {
564
+ const inline = ml.lods.find((x) => x.inline);
565
+ if (!inline) continue;
566
+ const srcPrim = inline.doc.getRoot().listMeshes()[0].listPrimitives()[0];
567
+ const dstPrim = rootRoot.listMeshes()[ml.meshIndex].listPrimitives()[ml.primIndex];
568
+
569
+ // Copy indices
570
+ const srcIdx = srcPrim.getIndices();
571
+ if (srcIdx) {
572
+ const newIdx = rootDoc.createAccessor()
573
+ .setType(srcIdx.getType())
574
+ .setArray(srcIdx.getArray().slice())
575
+ .setBuffer(rootRoot.listBuffers()[0]);
576
+ dstPrim.setIndices(newIdx);
577
+ }
578
+ // Copy each attribute
579
+ for (const sem of srcPrim.listSemantics()) {
580
+ const srcAttr = srcPrim.getAttribute(sem);
581
+ if (!srcAttr) continue;
582
+ const newAttr = rootDoc.createAccessor()
583
+ .setType(srcAttr.getType())
584
+ .setArray(srcAttr.getArray().slice())
585
+ .setBuffer(rootRoot.listBuffers()[0]);
586
+ dstPrim.setAttribute(sem, newAttr);
587
+ }
588
+ }
589
+
590
+ // Replace textures with smallest WebP.
591
+ for (const tl of texLODs) {
592
+ const tex = rootRoot.listTextures()[tl.textureIndex];
593
+ const inline = tl.lods.find((x) => x.inline);
594
+ if (!inline) continue;
595
+ tex.setImage(inline.buffer);
596
+ tex.setMimeType('image/webp');
597
+ }
598
+
599
+ // Build the extension descriptor as extras at the root.
600
+ // Per-primitive density = indexCount / triangle_count_in_world_space_isn't_available_at_bake → use baseline triangle count as proxy.
601
+ const extPayload = {
602
+ version: 1,
603
+ meshes: meshLODs.map((ml) => ({
604
+ meshIndex: ml.meshIndex,
605
+ primIndex: ml.primIndex,
606
+ lods: ml.lods
607
+ .filter((x) => !x.inline)
608
+ .concat([{ ratio: ml.lods.find((x) => x.inline).ratio, inline: true, indexCount: ml.lods.find((x) => x.inline).indexCount }])
609
+ .map((x) => ({
610
+ ratio: x.ratio,
611
+ kind: x.kind || 'textured',
612
+ path: x.path,
613
+ inline: !!x.inline,
614
+ indexCount: x.indexCount,
615
+ vertexCount: x.vertexCount,
616
+ bytes: x.bytes,
617
+ decodeAABB: x.decodeAABB || null,
618
+ })),
619
+ })),
620
+ textures: texLODs.map((tl) => ({
621
+ textureIndex: tl.textureIndex,
622
+ name: tl.name,
623
+ lods: tl.lods.map((x) => ({
624
+ width: x.width,
625
+ path: x.path,
626
+ inline: !!x.inline,
627
+ bytes: x.bytes,
628
+ })),
629
+ })),
630
+ };
631
+
632
+ rootRoot.setExtras({ ...rootRoot.getExtras(), LOCAL_progressive: extPayload });
633
+
634
+ // Drop orphaned accessors/bufferviews left over after swapping in the lowest LOD geometry.
635
+ await rootDoc.transform(prune(), dedup());
636
+
637
+ const rootBin = await io.writeBinary(rootDoc);
638
+ const rootOut = path.join(OUT_DIR, 'model.progressive.glb');
639
+ await writeFile(rootOut, rootBin);
640
+
641
+ // Splice the preserved passthrough extensions (VRM etc.) back into the
642
+ // root GLB's JSON chunk. gltf-transform's writer doesn't know about them
643
+ // so it would have dropped them during the round-trip.
644
+ if (Object.keys(passthroughBlob).length) {
645
+ await rewriteGlbJson(rootOut, (j) => {
646
+ j.extensions = { ...(j.extensions || {}), ...passthroughBlob };
647
+ const used = new Set([...(j.extensionsUsed || []), ...sourceExtensionsUsed]);
648
+ j.extensionsUsed = [...used];
649
+ if (sourceExtensionsRequired.length) {
650
+ const req = new Set([...(j.extensionsRequired || []), ...sourceExtensionsRequired]);
651
+ j.extensionsRequired = [...req];
652
+ }
653
+ });
654
+ }
655
+ const rootSize = (await stat(rootOut)).size;
656
+ const origSize = (await stat(INPUT)).size;
657
+ console.log(`\n[bake] root: ${rootOut}`);
658
+ console.log(`[bake] root size : ${(rootSize/1024/1024).toFixed(2)} MB`);
659
+ console.log(`[bake] orig size : ${(origSize/1024/1024).toFixed(2)} MB`);
660
+ console.log(`[bake] saving on initial load: ${(100 - (rootSize/origSize)*100).toFixed(1)}%`);
661
+ }
662
+
663
+ main().catch((e) => { console.error(e); process.exit(1); });