XNA动画的实现5——通过ContentManager加载动画数据
上一篇文章中我们讨论了如何使用.Net框架的IO功能将动画数据进行串行化,但是这种方法只能在PC平台上使用,这是因为XNA使用资源的标准方式是通过内容管道将它们转换为xnb格式,发布XNA软件的时候只会包含xnb格式的资源,而Xbox360、Phone、Zune只能使用这种方式读取资源。但是如果你将上一篇文章的动画数据文件直接导入到Content项目中,编译后会报错:“XML is not in the XNA intermediate format. Missing XnaContent root element.”意思是XML文件不是XNA能识别的中间格式,它缺少一个XnaContent根节点,“XnaContent根节点”是什么,我们后面就会讲到。
这篇文章主要参考自XNA 4.0帮助文件中的Adding Art, Music, and Other Game Assets→Adding Content to a Game→Using an XML File to Specify Content下的第1至第4篇文章,相同内容的网上地址为:Using an XML File to Specify Content(http://msdn.microsoft.com/zh-cn/library/ff604981.aspx)。建议你首先看懂它,把其中的一个很简单的示例自己做一下,那么理解下面的内容并不难。
而动画数据类和动画播放的代码主要参考自XNA官网的Custom Model Rigid and Skinned Animations(http://create.msdn.com/en-US/education/catalog/sample/custom_model_rigid_and_skinned)示例,这个示例通过自定义内容导入器实现了刚体动画和蒙皮动画。
动画数据类库
使用现在的新方法,Content项目需要知道将xml串行化的对象的类型,即不仅需要在XNA程序中使用动画类,在Content项目中也要使用动画类,因此最好的方法就是新建一个Windows Game Library,我命名为AnimationClassLibrary,将动画类放置在这个类库中,然后在XNA项目和Content项目中添加对这个类库的引用。
图1 添加引用
首先是关键帧类Keyframe.cs的代码:
/// <summary> /// 关键帧类,表示一个动画片段(AnimationClip)中一个指定时刻的状态(包含位置、旋转、缩放信息), /// 多个关键帧组成一个动画片段。 /// </summary> public class Keyframe { /// <summary> /// 关键帧对应的bone索引 /// </summary> [ContentSerializer] public int BoneIndex { get; private set; } /// <summary> /// 此关键帧离开动画开始时刻的时间 /// </summary> [ContentSerializer] public TimeSpan Time { get; private set; } /// <summary> /// 位置 /// </summary> [ContentSerializer] public Vector3 Position{ get;private set;} /// <summary> /// 旋转 /// </summary> [ContentSerializer] public Vector3 Rotation{ get;private set;} /// <summary> /// 缩放 /// </summary> [ContentSerializer] public Vector3 Scale{ get;private set;} /// <summary> /// 创建一个关键帧 /// </summary> public Keyframe(int boneIndex,TimeSpan time, Vector3 position, Vector3 rotation, Vector3 scale) { this.BoneIndex = boneIndex; this.Time = time; this.Position = position; this.Rotation = rotation; this.Scale = scale; } // 必须要有一个不带参数的构造函数用于串行化 private Keyframe() { } }
与上一篇文章的关键帧类的区别在于:
1. 又将double类型的Time属性改回了TimeSpan类型,这是因为如果通过XNA Content进行串行化,它是可以正确识别TimeSpan类型的。
2. 将公有属性又改回了只读,但需要添加[ContentSerializer]属性标识,否则串行化过程还是会报错。(若是可以读写,[ContentSerializer]是可以省略的)
3. 新添了BoneIndex属性,这样我们就可以指定这个属性,确定动画施加的对象,这样就无需像上一篇文章那样手动指定要施加动画的Bone。
然后是动画片段类AnimationClip.cs的代码:
/// <summary> /// 动画片段类,这个类保存一个关键帧集合和动画持续时间。 /// </summary> public class AnimationClip { /// <summary> /// 动画片段的播放长度 /// </summary> [ContentSerializer] public TimeSpan Duration { get; private set; } /// <summary> /// 关键帧集合 /// </summary> [ContentSerializer(CollectionItemName = "Keyframe")] public List<Keyframe> Keyframes { get; private set; } /// <summary> /// 创建一个动画片段 /// </summary> /// <param name="Keyframes">关键帧集合</param> public AnimationClip(List<Keyframe> Keyframes) { this.Keyframes = Keyframes; //对关键帧根据时间先后进行排序 Keyframes.Sort(CompareKeyframeTimes); // 动画播放的时间就是最后一个关键帧的Time属性 this.Duration = Keyframes[Keyframes.Count - 1].Time; } // 必须要有一个不带参数的构造函数用于串行化 private AnimationClip(){} // 按照关键帧的时间属性升序排序的Comparison方法 int CompareKeyframeTimes(Keyframe a, Keyframe b) { return a.Time.CompareTo(b.Time); } }
与上一篇文章的区别在于:
1. 属性改为只读,特别是在关键帧集合Keyframes上的属性多加了一个参数:[ContentSerializer(CollectionItemName = "Keyframe")],这样做的目的是为了使生成的xml的节点名称变为Keyframe,便于阅读,否则XNA串行化器会使用默认的Item作为集合元素的名称。
2. 取消了加载xml的构造函数,这是因为现在的动画数据并不是保存在AnimationClip中,而是下面新建的AnimationData类中。
最后是动画数据类AnimationData.cs的代码:
// 动画数据类,包含一组动画片段,自动将根节点动画片段放入RootAnimationClips集合, // 将ModelMesh动画片段放入ModelAnimationClips集合。 public class AnimationData { /// <summary> /// 包含动画片段的集合,其中键为动画名称,值为动画片段类AnimationClip /// </summary> [ContentSerializer(CollectionItemName = "AnimationDictionary")] public Dictionary<string, AnimationClip> AnimationClips { get; private set; } /// <summary> /// 动画数据类,包含一组动画片段 /// </summary> public AnimationData(Dictionary<string, AnimationClip> animationClips) { AnimationClips = new Dictionary<string, AnimationClip>(); } // 用于串行化的私有构造函数 private AnimationData() { } }
创建一个AnimationData的理由是一个模型往往包含多个动画片段,因此动画数据应该是几个动画片段的集合,而且这个集合是一个Dictionary,构建时需要指定动画片段的名称。
然后编写AnimationPlayer类播放动画数据,代码如下:
/// <summary> /// 这个类负责播放AnimationClip类型的动画数据, /// 它负责计算变换矩阵,还可以调整动画播放的速度。 /// </summary> public class AnimationPlayer { Model model; // 要施加动画的模型 Matrix[] originalModelTransforms; // 模型的原始变换矩阵 TimeSpan startTime, endTime; // 动画播放开始时刻和结束时刻 bool loop; // 动画是否循环 bool paused; // 动画是否处于暂停状态 protected float playbackRate = 1.0f; // 动画播放倍率,1表示正常速度播放 LerpMode lerpMode; // 动画数据的插值模式 Vector3 position, rotation, scale; // 当前时刻的位置、旋转和缩放 protected Matrix transform; // 变换矩阵 /// <summary> /// 当前播放的动画片段 /// </summary> public AnimationClip CurrentClip { get; private set; } /// <summary> /// 动画是否已经播放完成 /// </summary> public bool Done { get; private set; } /// <summary> /// 获取动画当前已经播放的时间 /// </summary> public TimeSpan ElapsedTime{ get;private set; } /// <summary> /// 获取动画当前播放的关键帧索引 /// </summary> public int CurrentKeyframeIndex{ get; private set; } /// <summary> /// 更新动画数据 /// </summary> public virtual void Update(GameTime gameTime) { if (CurrentClip == null || Done) return; if (paused) return; TimeSpan time = gameTime.ElapsedGameTime; // 调整动画播放速度 if (playbackRate != 1.0f) time = TimeSpan.FromSeconds(time.TotalSeconds * playbackRate); ElapsedTime += time; // 进行插值操作 // 如果动画播放的时间已经超过指定的时间间隔... while (ElapsedTime >= (endTime - startTime)) { // 如果是循环动画,则将动画播放时间回退到0 if (loop) { ElapsedTime -= (endTime - startTime); CurrentKeyframeIndex = 0; } // 否则,将播放时间进行截取 else { Done = true; ElapsedTime = endTime - startTime; break; } } // 读取关键帧集合 IList<Keyframe> keyframes = CurrentClip.Keyframes; TimeSpan totalTime = ElapsedTime + startTime; // 获取当前关键帧的索引 while (keyframes[CurrentKeyframeIndex + 1].Time < totalTime) CurrentKeyframeIndex++; // 获取从当前帧开始的动画播放时间 totalTime -= keyframes[CurrentKeyframeIndex].Time; // 获取播放到当前关键帧至下一个关键帧之间的位置,介于0和1之间 float amt = (float)((totalTime.TotalSeconds) / (keyframes[CurrentKeyframeIndex + 1].Time - keyframes[CurrentKeyframeIndex].Time).TotalSeconds); // 根据插值模式对位置进行插值运算 if (lerpMode == LerpMode.Linear) position = Vector3.Lerp(keyframes[CurrentKeyframeIndex].Position, keyframes[CurrentKeyframeIndex + 1].Position, amt); else position = catmullRom3D( keyframes[wrap(CurrentKeyframeIndex - 1, keyframes.Count - 1)].Position, keyframes[wrap(CurrentKeyframeIndex, keyframes.Count - 1)].Position, keyframes[wrap(CurrentKeyframeIndex + 1, keyframes.Count - 1)].Position, keyframes[wrap(CurrentKeyframeIndex + 2, keyframes.Count - 1)].Position, amt); // 曲线运动中的旋转和缩放仍然是线性插值 rotation = Vector3.Lerp(keyframes[CurrentKeyframeIndex].Rotation, keyframes[CurrentKeyframeIndex + 1].Rotation, amt); scale = Vector3.Lerp(keyframes[CurrentKeyframeIndex].Scale, keyframes[CurrentKeyframeIndex + 1].Scale, amt); // 计算变换矩阵 transform = Matrix.CreateScale(scale) * Matrix.CreateFromYawPitchRoll(rotation.Y, rotation.X, rotation.Z) * Matrix.CreateTranslation(position); // 获取当前播放的关键帧的Bone索引,然后根据这个值改变对应的Bone的变换矩阵 int currentBoneIndex = CurrentClip.Keyframes[CurrentKeyframeIndex].BoneIndex; model.Bones[currentBoneIndex].Transform = transform * originalModelTransforms[currentBoneIndex]; } Vector3 catmullRom3D(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float amt) { return new Vector3( MathHelper.CatmullRom(v1.X, v2.X, v3.X, v4.X, amt), MathHelper.CatmullRom(v1.Y, v2.Y, v3.Y, v4.Y, amt), MathHelper.CatmullRom(v1.Z, v2.Z, v3.Z, v4.Z, amt)); } // 辅助方法,将输入的"value"参数在[0, max]范围内循环 int wrap(int value, int max) { while (value > max) value -= max; while (value < 0) value += max; return value; } /// <summary> /// 播放完整的动画片段 /// </summary> /// <param name="clip">要播放的动画片段</param> /// <param name="loop">是否循环播放</param> /// <param name="lerpMode">变换插值模式</param> /// <param name="playbackRate">动画播放倍率</param> public void StartClip(AnimationClip clip, bool loop, LerpMode lerpMode, float playbackRate) { StartClip(clip, TimeSpan.FromSeconds(0), clip.Duration, loop, lerpMode, playbackRate); } /// <summary> /// 根据给定的关键帧索引播放两关键帧之间的动画片段 /// </summary> /// <param name="clip">要播放的动画片段</param> /// <param name="startFrame">开始时刻的关键帧索引</param> /// <param name="endFrame">结束时刻的关键帧索引</param> /// <param name="loop">是否循环播放</param> /// <param name="lerpMode">变换插值模式</param> /// <param name="playbackRate">动画播放倍率</param> public void StartClip(AnimationClip clip, int startFrame, int endFrame, bool loop, LerpMode lerpMode, float playbackRate) { StartClip(clip, clip.Keyframes[startFrame].Time, clip.Keyframes[endFrame].Time, loop, lerpMode, playbackRate); } /// <summary> /// 根据给定的开始时刻和结束时刻播放一段时间内的动画片段 /// </summary> /// <param name="clip">要播放的动画片段</param> /// <param name="StartTime">开始时刻</param> /// <param name="EndTime">结束时刻</param> /// <param name="loop">是否循环播放</param> /// <param name="lerpMode">变换插值模式</param> /// <param name="playbackRate">动画播放倍率</param> public void StartClip(AnimationClip clip, TimeSpan StartTime, TimeSpan EndTime, bool loop,LerpMode lerpMode, float playbackRate) { this.CurrentClip = clip; this.startTime = StartTime; this.endTime = EndTime; this.loop = loop; this.lerpMode = lerpMode; this.playbackRate = playbackRate; ElapsedTime = TimeSpan.FromSeconds(0); CurrentKeyframeIndex = 0; transform = Matrix.Identity; Done = false; } /// <summary> /// 暂停播放 /// </summary> public void PauseClip() { paused = true; } /// <summary> /// 继续播放 /// </summary> public void ResumeClip() { paused = false; } }
动画数据的生成
方法与上一篇文章是类似的。
首先在XNA主类中添加一个AnimationData类型的变量,然后在Initialize()方法中创建这个类:
protected override void Initialize() { fpsCam = new QuakeCamera(GraphicsDevice.Viewport, new Vector3(0, 12, 15), 0, -0.6f); // 下列动画片段施加在根节点上(即Body节点),使它旋转90度,即转身体 List<Keyframe> keyframes1 = new List<Keyframe>(); keyframes1.Add(new Keyframe(0,TimeSpan .FromSeconds(0), Vector3 .Zero ,Vector3 .Zero ,Vector3.One )); keyframes1.Add(new Keyframe(0,TimeSpan.FromSeconds(1),Vector3.Zero, new Vector3(0, MathHelper.ToRadians(90), 0), Vector3.One)); AnimationClip animation1 = new AnimationClip(keyframes1); // 下列动画片段施加在Head节点上,使它旋转90度,即转头 List<Keyframe> keyframes2 = new List<Keyframe>(); keyframes2.Add(new Keyframe(2, TimeSpan.FromSeconds(0), Vector3.Zero, Vector3.Zero, Vector3.One)); keyframes2.Add(new Keyframe(2,TimeSpan.FromSeconds(1),Vector3.Zero , new Vector3(0, MathHelper.ToRadians(90), 0), Vector3.One)); AnimationClip animation2 = new AnimationClip(keyframes2); Dictionary<string, AnimationClip> animationClips = new Dictionary<string,AnimationClipp>(); animationClips.Add("TurnBody", animation1); animationClips.Add("TurnHead", animation2); animationData = new AnimationData(animationClips); base.Initialize(); }
然后在Update方法中编写如下代码:
protected override void Update(GameTime gameTime) { […] // 点击数字键1将动画数据串行化为一个xml文件 if (IsNewKeyPress(Keys.D1)) { // 设置将xml元素缩进,便于阅读 XmlWriterSettings settings = new XmlWriterSettings(); settings.Indent = true; using (XmlWriter writer = XmlWriter.Create(@"E:/AnimationData.Animation", settings)) { IntermediateSerializer.Serialize(writer, animationData,null); } } […] }
我们发现与上一篇文章使用IO时的串行化代码几乎一样,区别在于使用了XNA框架的IntermediateSerializer而不是XmlSerializer。
注意:IntermediateSerializer属于Microsoft.Xna.Framework.Content.Pipeline.Serialization.Intermediate命名空间,而创建默认XNA程序时使用的目标框架为.NET Framework 4.0 Client Profile,你需要在项目的属性中改为.NET Framework 4.0,这样才能找到这个命名空间并引用。
运行代码然后按下数字键1,在E盘根目录(你也可以在代码中更改输出目录)下就会找到xml格式的动画数据文件:AnimationData.Animation,为了使用方便,文件的后缀名并没有使用.xml,而是使用了.Animation,但同样可以用记事本打开观看其中的内容:
<XnaContent> <Asset Type="AnimationClassLibrary.AnimationData"> <AnimationClips> <AnimationDictionary> <Key>TurnBody</Key> <Value> <Duration>PT1S</Duration> <Keyframes> <Keyframe> <BoneIndex>0</BoneIndex> <Time>PT0S</Time> <Position>0 0 0</Position> <Rotation>0 0 0</Rotation> <Scale>1 1 1</Scale> </Keyframe> <Keyframe> <BoneIndex>0</BoneIndex> <Time>PT1S</Time> <Position>0 0 0</Position> <Rotation>0 1.57079637 0</Rotation> <Scale>1 1 1</Scale> </Keyframe> </Keyframes> </Value> </AnimationDictionary> <AnimationDictionary> <Key>TurnHead</Key> <Value> <Duration>PT1S</Duration> <Keyframes> <Keyframe> <BoneIndex>2</BoneIndex> <Time>PT0S</Time> <Position>0 0 0</Position> <Rotation>0 0 0</Rotation> <Scale>1 1 1</Scale> </Keyframe> <Keyframe> <BoneIndex>2</BoneIndex> <Time>PT1S</Time> <Position>0 0 0</Position> <Rotation>0 1.57079637 0</Rotation> <Scale>1 1 1</Scale> </Keyframe> </Keyframes> </Value> </AnimationDictionary> </AnimationClips> </Asset> </XnaContent>
从这个xml文件中我们看到有两个Content自动生成的属性标签:<XnaContent>,<Asset Type>,其实还有第三个<Item>,但因为我在类中使用了诸如[ContentSerializer(CollectionItemName = "Keyframe")]的写法,所以集合的标签从默认的<Item>变成了指定的名称(此例中为Keyframe)。
XNA帮助文件中有这样一个表格描述了这三个标签:
元素(Element) | 父节点 | 子节点 | 描述 |
<XnaContent> | — | <Asset> | XNA Content的顶层标签. |
<Asset> | <XnaContent> | <Item> | 标记素材。其中的The Type属性指定了对应的命名空间和对应的数据类。 |
<Item< | <Asset> | — | 当素材包含多个对象时(例如在一个数组中),标记在组中的单个对象。它的子元素对应数据类中定义的属性。 |
有了这些元素,XNA Content项目就得到了串行化和反串行化操作所需的信息了。你可以把上述代码删除了。
在XNA程序中使用动画数据
有了数据文件,接下来我们需要将它导入到Content项目中,Content项目会根据文件的后缀名自动使用导入器和处理器,.xml文件会自动使用XML Content导入器导入,默认不处理。但是因为我们刚才已经将后缀修改为.Animation,所以你需要手动指定用XML Content导入器进行处理。要想实现自动调用,你必须编写自定义导入器,这会在文章的最后介绍。
然后在XNA程序中添加动画类:
AnimationData animationData; AnimationPlayer animationPlayer;
在LoadContent加载动画数据,现在我们就可以像Model一类用Content.Load方法加载动画数据了,还需要初始化动画播放类:
protected override void LoadContent() { […] // 加载动画数据 animationData = Content.Load<AnimationData>("AnimationData"); // 初始化动画播放器 animationPlayer = new AnimationPlayer(ref model); }
在Update方法中添加动画控制和更新动画数据:
protected override void Update(GameTime gameTime) { […] // 点击数字键2循环慢速播放身体动画 if (IsNewKeyPress(Keys.D2)) { animationPlayer.StartClip(animationData .AnimationClips["TurnBody"], true , LerpMode.Linear, 0.5f); } // 点击数字键3播放头部动画 if (IsNewKeyPress(Keys.D3)) { animationPlayer.StartClip(animationData .AnimationClips["TurnHead"], false, LerpMode.Linear, 1.0f); } animationPlayer.Update(gameTime); base.Update(gameTime); }
最后,Draw方法的代码也许做适当调整:
protected override void Draw(GameTime gameTime) { […] //绘制模型 Matrix rootTransform = Matrix.Identity; if(rootAnimationPlayer .CurrentClip !=null) rootTransform = rootAnimationPlayer.RootTransform; model.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in model.Meshes) { foreach (BasicEffect effect in mesh.Effects) { […] effect.World = modelTransforms[mesh.ParentBone.Index]*rootTransform ; } mesh.Draw(); } base.Draw(gameTime); }
运行程序,截图如下:
你可以点击数字键2、3、4播放不同的动画片段。
一点改进
前面已经提到,若动画数据文件的后缀名不是.xml,Content项目不会自动选择导入器,你需要手动指定,若想根据后缀名自动选择处理器,你需要编写自定义内容处理器。
首先在XNA项目中新建一个Content Pipeline Extension Library类库,我命名为AnimationContentPipeline,这个项目已经自动生成了一个默认ContentProcessor1.cs,因为我们需要自己编写导入器类,因此可以删除这个文件,新建一个类,名为AnimationDataImporter.cs,代码如下:
using System.Xml; using AnimationClassLibrary; using Microsoft.Xna.Framework.Content.Pipeline; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Intermediate; namespace AnimationContentPipeline { /// <summary> /// 读取动画数据.Animation文件的导入器,实际上是对IntermediateSerializer的封装。 /// 和框架自带的XmlImporter导入器是一样的,唯一的区别就是它识别的是.Animation文件。 /// </summary> [ContentImporter(".Animation", DisplayName = "AnimationData Importer")] public class AnimationDataImporter : ContentImporter<AnimationData> { public override AnimationData Import(string filename, ContentImporterContext context) { AnimationData animationData; using (XmlReader reader = XmlReader.Create(filename)) { animationData=IntermediateSerializer.Deserialize<AnimationData>(reader, null); } return animationData; } } }
代码很简单,主要就是在类属性[ContentImporter(".Animation", DisplayName = "AnimationData Importer")]中指定了这个导入类处理的后缀名为.Animation,具体的代码就是将磁盘上读取的Animation文件反串行化为对应的AnimationData类,实际上就是将框架里的XmlImporter重写了一遍。
最后别忘了在Content项目中添加这个内容导入器的引用。
总结
以上的方法不仅可以用于动画数据的保存,一切你想保存的数据都可以使用这个方式,是非常有用的一个技能。例如XNA官网的Particle示例就把粒子的配置保存在xml文件中,只需改变这个配置就可以改变粒子的外观,而如果将这些数据硬编码在类中,那么每次改变后都需要重新编译,使用起来不够灵活。如果以后你想实现一个场景编辑器、UI编辑器、动画编辑器,这个方法也是必备技能。
目前为止的代码的最大缺点就是无法实现复杂的动画,因为除非你是动画达人,仅靠你手动指定关键帧生成动画,效果不会理想(试试做一个人走动的动画)。复杂的动画通常都是借助与专业的3D应用程序生成的,而这些动画数据通常都包含在模型文件中了,因此,下一篇文章我们将介绍如何通过自定义处理器提取模型文件中的动画数据并进行播放。
还有一个缺点,理论上AnimationClip中的keyframe的boneIndex可以不同,但是用现在的播放器AnimationPlayer播放包含不同boneIndex的关键帧动画肯定会一片混乱,这是由插值原理决定的,解决方法是需要首先对关键帧集合根据boneIndex进行分组,然后让播放器分别对每组关键帧进行插值,但是这样做代码就会显得太复杂了,下一篇文章的方法可以回避这个问题,因为它不需要进行插值运算。
文件下载(已下载 1332 次)发布时间:2011/9/6 下午2:04:27 阅读次数:9026