-
-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy patharkit-blendshapes.js
More file actions
295 lines (274 loc) · 11.6 KB
/
Copy patharkit-blendshapes.js
File metadata and controls
295 lines (274 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
/**
* ARKit 52-blendshape vocabulary + cross-format mapping.
*
* ARKit defines the de-facto industry-standard set of 52 facial blendshape
* names (mouth, eye, brow, cheek, nose, jaw, tongue). three.ws standardizes
* on these names because:
*
* 1. Most modern avatar pipelines (Avaturn, Apple Live Link, MetaHuman,
* Wolf3D, Hyprface, Polywink) export with these names already.
* 2. They're orthogonal — overlaying a smile on top of a jaw-open works
* cleanly; in contrast, VRM's monolithic "A"/"I"/"U"/"E"/"O" shapes
* conflict when blended.
* 3. They map cleanly to Preston-Blair / Disney 12-viseme phoneme sets,
* which is what real-time lipsync drivers want.
*
* This module is *pure data + helpers*: no DOM, no three.js, no async I/O.
* Anything that touches a loaded GLB lives in avatar-morph-target.js and
* imports from here.
*/
// ── Canonical 52 ARKit blendshape names ─────────────────────────────────
export const ARKIT_NAMES = [
// brow
'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight',
// cheek
'cheekPuff', 'cheekSquintLeft', 'cheekSquintRight',
// eye
'eyeBlinkLeft', 'eyeBlinkRight',
'eyeLookDownLeft', 'eyeLookDownRight',
'eyeLookInLeft', 'eyeLookInRight',
'eyeLookOutLeft', 'eyeLookOutRight',
'eyeLookUpLeft', 'eyeLookUpRight',
'eyeSquintLeft', 'eyeSquintRight',
'eyeWideLeft', 'eyeWideRight',
// jaw
'jawForward', 'jawLeft', 'jawOpen', 'jawRight',
// mouth
'mouthClose', 'mouthDimpleLeft', 'mouthDimpleRight',
'mouthFrownLeft', 'mouthFrownRight',
'mouthFunnel',
'mouthLeft', 'mouthLowerDownLeft', 'mouthLowerDownRight',
'mouthPressLeft', 'mouthPressRight',
'mouthPucker',
'mouthRight',
'mouthRollLower', 'mouthRollUpper',
'mouthShrugLower', 'mouthShrugUpper',
'mouthSmileLeft', 'mouthSmileRight',
'mouthStretchLeft', 'mouthStretchRight',
'mouthUpperUpLeft', 'mouthUpperUpRight',
// nose
'noseSneerLeft', 'noseSneerRight',
// tongue
'tongueOut',
];
export const ARKIT_GROUPS = {
brow: ['browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight'],
cheek: ['cheekPuff', 'cheekSquintLeft', 'cheekSquintRight'],
eye: [
'eyeBlinkLeft', 'eyeBlinkRight',
'eyeLookDownLeft', 'eyeLookDownRight',
'eyeLookInLeft', 'eyeLookInRight',
'eyeLookOutLeft', 'eyeLookOutRight',
'eyeLookUpLeft', 'eyeLookUpRight',
'eyeSquintLeft', 'eyeSquintRight',
'eyeWideLeft', 'eyeWideRight',
],
jaw: ['jawForward', 'jawLeft', 'jawOpen', 'jawRight'],
mouth: [
'mouthClose', 'mouthDimpleLeft', 'mouthDimpleRight',
'mouthFrownLeft', 'mouthFrownRight',
'mouthFunnel',
'mouthLeft', 'mouthLowerDownLeft', 'mouthLowerDownRight',
'mouthPressLeft', 'mouthPressRight',
'mouthPucker', 'mouthRight',
'mouthRollLower', 'mouthRollUpper',
'mouthShrugLower', 'mouthShrugUpper',
'mouthSmileLeft', 'mouthSmileRight',
'mouthStretchLeft', 'mouthStretchRight',
'mouthUpperUpLeft', 'mouthUpperUpRight',
],
nose: ['noseSneerLeft', 'noseSneerRight'],
tongue: ['tongueOut'],
};
// ── VRM expression → ARKit weighted shape map ──────────────────────────
//
// VRM (Pixiv / Niconi standard) ships monolithic expression names. Map each
// to a *weighted combination* of orthogonal ARKit shapes so we can render
// VRM-tagged emotions on ARKit rigs without losing fidelity.
export const VRM_TO_ARKIT = {
// Vowel mouth shapes — VRM canonical
Aa: { jawOpen: 1.0 },
A: { jawOpen: 1.0 },
Ah: { jawOpen: 1.0 },
Ih: { mouthStretchLeft: 0.7, mouthStretchRight: 0.7, mouthSmileLeft: 0.25, mouthSmileRight: 0.25 },
I: { mouthStretchLeft: 0.7, mouthStretchRight: 0.7, mouthSmileLeft: 0.25, mouthSmileRight: 0.25 },
Ou: { mouthFunnel: 0.7, mouthPucker: 0.4, jawOpen: 0.2 },
U: { mouthFunnel: 0.7, mouthPucker: 0.4, jawOpen: 0.2 },
Ee: { mouthSmileLeft: 0.55, mouthSmileRight: 0.55, mouthStretchLeft: 0.35, mouthStretchRight: 0.35, jawOpen: 0.2 },
E: { mouthSmileLeft: 0.55, mouthSmileRight: 0.55, mouthStretchLeft: 0.35, mouthStretchRight: 0.35, jawOpen: 0.2 },
Oh: { jawOpen: 0.55, mouthFunnel: 0.55 },
O: { jawOpen: 0.55, mouthFunnel: 0.55 },
// VRM emotions
Joy: { mouthSmileLeft: 0.9, mouthSmileRight: 0.9, cheekSquintLeft: 0.4, cheekSquintRight: 0.4, eyeSquintLeft: 0.3, eyeSquintRight: 0.3 },
Happy: { mouthSmileLeft: 0.9, mouthSmileRight: 0.9, cheekSquintLeft: 0.4, cheekSquintRight: 0.4, eyeSquintLeft: 0.3, eyeSquintRight: 0.3 },
Angry: { browDownLeft: 0.9, browDownRight: 0.9, noseSneerLeft: 0.4, noseSneerRight: 0.4, mouthFrownLeft: 0.5, mouthFrownRight: 0.5 },
Sad: { browInnerUp: 0.7, mouthFrownLeft: 0.6, mouthFrownRight: 0.6, eyeLookDownLeft: 0.3, eyeLookDownRight: 0.3 },
Surprised: { eyeWideLeft: 0.9, eyeWideRight: 0.9, browInnerUp: 0.7, browOuterUpLeft: 0.6, browOuterUpRight: 0.6, jawOpen: 0.5 },
Fun: { mouthSmileLeft: 0.7, mouthSmileRight: 0.7, cheekSquintLeft: 0.3, cheekSquintRight: 0.3 },
// VRM blinks (commonly named just 'Blink')
Blink: { eyeBlinkLeft: 1.0, eyeBlinkRight: 1.0 },
BlinkL: { eyeBlinkLeft: 1.0 },
BlinkR: { eyeBlinkRight: 1.0 },
};
// ── Oculus / Meta visemes → ARKit ───────────────────────────────────────
//
// Oculus ship a 15-viseme set used by their Lipsync SDK. Many avatars produced
// for Meta apps carry these morph names verbatim.
export const OCULUS_TO_ARKIT = {
viseme_sil: {},
viseme_PP: { mouthClose: 0.9, mouthPressLeft: 0.4, mouthPressRight: 0.4 },
viseme_FF: { mouthLowerDownLeft: 0.4, mouthLowerDownRight: 0.4, mouthUpperUpLeft: 0.2, mouthUpperUpRight: 0.2 },
viseme_TH: { jawOpen: 0.25, tongueOut: 0.5 },
viseme_DD: { jawOpen: 0.3, tongueOut: 0.15 },
viseme_kk: { jawOpen: 0.35 },
viseme_CH: { mouthFunnel: 0.45, mouthPucker: 0.4, jawOpen: 0.25 },
viseme_SS: { mouthStretchLeft: 0.3, mouthStretchRight: 0.3 },
viseme_nn: { jawOpen: 0.2, tongueOut: 0.15 },
viseme_RR: { mouthFunnel: 0.4, jawOpen: 0.25 },
viseme_aa: { jawOpen: 0.95 },
viseme_E: { mouthSmileLeft: 0.55, mouthSmileRight: 0.55, jawOpen: 0.25 },
viseme_I: { mouthStretchLeft: 0.65, mouthStretchRight: 0.65 },
viseme_O: { jawOpen: 0.5, mouthFunnel: 0.55 },
viseme_U: { mouthFunnel: 0.7, mouthPucker: 0.45 },
};
// ── Preston-Blair phoneme codes → ARKit ─────────────────────────────────
//
// The classic 9-viseme cartoon-animation set, useful as a fallback when only
// rough phoneme estimates are available (e.g. from amplitude-band heuristics
// in lipsync-driver.js).
export const PHONEME_TO_ARKIT = {
// closed lips (M, B, P)
MBP: { mouthClose: 0.95, mouthPressLeft: 0.35, mouthPressRight: 0.35 },
// open vowel (A, I as in "cat")
AI: { jawOpen: 0.75, mouthSmileLeft: 0.2, mouthSmileRight: 0.2 },
// open-front (E as in "ten")
E: { jawOpen: 0.45, mouthSmileLeft: 0.45, mouthSmileRight: 0.45 },
// open-back (O as in "boat")
O: { jawOpen: 0.55, mouthFunnel: 0.55 },
// rounded (U as in "boot", W)
U: { mouthFunnel: 0.7, mouthPucker: 0.45 },
// L (tongue up)
L: { jawOpen: 0.3, tongueOut: 0.25 },
// F / V (lip-teeth)
FV: { mouthLowerDownLeft: 0.45, mouthLowerDownRight: 0.45, mouthUpperUpLeft: 0.25, mouthUpperUpRight: 0.25 },
// W / Q (puckered)
WQ: { mouthPucker: 0.7, mouthFunnel: 0.45 },
// silence / rest
REST: {},
};
// ── Name resolution ─────────────────────────────────────────────────────
//
// Different riggers spell ARKit names with different casing and separators
// (jawOpen, JawOpen, jaw_open, Jaw_Open, jaw-open, ARKit_JawOpen, …). This
// helper normalizes any incoming string to its canonical lowercase token so
// lookups across pipelines work without per-rigger branches.
function normalizeToken(name) {
return String(name || '')
.replace(/^arkit[_-]?/i, '')
.replace(/[_\-\s]+/g, '')
.toLowerCase();
}
const _canonicalIndex = new Map(ARKIT_NAMES.map((n) => [normalizeToken(n), n]));
/**
* Resolve any spelling of a morph name to its canonical ARKit name, or null
* if it doesn't match a known ARKit shape. Case- and separator-insensitive.
*/
export function canonicalARKitName(name) {
if (!name) return null;
return _canonicalIndex.get(normalizeToken(name)) || null;
}
/**
* Given a morph dictionary from a loaded mesh (mapping morph name → index),
* return a Map from canonical ARKit name → morph index. Drops any morphs
* that don't correspond to a known ARKit shape.
*/
export function indexARKitMorphs(morphDict) {
const result = new Map();
if (!morphDict) return result;
for (const [name, idx] of Object.entries(morphDict)) {
const canonical = canonicalARKitName(name);
if (canonical && !result.has(canonical)) {
result.set(canonical, idx);
}
}
return result;
}
/**
* Coverage report for an indexed mesh — useful for showing operators which
* groups a particular GLB supports (and to gate UI features that depend on
* specific shape availability, e.g. eye-look requires the eye group).
*/
export function coverageOf(arkitIndex) {
const report = {};
let total = 0;
let found = 0;
for (const [group, names] of Object.entries(ARKIT_GROUPS)) {
const present = names.filter((n) => arkitIndex.has(n));
report[group] = {
present: present.length,
total: names.length,
missing: names.filter((n) => !arkitIndex.has(n)),
};
total += names.length;
found += present.length;
}
report.overall = { present: found, total, ratio: total ? found / total : 0 };
return report;
}
// ── Weighted-shape application ───────────────────────────────────────────
/**
* Resolve a "high-level shape" — VRM name, Oculus viseme, Preston-Blair
* phoneme code, OR a raw ARKit name — to a `{ arkitName: weight }` map.
*
* Returns an empty object for unknown inputs.
*/
export function resolveShape(name) {
if (!name) return {};
// Direct ARKit hit?
const canon = canonicalARKitName(name);
if (canon) return { [canon]: 1 };
// Indirect maps — case-sensitive on purpose, the keys above are the
// canonical spellings emitted by each upstream pipeline.
if (name in VRM_TO_ARKIT) return { ...VRM_TO_ARKIT[name] };
if (name in OCULUS_TO_ARKIT) return { ...OCULUS_TO_ARKIT[name] };
if (name in PHONEME_TO_ARKIT) return { ...PHONEME_TO_ARKIT[name] };
// Case-insensitive retry on the indirect maps so callers don't have to
// match the exact studly-caps spelling.
const lower = String(name).toLowerCase();
for (const map of [VRM_TO_ARKIT, OCULUS_TO_ARKIT, PHONEME_TO_ARKIT]) {
for (const k of Object.keys(map)) {
if (k.toLowerCase() === lower) return { ...map[k] };
}
}
return {};
}
/**
* Blend any number of shape inputs (each either a string label or an
* already-resolved `{ arkitName: weight }` map) into a single ARKit map.
*
* Same name across inputs: takes the MAX weight (not sum). This mirrors how
* cinematic facial-rig software blends visemes — additive blending of
* orthogonal mouth shapes produces broken poses; max keeps the strongest
* activation per channel.
*/
export function blendShapes(...inputs) {
const out = {};
for (const input of inputs) {
if (!input) continue;
const resolved = typeof input === 'string' ? resolveShape(input) : input;
for (const [k, v] of Object.entries(resolved)) {
const canon = canonicalARKitName(k);
if (!canon) continue;
const w = clamp01(v);
if (w > (out[canon] || 0)) out[canon] = w;
}
}
return out;
}
function clamp01(n) {
const v = Number(n);
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}