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:

ConceptDescription
StateA named node in the graph, bound to one animation clip (e.g. idle, talk, wave)
TransitionA directed edge between two states, triggered by a parameter condition, with an optional crossfade duration
LayerAn 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.

main.js
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 })
OptionTypeDefaultDescription
loopbooleantrueWhether the clip loops when it reaches the end
speednumber1.0Playback speed multiplier
startTimenumber0Clip start offset in seconds
exitTimenumber | nullnullAuto-exit after this many seconds (for one-shot states)

For one-shot gestures (wave, nod), set loop: false and exitTime to 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

OptionTypeDefaultDescription
durationnumber0.2Crossfade blend duration in seconds
offsetnumber0Start the destination clip at this time offset
interruptiblebooleantrueAllow 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 speed

graph.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"),
})
OptionTypeDefaultDescription
additivebooleanfalseAdd delta transforms on top of base (true) vs. full override (false)
weightnumber1.0Blend weight: 0.0 = invisible, 1.0 = fully applied
maskstringnullBone mask: 'facial', 'upperBody', 'lowerBody', or a custom mask name
loopbooleantrueLoop the layer clip
onEndfunctionnullCallback fired when a non-looping layer clip finishes

Built-in bone masks

Mask nameBones included
facialHead, jaw, all facial joints
upperBodySpine, chest, neck, head, both arms and hands
lowerBodyHips, both legs and feet
rightArmRight shoulder, arm, forearm, hand, fingers
leftArmLeft 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:

avatar-graph.js
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

MethodSignatureDescription
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() → stringReturn the currently active state name
getParameter(name) → anyRead the current value of a parameter

human.animation

MethodSignatureDescription
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) → AnimationClipRetrieve a loaded clip from the bundle
loadClip(name, url) → PromiseLazy-load an additional clip at runtime

Next Steps