精通 C++ 游戏开发(一)
原文:
annas-archive.org/md5/C9DEE6A3AC368562ED493911597C48C0
译者:飞龙
前言
尽管现在许多语言被用于开发游戏,但 C++仍然是专业开发的标准。绝大多数库、引擎和工具链仍然严格使用 C++开发。以其性能和可靠性而闻名,C++仍然是真正跨平台兼容性的最佳选择。
通过阅读本书,您正在开始掌握这种强大语言的旅程。尽管这个旅程会很漫长,但它将充满发现!即使在我花费了无数小时与 C++一起工作之后,我仍然发现自己在发现新技术和方法时充满了喜悦。在本书中,我希望给您提供工具和理解,为您继续学习之旅做好准备。尽管新的、时髦的工具和引擎可能会出现并有可能消失,但对游戏、它们的工具和引擎在低级别上是如何开发的有着深刻的理解,将为您提供宝贵的知识。
本书适用对象
本书适用于中级到高级的 C++游戏开发人员,他们希望将自己的技能提升到更高水平,并学习 3D 游戏开发的深层概念。读者将学习 AAA 级游戏开发中使用的关键概念。全书将涵盖高级主题,如库创建、人工智能、着色器技术、高级效果和照明、工具创建、物理、网络以及其他关键游戏系统。
本书涵盖内容
第一章《C++游戏开发》涵盖了现代游戏开发中使用的一些更高级的 C++主题。我们将研究继承和多态性、指针、引用以及常见的 STL 通用容器。模板化的概念以及使用类、函数和变量模板构建通用代码。类型推断和新语言关键字 auto 和 decltype 以及它们与新的返回值语法的组合使用。最后,我们将通过研究当今使用的一些核心游戏模式来结束本章。
第二章《理解库》将教授可共享库的高级主题。我们将研究不同类型的可共享库,并介绍创建自己可共享库的各种方法。
第三章《打下坚实基础》将研究使用面向对象编程和多态性创建可重用结构的不同方法。我们将通过真实代码示例,讨论辅助、管理和接口类之间的区别。
第四章《构建资产管道》将涵盖开发中非常重要的部分,即处理资产的过程。我们将研究导入、处理和管理声音、图像和 3D 对象等内容的过程。有了这个基础系统,我们可以继续完善游戏开发所需的其他系统。
第五章《构建游戏系统》将涵盖大量内容,并在开发专业级项目所需的核心游戏系统方面取得重大进展。到本章结束时,我们将拥有自己的自定义游戏状态系统,可以被游戏引擎中的许多其他组件采用。我们将在构建对摄像机的理解的同时,开发自己的自定义摄像机系统,最后,我们将看看如何通过将 Bullet 物理引擎添加到我们的示例引擎中,将完整的第三方游戏系统添加到我们的项目中。
第六章,创建图形用户界面,将讨论创建 GUI 所需的不同方面。我们将深入探讨其实现,深入了解工作 GUI 背后的核心架构。我们将开发一个包含控制定位的面板和元素架构。我们将使用观察者设计模式实现用户输入结构,并通过编码渲染管道来完成在屏幕上显示 GUI 元素所需的内容。
第七章,高级渲染,将介绍与着色器一起工作的基础知识。我们将学习如何构建编译器和链接抽象层,以节省时间。我们将了解光照技术理论以及如何在着色器语言中实现它们。最后,我们将通过查看着色器的其他用途,如创建粒子效果,来结束本章。
第八章,高级游戏系统,将深入探讨如何在游戏项目中包含 Lua 等脚本语言。然后,我们将在此基础上探讨如何将对话和任务系统实现到我们的示例引擎中。
第九章,人工智能,将在短时间内涵盖广泛的研究领域。我们将发展游戏人工智能的基本定义,以及它实际上是什么,以及它不是什么。我们还将探讨如何通过包括人工智能技术来扩展决策功能。我们将介绍如何通过使用转向力和行为来控制人工智能代理的移动。最后,我们将通过查看路径规划算法的使用来为我们的人工智能代理创建从一个点到另一个点的路径来结束本章。
第十章,多人游戏,将大步迈向理解如何在低级别实现多人游戏。您将了解 TCP/IP 协议栈以及游戏开发中使用的不同网络拓扑。我们将研究使用 UDP 和 TCP 协议来在客户端-服务器设置中传递数据。最后,我们将看一些开发人员在开始实现多人游戏功能时面临的问题。
第十一章,虚拟现实,将是对虚拟现实开发世界的快速介绍;它应该为您的体验想法提供一个很好的测试基础。您将学习如何处理多个视图锥和各种硬件选项,最后看看我们如何使用 OpenVR SDK 为我们的示例引擎添加虚拟现实支持。
为了充分利用本书
本书将假定您具有一些 C++ 的先前知识。对游戏开发有基本的了解。总的来说,这将有助于您在整本书中更好地理解,但不应被视为先决条件。
为了充分利用示例和开发体验,建议您拥有一台至少具备以下配置的较新开发设备:
-
CPU:4 核
-
内存:8 GB RAM
-
磁盘空间:40 GB
这些示例(有少数例外)都经过设计,可以在 macOS 和 Windows PC 设备上运行。
为了跟随操作,您应该安装以下软件:
-
PC:Visual Studio 2015 Community 或更高版本
-
macOS:XCode 8.x 或更高版本。
其他所需的软件将根据需要进行描述。
下载示例代码文件
您可以从 www.packtpub.com 的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,文件将直接发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
登录或注册 www.packtpub.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Mastering-Cpp-Game-Development
。我们还有其他代码包,可以从我们丰富的图书和视频目录中获取,网址为 github.com/PacktPublishing/
。去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以从 www.packtpub.com/sites/default/files/downloads/MasteringCppGameDevelopment_ColorImages.pdf
下载。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个示例:"唯一的问题是它将包括所有的 ConsoleHelper
库。"
代码块设置如下:
int m_numberOfPlayers;
void RunScripts(){}
class GameObject {};
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
int m_numberOfPlayers;
void RunScripts(){}
class GameObject {};
任何命令行输入或输出都将以以下形式书写:
cl /c hello.cpp
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。以下是一个示例:"当出现时,选择 Developer Command Prompt for VS2105。"
警告或重要说明会出现在这样的形式中。
技巧和窍门会以这样的形式出现。
C++游戏开发
从我小时候起,我就被告知,无论是追求体育运动的完美,学习乐器,甚至是新的技术技能,对基本原理的深刻理解和实践是决定成败的关键。用 C++进行游戏开发也是如此。在你掌握这个过程之前,你必须完善基础知识。这就是本书第一章的内容,涵盖了将在整本书中使用的基础概念。本章分为以下几个部分:
-
高级 C++概念概述
-
使用类型和容器
-
游戏编程模式
本书中使用的约定
在整本书中,你将遇到代码片段和示例。为了保持代码的可读性和统一性,我将遵循一些基本的编码约定。虽然编码标准的话题是一个复杂而冗长的讨论,但我认为为任何高级项目制定一些指导方针是很重要的。至少应该考虑在任何工作开始之前,制定一个可访问的指南,说明预期的符号和命名约定。如果你想了解更多关于 C++中常用的编码标准,一个很好的起点是 ISO C++网站上关于编码标准常见问题解答部分的链接isocpp.org/wiki/faq/coding-standards
。在那里,你将找到各种情况下常用的标准以及一堆建议阅读的链接,以进一步扩展你的知识。
本书中使用的标准和约定基于一些核心 C++指南、行业最佳实践和我的个人经验。我们将在整本书中使用最新的 ISO C++标准,即 C++14。然而,有时我们可能会使用最新提议的修订版 C++17 的一些功能,也被称为 C++1y。当发生这种情况时,将会做出说明。
类和函数名称将遵循MixedCase风格,而变量将遵循camelCase风格。一些示例看起来会像下面这样:
int m_numberOfPlayers;
void RunScripts(){}
class GameObject {};
本书中使用的另一个重要约定是你应该了解的作用域前缀的使用。作用域前缀是提高其他开发人员和你自己在不可避免地忘记变量所属作用域时的可读性的一种快速方法。以下是使用的前缀列表:
-
m_
:这用于类成员变量。这些是private
,通过使用前缀,告诉任何使用变量的人,它在类中是明显可用的,或者通过外部的 getter 或 setter,例如m_numberOfPlayers
。 -
s_
:这用于静态类成员。这告诉任何使用这个变量的人,在类的所有实例中只存在一个副本,并且它是静态的,例如s_objDesc
。 -
g_
:这用于全局变量。这告诉任何使用这个变量的人,它在任何地方都是可用的。我们在书中不会看到很多这样的变量,例如g_playerInfo
。
高级 C++概念概述
在我们开始构建工具、库和其他游戏组件之前,最好先复习一下在整本书中会经常出现的一些更常见的概念。在本节中,我们将快速浏览一些高级主题。这并不意味着要列出一个完整的清单,目标也不是对每个主题进行全面的概述,而是在游戏开发时对概念进行回顾和解释。
我们将看一些简单的例子,并强调在处理这些概念时可能出现的一些问题。一些经验丰富的 C++ 开发人员可能能够跳过这一部分,但由于这些主题将在本书的其余部分中发挥重要作用,因此重要的是对它们每一个都有牢固的理解。如果您正在寻找更广泛的回顾或更深入的解释,请查看本章末尾总结部分中的一些建议阅读。
使用命名空间
与智能指针等相比,命名空间可能看起来不是一个非常高级的主题,但随着您在 C++ 游戏开发中的进展,命名空间将成为开发工具包的重要组成部分。简单回顾一下,命名空间是一个声明,为其封装内部的所有变量、类型和函数提供范围。这很重要,因为它为我们提供了一种将代码组织成逻辑组的方式。通过将代码分成这些组,我们不仅使其更易于阅读,还可以防止所谓的名称冲突。当您开始使用多个库时,名称冲突就会成为一个大问题。使用命名空间通过其作用域来防止这种情况。例如,假设我们为某个平台的专用字符串类实现了一个实现。为了防止这个专用版本干扰并与标准库实现发生冲突,我们可以像这样将我们的类型包装在一个命名空间中:
namespace ConsoleHelper
{
class string
{
friend bool operator == (const string &string1,
const string &string2);
friend bool operator < (const string &string1,
const string &string2);
//other operators ...
public:
string ();
string(const char* input);
~string() ;
//more functions ...
}
}
然后我们可以这样调用我们特定的字符串实现:
ConsoleHelper::string name = new ConsoleHelper::string("Player Name");
当然,如果我们不想一遍又一遍地输入ConsoleHelper
部分,我们可以添加一个using
语句,告诉编译器使用特定的命名空间来查找我们正在使用的函数、类型和变量。您可以使用以下代码行为我们的命名空间做到这一点:
using namespace ConsoleHelper;
唯一的问题是它将包括所有ConsoleHelper
库。如果我们只想包括命名空间的特定成员,我们可以使用以下语法:
using namespace ConsoleHelper::string;
这将只包括字符串成员,而不是整个命名空间。
继承和多态
继承和多态是可以轻松填满自己的章节的主题。它们是 C++ 非常复杂和强大的组成部分。我在这一部分的目标不是覆盖继承和多态的所有细节。相反,我想快速看一下这些概念如何帮助您构建代码结构。我们将涵盖重点,但我假设您对面向对象开发概念有基本的理解,并熟悉访问修饰符和友元等主题。
首先,我们将专注于继承。继承的概念是现代面向对象设计和开发的重要部分。虽然继承的能力可以节省击键,但当允许程序员开发派生类的复杂层次结构时,继承真正显示其力量。让我们通过一个简单的例子来看一下继承的使用。在这个例子中,我们创建了一个简单的Enemy
类。这个类将处理实体的健康、武器、要造成的伤害、AI 脚本等等:
class Enemy
{
public:
void RunAIScripts();
void Update(double deltaTime);
private:
int m_health;
int m_damage;
};
当我们开始向游戏中添加更多的敌人时,我们可能会开始添加一些不同的条件语句,以允许敌人有更多的变化。添加越来越多的if
语句,甚至在这里和那里插入一些 switch 语句。这很快就变成了一团纠缠、难以阅读的代码混乱。如果我们决定添加一个略有不同的敌人-一个有自己可能的条件语句的敌人,比如一个 boss 敌人类型。这个新的 boss 敌人类型与原始的Enemy
类有相似的结构,并且共享许多相同的类型和函数。我们可以将重叠的代码复制到我们的新Boss
类中。这样可以运行,但这并不是理想的解决方案。我们会有很多代码重复,而这种不必要的重复会增加出错的机会。然后,如果你不得不修复一个 bug,现在你必须在多个地方进行修复。这是一个不必要的维护头痛。相反,我们可以使用继承。如果我们的新 boss 敌人类型继承自原始敌人类型,这意味着我们可以使用原始类提供给我们的类型和函数。继承的更强大之处在于,我们不仅可以采用继承类的函数,还可以用我们自己的实现来覆盖它们。新的Boss
类可以这样写:
class Boss : public Enemy
{
public:
void Update(double deltaTime);
//more functions...
};
这种结构通常被称为父和子层次结构,其中Boss
类是Enemy
类的子类。这意味着Boss
现在将拥有从Enemy
类中继承的所有必需的结构。我应该指出,我们只继承了被声明为public
的函数和变量。这是因为在使用继承时,类的public
方法和变量对所有使用该类的人都是可见的。protected
方法和变量只对类本身和任何派生类可用。private
方法和变量只对该类可用,其他人无法访问,即使是派生类。
我们已经覆盖了Update()
函数的实现,为新的Boss
类提供了一个特殊版本。现在,在我们的代码中,我们可以写出以下内容:
//Somewhere in game or level manager
void UpdateObjects (double deltaTime)
{
enemy.Update(deltaTime);
boss.Update(deltaTime);
}
当这段代码运行时,它将调用对象的Update()
函数的各个独立实现。另一方面,考虑到我们有以下代码:
//Somewhere in game or level manager
void UpdateAI ()
{
enemy.RunAIScripts();
boss.RunAIScripts ();
}
在这里,我们没有覆盖RunAIScripts()
函数,因为它不继承原始类的函数实现。虽然这是一个非常基本的例子,但它确实展示了单一继承的能力,这让我想到了我的下一个主题-多重继承。
假设我们继续前面的例子,我们决定要添加一个新的敌人类型,一个可以飞行的 boss。我们有一个Boss
类,一个Enemy
类,甚至一个从Enemy
类继承的FlyingEnemy
类,看起来像这样:
class FlyingEnemy : public Enemy
{
public:
void Update(double deltaTime);
void FlightAI();
//many more functions...
}
问题是我们想要FlyingEnemy
的功能,但我们也想要Boss
的一些功能。同样,我们可以将我们想要的代码块复制到一个新的类中,但 C++为我们提供了一个更好的解决方案,多重继承。顾名思义,多重继承允许我们从多个来源派生我们的类。然后我们可以构建具有两个或更多父类的类,导致复杂的层次结构,但正如我们将看到的,这也可能导致一些问题。
继续我们的例子,我们的新FlyingBoss
类会看起来像下面这样:
class FlyingBoss : public Boss, public FlyingEnemy
{
public:
void Update(double deltaTime);
//other functions...
}
乍一看,这看起来像是完美的类,我们从两个父类中继承了我们需要的函数和变量。然而,在使用多重继承时,会出现一些问题。首先是歧义的问题。当被继承的两个或更多个类具有相同名称的函数或变量时,就会出现歧义。例如,在我们的例子中,如果我们没有覆盖Update()
函数,并且在对象上调用Update()
,编译器会查看我们从中继承的类的实现。由于它们都有相同名称的实现,编译器会抛出编译时错误,抱怨调用中的歧义。为了解决这个问题,我们必须在函数调用上使用前缀来标识我们想要使用的实现类。为此,我们在代码中使用作用域运算符(::
)来从FlyingEnemy
类中调用实现,代码看起来像这样:
FlyingEnemy::Update(deltaTime);
第二个问题可能不太明显;它与类继承树在我们的例子中的结构有关。表面上看,一切都很好;FlyingBoss
类从Boss
类和FlyingEnemy
类继承。问题出现在继承树的上一层,Boss
和FlyingEnemy
类都从Enemy
类继承。这在类层次结构中创建了可怕的死亡之钻模式。这可能看起来不是什么大问题,但是这种模式会导致一些不幸的问题。首先是再次出现歧义的问题。每当您尝试从FlyingBoss
类访问Enemy
类的任何成员变量或函数时,都会出现歧义。这是因为每个变量和函数都有多条路径。为了解决这个问题,我们可以通过再次使用作用域运算符(::
)来指定我们想要遵循的路径。死亡之钻模式引起的另一个问题是重复的问题。当我们创建一个FlyingBoss
对象时,它将拥有从Boss
类继承的一切的两个副本。这是因为FlyingEnemy
和Boss
类都有从Enemy
类继承的副本。正如您所看到的,这很混乱,可能会导致各种头痛。幸运的是,C++为我们提供了一个解决方案,即虚拟继承的概念。通过虚拟继承,我们可以确保父类只在任何子类中出现一次。要实现虚拟继承,我们只需在声明要继承的类时使用virtual
关键字。在我们的例子中,类声明看起来会像这样:
class Boss : public virtual Enemy
{
public:
//functions...
};
class FlyingEnemy : public virtual Enemy
{
public:
//functions...
}
class FlyingBoss : public Boss, public FlyingEnemy
{
public:
//other functions...
}
现在FlyingBoss
类只有一个通过继承获得的实例。
虽然这确实解决了死亡之钻和其他可能的层次问题,但这些问题通常是潜在设计问题的迹象。我建议在自动转向虚拟继承作为解决方案之前,研究所有其他选项。
最后,我想快速提到两个重要的主题,它们共同使继承成为了不可思议的工具,多态和虚函数。归结为基础知识,多态是将一个类的对象用作另一个类的一部分的能力。为了简单起见,让我们来看一下:
FlyingBoss* FlyBoss = new FlyingBoss();
这行代码创建了一个指向新的FlyingBoss
对象的指针,这里没有什么新鲜的。然而,我们也可以这样创建一个新的指针:
Boss* FlyBoss = new FlyingBoss();
这得益于继承和多态。我们能够将FlyBoss
对象称为Boss
类对象。现在可能看起来很简单,但随着你对 C++的理解不断深入,你会开始意识到这个概念有多么强大。它还引出了我想要在继承中谈到的最后一个话题,虚函数。由于我们可以创建这样的对象指针,如果我们在FlyingBoss
对象的Boss*
上调用Update()
函数会发生什么?这就是虚函数发挥作用的地方。如果一个函数被标记为virtual
关键字,就像这样:
virtual void Update(double deltaTime);
这告诉编译器使用调用函数的对象类型来确定在该情况下应该使用哪个实现。因此,在我们的例子中,如果我们在FlyingBoss
实现中使用虚函数,那么当从FlyingBoss
对象的Boss*
调用时,它将使用该实现。
指针和引用
C++中最被误解和害怕的概念之一就是指针和引用的概念。这往往是新开发人员放弃继续学习 C++的原因。已经有许多书籍和教程试图揭开这个话题的神秘面纱,坦率地说,我很容易就能写一章甚至一本专门讨论指针和引用的内部和外部知识。我希望你现在已经对经典意义上的指针和引用这个话题感到满意,并对它们的力量和灵活性有了健康的欣赏。因此,在这一部分,我们不打算涵盖核心原则,而是看看更重要的用途,即经典指针和引用的用途,并简要介绍旨在帮助消除一些神秘感和内存管理问题的新指针。
我们将从经典指针和引用开始。虽然你很快就会看到使用新指针的好处,但我仍然相信,像许多 C++游戏开发人员一样,旧版本仍然有其存在的价值。其中一个地方就是在处理向函数传递数据时。在调用函数时,往往很容易写出以下代码:
void MyFunction(GameObject myObj)
{
//do some object stuff
}
虽然这段代码完全合法,但如果对象的大小不容忽视,它可能会带来严重的性能问题。当传递这样的对象时,编译器会自动在内存中创建对象的副本。在大多数情况下,这不是我们想要的。为了防止编译器在内存中创建副本,我们可以使用经典指针或引用传递对象。前面的代码看起来会像这样:
void MyFunction (GameObject& myObj)
{
//do some object stuff
}
或者,它看起来会像这样:
void MyFunction (GameObject* myObj)
{
//do some object stuff
}
现在对象不会被复制到内存中,并允许我们通过解引用对实际对象进行操作。这是经典指针和引用的更常见和持续的用途之一。经典指针和引用的另一个常见用途是在处理字符串文字和移动对象时。这种类型的应用在许多游戏开发库中仍然很常见。因此,你应该习惯看到类似以下的代码:
const char* pixelShader;
随着现代 C++和 C++11 标准的推出,出现了一组新的托管指针,以帮助简化指针的理解和使用。这些新指针与经典指针非常相似,除了一个关键的区别;它们是托管的。这实际上意味着这些新指针将处理它们自己的内存分配和释放。由于经典指针的一个主要问题是必须手动管理内存和所有权的问题,这使得指针的使用更加受欢迎和更加灵活。这些托管指针(unique_ptr
和shared_ptr
)通常在更现代的游戏开发库中使用。
unique_ptr 和 shared_ptr
unique_ptr
或唯一指针被认为是智能指针。之所以称其为唯一,是因为这种类型的对象拥有其指针的唯一所有权。这意味着没有两个unique_ptr
指针可以管理相同的对象,它是唯一的。unique_ptr
的最大优势之一是它管理自己的生命周期。这意味着当指针超出范围时,它会自动销毁自身并释放其内存。这解决了可怕的悬空指针问题,并避免了内存泄漏。这也消除了所有权的问题,因为现在明确了谁删除了指针。
自 C++14 标准以来,我们现在可以使用一个方便的小函数来创建唯一指针,make_unique
。make_unique
函数创建了一个T
类型的对象,然后将其包装在唯一指针中。使用make_unique
创建unique_ptr
指针的语法如下:
std::unique_ptr<T> p = new std::make_unique<T>();
创建后,您可以像使用经典指针一样使用指针。解引用运算符*
和->
的工作方式与通常情况下一样。这里的最大区别再次在于,当指针超出范围时,它会自动销毁,使我们不必手动跟踪每个退出点以避免任何内存泄漏问题。
shared_ptr
或共享指针与唯一指针非常相似。它被认为是智能指针,可以自动处理内存的删除和释放。不同之处在于共享指针共享对象的所有权。这意味着,与唯一指针不同,共享指针可以是指向单个对象的多个共享指针之一。这意味着如果共享指针超出范围或指向另一个对象,通过reset()
或=
运算符,对象仍然存在。只有当拥有对象的所有shared_ptr
对象被销毁、超出范围或重新分配给另一个指针时,对象才会被销毁并释放其内存。
与唯一指针一样,共享指针也有一个用于创建的方便函数。make_shared
函数创建了一个T
类型的对象,然后将其包装在共享指针中。使用make_shared
函数创建shared_ptr
函数的语法如下:
std::shared_ptr<T> p = new std::make_shared<T>();
与唯一指针一样,共享指针也有典型的解引用运算符*
和->
。
const 正确性
在 C++社区中,const
正确性可能是一个有争议的话题。我第一门 C++课程的讲师甚至说const
关键字是语言中最重要的关键字之一。当然,我也听到了另一种说法,开发人员告诉我他们从不使用const
,这完全是在浪费击键。我认为我在const
方面处于中间位置;我相信它有重要的用途,但它可能像任何其他特性一样被过度使用。在这一部分,我想展示一些更好的const
使用方法。
简而言之,const
关键字用作类型限定符,让编译器知道这个值或对象是不可变的。在开始学习 C++游戏开发时,你对const
的第一次接触可能会很早。最常见的情况是,在定义我们想要轻松访问的重要值时,我们引入了const-ness的使用,比如这样:
const int MAX_BULLETS = 100;
然后我们可以在代码的其他部分轻松多次使用这个命名值。这样做的最大优势是,如果我们决定更改值,比如子弹的最大数量,在这种情况下,我们只需更改这个常量值,而不必更改代码库中散布的大量硬编码值。
随着您深入 C++开发,const
关键字将变得更加熟悉。它在库和引擎代码中以各种方式大量使用。它还用于函数参数的定义或用作函数定义的修饰符。让我们简要地看一下这些。
首先,在参数的定义中使用它,可以确保我们给定值的函数不会以任何方式修改它。例如,看下面的代码:
void ObjFunction(GameObject &myObject)
{
//do stuff
If(*myObject.value == 0)
{
//run some logic
Game.changeState(newState);
//possible unknown modifier function
*myObject.value = 1;
}
}
好吧,这是一个非常简单的例子,但如果您调用这样的函数,却不知道它可能会修改对象,您最终会得到您可能没有预期的结果。const
关键字有两种方式可以帮助解决这个可能的问题。一种是在传递值时使用const
关键字:
void ObjFunction(const GameObject &myObject)
{
//do stuff
If(*myObject.value == 0)
{
//run some logic
Game.ChangeState(newState);
//possible unknown modifier function
*myObject.value = 1; //now will throw a compile error
}
}
这样就不可能在函数中的任何地方修改传递的值,使其保持不变。
另一种方法是创建const
安全的函数。当您将函数定义为const
函数时,它允许const
对象调用它。默认情况下,const
对象不能调用非const
函数。但是,非const
对象仍然可以调用const
函数。要将函数定义为const
函数,我们可以添加const
关键字来修改函数定义本身。您只需在函数签名的末尾添加const
,如下所示:
void ObjFunction(const GameObject &myObject) const
{
//do stuff
If(*myObject.value == 0)
{
//run some logic
Game.ChangeState(newState);
//possible unknown modifier function
*myObject.value = 1; //now will throw a compile error
}
}
这是我编写任何不会修改任何对象或值的函数的首选方法。它允许在将来可以从const
对象调用它,并且还允许在其代码中使用该函数的其他开发人员轻松识别该函数不会修改与其组合使用的任何对象或值。
内存管理
在 C++中,内存管理的概念经常是初学者的噩梦话题。我经常听到开发人员说我不使用 C++是因为它的手动内存管理。事实上,在绝大多数项目中手动内存管理是非常罕见的。如今,随着现代概念如托管智能指针,手动构建的内存管理系统在日常开发中变得不那么重要。只有当涉及高性能计算,如游戏开发时,控制内存分配和释放才成为一个问题。在游戏开发中,控制内存分配和释放的概念仍然是开发人员关注的焦点,这也适用于大多数移动设备,尽管价格实惠的高内存设备不断增长。在接下来的部分,我们将重新审视堆栈和堆,以及处理内存分配的方法的差异。这将为下一章奠定基础,我们将看到一个自定义内存管理系统的示例。
让我们从堆栈开始,这个名字很贴切的内存结构,你可以把它想象成一堆盘子或碟子。当您在堆栈上创建一个对象或变量时,它被放在堆的顶部。当对象或变量超出范围时,这类似于从堆栈中移除盘子或碟子。在代码中,堆栈上的分配看起来像这样:
int number = 10;
Player plr = Player();
第一行创建一个整数值,并将其赋值为10
。存储整数所需的内存在堆栈上分配。第二行具有完全相同的想法,只是针对Player
对象而已。
使用堆栈的一个好处是,当对象或变量超出范围时,我们分配的任何内存都将被清理。然而,这可能是一把双刃剑;许多新开发人员遇到的问题是,他们在对象超出范围后仍然查找或调用对象,因为他们使用堆栈来存储它们。堆栈的另一个问题是其大小受限,这取决于平台和编译器设置。如果创建了大量对象并长时间保存,这可能会成为一个问题。尝试分配超出堆栈可用内存的内存将引发运行时错误。
另一种选择是堆,你可以将其视为一大块或一大容器的内存。与堆栈不同,这个内存堆是无序的,很容易变得碎片化。好消息是,现代内存和操作系统实现提供了一种低级机制来处理这种碎片化,通常称为内存虚拟化。这种虚拟化的另一个好处是,它提供了对比物理内存更多的堆存储的访问权限,通过在需要时将内存交换到硬盘。要在堆上分配和销毁内存,你可以使用关键字new
和delete
,以及new[]
和delete[]
用于对象的容器。代码看起来会像这样:
Player* plr = new Player();
char* name = new char[10];
delete plr;
delete[] name;
前两行创建了一个Player
对象和一个堆上的字符数组。接下来的两行分别删除了这些对象。重要的是要记住,对于在堆上创建的每个内存块,你必须调用 delete 来销毁或释放该内存块。如果不这样做,可能会导致内存泄漏,使你的应用程序继续消耗更多内存,直到设备耗尽并崩溃。这是一个常见的问题,很难追踪和调试。内存泄漏是新开发人员认为 C++内存管理困难的原因之一。
那么,你应该使用堆栈还是堆?嗯,这实际上取决于实现和要存储的对象或值。我建议的一个经验法则是,如果可以使用堆栈进行分配,那应该是你的默认选择。如果确实需要使用堆,尝试使用管理系统来处理创建和删除。这将减少内存泄漏和其他与处理自己的内存管理相关的问题的几率。我们将在下一章中讨论如何构建自己的内存管理器作为核心库的一部分。
处理错误
我希望我能说我写的每一行代码都能一次性无缺地运行。现实是我是人,容易犯错误。处理这些错误并追踪错误可能是大部分开发时间所花费的地方。有一个良好的方法来捕捉和处理在游戏运行时发生的错误和其他问题是至关重要的。本节介绍了一些用于查找和处理错误的 C++技术。
当你遇到问题时,可以使用一种技术优雅地让程序崩溃。这意味着,我们告诉计算机停止执行我们的代码并立即退出,而不是让计算机自行崩溃。在 C++中,我们可以使用assert()
方法来做到这一点。一个例子看起来会像下面的代码:
#include <assert.h>
...
void MyFunction(int number)
{
...
assert(number != NULL);
...
}
当计算机遇到代码行assert(number != NULL);
时,它会检查整数 number 是否为NULL
,如果是,这将导致断言失败,立即停止执行并退出程序。这至少让我们有些控制。我们可以利用assert()
函数提供的机会来捕获更多信息,以创建崩溃报告。我们可以打印出文件、行,甚至错误的描述作为自定义消息。虽然这样做有效,但还有很多需要改进的地方。
另一种处理错误的技术是异常,它可以提供更多的灵活性。异常的工作原理是这样的:当程序遇到问题时,它可以抛出一个异常来停止执行。然后程序会寻找最近的异常处理块。如果在抛出异常的函数中找不到该块,那么程序会在父函数中寻找处理块。这个过程会展开堆栈,意味着堆栈上创建的所有对象都会按照它们被传入的顺序被销毁。这个过程会一直持续,直到程序找到一个处理块或者到达堆栈的顶部,此时会调用默认的异常处理程序,程序将退出。总的来说,在 C++中处理异常的语法非常简单。要抛出异常,你可以使用关键字throw
。这将触发程序寻找一个处理块,用关键字Catch
表示。Catch
块必须位于Try
块的后面,Try
块封装了可能抛出异常的代码。一个简单的例子是:
Void ErroringFunction()
{
...// do something that causes error
throw;
}
Void MyFunction()
{
...
Try //the try block
{
...
ErroringFunction();
...
}
Catch(...)//catch *all exceptions block
{
... //handle the exception
}
}
您还可以通过将异常类型作为参数传递给 Catch 块来捕获和处理特定错误,如下面的代码所示:
...
Throw MyExeception("Error! Occurred in Myfunction()");
...
Catch(MyException e)
{
...//handle exception
}
使用异常的优势在于我们可以灵活地处理错误。如果情况允许,我们可以纠正导致错误的问题并继续进行,或者我们可以简单地将一些信息转储到日志文件中并退出程序。选择权在我们手中。
您实现的处理错误的解决方案完全取决于您所在的项目。事实上,一些开发人员选择完全忽略处理错误。然而,我强烈建议使用某种错误处理系统。在本书的演示示例代码中,我实现了一个异常处理系统。我建议将其作为起始参考。本章末尾的建议阅读部分还包含一些关于处理错误的优秀参考资料。
处理类型和容器
C++是一种强类型的不安全语言。它提供了令人难以置信的控制能力,但最终期望程序员知道自己在做什么。在高级水平上理解如何处理类型对于掌握游戏库和核心系统编程至关重要。游戏开发在很大程度上依赖于 C++中类型的灵活性,它还依赖于可用的高级库,比如标准模板库(STL)。在接下来的几节中,我们将看一些在游戏开发中常用的容器及其 STL 实现。我们还将介绍如何通过使用模板创建通用代码。最后,我们将通过查看类型推断及其更常见的用例来结束类型和容器的主题。
STL 通用容器
C++ STL 是一组容器类的集合,允许以不同的结构存储数据,具有提供对容器元素访问的迭代器,以及可以对容器和它们持有的元素执行操作的算法。这些结构、迭代器和算法都经过了极其优化,在大多数情况下使用了 C++语言标准的最新实现。STL 广泛使用 C++中的模板特性,以便轻松地适应我们自己的类型。我们将在下一节中看一下模板化。STL 是一个庞大的主题,有许多关于概念和实现的书籍。如果你对 STL 的经验很少,我强烈建议阅读一些关于这个主题的精彩书籍。我在本章末尾的总结部分列出了一些书籍。本节将集中介绍在游戏开发中更常用的一些 STL 容器。我假设你对容器有基本的了解,并且有一些使用迭代器遍历容器中的元素的经验。
让我们从两个序列容器 vector 和 list 开始。它们被称为序列容器是因为它们按特定顺序存储它们的元素。这允许在该顺序或序列的任何位置添加或删除元素。Vector 和 list 是你将遇到的最受欢迎的 STL 序列容器之一。了解一些关键事实将有助于您决定哪一个最适合特定任务。我已经包括了一些建议来帮助指导您。
向量
Vector是 STL 中提供的最基本的容器之一。虽然它相对简单,但它非常灵活,是游戏开发中最广泛使用的容器之一。你最有可能看到它的地方是替代 C 数组。使用数组带来的一个更大的缺点是你必须在声明时定义数组的大小。这意味着在大多数情况下,你需要知道所需元素的最大数量,或者你需要分配比你所需的更多。幸运的是,对于我们来说,向量没有这个预定义大小的缺点;向量将增长以容纳添加的新元素。要创建一个整数向量,我们可以使用以下语法:
std::vector<int> playerID ;
你可能注意到在vector
之前有std::
,这是因为vector
类是std
命名空间的一部分,所以我们需要确定我们希望使用该实现。请参阅本章前面的使用命名空间部分进行复习。我们可以通过在代码文件开头添加using namespace std;
语句来避免输入这个。我更喜欢在我的标准库调用或任何其他特定命名空间调用中添加std::
。由于游戏开发使用了很多库,使用很多using
语句可能会变得混乱且容易出错。虽然需要多按几下键盘,但可以避免很多麻烦。
我个人在大多数情况下使用向量代替数组,并建议您也这样做。不过,在将所有数组更改为向量之前,有一点很重要,那就是向量可能会导致问题的一个方面。当你创建一个向量时,会为它分配一个连续的内存块。内存的大小取决于向量中的元素数量。始终会有足够的空间来容纳向量中当前的所有元素,再加上一点额外的空间以便添加新元素。这就是向量的诀窍,随着添加更多的元素,最终开始耗尽空间,向量将获取更多的内存,以便始终有空间容纳新元素。它首先创建一个新的内存块,复制第一个内存块的所有内容,然后删除它。这就是问题可能出现的地方。为了防止不断的分配、复制和删除,当向量分配新内存时,通常会将前一个大小加倍。由于向量永远不会缩小,如果我们以一种方式使用向量,导致大量添加和删除元素,这很容易成为一个内存问题,特别是对于内存较低的设备。了解这一点不应该阻止您使用向量,在正确的情况下实现时,这应该很少成为问题,并且如果出现问题,可以通过重构来轻松解决。
一些使用向量的完美例子包括;玩家列表,角色动画列表,玩家武器,任何你可能不经常添加或删除的列表。这将避免可能的内存问题,同时让你可以使用向量的迭代器、算法和其他优点。
列表
列表是在使用 C++开发游戏时可能会看到的另一种序列容器类型。要创建一个整数值的列表容器,语法看起来会像这样:
std::list<int> objValues;
列表容器在其实现和开发中的一般用法上与向量有很大的不同。关键的区别在于,与向量不同,列表容器不会将所有元素存储在一个大的连续内存块中。相反,它将其元素存储为双向链表中的节点。每个节点都保存着指向下一个和上一个节点的指针。当然,这使得向量的额外内存分配问题消失了,因为列表中只有每个元素的内存是预先分配的。当添加新元素时,只会创建新节点的内存,节省了在向量实现中可能看到的浪费内存。这也允许在列表中的任何位置添加元素,与向量容器相比,性能要好得多。然而,也有一些缺点。由于内存中的单独节点设置,列表上的每个操作很可能最终会导致内存分配。由于每个节点可能散布在内存中,没有保证的顺序,这种不断的内存分配可能是在动态内存较慢的系统上的潜在问题。这也意味着列表遍历其元素比向量要慢。但这并不是要阻止您在项目中使用列表。我建议在您经常添加或删除的对象或元素组中使用列表。一个很好的例子是在每一帧中渲染的游戏对象或网格的列表。列表不应被视为向量的替代品。每种都有其优点和缺点,找到最佳解决方案通常是最困难的部分。
最后,我们将要看的最后一个容器是一个常用的关联容器。与序列容器不同,关联容器不保留其中元素的相对位置。相反,关联容器是为了速度而构建的,更具体地说是元素查找速度。不用进入大 O 符号,这些关联容器及其对应的算法在查找特定元素时远远优于向量和列表。它们被称为关联容器的原因是它们通常提供一个键/数据对,以便实现更快的查找。值得注意的是,有时容器中的键就是数据本身。我们将在这里关注的是地图容器。
地图
地图在游戏开发中有多种用途。与向量或列表相比,地图的独特之处在于每个地图由两部分数据组成。第一部分数据是一个键,第二部分是实际存储的元素。这就是使地图在查找元素时如此高效的原因。一个简单的思考方式是,地图就像数组,但是它不是使用整数值来索引元素,而是使用可以是任何类型的键来索引其元素。地图甚至有一个专门的[]
运算符,允许您使用熟悉的数组语法访问元素。
要创建一个以整数作为键和字符串作为元素类型或值的地图,我们的代码看起来会像下面这样:
std::map<int,string> gameObjects;
在内存使用方面,地图与列表和向量容器都不同。地图不像向量那样将数据存储在连续的块中,而是将元素保存在节点中,就像列表一样。列表和地图处理它们的分配方式的不同之处在于节点的结构方式。地图中的节点具有指向下一个节点和上一个节点的指针,就像列表一样,但这些节点是以树状模式排列的。这种树状模式会随着节点的添加和删除而自动平衡。好消息是,这种平衡行为不会增加任何新的分配。地图的性能与列表非常相似,因为内存管理是相似的,唯一可能看到差异的时候是节点树的自动平衡所带来的非常轻微的开销。
地图经常被用作字典的形式。它们通过键提供非常快速的唯一值查找;因此,在游戏开发中一些很好的地图示例包括:具有唯一 ID 的游戏元素列表,具有唯一 ID 的多人游戏客户端列表,以及几乎任何你想要以某种键值对存储的元素组。
模板
模板是 C++语言中的一个较新概念。模板有助于解决当使用不同的数据类型或类时不得不重写相同代码的普遍问题。这使我们能够编写所谓的通用代码。然后我们可以在项目的其他部分使用这个通用代码。截至 C++14 标准,现在有三种可以使用的模板类型:类模板,函数模板和变量模板。让我们在接下来的部分更仔细地看看它们。
类模板
使用类模板,我们可以创建抽象类,可以在不指定类的函数将处理什么数据类型的情况下进行定义。在构建库和容器时,这变得非常有用。事实上,C++标准库广泛使用类模板,包括我们在本章中早些时候看到的vector
类。让我们来看一个Rectangle
类的简单实现。这可能是一个有用的类,用于查找屏幕坐标、按钮和其他 GUI 元素,甚至简单的 2D 碰撞检测。
不使用类模板的基本实现将看起来像这样:
class Rectangle
{
public:
Rectangle(int topLeft, int topRight, int bottomLeft,
int bottomRight) :
m_topLeft (topLeft), m_topRight(topRight),
m_bottomLeft(bottomLeft), m_bottomRight(bottomRight){}
int GetWidth() { return m_topRight - m_topLeft; }
private:
int m_topLeft;
int m_topRight;
int m_bottomLeft;
int m_bottomRight;
};
在大多数情况下这是有效的,但是如果我们想在使用 0.0 到 1.0 的值的不同坐标系中使用这个矩形,我们将不得不做一些改变。我们可以只是复制代码并将整数数据类型更改为浮点数,这样也可以正常工作,但是使用类模板我们可以避免这种代码重复。
使用模板,新的Rectangle
类将看起来像这样:
template <class T>
class Rectangle
{
public:
Rectangle(T topLeft, T topRight, T bottomLeft,
T bottomRight) :
m_topLeft(topLeft), m_topRight (topRight),
m_bottomLeft(bottomLeft), m_bottomRight(bottomRight){}
T GetWidth() { return m_topRight - m_topLeft; }
T GetHeight() { return m_bottomLeft - m_topLeft;}
private:
T m_topLeft;
T m_topRight;
T m_bottomLeft;
T m_bottomRight;
};
你会注意到的第一个变化是在我们的类定义之前包含了template<class T>
。这告诉编译器这个类是一个模板。T
是一个数据类型的占位符。第二个变化是所有的整数数据类型都被替换为这个占位符。所以现在我们可以像这样使用int
数据类型创建一个矩形:
Rectangle(10,20,1,2);
当编译器遇到这行代码时,它会通过模板类并用int
替换所有占位符的实例,然后即时编译新的类。使用浮点值创建一个矩形,我们可以使用以下代码:
Rectangle (1,1,0.5,0.5);
我们可以对任何我们喜欢的数据类型这样做;唯一的限制是这些类型必须在类的操作中得到支持。如果不支持,就会抛出运行时错误。一个例子是一个具有乘法函数的类模板,试图使用该模板与一个字符串。
函数模板
函数模板的概念与类模板非常相似;最大的区别是函数模板不需要显式实例化。它们是根据传入的数据类型自动创建的。以下将交换两个值,但它不特定于任何类类型:
template<class T>
void Swap (T &a, T &b)
{
T temp = a;
a = b;
b = temp;
}
然后你可以传递整数值:
Swap(23,42);
or float values;
Swap(12.5, 5.2);
实际上,你可以将这个函数用于任何支持赋值运算符和复制构造函数的类型。这里的限制是两个数据类型必须是相同的类型。即使数据类型具有隐式转换,这也是正确的。
Swap(1.8, 22); // Results in a compile time error
变量模板
我想快速提到的最后一种模板类型是变量模板,不要与可变参数模板混淆。在 C++14 中引入的变量模板允许将一个变量包装在一个模板化的结构或类中。经常使用的例子是数学构造中的 pi:
template<class T>
constexpr T pi = T(3.1415926535897932385);
这意味着你可以将pi
作为float
、int
或double
变量,并在通用函数中使用它,例如,计算给定半径的圆的面积:
template<typename T>
T area_of_circle_with_radius(T r)
{
return pi<T> * r * r;
}
这个模板函数可以用于各种数据类型,因此你可以返回一个整数、一个浮点数,或者任何其他支持的数据类型作为面积。你可能不经常看到变量模板的使用。它们在 C++中仍然被认为是一个新的概念,但是了解它们的存在是很重要的。它们确实有一些独特的情况,也许有一天会帮助你解决一个困难的问题。
正如你所看到的,模板确实有它们的好处,我鼓励你在合适的地方使用它们。然而,重要的是要注意在实现模板时可能出现的一些潜在缺点。第一个潜在的缺点是所有的模板必须在同一个文件中有它们的整个实现,通常是头文件。export
关键字可以纠正这一点,但并非所有商业编译器都支持它。模板的另一个缺点是它们以难以调试而臭名昭著。当问题存在于模板代码内部时,编译器往往会给出晦涩的错误。我的最大建议是谨慎使用它们,就像其他功能一样。仅仅因为一个功能是先进的,并不意味着它就是一个好选择。最后,查看你的编译器以获取实现的确切细节。
类型推断及其使用时机
C++11 标准带来了一些非常有用的类型推断能力。这些新的能力给程序员提供了更多的工具来创建通用、灵活的代码。在这一部分,我们将更深入地研究这些新的能力。
我们将从一个新的强大关键字开始。auto
关键字允许您在声明时让编译器推断变量类型,如果可能的话。这意味着,与其像这样定义一个变量:
int value = 10;
现在你可以只使用auto
:
auto value = 10;
然而,这并不是auto
关键字的最佳用法,事实上,这是一个完美的例子,说明你不应该这样做。尽管在声明任何变量时使用auto
可能很诱人,但这不仅会给编译增加完全不必要的开销,还会使您的代码更难阅读和理解。这就是你不应该用auto
做的事情,那么你应该怎么用auto
呢?嗯,auto
真正显示其帮助之处的地方是与模板一起使用。与auto
关键字配合使用时,模板可以变得非常灵活和强大。让我们来看一个快速的例子。
在这个例子中,我们有一个简单的模板函数,为我们创建一些游戏对象,类似于以下内容:
template <typename ObjectType, typename ObjectFactory>
void CreateObject (const ObjectFactory &objFactory)
{
ObjectType obj = objFactory.makeObject();
// do stuff with obj
}
要调用这段代码,我们将使用以下代码:
MyObjFactory objFactory;
CreateObject<PreDefinedObj>(objFactory);
这段代码运行良好,但使用auto
关键字可以使其更加灵活和易于阅读。我们的代码现在看起来像这样:
template <typename ObjectFactory >
void CreateObject (const ObjectFactory &objFactory)
{
auto obj = objFactory.MakeObject();
// do stuff with obj
}
然后我们调用这个函数的代码将是:
MyObjFactory objFactory;
CreateObject (objFactory);
虽然这是一个过度简化,但它应该让您看到auto
可以提供的可能性。通过不定义对象工厂将返回的类型,我们允许工厂在其实现中更加自由,从而允许在我们的代码库中更广泛地使用工厂。
在模板之外,您将经常看到auto
关键字的应用之一是在 for 循环中迭代器的声明中。这已经成为许多更现代的库中的常见做法。您经常会看到 for 循环写成这样:
for (auto it = v.begin(); it != v.end(); ++it)
{
//do stuff
}
auto
关键字有一个辅助关键字decltype
,它从变量中提取类型。因此,auto
用于让编译器推断变量类型是什么,而decltype
用于确定变量的类型是什么。当您加入auto
关键字功能的最后一部分作为return
值时,这变得非常有用。在 C++11 之前和auto
关键字之前,return
值必须在函数名之前声明,如下所示:
TreeObject CreateObject (const ObjectFactory &objFactory)
{
auto obj = objFactory.MakeObject();
return obj;
}
这意味着CreateObject
函数必须返回一个TreeObject
类型,但正如前面提到的,让编译器推断objFactory.MakeObject();
返回的对象类型可以提供更大的灵活性。为了推断函数返回的对象类型,我们可以使用auto
、decltype
和新的return
语法的概念。我们的新函数现在看起来像这样:
template <typename ObjectFactory >
auto CreateObject(const ObjectFactory &objFactory) -> decltype (objFactory.makeObject())
{
auto obj = objFactory.MakeObject();
return obj;
}
还要注意的是,auto
和decltype
会增加我们的编译时间开销。在大多数情况下,这将是微不足道的,但在某些情况下可能会成为一个问题,因此在将这些新关键字纳入您的代码库时要意识到这一点。
随着您继续构建更多的库和工具集,构建更通用、灵活的代码的能力将变得至关重要。像使用auto
、decltype
和新的return
语法这样的技巧只是实现这一目标的一些方法。在接下来的章节中,我们将看到更多有用的概念。
游戏编程模式
编程模式或开发模式,简单来说,是常见或经常遇到的问题的解决方案。它是一个描述或模板,提供了可以在许多不同情况下使用的解决方案。这些模式是正式的最佳实践,通常是通过多年的迭代开发而形成的。通过在项目中使用模式,你可以使你的代码更具性能、更强大和更具适应性。它们允许你构建结构化的代码,天生就是解耦的。这种解耦是使你的代码更通用且更易于使用的原因。你不再需要将整个程序塞进脑海中,以理解特定代码段试图实现什么。相反,你可以专注于独立运行的小块。这就是面向对象设计的真正力量。这种解耦也将使得在测试过程中更容易追踪错误,通过将问题隔离到某个代码段。
至少对最基本的模式有扎实的理解,将对你开始构建自己的库和引擎结构至关重要。在接下来的几节中,我们将看一些这些基本模式。
使用循环进行工作
可以说,游戏开发中最重要的概念之一是循环的概念。如果你以前曾经制作过游戏,我几乎可以保证你曾经使用过某种形式的循环。尽管循环很常见,但循环的特定实现通常并非如此。模式为开发人员提供了构建高性能、灵活循环的指导方针和结构。
最常见的循环模式之一是游戏循环模式。游戏循环模式的目的是提供一种机制,将游戏时间的流逝与用户输入和其他事件分离,而不受处理器时钟速度的影响。简单来说,游戏循环在游戏运行期间或特定状态下持续运行,参见后面章节的状态机。在这个持续循环中,每个循环的时刻或轮次,我们都有机会更新游戏的各个部分。这通常包括更新当前游戏状态,检查和更新任何用户输入,而不会阻塞,并调用绘制或渲染任何游戏对象。许多平台和几乎所有引擎都有自己的实现。重要的是要注意你正在使用的平台或引擎是否有自己的游戏循环。如果有,你将需要将你的代码和循环结构连接到提供的机制中。
举个例子,Unity 游戏引擎抽象了循环过程,它通过所有游戏对象继承的Update()
函数暴露了与内部游戏循环的连接。这种 Unity 结构是游戏循环模式如何与其他模式(如更新模式)结合,构建一个级联循环系统的绝佳示例,允许主游戏循环驱动每个对象的内部循环机制。我们现在不会构建一个完整的示例,但随着我们继续阅读本书,我们将看到更多这样的结构是如何构建的。接下来的几节将继续探讨如何结合模式来构建完整的游戏系统流程。
为了帮助理解游戏循环是如何构建的,让我们看一个典型的、稍微简单的例子:
double lastTime = getSystemTime();
while (!gameOver)
{
double currentTime = getSystemTime ();
double deltaTime = currentTime - lastTime;
CheckInput();
Update(deltaTime);
Draw();
lastTime = currentTime;
}
代码的第一行,double lastTime = getSystemTime();
,在循环的第一次运行之前存储了时间。接下来是一个简单的while
循环,在这种情况下,只要变量gameOver
不为真,循环就会继续运行。在while
循环内,首先我们获取当前时间。接下来我们创建一个deltaTime
变量,它是自上次循环步骤以来经过的时间。然后我们调用游戏的其他组件:Input
,Update
和Draw
。这是游戏循环模式的关键;我们使用这个标准的运行循环来推动游戏向前发展。你可能会注意到我们将deltaTime
传递给Update
方法。这是循环的另一个重要组成部分,不深入研究更新模式,通过传递循环之间经过的时间,我们能够修改诸如游戏对象物理等东西,使用适当的时间片,这对保持一切同步和流畅非常重要。这种游戏循环模式实现的风格被称为可变时间步模式,因为循环步骤是基于更新所需的时间量。更新代码所需的时间越长,循环步骤之间的时间就越长。这意味着循环的每一步将决定经过了多少真实时间。使用这种方法意味着游戏将在不同硬件上以一致的速率运行,这也意味着拥有强大机器的用户将获得更流畅的游戏体验。然而,这种实现还远非完美。它没有优化渲染或处理步骤之间可能发生的延迟,但这是一个很好的开始。了解发生在幕后的事情是一个重要的步骤。在下一节中,我们将看一种允许我们基于事件创建代码路径的模式,这与循环的结合是游戏系统流的自然演变。
状态机
我们将要检查的下一个模式是状态模式;更具体地说,我们将看有限状态机。状态机是一个非常强大的工程概念。虽然在大多数编程学科中并不常见,除了可能是 AI 开发,有限状态机在构建分支代码中扮演着重要的角色。也许令人惊讶的是,我们日常生活中发现的许多机械逻辑电路都是由有限状态机的形式构建而成的。
一个现实世界的例子是一组交通信号灯,它根据等待的车辆改变状态(有时可能不够快)。有限状态机可以归结为一个抽象系统,其中机器只能处于有限数量的状态之一。机器将保持在这个状态,称为当前状态,直到事件或触发条件导致转换。让我们看一个演示这个概念的例子:
//simple enum to define our states
Enum GameState
{
Waiting,
Playing,
GameOver
}
GameState currentGameState = GameState.Waiting;
//Other game class functions...
void Update(double deltaTime)
{
//switch case that acts as our machine
switch(currentGameState)
{
case Waiting:
//do things while in waiting state
//Transition to the next state
currentGameState = Playing;
break;
case Playing:
//do things while in playing state
CheckInput();
UpdateObjects(deltaTime);
Draw();
//Transition to the next state
currentGameState = Gameover;
break;
case Gameover:
//do things while in waiting state
UploadHighScore();
ResetGame();
//Transition to the next state
currentGameState = Waiting;
break;
}
首先,我们有一个包含游戏状态的enum
结构。接下来,我们创建一个GameState
变量类型来保存机器当前所处的游戏状态。然后在一个Update
循环中,我们实现了一个控制从状态到状态转换流的switch case
结构。这种实现的关键在于机器的每个状态都有一个到下一个状态的转换状态。这保持了机器的运行,并允许我们根据机器当前的状态执行不同的操作。虽然这可能是游戏状态机的最基本形式之一,但它确实展示了有限状态模式的用处。当你开始创建库和其他组件时,你会开始看到这些令人难以置信的工具的更多用途。还有许多其他更复杂的实现和更多的模式来帮助描述它们。这些将在本书的后面章节中看到。
事件监听器
在游戏开发过程中经常会遇到这样的情况,即根据用户输入或来自其他代码块触发的条件执行某些代码。也许你只是需要一种可靠的方式让游戏对象进行通信。这就是使用事件或消息传递系统的想法产生的地方。已经创建了许多模式来帮助解决这个问题,包括Overseer,Model View Controller等。这些模式中的每一个都实现了处理事件的不同机制;许多实际上是基于彼此构建的。然而,在我们开始使用这些模式之前,我认为了解在幕后支持所有这些解决方案的基础是很重要的。通过构建我们自己的解决方案,我们将更好地理解问题,并更加欣赏解决它的模式。在我们的示例中,我们将使用本章学到的概念来构建一个简单但可重用的事件系统,可以在您自己的项目中使用。
我们可以采取的第一种方法是使用我们刚刚看到的状态机的简单版本。在这种方法中,我们使用switch case
结构来根据传入的事件类型分支代码。为了节省空间和时间,一些基本结构代码已被省略:
//Event could be an enum or struct that houses the different event types
void GameObject::HandleEvent(Event* event)
{
switch(event)
{
case Collision:
HandleCollision();
//Do other things...
break;
Case Explosion:
HandleExplosion()
//More things...
break;
}
}
这是一个快速而粗糙的实现,在一些非常基本的情况下可以工作。如果我们为我们的事件类型使用结构体或联合体,我们可以添加一些简单的消息功能,这将使其更加有用。不幸的是,这种方法最终存在太多重大问题。首先是我们需要有一个事件类型的单一来源。然后我们必须每次想要添加新的事件类型时编辑这个来源。其次是switch case
结构,同样,每次我们希望添加新的事件类型时,我们都必须追加和修改这个部分。所有这些都非常繁琐,容易出错,并且在面向对象的语言中是不好的设计。
我们可以采取的第二种方法依赖于运行时类型信息(RTTI)的能力,这是在运行时确定变量类型的概念。使用 RTTI 使我们能够在解决方案中使用dynamic_cast
来确定事件类型。我应该指出,并非所有的 RTTI 实现都是相同的,并且可能并非在所有编译器中默认打开。查看您的编译器的文档以获取确切信息。
首先,我们为我们将创建的所有特定事件创建一个简单的基类:
class Event
{
protected:
virtual ~event() {};
};
现在只需要使用dynamic_cast
来确定事件的类型,并将消息信息传递给对象自己的处理函数:
void onEvent(Event* event)
{
if (Collision* collision = dynamic_cast<Collision*>(event))
{
onCollision(collision);
}
else if (Explosion* explosion = dynamic_cast< Explosion *>(event))
{
onExplosion(explosion);
}
//etc...
}
这是一个比我们看到的第一个更优雅的解决方案。它提供了更多的灵活性,并且更容易维护。然而,我们可以重构这段代码,使其更加简单。使用我们之前学到的模板的概念,以及老式的重载,我们的新代码可以被构造如下:
Template <class T>
bool TryHandleEvent(const Event* event)
{
If(cosnt T* event = dynamic_cast<const T*> (event))
{
Return HandleEvent(event);
}
Return false;
}
void OnEvent( const Event* event)
{
If(TryHandleEvent<Collision>(event)) return;
Else if(TryHandleEvent<Explosion>(event)) return;
}
与本章中的其他示例一样,这个示例是基本的。虽然这种新方法比第一种更清晰、更具适应性,但它也有一些缺点。这包括dynamic_cast
的开销,这完全取决于类层次结构。维护和容易出错的代码问题仍然存在于if...else
链中。此外,我们还有更重要的不正确类型检测的问题。例如,使用这种方法,如果我们有一个从另一个类继承的类型,比如从Explosion
类继承的LargeExplosion
类。如果对对象类型的查询顺序不正确,事件指针首先被转换为Explosion
类,而实际上它指向LargeExplosion
类,编译器将不正确地检测类型并调用函数的错误版本。一个更理想的解决方案是有一个EventHandler
类,它将处理所有事件的注册、存储和多态函数。然后你可以有成员函数处理程序来实现特定的事件类型,这些成员函数处理程序可以从处理程序函数基类继承。这将解决我们在其他两种方法中看到的许多问题,同时给我们一个更通用、可重复使用的实现。
我们将在这里停止我们的实现。由于事件处理系统在游戏系统的许多不同部分中起着如此重要的作用,从工具链到用户输入和网络,我们将在本书的其余部分中看到更多这些模式和技术的应用。
总结
在本章中,我们涵盖了很多内容。我们讨论了一些现代游戏开发中使用的更高级的 C++主题。我们看了继承和多态性,指针,引用和常见的 STL 通用容器。模板化的概念以及使用类、函数和变量模板构建通用代码。类型推断和新语言关键字auto
和decltype
以及它们与新的return
值语法的组合使用。最后,我们在本章结束时看了一些当今使用的核心游戏模式。
在下一章中,我们将看看如何使用这些关键概念来创建可以在我们的游戏开发项目中使用和重复使用的核心库。
理解库
理解库的工作原理对于掌握 C++游戏开发非常重要。了解 C++库的工作方式将使您能够构建更健壮的游戏和工具。通常,创建游戏引擎核心的最基本要素可以在易于使用的可再分发库中找到。在本章中,我们将探讨库类型之间的关键差异,以及如何创建、构建和使用它们。在本章中,我假设您已经阅读了第一章,C++游戏开发,并且对编译和链接过程有一般的了解。本章包括以下部分:
-
库构建类型
-
构建自定义可共享库
我们为什么使用库?
库是 C++中的一个关键概念,它是使语言能够构建模块化设计和可移植代码的机制。通过使用库,我们能够创建可重用的代码,可以轻松地在多个程序之间共享,并与其他开发人员共享。它允许开发人员节省时间,不必一遍又一遍地重写特定的代码块。它还通过允许使用其他开发人员针对常见问题的解决方案来节省开发人员的时间。标准模板库(STL)就是一个很好的例子。STL 提供了大量在 C++中常见的问题的解决方案。这些解决方案包括数据类型的实现,如字符串,容器,如向量,以及排序等算法。这些标准实现经过多年的改进和开发。因此,它们往往非常高效和高度优化,我建议在适用的情况下默认使用 STD 实现而不是手写实现。对于 C++开发,有成千上万的库可供使用。
库构建类型
创建库文件有几种不同的方法。您可以使用不同的工具,如集成开发环境(IDE)。开发环境工具,如 Visual Studio 和 XCode,通常包含了用于创建各种平台和情况下的库文件的模板或起始项目。另一种更简单的方法,也是我们将在这里使用的方法,是通过命令行。更具体地说,是与 Visual Studio 2015 一起提供的开发人员命令提示符和 macOS X 提供的终端程序。您可以在 Visual Studio 网站上获取 Visual Studio 2015 社区版的副本,这是一个免费版本,适用于五名或更少开发人员的团队。
要在 Windows 8 或更高版本上打开开发人员命令提示符,请按 Windows 键,然后开始输入developer command prompt
,并在出现时选择 VS2105 的 Developer Command Prompt:
要在 OS X 上打开终端,请打开应用程序启动器,然后在屏幕顶部的搜索栏中键入Terminal
:
首先,让我们创建一个基本库,然后我们将能够从其他程序中使用它。在这个例子中,我们将只编写一个简单的函数,它将打印出经典的一行Hello World
。没有至少一个 hello world 程序的编程书籍就不完整了。这是我们将使用的文件,我将我的保存为hello.cpp
。
#include <iostream>
void Hello()
{
std::cout<< "Hello World!"<<std::endl;
}
静态链接库
静态库是作为应用程序的一部分编译的库。这意味着与库相关的所有代码都包含在一个单独的文件中,Windows 上是.lib
,Linux/OS X 系统上是.a
,并且直接链接到程序中。包含静态库的程序会从库中创建所需的代码副本,并将该副本放在调用库实现的程序中。对于每次调用库,都会这样做。这导致使用静态库的一个较大的缺点,即增加了可执行文件的总体大小。另一方面,使用静态库的优点是没有用户运行程序所需的外部依赖项。这有助于避免用户系统上的库版本错误或必须将其与程序一起分发的问题,这可能会产生一堆问题。您经常会听到这个常见问题被称为Dll Hell。静态库的另一个优点是,由于它们作为构建过程的一部分进行链接,这将使编译器和构建工具有更多机会优化实现。一个很好的经验法则是,对于大多数用户都会有的常见或标准库(如 OpenGL 或 DirectX),使用动态或共享库。对于较不常见的库(如 GLFW 或 SDL),您更有可能使用静态库。
要将我们的hello.cpp
文件转换为静态库,我们可以在开发人员命令提示符中按照以下步骤进行操作:
在 Windows 上
按照以下步骤进行操作:
- 对于 Windows,您需要输入以下命令:
cl /c hello.cpp
cl
是编译和链接的命令。/c
告诉编译器我们只想编译而不链接我们的文件。最后,我们传入要编译的文件。这将创建一个对象文件hello.obj
,然后我们可以使用它来创建我们的静态库文件。
- 现在我们已经创建了对象文件,我们可以使用库构建工具创建静态库。我们使用以下命令生成
.lib
文件:
lib /out:MyLib.lib hello.obj
lib
是启动构建工具的命令。/out:MyLib.lib
告诉编译器将库构建命名为MyLib.lib
。
- 如果列出目录的内容,您会看到我们现在有了静态库
MyLib.lib
:
- 我们现在可以在其他项目中使用我们新创建的库。让我们创建一个非常简单的程序来使用我们的库:
void Hello(); //Forward declaration of our Hello function
void main()
{
Hello();
}
我将文件保存为main.cpp
。
该程序将调用Hello
函数,编译器将在我们的链接库中寻找实现。
- 要编译此程序并链接我们的静态库,可以使用以下命令:
cl main.cpp /link MyLib.lib
- 编译完成后,我们现在在 Windows 目录中有一个
main.exe
:
在 macOS X 上
按照以下步骤进行操作:
- 对于 macOS X,您需要输入以下命令:
g++ -c hello.cpp
g++
是我们使用的开源编译器。标志-c
告诉g++
输出一个对象文件。在标志之后,我们指定了构建对象文件时要使用的 cpp 文件。此命令将生成文件hello.o
。
- 在 macOS X 平台上,我们使用以下命令生成
.a
文件:
arrvsMylib.ahello.o
ar
是我们用来创建静态库的库构建工具。首先我们设置了一些标志,rvs
,告诉ar
工具如何设置库存档。然后我们告诉工具我们正在创建的库的名称,然后是组成库的对象文件。
如果列出目录的内容,您会看到我们现在有了静态库Mylib.a
:
- 我们现在可以在其他项目中使用我们新创建的库。让我们创建一个非常简单的程序来使用我们的库:
void Hello(); //Forward declaration of our Hello function
void main()
{
Hello();
}
我将文件保存为main.cpp
。
该程序将调用Hello
函数,编译器将在我们的链接库中寻找实现。
- 我们使用以下命令编译程序并链接我们的静态库:
g++ main.cpp MyLib.a -o Main
编译完成后,我们现在将在我们的目录中有一个main.exe
(在 Windows 上)或一个主可执行文件(在 macOS X 上)。
注意 Windows 和 macOS X 上这个可执行文件的大小。再次,因为我们在静态链接我们的库,实际上我们将库的必要部分包含在可执行文件中。这消除了需要单独打包库与程序的需求,从而阻止了库的不匹配。事实上,现在库(.lib 文件)已经编译到可执行文件中,我们不再需要它,可以删除它。我们的程序仍然可以运行,但是如果我们想对库进行任何更改,我们将不得不重复前面的步骤来重新编译库,链接它,并将其添加到我们程序的构建中。
动态链接库
动态或共享库是在运行时链接其代码实现的库。这意味着动态库在程序源代码中可以被引用。当编译器看到这些引用时,它会在库实现中查找链接。当程序启动时,通过这些创建的链接包含了引用的代码。当程序使用动态库时,它只会创建对代码的引用,而不是代码的任何副本。这是使用动态库的最大优势之一,因为它们只是被引用,因此不像静态库那样增加可执行文件的总体大小。使用动态库的另一个重要优势是可维护性或修改。由于库是在运行时包含的,您可以进行更新或修改,而无需重新编译整个程序。这对于补丁样式的更新以及允许用户自己进行修改非常有用。最大的缺点是我之前提到的。通常需要将动态库与程序一起打包或安装。当然,这可能导致不匹配和可怕的 Dll Hell。
对于动态或共享库,我们必须进行一些修改并遵循略有不同的编译和链接步骤。首先,我们必须更改我们的库文件,让编译器知道我们希望与其他程序共享某些部分。在 Microsoft 平台上,我们使用__declspec
或声明规范来实现这一点。将dllexport
参数传递给__declspec
让编译器知道这个函数甚至类应该作为动态链接库的一部分导出。在 OS X 平台上,我们还使用一种声明类型来让编译器知道这些类或函数要导出。在这里,我们使用__attribute__((visibility("default")))
代替__declspec
。
在 Windows 上编译和链接动态库
以下是在 Windows 上编译和链接动态库的步骤:
hello.cpp
文件现在看起来是这样的:
#include <iostream>
__declspec(dllexport) void Hello()
{
std::cout<< "Hello World Dynamically" <<std::endl;
}
现在我们已经指定了要导出的函数,我们可以将文件编译成一个动态共享库。
- 在 Windows 上,我们可以使用以下命令从开发者控制台提示符创建一个
.dll
。
cl /LD /FeMyDynamicLib.dll hello.cpp
再次,cl
是启动编译器和链接器的命令。/LD
告诉编译器我们要创建一个动态链接库。/FeMyDynamicLib.dll
设置库的名称,/Fe
是编译器选项,MyDynamicLib.dll
是名称。最后,再次传入我们要使用的文件。
- 当编译器完成后,我们列出目录,现在将有
MyDynamicLib.lib
和MyDynamicLib.dll
两个文件:
你可能已经注意到的第一件事是,这个版本的.lib
文件比之前的静态库示例要小得多。这是因为实现不存储在这个文件中。相反,它充当指向.dll
文件中实际实现的指针。
- 接下来,我们可以使用以下命令(在 Windows 上)链接和构建我们的程序与我们新创建的库,就像前面的例子一样:
cl main.cpp /link MyDynamicLib.lib
- 所以现在如果我们运行程序,会看到显示
Hello World Dynamically!
这一行:
如果我们现在列出目录,会注意到新的主可执行文件,就像这个例子中的.lib
文件一样,比使用静态库的上一个版本要小得多。这是因为我们在构建时没有包含库的所需部分。相反,我们在运行时按需加载它们,动态地:
- 我之前提到的一个好处是,当您对动态链接库进行更改时,您不必重新编译整个程序;我们只需要重新编译库。为了看到这一点,让我们对
hello.cpp
文件进行一些小改动:
#include <iostream>
__declspec(dllexport) void Hello()
{
std::cout<< "Hello World Dynamically!"<<std::endl;
std::cout<< "Version 2" <<std::endl;
}
- 接下来,我们可以使用与之前相同的命令重新编译我们的库:
cl /LD /FeMyDynamicLib.dll hello.cpp
- 这将添加我们的新更改,我们可以看到它们在不重新编译
main.exe
的情况下生效,只需运行它。输出现在将是两行:Hello World Dynamically!
和Version 2
:
这使得升级非常容易,但也很容易在没有更新的库的机器上迅速导致 Dll 不匹配,通常被称为 Dll 地狱。
在 macOS X 上编译和链接动态库
现在,hello.cpp
文件看起来会是这样:
#include <iostream>
__attribute__((visibility("default"))) void Hello()
{
std::cout<< "Hello World Dynamically" <<std::endl;
}
我们可以使用以下命令从终端 shell 创建.dylib
文件:
g++ -dynamiclib -o MyDynamicLib.dylib hello.cpp
在这里,我们使用g++
编译器,并设置一个标志来创建一个动态库文件,-dynamiclib
。接下来的标志-o MyDynamicLib.dylib
告诉编译器输出文件的名称。最后,我们指定创建库时要使用的文件。如果你现在列出目录,你会看到新创建的MyDynamicLib.dylib
文件:
接下来,我们可以使用以下命令链接和构建我们的程序与我们新创建的库,就像前面的例子一样:
g++ main.cpp MyDynamicLib.dylib -o Main
所以现在如果我们运行程序,会看到显示Hello World Dynamically!
这一行:
如果我们现在列出目录,你会注意到新的主可执行文件,就像这个例子中的.lib
文件一样,比使用静态库的上一个版本要小得多。这是因为我们在构建时没有包含库的所需部分。相反,我们在运行时按需加载它们,动态地:
我之前提到的一个好处是,当您对动态链接库进行更改时,您不必重新编译整个程序;我们只需要重新编译库。为了看到这一点,让我们对hello.cpp
文件进行一些小改动:
#include <iostream>
__attribute__((visibility("default"))) void Hello()
{
std::cout<< "Hello World Dynamically!"<<std::endl;
std::cout<< "Version 2" <<std::endl;
}
接下来,我们可以使用与之前相同的命令重新编译我们的库:
g++ -dynamiclib -o MyDynamicLib.dylib hello.cpp
前一个命令的输出将会是这样:
这使得升级非常容易,但也很容易在没有更新的库的机器上迅速导致 Dll 不匹配,通常被称为 Dll 地狱。
仅有头文件或源文件的库
我想提到的最后一种共享库的方式是简单地共享源代码或头文件实现。这是一种完全合法的共享库方式,在开源和较小的项目中非常常见。它的明显优点是提供修改的源代码,并且可以让使用的开发人员轻松选择他们想要在项目中实现的部分。不过,这也可以被视为一个缺点,因为现在您的源代码是公开可用的。通过公开和自由地提供您的代码,您放弃了对其使用的控制,并且根据许可可能对其实现的解决方案几乎没有专有权主张。
要将我们的小例子更改为仅包含头文件的实现,我们只需将hello.cpp
文件更改为头文件hello.h
,并在其中执行所有函数的实现。我们的新hello.h
文件现在将如下所示:
#pragma once
#include <iostream>
void Hello()
{
std::cout<< "Hello World Header!"<<std::endl;
}
然后,为了使用头文件库,我们将在main.cpp
文件中包含它,就像包含任何其他头文件一样:
#include "hello.h"
void main()
{
Hello();
}
由于我们使用的是仅包含头文件的实现,我们不必担心在构建过程中链接库。我们可以使用以下命令从开发人员控制台提示符编译程序。
在 Windows 上:
cl main.cpp
编译后,您可以运行主可执行文件并看到类似的 hello world 消息,Hello World Header!
:
在 macOS X 上:
g++ main.cpp -o Main
编译后,您可以运行主可执行文件并看到类似的 hello world 消息,Hello World Header!
:
构建自定义可共享库
拥有创建自定义库的能力是一项非常有价值的技能。建立对创建、构建和使用库所需步骤的深入了解,将使您能够创建更有能力的系统和解决方案。在下一节中,我们将深入探讨如何在受控开发环境中创建、构建和使用可共享库项目。
设置和结构
对于本示例,我将继续在 Windows 上使用 Visual Studio,并在 macOS X 上使用 XCode。虽然在每个开发环境中一些确切的细节会有所不同,但推断这些步骤应该不会太困难。您可以在代码存储库的Chapter02
文件夹中找到此示例的完整源代码。
首先,我们将创建一个新项目。
在 Windows 上创建新项目
在 Windows 上,我们可以通过转到文件|新建|项目,然后展开 Visual C++下拉菜单,最后选择 Win32 控制台应用程序来完成这个操作。我将我的新项目命名为MemoryMgr
:
一旦您选择了“确定”,Win32 应用程序向导对话框将弹出。单击“下一步”将对话框移动到下一页:
在此对话框页面上,我们提供了一些不同的应用程序设置。对于我们的应用程序类型,我们将选择 DLL。这将创建一个.dll
和相应的.lib
文件,然后我们可以共享和使用。我们选择动态或共享库而不是静态库的原因是因为我可以演示如何构建和编译可共享库。这是一个简单的内存管理器库,在大多数情况下,它将包含在一套其他实用程序库中。我们可以很容易地修改此库为静态库,有关说明,请参见上一节。
选择空项目选项,这将为我们提供一个完全空白的项目,我们可以从中构建我们的库。这也会使大多数其他选项变灰,例如附加选项中的预编译头。这是一个常用的选项,通过在单个头文件中调用所有或大多数需要的头文件,然后将其作为单个头文件添加到其他实现文件中,来帮助加快大型项目的编译速度。您可以将安全开发生命周期(SDL)检查保留为选定状态,因为它不会引起任何问题。单击完成退出对话框并打开新项目:
项目加载后,我们将看到一个空白的编辑器窗口和空的解决方案资源管理器。
在 macOS X 上创建一个新项目
我们通过转到文件|新建|项目来创建一个新项目,然后从模板选择中选择 OS X,然后选择库:
单击下一步后,将出现一个包含项目设置选项的对话框。这些选项包括产品名称,我选择了MemoryMgr
,组织名称和组织标识符,我将其保留为默认选择。在生产环境中,您需要调整这些选项以匹配您的项目。最后两个选项是框架和类型。对于框架,选择 STL(C++库),这是在使用将包括对 STL 的访问权限的库时使用的模板。对于类型,选择动态,还有一个静态库项目的选项:
我们的下一步是创建库所需的源文件。在这个例子中,我们只会创建一个类,包括一个单独的头文件.h
和实现文件.cpp
。
在 Windows 上创建源文件
我们可以使用添加|类...对话框在 Visual Studio 中快速添加此类。
在解决方案资源管理器中右键单击 MemoryMgr 项目,导航到添加|类:
一个新的屏幕将弹出,其中有一些选项用于创建新的类;我们只会使用默认的通用 C++类选项。
选择添加以进入下一个对话框屏幕。现在我们在通用 C++类向导屏幕上。在类名部分添加您要创建的新类的名称,在我的例子中我称之为MemoryMgr
。当您输入类名时,向导将自动为您填充.h 文件和.cpp 文件。由于这不是一个继承的类,我们可以将基类部分留空。我们将访问保留在公共的默认设置,并且最后我们将保持虚析构函数和内联选项未选中。
单击完成以将类添加到我们的项目中:
当然,这与我们简单地键入完整的导出说明符是一样的:
__declspec(dllexport) int n; //Exporting a variable
__declspec(dllexport) intfnMemoryMgr(void); //Exporting a function
在 macOS X 上创建源文件
这一步已经默认为我们完成。项目创建向导会自动包含一个实现文件.cpp
和一个头文件,但在这种情况下,头文件的扩展名是.hpp
。自动生成的文件还包含一堆存根代码,以帮助启动项目。在我们的示例中,为了使事情更连贯,我们将删除这些存根代码并删除两个.hpp
文件。而是我们将创建一个新的.h
文件并插入我们自己的代码。创建一个新的.h
文件非常简单,导航到文件|新建|文件。在新文件对话框中,从左侧的平台列表中选择 OS X,然后从类型选择窗口中选择头文件:
单击“下一步”按钮将弹出文件保存对话框。将文件保存为MemoryMgr.h
,请注意我指定了.h
作为扩展名。如果您不指定扩展名,向导将默认为.hpp
。还要注意的是,确保在对话框底部选择了目标项目,这将确保它被视为 XCode 项目解决方案的一部分。
您的项目布局现在应该如下所示:
现在是编写代码的时候了。我们将从MemoryMgr
头文件MemoryMgr.h
开始。在这个文件中,我们将声明所有我们将使用的函数和变量,以及将提供对我们动态库访问的定义。这是MemoryMgr.h
,已经删除了注释以保持简洁:
#ifdef MEMORYMGR_EXPORTS
#ifdef _WIN32
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __declspec(dllimport)
#elif __APPLE__
#define EXPORT __attribute__((visibility("default")))
#endif
#endif
完整的文件内容可以在Chapter02
文件夹中的代码库中找到。
创建新动态库时的第一步是一个有用的快捷方式,它允许我们节省一些按键和简化导出类、函数或变量的创建。使用ifdef
指令,我们首先可以为我们的内存管理器创建一个标识符MEMORYMGR_EXPORTS
,然后为目标平台创建标识符,_WIN32
表示 Windows,__APPLE__
表示 macOS X。在每个平台的ifdef
指令内,我们可以为宏EXPORT
添加定义,对于 Windows,这些是dllexport
和dllimport
。这是使用宏的标准方式,使得导出和导入的过程更加简单。有了这些宏,包含此文件的任何项目将看到暴露的函数被导入,而动态库将看到使用此宏定义的任何内容被导出。这意味着我们现在可以简单地使用EXPORT
来代替在动态库中指定应该提供给其他人的内容时使用的_declspec(dllexport)
或__attribute__((visibility("default")))
。
创建内存管理器的下一步是创建一对struct
,用于我们的Block
和Heap
对象。一个块是我们将存储单个对象的内存切片或块。Heap
是这些Block
的集合,包含在内存的连续容器中。Block
结构简单地保存指向下一个Block
指针;这为每个Heap
中的Block
对象创建了一个单链表。Heap
结构还保存指向内存中下一个Heap
的指针,这再次为Heap
对象创建了一个单链表。Heap
结构还包含一个小的辅助函数,返回Heap
中的下一个块:
struct Block
{
Block* next;
};
struct Heap
{
Heap* next;
Block* block()
{
return reinterpret_cast<Block*>(this + 1);
}
};
现在我们已经有了Heap
和Block
结构,我们可以继续定义实际的内存管理器类CMemoryMgr
。这就是我们之前创建的定义派上用场的地方。在这种情况下,我们使用EXPORT
来指定我们希望整个类在动态库中被导出。当我们以这种方式导出类时,类的访问方式与任何其他类完全相同。这意味着所有的private
、protected
和public
对象继续具有相同的访问权限。
class EXPORT CMemoryMgr
在我们的简单示例中,导出整个类是有意义的,但并不总是如此。如果我们只想导出一个函数或变量,我们可以使用我们创建的EXPORT
宏来实现:
EXPORT int n; //Exporting a variable
EXPORT void fnMemoryMgr(void); //Exporting a function
当然,这与我们简单地输入完整的导出说明符是完全相同的(在 macOS X 上):
__attribute__((visibility("default"))) int n; //Exporting a
variable__attribute__((visibility("default"))) intfnMemoryMgr(void);
//Exporting a function
关于MemoryMgr
文件的更多信息:
现在我们知道如何导出类、函数和变量,让我们继续快速查看MemoryMgr
头文件的其余部分。首先,我们定义了我们的公共方法,在调用我们的库时将可用。这些包括构造函数,它接受三个参数;dataSize
,每个块对象的大小,heapSize
,每个内存堆的大小,以及memoryAlignmentSize
,这是我们用来在内存中移动对象的变量。
在内存中移动对象意味着我们将始终使用一定量的内存来保存对象,无论大小如何。我们这样做是为了使对象以这样的方式对齐,以便我们可以减少对实际内存硬件的调用次数,这当然会提高性能。这通常是开发人员使用自定义内存管理器的主要原因。
接下来,我们有一个不带参数的析构函数,然后是Allocate
,Deallocate
和DeallocateAll
,它们确切地执行它们的名字所暗示的操作。唯一带有参数的函数是Deallocate
函数,它接受一个指向要删除的内存的指针:
class EXPORT CMemoryMgr
{
public:
CMemoryMgr(unsigned int dataSize, unsigned int heapSize, unsigned int
memoryAlignmentSize);
~CMemoryMgr();
void* Allocate();
void Deallocate(void* pointerToMemory);
void DeallocateAll();
这些函数是我们的库中唯一公开的函数,在这个简单的例子中,可以被视为这个库的基本实现接口。
公共声明之后,当然需要私有声明来完成我们的库。它们以三个静态常量开始,这些常量保存了我们将使用的简单十六进制模式。这将帮助我们在调试时识别每个内存段,并提供一个简单的机制来检查我们是否在正确的时间段上工作:
private:
static const unsigned char ALLOCATION_PATTERN = 0xBEEF;
static const unsigned char ALIGNMENT_PATTERN = 0xBADD;
static const unsigned char FREE_MEMORY_PATTERN = 0xF00D;
然后我们有用于在我们的库中进行繁重工作的private
方法。辅助函数GetNextBlock
将返回Heap
中下一个链接的block
。OverWriteHeap
函数接受一个指向将写入特定Heap
的堆的指针。OverWriteBlock
接受一个指向要写入的块的指针,OverWriteAllocated
再次接受一个分配给写入的Block
指针:
Block* GetNextBlock(Block* block);
void OverWriteHeap(Heap* heapPointer);
void OverWriteBlock(Block* blockPointer);
void OverWriteAllocatedBlock(Block* blockPointer);
在private
方法之后,我们有将存储我们内存管理器库所需的各种类型数据的成员变量。前两个是指针列表,我们用它们来保存我们创建的堆和可用的空闲块:
Heap* m_heapList = nullptr;
Block* m_freeBlockList = nullptr;
最后,我们有一组无符号整数,保存各种数据。由于变量的名称相当不言自明,我不会逐个解释:
unsigned int m_dataSize;
unsigned int m_heapSize;
unsigned int m_memoryAlignment;
unsigned int m_blockSize;
unsigned int m_blocksPerHeap;
unsigned int m_numOfHeaps;
unsigned int m_numOfBlocks;
unsigned int m_numOfBlocksFree;
};
现在,在我们的实现文件(MemoryMgr.cpp
)中,由于在这个例子中我们正在导出整个类,我们不必包含任何特殊的内容,所有公开访问的内容将对使用我们的库的任何项目可用。如果我们决定只导出选定的函数和变量,而不是整个类,我们将不得不使用我们创建的EXPORT
宏来指定它们应该在我们的库中导出。为此,您只需在实现前面添加EXPORT
:
// This is an example of an exported variable
EXPORT int nMemoryMgr=0;
// This is an example of an exported function.
EXPORT int fnMemoryMgr(void)
{
return 42;
}
为了节省时间和空间,我不打算逐行讨论MemoryMgr.cpp
的实现。该文件有很好的文档,应该足够清楚地解释内存管理器的简单机制。尽管它很简单,但这个库是构建更健壮的内存管理器系统的绝佳起点,以满足任何项目的特定需求。
构建自定义库
在您或其他人可以使用您的自定义库之前,您需要构建它。我们可以通过几种不同的方式来实现这一点。
在 Windows
在我们之前部分的例子中,我们使用了 Visual Studio 2015,在这种情况下构建库非常简单。例如,要构建MemoryMgr
库,您可以在“解决方案资源管理器”中右键单击解决方案'MemoryMgr',然后选择“生成解决方案”,或者使用键盘快捷键Ctrl+Shift+B:
这将在项目的输出文件夹中创建所需的MemoryMgr.dll
和MemoryMgr.lib
文件,分别位于 Debug 或 Release 下,具体取决于所选的构建设置。我们构建库的另一种方法是使用我们在本章第一部分讨论的开发人员命令行工具。在这种情况下,我们可以简单地更改目录到项目文件并使用cl
命令以包括库名称和输入文件:
cl /LD /FeMemoryMgr.dll MemoryMgr.cpp
同样,这将创建MemoryMgr.dll
和MemoryMgr.lib
文件,这些文件在其他项目中使用我们的库时是需要的。
在 macOS X 上
构建 XCode 库项目非常容易。您可以简单地从工具栏中选择产品,然后单击构建,或者使用键盘快捷键 Command + B:
这将创建MemoryMgr.dylib
文件,这是我们在其他项目中包含库时需要的。我们构建库的另一种方法是使用我们在本章前面看到的终端 shell。在这种情况下,我们只需切换到项目文件的目录并运行g++
,并包括库名称和输入文件:
g++ -dynamiclib -o MemoryMgr.dylib MemoryMgr.cpp
在 Windows 上使用.def 文件构建动态库
我们将探讨使用仅.def
文件或同时使用链接器选项构建动态库的选项。
仅使用.def 文件
我还想提一下我们可以用来构建动态库的另一种方法,那就是使用.def
文件。模块定义或.def
文件是一个文本文件,其中包含描述动态库导出属性的模块语句。使用.def
文件,您无需创建任何宏或使用__declspec(dllexport)
指定符来导出 DLL 的函数。对于我们的MemoryMgr
示例,我们可以通过打开文本编辑器并添加以下内容来创建一个.def
文件:
LIBRARY MEMORYMGR
EXPORTS
Allocate @1
Deallocate @2
DeallocateAll @3
这将告诉编译器我们希望导出这三个函数:Allocate
,Deallocate
和DeallocateAll
。将文件保存为.def
文件;我把我的叫做MemoryMgr.def
。
在我们可以使用模块定义文件重新编译库之前,我们必须对MemoryMgr
的源代码进行一些更改。首先,我们可以删除我们创建的宏,并在CMemoryMgr
类定义之前删除EXPORT
。与需要宏或_declspec(dllexport)
指定符不同,我们之前创建的.def
文件将处理告诉编译器应该导出什么的工作。
在 Windows 平台上使用模块定义文件编译动态库,我们有几个选项。我们可以像之前一样使用开发者控制台编译库,但是需要额外的选项来指定.def
文件。从控制台编译MemoryMgr
库的命令看起来可能是这样的:
cl /LD /DEF:MemoryMgr.def /FeMemoryMgr2.dll MemoryMgr.cpp
/DEF:filename
是告诉编译器使用指定的模块定义文件来构建库的标志。这个命令将产生一个名为MemoryMgr2.dll
的动态库。
设置链接器选项
我们构建动态库使用.def
文件的第二个选项是在 Visual Studio 开发环境中设置链接器选项。这样做非常简单。
首先,通过右键单击解决方案资源管理器中项目的名称或使用键盘快捷键Alt + Enter来打开属性页对话框。打开属性页对话框后,选择链接器,点击输入属性页,最后在模块定义文件属性中输入.def
文件的名称。最终结果应该看起来像以下内容:
现在,当您构建动态库项目时,编译器将使用MemoryMgr.def
文件来确定应该导出哪些属性。
接下来,我们将看看在使用 Visual Studio 和 XCode 项目时如何使用和消耗这个和其他库。
共享和使用库
现在我们已经构建了自定义库,我们可以开始在其他项目中使用它。正如我们在本章前面看到的,我们可以使用命令行编译器工具链接动态和静态库。如果您只有几个库或者可能创建了一个自定义的构建脚本,那么这是可以的,但是在大多数情况下,当使用像 Visual Studio 这样的集成开发环境时,有更简单的方法来管理。实际上,在 Visual Studio 中向项目添加库可以非常简单。首先添加库,我们再次打开“属性页”对话框,右键单击并转到“属性”或在“解决方案资源管理器”中选择项目后按Alt + Enter。接下来,展开链接器并选择输入。在对话框顶部的“附加依赖项”属性上,单击下拉菜单并选择“编辑”。这将打开一个类似于此处所示的对话框:
在此对话框的属性窗口中,我们可以在编译时指定要包含的库。无论是动态库还是静态库,我们都包括.lib
文件。如果您已经在“配置属性”下的 VC++目录文件夹中设置了库目录,您可以简单地使用库名称,如MemoryMgr.lib
。您还可以通过指定库的路径来包含库,例如C:\project\lib\MemoryMgr.lib
。此属性还接受宏,使用宏很重要,因为否则将项目移动到另一个目录会破坏包含。您可以使用的一些宏包括:
-
$(SolutionDir)
: 这是顶层解决方案目录 -
$(SourceDir)
: 这是项目源代码的目录 -
$(Platform)
: 这是所选的平台(Win32、x64 或 ARM) -
$(Configuration)
: 这是所选的配置(调试或发布)
这意味着如果我在解决方案目录中的一个名为lib
的文件夹中为每个平台和配置都有一些库,我可以通过使用这样的宏来节省大量工作:
$(SolutionDir)/lib/$(Platform)/$(Configuration)/MemoryMgr.lib
现在,如果我切换平台或配置,我就不必每次都返回属性页面进行更改。
这解决了链接库的问题,但在使用或共享库时还需要另一个部分。在本章的第一组示例中,您一定已经注意到,在创建用于演示库使用的小控制台程序时,我使用了前向声明来指定库中Hello
函数的实现。
void Hello(); //Forward declaration of our Hello function
虽然这在像这样的小例子中可以工作,但是如果您使用具有多个属性的库,前向声明将变得非常繁琐。为了在项目中使用库,您通常需要包含定义文件,即头文件。这就是为什么当您看到共享库时,它们通常会有一个Include
文件夹,其中包含与该库一起使用所需的所有头文件。对于我们的MemoryMgr
库来说,这意味着如果我想在新项目中使用它或与其他开发人员共享它,我需要包含三个文件。MemoryMgr.dll
库实际上是一个动态库。MemoryMgr.lib
库是用于链接的库文件。最后,我还需要包含MemoryMgr.h
文件,该文件包含了我的库的所有属性定义。
由于大多数库都有多个头文件,简单地将它们复制到项目中可能会变得混乱。好消息是,像大多数集成开发环境一样,Visual Studio 具有配置设置,允许您指定哪些文件夹包含您希望包含在项目中的文件。设置这些配置选项也非常简单。首先,在“解决方案资源管理器”中突出显示项目后,打开“属性页”对话框,*Alt *+ Enter。
接下来,单击 C/C++文件夹以展开它。然后选择“常规”部分。在此属性窗口的顶部,您将看到“附加包含目录”,选择此属性的下拉菜单,然后单击“编辑”。这将带来一个类似于这里所示的对话框:
在此对话框中,我们可以通过单击添加文件夹图标或使用键盘快捷键Ctrl + Insert来添加新行。您可以使用文件夹资源管理器对话框来查找和选择所需的包含文件夹,但此属性还支持宏,因此指定所需的包含文件夹的更好方法是使用宏。如果我们在主解决方案目录中有一个名为 Include 的文件夹,其中包含一个名为MemoryMgr
的文件夹,我们可以使用以下宏来包含该文件夹:
$(SolutionDir)Include\MemoryMgr\
一旦您选择“确定”并应用以关闭“属性页”对话框,您可以像在项目中的任何其他头文件一样包含头文件。在我们的MemoryMgr
文件夹的情况下,我们将使用以下代码:
#include<MemoryMgr\MemoryMgr.h>;
请注意,文件系统层次结构得到了尊重。
总结
在本章中,我们介绍了可共享库的高级主题。我们看了看可用的不同类型的库。我们介绍了创建自己的可共享库的各种方法。
在下一章中,我们将利用这些高级库知识来构建资产管理流水线。
建立坚实的基础
虽然从头开始构建自己的库可能是一个有益的过程,但也很快会变得耗时。这就是为什么大多数专业游戏开发者依赖一些常见的库来加快开发时间,更重要的是提供专业的实现。通过连接这些常见的库并构建辅助和管理类来抽象这些库,实际上是在构建最终将驱动您的工具和游戏引擎的结构。
在接下来的几节中,我们将介绍这些库如何协同工作,并构建一些需要补充结构的库,为我们提供一个坚实的基础,以扩展本书其余部分的演示。
首先,我们将专注于任何游戏项目中可能是最重要的方面之一,即渲染系统。适当的、高效的实现不仅需要大量的时间,还需要对视频驱动程序实现和计算机图形学的专业知识。话虽如此,事实上,自己创建一个自定义的低级图形库并不是不可能的,只是如果您的最终目标只是制作视频游戏,这并不是特别推荐的。因此,大多数开发人员不会自己创建低级实现,而是转向一些不同的库,以提供对图形设备底层的抽象访问。
在本书中的示例中,我们将使用几种不同的图形 API 来帮助加快进程并在各个平台上提供一致性。这些 API 包括以下内容:
-
OpenGL (
www.opengl.org/
):开放图形库(OGL)是一个开放的跨语言、跨平台的应用程序编程接口,用于渲染 2D 和 3D 图形。该 API 提供对图形处理单元(GPU)的低级访问。 -
SDL (
www.libsdl.org/
):简单直接媒体层(SDL)是一个跨平台的软件开发库,旨在为多媒体硬件组件提供低级硬件抽象层。虽然它提供了自己的渲染机制,但 SDL 可以使用 OGL 来提供完整的 3D 渲染支持。
虽然这些 API 通过在处理图形硬件时提供一些抽象来节省我们的时间和精力,但很快就会显而易见,抽象的级别还不够高。
您需要另一层抽象来创建一种有效的方式在多个项目中重用这些 API。这就是辅助和管理类的作用。这些类将为我们和其他编码人员提供所需的结构和抽象。它们将包装设置和初始化库和硬件所需的所有通用代码。无论游戏玩法或类型如何,任何项目所需的代码都可以封装在这些类中,并成为引擎的一部分。
在本章中,我们将涵盖以下主题:
-
构建辅助类
-
使用管理器进行封装
-
创建接口
构建辅助类
在面向对象编程中,辅助类用于辅助提供一些功能,这些功能不是直接是应用程序的主要目标。辅助类有许多形式,通常是一个为方法或类的当前范围之外提供功能的总称。许多不同的编程模式使用辅助类。在我们的示例中,我们也将大量使用辅助类。这里只是一个例子。
让我们来看看用于创建窗口的非常常见的一组步骤。可以说,你将创建的大多数游戏都会有某种显示,并且通常会在不同的目标上是典型的,比如在我们的情况下是 Windows 和 macOS。不得不为每个新项目不断重复输入相同的指令似乎有点浪费。这种情况非常适合在一个帮助类中进行抽象,最终将成为引擎本身的一部分。以下代码是演示代码示例中包含的Window
类的头文件,你可以在 GitHub 代码库的Chapter03
文件夹中找到完整的源代码。
首先,我们需要一些必要的包含,SDL
,glew
是一个窗口创建辅助库,最后,标准的string
类也被包含进来:
#pragma once
#include <SDL/SDL.h>
#include <GL/glew.h>
#include <string>
接下来,我们有一个enum WindowFlags
。我们使用它来设置一些位操作,以改变窗口的显示方式;不可见、全屏或无边框。你会注意到我已经将代码放入了BookEngine
命名空间中,正如我在前一章中提到的,这对于避免发生命名冲突是必不可少的,并且一旦我们开始将我们的引擎导入项目中,这将非常有帮助:
namespace BookEngine
{
enum WindowFlags //Used for bitwise passing
{
INVISIBLE = 0x1,
FULLSCREEN = 0x2,
BORDERLESS = 0x4
};
现在我们有了Window
类本身。在这个类中有一些public
方法。首先是默认构造函数和析构函数。即使它们是空的,包括一个默认构造函数和析构函数也是一个好主意,尽管编译器包括自己的,但如果你打算创建智能或托管指针,比如unique_ptr
,这些指定的构造函数和析构函数是必需的:
class Window
{
public:
Window();
~Window();
接下来是Create
函数,这个函数将是构建或创建窗口的函数。它需要一些参数来创建窗口,比如窗口的名称、屏幕宽度和高度,以及我们想设置的任何标志,参见前面提到的enum
:
int Create(std::string windowName, int screenWidth, int
screenHeight, unsigned int currentFlags);
然后我们有两个Get
函数。这些函数将分别返回宽度和高度:
int GetScreenWidth() { return m_screenWidth; }
int GetScreenHeight() { return m_screenHeight; }
最后一个公共函数是SwapBuffer
函数;这是一个重要的函数,我们将很快深入研究它。
void SwapBuffer();
为了结束类定义,我们有一些私有变量。首先是指向SDL_Window*
类型的指针,适当命名为m_SDL_Window
。然后我们有两个持有者变量来存储屏幕的宽度和高度。这就完成了新的Window
类的定义,正如你所看到的,它在表面上非常简单。它提供了对窗口的创建的简单访问,而开发人员不需要知道实现的确切细节,这是面向对象编程和这种方法如此强大的一个方面:
private:
SDL_Window* m_SDL_Window;
int m_screenWidth;
int m_screenHeight;
};
}
为了真正理解抽象,让我们走一遍Window
类的实现,并真正看到创建窗口本身所需的所有部分:
#include ""Window.h""
#include ""Exception.h""
#include ""Logger.h""
namespace BookEngine
{
Window::Window()
{
}
Window::~Window()
{
}
Window.cpp
文件以需要的包含开始,当然,我们需要包含Window.h
,但你还会注意到我们还需要包含Exception.h
和Logger.h
头文件。这是另外两个帮助文件,用于抽象它们自己的过程。Exception.h
文件是一个帮助类,提供了一个易于使用的异常处理系统。Logger.h
文件是一个帮助类,正如其名称所示,提供了一个易于使用的日志记录系统。随意查看每一个;代码位于 GitHub 代码库的Chapter03
文件夹中。
在包含文件之后,我们再次将代码放入BookEngine
命名空间中,并为类提供空构造函数和析构函数。
Create
函数是第一个要实现的函数。在这个函数中,需要创建实际窗口的步骤。它开始设置窗口显示flags
,使用一系列if
语句来创建窗口选项的位表示。我们使用之前创建的enum
使得这对我们人类来说更容易阅读。
int Window::Create(std::string windowName, int screenWidth, int
screenHeight, unsigned int currentFlags)
{
Uint32 flags = SDL_WINDOW_OPENGL;
if (currentFlags & INVISIBLE)
{
flags |= SDL_WINDOW_HIDDEN;
}
if (currentFlags & FULLSCREEN)
{
flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
}
if (currentFlags & BORDERLESS)
{
flags |= SDL_WINDOW_BORDERLESS;
}
设置窗口的显示选项后,我们继续使用 SDL 库创建窗口。正如我之前提到的,我们使用诸如 SDL 之类的库来帮助我们简化这些结构的创建。我们开始将这些函数调用包装在try
语句中;这将允许我们捕获任何问题并将其传递给我们的Exception
类,正如我们很快将看到的那样:
try {
//Open an SDL window
m_SDL_Window = SDL_CreateWindow(windowName.c_str(),
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
screenWidth,
screenHeight,
flags);
第一行将私有成员变量m_SDL_Window
设置为使用传入的变量创建的新窗口,用于名称、宽度、高度和任何标志。我们还通过将SDL_WINDOWPOS_CENTERED
定义传递给函数,将默认窗口的生成点设置为屏幕中心:
if (m_SDL_Window == nullptr)
throw Exception(""SDL Window could not be created!"");
在尝试创建窗口之后,检查并查看进程是否成功是一个好主意。我们使用一个简单的 if 语句来检查变量m_SDL_Window
是否设置为nullptr
;如果是,我们抛出一个Exception
。我们向Exception
传递字符串""SDL Window could not be created!""
。这是我们可以在 catch 语句中打印出的错误消息。稍后,我们将看到这方面的一个例子。使用这种方法,我们提供了一些简单的错误检查。
创建窗口并进行一些错误检查后,我们可以继续设置其他一些组件。其中之一是需要设置的 OGL 库,它需要所谓的上下文。OGL 上下文可以被视为描述应用程序渲染相关细节的一组状态。在进行任何绘图之前,必须设置 OGL 上下文。
一个问题是,创建窗口和 OGL 上下文并不是 OGL 规范本身的一部分。这意味着每个平台都可以以不同的方式处理这个问题。幸运的是,SDL API 再次为我们抽象了繁重的工作,并允许我们在一行代码中完成所有这些工作。我们创建了一个名为glContext
的SDL_GLContext
变量。然后,我们将glContext
分配给SDL_GL_CreateContext
函数的返回值,该函数接受一个参数,即我们之前创建的SDL_Window
。之后,我们当然要进行简单的检查,以确保一切都按预期工作,就像我们之前创建窗口时所做的那样:
//Set up our OpenGL context
SDL_GLContext glContext = SDL_GL_CreateContext(m_SDL_Window);
if (glContext == nullptr)
throw Exception(""SDL_GL context could not be created!"");
我们需要初始化的下一个组件是GLEW
。同样,这对我们来说是一个简单的命令,glewInit()
。这个函数不带参数,但会返回一个错误状态码。我们可以使用这个状态码来执行类似于我们之前对窗口和 OGL 进行的错误检查。这次,我们不是检查它是否等于定义的GLEW_OK
。如果它的值不是GLEW_OK
,我们会抛出一个Exception
,以便稍后捕获。
//Set up GLEW (optional)
GLenum error = glewInit();
if (error != GLEW_OK)
throw Exception(""Could not initialize glew!"");
现在需要初始化的组件已经初始化,现在是记录有关运行应用程序的设备的一些信息的好时机。您可以记录有关设备的各种数据,这些数据在尝试跟踪晦涩问题时可以提供有价值的见解。在这种情况下,我正在轮询系统以获取运行应用程序的 OGL 版本,然后使用Logger
辅助类将其打印到运行时文本文件中:
//print some log info
std::string versionNumber = (const
char*)glGetString(GL_VERSION);
WriteLog(LogType::RUN, ""*** OpenGL Version: "" +
versionNumber + ""***"");
现在设置清除颜色或用于刷新图形卡的颜色。在这种情况下,它将是我们应用程序的背景颜色。glClearColor
函数接受四个浮点值,表示范围为0.0
到1.0
的红色、绿色、蓝色和 alpha 值。Alpha 是透明度值,其中1.0f
是不透明的,0.0f
是完全透明的:
//Set the background color to blue
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
下一行设置VSYNC
值,这是一种机制,它将尝试将应用程序的帧速率与物理显示器的帧速率匹配。SDL_GL_SetSwapInterval
函数接受一个参数,一个整数,可以是1
表示开启,0
表示关闭:
//Enable VSYNC
SDL_GL_SetSwapInterval(1);
组成try
语句块的最后两行,启用混合并设置执行 alpha 混合时使用的方法。有关这些特定函数的更多信息,请查看 OGL 开发文档:
//Enable alpha blend
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
在我们的try
块之后,我们现在必须包括catch
块。这是我们将捕获发生的任何抛出错误的地方。在我们的情况下,我们只会捕获所有的异常。我们使用Logger
辅助类的WriteLog
函数将异常消息e.reason
添加到错误日志文本文件中。这是一个非常基本的情况,但当然,我们在这里可以做更多的事情,可能甚至可以在可能的情况下从错误中恢复:
catch (Exception e)
{
//Write Log
WriteLog(LogType::ERROR, e.reason);
}
}
最后,在Window.cpp
文件中的最后一个函数是SwapBuffer
函数。不深入实现,交换缓冲区的作用是交换 GPU 的前后缓冲区。简而言之,这允许更流畅地绘制到屏幕上。这是一个复杂的过程,再次被 SDL 库抽象出来。我们的SwapBuffer
函数再次将这个过程抽象出来,这样当我们想要交换缓冲区时,我们只需调用SwapBuffer
而不必调用 SDL 函数并指定窗口,这正是函数中所做的:
void Window::SwapBuffer()
{
SDL_GL_SwapWindow(m_SDL_Window);
}
}
因此,正如您所看到的,构建这些辅助函数可以在开发和迭代过程中大大加快和简化。接下来,我们将看一种再次将繁重的工作抽象出来并为开发者提供对过程的控制的编程方法,即管理系统。
管理器封装
在处理诸如输入和音频系统之类的复杂系统时,直接控制和检查系统的每个状态和其他内部状态很容易变得乏味和笨拙。这就是管理器编程模式的概念所在。使用抽象和多态性,我们可以创建类,使我们能够模块化和简化与这些系统的交互。管理器类可以在许多不同的用例中找到。基本上,如果您发现需要对某个系统进行结构化控制,这可能是管理器类的候选对象。接下来是我为本书示例代码创建的管理器类的示例。随着我们的继续,您将看到更多。
暂时远离渲染系统,让我们看看任何游戏都需要执行的一个非常常见的任务,处理输入。由于每个游戏都需要某种形式的输入,将处理输入的代码移动到一个我们可以一遍又一遍使用的类中是很有意义的。让我们来看看InputManager
类,从头文件开始:
#pragma once
#include <unordered_map>
#include <glm/glm.hpp>
namespace BookEngine {
class InputManager
{
public:
InputManager();
~InputManager();
InputManager
类的开始就像其他类一样,我们需要的包括和再次将类包装在BookEngine
命名空间中以方便和安全。标准构造函数和析构函数也被定义。
接下来,我们有几个公共函数。首先是Update
函数,这将不奇怪地更新输入系统。然后我们有KeyPress
和KeyReleased
函数,这些函数都接受与键盘键对应的整数值。以下函数分别在按下或释放key
时触发:
void Update();
void KeyPress(unsigned int keyID);
void KeyRelease(unsigned int keyID);
在KeyPress
和KeyRelease
函数之后,我们还有两个与键相关的函数isKeyDown
和isKeyPressed
。与KeyPress
和KeyRelease
函数一样,isKeyDown
和isKeyPressed
函数接受与键盘键对应的整数值。显着的区别是这些函数根据键的状态返回布尔值。我们将在接下来的实现文件中看到更多关于这一点的内容。
bool isKeyDown(unsigned int keyID); //Returns true if key is
held bool isKeyPressed(unsigned int keyID); //Returns true if key
was pressed this update
InputManager
类中的最后两个公共函数是SetMouseCoords
和GetMouseCoords
,它们确实如其名称所示,分别设置或获取鼠标坐标。
void SetMouseCoords(float x, float y);
glm::vec2 GetMouseCoords() const { return m_mouseCoords; };
接下来是私有成员和函数,我们声明了一些变量来存储有关键和鼠标的一些信息。首先,我们有一个布尔值,用于存储按下键的状态。接下来,我们有两个无序映射,它们将存储当前的keymap
和先前的键映射。我们存储的最后一个值是鼠标坐标。我们使用另一个辅助库OpenGL Mathematics(GLM)中的vec2
构造。我们使用这个vec2
,它只是一个二维向量,来存储鼠标光标的x和y坐标值,因为它在一个二维平面上,即屏幕上。如果你想要复习向量和笛卡尔坐标系,我强烈推荐Dr. John P Flynt的Beginning Math Concepts for Game Developers一书:
private:
bool WasKeyDown(unsigned int keyID);
std::unordered_map<unsigned int, bool> m_keyMap;
std::unordered_map<unsigned int, bool> m_previousKeyMap;
glm::vec2 m_mouseCoords;
};
现在让我们看一下实现,InputManager.cpp
文件。
我们再次从包含和命名空间包装器开始。然后我们有构造函数和析构函数。这里需要注意的亮点是在构造函数中将m_mouseCoords
设置为0.0f
:
namespace BookEngine
{
InputManager::InputManager() : m_mouseCoords(0.0f)
{
}
InputManager::~InputManager()
{
}
接下来是Update
函数。这是一个简单的更新,我们正在遍历keyMap
中的每个键,并将其复制到先前的keyMap
持有者中。
m_previousKeyMap
:
void InputManager::Update()
{
for (auto& iter : m_keyMap)
{
m_previousKeyMap[iter.first] = iter.second;
}
}
接下来是KeyPress
函数。在这个函数中,我们使用关联数组的技巧来测试和插入与传入 ID 匹配的按下的键。技巧在于,如果位于keyID
索引处的项目不存在,它将自动创建:
void InputManager::KeyPress(unsigned int keyID)
{
m_keyMap[keyID] = true;
}
. We do the same for the KeyRelease function below.
void InputManager::KeyRelease(unsigned int keyID)
{
m_keyMap[keyID] = false;
}
KeyRelease
函数与KeyPressed
函数的设置相同,只是我们将keyMap
中的项目在keyID
索引处设置为 false:
bool InputManager::isKeyDown(unsigned int keyID)
{
auto key = m_keyMap.find(keyID);
if (key != m_keyMap.end())
return key->second; // Found the key
return false;
}
在KeyPress
和KeyRelease
函数之后,我们实现了isKeyDown
和isKeyPressed
函数。首先是isKeydown
函数;在这里,我们想测试键是否已经按下。在这种情况下,我们采用了与KeyPress
和KeyRelease
函数中不同的方法来测试键,并避免了关联数组的技巧。这是因为我们不想在键不存在时创建一个键,所以我们手动进行:
bool InputManager::isKeyPressed(unsigned int keyID)
{
if(isKeyDown(keyID) && !m_wasKeyDown(keyID))
{
return true;
}
return false;
}
isKeyPressed
函数非常简单。在这里,我们测试与传入 ID 匹配的键是否被按下,通过使用isKeyDown
函数,并且它还没有被m_wasKeyDown
按下。如果这两个条件都满足,我们返回 true,否则返回 false。接下来是WasKeyDown
函数,与isKeyDown
函数类似,我们进行手动查找,以避免意外创建对象使用关联数组的技巧:
bool InputManager::WasKeyDown(unsigned int keyID)
{
auto key = m_previousKeyMap.find(keyID);
if (key != m_previousKeyMap.end())
return key->second; // Found the key
return false;
}
InputManager
中的最后一个函数是SetMouseCoords
。这是一个非常简单的Set
函数,它接受传入的浮点数并将它们分配给二维向量m_mouseCoords
的x和y成员:
void InputManager::SetMouseCoords(float x, float y)
{
m_mouseCoords.x = x;
m_mouseCoords.y = y;
}
}
创建接口
有时你会面临这样一种情况,你需要描述一个类的能力并提供对一般行为的访问,而不承诺特定的实现。这就是接口或抽象类的概念发挥作用的地方。使用接口提供了一个简单的基类,其他类可以继承而不必担心内在的细节。构建强大的接口可以通过提供一个标准的类来与之交互来实现快速开发。虽然理论上接口可以创建任何类,但更常见的是在代码经常被重用的情况下使用它们。以下是书中示例代码创建的一个接口,用于创建游戏的主类的接口。
让我们看一下存储库中示例代码的接口。这个接口将提供对游戏的核心组件的访问。我将这个类命名为IGame
,使用前缀I
来标识这个类是一个接口。以下是从定义文件IGame.h
开始的实现。
首先,我们有所需的包含和命名空间包装器。您会注意到,我们包含的文件是我们刚刚创建的一些文件。这是抽象的延续的一个典型例子。我们使用这些构建模块来继续构建结构,以便实现无缝的抽象:
#pragma once
#include <memory>
#include ""BookEngine.h""
#include ""Window.h""
#include ""InputManager.h""
#include ""ScreenList.h""
namespace BookEngine
{
接下来,我们有一个前向声明。这个声明是为另一个为屏幕创建的接口。这个接口及其支持的辅助类的完整源代码都可以在代码存储库中找到。类IScreen
;在 C++中使用这样的前向声明是一种常见的做法。
如果定义文件只需要简单定义一个类,而不添加该类的头文件,将加快编译时间。
接下来是公共成员和函数,我们从构造函数和析构函数开始。您会注意到,在这种情况下,这个析构函数是虚拟的。我们将析构函数设置为虚拟的,以便通过指针调用派生类的实例上的 delete。当我们希望接口直接处理一些清理工作时,这很方便:
class IGame
{
public:
IGame();
virtual ~IGame();
接下来我们有Run
函数和ExitGame
函数的声明。
void Run();
void ExitGame();
然后我们有一些纯虚函数,OnInit
,OnExit
和AddScreens
。纯虚函数是必须由继承类重写的函数。通过在定义的末尾添加=0;
,我们告诉编译器这些函数是纯虚的。
在设计接口时,定义必须被重写的函数时要谨慎。还要注意,拥有纯虚函数会使其所定义的类成为抽象类。抽象类不能直接实例化,因此任何派生类都需要实现所有继承的纯虚函数。如果不这样做,它们也会变成抽象类:
virtual void OnInit() = 0;
virtual void OnExit() = 0;
virtual void AddScreens() = 0;
在纯虚函数声明之后,我们有一个名为OnSDLEvent
的函数,我们用它来连接到 SDL 事件系统。这为我们提供了对输入和其他事件驱动系统的支持:
void OnSDLEvent(SDL_Event& event);
IGame
接口类中的公共函数是一个简单的辅助函数GetFPS
,它返回当前的fps
。注意const
修饰符,它们快速标识出这个函数不会以任何方式修改变量的值:
const float GetFPS() const { return m_fps; }
在我们的受保护空间中,我们首先有一些函数声明。首先是Init
或初始化函数。这将是处理大部分设置的函数。然后我们有两个虚函数Update
和Draw
。
像纯虚函数一样,虚函数是可以被派生类实现的函数。与纯虚函数不同,虚函数默认不会使类成为抽象类,也不必被重写。虚函数和纯虚函数是多态设计的基石。随着开发的继续,您将很快看到它们的好处:
protected:
bool Init();
virtual void Update();
virtual void Draw();
在IGame
定义文件中,我们有一些成员来存放不同的对象和值。我不打算逐行讨论这些成员,因为我觉得它们相当容易理解:
std::unique_ptr<ScreenList> m_screenList = nullptr;
IGameScreen* m_currentScreen = nullptr;
Window m_window;
InputManager m_inputManager;
bool m_isRunning = false;
float m_fps = 0.0f;
};
}
现在我们已经看过了接口类的定义,让我们快速浏览一下实现。以下是IGame.cpp
文件。为了节省时间和空间,我将重点介绍关键点。在大多数情况下,代码是不言自明的,存储库中的源代码有更多的注释以提供更清晰的解释:
#include ""IGame.h""
#include ""IScreen.h""
#include ""ScreenList.h""
#include ""Timing.h""
namespace BookEngine
{
IGame::IGame()
{
m_screenList = std::make_unique<ScreenList>(this);
}
IGame::~IGame()
{
}
我们的实现从构造函数和析构函数开始。构造函数很简单,它的唯一工作是使用这个IGame
对象作为参数添加一个新屏幕的唯一指针。有关屏幕创建的更多信息,请参阅IScreen
类。接下来,我们实现了Run
函数。当调用这个函数时,将启动引擎。在函数内部,我们快速检查以确保我们已经初始化了对象。然后,我们再次使用另一个助手类fpsLimiter
来SetMaxFPS
,让我们的游戏可以运行。之后,我们将isRunning
布尔值设置为true
,然后用它来控制游戏循环:
void IGame::Run()
{
if (!Init())
return;
FPSLimiter fpsLimiter;
fpsLimiter.SetMaxFPS(60.0f);
m_isRunning = true;
接下来是游戏循环。在游戏循环中,我们进行了一些简单的调用。首先,我们启动了fpsLimiter
。然后,我们在我们的InputManager
上调用更新函数。
在进行其他更新或绘图之前,始终检查输入是一个好主意,因为它们的计算肯定会使用新的输入值。
在更新InputManager
之后,我们递归调用我们的Update
和Draw
类,我们很快就会看到。我们通过结束fpsLimiter
函数并在Window
对象上调用SwapBuffer
来结束循环:
///Game Loop
while (m_isRunning)
{
fpsLimiter.Begin();
m_inputManager.Update();
Update();
Draw();
m_fps = fpsLimiter.End();
m_window.SwapBuffer();
}
}
我们实现的下一个函数是ExitGame
函数。最终,这将是在游戏最终退出时调用的函数。我们关闭,销毁,并释放屏幕列表创建的任何内存,并将isRunning
布尔值设置为false
,这将结束循环:
void IGame::ExitGame()
{
m_currentScreen->OnExit();
if (m_screenList)
{
m_screenList->Destroy();
m_screenList.reset(); //Free memory
}
m_isRunning = false;
}
接下来是Init
函数。这个函数将初始化所有内部对象设置,并调用连接系统的初始化。同样,这是面向对象编程和多态性的一个很好的例子。以这种方式处理初始化允许级联效应,使代码模块化,并更容易修改:
bool IGame::Init()
{
BookEngine::Init();
SDL_GL_SetAttribute(SDL_GL_ACCELERATED_VISUAL, 1);
m_window.Create(""BookEngine"", 1024, 780, 0);
OnInit();
AddScreens();
m_currentScreen = m_screenList->GetCurrentScreen();
m_currentScreen->OnEntry();
m_currentScreen->Run();
return true;
}
接下来是Update
函数。在这个Update
函数中,我们创建一个结构,允许我们根据当前屏幕所处的状态执行特定的代码。我们使用一个简单的 switch case 方法和ScreenState
类型的枚举元素作为 case 来实现这一点。这种设置被认为是一个简单的有限状态机,是游戏开发中使用的一种非常强大的设计方法。你可以肯定会在整本书的示例中再次看到它:
void IGame::Update()
{
if (m_currentScreen)
{
switch (m_currentScreen->GetScreenState())
{
case ScreenState::RUNNING:
m_currentScreen->Update();
break;
case ScreenState::CHANGE_NEXT:
m_currentScreen->OnExit();
m_currentScreen = m_screenList->MoveToNextScreen();
if (m_currentScreen)
{
m_currentScreen->Run();
m_currentScreen->OnEntry();
}
break;
case ScreenState::CHANGE_PREVIOUS:
m_currentScreen->OnExit();
m_currentScreen = m_screenList->MoveToPreviousScreen();
if (m_currentScreen)
{
m_currentScreen->Run();
m_currentScreen->OnEntry();
}
break;
case ScreenState::EXIT_APP:
ExitGame();
break;
default:
break;
}
}
else
{
//we have no screen so exit
ExitGame();
}
}
在我们的Update
之后,我们实现了Draw
函数。在我们的函数中,我们只做了一些事情。首先,我们将Viewport
重置为一个简单的安全检查,然后如果当前屏幕的状态与枚举值RUNNING
匹配,我们再次使用多态性将Draw
调用传递到对象行:
void IGame::Draw()
{
//For safety
glViewport(0, 0, m_window.GetScreenWidth(), m_window.GetScreenHeight());
//Check if we have a screen and that the screen is running
if (m_currentScreen &&
m_currentScreen->GetScreenState() == ScreenState::RUNNING)
{
m_currentScreen->Draw();
}
}
我们需要实现的最后一个函数是OnSDLEvent
函数。就像我在这个类的定义部分提到的那样,我们将使用这个函数将我们的InputManager
系统连接到 SDL 内置的事件系统。
每次按键或鼠标移动都被视为一个事件。根据发生的事件类型,我们再次使用 switch case 语句创建一个简单的有限状态机。请参考前面的管理模式讨论部分,了解每个函数是如何实现的。
void IGame::OnSDLEvent(SDL_Event & event)
{
switch (event.type) {
case SDL_QUIT:
m_isRunning = false;
break;
case SDL_MOUSEMOTION:
m_inputManager.SetMouseCoords((float)event.motion.x,
(float)event.motion.y);
break;
case SDL_KEYDOWN:
m_inputManager.KeyPress(event.key.keysym.sym);
break;
case SDL_KEYUP:
m_inputManager.KeyRelease(event.key.keysym.sym);
break;
case SDL_MOUSEBUTTONDOWN:
m_inputManager.KeyPress(event.button.button);
break;
case SDL_MOUSEBUTTONUP:
m_inputManager.KeyRelease(event.button.button);
break;
}
}
}
好了,这就完成了IGame
接口。有了这个创建,我们现在可以创建一个新的项目,利用这个和其他接口在示例引擎中创建一个游戏,并只需几行代码就可以初始化它。这是位于代码存储库的Chapter03
文件夹中示例项目的App
类:
#pragma once
#include <BookEngine/IGame.h>
#include ""GamePlayScreen.h""
class App : public BookEngine::IGame
{
public:
App();
~App();
virtual void OnInit() override;
virtual void OnExit() override;
virtual void AddScreens() override;
private:
std::unique_ptr<GameplayScreen> m_gameplayScreen = nullptr;
};
这里需要注意的亮点是,一,App
类继承自BookEngine::IGame
接口,二,我们拥有继承类所需的所有必要覆盖。接下来,如果我们看一下main.cpp
文件,我们的应用程序的入口点,你会看到设置和启动所有我们接口、管理器和助手抽象的简单命令:
#include <BookEngine/IGame.h>
#include ""App.h""
int main(int argc, char** argv)
{
App app;
app.Run();
return 0;
}
正如您所看到的,这比每次创建新项目时不断从头开始重新创建框架要简单得多。
要查看本章描述的框架的输出,请构建BookEngine
项目,然后构建并运行示例项目。XCode 和 Visual Studio 项目可以在 GitHub 代码存储库的Chapter03
文件夹中找到。
在 Windows 上,运行示例项目将如下所示:
在 macOS 上,运行示例项目将如下所示:
摘要
在本章中,我们涵盖了相当多的内容。我们看了一下使用面向对象编程和多态性创建可重用结构的不同方法。我们通过真实代码示例详细介绍了辅助、管理器和接口类之间的区别。
在接下来的章节中,我们将看到这种结构被重复使用并不断完善以创建演示。事实上,在下一章中,我们将构建更多的管理器和辅助类,以创建资产管理流水线。