FBX、DAE模型的格式、导入与骨骼动画

发表于2016-08-09
评论1 6.9k浏览
  FBX和DAE分别是Autodesk和Khronos旗下核心的可交换型3D模型格式,也算是当前主流的支持顶点蒙皮骨骼动画的格式,在实时渲染领域基本是无人不识了。本文主要以笔记形式记录一下读取这两种模型时的一些须注意的地方。——ZwqXin.com
  本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
  原文地址:http://www.zwqxin.com/archives/opengl/model-fbx-dae-format-import-animation.html

  2011年的时候集中轰击了五款3D模型格式(obj、3ds、md2、md3、md5),那时候其实主要是从渲染方式和模型动画方式的进化角度来选择的,尤其是ID Tech的md系列,让这个世界的模型动画观念从最简单的“帧动画”到当前主流的“骨骼动画”进化。但是,即便是拥有骨骼动画的md5格式,也已经是将近10年前的诞生物了,那么这10年来,3d模型格式到底有没有发生了什么变化?——我们将从当今主流的格式略窥一二。
  作为开场前口水话,先来说一说实时渲染领域上的3d模型的些许历史罢。上世纪90年代初,由游戏产业带动的娱乐化三维实时渲染开始兴起,那时候的模型也只停留在非常简陋的阶段,甚至没必要专门的存储手段,更别谈什么模型动画了。但是随后当人们发现硬件和技术(值得一提的是,OpenGL也在此时应运而生)逐渐可以支持更复杂的静态模型数据的实时渲染时,存储问题也正式被重视。在没有一个统一标准的情况下,那些以往用于工业建模设计上的交换格式(简化版本),例如Autodesk 3DS Max下的.3ds和Wavefront软件下的.obj,就被选为最具代表性的两种主流静态模型格式了,情况甚至延续至今。
  随着90年代中后期视频游戏的爆发式发展,模型中的动画也自然变成需求的一部分,而引领动画技术快速发展的一个重要角色,就是前面说到的ID Software公司下的ID Tech系列引擎。包括至今被众多游戏制作者崇拜着的约翰·卡马克所策动的Doom系列和经典的C/S架构游戏Quake系列,ID Tech在世纪交换期可谓聚集式吸引了视频游戏领域的众眼球。在这众系列引擎中,也隐含着模型动画方式的进化:

  md(或者称md1),推测应该是ID Tech 1(1996)所使用的模型格式或概念,是否包含动画信息尚不可知,但是它必然是后来此系列模型格式的基石;
  md2,始于ID Tech 2(1997),真正地把动画中的“各帧模型”合并到一个模型中,利用帧数据(结合Morphing技术)还原动画,简单而数据量巨大;
  md3,始于ID Tech 3 (1999),虽仍以帧数据还原动画,但同时引入了骨骼这一概念,把模型分为下身、上身、头和武器这几部分,通过骨骼节点连接,下身节点带动上身移动和旋转,以此类推——相当于加入了一条短骨骼,有效地降低数据冗余度,各部分的动画可以各种组合;
  md4,草稿式的模型格式,它的存在印证着新千年初始期,ID Tech人员对更高效模型格式和渲染技术的探寻,但也只停留在概念阶段而被略去——但也有其他人实现了这一概念(mdr格式),也存在别的发展分支(mdl格式);
  md5,始于ID Tech 4(2004),从03年完善到05年,是真正的支持骨骼动画、顶点蒙皮渲染的模型格式,随着著名的Doom3游戏进入大众的视线。

  随着新世代显卡的发展、各种商业非商业实时渲染引擎的出现和发展,骨骼动画被引入为主流的模型动画标准,至今仍为主流技术。这10年来,随着ID Tech的沉默(最新的ID Tech 5已不如往日辉煌)和各路人马的强大和异军突起,业界也有巨头期盼着制作出某种更通用的(被更多模型制作工具支持或转换,可被各方人员交换和重用的)模型格式标准,这其中就包括Autodesk、Microsoft和与我们OpenGL标准息息相关Khronos委员会。
  fbx,[WIKI]源于Kaydara的FilmBox(后改为现称的MotionBuilder)软件(1996),后来被Autodesk收购,但是这个模型格式依然被一直发展,直至今天它还是“最”跟得上潮流的格式——鉴于Autodesk已经无法给3ds格式更好的动画信息支持了,现在fbx才是它的主打,也因为有个强大的爹,这个格式被广泛的建模软件和游戏引擎支持,也有引擎(像那个Xxity3D)把它作为主要支持的导入模型的;
  x,这是Microsoft弄出来的模型格式,随着DirectX的SDK一起升级,后来也成为了骨骼动画的代表模型格式之一,这是微软为了把DX塑造成完整的自生态开发系统而采取的常见行为,虽然格式本身是开放的,但可见对Direct3D是无条件支持的(甚至有内置函数可以直接读取),这里我就不过多阐述了,因长期没更新,所以业界使用率没比md5更广泛得了去哪,常见于D3D的例子Demo;
  dae,[WIKI]也称Collada模型,是非盈利性组织Khronos负责维护的开放性三维模型格式(现已成为 ISO标准之一),应该说跟OpenGL是同一级别的,作为一个标准,其目的自然是能被各方支持了,而毕竟Autodesk有自己的利益考虑,所以远没有像fbx那样完美的支持,但这不妨碍它现在还是能够与封闭的fbx分庭抗礼的主要模型格式。
  除了以上说的这些,其实还有很多模型格式,除了那些纯粹地用来保存建模软件的阶段结果的(.max、.blender之类)的,还有沿袭式地用于VR领域的(.vrml和.x3d之类),以及一些特定为某些游戏引擎所用的模型格式(像那Xxre的.Xxremesh和那Xxrlicht的Xxrmesh)。而本文主要还是聚焦于当前最流行的Fbx和Dae,切入正题。(在此希望不太懂顶点蒙皮原理的同学先自己学习下或者参考上面链接中的MD5模型导入的两文,不然看本文肯定觉得不知所云。)

