接下来,我们介绍一些骨骼动画。我们之前大致讲过骨骼动画,存储骨骼动画的网格文件要比普通的文件复杂一下。主要是增加了骨骼信息,蒙皮信息以及动画帧信息。骨骼动画的实现原理是仿照人体运动学,将3D模型由一种称作“蒙皮(skin mesh)”的网格模型和按照一定层次组织起来的“骨骼(bone)”组成。
骨骼层次(骨架)仿照关节动画的组织结构将3D模型组织成一个层次结构,而相邻骨骼之间通过做相对运动来实现特定的动作效果,从而就实现了不同的模型动画效果。
蒙皮(皮肤网格)与骨骼相关联,用于提供绘制动画所有需要的几何模型,还有纹理和材质等一些信息。组成皮肤网格的每个顶点都会受到一个或者多个骨骼的影响,而每个顶点受到多个骨骼影响的程度通过权值(weight)确定。通过计算每个顶点受到不同骨骼对他们影响的加权和,就可以得到这个顶点在运动过程中所处的实际位置。
骨骼蒙皮动画通过“关键帧”记录每个关键时间点的骨骼的位置、朝向等等一些信息。一个完整的动作包含了一系列的“关键帧”,我们称之为一个动画集(Animation Set)。每个动画集中相邻的两个关键帧之间进行插值运算,就可以确定某一时刻各骨骼所处的新位置和新朝向等一些额外信息。每个模型都有多个不同的动画集组成。
X文件有文本和二进制两种存储形式。当我们使用文本存储X文件的时候,我们就可以打开文件查看里面的明文数据。X文件定义了很多关键词来标示不同的数据,例如:
Mesh 定义一个网格,里面包含顶点数量和顶点坐标,三角面数量和构成三角面的顶点索引。MeshMaterialList 定义材质列表,包括纹理坐标的个数和纹理坐标。
MeshNormals 定义Mesh的法向量。普通的静态网格对象基本就包含这些信息了。
Frame 用来定义骨骼,可以包含多个Mesh对象,也可以包含子骨骼。
SkinWeights 定义骨骼对顶点的影响权重。
Animation用来保存动画信息,蒙皮网格包含的信息相对来说就复杂一些。
如下图就是一个X文件的一部分:
X文件可以存储正常的普通的静态mesh,也同样支持skin mesh。蒙皮网格和普通网格的唯一不同点就是看XSkinMeshHeader和SkinWeights模版是否存在。以上是一个独立的Frame(骨骼),它没有层次结构。但是大多数Frame都是有层次结构,各种父子,兄弟关系。网格(Mesh)中包含顶点,索引,法向量,纹理坐标,材质链表,SkinMeshHeader和SkinWeights。其中SkinWeights里面包含顶点权重信息以及骨骼偏移矩阵。DirectX3D使用D3DXFRAME来存储骨骼数据,使用D3DXMESHCONTAINER来存储蒙皮网格数据,使用Animation Set记录动画集。AnimationSet 包括一个或者多个Animation。Animation 描述一个动画,包含一个或几个AnimationKey。AnimationKey 动画关键帧,定义具体的动作数据,本质就是骨骼在该时间点的变换矩阵。骨骼本质就是变换矩阵。
在3D游戏开发中,骨骼动画基本上都有频繁的使用。骨骼动画的难点在于,不仅在制作过程中比较耗时耗力,在游戏开发中对骨骼动画的数据调整也非常困难。我们只所有要了解骨骼动画的存储细节,主要目的就是深入掌握骨骼动画是如何实现的。骨骼动画的播放,基本上就是按照时间来获取骨骼的变换矩阵,然后根据变换矩阵来渲染模型网格。这样,就会形成模型动画效果了。当然,这个过程描述起来很简单,但是使用DriectX实现起来就比较繁琐。坦白讲,DirectX对于骨骼动画的支持,感觉还是不够啊。DirectX中使用D3DXLoadMeshHierarchyFromX来加载一个骨骼动画模型文件。该方法中需要一个ID3DXAllocateHierarchy类型的参数,该类主要用来创建骨骼和蒙皮网格。骨骼动画播放就是根据时间来调整骨骼变换矩阵,最后绘制蒙皮网格。这里,我们只是简单的来做一个骨骼动画的案例,使用VS2019新建一个项目“D3D_08_Bone”,首先我们创建上面的ID3DXAllocateHierarchy类,它是一个接口,需要继承并实现里面的函数。为此我们创建“CAllocateHierarchy.h”和“CAllocateHierarchy.cpp”两个文件,文件的代码基本上来源于DirectX安装目录的SkinnedMesh案例中,当然需要做一些调整。这两个代码由于代码庞大且晦涩难懂,就不发出来了。大家可以下载后,依据注释去了解一下。接下来,我们需要创建“SkinMesh.h”和“SkinMesh.cpp”两个文件,这两个文件主要用来绘制播放骨骼动画。首先是“SkinMesh.h”,代码如下:
#pragma once
#include "CAllocateHierarchy.h"
// 骨骼动画网格对象
class SkinMesh {
public:
// 自定义CAllocateHierarchy类
CAllocateHierarchy* hierarchy = NULL;
// 网格根骨骼
LPD3DXFRAME root = NULL;
// 动画控制器
LPD3DXANIMATIONCONTROLLER controller = NULL;
// 动画开始时间
float startTime = 0;
// 动画集ID,不同的动画ID是不一样的
int current = 0;
// 是否播放动画
bool isPlay = false;
// Direct3D设备指针对象
LPDIRECT3DDEVICE9 D3DDevice = NULL;
public:
// 构造方法
SkinMesh() {};
SkinMesh(LPDIRECT3DDEVICE9 device, const wchar_t* dir, const wchar_t* file);
// 渲染
void render(D3DXMATRIX matrix);
// 析构方法
~SkinMesh();
// 绘制某一个骨骼下的蒙皮网格
void DrawMeshContainer(IDirect3DDevice9* pd3dDevice, LPD3DXMESHCONTAINER pMeshContainerBase, LPD3DXFRAME pFrameBase);
// 按照骨骼层次绘制蒙皮网格,调用上面的 DrawMeshContainer 函数
void DrawFrame(IDirect3DDevice9* pd3dDevice, LPD3DXFRAME pFrame);
// 更新骨骼层次中的组合变换矩阵,
void UpdateFrameMatrices(LPD3DXFRAME pFrameBase, LPD3DXMATRIX pParentMatrix);
// 记录骨骼的组合变换矩阵,初始化工作而已
HRESULT SetupBoneMatrixPointersOnMesh(LPD3DXMESHCONTAINER pMeshContainerBase, LPD3DXFRAME g_pFrameRoot);
// 根据骨骼层次来来保存骨骼的组合变换矩阵,调用上面的 SetupBoneMatrixPointersOnMesh 函数,初始化工作而已
HRESULT SetupBoneMatrixPointers(LPD3DXFRAME pFrame, LPD3DXFRAME g_pFrameRoot);
};
所有的动画数据都已经加载进来,动画的播放需要一个LPD3DXANIMATIONCONTROLLER控制器,它的作用根据时间来设置动画关键帧。这个关键帧里面保存了该时间点的变换矩阵,紧接着我们就调用UpdateFrameMatrices函数来更新整个骨骼层次的最终变换矩阵,然后调用DrawFrame函数,来根据骨骼层次逐一绘制模型的蒙皮网格。这样,我们不停的设置动画时间点,不停的绘制蒙皮网格,就形成动画了。由于“SkinMesh.cpp”文件代码庞大且晦涩难度,我们就不发出来了。大家可以直接下载,按照注释去了解一下。这里我们只介绍构造函数和render渲染方法内容:
// 构造方法
SkinMesh::SkinMesh(LPDIRECT3DDEVICE9 device, const wchar_t* dir, const wchar_t* file) {
// Direct3D设备指针对象
D3DDevice = device;
// 拼接文件路径
wchar_t xfile[100] = { 0 };
wcscat_s(xfile, 100, dir);
wcscat_s(xfile, 100, file);
// 创建骨骼动画
hierarchy = new CAllocateHierarchy(device, convert(dir));
D3DXLoadMeshHierarchyFromX(xfile, D3DXMESH_MANAGED, D3DDevice, hierarchy, NULL, &root, &controller);
// 使用一个数组记录所有骨骼的组合变换矩阵,这个数组在网格容器对象中
SetupBoneMatrixPointers(root, root);
// 动画开始时间
startTime = 0;
// 动画集ID,默认第一个动画
int current = 0;
// 是否播放动画
bool isPlay = false;
// 使用索引实例化第一个动画集,并将动画集应用于指定轨迹(1.0),接下来才能播放该动画集
LPD3DXANIMATIONSET pAnimationSet = NULL;
controller->GetAnimationSet(current, &pAnimationSet);
controller->SetTrackAnimationSet((UINT)1.0, pAnimationSet);
};
在构造方法中,我们加载骨骼动画文件,同时初始化骨骼的组合变换矩阵。然后就是实例化动画控制器。我们上文介绍过,一个骨骼动画可以包含多个动画集(例如行走动画,攻击动画,死亡动画等等)。每个动画集都可以使用索引或名称表示。本案例中,我们使用索引0来表示模型的第一个动画(行走动画)。播放不同的动画,就设置不同的索引即可。接下来就是render方法,如下:
// 渲染
void SkinMesh::render(D3DXMATRIX matrix) {
// 动画时间差
float fTimeDelta = 0;
// 计算动画时间差
if (isPlay) {
fTimeDelta = (float)timeGetTime();
fTimeDelta = (fTimeDelta - startTime) * 0.001f;
startTime = (float)timeGetTime();
}
// 设置骨骼动画的时间差
controller->AdvanceTime(fTimeDelta, NULL);
// 更新骨骼层次中的组合变换矩阵
// matWorld是一个世界矩阵,所有骨骼矩阵变换的起点就是它
UpdateFrameMatrices(root, &matrix);
// 绘制骨骼动画
DrawFrame(D3DDevice, root);
};
这个渲染的过程,就是之前讲述过的。先设置时间差,然后更新变换矩阵,最后根据骨骼层次来渲染蒙皮网格。设置时间差,其实就是获取骨骼“关键帧”的变换矩阵,然后根据这个变换矩阵更新整个骨架的骨骼变换矩阵,最后在绘制蒙皮网格。时间差不断的递增调整,变换矩阵不断改变,模型也就随之变换运动,形成动画。两个类都封装完毕后,我们就开始“main.cpp”文件的内容,首先是全局变量的声明:
// Direct3D设备指针对象
LPDIRECT3DDEVICE9 D3DDevice = NULL;
// 鼠标位置
int mx = 0, my = 0;
// 骨骼动画网格对象
SkinMesh* mesh = NULL;
紧接着,就是initScene函数,代码如下:
// 实例化骨骼动画网格对象
mesh = new SkinMesh(D3DDevice, L"tiny/", L"tiny.x");
//mesh = new SkinMesh(D3DDevice, L"asset/", L"jianling.X");
// 开始播放动画
mesh->current = 0;
mesh->isPlay = true;
mesh->startTime = (float)timeGetTime();
// 线性纹理
D3DDevice->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
D3DDevice->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
// 初始化投影变换
initProjection();
// 初始化光照
initLight();
在这里,我们提供了两个骨骼动画模型文件,一个是来源于DirectX的案例(tiny.x),一个来源于网络下载(jianling.X)。由于模型比较大,因此我们的摄像机位置也调整了,
// 设置取景变换矩阵
D3DXMATRIX viewMatrix;
D3DXVECTOR3 viewEye(300.0f, 100.0f, -700.0f);
D3DXVECTOR3 viewLookAt(0.0f, 0.0f, 0.0f);
D3DXVECTOR3 viewUp(0.0f, 1.0f, 0.0f);
D3DXMatrixLookAtLH(&viewMatrix, &viewEye, &viewLookAt, &viewUp);
D3DDevice->SetTransform(D3DTS_VIEW, &viewMatrix);
由于很多模型都不支持全局环境光,因此,本案例我们新增加一个平行光,代码如下:
// 设置一下环境光
D3DDevice->SetRenderState(D3DRS_AMBIENT, D3DCOLOR_XRGB(255, 255, 255));
// 设置一个平行光照
D3DLIGHT9 light;
::ZeroMemory(&light, sizeof(light));
light.Type = D3DLIGHT_DIRECTIONAL;
light.Ambient = D3DXCOLOR(1.0f, 1.0f, 1.0f, 1.0f);
light.Diffuse = D3DXCOLOR(1.0f, 1.0f, 1.0f, 1.0f);
light.Specular = D3DXCOLOR(1.0f, 1.0f, 1.0f, 1.0f);
light.Direction = D3DXVECTOR3(0.0f, -1.0f, 0.0f);
D3DDevice->SetLight(0, &light);
D3DDevice->LightEnable(0, true);
// 开启光照
D3DDevice->SetRenderState(D3DRS_LIGHTING, true);
// 设置默认材质
D3DMATERIAL9 defaultMaterial;
::ZeroMemory(&defaultMaterial, sizeof(defaultMaterial));
defaultMaterial.Ambient = D3DXCOLOR(1.0f, 1.0f, 1.0f, 0.0f); // 100%反射环境光
defaultMaterial.Diffuse = D3DXCOLOR(1.0f, 1.0f, 1.0f, 0.0f); // 100%反射漫反射光
defaultMaterial.Specular = D3DXCOLOR(0.0f, 0.0f, 0.0f, 0.0f); // 不反射高光
defaultMaterial.Emissive = D3DXCOLOR(0.0f, 0.0f, 0.0f, 0.0f); // 不自发光
defaultMaterial.Power = 0.0f; // 没有高光区
D3DDevice->SetMaterial(&defaultMaterial);
最后是我们的renderScene函数,代码如下:
// 世界变换矩阵
D3DXMATRIX worldMatrix, scalingMatrix;
D3DXMatrixTranslation(&worldMatrix, 0.0f, 0.0f, 0.0f);
//D3DXMatrixScaling(&scalingMatrix, 5.0f, 5.0f, 5.0f);
//D3DXMatrixMultiply(&worldMatrix, &worldMatrix, &scalingMatrix);
mesh->render(worldMatrix);
运行代码,就会看到模型行走动画了。
控制动画播放的代码,我们放到了initScene函数中,也就是如下:
// 开始播放动画
mesh->current = 0;
mesh->isPlay = true;
mesh->startTime = (float)timeGetTime();
在上面的代码中,起作用的就是时间的设置。最后,我们添加一个GUI按钮,来手动控制动画的播放和暂停。为此,我们创建“GuiClass.h”和“GuiClass.cpp”两个文件,由于之前我们就对此进行过封装,这里不在介绍这个两个文件,如果有细微的区别的话,以GuiClass为准吧。有了GUI的加入,我们还是先声明两个全局变量:
// 两个GUI按钮
GuiClass* start, * stop;
然后就是initScene函数的初始化工作:
// 开始播放动画
//mesh->current = 0;
//mesh->isPlay = true;
//mesh->startTime = (float)timeGetTime();
// 开始和停止按钮
start = new GuiClass(D3DDevice, 300.0f, 430.0f, 200.0f, 50.0f, L"开始播放", L"btn.jpg");
start->isShow = true;
stop = new GuiClass(D3DDevice, 300.0f, 500.0f, 200.0f, 50.0f, L"停止播放", L"btn.jpg");
stop->isShow = true;
接着就是renderScene函数绘制部分:
// GUI按钮渲染
start->render();
stop->render();
最后就是我们的update函数,
// 只接收鼠标点击事件
if (type != 3) return;
// 是否单击"开始播放"按钮
if (mx > start->posX && mx < start->posX + start->width && my > start->posY && my < start->posY + start->height) {
mesh->current = 0;
mesh->isPlay = true;
mesh->startTime = (float)timeGetTime();
}
// 是否点击"停止播放"按钮
if (mx > stop->posX && mx < stop->posX + stop->width && my > stop->posY && my < stop->posY + stop->height) {
mesh->isPlay = false;
mesh->startTime = 0;
}
运行效果如下:
本课程的所有代码案例下载地址:
备注:这是我们游戏开发系列教程的第二个课程,这个课程主要使用C++语言和DirectX来讲解游戏开发中的一些基础理论知识。学习目标主要依理解理论知识为主,附带的C++代码能够看懂且运行成功即可,不要求我们使用DirectX来开发游戏。课程中如果有一些错误的地方,请大家留言指正,感激不尽!