@genex-ai/cli-demo 0.87.0-dev.218 → 0.90.0-dev.227
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 +1664 -45
- package/package.json +1 -1
- package/templates/motion/anim-runtime.js +265 -0
- package/templates/motion/ik.js +75 -0
- package/templates/motion/motion.config.json +11 -0
- package/templates/motion/rigs.js +563 -0
- package/templates/motion/sets/jumps.json +1 -0
- package/templates/motion/sets/rifle.json +1 -0
- package/templates/skills/genex-ai-character/SKILL.md +24 -0
- package/templates/skills/genex-ai-character/references/motion.md +145 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +4 -0
- package/templates/skills/genex-threejs-creatures/SKILL.md +5 -0
- package/templates/skills/genex-threejs-procedural-animation/SKILL.md +6 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// rigs.js — the genex motion retarget formulas (AG-823, vendored by
|
|
2
|
+
// `genex motion install`): load an avatar and retarget ARDY CoreSkeleton27
|
|
3
|
+
// locals onto it. THIS FILE IS YOURS — the formulas are meant to be read and
|
|
4
|
+
// tuned in-place; the shipped versions were verified to 0.0-0.3° (VRM) /
|
|
5
|
+
// 0.0-0.4° (Meshy/Mixamo GLB) bone-direction error. Two transport formulas:
|
|
6
|
+
//
|
|
7
|
+
// VRM (three-vrm normalized bones) — normalized bones rest at IDENTITY world
|
|
8
|
+
// orientation, but after rotateVRM0 the whole rig lives in a frame yawed 180°
|
|
9
|
+
// about Y. Transport is frame CONJUGATION per local: q' = M·L·M⁻¹ (M = Y180 —
|
|
10
|
+
// negate quat x,z), root local = G·M, plus the rest-correction sandwich
|
|
11
|
+
// C0(parent)⁻¹ · q' · C0(bone). Unmapped ARDY joints (Spine3, hand ends) fold
|
|
12
|
+
// into the nearest mapped descendant by composing locals down the chain.
|
|
13
|
+
//
|
|
14
|
+
// Mixamo (real GLB skeletons — Meshy/Mixamo characters) — joints rest at
|
|
15
|
+
// arbitrary NON-identity orientations (bones point along +Y of their own local
|
|
16
|
+
// frame) and the hierarchy can differ from ARDY's (3-bone spine, no hand ends),
|
|
17
|
+
// so locals can't transport bone-by-bone. Transport the WORLD rotation instead:
|
|
18
|
+
// ARDY rest orientations are identity, so the source FK world rotation D(j) IS
|
|
19
|
+
// the delta from rest. Target bone world goal: W(b) = D(j) · A(b) · Wrest(b),
|
|
20
|
+
// where A(b) is the constant rest alignment (avatar bind bone direction →
|
|
21
|
+
// ARDY rest bone direction, minimal arc — this is what absorbs an A-pose bind);
|
|
22
|
+
// locals recover top-down: L(b) = W(parentBone)⁻¹ · W(b). Unmapped source
|
|
23
|
+
// joints fold in automatically through the FK product, and unmapped TARGET
|
|
24
|
+
// nodes between bones keep their bind locals (relRest). Frame transport is
|
|
25
|
+
// identity — ARDY and glTF characters both face +Z with left = +X.
|
|
26
|
+
//
|
|
27
|
+
// Verification stays non-circular for both: pages compare scene-graph bone
|
|
28
|
+
// directions (geoNode) against the npz posed_joints via rig.dirErrors().
|
|
29
|
+
|
|
30
|
+
import * as THREE from "three";
|
|
31
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
32
|
+
import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
|
|
33
|
+
|
|
34
|
+
const CORE_TO_VRM = {
|
|
35
|
+
Hips: "hips", Spine: "spine", Spine1: "chest", Spine2: "upperChest",
|
|
36
|
+
Neck: "neck", Head: "head",
|
|
37
|
+
RightShoulder: "rightShoulder", RightArm: "rightUpperArm", RightForeArm: "rightLowerArm", RightHand: "rightHand",
|
|
38
|
+
LeftShoulder: "leftShoulder", LeftArm: "leftUpperArm", LeftForeArm: "leftLowerArm", LeftHand: "leftHand",
|
|
39
|
+
RightUpLeg: "rightUpperLeg", RightLeg: "rightLowerLeg", RightFoot: "rightFoot", RightToeBase: "rightToes",
|
|
40
|
+
LeftUpLeg: "leftUpperLeg", LeftLeg: "leftLowerLeg", LeftFoot: "leftFoot", LeftToeBase: "leftToes",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// full-resolution primary chain in ARDY joint names; rest correction and metric
|
|
44
|
+
// segments resolve through unmapped joints by walking this until a mapped one
|
|
45
|
+
const PRIMARY_NEXT = {
|
|
46
|
+
Hips: null,
|
|
47
|
+
Spine: "Spine1", Spine1: "Spine2", Spine2: "Spine3", Spine3: "Neck",
|
|
48
|
+
Neck: "Head", Head: null,
|
|
49
|
+
RightShoulder: "RightArm", RightArm: "RightForeArm", RightForeArm: "RightHand",
|
|
50
|
+
RightHand: "RightHandEnd", RightHandEnd: null, RightHandThumb1: null,
|
|
51
|
+
LeftShoulder: "LeftArm", LeftArm: "LeftForeArm", LeftForeArm: "LeftHand",
|
|
52
|
+
LeftHand: "LeftHandEnd", LeftHandEnd: null, LeftHandThumb1: null,
|
|
53
|
+
RightUpLeg: "RightLeg", RightLeg: "RightFoot", RightFoot: "RightToeBase", RightToeBase: null,
|
|
54
|
+
LeftUpLeg: "LeftLeg", LeftLeg: "LeftFoot", LeftFoot: "LeftToeBase", LeftToeBase: null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Mixamo/Meshy bone-name aliases (normalized: lowercase, mixamorig prefix and
|
|
58
|
+
// non-alphanumerics stripped). Spine bones are matched STRUCTURALLY, never by
|
|
59
|
+
// name — Meshy names its lowest spine bone "Spine02" and its highest "Spine".
|
|
60
|
+
const MIXAMO_ALIASES = {
|
|
61
|
+
Hips: ["hips", "pelvis"],
|
|
62
|
+
Neck: ["neck", "neck01", "neck1"],
|
|
63
|
+
Head: ["head"],
|
|
64
|
+
LeftShoulder: ["leftshoulder", "leftclavicle"], RightShoulder: ["rightshoulder", "rightclavicle"],
|
|
65
|
+
LeftArm: ["leftarm", "leftupperarm"], RightArm: ["rightarm", "rightupperarm"],
|
|
66
|
+
LeftForeArm: ["leftforearm", "leftlowerarm"], RightForeArm: ["rightforearm", "rightlowerarm"],
|
|
67
|
+
LeftHand: ["lefthand"], RightHand: ["righthand"],
|
|
68
|
+
LeftUpLeg: ["leftupleg", "leftupperleg", "leftthigh"], RightUpLeg: ["rightupleg", "rightupperleg", "rightthigh"],
|
|
69
|
+
LeftLeg: ["leftleg", "leftlowerleg", "leftcalf", "leftshin"], RightLeg: ["rightleg", "rightlowerleg", "rightcalf", "rightshin"],
|
|
70
|
+
LeftFoot: ["leftfoot"], RightFoot: ["rightfoot"],
|
|
71
|
+
LeftToeBase: ["lefttoebase", "lefttoe", "lefttoes"], RightToeBase: ["righttoebase", "righttoe", "righttoes"],
|
|
72
|
+
};
|
|
73
|
+
const SPINE_SLOTS = ["Spine", "Spine1", "Spine2", "Spine3"];
|
|
74
|
+
const normName = s => s.toLowerCase().replace(/^mixamorig\d*[:_]?/, "").replace(/[^a-z0-9]/g, "");
|
|
75
|
+
|
|
76
|
+
class RigBase {
|
|
77
|
+
constructor(gltf, base, kind) {
|
|
78
|
+
this.gltf = gltf;
|
|
79
|
+
this.gltfScene = gltf.scene;
|
|
80
|
+
this.base = base; // { joints, parents, restPositions, hipsRestY }
|
|
81
|
+
this.kind = kind;
|
|
82
|
+
this.missing = [];
|
|
83
|
+
this._qT = new THREE.Quaternion();
|
|
84
|
+
this._qA = new THREE.Quaternion();
|
|
85
|
+
this._q = new THREE.Quaternion();
|
|
86
|
+
this._v = new THREE.Vector3();
|
|
87
|
+
this.gltfScene.traverse(o => { if (o.isSkinnedMesh) o.frustumCulled = false; });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// subclass fills nodeOf[] first; derives the shared fold/segment structure
|
|
91
|
+
finishSetup() {
|
|
92
|
+
const { joints, parents } = this.base;
|
|
93
|
+
const J = joints.length;
|
|
94
|
+
this.mapped = [];
|
|
95
|
+
for (let j = 0; j < J; j++) if (this.animNode(j)) this.mapped.push(j);
|
|
96
|
+
this.mappedAncestor = {}; this.chainFor = {};
|
|
97
|
+
for (const j of this.mapped) {
|
|
98
|
+
const seg = [j];
|
|
99
|
+
let p = parents[j];
|
|
100
|
+
while (p !== -1 && !this.animNode(p)) { seg.unshift(p); p = parents[p]; }
|
|
101
|
+
this.chainFor[j] = seg;
|
|
102
|
+
this.mappedAncestor[j] = p; // -1 for hips
|
|
103
|
+
}
|
|
104
|
+
this.primaryOf = {}; // j -> first MAPPED joint down the primary chain, or -1
|
|
105
|
+
for (const j of this.mapped) {
|
|
106
|
+
let n = PRIMARY_NEXT[joints[j]], res = -1;
|
|
107
|
+
while (n) {
|
|
108
|
+
const idx = joints.indexOf(n);
|
|
109
|
+
if (idx >= 0 && this.animNode(idx)) { res = idx; break; }
|
|
110
|
+
n = PRIMARY_NEXT[n];
|
|
111
|
+
}
|
|
112
|
+
this.primaryOf[j] = res;
|
|
113
|
+
}
|
|
114
|
+
// metric segments between mapped joints; "primary" = the parent bone's own
|
|
115
|
+
// direction (controllable); the rest are attachment offsets = body shape
|
|
116
|
+
this.segs = []; this.segPrimary = [];
|
|
117
|
+
for (const j of this.mapped) {
|
|
118
|
+
const a = this.mappedAncestor[j];
|
|
119
|
+
if (a < 0) continue;
|
|
120
|
+
this.segs.push([a, j]);
|
|
121
|
+
this.segPrimary.push(this.primaryOf[a] === j);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// drive the real pipeline with identity locals (corr off); whatever pose the
|
|
126
|
+
// scene lands in is the avatar's effective rest — align each bone direction
|
|
127
|
+
// onto ARDY's rest direction. Leaves the probe pose applied so callers can
|
|
128
|
+
// measure rest-pose deltas right after.
|
|
129
|
+
computeCorrection() {
|
|
130
|
+
this.applyFrame((k, out) => out.identity(), false);
|
|
131
|
+
this.setHipsFromArdy(0, this.base.hipsRestY, 0);
|
|
132
|
+
this.update(0);
|
|
133
|
+
this.scene.updateMatrixWorld(true);
|
|
134
|
+
const probe = {};
|
|
135
|
+
for (const j of this.mapped) probe[j] = this.geoNode(j).getWorldPosition(new THREE.Vector3());
|
|
136
|
+
const { joints, restPositions: rp } = this.base;
|
|
137
|
+
const dV = new THREE.Vector3(), dA = new THREE.Vector3();
|
|
138
|
+
this.corr = {};
|
|
139
|
+
const table = [];
|
|
140
|
+
for (const j of this.mapped) { // ascending = parents before children
|
|
141
|
+
if (joints[j] === "Hips") { this.corr[j] = new THREE.Quaternion(); continue; }
|
|
142
|
+
const cj = this.primaryOf[j];
|
|
143
|
+
if (cj < 0) { // leaf: inherit the parent's twist frame
|
|
144
|
+
this.corr[j] = (this.corr[this.mappedAncestor[j]] ?? new THREE.Quaternion()).clone();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
dV.copy(probe[cj]).sub(probe[j]).normalize();
|
|
148
|
+
this.mapDir(rp[cj][0] - rp[j][0], rp[cj][1] - rp[j][1], rp[cj][2] - rp[j][2], dA);
|
|
149
|
+
this.corr[j] = new THREE.Quaternion().setFromUnitVectors(dV, dA);
|
|
150
|
+
table.push([`${joints[j]}→${joints[cj]}`, +(dV.angleTo(dA) * 180 / Math.PI).toFixed(1)]);
|
|
151
|
+
}
|
|
152
|
+
return table.sort((a, b) => b[1] - a[1]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
applyFrame(readLocal, corr) {
|
|
156
|
+
this.retargetFrame(readLocal, corr, (j, q) => this.animNode(j).quaternion.copy(q));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// one THREE.AnimationClip from an ARDY clip { fps, localQuat[F][27][4], rootPos[F][3] }
|
|
160
|
+
buildClip(c, name, corr) {
|
|
161
|
+
const F = c.localQuat.length;
|
|
162
|
+
const times = new Float32Array(F).map((_, i) => i / c.fps);
|
|
163
|
+
const vals = {};
|
|
164
|
+
for (const j of this.mapped) vals[j] = new Float32Array(F * 4);
|
|
165
|
+
const pos = new Float32Array(F * 3);
|
|
166
|
+
for (let f = 0; f < F; f++) {
|
|
167
|
+
this.retargetFrame((k, out) => out.fromArray(c.localQuat[f][k]), corr,
|
|
168
|
+
(j, q) => q.toArray(vals[j], f * 4));
|
|
169
|
+
const r = c.rootPos[f];
|
|
170
|
+
this.hipsLocalFromArdy(r[0], r[1], r[2], this._v);
|
|
171
|
+
pos[f * 3] = this._v.x; pos[f * 3 + 1] = this._v.y; pos[f * 3 + 2] = this._v.z;
|
|
172
|
+
}
|
|
173
|
+
const tracks = [];
|
|
174
|
+
for (const j of this.mapped)
|
|
175
|
+
tracks.push(new THREE.QuaternionKeyframeTrack(`${this.animNode(j).name}.quaternion`, times, vals[j]));
|
|
176
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${this.animNode(0).name}.position`, times, pos));
|
|
177
|
+
return new THREE.AnimationClip(`ardy:${name}`, F / c.fps, tracks);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// THE honest metric: scene-graph bone directions vs ARDY posed_joints (one frame)
|
|
181
|
+
dirErrors(positionsFrame) {
|
|
182
|
+
const va = new THREE.Vector3(), vb = new THREE.Vector3(), tb = new THREE.Vector3();
|
|
183
|
+
const { joints } = this.base;
|
|
184
|
+
const rows = [];
|
|
185
|
+
for (let i = 0; i < this.segs.length; i++) {
|
|
186
|
+
const [a, b] = this.segs[i];
|
|
187
|
+
this.geoNode(a).getWorldPosition(va);
|
|
188
|
+
this.geoNode(b).getWorldPosition(vb);
|
|
189
|
+
vb.sub(va).normalize();
|
|
190
|
+
const pa = positionsFrame[a], pb = positionsFrame[b];
|
|
191
|
+
this.mapDir(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2], tb);
|
|
192
|
+
rows.push([`${joints[a]}→${joints[b]}`, vb.angleTo(tb) * 180 / Math.PI, this.segPrimary[i]]);
|
|
193
|
+
}
|
|
194
|
+
return rows;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------- VRM adapter
|
|
199
|
+
const Y180 = new THREE.Quaternion(0, 1, 0, 0);
|
|
200
|
+
const conjY180 = q => { q.x = -q.x; q.z = -q.z; return q; };
|
|
201
|
+
|
|
202
|
+
class VrmRig extends RigBase {
|
|
203
|
+
constructor(gltf, vrm, base) {
|
|
204
|
+
super(gltf, base, "vrm");
|
|
205
|
+
this.vrm = vrm;
|
|
206
|
+
VRMUtils.rotateVRM0(vrm); // no-op for VRM 1.x models
|
|
207
|
+
const { joints } = base;
|
|
208
|
+
this._anim = {}; this._geo = {};
|
|
209
|
+
for (let j = 0; j < joints.length; j++) {
|
|
210
|
+
const bn = CORE_TO_VRM[joints[j]];
|
|
211
|
+
if (!bn) continue; // hand ends / Spine3: no VRM slot by design
|
|
212
|
+
const n = vrm.humanoid.getNormalizedBoneNode(bn);
|
|
213
|
+
if (!n) { this.missing.push(`${joints[j]}→${bn}`); continue; }
|
|
214
|
+
this._anim[j] = n;
|
|
215
|
+
this._geo[j] = vrm.humanoid.getRawBoneNode(bn);
|
|
216
|
+
}
|
|
217
|
+
this.finishSetup();
|
|
218
|
+
this.hipsNode = this._anim[0];
|
|
219
|
+
this.hipsRestLocal = this.hipsNode.position.clone();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
readRest() { // after scene add: rest world hip height fixes the scale ratio
|
|
223
|
+
const w = this.hipsNode.getWorldPosition(new THREE.Vector3());
|
|
224
|
+
this.hipRatio = w.y / this.base.hipsRestY;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
animNode(j) { return this._anim[j] ?? null; }
|
|
228
|
+
geoNode(j) { return this._geo[j] ?? null; }
|
|
229
|
+
update(dt) { this.vrm.update(dt); }
|
|
230
|
+
|
|
231
|
+
// scene frame = ARDY frame yawed 180° about Y (mirror x,z)
|
|
232
|
+
mapPoint(p, out) {
|
|
233
|
+
return out.set(-p[0] * this.hipRatio - this.hipsRestLocal.x,
|
|
234
|
+
this.hipsRestLocal.y + (p[1] - this.base.hipsRestY) * this.hipRatio,
|
|
235
|
+
-p[2] * this.hipRatio - this.hipsRestLocal.z);
|
|
236
|
+
}
|
|
237
|
+
mapDir(dx, dy, dz, out) { return out.set(-dx, dy, -dz).normalize(); }
|
|
238
|
+
|
|
239
|
+
// hips local = M⁻¹·world_target with the rig root carrying M — so unmirrored
|
|
240
|
+
hipsLocalFromArdy(x, y, z, out) {
|
|
241
|
+
return out.set(this.hipsRestLocal.x + x * this.hipRatio,
|
|
242
|
+
this.hipsRestLocal.y + (y - this.base.hipsRestY) * this.hipRatio,
|
|
243
|
+
this.hipsRestLocal.z + z * this.hipRatio);
|
|
244
|
+
}
|
|
245
|
+
setHipsFromArdy(x, y, z) { this.hipsNode.position.copy(this.hipsLocalFromArdy(x, y, z, this._v)); }
|
|
246
|
+
|
|
247
|
+
retargetFrame(readLocal, corr, emit) {
|
|
248
|
+
for (const j of this.mapped) {
|
|
249
|
+
const q = this._q.identity();
|
|
250
|
+
for (const k of this.chainFor[j]) q.multiply(readLocal(k, this._qT));
|
|
251
|
+
if (this.mappedAncestor[j] === -1) {
|
|
252
|
+
q.multiply(Y180); // root: q = G·M (rig root carries M)
|
|
253
|
+
} else {
|
|
254
|
+
conjY180(q); // transport the local into the scene frame
|
|
255
|
+
if (corr) {
|
|
256
|
+
q.premultiply(this._qA.copy(this.corr[this.mappedAncestor[j]]).invert());
|
|
257
|
+
q.multiply(this.corr[j]);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
emit(j, q);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ------------------------------------------------------------- Mixamo adapter
|
|
266
|
+
class MixamoRig extends RigBase {
|
|
267
|
+
constructor(gltf, base) {
|
|
268
|
+
super(gltf, base, "mixamo");
|
|
269
|
+
const { joints } = base;
|
|
270
|
+
const J = joints.length;
|
|
271
|
+
|
|
272
|
+
// scan the skeleton by normalized names (spines matched structurally below)
|
|
273
|
+
const byNorm = {};
|
|
274
|
+
this.gltfScene.traverse(o => { if (o.name) byNorm[normName(o.name)] ??= o; });
|
|
275
|
+
const bones = {}; // ARDY joint name -> node
|
|
276
|
+
for (const [core, aliases] of Object.entries(MIXAMO_ALIASES)) {
|
|
277
|
+
for (const a of aliases) if (byNorm[a]) { bones[core] = byNorm[a]; break; }
|
|
278
|
+
}
|
|
279
|
+
if (!bones.Hips || !bones.LeftUpLeg || !bones.RightUpLeg)
|
|
280
|
+
throw new Error(`mixamo rig scan failed: hips/legs not found (have: ${Object.keys(bones).join(",")})`);
|
|
281
|
+
|
|
282
|
+
// spine chain = nodes strictly between neck (or head) and hips, hips-adjacent
|
|
283
|
+
// first; ends-anchored assignment onto ARDY's four spine slots
|
|
284
|
+
const top = bones.Neck ?? bones.Head;
|
|
285
|
+
const chain = [];
|
|
286
|
+
for (let p = top?.parent, i = 0; p && p !== bones.Hips && i < 10; p = p.parent, i++) chain.unshift(p);
|
|
287
|
+
if (top && chain.length && chain[0].parent === bones.Hips) {
|
|
288
|
+
const n = chain.length;
|
|
289
|
+
chain.forEach((node, i) => {
|
|
290
|
+
const slot = SPINE_SLOTS[n === 1 ? 3 : Math.round(i * (SPINE_SLOTS.length - 1) / (n - 1))];
|
|
291
|
+
bones[slot] ??= node;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this._anim = {};
|
|
296
|
+
for (let j = 0; j < J; j++) {
|
|
297
|
+
if (bones[joints[j]]) this._anim[j] = bones[joints[j]];
|
|
298
|
+
else if (MIXAMO_ALIASES[joints[j]]) this.missing.push(joints[j]); // spines/hand-ends: expected to fold
|
|
299
|
+
}
|
|
300
|
+
this.finishSetup();
|
|
301
|
+
this.hipsNode = this._anim[0];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
readRest() { // scene-added + updated: the default pose IS the bind pose (asserted offline)
|
|
305
|
+
const J = this.base.joints.length;
|
|
306
|
+
this.restWorld = {}; this.parentBone = {}; this.relRest = {}; this.parentStatic = {};
|
|
307
|
+
const isBoneNode = new Set(this.mapped.map(j => this._anim[j]));
|
|
308
|
+
for (const j of this.mapped) {
|
|
309
|
+
const node = this._anim[j];
|
|
310
|
+
this.restWorld[j] = node.getWorldQuaternion(new THREE.Quaternion());
|
|
311
|
+
// nearest mapped ancestor on the TARGET side + the static bind rotation of
|
|
312
|
+
// any unmapped nodes in between
|
|
313
|
+
const stack = [];
|
|
314
|
+
let p = node.parent;
|
|
315
|
+
while (p && !isBoneNode.has(p)) { stack.unshift(p); p = p.parent; }
|
|
316
|
+
if (p) {
|
|
317
|
+
this.parentBone[j] = this.mapped.find(k => this._anim[k] === p);
|
|
318
|
+
const rel = new THREE.Quaternion();
|
|
319
|
+
for (const s of stack) rel.multiply(s.quaternion);
|
|
320
|
+
this.relRest[j] = rel;
|
|
321
|
+
if (this.parentBone[j] >= j) console.warn(`mixamo rig: ${this.base.joints[j]} parent order anomaly`);
|
|
322
|
+
} else { // hips: everything above is static scenery (Armature etc.)
|
|
323
|
+
this.parentBone[j] = -1;
|
|
324
|
+
this.parentStatic[j] = node.parent.getWorldQuaternion(new THREE.Quaternion());
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
this.hipsRestWorld = this.hipsNode.getWorldPosition(new THREE.Vector3());
|
|
328
|
+
this.hipRatio = this.hipsRestWorld.y / this.base.hipsRestY;
|
|
329
|
+
this._hipsParentInv = this.hipsNode.parent.matrixWorld.clone().invert(); // static
|
|
330
|
+
this._D = Array.from({ length: J }, () => new THREE.Quaternion());
|
|
331
|
+
this._W = {};
|
|
332
|
+
for (const j of this.mapped) this._W[j] = new THREE.Quaternion();
|
|
333
|
+
this._qP = new THREE.Quaternion();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
animNode(j) { return this._anim[j] ?? null; }
|
|
337
|
+
geoNode(j) { return this._anim[j] ?? null; }
|
|
338
|
+
update() {}
|
|
339
|
+
|
|
340
|
+
// scene frame == ARDY frame (both face +Z, left = +X), anchored at the bind hips
|
|
341
|
+
mapPoint(p, out) {
|
|
342
|
+
return out.set(this.hipsRestWorld.x + p[0] * this.hipRatio,
|
|
343
|
+
this.hipsRestWorld.y + (p[1] - this.base.hipsRestY) * this.hipRatio,
|
|
344
|
+
this.hipsRestWorld.z + p[2] * this.hipRatio);
|
|
345
|
+
}
|
|
346
|
+
mapDir(dx, dy, dz, out) { return out.set(dx, dy, dz).normalize(); }
|
|
347
|
+
|
|
348
|
+
hipsLocalFromArdy(x, y, z, out) {
|
|
349
|
+
this.mapPoint([x, y, z], out);
|
|
350
|
+
return out.applyMatrix4(this._hipsParentInv); // world → armature-local (cm)
|
|
351
|
+
}
|
|
352
|
+
setHipsFromArdy(x, y, z) { this.hipsNode.position.copy(this.hipsLocalFromArdy(x, y, z, this._v)); }
|
|
353
|
+
|
|
354
|
+
retargetFrame(readLocal, corr, emit) {
|
|
355
|
+
const { parents } = this.base;
|
|
356
|
+
const J = parents.length;
|
|
357
|
+
for (let k = 0; k < J; k++) { // source FK: D = world rotation = delta from rest
|
|
358
|
+
const p = parents[k];
|
|
359
|
+
if (p === -1) this._D[k].copy(readLocal(k, this._qT));
|
|
360
|
+
else this._D[k].copy(this._D[p]).multiply(readLocal(k, this._qT));
|
|
361
|
+
}
|
|
362
|
+
for (const j of this.mapped) { // ascending: parents emitted before children
|
|
363
|
+
const W = this._W[j].copy(this._D[j]);
|
|
364
|
+
if (corr) W.multiply(this.corr[j]);
|
|
365
|
+
W.multiply(this.restWorld[j]);
|
|
366
|
+
const pb = this.parentBone[j];
|
|
367
|
+
if (pb === -1) this._qP.copy(this.parentStatic[j]);
|
|
368
|
+
else this._qP.copy(this._W[pb]).multiply(this.relRest[j]);
|
|
369
|
+
emit(j, this._qP.invert().multiply(W));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// -------------------------------------------------------------------- loader
|
|
375
|
+
export async function loadRig(url, base, scene) {
|
|
376
|
+
if (base.joints[0] !== "Hips") throw new Error("ARDY joint 0 must be Hips");
|
|
377
|
+
const loader = new GLTFLoader();
|
|
378
|
+
loader.register(p => new VRMLoaderPlugin(p));
|
|
379
|
+
const gltf = await loader.loadAsync(url);
|
|
380
|
+
const rig = gltf.userData.vrm ? new VrmRig(gltf, gltf.userData.vrm, base)
|
|
381
|
+
: new MixamoRig(gltf, base);
|
|
382
|
+
scene.add(gltf.scene);
|
|
383
|
+
scene.updateMatrixWorld(true);
|
|
384
|
+
rig.scene = scene;
|
|
385
|
+
rig.readRest();
|
|
386
|
+
return rig;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ===========================================================================
|
|
390
|
+
// LOAD-TIME PER-RIG NORMALIZATIONS (AG-823 §7.3 — the walker.glb session).
|
|
391
|
+
// All four are rigid frame-convention fixes in the allowed op class: constant
|
|
392
|
+
// per rig+set, zero joint-vs-joint edits. Call order matters:
|
|
393
|
+
//
|
|
394
|
+
// const rig = await loadRig(url, set, scene);
|
|
395
|
+
// rig.computeCorrection();
|
|
396
|
+
// const restAnkle = captureRestAnkle(rig, scene); // BEFORE any clip
|
|
397
|
+
// reanchorFeet(rig, set, scene); // BEFORE ClipSet
|
|
398
|
+
// const clipSet = new ClipSet(rig, set, set.gaits);
|
|
399
|
+
// groundCalibrate(rig, clipSet, scene, restAnkle); // AFTER ClipSet
|
|
400
|
+
// curlFingers(rig, scene); // once, at load
|
|
401
|
+
// ===========================================================================
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* REST-pose ankle baseline, captured in BIND pose before any clip plays.
|
|
405
|
+
* Settling into the current idle instead would let a floating idle calibrate
|
|
406
|
+
* its own defect away (the marine lesson). `rootY` = the character root's
|
|
407
|
+
* world Y at capture time (0 for a root still at the origin).
|
|
408
|
+
*/
|
|
409
|
+
export function captureRestAnkle(rig, scene, rootY = 0) {
|
|
410
|
+
scene.updateMatrixWorld(true);
|
|
411
|
+
const f = new THREE.Vector3(), g = new THREE.Vector3();
|
|
412
|
+
const jr = rig.base.joints.indexOf("RightFoot"), jl = rig.base.joints.indexOf("LeftFoot");
|
|
413
|
+
rig.geoNode(jr).getWorldPosition(f);
|
|
414
|
+
rig.geoNode(jl).getWorldPosition(g);
|
|
415
|
+
return Math.min(f.y, g.y) - rootY;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* FOOT-PITCH re-anchor + rigid-boot toe (mixamo rigs only). corr aligns the
|
|
420
|
+
* foot's bind direction onto ARDY's rest direction, so a planted foot plays
|
|
421
|
+
* at ARDY's foot pitch (27°); a steep-boot rig (walker: 42°, ankle 15 cm
|
|
422
|
+
* above the sole vs ARDY's 6.7 cm) then carries its front sole in the air.
|
|
423
|
+
* For FEET the rig's bind pitch IS ground truth (the sole is authored flat),
|
|
424
|
+
* so fold the constant pitch difference back into the corr slot:
|
|
425
|
+
* corr' = D⁻¹·R·D·corr, measured on the set's idle frame 0 (pitch only,
|
|
426
|
+
* heading stays ARDY's). Then lock the toe bone to the foot at its bind
|
|
427
|
+
* offset (corr[toe] = Dt⁻¹·D·corr[foot]) — a boot doesn't bend at the ball,
|
|
428
|
+
* and ARDY's toe-flex delta would tip the mesh under the floor.
|
|
429
|
+
*/
|
|
430
|
+
export function reanchorFeet(rig, set, scene) {
|
|
431
|
+
const idle = (set.gaits ?? set.clips)?.idle;
|
|
432
|
+
if (rig.kind !== "mixamo" || !idle) return;
|
|
433
|
+
const { joints, parents } = rig.base;
|
|
434
|
+
const upV = new THREE.Vector3(0, 1, 0);
|
|
435
|
+
for (const [fN, tN] of [["LeftFoot", "LeftToeBase"], ["RightFoot", "RightToeBase"]]) {
|
|
436
|
+
const jf = joints.indexOf(fN), jt = joints.indexOf(tN);
|
|
437
|
+
const fNode = jf >= 0 ? rig.geoNode(jf) : null, tNode = jt >= 0 ? rig.geoNode(jt) : null;
|
|
438
|
+
if (!fNode || !tNode || tNode.parent !== fNode || !rig.restWorld?.[jf]) continue;
|
|
439
|
+
// bind ankle→toe direction = bind world rotation · toe's local offset
|
|
440
|
+
const bindDir = tNode.position.clone().applyQuaternion(rig.restWorld[jf]).normalize();
|
|
441
|
+
// retarget idle frame 0 with the current corr and measure the live direction
|
|
442
|
+
rig.applyFrame((k, out) => out.fromArray(idle.localQuat[0][k]), true);
|
|
443
|
+
rig.setHipsFromArdy(idle.hipsPos[0][0], idle.hipsPos[0][1], idle.hipsPos[0][2]);
|
|
444
|
+
rig.update(0);
|
|
445
|
+
scene.updateMatrixWorld(true);
|
|
446
|
+
const pa = fNode.getWorldPosition(new THREE.Vector3());
|
|
447
|
+
const cur = tNode.getWorldPosition(new THREE.Vector3()).sub(pa).normalize();
|
|
448
|
+
// target: keep the take's heading, restore the bind pitch
|
|
449
|
+
const bindPitch = Math.asin(THREE.MathUtils.clamp(bindDir.y, -1, 1));
|
|
450
|
+
const h = cur.clone().addScaledVector(upV, -cur.y).normalize();
|
|
451
|
+
const target = h.multiplyScalar(Math.cos(bindPitch)).addScaledVector(upV, Math.sin(bindPitch));
|
|
452
|
+
const theta = cur.angleTo(target);
|
|
453
|
+
if (theta < 0.05) continue; // <3°: rig and ARDY already agree
|
|
454
|
+
const R = new THREE.Quaternion().setFromUnitVectors(cur, target);
|
|
455
|
+
const D = new THREE.Quaternion(), qk = new THREE.Quaternion();
|
|
456
|
+
const chain = [];
|
|
457
|
+
for (let p = jf; p !== -1; p = parents[p]) chain.unshift(p);
|
|
458
|
+
for (const k of chain) D.multiply(qk.fromArray(idle.localQuat[0][k]));
|
|
459
|
+
rig.corr[jf].premultiply(new THREE.Quaternion().copy(D).invert().multiply(R).multiply(D));
|
|
460
|
+
const Dt = new THREE.Quaternion();
|
|
461
|
+
const chainT = [];
|
|
462
|
+
for (let p = jt; p !== -1; p = parents[p]) chainT.unshift(p);
|
|
463
|
+
for (const k of chainT) Dt.multiply(qk.fromArray(idle.localQuat[0][k]));
|
|
464
|
+
rig.corr[jt] = new THREE.Quaternion().copy(Dt).invert().multiply(D).multiply(rig.corr[jf]);
|
|
465
|
+
console.log(`[motion] ${fN}: pitch re-anchored to bind by ${(theta * 180 / Math.PI).toFixed(1)}° + rigid-boot toe`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Per-rig GROUND CALIBRATION: hips Y is ARDY-absolute scaled by hipRatio,
|
|
471
|
+
* which assumes the rig shares ARDY's hips:leg proportions. A rig with
|
|
472
|
+
* proportionally shorter legs (walker.glb) hangs its feet above the floor on
|
|
473
|
+
* EVERY clip. Rigid fix: play the idle through the real retarget path,
|
|
474
|
+
* measure its lowest ankle vs the BIND-pose ankle (captureRestAnkle), and
|
|
475
|
+
* shift every track's hips Y by the difference — one constant per rig+set,
|
|
476
|
+
* no-op (<4 mm) on well-proportioned rigs. Call AFTER ClipSet construction.
|
|
477
|
+
*/
|
|
478
|
+
export function groundCalibrate(rig, clipSet, scene, restAnkle, rootY = 0) {
|
|
479
|
+
const joints = rig.base.joints;
|
|
480
|
+
const LFOOT = joints.indexOf("LeftFoot"), RFOOT = joints.indexOf("RightFoot");
|
|
481
|
+
const ref = clipSet.tracks.idle ?? clipSet.tracks.stance ?? Object.values(clipSet.tracks)[0];
|
|
482
|
+
if (!ref || LFOOT < 0 || RFOOT < 0) return;
|
|
483
|
+
const v = new THREE.Vector3();
|
|
484
|
+
let minY = Infinity;
|
|
485
|
+
for (let f = 0; f < ref.F; f++) {
|
|
486
|
+
for (const j of rig.mapped) rig.animNode(j).quaternion.fromArray(ref.quat[j], f * 4);
|
|
487
|
+
rig.setHipsFromArdy(ref.hips[f * 3], ref.hips[f * 3 + 1], ref.hips[f * 3 + 2]);
|
|
488
|
+
rig.update(0);
|
|
489
|
+
scene.updateMatrixWorld(true);
|
|
490
|
+
for (const jf of [LFOOT, RFOOT]) {
|
|
491
|
+
rig.geoNode(jf).getWorldPosition(v);
|
|
492
|
+
minY = Math.min(minY, v.y - rootY);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const delta = minY - restAnkle;
|
|
496
|
+
if (Math.abs(delta) > 0.004) {
|
|
497
|
+
const dArdy = delta / rig.hipRatio;
|
|
498
|
+
for (const t of Object.values(clipSet.tracks))
|
|
499
|
+
for (let f = 0; f < t.F; f++) t.hips[f * 3 + 1] -= dArdy;
|
|
500
|
+
console.log(`[motion] ground-cal: idle ankle ${(delta * 100).toFixed(1)} cm off bind ankle — hips shifted for this rig`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* GRIP CURL: ARDY's skeleton has no fingers, so retargeted rigs keep their
|
|
506
|
+
* REST hands — splayed-open fingers "holding" a weapon. Curl whatever finger
|
|
507
|
+
* bones the rig actually has, once at load (nothing animates fingers, so it
|
|
508
|
+
* sticks). The curl axis is the knuckle line; its SIGN is probed per hand
|
|
509
|
+
* (closing a finger brings its tip toward the wrist — rig conventions vary).
|
|
510
|
+
* Mitten/finger-less hands (walker.glb) are left alone: prefer weapon-carry
|
|
511
|
+
* looks that read well with flat hands on such rigs.
|
|
512
|
+
*/
|
|
513
|
+
export function curlFingers(rig, scene) {
|
|
514
|
+
const joints = rig.base.joints;
|
|
515
|
+
const RHAND = joints.indexOf("RightHand"), LHAND = joints.indexOf("LeftHand");
|
|
516
|
+
const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _hp = new THREE.Vector3();
|
|
517
|
+
const _t0 = new THREE.Vector3(), _cw = new THREE.Quaternion();
|
|
518
|
+
const _cp2 = new THREE.Quaternion(), _cx = new THREE.Quaternion();
|
|
519
|
+
const tipOf = (b) => { let n = b; while (n.children.length) n = n.children[0]; return n; };
|
|
520
|
+
const rotWorld = (bone, axis, ang) => {
|
|
521
|
+
_cx.setFromAxisAngle(axis, ang);
|
|
522
|
+
bone.getWorldQuaternion(_cw).premultiply(_cx);
|
|
523
|
+
bone.parent.getWorldQuaternion(_cp2).invert();
|
|
524
|
+
bone.quaternion.copy(_cp2.multiply(_cw));
|
|
525
|
+
bone.updateMatrixWorld(true);
|
|
526
|
+
};
|
|
527
|
+
for (const handJ of [RHAND, LHAND]) {
|
|
528
|
+
const hand = rig.animNode(handJ);
|
|
529
|
+
if (!hand) continue;
|
|
530
|
+
let roots = hand.children.filter((c) => c.isBone);
|
|
531
|
+
if (!roots.length) roots = [...hand.children];
|
|
532
|
+
roots = roots.filter((c) => !/thumb/i.test(c.name));
|
|
533
|
+
if (roots.length < 2) continue; // mitten hand
|
|
534
|
+
roots[0].getWorldPosition(_a);
|
|
535
|
+
roots[roots.length - 1].getWorldPosition(_b);
|
|
536
|
+
const axis = _b.clone().sub(_a);
|
|
537
|
+
if (axis.lengthSq() < 1e-8) continue;
|
|
538
|
+
axis.normalize();
|
|
539
|
+
const chain = roots[Math.floor(roots.length / 2)];
|
|
540
|
+
const tip = tipOf(chain);
|
|
541
|
+
hand.getWorldPosition(_hp);
|
|
542
|
+
const saved = chain.quaternion.clone();
|
|
543
|
+
let sign = 1, bestD = Infinity;
|
|
544
|
+
for (const s of [1, -1]) {
|
|
545
|
+
rotWorld(chain, axis, s * 0.9);
|
|
546
|
+
tip.getWorldPosition(_t0);
|
|
547
|
+
const dd = _t0.distanceTo(_hp);
|
|
548
|
+
if (dd < bestD) { bestD = dd; sign = s; }
|
|
549
|
+
chain.quaternion.copy(saved);
|
|
550
|
+
chain.updateMatrixWorld(true);
|
|
551
|
+
}
|
|
552
|
+
axis.multiplyScalar(sign);
|
|
553
|
+
const curl = (bone, depth) => {
|
|
554
|
+
if (depth >= 1 && !/thumb/i.test(bone.name)) {
|
|
555
|
+
rotWorld(bone, axis, [0, 0.5, 0.7, 0.8][Math.min(depth, 3)]);
|
|
556
|
+
}
|
|
557
|
+
for (const c of [...bone.children]) curl(c, depth + 1);
|
|
558
|
+
};
|
|
559
|
+
curl(hand, 0);
|
|
560
|
+
}
|
|
561
|
+
rig.update(0);
|
|
562
|
+
scene.updateMatrixWorld(true);
|
|
563
|
+
}
|