一、FBX
  如前所述,fbx是一种封闭的模型格式,这不仅说它通常作为二进制文件出现,而且是目前只能使用Autodesk提供的FBX SDK来操控这种文件。事实上,无论是3DS Max还是其SDK内置的Converter工具,都可以把其转换成ASCII的文本格式,虽然看上去有点JSON的样子,可事实上是全自给的“仅供观赏”的数据堆,也没有spec,通常也不会有人使用这种方式输出模型。好了,看来是必须借助其SDK了,所以首先要做的一件不太让人愉快的事情:加入这个SDK的库(lib&dll)。现在我用的是最新的fbxsdk-2013.3,下文仅就这版本兼容的模型而言。(注意,在预处理器选项中加入FBXSDK_NEW_API和FBXSDK_SHARED,如果你不想编译器抱怨一大堆的话。)
  这里首先说一下,对于Fbx(其实下文的Dae也是一样的),它保存的最大集合是一个Scene(场景),跟很多的游戏引擎所使用的概念是一致的,就是用场景节点树来组织成一个场景。以前的3ds[3DS文件结构的初步认识] 虽然也是树状地组织数据,但它是把不同类型数据堆抽象成节点,而Fbx/Dae则是纯粹地表述场景节点。所以对于后者们来说,即使把场景中多个物体/模型保存到同一个模型文件里,也是可以的,只不过是不同名字的节点而已,甚至把灯光、相机也可以抽象成节点而已。当然,对于我们编程者来说,一个模型文件仅仅对应一个模型是最自然的。所以接下来我只会谈及抽取模型和动画本身信息(事实上动画信息并非必须的——fbx和dae文件在没有动画或骨骼信息时,也就是静态模型了),不相关的部分则不涉及也不关心。
  因为有其自身的SDK帮我们分析fbx文件,所以对于fbx,只要去获取SDK的FbxImporter读取的结果来为我们所用就可以了——问题是要知道怎么获取我们需要的数据,你要让它直接告诉你每个网格数据的各个直接可用的顶点属性数组,那可为难了。我们还是必须把它分析出来的数据,转换成我们需要的数据的。所以还是先想清楚我们需要什么数据:1.动画信息(如果有的话);2.网格信息;3.关联前两者的骨骼信息(如果有的话)。在导入MD5模型([MD5模型的格式、导入与顶点蒙皮式骨骼动画I] [MD5模型的格式、导入与顶点蒙皮式骨骼动画II])时,其实也是一个寻找这些信息的过程(不过MD5可是把动画信息另外封成一个md5anim文件而已)。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FbxScene *pScene = FbxScene::Create(pFbxManager, "ImporterScene"); 
   
