create-aura3d 1.1.1 → 1.1.2
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/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/cartoon-channel/README.md +11 -1
- package/templates/cartoon-channel/src/experimental/README.md +24 -0
- package/templates/cartoon-channel/src/{concept-episode-2-5d.ts → experimental/concept-episode-2-5d.ts} +2 -2
- package/templates/cartoon-channel/src/{image-puppet-episode.ts → experimental/image-puppet-episode.ts} +2 -2
- package/templates/cartoon-channel/src/{puppet-episode-2d.ts → experimental/puppet-episode-2d.ts} +2 -2
- package/templates/cartoon-studio/ASSET-LICENSES.md +41 -0
- package/templates/cartoon-studio/aura.assets.json +321 -78
- package/templates/cartoon-studio/live-route.html +12 -0
- package/templates/cartoon-studio/package.json +1 -0
- package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
- package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
- package/templates/cartoon-studio/scripts/build-characters.ts +738 -0
- package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +262 -0
- package/templates/cartoon-studio/scripts/episode.ts +48 -1
- package/templates/cartoon-studio/scripts/render-live.ts +873 -0
- package/templates/cartoon-studio/scripts/validate-characters.ts +0 -0
- package/templates/cartoon-studio/src/aura-assets.ts +15 -12
- package/templates/cartoon-studio/src/main.ts +84 -17
- package/templates/cartoon-studio/src/render-live-route.ts +913 -0
- package/templates/cartoon-studio/tsconfig.json +3 -1
- package/templates/cartoon-studio/vite.config.ts +13 -0
- package/templates/episode-builder/README.md +4 -0
- package/templates/fighting-game/README.md +16 -0
- package/templates/prompt-cartoon-channel/README.md +4 -0
- package/templates/cartoon-studio/public/aura-assets/luma.047f5e5f.glb +0 -0
- package/templates/cartoon-studio/public/aura-assets/luma.humanoid-fixture.glb +0 -0
- package/templates/cartoon-studio/public/aura-assets/miko.047f5e5f.glb +0 -0
- /package/templates/cartoon-channel/src/{concept-episode-2-5d.css → experimental/concept-episode-2-5d.css} +0 -0
- /package/templates/cartoon-channel/src/{image-puppet-episode.css → experimental/image-puppet-episode.css} +0 -0
- /package/templates/cartoon-channel/src/{puppet-episode-2d.css → experimental/puppet-episode-2d.css} +0 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-characters.ts — Aura3D-authored, procedurally generated rigged cartoon
|
|
3
|
+
* characters as REAL binary glTF 2.0 (.glb).
|
|
4
|
+
*
|
|
5
|
+
* This emits TWO distinct stylized helper robots — "Miko" (rounded, cyan) and
|
|
6
|
+
* "Luma" (taller, warm gold) — as genuine authored, CC0 rigged assets, replacing
|
|
7
|
+
* the reused three.js RobotExpressive + Mixamo Soldier placeholders in the LIVE
|
|
8
|
+
* 3D route. Nothing is sourced; every vertex, joint, clip and morph delta below is
|
|
9
|
+
* generated from code in this file.
|
|
10
|
+
*
|
|
11
|
+
* Each GLB contains:
|
|
12
|
+
* - a single skinned triangle mesh (POSITION / NORMAL + JOINTS_0 / WEIGHTS_0),
|
|
13
|
+
* built from low-poly box parts (head, torso, 2 arms, 2 legs, a mouth panel),
|
|
14
|
+
* - a small skeleton: root -> spine -> head, plus arm.L/arm.R and leg.L/leg.R
|
|
15
|
+
* (6 joints), with skins[].inverseBindMatrices,
|
|
16
|
+
* - 3 animation clips — Idle, Wave, Walk — sampling node rotations/translations,
|
|
17
|
+
* - ONE mouth morph target named `mouthOpen` (POSITION deltas that drop the lower
|
|
18
|
+
* mouth panel to open a mouth), exposed via the base mesh `targets`, the mesh
|
|
19
|
+
* `weights`, and `meshes[].extras.targetNames = ["mouthOpen"]`.
|
|
20
|
+
*
|
|
21
|
+
* The buffers are authored by hand (accessors / bufferViews / min-max), little
|
|
22
|
+
* endian, 4-byte aligned, packed into one GLB BIN chunk. No three.js, no glTF
|
|
23
|
+
* exporter dependency — just typed-array math.
|
|
24
|
+
*
|
|
25
|
+
* Run: `npx tsx scripts/build-characters.ts`
|
|
26
|
+
* (also runnable from the monorepo with the same command in this directory).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { dirname, resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const TEMPLATE_ROOT = resolve(__dirname, "..");
|
|
35
|
+
const OUTPUT_DIR = resolve(TEMPLATE_ROOT, "public/aura-assets");
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Small math helpers (column-major mat4, glTF/Aura convention).
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
type Vec3 = [number, number, number];
|
|
41
|
+
type Quat = [number, number, number, number];
|
|
42
|
+
type Mat4 = number[]; // length 16, column-major
|
|
43
|
+
|
|
44
|
+
function identity(): Mat4 {
|
|
45
|
+
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function multiply(a: Mat4, b: Mat4): Mat4 {
|
|
49
|
+
const out = new Array<number>(16).fill(0);
|
|
50
|
+
for (let col = 0; col < 4; col += 1) {
|
|
51
|
+
for (let row = 0; row < 4; row += 1) {
|
|
52
|
+
let sum = 0;
|
|
53
|
+
for (let k = 0; k < 4; k += 1) {
|
|
54
|
+
sum += a[k * 4 + row]! * b[col * 4 + k]!;
|
|
55
|
+
}
|
|
56
|
+
out[col * 4 + row] = sum;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function translation(x: number, y: number, z: number): Mat4 {
|
|
63
|
+
const m = identity();
|
|
64
|
+
m[12] = x;
|
|
65
|
+
m[13] = y;
|
|
66
|
+
m[14] = z;
|
|
67
|
+
return m;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function invertTranslation(m: Mat4): Mat4 {
|
|
71
|
+
// Our joint binds are pure translations, so the inverse-bind is just a negated
|
|
72
|
+
// translation. (Computed generically would also work; this keeps it exact.)
|
|
73
|
+
return translation(-m[12]!, -m[13]!, -m[14]!);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function quatFromAxisAngle(axis: Vec3, angle: number): Quat {
|
|
77
|
+
const half = angle / 2;
|
|
78
|
+
const s = Math.sin(half);
|
|
79
|
+
const len = Math.hypot(axis[0], axis[1], axis[2]) || 1;
|
|
80
|
+
return [(axis[0] / len) * s, (axis[1] / len) * s, (axis[2] / len) * s, Math.cos(half)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Geometry builder: accumulate boxes into one skinned mesh.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
interface MeshAccumulator {
|
|
87
|
+
positions: number[]; // xyz triples
|
|
88
|
+
normals: number[]; // xyz triples
|
|
89
|
+
joints: number[]; // 4 per vertex (joint indices)
|
|
90
|
+
weights: number[]; // 4 per vertex
|
|
91
|
+
indices: number[];
|
|
92
|
+
morphDeltas: number[]; // xyz triples, one per vertex (mouthOpen)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function makeAccumulator(): MeshAccumulator {
|
|
96
|
+
return { positions: [], normals: [], joints: [], weights: [], indices: [], morphDeltas: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const BOX_FACES: { normal: Vec3; corners: [Vec3, Vec3, Vec3, Vec3] }[] = [
|
|
100
|
+
// +X
|
|
101
|
+
{ normal: [1, 0, 0], corners: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]] },
|
|
102
|
+
// -X
|
|
103
|
+
{ normal: [-1, 0, 0], corners: [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] },
|
|
104
|
+
// +Y
|
|
105
|
+
{ normal: [0, 1, 0], corners: [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]] },
|
|
106
|
+
// -Y
|
|
107
|
+
{ normal: [0, -1, 0], corners: [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]] },
|
|
108
|
+
// +Z
|
|
109
|
+
{ normal: [0, 0, 1], corners: [[1, 0, 1], [1, 1, 1], [0, 1, 1], [0, 0, 1]] },
|
|
110
|
+
// -Z
|
|
111
|
+
{ normal: [0, 0, -1], corners: [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]] }
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
interface BoxOptions {
|
|
115
|
+
/** Box center. */
|
|
116
|
+
readonly center: Vec3;
|
|
117
|
+
/** Full box size along each axis. */
|
|
118
|
+
readonly size: Vec3;
|
|
119
|
+
/** Joint index every vertex of this box is rigidly bound to (weight 1). */
|
|
120
|
+
readonly joint: number;
|
|
121
|
+
/**
|
|
122
|
+
* Optional mouthOpen morph delta applied to every vertex of this box (used for
|
|
123
|
+
* the lower-mouth panel so the morph visibly drops the lip open).
|
|
124
|
+
*/
|
|
125
|
+
readonly morphDelta?: Vec3;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Append an axis-aligned box (24 verts, 12 tris) rigidly skinned to one joint. */
|
|
129
|
+
function addBox(mesh: MeshAccumulator, options: BoxOptions): void {
|
|
130
|
+
const { center, size, joint } = options;
|
|
131
|
+
const half: Vec3 = [size[0] / 2, size[1] / 2, size[2] / 2];
|
|
132
|
+
const min: Vec3 = [center[0] - half[0], center[1] - half[1], center[2] - half[2]];
|
|
133
|
+
const morph = options.morphDelta ?? [0, 0, 0];
|
|
134
|
+
for (const face of BOX_FACES) {
|
|
135
|
+
const base = mesh.positions.length / 3;
|
|
136
|
+
for (const corner of face.corners) {
|
|
137
|
+
mesh.positions.push(min[0] + corner[0] * size[0], min[1] + corner[1] * size[1], min[2] + corner[2] * size[2]);
|
|
138
|
+
mesh.normals.push(face.normal[0], face.normal[1], face.normal[2]);
|
|
139
|
+
mesh.joints.push(joint, 0, 0, 0);
|
|
140
|
+
mesh.weights.push(1, 0, 0, 0);
|
|
141
|
+
mesh.morphDeltas.push(morph[0], morph[1], morph[2]);
|
|
142
|
+
}
|
|
143
|
+
// two triangles (CCW)
|
|
144
|
+
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Skeleton definition. Joint indices are referenced by JOINTS_0 above.
|
|
150
|
+
// Bind transforms are pure translations placing each joint in model space.
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
interface JointDef {
|
|
153
|
+
readonly name: string;
|
|
154
|
+
readonly parent: number; // -1 for root
|
|
155
|
+
readonly bind: Vec3; // model-space bind translation
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface CharacterDesign {
|
|
159
|
+
readonly id: string;
|
|
160
|
+
readonly name: string;
|
|
161
|
+
/** Body color (base color factor of the body material). */
|
|
162
|
+
readonly bodyColor: [number, number, number, number];
|
|
163
|
+
/** Head/accent color. */
|
|
164
|
+
readonly accentColor: [number, number, number, number];
|
|
165
|
+
/** Overall vertical scale multiplier (Luma is taller than Miko). */
|
|
166
|
+
readonly heightScale: number;
|
|
167
|
+
/** Limb girth multiplier (Miko is rounder/chunkier). */
|
|
168
|
+
readonly girth: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const JOINTS: readonly JointDef[] = [
|
|
172
|
+
{ name: "root", parent: -1, bind: [0, 0, 0] },
|
|
173
|
+
{ name: "spine", parent: 0, bind: [0, 0.55, 0] },
|
|
174
|
+
{ name: "head", parent: 1, bind: [0, 0.45, 0] },
|
|
175
|
+
{ name: "armL", parent: 1, bind: [0.28, 0.32, 0] },
|
|
176
|
+
{ name: "armR", parent: 1, bind: [-0.28, 0.32, 0] },
|
|
177
|
+
{ name: "legL", parent: 0, bind: [0.14, 0, 0] },
|
|
178
|
+
{ name: "legR", parent: 0, bind: [-0.14, 0, 0] }
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const JOINT_INDEX = Object.fromEntries(JOINTS.map((joint, index) => [joint.name, index])) as Record<string, number>;
|
|
182
|
+
|
|
183
|
+
/** World-space bind position of a joint (sum of bind translations up the chain). */
|
|
184
|
+
function jointWorldBind(index: number): Vec3 {
|
|
185
|
+
let current = index;
|
|
186
|
+
const out: Vec3 = [0, 0, 0];
|
|
187
|
+
while (current >= 0) {
|
|
188
|
+
const joint = JOINTS[current]!;
|
|
189
|
+
out[0] += joint.bind[0];
|
|
190
|
+
out[1] += joint.bind[1];
|
|
191
|
+
out[2] += joint.bind[2];
|
|
192
|
+
current = joint.parent;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build a low-poly humanoid robot skinned to JOINTS, with a mouthOpen morph on the
|
|
199
|
+
* lower-mouth panel. Returns the accumulated mesh.
|
|
200
|
+
*/
|
|
201
|
+
function buildBody(design: CharacterDesign): MeshAccumulator {
|
|
202
|
+
const mesh = makeAccumulator();
|
|
203
|
+
const g = design.girth;
|
|
204
|
+
const h = design.heightScale;
|
|
205
|
+
|
|
206
|
+
const headW = jointWorldBind(JOINT_INDEX.head!);
|
|
207
|
+
const spineW = jointWorldBind(JOINT_INDEX.spine!);
|
|
208
|
+
const armLW = jointWorldBind(JOINT_INDEX.armL!);
|
|
209
|
+
const armRW = jointWorldBind(JOINT_INDEX.armR!);
|
|
210
|
+
const legLW = jointWorldBind(JOINT_INDEX.legL!);
|
|
211
|
+
const legRW = jointWorldBind(JOINT_INDEX.legR!);
|
|
212
|
+
|
|
213
|
+
// Torso (bound to spine).
|
|
214
|
+
addBox(mesh, {
|
|
215
|
+
center: [spineW[0], spineW[1] + 0.18 * h, spineW[2]],
|
|
216
|
+
size: [0.42 * g, 0.5 * h, 0.3 * g],
|
|
217
|
+
joint: JOINT_INDEX.spine!
|
|
218
|
+
});
|
|
219
|
+
// Head (bound to head).
|
|
220
|
+
addBox(mesh, {
|
|
221
|
+
center: [headW[0], headW[1] + 0.12 * h, headW[2]],
|
|
222
|
+
size: [0.4 * g, 0.36 * h, 0.36 * g],
|
|
223
|
+
joint: JOINT_INDEX.head!
|
|
224
|
+
});
|
|
225
|
+
// Eyes / visor band (small front box bound to head) — purely visual accent.
|
|
226
|
+
addBox(mesh, {
|
|
227
|
+
center: [headW[0], headW[1] + 0.16 * h, headW[2] + 0.19 * g],
|
|
228
|
+
size: [0.3 * g, 0.08 * h, 0.04 * g],
|
|
229
|
+
joint: JOINT_INDEX.head!
|
|
230
|
+
});
|
|
231
|
+
// Upper-mouth panel (static, bound to head): the fixed top lip. A dark mouth box
|
|
232
|
+
// (its own material accent comes from emissive; here it shares the body material).
|
|
233
|
+
addBox(mesh, {
|
|
234
|
+
center: [headW[0], headW[1] + 0.02 * h, headW[2] + 0.19 * g],
|
|
235
|
+
size: [0.22 * g, 0.05 * h, 0.04 * g],
|
|
236
|
+
joint: JOINT_INDEX.head!
|
|
237
|
+
});
|
|
238
|
+
// Lower-mouth panel (bound to head) WITH the mouthOpen morph delta: at weight 1
|
|
239
|
+
// it drops downward a large, clearly visible amount, opening the mouth. This is
|
|
240
|
+
// the real blendshape that drives lip-sync in pixels.
|
|
241
|
+
addBox(mesh, {
|
|
242
|
+
center: [headW[0], headW[1] - 0.05 * h, headW[2] + 0.19 * g],
|
|
243
|
+
size: [0.22 * g, 0.05 * h, 0.04 * g],
|
|
244
|
+
joint: JOINT_INDEX.head!,
|
|
245
|
+
morphDelta: [0, -0.16 * h, 0.02 * g]
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Arms (bound to armL / armR).
|
|
249
|
+
addBox(mesh, {
|
|
250
|
+
center: [armLW[0] + 0.06 * g, armLW[1] - 0.12 * h, armLW[2]],
|
|
251
|
+
size: [0.12 * g, 0.4 * h, 0.12 * g],
|
|
252
|
+
joint: JOINT_INDEX.armL!
|
|
253
|
+
});
|
|
254
|
+
addBox(mesh, {
|
|
255
|
+
center: [armRW[0] - 0.06 * g, armRW[1] - 0.12 * h, armRW[2]],
|
|
256
|
+
size: [0.12 * g, 0.4 * h, 0.12 * g],
|
|
257
|
+
joint: JOINT_INDEX.armR!
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Legs (bound to legL / legR).
|
|
261
|
+
addBox(mesh, {
|
|
262
|
+
center: [legLW[0], legLW[1] - 0.26 * h, legLW[2]],
|
|
263
|
+
size: [0.14 * g, 0.5 * h, 0.16 * g],
|
|
264
|
+
joint: JOINT_INDEX.legL!
|
|
265
|
+
});
|
|
266
|
+
addBox(mesh, {
|
|
267
|
+
center: [legRW[0], legRW[1] - 0.26 * h, legRW[2]],
|
|
268
|
+
size: [0.14 * g, 0.5 * h, 0.16 * g],
|
|
269
|
+
joint: JOINT_INDEX.legR!
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return mesh;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Animation clips. Each clip is a set of channels; a channel = (node, path,
|
|
277
|
+
// times[], values[]). We sample rotations (quaternion) and translations.
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
interface Channel {
|
|
280
|
+
readonly jointName: string;
|
|
281
|
+
readonly path: "rotation" | "translation";
|
|
282
|
+
readonly times: number[];
|
|
283
|
+
/** Flat values: 4 per keyframe for rotation, 3 for translation. */
|
|
284
|
+
readonly values: number[];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface Clip {
|
|
288
|
+
readonly name: string;
|
|
289
|
+
readonly channels: Channel[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function rotChannel(jointName: string, times: number[], quats: Quat[]): Channel {
|
|
293
|
+
return { jointName, path: "rotation", times, values: quats.flat() };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function transChannel(jointName: string, times: number[], translations: Vec3[]): Channel {
|
|
297
|
+
return { jointName, path: "translation", times, values: translations.flat() };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildClips(): Clip[] {
|
|
301
|
+
// Idle: gentle spine sway + head bob (subtle).
|
|
302
|
+
const idle: Clip = {
|
|
303
|
+
name: "Idle",
|
|
304
|
+
channels: [
|
|
305
|
+
rotChannel("spine", [0, 1, 2], [
|
|
306
|
+
quatFromAxisAngle([0, 0, 1], 0.04),
|
|
307
|
+
quatFromAxisAngle([0, 0, 1], -0.04),
|
|
308
|
+
quatFromAxisAngle([0, 0, 1], 0.04)
|
|
309
|
+
]),
|
|
310
|
+
rotChannel("head", [0, 1, 2], [
|
|
311
|
+
quatFromAxisAngle([1, 0, 0], -0.03),
|
|
312
|
+
quatFromAxisAngle([1, 0, 0], 0.05),
|
|
313
|
+
quatFromAxisAngle([1, 0, 0], -0.03)
|
|
314
|
+
])
|
|
315
|
+
]
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Wave: right arm raises and waves; spine leans slightly.
|
|
319
|
+
const wave: Clip = {
|
|
320
|
+
name: "Wave",
|
|
321
|
+
channels: [
|
|
322
|
+
rotChannel("armR", [0, 0.5, 1, 1.5, 2], [
|
|
323
|
+
quatFromAxisAngle([0, 0, 1], 0),
|
|
324
|
+
quatFromAxisAngle([0, 0, 1], -2.2),
|
|
325
|
+
quatFromAxisAngle([0, 0, 1], -2.5),
|
|
326
|
+
quatFromAxisAngle([0, 0, 1], -2.2),
|
|
327
|
+
quatFromAxisAngle([0, 0, 1], -2.5)
|
|
328
|
+
]),
|
|
329
|
+
rotChannel("spine", [0, 1, 2], [
|
|
330
|
+
quatFromAxisAngle([0, 0, 1], 0.08),
|
|
331
|
+
quatFromAxisAngle([0, 0, 1], 0.12),
|
|
332
|
+
quatFromAxisAngle([0, 0, 1], 0.08)
|
|
333
|
+
])
|
|
334
|
+
]
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Walk: alternating legs + arms swing, slight body bob in translation.
|
|
338
|
+
const walk: Clip = {
|
|
339
|
+
name: "Walk",
|
|
340
|
+
channels: [
|
|
341
|
+
rotChannel("legL", [0, 0.5, 1], [
|
|
342
|
+
quatFromAxisAngle([1, 0, 0], 0.6),
|
|
343
|
+
quatFromAxisAngle([1, 0, 0], -0.6),
|
|
344
|
+
quatFromAxisAngle([1, 0, 0], 0.6)
|
|
345
|
+
]),
|
|
346
|
+
rotChannel("legR", [0, 0.5, 1], [
|
|
347
|
+
quatFromAxisAngle([1, 0, 0], -0.6),
|
|
348
|
+
quatFromAxisAngle([1, 0, 0], 0.6),
|
|
349
|
+
quatFromAxisAngle([1, 0, 0], -0.6)
|
|
350
|
+
]),
|
|
351
|
+
rotChannel("armL", [0, 0.5, 1], [
|
|
352
|
+
quatFromAxisAngle([1, 0, 0], -0.5),
|
|
353
|
+
quatFromAxisAngle([1, 0, 0], 0.5),
|
|
354
|
+
quatFromAxisAngle([1, 0, 0], -0.5)
|
|
355
|
+
]),
|
|
356
|
+
rotChannel("armR", [0, 0.5, 1], [
|
|
357
|
+
quatFromAxisAngle([1, 0, 0], 0.5),
|
|
358
|
+
quatFromAxisAngle([1, 0, 0], -0.5),
|
|
359
|
+
quatFromAxisAngle([1, 0, 0], 0.5)
|
|
360
|
+
]),
|
|
361
|
+
transChannel("root", [0, 0.25, 0.5, 0.75, 1], [
|
|
362
|
+
[0, 0, 0],
|
|
363
|
+
[0, 0.05, 0],
|
|
364
|
+
[0, 0, 0],
|
|
365
|
+
[0, 0.05, 0],
|
|
366
|
+
[0, 0, 0]
|
|
367
|
+
])
|
|
368
|
+
]
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
return [idle, wave, walk];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// GLB encoder. Builds JSON + BIN chunk from accessors/bufferViews.
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
const COMPONENT_FLOAT = 5126;
|
|
378
|
+
const COMPONENT_USHORT = 5123;
|
|
379
|
+
const TARGET_ARRAY_BUFFER = 34962;
|
|
380
|
+
const TARGET_ELEMENT_ARRAY_BUFFER = 34963;
|
|
381
|
+
|
|
382
|
+
interface PendingAccessor {
|
|
383
|
+
readonly bytes: Uint8Array;
|
|
384
|
+
readonly componentType: number;
|
|
385
|
+
readonly count: number;
|
|
386
|
+
readonly type: string;
|
|
387
|
+
readonly target?: number;
|
|
388
|
+
readonly min?: number[];
|
|
389
|
+
readonly max?: number[];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
interface GLTFBuild {
|
|
393
|
+
json: Record<string, unknown>;
|
|
394
|
+
accessors: PendingAccessor[];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function f32(values: number[]): Uint8Array {
|
|
398
|
+
const arr = new Float32Array(values);
|
|
399
|
+
return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function u16(values: number[]): Uint8Array {
|
|
403
|
+
const arr = new Uint16Array(values);
|
|
404
|
+
return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function minMax(values: number[], components: number): { min: number[]; max: number[] } {
|
|
408
|
+
const min = new Array<number>(components).fill(Infinity);
|
|
409
|
+
const max = new Array<number>(components).fill(-Infinity);
|
|
410
|
+
for (let i = 0; i < values.length; i += components) {
|
|
411
|
+
for (let c = 0; c < components; c += 1) {
|
|
412
|
+
const v = values[i + c]!;
|
|
413
|
+
if (v < min[c]!) min[c] = v;
|
|
414
|
+
if (v > max[c]!) max[c] = v;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return { min, max };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function buildGLTF(design: CharacterDesign, mesh: MeshAccumulator, clips: Clip[]): GLTFBuild {
|
|
421
|
+
const accessors: PendingAccessor[] = [];
|
|
422
|
+
const pushAccessor = (a: PendingAccessor): number => {
|
|
423
|
+
accessors.push(a);
|
|
424
|
+
return accessors.length - 1;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// --- Mesh attribute accessors ---
|
|
428
|
+
const posMM = minMax(mesh.positions, 3);
|
|
429
|
+
const positionAccessor = pushAccessor({
|
|
430
|
+
bytes: f32(mesh.positions),
|
|
431
|
+
componentType: COMPONENT_FLOAT,
|
|
432
|
+
count: mesh.positions.length / 3,
|
|
433
|
+
type: "VEC3",
|
|
434
|
+
target: TARGET_ARRAY_BUFFER,
|
|
435
|
+
min: posMM.min,
|
|
436
|
+
max: posMM.max
|
|
437
|
+
});
|
|
438
|
+
const normalAccessor = pushAccessor({
|
|
439
|
+
bytes: f32(mesh.normals),
|
|
440
|
+
componentType: COMPONENT_FLOAT,
|
|
441
|
+
count: mesh.normals.length / 3,
|
|
442
|
+
type: "VEC3",
|
|
443
|
+
target: TARGET_ARRAY_BUFFER
|
|
444
|
+
});
|
|
445
|
+
const jointsAccessor = pushAccessor({
|
|
446
|
+
bytes: u16(mesh.joints),
|
|
447
|
+
componentType: COMPONENT_USHORT,
|
|
448
|
+
count: mesh.joints.length / 4,
|
|
449
|
+
type: "VEC4",
|
|
450
|
+
target: TARGET_ARRAY_BUFFER
|
|
451
|
+
});
|
|
452
|
+
const weightsAccessor = pushAccessor({
|
|
453
|
+
bytes: f32(mesh.weights),
|
|
454
|
+
componentType: COMPONENT_FLOAT,
|
|
455
|
+
count: mesh.weights.length / 4,
|
|
456
|
+
type: "VEC4",
|
|
457
|
+
target: TARGET_ARRAY_BUFFER
|
|
458
|
+
});
|
|
459
|
+
const indexAccessor = pushAccessor({
|
|
460
|
+
bytes: u16(mesh.indices),
|
|
461
|
+
componentType: COMPONENT_USHORT,
|
|
462
|
+
count: mesh.indices.length,
|
|
463
|
+
type: "SCALAR",
|
|
464
|
+
target: TARGET_ELEMENT_ARRAY_BUFFER
|
|
465
|
+
});
|
|
466
|
+
// Morph target POSITION deltas (mouthOpen).
|
|
467
|
+
const morphMM = minMax(mesh.morphDeltas, 3);
|
|
468
|
+
const morphAccessor = pushAccessor({
|
|
469
|
+
bytes: f32(mesh.morphDeltas),
|
|
470
|
+
componentType: COMPONENT_FLOAT,
|
|
471
|
+
count: mesh.morphDeltas.length / 3,
|
|
472
|
+
type: "VEC3",
|
|
473
|
+
target: TARGET_ARRAY_BUFFER,
|
|
474
|
+
min: morphMM.min,
|
|
475
|
+
max: morphMM.max
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// --- Inverse bind matrices accessor ---
|
|
479
|
+
const ibmValues: number[] = [];
|
|
480
|
+
for (let index = 0; index < JOINTS.length; index += 1) {
|
|
481
|
+
const world = translation(...jointWorldBind(index));
|
|
482
|
+
const inverse = invertTranslation(world);
|
|
483
|
+
ibmValues.push(...inverse);
|
|
484
|
+
}
|
|
485
|
+
const ibmAccessor = pushAccessor({
|
|
486
|
+
bytes: f32(ibmValues),
|
|
487
|
+
componentType: COMPONENT_FLOAT,
|
|
488
|
+
count: JOINTS.length,
|
|
489
|
+
type: "MAT4"
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// --- Animation accessors ---
|
|
493
|
+
const animationSamplers: { input: number; output: number; interpolation: string }[] = [];
|
|
494
|
+
const gltfAnimations: Record<string, unknown>[] = [];
|
|
495
|
+
for (const clip of clips) {
|
|
496
|
+
const channels: Record<string, unknown>[] = [];
|
|
497
|
+
const samplers: Record<string, unknown>[] = [];
|
|
498
|
+
for (const channel of clip.channels) {
|
|
499
|
+
const timeMM = minMax(channel.times, 1);
|
|
500
|
+
const inputAccessor = pushAccessor({
|
|
501
|
+
bytes: f32(channel.times),
|
|
502
|
+
componentType: COMPONENT_FLOAT,
|
|
503
|
+
count: channel.times.length,
|
|
504
|
+
type: "SCALAR",
|
|
505
|
+
min: timeMM.min,
|
|
506
|
+
max: timeMM.max
|
|
507
|
+
});
|
|
508
|
+
const components = channel.path === "rotation" ? 4 : 3;
|
|
509
|
+
const outputAccessor = pushAccessor({
|
|
510
|
+
bytes: f32(channel.values),
|
|
511
|
+
componentType: COMPONENT_FLOAT,
|
|
512
|
+
count: channel.values.length / components,
|
|
513
|
+
type: channel.path === "rotation" ? "VEC4" : "VEC3"
|
|
514
|
+
});
|
|
515
|
+
const samplerIndex = samplers.length;
|
|
516
|
+
samplers.push({ input: inputAccessor, output: outputAccessor, interpolation: "LINEAR" });
|
|
517
|
+
channels.push({
|
|
518
|
+
sampler: samplerIndex,
|
|
519
|
+
target: { node: NODE_INDEX_FOR_JOINT[channel.jointName], path: channel.path }
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
gltfAnimations.push({ name: clip.name, samplers, channels });
|
|
523
|
+
}
|
|
524
|
+
void animationSamplers;
|
|
525
|
+
|
|
526
|
+
// --- Nodes ---
|
|
527
|
+
// Node layout: 0 = mesh node (skinned), then one node per joint.
|
|
528
|
+
// We place the skeleton joints as nodes too (the skin references them).
|
|
529
|
+
const MESH_NODE = 0;
|
|
530
|
+
const jointNodeIndices = JOINTS.map((_, index) => index + 1);
|
|
531
|
+
|
|
532
|
+
const nodes: Record<string, unknown>[] = [];
|
|
533
|
+
nodes.push({ name: `${design.id}_mesh`, mesh: 0, skin: 0 });
|
|
534
|
+
for (let index = 0; index < JOINTS.length; index += 1) {
|
|
535
|
+
const joint = JOINTS[index]!;
|
|
536
|
+
const node: Record<string, unknown> = {
|
|
537
|
+
name: joint.name,
|
|
538
|
+
translation: joint.bind
|
|
539
|
+
};
|
|
540
|
+
const children = JOINTS
|
|
541
|
+
.map((other, otherIndex) => (other.parent === index ? jointNodeIndices[otherIndex]! : -1))
|
|
542
|
+
.filter((value) => value >= 0);
|
|
543
|
+
if (children.length > 0) node.children = children;
|
|
544
|
+
nodes.push(node);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const rootJointNode = jointNodeIndices[0]!;
|
|
548
|
+
const sceneNodes = [MESH_NODE, rootJointNode];
|
|
549
|
+
|
|
550
|
+
const json: Record<string, unknown> = {
|
|
551
|
+
asset: { version: "2.0", generator: "Aura3D cartoon-studio build-characters.ts (CC0 procedural)" },
|
|
552
|
+
scene: 0,
|
|
553
|
+
scenes: [{ name: `${design.name} scene`, nodes: sceneNodes }],
|
|
554
|
+
nodes,
|
|
555
|
+
meshes: [
|
|
556
|
+
{
|
|
557
|
+
name: `${design.id}_body`,
|
|
558
|
+
primitives: [
|
|
559
|
+
{
|
|
560
|
+
attributes: {
|
|
561
|
+
POSITION: positionAccessor,
|
|
562
|
+
NORMAL: normalAccessor,
|
|
563
|
+
JOINTS_0: jointsAccessor,
|
|
564
|
+
WEIGHTS_0: weightsAccessor
|
|
565
|
+
},
|
|
566
|
+
indices: indexAccessor,
|
|
567
|
+
material: 0,
|
|
568
|
+
targets: [{ POSITION: morphAccessor }]
|
|
569
|
+
}
|
|
570
|
+
],
|
|
571
|
+
weights: [0],
|
|
572
|
+
extras: { targetNames: ["mouthOpen"] }
|
|
573
|
+
}
|
|
574
|
+
],
|
|
575
|
+
skins: [
|
|
576
|
+
{
|
|
577
|
+
name: `${design.id}_skin`,
|
|
578
|
+
inverseBindMatrices: ibmAccessor,
|
|
579
|
+
joints: jointNodeIndices,
|
|
580
|
+
skeleton: rootJointNode
|
|
581
|
+
}
|
|
582
|
+
],
|
|
583
|
+
animations: gltfAnimations,
|
|
584
|
+
materials: [
|
|
585
|
+
{
|
|
586
|
+
name: `${design.id}_body_mat`,
|
|
587
|
+
pbrMetallicRoughness: {
|
|
588
|
+
baseColorFactor: design.bodyColor,
|
|
589
|
+
metallicFactor: 0.1,
|
|
590
|
+
roughnessFactor: 0.6
|
|
591
|
+
},
|
|
592
|
+
emissiveFactor: [design.accentColor[0] * 0.15, design.accentColor[1] * 0.15, design.accentColor[2] * 0.15]
|
|
593
|
+
}
|
|
594
|
+
]
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
return { json, accessors };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Map joint name -> NODE index (joints are nodes 1..N; mesh is node 0).
|
|
601
|
+
const NODE_INDEX_FOR_JOINT: Record<string, number> = Object.fromEntries(
|
|
602
|
+
JOINTS.map((joint, index) => [joint.name, index + 1])
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// Pack accessors into bufferViews + a single binary buffer, write GLB.
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
function align4(n: number): number {
|
|
609
|
+
return (n + 3) & ~3;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function encodeGLB(build: GLTFBuild): Uint8Array {
|
|
613
|
+
const { json, accessors } = build;
|
|
614
|
+
const bufferViews: Record<string, unknown>[] = [];
|
|
615
|
+
const chunks: Uint8Array[] = [];
|
|
616
|
+
let byteOffset = 0;
|
|
617
|
+
|
|
618
|
+
const jsonAccessors = accessors.map((accessor, index) => {
|
|
619
|
+
const padded = align4(accessor.bytes.byteLength);
|
|
620
|
+
const view: Record<string, unknown> = {
|
|
621
|
+
buffer: 0,
|
|
622
|
+
byteOffset,
|
|
623
|
+
byteLength: accessor.bytes.byteLength
|
|
624
|
+
};
|
|
625
|
+
if (accessor.target !== undefined) view.target = accessor.target;
|
|
626
|
+
bufferViews.push(view);
|
|
627
|
+
|
|
628
|
+
const padding = padded - accessor.bytes.byteLength;
|
|
629
|
+
chunks.push(accessor.bytes);
|
|
630
|
+
if (padding > 0) chunks.push(new Uint8Array(padding));
|
|
631
|
+
byteOffset += padded;
|
|
632
|
+
|
|
633
|
+
const out: Record<string, unknown> = {
|
|
634
|
+
bufferView: index,
|
|
635
|
+
componentType: accessor.componentType,
|
|
636
|
+
count: accessor.count,
|
|
637
|
+
type: accessor.type
|
|
638
|
+
};
|
|
639
|
+
if (accessor.min) out.min = accessor.min;
|
|
640
|
+
if (accessor.max) out.max = accessor.max;
|
|
641
|
+
return out;
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const binLength = byteOffset;
|
|
645
|
+
json.bufferViews = bufferViews;
|
|
646
|
+
json.accessors = jsonAccessors;
|
|
647
|
+
json.buffers = [{ byteLength: binLength }];
|
|
648
|
+
|
|
649
|
+
// Assemble BIN chunk.
|
|
650
|
+
const bin = new Uint8Array(binLength);
|
|
651
|
+
let cursor = 0;
|
|
652
|
+
for (const chunk of chunks) {
|
|
653
|
+
bin.set(chunk, cursor);
|
|
654
|
+
cursor += chunk.byteLength;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// JSON chunk (4-byte aligned, padded with spaces).
|
|
658
|
+
const jsonText = JSON.stringify(json);
|
|
659
|
+
const jsonBytes = new TextEncoder().encode(jsonText);
|
|
660
|
+
const jsonPadded = align4(jsonBytes.byteLength);
|
|
661
|
+
const jsonChunk = new Uint8Array(jsonPadded).fill(0x20); // spaces
|
|
662
|
+
jsonChunk.set(jsonBytes, 0);
|
|
663
|
+
|
|
664
|
+
const binPadded = align4(bin.byteLength);
|
|
665
|
+
const binChunk = new Uint8Array(binPadded); // zero pad
|
|
666
|
+
binChunk.set(bin, 0);
|
|
667
|
+
|
|
668
|
+
const totalLength = 12 + 8 + jsonChunk.byteLength + 8 + binChunk.byteLength;
|
|
669
|
+
const glb = new Uint8Array(totalLength);
|
|
670
|
+
const dv = new DataView(glb.buffer);
|
|
671
|
+
let o = 0;
|
|
672
|
+
dv.setUint32(o, 0x46546c67, true); // "glTF"
|
|
673
|
+
dv.setUint32(o + 4, 2, true); // version
|
|
674
|
+
dv.setUint32(o + 8, totalLength, true);
|
|
675
|
+
o += 12;
|
|
676
|
+
// JSON chunk
|
|
677
|
+
dv.setUint32(o, jsonChunk.byteLength, true);
|
|
678
|
+
dv.setUint32(o + 4, 0x4e4f534a, true); // "JSON"
|
|
679
|
+
glb.set(jsonChunk, o + 8);
|
|
680
|
+
o += 8 + jsonChunk.byteLength;
|
|
681
|
+
// BIN chunk
|
|
682
|
+
dv.setUint32(o, binChunk.byteLength, true);
|
|
683
|
+
dv.setUint32(o + 4, 0x004e4942, true); // "BIN\0"
|
|
684
|
+
glb.set(binChunk, o + 8);
|
|
685
|
+
|
|
686
|
+
return glb;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// Character designs.
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
const DESIGNS: readonly { design: CharacterDesign; file: string }[] = [
|
|
693
|
+
{
|
|
694
|
+
file: "miko.authored.glb",
|
|
695
|
+
design: {
|
|
696
|
+
id: "miko",
|
|
697
|
+
name: "Miko",
|
|
698
|
+
// Rounded, cyan helper robot.
|
|
699
|
+
bodyColor: [0.36, 0.82, 0.92, 1],
|
|
700
|
+
accentColor: [0.6, 0.95, 1, 1],
|
|
701
|
+
heightScale: 1.0,
|
|
702
|
+
girth: 1.18
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
file: "luma.authored.glb",
|
|
707
|
+
design: {
|
|
708
|
+
id: "luma",
|
|
709
|
+
name: "Luma",
|
|
710
|
+
// Taller, warm-gold helper robot.
|
|
711
|
+
bodyColor: [0.96, 0.74, 0.34, 1],
|
|
712
|
+
accentColor: [1, 0.88, 0.55, 1],
|
|
713
|
+
heightScale: 1.32,
|
|
714
|
+
girth: 0.92
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
function main(): void {
|
|
720
|
+
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
721
|
+
const clips = buildClips();
|
|
722
|
+
for (const { design, file } of DESIGNS) {
|
|
723
|
+
const mesh = buildBody(design);
|
|
724
|
+
const build = buildGLTF(design, mesh, clips);
|
|
725
|
+
const glb = encodeGLB(build);
|
|
726
|
+
const outPath = resolve(OUTPUT_DIR, file);
|
|
727
|
+
writeFileSync(outPath, glb);
|
|
728
|
+
const vertexCount = mesh.positions.length / 3;
|
|
729
|
+
const triCount = mesh.indices.length / 3;
|
|
730
|
+
console.log(
|
|
731
|
+
`${file}: ${glb.byteLength} bytes | ${vertexCount} verts / ${triCount} tris | ` +
|
|
732
|
+
`joints=${JOINTS.length} clips=${clips.length} (${clips.map((c) => c.name).join(",")}) morph=mouthOpen`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
console.log(`\nWrote authored GLBs to ${OUTPUT_DIR}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
main();
|