Three.js From Zero · Article s3-01
Skeletal Animation Internals
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.
The five pieces of a skinned mesh
| Piece | What it is |
|---|---|
| Mesh | The visible geometry (vertices + faces) |
| Bones | Tree of Object3Ds; just empties with parent/child relationships |
| Skeleton | Three.js's container tying bones + inverse bind matrices together |
| skinIndex | Per-vertex attribute: which (up to 4) bones affect this vertex |
| skinWeight | Per-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:
- Undo the vertex's offset from the bind pose bone
- 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:
- Rank all bone influences on that vertex
- Keep the 4 largest
- 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 calledbind()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
- 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.
- Smooth weight falloff: use a cosine curve instead of linear for the cylinder's weight blend. Compare the deformation shape.
- 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.