@convai/embody 1.0.1-beta.0 → 1.1.0-beta.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 +38 -0
- package/README.md +12 -8
- package/dist/character/createCharacter.d.ts +66 -5
- package/dist/character/createCharacter.js +93 -22
- package/dist/character/pipeline.d.ts +9 -5
- package/dist/clips/animations.d.ts +13 -1
- package/dist/clips/animations.js +28 -6
- package/dist/clips/retarget.d.ts +168 -0
- package/dist/clips/retarget.js +856 -0
- package/dist/clips/sources.d.ts +70 -0
- package/dist/clips/sources.js +119 -0
- package/dist/connect/session.d.ts +11 -0
- package/dist/connect/session.js +22 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -0
- package/dist/inspect/inspectModel.d.ts +14 -0
- package/dist/inspect/inspectModel.js +26 -0
- package/dist/inspect/morphMap.d.ts +54 -0
- package/dist/inspect/morphMap.js +140 -0
- package/dist/inspect/report.d.ts +88 -0
- package/dist/inspect/report.js +193 -0
- package/dist/motion/eyeAim.d.ts +94 -0
- package/dist/motion/eyeAim.js +276 -0
- package/dist/motion/eyeGaze.d.ts +17 -0
- package/dist/motion/eyeGaze.js +42 -0
- package/dist/motion/footLock.d.ts +67 -0
- package/dist/motion/footLock.js +176 -0
- package/dist/motion/gaze.d.ts +135 -180
- package/dist/motion/gaze.js +257 -491
- package/dist/motion/hair.d.ts +27 -0
- package/dist/motion/hair.js +127 -0
- package/dist/motion/hairClearance.d.ts +39 -0
- package/dist/motion/hairClearance.js +125 -0
- package/dist/motion/morphNormals.d.ts +38 -0
- package/dist/motion/morphNormals.js +237 -0
- package/dist/motion/types.d.ts +15 -0
- package/dist/react/CharacterModel.d.ts +3 -1
- package/dist/react/CharacterModel.js +9 -1
- package/dist/react/ConvaiCharacter.js +4 -1
- package/dist/react/PortraitLights.d.ts +3 -0
- package/dist/react/PortraitLights.js +44 -0
- package/dist/render/faceMaterials.d.ts +60 -3
- package/dist/render/faceMaterials.js +320 -96
- package/dist/render/portraitRig.d.ts +28 -0
- package/dist/render/portraitRig.js +55 -0
- package/dist/render/surfaces.d.ts +181 -0
- package/dist/render/surfaces.js +1020 -0
- package/dist/rig/resolve.d.ts +8 -2
- package/dist/rig/resolve.js +7 -3
- package/dist/rig/units.d.ts +35 -16
- package/dist/rig/units.js +143 -42
- package/dist/speech/expression.d.ts +20 -0
- package/dist/speech/expression.js +118 -0
- package/dist/speech/lipsync.d.ts +68 -2
- package/dist/speech/lipsync.js +205 -5
- package/dist/speech/mouthFinish.d.ts +46 -0
- package/dist/speech/mouthFinish.js +235 -0
- package/dist/speech/voiceClock.d.ts +94 -0
- package/dist/speech/voiceClock.js +176 -0
- package/package.json +1 -1
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-time retargeting: a clip authored on another skeleton (Mixamo, the
|
|
3
|
+
* Unreal mannequin, Rigify) → a clip on the body's own skeleton.
|
|
4
|
+
*
|
|
5
|
+
* Ported from the coffee-shop studio's camera-tuned retargeter and
|
|
6
|
+
* generalised: every rig is read through one table of canonical joints
|
|
7
|
+
* (Mixamo's names), so neither side's bone names are hard-coded into the
|
|
8
|
+
* method. The posture constants are the ones tuned on convai-mha-1 bodies.
|
|
9
|
+
*
|
|
10
|
+
* Method, per sampled frame:
|
|
11
|
+
* 1. Both rigs are put in one canonical frame (left +X, up +Y, forward +Z),
|
|
12
|
+
* measured from their own hip line and the file's vertical.
|
|
13
|
+
* 2. Rest-pose fix: Mixamo rests in a T-pose, a MetaHuman binds in an
|
|
14
|
+
* A-pose. Each target bone gets the shortest-arc rotation that lays its
|
|
15
|
+
* rest direction (bone → mapped child) onto the source's. Feet are left
|
|
16
|
+
* flat, the neck and head keep their own build, and a clavicle built
|
|
17
|
+
* pointing back (Rigify) contributes only its elevation.
|
|
18
|
+
* 3. Each mapped bone takes the source bone's WORLD rotation change since
|
|
19
|
+
* rest; unmapped bones (twists, correctives, extra spine) keep their
|
|
20
|
+
* bind-pose local rotation.
|
|
21
|
+
* 4. The hips move by the source hips' offset, scaled by hip height.
|
|
22
|
+
* 5. Elbows and hands are kept outside the body's own torso and thighs,
|
|
23
|
+
* measured from its skinned vertices (a clip made for a slimmer body
|
|
24
|
+
* otherwise rests its hands inside this one).
|
|
25
|
+
*
|
|
26
|
+
* The top bone (`pelvis` on convai-mha-1) has a parent outside the skin
|
|
27
|
+
* (`root002`). It is solved against that parent's bind-frame transform,
|
|
28
|
+
* `bindMatrix · meshWorld⁻¹ · parent.matrixWorld`; solving it from skin bones
|
|
29
|
+
* alone writes a constant pelvis and breaks every clip that bends at the hips.
|
|
30
|
+
*/
|
|
31
|
+
import * as THREE from 'three';
|
|
32
|
+
// ── canonical joints and the rigs that are read ─────────────────────────────
|
|
33
|
+
const SIDES = [['Left', 'l', 'L'], ['Right', 'r', 'R']];
|
|
34
|
+
const FINGERS = [['Thumb', 'thumb', 'thumb'], ['Index', 'index', 'f_index'], ['Middle', 'middle', 'f_middle'], ['Ring', 'ring', 'f_ring'], ['Pinky', 'pinky', 'f_pinky']];
|
|
35
|
+
/** Every canonical joint: Mixamo's bone names without the `mixamorig:` prefix. */
|
|
36
|
+
const CANON = new Set(['Hips', 'Spine', 'Spine1', 'Spine2', 'Neck', 'Head']);
|
|
37
|
+
for (const [s] of SIDES) {
|
|
38
|
+
for (const j of ['Shoulder', 'Arm', 'ForeArm', 'Hand', 'UpLeg', 'Leg', 'Foot', 'ToeBase'])
|
|
39
|
+
CANON.add(`${s}${j}`);
|
|
40
|
+
for (const [f] of FINGERS)
|
|
41
|
+
for (const i of [1, 2, 3])
|
|
42
|
+
CANON.add(`${s}Hand${f}${i}`);
|
|
43
|
+
}
|
|
44
|
+
/** Which canonical child gives a joint its direction, for the rest-pose fix. */
|
|
45
|
+
const AIM = { Spine: 'Spine1', Spine1: 'Spine2', Spine2: 'Neck', Neck: 'Head' };
|
|
46
|
+
for (const [s] of SIDES) {
|
|
47
|
+
Object.assign(AIM, {
|
|
48
|
+
[`${s}Shoulder`]: `${s}Arm`,
|
|
49
|
+
[`${s}Arm`]: `${s}ForeArm`,
|
|
50
|
+
[`${s}ForeArm`]: `${s}Hand`,
|
|
51
|
+
[`${s}Hand`]: `${s}HandMiddle1`,
|
|
52
|
+
[`${s}UpLeg`]: `${s}Leg`,
|
|
53
|
+
[`${s}Leg`]: `${s}Foot`,
|
|
54
|
+
});
|
|
55
|
+
for (const [f] of FINGERS) {
|
|
56
|
+
AIM[`${s}Hand${f}1`] = `${s}Hand${f}2`;
|
|
57
|
+
AIM[`${s}Hand${f}2`] = `${s}Hand${f}3`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Joints whose rest pose already matches (every rig stands flat-footed), so
|
|
62
|
+
* they take no rest fix. Aligning ankle→toe directions instead pitches the
|
|
63
|
+
* foot by the difference in the two rigs' ankle heights, and the toes dip
|
|
64
|
+
* into the floor.
|
|
65
|
+
*/
|
|
66
|
+
const FLAT = new Set(['LeftFoot', 'RightFoot', 'LeftToeBase', 'RightToeBase']);
|
|
67
|
+
/**
|
|
68
|
+
* Joints whose rest direction differs between rigs by build, not by pose:
|
|
69
|
+
* every rig rests with the head upright, but where the neck starts and how
|
|
70
|
+
* far it leans is anatomy. Copying the source's neck direction pushed the
|
|
71
|
+
* head 10–50° forward of the shoulders. They take the source's rotation
|
|
72
|
+
* change only, on the body's own bind shape.
|
|
73
|
+
*/
|
|
74
|
+
const OWN_SHAPE = new Set(['Neck', 'Head']);
|
|
75
|
+
/**
|
|
76
|
+
* Clavicles resting more than this far forward or back of sideways are built
|
|
77
|
+
* that way (Rigify's start at the sternum and point 30–45° back), not posed:
|
|
78
|
+
* the source's rest then counts as sideways at its own elevation. Mixamo's
|
|
79
|
+
* (within ~15°) are taken as they are.
|
|
80
|
+
*/
|
|
81
|
+
const CLAVICLE_BUILD_DEG = 20;
|
|
82
|
+
const ELEVATION_ONLY = new Set(['LeftShoulder', 'RightShoulder']);
|
|
83
|
+
/** The joints a source must have to be read at all. */
|
|
84
|
+
const REQUIRED = ['Hips', 'LeftUpLeg', 'RightUpLeg', 'LeftFoot', 'RightFoot'];
|
|
85
|
+
const stripMixamo = (n) => n.replace(/^mixamorig\d*:?/, '');
|
|
86
|
+
// GLTFLoader and FBXLoader drop '.' and ':' from node names
|
|
87
|
+
// (DEF-thigh.L → DEF-thighL, mixamorig:Hips → mixamorigHips): compare without them.
|
|
88
|
+
const key = (n) => n.replace(/[.:]/g, '');
|
|
89
|
+
/**
|
|
90
|
+
* Reads a skeleton's bone names as canonical joints: Mixamo (any
|
|
91
|
+
* `mixamorig` prefix, or none), Unreal (UE4/UE5 mannequin, MetaHuman,
|
|
92
|
+
* convai-mha-1) and Rigify deform bones. Spines are matched bottom, middle,
|
|
93
|
+
* top: a 3-bone spine maps 1/2/3, a 5-bone one 1/3/5. `null` when the hips,
|
|
94
|
+
* thighs and feet cannot all be found.
|
|
95
|
+
*/
|
|
96
|
+
export function readRigNames(names) {
|
|
97
|
+
const has = new Set(names.map(key));
|
|
98
|
+
const alias = {};
|
|
99
|
+
let kind = 'mixamo';
|
|
100
|
+
if (has.has('pelvis') && has.has('thigh_l')) {
|
|
101
|
+
kind = 'unreal';
|
|
102
|
+
const five = has.has('spine_05');
|
|
103
|
+
Object.assign(alias, { pelvis: 'Hips', spine_01: 'Spine', [five ? 'spine_03' : 'spine_02']: 'Spine1', [five ? 'spine_05' : 'spine_03']: 'Spine2', neck_01: 'Neck', head: 'Head' });
|
|
104
|
+
for (const [s, t] of SIDES) {
|
|
105
|
+
Object.assign(alias, {
|
|
106
|
+
[`clavicle_${t}`]: `${s}Shoulder`,
|
|
107
|
+
[`upperarm_${t}`]: `${s}Arm`,
|
|
108
|
+
[`lowerarm_${t}`]: `${s}ForeArm`,
|
|
109
|
+
[`hand_${t}`]: `${s}Hand`,
|
|
110
|
+
[`thigh_${t}`]: `${s}UpLeg`,
|
|
111
|
+
[`calf_${t}`]: `${s}Leg`,
|
|
112
|
+
[`foot_${t}`]: `${s}Foot`,
|
|
113
|
+
[`ball_${t}`]: `${s}ToeBase`,
|
|
114
|
+
});
|
|
115
|
+
for (const [f, uf] of FINGERS)
|
|
116
|
+
for (const i of [1, 2, 3])
|
|
117
|
+
alias[`${uf}_0${i}_${t}`] = `${s}Hand${f}${i}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (has.has('DEF-hips') || has.has('DEF-spine001')) {
|
|
121
|
+
// Rigify deform bones (Quaternius' Universal Animation Library uses this rig).
|
|
122
|
+
kind = 'rigify';
|
|
123
|
+
const sp = (i) => `DEF-spine.${String(i).padStart(3, '0')}`;
|
|
124
|
+
Object.assign(alias, has.has('DEF-hips')
|
|
125
|
+
? { 'DEF-hips': 'Hips', [sp(1)]: 'Spine', [sp(2)]: 'Spine1', [sp(3)]: 'Spine2', 'DEF-neck': 'Neck', 'DEF-head': 'Head' }
|
|
126
|
+
: { 'DEF-spine': 'Hips', [sp(1)]: 'Spine', [sp(2)]: 'Spine1', [sp(3)]: 'Spine2', [sp(4)]: 'Neck', [sp(6)]: 'Head' });
|
|
127
|
+
for (const [s, , t] of SIDES) {
|
|
128
|
+
Object.assign(alias, {
|
|
129
|
+
[`DEF-shoulder.${t}`]: `${s}Shoulder`,
|
|
130
|
+
[`DEF-upper_arm.${t}`]: `${s}Arm`,
|
|
131
|
+
[`DEF-forearm.${t}`]: `${s}ForeArm`,
|
|
132
|
+
[`DEF-hand.${t}`]: `${s}Hand`,
|
|
133
|
+
[`DEF-thigh.${t}`]: `${s}UpLeg`,
|
|
134
|
+
[`DEF-shin.${t}`]: `${s}Leg`,
|
|
135
|
+
[`DEF-foot.${t}`]: `${s}Foot`,
|
|
136
|
+
[`DEF-toe.${t}`]: `${s}ToeBase`,
|
|
137
|
+
});
|
|
138
|
+
for (const [f, , rf] of FINGERS)
|
|
139
|
+
for (const i of [1, 2, 3])
|
|
140
|
+
alias[`DEF-${rf}.0${i}.${t}`] = `${s}Hand${f}${i}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const byKey = new Map(Object.entries(alias).map(([k, v]) => [key(k), v]));
|
|
144
|
+
const toCanon = new Map();
|
|
145
|
+
const fromCanon = new Map();
|
|
146
|
+
for (const n of names) {
|
|
147
|
+
const c = kind === 'mixamo' ? stripMixamo(key(n)) : byKey.get(key(n));
|
|
148
|
+
if (!c || !CANON.has(c) || fromCanon.has(c))
|
|
149
|
+
continue;
|
|
150
|
+
toCanon.set(n, c);
|
|
151
|
+
fromCanon.set(c, n);
|
|
152
|
+
}
|
|
153
|
+
if (!REQUIRED.every((c) => fromCanon.has(c)))
|
|
154
|
+
return null;
|
|
155
|
+
return { kind, toCanon, fromCanon };
|
|
156
|
+
}
|
|
157
|
+
/** Every bone under `root`, in traversal order. */
|
|
158
|
+
function bonesUnder(root) {
|
|
159
|
+
const out = [];
|
|
160
|
+
root.traverse((o) => { if (o.isBone)
|
|
161
|
+
out.push(o); });
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** The skeleton under `root` that `readRigNames` reads, or null. */
|
|
165
|
+
export function detectRig(root) {
|
|
166
|
+
return readRigNames(bonesUnder(root).map((b) => b.name));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Rotation taking the rig's frame to canonical (left +X, up +Y, forward +Z).
|
|
170
|
+
* Up is the file's vertical (+Y, both FBX and glTF), not pelvis → head: a
|
|
171
|
+
* MetaHuman binds with her head ~10° off the hip line, and measuring up from
|
|
172
|
+
* the spine added that lean to every upright source pose.
|
|
173
|
+
*/
|
|
174
|
+
function canonicalFrame(pos, l, r) {
|
|
175
|
+
const left = pos.get(l).clone().sub(pos.get(r));
|
|
176
|
+
left.y = 0;
|
|
177
|
+
left.normalize();
|
|
178
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
179
|
+
const fwd = new THREE.Vector3().crossVectors(left, up).normalize();
|
|
180
|
+
left.crossVectors(up, fwd).normalize();
|
|
181
|
+
const basis = new THREE.Matrix4().makeBasis(left, up, fwd); // canonical → rig
|
|
182
|
+
return new THREE.Quaternion().setFromRotationMatrix(basis).invert(); // rig → canonical
|
|
183
|
+
}
|
|
184
|
+
const SECTORS = 16;
|
|
185
|
+
/** Hip height of an average adult above the ankle joint, m: converts bind units to metres. */
|
|
186
|
+
const HIP_HEIGHT_M = 0.92;
|
|
187
|
+
/**
|
|
188
|
+
* Reads the body under `root` as a retarget target: the skinned mesh whose
|
|
189
|
+
* skeleton `readRigNames` reads (the largest, when several are), its bind
|
|
190
|
+
* pose from the inverse bind matrices, and its torso and thighs from every
|
|
191
|
+
* skinned mesh. Nothing is read from the current pose, so a body that is
|
|
192
|
+
* already animating (or scaled and grounded) gives the same rig. `null` when
|
|
193
|
+
* no skeleton is recognised.
|
|
194
|
+
*/
|
|
195
|
+
export function createTargetRig(root) {
|
|
196
|
+
let mesh = null;
|
|
197
|
+
let names = null;
|
|
198
|
+
const meshes = [];
|
|
199
|
+
root.traverse((o) => {
|
|
200
|
+
const m = o;
|
|
201
|
+
if (!m.isSkinnedMesh || !m.skeleton)
|
|
202
|
+
return;
|
|
203
|
+
meshes.push(m);
|
|
204
|
+
if (mesh && m.skeleton.bones.length <= mesh.skeleton.bones.length)
|
|
205
|
+
return;
|
|
206
|
+
const n = readRigNames(m.skeleton.bones.map((b) => b.name));
|
|
207
|
+
if (n)
|
|
208
|
+
[mesh, names] = [m, n];
|
|
209
|
+
});
|
|
210
|
+
if (!mesh || !names)
|
|
211
|
+
return null;
|
|
212
|
+
const sk = mesh.skeleton;
|
|
213
|
+
const rig = names;
|
|
214
|
+
const T = (c) => rig.fromCanon.get(c);
|
|
215
|
+
const bones = new Map();
|
|
216
|
+
const bind = { rot: new Map(), pos: new Map() };
|
|
217
|
+
const bindMatrix = new Map();
|
|
218
|
+
const p = new THREE.Vector3();
|
|
219
|
+
const q = new THREE.Quaternion();
|
|
220
|
+
const s = new THREE.Vector3();
|
|
221
|
+
sk.bones.forEach((b, i) => {
|
|
222
|
+
bones.set(b.name, b);
|
|
223
|
+
const m = sk.boneInverses[i].clone().invert();
|
|
224
|
+
bindMatrix.set(b.name, m);
|
|
225
|
+
m.decompose(p, q, s);
|
|
226
|
+
bind.rot.set(b.name, q.clone());
|
|
227
|
+
bind.pos.set(b.name, p.clone());
|
|
228
|
+
});
|
|
229
|
+
// Bind frame = bindMatrix · meshWorld⁻¹ · world (attached bind mode), so a
|
|
230
|
+
// static parent outside the skin can be placed in it from its world matrix.
|
|
231
|
+
root.updateMatrixWorld(true);
|
|
232
|
+
const toBind = mesh.bindMatrix.clone().multiply(mesh.matrixWorld.clone().invert());
|
|
233
|
+
const parentBind = (b) => {
|
|
234
|
+
const parent = b.parent;
|
|
235
|
+
const i = parent ? sk.bones.indexOf(parent) : -1;
|
|
236
|
+
if (i >= 0)
|
|
237
|
+
return sk.boneInverses[i].clone().invert();
|
|
238
|
+
return toBind.clone().multiply(parent ? parent.matrixWorld : new THREE.Matrix4());
|
|
239
|
+
};
|
|
240
|
+
const topParent = new Map();
|
|
241
|
+
const local = new Map();
|
|
242
|
+
const offset = new Map();
|
|
243
|
+
for (const b of sk.bones) {
|
|
244
|
+
const parent = b.parent;
|
|
245
|
+
let pq;
|
|
246
|
+
if (parent && bones.has(parent.name))
|
|
247
|
+
pq = bind.rot.get(parent.name);
|
|
248
|
+
else {
|
|
249
|
+
parentBind(b).decompose(p, q, s);
|
|
250
|
+
pq = q.clone();
|
|
251
|
+
topParent.set(b.name, pq);
|
|
252
|
+
}
|
|
253
|
+
local.set(b.name, pq.clone().invert().multiply(bind.rot.get(b.name)));
|
|
254
|
+
if (parent && bind.pos.has(parent.name))
|
|
255
|
+
offset.set(b.name, bind.pos.get(b.name).clone().sub(bind.pos.get(parent.name)).applyQuaternion(pq.clone().invert()));
|
|
256
|
+
}
|
|
257
|
+
const hips = T('Hips');
|
|
258
|
+
const hipsParentInv = parentBind(bones.get(hips)).invert();
|
|
259
|
+
const toCanon = canonicalFrame(bind.pos, T('LeftUpLeg'), T('RightUpLeg'));
|
|
260
|
+
const y = (n) => bind.pos.get(n).clone().applyQuaternion(toCanon).y;
|
|
261
|
+
const hipHeight = y(hips) - Math.min(y(T('LeftFoot')), y(T('RightFoot')));
|
|
262
|
+
const order = [];
|
|
263
|
+
const top = sk.bones.filter((b) => !b.parent || !bones.has(b.parent.name));
|
|
264
|
+
for (const t of top)
|
|
265
|
+
t.traverse((o) => { if (o.isBone && bones.has(o.name))
|
|
266
|
+
order.push(o); });
|
|
267
|
+
const subtree = new Map();
|
|
268
|
+
for (const b of order) {
|
|
269
|
+
const list = [];
|
|
270
|
+
b.traverse((o) => { if (o.isBone && bones.has(o.name))
|
|
271
|
+
list.push(o.name); });
|
|
272
|
+
subtree.set(b.name, list);
|
|
273
|
+
}
|
|
274
|
+
const partial = { names: rig, bones, bind, bindMatrix, local, toCanon, order, hipHeight, unitsPerM: hipHeight / HIP_HEIGHT_M, topParent, hipsParentInv, offset, subtree };
|
|
275
|
+
return { ...partial, colliders: buildColliders(meshes, partial) };
|
|
276
|
+
}
|
|
277
|
+
/** Hips → neck, bone by bone along the skeleton: [bone, its child on the way up]. */
|
|
278
|
+
function trunk(rig) {
|
|
279
|
+
const hips = rig.names.fromCanon.get('Hips');
|
|
280
|
+
const neck = rig.names.fromCanon.get('Neck');
|
|
281
|
+
if (!neck)
|
|
282
|
+
return [];
|
|
283
|
+
const chain = [];
|
|
284
|
+
for (let o = rig.bones.get(neck) ?? null; o && rig.bones.has(o.name); o = o.parent) {
|
|
285
|
+
chain.unshift(o.name);
|
|
286
|
+
if (o.name === hips)
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
if (chain[0] !== hips)
|
|
290
|
+
return [];
|
|
291
|
+
return chain.slice(0, -1).map((b, i) => [b, chain[i + 1]]);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The segments hands and elbows must stay outside of: the trunk, bone by
|
|
295
|
+
* bone, and each thigh. A vertex belongs to the segment of the nearest
|
|
296
|
+
* collider bone above its main bone, unless a mapped joint (a clavicle, the
|
|
297
|
+
* neck, a knee) is in between: twists and correctives count with their bone,
|
|
298
|
+
* arms and the head do not.
|
|
299
|
+
*/
|
|
300
|
+
function buildColliders(meshes, rig) {
|
|
301
|
+
const T = (c) => rig.names.fromCanon.get(c);
|
|
302
|
+
const segs = [...trunk(rig)];
|
|
303
|
+
for (const [s] of SIDES) {
|
|
304
|
+
const th = T(`${s}UpLeg`);
|
|
305
|
+
const ca = T(`${s}Leg`);
|
|
306
|
+
if (th && ca)
|
|
307
|
+
segs.push([th, ca]);
|
|
308
|
+
}
|
|
309
|
+
const segOf = new Map(segs.map(([b], k) => [b, k]));
|
|
310
|
+
const owner = new Map();
|
|
311
|
+
for (const name of rig.bones.keys()) {
|
|
312
|
+
let k = -1;
|
|
313
|
+
for (let o = rig.bones.get(name); o && rig.bones.has(o.name); o = o.parent) {
|
|
314
|
+
if (segOf.has(o.name)) {
|
|
315
|
+
k = segOf.get(o.name);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (rig.names.toCanon.has(o.name))
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
owner.set(name, k);
|
|
322
|
+
}
|
|
323
|
+
// Every skinned part (body, clothing) counts. Each vertex goes into the
|
|
324
|
+
// frame of its main bone through that mesh's own bind, then out into the
|
|
325
|
+
// rig's bind frame, so meshes bound separately still line up.
|
|
326
|
+
const per = segs.map(() => []);
|
|
327
|
+
const v = new THREE.Vector3();
|
|
328
|
+
for (const mesh of meshes) {
|
|
329
|
+
const g = mesh.geometry;
|
|
330
|
+
const pos = g.attributes.position;
|
|
331
|
+
const si = g.attributes.skinIndex;
|
|
332
|
+
const sw = g.attributes.skinWeight;
|
|
333
|
+
if (!pos || !si || !sw)
|
|
334
|
+
continue;
|
|
335
|
+
const bones = mesh.skeleton.bones;
|
|
336
|
+
const boneSeg = bones.map((b) => owner.get(b.name) ?? -1);
|
|
337
|
+
const boneM = bones.map((b, j) => {
|
|
338
|
+
const bm = rig.bindMatrix.get(b.name);
|
|
339
|
+
if (!bm || boneSeg[j] < 0)
|
|
340
|
+
return null;
|
|
341
|
+
return bm.clone().multiply(mesh.skeleton.boneInverses[j]).multiply(mesh.bindMatrix);
|
|
342
|
+
});
|
|
343
|
+
for (let i = 0; i < pos.count; i++) {
|
|
344
|
+
let best = -1;
|
|
345
|
+
let j = 0;
|
|
346
|
+
for (let c = 0; c < 4; c++) {
|
|
347
|
+
const w = sw.getComponent(i, c);
|
|
348
|
+
if (w > best) {
|
|
349
|
+
best = w;
|
|
350
|
+
j = si.getComponent(i, c);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const k = boneSeg[j] ?? -1;
|
|
354
|
+
if (k < 0)
|
|
355
|
+
continue;
|
|
356
|
+
per[k].push(v.fromBufferAttribute(pos, i).applyMatrix4(boneM[j]).clone());
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const out = [];
|
|
360
|
+
segs.forEach(([bone, tip], k) => {
|
|
361
|
+
const a = rig.bind.pos.get(bone);
|
|
362
|
+
const b = rig.bind.pos.get(tip);
|
|
363
|
+
const verts = per[k];
|
|
364
|
+
if (verts.length < 50)
|
|
365
|
+
return;
|
|
366
|
+
const u = b.clone().sub(a);
|
|
367
|
+
const len = u.length();
|
|
368
|
+
if (len < 1e-9)
|
|
369
|
+
return;
|
|
370
|
+
u.normalize();
|
|
371
|
+
const e1 = new THREE.Vector3(1, 0, 0).applyQuaternion(rig.bind.rot.get(bone));
|
|
372
|
+
e1.addScaledVector(u, -e1.dot(u));
|
|
373
|
+
if (e1.lengthSq() < 1e-8)
|
|
374
|
+
e1.set(0, 1, 0).applyQuaternion(rig.bind.rot.get(bone)).addScaledVector(u, -new THREE.Vector3(0, 1, 0).applyQuaternion(rig.bind.rot.get(bone)).dot(u));
|
|
375
|
+
e1.normalize();
|
|
376
|
+
const e2 = new THREE.Vector3().crossVectors(u, e1);
|
|
377
|
+
const bins = Array.from({ length: SECTORS }, () => []);
|
|
378
|
+
const ts = [];
|
|
379
|
+
const d = new THREE.Vector3();
|
|
380
|
+
for (const p of verts) {
|
|
381
|
+
d.copy(p).sub(a);
|
|
382
|
+
const t = d.dot(u);
|
|
383
|
+
ts.push(t / len);
|
|
384
|
+
d.addScaledVector(u, -t);
|
|
385
|
+
const th = Math.atan2(d.dot(e2), d.dot(e1));
|
|
386
|
+
bins[Math.floor(((th + Math.PI) / (2 * Math.PI)) * SECTORS) % SECTORS].push(d.length());
|
|
387
|
+
}
|
|
388
|
+
const pct = (x, f) => (x.sort((i, j) => i - j), x[Math.min(x.length - 1, Math.floor(x.length * f))]);
|
|
389
|
+
const radius = new Float32Array(SECTORS);
|
|
390
|
+
const all = bins.flat();
|
|
391
|
+
bins.forEach((r, i) => (radius[i] = r.length >= 5 ? pct(r, 0.9) : pct([...all], 0.5)));
|
|
392
|
+
out.push({ bone, tip, radius, t0: pct(ts, 0.05), t1: pct(ts, 0.95), e1 });
|
|
393
|
+
});
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Keeps elbows and hands outside the torso and thighs. A clip made for a
|
|
398
|
+
* slimmer or differently built body rests its hands on a belly or hips that
|
|
399
|
+
* this one is not, and they sink in. Per frame: an elbow inside swings the
|
|
400
|
+
* upper arm out about the shoulder; a hand inside swings the forearm out
|
|
401
|
+
* about the elbow, each by just the depth plus 1 cm. Works on `world`
|
|
402
|
+
* (bind-frame world rotations) in place; returns the deepest point found
|
|
403
|
+
* before clearing, in bind units.
|
|
404
|
+
*/
|
|
405
|
+
function clearArms(rig, world) {
|
|
406
|
+
const T = (c) => rig.names.fromCanon.get(c);
|
|
407
|
+
const margin = 0.01 * rig.unitsPerM;
|
|
408
|
+
let memo = new Map();
|
|
409
|
+
const pos = (n) => {
|
|
410
|
+
const hit = memo.get(n);
|
|
411
|
+
if (hit)
|
|
412
|
+
return hit;
|
|
413
|
+
const parent = rig.bones.get(n).parent?.name ?? '';
|
|
414
|
+
const p = !world.has(parent) || !rig.offset.has(n)
|
|
415
|
+
? rig.bind.pos.get(n).clone()
|
|
416
|
+
: pos(parent).clone().add(rig.offset.get(n).clone().applyQuaternion(world.get(parent)));
|
|
417
|
+
memo.set(n, p);
|
|
418
|
+
return p;
|
|
419
|
+
};
|
|
420
|
+
const inside = (p) => {
|
|
421
|
+
let depth = -Infinity;
|
|
422
|
+
const normal = new THREE.Vector3();
|
|
423
|
+
for (const c of rig.colliders) {
|
|
424
|
+
const toBind = rig.bind.rot.get(c.bone).clone().multiply(world.get(c.bone).clone().invert());
|
|
425
|
+
const q = p.clone().sub(pos(c.bone)).applyQuaternion(toBind);
|
|
426
|
+
const u = rig.bind.pos.get(c.tip).clone().sub(rig.bind.pos.get(c.bone));
|
|
427
|
+
const len = u.length();
|
|
428
|
+
u.normalize();
|
|
429
|
+
const t = q.dot(u) / len;
|
|
430
|
+
if (t < c.t0 - 0.05 || t > c.t1 + 0.05)
|
|
431
|
+
continue;
|
|
432
|
+
const d = q.addScaledVector(u, -t * len);
|
|
433
|
+
const e2 = new THREE.Vector3().crossVectors(u, c.e1);
|
|
434
|
+
const x = ((Math.atan2(d.dot(e2), d.dot(c.e1)) + Math.PI) / (2 * Math.PI)) * SECTORS - 0.5;
|
|
435
|
+
const i = Math.floor(x);
|
|
436
|
+
const k = x - i;
|
|
437
|
+
const r = c.radius[((i % SECTORS) + SECTORS) % SECTORS] * (1 - k) + c.radius[(((i + 1) % SECTORS) + SECTORS) % SECTORS] * k;
|
|
438
|
+
const dd = r + margin - d.length();
|
|
439
|
+
if (dd > depth) {
|
|
440
|
+
depth = dd;
|
|
441
|
+
normal.copy(d).normalize().applyQuaternion(toBind.invert());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return { depth, normal };
|
|
445
|
+
};
|
|
446
|
+
/** Swing `bone`'s subtree about its joint so `points` clear the body. */
|
|
447
|
+
const push = (bone, points) => {
|
|
448
|
+
let worst = 0;
|
|
449
|
+
for (let it = 0; it < 4; it++) {
|
|
450
|
+
const pivot = pos(bone);
|
|
451
|
+
let best = null;
|
|
452
|
+
for (const p of points()) {
|
|
453
|
+
const h = inside(p);
|
|
454
|
+
if (h.depth > 0 && (!best || h.depth > best.depth))
|
|
455
|
+
best = { ...h, p };
|
|
456
|
+
}
|
|
457
|
+
if (!best)
|
|
458
|
+
break;
|
|
459
|
+
worst = Math.max(worst, best.depth);
|
|
460
|
+
const arm = best.p.clone().sub(pivot);
|
|
461
|
+
const axis = new THREE.Vector3().crossVectors(arm, best.normal);
|
|
462
|
+
if (axis.lengthSq() < 1e-12 || arm.length() < 1e-9)
|
|
463
|
+
break;
|
|
464
|
+
const r = new THREE.Quaternion().setFromAxisAngle(axis.normalize(), best.depth / arm.length());
|
|
465
|
+
for (const n of rig.subtree.get(bone))
|
|
466
|
+
world.set(n, r.clone().multiply(world.get(n)));
|
|
467
|
+
memo = new Map();
|
|
468
|
+
}
|
|
469
|
+
return worst;
|
|
470
|
+
};
|
|
471
|
+
let deepest = 0;
|
|
472
|
+
for (const [s] of SIDES) {
|
|
473
|
+
const upper = T(`${s}Arm`);
|
|
474
|
+
const lower = T(`${s}ForeArm`);
|
|
475
|
+
const hand = T(`${s}Hand`);
|
|
476
|
+
if (!upper || !lower || !hand || !world.has(upper) || !world.has(lower))
|
|
477
|
+
continue;
|
|
478
|
+
const f1 = T(`${s}HandMiddle1`) ?? hand;
|
|
479
|
+
const f3 = T(`${s}HandMiddle3`) ?? f1;
|
|
480
|
+
deepest = Math.max(deepest, push(upper, () => [pos(lower), pos(upper).clone().lerp(pos(lower), 0.6)]), push(lower, () => [pos(hand), pos(f1), pos(f3), pos(lower).clone().lerp(pos(hand), 0.5)]));
|
|
481
|
+
}
|
|
482
|
+
return deepest;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Wraps a loaded file (an FBXLoader group, or a GLTF's `scene`) as a
|
|
486
|
+
* retarget source, recording its rest pose. Call before anything animates
|
|
487
|
+
* it. `null` when its skeleton is not one `readRigNames` reads.
|
|
488
|
+
*/
|
|
489
|
+
export function createAnimationSource(root, clips) {
|
|
490
|
+
const names = detectRig(root);
|
|
491
|
+
if (!names)
|
|
492
|
+
return null;
|
|
493
|
+
const rest = [];
|
|
494
|
+
root.traverse((o) => rest.push([o, o.position.clone(), o.quaternion.clone(), o.scale.clone()]));
|
|
495
|
+
return { root, clips, names, rest };
|
|
496
|
+
}
|
|
497
|
+
function samplePose(root, bones, toCanon) {
|
|
498
|
+
root.updateMatrixWorld(true);
|
|
499
|
+
const invM = root.matrixWorld.clone().invert();
|
|
500
|
+
const pose = { rot: new Map(), pos: new Map() };
|
|
501
|
+
const m = new THREE.Matrix4();
|
|
502
|
+
const s = new THREE.Vector3();
|
|
503
|
+
for (const b of bones) {
|
|
504
|
+
const n = toCanon.get(b.name);
|
|
505
|
+
if (!n)
|
|
506
|
+
continue;
|
|
507
|
+
const p = new THREE.Vector3();
|
|
508
|
+
const q = new THREE.Quaternion();
|
|
509
|
+
m.multiplyMatrices(invM, b.matrixWorld).decompose(p, q, s);
|
|
510
|
+
pose.rot.set(n, q);
|
|
511
|
+
pose.pos.set(n, p);
|
|
512
|
+
}
|
|
513
|
+
return pose;
|
|
514
|
+
}
|
|
515
|
+
// ── posture, tuned on convai-mha-1 bodies against their own clips ───────────
|
|
516
|
+
/**
|
|
517
|
+
* Trunk lean (hips → neck, degrees, + = forward) the convai-mha-1 pack idle
|
|
518
|
+
* holds. Mixamo's idles and talks stand 1–4° further back than Mixamo's rest
|
|
519
|
+
* pose, which reads as a backward lean on these bodies.
|
|
520
|
+
*/
|
|
521
|
+
export const UPRIGHT_DEG = -3;
|
|
522
|
+
/** Hips at least this share of standing height: a standing frame. */
|
|
523
|
+
const STANDING = 0.85;
|
|
524
|
+
/**
|
|
525
|
+
* Most the trunk is pitched forward, degrees. Only forward: a clip that
|
|
526
|
+
* already leans forward is left alone, and a deliberate lean back (pointing,
|
|
527
|
+
* laughing) is softened, not erased.
|
|
528
|
+
*/
|
|
529
|
+
const MAX_CORRECTION = 6;
|
|
530
|
+
/**
|
|
531
|
+
* Share of the posture correction each spine joint (and everything above it)
|
|
532
|
+
* takes. Leaning the upper back forward carries the neck's base forward of
|
|
533
|
+
* the shoulders, pushing the head out; the neck turns back by the same amount
|
|
534
|
+
* so the head stays over the shoulders as in the source. The head takes none,
|
|
535
|
+
* so the gaze stays where the clip put it.
|
|
536
|
+
*/
|
|
537
|
+
const SPINE_SHARE = [['Head', 0], ['Neck', -1], ['Spine2', 1], ['Spine1', 2 / 3], ['Spine', 1 / 3]];
|
|
538
|
+
/**
|
|
539
|
+
* Knee bend (thigh pitch − shin pitch, degrees) a standing clip's median is
|
|
540
|
+
* brought to. Mixamo's idles and talks stand with 30–45° of bend; the pack
|
|
541
|
+
* idle stands at 0°, and the bend pushes the hips back so the body reads as
|
|
542
|
+
* leaning back. Every frame's bend is scaled by the same factor, so stepping
|
|
543
|
+
* and weight shifts keep their shape.
|
|
544
|
+
*/
|
|
545
|
+
const KNEE_FLEX_DEG = 8;
|
|
546
|
+
/**
|
|
547
|
+
* Clavicle elevation (clavicle → upper arm above level, degrees) the pack
|
|
548
|
+
* clips hold. Mixamo's shoulders sit 3–8° lower; each side is lifted so the
|
|
549
|
+
* clip's median reaches this, by at most SHOULDER_MAX_DEG and never lowered.
|
|
550
|
+
*/
|
|
551
|
+
const SHOULDER_UP_DEG = -3.4;
|
|
552
|
+
const SHOULDER_MAX_DEG = 6;
|
|
553
|
+
/** Quaternions this close (|dot| ≥) are the same rotation. */
|
|
554
|
+
const SAME = 1 - 1e-9;
|
|
555
|
+
/**
|
|
556
|
+
* Retargets one clip of `source` onto `rig`: a clip of local rotations for
|
|
557
|
+
* the target's bones (only those that ever leave their rest) and the hips'
|
|
558
|
+
* position, sampled at `fps`.
|
|
559
|
+
*/
|
|
560
|
+
export function retargetClip(source, src, rig, opts = {}) {
|
|
561
|
+
const fps = opts.fps ?? 30;
|
|
562
|
+
const name = opts.name ?? src.name;
|
|
563
|
+
for (const [o, p, q, sc] of source.rest) {
|
|
564
|
+
o.position.copy(p);
|
|
565
|
+
o.quaternion.copy(q);
|
|
566
|
+
o.scale.copy(sc);
|
|
567
|
+
}
|
|
568
|
+
const sRoot = source.root;
|
|
569
|
+
const srcBones = bonesUnder(sRoot);
|
|
570
|
+
const sNames = source.names.toCanon;
|
|
571
|
+
const T = (c) => rig.names.fromCanon.get(c);
|
|
572
|
+
const rest = samplePose(sRoot, srcBones, sNames);
|
|
573
|
+
const sToCanon = canonicalFrame(rest.pos, 'LeftUpLeg', 'RightUpLeg');
|
|
574
|
+
const sy = (pose, n) => pose.pos.get(n).clone().applyQuaternion(sToCanon).y;
|
|
575
|
+
const sHip = sy(rest, 'Hips') - Math.min(sy(rest, 'LeftFoot'), sy(rest, 'RightFoot'));
|
|
576
|
+
if (!(sHip > 1e-9) || !(rig.hipHeight > 1e-9))
|
|
577
|
+
throw new Error('cannot measure hip height (hips not above the feet)');
|
|
578
|
+
const scale = rig.hipHeight / sHip;
|
|
579
|
+
const hips = T('Hips');
|
|
580
|
+
/** Target bone → canonical joint, for joints both rigs have. */
|
|
581
|
+
const mapped = new Map();
|
|
582
|
+
for (const [c, t] of rig.names.fromCanon)
|
|
583
|
+
if (rest.rot.has(c))
|
|
584
|
+
mapped.set(t, c);
|
|
585
|
+
// Rest-pose fix per mapped bone, in canonical space.
|
|
586
|
+
const ident = new THREE.Quaternion();
|
|
587
|
+
const fix = new Map();
|
|
588
|
+
for (const [t, c] of mapped) {
|
|
589
|
+
const aim = AIM[c];
|
|
590
|
+
const tAim = aim ? T(aim) : undefined;
|
|
591
|
+
if (FLAT.has(c) || OWN_SHAPE.has(c) || !aim || !tAim || !rest.pos.has(aim)) {
|
|
592
|
+
fix.set(t, ident.clone());
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const ds = rest.pos.get(aim).clone().sub(rest.pos.get(c)).applyQuaternion(sToCanon).normalize();
|
|
596
|
+
const dt = rig.bind.pos.get(tAim).clone().sub(rig.bind.pos.get(t)).applyQuaternion(rig.toCanon).normalize();
|
|
597
|
+
if (ELEVATION_ONLY.has(c) && Math.abs(Math.asin(THREE.MathUtils.clamp(ds.z, -1, 1))) > THREE.MathUtils.degToRad(CLAVICLE_BUILD_DEG))
|
|
598
|
+
ds.set(Math.sign(dt.x) * Math.sqrt(Math.max(0, 1 - ds.y * ds.y)), ds.y, 0);
|
|
599
|
+
fix.set(t, new THREE.Quaternion().setFromUnitVectors(dt, ds));
|
|
600
|
+
}
|
|
601
|
+
// Ends of chains (fingertips, toes) inherit their nearest fixed ancestor's fix.
|
|
602
|
+
for (const [t, c] of mapped) {
|
|
603
|
+
if (FLAT.has(c) || OWN_SHAPE.has(c) || !fix.get(t).equals(ident))
|
|
604
|
+
continue;
|
|
605
|
+
for (let p = rig.bones.get(t).parent; p && rig.bones.has(p.name); p = p.parent) {
|
|
606
|
+
const f = fix.get(p.name);
|
|
607
|
+
if (f && !f.equals(ident)) {
|
|
608
|
+
fix.set(t, f.clone());
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const mixer = new THREE.AnimationMixer(sRoot);
|
|
614
|
+
const sampler = mixer.clipAction(src);
|
|
615
|
+
// A one-shot that holds its last frame: a looping action wraps
|
|
616
|
+
// t = duration back to 0, which made every clip end on its first pose.
|
|
617
|
+
sampler.setLoop(THREE.LoopOnce, 1);
|
|
618
|
+
sampler.clampWhenFinished = true;
|
|
619
|
+
const sampleAt = (t) => {
|
|
620
|
+
sampler.reset().play();
|
|
621
|
+
mixer.setTime(t);
|
|
622
|
+
return samplePose(sRoot, srcBones, sNames);
|
|
623
|
+
};
|
|
624
|
+
const frames = Math.max(2, Math.round(src.duration * fps) + 1);
|
|
625
|
+
const times = new Float32Array(frames);
|
|
626
|
+
const qTracks = new Map();
|
|
627
|
+
for (const b of rig.order)
|
|
628
|
+
qTracks.set(b.name, new Float32Array(frames * 4));
|
|
629
|
+
const hipsPos = new Float32Array(frames * 3);
|
|
630
|
+
const tCanonInv = rig.toCanon.clone().invert();
|
|
631
|
+
const sCanonInv = sToCanon.clone().invert();
|
|
632
|
+
const world = new Map();
|
|
633
|
+
const restCanonInv = new Map();
|
|
634
|
+
for (const c of mapped.values())
|
|
635
|
+
restCanonInv.set(c, sToCanon.clone().multiply(rest.rot.get(c)).multiply(sCanonInv).invert());
|
|
636
|
+
const restT = new Map();
|
|
637
|
+
for (const t of mapped.keys())
|
|
638
|
+
restT.set(t, rig.toCanon.clone().multiply(rig.bind.rot.get(t)).multiply(tCanonInv));
|
|
639
|
+
const hipsRest = rig.bind.pos.get(hips).clone().applyMatrix4(rig.hipsParentInv);
|
|
640
|
+
const parentLinear = new THREE.Matrix3().setFromMatrix4(rig.hipsParentInv);
|
|
641
|
+
/** A bone's vector to `child` (anywhere below it), in its own bind frame. */
|
|
642
|
+
const segCache = new Map();
|
|
643
|
+
const seg = (bone, child) => {
|
|
644
|
+
const k = `${bone}>${child}`;
|
|
645
|
+
let v = segCache.get(k);
|
|
646
|
+
if (!v)
|
|
647
|
+
segCache.set(k, (v = rig.bind.pos.get(child).clone().sub(rig.bind.pos.get(bone)).applyQuaternion(rig.bind.rot.get(bone).clone().invert())));
|
|
648
|
+
return v;
|
|
649
|
+
};
|
|
650
|
+
/** The hip line for the hips' bind-frame rotation, level, in canonical space: the axis the body leans about. */
|
|
651
|
+
const hipLine = seg(hips, T('LeftUpLeg')).clone().sub(seg(hips, T('RightUpLeg')));
|
|
652
|
+
const hipAxis = (hipsWorld) => {
|
|
653
|
+
const v = hipLine.clone().applyQuaternion(hipsWorld).applyQuaternion(rig.toCanon);
|
|
654
|
+
v.y = 0;
|
|
655
|
+
return v.normalize();
|
|
656
|
+
};
|
|
657
|
+
const share = new Map();
|
|
658
|
+
const shareBy = new Map(SPINE_SHARE.flatMap(([c, k]) => (T(c) ? [[T(c), k]] : [])));
|
|
659
|
+
for (const b of rig.order) {
|
|
660
|
+
for (let o = b; o; o = o.parent) {
|
|
661
|
+
const k = shareBy.get(o.name);
|
|
662
|
+
if (k !== undefined) {
|
|
663
|
+
share.set(b.name, k);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const legs = SIDES.map(([s]) => [T(`${s}UpLeg`), T(`${s}Leg`), T(`${s}Foot`)])
|
|
669
|
+
.filter((l) => l.every((n) => n && mapped.has(n)));
|
|
670
|
+
const clavicles = SIDES.map(([s]) => [T(`${s}Shoulder`), T(`${s}Arm`)])
|
|
671
|
+
.filter((l) => l.every((n) => n && mapped.has(n)));
|
|
672
|
+
const trunkChain = trunk(rig);
|
|
673
|
+
/** Source hips height over the lower foot, as a share of standing height. */
|
|
674
|
+
const hipShare = (pose) => (sy(pose, 'Hips') - Math.min(sy(pose, 'LeftFoot'), sy(pose, 'RightFoot'))) / sHip;
|
|
675
|
+
/** A mapped bone's rotation in canonical space, before any posture correction. */
|
|
676
|
+
const canon = (t, pose) => {
|
|
677
|
+
const c = mapped.get(t);
|
|
678
|
+
return sToCanon.clone().multiply(pose.rot.get(c)).multiply(sCanonInv)
|
|
679
|
+
.multiply(restCanonInv.get(c)).multiply(fix.get(t)).multiply(restT.get(t));
|
|
680
|
+
};
|
|
681
|
+
/** A bone's vector to `child`, in canonical space, for canonical rotation c. */
|
|
682
|
+
const boneVec = (c, bone, child) => seg(bone, child).clone().applyQuaternion(rig.toCanon).applyQuaternion(c);
|
|
683
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
684
|
+
const pitch = (v, fwd) => THREE.MathUtils.radToDeg(Math.atan2(v.dot(fwd), -v.y));
|
|
685
|
+
const rotAbout = (axis, deg) => new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(deg));
|
|
686
|
+
const clearHands = opts.clearHands !== false && rig.colliders.length > 0;
|
|
687
|
+
/**
|
|
688
|
+
* Target world rotations for the source at time t, with the spine pitched
|
|
689
|
+
* `corr` degrees forward and each knee's bend scaled by `knee` (fading out
|
|
690
|
+
* as the hips drop below standing, so a squat keeps its bend). Returns the
|
|
691
|
+
* source pose, each leg's bend before scaling, how much the straighter
|
|
692
|
+
* standing leg lifts the hips (canonical units) and each clavicle's
|
|
693
|
+
* elevation.
|
|
694
|
+
*/
|
|
695
|
+
let handDepth = 0;
|
|
696
|
+
/** Bones whose solved local rotation ever leaves their bind (measured before Float32 storage). */
|
|
697
|
+
const moved = new Set();
|
|
698
|
+
const solve = (t, corr, knee = 1, f = -1, shoulders = {}) => {
|
|
699
|
+
const pose = sampleAt(t);
|
|
700
|
+
world.clear();
|
|
701
|
+
// Legs first: each bone's rotation is absolute, so the thigh and shin can
|
|
702
|
+
// be pitched apart without moving the foot's own orientation.
|
|
703
|
+
const legTurn = new Map();
|
|
704
|
+
const flex = [];
|
|
705
|
+
const hipsC = mapped.has(hips) ? canon(hips, pose) : restT.get(hips) ?? ident;
|
|
706
|
+
const axis = hipAxis(tCanonInv.clone().multiply(hipsC).multiply(rig.toCanon));
|
|
707
|
+
const fwd = new THREE.Vector3().crossVectors(axis, up);
|
|
708
|
+
const stand = THREE.MathUtils.smoothstep(hipShare(pose), STANDING - 0.1, STANDING);
|
|
709
|
+
const k = 1 - (1 - knee) * stand;
|
|
710
|
+
let lift = 0;
|
|
711
|
+
let lowest = -Infinity;
|
|
712
|
+
for (const [thigh, calf, foot] of legs) {
|
|
713
|
+
const tv = boneVec(canon(thigh, pose), thigh, calf);
|
|
714
|
+
const sv = boneVec(canon(calf, pose), calf, foot);
|
|
715
|
+
const bend = pitch(tv, fwd) - pitch(sv, fwd);
|
|
716
|
+
flex.push(bend);
|
|
717
|
+
const turn = (bend * (1 - k)) / 2;
|
|
718
|
+
legTurn.set(thigh, turn);
|
|
719
|
+
legTurn.set(calf, -turn);
|
|
720
|
+
const before = -(tv.y + sv.y);
|
|
721
|
+
if (before > lowest) {
|
|
722
|
+
lowest = before;
|
|
723
|
+
lift = -(tv.clone().applyQuaternion(rotAbout(axis, turn)).y + sv.clone().applyQuaternion(rotAbout(axis, -turn)).y) - before;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
for (const b of rig.order) {
|
|
727
|
+
const n = b.name;
|
|
728
|
+
const parentWorld = (b.parent && world.get(b.parent.name)) || rig.topParent.get(n) || null;
|
|
729
|
+
let w;
|
|
730
|
+
if (mapped.has(n)) {
|
|
731
|
+
// Δ in canonical space, applied to the rest-fixed target rotation.
|
|
732
|
+
let c = canon(n, pose);
|
|
733
|
+
const ks = share.get(n);
|
|
734
|
+
if (ks && corr)
|
|
735
|
+
c = rotAbout(axis, ks * corr).multiply(c);
|
|
736
|
+
const turn = legTurn.get(n);
|
|
737
|
+
if (turn)
|
|
738
|
+
c = rotAbout(axis, turn).multiply(c);
|
|
739
|
+
const lifted = shoulders[n];
|
|
740
|
+
if (lifted) {
|
|
741
|
+
const arm = clavicles.find(([cl]) => cl === n)[1];
|
|
742
|
+
const v = boneVec(c, n, arm);
|
|
743
|
+
c = rotAbout(new THREE.Vector3().crossVectors(v, up).normalize(), lifted).multiply(c);
|
|
744
|
+
}
|
|
745
|
+
w = tCanonInv.clone().multiply(c).multiply(rig.toCanon);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
w = parentWorld ? parentWorld.clone().multiply(rig.local.get(n)) : rig.bind.rot.get(n).clone();
|
|
749
|
+
}
|
|
750
|
+
world.set(n, w);
|
|
751
|
+
}
|
|
752
|
+
if (f >= 0) {
|
|
753
|
+
if (clearHands)
|
|
754
|
+
handDepth = Math.max(handDepth, clearArms(rig, world));
|
|
755
|
+
const tmp = new THREE.Quaternion();
|
|
756
|
+
for (const b of rig.order) {
|
|
757
|
+
const parentWorld = (b.parent && world.get(b.parent.name)) || rig.topParent.get(b.name) || null;
|
|
758
|
+
const l = parentWorld ? tmp.copy(parentWorld).invert().multiply(world.get(b.name)) : rig.local.get(b.name);
|
|
759
|
+
if (Math.abs(l.dot(rig.local.get(b.name))) < SAME)
|
|
760
|
+
moved.add(b.name);
|
|
761
|
+
l.toArray(qTracks.get(b.name), f * 4);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const clav = clavicles.map(([cl, arm]) => {
|
|
765
|
+
const v = boneVec(canon(cl, pose), cl, arm);
|
|
766
|
+
return THREE.MathUtils.radToDeg(Math.asin(THREE.MathUtils.clamp(v.y / v.length(), -1, 1)));
|
|
767
|
+
});
|
|
768
|
+
return { pose, flex, lift, clav };
|
|
769
|
+
};
|
|
770
|
+
/** Hips → neck lean of the solved pose, degrees forward of the hips. */
|
|
771
|
+
const trunkLean = () => {
|
|
772
|
+
const v = new THREE.Vector3();
|
|
773
|
+
for (const [b, child] of trunkChain)
|
|
774
|
+
v.add(seg(b, child).clone().applyQuaternion(world.get(b)));
|
|
775
|
+
v.applyQuaternion(rig.toCanon);
|
|
776
|
+
const fwd = new THREE.Vector3().crossVectors(hipAxis(world.get(hips)), up);
|
|
777
|
+
return THREE.MathUtils.radToDeg(Math.atan2(v.dot(fwd), v.y));
|
|
778
|
+
};
|
|
779
|
+
// Neutral lean = median over the frames where the body stands (a pick-up's
|
|
780
|
+
// bend or a sit doesn't count). The trunk turns less than the correction
|
|
781
|
+
// (the lower spine takes a share); the lean is close to linear in it, so
|
|
782
|
+
// one probe frame gives the gain.
|
|
783
|
+
const upright = opts.upright === undefined ? UPRIGHT_DEG : opts.upright;
|
|
784
|
+
const standing = [];
|
|
785
|
+
const bends = [];
|
|
786
|
+
const clavs = clavicles.map(() => []);
|
|
787
|
+
for (let f = 0; f < frames; f++) {
|
|
788
|
+
const { pose, flex, clav } = solve(Math.min(src.duration, f / fps), 0);
|
|
789
|
+
clav.forEach((v, i) => clavs[i].push(v));
|
|
790
|
+
if (hipShare(pose) <= STANDING)
|
|
791
|
+
continue;
|
|
792
|
+
if (trunkChain.length)
|
|
793
|
+
standing.push(trunkLean());
|
|
794
|
+
bends.push(...flex);
|
|
795
|
+
}
|
|
796
|
+
const median = (v) => (v.sort((x, y) => x - y), v.length ? v[Math.floor(v.length / 2)] : NaN);
|
|
797
|
+
const lean0 = median(standing);
|
|
798
|
+
const bend0 = median(bends);
|
|
799
|
+
const isStanding = upright !== null && standing.length >= frames / 2;
|
|
800
|
+
// A clip whose knees bend well past their usual stance (a squat to pick
|
|
801
|
+
// something up) means it; leave those knees alone.
|
|
802
|
+
const crouch = bends.length ? bends[Math.floor(bends.length * 0.95)] - bend0 > 20 : true;
|
|
803
|
+
const knee = isStanding && !crouch && bend0 > KNEE_FLEX_DEG + 4 ? Math.max(0.15, KNEE_FLEX_DEG / bend0) : 1;
|
|
804
|
+
const shoulders = {};
|
|
805
|
+
if (upright !== null)
|
|
806
|
+
clavicles.forEach(([cl], i) => (shoulders[cl] = THREE.MathUtils.clamp(SHOULDER_UP_DEG - median(clavs[i]), 0, SHOULDER_MAX_DEG)));
|
|
807
|
+
let corr = 0;
|
|
808
|
+
if (isStanding && upright - lean0 > 0.25) {
|
|
809
|
+
const probe = (c) => (solve(0, c, knee), trunkLean());
|
|
810
|
+
const guess = upright - lean0;
|
|
811
|
+
const gain = (probe(guess) - probe(0)) / guess;
|
|
812
|
+
if (gain > 1e-3)
|
|
813
|
+
corr = Math.min(MAX_CORRECTION, guess) / gain;
|
|
814
|
+
}
|
|
815
|
+
for (let f = 0; f < frames; f++) {
|
|
816
|
+
const t = Math.min(src.duration, f / fps);
|
|
817
|
+
times[f] = t;
|
|
818
|
+
const { pose, lift } = solve(t, corr, knee, f, shoulders);
|
|
819
|
+
// Hips: the source hips' offset from rest, in canonical space, scaled,
|
|
820
|
+
// raised by what straightening the standing leg added.
|
|
821
|
+
const d = pose.pos.get('Hips').clone().sub(rest.pos.get('Hips')).applyQuaternion(sToCanon).multiplyScalar(scale);
|
|
822
|
+
d.y += lift;
|
|
823
|
+
// Into the hips' parent's frame (bind frame → parent local, with its scale).
|
|
824
|
+
d.applyQuaternion(tCanonInv).applyMatrix3(parentLinear);
|
|
825
|
+
hipsRest.clone().add(d).toArray(hipsPos, f * 3);
|
|
826
|
+
}
|
|
827
|
+
mixer.stopAllAction();
|
|
828
|
+
mixer.uncacheRoot(sRoot);
|
|
829
|
+
for (const [o, p, q, sc] of source.rest) {
|
|
830
|
+
o.position.copy(p);
|
|
831
|
+
o.quaternion.copy(q);
|
|
832
|
+
o.scale.copy(sc);
|
|
833
|
+
}
|
|
834
|
+
// Only bones that ever leave their bind get a track: the rest stay as the
|
|
835
|
+
// scene holds them, and the mixer has fewer bindings to drive.
|
|
836
|
+
const tracks = [];
|
|
837
|
+
for (const [n, values] of qTracks)
|
|
838
|
+
if (moved.has(n))
|
|
839
|
+
tracks.push(new THREE.QuaternionKeyframeTrack(`${n}.quaternion`, times, values));
|
|
840
|
+
tracks.push(new THREE.VectorKeyframeTrack(`${hips}.position`, times, hipsPos));
|
|
841
|
+
for (const t of tracks)
|
|
842
|
+
if (t.values.some((v) => !Number.isFinite(v)))
|
|
843
|
+
throw new Error(`non-finite values on ${t.name}`);
|
|
844
|
+
const clip = new THREE.AnimationClip(name, src.duration, tracks);
|
|
845
|
+
const report = {
|
|
846
|
+
source: source.names.kind,
|
|
847
|
+
neutralLeanDeg: lean0,
|
|
848
|
+
trunkCorrectionDeg: corr && upright !== null ? Math.min(MAX_CORRECTION, upright - lean0) : 0,
|
|
849
|
+
kneeBendDeg: bend0,
|
|
850
|
+
kneeScale: knee,
|
|
851
|
+
shoulderLiftDeg: shoulders,
|
|
852
|
+
handsClearedCm: (handDepth / rig.unitsPerM) * 100,
|
|
853
|
+
};
|
|
854
|
+
clip.userData = { ...clip.userData, retarget: report };
|
|
855
|
+
return clip;
|
|
856
|
+
}
|