Animation Graph
The Animation Graph is OpenHuman's state machine that drives character animation at runtime. It manages clip playback, crossfade transitions, layered blending (body vs. facial), and parameter-driven blend trees - giving you full control over how a character moves and reacts without writing a frame loop by hand.
Concepts
Before diving into the API, it helps to understand the three building blocks of the Animation Graph:
| Concept | Description |
|---|---|
| State | A named node in the graph, bound to one animation clip (e.g. idle, talk, wave) |
| Transition | A directed edge between two states, triggered by a parameter condition, with an optional crossfade duration |
| Layer | An independent animation channel blended on top of the base graph - used for upper-body overrides or additive facial expressions |
At runtime the graph evaluates its active state, samples the bound clip at the current playhead time, optionally crossfades with a transitioning-out state, then uploads the resulting joint transforms to the GPU skinning pipeline every frame.
State machine overview
Quick Start
The simplest possible graph: two states (idle and talk) with a parameter-driven transition.
import { OpenHuman, AnimationGraph } from "@openhuman/sdk"
const human = await OpenHuman.load("character.ohb", canvas)
// 1. Create the graph
const graph = new AnimationGraph()
// 2. Add states
graph.addState("idle", human.animation.getClip("idle"))
graph.addState("talk", human.animation.getClip("talk_neutral"))
// 3. Add transitions
graph.addTransition("idle", "talk", { bool: "isTalking", value: true, duration: 0.3 })
graph.addTransition("talk", "idle", { bool: "isTalking", value: false, duration: 0.5 })
// 4. Attach to the character and start
human.animation.setGraph(graph)
human.animation.play("idle")
// 5. Trigger transitions at runtime
document.getElementById("btn-talk").addEventListener("click", () => {
graph.setBool("isTalking", true)
})States
graph.addState(name, clip, options?)
Registers an animation clip as a named state.
graph.addState("idle", human.animation.getClip("idle"))
graph.addState("talk", human.animation.getClip("talk_neutral"))
graph.addState("wave", human.animation.getClip("gesture_wave"))
graph.addState("blink", human.animation.getClip("blink"), { loop: false })
graph.addState("lookLeft", human.animation.getClip("look_left"), { loop: true, speed: 0.8 })| Option | Type | Default | Description |
|---|---|---|---|
loop | boolean | true | Whether the clip loops when it reaches the end |
speed | number | 1.0 | Playback speed multiplier |
startTime | number | 0 | Clip start offset in seconds |
exitTime | number | null | null | Auto-exit after this many seconds (for one-shot states) |
For one-shot gestures (wave, nod), set
loop: falseandexitTimeto the clip length. The graph will automatically transition back to the previous state when the clip ends - no manual trigger required.
// One-shot gesture that auto-returns to idle
graph.addState("wave", human.animation.getClip("gesture_wave"), {
loop: false,
exitTime: 2.1, // clip is 2.1s long
})
graph.addTransition("idle", "wave", { trigger: "doWave", duration: 0.2 })
graph.addTransition("wave", "idle", { onEnd: true, duration: 0.3 })Transitions
graph.addTransition(from, to, condition)
Adds a directed edge between two states. The transition fires when its condition is met.
Condition types
Boolean condition - fires when a named bool parameter reaches the specified value:
graph.addTransition("idle", "talk", {
bool: "isTalking",
value: true,
duration: 0.3, // crossfade duration in seconds
})Trigger condition - fires once on the next graph.trigger() call, then resets:
graph.addTransition("idle", "wave", {
trigger: "doWave",
duration: 0.2,
})
// Somewhere in your code:
graph.trigger("doWave")Float threshold condition - fires when a float parameter crosses a threshold:
graph.addTransition("walk", "run", {
float: "speed",
greaterThan: 0.7,
duration: 0.15,
})
graph.addTransition("run", "walk", {
float: "speed",
lessThan: 0.7,
duration: 0.15,
})On-end condition - fires automatically when the current clip reaches its end (useful for non-looping states):
graph.addTransition("wave", "idle", { onEnd: true, duration: 0.3 })Transition options
| Option | Type | Default | Description |
|---|---|---|---|
duration | number | 0.2 | Crossfade blend duration in seconds |
offset | number | 0 | Start the destination clip at this time offset |
interruptible | boolean | true | Allow a higher-priority transition to interrupt this one mid-blend |
Parameters
Parameters are named values you set at runtime to drive transitions and blend trees.
graph.setBool(name, value)
graph.setBool("isTalking", true)
graph.setBool("isTalking", false)graph.setFloat(name, value)
Float parameters can drive both transitions (threshold conditions) and 1D / 2D blend trees.
graph.setFloat("emotionHappy", 0.8) // 0.0–1.0
graph.setFloat("emotionSad", 0.0)
graph.setFloat("lookX", 0.3) // -1 left → +1 right
graph.setFloat("lookY", -0.1) // -1 down → +1 up
graph.setFloat("speed", 0.0) // locomotion speedgraph.trigger(name)
Fires a one-shot trigger that activates matching transitions once, then auto-resets.
graph.trigger("doWave")
graph.trigger("doNod")Layers
Layers let you blend a secondary animation on top of the base graph - for example, running a facial blink loop independently from the body animation, or overlaying an upper-body gesture without affecting the legs.
human.animation.setLayer(name, clip, options)
// Additive facial blink layer - runs on top of everything
human.animation.setLayer("blink", human.animation.getClip("blink_idle"), {
additive: true,
weight: 1.0,
mask: "facial", // only affects facial bones
loop: true,
})
// Override layer: upper-body gesture, lower body keeps base state
human.animation.setLayer("upperBody", human.animation.getClip("gesture_wave"), {
additive: false,
weight: 1.0,
mask: "upperBody",
loop: false,
onEnd: () => human.animation.removeLayer("upperBody"),
})| Option | Type | Default | Description |
|---|---|---|---|
additive | boolean | false | Add delta transforms on top of base (true) vs. full override (false) |
weight | number | 1.0 | Blend weight: 0.0 = invisible, 1.0 = fully applied |
mask | string | null | Bone mask: 'facial', 'upperBody', 'lowerBody', or a custom mask name |
loop | boolean | true | Loop the layer clip |
onEnd | function | null | Callback fired when a non-looping layer clip finishes |
Built-in bone masks
| Mask name | Bones included |
|---|---|
facial | Head, jaw, all facial joints |
upperBody | Spine, chest, neck, head, both arms and hands |
lowerBody | Hips, both legs and feet |
rightArm | Right shoulder, arm, forearm, hand, fingers |
leftArm | Left shoulder, arm, forearm, hand, fingers |
Custom bone masks can be defined by passing an array of joint names to
graph.defineMask(name, joints).
graph.defineMask("faceAndNeck", ["Neck", "Head", "Jaw", "LeftEye", "RightEye"])Blend Trees
For smooth directional or emotion blending across multiple clips, use a 1D or 2D blend tree as a state's animation source instead of a single clip.
1D Blend Tree - emotion intensity
import { BlendTree1D } from "@openhuman/sdk"
const emotionTree = new BlendTree1D("emotionHappy")
emotionTree.addClip(0.0, human.animation.getClip("talk_neutral"))
emotionTree.addClip(0.5, human.animation.getClip("talk_happy_light"))
emotionTree.addClip(1.0, human.animation.getClip("talk_happy_full"))
graph.addState("talk", emotionTree)
// At runtime: smoothly blend across emotion intensity
graph.setFloat("emotionHappy", 0.75)2D Blend Tree - gaze direction
import { BlendTree2D } from "@openhuman/sdk"
const gazeTree = new BlendTree2D("lookX", "lookY")
gazeTree.addClip([0, 0], human.animation.getClip("look_center"))
gazeTree.addClip([-1, 0], human.animation.getClip("look_left"))
gazeTree.addClip([1, 0], human.animation.getClip("look_right"))
gazeTree.addClip([0, 1], human.animation.getClip("look_up"))
gazeTree.addClip([0, -1], human.animation.getClip("look_down"))
human.animation.setLayer("gaze", gazeTree, { additive: true, mask: "facial" })
// Animate gaze toward a target
graph.setFloat("lookX", 0.4)
graph.setFloat("lookY", -0.2)Complete Example - Conversational Avatar
A full graph wiring suitable for a conversational AI avatar:
import { OpenHuman, AnimationGraph, BlendTree1D, BlendTree2D } from "@openhuman/sdk"
const human = await OpenHuman.load("character.ohb", canvas)
const graph = new AnimationGraph()
// ── Base layer ────────────────────────────────────────────────
// Idle state
graph.addState("idle", human.animation.getClip("idle"), { loop: true })
// Talk state - 1D blend across emotion
const talkTree = new BlendTree1D("emotionHappy")
talkTree.addClip(0.0, human.animation.getClip("talk_neutral"))
talkTree.addClip(1.0, human.animation.getClip("talk_happy"))
graph.addState("talk", talkTree, { loop: true })
// Gesture states (one-shot)
graph.addState("wave", human.animation.getClip("gesture_wave"), { loop: false, exitTime: 2.1 })
graph.addState("nod", human.animation.getClip("gesture_nod"), { loop: false, exitTime: 1.2 })
// ── Transitions ────────────────────────────────────────────────
graph.addTransition("idle", "talk", { bool: "isTalking", value: true, duration: 0.3 })
graph.addTransition("talk", "idle", { bool: "isTalking", value: false, duration: 0.5 })
graph.addTransition("idle", "wave", { trigger: "doWave", duration: 0.2 })
graph.addTransition("talk", "nod", { trigger: "doNod", duration: 0.15 })
graph.addTransition("wave", "idle", { onEnd: true, duration: 0.3 })
graph.addTransition("nod", "talk", { onEnd: true, duration: 0.2 })
// ── Additive layers ────────────────────────────────────────────
// Blink - runs continuously, independent of body state
human.animation.setLayer("blink", human.animation.getClip("blink_idle"), {
additive: true,
mask: "facial",
loop: true,
})
// Gaze - 2D blend tree driven by lookX / lookY
const gazeTree = new BlendTree2D("lookX", "lookY")
gazeTree.addClip([0, 0], human.animation.getClip("look_center"))
gazeTree.addClip([-1, 0], human.animation.getClip("look_left"))
gazeTree.addClip([1, 0], human.animation.getClip("look_right"))
gazeTree.addClip([0, 1], human.animation.getClip("look_up"))
gazeTree.addClip([0, -1], human.animation.getClip("look_down"))
human.animation.setLayer("gaze", gazeTree, {
additive: true,
mask: "facial",
weight: 0.6,
})
// ── Start ──────────────────────────────────────────────────────
human.animation.setGraph(graph)
human.animation.play("idle")
// ── Runtime control ────────────────────────────────────────────
// When AI starts speaking
function onSpeechStart(emotion = 0.5) {
graph.setBool("isTalking", true)
graph.setFloat("emotionHappy", emotion)
}
// When AI stops speaking
function onSpeechEnd() {
graph.setBool("isTalking", false)
}
// Trigger a gesture mid-conversation
function triggerNod() {
graph.trigger("doNod")
}
function triggerWave() {
graph.trigger("doWave")
}
// Animate gaze toward mouse position (normalised -1..1)
canvas.addEventListener("mousemove", (e) => {
const x = (e.clientX / canvas.clientWidth - 0.5) * 2
const y = -(e.clientY / canvas.clientHeight - 0.5) * 2
graph.setFloat("lookX", x * 0.6)
graph.setFloat("lookY", y * 0.4)
})Events
Listen to graph lifecycle events on the human instance:
human.on("stateEnter", ({ state }) => console.log("Entered state:", state))
human.on("stateExit", ({ state }) => console.log("Exited state:", state))
human.on("animationEnd", ({ clip }) => console.log("Clip ended:", clip))
human.on("transitionStart", ({ from, to, duration }) => {
console.log(`Crossfading ${from} → ${to} over ${duration}s`)
})API Reference
AnimationGraph
| Method | Signature | Description |
|---|---|---|
addState | (name, clip | blendTree, options?) | Register a state |
addTransition | (from, to, condition) | Add a directed transition edge |
setBool | (name, value: boolean) | Set a boolean parameter |
setFloat | (name, value: number) | Set a float parameter |
trigger | (name) | Fire a one-shot trigger |
defineMask | (name, joints: string[]) | Define a custom bone mask |
getActiveState | () → string | Return the currently active state name |
getParameter | (name) → any | Read the current value of a parameter |
human.animation
| Method | Signature | Description |
|---|---|---|
setGraph | (graph: AnimationGraph) | Attach a graph to the character |
play | (state: string) | Jump immediately to a state (no transition) |
crossFadeTo | (state, duration) | Manually trigger a crossfade |
setLayer | (name, clip | blendTree, options) | Add or replace an animation layer |
removeLayer | (name) | Remove a layer |
setLayerWeight | (name, weight: number) | Fade a layer in/out |
getClip | (name) → AnimationClip | Retrieve a loaded clip from the bundle |
loadClip | (name, url) → Promise | Lazy-load an additional clip at runtime |
Next Steps
- Facial Blendshapes - drive the 52 FACS morph targets for lip sync and expression
- Streaming Animation - feed real-time pose data from a WebSocket or HTTP stream
- Embed API Reference - embed the full character via the
<open-human>web component