Categories
Blog

XNA Skinned Model Animations

The Skinned Model sample from the App Hub education catalogue is great for getting animated characters into your game, but there’s a bit of a flaw with the export process. The problem is, when you export your character from 3DS Max (and possibly other modelling programs), all you get is one animation, named ‘Take 001’. Wouldn’t it be nice if we could define different animations for different parts of the animation timeline? Well, we’re going to do just that :). As an added bonus, we’ll also be adding in events, so you can be notified when certain parts of your animation are hit.

This tutorial is based on the Skinned Model sample, so grab it from the App Hub if you want to follow along, or skip to the end if you want the final version (which is released under the same license as the original).

What we’ll be doing is creating an XML file to go with our exported model, which will define our animation clips and events. The animations defined in this file will replace animations defined in the source model file. So, first thing is to define the class that will be represented by the XML file. Create a class in the SkinnedModelPipeline project named AnimationDefinition, like so:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Intermediate;
namespace SkinnedModelPipeline
{
    /// <summary>
    /// A class for storing our animation definitions
    /// </summary>
    public class AnimationDefinition
    {
        /// <summary>
        /// The original clip name that was exported by the modelling package
        /// Usually this will be Take 001
        /// </summary>
        public string OriginalClipName
        {
            get;
            set;
        }
        /// <summary>
        /// The number of frames in the original animation
        /// </summary>
        public int OriginalFrameCount
        {
            get;
            set;
        }
        /// <summary>
        /// A class for storing information about individual clips that we want to create
        /// </summary>
        public class ClipPart
        {
            /// <summary>
            /// The name we have given the clip
            /// </summary>
            public string ClipName
            {
                get;
                set;
            }
            /// <summary>
            /// The starting frame of the clip
            /// </summary>
            public int StartFrame
            {
                get;
                set;
            }
            /// <summary>
            /// The ending frame of the clip
            /// </summary>
            public int EndFrame
            {
                get;
                set;
            }
            /// <summary>
            /// A class for defining events in an animation
            /// </summary>
            public class Event
            {
                /// <summary>
                /// The name of the event
                /// </summary>
                public string Name
                {
                    get;
                    set;
                }
                /// <summary>
                /// The frame that the event fires on
                /// </summary>
                public int Keyframe
                {
                    get;
                    set;
                }
            };
            /// <summary>
            /// Our list of events in this animation clip
            /// Animation clips do not require events, so this is marked as optional
            /// </summary>
            [Microsoft.Xna.Framework.Content.ContentSerializer(Optional = true)]
            public List<event> Events
            {
                get;
                set;
            }
        };
        /// <summary>
        /// The list of clip parts that we are breaking the original clip into
        /// </summary>
        public List<clipPart> ClipParts
        {
            get;
            set;
        }
    }
}

Next, we’ll need to modify the runtime project to add information about our events to the animations. Create a class in the SkinnedModelWindows project named AnimationEvent, with this in it:

#region Using Statements
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
#endregion
namespace SkinnedModel
{
    /// <summary>
    /// Information about an event in our animation
    /// </summary>
    public class AnimationEvent
    {
        /// <summary>
        /// The name of the event
        /// </summary>
        public String EventName
        {
            get;
            set;
        }
        /// <summary>
        /// The time of the event
        /// </summary>
        public TimeSpan EventTime
        {
            get;
            set;
        }
    }
}

Now, we need to add our events store to the animation clip, as well as the clip name. So open up AnimationClip.cs, and add this to the end of the class:

	/// <summary>
        /// Callback events for the animation clips
        /// </summary>
        [ContentSerializer]
        public List<animationEvent> Events { get; private set; }
	/// <summary>
        /// The name of the clip
        /// </summary>
        [ContentSerializer]
        public string Name { get; private set; }

We also need to modify the constructor so that we can pass in the list of animation events when we create the clip. Our new constructor looks like this:

	/// <summary>
        /// Constructs a new animation clip object.
        /// </summary>
        public AnimationClip(TimeSpan duration, List<keyframe> keyframes, List<animationEvent> events, string name)
        {
            Duration = duration;
            Keyframes = keyframes;
            Events = events;
            Name = name;
        }

