精通-C++-游戏开发(全)

精通 C++ 游戏开发(全)

原文:annas-archive.org/md5/C9DEE6A3AC368562ED493911597C48C0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

尽管现在许多语言被用于开发游戏,但 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 并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册 www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • 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类继承。问题出现在继承树的上一层,BossFlyingEnemy类都从Enemy类继承。这在类层次结构中创建了可怕的死亡之钻模式。这可能看起来不是什么大问题,但是这种模式会导致一些不幸的问题。首先是再次出现歧义的问题。每当您尝试从FlyingBoss类访问Enemy类的任何成员变量或函数时,都会出现歧义。这是因为每个变量和函数都有多条路径。为了解决这个问题,我们可以通过再次使用作用域运算符(::)来指定我们想要遵循的路径。死亡之钻模式引起的另一个问题是重复的问题。当我们创建一个FlyingBoss对象时,它将拥有从Boss类继承的一切的两个副本。这是因为FlyingEnemyBoss类都有从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_ptrshared_ptr)通常在更现代的游戏开发库中使用。

unique_ptr 和 shared_ptr

unique_ptr或唯一指针被认为是智能指针。之所以称其为唯一,是因为这种类型的对象拥有其指针的唯一所有权。这意味着没有两个unique_ptr指针可以管理相同的对象,它是唯一的。unique_ptr的最大优势之一是它管理自己的生命周期。这意味着当指针超出范围时,它会自动销毁自身并释放其内存。这解决了可怕的悬空指针问题,并避免了内存泄漏。这也消除了所有权的问题,因为现在明确了谁删除了指针。

自 C++14 标准以来,我们现在可以使用一个方便的小函数来创建唯一指针,make_uniquemake_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对象而已。

使用堆栈的一个好处是,当对象或变量超出范围时,我们分配的任何内存都将被清理。然而,这可能是一把双刃剑;许多新开发人员遇到的问题是,他们在对象超出范围后仍然查找或调用对象,因为他们使用堆栈来存储它们。堆栈的另一个问题是其大小受限,这取决于平台和编译器设置。如果创建了大量对象并长时间保存,这可能会成为一个问题。尝试分配超出堆栈可用内存的内存将引发运行时错误。

另一种选择是堆,你可以将其视为一大块或一大容器的内存。与堆栈不同,这个内存堆是无序的,很容易变得碎片化。好消息是,现代内存和操作系统实现提供了一种低级机制来处理这种碎片化,通常称为内存虚拟化。这种虚拟化的另一个好处是,它提供了对比物理内存更多的堆存储的访问权限,通过在需要时将内存交换到硬盘。要在堆上分配和销毁内存,你可以使用关键字newdelete,以及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作为floatintdouble变量,并在通用函数中使用它,例如,计算给定半径的圆的面积:

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();返回的对象类型可以提供更大的灵活性。为了推断函数返回的对象类型,我们可以使用autodecltype和新的return语法的概念。我们的新函数现在看起来像这样:

template <typename ObjectFactory > 
auto CreateObject(const ObjectFactory &objFactory) -> decltype (objFactory.makeObject()) 
{ 
  auto obj = objFactory.MakeObject(); 
  return obj; 
} 

还要注意的是,autodecltype会增加我们的编译时间开销。在大多数情况下,这将是微不足道的,但在某些情况下可能会成为一个问题,因此在将这些新关键字纳入您的代码库时要意识到这一点。

随着您继续构建更多的库和工具集,构建更通用、灵活的代码的能力将变得至关重要。像使用autodecltype和新的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变量,它是自上次循环步骤以来经过的时间。然后我们调用游戏的其他组件:InputUpdateDraw。这是游戏循环模式的关键;我们使用这个标准的运行循环来推动游戏向前发展。你可能会注意到我们将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结构。这种实现的关键在于机器的每个状态都有一个到下一个状态的转换状态。这保持了机器的运行,并允许我们根据机器当前的状态执行不同的操作。虽然这可能是游戏状态机的最基本形式之一,但它确实展示了有限状态模式的用处。当你开始创建库和其他组件时,你会开始看到这些令人难以置信的工具的更多用途。还有许多其他更复杂的实现和更多的模式来帮助描述它们。这些将在本书的后面章节中看到。

事件监听器

在游戏开发过程中经常会遇到这样的情况,即根据用户输入或来自其他代码块触发的条件执行某些代码。也许你只是需要一种可靠的方式让游戏对象进行通信。这就是使用事件或消息传递系统的想法产生的地方。已经创建了许多模式来帮助解决这个问题,包括OverseerModel 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 通用容器。模板化的概念以及使用类、函数和变量模板构建通用代码。类型推断和新语言关键字autodecltype以及它们与新的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 上

按照以下步骤进行操作:

  1. 对于 Windows,您需要输入以下命令:
    cl /c hello.cpp

cl是编译和链接的命令。/c告诉编译器我们只想编译而不链接我们的文件。最后,我们传入要编译的文件。这将创建一个对象文件hello.obj,然后我们可以使用它来创建我们的静态库文件。

  1. 现在我们已经创建了对象文件,我们可以使用库构建工具创建静态库。我们使用以下命令生成.lib文件:
    lib /out:MyLib.lib hello.obj

lib是启动构建工具的命令。/out:MyLib.lib告诉编译器将库构建命名为MyLib.lib

  1. 如果列出目录的内容,您会看到我们现在有了静态库MyLib.lib

  1. 我们现在可以在其他项目中使用我们新创建的库。让我们创建一个非常简单的程序来使用我们的库:
void Hello(); //Forward declaration of our Hello function 
void main() 
{ 
  Hello(); 
} 

我将文件保存为main.cpp

该程序将调用Hello函数,编译器将在我们的链接库中寻找实现。

  1. 要编译此程序并链接我们的静态库,可以使用以下命令:
    cl main.cpp /link MyLib.lib
  1. 编译完成后,我们现在在 Windows 目录中有一个main.exe

在 macOS X 上

按照以下步骤进行操作:

  1. 对于 macOS X,您需要输入以下命令:
    g++ -c hello.cpp 

g++是我们使用的开源编译器。标志-c告诉g++输出一个对象文件。在标志之后,我们指定了构建对象文件时要使用的 cpp 文件。此命令将生成文件hello.o

  1. 在 macOS X 平台上,我们使用以下命令生成.a文件:
    arrvsMylib.ahello.o

ar是我们用来创建静态库的库构建工具。首先我们设置了一些标志,rvs,告诉ar工具如何设置库存档。然后我们告诉工具我们正在创建的库的名称,然后是组成库的对象文件。

如果列出目录的内容,您会看到我们现在有了静态库Mylib.a

  1. 我们现在可以在其他项目中使用我们新创建的库。让我们创建一个非常简单的程序来使用我们的库:
void Hello(); //Forward declaration of our Hello function 
void main() 
{ 
  Hello(); 
} 

我将文件保存为main.cpp

该程序将调用Hello函数,编译器将在我们的链接库中寻找实现。

  1. 我们使用以下命令编译程序并链接我们的静态库:
    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 上编译和链接动态库的步骤:

  1. hello.cpp文件现在看起来是这样的:
      #include <iostream> 
      __declspec(dllexport) void Hello() 
      { 
        std::cout<< "Hello World Dynamically" <<std::endl; 
      } 

现在我们已经指定了要导出的函数,我们可以将文件编译成一个动态共享库。

  1. 在 Windows 上,我们可以使用以下命令从开发者控制台提示符创建一个.dll
    cl /LD /FeMyDynamicLib.dll hello.cpp

再次,cl是启动编译器和链接器的命令。/LD告诉编译器我们要创建一个动态链接库。/FeMyDynamicLib.dll设置库的名称,/Fe是编译器选项,MyDynamicLib.dll是名称。最后,再次传入我们要使用的文件。

  1. 当编译器完成后,我们列出目录,现在将有MyDynamicLib.libMyDynamicLib.dll两个文件:

你可能已经注意到的第一件事是,这个版本的.lib文件比之前的静态库示例要小得多。这是因为实现不存储在这个文件中。相反,它充当指向.dll文件中实际实现的指针。

  1. 接下来,我们可以使用以下命令(在 Windows 上)链接和构建我们的程序与我们新创建的库,就像前面的例子一样:
    cl main.cpp /link MyDynamicLib.lib  
  1. 所以现在如果我们运行程序,会看到显示Hello World Dynamically!这一行:

如果我们现在列出目录,会注意到新的主可执行文件,就像这个例子中的.lib文件一样,比使用静态库的上一个版本要小得多。这是因为我们在构建时没有包含库的所需部分。相反,我们在运行时按需加载它们,动态地:

  1. 我之前提到的一个好处是,当您对动态链接库进行更改时,您不必重新编译整个程序;我们只需要重新编译库。为了看到这一点,让我们对hello.cpp文件进行一些小改动:
   #include <iostream> 
   __declspec(dllexport) void Hello() 
   { 
     std::cout<< "Hello World Dynamically!"<<std::endl; 
     std::cout<< "Version 2" <<std::endl; 
   } 
  1. 接下来,我们可以使用与之前相同的命令重新编译我们的库:
    cl /LD /FeMyDynamicLib.dll hello.cpp
  1. 这将添加我们的新更改,我们可以看到它们在不重新编译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,这些是dllexportdllimport。这是使用宏的标准方式,使得导出和导入的过程更加简单。有了这些宏,包含此文件的任何项目将看到暴露的函数被导入,而动态库将看到使用此宏定义的任何内容被导出。这意味着我们现在可以简单地使用EXPORT来代替在动态库中指定应该提供给其他人的内容时使用的_declspec(dllexport)__attribute__((visibility("default")))

创建内存管理器的下一步是创建一对struct,用于我们的BlockHeap对象。一个块是我们将存储单个对象的内存切片或块。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); 
  } 
}; 

现在我们已经有了HeapBlock结构,我们可以继续定义实际的内存管理器类CMemoryMgr。这就是我们之前创建的定义派上用场的地方。在这种情况下,我们使用EXPORT来指定我们希望整个类在动态库中被导出。当我们以这种方式导出类时,类的访问方式与任何其他类完全相同。这意味着所有的privateprotectedpublic对象继续具有相同的访问权限。

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,这是我们用来在内存中移动对象的变量。

在内存中移动对象意味着我们将始终使用一定量的内存来保存对象,无论大小如何。我们这样做是为了使对象以这样的方式对齐,以便我们可以减少对实际内存硬件的调用次数,这当然会提高性能。这通常是开发人员使用自定义内存管理器的主要原因。

接下来,我们有一个不带参数的析构函数,然后是AllocateDeallocateDeallocateAll,它们确切地执行它们的名字所暗示的操作。唯一带有参数的函数是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中下一个链接的blockOverWriteHeap函数接受一个指向将写入特定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.dllMemoryMgr.lib文件,分别位于 Debug 或 Release 下,具体取决于所选的构建设置。我们构建库的另一种方法是使用我们在本章第一部分讨论的开发人员命令行工具。在这种情况下,我们可以简单地更改目录到项目文件并使用cl命令以包括库名称和输入文件:

cl /LD /FeMemoryMgr.dll MemoryMgr.cpp

同样,这将创建MemoryMgr.dllMemoryMgr.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 

这将告诉编译器我们希望导出这三个函数:AllocateDeallocateDeallocateAll。将文件保存为.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文件夹中找到完整的源代码。

首先,我们需要一些必要的包含,SDLglew是一个窗口创建辅助库,最后,标准的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.hLogger.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 再次为我们抽象了繁重的工作,并允许我们在一行代码中完成所有这些工作。我们创建了一个名为glContextSDL_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.01.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函数,这将不奇怪地更新输入系统。然后我们有KeyPressKeyReleased函数,这些函数都接受与键盘键对应的整数值。以下函数分别在按下或释放key时触发:

void Update(); 
void KeyPress(unsigned int keyID);  
void KeyRelease(unsigned int keyID);

KeyPressKeyRelease函数之后,我们还有两个与键相关的函数isKeyDownisKeyPressed。与KeyPressKeyRelease函数一样,isKeyDownisKeyPressed函数接受与键盘键对应的整数值。显着的区别是这些函数根据键的状态返回布尔值。我们将在接下来的实现文件中看到更多关于这一点的内容。

 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类中的最后两个公共函数是SetMouseCoordsGetMouseCoords,它们确实如其名称所示,分别设置或获取鼠标坐标。

void SetMouseCoords(float x, float y); 
glm::vec2 GetMouseCoords() const { return m_mouseCoords; }; 

接下来是私有成员和函数,我们声明了一些变量来存储有关键和鼠标的一些信息。首先,我们有一个布尔值,用于存储按下键的状态。接下来,我们有两个无序映射,它们将存储当前的keymap和先前的键映射。我们存储的最后一个值是鼠标坐标。我们使用另一个辅助库OpenGL MathematicsGLM)中的vec2构造。我们使用这个vec2,它只是一个二维向量,来存储鼠标光标的xy坐标值,因为它在一个二维平面上,即屏幕上。如果你想要复习向量和笛卡尔坐标系,我强烈推荐Dr. John P FlyntBeginning 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; 
 }

KeyPressKeyRelease函数之后,我们实现了isKeyDownisKeyPressed函数。首先是isKeydown函数;在这里,我们想测试键是否已经按下。在这种情况下,我们采用了与KeyPressKeyRelease函数中不同的方法来测试键,并避免了关联数组的技巧。这是因为我们不想在键不存在时创建一个键,所以我们手动进行:

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_mouseCoordsxy成员:

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(); 

然后我们有一些纯虚函数,OnInitOnExitAddScreens。纯虚函数是必须由继承类重写的函数。通过在定义的末尾添加=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或初始化函数。这将是处理大部分设置的函数。然后我们有两个虚函数UpdateDraw

像纯虚函数一样,虚函数是可以被派生类实现的函数。与纯虚函数不同,虚函数默认不会使类成为抽象类,也不必被重写。虚函数和纯虚函数是多态设计的基石。随着开发的继续,您将很快看到它们的好处:

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函数。当调用这个函数时,将启动引擎。在函数内部,我们快速检查以确保我们已经初始化了对象。然后,我们再次使用另一个助手类fpsLimiterSetMaxFPS,让我们的游戏可以运行。之后,我们将isRunning布尔值设置为true,然后用它来控制游戏循环:

void IGame::Run() 
  { 
    if (!Init()) 
      return; 
    FPSLimiter fpsLimiter; 
    fpsLimiter.SetMaxFPS(60.0f); 
    m_isRunning = true; 

接下来是游戏循环。在游戏循环中,我们进行了一些简单的调用。首先,我们启动了fpsLimiter。然后,我们在我们的InputManager上调用更新函数。

在进行其他更新或绘图之前,始终检查输入是一个好主意,因为它们的计算肯定会使用新的输入值。

在更新InputManager之后,我们递归调用我们的UpdateDraw类,我们很快就会看到。我们通过结束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 上,运行示例项目将如下所示:

摘要

在本章中,我们涵盖了相当多的内容。我们看了一下使用面向对象编程和多态性创建可重用结构的不同方法。我们通过真实代码示例详细介绍了辅助、管理器和接口类之间的区别。

在接下来的章节中,我们将看到这种结构被重复使用并不断完善以创建演示。事实上,在下一章中,我们将构建更多的管理器和辅助类,以创建资产管理流水线。

第四章:构建资产流水线

游戏本质上是以有趣和引人入胜的方式打包的资产或内容的集合。处理视频游戏所需的所有内容本身就是一个巨大的挑战。在任何真正的项目中,都需要一个结构来导入、转换和使用这些资产。在本章中,我们将探讨开发和实施资产流水线的主题。以下是我们将要涵盖的主题:

  • 处理音频

  • 处理图像

  • 导入模型网格

什么是资产流水线?

在第三章中,构建坚实的基础,我们看了一下如何使用辅助和管理类的结构,将多个方法封装成易于消费的接口,以处理项目的各个部分。在接下来的几节中,我们将使用这些技术来构建我们自己的自定义框架/内容流水线。

处理音频

为了开始,我们将通过查看如何处理游戏项目中的音频资产来逐步进入这个过程。为了帮助我们进行这个过程,我们将再次使用一个辅助库。有数百种不同的库可以帮助使用音频。以下是一些较受欢迎的选择:

每个库都有其自身的优势和劣势。为您的项目选择合适的库归结为您应该问自己的几个不同问题。

这个库是否满足你的技术需求?它是否具有你想要的所有功能?

它是否符合项目的预算限制?许多更强大的库都有很高的价格标签。

这个库的学习曲线是否在你或团队的技能范围内?选择一个带有许多酷炫功能的高级 API 可能看起来是个好主意,但如果你花费更多时间来理解 API 而不是实施它,那可能是有害的。

在本书的示例中,我选择使用SDL_mixer API有几个原因。首先,与其他一些库相比,它相对容易上手。其次,它非常符合我的项目需求。它支持 FLAC、MP3,甚至 Ogg Vorbis 文件。第三,它与项目框架的其余部分连接良好,因为它是 SDL 库的扩展,而我们已经在使用。最后,我选择这个 API 是因为它是开源的,而且有一个简单的许可证,不需要我支付创建者我的游戏收益的一部分来使用该库。

让我们首先看一下我们需要的几个不同类的声明和实现。我们要看的文件是AudioManager.h文件,可以在代码库的Chapter04文件夹中找到。

我们从必要的包含开始,SDL/SDL_mixer.hstringmap的实现。与所有其他引擎组件一样,我们将这些声明封装在BookEngine命名空间中:

#pragma once 
#include <SDL/SDL_mixer.h> 
#include <string> 
#include <map> 

namespace BookEngine 
{

"AudioManager.h"文件中,我们声明了一些辅助类。第一个是SoundEffect类。这个类定义了游戏中要使用的音效对象的结构:

class SoundEffect 
 { 
  public: 
    friend class AudioManager; 
    ///Plays the sound file 
    ///@param numOfLoops: If == -1, loop forever, 
    ///otherwise loop of number times provided + 1 
    void Play(int numOfLoops = 0); 

  private: 
    Mix_Chunk* m_chunk = nullptr; 
  }; 

这些可以包括玩家跳跃、武器开火等声音,以及我们将在短暂时间内播放的任何声音。

在类定义内部,我们需要一个friend类语句,允许这个类访问AudioManager类的方法和变量,包括私有的。接下来是Play函数的定义。这个函数将简单地播放音效,并只接受一个参数,循环播放声音的次数。默认情况下,我们将其设置为0,如果您将循环次数设置为-1,它将无限循环播放音效。最后一个定义是一个Mix_Chunk类型的私有变量。Mix_Chunk是一个SDL_mixer对象类型,它在内存中存储音频数据。

Mix_Chunk对象的结构如下:

typedef struct { 
        int allocated; 
        Uint8 *abuf; 
        Uint32 alen; 
        Uint8 volume; 
} Mix_Chunk; 

这是对象的内部:

  • allocated:如果设置为1struct有自己的分配缓冲区

  • abuf:这是指向音频数据的指针

  • alen:这是音频数据的长度,以字节为单位

  • volume:这是每个样本的音量值,介于 0 和 128 之间

我们在AudioManager.h文件中的下一个辅助类是Music类。像音效一样,Music类定义了Music对象的结构。这可以用于像加载屏幕音乐、背景音乐和任何我们希望长时间播放或需要停止、开始和暂停的声音:

class Music 
  { 
  public: 
    friend class AudioManager; 
    ///Plays the music file 
    ///@param numOfLoops: If == -1, loop forever, 
    ///otherwise loop of number times provided 
    void Play(int numOfLoops = -1); 

    static void Pause() { Mix_PauseMusic(); }; 
    static void Stop() { Mix_HaltMusic(); }; 
    static void Resume() { Mix_ResumeMusic(); }; 

  private: 
    Mix_Music* m_music = nullptr; 
  }; 

对于类定义,我们再次从一个friend类语句开始,以便Music类可以访问AudioManager类的所需部分。接下来是一个Play函数,就像SoundEffect类一样,它接受一个参数来设置声音循环的次数。在Play函数之后,我们有另外三个函数,Pause()Stop()Resume()函数。这三个函数只是对底层 SDL_mixer API 调用的包装,用于暂停、停止和恢复音乐。

最后,我们有一个Mix_Music对象的私有声明。Mix_Music是用于音乐数据的 SDL_mixer 数据类型。它支持加载 WAV、MOD、MID、OGG 和 MP3 音频文件。我们将在接下来的实现部分中了解更多关于这个的信息:

class AudioManager 
  { 
  public: 
    AudioManager(); 
    ~AudioManager(); 

    void Init(); 
    void Destroy(); 

    SoundEffect LoadSoundEffect(const std::string& filePath); 
    Music LoadMusicEffect(const std::string& filePath); 
  private: 
    std::map<std::string, Mix_Chunk*> m_effectList; 
    std::map<std::string, Mix_Music*> m_musicList; 
    bool m_isInitialized = false; 
  }; 
} 

在两个MusicSoundEffect辅助类之后,我们现在来到AudioManager类的定义。AudioManager类将在我们这一边承担大部分繁重的工作,它将加载、保存和管理所有音乐和音效的创建和删除。

我们的类声明像大多数其他类一样以默认构造函数和析构函数开始。接下来是一个Init()函数。这个函数将处理音频系统的设置或初始化。然后是一个Destroy()函数,它将处理音频系统的删除和清理。在InitDestroy函数之后,我们有两个加载函数,LoadSoundEffect()LoadMusicEffent()函数。这两个函数都接受一个参数,一个标准字符串,其中包含音频文件的路径。这些函数将加载音频文件,并根据函数返回SoundEffectMusic对象。

我们的类的私有部分有三个对象。前两个私有对象是Mix_ChunkMix_Music类型的映射。这是我们将存储所有需要的效果和音乐的地方。通过存储我们加载的音效和音乐文件列表,我们创建了一个缓存。如果在项目的以后时间需要文件,我们可以检查这些列表并节省一些宝贵的加载时间。最后一个变量m_isInitialized保存一个布尔值,指定AudioManager类是否已经初始化。

这完成了AudioManager和辅助类的声明,让我们继续实现,我们可以更仔细地查看一些函数。您可以在代码存储库的Chapter04文件夹中找到AudioManager.cpp文件:

#include "AudioManager.h"
#include "Exception.h" 
#include "Logger.h"

namespace BookEngine 
{ 

  AudioManager::AudioManager() 
  { 
  } 

  AudioManager::~AudioManager() 
  { 
    Destroy(); 
  } 

我们的实现从包括、默认构造函数和析构函数开始。这里没有什么新东西,唯一值得注意的是我们从析构函数中调用Destroy()函数。这允许我们通过析构函数或通过显式调用对象本身的Destroy()函数来清理类的两种方法:

void BookEngine::AudioManager::Init() 
  { 
    //Check if we have already been initialized 
    if (m_isInitialized) 
      throw Exception("Audio manager is already initialized"); 

AudioManager类实现中的下一个函数是Init()函数。这是设置管理器所需组件的函数。函数开始时进行简单检查,看看我们是否已经初始化了该类;如果是,我们会抛出一个带有调试消息的异常:

//Can be Bitwise combination of  
//MIX_INIT_FAC, MIX_INIT_MOD, MIX_INIT_MP3, MIX_INIT_OGG 
if(Mix_Init(MIX_INIT_OGG || MIX_INIT_MP3) == -1) 
 throw Exception("SDL_Mixer could not initialize! Error: " + 
 std::string(Mix_GetError()));

在检查我们是否已经这样做之后,我们继续初始化 SDL_mixer 对象。我们通过调用Mix_Init()函数并传入一组标志的位组合来实现这一点,以设置支持的文件类型。这可以是 FLAC、MOD、MP3 和 OGG 的组合。在这个例子中,我们传递了 OGG 和 MP3 支持的标志。我们将这个调用包装在一个 if 语句中,以检查Mix_Init()函数调用是否有任何问题。如果遇到错误,我们会抛出另一个带有从Mix_Init()函数返回的错误信息的调试消息的异常:

if(Mix_OpenAudio(MIX_DEFAULT_FREQUENCY, MIX_DEFAULT_FORMAT, 2, 
 1024) == -1)      throw Exception("Mix_OpenAudio Error: " + 
 std::string(Mix_GetError()));

一旦SDL_mixer函数被初始化,我们就可以调用Mix_OpenAudio来配置要使用的frequencyformatchannelschunksize。重要的是要注意,这个函数必须在任何其他SDL_mixer函数之前调用。函数定义如下:

int Mix_OpenAudio(int frequency, Uint16 format, int channels, int chunksize)

以下是参数的含义:

  • frequency:这是每秒采样的输出频率,以赫兹为单位。在示例中,我们使用MIX_DEFAULT_FREQUENCY定义,即 22050,这是大多数情况下的一个很好的值。

  • format:这是输出样本格式;同样,在示例中,我们将其设置为默认值,使用MIX_DEFAULT_FORMAT定义,这与使用AUDIO_S16SYS或有符号 16 位样本,系统字节顺序相同。要查看完整的格式定义列表,请参见SDL_audio.h文件。

  • channels:这是输出中的声道数。立体声为 2 声道,单声道为 1 声道。我们的示例中使用值 2。

  • chunksize:这是每个输出样本使用的字节数。我们使用1024字节或 1 兆字节(mb)作为我们的 chunksize。

最后,在这个函数中我们做的最后一件事是将m_isInitalized布尔值设置为 true。这将阻止我们意外地尝试再次初始化该类:

m_isInitialized = true; 
  } 

AudioManager类中的下一个函数是Destroy()方法:

  void BookEngine::AudioManager::Destroy() 
  { 
    if (m_isInitialized) 
    { 
      m_isInitialized = false; 

      //Release the audio resources 
      for(auto& iter : m_effectList) 
        Mix_FreeChunk(iter.second); 
      for(auto& iter : m_musicList) 
        Mix_FreeMusic(iter.second); 
      Mix_CloseAudio(); 
      Mix_Quit(); 
    } 
  } 

我不会逐行讲解这个函数,因为它是不言自明的。基本概述是:检查AudioManager是否已经初始化,如果是,则使用Mix_FreeChunk()函数释放我们创建的每个声音和音乐资源。最后,我们使用Mix_CloseAudio()Mix_Quit()来关闭、清理和关闭 SDL_mixer API。

LoadSoundEffect是我们需要查看的下一个函数。这个函数就像它的名字所暗示的那样,是加载音效的函数:

 SoundEffect BookEngine::AudioManager::LoadSoundEffect(const std::string & filePath)
  { 
    SoundEffect effect; 

这个函数的第一步是创建一个SoundEffect对象,临时保存数据,直到我们将效果返回给调用方法。我们简单地称这个变量为 effect。

创建了我们的临时变量后,我们快速检查一下我们需要的这个效果是否已经被创建并存储在我们的缓存中,即 map 对象m_effectList

//Lookup audio file in the cached list 
auto iter = m_effectList.find(filePath); 

我们在这里做的有趣的方法是创建一个迭代器变量,并将其赋值为Map.find()的结果,其中传递的参数是我们要加载的声音文件的位置。这种方法的有趣之处在于,如果在缓存中找不到声音效果,迭代器的值将被设置为地图的末尾对象的索引,从而允许我们进行一个简单的检查,你将看到如下所示:

//Failed to find in cache, load 
    if (iter == m_effectList.end()) 
    { 
      Mix_Chunk* chunk = Mix_LoadWAV(filePath.c_str()); 
      //Error Loading file 
      if(chunk == nullptr) 
        throw Exception("Mix_LoadWAV Error: " + 
              std::string(Mix_GetError())); 

      effect.m_chunk = chunk; 
      m_effectList[filePath] = chunk; 
    } 

使用迭代器值技巧,我们只需检查iter变量的值是否与Map.end()函数的返回值匹配;如果是,这意味着音效不在缓存列表中,应该创建。

要加载音效,我们使用Mix_LoadWAV()函数,并将文件路径位置作为c字符串的参数。我们将返回的对象分配给一个名为块的Mix_Chunk指针。

然后我们检查块的值是否为nullptr指针,表示加载函数出现错误。如果是nullptr指针,我们将抛出一个异常,并提供Mix_GetError()函数提供的一些调试信息。如果成功,我们将临时持有者,效果的成员m_chunk,赋值为块的值,即我们加载的音效数据。

接下来,我们将这个新加载的效果添加到我们的缓存中,以便将来节省一些工作。

或者,如果我们对iter值的检查返回 false,这意味着我们尝试加载的音效在缓存中:

else //Found in cache 
    { 
      effect.m_chunk = iter->second; 
    } 

    return effect; 
  } 

现在迭代器的真正美丽被揭示了。查找结果,也就是auto iter = m_effectList.find(filePath);这一行的结果,当它找到音效时,将指向列表中的音效。所以我们所要做的就是将持有者变量效果成员值m_chunk分配给iter的第二个值,即音效的数据值。LoadSoundEffect()函数中的最后一件事是将效果变量返回给调用方法。这完成了过程,我们的音效现在可以使用了。

LoadSoundEffect()函数之后,是LoadMusic()函数:

Music BookEngine::AudioManager::LoadMusic(const std::string & filePath) 
  { 
    Music music; 

    //Lookup audio file in the cached list 
    auto iter = m_musicList.find(filePath); 

    //Failed to find in cache, load 
    if (iter == m_musicList.end()) 
    { 
      Mix_Music* chunk = Mix_LoadMUS(filePath.c_str()); 
      //Error Loading file 
      if (chunk == nullptr) 
           throw Exception("Mix_LoadMUS Error: " +
            std::string(Mix_GetError())); 

      music.m_music = chunk; 
      m_musicList[filePath] = chunk; 
    } 
    else //Found in cache 
    { 
      music.m_music = iter->second; 
    } 

    return music; 
  } 

我不会详细介绍这个函数,因为您可以看到它非常像LoadSoundEffect()函数,但它不是包装Mix_LoadWAV()函数,而是包装了SDL_mixer库的Mix_LoadMUS()

AudioManager.cpp文件中的最后两个函数实现不属于AudioManager类本身,而是SoundEffectMusic辅助类的Play函数的实现:

 void SoundEffect::Play(int numOfLoops) 
  { 
    if(Mix_PlayChannel(-1, m_chunk, numOfLoops) == -1) 
      if (Mix_PlayChannel(0, m_chunk, numOfLoops) == -1) 
          throw Exception("Mix_PlayChannel Error: " + 
                std::string(Mix_GetError())); 
  } 

  void Music::Play(int numOfLoops) 
  { 
    if (Mix_PlayMusic(m_music, numOfLoops) == -1) 
      throw Exception("Mix_PlayMusic Error: " + 
                 std::string(Mix_GetError())); 
  }   
} 

我不会逐行步进每个函数,而是想简单指出这些函数如何在 SDL_mixer 的Mix_PlayChannelMix_PlayMusic函数周围创建包装器。这实质上是AudioManager类的目的,它只是一个抽象加载文件和直接创建对象的包装器。这帮助我们创建一个可扩展的框架,管道,而不必担心底层机制。这意味着在任何时候,理论上,我们可以用另一个或甚至多个库替换底层库,而不会影响调用管理器类函数的代码。

为了完成这个示例,让我们看看如何在演示项目中使用这个AudioManager。您可以在代码存储库的Chapter04文件夹中找到这个演示,标记为SoundExample。音乐的来源归功于 Bensound(www.bensound.com)。

GameplayScreen.h文件开始:

private: 
  void CheckInput(); 
  BookEngine::AudioManager m_AudioManager; 
  BookEngine::Music m_bgMusic; 
}; 

我们在私有声明中添加了两个新对象,一个是名为m_AudioManagerAudioManager,另一个是名为m_bgMusicMusic对象。

GameplayScreen.cpp文件中:

void GameplayScreen::OnEntry() 
{ 
  m_AudioManager.Init(); 
  m_bgMusic = m_audioManager.LoadMusic("Audio/bensound-epic.mp3"); 
  m_bgMusic.Play(); 
} 

要初始化、加载和播放我们的音乐文件,我们需要在GameplayScreen类的OnEntry()中添加三行。

  • 第一行m_AudioManager.Init()设置了AudioManager并像之前看到的那样初始化了所有组件。

  • 接下来加载音乐文件,这里是bensound-epic.mp3文件,并将其分配给m_bgMusic变量。

  • 最后一行m_bgMusic.Play()开始播放音乐曲目。通过不传入循环音乐曲目的次数,默认为-1,这意味着它将继续循环直到程序停止。

这处理了音乐曲目的播放,但当游戏结束时,我们需要添加一些更多的函数调用来清理AudioManager,并在切换屏幕时停止音乐。

为了在离开这个屏幕时停止音乐播放,我们在GameplayScreen类的OnExit函数中添加以下内容:

m_bgMusic.Stop(); 

为了清理AudioManager并阻止任何潜在的内存泄漏,我们在GameplayScreen类的Destroy函数中调用以下内容:

  m_AudioManager.Destroy(); 

这将进而处理我们在前一节中所加载的任何音频资产的销毁和清理。

现在所有这些都已经就位,如果你运行SoundExample演示,你将听到一些史诗般的冒险音乐开始播放,并且如果你足够耐心,它将不断循环。现在我们在游戏中有了一些声音,让我们再进一步,看看如何将一些视觉资产导入到我们的项目中。

处理纹理

如果你对这个术语不熟悉,纹理基本上可以被认为是一种图像。这些纹理可以应用到一个简单的几何正方形,两个三角形,以制作一幅图像。这种类型的图像通常被称为Sprite。我们在本节末尾的演示中使用了Sprite类。还需要注意的是,纹理可以应用到更复杂的几何图形中,并且在 3D 建模中用于给物体上色。随着我们在书中后面继续进行演示,纹理将扮演更重要的角色。

资源管理器

让我们从高级别的类ResourceManager开始。这个管理类将负责在缓存中维护资源对象,并提供一个简单的、抽象的接口来获取资源:

#pragma once 
#include "TextureCache.h"
#include <string> 
namespace BookEngine 
{ 
class ResourceManager 
  { 
  public: 
    static GLTexture GetTexture(std::string pathToTextureFile); 
  private: 
    static TextureCache m_textureCache; 
  }; 
} 

声明文件ResourceManager.h是一个简单的类,包括一个公共函数GetTexture,和一个私有成员TextureCacheGetTexure将是我们向其他类公开的函数。它将负责返回纹理对象。TextureCache就像我们在AudioManager中使用的缓存,它将保存加载的纹理以供以后使用。让我们继续实现,这样我们就可以看到这是如何设置的:

#include "ResourceManager.h"
namespace BookEngine 
{ 
  TextureCache ResourceManager::m_textureCache; 

  GLTexture ResourceManager::GetTexture(std::string texturePath) 
  { 
    return m_textureCache.GetTexture(texturePath); 
  } 
} 

ResourceManager的实现实际上只是对底层结构的抽象调用。当我们调用ResourceManager类的GetTexture函数时,我们期望得到一个GLTexture类型的返回。作为这个函数的调用者,我不需要担心TextureCache的内部工作方式或对象是如何解析的。我所需要做的就是指定我希望加载的纹理的路径,然后资产管道就会完成剩下的工作。这应该是资产管道系统的最终目标,无论采用何种方法,接口都应该足够抽象,以允许开发人员和设计师在项目中导入和使用资产,而不需要底层系统的实现成为阻碍。

接下来我们将看一下这个例子纹理系统,它是ResourceManager类接口简单性的核心。

纹理和纹理缓存

之前我们看到了ResourceManager类结构中引入的两个新对象,GLTextureTextureCache。在接下来的章节中,我们将更详细地看一下这两个类,以便了解这些类如何与其他系统连接,构建一个强大的资产管理系统,最终回到ResourceManager的简单接口。

首先我们将看一下GLTexture类。这个类完全由一个描述我们纹理属性的struct组成。以下是GLTexture类的完整代码:

#pragma once 
#include <GL/glew.h> 
namespace BookEngine 
{ 
  struct GLTexture 
  { 
    GLuint id; 
    int width; 
    int height; 
  }; 
} 

如前所述,GLTexture类实际上只是一个名为GLTexturestruct的包装器。这个struct保存了一些简单的值。一个GLuint id,用于标识纹理和两个整数值,widthheight,当然保存了纹理/图像的高度和宽度。这个struct可以很容易地包含在TextureClass中,我选择以这种方式实现它,一是为了使它更容易阅读,二是为了允许一些未来发展的灵活性。再次,我们希望确保我们的资产管道允许适应不同的需求和包含新的资产类型。

接下来我们有TextureCache类,就像我们对音频资产所做的那样,为图像文件创建一个缓存是一个好主意。这将再次通过将它们保存在一个映射中并根据需要返回它们来为我们提供更快的访问所需的图像文件。我们只需要在缓存中不存在时创建一个新的纹理。在构建任何与资产相关的系统时,我倾向于使用这种带有缓存机制的实现方式。

虽然这些示例提供了一个基本的实现,但它们是创建更健壮的系统的绝佳起点,可以集成内存管理和其他组件。以下是TextureCache类的声明,它应该从前面的音频示例中看起来非常熟悉:

#pragma once 
#include <map> 
#include "GLTexture.h"

namespace BookEngine 
{ 
  class TextureCache 
  { 
  public: 
    TextureCache(); 
    ~TextureCache(); 

    GLTexture GetTexture(std::string texturePath);  
  private: 
    std::map<std::string, GLTexture> m_textureMap; 

  }; 
} 

继续实现TextureCache类,在TextureCache.cpp文件中,让我们看一下GetTexture()

GLTexture TextureCache::GetTexture(std::string texturePath) { 

    //lookup the texture and see if it''''s in the map 
    auto mit = m_textureMap.find(texturePath); 

    //check if its not in the map 
    if (mit == m_textureMap.end()) 
    { 
      //Load the texture 
      GLTexture newTexture = ImageLoader::LoadPNG(texturePath); 

      //Insert it into the map 
      m_textureMap.insert(std::make_pair(texturePath, newTexture)); 

      //std::cout << "Loaded Texture!\n"; 
      return newTexture; 
    } 
    //std::cout << "Used Cached Texture!\n"; 
    return mit->second; 
  }

这个实现看起来与我们之前看到的AudioManager示例非常相似。这里需要注意的主要一行是调用ImageLoader类加载图像文件的那一行,GLTexture newTexture = ImageLoader::LoadPNG(texturePath);。这个调用是类的重要部分,正如你所看到的,我们再次抽象了底层系统,只是从我们的GetTexture类中提供了一个GLTexture作为返回类型。让我们跳到下一节,看一下ImageLoader类的实现。

ImageLoader 类

现在我们已经有了结构来将纹理对象传递回给调用资源管理器,我们需要实现一个实际加载图像文件的类。ImageLoader就是这个类。它将处理加载、处理和创建纹理。这个简单的例子将加载一个便携式网络图形PNG)格式的图像。

由于我们在这里专注于资产管道的结构,我将坚持课程的核心部分。我将假设您对 OpenGL 的缓冲区和纹理创建有一些了解。如果您对 OpenGL 不熟悉,我强烈推荐 OpenGL 圣经系列作为一个很好的参考。在未来的章节中,当我们研究一些高级渲染和动画技术时,我们将会看到一些这些特性。

在这个例子中,ImageLoader.h文件只有一个LoadPNG函数的声明。这个函数接受一个参数,即图像文件的路径,并将返回一个GLTexture。以下是完整的ImageLoader

#pragma once 
#include "GLTexture.h" 
#include <string> 
namespace BookEngine 
{ 
  class ImageLoader 
  { 
  public: 
    static GLTexture LoadPNG(std::string filePath);
    static GLTexture LoadDDS(const char * imagepath);
  }; 
} 

继续实现,在ImageLoader.cpp文件中,让我们看一下LoadPNG函数:

... 
  GLTexture ImageLoader::LoadPNG(std::string filePath) { 
unsigned long width, height;     
GLTexture texture = {}; 
std::vector<unsigned char> in; 
  std::vector<unsigned char> out; 

我们要做的第一件事是创建一些临时变量来保存我们的工作数据。一个无符号的用于高度宽度,一个GLTexture对象,然后我们将其所有字段初始化为0。然后我们有两个存储无符号字符的向量容器。in向量将容纳从 PNG 中读取的原始编码数据。out向量将保存已转换的解码数据。

  ... 
  //Read in the image file contents into a buffer 
    if (IOManager::ReadFileToBuffer(filePath, in) == false) {
      throw Exception("Failed to load PNG file to buffer!");
    }

    //Decode the .png format into an array of pixels
    int errorCode = DecodePNG(out, width, height, &(in[0]), in.size());
    if (errorCode != 0) {
      throw Exception("decodePNG failed with error: " + std::to_string(errorCode));
    }
  ... 

接下来我们有两个函数调用。首先我们调用一个使用IOManager类的ReadFileToBuffer函数来读取图像文件的原始数据的函数。我们传入pathToFilein向量;函数将用原始编码数据填充向量。第二个调用是DecodePNG函数;这是我之前提到的单一函数库的调用。这个库将处理原始数据的读取、解码,并用解码后的数据填充输出向量容器。函数有四个参数:

  • 第一个是用来保存解码数据的向量,我们的例子中是out向量

  • 第二个是widthheight变量,DecodePNG函数将用图像的值填充它们

  • 第三个是指一个容器,它保存着编码数据,在我们的例子中,是in向量

  • 最后一个参数是缓冲区的大小,也就是向量in的大小

这两个调用是这个类的主要部分,它们完成了我们资产管道的图像加载组件的系统。我们现在不会深入到原始数据的读取和解码中。在下一节中,我们将看到一个类似的技术来加载 3D 模型,我们将看到如何详细地读取和解码数据。

函数的其余部分将处理在 OpenGL 中上传和处理图像,我不会在这部分函数上花时间。随着我们继续前进,我们将看到更多 OpenGL 框架的调用,并且在那时我会更深入地讨论。这个例子是专门为 OpenGL 构建的,但它很容易被更通用的代码或特定于其他图形库的代码所替代。

除了IOMangerDecodePNG类,这就完成了资产管道的图像处理。希望你能看到,有一个像我们所见的这样的结构,可以在底层提供很大的灵活性,同时提供一个简单的接口,几乎不需要了解底层系统。

现在我们通过一个简单的一行调用返回了一个纹理,ResourceManger::GetTexture(std::string pathToTextureFile),让我们把这个例子完整地展示一下,看看我们如何插入这个系统来创建一个Sprite(2D 图像)从加载的纹理中:

void Sprite::Init(float x, float y, float width, float height, std::string texturePath) { 
        //Set up our private vars 
        m_x = x; 
        m_y = y; 
        m_width = width; 
        m_height = height; 

        m_texture = ResourceManager::GetTexture(texturePath); 

在纹理示例项目中,进入Sprite类,如果我们关注Init(),我们会看到我们的简单接口允许我们调用ResourceManager类的GetTexture来返回处理过的图像。就是这样,非常简单!当然,这不仅限于精灵,我们可以使用这个函数来加载其他用途的纹理,比如建模和 GUI 用途。我们还可以扩展这个系统,加载不仅仅是 PNG 的文件,事实上,我会挑战你花一些时间为更多的文件格式构建这个系统,比如 DDS、BMP、JPG 和其他格式。ResourceManager本身有很大的改进和增长空间。这个基本结构很容易重复用于其他资产,比如声音、3D 模型、字体和其他一切。在下一节中,我们将深入一点,看看加载 3D 模型或网格时的情况。

要看整个系统的运行情况,运行纹理示例项目,你将看到一个由 NASA 的善良人士提供的非常漂亮的太阳图像。

以下是在 Windows 上的输出:

以下是在 macOS 上的输出:

导入模型-网格

模型或网格是三维空间中物体的表示。这些模型可以是玩家角色,也可以是小型景物,比如桌子或椅子。加载和操作这些对象是游戏引擎和底层系统的重要部分。在本节中,我们将看看加载 3D 网格的过程。我们将介绍一种描述三维对象的简单文件格式。我们将学习如何加载这种文件格式并将其解析为可供图形处理器使用的可读格式。最后,我们将介绍 OpenGL 用于渲染对象的步骤。让我们立即开始并从Mesh类开始:

namespace BookEngine 
{ 
  class Mesh 
  { 
  public: 
    Mesh(); 
    ~Mesh(); 
    void Init(); 
    void Draw(); 
  private: 
    GLuint m_vao; 
    GLuint m_vertexbuffer; 
    GLuint m_uvbuffer; 
    GLTexture m_texture;   

    std::vector<glm::vec3> m_vertices; 
    std::vector<glm::vec2> m_uvs; 
    std::vector<glm::vec3> m_normals; 
    // Won''''t be used at the moment. 
  }; 
} 

我们的Mesh类声明文件Mesh.h非常简单。我们有normal构造函数和析构函数。然后我们有另外两个作为public公开的函数。Init()函数将初始化所有Mesh组件,Draw函数将实际处理并将信息传递给渲染器。在private声明中,我们有一堆变量来保存网格的数据。首先是GLuint m_vao变量。这个变量将保存 OpenGL 顶点数组对象的句柄,我现在不会详细介绍,可以参考 OpenGL 文档进行快速了解。

接下来的两个GLuint变量,m_vertexbufferm_uvbuffer是用来保存vertexuv信息数据的缓冲区。在接下来的实现中会更多介绍。在缓冲区之后,我们有一个GLTexture变量m_texture。你会记得这个对象类型之前提到过;这将保存网格的纹理。最后三个变量是glm vec3的向量。这些向量保存了Mesh的顶点的笛卡尔坐标,纹理uvsnormal。在当前的例子中,我们不会使用 normal 值。

这让我们对Mesh类需要什么有了很好的理解;现在我们可以继续实现。我们将逐步学习这个类,遇到其他类时会转到其他类。让我们从Mesh.cpp文件开始:

namespace BookEngine 
{ 
  Mesh::Mesh() 
  { 
    m_vertexbuffer = 0; 
    m_uvbuffer = 0; 
    m_vao == 0; 
  }

Mesh.cpp文件以构造函数的实现开始。Mesh构造函数将两个缓冲区和顶点数组对象的值设置为零。我们这样做是为了以后进行简单的检查,看它们是否已经被初始化或删除,接下来我们将看到:

OBJModel::~OBJModel() 
  { 
    if (m_vertexbuffer != 0) 
      glDeleteBuffers(1, &m_vertexbuffer); 
    if (m_uvbuffer != 0)  
      glDeleteBuffers(1, &m_uvbuffer); 
if (m_vao != 0) 
      glDeleteVertexArrays(1, &m_vao); 
  } 

Mesh类的析构函数处理了BufferVertex数组的删除。我们进行简单的检查,看它们是否不等于零,这意味着它们已经被创建,如果它们不是,则删除它们:

void OBJModel::Init() 
  {   
    bool res = LoadOBJ("Meshes/Dwarf_2_Low.obj", m_vertices, m_uvs, m_normals); 
    m_texture = ResourceManager::GetTexture("Textures/dwarf_2_1K_color.png"); 

接下来是Init()函数,我们从加载我们的资源开始。在这里,我们使用熟悉的帮助函数ResourceManager类的GetTexture函数来获取模型所需的纹理。我们还加载Mesh,在这种情况下是一个名为Dwarf_2_Low.obj的 OBJ 格式模型,由 andromeda vfx 提供在TurboSquid.com上。这是通过使用LoadOBJ函数实现的。让我们暂时离开Mesh类,看看这个函数是如何实现的。

MeshLoader.h文件中,我们看到了LoadOBJ函数的声明:

bool LoadOBJ( 
    const char * path, 
    std::vector<glm::vec3> & out_vertices, 
    std::vector<glm::vec2> & out_uvs, 
    std::vector<glm::vec3> & out_normals 
  ); 

LoadOBJ函数有四个参数,OBJ 文件的文件路径和三个将填充 OBJ 文件中数据的向量。该函数还具有布尔类型的返回值,这是为了简单的错误检查能力。

在继续查看这个函数是如何组合的,以及它将如何解析数据来填充我们创建的向量之前,重要的是要了解我们正在使用的文件的结构。幸运的是,OBJ 文件是一种开放的文件格式,实际上可以在任何文本编辑器中以纯文本形式阅读。您也可以使用 OBJ 格式手工创建非常简单的模型。举个例子,让我们看一下在文本编辑器中查看的cube.obj文件。顺便说一句,您可以在 Visual Studio 中查看 OBJ 格式的 3D 渲染模型;它甚至有基本的编辑工具:

# Simple 3D Cube Model 
mtllib cube.mtl 
v 1.000000 -1.000000 -1.000000 
v 1.000000 -1.000000 1.000000 
v -1.000000 -1.000000 1.000000 
v -1.000000 -1.000000 -1.000000 
v 1.000000 1.000000 -1.000000 
v 0.999999 1.000000 1.000001 
v -1.000000 1.000000 1.000000 
v -1.000000 1.000000 -1.000000 
vt 0.748573 0.750412 
vt 0.749279 0.501284 
vt 0.999110 0.501077 
vt 0.999455 0.750380 
vt 0.250471 0.500702 
vt 0.249682 0.749677 
vt 0.001085 0.750380 
vt 0.001517 0.499994 
vt 0.499422 0.500239 
vt 0.500149 0.750166 
vt 0.748355 0.998230 
vt 0.500193 0.998728 
vt 0.498993 0.250415 
vt 0.748953 0.250920 
vn 0.000000 0.000000 -1.000000 
vn -1.000000 -0.000000 -0.000000 
vn -0.000000 -0.000000 1.000000 
vn -0.000001 0.000000 1.000000 
vn 1.000000 -0.000000 0.000000 
vn 1.000000 0.000000 0.000001 
vn 0.000000 1.000000 -0.000000 
vn -0.000000 -1.000000 0.000000 
usemtl Material_ray.png 
s off 
f 5/1/1 1/2/1 4/3/1 
f 5/1/1 4/3/1 8/4/1 
f 3/5/2 7/6/2 8/7/2 
f 3/5/2 8/7/2 4/8/2 
f 2/9/3 6/10/3 3/5/3 
f 6/10/4 7/6/4 3/5/4 
f 1/2/5 5/1/5 2/9/5 
f 5/1/6 6/10/6 2/9/6 
f 5/1/7 8/11/7 6/10/7 
f 8/11/7 7/12/7 6/10/7 
f 1/2/8 2/9/8 3/13/8 
f 1/2/8 3/13/8 4/14/8 

正如您所看到的,这些文件中包含了大量的数据。请记住,这只是一个简单的立方体模型的描述。看一下矮人 OBJ 文件,以更深入地了解其中包含的数据。对我们来说重要的部分是vvtvnf行。v行描述了Mesh的几何顶点,即模型在局部空间中的xyz值(相对于模型本身的原点的坐标)。vt行描述了模型的纹理顶点,这次值是标准化的 x 和 y 坐标,标准化意味着它们是01之间的值。vn行是顶点法线的描述,我们在当前示例中不会使用这些,但这些值给出了垂直于顶点的标准化向量单位。在计算光照和阴影等内容时,这些是非常有用的值。以下图示了一个十二面体形状网格的顶点法线:

最后一组行,f行,描述了网格的面。这些是构成网格的单个面,三角形的三个向量值组。这些再次是局部空间的 x、y 和 z 坐标。

在我们的示例引擎中渲染此文件将如下所示:

好了,这就是 OBJ 文件格式的概况,现在让我们继续并看看我们将如何解析这些数据并将其存储在缓冲区中供我们的渲染器使用。在MeshLoader.cpp文件中,我们找到了LoadOBJ()函数的实现:

... 
bool LoadOBJ( 
    std::string path, 
    std::vector<glm::vec3> & out_vertices, 
    std::vector<glm::vec2> & out_uvs, 
    std::vector<glm::vec3> & out_normals 
    )  
{ 
    WriteLog(LogType::RUN, "Loading OBJ file " + path + " ..."); 
    std::vector<unsigned int> vertexIndices, uvIndices, normalIndices; 
    std::vector<glm::vec3> temp_vertices; 
    std::vector<glm::vec2> temp_uvs; 
    std::vector<glm::vec3> temp_normals; 

为了开始LoadOBJ函数,创建了一些占位变量。变量声明的第一行是三个整数向量。这些将保存verticesuvsnormals的索引。在索引之后,我们有另外三个向量。两个vec3向量用于verticesnormal,一个vec2向量用于uvs。这些向量将保存每个临时值,允许我们执行一些计算:

    try  
{ 
std::ifstream in(path, std::ios::in); 

接下来我们开始一个try块,它将包含函数的核心逻辑。我们这样做是为了在这个函数的最后内部抛出一些异常,如果出现任何问题就在内部捕获它们。try块中的第一行,std::ifstream in(path, std::ios::in);尝试加载我们传入位置的文件。ifstream,正如您可能已经注意到的,是标准库的一部分,用于定义一个流对象,可以用来从文件中读取字符数据。在现代 I/O 系统中经常看到ifstream的使用,它是 C++中常见的fopen的替代品,后者实际上是 C 的:

if (!in) {
throw Exception("Error opening OBJ file: " + path); }

然后我们可以测试是否有任何加载文件错误,使用简单的 if 语句if(!in),这与直接检查状态标志相同,例如in.bad() == true;in.fail() == true。如果我们遇到错误,我们会抛出一个带有调试消息的异常。我们稍后在函数中处理这个异常:

std::string line; 
while (std::getline(in, line)) 
  { 

接下来,我们需要创建一个循环,这样我们就可以遍历文件并根据需要解析数据。我们使用while()循环,使用std::getline(in, line)函数作为参数。std::getline返回一行字符,直到它达到一个换行符。parameters std::getline()接受的是包含字符的流,我们的情况下是in,以及一个将保存函数输出的std::string对象。

通过将这个作为while循环的条件参数,我们将继续逐行遍历输入文件,直到达到文件的末尾。在条件变为假的时间内,我们将停止循环。这是一个非常方便的逐步遍历文件以解析的方法:

  if (line.substr(0, 2) == "v ") { 
    std::istringstream v(line.substr(2)); 
    glm::vec3 vert; 
    double x, y, z; 
    v >> x; v >> y; v >> z; 
    vert = glm::vec3(x, y, z); 
    temp_vertices.push_back(vert); 
  } 

在我们的while循环内,我们首先要尝试解析的是 OBJ 文件中的顶点数据。如果你还记得我们之前的解释,顶点数据包含在一个单独的行中,用v表示。然后,为了解析我们的顶点数据,我们应该首先测试一下这行是否是一个顶点(v)行。std::string()对象有一个方便的方法,允许你从字符串中选择一定数量的字符。这个方法就是substr()substr()方法可以接受两个参数,字符在字符串中的起始位置和结束位置。这样就创建了一个子字符串对象,然后我们可以对其进行测试。

在这个例子中,我们使用substr()方法来取字符串line的前两个字符,然后测试它们是否与字符串"v "(注意空格)匹配。如果这个条件是true,那就意味着我们有一个顶点行,然后可以继续将其解析成我们系统中有用的形式。

这段代码相当容易理解,但让我们来强调一些重要的部分。首先是std::istringstream对象vstringstream是一个特殊的对象,它提供了一个字符串缓冲区,方便地操作字符串,就像操作 I/O 对象(std::cout)一样。这意味着你可以使用>><<操作符来处理它,也可以使用str()方法来像处理std::string一样处理它。我们使用我们的字符串流对象来存储一组新的字符。这些新字符是通过对line.substr(2)的方法调用提供的。这一次,通过只传递一个参数2substr方法,我们告诉它返回从第二个字符开始的行的其余部分。这样做的效果是返回顶点行的值xyz,而不包括v标记。一旦我们有了这组新的字符,我们就可以逐个遍历每个字符,并将其分配给它匹配的双精度变量。正如你所看到的,这就是我们使用字符串流对象的独特性来将字符流到它的变量的地方,即v >> x;``v >> y; v >> x;行。在if语句的末尾,我们将这些xyz双精度数转换为vec3,最后将新创建的vec3推送到临时vertices向量的末尾:

else if (line.substr(0, 2) == "vt")  
{ 
std::istringstream v(line.substr(3)); 
          glm::vec2 uv; 
          double U, V; 
          v >> U;v >> V; 
          uv = glm::vec2(U, V); 
          uv.y = -uv.y; 
          temp_uvs.push_back(uv); 
        } 

对于纹理,我们做了很多相同的事情。除了检查"vt"之外,主要的区别是我们只寻找两个值,或者vec2向量。这里的另一个注意事项是我们反转了v坐标,因为我们使用的是纹理格式,它们是反转的。如果你想使用 TGA 或 BMP 格式的加载器,可以删除这部分:

        else if (line.substr(0, 2) == "vn") 
 { 

          std::istringstream v(line.substr(3)); 
          glm::vec3 normal; 
          double x, y, z; 
          v >> x;v >> y;v >> z; 
          normal = glm::vec3(x, y, z); 
          temp_normals.push_back(normal); 
        } 

对于法线,我们做的和顶点一样,但是寻找的是vn行:


        else if (line.substr(0, 2) == "f ") 
        { 
          unsigned int vertexIndex[3], uvIndex[3], normalIndex[3]; 
          const char* cstring = line.c_str(); 
          int matches = sscanf_s(cstring, "f %d/%d/%d %d/%d/%d %d/%d/%d\n", &vertexIndex[0], &uvIndex[0], &normalIndex[0], &vertexIndex[1], &uvIndex[1], &normalIndex[1], &vertexIndex[2], &uvIndex[2], &normalIndex[2]); 

对于面,一个三角形的集合,我们做一些不同的事情。首先,我们检查是否有一个"f "行。如果有,我们设置一些数组来保存vertexuvnormal的索引。然后我们将我们的std::string,line,转换为一个字符数组,即 C 字符串,使用const char* cstring = line.c_str();这一行。然后我们使用另一个 C 函数,sscanf_s来解析实际的字符串,并将每个字符分离到特定的索引数组元素中。一旦这个语句完成,sscanf_s()函数将返回一个元素集的整数值,我们将其赋给变量 matches:

if (matches != 9) 
    throw Exception("Unable to parse format"); 

然后我们使用matches变量来检查它是否等于9,这意味着我们有九个元素,这是我们可以处理的格式。如果 matches 的值不是9,那意味着我们有一个我们没有设置好处理的格式,所以我们抛出一个带有简单调试消息的异常:

          vertexIndices.push_back(vertexIndex[0]); 
          vertexIndices.push_back(vertexIndex[1]); 
          vertexIndices.push_back(vertexIndex[2]); 
          uvIndices.push_back(uvIndex[0]); 
          uvIndices.push_back(uvIndex[1]); 
          uvIndices.push_back(uvIndex[2]); 
          normalIndices.push_back(normalIndex[0]); 
          normalIndices.push_back(normalIndex[1]); 
          normalIndices.push_back(normalIndex[2]); 
        } 
      }

"f "或面行的 if 语句中,我们做的最后一件事是将所有分离的元素推入相应的索引向量中。我们使用这些值来构建实际的网格数据:

      for (unsigned int i = 0; i < vertexIndices.size(); i++)  
{ 
        // Get the indices of its attributes 
        unsigned int vertexIndex = vertexIndices[i]; 
        unsigned int uvIndex = uvIndices[i]; 
        unsigned int normalIndex = normalIndices[i]; 

为了创建我们的最终网格数据以提供输出向量,我们创建另一个循环来遍历模型数据,这次使用一个 for 循环和顶点数量作为条件。然后我们创建三个变量来保存每个vertexuvnormal的当前索引。每次我们通过这个循环,我们将这个索引设置为i的值,这个值在每一步中递增:

        glm::vec3 vertex = temp_vertices[vertexIndex - 1]; 
        glm::vec2 uv = temp_uvs[uvIndex - 1]; 
        glm::vec3 normal = temp_normals[normalIndex - 1]; 

然后,由于这些索引值,我们可以获得每个vertexuvnormal的属性。我们将这些设置为vec2vec3,这是我们输出向量所需要的:

        out_vertices.push_back(vertex); 
        out_uvs.push_back(uv); 
        out_normals.push_back(normal); 
      } 
    } 

最后,最后一步是将这些新值推入它们特定的输出向量中:

    catch (Exception e) 
    { 
      WriteLog(LogType::ERROR, e.reason); 
      return false; 
    } 
    return true; 
  } 
  ...

最后,我们有一个catch块来匹配顶部的try块。这个 catch 非常简单,我们从传入的Exception对象中取出 reason 成员对象,并用它来将调试消息打印到错误日志文件中。我们还从LoadOBJ()函数中返回 false,以让调用对象知道发生了错误。如果没有什么可捕捉的,我们简单地返回 true,以让调用对象知道一切都按预期工作。现在我们准备使用这个函数来加载我们的 OBJ 文件,并为渲染系统生成有用的数据。

现在,回到Mesh.cpp文件,我们将继续使用这个加载的数据来使用示例引擎绘制模型。我不会在每个函数上花太多时间,这再次是特定于 OpenGL API,但可以以更通用的方式编写,或者使用其他图形库,比如 DirectX:

    if (m_vao == 0)  
      glGenVertexArrays(1, &m_vao); 
    glBindVertexArray(m_vao); 

在这里,我们检查顶点数组对象是否已经生成;如果没有,我们使用我们的m_vao作为引用对象来创建一个。接下来我们绑定 VAO,这将允许我们在这个类中的所有后续 OpenGL 调用中使用它:

    if (m_vertexbuffer == 0) 
glGenBuffers(1, &m_vertexbuffer); 
    if (m_uvbuffer == 0)  
      glGenBuffers(1, &m_uvbuffer); 

接下来我们检查我们的顶点缓冲是否已经创建;如果没有,我们使用m_vertexbuffer变量作为引用对象来创建一个。我们对uvbuffer也是同样的操作:

    glBindBuffer(GL_ARRAY_BUFFER, m_vertexbuffer); 
    glBufferData(GL_ARRAY_BUFFER, m_vertices.size() * sizeof(glm::vec3), &m_vertices[0], GL_STATIC_DRAW); 
    glBindBuffer(GL_ARRAY_BUFFER, m_uvbuffer); 
    glBufferData(GL_ARRAY_BUFFER, m_uvs.size() * sizeof(glm::vec2), &m_uvs[0], GL_STATIC_DRAW); 
  }

在我们的Meshes Init()函数中,我们做的最后一件事是绑定vertexuv缓冲区,然后使用 OpenGL 的glBindBuffer()glBufferData()函数将数据上传到图形卡上。查看 OpenGL 文档以获取有关这些函数的更详细信息:

  void Mesh::Draw() 
  {   
    glActiveTexture(GL_TEXTURE0); 
    glBindTexture(GL_TEXTURE_2D, m_texture.id); 

对于Mesh类的Draw()函数,我们首先在 OpenGL API 框架中设置纹理。我们使用函数调用glActiveTexture()来激活纹理,使用glBindTexture()来实际绑定内存中的纹理数据:

    glBindBuffer(GL_ARRAY_BUFFER, m_vertexbuffer); 
    glVertexAttribPointer( 0,  3,  GL_FLOAT,  GL_FALSE,  0, (void*)0); 
    glBindBuffer(GL_ARRAY_BUFFER, m_uvbuffer); 
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, (void*)0); 

接下来我们绑定缓冲区并设置顶点数据和纹理坐标数据的属性。同样,我不会在这里专注于细节,代码中有注释来解释每个参数。关于这些函数的更多信息,我建议在线查看 OpenGL 文档。

    glDrawArrays(GL_TRIANGLES, 0, m_vertices.size()); 

当所有数据都绑定,并且所有属性都设置好之后,我们可以调用函数来实际绘制Mesh对象。在这种情况下,我们使用glDrawArrays()函数,传入GL_TRIANGLES作为绘制的方法。这意味着我们要使用三角形来渲染顶点数据。尝试将这个值更改为GL_POINTS来玩玩。

    glDisableVertexAttribArray(0); 
    glDisableVertexAttribArray(1); 
    glBindBuffer(GL_ARRAY_BUFFER, 0); 
  } 
}

在我们的绘制调用结束时,我们还有最后一步要完成,清理工作。在每次 OpenGL 绘制调用之后,需要禁用已设置的使用过的属性,并解绑已使用的缓冲区。glDisableVertexAttribArray()glBindBuffer()函数用于这些任务。

GameplayScreen.cpp文件中,我们添加了初始化模型的调用:

 ... 
//Init Model 
  m_model.Init("Meshes/Dwarf_2_Low.obj", "Textures/dwarf_2_1K_color.png"); 
  ... 

然后我们可以通过简单地在GameplayScreenDraw()函数中添加对模型的Draw()函数的调用来开始绘制它:

  ... 
//Draw Model 
  m_model.Draw(); 
... 

就是这样!如果你运行ModelExample,你会在屏幕上看到矮人模型的输出。我还为游戏添加了一个简单的 3D 摄像头,这样你就可以在模型周围移动。在游戏空间中,使用WASD来移动摄像头。使用鼠标来四处看。

以下是在 Windows 上的输出:

以下是在 macOS 上的输出:

总结

在本章中,我们涵盖了开发中非常重要的一个部分,即处理资产。我们看了一下导入、处理和管理内容(如声音、图像和 3D 对象)的过程。有了这个基础系统,我们可以继续完善游戏开发所需的其余系统。

在下一章中,我们将着眼于开发核心的游戏玩法系统,包括状态系统、物理系统、摄像头和 GUI/HUD 系统。

第五章:构建游戏系统

我们已经到了我们的旅程中的一个节点,我们能够开始将我们将用来驱动我们的游戏和工具的各种系统逐步拼凑在一起。这些系统是引擎的一部分,它们为我们现在能够导入游戏中的所有惊人资产提供互动的动力:

  • 理解状态

  • 设计摄像机系统

  • 使用物理

理解状态

我们以许多不同的方式使用状态。它们可以用于控制游戏流程,处理角色行为和反应的不同方式,甚至用于简单的菜单导航。不用说,状态是强大且可管理的代码基础的重要要求。

有许多不同类型的状态机;我们将在本节中重点关注有限状态机(FSM)模式。你们中的敏锐读者可能已经注意到,我们已经在实现的屏幕系统的功能中看到了 FSM 模式。事实上,我们将在这里创建的东西与为该系统创建的东西非常相似,只是有一些关键的区别,这将使其成为一个更通用和灵活的状态机。

我们可以在游戏中实现简单状态机的几种方式。一种方式是简单地使用 switch case 来控制状态,并使用enum结构来表示状态类型。一个例子如下:

enum PlayerState 
{ 
    Idle, 
      Walking 
} 
... 
PlayerState currentState = PlayerState::Idle; //A holder variable for the state currently in 
... 
// A simple function to change states 
void ChangeState(PlayState nextState) 
{ 
    currentState = nextState; 
} 
void Update(float deltaTime) 
{ 
    ... 
    switch(currentState) 
{ 
    case PlayerState::Idle: 
        ... //Do idle stuff 
        //Change to next state 
ChangeState(PlayerState::Walking); 
break; 
        case PlayerState::Walking: 
            ... //Do walking stuff 
            //Change to next state 
            ChangeState(PlayerState::Idle); 
break; 
    } 
    ... 
} 

像这样使用 switch/case 对于许多情况来说是有效的,但它确实有一些强大的缺点。如果我们决定添加一些新的状态怎么办?如果我们决定添加分支和更多的if条件呢?

我们开始时使用的简单 switch/case 突然变得非常庞大,无疑难以控制。每当我们想要进行更改或添加一些功能时,我们就会增加复杂性,并引入更多的错误机会。通过采用稍微不同的方法并使用类来表示我们的状态,我们可以帮助减轻一些这些问题,并提供更多的灵活性。通过继承和多态性的使用,我们可以构建一个结构,允许我们将状态链接在一起,并提供在许多情况下重用它们的灵活性。

让我们逐步了解如何在我们的演示示例中实现这一点,从我们将来将继承的基类IState开始:

... 
namespace BookEngine 
{ 
    class IState { 
    public: 
        IState() {} 
        virtual ~IState(){} 
        // Called when a state enters and exits  
        virtual void OnEntry() = 0; 
        virtual void OnExit() = 0; 

        // Called in the main game loop 
        virtual void Update(float deltaTime) = 0; 
    }; 
} 

正如你所看到的,这只是一个非常简单的类,它有一个构造函数,一个虚拟析构函数,以及三个完全虚拟的函数,每个继承的状态都必须重写。OnEntry将在状态首次进入时调用,每次状态更改时只执行一次。OnExitOnEntry一样,每次状态更改时只执行一次,并在状态即将退出时调用。最后一个函数是Update函数;这将在每个游戏循环中调用,并包含大部分状态的逻辑。虽然这看起来非常简单,但它给了我们一个很好的起点来构建更复杂的状态。现在让我们在我们的示例中实现这个基本的IState类,并看看我们如何将其用于状态机的一个常见需求:创建游戏状态。

首先,我们将创建一个名为GameState的新类,它将继承自IState。这将是我们的游戏所需的所有状态的新基类。GameState.h文件包括以下内容:

#pragma once 
#include <BookEngine\IState.h> 
class GameState : BookEngine::IState 
{ 
public: 
    GameState(); 
    ~GameState(); 
    //Our overrides 
    virtual void OnEntry() = 0; 
    virtual void OnExit() = 0; 
    virtual void Update(float deltaTime) = 0; 
    //Added specialty function 
    virtual void Draw() = 0; 
}; 

GameState类非常类似于它继承的IState类,除了一个关键的区别。在这个类中,我们添加了一个新的虚拟方法Draw(),所有继承自GameState的类现在都将实现它。每次我们使用IState并创建一个新的专门的基类,比如玩家状态、菜单状态等,我们可以添加这些新函数来根据状态机的要求进行定制。这就是我们如何使用继承和多态性来创建更复杂的状态和状态机。

继续我们的示例,现在让我们创建一个新的GameState。我们首先创建一个名为GameWaiting的新类,它继承自GameState。为了更容易跟踪,我将所有新的GameState继承类分组到一个名为GameStates.hGameStates.cpp的文件集中。GamStates.h文件将如下所示:

#pragma once 
#include "GameState.h" 

class GameWaiting: GameState 
{ 
    virtual void OnEntry() override; 
    virtual void OnExit() override; 
    virtual void Update(float deltaTime) override; 
    virtual void Draw() override; 
}; 

class GameRunning: GameState 
{ 
    virtual void OnEntry() override; 
    virtual void OnExit() override; 
    virtual void Update(float deltaTime) override; 
    virtual void Draw() override; 
}; 

class GameOver : GameState 
{ 
    virtual void OnEntry() override; 
    virtual void OnExit() override; 
    virtual void Update(float deltaTime) override; 
    virtual void Draw() override; 
}; 

这里没有什么新东西;我们只是声明了每个GameState类的函数。现在,在我们的GameStates.cpp文件中,我们可以按照前面的代码实现每个单独状态的函数。

#include "GameStates.h" 
    void GameWaiting::OnEntry() 
{ 
...  
//Called when entering the GameWaiting state's OnEntry function 
... 
} 

void GameWaiting::OnExit() 
{ 
...  
//Called when entering the GameWaiting state's OnEntry function 
... 
} 

void GameWaiting::Update(float deltaTime) 
{ 
...  
//Called when entering the GameWaiting state's OnEntry function 
... 

} 

void GameWaiting::Draw() 
{ 
...  
//Called when entering the GameWaiting state's OnEntry function 
... 

} 
...  
//Other GameState implementations  
... 

出于页面空间的考虑,我只显示了GameWaiting的实现,但其他状态也是一样的。每个状态都将有其自己独特的这些函数实现,这使您能够控制代码流程并根据需要实现更多状态,而不会创建一个难以遵循的代码路径迷宫。

现在我们已经定义了我们的状态,我们可以在游戏中实现它们。当然,我们可以以许多不同的方式进行。我们可以遵循与屏幕系统相同的模式,并实现一个GameState列表类,其定义可能如下所示:

    class GameState; 

    class GameStateList { 
    public: 
        GameStateList (IGame* game); 
        ~ GameStateList (); 

        GameState* GoToNext(); 
        GameState * GoToPrevious(); 

        void SetCurrentState(int nextState); 
        void AddState(GameState * newState); 

        void Destroy(); 

        GameState* GetCurrent(); 

    protected: 
        IGame* m_game = nullptr; 
        std::vector< GameState*> m_states; 
        int m_currentStateIndex = -1; 
    }; 
} 

或者我们可以简单地使用我们创建的GameState类与一个简单的enum和一个 switch case。状态模式的使用允许这种灵活性。在示例中,我选择了与屏幕系统相同的设计;您可以在源代码存储库中看到GameStateExample项目的完整实现。值得浏览源代码,因为我们将在整本书中继续使用这些状态设计。尝试修改示例;添加一个创建与其他状态不同的屏幕打印的新状态。您甚至可以尝试在状态内部嵌套状态,以创建更强大的代码分支能力。

与相机一起工作

到目前为止,我们已经讨论了系统结构的很多内容,现在我们已经能够继续设计与我们的游戏和 3D 环境交互的方式。这将我们带到一个重要的话题:虚拟相机系统的设计。相机是为我们提供 3D 世界的视觉表示的东西。这是我们如何沉浸自己,并为我们选择的交互提供反馈。在本节中,我们将讨论计算机图形学中虚拟相机的概念。

在我们开始编写相机代码之前,了解它的工作原理非常重要。让我们从能够在 3D 世界中导航的想法开始。为了做到这一点,我们需要使用所谓的变换管道。变换管道可以被认为是相对于相机视点的位置和方向来转换所有对象和点所采取的步骤。以下是一个详细说明变换管道流程的简单图表:

从管道的第一步开始,局部空间,当一个网格被创建时,它有一个局部原点 0 x,0 y,0 z。这个局部原点通常位于对象的中心,或者在一些玩家角色的情况下,位于脚的中心。构成该网格的所有点都是基于该局部原点的。当谈论一个尚未转换的网格时,我们称之为处于局部空间中。

上图显示了在模型编辑器中的侏儒网格。这就是我们所谓的局部空间。

接下来,我们想将一个网格带入我们的环境,即世界空间。为了做到这一点,我们必须将我们的网格点乘以所谓的模型矩阵。然后将网格放置在世界空间中,这将使所有网格点相对于单个世界原点。最容易将世界空间想象为描述构成游戏环境的所有对象的布局。一旦网格被放置在世界空间中,我们就可以开始做一些事情,比如比较距离和角度。这一步的一个很好的例子是在世界/关卡编辑器中放置游戏对象;这是在与其他对象和单个世界原点(0,0,0)相关的模型网格的描述。我们将在下一章更详细地讨论编辑器。

接下来,为了在这个世界空间中导航,我们必须重新排列点,使它们相对于摄像机的位置和方向。为了实现这一点,我们进行了一些简单的操作。首先是将对象平移到原点。首先,我们会将摄像机从其当前的世界坐标移动。

在下面的示例图中,x轴上有20y轴上有2z轴上有-15,相对于世界原点或0,0,0。然后我们可以通过减去摄像机的位置来映射对象,即用于平移摄像机对象的值,这种情况下为-20-215。因此,如果我们的游戏对象在x轴上开始为10.5,在y轴上为1,在z轴上为-20,则新的平移坐标将是-9.5-1-5。最后一个操作是将摄像机旋转到所需的方向;在我们当前的情况下,这意味着指向-z轴。对于下面的示例,这意味着将对象点旋转-90度,使示例游戏对象的新位置为5-1-9.5。这些操作组合成所谓的视图矩阵:

在我们继续之前,我想简要介绍一些重要的细节,当涉及到处理矩阵时,特别是处理矩阵乘法和操作顺序。在使用 OpenGL 时,所有矩阵都是以列主布局定义的。相反的是行主布局,在其他图形库中可以找到,比如微软的 DirectX。以下是列主视图矩阵的布局,其中 U 是指向上的单位向量,F 是我们指向前方的向量,R 是右向量,P 是摄像机的位置:

构建一个矩阵时,其中包含平移和旋转的组合,比如前面的视图矩阵,通常不能简单地将旋转和平移值放入单个矩阵中。为了创建一个正确的视图矩阵,我们需要使用矩阵乘法将两个或多个矩阵组合成一个最终的矩阵。记住我们使用的是列主记法,因此操作的顺序是从右到左。这很重要,因为使用方向(R)和平移(T)矩阵,如果我们说 V = T x R,这将产生一个不希望的效果,因为这首先会将点围绕世界原点旋转,然后将它们移动到与摄像机位置对齐。我们想要的是 V = R x T,其中点首先与摄像机对齐,然后应用旋转。在行主布局中,当然是相反的:

好消息是,我们不一定需要手动处理视图矩阵的创建。OpenGL 的旧版本和大多数现代数学库,包括 GLM,都有一个lookAt()函数的实现。大多数函数需要相机位置、目标或观察位置以及上方向作为参数,并返回一个完全创建好的视图矩阵。我们将很快看到如何使用 GLM 的lookAt()函数的实现,但如果你想看到刚才描述的想法的完整代码实现,请查看项目源代码库中包含的 GLM 的源代码。

继续通过变换管线,下一步是从眼空间转换为齐次裁剪空间。这个阶段将构建一个投影矩阵。投影矩阵负责一些事情。

首先是定义近裁剪平面和远裁剪平面。这是沿着定义的前向轴(通常为z)的可见范围。任何落在近距离前面或者远距离后面的物体都被视为超出范围。在后续步骤中,处于此范围之外的任何几何对象都将被裁剪(移除)。

第二步是定义视野FOV)。尽管名字是视野,但实际上是一个角度。对于 FOV,我们实际上只指定了垂直范围;大多数现代游戏使用 66 或 67 度。水平范围将由矩阵根据我们提供的宽高比(宽度相对于高度)来计算。举例来说,在 4:3 宽高比的显示器上,67 度的垂直角度将有一个 FOV 为 89.33 度(67 * 4/3 = 89.33)。

这两个步骤结合起来创建了一个形状类似于被截去顶部的金字塔的体积。这个创建的体积被称为视锥体。任何落在这个视锥体之外的几何体都被视为不可见。

以下图示了视锥体的外观:

你可能会注意到在视锥体的末端有更多的可见空间。为了在 2D 屏幕上正确显示这一点,我们需要告诉硬件如何计算透视。这是管线中的下一步。视锥体的较大、远端将被推在一起,形成一个盒子形状。在这个宽端可见的物体也将被挤在一起;这将为我们提供一个透视视图。要理解这一点,想象一下看着一条笔直的铁轨。随着铁轨延伸到远处,它们看起来会变得更小、更接近。

在定义裁剪空间之后,管线中的下一步是使用所谓的透视除法将点归一化为一个具有尺寸为(-1 到 1,-1 到 1,-1 到 1)的盒子形状。这被称为归一化设备空间。通过将尺寸归一化为单位大小,我们允许点被乘以以缩放到任何视口尺寸。

变换管线中的最后一个重要步骤是创建将要显示的 3D 的 2D 表示。为了做到这一点,我们将归一化设备空间中的远处物体绘制在靠近摄像机的物体后面(绘制深度)。尺寸从XY的归一化值被缩放为视口的实际像素值。在这一步之后,我们有了一个称为视口空间的 2D 空间。

这完成了转换管道阶段。有了这个理论,我们现在可以转向实现并编写一些代码。我们将从创建一个基本的第一人称 3D 摄像机开始,这意味着我们是通过玩家角色的眼睛观察。让我们从摄像机的头文件Camera3D.h开始,它可以在源代码库的Chapter05项目文件夹中找到。

... 
#include <glm/glm.hpp> 
#include <glm/gtc/matrix_transform.hpp> 
..., 

我们从必要的包含开始。正如我刚提到的,GLM 包括支持使用矩阵,所以我们包括glm.hppmatrix_transform.hpp来获得 GLM 的lookAt()函数的访问权限。

... 
   public: 
      Camera3D(); 
      ~Camera3D(); 
      void Init(glm::vec3 cameraPosition = glm::vec3(4,10,10), 
              float horizontalAngle = -2.0f,  
              float verticalAngle = 0.0f,  
              float initialFoV = 45.0f); 
      void Update(); 

接下来,我们有 Camera3D 类的公共可访问函数。前两个只是标准的构造函数和析构函数。然后是Init()函数。我们声明这个函数时提供了一些默认值,这样如果没有传入值,我们仍然有值可以在第一次更新调用中计算我们的矩阵。这将带我们到下一个声明的函数,Update()函数。这是游戏引擎每次循环调用以保持摄像机更新的函数。

glm::mat4 GetView() { return m_view; };
glm::mat4 GetProjection() { return m_projection; };
glm::vec3 GetForward() { return m_forward; };
glm::vec3 GetRight() { return m_right; };
glm::vec3 GetUp() { return m_up; };

Update()函数之后,有一组五个获取函数,用于返回视图和投影矩阵,以及摄像机的前向、向上和向右向量。为了保持实现的整洁,我们可以在头文件中简单地声明和实现这些getter函数。

void SetHorizontalAngle(float angle) { m_horizontalAngle = angle; };
void SetVerticalAngle(float angle) { m_verticalAngle = angle; };

在获取函数集之后,我们有两个设置函数。第一个将设置水平角度,第二个将设置垂直角度。当屏幕大小或纵横比发生变化时,这是很有用的。

void MoveCamera(glm::vec3 movementVector) { m_position +=   movementVector; };

Camera3D 类中的最后一个公共函数是MoveCamera()函数。这个简单的函数接收一个向量 3,然后将该向量累加到m_position变量中,这是当前摄像机的位置。

...
  private:
    glm::mat4 m_projection;
    glm::mat4 m_view; // Camera matrix

对于类的私有声明,我们从两个glm::mat4变量开始。glm::mat4是 4x4 矩阵的数据类型。我们创建一个用于视图或摄像机矩阵,一个用于投影矩阵。

glm::vec3 m_position;
float m_horizontalAngle;
float m_verticalAngle;
float m_initialFoV;

接下来,我们有一个单一的三维向量变量来保存摄像机的位置,然后是三个浮点值——一个用于水平角度,一个用于垂直角度,以及一个用于保存视野的变量。

glm::vec3 m_right;
glm::vec3 m_up;
glm::vec3 m_forward; 

然后我们有另外三个向量 3 变量类型,它们将保存摄像机对象的右、上和前向值。

现在我们已经声明了我们的 3D 摄像机类,下一步是实现头文件中尚未实现的任何函数。我们只需要提供两个函数,Init()Update()函数。让我们从Init()函数开始,它位于Camera3D.cpp文件中。

void Camera3D::Init(glm::vec3 cameraPosition, 
     float horizontalAngle, 
     float verticalAngle, 
     float initialFoV)
   {
     m_position = cameraPosition;
     m_horizontalAngle = horizontalAngle;
     m_verticalAngle = verticalAngle;
     m_initialFoV = initialFoV;

     Update();
    }
    ...

我们的Init()函数很简单;在函数中,我们只是接收提供的值并将它们设置为我们声明的相应变量。一旦我们设置了这些值,我们只需调用Update()函数来处理新创建的摄像机对象的计算。

...
   void Camera3D::Update()
   {
      m_forward = glm::vec3(
          glm::cos(m_verticalAngle) * glm::sin(m_horizontalAngle),
          glm::sin(m_verticalAngle),
          glm::cos(m_verticalAngle) * glm::cos(m_horizontalAngle)
        );

Update()函数是类的所有繁重工作都在做的地方。它首先计算摄像机的新前向。这是通过利用 GLM 的余弦和正弦函数的简单公式来完成的。正在发生的是,我们正在从球坐标转换为笛卡尔坐标,以便我们可以在创建我们的视图矩阵中使用该值。

  m_right = glm::vec3(
        glm::sin(m_horizontalAngle - 3.14f / 2.0f),
        0,
        glm::cos(m_horizontalAngle - 3.14f / 2.0f)
     );  

在计算了新的前向之后,我们然后使用一个简单的公式计算摄像机的新右向量,再次利用 GLM 的正弦和余弦函数。

 m_up = glm::cross(m_right, m_forward);

现在我们已经计算出了前向和向上的向量,我们可以使用 GLM 的叉积函数来计算摄像机的新向上向量。这三个步骤在摄像机改变位置或旋转之前,以及在创建摄像机的视图矩阵之前发生是很重要的。

  float FoV = m_initialFoV;

接下来,我们指定视野。目前,我只是将其设置回初始化摄像机对象时指定的初始视野。如果摄像机被放大或缩小,这将是重新计算视野的地方(提示:鼠标滚轮可能在这里很有用):

m_projection = glm::perspective(glm::radians(FoV), 4.0f / 3.0f, 0.1f, 100.0f);

一旦我们指定了视野,我们就可以计算摄像机的投影矩阵。幸运的是,GLM 有一个非常方便的函数叫做glm::perspective(),它接受弧度制的视野、宽高比、近裁剪距离和远裁剪距离,然后返回一个创建好的投影矩阵。由于这只是一个示例,我指定了一个 4:3 的宽高比(4.0f/3.0f)和一个直接的裁剪空间从 0.1 单位到 100 单位。在生产中,你理想情况下会将这些值移动到可以在运行时更改的变量中:

 m_view = glm::lookAt(
            m_position,           
            m_position + m_forward, 
            m_up
         );
      }

最后,在Update()函数中我们要做的是创建视图矩阵。正如我之前提到的,我们很幸运,GLM 库提供了一个lookAt()函数,用于抽象我们在本节前面讨论的所有步骤。这个lookAt()函数接受三个参数。第一个是摄像机的位置。第二个是摄像机指向的矢量值,或者看向的位置,我们通过简单地将摄像机当前位置和计算出的前向矢量相加来提供。最后一个参数是摄像机当前的上矢量,同样,我们之前计算过。完成后,这个函数将返回新更新的视图矩阵,供我们在图形管线中使用。

这就是一个简单的 3D 摄像机类。继续运行 CameraDemo 项目,看看系统是如何运作的。你可以用 WASD 键移动摄像机,用鼠标改变视角。接下来,我们将转向另一个重要的游戏引擎系统,物理!

处理物理

如今,很少有游戏不实现至少一些基本形式的物理。游戏物理的话题相当庞大和复杂,很容易填满几卷书才能算是全面覆盖。正因为如此,整个团队都致力于创建物理引擎,并且可能需要数年的开发才能构建生产级系统。因为情况如此,我们不会尝试在这里覆盖所有方面,而是采取更高层次的方法。我们将覆盖一些更常见的物理系统方面,特别是基本的碰撞检测。对于更高级的需求,比如支持重力、摩擦和高级碰撞检测,我们将覆盖第三方物理库的实现。在本节结束时,我们的演示引擎将具有高级的物理支持。

AABB 中的点

首先,让我们来看看在 3D 中可以执行的较简单的碰撞检查之一,即找出一个点是否在轴对齐边界框AABB)内或外。AABB 非常容易创建。你可以基本上将其想象成不可旋转的立方体或盒子。以下图像描述了 AABB 和点之间的碰撞:

要创建一个边界框,你可以指定一个向量格式的最大点和最小点,或者通过指定一个中心点,然后指定高度、宽度和深度。在这个例子中,我们将使用最小点和最大点的方法创建我们的 AABB:

struct BoundingBox
{
 glm::vec3 m_vecMax;
 glm::vec3 m_vecMin;
};  

前面的代码是一个简单的 AABB 结构的示例。

现在我们有了一个 AABB,我们可以开发一种方法来检查单个点是否落在 AABB 内。这个检查非常简单;我们只需要检查它的所有值,x、y 和 z,是否大于 AABB 的最小值并且小于 AABB 的最大值。在代码中,这个检查看起来会像下面这样,以最简单的形式:

bool PointInAABB(const BoundingBox& box, const glm::vec3 & vecPoint)
 {
   if(vecPoint.x > tBox.m_vecMin.x && vecPoint.x < tBox.m_vecMax.x &&
      vecPoint.y > tBox.m_vecMin.y && vecPoint.y < tBox.m_vecMax.y &&
      vecPoint.z > tBox.m_vecMin.z && vecPoint.z < tBox.m_vecMax.z)
     {
         return true;
     }
    return false;
  }

AABB 到 AABB

现在我们已经看到如何测试一个点是否在某个 AABB 内,接下来我们将看到的非常有用的碰撞检查是 AABB 到 AABB 的检查——一个快速测试,以找出两个 AABB 是否发生碰撞。以下图像描述了这个碰撞检查:

两个 AABB 之间的碰撞检查非常简单和快速。这是大多数需要一种碰撞检测形式的对象的常见选择。

AABB 的不好之处在于它们不能旋转。一旦它们旋转,它们就不再是 AABB,因为它们不再与xyz轴对齐。对于旋转的对象,更好的选择是使用球体、胶囊体,甚至是定向包围盒OBBs)。

要检查两个 AABB 是否发生碰撞,我们只需要检查第一个 AABB 的最大点是否大于第二个 AABB 的最小点,并且第一个 AABB 的最小点是否小于第二个 AABB 的最大点。以下是这个检查在代码中的简单形式:

bool AABBtoAABB(const BoundingBox& box1, const BoundingBox& box2) 
{ 
 if (box1.m_vecMax.x > tBox2.m_vecMin.x &&  
    box1.m_vecMin.x < tBox2.m_vecMax.x && 
    box1.m_vecMax.y > tBox2.m_vecMin.y && 
    box1.m_vecMin.y < tBox2.m_vecMax.y && 
    box1.m_vecMax.z > tBox2.m_vecMin.z && 
    box1.m_vecMin.z < tBox2.m_vecMax.z)  
{  
   return true; 
} 
return false; 
} 

当然,盒子的顺序,哪一个是第一个,哪一个是第二个,都无关紧要。

由于这个检查包含很多&&比较,如果第一个检查是假的,它将不会继续检查其余的;这就是允许非常快速测试的原因。

球到球

我想在这里谈论的最后一个简单的碰撞检查是测试两个球体是否相互碰撞。测试球体之间的碰撞非常简单且易于执行。球体相对于 AABB 等物体的优势在于,不管物体是否旋转,球体都将保持不变。以下是描述两个球体之间碰撞检查的图像:

为了进行检查,我们只需要计算球心之间的距离,并将其与它们的半径之和进行比较。如果这个距离小于它们的半径之和,那么球体重叠。如果相同,那么球体只是接触。以下是这个碰撞测试在代码中的简单形式:

... 
struct BoundingSphere 
{ 
glm::vec3    m_vecCenter; 
float          m_radius; 
}; 
... 
bool SphereToSphere(const BoundingSphere & Sphere1, const BoundingSphere & Sphere2) 
{ 

glm::vec3 distance(Sphere2.m_vecCenter - Sphere1.m_vecCenter); 
float distanceSqaured(glm::dot( & distance, & distance) ); 

为了得到球心之间的距离,我们需要创建一个连接它们中心点的向量:

float radiiSumSquared( Sphere1.m_radius + Sphere2.m_radius ); 
radiiSumSquared *= radiiSumSquared; 

然后我们可以计算该向量与半径之和的长度:

有一种更有效的方法。由于向量与自身的点积等于该向量的平方长度,我们可以只计算该向量的平方长度与半径之和的平方。如果我们这样做,就不需要计算向量的长度,这本身就是一个昂贵的操作。

if( distanceSqaured <= radiiSumSquared ) 
{ 
    return true; 
} 
return false; 
} 
... 

最后,我们可以进行碰撞检查。如果距离的平方小于或等于平方和,那么球体已经碰撞,否则,物体没有碰撞,我们返回 false。

有了这些简单的检查,大多数基本的碰撞检测都可以处理。事实上,正如我们将在下一节中看到的,大多数高级检查都由许多较小的检查组成。然而,总会有一个时刻,你会发现自己需要更高级或优化的物理处理方式;这时你可以求助于第三方库来提供支持。在下一节中,我们将看一下其中一个第三方库的实现。

实现 Bullet 物理库。

Bullet 是一个模拟碰撞检测和软体和刚体动力学的物理引擎。它已经被用于许多发布的视频游戏以及电影中的视觉效果。Bullet 物理库是免费的开源软件,受 zlib 许可证的条款约束。

Bullet 提供的一些功能包括:

  • 刚体和软体模拟,使用离散和连续碰撞检测

  • 碰撞形状:球、盒子、圆柱、锥体、使用 GJK 的凸壳、非凸和三角网格

  • 软体支持:布料、绳索和可变形物体

具有约束限制和电机的丰富的刚体和软体约束集。

你可以在以下网址找到源代码链接和更多信息:bulletphysics.org

让我们看看如何将 Bullet 引入到你自己的游戏项目中。我不打算花时间讲解如何将库链接到我们的演示项目,因为我们已经讲解了几次了。如果你需要复习,请翻回几章看看。我们要做的是将 Bullet 引擎整合到我们的演示引擎中,然后使用 Bullet 引擎的计算来实时定位我们的游戏对象。在这个例子中,我们将创建一个简单的地面平面,然后一个球(球体)下落并与地面碰撞。我们将使用 Bullet 的内置类型来支持这一点,包括重力以给它一个真实的效果。

从地面GameObject开始,我们设置了一些需要的物理值的变量。第一个是btCollisionShape类型。这是一个 Bullet 类型,允许在创建物理测试的边界对象时定义简单的形状。接下来是btDefaultMotionState类型,这也是一个 Bullet 数据类型,描述了物体在运动时的行为方式。我们需要的最后一个变量是btRigidBody类型,这是一个 Bullet 数据类型,将保存我们的物理引擎关心的物体的所有物理属性:

class GroundObject : BookEngine::GameObject 
{ 
   ... 

   btCollisionShape* groundShape = nullptr; 
   btDefaultMotionState* groundMotionState = nullptr; 
   btRigidBody* groundRigidBody = nullptr; 

一旦我们定义了这些变量,我们就可以在Init()函数中构建地面对象的物理表示:

void GroundObject::Init(const glm::vec3& pos, const glm::vec3& scale) 
{ 
   ... 
   groundShape = new btStaticPlaneShape(btVector3(0, 1, 0), 1); 
   groundMotionState = 
      new btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), btVector3(m_position.x, m_position.y, m_position.z))); 

我们首先将我们的groundShape变量设置为btStaticPlanShape。这是一个指定简单平面对象的 Bullet 对象,非常适合我们的需要和一个简单的地面对象。接下来,我们设置groundMotionState。我们通过使用btDefaultMotionState Bullet 对象来实现这一点。btDefaultMotionState是用于指定物体在运动中的行为方式的类型。创建一个新的btDefaultMotionState时,我们需要传入一些关于物体变换的信息,即物体的旋转和位置。为此,我们传入一个btTransform对象,其自身参数为四元数格式的旋转(btQuaternion(0, 0, 0, 1))和三维向量格式的位置(btVector3(m_position.x, m_position.y, m_position.z)):

btRigidBody::btRigidBodyConstructionInfo 
 groundRigidBodyCI(0, groundMotionState, groundShape, btVector3(0, 0,  0)); 
 groundRigidBody = new btRigidBody(groundRigidBodyCI); 

现在,groundShapegroundMotionState设置好了,我们可以继续创建和设置刚体信息。首先,我们为构造信息定义了一个btRigidBodyConstuctionInfo变量,名为groundRigidBodyCI。这个对象接受一些参数值,一个标量值来指定质量,物体的运动状态,碰撞形状,以及一个三维向量来指定局部惯性值。惯性是任何物体对其运动状态的任何改变的抵抗力。基本上是物体保持以恒定速度直线运动的倾向。

由于我们的地面对象是静态的,不需要根据物理输入进行任何更改,我们可以跳过Update()函数,继续进行我们将用来测试系统的 Ball 对象。

进入BallObject.h文件,我们定义了一些我们需要的变量,就像我们为地面对象做的那样。我们创建了一个运动状态,一个标量(整数)值用于质量,碰撞形状,最后是一个刚体:

btDefaultMotionState* fallMotionState;
btScalar mass = 1;
btCollisionShape* fallShape;
btRigidBody* fallRigidBody;
...  

现在,进入BallObject.cpp文件,我们为刚刚定义的变量分配一些值:

void BallObject::Init(const glm::vec3& pos, const glm::vec3& scale)
 {
    ...

    fallShape = new btSphereShape(10);
    btVector3 fallInertia(0.0f, 0.0f, 0.0f);  

首先,我们设置碰撞形状。在这种情况下,我们将使用类型btSphereShape。这是球体的默认形状,并接受一个参数来设置球体的半径。接下来,我们创建一个三维向量来保存球体的惯性。我们将其设置为全零,因为我们希望这个球体根据物体的质量和我们即将设置的重力值自由下落,没有阻力:

fallMotionState =
       new btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1),     
       btVector3(m_position.x, m_position.y, m_position.z)));

接下来,我们设置球的运动状态,就像我们为地面物体做的一样。我们将旋转设置为 0,位置设置为球对象的当前位置:

 fallShape->calculateLocalInertia(mass, fallInertia);
    btRigidBody::btRigidBodyConstructionInfo fallRigidBodyCI(mass,  fallMotionState, fallShape, fallInertia);
    fallRigidBody = new btRigidBody(fallRigidBodyCI);
     }

然后我们使用方便的calculateLocalInertia()函数计算局部惯性值,传入质量和fallInertia值。这将设置我们的球对象的下落向量,用于物理引擎的第一个 tick。最后,我们以与之前地面对象完全相同的方式设置刚体对象。

对于球对象,我们确实希望物理引擎的输出会影响球对象。正因为如此,我们需要在球对象的Update()函数中进行一些调整:

void BallObject::Update(float deltaTime)
 {
    btTransform trans;
    fallRigidBody->getMotionState()->getWorldTransform(trans);
    m_position.x = trans.getOrigin().getX();
    m_position.y = trans.getOrigin().getY();
    m_position.z = trans.getOrigin().getZ();
  }

对于球对象的更新循环中的第一步是从刚体获取物理对象的变换。一旦我们有了这个变换对象,我们就可以将球对象的网格(可见对象)设置为物理变换对象的位置。这就是对象本身的全部内容。球和地面对象现在包含了所有所需的物理信息。现在我们可以将物理引擎循环实现到我们的游戏循环中,并让球滚动,不是在开玩笑!

对于将物理引擎实现到我们现有的游戏引擎循环中,我们首先需要设置一些值。进入我们的Gameplayscreen.h,我们定义变量来保存这些值:

btBroadphaseInterface* broadphase = new btDbvtBroadphase();  

首先是btBroadphaseInterface类对象的定义,它提供了一个 Bullet 接口来检测 AABB 重叠的对象对。在这种情况下,我们将其设置为btDbvtBroadphase,它使用两个动态 AABB 边界体积层次/树来实现btBroadphase。当处理许多移动对象时,这往往是最好的广相位;它的对象插入/添加和移除通常比在btAxisSweep3bt32BitAxisSweep3中找到的扫描和修剪广相位更快:

btDefaultCollisionConfiguration* collisionConfiguration = new     
       btDefaultCollisionConfiguration();
btCollisionDispatcher* dispatcher = new              
       btCollisionDispatcher(collisionConfiguration); btSequentialImpulseConstraintSolver* solver = new    
       btSequentialImpulseConstraintSolver;

接下来,我们已经为碰撞配置、碰撞分发器和顺序脉冲约束求解器定义了。我们不会深入讨论每一个,但主要观点是碰撞配置设置了一些 Bullet 内部值,比如碰撞检测堆栈分配器和池内存分配器。碰撞分发器是处理碰撞的定义。它支持处理凸凸凸凹碰撞对的算法,时间的影响,最近的点和穿透深度。最后,顺序脉冲约束求解器定义了可以被认为是算法,将决定如何解决物体之间的碰撞。对于那些希望了解的人,这是一种单指令,多数据SIMD)实现的投影高斯-塞德尔(迭代 LCP)方法:

btDiscreteDynamicsWorld* dynamicsWorld = new       
     btDiscreteDynamicsWorld(dispatcher, broadphase, solver,    
     collisionConfiguration);

我们需要定义的最后一个变量是我们的动态世界对象。btDiscreteDynamicsWorld提供了离散刚体模拟。这可以被认为是发生物理模拟的环境或世界。一旦我们定义了这个,我们就有了开始物理模拟的所有要素。

让我们跳转到GameplayScreen.cpp文件,看看我们将用来初始化物理模拟的OnEntry()函数:

void GameplayScreen::OnEntry() 
{ 
   ... 

   dynamicsWorld->setGravity(btVector3(0, -1, 0)); 
   dynamicsWorld->addRigidBody(m_ground.groundRigidBody); 
   dynamicsWorld->addRigidBody(m_ball.fallRigidBody); 
... 
} 

我们设置的第一件事是重力向量。在我们的简单示例中,我们将其设置为y轴上的-1。接下来,我们将两个创建的刚体添加到模拟环境中,一个用于地面,一个用于球。这处理了我们物理引擎的初始化;现在我们需要在每个引擎 tick 上更新它:

void GameplayScreen::Update(float deltaTime) 
{ 
   CheckInput(deltaTime); 
   dynamicsWorld->stepSimulation(1 / 60.f, 10); 
   m_ball.Update(deltaTime); 

GameplayScreen::Update()函数中,我们首先检查输入,然后调用物理引擎的更新,最后调用游戏对象本身的更新。重要的是要注意这个顺序。我们首先要接受用户的输入,但我们要确保在对象之前已经更新了物理引擎。原因是物理计算应该对对象产生一些影响,我们不希望出现绘图循环领先于物理循环的情况,因为这肯定会导致一些不需要的效果。您还会注意到物理更新函数stepSimulation接受两个参数。第一个是要按时间步长模拟的时间量。这通常是自上次调用它以来的时间。在这种情况下,我们将其设置为 1/60 秒,或 60 FPS。第二个参数是 Bullet 允许每次调用它执行的最大步数。如果您将一个非常大的值作为第一个参数传递,比如,是固定内部时间步长或游戏时钟大小的五倍,那么您必须增加maxSubSteps的数量来补偿这一点;否则,您的模拟将丢失时间,这将再次导致一些不需要的物理计算输出。

就是这样!我们现在有一个物理引擎在运行其模拟,并影响我们在屏幕上绘制的世界中的对象。您可以通过在Chapter05 GitHub 存储库中运行PhysicsDemo示例项目来看到这一点。输出将类似于以下内容:

总结

在本章中,我们涵盖了很多内容,并在开发专业级项目所需的核心游戏系统方面取得了良好的进展。我们现在拥有自己的自定义游戏状态系统,可以被游戏引擎中的许多其他组件采用。我们在构建对摄像机的工作原理的理解的同时,开发了自己的自定义摄像机系统。最后,我们看了一下如何通过将 Bullet 物理引擎添加到我们的示例引擎中,可以向我们的项目添加完整的第三方游戏系统。

第六章:创建图形用户界面

在游戏中,用户交互是设计中非常重要的一部分。能够为用户提供视觉信息和视觉选择的能力是图形用户界面GUI)的作用所在。与本书中讨论的许多其他系统一样,已经有现成的库可供使用。在开源游戏开发世界中最常见的一个是Crazy Eddies GUICEGUI)。虽然 CEGUI 是一个非常强大的 GUI 系统实现,但随着这种强大性而来的是复杂性,老实说,大多数时候你真的只需要一个文本标签、一个简单的按钮,也许还有一个复选框和图标支持。有了这些简单的构建模块,你就可以创建很多东西。

在本章中,我们将构建基本组件并创建一个简单的 GUI 系统。需要注意的是,从头开始创建一个完整的、可生产的 GUI 系统是一项艰巨的任务,不是一个单独的章节可以完成的。因此,我们将专注于核心概念,并构建一个可以在以后扩展和扩展的系统。我们的 GUI 将不使用任何 API 特定内容,并将继续构建前几章创建的结构。本章涉及的主题如下:

  • 坐标系统和定位

  • 添加控制逻辑

  • 渲染 GUI

本章的完整代码示例可以在代码存储库的Chapter06文件夹中找到。为了简洁起见,我将省略一些非必要的代码行,并可能更频繁地跳转文件和类。

坐标系统和定位

每个 GUI 系统中最重要的部分之一是对象/元素在屏幕上的位置。在大多数情况下,图形 API 使用称为屏幕空间的坐标,通常表示为绝对范围[-1,1]。虽然这对于渲染是很好的,但在尝试开发我们的 GUI 系统时可能会引起一些问题。例如,让我们以使用绝对系统的想法为例。在这个系统中,我们将明确地将 GUI 中的每个元素设置为真实的像素坐标。这可能很容易实现,但只有在游戏的分辨率保持不变的情况下才能工作。如果我们在任何时候改变分辨率,元素将保持锁定在其像素坐标上,并且不会按比例缩放以匹配新的分辨率。

另一个选择是创建一个相对系统,其中每个 GUI 元素的位置都是相对于其他元素或屏幕位置描述的。这种方法比绝对系统好得多,但仍然存在一些缩放问题。例如,如果我们在屏幕的左上角放置了一个带有小偏移的元素,如果游戏的分辨率在任何时候发生了变化,我们使用的间距也会发生变化。

我们要构建的是 CEGUI 所采用的一种类似方法,这是前面提到的两种解决方案的结合。与此同时,我们还将添加现代 GUI 中使用的另一个常见约定:将组合的元素包含在面板中。我们希望将 GUI 元素分组在面板中有几个很好的理由。首先,如果我们想移动一堆元素,比如一个带有健康、弹药和物品指示器的状态栏,如果我们将它们分组在一个面板中,我们只需要移动面板,所有元素都会正确地跟随移动。这就引出了第二个原因:通过在面板中将元素分组在一起,我们可以定义元素相对于面板位置的位置,而不是将元素位置设置为像素坐标或相对于屏幕位置。

以下是描述此设计布局的图表:

如您所见,使用了相对和绝对定位的组合,但这次相对起始点不是整个屏幕的原点[0,0],而是我们面板的原点[0,0]。虽然面板的原点在屏幕上已经有一些坐标,但我们不使用它们来设置元素的位置。

理论上,我们现在在面板内有可扩展的元素,但我们仍然需要一种方法来锁定固定面板的位置,而不受屏幕分辨率的影响。这就是 GUI 锚点系统的概念发挥作用的地方。如果您以前使用过 GUI,很可能已经看到过锚点的作用。在我们的例子中,为了节省时间,我们将稍微简化这个概念。在我们的系统中,每个面板都将能够将其原点相对于五个锚点之一设置:左上、右上、左下、右下和中心。

以下图表演示了这个概念:

好的,那么我们如何在代码中实现这些概念并设计它们呢?让我们从一个所有其他元素都将继承的IGUIElement类开始。看一下IGUIElement类:

class IGUIElement
{
public:
virtual void Update() = 0;
glm::vec2 GetPosition() { return m_position; };
protected:
glm::vec2 m_position;
};
}

首先,我们的元素并不复杂。每个元素都将有一个Update()函数,以及一个 getter 函数来返回元素的位置。我们将在本章后面扩展这个类。

我们可以实现系统的下一部分是面板的概念。让我们从IGUIPanel.h的头文件开始看一下:

...
static enum class GUIAnchorPos {
TopRight,
TopLeft,
BottomRight,
BottomLeft,
Center
};
...

文件以声明一个名为GUIAnchorPosenum class开始;这个enum将给元素们访问计算出的锚点的权限。我们将这个enum类作为IGUIPanel类内部的一个enum,而不是一个IGUIPanel实例的需要,这样可以让元素们在不需要IGUIPanel实例的情况下访问锚点。后面,我们将看到一个将这些枚举值连接到已计算出的屏幕位置的函数。

...
IGUIPanel(glm::vec4 panelBounds = glm::vec4(0,0,200,480),
glm::vec2 panelAnchor = glm::vec2(0,0),
glm::vec2 offset = glm::vec2(0,0));
...

文件中感兴趣的下一部分是构造函数。在这里,我们要求传入一个 vector 4 来定义要创建的面板的边界。接下来,我们要求一个 vector two 来定义面板锚点的原点位置,以及一个 vector two 来提供面板位置的偏移或填充。您还会注意到,我们还为每个参数提供了一些默认值。我们这样做有几个原因,但最重要的原因是我们希望能够默认创建 GUI 元素并将它们附加到面板上。通过提供默认值,如果我们创建了一个 GUI 元素,而没有现有的面板可以附加它,我们可以在创建时不需要传入值来创建一个面板。我们将在本章后面重新讨论这个问题。让我们继续实现:

IGUIPanel::IGUIPanel(glm::vec4 panelBounds, glm::vec2 panelAnchor, glm::vec2 offset) : m_bounds(panelBounds), m_offset(offset)
{
  m_Pos = panelAnchor + m_offset;
  m_panelWidth = m_bounds.z;
  m_panelHeight = m_bounds.w;
}

对于IGUIPanel构造函数的实现,我们首先要计算的是面板在屏幕上的位置。我们通过将面板的锚点与传入的偏移相加来实现这一点,并将其存储在受保护的成员变量m_Pos中。接下来,我们计算面板的宽度和高度;我们使用传入的边界值来实现这一点。我们分别将它们存储在名为m_panelWidthm_panelHeight的受保护成员变量中。

现在我们已经放置了面板构造函数,我们可以继续设置面板如何保存它们的元素。为了实现这一点,我们简单地创建了一个名为m_GUIElementListIGUIElements指针的向量。然后我们可以开始创建一些公共方法来访问和操作面板的元素列表:

...
  void IGUIPanel::AddGUIElement(IGUIElement & GUIElement)
  {
     m_GUIElement.List.push_back(&GUIElement);
  }
...

首先,在IGUIPanel.cpp文件中,我们创建一个AddGUIElement()函数来向面板添加新元素。这个函数实现了对面板元素列表的push_back()方法的调用,将给定的GUIElement引用推入其中:

virtual std::vector<IGUIElements*>& GetGUIElementList() 
{ 
   return m_ GetGUIElementList; 
};

跳转到IGUIPanel.h文件,我们实现了一个 getter 函数GetGUIElementList(),以提供对私有元素列表的公共访问:

void IGUIPanel::Update()
{
  for (auto const& element : m_ m_GUIElement.List)
  {
     element ->Update();
  }
}

切换回IGUIPanel.cpp文件,我们可以查看面板类的Update()函数的实现。这个更新将遍历面板的元素列表,然后调用列表中每个元素的Update()函数。这将允许面板控制其元素的更新,并为实现诸如在面板隐藏时暂停元素更新等概念提供结构:

IGUIPanel::~IGUIPanel()
{
  std::for_each(m_GUIElementList.begin(),
  m_ GUIElementList.end(),
  std::default_delete<IGUIElement>());
}

最后,我们需要记住在调用析构函数时清理属于面板的所有元素。为此,我们将使用standard库的for_each()方法。我们主要使用这个方法是因为这是一个例子,而且我想向你介绍它。for_each()方法接受三个参数。前两个应用于范围,第三个是要执行的函数。在我们的例子中,我们将在我们遍历的每个元素上调用default_delete(),再次使用这个方法是为了向你介绍这个函数。default_delete()函数实际上是一个函数对象类,其类似函数的调用接受一个模板化的对象类型并删除它。这可以与简单使用 delete 进行删除操作的非专门化版本或用于数组的专门化版本delete[]进行比较。这个类专门设计用于与unique_ptr一起使用,并提供了一种在没有开销的情况下删除unique_ptr对象的方法。

好了,现在我们已经放置了IGUIPanel类,我们可以继续构建我们 GUI 系统所需的更复杂的元素。在这个例子中,我们将添加一个带有标签支持的基本按钮:

...
class IGUIButton : public IGUIElement
{
 public:
 IGUIButton(glm::vec4& bounds,
 glm::vec2& position,
 GLTexture* texture,
 std::string label,
 SpriteFont* font,
 glm::vec2& fontScale = glm::vec2(1.0f),
 IGUIPanel* panel = NULL);
 ~IGUIButton();
 virtual void Update() override;
...

IGUIButton.h文件中,我们可以看到按钮继承自我们基本的IGUIElement。这当然意味着我们可以访问父类的所有函数和受保护的成员,包括m_positionGetPosition()函数,因此我们不在这里重新定义它们。当我们查看IGUIButton.h时,我们还可以看一下构造函数,在那里我们定义了创建按钮时需要传入的内容。在我们的示例按钮中,我们正在寻找按钮的边界(大小),位置,绘制按钮时要使用的纹理,按钮的标签(要显示的文本),用于标签的字体,字体的比例(我们默认为1.0f),最后是要将按钮添加到的面板,默认为NULL,除非另有说明。随着我们继续本章,我们将更深入地研究这些参数。

在构造函数的实现方面,在IGUIButton.cpp中,在IGUIButton::IGUIButton(glm::vec4 & bounds, glm::vec2 & position, std::string label, GLTexture * texture, SpriteFont* font, glm::vec2& fontScale, IGUIPanel* panel)之前:


m_texture(*texture),
m_buttonLabel(label),
m_spriteFont(font),
m_fontScale(fontScale),
m_panel(panel)
{
   m_bounds = bounds;
   if (m_panel != NULL)
   {
   m_position = *m_panel->GetPosition() + position;

在大部分情况下,我们只是将内部成员变量设置为传入的值,但值得注意的是我们如何处理面板的值。在构造函数体中,我们进行了一个检查,看看m_panel中存储的值是否为空。如果这个检查为真,我们可以继续设置按钮元素相对于面板位置的位置。我们首先调用面板的GetPosition()函数,将返回的值添加到我们传入的位置值中,并将该计算保存在m_position成员变量中。这将部分地通过将按钮的位置设置为面板的关系原点来给我们想要的东西,但由于默认面板元素的原点是左下角,结果是按钮被放置在面板的底部。这不一定是期望的行为。为了纠正这一点,我们需要根据面板的顶部计算按钮的新y轴值,当然还有面板中已经存在的元素:

//Move to just below the last element in the list
if (!m_panel->GetGUIElementList().empty())
{
  IGUIElement* lastElement = m_panel-> GetGUIElementList().back();
  m_position.y = lastElement ->GetPosition().y -
  lastElement ->GetBounds().w -
  10.0f; // Used as default padding (should be dynamic)
}
else
{
   //Move to top of panel
   m_position.y += m_panel->GetBounds()->w - m_bounds.w;
   }
  }
}

首先,我们要检查我们要添加按钮的面板是否已经有任何现有元素。我们通过检查面板的向量和GetGUIElementList().empty()函数来实现这一点。如果面板的元素列表不为空,我们需要获取面板列表中最后一个元素的位置。我们通过创建一个临时元素lastElement并使用GetGUIElementList().back()将其赋值为面板列表中的最后一个元素来实现这一点。有了存储的元素,我们可以用它来计算按钮的y轴值。我们通过从存储的元素的y轴值减去存储的元素的高度(GetBounds().w)和一个默认的填充值来实现这一点,在这个例子中我们将填充值设置为10.0f。在完整的 GUI 实现中,您可能希望使这个填充值动态化。最后,如果面板是空的,并且这是第一个元素,我们通过计算面板的高度(GetBounds()->w)减去新按钮的高度来设置按钮的y轴。这将把按钮元素放在面板的顶部。

现在我们有了一个带有元素类和实现的按钮元素的面板系统。我们需要做的最后一件事是构建一个高级类来将系统粘合在一起。我们将创建一个IGUI类,它将容纳面板,为其他游戏系统提供对 GUI 方法的访问,并且,正如我们将在接下来的部分中看到的,提供输入、更新和绘制机制。让我们跳转到IGUI.cpp文件中的构造函数实现:

IGUI::IGUI(Window& window) : m_window(window)
{
...
m_BL = new glm::vec2( 
                      0,
                      0
                      );
m_BR = new glm::vec2( 
                      m_window.GetScreenWidth(),
                      0
                      );
m_TL = new glm::vec2( 
                      0,
                      m_window.GetScreenHeight()
                      );
m_TR = new glm::vec2( 
                      m_window.GetScreenWidth(),                     
                      m_window.GetScreenHeight()
                     );
m_C = new glm::vec2( 
                     m_window.GetScreenWidth() * 0.5f,                 
                     m_window.GetScreenHeight() * 0.5f
                     );
 ...

IGUI类的构造函数中,我们将定义我们将用于IGUI实例中保存的所有面板的锚点。我们将把这些值存储在私有成员变量中:m_BL表示屏幕左下角,m_BR表示屏幕右下角,m_TL表示屏幕左上角,m_TR表示屏幕右上角,m_C表示屏幕中心。我们使用设置m_window窗口对象来返回用于计算锚点的屏幕的宽度和高度。我们将看到这些点如何用于后面的课程中为面板提供锚点。

接下来,让我们看一下我们将用来将元素和面板添加到IGUI实例中的函数。

void IGUI::AddGUIElement(IGUIElement& GUIElement)
{
   if (!m_GUIPanelsList.empty())
  {
   m_GUIPanelsList[0]->AddGUIObject(GUIElement);
   }
   else
   {
   IGUIPanel* panel = new IGUIPanel();
   m_GUIPanelsList.push_back(panel);
   m_GUIPanelsList[0]->AddGUIObject(GUIElement);
   }
}

AddGUIElement函数开始,这个函数,正如它的名字所暗示的那样,将一个 GUI 元素添加到 GUI 中。默认情况下,元素将被添加到 GUI 的面板列表中找到的第一个面板中,这些面板存储在m_GUIPanelsList向量中。如果面板列表为空,我们将创建一个新的面板,将其添加到列表中,然后最终将元素添加到该面板中:

void IGUI::AddGUIPanel(IGUIPanel& GUIPanel)
{
  m_GUIPanelsList.push_back(&GUIPanel);
}

AddGUIPanel()函数非常简单。我们使用push_back()向量方法将传入的IGUIPanel对象添加到 GUI 的面板列表中。

我们需要查看的定位系统的最后一部分是GetAnchorPos()函数。这个函数将根据之前在IGUI构造函数中看到的计算屏幕值和面板本身的大小返回面板的锚点位置:

...
glm::vec2* IGUI::GetAnchorPos(GUIAnchorPos anchorPos, glm::vec4 bounds)
{
  switch (anchorPos)
  {
    case(GUIAnchorPos::TopRight):
    m_TR->y -= bounds.w;
    m_TR->x -= bounds.z;
    return m_TR;
    break;
    case(GUIAnchorPos::TopLeft):
    m_TL->y -= bounds.w;
    return m_TL;
    break;
    case(GUIAnchorPos::BottomRight):
    m_BR->x -= bounds.z;
    return m_BR;
    break;
    case(GUIAnchorPos::BottomLeft):
    return m_BL;
    break;
    case(GUIAnchorPos::Center):
    m_C->y -= bounds.w;
    return m_C;
    break;
  }
}
...

我们首先传入两个值。第一个是GUIAnchorPos,您可能还记得在IGUIPanel.h文件中定义enum类时在本章前面的部分。第二个是用四个向量对象描述的面板的边界。在函数内部,我们有一个 switch case 语句,我们使用它来确定要计算的锚点。

如果情况匹配TopRight枚举值,首先我们修改锚点的y轴值。我们这样做是因为我们使用左下角作为默认原点,所以我们需要修改这一点,使得左上角成为锚点的新原点。接下来,我们修改锚点的x轴值。我们这样做是因为我们需要将锚点从屏幕的右上角移动到面板对象的宽度。如果我们不修改x轴值,面板将绘制到屏幕右侧。

接下来,如果情况匹配TopLeft枚举值,我们修改锚点的y轴值。如前所述,我们这样做是为了考虑我们的坐标系的原点位于左下角。这次我们不需要修改x轴的值,因为当我们从左到右绘制时,我们的面板将出现在屏幕上。

如果情况匹配BottomRight枚举值,我们需要修改x轴的值。如前所述,我们需要将锚点向左移动面板的宽度,以确保面板绘制在屏幕上。这次我们不需要修改y轴的值,因为锚点将匹配默认坐标系的屏幕底部的y原点。

如果情况匹配BottomLeft枚举值,我们只需返回未修改的锚点,因为它与坐标系的默认原点匹配。

最后,如果情况匹配Center枚举值,我们只会修改y轴的值,因为我们只需要考虑默认原点位于屏幕左下角。构造函数中计算的x轴值将使面板向右移动,以正确地将其定位在屏幕中心。

这样就处理了我们的 GUI 系统的定位和锚点系统。我们现在有了一个坚实的框架,可以在本章的其余部分继续构建。接下来,我们将看看如何将输入控制添加到我们的 GUI 系统中。

添加控制逻辑

GUI 远不止是屏幕上所见的。在幕后还有逻辑运行,提供与对象交互所需的功能。处理鼠标移动到元素上时发生的情况,复选框被选中时发生的情况,或者按钮被点击时发生的情况,都是 GUI 输入系统的一部分。在本节中,我们将构建处理 GUI 鼠标输入所需的架构。

虽然我们可以以几种不同的方式实现处理 GUI 输入的系统,但我认为这是一个完美的机会,可以向您介绍我最喜欢的编程模式之一,即观察者模式。观察者是四人帮中最广为人知的模式之一。观察者如此常用,以至于 Java 有一个专门的核心库java.util.Observer,而 C#则将其纳入语言本身,以事件关键字的形式。

我认为解释观察者模式最简单的方法是,当您有对象执行另一个类或对象感兴趣的各种操作时,您可以订阅 事件,并在这些对象执行其有趣功能时得到通知。很可能您在开发过程中已经见过并/或使用过观察者模式。事实上,我们在本书中已经见过它。SDL 库使用自己的观察者模式来处理输入。我们利用它来根据用户的输入执行任务。以下是我们用来处理游戏输入的 SDL 事件实现:

SDL_Event event;
while (SDL_PollEvent(&event))
{
  m_game->OnSDLEvent(event);
}

我们要构建的东西可能有点基础,但它将让您了解如何为 GUI 实现输入系统,并且您可以希望熟悉一种灵活的模式,以便未来的开发。

首先,在IGUIElement头文件中,我们创建一个名为GUIEvent的新enum类:

enum class GUIEvent
{
 HoverOver,
 Released,
 Clicked,
};

这个enum类定义了我们的 GUI 元素可以监听的不同类型的事件。接下来,在我们的IGUIElement类头文件中,我们需要添加一个完全虚拟的函数OnNotify()

virtual void OnNotify(IGUIElement& element, GUIEvent event) = 0;

这个函数将被每种元素类型覆盖,并在事件发生时调用。实现了这个函数的元素可以监听对它们重要的事件,并根据需要执行操作。OnNotify()接受两个参数:一个定义受影响的元素的IGUIElement(),以及事件类型。这两个参数将为我们提供确定如何处理发送的每个事件的所有所需信息。

让我们来看看我们的IGUIButton()对象类中OnNotify()的实现:

void IGUIButton::OnNotify(IGUIElement & button, GUIEvent event)
{
   If(event == GUIEvent::HoverOver)
  {
   //Handle Hover
  }
}

IGUIButton::OnNotify的实现中,我们可以监听传入的不同类型的事件。在这个例子中,我们正在检查传入的事件是否是HoverOver事件。如果是,我们会添加一个注释,说明当按钮悬停时我们需要执行的任何操作。这就是设置listener的全部内容。接下来,我们需要将我们的 GUI 输入系统连接到当前输入系统,并开始发送事件通知。让我们继续看看IGUI对象类中CheckInput()函数的实现:

void IGUI::CheckInput(InputManager inputManager)
{
   float pointX = inputManager.GetMouseCoords().x;
   float pointY = inputManager.GetMouseCoords().y;
   for (auto &panel : m_GUIPanelsList) // access by reference to avoid                  
                                          copying
   {
    for (auto& object : panel->GetGUIElementList())
    {
    //Convert Y coordinate position to top upper left origin, y-down
     float convertedY =
     m_window.GetScreenHeight() -
     (object->GetPosition().y + object->GetBounds().w);
     if (pointX < object->GetPosition().x + (object->GetBounds().z) &&
     pointX >(object->GetPosition().x - (object->GetBounds().z)) &&
     pointY < convertedY + object->GetBounds().w &&
     pointY > convertedY - object->GetBounds().w)
    {
      object->OnNotify(*object, GUIEvent::HoverOver); 
      }
    }
  }
}

我们将逐步查看它。首先,我们从传入的InputManager对象中获取当前鼠标坐标,并将它们保存到临时变量中:

void IGUI::CheckInput(InputManager inputManager)
{
float pointX = inputManager.GetMouseCoords().x;
float pointY = inputManager.GetMouseCoords().y;

接下来,我们需要使用嵌套的for循环来遍历 GUI 中的所有面板,依次遍历每个面板上附加的所有元素:

for (auto &panel : m_GUIPanelsList) // access by reference to avoid copying
{
for (auto& object : panel->GetGUIElementList())
{

在嵌套循环内,我们将进行一个简单的hit测试,以查看我们是否在按钮的边界内。然而,首先,我们需要进行一个快速的计算。在本章的坐标和位置部分中,您可能还记得我们进行了一个转换,将锚点的y轴移动到左上角。现在我们需要做相反的操作,将元素位置的y轴转换回到左下角。我们之所以需要这样做,是因为鼠标光标的屏幕坐标系统与按钮位置相同:

float convertedY = m_window.GetScreenHeight() -
                  (object->GetPosition().y + object->GetBounds().w);

循环中我们需要做的最后一件事是执行实际的hit或边界检查。为此,我们检查并查看鼠标光标的x轴值是否在按钮的屏幕区域内。我们还使用之前转换的y值在y轴上进行相同的检查。如果所有这些条件都满足,那么我们可以向元素发送一个HoverOver事件通知:

if (pointX <element->GetPosition().x + (element->GetBounds().z) &&
pointX >(element->GetPosition().x - (element->GetBounds().z)) &&
pointY < convertedY + element->GetBounds().w &&
pointY > convertedY - element->GetBounds().w)
{
   object->OnNotify(*object, GUIEvent::HoverOver);
}
...

通过这样,我们虽然粗糙,但已经有了一个工作的事件系统。我们需要放置的最后一块拼图是将其连接到游戏引擎的当前输入处理系统。为此,我们在ExampleScreen类的CheckInput()函数中添加一行简单的代码,m_gui->CheckInput(m_game->GetInputManager());

void ExampleScreen::CheckInput(float deltaTime)
{
   SDL_Event event;
   while (SDL_PollEvent(&event))
   {
   m_game->OnSDLEvent(event);
   }
   ...
   m_gui->CheckInput(m_game->GetInputManager());
   ...
}

这就完成了本章示例的逻辑实现。肯定还有重构和调优的空间,但这应该为您提供了一个扩展的良好起点。我建议您继续进行下一步,并添加更多功能,甚至可能添加新的元素来使用。在下一节中,我们将通过向我们的 GUI 系统添加渲染并最终在屏幕上绘制示例来结束本章。

渲染 GUI

有了所有的定位和输入逻辑,我们现在可以通过实现一些基本的渲染来完成我们的 GUI 系统。好消息是,我们在书中前面已经建立了一个强大的主要渲染基础设施。我们将利用这个基础设施在屏幕上渲染我们的 GUI。基本上,在渲染 GUI 时有两种选择。您可以将 GUI 渲染到纹理中,然后将创建的纹理混合到最终绘制的场景中。另一个选择是在每一帧中将所有内容作为几何体渲染在场景的顶部。两者都有各自的问题,但我认为在大多数情况下,创建纹理并混合该纹理会比将 GUI 元素渲染为几何体要慢。

为了保持事情稍微简单,并更专注于实现,我们从一个更简单的方法开始,分别渲染每个元素。当然,如果 GUI 中有大量元素,这并不是最友好的性能渲染方式。在我们的示例中,我们不会有大量元素,如果您正在构建类似开始游戏/菜单 GUI 的东西,当前形式的解决方案将是完全足够的。注意您的帧率,如果注意到下降,那么很可能是有太多的绘制调用。

我们可以采用与渲染模型时相同的方法来处理我们的解决方案,只是有些细微差异。我们将再次使用着色器来绘制几何图形,因为这将为我们提供大量控制和执行任何混合、蒙版、图案和效果的能力。对于我们的 GUI 示例,我们将重用前几章的纹理顶点和片段着色器。在下一章中,我们将深入探讨高级着色器和绘图技术。

所以,让我们深入实现。将这些添加到IGUI.h文件中:

std::unique_ptr<Camera2D> m_camera = nullptr; 

        std::unique_ptr<ShaderManager> m_textureProgram = nullptr; 
        std::unique_ptr<SpriteBatch> m_spriteBatch = nullptr; 

然后在IGUI对象的构造函数中添加这个:

IGUI::IGUI(Window& window) : m_window(window)
{
   m_camera = std::make_unique<Camera2D>();
   ...
   m_textureProgram = std::make_unique<BookEngine::ShaderManager>();
   m_spriteBatch = std::make_unique<BookEngine::SpriteBatch>();
}

在这里,我们指定了一个着色器纹理程序、一个精灵批处理和一个 2D 相机。这个相机与我们在本书前面创建的 3D 版本略有不同。我不会深入讨论 2D 相机,因为它略微超出了本章的范围,但我会提到主要的变化是我们正在为 2D 绘图构建正交矩阵。我们为每个 GUI 实例提供自己的着色器、相机和精灵批处理。最终设置将由实例来处理。

ExampleGUI是我们示例中IGUI类的实现。看一下OnInit()函数,我们可以看到这些资源的设置:

void ExampleGUI::OnInit()
{
m_textureProgram->CompileShaders(
                        "Shaders/textureShading.vert",
                        "Shaders/textureShading.frag");
m_textureProgram->AddAttribute("vertexPosition");
m_textureProgram->AddAttribute("vertexColor");
m_textureProgram->AddAttribute("vertexUV");
m_textureProgram->LinkShaders();
m_spriteBatch->Init();
m_camera->Init(m_window.GetScreenWidth(), 
               m_window.GetScreenHeight());
m_camera->SetPosition(glm::vec2(
                                m_window.GetScreenWidth() * 0.5f, 
                                m_window.GetScreenHeight()* 0.5f));
panel = new BookEngine::IGUIPanel(
                                glm::vec4(0, 0, 150, 500),
                                *GetAnchorPos(
                                   BookEngine::GUIAnchorPos:BottomLeft,
                                    glm::vec4(0, 0, 150, 500)
                                  ),
                                  glm::vec2(0,0));
AddGUIPanel(*panel);

      BookEngine::GLTexture texture
    =BookEngine::ResourceManager::GetTexture("Textures/button.png");

button = new BookEngine::IGUIButton(
    glm::vec4(0, 0, 100, 50),
    glm::vec2(10, -10),"My Button", &texture,
    new BookEngine::SpriteFont("Fonts/Impact_Regular.ttf", 72),
       glm::vec2(0.2f), panel);

       AddGUIElement (*button);
}

我们将逐个分解。首先,我们需要编译我们 GUI 所需的Shaders,所以我们添加着色器所需的属性,最后将它们链接以供使用。这应该很熟悉:

m_textureProgram->CompileShaders(
"Shaders/textureShading.vert",
"Shaders/textureShading.frag");
m_textureProgram->AddAttribute("vertexPosition");
m_textureProgram->AddAttribute("vertexColor");
m_textureProgram->AddAttribute("vertexUV");
m_textureProgram->LinkShaders();
Next, we call Init on the sprite batch for the GUI instance:
m_spriteBatch->Init();

然后我们在 2D 相机实例上调用Init,传递屏幕宽度和高度。在Init之后,我们将相机的位置设置为屏幕中间,通过将屏幕的高度和宽度值除以 2:

m_camera->Init(m_window.GetScreenWidth(), 
               m_window.GetScreenHeight());
m_camera->SetPosition(glm::vec2(
                       m_window.GetScreenWidth() * 0.5f,
                       m_window.GetScreenHeight()* 0.5f));

现在我们有了着色器程序、精灵批处理和相机设置,我们继续创建 GUI 元素。首先是面板元素,我们使用之前在本章创建的架构来创建它。我们将其锚点设置为屏幕的左下角。面板创建完成后,我们通过调用类继承的AddGUIPanel函数将其添加到 GUI 实例中:

panel = new BookEngine::IGUIPanel(glm::vec4(0, 0, 150, 500),
                                 *GetAnchorPos(
                                 BookEngine::GUIAnchorPos:BottomLeft,                                  
                                 glm::vec4(0, 0, 150, 500)
                                 ),
  glm::vec2(0,0));
  AddGUIPanel(*panel);

面板创建并添加到 GUI 实例的面板列表后,我们将一个按钮添加到该面板。为此,我们首先创建一个临时变量来保存我们想要为此按钮加载的纹理。然后我们创建按钮本身。我们再次使用本章前面构建的结构。我们传入标签My Button和刚刚加载的纹理。完成后,我们调用AddGUIElement()函数并将按钮添加到面板:

BookEngine::GLTexture texture = BookEngine::ResourceManager::GetTexture("Textures/button.png");
button = new BookEngine::IGUIButton(
           glm::vec4(0, 0, 100, 50),
           glm::vec2(10, -10),
           "My Button",
           &texture,
           new BookEngine::SpriteFont("Fonts/Impact_Regular.ttf", 72),
glm::vec2(0.2f), panel);
AddGUIElement (*button);

现在我们的元素已经就位,渲染组件已经创建并设置好,我们可以为 GUI 系统最终确定渲染流程。为了做到这一点,我们将回归到我们在对象中创建的继承结构。要开始绘制调用链,我们从ExampleGUI类和它的Draw()函数实现开始:

void ExampleGUI::Draw() 
{ 

    ... 

    m_textureProgram->Use(); 

    ... 

    m_spriteBatch->Begin(); 

    //Draw all of the panels 
    for (auto const&panel : m_GUIPanelsList) 
    { 
        panel->Draw(*m_spriteBatch); 
    } 

    m_spriteBatch->End(); 
    m_spriteBatch->BatchRender(); 
    m_textureProgram->UnUse(); 

} 

关注我们 GUI 实现的一个重要方面,我们首先在Draw()函数中指定我们在渲染 GUI 元素时要使用的着色器程序。接下来,我们启动将用于 GUI 元素的精灵批次。然后,在精灵批次的开始和结束之间,我们使用一个for循环来遍历 GUI 面板列表中的所有面板,并调用其Draw()函数的实现。一旦for循环完成,我们就结束了精灵批次,调用BatchRender()方法来渲染批次中的所有对象,最后通过在着色器程序上调用UnUse()方法来关闭函数。

让我们在绘制链中再往下一级,并查看 IGUIPanel 的 Draw 函数实现:

void IGUIPanel::Draw(SpriteBatch& spriteBatch) 
    { 
spriteBatch.Draw(glm::vec4(m_Pos.x,  
m_Pos.y, 
m_panelWidth,  
m_panelHeight), 
 glm::vec4(0,0,1,1), 
BookEngine::ResourceManager::GetTexture( 
"Textures/background.png").id,  
-0.1f,  
ColorRGBA8(0,0,0,75) 
); 

        for (auto const&element : m_GUIElementList) 
        { 
            element->Draw(spriteBatch); 
        } 
    } 

IGUIPanel::Draw()函数中,我们首先将面板本身添加到从调用对象传入的精灵批次中。这将绘制一个略带不透明的黑色背景。理想情况下,您希望使用于背景的纹理成为一个非硬编码的值,并允许为每个实例进行设置。在我们将面板添加到用于绘制的精灵批次后,我们再次使用for循环来遍历面板元素列表中的每个元素,并调用其Draw()函数的实现。这实际上将其使用推到了绘制链中的下一层。

对于IGUIElement类,我们只需创建一个纯虚函数,继承该函数的元素将不得不实现:

virtual void Draw(SpriteBatch& spriteBatch) = 0;

这意味着我们现在可以进入我们绘制链示例中的最后一个链接,并查看IGUIButton::Draw()函数的实现:

void IGUIButton::Draw(SpriteBatch& spriteBatch)   { 
        ... 

        spriteBatch.Draw(glm::vec4(m_position.x, 
 m_position.y,  
m_bounds.z,  
m_bounds.w),  
uvRect,  
m_texture.id,  
0.0f,  
ColorRGBA8(255, 255, 255, 255)); 

        char buffer[256]; 
        m_spriteFont->Draw(spriteBatch,  
buffer,  
glm::vec2( 
m_position.x + (m_bounds.z * 0.5f),  
(m_position.y + (m_bounds.w * 0.5f)) - ((m_spriteFont->GetFontHeight() * m_fontScale.y) * 0.5f) 
), 
                            m_fontScale,  
0.2f,  
BookEngine::ColorRGBA8(255, 255, 255, 255), 
Justification::MIDDLE);         
    } 

再次,这些函数的实现并不太复杂。我们将元素添加到由调用对象传入的精灵批次中以进行绘制。这样做的效果是,所有面板及其元素将被添加到单个 GUI 实例的精灵批次中,这将比每个面板和对象依次绘制自身要更高效。Draw()函数中的最后一个代码块是对 Sprite Font 实例的Draw()方法的调用。我不会详细介绍 Sprite Font 类的工作原理,因为这超出了本章的范围,但请查看代码文件以了解其内部工作原理。SpriteFont类的作用与Sprite类类似,只是它提供了在屏幕上绘制字体/文本的方法。在这个例子中,我们使用它来绘制按钮的标签。

这就结束了绘制链。现在我们只需要将 GUI 的头部Draw()调用连接到主游戏的Draw()调用。为此,在ExampleScreen类的Draw()函数中添加一行调用 GUI 实例的Draw()方法:

void EditorScreen::Draw()
{ 
... 
    m_gui->Draw(); 
} 

现在,我很高兴地说,我们已经有了一个简单但完整的工作 GUI 系统。您可以运行示例演示来查看已完成的 GUI 运行情况。如果您想要查看面板如何受到每个定义的锚点的影响,您只需要在ExampleGUI类中设置面板时更改BookEngine::GUIAnchorPos的值:

 panel = new BookEngine::IGUIPanel(glm::vec4(0, 0, 150, 500), 
*GetAnchorPos( 
BookEngine::GUIAnchorPos::BottomRight, 
glm::vec4(0, 0, 150, 500) 
), 
 glm::vec2(0,0)); 

以下是 GUI 在运行中的屏幕截图,其锚点已更改为BottomLeftBottomRightTopLeftTopRightCenter

BottomRight的屏幕截图如下图所示:

BottomLeft的屏幕截图如下图所示:

TopLeft的屏幕截图如下图所示:

TopRight的屏幕截图如下图所示:

Center的屏幕截图如下图所示:

总结

在本章中,我们涵盖了大量信息。我们讨论了创建 GUI 所需的不同方面。我们深入探讨了工作 GUI 背后的核心架构。我们开发了一个面板和元素架构,包括用于控制定位的锚点。我们使用“观察者”设计模式实现了用户输入结构,并通过编写渲染管道来显示屏幕上的 GUI 元素。在下一章中,我们将深入探讨游戏开发中使用的一些高级渲染技术。

第七章:高级渲染

玩家对游戏的第一印象通常来自屏幕上的视觉效果。深入了解创建高级渲染技术对于构建引人入胜和沉浸式体验至关重要。在本章中,我们将探讨如何通过实现着色器技术来创建一些高级渲染效果。

  • 着色器简介

  • 照明技术

  • 使用着色器创建效果

着色器简介

简而言之,着色器是用于进行图像处理的计算机程序,例如特效、颜色效果、照明和着色。在运行时,可以使用在着色器程序中构建的算法改变屏幕上所有像素、顶点或纹理的位置、亮度、对比度、色调和其他效果,以产生最终图像。如今,大多数着色器程序都是为了直接在图形处理单元GPU)上运行而构建的。着色器程序是并行执行的。这意味着,例如,一个着色器可能会在每个像素上执行一次,每次执行都在 GPU 上的不同线程上同时运行。同时执行的线程数量取决于图形卡特定的 GPU,现代卡配备了数千个处理器。所有这些意味着着色器程序可以非常高效,并为开发人员提供了很多创造性的灵活性。在本节中,我们将熟悉着色器并为示例引擎实现自己的着色器基础设施。

着色器语言

随着图形卡技术的进步,渲染管线增加了更多的灵活性。曾经开发人员对于固定功能管线渲染等概念几乎没有控制权,新的进步使程序员能够更深入地控制图形硬件来渲染他们的作品。最初,这种更深入的控制是通过使用汇编语言编写着色器来实现的,这是一项复杂而繁琐的任务。不久之后,开发人员渴望有一个更好的解决方案。着色器编程语言应运而生。让我们简要地看一下一些常用的语言。

图形 CCg)是由 Nvidia 图形公司最初开发的着色语言。Cg 基于 C 编程语言,尽管它们共享相同的语法,但对 C 的一些特性进行了修改,并添加了新的数据类型,使 Cg 更适合于编程 GPU。Cg 编译器可以输出由 DirectX 和 OpenGL 都支持的着色器程序。虽然 Cg 大部分已经被淘汰,但它在 Unity 游戏引擎中的使用使其以一种新形式复兴。

高级着色语言HLSL)是由微软公司为 DirectX 图形 API 开发的着色语言。HLSL 再次是基于 C 编程语言建模,并且与 Cg 着色语言有许多相似之处。HLSL 仍在开发中,并且继续是 DirectX 的首选着色语言。自 DirectX 12 发布以来,HLSL 语言甚至支持更低级的硬件控制,并且性能有了显著的改进。

OpenGL 着色语言GLSL)是一种基于 C 编程语言的着色语言。它是由OpenGL 架构审查委员会OpenGL ARB)创建的,旨在使开发人员能够更直接地控制图形管线,而无需使用 ARB 汇编语言或其他硬件特定语言。该语言仍在开发中,并且将是我们在示例中专注的语言。

构建着色器程序基础设施

大多数现代着色器程序由多达五种不同类型的着色器文件组成:片段或像素着色器、顶点着色器、几何着色器、计算着色器和镶嵌着色器。构建着色器程序时,每个这些着色器文件必须被编译和链接在一起以供使用,就像 C++程序的编译和链接一样。接下来,我们将带您了解这个过程是如何工作的,看看我们如何构建一个基础设施,以便更轻松地与我们的着色器程序进行交互。

首先,让我们看看如何编译 GLSL 着色器。GLSL 编译器是 OpenGL 库的一部分,我们的着色器可以在 OpenGL 程序中进行编译。我们将构建一个支持内部编译的架构。编译着色器的整个过程可以分解为一些简单的步骤。首先,我们必须创建一个着色器对象,然后将源代码提供给着色器对象。然后我们可以要求着色器对象被编译。这些步骤可以用以下三个基本调用来表示 OpenGL API。

首先,我们创建着色器对象:

GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);

我们使用glCreateShader()函数创建着色器对象。我们传递的参数是我们要创建的着色器的类型。着色器的类型可以是GL_VERTEX_SHADERGL_FRAGMENT_SHADERGL_GEOMETRY_SHADERGL_TESS_EVALUATION_SHADERGL_TESS_CONTROL_SHADERGL_COMPUTE_SHADER。在我们的示例中,我们尝试编译一个顶点着色器,所以我们使用GL_VERTEX_SHADER类型。

接下来,我们将着色器源代码复制到着色器对象中:

GLchar* shaderCode = LoadShader("shaders/simple.vert");
glShaderSource(vertexShader, 1, shaderCode, NULL);

在这里,我们使用glShaderSource()函数将我们的着色器源代码加载到内存中。这个函数接受一个字符串数组,所以在调用glShaderSource()之前,我们使用一个尚未创建的方法创建一个指向shaderCode数组对象开头的指针。glShaderSource()的第一个参数是着色器对象的句柄。第二个是包含在数组中的源代码字符串的数量。第三个参数是指向源代码字符串数组的指针。最后一个参数是包含前一个参数中每个源代码字符串的长度的GLint值的数组。

最后,我们编译着色器:

glCompileShader(vertexShader);

最后一步是编译着色器。我们通过调用 OpenGL API 方法glCompileShader()来实现这一点,并传递我们想要编译的着色器的句柄。

当然,因为我们正在使用内存来存储着色器,我们应该知道如何在完成后进行清理。要删除着色器对象,我们可以调用glDeleteShader()函数。

删除着色器对象当不再需要着色器对象时,可以通过调用glDeleteShader()来删除。这将释放着色器对象使用的内存。应该注意,如果着色器对象已经附加到程序对象,即链接到着色器程序,它不会立即被删除,而是被标记为删除。如果对象被标记为删除,它将在从链接的着色器程序对象中分离时被删除。

一旦我们编译了我们的着色器,我们在将它们用于程序之前需要采取的下一步是将它们链接在一起成为一个完整的着色器程序。链接步骤的核心方面之一涉及从一个着色器的输入变量到另一个着色器的输出变量之间建立连接,并在着色器的输入/输出变量与 OpenGL 程序本身的适当位置之间建立连接。

链接与编译着色器非常相似。我们创建一个新的着色器程序,并将每个着色器对象附加到它上。然后我们告诉着色器程序对象将所有内容链接在一起。在 OpenGL 环境中实现这些步骤可以分解为对 API 的几个调用,如下所示:

首先,我们创建着色器程序对象:

GLuint shaderProgram = glCreateProgram();

首先,我们调用glCreateProgram()方法创建一个空的程序对象。这个函数返回一个句柄给着色器程序对象,这个例子中我们将其存储在一个名为shaderProgram的变量中。

接下来,我们将着色器附加到程序对象:

glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);

为了将每个着色器加载到着色器程序中,我们使用glAttachShader()方法。这个方法接受两个参数。第一个参数是着色器程序对象的句柄,第二个是要附加到着色器程序的着色器对象的句柄。

最后,我们链接程序:

glLinkProgram(programHandle);

当我们准备将着色器链接在一起时,我们调用glLinkProgram()方法。这个方法只有一个参数:我们要链接的着色器程序的句柄。

重要的是,我们记得清理掉我们不再使用的任何着色器程序。要从 OpenGL 内存中删除着色器程序,我们调用glDeleteProgram()方法。glDeleteProgram()方法接受一个参数:要删除的着色器程序的句柄。这个方法调用使句柄无效,并释放着色器程序使用的内存。重要的是要注意,如果着色器程序对象当前正在使用,它不会立即被删除,而是被标记为删除。这类似于删除着色器对象。还要注意,删除着色器程序将分离在链接时附加到着色器程序的任何着色器对象。然而,这并不意味着着色器对象会立即被删除,除非这些着色器对象已经被之前调用glDeleteShader()方法标记为删除。

这些就是创建、编译和链接着色器程序所需的简化 OpenGL API 调用。现在我们将继续实现一些结构,使整个过程更容易处理。为此,我们将创建一个名为ShaderManager的新类。这个类将充当编译、链接和管理着色器程序清理的接口。首先,让我们看一下ShaderManager.cpp文件中CompileShaders()方法的实现。我应该指出,我将专注于与架构实现相关的代码的重要方面。本章的完整源代码可以在 GitHub 存储库的Chapter07文件夹中找到。

void ShaderManager::CompileShaders(const std::string&                        
                        vertexShaderFilePath, const std::string&      
                        fragmentShaderFilepath)
{
   m_programID = glCreateProgram();
   m_vertexShaderID = glCreateShader(GL_VERTEX_SHADER);
   if (m_vertexShaderID == 0){
      Exception("Vertex shader failed to be created!");
   }
   m_fragmentShaderID = glCreateShader(GL_FRAGMENT_SHADER);
   if (m_fragmentShaderID == 0){
    Exception("Fragment shader failed to be created!");
   }
   CompileShader(vertexShaderFilePath, m_vertexShaderID);
   CompileShader(fragmentShaderFilepath, m_fragmentShaderID);
}

首先,对于这个示例,我们专注于两种着色器类型,所以我们的ShaderManager::CompileShaders()方法接受两个参数。第一个参数是顶点着色器文件的文件路径位置,第二个是片段着色器文件的文件路径位置。两者都是字符串。在方法体内,我们首先使用glCreateProgram()方法创建着色器程序句柄,并将其存储在m_programID变量中。接下来,我们使用glCreateShader()命令创建顶点和片段着色器的句柄。我们在创建着色器句柄时检查是否有任何错误,如果有,我们会抛出一个带有失败的着色器名称的异常。一旦句柄被创建,我们接下来调用CompileShader()方法,接下来我们将看到。CompileShader()函数接受两个参数:第一个是着色器文件的路径,第二个是编译后的着色器将被存储的句柄。

以下是完整的CompileShader()函数。它处理了从存储中查找和加载着色器文件,以及在着色器文件上调用 OpenGL 编译命令。我们将逐块地分解它:

void ShaderManager::CompileShader(const std::string& filePath, GLuint id) 
{
  std::ifstream shaderFile(filePath);
  if (shaderFile.fail()){
     perror(filePath.c_str());
     Exception("Failed to open " + filePath);
  }
    //File contents stores all the text in the file
     std::string fileContents = "";
    //line is used to grab each line of the file
    std::string line;
   //Get all the lines in the file and add it to the contents
    while (std::getline(shaderFile, line)){
    fileContents += line + "n";
 }
   shaderFile.close();
   //get a pointer to our file contents c string
   const char* contentsPtr = fileContents.c_str();   //tell opengl that        
   we want to use fileContents as the contents of the shader file 
  glShaderSource(id, 1, &contentsPtr, nullptr);
  //compile the shader
  glCompileShader(id);
  //check for errors
  GLint success = 0;
  glGetShaderiv(id, GL_COMPILE_STATUS, &success);
  if (success == GL_FALSE){
    GLint maxLength = 0;
    glGetShaderiv(id, GL_INFO_LOG_LENGTH, &maxLength);
    //The maxLength includes the NULL character
    std::vector<char> errorLog(maxLength);
    glGetShaderInfoLog(id, maxLength, &maxLength, &errorLog[0]);
    //Provide the infolog in whatever manor you deem best.
    //Exit with failure.
    glDeleteShader(id); //Don't leak the shader.
    //Print error log and quit
    std::printf("%sn", &(errorLog[0]));
        Exception("Shader " + filePath + " failed to compile");
  }
}

首先,我们使用一个ifstream对象打开包含着色器代码的文件。我们还检查是否有任何加载文件的问题,如果有,我们会抛出一个异常通知我们文件打开失败:

std::ifstream shaderFile(filePath);
if (shaderFile.fail()) {
  perror(filePath.c_str());
  Exception("Failed to open " + filePath);
}

接下来,我们需要解析着色器。为此,我们创建一个名为fileContents的字符串变量,它将保存着色器文件中的文本。然后,我们创建另一个名为 line 的字符串变量;这将是我们试图解析的着色器文件的每一行的临时持有者。接下来,我们使用while循环逐行遍历着色器文件,逐行解析内容并将每个循环保存到fileContents字符串中。一旦所有行都被读入持有变量,我们调用shaderFileifstream对象上的 close 方法,以释放用于读取文件的内存:

std::string fileContents = "";
std::string line;
while (std::getline(shaderFile, line)) {
  fileContents += line + "n";
}
shaderFile.close();

您可能还记得本章前面提到的,当我们使用glShaderSource()函数时,我们必须将着色器文件文本作为指向字符数组开头的指针传递。为了满足这一要求,我们将使用一个巧妙的技巧,即利用字符串类内置的 C 字符串转换方法,允许我们返回指向我们着色器字符数组开头的指针。如果您不熟悉,这本质上就是一个字符串:

const char* contentsPtr = fileContents.c_str();

现在我们有了指向着色器文本的指针,我们可以调用glShaderSource()方法告诉 OpenGL 我们要使用文件的内容来编译我们的着色器。最后,我们使用着色器的句柄作为参数调用glCompileShader()方法:

glShaderSource(id, 1, &contentsPtr, nullptr);
glCompileShader(id);

这处理了编译,但是为自己提供一些调试支持是个好主意。我们通过在CompileShader()函数中首先检查编译过程中是否有任何错误来实现这种编译调试支持。我们通过请求来自着色器编译器的信息来做到这一点,通过glGetShaderiv()函数,其中,它的参数之一是指定我们想要返回的信息。在这个调用中,我们请求编译状态:

GLint success = 0;
glGetShaderiv(id, GL_COMPILE_STATUS, &success);

接下来,我们检查返回的值是否为GL_FALSE,如果是,那意味着我们出现了错误,应该向编译器请求更多关于编译问题的信息。我们首先询问编译器错误日志的最大长度。我们使用这个最大长度值来创建一个名为 errorLog 的字符值向量。然后,我们可以通过使用glGetShaderInfoLog()方法请求着色器编译日志,传入着色器文件的句柄、我们要提取的字符数以及我们要保存日志的位置:

if (success == GL_FALSE){
  GLint maxLength = 0;
  glGetShaderiv(id, GL_INFO_LOG_LENGTH, &maxLength);
  std::vector<char> errorLog(maxLength); 
  glGetShaderInfoLog(id, maxLength, &maxLength, &errorLog[0]);

一旦我们保存了日志文件,我们继续使用glDeleteShader()方法删除着色器。这确保我们不会因为着色器而产生任何内存泄漏:

glDeleteShader(id);

最后,我们首先将错误日志打印到控制台窗口。这对于运行时调试非常有用。我们还会抛出一个异常,其中包括着色器名称/文件路径以及编译失败的消息:

std::printf("%sn", &(errorLog[0]));
Exception("Shader " + filePath + " failed to compile");
}
...

通过提供简单的接口来调用底层 API,这真的简化了编译着色器的过程。现在,在我们的示例程序中,要加载和编译着色器,我们使用类似以下的一行简单代码:

shaderManager.CompileShaders("Shaders/SimpleShader.vert",
"Shaders/SimpleShader.frag");

现在编译了着色器,我们已经完成了可用着色器程序的一半。我们仍然需要添加一个部分,即链接。为了抽象出一些链接着色器的过程并为我们提供一些调试功能,我们将为我们的ShaderManager类创建LinkShaders()方法。让我们看一下,然后分解它:

void ShaderManager::LinkShaders() {
//Attach our shaders to our program
glAttachShader(m_programID, m_vertexShaderID);
glAttachShader(m_programID, m_fragmentShaderID);
//Link our program
glLinkProgram(m_programID);
//Note the different functions here: glGetProgram* instead of glGetShader*.
GLint isLinked = 0;
glGetProgramiv(m_programID, GL_LINK_STATUS, (int *)&isLinked);
if (isLinked == GL_FALSE){
  GLint maxLength = 0;
  glGetProgramiv(m_programID, GL_INFO_LOG_LENGTH, &maxLength);
  //The maxLength includes the NULL character
  std::vector<char> errorLog(maxLength);
  glGetProgramInfoLog(m_programID, maxLength, &maxLength,   
  &errorLog[0]);
  //We don't need the program anymore.
  glDeleteProgram(m_programID);
  //Don't leak shaders either.
  glDeleteShader(m_vertexShaderID);
  glDeleteShader(m_fragmentShaderID);
  //print the error log and quit
  std::printf("%sn", &(errorLog[0]));
  Exception("Shaders failed to link!");
}
  //Always detach shaders after a successful link.
  glDetachShader(m_programID, m_vertexShaderID);
  glDetachShader(m_programID, m_fragmentShaderID);
  glDeleteShader(m_vertexShaderID);
  glDeleteShader(m_fragmentShaderID);
}

要开始我们的LinkShaders()函数,我们调用glAttachShader()方法两次,分别使用先前创建的着色器程序对象的句柄和我们希望链接的每个着色器的句柄:

glAttachShader(m_programID, m_vertexShaderID);
glAttachShader(m_programID, m_fragmentShaderID);

接下来,我们通过调用glLinkProgram()方法,使用程序对象的句柄作为参数,执行实际的着色器链接,将它们链接成一个可用的着色器程序:

glLinkProgram(m_programID);

然后我们可以检查链接过程是否已经完成,没有任何错误,并提供任何调试信息,如果有任何错误的话。我不会逐行讲解这段代码,因为它几乎与我们使用CompileShader()函数时所做的工作完全相同。但是请注意,从链接器返回信息的函数略有不同,使用的是glGetProgram*而不是之前的glGetShader*函数:

GLint isLinked = 0;
glGetProgramiv(m_programID, GL_LINK_STATUS, (int *)&isLinked);
if (isLinked == GL_FALSE){
  GLint maxLength = 0;
  glGetProgramiv(m_programID, GL_INFO_LOG_LENGTH, &maxLength);
  //The maxLength includes the NULL character
  std::vector<char> errorLog(maxLength);  
  glGetProgramInfoLog(m_programID, maxLength, &maxLength,   
  &errorLog[0]);
  //We don't need the program anymore.
  glDeleteProgram(m_programID);
  //Don't leak shaders either.
  glDeleteShader(m_vertexShaderID);
  glDeleteShader(m_fragmentShaderID);
  //print the error log and quit
  std::printf("%sn", &(errorLog[0]));
  Exception("Shaders failed to link!");
}

最后,如果我们在链接过程中成功了,我们需要稍微清理一下。首先,我们使用glDetachShader()方法从链接器中分离着色器。接下来,由于我们有一个完成的着色器程序,我们不再需要保留着色器在内存中,所以我们使用glDeleteShader()方法删除每个着色器。同样,这将确保我们在着色器程序创建过程中不会泄漏任何内存:

  glDetachShader(m_programID, m_vertexShaderID);
  glDetachShader(m_programID, m_fragmentShaderID);
  glDeleteShader(m_vertexShaderID);
  glDeleteShader(m_fragmentShaderID);
}

现在我们有了一个简化的方式将我们的着色器链接到一个工作的着色器程序中。我们可以通过简单地使用一行代码来调用这个接口到底层的 API 调用,类似于以下的代码:

  shaderManager.LinkShaders();

这样处理了编译和链接着色器的过程,但与着色器一起工作的另一个关键方面是将数据传递给运行在 GPU 上的程序/游戏和着色器程序之间的数据传递。我们将看看这个过程,以及如何将其抽象成一个易于使用的接口,用于我们引擎。接下来。

处理着色器数据

与着色器一起工作的最重要的方面之一是能够将数据传递给运行在 GPU 上的着色器程序,并从中传递数据。这可能是一个深入的话题,就像本书中的其他话题一样,有专门的书籍来讨论。在讨论这个话题时,我们将保持在较高的层次上,并再次专注于基本渲染所需的两种着色器类型:顶点和片段着色器。

首先,让我们看看如何使用顶点属性和顶点缓冲对象VBO)将数据发送到着色器。顶点着色器的工作是处理与顶点连接的数据,进行任何修改,然后将其传递到渲染管线的下一阶段。这是每个顶点发生一次。为了使着色器发挥作用,我们需要能够传递数据给它。为此,我们使用所谓的顶点属性,它们通常与所谓的 VBO 紧密配合工作。

对于顶点着色器,所有每个顶点的输入属性都使用关键字in进行定义。例如,如果我们想要定义一个名为VertexColour的三维向量输入属性,我们可以写如下内容:

in vec3 VertexColour;

现在,VertexColour属性的数据必须由程序/游戏提供。这就是 VBO 发挥作用的地方。在我们的主游戏或程序中,我们建立输入属性和顶点缓冲对象之间的连接,还必须定义如何解析或遍历数据。这样,当我们渲染时,OpenGL 可以从缓冲区中为每个顶点着色器调用提取属性的数据。

让我们来看一个非常简单的顶点着色器:

#version 410
in vec3 VertexPosition;
in vec3 VertexColour;
out vec3 Colour;
void main(){
  Colour = VertexColour;
  gl_Position = vec4(VertexPosition, 1.0);
}

在这个例子中,这个顶点着色器只有两个输入变量,VertexPositionVertexColor。我们的主 OpenGL 程序需要为每个顶点提供这两个属性的数据。我们将通过将我们的多边形/网格数据映射到这些变量来实现。我们还有一个名为Colour的输出变量,它将被发送到渲染管线的下一阶段,即片段着色器。在这个例子中,Colour只是VertexColour的一个未经处理的副本。VertexPosition属性只是被扩展并传递到 OpenGL API 输出变量gl_Position以进行更多处理。

接下来,让我们来看一个非常简单的片段着色器:

#version 410
in vec3 Colour;
out vec4 FragColour;
void main(){
  FragColour = vec4(Colour, 1.0);
}

在这个片段着色器示例中,只有一个输入属性Colour。这个输入对应于前一个渲染阶段的输出,顶点着色器的Colour输出。为了简单起见,我们只是扩展了Colour并将其输出为下一个渲染阶段的变量FragColour

这总结了连接的着色器部分,那么我们如何在引擎内部组合和发送数据呢?我们可以基本上通过四个步骤来完成这个过程。

首先,我们创建一个顶点数组对象VAO)实例来保存我们的数据:

GLunit vao;

接下来,我们为每个着色器的输入属性创建和填充 VBO。我们首先创建一个 VBO 变量,然后使用glGenBuffers()方法生成缓冲对象的内存。然后,我们为我们需要缓冲区的不同属性创建句柄,并将它们分配给 VBO 数组中的元素。最后,我们通过首先调用glBindBuffer()方法为每个属性填充缓冲区,指定要存储的对象类型。在这种情况下,对于两个属性,它是GL_ARRAY_BUFFER。然后我们调用glBufferData()方法,传递类型、大小和绑定句柄。glBufferData()方法的最后一个参数是一个提示 OpenGL 如何最好地管理内部缓冲区的参数。有关此参数的详细信息,请参阅 OpenGL 文档:

GLuint vbo[2];
glGenBuffers(2, vbo);
GLuint positionBufferHandle = vbo[0];
GLuint colorBufferHandle = vbo[1];
glBindBuffer(GL_ARRAY_BUFFER,positionBufferHandle);
glBufferData(GL_ARRAY_BUFFER,
             9 * sizeof(float),
             positionData,
             GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER,
             colorBufferHandle);
glBufferData(GL_ARRAY_BUFFER,
             9 * sizeof(float),
             colorData,
             GL_STATIC_DRAW);

第三步是创建和定义 VAO。这是我们将定义着色器的输入属性和我们刚刚创建的缓冲区之间关系的方法。VAO 包含了关于这些连接的信息。要创建一个 VAO,我们使用glGenVertexArrays()方法。这给了我们一个新对象的句柄,我们将其存储在之前创建的 VAO 变量中。然后,我们通过调用glEnableVertexAttribArray()方法来启用通用顶点属性索引 0 和 1。通过调用启用属性,我们指定它们将被访问和用于渲染。最后一步是将我们创建的缓冲对象与通用顶点属性索引进行匹配:

glGenVertexArrays( 1, &vao );
glBindVertexArray(vao);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, positionBufferHandle);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL);
glBindBuffer(GL_ARRAY_BUFFER, colorBufferHandle);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, NULL);

最后,在我们的Draw()函数调用中,我们绑定到 VAO 并调用glDrawArrays()来执行实际的渲染:

glBindVertexArray(vaoHandle);glDrawArrays(GL_TRIANGLES, 0, 3 );

在我们继续传递数据到着色器的另一种方式之前,我们需要讨论这种属性连接结构的另一个部分。如前所述,着色器中的输入变量在链接时与我们刚刚看到的通用顶点属性相关联。当我们需要指定关系结构时,我们有几种不同的选择。我们可以在着色器代码本身中使用称为布局限定符的内容。以下是一个例子:

layout (location=0) in vec3 VertexPosition;

另一种选择是让链接器在链接时创建映射,然后在之后查询它们。我个人更喜欢的第三种方法是在链接过程之前指定关系,通过调用glBindAttribLocation()方法。我们将在讨论如何抽象这些过程时很快看到这是如何实现的。

我们已经描述了如何使用属性将数据传递给着色器,但还有另一个选择:统一变量。统一变量专门用于不经常更改的数据。例如,矩阵非常适合作为统一变量的候选对象。在着色器内部,统一变量是只读的。这意味着该值只能从着色器外部更改。它们还可以出现在同一着色器程序中的多个着色器中。它们可以在程序中的一个或多个着色器中声明,但是如果具有给定名称的变量在多个着色器中声明,则其类型在所有着色器中必须相同。这使我们了解到统一变量实际上是在整个着色器程序的共享命名空间中保存的。

要在着色器中使用统一变量,首先必须在着色器文件中使用统一标识符关键字声明它。以下是这可能看起来的样子:

uniform mat4 ViewMatrix;

然后我们需要从游戏/程序内部提供统一变量的数据。我们通过首先使用glGetUniformLocation()方法找到变量的位置,然后使用glUniform()方法之一为找到的位置赋值。这个过程的代码可能看起来像下面这样:

GLuint location = glGetUniformLocation(programHandle," ViewMatrix ");
if( location >= 0 )
{
glUniformMatrix4fv(location, 1, GL_FALSE, &viewMatrix [0][0])
}

然后我们使用glUniformMatrix4fv()方法为统一变量的位置赋值。第一个参数是统一变量的位置。第二个参数是正在分配的矩阵的数量。第三个是 GL bool类型,指定矩阵是否应该被转置。由于我们在矩阵中使用 GLM 库,不需要转置。如果您使用的是按行顺序而不是按列顺序的数据来实现矩阵,您可能需要对这个参数使用GL_TRUE类型。最后一个参数是统一变量的数据的指针。

统一变量可以是任何 GLSL 类型,包括结构和数组等复杂类型。OpenGL API 提供了与每种类型匹配的不同后缀的glUniform()函数。例如,要分配给vec3类型的变量,我们将使用glUniform3f()glUniform3fv()方法(v表示数组中的多个值)。

因此,这些是将数据传递给着色器程序和从着色器程序传递数据的概念和技术。然而,就像我们为编译和链接着色器所做的那样,我们可以将这些过程抽象成我们ShaderManager类中的函数。我们将专注于处理属性和统一变量。我们有一个很好的类来抽象模型/网格的 VAO 和 VBO 的创建,我们在第四章中详细讨论了这一点,构建游戏系统,当时我们讨论了构建资产流水线。要查看它是如何构建的,要么翻回到第四章构建游戏系统,要么查看BookEngine解决方案的Mesh.hMesh.cpp文件中的实现。

首先,我们将看一下使用ShaderManger类的AddAttribute()函数添加属性绑定的抽象。这个函数接受一个参数,作为字符串绑定的属性名称。然后我们调用glBindAttribLocation()函数,传递程序的句柄和当前属性的索引或数量,我们在调用时增加,最后是attributeName字符串的 C 字符串转换,它提供了指向字符串数组中第一个字符的指针。这个函数必须在编译之后调用,但在着色器程序链接之前调用。

void ShaderManager::AddAttribute(const std::string& attributeName)
{
glBindAttribLocation(m_programID,
                     m_numAttributes++,
                     attributeName.c_str());
 }

对于统一变量,我们创建一个抽象查找着色器程序中统一变量位置的函数GetUniformLocation()。这个函数再次只接受一个变量,即以字符串形式的统一变量名称。然后我们创建一个临时持有者来保存位置,并将其赋值为glGetUniformLocation()方法调用的返回值。我们检查位置是否有效,如果不是,我们抛出一个异常,让我们知道错误。最后,如果找到,我们返回有效的位置。

GLint ShaderManager::GetUniformLocation(const std::string& uniformName)
{
    GLint location = glGetUniformLocation(m_programID,
    uniformName.c_str());
    if (location == GL_INVALID_INDEX) 
    {
     Exception("Uniform " + uniformName + " not found in shader!");
    }
  return location;
}

这为我们绑定数据提供了抽象,但我们仍然需要指定哪个着色器应该用于某个绘制调用,并激活我们需要的任何属性。为了实现这一点,我们在ShaderManager中创建一个名为Use()的函数。这个函数将首先使用glUseProgram()API 方法调用将当前着色器程序设置为活动的着色器程序。然后我们使用一个 for 循环来遍历着色器程序的属性列表,激活每一个:

void ShaderManager::Use(){
  glUseProgram(m_programID);
  for (int i = 0; i < m_numAttributes; i++) { 
    glEnableVertexAttribArray(i);
  }
}

当然,由于我们有一种抽象的方法来启用着色器程序,所以我们应该有一个函数来禁用着色器程序。这个函数与Use()函数非常相似,但在这种情况下,我们将正在使用的程序设置为 0,有效地使其为NULL,并使用glDisableVertexAtrribArray()方法在 for 循环中禁用属性:

void ShaderManager::UnUse() {
  glUseProgram(0);
  for (int i = 0; i < m_numAttributes; i++) {
    glDisableVertexAttribArray(i);
 }
}

这种抽象的净效果是,我们现在可以通过几个简单的调用来设置整个着色器程序结构。类似以下的代码将创建和编译着色器,添加必要的属性,将着色器链接到程序中,找到一个统一变量,并为网格创建 VAO 和 VBO:

shaderManager.CompileShaders("Shaders/SimpleShader.vert",
                             "Shaders/SimpleShader.frag");
shaderManager.AddAttribute("vertexPosition_modelspace");
shaderManager.AddAttribute("vertexColor");
shaderManager.LinkShaders();
MatrixID = shaderManager.GetUniformLocation("ModelViewProjection");
m_model.Init("Meshes/Dwarf_2_Low.obj", "Textures/dwarf_2_1K_color.png");

然后,在我们的Draw循环中,如果我们想要使用这个着色器程序进行绘制,我们可以简单地使用抽象函数来激活和停用我们的着色器,类似于以下代码:

  shaderManager.Use();
  m_model.Draw();
  shaderManager.UnUse();

这使得我们更容易使用着色器来测试和实现高级渲染技术。我们将使用这种结构来构建本章剩余部分以及实际上整本书的示例。

光照效果

着色器最常见的用途之一是创建光照和反射效果。通过使用着色器实现的光照效果有助于提供每个现代游戏都追求的一定程度的光泽和细节。在接下来的部分,我们将看一些用于创建不同表面外观效果的知名模型,并提供可以实现所讨论的光照效果的着色器示例。

每顶点漫反射

首先,我们将看一下其中一个较为简单的光照顶点着色器,即漫反射反射着色器。漫反射被认为更简单,因为我们假设我们正在渲染的表面看起来在所有方向上均匀地散射光线。通过这个着色器,光线与表面接触并在稍微穿透后在所有方向上被投射出去。这意味着一些光的波长至少部分被吸收。漫反射着色器的一个很好的例子是哑光油漆。表面看起来非常暗淡,没有光泽。

让我们快速看一下漫反射的数学模型。这个反射模型需要两个向量。一个是表面接触点到初始光源的方向,另一个是同一表面接触点的法向量。这看起来像下面这样:

值得注意的是,击中表面的光量部分取决于表面与光源的关系,而达到单个点的光量在法向量上最大,在法向量垂直时最低。通过计算点法向量和入射光线的点积,我们可以表达这种关系。这可以用以下公式表示:

光密度(源向量)法向量

这个方程中的源向量和法向量被假定为归一化。

如前所述,表面上的一些光线在重新投射之前会被吸收。为了将这种行为添加到我们的数学模型中,我们可以添加一个反射系数,也称为漫反射率。这个系数值成为入射光的缩放因子。我们指定出射光强度的新公式现在看起来像下面这样:

出射光 = (漫反射系数 x 光密度 x 光源向量) 法向量

有了这个新的公式,我们现在有了一个代表全向均匀散射的光照模型。

好了,现在我们知道了理论,让我们看看如何在 GLSL 着色器中实现这个光照模型。这个例子的完整源代码可以在 GitHub 存储库的Chapter07文件夹中找到,从以下所示的顶点着色器开始:

#version 410
in vec3 vertexPosition_modelspace;
in vec2 vertexUV;
in vec3 vertexNormal;
out vec2 UV;
out vec3 LightIntensity;
uniform vec4 LightPosition;
uniform vec3 DiffuseCoefficient ;
uniform vec3 LightSourceIntensity;
uniform mat4 ModelViewProjection;
uniform mat3 NormalMatrix;
uniform mat4 ModelViewMatrix;
uniform mat4 ProjectionMatrix;
void main(){
    vec3 tnorm = normalize(NormalMatrix * vertexNormal);
    vec4 CameraCoords = ModelViewMatrix *
    vec4(vertexPosition_modelspace,1.0);
    vec3 IncomingLightDirection = normalize(vec3(LightPosition -
    CameraCoords));
    LightIntensity = LightSourceIntensity * DiffuseCoefficient *
                     max( dot( IncomingLightDirection, tnorm ), 0.0 );
    gl_Position = ModelViewProjection *                   
                  vec4(vertexPosition_modelspace,1);
                  UV = vertexUV;
 }

我们将逐块地浏览这个着色器。首先,我们有我们的属性,vertexPosition_modelspacevertexUVvertexNormal。这些将由我们的游戏应用程序设置,在我们浏览完着色器之后我们会看到。然后我们有我们的输出变量,UV 和LightIntensity。这些值将在着色器中计算。然后我们有我们的 uniform 变量。这些包括我们讨论过的反射计算所需的值。它还包括所有必要的矩阵。与属性一样,这些 uniform 值将通过我们的游戏设置。

在这个着色器的主函数内部,我们的漫反射将在相机相对坐标中计算。为了实现这一点,我们首先通过将顶点法线乘以法线矩阵来归一化顶点法线,并将结果存储在一个名为tnorm的向量 3 变量中。接下来,我们通过使用模型视图矩阵将目前在模型空间中的顶点位置转换为相机坐标,从而计算出入射光方向,归一化,通过从相机坐标中的顶点位置减去光的位置。接下来,我们通过使用我们之前讨论过的公式计算出射光强度。这里需要注意的一点是使用 max 函数。这是当光线方向大于 90 度时的情况,就像光线是从物体内部发出一样。由于在我们的情况下,我们不需要支持这种情况,所以当出现这种情况时,我们只使用0.0的值。为了关闭着色器,我们将在裁剪空间中计算的模型视图投影矩阵存储在内置的输出变量gl_position中。我们还传递纹理的 UV,未更改,这在这个例子中实际上并没有使用。

现在我们已经有了着色器,我们需要提供计算所需的值。正如我们在本章的第一节中所学的,我们通过设置属性和 uniform 来实现这一点。我们构建了一个抽象层来帮助这个过程,所以让我们看看我们如何在游戏代码中设置这些值。在GamePlayScreen.cpp文件中,我们在Draw()函数中设置这些值。我应该指出,这是一个例子,在生产环境中,出于性能原因,你只想在循环中设置变化的值。由于这是一个例子,我想让它稍微容易一些:

GLint DiffuseCoefficient =    
        shaderManager.GetUniformLocation("DiffuseCoefficient ");
glUniform3f(DiffuseCoefficient, 0.9f, 0.5f, 0.3f);
GLint LightSourceIntensity =    
       shaderManager.GetUniformLocation("LightSourceIntensity ");
glUniform3f(LightSourceIntensity, 1.0f, 1.0f, 1.0f);
glm::vec4 lightPos = m_camera.GetView() * glm::vec4(5.0f, 5.0f, 2.0f,              
                     1.0f);
GLint lightPosUniform =      
                shaderManager.GetUniformLocation("LightPosition");
glUniform4f(lightPosUniform, lightPos[0], lightPos[1], lightPos[2],    
             lightPos[3]);
glm::mat4 modelView = m_camera.GetView() * glm::mat4(1.0f);
GLint modelViewUniform =           
               shaderManager.GetUniformLocation("ModelViewMatrix");
glUniformMatrix4fv(modelViewUniform, 1, GL_FALSE, &modelView[0][0]);
glm::mat3 normalMatrix = glm::mat3(glm::vec3(modelView[0]),     
                         glm::vec3(modelView[1]),  
                         glm::vec3(modelView[2]));
GLint normalMatrixUniform =     
                   shaderManager.GetUniformLocation("NormalMatrix");
glUniformMatrix3fv(normalMatrixUniform, 1, GL_FALSE, &normalMatrix[0][0]);
glUniformMatrix4fv(MatrixID, 1, GL_FALSE, &m_camera.GetMVPMatrix()[0][0]);

我不会逐行进行,因为我相信你可以看到模式。我们首先使用着色器管理器的GetUniformLocation()方法返回 uniform 的位置。接下来,我们使用 OpenGL 的glUniform*()方法设置这个 uniform 的值,该方法与值类型匹配。我们对所有需要的 uniform 值都这样做。我们还必须设置我们的属性,并且正如本章开头讨论的那样,我们要在编译和链接过程之间进行这样的操作。在这个例子中,我们在GamePlayScreen()类的OnEntry()方法中设置这些值:

shaderManager.AddAttribute("vertexPosition_modelspace");
shaderManager.AddAttribute("vertexColor");
shaderManager.AddAttribute("vertexNormal");

这样就处理了顶点着色器和传入所需的值,接下来,让我们看看这个例子的片段着色器:

#version 410
in vec2 UV;
in vec3 LightIntensity;
// Ouput data
out vec3 color;
// Values that stay constant for the whole mesh.
uniform sampler2D TextureSampler;
void main(){
  color = vec3(LightIntensity);
}

对于这个示例,我们的片段着色器非常简单。首先,我们有我们的 UV 和LightIntensity的输入值,这次我们只使用LightIntensity。然后,我们声明了我们的输出颜色值,指定为一个矢量 3。接下来,我们有用于纹理的sampler2D统一变量,但在这个示例中我们也不会使用这个值。最后,我们有主函数。这是我们通过简单地将LightIntensity传递到管道中的下一个阶段来设置最终输出颜色的地方。

如果你运行示例项目,你会看到漫反射的效果。输出应该看起来像下面的屏幕截图。正如你所看到的,这种反射模型对于非常迟钝的表面效果很好,但在实际环境中的使用有限。接下来,我们将看一下一个反射模型,它将允许我们描绘更多的表面类型:

每顶点环境、漫反射和镜面

环境漫反射镜面ADS)反射模型,也通常被称为冯氏反射模型,提供了一种创建反射光照着色器的方法。这种技术使用三种不同组件的组合来模拟光线在表面上的相互作用。环境组件模拟来自环境的光线;这意味着模拟光线被反射多次的情况,看起来好像它从任何地方都发出。我们在之前的示例中建模的漫反射组件代表了一个全向反射。最后一个组件,镜面组件,旨在表示在一个首选方向上的反射,提供了光眩光或明亮的点的外观。

这些组件的组合可以使用以下图表来可视化:

来源:维基百科

这个过程可以分解成讨论各个组件。首先,我们有环境组件,代表将均匀照亮所有表面并在所有方向上均匀反射的光线。这种光照效果不依赖于光线的入射或出射向量,因为它是均匀分布的,可以简单地通过将光源强度与表面反射性相乘来表示。这在数学公式 I[a] = L[a]K[a] 中显示。

下一个组件是我们之前讨论过的漫反射组件。漫反射组件模拟了一个粗糙或粗糙的表面,将光线散射到所有方向。同样,这可以用数学公式 I[d] = L[d]Kd 来表示。

最后一个组件是镜面组件,它用于模拟表面的光泽。这会产生一个眩光或明亮的点,在表现出光滑特性的表面上很常见。我们可以使用以下图表来可视化这种反射效果:

对于镜面分量,理想情况下,我们希望当与反射向量对齐时,反射最明显,然后随着角度的增加或减小而逐渐减弱。我们可以使用我们的观察向量和反射角之间的角度的余弦来模拟这种效果,然后将其提高到某个幂,如下面的方程所示:(r v) ^p。在这个方程中,p代表镜面高光,眩光点。输入的p值越大,点的大小就会越小,表面看起来就会更光滑。在添加了表示表面反射性和镜面光强度的值之后,用于计算表面镜面效果的公式如下:I[s] = L[s]Ks ^p

现在,如果我们将所有组件放在一起并用一个公式表示,我们得到 I = I[a] + I[d] + I[s] 或者更详细地分解为 I = L[a]K[a] + L[d]Kd + L[s]Ks ^p

有了我们的理论基础,让我们看看如何在每顶点着色器中实现这一点,从我们的顶点着色器开始如下:

#version 410
// Input vertex data, different for all executions of this shader.
in vec3 vertexPosition_modelspace;
in vec2 vertexUV;
in vec3 vertexNormal;
// Output data ; will be interpolated for each fragment.
out vec2 UV;
out vec3 LightIntensity;
struct LightInfo {
  vec4 Position; // Light position in eye coords.
  vec3 La; // Ambient light intensity
  vec3 Ld; // Diffuse light intensity
  vec3 Ls; // Specular light intensity
};
uniform LightInfo Light;
struct MaterialInfo {
  vec3 Ka; // Ambient reflectivity
  vec3 Kd; // Diffuse reflectivity
  vec3 Ks; // Specular reflectivity
  float Shininess; // Specular shininess factor
};
  uniform MaterialInfo Material;
  uniform mat4 ModelViewMatrix;
  uniform mat3 NormalMatrix;
  uniform mat4 ProjectionMatrix;
  uniform mat4 ModelViewProjection;
  void main(){
     vec3 tnorm = normalize( NormalMatrix * vertexNormal);
     vec4 CameraCoords = ModelViewMatrix *                
                     vec4(vertexPosition_modelspace,1.0);
     vec3 s = normalize(vec3(Light.Position - CameraCoords));
     vec3 v = normalize(-CameraCoords.xyz);
     vec3 r = reflect( -s, tnorm );
     float sDotN = max( dot(s,tnorm), 0.0 );
     vec3 ambient = Light.La * Material.Ka;
     vec3 diffuse = Light.Ld * Material.Kd * sDotN;
     vec3 spec = vec3(0.0);
     if( sDotN > 0.0 )
      spec = Light.Ls * Material.Ks *
      pow( max( dot(r,v), 0.0 ), Material.Shininess );
      LightIntensity = ambient + diffuse + spec;
      gl_Position = ModelViewProjection *
                vec4(vertexPosition_modelspace,1.0);
}

让我们先看看有什么不同。在这个着色器中,我们引入了一个新概念,即统一结构。我们声明了两个struct,一个用于描述光线,LightInfo,一个用于描述材质,MaterialInfo。这是一种非常有用的方式,可以将代表公式中一部分的值作为集合来包含。我们很快就会看到如何设置这些struct元素的值从游戏代码中。接着是函数的主要部分。首先,我们像在上一个例子中一样开始。我们计算tnormCameraCoords和光源向量。接下来,我们计算指向观察者/摄像机的向量(v),这是规范化的CameraCoords的负值。然后,我们使用提供的 GLSL 方法计算反射的方向。然后我们继续计算我们三个分量的值。环境光通过将光环境强度和表面的环境反射值相乘来计算。diffuse使用光强度、表面漫反射值和光源向量与tnorm的点积的结果来计算,我们刚刚在环境值之前计算了这个值。在计算镜面反射值之前,我们检查了sDotN的值。如果sDotN为零,则没有光线到达表面,因此没有计算镜面分量的意义。如果sDotN大于零,我们计算镜面分量。与前面的例子一样,我们使用 GLSL 方法将点积的值限制在10之间。GLSL 函数pow将点积提升到表面光泽指数的幂,我们之前在着色器方程中定义为p

最后,我们将这三个分量值相加,并将它们的总和作为 out 变量LightIntensity传递给片段着色器。最后,我们将顶点位置转换为裁剪空间,并通过将其分配给gl_Position变量将其传递到下一个阶段。

对于我们着色器所需的属性和统一变量的设置,我们处理过程与前面的例子中一样。这里的主要区别在于,我们需要在获取统一位置时指定我们正在分配的struct的元素。一个示例看起来类似于以下内容,您可以在 GitHub 存储库的Chapter07文件夹中的示例解决方案中看到完整的代码:

GLint Kd = shaderManager.GetUniformLocation("Material.Kd");
glUniform3f(Kd, 0.9f, 0.5f, 0.3f);

这个例子使用的片段着色器与我们用于漫反射的片段着色器相同,所以我在这里不再介绍它。

当您从 GitHub 存储库的Chapter07代码解决方案中运行 ADS 示例时,您将看到我们新创建的着色器生效,输出类似于以下内容:

在这个例子中,我们在顶点着色器中计算了阴影方程;这被称为每顶点着色器。这种方法可能会出现的一个问题是我们

眩光点,镜面高光,可能会出现扭曲或消失的现象。这是由于阴影被插值而不是针对脸部的每个点进行计算造成的。例如,设置在脸部中间附近的点可能不会出现,因为方程是在镜面分量接近零的顶点处计算的。在下一个例子中,我们将看一种可以通过在片段着色器中计算反射来消除这个问题的技术。

每片段 Phong 插值

在以前的例子中,我们一直在使用顶点着色器来处理光照计算。使用顶点着色器来评估每个顶点的颜色时会出现一个问题,就像在上一个例子中提到的那样,即颜色然后在整个面上进行插值。这可能会导致一些不太理想的效果。有另一种方法可以实现相同的光照效果,但精度更高。我们可以将计算移到片段着色器中。在片段着色器中,我们不是在整个面上进行插值,而是在法线和位置上进行插值,并使用这些值来在每个片段上计算。这种技术通常被称为冯氏插值。这种技术的结果比使用每个顶点实现的结果要准确得多。然而,由于这种按片段实现会评估每个片段,而不仅仅是顶点,所以这种实现比按顶点的技术运行得更慢。

让我们从查看这个例子的顶点着色器开始实现着色器的实现:

#version 410
in vec3 vertexPosition_modelspace;
in vec2 vertexUV;
in vec3 vertexNormal;
out vec2 UV;
out vec3 Position;
out vec3 Normal;
uniform mat4 ModelViewMatrix;
uniform mat3 NormalMatrix;
uniform mat4 ProjectionMatrix;
uniform mat4 ModelViewProjection;
void main(){
    UV = vertexUV;
    Normal = normalize( NormalMatrix * vertexNormal);
    Position = vec3( ModelViewMatrix *        
               vec4(vertexPosition_modelspace,1.0));
    gl_Position = ModelViewProjection *
                 vec4(vertexPosition_modelspace,1.0);
}

由于这种技术使用片段着色器来执行计算,我们的顶点着色器相当轻。在大多数情况下,我们正在进行一些简单的方程来计算法线和位置,然后将这些值传递到下一个阶段。

接下来,我们将看一下这种技术在片段着色器中的核心实现。以下是完整的片段着色器,我们将介绍与以前例子的不同之处:

#version 410
in vec3 Position;
in vec3 Normal;
in vec2 UV;
uniform sampler2D TextureSampler;
struct LightInfo {
  vec4 Position; // Light position in eye coords.
  vec3 Intensity; // A,D,S intensity
};
uniform LightInfo Light;
struct MaterialInfo {
  vec3 Ka; // Ambient reflectivity
  vec3 Kd; // Diffuse reflectivity
  vec3 Ks; // Specular reflectivity
  float Shininess; // Specular shininess factor
};
uniform MaterialInfo Material;
out vec3 color;
void phongModel( vec3 pos, vec3 norm, out vec3 ambAndDiff, out vec3
spec ) {
  vec3 s = normalize(vec3(Light.Position) - pos);
  vec3 v = normalize(-pos.xyz);
  vec3 r = reflect( -s, norm );
  vec3 ambient = Light.Intensity * Material.Ka;
  float sDotN = max( dot(s,norm), 0.0 );
  vec3 diffuse = Light.Intensity * Material.Kd * sDotN;
  spec = vec3(0.0);
  if( sDotN > 0.0 )
   spec = Light.Intensity * Material.Ks *
        pow( max( dot(r,v), 0.0 ), Material.Shininess );
        ambAndDiff = ambient + diffuse;
}
void main() {
   vec3 ambAndDiff, spec;
   vec3 texColor = texture( TextureSampler, UV ).rbg;
   phongModel( Position, Normal, ambAndDiff, spec );
   color = (vec3(ambAndDiff * texColor) + vec3(spec));
 }

这个片段着色器应该看起来非常熟悉,因为它几乎与我们以前的例子中的顶点着色器相同。除了这将按片段而不是按顶点运行之外,另一个重要的区别是我们通过实现一个处理冯氏模型计算的函数来清理着色器。这次我们还要传递一个纹理,把纹理还给小矮人。冯氏模型计算与我们以前看到的完全相同,所以我不会再次介绍它。我们将它移到一个函数中的原因主要是为了可读性,因为它使主函数保持整洁。在 GLSL 中创建函数几乎与在 C++和 C 中相同。你有一个返回类型,一个函数名,后面跟着参数和一个主体。我强烈建议在任何比几行更复杂的着色器中使用函数。

为了将我们的着色器连接到游戏中的值,我们遵循与之前相同的技术,在那里我们设置所需的属性和统一值。在这个例子中,我们必须提供 Ka、Kd、Ks、材料光泽度、LightPositionLightIntensity的值。这些值与先前描述的 ADS 方程相匹配。我们还需要传递通常的矩阵值。完整的代码可以再次在 GitHub 存储库的Chapter07文件夹中找到。

如果我们运行Chapter07解决方案中的Phong_Example,我们将看到新的着色器在运行中,包括纹理和更准确的反射表示。以下是输出的屏幕截图:

我们将在这里结束我们对光照技术的讨论,但我鼓励你继续研究这个主题。使用着色器可以实现许多有趣的光照效果,我们只是刚刚开始涉及。在下一节中,我们将看一下着色器的另一个常见用途:渲染效果。

使用着色器创建效果

着色器不仅仅局限于创建光照效果。您可以使用不同的着色器技术创建许多不同的视觉效果。在本节中,我们将介绍一些有趣的效果,包括使用丢弃关键字来丢弃像素,并使用着色器创建一个简单的粒子效果系统。

丢弃片段

通过使用片段着色器工具,我们能够创建一些很酷的效果。其中一个工具就是使用丢弃关键字。丢弃关键字,顾名思义,移除或丢弃片段。当使用丢弃关键字时,着色器立即停止执行并跳过片段,不向输出缓冲区写入任何数据。创建的效果是多边形面上的孔洞,而不使用混合效果。丢弃关键字也可以与 alpha 贴图结合使用,以允许纹理指定应丢弃哪些片段。在建模损坏对象等效果时,这可能是一个方便的技术。

在这个例子中,我们将创建一个片段着色器,使用丢弃关键字根据 UV 纹理坐标移除某些片段。效果将是我们的小矮人模型呈现出格子或穿孔的外观。

让我们从查看这个例子的顶点着色器开始:

#version 410
// Input vertex data, different for all executions of this shader.
in vec3 vertexPosition_modelspace;
in vec2 vertexUV;
in vec3 vertexNormal;
out vec3 FrontColor;
out vec3 BackColor;
out vec2 UV;
struct LightInfo {
vec4 Position; // Light position in eye coords.
vec3 La; // Ambient light intensity
vec3 Ld; // Diffuse light intensity
vec3 Ls; // Specular light intensity
};
uniform LightInfo Light;
struct MaterialInfo {vec3 Ka; // Ambient reflectivity
vec3 Kd; // Diffuse reflectivity
vec3 Ks; // Specular reflectivity
float Shininess; // Specular shininess factor
};
uniform MaterialInfo Material;
uniform mat4 ModelViewMatrix;
uniform mat3 NormalMatrix;
uniform mat4 ProjectionMatrix;
uniform mat4 ModelViewProjection;
void getCameraSpace( out vec3 norm, out vec4 position )
{
norm = normalize( NormalMatrix * vertexNormal);
position = ModelViewMatrix * vec4(vertexPosition_modelspace,1.0);
}
vec3 phongModel( vec4 position, vec3 norm )
{
...
//Same as previous examples
...}
void main()
{
vec3 cameraNorm;
vec4 cameraPosition;
UV = vertexUV;
// Get the position and normal in eye space
getCameraSpace(cameraNorm, cameraPosition);
FrontColor = phongModel( cameraPosition, cameraNorm );
BackColor = phongModel( cameraPosition, -cameraNorm );
gl_Position = ModelViewProjection *
vec4(vertexPosition_modelspace,1.0);
}

在这个例子中,我们将光照计算移回到顶点着色器。您可能已经注意到,这个顶点着色器与上一个例子非常相似,只是有一些细微的变化。要注意的第一个变化是,我们在这个例子中使用了 UV 纹理坐标。我们使用纹理坐标来确定要丢弃的片段,并且这次我们不打算渲染模型的纹理。由于我们将丢弃一些小矮人模型的片段,我们将能够看到模型的内部和另一侧。这意味着我们需要为脸的正面和背面都计算光照方程。我们通过为每一侧计算冯氏模型来实现这一点,改变传入的法向量。然后我们将这些值存储在FrontColorBackColor变量中,以便传递到片段着色器。为了使我们的主类再次更易于阅读,我们还将相机空间转换移到一个函数中。

接下来,让我们看一下这个例子的片段着色器:

#version 410
in vec3 FrontColor;
in vec3 BackColor;
in vec2 UV;
out vec4 FragColor;
void main() {
const float scale = 105.0;
bvec2 toDiscard = greaterThan( fract(UV * scale), vec2(0.2,0.2) );
if( all(toDiscard) )
discard;
else {
if( gl_FrontFacing )
FragColor = vec4(FrontColor, 1.0);
else
FragColor = vec4(BackColor, 1.0);
}
}

在我们的片段着色器中,我们正在计算要丢弃的片段,以实现所需的穿孔效果。为了实现这一点,我们首先使用我们的缩放因子来缩放 UV 坐标。这个缩放因子代表每个纹理坐标的穿孔矩形的数量。接下来,我们使用 GLSL 函数fract()来计算纹理坐标分量的小数部分。然后,我们使用另一个 GLSL 函数greaterThan()将每个xy分量与 0.2 的浮点值进行比较。

如果toDiscard变量中的向量的xy分量都评估为 true,这意味着片段位于穿孔矩形的边框内,我们希望丢弃它。我们可以使用 GLSL 函数来帮助我们执行这个检查。如果函数调用返回 true,我们执行discard语句来丢弃该片段。

接下来,我们有一个else块,根据片段是背面还是正面多边形来着色。为了帮助我们,我们使用gl_FronFacing()函数根据多边形的法线返回 true 或 false。

就像我们在之前的例子中一样,我们必须再次确保在游戏程序中设置着色器所需的属性和统一变量。要查看示例的完整实现,请参见Chapter07DiscardExample项目。如果我们运行这个例子程序,您将看到我们的小矮人模型看起来好像是由格子制成的。以下是输出的屏幕截图:

生成粒子

通过使用着色器,您可以实现的另一个效果是通常称为粒子效果的效果。您可以将粒子系统视为一组对象,这些对象一起用于创建烟雾、火灾、爆炸等的视觉外观。系统中的单个粒子被认为是一个具有位置但没有大小的点对象。要渲染这些点对象,GL_POINTS原语通常是最常见的方法。但是,您也可以像渲染任何其他对象一样渲染粒子,使用三角形或四边形。

在我们的示例中,我们将实现一个简单的粒子系统,它将呈现出一个喷泉的外观。我们系统中的每个粒子都将遵循这些规则。它将有一个有限的寿命,它将根据定义的标准被创建和动画化,然后终止。在一些粒子系统中,您可以回收粒子,但为了简单起见,我们的示例不会这样做。粒子的动画标准通常基于运动方程,这些方程定义了粒子的运动,基于重力加速度、风、摩擦和其他因素。同样,为了保持我们的示例简单,我们将使用标准的运动学计算来对粒子进行动画处理。以下方程描述了给定时间t时粒子的位置,其中P[0]是初始位置,V[0]t是初始速度,a代表加速度:

P(t) = P[0]+ V­[0]t + ½at²

在我们的示例中,我们将定义粒子的初始位置为原点(0,0,0)。初始速度将在一个范围内随机计算。由于每个粒子将在我们方程中的不同时间间隔内创建,时间将相对于该粒子的创建时间。

由于所有粒子的初始位置相同,我们不需要将其作为着色器的属性提供。我们只需要提供两个顶点属性:粒子的初始速度和开始时间。如前所述,我们将使用GL_POINTS来渲染每个粒子。使用GL_POINTS的好处是很容易将纹理应用到点精灵上,因为 OpenGL 会自动生成纹理坐标并通过 GLSL 变量gl_PointCoord将其传递给片段着色器。为了使粒子看起来逐渐消失,我们还将在粒子的寿命内线性增加点对象的透明度。

让我们从这个示例的顶点着色器开始:

#version 410
in vec3 VertexInitVel; // Particle initial velocity
in float StartTime; // Particle "birth" time
out float Transp; // Transparency of the particle
uniform float Time; // Animation time
uniform vec3 Gravity = vec3(0.0,-0.05,0.0); // world coords
uniform float ParticleLifetime; // Max particle lifetime
uniform mat4 ModelViewProjection;
void main()
{
// Assume the initial position is (0,0,0).
vec3 pos = vec3(0.0);
Transp = 0.0;
// Particle dosen't exist until the start time
if( Time > StartTime ) {
float t = Time - StartTime;
if( t < ParticleLifetime ) {
pos = VertexInitVel * t + Gravity * t * t;
Transp = 1.0 - t / ParticleLifetime;
}
}
// Draw at the current position
gl_Position = ModelViewProjection * vec4(pos, 1.0);
}

我们的着色器以两个必需的输入属性开始,即粒子的初始速度VertexInitVel和粒子的开始时间StartTime。然后我们有输出变量Transp,它将保存粒子透明度的计算结果传递到下一个着色器阶段。接下来,我们有我们的统一变量:时间,动画运行时间,重力,用于计算恒定加速度,以及ParticleLifetime,它指定粒子可以保持活动状态的最长时间。在主函数中,我们首先将粒子的初始位置设置为原点,在本例中为(0,0,0)。然后我们将透明度设置为 0。接下来,我们有一个条件,检查粒子是否已激活。如果当前时间大于开始时间,则粒子处于活动状态,否则粒子处于非活动状态。如果粒子处于非活动状态,则位置保持在原点,并且以完全透明度渲染粒子。然后,如果粒子仍然存活,我们通过从当前时间减去开始时间来确定粒子的当前年龄,并将结果存储在浮点值t中。然后我们将tParticleLiftime值进行比较,如果t大于粒子的寿命值,则粒子已经完成了其寿命动画,然后以完全透明度渲染。如果t不大于寿命值,则粒子处于活动状态,我们对粒子进行动画处理。我们使用我们之前讨论的方程来实现这种动画。透明度是根据粒子的运行时间或年龄进行插值确定的。

现在让我们看一下这个例子的片段着色器:

#version 410
in float Transp;
uniform sampler2D ParticleTex;
out vec4 FragColor;
void main()
{
FragColor = texture(ParticleTex, gl_PointCoord);
FragColor.a *= Transp;
}

我们这个例子的片段着色器非常基本。在这里,我们根据其纹理查找值设置片段的颜色。如前所述,因为我们使用GL_POINT原语,所以纹理坐标由 OpenGL 的gl_PointCoord变量自动计算。最后,我们将片段的最终颜色的 alpha 值乘以Transp输入变量。这将在我们的粒子运行时消逝时给我们淡出效果。

在我们的游戏代码中,我们需要创建两个缓冲区。第一个缓冲区将存储每个粒子的初始速度。第二个缓冲区将存储每个粒子的开始时间。我们还必须设置所需的统一变量,包括ParticleTex用于粒子纹理,Time变量用于表示动画开始后经过的时间量,Gravity变量用于表示加速度常数,以及ParticleLifetime变量用于定义粒子运行动画的持续时间。为了简洁起见,我不会在这里介绍代码,但您可以查看Chapter07文件夹中粒子示例项目的实现。

在测试我们的示例之前,我们还需要确保深度测试关闭,并且启用了 alpha 混合。您可以使用以下代码来实现:

glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

您可能还想将点对象的大小更改为更合理的值。您可以使用以下代码将值设置为 10 像素:

glPointSize(10.0f);

如果我们现在运行我们的示例项目,我们将看到类似喷泉的粒子效果。可以看到一些捕获的帧如下:

虽然这只是一个简单的例子,但它有很大的性能和灵活性提升空间,应该为您实现基于 GPU 的粒子系统提供了一个很好的起点。请随意尝试不同的输入值,甚至可以添加更多因素到粒子动画计算中。实验可能会带来很多有趣的结果。

总结

在本章中,我们介绍了使用着色器的基础知识。我们学习了如何构建编译器和链接抽象层,以节省时间。我们了解了光照技术理论以及如何在着色器语言中实现它们。最后,我们通过研究着色器的其他用途,比如创建粒子效果,结束了本章。在下一章中,我们将通过创建高级游戏玩法系统进一步扩展我们的示例游戏框架。

第八章:高级游戏系统

游戏不仅仅是简单的机制和基础引擎。它们由复杂的游戏系统组成,使我们能够与游戏世界互动,让我们感到被包容和沉浸其中。这些系统通常需要大量的时间和开发者专业知识来实现。在本章中,我们将看一下一些高级游戏系统以及在我们自己的项目中实现它们时如何给自己一层帮助。

本章包括以下主题:

  • 实现脚本语言

  • 构建对话系统

  • 脚本任务

实现脚本语言

正如之前提到的,实现一个高级游戏系统通常需要许多编码小时,并且可能需要开发人员对该特定系统具有专业知识。然而,我们可以通过包含对脚本语言的支持来使这一切变得更容易,对我们自己和其他人在项目上的工作也更容易。

为什么要使用脚本语言

也许你会想知道,既然这是一本关于 C++的书,为什么我们要花时间谈论脚本语言呢?为什么要加入脚本语言?我们难道不能只用 C++来构建整个引擎和游戏吗?是的,我们可以!然而,一旦你开始着手更大的项目,你会很快注意到每次需要进行更改时所花费的编译和重新编译的时间。虽然有一些方法可以解决这个问题,比如将游戏和引擎分成较小的模块并动态加载它们,或者使用 JSON 或 XML 描述性文件系统,但是这些技术不能提供实现脚本系统的所有好处。

那么,将脚本语言添加到游戏引擎中有什么好处呢?首先,你将使用的大多数脚本语言都是解释性语言,这意味着与 C++不同,你不需要编译代码。相反,你的代码在运行时加载和执行。这样做的一个巨大优势是你可以对脚本文件进行更改并快速看到结果,而无需重新编译整个游戏。事实上,你可以在游戏运行时重新加载脚本并立即看到更改。使用脚本语言的另一个可能好处是相对于 C++这样的语言,它被认为更易于使用。大多数脚本语言都是动态类型的,具有简化的语法和结构。这可以为团队的创造性一面,比如艺术家和设计师,提供机会,他们可以在不需要理解 C++这样的语言复杂性的情况下对项目进行小的更改。想象一下 GUI 设计师能够创建、放置和修改 GUI 元素,而无需知道 IGUI 框架是如何实现的。添加脚本支持还为社区内容支持打开了一条道路——想象一下地图、关卡和物品都是由游戏玩家设计的。这对于新游戏来说已经成为一个巨大的卖点,并为你的游戏提供了一些可能的长期性。在谈到长期性时,DLC 的实施可以通过脚本完成。这可以实现更快的开发周转,并且可以在不需要庞大补丁的情况下投入游戏中。

这些是使用脚本语言的一些好处,但并不总是在每种情况下都是最佳解决方案。脚本语言以运行速度较慢而臭名昭著,正如我们所知,性能在构建游戏时很重要。那么,什么时候应该使用脚本而不是使用 C++呢?我们将更仔细地看一些系统示例,但作为一个简单的遵循规则,你应该总是使用 C++来处理任何可以被认为是 CPU 密集型的东西。程序流程和其他高级逻辑是脚本的绝佳候选对象。让我们看看脚本可以在我们的游戏引擎组件中使用的地方。

让我们从物理组件开始。当然,当我们想到物理时,我们立刻想到大量的 CPU 使用。在大多数情况下,这是正确的。物理系统的核心应该是用 C++构建的,但也有机会在这个系统中引入脚本。例如,物理材料的概念。我们可以在脚本中定义材料的属性,比如质量,摩擦力,粘度等。我们甚至可以从脚本内部修改这些值。物理系统中脚本的另一个潜在用途是定义碰撞的响应。我们可以处理声音的生成,特效和其他事件,都可以在脚本中完成。

那么 AI 系统呢?这可以说是游戏引擎中脚本语言最常见的用途之一,我们将在下一章更深入地研究这一点。AI 系统的许多组件可以移入脚本中。这些包括复杂的行为定义,AI 目标的规定,AI 之间的通信,AI 个性和特征的定义,以及更多。虽然列表很长,但你应该注意到给出的示例并不占用 CPU,并且 AI 系统的复杂组件,如路径查找,模糊逻辑和其他密集算法应该在 C++代码中处理。

甚至可以将脚本添加到看似 CPU 和 GPU 密集的系统中,比如图形引擎。脚本可以处理设置光照参数,调整雾等效果,甚至在屏幕上添加和删除游戏元素。正如你所看到的,引擎中几乎没有什么是不能用某种形式的脚本抽象来补充的。

那么,你应该使用哪种脚本语言?有很多选择,从游戏特定的语言,如 GameMonkey(在撰写本书时似乎已经停用),到更通用的语言,如 Python 和 JavaScript。选择取决于你的具体需求。虽然 Python 和 JavaScript 等语言具有一些令人惊叹的功能,但为了获得这些功能,学习和执行会更加复杂。在本书的示例中,我们将使用一种称为 Lua 的语言。Lua 已经存在多年,虽然近年来其流行度有所下降,但在游戏开发行业中有着非常强大的记录。在本章的下一部分,我们将更好地了解 Lua,并看看如何将其纳入我们现有的引擎系统中。

介绍 LUA

Lua,发音为 LOO-ah,是一种轻量级的可嵌入脚本语言。它支持现代编程方法论,如面向对象,数据驱动,函数式和过程式编程。Lua 是一种可移植的语言,几乎可以在提供标准 C 编译器的所有系统上构建。Lua 可以在各种 Unix,Windows 和 Mac 系统上运行。Lua 甚至可以在运行 Android,iOS,Windows Phone 和 Symbian 的移动设备上找到。这使得它非常适合大多数游戏标题,并且是包括暴雪娱乐在内的公司使用它的主要原因之一,例如《魔兽世界》。Lua 也是免费的,根据 MIT 权限许可分发,并且可以用于任何商业目的而不产生任何费用。

Lua 也是一种简单但强大的语言。在 Lua 中,只有一种数据结构被称为table。这种表数据结构可以像简单数组一样使用,也可以像键值字典一样使用,我们甚至可以使用表作为原型来实现一种面向对象编程。这与在其他语言中进行 OOP 非常相似,比如 JavaScript。

虽然我们不会详细介绍语言,但有一些很好的资源可供参考,包括 Lua 文档网站。我们将简要介绍一些关键的语言概念,这些概念将在示例中得到体现。

让我们从变量和简单的程序流开始。在 Lua 中,所有数字都是双精度浮点数。您可以使用以下语法分配一个数字:

number = 42 

请注意缺少类型标识符和分号来表示语句结束。

Lua 中的字符串可以用几种方式定义。您可以用单引号定义它们,如下所示:

string = 'single quote string' 

您也可以使用双引号:

string = "double quotes string" 

对于跨多行的字符串,您可以使用双方括号来表示字符串的开始和结束:

string  = [[ multi-line  
             string]] 

Lua 是一种垃圾收集语言。您可以通过将对象设置为nil来删除定义,这相当于 C++中的NULL

string = nil 

Lua 中的语句块用语言关键字来表示,比如doendwhile循环块将如下所示:

while number < 100 do 
    number = number + 1 
end 

您可能会注意到我们在这里使用了number + 1,因为 Lua 语言中没有增量和减量运算符(++--)。

if条件代码块将如下所示:

if number > 100 then 
    print('Number is over 100') 
elseif number == 50 then 
    print('Number is 50') 
else 
    print(number) 
end 

Lua 中的函数构造方式类似,使用 end 来表示函数代码语句块的完成。一个简单的计算斐波那契数的函数将类似于以下示例:

function fib(number) 
    if number < 2 then 
        return 1 
    end 
    return fib(number - 2) + fib(number -1) 
end 

如前所述,表是 Lua 语言中唯一的复合数据结构。它们被视为关联数组对象,非常类似于 JavaScript 对象。表是哈希查找字典,也可以被视为列表。使用表作为映射/字典的示例如下:

table = { key1 = 'value1', 
          key2 = 100, 
          key3 = false }

在处理表时,您还可以使用类似 JavaScript 的点表示法。例如:

print (table.key1) 
Prints the text value1 

table.key2 = nil 

这将从表中删除key2

table.newKey = {}  

这将向表中添加一个新的键/值对。

这就结束了我们对 Lua 语言特定内容的快速介绍;随着我们构建示例,您将有机会了解更多。如果您想了解更多关于 Lua 的信息,我再次建议阅读官方网站上的文档www.lua.org/manual/5.3/

在下一节中,我们将看看如何在我们的示例游戏引擎项目中包含 Lua 语言支持的过程。

实现 LUA

为了在我们的示例引擎中使用 Lua,我们需要采取一些步骤。首先,我们需要获取 Lua 解释器作为一个库,然后将其包含在我们的项目中。接下来,我们将不得不获取或构建我们自己的辅助桥梁,以使我们的 C++代码和 Lua 脚本之间的交互更容易。最后,我们将不得不公开绑定函数、变量和其他对象,以便我们的 Lua 脚本可以访问它们。虽然这些步骤对于每个实现可能略有不同,但这将为我们的下一个示例提供一个很好的起点。

首先,我们需要一个 Lua 库的副本,以便在我们的引擎中使用。在我们的示例中,我们将使用 Lua 5.3.4,这是当时的最新版本。我选择在示例中使用动态库。您可以在 Lua 项目网站的预编译二进制文件页面(luabinaries.sourceforge.net/)上下载动态和静态版本的库,以及必要的包含文件。下载预编译库后,解压缩并将必要的文件包含在我们的项目中。我不打算再次详细介绍如何在项目中包含库。如果您需要复习,请翻回到第二章,理解库,在那里我们详细介绍了步骤。

与我们在整本书中看到的其他示例一样,有时创建辅助类和函数以允许各种库和组件之间更容易地进行交互是很重要的。当我们使用 Lua 时,这又是一个例子。为了使开发者更容易地进行交互,我们需要创建一个桥接类和函数来提供我们需要的功能。我们可以使用 Lua 本身提供的接口来构建这个桥接,Lua 有很好的文档,但也可以选择使用为此目的创建的众多库之一。在本章和整本书的示例中,我选择使用sol2库(github.com/ThePhD/sol2),因为这个库是轻量级的(只有一个头文件),速度快,并且提供了我们示例中需要的所有功能。有了这个库,我们可以抽象出很多桥接的维护工作,并专注于实现。要在我们的项目中使用这个库,我们只需要将单个头文件实现复制到我们的include文件夹中,它就可以使用了。

现在我们已经有了 Lua 引擎和sol2桥接库,我们可以继续进行最后一步,实现脚本。如前所述,为了我们能够使用底层游戏引擎组件,它们必须首先暴露给 Lua。这就是sol2库的作用所在。为了演示在我们的示例引擎中如何实现这一点,我创建了一个名为Bind_Example的小项目。您可以在代码存储库的Chapter08文件夹中找到完整的源代码。

首先让我们看一下 Lua 脚本本身。在这种情况下,我把我的脚本命名为BindExample.lua,并将它放在示例项目父目录的Scripts文件夹中:

player = { 
    name = "Bob", 
    isSpawned = false 
} 

function fib(number) 
    if number < 2 then 
        return 1 
    end 
    return fib(number - 2) + fib(number -1) 
end 

在这个示例中,我们的 Lua 脚本非常基本。我们有一个名为player的表,有两个元素。一个带有键name和值Bob的元素,以及一个带有键isSpawned和值false的元素。接下来,我们有一个名为fib的简单 Lua 函数。这个函数将计算斐波那契数列中直到传入的数字的所有数字。我觉得在这个例子中加入一点数学会很有趣。我应该指出,这个计算在序列越高时可能会变得相当消耗处理器,所以如果您希望它快速处理,请不要传入一个大于,比如说,20 的数字。

这给了我们一些快速的 Lua 代码示例来使用。现在我们需要将我们的程序和它的逻辑连接到这个新创建的脚本中。在这个示例中,我们将把这个连接代码添加到我们的GameplayScreen类中。

我们首先添加了sol2库的必要包含:

#include <sol/sol.hpp> 

接下来,我们将创建 Lua 状态。在 Lua 中,state可以被视为代码的操作环境。将其视为虚拟机。这个state是您的代码将被执行的地方,也是通过这个state您将能够访问正在运行的代码的地方:

    sol::state lua; 

然后我们打开了一些我们在 Lua 代码交互中需要的辅助库。这些库可以被视为 C++中#include的等价物。Lua 的理念是保持核心的精简,并通过这些库提供更多的功能:

    lua.open_libraries(sol::lib::base, sol::lib::package); 

在我们打开了库之后,我们可以继续加载实际的 Lua 脚本文件。我们通过调用之前创建的 Luastatescript_file方法来实现这一点。这个方法接受一个参数:文件的位置作为一个字符串。当执行这个方法时,文件将被自动加载和执行:

    lua.script_file("Scripts/PlayerTest.lua"); 

现在脚本已经加载,我们可以开始与它交互。首先,让我们看看如何从 Lua 的变量(表)中提取数据并在我们的 C++代码中使用它:

    std::string stringFromLua = lua["player"]["name"]; 
    std::cout << stringFromLua << std::endl; 

从 Lua 脚本中检索数据的过程非常简单。在这种情况下,我们创建了一个名为stringFromLua的字符串,并将其赋值为 Lua 表 players 的name元素中存储的值。语法看起来类似于调用数组元素,但在这里我们用字符串指定元素。如果我们想要isSpawned元素的值,我们将使用lua["player"]["isSpawned"],在我们的例子中,这将当前返回一个布尔值false

调用 Lua 函数和检索值一样简单,而且非常类似:

    double numberFromLua = lua"fib"; 
    std::cout << numberFromLua << std::endl; 

在这里,我们创建了一个名为numberFromLua的双精度类型的变量,并将其赋值为 Lua 函数fib的返回值。在这里,我们将函数名指定为一个字符串fib,然后指定该函数需要的任何参数。在这个例子中,我们传入值 20 来计算斐波那契数列直到第 20 个数字。

如果你运行Bind_Example项目,你将在引擎的命令窗口中看到以下输出:

虽然这涵盖了我们的 C++代码与 Lua 脚本系统之间的交互基础知识,但还有很多可以发现的地方。在接下来的几节中,我们将探讨如何利用这种脚本结构来增强各种高级游戏系统,并为我们提供一种灵活的方式来扩展我们的游戏项目。

构建对话系统

与游戏世界互动的最常见形式之一是通过某种对话形式。能够与NPC类进行交流,获取信息和任务,当然,通过对话推动故事叙述在大多数现代游戏标题中都是必不可少的。虽然你可以轻松地硬编码交互,但这种方法会让我们的灵活性非常有限。每次我们想要对任何对话或交互进行轻微更改时,我们都必须打开源代码,搜索项目,进行必要的更改,然后重新编译以查看效果。显然,这是一个繁琐的过程。想想你玩过多少游戏出现拼写、语法或其他错误。好消息是我们还有另一种方法。使用 Lua 这样的脚本语言,我们可以以动态方式驱动我们的交互,这将允许我们快速进行更改,而无需进行先前描述的繁琐过程。在本节中,我们将详细介绍构建对话系统的过程,它在高层描述上将加载一个脚本,将其附加到一个NPC,向玩家呈现带有选择的对话,最后,根据返回的玩家输入驱动对话树。

构建 C++基础设施

首先,我们需要在我们的示例引擎中构建基础设施,以支持对话系统的脚本化。实际上有成千上万种不同的方法可以实现这个实现。对于我们的示例,我会尽力保持简单。我们将使用我们在之前章节中学到的一些技术和模式,包括状态和更新模式,以及我们构建的 GUI 系统来处理交互和显示。

他们说一张图片胜过千言万语,所以为了让你对这个系统的连接方式有一个大致的了解,让我们来看一下一个代码映射图,它描述了所有类之间的连接:

这里有一些事情要做,所以我们将逐个类地分解它。首先,让我们看一下DialogGUI类。这个类是基于我们在之前章节中构建的 IGUI 示例。由于我们已经深入讨论了 IGUI 类的设计,我们只会涵盖我们添加的特定方面,以提供我们对话系统所需的功能。

首先,我们需要一些变量来保存对话和我们想要为玩家提供的任何选择。在DialogGUI.h中,我们有以下内容:选择的IGUILabel对象的向量和对话的单个IGUILabel。有关IGUILabel类的实现,请查看其源代码:

std::vector<BookEngine::IGUILabel*> choices; 
BookEngine::IGUILabel* m_dialog;

接下来,我们需要添加一些新的函数,为我们的 GUI 提供所需的交互和脚本提供的数据。为此,我们将在DialogGUI类中添加三种方法:

void SetDialog(std::string text); 
void SetOption(std::string text, int choiceNumber); 
void RemoveAllPanelElements(); 

SetDialog函数,顾名思义,将处理为每个交互屏幕设置对话框文本的工作。该函数只接受一个参数,即我们想要放置在 GUI 上的交互文本:

void DialogGUI::SetDialog(std::string text) 
{ 
    m_dialog = new BookEngine::IGUILabel(glm::vec4(0, 110, 250, 30), 
        glm::vec2(110, -10), 
        text, 
        new BookEngine::SpriteFont("Fonts/Impact_Regular.ttf", 72), 
        glm::vec2(0.3f), m_panel); 

    AddGUIElement(*m_dialog); 
} 

在函数体中,我们将m_dialog标签变量分配给IGUILabel对象的新实例。构造函数应该类似于之前看到的IGUIButton,其中传入了文本值。最后,我们通过调用AddGUIElement方法将标签添加到 GUI 面板中。

SetOption函数,顾名思义,再次设置当前交互屏幕上每个选项的文本。此函数接受两个参数。第一个是我们要将IGUILabel设置为的文本,第二个是选择编号,它是在呈现的选择选项列表中的编号。我们使用这个来查看选择了哪个选项:

void DialogGUI::SetOption(std::string text, int choiceNumber) 
{ 
    choices.resize(m_choices.size() + 1); 
    choices[choiceNumber] =  
new BookEngine::IGUILabel(glm::vec4(0, 110, 250, 20), 
            glm::vec2(110, 10), 
            text, 
            new BookEngine::SpriteFont("Fonts/Impact_Regular.ttf", 72), 
            glm::vec2(0.3f), m_panel); 

    AddGUIObject(*choices[choiceNumber]); 
}

在函数体中,我们正在执行与SetDialog函数非常相似的过程。这里的区别在于,我们将向选择向量添加IGUILabel实例。首先,我们进行一个小技巧,将向量的大小增加一,然后这将允许我们将新的标签实例分配给传入的选择编号值的向量位置。最后,我们通过调用AddGUIElement方法将IGUILabel添加到面板中。

我们添加到DialogGUI类的最后一个函数是RemoveAllPanelElements,它当然将处理删除我们添加到当前对话框屏幕的所有元素。我们正在删除这些元素,以便我们可以重用面板并避免每次更改交互时重新创建面板:

void DialogGUI::RemoveAllPanelElements() 
{ 
    m_panel->RemoveAllGUIElements(); 
} 

RemoveAllGUIElements函数反过来只是调用m_panel对象上的相同方法。IGUIPanel类的实现只是调用向量上的 clear 方法,删除所有元素:

void RemoveAllGUIObjects() { m_GUIObjectsList.clear(); }; 

这样就完成了对话系统的 GUI 设置,现在我们可以继续构建NPC类,该类将处理大部分脚本到引擎的桥接。

正如我之前提到的,我们将利用之前学到的一些模式来帮助我们构建对话系统。为了帮助我们控制何时构建 GUI 元素以及何时等待玩家做出选择,我们将使用有限状态机和更新模式。首先,在NPC.h文件中,我们有一个将定义我们将使用的状态的enum。在这种情况下,我们只有两个状态,DisplayWaitingForInput

... 
    enum InteractionState 
    { 
        Display, 
        WaitingForInput, 
    }; 
...

当然,我们还需要一种方式来跟踪状态,所以我们有一个名为currentStateInteractionState变量,我们将把它设置为我们当前所处的状态。稍后,我们将在Update函数中看到这个状态机的完成:

InteractionState currentState; 

我们还需要一个变量来保存我们的 Lua 状态,这是本章前一节中看到的:

    sol::state lua; 

您可能还记得之前显示的代码映射图中,我们的NPC将拥有一个DialogGUI的实例,用于处理对话内容的显示和与玩家的交互,因此我们还需要一个变量来保存它:

    DialogGUI* m_gui; 

继续实现NPC类,我们首先将查看NPC.cpp文件中该类的构造函数:

NPC::NPC(DialogGUI& gui) : m_gui(&gui) 
{ 
    std::cout << "Loading Scripts n"; 
    lua.open_libraries(sol::lib::base, sol::lib::package, sol::lib::table); 
    lua.script_file("Scripts/NPC.lua"); 
    currentState = InteractionState::Display; 
} 

构造函数接受一个参数,即我们将用于交互的对话实例的引用。我们将此引用设置为成员变量 m_gui 以供以后使用。然后,我们处理将要使用的 Lua 脚本的加载。最后,我们将内部状态机的当前状态设置为 Display 状态。

让我们重新查看我们的代码地图,看看我们需要实现的不同连接,以将 NPC 类的加载的脚本信息传递给我们已附加的 GUI 实例:

正如我们所看到的,有两个处理连接的方法。Say 函数是其中较简单的一个。在这里,NPC 类只是在附加的 GUI 上调用 SetDialog 方法,传递包含要显示的对话的字符串:

 void NPC::Say(std::string stringToSay) 
{ 
    m_gui->SetDialog(stringToSay); 
} 

PresentOptions 函数稍微复杂一些。首先,该函数从 Lua 脚本中检索一个表,该表表示当前交互的选择,我们很快就会看到脚本是如何设置的。接下来,我们将遍历该表(如果它是有效的),并简单地在附加的 GUI 上调用 SetOption 方法,传递选择文本作为字符串和用于选择的选择编号:

void NPC::PresentOptions() 
{ 

    sol::table choices = lua["CurrentDialog"]["choices"]; 
    int i = 0; 
    if (choices.valid()) 
    { 
        choices.for_each(& 
        { 
            m_gui->SetOption(value.as<std::string>(), i); 
            i++; 
        }); 
    } 
}

我们需要放置在引擎端对话系统的最后一部分是 Update 方法。正如我们已经多次看到的那样,这个方法将推动系统向前。通过连接到引擎的现有 Update 事件系统,我们的 NPC 类的 Update 方法将能够控制每一帧对话系统中发生的事情:

void NPC::Update(float deltaTime) 
{ 
    switch (currentState) 
    { 
    case InteractionState::Display: 
        Say(lua["CurrentDialog"]["say"]); 
        PresentOptions(); 
        currentState = InteractionState::WaitingForInput; 
        break; 
    case InteractionState::WaitingForInput: 
        for (int i = 0; i < m_gui->choices.size(); i++) 
        { 
            if (m_gui->choices[i]->GetClickedStatus() == true) 
            { 
                lua["CurrentDialog"]"onSelection"); 
                currentState = InteractionState::Display; 
                m_gui->choices.clear(); 
                m_gui->RemoveAllPanelElements (); 
            } 
        } 
        break; 
    } 
} 

与我们之前的有限状态机实现一样,我们将使用 switch case 来确定基于当前状态应该运行什么代码。在这个例子中,我们的 Display 状态是我们将调用连接方法 SayPresentOptions 的地方。在这里,Say 调用单独传递了它从已加载的脚本文件中提取的文本。我们将在接下来的脚本中看到这是如何工作的。如果在这个例子中,我们处于 WaitingForInput 状态,我们将遍历我们已加载的每个选择,并查看玩家是否已经选择了其中任何一个。如果找到了一个,我们将回调脚本并告诉它已选择了哪个选项。然后,我们将切换我们的状态到 Display 状态,这将启动加载下一个对话屏幕。然后,我们将清除附加的 DisplayGUI 中的选择向量,允许它随后加载下一组选择,并最后调用 RemoveAllPanelElements 方法来清理我们的 GUI 以便重用。

有了 Update 方法,我们现在已经设置好了处理加载、显示和输入处理所需的框架,用于我们的 NPC 交互脚本。接下来,我们将看看如何构建其中一个这样的脚本,以便与我们引擎新创建的对话系统一起使用。

创建对话树脚本

对话或会话树可以被视为交互的确定流程。实质上,它首先提供一个陈述,然后,基于呈现的响应选择,交互可以分支出不同的路径。我们示例对话流程的可视化表示如下图所示:

在这里,我们以一个介绍开始对话树。然后用户被呈现两个选择:是,需要帮助不,离开我。如果用户选择路径,那么我们继续到表达帮助对话。如果用户选择,我们移动到再见对话。从表达帮助对话,我们呈现三个选择:好的重新开始虚弱。根据选择,我们再次移动到对话树的下一个阶段。好的导致离开愉快对话。虚弱导致再见对话,重新开始,嗯,重新开始。这是一个基本的例子,但它演示了对话树如何工作的整体概念。

现在让我们看看如何在我们的 Lua 脚本引擎中实现这个示例树。以下是完整的脚本,我们将在接下来的部分深入了解细节:

intro = { 
    say = 'Hello I am the Helper NPC, can I help you?', 
    choices = { 
                 choice1 = "Yes! I need help", 
                 choice2 = "No!! Leave me alone" 
    }, 

    onSelection = function (choice)  
        if choice == CurrentDialog["choices"]["choice1"] then CurrentDialog = getHelp end 
        if choice  == CurrentDialog["choices"]["choice2"] then CurrentDialog = goodbye_mean end 
    end 
} 

getHelp = { 
    say = 'Ok I am still working on my helpfulness', 
    choices = { 
                 choice1 = "That's okay! Thank you!", 
                 choice2 = "That's weak, what a waste!", 
                 choice3 = "Start over please." 
        }, 
    onSelection = function (choice)  
        if choice  == CurrentDialog["choices"]["choice1"] then CurrentDialog = goodbye  
        elseif choice  == CurrentDialog["choices"]["choice2"] then CurrentDialog = goodbye_mean  
        elseif choice  == CurrentDialog["choices"]["choice3"] then CurrentDialog = intro end 
    end 

} 

goodbye = { 
    say = "See you soon, goodbye!" 
} 

goodbye_mean = { 
    say = "Wow that is mean, goodbye!" 
} 

CurrentDialog = intro 

正如你所看到的,整个脚本并不长。我们有一些概念使得这个脚本工作。首先是一个非常简单的状态机版本。我们有一个名为CurrentDialog的变量,这个变量将指向活动对话。在我们的脚本的最后,我们最初将其设置为intro对话对象,这将在加载脚本时启动对话树。我们在脚本设计中的下一个重要概念是将每个交互屏幕描述为一个表对象。让我们以介绍对话表为例。

intro = { 
    say = 'Hello I am the Helper NPC, can I help you?', 
    choices = { 
                 choice1 = "Yes! I need help", 
                 choice2 = "No!! Leave me alone" 
    }, 

    onSelection = function (choice)  
        if choice == CurrentDialog["choices"]["choice1"] then CurrentDialog = getHelp end 
        if choice  == CurrentDialog["choices"]["choice2"] then CurrentDialog = goodbye_mean end 
    end 
} 

每个对话表对象都有一个Say元素,这个元素是当Say函数询问脚本其对话内容时将显示的文本。接下来,我们有两个可选元素,但如果你想与玩家进行交互,这些元素是必需的。第一个是一个名为choices的嵌套表,其中包含了对话系统在玩家请求时将呈现给玩家的选择。第二个可选元素实际上是一个函数。当用户选择一个选项时,将调用此函数,并由一些if语句组成。这些if语句将测试选择了哪个选项,并根据选择将CurrentDialog对象设置为对话树路径上的下一个对话。

这就是全部。以这种方式设计我们的对话树系统的最大优点是,即使没有太多指导,甚至非程序员也可以设计一个像之前展示的简单脚本。

如果你继续使用Chapter08解决方案运行Dialog_Example项目,你将看到这个脚本的运行并能与之交互。以下是一些截图,展示输出的样子:

尽管这是一个简单的系统实现,但它非常灵活。再次需要指出的是,这些脚本不需要重新编译即可进行更改。自己试试吧。对NPC.lua文件进行一些更改,重新运行示例程序,你会看到你的更改出现。

在下一节中,我们将看到如何通过 Lua 脚本实现一个由任务系统驱动的对话树。

脚本任务

另一个非常常见的高级游戏玩法系统是任务系统。虽然任务更常见于角色扮演游戏中,但也可以出现在其他类型的游戏中。通常,这些其他类型会通过不同的名称来掩饰任务系统。例如,一些游戏有挑战,本质上与任务是一样的。

任务可以简单地被认为是为了实现特定结果而进行的尝试。通常,任务将涉及必须在任务被视为完成之前进行的一定数量的步骤。一些常见类型的任务包括击杀任务,玩家通常必须击败一定数量的敌人,通常被称为刷怪,以及交付任务,玩家必须扮演信使的角色,并经常需要前往游戏世界的新位置交付货物。当然,这是一个很好的方式,可以让玩家前往下一个期望的位置而不强迫他们。在收集任务中,玩家必须收集一定数量的特定物品。在护送任务中,玩家经常因为历史上糟糕的实现而感到害怕,玩家经常必须陪同一个NPC前往新的位置,并保护他们免受伤害。最后,混合任务通常是上述类型的混合,并且通常是更长的任务。

任务系统的另一个常见部分是支持所谓的任务链或任务线。在任务链中,每个任务的完成都是开始序列中下一个任务的先决条件。随着玩家在任务链中的进展,这些任务通常涉及越来越复杂的任务。这些任务是逐渐揭示情节的一个很好的方式。

这解释了任务是什么。在下一节中,我们将讨论在我们的游戏项目中添加任务支持的几种不同方式。然而,在我们查看实现的具体细节之前,对于我们来说定义每个任务对象需要的是很有用的。

为了简单起见,我们将假设任务对象将由以下内容组成:

  • 任务名称:任务的名称

  • 目标:完成任务所必须采取的行动

  • 奖励:玩家完成任务后将获得的奖励

  • 描述:关于任务的一些信息,也许是玩家为什么要承担这项任务的背景故事

  • 任务给予者:给予任务的NPC

有了这些简单的元素,我们就可以构建我们的基本任务系统。

正如我们在先前的游戏玩法系统示例中所看到的,我们可以以许多不同的方式来实现我们在示例引擎中的任务系统。现在让我们简要地看一下其中的一些,并讨论它们的优点和缺点。

引擎支持

我们支持任务系统的一种方式是将其构建到游戏引擎本身中。整个系统将设计得靠近引擎代码,并且使用本机引擎语言,对于我们来说是 C++。我们将创建基础设施来支持任务,使用我们已经多次看到的技术。通过继承,我们可以公开所需的基本函数和变量,并让开发人员构建这个结构。然后,一个简单的高级任务类可能看起来类似于以下内容:

class Quest 
{ 
public: 
    Quest(std::string name,  
    std::vector<GameObjects> rewards,  
    std::string description,  
    NPC questGiver); 
    ~Quest(); 
    Accept(); //accept the quest 
    TurnIn(); //complete the quest 
private: 
     std::string m_questName; 
       std::vector<GameObjects> m_rewards; 
       std::string m_questDescription; 
       NPC m_questGiver; 
     Bool isActive; 
}; 

当然,这只是一个简单的演示,而在这种情况下,我们将跳过实现。

这种实现方法的优点是它是用本机代码编写的,意味着它将运行得很快,并且它靠近引擎,这意味着它将更容易地访问底层系统,而无需接口层或其他库的需要。

这种实现方法的缺点包括,因为它是游戏引擎或游戏代码的一部分,这意味着任何更改都需要重新编译。这也使得非编程人员难以添加他们自己的任务想法,或者在发布后处理任务系统的扩展。

虽然这种方法确实有效,但更适用于较小的项目,在这些项目中,一旦任务或系统就位,您将不需要或不想要对其进行更改。

引擎/脚本桥

这种方法与我们之前实现NPC对话系统的方法相同。在这种设计中,我们创建一个处理脚本加载和数据传递的接口类。由于我们之前已经看到了类似的实现,我将跳过这里的示例代码,而是继续讨论这种方法的优缺点。

这种实现方法的优点包括与仅引擎实现相比的灵活性。如果我们想要进行任何更改,我们只需要在编辑器中加载脚本,进行更改,然后重新加载游戏。这也使得非编码人员更容易创建自己的任务。

这种实现方法的缺点包括它仍然部分地与引擎本身相关。脚本只能访问引擎接口公开的元素和函数。如果您想要为任务添加更多功能,您必须在脚本使用之前将其构建到引擎端。

这种方法更适合于较大的项目,但如前所述,仍然有其缺点。

基于脚本的系统

我们可以采取的另一种方法是在我们的脚本语言中构建整个系统,只从引擎中公开通用方法。这些通用方法很可能是模板函数的良好候选者。在这种方法中,任务系统的内部和任务脚本都将用脚本语言编写。在脚本中编写的每个任务都将包括对处理管理的任务系统脚本的引用。这种方法与仅引擎方法非常相似;它只是从引擎中移出,并进入脚本系统。

让我们来看一个简化版本的任务系统脚本。出于简洁起见,有些部分被省略了:

local questsys = {} 
questsys.quest = {} 

function questsys.new(questname, objectives, reward, description, location, level, questgiver) 
for keys, value in ipairs(objectives) do 
    value.value = 0 
  end 
  questsys.quest[#questsys.quest+1] = { 
    questname = questname, 
    objectives = objectives, 
    reward = reward, 
    description = description, 
    questgiver = questgiver, 
    accepted = false, 
    completed = false, 
    isAccepted = function(self) return self.accepted end, 
    isCompleted = function(self) return self.completed end 
  } 
end 

function questsys.accept(questname) 
  for key, value in ipairs(questsys.quest) do 
    if value.questname == questname then 
      if not value.accepted then 
        value.accepted = true 
      end 
  end 
end 

... 

function questsys.turnin(questname) 
  rejectMsg = "You have not completed the quest." 
  for key, value in ipairs(questsys.quest) do 
    if value.questname == questname then 
      for i, j in ipairs(questsys.quest[key].objectives) do 
        if j.value == j.maxValue then 
          value.completed = true 
          value.reward() 
        else return rejectMsg end 
      end 
  end 
end 

... 

questsys.get(questname, getinfo) 
  for key, value in ipairs(questsys.quest) do 
    if value.questname == questname then 
      if getinfo == "accepted" then return value:isAccepted() end 
      if getinfo == "completed" then return value:isCompleted() end 
      if getinfo == "questname" then return value.questname end 
      if getInfo == "description" then return value.description end 
      if getInfo == "location" then return value.location end 
      if getInfo == "level" then return value.level end 
      if getInfo == "questgiver" then return value.questgiver end 
    else error("No such quest name!") 
  end 
end 

return questsys 

再次,我省略了一些函数以节省空间,但理解系统所需的核心组件都在这里。首先,我们有一个创建新任务的函数,接受名称、目标、描述和任务给予者。然后我们有接受函数,将任务设置为活动状态。请注意,我们使用键/值查找方法来遍历我们的表 - 我们会经常这样做。然后我们有一个完成任务的函数,最后是一个简单的返回所有任务信息的函数。这里没有描绘的函数是用于获取和设置任务各种目标值的。要查看完整的实现,请查看代码存储库的Chapter08文件夹中的Quest_Example项目。

现在,有了任务系统脚本,我们有几个选择。首先,我们可以通过使用 Lua 内置的require系统将此系统添加到其他脚本中,这将允许我们在其他脚本中使用该脚本。这样做的语法如下:

local questsys = require('questsys') 

或者我们可以简单地在游戏引擎中加载脚本并使用接口,就像我们在上一个示例中所做的那样,并以这种方式与我们的任务系统交互。有了这种灵活性,选择权在于开发人员和情况。

这种实现方法的优点包括极大的灵活性。在这种方法中,不仅可以修改任务,还可以在不需要重新构建游戏或引擎的情况下即时修改任务系统本身。这通常是在产品发布后包含可下载内容(DLC)、游戏修改(mod)和其他额外内容的方法。

这种实现的缺点包括,尽管它非常灵活,但增加了额外的复杂性。它也可能会更慢,因为系统是用解释性的脚本语言编写的,性能可能会受到影响。它还要求开发人员对脚本语言有更多的了解,并可能需要更多的学习时间。

像其他方法一样,这种方法也有其适用的场合和时间。虽然我倾向于在较大的项目中使用这样的系统,但如果团队没有准备好,这种方法可能会增加更多的开销而不是简化使用。

总结

在本章中,当涉及到实施高级游戏玩法系统时,我们涵盖了大量内容。我们深入探讨了如何在游戏项目中包含像 Lua 这样的脚本语言。然后我们在这些知识的基础上,探讨了实施对话和任务系统到我们示例引擎中的方法。虽然我们讨论了很多内容,但我们只是触及了这个主题的表面。在下一章中,我们将继续基于这些新知识,为我们的游戏构建一些人工智能。

第九章:人工智能

大多数游戏都建立在竞争取胜的概念上。这种形式的竞争可以采取多种形式。自最早的视频游戏以来,玩家们发现自己在与机器竞争。思考、反应和挑战计算机对手的加入使游戏感觉生动并与玩家联系在一起。在本章中,我们将学习如何通过引入人工智能来为我们的游戏增加思考。

本章涵盖以下内容:

  • 什么是游戏人工智能?

  • 做决定

  • 运动和寻路技术

什么是游戏人工智能?

往往被误解的游戏人工智能的定义,以及游戏人工智能不是一项非常具有挑战性的任务。在 AI 这样一个广泛的领域中,很容易在这个主题上填满许多卷的书。鉴于我们只有一个章节来讨论这个概念和实施,在本节中,我们将尽力发展一个合理的游戏人工智能的定义以及它不是什么。

定义游戏人工智能

如前所述,确切地定义游戏人工智能是一项艰巨的任务,但我将尽力描述我认为是关于电子视频游戏的简明解释。当设计师创建游戏世界时,他们通过塑造愿景和定义一些常见的互动规则来实现。通常,玩家将通过观察世界的元素来体验这个世界。与世界的 NPC、对手和环境的互动,以及通过叙事方面,给玩家一种沉浸在游戏世界中的感觉。这些互动可以采取许多形式。在游戏中,玩家不断通过与无生命的物体互动来体验世界,但与其他人的互动才是真正突出的。这使得游戏感觉更具沉浸感、更具触感和更有生命力。

游戏世界中某物感觉活灵活现通常是通过对游戏世界和物体的观察来实现的,比如 NPC 做出决定。这是寻找游戏人工智能定义的一个重要标志。在更广泛的意义上,人工智能可以被认为是这种感知决策的应用。通常,这种决策的感知以自主的人工智能代理的形式出现,例如常见的 NPC。这些决定可能包括从移动、对话选择,甚至对环境的改变,这些改变可能传达开发者试图创造的体验。这再次是我在定义游戏人工智能时的另一个标志。本质上,这是关于开发者试图创造的体验。因此,游戏人工智能更多地是关于近似实现期望效果,而不一定是完美的科学解释。

当开发者着手创建人工智能体验时,重要的是要牢记玩家的乐趣和沉浸感。没有人想要与完美的对手对战。我们希望在互动的另一端感知到智能,只是不希望它更聪明。这就是游戏人工智能的开发和通用人工智能发展领域开始产生分歧的地方。我们将在下一节深入探讨这种分歧,但现在让我们看看游戏开发中人工智能的一些用途。

对话

通过对话进行某种形式的互动的游戏往往通过角色与玩家的连接以及玩家对他们故事的投入来给人一种沉浸在世界中的感觉。然而,这是一个挑战,通常是通过对话树来实现的,正如我们在上一章中所看到的。这种对话树的方法,在某些情况下是可靠的,但很容易变得复杂。

完全脚本化对话的另一个问题是,随着对话随着时间的推移而继续,玩家很快就会摆脱这是一种智能互动的幻觉。这使得互动感觉受限,反过来也使得世界感觉受限。解决这个问题的一种方法是在对话中引入人工智能。您可以使用决策算法来增强脚本化的互动,从而在回应中给人一种更深层次的智能感。在这个概念的极端方面,您可以采用一种解析玩家输入并动态生成回应的方法。这样的方法可能包括所谓的自然语言处理NLP)。通过利用类似于聊天机器人的东西,设计师和工程师可以创建由在用户互动时思考的代理人所居住的世界。虽然这听起来可能非常诱人,但自然语言处理领域仍被认为处于起步阶段。借助云计算提供动力的 API,如微软的认知服务 API,创建支持 NLP 的基础设施的过程变得更加容易。然而,适当的实施和语言模型的培训可能非常耗时。

竞争对手

许多游戏包括敌人或竞争对手的概念,供玩家进行互动。事实上,我会说这是大多数人会认为是游戏人工智能的一个例子。这些对手如何与玩家、他们的环境和其他由 AI 控制的对手互动,都是他们的人工智能设计的一部分。通常,这种人工智能设计将包括决策制定的概念,如行为树、反馈循环、状态和其他模式。它们通常还会包括其他人工智能组件,如运动算法和路径规划技术,我们稍后将更深入地介绍。创建有趣而具有挑战性的对手并不是一件容易的事。正如我之前所说,没有人想玩一个他们觉得没有赢的机会的游戏。拥有一个比玩家更快更聪明的人工智能不应该是设计对手人工智能的目标;相反,您应该专注于给用户一个有竞争力的人工智能,可能能够适应玩家不断增长的技能。正是在这种情况下,像使用机器学习来构建自适应人工智能这样的高级技术开始引起关注。尽管这些技术仍处于探索阶段,但定制人工智能对手的日子可能很快就会到来。

运动和路径规划

可以说,与使用人工智能作为对手一样常见的是利用人工智能进行运动和路径规划的概念。在运动中使用人工智能包括实施算法来处理游戏元素的自主移动。诸如转向、追逐和躲避等概念都可以在人工智能算法中表达。运动人工智能也常常用于处理简单的碰撞回避。路径规划是使用人工智能在将游戏对象从一个位置移动到另一个位置时找到最有效或最有效的路线的概念。自六十年代以来,DijkstraA*等算法一直存在,并为路径规划人工智能的发展提供了支柱。我们将在本章后面更深入地探讨运动和路径规划算法和技术。

游戏人工智能不是什么

人工智能作为一个研究领域非常广泛,实际上包括的远不止游戏使用的内容。最近,围绕开发者空间中的人工智能的讨论变得更加广泛,越来越多的开发者寻求在其项目中利用人工智能技术的方法。因此,我认为重要的是要提及游戏开发领域之外一些更常见的人工智能用例。

AI 领域中最热门的领域之一是机器学习。机器学习ML)可能最好由 Arthur Lee Samuel 描述,当他创造了机器学习这个术语时:计算机学习如何在没有明确编程的情况下实现结果或预测的能力。在数据分析领域,机器学习被用作一种方法来设计复杂的模型和算法,帮助预测给定问题的结果。这也被称为预测性分析。这些分析模型允许研究人员和数据科学家创建可靠、可重复的计算和结果,并通过数据中的历史关系和趋势发现其他见解。正如前一节中提到的,定制 AI 从您的游戏风格中学习并适应的想法是非常吸引人的概念。然而,这可能是一个很棘手的问题;如果 AI 变得太聪明,那么游戏的乐趣水平就会迅速下降。机器学习在游戏中的使用的一个很好的例子是 Forza 赛车游戏系列。在这里,赛车 AI 头像通过云计算驱动的机器学习实现来调整您遇到的 AI 赛车手的竞争水平,以适应您当前的能力水平。

AI 在游戏开发领域之外的另一个不断增长的用途是其在数据挖掘场景中的应用。虽然这一领域的 AI 仍处于早期阶段,但其在理解用户和客户数据方面的应用对许多商业部门非常有吸引力。这种 AI 用例的边界及其与游戏开发概念的潜在重叠尚未被定义。然而,一些数据挖掘的核心组件,用于理解玩家如何与游戏及其各个组件进行交互,很容易被视为对游戏开发者有益。准确了解玩家如何与游戏 GUI 等元素进行交互,将使开发者能够为每个用户创造更好的体验。

我想要讨论的 AI 在游戏开发领域之外的最后一个用例可能是当普通人想到 AI 时最为认可的用途之一,那就是在认知处理研究中使用 AI。在 AI 的学术解释中,认知处理是开发科学上可证明的这些过程的模型。这基本上可以概括为在 AI 过程中对人类智能进行建模。虽然这种方法对科学研究非常重要,但目前对游戏开发的用例来说还太过抽象,无法被认为是有用的。也就是说,机器人和自然语言处理的使用开始渗入游戏开发,正如前面提到的。

学术和研究 AI 的具体目标往往与游戏 AI 的目标完全不同。这是因为两者之间的实现和技术的固有差异完全不同。更多时候,游戏 AI 解决方案会倾向于简化方法,以便进行简单的更改和调整,而研究方法很可能会选择最科学完整的实现。在接下来的几节中,我们将看一些这些更简单的游戏开发实现,并讨论它们的用例和理论。

做决定

AI 的目标更多地是给人类智能的外观。智能感知的关键方面之一是 AI 代理做出决策的想法。即使是脚本化的,对某些行动有选择权,给玩家一种思考世界的感觉,由思考实体构成。在下一节中,我们将介绍游戏 AI 中一些更为知名的决策制定技术。

AI 状态机

如果你一直在按章节跟着这本书,你可能已经注意到状态模式的使用不止一次。这种模式是一个非常强大的模式,因此在我们各种组件设计中经常使用。在人工智能领域,状态模式再次成为一颗耀眼的明星。状态机的使用,特别是有限状态机(FSM),允许对代码的执行流程进行详细的表示。它非常适合在游戏中实现 AI,允许设计强大的交互而不需要复杂的代码。

我不打算花太多时间来讨论有限状态机实现的概念和理论,因为我们已经详细覆盖了。相反,我们将看一个在 AI 脚本中实现它的例子。如果你需要对这种模式进行复习,请查看第五章中关于理解状态的部分。

以下是一个描述敌人简单大脑的图表。在这个例子中,每个状态代表一个动作,比如搜索或攻击:

虽然这是一个简单的例子,但它确实为许多情况提供了有用的 AI。我们可以在 C++中实现这个项目,就像我们在Screen示例和其他地方看到的那样。然而,如果你已经阅读了前一章,你会看到我们可以在脚本中实现这样的逻辑。当然,这使我们能够灵活地进行脚本编写,比如不必重新构建项目来调整代码的元素。这对于 AI 来说非常有益,因此在本章中,我将展示使用 Lua 脚本的示例代码,这可以使用前一章中描述的步骤来实现。

在 Lua 脚本中,这种 AI 设计的可能实现可能看起来类似于以下内容:

Search = function () 
{ 
    //Do search actions.. 
    if playerFound == true then currentState = Attack end 
} 
Attack = function() 
{ 
    //Do attack actions 
    if playerAttacks == true then currentState = Evade  
    elseif playerOutOfSight == true then currentState = Search end 
} 
Evade = function() 
{ 
    //Do evade actions 
    If healthIsLow == true then currentState = FindHealth 
    Elseif playerRetreats == true then currentState == Attack end 
} 
FindHealth = function() 
{ 
    //Do finding health actions 
    If healthFound == true then currentState = Search end 
} 
currentState = Search 

这应该看起来很熟悉,就像上一章中的 NPC 对话示例。在这里,为了完成系统,我们首先会将脚本加载到 AI 代理或 NPC 的实例中,然后在游戏代码的Update循环中调用currentState变量当前分配的函数。通过这种代码实现,我们有了一种有效的构建基本 AI 交互的方法。这种技术自游戏开发的早期就存在。事实上,这与街机经典游戏《吃豆人》中的幽灵对手 AI 的实现非常相似。

我们还可以扩展这种简单的 FSM 实现,并将基于堆栈的 FSM 添加到解决方案中。这与第五章中看到的实现示例非常相似,因此我不会详细介绍关于基于堆栈的 FSM 理论的所有细节。基于堆栈的 FSM 的基本原则是,我们可以按照先进后出的顺序向堆栈添加和移除对象。向堆栈添加项目的常用术语称为推送,从堆栈中移除对象的操作称为弹出。因此,对于状态示例,在不同的函数期间,堆栈可能看起来类似于以下图表:

使用基于堆栈的 FSM 的一个主要优势是,现在可以使用堆栈来控制当前状态。每个状态可以从堆栈中弹出自己,允许执行下一个状态。我们还可以实现“进入”和“退出”的概念,使我们能够在状态内部有更多的状态。我们可以在每个状态中进行设置和清理等操作,使我们的 AI 状态系统更加灵活。

在 Lua 脚本中实现基于堆栈的有限状态机(FSM)的状态可能看起来类似于以下内容:

StateA =  
{ 
    Update = function () 
    { 
        //Do state update actions 
} 
OnEnter = function() 
{ 
    //Do actions for first load 
} 
OnExit = function() 
{ 
    //Do action for last call for this state 
} 
} 

然后,在我们的 C++代码中,我们将添加其余的架构,以支持基于状态的 FSM。在这里,我们将创建一个向量或数组对象,该对象将保存从 Lua 脚本中加载的状态对象的指针。然后,我们将调用OnEnterOnExitUpdate函数,用于当前占据数组中最后一个元素的状态对象。如前所述,我们可以通过简单创建一个枚举并切换案例来处理状态流。我们也可以创建一个StateList类,该类将实现包装 FSM 所需函数。对于我们的示例,这个StateList类可能如下所示:

class StateList { 
    public: 
        StateList (); 
        ~ StateList (); 

        LuaState * GoToNext(); 
        LuaState * GoToPrevious(); 

        void SetCurrentState(int nextState); 
        void AddState(State * newState); 

        void Destroy(); 

        LuaState* GetCurrent(); 

    protected: 
        std::vector< LuaState*> m_states; 
        int m_currentStateIndex = -1; 
    }; 
} 

无论你选择以哪种方式实现基于状态的 FSM,你仍然会获得堆栈控制的额外好处。正如你所看到的,状态模式在 AI 开发中使用时,为我们创建 AI 交互提供了一个伟大而灵活的起点。接下来,我们将看一些其他技术,介绍如何将决策引入到你的 AI 设计中。

决策树

决策树是一种类似流程图的结构,由分支和叶子组成。树的每个分支都是一个条件,用于做出决策。每个叶子是在条件中做出的选择的动作。在树的最远端,叶子是控制 AI 代理的实际命令。使用决策树结构可以更容易地设计和理解 AI 实现的流程。在决策树中实现的简单 AI 大脑可能看起来类似于以下图表:

你可能会想到,这看起来和听起来非常像我们在第八章中实现的对话树,高级游戏系统。那是因为它们就是!就像在处理对话和选择的情况下一样,使用树结构是脚本化 AI 交互流程的一种绝佳方式。决策树可以非常深,具有调用执行特定功能的子树的分支和节点。这使设计师能够使用大量不同的决策库,这些决策可以链接在一起,提供令人信服的 AI 交互深度。你甚至可以发展出可以根据当前任务的整体可取性排序的分支,然后在所需的分支失败时回退到其他决策。这种弹性和灵活性正是树结构的优势所在。

熟悉 C++数据结构的人可能已经在考虑如何在代码中实现这种树结构。也许列表已经浮现在脑海中。有许多不同的实现决策树的方法。我们可以将树定义为外部格式,比如 XML。我们可以使用 C++和 Lua 等脚本语言的混合来实现它的结构和架构,但由于我真的想要深入理解树设计,我们将把整个实现放在 Lua 中。这可以通过 David Young 在书籍使用 Lua 学习游戏 AI 编程中演示的一个很好的例子来完成,所以我们将以 David 更详细的例子为基础,构建我们的简单示例。

首先,让我们看一下树对象的结构。在DecisionTree.lua文件中,我们可以有以下代码:

DecisionTree = {}; 

function DecisionTree.SetBranch(self, branch)     
self.branch_ = branch; 
end 

function DecisionTree.Update(self, deltaTime)     
-- Skip execution if the tree hasn't been setup yet.     
if (self.branch_ == nil) then 
        return; 
    end 
    -- Search the tree for an Action to run if not currently     
    -- executing an Action. 
    if (self.currentAction_ == nil) then 
        self.currentAction_ = self.branch_:Evaluate(); 
        self.currentAction_:Initialize(); 
    end 
        local status = self.currentAction_:Update(deltaTime); 
end 
function DecisionTree.new() 
    local decisionTree = {}; 
        -- The DecisionTree's data members. 
    decisionTree.branch_ = nil; 
    decisionTree.currentAction_ = nil; 
        -- The DecisionTree's accessor functions. 
    decisionTree.SetBranch = decisionTree.SetBranch; 
    decisionTree.Update = decisionTree.Update; 
        return decisionTree; 
end 

在我们的树结构中,我们实现了一个更新循环,该循环评估树中的根分支并处理结果动作。一旦动作被创建、处理和完成,决策树将重新评估自身,从根分支重新开始确定下一个要执行的动作。

接下来是分支对象。在我们的实现中,分支将包括一个条件,该条件将确定接下来执行哪个元素。条件评估的责任是返回一个值,该值范围从分支中的子级的最大数量。这将表示应该执行哪个元素。我们的决策分支 Lua 类对象将具有基本函数,用于添加额外的子级以及在分支计算期间使用的设置条件函数。在DecisionBranch.lua文件中,我们可以有一个类似以下的实现:

DecisionBranch = {} 
DecisionBranch.Type = " DecisionBranch "; 
function DecisionBranch.new() 
    local branch = {}; 
    -- The DecisionBranch data members. 
    branch.children_ = {}; 
    branch.conditional_ = nil; 
    branch.type_ = DecisionBranch.Type; 
    -- The DecisionBranch accessor functions. 
    branch.AddChild = DecisionBranch.AddChild; 
    branch.Evaluate = DecisionBranch.Evaluate; 
    branch. SetConditional = DecisionBranch. SetConditional; 
    return branch; 
end 
function DecisionBranch.AddChild(self, child, index) 
    -- Add the child at the specified index, or as the last child. 
    index = index or (#self.children_ + 1); 
        table.insert(self.children_, index, child); 
end 
function DecisionBranch.SetConditional (self, conditional) 
    self. conditional _ = conditional; 
end 

正如大卫在他的例子中指出的那样,由于叶子只是动作,我们可以将每个叶子动作包含在分支中。这使我们能够在代码中获得所需的功能,而无需额外的结构。通过使用type_ 变量,我们可以确定分支的子级是另一个分支还是需要执行的动作。

对于分支本身的评估,我们执行条件,然后使用返回的值来确定树中的下一步。值得注意的是,树中的每个分支最终都必须以一个动作结束。如果树中有任何不以动作结束的叶子,那么树就是畸形的,将无法正确评估。

留在DecisionBranch.lua文件中,评估分支的代码看起来类似以下内容:

function DecisionBranch.Evaluate(self) 
    -- Execute the branch's evaluator function, this will return a 
    -- numeric value which indicates what child should execute. 
    local conditional = self. conditional _(); 
    local choice = self.children_[conditional]; 
    if (choice.type_ == DecisionBranch.Type) then 
        -- Recursively evaluate children to see if they are decision branches. 
        return choice:Evaluate(); 
    else 
        -- Return the leaf action. 
        return choice; 
    end 
end 

现在我们已经有了树数据结构,我们可以继续构建一个供使用的树。为此,我们首先创建决策树的新实例,创建树中所需的每个分支,连接条件分支,最后添加动作叶子。在AILogic.lua文件中,我们可以有类似以下的内容:

function AILogic_DecisionTree() 
    --Create a new instance of the tree 
    local tree = DecisionTree.new(); 
--Add branches 
local moveBranch = DecisionBranch.new(); 
    local shootBranch = DecisionBranch.new(); 
    --Connect the conditional branches and action leaves 
... 
moveBranch:AddChild(MoveAction()); 
      moveBranch:AddChild(randomBranch); 
      moveRandomBranch:SetConditional( 
        function() 
            if Conditional_HasMovePosition() then 
                return 1; 
            end 
            return 2; 
        end); 
... 
    --Set initial branch 
    tree:SetBranch(moveBranch); 
return tree; 
end 

有了决策树,我们现在可以调用此脚本并将树加载到 AI 代理对象中。我们可以随时进行更改,添加更多决策和动作,甚至添加其他 AI 技术来增强决策。虽然决策树允许开发人员和设计师创建易于理解和阅读的 AI 结构,但它也有缺点。最显着的缺点之一是其对复杂逻辑条件的建模,其中您需要考虑条件的每种可能结果。此外,随着更多分支可能性的增加,树也将开始需要平衡。如果不进行平衡,树的部分将需要复制,迅速增加树结构的复杂性,并导致更容易出现错误的代码。

反馈循环

我想简要谈一下 AI 决策中的最后一个主题,即反馈循环的概念。反馈循环是指系统的某个输出值被反馈或返回给系统,进而影响系统的状态,影响其后续值。理想情况下,在视频游戏中,特别是在 AI 交互中,每个循环都应该是一个稳定的反馈循环。稳定反馈循环的简单定义是系统的输出用于扭转导致反馈值的情况,使反馈系统移动到稳定状态的收敛。这可以防止您的 AI 反馈引起负面或正面反馈循环的失控效应。

为了帮助您真正理解反馈循环是什么,让我们以视频游戏中最常见的例子来说明,即耐力。耐力在许多场景中都有体现,比如角色奔跑或奔跑的能力,或者角色攀爬的能力。在我们的例子中,我们将看一下拳击比赛的例子。以下是一个显示我们想要实现的反馈循环的图表:

如前所述,我们需要确保拳击示例中的耐力反馈循环是稳定的。 这意味着当我们达到预定义的低耐力水平时,我们需要将循环切换到防守,以便我们恢复耐力。 如果达到预定义的恢复水平,我们则相反地切换到进攻以降低耐力水平。 这种切换允许我们保持循环稳定,并被称为振荡反馈循环。

在代码中实现这一点是令人惊讶地简单:

void Update(float deltaTime) 
{ 
    if(currentState == attacking) 
    { 
        ReduceStamina(); 
    if(player.stamina <= depleted) 
{ 
        currentState = defending; 
} 
} 
else if (currentState == defending) 
{ 
    IncreaseStamina(); 
    if(stamina >= replenished) 
    { 
        currentState = attacking; 
    } 
} 
} 

就是这样,老实说。 编写这种技术的实现并不复杂。 我们确实跳过了一些事情,比如如何处理减少和增加耐力。 考虑到这是一个 AI 系统,我们希望它看起来更真实,因此静态地增加这些值并不是很好。 在这里放置一个好的随机值可以使其更具真实感。 最终,这是一种易于实现的技术,可以提供一种很好的方式来改变结果,并为 AI 组件提供更独特的交互。

运动和路径规划技术

AI 代理和其他非玩家角色经常需要在游戏世界中移动。 实现这种移动,使其看起来像是真实的,是一个具有挑战性的过程。 在下一节中,我们将看看如何实现算法和技术,以将 AI 代理的移动和路径规划添加到我们的游戏开发项目中。

运动算法和技术

使用运动算法来控制 AI 代理在关卡或游戏世界中的移动是视频游戏中 AI 算法的一个非常常见的用例。 这些算法可以实现行为,给人以思考和反应的 AI 代理的印象,它们还可以执行其他任务,如简单的物体避让。 在下一节中,我们将看一些这些运动技术。

转向行为

转向行为是由各种技术组成的运动算法的子集,用于基于外部和内部变量控制 AI 代理的移动。 在我们的示例引擎中,我们已经整合了一个 3D 物理计算库-请参阅第五章,“构建游戏系统”,进行复习-我们已经有了一个 NPC 类的概念,作为我们的 AI 代理。 这意味着我们已经拥有了创建基于牛顿物理的转向系统所需框架的大部分内容,也称为基于转向的运动系统。 基于转向的运动系统由几个不同的分类组成,用于向 AI 代理添加力。 这些包括寻找、逃避、规避、徘徊、追逐等分类。 这些算法的完全详细实现将占据自己的章节,因此我们将专注于每个算法的高级概念和用例。 为了帮助您在实现方面,我在示例引擎中包含了OpenSteer库。 OpenSteer将处理计算的细节,使我们的引擎和我们的 AI Lua 脚本更容易使用这些算法来控制代理的移动。

以下是运行寻找和逃避算法的OpenSteer库程序的屏幕截图:

寻找

让我们从寻找算法开始。 寻找算法的目标是引导 AI 代理朝向游戏空间中的特定位置。 这种行为施加力,使当前航向和期望的航向朝向目标目的地对齐。 以下图表描述了这个过程:

期望航向实际上是一个从角色到目标的方向向量。期望航向的长度可以设置为一个值,比如角色当前的速度。转向向量或寻找路径是期望航向与角色当前航向的差。这个方程可以简化为以下形式:

    desiredHeading = normalize (position - target) * characterSpeed 
    steeringPath = desiredHeading - velocity 

寻找算法的一个有趣的副作用是,如果 AI 代理继续寻找,它最终会穿过目标,然后改变方向再次接近目标。这会产生一种看起来有点像蛾子围绕灯泡飞舞的运动路径。要使用OpenSteer来计算转向力,你需要调用steerForSeek函数,传递一个 3 点向量来描述目标的位置:

Vec3 steerForSeek (const Vec3& target); 

逃避

使用OpenSteer来计算逃避 AI 代理的转向力,你需要调用steerForEvasion函数,传递一个对象作为我们要逃避的目标,以及一个浮点值来指定在计算要施加的力时要使用的未来最大时间量:

 Vec3 steerForFlee (const Vec3& target); 

追逐

追逐转向行为与寻找行为非常相似,但这里的区别在于目标点实际上是一个移动的对象或玩家。下图说明了这个行为:

逃避

为了创建有效的追逐行为,我们需要对目标的未来位置进行一些预测。我们可以采取的一种方法是使用一个预测方法,在每次更新循环中重新评估。在我们简单的预测器中,我们将假设我们的目标在此更新循环中不会转向。虽然这种假设更容易出错,但预测结果只会在一小部分时间(1/30)内使用。这意味着,如果目标确实改变方向,下一个模拟步骤中将根据目标改变方向进行快速修正。同时,根据这个假设,可以通过将目标的速度乘以 X 并将该偏移添加到其当前位置来计算 X 单位时间内的目标位置。然后,只需将寻找转向行为应用于预测的目标位置,就可以实现追逐行为。

要使用OpenSteer来计算追逐 AI 代理的转向力,你需要调用steerForPursuit函数,传递一个对象作为我们要追逐的目标:

Vec3 steerForPursuit (const TargetObject& target); 

使用OpenSteer来计算逃避 AI 代理的转向力,你需要调用steerForFlee函数,传递一个 3 点向量来描述目标的位置:

逃避就像逃离是寻找的反向,逃避是追逐的反向。这意味着,我们不是朝着目标的计算未来位置驾驶 AI 代理,而是从目标的当前位置逃离。下图说明了这个行为:

使用逃避转向行为时,AI 代理将远离预测的相遇点。这通常会导致不太自然的行为,因为大多数真正逃离的实体可能会有一个随机的逃避模式。实现更自然效果的一种方法是修改施加的力与另一个行为,比如我们接下来将要介绍的漫游行为。

逃避行为就是寻找行为的反向。这意味着,AI 代理不是朝着特定目标对齐航向,而是朝着目标点的相反方向对齐航向。下图说明了这个过程:

Vec3 steerForEvasion (const AbstractVehicle& menace, 
                      const float maxPredictionTime); 

漫游

正如我之前提到的,有时通过添加另一个行为来修改力来使行为有一些波动会更好。漫游行为是一个很好的修改行为的例子。漫游行为基本上返回一个与代理的前向矢量相关的切线转向力。值得注意的是,由于漫游行为旨在为代理的移动增加一些偏差,它不应该单独用作转向力。

要使用OpenSteer来为 AI 代理计算漫游转向力,你可以调用steerForWander函数,并传递一个浮点值来指定漫游之间的时间步长。时间步长值允许在帧时间变化时保持漫游速率一致:

Vec3 steerForWander (float dt); 

虽然这本书中我们只能花这么多时间来研究 AI 转向行为,但我们只是开始了解可用的内容。像群集和简单的物体避让这样的概念不幸地超出了本章的范围,但是OpenSteer库完全支持这些概念。如果你有兴趣了解更多关于这些行为的内容,我强烈建议阅读OpenSteer文档。

搜索算法和路径规划技术

在许多游戏中,我们经常需要找到从一个位置到另一个位置的路径。游戏开发中人工智能的另一个非常常见的需求,也是本章将要涉及的最后一个需求,是使用搜索算法来寻找 AI 代理周围移动的最佳路径。

例如,这里我们将专注于图搜索算法。图搜索算法,顾名思义,使用图作为其数据输入的来源。在我们的地图示例中,图是一组位置和它们之间的连接。它们通常分别被称为节点和边。以下是一个非常基本的图数据可能看起来像的示例:

这些图搜索算法的输出可以用来制定 AI 代理需要采取的路径。这条路径由图的节点和边组成。值得注意的是,这些算法会告诉你的 AI 去哪里移动,但不会提供如何移动。这些算法不像本章前面的转向力算法,它们不会移动 AI 代理。然而,结合转向算法,这些路径规划算法将创建出色的整体 AI 行为。

现在我们对图是如何表示地图以及我们想要找到路径的点有了基本的了解,让我们来看一些最常用的算法。

广度优先

广度优先搜索是最简单的搜索算法。它平等地探索所有方向。那么它是如何探索的呢?在所有这些搜索算法中,关键思想是跟踪一个不断扩展的区域,称为前沿。广度优先算法通过从起点向外移动并首先检查其邻居,然后是邻居的邻居,依此类推来扩展这个前沿。以下是一个显示这种扩展在网格上发生的图表。数字表示网格方格被访问的顺序:

以下是如何在 C++中实现这一点的一个简单示例。出于篇幅考虑,我省略了一些代码部分。完整的实现可以在源代码库的Chapter09示例项目中找到:

void SearchGraph::BreadthFirst(int s) 
{ 
    // Mark all the vertices as not visited 
    bool *visited = new bool[V]; 
    for(int i = 0; i < V; i++) 
        visited[i] = false; 

    // Create a queue for BFS 
    list<int> queue; 

    // Mark the current node as visited and enqueue it 
    visited[s] = true; 
    queue.push_back(s); 

    // 'i' will be used to get all adjacent vertices of a vertex 
    list<int>::iterator i; 

    while(!queue.empty()) 
    { 
        // Dequeue a vertex from queue and print it 
        s = queue.front(); 
        cout << s << " "; 
        queue.pop_front(); 

        // Get all adjacent vertices of the dequeued vertex s 
        // If a adjacent has not been visited, then mark it visited 
        // and enqueue it 
        for(i = adj[s].begin(); i != adj[s].end(); ++i) 
        { 
            if(!visited[*i]) 
            { 
                visited[*i] = true; 
                queue.push_back(*i); 
            } 
        } 
    } 
} 

从源代码中你可能已经注意到,这个算法的一个技巧是我们需要避免重复处理节点并多次处理一个节点。在这个简单的例子中,我们实现了一个布尔值数组来标记已访问的节点。如果我们不在这个例子中标记已访问的顶点,我们就会创建一个无限循环过程。

这是一个非常有用的算法,不仅适用于常规路径规划,还适用于程序地图生成、流场路径规划、距离图和其他类型的地图分析。

Dijkstra 算法

在某些情况下,当每一步都可能有不同的成本时,我们需要找到最短的路径。例如,在文明游戏系列中,穿越不同的地形类型需要不同数量的回合。在这种情况下,我们可以实现 Dijkstra 算法,也称为统一成本搜索。这个算法让我们可以优先考虑要探索的路径。它不是平等地探索所有可能的路径,而是偏向于成本较低的路径。为了实现路径的优先级,我们需要跟踪移动成本。实质上,我们希望在决定如何评估每个位置时考虑移动成本。在这个算法中,我们需要所谓的优先队列或堆。使用堆而不是常规队列会改变前沿的扩展方式。以下是 C++中演示 Dijkstra 算法的示例代码摘录,为了节省空间,我再次省略了一些部分。您可以在源代码库的Chapter09文件夹中找到完整的 Dijkstra 示例:

// Prints shortest paths from src to all other vertices 
void SearchGraph:: Dijkstra(int src) 
{ 
    // Create a priority queue to store vertices that are being preprocessed 
    priority_queue< iPair, vector <iPair> , greater<iPair> > pq; 

    // Create a vector for distances and initialize all distances as infinite (INF) 
    vector<int> dist(V, INF); 

    // Insert source itself in priority queue and initialize its distance as 0\. 
    pq.push(make_pair(0, src)); 
    dist[src] = 0; 

    /* Looping till priority queue becomes empty (or all 
      distances are not finalized) */ 
    while (!pq.empty()) 
    { 
        int u = pq.top().second; 
        pq.pop(); 

        // 'i' is used to get all adjacent vertices of a vertex 
        list< pair<int, int> >::iterator i; 
        for (i = adj[u].begin(); i != adj[u].end(); ++i) 
        { 
            // Get vertex label and weight of current adjacent of u. 
            int v = (*i).first; 
            int weight = (*i).second; 

            // If there is shorted path to v through u. 
            if (dist[v] > dist[u] + weight) 
            { 
                // Updating distance of v 
                dist[v] = dist[u] + weight; 
                pq.push(make_pair(dist[v], v)); 
            } 
        } 
    } 

    // Print shortest distances stored in dist[] 
    printf("Vertex   Distance from Sourcen"); 
    for (int i = 0; i < V; ++i) 
        printf("%d tt %dn", i, dist[i]); 
}  

这个算法在使用不同成本找到最短路径时非常好,但它确实浪费时间在所有方向上探索。接下来,我们将看看另一个算法,它让我们找到通往单一目的地的最短路径。

A*

在路径规划中,可以说最好和最流行的技术之一是A算法。A是 Dijkstra 算法的一种优化,适用于单一目的地。Dijkstra 算法可以找到到所有位置的路径,而 A找到到一个位置的路径。它优先考虑似乎更接近目标的路径。实现非常类似于 Dijkstra 实现,但不同之处在于使用启发式搜索函数来增强算法。这种启发式搜索用于估计到目标的距离。这意味着 A使用 Dijkstra 搜索和启发式搜索的总和来计算到某一点的最快路径。

以下是维基百科提供的 A*算法过程的伪代码示例,非常出色(en.wikipedia.org/wiki/A*_search_algorithm):

function A*(start, goal) 
    // The set of nodes already evaluated 
    closedSet := {} 

    // The set of currently discovered nodes that are not evaluated yet. 
    // Initially, only the start node is known. 
    openSet := {start} 

    // For each node, which node it can most efficiently be reached from. 
    // If a node can be reached from many nodes, cameFrom will eventually contain the 
    // most efficient previous step. 
    cameFrom := the empty map 

    // For each node, the cost of getting from the start node to that node. 
    gScore := map with default value of Infinity 

    // The cost of going from start to start is zero. 
    gScore[start] := 0 

    // For each node, the total cost of getting from the start node to the goal 
    // by passing by that node. That value is partly known, partly heuristic. 
    fScore := map with default value of Infinity 

    // For the first node, that value is completely heuristic. 
    fScore[start] := heuristic_cost_estimate(start, goal) 

    while openSet is not empty 
        current := the node in openSet having the lowest fScore[] value 
        if current = goal 
            return reconstruct_path(cameFrom, current) 

        openSet.Remove(current) 
        closedSet.Add(current) 

        for each neighbor of current 
            if neighbor in closedSet 
                continue        // Ignore the neighbor which is already evaluated. 

            if neighbor not in openSet    // Discover a new node 
                openSet.Add(neighbor) 

            // The distance from start to a neighbor 
            tentative_gScore := gScore[current] + dist_between(current, neighbor) 
            if tentative_gScore >= gScore[neighbor] 
                continue        // This is not a better path. 

            // This path is the best until now. Record it! 
            cameFrom[neighbor] := current 
            gScore[neighbor] := tentative_gScore 
            fScore[neighbor] := gScore[neighbor] + heuristic_cost_estimate(neighbor, goal) 

    return failure 

function reconstruct_path(cameFrom, current) 
    total_path := [current] 
    while current in cameFrom.Keys: 
        current := cameFrom[current] 
        total_path.append(current) 
    return total_path 

这就是我们对一些常见路径规划技术的快速介绍。虽然在本节中我们看到了一些实现,但如果您正在寻找生产游戏的绝佳起点,我强烈建议您查看一些开源库。这些是非常有价值的学习资源,并提供了经过验证的实现技术,您可以在此基础上构建。

总结

在本章中,我们在短时间内涵盖了一个广泛的研究领域。我们对游戏 AI 的真正定义进行了基本界定,以及它不是什么。在本章中,我们还探讨了如何通过包括 AI 技术来扩展决策功能。我们讨论了如何通过使用转向力和行为来控制 AI 代理的移动。最后,我们通过查看路径规划算法的使用来为我们的 AI 代理创建从一个点到另一个点的路径来结束了本章。虽然我们在本章中涵盖了相当多的内容,但在游戏 AI 的世界中仍有许多未被发掘的内容。我恳请您继续您的旅程。在下一章中,我们将看看如何将多人游戏和其他网络功能添加到我们的示例游戏引擎中。

第十章:多人游戏

自从我最早的游戏冒险以来,我发现分享体验总是让它更加难忘。在那些日子里,多人游戏的概念围绕着与朋友一起在沙发上玩游戏或者与其他游戏爱好者聚在一起举办一场史诗般的LAN(本地区域网络)派对。自那时以来,情况发生了巨大变化,现在在线、全球共享的游戏体验已成为新常态。在本章中,我们将介绍如何为您的游戏项目添加多人支持的概念,重点关注网络多人游戏。正如我之前所说,计算机网络的话题是一个非常庞大和多样化的话题,需要比我们现在有的时间和空间更多的时间来全面覆盖。因此,我们将重点介绍高层概述,并在需要时深入讨论。在本章中,我们将涵盖以下主题:

  • 多人游戏简介

  • 网络设计和协议开发

  • 创建客户端/服务器

游戏中的多人游戏简介

简而言之,多人游戏是一种视频游戏类型,可以让多人同时玩。而单人游戏通常是围绕一个玩家与人工智能对手竞争和实现预定目标,而多人游戏则是围绕与其他人类玩家的互动而设计的。这些互动可以是竞争、合作伙伴关系,或者简单的社交互动。多人互动的实现方式可以根据地点和类型的因素而有所不同,从同屏多人游戏的格斗游戏到在线多人角色扮演游戏,用户共享一个共同的环境。在接下来的部分,我们将看一些多人互动可以包含在视频游戏中的各种方式。

本地多人游戏

游戏中的多人游戏概念最早出现在本地多人游戏的形式中。很早以前,很多游戏都有两人模式。一些游戏会实现一种称为回合制多人游戏的两人模式,玩家可以轮流玩游戏。尽管如此,开发者早早就看到了共享体验的好处。甚至最早的游戏,比如《Spacewar!》(1962)和《PONG》(1972)也是让玩家互相对抗的。街机游戏的兴起推动了本地多人游戏,比如《Gauntlet》(1985)这样的游戏提供了最多四个玩家的合作游戏体验。

大多数本地多人游戏可以分为几类,回合制、共享单屏或分屏多人游戏。

回合制,顾名思义,是一种多人游戏模式,玩家轮流使用单个屏幕玩游戏。一个很好的回合制多人游戏的例子是原版的《超级马里奥兄弟》,适用于任天堂娱乐系统NES)。在这个游戏中,如果选择了双人模式,第一个玩家扮演马里奥角色;当玩家死亡时,第二个玩家轮到,扮演另一个兄弟路易吉。

共享单屏多人游戏是一种常见的本地多人游戏模式,每个玩家的角色都在同一个屏幕上。每个玩家同时控制他们的角色/化身。这种模式非常适合对战游戏,比如体育和格斗游戏,以及合作游戏,比如平台游戏和解谜游戏。这种模式今天仍然非常受欢迎,一个很好的例子就是最近发布的《杯头》游戏。

单屏多人游戏

分屏多人游戏是另一种流行的本地多人游戏模式,其中每个玩家在整个本地屏幕上都有自己的游戏视图。每个玩家同时控制自己的角色/化身。这种模式非常适合对战游戏,如射击游戏。尽管大多数选择实施分屏模式的游戏都是双人游戏,但有些游戏支持多达四名本地玩家,本地屏幕被垂直和水平分成四分之一。一个很好的实施分屏多人游戏的游戏是第一人称射击游戏《光环》。

局域网

随着个人电脑在 20 世纪 90 年代初的大量普及,将计算机连接在一起共享信息的想法很快成为大多数计算机用户的核心需求。连接多台计算机的早期方法之一是通过局域网(LAN)。LAN 允许有限区域内的计算机进行连接,例如大学、办公室、学校,甚至个人住所。除非在 LAN 所在的有限区域内,否则默认情况下无法连接 LAN。虽然商业计算世界已经采用了 LAN 计算的想法,但游戏行业真正开始使用这项技术进行多人游戏是在 1993 年发布《毁灭战士》时。

自从互联网被广泛采用以来,基于局域网的多人游戏的流行度已经下降。尽管如此,局域网仍然是当今电子竞技联赛等比赛中进行多人游戏的方式。基于局域网的多人游戏也催生了一种称为局域网聚会的现象。局域网聚会是玩家们聚集在同一物理位置,将所有计算机连接在一起以便彼此游玩的活动。这些活动通常持续多天,玩家们会跋涉长途前来参加。局域网聚会是 20 世纪 90 年代初至 90 年代末游戏界的一个重要组成部分,对于参与其中的任何玩家来说,这是一种与其他玩家联系的难忘方式。

在线多人游戏

互联网的普及带来了全球玩家以全新方式连接和游玩的能力。与旧时的局域网聚会不同,玩家现在可以在家中舒适的环境中与世界各地的玩家一起游玩和竞争。在线多人游戏的历史可以追溯到早期的例子,如MUD(多用户地下城),用户可以通过互联网玩简单的角色扮演游戏。在线多人游戏几乎涵盖了当今游戏的各种类型,从第一人称射击游戏到实时策略游戏。基于互联网的游戏还催生了一种称为大型多人在线(MMO)游戏的新类型游戏。在 MMO 中,大量玩家可以在单个实例或世界中连接和互动。迄今为止最受欢迎的 MMO 游戏之一是《魔兽世界》。

网络设计和协议开发

在设计和开发多人游戏时,两个最重要的考虑因素是决定要使用的网络拓扑和连接协议。每个选择对实施和游戏本身都有重大影响。在本章的下一部分中,我们将介绍不同的网络拓扑和使用的协议,并讨论它们的各种影响和考虑因素。

网络拓扑

简单来说,网络拓扑是网络上的计算机如何连接在一起的方式。对于在线游戏,网络拓扑将决定如何组织网络上的计算机,以允许用户接收游戏的更新。计算机如何组网将决定整体多人游戏设计的许多方面,每种拓扑类型都有其自身的优势和劣势。在接下来的部分中,我们将介绍游戏开发中使用的两种最流行的拓扑结构,即客户端/服务器和点对点模型。

点对点

在点对点网络中,每个玩家都与游戏实例中的每个其他玩家连接。

点对点网络通常采用非权威设计。这意味着没有单一实体控制游戏状态,因此每个玩家必须处理自己的游戏状态,并将任何本地更改通知给其他连接的玩家。这意味着由于这种拓扑结构,我们需要考虑一些问题。首先是带宽;正如你可能想象的那样,使用这种设计需要在玩家之间传递大量数据。事实上,连接的数量可以表示为一个二次函数,其中每个玩家将有 O(n-1)个连接,这意味着对于这种网络拓扑结构,总共将有 O(2n)个连接。这种网络设计也是对称的,这意味着每个玩家都必须具有相同的可用带宽,用于上传和下载流。我们需要考虑的另一个问题是权威的概念。

正如我在这里提到的,处理点对点网络中的权威的最常见方法是让所有玩家共享对网络上每个其他玩家的更新。由于以这种方式处理权威的结果是玩家同时看到两种情况发生,即玩家自己的输入立即更新游戏状态以及其他玩家移动的模拟。由于其他玩家的更新需要在网络上传播,因此更新不是即时的。当本地玩家收到更新时,比如说将对手移动到(x,y,z)的位置,对手在收到更新时仍然在那个位置的可能性很低,这就是为什么需要对其他玩家的更新进行模拟。模拟更新的最大问题是随着延迟的增加,模拟变得越来越不准确。我们将在本章的下一节讨论处理更新延迟和模拟的技术。

客户端/服务器

在客户端-服务器拓扑结构中,一个实例被指定为服务器,所有其他玩家实例都连接到它。每个玩家实例(客户端)只会与服务器通信。服务器反过来负责将玩家的所有更新通知给网络上连接的其他客户端。以下图片展示了这种网络拓扑结构:

虽然不是唯一的方法,但客户端-服务器网络通常实现了一种权威设计。这意味着,当玩家执行动作,比如将他们的角色移动到另一个地方时,这些信息以更新的形式发送到服务器。服务器会检查更新是否正确,如果是,服务器会将此更新信息传递给网络上连接的其他玩家。如果客户端和服务器在更新信息上发生分歧,服务器被认为是正确的版本。与点对点拓扑结构一样,在实施时需要考虑一些事情。在带宽方面,理论上,每个玩家的带宽要求不会随着连接的玩家数量而改变。如果我们将其表示为二次方程,给定 n 个玩家,连接的总数将是 O(2n)。然而,与点对点拓扑结构不同,客户端-服务器拓扑结构是不对称的,这意味着服务器只有 O(n)个连接,或者每个客户端一个连接。这意味着随着连接的玩家数量增加,支持连接所需的带宽将线性增加。也就是说,在实践中,随着更多玩家加入,需要模拟更多对象,这可能会导致客户端和服务器的带宽需求略微增加。

权威设计被认为比作弊更安全。这是因为服务器完全控制游戏状态和更新。如果从玩家传递了可疑的更新,服务器可以忽略它,并向其他客户端提供正确的更新信息。

理解协议

在深入实现多人游戏之前,了解事情是如何处理的非常重要。其中最重要的一个方面是数据如何在两台计算机之间交换。这就是协议的作用。尽管在网络上有许多不同的数据交换方式,但在本节中,我们将重点关注主机到主机层协议的传输控制协议/互联网协议(TCP/IP)模型。

TCP/IP 模型

TCP/IP 模型是一个协议套件的描述,它是一组旨在共同工作以将数据从一台计算机传输到另一台计算机的协议。它以两个主要协议(TCP 和 IP)命名。TCP/IP 被认为是当今的事实标准协议,并已取代了较旧的协议套件,如 IPX 和 SPX。TCP/IP 协议套件可以分解为以下图像中显示的 4 层模型:

大多数现代网络课程教授 7 层 OSI 模型。OSI 模型是一种理想化的网络模型,目前还没有实际实现。

这四层分别是应用层、传输层、网络层和数据链路层。应用层代表用户的数据并处理编码和对话控制。一个众所周知的应用层协议是超文本传输协议(HTTP),这是我们日常使用的网站的协议。传输层,也称为主机到主机层,支持各种设备和网络之间的低级通信,独立于所使用的硬件。我们将在下一节深入探讨这一层。网络层确定数据通过网络的最佳路径并处理寻址。这一层中最常见的协议是互联网协议(IP)。IP 有两个版本:IPv4 标准和 IPv6 标准。第四层也是最后一层是数据链路或网络访问层。数据链路层指定组成网络的硬件设备和媒体。常见的数据链路协议是以太网和 Wi-Fi。

现在我们对层有了一般的了解,让我们更仔细地看一下游戏开发中最常用的两个网络层协议:TCP 和 UDP。

UDP – 用户数据报协议

首先,让我们看看用户数据报协议(UDP)。UDP 是一个非常轻量级的协议,可用于从一台主机的指定端口传递数据到另一台主机的指定端口。一次发送的数据组称为数据报。数据报由 8 字节的头部和随后要传递的数据组成,称为有效载荷。UDP 头部如下表所示:

位# 0 16
0-31 源端口 目标端口
32-63 长度 校验和

UDP 头部

逐位分解:

  • 源端口:(16 位)这标识传递数据的端口的来源。

  • 目标端口:(16 位)这是传递数据的目标端口。

  • 长度:(16 位)这是 UDP 头部和数据有效载荷的总长度。

  • 校验和:(16 位,可选)这是根据 UDP 头部、有效载荷和 IP 头部的某些字段计算的校验和。默认情况下,此字段设置为全零。

因为 UDP 是一个如此简单的协议,它放弃了一些功能以保持轻量级。一个缺失的功能是两个主机之间的共享状态。这意味着不会有努力来确保数据报的完整传递。不能保证数据到达时会按正确的顺序,甚至是否会到达。这与我们将要看的下一个协议 TCP 协议非常不同。

TCP - 传输控制协议

与 UDP 不同,TCP 协议创建了两个主机之间的传输恒定连接,这允许可靠的数据流在两个主机之间来回传递。TCP 还试图确保所有发送的数据实际上都被接收并且按正确的顺序。随着这些附加功能的增加,也带来了一些额外的开销。TCP 连接的头部比 UDP 的要大得多。TCP 头部的表格格式如下所示:

TCP 头部

对于 TCP 连接,数据传输的一个单位称为一个段。一个段由 TCP 头部和在该单个段中传递的数据组成。

让我们逐位分解如下:

  • 源端口:(16 位)这标识了正在传递的数据的起始端口。

  • 目标端口:(16 位)这是正在传递的数据的目标端口。

  • 序列号:(32 位)这是一个唯一的标识号。由于 TCP 试图让接收方按照发送顺序接收数据,通过 TCP 传输的每个字节都会收到一个序列号。这些数字允许接收方和发送方通过遵循这些数字的顺序来确保顺序。

  • 确认号:(32 位)这是发送方正在传递的下一个数据字节的序列号。实质上,这充当了所有序列号低于此号码的数据的确认。

  • 数据偏移:(4 位)这指定了头部以 32 位字为单位的长度。如果需要,它允许添加自定义头部组件。

  • 控制位:(9 位)这保存了头部的元数据。

  • 接收窗口:(16 位)这传达了发送方用于传入数据的剩余缓冲空间的数量。在尝试维护流量控制时,这很重要。

  • 紧急指针:(16 位)这是该段中数据的第一个字节和紧急数据的第一个字节之间的增量值。这是可选的,只有在头部的元数据中设置了URG标志时才相关。

介绍套接字

在 OSI 模型中,有几种不同类型的套接字确定了传输层的结构。最常见的两种类型是流套接字和数据报套接字。在本节中,我们将简要介绍它们以及它们的区别。

流套接字

流套接字用于不同主机之间可靠的双向通信。您可以将流套接字视为类似于打电话。当一个主机呼叫时,另一个主机的连接被初始化;一旦连接建立,双方可以来回通信。连接像流一样是恒定的。

流套接字的使用示例可以在我们在本章前面讨论过的传输控制协议中看到。使用 TCP 允许数据以序列或数据包的形式发送。如前所述,TCP 维护状态并提供了一种确保数据到达并且顺序与发送时相同的方法。这对于许多类型的应用程序非常重要,包括 Web 服务器、邮件服务器和它们的客户端应用程序之间的通信。

在后面的部分,我们将看看如何使用传输控制协议实现自己的流套接字。

数据报套接字

与流套接字相反,数据报套接字更像是寄信而不是打电话。数据报套接字连接是单向的,是不可靠的连接。不可靠是指您无法确定数据报套接字数据何时甚至是否会到达接收方。无法保证数据到达的顺序。

如前一节所述,用户数据报协议使用数据报套接字。虽然 UDP 和数据报套接字更轻量级,但在只需要发送数据时,它们提供了一个很好的选择。在许多情况下,创建流套接字、建立然后维护套接字连接的开销可能过大。

数据报套接字和 UDP 通常用于网络游戏和流媒体。当客户端需要向服务器发出短查询并且希望接收单个响应时,UDP 通常是一个不错的选择。为了提供这种发送和接收服务,我们需要使用 UDP 特定的函数调用sendto()recvfrom(),而不是在套接字实现中看到的read()write()

创建一个简单的 TCP 服务器

在本节中,我们将看一下使用前面部分讨论的套接字技术实现一个简单的 TCP 服务器示例的过程。然后可以扩展此示例以支持各种游戏需求和功能。

由于为每个平台创建服务器的过程略有不同,我已将示例分成了两个不同的版本。

Windows

让我们首先看看如何在 Windows 平台上使用 WinSock 库创建一个简单的套接字服务器,该服务器将监听连接并在建立连接时打印一个简单的调试消息。有关完整实现,请查看代码存储库的Chapter10目录:

…
#include <stdio.h>
#include <windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>

#define PORT "44000" /* Port to listen on */

…

首先,我们有我们的包含文件。这使我们能够访问我们需要创建套接字的库(这对其他平台来说是不同的)。

…
 if ((iResult = WSAStartup(wVersion, &wsaData)) != 0) {
     printf("WSAStartup failed: %d\n", iResult);
     return 1;
 }

跳转到主方法,我们开始初始化底层库。在这种情况下,我们使用 WinSock 库。


 ZeroMemory(&hints, sizeof hints);
 hints.ai_family = AF_INET;
 hints.ai_socktype = SOCK_STREAM;
 if (getaddrinfo(NULL, PORT, &hints, &res) != 0) {
     perror("getaddrinfo");
     return 1;
 }

接下来,我们为套接字设置寻址信息。

 sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
 if (sock == INVALID_SOCKET) {
     perror("socket");
     WSACleanup();
     return 1;
 }

然后我们创建套接字,传入我们在寻址阶段创建的元素。

    /* Enable the socket to reuse the address */
    if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char *)&reuseaddr,
        sizeof(int)) == SOCKET_ERROR) {
        perror("setsockopt");
        WSACleanup();
        return 1;
    }

创建完套接字后,最好设置套接字以便在关闭或重置时能够重用我们定义的地址。

    if (bind(sock, res->ai_addr, res->ai_addrlen) == SOCKET_ERROR) {
        perror("bind");
        WSACleanup();
        return 1;
    }
    if (listen(sock, 1) == SOCKET_ERROR) {
        perror("listen");
        WSACleanup();
        return 1;
    }

现在我们可以绑定我们的地址,最后监听连接。

…
    while(1) {
        size_t size = sizeof(struct sockaddr);
        struct sockaddr_in their_addr;
        SOCKET newsock;
        ZeroMemory(&their_addr, sizeof (struct sockaddr));
        newsock = accept(sock, (struct sockaddr*)&their_addr, &size);
        if (newsock == INVALID_SOCKET) {
            perror("accept\n");
        }
        else {
            printf("Got a connection from %s on port %d\n",
                inet_ntoa(their_addr.sin_addr), ntohs(their_addr.sin_port));
 …
        }
    }

在我们的主循环中,我们检查新的连接,当接收到一个有效的连接时,我们会在控制台上打印一个简单的调试消息。

    /* Clean up */
    closesocket(sock);
    WSACleanup();
    return 0;
}

最后,我们必须清理自己。我们关闭套接字并调用WSACleanup函数来初始化清理 WinSock 库。

就是这样。现在我们有一个简单的服务器,它将在我们指定的端口44000上监听传入的连接。

macOS

对于 macOS(和其他*nix 系统),该过程与 Windows 示例非常相似,但是我们需要使用不同的库来帮助我们支持。

#include <stdio.h>
#include <string.h> /* memset() */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netdb.h>
#define PORT    "44000"
…

首先,我们有包含文件,在这里我们使用系统套接字,*nix 系统上基于 BSD 实现。

int main(void)
{
    int sock;
    struct addrinfo hints, *res;
    int reuseaddr = 1; /* True */
    /* Get the address info */
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    if (getaddrinfo(NULL, PORT, &hints, &res) != 0) {
        perror("getaddrinfo");
        return 1;
    }

在我们的主函数中,我们首先设置寻址信息。

    /* Create the socket */
    sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (sock == -1) {
        perror("socket");
        return 1;
    }

然后我们创建套接字,传入我们在寻址阶段创建的元素。

    /* Enable the socket to reuse the address */
    if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuseaddr, sizeof(int)) == -1) {
        perror("setsockopt");
        return 1;
    }

创建完套接字后,最好设置套接字以便在关闭或重置时能够重用我们定义的地址。

    if (bind(sock, res->ai_addr, res->ai_addrlen) == -1) {
        perror("bind");
        return 1;
    }

    if (listen(sock, 1) == -1) {
        perror("listen");
        return 1;
    }

现在我们可以绑定我们的地址,最后监听连接。

    while (1) {
        socklen_t size = sizeof(struct sockaddr_in);
        struct sockaddr_in their_addr;
        int newsock = accept(sock, (struct sockaddr*)&their_addr, &size);
        if (newsock == -1) {
            perror("accept");
        }
        else {
            printf("Got a connection from %s on port %d\n",
                    inet_ntoa(their_addr.sin_addr), htons(their_addr.sin_port));
            handle(newsock);
        }
    }

在我们的主循环中,我们检查新的连接,当接收到一个有效的连接时,我们会在控制台上打印一个简单的调试消息。

    close(sock);
    return 0;
}

最后,我们必须清理自己。在这种情况下,我们只需要关闭套接字。

就是这样。现在我们有一个简单的服务器,它将在我们指定的端口44000上监听传入的连接。

为了测试我们的示例,我们可以使用现有的程序,比如putty来连接到我们的服务器。或者我们可以创建一个简单的客户端,这个项目就留给你来完成。虽然只是一个简单的服务器,但这为构建你自己的实现提供了一个起点。

总结

在本章中,我们迈出了重要的一步,以了解多人游戏是如何在更低的层次上实现的。你学习了关于 TCP/IP 协议栈和不同的网络拓扑在游戏开发中的使用。我们研究了使用 UDP 和 TCP 协议来在客户端-服务器设置中传递数据。最后,我们看了一些开发者在开始实现多人游戏功能时面临的问题。在下一章中,我们将看看如何将我们的游戏带入一个新的领域——虚拟现实。

第十一章:虚拟现实

虚拟现实VR)是当今游戏开发中非常热门的话题。在本章中,我们将看看如何利用 C++的强大功能来创建沉浸式的 VR 体验。需要注意的是,虽然用于示例集成的 SDK 可用于 macOS,但本章中介绍的硬件和示例代码尚未在 macOS 上进行测试,也不能保证支持。还需要注意的是,您需要一台 VR 头戴式显示器和一台性能强大的 PC 和显卡来运行本章的示例。建议您拥有与英特尔 i5-4590 或 AMD FX 8350 相匹配或超过的 CPU,以及与 NVIDIA GeForce GTX 960 或 AMD Radeon R9 290 相匹配或超过的 GPU。在本章中,我们将涵盖以下主题:

  • 当前 VR 硬件

  • VR 渲染概念

  • 头戴式显示器 SDK

  • 实施 VR 支持

快速 VR 概述

VR 是一种计算机技术,利用各种形式的硬件通过逼真的图像、声音和其他感觉来生成用户在重建或虚构环境中的物理存在的模拟。处于 VR 环境中的用户能够环顾周围的人工世界,并且随着 VR 技术的新进展,还能在其中移动并与虚拟物品或对象进行交互。虽然 VR 技术可以追溯到 20 世纪 50 年代,但随着计算机图形、处理和性能的最新进展,VR 技术已经出现了复苏。著名的科技巨头,如 Facebook、索尼、谷歌和微软,都在虚拟和增强现实技术上进行了大笔投资。自鼠标发明以来,用户与计算机的交互方式从未有过如此大的创新潜力。VR 的用例不仅限于游戏开发。许多其他领域都希望利用 VR 技术来扩展他们自己独特的交互方式。医疗保健、教育、培训、工程、社会科学、营销,当然还有电影和娱乐,都为具有本书和游戏开发中学到的技能集的开发人员提供了有前途的机会。我经常建议寻求改变步调或新挑战的游戏开发人员,将目光投向新兴的 VR 开发领域,作为他们知识和技能基础的替代用途。

当前 VR 硬件

作为开发人员,我们正处于 VR 硬件开发的非常幸运的时期。在 VR 硬件方面有许多不同的选择,包括投影系统,如CAVE头戴式显示器HMDs),甚至基于手机的系统,如 Google Daydream 和 Cardboard。在这里,我们将重点关注沉浸式 PC 和主机驱动的 HMDs。这些 HMD 背后的大部分技术都非常相似。这里列出的每个 HMD 在运动方面至少有六个自由度6DOF),即在 3D 空间中的头部跟踪,并且一些甚至具有基本的空间意识,通常称为房间感知。对于这些头戴式显示器的开发,从高层次上来说,可以以类似的方式进行,但了解每个不同设备的基本情况是很有必要的。接下来,我们将快速浏览目前消费者可以获得的一些最常见的头戴式显示器。

Oculus Rift CV1

最初作为众筹项目开始,Oculus Rift 已成为目前最受欢迎的头戴式显示器之一。Oculus Rift 已经推出了几个版本。最初的两个硬件发布是面向开发人员的(DK1 和 DK2)。在 Facebook 收购 Oculus 后,这家社交媒体巨头发布了硬件的第一个商用版本,称为消费者版本 1CV1)。虽然在Steam游戏平台上受到支持,但 Oculus 与自己的启动器和软件平台紧密相连。该头戴式显示器目前仅支持 PC 开发:

以下是 Oculus Rift CV1 的特点:

  • 屏幕类型:AMOLED

  • 分辨率:每只眼睛 1080 x 1200

  • 视野:~110⁰

  • 头部跟踪:IMU(指南针、加速计、陀螺仪),红外光学跟踪

最低推荐的 PC 规格如下:

  • GPU:NVIDIA GeForce GTX 970 或 AMD Radeon R9 290

  • CPU:Intel i5-4590 或 AMD FX 8350

  • RAM:8 GB

  • 操作系统:Windows 7

HTC Vive

可以说是目前最受欢迎的头戴式显示器,HTC Vive 是由 HTC(一家智能手机和平板电脑制造商)和 Valve 公司(一家以 Steam 游戏平台闻名的游戏公司)共同创建的。与 Oculus Rift 直接比较,HTC Vive 在设计上有许多相似之处,但在许多开发人员看来,略有不同之处使 HTC Vive 成为更优秀的硬件:

以下是 HTC Vive 的特点:

  • 屏幕类型:AMOLED

  • 分辨率:每只眼睛 1080 x 1200

  • 视野:110⁰

  • 头部跟踪:IMU(指南针、加速计、陀螺仪),2 个红外基站

最低推荐的 PC 规格如下:

  • GPU:NVIDIA GeForce GTX 970 或 AMD Radeon R9 290

  • CPU:Intel i5-4590 或 AMD FX 8350

  • RAM:4 GB

  • 操作系统:Windows 7,Linux

开源虚拟现实(OSVR)开发套件

另一个非常有趣的硬件选择是由雷蛇和 Sensics 开发的 OSVR 套件。 OSVR 的独特之处在于它是一个开放许可、非专有硬件平台和生态系统。这使得开发人员在设计其 AR/VR 体验时有很大的自由度。OSVR 也是一个软件框架,我们很快会介绍。该框架与硬件一样,是开放许可的,旨在跨平台设计:

以下是 OSVR 的特点:

  • 屏幕类型:AMOLED

  • 分辨率:每只眼睛 960 x 1080

  • 视野:100⁰

  • 头部跟踪:IMU(指南针、加速计、陀螺仪),红外光学跟踪

最低推荐的 PC 规格如下:

  • GPU:NVIDIA GeForce GTX 970 或 AMD Radeon R9 290

  • CPU:Intel i5-4590 或 AMD FX 8350

  • RAM:4 GB

  • 操作系统:跨平台支持

索尼 PlayStation VR

最初被称为Project Morpheus,索尼 PlayStation VR 是索尼公司进入 VR 领域的产品。与此列表中的其他头戴式显示器不同,索尼 PlayStation VR 头戴式显示器不是由 PC 驱动,而是连接到索尼 PlayStation 4 游戏主机。通过使用 PS4 作为其平台,索尼 PlayStation VR 头戴式显示器有 3000 多万的游戏主机用户:

以下是索尼 PlayStation VR 的特点:

  • 屏幕类型:AMOLED

  • 分辨率:每只眼睛 960 x 1080

  • 视野:~100⁰

  • 头部跟踪:IMU(指南针、加速计、陀螺仪),红外光学跟踪

  • 控制台硬件:索尼 PlayStation 4

Windows Mixed Reality 头戴式显示器

最新进入 VR 硬件领域的是 Windows Mixed Reality 启用的一组头戴式显示器。虽然不是单一的头戴式显示器设计,但 Windows Mixed Reality 具有一套规格和软件支持,可以从 Windows 10 桌面实现 VR。被称为混合现实MR),这些头戴式显示器的独特功能是其内置的空间感知或房间感知。其他头戴式显示器,如 Oculus Rift 和 HTC Vive,支持类似的功能,但与 Windows MR 设备不同,它们需要额外的硬件来支持跟踪。这种缺乏额外硬件意味着 Windows MR 头戴式显示器应该更容易设置,并有可能使 PC 供电的 VR 体验更加便携:

以下是 Windows MR 头戴式显示器的特点:

  • 屏幕类型:各种

  • 分辨率:各种

  • 视野:各种

  • 头部跟踪:基于头戴式内部 9DoF 追踪系统

最低推荐的 PC 规格如下:

  • GPU:NVIDIA GeForce GTX 960,AMD Radeon RX 460 或集成的 Intel HD Graphics 620

  • CPU:Intel i5-4590 或 AMD FX 8350

  • 内存:8 GB

  • 操作系统:Windows 10

VR 渲染概念

从渲染的角度来看 VR,很快就会发现 VR 提出了一些独特的挑战。这部分是由于需要达到一些必要的性能基准和当前渲染硬件的限制。在渲染 VR 内容时,需要以比标准高清更高的分辨率进行渲染,通常是两倍或更多。渲染还需要非常快速,每只眼睛的帧率达到 90 帧或更高是基准。这,再加上抗锯齿和采样技术的使用,意味着渲染 VR 场景需要比以 1080p 分辨率以 60 帧每秒运行的标准游戏多五倍的计算能力。在接下来的章节中,我们将介绍在渲染 VR 内容时的一些关键区别,并涉及一些你可以实施以保持性能的概念。

使用视锥体

在开发 VR 就绪引擎时最大的区别在于理解如何在处理多个视点时构建适当的、裁剪的视锥体。在典型的非 VR 游戏中,你有一个单一的视点(摄像头),从中创建一个视锥体。如果需要完整的复习,请参考本书早些时候的内容,但这个视锥体决定了将被渲染并最终显示在屏幕上给用户的内容。以下是一个典型视锥体的图示:

在 VR 渲染时,每只眼睛至少有一个视锥体,通常显示在单个头戴式显示器上,意味着在单个屏幕上显示一对图像,从而产生深度的错觉。通常这些图像描绘了场景的左眼和右眼视图。这意味着我们必须考虑两只眼睛的位置,并通过结合它们来产生最终的渲染视锥体。以下是这些视锥体的图示:

当创建一个结合了左右眼视锥体的单个视锥体时,实际上是非常容易的。如下图所示,你需要将新视锥体的顶点放在两只眼睛之间并略微向后移动。然后移动近裁剪平面的位置,使其与任一眼睛视锥体的裁剪平面对齐。这对于最终的显示视锥体剔除是很重要的。

你可以使用一些简单的数学计算来计算这个视锥体,使用瞳距IPD)来演示,正如 Oculus Rift 团队的 Cass Everitt 在以下图示中所展示的:

我们也可以通过简单地对共享眼睛视锥体的顶部和底部平面进行剔除来简化这个过程。虽然在技术上并不形成完美的视锥体,但使用一个测试单个平面的剔除算法将产生期望的效果。

好消息是大部分可以被抽象化,而且在许多头戴式显示器 SDK 中都有方法来帮助你。然而,重要的是要理解在非 VR 场景渲染中,视锥体的使用方式与 VR 中的不同。

提高渲染性能

当使用单个摄像头和视点时,就像大多数非 VR 游戏一样,我们可以简单地将渲染过程视为引擎内的一个步骤。但是当使用多个视点时,情况就不同了。当然,我们可以将每个视点视为一个单独的渲染任务,依次处理,但这会导致渲染速度慢。

如前一节所示,每只眼睛看到的内容有很大的重叠。这为我们提供了通过共享和重用数据来优化我们的渲染过程的绝佳机会。为此,我们可以实现数据上下文的概念。使用这个概念,我们可以对哪些元素是唯一适用于单只眼睛进行分类,哪些元素可以共享进行分类。让我们看看这些数据上下文以及如何使用它们来加快我们的渲染速度:

  • 帧上下文:简而言之,帧上下文用于任何需要被渲染且与视角无关的元素。这将包括诸如天空盒、全局反射、水纹理等元素。任何可以在视点之间共享的东西都可以放在这个上下文中。

  • 眼睛上下文:这是不能在视点之间共享的元素的上下文。这将包括在渲染时需要立体视差的任何元素。我们还可以在这个上下文中存储每只眼睛的数据,这些数据将在我们的着色器计算中使用。

通过将数据简单地分成不同的上下文,我们可以重新组织我们的渲染过程,使其看起来类似于以下内容:

RenderScene(Frame f)
{
  ProcessFrame(f); //Handle any needed globally shared calculations
  RenderFrame(f); //Render any globally shared elements
  for(int i=0; i<numview points; i++) //numview points would be 2 for                            stereo
    {
      ProcessEye(i, f); //Handle any per eye needed calculations
      RenderEye(i, f); //Render any per eye elements
    }
}

虽然这在表面上看起来很基本,但这是一个非常强大的概念。通过以这种方式分离渲染并共享我们可以的内容,我们大大提高了整体渲染器的性能。这是最简单的优化之一,但回报却很大。我们还可以将这种方法应用到如何设置我们的着色器统一变量上,将它们分成上下文片段:

layout (binding = 0) uniform FrameContext
{
  Mat4x4 location; //modelview
  Mat4x4 projection;
  Vec3 viewerPosition;
  Vec3 position;
}frame;
layout (binding = 1) uniform EyeContext
{
  Mat4x4 location; //modelview
  Mat4x4 projection;
  Vec3 position;
}eye;

从概念上讲,数据的这种分割非常有效,每个数据片段都可以在不同的时间更新,从而提供更好的性能。

这基本上描述了以高层次处理多个视点的 VR 渲染的高效方法。如前所述,在开发中,与硬件和管道连接相关的大部分设置都在我们开发的 SDK 中被抽象掉了。在下一节中,我们将看一些这些 SDK,并通过查看在我们的示例引擎中实现 SDK 来结束本章。

头显 SDK

有许多 SDK 可用于实现各种头显和支持硬件,大多数制造商以某种形式提供自己的 SDK。在接下来的章节中,我们将快速查看开发 PC 驱动 HMD VR 体验时最常用的三个 SDK:

  • Oculus PC SDK(developer.oculus.com/downloads/package/oculus-sdk-for-windows/):此 SDK 专为在 C++中开发 Oculus Rift HMD 体验和游戏而创建。核心 SDK 提供了开发人员访问渲染、跟踪、输入和其他核心硬件功能所需的一切。核心 SDK 由其他支持音频、平台和头像的 SDK 支持。

  • OpenVRgithub.com/ValveSoftware/openvr):这是由 Valve 公司提供的默认 API 和 SteamVR 平台的运行时的 SDK。这也是 HTC Vive HMD 开发的默认 SDK,但设计为支持多个供应商。这意味着您可以在不知道连接了哪个 HMD 的情况下,针对多个 HMD 进行开发。这将是我们在示例引擎中实现的 SDK。

  • OSVRosvr.github.io/):OSVR SDK,正如其名称所示,是一个设计用于与多个硬件供应商配合使用的开源 SDK。这个 SDK 是同名头显 OSVR 的默认 SDK。该项目由雷蛇和 Sensics 领导,许多大型游戏合作伙伴也加入了。OSVR SDK 可用于 Microsoft Windows、Linux、Android 和 macOS。

实现 VR 支持

与本书中讨论过的许多其他系统一样,从头开始实现 VR 支持可能是一个非常具有挑战性和耗时的过程。然而,就像其他系统一样,存在着可以帮助简化和简化过程的库和 SDK。在下一节中,我们将介绍如何使用 Valve 公司提供的 OpenVR SDK 向我们的示例引擎添加 VR 渲染支持。我们将只完整地介绍主要要点。要查看每种方法的更完整概述,请参考示例代码中的注释,并访问 OpenVR SDK Wiki 获取更多 SDK 特定信息(github.com/ValveSoftware/openvr/wiki)。

验证 HMD

首先,我们需要做一些事情来设置我们的硬件和环境。我们需要首先测试一下计算机上是否连接了头显。然后我们需要检查 OpenVR 运行时是否已安装。然后我们可以初始化硬件,最后询问一些关于其功能的问题。为此,我们将向我们的GameplayScreen类添加一些代码;为简洁起见,我们将跳过一些部分。完整的代码可以在代码存储库的Chapter11文件夹中的示例项目中找到。

让我们首先检查一下计算机是否连接了 VR 头显,以及 OpenVR(SteamVR)运行时是否已安装。为此,我们将在Build()方法中添加以下内容:

void GameplayScreen::Build()
{
  if (!vr::VR_IsHmdPresent())
   {
      throw BookEngine::Exception("No HMD attached to the system");
   }
  if (!vr::VR_IsRuntimeInstalled())
   {
      throw BookEngine::Exception("OpenVR Runtime not found");
   }
}

在这里,如果这些检查中的任何一个失败,我们会抛出一个异常来处理和记录。现在我们知道我们有一些硬件和所需的软件,我们可以初始化框架。为此,我们调用InitVR函数:

InitVR();

InitVR函数的主要目的是依次调用 OpenVR SDK 的VR_Init方法。为了做到这一点,它需要首先创建和设置一个错误处理程序。它还需要我们定义这将是什么类型的应用程序。在我们的情况下,我们声明这将是一个场景应用程序,vr::VRApplication_Scene。这意味着我们正在创建一个将绘制环境的 3D 应用程序。还有其他选项,比如创建实用程序或仅覆盖应用程序。最后,一旦 HMD 初始化完成,没有错误,我们要求头显告诉我们一些关于它自身的信息。我们使用GetTrackedDeviceString方法来做到这一点,我们很快就会看到。整个InitVR方法看起来像下面这样:

void GameplayScreen::InitVR()
{
   vr::EVRInitError err = vr::VRInitError_None;
   m_hmd = vr::VR_Init(&err, vr::VRApplication_Scene);
   if (err != vr::VRInitError_None)
   {
     HandleVRError(err);
   }
   std::cout << GetTrackedDeviceString(m_hmd,
   vr::k_unTrackedDeviceIndex_Hmd,vr::Prop_TrackingSystemName_String)
   << std::endl;
   std::clog << GetTrackedDeviceString(m_hmd,                  vr::k_unTrackedDeviceIndex_Hmd, vr::Prop_SerialNumber_String)<<        std::endl;
}

HandleVRError方法只是一个简单的辅助方法,它接受传入的错误并抛出一个要处理和记录的错误,同时提供错误的英文翻译。以下是该方法的全部内容:

void GameplayScreen::HandleVRError(vr::EVRInitError err)
{
  throw BookEngine::Exception(vr::VR_GetVRInitErrorAsEnglishDescription(err));
}

InitVR函数调用的另一个方法是GetTrackedDeviceString函数。这是 OpenVR 示例代码的一部分,允许我们返回有关附加设备的一些信息的函数。在我们的情况下,我们要求返回附加设备的系统名称和序列号属性(如果可用):

std::string GameplayScreen::GetTrackedDeviceString(vr::IVRSystem * pHmd, vr::TrackedDeviceIndex_t unDevice, vr::TrackedDeviceProperty prop, vr::TrackedPropertyError * peError)
{
  uint32_t unRequiredBufferLen = pHmd-                  >GetStringTrackedDeviceProperty(unDevice, prop, NULL, 0, peError);
    if (unRequiredBufferLen == 0)
      return "";

   char *pchBuffer = new char[unRequiredBufferLen];
   unRequiredBufferLen = pHmd->GetStringTrackedDeviceProperty(unDevice,   prop, pchBuffer, unRequiredBufferLen, peError);
   std::string sResult = pchBuffer;
   delete[] pchBuffer;
   return sResult;
}

最后,在我们的Build方法中,现在我们已经完成了初始化步骤,我们可以通过询问系统VRCompositor函数是否设置为 NULL 以外的值来检查一切是否顺利。如果是,那意味着一切准备就绪,我们可以询问我们的 HMD 希望我们的渲染目标大小是多少,并在控制台窗口中显示为字符串输出:

if (!vr::VRCompositor())
 {
   throw BookEngine::Exception("Unable to initialize VR compositor!\n ");
 }
m_hmd->GetRecommendedRenderTargetSize(&m_VRWidth, &m_VRHeight);

std::cout << "Initialized HMD with suggested render target size : " << m_VRWidth << "x" << m_VRHeight << std::endl;
}

我们需要做的最后一件事是确保在程序完成时进行清理。在GamplayScreenDestroy方法中,我们首先检查 HMD 是否已初始化;如果是,我们调用VR_Shutdown方法并将m_hmd变量设置为 NULL。在应用程序关闭时调用VR_Shutdown非常重要,因为如果不这样做,OpenVR/SteamVR 可能会挂起,并且可能需要重新启动才能再次运行:

void GameplayScreen::Destroy()
{
   if (m_hmd)
    {
       vr::VR_Shutdown();
       m_hmd = NULL;
    }
}

现在,如果我们继续运行这个示例,在控制台窗口中,您应该看到类似以下的内容:

渲染

现在我们已经设置好了 HMD 并与我们的引擎进行了通信,下一步是对其进行渲染。实际上,这个过程并不复杂;如前所述,SDK 已经为我们处理了很多事情。为了尽可能简单,这个示例只是一个简单的渲染示例。我们不处理头部跟踪或输入,我们只是简单地在每只眼睛中显示不同的颜色。与之前的示例一样,为了节省时间和空间,我们只会涵盖重要的元素,让您掌握概念。完整的代码可以在代码库的Chapter11文件夹中的示例项目中找到。

正如我们之前讨论的那样,在立体视觉渲染时,通常会渲染一个被分成两半的单个显示器。然后我们将适当的数据传递给每半部分,取决于在该眼睛中可见的内容。回顾一下使用视锥部分,了解为什么会这样。这归结为我们需要为每只眼睛创建一个帧缓冲。为此,我们有一个RenderTarget类,它创建帧缓冲,附加纹理,最后创建所需的视口(即总显示宽度的一半)。为了节省空间,我不会打印出RenderTarget类;它非常简单,我们以前已经见过。相反,让我们继续设置和实际处理在 HMD 中显示场景的函数。首先,我们需要将我们的RenderTarget连接到我们的纹理,并且为了正确实现清除和设置缓冲区。为此,我们将以下内容添加到GameplayScreenOnEntry方法中:

BasicRenderTarget leftRT(1, vrApp.rtWidth, vrApp.rtHeight);
BasicRenderTarget rightRT(1, vrApp.rtWidth, vrApp.rtHeight);

leftRT.Init(leftEyeTexture.name);
rightRT.Init(rightEyeTexture.name);

glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
leftRT.fbo.Bind(GL_FRAMEBUFFER);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
  {
    throw std::runtime_error("left rt incomplete");
  }
glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
rightRT.fbo.Bind(GL_FRAMEBUFFER);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
  {
    throw std::runtime_error("right rt incomplete");
  }
glBindFramebuffer(GL_FRAMEBUFFER, 0);

glClearColor (0.0f, 0.0f, 1.0f, 1.0f);

我不会逐行讲解之前的代码,因为我们以前已经看过了。现在,我们的缓冲区和纹理已经设置好,我们可以继续添加绘图调用了。

OpenVR SDK 提供了处理显示 VR 场景复杂部分所需的方法。大部分这些复杂工作是由合成器系统完成的。正如 Valve 所说的那样,“合成器通过处理失真、预测、同步和其他细微问题,简化了向用户显示图像的过程,这些问题可能对于获得良好的 VR 体验而言是一个挑战。”

为了连接到合成器子系统,我们创建了一个名为SubmitFrames的简单方法。这个方法接受三个参数——每只眼睛的纹理和一个布尔值,用于指定颜色空间是否应该是线性。在撰写本文时,我们总是希望指定颜色空间应该是Gamma,适用于OpenGL。在方法内部,我们获取希望渲染到的设备,设置颜色空间,转换纹理,然后将这些纹理提交给VRCompositor,然后在幕后处理将纹理显示到正确的眼睛上。整个方法看起来像下面这样:

void GameplayScreen::SubmitFrames(GLint leftEyeTex, GLint rightEyeTex, bool linear = false)
{
 if (!m_hmd)
  {
    throw std::runtime_error("Error : presenting frames when VR system handle is NULL");
  }
  vr::TrackedDevicePose_t trackedDevicePose[vr::k_unMaxTrackedDeviceCount];
  vr::VRCompositor()->WaitGetPoses(trackedDevicePose,        vr::k_unMaxTrackedDeviceCount, nullptr, 0);

  vr::EColorSpace colorSpace = linear ? vr::ColorSpace_Linear :    vr::ColorSpace_Gamma;

  vr::Texture_t leftEyeTexture = { (void*)leftEyeTex,    vr::TextureType_OpenGL, colorSpace };
  vr::Texture_t rightEyeTexture = { (void*)rightEyeTex,   vr::TextureType_OpenGL, colorSpace };

  vr::VRCompositor()->Submit(vr::Eye_Left, &leftEyeTexture);
  vr::VRCompositor()->Submit(vr::Eye_Right, &rightEyeTexture);

  vr::VRCompositor()->PostPresentHandoff();
}

有了我们的SubmitFrames函数,我们可以在GameplayScreen更新中调用该方法,就在glClear函数调用之后:

…
glClear(GL_COLOR_BUFFER_BIT);
SubmitFrames(leftEyeTexture.id, rightEyeTexture.id);

如果您现在运行示例项目,并且已经安装了必要的 SteamVR 框架,您应该会看到头戴显示器的每只眼睛显示不同的颜色。

总结

虽然这只是对 VR 开发世界的快速介绍,但它应该为您的体验创意提供了一个很好的测试基础。我们学习了如何处理多个视图截头体,了解了各种硬件选项,最后看了一下我们如何使用 OpenVR SDK 为我们的示例引擎添加 VR 支持。随着硬件的进步,VR 将继续获得动力,并将继续向新领域推进。全面了解 VR 渲染的工作原理将为您的开发知识储备提供新的深度水平。

posted @ 2024-05-05 00:04  绝不原创的飞龙  阅读(1129)  评论(0编辑  收藏  举报