pSdkImporter->Import(pScene); 
   
PrepareAnimationInfo((Scene*)pScene); 
   
FbxNode *pRootNode = pScene->GetRootNode(); 
   
if (pRootNode) 
    for (int i = 0; i < pRootNode->GetChildCount(); ++i) 
    
        FbxNode *pNode = pRootNode->GetChild(i); 
   
        ProcessNode((Node*)pNode, szResDirectory); 
    
   
SetupJointKeyFrameInfo();

  在这里,我首先获取到这个场景(Scene),然后用PrepareAnimationInfo来预先查找场景中存在的动画信息(这里只简单查询动画的基本信息,通过Scene内的各FbxAnimStack下查各FbxAnimLayer,每个Anim Layer保存着一个动画,这里是把这些Layer的地址先存起来),然后获取场景的根节点,逐个处理其下属节点( ProcessNode函数里再轮询处理该节点的下属节点,递归调用 ProcessNode,完成整棵场景树的深度遍历),最后就是SetupJointKeyFrameInfo,获取具体的动画数据并把前两者的信息结合起来。
  场景节点除了网格对象(FbxNodeAttribute::eMesh)外还有其他多种类型,前面说过了,省略。对于每个Mesh网格对象,我们要得到它的几个顶点属性数组:位置、纹理坐标、影响的骨骼点(Joint)个数,以及这些骨骼点的索引(Index)和影响因子(Bias),另外对于法线切线这些,也可以顺便获取也可以自行计算。注意除了位置(和顶点属性索引)外其余属性并非每个mesh都有,注意判断了。其中,获取骨骼蒙皮信息(也就是这个Mesh的Skin)还是很值得注意的:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
FbxSkin *pSkinDeformer = (FbxSkin *)pMesh->GetDeformer(0, FbxDeformer::eSkin); 
   
