第2章 从像素开始学习2D 图形处理

图像说明文字

上一章开发的《箱子搬运工》已经基本完成了游戏的核心模块。因为玩家对游戏的第一印象往往取决于美术质量,所以就上一章完成的游戏状态而言,作为商业产品肯定是不合格的。因此,本章将更进一步,在游戏中绘制图形。首先从需要的准备工作开始,然后逐步将《箱子搬运工》改写为图形版本。

实际上,绘图处理要做到不依赖运行环境是很困难的。相比文字输出,计算机上的图形显示要复杂得多。即便仅仅是画一个圈,也要根据计算机操作系统和硬件环境的不同而采用不同的方法。但是考虑到对这些细枝末节进行讨论有些浪费时间,所以请读者直接使用笔者提供的类库来创建程序。硬件或者操作系统的差异都交由类库处理,读者只需集中精力学习关键的内容就好。

本书为各章准备了专用的类库,本章只会用到 2D 图形输出的模块。随着读者掌握的知识越来越多,笔者所提供的类库的功能也会越来越丰富。

2.1 什么是 2D 图形处理

计算机画面是由大量四边形色块组成的,稍微靠近屏幕观察就可以发现这些色块,这些四边形色块称为像素(pixel)。例如,1280 × 1024 的显示器表示由横向 1280 个像素、纵向 1024 个像素组成,设置好各个像素的颜色后,就能显示出图像。

图像说明文字

3D 图形处理的情况下也是如此。无论多么绚丽的 CG,本质上都是通过循环设置各个像素的颜色来绘制图形的。

此外,所有颜色都是通过红、绿、蓝三原色来表现的。这方面的知识后面会更详细地介绍,读者暂时只要记住这一点即可。各个原色值的范围是 0 ~ 255。如果红值为 255,其余两种原色值为 0,则显示红色;如果红绿值都为 255,则显示黄色;如果三种原色值全部为 255,则显示白色;如果全部为 0,则显示黑色。颜色的种类有 256 × 256 × 256 ≈ 1677 万种,一般情况下完全够用了。

2.1.1  关于类库

本章提供的类库是为了帮助读者理解像素成像的原理而开发的。程序中以数组的形式提供了一组画面像素,分别设置好颜色后,画面就会呈现到屏幕上。下面是读者将会使用到的类库。

图像说明文字

读者有必要学习前三个函数的用法,最后一个函数可以自己填充逻辑。

  • videoMemory()

    videoMemory()函数用于获取构成画面的像素的数组。在像素数组中,x 从左向右排列,y 从上向下排列。例如,5 × 4 的像素数组如下图所示。

    图像说明文字

这是个一维数组,坐标 (x, y) 对应的像素的下标为“y * 宽度 + x”。

该数组是 unsigned型数组。1 个 unsigned表示 1 个像素,按照“变量值的第 0 位到第 7 位表示蓝,第 8 位到第 15 位表示绿,第 16 位到第 23 位表示红”的规则,就可以将三原色的值都保存在 unsigned变量中。当然也可以通过 3 个 unsigned char变量来分别表示红、绿、蓝,只是笔者习惯使用 unsigned型。

如果用 3 个 unsigned char(值的范围是 0 ~ 255)来表示三原色,可以像下面这样合成颜色值。

unsigned char red, green, blue;
unsigned color = ( red<<16 ) | ( green<<8 ) | blue;

读者如果对位运算理解得不够透彻,可以参考第 1 章的补充内容。

图像说明文字

  •  width() 和 height()

width()和 height()分别是用于获取画面宽度和高度的函数。本章的类库中画面固定为320 × 240,但这样会丧失一些灵活性,最好能通过这两个函数来获取相应的值。这样一来,以后移植到其他环境时也能更省事 ①。

  •  update()

类库中只是在头文件中声明了 update()函数,没有实现函数体,读者可以填充自己的游戏逻辑。稍后我们会详细介绍相关内容。

① 函数名称如果是名词,则表示该函数将返回其名称所指代的东西,除此之外的函数都以动词开头。虽然也可以统一为 getWidth()、getHeight()等形式,但这样会导致代码中到处都是 get,读写起来很不方便。当然全部加上get也很好,能够避免混乱。

2.2 准备工作

下面可以开始创建 Visual Studio 项目了。如下图所示,先创建一个空的 Windows 应用程序项目。然后再添加一个 .cpp 文件,否则将无法进行 C ++ 关联项目的设置。如果想在设置完成后再添加文件,这里可以先把 main.cpp 加上。现阶段只需要一个 main.cpp 就足够了,不过读者如果想创建各种类的话,也可以继续添加。

