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:

finalPosition=basePosition+i(weight[i]×delta[i])\text{finalPosition} = \text{basePosition} + \sum_{i} \left( \text{weight}[i] \times \text{delta}[i] \right)

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

main.js
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

TargetDescription
browDownLeftLeft brow pulled down and inward (anger, concentration)
browDownRightRight brow pulled down and inward
browInnerUpInner corners of both brows raised (worry, surprise)
browOuterUpLeftOuter corner of left brow raised (curiosity, scepticism)
browOuterUpRightOuter 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

TargetDescription
cheekPuffBoth cheeks puffed outward
cheekSquintLeftLeft cheek raised (squinting smile)
cheekSquintRightRight cheek raised
// Full smile with cheek raise
human.morph.setMany({
    mouthSmileLeft: 0.9,
    mouthSmileRight: 0.9,
    cheekSquintLeft: 0.6,
    cheekSquintRight: 0.6,
})

Eyes

TargetDescription
eyeBlinkLeftLeft eye blink (0 = open, 1 = fully closed)
eyeBlinkRightRight eye blink
eyeSquintLeftLeft eye squint (lid tension, focus)
eyeSquintRightRight eye squint
eyeWideLeftLeft eye wide open (surprise, fear)
eyeWideRightRight eye wide open
eyeLookDownLeftLeft eye rotated downward
eyeLookDownRightRight eye rotated downward
eyeLookInLeftLeft eye rotated inward (toward nose)
eyeLookInRightRight eye rotated inward
eyeLookOutLeftLeft eye rotated outward
eyeLookOutRightRight eye rotated outward
eyeLookUpLeftLeft eye rotated upward
eyeLookUpRightRight 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

TargetDescription
jawOpenJaw drops down (speech, surprise)
jawForwardJaw pushed forward
jawLeftJaw shifted left
jawRightJaw shifted right
// Simulate speech jaw motion
function applyJaw(openAmount) {
    human.morph.set("jawOpen", Math.max(0, Math.min(1, openAmount)))
}

Mouth - Shape

TargetDescription
mouthCloseLips pressed shut (overrides jawOpen)
mouthFunnelLips formed into an "O" shape
mouthPuckerLips puckered (kiss shape)
mouthLeftMouth shifted left
mouthRightMouth shifted right
mouthSmileLeftLeft lip corner pulled up and back
mouthSmileRightRight lip corner pulled up and back
mouthFrownLeftLeft lip corner pulled down
mouthFrownRightRight lip corner pulled down
mouthDimpleLeftLeft dimple indentation
mouthDimpleRightRight dimple indentation
mouthStretchLeftLeft lip corner pulled wide
mouthStretchRightRight lip corner pulled wide

Mouth - Lips

TargetDescription
mouthUpperUpLeftLeft upper lip raised
mouthUpperUpRightRight upper lip raised
mouthLowerDownLeftLeft lower lip pulled down
mouthLowerDownRightRight lower lip pulled down
mouthRollUpperUpper lip rolled inward over teeth
mouthRollLowerLower lip rolled inward over teeth
mouthShrugUpperUpper lip raised and compressed
mouthShrugLowerLower lip raised and compressed
mouthPressLeftLeft lip corner pressed tight
mouthPressRightRight lip corner pressed tight

Nose

TargetDescription
noseSneerLeftLeft side of nose wrinkled (disgust)
noseSneerRightRight side of nose wrinkled

Tongue

TargetDescription
tongueOutTongue 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 optionDescription
'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 + mouthFunnel

Built-in viseme map

Phoneme groupExample soundsPrimary FACS targets activated
Silence-all → 0.0
Open vowelsAH, AAjawOpen, mouthFunnel
Mid vowelsEH, AEjawOpen, mouthStretchLeft/Right
Close vowelsIY, IHmouthSmileLeft/Right, mouthStretchLeft/Right
Rounded vowelsOW, UHmouthPucker, mouthFunnel, jawOpen
BilabialsP, B, MmouthClose, mouthPressLeft/Right
LabiodentalsF, VmouthLowerDownLeft/Right, mouthUpperUpLeft/Right
Dental/alveolarT, D, N, S, ZmouthStretchLeft/Right, tongueOut
VelarsK, GjawOpen, mouthFunnel
ApproximantsR, L, W, YmouthPucker, 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 using graph.excludeFromMask('facial', ['jawOpen']).


Complete Example - Expressive Conversational Avatar

expressive-avatar.js
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() and human.morph.setMany() are synchronous and non-blocking - they write into a Float32Array that 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() uses requestAnimationFrame internally and returns a Promise that resolves when the animation completes. It is safe to await in sequence.
  • Setting a weight to 0.0 on 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

MethodSignatureDescription
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) → numberRead the current weight of a target
getAll() → Record<string, number>Read all current weights as a plain object
getAllAsArray() → Float32ArrayRead 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?) → PromiseSmoothly animate to target weights

VisemeMapper

MethodSignatureDescription
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