for (int i = 0; i < pSkinDeformer->GetClusterCount(); ++i) 
    FbxCluster *pCluster = pSkinDeformer->GetCluster(i); 
   
    int nInfluencedPointIndexCount = pCluster->GetControlPointIndicesCount(); 
    int *pInfluencedPointIndice = pCluster->GetControlPointIndices(); 
    double *pInfluencedPointWeights = pCluster->GetControlPointWeights(); 
   
    if (pLinkingBoneNode && pInfluencedPointIndice && pInfluencedPointWeights) 
    
        t3DJoint *pJoint = new t3DJoint((pCluster->GetLink()->GetName());      
   
        pCluster->GetTransformMatrix(transMatrix); 
        pCluster->GetTransformLinkMatrix(transLinkMatrix); 
   
        GetMatrixValue(transMatrix.Inverse(), &pJoint->mtPreFramePosed); 
        GetMatrixValue(transLinkMatrix, &pJoint->mtBindPose); 
  
        transLinkMatrix = transLinkMatrix.Inverse() * transMatrix; 
        GetMatrixValue(transLinkMatrix, &pJoint->mtPostFramePosed); 
   
        AddJoint(pJoint); 
   
        for (int iPtIndex = 0; iPtIndex < nInfluencedPointIndexCount; ++iPtIndex) 
        
            int nVertIndex = pInfluencedPointIndice[iPtIndex]; 
                       
            tVertWeights.nAttachJointIndex = GetJointCount() - 1; 
            tVertWeights.fWeightBias = (float)pInfluencedPointWeights[iPtIndex]; 
   
            std::map<int, std::vector"">>::iterator pFind = VertJointInfo.find(nVertIndex); 
   
            if (VertJointInfo.end() != pFind) 
                pFind->second.push_back(tVertWeights); 
            else 
                VertJointInfo.insert(std::make_pair(nVertIndex,  
                    std::vector())).first->second.push_back(tVertWeights); 
        
    
int,>

  可见,获得这个网格的skin后,就可以去查询这个skin内的各个cluster了,每个cluster其实就对应一个joint【骨骼节点】(FBX SDK内习惯叫Bone【骨骼】,其实本质都是一样的,我们需要的是影响骨骼的对应数量的矩阵),接下来保存一个>的map的过程就不多说了,这里的key值是给SDK索引顶点用的顶点index(注意不是顶点属性索引,而只是单纯顶点的索引),value就是对应的joint信息,对应多少个joint,vector里就存多少组信息。接下来最好像导入MD5时那样把数量规范化到4个以下,不然就不好传入vertex shader了。注意这里代码的重点:生成Joint的同时,也要获取对应的矩阵信息。
  FBX不像MD5那样还要自己计算bindpose下的顶点坐标,但是还是需要知道对于每个Joint,怎样把顶点从bindpose空间转换到模型空间。在MD5中[MD5模型的格式、导入与顶点蒙皮式骨骼动画II] ,这个转换只需乘以bindpose矩阵的逆矩阵就OK了,可是Fbx里可像是没那么简单哦(这还是我碰壁后去翻sdk的例子程序里的代码比对才知道的,那个惨):参见上面的代码,每个cluster(joint)可以通过GetTransformMatrix和GetTransformLinkMatrix获取两个矩阵(前者我也不太知道具体意义是啥,不妨自己望文生义一下,后者看来就是bindpose矩阵咯),不妨设为MTrans和Mbindpose。把顶点从bindpose空间转换到模型空间的“Joint影响矩阵”:
  Mjoint‘   =  MTrans -1   *  Mjoint  *   Mbindpose-1   *   MTrans   
  其中Mjoint是当前帧下的该骨骼Joint的变换矩阵(在之前也说过了,就是由该joint的位移旋转缩放信息构成,相当于该Joint的模型矩阵,注意这里不像MD5里可以省略缩放信息,FBX和DAE的动画信息里都是包含缩放信息的说),等式右边是传入shader的joint影响矩阵。对比MD5的公式,可以看到这里多了个“程咬金”:MTrans,居然还分左右地夹在两边,左边是逆矩阵,右边是原矩阵。这样,在每帧计算Joint矩阵时可别忘了它咯。在代码中,考虑到模块统一的问题,Mjoint的左右两边干脆被我封在mtPreFramePosed和mtPostFramePosed中了……
  接下来谈一下网格信息。Mesh类型的Node都能获得对应的FbxMesh,顶点属性大致是GetElement类函数获取(再根据GetMappingMode/GetReferenceMode来看怎样具体通过GetDirectArray/GetIndexArray获取数据,这点应该来说是好麻烦的,不过人家也是为了尽量压缩冗余数据)。还有一点就是fbx里保存的不一定是三角面片,也可能是四角面或多角面,为了为我们所用,须通过一些方法转换成三角面的索引顺序(如下的多重循环)或者直接通过SDK自带的FbxGeometryConverter来预先三角化(TriangulateInPlace)。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (int i = 0; i < pMesh->GetPolygonCount(); ++i) 
    int nPolySize = pMesh->GetPolygonSize(i); 
   
    if (nPolySize < 3)  continue
   
    for (int nTriCount = 3; nTriCount <= nPolySize; ++nTriCount) 
        for (int k = nTriCount - 1; k < nPolySize; ++k) 
            for (int j = nTriCount - 3; j < nTriCount; ++j) 
            
                if (j == nTriCount - 1)    j = k;
   
                int nVertexIndex = pMesh->GetPolygonVertex(i, j); 
   
                vPosition = mtBindShape * pMesh->GetControlPointAt(nVertexIndex); 
                //.....

  这里更重要的是,得出的顶点(fbx内称control point)须进一步经过一个矩阵(mtBindShape)变换一下。这个叫做BindShape矩阵的矩阵,我的理解是,有时候模型制作者绑定骨骼节点参数时的基准并不是bindpose状态而是稍微对每个网格经过一个调整(缩放旋转移位)后再进行的,那么导出时就会给每个mesh生成这样个BindShape。看sdk自带例子中的这一步,应该就是获取此矩阵的方法了:

?
1
2
3
4
5
const FbxVector4 lT = pMesh->GetNode()->GetGeometricTranslation(FbxNode::eSourcePivot); 
const FbxVector4 lR = pMesh->GetNode()->GetGeometricRotation(FbxNode::eSourcePivot); 
const FbxVector4 lS = pMesh->GetNode()->GetGeometricScaling(FbxNode::eSourcePivot); 
   
GetMatrixValue(FbxAMatrix(lT, lR, lS), &mtBindShape);//生成矩阵

  在获得网格顶点信息、顶点骨骼信息、纹理信息之后,对于这个网格,还需要判断它是直接由骨骼驱动,还是通过Attach的方式(例如武器)绑在其他节点或骨骼上,这个对于模型正确性来说还是比较重要的。而最后,根据动画信息(之前获得的AnimLayer)和骨骼,通过GetCurve-KeyGetCount来获取关键帧的时间集,一一去计算出骨骼节点在每个关键帧时间点的变换矩阵(EvaluateGlobalTransform),即Joint矩阵。fbx文件看上去内部似乎真的存储了一个一个属性曲线(Curve)一样,非得弄这种类似采样的方法去获取动画过程中的各属性值,但相信其实存储的也就关键点和值,比起构造Curve-Sampling的方式,直接能够取得关键帧的各信息肯定效率更高,但SDK内没找到类似接口——结果是,模型导入的大部分耗时都花在EvaluateGlobalTransform这类函数上了。


二、DAE
  dae是纯文本的模型格式,其本质就是一个单纯的xml文件。相比fbx,对dae格式模型的载入我们拥有非常高的自由控制,但是我们也必须承担读取和分析数据信息这一工作——这也是最复杂的地方。
  首先是xml的开源读取工具,可以选择的范围很广,我也只按己习惯选用rapidxml,比起fbx那巨大的sdk开发包,rapidxml的这两三个头文件就能精悍地协助整个载入工作,是为dae最大的优点。其次是格式的文档,khronos的官方网站上就有详尽的spec和Reference Card,对整个dae格式的了解也就可以直接从这里入手。目前dae的最新版本是1.5(事实上1.4也差不多而已),但这也是08年的事情了,那时候我都还没跳坑吧,可见它不如fbx那样一直被注入精力去完善发展,而是到达一个安稳的阶段暂时停顿(目前的发展方向好像是),但是基本上我们需要的东西它已经完全包含了,跟fbx相比,也许就是文件体积的大之外,还有一点就是对动画的支持稍逊点点。对比fbx,资源较难找一点,也没见过含多套动画在内的模型,这些也就是上面提及的原因导致的吧。
  基本上,dae文件内一开始就把数据分成了好几大块。对我们来说最为有用的是VisualScenes(包含场景骨骼节点树)、Nodes(与VisualScenes类似,两者或互为补充)、Geometries(网格数据)、Materials/Effects/Images(材质相关信息)、Controllers(骨骼信息数据)、Animations(动画数据)、AnimationClips(全局的动画信息),其中静态模型一般也就包括VisualScenes和Geometries。我选择的处理顺序是VisualScenes/Nodes -> Geometries(含Controllers、Materials等) -> Animations(含AnimationClips)。也就是说,先把场景节点树整理出来(同时也找出所有的骨骼节点),然后处理网格骨骼信息,最后是动画。
  dae的数据读取其实就是一项苦逼工作,但是其实一开始推进后进展会比较快,网格、材质纹理、骨骼信息的数据很快就出来了,但是也很快就跌入痛苦的深谷,尤其是到达动画信息读取的阶段,会发觉很难把前面这些数据联结起来,渲染的结果出现问题,修改读取方式,好了正常了,再读取另一个模型,发现出现别的问题,试再另一个模型,发现新的问题……那时候真心奔溃。究其原因,就是dae模型数据组织的自由度实在太高——类似的数据信息,有多种存储方式——对于不同的建模工具或转换工具,只要符合spec,数据组织的方式就可以随它所欲地存储。这样导致的结果就是,对于我们现在的读取工作,必须不断结合针对不同测试用例有效的不同读取方式,形成更泛化的方式,使得读取类满足所有测试用例。是的,测试用例,即使现在我也只能说我的这个读取类仅对目前我用这堆测试用例有效,不排除某天对偶尔找来的一个dae模型失效——渲染结果不正确。为了适应不同的建模工具和转换工具,为了作为标准被大伙认同和采用,开源而较为弱势的Collada很明显变得被动,变得妥协——如同OpenGL的某个困境般。
  Dae模型的Joint矩阵计算并没有fbx那样存在一个"程咬金"MTrans的影响,甚至会直接给出各骨骼joint的bindpose逆矩阵(mtPostFramePosed),直接计算即可:Mjoint‘   =    Mjoint  *   Mbindpose-1      ,但是网格顶点还是同样可能需要经过一个bindshape矩阵(Controllers中的各网格的bind_shape_matrix字段)的变换。
  读取骨骼节点或场景节点的时候,我们主要获取的是它的变换矩阵,但是在dae这里,这个信息并不是那么直观的。还记得OpenGL3.x之前,顶点数据的模视矩阵变换,都习惯在CPU端用函数(glTranslate/glRotate/glScale)来控制的,在一个或多个矩阵栈内按合适顺序调用这些函数,以完成模型矩阵的构建。Dae格式规范中也继承了这种方式(Collada当前最新版本出来时还是GL2.x年代嘛),所以对一个场景节点,会看到如下的xml子句:

?
1
2
3
4
5
6
7
8
"Scene_Root1_CENTER_G_3"
    "translation">0.237243 -0.051032 -0.410632 
    "rotation_z">0.000000 0.000000 1.000000 7.869819 
    "rotation_y">0.000000 1.000000 0.000000 -170.329865 
    "rotation_x">1.000000 0.000000 0.000000 -1.480750 
    "scale">1.000000 1.000000 1.000000 
   //....<-->

  等价的OpenGL2.x代码:

?
1
2
3
4
5
6
7
8
9
glPushMatrix() 
 glTranslatef(0.237243, -0.051032,-0.410632 ); 
 glRoatef(7.869819, 0, 0, 1); 
 glRoatef(-170.329865, 0, 1, 0); 
 glRoatef(-1.480750, 1, 0, 0); 
 glScalef(1, 1, 1); 
 DrawNodeStuff("Scene_Root1_CENTER_G_3"); 
//... 
glPopMatrix();

  再复杂的变换,也可以这样的方式表达出来:

?
1
2
3
4
5
6
7
8
9
"l_knee" name="l_knee" sid="l_knee" type="JOINT"
    "translate">1.833014 -0.024761 -0.002519 
    "jointOrientZ">0 0 1.000000 2.742172 
    "jointOrientY">0 1.000000 0 -8.695618 
    "jointOrientX">1.000000 0 0 -120.102058 
    "rotateZ">0 0 1.000000 0 
    "rotateAxisX">1.000000 0 0 -167.476486 
    //....<-->

  如果是以以前的OpenGL为载体,这样的格式也方便。可是现在对我们有用的是它们的合成结果(模型矩阵),是不是直接自行构建一下就可以了呢?如果没有动画的话那还好,如果存在一个动画,要求把上述sid的值为jointOrientX的角度值由0度到50度呢?为了适应dae的动画机制,我们必须预先把每个node的各行变换储存起来:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
enum VertInfoType 
    VIT_Translate = 0, 
    VIT_Rotate, 
    VIT_Scale, 
    VIT_Count, 
}; 
   
