🦾 Tutorial · Animation · Procedural
📅 March 2026 ⏱ 20 min 🎓 Intermediate

Procedural Animation with FABRIK Inverse Kinematics

Inverse kinematics is the core algorithm behind procedural spider legs, robotic arm simulations, and rag-doll physics. FABRIK — Forward And Backward Reaching Inverse Kinematics — is the simplest IK solver that works for any chain length, converges in 1–10 iterations, and requires no matrix inversions.

1. The Inverse Kinematics Problem

Forward Kinematics (FK): given a set of joint angles, compute where the end-effector ends up.
Inverse Kinematics (IK): given a target position, find the joint angles that place the end-effector there.

A chain has N bones (segments) with lengths L₁, L₂, …, Lₙ. The first joint (root) is anchored. The last joint (end-effector) must reach a target point T.

Reachability check:
Sum of bone lengths: L_total = Σ Lᵢ
Distance to target: d = |T − root|

Reachable if d ≤ L_total
If unreachable (d > L_total): align all bones toward target (full extension)

Classic IK solvers (Jacobian transpose, pseudo-inverse) need matrix math and can be numerically unstable. FABRIK avoids it entirely with a pure geometric approach.

2. The FABRIK Algorithm

FABRIK alternates between two passes until convergence:

1Initialise: joints[0] = root, joints[N] = target (unconstrained start)
2Backward pass (end → root): Move joint[N] to target. Then move each joint towards the previous joint while maintaining bone length.
3Forward pass (root → end): Reset joints[0] to original root. Then move each joint towards the next while maintaining bone length.
4Check convergence: If |joints[N] − target| < tolerance, stop. Else repeat from step 2.
Backward pass for joint i (from i=N-1 down to i=0):
direction = normalize(joints[i] − joints[i+1])
joints[i] = joints[i+1] + direction × L[i]

Forward pass for joint i (from i=1 up to i=N):
direction = normalize(joints[i] − joints[i-1])
joints[i] = joints[i-1] + direction × L[i-1]

Typically converges in 1–10 iterations to < 1px error.
Why it works: Each pass independently satisfies the bone length constraint from one end. Running both passes together is equivalent to a Gauss-Seidel relaxation of the constraint system. Unlike gradient descent, it doesn't overshoot — each step moves a joint exactly to satisfy its constraint.

3. 2D Vector Helpers

// Minimal Vec2 class for IK
class Vec2 {
  constructor(x = 0, y = 0) { this.x = x; this.y = y; }

  clone()           { return new Vec2(this.x, this.y); }
  add(v)            { return new Vec2(this.x + v.x, this.y + v.y); }
  sub(v)            { return new Vec2(this.x - v.x, this.y - v.y); }
  scale(s)          { return new Vec2(this.x * s, this.y * s); }
  length()          { return Math.sqrt(this.x*this.x + this.y*this.y); }
  distTo(v)         { return this.sub(v).length(); }
  normalize()       {
    const l = this.length();
    return l > 1e-8 ? new Vec2(this.x/l, this.y/l) : new Vec2(0, 0);
  }
  angle()           { return Math.atan2(this.y, this.x); }
}

4. The IK Chain Class

class IKChain {
  /**
   * @param {Vec2} rootPos  — anchor point (does not move)
   * @param {number[]} lengths — bone lengths [L0, L1, ..., L_{N-1}]
   */
  constructor(rootPos, lengths) {
    this.root    = rootPos.clone();
    this.lengths = lengths;
    const N      = lengths.length + 1;  // N+1 joints for N bones
    // Initialise joints in a straight horizontal line
    this.joints  = Array.from({length: N}, (_, i) => {
      const offset = lengths.slice(0, i).reduce((a, b) => a + b, 0);
      return new Vec2(rootPos.x + offset, rootPos.y);
    });
  }

  solve(target, maxIter = 10, tolerance = 0.5) {
    const N    = this.joints.length - 1; // number of bones
    const dist = this.joints[0].distTo(target);
    const totalLen = this.lengths.reduce((a, b) => a + b, 0);

    // Unreachable: extend fully toward target
    if (dist >= totalLen) {
      const dir = target.sub(this.joints[0]).normalize();
      for (let i = 1; i <= N; i++) {
        this.joints[i] = this.joints[i-1].add(dir.scale(this.lengths[i-1]));
      }
      return;
    }

    for (let iter = 0; iter < maxIter; iter++) {
      // --- Backward pass: pull end to target ---
      this.joints[N] = target.clone();
      for (let i = N - 1; i >= 0; i--) {
        const dir = this.joints[i].sub(this.joints[i+1]).normalize();
        this.joints[i] = this.joints[i+1].add(dir.scale(this.lengths[i]));
      }

      // --- Forward pass: anchor root ---
      this.joints[0] = this.root.clone();
      for (let i = 1; i <= N; i++) {
        const dir = this.joints[i].sub(this.joints[i-1]).normalize();
        this.joints[i] = this.joints[i-1].add(dir.scale(this.lengths[i-1]));
      }

      // --- Convergence check ---
      if (this.joints[N].distTo(target) < tolerance) break;
    }
  }
}

5. Adding Angle Constraints

Without constraints, bones can fold in any direction — useful for tentacles, not for legs. The simplest constraint: limit each bone's angle relative to its parent.

