1、骨骼动画(Skeletal Animation)相信这里没有人没玩过采用骨骼动画技术的游戏,看看那些热门的动作游戏,例如波斯王子、分裂细胞和战神,你就知道骨骼动画的威力了(我承认是猜的)。骨骼动画技术用来使我们的 3D 模型在屏幕上动起来,通过和动作捕捉技术结合,可以让模型做出非常逼真的动作。而这样一个极具威力的技术,其原理却相当简单。 假设我们要让游戏主角做出一个动作,例如波斯王子拿弯刀往前一劈。最简单的方法,就是让模型师建一个动画序列,然后在程序中逐帧播放,就像放电影一样。不过这样一来工作量就太大了,玩家也需要 N 个 G 的硬盘来安装这个游戏。与此不同,骨骼动画技术采用了一种很聪明的方式
2、。首先,建模师完成一个标准姿势的 3D 模型,通常是双手沿着肩的方向伸展平放,双脚打开。所有的后继动作将由这个基础动作演变得到。在完成这个基准模型之后,建模师再建一个骨骼结构,一系列相互关联的顶点,就像一个骨架一样,与人体模型各个关节匹配并且都会有一定数量的顶点与之关联。想象一下人和人身上的骨骼就很容易知道我在说什么。之后,在我们想要完成的动画序列中,挑选一些关键帧,对每个关键帧,将骨骼的位置与关键帧匹配。然后把这一系列的关键帧骨骼保存起来,除了骨骼的位置,同时保存的还有从基准位置变换到当前关键帧的旋转、平移、缩放或者一个混合的坐标变换矩阵。在我们引擎中,首先根据当前时间查找这时候角色是处于哪
3、两个关键帧中间。找到之后以时间为参数在关键帧的坐标变换矩阵之间求插值,用插值结果来决定骨骼当前的位置。骨骼位置求出来后,所有和骨骼关联的顶点的坐标也可以相应求出来了。通过使用骨骼动画技术,我们用相对较少的数据就可以播放很平滑的动画!了解了相关原理,来看看如何在 directx 中播放骨骼动画。我的参考书是Advanced Animation with DirectX。现在我们知道为了播放骨骼动画,需要有骨骼(bone)的数据,模型(mesh)的数据,关联骨骼和模型上每个顶点的关联数据,以及关键帧的坐标变换数据。所有这些数据必须以某种形式存在于某个地方供我们获取才行。这里要介绍的 MS 的 x
4、文件格式以及从中获取数据的方法。强烈建议大家都来学习一下 x 文件格式!你会发现它即简单又强大,即使用来存放自定义数据也是相当的方便,一旦掌握之后我保证你会对它爱不释手。 典型的 x 文件以数据模板和实际数据两部分组成。数据模板类似 c+中的结构定义,不过更为灵活和开放。实际数据就是遵守模板定义的数据段。看一个例子,template Employee / 每个模板关联唯一的 GUID STRING Name; / 姓名DWORD Sex; / 性别 ContactEntry / 联系方式, 另一个模板,模板可以嵌套template ContactEntry / GUIDSTRING Phone
5、Number; / 电话号码STRING Address; / 地址Employee David“David“;1;ContactEntry“100-100000000“;“far far away“;从上面这个简单的例子我们就可以看出 x 文件的大概模样了,详细的情况大家可以参考Advanced Animation with DirectX。下面我们看如何来读取这样一个 x 文件,借助下几个对象,ID3DXFile - x 文件格式文档对象。例如 Employee.x 这样一个文件。ID3DXFileEnumObject - 用来枚举 x 文档的顶级模板数据。所谓顶级模板数据是指那些没有 父
6、模板的数据,例如上面的 David 数据段。ID3DXFILEDATA - 模板数据。上面的 David 和他的联系方式都是 ID3DXFILEDATA对象,自包含。下面看实际的分析函数, 下面的代码适用于 DirectX 9.0 SDK Update (October 2004),原书的代码有点过时了。/-/ 名称 : Parse/ 描述 : 分析 x 文件格式文档/-bool Parse( char *filename, void *pData )LPD3DXFILE lpD3DXFile;LPD3DXFILEENUMOBJECT lpD3DXFileEnumObj;LPD3DXFILED
7、ATA lpD3DXFileData;/ 参数检查if( NULL = filename )return false;/ 创建 X 文件对象 HRESULT hr = D3DXFileCreate( if( FAILED( hr ) )return false;/ 注册标准模板hr = lpD3DXFile-RegisterTemplates( ( LPVOID )D3DRM_XTEMPLATES, D3DRM_XTEMPLATE_BYTES );if( FAILED( hr ) )Release( lpD3DXFile ); return false;/ 创建 X 文件枚举对象hr = lp
8、D3DXFile-CreateEnumObject( filename, D3DXF_FILELOAD_FROMFILE, if( FAILED( hr ) )Release( lpD3DXFile ); return false;/ 解析开始 bool parseResult = BeginParse( pData );if( true = parseResult )/ 查询顶级模板数SIZE_T childCount = 0;lpD3DXFileEnumObj-GetChildren( / 分析每个订级模板for( DWORD i=0; iGetChild( i, if( FAILED(
9、hr ) )break;/ 分析parseResult = ParseObject( lpD3DXFileData, NULL, 0, pData );/ 释放 FileData 对象Release( lpD3DXFileData ); / 出现错误,中断分析if( false = parseResult )break;/ 解析结束if( parseResult ) parseResult = EndParse( pData );/ 释放相关对象Release( lpD3DXFileEnumObj );Release( lpD3DXFile ); / 解析结束return parseResul
10、t;/-/ 名称 : ParseObject/ 描述 : 递归解析顶级模板/-bool ParseObject( LPD3DXFILEDATA pDataObj,LPD3DXFILEDATA pParentDataObj,DWORD depth,void *pData )LPD3DXFILEDATA pSubDataObj;bool parseResult = true;HRESULT hr;/ 获取子模板数目DWORD childCount;pDataObj-GetChildren( / 遍历模板并分析 for( DWORD i=0; iGetChild( i, if( FAILED( hr
11、 ) )break;/ 分析子模板parseResult = ParseObject( pSubDataObj, pDataObj, depth+1, pData );/ 释放数据对象Release( pSubDataObj ); / 出现错误,停止分析if( false = parseResult )break;return parseResult;就那么简单,相信大家都看得明白。通过重载 ParseObject 方法,我们以判断当前分析的模板类型,然后创建实际的模板对象,从文档中复制数据。有了上面的工具,我们就可以自己来读取和解析 x 格式的骨骼动画文件了。下面我们就来看看如何重载 Par
12、seObject 方法来获得我们感兴趣的数据,不要担心,绝对简单。仔细看代码,你会发现只需要做一件事情,判断当前数据段的类型(通过 GUID),分配对应的结构对象,然后从数据段拷贝数据(所有 SDK 自定义模板的 GUID都在头文件 rmxfguid.h 中定义, 你需要把它加入你的工程中。所有预定义模板在这里可以找到)。先来看看如何获取当前数据段的 GUID, GUID objGUID;pDataObj-GetType( 简单吧,下面开始我们的分析之旅。x 动画文件中骨骼是用 Frame 模板定义的,template FrameFrameTransformMatrix frameTransf
13、ormMatrix; / 骨骼相对于父节点的坐标变换矩阵Mesh mesh; / 骨骼的 Mesh 只有两个字段。FrameTransformMatrix 就是一个 matrix。Mesh 稍微复杂,详细格式大家自己参考 MSDN,我们也会有专门的代码来加载 Mesh,现在关注 Frame。为了加载Frame,我们要在程序中定义一个和 Frame 模板对应的数据结构,SDK 中经默认提供了一个,那就是 D3DXFRAME,typedef struct _D3DXFRAMELPSTR Name; / 骨骼名称 D3DXMATRIX TransformationMatrix; / 相对与父节点的坐
14、标变换矩阵LPD3DXMESHCONTAINER pMeshContainer; / LPD3DXMESHCONTAINER 对象,用来/ 加载 MESH,还有一些附加属性,见 SDKstruct _D3DXFRAME *pFrameSibling; / 兄弟节点指针,和下面的子节点指针/ 一块作用构成骨骼的层次结构。 struct _D3DXFRAME *pFrameFirstChild; / 子节点指针. D3DXFRAME, *LPD3DXFRAME;这样一个结构已经足够容纳 Frame 模板中的数据并形成一个层次结构,不过为了我们程序的需要,我们还需要其他字段,为此我们通常会扩展 D3
15、DXFRAME,typedef struct _D3DXFRAME_EX : public D3DXFRAME D3DXMATRIX matCombined; / 存储当前节点相对于根节点的位置偏移矩阵,沿着到/ 到根骨骼的路径把所有的坐标变换矩阵相乘得到。D3DXMATRIX matOriginal; / 在播放动画的时候有可能会改变原来结构中的/ TransformationMatrix,因此我们声名一个新的字段/ 将原来的坐标变换矩阵保存起来以便在需要的时候恢/ 复回去。. / 忽略一些方法定义我知道有些人已经按捺不住了,那么动手吧,/ 判断当前分析的是不是 Frame 节点if( ob
16、jGUID = TID_D3DRMFrame )/ 引用对象直接返回,不需要做分析。一个数据段实际定义一次后可以/ 被其他模板引用,例如后面的 Animation 动画模板就会引用这里的 Frame/ 节点,标识动画关联的骨骼。if( pDataObj-IsReference() )return true;/ 创建 D3DXFRAME_EX 结构,准备拷贝数据D3DXFRAME_EX *pFrame = new D3DXFRAME_EX();/ 拷贝名称pFrame-Name = GetObjectName( pDataObj );/ 注意观察文件就可以发现一个 Frame 要么是根 Fram
17、e,父节点不存在,/ 要么作为某个 Frame 的下级 Frame 而存在。if( NULL = pData )/ 作为根节点的兄弟节点加入链表。pFrame-pFrameSibling = m_pRootFrame;m_pRootFrame = pFrame;pFrame = NULL;/ 将自定义数据指针指向自己,供子/ 节点引用。pData = ( void* )else/ 作为传入节点的子节点D3DXFRAME_EX *pDataFrame = ( D3DXFRAME_EX* )( *pData );pFrame-pFrameSibling = pDataFrame-pFrameFir
18、stChild;pDataFrame-pFrameFirstChild = pFrame; pFrame = NULL;pData = ( void* ) 结束了!是不是很简单,呵呵,记住我们只需要做一件事情,判断类型,分配匹配的对象然后拷贝数据,下面来分析 Frame 中的 matrix,/ frame 的坐标变换矩阵, 因为 matrix 必然属于某个 Frame 所以 pData 必须有效else if( objGUID = TID_D3DRMFrameTransformMatrix / 先取得缓冲区大小,应该是个标准的 4x4 矩阵 DWORD size = 0;LPCVOID buf
19、fer = NULL;hr = pDataObj-Lock( if( FAILED( hr ) )return false;/ 拷贝数据if( size = sizeof( D3DXMATRIX ) )memcpy( pDataObj-Unlock(); pDataFrame-matOriginal = pDataFrame-TransformationMatrix;这样大家应该对其他类型的模板数据分析代码都应该大致猜的出来了。具体的代码我就不在这里提供,只是简单的介绍一下它们的作用和关系,大家可以参考最后附上的工程。Frame - 骨骼。正如大家已经看到的那样,我们可以用 pFrameSib
20、ling 和pFrameFirstChild 两个字段来构成骨骼的层次结构。骨骼模板包含了当前骨骼相对父骨骼的坐标变换矩阵和骨骼对应的模型Mesh - 模型。角色的顶点数据,包含 vertex buffer, index buffer 等。我们可以直接用普通的 ID3DXMesh 来加载其中的数据。除此之外,Mesh 中还包含了 SkinWeight 模板。SkinWeight -骨骼关联的顶点已经该骨骼的坐标变换对该顶点的权重。实际中我们并不需要特殊处理这类模板数据,ID3DXMesh 已经包含了对应的代码。AnimationSet -动画集合。例如角色的各种动作“Kill”,“Jump”等
21、等,包含多个 Animation。Animation -动画。由对应骨骼的名称和一组 AnimationKey 组成。AnimationKey -动画键。包含一组时间戳以及在对应时间戳应用到骨骼上的平移、缩放、旋转向量或者复合的坐标变换矩阵。以上就是我们需要了解的全部了。至此,所有原料都已经准备齐全,各位大厨们下一步要做的就是骨骼动画这道小菜啦!我们的目标是根据骨骼动画来更新模型。看看手上的材料, 1)骨骼动画数据。上一节中我们已经读出了 AnimationSet、Animation 和AnimationKey 这些动画数据,我们现在要做的就是把它们应用到骨骼上面去。AnimationSet
22、只是标明了我们要播放的动画名称,关键的处理在 Animation 和AnimationKey 上面。Animation 包含了所对应的骨骼名称,下属的 AnimationKey 包含了坐标变换的类型以及对应的时间戳,我们也把 AnimationKey 看做一个关键帧。下面要做的就是根据当前时间判断动画落在哪两个关键帧中间,例如 key1 和 key2,然后求出插值系数 scaler, scaler = (当前时间-key1.时间)/(key2.时间-key1.时间)求出插值系数后,骨骼的当前位置就可以用下面的方法求出,注意各种 key 类型求插值的方法不一样,switch( Key 的类型 )
23、case 旋转:./ 四元数插值D3DXQUATERNION RotationQuaternion;D3DXQuaternionSlerp( / 应用旋转矩阵D3DXMATRIX RotationMatrix;D3DXMatrixRotationQuaternion( pAnimation-pBone-TransformationMatrix *= RotationMatrix;break;case 平移和缩放 :./ 矢量插值D3DXVECTOR3 InterpolatedVector = pAnimationKey-pVectorKeysKey1.value + Scaler * ( pA
24、nimationKey-pVectorKeysKey2.value - pAnimationKey-pVectorKeysKey1.value );if( pAnimationKey-Type = XAnimationKey:KeyType:Scaling )/ 应用缩放矩阵D3DXMATRIX ScalingMatrix;D3DXMatrixScaling( pAnimation-pBone-TransformationMatrix *= ScalingMatrix;else/ 应用平移矩阵D3DXMATRIX TranslationMatrix;D3DXMatrixTranslation(
25、 pAnimation-pBone-TransformationMatrix *= TranslationMatrix;break;case 坐标变换矩阵 :./ 矩阵插值D3DXMATRIX TransformMatrix = pAnimationKey-pMatrixKeysKey1.value + Scaler * ( pAnimationKey-pMatrixKeysKey2.value - pAnimationKey-pMatrixKeysKey1.value );/ 应用坐标变换矩阵pAnimation-pBone-TransformationMatrix *= Transform
26、Matrix;break; / switch这样我们就把根据当前时间计算出来的插值坐标变换矩阵应用到骨骼上了。2)骨骼数据。在从文件中读出来的时候,我们已经利用 pFrameSibling 和pFrameFirstChild 两个字段构造了一个层次结构。注意骨骼中的TransformationMatrix 包含的是当前骨骼相对于父骨骼的坐标变换,应用到 mesh 上的时候,我们需要的相对于根骨骼的坐标变换。因此我们要做一下处理,简单的一个递归调用,/-/ 名称 : UpdateHierarchy/ 描述 : 计算本节点及所有兄弟、子节点相对于根节点的偏移矩阵/-void UpdateHiera
27、rchy( D3DXMATRIX *matTrans = NULL )D3DXMATRIX matIdentity;/ 根节点的偏移矩阵为单位矩阵if( NULL = matTrans )D3DXMatrixIdentity( matTrans = / 计算偏移矩阵matCombined = TransformationMatrix * ( *matTrans );/ 更新兄弟节点if( pFrameSibling ) ( ( D3DXFRAME_EX* )pFrameSibling )-UpdateHierarchy( matTrans ); / 更新子节点if( pFrameFirstCh
28、ild )( ( D3DXFRAME_EX* )pFrameFirstChild )-UpdateHierarchy( 通过在根骨骼上的一次调用,我们就可以在自定义的 matCombined 字段中得到各个骨骼相对于根骨骼的坐标变换矩阵.3)mesh 数据。mesh 数据相对简单,ID3DXMesh 和 ID3DXSkinInfo 接口为我们了做大部分的工作。不过天底下没有免费的午餐,为了让它们运转起来,我们还是要做一些额外的努力。在步骤 1 里面我们已经通过插值得到了骨骼当前的坐标变换矩阵,不过这个坐标变换是相对于模型本地坐标的,为了应用到 mesh 上,我们需要将坐标对齐到 mesh 的中
29、心,/ 首先 bone 转换到以 mesh 中心为坐标原点的坐标系,然后再应用 frame 的坐标变换矩阵for( DWORD i=0; ipSkinInfo-GetNumBones(); i+ )m_pRootMeshContainer-pBoneMatricesi = *( m_pRootMeshContainer-pSkinInfo-GetBoneOffsetMatrix( i ) ); if( m_pRootMeshContainer-ppFrameMatricesi )m_pRootMeshContainer-pBoneMatricesi *= *m_pRootMeshContain
30、er-ppFrameMatricesi; 这样所有的数据都准备好了,用 ID3DXSkinInfo 的方法来更新骨骼关联的每个顶点的坐标,(每个顶点根据关联的所有骨骼的坐标变换矩阵乘以对应的权重再相加来得到最终应用到顶点上的坐标变换矩阵)m_pRootMeshContainer-pSkinInfo-UpdateSkinnedMesh(m_pRootMeshContainer-pBoneMatrices, NULL, pSrcVertex, pDestVertex );到此一切准备都结束了! 绘制 Mesh 的动作和平常一样,设置材质和纹理,然后调用 DrawSubSet 方法,这个想来对大家是没有什么难度的事情。怎么样,是不是想从头再看一遍回味一下呢? 呵呵。(附件里面是测试工程,编译环境为 VS.NET2003, DXSDK(October 2004)。大家有什么不明白或者我哪里说错的,欢迎给我写信或者留言。)