struct t3DNodeVertInfo 
    t3DNodeVertInfo() : nComponent(-1){} 
    t3DNodeVertInfo(VertInfoType nInfoType) : nType(nInfoType), nComponent(-1){} 
    VertInfoType nType; 
    ZWVector3    vInfo; 
    int          nComponent; 
    std::string  strIdentifier; 
}; 
   
struct t3DNodeVertInfoSet 
    std::vector DataVec; 
}; 
   
struct t3DNodeInfo 
    t3DNodeInfo() : pParentInfo(NULL){} 
    t3DNodeInfo       *pParentInfo; 
    std::string        strName; 
    std::string        strSidName; 
    t3DNodeVertInfoSet DefaultVertInfoSet; 
};

  假设用一个t3DNodeInfo结构体来描述一个场景/骨骼节点的信息,这里的DefaultVertInfoSet就是一个t3DNodeVertInfo数组,每个t3DNodeVertInfo表达了一行的变换数据。在构建动画时,根据动画关键帧中需要变化的数据行的标识(譬如上面的jointOrientX),替换出实际的数据,再把数据替换后的整个DefaultVertInfoSet转换成一系列矩阵的相乘结果——该节点在该关键帧的变换矩阵(通常我会再把矩阵分解出位移/旋转/缩放值,以便骨骼动画更新时能够准确的插值——虽然直接插值一个矩阵的渲染结果应该也不会看得出什么大问题):

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
ZWQuaternion qRotation; 
ZWMatrix16 mtTransform; 
ZWMatrix16 mtResTransform; 
   
