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,453 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase 2 streaming baker.
|
|
3
|
+
// Produces a single model.streaming.glb whose binary chunk packs every LOD
|
|
4
|
+
// (mesh attributes + texture images) as independent regions referenced by
|
|
5
|
+
// glTF bufferViews. The default scene displays the lowest LOD; an extras
|
|
6
|
+
// payload `LOCAL_streaming` maps each (meshIndex, primIndex, lodLevel) to
|
|
7
|
+
// the exact bufferView indices for indices/POSITION/NORMAL/UV/etc., and
|
|
8
|
+
// each texture LOD to a bufferView.
|
|
9
|
+
//
|
|
10
|
+
// The runtime opens this file with a custom loader that fetches:
|
|
11
|
+
// 1. The first 12 bytes (header) + 8 bytes (JSON chunk header) + the JSON
|
|
12
|
+
// chunk via one Range request to learn all bufferView byteOffsets.
|
|
13
|
+
// 2. ONLY the bufferViews for the currently-needed LOD via subsequent
|
|
14
|
+
// Range requests. The browser never downloads higher-LOD bytes unless
|
|
15
|
+
// they cross the density threshold.
|
|
16
|
+
//
|
|
17
|
+
// Because GLB 2.0 allows exactly one BIN chunk per file, we pack all LODs
|
|
18
|
+
// into that single chunk and use bufferView byteOffsets to address them.
|
|
19
|
+
|
|
20
|
+
import { NodeIO, BufferUtils } from '@gltf-transform/core';
|
|
21
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
22
|
+
import { simplify, cloneDocument, prune, dedup } from '@gltf-transform/functions';
|
|
23
|
+
import { MeshoptSimplifier } from 'meshoptimizer';
|
|
24
|
+
import sharp from 'sharp';
|
|
25
|
+
import { mkdir, writeFile, rm, stat } from 'node:fs/promises';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const repoRoot = path.resolve(__dirname, '..');
|
|
31
|
+
|
|
32
|
+
const INPUT = process.argv[2] || path.join(repoRoot, 'model.glb');
|
|
33
|
+
const OUT_DIR = process.argv[3] || path.join(repoRoot, 'examples/local-progressive/output');
|
|
34
|
+
|
|
35
|
+
const MESH_LOD_RATIOS = [1.0, 0.4, 0.15, 0.04];
|
|
36
|
+
const TEX_LOD_SIZES = [2048, 1024, 512, 256, 128];
|
|
37
|
+
|
|
38
|
+
// Helper: write a GLB blob given JSON object + concatenated BIN bytes.
|
|
39
|
+
function writeGlbBlob(json, binBytes) {
|
|
40
|
+
const jsonStr = JSON.stringify(json);
|
|
41
|
+
// GLB requires JSON chunk length be multiple of 4 (pad with spaces) and BIN multiple of 4 (pad with zeros).
|
|
42
|
+
const jsonBuf = new TextEncoder().encode(jsonStr);
|
|
43
|
+
const jsonPad = (4 - (jsonBuf.length % 4)) % 4;
|
|
44
|
+
const binPad = (4 - (binBytes.length % 4)) % 4;
|
|
45
|
+
const jsonChunkLen = jsonBuf.length + jsonPad;
|
|
46
|
+
const binChunkLen = binBytes.length + binPad;
|
|
47
|
+
const total = 12 + 8 + jsonChunkLen + 8 + binChunkLen;
|
|
48
|
+
const out = new Uint8Array(total);
|
|
49
|
+
const dv = new DataView(out.buffer);
|
|
50
|
+
// Header
|
|
51
|
+
dv.setUint32(0, 0x46546C67, true); // 'glTF'
|
|
52
|
+
dv.setUint32(4, 2, true);
|
|
53
|
+
dv.setUint32(8, total, true);
|
|
54
|
+
// JSON chunk
|
|
55
|
+
dv.setUint32(12, jsonChunkLen, true);
|
|
56
|
+
dv.setUint32(16, 0x4E4F534A, true); // 'JSON'
|
|
57
|
+
out.set(jsonBuf, 20);
|
|
58
|
+
for (let i = 0; i < jsonPad; i++) out[20 + jsonBuf.length + i] = 0x20;
|
|
59
|
+
// BIN chunk
|
|
60
|
+
const binChunkStart = 20 + jsonChunkLen;
|
|
61
|
+
dv.setUint32(binChunkStart, binChunkLen, true);
|
|
62
|
+
dv.setUint32(binChunkStart + 4, 0x004E4942, true); // 'BIN\0'
|
|
63
|
+
out.set(binBytes, binChunkStart + 8);
|
|
64
|
+
// tail already zero
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function loadAttrBytes(io, doc) {
|
|
69
|
+
// Serialize the doc to a GLB blob and read attributes from there is overkill.
|
|
70
|
+
// Instead, use gltf-transform accessors directly.
|
|
71
|
+
const meshes = doc.getRoot().listMeshes();
|
|
72
|
+
const result = [];
|
|
73
|
+
for (let mi = 0; mi < meshes.length; mi++) {
|
|
74
|
+
const prims = meshes[mi].listPrimitives();
|
|
75
|
+
for (let pi = 0; pi < prims.length; pi++) {
|
|
76
|
+
const prim = prims[pi];
|
|
77
|
+
const entry = { meshIndex: mi, primIndex: pi, attrs: {}, indices: null, semantics: prim.listSemantics() };
|
|
78
|
+
const idx = prim.getIndices();
|
|
79
|
+
if (idx) {
|
|
80
|
+
entry.indices = {
|
|
81
|
+
componentType: gltfComponentType(idx.getArray()),
|
|
82
|
+
count: idx.getCount(),
|
|
83
|
+
bytes: toUint8(idx.getArray()),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
for (const sem of entry.semantics) {
|
|
87
|
+
const a = prim.getAttribute(sem);
|
|
88
|
+
entry.attrs[sem] = {
|
|
89
|
+
type: a.getType(),
|
|
90
|
+
componentType: gltfComponentType(a.getArray()),
|
|
91
|
+
count: a.getCount(),
|
|
92
|
+
bytes: toUint8(a.getArray()),
|
|
93
|
+
min: a.getMin(new Array(typeNumComponents(a.getType())).fill(0)).slice(),
|
|
94
|
+
max: a.getMax(new Array(typeNumComponents(a.getType())).fill(0)).slice(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
result.push(entry);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function gltfComponentType(typedArray) {
|
|
104
|
+
if (typedArray instanceof Int8Array) return 5120;
|
|
105
|
+
if (typedArray instanceof Uint8Array) return 5121;
|
|
106
|
+
if (typedArray instanceof Int16Array) return 5122;
|
|
107
|
+
if (typedArray instanceof Uint16Array) return 5123;
|
|
108
|
+
if (typedArray instanceof Uint32Array) return 5125;
|
|
109
|
+
if (typedArray instanceof Float32Array) return 5126;
|
|
110
|
+
throw new Error('unknown typed array');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toUint8(typedArray) {
|
|
114
|
+
return new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function typeNumComponents(type) {
|
|
118
|
+
return { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }[type];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const CT_SIZE = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
122
|
+
|
|
123
|
+
async function main() {
|
|
124
|
+
console.log(`[bake-streaming] input : ${INPUT}`);
|
|
125
|
+
console.log(`[bake-streaming] output : ${OUT_DIR}`);
|
|
126
|
+
|
|
127
|
+
await mkdir(OUT_DIR, { recursive: true });
|
|
128
|
+
await MeshoptSimplifier.ready;
|
|
129
|
+
|
|
130
|
+
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
131
|
+
const sourceDoc = await io.read(INPUT);
|
|
132
|
+
|
|
133
|
+
// Step 1: build all LOD attribute byte arrays.
|
|
134
|
+
const perPrimLODs = []; // [{ meshIndex, primIndex, lods: [{ ratio, attrs, indices, semantics }] }]
|
|
135
|
+
const sourcePrims = await loadAttrBytes(io, sourceDoc);
|
|
136
|
+
|
|
137
|
+
for (const srcPrim of sourcePrims) {
|
|
138
|
+
const entry = { meshIndex: srcPrim.meshIndex, primIndex: srcPrim.primIndex, lods: [] };
|
|
139
|
+
for (const ratio of MESH_LOD_RATIOS) {
|
|
140
|
+
let lodAttrs, lodIndices, semantics;
|
|
141
|
+
if (ratio === 1.0) {
|
|
142
|
+
lodAttrs = srcPrim.attrs;
|
|
143
|
+
lodIndices = srcPrim.indices;
|
|
144
|
+
semantics = srcPrim.semantics;
|
|
145
|
+
} else {
|
|
146
|
+
const cloneDoc = cloneDocument(sourceDoc);
|
|
147
|
+
const cMesh = cloneDoc.getRoot().listMeshes()[srcPrim.meshIndex];
|
|
148
|
+
const cPrims = cMesh.listPrimitives();
|
|
149
|
+
cPrims.forEach((p, i) => { if (i !== srcPrim.primIndex) cMesh.removePrimitive(p); });
|
|
150
|
+
await cloneDoc.transform(simplify({ simplifier: MeshoptSimplifier, ratio, error: 0.001, lockBorder: false }));
|
|
151
|
+
const decoded = await loadAttrBytes(io, cloneDoc);
|
|
152
|
+
const dPrim = decoded[0];
|
|
153
|
+
lodAttrs = dPrim.attrs;
|
|
154
|
+
lodIndices = dPrim.indices;
|
|
155
|
+
semantics = dPrim.semantics;
|
|
156
|
+
}
|
|
157
|
+
entry.lods.push({ ratio, attrs: lodAttrs, indices: lodIndices, semantics });
|
|
158
|
+
const idxCount = lodIndices?.count ?? 0;
|
|
159
|
+
const vCount = lodAttrs[semantics[0]]?.count ?? 0;
|
|
160
|
+
console.log(`[bake-streaming] mesh ${srcPrim.meshIndex} prim ${srcPrim.primIndex} ratio=${ratio} idx=${idxCount} verts=${vCount}`);
|
|
161
|
+
}
|
|
162
|
+
perPrimLODs.push(entry);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step 2: bake texture LOD bytes.
|
|
166
|
+
const perTexLODs = []; // [{ textureIndex, name, lods: [{ width, bytes, mime }] }]
|
|
167
|
+
const textures = sourceDoc.getRoot().listTextures();
|
|
168
|
+
for (let ti = 0; ti < textures.length; ti++) {
|
|
169
|
+
const tex = textures[ti];
|
|
170
|
+
const name = tex.getName() || `tex_${ti}`;
|
|
171
|
+
const img = tex.getImage();
|
|
172
|
+
if (!img) continue;
|
|
173
|
+
const meta = await sharp(Buffer.from(img)).metadata();
|
|
174
|
+
const sizes = TEX_LOD_SIZES.filter((s) => s <= Math.max(meta.width, meta.height));
|
|
175
|
+
if (sizes.length === 0) sizes.push(Math.max(meta.width, meta.height));
|
|
176
|
+
const lods = [];
|
|
177
|
+
for (const sz of sizes) {
|
|
178
|
+
const buf = await sharp(Buffer.from(img))
|
|
179
|
+
.resize(sz, sz, { fit: 'inside', withoutEnlargement: true })
|
|
180
|
+
.webp({ quality: 82 })
|
|
181
|
+
.toBuffer();
|
|
182
|
+
lods.push({ width: sz, bytes: new Uint8Array(buf), mime: 'image/webp' });
|
|
183
|
+
}
|
|
184
|
+
perTexLODs.push({ textureIndex: ti, name, lods });
|
|
185
|
+
console.log(`[bake-streaming] tex ${ti} (${name}): ${lods.length} sizes`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Step 3: build the GLB JSON + binary, packing every LOD as bufferViews.
|
|
189
|
+
// We reuse most of the source JSON (materials, samplers, scene graph, the
|
|
190
|
+
// original mesh primitive with its material reference) but rewrite the
|
|
191
|
+
// primitive's accessors to point at the LOWEST LOD initially.
|
|
192
|
+
// Higher LODs get extra accessors+bufferViews that aren't referenced by any
|
|
193
|
+
// node — they live in the file purely to be range-fetched.
|
|
194
|
+
|
|
195
|
+
// Round-trip the source through gltf-transform to a fresh GLB blob first so
|
|
196
|
+
// we get baseline JSON we can extend.
|
|
197
|
+
const baseGlb = await io.writeBinary(sourceDoc);
|
|
198
|
+
// Parse it back out to JSON + BIN.
|
|
199
|
+
const baseJson = extractGlbJson(baseGlb);
|
|
200
|
+
|
|
201
|
+
// Extract the original BIN chunk from baseGlb so we can re-pack referenced
|
|
202
|
+
// ancillary accessors (skin inverse-bind matrices, animation samplers).
|
|
203
|
+
const baseBin = (() => {
|
|
204
|
+
const dv = new DataView(baseGlb.buffer, baseGlb.byteOffset, baseGlb.byteLength);
|
|
205
|
+
const jLen = dv.getUint32(12, true);
|
|
206
|
+
const binChunkStart = 20 + jLen;
|
|
207
|
+
const bLen = dv.getUint32(binChunkStart, true);
|
|
208
|
+
return new Uint8Array(baseGlb.buffer, baseGlb.byteOffset + binChunkStart + 8, bLen);
|
|
209
|
+
})();
|
|
210
|
+
|
|
211
|
+
// Build new bufferViews + accessors per-LOD, appending bytes to a single BIN array.
|
|
212
|
+
const binParts = []; // [{ bytes, alignment }]
|
|
213
|
+
let binCursor = 0;
|
|
214
|
+
const newBufferViews = [];
|
|
215
|
+
const newAccessors = [];
|
|
216
|
+
|
|
217
|
+
function pushBytes(bytes, alignment = 4) {
|
|
218
|
+
const pad = (alignment - (binCursor % alignment)) % alignment;
|
|
219
|
+
if (pad) {
|
|
220
|
+
binParts.push(new Uint8Array(pad));
|
|
221
|
+
binCursor += pad;
|
|
222
|
+
}
|
|
223
|
+
const offset = binCursor;
|
|
224
|
+
binParts.push(bytes);
|
|
225
|
+
binCursor += bytes.byteLength;
|
|
226
|
+
return offset;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function addBufferView(byteOffset, byteLength, target) {
|
|
230
|
+
const bv = { buffer: 0, byteOffset, byteLength };
|
|
231
|
+
if (target) bv.target = target;
|
|
232
|
+
newBufferViews.push(bv);
|
|
233
|
+
return newBufferViews.length - 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function addAccessor({ bufferView, componentType, count, type, min, max, byteOffset = 0, normalized = false }) {
|
|
237
|
+
const a = { bufferView, componentType, count, type };
|
|
238
|
+
if (byteOffset) a.byteOffset = byteOffset;
|
|
239
|
+
if (normalized) a.normalized = true;
|
|
240
|
+
if (min) a.min = min;
|
|
241
|
+
if (max) a.max = max;
|
|
242
|
+
newAccessors.push(a);
|
|
243
|
+
return newAccessors.length - 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Repack an accessor from baseJson by copying its bufferView bytes into our
|
|
247
|
+
// new BIN and emitting fresh accessor + bufferView records. Returns the new
|
|
248
|
+
// accessor index, or -1 if the source accessor doesn't exist.
|
|
249
|
+
function repackAccessor(oldIndex) {
|
|
250
|
+
const oldAcc = baseJson.accessors?.[oldIndex];
|
|
251
|
+
if (!oldAcc) return -1;
|
|
252
|
+
const oldBv = baseJson.bufferViews?.[oldAcc.bufferView];
|
|
253
|
+
if (!oldBv) return -1;
|
|
254
|
+
const componentSize = CT_SIZE[oldAcc.componentType];
|
|
255
|
+
const numComponents = typeNumComponents(oldAcc.type);
|
|
256
|
+
const elementSize = componentSize * numComponents;
|
|
257
|
+
// Accessor.byteOffset is offset within the bufferView; bufferView.byteOffset is offset within BIN.
|
|
258
|
+
const sliceStart = (oldBv.byteOffset || 0) + (oldAcc.byteOffset || 0);
|
|
259
|
+
const sliceLen = oldAcc.count * elementSize;
|
|
260
|
+
const slice = baseBin.subarray(sliceStart, sliceStart + sliceLen);
|
|
261
|
+
const newOff = pushBytes(slice, Math.max(4, elementSize));
|
|
262
|
+
const newBv = addBufferView(newOff, slice.byteLength);
|
|
263
|
+
return addAccessor({
|
|
264
|
+
bufferView: newBv,
|
|
265
|
+
componentType: oldAcc.componentType,
|
|
266
|
+
count: oldAcc.count,
|
|
267
|
+
type: oldAcc.type,
|
|
268
|
+
min: oldAcc.min,
|
|
269
|
+
max: oldAcc.max,
|
|
270
|
+
normalized: oldAcc.normalized,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Pack per-primitive LODs.
|
|
275
|
+
const lodMap = []; // [{ meshIndex, primIndex, lods: [{ ratio, indicesAcc, attrAccs: { POSITION: idx, ... } }] }]
|
|
276
|
+
for (const entry of perPrimLODs) {
|
|
277
|
+
const entryRecord = { meshIndex: entry.meshIndex, primIndex: entry.primIndex, lods: [] };
|
|
278
|
+
for (const lod of entry.lods) {
|
|
279
|
+
const lodRecord = { ratio: lod.ratio, attrAccs: {} };
|
|
280
|
+
if (lod.indices) {
|
|
281
|
+
const off = pushBytes(lod.indices.bytes, 4);
|
|
282
|
+
const bv = addBufferView(off, lod.indices.bytes.byteLength, 34963); // ELEMENT_ARRAY_BUFFER
|
|
283
|
+
lodRecord.indicesAcc = addAccessor({
|
|
284
|
+
bufferView: bv, componentType: lod.indices.componentType, count: lod.indices.count, type: 'SCALAR'
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
for (const sem of lod.semantics) {
|
|
288
|
+
const attr = lod.attrs[sem];
|
|
289
|
+
const off = pushBytes(attr.bytes, 4);
|
|
290
|
+
const bv = addBufferView(off, attr.bytes.byteLength, 34962); // ARRAY_BUFFER
|
|
291
|
+
lodRecord.attrAccs[sem] = addAccessor({
|
|
292
|
+
bufferView: bv, componentType: attr.componentType, count: attr.count, type: attr.type, min: attr.min, max: attr.max,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
entryRecord.lods.push(lodRecord);
|
|
296
|
+
}
|
|
297
|
+
lodMap.push(entryRecord);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Pack texture LODs.
|
|
301
|
+
const texMap = []; // [{ textureIndex, name, lods: [{ width, bufferView, mime, byteOffset, byteLength }] }]
|
|
302
|
+
for (const tex of perTexLODs) {
|
|
303
|
+
const rec = { textureIndex: tex.textureIndex, name: tex.name, lods: [] };
|
|
304
|
+
for (const lod of tex.lods) {
|
|
305
|
+
const off = pushBytes(lod.bytes, 4);
|
|
306
|
+
const bv = addBufferView(off, lod.bytes.byteLength);
|
|
307
|
+
rec.lods.push({ width: lod.width, bufferView: bv, mime: lod.mime, byteOffset: off, byteLength: lod.bytes.byteLength });
|
|
308
|
+
}
|
|
309
|
+
texMap.push(rec);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Build the final glTF JSON.
|
|
313
|
+
// Strategy: start from a minimal subset of baseJson, rewrite mesh primitives
|
|
314
|
+
// to reference the LOWEST LOD's accessors as the initial render-state.
|
|
315
|
+
const finalJson = JSON.parse(JSON.stringify(baseJson));
|
|
316
|
+
finalJson.bufferViews = newBufferViews;
|
|
317
|
+
finalJson.accessors = newAccessors;
|
|
318
|
+
finalJson.buffers = [{ byteLength: binCursor }];
|
|
319
|
+
|
|
320
|
+
// Rewrite primitive attribute pointers to lowest LOD (last in our ratios list).
|
|
321
|
+
for (const rec of lodMap) {
|
|
322
|
+
const mesh = finalJson.meshes[rec.meshIndex];
|
|
323
|
+
const prim = mesh.primitives[rec.primIndex];
|
|
324
|
+
const lowest = rec.lods[rec.lods.length - 1];
|
|
325
|
+
prim.attributes = {};
|
|
326
|
+
for (const sem of Object.keys(lowest.attrAccs)) {
|
|
327
|
+
prim.attributes[sem] = lowest.attrAccs[sem];
|
|
328
|
+
}
|
|
329
|
+
if (lowest.indicesAcc != null) prim.indices = lowest.indicesAcc;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Rewrite each top-level texture/image to reference the smallest LOD by default.
|
|
333
|
+
finalJson.images = texMap.map((tx) => {
|
|
334
|
+
const smallest = tx.lods[tx.lods.length - 1];
|
|
335
|
+
return {
|
|
336
|
+
bufferView: smallest.bufferView,
|
|
337
|
+
mimeType: smallest.mime,
|
|
338
|
+
name: tx.name,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
// Textures array index map remains as in source; ensure each texture entry references the corresponding image index.
|
|
342
|
+
finalJson.textures = (finalJson.textures || []).map((t, i) => ({ ...t, source: i }));
|
|
343
|
+
|
|
344
|
+
// Attach the streaming descriptor.
|
|
345
|
+
finalJson.extras = finalJson.extras || {};
|
|
346
|
+
finalJson.extras.LOCAL_streaming = {
|
|
347
|
+
version: 1,
|
|
348
|
+
meshes: lodMap.map((rec) => ({
|
|
349
|
+
meshIndex: rec.meshIndex,
|
|
350
|
+
primIndex: rec.primIndex,
|
|
351
|
+
lods: rec.lods.map((l) => ({
|
|
352
|
+
ratio: l.ratio,
|
|
353
|
+
indicesAcc: l.indicesAcc,
|
|
354
|
+
attrAccs: l.attrAccs,
|
|
355
|
+
})),
|
|
356
|
+
})),
|
|
357
|
+
textures: texMap.map((rec) => ({
|
|
358
|
+
textureIndex: rec.textureIndex,
|
|
359
|
+
name: rec.name,
|
|
360
|
+
lods: rec.lods.map((l) => ({
|
|
361
|
+
width: l.width,
|
|
362
|
+
bufferView: l.bufferView,
|
|
363
|
+
mime: l.mime,
|
|
364
|
+
byteOffset: l.byteOffset,
|
|
365
|
+
byteLength: l.byteLength,
|
|
366
|
+
})),
|
|
367
|
+
})),
|
|
368
|
+
// Mirror bufferView byte ranges so the runtime doesn't need to re-derive them.
|
|
369
|
+
bufferViewRanges: newBufferViews.map((bv) => ({ byteOffset: bv.byteOffset, byteLength: bv.byteLength })),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Re-pack skin inverse-bind matrices (each skin has one IBM accessor)
|
|
373
|
+
// and animation sampler input/output accessors so the new JSON is valid.
|
|
374
|
+
if (baseJson.skins?.length) {
|
|
375
|
+
finalJson.skins = baseJson.skins.map((s) => {
|
|
376
|
+
const out = { ...s };
|
|
377
|
+
if (s.inverseBindMatrices != null) {
|
|
378
|
+
const ni = repackAccessor(s.inverseBindMatrices);
|
|
379
|
+
if (ni >= 0) out.inverseBindMatrices = ni; else delete out.inverseBindMatrices;
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
delete finalJson.skins;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (baseJson.animations?.length) {
|
|
388
|
+
finalJson.animations = baseJson.animations.map((anim) => {
|
|
389
|
+
const out = { ...anim };
|
|
390
|
+
out.samplers = anim.samplers.map((sm) => {
|
|
391
|
+
const ns = { ...sm };
|
|
392
|
+
const ni = repackAccessor(sm.input);
|
|
393
|
+
const no = repackAccessor(sm.output);
|
|
394
|
+
if (ni >= 0) ns.input = ni;
|
|
395
|
+
if (no >= 0) ns.output = no;
|
|
396
|
+
return ns;
|
|
397
|
+
});
|
|
398
|
+
out.channels = anim.channels.map((ch) => ({ ...ch }));
|
|
399
|
+
return out;
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
402
|
+
delete finalJson.animations;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Nodes carrying skin references stay as-is. The mesh primitive's
|
|
406
|
+
// JOINTS_0/WEIGHTS_0 attributes are already wired to lowest-LOD accessors
|
|
407
|
+
// and the skinned-mesh runtime will rebuild them at higher LODs.
|
|
408
|
+
|
|
409
|
+
const binConcat = BufferUtils.concat(binParts);
|
|
410
|
+
const glb = writeGlbBlob(finalJson, binConcat);
|
|
411
|
+
const outPath = path.join(OUT_DIR, 'model.streaming.glb');
|
|
412
|
+
await writeFile(outPath, glb);
|
|
413
|
+
const origSize = (await stat(INPUT)).size;
|
|
414
|
+
console.log(`\n[bake-streaming] wrote ${outPath}`);
|
|
415
|
+
console.log(`[bake-streaming] file size : ${(glb.length/1024/1024).toFixed(2)} MB`);
|
|
416
|
+
console.log(`[bake-streaming] orig size : ${(origSize/1024/1024).toFixed(2)} MB`);
|
|
417
|
+
|
|
418
|
+
// Compute the byte range needed for the initial fetch: header(12) + json-header(8) + JSON.
|
|
419
|
+
// Also compute total bytes used by lowest-LOD bufferViews to predict initial bandwidth.
|
|
420
|
+
const jsonChunkLen = (() => {
|
|
421
|
+
const dv = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
|
|
422
|
+
return dv.getUint32(12, true);
|
|
423
|
+
})();
|
|
424
|
+
const jsonEnd = 20 + jsonChunkLen;
|
|
425
|
+
console.log(`[bake-streaming] header+json bytes : ${jsonEnd} (${(jsonEnd/1024).toFixed(1)} KB)`);
|
|
426
|
+
|
|
427
|
+
let lowestBytes = 0;
|
|
428
|
+
for (const rec of lodMap) {
|
|
429
|
+
const lowest = rec.lods[rec.lods.length - 1];
|
|
430
|
+
if (lowest.indicesAcc != null) lowestBytes += newBufferViews[newAccessors[lowest.indicesAcc].bufferView].byteLength;
|
|
431
|
+
for (const accIdx of Object.values(lowest.attrAccs)) {
|
|
432
|
+
lowestBytes += newBufferViews[newAccessors[accIdx].bufferView].byteLength;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const rec of texMap) {
|
|
436
|
+
const smallest = rec.lods[rec.lods.length - 1];
|
|
437
|
+
lowestBytes += smallest.byteLength;
|
|
438
|
+
}
|
|
439
|
+
console.log(`[bake-streaming] lowest LOD bytes : ${lowestBytes} (${(lowestBytes/1024).toFixed(1)} KB)`);
|
|
440
|
+
console.log(`[bake-streaming] minimum first-paint fetch ≈ ${((jsonEnd + lowestBytes)/1024).toFixed(1)} KB`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function extractGlbJson(glb) {
|
|
444
|
+
const dv = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
|
|
445
|
+
if (dv.getUint32(0, true) !== 0x46546C67) throw new Error('not a GLB');
|
|
446
|
+
const jsonLen = dv.getUint32(12, true);
|
|
447
|
+
const jsonType = dv.getUint32(16, true);
|
|
448
|
+
if (jsonType !== 0x4E4F534A) throw new Error('expected JSON chunk first');
|
|
449
|
+
const jsonBytes = new Uint8Array(glb.buffer, glb.byteOffset + 20, jsonLen);
|
|
450
|
+
return JSON.parse(new TextDecoder().decode(jsonBytes));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|