XNA动画的实现1——基本原理
2011年暑假,网友发给我XNA官网的机器人对战游戏RobotGame的源代码,研究了一下,觉得又有了新的收获,特别是对动画的实现有了进一步的认识,因此理了一下思路,总结了几篇文章,循序渐进地讨论一下动画的实现,其中的原理不仅适用于XNA,也适用于其他3D API。
这篇文章主要参考了4.9 使Bone独立运动:模型动画。
Model的理解
要理解模型的动画,首先要对模型的数据结构有深入的理解。要在3D空间中描述一个模型,需要哪些信息?答案是:顶点坐标,顶点索引(不是必须的,但有索引可以减少所需顶点的数量),法线、材质信息(简单地说就是表面颜色),纹理信息,模型的位置、朝向和缩放等。将这些信息保存到一个文件中,这种文件就是模型文件,常见的有.x、.fbx、3DSMAX中的.max、Quake2的.md2文件等,虽然每种文件信息保存的方式不同,但基本原理是相同的。而其中模型的位置、朝向和缩放就是实现动画的关键,在DirectX和OpenGL等3D API中,它们使用一个4x4矩阵表示的,但在模型文件中却不一定。
以下以.x文件为例具体说明,虽然在商业游戏中通常都会采用自己的模型格式,但作为教学或自学,x文件能帮你更容易地理解模型数据的本质。
首先在3DSMAX中制作一个边长为2的红色正方体,离地高1,命名为Body;然后制作一个边长为1的蓝色正方体,离地高3,命名为Head,而且将这个正方体的上表面的颜色修改为绿色。其实就是一个粗糙的人体模型。然后将它导出为x文件,我命名为SeparateMan.X。(以上步骤的详细信息你可以参见本网站中x文件的导出系列中的几篇文章)
用记事本打开这个x文件,内容如下,注意:因为文件较长,而且我关心的表示模型变换的矩阵,所以省略了其他信息:
// 省略了文件头和模板定义 […] Frame Body { FrameTransformMatrix { 1.000000,0.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000, 0.000000,0.000000,1.000000,0.000000,0.000000,1.000000,0.000000,1.000000;; } Mesh { //省略了顶点坐标和索引的数据 [..] MeshNormals { // 省略了法线数据 [..] } MeshMaterialList { // 省略了材质数据 [..] } } } Frame Head { FrameTransformMatrix { 1.000000,0.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000, 0.000000,0.000000,1.000000,0.000000,0.000000,3.000000,0.000000,1.000000;; } Mesh { //省略了顶点坐标和索引的数据 [..] MeshNormals { // 省略了法线数据 [..] } MeshMaterialList { // 省略了材质数据 [..] } } }
从以上内容我们可以看到这个模型有两个正方体组成,这两个正方体在XNA中称为ModelMesh,每个ModelMesh都有相同的变换,对应于FrameTransformMatrix,其中的16个数字就是表示变换的矩阵的16个元素。如果你看得懂矩阵的话,就会看出Body在y方向偏离1个单位,Head在y方向偏离3个单位,这就是我们在3DSMax中所设置的。
还要说明一点,因为我将Head几何体的上表面设置为绿色,即材质与其他面不同,导入到XNA中这称为ModelMeshPart,即这个Model有两个ModelMesh:Body和Head,而Body只有一个ModelMeshPart,而Head有两个ModelMeshPart。(详细解释可参见4.1 使用BasicEffect类载入模型)
因为Head会随着Body的移动而移动,因此这个模型更好的做法是在3DSMax中将几何体Head链接到Body上,具体做法可参见X文件的导出系列3——层级模型。
导出为HierarchicalMan.X文件后看一下内容:
// 省略了文件头和模板定义 […] Frame Body { FrameTransformMatrix { 1.000000,0.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000, 0.000000,0.000000,1.000000,0.000000,0.000000,1.000000,0.000000,1.000000;; } Mesh { //省略了Mesh数据 [..] } Frame Head { FrameTransformMatrix { 1.000000,0.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000, 0.000000,0.000000,1.000000,0.000000,0.000000,2.000000,0.000000,1.000000;; } Mesh { //省略了Mesh数据 [..] } } }
从以上内容我们可以发现与上一个x文件最大的不同是Head现在作为Body的子节点而不是同级节点,而且Head的FrameTransformMatrix保存的是相对于Body的变换,此处是相对于Body在y方向偏移2,即相对于根节点偏移3(这又被称为绝对变换)。
XNA对模型文件的处理
当将x文件导入到XNA的Content项目中时,会根据后缀名自动使用导入器Content Importer和处理器Content Processor,因为此时后缀为.x,所以导入器为X File - XNA Framework,处理器为Model - XNA Framework。
虽然我不知道这个导入器的源代码,但可以知道它的大致步骤,应该是首先使用文件流读入文件内容,然后将内容分门别类地放入NodeContent中,例如x文件中的Mesh部分的内容放入NodeContent.MeshContent.GeometryContent中,FrameTransformMatrix中的内容放入BoneContent中。
然后在处理器Content Processor中将x文件转换为可以被PC、XBOX、Zune、Phone跨平台使用的中间格式xnb。虽然我不知道处理器的源代码,但可以知道它的大致步骤。它将从导入器处理的NodeContent转换为XNA框架中的Model类,还要绑定Effect(默认为框架自带的BasicEffect,你可以编写自定义的处理器使用自己的Effect替换它)、加载纹理等工作。
题外话:为什么要有导入和处理两个过程?答案是为了代码重用。例如fbx文件的导入用的是Autodesk FBX - XNA Framework,但导入后就可以与x文件共享Model - XNA Framework处理器。
我们可以通过设置断点的方法观察加载到内存中的模型的结构,若加载的是SeparateMan.x,如下图所示:
可见,XNA框架将几何数据保存在Meshes中,有2个;变换数据保存在Bones中,有5个,Root表示根节点的变换矩阵。以上结构可表示为下面的图:
而HierarchicalMan.X的结构可以表示为下图:
请自己比较两者的差异。其实就是一个具体而微的场景图,而著名的开源引擎Ogre就是用场景图管理各个节点的。所以下面这个疯狂的想法在理论上是正确的:即将场景中的所有物体制作成一个Model,比方说将天空、地面、车辆、人物制作成一个Model,每个对象都是一个ModelMesh,移动人物就是对ModelMesh实施动画。如果将人物作为车的一个节点,那么只要移动车,人就会跟着一起移动,而无需处理人的动画,这也是使用场景图的一个优点。在我的StunEngine引擎中并没有实现场景图,或者说只能实现深度为1层的场景图,不过要处理简单的情况,场景图并不是必不可少的。
以上内容还可以参考: 4.8 可视化模型骨骼结构。
但是这里还有一个问题,在x文件中明明只有2个变换矩阵,为什么经过XNA的内容管道处理后又多出几个Name为null的Bone,虽然我不知道内容导入器的源代码,所以也不知道它是如何生成这些Bone的,但是我知道这些Bone的用处:是用在Draw代码中的,以下是代码片段:
protected override void Draw(GameTime gameTime) { […] // 绘制模型 model.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in model.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.World = modelTransforms[mesh.ParentBone.Index]; […] } mesh.Draw(); } base.Draw(gameTime); }
因为Model类保存的是每个Bone的相对变换矩阵,所以首先使用CopyAbsoluteBoneTransformsTo方法将它们转换为绝对变换矩阵,虽然我不知道CopyAbsoluteBoneTransformsTo方法的源代码,也可以想象出它内部一定执行了递归算法遍历整个节点树,然后将子节点的变换矩阵依次乘以父节点、父父节点直至根节点。 之后即将ModelMesh的ParentBone的Transform矩阵作为effect的world,而ModelMesh的ParentBone就是那些XNA导入器自动生成的Name为null的Bone。 如果你比较深入地理解了Bones的结构,你会发现通常有三种情况无需使用CopyAbsoluteBoneTransformsTo:
- 整个Model只有一个ModelMesh;
- 整个Model虽然有一个以上的ModelMesh,但每个ModelMesh无需实现各自的动画;
- 如果你通过自定义处理器处理Model,你可以将计算绝对变换矩阵的工作在处理器中完成,就类似于XNA官网的蒙皮动画示例SkinningModelSample中所做的那样。
有了以上分析,现在得出的结论是:要实现XNA中的动画,只需改变Name不为null的Bone的TransForm矩阵即可。最好不要改变Name为null的Bone的Transform矩阵,这样做动画的效果往往是不正确的。
在XNA中的实现
知道了原理,下面就可以实现代码了,比方说我想实现这样一个动画:在4秒时间内将模型从坐标原点匀速移动至(16,0,16),同时大小变为原来的3倍,并沿竖直轴旋转180度。
我们要做的就是将这句话翻译成计算机语言,首先在XNA主类中定义几个字段:
Vector3 position; // 模型的位置 Vector3 rotation; // 模型的旋转,三个分量表示绕x、y和z轴的旋转弧度 Vector3 scale=Vector3 .One; // 模型的缩放,三个分量表示沿x、y和z轴的缩放 TimeSpan duration; // 动画的持续时间 TimeSpan elapsedTime = TimeSpan.FromSeconds(0); // 动画流逝的时间 bool loop = false; // 是否循环播放动画 Matrix transform = Matrix.Identity; // 由位置、旋转、缩放信息生成的最终组合变换矩阵
我们最终目的就是获取一个变换矩阵,即代码中的transform,然后用指定的Bone的Transform乘以这个矩阵(注意乘法的顺序),就可以实现动画了。
因此还需要添加变量保存Bone的原始Transform矩阵:
// 储存根、身体、头部Bone的原始变换矩阵 Matrix originalRootTransform; Matrix originalBodyTransform; Matrix originalHeadTransform;
因为我们只对根、身体、头部进行动画操作,所以存储了它们的矩阵。
你也可以定义一个矩阵数组:
// 储存所有Bone的矩阵数组 Matrix[] originalTransforms;
然后在LoadContent方法中、加载model完成之后调用CopyBoneTransformsTo方法保存所有Bone的变换矩阵:
protected override void LoadContent() { // 加载模型 model = Content.Load<Model>("HierarchicalMan"); […] originalTransforms = new Matrix[model.Bones.Count]; model.CopyBoneTransformsTo(originalTransforms); }
只是这种方法会保存不必要的数据,但对简单程序影响不大。
我们知道,变换包含三种类型,即平移、缩放和旋转,在3D API中变换使用一个4x4矩阵表示的。平移和缩放并不难,但旋转有难度,要表示旋转,主要有三种方法,第一种是直接使用一个3x3矩阵,但矩阵太难理解,能直接写出旋转矩阵的人一定是个线性代数达人;第二种方法是使用欧拉角,它最符合人的使用习惯,也是我现在使用的方法,但在特殊情况下会遇到万向节锁问题;最后一种是使用四元数,四元数有点抽象,但能避免万向节锁问题,或许在后面的文章中我会使用四元数表示旋转,在Stunengine中使用的也是四元数。想了解这段内容,推荐文章:旋转:矩阵,四元数和欧拉角向量,2.4创建一个Freelancer风格的相机:使用四元数的3D旋转。想深入了解,请看《3D图形数学基础:图形与游戏开发》(本网站有电子书下载)中的第10章 3D中的方位和角位移。但不管使用何种方法,最终都要转换为3D API可以直接使用的4x4矩阵形式。
然后在初始化方法中设置动画时间等:
protected override void Initialize() { […] // 设置动画播放时间为2秒,循环播放 duration = TimeSpan.FromSeconds(4); loop = true; base.Initialize(); }
关键的代码位于Update方法中:
protected override void Update(GameTime gameTime) { […] // ----------------------------- // 更新动画 // ----------------------------- // 更新动画播放时间 elapsedTime += gameTime.ElapsedGameTime; // 更新模型的位置、旋转和缩放 position += new Vector3(16f / 240,0,16f / 240); scale += 2f/240*Vector3.One; rotation += new Vector3 (0,MathHelper.ToRadians(180f/240),0); // 如果动画播放时间已经超过动画的持续时间 if (elapsedTime > duration) { // 如果允许动画循环播放,则将对应的值设置为初始值 if (loop) { position = Vector3.Zero; scale = Vector3.One; rotation = Vector3.Zero; elapsedTime = TimeSpan.FromSeconds(0); } // 否则将模型保持在最后时刻的状态 else { position = new Vector3(16, 0, 16); scale = 3f * Vector3.One; rotation =new Vector3(0, MathHelper.ToRadians(180), 0); } } // 缩放矩阵 Matrix scaleMatrix = Matrix.CreateScale(scale); // 旋转矩阵 Matrix rotationMatrix = Matrix.CreateFromYawPitchRoll(rotation.Y, rotation.X, rotation.Z); // 你也可以使用矩阵的CreateRotationX,CreateRotationY,CreateRotationZ方法设置旋转矩阵 //Matrix rotationMatrix =Matrix.CreateRotationX(rotation.X)*Matrix.CreateRotationY(rotation.Y)*Matrix.CreateRotationZ(rotation.Z); // 平移矩阵 Matrix translationMatrix = Matrix.CreateTranslation(position); // 组合起来的最终变换矩阵 transform = scaleMatrix * rotationMatrix * translationMatrix; model.Root.Transform = transform *originalRootTransform; //model.Bones["Body"].Transform = transform * originalBodyTransform; //model.Bones["Head"].Transform = transform * originalHeadTransform; // 在使用HierarchicalMan时,下面这行注释的代码效果与上一行代码相同, // 通过索引访问会比通过Bone名称访问稍微快一些 //model.Bones[2].Transform = transform * originalHeadTransform; base.Update(gameTime); }
因为默认的更新频率为1/60秒,而动画时间为4秒即240帧,因此一帧移动16/240个单位,这个操作实际上就是线性插值。缩放和旋转的代码原理相同。
若将计算得出的变换矩阵transform施加于根Bone节点的Transform,效果就是整体的动画,若将这个变换矩阵施加于头的Bone的变换矩阵,则身体不动头飞出去(虎力大仙?)。
程序截图如下:
如果你对面向对象编程有一定的了解,可以发现以上代码的缺点是:代码无法重用,自然就会想到将这些代码封装到一个类中,这将在下一篇文章中介绍。
文件下载(已下载 1669 次)发布时间:2011/8/30 下午12:19:20 阅读次数:8950