for (std::vector::reverse_iterator p = VertInfoSet.DataVec.rbegin(); p != VertInfoSet.DataVec.rend(); ++p) 
    mtTransform.LoadIdentity(); 
   
    switch (p->nType) 
    
    case VIT_Translate: 
        mtTransform.SetTranslationPart(p->vInfo); 
        break
    case VIT_Rotate: 
        qRotation.setIdentity(); 
        if (0 == p->nComponent) 
            qRotation.setValueFromPitch(DegreeToRadian(p->vInfo.x)); 
        else if (1 == p->nComponent) 
            qRotation.setValueFromYaw(DegreeToRadian(p->vInfo.y));
        else if (2 == p->nComponent) 
            qRotation.setValueFromRoll(DegreeToRadian(p->vInfo.z)); 
        else if (ZWMatrix16::ITEM_COUNT == p->nComponent) 
            qRotation.setValueFromEulerAngles(DegreeToRadian(p->vInfo.y,  p->vInfo.x,  p->vInfo.z)); 
        qRotation.GenerateMatrix3X3(&mtTransform); 
        break
    case VIT_Scale: 
        mtTransform.MultiplyScaling(p->vInfo); 
        break
    default
        break
    }        
    mtResTransform = mtTransform * mtResTransform; 
   
RetrieveVertInfo(mtResTransform, ResTransform.vInfo[VIT_Translate], ResTransform.vInfo[VIT_Rotate], ResTransform.vInfo[VIT_Scale]); 

  变换的类型除了translate/rotate/scale,还有比较常见的就是matrix(直接提供变换矩阵),为了一致性,此时我也会把这个矩阵先分解成一组translate/rotate/scale存入DefaultVertInfoSet。动画信息的构建过程可以理解为把关键帧中的值对应替换default值,当然了,实际操作起来感觉还是会变得很复杂的——如前所述,通用性,要考虑的状况太多。最后洋洋洒洒的3000行数据读取分析代码便是佐证了。


