Tutorial 57: SkinnedEffect and Skeletal Animation
Backend support: SkinnedEffect is pixel-tested and confirmed working on the EASYGL backend. Vulkan backend support is in progress. The SDL_RENDERER backend does not support programmable shaders and cannot run SkinnedEffect.
Skeletal animation (also called skinning) is the standard technique for animating character meshes. A skeleton of hierarchical bones drives the mesh: each vertex is bound to one or more bones with influence weights that sum to 1. At runtime, a matrix for each bone is computed and uploaded to the GPU, which transforms each vertex in the vertex shader using the weighted sum of its bound bone matrices. CNA's SkinnedEffect provides an XNA 4.0-compatible interface to this pipeline.
SkinnedEffect Overview
SkinnedEffect is a built-in effect that extends the basic per-vertex lighting model of BasicEffect with a skinning pass. The vertex shader accepts a palette of up to 72 bone matrices and, for each vertex, computes a weighted blend of the bone transforms:
// Simplified skinning in the vertex shader
vec4 skinnedPos = vec4(0.0);
for (int i = 0; i < WEIGHTS_PER_VERTEX; ++i) {
int boneIdx = int(a_blendIndices[i]);
float weight = a_blendWeights[i];
skinnedPos += u_bones[boneIdx] * vec4(a_position, 1.0) * weight;
}
gl_Position = u_viewProj * skinnedPos;
The effect supports the same lighting properties as BasicEffect: up to three directional lights, per-vertex ambient, and optional fog. Texturing is single-texture with optional per-vertex colour.
SetBoneTransforms
SetBoneTransforms(const Matrix* matrices, int count) uploads the bone matrix palette to the GPU. The matrices should be in object space, incorporating the full chain of transforms from the root bone to each individual bone. CNA uploads them to a GLSL uniform array u_bones[72] via a UBO on EASYGL or a descriptor set on Vulkan:
// Upload bone palette (typically called each frame in Update or Draw)
skinEffect_->SetBoneTransforms(boneXforms_.data(), (int)boneXforms_.size());
The count parameter must be in the range [1, 72]. Uploading more than 72 matrices is a runtime error. The 72-bone limit matches XNA 4.0 and is sufficient for humanoid characters; complex creatures with more bones require splitting the mesh into multiple draw calls with separate palettes.
The bone matrices represent the skinning matrix for each bone, defined as:
// skinningMatrix[i] = inverseBindPose[i] * currentPoseWorldTransform[i]
// where inverseBindPose[i] transforms from model space to the bone's local space
// at the bind pose, and currentPoseWorldTransform[i] is the current animation pose
Matrix skinMatrix = model_->getInverseBindPose(i) * boneWorldTransform[i];
Weights Per Vertex
The WeightsPerVertex property controls how many bone influences each vertex receives. Valid values are 1, 2, or 4 (the XNA 4.0 standard). More weights produce smoother deformation at joints (elbows, knees) at the cost of more GPU computation per vertex:
- 1 — "Rigid skinning". Each vertex is 100% controlled by a single bone. No blending, fastest. Suitable for robotic characters with hard joints.
- 2 — Two-bone blend. Good compromise for simple humanoids and most game characters.
- 4 — Four-bone blend. The standard for high-quality character animation. Required for smooth shoulder, wrist, and ankle deformation.
skinEffect_->setWeightsPerVertex(4); // highest quality, typical for hero characters
skinEffect_->setWeightsPerVertex(1); // fastest, use for distant crowd NPCs
The vertex buffer must use a matching vertex declaration. CNA provides VertexPositionNormalTextureSkin which carries position, normal, texture coordinates, a BlendIndices (packed four 8-bit bone indices as a Vector4), and a BlendWeights (Vector4 of normalised float weights).
AnimationClip Loading
CNA's model content pipeline extracts animation data from FBX and glTF files. After loading, animation clips are accessible via the Model object:
model_ = Content.Load<Model>("models/character");
// List available clips
for (const auto& clip : model_->getAnimationClips()) {
std::cout << clip.getName() << " duration=" << clip.Duration << "s\n";
}
// Retrieve a specific clip by name
AnimationClip* walkClip = model_->getAnimationClip("Walk");
AnimationClip* runClip = model_->getAnimationClip("Run");
AnimationClip* idleClip = model_->getAnimationClip("Idle");
Each AnimationClip contains a sequence of keyframes per bone. Keyframes store a time value and a transform (position, rotation as quaternion, scale). At playback time, CNA interpolates between the nearest two keyframes using linear interpolation for position and scale, and spherical linear interpolation (SLERP) for rotations to ensure smooth joint rotation.
Bone Hierarchy and Matrix Palette
Characters are animated by walking the bone tree each frame and accumulating the full world-space transform for each bone. The process is:
- Sample each bone's local transform at the current clip time (interpolate between keyframes).
- Walk the bone tree from root to leaves, multiplying each bone's local transform by its parent's accumulated world transform.
- Multiply each resulting world transform by the bone's inverse bind-pose matrix to produce the final skinning matrix.
- Upload all skinning matrices to
SkinnedEffectviaSetBoneTransforms().
CNA's Model::computeBoneTransforms() helper performs steps 1–3 automatically given a clip and a time value. For custom animation blending (e.g., lerping between Walk and Run), you can sample two clips at their respective weights and blend the resulting matrices directly:
std::vector<Matrix> poseA(boneCount), poseB(boneCount), poseBlend(boneCount);
model_->computeBoneTransforms(walkClip_, walkTime_, poseA);
model_->computeBoneTransforms(runClip_, runTime_, poseB);
float blend = walkToRunFactor_; // 0 = walk, 1 = run
for (int i = 0; i < boneCount; ++i) {
poseBlend[i] = Matrix::Lerp(poseA[i], poseB[i], blend);
}
skinEffect_->SetBoneTransforms(poseBlend.data(), boneCount);
SkinnedEffect on the EasyGL Backend
As of the current CNA milestone, SkinnedEffect is pixel-tested and confirmed correct on the EASYGL backend. The UBO holding the 72 bone matrices is approximately 4.6 KB, which fits within the minimum UBO size guaranteed by the OpenGL ES 3.0 specification. On most desktop OpenGL 4.x drivers this is uploaded via a GL_UNIFORM_BUFFER with streaming hint, re-uploaded each frame. The Vulkan backend is expected to use a storage buffer or large push constant block; this work is tracked in the CNA issue tracker.
Complete Example: Animated Character
class SkinnedGame final : public Game {
std::unique_ptr<Model> model_;
std::unique_ptr<SkinnedEffect> skinEffect_;
AnimationClip* walkClip_ = nullptr;
AnimationClip* idleClip_ = nullptr;
float clipTime_ = 0.0f;
bool isWalking_ = false;
std::vector<Matrix> boneXforms_;
Camera camera_;
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
model_ = Content.Load<Model>("models/character");
skinEffect_ = std::make_unique<SkinnedEffect>(gd);
skinEffect_->setWeightsPerVertex(4);
// Lighting setup
skinEffect_->setAmbientLightColor(Vector3(0.3f, 0.3f, 0.35f));
skinEffect_->DirectionalLight0.setEnabled(true);
skinEffect_->DirectionalLight0.setDirection(
Vector3::Normalize(Vector3(0.5f, -1.0f, -0.7f)));
skinEffect_->DirectionalLight0.setDiffuseColor(Vector3(1.0f, 0.9f, 0.8f));
// Texture (shared across all meshes)
auto charTex = Content.Load<Texture2D>("textures/character_atlas");
skinEffect_->setTexture(charTex.get());
// Animation clips
walkClip_ = model_->getAnimationClip("Walk");
idleClip_ = model_->getAnimationClip("Idle");
boneXforms_.resize(model_->getBoneCount(), Matrix::Identity);
}
void Update(const GameTime& gt) override {
float dt = (float)gt.getElapsedGameTime().TotalSeconds();
AnimationClip* activeClip = isWalking_ ? walkClip_ : idleClip_;
clipTime_ += dt;
if (clipTime_ > activeClip->Duration) {
clipTime_ = fmodf(clipTime_, activeClip->Duration);
}
// Compute bone transforms from active clip at current time
model_->computeBoneTransforms(activeClip, clipTime_, boneXforms_);
// Upload to GPU (before Draw)
skinEffect_->SetBoneTransforms(
boneXforms_.data(), (int)boneXforms_.size());
// Toggle walk/idle on key press
if (getKeyboardState().IsKeyDown(Keys::W)) isWalking_ = true;
if (getKeyboardState().IsKeyDown(Keys::S)) isWalking_ = false;
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::CornflowerBlue);
skinEffect_->setWorld(Matrix::Identity);
skinEffect_->setView(camera_.View());
skinEffect_->setProjection(camera_.Projection());
// Draw each mesh in the model with the skinned effect
model_->draw(gd, *skinEffect_);
gd.Present();
}
};