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,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); });
|