第六章 DirectX 2D游戏和帧动画(上)

您所在的位置:网站首页 游戏动画渲染 第六章 DirectX 2D游戏和帧动画(上)

第六章 DirectX 2D游戏和帧动画(上)

2024-01-21 14:11| 来源: 网络整理| 查看: 265

目前,我们已经掌握了如何使用DirectX绘制四边形,纹理映射技术,以及正交摄像机的内容。对于2D游戏的开发,这些内容基本上已经足够了。2D游戏的本质就是图像游戏,2D游戏中的动画其实就是一系列连续动作的图像,称之为序列帧动画。这也是动画片制作的原理。在动画中,每一张图像就是一帧,为了让人们的眼睛感觉出这是一个连续的动作,需要在1秒钟之内至少要变化24帧,才能达到自然过渡的流畅效果。在游戏开发中,手机端至少要达到30帧,电脑端至少要达到60帧,在VR中至少要达到90帧。我们以电脑游戏为例,1秒钟内我们至少要进行60次的画面渲染,我们称之为每秒传输帧数(FPS)。我们在使用DirectX构建Direct3D设备指针对象的时候,有一个参数设置为:D3DParameters.PresentationInterval = D3DPRESENT_INTERVAL_DEFAULT; 它代表的意思就是说我们指定游戏的FPS约等于电脑屏幕刷新频率。我们可以通过查看自己电脑的“监视器”来看屏幕刷新频率,如下图:

一般情况下,电脑的屏幕刷新频率都是60赫兹。在Unity中提供了一个Application.targetFrameRate变量来设置这个帧数。但是,这个并不是绝对的。也就是说,在1秒钟之内,并不是绝对的渲染画面60次。这个要根据每次绘制画面的工作量来决定,如果场景非常的复杂,并且还有很多的实时光影效果,那么这个渲染的时间就可能会变长。如果这个场景持续很长时间,那么我们实际的FPS值可能会低于60。这种情况下,会影响我们的动画效果。举一个简单的例子,角色移动速度是每秒2米,也就是说在1秒钟之内,该角色必须完成移动2米的动画。但是,如果由于渲染时间过长,导致FPS值下降,那么它的实际可能就不会完成2米的动画效果。这种情况下,我们就需要让这个动画乘以前后两帧的时间间隔。也就是使用时间来干预控制动画的实际效果。这里的关键点就是前后两帧之间的时间间隔,在DirectX中,我们可以手动计算,在Unity中提供了Time.deltaTime。

我们使用VS2019创建一个新项目“D3D_06_Animation”,这个案例就用来演示2D动画效果,本质就是连续渲染不同的贴图而已。为此,我们提前准备了一系列的图片,如下:

本案例代码主要复制“D3D_05_Texture”旧项目。首先,我们还是声明全局变量,如下代码:

// 引入头文件 #include #include #include #include #include #include // 引入依赖的库文件 #pragma comment(lib,"d3d9.lib") #pragma comment(lib,"d3dx9.lib") #pragma comment(lib,"winmm.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; // 动画播放当前纹理贴图索引(纹理数组下标) int index = 0; // 动画纹理贴图总数量(纹理数组大小) const int total = 22; // 定义FVF灵活顶点格式结构体 struct D3D_DATA_VERTEX { FLOAT x, y, z, u, v; }; // 定义包含纹理的顶点类型 #define D3D_FVF_VERTEX (D3DFVF_XYZ | D3DFVF_TEX1) // 顶点缓冲区对象 LPDIRECT3DVERTEXBUFFER9 D3DVertexBuffer = NULL; // 纹理对象数组 LPDIRECT3DTEXTURE9 D3DTexture[total];

请注意,本案例需要加载的头文件以及库文件!!!我们定义了一个纹理数组,共计22个纹理对象,也就是对应我们上面的22张图片。我们需要连续渲染纹理数组里面的图片,因此,我们声明了一个全局数组下标变量。接下来就是我们的initScene函数,主要就是构建四边形顶点数组数据,以及加载22个纹理对象。代码如下:

// 初始化四边形顶点数组数据 D3D_DATA_VERTEX vertexArray[] = { // 三角形V0V1V2,左下角顺时针 { -10.0f, -10.0f, 0.0f, 0.0f, 1.0f }, // 左下角UV坐标(0,1) { -10.0f, 10.0f, 0.0f, 0.0f, 0.0f }, // 左上角UV坐标(0,0) { 10.0f, 10.0f, 0.0f, 1.0f, 0.0f }, // 右上角UV坐标(1,0) // 三角形V0V2V3,左下角顺时针 { -10.0f, -10.0f, 0.0f, 0.0f, 1.0f }, // 左下角UV坐标(0,1) { 10.0f, 10.0f, 0.0f, 1.0f, 0.0f }, // 右上角UV坐标(1,0) { 10.0f, -10.0f, 0.0f, 1.0f, 1.0f }, // 右下角UV坐标(1,1) }; // 创建顶点缓存对象 D3DDevice->CreateVertexBuffer(sizeof(vertexArray), 0, D3D_FVF_VERTEX, D3DPOOL_DEFAULT, &D3DVertexBuffer, NULL); // 填充顶点缓存对象 void* ptr; D3DVertexBuffer->Lock(0, sizeof(vertexArray), (void**)&ptr, 0); memcpy(ptr, vertexArray, sizeof(vertexArray)); D3DVertexBuffer->Unlock(); // 创建纹理对象,纹理图片统一放在"asset"目录下 for (int i = 0; i < total; i++) { // 拼接纹理贴图文件名称 wchar_t file1[10], file2[20]; swprintf_s(file1, 10, L"asset/%d", (i + 1)); wcscpy_s(file2, 20, file1); wcscat_s(file2, 20, L".jpg"); D3DXCreateTextureFromFile(D3DDevice, file2, &D3DTexture[i]); } // 线性纹理 D3DDevice->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); D3DDevice->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR); // 初始化投影变换 initProjection(); // 初始化光照 initLight();

上面的代码几乎没有改动,主要是纹理图片的加载,我们使用一个循环来创建纹理对象。关于如何拼接图片目录和名称,这里不在叙述。值得注意的,这部分操作需要iostream头文件支持。投影变换和光照都是之前“D3D_05_Texture”旧项目中的代码,不需要改动。接下来就是renderScene函数,代码如下:

// 设置纹理 D3DDevice->SetTexture(0, D3DTexture[index]); // 纹理数组索引累加 index++; if (index >= total) { index = 0; } // 绘制四边形 D3DDevice->SetStreamSource(0, D3DVertexBuffer, 0, sizeof(D3D_DATA_VERTEX)); D3DDevice->SetFVF(D3D_FVF_VERTEX); D3DDevice->DrawPrimitive(D3DPT_TRIANGLELIST, 0, 2);

上面的代码重点就是渲染的纹理对象是D3DTexture数组中指定下标的元素(纹理对象)。这个下标是一个全局变量,每次渲染一个数组元素后,下标变量累加,如果超出数组长度,就从数组第一个元素(0)继续开始。这样,每次绘制图形的时候,加载的纹理就不一样了。运行之后,就能看到动画效果。

我们发现,动画展示的速度非常快。为了计算到底有多快,我们可以手动计算一下FPS数值是多少。我们增加GetFPS函数,用来计算FPS数值,并通过字体对象打印到窗体上面。首先就是声明GetFPS函数和字体对象:

static float fps = 0; // FPS值 static int frameCount = 0; // 累积帧数 static float currentTime = 0.0f; // 当前时间 static float lastTime = 0.0f; // 持续时间 // 每调用一次Get_FPS()函数,帧数自增1 frameCount++; // 获取系统时间,其中timeGetTime函数返回的是以毫秒为单位的系统时间,所以需要乘以0.001,得到单位为秒的时间 currentTime = timeGetTime() * 0.001f; // 如果当前时间减去持续时间大于了1秒钟,就进行一次FPS的计算和持续时间的更新,并将帧数值清零 if (currentTime - lastTime > 1.0f) { // 计算这1秒钟的FPS值 fps = (float)frameCount / (currentTime - lastTime); // 将当前时间currentTime赋给持续时间lastTime,作为下一秒的基准时间 lastTime = currentTime; // 将本次帧数frameCount值清零 frameCount = 0; } return fps;

这个函数的算法比较简单,首先注意的是,函数中定义了静态变量,也就是说,即便函数调用结束,该变量的数值依然存在。这样在每次绘制(renderScene函数)调用GetFPS函数的时候,静态变量的值是一直存在的。每次调用GetFPS函数,我们就累加frameCount,同时记录总的渲染时间是否过去了1秒钟。如果超过一秒,就使用总的渲染次数frameCount除以这个时间段(1秒钟左右)。这样,我们就能够计算出FPS数值了。当然,计算完之后,我们会将静态变量frameCount清零,继续计算下一个1秒钟时间段内的FPS数值。需要注意的是,该方法中timeGetTime()函数用户系统时间,该函数需要头文件time.h和库文件winmm.lib的支持。为了能够把FPS数值打印到窗体上,我们还需要创建字体对应,也就是在initScene函数中完成:

// 创建一个字体对象 D3DXCreateFont(D3DDevice, 24, 0, 1, 1, false, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, DEFAULT_QUALITY, 0, L"微软雅黑", &D3DFont);

字体对象完成后,就能够在renderScene函数中调用GetFPS函数,打印FPS数值了:

// 定义一个窗口矩形 RECT formatRect; GetClientRect(hwnd, &formatRect); // 绘制FPS值,同时输出数组下标变量值 float charCount = GetFPS(); std::string mystring = std::string("FPS:") + std::to_string(charCount) + " (" + std::to_string(index) + ")"; int mystringSize = (int)(mystring.length() + 1); wchar_t* mywstring = new wchar_t[mystringSize]; MultiByteToWideChar(CP_ACP, 0, mystring.c_str(), -1, mywstring, mystringSize); D3DFont->DrawText(0, mywstring, -1, &formatRect, DT_LEFT, D3DCOLOR_XRGB(255, 0, 0));

上面的代码我们使用了string来显示FPS字符串,同时将string转化成wchar_t字符串,用于D3DFont->DrawText函数的输出。String需要头文件string的支持。运行效果如下:

我们可以看到,我们计算出来的FPS数值基本上就是60,也就是我们电脑屏幕的刷新率。对于我们本案例的动画,60的FPS数值确实有点高了,动画播放太快了。那么,我们如何手动去控制这个FPS数值呢?因为,我们间接调用renderScene函数的位置是在入口wWinMain函数的消息循环过程,我们在这个无限循环中,每次都是间接调用renderScene函数的。其实,我们可以按照GetFPS函数的思路,来计算两次循环之间的时间差,如果符合我们给定的条件,就去调用renderScene函数。例如,我们想要30的FPS数值,也就是1秒钟渲染30次,那么前后渲染两次的时间差就约等于0.5秒。本案例中,我们使用另一种方式来解决动画播放快慢的问题。我们新增加两个全局变量,如下:

// 动画播放总时间,也就是2秒钟绘制完一套纹理贴图 const int second = 2000; // 动画帧间隔时间,播放总时间 ÷ 动画纹理贴图总数量 const int interval = 90;

我们声明一个动画播放一遍的总时间,例如2秒钟。那么每一帧的渲染时间差应该大约是0.09秒左右,也就是90微秒。接下来,我们只需要改动纹理数组的下标变量值即可。让这个下标变量值得变动符合90微秒的限制,代码如下:

// 渲染运行时间差大于等于 interval 才会更新纹理,否则维持原纹理 int currentSecond = timeGetTime(); static int lastSecond = currentSecond; if (currentSecond - lastSecond >= interval) { index++; lastSecond = currentSecond; if (index >= total) { index = 0; } } D3DDevice->SetTexture(0, D3DTexture[index]);

之前的代码可以注释或删除掉,然后使用上面的代码。它的原理也是一样的,就是计算前后两次渲染的时间差,如果大于我们规定的90微秒,就变动纹理数组下标变量。运行代码,我们就可以明显的看到,动画运行的速度下降了。使用这种方式的好处在于,我们可以手动控制动画的播放速度。例如,有两个角色移动速度为5和10,那么在相同时间内,两者的移动距离是不一样的,而且播放的帧数也不一样。否则就会产出不协调的事情发生,两角色迈着相同的步伐,却移动了不同的距离。这里的代码仍然有一个缺陷,也就是上面我们提到时间差对动画的影响。因为上述代码能够运行成功的前提是,我们的FPS数值是60的情况,也就是说快的动画,可以减慢。假如,我们电脑配置很低,实际运行后发现,FPS数值可能非常的低,导致我们在2秒钟之内,不能完成22张贴图的渲染(我们代码是累加的),这样的效果肯定是不行的。如果,我们要求不管实际FPS值是多少,我们都需要在2秒钟之内完成22张贴图的渲染,显然不容易实现。那么,我们就可以根据时间差来快进数组索引变量值,也就是说时间点到哪里,就渲染对应的纹理对象。如果因为本次渲染时间过长,导致下次纹理对象无法渲染的话,就直接渲染下下张纹理图片。总之,保证2秒钟之后,一定是渲染最后一张纹理图片。这就是上面我们提到的,让动画乘以前后两帧的时间间隔,来实现一致的动画效果(计算机硬件导致FPS值偏低的情况)。在Unity中,所有的动画基本上都是按照这个思路来完成的。