图像说明文字

2.2.1  设置类库的查找路径

接下来为使用类库做准备。

所谓“准备”,主要是指通知接下来要创建的程序“这里有可用的类库”,这项操作称为设置路径。在计算机术语中,“路径”(path)表示文件所处的目录位置,即文件存放的位置。

读者是否已经将随书下载中的内容复制到计算机上了?如果还没有,请参考上一章开始部分的内容。我们需要使用解压后的 2DGraphical 目录,并将其中的 include 目录添加到头文件路径,将 lib目录添加到库路径。

图像说明文字

Debug(调试)模式下请在“链接器”的“输入”中添加 GameLib_d.lib,Release(发布)模式下则添加 GameLib.lib。

图像说明文字

头文件路径指明了头文件存放的位置,库路径则指明了类库文件的存放位置。ifstream类无须做任何设置就可以使用,这是因为编译器会自动对标准类库进行配置。如果是自己创建的类,则必须设置相关路径。

2.2.2  Debug 和 Release

Visual Studio 支持 Debug 和 Release 两种配置,我们可以修改其中的设置。开发时一般选择便于调试的 Debug 模式,发布时选择 Release 模式可以将调试功能移除,使程序运行得更快。因为目前不需要发布程序,所以可以只使用 Debug 模式,不过如果选择了“所有配置”,设置的内容会同时反映到 Debug 和 Release 模式中,将来准备发布时会更方便。

另外,在“C/C ++”的“代码生成”设置中,如果是 Debug 配置,则将运行库设置为“多线程调试(/MTd)”,如果是 Release 配置,则设置为“多线程(/MT)”,否则将可能导致链接时发生错误而终止。这样设置是为了保证程序在其他计算机上也能运行。因为包含“DLL”的程序版本在缺乏Visual Studio 附带的 .dll 文件的计算机上无法运行。

因为以后每次创建项目时都需要进行这样的设置,读者可能会反复参考这里的内容,所以建议将本页折叠起来以便快速查找。

2.2.3  环境变量

在编译本书的示例代码前,必须先设置环境变量。环境变量是事先在计算机中设置的变量,程序在执行过程中可以通过查找获得该变量值。在多个程序间共享相同的配置时,使用环境变量非常方便。本书的示例解决方案中要通过查找环境变量来获取类库的安装位置,因此必须进行设置。

如果像前面那样把 zip 文件解压到了 D 盘根目录,那么就可以把环境变量 GAME_LIB_DIR设置为“d:\GameLib”。在 WindowsXP 系统的情况下,设置步骤为:右键单击“我的电脑”,在弹出的菜单上选择 “属性”,然后在“高级”标签页中选择“环境变量”,之后在用户变量中新建并设置变量名和变量值。

图像说明文字

在 Windows 中指定文件名或目录名时采用“\”作为分隔符,比如“d:\GameLib\include”。 Windows 以外的系统中则一般使用“/”符号,比如“/usr/local/etc/rc.d”。因为 Visual Studio 和环境变量的设置都在 Windows 上进行,所以要使用“\”,但在代码中不能使用“\”,必须用“/”。

ifstream in( "data/foo.txt" );

上面这种写法在所有主流编译器中都可以顺利执行。

2.2.4  关于 main 函数

之前我们的程序都是从 main函数开始写起的,本章我们来写类库中特定的函数。前面提到过,类库中没有对函数 Framework::update()进行实现,读者必须自行编写逻辑。

需要注意的是,不同于 main(),update()处理的内容相当于一次主循环,因此不可以将主循环的内容写在 main函数中。

代码结构大致如下所示。

图像说明文字

可以认为 main()已经隐含在类库中了。

为了加深理解,下面我们再来看一段示例代码。

图像说明文字

因为整个类库都被包含在 GameLib名称空间中,所以代码必须放入 namespace GameLib块中才能通过编译。此外还必须包含 GameLib/Framework.h头文件,只有这样才可以使用包括 cout和 cin在内的所有功能。

2.2.5  与上一章中的 cout 和 cin 的区别

实际上,这里使用的 cin和 cout并非上一章介绍的 iostream,而是按照相同的接口仿制的替代品,因此不需要包含 iostream头文件。

除了向 Visual Studio 的调试窗口而非系统的命令行窗口输出信息以外,该 cout的其他功能和iostream中的 cout是一样的。