Almost there. We need to modify the SkinnedModelProcessor class so that it reads in our XML files describing our animations and stores them in the model file that it generates, replacing the original animation (the Take 001). So, in SkinnedModelProcessor.cs, in the ProcessAnimations function, we need to first check if an animation definition file exists that we will use to override the ones in the model. By having this check, it means that we don’t need to create an animation definition for every skinned model, just the ones that we want custom animations on. You’ll also need to modify the ProcessAnimations function to take two extra parameters, which are the ContentProcessorContext and ContentIdentity. We use these to get information about the current file we are processing, so we can look for an animation definition with the same name. The below code is the updated ProcessAnimations function:

        /// <summary>
        /// Converts an intermediate format content pipeline AnimationContentDictionary
        /// object to our runtime AnimationClip format.
        /// </summary>
        static Dictionary<string, AnimationClip> ProcessAnimations(
            AnimationContentDictionary animations, IList<boneContent> bones,
            ContentProcessorContext context, ContentIdentity sourceIdentity)
        {
            // Build up a table mapping bone names to indices.
            Dictionary<string, int> boneMap = new Dictionary<string, int>();
            for (int i = 0; i < bones.Count; i++)
            {
                string boneName = bones[i].Name;
                if (!string.IsNullOrEmpty(boneName))
                    boneMap.Add(boneName, i);
            }
            // Convert each animation in turn.
            Dictionary<string, AnimationClip> animationClips;
            animationClips = new Dictionary<string, AnimationClip>();
            // We process the original animation first, so we can use their keyframes
            foreach (KeyValuePair<string, AnimationContent> animation in animations)
            {
                AnimationClip processed = ProcessAnimation(animation.Value, boneMap, animation.Key);
                animationClips.Add(animation.Key, processed);
            }
            // Check to see if there's an animation clip definition
            // Here, we're checking for a file with the _Anims suffix.
            // So, if your model is named dude.fbx, we'd add dude_Anims.xml in the same folder
            // and the pipeline will see the file and use it to override the animations in the
            // original model file.
            string SourceModelFile = sourceIdentity.SourceFilename;
            string SourcePath = Path.GetDirectoryName(SourceModelFile);
            string AnimFilename = Path.GetFileNameWithoutExtension(SourceModelFile);
            AnimFilename += "_Anims.xml";
            string AnimPath = Path.Combine(SourcePath, AnimFilename);
            if (File.Exists(AnimPath))
            {
                // Add the filename as a dependency, so if it changes, the model is rebuilt
                context.AddDependency(AnimPath);
                // Load the animation definition from the XML file
                AnimationDefinition AnimDef = context.BuildAndLoadAsset<xmlImporter, AnimationDefinition>(new ExternalReference<xmlImporter>(AnimPath), null);
                // Break up the original animation clips into our new clips
                // First, we check if the clips contains our clip to break up
                if (animationClips.ContainsKey(AnimDef.OriginalClipName))
                {
                    // Grab the main clip that we are using
                    AnimationClip MainClip = animationClips[AnimDef.OriginalClipName];
                    // Now remove the original clip from our animations
                    animationClips.Remove(AnimDef.OriginalClipName);
                    // Process each of our new animation clip parts
                    foreach (AnimationDefinition.ClipPart Part in AnimDef.ClipParts)
                    {
                        // Calculate the frame times
                        TimeSpan StartTime = GetTimeSpanForFrame(Part.StartFrame, AnimDef.OriginalFrameCount, MainClip.Duration.Ticks);
                        TimeSpan EndTime = GetTimeSpanForFrame(Part.EndFrame, AnimDef.OriginalFrameCount, MainClip.Duration.Ticks);
                        // Get all the keyframes for the animation clip
                        // that fall within the start and end time
                        List<keyframe> Keyframes = new List<keyframe>();
                        foreach (Keyframe AnimFrame in MainClip.Keyframes)
                        {
                            if ((AnimFrame.Time >= StartTime) && (AnimFrame.Time <= EndTime))
                            {
                                Keyframe NewFrame = new Keyframe(AnimFrame.Bone, AnimFrame.Time - StartTime, AnimFrame.Transform);
                                Keyframes.Add(NewFrame);
                            }
                        }
                        // Process the events
                        List<animationEvent> Events = new List<animationEvent>();
                        if (Part.Events != null)
                        {
                            // Process each event
                            foreach (AnimationDefinition.ClipPart.Event Event in Part.Events)
                            {
                                // Get the event time within the animation
                                TimeSpan EventTime = GetTimeSpanForFrame(Event.Keyframe, AnimDef.OriginalFrameCount, MainClip.Duration.Ticks);
                                // Offset the event time so it is relative to the start of the animation
                                EventTime -= StartTime;
                                // Create the event
                                AnimationEvent NewEvent = new AnimationEvent();
                                NewEvent.EventTime = EventTime;
                                NewEvent.EventName = Event.Name;
                                Events.Add(NewEvent);
                            }
                        }
                        // Create the clip
                        AnimationClip NewClip = new AnimationClip(EndTime - StartTime, Keyframes, Events, Part.ClipName);
                        animationClips[Part.ClipName] = NewClip;
                    }
                }
            }
            if (animationClips.Count == 0)
            {
                throw new InvalidContentException(
                            "Input file does not contain any animations.");
            }
            return animationClips;
        }
        /// <summary>
        /// Gets a TimeSpan value for a frame index in an animation
        /// </summary>
        private static TimeSpan GetTimeSpanForFrame(int FrameIndex, int TotalFrameCount, long TotalTicks)
        {
            float MaxFrameIndex = (float)TotalFrameCount - 1;
            float AmountOfAnimation = (float)FrameIndex / MaxFrameIndex;
            float NumTicks = AmountOfAnimation * (float)TotalTicks;
            return new TimeSpan((long)NumTicks);
        }

