MD3模型就是MD2模型的升级版,是ID公司的游戏引擎Quake3中使用的主模型格式。它的特点主要集中在骨骼概念的引入吧。本文记录一下ZWModelMD3的一些细节。昨日日本的大地震大海啸真是触目惊心,多少只大怪兽联合才能做到这种恐怖程度……祈福,致所有最近在地震中受灾的地球人。——ZwqXin.com
相关的模型载入类文章:
[用Indexed-VBO渲染3DS模型]
[OBJ模型文件的结构、导入与渲染Ⅰ]
[OBJ模型文件的结构、导入与渲染Ⅱ]
[MD2格式模型的格式、导入与帧动画]
本文来源于 ZwqXin (http://www.zwqxin.cn/), 转载请注明
原文地址:http://www.zwqxin.cn/archives/opengl/model-md3-format-import-animation.html
言归正传吧。其实我觉得MD3模型是一种颇麻烦的模型格式。也许当时骨骼动画还没有大行其道吧,制定模型格式的时候对骨骼概念有了借鉴,但是也只是仅仅限于比较表层的概念:譬如说上层骨骼的运动会影响其下层的骨骼的运动,这种父子关系应该说是骨骼动画中比较核心的东西。MD3实现了这层概念,但是表现出来的却是颇粗糙的:一个怪物模型(MD系列模型主要就是一些人体和怪物)被分成三部分:头部模型、上身模型、下身模型(当然可以说还有一些武器模型)。这三个模型是分别独立的,分别对应一个.md3后缀二进制模型文件和一个.skin后缀文本的材质文件(这些文件名包含head、upper、lower来作区分,显得随意性很大)。
三个模型之间用tag来标记连接点,下身模型(lower)作为基本的父模型,上身模型(upper)则是它的子模型,下身的运动会相应地传递到上身,同样头部模型(head)是上身模型的子模型。怎么样,很简朴的骨骼吧。我们其实很难说MD3它不是骨骼模型,但是说它是又好像觉得太勉强了点——嘛,就是这种状态。动画信息存储在一个以.animation后缀名的文本文件中:
- sex f
- // first frame, num frames, looping frames, frames per second
- headoffset -15 0 0
- 0 30 0 20 // BOTH_DEATH1
- 29 1 0 20 // BOTH_DEAD1
- .....
- 90 40 0 20 // TORSO_GESTURE
- ...
- 153 8 8 20 // LEGS_WALKCR
- ....
动画只有下身和上身具备,头部和武器等都是连接到上身模型上,跟着它运动的而已。每个数字的意义在开头的注释行说得很清楚了。而其每一行所代表的动画则用后注释的方式标明——是的,即使不标也没关系,不过是隐藏了动画的信息难为使用者而已。(本ZWModelMD3类是根据这些字眼来判断动画归属上半身还是下半身模型的,所以不标则很麻烦。)还好MD系列模型所属的ID公司不是那么小家子的,毕竟连模型格式都那么公开了。MD3模型,即使那么不方便,也算是标记了一个小时代了吧。
说它不方便,首先当然是一个模型就包含那么多个文件:最基本的3个.md3和3个.skin、1个.animation还有纹理(与MD2不同,不再是一个模型一个纹理那样简洁的了),有时候还附加一个或2个.md3的武器模型和对应的材质文件(用.shader文件)。所以标准的做法是把它们集合到一个以模型名字为名字的文件夹中。这里给我的模型管理类引入了路径问题,已经勉强解决了我就不在此提了。
理应MD3的载入类ZWModelMD3与ZModelMD2很相似,但是事实完全8是这样的。我还是得几乎重新写载入部分的代码。其中一个理由很容易理解,存储格式变化颇大(后面慢慢道来);还有一个理由则是现在一个模型要包含三个子部分模型和一个武器模型的集合(可以为0),也就是很多东西都要至少三份了。当然导入上就只是加个循环的简单问题,但是识别文件和连接模型也得花点心思了。
- //寻找该文件上层文件夹下的md3后缀文件
- int nPartType = 0;
- WIN32_FIND_DATA FindData;
- memset(szPathFileName, 0, sizeof(szPathFileName));
- wcscat_s(szPathFileName, sizeof(szPathFileName) / sizeof(wchar_t), szResDirectory);
- wcscat_s(szPathFileName, sizeof(szPathFileName) / sizeof(wchar_t), L"*.md3");
- HANDLE hFindHandle = ::FindFirstFile(szPathFileName, &FindData);
- if(INVALID_HANDLE_VALUE != hFindHandle)
- {
- do
- {
- nPartType = -1;
- _wcslwr_s(FindData.cFileName, sizeof(FindData.cFileName) / sizeof(wchar_t));
- if(wcsstr(FindData.cFileName, L"head"))
- {
- nPartType = MD3_MAINPART_HEAD;
- }
- else if(wcsstr(FindData.cFileName, L"upper"))
- {
- nPartType = MD3_MAINPART_UPPER;
- }
- else if(wcsstr(FindData.cFileName, L"lower"))
- {
- nPartType = MD3_MAINPART_LOWER;
- }
- if(nPartType >= 0 && nPartType < MD3_MAINPART_COUNT)
- {
- memset(szPathFileName, 0, sizeof(szPathFileName));
- wcscat_s(szPathFileName, sizeof(szPathFileName) / sizeof(wchar_t), szResDirectory);
- wcscat_s(szPathFileName, sizeof(szPathFileName) / sizeof(wchar_t), FindData.cFileName);
- if(0 == _wfopen_s(&m_FilePointer, szPathFileName, L"rb"))
- {
- if(!ImportSubModel(&m_ModelMainMD3[nPartType], szPathFileName,
- (MD3_MAINPART_UPPER == nPartType || MD3_MAINPART_LOWER == nPartType), usage))
- {
- return false;
- }
- fclose(m_FilePointer);
- m_FilePointer = NULL;
- }
- }
- } while (::FindNextFile(hFindHandle, &FindData));
- }
可以的话我都不想这样做,加了这么多平台相关的代码不说,实在很麻烦(skin文件也是类似的):轮询文件夹下的文件(m_ModelMainMD3[MD3_MAINPART_COUNT]是那三个SubModel),找文件名的关键字(是的,如果你的头部模型文件里没有head这四个字符或者文件夹里有两个以上包含head字符的.md3文件,会把问题弄得很复杂)。ImportSubModel函数与以往一样,导入文件后初始化帧信息和生成VBO。还是从MD3模型格式开始吧:
- typedef struct tagMd3Header
- {
- char szMagic[4]; // The magic string used to identify the file.
- int32 nVersion; // The file version number (must be 15).
- char szFileName[MNAME]; // The file name.
- int32 nFlag; // The retained flag.
- int32 nNumFrames; // The number of animated bones' frames.
- int32 nNumTags; // The number of tags.
- int32 nNumMeshes; // The number of meshes associated with the model.
- int32 nNumMaxSkin; // The max number of skins.
- int32 nOffsetFrames; // The offset in the file for the frames data.
- int32 nOffsetTags; // The offset in the file for the tags data.
- int32 nOffsetMeshes; // The offset in the file for the meshes data.
- int32 nFileSize; // The size of the whole file.
- }Md3Header;
- //导入结构定义
- typedef struct tagBoneFrameInfo
- {
- float32 fMinPos[3];
- float32 fMaxPos[3];
- float32 fPos[3];
- float32 fScale;
- char szCreator[16];
- }BoneFrameInfo;
- typedef struct tagTagInfo
- {
- char szName[MNAME];
- float32 fTranslation[3];
- float32 fRotation[3][3];
- }TagInfo;
- typedef struct tagMeshHeaderInfo
- {
- char szMeshID[4]; // The mesh ID.
- char szMeshName[MNAME]; // The mesh name.
- int32 nFlag; // The retained flag.
- int32 nNumMeshFrames; // The number of frames of every mesh(should equal to header's nNumFrames).
- int32 nNumSkins; // The number of mesh skins.(should equal to 1 ?)
- int32 nNumVertices; // The number of mesh vertices.
- int32 nNumFaces; // The number of mesh faces.
- int32 nOffsetFaces; // The offset from the nOffsetMeshes defined by header, for the faces data.
- int32 nOffsetSkins; // The offset from the nOffsetMeshes defined by header, for the skins data.
- int32 nOffsetTexcoord; // The offset from the nOffsetMeshes defined by header, for the texcoords data.
- int32 nOffsetVertices; // The offset from the nOffsetMeshes defined by header, for the vertices data.
- int32 nMeshInfoSize; // The size of the whole mesh info data.
- }MeshHeaderInfo;
- typedef struct tagFaceInfo
- {
- int32 nVertIndex[3];
- }FaceInfo;
- typedef struct tagSkinInfo
- {
- char szName[MNAME];
- int32 nIndex;
- }SkinInfo;
- typedef struct tagTexcoordInfo
- {
- float32 u;
- float32 v;
- }TexcoordInfo;
- typedef struct tagFrameVertInfo
- {
- int16 vertSrc[3];
- unsigned char normalInfo[2];
- }FrameVertInfo;
- //导入结构定义结束
与MD2格式模型一样,一切从文件头Md3Header开始,每个MD3文件开头的sizeof(Md3Header)大小的都是一个这样的文件头,注释部分我参考了网上注解,应该没错吧(如果有错请指出)。与MD2的不同貌似信息少了不少——别担心,后面还有个MeshHeaderInfo呢。Md3Header除了用于标识的MagicWord(IDP3),version(15),没啥用的maxSkin外,最主要的就是三个东西:boneFrame、tag、mesh。在“导入结构定义”部分的头三个,就是这部分信息的数据结构,BoneFrameInfo基本可以不理会,除非你想把骨骼控制点(建模软件里弄骨骼的那些)也渲染出来。TagInfo就是骨骼信息的核心了,每个tag作为一个连接标记,决定了所连接的子模型的位置和运动形式——一个变换矩阵。而MeshHeaderInfo就是我们模型的Base数据了,一个模型由一个或多个网格对象(Mesh)组成,每个网格对象对应单一纹理和VBO信息组,这个与3DS、OBJ类似(参见[用Indexed-VBO渲染3DS模型] [OBJ模型文件的结构、导入与渲染Ⅱ])。每个Mesh段也是开头一个信息头,指涉其所在区域内数据的组织:顶点位置、顶点法线、顶点纹理坐标、顶点索引(Skin信息不用理会,我们使用的是用mesh的名字标识去检索.skin文件内容)。顶点信息(FrameVertInfo)有点怪异,接下来会说说。如果你这时惊讶于为什么还是每帧都一堆顶点属性(这不还是逐帧动画么),你还没弄清楚“骨骼概念”动画与真正的骨骼动画的差距。
再来看看我自定义的保存模型信息的数据结构(再提醒一次,如果你只打算用传统的glVertex打点来绘制而不用VBO,你大可不必这么麻烦地转换,从上述读入的文件"raw信息"已经基本足够提供个给你绘制了,网上也有很多这样的例子吧):
- // Tag信息
- typedef struct tag3DTagFrameInfo
- {
- Vector3 fTranslation;
- float fRotation[3][3];
- }t3DTagFrameInfo;
- // TagLink挂接模型信息
- typedef struct tag3DTagLink
- {
- char szTagName[MNAME]; // Tag名称
- t3DTagFrameInfo *pTagFrameInfos; // 该Tag下各帧的矩阵信息
- std::vector<void *> LinkingModels; // 挂接模型
- }t3DTagLink;
- // 动画信息
- typedef struct tag3DAnimation
- {
- unsigned int nStartFrame;
- unsigned int nEndFrame;
- unsigned int nFramesPerSec;
- }t3DAnim;
- // 模型的帧动画信息
- typedef struct tag3DFrameInfo
- {
- unsigned int nCurFrame; // 当前帧
- unsigned int nNextFrame; // 下一帧
- unsigned int nStartFrame;// 开始帧
- unsigned int nEndFrame; // 结束帧
- float fSecPerKeyFrame; // 关健帧间隔
- float fCurBlendValue; // 当前融合变量
- DWORD DStartPlot; // 开始时点
- }t3DFrameInfo;
- // 网格对象信息
- typedef struct tag3DObject
- {
- char szName[MNAME];
- GLuint nDiffuseMap;
- Vector3 *pPosVerts;
- Vector3 *pNormals;
- TexCoord *pTexcoords;
- unsigned short *pIndexes;
- unsigned int nNumIndexes;
- unsigned int nNumVerts;
- unsigned int nNumPosVBOs;
- GLuint *pPosVBO;
- GLuint *pNormVBO;
- GLuint nTexcoordVBO;
- GLuint nIndexVBO;
- }t3DObject;
- // 模型信息结构体
- typedef struct tag3DModel
- {
- bool bVisable; // 是否渲染
- bool bIsTextured;// 是否使用纹理
- Md3Header MD3Header; // MD3头文件
- t3DObject *pObjects; // 网格对象
- t3DTagLink *pLinkModels;// Tag连接的模型
- std::vector<t3DAnim> Animations; // 动画列表
- t3DFrameInfo *pFrameInfo; // 帧信息(只对Upper和Loewer)
- GLuint TexObjDiffuseMap; // 纹理所在的纹理对象
- }t3DModel;
网格对象t3DObject这个我就不多解释了,结合[OBJ模型文件的结构、导入与渲染Ⅰ]中类似的数据定义,或者看名字都知道了。t3DModel这里除了保存一堆网格对象外,还有一个帧信息t3DFrameInfo,只有上身模型和下身模型才有必要new一个出来:因为它就是用于动画的信息啊(结合一个模型所具有的动画t3DAnim列表[由.animation文件导入])。然后比较新颖的就是这个t3DTagLink结构了吧,它就是保存上述的tag信息了,它保存了tag名字、这个tag在每一动画帧下的用于子模型的变换矩阵信息(位置和旋转),还保存着连接到这个tag的子模型列表(LinkingModels)。
数据的转换应该来说关键只是这些数据结构的定义,实际的转换代码也就那么些赋值转换之类了。这里特别注意的是FrameVertInfo(上面提及过),3个short和2个字节可以表示一个顶点位置和一个法线?ID公司的人就是那么有点犀利——把short除以64来得到位置信息,把两个BYTE归一成(0,1)再乘以360度的弧度来标识球体的经度维度——原点指向该经纬位置的向量,就是我们需要的法线啦!绝!这个我是从GPU GEMS1某个例子里导入MD3模型的代码中发现奇怪的,网上查到解析信息才知道的,不然我可能就忽略它自己去计算粗糙的法线了(毕竟MD2就只能这样)。以下清单的解压缩部分是重点哈~~
- typedef struct tagFrameVertInfo
- {
- int16 vertSrc[3];
- unsigned char normalInfo[2];
- }FrameVertInfo;
- // 读入文件中的对象到模型中
- void ZWModelMD3::ProcessFileInfo(t3DModel *pModel)
- {
- //Load Tags Data
- .....
- //Load Meshes Data
- pModel->pObjects = new t3DObject[pModel->MD3Header.nNumMeshes];
- MeshHeaderInfo meshHeaderInfo = {0};
- float dLongtitude = 0, dLattitude = 0;
- int32 nCurMeshOffset = pModel->MD3Header.nOffsetMeshes;
- for(int i = 0; i < pModel->MD3Header.nNumMeshes; ++i)
- {
- fseek(m_FilePointer, nCurMeshOffset, SEEK_SET);
- fread_s(&meshHeaderInfo, sizeof(MeshHeaderInfo), sizeof(unsigned char),
- sizeof(MeshHeaderInfo) / sizeof(unsigned char), m_FilePointer);
- int nTotalVerts = meshHeaderInfo.nNumVertices * pModel->MD3Header.nNumFrames
- FrameVertInfo *pFrameVertInfo = new FrameVertInfo[nTotalVerts];
- pModel->pObjects[i].pPosVerts = new Vector3[nTotalVerts];
- pModel->pObjects[i].pNormals = new Vector3[nTotalVerts];
- for(int j = 0; j < nTotalVerts; ++j)
- {
- pModel->pObjects[i].pPosVerts[j].set(pFrameVertInfo[j].vertSrc[0] / 64.0f,
- pFrameVertInfo[j].vertSrc[1] / 64.0f,
- pFrameVertInfo[j].vertSrc[2] / 64.0f);
- dLongtitude = pFrameVertInfo[j].normalInfo[0] * 2.0f * MD3_PI / 255;
- dLattitude = pFrameVertInfo[j].normalInfo[1] * 2.0f * MD3_PI / 255;
- pModel->pObjects[i].pNormals[j].set(cosf(dLattitude) * sinf(dLongtitude),
- cosf(dLongtitude),
- -sinf(dLattitude) * sinf(dLongtitude));
- }
- delete []pFrameVertInfo;
- //...............
- }
- };
现在再来看看怎么连接模型吧(MD3的骨骼概念核心):
- void ZWModelMD3::LinkModels(t3DModel *pParentModel, t3DModel *pChildModel, char *szTagName)
- {
- for(int i = 0; i < pParentModel->MD3Header.nNumTags; ++i)
- {
- if(0 == strcmp(pParentModel->pLinkModels[i].szTagName, szTagName))
- {
- pParentModel->pLinkModels[i].LinkingModels.push_back(pChildModel);
- }
- }
- }
- LinkModels(&m_ModelMainMD3[MD3_MAINPART_LOWER], &m_ModelMainMD3[MD3_MAINPART_UPPER], "tag_torso");
- LinkModels(&m_ModelMainMD3[MD3_MAINPART_UPPER], &m_ModelMainMD3[MD3_MAINPART_HEAD], "tag_head");
后面是ImportModel里的执行代码。其实我们只是在t3DTagLink里保存了子模型的指针而已。下身模型的一个叫“tag_torso”的Tag连接上身模型,上身模型的一个叫“tag_head”的Tag连接头部模型。(顺带一提,上身模型的一个叫“tag_weapon”的Tag一般是用来连接武器模型的[如果有的话]。)我们渲染的时候,其实就是先渲染父模型,再渲染子模型,再渲染子模型的子模型这样下去(每个子模型渲染前都乘以对应连接tag的变换矩阵)……因为是一种树型的调用模式,所以递归出场了(实在兴奋不起来):
- void ZWModelMD3::DrawModelWithSubModel(t3DModel *pModel, bool bShaderMode)
- {
- DrawSubModel(pModel, bShaderMode);
- ZWQuaternion qQuat, qNextQuat, qInterpolatedQuat;
- Vector3 iterpPos;
- ZWMatrix16 mtFin;
- for(int i = 0; i < pModel->MD3Header.nNumTags; ++i)
- {
- if(pModel->pLinkModels[i].LinkingModels.size() > 0 && pModel->pFrameInfo)
- {
- iterpPos = pModel->pLinkModels[i].pTagFrameInfos[pModel->pFrameInfo->nCurFrame].fTranslation;
- iterpPos = iterpPos + (pModel->pLinkModels[i].pTagFrameInfos[pModel->pFrameInfo->nNextFrame].fTranslation
- - iterpPos) * pModel->pFrameInfo->fCurBlendValue;
- qQuat.SetFromMatrixRotationElements(&pModel->pLinkModels[i].pTagFrameInfos[pModel->pFrameInfo->nCurFrame].fRotation[0][0]);
- qNextQuat.SetFromMatrixRotationElements(&pModel->pLinkModels[i].pTagFrameInfos[pModel->pFrameInfo->nNextFrame].fRotation[0][0]);
- qInterpolatedQuat.SlerpFrom(qQuat, qNextQuat, pModel->pFrameInfo->fCurBlendValue);
- qInterpolatedQuat.GenerateMatrix3X3(&mtFin);
- mtFin.SetTranslationPart(ZWVector4D(iterpPos.x, iterpPos.y, iterpPos.z, 1.0f));
- glPushMatrix();
- glMultMatrixf(mtFin.mt);
- for(unsigned int j = 0; j < pModel->pLinkModels[i].LinkingModels.size(); ++j)
- {
- DrawModelWithSubModel((t3DModel*)pModel->pLinkModels[i].LinkingModels[j], bShaderMode);
- }
- glPopMatrix();
- }
- }
- }
- //渲染时调用
- DrawModelWithSubModel(&m_ModelMainMD3[MD3_MAINPART_LOWER], true);
这部分参考的是GameTutorials, LLC的一个MD3导入教程(因为比较久远,那时候VBO还没出生)。这个教程更重要的是一些tips,譬如导入.animation文件时lower的索引要减去upper索引的首值。还有就是我们插值的方式,我们要进行动画帧间的插值,线性插值的前后应该是对应乘了变换矩阵的点坐标——这样麻烦了点,效果也不好。考虑到我们实际插值的是一个点坐标向量的旋转,所以真●主角出场了——
Quaternion四元数([GimbalLock万向节锁与四元数旋转])!!
这下倒有点兴奋了。我们在上面把Tag信息里相邻两帧的3X3roatation传给ZWQuaternion类生成两个四元数,再利用SLERP四元数球插值(网上参考很多)来弄出中间的代表旋转的四元数——变换回矩阵,加上translation部分,这样就把子模型的位置点坐标转到正确漂亮的位置了!
那你说最底的父模型——下身模型怎么办,没有其他外部的tag信息提供变换矩阵参数给它啊——是的,它的话,唯有线性插值了TAT(建议用shader Mode)。
渲染部分整个代码就跟其他格式模型差不多了,动画部分也可以根据MD2的那套延伸出[MD2格式模型的格式、导入与帧动画](毕竟本质上还是帧动画,只不过渲染上加了个骨骼的概念而已嘛)。导入skin文件其实就是查询匹配模型的网格对象的名字,然后把路径最后的文件名指涉的纹理文件导入为纹理而已;最后给出的是我的.animation文件的导入函数,其实就是上面提及的那个BUG不BUG的东西要注意点。注意,上下身的动画帧总数很多时候是8一样的,连续动画最后模型姿态会变囧的。(其实MD3模型就是上下身各指定一个动画吧,不过除了BOTH的动画外,其他的组合实在很容易让人,擦。)
- void ZWModelMD3::LoadAnimationInfo()
- {
- char strBuff[MAX_LINE] = {0};
- t3DAnim animation = {0};
- unsigned int numfr = 0, loopfr = 0;
- int nLegFrameFix = -1;
- while (fgets(strBuff, MAX_LINE, m_FilePointer))
- {
- if(!isdigit(strBuff[0]))
- {
- continue;
- }
- if(4 == sscanf_s(strBuff, "%d %d %d %d", &animation.nStartFrame, &numfr, &loopfr,
- &animation.nFramesPerSec))
- {
- animation.nEndFrame = animation.nStartFrame + numfr;
- if(strstr(strBuff, "BOTH"))
- {
- m_ModelMainMD3[MD3_MAINPART_UPPER].Animations.push_back(animation);
- m_ModelMainMD3[MD3_MAINPART_LOWER].Animations.push_back(animation);
- }
- else if(strstr(strBuff, "TORSO"))
- {
- m_ModelMainMD3[MD3_MAINPART_UPPER].Animations.push_back(animation);
- }
- else if(strstr(strBuff, "LEG"))
- {
- if(-1 == nLegFrameFix)
- {
- nLegFrameFix = animation.nStartFrame -
- m_ModelMainMD3[MD3_MAINPART_UPPER].Animations[0].nStartFrame;
- }
- animation.nStartFrame -= nLegFrameFix;
- animation.nEndFrame -= nLegFrameFix;
- m_ModelMainMD3[MD3_MAINPART_LOWER].Animations.push_back(animation);
- }
- }
- }
- }
就这样结束吧。老实说,MD3这么麻烦,要不是为了学习性连贯点,我就该直奔MD5去了。
这么多位型人,快点左一脚右一脚去收拾那些大怪兽啦!
本文来源于 ZwqXin (http://www.zwqxin.cn/), 转载请注明
原文地址:http://www.zwqxin.cn/archives/opengl/model-md3-format-import-animation.html