@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,193 @@
|
|
|
1
|
+
import { detectRig } from '../clips/retarget.js';
|
|
2
|
+
import { REQUIRED_BONES } from '../rig/resolve.js';
|
|
3
|
+
import { CHANNELS } from './morphMap.js';
|
|
4
|
+
/** The mouth motions speech needs, each by the channels that carry it. */
|
|
5
|
+
const MOUTH_GROUPS = {
|
|
6
|
+
'jaw open': /^jawOpen$/,
|
|
7
|
+
'lip rounding (oo, w)': /^(mouthFunnel|mouthLipsPurse)/,
|
|
8
|
+
'lip closure (m, b, p)': /^(mouthLipsTogether|mouthPress|mouthLipsPress)/,
|
|
9
|
+
'lip spread (ee, smile)': /^(mouthCornerPull|mouthStretch)/,
|
|
10
|
+
'upper and lower lip (f, v, open vowels)': /^(mouthUpperLipRaise|mouthLowerLipDepress)/,
|
|
11
|
+
};
|
|
12
|
+
const PREFIX = 'CTRL_expressions_';
|
|
13
|
+
const list = (xs, n = 6) => (xs.length > n ? `${xs.slice(0, n).join(', ')} and ${xs.length - n} more` : xs.join(', '));
|
|
14
|
+
const configSnippet = (map) => `morphMap: {\n${Object.entries(map).map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`).join('\n')}\n}`;
|
|
15
|
+
export function inspectCharacter(input) {
|
|
16
|
+
const { root, rig, match } = input;
|
|
17
|
+
const issues = [];
|
|
18
|
+
// ── face ──────────────────────────────────────────────────────────────────
|
|
19
|
+
const answered = new Set();
|
|
20
|
+
let morphTargets = 0;
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
root.traverse((o) => {
|
|
23
|
+
const dict = o.morphTargetDictionary;
|
|
24
|
+
if (!dict)
|
|
25
|
+
return;
|
|
26
|
+
for (const k of Object.keys(dict)) {
|
|
27
|
+
if (k.startsWith(PREFIX))
|
|
28
|
+
answered.add(k.slice(PREFIX.length));
|
|
29
|
+
if (!seen.has(k) && !(k.startsWith(PREFIX) && input.morphMap.map[k.slice(PREFIX.length)]))
|
|
30
|
+
seen.add(k);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
morphTargets = seen.size;
|
|
34
|
+
const bound = CHANNELS.filter((c) => answered.has(c));
|
|
35
|
+
const mouth = Object.fromEntries(Object.entries(MOUTH_GROUPS).map(([g, re]) => [g, bound.some((c) => re.test(c))]));
|
|
36
|
+
const blink = rig.morphs.has('blinkL') || rig.morphs.has('blinkR');
|
|
37
|
+
const eyeLook = rig.morphs.has('eyeLookUpL') || rig.morphs.has('eyeLookInL');
|
|
38
|
+
if (match.naming === 'none') {
|
|
39
|
+
issues.push({
|
|
40
|
+
level: 'error', area: 'face',
|
|
41
|
+
message: 'The model has no morph targets (blendshapes), so its face cannot move: no lipsync, no blinking, no eye movement. Head and body motion still work.',
|
|
42
|
+
fix: 'Export the face with blendshapes: MetaHuman expressions (CTRL_expressions_*) for the best lipsync, or the ARKit 52. In Blender, keep shape keys on export; in Maya or Unreal, export morph targets.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
if (!mouth['jaw open']) {
|
|
47
|
+
issues.push({
|
|
48
|
+
level: 'error', area: 'face',
|
|
49
|
+
message: `None of the model's ${morphTargets} morph targets opens the jaw under a name embody recognises, so the mouth will not move with speech.` +
|
|
50
|
+
(match.naming === 'visemes' ? ` It has viseme shapes (${list(match.visemes, 4)}); the stream drives expression channels, not visemes.` : ''),
|
|
51
|
+
fix: 'Map the jaw-open shape and the other mouth shapes with `morphMap` (MetaHuman channel name → your morph name), e.g. morphMap: { jawOpen: "MouthOpen" }, or export the ARKit 52 or MetaHuman expressions.',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const lacking = Object.entries(mouth).filter(([, ok]) => !ok).map(([g]) => g);
|
|
56
|
+
if (lacking.length) {
|
|
57
|
+
issues.push({
|
|
58
|
+
level: 'warning', area: 'face',
|
|
59
|
+
message: `The mouth opens with speech but cannot make ${list(lacking)}: speech will look like a jaw flapping.`,
|
|
60
|
+
fix: 'Add those shapes to the model, or map existing ones with `morphMap`.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!blink)
|
|
65
|
+
issues.push({ level: 'warning', area: 'face', message: 'No blink shapes found: the eyes will never blink.', fix: 'Map them with `morphMap: { eyeBlinkL: "…", eyeBlinkR: "…" }`.' });
|
|
66
|
+
if (!eyeLook)
|
|
67
|
+
issues.push({ level: 'warning', area: 'face', message: 'No eye-look shapes found: the eyes will not follow the camera.', fix: 'Map eyeLookUpL/DownL/LeftL/RightL and the R side with `morphMap`.' });
|
|
68
|
+
if (match.naming === 'arkit') {
|
|
69
|
+
issues.push({
|
|
70
|
+
level: 'info', area: 'face',
|
|
71
|
+
message: 'ARKit face: MetaHuman channels were mapped onto ARKit shapes by meaning. Lipsync works; the finer MetaHuman lip detail (sticky lips, lip roll-in per corner) has no ARKit shape.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const mapped = Object.keys(input.morphMap.map).length;
|
|
76
|
+
if (input.morphMap.source === 'auto' && mapped) {
|
|
77
|
+
issues.push({
|
|
78
|
+
level: 'info', area: 'face',
|
|
79
|
+
message: `${mapped} MetaHuman channel${mapped === 1 ? '' : 's'} mapped onto the model's own shapes by name. Pass the map yourself to pin it or correct an entry.`,
|
|
80
|
+
fix: configSnippet(input.morphMap.map),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (input.morphMap.unknown.length) {
|
|
84
|
+
issues.push({
|
|
85
|
+
level: 'error', area: 'face',
|
|
86
|
+
message: `morphMap names shapes the model does not have: ${list(input.morphMap.unknown)}.`,
|
|
87
|
+
fix: Object.keys(match.map).length ? `Shapes embody found by name:\n${configSnippet(match.map)}` : 'Check the names against the model in a glTF viewer.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// ── skeleton ──────────────────────────────────────────────────────────────
|
|
91
|
+
const bones = [];
|
|
92
|
+
root.traverse((o) => { if (o.isBone)
|
|
93
|
+
bones.push(o); });
|
|
94
|
+
const missing = rig.missing.map((r) => rig.profile.bones[r]?.[0] ?? r);
|
|
95
|
+
const detected = detectRig(root);
|
|
96
|
+
const kind = bones.length === 0 ? 'none'
|
|
97
|
+
: missing.length === 0 ? 'convai-mha-1'
|
|
98
|
+
: root.getObjectByName('CC_Base_Head') ? 'character-creator'
|
|
99
|
+
: detected?.kind ?? 'unknown';
|
|
100
|
+
if (kind === 'none') {
|
|
101
|
+
issues.push({
|
|
102
|
+
level: 'warning', area: 'skeleton',
|
|
103
|
+
message: 'The model has no skeleton: it cannot play animations, turn its head or breathe.',
|
|
104
|
+
fix: 'Rig and skin the model (Mixamo auto-rigging works) and export the armature with it.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (missing.length) {
|
|
108
|
+
const what = missing.length === REQUIRED_BONES.length ? 'Gaze and breathing are off' : 'Parts of gaze and breathing are off';
|
|
109
|
+
issues.push({
|
|
110
|
+
level: 'warning', area: 'skeleton',
|
|
111
|
+
message: `${what}: the ${kind === 'unknown' ? '' : `${kind} `}skeleton has no ${list(missing)} (MetaHuman bone names). The head will not turn toward the camera.` +
|
|
112
|
+
(kind === 'mixamo' || kind === 'unreal' || kind === 'rigify' ? ` Animations from Mixamo, Unreal and Rigify skeletons still play: they are retargeted.` : ''),
|
|
113
|
+
fix: 'Rename the head, neck and spine bones to head, neck_01, neck_02, spine_04 and spine_02, or retarget the model to the MetaHuman skeleton.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// ── animations ────────────────────────────────────────────────────────────
|
|
117
|
+
const registered = new Map(input.registered.map((r) => [r.name, r]));
|
|
118
|
+
const clips = input.clipNotes.map((n) => {
|
|
119
|
+
const r = registered.get(n.name);
|
|
120
|
+
return r ? { ...n, tracks: r.kept, dropped: r.dropped } : n;
|
|
121
|
+
});
|
|
122
|
+
for (const c of clips) {
|
|
123
|
+
if (c.status === 'not-in-file') {
|
|
124
|
+
issues.push({
|
|
125
|
+
level: 'error', area: 'animation',
|
|
126
|
+
message: `Clip "${c.name}" is not in ${c.url}${c.available?.length ? `, which has: ${list(c.available, 8)}` : ', which has no animations'}.`,
|
|
127
|
+
fix: c.available?.length ? `Use one of those names as the clip's key, e.g. { ${JSON.stringify(c.available[0])}: ${JSON.stringify(c.url)} }.` : undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else if (c.status === 'not-loaded') {
|
|
131
|
+
issues.push({ level: 'error', area: 'animation', message: `Clip "${c.name}": ${c.url} did not load${c.detail ? ` (${c.detail})` : ''}.`, fix: 'Check the URL, that the host sends CORS headers for this page, and that the file is GLB, glTF or FBX.' });
|
|
132
|
+
}
|
|
133
|
+
else if (c.status === 'failed') {
|
|
134
|
+
issues.push({ level: 'error', area: 'animation', message: `Clip "${c.name}" could not be retargeted from its ${c.from} skeleton: ${c.detail}` });
|
|
135
|
+
}
|
|
136
|
+
else if ('tracks' in c && c.tracks === 0) {
|
|
137
|
+
issues.push({
|
|
138
|
+
level: 'error', area: 'animation',
|
|
139
|
+
message: `Clip "${c.name}" moves nothing on this model: none of its ${c.dropped} tracks names a bone the model has.`,
|
|
140
|
+
fix: 'Export the clip on the model\'s own skeleton, or on Mixamo, the Unreal mannequin or Rigify, which embody retargets.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else if (c.status === 'foreign') {
|
|
144
|
+
issues.push({
|
|
145
|
+
level: 'warning', area: 'animation',
|
|
146
|
+
message: `Clip "${c.name}" is on a different skeleton (${c.from ?? 'unrecognised'}) and ${c.detail}; it plays only where bone names happen to match.`,
|
|
147
|
+
fix: 'Export the clip on the model\'s own skeleton, or on Mixamo, the Unreal mannequin or Rigify, which embody retargets.',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else if (c.status === 'retargeted') {
|
|
151
|
+
issues.push({ level: 'info', area: 'animation', message: `Clip "${c.name}" was retargeted from a ${c.from} skeleton.` });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (input.embedded) {
|
|
155
|
+
issues.push({ level: 'info', area: 'animation', message: `No clips were declared; the ${input.embedded} animation${input.embedded === 1 ? '' : 's'} inside the model file ${input.embedded === 1 ? 'is' : 'are'} used.` });
|
|
156
|
+
}
|
|
157
|
+
if (input.idleRequested && input.idle !== input.idleRequested) {
|
|
158
|
+
issues.push({
|
|
159
|
+
level: 'error', area: 'animation',
|
|
160
|
+
message: `idleClip "${input.idleRequested}" is not one of the character's clips${input.registered.length ? ` (${list(input.registered.map((r) => r.name), 8)})` : ''}.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else if (!input.idle && kind !== 'none' && input.idleRequested === undefined) {
|
|
164
|
+
issues.push({
|
|
165
|
+
level: 'warning', area: 'animation',
|
|
166
|
+
message: 'No animation plays at rest, so the body holds its bind pose (often arms out).',
|
|
167
|
+
fix: 'Add an idle clip: animations: { Idle: "https://…/idle.fbx" } (Mixamo FBX, GLB and glTF work).',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
ok: !issues.some((i) => i.level === 'error'),
|
|
172
|
+
face: { morphTargets, naming: match.naming, channels: { bound: bound.length, of: CHANNELS.length }, mouth, blink, eyeLook, morphMap: input.morphMap.map },
|
|
173
|
+
skeleton: { kind, bones: bones.length, missing },
|
|
174
|
+
animations: { clips, idle: input.idle },
|
|
175
|
+
issues,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** The report as console text: a summary line, then each issue with its fix. */
|
|
179
|
+
export function formatReport(report, label = 'character') {
|
|
180
|
+
const f = report.face;
|
|
181
|
+
const lines = [
|
|
182
|
+
`[embody] ${label}: ${report.ok ? 'ready' : 'needs attention'} — face ${f.naming} (${f.channels.bound}/${f.channels.of} channels), skeleton ${report.skeleton.kind}, ${report.animations.clips.length || 'no'} clip${report.animations.clips.length === 1 ? '' : 's'}${report.animations.idle ? `, idle "${report.animations.idle}"` : ''}`,
|
|
183
|
+
];
|
|
184
|
+
const order = ['error', 'warning', 'info'];
|
|
185
|
+
for (const level of order) {
|
|
186
|
+
for (const i of report.issues.filter((x) => x.level === level)) {
|
|
187
|
+
lines.push(` ${level.toUpperCase()} (${i.area}) ${i.message}`);
|
|
188
|
+
if (i.fix)
|
|
189
|
+
lines.push(...i.fix.split('\n').map((l, k) => ` ${k === 0 ? 'fix: ' : ' '}${l}`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eyes that look at a point, each on its own, measured from the body itself.
|
|
3
|
+
*
|
|
4
|
+
* convai-mha-1 turns the eyes with blend shapes, not bones, and a blend shape
|
|
5
|
+
* moves a vertex in a straight line rather than round the eyeball, so weight
|
|
6
|
+
* is not angle — and how far a shape turns the eye is a property of each
|
|
7
|
+
* body's sculpt. The fixed `residual × 0.65` mapping this replaces moved the
|
|
8
|
+
* eyes about a third of the way to the camera on the published bodies (the
|
|
9
|
+
* shapes turn them ~50° sideways, 33° up and 44° down at full weight): they
|
|
10
|
+
* never met the lens.
|
|
11
|
+
*
|
|
12
|
+
* So at load, from the eye mesh's own geometry:
|
|
13
|
+
* · each eye's vertices are the ones its own (`…L` / `…R`) look shapes move;
|
|
14
|
+
* · the pupil is the vertex at the centre of the iris texture (uv 0.5, 0.5),
|
|
15
|
+
* the eyeball centre one eyeball radius (12 mm) behind it;
|
|
16
|
+
* · "left" and "up" are the directions the look-left and look-up shapes move
|
|
17
|
+
* the pupil — no assumption about the export's axes or units;
|
|
18
|
+
* · each shape's weight → angle curve is tabulated from the pupil's path.
|
|
19
|
+
*
|
|
20
|
+
* Each frame both eyes aim at the target separately (so they converge on a
|
|
21
|
+
* near one), the weights come from inverting the curves, and the pupil's real
|
|
22
|
+
* direction — with every other active shape applied (relax, lids, blink) — is
|
|
23
|
+
* fed back as a correction the next frame.
|
|
24
|
+
*/
|
|
25
|
+
import * as THREE from 'three';
|
|
26
|
+
import type { ResolvedRig } from '../rig/resolve.js';
|
|
27
|
+
type Curve = number[];
|
|
28
|
+
export interface EyeAimLimits {
|
|
29
|
+
left: number;
|
|
30
|
+
right: number;
|
|
31
|
+
up: number;
|
|
32
|
+
down: number;
|
|
33
|
+
}
|
|
34
|
+
/** The weight at which a curve reaches `deg` (0 at or below 0, 1 past its reach). */
|
|
35
|
+
export declare function weightFor(curve: Curve, deg: number): number;
|
|
36
|
+
export declare class EyeAim {
|
|
37
|
+
private readonly rig;
|
|
38
|
+
private readonly eyes;
|
|
39
|
+
private readonly mesh;
|
|
40
|
+
private readonly head;
|
|
41
|
+
private readonly toHead;
|
|
42
|
+
private readonly m;
|
|
43
|
+
private readonly v;
|
|
44
|
+
private readonly w;
|
|
45
|
+
private readonly t;
|
|
46
|
+
readonly limits: EyeAimLimits;
|
|
47
|
+
/**
|
|
48
|
+
* Measure the body. Returns null when it cannot be measured — no skinned eye
|
|
49
|
+
* mesh carrying both eyes' look shapes, or one not bound to the head bone —
|
|
50
|
+
* and the caller keeps its fallback.
|
|
51
|
+
*/
|
|
52
|
+
static calibrate(rig: ResolvedRig): EyeAim | null;
|
|
53
|
+
private constructor();
|
|
54
|
+
/**
|
|
55
|
+
* Where `target` (world) lies from between the eyes, in the eyes' neutral
|
|
56
|
+
* frame: yaw (+ toward the character's left) and pitch (+ up), degrees.
|
|
57
|
+
*/
|
|
58
|
+
angleTo(target: THREE.Vector3): {
|
|
59
|
+
yaw: number;
|
|
60
|
+
pitch: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Turn the eyes toward `target` (world), offset by `offset` degrees.
|
|
64
|
+
* `rate` is the exponential approach rate, 1/s. Writes only the eight
|
|
65
|
+
* look shapes.
|
|
66
|
+
*/
|
|
67
|
+
update(dt: number, target: THREE.Vector3 | null, rate: number, offset?: {
|
|
68
|
+
yaw: number;
|
|
69
|
+
pitch: number;
|
|
70
|
+
}): void;
|
|
71
|
+
/** Current aim of each eye, degrees, for tests and debugging. */
|
|
72
|
+
get aim(): {
|
|
73
|
+
yaw: number;
|
|
74
|
+
pitch: number;
|
|
75
|
+
}[];
|
|
76
|
+
/**
|
|
77
|
+
* The closed-loop correction each eye currently carries, degrees. Near zero
|
|
78
|
+
* when the measured curves are right and no other shape moves the pupil.
|
|
79
|
+
*/
|
|
80
|
+
get correction(): {
|
|
81
|
+
yaw: number;
|
|
82
|
+
pitch: number;
|
|
83
|
+
}[];
|
|
84
|
+
/** The pupil, every active shape applied, measured as the eye sees it: degrees. */
|
|
85
|
+
measured(): {
|
|
86
|
+
yaw: number;
|
|
87
|
+
pitch: number;
|
|
88
|
+
}[];
|
|
89
|
+
private local;
|
|
90
|
+
private angles;
|
|
91
|
+
private pupilNow;
|
|
92
|
+
private write;
|
|
93
|
+
}
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eyes that look at a point, each on its own, measured from the body itself.
|
|
3
|
+
*
|
|
4
|
+
* convai-mha-1 turns the eyes with blend shapes, not bones, and a blend shape
|
|
5
|
+
* moves a vertex in a straight line rather than round the eyeball, so weight
|
|
6
|
+
* is not angle — and how far a shape turns the eye is a property of each
|
|
7
|
+
* body's sculpt. The fixed `residual × 0.65` mapping this replaces moved the
|
|
8
|
+
* eyes about a third of the way to the camera on the published bodies (the
|
|
9
|
+
* shapes turn them ~50° sideways, 33° up and 44° down at full weight): they
|
|
10
|
+
* never met the lens.
|
|
11
|
+
*
|
|
12
|
+
* So at load, from the eye mesh's own geometry:
|
|
13
|
+
* · each eye's vertices are the ones its own (`…L` / `…R`) look shapes move;
|
|
14
|
+
* · the pupil is the vertex at the centre of the iris texture (uv 0.5, 0.5),
|
|
15
|
+
* the eyeball centre one eyeball radius (12 mm) behind it;
|
|
16
|
+
* · "left" and "up" are the directions the look-left and look-up shapes move
|
|
17
|
+
* the pupil — no assumption about the export's axes or units;
|
|
18
|
+
* · each shape's weight → angle curve is tabulated from the pupil's path.
|
|
19
|
+
*
|
|
20
|
+
* Each frame both eyes aim at the target separately (so they converge on a
|
|
21
|
+
* near one), the weights come from inverting the curves, and the pupil's real
|
|
22
|
+
* direction — with every other active shape applied (relax, lids, blink) — is
|
|
23
|
+
* fed back as a correction the next frame.
|
|
24
|
+
*/
|
|
25
|
+
import * as THREE from 'three';
|
|
26
|
+
/** Weight steps per tabulated curve. */
|
|
27
|
+
const STEPS = 50;
|
|
28
|
+
/** Human eyeball radius, metres. */
|
|
29
|
+
const EYEBALL_RADIUS = 0.012;
|
|
30
|
+
/** The weight at which a curve reaches `deg` (0 at or below 0, 1 past its reach). */
|
|
31
|
+
export function weightFor(curve, deg) {
|
|
32
|
+
if (deg <= 0)
|
|
33
|
+
return 0;
|
|
34
|
+
for (let j = 1; j < curve.length; j++)
|
|
35
|
+
if (curve[j] >= deg) {
|
|
36
|
+
const a = curve[j - 1];
|
|
37
|
+
return (j - 1 + (deg - a) / Math.max(1e-6, curve[j] - a)) / (curve.length - 1);
|
|
38
|
+
}
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
const SIDES = {
|
|
42
|
+
// The profile names in/out in the rig basis' frame; see CONVAI_MHA_1. For
|
|
43
|
+
// the left eye "in" is toward the character's left, for the right eye "out".
|
|
44
|
+
L: { left: 'eyeLookInL', right: 'eyeLookOutL', up: 'eyeLookUpL', down: 'eyeLookDownL' },
|
|
45
|
+
R: { left: 'eyeLookOutR', right: 'eyeLookInR', up: 'eyeLookUpR', down: 'eyeLookDownR' },
|
|
46
|
+
};
|
|
47
|
+
function morphIndex(rig, role, mesh) {
|
|
48
|
+
return rig.morphs.get(role)?.find((b) => b.mesh === mesh)?.index;
|
|
49
|
+
}
|
|
50
|
+
export class EyeAim {
|
|
51
|
+
/**
|
|
52
|
+
* Measure the body. Returns null when it cannot be measured — no skinned eye
|
|
53
|
+
* mesh carrying both eyes' look shapes, or one not bound to the head bone —
|
|
54
|
+
* and the caller keeps its fallback.
|
|
55
|
+
*/
|
|
56
|
+
static calibrate(rig) {
|
|
57
|
+
const head = rig.bones.head;
|
|
58
|
+
if (!head)
|
|
59
|
+
return null;
|
|
60
|
+
const candidates = rig.morphs.get('eyeLookUpL')?.map((b) => b.mesh) ?? [];
|
|
61
|
+
const mesh = candidates.find((m) => {
|
|
62
|
+
const mat = (Array.isArray(m.material) ? m.material[0] : m.material);
|
|
63
|
+
return m.isSkinnedMesh && /^eyes?(?!occlusion)/i.test(mat?.name ?? '');
|
|
64
|
+
});
|
|
65
|
+
if (!mesh?.geometry.morphAttributes.position)
|
|
66
|
+
return null;
|
|
67
|
+
const bone = mesh.skeleton.bones.indexOf(head);
|
|
68
|
+
if (bone < 0)
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return new EyeAim(rig, mesh, head, bone);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
constructor(rig, mesh, head, bone) {
|
|
78
|
+
this.rig = rig;
|
|
79
|
+
this.eyes = [];
|
|
80
|
+
this.toHead = new THREE.Matrix4();
|
|
81
|
+
this.m = new THREE.Matrix4();
|
|
82
|
+
this.v = new THREE.Vector3();
|
|
83
|
+
this.w = new THREE.Vector3();
|
|
84
|
+
this.t = new THREE.Vector3();
|
|
85
|
+
this.mesh = mesh;
|
|
86
|
+
this.head = head;
|
|
87
|
+
// Mesh space → head-bone space, from the bind pose alone.
|
|
88
|
+
this.toHead.copy(mesh.skeleton.boneInverses[bone]).multiply(mesh.bindMatrix);
|
|
89
|
+
const g = mesh.geometry;
|
|
90
|
+
const pos = g.attributes.position;
|
|
91
|
+
const uv = g.attributes.uv;
|
|
92
|
+
const shapes = g.morphAttributes.position;
|
|
93
|
+
// Mesh units per metre, from how the body is placed in the world now
|
|
94
|
+
// (scaled to metres by the unit normaliser, still in its bind pose).
|
|
95
|
+
mesh.updateWorldMatrix(true, false);
|
|
96
|
+
const unitsPerMetre = 1 / mesh.matrixWorld.getMaxScaleOnAxis();
|
|
97
|
+
const radius = EYEBALL_RADIUS * unitsPerMetre;
|
|
98
|
+
const delta = (shape, i) => new THREE.Vector3().fromBufferAttribute(shapes[shape], i);
|
|
99
|
+
const curves = {};
|
|
100
|
+
for (const side of ['L', 'R']) {
|
|
101
|
+
const roles = SIDES[side];
|
|
102
|
+
const idx = {
|
|
103
|
+
left: morphIndex(rig, roles.left, mesh),
|
|
104
|
+
right: morphIndex(rig, roles.right, mesh),
|
|
105
|
+
up: morphIndex(rig, roles.up, mesh),
|
|
106
|
+
down: morphIndex(rig, roles.down, mesh),
|
|
107
|
+
};
|
|
108
|
+
if (idx.left === undefined || idx.up === undefined || idx.right === undefined || idx.down === undefined)
|
|
109
|
+
throw new Error('look shapes missing');
|
|
110
|
+
// This eye: the vertices its own look-up shape moves.
|
|
111
|
+
const own = [];
|
|
112
|
+
for (let i = 0; i < pos.count; i++)
|
|
113
|
+
if (delta(idx.up, i).lengthSq() > 1e-12)
|
|
114
|
+
own.push(i);
|
|
115
|
+
if (own.length < 3)
|
|
116
|
+
throw new Error('eye not found');
|
|
117
|
+
let pupil = own[0];
|
|
118
|
+
if (uv) {
|
|
119
|
+
let best = Infinity;
|
|
120
|
+
for (const i of own) {
|
|
121
|
+
const d = Math.hypot(uv.getX(i) - 0.5, uv.getY(i) - 0.5);
|
|
122
|
+
if (d < best) {
|
|
123
|
+
best = d;
|
|
124
|
+
pupil = i;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
let most = -1;
|
|
130
|
+
for (const i of own) {
|
|
131
|
+
const d = delta(idx.up, i).lengthSq();
|
|
132
|
+
if (d > most) {
|
|
133
|
+
most = d;
|
|
134
|
+
pupil = i;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const apex = new THREE.Vector3().fromBufferAttribute(pos, pupil);
|
|
139
|
+
const centroid = new THREE.Vector3();
|
|
140
|
+
for (const i of own)
|
|
141
|
+
centroid.add(this.v.fromBufferAttribute(pos, i));
|
|
142
|
+
centroid.divideScalar(own.length);
|
|
143
|
+
// The eye mesh is a front cap: its centroid sits behind the pupil.
|
|
144
|
+
const fwd = apex.clone().sub(centroid).normalize();
|
|
145
|
+
const centre = apex.clone().addScaledVector(fwd, -radius);
|
|
146
|
+
const orth = (d, ...axes) => {
|
|
147
|
+
for (const a of axes)
|
|
148
|
+
d.addScaledVector(a, -d.dot(a));
|
|
149
|
+
return d.normalize();
|
|
150
|
+
};
|
|
151
|
+
const left = orth(delta(idx.left, pupil), fwd);
|
|
152
|
+
const up = orth(delta(idx.up, pupil), fwd, left);
|
|
153
|
+
if (!Number.isFinite(left.x) || !Number.isFinite(up.x))
|
|
154
|
+
throw new Error('degenerate shapes');
|
|
155
|
+
const angle = (shape, axis, sign) => {
|
|
156
|
+
const d = delta(shape, pupil);
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let j = 0; j <= STEPS; j++) {
|
|
159
|
+
const p = apex.clone().addScaledVector(d, j / STEPS).sub(centre);
|
|
160
|
+
const a = p.dot(axis);
|
|
161
|
+
out.push(sign * THREE.MathUtils.radToDeg(Math.atan2(a, p.dot(fwd))));
|
|
162
|
+
}
|
|
163
|
+
// Monotone, as the inversion assumes.
|
|
164
|
+
for (let j = 1; j < out.length; j++)
|
|
165
|
+
out[j] = Math.max(out[j], out[j - 1]);
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
curves[side] = {
|
|
169
|
+
roles,
|
|
170
|
+
curves: {
|
|
171
|
+
left: angle(idx.left, left, 1),
|
|
172
|
+
right: angle(idx.right, left, -1),
|
|
173
|
+
up: angle(idx.up, up, 1),
|
|
174
|
+
down: angle(idx.down, up, -1),
|
|
175
|
+
},
|
|
176
|
+
vertex: pupil,
|
|
177
|
+
pupil: apex.clone().applyMatrix4(this.toHead),
|
|
178
|
+
centre: centre.clone().applyMatrix4(this.toHead),
|
|
179
|
+
fwd: fwd.clone().transformDirection(this.toHead),
|
|
180
|
+
left: left.clone().transformDirection(this.toHead),
|
|
181
|
+
up: up.clone().transformDirection(this.toHead),
|
|
182
|
+
yaw: 0, pitch: 0, fixYaw: 0, fixPitch: 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
this.eyes.push(curves.L, curves.R);
|
|
186
|
+
const reach = (k) => Math.min(...this.eyes.map((e) => e.curves[k][STEPS]));
|
|
187
|
+
this.limits = { left: reach('left'), right: reach('right'), up: reach('up'), down: reach('down') };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Where `target` (world) lies from between the eyes, in the eyes' neutral
|
|
191
|
+
* frame: yaw (+ toward the character's left) and pitch (+ up), degrees.
|
|
192
|
+
*/
|
|
193
|
+
angleTo(target) {
|
|
194
|
+
const local = this.local(target);
|
|
195
|
+
const mid = this.w.copy(this.eyes[0].centre).add(this.eyes[1].centre).multiplyScalar(0.5);
|
|
196
|
+
return this.angles(this.eyes[0], local.sub(mid));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Turn the eyes toward `target` (world), offset by `offset` degrees.
|
|
200
|
+
* `rate` is the exponential approach rate, 1/s. Writes only the eight
|
|
201
|
+
* look shapes.
|
|
202
|
+
*/
|
|
203
|
+
update(dt, target, rate, offset = { yaw: 0, pitch: 0 }) {
|
|
204
|
+
const k = 1 - Math.exp(-rate * Math.max(0, dt));
|
|
205
|
+
const local = target ? this.local(target) : null;
|
|
206
|
+
for (const e of this.eyes) {
|
|
207
|
+
let yaw = 0, pitch = 0;
|
|
208
|
+
if (local) {
|
|
209
|
+
const want = this.angles(e, this.w.copy(local).sub(e.centre));
|
|
210
|
+
yaw = THREE.MathUtils.clamp(want.yaw + offset.yaw, -this.limits.right, this.limits.left);
|
|
211
|
+
pitch = THREE.MathUtils.clamp(want.pitch + offset.pitch, -this.limits.down, this.limits.up);
|
|
212
|
+
}
|
|
213
|
+
e.yaw += (yaw - e.yaw) * k;
|
|
214
|
+
e.pitch += (pitch - e.pitch) * k;
|
|
215
|
+
const y = e.yaw + e.fixYaw;
|
|
216
|
+
const p = e.pitch + e.fixPitch;
|
|
217
|
+
this.write(e.roles.left, weightFor(e.curves.left, y));
|
|
218
|
+
this.write(e.roles.right, weightFor(e.curves.right, -y));
|
|
219
|
+
this.write(e.roles.up, weightFor(e.curves.up, p));
|
|
220
|
+
this.write(e.roles.down, weightFor(e.curves.down, -p));
|
|
221
|
+
// Closed loop: other shapes move the pupil too, and two look shapes at
|
|
222
|
+
// once do not add exactly. Correct by half the error each frame.
|
|
223
|
+
const got = this.angles(e, this.pupilNow(e).sub(e.centre));
|
|
224
|
+
e.fixYaw = THREE.MathUtils.clamp(e.fixYaw + (e.yaw - got.yaw) * 0.5, -15, 15);
|
|
225
|
+
e.fixPitch = THREE.MathUtils.clamp(e.fixPitch + (e.pitch - got.pitch) * 0.5, -15, 15);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Current aim of each eye, degrees, for tests and debugging. */
|
|
229
|
+
get aim() {
|
|
230
|
+
return this.eyes.map((e) => ({ yaw: e.yaw, pitch: e.pitch }));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The closed-loop correction each eye currently carries, degrees. Near zero
|
|
234
|
+
* when the measured curves are right and no other shape moves the pupil.
|
|
235
|
+
*/
|
|
236
|
+
get correction() {
|
|
237
|
+
return this.eyes.map((e) => ({ yaw: e.fixYaw, pitch: e.fixPitch }));
|
|
238
|
+
}
|
|
239
|
+
/** The pupil, every active shape applied, measured as the eye sees it: degrees. */
|
|
240
|
+
measured() {
|
|
241
|
+
return this.eyes.map((e) => this.angles(e, this.pupilNow(e).sub(e.centre)));
|
|
242
|
+
}
|
|
243
|
+
local(target) {
|
|
244
|
+
this.head.updateWorldMatrix(true, false);
|
|
245
|
+
return this.t.copy(target).applyMatrix4(this.m.copy(this.head.matrixWorld).invert());
|
|
246
|
+
}
|
|
247
|
+
angles(e, v) {
|
|
248
|
+
const x = v.dot(e.left);
|
|
249
|
+
const y = v.dot(e.up);
|
|
250
|
+
const z = v.dot(e.fwd);
|
|
251
|
+
return { yaw: THREE.MathUtils.radToDeg(Math.atan2(x, z)), pitch: THREE.MathUtils.radToDeg(Math.atan2(y, Math.hypot(x, z))) };
|
|
252
|
+
}
|
|
253
|
+
pupilNow(e) {
|
|
254
|
+
const g = this.mesh.geometry;
|
|
255
|
+
const v = this.v.fromBufferAttribute(g.attributes.position, e.vertex);
|
|
256
|
+
const inf = this.mesh.morphTargetInfluences ?? [];
|
|
257
|
+
const shapes = g.morphAttributes.position;
|
|
258
|
+
for (let i = 0; i < inf.length; i++) {
|
|
259
|
+
const wgt = inf[i];
|
|
260
|
+
if (wgt)
|
|
261
|
+
v.add(this.w.fromBufferAttribute(shapes[i], e.vertex).multiplyScalar(wgt));
|
|
262
|
+
}
|
|
263
|
+
return v.applyMatrix4(this.toHead);
|
|
264
|
+
}
|
|
265
|
+
write(role, value) {
|
|
266
|
+
const bindings = this.rig.morphs.get(role);
|
|
267
|
+
if (!bindings)
|
|
268
|
+
return;
|
|
269
|
+
const v = THREE.MathUtils.clamp(value, 0, 1);
|
|
270
|
+
for (const b of bindings) {
|
|
271
|
+
const infl = b.mesh.morphTargetInfluences;
|
|
272
|
+
if (infl)
|
|
273
|
+
infl[b.index] = v;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
package/dist/motion/eyeGaze.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { FrameContext, System } from '../character/pipeline.js';
|
|
2
2
|
import type { GazeSource } from './types.js';
|
|
3
|
+
import { EyeAim } from './eyeAim.js';
|
|
3
4
|
export interface EyeGazeConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Micro-saccades while holding eye contact: a tiny jump every 0.5–1.6 s, so
|
|
7
|
+
* the stare is alive but never leaves the viewer. Default `true`; off under
|
|
8
|
+
* reduced motion regardless.
|
|
9
|
+
*/
|
|
10
|
+
saccades?: boolean;
|
|
4
11
|
/**
|
|
5
12
|
* Per-frame ease rate toward the gaze target. Verbatim from
|
|
6
13
|
* `useEyeTracking.ts`'s `DEFAULT_CONFIG.lerpSpeed` (useEyeTracking.ts:108)
|
|
@@ -60,6 +67,16 @@ export declare class EyeGazeSystem implements System {
|
|
|
60
67
|
readonly stage: 6;
|
|
61
68
|
private h;
|
|
62
69
|
private v;
|
|
70
|
+
/** undefined until the first frame measures the body; null if it cannot be. */
|
|
71
|
+
private aim;
|
|
72
|
+
private readonly target;
|
|
73
|
+
private offset;
|
|
74
|
+
private nextSaccade;
|
|
75
|
+
private saccadeLeft;
|
|
63
76
|
constructor(gaze: GazeSource, cfg?: EyeGazeConfig);
|
|
77
|
+
/** The calibrated aim, once measured (null when the body cannot be). */
|
|
78
|
+
get calibrated(): EyeAim | null;
|
|
64
79
|
update(dt: number, ctx: FrameContext): void;
|
|
80
|
+
private aimAt;
|
|
81
|
+
private legacy;
|
|
65
82
|
}
|
package/dist/motion/eyeGaze.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { writeMorph } from '../rig/resolve.js';
|
|
3
|
+
import { EyeAim } from './eyeAim.js';
|
|
3
4
|
/**
|
|
4
5
|
* Eye gaze on convai-mha-1 is MORPH-ONLY — the mandated rig has no eye bones
|
|
5
6
|
* (character spec 2.2). This system ports only the morph-output half of
|
|
@@ -31,8 +32,49 @@ export class EyeGazeSystem {
|
|
|
31
32
|
this.stage = 6;
|
|
32
33
|
this.h = 0;
|
|
33
34
|
this.v = 0;
|
|
35
|
+
this.target = new THREE.Vector3();
|
|
36
|
+
this.offset = { yaw: 0, pitch: 0 };
|
|
37
|
+
this.nextSaccade = 0.3;
|
|
38
|
+
this.saccadeLeft = 0;
|
|
39
|
+
}
|
|
40
|
+
/** The calibrated aim, once measured (null when the body cannot be). */
|
|
41
|
+
get calibrated() {
|
|
42
|
+
return this.aim ?? null;
|
|
34
43
|
}
|
|
35
44
|
update(dt, ctx) {
|
|
45
|
+
// Measured on the first frame rather than at construction: by then the
|
|
46
|
+
// unit normaliser has put the body in metres, which sets the eyeball's
|
|
47
|
+
// size in mesh units.
|
|
48
|
+
if (this.aim === undefined)
|
|
49
|
+
this.aim = EyeAim.calibrate(ctx.rig);
|
|
50
|
+
if (this.aim)
|
|
51
|
+
return this.aimAt(dt, ctx, this.aim);
|
|
52
|
+
this.legacy(dt, ctx);
|
|
53
|
+
}
|
|
54
|
+
aimAt(dt, ctx, aim) {
|
|
55
|
+
const attending = !!ctx.camera && this.gaze.attending !== false;
|
|
56
|
+
if (!attending || ctx.reducedMotion) {
|
|
57
|
+
this.offset.yaw = this.offset.pitch = 0;
|
|
58
|
+
this.saccadeLeft = 0;
|
|
59
|
+
}
|
|
60
|
+
else if (this.cfg.saccades !== false) {
|
|
61
|
+
this.nextSaccade -= dt;
|
|
62
|
+
if (this.nextSaccade <= 0) {
|
|
63
|
+
// A micro-saccade: about half a degree, held 0.5–1.6 s.
|
|
64
|
+
this.offset = { yaw: (Math.random() * 2 - 1) * 0.6, pitch: (Math.random() * 2 - 1) * 0.5 };
|
|
65
|
+
this.nextSaccade = 0.5 + Math.random() * 1.1;
|
|
66
|
+
this.saccadeLeft = 0.06;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// A saccade is a ~40 ms jump; between them the eyes settle, and they ease
|
|
70
|
+
// home slowly when the character stops attending.
|
|
71
|
+
const rate = !attending ? 6 : this.saccadeLeft > 0 ? 28 : ctx.reducedMotion ? 8 : 14;
|
|
72
|
+
this.saccadeLeft = Math.max(0, this.saccadeLeft - dt);
|
|
73
|
+
const focus = this.gaze.focus;
|
|
74
|
+
const target = !attending ? null : focus ? this.target.set(focus.x, focus.y, focus.z) : ctx.camera.getWorldPosition(this.target);
|
|
75
|
+
aim.update(dt, target, rate, this.offset);
|
|
76
|
+
}
|
|
77
|
+
legacy(dt, ctx) {
|
|
36
78
|
const lerpSpeed = this.cfg.lerpSpeed ?? 0.2;
|
|
37
79
|
const verticalOffset = this.cfg.verticalOffset ?? -0.15;
|
|
38
80
|
const gain = this.cfg.morphGain ?? 0.65;
|