OpenGL-游戏开发示例-全-
OpenGL 游戏开发示例(全)
原文:
zh.annas-archive.org/md5/5fc2c021fc9e2359584d749d61c98781译者:飞龙
前言
欢迎来到 OpenGL 游戏开发蓝图!我们很高兴您选择这本书作为您学习 OpenGL 和游戏开发的指南。本节将为您简要介绍每一章的内容,随后将介绍完成书中所述工作所需的技术。最后,我们将讨论本书的目标读者,以便您了解这本书是否适合您。
本书涵盖的内容
第一章,构建基础,指导您创建游戏代码框架。游戏使用一种称为 游戏循环 的特定结构。到本章结束时,您将理解并创建游戏的游戏循环,以及初始化所需的 OpenGL 元素。
第二章,你的视角,介绍了本书的第一个项目——创建一个 2D 平台游戏。这个项目的第一步将是定义 OpenGL 所需的视图类型,并渲染游戏的背景。
第三章,角色问题,涵盖了在屏幕上移动的精灵的创建。基于帧的 2D 动画是任何 2D 游戏的核心,您将学习如何创建简单的图形并将它们渲染到屏幕上。
第四章,控制狂,教您如何构建一个输入系统,让您能够控制主要角色和游戏的其它方面。您还将创建一个基本用户界面,让您能够开始游戏并导航到各种选项。
第五章,碰撞检测,涵盖了碰撞检测的内容。您将学习如何阻止角色穿过地面,如何落在物体上,以及如何检测敌人是否被您或玩家的武器击中。到本章结束时,您将能够第一次玩这款游戏。
第六章,抛光银色,涵盖了使游戏看起来更吸引人的主题(但往往被新手开发者忽视)。您将学习如何实现得分系统、游戏结束和游戏胜利场景,以及简单的关卡进度。本章将结束本书的 2D 项目。
第七章,音频肾上腺素,指导您在游戏中实现音效和音乐。我们将提供一些音频文件的链接,您可以在游戏中使用。
第八章,拓展你的视野,将开始本书的第二个项目——一个 3D 第一人称太空射击游戏。到本章结束时,您将创建一个新的项目,为 3D 游戏搭建框架。
第九章,超级模型,向您介绍了 3D 艺术和建模的概念,然后引导您将 3D 模型加载到游戏环境中。尽管您将有机会尝试创建 3D 模型,但游戏所需的资源将在线提供。
第十章,扩展空间,扩展了书中 2D 部分所涵盖的许多概念,并将它们应用于 3D 世界。移动和碰撞检测被重新设计以考虑这个新维度。实现了在 3D 空间中移动的输入方案。到本章结束时,您将能够在 3D 空间中控制 3D 模型。
第十一章,抬头看,将引导您在 3D 世界之上创建 2D 用户界面。您将创建一个菜单系统来开始和结束游戏,以及一个显示游戏得分和统计信息的抬头显示(HUD)。到本章结束时,您将创建一个可玩的三维射击游戏。
第十二章,征服宇宙,向您介绍了一些超出本书范围的高级概念,并为您提高技能提供了方向。
您需要这本书的
书中的每一章都会有您需要编写的练习。每个练习都是创建您的第一个 OpenGL 游戏的基础。实际上编写代码至关重要。根据我们的经验,不实际编写代码就无法学习任何类型的计算机编程。不要只是阅读这本书,要动手做这本书!
书的第一章将详细介绍设置开发环境的细节,以便您可以在书中编写示例代码。一般来说,您需要以下内容:
-
基于 Windows 的个人电脑:您可以使用 Mac,但本书中的示例是基于 Windows 10 操作系统的。
-
Visual Studio 的副本:我们将在第一章中向您展示如何免费获取和安装它,或者您现在就可以直接访问
www.visualstudio.com/downloads/download-visual-studio-vs。再次提醒,您也可以使用其他开发工具和编译器,但您需要自行设置。 -
一个二维图像编辑程序:我们推荐使用 GIMP,您可以在
www.gimp.org/免费下载。 -
一个三维建模程序:我们推荐使用 Blender,您可以在
www.blender.org/免费下载。 -
互联网连接:您可以在没有互联网连接的情况下完成练习,但互联网连接对于查找额外资源非常有用。
-
一些空闲时间和投入!
就这样!好消息是,只要您有一台个人电脑,用于使用 OpenGL 创建游戏的技术和工具都是完全免费的!
本书面向的对象
如果您正在阅读这本书,那么很明显您对游戏开发感兴趣。您可能听说过 OpenGL,或者甚至使用过它,并希望了解更多。最后,您已经是某种计算机语言的程序员,或者您想成为程序员。
这听起来像您吗?继续阅读!
本书假设您对 C++计算机语言有一定的了解。如果您在其他语言中编程,例如 C#、Java、JavaScript 或 PHP,那么您对 C++语言的构造相当熟悉。尽管如此,如果您从未使用过 C++编程,那么您可能需要复习一下您的技能。您可以尝试阅读由 Packt Publishing 出版的《Microsoft Visual C++ Windows Applications by Example》。如果您对编程总体上感到舒适,但尚未使用 C++进行编码,您可以在www.cplusplus.com/doc/tutorial/查看免费的在线 C++教程。
我们不假设您对 OpenGL 有任何了解——这正是本书要提供的。我们从解释 OpenGL 的基本概念开始,并通过示例逐步介绍更高级的概念。在学习的过程中,您还将进行编码,这为您提供了将所学知识付诸实践的机会。本书不会让您一夜之间成为 OpenGL 专家,但它会为您理解和使用 OpenGL 打下基础。本书结束时,我们将为您提供一些指向其他资源的指南,这些资源将帮助您学习更多关于 OpenGL 的知识。
我们也不假设您有任何开发游戏的经验。本书的独特之处在于它为您提供了学习 OpenGL 和游戏开发的入门指南。市面上有许多教 OpenGL 的书籍,但大多数都是在更学术或理论框架内进行。我们认为,在您使用 OpenGL 创建实际游戏的同时学习 OpenGL 会更好。实际上,您将编写两个游戏:一个 2D 游戏和一个 3D 游戏。一价两得!
约定
在本书中,您会发现许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称的显示方式如下:“像往常一样,在 update 中更改中间行以调用drawQuad。”
代码块设置如下:
void CheckCollisions()
{
if (player->IntersectsRect(pickup))
{
pickup->IsVisible(false);
pickup->IsActive(false);
player->SetValue(player->GetValue() + pickup->GetValue());
pickupSpawnTimer = 0.0f;
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
pause->Update(p_deltaTime);
resume->Update(p_deltaTime);
pickup->Update(p_deltaTime);
SpawnPickup(p_deltaTime);
CheckCollisions();
}
新术语和重要词汇将以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,将以文本中的这种形式出现:“对于配置下拉框,请确保您选择了所有配置。”
注意
警告或重要提示将以这样的框显示。
小贴士
小贴士和技巧将以这样的形式出现。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
如要向我们发送一般反馈,请简单地将电子邮件发送至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南 www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有多个方面可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在 www.packtpub.com 的账户下载所有已购买 Packt 出版物的示例代码文件。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下现有的任何勘误列表中。
要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。
第一章. 建立基础
建立一个游戏就像建造一栋房子。只不过这是一栋疯狂的房子,房间到处突出,任何时候都可能有人决定在这里添加一个房间,或者在那里移除一个房间。您最好有一个良好的基础!
本章将带您了解建立游戏基础的过程。您将学习如何使用 Visual Studio 设置开发环境。接下来,您将设置游戏循环,这是每个游戏的基础。最后,您将设置开发环境以使用 OpenGL 作为您的渲染引擎。
介绍开发环境
开发环境是您用来编辑、编译和运行程序的工具集。市面上有许多开发工具;有些工具是经过美化的文本编辑器,而其他则是集成到单个应用程序中的完整工具套件。这些更高级的套件被称为集成开发环境(IDEs)。
微软的 Visual Studio 是迄今为止最广泛使用的 IDE,好消息是您可以免费获取和使用它。请访问 www.visualstudio.com/en-us/products/visual-studio-express-vs.aspx 并按照链接下载 Visual Studio Community 的最新版本,之前被称为 Visual Studio Express。Visual Studio Community 不是试用版,也不会过期。您可能会看到提供的 Visual Studio 试用版,所以请确保您下载的是免费版本的 Visual Studio Community。
Visual Studio 提供了多种编程语言。本书我们将使用 C++。当您第一次使用 Visual Studio 时,您可能被要求选择您想要为哪个语言设置开发环境。我建议您选择 C++ 设置。然而,即使您选择了不同的默认编程语言,您仍然可以使用 Visual Studio 进行 C++ 编程。
当本书编写时,Visual Studio Community 2013 是当前版本。书中所有截图均来自该版本。在您拿到这本书时,很可能已经推出了 Visual Studio 的后续版本。从一种版本到另一种版本,一般功能保持不变,所以这不应该成问题。如果您使用的是 Visual Studio 的不同版本,那么一些命令的确切位置可能不会与本书中的截图相同。
小贴士
微软区分了为 Windows 桌面编写的程序和为 Windows 全平台编写的程序。请确保您下载适用于桌面的 Visual Studio Community Express。
当您第一次启动 Visual Studio 时,您将需要选择一些选项,因此我想在这里介绍一下:
-
如果您被问及希望将哪种编程语言设置为默认的开发环境,实际上您选择哪种语言都无关紧要。如果您认为您会大量使用 C++,那么请选择 C++。如果您选择其他语言作为默认语言,您仍然可以使用 C++进行编码。
-
您将被要求登录到您的 Microsoft 账户。如果您曾经使用过 MSN、Hotmail 或 Windows Messenger,那么您已经有一个 Microsoft 账户。无论如何,如果您没有 Microsoft 账户,您可以使用自己的电子邮件地址创建一个,而且这不会花费您任何费用。
-
您可能需要为 Windows 设置一个开发者许可证。只需点击我同意,它就会完成。再次强调,无需付费!
快速查看 Visual Studio
由于 Visual Studio 可以做很多事情,因此第一次使用时可能会有些令人畏惧。我已经使用 Visual Studio 超过 20 年了,仍然有一些部分我从未使用过!让我们看看以下截图中的关键组件,这些组件您将每天都会用到:

启动屏幕
启动屏幕,如前一张截图所示,允许您快速启动新项目或打开现有项目。您最近工作的项目可以从最近项目列表中快速访问。
解决方案资源管理器面板
解决方案资源管理器面板允许您导航并处理项目中所有的代码和其他资源。如果您在屏幕上没有看到解决方案资源管理器窗口,请点击视图 | 解决方案资源管理器。

从这个窗口中,您可以:
-
双击任何项目以打开它
-
右键单击以将现有项目添加到项目中
-
右键单击以将新项目添加到项目中
-
创建文件夹以组织您的代码
标准工具栏面板
标准工具栏面板包含执行最常见任务的按钮:
-
保存当前文件
-
保存所有已修改的文件
-
撤销和重做
-
运行程序
小贴士
运行您的程序基本上有两种方式。您可以选择带或不带调试来运行程序。调试模式允许您设置检查点,使程序停止并让您查看变量的状态,以及在代码运行时执行其他操作。如果您不带调试运行程序,您将无法执行这些操作。

代码窗口
IDE 的中心是代码窗口。这是您输入和编辑代码的地方。您可以同时打开多个代码窗口。每个代码窗口都会在顶部添加一个标签,让您可以通过单次点击在各个代码片段之间切换:

你会注意到文本是彩色的。这允许你轻松地看到不同类型的代码。例如,前一个屏幕截图中的代码注释是绿色的,而 C++对象是蓝色的。你还可以通过按住Ctrl按钮并使用鼠标滚轮来放大和缩小代码。
输出窗口
输出窗口通常位于 IDE 的底部。这个窗口是你查看当前运行状态的地方,也是你在尝试编译运行程序时查找错误的地方。
如果你看到输出窗口中的错误,通常可以双击它,Visual Studio 会带你到导致错误的代码行:

开始你的项目
是时候停止阅读并开始动手了!我们将使用 Visual Studio 来开始我们的游戏项目。
-
打开 Visual Studio,并在启动窗口中点击新建项目链接。
-
导航到左侧面板,并在模板下的Visual C++分支中选择Win32。
![开始你的项目]()
-
在中间区域选择Win32 项目。
-
给项目起一个名字。我们将要工作的第一个游戏是一个名为
RoboRacer2D的 2D 机器人赛车游戏。 -
选择一个文件夹位置来存储项目,或者直接保留默认位置。
-
解决方案名称几乎总是与项目名称相同,所以保持原样。
-
保持为解决方案创建目录复选框选中。
-
点击确定。
-
在下一屏幕上点击完成。
我们需要告诉 Visual Studio 如何处理 Unicode 字符。在解决方案资源管理器面板中右键单击项目名称,并选择属性。然后选择常规。将字符集属性更改为未设置。
恭喜!你现在已经创建了一个 Windows 应用程序并设置了你的开发环境。现在是时候继续创建你的游戏框架了。
游戏循环
游戏循环是推动游戏时间前进的主要机制。在我们学习如何创建这个重要组件之前,让我们简要地看看大多数游戏的结构。
游戏结构
大多数游戏有三个阶段:初始化阶段、游戏循环和关闭阶段。任何游戏的内核都是游戏循环。

游戏循环是一系列在游戏运行期间持续运行的进程。游戏循环中发生的三个主要进程是输入、更新和渲染。
输入过程是玩家控制游戏的方式。这可以是键盘、鼠标或控制板的任何组合。新技术允许游戏通过检测手势的感应设备来控制,而移动设备可以检测触摸、加速度甚至 GPS。
更新过程包括更新游戏所需的所有任务:计算角色和游戏对象的位置变化,确定游戏中的物品是否发生碰撞,以及应用游戏中的物理和其他力。
在完成前面的计算后,接下来就是绘制结果的时候了。这被称为渲染过程。OpenGL 是处理你游戏渲染的代码库。
小贴士
许多人认为 OpenGL 是一个游戏引擎。这并不准确。OpenGL——开放的图形语言——是一个渲染库。正如你所见,渲染只是游戏执行过程中涉及的一个过程。
让我们更详细地看看游戏的每个阶段,以便我们更好地了解 OpenGL 是如何融入其中的。
初始化
游戏中有些部分必须在游戏运行之前只设置一次。这通常包括初始化变量和加载资源。OpenGL 的一些部分也必须在这一阶段进行初始化。
游戏循环
初始化完成后,游戏循环接管。游戏循环实际上是一个无限循环,它会一直循环,直到有东西告诉它停止。这通常是由玩家告诉游戏结束。
为了创造运动的错觉,渲染阶段必须每秒发生几次。一般来说,游戏努力每秒至少渲染 30 帧到屏幕上,每秒 60 帧(fps)则更好。
小贴士
结果是,24 fps 是人的眼睛开始看到连续运动而不是单独帧的阈值。这就是为什么我们希望我们的游戏最慢的速度是 30 fps。
关闭
当游戏结束时,仅仅退出程序是不够的。占用宝贵计算机内存的资源必须被正确释放以回收内存。例如,如果你为图像分配了内存,你希望在游戏结束时释放该内存。OpenGL 必须被正确关闭,以免继续控制图形处理单元(GPU)。游戏的最后阶段是将控制权交还给设备,以便它在正常、非游戏模式下继续正常工作。
创建游戏结构
现在我们已经在 Visual Studio 项目中创建了RoboRacer2D项目,让我们学习如何修改此代码以创建我们的游戏结构。启动 Visual Studio 并打开我们刚刚创建的项目。
你现在应该看到一个包含代码的窗口。代码文件的名称应该是RoboRacer2D.cpp。如果你看不到这个代码窗口,那么找到解决方案资源管理器,导航到RoboRacer2D.cpp,并打开它。
我会第一个承认 Windows C++代码既丑陋又令人畏惧!当您选择 Windows 桌面模板创建项目时,Visual Studio 会为您创建大量的代码。实际上,您现在就可以通过从菜单栏中选择调试然后选择开始调试来运行此代码。您也可以按F5键。
好吧,就按这样做吧!

您将看到一个窗口告诉您项目已过时。这仅仅意味着 Visual Studio 需要处理您的代码并将其转换为可执行文件——这个过程称为构建项目。对于计算机科学专业的学生来说,这是您的代码被编译、链接并由操作系统执行的地方。
点击是继续。

恭喜!您现在已经在 Visual Studio 中创建并运行了您的第一个程序。它可能看起来不多,但这里有很多事情在进行中:
-
一个可全尺寸调整和移动的窗口
-
一个带有文件和帮助选项的工作菜单系统
-
带有RoboRacer2D标题栏
-
工作中的最小化、最大化和关闭按钮
请记住,您还没有编写任何一行代码!
现在您已经看到了,请随意使用关闭按钮关闭窗口并返回 Visual Studio。
但是等等,这看起来不像一个游戏!
如果您认为 RoboRacer2D 程序看起来不像一个游戏,您是对的!实际上,为了制作一个游戏,我们通常会移除现在看到的大部分内容。然而,在这个演示中,我们将保持窗口不变,更多地关注代码而不是外观。
端口访问
每个程序都有一个起点,对于 Windows 程序来说,入口点是tWinMain函数。寻找以下代码行:
int APIENTRY wWinMain
_wWinMain函数将开始运行,并设置运行 Windows 桌面程序所需的一切。本书的范围不包括这里所发生的一切。我们只是假设我们正在查看的代码设置了在 Windows 中运行的环境,我们将专注于我们需要修改以制作游戏的部分。
Windows 消息循环
结果表明_wWinMain已经设置了一个循环。与游戏类似,Windows 程序实际上在一个无限循环中运行,直到它们收到某种事件告诉它们停止。以下是代码:
// Main message loop:
while (GetMessage(&msg, nullptr, 0, 0))
{
if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
如您所见,这些代码行设置了一个 while 循环,该循环将一直运行,直到GetMessage调用的结果为false。
同样,我们不会担心具体的细节,但可以说GetMessage会不断检查由 Windows 发送的消息或事件。一条特定的消息是退出事件,它将返回一个结果为 false,结束while循环,退出tWinMain函数,并结束程序。
我们的目标是修改 Windows 消息循环,并将此代码块转换为游戏循环:
StartGame();
//Game Loop
bool done = false;
while (!done)
{
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
{
done = true;
}
else
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
else
{
GameLoop();
}
}
EndGame();
研究前面的代码。你会看到我们添加了三个新的函数:StartGame、GameLoop 和 EndGame。
-
StartGame在 Windows 消息循环之前,这意味着StartGame中的所有内容都会在 Windows 进入其循环之前运行一次。我们将所有的游戏初始化代码放在StartGame函数中。 -
EndGame在 Windows 消息循环之后。这意味着EndGame中的代码只有在 Windows 消息循环退出后才会执行一次。这是我们释放资源并关闭游戏的完美地方。 -
GameLoop与 Windows 消息循环交织在一起。基本上,代码是在说,“继续运行,直到你收到退出 Windows 消息。当你运行时,检查 Windows 是否传递了需要处理的事件。如果没有要处理的消息,那么运行我们的游戏。”
提示
顺序很重要。例如,你必须在这些函数在 wWinMain 函数之前声明。这是因为它们会被 wWinMain 调用,所以它们必须在 tWinMain 使用它们之前存在。一般来说,一个函数必须在使用它的代码之前声明。
为了使这些新函数有效,请转到 _tWinMain 之前的行,并为这三个函数输入一些存根:
void StartGame()
{
}
void GameLoop()
{
}
void EndGame()
{
}
这里的想法是帮助你看到将标准的 Windows 消息循环转换为游戏循环是多么容易。
介绍 OpenGL
我们已经花费了很多时间来谈论游戏循环和 Visual Studio。我们终于要讨论这本书的主要内容:OpenGL!
什么是 OpenGL?
OpenGL 使得在计算机屏幕上渲染复杂的 2D 和 3D 图形成为可能。实际上,OpenGL 也是大多数移动设备和平板电脑背后的技术。
OpenGL 与你的设备的图形设备协同工作,在屏幕上绘制图形。大多数现代计算设备有两个处理器:中央处理单元(CPU)和图形处理单元(GPU)。
绘制现代 2D 和 3D 图形是一个非常耗处理器的任务。为了释放计算机的主处理器(CPU)来完成其工作,GPU 承担了将渲染到屏幕上的任务。OpenGL 是一种语言,告诉 GPU 要做什么以及如何做。
提示
技术上,OpenGL 是一个 API,或者应用程序编程接口。另一种理解方式是,OpenGL 是一个代码库,一旦你在代码中包含了适当的头文件,你就可以访问它。OpenGL 有不同的版本。这本书使用的是 OpenGL 1.1。尽管这是 OpenGL 的第一个版本,但它包含在所有版本的 Windows 中,并为所有未来的版本提供了构建块。
另一个 GL
顺便说一句,你可能听说过“其他”图形引擎——微软的 DirectX。与 OpenGL 类似,DirectX 允许程序员与 GPU 通信。很多人想知道 OpenGL 和 DirectX 之间的区别,以及哪个是最好的选择。
虽然肯定会有 DirectX 和 OpenGL 的支持者和捍卫者,但 DirectX 和 OpenGL 之间唯一的真正区别是你编写它们的具体方式。在功能和能力方面,这两种技术大致相同。
OpenGL 相对于 DirectX 有一个优势。DirectX 只适用于 Microsoft 技术,而 OpenGL 适用于 Microsoft 技术以及许多其他技术,包括大多数现代手机和苹果 Mac 电脑系列。
下载 OpenGL
我记得我刚开始学习 OpenGL 的时候。我徒劳地搜索,寻找下载 OpenGL SDK 的链接。结果发现,你不必下载 OpenGL SDK,因为它在安装 Visual Studio 时就已经安装了。
你确实想确保你有最新的显卡 OpenGL 驱动程序。为此,请访问www.opengl.org/wiki/Getting_started#Downloading_OpenGL并遵循适当的链接。
将 OpenGL 添加到项目
为了在我们的程序中使用 OpenGL,我们需要添加一些代码。打开我们一直在工作的RoboRacer2D项目,让我们来做这件事!
链接到 OpenGL 库
你需要使用 OpenGL 的所有内容都在OpenGL32.dll库文件中。取决于你告诉 Visual Studio 你想要在项目中使用 OpenGL 库。
右键单击项目 | RoboRacer2D 属性。
小贴士
顺便说一下,Visual Studio 首先创建一个解决方案,然后在解决方案中添加一个项目。解决方案是解决方案资源管理器层次结构中的顶级条目,项目是第一个子项。在这种情况下,请确保您右键单击项目,而不是解决方案。

-
对于配置下拉框,请确保你选择所有配置。
-
打开配置属性分支,然后是链接器分支。
-
选择输入选项。
-
点击附加依赖项下拉菜单并选择<编辑…>。
-
在对话框窗口中输入
OpenGL32.lib并点击确定。![链接到 OpenGL 库]()
-
关闭属性页窗口。
即使你在编写 64 位应用程序,你也会使用 OpenGL 32 位库。
接下来,我们需要告诉 Visual Studio 你想要在程序中包含 OpenGL 头文件。如果你查看代码的顶部,你会看到已经加载了几个头文件:
#include "stdafx.h"
#include "RoboRacer2D.h"
在这些行下面,添加以下内容:
#include <Windows.h>
#include <gl\GL.h>
#include <gl\GLU.h>
小贴士
GL.h 是 OpenGL 库的主要头文件。GLU.h代表 GL Utility,是一个额外的功能库,使 OpenGL 更容易使用。这些头文件对应于我们添加到项目的OpenGL32.lib和Glu32.lib库。
恭喜!你已经设置了使用 OpenGL 的开发环境,你现在可以开始编写你的第一个游戏了。
摘要
在本章中,我们覆盖了大量的内容。我们学习了如何通过下载和安装 Visual Studio 来设置你的开发环境。接下来,我们创建了一个 C++ Windows 桌面应用程序。
我们讨论了大多数游戏的结构以及游戏循环的重要性。回想一下,一款普通游戏应该以 30 fps 的速度运行,而高端游戏则追求 60 fps 以提供流畅的动画。
最后,我们学习了 OpenGL 以及如何在项目中初始化 OpenGL。记住,OpenGL 是负责利用 GPU 的强大功能将每个图像和文本绘制到屏幕上的图形引擎。
在完成所有这些工作之后,仍然没有太多可以展示的内容。在下一章中,我们将详细介绍如何将第一张图像渲染到屏幕上。信不信由你,正确设置你的开发环境意味着你已经为使用 OpenGL 创建你的第一款游戏迈出了重要的一步。
第二章. 你的视角
想象一下你正在制作一个视频。你拿出手机,指向你想拍摄的区域,然后按下录制。你正在拍摄大峡谷的视频,所以你必须移动摄像头来捕捉整个场景。突然,一只鸟飞过了视野,你捕捉到了整个场景。
上述场景基本上就是游戏的工作方式。游戏有一个虚拟摄像头,它可以被定位,甚至可以移动。与你的手机上的视频摄像头类似,游戏摄像头只能看到游戏世界的一部分,所以有时你必须移动它。任何在摄像头前移动的游戏对象都会被玩家看到。
本章将解释游戏中的渲染方式。渲染是将图像实际显示在屏幕上的过程。为了将你的游戏显示在屏幕上,你需要对以下术语有一个扎实的理解:
-
坐标系:坐标系是允许你在游戏中定位对象的参考系
-
原语:原语是你在屏幕上看到的图像的基本构建块,OpenGL 被设计用来与它们一起工作
-
纹理:纹理是用于给你的游戏中的对象赋予逼真外观的图像文件
到你阅读完这一章时,你将了解如何使用图像来构建你的游戏世界并在屏幕上显示它。
绘制你的复仇计划
好吧,你并不是真的在绘制你的复仇计划。但你是在像在一张图纸上放下所有东西一样,在你的游戏中绘制一切。你还记得高中几何吗?你拿出你的图纸,画了几条代表 X 轴和 Y 轴的线,然后在图上绘制了点。OpenGL 基本上以同样的方式工作。
OpenGL 坐标系
OpenGL 坐标系是一个标准的 X 轴和 Y 轴坐标系,你很可能一生都在学习它。你可以将(0, 0)概念化为屏幕的中心。
假设我们想在屏幕上显示一辆移动的汽车。我们可以从在坐标系中绘制汽车的位置(5, 5)开始。如果我们然后将汽车从(5, 5)移动到(6, 5),然后到(7, 5),以此类推,汽车就会向右移动(最终离开屏幕),如下面的图所示:

我们对你并不完全诚实。由于 OpenGL 是一个 3D 渲染引擎,实际上还有一个我们尚未讨论的 Z 轴。由于本书的这一部分专注于 2D 游戏编程,我们将暂时忽略 Z 轴。
陈述你的观点
随着我们学习每个概念,我们实际上会编写代码来演示每个要点。说到要点,我们将编写代码使用 OpenGL 绘制点。
我们将把这个项目设置为一个独立的项目,而不是实际的游戏项目。我们将使用这个项目来演示如何编写基本的 OpenGL 任务。为了使这个项目尽可能简单,这个项目将在 Visual Studio 中作为一个控制台项目创建。控制台项目没有完整 Windows 项目的大部分功能,因此,设置代码要小得多。
启动 Visual Studio 并创建一个新项目。在模板中,从Visual C++模板组中选择Win32 控制台应用程序。将项目命名为OpenGLFun,然后点击OK。点击Finish以完成项目向导。
小贴士
你应该注意到,代码比上一章为完整 Windows 应用程序创建的代码要简单得多。随着我们继续构建游戏,我们将回到使用更复杂的代码。

一旦创建了项目,将以下代码输入到代码窗口中:
#include "stdafx.h"
#include <windows.h>
#include "glut.h"
void initGL() {
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}
void drawPoints()
{
glBegin(GL_POINTS);
glColor3f(1.0f, 1.0f, 1.0f);
glVertex2f(0.1f, -0.6f);
glVertex2f(0.7f, -0.6f);
glVertex2f(0.4f, -0.1f);
glEnd();
}
void update()
{
glClear(GL_COLOR_BUFFER_BIT);
drawPoints();
glFlush();
}
int _tmain(int argc, _TCHAR* argv[])
{
glutCreateWindow("GL Fun");
glutInitWindowSize(320, 320);
glutInitWindowPosition(50, 50);
glutDisplayFunc(update);
initGL();
glutMainLoop();
return 0;
}
理解代码
由于我们将使用此代码来演示 OpenGL 的基本原理,我们将详细查看它,以便你理解代码正在做什么。
头文件
此代码使用三个头文件:
-
stdafx.h:此头文件加载 Visual Studio 在创建项目时创建的预编译头文件 -
windows.h:此头文件允许创建渲染 OpenGL 内容的窗口 -
glut.h:此头文件允许我们使用 OpenGL 实用工具包,它简化了 OpenGL 的设置和使用
小贴士
你需要下载 GLUT 文件并将它们放置在项目文件夹中。从www.javaforge.com/doc/105278下载文件。打开压缩文件,将glut.h、glut32.dll和glut32.lib复制到包含你的源代码的文件夹中。你可能需要将glut.h添加到你的项目中(右键单击Header files | Add | Existing item)。
初始化 OpenGL
你会注意到一个名为initGL的函数。此函数目前包含一行代码,其唯一目的是在每一帧的开始设置屏幕的背景颜色。这通常被称为清除颜色,因为 OpenGL 在开始渲染其他项目之前会将背景清除到默认值:
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
括号内的四个数字定义了颜色及其透明度。前三个数字代表用于创建颜色的红色、绿色和蓝色(RGB)的量。第四个数字代表颜色的透明度(或者从另一个角度来看,透明度)。这通常被称为 alpha 通道(RGBA)。上面的值创建了一个 100%不透明的黑色背景。
OpenGL 中的所有值范围从 0 到 1。这意味着会有很多小数,在 C++中称为浮点数。因此,在 C++术语中,范围是从0.0f到1.0f。
C++与许多使用整数甚至十六进制数来表示其范围的许多语言不同。例如,许多其他语言使用 0 到 255 的范围来表示每个颜色分量。在这些情况下,整数 0 对应于0.0f,整数 255 对应于1.0f。
小贴士
要将范围在 0 到 255 之间的整数转换为 OpenGL 的系统,使用公式(1/255) * value,其中value是要转换的整数值。因此,要将数字 50 转换为,您将计算(1/255) * 50,结果为 0.1096。
主入口点
每个程序都必须有一个起始点,称为入口点。在我们的程序中,这是_tmain函数。我们将其放在代码的最后,因为 C++期望被调用的函数在调用它们的函数之前已经定义。关于这一点有各种技巧,但我们将保持示例简单,并始终将_tmain定义为代码中的最后一个函数。
当我们启动程序时,有一些事情必须做来设置 OpenGL 渲染的环境。以下是_tmain函数的结构:
-
glutCreateWindow("GL Fun"): 这个函数创建了一个用于渲染 OpenGL 内容的窗口。我们将程序名称作为参数包含在内。 -
glutInitWindowSize(320, 320): 这个函数初始化窗口的大小。我们指定了 320 像素乘以 320 像素。您可以自由尝试更大的(或更小的)窗口大小。 -
glutInitWindowPosition(50, 50): 这个函数设置窗口左上角相对于设备屏幕的位置。在这种情况下,窗口将从屏幕左侧和顶部各开始绘制 50 像素。您可以自由尝试其他位置。 -
glutDisplayFunc(update): 记得之前章节中我们讨论的游戏循环吗?游戏循环是程序中反复运行的部分(即每一帧)。我们需要告诉 GLUT 我们想要在每一帧运行的函数的名称。在这种情况下,我们告诉 GLUT 使用名为update的函数(将在下一节中描述)。 -
initGL(): 这只是调用我们之前描述的initGL函数。 -
glutMainLoop(): 这个函数启动主游戏循环,它反过来会每帧调用我们的update函数。这实际上启动了我们的程序,它将在无限循环中运行,直到我们关闭程序。 -
return 0: 这一行是_tmain函数所必需的。它基本上告诉我们的系统程序已退出且一切正常。此行代码将在我们退出程序之前不会运行。
更新函数
更新函数在每一帧都会被调用。我们想要执行的所有工作都必须在这个函数中编码。更新函数目前有三行代码:
-
glClear(GL_COLOR_BUFFER_BIT):glClear函数将 渲染缓冲区 重置为之前由glClearColor函数指定的颜色。渲染缓冲区是内存中一个独立的位置,OpenGL 在对象显示在屏幕上之前在这里渲染对象。稍后,当所有的渲染操作完成后,缓冲区的内容将快速传输到屏幕上。 -
drawPoints(): 这是一个我们编写的函数,用于在屏幕上显示三个点。稍后,我们将替换这一行代码以绘制其他对象。该函数将在下一节中描述。 -
glFlush(): 这个函数刷新 OpenGL 缓冲区,包括当前持有我们的渲染的后缓冲区。结果,渲染缓冲区被刷新,所有内容都被渲染到设备屏幕上。提示
OpenGL 使用两个缓冲区进行绘制。一个是屏幕缓冲区,这是玩家目前在计算机显示器上看到的。另一个是后缓冲区,这是我们打算在下个帧中渲染的对象所在的地方。一旦我们在后缓冲区中完成创建渲染,我们就快速将后缓冲区的内容交换到当前屏幕上。这个过程发生得如此之快,以至于玩家无法检测到交换。
绘制点
drawPoints 函数执行确定绘制什么以及在哪里绘制的实际工作。以下是每行代码的作用:
-
glBegin(GL_POINTS):glBegin调用告诉 OpenGL 准备将项目渲染到屏幕上。我们还告诉 OpenGL 我们想要渲染什么。在我们的示例中,我们指导 OpenGL 解释我们发送给它的数据作为单独的点。稍后,我们将学习如何使用GL_TRIANGLES绘制三角形或使用GL_QUADS绘制矩形来渲染其他对象。 -
glColor3f(1.0f, 1.0f, 1.0f): 如其名所示,glColor设置将要渲染的项目颜色。记住,OpenGL 使用 RGB 颜色系统,所以颜色将是白色(指定为黑色的是 0, 0, 0)。 -
glVertex2f(0.1f, -0.6f): 在 OpenGL 中,每个点都称为 顶点。这段代码告诉 OpenGL 在坐标 (0.1, -0.6) 处渲染一个单独的点。在这种情况下,零表示屏幕中心,一表示从中心的一个单位。相机的设置决定了从中心的一个单位实际上在屏幕上有多远。在我们的示例代码中,有三个glVertex调用,每个调用对应于我们想要渲染到屏幕上的一个点。提示
OpenGL 函数的命名为你如何使用该函数提供了线索。例如,
glVertex2f表示这个函数接受 2 个参数,并且它们将是float类型。相比之下,glVertex3f函数接受三个float类型的参数。 -
glEnd(): 就像所有美好的事物都必须有个结束一样,我们必须告诉 OpenGL 我们何时完成渲染。这就是调用glEnd的目的。提示
你可能已经注意到大量使用了小写字母 f;这代表 float,意味着一个可能包含小数部分的数字(与总是整数的 integer 相反)。所以,一个如
0.0f的数字告诉 C++ 将数字零视为浮点数。OpenGL 使用类似的命名约定为其函数命名。例如,函数glVertex2f表示该函数需要两个浮点数(在这种情况下,要渲染的点的 x 和 y 坐标)。
运行程序
现在你已经输入了代码,是时候看到它的实际效果了。当你运行程序(调试 | 开始调试)时,你会看到以下内容:

你需要仔细观察,但如果一切顺利,你应该能在屏幕的右下角看到三个白色点。恭喜!你已经渲染了你的第一个 OpenGL 对象!
希望你已经能够跟随代码。将 _tmain 视为一个管理者,通过设置一切并调用 main 循环来控制程序(就像我们将在我们的游戏中做的那样)。然后 GLUT 接管并每帧调用 update 函数。update 函数初始化渲染缓冲区,将对象绘制到渲染缓冲区,然后将渲染缓冲区的内容传输到屏幕。在一个每秒运行 60 帧的游戏中,这个整个操作每秒会发生 60 次!
拉伸你的点
让我们看看修改 GLFun 以绘制其他对象有多容易。这次我们将画两条线。在 drawPoints 函数下方添加以下函数:
void drawLines()
{
glBegin(GL_LINES);
glColor3f(1.0f, 1.0f, 1.0f);
glVertex2f(0.1f, -0.6f);
glVertex2f(0.7f, -0.6f);
glVertex2f(0.7f, -0.6f);
glVertex2f(0.4f, -0.1f);
glEnd();
}
接下来,进入 update 函数并将 drawPoints 替换为对 drawLines 的调用。新的 update 函数将看起来像这样:
void update()
{
glClear(GL_COLOR_BUFFER_BIT);
drawLines();
glFlush();
}
你会注意到有四个 glVertex 调用。每一对顶点设置了一条线的起始和结束点。由于定义了四个点,因此结果是画出了两条线。

获取原语
基本对象,如点和线,被称为原语。如果只用点和线来创建一切将会非常困难,所以 OpenGL 定义了其他原语形状,你可以使用它们来创建更复杂的对象。
在本节中,我们将深入底层,了解 OpenGL 如何在屏幕上创建更逼真的图像。可能会让你惊讶的是,一个单一的几何图形被用来创建从最简单到最复杂的图形。所以,卷起袖子,准备变得有点油腻。
任何名字的三角形
你见过地理圆顶吗?尽管圆顶看起来是球形的,但它实际上是由三角形的组合构成的。结果是三角形很容易组合在一起,这样你就可以在物体上添加一点曲率。每个三角形可以以轻微的角度附着在其他的三角形上,这样你就可以用平面的三角形制作一个圆顶。此外,考虑这一点:三角形越小,最终结果就越令人信服!

用来绘制所有现代图形的基本单位是谦逊的三角形。图形卡被特别设计成能够快速绘制三角形——非常小的三角形。典型的图形卡每秒可以绘制数百万个三角形。高端卡每秒可以达到数十亿个三角形。

记得我们之前画点和线的时候吗?每个点有一个顶点,每条线有两个顶点。当然,每个三角形有三个顶点。
一个原始示例
是时候看看一些代码的实际效果了。在 GLFun 项目中 drawLines 函数之后添加以下代码:
void drawSolidTriangle()
{
glBegin(GL_TRIANGLES);
glColor3f(0.0f, 0.0f, 1.0f);
glVertex2f(0.1f, -0.6f);
glVertex2f(0.7f, -0.6f);
glVertex2f(0.4f, -0.1f);
glEnd();
}
然后将 update 函数的中间行更改为调用 drawSolidTriangle:
void update()
{
glClear(GL_COLOR_BUFFER_BIT);
drawSolidTriangle();
glFlush();
}
运行程序,你会看到以下输出:

你可能会注意到 drawSolidTriangle 和 drawPoints 的代码之间有相似之处。仔细查看代码,你会看到三个 glVertex 函数定义了相同的三个点。然而,在这种情况下,我们告诉 OpenGL 绘制三角形而不是点。你也应该查看代码,确保你理解为什么三角形被渲染成蓝色。
让我们再看一个例子。在 drawSolidTriangle 函数下面添加以下代码:
void drawGradientTriangle()
{
glBegin(GL_TRIANGLES);
glColor3f(1.0f, 0.0f, 0.0f);
glVertex2f(0.3f, -0.4f);
glColor3f(0.0f, 1.0f, 0.0f);
glVertex2f(0.9f, -0.4f);
glColor3f(0.0f, 0.0f, 1.0f);
glVertex2f(0.6f, -0.9f);
glEnd();
}
一定要在 update 函数的中间行中更改为调用 drawGradientTriangle:
void update()
{
glClear(GL_COLOR_BUFFER_BIT);
drawGradientTriangle();
glFlush();
}
运行程序,这就是你将看到的内容:

你会立刻注意到这个三角形填充的是渐变色而不是纯色。如果你仔细查看代码,你会看到每个顶点都被设置了不同的颜色。OpenGL 然后负责在每个顶点之间插值颜色。
从三角形到模型
三角形可以以无数种方式组合在一起,形成几乎任何可以想象到的形状。重要的是要理解三角形仅仅是几何学的一部分。三角形被用来构建物体的形状。我们把这些形状称为模型。
一次构建一个三角形来构建模型会非常耗时,因此 3D 图形程序,如Maya和Blender,允许你创建更复杂的形状(这些形状本身是由三角形构成的)模型。然后可以将这些模型加载到你的游戏中,并由 OpenGL 渲染。OpenGL 实际上将形成这些三角形的点列表直接发送到显卡,然后显卡在屏幕上创建图像。当我们开始处理 3D 游戏设计时,我们将看到这个过程在实际中的应用。
介绍纹理
游戏中的图像被称为纹理。纹理允许我们使用现实世界的图像来绘制我们的世界。想想要创建一条土路需要什么。你可以选择用正确的颜色为三角形上色,使整个场景看起来像土,或者你可以将实际的土图像(即纹理)应用到三角形上。你认为哪种方式看起来更逼真?
使用纹理填充三角形
假设你打算粉刷你的卧室。你可以选择用油漆给墙壁上色,或者你可以买一些壁纸并将其贴在墙上。使用图像为我们的三角形添加颜色,基本上就像用壁纸给我们的卧室墙壁上色一样。图像被应用到三角形上,使其外观比仅用颜色所能创造出的更加复杂:

当我们想要变得非常巧妙时,我们使用纹理来填充三角形的内部而不是颜色。前一张图像中的三角形已经应用了大理石纹理。你可以想象使用这种技术来创建大理石地板。
记得我们之前一直在处理的汽车吗?它看起来并不像三角形,对吧?事实上,许多现实世界中的物体看起来更像矩形而不是三角形:

结果表明,我们在游戏中使用的所有纹理实际上都是矩形。想象一下,我们一直在处理的汽车实际上嵌入在一个不可见的矩形中,如下面的图像所示,以浅灰色表示:

大多数图形程序使用棋盘格背景来指示图像中透明的区域。

使用矩形来绘制我们所有的形状解决了你可能之前没有考虑过的一个大问题。如果你还记得,将汽车放置在精确的(5,5)位置非常重要。为了做到这一点,我们决定将汽车的左下角放置在点(5,5)。

看着汽车,实际上很难确定左下角的确切位置。是保险杠的左下角、轮胎还是其他地方?

正如我们刚才讨论的那样,通过将汽车嵌入矩形中,问题立即得到解决。

参考问题
在处理纹理时,知道用作参考的点非常重要,通常被称为旋转中心点。在以下图像中,一个黑点被用来表示旋转中心点。旋转中心点影响两个关键问题。首先,旋转中心点决定了图像将在屏幕上的确切位置。其次,旋转中心点是图像旋转时围绕的点。
比较以下图像中描绘的两个场景:

前面图像中汽车的旋转中心点被设置为图像的左下角。汽车已经逆时针旋转了 90 度。

前面图像中汽车的旋转中心点被设置为图像的中心。汽车已经逆时针旋转了 90 度。注意旋转中心点不仅影响汽车的旋转方式,还影响旋转完成后与原始位置的关系。
在广场上闲逛
那么,你现在是不是感到困惑了?首先,我告诉你,用于创建图像的最基本形状是三角形,然后我告诉你,所有纹理实际上都是矩形。哪一个是正确的?
就在这时,你的高中几何老师默默地走进教室,走到刚刚神奇地出现在你墙上的黑板前,画出了以下这样的图:

当然!你突然意识到两个三角形可以组合在一起形成一个矩形。事实上,这种排列非常有用,以至于我们给它起了一个名字:四边形。
当涉及到 2D 图形时,四边形是王者。
编码四边形
是时候看看一些代码了。在GLFun中的drawGradientTriangle函数下方添加以下代码:
void drawQuad()
{
glBegin(GL_QUADS);
glColor3f(0.0f, 1.0f, 0.0f);
glVertex2f(0.1f, -0.1f);
glVertex2f(0.1f, -0.6f);
glVertex2f(0.6f, -0.6f);
glVertex2f(0.6f, -0.1f);
glEnd();
}
如往常一样,将更新中的中间行改为调用drawQuad。运行程序,你将得到一个漂亮的绿色正方形,或者说是一个四边形!需要注意的是,点是从左上角开始定义的,然后逆时针顺序移动。

小贴士
点的定义顺序被称为环绕。默认情况下,逆时针环绕告诉 OpenGL,面向外的面被认为是正面。这有助于确定许多事情,例如是否应该照亮这个面。当我们开始 3D 工作时,这一点变得更加重要。实际上,GLUT 简化了我们的工作,使得在使用 GLUT 时,无论是顺时针还是逆时针环绕都不重要。
渲染纹理
渲染纹理包括两个步骤:加载图像和使用 OpenGL 原语渲染图像。我们本章的最终目标将是修改 GLFun,使其能够使用四边形渲染纹理。
加载纹理
我们的第一步是创建一个加载纹理的函数。实际上,这并不那么容易。所以,我将给你一个加载 24 位 BMP 文件的函数代码,我们将它视为一个黑盒,你可以在自己的代码中使用它。
将此代码添加到现有GLFun代码的顶部:
GLuint texture;
#pragma warning(disable: 4996)
bool loadTexture(const char* filename)
{
unsigned char header[54];
unsigned char* data;
int dataPos;
int width;
int height;
int imageSize;
FILE * file = fopen(filename, "rb");
if (!file) return false;
if (fread(header, 1, 54, file) != 54) return false;
if (header[0] != 'B' || header[1] != 'M') return false;
dataPos = *(int*)&(header[0x0A]);
imageSize = *(int*)&(header[0x22]);
width = *(int*)&(header[0x12]);
height = *(int*)&(header[0x16]);
if (imageSize == 0) imageSize = width*height * 3;
if (dataPos == 0) dataPos = 54;
data = new unsigned char[imageSize];
fread(data, 1, imageSize, file);
fclose(file);
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
return true;
}
将以下代码行添加到initGL中:
glEnable(GL_TEXTURE_2D);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
我们不会逐行分析这段代码。简而言之,它打开图像文件,提取文件的前 54 个字节(bmp 头数据),并将文件的其余部分作为图像数据存储。进行了一些 OpenGL 调用,将这些数据分配给 OpenGL 纹理,然后完成。
你需要有一个调用加载纹理的函数,所以请将此行代码添加到_tmain中,在调用initGL之后:
loadTexture("car.bmp");
当然,将car.bmp替换为你想要加载的文件。确保你已经将适当的图形文件放置在源代码文件夹中。
纹理包裹
为了在屏幕上显示纹理,OpenGL 将纹理映射到另一个原语上。这个过程被称为纹理包裹。由于纹理是矩形的,将其映射到四边形上是有意义的。
以下图像显示了 OpenGL 如何看到纹理:一个具有四个纹理坐标的矩形:

左上角是纹理坐标0, 0。右下角是纹理坐标1, 1。你应该能够识别其他角落的纹理坐标。
小贴士
如果将 OpenGL 数字转换为百分比可能会更容易理解,其中 0 表示零百分比,1 表示 100 百分比。例如,你可以将左下角视为纹理宽度的零百分比和纹理高度的百分之百。
为了渲染纹理,我们将其(或包裹)叠加到四边形上。所以,假设我们定义了以下四边形:

我们可以将纹理坐标映射到四边形坐标:
| 纹理坐标 | 映射到 | 四边形坐标 |
|---|---|---|
| 0, 0 | 0, 0 | |
| 1, 0 | 1, 0 | |
| 1, 0 | 1, 0 | |
| 0, 1 | 0, 1 |
以下图示展示了这一点:

在最简单的情况下,纹理包裹是将纹理的角落映射到四边形的角落的过程。
小贴士
你将看到纹理包裹也被称为uv包裹。我一直试图弄清楚uv代表什么!以下是真实的故事:x和y已经被用来指代四边形坐标,我们需要有其他东西来称呼纹理坐标,所以某个聪明的人说:“让我们用 u 和 v!”
创建纹理四边形
现在,我们将编写代码来渲染一个纹理四边形。将以下函数添加到代码中:
void drawTexture()
{
glBindTexture(GL_TEXTURE_2D, texture);
glBegin(GL_QUADS);
glTexCoord2d(0.0, 0.0); glVertex2d(0.0, 0.0);
glTexCoord2d(1.0, 0.0); glVertex2d(0.5, 0.0);
glTexCoord2d(1.0, 1.0); glVertex2d(0.5, 0.5);
glTexCoord2d(0.0, 1.0); glVertex2d(0.0, 0.5);
glEnd();
}
这段代码的作用如下:
-
glBindTexture(GL_TEXTURE_2D, texture): 即使在一个游戏中我们有成千上万的纹理,OpenGL 一次也只能处理一个纹理。glBindTexture的调用告诉 OpenGL 我们现在正在使用哪个纹理。每次创建纹理时,OpenGL 都会为该纹理分配一个数字,称为纹理句柄。当我们加载位图时,我们使用了
glGenTextures(1, &texture)命令,该命令指示 OpenGL 生成一个纹理并将句柄保存到名为 texture 的变量中。然后我们将此值传递给glBindTexture函数,同时传递一个标志告诉 OpenGL 我们正在处理一个 2D 纹理。 -
glTexCoord2d(0.0, 0.0); glVertex2d(0.0, 0.0): 我们将这两行放在一起,因为它们是协同工作的。你应该能认出glVertex2d的调用。这个函数告诉 OpenGL 如何将纹理包裹到四边形上(你也应该能认出我们正在绘制一个四边形,因为我们已经在上一行代码中设置了这一点)。 -
每次调用
glTexCoord2d定义一个纹理坐标。紧接着的下一行代码将纹理坐标映射到四边形坐标。顺序至关重要:首先定义纹理坐标,然后定义相应的四边形坐标。
顺便说一句,别忘了在 update 中的中间代码行替换为以下代码行:
drawTexture();
现在,运行程序!

将碎片组合在一起
以下图像是一个组合,展示了我们迄今为止所涵盖的大部分概念。看看你是否能识别以下内容:
-
透明区域
-
三角形
-
顶点
-
轴心点
-
纹理
-
四边形

概述
本章介绍了在屏幕上显示图像所需的核心概念。我们首先讨论了 2D 游戏的 OpenGL 坐标系。坐标系允许你在屏幕上放置对象。随后讨论了相机,这是 OpenGL 查看屏幕上出现的对象的方式。
接下来,你学习了如何使用三角形和四边形来创建简单的图形,以及如何将这些纹理应用到这些原语上,以将 2D 图像渲染到屏幕上。
你最终可以在屏幕上看到 OpenGL 渲染的图像。正如他们所说,一张图片胜过千言万语!
在下一章中,你将学习如何通过动画的奇妙之处将你的静态摄影变成动态画面!
第三章。角色问题
没有角色,视频游戏就不会有趣。本章全部关于让您的游戏角色栩栩如生。游戏通常有两种角色。首先,有你扮演的角色或角色。这些被称为玩家角色。由计算机控制的角色被称为非玩家角色或 NPC。
本章将解释如何为您的游戏创建角色。在这个过程中,我们将涵盖:
-
小精灵:小精灵是玩家在游戏中与之交互的任何纹理。这包括玩家角色、NPC 和游戏中的其他物体。
-
动画:使图像看起来像是在移动的艺术称为动画。您将学习如何使用多个图像使您的纹理在屏幕上移动。
-
图集:图像可以逐个存储,也可以组合成单个复合纹理,称为精灵图集或图集。
小精灵式地说
多年前,一位计算机爱好者发明了一种酷炫的方法,在计算机屏幕上渲染和显示小图像。这些图像会在屏幕上移动,甚至与其他物体发生碰撞。这位计算机爱好者将这些图像称为小精灵,这个名字一直沿用至今。
小精灵与无小精灵
小精灵只是代表屏幕上对象的图像。小精灵的例子包括角色、NPC、武器、外星太空船和岩石。任何可以在屏幕上移动或被游戏中的其他物体击中的对象都是小精灵。不与其他物体交互的对象不是小精灵。例子可能包括背景中的山脉、地面和天空。
显然,要实现一个游戏,既需要小精灵也需要无小精灵。此外,这种区分有点任意。有些游戏将游戏中的所有图像都实现为小精灵,因为将游戏中的所有图像以一致的方式处理更为方便。
翻页动画
你小时候有没有制作过翻页书?为了唤起你的记忆,这里是如何工作的。首先,你在笔记本上画了一个简单的图形。然后你翻到下一页,画了同样的图像,但这次有一点不同。你继续在连续的页面上画一些与原始图像略有不同的图像。当你完成时,你翻动笔记本边缘的页面,看到了看似是一部原始的电影。

另一个例子是电影。电影以帧的形式记录在胶片上。然后,胶片通过放映机播放,放映机逐帧播放电影。如前所述,关键是至少以每秒 24 帧的速度播放帧,以欺骗眼睛,使其认为有流畅的运动。
帧动画
2D 精灵动画的工作方式与翻页书类似。艺术家绘制图像的连续版本。当这些图像依次渲染时,它们看起来就像在移动。动画中的每一张图像被称为一帧。至少需要 24 或更多 fps 才能创建一个令人信服的动画。显然,更多的帧将创建一个更平滑的动画。

上一张图像展示了一个使用四个帧的非常简单的动画。唯一改变的是机器人的手臂位置。从帧 1到帧 4依次播放,手臂看起来会从前面摆动到后面,然后再向前摆动。如果将这个动作与将精灵向右移动结合起来,那么你将得到一个简单的行走机器人的动画。
小贴士
如前例所示,我并不是一个艺术家!我是一个程序员,所以这本书中创建的艺术将会非常简单。实际上,在游戏的初始阶段,使用非常简单的占位符艺术是很常见的。这允许程序员在艺术团队在后期阶段将真正的艺术作品放入游戏的同时测试游戏的功能。
创建精灵
专业 2D 艺术家使用 Adobe Photoshop 等程序为游戏创建 2D 资源。不幸的是,我们没有时间教你如何使用像 Photoshop 这样复杂的程序。
如果你想要尝试创建自己的资源,那么你可以尝试在任意基于 Windows 的电脑上安装的 Paint 程序。如果你真的想深入挖掘 2D 艺术创作而不需要深入挖掘你的银行账户,那么你可以下载 GIMP (www.gimp.org),这是一个免费的全功能 2D 图像处理程序。
使用 PNGs
在上一章中,我们加载并渲染了一个位图文件。结果发现,位图并不是处理精灵的最佳格式,因为它们比 PNGs 占用更多的文件空间(因此,更多的内存),并且位图不支持透明度。
小贴士
在我们有了允许直接将透明度编码为图像一部分的图像格式之前,我们使用特定的背景颜色,并期望我们的图像库在处理图像时移除该颜色。由于洋红色在图像中很少使用,因此经常被用作背景颜色。
位图文件的大小比 PNGs 大,因为它们不是以压缩格式存储的。压缩允许图像以更小的空间存储,这在设备上,如手机上,可能非常重要。
PNGs 使用无损压缩算法进行存储。无损意味着为了达到压缩效果,不会牺牲图像质量。其他格式,如 JPEG,可以以压缩格式存储,但使用的是有损算法,这会降低图像质量。
PNG 还支持使用alpha通道进行透明度。除了存储每个像素的红色、绿色和蓝色成分(RGB)外,PNG 还存储每个像素的透明度在 alpha 通道中(RGBA)。
您会记得,在前一章中,所有纹理在游戏中都表示为矩形。然而,真实形状并不是矩形的。树木、汽车和机器人都有更复杂的形状。
如果我们使用位图作为所有图像,那么纹理的全矩形将渲染,遮挡掉精灵后面的所有内容。在以下图像中,我们的机器人在管道前通过,管道的一部分被位图的空白区域遮挡。

在 PNG 图像中,我们将空白区域设置为透明。在以下图像中,管道不再被机器人的图像透明部分遮挡:

在上一章中,我们编写了加载 BMP 文件的代码。通常,我们不得不编写不同的代码来加载 PNG 文件。实际上,我们必须为每种我们想要处理的图像类型编写一个加载器。
幸运的是,有人已经做了所有这些工作,并将其作为一个名为SOIL(简单 OpenGL 图像库)的库提供。您可以从www.lonesock.net/soil.html下载您的副本。
使用 SOIL 库有几个优点:
-
我们不再需要担心为每种我们想要使用的图像类型编写自己的加载器。SOIL 支持 BMP、PNG 以及许多其他格式。
-
文件加载不是完全抽象的。您不必担心代码是如何工作的,只需知道它确实可以工作。
-
SOIL 具有其他可能有用的功能(例如,能够写入图像文件)。
下载文件以压缩文件夹的形式提供。一旦解压文件夹,您将看到一个名为Simple OpenGL Image Library的文件夹。这个文件夹包含很多文件,但我们只需要soil.h。
链接到 SOIL 库
现在,是时候将 SOIL 库添加到我们的项目中了:
-
找到您解压 SOIL 代码的文件夹。
-
打开
lib文件夹,找到libSOIL.a。 -
将
libSOIL.a复制到包含RoboRacer2D源代码的文件夹中。 -
打开RoboRacer2D项目。
-
在解决方案资源管理器面板中右键单击RoboRacer2D项目,并选择属性。
-
对于配置下拉框,请确保您选择了所有配置。
-
打开配置属性分支,然后是链接器分支。
-
选择输入选项。
-
点击附加依赖项下拉菜单,选择<编辑…>。
-
在对话框窗口中分别输入
opengl32.lib和glu32.lib,然后点击确定。![链接到 SOIL 库]()
小贴士
Windows 的库文件通常以 .lib 结尾,而为 UNIX 编写的文件以 .a 结尾。标准的 SOIL 发行版附带 UNIX 库;您需要使用 Windows 库。您可以在网上找到 SOIL.lib,使用 SOIL 源代码创建自己的 Windows 库文件,或者从本书的网站上下载 SOIL.lib。
包含 SOIL 头文件
接下来,我们需要将 SOIL 头文件复制到我们的项目中,并在代码中包含它:
-
找到您解压 SOIL 代码的文件夹。
-
打开
src文件夹并找到SOIL.h。 -
将
SOIL.h复制到包含 RoboRacer2D 源代码的文件夹。 -
打开 RoboRacer2D 项目。
-
打开
RoboRacer2D.cpp。 -
将
#include "SOIL.h"添加到包含列表中。
小贴士
您会注意到 SOIL 包中还有许多其他文件被解压出来。这包括所有原始源文件以及几个如何使用库的示例。
打开图像文件
现在,我们准备编写一个加载图像文件的函数。我们将传递文件名,该函数将返回一个表示 OpenGL 纹理句柄的整数。
以下代码行使用 SOIL 加载图像:
GLuint texture = SOIL_load_OGL_texture
(
imageName,
SOIL_LOAD_AUTO,
SOIL_CREATE_NEW_ID,
0
);
所有工作都是由 SOIL_load_OGL_texture 调用完成的。这四个参数是最通用的设置:
-
第一个参数是图像文件的路径和文件名。
-
第二个参数告诉 SOIL 如何加载图像(在这种情况下,我们指示 SOIL 自动解决问题)。
-
第三个参数告诉 SOIL 为我们创建一个 OpenGL 纹理 ID。
-
如果使用,第四个参数可以设置为几个标志位,告诉 SOIL 执行一些自定义处理。我们目前没有使用这个,所以我们只发送一个
0。
我们将使用代码,例如这个,将图像加载到我们的 sprite 类中。
小贴士
如果您想查看所有可用的选项,请打开 SOIL.h 并阅读源代码注释。
编写精灵类代码
为了轻松地将精灵集成到我们的游戏中,我们将创建一个专门处理精灵的类。
让我们考虑我们想要的功能:
-
一组图像。
-
表示当前帧的索引。
-
一个变量,存储总帧数。
-
变量用于存储精灵当前 x 和 y 位置。对于这个游戏,这将图像的左上角。
-
一个变量,存储精灵当前速度的 x 和 y 分量(如果没有移动则为
0)。 -
存储图像宽度和高度的变量。注意,如果精灵有多个图像,它们必须都是相同的大小。
-
一个布尔值,告诉我们这个精灵是否与其他精灵发生碰撞。
-
一个布尔值,告诉我们这个精灵应该正常渲染还是翻转。
-
一个布尔值,告诉我们这个精灵现在是否可见。
-
一个布尔值,告诉我们这个精灵现在是否是活跃的。
除了这些属性外,我们还希望能够以几种方式操作精灵。我们可能需要添加以下方法:
-
向精灵添加图像
-
更新精灵的位置
-
更新精灵的动画帧
-
将精灵渲染到屏幕上
打开您的游戏项目,并添加一个名为 Sprite.cpp 的新类,以及一个名为 Sprite.h 的头文件。
小贴士
在 Visual Studio 中,在解决方案资源管理器窗格中右键单击 头文件 过滤器。然后选择 添加类。给类命名为 Sprite 并点击 添加。Visual Studio 将为您创建模板头文件和源代码文件。
使用以下代码为 Sprite.h:
#pragma once:
#include <gl\gl.h>
class Sprite
{
public:
struct Point
{
GLfloat x;
GLfloat y;
};
struct Size
{
GLfloat width;
GLfloat height;
};
struct Rect
{
GLfloat top;
GLfloat bottom;
GLfloat left;
GLfloat right;
};
protected:
GLuint* m_textures;
unsigned int m_textureIndex;
unsigned int m_currentFrame;
unsigned int m_numberOfFrames;
GLfloat m_animationDelay;
GLfloat m_animationElapsed;
Point m_position;
Size m_size;
GLfloat m_velocity;
bool m_isCollideable;
bool m_flipHorizontal;
bool m_flipVertical;
bool m_isVisible;
bool m_isActive;
bool m_useTransparency;
bool m_isSpriteSheet;
public:
Sprite(const unsigned int m_pNumberOfTextures);
~Sprite();
void Update(const float p_deltaTime);
void Render();
const bool AddTexture(const char* p_fileName, const bool p_useTransparency = true);
const GLuint GetCurrentFrame() {
if (m_isSpriteSheet)
{
return m_textures[0];
}
else
{
return m_textures[m_currentFrame];
}
}
void SetPosition(const GLfloat p_x, const GLfloat p_y) { m_position.x = p_x; m_position.y = p_y; }
void SetPosition(const Point p_position) { m_position = p_position; }
const Point GetPosition() { return m_position; }
const Size GetSize() const { return m_size; }
void SetFrameSize(const GLfloat p_width, const GLfloat p_height) {
m_size.width = p_width; m_size.height = p_height; }
void SetVelocity(const GLfloat p_velocity) { m_velocity = p_velocity; }
void SetNumberOfFrames(const unsigned int p_frames) { m_numberOfFrames = p_frames; }
const bool isCollideable() const { return m_isCollideable; }
void IsCollideable(const bool p_value) { m_isCollideable = p_value; }
void FlipHorizontal(const bool p_value) { m_flipHorizontal = p_value; }
void FlipVertical(const bool p_value) { m_flipVertical = p_value; }
void IsActive(const bool p_value) { m_isActive = p_value; }
const bool IsActive() const { return m_isActive; }
void IsVisible(const bool p_value) { m_isVisible = p_value; }
const bool IsVisible() const { return m_isVisible; }
void UseTransparency(const bool p_value) { m_useTransparency = p_value; }
};
我知道,代码很多!这是一个典型的面向对象类,由受保护的属性和公共方法组成。让我们看看这个类的功能:
-
#pragma once:这是一个 C++指令,告诉 Visual Studio 如果它们在多个源文件中包含,则只包含文件一次。小贴士
另一个选择是使用头文件保护:
#ifndef SPRITE_H #define SPRITE_H ...code... #endif如果
SPRITE_H已经被定义,这将阻止代码被包含。那么头文件已经被包含,并且不会重复包含。 -
我们在这个头文件中包含
gl.h,因为我们需要访问标准的 OpenGL 变量类型。 -
在课堂内部,我们定义了两个非常有用的结构:点和矩形。我们与点和矩形打交道如此频繁,以至于拥有简单结构来保存它们的值是有意义的。
-
成员变量如下:
-
m_textures是一个GLuint数组,将动态保存构成这个精灵的所有 OpenGL 纹理句柄。 -
m_textureIndex从零开始,每次向精灵添加纹理时都会递增。 -
m_currentFrame从零开始,每次我们想要前进动画帧时都会递增。 -
m_numberOfFrames存储组成我们动画的总帧数。 -
m_animationDelay是我们想要在动画帧前进之前经过的秒数。这允许我们控制动画的速度。 -
m_animationElapsed将保存自上次动画帧更改以来经过的时间。 -
m_position保存精灵的x和y位置。 -
m_size保存精灵的width和height。 -
m_velocity保存精灵的速度。较大的值将使精灵在屏幕上移动得更快。 -
m_isCollideable是一个标志,告诉我们这个精灵是否与屏幕上的其他对象发生碰撞。当设置为false时,精灵将穿过屏幕上的其他对象。 -
m_flipHorizontal是一个标志,告诉类在渲染时精灵图像是否应该水平翻转。这种技术可以通过重用单个纹理来保存纹理内存,用于左右移动。 -
m_flipVertical是一个标志,告诉类在渲染时精灵图像是否应该垂直翻转。 -
m_isVisible是一个标志,表示精灵是否当前在游戏中可见。如果设置为false,则精灵将不会被渲染。 -
m_isActive是一个标志,表示精灵当前是否激活。如果设置为 false,则不会更新精灵的动画帧和位置。 -
m_useTransparency是一个标志,它告诉精灵类是否在精灵中使用 alpha 通道。由于 alpha 检查成本较高,我们将其设置为 false,用于没有透明度(如游戏背景)的图像。
-
-
m_isSpriteSheet是一个标志,它告诉精灵类是否使用单个纹理来保存此精灵的所有帧。如果设置为true,则每个帧都作为单独的纹理加载。 -
接下来,我们有以下方法:
-
Sprite是一个构造函数,它接受一个参数p_numberOfTextures。我们必须告诉类在创建精灵时将使用多少纹理,以便为纹理动态数组分配正确的内存量。 -
~Sprite是类的析构函数。 -
将使用
Update来更新当前动画帧和精灵的当前位置。 -
将使用
Render来实际在屏幕上显示精灵。 -
AddTexture在精灵创建后使用,用于添加所需的纹理。 -
当精灵被渲染时,使用
GetCurrentFrame来确定要渲染的精灵帧。
-
-
剩余的方法只是访问器方法,允许您修改类属性。
接下来,让我们开始类的实现。打开Sprite.cpp并添加以下代码:
#include "stdafx.h"
#include "Sprite.h"
#include "SOIL.h"
Sprite::Sprite(const unsigned int p_numberOfTextures)
{
m_textures = new GLuint[p_numberOfTextures];
m_textureIndex = 0;
m_currentFrame = 0;
m_numberOfFrames = 0;
m_animationDelay = 0.25f;
m_animationElapsed = 0.0f;
m_position.x = 0.0f;
m_position.y = 0.0f;
m_size.height = 0.0f;
m_size.width = 0.0f;
m_velocity = 0.0f;
m_isCollideable = true;
m_flipHorizontal = false;
m_flipVertical = false;
m_isVisible = false;
m_isActive = false;
m_isSpriteSheet = false;
}
Sprite::~Sprite()
{
delete[] m_textures;
}
下面是实现代码的一些细节:
-
除了
stdafx.h和Sprite.h,我们还包含了SOIL.h,因为这是我们实际用来加载纹理的代码块。 -
Sprite构造函数:-
根据给定的
p_numberOfTextures动态为m_textures数组分配空间。 -
初始化所有其他类属性。请注意,大多数布尔属性都设置为
false。结果是,新创建的精灵将不会激活或可见,直到我们明确将其设置为激活和可见。
-
-
~Sprite析构函数释放了用于m_textures数组的内存。
我们将接下来实现Update、Render和AddTexture方法。
小贴士
你可能已经注意到,我在代码中的许多变量前都加上了m_或p_前缀。m_总是用来作为类属性(或成员变量)名称的前缀,而p_用来作为函数中用作参数的变量的前缀。如果一个变量没有前缀,它通常是一个局部变量。
创建精灵帧
我们已经讨论了如何通过绘制图像的多个帧来创建 2D 动画,每个帧都略有不同。必须记住的关键点是:
-
每个框架必须具有完全相同的尺寸
-
图像在框架中的放置必须保持一致
-
只有图像中应该移动的部分应该从一帧到另一帧发生变化。
保存每一帧
保存帧的一个技巧是将每个帧保存为其自己的图像。由于你最终将会有很多精灵和帧需要处理,因此为所有图像制定一个一致的命名约定非常重要。例如,对于之前展示的三个帧的机器人动画,我们可能会使用以下文件名:
-
robot_left_00.png -
robot_left_01.png -
robot_left_02.png -
robot_left_03.png -
robot_right_00.png -
robot_right_01.png -
robot_right_02.png -
robot_right_03.png
游戏中的每个图像都应该使用相同的命名机制。这将在编码动画系统时为你节省无尽的麻烦。
小贴士
你应该将所有图像保存在名为“resources”的文件夹中,该文件夹应与包含源文件的文件夹位于同一位置。
从单个纹理加载精灵
让我们看看加载每个帧都保存为单独文件的精灵的代码:
robot_right = new Sprite(4);
robot_right->SetFrameSize(100.0f, 125.0f);
robot_right->SetNumberOfFrames(4);
robot_right->SetPosition(0, screen_height - 130.0f);
robot_right->AddTexture("resources/robot_right_00.png");
robot_right->AddTexture("resources/robot_right_01.png");
robot_right->AddTexture("resources/robot_right_02.png");
robot_right->AddTexture("resources/robot_right_03.png");
关于前面代码的重要点:
-
我们创建了一个新的精灵类实例来存储信息。我们必须告诉精灵类为这个精灵分配 4 个纹理的空间。
-
我们首先存储每个帧的宽度和高度。在这种情况下,这恰好是构成这个精灵的每个纹理的宽度和高度。由于构成特定精灵的每个纹理都必须具有相同的尺寸,我们只需要调用一次。
-
然后我们存储这个精灵中的帧数。这似乎与我们在构造函数中指定的纹理数量重复。然而,正如你将在下一节中看到的,纹理的数量并不总是等于帧的数量。
-
我们现在将每个纹理添加到精灵中。精灵类会为我们分配必要的内存。
创建精灵图
存储精灵的另一种方法是使用精灵图。精灵图将特定动画的所有精灵存储在一个文件中。精灵通常组织成条带。

由于每个帧的尺寸相同,我们可以将每个帧在特定动画中的位置计算为从精灵图中的第一个帧的偏移量。
小贴士
你可以在www.varcade.com/blog/glueit-sprite-sheet-maker-download/下载一个名为GlueIt的小程序。这个小程序允许你指定几个单独的图像,然后它会将这些图像粘合到一个精灵图中。
加载精灵图
以下代码加载了一个存储为精灵图的精灵:
robot_right_strip = new Sprite(1);
robot_right_strip->SetFrameSize(125.0f, 100.0f);
robot_right_strip->SetNumberOfFrames(4);
robot_right_strip->SetPosition(0, screen_height - 130.0f);
robot_right_strip->AddTexture("resources/robot_right_strip.png");
这段代码与我们之前用来创建具有单个纹理的精灵的代码非常相似。然而,有一些重要的区别:
-
我们只需要为单个纹理分配空间,因为我们只加载一个纹理。这是使用精灵表的主要优势,因为加载单个大纹理比加载几个小纹理要高效得多。
-
再次强调,我们设置每个帧的宽度和高度。请注意,这些值与加载单个纹理时的值相同,因为重要的是每个帧的宽度和高度,而不是纹理的宽度和高度。
-
再次,我们存储这个精灵的帧数。这个精灵仍然有四个帧,尽管所有四个帧都存储在一个单独的图像中。
-
我们然后向精灵添加单个图像。
小贴士
当我们准备好渲染动画的每一帧时,精灵类将负责根据当前帧和每帧的宽度计算渲染精灵条的确切部分。
加载我们的精灵
以下代码显示了我们将用于将精灵加载到游戏中的完整代码。打开 RoboRacer2D 项目并打开 RoboRacer.cpp。首先我们需要包含 Sprite 头文件:
#include "Sprite.h"
接下来,我们需要一些全局变量来存储我们的精灵。在代码的变量声明部分添加此代码(在所有函数之前):
Sprite* robot_left;
Sprite* robot_right;
Sprite* robot_right_strip;
Sprite* robot_left_strip;
Sprite* background;
Sprite* player;
我们为游戏直到此点需要的每个精灵创建了指针:
-
一个用于将机器人向左移动的精灵
-
一个用于将机器人向右移动的精灵
-
一个用于背景的精灵
小贴士
为了让您更容易地处理两种类型的精灵,我为每个机器人方向定义了两个精灵。例如,robot_left 将定义由单个纹理组成的精灵,而 robot_left_strip 将定义由单个精灵表组成的精灵。通常情况下,你不会在单个游戏中使用两者!
现在,添加 LoadTextures 函数:
const bool LoadTextures()
{
background = new Sprite(1);
background->SetFrameSize(1877.0f, 600.0f);
background->SetNumberOfFrames(1);
background->AddTexture("resources/background.png", false);
robot_right = new Sprite(4);
robot_right->SetFrameSize(100.0f, 125.0f);
robot_right->SetNumberOfFrames(4);
robot_right->SetPosition(0, screen_height - 130.0f);
robot_right->AddTexture("resources/robot_right_00.png");
robot_right->AddTexture("resources/robot_right_01.png");
robot_right->AddTexture("resources/robot_right_02.png");
robot_right->AddTexture("resources/robot_right_03.png");
robot_left = new Sprite(4);
robot_left->SetFrameSize(100.0f, 125.0f);
robot_left->SetNumberOfFrames(4);
robot_left->SetPosition(0, screen_height - 130.0f);
robot_left->AddTexture("resources/robot_left_00.png");
robot_left->AddTexture("resources/robot_left_01.png");
robot_left->AddTexture("resources/robot_left_02.png");
robot_left->AddTexture("resources/robot_left_03.png");
robot_right_strip = new Sprite(1);
robot_right_strip->SetFrameSize(125.0f, 100.0f);
robot_right_strip->SetNumberOfFrames(4);
robot_right_strip->SetPosition(0, screen_height - 130.0f);
robot_right_strip->AddTexture("resources/robot_right_strip.png");
robot_left_strip = new Sprite(1);
robot_left_strip->SetFrameSize(125.0f, 100.0f);
robot_left_strip->SetNumberOfFrames(4);
robot_right_strip->SetPosition(0, screen_height - 130.0f);
robot_left_strip->AddTexture("resources/robot_left_strip.png");
background->IsVisible(true);
background->IsActive(true);
background->SetVelocity(-50.0f);
robot_right->IsActive(true);
robot_right->IsVisible(true);
robot_right->SetVelocity(50.0f);
player = robot_right;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(50.0f);
return true;
}
这段代码与之前向您展示的加载精灵的代码完全相同。它只是更全面:
-
LoadTexures加载游戏中需要的所有精灵(包括重复的 strip 版本,这样你可以看到使用精灵表与单个纹理之间的区别)。 -
SetPosition用于设置机器人精灵的初始位置。请注意,我们不对背景精灵这样做,因为它的位置从(0, 0)开始,这是默认值。 -
SetVisible和SetActive用于设置background精灵和robot_left_strip精灵为活动状态和可见状态。所有其他精灵都将保持非活动状态和不可见状态。
由于纹理的加载在游戏中只需要发生一次,我们将添加调用此操作的 StartGame 函数。修改 RoboRacer.cpp 中的 StartGame 函数:
void StartGame()
{
LoadTextures();
}
加载纹理的最后一步是在我们的精灵类中实现 AddTexture 方法。打开 Sprite.cpp 并添加以下代码:
const bool Sprite::AddTexture(const char* p_imageName, const bool p_useTransparency)
{
GLuint texture = SOIL_load_OGL_texture( p_imageName, SOIL_LOAD_AUTO, SOIL_CREATE_NEW_ID, 0 );
if (texture == 0)
{
return false;
}
m_textures[m_textureIndex] = texture;
m_textureIndex++;
if (m_textureIndex == 1 && m_numberOfFrames > 1)
{
m_isSpriteSheet= true;
}
else
{
m_isSpriteSheet = false;
}
m_useTransparency = p_useTransparency;
return true;
}
AddTexture 在创建新精灵后使用。它将所需的纹理添加到 m_textures 数组中。以下是它的工作原理:
-
p_imageName保存要加载的图像的名称和路径。 -
p_useTransparency用于告诉精灵类这个图像是否使用 alpha 通道。由于我们的大部分精灵都会使用透明度,所以这个值默认设置为true。然而,如果我们把p_useTransparency设置为false,那么任何透明度信息都将被忽略。 -
SOIL_load_OGL_texture完成了加载纹理的所有工作。这个调用的参数在本章前面已经描述过。请注意,SOIL 足够智能,可以根据文件扩展名加载图像类型。 -
如果纹理成功加载,
SOIL_load_OGL_texture将返回一个 OpenGL 纹理句柄。如果没有,它将返回0。通常,我们会测试这个值并使用某种错误处理,或者在任何纹理没有正确加载时退出。 -
由于
m_textures数组是在构造函数中分配的,我们可以简单地将纹理存储在m_textureIndex槽中。 -
然后我们增加
m_textureIndex。 -
我们使用一个小技巧来确定这个精灵是否使用精灵表或单个精灵。基本上,如果只有一个纹理但有很多帧,那么我们假设这个精灵使用精灵表,并将
m_isSpriteSheet设置为true。 -
最后,我们将
m_useTransparency设置为传入的值。这将在Render方法中稍后使用。
渲染
我们在创建精灵时做了很多工作,但直到我们实际使用 OpenGL 渲染精灵之前,什么都不会显示出来。渲染是针对游戏每一帧进行的。首先,调用Update函数来更新游戏状态,然后一切都会被渲染到屏幕上。
在游戏循环中添加渲染
让我们从在GameLoop RoboRacer.cpp 中添加对Render的调用开始:
void GameLoop()
{
Render();
}
在这一点上,我们只是在调用主要的Render函数(在下一节中实现)。每个可以绘制到屏幕上的对象也将有一个Render方法。这样,渲染游戏的调用将级联到游戏中每个可渲染对象。
实现主渲染函数
现在,是时候实现主要的Render函数了。将以下代码添加到RoboRacer.cpp中:
void Render()
{
glClear(GL_COLOR_BUFFER_BIT);
glLoadIdentity();
background->Render();
robot_left->Render();
robot_right->Render();
robot_left_strip->Render();
robot_right_strip->Render();
SwapBuffers(hDC);
}
小贴士
注意,我们首先渲染背景。在 2D 游戏中,对象将按照先来先渲染的原则进行渲染。这样,机器人总是会渲染在背景之上。
这是它的工作方式:
-
我们总是通过重置 OpenGL 渲染管线来开始我们的渲染周期。
glClear将整个颜色缓冲区设置为我们在初始化 OpenGL 时选择的背景颜色。glLoadIdentify重置渲染矩阵。 -
接下来,我们为每个精灵调用
Render。我们不在乎精灵是否实际上是可见的。我们让精灵类的Render方法做出这个决定。 -
一旦所有对象都渲染完毕,我们调用
SwapBuffers。这是一种称为双缓冲的技术。当我们渲染场景时,它实际上是在屏幕外的缓冲区中创建的。这样玩家就不会看到单独的图像,因为它们被合成为屏幕上的图像。然后,一个单独的SwapBuffers调用将离屏缓冲区快速复制到实际屏幕缓冲区。这使得屏幕渲染看起来更加平滑。
在精灵类中实现渲染
我们渲染链的最后一个步骤是在Sprite类中添加一个渲染方法。这将允许每个精灵将自己渲染到屏幕上。打开Sprite.h并添加以下代码:
void Sprite::Render()
{
if (m_isVisible)
{
if (m_useTransparency)
{
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
glBindTexture(GL_TEXTURE_2D, GetCurrentFrame());
glBegin(GL_QUADS);
GLfloat x = m_position.x;
GLfloat y = m_position.y;
GLfloat w = m_size.width;
GLfloat h = m_size.height;
GLfloat texWidth = (GLfloat)m_textureIndex / (GLfloat)m_numberOfFrames;
GLfloat texHeight = 1.0f;
GLfloat u = 0.0f;
GLfloat v = 0.0f;
if (m_textureIndex < m_numberOfFrames)
{
u = (GLfloat)m_currentFrame * texWidth;
}
glTexCoord2f(u, v); glVertex2f(x, y);
glTexCoord2f(u + texWidth, v); glVertex2f(x + w, y);
glTexCoord2f(u + texWidth, v + texHeight); glVertex2f(x + w, y + h);
glTexCoord2f(u, v + texHeight); glVertex2f(x, y + h);
glEnd();
if (m_useTransparency)
{
glDisable(GL_BLEND);
}
}
}
这可能是代码中较为复杂的部分之一,因为渲染需要考虑许多因素。精灵是否可见?我们正在渲染精灵的哪一帧?精灵应该在屏幕上的哪个位置渲染?我们是否关心透明度?让我们一步一步地分析代码:
-
首先,我们检查
m_visible是否为true。如果不是,我们跳过整个渲染过程。 -
接下来,我们检查这个精灵是否使用透明度。如果是,我们必须启用透明度。实现透明度的技术术语是混合。OpenGL 必须将当前纹理与屏幕上已有的内容进行混合。
glEnable(GL_BLEND)打开透明度混合。glBlendFunc的调用告诉 OpenGL 我们想要实现哪种混合类型。简单来说,GL_SRC_ALPHA和GL_ONE_MIUS_SRC_ALPHA参数告诉 OpenGL 允许背景图像通过精灵的透明部分可见。 -
glBindTexture告诉 OpenGL 我们现在想要使用哪个纹理。GetCurrentFrame的调用返回适当的 OpenGL 纹理句柄。 -
glBegin告诉 OpenGL 我们准备渲染特定项目。在这种情况下,我们正在渲染一个四边形。 -
接下来的两行代码根据存储在
m_position中的x和y值设置精灵的x和y坐标。这些值在glVertex2f调用中使用,以定位精灵。 -
我们还需要当前帧的
width和height,接下来的两行代码将它们存储为w和h以方便使用。 -
最后,我们需要知道我们将要渲染纹理的多少部分。通常,我们会渲染整个纹理。然而,在精灵图的情况下,我们只想渲染纹理的一部分。我们将在稍后更详细地讨论这一点。
-
一旦我们有了要渲染的位置、宽度和纹理部分,我们就使用
glTexCoord2f和glVertex2f的成对调用,将纹理的每个角落映射到四边形上。这一点在第二章你的视角中进行了详细的讨论。 -
glEnd的调用告诉 OpenGL 我们已完成当前渲染。 -
由于 alpha 检查计算成本较高,我们在渲染结束时通过调用
glDisable(GL_BLEND)将其关闭。
UV 贴图
UV 贴图在第二章 你的视角中进行了详细说明。然而,我们在这里将进行回顾,并看看它在代码中的实现方式。
按照惯例,我们将纹理的左侧坐标分配给变量u,将纹理的顶部坐标分配给变量v。因此,这种技术被称为uv贴图。
OpenGL 将纹理的原点视为uv坐标(0, 0),将纹理的最远延伸点视为uv坐标(1, 1)。因此,如果我们想渲染整个纹理,我们将从(0, 0)到(1, 1)映射整个范围到四边形的四个角。但是,假设我们只想渲染图像宽度的一半(但整个高度)。在这种情况下,我们将uv坐标的范围从(0, 1)到(0.5, 1)映射到四边形的四个角。希望你能想象这将只渲染纹理的一半。
因此,为了渲染我们的精灵图集,我们首先通过将m_textureIndex除以m_numberOfFrames来确定每个精灵帧的宽度。对于一个有四个帧的精灵,这将给出 0.25 的值。
接下来,我们确定我们处于哪个帧。以下表格显示了具有四个帧的精灵的每个帧的uv范围:
| 帧 | u | v |
|---|---|---|
| 0 | 0.0 to 0.25 | 0.0 to 1.0 |
| 1 | 0.25 to 0.5 | 0.0 to 1.0 |
| 2 | 0.5 to 0.75 | 0.0 to 1.0 |
| 3 | 0.75 to 1.0 | 0.0 to 1.0 |
由于我们的精灵图集是水平设置的,我们只需要关注从整个纹理中获取正确的u范围,而v的范围保持不变。
因此,以下是我们算法的工作方式:
-
如果精灵不是精灵图集,那么每个帧使用 100%的纹理,我们使用从(0,0)到(1,1)的 uv 值范围。
-
如果精灵基于精灵图集,我们通过将
m_textureIndex除以m_numberOfFrames来确定每个帧的宽度(texWidth)。 -
我们通过将
m_currentFrame乘以texWidth来确定起始的u值。 -
我们通过将
u+texWidth来确定u的范围。 -
我们将u映射到四边形的右上角,并将
u+texWidth映射到四边形的左下角。 -
v的映射是正常的,因为我们的精灵图集使用了纹理高度的 100%。
小贴士
如果你很难理解 UV 贴图,不要担心。我花费了多年的应用实践才完全理解这个概念。你可以尝试调整 uv 坐标来查看事物是如何工作的。例如,尝试设置.05、1 和 1.5,看看会发生什么!
一个额外的细节
我们需要仔细查看GetCurrentFrame的调用,以确保你理解这个函数的作用。以下是其实施方式:
const GLuint GetCurrentFrame()
{
if(m_isSpriteSheet)
{
return m_textures[0];
}
else
{
return m_textures[m_currentFrame];
}
}
这里正在发生的事情:
-
如果精灵是精灵图集,我们总是返回
m_textures[0],因为根据定义,索引0处只有一个纹理。 -
如果精灵不是一个精灵表,那么我们就返回索引
m_currentFrame处的纹理。m_currentFrame在精灵更新方法(定义在下面)中更新。
一个移动的例子
我们到目前为止创建的代码创建了一个包含我们的机器人和背景的基本场景。现在,是时候利用动画的力量让我们的机器人复活了。
动画实际上有两个组成部分。首先,由于我们将按顺序播放精灵的每一帧,所以精灵本身将看起来像是在动画。如果你使用为这本书制作的库存文件,你会看到机器人的眼睛和手臂在移动。
第二个组成部分是屏幕上的移动。这是机器人的水平移动和身体动作的组合,将产生令人信服的动画。
将更新添加到游戏循环中
与渲染一样,我们首先在 GameLoop 函数中添加一个 Update 调用。修改 RoboRacer.cpp 中的 GameLoop 函数:
void GameLoop(const float p_deltatTime)
{
Update(p_deltatTime);
Render();
}
我们现在有两个新功能:
-
我们添加了
p_deltaTime作为参数。这代表自上一帧以来经过的毫秒数。我们将在下一节中看到它是如何计算的。 -
我们添加了对主
Update函数的调用(定义在下一节)。游戏中的每个对象也将有一个Update方法。这样,更新游戏的调用将级联通过游戏中的每个对象。我们传递p_deltatTime,以便后续对Update的每次调用都知道游戏中经过的时间。
实现主 Update 调用
我们的首要任务是实现在 RoboRacer.cpp 中的 Update 函数。将以下函数添加到 RoboRacer.cpp 中:
void Update(const float p_deltaTime)
{
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
}
注意,我们向每个精灵调用 Update。在这个阶段,我们并不关心精灵是否真的需要更新。这个决定将在 Sprite 类内部做出。
小贴士
在一个真正的游戏中,我们可能有一个精灵数组,并且我们会通过遍历数组并对每个元素调用更新来更新它们。由于这个游戏使用的精灵很少,所以我为每个精灵单独编写了代码。
在精灵类中实现 Update
现在是时候在我们的 Sprite 类中实现 Update 方法了。这个方法执行了将精灵定位并更新精灵内部动画所需的所有工作。将以下代码添加到 Sprite.h 中:
void Sprite::Update(const float p_deltaTime)
{
float dt = p_deltaTime;
if (m_isActive)
{
m_animationElapsed += dt;
if (m_animationElapsed >= m_animationDelay)
{
m_currentFrame++;
if (m_currentFrame >= m_numberOfFrames) m_currentFrame = 0;
m_animationElapsed = 0.0f;
}
m_position.x = m_position.x + m_velocity * dt;
}
}
这段代码的作用如下:
-
为了方便,我们将
p_deltaTime存储到局部变量dt中。这在测试期间有时需要将dt的值硬编码时很有用。 -
接下来,我们测试
m_active。如果这个值为false,则跳过整个更新。 -
我们现在处理精灵的内部动画。我们首先将
dt添加到m_animationElapsed中,以查看自上次帧变化以来经过的时间。如果m_animationElapsed超过m_animationDelay,那么就是切换到下一帧的时候了。这意味着m_animationDelay的值越高,精灵的动画速度就越慢。 -
如果需要,我们增加
m_currentFrame,确保一旦我们超过了总帧数,就重置为0。 -
如果我们只是进行帧增量,我们也希望将
m_animationElapsed重置为0。 -
现在,我们根据
m_velocity和dt移动精灵。请查看下一节中关于使用 delta time 计算移动的详细信息。
角色移动
在这个游戏版本中,我们编程我们的机器人从左到右移动屏幕。使我们的角色移动的关键是速度属性。速度属性告诉程序每个游戏周期移动机器人多少像素。
由于帧来得很快,速度通常很小。例如,在一个以 60 fps 运行的游戏中,速度为 1 会使机器人在每个游戏帧中移动 60 像素。精灵可能移动得太快而无法交互。
使用 delta time
将速度设置为固定值有一个小问题。显然,有些计算机比其他计算机快。使用固定速度,机器人在较快的计算机上会移动得更快。这是一个问题,因为它意味着在较快的计算机上玩游戏的人必须玩得更好!
我们可以使用计算机的时钟来解决此问题。计算机跟踪自上一帧开始经过的时间。在游戏术语中,这被称为delta time,并将其分配给一个我们可以在Update循环中访问的变量:
void Update(float deltaTime);
在前面的函数定义中,deltaTime是一个浮点值。记住,我们的游戏通常以 60 fps 运行,所以deltaTime将是一个非常小的数字。
小贴士
当我们设置游戏以 60 fps 运行时,它很少以确切的那个速度运行。每个帧完成其计算所需的时间可能略有不同。delta time 告诉我们确切经过的时间,我们可以使用这些信息来调整事件的时间或速度。
让我们更详细地看看我们如何使用速度来定位我们的精灵:
m_position.x += m_velocity * dt;
我们将m_velocity乘以dt,然后将这个值加到当前位置。这种技术会根据自上一帧以来经过的时间自动调整速度。如果上一帧处理时间略短,那么机器人会移动得略少。如果上一帧处理时间略长,那么我们的机器人会移动得更远。最终结果是,机器人在更快和更慢的计算机上都能保持一致的移动。
小贴士
对于较慢的计算机,这可能会引起其他副作用,特别是关于碰撞检测。如果时间过长,那么精灵会移动得更远。例如,这可能导致精灵在碰撞检测之前穿过墙壁。
由于dt是一个非常小的数字,我们现在将不得不使用一个较大的数字作为速度。当前代码使用的是 50。当然,在完整游戏中,这个值将根据我们的机器人发生的情况而变化。
计算 delta time
我们已经有了所有代码,除了实际计算 delta time 的代码。为了计算游戏中每一帧经过的时间,我们必须:
-
存储帧前的時間。
-
存储帧后的时间。
-
计算两者之间的差异。
打开RoboRacer.cpp,并在调用StartGame之后直接添加以下代码:
int previousTime = glutGet(GLUT_ELAPSED_TIME);
注意,我们正在使用GLUT来获取当前经过的时间。每次调用glutGet(GLUT_ELAPSED_TIME)都会给我们自游戏开始以来经过的毫秒数。
小贴士
为了使用 GLUT,请记住将 OpenGLFun 项目中的glut.h、glut32.dll和glut32.lib复制到 RoboRacer2D 的源代码文件夹中。在 SpaceRacer2D.cpp 的顶部包含glut.h。
接下来,在调用GameLoop之前直接添加以下行:
int currentTime = glutGet(GLUT_ELAPSED_TIME);
float deltaTime = (float)(currentTime - previousTime) / 1000;
previousTime= currentTime;
GameLoop(deltaTime);
这里是我们所做的工作:
-
首先,我们捕获了当前经过的时间,并将其存储在
m_currentTime中。 -
我们通过从
m_previousTime中减去m_currentTime来计算自上一帧以来经过的时间。我们将这个值转换为秒,以便更容易处理。 -
然后将
previousTime设置为等于当前时间,以便我们有下一个计算的基准。 -
最后,我们修改了对
GameLoop的调用,以传递deltaTime的值。这将随后传递到游戏中每个Update调用。
翻转
今天的游戏可以在各种设备上创建和播放,从超级电脑到移动电话。每个设备都有自己的优点和缺点。然而,一个经验法则是,随着设备变得更小,其功能变得更加有限。
这些限制变得至关重要的一个领域是纹理内存。纹理内存是内存中存储正在游戏中使用的纹理的位置。特别是移动设备,其可用的纹理内存非常有限,游戏程序员必须非常小心,不要超过这个限制。
2D 游戏往往使用大量的纹理内存。这是因为每个动画的每一帧都必须存储在内存中,以便使 2D 图像栩栩如生。对于 2D 游戏来说,典型的情况是拥有数千帧的纹理需要加载到内存中。
几乎将所需的纹理内存量减半的一个简单方法是通过利用纹理翻转。简单来说,我们的机器人向左移动是我们机器人向右移动的镜像。而不是使用一组纹理向左移动,另一组纹理向右移动,我们可以在渲染时使用代码翻转纹理。

如果你想尝试一下,翻转将通过改变将精灵的uv坐标映射到纹理的方式来实现。
滚动背景
你可能想知道为什么我们将背景设置为精灵。毕竟,我们定义精灵为玩家在游戏中与之交互的对象,而背景基本上被机器人忽略。
将背景设置为精灵的主要原因是可以让我们以统一的方式处理所有纹理。这种做法的优势在于,我们可以将相同的属性应用到所有图像上。例如,如果我们决定想要我们的背景移动呢?
滚动背景在 2D 游戏中用于营造持续变化的背景印象。实际上,2D 横版滚动游戏被认为是一个独立的游戏类型。创建滚动背景有两个基本要求:
-
创建一个比屏幕更宽的大纹理。
-
为纹理分配一个速度,使其向侧面移动。
![滚动背景]()
超出屏幕宽度的纹理背景部分将不会被渲染。随着图像的移动,背景看起来像是向左或向右滑动。如果你将背景图像的速度设置为与玩家速度完全相同,那么当机器人向左或向右跑时,你会得到一个背景飞过的错觉。
由于我们已经将背景图像实现为精灵,我们唯一需要做的就是设置其速度。这已经在AddTextures的代码中完成了:
background->SetVelocity(-50.0f);
通过将背景速度设置为-50,当机器人向右移动时,背景向左滚动。
使用图谱
正如我之前提到的,纹理内存是你们的核心资源之一。实际上,由于所有纹理都需要为典型的 2D 游戏动画,内存不足是很常见的情况。与加载单个纹理相比,加载更大的纹理也耗时。因此,我们必须想出更有效地使用纹理内存的方法。
一种旨在将更多纹理压缩到更少空间中的常用技术被称为图谱化。纹理图谱的工作方式与本章前面描述的精灵图非常相似。我们不是将每个纹理作为单独的图像存储,而是将整个游戏的所有纹理打包到一个或多个称为图谱的纹理中。
正如其词义所示,图谱的工作方式与地图非常相似。我们只需要知道任何特定图像的位置,我们就可以在图谱中找到并提取它。每个图谱都由两部分组成:
-
包含所有图像的纹理
-
包含每个图像在图谱中位置的文本文件
如你所想,将成千上万的图像高效地打包进图谱,并手动跟踪每个图像在图谱中的位置几乎是不可能的。这就是为什么有程序为我们做这件事。
我使用一个名为Texture Atlas Generator的免费纹理图集工具。你可以从这里下载:www.gogo-robot.com/2010/03/20/texture-atlas-sprite-sheet-generator/。
本章不涉及详细的图集示例。如果你想自己探索,以下是你需要的步骤:
-
使用一个程序,例如刚才提到的程序,来创建你的图集。
-
将你的数据保存为 XML 文件。
-
编写一个类来解析之前步骤中保存的 XML(我建议从
www.grinninglizard.com/tinyxml/上的TinyXML开始)。 -
使用与精灵图集一起工作的代码,修改精灵类以能够从更大的纹理中的任意位置处理子纹理。
摘要
本章涵盖了大量的内容。你创建了一个新的类专门用于处理精灵。将这个类视为你为任何将要创建的游戏的实用工具箱的一个巨大部分。这个类处理了你在游戏中加载、移动和处理纹理作为对象所需的所有要求。
在下一章中,你将学习如何处理输入,并实际控制你的机器人。
第四章. 控制狂
大多数游戏都是设计成交互式的。这意味着玩家必须有一些方式来控制游戏中的发生的事情。在上一章中,你编写了显示机器人和在屏幕上移动它的代码。现在,你将控制机器人!
本章将解释如何实现一个输入系统来控制游戏角色,并与游戏交互。包括以下主题:
-
输入类型:与你的游戏互动有许多方式。通常,为 PC 编写的游戏依赖于鼠标和键盘。直接触摸输入现在已成为移动和平板设备的标准,不久的将来,每台 PC 也将拥有触摸屏显示器。我们将介绍在游戏中接收输入的最常见方法。
-
使用鼠标和键盘:在本节中,你将编写代码来接收鼠标和键盘的输入,以控制游戏和我们的友好机器人。
-
创建用户界面:除了控制我们的机器人外,我们还需要一种与游戏交互的方式。你将学习如何创建一个屏幕上的界面,允许你控制游戏和选择游戏选项。
-
控制角色:我们希望我们的机器人能够行走、奔跑、跳跃和玩耍!你将学习如何使用鼠标和键盘来控制你的机器人在屏幕上的移动。
一便士作为你的输入
很可能在你的生活中,你曾经参与过一场看似单方面的对话。另一方一直在说话,你似乎无法插话。过了一会儿,这样的对话变得相当无聊!
如果一个不允许任何 输入 的电脑游戏也会发生同样的事情。输入是一组允许你控制游戏的技术。有许多方法可以实现输入系统,我们将在本章中介绍。
键盘输入
对于大多数计算机来说,最常见的形式是键盘。显然,键盘可以用来输入文本,但键盘也可以用来直接控制游戏。
以下是一些例子:
-
使用右箭头、左箭头、上箭头和下箭头键来控制角色(我们将使用这种方法)
-
使用 W、A、S 和 D 键来移动角色(这些键几乎在键盘上形成一个十字,分别作为向上、向左、向下和向右移动的好替代)
-
使用某些键执行预定义的操作,例如:
-
使用 Esc 键或 Q 退出
-
使用空格键或 Enter 键 发射投射物
-
这些只是几个例子。实际上,有些游戏似乎使用了键盘上的每一个键!
使用鼠标
鼠标已经存在很长时间了,所以在许多游戏中使用鼠标是合理的。鼠标可以用几种方式使用:
-
左右鼠标按钮可以执行特定操作。
-
滚轮可以推动并用作第三个按钮。
-
鼠标滚轮可以用来滚动。
-
可以跟踪鼠标指针的位置,并将其与任何之前的行为结合使用。当我们设计用户界面时,我们将使用左鼠标按钮和鼠标指针位置的组合来点击屏幕上的按钮。
触摸
越来越多的设备现在对触摸做出反应。许多输入系统将触摸处理得与鼠标非常相似:
-
单个触摸相当于使用左鼠标按钮
-
持续的单个触摸相当于使用右鼠标按钮
-
指针的位置可以像鼠标指针一样使用
然而,触摸的许多功能并不能轻易地等同于鼠标。例如,大多数触摸界面允许同时处理多个触摸。这个特性被称为多点触控。这导致了许多标准手势,包括:
-
滑动或轻扫(将一个或多个手指快速在屏幕上移动)
-
捏合(将两个手指合拢)
-
缩放(将两个手指分开)
不幸的是,我们不会在这个游戏中实现触摸,因为本书的目标设备是 PC。
其他输入
移动设备的出现随后是输入技术的爆炸式增长。其中一些更常见的包括:
-
加速度计,可用于跟踪设备的物理运动
-
地理定位,可用于检测设备的物理位置
-
指南针,可用于检测设备的方向
-
麦克风,可用于接受语音输入
还有许多其他的输入技术,并且有很多重叠。例如,大多数 PC 都有一个麦克风。同样,虽然移动市场上的许多游戏正在利用这些替代输入方法,但我们的游戏将仅限于键盘和鼠标。
有人正在监听
现在,是时候实际编写一些代码来实现我们游戏的输入了。结果证明,一些基本的输入已经被实现。这是因为 Windows 是一个事件驱动的操作系统,并且已经在寻找输入事件的发生。从简单的角度来看,Windows(或任何现代操作系统)的主要任务是监听事件,然后根据这些事件执行某些操作。
因此,无论何时你在键盘上按下一个键,都会触发一个事件,唤醒 Windows 并说:“嘿,有人按了键盘!”Windows 然后将该信息传递给任何可能正在监听键盘事件的程序。当你使用鼠标时,情况也是如此。
WndProc 事件监听器
我们已经告诉我们的程序我们希望它监听事件。打开RoboRacer.cpp并定位到WndProc函数。WndProc是我们使用Win32 项目模板开始游戏时为我们创建的代码的一部分。WndProc被称为回调函数。
下面是如何实现回调函数的:
-
首先,将函数名称注册到操作系统中。在我们的例子中,这发生在
CreateGLWindow中:wc.lpfnWndProc = (WNDPROC)WndProc;这行代码告诉我们的窗口类将一个名为
WndProc的函数注册为程序的事件处理器。 -
现在,任何被 Windows 捕获的事件都会传递给
WndProc函数。WndProc中的代码然后决定处理哪些事件。任何未被WndProc处理的事件都会被程序简单地忽略。
由于 WndProc 是为典型的 Windows 应用程序创建的,它包含了一些我们不需要的东西,同时也有一些我们可以使用的东西:
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
int wmId, wmEvent;
PAINTSTRUCT ps;
HDC hdc;
switch (message)
{
case WM_COMMAND:
wmId = LOWORD(wParam);
wmEvent = HIWORD(wParam);
// Parse the menu selections:
switch (wmId)
{
case IDM_ABOUT:
DialogBox(hInstance, MAKEINTRESOURCE(IDD_ABOUTBOX), hWnd, About);
break;
case IDM_EXIT:
DestroyWindow(hWnd);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
break;
case WM_PAINT:
hdc = BeginPaint(hWnd, &ps);
// TODO: Add any drawing code here...
EndPaint(hWnd, &ps);
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
return 0;
}
主要的工作由 switch 完成,它处理各种窗口事件(所有以 WM 为前缀,它是 Windows Message 的缩写):
-
可以忽略所有的
WM_COMMAND事件。在一个典型的 Windows 应用程序中,你会创建一个菜单,然后将各种命令事件分配给当用户点击菜单上的命令时触发(例如,点击 关于 命令的IDM_ABOUT)。游戏几乎从不使用标准的 Windows 菜单结构(因此,我们也不使用)。 -
我们也忽略了
WM_PAINT事件。此事件在包含程序的窗口需要重绘时触发。然而,我们正在通过Render函数使用 OpenGL 不断重绘我们的窗口,因此我们不需要在这里添加代码。 -
我们已经处理了
WM_DESTROY事件。当你在 Windows 右上角点击关闭图标(X)时,会触发此事件。我们的处理程序通过使用PostQuitMessage(0)发送自己的消息来响应。这告诉我们的程序是时候退出了。
处理消息队列
我们在第一章 建立基础 中讨论了 Windows 消息系统,但这次讨论需要回顾一下。如果你查看 _wWinMain 函数,你会看到这个设置主消息循环的代码块:
bool done = false;
while (!done)
{
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
{
done = true;
}
else
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
else
{
int currentTime = glutGet(GLUT_ELAPSED_TIME);
float deltaTime = (float)(currentTime - previousTime) / 1000;
previousTime= currentTime;
GameLoop(deltaTime);
}
}
这个讨论的相关部分是 PeekMessage 的调用。PeekMessage 查询消息队列。在我们的情况下,如果已经通过 PostQuitMessage 发送了 WM_QUIT 消息,则 done 被设置为 true,while 循环退出,结束游戏。只要没有发送 WM_QUIT,while 循环就会继续,并调用 GameLoop。
事件驱动系统是处理大多数程序输入和其他动作的绝佳方式,但它不适合游戏。与游戏不同,大多数程序只是坐着等待某种输入发生。例如,文字处理程序等待按键、鼠标按钮点击或发出命令。在这种类型的系统中,每当发生事件时唤醒程序以处理事件是有意义的。
另一方面,游戏不会休眠!无论你是否按按钮,游戏仍在运行。此外,我们需要能够控制进程,以便只有在准备好处理输入时才处理输入。例如,我们不希望输入中断我们的渲染循环。
以下图示展示了当前 Windows 处理输入的方式:

处理鼠标和键盘输入
我们可以将 WndProc 扩展以处理所有输入事件。然而,这处理输入的方式非常低效,尤其是在实时程序中,如游戏。我们将让 Windows 处理用户关闭窗口的情况。对于其他所有情况,我们将创建自己的输入类,该类将直接轮询输入。
设计输入系统有许多不同的方法,我不会假设这是最好的系统。然而,我们的输入系统完成了两个重要的任务:
-
我们定义了一个一致的输入接口,用于处理鼠标和键盘输入。
-
我们通过在每一帧中直接轮询鼠标和键盘事件来处理输入(而不是等待 Windows 将它们发送给我们)。
创建输入类
创建一个名为 Input 的新类。然后,将以下代码添加到 Input.h 中:
#pragma once
#include <Windows.h>
class Input
{
public:
enum Key
{
K_ESC = VK_ESCAPE,
K_SPACE = VK_SPACE,
K_LEFT = VK_LEFT,
K_RIGHT = VK_RIGHT,
K_UP = VK_UP,
K_DOWN = VK_DOWN,
K_W = 87,
K_A = 65,
K_S = 83,
K_D = 68,
K_Q = 81,
K_ENTER = VK_RETURN,
K_LB = VK_LBUTTON,
K_RB = VK_RBUTTON
};
enum Command
{
CM_LEFT,
CM_RIGHT,
CM_STOP,
CM_UP,
CM_DOWN,
CM_QUIT
};
#define KEYDOWN(vk_code) ((GetAsyncKeyState(vk_code) & 0x8000) ? 1 : 0)
protected:
Command m_command;
HWND m_hWnd;
public:
Input(const HWND m_hWnd);
~Input();
void Update(const float p_detlaTime);
const Command GetCommand() const { return m_command; }
};
就像我们所有的代码一样,让我们仔细看看这是如何设计的:
-
我们包含
Windows.h,因为我们想要访问 Windows API 虚拟键常量。这些常量被定义为表示键盘和鼠标上的特殊键。 -
我们创建了
Key枚举,以便我们可以轻松定义轮询我们想要处理的键的值。 -
我们创建了
Command枚举,以便我们可以轻松地将输入映射到我们想要支持的命令操作。 -
我们定义了一个名为
KEYDOWN的 C++宏,这极大地简化了我们的未来代码(详情见下一步)。 -
该类只有一个成员变量,
m_command,它将用于存储最后请求的操作。 -
我们定义了三个成员函数:构造函数、析构函数、
Update和GetCommand。
虚拟键码
为了理解我们的输入系统是如何工作的,你必须首先了解虚拟键码。键盘上有许多键。除了字母和数字之外,还有特殊键,包括 shift、control、escape、enter、箭头键和功能键。想出一个简单的方法来识别每个键是一项相当艰巨的任务!
Windows 使用两种技术来识别键;对于普通键(字母和数字),每个键都通过正在测试的值的 ASCII 码来识别。以下表格显示了我们在游戏中使用的键的 ASCII 值:
| ASCII 值 | 键 |
|---|---|
| 87 | W |
| 65 | A |
| 83 | S |
| 68 | D |
| 81 | Q |
对于特殊键,Windows 定义了整数常量,以便更容易地使用它们。这些被称为虚拟键码。以下表格显示了我们在游戏中将使用的虚拟键码:
| 虚拟键码 | 键 |
|---|---|
VK_ESC |
Esc |
VK_SPACE |
空格键 |
VK_LEFT |
左箭头 |
VK_RIGHT |
向右箭头 |
VK_UP |
向上箭头 |
VK_DOWN |
向下箭头 |
VK_RETURN |
Enter |
VK_LBUTTON |
左鼠标按钮 |
VK_RBUTTON |
右鼠标按钮 |
注意,甚至还有鼠标按钮的虚拟键码!
查询输入
GetAsyncKeyState函数用于查询系统中的键盘和鼠标输入。以下是一个该命令的示例:
if ( (getAsyncKeyState(VK_ESC) & 0x8000) == true )
{
PostQuitMessage(0);
}
首先,我们传递一个虚拟键码(或 ASCII 值),然后我们使用十六进制值8000进行逻辑与操作,以去除我们不需要的信息。如果这个调用的结果是true,那么被查询的键正在被按下。
这是一个相当尴尬的命令,需要反复使用!因此,我们创建一个 C++宏来简化事情:
#define KEYDOWN(vk_code) ((GetAsyncKeyState(vk_code) & 0x8000) ? 1 : 0)
KEYDOWN执行GetAsyncKeyState命令。该宏接受一个键码作为参数,如果该键被按下则返回true,如果该键没有被按下则返回false。
实现输入类
我们输入系统的所有实际工作都是在Update函数中完成的,所以让我们实现Input类。打开Input.cpp并输入以下代码:
#include "stdafx.h"
#include "Input.h"
Input::Input(const HWND p_hWnd)
{
m_command = Command::CM_STOP;
m_hWnd = p_hWnd;
}
Input::~Input()
{
}
void Input::Update(const float p_deltaTime)
{
m_command = Command::CM_STOP;
if (KEYDOWN(Key::K_LEFT) || KEYDOWN(Key::K_A))
{
m_command = Command::CM_LEFT;
}
if (KEYDOWN(Key::K_RIGHT) || KEYDOWN(Key::K_D))
{
m_command = Command::CM_RIGHT;
}
if (KEYDOWN(Key::K_UP) || KEYDOWN(Key::K_LB))
{
m_command = Command::CM_UP;
}
if (KEYDOWN(Key::K_DOWN) || KEYDOWN(Key::K_RB))
{
m_command = Command::CM_DOWN;
}
if (KEYDOWN(Key::K_ESC) || KEYDOWN(Key::K_Q))
{
m_command = Command::CM_QUIT;
}
}
简而言之,Update函数查询我们想要同时检查的所有键,然后将这些键映射到我们在类头文件中定义的一个命令枚举。然后程序调用类的GetCommand方法来确定当前必须采取的操作。
如果你真的在注意听,那么你可能已经意识到我们只将单个命令结果存储到m_command中,但我们正在查询许多键。我们可以这样做的两个原因是:
-
这是一个无限简单的输入系统,要求很少
-
计算机以每秒 60 帧的速度循环输入,因此与玩家按键和释放按键的过程相比,这个过程是无限缓慢的。
基本上,最后检测到的按键将它的命令存储在m_command中,这对我们来说已经足够好了。
此外,请注意,我们将初始命令设置为Input::Command::STOP。因此,如果没有按键当前被按下,则STOP命令将是m_command的最终值。结果是,如果我们没有按键来使我们的机器人移动,那么它将停止。
将输入添加到游戏循环
现在我们有了输入类,我们将在我们的游戏中实现它。我们将通过将其添加到Update中来处理输入。这使我们能够完全控制何时以及如何处理输入。我们只依赖 Windows 事件监听器来告诉我们窗口是否已被关闭(这样我们仍然可以正确地关闭游戏)。
打开RoboRacer.cpp并修改Update函数,使其看起来像以下代码:
void Update(const float p_deltaTime)
{
inputManager->Update(p_deltaTime);
ProcessInput (p_deltaTime);
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
}
在此之前,我们的Update函数只更新了游戏精灵。如果你还记得,精灵的Update方法会修改精灵的位置。因此,在更新精灵之前执行输入是有意义的。Input类的Update方法查询系统输入,然后我们运行ProcessInput来决定要做什么。
处理我们的输入
在更新所有精灵之前,我们需要处理输入。记住,Input 类的 Update 方法只查询输入并存储一个命令。它实际上并没有改变任何东西。这是因为 Input 类无法访问我们的精灵。
首先,打开 RoboRacer.cpp 并包含 Input 头文件:
include "Input.h"
我们需要添加一个变量来指向我们的 Input 类。在变量声明部分添加以下行:
Input* inputManager;
然后,修改 StartGame 以实例化 Input 类:
void StartGame()
{
inputManager = new Input(hWnd);
LoadTextures();
}
现在,我们将创建一个函数来处理输入。将以下函数添加到 RoboRacer.cpp:
void ProcessInput (const float p_deltaTime);
{
switch (inputManager->GetCommand())
{
case Input::Command::CM_STOP:
player->SetVelocity(0.0f);
background->SetVelocity(0.0f);
break;
case Input::Command::CM_LEFT:
if (player == robot_right)
{
robot_right->IsActive(false);
robot_right->IsVisible(false);
robot_left->SetPosition(robot_right->GetPosition());
}
player = robot_left;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(-50.0f);
background->SetVelocity(50.0f);
break;
case Input::Command::CM_RIGHT:
if (player == robot_left)
{
robot_left->IsActive(false);
robot_left->IsVisible(false);
robot_right->SetPosition(robot_left->GetPosition());
}
player = robot_right;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(50.0f);
background->SetVelocity(-50.0f);
break;
case Input::Command::CM_UP:
player->Jump(Sprite::SpriteState::UP);
break;
case Input::Command::CM_DOWN:
player->Jump(Sprite::SpriteState::DOWN);
break;
case Input::Command::CM_QUIT:
PostQuitMessage(0);
break;
}
}
ProcessInput 是我们游戏实际发生更改的地方。尽管代码看起来很多,但实际上只发生了两件事:
-
我们使用
inputManager->GetCommand()查询输入系统以获取最新的命令 -
根据该命令执行所需的操作
以下表格显示了我们所定义的命令,以及这些命令如何影响游戏:
| 命令 | 操作 |
|---|---|
CM_STOP |
-
将
player的速度设置为0 -
将背景速度设置为
0
|
CM_LEFT |
|---|
-
如果
player正在向右移动,则停用右侧精灵并使其不可见,并将左侧精灵设置为右侧精灵的位置 -
将
player设置为左侧精灵 -
激活左侧精灵并使其可见
-
将左侧精灵的速度设置为
-50 -
将背景速度设置为
50
|
CM_RIGHT |
|---|
-
如果
player正在向左移动,则停用左侧精灵并使其不可见,并将右侧精灵设置为左侧精灵的位置 -
将
player设置为右侧精灵 -
激活右侧精灵并使其可见
-
将右侧精灵的速度设置为
50 -
将背景速度设置为
-50
|
CM_UP |
|---|
- 使用参数设置为
UP调用精灵的Jump方法
|
CM_DOWN |
|---|
- 使用参数设置为
DOWN调用精灵的Jump方法
|
CM_QUIT |
|---|
- 退出游戏
|
精灵类的更改
现在机器人可以跳跃了,我们需要向 Sprite 类添加一个新的方法来赋予机器人跳跃的能力:
首先,我们将在 Sprite.h 中添加一个枚举来跟踪精灵状态:
enum SpriteState
{
UP,
DOWN
};
接下来,我们需要一个新的成员变量来跟踪是否已点击元素。添加:
bool m_isClicked;
现在转到 Sprite.cpp 的构造函数并添加一行以初始化新变量:
m_isClicked = false;
将以下代码添加到 Sprite.h:
void Jump(SpriteState p_state);
void IsClicked(const bool p_value) { m_isClicked = p_value; }
const bool IsClicked() const { return m_isClicked; }
然后添加以下代码到 Sprite.cpp:
void Sprite::Jump(SpriteState p_state)
{
if (p_state == SpriteState::DOWN )
{
if (m_position.y < 470.0f) m_position.y += 75.0f;
}
else if (p_state == SpriteState::UP)
{
if (m_position.y >= 470.0f) m_position.y -= 75.0f;
}
}
我们的机器人有点独特。当他跳跃时,他会停留在提升的水平上,直到我们告诉他下来。Jump 方法在玩家按下上箭头时将机器人向上移动 75 像素,当玩家按下下箭头时将其向下移动 75 像素。然而,我们想确保不允许双重跳跃向上或向下,因此我们在应用更改之前检查当前的 y 位置。
现在我们将使用输入来控制我们的机器人,我们不再需要像上一章那样设置初始速度。在 LoadTextures 中找到以下两行代码,并将它们删除:
background->SetVelocity(-50.0f);
player->SetVelocity(50.0f);
运行游戏。现在你应该能够使用箭头键控制机器人,左右移动,上下移动。恭喜你,你已经成为了一个控制狂!
图形用户界面
现在是时候将我们的注意力转向图形用户界面,或称 GUI。GUI 允许我们控制游戏的其他元素,例如开始或停止游戏,或设置各种选项。
在本节中,你将学习如何在屏幕上创建可以被鼠标点击的按钮。我们将通过添加一个用于暂停游戏的按钮来保持简单。在此过程中,我们还将学习有关游戏状态的重要课程。
创建按钮
按钮不过是在屏幕上显示的纹理。然而,我们必须执行一些特殊的编码来检测按钮是否被点击。我们将添加此功能到精灵类中,以便我们的按钮由处理我们游戏中其他图像的同一类处理。
我们实际上将创建两个按钮:一个用于暂停,一个用于恢复。我使用了一个简单的图形程序创建了以下两个按钮:

我将这些按钮保存为 pause.png 和 resume.png,在 resources 文件夹中。
增强 Input 类
为了将 UI 集成到我们现有的 Input 类中,我们不得不添加一些额外的功能。我们将在 Input 类中添加一个动态数组来保存我们需要检查输入的 UI 元素列表。
首先,将以下行添加到 Input.h 的包含中:
#include "Sprite.h"
我们需要包含 Sprite 类,以便在 Input 类中处理精灵。
接下来,我们添加一个新的命令。修改 Command 枚举,使其看起来像以下列表:
enum Command
{
CM_INVALID,
CM_LEFT,
CM_RIGHT,
CM_STOP,
CM_UP,
CM_DOWN,
CM_QUIT,
CM_UI
};
我们添加了 CM_UI,如果任何 UI 元素被点击,它将被设置为当前命令。
现在,我们定义一个成员变量来保存 UI 元素列表。将以下行代码添加到 Input.h 的成员变量中:
Sprite** m_uiElements;
unsigned int m_uiCount;
m_uiElements 将是我们元素的指针动态列表,而 m_uiCount 将跟踪列表中的元素数量。
对 Input.h 的最终修改是在公共方法中添加以下行:
void AddUiElement(Sprite* m_pElement);
将 UI 元素添加到列表中
我们需要能够将元素列表添加到我们的 Input 类中,以便在输入处理过程中进行检查。
首先,我们必须为我们的元素列表分配内存。将以下行添加到 Input.cpp 中的 Input 构造函数中:
m_uiElements = new Sprite*[10];
m_uiCount = 0;
我可能可以比这更聪明,但到目前为止,我们将分配足够的内存来保存 10 个 UI 元素。然后我们将 m_uiCount 初始化为 0。现在,我们需要将以下方法添加到 Input.cpp 中:
void Input::AddUiElement(Sprite* p_element)
{
m_uiElements[m_uiCount] = p_element;
m_uiCount++;
}
此方法允许我们将 UI 元素添加到我们的列表中(内部,每个 UI 元素都是一个指向精灵的指针)。我们将元素添加到当前的m_uiElements数组索引处,然后增加m_uiCount。
检查每个 UI 元素
最终,Input 类将包含一个列表,其中包含它应该检查的所有 UI 元素。我们需要遍历这个列表,以查看是否有任何激活的元素被点击(如果我们想忽略特定的元素,我们只需将其激活标志设置为false)。
打开Input.cpp并在Update中添加以下代码(在现有代码之上):
for (unsigned int i = 0; i < m_uiCount; i++)
{
Sprite* element = m_uiElements[i];
if (element->IsActive() == true)
{
if (CheckForClick(element))
{
element->IsClicked(true);
m_command = Input::Command::CM_UI;
return;
}
}
}
此代码遍历m_uiElements数组中的每个项目。如果元素是激活的,则调用CheckForClick来查看此元素是否被点击。如果元素被点击,则将元素的IsClicked属性设置为true,并将m_command设置为CM_UI。
我们将此代码放在现有代码之上,因为我们希望检查 UI 的优先级高于检查游戏输入。注意在前面代码中,如果我们找到一个被点击的 UI 元素,我们会退出函数。
按下你的按钮
为了查看一个元素是否被点击,我们需要检查当鼠标指针在 UI 元素定义的区域内部时,左鼠标按钮是否按下。
首先,打开Input.cpp并添加以下代码:
const bool Input::CheckForClick(Sprite* p_element) const
{
if (KEYDOWN(Key::K_LB))
{
POINT cursorPosition;
GetCursorPos(&cursorPosition);
ScreenToClient(m_hWnd, &cursorPosition);
float left = p_element->GetPosition().x;
float right = p_element->GetPosition().x + p_element->GetSize().width;
float top = p_element->GetPosition().y;
float bottom = p_element->GetPosition().y + p_element->GetSize().height;
if (cursorPosition.x >= left &&
cursorPosition.x <= right &&
cursorPosition.y >= top &&
cursorPosition.y <= bottom)
{
return true;
}
else
{
return false;
}
}
return false;
}
下面是我们正在做的事情:
-
我们首先确保左鼠标按钮是按下的。
-
我们需要存储鼠标的当前位置。为此,我们创建一个名为
cursorPosition的POINT,然后通过引用将其传递给GetCursorPos。这将把cursorPosition设置为屏幕坐标中的当前鼠标位置。 -
实际上我们需要客户端坐标中的鼠标位置(我们实际需要工作的区域,忽略窗口边框和杂项)。为了得到这个,我们将
cursorPosition和当前窗口的句柄传递给ScreenToClient。 -
现在我们有了
cursorPosition,我们想要测试它是否在界定我们的 UI 元素的矩形内部。我们计算精灵的左、右、上和下坐标。 -
最后,我们检查
cursorPosition是否在 UI 元素的边界内。如果是,我们返回true;否则,我们返回false。
确保将以下声明添加到Sprite.h中:
const bool CheckForClick(Sprite* p_element) const;
添加我们的暂停按钮
我们现在需要在我们的游戏中添加代码来创建和监控暂停和恢复按钮。
首先,我们将为我们的两个新精灵添加两个变量。将以下两行添加到RoboRacer.cpp的变量声明块中:
Sprite* pauseButton;
Sprite* resumeButton;
然后,将以下行添加到LoadTextures(在return语句之前):
pauseButton = new Sprite(1);
pauseButton->SetFrameSize(75.0f, 38.0f);
pauseButton->SetNumberOfFrames(1);
pauseButton->SetPosition(5.0f, 5.0f);
pauseButton->AddTexture("resources/pauseButton.png");
pauseButton->IsVisible(true);
pauseButton->IsActive(true);
inputManager->AddUiElement(pauseButton);
resumeButton = new Sprite(1);
resumeButton->SetFrameSize(75.0f, 38.0f);
resumeButton->SetNumberOfFrames(1);
resumeButton->SetPosition(80.0f, 5.0f);
resumeButton->AddTexture("resources/resumeButton.png");
inputManager->AddUiElement(resumeButton);
这段代码设置暂停和恢复精灵的方式与我们设置游戏中的其他精灵完全一样。只有暂停精灵被设置为激活和可见。
你会注意到一个重要的添加:我们通过调用AddUiElement将每个精灵添加到Input类中。这会将精灵添加到需要检查输入的 UI 元素列表中。
我们还必须在RoboRacer.cpp中的Update函数中添加代码:
pauseButton->Update(p_deltaTime);
resumeButton->Update(p_deltaTime);
类似地,我们必须在RoboRacer.cpp中的Render函数中添加代码(在调用SwapBuffers之前):
pauseButton->Render();
resumeButton->Render();
就这样!如果你现在运行游戏,你应该会在左上角看到新的暂停按钮。不幸的是,它现在还没有做任何事情(除了将按钮从暂停改为恢复。在我们实际上能够暂停游戏之前,我们需要了解状态管理。
状态管理
想想看。如果我们想让我们的游戏暂停,那么我们必须设置某种类型的标志来告诉游戏我们想要它休息一下。我们可以设置一个布尔值:
bool m_isPaused;
如果游戏暂停,我们将m_isPaused设置为true,如果游戏正在运行,则将其设置为false。
这种方法的缺点是,在实际游戏中可能会遇到很多特殊情况。在任何时候,游戏可能会是:
-
开始
-
结束
-
运行
-
暂停
这些只是游戏状态的一些示例。游戏状态是一种需要特殊处理的具体模式。由于可能有这么多状态,我们通常创建一个状态管理器来跟踪我们当前所处的状态。
创建状态管理器
状态管理器的最简单版本是从一个枚举开始,该枚举定义了所有游戏状态。打开RoboRacer.cpp,并在包含语句下方添加以下代码:
enum GameState
{
GS_Running,
GS_Paused
};
然后转到变量声明块,并添加以下行:
GameState m_gameState;
为了保持简单,我们将定义两个状态:运行和暂停。大型游戏将有许多更多状态。
枚举相对于布尔变量有一个很大的优点。首先,它们的目的通常更清晰。说游戏状态是GS_Paused或GS_Running比仅仅将布尔值设置为true或false要清晰得多。
另一个优点是枚举可以具有多个值。如果我们需要向我们的游戏添加另一个状态,只需向我们的GameState枚举列表中添加另一个值即可。
我们的游戏将以运行状态开始,因此请将以下代码行添加到StartGame函数中:
m_gameState = GS_Running;
暂停游戏
想想看。当游戏暂停时,我们想做什么?我们仍然想在屏幕上看到东西,这意味着我们仍然想调用所有的渲染调用。然而,我们不想让东西改变位置或动画。我们也不希望处理游戏输入,尽管我们需要处理 UI 输入。
所有这些都应该让你思考更新调用。我们希望阻止除 UI 之外的所有更新的调用。修改RoboRacer.cpp中的Update函数,使其包含以下代码:
void Update(const float p_deltaTime)
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
if (m_gameState == GS_Running)
{
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
pauseButton->Update(p_deltaTime);
resumeButton->Update(p_deltaTime);
}
}
注意,我们只有在游戏状态为GS_Running时才会处理精灵更新。
我们将准备接受鼠标输入。首先,我们将设置一个计时器。在RoboRacer2d.cpp的变量声明中添加以下代码:
float uiTimer;
const float UI_THRESHOLD = 0.2f;
然后将以下代码行添加到StartGame中:
uiTimer = 0.0f;
时间将被用来为鼠标输入添加一小段延迟。如果没有延迟,鼠标的每次点击都会被记录多次,而不是一次。
我们仍然需要处理输入,但不是所有的输入。前往RoboRacer.cpp中的ProcessInput函数,并做出以下更改:
void ProcessInput(const float p_deltaTime)
{
Input::Command command = inputManager->GetCommand();
if (m_gameState == GS_Paused) command = Input::Command::CM_UI;
uiTimer += p_deltaTime;
if (uiTimer > UI_THRESHOLD)
{
uiTimer = 0.0f;
switch (command)
{
case Input::Command::CM_STOP:
player->SetVelocity(0.0f);
background->SetVelocity(0.0f);
break;
case Input::Command::CM_LEFT:
if (player == robot_right)
{
robot_right->IsActive(false);
robot_right->IsVisible(false);
robot_left->SetPosition(robot_right->GetPosition());
}
player = robot_left;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(-50.0f);
background->SetVelocity(50.0f);
break;
case Input::Command::CM_RIGHT:
if (player == robot_left)
{
robot_left->IsActive(false);
robot_left->IsVisible(false);
robot_right->SetPosition(robot_left->GetPosition());
}
player = robot_right;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(50.0f);
background->SetVelocity(-50.0f);
break;
case Input::Command::CM_UP:
player->Jump(Sprite::SpriteState::UP);
break;
case Input::Command::CM_DOWN:
player->Jump(Sprite::SpriteState::DOWN);
break;
case Input::Command::CM_QUIT:
PostQuitMessage(0);
break;
case Input::Command::CM_UI:
if (pauseButton->IsClicked())
{
pauseButton->IsClicked(false);
pauseButton->IsVisible(false);
pauseButton->IsActive(false);
resumeButton->IsVisible(true);
resumeButton->IsActive(true);
m_gameState = GS_Paused;
}
if (resumeButton->IsClicked())
{
resumeButton->IsClicked(false);
resumeButton->IsVisible(false);
resumeButton->IsActive(false);
pauseButton->IsVisible(true);
pauseButton->IsActive(true);
m_gameState = GS_Running;
}
}
}
command = Input::Command::CM_INVALID;
}
看看第二行。如果游戏处于暂停状态,它将命令设置为CM_UI。这意味着在游戏暂停期间,只会处理 UI 命令。这是一种黑客行为吗?也许吧,但它完成了工作!
我们只需要做两个更改。当按下暂停按钮时,我们需要将游戏状态更改为GS_Paused,而当按下继续按钮时,我们需要将游戏状态更改为GS_Running。这些更改已经在前面代码中的CS_UI案例中完成!
当你现在运行程序时,你会看到当你点击暂停按钮时游戏会暂停。当你点击继续按钮时,一切都会重新开始。
摘要
再次强调,你已经走得很远了!我们实现了一个基本的输入类,然后修改了我们的精灵类以处理 UI。这种统一的方法允许一个类既可以处理作为游戏对象的精灵,也可以处理作为用户界面一部分的精灵。检查按钮是否被按下的相同方法,也可以用于游戏对象的碰撞检测。然后你学习了如何创建状态机来处理游戏可能处于的各种状态。
在下一章中,我们将学习如何检测游戏对象之间的碰撞。
第五章。碰撞与逃逸
自从你在第一章开始阅读本书以来,你已经取得了很大的进步!你已经能够将移动图像渲染到屏幕上并控制它们的移动。你正在朝着创建一个伟大的游戏迈进。下一步是编写游戏中各种对象之间的交互代码。
本章将解释如何实现碰撞检测。碰撞检测确定当对象位于同一位置时它们如何相互作用。包括以下主题:
-
边界检测:当一个对象达到屏幕的顶部、底部、左侧或右侧边缘时,会发生什么?有惊人的多种选择,你可以选择你想要做什么。
-
碰撞检测:存在各种场景,我们经常需要检查以确定两个对象是否相撞。我们将介绍圆形和矩形碰撞检测算法。我们还将讨论何时使用每种类型的碰撞检测是合适的。
超出边界!
如果你运行我们当前的游戏,你会注意到,如果你允许机器人继续向左或向右移动,它将会离开屏幕。当他到达屏幕边缘时,他会继续移动,直到他不再可见。如果你反转他的方向,并让他移动相同的步数,他将会重新出现在屏幕上。
当一个对象达到屏幕边缘时,我们通常希望它执行一些特殊操作,例如停止或转身。确定对象何时达到屏幕边缘的代码称为边界检测。当对象达到边界时,我们可以做的事情有很多可能性。
-
停止对象
-
允许对象超出边界(因此,消失)
-
允许对象超出边界并在对面的边界重新出现(你玩过 Asteroids 的街机版本吗?)
-
沿着对象(即马里奥)滚动相机和屏幕
-
允许对象反弹离开边界(你玩过 Breakout 吗?)
由于我们的机器人由玩家控制,我们将简单地强制他在到达屏幕边缘时停止移动。
获取锚点
为了实现边界检查,你必须首先知道图像的确切锚点。技术上,锚点可以位于任何位置,但最常见的两个位置是左上角和图像中心。
首先,让我们看看如果我们忽略锚点会发生什么。打开RoboRacer2D项目,然后打开RoboRacer2D.cpp。
插入以下函数:
void CheckBoundaries(Sprite* p_sprite)
{
if (p_sprite->GetPosition().x < 0)
{
p_sprite->SetVelocity(0.0f);
}
else if (p_sprite->GetPosition().x > screen_width)
{
p_sprite->SetVelocity(0.0f);
}
}
下面是这段代码为我们做了什么:
-
函数接受一个精灵作为其参数
-
函数首先检查精灵的
x位置是否小于0,其中0是屏幕最左边缘的x坐标 -
函数随后检查精灵的
x位置是否大于屏幕宽度,其中screen_width是屏幕最右边缘的x坐标 -
如果任何一个检查为
true,精灵的速度将被设置为0,从而有效地阻止了精灵的运动
现在,将高亮的代码行添加到 RoboRacer2D.cpp 中的 Update 函数,紧接在 ProcessInput 之后:
inputManager->Update(p_deltaTime);
ProcessInput();
CheckBoundaries(player);
这只是调用我们刚刚创建的 CheckBoundaries 函数,并传入 player 对象。
现在,运行程序。移动 Robo 直到他到达屏幕的左端。然后将他移动到屏幕的右端。我们实现边界检查的方式有什么不妥之处吗?
小贴士
忽略背景向一侧滚动的方式。我们很快就会修复这个问题。
问题 1:Robo 似乎没有碰到左边的边界。
以下截图显示了如果你允许 Robo 前往屏幕的左端会发生什么。他看起来在到达边缘前就停止了。尽管你无法在以下截图中看到,但有一个影子始终延伸到机器人的左侧。被检测为图像边缘的是影子的左边缘。
结果表明,我们通过图像加载例程加载的图像的默认锚点实际上是在左上角。

问题 2:Robo 完全移出屏幕到右边。
以下截图显示了如果你允许 Robo 继续向右移动会发生什么。现在你理解了锚点位于左上角,你可能已经明白了正在发生的事情。
由于边界检查是基于精灵的 x 坐标,当左上角超过屏幕宽度时,整个精灵已经移出屏幕。机器人的灰度图像显示了他实际的位置,如果我们能看到他的话:

问题 3:一旦 Robo 到达屏幕的左端或右端,他会卡住。改变他的方向似乎没有任何效果!
这个问题被称为 嵌入。以下是发生的事情:
-
我们继续检查 Robo 的位置,直到他的 x 坐标超过一个阈值。
-
一旦他超过了那个阈值,我们就将他的速度设置为
0。 -
现在 Robo 的 x 坐标超过了那个阈值,它将始终超过那个阈值。任何试图将他移动到相反方向的努力都会触发边界检查,这将发现 Robo 的 x 坐标仍然超过阈值,他的速度将被设置为
0。
解决方案是在我们发现 Robo 已经越过阈值时立即将 Robo 的位置设置在阈值另一侧。我们将添加这个修正,但首先我们必须理解碰撞矩形。
碰撞矩形
看看以下 Robo 的图像。实心矩形代表纹理的边界。虚线矩形代表我们实际上想要考虑的边界和碰撞检测区域。这被称为 碰撞矩形。

比较两个矩形,以下是我们将纹理矩形转换为碰撞矩形的步骤:
-
在左侧纹理边界添加大约 34 个像素
-
从右侧纹理边界减去大约 10 个像素
-
顶部和右侧边界不需要调整
让我们通过添加定义碰撞矩形的功能来增强精灵类。
打开Sprite.h并添加以下成员变量:
Rect m_collision;
然后添加两个访问器方法:
const Rect GetCollisionRect() const;
void SetCollisionRectOffset(const Rect p_rect) { m_collision = p_rect; }
GetCollisionRect的实现稍微复杂一些,所以我们将这段代码放入Sprite.cpp:
const Sprite::Rect Sprite::GetCollisionRect() const
{
Rect rect;
rect.left = m_position.x + m_collision.left;
rect.right = m_position.x + m_size.width + m_collision.right;
rect.top = m_position.y + m_collision.top;
rect.bottom = m_position.y + m_size.height + m_collision.bottom;
return rect;
}
我们正在做的是:
-
m_collision:这将保存四个偏移量值。这些值将代表一个必须添加到纹理的边界矩形中,以得到我们想要的碰撞矩形。 -
SetCollisionRectOffset:这个方法接受一个Rect参数,它包含四个偏移量——顶部、底部、左侧和右侧,这些偏移量必须添加到纹理边界的顶部、底部、左侧和右侧以创建碰撞矩形。 -
GetCollisionRect:这个方法返回我们在检查边界和检测碰撞时实际可以使用的碰撞矩形。这是通过将宽度和高度添加到精灵的当前锚点(左上角)并调整m_collision中的值来计算的。
注意,GetCollisionRect是动态的;它总是根据精灵的当前位置返回当前的碰撞矩形。因此,我们在任何游戏时刻返回实际需要检查的顶部、底部、左侧和右侧边界。
如果仔细观察设计,你应该能够看到如果没有定义碰撞矩形,GetCollisionRect将返回由纹理矩形确定的碰撞矩形。因此,这种新的设计允许我们默认使用纹理矩形作为碰撞矩形。另一方面,如果我们想指定自己的碰撞矩形,我们可以使用SetCollisionRectOffset来做到这一点。
为了安全起见,我们将在构造函数中通过添加以下行来初始化 m_collision:
m_collision.left = 0.0f;
m_collision.right = 0.0f;
m_collision.top = 0.0f;
m_collision.bottom = 0.0f;
现在我们有了支持碰撞矩形的代码,我们需要为机器人的精灵定义碰撞矩形。转到RoboRacer2D.cpp中的LoadTextures函数,并在return true代码行之前添加以下突出显示的行:
Sprite::Rect collision;
collision.left = 34.0f;
collision.right = -10.0f;
collision.top = 0.0f;
collision.bottom = 0.0f;
robot_left->SetCollisionRectOffset(collision);
robot_right->SetCollisionRectOffset(collision);
return true;
记住,只添加前面的突出显示代码。代码的最后一行是为了提供上下文。
现在我们将重写我们的边界检测函数,以利用碰撞矩形。在这个过程中,我们将解决我们在第一次尝试中遇到的所有三个问题。当前的代码使用图像的锚点,这并不能准确地反映我们想要检查的实际边界。新的代码将使用碰撞矩形。将 RoboRacer2D 中的CheckBoundaries函数替换为以下代码:
void CheckBoundaries(Sprite* p_sprite)
{
Sprite::Rect check = p_sprite->GetCollisionRect();
if (check.left < 0.0f)
{
p_sprite->SetVelocity(0.0f);
}
else if (check.right > screen_width)
{
p_sprite->SetVelocity(0.0f);
}
}
这段代码使用了为正在检查的精灵定义的碰撞矩形。正如我们之前讨论的,GetCollisionRect根据精灵的当前位置返回顶部、底部、左侧和右侧边界,这极大地简化了我们的代码!现在,我们只需检查精灵的左侧是否小于零,或者精灵的右侧是否大于零,我们就完成了!

嵌入
哈哈!Robo 现在成功停在屏幕边缘(前一个图像中只显示了右侧)。但是,哎呀!它仍然卡住了!正如我们之前提到的,这个问题被称为嵌入。如果我们放大,我们可以看到发生了什么:

垂直线代表屏幕的边缘。当 Robo 停止时,它的右边缘已经超过了屏幕的右边缘,所以我们停止它。不幸的是,即使我们试图让他转向相反方向,CheckBoundaries函数也会在 Robo 有机会开始移动之前在下一个帧上检查:

根据边界检查,Robo 的右边缘仍然在屏幕的右边缘之外,所以 Robo 的速度再次被设置为零。Robo 在甚至无法迈出一步之前就被停止了!
这里是解决方案;一旦我们检测到 Robo 超出了边界,我们就将他的速度设置为零,并将 Robo 重新定位到边界另一侧:

现在,只要 Robo 朝相反方向移动,它就能移动。
为了实现这个更改,我们再次将更改应用于CheckBoundaries函数:
void CheckBoundaries(Sprite* p_sprite)
{
Sprite::Rect check = p_sprite->GetCollisionRect();
float offset;
float x;
float y;
if (check.left < 0.0f)
{
p_sprite->SetVelocity(0.0f);
offset = check.left;
x = p_sprite->GetPosition().x - offset;
y = p_sprite->GetPosition().y;
p_sprite->SetPosition(x, y);
}
else if (check.right > screen_width)
{
p_sprite->SetVelocity(0.0f);
offset = screen_width - check.right;
x = p_sprite->GetPosition().x + offset;
y = p_sprite->GetPosition().y;
p_sprite->SetPosition(x, y);
}
if (check.top < 0.0f)
{
p_sprite->SetVelocity(0.0f);
offset = check.top;
y = p_sprite->GetPosition().y - offset;
x = p_sprite->GetPosition().x;
p_sprite->SetPosition(x, y);
}
else if (check.bottom > screen_height)
{
p_sprite->SetVelocity(0.0f);
offset = screen_height - check.bottom;
y = p_sprite->GetPosition().y + offset;
x = p_sprite->GetPosition().x;
p_sprite->SetPosition(x, y);
}
}
突出的行显示了添加的代码。基本上,我们执行以下操作:
-
计算 Robo 超出边界的距离
-
调整他的位置,使他现在正好位于边界上
你会注意到我们还填写了处理顶部和底部边界的函数,以便边界检查可以用于任何方向移动的任何精灵。
调整背景
现在我们已经让 Robo 按照我们希望的方式移动,两个新的问题已经出现在背景图片上:
-
当 Robo 停止时,背景继续滚动。
-
当背景图片在右侧或左侧结束时,它会从屏幕上滑出,我们只剩下黑色背景。
在我们继续进行碰撞检测之前,让我们修复背景。首先,我们将添加以下函数到RoboRacer2D.cpp:
void CheckBackground()
{
float leftThreshold = 0.0f;
float rightThreshold = -(background->GetSize().width - screen_width);
if (background->GetPosition().x > 0)
{
background->SetPosition(0.0f, background->GetPosition().y);
}
else if (background->GetPosition().x < rightThreshold)
{
background->SetPosition(rightThreshold, background->GetPosition().y);
}
}
这段代码与边界检查代码非常相似。如果背景锚点向左移动足够远以至于暴露了纹理的右边缘,它将被重置。如果背景锚点向右移动足够远以至于暴露了纹理的左边缘,它将被重置。
现在,将高亮显示的代码行添加到Update函数中,在RoboRacer2D.cpp中调用CheckBoundaries之后:
inputManager->Update(p_deltaTime);
ProcessInput();
CheckBoundaries(player);
CheckBackground();
背景现在应该从边缘延伸到边缘。玩玩游戏,休息一下喝杯咖啡吧。你应得的!
可碰撞物体
有很多次我们可能想要检查和查看游戏中的物体是否相互碰撞。我们可能想知道玩家是否撞到了障碍物或敌人。我们可能有玩家可以捡起的物体,通常称为拾取物或道具。
在游戏中,可以与其他物体发生碰撞的物体统称为可碰撞物体。当我们创建Sprite类时,实际上是为这个目的设计的。查看类构造函数,你会注意到成员变量m_isCollideable被设置为false。当我们编写碰撞检测代码时,我们将忽略那些m_isCollideable设置为false的物体。如果我们想让一个物体能够与其他物体发生碰撞,我们必须确保将m_collideable设置为true。
准备得分
为了保持我们的设计简单,我们将创建一个敌人和一个拾取物。撞到敌人会从玩家的得分中扣除分数,而撞到拾取物则会增加玩家的得分。我们将在精灵类中添加一些额外的代码来支持这个功能。
首先,让我们添加一些新的成员变量。在Sprite.h中声明一个新的变量:
int m_value;
然后添加以下方法:
void SetValue(const int p_value) { m_value = p_value; }
const int GetValue() const { return m_value; }
通过这些更改,每个精灵都将有一个内在值。如果值为正,则表示奖励。如果值为负,则表示惩罚。
不要忘记在Sprite类构造函数中将m_value初始化为零!
真正的朋友
让我们添加我们的拾取物精灵。在这种情况下,拾取物是一个油罐,以保持 Robo 的关节平滑工作。
将以下精灵定义添加到 RoboRacer2D 中:
Sprite* pickup;
现在,我们将设置精灵。将以下代码添加到LoadTextures:
pickup = new Sprite(1);
pickup->SetFrameSize(26.0f, 50.0f);
pickup->SetNumberOfFrames(1);
pickup->AddTexture("resources/oil.png");
pickup->IsVisible(false);
pickup->IsActive(false);
pickup->SetValue(50);
这段代码基本上与我们用来创建所有精灵的代码相同。一个值得注意的区别是我们使用新的SetValue方法向精灵添加一个值。这代表玩家收集这个拾取物将获得的分数。
生成时间
注意,我们已经将精灵设置为非活动状态和不可见状态。现在,我们将编写一个函数来随机生成拾取物。首先,我们需要添加两个额外的 C++头文件。在RoboRacer2D.cpp中添加以下头文件:
#include <stdlib.h>
#include <time.h>
我们需要stdlib中的rand函数和time来为我们提供一个值来初始化随机生成器。
小贴士
随机数是从内部表中生成的。为了保证每次程序启动时选择不同的随机数,你首先需要用保证每次启动程序时都不同的值来初始化随机数生成器。由于程序启动的时间总是不同的,我们通常使用时间作为种子。
接下来,我们需要一个计时器。在RoboRacer2D.cpp中声明以下变量:
float pickupSpawnThreshold;
float pickupSpawnTimer;
阈值是我们希望在生成拾取物之前经过的秒数。计时器将从零开始并计数到那个秒数。
让我们在StartGame函数中初始化这些值。StartGame函数也是一个很好的地方来初始化我们的随机数生成器。将以下三行代码添加到StartGame的末尾:
srand(time(NULL));
pickupSpawnThreshold = 15.0f;
pickupSpawnTimer = 0.0f;
第一行通过传递表示当前时间的整数来初始化随机数生成器。下一行设置生成阈值为15秒。第三行将生成计时器设置为0。
现在,让我们创建一个生成拾取物的函数。将以下代码添加到RoboRacer2D.cpp中:
void SpawnPickup(float p_DeltaTime)
{
if (pickup->IsVisible() == false)
{
pickupSpawnTimer += p_DeltaTime;
if (pickupSpawnTimer > pickupSpawnThreshold)
{
float marginX = pickup->GetSize().width;
float marginY = pickup->GetSize().height;
float spawnX = (rand() % (int)(screen_width - (marginX * 2))) + marginX;
float spawnY = screen_height - ((rand() % (int)(player->GetSize().height - (marginY * 1.5))) + marginY);
pickup->SetPosition(spawnX, spawnY);
pickup->IsVisible(true);
pickup->IsActive(true);
pickupSpawnTimer = 0.0f;
}
}
}
这段代码执行以下操作:
-
它会检查拾取物是否已经在屏幕上了
-
如果没有拾取物,则生成计时器递增
-
如果生成计时器超过了生成阈值,拾取物将在屏幕宽度和 Robo 的垂直范围内随机位置生成。
不要过于担心所使用的特定数学。你的拾取物定位算法可能完全不同。这里的关键是,将生成一个位于 Robo 可触及范围内的单个拾取物。
确保在Update函数中调用SpawnPickup并在更新拾取物时添加一行:
if (m_gameState == GS_Running)
{
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
pause->Update(p_deltaTime);
resume->Update(p_deltaTime);
pickup->Update(p_deltaTime);
SpawnPickup(p_deltaTime);
}
我们还需要在Render中添加一行代码来渲染拾取物:
void Render()
{
glClear(GL_COLOR_BUFFER_BIT);
glLoadIdentity();
background->Render();
robot_left->Render();
robot_right->Render();
robot_left_strip->Render();
robot_right_strip->Render();
pause->Render();
resume->Render();
pickup->Render();
SwapBuffers(hDC);
}
如果你现在运行游戏,那么在游戏开始后的约五秒钟,应该会生成一个油桶。
提示
当前代码有一个缺陷。它可能会在 Robo 的正上方生成拾取物。一旦我们实现了碰撞检测,Robo 将立即捡起油桶。这将会发生得如此之快,以至于你甚至看不到它发生。为了保持简单,我们将容忍这个特定的缺陷。
圆形碰撞检测
检测碰撞的一种方法是通过观察每个物体彼此之间的距离。这被称为圆形碰撞检测,因为它将每个物体视为被一个圆所包围,并使用该圆的半径来确定物体是否足够接近以发生碰撞。
看看下面的图:

左侧的圆没有发生碰撞,而右侧的圆发生了碰撞。对于没有碰撞的圆,两个圆心之间的距离(d)大于两个半径(r1 + r2)之和。对于发生碰撞的圆,两个圆心之间的距离(d)小于两个半径(r1 + r2)之和。我们可以利用这个知识来测试任何两个物体是否基于圆的半径和物体中心点之间的距离发生碰撞。
那么,我们如何使用这些信息呢?
-
我们将知道r1和r2,因为我们创建精灵时设置了它们。
-
我们将使用每个圆心的x和y坐标来计算直角三角形的两条边。
-
我们将使用勾股定理的变体来计算两个中心点之间的距离 d。
这可能会让你感到有点头疼,但我希望刷新你记忆中基本几何的一个定理。
勾股定理
勾股定理允许我们在知道形成两个点之间直角的两条线段长度的情况下,找到二维空间中任意两点之间的距离。

a² + b² = c²
在我们的案例中,我们试图计算两点之间的距离(c)。
一点代数变换可以将这个方程转换为:

计算平方根是计算上昂贵的。一个很棒的数学技巧实际上允许我们在不计算平方根的情况下进行碰撞检测。
如果我们使用平方根来进行这个计算,它可能看起来是这样的:
c = sqrt(a * a + b * b);
if (c <= r1 + r2) return true;
虽然这可以工作,但有一个很棒的数学技巧可以让我们在不计算平方根的情况下完成这个测试。看看这个:
c = a * a + b * b;
if (c<= r1 * r1 + r2 * r2) return true;
结果表明,我们可以在方程中保持所有项的平方,比较仍然有效。这是因为我们只对距离和半径之和的相对比较感兴趣,而不是绝对数学值。
小贴士
如果我们在这里展示的数学让你感到困惑,那么请不要过于担心。圆形碰撞检测非常常见,检测它的数学通常已经内置到你将使用的游戏引擎中。然而,我想让你稍微了解一下引擎内部。毕竟,游戏编程本质上是数学性的,你对数学了解得越多,你的编码能力就会越强。
添加圆形碰撞代码
现在,是时候修改 Sprite.h 以添加对圆形碰撞检测的支持。首先,我们需要添加一些成员变量来保存中心点和半径。将这两个属性添加到 Sprite.h 中:
float m_radius;
Point m_center;
然后添加以下方法声明:
void SetRadius(const GLfloat p_radius) { m_radius = p_radius; }
const float GetRadius() const { return m_radius; }
void SetCenter(const Point p_center) { m_center = p_center; }
const Point GetCenter() const;
const bool IntersectsCircle(const Sprite* p_sprite) const;
这些方法允许我们设置和检索精灵的中心点和半径。GetCenter 方法有多行,因此我们将在 Sprite.cpp 中实现它:
const Sprite::Point Sprite::GetCenter() const
{
Point center;
center.x = this->GetPosition().x + m_center.x;
center.y = this->GetPosition().y + m_center.y;
return center;
}
这里需要注意的一个重要点是,m_center 代表精灵锚点的一个 x 和 y 偏移。因此,为了返回中心点,我们将 m_center 添加到精灵的当前位置,这将给出精灵在游戏中的当前中心点。
我们现在需要添加代码以执行碰撞检测。将以下代码添加到 Sprite.cpp 中:
const bool Sprite::IntersectsCircle(const Sprite* p_sprite) const
{
if (this->IsCollideable() && p_sprite->IsCollideable() && this->IsActive() && p_sprite->IsActive())
{
const Point p1 = this->GetCenter();
const Point p2 = p_sprite->GetCenter();
float y = p2.y - p1.y;
float x = p2.x - p1.x;
float d = x*x + y*y;
float r1 = this->GetRadius() * this->GetRadius();
float r2 = p_sprite->GetRadius() * p_sprite->GetRadius();
if (d <= r1 + r2)
{
return true;
}
}
return false;
}
我们已经解释了勾股定理的使用,所以这段代码对你来说可能有点熟悉。以下是我们在做什么:
这个函数接受一个与自身比较的精灵。
-
首先,我们检查确保两个精灵都是可碰撞的。
-
p1和p2代表两个中心点。 -
x和y代表直角三角形的边a和b的长度。请注意,计算只是简单地计算每个精灵的x和y位置之间的差异。 -
r1和r2是两个圆的半径(以 2 的幂表示)。 -
d是两个中心之间的距离(以 2 的幂表示)。 -
如果
d小于或等于两个半径之和,则表示圆相交。
为什么使用圆形碰撞检测?
正如我们多次讨论的那样,纹理被表示为矩形。实际上,我们将在本章后面介绍矩形碰撞检测时利用这一点。以下图示说明了矩形和圆形碰撞检测的不同之处(相对大小被夸张以说明问题):

左侧的精灵使用矩形边界框进行碰撞。右侧的精灵使用边界圆进行碰撞。一般来说,当我们处理更圆的形状时,使用边界圆在视觉上更有说服力。
小贴士
我必须承认,在这个例子中,差异并不大。你可以在这个例子中用矩形或圆形碰撞检测。油桶的圆形特性使其成为圆形碰撞检测的良好候选者。如果碰撞的两个物体实际上是圆形(即,两个球体碰撞),圆形碰撞检测就非常关键。
使用我们开发的代码,我们需要为任何将使用圆形碰撞检测的精灵定义中心和半径。将以下代码添加到RoboRacer.cpp中的LoadTextures函数:
Sprite::Point center;
float radius;
center.x = robot_right->GetSize().width / 2.0f;
center.y = robot_right->GetSize().height / 2.0f;
radius = (center.x + center.y) / 2.0f;
robot_right->SetCenter(center);
robot_right->SetRadius(radius);
robot_left->SetCenter(center);
robot_left->SetRadius(radius);
center.x = pickup->GetSize().width / 2.0f;
float yOffset = (pickup->GetSize().height / 4.0f) * 3.0f;
center.y = yOffset;
pickup->SetCenter(center);
radius = pickup->GetSize().width / 2.0f;
pickup->SetRadius(radius);
不要过于担心我们在这里使用的确切值。我们基本上是为 Robo 和油桶设置一个边界圆,以匹配前面的图。Robo 的边界圆设置为机器人的中间,而油桶的圆设置为纹理的下半部分。
连接碰撞检测
现在,我们将添加一个新函数来执行所有的碰撞检测。将以下函数添加到RoboRacer2D.cpp中:
void CheckCollisions()
{
if (player->IntersectsCircle(pickup))
{
pickup->IsVisible(false);
pickup->IsActive(false);
player->SetValue(player->GetValue() + pickup->GetValue());
pickupSpawnTimer = 0.0f;
}
}
此代码的目的是检查玩家是否与拾取物品发生了碰撞:
-
如果
player->IntersectsCircle(pickup)的调用返回true,则表示玩家与拾取物品发生了碰撞 -
拾取物品被禁用并变得不可见
-
拾取物品的值被添加到玩家的值中(这将是未来章节中计分的基准)
-
生成计时器被重置
我们还剩下两个小细节。首先,你必须将CheckCollisions的调用添加到Update函数中:
if (m_gameState == GS_Running)
{
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
pause->Update(p_deltaTime);
resume->Update(p_deltaTime);
pickup->Update(p_deltaTime);
SpawnPickup(p_deltaTime);
CheckCollisions();
}
其次,你需要使玩家和拾取物品可碰撞。将以下三行代码添加到LoadTextures的底部,就在返回语句之前:
robot_left->IsCollideable(true);
robot_right->IsCollideable(true);
pickup->IsCollideable(true);
现在,真正的乐趣开始了!玩游戏,当油桶生成时,用 Robo 去拾取它。五秒后,另一个油桶生成。乐趣永无止境!
矩形碰撞检测
现在,我们将学习如何实现矩形碰撞检测。结果证明,机器人和我们的敌人(一个水瓶)都非常矩形,这使得矩形碰撞检测是最好的选择。
敌人内部
让我们介绍我们的机器人敌人——一瓶水来生锈他的齿轮。这个代码将在下面包括。
将以下精灵定义添加到 RoboRacer2D:
Sprite* enemy;
现在,我们将设置精灵。将以下代码添加到 LoadTextures:
enemy = new Sprite(1);
enemy->SetFrameSize(32.0f, 50.0f);
enemy->SetNumberOfFrames(1);
enemy->AddTexture("resources/water.png");
enemy->IsVisible(false);
enemy->IsActive(false);
enemy->SetValue(-50);
enemy->IsCollideable(true);
这段代码基本上是我们用来创建所有精灵的相同代码。一个值得注意的差异是,我们使用新的 SetValue 方法向精灵添加一个负值。这就是玩家如果击中这个敌人将失去多少分。我们还确保将敌人设置为可碰撞。
生成敌人
就像拾取物一样,我们需要生成我们的敌人。我们可以使用与拾取物相同的代码,但我认为如果我们的敌人使用不同的计时器会更好。
在 RoboRacer2D.cpp 中声明以下变量:
float enemySpawnThreshold;
float enemySpawnTimer;
阈值是我们希望敌人生成前要经过的秒数。计时器将从零开始并向上计数到这个秒数。
让我们在 StartGame 函数中初始化这些值。将以下两行代码添加到 StartGame 的末尾:
enemySpawnThreshold = 7.0f;
enemySpawnTimer = 0.0f;
我们设置了 7 秒的生成阈值,并将生成计时器设置为 0。
现在,让我们创建一个生成我们敌人的函数。将以下代码添加到 RoboRacer2D.cpp:
void SpawnEnemy(float p_DeltaTime)
{
if (enemy->IsVisible() == false)
{
enemySpawnTimer += p_DeltaTime;
if (enemySpawnTimer >enemySpawnThreshold)
{
float marginX = enemy->GetSize().width;
float marginY = enemy->GetSize().height;
float spawnX = (rand() % (int)(screen_width - (marginX * 2))) + marginX;
float spawnY = screen_height - ((rand() % (int)(player->GetSize().height - (marginY * 2))) + marginY);
enemy->SetPosition(spawnX, spawnY);
enemy->IsVisible(true);
enemy->IsActive(true);
}
}
}
这段代码执行以下操作:
-
它检查确保敌人尚未在屏幕上
-
如果没有敌人,则生成计时器增加
-
如果生成计时器超过生成阈值,敌人将在屏幕宽度内和机器人垂直范围内随机位置生成
不要过于担心所使用的特定数学。你的敌人定位算法可以完全不同。这里的关键是,一个敌人将在机器人的路径内生成。
确保在 Update 函数中添加对 SpawnEnemy 的调用以及更新敌人的行:
if (m_gameState == GS_Running)
{
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
pause->Update(p_deltaTime);
resume->Update(p_deltaTime);
pickup->Update(p_deltaTime);
SpawnPickup(p_deltaTime);
enemy->Update(p_deltaTime);
SpawnEnemy(p_deltaTime);
CheckCollisions();
}
我们还需要在 Render 中添加一行来渲染敌人:
void Render()
{
glClear(GL_COLOR_BUFFER_BIT);
glLoadIdentity();
background->Render();
robot_left->Render();
robot_right->Render();
robot_left_strip->Render();
robot_right_strip->Render();
pause->Render();
resume->Render();
pickup->Render();
enemy->Render();
SwapBuffers(hDC);
}
如果你现在运行游戏,那么大约在游戏开始后七秒应该会生成一个水瓶。
添加矩形碰撞代码
正如我们多次提到的,所有精灵本质上都是矩形。从视觉上看,如果这些矩形的任何边框重叠,我们可以假设两个精灵发生了碰撞。
我们将向我们的 Sprite 类添加一个函数,用于确定两个矩形是否相交。打开 Sprite.h 并添加以下方法声明:
const bool IntersectsRect(const Sprite*p_sprite) const;
现在,让我们将实现添加到 Sprite.cpp:
const bool Sprite::IntersectsRect(const Sprite* p_sprite) const
{
if (this->IsCollideable() && p_sprite->IsCollideable() && this->IsActive() && p_sprite->IsActive())
{
const Rect recta = this->GetCollisionRect();
const Rect rectb = p_sprite->GetCollisionRect();
if (recta.left >= rectb.left && recta.left <= rectb.right && recta.top >= rectb.top && recta.top <= rectb.bottom)
{
return true;
}
else if (recta.right >= rectb.left && recta.right <= rectb.right && recta.top >= rectb.top && recta.top <= rectb.bottom)
{
return true;
}
else if (recta.left >= rectb.left && recta.right <= rectb.right && recta.top < rectb.top && recta.bottom > rectb.bottom)
{
return true;
}
else if (recta.top >= rectb.top && recta.bottom <= rectb.bottom && recta.left < rectb.left && recta.right > rectb.right)
{
return true;
}
else if (rectb.left >= recta.left && rectb.left <= recta.right &&
rectb.top >= recta.top && rectb.top <= recta.bottom)
{
return true;
}
else if (rectb.right >= recta.left && rectb.right <= recta.right && rectb.top >= recta.top && rectb.top <= recta.bottom)
{
return true;
}
else if (rectb.left >= recta.left && rectb.right <= recta.right && rectb.top < recta.top && rectb.bottom > recta.bottom)
{
return true;
}
else if (recta.top >= rectb.top && recta.bottom <= rectb.bottom && recta.left < rectb.left && recta.right > rectb.right)
{
return true;
}
else if (rectb.top >= recta.top && rectb.bottom <= recta.bottom && rectb.left < recta.left && rectb.right > recta.right)
{
return true;
}
}
return false;
}
这就是这段代码的工作方式:
-
这个函数看起来很复杂,但实际上只做了几件事情。
-
该函数接受一个精灵参数。
-
我们将
recta设置为调用IntersectsRect方法的精灵的碰撞矩形,并将rectb设置为传入的精灵的碰撞矩形。 -
然后,我们测试
recta中顶点的位置与rectb中所有可能的位置组合。如果任何测试返回true,则我们返回true。否则,我们返回false。
以下图示说明了两个矩形可能交互的一些方式:

连接继续
我们已经通过使用CheckCollisions实现了碰撞检测。我们只需将以下代码添加到CheckCollisions中,以检查玩家是否与敌人发生碰撞:
if (player->IntersectsRect(enemy))
{
enemy->IsVisible(false);
enemy->IsActive(false);
enemy->SetValue(player->GetValue() + enemy->GetValue());
enemySpawnTimer = 0.0f;
}
现在,真正的乐趣开始了!玩这个游戏,当水敌人出现时确保 Robo 避开它!如果你与敌人发生碰撞,你会失去分数(因为敌人的值被设置为负数)。在我们实现可见分数之前,你可能需要将分数输出到控制台。
摘要
我相信你现在可以理解,没有碰撞检测,大多数游戏都是不可能实现的。碰撞检测允许游戏中的对象相互交互。我们使用碰撞检测来获取物品并检测我们是否遇到了敌人。
我们还讨论了边界检查的基本任务。边界检查是一种特殊的碰撞检测形式,用于检查一个对象是否达到了屏幕边界。另一种类型的边界检查用于管理场景背景。
在下一章中,我们将通过添加一些收尾工作来结束游戏,包括一个抬头显示(heads-up display)!
第六章. 精炼银色
我相信您和我一样,对您在游戏上取得的进展感到兴奋。它几乎准备好发布了,对吧?嗯,还不完全是!在游戏准备好发布之前,还有很多工作要做,这正是本章的主题。
许多人有一个很好的游戏想法,也有很多像您这样的热情的程序员实际上将他们的游戏编码到了我们现在所达到的程度。不幸的是,这就是许多项目失败的地方。由于某种原因,许多第一次尝试编写游戏的程序员没有花时间真正完成他们的游戏。还有很多事情要做,才能使您的游戏看起来更专业:
-
游戏状态:当您学习如何暂停游戏时,我们已经稍微提到了游戏状态。本章将继续讨论如何使用游戏状态来管理游戏在游戏过程中的各个阶段。
-
启动画面:大多数游戏在游戏开始之前会显示一个或多个屏幕。这些屏幕被称为启动画面,通常显示参与游戏制作的公司的标志和名称。启动画面表明您在精炼游戏方面已经做得很好。
-
菜单屏幕:大多数游戏都是以供玩家选择的菜单开始的。我们将在启动画面之后创建一个简单的菜单,为玩家提供一些选项。
-
得分和统计数据:您可能已经注意到,我们的游戏目前还没有得分。虽然设计一个不涉及得分的游戏是可能的,但大多数玩家想知道他们在游戏中的表现。
-
胜利和失败:同样,虽然确实存在一些游戏没有胜利或失败的情况,但大多数游戏都有胜利或失败的条件,这标志着游戏的结束。
-
游戏进度:大多数游戏允许玩家在达到某些目标之前继续玩游戏。许多游戏被分解成一系列关卡,每个关卡都比前一个关卡稍微难一些。您将学习如何将这种进度添加到您的游戏中。
-
致谢:每个人都喜欢为自己的工作获得认可!就像电影一样,包含一个显示参与游戏制作的所有人员及其角色的屏幕是传统的。我将向你展示如何创建一个简单的致谢屏幕。
游戏状态
记得我们在第四章中编码暂停按钮的时候吗,控制狂?我们必须添加一些代码来告诉游戏它是处于活动状态还是暂停状态。实际上,我们定义了以下枚举:
enum GameState
{
GS_Running,
GS_Paused
};
这些枚举定义了两个游戏状态:GS_Running和GS_Paused。然后我们在StartGame函数中将默认游戏状态设置为GS_Running:
void StartGame()
{
inputManager = new Input(hWnd);
LoadTextures();
m_gameState = GS_Running;
srand(time(NULL));
pickupSpawnThreshold = 5.0f;
pickupSpawnTimer = 0.0f;
}
只要游戏状态设置为GS_Running,游戏就会继续循环通过游戏循环,处理更新,并渲染场景。然而,当你点击暂停按钮时,游戏状态就会设置为GS_Paused。当游戏暂停时,我们不再更新游戏对象(即机器人、拾取物和敌人),但我们仍然继续渲染场景并处理用户界面(UI),以便可以点击按钮。
状态机
用于设置和控制游戏状态的机制被称为状态机。状态机为游戏设置单独且不同的阶段(或状态)。每个状态定义了在每个状态下应该发生或不应发生的一定规则。例如,我们的简单状态机有两个状态,以下矩阵展示了这些规则:
| GS_Running | GS_Paused | |
|---|---|---|
| 输入 | 所有输入 | 仅 UI 输入 |
| 对象更新 | 所有对象 | 仅 UI 对象 |
| 碰撞检测 | 所有可碰撞对象 | 无需检查碰撞 |
| 生成 | 所有可生成对象 | 不需要生成 |
| 渲染 | 所有对象 | 所有对象 |
状态机还定义了从一个状态到另一个状态的转换。以下是一个简单的图,展示了我们当前状态机的转换过程:

这个状态图相当简单。如果你处于运行状态,那么切换到暂停状态是合法的。如果你处于暂停状态,那么切换到运行状态也是合法的。正如我们将看到的,大多数游戏比这要复杂得多!
我们为什么需要状态机?
初看之下,你可能想知道我们为什么甚至需要状态机。例如,你可以设置几个布尔标志(可能一个叫running,另一个叫paused),然后像使用枚举一样将它们插入到代码中。
考虑到我们当前的游戏只有两个状态,这个解决方案可能可行,但即使如此,如果你选择使用布尔值,它也会开始变得复杂。例如,要将状态从运行更改为暂停,我必须始终确保正确设置这两个布尔值:
running = false;
paused = true;
当我从运行状态切换到暂停状态时,我必须再次设置这两个布尔值:
running = true;
paused = false;
想象一下,如果我忘记更改这两个布尔值,游戏处于同时运行和暂停的状态,会发生什么问题!然后想象一下,如果我的游戏有三个、四个或十个状态,这会变得多么复杂!
使用枚举不是设置状态机的唯一方法,但它确实在使用布尔值时具有立即的优势:
-
枚举与其值相关联的描述性名称(例如,
GS_Paused),而布尔值只有true和false。 -
枚举已经互斥。为了使一组布尔值互斥,我必须将一个设置为
true,而将所有其他设置为false。
接下来考虑为什么我们需要状态机的原因是它简化了游戏控制的编码。大多数游戏都有几个游戏状态,我们能够轻松地管理哪些代码在哪个状态下运行是很重要的。大多数游戏常见的游戏状态示例包括:
-
加载
-
开始
-
运行
-
暂停
-
结束
-
游戏胜利
-
游戏失败
-
游戏结束
-
下一个等级
-
退出
当然,这只是一个代表性的列表,每个程序员都会为自己的游戏状态选择自己的名称。但我认为你已经明白了:游戏可以处于很多状态,这意味着能够管理每个状态发生的事情是很重要的。如果玩家在游戏暂停时角色死亡,他们往往会感到愤怒!
规划状态
我们将扩展我们的简单状态机,以包括几个更多的游戏状态。这将帮助我们更好地组织游戏的处理,并更好地定义在任何特定时间应该运行哪些过程。
下表显示了我们将为我们的游戏定义的游戏状态:
| 状态 | 描述 |
|---|---|
| 加载 | 游戏正在加载,应显示启动画面 |
| 菜单 | 主菜单正在显示 |
| 运行 | 游戏正在积极运行 |
| 暂停 | 游戏已暂停 |
| 下一个等级 | 游戏正在加载下一个等级 |
| 游戏结束 | 游戏结束,正在显示统计数据 |
| 信用 | 显示信用屏幕 |
这里是我们的状态图机器:
| 启动画面 | 加载 | 菜单 | 运行 | 暂停 | 下一个 | 游戏结束 | 信用 | |
|---|---|---|---|---|---|---|---|---|
| 输入 | 无 | 无 | UI | 所有 | UI | UI | UI | UI |
| 更新 | 启动画面 | 启动画面 | UI | 所有 | UI | UI | UI | UI |
| 碰撞检测 | 无 | 无 | 无 | 所有 | 无 | 无 | 无 | 无 |
| 生成 | 无 | 无 | 无 | 所有 | 无 | 无 | 无 | 无 |
| 渲染 | 启动画面 | 启动画面 | 菜单 | 游戏 | 游戏 | 游戏 | 游戏结束 | 信用 |
最后,这是我们的状态图:

结果表明,我们的状态图也将作为 UI 图。UI 图是程序中所有屏幕及其相互交互的图。结果是,每次我们想要在我们的游戏中切换到不同的屏幕时,我们也在切换到不同的屏幕。这并不完全是这样——当游戏暂停时,它不会启动一个全新的屏幕。然而,UI 图和状态图之间通常有非常紧密的相关性。
观察状态图,你可以很容易地看到合法的状态变化与非法的状态变化。例如,从播放状态变为暂停状态是合法的,但你不能从播放状态变为信用状态。
在此结构到位的情况下,它将指导我们实现我们想要添加到游戏中的所有最终润色功能。
定义新状态
扩展我们的游戏状态机的第一步是添加所需的enums。用以下代码替换GameState enum代码:
enum GameState
{
GS_Splash,
GS_Loading,
GS_Menu,
GS_Credits,
GS_Running,
GS_NextLevel,
GS_Paused,
GS_GameOver,
};
随着我们实现本章中涵盖的润色功能,我们将实现使用这些游戏状态的代码。
实现状态机
为了让我们的状态机产生任何效果,我们需要修改代码,使得关键决策基于游戏状态。有三个函数受到游戏状态的重大影响:
-
更新:一些游戏状态更新游戏对象,而其他游戏状态只更新 UI 或特定的精灵
-
渲染:不同的游戏状态渲染不同的项目
-
输入:一些游戏状态接受所有输入,而其他游戏状态只处理 UI 输入
因此,我们将更改Update、Render和ProcessInput函数,这应该不会令人惊讶。
首先,让我们修改Update函数。将RoboRacer2D.cpp中的Update函数修改为以下代码:
void Update(const float p_deltaTime)
{
switch (m_gameState)
{
case GameState::GS_Splash:
case GameState::GS_Loading:
{
}
break;
case GameState::GS_Menu:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
case GameState::GS_Credits:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
case GameState::GS_Running:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
CheckBoundaries(player);
CheckBackground();
background->Update(p_deltaTime);
robot_left->Update(p_deltaTime);
robot_right->Update(p_deltaTime);
robot_left_strip->Update(p_deltaTime);
robot_right_strip->Update(p_deltaTime);
pauseButton->Update(p_deltaTime);
resumeButton->Update(p_deltaTime);
pickup->Update(p_deltaTime);
SpawnPickup(p_deltaTime);
SpawnEnemy(p_deltaTime);
enemy->Update(p_deltaTime);
CheckCollisions();
}
break;
case GameState::GS_Paused:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
case GameState::GS_NextLevel:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
case GameState::GS_GameOver:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
}
}
如您所见,我们现在使用switch语句来处理每个游戏状态。这比使用if语句可读性要好得多,并且使代码结构更加清晰。如果我们需要添加另一个游戏状态,我们只需在switch语句中添加另一个case。
注意到每个case都有其代码来运行特定于该游戏状态的代码。一些代码行是重复的(几乎每个状态都有一些输入),但这为了清晰度而付出的微小代价。GS_Running需要做最多的工作,而GS_Loading需要做最少的工作。随着我们添加润色功能,我们将在每个开关中添加代码。
现在,让我们升级Render函数。用以下代码替换Render函数:
switch (m_gameState)
{
case GameState::GS_Splash:
case GameState::GS_Loading:
{
}
break;
case GameState::GS_Menu:
{
}
break;
case GameState::GS_Credits:
{
}
break;
case GameState::GS_Running:
case GameState::GS_Paused:
{
background->Render();
robot_left->Render();
robot_right->Render();
robot_left_strip->Render();
robot_right_strip->Render();
pauseButton->Render();
resumeButton->Render();
pickup->Render();
enemy->Render();
DrawScore();
}
break;
case GameState::GS_NextLevel:
{
}
break;
case GameState::GS_GameOver:
{
}
break;
}
SwapBuffers(hDC);
}
在这种情况下,我们需要做一些无论游戏状态如何都需要完成的工作。我们需要清除 OpenGL 缓冲区,并将矩阵设置为单位矩阵。然后我们根据游戏状态决定要渲染哪些项目,最后交换缓冲区。
如果您仔细观察,GS_Running和GS_Paused渲染相同的项目。这是因为暂停和渲染按钮渲染在游戏屏幕的顶部,所以即使我们在暂停时,我们仍然需要渲染整个游戏。随着我们添加润色功能,我们将为每个开关添加代码。
最后,我们需要将我们的状态机应用到ProcessInput函数上。由于该函数非常长,我仅显示函数的上部行。将所有在uiTimer += p_deltaTime;语句之上的行更改为以下代码:
Replace highlighted code with:
switch (m_gameState)
{
case GameState::GS_Splash:
case GameState::GS_Loading:
{
return;
}
break;
case GameState::GS_Menu:
case GameState::GS_Credits:
case GameState::GS_Paused:
case GameState::GS_NextLevel:
case GameState::GS_GameOver:
{
command = Input::Command::CM_UI;
}
break;
case GameState::GS_Running:
{
}
break;
}
}
uiTimer += p_deltaTime;
首先,我们获取最新的命令。然后,根据游戏状态,我们执行以下操作:
-
如果我们仍然处于加载状态,则忽略并返回
-
如果游戏状态是菜单、暂停、下一级或游戏结束,则将命令重置为仅处理 UI 命令
-
如果我们处于运行游戏状态,则保持命令不变
这正是我们在先前版本中做的,只是先前版本中我们只处理了两个游戏状态。一旦处理了命令,我们就继续到uiTimer += p_deltaTime;(此行之后的内容与先前版本相同)。
制作启动画面
启动菜单为你的游戏增添了一丝格调,同时也做了一点炫耀。通常,启动画面会展示你的公司标志。实际上,许多游戏项目由多个工作室共同制作,因此经常会有多个启动画面。我们将只使用一个!
尽快让启动画面运行起来非常重要,所以我们将在执行任何其他加载之前先做这件事。启动画面的部分功能是在游戏的其他部分加载时,给玩家一些漂亮的东西来看。
创建启动画面
创建一个定义你游戏的启动画面取决于你。为了方便,我们在本章的代码资源包中包含了一个名为splash.png的启动画面。确保将splash.png复制到你的项目中。启动画面的唯一要求是它必须是 800 x 600 像素,与我们的游戏屏幕分辨率相同。
定义启动画面
与游戏中所有图像一样,我们将启动画面实现为一个精灵。在RoboRacer2D.cpp的顶部声明启动精灵:
Sprite* splashScreen;
我们还希望为启动画面定义一些计时器:
float splashDisplayTimer;
float splashDisplayThreshold;
由于我们希望将启动画面单独定义,我们将创建一个单独的函数来加载它。使用以下代码创建LoadSplash函数:
void LoadSplash()
{
m_gameState = GameState::GS_Splash;
splashScreen = new Sprite(1);
splashScreen->SetFrameSize(800.0f, 600.0f);
splashScreen->SetNumberOfFrames(1);
splashScreen->AddTexture("resources/splash.png", false);
splashScreen->IsActive(true);
splashScreen->IsVisible(true);
}
我们不会对StartGame函数进行重大修改。我们只将加载启动画面,并推迟加载其他游戏资源。这将尽快让启动画面显示出来。将StartGame函数修改为以下代码:
void StartGame()
{
LoadSplash();
inputManager = new Input(hWnd);
uiTimer = 0.0f;
srand(time(NULL));
pickupSpawnThreshold = 3.0f;
pickupSpawnTimer = 0.0f;
enemySpawnThreshold = 7.0f;
enemySpawnTimer = 0.0f;
splashDisplayTimer = 0.0f;
splashDisplayThreshold = 5.0f;
}
注意,我们在这里只加载启动画面资源并设置了一些变量。我们还设置了启动画面计时器,以确保它至少显示五秒钟。
接下来,修改Update函数中的GS_Splash情况,使其看起来像以下代码:
switch (m_gameState)
{
case GameState::GS_Splash:
case GameState::GS_Loading:
{
splashScreen->Update(p_deltaTime);
splashDisplayTimer += p_deltaTime;
if (splashDisplayTimer > splashDisplayThreshold)
{
m_gameState = GameState::GS_Menu;
}
}
break;
此代码更新启动画面计时器。当计时器超过我们的阈值时,游戏状态将变为GS_Menu。我们将定义加载下一个菜单的代码。
修改Render函数中的GS_Splash情况,使其看起来像以下代码:
case GameState::GS_Loading:
splashScreen->Render();
break;
提示
由于启动精灵只是一个静态图像,你可能想知道为什么我们要更新启动精灵。虽然更新对我们的当前代码没有影响,但考虑一下我想实现一个动态、动画启动画面的情况。
加载我们的资源
如果你一直在注意,你应该意识到我们从StartGame函数中移除了LoadTextures调用。相反,我们将在GameLoop函数中加载纹理。将GameLoop修改为以下代码:
void GameLoop(const float p_deltatTime)
{
if (m_gameState == GameState::GS_Splash)
{
LoadTextures();
m_gameState = GameState::GS_Loading;
}
Update(p_deltatTime);
Render();
}
如果你记得,GameLoop每帧都会被调用。我们需要GameLoop运行以显示我们的启动屏幕,我们已经加载了它。但在第一次调用GameLoop时,我们还没有加载其他资源。
我们检查游戏状态是否为GS_Splash。如果是,我们调用加载纹理,并立即将游戏状态更改为GS_Loading。如果我们没有更改游戏状态,那么游戏将尝试在每一帧加载纹理,这将是非常糟糕的事情!这是我们在状态机中定义不同游戏状态的另一个实际例子。
小贴士
在某种意义上,我们还没有创建一个真正的启动屏幕。这是因为我们的启动屏幕仍然依赖于 Windows 和 OpenGL 在启动屏幕可以加载和渲染之前进行初始化。真正的启动屏幕使用一段不依赖于所有这些初始化的代码片段,以便它们可以在其他所有内容之前加载。不幸的是,这个层面的细节超出了我们书籍的范围。有时,启动屏幕将在单独的线程上运行,以便它独立于启动代码。

当你运行游戏时,你应该看到启动屏幕显示,但随后没有其他动作发生。这是因为我们在Update函数中将游戏状态更改为GS_Menu,而我们还没有为该游戏状态编写代码!如果你想测试你的启动屏幕,将Update函数中的m_gameState = GameState::GS_Menu更改为m_gameState = GameState::GS_Running。只是别忘了在继续之前将其改回。
小贴士
改变游戏状态的能力让你能够重新引导游戏流程。这在尝试编写新的游戏状态但尚未准备好在游戏中运行时非常有用。一旦新的游戏状态编写完成,你就可以将其连接到游戏中。
菜单上有什么?
主菜单在很多应用程序中可能已经消失了,但在游戏中它们仍然存在且运行良好。主菜单在游戏加载后给玩家一个决定做什么的机会。我们将创建一个简单的菜单,允许玩家开始游戏、显示信用信息或退出游戏。
创建菜单
我们将使用两个组件构建菜单。首先,我们将加载一个图像作为背景。接下来,我们将加载额外的图像作为 UI 按钮。这些图像共同创建一个屏幕,允许玩家导航我们的游戏。
我们将首先定义一个精灵来表示菜单。将以下代码行添加到RoboRacer2D.cpp中的变量声明部分:
Sprite* menuScreen;
接下来,我们将在LoadTextures函数中实例化菜单。将以下代码添加到LoadTextures:
menuScreen = new Sprite(1);
menuScreen->SetFrameSize(800.0f, 600.0f);
menuScreen->SetNumberOfFrames(1);
menuScreen->AddTexture("resources/mainmenu.png", false);
menuScreen->IsActive(true);
menuScreen->IsVisible(true);
确保你已经从书籍网站下载了menu.png纹理,或者你已经创建了自己的 800x600 像素的背景。
现在,我们必须修改Update和Render函数。将Update中的GS_Menu情况修改为以下代码:
case GameState::GS_Menu:
{
menuScreen->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
接下来,修改Render函数中的GS_Menu情况:
case GameState::GS_Menu:
{
menuScreen->Render();
}
break;
如果你现在运行游戏,启动屏幕应该显示五秒钟,然后是菜单屏幕。
定义菜单按钮
我们接下来的任务是向菜单屏幕添加玩家可以点击的按钮。这些按钮的工作方式将与我们已经创建的暂停和继续按钮类似。
我们将首先为按钮声明变量。将以下声明添加到RoboRacer2D.cpp中的变量部分:
Sprite* playButton;
Sprite* creditsButton;
Sprite* exitButton;
这三个指针将管理主菜单上的三个按钮。接下来,将以下代码添加到LoadTextures中以实例化按钮:
playButton = new Sprite(1);
playButton->SetFrameSize(75.0f, 38.0f);
playButton->SetNumberOfFrames(1);
playButton->SetPosition(390.0f, 300.0f);
playButton->AddTexture("resources/playButton.png");
playButton->IsVisible(true);
playButton->IsActive(false);
inputManager->AddUiElement(playButton);
creditsButton = new Sprite(1);
creditsButton->SetFrameSize(75.0f, 38.0f);
creditsButton->SetNumberOfFrames(1);
creditsButton->SetPosition(390.0f, 350.0f);
creditsButton->AddTexture("resources/creditsButton.png");
creditsButton->IsVisible(true);
creditsButton->IsActive(false);
inputManager->AddUiElement(creditsButton);
exitButton = new Sprite(1);
exitButton->SetFrameSize(75.0f, 38.0f);
exitButton->SetNumberOfFrames(1);
exitButton->SetPosition(390.0f, 500.0f);
exitButton->AddTexture("resources/exitButton.png");
exitButton->IsVisible(true);
exitButton->IsActive(false);
inputManager->AddUiElement(exitButton);
这段代码基本上与我们用来实例化暂停和继续按钮的代码相同。唯一的小区别是我们将所有三个按钮都设置为可见。我们的代码已经强制这些按钮只有在游戏状态GS_Menu时才会渲染。
我们确实希望将按钮设置为不活动状态。这样,input类就会忽略它们,直到我们想要激活它们。
就像我们所有的对象一样,我们现在需要将它们连接到Update和Render函数。将Update函数中的GS_Menu情况更改为以下代码:
case GameState::GS_Menu:
{
menuScreen->Update(p_deltaTime);
playButton->IsActive(true);
creditsButton->IsActive(true);
exitButton->IsActive(true);
playButton->Update(p_deltaTime);
creditsButton->Update(p_deltaTime);
exitButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
这是我们将菜单上的按钮设置为活动状态的地方。我们希望确保在游戏状态GS_Menu时菜单上的按钮是活动的。
接下来,将Render函数中的GS_Menu情况更改为以下代码:
case GameState::GS_Menu:
{
menuScreen->Render();
playButton->Render();
creditsButton->Render();
exitButton->Render();
}
break;
为了让按钮真正做些事情,我们需要将以下代码添加到ProcessInput中的CM_UI情况:
if (playButton->IsClicked())
{
playButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
m_gameState = GameState::GS_Running;
}
if (creditsButton->IsClicked())
{
creditsButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
m_gameState = GameState::GS_Credits;
}
if (exitButton->IsClicked())
{
playButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
PostQuitMessage(0);
}
注意,如果点击了播放按钮或信用按钮(如果点击了退出按钮,我们只需发送退出消息)。注意,我们必须进行一些按钮管理,当我们不再处于GS_Menu游戏状态时,将菜单上的按钮设置为不活动状态。这是因为我们的输入类会检查所有活动的按钮。如果按钮保持活动状态,则意味着即使它们没有显示在屏幕上,仍然可以被点击。
我们不必将按钮设置为不可见。这是因为改变状态将自动停止这些按钮的更新或渲染。菜单屏幕也是如此。一旦游戏状态改变,它将不会渲染或更新。这是利用状态机的重大优势之一。

如果你现在运行程序,主菜单将显示。如果你点击播放按钮,游戏将开始。如果你点击退出按钮,游戏将退出。我们将接下来实现信用屏幕。
获得一些信用
每个人都喜欢为自己的辛勤工作获得认可!大多数游戏都会实现一个信用屏幕,显示每个参与创建游戏的人的名字和职能。对于 AAA 级游戏,这个列表可能和电影列表一样长。对于小型独立游戏,这个列表可能只有三个人。
创建信用屏幕
与主菜单类似,信用屏幕将基于背景图像和可点击的按钮。我们还需要在屏幕上添加文本。
让我们从在 RoboRacer2D.cpp 的变量部分声明我们的屏幕指针开始。添加以下声明:
Sprite* creditsScreen;
然后,我们在 LoadTextures 中实例化信用屏幕:
creditsScreen = new Sprite(1);
creditsScreen->SetFrameSize(800.0f, 600.0f);
creditsScreen->SetNumberOfFrames(1);
creditsScreen->AddTexture("resources/credits.png", false);
creditsScreen->IsActive(false);
creditsScreen->IsVisible(true);
接下来,我们将信用屏幕连接到 Update:
case GameState::GS_Credits:
{
creditsScreen->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
我们还更新了 Render:
case GameState::GS_Credits:
{
creditsScreen->Render();
}
break;
返回主菜单
现在,我们需要添加一个按钮,允许我们从信用屏幕返回主菜单。我们首先在变量声明部分声明指针:
Sprite* menuButton;
我们然后在 LoadTextures 中实例化按钮:
menuButton = new Sprite(1);
menuButton->SetFrameSize(75.0f, 38.0f);
menuButton->SetNumberOfFrames(1);
menuButton->SetPosition(390.0f, 400.0f);
menuButton->AddTexture("resources/menuButton.png");
menuButton->IsVisible(true);
menuButton->IsActive(false);
inputManager->AddUiElement(menuButton);
让我们在 Update 中添加按钮:
case GameState::GS_Credits:
{
creditsScreen->Update(p_deltaTime);
menuButton->IsActive(true);
menuButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
我们还更新了 Render:
case GameState::GS_Credits:
{
creditsScreen->Render();
menuButton->Render();
}
break;
与菜单按钮类似,我们现在需要在 ProcessInput 中的 Input::Command::CM_UI: 情况中添加代码来处理点击菜单按钮:
if (menuButton->IsClicked())
{
menuButton->IsClicked(false);
menuButton->IsActive(false);
m_gameState = GameState::GS_Menu;
}
当菜单按钮被点击时,我们将游戏状态改回菜单,并将菜单按钮设置为不活动。由于我们已经编写的代码,菜单屏幕将自动显示。

与字体一起工作
到目前为止,我们已经在现有的纹理中嵌入任何需要的文本。然而,有时我们可能希望代码决定要显示的文本。例如,在我们的信用屏幕上,我们不想为参与创建游戏的每个人的名字制作图形。
创建字体
我们需要一种方法将文本直接渲染到屏幕上,这意味着我们还需要一种方法来定义在渲染文本时想要使用的字体。首先,我们需要添加一个全局变量,作为我们字体的句柄。将以下行添加到代码中的变量声明部分:
GLuint fontBase;
现在,我们需要添加以下代码来创建字体:
GLvoid BuildFont(GLvoid)
{
HFONT newFont;
HFONT tempFont;
fontBase = glGenLists(96);
tempFont = CreateFont(-26, // Height
0, // Width
0, // Escapement
0, // Orientation
FW_BOLD, // Weight
FALSE, // Italic
FALSE, // Underline
FALSE, // Strikeout
ANSI_CHARSET, // Character Set
OUT_TT_PRECIS, // Output Precision
CLIP_DEFAULT_PRECIS, // Clipping Precision
ANTIALIASED_QUALITY,// Output Quality
FF_DONTCARE | DEFAULT_PITCH, // Family/Pitch
"Courier New"); // Font Name
newFont = (HFONT)SelectObject(hDC, tempFont);
wglUseFontBitmaps(hDC, 32, 96, fontBase);
SelectObject(hDC, newFont);
DeleteObject(tempFont);
}
此代码使用三个主要元素创建字体。
首先,我们使用 glGenLists 创建 96 个显示列表来存储我们字体的每个字母。显示列表基本上是一个可以存储渲染数据的缓冲区。接下来,我们调用 CreateFont 创建一个 Windows 字体。CreateFont 函数的参数指定了我们想要创建的字体的类型。最后,我们使用 wglUseFontBitmaps 将我们创建的新字体分配给之前创建的字体句柄。
一个小技巧是我们必须创建一个具有所有属性的临时 HFONT 对象,称为 tempFont,然后将 tempFont 分配给 newFont 并删除 tempFont。
当程序关闭时,我们想要删除显示列表,所以添加以下实用函数:
GLvoid KillFont(GLvoid)
{
glDeleteLists(fontBase, 96);
}
此代码简单地使用 glDeleteLists 删除我们创建来存储字体的显示列表。
绘制文本
现在我们有了字体,我们需要一个函数将文本渲染到屏幕上。将以下函数添加到代码中:
void DrawText(const char* p_text, const float p_x, const float p_y, const float r, const float g, const float b)
{
glBindTexture(GL_TEXTURE_2D, 0);
glColor3f(r, g, b);
glRasterPos2f(p_x, p_y);
if (p_text != NULL)
{
glPushAttrib(GL_LIST_BIT);
glListBase(fontBase - 32);
glCallLists(strlen(p_text), GL_UNSIGNED_BYTE, p_text);
glPopAttrib();
}
glColor3f(1.0f, 1.0f, 1.0f);
}
此代码接受一个字符串和一个 x 和 y 位置,并在该位置绘制文本。它还接受 r、g 和 b 参数来定义文本颜色:
-
glBindTexture(GL_TEXTURE_2D, 0): 这告诉 OpenGL 我们将要处理 2D 纹理(即字体)glColor3f(r, g, b): 这设置字体的颜色。 -
glRasterPos2f: 这用于设置屏幕上的当前绘制位置。 -
glPushAttrib(GL_LIST_BIT): 这告诉 OpenGL 我们将使用显示列表进行渲染。 -
glListBase: 这将设置列表的当前起始位置。我们减去 32,因为空格的 ASCII 值是 32,我们不使用 ASCII 值较低的字符。 -
glCallLists: 这用于检索文本中每个字符的列表。 -
glPopAttrib: 这将 OpenGL 属性返回到其先前值。
现在,我们准备绘制我们的致谢文本:
void DrawCredits()
{
float startX = 325.0f;
float startY = 250.0f;
float spaceY = 30.0f;
DrawText("Robert Madsen", startX, startY, 0.0f, 0.0f, 1.0f);
DrawText("Author", startX, startY + spaceY, 0.0f, 0.0f, 1.0f);
}
首先,我们设置屏幕上想要绘制的位置,然后我们使用DrawText函数进行实际绘制。第一行是我(一种微妙的放纵),第二行是给你的!
链接字体支持
我们还有一些账务任务要完成,以便使字体支持工作。首先,修改GameLoop代码,添加高亮行:
if (m_gameState == GameState::GS_Splash)
{
BuildFont();
LoadTextures();
m_gameState = GameState::GS_Loading;
}
这将在游戏启动时创建我们的字体。
接下来,在Render函数中的m_gameState开关的GS_Credits情况中填写:
case GameState::GS_Credits:
{
creditsScreen->Update(p_deltaTime);
menuButton->IsActive(true);
menuButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
当游戏状态变为GS_Credits时,这将绘制致谢文本。恭喜!你终于可以得到你应得的荣誉了!
等级提升!
游戏中的很多乐趣在于尝试提高分数。良好的游戏设计的一部分是使游戏具有挑战性,但不要过于挑战,以至于玩家无法得分或提高。
大多数玩家在玩游戏的过程中也会变得更好,所以如果游戏难度不增加,玩家最终会感到无聊,因为玩家将不再面临挑战。
我们将首先在屏幕上简单地显示分数,以便玩家可以看到他们的表现。然后我们将讨论用于不断增加游戏难度、从而稳步增加挑战的技术。
显示分数
当我们创建致谢屏幕时,我们已经学习了如何在屏幕上显示文本。现在,我们将使用相同的技术来显示分数。
如果你记得,我们已经有了一个跟踪分数的机制。每个精灵都有一个值属性。对于拾取物,我们分配一个正值,以便玩家每次拾取都能得分。对于敌人,我们分配一个负值,以便玩家每次与敌人碰撞都会失去分数。我们将当前分数存储在玩家的值属性中。
将以下代码添加到RoboRacer2D.cpp以创建DrawScore函数:
void DrawScore()
{
char score[50];
sprintf_s(score, 50, "Score: %i", player->GetValue());
DrawText(score, 350.0f, 25.0f, 0.0f, 0.0f, 1.0f);
}
这段代码的工作方式与之前创建的DrawCredits函数相同。首先,我们创建一个包含当前分数和标题的字符串,然后我们使用DrawText来渲染文本。
我们还需要将其连接到主游戏中。修改Render函数中的m_gameState开关的GS_Running情况,使用高亮行:
case GameState::GS_Running:
case GameState::GS_Paused:
{
background->Render();
robot_left->Render();
robot_right->Render();
robot_left_strip->Render();
robot_right_strip->Render();
pauseButton->Render();
resumeButton->Render();
pickup->Render();
enemy->Render();
DrawScore();
}
break;
得分将在游戏运行时和游戏暂停时都显示。
游戏进度
为了给游戏添加进度,我们需要建立某些阈值。对于我们的游戏,我们将设置三个阈值:
-
每个关卡将持续两分钟
-
如果玩家在一个关卡中收到的拾取物品少于五个,游戏将结束,并显示游戏结束屏幕。
-
如果玩家收到了五个或更多的拾取物品,那么关卡结束,将显示下一级屏幕。
对于玩家成功完成的每个关卡,我们将使游戏变得更难。我们可以以多种方式增加每个关卡的难度:
-
增加拾取物品的生成时间
-
减少机器人的速度
为了保持简单,我们只会做其中之一。我们将为每个关卡增加拾取物品生成时间的阈值 0.25 秒。随着拾取物品生成频率的降低,玩家最终会收到过多的拾取物品,游戏将结束。
定义游戏关卡
让我们设置关卡进度的代码。我们将首先定义一个计时器来跟踪已经过去的时间。将以下声明添加到RoboRacer2D.cpp中:
float levelTimer;
float levelMaxTime;
float pickupSpawnAdjustment;
int pickupsReceived;
int pickupsThreshold;
int enemiesHit;
我们将在StartGame函数中初始化变量:
levelTimer = 0.0f;
levelMaxTime = 30.0f;
pickupSpawnAdjustment = 0.25f;
pickupsReceived = 0;
pickupsThreshold = 5;
enemiesHit =0;
我们正在设置一个计时器,它将运行 120 秒,即两分钟。两分钟后,关卡将结束,拾取物品的生成时间将增加 0.25 秒。我们还将检查玩家是否收到了五个拾取物品。如果没有,游戏将结束。
为了处理关卡进度的逻辑,让我们添加一个名为NextLevel的新函数,通过添加以下代码:
void NextLevel()
{
if (pickupsReceived < pickupsThreshold)
{
m_gameState = GameState::GS_GameOver;
}
else
{
pickupSpawnThreshold += pickupSpawnAdjustment;
levelTimer = 0.0f;
m_gameState = GameState::GS_NextLevel;
}
}
如前所述,我们检查机器人拾取的物品数量是否少于拾取阈值。如果是这样,我们将游戏状态更改为GS_GameOver。否则,我们将重置关卡计时器,重置收到的拾取物品计数器,增加拾取物品生成计时器,并将游戏状态重置为GS_Running。
我们仍然需要添加一些代码来更新关卡计时器并检查关卡是否结束。将以下代码添加到Update函数中的GS_Running情况:
levelTimer += p_deltaTime;
if (levelTimer > levelMaxTime)
{
NextLevel();
}
此代码更新关卡计时器。如果计时器超过我们的阈值,则调用NextLevel以查看接下来会发生什么。
最后,我们需要在CheckCollisions中添加两行代码来计算玩家收到的拾取物品数量。将以下高亮显示的代码行添加到CheckCollisions中:
if (player->IntersectsCircle(pickup))
{
pickup->IsVisible(false);
pickup->IsActive(false);
player->SetValue(player->GetValue() + pickup->GetValue());
pickupSpawnTimer = 0.0f;
pickupsReceived++;
}
if (player->IntersectsRect(enemy))
{
enemy->IsVisible(false);
enemy->IsActive(false);
player->SetValue(player->GetValue() + enemy->GetValue());
enemySpawnTimer = 0.0f;
enemiesHit++;
}
游戏统计数据
如果玩家能够在每个关卡之间看到自己的表现,那将很棒。让我们添加一个功能来显示玩家的统计数据:
void DrawStats()
{
char pickupsStat[50];
char enemiesStat[50];
char score[50];
sprintf_s(pickupsStat, 50, "Enemies Hit: %i", enemiesHit);
sprintf_s(enemiesStat, 50, "Pickups: %i", pickupsReceived);
sprintf_s(score, 50, "Score: %i", player->GetValue());
DrawText(enemiesStat, 350.0f, 270.0f, 0.0f, 0.0f, 1.0f);
DrawText(pickupsStat, 350.0f, 320.0f, 0.0f, 0.0f, 1.0f);
DrawText(score, 350.0f, 370.0f, 0.0f, 0.0f, 1.0f);
}
我们现在将把这个功能连接到下一级屏幕。
下一级屏幕
现在我们已经有了检测关卡结束的逻辑,是时候实现我们的下一级屏幕了。到目前为止,这个过程应该已经变得很自然,所以让我们尝试一个简化的方法:
-
声明一个指向屏幕的指针:
Sprite* nextLevelScreen; -
在
LoadTextures中实例化精灵:nextLevelScreen = new Sprite(1); nextLevelScreen->SetFrameSize(800.0f, 600.0f); nextLevelScreen->SetNumberOfFrames(1); nextLevelScreen->AddTexture("resources/level.png", false); nextLevelScreen->IsActive(true); nextLevelScreen->IsVisible(true); -
修改
Update函数中的GS_NextLevel情况:case GameState::GS_NextLevel: { nextLevelScreen->Update(p_deltaTime); continueButton->IsActive(true); continueButton->Update(p_deltaTime); inputManager->Update(p_deltaTime); ProcessInput(p_deltaTime); break; } -
修改
Render函数中的GS_NextLevel情况,使其看起来像以下代码:case GameState::GS_NextLevel: { nextLevelScreen->Render(); DrawStats(); continueButton->Render(); } break;
继续游戏
现在,我们需要添加一个按钮,允许玩家继续游戏。同样,你已经这样做了很多次,所以我们将使用一种简化的方法:
-
声明按钮指针:
Sprite* continueButton; -
在
LoadTextures中实例化按钮:continueButton = new Sprite(1); continueButton->SetFrameSize(75.0f, 38.0f); continueButton->SetNumberOfFrames(1); continueButton->SetPosition(390.0f, 400.0f); continueButton->AddTexture("resources/continueButton.png"); continueButton->IsVisible(true); continueButton->IsActive(false); inputManager->AddUiElement(continueButton); -
将此代码添加到
Update中:case GameState::GS_NextLevel: { nextLevelScreen->Update(p_deltaTime); continueButton->IsActive(true); continueButton->Update(p_deltaTime); inputManager->Update(p_deltaTime); ProcessInput(p_deltaTime); } break; -
将此代码添加到
Render中:case GameState::GS_NextLevel: { nextLevelScreen->Render(); DrawStats(); continueButton->Render(); } break; -
将此代码添加到
ProcessInput中:if (continueButton->IsClicked()) { continueButton->IsClicked(false); continueButton->IsActive(false); m_gameState = GameState::GS_Running; pickupsReceived = 0; enemiesHit = 0; }
点击继续按钮只是将游戏状态改回 GS_Running。当调用 NextLevel 时,等级计算已经发生。
游戏结束
正如俗话所说,所有美好的事物都必须结束。如果玩家没有达到拾取阈值,游戏将结束,并显示游戏结束界面。玩家可以选择重新玩游戏或退出。
游戏结束界面
我们最后的屏幕是游戏结束界面。到现在,这个过程应该已经变得很自然,所以让我们尝试一种简化的方法:
-
声明屏幕指针:
Sprite* gameOverScreen; -
在
LoadTextures中实例化精灵:gameOverScreen = new Sprite(1); gameOverScreen->SetFrameSize(800.0f, 600.0f); gameOverScreen->SetNumberOfFrames(1); gameOverScreen->AddTexture("resources/gameover.png", false); gameOverScreen->IsActive(true); gameOverScreen->IsVisible(true); -
将
Update函数中的GS_GameOver情况修改为以下代码:case GameState::GS_GameOver: { gameOverScreen->Update(p_deltaTime); replayButton->IsActive(true); replayButton->Update(p_deltaTime); exitButton->IsActive(true); exitButton->Update(p_deltaTime); inputManager->Update(p_deltaTime); ProcessInput(p_deltaTime); } break; -
将以下代码添加到
Render中:case GameState::GS_GameOver: { gameOverScreen->Render(); replayButton->Render(); DrawStats(); } break;
作为额外奖励,我们还将绘制游戏统计信息在游戏结束界面上。

重新玩游戏
我们需要一种方法来将游戏重置到其初始状态。所以,让我们创建一个函数来做这件事:
void RestartGame()
{
player->SetValue(0);
robot_right->SetValue(0);
robot_left->SetValue(0);
pickupSpawnThreshold = 5.0f;
pickupSpawnTimer = 0.0f;
enemySpawnThreshold = 7.0f;
enemySpawnTimer = 0.0f;
splashDisplayTimer = 0.0f;
splashDisplayThreshold = 5.0f;
levelTimer = 0.0f;
pickupsReceived = 0;
pickupsThreshold = 5;
pickupsReceived = 0;
pickup->IsVisible(false);
enemy->IsVisible(false);
background->SetVelocity(0.0f);
robot_left->SetPosition(screen_width / 2.0f - 50.0f, screen_height - 130.0f);
robot_left->IsVisible(false);
robot_right->SetPosition(screen_width / 2.0f - 50.0f, screen_height - 130.0f);
player = robot_right;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(0.0f);
}
接下来,我们需要添加一个按钮,允许玩家重新玩游戏。同样,由于你已经这样做了很多次,我们将使用一种简化的方法:
-
声明按钮指针:
Sprite* replayButton; -
在
LoadTextures中实例化按钮:replayButton = new Sprite(1); replayButton->SetFrameSize(75.0f, 38.0f); replayButton->SetNumberOfFrames(1); replayButton->SetPosition(390.0f, 400.0f); replayButton->AddTexture("resources/replayButton.png"); replayButton->IsVisible(true); replayButton->IsActive(false); inputManager->AddUiElement(replayButton); -
将以下代码添加到
Update中:case GameState::GS_GameOver: { gameOverScreen->Update(p_deltaTime); replayButton->IsActive(true); replayButton->Update(p_deltaTime); exitButton->IsActive(true); exitButton->Update(p_deltaTime); inputManager->Update(p_deltaTime); ProcessInput(p_deltaTime); } break; -
将以下代码添加到
Render中:case GameState::GS_GameOver: { gameOverScreen->Render(); replayButton->Render(); DrawStats(); } break; -
将以下代码添加到
ProcessInput中:if (replayButton->IsClicked()) { replayButton->IsClicked(false); replayButton->IsActive(false); exitButton->IsActive(false); RestartGame(); m_gameState = GameState::GS_Running; }
注意我们如何在 Update 函数中重复使用退出按钮。此外,如果玩家想要重新玩游戏,当玩家点击重玩按钮时,我们将调用 RestartGame 函数。这将重置所有游戏变量,并允许玩家从头开始。

概述
在本章中,我们涵盖了大量的内容。本章的重点是向游戏中添加所有最终元素,使其成为一个真正精致的游戏。这涉及到添加很多屏幕和按钮,为了管理所有这些,我们引入了一个更高级的状态机。状态机就像交通指挥官,根据游戏状态将游戏路由到正确的例程。
在下一章中,我们将向我们的游戏添加音效和音乐!
第七章。音频肾上腺素
这是关于我们一直在工作的 2D 游戏的最后一章。尽管我们的 Robo Racer 2D 游戏几乎完成,但我们还没有包括一个元素来使其成为一个完整的游戏。除非你喜欢无声电影,否则你可能已经注意到我们在这个游戏中没有任何音频。大多数游戏都依赖于音频,我们的也不例外。在本章中,我们将介绍音频以及一些其他维护事项。
-
音频格式:了解音频在计算机中的表示方式以及它在游戏中的应用方式非常重要。我们将讨论采样率和比特数,并帮助你理解音频是如何工作的。
-
音频引擎:我们需要某种音频引擎来将音频集成到我们的游戏中。我们将讨论 FMOD,这是一个非常流行的引擎,它允许你使用 C++轻松集成音频。
-
音效:音效在大多数游戏中扮演着巨大的角色,我们将为我们的游戏添加音效,使其栩栩如生。
-
音乐:大多数游戏都使用某种形式的音乐。音乐的处理方式与音效不同,你将学习这两者之间的区别。
-
最后的维护:最后一点,对于我们的游戏,我们在这个章节中保留了游戏关闭。我们并不是很好的程序员,因为我们没有正确释放游戏中的对象。我们将学习为什么这样做很重要,以及如何做到这一点。
比特和字节
音频本质上是一种模拟体验。声音是通过压缩波在空气中传播并与我们的耳膜相互作用而创造的。直到最近,用于重现音频的技术也是严格属于音频领域的。例如,麦克风记录声音的方式与我们的耳朵相似,通过捕捉空气压力的变化并将其转换为电脉冲。扬声器通过将电信号转换回空气压力的波来实现相反的过程。
相反,计算机是数字的。计算机通过采样音频将音频样本转换为比特和字节。为了简化,让我们考虑一个系统,其中声音波的当前频率(即波移动的速度)被捕获为一个 16 位(2 字节)的数字。结果是,16 位数字可以捕获从 0 到 65,536 的数字范围。每个声音波的样本都必须编码在这个范围内。此外,由于我们实际上每次捕获两个样本(用于立体声),我们需要 4 个字节来捕获每个样本。
下一个重要因素是您多久采样一次声音。音频频率的范围大致从 20 赫兹到 20,000 赫兹(Hz = 每秒周期数)。一位名叫尼奎斯特的非常聪明的人发现,为了准确捕捉波形,我们必须以两倍于音频频率的频率采样。这意味着我们每秒至少需要捕捉 40,000 个样本才能准确捕捉声音。相反,我们必须以相同的频率播放声音。这就是为什么光盘上的音频采样频率为 44,100 赫兹。
你现在应该能够看出,处理声音需要大量的磁盘空间和内存。一分钟的音频文件大约需要 10 MB 的存储空间!这意味着,如果我们一次性加载整个音频文件,相同的音频将需要 10 MB 的内存。
你可能会想知道现代游戏是如何运作的。有些游戏的配乐时长以小时计算,而不是分钟。同样,可能会有数百甚至数千个音效,更不用说录音的语音,这些都以音频的形式记录。
音效的别称
音频文件可以存储在许多格式中。我们将处理两种在游戏中常用的常见格式:WAV 文件和 MP3 文件。WAV 文件以未压缩的格式存储音频数据。
虽然 WAV 文件可以用于所有音频,但它们通常用于音效。音效通常非常短,通常不到 1 秒。这意味着文件的大小将会相对较小,因为音频文件非常短。
虽然音效通常以 WAV 文件保存,但音乐通常不是。这是因为音乐的长度往往比音效长得多。将一个三到五分钟长的音乐文件加载到内存中会消耗大量的内存。
处理较大音频文件的主要技术有两种。首先,可以使用数据压缩来减小音频文件的大小。提供数据压缩的最常见音频格式之一是 MP3 格式。通过数学技巧,MP3 文件在不牺牲任何音质的情况下,将声音数据存储在更小的空间中。
处理大文件的第二种技术是流式传输。不是将整个声音文件一次性加载到内存中,而是将文件分批次作为连续的数据流发送,然后在游戏中播放。
流式传输有一些局限性。首先,从硬盘或另一个存储设备传输数据比从内存中传输数据慢得多。流式音频可能会出现延迟,这是从触发播放到声音实际播放所需的时间。
对于音效来说,延迟比音乐更重要。这是因为特定的音效通常与游戏中刚刚发生的事情相吻合。如果子弹发射后半秒才听到子弹的声音,那会让人感到不安!另一方面,音乐通常开始后持续几分钟。音乐开始时的轻微延迟通常可以忽略不计。
制造噪音
进入一个关于创建声音和音乐的全面课程,当然超出了本书的范围。然而,我确实想给你提供一些资源,帮助你开始学习。
你可能会问的第一个问题是哪里可以找到声音。实际上,有成千上万的网站提供可以在游戏中使用的声音和音乐。许多网站收费,而一些网站提供免费音频。
需要记住的一点是,免版税并不一定意味着免费。免版税音频意味着一旦你获得使用音频的许可,你就不必为使用音乐支付任何额外费用。
所以,这里是我的大贴士。我找到的每个网站都为音效和音乐收取一小笔费用。但有一种方法,我找到了免费获取声音的方法,那就是使用Unity 资产商店。前往unity3d.com并安装免费的 Unity 版本。一旦你启动了 Unity,执行以下步骤:
-
通过点击Unity 项目向导中的创建新项目标签创建一个新的项目。点击浏览并导航到或创建一个文件夹来存储你的项目。然后点击选择文件夹。
-
一旦 Unity 加载了项目,点击窗口然后从菜单中选择资产商店。
-
当资产商店窗口出现时,在搜索资产商店文本框中输入相关搜索词(例如,音乐或 SFX),然后按Enter键。
-
浏览免费资产的结果。点击任何列表以获取更多详细信息。如果你找到了你喜欢的东西,点击下载链接。
-
一旦 Unity 下载了资产,将出现名为导入包的屏幕。点击导入按钮。
-
现在,你可以退出 Unity 并导航到你创建新项目的文件夹。然后导航到
Assets文件夹。从这里,取决于你导入的包的结构,但如果你四处浏览,你应该能够找到音频文件。小贴士
实际上,我们正在使用 Robson Cozendey 提供的名为 Jolly Bot 的音乐作品。www.cozendey.com。我们还找到了一个很棒的 SFX 包。
-
现在,你可以将音频文件复制到你的项目中!
小贴士
在浏览音频文件时,你可能会遇到一些带有
ogg扩展名的文件。这是一种类似于 MP3 的常见音频格式。然而,我们将使用的引擎不支持 ogg 文件,因此你需要将它们转换为 MP3 文件。接下来要介绍的 Audacity 将允许你将音频文件从一种格式转换为另一种格式。
你可能会发现你想编辑或混合你的音频文件。或者,你可能需要将你的音频文件从一种格式转换为另一种格式。我发现最适合处理音频的免费工具是Audacity,你可以在audacity.sourceforge.net/下载它。Audacity 是一个功能齐全的音频混音器,它将允许你播放、编辑和转换音频文件。
小贴士
要将文件导出为 MP3 格式,你需要在你的系统上安装一份LAME。你可以在lame.buanzo.org/#lamewindl下载 LAME。
提高你的引擎
现在你已经更好地理解了计算机中音频的工作方式,是时候编写一些代码将音频引入你的游戏了。我们通常不直接处理音频。相反,有音频引擎为我们做所有艰苦的工作,其中最受欢迎的一个是FMOD。
FMOD 是一个 C 和 C++ API,它允许我们加载、管理和播放音频源。FMOD 对学生和独立项目免费使用,因此它是我们游戏的完美音频引擎。要使用 FMOD,你必须访问 FMOD 网站,下载适当的 API 版本,并将其安装到你的系统上:
-
要下载 FMOD,请访问
www.FMOD.org/download/。 -
有几个下载选项可供选择。向下滚动到FMOD Ex 程序员 API,然后点击 Windows 的下载按钮。
-
你必须找到你刚刚下载的 exe 文件并安装它。记下 FMOD 安装的文件夹。
-
下载 FMOD 后,你必须将其集成到游戏项目中。首先打开
RoboRacer2D项目。
小贴士
我相信你很想看到FMOD API的完整文档。如果你在默认位置安装了 FMOD,你将在C:\Program Files (x86)\FMOD SoundSystem\FMOD Programmers API Windows\documentation找到文档。主要文档位于文件 fmodex.chm 中。
现在,是时候设置我们的游戏以使用 FMOD 了。类似于大多数第三方库,连接东西有三个步骤:
-
访问
.dll文件。 -
链接到库。
-
指向包含文件。
让我们一步步来。
访问 FMOD .dll 文件
FMOD 包含几个.dll文件,使用正确的文件很重要。以下表格总结了随 FMOD 提供的 dll 文件及其相关的库文件:
| Dll | 描述 | 库 |
|---|---|---|
fmodex.dll |
32 位 FMOD API | fmodex_vc.lib |
fmodexL.dll |
32 位带调试日志的 FMOD API | fmodexL_vc.lib |
fmodex64.dll |
64 位 FMOD API | fmodex64_vc.lib |
fmodexL64.dll |
64 位带调试日志的 FMOD API | fmodexL64_vc.lib |
由你决定是否使用库的 32 位或 64 位版本。库的调试版本会将日志信息写入文件。你可以在文档中找到更多信息。
我们将在游戏中使用 32 位文件。我们可以将文件放置在几个地方,但最简单的方法是将.dll文件直接复制到我们的项目中:
-
导航到
C:\Program Files (x86)\FMOD SoundSystem\FMOD Programmers API Windows\api。小贴士
前面的路径假设你使用了默认的安装位置。如果你选择了另一个位置,你可能需要修改路径。
-
将
fmodex.dll复制到包含RoboRacer2D源代码的项目文件夹中。
链接到库
下一步是告诉 Visual Studio 我们想要访问 FMOD 库。这是通过将库添加到项目属性中完成的:
-
右键单击项目并选择属性。
-
在配置属性下的链接器分支中打开,然后点击输入。链接到库
-
在添加依赖项条目中点击,然后点击下拉箭头并选择<编辑…>。
-
将
fmodex_vc.lib添加到依赖项列表中。链接到库 -
点击确定关闭
附加依赖项窗口。 -
点击确定关闭
属性页窗口。
现在,我们必须告诉 Visual Studio 在哪里可以找到库:
-
右键单击项目并选择属性。链接到库
-
在配置属性下的链接器分支中打开,然后点击常规。
-
在附加库目录条目中点击,然后点击下拉箭头并选择<编辑…>:链接到库
-
点击新行图标,然后点击出现的省略号(…)。
-
导航到
C:\Program Files (x86)\FMOD SoundSystem\FMOD Programmers API Windows\api\lib并点击选择文件夹。 -
点击确定关闭
附加库目录窗口。 -
点击确定关闭
属性页窗口。
指向包含文件
无论何时使用第三方代码,通常你都必须在代码中包含 C++头文件。有时,我们只是将相关的头文件复制到项目文件夹中(例如,这就是我们处理SOIL.h的方式)。
对于像 FMOD 这样的大型代码库,我们将 Visual Studio 指向头文件安装的位置:
-
右键单击项目并选择属性。指向包含文件
-
在配置属性下的C/C++分支中打开,然后点击常规。
-
点击附加包含目录条目,然后点击下拉箭头,选择<编辑…>。指向包含文件
-
点击新行图标,然后点击出现的省略号(…)。
-
导航到
C:\Program Files (x86)\FMOD SoundSystem\FMOD Programmers API Windows\api\inc并点击选择文件夹。 -
点击确定关闭
附加包含目录窗口。 -
点击确定关闭
属性页窗口。
最后一步是将头文件包含到我们的程序中。打开RoboRacer2D.cpp并将以下行添加到包含头文件:
#include "fmod.hpp"
你终于准备好使用我们的音频引擎了!
初始化 FMOD
我们需要添加的第一段代码是初始化音频引擎的代码。就像我们必须初始化 OpenGL 一样,这段代码将设置 FMOD 并检查过程中是否有任何错误。
打开RoboRacer2D.cpp并将以下代码添加到变量声明区域:
FMOD::System* audiomgr;
然后添加以下函数:
bool InitFmod()
{
FMOD_RESULT result;
result = FMOD::System_Create(&audiomgr);
if (result != FMOD_OK)
{
return false;
}
result = audiomgr->init(50, FMOD_INIT_NORMAL, NULL);
if (result != FMOD_OK)
{
return false;
}
return true;
}
此函数创建 FMOD 系统并初始化它:
-
首先,我们定义一个变量来捕获 FMOD 错误代码
-
System_Create调用创建引擎并将结果存储在audiomgr -
然后我们用 50 个虚拟通道、正常模式初始化 FMOD,
最后,我们需要调用InitAudio函数。修改GameLoop函数,添加高亮行:
void GameLoop(const float p_deltatTime)
{
if (m_gameState == GameState::GS_Splash)
{
InitFmod();
BuildFont();
LoadTextures();
m_gameState = GameState::GS_Loading;
}
Update(p_deltatTime);
Render();
}
虚拟通道
FMOD 为我们提供的最重要的功能是虚拟通道。每个播放的声音都必须有自己的通道来播放。播放音频的物理通道数量因设备而异。早期的声卡一次只能处理两到四个声道的声音。现代声卡可能能够处理八个、十六个甚至更多的声道。
以前,确保在任何时候播放的声音数量不超过硬件通道数量是由开发者负责的。如果游戏触发了一个新的声音,但没有可用通道,那么声音就不会播放。这导致了音频的断断续续和不可预测。
幸运的是,FMOD 为我们处理了所有这些。FMOD 使用虚拟通道,并允许你决定想要使用多少虚拟通道。在幕后,FMOD 决定在任何给定时间需要分配给硬件通道的虚拟通道。
在我们的代码示例中,我们用 50 个虚拟通道初始化了 FMOD。这实际上比我们在这个游戏中会用到的要多得多,但对于一个完整游戏来说这并不夸张。在考虑分配多少虚拟通道时,你应该考虑在任何特定时间将加载多少音频源。这些声音不会同时播放,只是可供播放。
通道优先级
FMOD 无法使你的硬件播放比物理声道更多的同时声音,因此你可能想知道为什么你总是会分配比硬件通道更多的虚拟通道。
这个问题的第一个答案是,你实际上不知道玩家实际在系统中玩游戏时会有多少硬件通道可用。虚拟通道的使用消除了你的这个担忧。
第二个答案是,虚拟通道允许你设计你的音频,就像你真的有 50(或 100)个通道可用一样。然后 FMOD 在幕后负责管理这些通道。
那么,如果你的游戏需要播放第九个声音,而只有八个物理通道会发生什么?FMOD 使用优先级系统来决定当前八个通道中哪一个不再需要。例如,第七个通道可能被分配给一个不再播放的声音效果。然后 FMOD 将第七个通道分配给想要播放的新声音。
如果所有物理通道现在都在播放声音,而 FMOD 需要播放一个新的声音,那么它将选择优先级最低的通道,停止在该通道上播放声音,并播放新的声音。决定优先级的因素包括:
-
声音被触发的时间有多久
-
声音是否被设置为连续循环
-
程序员使用
Channel:setPriority或Sound::setDefaults函数分配的优先级 -
在 3D 声音中,声音距离的远近
-
声音的当前音量
因此,如果您的声音设计超过了同时物理通道的数量,您最终可能会得到丢失的声音。但 FMOD 会尽力限制这种影响。
喇叭声和嘟嘟声
想象一下观看一个没有声音的电影。当主要角色沿着小巷跑时,没有脚步声。当他手臂摩擦夹克时,没有摩擦声。当一辆车在他即将撞到他之前停下来时,没有尖叫声。
没有声音的电影会相当无聊,大多数游戏也是如此。声音让游戏栩栩如生。最好的声音设计是玩家实际上并没有意识到有声音设计。这意味着以补充游戏而不令人讨厌的方式制作音效和音乐。
音效
音效通常对应于游戏中发生的一些事件或动作。特定的声音通常对应于玩家可以看到的东西,但音效也可能发生在玩家看不到的地方,比如在角落附近。
让我们向游戏中添加第一个音效。我们将保持简单,并添加以下声音:
-
当 Robo 在屏幕上移动时发出的滚动声音
-
当 Robo 跳起或跳下时发出的声音
-
当他与油罐碰撞时发出的欢快声音
-
当他与水瓶碰撞时发出的不太愉快的声音
设置声音
我们将首先设置一些变量作为指向我们的声音的指针。打开 RoboRacer2D.cpp 并在变量声明部分添加以下代码:
FMOD::Sound* sfxWater;
FMOD::Sound* sfxOilcan;
FMOD::Sound* sfxJump;
FMOD::Sound* sfxMovement;
FMOD::Channel* chMovement;
我们有三个指向声音的指针和一个指向通道的指针。我们只需要一个通道指针,因为只有一个声音(sfxMovement)将是循环声音。循环声音需要一个持久的通道指针,而一次性声音则不需要。
接下来,我们将加载这些声音。将以下函数添加到 RoboRacer2D.cpp:
const bool LoadAudio()
{
FMOD_RESULT result;
result = audiomgr-> createSound ("resources/oil.wav", FMOD_DEFAULT, 0, &sfxOilcan);
result = audiomgr-> createSound ("resources/water.wav", FMOD_DEFAULT, 0, &sfxWater);
result = audiomgr-> createSound ("resources/jump.wav", FMOD_DEFAULT, 0, &sfxJump);
result = audiomgr->createSound("resources/movement.wav", FMOD_LOOP_NORMAL | FMOD_2D | FMOD_HARDWARE, 0, &sfxMovement);
result = audiomgr->playSound(FMOD_CHANNEL_FREE, sfxMovement, true, &chMovement);
return true; }
小贴士
您可以从本书的网站下载这些声音,或者您可以用自己的声音替换它们。只需确保您使用非常短的声音来模拟油、水和跳跃,因为它们旨在快速播放。
此函数将我们的三个音效文件加载到音频系统中。
-
createSound函数为声音分配内存并设置声音的 FMOD 属性。 -
FMOD_DEFAULT设置以下 FMOD 属性:-
FMOD_LOOP_OFF:声音播放一次,不会循环 -
FMOD_2D:这是一个 2D 声音 -
FMOD_HARDWARE:这使用设备的硬件功能来处理音频
-
-
结果变量捕获返回值。在生产游戏中,你每次都会测试这个功能,以确保声音已成功加载(我们在这里省略了那些错误检查,以节省空间)。
-
注意我们在移动 SFX 上调用
playSound。我们将开始这个声音,将其分配给下一个空闲的硬件通道(FMOD_CHANNEL_FREE),但告诉 FMOD 立即暂停它(因此true参数)。当我们想要播放声音时,我们将播放它,当我们想要停止它时,我们将暂停它。 -
我们将根据需要调用其他 SFX 的
playSound。由于它们不是循环声音,我们不需要管理它们的暂停状态。
注意,我们将sfxJump、sfxOilcan和sfxWater设置为使用FMOD_DEFAULT设置。然而,我们需要sfxMovement循环,因此我们必须单独设置其设置标志。
有几个标志可以用来设置声音的属性,并且可以使用 OR 运算符(|)来组合标志:
-
FMOD_HARDWARE:这使用设备硬件来处理音频。 -
FMOD_SOFTWARE:这使用 FMOD 的软件模拟来处理音频(较慢,但可能可以访问设备不支持的功能)。 -
FMOD_2D:这是一个 2D 声音。这是我们将在游戏中使用的格式! -
FMOD_3D:这是一个 3D 声音。3D 声音可以放置在 3D 空间中,并似乎具有距离(例如,声音随着距离的增加而变弱)和位置(左、右、前面、后面)。 -
FMOD_LOOP_OFF:声音播放一次且不循环。 -
FMOD_LOOP_NORMAL:声音播放后重新开始,无限循环。
有许多其他可以设置的标志。请查看 FMOD 文档以获取更多详细信息。
现在我们已经有了加载我们的声音的函数,我们必须将其连接到游戏的初始化中。修改GameLoop函数,添加以下突出显示的行:
void GameLoop(const float p_deltatTime)
{
if (m_gameState == GameState::GS_Splash)
{
InitFmod();
LoadAudio();
BuildFont();
LoadTextures();
m_gameState = GameState::GS_Loading;
}
Update(p_deltatTime);
Render();
}
播放声音
现在,我们需要在适当的时间触发音效。让我们从 Robo 的移动音效开始。基本上,我们希望在 Robo 实际移动时播放这个声音。
我们将修改ProcessInput函数中的CM_STOP、CM_LEFT和CM_RIGHT情况。通过插入以下突出显示的行来更新代码:
case Input::Command::CM_STOP:
player->SetVelocity(0.0f);
background->SetVelocity(0.0f);
chMovement->setPaused(true);
break;
case Input::Command::CM_LEFT:
if (player == robot_right)
{
robot_right->IsActive(false);
robot_right->IsVisible(false);
robot_left->SetPosition(robot_right->GetPosition());
robot_left->SetValue(robot_right->GetValue());
}
player = robot_left;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(-50.0f);
background->SetVelocity(50.0f);
chMovement->setPaused(false);
break;
case Input::Command::CM_RIGHT:
if (player == robot_left)
{
robot_left->IsActive(false);
robot_left->IsVisible(false);
robot_right->SetPosition(robot_left->GetPosition());
robot_right->SetValue(robot_left->GetValue());
}
player = robot_right;
player->IsActive(true);
player->IsVisible(true);
player->SetVelocity(50.0f);
background->SetVelocity(-50.0f);
chMovement->setPaused(false);
break;
记住,我们已加载sfxMovement并将其分配给一个虚拟通道(chMovement),然后告诉它以暂停状态开始播放。实际上,在 FMOD 中,我们暂停和播放通道,而不是声音。因此,我们现在只需在 Robo 移动时调用chMovement->setPaused(true),在他不移动时调用chMovement->setPaused(false)。
现在,我们需要处理油和水收集。这两个都可以在CheckCollisions函数中处理。通过添加以下突出显示的代码行来修改CheckCollisions:
void CheckCollisions()
{
if (player->IntersectsCircle(pickup))
{
FMOD::Channel* channel;
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxOilcan, false, &channel);
pickup->IsVisible(false);
pickup->IsActive(false);
player->SetValue(player->GetValue() + pickup->GetValue());
pickupSpawnTimer = 0.0f;
pickupsReceived++;
}
if (player->IntersectsRect(enemy))
{
FMOD::Channel* channel;
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxWater, false, &channel);
enemy->IsVisible(false);
enemy->IsActive(false);
player->SetValue(player->GetValue() + enemy->GetValue());
enemySpawnTimer = 0.0f;
}
}
最后,我们将为 Robo 跳跃或下落时添加音效。这些更改将应用于ProcessInput函数中的CM_UP和CM_DOWN情况。使用以下突出显示的行修改现有代码:
case Input::Command::CM_UP:
{
FMOD::Channel* channel;
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxJump, false, &channel);
player->Jump(Sprite::SpriteState::UP);
}
break;
case Input::Command::CM_DOWN:
{
FMOD::Channel* channel;
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxJump, false, &channel);
player->Jump(Sprite::SpriteState::DOWN);
}
break;
这些音效是单次声音。当它们播放完毕后,我们不需要再担心它们,直到再次播放它们的时候。对于这种类型的音效,我们创建一个通道(FMOD::channel* channel),然后使用以下方式调用 playSound:
-
FMOD_CHANNEL_FREE:这允许 FMOD 选择下一个可用的硬件声音通道。 -
音效指针:
sfxWater用于水瓶,sfxOilcan用于油,sfxJump用于跳跃音效。 -
false:不要暂停声音! -
&channel:这是虚拟通道句柄。请注意,这只是一个局部变量。对于一次性音效,我们不需要将其存储在任何地方。
就这样!如果你现在玩游戏,四个音效应该会根据我们的设计触发。
UI 反馈
到目前为止,我们已经创建了音效来响应当前游戏中的事件和动作。音效也用于从用户界面提供反馈。例如,当玩家点击按钮时,应该播放某种音频,以便玩家立即知道点击已被注册。
幸运的是,我们已经捕捉到每次用户点击 UI 按钮的情况,所以每次发生时触发声音很容易。让我们首先添加一个新的声音指针。在 RoboRacer2D.cpp 中,将以下行添加到变量声明中:
FMOD::Sound* sfxButton;
然后在 LoadAudio 中添加以下代码:
result = audiomgr->createSound("resources/button.wav", FMOD_DEFAULT, 0, &sfxButton);
最后,将以下高亮显示的代码行添加到 ProcessInput 中的 CM_UI 情况:
case Input::Command::CM_UI:
FMOD::Channel* channel;
if (pauseButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
pauseButton->IsClicked(false);
pauseButton->IsVisible(false);
pauseButton->IsActive(false);
resumeButton->IsClicked(false);
resumeButton->IsVisible(true);
resumeButton->IsActive(true);
m_gameState = GS_Paused;
}
if (resumeButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
resumeButton->IsClicked(false);
resumeButton->IsVisible(false);
resumeButton->IsActive(false);
pauseButton->IsClicked(false);
pauseButton->IsVisible(true);
pauseButton->IsActive(true);
m_gameState = GS_Running;
}
if (playButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
playButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
m_gameState = GameState::GS_Running;
}
if (creditsButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
creditsButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
m_gameState = GameState::GS_Credits;
}
if (exitButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
playButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
PostQuitMessage(0);
}
if (menuButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
menuButton->IsClicked(false);
menuButton->IsActive(false);
m_gameState = GameState::GS_Menu;
}
if (continueButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
continueButton->IsClicked(false);
continueButton->IsActive(false);
m_gameState = GameState::GS_Running;
}
if (replayButton->IsClicked())
{
audiomgr->playSound(FMOD_CHANNEL_FREE, sfxButton, false, &channel);
replayButton->IsClicked(false);
replayButton->IsActive(false);
exitButton->IsActive(false);
RestartGame();
m_gameState = GameState::GS_Running;
}
break;
到目前为止,当你运行游戏时,每次点击按钮你都会听到一个音效。
音乐的声音
我们现在转向我们游戏的音频音轨。就像电影配乐一样,在游戏中播放的音乐为游戏设定了基调。许多游戏有巨大的、编排精良的制作,而其他游戏则有合成或 8 位音乐。
正如我们已经讨论过的,音乐文件和音效的处理方式不同。这是因为音效通常是很短的声音,最好以 wav 文件的形式存储。音乐文件通常要长得多,并以 MP3 文件的形式存储,因为数据可以被压缩,占用更少的存储空间和内存。
我们将向我们的游戏添加一条单独的音乐音轨。为了使事情简单,我们将告诉音轨循环播放,以便它在整个游戏中持续运行。
我们首先添加一个声音指针。打开 RoboRacer2D.cpp 并在变量声明中添加以下代码行:
FMOD::Sound* musBackground;
接下来,转到 LoadAudio 函数并添加以下行:
result = audiomgr->createSound("resources/jollybot.mp3", FMOD_LOOP_NORMAL | FMOD_2D | FMOD_HARDWARE, 0, &musBackground);
FMOD::Channel* channel;
result = audiomgr->playSound(FMOD_CHANNEL_FREE, musBackground, false, &channel);
注意,我们使用 createStream 而不是 createSound 来加载我们的音乐文件。由于音乐比音效长得多,音乐是从存储中流式传输的,而不是直接加载到内存中。
我们希望音轨在游戏开始时启动,所以我们在加载后立即使用 playSound 开始播放音乐。
就这么多了!我们的游戏现在通过生动的声音景观得到了增强。
打扫房子
我们有一个相当完整的游戏。当然,它不会打破任何记录或使任何人致富,但如果这是你的第一个游戏,那么恭喜你!
我们在某个方面有所疏忽:良好的编程实践要求我们每次创建一个对象后,在使用完毕时都应将其删除。到目前为止,你可能想知道我们是否真的会这样做!好吧,现在就是时候了。
我们在EndGame函数中为所有这些操作留了一个占位符。现在,我们将添加必要的代码来正确释放我们的资源。
释放精灵
让我们从清理我们的精灵开始。重要的是要记住,当我们移除任何资源时,我们需要确保它也释放了自己的资源。这就是类析构函数的目的。让我们以Sprite类为例。打开Sprite.cpp,你应该会看到使用以下代码定义的析构函数:
Sprite::~Sprite()
{
for (int i = 0; i < m_textureIndex; i++)
{
glDeleteTextures(1, &m_textures[i]);
}
delete[] m_textures;
m_textures = NULL;
}
我们首先想要释放m_textures数组中的所有纹理。然后我们使用delete[]来释放m_textures数组。一旦对象被删除,将变量设置为NULL也是良好的编程实践。
当我们在精灵对象上调用delete时,将调用Sprite析构函数。因此,我们需要首先在EndGame中添加对为我们的游戏创建的每个精灵的delete操作。在EndGame函数中添加以下代码行:
delete robot_left;
delete robot_right;
delete robot_right_strip;
delete robot_left_strip;
delete background;
delete pickup;
delete enemy;
delete pauseButton;
delete resumeButton;
delete splashScreen;
delete menuScreen;
delete creditsScreen;
delete playButton;
delete creditsButton;
delete exitButton;
delete menuButton;
delete nextLevelScreen;
delete continueButton;
delete gameOverScreen;
delete replayButton;
小贴士
如果你仔细观察,你会注意到我们没有删除玩家对象。这是因为玩家仅用作指向已创建精灵的指针。换句话说,我们从未使用玩家来创建新的精灵。一个很好的经验法则是,对于每个新创建的对象,应该恰好有一个删除操作。
释放输入
我们接下来要关闭的系统是输入系统。首先,让我们完成Input析构函数。在Input类的析构函数中添加以下高亮代码:
Input::~Input()
{
delete[] m_uiElements;
m_uiElements = NULL;
}
我们必须删除uiElements数组,这是一个指向输入系统中的精灵的指针数组。请注意,我们在这里没有删除实际的精灵,因为它们不是由输入系统创建的。
现在,在EndGame中添加以下代码行:
delete inputManager;
释放字体
添加以下行以释放我们用于存储字体的显示列表:
KillFont();
释放音频
我们最后的清理工作是音频系统。在EndGame中添加以下代码行:
sfxWater->release();
sfxOilcan->release();
sfxJump->release();
sfxMovement->release();
sfxButton->release();
musBackground->release();
audiomgr->release();
恭喜!你的房子已经全部清理干净了。
概述
我们在本章中涵盖了大量的内容,在这个过程中,我们完成了我们的 2D 游戏。你了解了一些关于计算机中音频表示的知识。然后我们安装了 FMOD API,并学习了如何将其集成到我们的项目中。最后,我们使用 FMOD 在游戏中设置和播放音效和音乐。
本章完成了我们对 2D 游戏编程的讨论。你应该现在已经清楚,完成一个游戏不仅仅使用 OpenGL 库。记住,OpenGL 是一个渲染库。我们必须编写自己的类来处理输入,并使用第三方类来处理音频。
在下一章,我们开始探索 3D 编程的世界!
第八章。拓展视野
到目前为止,我们的编码仅限于二维。现在,是时候扩展到三维了。在许多方面,这并不会像听起来那么令人畏惧。毕竟,我们不再使用两个坐标(x 和 y)来指定位置,而是现在简单地添加一个第三个坐标(z)。然而,有一些领域,第三维会增加相当大的复杂性,我的工作就是帮助你掌握这种复杂性。在本章中,我们将从在三维世界中放置对象的基本理解开始,包括:
-
三维坐标系:你已经掌握了笛卡尔坐标系(x 和 y 坐标)。我们将讨论如何将其扩展到第三个轴。
-
三维摄像机:在二维游戏中,摄像机基本上是固定的,而物体则在其周围移动。在三维游戏编程中,我们经常将摄像机向前、向后、向侧面移动,甚至围绕游戏中的物体旋转。
-
三维视图:2D 计算机屏幕是如何精确地表示 3D 游戏的?你将学习 3D 如何通过图形管道进行转换的基础知识。
-
三维变换:在三维空间中移动比在二维空间中移动要复杂得多。实际上,我们使用一种全新的数学形式来做到这一点。你将学习矩阵的基础知识,以及如何使用它们来移动、旋转和改变三维对象的大小。
进入第三维度!
你已经生活在一个三维的世界里。你可以前后走动,左右移动,跳跃或蹲下。如果你在飞行或甚至游泳,三维的现实变得更加明显。
大多数二维游戏通过允许玩家左右移动,或上下跳跃来操作。这就是我们创建 RoboRacer2D 时所做的事情。在这种类型的二维游戏中,缺失的维度是深度。我们的机器人不能离我们更远或更近。考虑到我们是在一个平面上画他,他仅限于二维也就不足为奇了。
模拟三维
当然,艺术家们早在几百年前就找到了一种绕过这种限制的方法,他们观察到,当一个物体离我们越来越远时,它会变得越小,而当我们靠近它时,它会变得越大。因此,在二维世界中表示三维的一个简单方法就是简单地画出更远的物体作为更小的物体。二维游戏很早就学会了这个技巧,并用来模拟三维:

在前面的图像中,较大的水箱看起来比较小的水箱更近。
深度的一个重要方面是透视。艺术家们了解到,当平行线远离时,它们似乎会向中心汇聚。它们似乎汇聚的点被称为消失点:

在前面的图像中,墙壁和地板面板都是平行的,但它们似乎向图像的中心汇聚。
3D 运动的第三个方面是,远离我们的物体看起来比靠近我们的物体移动得慢。因此,当你驾驶时,电线杆会比你远处的山移动得快得多。一些 2D 游戏利用这种现象,称为视差,通过在游戏中创建一个移动速度远慢于前景的背景层。实际上,这正是我们在 RoboRacer2D 中所做的,因为前景中的机器人移动速度比背景中的物体快。
2D 游戏在硬件和显卡为我们做这些之前就已经使用了所有这些特性——大小、透视和视差——来模拟 3D。最早以令人信服的方式做到这一点的游戏之一是 Pole Position。真正让人震惊的游戏是 Doom,这可能是第一个允许玩家在 3D 世界中自由移动的游戏。
真实 3D
现代 3D 游戏将模拟 3D 的想法提升到了新的水平。在我们刚才讨论的模拟 3D 部分中,程序员的任务是调整图像的大小,使其看起来越远越小,处理透视,并处理视差。现在这些任务由 3D 显卡处理。

前面的图像展示了一个坦克的 3D模型。这些模型是使用特殊软件创建的,例如 Maya 或 3ds Max。这个模型与我们之前展示的 2D 坦克图像在本质上不同,因为它以三维的形式表示坦克。
我们将在未来的章节中更详细地讨论 3D 建模。目前,重要的概念是 3D 坦克的数据被发送到显卡,而显卡负责在坦克位于 3D 空间中的尺寸、透视和视差。这大大减轻了程序员的负担!
3D 坐标系统
现在你已经对如何在 2D 屏幕上创建 3D 幻觉有了基本的了解,让我们学习增加另一个维度是如何影响我们的坐标系统的。
在第二章中,我向你介绍了许多游戏系统使用的 2D 坐标系。

前面的图示显示了一辆位于坐标位置(5, 5)的汽车。现在让我们加入第三个维度,看看它是如何比较的:

注意,我们添加了第三个轴,并将其标记为 Z 轴。Z 轴上的正值离我们更近,而 Z 轴上的负值离我们更远。现在汽车位于 3D 空间中的坐标(5, 5, -5)。随着汽车距离更远,它看起来也比之前 2D 图像中的小(你可以将 2D 空间视为所有z坐标都是 0 的空间)。
前面的图示展示了 Z 轴以一个角度呈现,但重要的是要理解 Z 轴实际上是与计算机屏幕的平面垂直的。

将 Z 轴想象成从屏幕前方穿过中心并延伸到后面的线!
小贴士
实际上,在 3D 世界中表示轴的方法有很多。OpenGL 和 DirectX 之间的一个区别是 Z 轴。在 OpenGL 中,正的z值更靠近玩家。在 DirectX(微软的 3D 渲染引擎)中,负的 z 值更靠近玩家。了解这一点是很好的,因为你很可能需要与这两个系统一起工作。OpenGL 被称为右手坐标系,而 DirectX 是左手坐标系。解释它们如何得到这些名称有点困难,所以如果你想了解更多,请在互联网上搜索!
摄像机
在第二章你的视角中,我们将创建游戏与制作视频录制进行了比较。你的摄像机捕捉了你面前的一部分视图。如果对象进入或离开该视野,它们将不再出现在视频录制中。
3D 游戏也使用摄像机。OpenGL 允许你在六个轴上移动游戏摄像机:上、下、左、右、进、出。当你移动游戏摄像机时,其视野中的对象会发生变化。
假设你将摄像机对准场景中的汽车,并向左或向右平移。汽车将进入或离开视野。当然,如果你向上或向下平移摄像机,也会发生相同的情况。后退(或缩小)时,汽车看起来会更小。向前(或放大)时,汽车看起来会更大。倾斜摄像机,汽车看起来就像在上坡、下坡,甚至可能颠倒过来!
记得那些家庭电影吗?
记得那些随着摄像机移动整个场景会跳动的家庭电影吗?显然,摄像机的位置和运动与汽车的外观有很大关系。在游戏世界中也是如此。
OpenGL 使用摄像机概念来确定屏幕上显示的内容以及如何显示。你可以上下移动摄像机,也可以左右移动。你可以旋转或倾斜摄像机。你拥有完全的控制权!
保持稳定!
虽然你对移动摄像机有完全的控制权,但有些游戏只是将摄像机放置在特定的位置,然后将其固定。这类似于将你的家庭摄像机固定在三脚架上。
许多 2D 游戏使用固定摄像机,这正是我们在 RoboRacer2D 中所做的。游戏中的所有运动都来自于改变游戏对象的位置,而不是改变摄像机的位置。
在 3D 游戏中,同时移动摄像机和游戏中的对象是非常常见的。想象一下,我们有一个带有移动汽车的 3D 场景。如果摄像机保持固定,汽车最终会移出场景。为了保持汽车在场景中,我们需要移动摄像机以跟随汽车。汽车和摄像机都需要移动。
视口
在游戏术语中,任何时刻相机可以看到的区域称为视口。视口定义了相机可以看到的游戏世界的区域:

上一幅图显示了具有特定宽度和高度的视口。如果汽车移动到这些边界之外,它将不再可见。在 3D 世界中,我们还必须定义我们想要捕捉的图像的深度。

上一幅图显示了如何定义 3D 视口:
-
前裁剪平面定义了物体可以离相机多近。任何比前裁剪平面更近的物体都不会在屏幕上渲染。
-
后裁剪平面定义了物体可以离相机多远。任何超出后裁剪平面的物体都不会在屏幕上渲染。
-
前后裁剪平面的区域称为锥体。锥体内的对象将被渲染到屏幕上。
-
视场决定了从相机看去的视角的高度和宽度。宽视场将渲染更多区域,而窄视场将渲染较少区域。更宽的视角也会在图像中引入更多的扭曲。
进入矩阵
现在是所有新游戏程序员心中恐惧的主题:矩阵。矩阵是一种数学工具(线性代数的一部分),它使得处理大量相关数字变得更加容易。
在最简单的形式中,矩阵是一个数字表。比如说,我想表示空间中的一个坐标。我可以将其值写下如下:

向量
矩阵的单行或单列称为向量。向量很重要,因为它们可以用来定位和移动物体。
游戏中常用的矩阵包含四个值:x,y,z和w。这些x,y和z分量通常指的是 3D 坐标系中的位置,而w是一个开关:
-
值 1 表示这个向量是一个位置
-
值 0 表示这个向量是一个速度
这里有一个例子:
-
向量(
1, 5, 10, 1)代表在 3D 坐标系中 x =1,y =5,z =10的点。 -
向量(
1, 5, 10, 0)是一个在x方向上移动 1 个单位,在y方向上移动 5 个单位,在z方向上移动 10 个单位的点
小贴士
注意,向量可以用括号内的一系列数字来表示。这比每次写向量时都要画一个表格要容易得多!
向量组合
向量的真正力量在于它们组合在一起时。组合向量的最常见方式是乘法。看看以下例子:

左边的矩阵被称为平移矩阵,因为当你用它乘以一个位置向量时,结果将是一个新的位置(在 3D 空间中移动事物被称为平移)。在这种情况下,点(2, 1, 0)已经平移到了新的位置(3, 6, 6)。
小贴士
记住:在(1, 5, 6, 1)和(2, 1, 0, 1)中的最后一个1是w值,它简单地告诉我们我们正在处理一个位置。注意,w值在最终结果中也保持为1!
如果你一直在关注,你肯定在想我们是如何得到第三个矩阵的!实际上,乘以两个矩阵比看起来要复杂得多。为了乘以前面显示的两个矩阵,必须执行以下操作:
-
(1 * 2) + (0 * 1) + (0 * 0) + (1 * 1) = 3
-
(0 * 2) + (1 * 1) + (0 * 0) + (5 * 1) = 6
-
(0 * 2) + (0 * 1) + (1 * 0) + (6 * 1) = 6
-
(0 * 2) + (0 * 1) + (0 * 0) + (1 * 1) = 1
第一个矩阵的每一行中的每个单元格都会与第二个矩阵的每一列中的每个单元格相乘。
这可能看起来只是为了移动一个点而费尽周折,但当涉及到在游戏中快速移动 3D 对象时,矩阵数学比其他技术要快得多。
别担心!这就是我们要说的关于矩阵和向量的全部内容。你应该知道 OpenGL 使用矩阵来计算变换,包括:
-
移动
-
缩放
-
旋转
小贴士
如果你曾经同时使用 OpenGL 和 DirectX,你必须意识到它们在处理矩阵的方式上存在差异。OpenGL 使用行主序,而 DirectX 使用列主序。在行主序矩阵中,第一列的所有单元格都是相邻的,然后是下一行的所有单元格,依此类推。在列主序矩阵中,第一列的所有单元格都是相邻的,然后是下一列的所有单元格,依此类推。这在你操作和计算矩阵时会产生巨大的差异!
单位矩阵
我将提到另一个特殊的矩阵:
| 1 | 0 | 0 | 0 |
|---|---|---|---|
| 0 | 1 | 0 | 0 |
| 0 | 0 | 1 | 0 |
| 0 | 0 | 0 | 1 |
前面的矩阵被称为单位矩阵。如果你将任何矩阵乘以单位矩阵,结果将是原始矩阵(就像将任何数字乘以 1 的结果是原始数字一样)。每次我们想要初始化一个矩阵时,我们将其设置为单位矩阵。
OpenGL 中有特殊的矩阵,你将在接下来的代码中了解到一些。
在 3D 中进行编码
是时候将我们的理论付诸实践,创建我们的第一个 3D 场景了。为了保持简单,我们将通过在 3D 空间中放置一个立方体的步骤进行。这也将是我们 3D 游戏的开始,所以让我们在 Visual Studio 中创建一个全新的项目。
创建项目
当我们为我们的 2D 游戏创建项目时,我们从一个标准的 Windows 项目开始,然后删除(或忽略)了我们不需要使用的项目。事实上,标准的 Windows 项目有很多不必要的开销。这是因为 Windows 项目模板假设 Windows 将负责渲染和处理。这对我们的 2D 项目很有用,但只是添加了我们不需要的大量额外代码。
对于这个项目,我们将从一个空的 Windows 项目开始,然后添加必要的代码来初始化和创建一个 OpenGL 窗口。然后,我们将从这里开始逐步进行:
-
首先打开 Visual Studio。
-
当 Visual Studio 打开后,通过点击文件,新建,项目来创建一个新的项目。从Visual C++分支选择空项目。
![创建项目]()
-
命名项目为
SpaceRacer3D,将其放置在你选择的地点,然后点击确定。结果是没有任何代码的项目。让我们通过创建我们的主游戏文件来解决这个问题。 -
在解决方案资源管理器面板的源文件文件夹上右键单击。
-
选择添加,新建项…。
-
点击C++文件(.cpp)。
-
输入
SpaceRacer3D.cpp作为名称并点击添加。![创建项目]()
获取 OpenGL 文件
当你安装 Visual Studio 时,标准的 OpenGL 库已经安装好了。然而,OpenGL 实用工具库可能没有安装。为了简化事情,我们将简单地从我们的 RoboRacer2D 项目中复制所需的文件。
打开RoboRacer2D项目文件夹,并选择以下文件:
-
glut.h -
glut32.dll -
glut32.lib
现在将这些文件复制到SpaceRacer3D源文件夹中。这将与你的SpaceRacer3D.cpp文件所在的文件夹相同。
将项目链接到 OpenGL 库
现在我们有了项目和相关的 OpenGL 文件,我们需要链接到 OpenGL 库。这是通过访问项目属性来完成的。
在解决方案资源管理器面板中执行以下操作:
-
右键单击项目名称(不是解决方案),然后选择属性。
-
在配置属性下的链接器分支中打开,并选择输入。
-
点击附加依赖项,然后点击出现的下拉箭头。
-
点击<编辑…>。
-
在附加依赖项对话框窗口中添加
OpenGL32.lib和GLu32.lib。![将项目链接到 OpenGL 库]()
设置 OpenGL 窗口
现在我们将添加创建 OpenGL 窗口所需的代码。我们之前为 RoboRacer2D 做过一次,但现在,我们正在创建一个 3D 游戏,会有一些不同。以下是我们需要做的:
-
包含头文件。
-
定义全局变量。
-
创建 OpenGL 窗口。
-
初始化 OpenGL 窗口。
-
调整 OpenGL 窗口的大小。
-
移除 OpenGL 窗口。
-
创建 Windows 事件处理程序。
-
创建
WinMain函数。
注意,我们仍然需要编写一些代码来满足 Windows 的要求。我们需要一个事件处理程序来处理 Windows 事件,我们仍然需要一个主函数作为程序的入口点并运行主程序循环。列表中的其他一切都是用来设置 OpenGL 环境的。
小贴士
我按照逻辑顺序列出了我们需要的功能任务。当我们实际编写代码时,我们将以稍微不同的顺序创建事物。这是因为某些函数需要另一个函数已经定义。例如,创建 OpenGL 窗口的函数会调用初始化 OpenGL 窗口的函数,因此初始化函数是首先编写的。
包含头文件
第一步是包含适当的头文件。在SpaceRacer3D.cpp的顶部添加以下头文件:
#include <windows.h>
#include <gl\GL.h>
#include <gl\GLU.h>
#include "glut.h"
这些文件与我们在 2D 项目中使用的相同,但这里有一个快速描述,以便你不必翻回:
-
我们仍在 Windows 上运行,因此必须包含
windows.h -
OpenGL 的核心头文件是
GL.h -
在
GLU.h中有一些非常棒的实用工具可以简化我们的工作 -
在
glut.h中也有一些有用的工具
定义全局变量
我们需要一些全局变量来保存对 Windows 和 OpenGL 对象的引用。在标题行下方添加以下代码行:
HINSTANCE hInstance = NULL;
HDC hDC = NULL;
HGLRC hRC = NULL;
HWND hWnd = NULL;
bool fullscreen = false;
这里是一个快速列表,说明这些变量用于什么:
-
hInstance:这保存了对应用程序此实例的引用 -
hDC:这保存了对用于在原生 Windows 中绘图的 GDI 设备上下文的引用 -
hRC:这保存了对 OpenGL 渲染上下文的引用,用于渲染 3D -
hWnd:这保存了应用程序实际运行的窗口的引用
我们还包含了一个全局的fullscreen变量。如果你将其设置为true,游戏将以全屏模式运行。如果你将其设置为false,游戏将以窗口模式运行。
创建创建 OpenGL 窗口的函数
我们还将包含对 Windows 事件处理程序的前向引用。添加以下代码行:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
前向引用允许我们定义一个实际实现将在代码后面出现的函数。WndProc的代码将在稍后添加。
调整 OpenGL 窗口大小
接下来,我们将创建一个调整 OpenGL 窗口大小的函数。当程序启动以及应用程序运行的窗口大小调整时都会调用此函数。添加以下代码:
void ReSizeGLScene(const GLsizei p_width, const GLsizei p_height)
{
GLsizei h = p_height;
GLsizei w = p_width;
if (h == 0)
{
h = 1;
}
glViewport(0, 0, w, h);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
gluPerspective(45.0f, (GLfloat)w / (GLfloat)h, 0.1f, 100.0f);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
}
这段代码设置了 OpenGL 窗口的大小,并准备在 3D 中渲染窗口:
-
首先,我们获取宽度和高度(确保高度永远不会等于 0),并使用它们通过
glViewport函数定义视口的大小。前两个参数是视口左下角x和y的值,然后是宽度和高度。这四个参数定义了视口的大小和位置。 -
接下来,我们必须定义视锥体。在告诉 OpenGL 使用投影矩阵之后,我们使用
gluPerspective函数,它接受四个参数:视野(以度为单位,不是弧度),纵横比,前裁剪平面的距离和后裁剪平面的距离。视野是从相机中心的角度。纵横比是宽度除以高度。这四个参数定义了视锥体的大小。提示
完成此章节后,你可以尝试调整此函数的值,看看它如何改变渲染效果。
-
最后,我们告诉 OpenGL 从此点开始使用模型视图。
如果你将此函数与我们用于 RoboRacer2D 的GLSize函数进行比较,你会注意到一个显著的区别:我们没有调用glOrtho。记住,RoboRacer2D 是一个 2D 游戏。2D 游戏在渲染场景时使用正交投影,这会移除透视。在 2D 游戏中不需要透视。大多数 3D 游戏使用透视投影,它由gluPerspective调用定义。
注意
OpenGL 矩阵
在gluPerspective调用之前,你会注意到两个函数:glMatrixMode和glLoadIdentity。记得我们讨论矩阵时提到,矩阵用于存储一组值。OpenGL 有许多标准矩阵,其中之一是投影矩阵,它用于定义视图视锥体。
如果我们想要设置矩阵的值,我们必须首先告诉 OpenGL 我们想要使用这个矩阵。接下来,我们通常初始化矩阵,最后,我们进行一个设置矩阵值的调用。
看看设置视图视锥体的代码,这正是我们做的:
-
glMatrixMode(GL_PROJECTION):这告诉 OpenGL 我们想要使用投影矩阵。在此调用之后的任何矩阵操作都将应用于投影矩阵。 -
glLoadIdentity():这设置投影矩阵为单位矩阵,因此清除任何之前的值。 -
gluPerspective(45.0f, (GLfloat)w / (GLfloat)h, 0.1f, 100.0f):这设置投影矩阵的值。
你应该习惯这种模式,因为它在 OpenGL 中经常被使用:设置一个矩阵来工作,初始化矩阵,然后设置矩阵的值。例如,在此函数的末尾,我们告诉 OpenGL 使用模型视图矩阵并初始化它。之后的任何操作都将影响模型视图。
初始化 OpenGL 窗口
添加以下代码以初始化 OpenGL:
const bool InitGL()
{
glShadeModel(GL_SMOOTH);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClearDepth(1.0f);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST);
return true;
}
此函数通过定义确定场景如何渲染的重要设置来初始化 OpenGL:
-
glShadeModel:这告诉 OpenGL 我们想要它平滑顶点的边缘。这大大提高了我们图像的外观。 -
glClearColor:此设置每次调用glClear清除渲染缓冲区时使用的颜色。它也是场景中显示的默认颜色。 -
glClearDepth(1.0f): 这告诉 OpenGL,每次调用glClear时,我们希望清除整个深度缓冲区。记住,我们现在正在 3D 环境中工作,深度缓冲区大致等同于 Z 轴。 -
glEnable(GL_DEPTH_TEST): 这打开深度检查。深度检查用于确定特定数据是否将被渲染。 -
glDepthFunc(GL_LEQUAL): 这告诉 OpenGL 你希望如何执行深度测试。LEQUAL告诉 OpenGL 只有当传入数据的 z 值小于或等于现有数据的 z 值时才写入数据。 -
glHint((GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST)): 这是一个有趣的函数。glHint表示此函数将建议 OpenGL 使用作为参数传递的设置。然而,由于存在许多不同类型的设备,无法保证这些设置实际上会被强制执行。GL_PERSPECTIVE提示告诉 OpenGL 在渲染透视时使用最高质量,而GL_NICEST表示在渲染质量与速度之间更注重质量。
创建一个用于删除 OpenGL 窗口的函数
最终,我们可能需要关闭这些操作。良好的编程实践要求我们释放 OpenGL 窗口所使用的资源。将以下函数添加到我们的代码中:
GLvoid KillGLWindow(GLvoid)
{
if (fullscreen)
{
ChangeDisplaySettings(NULL, 0);
ShowCursor(TRUE);
}
if (hRC)
{
wglMakeCurrent(NULL, NULL);
wglDeleteContext(hRC);
hRC = NULL;
}
if (hDC)
{
ReleaseDC(hWnd, hDC)
hDC = NULL;
}
if (hWnd)
{
DestroyWindow(hWnd);
hWnd = NULL;
}
UnregisterClass("OpenGL", hInstance)
hInstance = NULL;
}
首先,我们告诉 Windows 退出全屏模式(如果我们在全屏运行),并将光标恢复。然后,我们检查每个附加了资源的对象,释放该对象,然后将其设置为 null。需要释放的对象包括:
-
hRC: 这是指 OpenGL 渲染上下文 -
hDC: 这是指 Windows 设备上下文 -
hWnd: 这是指向窗口的句柄 -
hInstance: 这是指向应用程序的句柄
提示
你可能会注意到以 wgl 开头的两个函数(wglMakeCurrent 和 wglDeleteContext)。这代表 Windows GL,这些是仅在 Windows 中工作的特殊 OpenGL 函数。
创建 OpenGL 窗口
现在我们已经定义了其他 OpenGL 支持函数,我们可以添加用于实际创建 OpenGL 窗口的函数。添加以下代码:
const bool CreateGLWindow(const char* p_title, const int p_width, const int p_height, const int p_bits, const bool p_fullscreenflag)
{
GLuint PixelFormat;
WNDCLASS wc;
DWORD dwExStyle;
DWORD dwStyle;
RECT WindowRect;
WindowRect.left = (long)0;
WindowRect.right = (long)p_width;
WindowRect.top = (long)0;
WindowRect.bottom = (long)p_height;
fullscreen = p_fullscreenflag;
GLfloat screen_height = (GLfloat)p_height;
GLfloat screen_width = (GLfloat)p_width;
hInstance = GetModuleHandle(NULL);
wc.style = CS_HREDRAW | CS_VREDRAW | CS_OWNDC;
wc.lpfnWndProc = (WNDPROC)WndProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hInstance;
wc.hIcon = LoadIcon(NULL, IDI_WINLOGO);
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.hbrBackground = NULL;
wc.lpszMenuName = NULL;
wc.lpszClassName = "OpenGL";
RegisterClass(&wc);
if (fullscreen)
{
DEVMODE dmScreenSettings;
memset(&dmScreenSettings, 0, sizeof(dmScreenSettings));
dmScreenSettings.dmSize = sizeof(dmScreenSettings);
dmScreenSettings.dmPelsWidth = p_width;
dmScreenSettings.dmPelsHeight = p_height;
dmScreenSettings.dmBitsPerPel = p_bits;
dmScreenSettings.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT;
ChangeDisplaySettings(&dmScreenSettings, CDS_FULLSCREEN);
}
if (fullscreen)
{
dwExStyle = WS_EX_APPWINDOW;
dwStyle = WS_POPUP;
ShowCursor(false);
}
else
{
dwExStyle = WS_EX_APPWINDOW | WS_EX_WINDOWEDGE;
dwStyle = WS_OVERLAPPEDWINDOW;
}
AdjustWindowRectEx(&WindowRect, dwStyle, FALSE, dwExStyle);
hWnd = CreateWindowEx(dwExStyle,"OpenGL", p_title,
dwStyle | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
0, 0, WindowRect.right - WindowRect.left, WindowRect.bottom - WindowRect.top,
NULL, NULL, hInstance, NULL);
static PIXELFORMATDESCRIPTOR pfd =
{
sizeof(PIXELFORMATDESCRIPTOR),
1,
PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER,
PFD_TYPE_RGBA, p_bits,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
16, 0, 0,
PFD_MAIN_PLANE,
0, 0, 0, 0
};
hDC = GetDC(hWnd);
PixelFormat = ChoosePixelFormat(hDC, &pfd);
SetPixelFormat(hDC, PixelFormat, &pfd);
hRC = wglCreateContext(hDC);
wglMakeCurrent(hDC, hRC);
ShowWindow(hWnd, SW_SHOW);
SetForegroundWindow(hWnd);
SetFocus(hWnd);
ReSizeGLScene(p_width, p_height);
InitGL();
return true;
}
CreateGLWindow 的目的是创建一个可以与 OpenGL 一起工作的窗口。此函数完成的主要任务如下:
-
设置窗口属性
-
将应用程序注册到 Windows 中—
RegisterClass -
如果需要,设置全屏模式—
ChangeDisplaySettings -
创建窗口—
CreateWindowEx -
获取 Windows 设备上下文—
GetDC -
设置 OpenGL 像素格式—
SetPixelFormat -
创建 OpenGL 渲染上下文—
wglCreateContext -
将 Windows 设备上下文和 OpenGL 渲染上下文绑定在一起—
wglMakeCurrent -
显示窗口—
ShowWindow,SetForegroundWindow(hWnd), 和SetFocus(hWnd) -
初始化 OpenGL 窗口—
ReSizeGLScene,InitGL; 创建WinMain函数
WinMain 函数是应用程序的入口点。添加以下代码:
int APIENTRY WinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPTSTR lpCmdLine,
_In_ int nCmdShow)
{
MSG msg;
bool done = false;
if (!CreateGLWindow("SpaceRacer3D", 800, 600, 16, false))
{
return false;
}
StartGame();
int previousTime = glutGet(GLUT_ELAPSED_TIME);
while (!done)
{
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
{
done = true;
}
else
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
else
{
int currentTime = glutGet(GLUT_ELAPSED_TIME);
float deltaTime = (float)(currentTime - previousTime) / 1000;
previousTime = currentTime;
GameLoop(deltaTime);
}
}
EndGame();
return (int)msg.wParam;
}
它调用所有其他函数来初始化 Windows,然后 OpenGL 启动主消息循环,我们劫持并适配它成为我们的游戏循环。正如我们在第一章中解释的所有这些代码,建立基础,我们在这里不再重复。
创建 Windows 事件处理器
最后,我们必须有一个事件处理器来接收来自 Windows 的事件并处理它们。我们在代码顶部创建了前向声明,现在我们将实际实现处理器。添加以下代码:
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_DESTROY:
PostQuitMessage(0);
break;
case WM_SIZE:
ReSizeGLScene(LOWORD(lParam), HIWORD(lParam));
return 0;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
return false;
}
这个函数将在 Windows 向我们的程序发送事件时被调用。我们处理两个事件:WM_DESTROY和WM_SIZE:
-
WM_DESTROY在窗口关闭时触发。当发生这种情况时,我们使用PostQuitMessage告诉主游戏循环是时候停止了。 -
WM_SIZE在窗口大小改变时触发。当发生这种情况时,我们调用ReSizeGLScene。
游戏循环
我们仍然需要为我们的游戏函数添加一些存根函数:StartGame、Update、Render、EndGame和GameLoop。在WinMain函数之前添加以下代码:
void StartGame()
{
}
void Update(const float p_deltaTime)
{
}
void Render()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
DrawCube();
SwapBuffers(hDC);
}
void EndGame()
{
}
void GameLoop(const float p_deltatTime)
{
Update(p_deltatTime);
Render();
}
这些函数与 RoboRacer2D 中相同。GameLoop从 Windows 主循环中被调用,然后调用Update和Render。StartGame在 Windows 主循环之前被调用,EndGame在游戏结束时被调用。
结尾
如果你现在运行游戏,你会看到一个漂亮的黑色窗口。这是因为我们还没有告诉程序去绘制任何东西!做所有这些工作却得到一个黑色屏幕似乎不太公平,所以如果你想做一些额外的工作,请在StartGame函数之前添加以下代码:
void DrawCube()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glTranslatef(0.0f, 0.0f, -7.0f);
glRotatef(fRotate, 1.0f, 1.0f, 1.0f);
glBegin(GL_QUADS);
glColor3f(0.0f, 1.0f, 0.0f);
glVertex3f(1.0f, 1.0f, -1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glVertex3f(-1.0f, 1.0f, 1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glColor3f(1.0f, 0.5f, 0.0f);
glVertex3f(1.0f, -1.0f, 1.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, -1.0f); glVertex3f(1.0f, -1.0f, -1.0f);
glColor3f(1.0f, 0.0f, 0.0f);
glVertex3f(1.0f, 1.0f, 1.0f); glVertex3f(-1.0f, 1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, 1.0f); glVertex3f(1.0f, -1.0f, 1.0f);
glColor3f(1.0f, 1.0f, 0.0f);
glVertex3f(1.0f, -1.0f, -1.0f); glVertex3f(-1.0f, -1.0f, -1.0f);
glVertex3f(-1.0f, 1.0f, -1.0f); glVertex3f(1.0f, 1.0f, -1.0f);
glColor3f(0.0f, 0.0f, 1.0f);
glVertex3f(-1.0f, 1.0f, 1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glVertex3f(-1.0f, -1.0f, -1.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glColor3f(1.0f, 0.0f, 1.0f);
glVertex3f(1.0f, 1.0f, -1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glVertex3f(1.0f, -1.0f, 1.0f); glVertex3f(1.0f, -1.0f, -1.0f);
glEnd();
fRotate -= 0.05f;
}
此外,你还需要确保声明以下全局变量:
float frotate = 1.0f;
现在运行程序,你应该会看到一个五彩斑斓的旋转立方体。不用担心它是如何工作的——我们将在下一章中学习。
概述
在本章中,我们涵盖了创建 3D 游戏相关的大量新内容。你学习了游戏摄像头是如何像视频摄像头一样工作的。摄像头视锥体内的任何东西都将被渲染到屏幕上。你还学习了用于在 3D 世界中放置对象的 3D 坐标系。最后,你学习了矩阵和向量,它们是 3D 对象操作的基础。
最后,我们从一个空白项目开始,走过了设置使用 OpenGL 渲染的 3D 游戏所需的所有代码。记住,你永远不需要记住这段代码!但是,了解每一行代码的作用是很重要的。
在下一章中,你将学习如何从建模程序创建和加载 3D 模型。
第九章。超级模型
在上一章中,你创建了一个框架来在 3D 中渲染 OpenGL。在那个章节的结尾,我们添加了一块代码,用来渲染一个立方体。在本章中,你将学习如何在 OpenGL 中创建 3D 对象,首先是通过代码,然后是通过 3D 建模程序。在本章中,我们将涵盖以下内容:
-
显卡:3D 显卡基本上是小型计算机,它们被优化来渲染 3D 对象。我们将快速了解一下显卡是如何做到它最擅长的事情的。
-
顶点:3D 对象是通过绘制点并告诉 OpenGL 使用这些点来创建一个可以在屏幕上渲染的对象来绘制的。
-
三角形:三角形被用来创建所有 3D 对象。你将了解顶点和三角形之间的关系以及它们是如何用来创建简单对象的。
-
建模:一旦你了解了如何使用代码创建简单的 3D 对象,你也会明白,如果你想创建任何复杂的东西,你需要一个更有效的工具。这就是 3D 建模软件出现并拯救了局面。
-
一旦创建了一个 3D 模型,你必须将模型放入游戏中。我们将通过读取建模软件生成的数据来创建代码,将 3D 模型加载到我们的游戏中。
新空间
到目前为止,我们一直在二维空间中工作。这意味着我们能够创建具有高度和宽度的游戏对象。这很好,因为我们的计算机屏幕也是二维的。当我们进入三维空间时,我们需要给我们的对象添加另一个维度:深度。由于计算机屏幕在物理上没有第三个维度来显示像素,这一切都是通过数学魔法来实现的!
在第八章 拓展视野 中,我们讨论了几种在二维显示中模拟三维的方法:
-
离我们更远的对象可以显得比靠近我们的对象更小
-
离我们更远的对象可以显得比靠近我们的对象移动得更慢
-
平行的线条可以画出向中心汇聚的趋势,随着它们离我们越来越远
这三种技术有一个主要的缺点:它们都要求程序员编写代码来使每个视觉效果工作。例如,程序员必须确保远离玩家的对象不断缩小,以便它们变得越来越小。
在一个真正的 3D 游戏中,程序员唯一需要担心的是将每个对象放置在 3D 空间中的正确坐标。特殊的显卡负责执行所有与大小、速度和视差相关的计算。这使程序员从这些计算中解放出来,但实际上增加了与三维空间中定位和旋转对象相关的一整套新要求。
计算机中的计算机
关于你的计算机处理你的游戏所需的东西。计算机必须从玩家那里接收输入,解释那个输入,然后将结果应用于游戏。一旦输入完成,计算机必须处理游戏的物理:物体必须移动,碰撞必须发生,爆炸必须随之而来。一旦计算机更新了游戏中的所有物体,它必须将这些结果渲染到屏幕上。最后,为了让人信服,所有这些至少每秒发生 30 次,通常是每秒 60 次!
真是令人惊讶,计算机可以如此快速地处理这么多信息。实际上,如果完全由你的计算机的中央处理器来完成这项工作,那么它将无法跟上。
3D 显卡通过处理渲染过程来解决这个问题,这样你的计算机的主 CPU 就不必处理了。你的 CPU 只需提供数据,而显卡则处理其余部分,使主 CPU 可以继续处理其他事情。
现代 3D 显卡实际上是一个完整的计算机系统,它位于你主计算机内部的硅芯片上。显卡就是你的计算机中的另一个计算机!显卡有自己的输入、输出,以及称为图形处理单元(GPU)的处理器。它还包含自己的内存,通常高达 4GB 或更多。
下面的图表显示了显卡的基本结构和它如何处理信息:

所描述的先前序列被称为图形管线。对过程中每一步的详细讨论超出了我们书籍的范围,但了解图形管线的基本知识是好的,所以这里有一些基础知识:
-
图形总线:在计算机术语中,总线只是移动数据的一种方式。将总线想象成高速公路:高速公路上的车道越多,交通就越快。在你的计算机内部,交通是数据位,而大多数现代显卡都有 64 条车道(称为 64 位总线),这允许同时移动多达 64 位(或 8 字节)的数据。图形总线直接从 CPU 接收其数据。
-
图形处理单元:GPU 做所有的工作,正如你所看到的,有很多工作要做。
-
变换:每个顶点,作为一个 3D 空间中的点,必须被正确放置。需要处理几个参考系。例如,局部坐标可能描述汽车轮胎与车身之间的距离,而全局坐标描述汽车与即将到来的悬崖之间的距离。所有数据都必须转换成单一的参考系。
-
光照:每个顶点都必须被照亮。这意味着将光和颜色应用到每个顶点,并从顶点之间插值光和颜色的强度。就像太阳照亮我们的世界,荧光灯照亮我们的办公室一样,GPU 使用光照数据来正确照亮你的游戏世界。
-
基本图形元素:这些是用于构建更复杂对象的简单对象。类似于虚拟乐高积木套装,GPU 使用三角形、矩形、圆形、立方体、球体、圆锥体和圆柱体来构建你在游戏中的所有内容。我们将在本章后面了解更多关于这方面的内容。
-
投影:一旦 GPU 构建了世界的 3D 模型,它现在必须将世界的 3D 投影到 2D 空间(记住,你的显示只有两个维度)。这类似于太阳如何将 3D 物体的 2D 影子投影出来。
-
裁剪:一旦 3D 场景被投影到 2D 空间,一些顶点将位于其他顶点之后,因此在这个时候实际上看不到。裁剪,或移除不可见的顶点,将这些顶点从数据中移除,简化了整个过程。
-
光栅化:我们现在有一个二维模型,它从数学上表示了必须显示在屏幕上的当前图像。光栅化是将这个虚拟图像转换为实际像素的过程,这些像素必须显示在屏幕上。
-
着色:这个最终过程决定了必须应用到屏幕上每个像素的实际颜色,以正确显示在早期阶段创建的模型。甚至可以编写代码来操纵这个过程以创建特殊视觉效果。在图形管线中修改着色过程的代码被称为着色器。
-
-
渲染:当然,我们之所以做所有这些,是为了能在计算机屏幕上显示我们的游戏。图形管线的最终输出是渲染缓冲区中当前屏幕的表示。现在,CPU 只需要将渲染缓冲区中的数据交换到实际屏幕缓冲区,结果就是你在游戏中看到的下一帧!
顺便说一下,你会在幕后(前一张图中的大箭头)注意到,所有这些操作都是由显卡上的专用内存支持的。所有数据都是从 CPU 移动到显卡内存中,在那里它被处理和加工,然后再发送回 CPU。这意味着主计算机上的内存不需要为处理图形而预留。
小贴士
重要的是要理解,前面的图是图形管线的通用表示。不同显卡上的特定硬件可能处理方式不同,OpenGL 和 DirectX 规范也略有不同,但前面的图仍然是基本管线。
拿出你的武器
是时候让我们学习如何在 OpenGL 中绘制东西了。无论你是在绘制你的武器、外星飞船还是一片草叶,一切都是从非常简单的形状开始的,这些形状组合起来形成更复杂的形状。
获取原始形状
在 OpenGL 中可以绘制的最基本形状被称为原始形状。OpenGL 可以绘制的原始形状包括:
-
点:正如其名所示,点渲染一个单独的点,由一个顶点定义。
-
线条:线条通过两个顶点之间的线条进行渲染。
-
三角形:三角形由三个顶点和连接这些顶点的三条线定义。
-
四边形:四边形由四个顶点和连接这些顶点的四条线定义。技术上讲,四边形实际上是两个在斜边处连接在一起的三角形。
就这样,朋友们!已知存在的所有事物都可以从这四种原始形状中创建出来。在三维中进行外推,这里有这些三维原始形状:
-
平面是线的二维拉伸(好吧,我知道平面实际上并不是三维的!)
-
金字塔是四边形和四个三角形的 3D 表示。
-
立方体是四边形的 3D 拉伸。
-
球体是一个基于圆的三维结构,它由线条(是的,线条,线条越短,圆越有说服力)构成。
-
圆柱体是圆的三维拉伸。
上述列表中的对象实际上并没有定义为 OpenGL 原始形状。然而,许多 3D 建模程序将它们称为原始形状,因为它们是最简单的三维对象之一。
绘制原始形状
在上一章中,我们使用以下代码创建了一个立方体:
void DrawCube()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glTranslatef(0.0f, 0.0f, -7.0f);
glRotatef(fRotate, 1.0f, 1.0f, 1.0f);
glBegin(GL_QUADS);
glColor3f(0.0f, 1.0f, 0.0f);
glVertex3f(1.0f, 1.0f, -1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glVertex3f(-1.0f, 1.0f, 1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glColor3f(1.0f, 0.5f, 0.0f);
glVertex3f(1.0f, -1.0f, 1.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, -1.0f); glVertex3f(1.0f, -1.0f, -1.0f);
glColor3f(1.0f, 0.0f, 0.0f);
glVertex3f(1.0f, 1.0f, 1.0f); glVertex3f(-1.0f, 1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, 1.0f); glVertex3f(1.0f, -1.0f, 1.0f);
glColor3f(1.0f, 1.0f, 0.0f);
glVertex3f(1.0f, -1.0f, -1.0f); glVertex3f(-1.0f, -1.0f, -1.0f);
glVertex3f(-1.0f, 1.0f, -1.0f); glVertex3f(1.0f, 1.0f, -1.0f);
glColor3f(0.0f, 0.0f, 1.0f);
glVertex3f(-1.0f, 1.0f, 1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glVertex3f(-1.0f, -1.0f, -1.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glColor3f(1.0f, 0.0f, 1.0f);
glVertex3f(1.0f, 1.0f, -1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glVertex3f(1.0f, -1.0f, 1.0f); glVertex3f(1.0f, -1.0f, -1.0f);
glEnd();
fRotate -= 0.05f;
}
现在,让我们来了解这段代码是如何实际工作的:
-
每当我们想在 OpenGL 中绘制某物时,我们首先从清除渲染缓冲区开始。换句话说,每一帧都是从零开始绘制的。
glClear函数清除缓冲区,以便我们可以开始绘制。 -
在我们开始绘制对象之前,我们希望告诉 OpenGL 在哪里绘制它们。
glTranslatef命令将我们移动到三维空间中的某个点,从这个点开始我们的绘制(实际上,glTranslatef移动了相机,但效果是相同的)。 -
如果我们想旋转我们的对象,那么我们使用
glRotatef函数提供该信息。回想一下,上一章中的立方体缓慢地旋转。 -
在我们向 OpenGL 提供顶点之前,我们需要告诉 OpenGL 如何解释这些顶点。它们是单独的点?线条?三角形?在我们的例子中,我们为构成立方体面的六个正方形定义了顶点,因此我们指定
glBegin(GL_QUADS)以让 OpenGL 知道我们将为每个四边形提供顶点。接下来我们将描述几种其他可能性。 -
在 OpenGL 中,你在定义顶点之前指定每个顶点的属性。例如,我们使用
glColor3f函数来定义我们定义的下一组顶点的颜色。后续的每个顶点都将用这个指定的颜色绘制,直到我们通过另一个glColor3f调用更改颜色。 -
最后,我们为四边形定义每个顶点。由于四边形需要四个顶点,接下来的四个
glVertex3f调用将定义一个四边形。如果你仔细查看代码,你会注意到有六组四顶点定义(每组前面都有一个颜色定义),它们共同作用来创建我们立方体的六个面。
现在你已经了解了 OpenGL 如何绘制四边形,让我们通过介绍其他类型的原语来扩展你的知识。
表达观点
只有一种点原语。
Gl_Points
glBegin(GL_POINTS) 函数调用告诉 OpenGL,每个后续的顶点都应该被渲染为一个单独的点。点甚至可以映射上纹理,这些被称为 点精灵。
点实际上是根据 glEnable 函数的 GL_PROGRAM_POINT_SIZE 参数定义的大小生成的像素方块。大小定义了点每边的像素数。点的位置被定义为该方块的中心。
点的大小必须大于零,否则会产生未定义的行为。点的大小有一个实现定义的范围,给定的大小将被夹到这个范围内。还有两个 OpenGL 属性决定了点如何被渲染:GL_POINT_SIZE_RANGE(返回 2 个浮点数)和 GL_POINT_SIZE_GRANULARITY。这个特定的 OpenGL 实现将大小夹到最接近的粒度倍数。
排队
根据顶点列表的不同解释,有三种类型的线原语。
Gl_Lines
当你调用 glBegin(GL_LINES) 时,每一对顶点被解释为一条单独的线。顶点 1 和 2 被视为一条线。顶点 3 和 4 被视为另一条线。如果用户指定了奇数个顶点,则额外的顶点将被忽略。
Gl_Line_Strip
当你调用 glBegin(GL_LINES) 时,第一个顶点定义了第一条线的开始。之后的每个顶点定义了前一条线的结束和下一条线的开始。这会产生将线连接在一起直到列表中最后一个顶点的效果。因此,如果你传递 n 个顶点,你会得到 n-1 条线。如果用户只指定了一个顶点,则绘制命令将被忽略。

Gl_Line_Loop
glBegin(GL_LINE_LOOP) 调用几乎与线带完全一样,除了第一个和最后一个顶点被作为一条线连接。因此,对于 n 个输入顶点,你会得到 n 条线。如果用户只指定了一个顶点,则绘制命令将被忽略。第一条和最后一条顶点之间的线发生在序列中的所有前一条线之后。

三角剖分
三角形是由三个顶点形成的原语。根据顶点流的不同的解释,有三种类型的三角形原语。
Gl_Triangles
当你调用 glBegin(GL_TRIANGLES) 时,每个三个顶点定义一个三角形。顶点 1、2 和 3 形成一个三角形。顶点 4、5 和 6 形成另一个三角形。如果列表末尾少于三个顶点,它们将被忽略:
glBegin(GL_TRIANGLES);
glVertex3f( 0.0f, 1.0f, 0.0f);
glVertex3f(-1.0f,-1.0f, 0.0f);
glVertex3f( 1.0f,-1.0f, 0.0f);
glEnd();
Gl_Triangle_Strip
当你调用 glBegin(GL_TRIANGLE_STRIP) 时,前三个顶点创建第一个三角形。之后,接下来的两个顶点创建下一个三角形,形成一个相邻三角形组。长度为 n 的顶点流将生成 n-2 个三角形:

Gl_Triangle_Fan
当你调用 glBegin(GL_TRIANGLE_FAN) 时,第一个顶点定义了所有其他三角形定义的点。之后,每对两个顶点定义一个新的三角形,其顶点与第一个三角形相同,形成一个扇形。长度为 n 的顶点流将生成 n-2 个三角形。任何剩余的顶点都将被忽略:

成方形
四边形是一个四边形,有四条边。不要混淆,认为所有四边形都是正方形或矩形。任何有四条边的形状都是四边形。预期的四个顶点应在同一平面上,否则可能导致未定义的结果。四边形通常由一对三角形组成,这可能导致伪影(图像中的意外故障)。
Gl_Quads
当你调用 glBegin(GL_QUADS) 时,每组四个顶点定义一个四边形。顶点 1 到 4 形成一个四边形,而顶点 5 到 8 形成另一个。顶点列表必须是 4 的倍数才能正常工作:
glBegin(GL_QUADS);
glVertex3f(-1.0f, 1.0f, 0.0f);
glVertex3f( 1.0f, 1.0f, 0.0f);
glVertex3f( 1.0f,-1.0f, 0.0f);
glVertex3f(-1.0f,-1.0f, 0.0f);
glEnd();
Gl_Quad_Strip
类似于三角形带,四边形带使用相邻边来形成下一个四边形。在四边形的情况下,一个四边形的第三和第四个顶点用作下一个四边形的边。因此,顶点 1 到 4 定义了第一个四边形,而 5 到 6 扩展了下一个四边形。长度为 n 的顶点列表将生成 (n - 2)/2 个四边形:

保存面子
我们所讨论的所有原语都是通过创建多个粘合在一起的多边形来创建的,或多或少。OpenGL 需要知道形状的哪个面朝向相机,这由绕序决定。由于你无法看到原语的前后两面,OpenGL 使用朝向来决定必须渲染哪一侧。
通常,OpenGL 负责处理绕序,以确保特定列表中的所有形状都有一致的朝向。如果你作为程序员尝试手动处理朝向,你实际上是在质疑 OpenGL。
回到埃及
如我们已经演示了绘制立方体的代码,让我们尝试一些更有趣的东西:一个金字塔。金字塔是由底部的正方形和四个三角形构成的。因此,创建金字塔的最简单方法就是创建四个GL_TRIANGLE原语和一个GL_QUAD原语:
int DrawGlPyramid(GLvoid)
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glLoadIdentity();
glTranslatef(-1.5f,0.0f,-6.0f);
glBegin(GL_TRIANGLES);
glColor3f(1.0f,0.0f,0.0f);
glVertex3f( 0.0f, 1.0f, 0.0f);
glColor3f(0.0f,1.0f,0.0f);
glVertex3f(-1.0f,-1.0f, 1.0f);
glColor3f(0.0f,0.0f,1.0f);
glVertex3f( 1.0f,-1.0f, 1.0f);
glColor3f(1.0f,0.0f,0.0f);
glVertex3f( 0.0f, 1.0f, 0.0f);
glColor3f(0.0f,0.0f,1.0f);
glVertex3f( 1.0f,-1.0f, 1.0f);
glColor3f(0.0f,1.0f,0.0f);
glVertex3f( 1.0f,-1.0f, -1.0f);
glColor3f(1.0f,0.0f,0.0f);
glVertex3f( 0.0f, 1.0f, 0.0f);
glColor3f(0.0f,1.0f,0.0f);
glVertex3f( 1.0f,-1.0f, -1.0f);
glColor3f(0.0f,0.0f,1.0f);
glVertex3f(-1.0f,-1.0f, -1.0f);
glColor3f(1.0f,0.0f,0.0f);
glVertex3f( 0.0f, 1.0f, 0.0f);
glColor3f(0.0f,0.0f,1.0f);
glVertex3f(-1.0f,-1.0f,-1.0f);
glColor3f(0.0f,1.0f,0.0f);
glVertex3f(-1.0f,-1.0f, 1.0f);
glEnd();
}
建模职业
当你考虑到创建甚至最基本的形状所需的代码量时,你可能会对编写复杂的 3D 游戏感到绝望!幸运的是,有更好的工具可以创建 3D 对象。3D 建模软件允许 3D 建模师创建与艺术家使用绘图软件创建 2D 图像相似的三维对象。
将 3D 对象导入我们的游戏的过程通常有三个步骤:
-
在 3D 建模工具中创建 3D 对象。
-
将模型导出为数据文件。
-
将数据文件加载到我们的游戏中。
混合操作
有许多流行的工具被专业人士用来创建 3D 模型。其中最受欢迎的两个是 3D Max 和 Maya。然而,这些工具也相对昂贵。结果发现,有一个非常强大的免费 3D 建模工具叫做Blender。我们将安装 Blender,然后学习如何使用它来为我们的游戏创建 3D 模型。
Blender 是一个适合想要尝试 3D 建模的初学者的 3D 建模和动画套件。Blender 是由 Blender 组织创建的开源软件,并且免费提供(尽管 Blender 组织将很高兴接受你的捐赠)。按照以下步骤在您的计算机上安装 Blender:
-
前往
www.Blender.Org并按Enter。 -
点击页面顶部的下载链接。
-
下载与您的计算机兼容的文件。对于我的 64 位 Windows 计算机,我在以下截图中的圆圈中做了选择:
![混合操作]()
-
下载 Blender 后,运行安装程序并接受所有默认值以在您的计算机上安装 Blender。
Blender 概述
一旦你在你的计算机上安装了 Blender,打开它,你应该会看到以下屏幕:

不要让屏幕的复杂性吓到你。Blender 有很多功能,你会在使用过程中逐渐学会,他们甚至尝试将许多功能直接放在你的指尖(或者说鼠标指尖)。他们甚至为你创建了一个立方体模型,让你可以立即开始。
屏幕中间是动作发生的地方。这是 3D 视图。网格为你提供了一个参考,但它不是模型的一部分。在上面的截图中,唯一的模型是立方体。
中间周围的面板提供了创建和操作对象的各种选项。我们不会有时间涵盖这些选项中的大部分,但网上有很多教程可供参考。
构建你的宇宙飞船
就像我们在书的 2D 部分所做的那样,我们将构建一个简单的 3D 宇宙飞船,这样我们就可以在我们的宇宙中飞来飞去。由于我是一个程序员而不是建模师,这将是一个极其简单的宇宙飞船。让我们用圆柱体来构建它。
要构建我们的宇宙飞船,我们首先想要移除立方体。使用你的右鼠标按钮选择立方体。你可以通过它发出的三个箭头来判断它是否被选中:

现在按下键盘上的删除键,立方体就会消失。
小贴士
如果你像我一样,你会尝试并尝试使用左鼠标按钮来选择物体。然而,Blender 使用右鼠标按钮来选择物体!
你可能会在 3D 视图中注意到另外两个物体:

前一张图片中的物体代表相机。这不是你的游戏物体的一部分,而是代表从 Blender 内部看到的相机角度。你可以通过右键点击它并按H来隐藏它。

前一张图片中的物体代表光源。这不是你的游戏物体的一部分,而是代表 Blender 使用的光源。你可以通过右键点击它并按H来隐藏它。
现在,让我们创建那个圆柱体。在左侧面板中找到创建标签,并使用你的左鼠标按钮点击它:

接下来,点击圆柱体按钮。Blender 将在 3D 视图中创建一个圆柱体:

注意到三个箭头。这些指示圆柱体是选中的物体。箭头用于移动、调整大小和旋转物体,但我们今天不会做这些。
你还应该注意到圆柱体内部有一个同心虚线圆圈。这表示物体的原点,即物体将围绕这个点移动、调整大小和旋转的点。
如果我们在建模一个真实物体,我们还会做很多其他的事情。由于这是一本编程书而不是建模书,我们不会做那些事情,但这里有一些未来学习的一些想法:
-
我们可以继续创建更多的物体,并使用它们来构建一个更加复杂的宇宙飞船
-
我们可以使用纹理和材质来给我们的宇宙飞船一个皮肤
导出物体
为了将宇宙飞船带入我们的游戏,我们必须首先将物体导出为一个可以被游戏读取的数据文件。我们可以使用很多不同的格式,但为了这个游戏,我们将使用.obj导出类型。要导出物体,执行以下操作:
-
点击文件命令,然后点击导出。
-
选择Wavefront (.obj)作为文件类型。
-
在下一个屏幕中,选择你的导出位置(最好是游戏源代码的位置)并将其命名为
ship.obj。 -
点击屏幕右侧的导出 OBJ按钮。
![导出对象]()
恭喜!你现在离将这个对象带入你的游戏只有一步之遥了。
正在加载
.obj文件只是一个文本文件,它存储了所有用于在 OpenGL 中渲染此对象的顶点和其他数据。以下截图显示了在记事本中打开的ship.obj文件:

-
#:这定义了一个注释 -
v:这定义了一个顶点 -
vt:这定义了一个纹理坐标 -
vn:这定义了一个法线 -
f:这定义了一个面
我们现在将编写代码将此数据加载到我们的游戏中。将 SpaceRacer3D 项目打开到 Visual Studio 中。然后添加以下头文件:
#include #include #include enum Primitive
{
Triangles = 0,
Quads = 1
};
struct Vec2
{
Vec2()
{
x = 0.0f;
y = 0.0f;
}
Vec2(const float p_x, const float p_y)
{
x = p_x;
y = p_y;
}
float x;
float y;
};
struct Vec3
{
Vec3()
{
x = 0.0f;
y = 0.0f;
z = 0.0f;
}
Vec3(const float p_x, const float p_y, const float p_z)
{
x = p_x;
y = p_y;
z = p_z;
}
float x;
float y;
float z;
};
const bool LoadObj(
const char * filepath,
std::vectortemp_vertices;
std::vectortemp_normals;
FILE * file = fopen(filepath, "r");
if (file == NULL)
{
return false;
}
bool finished = false;
while (!finished)
{
char line[128];
int check = fscanf(file, "%s", line);
if (check == EOF)
{
finished = true;
}
else
{
if (strcmp(line, "v") == 0)
{
Vec3 vertex;
fscanf(file, "%f %f %f\n", vertices.size(); i++)
{
unsigned int vertexIndex = vertices[i];
unsigned int normalIndex = normals[i];
Vec3 vertex = temp_vertices[vertexIndex - 1];
Vec3 normal = temp_normals[normalIndex - 1];
o_vertices.push_back(vertex);
o_normals.push_back(normal);
}
return true;
}
Before you can compile the code you will need a to add a pre-processor definition. Open the project properties, and navigate to the C/C++ branch of the Configuration Properties. Add _CRT_SECURE_NO_WARNINGS to the Preprocessor Definitions.
这是加载器正在做的事情:
加载器接受参数(一个输入和三个输出):
-
一个文件名。
-
一个指向顶点数组的指针。
-
一个指向 uvs 数组的指针。
-
一个指向法向量数组的指针。
-
创建了三个向量(C++中数组的一种类型)来存储从文件中解析出的数据。一个用于存储顶点,一个用于存储 uvs,一个用于存储法线。还创建了一个第四个向量,用于将每个顶点与一个 uv 坐标配对。
-
创建了三个临时向量作为输入缓冲区,以便在读取数据时使用。
-
现在正在读取
fbx文件。程序寻找指示正在读取的数据类型的标志。对于我们现在的目的,我们只关心顶点数据。 -
当读取每条数据时,它会被放入适当的向量中。
-
向量被返回,以便程序可以处理它们。
简单到足以吗?但是,代码量很大,因为解析总是很有趣!对于我们来说,从模型中提取的最重要数据是顶点数组。
小贴士
我们还没有讨论 uvs 和法向量,因为我不想让这本书变成一本关于建模的书。Uvs 用于给对象添加纹理。由于我们没有添加任何纹理,所以不会有 uv 数据。法向量告诉 OpenGL 对象的哪个面是朝外的。这些数据用于正确渲染和照亮对象。
在下一章中,我们将使用这个加载器将我们的模型加载到游戏中。
概述
在本章中,我们覆盖了很多内容。你学习了如何使用 OpenGL 在代码中创建 3D 对象。同时,你也了解到实际上你并不是在代码中创建 3D 对象!相反,真正的游戏使用的是在特殊的 3D 建模软件(如 Blender)中创建的模型。
即使作为一个程序员,学习一些关于使用软件(如 Blender)的知识也是有用的,但最终你将想要找到真正知道如何充分利用这些工具的艺术家和建模师。你甚至可以在网上找到 3D 模型并将它们集成到你的游戏中。
为了结束这一切,我们学习了如何将 3D 模型加载到我们的。花几天时间在 Blender 中玩玩,看看你能想出什么,然后继续下一章!
第十章。扩展空间
现在你已经知道如何构建你的 3D 世界,是时候做一些事情了!由于我们正在构建太空赛车游戏,我们需要能够移动我们的太空船。我们还会在游戏中放置一些障碍物,以便我们有东西可以与之竞赛。在本章中,你将学习以下主题:
-
放置游戏对象:我们将取一些 3D 对象,将它们加载到我们的游戏中,并在 3D 空间中放置它们。
-
变换:我们需要学习如何在 3D 中移动。在 2D 中移动很容易。在 3D 中,我们还有一个维度,现在我们还将想要在移动时考虑旋转。
-
视角:我们将学习视角如何影响我们玩游戏的方式。你想要坐在驾驶员座位上,还是只是在外面?
-
碰撞:我们在 2D 游戏中进行了碰撞检测。3D 中的碰撞检测更复杂,因为现在我们必须考虑检查中的三个空间维度。
创建 101
我们的首要任务是加载我们的世界。我们需要一些基本组件。首先,我们需要一个宇宙。这个宇宙将包含星星、小行星和我们的太空船。打开 SpaceRacer3D,让我们开始编码吧!
准备项目
在我们开始之前,我们需要将一些代码从我们的 2D 项目中移动过来。从 RoboRacer2D 复制以下文件和设置到 SpaceRacer3D:
-
复制
Input.cpp和Input.h——我们将使用这些类来处理用户输入。 -
复制
Sprite.cpp、Sprite.h、SOIL.h和SOIL.lib——我们将在下一章中使用它们来支持用户界面。你可能需要从Sprite.cpp中删除#include "stdafx.h"这一行。 -
复制
fmodex.dll——我们需要这个文件来支持音频。 -
复制项目配置属性/C/C++/常规/附加包含目录设置中的设置——这是为了提供对 FMOD 库的访问:
![准备项目]()
-
复制项目配置属性/链接器/输入/附加依赖项设置中的设置——这是为了提供对 OpenGL、FMOD 和 SOIL 库的访问:
![准备项目]()
-
复制项目配置属性/链接器/常规/附加库目录设置中的设置——这同样也是为了提供对 FMOD 库的访问:
![准备项目]()
加载游戏对象
在上一章中,我们学习了如何在 Blender 中创建 3D 对象并将它们导出为 obj 文件。然后,我们将代码添加到我们的项目中以加载 obj 数据。现在,我们将使用这段代码将一些模型加载到我们的游戏中。
我们将加载四个模型到我们的游戏中:太空船和三个小行星。想法是通过小行星带进行竞赛。由于我们的加载器将模型数据作为三个数组(顶点、纹理坐标和法线)持有,我们将创建一个模型类来定义这些数组,然后使用这个类来加载我们想要加载到游戏中的每个模型。
模型类头文件
创建一个新的类和头文件,分别命名为 Model.cpp 和 Model.h。打开 Model.h。首先,让我们设置好头文件:
#pragma once
#include <stdlib.h>
#include <math.h>
#include "LoadObj.h"
#include "glut.h"
我们需要使用在 math.h 中定义的一些常量,因此我们需要添加一个预处理器指令。将 _USE_MATH_DEFINES 添加到 Configuration Properties/C/C++/Preprocessor/Preprocessor Definitions。注意,我们还包含了 LoadObj.h,因为我们将在类内部加载模型。现在,让我们创建这个类:
class Model
{
public:
struct Color
{
Color()
{
r = 0.0f;
g = 0.0f;
b = 0.0f;
}
Color(const float p_r, const float p_g, const float p_b)
{
r = p_r;
g = p_g;
b = p_b;
}
float r;
float g;
float b;
};
};
我们将大量使用颜色,因此我们定义了一个 struct 来保存 r、g 和 b 值,使事情更加方便。现在,对于我们的方法,我们使用以下代码:
Model(const char* p_filepath, const Color p_color);
~Model();
void Update(const float p_deltaTime);
void Render();
void SetPosition(const float p_x, const float p_y, const float p_z);
void SetPosition(const Vec3 p_position);
const Vec3 GetPosition() const;
void SetHeading(const float p_x, const float p_y, const float p_z);
void SetHeading(const Vec3 p_heading);
const Vec3 GetHeading() const;
void SetColor(const float p_red, const float p_green, const float p_blue);
void SetColor(const Color p_color);
void SetBaseRotation(const float p_x, const float p_y, const float p_z);
void SetBaseRotation(const Vec3 p_rotation);
const Vec3 GetBaseRotation() const;
void SetHeadingRotation(const float p_x, const float p_y, const float p_z);
void SetHeadingRotation(const Vec3 p_rotation);
const Vec3 GetHeadingRotation() const;
void SetVelocity(const float p_velocity);
const float GetVelocity() const;
const bool IsShip();
void IsShip(const bool p_IsShip);
const bool IsVisible() const { return m_isVisible; };
void IsVisible(const bool p_isVisible) { m_isVisible = p_isVisible; };
};
下面是每个方法的简要描述:
-
Model是构造函数。它接受一个文件名和一个颜色。由于我们的模型是简单的形状,我们将使用颜色来给它们增添一些魅力。 -
SetPosition和GetPosition管理对象在全局空间中的位置。 -
SetHeading和GetHeading管理对象前进的方向。 -
SetColor和GetColor管理对象的颜色。 -
SetBaseRotation和GetBaseRotation管理应用于对象的任何局部旋转。 -
SetHeadingRotation和GetHeadingRotation管理对象在全局空间中的方向。 -
SetVelocity和GetVelocity管理对象的速率。
现在,对于变量,我们使用以下代码:
m_vertices;
std::vectorm_normals;
Vec3 m_position;
Vec3 m_heading;
Vec3 m_baseRotation;
Vec3 m_headingRotation;
Color m_color;
Primitive m_primitive;
float m_velocity;
bool m_isVisible;
bool m_loaded;
bool m_IsShip;
float m_radius;
bool m_collideable;
这些方法都是不言自明的,因为它们直接对应于之前描述的方法。这个头文件是我们放置世界中的对象并移动它们的良好结构。
实现 Model 类
现在,让我们实现这个类。打开 Model.cpp 并开始。首先,我们实现头文件、构造函数和析构函数:
#include "Model.h"
Model::Model(const char* p_filepath, const Color p_color)
{
m_filepath = p_filepath;
m_loaded = LoadObj(m_filepath, m_vertices, m_normals, m_primitive);
SetPosition(0.0f, 0.0f, 0.0f);
SetHeading(0.0f, 0.0f, 0.0f);
SetHeadingRotation(0.0f, 0.0f, 0.0f);
SetBaseRotation(0.0f, 0.0f, 0.0f);
IsShip(false);
SetVelocity(0.0f);
SetColor(p_color.r, p_color.g, p_color.b);
SetRadius(1.0f);
IsCollideable(true);
IsVisible(true);
}
Model::~Model()
{
m_vertices.clear();
m_normals.clear();
}
构造函数设置了所有内容。注意,我们从构造函数中调用 LoadObj 来实际将对象加载到类中。结果将存储在成员数组 m_vertices 和 m_normals 中。m_primitive 将持有枚举,告诉我们这个对象是由四边形还是三角形定义的。其余变量被设置为默认值。这些可以在游戏中的任何时间通过使用适当的 accessor 方法来定义:
float Deg2Rad(const float p_degrees)
{
return p_degrees * (M_PI / 180.0f);
}
Deg2Rad 是一个辅助函数,它将度数转换为弧度。当我们移动船只时,我们跟踪航向角度的度数,但我们在 OpenGL 函数中经常需要使用弧度:
void Model::Update(const float p_deltaTime)
{
Vec3 targetRotation = GetHeadingRotation();
Vec3 currentPosition = GetPosition();
Vec3 targetPosition = GetPosition();
float distance = m_velocity * p_deltaTime;
Vec3 deltaPosition;
deltaPosition.y = cos(Deg2Rad(targetRotation.z)) * distance;
deltaPosition.x = -sin(Deg2Rad(targetRotation.z)) * distance;
deltaPosition.z = sin(Deg2Rad(targetRotation.x)) * distance;
targetPosition.x += deltaPosition.x;
targetPosition.y += deltaPosition.y;
targetPosition.z += deltaPosition.z;
SetPosition(targetPosition);
}
Update 函数根据对象的速率更新对象的位置。最后,我们更新 m_heading,它将在渲染期间用于定位世界相机。然后更新对象在全局空间中的位置:
void Model::Render()
{
if (IsVisible())
{
glRotatef(-m_baseRotation.x, 1.0f, 0.0f, 0.0f);
glRotatef(-m_baseRotation.y, 0.0f, 1.0f, 0.0f);
glRotatef(-m_baseRotation.z, 0.0f, 0.0f, 1.0f);
Vec3 targetRotation = GetHeadingRotation();
Vec3 currentPosition = GetPosition();
if (m_IsShip)
{
glPushMatrix();
glLoadIdentity();
glRotatef(targetRotation.x, 1.0f, 0.0f, 0.0f);
glRotatef(targetRotation.y, 0.0f, 1.0f, 0.0f);
glRotatef(targetRotation.z, 0.0f, 0.0f, 1.0f);
GLfloat matrix[16];
glGetFloatv(GL_MODELVIEW_MATRIX, matrix);
glPopMatrix();
glTranslatef(currentPosition.x, currentPosition.y, currentPosition.z);
glMultMatrixf(matrix);
}
switch (m_primitive)
{
case Primitive::Quads:
glBegin(GL_QUADS);
break;
case Primitive::Triangles:
glBegin(GL_TRIANGLES);
break;
}
glColor3f(m_color.r, m_color.g, m_color.b);
for (unsigned int i = 0; i < m_vertices.size(); i++)
{
if (m_IsShip)
{
glVertex3f(m_vertices[i].x, m_vertices[i].y, m_vertices[i].z);
}
else
{
glVertex3f(m_vertices[i].x + m_position.x, m_vertices[i].y + m_position.y, m_vertices[i].z + m_position.z);
}
}
glEnd();
}
}
Render 函数负责渲染这个特定的对象。世界矩阵的设置将在游戏代码中完成。然后,游戏中的每个对象都将被渲染。
记得相机吗?相机是一个用于查看场景的虚拟对象。在我们的例子中,相机是船只。无论船只驶向何方,相机都会跟随。无论船只指向哪里,相机也会指向那里。
现在是真正的震撼点;OpenGL 实际上并没有相机。也就是说,在场景中并没有可以移动的相机。相反,相机始终位于坐标(0.0, 0.0, 0.0),即世界的原点。这意味着我们的船将始终位于原点。我们不会移动船,实际上我们会移动其他物体到相反的方向。当我们转向船时,实际上我们会旋转世界到相反的方向。
现在看看 Render 函数的代码:
-
首先,我们使用
glRotate来旋转物体的基础旋转。如果需要定位物体,这会很有用。例如,我们在上一章中建模的圆柱体是立着的,在游戏中侧躺效果更好。你会在后面看到,我们给圆柱体应用了 90 度的旋转来实现这一点。 -
接下来,我们必须决定是渲染四边形还是三角形。当 Blender 导出模型时,它将其导出为四边形或三角形。加载器会确定模型是定义为四边形还是三角形,并将结果存储在
m_primitive中。然后我们使用这个结果来确定这个特定物体是否必须使用三角形或四边形进行渲染。 -
我们使用
glColor来设置物体的颜色。在这个阶段,我们还没有为我们的模型分配任何纹理,所以颜色为我们提供了一个简单的方式来给每个物体赋予个性。
现在是真正的工作时间!我们需要在全局空间中绘制物体的每个顶点。为此,我们遍历顶点数组中的每个点,并使用 glVertex3f 来放置每个点。
但要注意的是;顶点数组中的点是局部坐标。如果我们使用这些点绘制每个物体,那么它们都会在原点绘制。你可能会记得,我们希望将每个物体放置在相对于船的游戏中。因此,我们在原点绘制船,并根据船的位置绘制游戏中的每个其他物体。我们移动的是宇宙,而不是船。

当船移动时,整个坐标系也会随之移动。实际上,坐标系保持不动,整个宇宙都在它周围移动!

如果我们正在渲染船,我们只需使用其局部坐标来绘制它,它将在原点渲染。所有其他物体都根据船的位置在船的某个距离处绘制。
现在,对于类实现的其余部分,使用以下代码:
void Model::SetPosition(const float p_x, const float p_y, const float p_z)
{
m_position.x = p_x;
m_position.y = p_y;
m_position.z = p_z;
}
void Model::SetPosition(const Vec3 p_position)
{
m_position.x = p_position.x;
m_position.y = p_position.y;
m_position.z = p_position.z;
}
const Vec3 Model::GetPosition() const
{
return m_position;
}
这些方法设置和检索物体的位置。位置是基于物体在 Update 方法中的速度来改变的:
void Model::SetHeading(const float p_x, const float p_y, const float p_z)
{
m_heading.x = p_x;
m_heading.y = p_y;
m_heading.z = p_z;
}
void Model::SetHeading(const Vec3 p_heading)
{
m_heading.x = p_heading.x;
m_heading.y = p_heading.y;
m_heading.z = p_heading.z;
}
const Vec3 Model::GetHeading() const
{
return m_heading;
}
这些方法设置和检索物体的航向。航向是基于物体在 Update 方法中的航向旋转来改变的。航向是船前进的方向,用于旋转世界,使船看起来朝正确的方向前进:
void Model::SetColor(const float p_red, const float p_green, const float p_blue)
{
m_color.r = p_red;
m_color.g = p_green;
m_color.b = p_blue;
}
void Model::SetColor(const Color p_color)
{
m_color.r = p_color.r;
m_color.g = p_color.g;
m_color.b = p_color.b;
}
这些方法用于管理对象的颜色:
void Model::SetVelocity(const float p_velocity)
{
m_velocity = p_velocity;
}
const float Model::GetVelocity() const
{
return m_velocity;
}
这些方法用于管理对象的速率。速率在游戏代码的输入阶段设置:
void Model::SetBaseRotation(const float p_x, const float p_y, const float p_z)
{
m_baseRotation.x = p_x;
m_baseRotation.y = p_y;
m_baseRotation.z = p_z;
}
void Model::SetBaseRotation(const Vec3 p_rotation)
{
m_baseRotation.x = p_rotation.x;
m_baseRotation.y = p_rotation.y;
m_baseRotation.z = p_rotation.z;
}
const Vec3 Model::GetBaseRotation() const
{
return m_baseRotation;
}
这些方法用于管理对象的基本旋转。基本旋转用于在局部空间中旋转对象:
void Model::SetHeadingRotation(const float p_x, const float p_y, const float p_z)
{
m_headingRotation.x = p_x;
m_headingRotation.y = p_y;
m_headingRotation.z = p_z;
}
void Model::SetHeadingRotation(const Vec3 p_rotation)
{
m_headingRotation.x = p_rotation.x;
m_headingRotation.y = p_rotation.y;
m_headingRotation.z = p_rotation.z;
}
const Vec3 Model::GetHeadingRotation() const
{
return m_headingRotation;
}
这些方法用于管理对象的方向旋转。方向旋转用于围绕对象旋转世界,使对象看起来朝向特定方向。只有一个对象(飞船)将具有方向旋转。另一种思考方式是,方向旋转是摄像机的旋转,在我们的游戏中,摄像机是附着在飞船上的。
修改游戏代码
现在是时候修改我们的游戏代码,使其能够加载和操作游戏模型了。打开 SpaceRacer3D.cpp。
我们首先添加适当的头文件。在代码顶部,修改头文件定义,使其看起来像以下代码:
#include <windows.h>
#include "Model.h"
#include "Sprite.h"
#include "Input.h"
#include "glut.h"
注意,我们已经添加了 Model.h 来加载我们的模型。我们还从 RoboRacer2D 中包含了 Sprite.h 和 Input.h,这样我们就可以在我们新游戏需要时使用这些类。
现在,我们需要定义一些全局变量来管理模型加载。在已定义的任何全局变量下面,添加以下代码:
Model* ship;
std::vector<Model*> asteroids;
这些变量定义了指向我们的游戏对象的指针。由于飞船有点特殊,我们给它自己的指针。我们希望能够有任意数量的小行星;我们设置了一个指针向量(一个很好的动态数组)称为 asteroids。
移动到 StartGame 函数,我们使用它来初始化所有的游戏模型。修改 StartGame 函数,使其看起来像以下代码:
void StartGame()
{
//Ship
Model::Color c(0.0f, 0.0f, 1.0f);
ship = new Model("ship.obj", c);
Vec3 rotation(90.0f, 0.0f, 0.0f);
ship->SetBaseRotation(rotation);
ship->IsShip(true);
ship->SetVelocity(1.0f);
//Asteroid 1
c.r = 1.0f;
c.g = 0.0f;
c.b = 0.0f;
Model* asteroid = new Model("asteroid.obj", c);
Vec3 position(0.0f, 0.0f, -10.0f);
asteroid->SetPosition(position);
asteroids.push_back(asteroid);
//Asteroid 2
c.r = 0.0f;
c.g = 1.0f;
c.b = 0.0f;
asteroid = new Model("asteroid.obj", c);
position.x = 5.0f;
position.y = 0.0f;
position.z = -15.0f;
asteroid->SetPosition(position);
asteroids.push_back(asteroid);
//Asteroid 3
c.r = 0.0f;
c.g = 1.0f;
c.b = 1.0f;
asteroid = new Model("asteroid.obj", c);
position.x = 5.0f;
position.y = 5.0f;
position.z = -20.0f;
asteroid->SetPosition(position);
asteroids.push_back(asteroid);
}
我们将创建一个飞船对象和三个小行星对象。对于每个对象,我们首先定义一个颜色,然后创建一个新的 Model,传递对象的文件名和颜色。Model 类将加载从 Blender 导出的对象文件。
注意,我们使用 IsCamera(true) 调用将飞船设置为相机。我们还使用 AttachCamera(ship) 调用将飞船作为相机附加到每个游戏对象。
我们还为每个对象设置了一个位置。这将设置世界空间中的位置。这样我们就不会在每个对象的原点绘制每个对象了!
每个小行星都使用 push.back 方法放入小行星数组中。
现在,我们转到 Update 函数。修改 Update 函数,使其看起来像以下代码:
void Update(const float p_deltaTime)
{
ship->Update(p_deltaTime);
for (unsigned int i = 0; i < asteroids.size(); i++)
{
asteroids[i]->Update(p_deltaTime);
}
}
更新只是调用游戏中每个对象的 Update 方法。像往常一样,更新基于游戏中经过的时间量,所以我们传递 p_deltaTime。
现在转到 Render 函数。用以下代码替换现有的代码:
void Render()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
for (unsigned int i = 0; i < asteroids.size(); i++)
{
asteroids[i]->Render();
}
ship->Render();
SwapBuffers(hDC);
}
渲染代码是游戏中的真正工作马。首先,我们设置本帧的渲染调用,然后对每个游戏对象调用 Render 方法:
-
GlClear:此操作清除渲染缓冲区。 -
GlMatrixMode:这会将模型设置为模型视图。所有平移和旋转都应用于模型视图。 -
glLoadIdentity():这会重置矩阵。 -
接下来,我们为游戏中的每个对象调用
Render方法。 -
最后,我们调用
SwapBuffers,这实际上会将场景渲染到屏幕上。
恭喜!如果您现在运行游戏,应该能看到飞船和远处的三个小行星。由于我们设置了飞船的速度为 1.0,您也应该看到飞船缓慢地穿过小行星。然而,我们还没有任何控制飞船的方法,因为我们还没有实现任何输入。

掌控
我们现在有一个加载和渲染游戏对象的框架。但是,我们没有移动飞船的方法!好消息是,我们已经在 RoboRacer2D 中编写了一个输入类,并且可以在这里重用那段代码。
实现输入
在本章的早期,我让您将Input类从 RoboRacer2D 复制到 SpaceRacer3D 的源文件夹中。现在,我们只需将其连接到我们的游戏代码中。
打开 SpaceRacer3D。首先,我们需要包含输入头文件。在头文件中添加以下代码行:
#include "Input.h"
我们还需要创建一个全局指针来管理Input类。在模型指针下方添加以下行:
Input* m_input;
接下来,我们需要创建Input类的一个实例。在StartGame函数顶部添加以下代码行:
m_input = new Input(hWnd);
现在,我们必须创建一个函数来处理我们的输入。在Update方法上方添加以下函数:
void ProcessInput(const float p_deltaTime)
{
Vec3 rotation;
m_input->Update(p_deltaTime);
Input::Command command = m_input->GetCommand();
switch (command)
{
case Input::CM_STOP:
{
if (ship->GetVelocity() > 0.0f)
{
ship->SetVelocity(0.0f);
}
else
{
ship->SetVelocity(1.0f);
}
}
break;
case Input::CM_DOWN:
{
rotation = ship->GetHeadingRotation();
rotation.x += -1.0f;
if (rotation.x < 0.0f)
{
rotation.x = 359.0f;
}
if (rotation.x < 359.0f && rotation.x > 180.0f)
{
if (rotation.x < 315.0f)
{
rotation.x = 315.0f;
}
}
ship->SetHeadingRotation(rotation);
}
break;
case Input::CM_UP:
{
rotation = ship->GetHeadingRotation();
rotation.x += 1.0f;
if (rotation.x > 359.0f)
{
rotation.x = 0.0f;
}
if (rotation.x > 0.0f && rotation.x < 180.0f)
{
if (rotation.x > 45.0f)
{
rotation.x = 45.0f;
}
}
ship->SetHeadingRotation(rotation);
}
break;
case Input::CM_LEFT:
{
rotation = ship->GetHeadingRotation();
rotation.z += 1.0f;
if (rotation.z > 359.0f)
{
rotation.z = 0.0f;
}
if (rotation.z > 0.0f && rotation.z < 180.0f)
{
if (rotation.z > 45.0f)
{
rotation.z = 45.0f;
}
}
ship->SetHeadingRotation(rotation);
}
break;
case Input::CM_RIGHT:
{
rotation = ship->GetHeadingRotation();
rotation.z += -1.0f;
if (rotation.z < 0.0f)
{
rotation.z = 359.0f;
}
if (rotation.z < 359.0f && rotation.z > 180.0f)
{
if (rotation.z < 315.0f)
{
rotation.z = 315.0f;
}
}
ship->SetHeadingRotation(rotation);
}
break;
}
}
此代码处理键盘输入。您会记得从 RoboRacer2D 中,我们将虚拟命令映射到以下键:
-
CM_STOP:这是空格键。我们使用空格键作为切换来启动和停止飞船。如果飞船停止,按下空格键会设置速度。如果飞船的速度大于零,则按下空格键会将速度设置为零。 -
CM_UP:这是向上箭头键和W键。按下任一键都会改变航向旋转,使飞船向上移动。 -
CM_DOWN:这是向下箭头键和S键。按下任一键都会改变航向旋转,使飞船向下移动。 -
CM_LEFT:这是左箭头键和A键。按下任一键都会改变航向旋转,使飞船向左移动。 -
CM_RIGHT:这是向右箭头键和D键。按下任一键都会改变航向旋转,使飞船向右移动。
每个方向命令通过检索当前航向角度并改变航向向量的适当分量一度来实现。航向角度由每个对象的Update方法用来计算航向向量,该向量用于在Render方法中指向相机。
最后,我们需要从游戏的 Update 函数中调用 HandleInput。在对象更新调用之前,将以下代码行添加到 Update 方法的顶部。我们希望首先处理输入,然后调用每个对象的更新方法:
ProcessInput(p_deltaTime);
就这样!给自己鼓掌并运行游戏。你现在可以使用键盘来控制飞船并导航你的宇宙。
小行星回转滑雪
现在是时候实现本章的最后一个特性了。我们将实现一个带有转折点的回转滑雪比赛。在典型的回转滑雪中,目标是绕过每个障碍物而不触碰它。为了简化问题,我们将通过每个小行星。如果你成功通过每个小行星,你就赢得了比赛。
设置碰撞检测
为了确定你是否通过了小行星,我们必须实现一些 3D 碰撞检测。有许多种类的碰撞检测,但我们将保持简单,并实现球形碰撞检测。
球形碰撞检测是一个简单的检查,看看两个 3D 对象中心是否在彼此的一定距离内。由于我们的陨石是球形的,这将是一个相当准确的指示,表明我们是否与之相撞。然而,飞船不是球形的,所以这种方法并不完美。
让我们从向 Model 类添加适当的方法开始我们的碰撞检测编码。打开 Model.h 并添加以下方法:
const bool IsCollideable();
void IsCollideable(const bool collideable);
const bool CollidedWith(Model* target);
const Vec3 GetCenter() const;
void SetRadius(const float p_radius);
const float GetRadius() const;
这是我们将如何使用每个方法:
-
IsCollideable用于获取或设置m_collideable标志。对象默认设置为可碰撞。我们游戏中的所有对象都设置为可碰撞,这样我们就可以检测飞船是否撞上了小行星。然而,在游戏中有一些对象你不希望它们发生碰撞是很常见的。如果你设置IsCollideable(false),则碰撞检测将被忽略。 -
CollidedWith是执行实际碰撞检测的方法。 -
GetCenter是一个辅助函数,用于计算对象在全局空间中的中心点。 -
SetRadius和GetRadius是帮助函数,用于管理对象的碰撞半径。
我们还需要添加两个变量来跟踪半径和碰撞:
float m_radius;
bool m_collideable;
现在,打开 Model.cpp 并添加以下代码以实现碰撞方法。
首先,我们需要在构造函数中定义半径。将以下代码行添加到构造函数中:
SetRadius(1.0f);
IsCollideable(true);
现在添加以下方法:
const bool Model::IsCollideable()
{
return m_collideable;
}
void Model::IsCollideable(const bool p_collideable)
{
m_collideable = p_collideable;
}
const bool Model::CollidedWith(Model* p_target)
{
if (p_target->IsCollideable() && this->IsCollideable())
{
const Vec3 p1 = this->GetCenter();
const Vec3 p2 = p_sprite->GetCenter();
float y = p2.y - p1.y;
float x = p2.x - p1.x;
float z = p2.z - p1.z;
float d = x*x + y*y + z*z;
float r1 = this->GetRadius() * this->GetRadius();
float r2 = p_sprite->GetRadius() * p_sprite->GetRadius();
if (d <= r1 + r2)
{
return true;
}
}
return false;
}
const Vec3 Model::GetCenter() const
{
Vec3 center;
center = GetPosition();
if (m_IsShip)
{
center.z = -m_position.y;
center.x = m_position.x;
center.y = m_position.z;
}
return center;
}
void Model::SetRadius(const float p_radius)
{
m_radius = p_radius;
}
const float Model::GetRadius() const
{
return m_radius;
}
-
IsCollideable和重写用于获取对象是否可以碰撞或获取碰撞标志的状态。 -
GetCenter返回对象的当前位置。由于我们用对象原点在中心建模了所有对象,返回位置也返回了对象中心。一个更复杂的算法将使用对象的边界大小来计算中心。 -
GetRadius和SetRadius管理半径,这对于碰撞检查代码是必需的。 -
CollidedWith是执行所有工作的方法。在确认当前对象和目标对象都可以发生碰撞之后,该方法执行以下操作:-
获取两个物体的中心点
-
计算两个中心点之间的 3D 距离
-
检查距离是否小于两个半径之和。如果是这样,则两个物体已经相撞:
![设置碰撞检测]()
-
如果你足够敏锐,你会注意到这种碰撞检测与 RoboRacer2D 中使用的碰撞检测非常相似。我们只是简单地给方程增加了 z 维度。
打开碰撞
现在,我们将在游戏中实现碰撞检测代码。打开SpaceRacer3D.cpp文件,在Update函数之前添加以下函数:
void CheckCollisions()
{
bool collision = false;
for (int i = 0; i < asteroids.size(); i++)
{
Model* item = asteroids[i];
collision = ship->CollidedWith(item);
if (collision)
{
item->IsCollideable(false);
item->IsVisible(false);
score++;
asteroidsHit++;
}
}
}
此方法执行以下操作:
-
它定义了一个碰撞标志。
-
它遍历所有的陨石。
-
它检查小行星是否与飞船相撞。
-
如果小行星与飞船相撞,我们将小行星的
IsCollideable属性设置为false。这样,当飞船穿过小行星时,碰撞就不会发生多次。对于我们的游戏,我们只需要与小行星碰撞一次,所以这已经足够了。
我们需要将碰撞检测连接到Update函数中。在HandleInput调用之后,向Update方法中添加以下行:
HandleCollisions();
就这样。我们现在已经实现了基本的碰撞检测!
摘要
在本章中,我们涵盖了大量的代码。你实现了一个简单而有效的框架,用于在游戏中创建和管理 3D 对象。这个类包括了加载模型、在 3D 空间中定位模型以及检测碰撞所必需的功能。
我们还在游戏中实现了输入和碰撞检测,以创建一个修改过的回旋赛,要求你穿过每一个陨石。
在下一章中,我们将实现用户界面和计分系统,使这个游戏更加完整。
第十一章。抬头
在本章中,我们将通过添加几乎任何游戏中都会看到的一些功能来对 Space Racer 3D 进行一些收尾工作。其中许多功能与我们在 Robo Racer 2D 游戏中添加的收尾工作类似,尽管现在我们在 3D 中工作有一些特殊考虑。我们将涵盖的主题包括以下内容:
-
在 3D 世界中实现 2D:到目前为止,我们学习了如何在 2D 中渲染,以及如何在 3D 中渲染。然而,在 3D 世界中创建 2D 内容有一些特殊考虑。由于我们的用户界面通常是 2D 创建的,我们将学习如何混合这两种类型的渲染。
-
创建抬头显示(HUD):对于第一人称 3D 游戏来说,有一个持续显示与游戏相关的信息的状态是非常典型的。我们将学习如何创建一个基本的抬头显示或 HUD。
-
更多游戏状态:正如我们在 Robo Racer 2D 中所做的那样,我们将创建一个基本的状态管理器来处理我们完成的游戏中的各种模式。
-
计分:我们需要一种方法来在我们的游戏中计分,并需要设置基本的胜负条件。
-
游戏结束:当游戏结束时,我们将通过 3D 的转折点给予一些信用。
混合事物
现在我们正在 3D 中渲染,如何渲染 2D 内容并不立即明显。这尤其适用于我们的用户界面,它必须渲染在 3D 场景之上,并且不会随着世界其他部分移动或旋转。
在 3D 世界中创建 2D 界面的技巧是首先渲染 3D 世界,然后在 OpenGL 中切换模式,然后渲染 2D 内容。以下图像表示我们需要渲染的 3D 内容:

下一个图像表示我们想要渲染的 2D 文本:

我们希望最终结果是 3D 和 2D 内容的组合,如图所示:

保存状态
状态是游戏编程中用于许多不同方式的一个术语。例如,我们将在本章后面创建一个状态管理器来管理游戏中的不同状态或模式。定义状态的另一种方式是一组条件。例如,当我们设置渲染为 3D 时,这是一组条件或状态。当我们设置渲染为 2D 时,这是另一组条件或状态。
能够在 2D 和 3D 中渲染的技巧是能够设置一个状态,然后切换到另一个状态。OpenGL 通过矩阵保存状态。为了从一个状态切换到另一个状态,我们需要一种方法来保存当前矩阵,设置另一个矩阵,然后在我们完成时返回到先前的矩阵。
推送和弹出
OpenGL 提供了两种方法来保存当前状态并在稍后检索它:
-
glPushMarix():此命令通过将其放置在堆栈上保存当前状态。 -
glPopMatrix():此命令通过从堆栈中取出它来检索先前状态。
栈是一种结构,允许你将其数据放在顶部(一个推入),然后稍后从顶部检索该数据(一个弹出)。当你想要按顺序保存数据,然后稍后以相反的顺序检索它时,栈非常有用。
假设我们从一个称为状态 A的初始条件集开始:

调用glPushMatrix()会将状态 A压入栈中:

接下来,我们设置状态 B的条件。如果我们想保存这个状态,我们将发出另一个glPushMatrix()调用:

现在我们栈中有两个项目,这也应该非常清楚地说明为什么它被称为栈!然后我们可以定义状态 C。这个步骤序列可以按需继续,创建一个渲染状态并将其推入栈中。一般来说,我们希望以我们加载的相反顺序卸载栈。这被称为FILO栈:先进先出。
我们使用glPopMatrix()命令从栈中移除项目:

结果替换了状态 C,将渲染设置恢复到状态 B:

另一个glPopMatrix()调用会清空栈并将渲染设置恢复到状态 A:

模型视图允许将 32 个矩阵放入栈中。每个视图都有自己的栈,因此投影视图有一个与模型视图分开的栈。此外,如果你发出glPopMatrix,而栈中没有矩阵,你会收到一个错误。换句话说,不要尝试弹出比你推入的更多的东西!
小贴士
为了最佳地管理内存,你应该始终弹出你已推入的状态,即使你不需要对它们进行任何操作。这会释放出用于保存你正在保存的状态中数据所占用的内存。
双状态渲染
我们现在将设置我们的代码,使其能够在 3D 和 2D 中渲染。打开SpaceRacer3D.cpp。我们将把渲染分成两个函数:Render3D和Render2D。然后,我们将从主Render函数中调用这些函数。让我们从Render3D开始。在Render函数上方添加以下代码(你可以直接从Render函数中剪切它):
void Render3D()
{
if (gameState == GS_Running)
{
for (unsigned int i = 0; i < asteroids.size(); i++)
{
asteroids[i]->Render();
}
ship->Render();
}
}
接下来,我们将创建两个支持函数来打开和关闭 2D 渲染。第一个将是Enable2D。在Render3D函数上方添加以下函数:
void Enable2D()
{
glColor3f(1.0f, 1.0f, 1.0f);
glEnable(GL_TEXTURE_2D);
glMatrixMode(GL_PROJECTION);
glPushMatrix();
glLoadIdentity();
glOrtho(0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 1);
glMatrixMode(GL_MODELVIEW);
glPushMatrix();
glLoadIdentity();
glPushAttrib(GL_DEPTH_BUFFER_BIT);
glDisable(GL_DEPTH_TEST);
}
Enable2D执行将渲染模式更改为 2D 所必需的任务:
-
glColor3f调用设置当前绘图颜色为白色。这需要一些解释。我们总是先渲染 3D,然后切换到 2D。如果我们没有将颜色设置为白色,那么 2D 内容中的所有颜色都会与 3D 渲染最后使用的颜色混合。将渲染颜色设置为白色实际上意味着清除渲染颜色,以便 2D 内容可以准确渲染。将颜色设置为白色并不意味着所有内容都会以白色绘制。这意味着不会向我们在 2D 中渲染的对象添加额外的颜色。 -
如果你想渲染 2D 纹理,
glEnable(GL_TEXTURE_2D)调用是必需的。如果省略了这个调用,那么任何 2D 纹理都不会正确渲染。 -
接下来的四行代码保存了 3D 投影矩阵,并设置投影矩阵以 2D 模式渲染。
glPushMatrix将当前投影矩阵推入栈中。然后我们使用glLoadIdentity初始化投影矩阵。最后,通过调用glOrtho设置正交投影。看看 RoboRacer2D,你会注意到它使用相同的glOrtho调用设置 2D 渲染! -
接下来的三行代码保存了 3D 模型视图矩阵,并为其 2D 绘制初始化。
glPushMatrix将当前模型视图矩阵推入栈中。然后我们通过调用glLoadIdentity初始化模型视图矩阵。 -
最后,我们需要关闭深度缓冲区的检查。深度缓冲区检查仅适用于 3D 渲染,并干扰 2D 渲染。
glPushAttrib与glPushMatrix的工作方式类似,但它只将单个 OpenGL 属性推入栈中。在这种情况下,我们将当前的GL_DEPTH_BUFFER_BIT推入属性栈,从而保存之前 3D 渲染的当前状态。接下来,我们使用glDisable调用关闭深度检查。
因此,设置 2D 渲染环境涉及四个步骤:
-
重置渲染颜色并启用 2D 纹理。
-
保存 3D 投影矩阵并设置 2D 投影矩阵。
-
保存 3D 模型视图矩阵并初始化 2D 模型视图矩阵。
-
保存 3D 深度位并关闭 2D 中的深度检查。
现在,我们准备好编写Disable2D函数。在刚刚创建的Enable2D函数下方创建这个新函数:
void Disable2D()
{
glPopAttrib();
glMatrixMode(GL_PROJECTION);
glPopMatrix();
glMatrixMode(GL_MODELVIEW);
glPopMatrix();
glDisable(GL_TEXTURE_2D);
}
并不令人意外的是,Disable2D执行的动作顺序与我们执行Enable2D时的顺序相反:
-
首先,我们通过调用
glPopAttrib()恢复深度检查,该调用从属性栈中移除最后推入的属性并将其恢复到当前渲染状态。这将恢复到我们开始 2D 渲染之前的深度检查状态。 -
接下来的两行代码将投影矩阵恢复到 3D 状态。同样,
glPopMatrix调用从栈顶取出项目并将其应用于当前渲染状态。 -
接下来的两行代码弹出模型视图矩阵。
-
最后一行禁用了 2D 纹理。
现在,是时候创建我们的Render2D函数了。在Render3D函数上方添加以下代码:
void Render2D()
{
Enable2D();
// Future 2D rendering code here
Disable2D();
}
好玩的是,我们目前还没有任何 2D 内容要渲染!在本章的后面部分,我们将填充这个函数的其余内容。这里需要注意的重要事情是,这个函数将负责通过调用Enable2D启用 2D 渲染。然后代码将被添加来渲染我们的 2D 内容。最后,我们将通过调用Disable2D关闭 2D 渲染。
现在我们已经有了渲染 2D 和 3D 所需的所有必要支持代码,我们将修改Render函数:
void Render()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
Render3D();
Render2D();
SwapBuffers(hDC);
}
你会注意到现在这有多简单:
-
首先,我们清除颜色缓冲区并重置矩阵。我们在每次渲染每一帧之前都会这样做。
-
接下来,我们渲染 3D 内容。
-
然后我们渲染 2D 内容。
-
最后,我们交换缓冲区,这将渲染我们所有的内容到屏幕上。
如果你现在运行游戏,你应该会注意到没有任何变化。因为我们还没有创建任何 2D 内容来渲染,3D 内容将显示得和之前一样。现在我们准备添加我们的 2D 内容。在这个过程中,我们将完善一些额外的功能,以制作一个更完整的游戏。
状态问题
在我们开始实际渲染 2D 项目之前,我们需要在我们的游戏中添加一个状态机。就像我们在 RoboRacer2D 中所做的那样,我们需要能够处理几个不同的游戏状态:显示启动屏幕、加载资源、显示主菜单、运行游戏、暂停游戏和游戏结束。
小贴士
不要让“状态”这个词让你困惑,因为它在计算机编程中有多种用法。我们刚刚完成了一个关于渲染状态的章节,学习了如何从 OpenGL 堆栈中推送和弹出这个状态。现在,我们正在谈论游戏状态,你可以将其视为我们的游戏处于的不同模式。处理不同游戏状态的框架被称为状态机。
添加状态机
幸运的是,我们将能够直接从 RoboRacer2D 中获取一些代码。通过在 SpaceRacer3D 项目中点击文件,然后打开,并浏览到RoboRacer2D.cpp,你可以这样做。这将允许你从RoboRacer2D.cpp中复制信息并将其粘贴到 SpaceRacer3D 中。
小贴士
打开文件会将它加载到当前项目中,但不会将文件添加到当前项目中。然而,你需要小心,因为如果你修改了文件并保存,原始源文件将被修改。
复制GameState枚举,然后将其粘贴到SpaceRacer3D.cpp的顶部,紧随头文件之后:
enum GameState
{
GS_Splash,
GS_Loading,
GS_Menu,
GS_Credits,
GS_Running,
GS_NextLevel,
GS_Paused,
GS_GameOver,
};
我们将复制更多代码来自 RoboRacer2D.cpp,所以请继续打开它。
接下来,我们需要创建一个全局游戏状态变量。在SpaceRacer3D.cpp的全局变量部分添加以下定义:
GameState gameState;
gameState变量将存储当前游戏状态。
准备启动
正如我们在 RoboRacer2D 中所做的那样,我们将以启动屏幕开始我们的游戏。启动屏幕将在加载其他资源之前快速加载,并在移动到加载游戏资源和开始游戏之前显示几秒钟。
在gameState定义下方,添加以下几行:
float splashDisplayTimer;
float splashDisplayThreshold;
这两个变量将处理启动屏幕的时间。我们的启动屏幕将是游戏加载的众多 2D 资源之一。让我们继续定义一些用于我们的 2D 资源的变量。将以下代码行添加到SpaceRacer3D.cpp的全局变量部分:
Sprite* splashScreen;
Sprite* menuScreen;
Sprite* creditsScreen;
Sprite* playButton;
Sprite* creditsButton;
Sprite* exitButton;
Sprite* menuButton;
Sprite* gameOverScreen;
Sprite* replayButton;
你会注意到我们所有的 2D 资源都被处理为精灵(Sprites),这是一个我们从 RoboRacer2D 借用的类。
当我们在这里时,让我们也添加以下两行:
float uiTimer;
const float UI_THRESHOLD = 0.1f;
这两个变量将用于为鼠标点击添加时间缓冲。现在,让我们创建一个加载启动屏幕的函数。将以下函数添加到SpaceRacer3D.cpp中,位置在StartGame函数之前:
void LoadSplash()
{
gameState = GameState::GS_Splash;
splashScreen = new Sprite(1);
splashScreen->SetFrameSize(screenWidth, screenHeight);
splashScreen->SetNumberOfFrames(1);
splashScreen->AddTexture("resources/splash.png", false);
splashScreen->IsActive(true);
splashScreen->IsVisible(true);
splashScreen->SetPosition(0.0f, 0.0f);
}
这段代码与 RoboRacer2D 中的代码完全相同。实际上,你可以直接从RoboRacer2D.cpp中复制并粘贴它。
记住:我们设置了 2D 正交视口,以精确复制我们在 RoboRacer2D 中设置的设置。这允许我们使用完全相同的代码和位置来处理我们的 2D 对象。甚至更好,它允许我们使用 RoboRacer2D 中的Sprite类,而无需更改任何代码。
小贴士
LoadSplash函数从游戏资源文件夹中加载一个名为splash.png的文件。你可以从本书的网站下载这个文件以及本章中使用的所有其他 2D 资源。你应该将它们全部放在与游戏源代码相同的文件夹下的resources文件夹中。你还得记得通过右键点击资源文件,然后选择添加现有项,浏览到resources文件夹,并将该文件夹中的所有项目添加到资源文件文件夹中。
接下来,我们需要修改StartGame函数以加载启动屏幕。移动到StartGame函数,并添加以下代码:
LoadSplash();
uiTimer = 0.0f;
splashDisplayTimer = 0.0f;
splashDisplayThreshold = 5.0f;
我们首先调用LoadSplash函数,将游戏状态设置为GS_Splash,然后加载启动页面。接下来,我们必须更新并渲染启动页面。移动到Update函数,并修改它,使其看起来像这样:
void Update(const float p_deltaTime)
{
switch (gameState)
{
case GameState::GS_Splash:
case GameState::GS_Loading:
{
splashScreen->Update(p_deltaTime);
}
break;
case GameState::GS_Running:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
ship->Update(p_deltaTime);
ship->SetVelocity(ship->GetVelocity() + ship->GetVelocity()*p_deltaTime/10.0f);
speed = ship->GetVelocity() * 1000;
if (maximumSpeed < speed)
{
maximumSpeed = speed;
}
missionTime = missionTime + p_deltaTime * 100.0f;
CheckCollisions();
if (ship->GetPosition().z > 10.0f)
{
gameState = GS_GameOver;
menuButton->IsActive(true);
gameOverScreen->IsActive(true);
}
}
break;
case GameState::GS_GameOver:
{
gameOverScreen->Update(p_deltaTime);
replayButton->IsActive(true);
replayButton->Update(p_deltaTime);
exitButton->IsActive(true);
exitButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
ship->Update(p_deltaTime);
CheckCollisions();
}
break;
}
}
唯一真正的变化是我们实现了一部分状态机。你会注意到我们将所有运行游戏的代码移动到了GS_Running游戏状态案例下。接下来,我们添加了对启动屏幕游戏状态的更新。我们最终将修改Update函数以处理所有游戏状态,但我们还有一些工作要做。
现在,我们已经准备好渲染启动屏幕了。移动到Render2D函数,并在Enable2D和Disable2D调用之间添加以下代码行:
splashScreen->Render();
在这一点上,如果你运行游戏,你会看到一个启动屏幕被渲染。游戏不会超出启动屏幕,因为我们还没有添加前进的代码。
创建用户界面
我们现在准备好定义我们的用户界面,它将包括 2D 屏幕、文本和按钮。这些都将与 RoboRacer2D 中的工作方式完全相同。查看本章前面“准备启动”部分中的提示,以提醒如何将预构建的 2D 资源包含到你的项目中。
定义文本系统
2D 文本系统是通过首先创建一个字体框架,然后创建在屏幕上显示文本的函数来构建的。打开RoboRacer2D.cpp并复制以下函数。然后将其粘贴到SpaceRacer3D.cpp中:
-
BuildFont -
KillFont -
DrawText
我们将添加一些新变量来处理我们想要显示的数据。将以下代码行添加到SpaceRacer3D.cpp的全局变量部分:
int score;
int speed;
int missionTime;
int asteroidsHit;
int maximumSpeed;
这些变量将保存游戏使用的统计数据和得分:
-
score: 这是当前游戏得分 -
speed: 这是飞船的当前速度 -
missionTime: 这是自开始任务以来经过的秒数 -
asteroidsHit: 这是玩家击中的陨石数量 -
maximumSpeed: 这是玩家获得的最大速度
Score, speed, 和 missionTime 都将在玩家驾驶飞船时显示在抬头显示(HUD)上。Score, asteroidsHit, missionTime, 和 maximumSpeed 将在游戏结束时显示为统计数据。
让我们转到StartGame并初始化这些变量:
score = 0;
speed = 1.0f;
maximumSpeed = 0;
asteroidsHit = 0;
missionTime = 0;
现在,让我们创建在屏幕上渲染这些项目的函数。将以下两个函数添加到游戏中的某个位置,在Render2D函数之上:
void DrawUi()
{
float startY = screenHeight - 50.0f;
float x1 = 50.0f;
float x2 = screenWidth / 2.0f - 50.0f;
float x3 = screenWidth - 250.0f;
char scoreText[50];
char speedText[50];
char missionTimeText[50];
sprintf_s(scoreText, 50, "Score: %i", score);
sprintf_s(speedText, 50, "Speed: %i", speed);
sprintf_s(missionTimeText, 50, "Time: %f", missionTime / 100.0f);
DrawText(scoreText, x1, startY, 0.0f, 1.0f, 0.0f);
DrawText(speedText, x2, startY, 0.0f, 1.0f, 0.0f);
DrawText(missionTimeText, x3, startY, 0.0f, 1.0f, 0.0f);
}
void DrawStats()
{
float startX = screenWidth - screenWidth / 2.5f;
float startY = 275.0f;
float spaceY = 30.0f;
char asteroidsHitText[50];
char maximumSpeedText[50];
char scoreText[50];
char missionTimeText[50];
sprintf_s(asteroidsHitText, 50, "Asteroids Hit: %i", asteroidsHit);
sprintf_s(maximumSpeedText, 50, "Maximum Speed: %i", maximumSpeed);
sprintf_s(scoreText, 50, "Score: %i", score);
sprintf_s(missionTimeText, 50, "Time: %f", missionTime / 100.0f);
DrawText(asteroidsHitText, startX, startY, 0.0f, 1.0f, 0.0f);
DrawText(maximumSpeedText, startX, startY + spaceY, 0.0f, 1.0f, 0.0f);
DrawText(scoreText, startX, startY + spaceY * 2.0f, 0.0f, 1.0f, 0.0f);
DrawText(missionTimeText, startX, startY + spaceY * 3.0f, 0.0f, 1.0f, 0.0f);
}
void DrawCredits()
{
float startX = screenWidth - screenWidth / 2.5f;
float startY = 300.0f;
float spaceY = 30.0f;
DrawText("Robert Madsen", startX, startY, 0.0f, 1.0f, 0.0f);
DrawText("Author", startX, startY + spaceY, 0.0f, 1.0f, 0.0f);
}
这些函数与 RoboRacer2D 中相应的函数工作方式完全相同。首先,我们使用sprintf_s创建一个包含我们想要显示的文本的字符字符串。接下来,我们使用glRasterPos2f设置 2D 的渲染位置。然后,我们使用glCallLists实际渲染字体。在DrawCredits函数中,我们使用DrawText辅助函数来渲染文本。
将CheckCollisions修改为以下代码:
void CheckCollisions()
{
bool collision = false;
for (int i = 0; i < asteroids.size(); i++)
{
Model* item = asteroids[i];
collision = ship->CollidedWith(item);
if (collision)
{
item->IsCollideable(false);
score++;
asteroidsHit++;
}
}
}
此代码更新得分和陨石统计数据。
定义纹理
现在,是时候加载我们所有的纹理了。将以下函数添加到游戏中:
const bool LoadTextures()
{
menuScreen = new Sprite(1);
menuScreen->SetFrameSize(screenWidth, screenHeight);
menuScreen->SetNumberOfFrames(1);
menuScreen->AddTexture("resources/mainmenu.png", false);
menuScreen->IsActive(true);
menuScreen->IsVisible(true);
menuScreen->SetPosition(0.0f, 0.0f);
playButton = new Sprite(1);
playButton->SetFrameSize(75.0f, 38.0f);
playButton->SetNumberOfFrames(1);
playButton->SetPosition(690.0f, 300.0f);
playButton->AddTexture("resources/playButton.png");
playButton->IsVisible(true);
playButton->IsActive(false);
inputManager->AddUiElement(playButton);
creditsButton = new Sprite(1);
creditsButton->SetFrameSize(75.0f, 38.0f);
creditsButton->SetNumberOfFrames(1);
creditsButton->SetPosition(690.0f, 350.0f);
creditsButton->AddTexture("resources/creditsButton.png");
creditsButton->IsVisible(true);
creditsButton->IsActive(false);
inputManager->AddUiElement(creditsButton);
exitButton = new Sprite(1);
exitButton->SetFrameSize(75.0f, 38.0f);
exitButton->SetNumberOfFrames(1);
exitButton->SetPosition(690.0f, 500.0f);
exitButton->AddTexture("resources/exitButton.png");
exitButton->IsVisible(true);
exitButton->IsActive(false);
inputManager->AddUiElement(exitButton);
creditsScreen = new Sprite(1);
creditsScreen->SetFrameSize(screenWidth, screenHeight);
creditsScreen->SetNumberOfFrames(1);
creditsScreen->AddTexture("resources/credits.png", false);
creditsScreen->IsActive(true);
creditsScreen->IsVisible(true);
menuButton = new Sprite(1);
menuButton->SetFrameSize(75.0f, 38.0f);
menuButton->SetNumberOfFrames(1);
menuButton->SetPosition(690.0f, 400.0f);
menuButton->AddTexture("resources/menuButton.png");
menuButton->IsVisible(true);
menuButton->IsActive(false);
inputManager->AddUiElement(menuButton);
gameOverScreen = new Sprite(1);
gameOverScreen->SetFrameSize(screenWidth, screenHeight);
gameOverScreen->SetNumberOfFrames(1);
gameOverScreen->AddTexture("resources/gameover.png", false);
gameOverScreen->IsActive(true);
gameOverScreen->IsVisible(true);
replayButton = new Sprite(1);
replayButton->SetFrameSize(75.0f, 38.0f);
replayButton->SetNumberOfFrames(1);
replayButton->SetPosition(690.0f, 400.0f);
replayButton->AddTexture("resources/replayButton.png");
replayButton->IsVisible(true);
replayButton->IsActive(false);
inputManager->AddUiElement(replayButton);
return true;
}
这里没有什么新的!我们只是将所有的 2D 资产作为精灵加载到游戏中。以下是一些关于如何工作的提醒:
-
每个精灵都是从 PNG 文件加载的,指定了帧数。由于这些精灵都没有动画,它们都只有一个帧。
-
我们使用 2D 坐标定位每个精灵。
-
我们设置属性——可见意味着它可以被看到,而激活意味着它可以被点击。
-
如果对象打算是一个按钮,我们将其添加到 UI 系统中。
连接渲染、更新和游戏循环
现在我们终于加载了所有的 2D 资产,我们准备完成 Render2D 函数:
void Render2D()
{
Enable2D();
switch (gameState)
{
case GameState::GS_Loading:
{
splashScreen->Render();
}
break;
case GameState::GS_Menu:
{
menuScreen->Render();
playButton->Render();
creditsButton->Render();
exitButton->Render();
}
break;
case GameState::GS_Credits:
{
creditsScreen->Render();
menuButton->Render();
DrawCredits();
}
break;
case GameState::GS_Running:
{
DrawUi();
}
break;
case GameState::GS_Splash:
{
splashScreen->Render();
}
break;
case GameState::GS_GameOver:
{
gameOverScreen->Render();
DrawStats();
menuButton->Render();
}
break;
}
Disable2D();
}
再次强调,这里没有什么是你之前没有见过的。我们只是在实现完整的状态引擎。
现在我们有了可点击的按钮,我们可以实现完整的 ProcessInput 函数。将以下行添加到 switch 语句中:
case Input::Command::CM_UI:
{
if (playButton->IsClicked())
{
playButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
gameState = GameState::GS_Running;
}
if (creditsButton->IsClicked())
{
creditsButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
gameState = GameState::GS_Credits;
}
if (menuButton->IsClicked())
{
menuButton->IsClicked(false);
exitButton->IsActive(true);
playButton->IsActive(true);
menuButton->IsActive(false);
switch (gameState)
{
case GameState::GS_Credits:
{
gameState = GameState::GS_Menu;
}
break;
case GameState::GS_GameOver:
{
StartGame();
}
break;
}
}
if (exitButton->IsClicked())
{
playButton->IsClicked(false);
exitButton->IsActive(false);
playButton->IsActive(false);
creditsButton->IsActive(false);
PostQuitMessage(0);
}
}
break;
}
是的,我们之前已经见过这些了。如果你还记得,Input 类为每个可点击的按钮分配了一个命令枚举。这段代码只是简单地处理命令,如果有任何命令,并根据刚刚点击的按钮设置状态。
我们现在实现完整的 Update 函数来处理我们新的状态机:
void Update(const float p_deltaTime)
{
switch (gameState)
{
case GameState::GS_Splash:
case GameState::GS_Loading:
{
splashScreen->Update(p_deltaTime);
splashDisplayTimer += p_deltaTime;
if (splashDisplayTimer > splashDisplayThreshold)
{
gameState = GameState::GS_Menu;
}
}
break;
case GameState::GS_Menu:
{
menuScreen->Update(p_deltaTime);
playButton->IsActive(true);
creditsButton->IsActive(true);
exitButton->IsActive(true);
playButton->Update(p_deltaTime);
creditsButton->Update(p_deltaTime);
exitButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
case GameState::GS_Credits:
{
creditsScreen->Update(p_deltaTime);
menuButton->IsActive(true);
menuButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
case GameState::GS_Running:
{
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
ship->Update(p_deltaTime);
ship->SetVelocity(ship->GetVelocity() + ship->GetVelocity()*p_deltaTime/10.0f);
speed = ship->GetVelocity() * 1000;
if (maximumSpeed < speed)
{
maximumSpeed = speed;
}
missionTime = missionTime + p_deltaTime * 100.0f;
CheckCollisions();
if (ship->GetPosition().z > 10.0f)
{
gameState = GS_GameOver;
menuButton->IsActive(true);
gameOverScreen->IsActive(true);
}
}
break;
case GameState::GS_GameOver:
{
gameOverScreen->Update(p_deltaTime);
replayButton->IsActive(true);
replayButton->Update(p_deltaTime);
exitButton->IsActive(true);
exitButton->Update(p_deltaTime);
inputManager->Update(p_deltaTime);
ProcessInput(p_deltaTime);
}
break;
}
}
最后,我们需要修改游戏循环,使其支持我们所有的新特性。移动到 GameLoop 函数并修改它,使其看起来像以下代码:
void GameLoop(const float p_deltatTime)
{
if (gameState == GameState::GS_Splash)
{
BuildFont();
LoadTextures();
gameState = GameState::GS_Loading;
}
Update(p_deltatTime);
Render();
}
和往常一样,游戏循环调用 Update 和 Render 函数。我们添加了一个特殊案例来处理启动画面。如果我们处于 GS_Splash 游戏状态,那么我们就加载游戏的其他资源,并将游戏状态更改为 GS_Loading。
注意,之前提到的几个函数还没有创建!随着我们的继续,我们将添加对声音、字体和纹理的支持。
摘要
在本章中,我们讨论了大量的代码。本章的主要课程是学习如何同时渲染 2D 和 3D。然后我们添加了代码来加载所有 2D 资源作为精灵。我们还添加了渲染文本的能力,现在我们可以看到我们的得分、统计数据和信用。
我们为游戏实现了那个状态机,并将其连接到输入、更新、渲染和游戏循环系统中。这包括创建启动画面、加载资源、玩游戏和显示各种游戏屏幕的状态。
现在你已经拥有了一个完整的 3D 游戏。当然,你还可以用它做更多的事情。在下一章和最后一章中,我们将学习一些新技巧,其余的则由你决定!
第十二章. 征服宇宙
恭喜!你已经走到这一步了。如果你正在阅读这一章,那么你已经创建了两个游戏——一个 2D 游戏和一个 3D 游戏。当然,它们可能不会卖出去并让你赚一百万美元,但你已经完成了比 90%试图玩游戏的人更多的游戏。
还有更多东西要学习,我们不可能在一本书中涵盖所有内容。本章将简要介绍几个更多的话题,并希望至少为您提供足够的信息,以便在完成本书后进一步实验。实际上,我们将设置一个框架,让您可以玩游戏,所以我们将称之为游乐场。
我们将涵盖的主题包括以下内容:
-
游乐场:我们将首先设置一个模板,您可以在尝试不同功能时反复使用。这个模板也将是您想要创建的任何未来游戏的良好起点。
-
纹理映射:到目前为止,我们只处理了颜色,而不是纹理。仅使用颜色制作逼真的游戏会相当困难。事实证明,我们可以将纹理应用到我们的模型上,使它们更加逼真。我们将学习在简单的 3D 形状上进行纹理映射的基本知识。
-
光照:到目前为止,我们使用了 OpenGL 提供的默认光照。大多数时候,我们希望对光照有更多的控制。我们将讨论各种光照类型及其使用方法。
-
天空盒:游戏宇宙不可能永远继续下去。我们经常使用一种称为天空盒的设备来包围我们的游戏世界,使其看起来比实际更大。我们将学习如何将天空盒添加到我们的太空游戏中。
-
物理:在现实世界中,物体根据物理定律弹跳、下落并做其他事情。我们将讨论物体如何相互交互以及与宇宙其他部分的关系。
-
人工智能:许多游戏都有敌人或武器试图摧毁玩家。这些敌人通常由某种形式的人工智能(AI)控制。我们将讨论一些简单的 AI 形式,并学习游戏如何控制游戏中的对象。
-
下一步是什么:最后,我将给你一些建议,告诉你如何在完成这本书后继续提高你的技能。我们将讨论游戏引擎和额外的学习主题。
一个有趣的框架
现在,是时候创建我们的游乐场了。在我们开始编码之前,让我们决定我们想要设置的基本功能:
-
Visual Studio 项目
-
Windows 环境
-
OpenGL 环境
-
游戏循环
到此为止,我们就要做这些了。我们的想法是设置一个基本的模板,您可以用它来开始任何游戏或实验项目。我们不希望在这个基本框架中包含太多内容,所以现在我们将省略声音、输入、精灵和模型加载。这些可以在需要时添加。
设置 Visual Studio 项目
开始一个新的空白项目,并将其命名为FunWith3D。确保在项目的属性、配置属性、链接器、输入、附加依赖项属性中添加正确的库:
glu32.lib;opengl32.lib;SOIL.lib;
我们将包含 SOIL 库,因为它在加载图像方面非常有用。你需要从我们的SpaceRacer3D.cpp项目文件夹中复制以下文件:
-
glut.h -
glut32.lib -
glut32.dll -
SOIL.h -
SOIL.lib
将以下库添加到属性、配置属性、输入和附加依赖项中:
-
glut32.lib -
SOIL.lib
设置 Windows 环境
创建一个新的 C++文件,并将其命名为FunWith3D.cpp。然后添加以下代码:
#include <windows.h>
#include <stdio.h>
#include "glut.h"
#include "SOIL.h"
const int screenWidth = 1024;
const int screenHeight = 768;
// Global Variables:
HINSTANCE hInstance = NULL;
HDC hDC = NULL;
HGLRC hRC = NULL;
HWND hWnd = NULL;
bool fullscreen = false;
// Forward declarations of functions included in this code module:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
现在,从上一个项目中打开SpaceRacer3D.cpp并复制以下函数:
-
WinMain -
WndProc
这些是 Windows 执行其操作所需的头文件和两个函数。所有这些代码在前面的章节中都有解释,所以在这里我不会再重复解释。实际上,你可以节省一些打字时间,直接从我们之前的项目中复制这段代码。
设置 OpenGL 环境
现在,是时候设置 OpenGL 了。从 SpaceRacer3D 中复制以下函数,并在WndProc声明之后添加它们:
-
ReSizeGLScene -
InitGL -
KillGLWindow -
CreateGLWindow
设置游戏循环
现在,我们添加定义我们的游戏循环的函数。在刚刚添加的 OpenGL 代码之后添加这些函数:
void StartGame()
{
}
void Update(const float p_deltaTime)
{
}
void Enable2D()
{
glColor3f(1.0f, 1.0f, 1.0f);
glEnable(GL_TEXTURE_2D);
glMatrixMode(GL_PROJECTION);
glPushMatrix();
glLoadIdentity();
glOrtho(0, screenWidth, screenHeight, 0, 0, 1);
glMatrixMode(GL_MODELVIEW);
glPushMatrix();
glLoadIdentity();
glPushAttrib(GL_DEPTH_BUFFER_BIT);
glDisable(GL_DEPTH_TEST);
}
void Disable2D()
{
glPopAttrib();
glMatrixMode(GL_PROJECTION);
glPopMatrix();
glMatrixMode(GL_MODELVIEW);
glPopMatrix();
glDisable(GL_TEXTURE_2D);
}
void Render2D()
{
Enable2D();
//Add your 2D rendering here
Disable2D();
}
void Render3D()
{
//Add your 3D rendering here
}
void Render()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
Render3D();
Render2D();
SwapBuffers(hDC);
}
void EndGame()
{
}
void GameLoop(const float p_deltatTime)
{
Update(p_deltatTime);
Render();
}
为了与其他我们编写的代码保持一致,你需要在项目的属性、配置属性、C/C++、预处理器和预处理器定义属性中添加以下预编译指令:
-
_USE_MATH_DEFINES -
_CRT_SECURE_NO_WARNINGS
恭喜!你现在有一个可以用于任何未来项目和实验的框架。你刚刚也成功回顾了我们整本书中一直在使用的 OpenGL 和游戏代码。
你会注意到我还保留了代码,这样你就可以在 3D 或 2D 中渲染!总的来说,你现在有一个小而有效的游戏引擎起点。我建议你保存包含此解决方案和项目的文件夹的副本。然后,当你准备好开始一个新项目时,你可以简单地复制解决方案文件夹,给它另一个名字,然后就可以开始了。我们将以此作为本章中编写任何代码的基础。
小贴士
为了节省空间并使我们的小型游乐场保持简单,我没有包含一些关键特性,例如输入、精灵、模型和声音。如果你觉得这些中的任何一个是你的游乐场中必不可少的,那么这将是你第一次练习。一般来说,你只需将相关文件和/或代码从 SpaceRacer3D 的最后一个版本复制到你的项目文件夹中。
纹理映射
到目前为止,我们所有的形状和模型都使用了颜色,但当我们开始将纹理应用于模型时,一个全新的世界等待着我们。将 2D 纹理添加到 3D 模型称为 纹理映射,在某些情况下也称为 纹理包裹。让我们看看添加一些纹理到我们的 3D 模型需要什么。我们将从一个简单的立方体开始。
首先,使用你喜欢的图像编辑软件创建一个 256 x 256 像素的正方形,并给它添加某种纹理。我将使用以下这个:

将此纹理保存为位图(BMP)。我们将使用位图而不是 PNG,因为位图的内部数据结构恰好与 OpenGL 期望的数据结构相匹配。换句话说,这更容易!
我总是为我的图片创建一个名为 resources 的文件夹。将它们作为资源包含在 Visual Studio 项目中也是一个好主意(在 Solution Explorer 面板中右键点击 Resources 文件夹,然后选择 Add Existing…,然后导航到图片)。
加载纹理
如果你还记得,我们为之前的项目创建了一个精灵类。我们使用 Sprite 类的 AddTexture 方法来调用 SOIL 库以加载图片。我们不会使用 Sprite 类来处理这些纹理。Sprite 类有很多方法和属性,这些并不适用于纹理化 3D 模型,因此我们将为这个用途编写自己的纹理加载器。在渲染函数上方某处添加以下代码:
void LoadTexture(const char* filepath, GLsizei height, GLsizei width, unsigned int colordepth, GLuint &texture)
{
unsigned char* data;
FILE* file;
file = fopen(filepath, "r");
data = (unsigned char*)malloc(width * height * colordepth);
fread(data, width * height * colordepth, 1, file);
fclose(file);
texture = SOIL_load_OGL_texture(filepath, SOIL_LOAD_AUTO, SOIL_CREATE_NEW_ID, 0);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, colordepth == 3 ? GL_RGB:GL_RGBA, width, height, 0, colordepth == 3 ? GL_RGB:GL_RGBA, GL_UNSIGNED_BYTE, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
free(data);
}
LoadTexture 的目的是将纹理加载到内存中,并将其设置为 3D 对象的纹理映射。为了完成这个任务,我们实际上需要将纹理加载两次。首先,我们直接打开文件,将其作为二进制文件读入名为 data 的缓冲区。我们使用 char 数据类型,因为我们想将二进制数据存储为无符号整数,而 char 在此方面做得非常好。因此,我们的前几行代码:
-
定义数据数组
-
创建文件句柄
-
为数据分配内存
-
将文件读入数据缓冲区
-
关闭文件(但不是缓冲区)
现在,再次读取图片,但这次我们使用 SOIL 库将其作为 OpenGL 纹理读取,并使用 SOIL 加载纹理并将其分配给由 texture 引用的 OpenGL。
然后,我们对其执行一些复杂的 OpenGL 操作,将其设置为模型纹理:
-
GL_BindTexture简单地告诉 OpenGL 我们想要这个纹理成为当前纹理,我们将应用随后的设置。 -
glTexImage2D告诉 OpenGL 如何解释我们读取的数据。我们告诉 OpenGL 将数据视为 RGB 或 RGBA 类型的 2D 纹理(由colordepth参数控制),并且数据以无符号整数存储(因此,使用char数据类型)。 -
下两个函数,都是对
glTexParameteri的调用,告诉 OpenGL 如何处理纹理,当它靠近或远离相机时。它们都设置为使用线性过滤来处理这种细节级别。 -
最后,由于不再需要,我们关闭数据缓冲区。
我们已经设置了LoadTexture函数,以便根据您的需求调用它。在我们的例子中,我们首先将设置一个指向此纹理的句柄。在代码顶部,将此行添加到全局变量部分:
GLuint texMarble;
接下来,我们将加载纹理的调用放置在StartGame函数中:
LoadTexture("resources/marble.bmp", 256, 256, 4, texMarble);
此调用告诉程序:
-
文件的位置
-
图像的宽度和高度
-
图像的色彩深度(在本例中
4= RGBA) -
OpenGL 纹理句柄
渲染立方体
现在我们已经设置了一个纹理,但我们需要一个模型来纹理化。为了使事情简单,我们将使用四边形来创建一个立方体,并将大理石纹理应用到立方体的每个面上。
在我们开始之前,我们需要添加三个变量来跟踪旋转。将这些行添加到全局变量部分:
float xrot = 1.0f;
float yrot = 1.0f;
float zrot = 1.0f;
现在,在LoadTexture函数下方创建以下函数:
int DrawTexturedCube(GLvoid)
{
glEnable(GL_TEXTURE_2D);
glLoadIdentity();
glTranslatef(0.0f, 0.0f, -5.0f);
glRotatef(xrot, 1.0f, 0.0f, 0.0f);
glRotatef(yrot, 0.0f, 1.0f, 0.0f);
glRotatef(zrot, 0.0f, 0.0f, 1.0f);
glBindTexture(GL_TEXTURE_2D, texMarble);
glBegin(GL_QUADS);
// Font Face
glTexCoord2f(0.0f, 0.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glTexCoord2f(1.0f, 0.0f); glVertex3f(1.0f, -1.0f, 1.0f);
glTexCoord2f(1.0f, 1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glTexCoord2f(0.0f, 1.0f); glVertex3f(-1.0f, 1.0f, 1.0f);
// Back Face
glTexCoord2f(1.0f, 0.0f); glVertex3f(-1.0f, -1.0f, -1.0f);
glTexCoord2f(1.0f, 1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glTexCoord2f(0.0f, 1.0f); glVertex3f(1.0f, 1.0f, -1.0f);
glTexCoord2f(0.0f, 0.0f); glVertex3f(1.0f, -1.0f, -1.0f);
// Top Face
glTexCoord2f(0.0f, 1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glTexCoord2f(0.0f, 0.0f); glVertex3f(-1.0f, 1.0f, 1.0f);
glTexCoord2f(1.0f, 0.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glTexCoord2f(1.0f, 1.0f); glVertex3f(1.0f, 1.0f, -1.0f);
// Bottom Face
glTexCoord2f(1.0f, 1.0f); glVertex3f(-1.0f, -1.0f, -1.0f);
glTexCoord2f(0.0f, 1.0f); glVertex3f(1.0f, -1.0f, -1.0f);
glTexCoord2f(0.0f, 0.0f); glVertex3f(1.0f, -1.0f, 1.0f);
glTexCoord2f(1.0f, 0.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
// Right face
glTexCoord2f(1.0f, 0.0f); glVertex3f(1.0f, -1.0f, -1.0f);
glTexCoord2f(1.0f, 1.0f); glVertex3f(1.0f, 1.0f, -1.0f);
glTexCoord2f(0.0f, 1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glTexCoord2f(0.0f, 0.0f); glVertex3f(1.0f, -1.0f, 1.0f);
// Left Face
glTexCoord2f(0.0f, 0.0f); glVertex3f(-1.0f, -1.0f, -1.0f);
glTexCoord2f(1.0f, 0.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glTexCoord2f(1.0f, 1.0f); glVertex3f(-1.0f, 1.0f, 1.0f);
glTexCoord2f(0.0f, 1.0f); glVertex3f(-1.0f, 1.0f, -1.0f);
glEnd();
xrot += 0.01f;
yrot += 0.02f;
zrot += 0.03f;
return TRUE;
}
这段代码与我们在前一章中用来绘制立方体的代码非常相似。然而,当我们绘制那个立方体时,我们对每个顶点应用了颜色。现在,我们将纹理应用到每个面上。首先,我们设置一些事情:
-
我们首先使用
glEnable(GL_TEXTURE_2D)来启用 2D 纹理。在我们的初始设置中,我们禁用了 2D 纹理。如果我们在这里不启用它们,那么我们的纹理将不会显示出来! -
接下来,我们使用
glLoadIdentity()来初始化当前矩阵。 -
我们调用
glTranslatef(0.0f, 0.0f, -5.0f)将相机向后移动(这样我们就会在立方体外面)。 -
三次调用
glRotate3f将为我们旋转立方体。 -
然后,我们使用
glBindTexture(GL_TEXTURE_2D, texMarble)来通知 OpenGL,在接下来的绘制操作中,我们将使用由texMarble引用的纹理。
完成此设置后,我们就可以开始绘图了:
-
我们从
glBegin(GL_QUADS)开始,告诉 OpenGL 我们将绘制四边形。 -
现在,每个调用都成对出现。首先是一个
glTexCoord2f的调用,然后是一个glVertex3f的调用。glTexCoord2f的调用告诉 OpenGL 将纹理的哪一部分放置在由glVertex3f指定的位置。这样,我们可以将纹理中的任何点映射到四边形中的任何点。OpenGL 负责确定纹理的哪些部分位于顶点之间。 -
当我们完成立方体的绘制后,我们发出
glEnd()命令。 -
最后三行更新旋转变量。
-
最后,我们必须在
Render3D函数中调用DrawTexturedCube:DrawTexturedCube(); -
运行程序,看看立方体在纹理的华丽中!!渲染立方体
映射操作
我需要向您解释更多关于纹理映射的工作原理。看看DrawTexturedCube中的这四行代码:
glTexCoord2f(0.0f, 0.0f); glVertex3f(-1.0f, -1.0f, 1.0f);
glTexCoord2f(1.0f, 0.0f); glVertex3f(1.0f, -1.0f, 1.0f);
glTexCoord2f(1.0f, 1.0f); glVertex3f(1.0f, 1.0f, 1.0f);
glTexCoord2f(0.0f, 1.0f); glVertex3f(-1.0f, 1.0f, 1.0f);
这四行定义了一个四边形。每个顶点由一个纹理坐标(glTexCoord2f)和一个顶点坐标(glVertex3f)组成。当 OpenGL 查看纹理时,它看到的是:

不论纹理在像素中有多大,在纹理坐标中,纹理正好是一单位宽和一单位高。所以,前面代码的第一行告诉 OpenGL 将纹理的点(0,0)(左上角)映射到定义的下一个顶点(在这个例子中是四边形的左上角)。你会注意到第三行将纹理坐标(1,1)映射到四边形的右下角。实际上,我们正在将纹理拉伸到四边形的面上!然而,OpenGL 也会调整映射,使得纹理看起来不会模糊,所以这并不是真正发生的事情。相反,你将看到一些平铺效果。
让有光吧!
到目前为止,我们还没有担心过光照。实际上,我们只是假设光线会存在,这样我们才能看到我们的图像。OpenGL 有一个光照设置,它会均匀地照亮一切。这个设置默认是开启的,直到我们告诉 OpenGL 我们想要处理光照。
想象一下如果没有光照,我们的场景会是什么样子。实际上,这有一天会发生。你将一切设置好并准备就绪,你将运行程序,然后你会得到一个又大又黑的什么都没有!怎么了?你忘记打开灯了!就像下面的图片所示:

就像现实生活一样,如果你没有光源,你将什么也看不到。OpenGL 有许多类型的光源。一种常见的光源是环境光。环境光似乎同时从所有方向照射过来,就像阳光充满房间一样。

光照在 3D 游戏中非常重要,大多数游戏都有多个光源来增加游戏的真实感。
定义光源
让我们接管并定义我们自己的光源。将以下代码行添加到 DrawTexturedCube 函数的顶部:
glEnable(GL_LIGHTING);
GLfloat ambientLight[] = { 0.0f, 0.0f, 1.0f, 1.0f };
glLightModelfv(GL_LIGHT_MODEL_AMBIENT, ambientLight);
glEnable(GL_COLOR_MATERIAL);
glColorMaterial(GL_FRONT, GL_AMBIENT);
运行程序,然后回来查看发生了什么:
-
glEnable(GL_LIGHTING)告诉 OpenGL 我们现在想要控制光照。记住:一旦启用光照,就由你来决定了。实际上,如果你启用了光照但没有定义任何光源,那么你将得到一个完全黑色的场景。 -
接下来,我们为我们的光源定义一个颜色。在这种情况下,我们正在创建一束蓝色光。
-
现在我们通过
glLightModelfv告诉 OpenGL 我们想要使用哪种类型的光照。在这种情况下,我们正在开启一种蓝色,环境光。 -
光需要有材料来反射。因此,我们使用
glEnable(GL_COLOR_MATERIAL)来告诉 OpenGL 使用一个会反射颜色的材料。 -
调用
glColorMaterial(GL_FRONT, GL_AMBIENT)告诉 OpenGL,这个材质的前面应该像环境光一样反射光线。记住,环境光来自所有方向。
当然,你已经看到了结果。我们的立方体是蓝色的!尝试不同的颜色。我们只有时间对光照进行浅尝辄止。你还将想要了解漫反射光照。漫反射光随着距离的增大而减弱。使用漫反射光时,你不仅设置颜色,还要在某个位置放置光源。
天空盒
虽然空间可能是无限的,但你的计算机不是,所以必须有一个边界。这个边界被称为天空盒。
想象我们的宇宙飞船正在太空中飞行!空间很大。虽然我们可以在我们的宇宙中放置一些行星和小行星,让宇宙飞船有东西可以与之交互,但我们当然不会为每一颗星星建模。这就是我们的宇宙看起来像的:

这看起来相当空旷,对吧?你可能在我们的游戏 SpaceRacer3D 中已经注意到了这一点。当然,我们可以添加一些自己的物体——更多的小行星,添加一大堆星星——在一个真正的游戏中,我们会这样做。但是,在开始出现性能问题时,你可以添加到游戏中的物体数量总是有限的。
对于真正遥远的物体,例如遥远的星星,我们通过使用二维纹理来模拟。例如,我们的游戏可以使用星纹理来模拟太空中的星星和星云,如下面的图片所示:

现在,由于一个立方体有六个面,我们真正想要的是六个纹理。一个典型天空盒看起来类似于以下图片:

并不需要太多的想象力就能看到这个纹理如何被包裹在立方体上并覆盖所有尺寸的侧面。这创造了一个覆盖天空盒封装的所有空间的图像,给人一种被星星和星云包围的错觉,如下面的图片所示:

下面的插图展示了从另一个角度应用到天空盒上的纹理:

包含飞船和小行星的立方体代表游戏世界。飞船和小行星是这个世界中的真实物体。左边的图像是一个包含星星的纹理。
现在,想象星纹理被包裹在立方体上,这就是你的整个宇宙,由星星、飞船和小行星组成。包裹在立方体上的星纹理就是天空盒。
高级主题
不幸的是,对于最后两个主题,我们只有时间给予它们一个荣誉的提及。我包括它们是因为你将要听到这些主题,你需要知道这些术语的含义。
游戏物理
游戏物理是定义游戏宇宙内物体如何相互作用的规则。例如,在 SpaceRacer3D 中,飞船只是简单地穿过小行星。然而,可能会有许多其他结果:
-
飞船和小行星可能会相互弹跳(反弹)
-
飞船可能会被小行星吸进去,随着飞船靠近,力量会逐渐增加(重力)
-
当飞船靠近小行星时,小行星可能会对飞船产生推力(反向重力)
这些效果都会被编程到游戏中。每个效果也会创造不同类型的游戏体验。一个被称为基于物理的游戏的整个游戏类型,仅仅定义了一个游戏宇宙中的物理定律,然后让事物相互作用,看看会发生什么。
人工智能
人工智能,或人工智能,是一套规则,定义了由计算机控制的字符或物体如何行为。人工智能通常应用于敌人和其他非玩家角色(NPC),以在游戏中赋予它们逼真的外观。人工智能的一些例子包括:
-
一个自动检测到敌人靠近并爆炸的雷区
-
一种制导导弹,无论飞船如何导航,都会锁定并靠近太空船
-
一个敌人角色检测到玩家靠近并躲在岩石后面
人工智能通常被认为是游戏编程中最困难的领域之一。一些算法相当简单(例如,制导导弹只需要知道飞船的位置来追踪它),而其他算法则非常复杂(例如,躲在岩石后面)。有些游戏甚至为你提供了一个 AI 对手来对抗。
未来
你确实已经走了很长的路。如果你在阅读这些文字,尤其是如果你在过程中编写了所有代码,那么你已经取得了巨大的成就,但还有许多东西要学习。我鼓励你找到其他书籍,永远不要停止学习。阻止你成为伟大游戏程序员的唯一因素是你自己!
摘要
如同往常,我们在本章中涵盖了大量的主题。你学习了如何将纹理映射到物体上,然后你学习了如何打开灯光。你学习了天空盒如何被用来使你的世界看起来比实际更大。你还仅仅尝到了物理和人工智能的滋味,这些主题可以轻易地填满整本书。不要停止,直到你让这本书中的每一行代码为你所用,然后开始改变代码,让它变得不同和令人惊叹。
祝你好运!















浙公网安备 33010602011771号