使用方法的差异主要在于名称空间的不同,这里的 cout和 cin位于 GameLib名称空间而非std。使用时要么每次都像 GameLib::cout、GameLib::cin、GameLib::endl这样写出名称空间,要么在特定位置写上 using namespace GameLib。

不过使用该类库时有一点需要特别注意,那就是点击“×”按钮可能无法关闭程序。这是因为程序正在等待 cin的输入而无法响应鼠标操作。如果想结束程序,可以在运行时同时按下 Alt 和 F4,或者直接在 Visual Studio 上按下 Shift+ F5 结束调试。

2.3 打印一个点

本章提供的类库的功能其实就是打印一个点而已。如果要在 (100, 200) 处打印一个红点,可以通过下列代码来实现。

unsigned* vram = videoMemory();
vram[ 200 * width() + 100 ] = 0xff0000;

videoMemory()返回的是一维数组,如果要将其视作二维数组来访问元素 (x, y),可以按照之前介绍的方法,将“y * 宽度 + x”作为元素的下标。具体请参考上面的代码。运行后将在画面上打印出一个点。

本章新添加的内容就是这些。类库只提供了这个功能,后面所有的工作都是基于此功能完成的。

我们再看一个例子。下面的代码将绘制一个左上角位于 (100, 100)、右下角位于 (200, 200) 的红色四边形。

unsigned* vram = videoMemory();
int width = width();
for ( int i = 100; i <= 200; ++i ){
    for ( int j = 100; j <= 200; ++j ){
    vram[ j * width + i ] = 0xff0000;
    }
}

注意不要让坐标超出画面范围。在移动四边形的过程中,x 和 y 的值很有可能超过宽度和高度的范围或者小于 0。如果没考虑到这一点,程序就可能会出现问题。

另外,在 Debug 版本(GameLib_d.lib)中,程序会检测数组下标是否位于上下方向的合理范围内,一旦发现异常,就会立即终止运行。访问了正常范围外的数据是导致游戏崩溃的一个主要原因,读者一定要养成使用前对下标值进行范围检查的习惯。

2.3.1  示例代码

02_2DGraphica1 解决方案中的 drawPixels 示例程序会按上述步骤陆续在适当的位置打印出小点。 main.cpp 只有 10 行左右的代码。需要注意的是,不要忘了包含头文件 GameLib/Framework.h。 Framework::update()要放在 namespace GameLib的大括号中。另外,在向 videoMemory()返回的数组中写入值之前要判断下标的合法性。

因为 update()是 Framework的成员函数,所以在函数内可以直接调用 videoMemory()等函数。但如果要在 update()之外调用,则应当写成如下形式。

Framework::instance().videoMemory();

先获得一个 Framework类型的变量,然后调用它的函数。instance()函数会返回Framework类型的变量。后面我们会经常遇到这种用法,请读者留意 ①。

① instance 称为“实例”。“for instance”和“例如”意思相近。实际创建了某种类型的变量后,就把这个变量称为该类型的实例。如果创建了 int型的 a,那么 a就是 int型的实例。

2.4 移植《箱子搬运工》

下面我们将使用类库来开发图形版本的《箱子搬运工》。首先让程序运行起来。

2.4.1  开发没有图形的《箱子搬运工》

我们先将最开始写的控制台版本的游戏整个移植过来,如下所示。

图像说明文字

不过,其实只要把 while内部的逻辑移到 update()中就可以了。

图像说明文字

试着对之前写好的文本版游戏进行整理,会发现游戏也可以运行起来。虽然画面上一片漆黑,但是 cout会向 Visual Studio 的调试窗口输出游戏画面的相关内容。这样就表示移植成功了。

  •  示例代码

本节的示例代码位于 NimotsukunTextOnly 中。这是一个画面漆黑、游戏内容都显示在 Visual Studio 输出窗口的非常单调的作品。注意,如果在 Visual Studio 外启动游戏,将什么也看不见。

请读者对比一下在使用类库开发时游戏结构的改变。在这个版本中,基础代码都来自于上一章中第二版的《箱子搬运工》。文件载入模块没有进行任何修改,stageData.txt 位于项目目录下。

2.4.2  为游戏添加图形

现在我们来尝试绘制图形。

为了在画面上绘制出图形,只需要把 draw()中“通过 cout输出文字”的处理替换为“向画面打印小点”即可。当然,保留输出文字的处理也没有什么影响,因此我们在保留 cout输出的基础上,向 draw()中添加绘制小点的代码。

