Three.js From Zero · Article s3-01

Skeletal Animation Internals

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

Skeletal Animation Internals

Season 2 ended with procedural worlds. Season 3 goes inside the characters that live in them. A rigged character is the single most complex primitive in real-time 3D — it's a mesh whose vertices are attached to an invisible skeleton, each vertex weighted across multiple bones, the bones arranged hierarchically so rotating a shoulder carries the arm and hand with it.

In S1-05 we loaded glTF characters and trusted them to "just animate." Today we build one from scratch. A cylinder with bones. No artist, no DCC tool — just code. By the end you'll understand every term in the glTF skin spec and be able to debug rigs that misbehave.

loading…

The five pieces of a skinned mesh

PieceWhat it is
MeshThe visible geometry (vertices + faces)
BonesTree of Object3Ds; just empties with parent/child relationships
SkeletonThree.js's container tying bones + inverse bind matrices together
skinIndexPer-vertex attribute: which (up to 4) bones affect this vertex
skinWeightPer-vertex attribute: how much each of those bones affects it (weights sum to 1)

The mesh is a THREE.SkinnedMesh — a subclass of Mesh that knows about the skeleton.

The math in one paragraph

For each vertex, the GPU does: for each of my (up to) 4 bones, transform my rest position by (that bone's current world matrix × that bone's inverse bind matrix), multiply by my weight to that bone, sum them all up. The result is the vertex's deformed position in world space.

// The skinning formula in plain code:
function skinVertex(vRest, bones, indices, weights) {
  const out = new Vector3();
  for (let i = 0; i < 4; i++) {
    const boneIndex = indices[i];
    const weight = weights[i];
    if (weight > 0) {
      const bone = bones[boneIndex];
      const skinMatrix = bone.matrixWorld.clone().multiply(bone.inverseBindMatrix);
      const transformed = vRest.clone().applyMatrix4(skinMatrix);
      out.addScaledVector(transformed, weight);
    }
  }
  return out;
}

That's the entire idea. The GPU vertex shader does this automatically when you use THREE.SkinnedMesh with a MeshStandardMaterial. But understanding it makes everything else clear.

Bind pose + inverse bind matrix

Here's the clever part that trips everyone up. When the artist rigs the mesh, the skeleton is in a specific pose — the bind pose (usually T-pose). The weights and indices are painted relative to THIS pose.

When you animate, bones move. To compute a vertex's new position, the GPU needs to:

  1. Undo the vertex's offset from the bind pose bone
  2. Apply the bone's current transform

That's what inverse bind matrices store — the inverse of each bone's world matrix AT BIND TIME. Multiply by that first (strips bind pose out), then by the current bone world matrix (applies the new pose).

skinMatrix = boneCurrentWorld × inverseBindMatrix

// where inverseBindMatrix = inverse(boneBindPoseWorld)

When you build a skeleton in code, you compute inverse bind matrices by calling skeleton.calculateInverses() or letting new THREE.Skeleton(bones) do it automatically from the bones' current matrices.

Building a skinned mesh from scratch

This is what the demo does. A cylinder, subdivided vertically. Bones as a chain along its length. Weights distributed so each ring of vertices blends between two bones.

// 1. Geometry
const geom = new THREE.CylinderGeometry(0.3, 0.3, 4, 16, 16);

// 2. Bones — a chain up the Y axis
const BONE_COUNT = 4;
const BONE_LEN = 4 / BONE_COUNT;
const bones = [];
for (let i = 0; i < BONE_COUNT; i++) {
  const bone = new THREE.Bone();
  // Each bone is placed BONE_LEN above its parent
  if (i === 0) bone.position.y = -2;        // bottom of cylinder
  else        bone.position.y = BONE_LEN;    // relative to parent
  if (i > 0) bones[i - 1].add(bone);
  bones.push(bone);
}

// 3. skinIndex + skinWeight per vertex
const positions = geom.attributes.position;
const skinIndex  = new Uint16Array(positions.count * 4);
const skinWeight = new Float32Array(positions.count * 4);

for (let i = 0; i < positions.count; i++) {
  const y = positions.getY(i);
  // Map y (-2 to +2) into bone index space
  const t = (y + 2) / 4 * BONE_COUNT;
  const lower = Math.floor(t);
  const upper = Math.min(BONE_COUNT - 1, lower + 1);
  const blend = t - lower;

  skinIndex[i * 4 + 0] = lower;
  skinIndex[i * 4 + 1] = upper;
  skinWeight[i * 4 + 0] = 1 - blend;
  skinWeight[i * 4 + 1] = blend;
}
geom.setAttribute('skinIndex',  new THREE.BufferAttribute(skinIndex,  4));
geom.setAttribute('skinWeight', new THREE.BufferAttribute(skinWeight, 4));