帧动画虽然简单,但是需要美工人员绘制大量的图像。在2D动画中,还有另外一种形式的动画,称之为骨骼动画。它的原理就类似于“皮影戏”。这种技术的本质就是将角色的二维图像拆分成多个部分,然后再拼接成一个完整的图像,各个部分通过矩阵变换完成不同的动画帧。其实3D游戏的骨骼动画,也是这个原理。这种2D形式的动画,不需要绘制大量的图像,只需要存储每个帧动画对应的矩阵数据即可。当然,这种动画需要第三方的支持库才能使用的,比如Spine,DragonBones等等,当然Unity也支持2D的骨骼动画。注意,2D骨骼动画虽然能够减少贴图的文件数量,以及动画制作工作量。但是,某些复杂的旋转动画,2D骨骼动画可能就无法完美的实现了。

在2D游戏开发中,贴图是最基本的,称之为精灵(Sprite)。DirectX中专门提供了一个LPD3DXSPRITE对象,用于精灵的支持。这个对象,我们可以理解它就是一个四边形纹理。我们使用VS2019创建一个新项目“D3D_06_Sprite”用来演示精灵的使用方法。首先,还是我们全局变量的声明:

// Direct3D设备指针对象 LPDIRECT3DDEVICE9 D3DDevice = NULL; // 鼠标位置 int mx = 0, my = 0; // 精灵对像 LPD3DXSPRITE D3DSprite = NULL; // 精灵纹理 LPDIRECT3DTEXTURE9 D3DTexture = NULL;

接下来就是我们的initScene函数,代码如下:

// 初始化精灵对像 D3DXCreateSprite(D3DDevice, &D3DSprite); // 创建纹理对象 D3DXCreateTextureFromFile(D3DDevice, L"sunwukong.bmp", &D3DTexture); // 线性纹理 D3DDevice->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); D3DDevice->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);

可以看出,这个精灵对象要比构建四边形简单多了。需要注意的是,精灵对象的使用不需要投影和光照。这个就有点类似于D3DFVF_XYZRHW 顶点类型。接下来就是我们的renderScene函数,代码如下:

// 精灵透明渲染 D3DSprite->Begin(D3DXSPRITE_ALPHABLEND); // 2D坐标转换矩阵 D3DXMATRIX mat; D3DXVECTOR2 scale = D3DXVECTOR2(0.5f, 0.5f); D3DXVECTOR2 pos = D3DXVECTOR2(0.0f, 0.0f); D3DXMatrixTransformation2D( &mat, // 指向 D3DXMATRIX 结构的变换矩阵 NULL, // 缩放中心向量 0.0f, // 缩放旋转系数 &scale, // 缩放向量,缩小一半(0.5f) NULL, // 旋转向量 0.0f, // 旋转角度,单位是弧度 &pos); // 平移向量,屏幕的(100,100)坐标处 D3DSprite->SetTransform(&mat); // 矩形区域 RECT rect; rect.left = 0; rect.top = 0; rect.right = 512; rect.bottom = 512; // 精灵旋转中心点 D3DXVECTOR3 center = D3DXVECTOR3(0, 0, 0); // 精灵位置 D3DXVECTOR3 position = D3DXVECTOR3(0.0f, 0.0f, 0.0f); // 渲染精灵 D3DSprite->Draw( D3DTexture, // 精灵所用到的纹理 &rect, // 纹理图像指定区域,可以为NULL &position, // 纹理旋转中心点 &position, // 精灵在屏幕上的渲染位置 0xffffffff); // 纹理颜色调整 // 结束渲染 D3DSprite->End();

同代码,我们可以看到,精灵类的使用,还是比较复杂的。这里我们不在详细介绍,代码注释中已经说的差不多了。我们使用了透明纹理,这一点精灵的用法还是很简单的。我们可以运行代码,看看效果:

 在2D游戏的实际开发中,很多人还是不怎么使用上面的精灵类,他们仍然坚持使用四边形纹理,只不过就此进行了封装,方便使用。

 本课程的所有代码案例下载地址:

workspace.zip

备注:这是我们游戏开发系列教程的第二个课程,这个课程主要使用C++语言和DirectX来讲解游戏开发中的一些基础理论知识。学习目标主要依理解理论知识为主,附带的C++代码能够看懂且运行成功即可,不要求我们使用DirectX来开发游戏。课程中如果有一些错误的地方,请大家留言指正,感激不尽!



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3