三、模型包围盒(BoundingBox)
  对一个渲染系统里的渲染物件(Renderer)来说,包围盒是很重要的。包围盒的一个重要作用是提供视锥剔除(Frustum-View-Culling)的依据(计算物件的最终包围盒是否在视锥体内以判断是否提交到GPU作渲染),另外也可以做简单的碰撞检测之类。最简单的包围盒是AABB(Axis-Align-Bounding-Box),也就是无论渲染物件怎样旋转,其包围盒各边始终平行于世界坐标系的xyz轴,它只需要盒子的最小最大两个点坐标就可以描述。包围盒跟物件是同体的,所以只要得到物件在局部(本地)坐标系下的包围盒,乘上物件的模型矩阵,再重获变换后的立方体的最大最小点,就构建出最终包围盒了。
  对于静态的模型,它的局部包围盒很容易构建——传入VBO的顶点位置坐标,取其最大最小值即可;帧动画的模型,也就是取各帧的包围盒插值即可;问题是骨骼动画的模型——一般来说,模型各顶点的本地坐标值是在shader里计算出来的(顶点蒙皮,参见[MD5模型的格式、导入与顶点蒙皮式骨骼动画II] ),所以无法在应用端获取。难道需要预先进行一次CPU端的蒙皮演练?
  在MD5模型中,存在BoundingBox的字段,可以直接取得各帧的模型包围盒。但是无论fbx还是dae都没有这种强制性机制,当然建模人员可以以某种方式预先确定好包围盒,存入这两种模型格式的“用户自定义”字段中,但是对于我们这种比较注重通用性的模型导入模块中,这种方式是不可以预先依赖的——更需要一种通用的方式,计算骨骼动画模型的包围盒。
  实时地计算骨骼模型的包围盒,必然从其“骨骼”入手,因为本身骨骼的位置数据是每一帧都须计算的,可以直接取得。比较容易想到的是单纯计算骨骼的包围盒,再稍微膨胀一下——但是膨胀多少是很难确定的,并不保险。最好的方法,是先在导入阶段计算出bindpose空间下的各个网格对应影响该网格的各骨骼的包围盒——对每个骨骼Joint,计算其影响值大于0的网格顶点集(在bindpose空间下,注意是乘了bindshape矩阵的结果)的包围盒——Joint的bindpose包围盒。跟顶点集一样,运行时先后乘上该joint的bindpose逆矩阵和joint矩阵,得到局部坐标系下的joint包围盒——网格对象的包围盒直接由影响它的骨骼joint的包围盒合成(Merge):


