Skeletal Animation & Skinning
Your character mesh loads and renders, but it's a statue: every vertex is frozen in the pose it was modeled in. Make it walk naively and the mesh explodes into a tangle, pinches to a point at the wrist, or shrinks mid-blend. Skeletal animation binds each vertex to a few joints, animates the joints, and deforms the mesh to follow. The data structure is a tree of transforms, the central equation is the skinning matrix (animated joint times inverse bind), and the failure modes (the candy-wrapper twist, lerping the wrong way around a rotation) are where implementations go wrong.
- Walk a joint hierarchy parent-first to get every joint's model-space transform.
- Build the skinning-matrix palette from animated joints and inverse bind matrices, in the right order.
- Skin vertices on the GPU with linear blend skinning, and recognize the candy-wrapper and when dual quaternions help.
- Sample and blend animation clips with quaternion slerp or nlerp, in local space.
- Load a glTF skin without the classic indexing bugs, and solve two-bone IK with the law of cosines.
01Why skeletal animation
An animator wants the character to walk, run, crouch and wave, each keyed at 30 poses a second. Authoring a separate mesh per animation frame doesn't scale (that's vertex animation, which suits crowds; see the VAT tutorial). Skeletal animation instead stores a small skeleton of joints, animates the joints over time, and computes each vertex's deformed position from the joints it's attached to. A 60-joint skeleton drives a 50,000-vertex character; you animate 60 transforms, not 50,000 positions.
Worked example
Store each joint's pose as translation (3 floats), rotation (a 4-float quaternion) and scale (3 floats): 60 joints × 10 floats × 4 bytes = 2,400 bytes per frame. The same frame as raw vertex positions is 50,000 × 12 bytes = 600,000 bytes, 250 times more. A one-second clip at 30 frames a second is 72 KB of joint data against 18 MB of positions.
Skeleton (a joint tree) → bind pose + inverse bind matrices (the mesh's rest state) → a per-vertex skinning matrix (animated joint × inverse bind) → a deformation method (linear blend or dual-quaternion) → animation clips (keyframes sampled over time) → blending (mixing clips) → the GPU (a matrix palette, skinned in the vertex shader). This tutorial builds it in that order: structure, then deformation, then time, then hardware.
02The skeleton
Bend the elbow and the forearm has to follow it, the hand has to follow the forearm, and the fingers have to follow the hand. A skeleton is a tree of joints, each storing a local transform (translation, rotation, scale) relative to its parent. A joint's model-space (global) transform is its parent's global transform times its local, walking from the root: the same scene-graph propagation as in Going 3D[1].
Worked example
A shoulder sits at (0, 1.5, 0) m, and the elbow's local transform puts it 0.3 m along the shoulder's x axis. Rotate the shoulder 90° about z and the elbow's global position becomes the shoulder's position plus that rotated offset: (0, 1.5, 0) + (0, 0.3, 0) = (0, 1.8, 0). The arm now points straight up, and nobody touched the elbow's local transform.
The rule that produced that position, for every joint:
In words: a joint's place in the model is its parent's place times its own local transform. Walk the skeleton parent-first and each joint just multiplies onto its parent, which is why the root has to be processed before its children. Hover any symbol to see what it stands for.
glTF (Khronos's open 3D asset format) and most math call the node a joint; a "bone" loosely means the visible segment between a joint and its parent. The transform lives on the joint. And a parent's global transform must be computed before its children's: walk the node tree (or sort so parents precede children) rather than iterating the joint array in storage order, or a child reads a stale parent. The skeleton can have any number of joints; that's separate from the per-vertex influence cap (§4).
03The inverse bind matrix
The modeler built the arm sticking straight out. To bend it, you first need each vertex's position measured from the joint that will move it, instead of from the model's origin. Mesh vertices are authored once, in model space, in the (the rest pose the mesh was modeled in, often a T-pose). The for joint j is the inverse of that joint's global transform in the bind pose. It maps a vertex from model space into joint j's local space, so the joint's animated transform can then carry it to its deformed position.
Worked example
In the bind pose the elbow sits at (0.3, 1.5, 0), and a forearm vertex at (0.4, 1.5, 0). The elbow's inverse bind matrix subtracts (0.3, 1.5, 0), leaving (0.1, 0, 0): 10 cm along the forearm. Now raise the arm as in §2, so the elbow is at (0, 1.8, 0) and turned 90°. The 10 cm offset turns into (0, 0.1, 0), and the vertex lands at (0, 1.9, 0), still 10 cm along the forearm. Skip the inverse bind and the animated elbow transform turns the whole model-space position instead: (0, 1.8, 0) + (−1.5, 0.4, 0) = (−1.5, 2.2, 0), a meter and a half off to the side.
The matrix that does that subtraction, for any joint, is the inverse of the joint's bind-pose transform:
In words: before re-posing a vertex you have to cancel out the joint's rest pose, or the bind transform gets counted twice. Inverting the joint's bind-pose global transform gives the matrix that carries a model-space vertex into that joint's local space, ready for the animated transform to take over.
Forgetting the inverse bind (or applying it on the wrong side) makes the mesh explode the instant animation starts, because the animated joint transform gets applied to a vertex that's already in model space, double-counting the bind pose. glTF stores inverseBindMatrices directly (one per joint); use the stored values rather than recomputing them with a convention mismatch. The whole skinning idea is: undo the bind pose, then redo the current pose. Keep globalBindTransform (fixed) and globalPose (animated) clearly separate.
04The skinning matrix
A vertex in the crease of the elbow belongs partly to the upper arm and partly to the forearm; give it to either one alone and the skin tears or folds at the joint. Each vertex is bound to up to N joints (glTF: 4 per set) with weights that sum to 1, each weight saying how much that joint pulls on the vertex. The skinning matrix is the weighted sum of each influencing joint's skinning transform = jointGlobalAnimated[j] · inverseBind[j].
Worked example
Keep the shoulder still and bend only the elbow 90°. A vertex just past the elbow, at (0.35, 1.5, 0), has weight 0.5 on each joint. The upper arm hasn't moved, so its skinning matrix leaves the vertex at (0.35, 1.5, 0). The forearm's turns the vertex's 5 cm offset from the elbow upward, to (0.3, 1.55, 0). The blend is the average, (0.325, 1.525, 0): the crease rounds off between the two bones. Halve both weights so they sum to 0.5 and the result is (0.1625, 0.7625, 0), pulled halfway to the model's origin.
Written out, the weighted blend for one vertex is:
In words: for every joint that influences the vertex, undo that joint's rest pose (inverseBind), apply where the joint is now (jointGlobalAnimated), and scale by its weight. Σi sums those weighted matrices, and the blend transforms the vertex. Because the weights sum to 1, a vertex at the knee bends smoothly between thigh and shin instead of tearing.
The order is jointGlobalAnimated · inverseBind: the inverse bind on the right (applied first, to a model-space vertex), the animated global on the left. Weights must be non-negative and sum to 1: weights summing to s scale the skinned position by s about the model-space origin. 4 influences is glTF's per-set cap, not a universal law; more needs a second JOINTS_1/WEIGHTS_1 set[2]. And transform the normal too, by the skinning matrix's inverse-transpose under non-uniform scale (the 3D Math rule); the cheap normalize(mat3(skin) · normal) path is exact only when the joints carry no non-uniform scale.
The widget skins a two-joint limb with exactly that blend:
// joints sorted so a parent always precedes its children
for (size_t joint = 0; joint < jointCount; ++joint) {
int parent = parentIndex[joint]; // -1 for the root
glm::mat4 local = localPose[joint].toMatrix(); // animated TRS this frame
globalPose[joint] = (parent < 0) ? local
: globalPose[parent] * local; // parent.global * local
}
for (size_t joint = 0; joint < jointCount; ++joint)
skinningMatrix[joint] = globalPose[joint] * inverseBind[joint]; // ORDER: animated * invBind
// joints sorted so a parent always precedes its children
for joint in 0..joint_count {
let parent = parent_index[joint]; // -1 (i32) for the root
let local = local_pose[joint].to_matrix(); // animated TRS this frame
global_pose[joint] = if parent < 0 { local }
else { global_pose[parent as usize] * local }; // parent.global * local
}
for joint in 0..joint_count {
skinning_matrix[joint] = global_pose[joint] * inverse_bind[joint]; // ORDER: animated * inv_bind
}
05LBS & DQS
Twist a character's wrist toward 180° and, with the standard skinning math, the forearm pinches to a point like the twisted end of a candy wrapper. (LBS, a.k.a. matrix palette skinning) is the equation above verbatim: blend the joint matrices by the weights, then transform. It's the most common method in real-time rendering[4] and the one glTF's skinning definition uses: a linear weighted sum of joint matrices[2]. But a weighted sum of matrices is not a rigid transform, so it has two artifacts:
- Candy-wrapper (twist): at a joint twisted toward 180°, the blend collapses volume. With one rotation the identity and the other a 180° twist,
0.5·I + 0.5·Ris a rank-1 matrix that projects space onto the axis, pinching the cross-section to a point[6]. - Volume loss (bend): at a sharply bent elbow, linearly interpolating between two rotated positions cuts the corner, shrinking the inner volume.
Worked example
A forearm 5 cm in radius, twisted at the wrist, with a vertex halfway along weighted 50/50 between elbow and wrist. LBS scales that cross-section by cos(twist ÷ 2): at a 90° twist the radius drops to 3.5 cm, at 150° to 1.3 cm, and at 180° to zero. Dual-quaternion skinning keeps it at 5 cm throughout.
(DQS, Kavan et al.) blends rigid transforms instead of matrices, so it preserves volume through twists with no candy-wrapper, at cost comparable to LBS[5]. But DQS has its own artifact, joint bulging on sharp bends (it over-preserves volume), it can't represent non-uniform scale or shear, and it needs antipodality handling (the q vs −q double cover from 3D Math). LBS stays the default; the common production fix for its artifacts is twist helper joints (extra forearm joints that each take part of the twist) and corrective blend shapes (sculpted fixes that fade in at certain joint angles)[4], not necessarily a switch to DQS.
The widget twists a forearm under both methods:
06The animation clip
An animator keys the arm at 0.0 s and 0.5 s, and the game needs a pose every 16.7 ms in between. A clip is a set of per-joint keyframe tracks, separate tracks for translation, rotation, and scale (glTF animation channels target exactly these three node properties, plus morph-target weights[2]). Sampling at time t: find the bracketing keyframe pair, compute the local parameter, and interpolate. Rotation interpolates with quaternion slerp or nlerp (slerp swings along the rotation's arc at constant speed; nlerp averages the four numbers and rescales them to length 1); translation and scale lerp.
Worked example
A joint's rotation is keyed at 0° at 0.0 s and 90° at 0.5 s, about the same axis. At 0.2 s the local parameter is 0.2 ÷ 0.5 = 0.4. Slerp gives 0.4 × 90° = 36°; nlerp gives 35.5°, half a degree off. If the joint's translation goes from 0 to 1 m over the same keys, it's at 0.4 m.
Euler lerp takes a wrong arc and hits gimbal-lock degeneracies; component-wise matrix lerp gives a non-rigid in-between (the same problem as LBS). Quaternion slerp/nlerp is the correct cheap path[8]. Blow frames the slerp-vs-nlerp trade as three properties, of which each method gets two: slerp is constant-velocity and torque-minimal but not commutative; nlerp is commutative and torque-minimal but not constant-velocity. Between two close keyframes the two are nearly identical; for blending several poses (§7), nlerp's commutativity is the property that matters. And check the quaternion dot sign and negate one before interpolating (the double cover), or you go the long way around.
Transform sampleTrack(const Track& track, float time) {
auto [k0, k1, alpha] = track.bracket(time); // bracketing keys + [0,1] factor
Transform out;
out.translation = glm::mix(k0.translation, k1.translation, alpha); // lerp position
out.scale = glm::mix(k0.scale, k1.scale, alpha); // lerp scale
out.rotation = glm::slerp(k0.rotation, k1.rotation, alpha); // SLERP rotation
return out; // glm::slerp handles the shortest path
}
fn sample_track(track: &Track, time: f32) -> Transform {
let (k0, k1, alpha) = track.bracket(time); // bracketing keys + [0,1] factor
Transform {
translation: k0.translation.lerp(k1.translation, alpha), // lerp position
scale: k0.scale.lerp(k1.scale, alpha), // lerp scale
rotation: k0.rotation.slerp(k1.rotation, alpha), // SLERP (glam: shortest path)
}
}
07Blending
The character walks at 1.4 m/s and runs at 4 m/s, and the player has the stick halfway. Neither clip is right; a mix of the two is. Blending combines poses per joint, in local space, then recomputes globals[1]. A cross-fade interpolates each joint's local rotation by slerp and translation by lerp over the fade. Blend trees parametrically mix clips by 1D (speed) or 2D (speed × direction) parameters; Unreal calls them Blend Spaces and Unity calls them Blend Trees. Additive blending applies a pose difference (a clip authored relative to a reference) on top of a base, for aim offsets and hit reactions layered over locomotion.
Worked example
A blend weight of 0.3 toward run, with the thigh's local rotation at 20° in the walk and 40° in the run about the same axis, gives 20° + 0.3 × 20° = 26°. An additive aim offset authored as +15° from its reference pose then composes on top, to 41°, whether the base pose came from the walk, the run or a crouch.
Don't blend global joint matrices: that gives non-rigid, broken in-betweens (the matrix-averaging problem again). Blend local TRS per joint (slerp the rotations), then walk the hierarchy. Additive is base + delta per joint (compose the delta rotation, add the delta translation), not a second clip played on top; that's why one aim-offset clip composes over walk, run, and crouch without authoring every combination.
The widget blends one joint between two poses with three interpolation methods:
08The GPU side & glTF
The CPU knows where every joint is this frame, and the vertices live on the GPU, so the joint data has to cross over every frame. Each frame the CPU builds the matrix palette (one skinning matrix per joint) and uploads it; the vertex shader reads the per-vertex joint indices and weights, sums the four palette matrices, and transforms position and normal. Here skinning happens in the vertex shader; some engines skin in a compute pass instead and reuse the skinned vertices across render passes.
Worked example
A 100-joint character's palette is 100 × 64 bytes = 6,400 bytes, comfortably inside a 16 KiB uniform buffer, which holds 256 matrices. A thousand such characters in one storage buffer is 6.4 MB to rebuild and upload every frame, 384 MB/s at 60 fps, on top of the CPU time to compute every pose.
Vulkan's guaranteed-minimum maxUniformBufferRange is 16 KiB through Vulkan 1.3 (1.4 raises it to 64 KiB), and a mat4 is 64 bytes, so a portable UBO (uniform buffer, a small read-only constant buffer) palette holds 256 matrices[11]. Fine for one ~100-joint skeleton, but many skinned instances in one buffer need an SSBO (storage buffer, guaranteed up to 128 MiB), indexed by instanceID · jointCount + joint. This is the same UBO-vs-SSBO trade as in Going 3D. At crowd scale the per-character CPU work of building all those palettes is what VAT removes.
#version 450
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_normal;
layout(location = 2) in uvec4 in_jointIndices; // glTF JOINTS_0: unsigned, so a *_UINT vertex format
layout(location = 3) in vec4 in_jointWeights; // glTF WEIGHTS_0 (sum to 1)
layout(std430, set = 0, binding = 0) readonly buffer Palette {
mat4 skinningMatrix[]; // globalPose[j] * inverseBind[j], one per joint
} palette;
layout(std140, set = 0, binding = 1) uniform Camera {
mat4 viewProj; // assumes the joint globals include the character's world transform
} camera;
layout(location = 0) out vec3 out_normal;
void main() {
mat4 skin = in_jointWeights.x * palette.skinningMatrix[in_jointIndices.x]
+ in_jointWeights.y * palette.skinningMatrix[in_jointIndices.y]
+ in_jointWeights.z * palette.skinningMatrix[in_jointIndices.z]
+ in_jointWeights.w * palette.skinningMatrix[in_jointIndices.w]; // LBS
vec4 skinnedPos = skin * vec4(in_position, 1.0);
vec3 skinnedNrm = normalize(mat3(skin) * in_normal); // rigid approx; invT under non-uniform scale
gl_Position = camera.viewProj * skinnedPos;
out_normal = skinnedNrm;
}
A glTF skin has a joints array (node indices), an inverseBindMatrices accessor (one mat4 per joint, same order), and an optional skeleton root hint (it doesn't change the math)[2]. The skinned primitive carries JOINTS_0 (a VEC4 of indices into skin.joints, not into the node array; mixing the two is a classic bug) and WEIGHTS_0. The skinned mesh node's own transform is ignored: the joint matrix is globalJoint · inverseBind, and the joints' global transforms already place the mesh in the scene[3]. (Older versions of the Khronos tutorial also multiplied by inverse(meshNodeGlobal) on the left, which cancels the mesh node's transform when the mesh is drawn with it.) LearnOpenGL's shader uses the same cheap mat3 cast for normals[7].
09IK & root motion
The walk cycle was authored on flat ground, and now the character is on stairs: one foot floats above a step and the other sinks through it. Forward kinematics computes positions from joint angles (everything above). Inverse kinematics is the reverse: given a target (put the foot on the ground, the hand on the doorknob), find the joint angles that reach it. IK modifies the pose before the skinning palette is built.
- Two-bone (limb) IK is analytic: the interior elbow angle comes from the law of cosines,
acos((l1² + l2² − D²) / (2·l1·l2))for target distance D, then orient the triangle toward the target[9]. Clamp D to the reachable range first, oracosgets an argument outside [−1, 1]. The law of cosines fixes the elbow angle but not the plane: the triangle can spin freely about the shoulder-to-target line, so a pole (bend) vector picks which way the elbow points. Take a 0.3 m thigh, a 0.25 m shin and an ankle target 0.4 m from the hip. The argument is (0.09 + 0.0625 − 0.16) ÷ 0.15 = −0.05, so the knee's interior angle is 92.9°. A target 0.6 m away gives −1.38, outside acos's range; clamped to the 0.55 m reach, the leg straightens to 180°. - General chains use iterative solvers: FABRIK places joints along lines, first forward from the end effector (set to the target) in to the base, then backward from the base (reset to its anchor) out to the tip, converging in few iterations with no rotation matrices or angles[10]; CCD rotates one joint at a time from the end inward.
In-place: the root stays at the origin and gameplay code moves the character. Root motion: the animation's root bone translates within the clip, and the engine drives the character's world transform from that root delta, so the visual stride matches the displacement[12]. A common cause of foot sliding is an in-place clip whose authored stride speed differs from the code-driven movement speed (a clip authored at 1.4 m/s moved at 1.6 m/s slides its planted foot 3.3 mm every 60 Hz frame); root motion removes that mismatch, and foot-locking IK handles other sources. Root motion has costs too: the animation, not gameplay code, now decides movement, and networked games have to replicate or predict it. Many games therefore keep locomotion in place and use root motion for discrete actions such as attacks and vaults.
Wrong answers, and why: an exploding mesh on playback is the inverse-bind order/omission (not shader stage or UBO size); and mid-blend shrinkage is wrong rotation interpolation / blending globals (not the blend factor, and not a DQS issue).
10Pitfalls
11What's next
Characters now move. The next module, Blending, State Machines & IK, covers everything upstream of the palette: animation state machines, 2D blend spaces, layered and additive animation, motion matching, and IK in depth. After that the series moves on to physics and the gameplay layer; the full path is on the series hub.
- Jason Gregory. Game Engine Architecture, 3rd ed., ch. 12 (Animation Systems): skeletons, poses, clips, the matrix palette, and blending. gameenginebook.com. The canonical engine treatment.
- The Khronos Group. glTF 2.0 Specification, §3.7.3 Skins. registry.khronos.org. The skinning data model (
joints,inverseBindMatrices,JOINTS_n/WEIGHTS_nsets, the weight rules), the linear weighted sum of joint matrices, and the animation channel targets. - The Khronos Group. glTF Tutorial, "Skins." github.khronos.org. The joint-matrix formula (global joint transform times inverse bind), the GLSL weighted sum of four joint matrices, and that the skinned mesh node's transform is ignored.
- Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., §4.4 Vertex Blending. realtimerendering.com. LBS as the most common method; the weighted-average formula; DQS and corrective shapes.
- Ladislav Kavan, Steven Collins, Jiří Žára, Carol O'Sullivan. "Geometric Skinning with Approximate Dual Quaternion Blending." ACM TOG 2008. users.cs.utah.edu. DQS preserves rigidity (no candy-wrapper) at LBS-comparable cost, with a slight bulge at joints.
- Alec Jacobson, Zhigang Deng, Ladislav Kavan, J.P. Lewis. "Skinning: Real-time Shape Deformation." SIGGRAPH 2014 Course. skinning.org. Part I: averaging the identity with a 180° rotation gives a rank-1 projection onto the axis, which collapses the cross-section to a point (the candy-wrapper); DQS's limits with non-uniform scale and shear.
- Ankit Singh Kushwah. LearnOpenGL guest article, "Skeletal Animation" (2020). learnopengl.com. A hands-on vertex-shader skinning walkthrough (bone IDs, weights, the offset/inverse-bind matrix).
- Jonathan Blow. "Understanding Slerp, Then Not Using It." number-none.com. The slerp/nlerp property triad: constant-velocity vs commutative vs torque-minimal.
- Daniel Holden. "Simple Two Joint IK." theorangeduck.com. Closed-form two-bone IK via the law of cosines, with the bend-axis vector.
- Andreas Aristidou and Joan Lasenby. "FABRIK: A fast, iterative solver for the Inverse Kinematics problem." Graphical Models 2011. andreasaristidou.com. The forward/backward point-on-line iterative chain solver.
- The Khronos Group. Vulkan Specification, Required Limits. docs.vulkan.org.
maxUniformBufferRangeguaranteed minimum 16384 bytes (256 mat4) in core Vulkan, 65536 in Vulkan 1.4;maxStorageBufferRangeminimum 227 bytes (128 MiB). - Unity Technologies. "How Root Motion works." docs.unity3d.com. Root motion as a per-frame change in the root transform applied to the GameObject.