What we do is process the original animations, so that all the keyframe data is there, then we check for an animation definition file and, if it exists, use it to replace the animation clips from the original model. We also need to modify ProcessAnimation to handle the new AnimationClip constructor:

        /// <summary>
        /// Converts an intermediate format content pipeline AnimationContent
        /// object to our runtime AnimationClip format.
        /// </summary>
        static AnimationClip ProcessAnimation(AnimationContent animation,
                                              Dictionary<string, int> boneMap,
                                              string clipName)
        {
            List<keyframe> keyframes = new List<keyframe>();
            // For each input animation channel.
            foreach (KeyValuePair<string, AnimationChannel> channel in
                animation.Channels)
            {
                // Look up what bone this channel is controlling.
                int boneIndex;
                if (!boneMap.TryGetValue(channel.Key, out boneIndex))
                {
                    throw new InvalidContentException(string.Format(
                        "Found animation for bone '{0}', " +
                        "which is not part of the skeleton.", channel.Key));
                }
                // Convert the keyframe data.
                foreach (AnimationKeyframe keyframe in channel.Value)
                {
                    keyframes.Add(new Keyframe(boneIndex, keyframe.Time,
                                               keyframe.Transform));
                }
            }
            // Sort the merged keyframes by time.
            keyframes.Sort(CompareKeyframeTimes);
            if (keyframes.Count == 0)
                throw new InvalidContentException("Animation has no keyframes.");
            if (animation.Duration <= TimeSpan.Zero)
                throw new InvalidContentException("Animation has a zero duration.");
            return new AnimationClip(animation.Duration, keyframes, new List<animationEvent>(), clipName);
        }

OK, so now we can override animations in the models using our XML file. Before I show you an example file, there’s one last thing to do, which is to add in the event callback system into the runtime. So, we need to add in a place to register our event callbacks into the AnimationPlayer class. At the end of the ‘Fields’ region, we need to add:

	// The delegate template for the event callbacks
        public delegate void EventCallback(string Event);
        // The reigstered events
        Dictionary<string, Dictionary<string, EventCallback>> registeredEvents = new Dictionary<string, Dictionary<string, EventCallback>>();
        public Dictionary<string, Dictionary<string, EventCallback>> RegisteredEvents
        {
            get { return registeredEvents; }
        }

And initialise the registeredEvents dictionary in the constructor:

	// Construct the event dictionaries for each clip
        foreach (string clipName in skinningData.AnimationClips.Keys)
        {
            registeredEvents[clipName] = new Dictionary<string, EventCallback>();
        }

