Three.js From Zero · Article s3-03
Animation Blending & Blend Trees
Animation Blending & Blend Trees
In S1-05 we crossfaded between two clips. That handles transitions but not continuous blends — the "how fast am I running" spectrum. Blend trees solve it: one axis (like speed), three anchor clips (walk/jog/sprint), the mixer blends between the two closest based on where the axis sits. Your character goes smoothly from still to sprint without a discrete state change.
The demo shows a 1D blend on a speed axis and a 2D blend on a movement-direction (x/z) grid. Sliders drive the axis values. Watch the weights redistribute as you move the point around the grid.
1D blend — one axis, N clips
Given an axis value v and clips at positions [0, 0.5, 1]
(Idle, Walk, Run), find the two closest and linearly interpolate:
function blend1D(v, nodes) {
// nodes: [{pos, clip}, ...] sorted by pos
v = Math.max(nodes[0].pos, Math.min(nodes[nodes.length-1].pos, v));
for (let i = 0; i < nodes.length - 1; i++) {
const a = nodes[i], b = nodes[i + 1];
if (v >= a.pos && v <= b.pos) {
const t = (v - a.pos) / (b.pos - a.pos);
return [
{ clip: a.clip, weight: 1 - t },
{ clip: b.clip, weight: t },
];
}
}
}
Every other clip in the tree gets weight 0. Apply via AnimationMixer:
const weights = blend1D(speedAxis, [
{ pos: 0, clip: idleClip },
{ pos: 0.5, clip: walkClip },
{ pos: 1, clip: runClip },
]);
for (const clip of allClips) {
const action = mixer.clipAction(clip);
const w = weights.find((x) => x.clip === clip)?.weight ?? 0;
action.setEffectiveWeight(w);
if (!action.isRunning() && w > 0) action.play();
}
2D blend — barycentric on a grid
For strafe movement you need two axes: forward/back + left/right. Place clips around a unit circle: forward, back, left, right (+ diagonals + center = idle). Given a point (x, z), find the three closest anchors, compute barycentric weights from the triangle:
function blend2D(point, anchors) {
// anchors: [{ pos: vec2, clip }]
// 1. Triangulate anchors (Delaunay) — precomputed
// 2. Find triangle containing 'point'
// 3. Barycentric weights: each vertex gets a weight based on how close the point is
const tri = findTriangleContaining(point, triangles);
const [w0, w1, w2] = barycentric(point, tri.a.pos, tri.b.pos, tri.c.pos);
return [
{ clip: tri.a.clip, weight: w0 },
{ clip: tri.b.clip, weight: w1 },
{ clip: tri.c.clip, weight: w2 },
];
}
At most 3 clips weighted at any time. Clips outside the triangle = weight 0. Smooth as you move the control point across triangle boundaries because the barycentric weights are continuous.
Barycentric formula
function barycentric(p, a, b, c) {
const v0 = b.sub(a), v1 = c.sub(a), v2 = p.sub(a);
const d00 = v0.dot(v0), d01 = v0.dot(v1), d11 = v1.dot(v1);
const d20 = v2.dot(v0), d21 = v2.dot(v1);
const denom = d00 * d11 - d01 * d01;
const v = (d11 * d20 - d01 * d21) / denom;
const w = (d00 * d21 - d01 * d20) / denom;
const u = 1 - v - w;
return [u, v, w];
}
Layered blending — upper body + lower body
Classic AAA pattern: character walks with legs (lower body animation), aims a rifle with arms (upper body animation). Two blend graphs, each driving a bone subset, combined into one pose.
Three.js's AnimationMixer supports this via per-bone masks — or more
commonly, you create two AnimationClips that only affect their respective
bone subsets, and they naturally layer. Bones not mentioned in a clip keep their current
value.
// Upper body clip: only affects spine + arms bones
// Lower body clip: only affects legs + hips bones
// Play both simultaneously at weight 1 — no conflict
mixer.clipAction(walkLowerBody).play(); // weight 1
mixer.clipAction(aimUpperBody).play(); // weight 1
Root motion — the clip drives the character's world position
Walk animations in AAA games don't just wiggle legs — they actually translate the root bone forward. "Root motion" means extracting that translation per frame and applying it to the character's game-logic position instead of letting it translate the visual:
mixer.update(dt);
// Read root bone's delta for this frame
const rootPos = rootBone.getWorldPosition(new THREE.Vector3());
const delta = rootPos.clone().sub(lastRootPos);
lastRootPos.copy(rootPos);
// Apply to character group (not to the skeleton root directly)
character.position.add(delta);
rootBone.position.sub(delta); // "unroot" the skeleton
This gives you foot-planting perfection — the foot stays on the ground because the animation data tells the character exactly how far to move. No more "ice-skating" walk cycles.
Feet IK + root motion = no foot sliding
Even with root motion, slopes cause foot sliding — the clip was authored for flat ground. Foot IK (S3-04) lifts/lowers each foot to match terrain height every frame. Run root motion + foot IK + hip adjustment together for professional-grade locomotion.
Common first-time pitfalls
- Blend is jumpy at triangle boundaries. Your triangulation isn't Delaunay, or anchors don't form a convex hull. Use a proper triangulation library (d3-delaunay, delatin).
- Weights don't sum to 1. Normalize them after computing — matches skinning logic.
- Characters stutter at low speed. Idle should blend smoothly to walk. Place Idle at 0, Walk at 0.3, not 0.5 — small dead zone is fine, but 0.5 leaves 30% of the axis blending Idle (which doesn't translate) with Walk (which does).
- Upper/lower body layer fights. Both clips affect the spine. Either mask out shared bones, or author one clip without those bones.
- Root motion teleports character. You're reading absolute position instead of delta. Subtract last-frame root.
Exercises
- Add sprint: extend the 1D blend to Idle → Walk → Jog → Sprint (4 anchors).
- Implement Delaunay triangulation using d3-delaunay. Visualize the triangle the point is currently in.
- Directional strafe blend on Mixamo's 8-direction locomotion pack: load Forward/ForwardLeft/Left/BackLeft/Back/BackRight/Right/ForwardRight, drive via 2D blend.
What's next
S3-04 — Inverse Kinematics. CCDIK, FABRIK, two-bone — the solvers that let your character's hand reach a door handle, or foot plant on uneven ground.