You already have three of the four pieces this chapter needs. The transform chapter showed you how a parent's world matrix and a child's local matrix combine, one mul() at a time, to place a hierarchy of objects in the world, and the rule "model = T * R * S, scale first, rotate second, translate last." The quaternion chapter gave you a clean way to store an orientation as (w, x, y, z), combine rotations with quatMultiply, and smoothly blend between two orientations with quatSlerp. The rigging chapter introduced the idea of a skeleton bound to a mesh, and bone weights that say how much each bone should drag each vertex along. This chapter is where all of that becomes an actual moving, skinned character. By the end you will know exactly what happens, matrix by matrix, between "here is a walk animation" and "here is a mesh bending its knees on screen."
Picture the most direct way to animate a character: for every frame of the walk cycle, store the position of every single vertex in the mesh. A "keyframe" would just be a full snapshot of the whole mesh. This is called vertex animation (or morph-target animation when you blend between a small number of these full snapshots), and it is genuinely simple to understand.
It falls apart at scale. A character mesh with 10,000 vertices, animated at 30 keyframes per second for a 2-second walk cycle, needs 10,000 * 60 * 3 floats just for that one clip — over a million numbers, for one animation, on one character. Want a run cycle too? Store another full copy. Want the walk and the run to blend smoothly into each other when the player starts sprinting? There is no clean way to blend two entire vertex snapshots that both came from independently authored data — you would be interpolating raw, disconnected numbers with no idea which vertex on the mesh they even belong to as it deforms.
Skeletal animation fixes both problems by animating something much smaller than the mesh: a skeleton, a stripped-down set of maybe 30 to 150 bones (also called joints — the two words mean the same thing here). Only the bones move. The mesh's actual vertices stay fixed relative to the bones they are attached to, and follow automatically. One skeleton, a handful of small animation clips, and one mesh bound to it once — that combination replaces a separate full-mesh snapshot for every single pose you will ever need.
The rest of this chapter answers one question in stages: given a skeleton and an animation clip, how do we turn a handful of small bone numbers into the final position of every vertex on screen, sixty times a second?
You already know that a Transform stores a position, rotation, and scale, and that a child's world matrix comes from multiplying its parent's world matrix by its own local matrix. A skeleton is exactly that idea, applied to bones instead of scene objects: it is a tree of transforms, where each bone's own position and rotation are stored relative to its parent bone, not relative to the world.
This is not a coincidence, and it is not a new concept — it is the transform hierarchy you already know, wearing a different hat. Rotate a shoulder bone, and every bone below it in the tree — upper arm, forearm, hand, fingers — sweeps along with it automatically, for the exact same reason that moving a parent GameObject drags every child along in Unity. That is the entire point of using a hierarchy instead of a flat list of independent bones: you almost never want to move a hand without the arm coming with it.
In code, each bone stores its parent by index into an array, not by pointer — the same reasoning you already used for array-based data structures back in the C chapters: an index is trivial to copy, easy to save straight into a file, and does not carry pointer-ownership headaches. The root bone (usually the hips or pelvis) has no parent, marked with -1.
struct Bone {
std::string name;
int parentIndex; // -1 for the root bone, otherwise an index into Skeleton::bones
Mat4 inverseBindMatrix; // explained in Section 6 -- ignore it for now
};
struct Skeleton {
std::vector<Bone> bones; // parents ALWAYS appear before their children in this array
};
bones[i].parentIndex < i for every bone — a parent's world matrix must already be computed before you try to use it to compute a child's. Every real animation exporter (FBX, glTF, Unity's own rig importer) guarantees this ordering for exactly this reason. If you ever hand-build a skeleton yourself, sort it this way first.A skeleton by itself is just a rest shape — it does not move on its own. An animation clip ("Walk", "Idle", "Jump") is the data that moves it: a fixed duration in seconds, and for every bone in the skeleton, a track of keyframes — timestamped snapshots of that one bone's local position and rotation.
The word "key" is doing real work here. An artist does not hand-place a value for every single bone on every single frame — that would be almost as wasteful as the vertex animation from Section 1. Instead they set a handful of key poses at the moments that matter (foot plants the ground, hand reaches full extension) and let the computer fill in everything between two keys by interpolation, which is exactly Section 4's job. A bone that barely moves for a whole second of the clip might only need two keys, one at each end of that stretch.
struct PositionKey { float time; Vec3 value; };
struct RotationKey { float time; Quat value; };
struct BoneTrack {
std::vector<PositionKey> positions;
std::vector<RotationKey> rotations;
};
struct AnimationClip {
float duration; // total length in seconds
std::vector<BoneTrack> tracks; // tracks[i] drives skeleton.bones[i]
};
As a tiny concrete example, here is a plausible rotation track for a "RightElbow" bone bending during one punch, using quatFromAxisAngle exactly as defined in the quaternion chapter:
BoneTrack elbowTrack;
elbowTrack.rotations = {
{ 0.0f, quatFromAxisAngle(Vec3{0,0,1}, 0.0f) }, // 0.0s: straight arm
{ 0.2f, quatFromAxisAngle(Vec3{0,0,1}, 100.0f * DEG2RAD) }, // 0.2s: elbow bent back
{ 0.4f, quatFromAxisAngle(Vec3{0,0,1}, 10.0f * DEG2RAD) }, // 0.4s: arm snaps forward
};
(No output to run yet — this is still just a data shape, three (time, Quat) pairs sitting in a std::vector. The next section is where a clip actually starts producing numbers.)
The real question a game asks, sixty times a second, is not "what are the keyframes?" but: given a specific time t, like 0.27 seconds into a 0.4-second punch, what is this bone's position and rotation right now? This is called sampling the clip. The recipe is always the same two steps: find the two keyframes that surround t, then interpolate between them.
Position keys interpolate with plain lerp, exactly as you already know it. Rotation keys interpolate with quatSlerp, exactly as defined in the quaternion chapter — never with a plain component-wise lerp on the four raw numbers, for the same reason Section 10 of that chapter gave: four independently-blended numbers do not stay a valid, unit-length rotation. This is precisely where the quaternion chapter's work pays off directly.
Vec3 lerp(Vec3 a, Vec3 b, float t) {
return Vec3{ lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t) }; // reuses float lerp()
}
Vec3 sampleTrackPosition(const std::vector<PositionKey>& keys, float t) {
if (keys.size() == 1) return keys[0].value;
for (size_t i = 0; i + 1 < keys.size(); i++) {
if (t >= keys[i].time && t <= keys[i + 1].time) {
float span = keys[i + 1].time - keys[i].time;
float localT = (span > 0.0f) ? (t - keys[i].time) / span : 0.0f;
return lerp(keys[i].value, keys[i + 1].value, localT);
}
}
return keys.back().value; // t is past the last key: hold the final pose
}
Quat sampleTrackRotation(const std::vector<RotationKey>& keys, float t) {
if (keys.size() == 1) return keys[0].value;
for (size_t i = 0; i + 1 < keys.size(); i++) {
if (t >= keys[i].time && t <= keys[i + 1].time) {
float span = keys[i + 1].time - keys[i].time;
float localT = (span > 0.0f) ? (t - keys[i].time) / span : 0.0f;
return quatSlerp(keys[i].value, keys[i + 1].value, localT); // from the quaternion chapter
}
}
return keys.back().value;
}
Let's actually run this on the elbow track from Section 3 and check the constant-speed fact from the quaternion chapter still holds inside a track with more than two keys:
int main() {
std::vector<float> times = {0.05f, 0.13f, 0.20f, 0.30f};
for (float t : times) {
Quat q = sampleTrackRotation(elbowTrack.rotations, t);
float angle = 2.0f * acosf(q.w) * 180.0f / PI;
printf("t=%.2f -> angle=%.1f deg\n", t, angle);
}
return 0;
}
Between key0 (0 deg at t=0.0) and key1 (100 deg at t=0.2), the angle climbs in exact proportion to how far t is through that segment — t=0.05 is a quarter of the way through the segment and lands at 25 deg, t=0.13 is 65% through and lands at 65 deg, exactly matching the constant-angular-speed property SLERP already proved in the quaternion chapter. At t=0.30 we have crossed into the next segment (key1 to key2, 100 deg down to 10 deg), and the angle is already dropping back down — sampling always re-finds whichever two keys currently bracket t.
Sampling every bone's track at time t gives you a local pose: one position and one rotation per bone, each still expressed relative to that bone's own parent, exactly like a local Transform. To actually place a vertex in the world, you need to walk the hierarchy from Section 2 and turn every local pose into a world matrix, one bone at a time, parent before child — the exact same accumulation rule from the transform chapter, just applied bone by bone instead of object by object.
First, the transform chapter's Mat3 tool from the vectors-and-matrices chapter grows one dimension. A Mat4 stores rotation, scale, and translation together using the same homogeneous-coordinate trick (the "extra 1" from that chapter), and combines with the identical mul(A, B) convention: B happens first, A happens second.
struct Mat4 { float m[4][4]; };
Mat4 mat4Identity() {
Mat4 R{};
R.m[0][0] = R.m[1][1] = R.m[2][2] = R.m[3][3] = 1.0f;
return R;
}
Mat4 mul(const Mat4& A, const Mat4& B) { // same convention as Mat3: B first, then A
Mat4 R{};
for (int row = 0; row < 4; row++)
for (int col = 0; col < 4; col++) {
float sum = 0.0f;
for (int k = 0; k < 4; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
Vec3 transformPoint(const Mat4& M, Vec3 p) { // treats p as (x,y,z,1) -- a point, not a direction
return Vec3{
M.m[0][0]*p.x + M.m[0][1]*p.y + M.m[0][2]*p.z + M.m[0][3],
M.m[1][0]*p.x + M.m[1][1]*p.y + M.m[1][2]*p.z + M.m[1][3],
M.m[2][0]*p.x + M.m[2][1]*p.y + M.m[2][2]*p.z + M.m[2][3]
};
}
Building a local matrix from a bone's sampled position and rotation is exactly model = T * R * S from the transform chapter, with bone scale held at 1 here to keep the numbers simple (some formats do animate per-bone scale, for squash-and-stretch effects — the math is identical, just one more diagonal matrix multiplied in). The rotation block is built straight from the quaternion, using the standard quaternion-to-matrix formula — you do not need to re-derive it, only trust it the same way you already trust quatMultiply:
Mat4 mat4RotationFromQuat(const Quat& q) {
float x = q.x, y = q.y, z = q.z, w = q.w;
Mat4 R = mat4Identity();
R.m[0][0] = 1 - 2*(y*y + z*z); R.m[0][1] = 2*(x*y - w*z); R.m[0][2] = 2*(x*z + w*y);
R.m[1][0] = 2*(x*y + w*z); R.m[1][1] = 1 - 2*(x*x + z*z); R.m[1][2] = 2*(y*z - w*x);
R.m[2][0] = 2*(x*z - w*y); R.m[2][1] = 2*(y*z + w*x); R.m[2][2] = 1 - 2*(x*x + y*y);
return R;
}
Mat4 mat4Translation(Vec3 t) {
Mat4 R = mat4Identity();
R.m[0][3] = t.x; R.m[1][3] = t.y; R.m[2][3] = t.z;
return R;
}
Mat4 mat4FromTRS(Vec3 position, Quat rotation) { // bone scale = 1 here, see note above
return mul(mat4Translation(position), mat4RotationFromQuat(rotation));
}
Now walk the hierarchy: each bone's world matrix is its parent's already-computed world matrix, multiplied by this bone's own local matrix.
std::vector<Mat4> computeWorldMatrices(const Skeleton& skeleton,
const std::vector<Vec3>& localPos,
const std::vector<Quat>& localRot) {
std::vector<Mat4> world(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
Mat4 local = mat4FromTRS(localPos[i], localRot[i]);
int p = skeleton.bones[i].parentIndex;
world[i] = (p == -1) ? local : mul(world[p], local); // parent's world was already computed
}
return world;
}
Let's trace two bones by hand: a Shoulder (root of this small chain, sitting at local position (0,0,0)) and a child Elbow, offset (1,0,0) from the shoulder in bind pose — think of that offset as the length of the upper arm bone. Animate the shoulder with a 90-degree rotation around Z ("arm raised"), and leave the elbow's own local pose untouched:
int main() {
Quat shoulderRot = quatFromAxisAngle(Vec3{0,0,1}, 90.0f * DEG2RAD);
Quat elbowRot = quatFromAxisAngle(Vec3{0,0,1}, 0.0f); // identity: no local bend yet
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, shoulderRot);
Mat4 elbowLocal = mat4FromTRS(Vec3{1,0,0}, elbowRot);
Mat4 elbowWorld = mul(shoulderWorld, elbowLocal);
Vec3 elbowWorldPos = transformPoint(elbowWorld, Vec3{0,0,0}); // the elbow joint itself
printf("elbow world position = (%.3f, %.3f, %.3f)\n", elbowWorldPos.x, elbowWorldPos.y, elbowWorldPos.z);
return 0;
}
That is exactly the "rotate (1,0) by 90 degrees lands on (0,1)" trick from the transform chapter's matrix section, just carried through one extra multiply. The elbow started out 1 unit along the shoulder's local +X; rotating the shoulder 90 degrees around Z swings that entire offset up to world +Y, dragging the elbow with it — and every vertex skinned to bones below the elbow would swing along too, which is Section 8's job.
Every mesh vertex is authored once, in model space, sitting in a fixed reference pose the skeleton was in when the artist painted the skin weights — usually a T-pose or A-pose. This reference is called the bind pose ("bind" as in: the moment the mesh got bound to the skeleton). Compute the bind pose's own world matrices with the exact same computeWorldMatrices from Section 5, just fed the bind-pose local positions and rotations instead of an animated clip's.
Here is the problem those bind-pose world matrices create: a vertex's stored model-space position already bakes in wherever the mesh happened to sit relative to the bones at bind time. If you skinned a vertex by simply multiplying it by the bone's current, animated world matrix, you would be applying the bind-time offset a second time on top of the animated one — the mesh would drag along the bind pose's own shape as extra, unwanted baggage.
The fix is the inverse bind matrix: for every bone, invert its bind-pose world matrix, once, offline, when the mesh is loaded (real pipelines usually export this straight from the FBX or glTF file, already computed). Multiplying a vertex by inverseBindMatrix first strips the bind-time transform back out, landing the vertex in that bone's own local space — from there, multiplying by the bone's current animated world matrix places it correctly, with no leftover baggage.
Computing a full matrix inverse is more linear algebra than this chapter needs to derive — treat mat4Inverse as a black box the same way you already treat sqrtf: your math library, or the importer that loaded the mesh, already has a tested one.
Mat4 mat4Inverse(const Mat4& m); // black box -- provided by your math library
void computeInverseBindMatrices(Skeleton& skeleton,
const std::vector<Vec3>& bindLocalPos,
const std::vector<Quat>& bindLocalRot) {
std::vector<Mat4> bindWorld = computeWorldMatrices(skeleton, bindLocalPos, bindLocalRot);
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skeleton.bones[i].inverseBindMatrix = mat4Inverse(bindWorld[i]); // done ONCE, at load time
}
}
For the Shoulder/Elbow chain from Section 5, the bind pose is simple — both bones sit at their rest local pose, no rotation: shoulder at (0,0,0), elbow offset (1,0,0). That makes shoulderBindWorld the identity matrix, and elbowBindWorld a pure translation by (1,0,0). Inverting a pure translation just negates it — no rotation to undo — so:
skinningMatrix = mul(boneWorld, inverseBindMatrix) = mul(bindWorld, mat4Inverse(bindWorld)) — which is the identity matrix, by the very definition of an inverse. Section 12's debugging tip leans on exactly this fact.Put Sections 5 and 6 together and you get the one matrix a vertex actually needs: the skinning matrix.
skinningMatrix = mul(boneWorldMatrix, inverseBindMatrix)
Read it right-to-left, exactly as the transform chapter taught you to read every mul(): first strip out the bind pose (inverseBindMatrix), then apply the bone's current, animated world transform (boneWorldMatrix). One skinning matrix per bone, recomputed every single frame the skeleton moves:
std::vector<Mat4> computeSkinningMatrices(const Skeleton& skeleton, const std::vector<Mat4>& boneWorld) {
std::vector<Mat4> skin(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skin[i] = mul(boneWorld[i], skeleton.bones[i].inverseBindMatrix);
}
return skin;
}
Let's actually check the numbers from Sections 5 and 6 agree. Take a vertex sitting exactly on the elbow joint in bind pose — its model-space bind position is (1, 0, 0), the same offset we gave the elbow bone. Skin it with skinningMatrix[Elbow] from the animated pose in Section 5 (shoulder rotated 90 degrees, elbow unchanged):
int main() {
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 90.0f * DEG2RAD));
Mat4 elbowWorld = mul(shoulderWorld, mat4FromTRS(Vec3{1,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 0.0f)));
Mat4 inverseBindElbow = mat4Translation(Vec3{-1,0,0}); // from Section 6
Mat4 skinElbow = mul(elbowWorld, inverseBindElbow);
Vec3 skinned = transformPoint(skinElbow, Vec3{1,0,0}); // vertex's bind-pose position
printf("skinned vertex = (%.3f, %.3f, %.3f)\n", skinned.x, skinned.y, skinned.z);
return 0;
}
The vertex lands exactly on (0, 1, 0) — the same world position we computed for the elbow joint itself back in Section 5. That has to be true: a vertex sitting exactly at a joint, fully weighted to that bone, must move to wherever the joint moves. It is a small result, but it is the single most useful sanity check you have for catching a broken skinning pipeline, and Section 12 comes back to it directly.
Skinning matrices move whole rigid chunks of space, but real mesh surfaces do not split cleanly at a joint — the skin near an elbow has to stretch and bend smoothly across both the upper arm and the forearm at once. The standard technique is linear blend skinning (LBS, also called skeletal subspace deformation): every vertex stores up to four bone influences, each an (index, weight) pair, with the weights summing to 1. The vertex's final position is a weighted blend of what each influencing bone's skinning matrix, alone, would have done to it.
skinnedVertex = SUM over k of ( weight[k] * transformPoint(skinningMatrix[boneIndex[k]], bindVertex) )
struct SkinnedVertex {
Vec3 bindPosition; // position in model space, in the bind pose
int boneIndices[4]; // up to four influencing bones
float boneWeights[4]; // weights, sum to 1.0
};
Vec3 skinVertex(const SkinnedVertex& v, const std::vector<Mat4>& skin) {
Vec3 result{0, 0, 0};
for (int k = 0; k < 4; k++) {
if (v.boneWeights[k] <= 0.0f) continue;
Vec3 contribution = transformPoint(skin[v.boneIndices[k]], v.bindPosition);
result.x += v.boneWeights[k] * contribution.x;
result.y += v.boneWeights[k] * contribution.y;
result.z += v.boneWeights[k] * contribution.z;
}
return result;
}
Let's put a real number on this with the same rig, now with the elbow itself bending 30 degrees locally on top of the shoulder's 90-degree raise, and a vertex sitting just past the joint at bind position (1.2, 0, 0), weighted 50% Shoulder / 50% Elbow:
int main() {
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 90.0f * DEG2RAD));
Mat4 elbowWorld = mul(shoulderWorld,
mat4FromTRS(Vec3{1,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 30.0f * DEG2RAD)));
Mat4 skinShoulder = mul(shoulderWorld, mat4Identity()); // inverse bind = identity
Mat4 skinElbow = mul(elbowWorld, mat4Translation(Vec3{-1,0,0})); // inverse bind from Section 6
Vec3 bindPos = Vec3{1.2f, 0.0f, 0.0f};
Vec3 fromShoulder = transformPoint(skinShoulder, bindPos);
Vec3 fromElbow = transformPoint(skinElbow, bindPos);
Vec3 blended;
blended.x = 0.5f * fromShoulder.x + 0.5f * fromElbow.x;
blended.y = 0.5f * fromShoulder.y + 0.5f * fromElbow.y;
blended.z = 0.5f * fromShoulder.z + 0.5f * fromElbow.z;
printf("from Shoulder alone = (%.3f, %.3f, %.3f)\n", fromShoulder.x, fromShoulder.y, fromShoulder.z);
printf("from Elbow alone = (%.3f, %.3f, %.3f)\n", fromElbow.x, fromElbow.y, fromElbow.z);
printf("blended (50/50) = (%.3f, %.3f, %.3f)\n", blended.x, blended.y, blended.z);
return 0;
}
The blended vertex lands neither where "rigidly attached to the shoulder only" would put it, nor where "rigidly attached to the elbow only" would put it — it sits smoothly between them, exactly what a real elbow's skin should do as it bends. This is the entire idea of LBS: pull every influencing bone's opinion toward a single averaged answer, weighted by how much each bone should matter at that particular vertex.
skinVertex from Section 8 runs correctly on the CPU, but no shipping game skins its rendered characters on the CPU — a mesh with tens of thousands of vertices, redone every frame for every visible character, would burn CPU time that the rest of the game needs. Instead, the engine does exactly two things on the CPU per frame (Sections 5 through 7: sample the clip, walk the hierarchy, compute skinning matrices) and hands the small array of skinning matrices — one per bone, maybe 60 to 150 small 4x4 matrices — to the GPU. Every vertex already carries its own baked-in boneIndices and boneWeights from Section 8, set once when the mesh was imported. The GPU's vertex shader (the small program that runs once per vertex, in parallel, for every vertex on screen) does Section 8's exact blend formula itself, for every vertex, every frame:
// Vertex shader, HLSL-style pseudocode
struct VSInput {
float3 position : POSITION; // bind-pose position, same as SkinnedVertex::bindPosition
int4 boneIndices : BLENDINDICES; // up to 4 influencing bones
float4 boneWeights : BLENDWEIGHT; // weights, sum to 1.0
};
cbuffer SkinningData {
float4x4 skinMatrices[MAX_BONES]; // uploaded once per frame from the CPU-side array in Section 7
};
float4 main(VSInput input) : SV_Position {
float4 bindPos = float4(input.position, 1.0);
float4 skinnedPos = 0;
skinnedPos += input.boneWeights.x * mul(skinMatrices[input.boneIndices.x], bindPos);
skinnedPos += input.boneWeights.y * mul(skinMatrices[input.boneIndices.y], bindPos);
skinnedPos += input.boneWeights.z * mul(skinMatrices[input.boneIndices.z], bindPos);
skinnedPos += input.boneWeights.w * mul(skinMatrices[input.boneIndices.w], bindPos);
return mul(viewProjectionMatrix, skinnedPos);
}
The "up to four influences" limit is not a law of physics — it comes from a practical vertex-format budget (4 indices and 4 weights fit tidily into two 4-wide GPU registers). This whole GPU-side loop is precisely what Unity's SkinnedMeshRenderer and Unreal's Skeletal Mesh Component are doing under the hood, every frame, for every character you have ever seen move in either engine: the CPU handles Sections 5-7's skeleton math once, and the GPU repeats Section 8's blend once per vertex, in parallel, across thousands of vertices at once.
Linear blend skinning has one well-known visual flaw, and it comes from the exact same root cause the quaternion chapter already warned you about: blending two rotation matrices by simple weighted averaging does not produce a valid rotation matrix. It shrinks. At a joint that twists a large amount — a wrist rotated far, a shoulder swung wide — LBS's blended skin visibly pinches inward near the joint, a well-known artifact nicknamed the candy-wrapper effect (the mesh squeezes at the joint the way a candy wrapper twists thin in the middle).
The fix follows the same lesson the quaternion chapter already taught: rotations blend safely when you interpolate the rotation representation itself (quaternions, with SLERP or NLERP), not a matrix built from it. Dual quaternion skinning (DQS) represents each bone's rigid transform — rotation and translation together — as a dual quaternion (a pair of ordinary quaternions packed together: one for rotation, one that encodes translation) instead of a 4x4 matrix, then blends dual quaternions per vertex instead of matrices. Blending unit dual quaternions and renormalizing stays far closer to a true rigid motion than blending matrices ever does, which is exactly why the pinch mostly disappears.
struct DualQuat {
Quat real; // encodes rotation, same Quat type from the quaternion chapter
Quat dual; // encodes translation, combined algebraically with "real"
};
// briefly, conceptually -- full derivation is out of scope here:
DualQuat dqFromBone(Quat rotation, Vec3 translation);
DualQuat dqBlend(const DualQuat& a, float weightA, const DualQuat& b, float weightB); // blend, then normalize
Vec3 dqTransformPoint(const DualQuat& dq, Vec3 p);
DQS costs a little more per vertex than LBS, so most games use LBS everywhere by default and reach for DQS only on the handful of joints that actually twist enough to show the pinch — wrists and shoulders are the usual suspects — or enable it everywhere once the target hardware can afford the extra cost.
Section 3 already hinted at this: storing a key on every single frame, for every bone, in every clip, adds up fast. Take a modest 60-bone skeleton, a 5-second clip, sampled at 30 keys per second: that is 60 * 150 = 9,000 bone-samples, and each sample needs a Vec3 position plus a Quat rotation — 7 floats, 28 bytes. That is 9,000 * 28 = 252,000 bytes, about 246 KB, for one clip. A character with a realistic roster of 80 clips (idle, walk, run, jump, several attacks, a handful of emotes) would need roughly 19 MB just for its own animation data — before counting any other character in the game.
Animation compression is the set of tricks that shrink this without the player noticing, usually applied after a clip is authored, as an offline export step:
w*w + x*x + y*y + z*z = 1, the same length-1 fact from the quaternion chapter), so you only ever need to store three of the four numbers plus which one you dropped, and reconstruct the last one with a square root at load time.Applied together, these routinely shrink a clip to a fifth or a tenth of the naive size — that 246 KB clip landing closer to 25-50 KB is a normal result, not an exceptional one. It is the exact same trade this whole chapter has been making since Section 3: keep only the data interpolation cannot cheaply reconstruct on its own, and let the sampling math from Section 4 do the rest of the work for free.
Almost every graphics programmer eventually ships a build where a character's mesh, the instant animation turns on, flies apart into a scattered cloud of triangles — often called, without much affection, an "exploding character." It looks catastrophic, but the cause is nearly always narrow: a wrong or missing inverse bind matrix.
// BAD: forgot the inverse bind matrix entirely
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skinningMatrix[i] = boneWorld[i]; // still carries the FULL bind-pose offset baked in
}
// GOOD: strip the bind pose out first, exactly as Section 7 defined it
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skinningMatrix[i] = mul(boneWorld[i], skeleton.bones[i].inverseBindMatrix);
}
In the BAD version, every vertex gets the bone's entire world-space bind offset applied a second time on top of its own already bind-pose position — and because different bones sit at different distances and orientations from the origin at bind time, every vertex gets yanked by a different, inconsistent amount, all in the same frame. The mesh does not deform; it disintegrates. The same family of bug shows up as: an inverse computed by hand that only negates the translation and ignores rotation, or a mesh whose vertex weights index bones in a different order than the skeleton array actually stores them (bone 5 in the mesh file is not bone 5 in the loaded skeleton) — both hand a vertex the wrong bone's transform outright.
skinningMatrix[i]. Every single one must come out as the identity matrix. Whichever bone's does not is exactly where the bug lives — you have turned a whole-mesh explosion into a search over a short, ordered list of bones.A character rarely snaps straight from "Idle" to "Walk" — a visible pop reads as broken. The fix is a short crossfade: blend the two clips' sampled local poses for a fraction of a second, ramping the blend weight from 0 to 1, then run the ordinary Section 5-9 pipeline on the single blended pose that comes out. The pipeline downstream of this never needs to know a blend even happened.
struct LocalPose { std::vector<Vec3> positions; std::vector<Quat> rotations; };
LocalPose blendPoses(const LocalPose& a, const LocalPose& b, float blendFactor) {
LocalPose result;
result.positions.resize(a.positions.size());
result.rotations.resize(a.rotations.size());
for (size_t i = 0; i < a.positions.size(); i++) {
result.positions[i] = lerp(a.positions[i], b.positions[i], blendFactor);
result.rotations[i] = quatSlerp(a.rotations[i], b.rotations[i], blendFactor); // from the quaternion chapter
}
return result;
}
Every frame, for every animated character, the whole chapter runs in this order:
t means finding the surrounding keys and interpolating — lerp for position, quatSlerp for rotation.boneWorld * inverseBind) is what actually moves a vertex.parentIndex == -1), usually the hips.t by interpolating the two surrounding keyframes.mul(boneWorldMatrix, inverseBindMatrix); the one matrix that actually deforms a vertex correctly.0 deg at t=0.0, 45 deg at t=0.5, and 90 deg at t=1.0. Using the constant-angular-speed property of SLERP (from the quaternion chapter, and confirmed again in Section 4), predict by hand what angle sampleTrackRotation should return at t=0.25 and at t=0.75. Then write the track and the code to confirm it, printing the angle with the 2*acosf(w)*180/PI trick.t=0.25 falls in the first segment (t=0.0 to t=0.5), exactly halfway through it, so the answer should be halfway between 0 and 45 degrees: 22.5 deg. t=0.75 falls in the second segment (t=0.5 to t=1.0), also exactly halfway through it, so the answer should be halfway between 45 and 90 degrees: 67.5 deg.
int main() {
std::vector<RotationKey> keys = {
{ 0.0f, quatFromAxisAngle(Vec3{0,1,0}, 0.0f) },
{ 0.5f, quatFromAxisAngle(Vec3{0,1,0}, 45.0f * DEG2RAD) },
{ 1.0f, quatFromAxisAngle(Vec3{0,1,0}, 90.0f * DEG2RAD) },
};
for (float t : {0.25f, 0.75f}) {
Quat q = sampleTrackRotation(keys, t);
float angle = 2.0f * acosf(q.w) * 180.0f / PI;
printf("t=%.2f -> angle=%.1f deg\n", t, angle);
}
return 0;
}
Both match the hand prediction exactly, because each query sits precisely halfway through its own segment, and SLERP moves at a constant angular rate across any single segment — the same fact the quaternion chapter measured directly with its own table of angles.
(0,0,0), elbow offset (1,0,0), both with identity rotation in bind pose), compute both bones' skinning matrices when the current pose is set to exactly equal the bind pose (no animation applied at all). Print each skinning matrix's translation column and confirm it comes out as (0,0,0) for both bones, and briefly explain in words why this must always be true, using Section 6's tip.int main() {
// "current" pose == bind pose: shoulder identity, elbow at (1,0,0), no rotation
Mat4 shoulderWorld = mat4FromTRS(Vec3{0,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 0.0f));
Mat4 elbowWorld = mul(shoulderWorld, mat4FromTRS(Vec3{1,0,0}, quatFromAxisAngle(Vec3{0,0,1}, 0.0f)));
Mat4 inverseBindShoulder = mat4Identity(); // from Section 6
Mat4 inverseBindElbow = mat4Translation(Vec3{-1,0,0}); // from Section 6
Mat4 skinShoulder = mul(shoulderWorld, inverseBindShoulder);
Mat4 skinElbow = mul(elbowWorld, inverseBindElbow);
printf("skinShoulder translation = (%.3f, %.3f, %.3f)\n", skinShoulder.m[0][3], skinShoulder.m[1][3], skinShoulder.m[2][3]);
printf("skinElbow translation = (%.3f, %.3f, %.3f)\n", skinElbow.m[0][3], skinElbow.m[1][3], skinElbow.m[2][3]);
return 0;
}
Both columns are exactly zero, and both full matrices are exactly the identity matrix, not just their translation parts. This has to hold because the current pose and the bind pose are the same pose here: boneWorld equals bindWorld for every bone, so skinningMatrix = mul(bindWorld, mat4Inverse(bindWorld)), which is the identity matrix by definition — a matrix multiplied by its own inverse always cancels out to "do nothing." This is exactly the check Section 12 recommends running first whenever a skinned mesh looks broken.
std::vector<Mat4> computeSkinningMatrices(const Skeleton& skeleton, const std::vector<Mat4>& boneWorld) {
std::vector<Mat4> skin(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skin[i] = mul(skeleton.bones[i].inverseBindMatrix, boneWorld[i]); // <-- bug is on this line
}
return skin;
}
The two arguments to mul are swapped. Remembering the transform chapter's rule — mul(A, B) applies B first, then A — the buggy line applies the bone's brand-new, animated world matrix first, directly to a vertex that is still sitting in bind-pose model space, and only afterward tries to strip out the bind-pose offset with inverseBindMatrix. That is backwards: the vertex needs to be pulled out of the bind pose first (into the bone's own local space), and only then pushed into the current animated world pose. Applying them in the wrong order does not compute anything close to a rigid motion, and different bones end up dragging their vertices by wildly different, inconsistent amounts — the mesh explodes.
std::vector<Mat4> computeSkinningMatrices(const Skeleton& skeleton, const std::vector<Mat4>& boneWorld) {
std::vector<Mat4> skin(skeleton.bones.size());
for (size_t i = 0; i < skeleton.bones.size(); i++) {
skin[i] = mul(boneWorld[i], skeleton.bones[i].inverseBindMatrix); // inverseBind FIRST, boneWorld SECOND
}
return skin;
}
This is exactly Section 7's original definition, and exactly the same "order matters" lesson the transform chapter first taught with plain scale-then-translate matrices — the same mistake, just one hierarchy level deeper.