?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ZWBoundingBox ZWModelBase::t3DObject::GetRetrictingBoundingBox() 
    if (bHasSkinning) 
    
        ZWBoundingBox boxMerged; 
   
        for (ZWModelJointList::iterator q = InfluencedJoints.begin(); q != InfluencedJoints.end(); ++q) 
        {    
            boxMerged.Merge((*q)->boxBindPose.WithTransform((*q)->mtPreFramePosed * (*q)->mtCurrent * (*q)->mtPostFramePosed));  
        
   
        return boxMerged; 
    
    else 
    
        return ZWRenderer::GetBoundingBox(); 
    
}

  好了,非常完美的包围盒(RestrictingBounding),时刻准确地包围着模型——但是,场景中有多个模型时,运行时的上述这个计算,造成帧率的巨大下降!毕竟是针对每个网格每个骨骼的计算,而现在的一个模型随便就可以上百根骨骼的总数,造成了这种捡了芝麻丢了西瓜的状况——我们需要boundingbox来做剔除操作,目的不就是为了降低帧率嘛!必须换一种思路——必须在动画开始前就确定好一个针对该动画的最大包围盒。
  考虑到设置或更改模型动画时能一次性完成最大包围盒(MaxBounding,准确地说,是动画过程中始终能包围住网格物件的最小包围盒)的计算:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void SetAnimationTime(t3DAnimation *pAnimation, float fStartTimeSec, float fEndTimeSec) 
    std::string strAnimName = pAnimation->GetName(); 
   
    ZWMatrix16 mtKeyFrame; 
   
    for (std::mapstring, t3djoint="" *="">::iterator p = m_JointMap.begin(); p != m_JointMap.end(); ++p) 
    
        t3DJoint *pTargetJoint = p->second; 
   
        t3DJointAnim *pTargetJointAnim = pTargetJoint->GetAnimInfo(strAnimName); 
        int nStartFrame = GetFrame(fStartTimeSec);
        int nEndFrame = GetFrame(fEndTimeSec);
   
        for (int j = nStartFrame; j <= nEndFrame; ++j) 
        {   
            mtKeyFrame.SetFromFrameData(pTargetJointAnim->pKeyFrameSet); 
   
            mtKeyFrame = pTargetJoint->mtPreFramePosed * mtKeyFrame * pTargetJoint->mtPostFramePosed;  
   
            pTargetJoint->boxMaxCurrent.Merge(pTargetJoint->boxBindPose.WithTransform(mtKeyFrame)); 
        
    
//.... 
 
ZWBoundingBox boxMerged; 
for (ZWModelJointList::iterator q = InfluencedJoints.begin(); InfluencedJoints.end(); ++q) 
{    
    boxMerged.Merge((*q)->boxMaxCurrent);    
MeshObject[i]->ResetBoundingBox(boxMerged); 
string,>


  至此完成7种代表性的模型格式的导入和渲染(3ds、obj、md2、md3、md5、fbx、dae),放张图纪念一下。除非突发的需要或未来主流的变化,大概本系列的文章也暂止于此了,希望对想涉及这方面了解的同学有些助益罢。


  于此结束本文。
腾讯GAD游戏程序交流群:484290331Gad游戏开发核心用户群

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引