我们在第一章中讲过,一个3D场景中的模型是如何被显示到屏幕上的。这个流程就称之为渲染流水线。这个流程在OpenGL中也是大致如此的。Direct3D的渲染流水线过程如下:
在DirectX 9 中,有两套实现图形渲染的方案,一套为固定功能渲染流水线,另一套为可编程渲染流水线。在DirectX 10 以及以后版本中,固定功能渲染流水线已经被移除了。固定功能渲染流水线基本上都是按以下步骤进行的: 填充顶点数据,指定纹理坐标,设置材质,设置光照, 设置变换矩阵, 设置渲染状态,最终绘制图形。因为渲染的思路大致相同,且固定功能渲染流水线比较简单理解,因此是学习渲染流水线的最佳入门途径。
可编程渲染流水线与固定功能流水线有着很大的不同,通常我们说的自己写shader 就是指的这套渲染体系,这是目前3D 编程的大趋势。可编程渲染流水线中的核心,是着色器( shader )。在Direct3D9中,我们主要关注的是顶点着色器(vertex shader )和像素着色器( pixel shader)。也就是说,我们为着色器编写一些规模较小的代码,然后在GPU 上进行编译和运行,这就是所谓的可编程渲染流水线的精髓。当然我们需要学习着色器编程语言——HLSL ( High Level Shader Language,高级着色器语言)。
接下来,我们使用VS2019创建一个项目“D3D_04_Cube”,来实现固定功能流水线。在这个项目中,我们会使用顶点缓冲区和索引缓冲区来绘制一个立方体,这个过程其实不太重要。因为我们的3D模型基本都是由建模软件来制作的,然后导出DirectX的专用X格式文件。DirectX API中有专门的函数用于加载X文件。我们不需要使用顶点的方式来实现复杂的3D模型。但是我们要理解,3D模型的本质就是顶点,有些时候我们需要解析模型的顶点数据。本案例主要是如何将这个顶点立方体从世界坐标系显示到窗体坐标系上面,这也就是固定功能流水线要实现的功能。我们创建“main.cpp”文件,先引入头文件,代码如下:
// 引入头文件
#include <windows.h>
#include <d3d9.h>
#include <d3dx9.h>
// 引入依赖的库文件
#pragma comment(lib,"d3d9.lib")
#pragma comment(lib,"d3dx9.lib")
#define WINDOW_LEFT 200 // 窗口位置
#define WINDOW_TOP 100 // 窗口位置
#define WINDOW_WIDTH 800 // 窗口宽度
#define WINDOW_HEIGHT 600 // 窗口高度
#define WINDOW_TITLE L"D3D游戏开发" // 窗口标题
#define CLASS_NAME L"D3D游戏开发" // 窗口类名
// Direct3D设备指针对象
LPDIRECT3DDEVICE9 D3DDevice = NULL;
// 鼠标位置
int mx = 0, my = 0;
紧接着,我们要定义顶点的结构和格式,并声明顶点缓存对象和索引对象,代码如下:
// 定义FVF灵活顶点格式结构体
struct D3D_DATA_VERTEX { FLOAT x, y, z; DWORD color; };
// 世界坐标,窗体中央为坐标系原点,需要投影(正交/透视)
#define D3D_FVF_VERTEX (D3DFVF_XYZ | D3DFVF_DIFFUSE)
// 顶点缓冲区对象
LPDIRECT3DVERTEXBUFFER9 D3DVertexBuffer = NULL;
// 索引缓冲区对象
LPDIRECT3DINDEXBUFFER9 D3DIndexBuffer = NULL;
注意,这里我们定义的顶点格式为D3DFVF_XYZ,这个我们之前讲过,使用该类型后,顶点的坐标就是游戏世界坐标系的虚拟单位。在游戏世界坐标系中,我们设置窗体中间为坐标系的原点(0,0,0),水平方向为X轴(左负右正),垂直方向为Y轴(下负上正),向里向外为Z轴(里正外负)。这就是游戏的世界坐标系。余下的代码,我们同样把之前的代码全部复制过来,我们要改动的只是initScene函数和renderScene函数。由于这部分的代码比较复杂,我们先做讲解,再去完成上面的函数内容。
首先,我们先看一下局部坐标系到世界坐标系的变换。这种变换包括三种,平移,旋转和缩放。我们之前讲过,所有的变换都是通过矩阵来实现的。也就是意味着有三种类型的变换矩阵,对应就是平移变换矩阵,旋转变换矩阵和缩放变换矩阵。在Direct3D中统一使用SetTransform方法就可以完成所有的矩阵变换(包括其他类型的变换)。该方法只有两个参数,第一个是变换类型,另一个就是变换矩阵。代码如下:
// 平移变换,移动到世界坐标原点,也就是窗体中央位置
D3DXMATRIX moveMatrix;
D3DXMatrixTranslation(&moveMatrix, 0, 0, 0);
// 旋转变换,沿Y轴旋转45度
D3DXMATRIX rotationMatrix;
float angle = 45 * D3DX_PI / 180.0f;
D3DXMatrixRotationY(&rotationMatrix, angle);
// 缩放变换,沿x/y/z方向都缩放十倍
D3DXMATRIX scalingMatrix;
D3DXMatrixScaling(&scalingMatrix, 10.0f, 10.0f, 10.0f);
// 合并三种缩放,获取最终世界矩阵
D3DXMATRIX worldMatrix;
D3DXMatrixMultiply(&worldMatrix, &scalingMatrix, &rotationMatrix);
D3DXMatrixMultiply(&worldMatrix, &worldMatrix, &moveMatrix);
// 设置最终世界矩阵
D3DDevice->SetTransform(D3DTS_WORLD, &worldMatrix);
请注意,旋转矩阵对应三个函数D3DXMatrixRotationX、D3DXMatrixRotationY 以及D3DXMatrixRotationZ,通过字面意思我们就能理解它们分别代表着是围绕X/Y/Z轴进行旋转,在我们的代码中使用了D3DXMatrixRotationY进行围绕Y轴旋转。它们主要参数是FLOAT 类型的angle 表示要旋转的弧度值。弧度指用弧长与半径之比。例如180°=π弧度 ,所以1弧度=180°/π ≈ 57.3°。弧度正值代表顺时针旋转,负值代表逆时针旋转。在这三种类型的变种中,旋转变换是最复杂的,后续的游戏开发中大家就能体会到。
这里我们需要注意的一个问题就是,模型自身的局部坐标系也有一个原点(0,0,0)的。一般情况下,模型局部坐标系的原点位于模型的中央位置。当然也可以是其他位置。例如,一个人物角色模型的局部坐标系原点位于脚底中间位置。我们上面所说的平移变换,旋转变换和缩放变换都是相对于模型的局部坐标系原点而言的。例如平移变换的时候,其实就是设置模型的局部坐标系原点平移指定的世界坐标系的某点。因为,一个有体积的模型,不可能平移到一个点上面的,它一定是模型上的一个点平移到一个世界坐标系的一个点上。另外,多种变换是可以合并的,方法就是通过将不同变换矩阵相乘得到最终变换矩阵,合并矩阵安装先缩放、再旋转、最后平移的原则。我们在游戏开发过程中,游戏角色的行走和转身都会使用这种变换来完成。
请注意,本案例中的变换矩阵数值都是根据世界坐标系而定的,其实在我们实际游戏开发中,有时候使用局部坐标系进行变换,可能更加方便一下。例如,我们说让角色向前移动,其实就是相对于自己的局部坐标系而言的。在Unity中对于角色的移动,旋转和缩放,都可以选择是参照世界坐标系(space.world)还是模型局部坐标系(space.self)。这一点,大家必须清楚。我们知道游戏角色的行走和转身都是实时进行的,也就是说这种世界变换是实时发生的,因此上述代码应该放在renderScene方法中,剩余代码就是绘制立方体了,代码如下:
// 绘制立方体,8个顶点,12个三角形
D3DDevice->SetStreamSource(0, D3DVertexBuffer, 0, sizeof(D3D_DATA_VERTEX));
D3DDevice->SetFVF(D3D_FVF_VERTEX);
D3DDevice->SetIndices(D3DIndexBuffer);
D3DDevice->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 0, 8, 0, 12);
至此,renderScene函数已经完成了。下一步就是世界坐标系到观察者坐标系的转换,也就是取景变换。为此我们需要构建一个取景变换矩阵(视图矩阵)。为什么会存在取景变换?我们可以对比现实世界来理解这个事情。在现实世界中,所有的物体都是存在且运动的。我们个人通过眼睛看到的只是这个世界的一部分。我们的眼睛看到哪里,我们的大脑就会通过眼睛来采集哪里的世界内容,我们就能看到那部分的世界内容。游戏的世界也是如此,取景变换就如同一台摄像机。我们通过控制摄像机去采集游戏世界的内容,最终呈现到屏幕上面。这个摄像机就如通过我们的眼睛是一样的。我们在游戏世界中,摄像机一般都会跟随角色移动。也就是说,我们角色移动的时候,摄像机也会跟着移动,我们旋转的时候,摄像机也会旋转。这样,摄像机采集的游戏世界内容就等效于我们眼睛看到的内容。在这里,我们先固定摄像机的位置,毕竟现阶段还不适合完成一个自由移动的摄像机(后续我们会完成)。
// 设置取景变换矩阵
D3DXMATRIX viewMatrix;
D3DXVECTOR3 viewEye(0.0f, 300.0f, -500.0f); // 摄像机的位置
D3DXVECTOR3 viewLookAt(0.0f, 0.0f, 0.0f); // 观察点的位置
D3DXVECTOR3 viewUp(0.0f, 1.0f, 0.0f); // 向上的向量,一般使用(0,1,0)即可
D3DXMatrixLookAtLH(&viewMatrix, &viewEye, &viewLookAt, &viewUp);
D3DDevice->SetTransform(D3DTS_VIEW, &viewMatrix);
生成取景变换矩阵需要D3DXMatrixLookAtLH函数,该函数比较人性化。函数里面有两个重要的参数,一个就是摄像机的位置(世界坐标系),另一个就是摄像机的朝向,也就是观察点坐标(世界坐标系)。上面的代码表示,我们将摄像机放在世界坐标(0, 300,-500)位置,同时摄像机朝向世界坐标原点。我们将绘制的立方体放置在世界坐标系原点。然后在立方体的后上方来观察它。这样,立方体就会在窗体的中央位置显示了,也就是说,窗体中央位置是世界坐标系的原点。当然我们还可以定义摄像机其他的位置和朝向。在游戏开发中,我们经常会根据玩家的一些操作来同步调整摄像机的位置,来模拟用户的看到不同角度的场景内容。因此这部分代码应该也放到renderScene函数中,但是我们这里只想制作一个固定不变的摄像机,因此我们将上述代码放入到initScene函数中。
背面消隐,我们通过摄像机看到的三维模型是一个立体的空间,这样的一个立体空间必然有正面和反面,我们看到的就是正面,对应看不到的就是反面。既然看不到,那么我们就没有必要对其进行渲染了。默认情况下,Direct3D是要背面消隐的,当然我们可以通过下面的代码禁用背面消隐,一般情况下,我们保持默认即可,我们不使用下面的代码。
// 取消背面消隐
D3DDevice->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
光照,我们可以定义自己的光源,光源发出的光可以照亮场景,从而获取到逼真的显示效果。一般情况下,光照和材质搭配使用的。因为有了光源,必须有反光材质才能表现模型的颜色。我们在使用3ds max或者maya进行建模的时候,都会给模型添加材质。也就是说,我们在游戏开发过程中,创建完光源之后,需要从模型文件中读取材质信息。这样在绘制模型的时候,设置该模型的材质,就能表现模型的颜色了。在本案例中,我们不需要那么复杂的过程。我们直接定义立方体顶点的颜色,如果要顶点颜色起作用的话,需要关闭光照。
// 关闭光照,顶点颜色才会起作用
D3DDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
该代码放在取景变换矩阵后面即可,因为这个代码的执行也是一次性的,不需要放在renderScene函数中频繁的执行。
裁剪,我们知道透视投影的内容就是摄像机和投影平面构造的正四棱体的范围,只有在范围之内的模型物体,才能被玩家看到。也就是说,范围之外的模型物体没有必要进行渲染。
裁剪完之后就是透视投影,将三维物体投影到二维的投影平面上,这是一个降维的过程。我们在第一章中也大致讲过。定义透视矩阵其实就是定义一个正四棱体(视域体),见下图:
正四棱锥上面的顶点就是摄像机的位置,下面正四边形中间点就是摄像机观察点。我们可以理解在正四棱锥内的物体都会被摄像机采集到,最终输出到屏幕上去。透视投影就是定义这个正四棱锥的尺寸。其中有三个参数比较重要,第一个就是视域角度。视域角度越大,看到的视野也大,正四棱体越宽阔,也就是说下面的正四边形就越大。另外两个参数就是两个横截面,一个近裁剪面,一个远裁剪面。两个面中间的部分才会被摄像机采集并最终投影到屏幕上。这两个面其实就是两个距离数值而已。理解完这个正四棱锥后,我们就可以使用D3DXMatrixPerspectiveFovLH函数来创建一个透视投影变换矩阵,代码如下:
// 设置透视投影变换矩阵
D3DXMATRIX projMatrix;
float angle = D3DX_PI * 0.5f; // 90度
float wh = (float)WINDOW_WIDTH / (float)WINDOW_HEIGHT;
D3DXMatrixPerspectiveFovLH(
&projMatrix, // 表示投影变换矩阵
angle, // 表示摄像机的视域角度
wh, // 表示屏幕显示区的横纵比
1.0f, // 表示视域体中近裁剪面
1000.0f); // 表示视域体中远裁剪面
D3DDevice->SetTransform(D3DTS_PROJECTION, &projMatrix);
一般情况下,在3D场景中,我们使用透视投影。但是在2D场景中,我们一般使用平行投影(正交投影)。也就是说,如果此时我们构建的是一个2D场景的话,我们需要设置一个正交投影矩阵。如何创建一个正交投影,我们稍后在详细介绍。最有一个是视口转换,就是将投影平面的内容转换到屏幕窗体上,代码如下:
// 设置视口变换
D3DVIEWPORT9 viewport = {
0, // 视口相对于窗口的X坐标,默认0即可
0, // 视口相对于窗口的Y坐标,默认0即可
WINDOW_WIDTH, // 视口的宽度
WINDOW_HEIGHT,// 视口的高度
0, // 视口在深度缓存中的最小深度值,默认0即可
1 // 视口在深度缓存中的最大深度值,默认1即可
};
D3DDevice->SetViewport(&viewport);
上面的代码,就是按照屏幕窗体的尺寸输出视口里面的内容。这个代码基本固定不变的。
光栅化:模型顶点坐标变换到屏幕坐标之后,就需要绘制三角形,并计算屏幕上每个像素的颜色值。
至此,Direct3D的固定渲染流水线讲解完毕!为了将代码规划的更加合理,我们将重新定义一个initProjection函数专门负责投影变换的初始化工作,并将上面的代码全部放入该函数中,该函数将在initScene函数的末尾被调用。完成了整个固定渲染流水线之后,initScene函数的最后就是顶点立方体的顶点缓冲区对象和索引缓冲区对象的数据填充操作了:
// 创建顶点缓冲区对象,8个顶点
D3DDevice->CreateVertexBuffer(
8 * sizeof(D3D_DATA_VERTEX),// 表示顶点缓存的字节大小,这里是8个顶点
0, // 表示使用缓存的额外信息,默认0即可。
D3D_FVF_VERTEX, // 表示灵活顶点格式
D3DPOOL_DEFAULT, // 表示顶点缓存的存储位置,D3DPOOL_DEFAULT表示显卡的显存中
&D3DVertexBuffer, // 表示顶点缓存指针对象
NULL); // 表示保留参数,在此设置为NULL
// 创建索引缓冲区,36个索引顶点
D3DDevice->CreateIndexBuffer(
36 * sizeof(WORD), // 表示索引缓存区字节大小,这里是36个16位WORD类型的大小
0, // 表示索引缓存区属性,设置为0即可
D3DFMT_INDEX16, // 表示索引值大小,D3DFMT_INDEX16代表16位,也就是WORD的大小值
D3DPOOL_DEFAULT, // 表示索引缓存区的存储位置,D3DPOOL_DEFAULT表示显卡的显存中
&D3DIndexBuffer, // 表示索引缓存区指针对象
NULL); // 表示保留参数,设为0即可
// 构建立方体顶点数据
D3D_DATA_VERTEX* vertexArray;
D3DVertexBuffer->Lock(0, 0, (void**)&vertexArray, 0);
vertexArray[0] = { -10.0f, -10.0f, -10.0f, D3DCOLOR_XRGB(50, 100, 200) };
vertexArray[1] = { -10.0f, 10.0f, -10.0f, D3DCOLOR_XRGB(200, 100, 50) };
vertexArray[2] = { 10.0f, 10.0f, -10.0f, D3DCOLOR_XRGB(200, 100, 50) };
vertexArray[3] = { 10.0f, -10.0f, -10.0f, D3DCOLOR_XRGB(50, 100, 200) };
vertexArray[4] = { -10.0f, -10.0f, 10.0f, D3DCOLOR_XRGB(50, 100, 200) };
vertexArray[5] = { -10.0f, 10.0f, 10.0f, D3DCOLOR_XRGB(200, 100, 50) };
vertexArray[6] = { 10.0f, 10.0f, 10.0f, D3DCOLOR_XRGB(200, 100, 50) };
vertexArray[7] = { 10.0f, -10.0f, 10.0f, D3DCOLOR_XRGB(50, 100, 200) };
D3DVertexBuffer->Unlock();
// 定义立方体索引数据
WORD* indexArray = 0;
D3DIndexBuffer->Lock(0, 0, (void**)&indexArray, 0);
// 前面
indexArray[0] = 0; indexArray[1] = 1; indexArray[2] = 2;
indexArray[3] = 0; indexArray[4] = 2; indexArray[5] = 3;
// 背面
indexArray[6] = 4; indexArray[7] = 6; indexArray[8] = 5;
indexArray[9] = 4; indexArray[10] = 7; indexArray[11] = 6;
// 左面
indexArray[12] = 4; indexArray[13] = 5; indexArray[14] = 1;
indexArray[15] = 4; indexArray[16] = 1; indexArray[17] = 0;
// 右面
indexArray[18] = 3; indexArray[19] = 2; indexArray[20] = 6;
indexArray[21] = 3; indexArray[22] = 6; indexArray[23] = 7;
// 顶面
indexArray[24] = 1; indexArray[25] = 5; indexArray[26] = 6;
indexArray[27] = 1; indexArray[28] = 6; indexArray[29] = 2;
// 底面
indexArray[30] = 4; indexArray[31] = 0; indexArray[32] = 3;
indexArray[33] = 4; indexArray[34] = 3; indexArray[35] = 7;
D3DIndexBuffer->Unlock();
// 设置多边形填充模式,D3DFILL_POINT代表点模式,D3DFILL_WIREFRAME代表线模式,D3DFILL_SOLID代表面模式
D3DDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_WIREFRAME);
// 初始化投影变换
initProjection();
我们定义一个立方体,其实就是定义它的6个正方形。这里我们需要说的是设置多边形填充模式,由三种方式,一种是点模式,一种是线模式,一种是面模式。本案例中,我们使用线模式,效果如下:
如果改成面模式的话,效果如下:
在DerictX的单位中,我们介绍过3D模型的大小显示,主要由摄像机与模型的距离决定。本案例中,摄像机和模型的距离是500的距离,如果我们缩小这个距离,那么这个立方体就会变大。另外一个改变模型大小的就是缩放变换矩阵。本案例中我们将立方体放大了10倍,同样我们也可以使用小数来缩小立方体。
本课程的所有代码案例下载地址:
备注:这是我们游戏开发系列教程的第二个课程,这个课程主要使用C++语言和DirectX来讲解游戏开发中的一些基础理论知识。学习目标主要依理解理论知识为主,附带的C++代码能够看懂且运行成功即可,不要求我们使用DirectX来开发游戏。课程中如果有一些错误的地方,请大家留言指正,感激不尽!