Animation System
Introduction
Before I moved to Stockholm to become a game programmer, I spent my free time learning game development by getting to know software like Blender and game engines like Unity and Unreal Engine. While working on my little projects, the thing I loved to do most (second to programming) was animating characters.
So when the time came to implement animation in my graphics engine I was excited! An animation system is a delicate machine and there are a lot of things that can go wrong. I spent many late hours and a lot of trying, failing, and trying again. But eventually I succeeded! When I finally got it to work it was easy for me to transfer it from my engine to the group’s engine, since I co-created the graphics engine with another member of the group.
Implementation
Initially I had to create data structures for the data FBX SDK would give me from the .fbx file.
Our ModelData
class will contain a skeleton which contains all the bones. Each bone has a bind pose, the pose in which the model is created and rigged. These are essential when calculating the final transform of the bones later.
struct Bone
{
std::string myName = "None";
DirectX::XMMATRIX myBindPose = {};
int myParentIndex = -1;
};
struct Skeleton
{
int myRootBoneIndex = -1;
std::vector<Bone> myBones = {};
std::vector<std::string> myBoneNames = {};
};
An animation clip contains keyframes, consisting of transform matrices for each bone relative to its parent. This decides which pose a skeleton should have at a certain frame.
struct Keyframe
{
std::vector<DirectX::XMMATRIX> myBoneTransforms{};
};
struct AnimationClip
{
std::string myName = "None";
float myFps = 0.0f;
float myDuration = 0.0f;
std::vector<Keyframe> myKeyframes = {};
std::vector<AnimationEvent*> myEvents = {};
};
Playback
An animation is made up of keyframes. If your animation have 24 frames per second (FPS) and you play it on a lower speed without interpolation, the bones will appear to jump in place from one keyframe to another like this.
When interpolating, the animation player will calculate the pose between keyframe N and N+1, lerping between them to make a smooth transition between the frames.
In this code, we interpolate between keyframes and apply the pose to a buffer that we pass to the model’s vertex shader:
// Calculate the current frame and delta
const float frameRate = 1.0f / aOutAnimation.myClip->fps;
const float result = aOutAnimation.myTime / frameRate;
const int frame = static_cast<int>(std::floor(result)); // Current frame
const float delta = result - static_cast<float>(frame); // Progress to the next frame
aOutAnimation.UpdateEvents(frame);
// Interpolate between current and next frame
for (size_t i = 0; i < skeleton->myBones.size(); i++)
{
DirectX::XMMATRIX currentFramePose = aOutAnimation.myClip->keyframes[frame].boneTransforms[i];
DirectX::XMMATRIX nextFramePose = aOutAnimation.myClip->keyframes[(frame + 1) %
aOutAnimation.myClip->keyframes.size()].boneTransforms[i];
// Interpolate between current and next frame using delta
DirectX::XMMATRIX blendedPose = currentFramePose + delta * (nextFramePose - currentFramePose);
const int parentIndex = skeleton->myBones[i].myParentIndex;
if (parentIndex >= 0)
{
// Accumulate relative transformation
aOutAnimation.myCombinedTransforms[i] = blendedPose *
aOutAnimation.myCombinedTransforms[parentIndex];
}
else
{
// Root bone, use absolute transformation
aOutAnimation.myCombinedTransforms[i] = blendedPose;
}
aOutAnimation.myFinalTransforms[i] = skeleton->myBones[i].myBindPose *
aOutAnimation.myCombinedTransforms[i];
}
Blending
Initially, blending between animations was a bit tricky to achieve the expected results. What helped blending poses correctly was, instead of directly applying an animation clip on a character, I created an Animation
class:
struct Animation
{
AnimationClip* myClip = nullptr;
float myTime = 0.0f;
float mySpeed = 1.0f;
bool isPlaying = false;
bool isLooping = true;
std::vector<DirectX::XMMATRIX> myCombinedTransforms = {};
std::vector<DirectX::XMMATRIX> myFinalTransforms = {};
}
I could then play an animation clip directly to an animation object without even having a character. Blending poses was then just a simple linear interpolation between two animations. The final animation is then applied to the character.
for (size_t i = 0; i < skeleton->myBones.size(); i++)
{
{
const DirectX::XMMATRIX fromFramePose = aFromAnimation.myCombinedTransforms[i];
const DirectX::XMMATRIX toFramePose = aToAnimation.myCombinedTransforms[i];
// Blended pose needs to be multiplication of decomposed matrices
DirectX::XMVECTOR fromScale, fromRotation, fromTranslation;
DirectX::XMVECTOR toScale, toRotation, toTranslation;
DirectX::XMMatrixDecompose(&fromScale, &fromRotation, &fromTranslation, fromFramePose);
DirectX::XMMatrixDecompose(&toScale, &toRotation, &toTranslation, toFramePose);
DirectX::XMVECTOR rotationOrigin = DirectX::XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f);
DirectX::XMMATRIX blendedPose = DirectX::XMMatrixAffineTransformation(
DirectX::XMVectorLerp(fromScale, toScale, aBlendFactor),
rotationOrigin,
DirectX::XMQuaternionSlerp(fromRotation, toRotation, aBlendFactor),
DirectX::XMVectorLerp(fromTranslation, toTranslation, aBlendFactor));
modelData->myFinalTransforms[i] = skeleton->myBones[i].myBindPose * blendedPose;
}
}
Animation events
For Harbor Havoc, I introduced animation events, so that when passing a certain frame, an event is sent. Listeners to the event can then play a sound or a VFX at that frame.
struct AnimationEvent : ES::Event
{
AnimationEvent() = default;
~AnimationEvent() = default;
std::string myName = "None";
bool myIsTriggeredThisFrame = false;
unsigned int myFrameIndex = 0;
};
void UpdateEvents(const unsigned aFrame) const
{
for (const auto& event : myClip->myEvents)
{
if (event->myIsTriggeredThisFrame)
{
continue;
}
if (event->myFrameIndex > aFrame)
{
event->myIsTriggeredThisFrame = true;
ES::EventSystem::GetInstance().QueueEvent(event);
}
}
}