// After the backward pass, apply angle constraints to each joint
// minAngle/maxAngle: allowed range relative to parent bone direction
function constrainAngle(joint, parent, grandparent, minAngle, maxAngle) {
  // Parent bone direction
  const parentDir = parent.sub(grandparent);
  const parentAngle = parentDir.angle();
  // Current bone direction
  const currentAngle = joint.sub(parent).angle();
  // Angle relative to parent
  let rel = currentAngle - parentAngle;
  // Wrap to [-π, π]
  while (rel >  Math.PI) rel -= 2 * Math.PI;
  while (rel < -Math.PI) rel += 2 * Math.PI;
  // Clamp
  const clamped = Math.max(minAngle, Math.min(maxAngle, rel));
  const finalAngle = parentAngle + clamped;
  const bone = joint.sub(parent);
  const len  = bone.length();
  return new Vec2(
    parent.x + Math.cos(finalAngle) * len,
    parent.y + Math.sin(finalAngle) * len
  );
}
Constraint quality: Simple angle clamping works well for 2D. For 3D ("cone + twist" constraints), you need to decompose the relative rotation into swing and twist components and clamp separately. Libraries like THREE.js's IKSolver or Kalidokit handle this.

6. Multiple Chains: Spider Legs

class SpiderBody {
  constructor(pos, numLegs = 8) {
    this.pos  = pos.clone();
    this.legs = [];
    this.targets = [];
    this.restPositions = [];

    for (let i = 0; i < numLegs; i++) {
      const angle = (i / numLegs) * Math.PI * 2;
      const rest = this.pos.add(new Vec2(
        Math.cos(angle) * 120, Math.sin(angle) * 60
      ));
      this.restPositions.push(rest);
      this.targets.push(rest.clone());
      const chain = new IKChain(this.pos.clone(), [50, 45, 35]);
      chain.solve(rest);
      this.legs.push(chain);
    }
  }

  update(bodyPos, dt) {
    this.pos = bodyPos.clone();
    for (let i = 0; i < this.legs.length; i++) {
      const restWorld = bodyPos.add(this.restPositions[i].sub(this.pos));
      // Step foot if it's too far from rest position
      const dist = this.targets[i].distTo(restWorld);
      if (dist > 60) {
        const opposite = (i + this.legs.length/2 | 0) % this.legs.length;
        // Stagger: step even legs first, then odd
        if (this.legs[opposite].isMoving === undefined ||
            !this.legs[opposite].isMoving) {
          this.targets[i] = restWorld.clone();
        }
      }
      // Update chain root and re-solve
      this.legs[i].root = this.pos.clone();
      this.legs[i].solve(this.targets[i]);
    }
  }
}

7. Canvas Demo

// Canvas render loop
const canvas = document.getElementById('ik-demo');
const ctx    = canvas.getContext('2d');
let mouse    = new Vec2(400, 200);
canvas.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mouse = new Vec2(e.clientX - r.left, e.clientY - r.top);
});

const chain = new IKChain(new Vec2(80, 170), [80, 70, 60, 50]);

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  chain.solve(mouse);

  // Draw bones
  ctx.strokeStyle = '#fbbf24';
  ctx.lineWidth  = 4;
  ctx.lineCap    = 'round';
  for (let i = 0; i < chain.joints.length - 1; i++) {
    ctx.beginPath();
    ctx.moveTo(chain.joints[i].x, chain.joints[i].y);
    ctx.lineTo(chain.joints[i+1].x, chain.joints[i+1].y);
    ctx.stroke();
  }

  // Draw joints
  ctx.fillStyle = '#fff';
  for (const j of chain.joints) {
    ctx.beginPath();
    ctx.arc(j.x, j.y, 5, 0, Math.PI * 2);
    ctx.fill();
  }

  // Draw target
  ctx.strokeStyle = '#f87171';
  ctx.lineWidth   = 2;
  ctx.beginPath();
  ctx.arc(mouse.x, mouse.y, 8, 0, Math.PI * 2);
  ctx.stroke();

  requestAnimationFrame(draw);
}
draw();

8. Extending to 3D

FABRIK extends naturally to 3D — replace Vec2 with Vec3 and use Vector3 arithmetic. The algorithm is identical; only the vector operations change.

class IKChain3D {
  constructor(root, lengths) {
    this.root    = root.clone();
    this.lengths = lengths;
    const N = lengths.length + 1;
    this.joints = Array.from({length: N}, (_, i) => {
      const offset = lengths.slice(0, i).reduce((a, b) => a + b, 0);
      return root.clone().addScaledVector(new THREE.Vector3(1, 0, 0), offset);
    });
  }

  solve(target, maxIter = 10, tol = 0.01) {
    const N = this.joints.length - 1;
    const totalLen = this.lengths.reduce((a, b) => a + b, 0);
    if (this.joints[0].distanceTo(target) >= totalLen) {
      const dir = target.clone().sub(this.joints[0]).normalize();
      for (let i = 1; i <= N; i++)
        this.joints[i] = this.joints[i-1].clone().addScaledVector(dir, this.lengths[i-1]);
      return;
    }
    for (let it = 0; it < maxIter; it++) {
      this.joints[N].copy(target);
      for (let i = N-1; i >= 0; i--) {
        const dir = this.joints[i].clone().sub(this.joints[i+1]).normalize();
        this.joints[i] = this.joints[i+1].clone().addScaledVector(dir, this.lengths[i]);
      }
      this.joints[0].copy(this.root);
      for (let i = 1; i <= N; i++) {
        const dir = this.joints[i].clone().sub(this.joints[i-1]).normalize();
        this.joints[i] = this.joints[i-1].clone().addScaledVector(dir, this.lengths[i-1]);
      }
      if (this.joints[N].distanceTo(target) < tol) break;
    }
  }
}
Three.js integration: Once you have the world-space joint positions from the IKChain3D, update your Three.js Bone objects: for each i, set bones[i].position to the local position (transform by the parent's inverse world matrix). Three.js's SkeletonHelper can visualise the result.