three-vat 4.1.0 → 4.2.0

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/README.md CHANGED
@@ -91,8 +91,10 @@ says why. [The rig encoding](./docs/usage.md#the-rig-encoding-encoding-rig).
91
91
  <details>
92
92
  <summary><b>Does it work with my model?</b></summary>
93
93
 
94
- If `GLTFLoader` loads it and it has an `AnimationClip`, yes — the four shapes
95
- listed in the snippet above, and any mix of them, through that one call.
94
+ If `GLTFLoader` or `FBXLoader` loads it and it has an `AnimationClip`, yes — the
95
+ four shapes listed in the snippet above, and any mix of them, through that one
96
+ call. Run an FBX mesh's geometry through `mergeVertices` first: the loader never
97
+ indexes it ([Loading FBX](./docs/usage.md#loading-fbx)).
96
98
 
97
99
  The bake unit is the **subtree**, not the mesh
98
100
  ([ADR-0008](./docs/adr/0008-a-vat-bakes-a-posed-subtree-not-a-skinnedmesh.md)),
@@ -103,7 +105,8 @@ Positions bake exactly under any rig; normals match what three's own skinning
103
105
  shader draws, which is approximate under non-uniform bone scale — `bakeVAT`
104
106
  warns once and names the bone. A rest-pose track such as Mixamo's `TPose` bakes
105
107
  to a frozen band and reports it as a near-zero `clip.maxDelta`: filter those out
106
- of `gltf.animations` rather than spending texture rows on them.
108
+ of `gltf.animations` rather than spending texture rows on them, as you would
109
+ the empty `Take 001` a Mixamo FBX carries.
107
110
 
108
111
  </details>
109
112
 
@@ -137,6 +140,22 @@ which is the finer grain anyway.
137
140
 
138
141
  </details>
139
142
 
143
+ <details>
144
+ <summary><b>Playing backwards</b></summary>
145
+
146
+ A negative rate plays a clip backwards, from the band already baked, with
147
+ nothing extra in the texture. Two spellings:
148
+
149
+ - **One instance:** `speed: -1` on its entry, or on a `setVATInstance` write.
150
+ - **Every instance of a clip:** `action.timeScale = -1` on the action handed to
151
+ `bakeVAT`, the way three already spells it. An instance's own `speed`
152
+ still overrides it, in either direction.
153
+
154
+ A reversed one-shot starts on its last frame, with no `action.time` to move
155
+ first. [Playing backwards](./docs/usage.md#playing-backwards).
156
+
157
+ </details>
158
+
140
159
  <details>
141
160
  <summary><b>Upgrading from 1.x</b></summary>
142
161
 
@@ -179,18 +198,28 @@ The bake is CPU work done once at load. Under the vertex encoding that is about
179
198
  many clips; the rig encoding bakes several to a few hundred times faster. It
180
199
  never touches the renderer, so `bakeVATInWorker` runs it in a Web Worker as-is.
181
200
 
201
+ Or bake once at build time, with `three-vat bake --out`, and `loadVAT` reads
202
+ the **baked file** back as the VAT `bakeVAT` would have returned:
203
+
204
+ ```js
205
+ import { loadVAT } from 'three-vat'
206
+
207
+ const vat = await loadVAT('/model.vat.glb')
208
+ const { mesh, time } = createVATMesh(vat, instances)
209
+ ```
210
+
182
211
  **[docs/usage.md](./docs/usage.md)** has the measured bake-cost table, the
183
- worker bake, the draw-call arithmetic, and the primitives underneath
184
- `createVATMesh` for when you are not rendering onto a plain `InstancedMesh`.
212
+ worker bake, [baking at build time](./docs/usage.md#baking-at-build-time-loadvat),
213
+ the draw-call arithmetic, and the primitives underneath `createVATMesh` for
214
+ when you are not rendering onto a plain `InstancedMesh`.
185
215
 
186
216
  </details>
187
217
 
188
218
  <details>
189
219
  <summary><b>What it does not do</b></summary>
190
220
 
191
- No LOD, no baking CLI or file format, no React/drei binding, glTF input
192
- only. Each is a decision rather than a
193
- gap, and each is written up with its reasoning in
221
+ No LOD, no React/drei binding, glTF and FBX input only. Each is a decision
222
+ rather than a gap, and each is written up with its reasoning in
194
223
  **[docs/usage.md](./docs/usage.md#what-10-does-not-do)**, alongside the
195
224
  trade-offs against `SkinnedMesh` and bone-texture instancing.
196
225
 
package/dist/bin.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/bin.js ADDED
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env node
2
+ import { writeBakedFile, readSourceImages, pbrConversions, dataURI } from './chunk-7TT7B5WS.js';
3
+ import { bakeVAT } from './chunk-P6AFXEBT.js';
4
+ import { EndMode, INFINITE_REPETITIONS, LoopMode } from './chunk-4IKOXUSX.js';
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
6
+ import { dirname } from 'path';
7
+ import process, { cwd, argv, stderr, stdout } from 'process';
8
+ import { AnimationMixer, LoopPingPong, LoopOnce, LoopRepeat, LoadingManager, Texture, InterleavedBufferAttribute } from 'three';
9
+ import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
10
+ import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
11
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
12
+ import { mergeVertices, deinterleaveAttribute } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
13
+
14
+ var EXIT_OK = 0;
15
+ var EXIT_REFUSED = 1;
16
+ var EXIT_USAGE = 2;
17
+ var EXIT_CRASH = 3;
18
+ var CONFIG_NAME = "vat.config.json";
19
+ var USAGE = `usage: three-vat bake <input.glb|input.gltf|input.fbx> [flags]
20
+
21
+ --fps <n> sample rate, default 30
22
+ --clips <name,...> the clips to bake, default every clip in the file
23
+ --encoding <auto|rig|delta>
24
+ --max-texture-size <n> the target GPU's largest texture dimension
25
+ --no-normals drop the vertex encoding's normal layer
26
+ --merge-flat-materials collapse materials that differ only in colour
27
+ --max-bytes <n> fail when the VAT's textures exceed this many bytes
28
+ --config <path> a vat.config.json, default the one beside the input
29
+ --out <path> also write a baked file, for loadVAT to load
30
+ `;
31
+ var CommandError = class extends Error {
32
+ constructor(message, code, showUsage = false) {
33
+ super(message);
34
+ this.code = code;
35
+ this.showUsage = showUsage;
36
+ }
37
+ };
38
+ var usage = (message) => new CommandError(`three-vat: ${message}`, EXIT_USAGE);
39
+ var ENCODINGS = ["auto", "rig", "delta"];
40
+ function positiveInteger(name, raw) {
41
+ const value = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : raw;
42
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
43
+ throw usage(`${name} takes a positive integer, not ${JSON.stringify(raw)}`);
44
+ }
45
+ return value;
46
+ }
47
+ function positiveNumber(name, raw) {
48
+ const value = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : raw;
49
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
50
+ throw usage(`${name} takes a positive number, not ${JSON.stringify(raw)}`);
51
+ }
52
+ return value;
53
+ }
54
+ function encodingOf(name, raw) {
55
+ if (!ENCODINGS.includes(raw)) {
56
+ throw usage(`${name} takes ${ENCODINGS.join(", ")}, not ${JSON.stringify(raw)}`);
57
+ }
58
+ return raw;
59
+ }
60
+ function parseArgs(argv2) {
61
+ const [command, ...rest] = argv2;
62
+ if (command !== "bake") {
63
+ throw usage(command === void 0 ? "no command given" : `unknown command "${command}"; the one command is "bake"`);
64
+ }
65
+ const flags = {};
66
+ let input;
67
+ let config;
68
+ let out;
69
+ for (let i = 0; i < rest.length; i++) {
70
+ const arg = rest[i];
71
+ if (!arg.startsWith("--")) {
72
+ if (input !== void 0) throw usage(`one input at a time: "${input}", then "${arg}"`);
73
+ input = arg;
74
+ continue;
75
+ }
76
+ const eq = arg.indexOf("=");
77
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
78
+ const value = () => {
79
+ if (eq !== -1) return arg.slice(eq + 1);
80
+ const next = rest[++i];
81
+ if (next === void 0) throw usage(`${flag} takes a value`);
82
+ return next;
83
+ };
84
+ const bare = () => {
85
+ if (eq !== -1) throw usage(`${flag} takes no value`);
86
+ };
87
+ switch (flag) {
88
+ case "--fps":
89
+ flags.fps = positiveNumber(flag, value());
90
+ break;
91
+ case "--clips":
92
+ flags.clips = value().split(",").map((name) => name.trim()).filter((name) => name !== "");
93
+ if (flags.clips.length === 0) throw usage("--clips names no clip");
94
+ break;
95
+ case "--encoding":
96
+ flags.encoding = encodingOf(flag, value());
97
+ break;
98
+ case "--max-texture-size":
99
+ flags.maxTextureSize = positiveInteger(flag, value());
100
+ break;
101
+ case "--no-normals":
102
+ bare();
103
+ flags.bakeNormals = false;
104
+ break;
105
+ case "--merge-flat-materials":
106
+ bare();
107
+ flags.mergeFlatMaterials = true;
108
+ break;
109
+ case "--max-bytes":
110
+ flags.maxBytes = positiveInteger(flag, value());
111
+ break;
112
+ case "--config":
113
+ config = value();
114
+ break;
115
+ case "--out":
116
+ out = value();
117
+ break;
118
+ default:
119
+ throw usage(`unknown flag ${flag}`);
120
+ }
121
+ }
122
+ if (input === void 0) throw usage("bake takes an input file");
123
+ return { input, config, out, flags };
124
+ }
125
+ var CONFIG_KEYS = [
126
+ "fps",
127
+ "clips",
128
+ "encoding",
129
+ "maxTextureSize",
130
+ "bakeNormals",
131
+ "mergeFlatMaterials",
132
+ "maxBytes",
133
+ "clipDefaults"
134
+ ];
135
+ var CLIP_DEFAULT_KEYS = ["loopMode", "repetitions", "endMode", "speed"];
136
+ var LOOP_NAMES = ["repeat", "once", "pingpong"];
137
+ var END_NAMES = ["clamp", "rewind"];
138
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
139
+ function parseConfigEntry(where, entry) {
140
+ if (!isRecord(entry)) throw usage(`${where} is not an object`);
141
+ const options = {};
142
+ for (const [key, raw] of Object.entries(entry)) {
143
+ const name = `${where}.${key}`;
144
+ switch (key) {
145
+ case "fps":
146
+ options.fps = positiveNumber(name, raw);
147
+ break;
148
+ case "clips":
149
+ if (!Array.isArray(raw) || raw.length === 0 || raw.some((c) => typeof c !== "string")) {
150
+ throw usage(`${name} takes a list of clip names`);
151
+ }
152
+ options.clips = raw;
153
+ break;
154
+ case "encoding":
155
+ options.encoding = encodingOf(name, raw);
156
+ break;
157
+ case "maxTextureSize":
158
+ options.maxTextureSize = positiveInteger(name, raw);
159
+ break;
160
+ case "maxBytes":
161
+ options.maxBytes = positiveInteger(name, raw);
162
+ break;
163
+ case "bakeNormals":
164
+ case "mergeFlatMaterials":
165
+ if (typeof raw !== "boolean") throw usage(`${name} takes true or false`);
166
+ options[key] = raw;
167
+ break;
168
+ case "clipDefaults":
169
+ if (!isRecord(raw)) throw usage(`${name} is not an object keyed by clip name`);
170
+ options.clipDefaults = {};
171
+ for (const [clip, defaults] of Object.entries(raw)) {
172
+ options.clipDefaults[clip] = parseClipDefaults(`${name}.${clip}`, defaults);
173
+ }
174
+ break;
175
+ default:
176
+ throw usage(`unknown config key "${key}" in ${where}; expected one of ${CONFIG_KEYS.join(", ")}`);
177
+ }
178
+ }
179
+ return options;
180
+ }
181
+ function parseClipDefaults(where, raw) {
182
+ if (!isRecord(raw)) throw usage(`${where} is not an object`);
183
+ const defaults = {};
184
+ for (const [key, value] of Object.entries(raw)) {
185
+ const name = `${where}.${key}`;
186
+ switch (key) {
187
+ case "loopMode":
188
+ if (!LOOP_NAMES.includes(value)) throw usage(`${name} takes ${LOOP_NAMES.join(", ")}`);
189
+ defaults.loopMode = value;
190
+ break;
191
+ case "endMode":
192
+ if (!END_NAMES.includes(value)) throw usage(`${name} takes ${END_NAMES.join(", ")}`);
193
+ defaults.endMode = value;
194
+ break;
195
+ case "repetitions":
196
+ defaults.repetitions = positiveInteger(name, value);
197
+ break;
198
+ case "speed":
199
+ if (typeof value !== "number" || !Number.isFinite(value)) throw usage(`${name} takes a number`);
200
+ defaults.speed = value;
201
+ break;
202
+ default:
203
+ throw usage(`unknown config key "${key}" in ${where}; expected one of ${CLIP_DEFAULT_KEYS.join(", ")}`);
204
+ }
205
+ }
206
+ return defaults;
207
+ }
208
+ var isAbsolute = (path) => /^([A-Za-z]:)?[\\/]/.test(path);
209
+ function resolvePath(from, path) {
210
+ const joined = isAbsolute(path) ? path : `${from.replace(/[\\/]+$/, "")}/${path}`;
211
+ const drive = /^[A-Za-z]:/.exec(joined)?.[0] ?? (/^[\\/]{2}(?![\\/])/.test(joined) ? "/" : "");
212
+ const parts = [];
213
+ for (const part of joined.slice(drive.length).split(/[\\/]+/)) {
214
+ if (part === "" || part === ".") continue;
215
+ if (part === "..") parts.pop();
216
+ else parts.push(part);
217
+ }
218
+ return `${drive}/${parts.join("/")}`;
219
+ }
220
+ var dirnameOf = (path) => path.slice(0, Math.max(path.lastIndexOf("/"), 0)) || "/";
221
+ function readConfig(io, input, named) {
222
+ const path = named !== void 0 ? resolvePath(io.cwd, named) : resolvePath(dirnameOf(input), CONFIG_NAME);
223
+ if (named === void 0 && !io.exists(path)) return { path: null, options: {} };
224
+ let text;
225
+ try {
226
+ text = new TextDecoder().decode(io.readFile(path));
227
+ } catch (error) {
228
+ throw usage(`cannot read the config ${named ?? path}: ${error.message}`);
229
+ }
230
+ let json;
231
+ try {
232
+ json = JSON.parse(text);
233
+ } catch (error) {
234
+ throw usage(`the config ${named ?? path} is not JSON: ${error.message}`);
235
+ }
236
+ if (!isRecord(json)) throw usage(`the config ${named ?? path} is not an object keyed by input file`);
237
+ const dir = dirnameOf(path);
238
+ for (const [key, entry] of Object.entries(json)) {
239
+ if (resolvePath(dir, key) === input) return { path: named ?? path, options: parseConfigEntry(`"${key}"`, entry) };
240
+ }
241
+ return { path: named ?? path, options: {} };
242
+ }
243
+ var DRACO = "KHR_draco_mesh_compression";
244
+ var ownBuffer = (bytes) => new Uint8Array(bytes).buffer;
245
+ function gltfJSON(bytes) {
246
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
247
+ const isGLB = bytes.byteLength >= 20 && view.getUint32(0, true) === 1179937895;
248
+ const text = isGLB ? new TextDecoder().decode(bytes.subarray(20, 20 + view.getUint32(12, true))) : new TextDecoder().decode(bytes);
249
+ const json = JSON.parse(text);
250
+ if (!isRecord(json)) throw new Error("its JSON is not an object");
251
+ return json;
252
+ }
253
+ var meshesIn = (root) => {
254
+ const meshes = [];
255
+ root.traverse((o) => void (o.isMesh && meshes.push(o)));
256
+ return meshes;
257
+ };
258
+ var vertexTotal = (root) => meshesIn(root).reduce((n, mesh) => n + (mesh.geometry.attributes.position?.count ?? 0), 0);
259
+ async function loadGLTF(io, input, argPath, bytes) {
260
+ let json;
261
+ try {
262
+ json = gltfJSON(bytes);
263
+ } catch (error) {
264
+ throw usage(`cannot read ${argPath} as glTF: ${error.message}`);
265
+ }
266
+ const used = Array.isArray(json.extensionsUsed) ? json.extensionsUsed : [];
267
+ if (used.includes(DRACO)) {
268
+ const decoded = argPath.replace(/(\.[^./\\]+)?$/, ".decoded$1");
269
+ throw usage(
270
+ `${argPath} is Draco-compressed (${DRACO}), which the command cannot decode in Node. Decode it first, then bake the result:
271
+
272
+ npx @gltf-transform/cli copy ${argPath} ${decoded}
273
+ `
274
+ );
275
+ }
276
+ const readBuffer = (url) => {
277
+ const data = dataURI(url);
278
+ if (data) return data;
279
+ return ownBuffer(io.readFile(resolvePath(dirnameOf(input), decodeURIComponent(url))));
280
+ };
281
+ const fileLoader = {
282
+ load(url, onLoad, _, onError) {
283
+ let buffer;
284
+ try {
285
+ buffer = readBuffer(url);
286
+ } catch (error) {
287
+ onError(error);
288
+ return;
289
+ }
290
+ onLoad(buffer);
291
+ }
292
+ };
293
+ await MeshoptDecoder.ready;
294
+ const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).setKTX2Loader({}).register((parser2) => {
295
+ const reach = parser2;
296
+ reach.fileLoader = fileLoader;
297
+ reach.loadImageSource = () => Promise.resolve(new Texture());
298
+ return { name: "three-vat:read-through-the-command" };
299
+ });
300
+ let gltf;
301
+ try {
302
+ gltf = await loader.parseAsync(ownBuffer(bytes), "");
303
+ } catch (error) {
304
+ throw usage(`cannot load ${argPath}: ${error.message}`);
305
+ }
306
+ deinterleave(gltf.scene);
307
+ const { parser } = gltf;
308
+ return {
309
+ root: gltf.scene,
310
+ clips: gltf.animations,
311
+ loadedVertices: vertexTotal(gltf.scene),
312
+ readImages: () => readSourceImages(parser, readBuffer)
313
+ };
314
+ }
315
+ function deinterleave(root) {
316
+ const copyOut = deinterleaveAttribute;
317
+ const copies = /* @__PURE__ */ new Map();
318
+ const plain = (attribute) => {
319
+ if (!(attribute instanceof InterleavedBufferAttribute)) return attribute;
320
+ let copy = copies.get(attribute);
321
+ if (!copy) copies.set(attribute, copy = copyOut(attribute));
322
+ return copy;
323
+ };
324
+ for (const mesh of meshesIn(root)) {
325
+ const geometry = mesh.geometry;
326
+ for (const [name, attribute] of Object.entries(geometry.attributes)) geometry.setAttribute(name, plain(attribute));
327
+ for (const targets of Object.values(geometry.morphAttributes)) {
328
+ targets.forEach((attribute, i) => targets[i] = plain(attribute));
329
+ }
330
+ }
331
+ }
332
+ var NoImageLoader = class {
333
+ constructor() {
334
+ this.path = "";
335
+ }
336
+ setPath(path) {
337
+ this.path = path;
338
+ return this;
339
+ }
340
+ load() {
341
+ return new Texture();
342
+ }
343
+ };
344
+ function loadFBX(argPath, bytes) {
345
+ const manager = new LoadingManager();
346
+ manager.addHandler(/./, new NoImageLoader());
347
+ let root;
348
+ try {
349
+ root = new FBXLoader(manager).parse(ownBuffer(bytes), "");
350
+ } catch (error) {
351
+ throw usage(`cannot load ${argPath}: ${error.message}`);
352
+ }
353
+ const loadedVertices = vertexTotal(root);
354
+ for (const mesh of meshesIn(root)) mesh.geometry = mergeVertices(mesh.geometry);
355
+ const clips = root.animations.filter((clip) => !(clip.name === "Take 001" && clip.tracks.length === 0));
356
+ return { root, clips, loadedVertices, readImages: () => Promise.resolve(/* @__PURE__ */ new Map()) };
357
+ }
358
+ async function load(io, input, argPath) {
359
+ const extension = /\.([^./\\]+)$/.exec(input)?.[1]?.toLowerCase();
360
+ if (extension !== "glb" && extension !== "gltf" && extension !== "fbx") {
361
+ throw usage(`${argPath} is not a supported format; the command bakes .glb, .gltf and .fbx`);
362
+ }
363
+ let bytes;
364
+ try {
365
+ bytes = io.readFile(input);
366
+ } catch (error) {
367
+ throw usage(`cannot read ${argPath}: ${error.message}`);
368
+ }
369
+ return extension === "fbx" ? loadFBX(argPath, bytes) : loadGLTF(io, input, argPath, bytes);
370
+ }
371
+ var LOOP_STYLES = {
372
+ repeat: LoopRepeat,
373
+ once: LoopOnce,
374
+ pingpong: LoopPingPong
375
+ };
376
+ function bakeInputs(loaded, options) {
377
+ const byName = new Map(loaded.clips.map((clip) => [clip.name, clip]));
378
+ const known = () => [...byName.keys()].map((name) => `"${name}"`).join(", ") || "none";
379
+ const names = options.clips ?? [...byName.keys()];
380
+ for (const name of [...names, ...Object.keys(options.clipDefaults ?? {})]) {
381
+ if (!byName.has(name)) throw usage(`no clip named "${name}"; the file has ${known()}`);
382
+ }
383
+ const mixer = new AnimationMixer(loaded.root);
384
+ const endModes = /* @__PURE__ */ new Map();
385
+ const inputs = names.map((name) => {
386
+ const clip = byName.get(name);
387
+ const defaults = options.clipDefaults?.[name];
388
+ if (defaults === void 0) return clip;
389
+ const action = mixer.clipAction(clip);
390
+ if (defaults.loopMode !== void 0) action.loop = LOOP_STYLES[defaults.loopMode];
391
+ if (defaults.repetitions !== void 0) action.repetitions = defaults.repetitions;
392
+ if (defaults.speed !== void 0) action.timeScale = defaults.speed;
393
+ if (defaults.endMode !== void 0) endModes.set(name, defaults.endMode === "rewind" ? EndMode.Rewind : EndMode.Clamp);
394
+ return action;
395
+ });
396
+ return { inputs, endModes };
397
+ }
398
+ function layersOf(vat) {
399
+ if (vat.encoding === "rig") return [{ name: "rig", texture: vat.rigTexture, format: "RGBA32F" }];
400
+ const layers = [{ name: "position", texture: vat.positionTexture, format: "RGBA16F" }];
401
+ if (vat.normalTexture) layers.push({ name: "normal", texture: vat.normalTexture, format: "RG8" });
402
+ return layers;
403
+ }
404
+ var bytesOf = (texture) => texture.image.data.byteLength;
405
+ var round = (n) => String(Number(n.toFixed(2)));
406
+ function describeDefaults(clip) {
407
+ const times = clip.repetitions === INFINITE_REPETITIONS ? "forever" : `x${clip.repetitions}`;
408
+ const loop = clip.loopMode === LoopMode.Once ? "once" : clip.loopMode === LoopMode.PingPong ? `pingpong ${times}` : `repeat ${times}`;
409
+ const end = clip.endMode === EndMode.Rewind ? "rewind" : "clamp";
410
+ return `${loop}, ${end}, speed ${round(clip.speed)}`;
411
+ }
412
+ function report(argPath, config, loaded, vat, ms, written) {
413
+ const lines = [`three-vat bake ${argPath}`, `config: ${config ?? "none"}`];
414
+ if (vat.encoding === "rig") {
415
+ lines.push(`encoding: rig, ${vat.slotCount} slot${vat.slotCount === 1 ? "" : "s"}`);
416
+ } else {
417
+ const rows = vat.rowsPerFrame > 1 ? `, ${vat.rowsPerFrame} rows per frame` : "";
418
+ lines.push(`encoding: delta${vat.fallback === null ? "" : ", fallen back from rig"}${rows}`);
419
+ if (vat.fallback !== null) lines.push(`fallback: ${vat.fallback}`);
420
+ }
421
+ lines.push(`clips: ${vat.clips.length}, ${vat.totalFrames} frames`);
422
+ for (const clip of vat.clips) {
423
+ lines.push(` ${clip.name}: ${clip.frames} frames at ${round(clip.fps)} fps, ${describeDefaults(clip)}`);
424
+ }
425
+ const layers = layersOf(vat);
426
+ lines.push("textures:");
427
+ for (const { name, texture, format } of layers) {
428
+ lines.push(` ${name}: ${texture.image.width} x ${texture.image.height} ${format}, ${bytesOf(texture)} bytes`);
429
+ }
430
+ lines.push(` total: ${textureBytes(vat)} bytes`);
431
+ lines.push(`vertices: ${loaded.loadedVertices} as loaded, ${vat.vertexCount} merged`);
432
+ lines.push(`materials: ${vat.materials.length}`);
433
+ if (written) {
434
+ for (const { name, from, lost } of pbrConversions(vat.materials)) {
435
+ lines.push(` ${name}: ${from} written as MeshStandardMaterial${lost.length ? `, losing ${lost.join(" and ")}` : ""}`);
436
+ }
437
+ lines.push(`written: ${written.path}, ${written.bytes} bytes`);
438
+ }
439
+ lines.push(`bake time: ${Math.round(ms)} ms`);
440
+ return `${lines.join("\n")}
441
+ `;
442
+ }
443
+ var textureBytes = (vat) => layersOf(vat).reduce((n, { texture }) => n + bytesOf(texture), 0);
444
+ async function runCommand(argv2, io) {
445
+ if (argv2.length === 0) {
446
+ io.stderr(USAGE);
447
+ return EXIT_USAGE;
448
+ }
449
+ const [first, ...rest] = argv2;
450
+ if (first === "--help" || first === "-h" || first === "bake" && rest.includes("--help")) {
451
+ io.stdout(USAGE);
452
+ return EXIT_OK;
453
+ }
454
+ try {
455
+ return await bake(argv2, io);
456
+ } catch (error) {
457
+ if (!(error instanceof CommandError)) throw error;
458
+ io.stderr(`${error.message}
459
+ `);
460
+ if (error.showUsage) io.stderr(`
461
+ ${USAGE}`);
462
+ return error.code;
463
+ }
464
+ }
465
+ async function bake(argv2, io) {
466
+ let invocation;
467
+ try {
468
+ invocation = parseArgs(argv2);
469
+ } catch (error) {
470
+ if (error instanceof CommandError) throw new CommandError(error.message, error.code, true);
471
+ throw error;
472
+ }
473
+ const { input: argPath, config: named, out, flags } = invocation;
474
+ const input = resolvePath(io.cwd, argPath);
475
+ const config = readConfig(io, input, named);
476
+ const defined = Object.fromEntries(Object.entries(flags).filter(([, v]) => v !== void 0));
477
+ const options = { ...config.options, ...defined };
478
+ const loaded = await load(io, input, argPath);
479
+ const { inputs, endModes } = bakeInputs(loaded, options);
480
+ const bakeOptions = {
481
+ fps: options.fps,
482
+ encoding: options.encoding,
483
+ maxTextureSize: options.maxTextureSize,
484
+ bakeNormals: options.bakeNormals,
485
+ mergeFlatMaterials: options.mergeFlatMaterials
486
+ };
487
+ const now = io.now ?? (() => performance.now());
488
+ const start = now();
489
+ let vat;
490
+ try {
491
+ vat = bakeVAT(loaded.root, inputs, bakeOptions);
492
+ } catch (error) {
493
+ throw refusal(error);
494
+ }
495
+ const ms = now() - start;
496
+ for (const clip of vat.clips) {
497
+ const endMode = endModes.get(clip.name);
498
+ if (endMode !== void 0) clip.endMode = endMode;
499
+ }
500
+ const printReport = (written2) => io.stdout(report(argPath, config.path, loaded, vat, ms, written2));
501
+ const bytes = textureBytes(vat);
502
+ if (options.maxBytes !== void 0 && bytes > options.maxBytes) {
503
+ printReport(null);
504
+ throw new CommandError(
505
+ `three-vat: the VAT's textures are ${bytes} bytes, over the ${options.maxBytes} --max-bytes allows`,
506
+ EXIT_REFUSED
507
+ );
508
+ }
509
+ let written = null;
510
+ if (out !== void 0) {
511
+ let images;
512
+ try {
513
+ images = await loaded.readImages();
514
+ } catch (error) {
515
+ printReport(null);
516
+ throw usage(`cannot read the images of ${argPath}: ${error.message}`);
517
+ }
518
+ let file;
519
+ try {
520
+ file = await writeBakedFile(vat, { images });
521
+ } catch (error) {
522
+ printReport(null);
523
+ throw refusal(error);
524
+ }
525
+ written = writeBakedFileTo(io, out, file);
526
+ }
527
+ printReport(written);
528
+ return EXIT_OK;
529
+ }
530
+ function refusal(error) {
531
+ if (!(error instanceof Error) || error instanceof TypeError || error instanceof RangeError || error instanceof ReferenceError) {
532
+ throw error;
533
+ }
534
+ return new CommandError(error.message, EXIT_REFUSED);
535
+ }
536
+ function writeBakedFileTo(io, out, file) {
537
+ try {
538
+ io.writeFile(resolvePath(io.cwd, out), file);
539
+ } catch (error) {
540
+ throw usage(`cannot write ${out}: ${error.message}`);
541
+ }
542
+ return { path: out, bytes: file.byteLength };
543
+ }
544
+
545
+ // src/file-reader.ts
546
+ function installFileReader() {
547
+ const scope = globalThis;
548
+ if (scope.FileReader !== void 0) return;
549
+ scope.FileReader = class {
550
+ constructor() {
551
+ this.result = null;
552
+ this.onloadend = null;
553
+ }
554
+ readAsArrayBuffer(blob) {
555
+ void blob.arrayBuffer().then((buffer) => {
556
+ this.result = buffer;
557
+ this.onloadend?.();
558
+ });
559
+ }
560
+ };
561
+ }
562
+
563
+ // src/bin.ts
564
+ installFileReader();
565
+ runCommand(argv.slice(2), {
566
+ cwd: cwd(),
567
+ readFile: (path) => readFileSync(path),
568
+ exists: existsSync,
569
+ writeFile: (path, bytes) => {
570
+ mkdirSync(dirname(path), { recursive: true });
571
+ writeFileSync(path, bytes);
572
+ },
573
+ stdout: (text) => void stdout.write(text),
574
+ stderr: (text) => void stderr.write(text)
575
+ }).then(
576
+ (code) => {
577
+ process.exitCode = code;
578
+ },
579
+ (error) => {
580
+ stderr.write(`three-vat: the command crashed; please report it
581
+ ${error?.stack ?? String(error)}
582
+ `);
583
+ process.exitCode = EXIT_CRASH;
584
+ }
585
+ );
@@ -0,0 +1,13 @@
1
+ import { InstancedMesh, BatchedMesh } from 'three';
2
+
3
+ /**
4
+ * A mesh a VAT crowd can ride.
5
+ *
6
+ * `InstancedMesh` is what `createVATMesh` builds on either path.
7
+ * `BatchedMesh` is reached through the primitives, and buys per-instance
8
+ * frustum culling and depth sorting from three.js itself — see
9
+ * docs/usage.md, which also says what it does *not* buy.
10
+ */
11
+ type VATCarrier = InstancedMesh | BatchedMesh;
12
+
13
+ export type { VATCarrier as V };