@homie-rocks/studio 0.24.5 → 0.25.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/CHANGELOG.md +32 -1
- package/README.md +47 -1
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- package/lib/characters.mjs +380 -0
- package/lib/clips.mjs +417 -0
- package/lib/decisions.mjs +38 -8
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +2 -2
- package/lib/optimise.mjs +79 -3
- package/lib/render3d.mjs +2 -0
- package/lib/rig.mjs +431 -0
- package/lib/scaffold.mjs +7 -0
- package/lib/studio.mjs +18 -3
- package/lib/style-board.mjs +3 -1
- package/lib/template-history.json +39 -0
- package/mcp/ui/animation.js +56 -0
- package/mcp/ui/card.css +4 -0
- package/mcp/ui/cast.js +36 -2
- package/mcp/ui/lineup.js +7 -7
- package/package.json +5 -1
- package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
- package/starters/hero-rush-3d/assets/manifest.json +1471 -0
- package/starters/hero-rush-3d/credits.json +40 -0
- package/starters/hero-rush-3d/game.json +11 -0
- package/starters/hero-rush-3d/index.html +20 -0
- package/starters/hero-rush-3d/lab.json +32 -0
- package/starters/hero-rush-3d/src/main.ts +2361 -0
- package/starters/hero-rush-3d/style.json +68 -0
- package/starters/hero-rush-3d/tunables.json +32 -0
- package/worker/version.mjs +1 -1
package/lib/optimise.mjs
CHANGED
|
@@ -134,13 +134,31 @@ export async function measureDoc(doc) {
|
|
|
134
134
|
return { name: t.getName() || t.getURI() || '', mimeType: t.getMimeType(), px: px ? [px[0], px[1]] : null, bytes: t.getImage()?.byteLength ?? 0, gpuBytes: px ? Math.round(px[0] * px[1] * 4 * (4 / 3)) : 0, slots };
|
|
135
135
|
});
|
|
136
136
|
let box = null;
|
|
137
|
+
const skins = root.listSkins();
|
|
137
138
|
if (scene) {
|
|
138
|
-
|
|
139
|
+
// A skinned model is where its bones put it (its mesh node's own transform does nothing): measured skinned.
|
|
140
|
+
const b = skins.length ? skinnedBounds(doc) ?? fn.getBounds(scene) : fn.getBounds(scene);
|
|
139
141
|
if (Number.isFinite(b.min[0]) && Number.isFinite(b.max[0])) box = { min: b.min.map((v) => round(v)), max: b.max.map((v) => round(v)), size: b.max.map((v, i) => round(v - b.min[i])) };
|
|
140
142
|
}
|
|
141
|
-
|
|
143
|
+
// Skinning: vertices a skin moves, and the most joints one vertex reads (three.js reads JOINTS_0 only: 4).
|
|
144
|
+
let skinnedVertices = 0; let influences = 0;
|
|
145
|
+
for (const node of root.listNodes()) {
|
|
146
|
+
if (!node.getSkin() || !node.getMesh()) continue;
|
|
147
|
+
for (const prim of node.getMesh().listPrimitives()) {
|
|
148
|
+
skinnedVertices += prim.getAttribute('POSITION')?.getCount() ?? 0;
|
|
149
|
+
influences = Math.max(influences, prim.getAttribute('JOINTS_1') ? 8 : prim.getAttribute('JOINTS_0') ? 4 : 0);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const anims = root.listAnimations();
|
|
153
|
+
const keys = anims.reduce((n, a) => n + a.listSamplers().reduce((m, x) => m + x.getInput().getCount(), 0), 0);
|
|
154
|
+
const clipSeconds = anims.reduce((n, a) => n + a.listSamplers().reduce((m, x) => Math.max(m, x.getInput().getMax([])[0] ?? 0), 0), 0);
|
|
155
|
+
const channels = anims.reduce((n, a) => n + a.listChannels().length, 0);
|
|
142
156
|
return {
|
|
143
157
|
triangles, vertices, drawCalls,
|
|
158
|
+
skinnedVertices, influences,
|
|
159
|
+
clipKeys: keys, clipSeconds: round(clipSeconds, 2), clipChannels: channels,
|
|
160
|
+
// Keys a second per channel: 30 is the house rate (resampled clips usually sit well under it).
|
|
161
|
+
clipRate: channels && clipSeconds ? round(keys / channels / (clipSeconds / Math.max(1, anims.length)), 1) : 0,
|
|
144
162
|
materials: seenMaterials.size,
|
|
145
163
|
textures,
|
|
146
164
|
textureGpuBytes: textures.reduce((s, t) => s + t.gpuBytes, 0),
|
|
@@ -155,6 +173,52 @@ export async function measureDoc(doc) {
|
|
|
155
173
|
};
|
|
156
174
|
}
|
|
157
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The rest-pose bounds of a model's skinned meshes, as a renderer draws them: every vertex through its joints
|
|
178
|
+
* (joint world matrix x inverse bind matrix, weighted), never through the mesh node's own transform (glTF ignores it
|
|
179
|
+
* for a skinned mesh). A rig in centimetres under a 0.01 armature, or one scaled by a wrapper node, measures true.
|
|
180
|
+
* Returns { min, max } or null when nothing is skinned. Up to 40,000 vertices are read (evenly spread).
|
|
181
|
+
*/
|
|
182
|
+
export function skinnedBounds(doc) {
|
|
183
|
+
const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity];
|
|
184
|
+
const v = [0, 0, 0]; const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
|
|
185
|
+
let any = false;
|
|
186
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
187
|
+
const skin = node.getSkin(); const mesh = node.getMesh();
|
|
188
|
+
if (!skin || !mesh) continue;
|
|
189
|
+
const joints = skin.listJoints();
|
|
190
|
+
const ibm = skin.getInverseBindMatrices()?.getArray() ?? null;
|
|
191
|
+
const mats = joints.map((jn, k) => mul4(jn.getWorldMatrix(), ibm ? Array.from(ibm.subarray(k * 16, k * 16 + 16)) : IDENTITY));
|
|
192
|
+
for (const prim of mesh.listPrimitives()) {
|
|
193
|
+
const P = prim.getAttribute('POSITION'); const J = prim.getAttribute('JOINTS_0'); const W = prim.getAttribute('WEIGHTS_0');
|
|
194
|
+
if (!P || !J || !W) continue;
|
|
195
|
+
const step = Math.max(1, Math.floor(P.getCount() / 40000));
|
|
196
|
+
for (let i = 0; i < P.getCount(); i += step) {
|
|
197
|
+
P.getElement(i, v); J.getElement(i, j); W.getElement(i, w);
|
|
198
|
+
let x = 0; let y = 0; let z = 0; let sum = 0;
|
|
199
|
+
for (let k = 0; k < 4; k++) {
|
|
200
|
+
const m = mats[j[k]]; const wk = w[k];
|
|
201
|
+
if (!m || !wk) continue;
|
|
202
|
+
x += wk * (m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12]); y += wk * (m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13]); z += wk * (m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]);
|
|
203
|
+
sum += wk;
|
|
204
|
+
}
|
|
205
|
+
if (sum <= 0) continue;
|
|
206
|
+
x /= sum; y /= sum; z /= sum;
|
|
207
|
+
if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z;
|
|
208
|
+
if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z;
|
|
209
|
+
any = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return any ? { min, max } : null;
|
|
214
|
+
}
|
|
215
|
+
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
216
|
+
function mul4(a, b) {
|
|
217
|
+
const o = new Array(16);
|
|
218
|
+
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) { let t = 0; for (let k = 0; k < 4; k++) t += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = t; }
|
|
219
|
+
return o;
|
|
220
|
+
}
|
|
221
|
+
|
|
158
222
|
/** The Khronos glTF-Validator's verdict: { errors, warnings, messages: [first few] } (null when it is not installed). */
|
|
159
223
|
export async function validateModel(bytes) {
|
|
160
224
|
const { validator } = await modelTools();
|
|
@@ -289,7 +353,19 @@ export async function optimiseModel(input, { out = null, triangles = 1500, textu
|
|
|
289
353
|
ops.push(`scale ${round(s, 4)} (to ${height} m)`);
|
|
290
354
|
}
|
|
291
355
|
}
|
|
292
|
-
|
|
356
|
+
if (hasRig && doc.getRoot().listSkins().length) {
|
|
357
|
+
// A skinned model's pivot is set on a wrapper over everything (its bones carry its mesh): gltf-transform's center
|
|
358
|
+
// measures a skinned mesh by its node, which a rig in centimetres gets wrong a hundredfold.
|
|
359
|
+
const b = skinnedBounds(doc);
|
|
360
|
+
if (b) {
|
|
361
|
+
const top = scene.listChildren();
|
|
362
|
+
const wrap = top.length === 1 && top[0].getName() === 'homie-scale' ? top[0] : null;
|
|
363
|
+
const holder = wrap ?? doc.createNode('homie-pivot');
|
|
364
|
+
if (!wrap) { for (const child of scene.listChildren()) { scene.removeChild(child); holder.addChild(child); } scene.addChild(holder); }
|
|
365
|
+
const t = holder.getTranslation();
|
|
366
|
+
holder.setTranslation([t[0] - (b.min[0] + b.max[0]) / 2, t[1] - b.min[1], t[2] - (b.min[2] + b.max[2]) / 2]);
|
|
367
|
+
}
|
|
368
|
+
} else await doc.transform(fn.center({ pivot: 'below' }));
|
|
293
369
|
ops.push('center bottom');
|
|
294
370
|
|
|
295
371
|
// Simplify to the triangle budget, loosening the error until it fits (a closed silhouette first).
|
package/lib/render3d.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* const jpg = await r.swatch(tokens, models, { title }) // a data: URL
|
|
8
8
|
* const l = await r.lineup(models, tokens) // { front, quarter, silhouettes, rows }
|
|
9
9
|
* const t = await r.thumb({ id, glb }) // { image, size }
|
|
10
|
+
* const f = await r.frames({ id, glb, anims }, tokens, { verbs: ['idle', 'run'], count: 12 }) // a character's clips, frame by frame
|
|
10
11
|
* });
|
|
11
12
|
*
|
|
12
13
|
* Models go in as { id, glb: <base64>, label }. Nothing is fetched but Google Fonts (the swatch's title font).
|
|
@@ -72,6 +73,7 @@ export async function withRenderer(fn, { log = () => {} } = {}) {
|
|
|
72
73
|
swatch: (tokens, models = [], opts = {}) => call('swatch', tokens, models, opts),
|
|
73
74
|
lineup: (models, tokens, opts = {}) => call('lineup', models, tokens, opts),
|
|
74
75
|
thumb: (model, opts = {}) => call('thumb', model, opts),
|
|
76
|
+
frames: (model, tokens, opts = {}) => call('frames', model, tokens, opts),
|
|
75
77
|
});
|
|
76
78
|
} finally {
|
|
77
79
|
await browser.close().catch(() => {});
|
package/lib/rig.mjs
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SKELETON STANDARD: one bone vocabulary for every character a studio makes, so clips, sockets, look-at and foot
|
|
3
|
+
* planting work on any of them (free, on this computer).
|
|
4
|
+
*
|
|
5
|
+
* Families
|
|
6
|
+
* humanoid people: the VRM 1.0 humanoid bone names (hips, spine, chest, neck, head, leftUpperArm, leftLowerArm,
|
|
7
|
+
* leftHand, leftUpperLeg, leftLowerLeg, leftFoot, leftToes, and the right side), 4 influences a vertex.
|
|
8
|
+
* In: Mixamo names (mixamorig:LeftArm, the de facto standard: Tripo's mixamo spec, Meshy, Rokoko),
|
|
9
|
+
* KayKit (upperarm.l), Blender (upper_arm.L, thigh.L), Unreal (upperarm_l, thigh_l, calf_l).
|
|
10
|
+
* mini chibi people of one piece per limb (Kenney's mini and blocky characters): hips, spine, head, and an
|
|
11
|
+
* upper arm and upper leg a side. Humanoid clips retarget onto it (a whole leg follows the thigh).
|
|
12
|
+
* quadruped four legs (Kenney's cube pets): body, head, neck, tail, frontLeftLeg, frontRightLeg, backLeftLeg,
|
|
13
|
+
* backRightLeg. Clips come from its own species; humanoid clips never fit it.
|
|
14
|
+
* parts anything else that moves by its nodes: its own clips only.
|
|
15
|
+
* A rig is skinned (bones bend a mesh) or rigid (each part a node that moves: Kenney's blocky characters and pets);
|
|
16
|
+
* both play clips through three.js's AnimationMixer the same way.
|
|
17
|
+
*
|
|
18
|
+
* skeletonOf(doc) the family, the map from the file's names to the standard, what is missing, the helper
|
|
19
|
+
* bones that move nothing, and a fingerprint: characters with one fingerprint share one
|
|
20
|
+
* clip library (public/anims/<skeleton>.glb)
|
|
21
|
+
* normaliseRig(doc, opts) the shipped character: joints renamed to the standard, helper bones that move nothing
|
|
22
|
+
* removed, every skinned part and every held thing (a sword under the hand) merged into ONE
|
|
23
|
+
* skinned mesh (one draw call; `keep` picks which held things stay: part swaps), clips taken out
|
|
24
|
+
*
|
|
25
|
+
* Bone names are matched after lower-casing and dropping separators, so "mixamorig:LeftUpLeg", "LeftUpLeg" and
|
|
26
|
+
* "left_up_leg" are one name. Fingers and anything unmatched keep their own names.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
/** The VRM 1.0 humanoid bones Homie uses (fingers keep their own names; three.js reads 4 influences, so do we). */
|
|
31
|
+
export const HUMANOID = Object.freeze([
|
|
32
|
+
'hips', 'spine', 'chest', 'upperChest', 'neck', 'head', 'jaw', 'leftEye', 'rightEye',
|
|
33
|
+
'leftShoulder', 'leftUpperArm', 'leftLowerArm', 'leftHand', 'rightShoulder', 'rightUpperArm', 'rightLowerArm', 'rightHand',
|
|
34
|
+
'leftUpperLeg', 'leftLowerLeg', 'leftFoot', 'leftToes', 'rightUpperLeg', 'rightLowerLeg', 'rightFoot', 'rightToes',
|
|
35
|
+
]);
|
|
36
|
+
/** VRM's required fifteen: a rig missing one of these is not a humanoid. */
|
|
37
|
+
export const HUMANOID_REQUIRED = Object.freeze(['hips', 'spine', 'head', 'leftUpperArm', 'leftLowerArm', 'leftHand', 'rightUpperArm', 'rightLowerArm', 'rightHand', 'leftUpperLeg', 'leftLowerLeg', 'leftFoot', 'rightUpperLeg', 'rightLowerLeg', 'rightFoot']);
|
|
38
|
+
export const MINI = Object.freeze(['hips', 'spine', 'head', 'leftUpperArm', 'rightUpperArm', 'leftUpperLeg', 'rightUpperLeg']);
|
|
39
|
+
export const QUADRUPED = Object.freeze(['body', 'neck', 'head', 'tail', 'frontLeftLeg', 'frontRightLeg', 'backLeftLeg', 'backRightLeg']);
|
|
40
|
+
/** Where held things go: the standard socket names and the bones that carry them when a rig has no slot bone. */
|
|
41
|
+
export const SOCKETS = Object.freeze({ rightHand: ['rightHandSlot', 'rightHand'], leftHand: ['leftHandSlot', 'leftHand'], head: ['headSlot', 'head'], back: ['upperChest', 'chest', 'spine'] });
|
|
42
|
+
|
|
43
|
+
export const FAMILIES = Object.freeze({
|
|
44
|
+
humanoid: { label: 'Homie humanoid (VRM names)', bones: HUMANOID, required: HUMANOID_REQUIRED, note: 'people: one vocabulary, so one clip set retargets onto any of them' },
|
|
45
|
+
mini: { label: 'Mini humanoid (one piece per limb)', bones: MINI, required: ['hips', 'head', 'leftUpperArm', 'rightUpperArm', 'leftUpperLeg', 'rightUpperLeg'], note: 'chibi people: humanoid clips retarget onto them, a whole limb following its upper bone' },
|
|
46
|
+
quadruped: { label: 'Quadruped (four legs)', bones: QUADRUPED, required: ['body', 'frontLeftLeg', 'frontRightLeg', 'backLeftLeg', 'backRightLeg'], note: 'animals: their own species\' clips' },
|
|
47
|
+
parts: { label: 'Moving parts', bones: [], required: [], note: 'its own clips only' },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Prefixes rigs put on every bone ("mixamorig:", "Armature|", "DEF-"); "rig" only with a separator, or "rightHand" loses it.
|
|
51
|
+
const key = (s) => String(s ?? '').toLowerCase().replace(/^(mixamorig\d*[:_]?|(armature|rig|bip0?1|def|org|mch)[:_. |-]+)/, '').replace(/[^a-z0-9]/g, '');
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* Name tables, by the key above. Order matters only inside a table: a rig names each bone once.
|
|
55
|
+
* Sources: Mixamo's mixamorig set (three-vrm's mixamoVRMRigMap, MIT), KayKit's rig (its CC0 packs), Blender's
|
|
56
|
+
* metarig and Rigify DEF bones, Unreal's mannequin, Kenney's mini and blocky characters and cube pets.
|
|
57
|
+
*/
|
|
58
|
+
const HUMANOID_NAMES = {
|
|
59
|
+
hips: ['hips', 'hip', 'pelvis', 'root_pelvis'],
|
|
60
|
+
spine: ['spine', 'spine01', 'spine1', 'spine001', 'torso', 'abdomen'],
|
|
61
|
+
chest: ['spine1', 'spine02', 'spine2', 'chest', 'spine002', 'spine003'],
|
|
62
|
+
upperChest: ['spine2', 'spine03', 'upperchest', 'spine004'],
|
|
63
|
+
neck: ['neck', 'neck01', 'neck1'],
|
|
64
|
+
head: ['head'],
|
|
65
|
+
jaw: ['jaw'],
|
|
66
|
+
leftEye: ['lefteye', 'eyel', 'eyeleft'],
|
|
67
|
+
rightEye: ['righteye', 'eyer', 'eyeright'],
|
|
68
|
+
leftShoulder: ['leftshoulder', 'shoulderl', 'claviclel', 'leftclavicle', 'lshoulder'],
|
|
69
|
+
leftUpperArm: ['leftarm', 'upperarml', 'leftupperarm', 'lupperarm', 'larm', 'armleft'],
|
|
70
|
+
leftLowerArm: ['leftforearm', 'lowerarml', 'forearml', 'leftlowerarm', 'lforearm'],
|
|
71
|
+
leftHand: ['lefthand', 'wristl', 'handl', 'lhand'],
|
|
72
|
+
rightShoulder: ['rightshoulder', 'shoulderr', 'clavicler', 'rightclavicle', 'rshoulder'],
|
|
73
|
+
rightUpperArm: ['rightarm', 'upperarmr', 'rightupperarm', 'rupperarm', 'rarm', 'armright'],
|
|
74
|
+
rightLowerArm: ['rightforearm', 'lowerarmr', 'forearmr', 'rightlowerarm', 'rforearm'],
|
|
75
|
+
rightHand: ['righthand', 'wristr', 'handr', 'rhand'],
|
|
76
|
+
leftUpperLeg: ['leftupleg', 'upperlegl', 'thighl', 'leftthigh', 'leftupperleg', 'lthigh', 'legleft'],
|
|
77
|
+
leftLowerLeg: ['leftleg', 'lowerlegl', 'shinl', 'calfl', 'leftshin', 'leftlowerleg', 'lcalf', 'leftcalf'],
|
|
78
|
+
leftFoot: ['leftfoot', 'footl', 'lfoot'],
|
|
79
|
+
leftToes: ['lefttoebase', 'toesl', 'toel', 'balll', 'lefttoe', 'ltoe'],
|
|
80
|
+
rightUpperLeg: ['rightupleg', 'upperlegr', 'thighr', 'rightthigh', 'rightupperleg', 'rthigh', 'legright'],
|
|
81
|
+
rightLowerLeg: ['rightleg', 'lowerlegr', 'shinr', 'calfr', 'rightshin', 'rightlowerleg', 'rcalf', 'rightcalf'],
|
|
82
|
+
rightFoot: ['rightfoot', 'footr', 'rfoot'],
|
|
83
|
+
rightToes: ['righttoebase', 'toesr', 'toer', 'ballr', 'righttoe', 'rtoe'],
|
|
84
|
+
leftHandSlot: ['handslotl', 'lefthandslot', 'weaponl', 'propl'],
|
|
85
|
+
rightHandSlot: ['handslotr', 'righthandslot', 'weaponr', 'propr'],
|
|
86
|
+
};
|
|
87
|
+
/** KayKit and Unreal suffixes (".l", "_l") collide with Mixamo's prefixes after keying; these settle it. */
|
|
88
|
+
const SUFFIXED = { upperarm: 'UpperArm', lowerarm: 'LowerArm', forearm: 'LowerArm', upperleg: 'UpperLeg', lowerleg: 'LowerLeg', thigh: 'UpperLeg', calf: 'LowerLeg', shin: 'LowerLeg', foot: 'Foot', toes: 'Toes', toe: 'Toes', ball: 'Toes', wrist: 'Hand', hand: 'Hand', clavicle: 'Shoulder', shoulder: 'Shoulder', upperarmtwist: null };
|
|
89
|
+
|
|
90
|
+
const MINI_NAMES = { hips: ['root', 'hips', 'body'], spine: ['torso', 'spine', 'chest'], head: ['head'], leftUpperArm: ['armleft', 'leftarm'], rightUpperArm: ['armright', 'rightarm'], leftUpperLeg: ['legleft', 'leftleg'], rightUpperLeg: ['legright', 'rightleg'] };
|
|
91
|
+
const QUAD_NAMES = { body: ['body', 'spine', 'torso'], neck: ['neck'], head: ['head'], tail: ['tail', 'tail01', 'tail1'], frontLeftLeg: ['legfrontleft', 'frontleftleg', 'legfl', 'frontlegl', 'leftfrontleg'], frontRightLeg: ['legfrontright', 'frontrightleg', 'legfr', 'frontlegr', 'rightfrontleg'], backLeftLeg: ['legbackleft', 'backleftleg', 'legbl', 'backlegl', 'hindlegl', 'leftbackleg', 'leftbackleg'], backRightLeg: ['legbackright', 'backrightleg', 'legbr', 'backlegr', 'hindlegr', 'rightbackleg'] };
|
|
92
|
+
|
|
93
|
+
/** A name read with a side suffix (KayKit "upperarm.l", Unreal "thigh_l", Blender "thigh.L"): its standard name. */
|
|
94
|
+
function suffixed(raw) {
|
|
95
|
+
const m = /^(?:.*?[:_. -])?([a-z]+?)(?:[_. -]?0*\d*)?[_. -](l|r|left|right)$/i.exec(String(raw).toLowerCase().replace(/^(mixamorig\d*|def|org|mch)[:_. -]*/, ''));
|
|
96
|
+
if (!m) return null;
|
|
97
|
+
const base = m[1].replace(/[^a-z]/g, '');
|
|
98
|
+
const side = m[2].startsWith('l') ? 'left' : 'right';
|
|
99
|
+
if (base === 'handslot' || base === 'weapon' || base === 'prop') return `${side}HandSlot`;
|
|
100
|
+
const part = SUFFIXED[base];
|
|
101
|
+
return part ? `${side}${part}` : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map a list of joint names to one family's standard names: { map: { std: name }, score }. */
|
|
105
|
+
function mapNames(names, table, { useSuffix = false } = {}) {
|
|
106
|
+
const map = {};
|
|
107
|
+
const taken = new Set();
|
|
108
|
+
// Exact keys first, in the table's order (so Mixamo's Spine/Spine1/Spine2 land on spine/chest/upperChest).
|
|
109
|
+
const keyed = names.map((n) => ({ n, k: key(n) }));
|
|
110
|
+
for (const [std, alts] of Object.entries(table)) {
|
|
111
|
+
// A rig already normalised names its bones by the standard itself.
|
|
112
|
+
for (const alt of [std.toLowerCase(), ...alts]) {
|
|
113
|
+
const hit = keyed.find((x) => x.k === alt && !taken.has(x.n));
|
|
114
|
+
if (hit) { map[std] = hit.n; taken.add(hit.n); break; }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (useSuffix) {
|
|
118
|
+
for (const n of names) {
|
|
119
|
+
if (taken.has(n)) continue;
|
|
120
|
+
const std = suffixed(n);
|
|
121
|
+
if (std && !map[std] && (table[std] || /HandSlot$/.test(std))) { map[std] = n; taken.add(n); }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The family and the standard-name map of a list of joint names (or node names of a rigid rig).
|
|
129
|
+
* Returns { family, map: { std: original }, missing: [std], extra: [original] }.
|
|
130
|
+
*/
|
|
131
|
+
export function mapSkeleton(names, parentOf = null) {
|
|
132
|
+
const hum = mapNames(names, HUMANOID_NAMES, { useSuffix: true });
|
|
133
|
+
// The spine chain by the tree, not by its numbers: Mixamo counts Spine, Spine1, Spine2 upward, Meshy counts Spine02,
|
|
134
|
+
// Spine01, Spine upward. Whatever they are called, the joints between the hips and the neck (or head) are spine,
|
|
135
|
+
// chest and upper chest, in that order from the hips.
|
|
136
|
+
if (parentOf && hum.hips && (hum.neck || hum.head)) {
|
|
137
|
+
const chain = [];
|
|
138
|
+
let at = parentOf[hum.neck ?? hum.head];
|
|
139
|
+
while (at && at !== hum.hips && chain.length < 8) { chain.unshift(at); at = parentOf[at]; }
|
|
140
|
+
if (at === hum.hips && chain.length) {
|
|
141
|
+
for (const k of ['spine', 'chest', 'upperChest']) delete hum[k];
|
|
142
|
+
['spine', 'chest', 'upperChest'].forEach((k, i) => { if (chain[i]) hum[k] = chain[i]; });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Mixamo's chain is Spine, Spine1, Spine2: spine, chest, upperChest. KayKit's is spine, chest.
|
|
146
|
+
const humMissing = HUMANOID_REQUIRED.filter((b) => !hum[b]);
|
|
147
|
+
if (humMissing.length <= 1 && hum.hips && hum.leftUpperLeg && hum.leftLowerLeg) {
|
|
148
|
+
return { family: 'humanoid', map: hum, missing: humMissing, extra: names.filter((n) => !Object.values(hum).includes(n)) };
|
|
149
|
+
}
|
|
150
|
+
const quad = mapNames(names, QUAD_NAMES);
|
|
151
|
+
if (FAMILIES.quadruped.required.every((b) => quad[b])) return { family: 'quadruped', map: quad, missing: QUADRUPED.filter((b) => !quad[b]), extra: names.filter((n) => !Object.values(quad).includes(n)) };
|
|
152
|
+
const mini = mapNames(names, MINI_NAMES);
|
|
153
|
+
if (FAMILIES.mini.required.every((b) => mini[b])) return { family: 'mini', map: mini, missing: MINI.filter((b) => !mini[b]), extra: names.filter((n) => !Object.values(mini).includes(n)) };
|
|
154
|
+
return { family: names.length ? 'parts' : 'none', map: {}, missing: [], extra: names };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ------------------------------------------------------------------ reading a rig from a glTF Document */
|
|
158
|
+
|
|
159
|
+
const parentsOf = (root) => { const p = new Map(); for (const n of root.listNodes()) for (const c of n.listChildren()) p.set(c, n); return p; };
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The joints of a Document's character: a skin's joints (skinned), else the nodes its clips move (rigid parts).
|
|
163
|
+
* Returns { skinned, joints: Node[], skin } (joints in hierarchy order, parents first).
|
|
164
|
+
*/
|
|
165
|
+
export function jointsOf(doc) {
|
|
166
|
+
const root = doc.getRoot();
|
|
167
|
+
const skins = root.listSkins();
|
|
168
|
+
if (skins.length) {
|
|
169
|
+
// Characters with several skins (rare) share one skeleton in practice: take the biggest.
|
|
170
|
+
const skin = skins.reduce((a, b) => (b.listJoints().length > a.listJoints().length ? b : a));
|
|
171
|
+
return { skinned: true, skin, joints: depthOrder(root, skin.listJoints()) };
|
|
172
|
+
}
|
|
173
|
+
const moved = new Set();
|
|
174
|
+
for (const a of root.listAnimations()) for (const c of a.listChannels()) { const n = c.getTargetNode(); if (n) moved.add(n); }
|
|
175
|
+
// Every part under a moving part is a part too (a head no clip happens to turn is still the head).
|
|
176
|
+
for (const n of [...moved]) { const walk = (x) => { for (const c of x.listChildren()) { if (!moved.has(c) && (c.getMesh() || c.listChildren().length)) moved.add(c); walk(c); } }; walk(n); }
|
|
177
|
+
// A rigid rig whose clips went to its clip library: normaliseRig marked its moving parts.
|
|
178
|
+
if (!moved.size) for (const n of root.listNodes()) if (n.getExtras()?.homie?.joint) moved.add(n);
|
|
179
|
+
return { skinned: false, skin: null, joints: depthOrder(root, [...moved]) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function depthOrder(root, nodes) {
|
|
183
|
+
const parents = parentsOf(root);
|
|
184
|
+
const depth = (n) => { let d = 0; let p = parents.get(n); while (p) { d += 1; p = parents.get(p); } return d; };
|
|
185
|
+
return [...nodes].sort((a, b) => depth(a) - depth(b));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Total vertex weight per joint index of a skin (a joint that moves nothing has 0). */
|
|
189
|
+
export function jointWeights(doc, skin) {
|
|
190
|
+
const joints = skin.listJoints();
|
|
191
|
+
const used = new Float64Array(joints.length);
|
|
192
|
+
const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
|
|
193
|
+
let influences = 4;
|
|
194
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
195
|
+
if (node.getSkin() !== skin || !node.getMesh()) continue;
|
|
196
|
+
for (const p of node.getMesh().listPrimitives()) {
|
|
197
|
+
if (p.getAttribute('JOINTS_1')) influences = 8;
|
|
198
|
+
const J = p.getAttribute('JOINTS_0'); const W = p.getAttribute('WEIGHTS_0');
|
|
199
|
+
if (!J || !W) continue;
|
|
200
|
+
for (let i = 0; i < J.getCount(); i++) { J.getElement(i, j); W.getElement(i, w); for (let k = 0; k < 4; k++) if (j[k] < used.length) used[j[k]] += w[k]; }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { used, influences };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* skeletonOf(doc): what kind of rig a character has. { family, skinned, bones (joints), deform (joints that move a
|
|
208
|
+
* vertex, or every joint of a rigid rig), map: { std: original }, missing, helpers (joints that move nothing and lead
|
|
209
|
+
* to nothing that does), influences, fingerprint, id }.
|
|
210
|
+
*/
|
|
211
|
+
export function skeletonOf(doc) {
|
|
212
|
+
const { skinned, skin, joints } = jointsOf(doc);
|
|
213
|
+
if (!joints.length) return { family: 'none', skinned: false, bones: 0, deform: 0, map: {}, missing: [], helpers: [], influences: 0, fingerprint: null, id: null, joints: [] };
|
|
214
|
+
const names = joints.map((n) => n.getName());
|
|
215
|
+
const tree = parentsOf(doc.getRoot());
|
|
216
|
+
const parentOf = Object.fromEntries(joints.map((n) => [n.getName(), tree.get(n)?.getName() ?? null]));
|
|
217
|
+
const { family, map, missing } = mapSkeleton(names, parentOf);
|
|
218
|
+
let helpers = [];
|
|
219
|
+
let deform = joints.length;
|
|
220
|
+
let influences = 4;
|
|
221
|
+
if (skinned) {
|
|
222
|
+
const w = jointWeights(doc, skin);
|
|
223
|
+
influences = w.influences;
|
|
224
|
+
const all = skin.listJoints();
|
|
225
|
+
const weighted = new Set(all.filter((_, i) => w.used[i] > 1e-6));
|
|
226
|
+
deform = weighted.size;
|
|
227
|
+
// A helper moves no vertex, has no weighted joint under it, is not a mapped bone or a socket: IK targets, poles.
|
|
228
|
+
const parents = parentsOf(doc.getRoot());
|
|
229
|
+
const keepers = new Set([...weighted, ...Object.values(map).map((n) => all.find((x) => x.getName() === n)).filter(Boolean)]);
|
|
230
|
+
for (const n of [...keepers]) { let p = parents.get(n); while (p && all.includes(p)) { keepers.add(p); p = parents.get(p); } }
|
|
231
|
+
helpers = all.filter((n) => !keepers.has(n)).map((n) => n.getName());
|
|
232
|
+
}
|
|
233
|
+
const std = Object.fromEntries(Object.entries(map).map(([s, o]) => [o, s]));
|
|
234
|
+
const parents = parentsOf(doc.getRoot());
|
|
235
|
+
const rows = joints.filter((n) => !helpers.includes(n.getName())).map((n) => {
|
|
236
|
+
const r = n.getRotation();
|
|
237
|
+
return `${std[n.getName()] ?? n.getName()}<${std[parents.get(n)?.getName()] ?? parents.get(n)?.getName() ?? ''}:${r.map((v) => (Math.round(v * 50) / 50).toFixed(2)).join(',')}`;
|
|
238
|
+
});
|
|
239
|
+
const fingerprint = createHash('sha256').update(`${family}|${skinned ? 's' : 'r'}|${rows.join(';')}`).digest('hex').slice(0, 10);
|
|
240
|
+
return { family, skinned, bones: joints.length, deform, map, missing, helpers, influences, fingerprint, id: `${family}-${fingerprint.slice(0, 6)}`, joints: names };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ------------------------------------------------------------------ the shipped character */
|
|
244
|
+
|
|
245
|
+
const mat4 = () => new Float64Array(16);
|
|
246
|
+
function mul(a, b) {
|
|
247
|
+
const o = mat4();
|
|
248
|
+
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; }
|
|
249
|
+
return o;
|
|
250
|
+
}
|
|
251
|
+
function invert(m) {
|
|
252
|
+
const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
|
|
253
|
+
const b00 = a00 * a11 - a01 * a10; const b01 = a00 * a12 - a02 * a10; const b02 = a00 * a13 - a03 * a10; const b03 = a01 * a12 - a02 * a11;
|
|
254
|
+
const b04 = a01 * a13 - a03 * a11; const b05 = a02 * a13 - a03 * a12; const b06 = a20 * a31 - a21 * a30; const b07 = a20 * a32 - a22 * a30;
|
|
255
|
+
const b08 = a20 * a33 - a23 * a30; const b09 = a21 * a32 - a22 * a31; const b10 = a21 * a33 - a23 * a31; const b11 = a22 * a33 - a23 * a32;
|
|
256
|
+
const det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
|
|
257
|
+
if (!det) return null;
|
|
258
|
+
const d = 1 / det;
|
|
259
|
+
return Float64Array.from([
|
|
260
|
+
(a11 * b11 - a12 * b10 + a13 * b09) * d, (a02 * b10 - a01 * b11 - a03 * b09) * d, (a31 * b05 - a32 * b04 + a33 * b03) * d, (a22 * b04 - a21 * b05 - a23 * b03) * d,
|
|
261
|
+
(a12 * b08 - a10 * b11 - a13 * b07) * d, (a00 * b11 - a02 * b08 + a03 * b07) * d, (a32 * b02 - a30 * b05 - a33 * b01) * d, (a20 * b05 - a22 * b02 + a23 * b01) * d,
|
|
262
|
+
(a10 * b10 - a11 * b08 + a13 * b06) * d, (a01 * b08 - a00 * b10 - a03 * b06) * d, (a30 * b04 - a31 * b02 + a33 * b00) * d, (a21 * b02 - a20 * b04 - a23 * b00) * d,
|
|
263
|
+
(a11 * b07 - a10 * b09 - a12 * b06) * d, (a00 * b09 - a01 * b07 + a02 * b06) * d, (a31 * b01 - a30 * b03 - a32 * b00) * d, (a20 * b03 - a21 * b01 + a22 * b00) * d,
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
const apply = (m, v) => [m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]];
|
|
267
|
+
const applyDir = (m, v) => { const o = [m[0] * v[0] + m[4] * v[1] + m[8] * v[2], m[1] * v[0] + m[5] * v[1] + m[9] * v[2], m[2] * v[0] + m[6] * v[1] + m[10] * v[2]]; const l = Math.hypot(...o) || 1; return o.map((x) => x / l); };
|
|
268
|
+
|
|
269
|
+
/** A node's world matrix from its own and its parents' rest transforms. */
|
|
270
|
+
const worldOf = (node) => Float64Array.from(node.getWorldMatrix());
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The character a game ships, from a raw rigged model (a Document, changed in place):
|
|
274
|
+
* rename joints to the standard names (the map from skeletonOf), so clips, sockets and look-at find them
|
|
275
|
+
* helpers joints that move nothing (IK targets, poles) removed from the skin and the tree
|
|
276
|
+
* merge every skinned part and every held thing under a joint (a sword in the hand, a hat on the head) into ONE
|
|
277
|
+
* skinned primitive per material: one draw call. `keep`: the held things to keep (names, or a test); the
|
|
278
|
+
* rest are dropped (part swaps); null keeps them all
|
|
279
|
+
* clips every clip taken out and returned (they go to the skeleton's clip library)
|
|
280
|
+
* Returns { skeleton (after), renamed, removedHelpers, merged, dropped, kept, clips: Animation[] (detached copies are
|
|
281
|
+
* not made: the caller reads them before disposing) }.
|
|
282
|
+
*/
|
|
283
|
+
export function normaliseRig(doc, { keep = null, merge = true, rename = true, helpers = true, takeClips = true } = {}) {
|
|
284
|
+
const root = doc.getRoot();
|
|
285
|
+
const before = skeletonOf(doc);
|
|
286
|
+
const { skinned, skin } = jointsOf(doc);
|
|
287
|
+
const out = { renamed: 0, removedHelpers: [], merged: 0, dropped: [], kept: [] };
|
|
288
|
+
if (rename) {
|
|
289
|
+
for (const [std, orig] of Object.entries(before.map)) {
|
|
290
|
+
const n = root.listNodes().find((x) => x.getName() === orig);
|
|
291
|
+
if (n && n.getName() !== std) { n.setName(std); out.renamed += 1; }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (skinned && helpers && before.helpers.length) {
|
|
295
|
+
out.removedHelpers = removeJoints(doc, skin, before.helpers);
|
|
296
|
+
}
|
|
297
|
+
if (skinned && merge) Object.assign(out, mergeCharacter(doc, skin, keep));
|
|
298
|
+
// A rigid rig (parts that move by their nodes) keeps knowing its joints once its clips are gone.
|
|
299
|
+
if (!skinned) for (const n of jointsOf(doc).joints) n.setExtras({ ...(n.getExtras() ?? {}), homie: { ...(n.getExtras()?.homie ?? {}), joint: true } });
|
|
300
|
+
// The clips go to the skeleton's clip library (lib/clips.mjs bakes them before this runs); the model ships without.
|
|
301
|
+
if (takeClips) for (const a of root.listAnimations()) a.dispose();
|
|
302
|
+
return { ...out, before, after: skeletonOf(doc) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Remove joints from a skin (and the tree) and re-index every vertex's JOINTS_0. Returns the removed names. */
|
|
306
|
+
function removeJoints(doc, skin, names) {
|
|
307
|
+
const all = skin.listJoints();
|
|
308
|
+
const gone = all.filter((n) => names.includes(n.getName()));
|
|
309
|
+
if (!gone.length) return [];
|
|
310
|
+
const keepIdx = all.map((n, i) => (gone.includes(n) ? -1 : i)).filter((i) => i >= 0);
|
|
311
|
+
const remap = new Int32Array(all.length).fill(0);
|
|
312
|
+
keepIdx.forEach((old, neu) => { remap[old] = neu; });
|
|
313
|
+
const ibm = skin.getInverseBindMatrices();
|
|
314
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
315
|
+
if (node.getSkin() !== skin || !node.getMesh()) continue;
|
|
316
|
+
for (const p of node.getMesh().listPrimitives()) {
|
|
317
|
+
const J = p.getAttribute('JOINTS_0'); const W = p.getAttribute('WEIGHTS_0');
|
|
318
|
+
if (!J) continue;
|
|
319
|
+
const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
|
|
320
|
+
for (let i = 0; i < J.getCount(); i++) {
|
|
321
|
+
J.getElement(i, j); W.getElement(i, w);
|
|
322
|
+
// A removed joint carried no weight: its slot is zeroed and points at joint 0.
|
|
323
|
+
for (let k = 0; k < 4; k++) { if (remap[j[k]] === undefined || gone.includes(all[j[k]])) { j[k] = 0; w[k] = 0; } else j[k] = remap[j[k]]; }
|
|
324
|
+
J.setElement(i, j); W.setElement(i, w);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (ibm) {
|
|
329
|
+
const arr = ibm.getArray();
|
|
330
|
+
const next = new Float32Array(keepIdx.length * 16);
|
|
331
|
+
keepIdx.forEach((old, neu) => next.set(arr.subarray(old * 16, old * 16 + 16), neu * 16));
|
|
332
|
+
ibm.setArray(next);
|
|
333
|
+
}
|
|
334
|
+
const removed = gone.map((n) => n.getName());
|
|
335
|
+
for (const n of gone) skin.removeJoint(n);
|
|
336
|
+
// Out of the tree too (a helper's children are helpers as well).
|
|
337
|
+
for (const n of gone) n.dispose();
|
|
338
|
+
return removed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Merge a character into one skinned primitive per material. Skinned parts are already in bind space; a held thing
|
|
343
|
+
* (a rigid mesh under a joint) becomes skinned to that joint with full weight: its vertices are carried into bind
|
|
344
|
+
* space through the joint's bind matrix. Returns { merged, dropped, kept }.
|
|
345
|
+
*/
|
|
346
|
+
function mergeCharacter(doc, skin, keep) {
|
|
347
|
+
const root = doc.getRoot();
|
|
348
|
+
const joints = skin.listJoints();
|
|
349
|
+
const parents = parentsOf(root);
|
|
350
|
+
const ibmArr = skin.getInverseBindMatrices()?.getArray() ?? null;
|
|
351
|
+
const bindOf = (ji) => (ibmArr ? invert(Float64Array.from(ibmArr.subarray(ji * 16, ji * 16 + 16))) : worldOf(joints[ji]));
|
|
352
|
+
const wants = (name) => (keep === null || keep === undefined ? true : typeof keep === 'function' ? keep(name) : keep.some((k) => k.toLowerCase() === String(name).toLowerCase()));
|
|
353
|
+
const parts = []; // { node, prims, rigid: jointIndex | null }
|
|
354
|
+
const dropped = []; const kept = [];
|
|
355
|
+
for (const node of root.listNodes()) {
|
|
356
|
+
const mesh = node.getMesh();
|
|
357
|
+
if (!mesh) continue;
|
|
358
|
+
if (node.getSkin() === skin) { parts.push({ node, rigid: null }); continue; }
|
|
359
|
+
if (node.getSkin()) continue;
|
|
360
|
+
// A rigid mesh: under which joint?
|
|
361
|
+
let p = parents.get(node); let chain = [node];
|
|
362
|
+
while (p && !joints.includes(p)) { chain.unshift(p); p = parents.get(p); }
|
|
363
|
+
if (!p) continue;
|
|
364
|
+
if (!wants(node.getName())) { dropped.push(node.getName()); node.setMesh(null); continue; }
|
|
365
|
+
kept.push(node.getName());
|
|
366
|
+
parts.push({ node, rigid: joints.indexOf(p), chain });
|
|
367
|
+
}
|
|
368
|
+
if (parts.length < 2) return { merged: 0, dropped, kept };
|
|
369
|
+
// Group primitives by material and by the attributes they can share.
|
|
370
|
+
const groups = new Map();
|
|
371
|
+
for (const part of parts) {
|
|
372
|
+
for (const prim of part.node.getMesh().listPrimitives()) {
|
|
373
|
+
if (prim.getMode() !== 4 || !prim.getIndices()) return { merged: 0, dropped, kept };
|
|
374
|
+
const k = `${prim.getMaterial()?.getName() ?? ''}#${root.listMaterials().indexOf(prim.getMaterial())}`;
|
|
375
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
376
|
+
groups.get(k).push({ part, prim });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const buffer = root.listBuffers()[0] ?? doc.createBuffer();
|
|
380
|
+
const target = parts.find((p) => p.rigid === null)?.node;
|
|
381
|
+
if (!target) return { merged: 0, dropped, kept };
|
|
382
|
+
const mesh = doc.createMesh(`${target.getName() || 'character'}`);
|
|
383
|
+
let merged = 0;
|
|
384
|
+
for (const items of groups.values()) {
|
|
385
|
+
const semantics = ['POSITION', 'NORMAL', 'TEXCOORD_0'].filter((s) => items.every(({ prim }) => prim.getAttribute(s)));
|
|
386
|
+
const pos = []; const nor = []; const uv = []; const jo = []; const we = []; const idx = [];
|
|
387
|
+
let base = 0;
|
|
388
|
+
for (const { part, prim } of items) {
|
|
389
|
+
const P = prim.getAttribute('POSITION'); const N = prim.getAttribute('NORMAL'); const T = prim.getAttribute('TEXCOORD_0');
|
|
390
|
+
const J = prim.getAttribute('JOINTS_0'); const W = prim.getAttribute('WEIGHTS_0');
|
|
391
|
+
let m = null;
|
|
392
|
+
if (part.rigid !== null) {
|
|
393
|
+
// Rigid: its world rest transform, carried into the bind space of the joint it hangs from.
|
|
394
|
+
const world = worldOf(part.node);
|
|
395
|
+
const jointWorld = worldOf(joints[part.rigid]);
|
|
396
|
+
m = mul(bindOf(part.rigid), mul(invert(jointWorld), world));
|
|
397
|
+
}
|
|
398
|
+
const v = [0, 0, 0]; const n = [0, 0, 0]; const t = [0, 0]; const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
|
|
399
|
+
for (let i = 0; i < P.getCount(); i++) {
|
|
400
|
+
P.getElement(i, v);
|
|
401
|
+
pos.push(...(m ? apply(m, v) : v));
|
|
402
|
+
if (semantics.includes('NORMAL')) { N.getElement(i, n); nor.push(...(m ? applyDir(m, n) : n)); }
|
|
403
|
+
if (semantics.includes('TEXCOORD_0')) { T.getElement(i, t); uv.push(t[0], t[1]); }
|
|
404
|
+
if (part.rigid !== null) { jo.push(part.rigid, 0, 0, 0); we.push(1, 0, 0, 0); }
|
|
405
|
+
else { J.getElement(i, j); W.getElement(i, w); jo.push(...j); we.push(...w); }
|
|
406
|
+
}
|
|
407
|
+
const I = prim.getIndices();
|
|
408
|
+
for (let i = 0; i < I.getCount(); i++) idx.push(I.getScalar(i) + base);
|
|
409
|
+
base += P.getCount();
|
|
410
|
+
}
|
|
411
|
+
const prim = doc.createPrimitive().setMaterial(items[0].prim.getMaterial());
|
|
412
|
+
prim.setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(new Float32Array(pos)).setBuffer(buffer));
|
|
413
|
+
if (nor.length) prim.setAttribute('NORMAL', doc.createAccessor().setType('VEC3').setArray(new Float32Array(nor)).setBuffer(buffer));
|
|
414
|
+
if (uv.length) prim.setAttribute('TEXCOORD_0', doc.createAccessor().setType('VEC2').setArray(new Float32Array(uv)).setBuffer(buffer));
|
|
415
|
+
prim.setAttribute('JOINTS_0', doc.createAccessor().setType('VEC4').setArray(joints.length > 255 ? new Uint16Array(jo) : new Uint8Array(jo)).setBuffer(buffer));
|
|
416
|
+
prim.setAttribute('WEIGHTS_0', doc.createAccessor().setType('VEC4').setArray(new Float32Array(we)).setBuffer(buffer));
|
|
417
|
+
prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(base > 65535 ? new Uint32Array(idx) : new Uint16Array(idx)).setBuffer(buffer));
|
|
418
|
+
mesh.addPrimitive(prim);
|
|
419
|
+
merged += items.length;
|
|
420
|
+
}
|
|
421
|
+
// The merged mesh hangs where the first skinned part did; every part's own node loses its mesh.
|
|
422
|
+
for (const part of parts) { const old = part.node.getMesh(); part.node.setMesh(null); if (part.node !== target) part.node.setSkin(null); if (old && !old.listParents().some((x) => x.propertyType === 'Node')) old.dispose(); }
|
|
423
|
+
target.setMesh(mesh).setSkin(skin).setName(target.getName());
|
|
424
|
+
return { merged, dropped, kept };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The standard name of a socket on a rig (`rightHand` -> its slot bone, else the hand), from a list of joint names. */
|
|
428
|
+
export function socketBone(names, socket) {
|
|
429
|
+
for (const n of SOCKETS[socket] ?? [socket]) if (names.includes(n)) return n;
|
|
430
|
+
return null;
|
|
431
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -289,6 +289,13 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
289
289
|
with its licence; \`RIGHTS.md\` and the credits page follow. \`assets check <id>\` holds a game to the phone
|
|
290
290
|
budgets; \`assets lineup <id>\` shows everything at true scale. A three.js game loads models through
|
|
291
291
|
\`@homie-rocks/studio/assets\` (\`createModels\`); \`game new <id> --from gem-rush-3d\` starts one.
|
|
292
|
+
- **Characters and clips**: an animated library character (\`assets find "knight" --kind character\`) comes in
|
|
293
|
+
phone-sized, one draw call, its bones named by the skeleton standard, its clips in one shared clip library per
|
|
294
|
+
skeleton (\`public/anims/<skeleton>.glb\`); \`anim plan <id>\` lists each one's clips against the verbs the game
|
|
295
|
+
needs, \`anim add <id> <asset> --verbs jump,attack\` retargets more onto it (free), \`anim preview <id>\` draws
|
|
296
|
+
them looping. A game plays them through \`@homie-rocks/studio/animate\` (\`loadCharacter\`: blends, hits, jumps,
|
|
297
|
+
lean, look-at, crowds; every number a Game Lab tunable); \`game new <id> --from hero-rush-3d\` starts one. The
|
|
298
|
+
plugin's \`animate\` skill has the rest.
|
|
292
299
|
- **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
|
|
293
300
|
generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
|
|
294
301
|
checked by SHA-256, and a grey placeholder for the rest.
|
package/lib/studio.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* current directory, and its games.
|
|
4
4
|
*/
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
|
|
9
9
|
|
|
@@ -208,7 +208,10 @@ async function fetchStarterModels(dest, only = null) {
|
|
|
208
208
|
}
|
|
209
209
|
const missing = [];
|
|
210
210
|
let fetched = 0;
|
|
211
|
-
|
|
211
|
+
// A character the starter names with a `rig` recipe goes through the character pipeline (lib/characters.mjs): made
|
|
212
|
+
// phone-sized, its held things picked, its clips baked into its skeleton's clip library. Everything else is copied.
|
|
213
|
+
const rigged = wanted.filter((a) => a.rig && ['character', 'creature'].includes(a.kind));
|
|
214
|
+
for (const a of wanted.filter((x) => !rigged.includes(x))) {
|
|
212
215
|
const item = index.items.find((x) => x.id === a.from.item);
|
|
213
216
|
const f = a.files.find((x) => x.role === 'model');
|
|
214
217
|
if (!item) { missing.push({ asset: a.id, item: a.from.item, why: `the library has no ${a.from.item}` }); continue; }
|
|
@@ -222,7 +225,19 @@ async function fetchStarterModels(dest, only = null) {
|
|
|
222
225
|
} catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
|
|
223
226
|
}
|
|
224
227
|
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
225
|
-
|
|
228
|
+
let baked = 0;
|
|
229
|
+
if (rigged.length) {
|
|
230
|
+
const { addCharacter } = await import('./characters.mjs');
|
|
231
|
+
const root = join(dest, '..', '..');
|
|
232
|
+
const game = basename(dest);
|
|
233
|
+
for (const a of rigged) {
|
|
234
|
+
try {
|
|
235
|
+
await addCharacter(root, game, { item: a.from.item, as: a.id, kind: a.kind, card: a.card ?? null, height: a.rig.heightM ?? null, keep: a.rig.keep ?? null, verbs: a.rig.verbs ?? [], lib, index });
|
|
236
|
+
fetched += 1; baked += 1;
|
|
237
|
+
} catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { fetched, missing, from: lib.base, ...(baked ? { characters: baked } : {}) };
|
|
226
241
|
}
|
|
227
242
|
|
|
228
243
|
/**
|
package/lib/style-board.mjs
CHANGED
|
@@ -170,7 +170,9 @@ export async function assetsLineup(root, id, { log = () => {} } = {}) {
|
|
|
170
170
|
// `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
|
|
171
171
|
// lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
|
|
172
172
|
const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
|
|
173
|
-
|
|
173
|
+
// A character stands in its idle pose from its skeleton's clip library (not its bind pose: arms out in a T).
|
|
174
|
+
const anims = a.rig?.anims && existsSync(join(root, 'games', id, a.rig.anims)) ? readFileSync(join(root, 'games', id, a.rig.anims)).toString('base64') : null;
|
|
175
|
+
models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(anims ? { anims, pose: 'idle', poseAt: 0.3 } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
|
|
174
176
|
}
|
|
175
177
|
if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
|
|
176
178
|
const dir = join(root, '.studio', 'art', id);
|