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表示根节点的变换矩阵。以上结构可表示为下面的图:

模型1结构图示

而HierarchicalMan.X的结构可以表示为下图:

模型2结构图示

请自己比较两者的差异。其实就是一个具体而微的场景图,而著名的开源引擎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:

  1. 整个Model只有一个ModelMesh;
  2. 整个Model虽然有一个以上的ModelMesh,但每个ModelMesh无需实现各自的动画;
  3. 如果你通过自定义处理器处理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  阅读次数:8877

2006 - 2024,推荐分辨率 1024*768 以上,推荐浏览器 Chrome、Edge 等现代浏览器,截止 2021 年 12 月 5 日的访问次数:1872 万 9823 站长邮箱

沪 ICP 备 18037240 号-1

沪公网安备 31011002002865 号