Now we can add events, so the last thing to do is to add the code that calls them, then we’ll be done. In the UpdateBoneTransforms function in AnimationPlayer, we need to modify it like so:

        /// <summary>
        /// Helper used by the Update method to refresh the BoneTransforms data.
        /// </summary>
        public void UpdateBoneTransforms(TimeSpan time, bool relativeToCurrentTime)
        {
            if (currentClipValue == null)
                throw new InvalidOperationException(
                            "AnimationPlayer.Update was called before StartClip");
            // Store the previous time
            TimeSpan lastTime = time;
            // Update the animation position.
            if (relativeToCurrentTime)
            {
                lastTime = currentTimeValue;
                time += currentTimeValue;
                // Check for events
                CheckEvents(ref time, ref lastTime);
                // If we reached the end, loop back to the start.
                bool hasLooped = false;
                while (time >= currentClipValue.Duration)
                {
                    hasLooped = true;
                    time -= currentClipValue.Duration;
                }
                // If we've looped, reprocess the events
                if (hasLooped)
                {
                    CheckEvents(ref time, ref lastTime);
                }
            }
            if ((time < TimeSpan.Zero) || (time >= currentClipValue.Duration))
                throw new ArgumentOutOfRangeException("time");
            // If the position moved backwards, reset the keyframe index.
            bool HasResetKeyframe = false;
            if (time < currentTimeValue)
            {
                HasResetKeyframe = true;
                currentKeyframe = 0;
                skinningDataValue.BindPose.CopyTo(boneTransforms, 0);
            }
            currentTimeValue = time;
            // Read keyframe matrices.
            IList<keyframe> keyframes = currentClipValue.Keyframes;
            while (currentKeyframe < keyframes.Count)
            {
                Keyframe keyframe = keyframes[currentKeyframe];
                // Stop when we've read up to the current time position.
                if ((keyframe.Time > currentTimeValue) && (!HasResetKeyframe))
                    break;
                // Use this keyframe.
                boneTransforms[keyframe.Bone] = keyframe.Transform;
                currentKeyframe++;
                if(HasResetKeyframe)
                {
                    currentTimeValue = keyframe.Time;
                    HasResetKeyframe = false;
                }
            }
        }

And add the CheckEvents function:

        /// <summary>
        /// Checks to see if any events have passed
        /// </summary>
        private void CheckEvents(ref TimeSpan time, ref TimeSpan lastTime)
        {
            foreach (string eventName in registeredEvents[currentClipValue.Name].Keys)
            {
                // Find the event
                foreach (AnimationEvent animEvent in currentClipValue.Events)
                {
                    if (animEvent.EventName == eventName)
                    {
                        TimeSpan eventTime = animEvent.EventTime;
                        if ((lastTime < eventTime) && (time >= eventTime))
                        {
                            // Call the event
                            registeredEvents[currentClipValue.Name][eventName](eventName);
                        }
                    }
                }
            }
        }

So now we can define custom animations with events. Lets see it in action…

Add a new file to your content project, giving it the same name as the model, but with _Anims.xml. In our case, our model file is dude.fbx, so we want dude_Anims.xml. We don’t actually want the content pipeline to build this directly, so set the Build Action property to None, the Content Processor to No Processing Required, and Copy to Output Directory to Do not copy. Our XML looks like this:

<?xml version="1.0" encoding="utf-8" ?>
<xnaContent>
  <asset Type="SkinnedModelPipeline.AnimationDefinition">
    <!-- The original name of the clip we are breaking up -->
    <originalClipName>Take 001</originalClipName>
    <!-- The total number of frames in the original clip -->
    <originalFrameCount>100</originalFrameCount>
    <!-- The new parts we want -->
    <clipParts>
      <!-- Each item is one of our new clips -->
      <item>
        <clipName>Idle</clipName>
        <startFrame>0</startFrame>
        <endFrame>50</endFrame>
      </item>
      <item>
        <clipName>Fire</clipName>
        <startFrame>51</startFrame>
        <endFrame>99</endFrame>
        <!-- We can register events in this clip, so we can know when certain frames are hit -->
        <events>
          <item>
            <name>FireFrame</name>
            <keyframe>70</keyframe>
          </item>
        </events>
      </item>
    </clipParts>
  </asset>
</xnaContent>

Now we can play our new clips like normal. We can also add callbacks for events. For example, the FireFrame event, we add like this:

            // Create an animation player, and start decoding an animation clip.
            animationPlayer = new AnimationPlayer(skinningData);
            // Register an event
            animationPlayer.RegisteredEvents["Fire"].Add("FireFrame", new AnimationPlayer.EventCallback(OnFire));
            AnimationClip clip = skinningData.AnimationClips["Fire"];
            animationPlayer.StartClip(clip);

And our function just looks like this:

        private void OnFire(string EventName)
        {
            // Do something here like create a bullet
        }

So there you have it. You can download the updated sample here. You can use the ‘1’ and ‘2’ keys to switch between animation clips. The second one has a callback registered to it. One thing to note is that you’ll have to make sure you do a Rebuild Solution if you’re adding an animation XML to an existing model, because the content pipeline doesn’t know that the model depends on the animation definition until after it has built it once with the animation file there. Enjoy!