MD5模型是ID公司第一款真正意义上的骨骼格式模型,在04年随着Doom3一起面世,经过几个版本的变更,现在在骨骼模型格式中依然有其重要地位。本文记录一下ZWModelMD5中的一些细节,先是稍微笔记一下骨骼模型的基本概念和MD5文件的格式与导入。——ZwqXin.com
本文来源于 ZwqXin (http://www.zwqxin.cn/), 转载请注明
原文地址:http://www.zwqxin.cn/archives/opengl/model-md5-format-import-animation-1.html
经过MD2的帧动画和MD3的骨骼概念动画,当然还有MD4/MDL的尝试,在那个骨骼模型开始风行的时代,MD5作为骨骼动画出现了。在今天,3D模型通常分为静态模型、帧动画模型、骨骼动画模型,它们分别应用于不同的场合,静态模型就不用说了,帧动画模型主要用于人物动作简单、固定、与场景不怎么需要交互的场合,而骨骼动画模型就是与此相对了。
骨骼的这个概念与我们人体的骨骼还是类似的。我们可以把自己看做一堆骨骼,然后外面蒙上一层肌肉啊皮啊什么的,然后这些肌肉啊皮肤啊的就跟随骨骼的运动而运动。当然了,重要的是我们体内还有那么多器官,那些MD5人体和怪物模型就没有了(笑)。骨骼与骨骼之间是用骨骼节点连接的,我们称骨骼为Bone,称骨骼节点为Joint,一根bone的一端或两端连着两个Joint,而一个Joint可能连着数条Bone。骨骼模型的描述也分为以Bone为主和以Joint为主,MD5是后者。你可以认为Joint就是控制点,通过控制Joint的位置和旋转,可以控制整个骨骼,而整个骨骼也就影响模型的外皮(顶点网格),于是动画模式建立了。Joint的集合可以用一个树的数据结构描述——跟MD3一样,有一个总的父节点,总的父节点下连着一个或多个子节点,这些子节点本身也作为父节点下连一个或多个子节点……父节点的移动直接先作用到子节点上(抬动肩关节时手臂节点也跟着作同样的运动,之后手肘节点跟着手臂节点作同样移动……类推到指尖节点),再叠加上子节点本身的移动(手臂节点本身可以再那基础上作移动,其影响共同作用到手肘节点……用身体摆摆姿势,这其实是很形象的),于是这个前向的驱动模式建立了。每个Joint的运动信息可以抽象成一个变换矩阵M([乱弹OpenGL中的矩阵变换(上)] ),这样这个驱动模型可以看做是每个时刻给予每个节点一个变换矩阵,变换节点的位置和旋向以驱动骨架。
既然骨架模型建立了,接下来就是骨架与模型顶点数据的关系。骨骼模型本身渲染出来的不是骨架,而是组成网格(皮肤)的一堆顶点。这堆顶点是怎样定义的呢?在MD2中,每帧都包含一堆顶点位置数据,结果就是程序需要存储大规模的顶点位置数据。MD5则不直接储存顶点位置数据,而是让程序每帧”计算“出来。在MD5的文件中的网格数据包括纹理坐标(因为最后的顶点数目是固定的,做一纹理坐标数据的数目与之一致)、索引(把顶点组成三角面片,因为最后的顶点是有序的,前一帧的顶点跟后一帧的一一对应,所以只要按这个次序定义索引即可)、节点权重(weight,这就是关联骨骼节点跟顶点的东西,下述);一个顶点数据有一个纹理坐标、一个或多个weight组成,然后索引数据组织顶点。与以往不同的是,这里面没有法线数据,MD5采用的是与3DS([用Indexed-VBO渲染3DS模型] )和OBJ([OBJ模型文件的结构、导入与渲染Ⅰ] )一样的策略,让程序自己去计算。
一个weight包含了它对应的Joint的索引(这样一来就建立了 vertex->weight->joint的连接),一个位置值(pos)和一个作用比率(bias)。一个顶点的计算公式如下:
- VertexPos = (MJ-0 * weight[index0].pos * weight[index0].bias) + ... + (MJ-N * weight[indexN].pos * weight[indexN].bias)
其中,MJ-x表示第x个weight对应的节点Joint的变换矩阵。作用比率bias的总和需要是1(100%),这样一个顶点位置可以看作是各个经过矩阵变换后的weight位置的加权平均。而这个Joint矩阵在动画过程中变化的话,结果就是对应计算出来的顶点位置也跟着变化了。这就是骨架驱动皮肤的过程,也称为”蒙皮“。这步计算可以在CPU上执行,也可以在GPU上执行——通过vertex shadr执行蒙皮,就称为”顶点蒙皮(vertex skinning)“,我将在下篇文章讲述。
一个MD5模型包含两个文件,其中.md5mesh后缀的文件包含了该模型的几何体数据(mesh),而.md5anim后缀的文件则包含了该模型的动画信息。这一点与MD3模型是一样的,只不过很多方面看上去更为规范,没有在[MD3模型的格式、导入与骨骼概念动画]文末提及的那些令人不爽的“小提示"。另一点很本质上不同的是,md5的两个文件都是文本文件,这当然提供了更大的方便性,但同时也容易出现文件被乱改的问题(当然了,本来idSoft就只是想自用而已)。
一个MD5可以只有md5mesh文件,这样模型只不过不含动画信息而已。而这时候出来的模型的姿态被称为Bind-pose。以前看视频看人用maya建模(就是看那部《堕落的艺术》的幕后花粹时),在修改模型,未定义动作之前,人物会呈现一个站立并两手平举的姿态。这就是一个模型的bind-pose姿态吧。这个概念在顶点蒙皮过程中尤显重要,不过你只需要记住这就是没有动画信息(没有md5anim)时候给予模型的一个”预设姿势“好了。下面看看文件结构
- joints {
- "origin" -1 ( -0.000000 0.016430 -0.006044 ) ( 0.707107 0.000000 0.707107 ) //
- "body" 0 ( -0.0000002384 0 56.5783920288 ) ( 0.507041 -0.578614 0.354181 ) // origin
- ....
- }
那些版本号啊XX总数的就不管了,从md5mesh文件开头看起,首先是Joint的定义:名称、父节点序号(-1说明本身是总父节点,这个序号其实就是行号了,譬如上面”origin“节点的序号就是0,无父节点; "body"节点序号是1,父节点序号是0,也就是说父节点是”origin“)、bind-pose姿态下节点的位置(位移)和旋转(旋转用四元数【[GimbalLock万向节锁与四元数旋转] 】表达,括号里是xyz,需程序自行计算w值)——后面两者可以组成一个变换矩阵Mself-bindpose,即bindpose姿态下各个节点自身的变换矩阵,如果给这个矩阵依次向上左乘该节点的树分支上各级父节点的变换矩阵,得到就是bindpose下该节点的真正变换矩阵MJ-x(bindpose)了。
- mesh { //一个网格对象
- shader "body1.tga" //该网格对象的纹理
- numverts 590 //顶点数据:vert 序号 (纹理坐标) 对应weight的起始序号 weight总数
- vert 0 ( 0.394531 0.513672 ) 0 2
- .....
- numtris 888 //索引数据: tri 序号 三角面片对应的顶点数据的序号
- tri 0 0 2 1
- .....
- numweights 967 //权重数据:weight 序号 对应的Joint的序号 比率bias值 (位置值)
- weight 0 5 1.000000 ( 6.175774 8.105262 -0.023020 )
- .....
- }
md5mesh文件后面部分就是一个个网格对象(mesh)的数据了。看上面注释,跟前面的讲述是一致的。注意这里vert末尾两个数据是对应下面那堆weight的,而且总是相邻的一个或多个weight,所以只需要第一个的序号和连续的weight的个数就可以确定了。顶点仅会被附近的weight影响。
接下来看md5anim:
- hierarchy { //Joint 名字 父节点序号 flag 影响的帧数据起始索引
- "origin" -1 63 0 //
- "Body" 0 63 6 // origin
- ....
- }
- bounds { //每帧的包围盒
- ( ... ) ( ... )
- }
- baseframe{ // 基础帧数据
- ( ... ) ( ... )
- }
- frame 0 { //帧0数据
- ...
- }
- frame 1 {
- ...
- }
- ...
老实说我觉得md5anim文件特别别扭,虽然理解起来不难。首先文件的开头也是joint的信息,不过这里主要针对帧数据,尾部的数据是一个索引值(nStartIndex),指向后面每一帧(frame x)的数据堆里, flag是一个bit位。嘛,这样看吧。MD5虽然不是帧动画,但它依然有”关健帧“的概念(也可以说这是动画本身的概念),模型的某个动画由有限个关健帧穿插并近邻插值而成,但MD5不同于MD2之处在于它只需要每个关健帧骨骼节点Joint的数据。为了替换上面bindpose的顶点计算公式,我们需要的只是每个joint在动画期间的变换矩阵MJ,但我们为了能在关健帧之间合理插值,通常并不直接保存矩阵而是分别保存位移信息(transform-vector3)和旋转信息(Rotation-quternion)。这个文件主要包含的就是这每个关健帧下每个Joint的这两个数据,当然还包括关健帧数目。至于这文件里的每帧的模型包围盒信息,并不是必要的。
在上面的baseframe里有与Joint数目相等的行数,把每行看作一个joint的位移信息+旋转信息(6个数字,这跟md5mesh文件开头joint的bindpose信息是一样的格式),但这里的baseframe数据无实际意义,仅表示一个”基础数值“,对于第x个关健帧,就拿下面frame x里的某些数据替换这些”基础信息“,具体每个joint要拿哪些数据去替换,正就是开头的索引值(nStartIndex)和flag决定的了。nStartIndex决定了替换开始对应数据堆的位置,nflag决定替换6个数字中的哪几个(flag分别与1、2、4、8、16、32作逻辑与,第一个出现为真的时候就拿nStartIndex处的数据替换掉,第二个出现真的时候就拿nStartIndex+1处的数据替换掉...如果逻辑与结果为假则不替换直接用回basefame里对应的数据)。这样下来就能取得我们要的"每个关健帧下每个Joint的位移信息+旋转信息"。
导入代码没什么特别的,也就按步骤进行”文件->一定数据结构下的内存数据“的转换。但确实颇冗长,尤其我还是以C语言方式进行读文件的……最后计算法线、生成VBO等都跟以前的模型导入流程差不多,有些细节地方我将在下篇文章提及。最后给出我用于导入的数据结构:
- //包围盒信息
- typedef struct tag3DBound
- {
- Vector3 vMin;
- Vector3 vMax;
- }t3DBound;
- // 模型的帧动画信息
- typedef struct tag3DFrameInfo
- {
- unsigned int nFrameCount; // 总帧数
- unsigned int nAnimComponent; // 每帧动画数据量
- unsigned int nCurFrame; // 当前帧
- unsigned int nNextFrame; // 下一帧
- unsigned int nStartFrame; // 开始帧
- unsigned int nEndFrame; // 结束帧
- float fSecPerKeyFrame; // 关健帧间隔
- float fCurBlendValue; // 当前融合变量
- DWORD DStartPlot; // 开始时点
- t3DBound *tBoundingBox; // 包围盒
- }t3DFrameInfo;
- // Joint属性
- typedef struct tag3DJointInfo
- {
- Vector3 vTransform;
- Quaternion qRotatation;
- }t3DJointInfo;
- // Joint模型关节点信息
- typedef struct tag3DJoint
- {
- char szJointName[MNAME]; // Joint名称
- int nParentJointIndex; // 父关节点索引
- Matrix16 BindPoseMatrix; // Joint 基本变换(位移和旋转)矩阵
- Matrix16 BindPoseMatrixInv; // Joint 基本变换(位移和旋转)矩阵的逆矩阵
- std::vector<t3DJointInfo> FramePoseInfoVec; // Joint 帧位移和旋转
- BYTE nAffectFlags; // 产生影响的顶点数据对象的标记
- unsigned int nAffectStartIndex; // 产生影响的顶点数据对象在帧数据的起始位置
- }t3DJoint;
- //顶点权位信息
- typedef struct tag3DWeight
- {
- unsigned int nAttachJoint;
- float fBias;
- Vector3 vPos;
- }t3DWeight;
- typedef struct tag3DVectorInfo
- {
- unsigned int nWeightStartIndex;
- unsigned int nWeightCount;
- }VectorWeightInfo;
- // 网格对象信息
- typedef struct tag3DObject
- {
- GLuint nDiffuseMap;
- Vector3 *pPosVerts;
- Vector3 *pNormals;
- TexCoord *pTexcoords;
- t3DWeight *pPosWeights;
- VectorWeightInfo *pVecWeightInfo;
- unsigned short *pIndexes;
- unsigned int nNumIndexes;
- unsigned int nNumVerts;
- unsigned int nNumWeights;
- GLuint nPosVBO;
- GLuint nNormVBO;
- GLuint nTexcoordVBO;
- GLuint nWeightVBO;
- GLuint nWightCountVBO;
- GLuint nJointIndexVBO;
- GLuint nIndexVBO;
- }t3DObject;
- // 模型信息结构体
- typedef struct tag3DModel
- {
- bool bVisable; // 是否渲染
- bool bIsTextured; // 是否使用纹理
- bool bHasAnim; // 是否含动画信息
- GLuint TexObjMap; // 纹理对象
- std::vector<t3DObject> t3DObjVec; // 网格对象列表
- std::vector<t3DJoint> t3DJointVec; // 骨骼点列表
- t3DFrameInfo tFrameInfo; // 帧信息
- }t3DModel;
注意,对于Bindpose的Joint信息我是直接作为矩阵存储的(它的逆矩阵在顶点蒙皮的时候有用,所以也预先存储了),而动画过程中的Joint信息我是作为位移+旋转信息存储的(为了在关键帧中插值)。现在它们都在同一结构体内,迟些时候我应该会分开它们的(分开mesh部分和anim部分)。VBO部分有三个比较特殊的:nWeightVBO(weight的比率bias,用vec4传输,也就是说如果影响一个顶点的weight多于4个,我会把它们压成4个);nWightCountVBO(实际的weight数目);nJointIndexVBO(该weight对应的Joint的序号),这些对于顶点蒙皮是有用的,所以需要作为顶点属性传入vertex-sahder。
本文来源于 ZwqXin (http://www.zwqxin.cn/), 转载请注明
原文地址:http://www.zwqxin.cn/archives/opengl/model-md5-format-import-animation-1.html