Facial Blendshapes
OpenHuman's facial system is built on the Facial Action Coding System (FACS) - 52 morph targets that cover every meaningful movement of the human face, from a subtle brow raise to a full jaw open. Each target is a GPU-resident delta texture blended in real time, allowing hundreds of simultaneous weight changes with no CPU overhead.
How It Works
Under the hood, all 52 morph deltas are packed into a single RGB16F GPU texture atlas (morphs.bin). Every frame, the engine runs a GPU accumulation pass:
This means setting any number of morph weights is essentially free - the cost is a single fullscreen compute pass regardless of how many targets are active. Weights are uploaded to the GPU once per frame as a Float32Array uniform.
Quick Start
import { OpenHuman } from "@openhuman/sdk"
const human = await OpenHuman.load("character.ohb", canvas)
// Set a single morph target weight (0.0 – 1.0)
human.morph.set("mouthSmileLeft", 0.7)
human.morph.set("mouthSmileRight", 0.7)
// Set multiple targets in one call
human.morph.setMany({
jawOpen: 0.4,
mouthFunnel: 0.3,
browInnerUp: 0.5,
})
// Read current weight
const w = human.morph.get("jawOpen") // → 0.4
// Reset all targets to 0
human.morph.reset()Complete FACS Reference
All 52 targets grouped by facial region. Every weight is a number in the range 0.0 (neutral) to 1.0 (full activation). Values outside this range are clamped.
Brows
| Target | Description |
|---|---|
browDownLeft | Left brow pulled down and inward (anger, concentration) |
browDownRight | Right brow pulled down and inward |
browInnerUp | Inner corners of both brows raised (worry, surprise) |
browOuterUpLeft | Outer corner of left brow raised (curiosity, scepticism) |
browOuterUpRight | Outer corner of right brow raised |
// Worried expression
human.morph.setMany({ browInnerUp: 0.7, browDownLeft: 0.2, browDownRight: 0.2 })
// Sceptical (one brow raised)
human.morph.setMany({ browOuterUpLeft: 0.8 })Cheeks
| Target | Description |
|---|---|
cheekPuff | Both cheeks puffed outward |
cheekSquintLeft | Left cheek raised (squinting smile) |
cheekSquintRight | Right cheek raised |
// Full smile with cheek raise
human.morph.setMany({
mouthSmileLeft: 0.9,
mouthSmileRight: 0.9,
cheekSquintLeft: 0.6,
cheekSquintRight: 0.6,
})Eyes
| Target | Description |
|---|---|
eyeBlinkLeft | Left eye blink (0 = open, 1 = fully closed) |
eyeBlinkRight | Right eye blink |
eyeSquintLeft | Left eye squint (lid tension, focus) |
eyeSquintRight | Right eye squint |
eyeWideLeft | Left eye wide open (surprise, fear) |
eyeWideRight | Right eye wide open |
eyeLookDownLeft | Left eye rotated downward |
eyeLookDownRight | Right eye rotated downward |
eyeLookInLeft | Left eye rotated inward (toward nose) |
eyeLookInRight | Right eye rotated inward |
eyeLookOutLeft | Left eye rotated outward |
eyeLookOutRight | Right eye rotated outward |
eyeLookUpLeft | Left eye rotated upward |
eyeLookUpRight | Right eye rotated upward |
// Natural blink
async function blink() {
human.morph.set("eyeBlinkLeft", 1.0)
human.morph.set("eyeBlinkRight", 1.0)
await delay(80)
human.morph.set("eyeBlinkLeft", 0.0)
human.morph.set("eyeBlinkRight", 0.0)
}
// Look left
human.morph.setMany({
eyeLookOutLeft: 0.6,
eyeLookInRight: 0.6,
})Eye look targets (
eyeLookIn/Out/Up/Down) move the eye mesh geometry. For realistic gaze, combine them with the Animation Graph gaze layer which rotates the eye bones - the two systems stack additively.
Jaw
| Target | Description |
|---|---|
jawOpen | Jaw drops down (speech, surprise) |
jawForward | Jaw pushed forward |
jawLeft | Jaw shifted left |
jawRight | Jaw shifted right |
// Simulate speech jaw motion
function applyJaw(openAmount) {
human.morph.set("jawOpen", Math.max(0, Math.min(1, openAmount)))
}Mouth - Shape
| Target | Description |
|---|---|
mouthClose | Lips pressed shut (overrides jawOpen) |
mouthFunnel | Lips formed into an "O" shape |
mouthPucker | Lips puckered (kiss shape) |
mouthLeft | Mouth shifted left |
mouthRight | Mouth shifted right |
mouthSmileLeft | Left lip corner pulled up and back |
mouthSmileRight | Right lip corner pulled up and back |
mouthFrownLeft | Left lip corner pulled down |
mouthFrownRight | Right lip corner pulled down |
mouthDimpleLeft | Left dimple indentation |
mouthDimpleRight | Right dimple indentation |
mouthStretchLeft | Left lip corner pulled wide |
mouthStretchRight | Right lip corner pulled wide |
Mouth - Lips
| Target | Description |
|---|---|
mouthUpperUpLeft | Left upper lip raised |
mouthUpperUpRight | Right upper lip raised |
mouthLowerDownLeft | Left lower lip pulled down |
mouthLowerDownRight | Right lower lip pulled down |
mouthRollUpper | Upper lip rolled inward over teeth |
mouthRollLower | Lower lip rolled inward over teeth |
mouthShrugUpper | Upper lip raised and compressed |
mouthShrugLower | Lower lip raised and compressed |
mouthPressLeft | Left lip corner pressed tight |
mouthPressRight | Right lip corner pressed tight |
Nose
| Target | Description |
|---|---|
noseSneerLeft | Left side of nose wrinkled (disgust) |
noseSneerRight | Right side of nose wrinkled |
Tongue
| Target | Description |
|---|---|
tongueOut | Tongue protruding past the lower lip |
Preset Expressions
For common expressions, use the built-in human.morph.applyPreset() helper. Each preset is a curated setMany call tuned by the OpenHuman team for natural results:
// Apply a named preset
human.morph.applyPreset("happy") // warm smile, cheek squint
human.morph.applyPreset("sad") // brow down, lip corners down
human.morph.applyPreset("surprised") // wide eyes, jaw open, brows up
human.morph.applyPreset("angry") // brow down, nose sneer, jaw tight
human.morph.applyPreset("disgusted") // nose sneer, upper lip raise
human.morph.applyPreset("fearful") // wide eyes, brow raise, lip stretch
human.morph.applyPreset("neutral") // reset all to 0.0
// Apply preset with intensity (0.0 – 1.0)
human.morph.applyPreset("happy", 0.5) // 50% happy blend
// Crossfade from current weights to preset over time (seconds)
human.morph.applyPreset("surprised", 1.0, { fadeDuration: 0.4 })Animating Weights Over Time
For smooth expressions, use human.morph.animateTo() instead of setting weights directly:
// Animate from current weights to target weights
await human.morph.animateTo({ mouthSmileLeft: 0.8, mouthSmileRight: 0.8, cheekSquintLeft: 0.5, cheekSquintRight: 0.5 }, { duration: 0.3, easing: "easeOutCubic" })
// Chain expressions
await human.morph.animateTo({ browInnerUp: 0.6, eyeWideLeft: 0.4, eyeWideRight: 0.4 }, { duration: 0.2 })
await delay(1000)
await human.morph.animateTo({}, { duration: 0.5 }) // fade back to neutral| Easing option | Description |
|---|---|
'linear' | Constant rate |
'easeInCubic' | Slow start, fast end |
'easeOutCubic' | Fast start, slow end (default) |
'easeInOutCubic' | Slow start and end, fast middle |
'spring' | Physically-based spring (overshoot then settle) |
Lip Sync
From a FACS weight stream
If your TTS or audio analysis backend produces FACS weights, feed them directly into human.morph.setMany() each audio frame:
// Called by your audio/TTS pipeline at ~30–60 fps
function onFacsFrame(weights) {
// weights: Float32Array[52] - one value per FACS target in canonical order
human.morph.setFromArray(weights)
}human.morph.setFromArray() accepts a Float32Array of exactly 52 values in the canonical FACS order (same as the full reference table, top to bottom). This skips the property-name lookup overhead for high-frequency streaming.
From phoneme visemes
If your backend produces phoneme codes instead of raw FACS weights, use the built-in viseme mapper:
import { VisemeMapper } from "@openhuman/sdk"
const mapper = new VisemeMapper()
// Map an ARPAbet phoneme to FACS weights and apply
function onPhoneme(phoneme, intensity = 1.0) {
const weights = mapper.toFACS(phoneme, intensity)
human.morph.setMany(weights)
}
// Example phoneme events from a TTS engine
onPhoneme("AH", 0.8) // open vowel → jawOpen + mouthFunnel
onPhoneme("M", 1.0) // bilabial → mouthClose
onPhoneme("S", 0.9) // sibilant → mouthStretchLeft + mouthStretchRight
onPhoneme("OW", 0.7) // rounded → mouthPucker + mouthFunnelBuilt-in viseme map
| Phoneme group | Example sounds | Primary FACS targets activated |
|---|---|---|
| Silence | - | all → 0.0 |
| Open vowels | AH, AA | jawOpen, mouthFunnel |
| Mid vowels | EH, AE | jawOpen, mouthStretchLeft/Right |
| Close vowels | IY, IH | mouthSmileLeft/Right, mouthStretchLeft/Right |
| Rounded vowels | OW, UH | mouthPucker, mouthFunnel, jawOpen |
| Bilabials | P, B, M | mouthClose, mouthPressLeft/Right |
| Labiodentals | F, V | mouthLowerDownLeft/Right, mouthUpperUpLeft/Right |
| Dental/alveolar | T, D, N, S, Z | mouthStretchLeft/Right, tongueOut |
| Velars | K, G | jawOpen, mouthFunnel |
| Approximants | R, L, W, Y | mouthPucker, mouthFunnel |
Blending with the Animation Graph
Morph weights set via human.morph compose additively with weights driven by the Animation Graph's facial layer. The final weight for each target is:
finalWeight = clamp(graphWeight + morphAPIWeight, 0.0, 1.0)
This means you can run a procedural blink animation via the graph while simultaneously driving lip sync via human.morph.setMany() without conflicts.
// Graph drives blink loop autonomously
human.animation.setLayer("blink", blinkClip, {
additive: true,
mask: "facial",
loop: true,
})
// Morph API drives lip sync independently
function onFacsFrame(weights) {
human.morph.setFromArray(weights) // blink weights from graph still apply on top
}To prevent the graph from interfering with specific targets (e.g. you want full manual control of
jawOpen), exclude those targets from the facial layer mask usinggraph.excludeFromMask('facial', ['jawOpen']).
Complete Example - Expressive Conversational Avatar
import { OpenHuman, VisemeMapper } from "@openhuman/sdk"
const human = await OpenHuman.load("character.ohb", canvas)
const mapper = new VisemeMapper()
// ── Autonomous blink (via Animation Graph layer) ──────────────
human.animation.setLayer("blink", human.animation.getClip("blink_idle"), {
additive: true,
mask: "facial",
loop: true,
})
// ── Emotion state ─────────────────────────────────────────────
let currentEmotion = "neutral"
async function setEmotion(name, intensity = 1.0) {
currentEmotion = name
await human.morph.applyPreset(name, intensity, { fadeDuration: 0.35 })
}
// ── Lip sync (phoneme-driven) ─────────────────────────────────
let lipSyncActive = false
function startSpeech(emotion = "happy", intensity = 0.6) {
lipSyncActive = true
setEmotion(emotion, intensity)
}
function onPhoneme(phoneme, intensity) {
if (!lipSyncActive) return
const weights = mapper.toFACS(phoneme, intensity)
human.morph.setMany(weights)
}
function stopSpeech() {
lipSyncActive = false
// Fade mouth back to emotion preset, keep brows / cheeks
human.morph.animateTo({ jawOpen: 0, mouthFunnel: 0, mouthPucker: 0, tongueOut: 0 }, { duration: 0.15 })
}
// ── Reactive micro-expressions ────────────────────────────────
function raiseBrow(side = "both", amount = 0.4) {
if (side === "left" || side === "both") human.morph.set("browOuterUpLeft", amount)
if (side === "right" || side === "both") human.morph.set("browOuterUpRight", amount)
setTimeout(() => {
human.morph.animateTo({ browOuterUpLeft: 0, browOuterUpRight: 0 }, { duration: 0.4 })
}, 600)
}
// ── Usage ─────────────────────────────────────────────────────
await setEmotion("neutral")
// Simulate a TTS speech event
startSpeech("happy", 0.7)
onPhoneme("HH", 0.5)
onPhoneme("EH", 0.9)
onPhoneme("L", 0.8)
onPhoneme("OW", 0.7)
stopSpeech()
// React to a user statement
raiseBrow("left", 0.5)Performance Notes
human.morph.set()andhuman.morph.setMany()are synchronous and non-blocking - they write into aFloat32Arraythat is uploaded to the GPU once at the start of the next frame.- Calling
setMany()with 52 entries costs the same as calling it with 1 - batch your updates. human.morph.animateTo()usesrequestAnimationFrameinternally and returns aPromisethat resolves when the animation completes. It is safe toawaitin sequence.- Setting a weight to
0.0on a target is genuinely free - the GPU accumulation pass multiplies by zero, which the shader compiler optimises away on most GPUs.
API Reference
human.morph
| Method | Signature | Description |
|---|---|---|
set | (target: string, weight: number) | Set a single morph weight (0.0 – 1.0) |
setMany | (weights: Record<string, number>) | Set multiple weights in one call |
setFromArray | (weights: Float32Array) | Set all 52 weights from canonical-order array |
get | (target: string) → number | Read the current weight of a target |
getAll | () → Record<string, number> | Read all current weights as a plain object |
getAllAsArray | () → Float32Array | Read all weights in canonical FACS order |
reset | () | Set all weights to 0.0 immediately |
applyPreset | (name, intensity?, options?) | Apply a named expression preset |
animateTo | (weights, options?) → Promise | Smoothly animate to target weights |
VisemeMapper
| Method | Signature | Description |
|---|---|---|
toFACS | (phoneme: string, intensity?: number) → Record<string, number> | Map an ARPAbet phoneme to FACS weights |
setVisemeWeight | (phoneme, target, weight) | Override a single entry in the viseme map |
loadCustomMap | (map: Record<string, Record<string, number>>) | Replace the entire viseme map |
Next Steps
- Streaming Animation - receive real-time FACS arrays over WebSocket for AI lip sync
- Animation Graph - layer facial animation clips on top of body states
- Embed API Reference - embed the character with a
<open-human>web component