Three.js From Zero · Article s3-03

Animation Blending & Blend Trees

← threejs-from-zeroS3 · Article 03 Season 3
Article S3-03 · Three.js From Zero

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.

loading…

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

  1. Add sprint: extend the 1D blend to Idle → Walk → Jog → Sprint (4 anchors).
  2. Implement Delaunay triangulation using d3-delaunay. Visualize the triangle the point is currently in.
  3. 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.