因为太逼真的图形绘制起来很烦琐,所以我们用各种颜色来代表不同种类的物体,形状则采用简单的小点。墙壁为白色,玩家为绿色,箱子为红色,目的地为蓝色。目的地上的玩家是绿色和蓝色混合而成的淡蓝色,目的地上的箱子则是红色和蓝色混合而成的紫色。如果箱子全部变为紫色,就意味着游戏过关了。

  •  示例代码

本节的示例代码位于 NimotsuKunDot 中,和 NimotsuKunTextOnly 的区别仅在于 draw()中的处理。代码不算太长,这里将其全部列出来。

图像说明文字 图像说明文字

代码中通过 videoMemory()获取像素数组,通过 width()获取宽度。然后根据物体种类决定颜色,并将信息写入坐标对应的点。内容并不难理解。

这里,goalFlag的 if判断和 switch之间的顺序也可以倒过来,在 switch中执行 if判断。因为笔者坚持“尽量让 switch内部简单”的原则,所以这里把 if放在了外面。

2.4.3  输出更大的图形

上一节绘制的图形很难看清。因为一个点只是一个像素,确实太小了。 现在我们让它变得稍微大一些,按照 16 × 16 的尺寸放大每个点。为此,我们只需要把

vram[y * windowWidth + x] = color;

替换为以下内容即可。

图像说明文字

但是这样写就意味着代码中将出现 4 层 for循环,很难阅读,所以不如把它封装成一个函数。

图像说明文字

有了这个函数后,图形绘制的逻辑就都在这个函数中完成,而无须在 draw()中直接调用videoMemory()。这样一来,将来在修改类库时只需编辑 drawCell()中的内容就行了。尽量合并那些需要依赖特定类库的代码会便于日后的拓展维护。

  •  关于处理顺序

这里还有一点需要注意。

cout在接收到字符的瞬间就会输出文字,但是画面上的图形输出却不是这样的。画面输出处理并没有在写入 videoMemory()返回的空间后立即执行,而是要等到 Framework::update()结束后才执行。因此,如果像下面的代码这样把 draw()提前,最后执行 updateGame()更新,那么屏幕上显示的将是更新前的图形。输入向上并回车,画面不会发生任何变化,继续输入向左并回车,画面却显示人物在往上走。

图像说明文字

为了避免出现这种“延迟”状态,应当将 draw()放在最后。

  •  示例代码

完成上述改造后的示例代码位于 NimotsuKunBox 中。读者可以看一下代码发生了什么变化,尤其要注意 draw()的位置和函数体的内容。

图像说明文字

读者可能会发现 drawCell()中并未执行之前提到的范围检测,这样在生成大场景时是否会因为越界而使程序崩溃呢?其实这里可以在读取大场景数据后以玩家为中心绘制一个屏幕大小的画面,或者在载入该场景时就进行错误检测并提示。是否需要在 drawCell()中再次执行安全检测依具体情况而定,不过考虑得周全一些总是好的。

本章绘制的图形种类还不够丰富,如果读者有兴趣,可以试着追加绘制三角形或圆形等的函数,让画面更形象。

如果到这里的内容都能理解,那么本章的学习目标就基本达成了。下面是补充内容,以后再读也没什么影响。

2.5 补充内容:结束处理

其实程序中存在一个问题。请读者在 Visual Studio 中启动 NimotsuKunBox,试着按下 Esc 或者Alt + F4 来结束程序,这时 Visual Studio 的调试窗口中会显示下列信息。

图像说明文字

最后 3 行提示代码中没有 delete,显示有 3 处内存忘记释放。如果读者在代码中使用了 new操作,很有可能也会出现这个警告。现在程序的规模较小,警告的数量不多,但如果是大规模的游戏,这样的列表中就可能会包含成百上千个警告。不过,现实中这些问题也未必非改不可。毕竟程序已经退出,不会面临内存不足的问题,而且操作系统在程序结束时会清理释放其占有的内存,所以不至于出现内存枯竭的情况。

遗憾的是,我们不能保证所有的机器都会这样处理。世界上有那么多种计算机,说不准哪种机器就不支持这种机制。这么说来,还是有必要学习程序的结束机制,在代码中严格执行释放处理。

另外,有时也可能需要在游戏内终止程序运行。目前的代码中也没有实现这一点,无法做到按下 q 键后终止程序运行。

下面我们就来介绍一下结束处理。后面出现的类库中也都会采用相同的处理。

2.5.1  结束处理函数

Framework类中添加了如下两个函数。

