Tutorial 112: Skeletal Animation from glTF
What you’ll learn
- Where an imported skeleton and its clips actually live on a loaded
Model. - Driving
AnimationPlayer, and feeding its output intoSkinnedEffect. - How LINEAR, STEP and CUBICSPLINE are resolved — and why it happens at import, not at playback.
- The one silent failure that eats whole animations without a word of diagnostic.
Before you start — Tutorial 57: SkinnedEffect and Skeletal Animation covers the effect this tutorial feeds, and Tutorial 110 covers getting the model in.
A rigged glTF file arrives in CNA with its skeleton, its bind pose and every one of its clips already unpacked. There is no separate animation import step and no separate animation asset to load — it all comes attached to the Model. What is left to you is three lines of setup and one line per frame.
Where the data lives
Real XNA’s Model class has no skinning property, so CNA follows the convention Microsoft’s own Skinned Model Sample established and stashes the data on the model’s Tag:
#include "Microsoft/Xna/Framework/Graphics/AnimationPlayer.hpp"
using namespace Microsoft::Xna::Framework::Graphics;
Model* hero = Content->Load<Model>("hero");
auto* skinning = static_cast<SkinningData*>(hero->getTagProperty());
if (skinning == nullptr) {
// Not a skinned model: no skin in the source file, so no skeleton and no clips.
return;
}
Always check for null. Tag is left empty for a model with no skin, and that includes several cases you might not expect — see the trap below.
SkinningData is a plain aggregate. Everything on it is public:
| Member | Meaning |
|---|---|
BoneCount | Number of bones in the skeleton. |
SkeletonHierarchy | Parent index per bone (-1 for a root), in topological order. |
BindPose | Each bone’s local bind transform, relative to its parent. |
InverseBindPose | Inverse of each bone’s bind-pose global transform. |
SkeletonRootPrefix | Per-root context: the scene ancestry above the joint set, and the cancellation of the skinned mesh node’s own transform. May be empty, which reads as all-identity. |
AnimationClips | std::unordered_map<std::string, AnimationClip>, keyed by clip name. |
The clip names are the animation names from the source file. Enumerate them if you do not know them:
for (const auto& [name, clip] : skinning->AnimationClips) {
// name is the glTF animation name; clip.Duration is a System::TimeSpan
}
An AnimationClip is a duration plus a list of per-bone tracks; each track is a bone index and a list of keyframes carrying Time, Translation, Rotation and Scale. You rarely need to touch any of that directly — that is what the player is for.
SkinningData, AnimationClip, Keyframe and AnimationPlayer all carry the CNAEXT marker. None of them is XNA 4.0 API — they mirror the classes that almost every XNA game copy-pasted out of Microsoft’s Skinned Model Sample, because real XNA never shipped them as framework code. See Tutorial 115.
AnimationPlayer, in four calls
The player owns no data of its own. It holds a reference to your SkinningData — which must outlive it — and recomputes three transform arrays whenever you advance it.
// Once, after loading. skinning must stay alive for as long as player does.
AnimationPlayer player(*skinning);
player.StartClip(skinning->AnimationClips.at("Walk"));
// Every frame.
player.Update(gameTime.getElapsedGameTimeProperty(), /*relativeToCurrentTime*/ true, /*loop*/ true);
| Call | Does |
|---|---|
AnimationPlayer(const SkinningData&) | Binds to a skeleton and immediately snaps every bone to the bind pose. |
StartClip(const AnimationClip&) | Switches clip and rewinds to zero. |
Update(TimeSpan, bool relativeToCurrentTime, bool loop = true) | Advances or seeks, then recomputes all three arrays. |
GetSkinTransforms() | The array you hand to the effect. |
Two more getters are available when you need them. GetBoneTransforms() gives each bone’s local transform relative to its parent, and GetWorldTransforms() gives each bone’s model-space transform — useful for attaching a weapon to a hand bone or drawing a debug skeleton. getCurrentPositionProperty() and getCurrentClipProperty() report playback state; the latter returns nullptr before the first StartClip().
If StartClip() was never called, Update() leaves every bone at its bind pose rather than misbehaving. That makes the bind pose a safe default to draw.
Looping, clamping and seeking
The two bool parameters cover every playback mode you are likely to want:
// Loop a walk cycle forever.
player.Update(elapsed, true, true);
// Play an attack once and hold the final pose.
player.Update(elapsed, true, false);
// Scrub to an absolute position -- an editor timeline, or a cutscene sync point.
player.Update(System::TimeSpan::FromSeconds(1.5), false, false);
// Play backwards.
player.Update(-elapsed, true, true);
With loop true, the position wraps around the clip duration — and it wraps correctly for negative positions too, so reverse playback loops cleanly. With loop false, the position is clamped to [0, Duration]. The wrap is computed arithmetically rather than by repeated subtraction, so a long session with a short clip costs the same per frame as a fresh one.
Feeding the transforms into an effect
GetSkinTransforms() returns exactly what SetBoneTransforms() wants — InverseBindPose already composed with the world transform, one matrix per bone. Hand it straight over:
for (ModelMesh* mesh : model->getMeshesProperty()) {
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
auto* skinned = dynamic_cast<SkinnedEffect*>(part->getEffectProperty());
if (skinned == nullptr) { continue; }
skinned->SetBoneTransforms(player.GetSkinTransforms());
skinned->setWorldProperty(world);
skinned->setViewProperty(view);
skinned->setProjectionProperty(projection);
}
mesh->Draw();
}
Two limits carry over from Tutorial 57 and apply here unchanged. SkinnedEffect::MaxBones is 72, and passing more matrices than that is an error rather than a silent truncation. WeightsPerVertex must be 1, 2 or 4 — there is no 3.
If the importer chose SkinnedPbrEffect instead — which it does when the source material carries a normal or metallic-roughness map — the bone API is identical. Same MaxBones = 72, same SetBoneTransforms(), same getWeightsPerVertexProperty(). Cast to whichever type the part actually has, or handle both:
if (auto* pbr = dynamic_cast<SkinnedPbrEffect*>(part->getEffectProperty())) {
pbr->SetBoneTransforms(player.GetSkinTransforms());
} else if (auto* skinned = dynamic_cast<SkinnedEffect*>(part->getEffectProperty())) {
skinned->SetBoneTransforms(player.GetSkinTransforms());
}
See Tutorial 114 for what the PBR variant does with the rest of the material.
Interpolation is resolved at import, not at playback
glTF offers three sampler interpolation modes, and CNA supports all three — but it is worth knowing exactly where each one is honoured, because it explains a behaviour that otherwise looks like a bug.
| Mode | Support | Handled by |
|---|---|---|
LINEAR | Yes | Importer, then the player. |
STEP | Yes | Importer — a real hold-last-value, not an approximation. |
CUBICSPLINE | Yes | Importer — real Hermite evaluation using the file’s own tangents. |
Here is what the importer actually does. For each animated bone it collects the union of every sample time across that bone’s translation, rotation and scale channels, sorts and de-duplicates it, and then evaluates all three channels at each of those times using that channel’s own declared interpolation mode. A channel that has no key at a given time contributes its evaluated value; a channel absent altogether contributes the bind pose, decomposed. The result is one flat list of fully-populated keyframes per bone.
At playback, AnimationPlayer then does the simple thing between those baked keys: Lerp on translation and scale, Slerp on rotation, clamped at both ends.
The consequence: a CUBICSPLINE curve is sampled onto the key times that exist in the file, and interpolated linearly between them. If your exporter wrote sparse spline keys expecting the runtime to reconstruct the curve, the imported motion is a polyline through the same points. Export denser keys if a specific curve shape matters; the cost is file size, not runtime work.
One channel path is not imported at all: weights, the morph-target channel. That is not a defect — it targets a mesh node rather than a skeleton joint, so it has no bone index to key against, and it is carried on a separate track type instead. Tutorial 113 covers it. The converter emits a warning when it sees one.
The trap: rigid animation disappears silently
Animation of an unskinned node is dropped, with no diagnostic and no clip. A rotating door, an orbiting moon, a spinning fan, a bobbing pickup — none of them survive import. This is tracked as a known defect, not a design decision.
There are two layers to it, and knowing both makes it easy to recognise.
Channels are resolved against the skin’s joint set. An animation channel names a target node. The importer looks that node up in the skeleton it built from the skin; if the node is not one of the joints, the channel is skipped. An ordinary mesh node is never a joint, so its channels never match.
A file with no skin extracts no clips at all. Clip extraction only runs for a mesh group that has a skin. A file containing nothing but animated rigid objects therefore produces a model whose Tag is null and whose clip map does not exist.
So the symptom is one of two things: an animation you know you exported is simply not in AnimationClips, or getTagProperty() returns nullptr for a file you are certain contains animation. Check for this first — nothing is logged, so there is nothing to search for.
Two workarounds are available today:
- Animate it in game code. A rigid transform is one matrix. Compose your own rotation into the
worldmatrix you pass to the effect, or into the bone transform before drawing, and you have lost nothing but authoring convenience. - Give the object a one-joint skin. If the motion must ship inside the asset, bind the mesh to a single joint and animate that joint. It then travels the supported path end to end — at the cost of a skinned vertex layout and a
SkinnedEffect.
Putting it together
class HeroActor
{
public:
void Load(ContentManager& content)
{
model_ = content.Load<Model>("hero");
skinning_ = static_cast<SkinningData*>(model_->getTagProperty());
if (skinning_ == nullptr) {
throw std::runtime_error("hero has no skin -- rigid-only animation is dropped on import");
}
player_ = std::make_unique<AnimationPlayer>(*skinning_);
Play("Idle");
}
void Play(const std::string& clipName)
{
auto it = skinning_->AnimationClips.find(clipName);
if (it == skinning_->AnimationClips.end()) { return; } // clip was dropped, or misspelled
player_->StartClip(it->second);
}
void Update(const GameTime& gameTime)
{
player_->Update(gameTime.getElapsedGameTimeProperty(), true, true);
}
void Draw(const Matrix& world, const Matrix& view, const Matrix& projection)
{
const std::vector<Matrix>& skin = player_->GetSkinTransforms();
for (ModelMesh* mesh : model_->getMeshesProperty()) {
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
if (auto* e = dynamic_cast<SkinnedEffect*>(part->getEffectProperty())) {
e->SetBoneTransforms(skin);
e->setWorldProperty(world);
e->setViewProperty(view);
e->setProjectionProperty(projection);
}
}
mesh->Draw();
}
}
private:
Model* model_ = nullptr;
SkinningData* skinning_ = nullptr;
std::unique_ptr<AnimationPlayer> player_;
};
One player per animated instance, not one per model: the player owns the playback position, so two characters sharing a loaded Model need two players and one SkinningData.
Where to go next
- Tutorial 113: Morph Targets — the other half of glTF animation.
- Tutorial 114: PBR Materials —
SkinnedPbrEffectin full. - Tutorial 111: The CNJ Format — where those clip files come from.
- Tutorial 57: SkinnedEffect
- Effects reference