// 4. Skeleton + mesh
const skeleton = new THREE.Skeleton(bones);
const mesh = new THREE.SkinnedMesh(geom, material);
mesh.add(bones[0]);             // root bone must be a child of the mesh
mesh.bind(skeleton);            // compute inverse bind matrices from current pose

Now change bones[2].rotation.z = 0.5 and the top of the cylinder bends — automatically, on the GPU, because the vertex shader interpolates between bones based on weights.

Weights sum to 1 — the golden rule

Each vertex's weights across its 4 bones MUST sum to 1. If they don't:

  • Sum < 1 → vertex shrinks toward origin (too little transform applied)
  • Sum > 1 → vertex overshoots (too much transform)

glTF exporters normalize for you. When building by hand, normalize:

function normalizeWeights(weights) {
  const s = weights[0] + weights[1] + weights[2] + weights[3];
  if (s > 0) {
    weights[0] /= s; weights[1] /= s; weights[2] /= s; weights[3] /= s;
  }
}

4 bones per vertex — the hardware limit

GPUs have a practical cap of 4 bone influences per vertex (stored in a vec4). That's why skinIndex + skinWeight have stride 4. If a vertex should be influenced by more than 4 bones:

  1. Rank all bone influences on that vertex
  2. Keep the 4 largest
  3. Drop the rest, normalize the 4 remaining

For high-end characters you can push to 8 via custom shader (two skinIndex / skinWeight attributes), but 4 is the baseline every engine uses.

Bone hierarchy = parent/child

Bones are just Object3Ds. The hierarchy is set with parent.add(child). This gives you forward kinematics for free — rotating a shoulder rotates all descendants in world space.

spine.add(upperArm);      // arm follows spine
upperArm.add(forearm);    // forearm follows upper arm
forearm.add(hand);        // hand follows forearm

spine.rotation.y = 0.5;   // all four rotate together

The bone has no visual. It's an empty. You visualize the skeleton with new THREE.SkeletonHelper(mesh) to see where bones are.

Posing bones — the right way

Set bone.position, bone.rotation, or bone.quaternion. Three.js updates matrices automatically on render (or you can force with bone.updateMatrixWorld(true)).

Don't set bone.matrix directly — the matrix is recomputed from position/rotation/scale each frame. Changes to matrix are overwritten.

// Good
bones[2].rotation.z = angleInRadians;

// Bad — gets overwritten
bones[2].matrix.makeRotationZ(angleInRadians);

Visualizing the skeleton

const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);

// Make it more visible:
helper.material.linewidth = 2;   // varies by platform
helper.material.depthTest = false;    // always draw on top
helper.material.transparent = true;
helper.material.opacity = 0.8;

Toggle show skeleton in the demo to overlay bones on the skinned cylinder. Watch which vertices bend with which bones as you move the "bend" slider.

Reading a glTF's skeleton

A glTF import gives you everything wired up. But it's useful to know how to find the bones:

gltf.scene.traverse((o) => {
  if (o.isSkinnedMesh) {
    console.log('skinned mesh', o.name);
    console.log('bones', o.skeleton.bones.map(b => b.name));
    console.log('bind matrices', o.skeleton.boneInverses);
  }
});

// Grab a bone by name for IK / procedural anim
const head = skinnedMesh.skeleton.getBoneByName('Head');
head.rotation.x = 0.3;   // nod

Per-vertex weight painting (the workflow)

In Blender: switch to Weight Paint mode, select a bone, paint with a brush on the mesh to assign weight. Red = 1 (full influence), blue = 0. Done for every bone.

glTF export preserves this as the WEIGHTS_0 / JOINTS_0 accessors in the file. Three.js maps these directly to skinWeight / skinIndex.

Common first-time pitfalls

  • Mesh deforms weirdly. Weights don't sum to 1. Normalize.
  • Skeleton appears at wrong location. Root bone not added as mesh child. mesh.add(bones[0]).
  • Bones move but mesh stays still. Forgot mesh.bind(skeleton) OR called bind() before bones were positioned correctly.
  • Inverse bind matrices look wrong. You set bone transforms AFTER calling bind(). Order: position bones → bind → then animate.
  • SkeletonHelper shows wireframe but doesn't move. Helper is cached; update: helper.update() each frame if you're moving bones programmatically.
  • Character has floppy geometry. Vertex has all weights on one bone (no blending). Paint gradient weights near joints.

Exercises

  1. Add a twist bone: between two bones, insert a third that twists around the local Y axis. Use it to split shoulder rotation from elbow bend.
  2. Smooth weight falloff: use a cosine curve instead of linear for the cylinder's weight blend. Compare the deformation shape.
  3. Dual-quaternion skinning: the default is linear-blend skinning (LBS) which collapses at extreme twists. Implement DQS for realistic shoulders. Research topic.

What's next

Article S3-02 — Morph Targets & Facial Animation. The other half of character deformation: blend shapes. Lip sync, facial expressions, and the math behind viseme-driven animation.