Managed Direct3D: Animation
In the article about using frames I showed a basic example of how you can animate a frame hierarchy by accessing individual frames and calculate the aggregated transformations with information dynamically calculated somewhere else in the application. In this article I will show how you can use pre-calculated/pre-modelled animations in your managed directx application.
Feedback
If you find this useful, have any questions, or find any errors, please let me know.
Animation Sets
An AnimationSet contains one or more animations. Each animation contains a list of animation keys, which specifies transformations that are to be applied to a frame at certain time intervals. These key frames are then used to calculate the transformation that are to be applied to the geometry at a specific time in the animation. If we are rendering our geometry at time t and we have key frames specified for t0 and t1 and t0 < t < t1, then the current transformation to be applied is interpolated between the transformation specified at t0 and the one specified at t1.
I personally find it informative to view how animation sets are stored in a DirectX file. I think it gives you an overview of how the different entities relate to each other. The following example shows an animation set for rotating the wheels of a vehicle. The animation set is named RotateWheels and contains one animation for each wheel. The first animation is named Anim-Wheel01 and contains a set of animation keys in the form of a list of transformation matrices that are to be applied to the frame Wheel01 at certain times in the animation. Note, this animation is automatically generated and made more complex than needed. We do not need this many keys to describe the rotation of a wheel.
AnimationSet RotateWheels {
Animation Anim-Wheel01 {
{ Wheel01 } // The frame that this animation applies to
AnimationKey {
4; // Type of keys
101; // Number of keys
0; // The time to apply this key
16; // No. of elements in transformation
0.321631,0.000000,0.000000,0.000000,
0.000000,0.321631,0.000000,0.000000,
0.000000,0.000000,0.321631,0.000000,
-35.858887,8.454094,34.258354,1.000000;;,
160;16;
0.321631,0.000000,0.000000,0.000000,
0.000000,0.320997,0.020195,0.000000,
0.000000,-0.020195,0.320997,0.000000,
35.858887,8.454094,-38.623192,1.000000;;,
...
}
}
Animation Anim-Wheel02 {
{ Wheel02 }
AnimationKey {
...
}
}
...
}
The first field in the AnimationKey structure specifies how the key frames are described. In the example above we use matrices but it is also possible to describe the scaling, rotation, and translation separately.
Loading an Animation Set
In my article about using frame hierarchies I used the AnimationRootFrame class to store the frame hierarchy. In addition to exposing the FrameHierarchy property, which was used in that article, the AnimationRootFrame also exposes the AnimationController property, which can be used to control animations. As we already have an AnimationController object in place for storing the information we do not have to alter the code for loading a DirectX file with animation data. To recap, here is the code snippet from the last article:
// Load the frame hierarchy from file.
public virtual void Initialize()
{
this.rootFrame = Mesh.LoadHierarchyFromFile(this.xFilePath,
MeshFlags.Managed,
this.device,
new CustomAllocateHierarchy(this.xFilePath),
null);
}
If the DirectX file contains only one animation set, we can begin animating by calling the AnimationController.AdvanceTime method each time we render the scene:
// Calculate how much we are to advance the animation
float elapsed = (float)timer.GetElapsedTime();
AnimationController ac = this.rootFrame.AnimationController;
ac.AdvanceTime(elapsed, null);
We first calculate how much time has passed since we last advanced the animation, then we simply pass that time to the the AdvanceTime method, which handles all interpolation between key frames automatically and applies the resulting transform on the frame of the current animation set. The timer class is described later in this article.
The AnimationController
The AnimationController class encapsulates the animation of animation sets. It introduces the concept of tracks, which are entities that contain information about one part of the animation, and events that are scheduled entities that enables you to specify a change in the animation at a specific time in the animation and how the tranisition from the current state and the new state should be handled.
The AnimationController contains the animation sets that are to be used in the animation. To get a reference to one of the animation sets you use the AnimationController.GetTrackAnimationSet method, which according to the documentation retrieves the animation set for a track. This is not true. It returns the animation set of the animation controller specified by the index. The following code shows this:
AnimationController ac = this.rootFrame.AnimationController;
for(int i=0; i<ac.MaxNumberAnimationSets; i++)
{
AnimationSet aset = ac.GetTrackAnimationSet(i);
Debug.WriteLine(aset.Name);
}
Microsoft DirectX 9.0 SDK Update (Summer 2004): In the summer update 2004 the AnimationController.GetTrackAnimationSet works as expected; it retrieves the animation set associated with a track. In addition, the AnimationController.GetAnimationSet method with two overloads is added, which is used to retrieve an animation set that is registered in the AnimationController, either by index or name.
The code above accesses the animation sets associated with the AnimationController by their index and outputs their name to the trace listener. This does not get the animationsets for the tracks, it gets the animation sets that you can potentially associate with a track. There is a difference.
Tracks
An AnimationController object may contain several tracks that may be played simultaneously and mixed mathematically to achieve an animation. Unfortunately there exists no Track class that exposes the properties and methods of a track. Instead the AnimationController class exposes methods by which you can manipulate the tracks.
| Property | Accessor Method | Description |
|---|---|---|
| "AnimationSet" | SetTrackAnimationSet | Associates an AnimationSet with a track. |
| "Enable" | SetTrackEnable | Enables the track. Only enabled tracks are used in the animation. |
| "Position" | SetTrackPosition | Sets the position of the track. Specified in the local time of the track. |
| "Speed" | SetTrackSpeed | Sets the speed of the track. |
| "Priority" | SetTrackPriority | Either PriorityType.Low or PriorityType.High. This is used when blending tracks. Tracks with the same priority are blended together first. The results are then blended together to produce the final output. |
| "Weight" | SetTrackWeight | A value between 0 and 1. Determines the weight of the track when blending. |
The AnimationSet, Enable, Position, and Speed properties of a track are pretty obvious. The Priority and Weight are used when blending tracks. Imagine that you have a model of a character in a game and that the character is supposed to be able to walk, shoot, and walk and shoot at the same time. Without track blending the modeller would have to produce three animations, but with track blending you only need two animations, walk and shoot, and can blend these together to produce the walk and shoot animation. I will not get into the details of blending until a later article.
The following code snippet sets up the properties for track 0 of an AnimationController object:
AnimationController ac = this.rootFrame.AnimationController;
// Enable track 0.
ac.SetTrackEnable(0, true);
// Set track 0 at it's start position.
ac.SetTrackPosition(0, 0.0);
// Set the tracks speed to two times the normal speed.
ac.SetTrackSpeed(0, 2);
// Associate animation set 2 with track 0.
ac.SetTrackAnimationSet(0, ac.GetAnimationSet(2));
Key Events
Key events enables you to specify changes to the properties discussed above at specific times in the animation. You can also specify how the change of the property is to be performed. The following code shows an example of how you can use key events in your animations:
AnimationController ac = this.rootFrame.AnimationController;
ac.KeyTrackSpeed(0, 5.0F, 10.0, 5.0, TransitionType.EaseInEaseOut);
This sets up an event 10.0 seconds into the animation for track 0. The event will adjust the speed of the animation for this track to 5 times normal speed, and the transition will take 5.0 seconds.
Timing
When performing animations you should always determine how much time has elapsed since the last time when you are to render a scene. Imagine that you are to rotate an object. If you ignore how much time has passed between each rendering and simply adds a constant amount to the rotation angle, then on faster machines the rotation will be faster than on slower machines. But if you take into account how much time has actually passed, the slower machine will rotate the object more each time it is rendered, thus compensating that the rendering is done more seldom.
The following class may be used to retrieve the time elapsed between calls. It uses the QueryPerformanceCounter API, which allows for a more accurate timing if the hardware supports it. A more comprehensive implementation can be found in dxutil.cs, which comes with the SDK. The timer in the SDK falls back to other means of determine the time if the hardware does not support the QueryPerfomanceCounter API.
public enum TimerState{ Stopped, Running}
public class QPTimer
{
[System.Security.SuppressUnmanagedCodeSecurity]
[DllImport("kernel32")]
private static extern bool QueryPerformanceFrequency(ref long PerformanceFrequency);
[System.Security.SuppressUnmanagedCodeSecurity]
[DllImport("kernel32")]
private static extern bool QueryPerformanceCounter(ref long PerformanceCount);
private long tickFreq;
private TimerState timerState;
private long lastTickCount;
public QPTimer()
{
this.timerState = TimerState.Stopped;
if(QueryPerformanceFrequency(ref tickFreq) == false)
{
throw new ApplicationException("Failed to query for the performance frequency!");
}
}
public void Start()
{
this.timerState = TimerState.Stopped;
this.lastTickCount = GetCurrentCount();
this.timerState = TimerState.Running;
}
public void Stop()
{
this.timerState = TimerState.Stopped;
}
public double GetElapsedTime()
{
if(this.timerState == TimerState.Stopped)
{
throw new ApplicationException("Timer is not running!");
}
long currTick = GetCurrentCount();
double elapsed = (currTick-this.lastTickCount)/(double)this.tickFreq;
this.lastTickCount = currTick;
return elapsed;
}
public TimerState State
{
get
{
return this.timerState;
}
}
protected long GetCurrentCount()
{
long tickCount = 0;
if(QueryPerformanceCounter(ref tickCount) == false)
{
throw new ApplicationException("Failed to query performance counter!");
}
return tickCount;
}
}
Using Multiple Animation Sets Simultaneously
The DirectX file described in the beginning of the article contained one AnimationSet, which rotated all of the wheels of a vehicle. Imagine that we want to be able to rotate each wheel independently of the others. If a vehicle is turning the wheels turn with different speeds; the inner wheels turn more slowly than the outer wheels. An animation where all wheels turn with the same speed would look weird. In this DirectX file I have split up the animations into four AnimationSets, one for each wheel, named RotateWheel01, RotateWheel02, RotateWheel03, and RotateWheel04.
To rotate the wheels separately we want to use an AnimationController with four tracks, one for each AnimationSet. We can then set the speed of each track in order to control the speed of each individual wheel. Then we just call AnimationController.AdvanceTime each time we render the scene. When loading the file, using the Mesh.LoadHierarchyFromFile method, we run into a problem. The number of tracks in the AnimationController of the AnimationRootFrame created for us has the maximum number of tracks hardcoded to 2! The AnimationController.MaxNumberTracks is read-only so we have no way of changing it to 4, which is what we need.
A workaround is to clone the AnimationController created for us. The AnimationController.Clone method allows us to create a clone of an existing AnimationController object. The Clone method also lets us specify some of the properties of the clone, such as the number of tracks. This means that we cannot use the AnimationController created for us in the AnimationRootFrame object for animating, which I find very disturbing! The following code will create a clone and set it's MaxNumberTracks property to 4:
// Create a clone of the AnimationController stored in our AnimationRootFrame
AnimationController ac = this.rootFrame.AnimationController;
this.animCtrl = ac.Clone(ac.MaxNumberAnimationOutputs,
ac.MaxNumberAnimationSets, 4, ac.MaxNumberEvents);
After this operation is performed, we use the cloned AnimationController object (this.animCtrl) instead of the object created for us in the AnimationRootFrame object (this.rootFrame.AnimationController). I find this a bit messy, and if you have a better method I urge you to let me know about it!
A Complete Example
There are a couple of things that I have not covered in this article, such as how blending animations that operate on the same vertices. But as want to keep each article concentrated on as few issues as possible, I choose to wait until a later article.
The following example rotates the four wheels of a tank independently by using four animation tracks. It also sets a key event in each track to change the speed and direction of the wheels at a specific time into the animation. This example builds on the one used last time. The DirectX file used may be found here.
Microsoft DirectX 9.0 SDK Update (Summer 2004): The following code has been updated to work with the summer 2004 update. This means that you should be referencing the following assemblies in order to get it to work (note: the version number of the referenced assemblies must be correct!): Microsoft.DirectX version 1.0.2902.0, Microsoft.DirectX.Direct3D version 1.0.2902.0, Microsoft.DirectX.Direct3DX version 1.0.2902.0.
using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
using System.IO;
using System.Runtime.InteropServices;
using Microsoft.DirectX;
using Microsoft.DirectX.Direct3D;
public class CustomFrame : Frame
{
// This transformation matrix allows us to add a transformation
// to an individual frame within the frame hierarchy.
protected Matrix customTransform = Matrix.Identity;
public CustomFrame() : base()
{
}
public CustomFrame(string name) : base()
{
this.Name = name;
}
public Matrix CustomTransform
{
get
{
return this.customTransform;
}
set
{
this.customTransform = value;
}
}
}
public class CustomMeshContainer : MeshContainer
{
private string xFilePath;
private Texture[] textures;
public CustomMeshContainer(string xFilePath, string name, MeshData meshData, ExtendedMaterial[] materials,
EffectInstance[] effectInstances, GraphicsStream adjacency, SkinInformation skinInfo) : base()
{
this.Name = name;
this.MeshData = meshData;
this.SetMaterials(materials);
this.SetEffectInstances(effectInstances);
this.SetAdjacency(adjacency);
this.SkinInformation = skinInfo;
this.textures = null;
this.xFilePath = xFilePath;
}
public void Initialize()
{
// Load the textures for the mesh.
ExtendedMaterial[] m = this.GetMaterials();
this.textures = new Texture[m.Length];
for(int i=0; i<m.Length; i++)
{
if(m[i].TextureFilename != null)
{
string path =
Path.Combine(Path.GetDirectoryName(this.xFilePath), m[i].TextureFilename);
this.textures[i] = TextureLoader.FromFile(this.MeshData.Mesh.Device, path);
}
}
}
public Texture[] Textures
{
get
{
return this.textures;
}
}
}
public class CustomAllocateHierarchy : AllocateHierarchy
{
private string xFilePath;
public CustomAllocateHierarchy(string xFilePath) : base()
{
this.xFilePath = xFilePath;
}
public override Frame CreateFrame(string name)
{
return new CustomFrame(name);
}
public override MeshContainer CreateMeshContainer(string name, MeshData meshData,
ExtendedMaterial[] materials, EffectInstance[] effectInstances, GraphicsStream adjacency,
SkinInformation skinInfo)
{
CustomMeshContainer mc = new CustomMeshContainer(this.xFilePath, name, meshData, materials,
effectInstances, adjacency, skinInfo);
mc.Initialize();
return mc;
}
}
public class GraphicsObject
{
// Path to the x-file containing the frame hierarchy for this GO.
protected string xFilePath;
// Container for the frames
protected AnimationRootFrame rootFrame;
// The device for the scene
protected Device device;
// We use our own AnimationController instead of
// the one supplied by the AnimationRootFrame. This
// gives us the opportunity to use more than 2
// tracks for animation.
protected const int MaxNumberAnimationTracks = 4;
protected AnimationController animCtrl;
// Event used to perform pre-render operations
public event EventHandler SetupCustomTransform;
public GraphicsObject(string xFilePath, Device device)
{
this.xFilePath = xFilePath;
this.device = device;
}
// Allow caller to get a hold of a named frame within the
// frame hierarchy.
public CustomFrame GetFrame(string name)
{
return (CustomFrame)Frame.Find(this.rootFrame.FrameHierarchy, name);
}
// Load the frame hierarchy from file.
public virtual void Initialize()
{
this.rootFrame = Mesh.LoadHierarchyFromFile(this.xFilePath,
MeshFlags.Managed,
this.device,
new CustomAllocateHierarchy(this.xFilePath),
null);
// Create an AnimationController with the number of tracks
// we need.
AnimationController ac = this.rootFrame.AnimationController;
this.animCtrl = ac.Clone(ac.MaxNumberAnimationOutputs, ac.MaxNumberAnimationSets,
GraphicsObject.MaxNumberAnimationTracks, ac.MaxNumberEvents);
}
// Render the frame hierarchy.
public bool Render(float elapsed)
{
// Allow any event listeners to adjust the custom transformation
// matrixes for the individual frames in the hierarchy.
if(this.SetupCustomTransform != null)
{
this.SetupCustomTransform(this, null);
}
// Advance the animation by the time elapsed since last time.
this.animCtrl.AdvanceTime(elapsed, null);
// Begin recusively rendering the frames.
// Use the identity matrix as the custom transform for the entire
// frame hierarchy.
this.RenderFrame(this.rootFrame.FrameHierarchy,
Matrix.Identity);
return true;
}
protected void RenderFrame(Frame frame, Matrix parentTransformationMatrix)
{
// First, render all sibling frames at this level passing the parent's
// aggregated transformation matrix.
if(frame.FrameSibling != null)
{
this.RenderFrame(frame.FrameSibling, parentTransformationMatrix);
}
// Aggregate the transformation matrix to be used for this frame.
// 1. Apply the frames transformation as specified in the x-file.
// 2. Apply the custom transformation for this individual frame.
// 3. Apply the parent's aggregated transformation matrix.
Matrix tm =
frame.TransformationMatrix *
((CustomFrame)frame).CustomTransform *
parentTransformationMatrix;
// Go on and render the children of this frame, passing the transformation
// we just aggregated.
if(frame.FrameFirstChild != null)
{
this.RenderFrame(frame.FrameFirstChild, tm);
}
// TODO: Adjust for the possibility of a mesh container hierarchy.
// Perform the actual rendering for this frame.
if(frame.MeshContainer != null)
this.DoRender(frame, tm);
}
protected void DoRender(Frame frame, Matrix m)
{
this.device.Transform.World = m;
ExtendedMaterial[] em = frame.MeshContainer.GetMaterials();
Texture[] t = ((CustomMeshContainer)(frame.MeshContainer)).Textures;
for(int i=0; i < em.Length; i++)
{
this.device.Material = em[i].Material3D;
if(t[i] != null)
{
this.device.SetTexture(0, t[i]);
}
frame.MeshContainer.MeshData.Mesh.DrawSubset(i);
}
}
}
public class Tank : GraphicsObject
{
protected float turretAngle = 0.0F;
protected float bearing = 0.0F;
protected Vector3 position = new Vector3(0,0,0);
protected float pitch = 0.0F;
protected float roll = 0.0F;
public Tank(string xFilePath, Device device) : base(xFilePath, device)
{
}
public override void Initialize()
{
base.Initialize();
this.SetupCustomTransform += new EventHandler(this.Tank_CalcCustomTransform);
// Set up the tracks for the animation.
for(int i=0; i<MaxNumberAnimationTracks; i++)
{
this.animCtrl.SetTrackEnable(i, true);
this.animCtrl.SetTrackPosition(i, 0.0);
this.animCtrl.SetTrackPriority(i, PriorityType.Low);
// Set the rotation of each wheel independently.
this.animCtrl.SetTrackSpeed(i, (float)(i+1));
AnimationSet animSet = this.animCtrl.GetAnimationSet(i);
this.animCtrl.SetTrackAnimationSet(i, animSet);
this.animCtrl.SetTrackWeight(i, 1.0F);
// Set up a track event that will change the speed of the wheels to ten times
// the original speed, backwards. The change will occur 10 seconds
// into the animation and the transition will take 5 seconds.
this.animCtrl.KeyTrackSpeed(i, -10.0F, 10.0, 5.0, TransitionType.EaseInEaseOut);
}
this.animCtrl.PriorityBlend = 1.0F;
}
public void Tank_CalcCustomTransform(object sender, EventArgs e)
{
this.GetFrame("Body").CustomTransform =
Matrix.RotationYawPitchRoll(this.bearing, this.pitch, this.roll)*
Matrix.Translation(this.position);
this.GetFrame("Turret").CustomTransform = Matrix.RotationY(this.turretAngle);
}
public float TurretAngle
{
get
{
return this.turretAngle;
}
set
{
this.turretAngle = value;
}
}
public float Bearing
{
get
{
return this.bearing;
}
set
{
this.bearing = value;
}
}
public Vector3 Position
{
get
{
return this.position;
}
set
{
this.position = value;
}
}
public float Pitch
{
get
{
return this.pitch;
}
set
{
this.pitch = value;
}
}
public float Roll
{
get
{
return this.roll;
}
set
{
this.roll = value;
}
}
}
public enum TimerState{ Stopped, Running}
public class QPTimer
{
[System.Security.SuppressUnmanagedCodeSecurity]
[DllImport("kernel32")]
private static extern bool QueryPerformanceFrequency(ref long PerformanceFrequency);
[System.Security.SuppressUnmanagedCodeSecurity]
[DllImport("kernel32")]
private static extern bool QueryPerformanceCounter(ref long PerformanceCount);
private long tickFreq;
private TimerState timerState;
private long lastTickCount;
public QPTimer()
{
this.timerState = TimerState.Stopped;
if(QueryPerformanceFrequency(ref tickFreq) == false)
{
throw new ApplicationException("Failed to query for the performance frequency!");
}
}
public void Start()
{
this.timerState = TimerState.Stopped;
this.lastTickCount = GetCurrentCount();
this.timerState = TimerState.Running;
}
public void Stop()
{
this.timerState = TimerState.Stopped;
}
public double GetElapsedTime()
{
if(this.timerState == TimerState.Stopped)
{
throw new ApplicationException("Timer is not running!");
}
long currTick = GetCurrentCount();
double elapsed = (currTick-this.lastTickCount)/(double)this.tickFreq;
this.lastTickCount = currTick;
return elapsed;
}
public TimerState State
{
get
{
return this.timerState;
}
}
protected long GetCurrentCount()
{
long tickCount = 0;
if(QueryPerformanceCounter(ref tickCount) == false)
{
throw new ApplicationException("Failed to query performance counter!");
}
return tickCount;
}
}
public class Form1 : Form
{
private Device device;
private Tank tank;
private QPTimer timer = new QPTimer();
private double elapsedTime = 0.0;
public Form1()
{
}
[STAThread]
static void Main()
{
Form1 app = new Form1();
app.Initialize();
app.Show();
while(app.Created)
{
app.StepTime();
app.UpdateGraphics();
app.Render();
Application.DoEvents();
}
}
private void Initialize()
{
this.SetupDevice();
this.tank = new Tank(@".\tank2.x", this.device);
this.tank.Initialize();
this.tank.Position = new Vector3(0, -50, 250);
this.tank.Bearing = (float)(Math.PI/2.0);
this.timer.Start();
}
private void SetupDevice()
{
PresentParameters presParams = new PresentParameters();
presParams.Windowed = true;
presParams.SwapEffect = SwapEffect.Discard;
presParams.AutoDepthStencilFormat = DepthFormat.D16;
presParams.EnableAutoDepthStencil = true ;
this.device = new Device(0, DeviceType.Hardware, this,
CreateFlags.SoftwareVertexProcessing, presParams);
this.device.RenderState.CullMode = Cull.CounterClockwise;
this.device.RenderState.ZBufferEnable = true;
device.RenderState.Lighting = true ;
device.Lights[0].Diffuse = Color.LightGray;
device.Lights[0].Specular = Color.DarkGray;
device.Lights[0].Type = LightType.Directional;
device.Lights[0].Direction = new Vector3(-1, -1, 3);
device.Lights[0].Enabled = true;
device.RenderState.Ambient = System.Drawing.Color.Bisque;
this.device.Transform.View = Matrix.LookAtLH( new Vector3(0, 0.5F, -10),
new Vector3(0, 0.5F, 0), new Vector3(0, 1, 0));
this.device.Transform.Projection =
Matrix.PerspectiveFovLH(( float )Math.PI/4.0F, 1.0F, 1.0F, 10000.0F);
}
protected void StepTime()
{
if(this.timer.State != TimerState.Running)
{
this.elapsedTime = 0.0;
this.timer.Start();
}
else
{
this.elapsedTime = this.timer.GetElapsedTime();
}
}
protected void UpdateGraphics()
{
this.tank.Bearing += (float)this.elapsedTime;
}
private void Render()
{
this.device.Clear(
ClearFlags.Target | ClearFlags.ZBuffer,
Color.Fuchsia, 1.0F, 0);
this.device.BeginScene();
this.tank.Render((float)this.elapsedTime);
this.device.EndScene();
try
{
this.device.Present();
}
catch(DeviceLostException)
{
}
}
}