图像说明文字

requestEnd()函数用于向程序发送“可以结束了”的信号,调用该函数后程序将在下一次执行 update()之前结束运行。

后面的 isEndRequested()函数用于检测 requestEnd()是否被调用了。程序调用requestEnd()后 isEndRequested()肯定会返回 true,不过按下“Alt + F4”或者“×”按钮后也会返回 true。也就是说,Framework::update()中的每一帧都会调用它来检测是否按下了“×”按钮。如果检测到按下了,则释放所有创建的对象。

考虑到这一点,我们可以像下面这样改写代码。

图像说明文字

在一般的游戏逻辑处理结束后,如果满足游戏的结束条件,就会通过 requestEnd()发送结束请求,如果探测到有结束请求,程序就会调用 endGame()函数来销毁游戏中的所有对象。另外,如前所述,鼠标点击“×”按钮时,即使没有在代码中调用 requestEnd(),isEndRequested()也可能会返回 true。

  •  示例代码

添加了结束处理的示例代码如 NimotsuKunBoxWithTermination 所示。在该示例代码中,按下 “q”键后回车,游戏将退出,如果收到了“Alt + F4”这样的外部请求,程序也会执行 delete操作。美中不足的是,“×”按钮的结束处理并没有实现,在以后的类库中我们再解决这个问题。

和之前的版本相比,该版本只修改了 mainLoop()函数。可以看到,前一版本在结束时出现的内存泄漏报告在这一版本中不再出现了。

2.5.2  结束处理的必要性

读者可能会问:“家用游戏机也必须执行这种结束处理吗?” 反正每次结束游戏时都是直接将电源切断就完了,好像也没出现什么问题。

遗憾的是,以现在的情况来看,答案是肯定的。

现代的游戏机大多在操作系统上运行,要结束游戏,除了切断电源之外还有很多方法。读者一定见过很多游戏主机的控制手柄中间有一个特别的按钮,它和游戏处理无关,只有当需要结束游戏时才发挥作用。作用类似于 Windows 上的“×”按钮,按下后将执行和计算机游戏相似的结束处理。

2006 年之后的电视游戏机都是这样设计的,一收到结束请求,就必须马上结束游戏。保险起见,需要保证在每个阶段都能响应请求,包括在加载过程中和初始化过程中。虽说实现的难度因游戏机而不同,但至少会比使用本书提供的类库方法麻烦得多。

话虽如此,读者在实际开发正式的游戏之前可能还不太容易理解,但目前也只能先把这部分内容储备起来。在使用其他类库开发游戏时,也务必养成编写结束处理的习惯。

2.6 本章小结

本章通过打印小点来呈现《箱子搬运工》的游戏画面。虽然现在看起来只是一堆小点,但是无论多么高级的画面,从程序上来说,一个点和一张图的处理在本质上都是相同的。

本章还试着使用笔者提供的类库进行了开发。今后我们还会多次执行项目设置,到时可能还要参考本章开头的内容。

另外我们还在补充内容中学习了结束处理,不过读者跳过这一部分内容也没有关系。下一章开始的示例代码中将会包含这部分内容,不过代码很少,不用担心。

下一章我们将尝试让画面变得更高级。我们将学习一种新的绘制方法,用这种方法输出的画面质量是通过逐个打点无论如何也无法企及的。

目录

  • 译者序
  • 前言
  • 第1部分 2D 游戏
  • 第1章 第一个游戏
  • 第2章 从像素开始学习2D 图形处理
  • 第3章 使用图片素材
  • 第4章 实时游戏
  • 第5章 简单的状态迁移
  • 第6章 文本绘制方法
  • 第7章 动作游戏初体验
  • 第8章 2D 平面内的碰撞处理
  • 第9章 各种输入设备 
  • 第10章 状态迁移详解
  • 第11章 播放声音
  • 第12章 旋转、缩放与平移
  • 第13章 显卡的力量
  • 第2部分 3D 游戏
  • 第14章 绘制立体物体
  • 第15章 类库的封装方法
  • 第16章 伪XML 文件的读取
  • 第17章 编写高性能的代码
  • 第18章 3D 碰撞处理
  • 第19章 《机甲大战》的设计
  • 第20章 光照
  • 第21章 角色动画 
  • 第3部分 通往商业游戏之路
  • 第22章 高效的碰撞检测 
  • 第23章 数据加载
  • 第24章 float 的用法 
  • 第25章 随书类库概要
  • 第26章 bug 的应对方法
  • 第27章 进阶方向