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 +37 -8
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +585 -0
- package/dist/carrier-DmKhB4QL.d.ts +13 -0
- package/dist/{chunk-OEKKEHRQ.js → chunk-4IKOXUSX.js} +85 -26
- package/dist/chunk-7TT7B5WS.js +263 -0
- package/dist/chunk-P6AFXEBT.js +1104 -0
- package/dist/index.d.ts +37 -5
- package/dist/index.js +142 -965
- package/dist/tsl.d.ts +2 -1
- package/dist/tsl.js +18 -15
- package/dist/{carrier-VLSJLgjX.d.ts → types-CDPXKe0_.d.ts} +99 -34
- package/dist/webgl.d.ts +2 -1
- package/dist/webgl.js +25 -14
- package/dist/write.d.ts +53 -0
- package/dist/write.js +3 -0
- package/package.json +88 -81
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
|
|
95
|
-
listed in the snippet above, and any mix of them, through that one
|
|
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,
|
|
184
|
-
|
|
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
|
|
192
|
-
|
|
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 };
|