【ZT】D3D中的骨骼动画
相信这里没有人没玩过采用骨骼动画技术的游戏,看看那些热门的动作游戏,例如《波斯王子》、《分裂细胞》和《战神》,你就知道骨骼动画的威力了(我承认是猜的)。骨骼动画技术用来使我们的3D模型在屏幕上动起来,通过和动作捕捉技术结合,可以让模型做出非常逼真的动作。而这样一个极具威力的技术,其原理却相当简单。
假设我们要让游戏主角做出一个动作,例如波斯王子拿弯刀往前一劈。最简单的方法,就是让模型师建一个动画序列,然后在程序中逐帧播放,就像放电影一样。不过这样一来工作量就太大了,玩家也需要N个G的硬盘来安装这个游戏。与此不同,骨骼动画技术采用了一种很聪明的方式。首先,建模师完成一个标准姿势的3D模型,通常是双手沿着肩的方向伸展平放,双脚打开。所有的后继动作将由这个基础动作演变得到。在完成这个基准模型之后,建模师再建一个骨骼结构,一系列相互关联的顶点,就像一个骨架一样,与人体模型各个关节匹配并且都会有一定数量的顶点与之关联。想象一下人和人身上的骨骼就很容易知道我在说什么。之后,在我们想要完成的动画序列中,挑选一些关键帧,对每个关键帧,将骨骼的位置与关键帧匹配。然后把这一系列的关键帧骨骼保存起来,除了骨骼的位置,同时保存的还有从基准位置变换到当前关键帧的旋转、平移、缩放或者一个混合的坐标变换矩阵。在我们引擎中,首先根据当前时间查找这时候角色是处于哪两个关键帧中间。找到之后以时间为参数在关键帧的坐标变换矩阵之间求插值,用插值结果来决定骨骼当前的位置。骨骼位置求出来后,所有和骨骼关联的顶点的坐标也可以相应求出来了。通过使用骨骼动画技术,我们用相对较少的数据就可以播放很平滑的动画!
了解了相关原理,来看看如何在directx中播放骨骼动画。我的参考书是《Advanced Animation with DirectX》。现在我们知道为了播放骨骼动画,需要有骨骼 (bone)的数据,模型(mesh)的数据,关联骨骼和模型上每个顶点的关联数据,以及关键帧的坐标变换数据。所有这些数据必须以某种形式存在于某个地方供我们获取才行。这里要介绍的MS的x文件格式以及从中获取数据的方法。强烈建议大家都来学习一下x文件格式!你会发现它即简单又强大,即使用来存放自定义数据也是相当的方便,一旦掌握之后我保证你会对它爱不释手。
典型的x文件以数据模板和实际数据两部分组成。数据模板类似c++中的结构定义,不过更为灵活和开放。实际数据就是遵守模板定义的数据段。
看一个例子:
< 3D82AB43-62DA-11cf-AB39-0020AF71E433 > // 每个模板关联唯一的GUID
STRING Name; // 姓名
DWORD Sex; // 性别
[ContactEntry] // 联系方式, 另一个模板,模板可以嵌套
}
template ContactEntry {
< 4C9D055B-C64D-4bfe-A7D9-981F507E45FF > // GUID
STRING PhoneNumber; // 电话号码
STRING Address; // 地址
}
Employee David{
"David";
1;
ContactEntry{
"100-100000000";
"far far away";
}
}
从上面这个简单的例子我们就可以看出x文件的大概模样了,详细的情况大家可以参考《Advanced Animation with DirectX》。下面我们看如何来读取这样一个x文件,借助下几个对象:
ID3DXFile -- x文件格式文档对象。例如Employee.x这样一个文件。
ID3DXFileEnumObject -- 用来枚举x文档的顶级模板数据。所谓顶级模板数据是指那些没有父模板的数据,例如上面的David数据段。
ID3DXFILEDATA -- 模板数据。上面的David和他的联系方式都是ID3DXFILEDATA对象,自包含。
下面看实际的分析函数, 下面的代码适用于DirectX 9.0 SDK Update (October 2004),原书的代码有点过时了...
// 名称 : Parse
// 描述 : 分析x文件格式文档
// -----------------------------------------------------------------------------
bool Parse( char * filename, void ** pData )
{
LPD3DXFILE lpD3DXFile;
LPD3DXFILEENUMOBJECT lpD3DXFileEnumObj;
LPD3DXFILEDATA lpD3DXFileData;
// 参数检查
if ( NULL == filename )
return false ;
// 创建X文件对象
HRESULT hr = D3DXFileCreate( & lpD3DXFile );
if ( FAILED( hr ) )
return false ;
// 注册标准模板
hr = lpD3DXFile -> RegisterTemplates(
( LPVOID )D3DRM_XTEMPLATES, D3DRM_XTEMPLATE_BYTES );
if ( FAILED( hr ) )
{
Release < LPD3DXFILE > ( lpD3DXFile );
return false ;
}
// 创建X文件枚举对象
hr = lpD3DXFile -> CreateEnumObject(
filename, D3DXF_FILELOAD_FROMFILE, & lpD3DXFileEnumObj );
if ( FAILED( hr ) )
{
Release < LPD3DXFILE > ( lpD3DXFile );
return false ;
}
// 解析开始
bool parseResult = BeginParse( pData );
if ( true == parseResult )
{
// 查询顶级模板数
SIZE_T childCount = 0 ;
lpD3DXFileEnumObj -> GetChildren( & childCount );
// 分析每个订级模板
for ( DWORD i = 0 ; i < childCount; i ++ )
{
// 获取当前模板
hr = lpD3DXFileEnumObj -> GetChild( i, & lpD3DXFileData );
if ( FAILED( hr ) )
break ;
// 分析
parseResult = ParseObject( lpD3DXFileData, NULL, 0 , pData );
// 释放FileData对象
Release < LPD3DXFILEDATA > ( lpD3DXFileData );
// 出现错误,中断分析
if ( false == parseResult )
break ;
}
// 解析结束
if ( parseResult )
parseResult = EndParse( pData );
}
// 释放相关对象
Release < LPD3DXFILEENUMOBJECT > ( lpD3DXFileEnumObj );
Release < LPD3DXFILE > ( lpD3DXFile );
// 解析结束
return parseResult;
}
// -----------------------------------------------------------------------------
// 名称 : ParseObject
// 描述 : 递归解析顶级模板
// -----------------------------------------------------------------------------
bool ParseObject(
LPD3DXFILEDATA pDataObj,
LPD3DXFILEDATA pParentDataObj,
DWORD depth,
void ** pData )
{
LPD3DXFILEDATA pSubDataObj;
bool parseResult = true ;
HRESULT hr;
// 获取子模板数目
DWORD childCount;
pDataObj -> GetChildren( & childCount );
// 遍历模板并分析
for ( DWORD i = 0 ; i < childCount; i ++ )
{
// 取子模板对象
hr = pDataObj -> GetChild( i, & pSubDataObj );
if ( FAILED( hr ) )
break ;
// 分析子模板
parseResult = ParseObject( pSubDataObj, pDataObj, depth + 1 , pData );
// 释放数据对象
Release < LPD3DXFILEDATA > ( pSubDataObj );
// 出现错误,停止分析
if ( false == parseResult )
break ;
}
return parseResult;
}
就那么简单,相信大家都看得明白。通过重载ParseObject方法,我们以判断当前分析的模板类型,然后创建实际的模板对象,从文档中复制数据。有了上面的工具,我们就可以自己来读取和解析x格式的骨骼动画文件了。
下面我们就来看看如何重载 ParseObject方法来获得我们感兴趣的数据,不要担心,绝对简单。仔细看代码,你会发现只需要做一件事情,判断当前数据段的类型(通过GUID),分配对应的结构对象,然后从数据段拷贝数据(所有SDK自定义模板的GUID都在头文件rmxfguid.h中定义, 你需要把它加入你的工程中。所有预定义模板在 这里可以找到)。先来看看如何获取当前数据段的GUID,
GUID objGUID;
pDataObj->GetType( &objGUID );
简单吧,下面开始我们的分析之旅。x动画文件中骨骼是用Frame模板定义的,
{
< 3D82AB46-62DA-11cf-AB39-0020AF71E433 >
FrameTransformMatrix frameTransformMatrix; // 骨骼相对于父节点的坐标变换矩阵
Mesh mesh; // 骨骼的Mesh
}
只有两个字段。FrameTransformMatrix就是一个matrix。Mesh稍微复杂,详细格式大家自己参考MSDN,我们也会有专门的代码来加载Mesh,现在关注Frame。为了加载Frame,我们要在程序中定义一个和Frame模板对应的数据结构,SDK中经默认提供了一个,那就是 D3DXFRAME,
{
LPSTR Name; // 骨骼名称
D3DXMATRIX TransformationMatrix; // 相对与父节点的坐标变换矩阵
LPD3DXMESHCONTAINER pMeshContainer; // LPD3DXMESHCONTAINER对象,用来
// 加载MESH,还有一些附加属性,见SDK
struct _D3DXFRAME * pFrameSibling; // 兄弟节点指针,和下面的子节点指针
// 一块作用构成骨骼的层次结构。
struct _D3DXFRAME * pFrameFirstChild; // 子节点指针.
} D3DXFRAME, * LPD3DXFRAME;
这样一个结构已经足够容纳Frame模板中的数据并形成一个层次结构,不过为了我们程序的需要,我们还需要其他字段,为此我们通常会扩展D3DXFRAME,
{
D3DXMATRIX matCombined; // 存储当前节点相对于根节点的位置偏移矩阵,沿着到
// 到根骨骼的路径把所有的坐标变换矩阵相乘得到。
D3DXMATRIX matOriginal; // 在播放动画的时候有可能会改变原来结构中的
// TransformationMatrix,因此我们声名一个新的字段
// 将原来的坐标变换矩阵保存起来以便在需要的时候恢
// 复回去。
... // 忽略一些方法定义
}
我知道有些人已经按捺不住了,那么动手吧,
if ( objGUID == TID_D3DRMFrame )
{
// 引用对象直接返回,不需要做分析。一个数据段实际定义一次后可以
// 被其他模板引用,例如后面的Animation动画模板就会引用这里的Frame
// 节点,标识动画关联的骨骼。
if ( pDataObj -> IsReference() )
return true ;
// 创建D3DXFRAME_EX结构,准备拷贝数据
D3DXFRAME_EX * pFrame = new D3DXFRAME_EX();
// 拷贝名称
pFrame -> Name = GetObjectName( pDataObj );
// 注意观察文件就可以发现一个Frame要么是根Frame,父节点不存在,
// 要么作为某个Frame的下级Frame而存在。
if ( NULL == pData )
{
// 作为根节点的兄弟节点加入链表。
pFrame -> pFrameSibling = m_pRootFrame;
m_pRootFrame = pFrame;
pFrame = NULL;
// 将自定义数据指针指向自己,供子
// 节点引用。
pData = ( void ** ) & m_pRootFrame;
}
else
{
// 作为传入节点的子节点
D3DXFRAME_EX * pDataFrame = ( D3DXFRAME_EX * )( * pData );
pFrame -> pFrameSibling = pDataFrame -> pFrameFirstChild;
pDataFrame -> pFrameFirstChild = pFrame;
pFrame = NULL;
pData = ( void ** ) & pDataFrame -> pFrameFirstChild;
}
}
结束了!是不是很简单,呵呵,记住我们只需要做一件事情,判断类型,分配匹配的对象然后拷贝数据,下面来分析Frame中的 matrix,
else if ( objGUID == TID_D3DRMFrameTransformMatrix && pData )
{
// 我们可以肯定pData指向某个Frame
D3DXFRAME_EX * pDataFrame = ( D3DXFRAME_EX * )( * pData );
// 先取得缓冲区大小,应该是个标准的4x4矩阵
DWORD size = 0 ;
LPCVOID buffer = NULL;
hr = pDataObj -> Lock( & size, & buffer );
if ( FAILED( hr ) )
return false ;
// 拷贝数据
if ( size == sizeof ( D3DXMATRIX ) )
{
memcpy( & pDataFrame -> TransformationMatrix, buffer, size );
pDataObj -> Unlock();
pDataFrame -> matOriginal = pDataFrame -> TransformationMatrix;
}
}
这样大家应该对其他类型的模板数据分析代码都应该大致猜的出来了。具体的代码我就不在这里提供,只是简单的介绍一下它们的作用和关系,大家可以参考最后附上的工程。
Frame --
骨骼。正如大家已经看到的那样,我们可以用pFrameSibling和pFrameFirstChild两个字段来构成骨骼的层次结构。骨骼模板包含了当前骨骼相对父骨骼的坐标变换矩阵和骨骼对应的模型
Mesh --
模型。角色的顶点数据,包含vertex buffer, index buffer等。我们可以直接用普通的ID3DXMesh来加载其中的数据。除此之外,Mesh中还包含了SkinWeight模板。
SkinWeight --
骨骼关联的顶点已经该骨骼的坐标变换对该顶点的权重。实际中我们并不需要特殊处理这类模板数据,ID3DXMesh已经包含了对应的代码。
AnimationSet --
动画集合。例如角色的各种动作“Kill”,“Jump”等等,包含多个Animation。
Animation --
动画。由对应骨骼的名称和一组AnimationKey组成。
AnimationKey --
动画键。包含一组时间戳以及在对应时间戳应用到骨骼上的平移、缩放、旋转向量或者复合的坐标变换矩阵。
以上就是我们需要了解的全部了。至此,所有原料都已经准备齐全,各位大厨们下一步要做的就是骨骼动画这道小菜啦!