Tutorial 112: Skeletal Animation from glTF

CNA Tutorials  ·  3D Content

ℹ

What you’ll learn

  • Where an imported skeleton and its clips actually live on a loaded Model.
  • Driving AnimationPlayer, and feeding its output into SkinnedEffect.
  • How LINEAR, STEP and CUBICSPLINE are resolved — and why it happens at import, not at playback.
  • Playing unskinned scene-node clips, plus the reported boundary for mixed skin and rigid animation.

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 before using the skinned path. An unskinned animated model uses the same Tag slot for ModelAnimationsEXT instead; see rigid animation below.

SkinningData is a plain aggregate. Everything on it is public:

MemberMeaning
BoneCountNumber of bones in the skeleton.
SkeletonHierarchyParent index per bone (-1 for a root), in topological order.
BindPoseEach bone’s local bind transform, relative to its parent.
InverseBindPoseInverse of each bone’s bind-pose global transform.
SkeletonRootPrefixPer-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.
AnimationClipsstd::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);
CallDoes
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 for glTF’s metallic-roughness material model even when it is factor-only — 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.

ModeSupportHandled by
LINEARYesImporter, then the player.
STEPYesImporter — a real hold-last-value, not an approximation.
CUBICSPLINEYesImporter — 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.

Rigid scene-node animation

An unskinned door, fan or orbiting moon now arrives with a real scene-node clip. Because no SkinningData needs the compatibility Tag slot, the loader places a ModelAnimationsEXT there. Its clips address the imported ModelBone hierarchy directly:

auto* animations = static_cast<ModelAnimationsEXT*>(model->getTagProperty());
if (animations != nullptr) {
    const AnimationClip& spin = animations->Clips.at("Spin");
    ApplyClipToBonesEXT(*model, spin, gameTime.getTotalGameTimeProperty());
}

ApplyClipToBonesEXT() interpolates translation and scale linearly and rotation spherically, then writes the local transforms onto the bones targeted by the clip. Ordinary Model::Draw() and CopyAbsoluteBoneTransformsTo() immediately see the new pose.

⚠

Mixed skin and extra rigid tracks remain an alpha boundary. A skinned model’s Tag is already occupied by its first SkinningData. If the same file also contains additional scene-node animation, those rigid tracks cannot be exposed there and are omitted with a named entry in getGltfImportReportEXTProperty(). The loss is reported rather than silent.

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 SkinningData; use ModelAnimationsEXT for a rigid model");
        }
        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 name is absent 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