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.
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:
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.
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
);
}
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;
}
}
}
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.