C---游戏开发示例-全-

C++ 游戏开发示例(全)

原文:zh.annas-archive.org/md5/262fd2303f01348f0fc754084f6f20af

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

计算机图形编程被认为是难度最大的主题之一,因为它涉及到复杂的数学、编程和图形概念,这些对普通开发者来说可能令人望而生畏。此外,随着可用的替代游戏引擎,如 Unity 和 Unreal,了解图形编程变得很重要,因为使用这些更复杂的游戏引擎制作 2D 或 3D 游戏要容易得多。这些引擎也使用一些渲染 API,如 OpenGL、Vulkan、Direct3D 和 Metal,来绘制场景中的对象,而游戏引擎中的图形引擎构成了其超过 50%的部分。因此,了解图形编程和图形 API 是至关重要的。

本书的目标是将这个复杂主题分解成小块,使其易于理解。因此,我们将从理解数学、编程和图形基础知识所需的基本概念开始。

在本书的下一部分,我们将创建一个 2D 游戏,最初使用简单快速多媒体库SFML),它涵盖了创建任何游戏所需的基本知识,并且你可以用它非常容易地制作任何游戏,无需担心游戏对象是如何绘制的。我们将使用 SFML 仅用于绘制我们的游戏对象。

在本书的下一部分,我们将了解游戏对象是如何使用 OpenGL 在屏幕上呈现的。OpenGL 是一个高级图形 API,它使我们能够快速将内容渲染到场景中。在 SFML 中创建的简单精灵在实际上绘制到屏幕之前要经过很多步骤。我们将看到一张简单的图像是如何被加载并在屏幕上显示的,以及完成这一过程所需的步骤。但这只是开始。我们将看到如何向游戏添加 3D 物理效果,并从头开始开发基于物理的游戏。最后,我们将添加一些光照,使场景更加有趣。

带着对 OpenGL 的知识,我们将进一步深入图形编程,看看 Vulkan 是如何工作的。Vulkan 是 OpenGL 的后继者,是一个低级、冗长的图形 API。OpenGL 是一个高级图形 API,它隐藏了很多内部工作原理。使用 Vulkan,你可以完全访问 GPU,并且通过 Vulkan 图形 API,我们将学习如何渲染我们的游戏对象。

本书面向的对象

本书的目标是针对那些渴望使用 C++和 OpenGL 或 Vulkan 图形 API 学习游戏开发的开发者。本书也适用于那些希望更新他们现有知识的人。假设读者对 C++编程有一些先前的了解。

本书涵盖的内容

第一章C++概念,涵盖了 C++编程的基础知识,这是理解并编写本书章节所必需的。

第二章,数学和图形概念,本章涵盖了数学的基本主题,如向量计算和矩阵知识。这些对于图形编程和基础物理编程至关重要。然后,我们将继续介绍图形编程的基础,从如何将多个顶点发送到图形管线以及它们如何被转换为形状并在屏幕上渲染开始。

第三章,设置你的游戏,介绍了 SFML 框架,它的用途和限制。它还涵盖了创建 Visual Studio 项目并将 SFML 添加到其中的方法,创建一个具有游戏基本框架的基本窗口以初始化、更新、渲染和关闭它。我们还将学习如何绘制不同的形状,以及如何向场景添加纹理精灵和键盘输入*。

第四章,创建你的游戏,介绍了创建角色类并为角色添加功能以使其移动和跳跃。我们还将创建敌人类以填充游戏中的敌人。我们将添加火箭类,以便玩家在射击时可以生成火箭。最后,我们将添加碰撞检测以检测两个精灵之间的碰撞。

第五章,完成你的游戏,介绍了完成游戏并添加评分、文本和音频等润色细节。我们还将为玩家角色添加一些动画,使游戏更加生动。

第六章,开始使用 OpenGL,探讨了 OpenGL 是什么,它的优点和缺点。我们将 OpenGL 集成到 Visual Studio 项目中,并使用 GLFW 创建一个窗口。我们将创建一个基本的游戏循环,并使用顶点和片段着色器渲染我们的第一个 3D 对象。

第七章,基于游戏对象构建,涵盖了向对象添加纹理。我们将把 Bullet 物理库包含到项目中以添加场景的物理效果。我们将了解如何将物理效果与我们的 3D OpenGL 渲染对象集成。最后,我们将创建一个基本关卡来测试物理效果。

第八章,通过碰撞、循环和光照增强你的游戏,涵盖了添加游戏结束条件和完成游戏循环。我们将通过添加一些基本的 3D 光照和文本来显示得分和游戏结束条件,为游戏添加一些润色细节。

第九章,从 Vulkan 入门,探讨了 Vulkan 的需求以及它与 OpenGL 的不同之处。我们将探讨 Vulkan 的优缺点,将其集成到 Visual Studio 项目中,并添加 GLFW 以创建项目窗口。我们将创建一个应用程序和一个 Vulkan 实例,并添加验证层以检查 Vulkan 是否按需运行。我们还将获取物理设备属性并创建逻辑设备。

第十章,准备清晰屏幕,介绍了创建一个可以渲染场景的窗口表面。我们还需要创建一个交换链,以便在前后缓冲区之间进行 ping-pong 操作,并创建图像视图和帧缓冲区,这些视图将附加到它们上。我们将创建绘制命令缓冲区以记录和提交图形命令,并创建一个渲染通道以清除屏幕。

第十一章,创建对象资源,介绍了绘制几何形状所需资源的创建。这包括添加一个包含所有几何信息(包括顶点和索引数据)的网格类。我们将创建对象缓冲区以存储顶点、索引和统一缓冲区。我们还将创建描述符集布局和描述符集,最后将创建着色器并将它们转换为 SPIR-V 二进制格式。

第十二章,绘制 Vulkan 对象,介绍了创建图形管道,其中我们设置顶点并启用视口、多采样、深度和模板测试。我们还将创建一个对象类,这将有助于创建对象缓冲区、描述符集和对象的图形管道。我们将创建一个摄像机类来通过它观察世界,然后最终渲染对象。在章节末尾,我们还将看到如何同步发送的信息。

要充分利用本书

本书设计为从头到尾,逐章阅读。如果您对某章的内容已有先验知识,那么请随意跳过。

有一些 C++编程的先验编程经验是好的,但如果没有,本书中有一章介绍 C++编程,涵盖了基础知识。假设没有图形编程的先验知识。

要运行 OpenGL 和 Vulkan 项目,请确保您的硬件支持当前版本的 API。本书使用 OpenGL 4.5 和 Vulkan 1.1。大多数 GPU 供应商支持 OpenGL 和 Vulkan,但有关支持的 GPU 完整列表,请参阅 GPU 制造商或维基百科,在en.wikipedia.org/wiki/Vulkan_(API)

下载示例代码文件

您可以从www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”标签。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/CPP-Game-Development-By-Example。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,这些代码包可在github.com/PacktPublishing/找到。去看看吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:

www.packtpub.com/sites/default/files/downloads/9781789535303_ColorImages.pdf.

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在这里,将Hello, World的打印任务分配给main函数。”

代码块设置如下:

#include <iostream>
// Program prints out "Hello, World" to screen
int main()
{
std::cout<< "Hello, World."<<std::endl;
return 0;
} 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

int main() {
    //init game objects
        while (window.isOpen()) {
            // Handle Keyboard events
            // Update Game Objects in the scene
    window.clear(sf::Color::Red);
    // Render Game Objects
    window.display();
        }
        return 0;
    }

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“在输入和链接器下,输入以下.lib文件。”

警告或重要注意事项显示如下。

技巧和窍门显示如下。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送电子邮件。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评价

请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

想了解更多关于 Packt 的信息,请访问 packt.com

第一部分:基本概念

本节涵盖了 C++ 游戏开发的一些基本概念。为了准备好书中后续章节,我们需要对数学、编程和计算机图形学有一个良好的理解。

以下章节包含在本节中:

第一章,C++ 概念

第二章,数学和图形概念

第一章:C++ 概念

在本章中,我们将探讨编写 C++ 程序的基础。在这里,我们将涵盖足够的内容,以便我们能够理解 C++ 编程语言的能力。这将有助于理解本书中使用的代码。

要运行示例,请使用 Visual Studio 2017。您可以在 visualstudio.microsoft.com/vs/ 免费下载社区版:

图片

本章涵盖的主题如下:

  • 程序基础

  • 变量

  • 运算符

  • 语句

  • 迭代

  • 函数

  • 数组和指针

  • StructEnum

  • 类和继承

程序基础

C++ 是一种编程语言,但究竟什么是程序?程序是一系列按顺序执行的指令,以产生期望的输出。

让我们看看我们的第一个程序:

#include <iostream> 
// Program prints out "Hello, World" to screen 
int main() 
{ 
    std::cout<< "Hello, World."<<std::endl; 
    return 0; 
} 

我们可以逐行查看此代码。

当我们想要包含任何使用有效 C++ 语法的内容时,使用 # include。在这种情况下,我们正在将标准 C++ 库包含到我们的程序中。我们想要包含的文件指定在 <> 角括号内。在这里,我们包含了一个名为 iostream.h 的文件。该文件处理数据到控制台/屏幕的输入和输出。

在第二行,// 双斜杠标志着代码注释的开始。代码中的注释不会被程序执行。它们主要是为了告诉查看代码的人当前代码正在做什么。注释代码是一个好的实践,这样当你查看一年前写的代码时,你会知道代码的作用。

基本上,main() 是一个函数。我们将在稍后介绍函数,但 main 函数是程序中首先执行的函数,也称为入口点。函数用于执行特定任务。在这里,打印 Hello, World 的任务分配给了 main 函数。需要执行的内容必须包含在函数的大括号内。在 main() 关键字之前的前缀 int 暗示该函数将返回一个整数。这就是为什么我们在 main 函数的末尾返回 0,表明程序可以执行且可以无错误地终止。

当我们想要将某些内容打印到控制台/屏幕上时,我们使用 C++ 的 std::cout(控制台输出)命令将内容发送到屏幕。我们想要发送的内容应该以输出操作符 << 开始和结束。此外,<<std::endl 是另一个 C++ 命令,它指定了行尾,并且在该行之后不应打印任何其他内容。我们必须在 std:: 前使用前缀来告诉 C++ 我们正在使用带有 std 命名空间的标准命名空间。但为什么命名空间是必要的呢?我们需要命名空间是因为任何人都可以使用 std 声明一个变量名。编译器如何区分这两种类型的 std 呢?为此,我们有了命名空间来区分它们。

注意,我们在主函数中编写的两行代码的每一行都以分号(;)结尾。分号告诉编译器这是该行代码指令的结束,以便程序在到达分号时停止读取并转到下一行指令。因此,在每条指令的末尾添加分号是强制性的。

我们之前编写的两行代码可以合并为一行,如下所示:

std::cout<< "Hello, World."<<std::endl;return 0; 

即使它只写了一行,对于编译器来说,有两个指令,两个指令都以分号结尾。

第一条指令是将Hello, World打印到控制台,第二条指令是终止程序而不产生错误。

忘记分号是一个非常常见的错误,即使是经验丰富的程序员有时也会犯这个错误。所以,当你遇到第一组编译器错误时,记住这一点是很好的。

让我们按照以下步骤在 Visual Studio 中运行此代码:

  1. 打开 Visual Studio,通过 File | New | Project 创建一个新项目。

  2. 在左侧,选择 Visual C++然后选择 Other。对于 Project Type,选择 Empty Project。给这个项目一个名称。Visual Studio 自动将第一个项目命名为MyFirstProject。你可以随意命名。

  3. 选择你想要项目保存的位置:

图片

  1. 项目创建后,在 Solution Explorer 中,右键单击并选择 Add | New Item:

图片

  1. 创建一个新的.cpp文件,称为Source文件:

图片

  1. 将该节开始的代码复制到Source.cpp文件中。

  2. 现在按键盘上的F5键或按窗口顶部的 Local Window Debugger 按钮来运行应用程序。

  3. 运行程序时,应该会弹出一个控制台窗口。为了使控制台保持打开状态,以便我们可以看到正在发生的事情,请将以下突出显示的行添加到代码中:

#include <iostream> 
#include <conio.h>
// Program prints out "Hello, World" to screen 
int main() 
{ 
   std::cout << "Hello, World." << std::endl;       
    _getch();
   return 0; 
} 

_getch()的作用是使程序暂停并等待控制台输入字符,而不将字符打印到控制台。因此,程序将等待一些输入然后关闭控制台。

要查看打印到控制台的内容,我们只需添加它以方便使用。要使用此功能,我们需要包含conio.h头文件。

  1. 当你再次运行项目时,你将看到以下输出:

图片

现在我们知道了如何运行一个基本程序,让我们看看 C++中包含的不同数据类型。

变量

变量用于存储值。你存储在变量中的任何值都存储在与之关联的内存位置。你可以使用以下语法给变量赋值。

我们可以先通过指定类型和变量名来声明变量类型:

Type variable;

在这里,type是变量类型,variable是变量的名称。

接下来,我们可以给变量赋值:

Variable = value;

现在这个值已经赋给了变量。

或者,你可以在一行中同时声明变量并给它赋值,如下所示:

type variable = value;

在设置变量之前,你必须指定变量类型。然后你可以使用等号(=)给变量赋值。

让我们看看一些示例代码:

#include <iostream> 
#include <conio.h>
// Program prints out value of n to screen 
int main() 
{ 
   int n = 42;  
std::cout <<"Value of n is: "<< n << std::endl;     
    _getch();
   return 0; 
} 

Source.cpp中将之前的代码替换为以下代码并运行应用程序。你应该得到以下输出:

在这个程序中,我们指定数据类型为intint是 C++数据类型,可以存储整数。因此,它不能存储小数。我们声明一个名为n的变量,并将其赋值为42。不要忘记在行尾添加分号。

在下一行,我们将值打印到控制台。请注意,为了打印n的值,我们只需在cout中传递n,而无需添加引号。

在 32 位系统上,int变量使用 4 个字节(等于 32 位)的内存。这基本上意味着int数据类型可以存储从 0 到 2³²-1(4,294,967,295)的值。然而,需要一个位来描述值的符号(正或负),这留下了 31 位来表示实际值。因此,有符号的int可以存储从-2³¹(-2,147,483,648)到 2³¹-1(2,147,483,647)的值。

让我们看看其他一些数据类型:

  • bool:布尔值只能有两个值。它可以存储truefalse

  • char:这些存储介于 -128 和 127 之间的整数。请注意,char或字符变量用于存储 ASCII 字符,例如单个字符——例如字母。

  • shortlong:这些也是整数类型,但它们能够存储比int更多的信息。int的大小依赖于系统,而longshort的大小不依赖于使用的系统。

  • float:这是一个浮点类型。这意味着它可以存储带有小数点的值,例如 3.14、0.000875 和-9.875。它可以存储最多七位小数的值。

  • double:这是一个精度更高的float类型。它可以存储最多 15 位小数的十进制值。

数据类型 最小值 最大值 大小(字节)
bool false true 1
char -128 127 1
short -32768 32767 2
int -2,147,483,648 2,147,483,647 4
long -2,147,483,648 2,147,483,647 4
float 3.4 x 10^-38 3.4 x 10³⁸ 4
double 1.7 x 10^-308 1.7 x 10³⁰⁸ 8

你还有相同数据类型的无符号数据类型,用于最大化它们可以存储的值的范围。无符号数据类型用于存储正值。因此,所有无符号值都从 0 开始。

因此,charunsigned char可以存储从0255的正值。与unsigned char类似,我们还有unsigned shortintlong

你可以像下面这样给boolcharfloat赋值:

#include <iostream> 
#include <conio.h> 
// Program prints out value of bool, char and float to screen 
int main() 
{ 
   bool a = false; 
   char b = 'b'; 
   float c = 3.1416f; 
   unsigned int d = -82; 

   std::cout << "Value of a is : " << a << std::endl; 
   std::cout << "Value of b is : " << b << std::endl; 
   std::cout << "Value of c is : " << c << std::endl; 
   std::cout << "Value of d is : " << d << std::endl; 

   _getch(); 
   return 0; 
} 

这是运行应用程序时的输出结果:

图片

除了d之外,所有内容打印都很正常,d被分配了-82。这里发生了什么?嗯,这是因为d只能存储无符号值,所以如果我们给它分配-82,它就会得到一个垃圾值。将其更改为不带负号的82,它就会打印出正确的值:

图片

int不同,bool存储一个二进制值,其中false0true1。所以,当你打印出truefalse的值时,输出将分别是10

基本上,char存储用单引号指定的字符,带有小数点的值以你存储在浮点数中的方式打印。在分配float时,在值末尾添加一个f,以告诉系统它是一个浮点数而不是双精度数。

字符串

非数值变量要么是一个字符,要么是一系列称为字符串的字符。在 C++中,一系列字符可以存储在一个特殊变量中,称为字符串。字符串通过标准string类提供。

为了声明和使用string对象,我们必须包含字符串头文件。在#include <conio.h>之后,还要在文件顶部添加#include <string>

字符串变量声明的方式与其他变量类型相同,只是在字符串类型之前你必须使用std命名空间。

如果你不喜欢添加std::命名空间前缀,你还可以在#include之后添加使用std命名空间的行。这样,你就不需要添加std::前缀,因为程序没有它也能很好地理解。然而,它和其他变量一样可以打印出来:

#include <iostream> 
#include <conio.h> 
#include <string> 

// Program prints out values to screen 

int main() 
{ 

   std::string name = "The Dude"; 

   std::cout << "My name is: " << name << std::endl; 

   _getch(); 
   return 0; 
} 

这里是输出结果:

图片

运算符

运算符是一个符号,它在变量或表达式上执行某种操作。到目前为止,我们已经使用了=符号,它调用一个赋值运算符,将等号右侧的值或表达式赋给等号左侧的变量。

其他种类运算符的最简单形式是算术运算符,如+-*/%。这些运算符作用于变量,如intfloat。让我们看看这些运算符的一些用法示例:

#include <iostream> 
#include <conio.h> 
// Program prints out value of a + b and x + y to screen 
int main() 
{ 
   int a = 8; 
   int b = 12; 
   std::cout << "Value of a + b is : " << a + b << std::endl; 

   float x = 7.345f; 
   float y = 12.8354; 
   std::cout << "Value of x + y is : " << x + y << std::endl; 

   _getch(); 
   return 0; 
} 

这个输出的结果如下:

图片

让我们看看其他操作的示例:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 
   int a = 36; 
   int b = 5; 

   std::cout << "Value of a + b is : " << a + b << std::endl; 
   std::cout << "Value of a - b is : " << a - b << std::endl; 
   std::cout << "Value of a * b is : " << a * b << std::endl; 
   std::cout << "Value of a / b is : " << a / b << std::endl; 
   std::cout << "Value of a % b is : " << a % b << std::endl; 

   _getch(); 
   return 0; 
} 

输出结果如下:

图片

+-*/符号是自解释的。然而,还有一个算术运算符:%,称为取模运算符。它返回除法的余数。

5 在 36 中包含多少次?答案是 7 次,余数为 1。这就是为什么结果是 1 的原因。

除了算术运算符之外,我们还有增量/减量运算符。

在编程中,我们经常递增变量。你可以用 a=a+1; 来递增,用 a=a-1; 来递减变量值。或者,你也可以用 a+=1;a-=1; 来递增和递减,但在 C++ 编程中,有一个更短的方法来做这件事,那就是使用 ++-- 符号来递增和递减变量的值 1

让我们看看如何使用它来递增和递减一个值 1 的例子:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 

   int a = 36; 
   int b = 5; 

   std::cout << "Value of ++a is : " << ++a << std::endl; 
   std::cout << "Value of --b is : " << --b << std::endl; 

   std::cout << "Value of a is : " << a << std::endl; 
   std::cout << "Value of b is : " << b << std::endl; 

   _getch(); 
   return 0; 
} 

这个输出的结果如下:

因此,++-- 运算符永久地递增值。如果 ++ 运算符在变量左侧,它被称为前递增运算符。如果它放在后面,它被称为后递增运算符。两者之间有一点区别。如果我们把 ++ 放在另一边,我们得到以下输出:

在这种情况下,ab 在下一行中递增和递减。所以,当你打印这些值时,它会打印出正确的结果。

在这个简单的例子中,这没有区别,但总的来说,它确实有区别,了解这个区别是好的。在这本书中,我们将主要使用后递增运算符。

事实上,这就是 C++ 得名的原因;它是 C 的一个增量。

除了算术、递增和递减运算符之外,你还有逻辑和比较运算符。

逻辑运算符如下表所示:

运算符 操作
!
&&
&#124;&#124;

这里是比较运算符:

运算符 比较
== 等于
!= 不等于
< 小于
> 大于
<= 小于等于
>= 大于等于

我们将在下一节中介绍这些运算符。

语句

一个程序可能不总是线性的。根据你的需求,你可能需要分支或分叉,重复一组代码,或者做出决定。为此,有条件语句和循环。

在条件语句中,你检查一个条件是否为真。如果是,你将执行该语句。

第一个条件语句是 if 语句。这个语句的语法如下:

If (condition) statement; 

让我们看看如何在以下代码中使用它。这里我们使用一个比较运算符:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 
   int a = 36; 
   int b = 5; 

   if (a > b) 
   std::cout << a << " is greater than " << b << std::endl; 

   _getch(); 
   return 0; 
} 

输出如下:

我们检查 a 是否大于 b,如果条件为真,则打印出该语句。

但如果情况相反呢?为此,我们有 if...else 语句,这是一个基本上执行替代语句的语句。其语法如下:

if (condition) statement1; 
else statement2; 

让我们通过代码来看看:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 

   int a = 2; 
   int b = 28; 

   if (a > b) 
   std::cout << a << " is greater than " << b << std::endl; 
   else 
   std::cout << b << " is greater than " << a << std::endl; 

   _getch(); 
   return 0; 
}

在这里,ab 的值被改变,使得 b 大于 a

注意一点是,在 ifelse 条件之后,C++ 将执行单行语句。如果有多个语句在 ifelse 之后,则这些语句需要用大括号括起来,如下所示:


   if (a > b) 
   {      
         std::cout << a << " is greater than " << b << std::endl; 
   } 
   else 
   { 
         std::cout << b << " is greater than " << a << std::endl; 
   }    

在使用 else if 之后,也可以有 if 语句:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 

   int a = 28; 
   int b = 28; 

   if (a > b) 
   {      
         std::cout << a << " is greater than " << b << std::endl; 
   } 
   else if (a == b)  
{ 

         std::cout << a << " is equal to " << b << std::endl; 
   } 
   else 
   { 
         std::cout << b << " is greater than " << a << std::endl; 
   } 

   _getch(); 
   return 0; 
} 

输出如下所示:

图片

迭代

迭代是重复调用相同语句的过程。C++ 有三个迭代语句:whiledo...whilefor 语句。迭代也常被称为循环。

while 循环的语法如下所示:

while (condition) statement;

让我们看看它的实际操作:

#include <iostream> 
#include <conio.h>  
// Program prints out values to screen  
int main() 
{  
   int a = 10;  
   int n = 0;  
   while (n < a) { 

         std::cout << "value of n is: " << n << std::endl;  
         n++;    
   } 
   _getch(); 
   return 0;  
} 

这是此代码的输出:

图片

在这里,n 的值会被打印到控制台,直到满足条件为止。

do while 语句几乎与 while 语句相同,只是在这种情况下,首先执行语句,然后测试条件。语法如下:

do statement  
while (condition); 

你可以自己尝试并查看结果。

编程中最常用的循环是 for 循环。其语法如下所示:

for (initialization; continuing condition; update) statement; 

for 循环非常自包含。在 while 循环中,我们必须在 while 循环外部初始化 n,但在 for 循环中,初始化是在 for 循环的声明中完成的。

这里是与 while 循环相同的示例,但使用的是 for 循环:

#include <iostream> 
#include <conio.h>  
// Program prints out values to screen  
int main() 
{  
   for (int n = 0; n < 10; n++)       
         std::cout << "value of n is: " << n << std::endl;  
   _getch(); 
   return 0; 
} 

输出与 while 循环相同,但与 while 循环相比,代码更加紧凑。此外,nfor 循环体中是局部作用域。

我们也可以将 n 增加 2 而不是 1,如下所示:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen  
int main() 
{  
   for (int n = 0; n < 10; n+=2)      
         std::cout << "value of n is: " << n << std::endl; 
   _getch(); 
   return 0; 
} 

这是此代码的输出:

图片

跳转语句

除了条件和迭代语句,你还有 breakcontinue 语句。

break 语句用于跳出迭代。如果满足某个条件,我们可以离开循环并强制其退出。

让我们看看 break 语句的使用情况:

#include <iostream> 
#include <conio.h>  
// Program prints out values to screen  
int main() 
{  
   for (int n = 0; n < 10; n++) 
   {         
         if (n == 5) {               
               std::cout << "break" << std::endl; 
               break; 
         } 
         std::cout << "value of n is: " << n << std::endl; 
   }  
   _getch(); 
   return 0; 
} 

此输出的结果如下所示:

图片

continue 语句将跳过当前迭代,并继续执行直到循环结束的语句。在 break 代码中,将 break 替换为 continue 以查看差异:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 

   for (int n = 0; n < 10; n++) 
   { 
         if (n == 5) { 

               std::cout << "continue" << std::endl; 

               continue; 
         } 
         std::cout << "value of n is: " << n << std::endl; 
   } 
   _getch(); 
   return 0; 
} 

当将 break 替换为 continue 时,这是输出结果:

图片

Switch 语句

最后一个语句是 switch 语句。switch 语句检查多个值的几种情况,如果值与表达式匹配,则执行相应的语句并退出 switch 语句。如果没有找到任何值,则输出默认语句。

其语法如下所示:

switch( expression){ 

case constant1:  statement1; break; 
case constant2:  statement2; break; 
. 
. 
. 
default: default statement; break;  

}  

这看起来与 else if 语句非常相似,但更为复杂。以下是一个示例:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

int main() 
{ 
   int a = 28; 

   switch (a) 
   { 
   case 1: std::cout << " value of a is " << a << std::endl; break; 
   case 2: std::cout << " value of a is " << a << std::endl; break; 
   case 3: std::cout << " value of a is " << a << std::endl; break; 
   case 4: std::cout << " value of a is " << a << std::endl; break; 
   case 5: std::cout << " value of a is " << a << std::endl; break; 
   default: std::cout << " value a is out of range " << std::endl; break; 
   } 

   _getch(); 
   return 0; 
} 

输出如下所示:

图片

a的值改为等于2,你就会看到当2的情况正确时,它会打印出该语句。

还要注意,添加break语句是很重要的。如果你忘记添加它,那么程序将无法跳出该语句。

函数

到目前为止,我们已经在main函数中编写了所有的代码。如果你只做一项任务,这是可以的,但一旦你开始用程序做更多的事情,代码就会变得更大,随着时间的推移,所有内容都会在main函数中,这会显得非常混乱。

使用函数,你可以将你的代码拆分成更小、更易于管理的块。这将使你能够更好地组织你的程序。

函数有以下语法:

type function name (parameter1, parameter2) {statements;}

从左到右,这里的type是返回类型。在执行一个语句之后,函数能够返回一个值。这个值可以是任何类型,所以我们在这里指定一个类型。函数一次只能有一个变量。

函数名就是函数本身的名称。

然后,在括号内,你将传入参数。这些参数是传递给函数的特定类型的变量,以便执行特定功能。

这里有一个例子:传入两个参数,但你可以传入你想要的任何数量的参数。你可以在每个函数中传入多个参数,每个参数之间用逗号分隔。

让我们看看这个例子:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

void add(int a, int b)  
{ 
   int c = a + b; 

   std::cout << "Sum of " << a << " and " << b << " is " << c <<   
   std::endl;  
}  
int main() 
{ 
   int x = 28; 
   int y = 12; 

   add(x, y); 

   _getch(); 
   return 0; 
}   

在这里,我们创建了一个名为add的新函数。目前,请确保在main函数之前添加函数;否则,main将不知道函数的存在。

add函数不返回任何内容,所以我们使用void关键字在函数的开始处。并不是所有的函数都必须返回一个值。接下来,我们命名函数为add,然后传入两个参数,这两个参数是int类型的ab

在函数中,我们创建了一个名为c的新变量,其类型为int,将传入的参数值相加,并将其赋值给c。新的add函数最终会打印出c的值。

此外,在main函数中,我们创建了两个名为xyint类型变量,调用了add函数,并将xy作为参数传入。

当我们调用函数时,我们将x的值传递给a,将y的值传递给b,它们相加并存储在c中,得到以下输出:

图片

当你创建新的函数时,请确保它们写在main函数之上;否则,它将看不到函数,编译器会抛出错误。

现在,让我们再写一个函数。这次,我们将确保函数返回一个值。创建一个名为multiply的新函数,如下所示:

int multiply(int a, int b) { 

   return a * b; 

}

main函数中,在调用add函数之后,添加以下行:

   add(x, y); 

   int c = multiply(12, 32); 

   std::cout << "Value returned by multiply function is: " << c <<  
   std::endl; 

multiply 函数中,我们有一个返回类型为 int,因此函数在函数末尾期望有一个返回值,我们使用 return 关键字返回。返回值是 a 变量与 b 变量的乘积。

main 函数中,我们创建了一个名为 c 的新变量;调用 multiply 函数并传入 1232。在乘法之后,返回值将被分配给 c 的值。之后,我们在 main 函数中打印出 c 的值。

这个输出的结果如下:

图片

我们可以有一个具有相同名称的函数,但我们可以传入不同的变量或不同数量的变量。这被称为函数重载

创建一个名为 multiply 的新函数,但这次传入浮点数并设置返回值为浮点数:

float multiply(float a, float b) { 

   return a * b; 

} 

这被称为函数重载,其中函数名称相同,但它接受不同类型的参数。

main 函数中,在我们打印了 c 的值之后,添加以下代码:

float d = multiply(8.352f, -12.365f); 
std::cout << "Value returned by multiply function is: " << d << std::endl;

那么,浮点值后面的这个 f 是什么意思呢?嗯,f 只是将双精度浮点数转换为浮点数。如果我们不加 f,编译器将把值视为双精度浮点数。

当你运行程序时,你会得到打印出的 d 的值:

图片

变量的作用域

你可能已经注意到,在程序中现在有两个名为 c 的变量。main 函数中有一个 cadd 函数中也有一个 c。它们都命名为 c 但值却不同,这是怎么回事?

在 C++ 中,存在局部变量的概念。这意味着变量的定义仅限于其定义的局部代码块内。因此,add 函数中的 c 变量与 main 函数中的 c 变量处理方式不同。

还有全局变量,需要在函数或代码块外部声明。任何写在花括号之间的代码都被认为是代码块。因此,要使变量成为全局变量,它需要位于程序的主体中,或者它需要在函数的代码块外部声明。

数组

到目前为止,我们只看了单个变量,但如果我们想将一些变量分组在一起呢?比如一个班级所有学生的年龄。你可以继续创建单独的变量,abcd 等等,要访问每个变量,你必须调用它们,这很麻烦,因为你不知道它们持有的数据类型。

为了更好地组织数据,我们可以使用数组。数组使用连续的内存空间按顺序存储值,你可以使用索引号访问每个元素。

数组的语法如下:

type name [size] = { value0, value1, ....., valuesize-1};

因此,我们可以如下存储五个学生的年龄:

int age[5] = {12, 6, 18 , 7, 9 }; 

当创建具有固定数量值的数组时,你不必指定大小,但这样做是个好主意。要访问每个值,我们使用从 04 的索引作为第一个元素,其值为 12 在第 0^(th) 索引处,以及最后一个元素,9 在第四个索引处。

让我们看看如何在代码中使用它:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 
int main() 
{ 
   int age[5] = { 12, 6, 18 , 7, 9 }; 

   std::cout << "Element at the 0th index " << age[0]<< std::endl; 
   std::cout << "Element at the 4th index " << age[4] << std::endl; 

   _getch(); 
   return 0; 
} 

输出如下:

要访问数组中的每个元素,你可以使用 or 循环:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 
int main() 
{ 
   int age[5] = { 12, 6, 18 , 7, 9 }; 

   for (int i = 0; i < 5; i++) { 

         std::cout << "Element at the "<< i << "th index is: " << 
         age[i] << std::endl;  
   }  
   _getch(); 
   return 0; 
} 

输出如下:

我们不是调用 age[0] 等等,而是使用 for 循环本身的 i 索引,并将其传递到 age 数组中以打印出索引和存储在该索引处的值。

age 数组是一维数组。在图形编程中,我们看到了我们使用二维数组,这通常是一个 4x4 矩阵。让我们看看二维 4x4 数组的示例。二维数组定义如下:

int matrix[4][4] = {
{2, 8, 10, -5},
{15, 21, 22, 32},
{3, 0, 19, 5},
{5, 7, -23, 18}
};

要访问每个元素,你使用嵌套的 for 循环。

让我们在以下代码中看看这个:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 
int main() 
{ 

   int matrix[4][4] = { 
                              {2, 8, 10, -5}, 
                              {15, 21, 22, 32}, 
                              {3, 0, 19, 5}, 
                              {5, 7, -23, 18} 
   }; 

   for (int x = 0; x < 4; x++) { 
         for (int y = 0; y < 4; y++) { 
               std::cout<< matrix[x][y] <<" "; 
         } 
         std::cout<<""<<std::endl; 
   } 

   _getch(); 
   return 0; 
} 

输出如下:

作为测试,创建两个矩阵并尝试执行矩阵乘法。

你甚至可以将数组作为参数传递给函数,以下是一个示例。

这里,matrixPrinter 函数不返回任何内容,而是打印出 4x4 矩阵中每个元素存储的值:

#include <iostream> 
#include <conio.h> 

void matrixPrinter(int a[4][4]) { 

   for (int x = 0; x < 4; x++) { 
         for (int y = 0; y < 4; y++) { 
               std::cout << a[x][y] << " "; 
         } 
         std::cout << "" << std::endl; 
   } 
} 

// Program prints out values to screen 
int main() 
{ 

   int matrix[4][4] = { 
                            {2, 8, 10, -5}, 
                            {15, 21, 22, 32}, 
                            {3, 0, 19, 5}, 
                            {5, 7, -23, 18} 
   }; 

   matrixPrinter(matrix); 

   _getch(); 
   return 0; 
} 

我们甚至可以使用 char 数组来创建一个单词字符串。与 intfloat 数组不同,数组中的字符不必放在花括号内,也不需要用逗号分隔。

要创建一个字符数组,你定义如下:

   char name[] = "Hello, World !"; 

你可以通过调用数组的名称来打印出值,如下所示:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 
int main() 
{ 

   char name[] = "Hello, World !"; 

   std::cout << name << std::endl; 

   _getch(); 
   return 0; 
} 

输出如下:

指针

每当我们声明新变量以便在其中存储值时,我们实际上向操作系统发送一个内存分配请求。如果剩余足够的空闲内存,操作系统将尝试为我们的应用程序保留一块连续的内存。

当我们想要访问存储在该内存空间中的值时,我们调用变量名。

我们不必担心存储值的内存位置。然而,如果我们想获取变量存储位置的地址呢?

定位变量在内存中的地址称为变量的引用。要访问此,我们使用 & 操作符的地址。要获取地址位置,我们将操作符放在变量之前。

指针是变量,就像任何其他变量一样,它们用于存储一个值;然而,这种特定的变量类型允许存储另一个变量的地址——即引用。

在 C/C++中,每个变量也可以通过在其变量名前加一个星号(*)来声明为指针,该指针持有对特定数据类型值的引用。这意味着,例如,int指针持有对可能存储int值的内存地址的引用。

指针可以用在任何内置或自定义数据类型上。如果我们访问pointer变量的值,我们只会得到它引用的内存地址。因此,为了访问pointer变量引用的实际值,我们必须使用所谓的解引用运算符(*)。

如果我们有一个名为age的变量并将其赋值,为了获取引用地址位置,我们使用&age来存储这个地址。为了存储引用地址,我们不能只是使用常规变量;我们必须使用指针变量,并在它之前使用解引用运算符来访问地址,如下所示:

   int age = 10;  
   int *location = &age; 

这里,指针位置将存储age变量值存储的地址。

如果我们打印location的值,我们将得到存储age的引用地址:

#include <iostream> 
#include <conio.h>  
// Program prints out values to screen 
int main() 
{  
   int age = 10;  
   int *location = &age; 
   std::cout << location << std::endl;  
   _getch(); 
   return 0; 
} 

这是输出:

这个值对于你可能不同,因为位置会因机器而异。

要获取location变量本身存储的位置,我们可以同时打印出&location

这是变量在我的系统内存中的内存位置:

让我们再看另一个例子:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 
int main() 
{ 
   int age = 18; 
   int *pointer; 

   pointer = &age; 

   *pointer = 12; 

   std::cout << age << std::endl; 

   _getch(); 
   return 0; 
}  

这里,我们创建了两个int变量;一个是常规的int,另一个是指针类型。

我们首先将age变量设置为18,然后设置age的地址,并将其分配给名为pointer的指针变量。

现在,int指针正指向存储age变量int值的同一地址。

接下来,在pointer变量上使用解引用运算符,以获得对引用地址存储的int值的访问,并将当前值更改为12

现在,当我们打印出age变量的值时,我们将看到前面的语句确实改变了age变量的值。空指针是一个不指向任何内容的指针,其设置如下:

   int *p = nullptr; 

指针与数组紧密相关。因为数组不过是连续的内存序列,所以我们可以使用指针与它们一起使用。

考虑我们在数组部分提到的数组示例:

int age[5] = { 12, 6, 18 , 7, 9 }; 

我们可以不用索引,而是用指针指向数组中的值。

考虑以下代码:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 
int main() 
{ 
   int *p = nullptr; 
   int age[5] = { 12, 6, 18 , 7, 9 }; 

   p = age; 

   std::cout << *p << std::endl; 

   p++; 

   std::cout << *p << std::endl; 

   std::cout << *(p + 3) << std::endl; 

std::cout << *p << std::endl; 

   _getch(); 
   return 0; 
} 

main函数中,我们创建了一个名为pointer的指针,以及一个包含五个元素的数组。我们将数组分配给指针。这导致指针获取数组第一个元素的地址。因此,当我们打印指针指向的值时,我们得到数组的第一个元素的值。

使用指针,我们也可以像常规int一样递增和递减。然而,与常规int递增不同,当你递增指针时,它会增加变量的值,而指针会指向下一个内存位置。所以,当我们递增p时,它现在指向数组的下一个内存位置。递增和递减指针意味着将引用的地址移动一定数量的字节。字节数取决于用于指针变量的数据类型。

在这里,指针是int类型,所以当我们移动指针一个单位时,它移动 4 个字节并指向下一个整数。当我们打印p现在指向的值时,它打印第二个元素的值。

我们也可以通过获取指针的当前位置并添加到它,使用*(p + n)来获取数组中其他元素的价值,其中n是从当前位置获取的第 n个数字。所以,当我们做*(p + 3)时,我们将从p当前指向的位置获取第三个元素。由于p递增到了第二个元素,从第二个元素开始的第三个元素是第五个元素,因此打印出第五个元素的价值。

然而,这并没有改变p指向的位置,它仍然是第二个位置。

这是输出的结果:

图片

结构体

结构体或struct用于将数据组合在一起。一个struct可以包含不同的数据元素,称为成员,整数、浮点数、字符等。你可以创建许多类似struct的对象,并在struct中存储值以进行数据管理。

struct的语法如下:

struct name{ 

type1 name1; 
type2 name2; 
. 
. 
} ; 

可以如下创建struct对象:

struct_name     object_name; 

一个对象是struct的一个实例,在创建struct时我们可以将属性分配给创建的数据类型。以下是一个例子。

在你想要维护学生年龄和班级身高数据库的情况下,你的struct定义将如下所示:

struct student { 

   int age; 
   float height; 

}; 

现在你可以创建一个对象数组并存储每个学生的值:

int main() 
{ 

   student section[3]; 

   section[0].age = 17; 
   section[0].height = 39.45f; 

   section[1].age = 12; 
   section[1].height = 29.45f; 

   section[2].age = 8; 
   section[2].height = 13.45f; 

   for (int i = 0; i < 3; i++) { 

         std::cout << "student " << i << " age: " << section[i].age << 
           " height: " << section[i].height << std::endl; 
   } 

   _getch(); 
   return 0; 
} 

这是输出的结果:

图片

枚举

枚举用于在列表中列举项目。当比较项目时,比较名称比仅仅比较数字更容易。例如,一周中的日子是星期一到星期日。在一个程序中,我们将星期一分配给 0,星期二分配给 1,星期日分配给 7,例如。要检查今天是否是星期五,你必须数到并到达 5。然而,检查Today == Friday不是更容易吗?

为了这个,我们有枚举,声明如下:

enum name{ 
value1, 
value2, 
. 
. 
. 
};

因此,在我们的例子中,它可能看起来像这样:

#include <iostream> 
#include <conio.h> 

// Program prints out values to screen 

enum Weekdays { 
   Monday = 0, 
   Tuesday, 
   Wednesday, 
   Thursday, 
   Friday, 
   Saturday, 
   Sunday, 
}; 

int main() 
{ 

   Weekdays today; 

   today = Friday; 

   if (today == Friday) { 
         std::cout << "The weekend is here !!!!" << std::endl; 
   } 

   _getch(); 
   return 0; 
} 

这个输出的结果如下:

图片

还要注意,Monday = 0。如果我们不使用初始化器,第一个项目的值将被设置为 0。每个后续未使用初始化器的项目将使用前一个项目的值加上 1 作为其值。

在 C++ 中,结构和类是相同的。你可以用它们做完全一样的事情。唯一的区别是默认的访问修饰符:结构为 public,类为 private

类的声明如下所示:

class name{ 

access specifier: 

name(); 
~name(); 

member1; 
member2; 

} ; 

类以 class 关键字开头,后跟类的名字。

在类中,我们首先指定访问修饰符。有三个访问修饰符:publicprivateprotected

  • public:所有成员在任何地方都可以访问。

  • private:成员仅可以从类内部访问。

  • protected:成员可以被继承自该类的其他类访问。

默认情况下,所有成员都是私有的。

此外,name();~name(); 被称为类的构造函数和析构函数。它们的名字与类的名字本身相同。

构造函数是一个特殊函数,当创建类的新的对象时会被调用。析构函数在对象被销毁时被调用。

我们可以自定义构造函数来在使用成员变量之前设置值。这被称为构造函数重载。

注意,尽管构造函数和析构函数是函数,但它们没有提供返回值。这是因为它们不是为了返回值而存在的。

让我们看看一个类的例子,我们创建了一个名为 shape 的类。这个类有两个成员变量 ab 的边,以及一个成员函数,该函数计算并打印面积:

class shape {  
   int a, b;  
public: 

   shape(int _length, int _width) { 
         a = _length;  
         b = _width; 

         std::cout << "length is: " << a << " width is: " << b << 
                      std::endl; 
   } 

   void area() { 

         std::cout << "Area is: " << a * b << std::endl; 
   } 
}; 

我们通过创建类的对象来使用类。

这里,我们创建了两个对象,分别称为 squarerectangle。我们通过调用自定义构造函数来设置值,该构造函数设置 ab 的值。然后,我们通过使用点操作符(在键盘上按下 . 按钮后输入对象的名字)调用对象的 area 函数:

int main() 
{  
   shape square(8, 8); 
   square.area(); 

   shape rectangle(12, 20); 
   rectangle.area(); 

   _getch(); 
   return 0; 
} 

输出如下所示:

图片

继承

C++ 的一个关键特性是继承,通过它可以创建从其他类派生出来的类,这样派生类或子类会自动包含其父类的一些成员变量和函数。

例如,我们研究了 shape 类。从这一点出发,我们可以有一个名为 circle 的单独类,另一个名为 triangle 的类,它具有与其他形状相同的属性,例如面积。

继承类的语法如下所示:

class inheritedClassName: accessSpecifier parentClassName{ 

};   

注意,accessSpecifier 可以是 publicprivateprotected,具体取决于你想要提供给父成员变量和函数的最小访问级别。

让我们看看继承的一个例子。考虑相同的 shape 类,它将是父类:

class shape { 

protected:  
   float a, b;  
public: 
    void setValues(float _length, float _width) 
   { 
         a = _length; 
         b = _width; 

         std::cout << "length is: " << a << " width is: " << b <<  
         std::endl; 
   }  
   void area() {  
         std::cout << "Area is: " << a * b << std::endl; 
   } 

}; 

由于我们希望triangle类能够访问父类的ab,我们必须将访问修饰符设置为 protected,如前所述;否则,它将默认设置为 private。除此之外,我们还更改了数据类型为 float 以获得更高的精度。完成这些后,我们创建了一个名为setValues的函数来设置ab的值。然后我们创建了一个shape类的子类,命名为triangle

class triangle : public shape { 

public: 
   void area() { 

         std::cout << "Area of a Triangle is: " << 0.5f * a * b << 
                      std::endl; 
   } 

}; 

由于从shape类继承,我们不需要添加ab成员变量,也不需要添加setValues成员函数,因为这些是从shape类继承的。我们只需添加一个名为area的新函数,该函数计算三角形的面积。

在主函数中,我们创建了一个triangle类的对象,设置了值,并按如下方式打印面积:

int main() 
{ 

   shape rectangle; 
   rectangle.setValues(8.0f, 12.0f); 
   rectangle.area(); 

   triangle tri; 
   tri.setValues(3.0f, 23.0f); 
   tri.area(); 

   _getch(); 
   return 0; 
}

这里是这个输出的内容:

图片

为了计算circle的面积,我们修改了shape类并添加了一个新的重载的setValues函数,如下所示:

#include <iostream> 
#include <conio.h> 

class shape { 

protected: 

   float a, b; 

public: 

   void setValues(float _length, float _width) 
   { 
         a = _length; 
         b = _width; 

         std::cout << "length is: " << a << " height is: " << b << 
                      std::endl; 
   } 

 void setValues(float _a)
{
a = _a;
} 
   void area() { 

         std::cout << "Area is: " << a * b << std::endl; 
   } 

};

然后我们将添加一个新的继承类,命名为circle

class circle : public shape { 

public: 
   void area() { 

         std::cout << "Area of a Circle is: " << 3.14f * a * a << 
                      std::endl; 
   } 

}; 

在主函数中,我们创建一个新的circle对象,设置半径,并打印面积:

int main() 
{ 

   shape rectangle; 
   rectangle.setValues(8.0f, 12.0f); 
   rectangle.area(); 

   triangle tri; 
   tri.setValues(3.0f, 23.0f); 
   tri.area(); 

   circle c; 
   c.setValues(5.0f); 
   c.area(); 

   _getch(); 
   return 0; 
}

这里是这个输出的内容:

图片

摘要

在本章中,我们介绍了编程的基础知识——从变量是什么以及如何将值存储在它们中,到查看运算符和语句,到如何决定何时需要它们。之后,我们探讨了迭代器和函数,这些可以简化我们的工作并尽可能自动化代码。数组和指针帮助我们分组和存储类似类型的数据,而structenum则允许我们创建自定义数据类型。最后,我们探讨了类和继承,这是使用 C++的关键,使得定义具有自定义属性的我们的数据类型变得方便。

在下一章中,我们将探讨图形编程的基础,并探索如何在屏幕上显示三维和二维对象。

第二章:数学与图形概念

在我们开始渲染对象之前,了解本书项目中将使用的数学知识是至关重要的。数学在游戏开发中起着至关重要的作用,图形编程通常广泛使用向量和矩阵。在本章中,你将了解这些数学概念如何派上用场。首先,我们将回顾一些关键数学概念,然后应用它们,以便我们可以处理空间变换和渲染管线。有专门的书籍涵盖了你在游戏开发中需要的所有数学相关主题。然而,由于我们将使用 C++进行图形编程,其他数学主题超出了本书的范围。

在接下来的章节中,我们将使用 OpenGL 和 Vulkan 图形 API 来渲染我们的对象,并使用 GLM 数学库来进行数学运算。在本章中,我们将探讨在虚拟世界中使用矩阵和向量变换创建 3D 对象的过程。然后,我们将看看如何使用空间变换将 3D 点转换为 2D 位置,以及图形管线如何帮助我们实现这一点。

在本章中,我们将涵盖以下主题:

  • 3D 坐标系

  • 向量

  • 矩阵

  • GLM OpenGL 数学

  • OpenGL 数据类型

  • 空间变换

  • 渲染管线

3D 坐标系

在我们能够指定一个位置之前,我们必须指定一个坐标系。一个 3D 坐标系有三个轴:x轴、y轴和z轴。这三个轴从三个轴相交的原点开始。

正的 x 轴从原点开始,并无限地向一个特定方向移动,而负的x轴则向相反方向移动。正的y轴从原点开始,以 90 度角向上移动,与x轴垂直,负的y轴则向相反方向移动。这描述了一个 2D XY 平面,它是 2D 坐标系的基础。

正的z轴与x轴和y轴有相同的原点,并且垂直于 X 轴和 Y 轴。正z轴可以沿着 XY 平面的任意方向移动,以形成一个 3D 坐标系。

假设正x轴向右,正y轴向上,那么z轴可以进入或离开屏幕。这是因为z轴与x轴和y轴垂直。

当正的z轴进入屏幕时,这被称为左手坐标系。当正的z轴从屏幕出来时,这被称为右手坐标系

将你的右手臂伸直,使其在你面前,手掌朝向你,并握紧拳头。将你的大拇指向右伸直,然后伸直食指向上。现在,将你的中指伸直,使其朝向你。这可以用来解释右手坐标系。

拇指代表正x轴的方向,食指代表正y轴的方向,中指代表正z轴的方向。OpenGL、Vulkan 或任何使用这些轴的图形框架也使用这个坐标系。

对于左手坐标系,伸出你的左臂,使其在你面前,手掌朝向你,然后握拳。接下来,伸出你的拇指和食指,分别指向右上方。现在,伸出你的中指,使其远离你。在这种情况下,拇指代表x轴的方向,食指指向正y轴的方向。z轴(中指)现在远离你。Direct3D 的 DirectX 使用这个坐标系。

在这本书中,由于我们将要介绍 OpenGL 和 Vulkan,我们将使用右手坐标系

现在我们已经定义了坐标系,我们可以指定一个点的定义。一个 3D 点是在 3D 空间中的一个位置,它通过XYZ轴的距离以及坐标系的起点来指定。它被指定为(X, Y, Z),其中 X、Y 和 Z 是从原点到该点的距离。但我们所说的原点是什么?原点也是三个轴相交的点。原点位于(0, 0, 0),原点的位置在坐标系中指定,如下所示:

要指定坐标系内的点,想象在每个方向上,轴由更小的单位组成。这个单位可以是 1 毫米、1 厘米或 1 千米,具体取决于你有多少数据。

如果我们只看 X 轴和 Y 轴,这看起来可能就像这样:

如果我们看x轴,值 1 和 2 指定了从原点(值为 0)沿该轴的点的距离。所以,x轴上的点 1 位于(1, 0, 0)。同样,沿y轴的点 1 位于(0, 1, 0)。

此外,红色点的位置将在(1, 1, 0);也就是说,沿x轴和y轴各 1 个单位。由于 Z 值为 0,我们指定其值为 0。

类似地,3D 空间中的点如下表示:

向量

向量是一个具有大小和方向的量。具有大小和方向的量的例子包括位移、速度、加速度和力。对于位移,你可以指定方向以及物体移动的总距离。

速度和速度的区别在于,速度只指定物体移动的速度,但不确定物体移动的方向。然而,速度指定了大小,这包括速度和方向。与速度类似,我们还有加速度。加速度的一种形式是重力,我们知道它总是向下作用,并且总是大约 9.81 m/s²  —— 好吧,至少在地球上是这样。在月球上,这个值是地球的 1/6。

力的一个例子是重量。重量也向下作用,并且是质量乘以加速度的计算结果。

向量通过一个带箭头的线段进行图形表示,线段的长度表示向量的模,而带箭头的箭头表示向量的方向。我们可以围绕一个向量移动,因为这样做不会改变其大小或方向。

两个向量如果它们的大小和方向都相同,即使它们位于不同的位置,则称这两个向量相等。向量用字母上方的箭头表示。

在以下图中,向量图片图片的起点不同。由于箭头的大小和方向相同,它们是相等的:

图片

在三维坐标系中,一个向量由相对于该坐标系的坐标来指定。在以下图中,向量图片等于(2, 3, 1)并且表示为图片=:

图片

向量运算

就像标量信息一样,向量也可以相加、相减和相乘。假设你有两个向量图片图片,其中图片= (a[x], a[y], a[z])和图片= (b[x], b[y], b[z])。让我们看看我们如何将这些向量相加和相减。

在添加向量时,我们分别将分量相加以创建一个新的向量:

图片= 图片 + 图片

图片= ((ax + bx) , (ay + by) , (az + bz ))

现在,让我们在图表中可视化两个向量的加法。为了方便起见,Z 值保持为 0.0。在这里,图片=(1.0, 0.4, 0.0) 和 图片=(0.6, 2.0, 0.0),这意味着结果向量 图片= 图片 + 图片,= (1.0 + 0.6, 0.4 + 2.0, 0.0 + 0.0) = (1.6, 2.4, 0.0):

图片

向量也是交换的,这意味着 图片 + 图片 将给出与 图片 + 图片 相同的结果。

然而,如果我们把 图片 加到 图片 上,那么虚线将从向量 图片 移动到向量 图片,如图所示。此外,在向量减法中,我们通过减去向量的各个分量来创建一个新的向量:

图片= 图片 - 图片

图片= ((ax - bx), (ay - by), (az - bz))

现在,让我们在图表中可视化两个向量的减法。

在这里,图片= (1.0, 0.4, 0.0) 和 图片= (0.6, 2.0, 0.0)。因此,结果向量 图片= 图片 - 图片,= (1.0 - 0.6, 0.4 - 2.0, 0.0 - 0.0) = (0.4, -1.6, 0.0):

图片

如果向量 A 和 B 相等,结果将是一个所有三个分量都为零的零向量。

如果 图片= 图片,这意味着 a[x] = b[x], a[y] = b[y], a[z] = b[z]。如果是这种情况,那么,图片= 图片 - 图片= (0, 0, 0).

我们可以将标量乘以向量。结果是每个向量分量都乘以标量的向量。

例如,如果 A 乘以一个单一的值 s,我们将得到以下结果:

图片

向量的大小

向量的大小等于向量本身的长度。但我们如何从数学上计算它呢?

向量的大小由勾股定理给出,该定理规定,在右手三角形中,对角线长度的平方等于相邻边的平方和。让我们看看以下右手三角形,c² = x² + y²

图片

这可以扩展到三维,c² = x² + y² + z²

向量的大小用双竖线表示,所以向量的大小,

图片表示为图片。大小始终大于或等于零。

因此,如果向量 A = (X, Y, Z),那么大小由以下方程给出:

图片

如果图片 = (3, -5, 7),那么我们得到以下:

图片

因此,图片的长度为 9.11 单位。

单位向量

在某些情况下,我们不在乎向量的大小;我们只想知道向量的方向。为了找出这一点,我们希望向量在 X、Y 和 Z 方向上的长度等于 1。这样的向量被称为单位向量或归一化向量。

在单位向量中,向量的 X、Y 和 Z 分量被除以大小以创建一个单位长度的向量。它们用向量名称上方的帽子表示,而不是箭头。因此,向量 A 的单位向量将表示为图片,如下所示:

图片

当一个向量被转换为单位向量时,它被称为归一化。这意味着值始终在 0.0 和 1.0 之间。原始值已被缩放到这个范围内。让我们将向量图片= (3, -5, 7)归一化。

首先,我们必须计算图片的大小,这我们已经做了(它是 9.11)。

因此,单位向量如下:

图片

点积

点积是一种向量乘法,其中结果向量是一个标量。它也被称为标量积,原因相同。两个向量的标量积是对应分量的乘积之和。

如果你有两个向量,A = (a[x], a[y], a[z]) 和 B = (b[x], b[y], b[z]),这由以下方程给出:

图片

两个向量的点积也等于乘以两个向量大小后夹角的余弦。注意点积用向量之间的点表示:

图片

θ始终在 0 和π之间。通过将方程 1 和 2 结合起来,我们可以找出两个向量之间的角度:

因此我们得到:

这种形式具有一些独特的几何特性:

  • 如果 = 0,那么  垂直于 ,即 cos 90 = 0。

  • 如果  =  ,那么这两个向量是平行的,即 cos 0 = 1。

  • 如果 > 0,那么向量之间的角度小于 90 度。

  • 如果 < 0,那么向量之间的角度大于 90 度。

现在,让我们来看一个点积的例子。

如果 = (3, -5, 7) 和  = (2, 4 , 1),那么  = 9.110 和 

接下来,我们这样计算:

叉积

叉积是向量乘法的一种形式,其中乘积的结果是另一个向量。将  和  的叉积将得到一个第三向量,该向量垂直于向量  和  。

如果你有两个向量,  = (a[x], a[y], a[z]) 和  = (b[x], b[y], b[z]),那么  如下所示:

以下是对向量叉积的矩阵和图形实现:

 

结果法向量的方向将遵循右手定则,即用右手的手指(从  到 )弯曲时,拇指将指向结果法向量的方向 ()。

此外,请注意,你乘法时向量的顺序很重要,因为如果你反过来乘,那么结果向量将指向相反的方向。

当我们想要找到多边形面(如三角形)的法线时,叉积非常有用。

以下方程帮助我们找到向量 = (3, -5, 7) 和 = (2, 4 , 1) 的叉积:

C = A × B = (ay bz - az by, az bx - ax bz, , ax by - ay bx)

= (-5 * 1 - 7*4 , 7 * 2 - 3 * 1, 3 * 4 - (-5) * 2 )

= (-5-28, 14 - 3, 12 + 10)

= (-33, 11, 22)

矩阵

在计算机图形学中,矩阵用于计算对象变换,例如平移,即移动,X、Y 和 Z 轴上的缩放,以及围绕 X、Y 和 Z 轴的旋转。我们还将改变对象从一个坐标系到另一个坐标系的位置,这被称为空间变换。我们将看到矩阵是如何工作的,以及它们如何帮助我们简化必须使用的数学。

矩阵有行和列。一个有m行和n列的矩阵被称为m × n矩阵。矩阵的每个元素表示为索引ij,其中i指定行号,j表示列号。

因此,一个大小为 3 × 2 的矩阵 M 表示如下:

这里,矩阵 M 有三行两列,每个元素表示为 m11,m12,依此类推,直到 m32,这是矩阵的大小。

在 3D 图形编程中,我们主要处理 4 × 4 矩阵。让我们看看另一个大小为 4 x 4 的矩阵:

矩阵 A 可以表示如下:

这里,元素是 A[11] = 3, A[32] = 1, 和 A[44] = 1,矩阵的维度是 4 × 4。

我们还可以有一个一维矩阵,其中向量表示如下。在这里,B 被称为行向量,C 被称为列向量:

  • 如果行数和列数相同,并且对应元素具有相同的值,则两个矩阵相等。

  • 如果两个矩阵具有相同的行数和列数,则可以将它们相加。我们将对应位置的每个元素加到两个矩阵上,以得到一个与相加矩阵具有相同维度的第三个矩阵。

矩阵加法和减法

考虑以下两个矩阵,A 和 B。这两个矩阵的大小都是 3 x 3:

这里,如果 C = A + B,则可以表示如下:

当矩阵的每个元素从另一个矩阵的对应元素中减去时,矩阵减法以相同的方式工作。

矩阵乘法

让我们看看一个标量值如何乘以一个矩阵。我们可以通过将矩阵的每个元素乘以相同的标量值来实现这一点。这将给我们一个新的矩阵,其维度与原始矩阵相同。

再次,考虑一个矩阵 A,它已经被一些标量乘过。这里,s×A,如下所示:

两个 A 和 B 矩阵可以相乘,前提是 A 的列数等于 B 的行数。所以,如果矩阵 A 的维度是 a × b,B 的维度是 X × Y,那么为了 A 能够乘以 B,b 应该等于 X:

矩阵的结果大小将是 a × Y。两个矩阵可以像这样相乘:

图片

在这里,矩阵 A 的大小是 3 × 2,矩阵 B 的大小是 2 × 3,这意味着结果矩阵 C 的大小将是 3 × 3:

图片

然而,请注意,矩阵乘法不是交换律的,这意味着 A×B ≠ B×A。实际上,在某些情况下,甚至无法以另一种方式相乘,就像在这个例子中一样。在这里,我们甚至不能乘以 B×A,因为 B 的列数不等于 A 的行数。换句话说,矩阵的内部维度应该匹配,以便维度形式为 a![t] 和 t![b]。

您还可以将向量矩阵与普通矩阵相乘,如下所示:

图片

结果将是一个 3 × 1 大小的单维向量,如下所示:

图片

注意,当用矩阵乘以向量矩阵时,向量位于矩阵的右侧。这样做是为了使 3 × 3 大小的矩阵能够乘以 3 × 1 大小的向量矩阵。

当我们有一个只有一列的矩阵时,这被称为列主序矩阵。因此,矩阵 C 是一个列主序矩阵,就像矩阵 V 一样。

如果用一行来表示相同的向量 V,它将被称为行主序矩阵。这可以表示如下:

图片

那么,如果内部维度不匹配,我们如何将一个大小为 3 × 3 的矩阵 A 与一个大小为 1 × 3 的行主序矩阵 V 相乘呢?

这里的简单解决方案是,而不是乘以矩阵 A × V,我们乘以 V × A。这样,向量矩阵和普通矩阵的内部维度将匹配 1 × 3 和 3 × 3,结果矩阵也将是一个行主序矩阵。

在整本书中,我们将使用列主序矩阵。

如果我们要使用 4 × 4 矩阵,例如,我们如何使用 x、y 和 z 的坐标来乘以 4 × 4 矩阵?

当用 X、Y 和 Z 点乘以 4 × 4 矩阵时,我们在列主序矩阵中添加一行,并将其值设为 1。新的点将是(X, Y, Z, 1),这被称为齐次点。这使得用 4 × 4 矩阵乘以 4 × 1 向量变得容易:

图片

矩阵乘法可以推广到乘以另一个 4 × 4 矩阵。让我们看看我们如何做到这一点:

图片

单位矩阵

单位矩阵是一种特殊的矩阵,其中行数等于列数。这被称为方阵。在单位矩阵中,矩阵对角线上的元素都是 1,而其余元素都是 0。

这里是一个 4 × 4 单位矩阵的例子:

单位矩阵的工作方式类似于我们乘以任何数与 1 相乘得到相同的数。同样,当我们乘以任何矩阵与单位矩阵时,我们得到相同的矩阵。

例如,A×I = A,其中 A 是一个 4 × 4 矩阵,I 是相同大小的单位矩阵。让我们来看一个这个的例子:

矩阵转置

矩阵转置发生在行和列相互交换时。所以,m X n 矩阵的转置是 n X m。任何矩阵的转置写作 M^T。矩阵的转置如下:

观察矩阵对角线上的元素保持不变,但所有对角线周围的元素都被交换了。

在矩阵中,这个从左上角到右下角的对角线被称为主对角线。

显然,如果你对转置矩阵再次进行转置,你会得到原始矩阵,所以 (AT)T = A。

矩阵逆

任何矩阵的逆是任何矩阵乘以其逆矩阵将得到单位矩阵的情况。对于矩阵 M,矩阵的逆表示为 M^(-1)。

逆矩阵在图形编程中非常有用,当我们想要撤销矩阵的乘法时。

例如,矩阵 M 等于 A × B × C × D,其中 A、B、C 和 D 也是矩阵。现在,假设我们想知道 A× B × C 的结果,而不是将三个矩阵相乘,这是一个两步操作:首先,你将 A 与 B 相乘,然后将得到的矩阵与 C 相乘。你可以将 M 与 D^(-1) 相乘,这将得到相同的结果:

GLM OpenGL 数学

为了在 OpenGL 和 Vulkan 项目中执行我们刚才看到的数学运算,我们将使用一个仅包含头文件的 C++ 数学库,称为 GLM。这最初是为了与 OpenGL 一起使用而开发的,但它也可以与 Vulkan 一起使用:

最新版本的 GLM 可以从 glm.g-truc.net/0.9.9/index.html 下载。

除了能够创建点和执行向量加法和减法之外,GLM 还可以定义矩阵,执行矩阵变换,生成随机数,以及生成噪声。以下是一些这些函数如何执行的例子:

  • 要定义 2D 和 3D 点,我们需要包含 #include <glm/glm.hpp>,它使用 glm 命名空间。要定义空间中的 2D 点,我们使用以下代码:
glm::vec2 p1 = glm::vec2(2.0f, 10.0f); 

Where the 2 arguments passed in are the x and y position. 
  • 要定义 3D 点,我们使用以下代码:
glm::vec3 p2 = glm::vec3(10.0f, 5.0f, 2.0f);   
  • 使用 glm 也可以创建一个 4x4 矩阵,如下面的代码所示。一个 4x4 矩阵是 mat4 类型,可以创建如下:
glm::mat4 matrix = glm::mat4(1.0f); 

Here the 1.0f parameter passed in shows that the matrix is initialized as a identity matrix. 

  • 对于平移和旋转,您需要包含必要的 GLM 扩展,如下面的代码所示:
#include <glm/ext.hpp> 
glm::mat4 translation = glm::translate(glm::mat4(1.0f),  
glm::vec3(3.0f,4.0f, 8.0f)); 
  • 要将对象平移到其当前位置的 (3.0, 4.0, 8.0),请执行以下操作:
glm:: mat4 scale = glm::scale(glm::mat4(1.0f),  
glm::vec3( 2.0f, 2.0f, 2.0f));
  • 我们还可以将值缩放,使其在 xyz 方向上是原来大小的两倍:
glm::mat4 rxMatrix = glm::rotate(glm::mat4(), glm::radians(45.0f), glm::vec3(1.0, 0.0, 0.0)); 
glm::mat4 ryMatrix = glm::rotate(glm::mat4(), glm::radians(25.0f), glm::vec3(0.0, 1.0, 0.0)); 
glm::mat4 rzMatrix = glm::rotate(glm::mat4(), glm::radians(10.0f), glm::vec3(0.0, 0.0, 1.0)); 

上述代码通过 x 轴旋转对象 45,0f 度,通过 y 轴旋转 25.0f 度,通过 z 轴旋转 10.0f 度。

注意,我们在这里使用 glm::radians()。这个 glm 函数将度数转换为弧度。本章将介绍更多的 GLM 函数。

OpenGL 数据类型

OpenGL 也有它自己的数据类型。这些类型可以在不同平台上移植。

OpenGL 数据类型以 GL 前缀开头,后跟数据类型。因此,与 int 变量等效的 GL 类型是 GLint,依此类推。以下表格显示了一个 GL 数据类型的列表(列表可查看于 www.khronos.org/opengl/wiki/OpenGL_Type):

空间变换

3D 图形的主要任务是模拟一个 3D 世界,并将该世界投影到一个 2D 位置,即视口窗口。我们想要渲染的 3D 或 2D 对象不过是顶点的集合。然后这些顶点被组合成三角形的集合,以形成对象的球形:

左侧的截图显示了传入的顶点,而右侧的截图显示了顶点被用来创建三角形。每个三角形都形成了最终对象形状表面的一小部分。

本地/对象空间

当设置任何对象的顶点时,我们从坐标系的起点开始。这些顶点或点被放置,然后连接起来以创建对象的形状,例如三角形。围绕模型创建的这个坐标系被称为对象空间、模型空间或本地空间:

世界空间

现在我们已经指定了模型的形状,我们想要将它放置在一个场景中,以及一些其他形状,例如球体和立方体。立方体和球体形状也是使用它们自己的模型空间创建的。当我们将这些对象放置到 3D 场景中时,我们是以 3D 对象将被放置的坐标系为基准来进行的。这个新的坐标系被称为世界坐标系,或世界空间。

将对象从对象空间移动到世界空间是通过矩阵变换完成的。对象的局部位置乘以世界空间矩阵。因此,每个顶点都乘以世界空间矩阵,以将其缩放、旋转和位置从局部空间转换到世界空间。

世界空间矩阵是缩放、旋转和平移矩阵的乘积,如下面的公式所示:

世界矩阵 = W = T × R × S

S、R 和 T 分别是相对于世界空间局部空间的缩放、旋转和平移。让我们分别看看它们:

  • 3D 空间的缩放矩阵是一个 4x4 矩阵,其对角线表示xyz方向上的缩放,如下所示:

图片 4

  • 旋转矩阵可以采取三种形式,具体取决于你正在旋转对象的哪个轴。RxRyRz是我们用于沿每个轴旋转的矩阵,如下面的矩阵所示:

图片 1图片 2图片 3

  • 平移矩阵是一个单位矩阵,其中最后一列表示在xyz方向上的平移:

图片 6

现在,我们可以通过将对象的局部位置与世界矩阵相乘来获取世界位置,如下所示:

位置[世界] = 矩阵[世界] × 位置[局部]

视图空间

为了查看整个场景,我们需要一个相机。这个相机还将决定哪些对象对我们是可见的,哪些对象不会被渲染到屏幕上。

因此,我们可以在场景中放置一个虚拟相机,位于某个世界位置,如下面的图所示:

图片 5

场景中的对象随后从世界空间转换到一个新的坐标系,该坐标系位于相机位置。这个位于相机位置的新坐标系被称为视图空间、相机空间或视点空间。x轴是红色,y轴是绿色,正z轴是蓝色。

要将点从世界空间转换到相机空间,我们必须使用虚拟相机位置的负值来平移它们,并使用相机朝向的负值来旋转它们。

然而,使用 GLM 创建视图矩阵有一个更简单的方法。我们必须提供三个变量来分别定义相机位置、相机目标位置和相机向上向量:

   glm::vec3cameraPos = glm::vec3(0.0f, 0.0f, 200.0f); 
   glm::vec3cameraFront = glm::vec3(0.0f, 0.0f, 0.0f); 
   glm::vec3cameraUp = glm::vec3(0.0f, 1.0f, 0.0f); 

我们可以通过调用lookAt函数并传递相机位置、观察位置和向上向量来使用这些变量创建视图矩阵,如下所示:

   glm::mat4 viewMatrix = glm::lookAt(cameraPos, cameraPos + 
   cameraFront, cameraUp); 

一旦我们有了视图矩阵,局部位置可以通过乘以世界矩阵和视图矩阵来得到视图空间中的位置,如下所示:

位置[视图] = 视图[矩阵] × 世界[矩阵] × 位置[局部]

投影空间

下一个任务是将在相机中可见的 3D 对象投影到 2D 平面上。投影需要以这种方式进行,使得最远的对象看起来更小,而较近的对象看起来更大。基本上,在观察一个对象时,点需要汇聚到一个消失点上。

在下面的屏幕截图中,右边的图像显示了一个正在渲染的立方体。注意长边上的线条实际上是平行的。

然而,当从相机观察同一个盒子时,相同的侧面线会汇聚,并且当这些线被延伸时,它们会在盒子后面的一个点上汇聚:

图片

现在,我们将介绍另一个矩阵,称为投影矩阵,它允许使用透视投影渲染对象。对象的顶点将使用所谓的投影矩阵进行变换,以执行透视投影变换。

在投影矩阵中,我们定义了一个称为视锥体的投影体积。视锥体内部的所有对象将被投影到 2D 显示上。投影平面之外的对象将不会被渲染:

图片

投影矩阵的创建如下:

图片

q = 1/tan(FieldOfView/2)

A = q/Aspect Ratio

B = (zNear + zFar)/(zNear - zFar)

*C = 2 (zNear * zFar)/(zNear - zFar)

宽高比是投影平面的宽度除以投影平面的高度。zNear 是从相机到近平面的距离。zFar 是从相机到远平面的距离。视场角FOV)是视图视锥体上下平面之间的角度。

在 GLM 中,有一个我们可以用来创建透视投影矩阵的函数,如下所示:

GLfloat FOV = 45.0f; 
GLfloat width = 1280.0f; 
GLfloat height = 720.0f; 
GLfloat nearPlane = 0.1f; 
Glfloat farPlane = 1000.0f; 

glm::mat4 projectionMatrix = glm::perspective(FOV, width /height, nearPlane, farPlane); 

注意,nearPlane 总是必须大于 0.0f,这样我们才能在相机前方创建视锥体的起始部分。

glm::perspective 函数接受四个参数:

  • FOV

  • 宽高比

  • 到近平面的距离

  • 到远平面的距离

因此,在获得投影矩阵后,我们最终可以对我们的视图变换点执行透视投影变换,将顶点投影到屏幕上:

Position[final] = Projection[matrix] × View[matrix] × World[matrix] × Position[local]

现在我们从理论上理解了这一点,让我们看看我们如何实际实现它。

屏幕空间

在将局部位置乘以模型、视图和投影矩阵之后,OpenGL 会将场景转换到屏幕空间。

如果你的应用程序的屏幕大小分辨率为 1,280 x 720,那么它将像这样将场景投影到屏幕上;这是相机在视图空间中的观察结果:

图片

对于这个例子,窗口的宽度将是 1,280 像素,窗口的高度将是 720 像素。

渲染管线

如我之前所述,我们必须将由顶点和纹理组成的 3D 对象转换为 2D 屏幕上的像素,以便在屏幕上表示。这是通过所谓的渲染管线来完成的。以下图表解释了涉及的步骤:

图表

管道简单来说是一系列依次执行以实现特定目标的步骤。在前面的图表中,用橙色框(或如果你阅读的是黑白版本的这本书,则是浅色阴影框)突出显示的阶段是固定的,这意味着你不能修改这些阶段中数据的处理方式。蓝色或更深的框中的阶段是可编程阶段,这意味着你可以编写特殊的程序来修改输出。前面的图表显示了一个基本的管道,它包括我们完成渲染对象所需的最小阶段。还有一些其他阶段,如几何、细分和计算,这些都是可选阶段。然而,由于我们只介绍图形编程,这些内容将不会在本书中讨论。

图形管线本身对 OpenGL 和 Vulkan 都是通用的。然而,它们的实现方式不同,但我们将会在接下来的章节中看到这一点。

渲染管线用于将 2D 或 3D 对象渲染到电视或显示器上。让我们详细查看图形管线中的每个阶段。

顶点规范

当我们想要将对象渲染到屏幕上时,我们设置有关该对象的信息。我们需要设置的信息非常基本,即构成几何形状的点或顶点。我们将通过创建顶点数组来创建对象。这将用于创建构成我们想要渲染的几何形状的多个三角形。这些顶点需要被发送到图形管线。

例如,要向 OpenGL 中的管道发送信息,我们使用顶点数组对象VAO)和顶点缓冲区对象VBO)。VAO 用于定义每个顶点具有哪些数据;VBO 包含实际的顶点数据。

顶点数据可以有一系列属性。一个顶点可以具有属性属性,如位置、颜色、法线等。显然,任何顶点都需要的主要属性之一是位置信息。除了位置之外,我们还将查看可以传递的其他类型的信息,例如每个顶点的颜色。我们将在未来的章节中查看更多属性,在第三部分,现代 OpenGL 3D 游戏开发中,当我们介绍使用 OpenGL 渲染原语时。

假设我们有一个由三个点组成的数组。让我们看看我们如何创建VAOVBO

 float vertices[] = { 
    -0.5f, -0.5f, 0.0f, 
     0.5f, -0.5f, 0.0f, 
     0.0f,  0.5f, 0.0f 
};   

因此,让我们开始吧:

  1. 首先,我们生成一个 Glint 类型的顶点数组对象。OpenGL 返回一个实际对象的句柄,以便将来引用,如下所示:
unsigned int VAO; 
glGenVertexArrays(1, &VAO);
  1. 然后,我们生成一个顶点缓冲区对象,如下所示:
unsigned int VBO; 
glGenBuffers(1, &VBO);   
  1. 接下来,我们指定缓冲区对象类型。在这里,它是GL_ARRAY_BUFFER类型,这意味着这是一个数组缓冲区:
glBindBuffer(GL_ARRAY_BUFFER, VBO);   
  1. 然后,我们按照以下方式将数据绑定到缓冲区:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);  

第一个参数是数据缓冲区类型,它是GL_ARRAY_BUFFER类型。第二个参数是传入的数据类型的大小。sizeof()是 C++关键字,用于获取数据的字节数。

下一个参数是数据本身,而最后一个参数用于指定这些数据是否会改变。GL_STATIC_DRAW表示一旦值被存储,数据将不会被修改。

  1. 然后,我们指定顶点属性,如下所示:
  • 第一个参数是属性的索引。在这种情况下,我们只有一个位于第 0 个索引的位置属性。

  • 第二个是属性的大小。每个顶点由三个浮点数表示xyz,所以这里指定的值是3

  • 第三个参数是要传递的数据类型,它是GL_FLOAT类型。

  • 第四个参数是一个布尔值,询问值是否应该被归一化或直接使用。我们指定GL_FALSE,因为我们不希望数据被归一化。

  • 第五个参数称为步长;它指定了属性之间的偏移量。在这里,下一个位置的值是三个浮点数的大小,即xyz

  • 最后一个参数指定了第一个组件的起始偏移量,这里为 0。我们将数据类型转换为更通用的数据类型(void*),称为空指针:

 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 *  
 sizeof(float), (void*)0);  
  1. 最后,我们启用第 0 个索引处的属性,如下所示:
glEnableVertexAttribArray(0);

这是一个基本示例。当我们添加额外的属性,如颜色时,这会发生变化。

顶点着色器

顶点着色器阶段对每个顶点执行操作。根据你传递给顶点着色器的顶点数量,顶点着色器将被调用相应次数。如果你传递三个顶点来形成一个三角形,那么顶点着色器将被调用三次。

根据传入着色器的属性,顶点着色器会修改传入的值,并输出该属性的最终值。例如,当你传入一个位置属性时,你可以操作它的值,并在顶点着色器结束时发送出该顶点的最终位置。

以下代码是为之前传入的单个属性的基本顶点着色器:

#version 430 core 
layout (location = 0) in vec3 position; 

void main() 
{ 
    gl_Position = vec4(position.x, position.y, position.z, 1.0); 
} 

着色器是用类似于 C 的语言编写的程序。着色器总是以GL 着色器语言GLSL)的版本开始。还有其他着色器语言,包括高级着色语言HLSL),它被 Direct 3D 和 CG 使用。CG 也被用于 Unity。

现在,我们将声明我们想要使用的所有输入属性。为此,我们使用layout关键字,并在括号中指定我们想要使用的属性的位置或索引。由于我们传递了一个顶点位置属性,并为其指定了索引 0,同时指定了属性指针,我们将位置设置为 0。然后,我们使用in关键字来说明我们正在接收信息。我们将每个位置值存储在一个名为vec3的变量类型中,并附上名称位置。

vec3,这是一个变量类型,用于存储传递到着色器的向量,它是 GLSL 内建数据类型。在这里,由于我们传递了具有xyz分量的位置信息,使用vec3很方便。我们还有一个vec4,它有一个额外的w分量,可以用来存储颜色信息,例如。

每个着色器都需要有一个主函数,在其中执行与着色器相关的重大功能。在这里,我们并没有做太复杂的事情:我们只是获取vec3,将其转换为vec4,然后将值设置为gl_Position。我们必须将vec3转换为vec4,因为gl_Position是一个vec4。它也是 GLSL 的内建变量,用于存储和从顶点着色器输出值。

由于这是一个基本的顶点着色器示例,我们不会将每个点与ModelViewProjection矩阵相乘以将点转换到投影空间。我们将在本书的后面扩展这个示例。

顶点后处理

在这个阶段,会发生裁剪。不在相机可见锥体内的对象不会被渲染到屏幕上。这些未显示的原始对象被称为裁剪。

假设只有部分球体是可见的。原始对象被分解成更小的原始对象,并且只有可见的原始对象将被显示。原始对象的顶点位置将从裁剪空间转换到窗口空间。

例如,在下面的图中,只有球体、三角形和长方体的部分是可见的。形状的其余部分对相机不可见,因此它们已被裁剪:

图片

原始对象装配

原始对象装配阶段收集了上一阶段未裁剪的所有原始对象,并创建了一系列原始对象。

在这个阶段也会执行面裁剪。面裁剪是一个过程,其中位于视图前方但朝向背面的原始对象将被裁剪,因为它们是不可见的。例如,当你观察一个立方体时,你只能看到立方体的前面,而不是背面,因此当背面不可见时,渲染立方体的背面是没有意义的。这被称为背面裁剪。

光栅化

GPU 需要将用向量描述的几何形状转换为像素。我们称之为光栅化。通过裁剪和剔除之前阶段的原始图形将被进一步处理,以便它们可以被光栅化。光栅化过程从这些原始图形中创建一系列片段。将原始图形转换为光栅化图像的过程由 GPU 完成。在光栅化过程中(从向量到像素),我们总是丢失信息,因此得名“原始片段的碎片”。片段着色器用于计算最终像素的颜色值,该值将被输出到屏幕。

片段着色器

光栅化阶段的片段随后将使用片段着色器进行处理。就像顶点着色器阶段一样,片段着色器也是一个程序,可以编写以对每个片段进行修改。

对于之前阶段的每个片段,将调用片段着色器。

这是一个基本片段着色器的示例:

#version 430 core 
out vec4 Color; 

void main() 
{ 
    Color = vec4(0.0f, 0.0f, 1.0f, 1.0f); 
} 

就像顶点着色器一样,你需要指定要使用的 GLSL 版本。

我们使用out关键字从片段着色器发送输出值。在这里,我们想要发送一个名为Colorvec4类型的变量。主函数是所有魔法发生的地方。对于每个被处理的片段,我们将Color的值设置为蓝色。因此,当原始图形被渲染到视口时,它将完全呈现蓝色。

这就是球体变成蓝色的原因。

每样本操作

与顶点后处理阶段裁剪原始图形的方式相同,每样本操作也会移除不会显示的片段。一个片段是否需要在屏幕上显示取决于用户可以启用的某些测试。

其中一个更常用的测试是深度测试。当启用时,这将检查一个片段是否位于另一个片段之后。如果是这种情况,则当前片段将被丢弃。例如,在这里,由于它位于灰色球体之后,只有部分立方体是可见的:

图片

我们还可以执行其他测试,例如裁剪和模板测试,这些测试将根据我们指定的某些条件只显示屏幕或对象的一部分。

颜色混合也在此阶段进行。在这里,基于某些混合方程,颜色可以进行混合。例如,在这里,球体是透明的,因此我们可以看到立方体的颜色与球体的颜色混合:

图片

帧缓冲区

最后,当一帧中所有片段的每样本操作完成后,最终图像将被渲染到帧缓冲区,然后呈现在屏幕上。

帧缓冲区是一组每帧绘制的图像。这些图像中的每一个都附加到帧缓冲区上。帧缓冲区有附加物,例如显示在屏幕上的颜色图像。还有其他附加物,例如深度或图像/纹理;这仅仅存储了每个像素的深度信息。最终用户看不到这些,但有时游戏会为了图形目的使用它。

在 OpenGL 中,帧缓冲区在开始时自动创建。也存在用户创建的帧缓冲区,可以用来首先渲染场景,对其应用后处理,然后将它交还给系统以便在屏幕上显示。

摘要

在本章中,我们介绍了本书中将要用到的一些数学基础知识。特别是,我们学习了坐标系、点、向量和矩阵。然后,我们学习了如何将这些概念应用到 OpenGL 数学和空间变换中。之后,我们了解了 GLM,这是一个数学库,我们将使用它来简化我们的数学计算。最后,我们涵盖了空间变换并理解了图形管道的流程。

在下一章中,我们将探讨如何使用像 SFML 这样的简单框架来制作 2D 游戏

第二部分:SFML 2D 游戏开发

我们将创建一个基本的横向卷轴 2D 动作游戏,其中我们将涵盖基本游戏概念,包括创建游戏循环,使用 SFML 渲染 2D 游戏场景,2D 精灵创建,2D 精灵动画,UI 文本和按钮,物理和碰撞检测。

以下章节在本节中:

第三章,设置您的游戏

第四章, 创建您的游戏

第五章,完成您的游戏

第三章:设置您的游戏

在本章中,我们将从游戏制作的基础知识以及游戏所需的基本图形组件开始。由于本书将涵盖使用 C++ 的图形,我们将主要关注游戏中图形引擎所需的图形功能。我们还将介绍音效系统,以便使游戏更加有趣。

为了创建一个基本的图形引擎,我们将使用简单快速多媒体库SFML),因为它包含了启动游戏所需的大部分功能。选择 SFML 的原因是它非常基础且易于理解,与其他引擎和框架不同。

在本章中,我们将为我们的游戏创建一个窗口并向其中添加动画。我们还将学习如何创建和控制玩家的移动。

本章涵盖了以下主题:

  • SFML 概述

  • 下载 SFML 并配置 Visual Studio

  • 创建窗口

  • 绘制形状

  • 添加精灵

  • 键盘输入

  • 处理玩家移动

SFML 概述

游戏(与其它娱乐媒体不同)实际上涉及加载各种资源,如图像、视频、声音、字体类型等。SFML 提供了将所有这些功能加载到游戏中的函数。

SFML 兼容多个平台,这意味着它允许你在不同的平台上开发和运行游戏。它还支持除 C++ 之外的各种语言。此外,它是开源的,因此你可以查看源代码并向其添加功能(如果尚未包含)。

SFML 被分解为五个模块,可以定义为以下内容:

  • 系统:此模块直接与系统(如 Windows)交互,该系统本质上将是它将使用的操作系统OS)。由于 SFML 兼容多个平台,并且每个操作系统在处理数据方面都有所不同,因此此模块负责与操作系统交互。

  • 窗口:在屏幕上渲染任何内容时,我们首先需要的是一个视口或窗口。一旦我们获得访问权限,我们就可以开始将渲染的场景发送到它。窗口模块负责窗口的创建、输入处理等。

  • 图形:在我们获得窗口访问权限后,我们可以使用图形模块开始将场景渲染到窗口中。在 SFML 中,图形模块主要使用 OpenGL 进行渲染,仅处理 2D 场景渲染。因此,它不能用于制作 3D 游戏。

  • 音频:音频模块负责播放音频和音频流,以及录音。

  • 网络:SFML 还包括一个用于发送和接收数据的网络库,可用于开发多人游戏。

下载 SFML 并配置 Visual Studio

现在我们已经熟悉了基础知识,让我们开始吧:

  1. 导航到 SFML 下载页面 (www.sfml-dev.org/download.php):

图片

  1. 选择SFML 2.5.1。或者,您也可以克隆仓库并使用 CMake 构建最新版本。

  2. 下载 32 位或 64 位版本(取决于您的操作系统)用于 Visual Studio 2017。

虽然我们将为 Windows 开发游戏,但您可以从同一网页下载 SFML 的 Linux 或 macOS 版本。

在下载的 ZIP 文件中,您将看到以下目录:

图片

这些目录可以定义如下:

  • bin:这里包含运行所有 SFML 模块所需的全部动态链接库(DLLs)。这包含一个.dll文件,其中包含调试和发布版本。调试版本在文件末尾有一个-d后缀。没有这个后缀的文件是发布版本的.dll文件。

  • doc:这里包含以 HTML 格式提供的 SFML 文档。

  • examples:这里包含我们可以用来实现 SFML 模块和功能的示例。它告诉我们如何打开窗口、包含 OpenGL、执行网络操作以及如何创建一个基本的 pong 游戏。

  • include:这里包含所有模块的头文件。图形模块有用于创建精灵、加载纹理、创建形状等类的类。

  • lib:这里包含我们运行 SFML 所需的全部库文件。

此外,还有两个文件:readme.mdlicense.md。许可文件表明 SFML 可用于商业目的。因此,它可以被修改和重新分发,前提是你不要声称是你创建的。

  1. 要设置 Visual Studio 项目,创建一个名为SFMLProject的新项目。在这个 Visual Studio 项目根目录中,SFMLProject.vcxproj文件所在的位置,提取SFML-2.5.1文件夹并将其放置在此处。

  2. 然后,在根目录中,将.bin文件夹中的所有.dll文件移动到根目录。您的项目根目录应类似于以下截图:

图片

  1. 在 Visual Studio 项目中,创建一个新的source.cpp文件。

  2. 接下来,通过在解决方案资源管理器中右键单击项目来打开项目属性。

  3. 确保选择了 Win32 配置。在配置属性下,选择 VC++目录。将$(ProjectDir)\SFML-2.5.1\include添加到包含目录。然后,将$(ProjectDIr)\SFML-2.5.1\lib添加到库目录,如以下截图所示:

图片

$(ProjectDir)关键字始终确保文件是相对于项目目录进行搜索的,即.vcxproj文件所在的目录。这使得项目可移植,并且能够在任何 Windows 系统上运行。

  1. 接下来,我们必须设置我们想要使用的库;在链接器下拉菜单中选择“输入”,并输入以下.lib文件:

图片

虽然我们在这本书中不会使用sfml-network-d.lib,但最好还是包含它,这样,如果您以后想要制作多人游戏,您就已经为它做好了准备。

现在我们已经完成了设置,我们终于可以开始编写一些代码了。

创建窗口

在我们绘制任何东西之前,我们首先需要的是一个窗口,这样我们才能在屏幕上显示一些内容。让我们创建一个窗口:

  1. source.cpp文件的顶部,包含Graphics.hpp文件以访问 SFML 图形库:
#include "SFML-2.5.1\include\SFML\Graphics.hpp"  
  1. 接下来,添加主函数,它将是应用程序的主要入口点:
int main(){ 

return 0; 
}  
  1. 要创建窗口,我们必须指定我们想要创建的窗口大小。SFML 有一个Vector2f数据类型,它接受一个x值和一个y值,并使用它们来定义我们将要使用的窗口大小。

includemain之间添加以下代码行。创建一个名为viewSize的变量,并将xy值分别设置为1024768

sf::Vector2f viewSize(1024, 768); 

游戏资源是为分辨率创建的,因此我正在使用这个视图大小;我们还需要指定一个viewMode类。

viewMode是一个 SFML 类,用于设置窗口的宽度和高度。它还获取表示像素中颜色值的位数。viewMode还获取您的显示器支持的分辨率,这样您就可以让用户将游戏的分辨率设置为所需的 4K 分辨率。

  1. 要设置视图模式,在设置viewSize变量后添加以下代码:
sf::videoMode vm(viewSize.x, viewSize.y);  
  1. 现在,我们终于可以创建一个窗口了。窗口是通过RenderWindow类创建的。RenderWindow构造函数接受三个参数:一个viewMode参数、一个窗口名称参数和一个Style参数。

我们已经创建了一个viewMode参数,并且我们可以使用一个字符串在这里传递窗口名称。第三个参数,StyleStyle是一个enum值;我们可以添加一个称为位掩码的数值,以创建我们想要的窗口样式:

  • sf::style::Titlebar:这将在窗口顶部添加一个标题栏。

  • sf::style::Fullscreen:这创建了一个全屏模式的窗口。

  • sf::style::Default:这是默认样式,它结合了调整窗口大小、关闭窗口和添加标题栏的能力。

  1. 让我们创建一个默认样式的窗口。首先,使用以下命令创建窗口,并在创建viewMode参数后添加它:
sf::RenderWindow window(vm, "Hello SFMLGame !!!", sf::Style::Default); 
  1. main()类中,我们将创建一个while循环,它将处理游戏的主循环。这将检查窗口是否打开,这样我们就可以通过更新和渲染场景中的对象来添加一些键盘事件。while循环将在窗口打开时运行。在main函数中添加以下代码:
int main() { 

   //Initialize Game Objects 

         while (window.isOpen()) { 

               // Handle Keyboard Events 
               // Update Game Objects in the scene 
               // Render Game Objects  

         } 

   return 0; 
}

现在,运行应用程序。在这里,你有一个不那么有趣的带有白色背景的窗口。嘿,至少你现在有一个窗口了!要在这里显示某些内容,我们必须清除窗口并在每一帧中显示我们绘制的任何内容。这是通过使用cleardisplay函数来完成的。

  1. 在我们可以渲染场景之前,我们必须调用window.clear(),然后调用window.display()来显示场景对象。

while循环中,添加cleardisplay函数。游戏对象将在clear函数和display函数之间绘制:

int main() { 

   //init game objects 
         while (window.isOpen()) { 
               // Handle Keyboard events 
               // Update Game Objects in the scene 
    window.clear(sf::Color::Red);                
 // Render Game Objects  
window.display();
         } 
   return 0; 
} 

clear函数接受一个清除颜色。在这里,我们将颜色红色作为值传递给函数。此函数用这种实色值填充整个窗口:

绘制形状

SFML 为我们提供了绘制基本形状(如矩形、圆形和三角形)的功能。形状可以设置到一定的大小,并具有如fillColorPositionOrigin等函数,这样我们就可以分别设置颜色、形状在视口中的位置以及形状可以围绕其旋转的原点。让我们看看一个矩形形状的例子:

  1. while循环之前,添加以下代码来设置矩形:

   sf::RectangleShape rect(sf::Vector2f(500.0f, 300.0f)); 
   rect.setFillColor(sf::Color::Yellow); 
   rect.setPosition(viewSize.x / 2, viewSize.y / 2); 
   rect.setOrigin(sf::Vector2f(rect.getSize().x / 2, 
   rect.getSize().y / 2)); 

在这里,我们创建了一个Rectangle参数,其类型为RectangleShape,并命名为rectRectangleShape的构造函数接受矩形的尺寸。其尺寸为500 x 300。然后,我们将矩形的颜色设置为黄色。之后,我们将矩形的设置在视口的中心,并将原点设置为矩形的中心。

  1. 要绘制矩形,我们必须调用window.draw()函数并将矩形传递给它。确保你在while循环中的cleardisplay函数之间调用此函数。现在,添加以下代码:

   #include "SFML-2.5.1\include\SFML\Graphics.hpp" 

   sf::Vector2f viewSize(1024, 768); 
   sf::VideoMode vm(viewSize.x, viewSize.y); 
   sf::RenderWindow window(vm, "Hello Game SFML !!!", 
   sf::Style::Default); 

   int main() { 

   //init game objects 

   sf::RectangleShape rect(sf::Vector2f(500.0f, 300.0f));
 rect.setFillColor(sf::Color::Yellow);
 rect.setPosition(viewSize.x / 2, viewSize.y / 2);
 rect.setOrigin(sf::Vector2f(rect.getSize().x / 2, 
   rect.getSize().y / 2));
         while (window.isOpen()) { 
               // Handle Keyboard events 
               // Update Game Objects in the scene 

               window.clear(sf::Color::Red); 

               // Render Game Objects  
               window.draw(rect);

               window.display(); 
         } 
   return 0; 
} 
  1. 现在,运行项目;你将看到一个黄色的矩形在红色的视口中,如下所示:

  1. 如果我们将位置设置为(0, 0),你将看到在 SFML 中的 2D 矩形的原点在哪里——它在视口的左上角:

  1. 通过撤销上一个动作将其移回视口的中心。然后,再次将矩形设置为视口的中心,如下所示:
rect.setPosition(viewSize.x / 2, viewSize.y / 2);
  1. 现在,我们可以添加一些更多的形状,例如圆形和三角形。我们可以使用CircleShape类来创建圆形,而使用ConvexShape类来创建三角形。在主循环之前,我们将使用CircleShapeTriangleConvexShape创建一个圆形,如下所示:
   sf::CircleShape circle(100); 
   circle.setFillColor(sf::Color::Green); 
   circle.setPosition(viewSize.x / 2, viewSize.y / 2); 
   circle.setOrigin(sf::Vector2f(circle.getRadius(), 
   circle.getRadius())); 
   sf::ConvexShape triangle; 
   triangle.setPointCount(3); 
   triangle.setPoint(0, sf::Vector2f(-100, 0)); 
   triangle.setPoint(1, sf::Vector2f(0, -100)); 
   triangle.setPoint(2, sf::Vector2f(100, 0)); 
   triangle.setFillColor(sf::Color(128, 0, 128, 255)); 
   triangle.setPosition(viewSize.x / 2, viewSize.y / 2); 

CircleShape类只接受一个参数(即圆的半径),与需要两个参数的矩形相比。我们使用setFillColor函数将圆的颜色设置为绿色,然后设置其位置和原点。

要创建三角形,我们使用ConvexShape类。要创建一个形状,我们指定setPointCount,它接受一个参数。我们将使用它来指定组成形状的点数。接下来,使用setPoint函数,我们设置点的位置。这需要两个参数:第一个是点的索引,第二个是点的位置。

要创建三角形,我们使用三个点:第一个,索引为0,位置为(-100, 0);第二个,索引为1,位置为(0, -100);第三个,索引为2,位置为(100, 0)

现在,我们需要设置三角形的颜色。我们通过设置红色、绿色、蓝色和 alpha 值来实现这一点。在 SFML 中,颜色是 8 位整数值。这意味着每个颜色范围在 0 到 255 之间,其中 0 是黑色,255 是最大颜色范围。所以,当我们把三角形的颜色设置为triangle.setFillColor(sf::Color(128, 0, 128, 255));时,红色是其最大范围的二分之一,没有绿色,蓝色也是最大范围的二分之一,alpha 值为255,使三角形完全不透明。然后,我们设置三角形的位置,使其位于屏幕中心。

  1. 接下来,我们绘制圆形和三角形。在绘制矩形之后,调用圆形和三角形的draw函数,如下所示:
  while (window.isOpen()) { 
               // Handle Keyboard events 
               // Update Game Objects in the scene 

               window.clear(sf::Color::Red); 

               // Render Game Objects  
               window.draw(rect); 
               window.draw(circle);
 window.draw(triangle);

               window.display(); 
         } 
  1. 上述代码的输出如下:

图片

注意,在创建三角形时,第二个点的y值被设置为负100

triangle.setPoint(0, sf::Vector2f(-100, 0)); 
triangle.setPoint(1, sf::Vector2f(0, -100)); 
triangle.setPoint(2, sf::Vector2f(100, 0)); 

然而,三角形是向上指的。这意味着+y轴是向下的。你会发现这种情况在 2D 框架中很常见。此外,场景的原点位于左上角,因此坐标系如下:

图片

还需要注意的是,绘制顺序很重要。绘制是从后往前进行的。因此,首先绘制的形状将位于同一位置上后来绘制的形状之后。后来绘制的对象只是简单地覆盖了先前的对象,就像艺术家在画布上绘画时在现实生活中所做的那样。所以,请确保先绘制较大的对象,然后稍后绘制较小的对象。如果你在绘制较大的对象之前绘制了较小的对象,那么较小的对象将位于较大的对象之后,你将看不到它们。确保这种情况不会发生,因为你不会得到任何错误,代码中的所有内容都将正确,所以你不会知道是否出了问题。

添加精灵

精灵是一个应用了图片的矩形。你可能想知道,“为什么不直接使用图片呢?”当然,我们确实加载了一张图片,然后我们无法移动或旋转它。因此,我们将图片或纹理应用到可以移动和旋转的矩形上,使其看起来像图片在这样做。让我们学习如何做到这一点:

  1. 由于我们将在游戏项目中加载图像,该游戏项目位于项目的根目录,因此让我们创建一个名为 Assets 的文件夹。

  2. 在此文件夹中,创建一个名为 graphics 的子文件夹,然后将 sky.png 文件复制粘贴到 graphics 文件夹中:

图片

要创建精灵,我们使用 SFML 的 Sprite 类。Sprite 类接受一个纹理。然后,使用 Texture 类加载图片。在绘制时,你需要调用 window.draw.(sprite) 来绘制精灵。让我们看看如何做到这一点。

  1. 声明一个名为 skyTextureTexture 类和一个名为 skySpriteSprite 类。这应该在创建 RenderWindow 类之后完成:
sf::Texture skyTexture; 
sf::Sprite skySprite;
  1. source.cpp 文件中创建一个名为 init 的新函数,它位于 main 函数之前。由于我们不希望 main 函数过于杂乱,我们将添加初始化 skyTextureskySprite 的代码到其中。在 init 函数中,添加以下代码:
void init() { 

// Load sky Texture 
   skyTexture.loadFromFile("Assets/graphics/sky.png"); 

// Set and  Attacha Texture to Sprite 
   skySprite.setTexture(skyTexture); 

} 

首先,我们通过调用 loadFromFile 函数加载 skyTexture 函数。我们传入要加载的文件的路径和文件名。在这里,我们想从 Assets 文件夹中加载 sky.png 文件。

接下来,我们使用精灵的 setTexture 函数,并将 skyTexture 函数传递给它。

  1. 要做到这一点,在 maininit 函数上方创建一个名为 draw() 的新函数。我们在其中调用 draw (skySprite`) 来绘制精灵,如下所示:
void draw() { 

   window.draw(skySprite); 

} 
  1. 现在,我们必须在 main 函数的开始处调用 init(),并在我们添加到 main 函数的 while 循环中调用 draw()。你可以从 main 函数中删除用于创建和绘制形状的所有代码。你的 main 函数应该如下所示:
 #include "SFML-2.5.1\include\SFML\Graphics.hpp" 

sf::Vector2f viewSize(1024, 768); 
sf::VideoMode vm(viewSize.x, viewSize.y); 
sf::RenderWindow window(vm, "Hello Game SFML !!!", sf::Style::Default); 

sf::Texture skyTexture; 
sf::Sprite skySprite; 

void init() { 

   skyTexture.loadFromFile("Assets/graphics/sky.png"); 
   skySprite.setTexture(skyTexture); 

} 

void draw() { 

   window.draw(skySprite); 

} 

int main() { 

   init(); 

   while (window.isOpen()) { 

         window.clear(sf::Color::Red); 

         draw(); 

         window.display(); 

   } 

   return 0; 
} 

输出如下:

图片

向太阳致敬!瞧,我们已经加载了天空纹理,并在窗口中以精灵的形式绘制了它。

  1. 我还包含了一张背景纹理图片,名为 bg.png,它位于本章项目的 Assets 文件夹中。尝试以相同的方式加载纹理并绘制纹理。

  2. 我将背景纹理和精灵的变量分别命名为 bgTexturebgSprite,并将 bgSprite 变量绘制到场景中。别忘了将 bg.png 文件添加到 Assets/graphics 目录。

你的场景现在应该如下所示:

图片

  1. 接下来,添加另一个名为 heroSprite 的精灵,并使用 heroTexture 加载图片。将精灵的原点设置为它的中心,并将其放置在场景的中间。这里提供了 hero.png 文件图像,确保将其放置在 Assets/graphics 文件夹中。现在,声明 heroSpriteheroTexture,如下所示:
sf::Texture heroTexture; 
sf::Sprite heroSprite; 

In the init function initialize the following values: 
   heroTexture.loadFromFile("Assets/graphics/hero.png"); 
   heroSprite.setTexture(heroTexture); 
   heroSprite.setPosition(sf::Vector2f(viewSize.x/2, 
      viewSize.y/2)); 
   heroSprite.setOrigin(heroTexture.getSize().x / 2, 
      heroTexture.getSize().y / 2);  
  1. 要设置精灵的原点,我们将纹理和高度除以 2

使用 draw 函数绘制 heroSprite 精灵,如下所示:

void draw() { 
   window.draw(skySprite); 
   window.draw(bgSprite); 
   window.draw(heroSprite);
}
  1. 我们的英雄现在将出现在场景中,如下所示:

图片

键盘输入

能够添加形状、精灵和纹理是很好的,然而,计算机游戏本质上都是交互式的。我们需要允许玩家使用键盘输入,以便他们可以访问游戏内容。但我们如何知道玩家按下了哪个按钮呢?嗯,这是通过事件轮询来处理的。轮询只是定期检查键的状态;事件用于检查是否触发了事件,例如视口的关闭。

SFML 提供了 sf::Event 类,以便我们可以轮询事件。我们可以使用窗口的 pollEvent 函数来检查可能发生的事件,例如玩家按下按钮。

创建一个名为 updateInput() 的新函数。在这里,我们将创建一个名为 event 的新 sf::Event 类对象。我们将创建一个名为 window.pollEventwhile 循环,并将 event 变量传递进去以检查事件。

到目前为止,我们一直在使用 Shift + F5 或 Visual Studio 中的停止按钮来停止应用程序。我们可以做的基本事情之一是检查 Esc 键是否被按下。如果被按下,我们希望关闭窗口。为此,添加以下代码:

void updateInput() { 

   sf::Event event; 

   while (window.pollEvent(event)) { 

if (event.key.code == sf::Keyboard::Escape || 
         event.type ==sf::Event::Closed) 
                     window.close(); 
   } 

} 

while 循环中,我们需要检查事件键码是否是 Esc 键码,或者事件是否是 Event::closed。然后,我们调用 window.close() 函数来关闭窗口。当我们关闭窗口时,它将关闭应用程序。

在主 while 循环中在 window.clear() 函数之前调用 updateInput() 函数。现在,当应用程序运行时按下 Esc,它将关闭。SFML 不只是限制输入到键盘;它还提供了鼠标、摇杆和触摸输入的功能。

处理玩家移动

现在我们已经可以访问玩家的键盘,我们可以学习如何移动游戏对象。当键盘上按下右箭头键时,让我们将玩家角色向右移动。当右箭头键释放时,我们将停止移动英雄:

  1. heroSprite 之后创建一个全局的 Vector2f 叫做 playerPosition

  2. 创建一个布尔数据类型 playerMoving 并将其设置为 false

  3. updateInput 函数中,我们将检查右键是否被按下或释放。如果按钮被按下,我们将 playerMoving 设置为 true。如果按钮被释放,则将 playerMoving 设置为 false

updateInput 函数应该如下所示:

void updateInput() { 

   sf::Event event; 

   while (window.pollEvent(event)) { 

         if (event.type == sf::Event::KeyPressed) { 

               if (event.key.code == sf::Keyboard::Right) { 

                     playerMoving = true; 
               } 
         }           
         if (event.type == sf::Event::KeyReleased) { 

               if (event.key.code == sf::Keyboard::Right) { 
                     playerMoving = false; 
               }               
         } 

         if (event.key.code == sf::Keyboard::Escape || event.type 
         == sf::Event::Closed) 
               window.close();  
   } 
}
  1. 为了更新场景中的对象,我们将创建一个名为 update 的函数,它将接受一个名为 dt 的浮点数。这代表时间差,指的是上一次更新和当前更新调用之间经过的时间。在 update 函数中,我们将检查玩家是否在移动。如果玩家在移动,那么我们将沿着 +x 方向移动玩家的位置,并将其乘以 dt

我们乘以时间步长(delta time)的原因是,如果不这样做,更新将不会是时间依赖的,而是处理器依赖的。如果你不将位置乘以 dt,那么在更快的电脑上更新将会更快,而在较慢的电脑上则会更慢。所以,确保任何移动都是乘以 dt

update 函数应该如下所示。确保这个函数出现在 main 函数之前:

void update(float dt) { 

   if (playerMoving) { 
         heroSprite.move(50.0f * dt, 0); 
   } 
} 
  1. main 函数的开始处,创建一个名为 Clocksf::Clock 类型的对象。Clock 类负责获取系统时钟,并允许我们以秒、毫秒或微秒为单位获取时间步长。

  2. while 循环中,在调用 updateInput() 之后,创建一个名为 dtsf::Time 类型的变量,并通过调用 clock.restart() 来设置 dt 变量。

  3. 现在,调用 update 函数并传入 dt.asSeconds(),这将给出每秒 60 帧的时间步长,大约是 .0167 秒。

main 函数应该如下所示:


int main() { 

   sf::Clock clock; 
   init(); 
   while (window.isOpen()) { 

         // Update input 
         updateInput(); 

         // Update Game 
         sf::Time dt = clock.restart(); 
         update(dt.asSeconds()); 

   window.clear(sf::Color::Red); 

         //Draw Game  
         draw(); 

         window.display();   
   }  
   return 0; 
} 
  1. 现在,当你运行项目并按键盘上的右箭头键时,玩家将开始向右移动,并且当你释放右箭头键时停止:

图片

摘要

在本章中,我们探讨了如何设置 SFML 以便我们可以开始创建游戏。我们涵盖了构成 SFML 的五个基本模块,并查看如何使用 SFML 创建形状,以及将背景和玩家精灵添加到场景中。我们还添加了键盘输入,并使用它来使玩家角色在场景内移动。

在下一章中,我们将创建游戏的基本框架。我们还将把玩家角色移动到单独的类中,并为角色添加一些基本的物理属性,以便它们可以在游戏中跳跃。

第四章:创建您的游戏

在本章中,我们将通过将游戏对象作为类添加而不是将它们添加到 source.cpp 文件中来使我们的项目更加灵活。在这种情况下,我们将使用类来创建主要角色和敌人。我们将创建一个新的火箭类,玩家将能够向敌人射击。当我们按下按钮时,我们将定期生成敌人以及新的火箭。最后,我们将检查火箭和敌人之间的碰撞,并相应地从场景中移除敌人。

本章将涵盖以下主题:

  • 从头开始

  • 创建 Hero

  • 创建 Enemy

  • 添加敌人

  • 创建 Rocket

  • 添加火箭

  • 碰撞检测

从头开始

由于我们将为主要角色创建一个新的类,我们将从主文件中删除与玩家角色相关的代码。让我们学习如何做到这一点。

main.cpp 文件中删除所有与玩家相关的代码。完成此操作后,文件应如下所示:

#include "SFML-2.5.1\include\SFML\Graphics.hpp" 

sf::Vector2f viewSize(1024, 768); 
sf::VideoMode vm(viewSize.x, viewSize.y); 
sf::RenderWindow window(vm, "Hello SFML Game !!!", sf::Style::Default); 

sf::Vector2f playerPosition; 
bool playerMoving = false; 

sf::Texture skyTexture; 
sf::Sprite skySprite; 

sf::Texture bgTexture; 
sf::Sprite bgSprite; 

void init() { 

   skyTexture.loadFromFile("Assets/graphics/sky.png"); 
   skySprite.setTexture(skyTexture); 

   bgTexture.loadFromFile("Assets/graphics/bg.png"); 
   bgSprite.setTexture(bgTexture); 

} 

void updateInput() { 

   sf::Event event; 

   // while there are pending events... 
   while (window.pollEvent(event)) { 

      if (event.key.code == sf::Keyboard::Escape || event.type == 
          sf::Event::Closed) 
         window.close(); 

   } 

} 

void update(float dt) { 

} 

void draw() { 

   window.draw(skySprite); 
   window.draw(bgSprite); 

} 

int main() { 

   sf::Clock clock; 
   window.setFramerateLimit(60); 

   init(); 

   while (window.isOpen()) { 

      updateInput(); 

      sf::Time dt = clock.restart(); 
      update(dt.asSeconds()); 

      window.clear(sf::Color::Red); 

      draw(); 

      window.display(); 

   } 

   return 0; 
} 

创建 Hero

我们现在将按照以下步骤继续创建一个新类:

  1. 在解决方案资源管理器中选择项目,然后右键单击并选择添加 | 类。在这个类名中,指定名称为 Hero。您将看到 .h.cpp 文件部分将自动填充为 Hero.hHero.cpp。点击确定。

  2. Hero.h 文件中,添加 SFML 图形头文件并创建 Hero 类:

#include "SFML-2.5.0\include\SFML\Graphics.hpp" 

class Hero{ 

}; 
  1. Hero 类中,我们将创建类所需的函数和变量。我们还将创建一些公共属性,这些属性可以在类外部访问,如下所示:
public: 
   Hero(); 
   ~Hero(); 

   void init(std::string textureName, sf::Vector2f position, float 
   mass); 
   void update(float dt); 
   void jump(float velocity); 
   sf::Sprite getSprite(); 

这里,我们有构造函数和析构函数,它们将在对象创建和销毁时分别被调用。我们添加了一个 init 函数,用于传递纹理名称、生成玩家并指定质量。我们在这里指定质量是因为我们将创建一些非常基础的物理效果,这样当按下跳跃按钮时,玩家将跳起来并安全着地。

此外,updatejumpgetSprite 函数将分别更新玩家位置、使玩家跳跃和获取用于描绘玩家角色的精灵。

  1. 除了这些 public 变量之外,我们还需要一些只能在类内部访问的 private 变量。在 Hero 类中添加这些变量,如下所示:
private: 

   sf::Texture m_texture; 
   sf::Sprite m_sprite; 
   sf::Vector2f m_position; 

int jumpCount = 0;    
float m_mass; 
   float m_velocity; 
   const float m_gravity = 9.80f; 
      bool m_grounded; 

private 部分中,我们为 texturespriteposition 创建变量,以便我们可以本地设置这些值。我们有一个名为 jumpCountint 变量,这样我们就可以检查玩家角色跳跃的次数。这需要因为玩家有时可以双跳,这是我们不想看到的。

我们还需要float变量来存储玩家的质量、跳跃时的速度以及下落回地面时的重力,这是一个常数。const关键字告诉程序这是一个常数,在任何情况下都不应该改变其值。

最后,我们添加一个bool值来检查玩家是否在地面上。只有当玩家在地面上时,他们才能开始跳跃。

  1. 接下来,在Hero.cpp文件中,我们将实现.h文件中添加的函数。在.cpp文件的顶部,包含Hero.h文件,然后添加构造函数和析构函数:
#include "Hero.h" 

Hero::Hero(){ 

} 

::符号代表作用域解析运算符。具有相同名称的函数可以在两个不同的类中定义。为了访问特定类的成员,使用作用域解析运算符。

  1. 这里,Hero函数的作用域限定在Hero类中:
 Hero::~Hero(){ 

}
  1. 接下来,我们将设置init函数,如下所示:
void Hero::init(std::string textureName, sf::Vector2f position, float mass){ 

   m_position = position; 
   m_mass = mass; 

   m_grounded = false; 

   // Load a Texture 
   m_texture.loadFromFile(textureName.c_str()); 

   // Create Sprite and Attach a Texture 
   m_sprite.setTexture(m_texture); 
   m_sprite.setPosition(m_position); 
   m_sprite.setOrigin(m_texture.getSize().x / 2, 
   m_texture.getSize().y / 2); 

} 

我们将位置和质量设置为局部变量,并将接地状态设置为false。然后,通过调用loadFromFile并传入纹理名称的字符串来设置纹理。c_str()短语返回一个指向包含空终止序列的字符数组的指针(即C字符串),表示当前string对象的价值(www.cplusplus.com/reference/string/string/c_str/)。然后,我们设置精灵纹理、位置和精灵本身的原始位置。

  1. 现在,我们添加了update函数,在其中我们实现了更新玩家位置的逻辑。玩家的角色不能左右移动;相反,它只能向上移动,即y方向。当施加初始速度时,玩家会向上跳起,然后由于重力开始下落。将update函数添加到更新英雄位置如下:
void Hero::update(float dt){ 

   m_force -= m_mass * m_gravity * dt; 

   m_position.y -= m_force * dt; 

   m_sprite.setPosition(m_position); 

   if (m_position.y >= 768 * 0.75f) { 

      m_position.y = 768 * 0.75f; 
      m_force = 0; 
      m_grounded = true; 
      jumpCount = 0; 
   } 

}  

当速度施加到角色上时,玩家最初会因为力量而向上移动,但随后会开始下落,因为重力。结果速度向下作用,其计算公式如下:

速度 = 加速度 × 时间

我们将加速度乘以质量,以便玩家下落得更快。为了计算垂直移动的距离,我们使用以下公式:

距离 = 速度 × 时间

然后,我们计算前一个帧和当前帧之间的移动距离。然后,根据我们计算出的位置设置精灵的位置。

我们还有一个条件来检查玩家是否在屏幕底部的四分之一距离处。我们将其乘以768,这是窗口的高度,然后乘以.75f,此时玩家被认为是站在地面上。如果满足这个条件,我们设置玩家的位置,设置结果速度为0,设置地面布尔值为true,最后将跳跃计数器重置为0

  1. 当我们想要让玩家跳跃时,我们调用jump函数,该函数需要一个初始速度。我们现在将添加jump函数,如下所示:
void Hero::jump(float velocity){ 

   if (jumpCount < 2) { 
      jumpCount++; 

      m_velocity = VELOCITY; 
      m_grounded = false; 
   } 

}

在这里,我们首先检查jumpCount是否小于2,因为我们只想让玩家跳跃两次。如果jumpCount小于2,则将jumpCount值增加1,设置初始速度,并将地面布尔值设置为false

  1. 最后,我们添加了getSprite函数,该函数简单地获取当前精灵,如下所示:
 sf::Sprite Hero::getSprite(){ 

   return m_sprite; 
}  

恭喜!我们现在有了我们的Hero类。让我们通过以下步骤在source.cpp文件中使用它:

  1. main.cpp文件的顶部包含Hero.h
#include "SFML-2.5.1\include\SFML\Graphics.hpp" 
#include "Hero.h"
  1. 接下来,添加Hero类的一个实例,如下所示:
sf::Texture skyTexture; 
sf::Sprite skySprite; 

sf::Texture bgTexture; 
sf::Sprite bgSprite; 
Hero hero;
  1. init函数中,初始化Hero类:
   // Load bg Texture 

   bgTexture.loadFromFile("Assets/graphics/bg.png"); 

   // Create Sprite and Attach a Texture 
   bgSprite.setTexture(bgTexture); 

   hero.init("Assets/graphics/hero.png", sf::Vector2f(viewSize.x *
 0.25f, viewSize.y * 0.5f), 200); 

在这里,我们设置纹理图片;为此,将位置设置为屏幕左侧的.25(或 25%)处,并在y轴上居中。我们还设置了质量为200,因为我们的角色相当胖。

  1. 接下来,我们想要在按下上箭头键时让玩家跳跃。因此,在updateInput函数中,在轮询窗口事件时,我们添加以下代码:
while (window.pollEvent(event)) {  
    if (event.type == sf::Event::KeyPressed) {
 if (event.key.code == sf::Keyboard::Up) {
 hero.jump(750.0f);
 }
 }
      if (event.key.code == sf::Keyboard::Escape || event.type == 
       sf::Event::Closed) 
         window.close(); 

   }  

在这里,我们检查玩家是否按下了按键。如果按键被按下,并且按钮是键盘上的上箭头,那么我们调用hero.jump函数,并传入初始速度值750

  1. 接下来,在update函数中,我们调用hero.update函数,如下所示:
void update(float dt) { 
 hero.update(dt); 
} 

  1. 最后,在draw函数中,我们绘制英雄精灵:
void draw() { 

   window.draw(skySprite); 
   window.draw(bgSprite); 
 window.draw(hero.getSprite());

}
  1. 你现在可以运行游戏了。当玩家在地面时,按下键盘上的上箭头按钮,可以看到玩家跳跃。当玩家在空中时,再次按下跳跃按钮,你将看到玩家在空中再次跳跃:

图片

创建Enemy

玩家角色看起来非常孤单。她准备好制造一些混乱,但现在没有什么可以射击的。让我们添加一些敌人来解决这个问题:

  1. 敌人将通过敌人类创建;让我们创建一个新的类,并将其命名为Enemy

  2. 就像Hero类一样,Enemy类也将有一个.h文件和一个.cpp文件。在Enemy.h文件中,添加以下代码:

#pragma once 
#include "SFML-2.5.1\include\SFML\Graphics.hpp" 

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

   void init(std::string textureName, sf::Vector2f position, 
     float_speed); 
   void update(float dt); 
   sf::Sprite getSprite(); 

private: 

   sf::Texture m_texture; 
   sf::Sprite m_sprite; 
   sf::Vector2f m_position; 
   float m_speed; 

}; 

这里,Enemy类,就像Hero类一样,也有构造函数和析构函数。此外,它有一个init函数,该函数接受纹理和位置;然而,它不是质量,而是一个用于设置敌人初始速度的浮点变量。敌人不会受到重力的影响,并且只会从屏幕右侧生成并向屏幕左侧移动。还有updategetSprite函数;由于敌人不会跳跃,所以没有jump函数。最后,在私有部分,我们创建了纹理、精灵、位置和速度的局部变量。

  1. Enemy.cpp文件中,我们添加了构造函数、析构函数、initupdategetSprite函数:
#include "Enemy.h" 

Enemy::Enemy(){} 

Enemy::~Enemy(){} 

void Enemy::init(std::string textureName, sf::Vector2f position, 
    float _speed) { 

   m_speed = _speed; 
   m_position = position; 

   // Load a Texture 
   m_texture.loadFromFile(textureName.c_str()); 

   // Create Sprite and Attach a Texture 
   m_sprite.setTexture(m_texture); 
   m_sprite.setPosition(m_position); 
   m_sprite.setOrigin(m_texture.getSize().x / 2,    
   m_texture.getSize().y / 2); 

} 

不要忘记在主函数顶部包含Enemy.h。然后我们添加构造函数和析构函数。在init函数中,我们设置局部速度和位置值。接下来,我们从文件中加载Texture并设置敌人的纹理、位置和原点。

  1. updategetSprite函数中,我们更新位置并获取敌人精灵:
 void Enemy::update(float dt) { 

   m_sprite.move(m_speed * dt, 0); 

} 

sf::Sprite Enemy::getSprite() { 

   return m_sprite; 
}
  1. 我们已经准备好了Enemy类。现在让我们看看如何在游戏中使用它。

添加敌人

main.cpp类中,包含Enemy头文件。由于我们想要多个敌人实例,我们需要添加一个名为enemies的向量,并将所有新创建的敌人添加到其中。

在以下代码的上下文中,vector一词与数学毫无关系,而是与对象列表有关。实际上,它就像一个数组,我们可以存储多个对象。我们使用向量而不是数组,因为向量是动态的,这使得添加和从列表中删除对象(与数组相比,数组是一个静态列表)更容易。让我们开始吧:

  1. 我们需要在main.cpp文件中包含<vector>,如下所示:
#include "SFML-2.5.1\include\SFML\Graphics.hpp" 
#include <vector> 

#include "Hero.h" 
#include "Enemy.h" 
  1. 接下来,添加一个名为enemies的新变量,其类型为vector,它将存储Enemy数据类型:
sf::Texture bgTexture; 
sf::Sprite bgSprite; 

Hero hero; 

std::vector<Enemy*> enemies;  
  1. 为了创建一个特定对象类型的向量,你使用vector关键字,并在箭头括号内指定向量将持有的数据类型,然后指定你创建的向量的名称。这样,我们可以创建一个名为spawnEnemy()的新函数,并在主函数顶部添加其原型。

当任何函数在主函数下方编写时,主函数将不知道该函数的存在。因此,将创建一个原型并将其放置在主函数上方。这意味着函数现在可以在主函数下方实现——本质上,原型告诉主函数下面将有一个函数将被实现,因此要留意它。

sf::Vector2f viewSize(1024, 768);
sf::VideoMode vm(viewSize.x, viewSize.y); 
sf::RenderWindow window(vm, "Hello SFML Game !!!", sf::Style::Default); 

void spawnEnemy(); 

现在,我们希望敌人从屏幕的右侧生成,但我们还希望敌人以与玩家相同的高度、略高于玩家的高度或远高于玩家的高度生成,这样玩家就必须使用单跳或双跳来攻击敌人。

  1. 为了做到这一点,我们在init函数下方添加一些随机性,使游戏不那么可预测。为此,我们添加以下代码行:
hero.init("Assets/graphics/hero.png", sf::Vector2f(viewSize.x * 
0.25f, viewSize.y * 0.5f), 200); 

srand((int)time(0)); 

srand短语是一个伪随机数,通过传递种子值进行初始化。在这种情况下,我们传递当前时间作为种子值。

对于每个种子值,将生成一系列数字。如果种子值始终相同,则将生成相同的数字序列。这就是我们传递时间值的原因——以确保每次生成的数字序列都不同。我们可以通过调用rand函数来获取序列中的下一个随机数。

  1. 接下来,我们添加spawnEnemy函数,如下所示:
void spawnEnemy() { 

   int randLoc = rand() % 3; 

   sf::Vector2f enemyPos; 

   float speed; 

   switch (randLoc) { 

   case 0: enemyPos = sf::Vector2f(viewSize.x, viewSize.y * 0.75f);
   speed = -400; break; 

   case 1: enemyPos = sf::Vector2f(viewSize.x, viewSize.y * 0.60f); 
   speed = -550; break; 

   case 2: enemyPos = sf::Vector2f(viewSize.x, viewSize.y * 0.40f); 
   speed = -650; break; 

   default: printf("incorrect y value \n"); return; 

   } 

   Enemy* enemy = new Enemy(); 
   enemy->init("Assets/graphics/enemy.png", enemyPos, speed); 

   enemies.push_back(enemy); 
} 

在这里,我们首先获取一个随机数——这将由于获取随机位置时的模运算符而创建一个新的从02的随机数。因此,每次函数被调用时,randLoc的值将是012

创建一个新的enemyPos变量,其值将根据randLoc值分配。我们还将根据randLoc值设置敌人的速度;为此,我们创建一个新的浮点数speed,稍后将其分配。然后我们创建一个switch语句,它接受randLoc值——这允许随机位置生成敌人。

根据场景的不同,我们可以设置敌人的enemyPosition变量和速度:

  • randLoc0时,敌人从底部生成并以速度-400移动。

  • randLoc1时,敌人从屏幕中间生成并以速度-500移动。

  • randLoc的值为2时,敌人从屏幕顶部生成并以更快的速度-650移动。

  • 如果randLoc不是这些值中的任何一个,则输出一条消息,说明y的值不正确,而不是中断,我们返回以确保敌人不会在随机位置生成。

要在控制台打印消息,我们可以使用printf函数,它接受一个字符串值。在字符串的末尾,我们指定\n;这是一个关键字,告诉编译器这是行的末尾,之后写的内容需要放在新的一行,类似于调用std::cout

  1. 一旦我们知道位置和速度,我们就可以创建敌人对象本身并初始化它。请注意,敌人是以指针的形式创建的;否则,纹理的引用会丢失,当敌人生成时,纹理将不会显示。此外,当我们使用new关键字创建敌人作为原始指针时,系统会分配内存,我们稍后必须删除它。

  2. 在敌人创建后,我们通过调用向量的push函数将其添加到enemies向量中。

  3. 我们希望敌人以固定的时间间隔自动出生。为此,我们创建两个新变量来跟踪当前时间,并在每1.125秒生成一个新敌人。

  4. 接下来,创建两个新的float类型变量,分别称为currentTimeprevTime

Hero hero; 

std::vector<Enemy*> enemies; 

float currentTime; 
float prevTime = 0.0f;  
  1. 然后,在update函数中,在更新hero函数之后,添加以下代码行以创建一个新敌人:
 hero.update(dt); 
 currentTime += dt;
 // Spawn Enemies
 if (currentTime >= prevTime + 1.125f)))) {
 spawnEnemy();
 prevTime = currentTime;
}

首先,我们增加currentTime变量。这个变量将在游戏开始后立即开始增加,以便我们可以跟踪自我们开始游戏以来已经过去了多长时间。接下来,我们检查当前时间是否大于或等于上一个时间加上1.125秒,因为这是我们希望新敌人出生的时间。如果是true,那么我们调用spawnEnemy函数,这将创建一个新的敌人。我们还设置上一个时间等于当前时间,这样我们就可以知道最后一个敌人是在什么时候出生的。好!所以,现在我们已经让游戏中的敌人开始出生了,我们可以update敌人并draw它们。

  1. update函数中,我们同样创建一个for循环来更新敌人并删除一旦它们超出屏幕左侧的敌人。为此,我们在update函数中添加以下代码:
   // Update Enemies 

   for (int i = 0; i < enemies.size(); i++) { 

      Enemy *enemy = enemies[i]; 

      enemy->update(dt); 

      if (enemy->getSprite().getPosition().x < 0) { 

         enemies.erase(enemies.begin() + i); 
         delete(enemy); 

      } 
   } 

这正是向量使用非常有帮助的地方。使用向量,我们能够向向量中添加、删除和插入元素。在这个例子中,我们获取向量中位置索引为i的敌人的引用。如果那个敌人超出屏幕并需要被删除,那么我们只需使用erase函数并传递从向量开始的位置索引来删除该索引处的敌人。当我们重置游戏时,我们也删除了我们创建的敌人的局部引用。这也会释放我们创建新敌人时分配的内存空间。

  1. draw函数中,我们通过一个for...each循环遍历每个敌人并绘制它们:
window.draw(skySprite); 
window.draw(bgSprite); 

window.draw(hero.getSprite()); 

for (Enemy *enemy : enemies) { 
  window.draw(enemy->getSprite()); 
}

我们使用for...each循环遍历所有敌人,因为getSprite函数需要在它们所有身上调用。有趣的是,当我们需要更新敌人时我们没有使用for...each,因为使用for循环,如果我们需要删除它,我们可以简单地使用敌人的索引。

  1. 最后,将Enemy.png文件添加到Assets/graphics文件夹。现在,当你运行游戏时,你将看到敌人以不同的高度在屏幕左侧出生并移动:

图片

创建火箭类

游戏中现在有敌人了,但玩家仍然不能射击它们。让我们创建一些火箭,这样它们就可以通过以下步骤从玩家的火箭筒中发射出来:

  1. 在项目中,创建一个名为Rocket的新类。如以下代码块所示,Rocket.h类与Enemy.h类非常相似:
#pragma once 

#include "SFML-2.5.1\include\SFML\Graphics.hpp" 

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

   void init(std::string textureName, sf::Vector2f position, 
      float_speed); 
   void update(float dt); 
   sf::Sprite getSprite(); 

private: 

   sf::Texture m_texture; 
   sf::Sprite m_sprite; 
   sf::Vector2f m_position; 
   float m_speed; 

}; 

public部分包含initupdategetSprite函数。init函数接受要加载的纹理名称、设置的位置以及初始化对象的速度。private部分有texturespritepositionspeed的局部变量。

  1. Rocket.cpp文件中,我们添加构造函数和析构函数,如下所示:
#include "Rocket.h" 

Rocket::Rocket(){ 
} 

Rocket::~Rocket(){ 
} 

init函数中,我们设置speedposition变量。然后我们设置texture变量,并用texture变量初始化精灵。

  1. 接下来,我们设置精灵的position变量和原点,如下所示:
void Rocket::init(std::string textureName, sf::Vector2f position, float _speed){ 

   m_speed = _speed; 
   m_position = position; 

   // Load a Texture 
   m_texture.loadFromFile(textureName.c_str()); 

   // Create Sprite and Attach a Texture 
   m_sprite.setTexture(m_texture); 
   m_sprite.setPosition(m_position); 
   m_sprite.setOrigin(m_texture.getSize().x / 2, 
     m_texture.getSize().y / 2); 

} 
  1. update函数中,对象根据speed变量移动:
void Rocket::update(float dt){ 
   \ 
   m_sprite.move(m_speed * dt, 0); 

} 
  1. getSprite函数返回当前精灵,如下所示:
sf::Sprite Rocket::getSprite() { 

   return m_sprite; 
} 

添加火箭

现在我们已经创建了火箭,让我们学习如何添加它们:

  1. main.cpp文件中,我们按照如下方式包含Rocket.h类:
#include "Hero.h" 
#include "Enemy.h" 
#include "Rocket.h" 
  1. 然后我们创建一个新的Rocket向量,称为rockets,它接受Rocket
std::vector<Enemy*> enemies; 
std::vector<Rocket*> rockets;  
  1. update函数中,在我们更新了所有敌人之后,我们更新所有火箭。我们还会删除超出屏幕右边的火箭:
   // Update Enemies 

   for (int i = 0; i < enemies.size(); i++) { 

      Enemy* enemy = enemies[i]; 

      enemy->update(dt); 

      if (enemy->getSprite().getPosition().x < 0) { 

         enemies.erase(enemies.begin() + i); 
         delete(enemy); 

      } 
   } 
 // Update rockets

 for (int i = 0; i < rockets.size(); i++) {

 Rocket* rocket = rockets[i];

 rocket->update(dt);

 if (rocket->getSprite().getPosition().x > viewSize.x) {
 rockets.erase(rockets.begin() + i);
 delete(rocket);
 }
}
  1. 最后,我们通过遍历场景中的每个火箭,使用draw函数绘制所有火箭:
    for (Enemy *enemy : enemies) { 

      window.draw(enemy->getSprite()); 
   } 

for (Rocket *rocket : rockets) {
 window.draw(rocket->getSprite());
}
  1. 现在,我们实际上可以发射火箭了。在main.cpp文件中,在类中创建一个名为shoot()的新函数,并在主函数的顶部添加它的原型:
void spawnEnemy(); 
void shoot(); 
  1. shoot函数中,我们将添加发射火箭的功能。我们将生成新的火箭并将它们推回到rockets向量中。你可以按照以下方式添加shoot函数:
void shoot() { 

   Rocket* rocket = new Rocket(); 

rocket->init("Assets/graphics/rocket.png",  
            hero.getSprite().getPosition(),  
    400.0f); 

   rockets.push_back(rocket); 

} 

当这个函数被调用时,它创建一个新的Rocket,并用Rocket.png文件初始化它,将其位置设置为与英雄精灵的位置相同,然后将速度设置为400.0f。然后火箭被添加到rockets向量中。

  1. 现在,在updateInput函数中,添加以下代码,以便当按下向下箭头键时,调用shoot函数:
   if (event.type == sf::Event::KeyPressed) { 

      if (event.key.code == sf::Keyboard::Up) { 

         hero.jump(750.0f); 
      } 

      if (event.key.code == sf::Keyboard::Down) { 

         shoot(); 
      } 
   }  
  1. 不要忘记将rocket.png文件放置在assets文件夹中。现在,当你运行游戏并按下向下箭头键时,会发射一枚火箭:

碰撞检测

在本章的最后部分,让我们添加一些碰撞检测,以便当火箭和敌人同时接触时,火箭实际上可以消灭敌人:

  1. 创建一个名为checkCollision的新函数,并在主函数的顶部创建它的原型:
void spawnEnemy(); 
void shoot(); 

bool checkCollision(sf::Sprite sprite1, sf::Sprite sprite2); 
  1. 这个函数接受两个精灵,以便我们可以检查它们之间的交集。在添加shoot函数的同一位置添加以下代码来实现该函数:
void shoot() { 

   Rocket* rocket = new Rocket(); 

   rocket->init("Assets/graphics/rocket.png", 
     hero.getSprite().getPosition(), 400.0f); 

   rockets.push_back(rocket); 

} 

bool checkCollision(sf::Sprite sprite1, sf::Sprite sprite2) { 

   sf::FloatRect shape1 = sprite1.getGlobalBounds(); 
   sf::FloatRect shape2 = sprite2.getGlobalBounds(); 

   if (shape1.intersects(shape2)) { 

      return true; 

   } 
   else { 

      return false; 

   } 

}

checkCollision 函数内部,我们创建了两个 FloatRect 类型的局部变量。然后,我们将精灵的 GlobalBounds 分配给每个名为 shape1shape2FloatRect 变量。GlobalBounds 获取精灵的矩形区域,该区域从当前对象所在的位置开始。

FloatRect 类型只是一个矩形;我们可以使用 intersects 函数来检查这个矩形是否与另一个矩形相交。如果第一个矩形与另一个矩形相交,那么我们返回 true 来表示精灵之间存在交集或碰撞。如果没有交集,则返回 false

  1. update 函数中,在更新 enemyrocket 类之后,我们使用嵌套 for 循环检查每个火箭和每个敌人之间的碰撞。你可以如下添加碰撞检查:
   // Update rockets 

   for (int i = 0; i < rockets.size(); i++) { 

      Rocket* rocket = rockets[i]; 

      rocket->update(dt); 

      if (rocket->getSprite().getPosition().x > viewSize.x) { 

         rockets.erase(rockets.begin() + i); 
         delete(rocket); 

      } 

   } 

    // Check collision between Rocket and Enemies 

   for (int i = 0; i < rockets.size(); i++) { 
      for (int j = 0; j < enemies.size(); j++) { 

         Rocket* rocket = rockets[i]; 
         Enemy* enemy = enemies[j]; 

         if (checkCollision(rocket->getSprite(), 
            enemy->getSprite())) { 

            rockets.erase(rockets.begin() + i); 
            enemies.erase(enemies.begin() + j); 

            delete(rocket); 
            delete(enemy); 

            printf(" rocket intersects enemy \n"); 
         } 

      } 
   }   

在这里,我们创建了一个双重 for 循环,调用 checkCollision 函数,然后将每个火箭和敌人传递给它以检查它们之间的交集。

  1. 如果存在交集,我们将火箭和敌人从向量中移除,并从场景中删除它们。这样,我们就完成了碰撞检测。

摘要

在本章中,我们创建了一个单独的 Hero 类,以便所有与 Hero 类相关的代码都集中在一个单独的文件中。在这个 Hero 类中,我们管理跳跃和火箭的射击。接下来,我们创建了 Enemy 类,因为对于每个英雄,故事中都需要一个反派!我们学习了如何将敌人添加到向量中,以便更容易地在敌人之间循环以更新它们的位置。我们还创建了一个 Rocket 类,并使用向量管理火箭。最后,我们学习了如何检查敌人和火箭之间的碰撞。这为游戏循环的构建奠定了基础。

在下一章中,我们将完成游戏,向其中添加声音和文本,以便向玩家提供音频反馈并显示当前分数。

第五章:完成您的游戏

在上一章中,我们探讨了如何创建游戏;在本章中,我们将完成Gameloop,以便您可以玩游戏。游戏的目标是确保没有敌人能够到达屏幕的左侧。如果他们做到了,游戏就结束了。

我们将添加一个计分系统,以便玩家知道他们在回合中获得了多少分数。对于每个被击落的敌人,玩家将获得一分。我们还将向游戏中添加文本,以显示游戏的标题、玩家的分数以及一个小教程,展示如何玩游戏。

在本章结束时,我们将装饰游戏。我们将添加用作背景音乐的音频,以及玩家射击火箭和玩家火箭击中敌人的音效。我们还将为玩家添加一些动画,使角色看起来更加生动。

在本章中,我们将介绍以下主题:

  • 完成 Gameloop 并添加计分

  • 添加文本

  • 添加音频

  • 添加玩家动画

那么,让我们开始吧!

完成 Gameloop 并添加计分

以下步骤将向您展示如何完成 Gameloop 并添加计分到游戏代码中:

  1. source.cpp文件中添加两个新变量:一个int类型的,命名为score,另一个bool类型的,命名为gameover。将score初始化为0,将gameover初始化为true
std::vector<Enemy*> enemies; 
std::vector<Rocket*> rockets; 

float currentTime; 
float prevTime = 0.0f; 

int score = 0; 
bool gameover = true; 
  1. 创建一个名为reset()的新函数。我们将使用它来重置变量。在source.cpp文件顶部创建重置函数的原型:
bool checkCollision(sf::Sprite sprite1, sf::Sprite sprite2); 
void reset(); 

source.cpp文件的底部,在我们创建了checkCollision函数之后,添加重置函数本身,以便在游戏重置时,所有值也会重置。为此,使用以下代码:

void reset() { 

   score = 0; 
   currentTime = 0.0f; 
   prevTime = 0.0; 

   for (Enemy *enemy : enemies) { 
         delete(enemy); 
   } 
   for (Rocket *rocket : rockets) { 
         delete(rocket); 
   } 

   enemies.clear(); 
   rockets.clear(); 
}

如果游戏结束,按下向下箭头键一次将重新启动游戏。游戏再次开始后,将调用reset()函数。在reset()函数中,我们需要将scorecurrentTimeprevTime设置为0

当游戏重置时,通过删除并释放内存来移除任何已实例化的敌人和火箭对象。这也清除了持有现在已删除对象引用的向量。现在我们已经设置了变量和重置函数,让我们在游戏中使用它们来在重新启动游戏时重置值。

UpdateInput函数中,在while循环中,检查键盘上的向下箭头键是否被按下,我们将添加一个if条件来检查游戏是否结束。如果游戏结束,我们将gameover布尔值设置为false,以便游戏可以开始,并且我们将通过调用reset函数重置变量,如下所示:

           if (event.key.code == sf::Keyboard::Down) { 

             if (gameover) {
                gameover = false;
                reset();
              } 
            else { 
                 shoot(); 
                  }                
            } 

在这里,shoot()被移动到else语句中,以便玩家只能在游戏运行时射击。

接下来,当敌人超出屏幕的左侧时,我们将设置gameover条件为true

当我们更新敌人时,敌人从屏幕消失时将被删除,并且我们将游戏结束条件设置为true

将以下更新敌人的代码添加到update()函数中:

// Update Enemies 
   for (int i = 0; i < enemies.size(); i++) { 

         Enemy* enemy = enemies[i]; 

         enemy->update(dt); 

         if (enemy->getSprite().getPosition().x < 0) { 

               enemies.erase(enemies.begin() + i); 
               delete(enemy); 
               gameover = true;
         } 
   } 
  1. 在这里,我们希望在gameoverfalse时更新游戏。在main函数中,在我们更新游戏之前,我们将添加一个检查以确定游戏是否结束。如果游戏结束,我们不会更新游戏。为此,使用以下代码:
   while (window.isOpen()) { 

         ////update input 
         updateInput(); 

         //// +++ Update Game Here +++ 
         sf::Time dt = clock.restart(); 
         if(!gameover)
             update(dt.asSeconds());
         //// +++ Draw Game Here ++ 

         window.clear(sf::Color::Red); 

         draw(); 

         // Show everything we just drew 
         window.display(); 

   } 

当火箭与敌人碰撞时,我们将增加分数。这意味着在update()函数中,当我们删除交叉后的火箭和敌人时,我们还将更新分数:

   // Check collision between Rocket and Enemies 

   for (int i = 0; i < rockets.size(); i++) { 
         for (int j = 0; j < enemies.size(); j++) { 

               Rocket* rocket = rockets[i]; 
               Enemy* enemy = enemies[j]; 

               if (checkCollision(rocket->getSprite(), enemy-
                   >getSprite())) { 

                     score++; 

                     rockets.erase(rockets.begin() + i); 
                     enemies.erase(enemies.begin() + j); 

                     delete(rocket); 
                     delete(enemy); 

                     printf(" rocket intersects enemy \n"); 
               } 

         } 
   } 

当你运行游戏时,通过按下向下箭头键开始游戏。当其中一个敌人穿过屏幕的左侧时,游戏将结束。当你再次按下向下箭头键时,游戏将重新开始。

游戏循环现在已完成,但我们仍然看不到分数。为此,让我们向游戏中添加一些文本。

添加文本

这些步骤将指导你如何向游戏中添加文本:

  1. 创建一个名为headingFontsf::Font,以便我们可以加载字体然后使用它来显示游戏名称。在屏幕顶部创建所有变量的地方,创建headingFont变量,如下所示:
int score = 0; 
bool gameover = true; 

// Text 
sf::Font headingFont;   
  1. init()函数中,在我们加载bgSprite之后,我们将使用loadFromFile函数加载字体:
   // Create Sprite and Attach a Texture 
   bgSprite.setTexture(bgTexture); 

   // Load font 

   headingFont.loadFromFile("Assets/fonts/SnackerComic.ttf"); 

由于我们需要从系统中加载字体,我们必须将字体放在fonts目录中,该目录位于Assets目录下。确保你将字体文件放在那里。我们将用于标题的字体是SnackerComic.ttf文件。我还包括了arial.ttf文件,我们将使用它来显示分数,所以请确保你也添加它。

  1. 使用sf::Text类型创建headingText变量,以便我们可以显示游戏的标题。在代码的开始处执行此操作:
sf::Text headingText;   
  1. init()函数中,在加载headingFont之后,我们将添加创建游戏标题的代码:
   // Set Heading Text 
   headingText.setFont(headingFont); 
   headingText.setString("Tiny Bazooka"); 
   headingText.setCharacterSize(84); 
   headingText.setFillColor(sf::Color::Red); 

我们需要使用setFont函数设置标题文本的字体。在setFont中传入我们刚刚创建的headingFont变量。

我们需要告诉headingText需要显示的内容。为此,我们将使用setString函数并传入TinyBazooka字符串,因为这是我们刚刚制作的游戏的名称。名字很酷,不是吗?

让我们设置字体本身的大小。为此,我们将使用setCharacterSize函数并传入84作为像素大小,以便它清晰可见。现在,我们可以使用setFillColor函数将颜色设置为红色。

  1. 我们希望标题在视口中居中,因此我们将获取文本的边界并将它的原点设置在视口的center位置,在xy方向上。设置文本的位置,使其位于 x 方向的中心以及沿y-方向从顶部起0.10的高度处:
   sf::FloatRect headingbounds = headingText.getLocalBounds(); 
   headingText.setOrigin(headingbounds.width/2, 
      headingbounds.height / 2); 
   headingText.setPosition(sf::Vector2f(viewSize.x * 0.5f, 
      viewSize.y * 0.10f));
  1. 要显示文本,调用 window.draw 并将 headingText 传递给它。我们还想在游戏结束时绘制文本。为此,添加一个 if 语句,检查游戏是否结束:
   if (gameover) { 
         window.draw(headingText); 
   }
  1. 运行游戏。你将看到游戏名称显示在顶部:

图片

  1. 我们仍然看不到分数,所以让我们添加一个 Font 变量和一个 Text 变量,分别命名为 scoreFontscoreText。在 scoreFont 变量中,加载 arial.ttf 字体,并使用 scoreText 变量设置分数的文本:
sf::Font headingFont; 
sf::Text headingText; 

sf::Font scoreFont; 
sf::Text scoreText;
  1. 加载 ScoreFont 字符串,然后设置 ScoreText 字符串:
   scoreFont.loadFromFile("Assets/fonts/arial.ttf"); 

   // Set Score Text 

   scoreText.setFont(scoreFont); 
   scoreText.setString("Score: 0"); 
   scoreText.setCharacterSize(45); 
   scoreText.setFillColor(sf::Color::Red); 

   sf::FloatRect scorebounds = scoreText.getLocalBounds(); 
   scoreText.setOrigin(scorebounds.width / 2,
      scorebounds.height / 2); 
   scoreText.setPosition(sf::Vector2f(viewSize.x * 0.5f, 
      viewSize.y * 0.10f)); 

在这里,我们将 scoreText 字符串设置为 0 分,一旦分数增加,我们将更改它。设置字体大小为 45

将分数设置为与 headingText 相同的位置,因为它只会在游戏结束时显示。当游戏运行时,scoreText 将显示。

  1. update 函数中,更新分数时更新 scoreText
   score++; 
   std::string finalScore = "Score: " + std::to_string(score); 
   scoreText.setString(finalScore); 
   sf::FloatRect scorebounds = scoreText.getLocalBounds(); 
   scoreText.setOrigin(scorebounds.width / 2, 
     scorebounds.height / 2); 
   scoreText.setPosition(sf::Vector2f(viewSize.x * 0.5f, viewSize.y
     * 0.10f));

为了方便,我们创建了一个新的字符串,名为 finalScore。在这里,我们将 "Score: " 字符串与分数连接起来,分数是一个通过字符串类的 toString 属性转换为字符串的 int。然后,我们使用 sf::TextsetString 函数设置字符串。由于文本会发生变化,我们必须获取新的文本边界。设置更新文本的原点、中心和位置。

  1. draw 函数中,创建一个新的 else 语句。如果游戏没有结束,绘制 scoreText
   if (gameover) { 
         window.draw(headingText); 
   } else { 
        window.draw(scoreText);
    } 
  1. reset() 函数中重置 scoreText
   prevTime = 0.0; 
   scoreText.setString("Score: 0"); 

当你运行游戏时,分数将不断更新。当你重新启动游戏时,值将重置。

得分系统如下所示:

图片

  1. 添加一个教程,让玩家知道游戏开始时该做什么。创建一个新的 sf::Text,名为 tutorialText
sf::Text tutorialText; 

  1. init() 函数中初始化 scoreText 后面的文本:
   // Tutorial Text 

   tutorialText.setFont(scoreFont); 
   tutorialText.setString("Press Down Arrow to Fire and Start Game, 
   Up Arrow to Jump"); 
   tutorialText.setCharacterSize(35); 
   tutorialText.setFillColor(sf::Color::Red); 

   sf::FloatRect tutorialbounds = tutorialText.getLocalBounds(); 
   tutorialText.setOrigin(tutorialbounds.width / 2, tutorialbounds.height / 2); 
   tutorialText.setPosition(sf::Vector2f(viewSize.x * 0.5f, viewSize.y * 0.20f)); 
  1. 我们只想在游戏开始时显示教程,以及标题文本。将以下代码添加到 draw 函数中:
if (gameover) { 
         window.draw(headingText); 
window.draw(tutorialText);
  } 
   else { 
         window.draw(scoreText); 
   } 

现在,当你开始游戏时,玩家将看到如果他们按下向下箭头键,游戏将开始。他们也会知道,当游戏运行时,他们可以按下向下箭头键发射火箭,并使用向上箭头键跳跃。以下屏幕截图显示了屏幕上的文本:

图片

添加音频

让我们添加一些音频到游戏中,使其更有趣。这也会为玩家提供音频反馈,告诉他们火箭是否被发射或敌人是否被击中。

SFML 支持.wav.ogg文件,但它不支持.mp3文件。对于这个项目,所有文件都将使用.ogg文件格式,因为它适合压缩,并且也是跨平台兼容的。首先,将音频文件放置在系统Assets文件夹中的Audio目录下。音频文件就绪后,我们可以开始播放音频文件。

音频文件有两种类型:

  • 背景音乐,其持续时间比游戏中的其他文件长得多,质量也高得多。这些文件使用sf::Music类播放。

  • 其他声音文件,如音效——通常体积较小,有时质量较低——使用sf::Sound类播放。要播放文件,你还需要一个sf::SoundBuffer类,它用于存储文件并在以后播放。

要将音频添加到游戏中,请按照以下步骤操作:

  1. 让我们播放背景音乐文件bgMusic.ogg。音频文件使用Audio.hpp头文件,需要在main.cpp文件的顶部包含它。可以这样做:
 #include "SFML-2.5.1\include\SFML\Audio.hpp" 
  1. main.cpp文件的顶部创建一个新的sf::Music实例,并将其命名为bgMusic
sf::Music bgMusic;  
  1. init()函数中,添加以下行以打开bgMusic.ogg文件并播放bgMusic文件:
   // Audio  

   bgMusic.openFromFile("Assets/audio/bgMusic.ogg"); 
   bgMusic.play();
  1. 运行游戏。游戏开始时,你会立即听到背景音乐播放。

  2. 要添加发射火箭和敌人被击中的声音文件,我们需要两个声音缓冲区来存储这两种效果,以及两个声音文件来播放声音。创建两个名为fireBufferhitBuffersf::SoundBuffer类型的变量:

sf::SoundBuffer fireBuffer; 
sf::SoundBuffer hitBuffer;
  1. 现在,创建两个名为fireSoundhitSoundsf::Sound变量。它们可以通过传递给各自的缓冲区来初始化,如下所示:
sf::Sound fireSound(fireBuffer); 
sf::Sound hitSound(hitBuffer); 
  1. init函数中,首先初始化缓冲区,如下所示:
bgMusic.openFromFile("Assets/audio/bgMusic.ogg"); 
   bgMusic.play(); 

   hitBuffer.loadFromFile("Assets/audio/hit.ogg"); 
   fireBuffer.loadFromFile("Assets/audio/fire.ogg"); 
  1. 当火箭与敌人相交时,我们将播放hitSound效果:
hitSound.play(); 
         score++; 

         std::string finalScore = "Score: " + 
                                  std::to_string(score); 

         scoreText.setString(finalScore); 

         sf::FloatRect scorebounds = scoreText.getLocalBounds(); 
         scoreText.setOrigin(scorebounds.width / 2,
         scorebounds.height / 2); 
         scoreText.setPosition(sf::Vector2f(viewSize.x * 0.5f,
         viewSize.y * 0.10f)); 
  1. shoot函数中,我们将播放fireSound文件,如下所示:
void shoot() { 
   Rocket* rocket = new Rocket(); 

   rocket->init("Assets/graphics/rocket.png", hero.getSprite().getPosition(), 400.0f); 

   rockets.push_back(rocket); 
 fireSound.play();
} 

现在,当你玩游戏时,你会在发射火箭和火箭击中敌人时听到声音效果。

添加玩家动画

游戏现在已经进入开发阶段的最后阶段。让我们给游戏添加一些动画,让它真正活跃起来。要动画化 2D 精灵,我们需要一个精灵表。我们可以使用其他技术来添加 2D 动画,例如骨骼动画,但基于精灵表的 2D 动画制作起来更快。因此,我们将使用精灵表来为主角添加动画。

精灵表是一张图像文件;然而,它包含的不是单个图像,而是一系列图像的集合,这样我们就可以循环播放它们来创建动画。序列中的每一张图像被称为帧。

这里是我们将要用来动画化玩家的精灵表:

从左到右看,我们可以看到每个帧都与上一个略有不同。这里正在动画化的主要事物是玩家角色的喷气背包和玩家角色的眼睛(这样角色看起来就像是在眨眼)。当游戏运行时,每张图片将作为动画帧显示,就像在翻页动画中,一张图片迅速被另一张图片替换,以产生动画效果。

SFML 使得动画 2D 角色变得非常简单,因为我们可以在 update 函数中选择要显示的帧。让我们开始动画角色:

  1. 将精灵图集文件添加到 Assets/graphics 文件夹。我们需要对 Hero.hHero.cpp 文件进行一些修改。让我们首先看看 Hero.h 文件的修改:
class Hero{ 

public: 
   Hero(); 
   ~Hero(); 

   void init(std::string textureName, int frameCount, 
      float animDuration, sf::Vector2f position, float mass); 

void update(float dt); 
   void jump(float velocity); 
   sf::Sprite getSprite(); 

private: 

   int jumpCount = 0; 
   sf::Texture m_texture; 
   sf::Sprite m_sprite; 
   sf::Vector2f m_position; 
   float m_mass; 
   float m_velocity; 
   const float m_gravity = 9.81f; 
   bool m_grounded; 

   int m_frameCount; 
   float m_animDuration; 
   float m_elapsedTime;; 
   sf::Vector2i m_spriteSize; 

}; 

我们需要向 init 函数添加两个额外的参数。第一个是一个名为 frameCount 的整数,它是动画中的帧数。在我们的例子中,英雄精灵图集中有四帧。另一个参数是一个浮点数,称为 animDuration,它基本上设置了动画播放的时长。这将决定动画的速度。

我们还将创建一些变量。我们将创建的前两个变量,m_frameCountm_animDuration,将用于在本地存储 frameCountanimDuration。我们还将创建一个名为 m_elapsedTime 的浮点数,它将跟踪游戏运行了多长时间,以及一个名为 m_spriteSizevector2 整数,它将存储每个帧的大小。

  1. 让我们继续到 Hero.cpp 文件,看看需要哪些修改。以下是修改后的 init 函数:
void Hero::init(std::string textureName, int frameCount, 
  float animDuration, sf::Vector2f position, float mass){ 

   m_position = position; 
   m_mass = mass; 
   m_grounded = false; 

   m_frameCount = frameCount;
   m_animDuration = animDuration;

   // Load a Texture 
   m_texture.loadFromFile(textureName.c_str()); 

   m_spriteSize = sf::Vector2i(92, 126);

   // Create Sprite and Attach a Texture 
   m_sprite.setTexture(m_texture); 
   m_sprite.setTextureRect(sf::IntRect(0, 0, m_spriteSize.x, 
     m_spriteSize.y));

   m_sprite.setPosition(m_position); 
   m_sprite.setOrigin(m_spriteSize.x / 2, m_spriteSize.y / 2);

} 

init 函数中,我们设置 m_frameCountm_animationDuration 本地。我们需要将每个帧的宽度(作为 92)和高度(作为 126)的值硬编码。如果您正在加载自己的图像,这些值将不同。

在调用 setTexture 之后,我们将调用 Sprite 类的 setTextureRect 函数来设置我们想要显示的精灵图集部分。从精灵的原点开始,通过传递 spriteSheet 的宽度和高度来获取精灵图集的第一帧。我们传递了新的 heroAnim.png 文件而不是 size

设置位置和原点,等于 spriteSize 的宽度和高度的中间。

  1. 让我们对 update 函数进行一些修改,这是主要魔法发生的地方:
void Hero::update(float dt){ 
   // Animate Sprite 
   M_elapsedTime += dt; 
   int animFrame = static_cast<int> ((m_elapsedTime / 
                   m_animDuration) * m_frameCount) % m_frameCount; 

   m_sprite.setTextureRect(sf::IntRect(animFrame * m_spriteSize.x, 
      0, m_spriteSize.x, m_spriteSize.y)); 

   // Update Position 

   m_velocity -= m_mass * m_gravity * dt; 

   m_position.y -= m_velocity * dt; 

   m_sprite.setPosition(m_position); 

   if (m_position.y >= 768 * 0.75) { 

         m_position.y = 768 * 0.75; 
         m_velocity = 0; 
         m_grounded = true; 
         jumpCount = 0; 
   } 

} 

update 函数中,通过 delta 时间增加已过时间。然后,计算当前动画帧号。

通过调用 setTextureRect 更新要显示的精灵图集部分,并将帧的原点移动到 x 轴上,这取决于 animFrame,通过乘以帧的宽度来实现。新帧的高度不变,所以我们将其设置为 0。帧的宽度和高度保持不变,因此我们传递帧本身的尺寸。

Hero.cpp中的其余功能保持不变,不需要对它们进行任何更改。

  1. 返回main.cpp,以便我们可以更改调用hero.init的方式。在init函数中,进行必要的更改:
hero.init("Assets/graphics/heroAnim.png", 4, 1.0f, sf::Vector2f(viewSize.x * 0.25f, viewSize.y * 0.5f), 200); 

在这里,我们传递了新的heroAnim.png文件,而不是之前加载的单帧.png文件。将帧数设置为4并将animDuration设置为1.0f

  1. 运行游戏。你会看到玩家角色现在被动画化,每四帧闪烁一次:

摘要

在本章中,我们完成了游戏循环并添加了gameover条件。我们添加了得分,以便玩家知道他们获得了多少分。我们还添加了文本,以便显示游戏名称、玩家的得分以及一个教程,告诉用户如何玩游戏。然后,我们学习了如何将这些元素放置在视口的中心。最后,我们添加了音效和动画,使我们的游戏栩栩如生。

在下一章中,我们将探讨如何在场景中渲染 3D 和 2D 对象。我们将不使用框架,而是开始创建一个基本引擎,并开始了解渲染基础之旅。

第三部分:现代 OpenGL 3D 游戏开发

在本节中,我们将使用在第二部分中学到的游戏开发概念,利用现代 OpenGL 和 bullet 物理引擎创建一个 3D 物理益智游戏。我们将学习图形管线和通过顶点缓冲区和索引缓冲区创建 3D 对象。然后,我们将使用顶点着色器和片段着色器将它们添加到场景中,添加纹理,并使用文本渲染,包括 bullet 物理库、光照模型、后期处理效果以及 3D 粒子系统生成。本节涵盖了以下章节:

第六章,OpenGL 入门

第七章,基于游戏对象进行构建

第八章,通过碰撞、循环和光照增强你的游戏

第六章:开始使用 OpenGL

在前三个章节中,我们在微小的 Bazooka 游戏中使用简单快速媒体库SFML)渲染了名为 sprite 的 2D 对象。SFML 的核心是 OpenGL;这用于在屏幕上渲染任何内容,包括 2D 对象。

SFML 非常擅长将所有内容打包成一个精美的小包,这使得我们能够快速开始 3D 游戏。然而,为了理解图形库实际上是如何工作的,我们需要通过深入了解如何使用它来学习 OpenGL 的工作原理,这样我们就可以在屏幕上渲染任何内容。

在本章中,我们将了解如何使用图形库,如 OpenGL,来在任意场景中渲染 3D 对象。我们将涵盖以下主题:

  • 什么是 OpenGL?

  • 创建我们的第一个 OpenGL 项目

  • 创建窗口和清屏

  • 创建一个Mesh

  • 创建一个相机类

  • Shaderloader 类

  • 光照渲染器类

  • 绘制对象

什么是 OpenGL?

那么,我们所说的 OpenGL 是什么?嗯,OpenGL 是一组图形 API;本质上,这是一个允许你访问图形硬件功能的代码集合。当前 OpenGL 的版本是 4.6,但任何能够运行 OpenGL 4.5 的图形硬件也可以运行 4.6。

OpenGL 完全独立于硬件和操作系统,所以无论你使用的是 NVIDIA 还是 AMD GPU,它在这两种硬件上都会以相同的方式工作。OpenGL 的功能工作方式是由一个规范定义的,该规范被图形硬件制造商在开发其硬件的驱动程序时使用。这就是为什么有时我们不得不更新图形硬件驱动程序,如果某些东西看起来不对或者游戏表现不佳。

此外,OpenGL 在 Windows 或 Linux 机器上运行都是相同的。然而,在 macOS Mojave 上已被弃用,但如果你运行的 macOS 版本早于 Mojave,则它仍然兼容。

OpenGL 只负责在场景中渲染对象。与允许你创建窗口然后访问键盘和鼠标输入的 SFML 不同,我们需要添加一个单独的库来处理所有这些。

因此,让我们通过在场景中渲染一个 3D OpenGL 对象来开始准备我们的项目。

创建我们的第一个 OpenGL 项目

既然我们已经了解了 OpenGL 是什么,让我们来检查如何创建我们的第一个 OpenGL 项目,如下所示:

  1. 在 Visual Studio 中创建一个新的空 C++项目,并将其命名为OpenGLProject

  2. 然后,下载 GLEW;这是一个 C/C++扩展加载库。OpenGL 支持各种 GPU 供应商可以使用来编写和扩展 OpenGL 功能性的扩展。这个库将确定平台支持哪些扩展。

  3. 前往glew.sourceforge.net/下载 Windows 32 位和 64 位二进制文件:

图片

  1. 接下来,我们需要下载 GLFW;这是一个平台无关的 API,用于创建窗口、读取输入和处理事件。访问 www.glfw.org/download.html 并下载 64 位 Windows 二进制文件。在这本书中,我们将主要关注在 Windows 平台上实现它:

  1. 接下来,我们需要下载 glm,它用于我们图形计算的数学运算。访问 glm.g-truc.net/0.9.9/index.html 并从该网站下载 GLM。

  2. 现在我们已经下载了所有必需的库和头文件,我们可以开始将它们添加到我们的项目中。

  3. 在项目文件根目录(Visual Studio 项目文件存储的位置)中,创建一个名为 Dependencies 的新目录。

  4. 从此目录中提取 glewglfwglmDependencies 目录现在应该如下所示:

  1. 打开 Visual Studio 项目。我们需要设置头文件和库文件的位置。为此,打开 OpenGLProject 的项目属性,将配置设置为 Release,平台设置为 x64。

  2. 在 C/C++ | 通用下,选择 Additional Include Directories 并选择以下目录用于 GLEW 和 GLFW:

  1. 接下来,在 Linker | General 下,选择 Additional Library Directories,然后选择 glewglfw 目录中 .lib 文件的位置,如下所示:

  1. 接下来,我们必须转到 Linker | Input 并指定我们正在使用的 .lib 文件。

  2. 在 Linker | Input 下,选择 Additional Dependencies 并添加 opengl32.lib、glfw3.lib 和 glew32.lib,如下所示:

  1. 虽然我们没有特别下载 opengl32.lib,但在更新图形硬件的驱动程序时它会自动包含。因此,请确保您正在运行最新的 GPU 驱动程序;如果不是,请从制造商的网站下载它们。

  2. 最后,我们必须将 glew32.dllglfw3.dll 文件添加到项目的根目录中。glew32.dllglew-2.1.0/bin/Release/64 中,而 glfw3.dllglfw-3.2.1/lib-vc2015 中。

  3. 项目文件根目录现在应该如下所示:

  1. 在完成这些之后,我们终于可以开始着手项目了。

创建窗口和清屏

现在,让我们探索如何使用我们创建的 OpenGL 项目:

  1. 我们首先要做的是创建一个窗口,这样我们就可以开始将游戏对象渲染到屏幕上了。

  2. 创建一个新的 .cpp 文件;Visual Studio 会自动将其命名为 source.cpp,所以保持原样即可。

  3. 在文件顶部,包含glewglfw头文件。确保首先包含glew.h,因为它包含了需要包含的正确 OpenGL 头文件:

#include <GL/glew.h> 
#include <GLFW/glfw3.h> 

Then create a main function and add the following to it. 

int main(int argc, char **argv) 
{ 

   glfwInit(); 

   GLFWwindow* window = glfwCreateWindow(800, 600,
    " Hello OpenGL ", NULL, NULL); 

   return 0; 
} 
  1. 在这里,我们首先需要做的是通过调用glfwInit()来初始化glfw

  2. 一旦初始化完成,我们就可以创建一个窗口,我们的游戏场景将会在这个窗口上渲染。为了创建一个窗口,我们需要创建一个新的GLFWWindow实例,命名为window,并调用glfwCreateWindow函数。这个函数需要五个参数,包括窗口的宽度和高度,以及窗口的名称。最后的两个参数——monitorshare——被设置为NULLmonitor参数指定了窗口将要创建的特定显示器。如果设置为null,则选择默认显示器。share参数允许我们与用户共享窗口资源。在这里,我们将其设置为NULL,因为我们不想共享窗口资源。

  3. 现在,运行项目;你将看到在应用程序关闭之前,窗口会短暂出现。

  4. 嗯,这并不很有趣。让我们添加剩余的代码,这样我们就可以在视口中看到一些被渲染的内容。

  5. 我们首先需要做的是初始化 OpenGL 上下文。OpenGL 上下文是 OpenGL 所有当前状态的集合。我们将在接下来的章节中讨论不同的状态。

  6. 要做到这一点,调用glfwMakeCurrentContext并传入我们刚刚创建的窗口:

glfwMakeContextCurrent(window);     
  1. 我们现在可以通过调用glewInit()来初始化 GLEW。

  2. 接下来,我们将在main函数中的glewInit()return 0之间添加以下代码:

   while (!glfwWindowShouldClose(window)){ 

               // render our scene 

         glfwSwapBuffers(window); 
         glfwPollEvents(); 
   } 

         glfwTerminate();
  1. 在这里,我们创建了一个while循环,调用glfwWindowShouldClose,然后将其传递给当前窗口。当窗口打开时,将执行glfwSwapBuffers(window);glfwPollEvents();命令。

  2. while循环中,我们将渲染我们的场景。然后,我们将交换显示缓冲区。显示缓冲区是当前帧被渲染和存储的地方。当当前帧正在显示时,下一帧实际上在后台被渲染,我们看不到。当下一帧准备好时,当前帧与新的帧进行交换。这种帧的交换是通过glfwSwapBuffer完成的,并由 OpenGL 管理。

  3. 在交换显示缓冲区之后,我们需要检查是否有任何触发的事件,例如在glfwPollEvents()中窗口被关闭。一旦窗口关闭,glfw将被终止。

  4. 如果你现在运行项目,你将看到一个黑色的窗口;虽然它没有消失,但仍然不是很令人印象深刻。我们可以使用 OpenGL 以我们选择的颜色清除视口,所以让我们来做这件事。

  5. 创建一个名为void renderScene()的新函数。从现在开始,我们将把渲染到场景中的任何内容都添加到这个函数中。将void renderScene()的新原型添加到source.cpp文件的顶部。

  6. renderScene函数中,添加以下代码行:

void renderScene(){ 

   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 
   glClearColor(1.0, 0.0, 0.0, 1.0);//clear yellow 

   // Draw game objects here 
}

在第一个函数中,我们调用 glClear()。所有 OpenGL 函数都以 gl 前缀开始;glClear 函数清除缓冲区。在这种情况下,我们要求 OpenGL 清除颜色缓冲区和深度缓冲区。颜色缓冲区存储场景中所有的颜色信息。深度缓冲区存储哪个像素在前面;这意味着如果一个像素在另一个像素后面,那么那个像素将不会被存储。这对于 3D 场景尤为重要,其中一些对象可能位于其他对象后面,并被前面的对象遮挡。我们只需要有关前面对象像素的信息,因为我们只会看到那些对象,而不会看到后面的对象。

接下来,我们调用 glClearColor 函数并传入一个 RGBA 值;在这种情况下,是红色。glClearColor 函数在每一帧中用特定的颜色清除颜色缓冲区。缓冲区需要在每一帧中清除;否则,上一帧将被当前帧中的图像覆盖。想象一下,这就像在每一帧在画板上画任何东西之前清除黑板一样。

深度缓冲区也在每一帧之后使用默认的白色颜色清除。这意味着我们不需要手动清除它,因为这将是默认操作的。

  1. 现在,在交换缓冲区之前调用 renderScene 并再次运行项目。你应该会看到一个漂亮的黄色视口,如下所示:

图片

在绘制对象之前,我们必须创建一些额外的类,这些类将帮助我们定义想要绘制的形状。我们还需要创建一个相机类,以便设置一个虚拟相机,通过这个相机我们可以查看场景。此外,我们需要编写一个基本的顶点、一个 shader 片段和一个 Shaderloader 类,这些类将创建一个 shader 程序,我们可以使用它来渲染我们的形状。

首先,让我们创建 Mesh 类,这是我们定义想要绘制的不同形状的地方。

创建 Mesh 类

以下步骤解释了如何创建一个 Mesh 类:

  1. 创建新的 .h.cpp 文件,分别命名为 Mesh.hMesh.cpp。这些文件将用于创建一个新的 Mesh 类。在 Mesh.h 文件中,添加以下代码:
#include <vector> 
#include "Dependencies/glm/glm/glm.hpp" 

enum MeshType { 

   kTriangle = 0, 
   kQuad = 1, 
   kCube = 2, 
   kSphere = 3 

}; 

struct Vertex { 

   glm::vec3 pos; 
   glm::vec3 normal; 
   glm::vec3 color; 
   glm::vec2 texCoords; 

}; 

class Mesh { 

public: 
   static void setTriData(std::vector<Vertex>& vertices, 
     std::vector<uint32_t>&indices); 
   static void setQuadData(std::vector<Vertex>& vertices,    
     std::vector<uint32_t>&indices); 
   static void setCubeData(std::vector<Vertex>& vertices, 
     std::vector<uint32_t>&indices); 
   static void setSphereData(std::vector<Vertex>& vertices, 
     std::vector<uint32_t>&indices); 

};
  1. Mesh.h 文件的顶部,我们包含一个向量,以便我们可以将点存储在向量中,并包含 glm.hpp。这将帮助我们使用 vec3 变量在空间中定义点。

  2. 然后,我们创建一个新的 enum 类型,称为 MeshType,并创建四种类型:Mesh TriangleQuadCubeSphere。我们这样做是为了指定我们使用的网格类型,并且数据将相应地填充。

  3. 接下来,我们创建一个新的 struct 类型,称为 Vertex,它有 vec3 属性,分别命名为 posColorNormal,以及一个 vec2 属性,称为 textCoords

每个顶点都有某些属性,例如 PositionColorNormalTexture CoordinatePositionColor 分别存储每个顶点的位置和颜色信息。Normal 指定法线属性指向的方向,而 Texture Coordinate 指定纹理应该如何布局。当介绍光照和如何将纹理应用到我们的对象时,我们将介绍法线和纹理坐标属性。

  1. 然后,创建 Mesh 类。这个类有四个函数,用于设置每个顶点的顶点和索引数据。

  2. Mesh.cpp 文件中,我们包含 Mesh.h 文件并设置四个形状的数据。以下是如何 setTriData 为顶点和索引设置值的示例:

#include "Mesh.h" 

void Mesh::setTriData(std::vector<Vertex>& vertices, std::vector<uint32_t>& indices) { 

   std::vector<Vertex> _vertices = { 

{ { 0.0f, -1.0f, 0.0f },          // Position 
{ 0.0f, 0.0f, 1.0 },              // Normal 
{ 1.0f, 0.0f, 0.0 },              // Color 
{ 0.0, 1.0 }                      // Texture Coordinate 
},                                // 0 

         { { 1.0f, 1.0f, 0.0f },{ 0.0f, 0.0f, 1.0 },{ 0.0f, 1.0f, 
          0.0 },{ 0.0, 0.0 } }, // 1 

         { { -1.0f, 1.0f, 0.0f },{ 0.0f, 0.0f, 1.0 },{ 0.0f, 0.0f, 
          1.0 },{ 1.0, 0.0 } }, // 2 
   }; 

   std::vector<uint32_t> _indices = { 
         0, 1, 2, 
   }; 

   vertices.clear(); indices.clear(); 

   vertices = _vertices; 
   indices = _indices; 
} 
  1. 对于三角形的三个顶点,我们在 vertices 向量中设置位置、法线、颜色和纹理坐标信息。

接下来,我们在 indices 向量中设置索引。关于其他函数的定义,您可以参考本书附带的项目。然后,我们将 _vertices_indices 向量分别设置为引用顶点和索引。

创建 Camera

以下步骤将帮助您创建 Camera 类:

  1. 创建两个文件:Camera.hCamera.cpp。在 Camera.h 文件中,包含以下代码:
#include <GL/glew.h> 

#include "Dependencies/glm/glm/glm.hpp" 
#include "Dependencies/glm/glm/gtc/matrix_transform.hpp" 

  1. 然后,创建 Camera 类本身,如下所示:
class Camera 
{ 
public: 

   Camera(GLfloat FOV, GLfloat width, GLfloat height, GLfloat 
     nearPlane, GLfloat farPlane, glm::vec3 camPos); 
   ~Camera(); 

   glm::mat4 getViewMatrix(); 
   glm::mat4 getProjectionMatrix(); 
   glm::vec3 getCameraPosition(); 

private: 

   glm::mat4 viewMatrix; 
   glm::mat4 projectionMatrix; 
   glm::vec3 cameraPos; 

};
  1. camera 类的构造函数和公共区域中,我们获取视野FOV)、视口的宽度和高度、到 nearPlane 的距离、到 farPlane 的距离以及我们想要设置相机位置的坐标。

  2. 我们还添加了三个获取器来获取视图矩阵、投影矩阵和相机位置。

  3. 在私有部分,我们创建三个变量:两个用于设置视图和投影矩阵的 4x4 矩阵和一个 vec3 属性来指定相机位置。

  4. Camera.cpp 文件中,我们在顶部包含 Camera.h 文件并创建 camera 构造函数,如下所示:

#include "Camera.h" 

Camera::Camera(GLfloat FOV, GLfloat width, GLfloat height, GLfloat nearPlane, GLfloat farPlane, glm::vec3 camPos){ 

   cameraPos = camPos; 
   glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, 0.0f); 
   glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f); 

   viewMatrix = glm::lookAt(cameraPos, cameraFront, cameraUp); 
   projectionMatrix = glm::perspective(FOV, width /height, 
                      nearPlane, farPlane); 
} 
  1. 在构造函数中,我们将相机位置设置为局部变量,并设置两个名为 cameraFrontcameraUpvec3 属性。我们的相机将是一个静止的相机,始终朝向世界坐标的中心;up 向量始终指向正 y 轴。

  2. 要创建 viewMatrix,我们调用 glm::lookAt 函数,并传入 cameraPoscameraFrontcameraUp 向量。

  3. 我们通过设置 FOVFOV 值来创建投影矩阵;这是一个由 width 值除以 heightnearPlanefarPlane 值给出的宽高比。

  4. 在设置视图和投影矩阵后,我们现在可以创建获取器函数,如下所示:

glm::mat4 Camera::getViewMatrix() { 

   return viewMatrix; 
} 
glm::mat4 Camera::getProjectionMatrix() { 

   return projectionMatrix; 
} 

glm::vec3 Camera::getCameraPosition() { 

   return cameraPos; 
} 

接下来,我们将创建 shaderLoader 类,这将使我们能够创建 shader 程序。

ShaderLoader

以下步骤将向您展示如何在 OpenGL 项目中实现 ShaderLoader 类:

  1. ShaderLoader 类中,创建一个名为 createProgram 的公共函数,它接收顶点和片段 shader 文件。

  2. 我们还将创建两个私有函数:readShader,它返回一个字符串,以及 createShader,它返回一个无符号的 GL int

#include <GL/glew.h> 

class ShaderLoader { 

   public: 

         GLuint CreateProgram(const char* vertexShaderFilename, 
           const char* fragmentShaderFilename); 

   private: 

         std::string readShader(const char *filename); 
         GLuint createShader(GLenum shaderType, std::string source, 
           const char* shaderName); 
};
  1. ShaderLoader.cpp 文件中,我们包含我们的 ShaderLoader.h 头文件、iostream 系统头文件和 fstream 向量,如下所示:
#include "ShaderLoader.h"  

#include<iostream> 
#include<fstream> 
#include<vector>

iostream 用于当你想要将某些内容打印到控制台时;fstream 用于读取文件。我们将需要它,因为我们将会传递顶点和着色器文件给 fstream 读取,以及用于存储字符字符串的向量。

  1. 首先,我们创建 readerShader 函数;这将用于读取我们传递的 shader 文件:
std::string ShaderLoader::readShader(const char *filename) 
{ 
   std::string shaderCode; 
   std::ifstream file(filename, std::ios::in); 

   if (!file.good()){ 
         std::cout << "Can't read file " << filename << std::endl; 
         std::terminate(); 
   } 

   file.seekg(0, std::ios::end); 
   shaderCode.resize((unsigned int)file.tellg()); 
   file.seekg(0, std::ios::beg); 
   file.read(&shaderCode[0], shaderCode.size()); 
   file.close(); 
   return shaderCode; 
} 

shader 文件的内容随后被存储在一个字符串中并返回。

  1. 接下来,我们创建 createShader 函数,该函数将实际编译着色器,如下所示:
GLuint ShaderLoader::createShader(GLenum shaderType, std::string source, const char* shaderName) 
{ 

   int compile_result = 0; 

   GLuint shader = glCreateShader(shaderType); 
   const char *shader_code_ptr = source.c_str(); 
   const int shader_code_size = source.size(); 

   glShaderSource(shader, 1, &shader_code_ptr, 
     &shader_code_size); 
   glCompileShader(shader); 
   glGetShaderiv(shader, GL_COMPILE_STATUS, 
     &compile_result); 

   //check for errors 

   if (compile_result == GL_FALSE) 
   { 

         int info_log_length = 0; 
         glGetShaderiv(shader, GL_INFO_LOG_LENGTH, 
           &info_log_length); 

         std::vector<char> shader_log(info_log_length); 

         glGetShaderInfoLog(shader, info_log_length, NULL, 
           &shader_log[0]); 
         std::cout << "ERROR compiling shader: " << 
          shaderName << std::endl <<&shader_log[0] <<
          std::endl; 
         return 0; 
   } 
   return shader; 
}  
  1. CreateShader 函数接收以下三个参数:
  • 第一个参数是 enum 参数,称为 shaderType,它指定了要编译的 shader 类型。在这种情况下,它可以是顶点着色器或片段着色器。

  • 第二个参数是包含着色器代码的字符串。

  • 最后一个参数是包含 shader 类型的字符串,它将用于指定在编译 shader 类型时是否存在问题。

  1. CreateShader 函数中,我们调用 glCreateShader 以指定正在创建的着色器类型;然后,调用 glCompileShader 编译着色器。之后,我们获取着色器的编译结果。

  2. 如果编译着色器存在问题,那么我们将发送一条消息,说明存在编译着色器的错误,并附带 shaderLog,其中将详细说明编译错误。如果没有错误发生,则返回着色器。

  3. 最后一个函数是 createProgram 函数,它接收 vertexfragment 着色器:

GLuint ShaderLoader::createProgram (const char* vertexShaderFilename, const char* fragmentShaderFilename){

  std::string vertex_shader_code = readShader 
                                   (vertexShaderFilename);

  std::string fragment_shader_code = readShader 
                                     (fragmentShaderFilename);

  GLuint vertex_shader = createShader (GL_VERTEX_SHADER, 
                         vertex_shader_code,
                         “vertex shader” );

  GLuint fragment_shader = createShader (GL_FRAGMENT_SHADER, 
                           fragment_shader_code,
                           “fragment shader”);

  int link_result = 0;
  //create the program handle, attach the shaders and link it
  GLuint program = glCreateProgram();
  glAttachShader(program, vertex_shader);
  glAttachShader(program, fragment_shader);

  glLinkProgram(program);
  glGetProgramiv(program, GL_LINK_STATUS, &link_result);
  //check for link errors
  if (link_result == GL_FALSE) {

    int info_log_length = 0;
    glGetProgramiv(program, GL_INFO_LOG_LENGTH, &info_log_length);

    std::vector<char> program_log(info_log_length);

    glGetProgramInfoLog(program, info_log_length, NULL, 
      &program_log[0]);
    std::cout << “Shader Loader : LINK ERROR” << std::endl 
      <<&program_log[0] << std::endl;

    return 0;
  }
  return program;
}
  1. 此函数接收顶点和片段着色器文件,读取它们,然后编译这两个文件。

  2. 然后,我们通过调用 glCreateProgram() 创建一个新的 shaderProgram 函数并将其分配给程序。

  3. 现在,我们必须通过调用 glAttachShader 并传递程序和着色器来将两个着色器附加到程序上。

  4. 最后,我们通过调用 glLinkProgram 链接程序。之后,我们传递程序并检查是否有链接错误。

  5. 如果存在任何链接错误,我们将向控制台发送错误消息,并附带一个程序日志,其中将详细说明链接错误。如果没有错误,则返回程序。

光照渲染器类

现在,是时候绘制我们的第一个对象了;为此,执行以下步骤:

  1. 我们将在当前场景上方绘制一个基本的光源,以便我们可以可视化光源在场景中的位置。我们将使用这个光源的位置来计算我们对象上的光照。请注意,具有平面着色的对象不需要在其上进行光照计算。

  2. 首先,创建一个LightRenderer.h文件和一个.cpp文件,然后创建LightRenderer类。

  3. LightRenderer.h文件的顶部,包含以下头文件:

#include <GL/glew.h> 

#include "Dependencies/glm/glm/glm.hpp" 
#include "Dependencies/glm/glm/gtc/type_ptr.hpp" 

#include "Mesh.h" 
#include "ShaderLoader.h"; 
#include "Camera.h"  
  1. 我们需要glew.h来调用 OpenGL 命令,同时我们需要glm头文件来定义vec3和矩阵。

  2. 我们还需要Mesh.h,它允许我们在光源中定义光的形状。你可以使用ShaderLoader类来加载着色器以渲染对象,并使用Camera.h来获取相机在场景中的位置、视图和投影矩阵。

  3. 我们接下来将创建LightRenderer类:

class LightRenderer 
{ 

}; 

我们将向这个类添加以下public部分:

public: 
   LightRenderer(MeshType meshType, Camera* camera); 
   ~LightRenderer(); 

   void draw(); 

   void setPosition(glm::vec3 _position); 
   void setColor(glm::vec3 _color); 
   void setProgram(GLuint program); 

   glm::vec3 getPosition(); 
   glm::vec3 getColor(); 

  1. 在公共部分,我们创建一个构造函数,我们将传递MeshType给它;这将用于设置我们想要渲染的对象的形状。然后,我们有析构函数。在这里,我们有一个名为draw的函数,它将用于绘制网格。然后,我们有几个设置器用于设置对象的位置、颜色和着色器程序。

  2. 在定义公共部分之后,我们设置private部分,如下所示:

private: 

   Camera* camera; 

   std::vector<Vertex>vertices; 
   std::vector<GLuint>indices; 

glm::vec3 position, color; 

GLuint vbo, ebo, vao, program;  
  1. private部分,我们有一个private变量,以便我们可以局部存储相机。我们创建向量来存储顶点和索引数据;我们还创建局部变量来存储位置和颜色信息。然后,我们有GLuint,它将存储vboebovao和程序变量。

程序变量将包含我们想要用于绘制对象的着色器程序。然后,我们有vbo,代表顶点缓冲对象;ebo,代表元素缓冲对象;以及vao,代表顶点数组对象。让我们检查这些缓冲对象并了解它们的作用:

  • 顶点缓冲对象VBO):这是几何信息;它包括位置、颜色、法线和纹理坐标等属性。这些属性在 GPU 上按顶点存储。

  • 元素缓冲对象EBO):用于存储每个顶点的索引,并在绘制网格时使用。

  • 顶点数组对象VAO):这是一个辅助容器对象,用于存储所有的 VBOs 和属性。当你为每个对象渲染每一帧时,你可能会有多个 VBOs,因此再次绑定 VBOs 会变得繁琐。

缓冲区用于在 GPU 内存中存储信息,以便快速高效地访问数据。现代 GPU 的内存带宽大约为 600 GB/s,与当前高端 CPU 的约 12 GB/s 相比,这是一个巨大的差距。

缓冲区对象用于存储、检索和移动数据。在 OpenGL 中生成缓冲区对象非常容易。你可以通过调用glGenBuffers()轻松地生成一个。

这就是LightRender.h的全部内容;现在,让我们继续到LightRenderer.cpp,如下所示:

  1. LightRenderer.cpp的顶部,包含LightRenderer.h。然后,添加构造函数,如下所示:
LightRenderer::LightRenderer(MeshType meshType, Camera* camera) { 

} 
  1. LightRenderer构造函数中,我们开始添加代码。首先,我们初始化本地相机,如下所示:
this->camera = camera; 
  1. 接着,我们设置我们想要绘制的对象的形状,这取决于MeshType类型。为此,我们将创建一个switch语句并调用适当的setData函数,如下所示:
   switch (modelType) { 

         case kTriangle: Mesh::setTriData(vertices, indices); 
           break; 
         case kQuad: Mesh::setQuadData(vertices, indices); break; 
         case kCube: Mesh::setCubeData(vertices, indices); break; 
         case kSphere: Mesh::setSphereData(vertices, indices); 
           break; 
   }  
  1. 接下来,我们将生成并绑定vao缓冲区对象,如下所示:
glGenVertexArrays(1, &vao); 
glBindVertexArray(vao); 

glGenVertexArrays函数需要两个参数;第一个参数是我们想要生成的顶点数组对象名称的数量。在这种情况下,我们只想创建一个,所以它被这样指定。第二个参数接受一个数组,其中存储了顶点数组名称,因此我们传递了vao缓冲区对象。

  1. 接下来调用glBindVertexArray函数,并将vao传递给它以绑定vao缓冲区对象。vao缓冲区对象将在应用程序的整个运行期间被绑定。缓冲区是一个管理特定内存块的对象;缓冲区可以是不同类型的,因此它们需要绑定到特定的缓冲区目标,以便它们可以为缓冲区赋予意义。

  2. 一旦vao缓冲区对象被绑定,我们就可以生成顶点缓冲区对象并存储顶点属性。

  3. 要生成顶点缓冲区对象,我们调用glGenBuffers();这也需要两个参数。第一个参数是我们想要生成的缓冲区数量,而第二个参数是 VBO 数组。在这种情况下,因为我们只有一个vbo缓冲区对象,所以我们将第一个参数设置为1,并将vbo作为第二个参数传递:

glGenBuffers(1, &vbo);  
  1. 接下来,我们必须指定缓冲区类型。这是通过使用glBindBuffer()函数完成的;它再次需要两个参数。第一个是缓冲区类型,在这种情况下,它是GL_ARRAY_BUFFER类型,而第二个参数是缓冲区对象的名称,即vbo。现在,添加以下代码行:
glBindBuffer(GL_ARRAY_BUFFER, vbo);
  1. 在下一步中,我们实际上传递了我们要存储在缓冲区中的数据。这是通过调用glBufferData完成的;glBufferData函数需要四个参数:
  • 第一个参数是缓冲区类型,在这种情况下,是GL_ARRAY_BUFFER

  • 第二个参数是要存储的缓冲区数据的字节数。

  • 第三个参数是指向数据的指针,该数据将被复制。

  • 第四个参数是存储数据的预期用途。

在我们的情况下,我们只需修改一次数据并多次使用它,所以它将被称为GL_STATIC_DRAW

  1. 现在,添加用于存储数据的glBufferData函数,如下所示:
glBufferData(GL_ARRAY_BUFFER,  
sizeof(Vertex) * vertices.size(),  
&vertices[0],  
GL_STATIC_DRAW); 

现在,我们必须设置我们将要使用的顶点属性。在创建struct顶点时,我们有位置、颜色、法线和纹理坐标等属性;然而,我们可能并不总是需要所有这些属性。因此,我们只需要指定我们需要的属性。在我们的例子中,由于我们不使用任何光照计算或应用任何纹理到对象上,我们不需要指定这些属性——我们现在只需要位置和颜色属性。然而,这些属性需要首先启用。

  1. 要启用这些属性,我们将调用glEnableVertexAttribArray并传入我们想要启用的索引。位置将在 0 号索引,因此我们将值设置为如下:
glEnableVertexAttribArray(0);   
  1. 接下来,我们调用glVertexAttribPointer以便我们可以设置我们想要使用的属性。第一个属性将位于 0 号索引。这需要六个参数,如下所示:
  • 第一个参数是顶点属性的索引,在这个例子中,它是 0。

  • 第二个参数是属性的大小。本质上,这是顶点属性拥有的组件数量。在这种情况下,它是xyz组件的位置,因此指定为3

  • 第三个参数用于组件的变量类型;由于它们在GLfloat中指定,我们指定GL_FLOAT

  • 第四个参数是一个布尔值,指定值是否应该被归一化或是否应该转换为定点值。由于我们不希望值被归一化,我们指定GL_FALSE

  • 第五个参数称为步长,它是连续顶点属性之间的偏移量。想象一下顶点在内存中的布局如下。步长指的是你将必须通过以到达下一组顶点属性;这是struct顶点的大小:

图片

  • 第六个参数是顶点属性在struct顶点中第一个组件的偏移量。我们正在查看的属性是位置属性,它在struct顶点的开始处,因此我们将传递0

图片

  1. 设置glVertexAttribute指针,如下所示:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid*)0);
  1. 让我们再创建一个属性指针,以便我们可以为对象着色。像之前一样,我们需要启用属性并设置attrib指针,如下所示:
glEnableVertexAttribArray(1); 
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)(offsetof(Vertex, Vertex::color))); 

  1. 由于下一个属性索引是1,我们使用1启用属性数组。在设置属性指针时,第一个参数是1,因为这是第一个索引。color有三个组件——rgb——所以下一个参数是3。颜色定义为浮点数,因此我们指定GL_FLOAT作为此参数。

  2. 由于我们不希望第四个参数被归一化,我们将参数设置为 GL_FALSE。第五个参数是步长,它仍然等于 struct 顶点的尺寸。最后,对于偏移量,我们使用 offsetof 函数来设置 struct 顶点中 vertex::color 的偏移量。

接下来,我们必须设置元素缓冲区对象。这是通过与设置顶点缓冲区对象相同的方式进行:我们需要生成元素,设置绑定,然后将数据绑定到缓冲区,如下所示:

  1. 首先,我们通过调用 glGenBuffers 来生成缓冲区。这是通过传递我们想要创建的缓冲区数量,即 1,然后传递要生成的缓冲区对象的名称来完成的:
glGenBuffers(1, &ebo); 
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); 

glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLuint) * indices.size(), &indices[0], GL_STATIC_DRAW); 
  1. 然后,我们将缓冲区类型绑定到缓冲区对象上,在这个例子中,它是 GL_ELEMENT_ARRAY_BUFFER。它将存储元素或索引数据。

  2. 然后,我们通过调用 glBufferData 来设置索引数据本身。我们首先传递缓冲区类型,设置元素数据的大小,然后传递数据和用法 GL_STATIC_DRAW,就像我们之前做的那样。

  3. 在构造函数的末尾,我们作为预防措施解绑缓冲区和顶点数组:

glBindBuffer(GL_ARRAY_BUFFER, 0); 
glBindVertexArray(0);
  1. 接下来,我们将创建一个名为 draw 的函数;这个函数将用于绘制对象本身。为此,添加以下 draw 函数:
void LightRenderer::draw() { 

}
  1. 我们将使用这个函数来添加绘制对象的代码。我们首先要做的是创建一个名为 modelglm::mat4 函数并初始化它;然后,我们将使用 glm::translate 函数将对象移动到所需的位置:
glm::mat4 model = glm::mat4(1.0f); 

   model = glm::translate(glm::mat4(1.0),position); 

接下来,我们将设置模型、视图和投影矩阵来将对象从其局部空间转换。这已经在 第二章 中介绍过,即 数学和图形概念,所以现在是你去复习图形概念的好时机。

模型、视图和投影矩阵在顶点着色器中设置。通过调用 glUseProgram 并传入一个着色器程序来将信息发送到着色器。

glUseProgram(this->program); 

然后,我们可以通过统一变量发送所需的信息。在着色器中,我们将使用一个名称创建一个统一数据类型。在 draw 函数中,我们需要通过调用 glGetUniformLocation 来获取这个统一变量的位置,然后传递程序和我们在着色器中设置的变量字符串,如下所示:

   GLint modelLoc = glGetUniformLocation(program, "model"); 

这将返回一个包含变量位置的 GLuint 值,在这里是模型矩阵。

现在,我们可以使用glUniform函数设置模型矩阵的值。由于我们正在设置一个矩阵统一变量,我们使用glUniformMatrix3fv函数;它接受四个参数。第一个参数是我们之前获得的定位,第二个参数是我们传递的数据量;在这种情况下,我们只传递一个矩阵,所以我们将其指定为1。第三个参数是一个布尔值,它指定数据是否需要转置。我们不希望矩阵被转置,所以我们将其指定为GL_FALSE。最后一个参数是数据的指针,gl::ptr_value;我们将其传递给模型矩阵。

现在,添加设置模型矩阵的函数,如下所示:

glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));

与模型矩阵类似,我们必须将视图和投影矩阵传递给着色器。为此,我们从camera类中获取视图和投影矩阵。然后,我们获取在着色器中定义的统一变量的位置,并使用glUniformMatrix4fv函数设置视图和投影矩阵的值:

   glm::mat4 view = camera->getViewMatrix(); 
   GLint vLoc = glGetUniformLocation(program, "view"); 
   glUniformMatrix4fv(vLoc, 1, GL_FALSE, glm::value_ptr(view)); 

   glm::mat4 proj = camera->getprojectionMatrix(); 
   GLint pLoc = glGetUniformLocation(program, "projection"); 
   glUniformMatrix4fv(pLoc, 1, GL_FALSE, glm::value_ptr(proj)); 

一旦我们有了绘制对象所需的所有数据,我们就可以最终绘制对象了。在这个时候,我们调用glBindVertexArray,绑定vao缓冲区对象,然后调用glDrawElements函数来绘制对象。

glDrawElements函数接受四个参数。第一个参数是我们可以通过调用GL_LINES来绘制的线条模式。或者,我们可以使用GL_TRIANGLES来绘制三角形。实际上,还有更多类型的模式可以指定,但在这个案例中,我们只会指定GL_TRIANGLES

第二个参数是需要绘制的元素数量或索引数量。这是在我们创建对象时指定的。第三个参数是我们将要传递的索引数据类型,它是GL_UNSIGNED_INT类型。最后一个参数是索引存储的位置——这被设置为 0。

添加以下代码行:

glBindVertexArray(vao); 
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0); 

为了安全起见,我们将通过将它们的值设置为0来解绑顶点数组和程序变量:

glBindVertexArray(0); 
glUseProgram(0); 

这标志着draw函数的结束。

添加析构函数以及其余的设置器和获取器以完成类的定义,如下所示:

LightRenderer::~LightRenderer() { 

} 

void LightRenderer::setPosition(glm::vec3 _position) { 

   position = _position; 
} 

void LightRenderer::setColor(glm::vec3 _color) { 

   this->color = _color; 
} 

void LightRenderer::setProgram(GLuint _program) { 

   this->program = _program; 
} 

//getters 
glm::vec3 LightRenderer::getPosition() { 

   return position; 
} 

glm::vec3 LightRenderer::getColor() { 

   return color; 
} 

绘制对象

让我们回到source.cpp文件,并按照以下方式渲染LightRenderer

  1. 在文件顶部,包含ShaderLoader.hCamera.hLightRenderer.h,然后创建一个名为cameralightCameraLightRenderer类实例,如下所示:
#include "ShaderLoader.h" 
#include "Camera.h" 
#include "LightRenderer.h" 
Camera* camera; 
LightRenderer* light;
  1. 创建一个名为initGame的新函数,并将其原型添加到文件顶部。在gameInit函数中,加载着色器并初始化相机和光源。

  2. 添加新的函数,如下所示:

 void initGame(){ 
... 

}  
  1. 我们首先要做的是启用深度测试,以便只绘制前方的像素。这是通过调用glEnable()函数并传入GL_DEPTH_TEST变量来完成的;这将启用以下深度测试:
   glEnable(GL_DEPTH_TEST); 
  1. 接下来,在init函数中,我们将创建一个名为shader的新ShaderLoader实例。然后,我们需要调用createProgram函数并将顶点和片段着色器文件传递进去以着色光源。程序将返回一个GLuint值,我们将其存储在一个名为flatShaderProgram的变量中,如下所示:
ShaderLoader shader; 

GLuint flatShaderProgram = shader.createProgram("Assets/Shaders/FlatModel.vs", "Assets/Shaders/FlatModel.fs"); 
  1. 顶点和着色器文件位于Shaders文件夹下的Assets文件夹中;FlatModel.vs文件将如下所示:
#version 450 core 

layout (location = 0) in vec3 Position; 
layout (location = 1) in vec3 Color; 

uniform mat4 projection; 
uniform mat4 view; 
uniform mat4 model; 

out vec3 outColor; 

void main(){ 

   gl_Position = projection * view * model * vec4(Position, 1.0); 

   outColor = Color; 
}

#version指定了我们使用的 GLSL 版本,即450。这代表 OpenGL 版本 4.50。接下来,layout (location = 0)layout (location = 1)指定了传入的顶点属性的位置;在这种情况下,这是位置和颜色。01索引对应于设置vertexAttribPointer时的索引号。在指定的变量中,这些数据被放置在着色器中,并存储在着色器特定的vec3数据类型中,称为PositionColor

我们从draw调用中发送的用于存储模型、视图和投影矩阵的三个统一变量存储在一个名为uniform的变量类型和一个mat4存储数据类型中,两者都是矩阵类型。之后,我们创建另一个名为out类型的变量,指定这将从顶点着色器发送出去;这是vec3类型,称为outColor。接下来,所有实际工作都在main函数内部完成。为此,我们通过乘以模型、视图和投影矩阵来变换局部坐标系。结果是存储在名为gl_Position的 GLSL 内建变量中——这是物体的最终位置。然后,我们将Color属性存储在我们创建的名为outColorout vec3变量中——这就是顶点着色器的工作内容!

  1. 接下来,让我们看一下片段着色器的FlatModel.fs文件:
#version 450 core 

in vec3 outColor; 

out vec4 color; 

void main(){ 

   color = vec4(outColor, 1.0f); 

} 

在片段着色器文件中,我们也指定了我们使用的 GLSL 版本。

接下来,我们指定一个名为outColorin vec3变量,它将是从顶点着色器发送出的颜色。这可以在片段着色器中使用。我们还创建了一个名为colorout vec4变量,它将从片段着色器发送出去,并用于着色物体。从片段着色器发送出的颜色预期是一个vec4变量。然后,在主函数中,我们将outColorvec3变量转换为vec4变量,并将其设置为color变量。

在着色器中,我们可以通过执行以下操作将vec3变量转换为vec4变量。这看起来可能有点奇怪,但为了方便起见,这种独特功能在着色器编程中可用,使我们的生活变得稍微容易一些。

  1. 返回到source.cpp文件,当我们传递顶点和片段着色器文件时,它们将创建flatShaderProgram。接下来,在initGame函数中,我们创建并初始化相机,如下所示:
camera = new Camera(45.0f, 800, 600, 0.1f, 100.0f, glm::vec3(0.0f, 
         4.0f, 6.0f)); 

这里,我们创建了一个具有45度视野、宽度和高度为800 x 600、近平面和远平面分别为0.1f100.0f的新相机,以及沿X0的位置、沿 Y 轴4.0的位置和沿 Z 轴6.0的位置。

  1. 接下来,我们创建light,如下所示:
light = new LightRenderer(MeshType::kTriangle, camera); 
light->setProgram(flatShaderProgram); 
light->setPosition(glm::vec3(0.0f, 0.0f, 0.0f)); 
  1. 这是通过三角形的形状实现的,然后将其传递给相机。然后,我们将着色器设置为flatShaderProgram并将位置设置为世界中心。

  2. 现在,我们在renderScene()函数中调用光的draw函数,如下所示:

void renderScene(){ 

   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 
   glClearColor(1.0, 1.0, 0.0, 1.0);//clear yellow  
   light->draw(); 

}
  1. 我将清除屏幕颜色改为黄色,以便可以清楚地看到三角形。接下来,在main函数中调用initGame函数,如下所示:
int main(int argc, char **argv) 
{ 

   glfwInit(); 

   GLFWwindow* window = glfwCreateWindow(800, 600, 
                        " Hello OpenGL ", NULL, NULL); 

   glfwMakeContextCurrent(window); 

   glewInit(); 

   initGame(); 

   while (!glfwWindowShouldClose(window)){ 

         renderScene(); 

         glfwSwapBuffers(window); 
         glfwPollEvents(); 
   } 

   glfwTerminate(); 

   delete camera; 
   delete light; 

   return 0; 
}
  1. 在最后删除相机和光源,以便释放系统资源。

  2. 现在,运行项目以查看我们设置为光源形状的辉煌三角形:

图片

  1. MeshType类型更改为cube以查看绘制的是立方体:

图片

如果你得到错误而不是彩色对象作为输出,那么这可能意味着你做错了什么,或者你的驱动程序没有更新,你的 GPU 不支持 OpenGL 4.5。

  1. 为了确保 GLFW 支持你的驱动程序版本,添加以下代码,该代码检查任何 GLFW 错误。然后,运行项目并查看控制台输出中的任何错误:
static void glfwError(int id, const char* description)
{
  std::cout << description << std::endl;
}

int main(int argc, char **argv)
{

  glfwSetErrorCallback(&glfwError);

  glfwInit();

  GLFWwindow* window = glfwCreateWindow(800, 600, " Hello OpenGL ", 
                       NULL, NULL);

  glfwMakeContextCurrent(window);

  glewInit();

  initGame();

  while (!glfwWindowShouldClose(window)){

    renderScene();

    glfwSwapBuffers(window);
    glfwPollEvents();
  }

  glfwTerminate();

  delete camera;
  delete light;

  return 0;
}

如果你得到以下输出,那么这可能意味着你使用的 OpenGL 版本不受支持:

图片

这将伴随着以下错误,提示你使用的 GLSL 版本不受支持:

图片

在这种情况下,将着色器顶部代码的版本更改为330而不是450,然后再次尝试运行项目。

这应该会给出你期望的输出。

摘要

在本章中,我们创建了一个新的 OpenGL 项目并添加了必要的库以使项目工作。然后,我们使用 GLFW 创建了一个新窗口进行工作。在编写几行代码之后,我们能够使用我们选择的颜色清除视口。

接下来,我们开始准备一些可以帮助我们绘制对象(如Mesh类,它定义了对象的形状,以及我们用来查看对象的Camera类)的类。然后,我们创建了一个ShaderLoader类,它帮助我们创建用于绘制对象的着色器程序。

在完成必要的准备后,我们创建了一个LightRenderer类。这个类用于绘制一个代表由形状定义的光源位置的对象。我们使用这个类来绘制我们的第一个对象。

在下一章中,我们将探讨如何通过向渲染引擎添加纹理和物理效果来绘制其他对象。

第七章:基于 Game 对象构建

在最后一章,我们探讨了如何使用 OpenGL 绘制基本形状。现在我们已经掌握了基础知识,让我们通过给对象添加一些纹理来提高它们,这样对象就不会仅仅看起来像一个普通的立方体和球体。

我们可以像上次那样编写我们的物理代码,但是当处理 3D 对象时,编写自己的物理代码可能会变得困难且耗时。为了简化过程,我们将使用外部物理库来处理物理和碰撞检测。

我们将在本章中涵盖以下主题:

  • 创建 MeshRenderer

  • 创建 TextureLoader

  • 添加 Bullet 物理引擎

  • 添加刚体

创建 MeshRenderer

对于绘制常规游戏对象,我们将从 LightRenderer 类中创建一个单独的类,通过添加纹理,并且我们还将通过添加物理属性来给对象添加运动。我们将在本章的下一节中绘制一个纹理对象,并给这个对象添加物理。为此,我们将创建一个新的 .h.cpp 文件,名为 MeshRenderer

MeshRenderer.h 文件中,我们将执行以下操作:

  1. 首先,我们将添加以下包含:
#include <vector> 

#include "Camera.h" 
#include "LightRenderer.h" 

#include <GL/glew.h> 

#include "Dependencies/glm/glm/glm.hpp" 
#include "Dependencies/glm/glm/gtc/matrix_transform.hpp" 
#include "Dependencies/glm/glm/gtc/type_ptr.hpp" 
  1. 接下来,我们将创建类本身,如下所示:
Class MeshRenderer{  

}; 
  1. 我们首先创建 public 部分,如下所示:
   public: 
         MeshRenderer(MeshType modelType, Camera* _camera); 
         ~MeshRenderer(); 

          void draw(); 

         void setPosition(glm::vec3 _position); 
         void setScale(glm::vec3 _scale); 
         void setProgram(GLuint _program); 
         void setTexture(GLuint _textureID); 

在本节中,我们创建一个构造函数,它接受 ModelType_camera。之后,我们添加析构函数。我们有一个单独的函数用于绘制对象。

  1. 然后我们使用一些 setter 函数来设置位置、缩放、着色器程序以及 textureID 函数,我们将使用它来设置对象上的纹理。

  2. 接下来,我们将添加 private 部分,如下所示:

   private: 

         std::vector<Vertex>vertices; 
         std::vector<GLuint>indices; 
         glm::mat4 modelMatrix; 

         Camera* camera; 

         glm::vec3 position, scale; 

               GLuint vao, vbo, ebo, texture, program;  

private 部分,我们有向量来存储顶点和索引。然后,我们有一个名为 modelMatrixglm::mat4 变量,用于存储模型矩阵值。

  1. 我们为相机创建一个局部变量,并为存储位置和缩放值创建 vec3s

  2. 最后,我们有 Gluint 来存储 vaovboebotextureID 和着色器程序。

我们将接着通过以下步骤来设置 MeshRenderer.cpp 文件:

  1. 首先,我们将在 MeshRenderer.cpp 的顶部包含 MeshRenderer.h 文件。

  2. 接下来,我们将为 MeshRenderer 创建构造函数,如下所示:

MeshRenderer::MeshRenderer(MeshType modelType, Camera* _camera) { 

} 
  1. 为了这个,我们首先初始化 camerapositionscale 本地值,如下所示:
   camera = _camera; 

   scale = glm::vec3(1.0f, 1.0f, 1.0f); 
   position = glm::vec3(0.0, 0.0, 0.0);
  1. 然后我们创建一个 switch 语句,就像我们在 LightRenderer 中做的那样,以获取网格数据,如下所示:
   switch (modelType){ 

         case kTriangle: Mesh::setTriData(vertices, indices);  
               break; 
         case kQuad: Mesh::setQuadData(vertices, indices);  
               break; 
         case kCube: Mesh::setCubeData(vertices, indices); 
               break; 
         case kSphere: Mesh::setSphereData(vertices, indices);  
               break; 
   } 
  1. 然后,我们生成并绑定 vaovboebo。此外,我们按照以下方式设置 vboebo 的数据:
   glGenVertexArrays(1, &vao); 
   glBindVertexArray(vao); 

   glGenBuffers(1, &vbo); 
   glBindBuffer(GL_ARRAY_BUFFER, vbo); 
   glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex) * vertices.size(),
   &vertices[0], GL_STATIC_DRAW); 

   glGenBuffers(1, &ebo); 
   glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); 
   glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLuint) * 
      indices.size(), &indices[0], GL_STATIC_DRAW); 
  1. 下一步是设置属性。在这种情况下,我们将设置 position 属性,但不是颜色,我们将设置纹理坐标属性,因为它将用于在对象上设置纹理。

  2. 0 索引处的属性仍然是一个顶点位置,但这次第一个索引处的属性将是一个纹理坐标,如下面的代码所示:

glEnableVertexAttribArray(0);

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
   (GLvoid*)0);

glEnableVertexAttribArray(1);

glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),   
   (void*)(offsetof(Vertex, Vertex::texCoords)));

在这里,顶点位置的属性保持不变,但对于纹理坐标,第一个索引如之前一样被启用。变化发生在组件数量上。纹理坐标在x轴和y轴上定义,因为这是一个 2D 纹理,所以对于第二个参数,我们指定2而不是3。步长仍然保持不变,但偏移量改为texCoords

  1. 为了关闭构造函数,我们解绑缓冲区和vertexArray,如下所示:
glBindBuffer(GL_ARRAY_BUFFER, 0); 
glBindVertexArray(0); 
  1. 我们现在添加draw函数,如下所示:
void MeshRenderer::draw() { 

} 

  1. 在这个draw函数中,我们首先将模型矩阵设置为以下内容:

   glm::mat4 TranslationMatrix = glm::translate(glm::mat4(1.0f),  
      position); 

   glm::mat4 scaleMatrix = glm::scale(glm::mat4(1.0f), scale); 

   modelMatrix = glm::mat4(1.0f); 

   modelMatrix = TranslationMatrix *scaleMatrix; 
  1. 我们将创建两个矩阵来存储translationMatrixscaleMatrix,然后设置它们的值。

  2. 然后我们将初始化modelMatrix变量,将缩放和变换矩阵相乘,并将它们赋值给modelMatrix变量。

  3. 接下来,我们不再创建单独的视图和投影矩阵,而是可以创建一个名为vp的单个矩阵,并将乘积的视图和投影矩阵赋值给它,如下所示:

glm::mat4 vp = camera->getprojectionMatrix() * camera->
               getViewMatrix(); 

显然,视图和投影矩阵相乘的顺序很重要,不能颠倒。

  1. 我们现在可以将值发送到 GPU。

  2. 在我们将值发送到着色器之前,我们必须做的第一件事是调用glUseProgram并设置着色器程序,以便数据被发送到正确的程序。一旦完成,我们就可以设置vpmodelMatrix的值,如下所示:

glUseProgram(this->program); 

GLint vpLoc = glGetUniformLocation(program, "vp"); 
glUniformMatrix4fv(vpLoc, 1, GL_FALSE, glm::value_ptr(vp)); 

GLint modelLoc = glGetUniformLocation(program, "model"); 
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(modelMatrix));  
  1. 接下来,我们将绑定texture对象。我们使用glBindTexture函数来绑定纹理。该函数接受两个参数,第一个是纹理目标。我们有一个 2D 纹理,因此我们将GL_TEXTURE_2D作为第一个参数传递,并将纹理 ID 作为第二个参数。为此,我们添加以下行来绑定纹理:
glBindTexture(GL_TEXTURE_2D, texture);  

你可能想知道为什么在设置纹理位置时我们没有使用glUniformMatrix4fv或类似函数,就像我们为矩阵所做的那样。嗯,因为我们只有一个纹理,程序默认将统一位置设置为 0 索引,所以我们不必担心这一点。这就是我们绑定纹理所需的所有内容。

  1. 接下来,我们可以绑定vao并绘制对象,如下所示:
glBindVertexArray(vao);           
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);   
  1. 最后,按照以下方式解绑VertexArray
glBindVertexArray(0); 

  1. 接下来,我们将添加析构函数和setters的定义,如下所示:
MeshRenderer::~MeshRenderer() { 

} 

// setters  

void MeshRenderer::setTexture(GLuint textureID) { 

   texture = textureID; 

} 

void MeshRenderer::setScale(glm::vec3 _scale) { 

   this->scale = _scale; 
} 

void MeshRenderer::setPosition(glm::vec3 _position) { 

   this->position = _position; 
} 

void MeshRenderer::setProgram(GLuint _program) { 

   this->program = _program; 
} 

创建 TextureLoader 类

我们创建了MeshRenderer类,但我们仍然需要加载纹理并设置纹理 ID,这可以传递给MeshRenderer对象。为此,我们将创建一个TextureLoader类,该类将负责加载纹理。让我们看看如何做到这一点。

我们首先需要创建一个新的.h.cpp文件,名为TextureLoader

要加载 JPEG 或 PNG 图像,我们将使用一个仅包含头文件的库,称为 STB。可以从github.com/nothings/stb下载。从链接克隆或下载源代码,并将stb-master文件夹放置在Dependencies文件夹中。

TextureLoader类中,添加以下内容:

#include <string> 
#include <GL/glew.h> 

class TextureLoader 
{ 
public: 
   TextureLoader(); 

   GLuint getTextureID(std::string  texFileName); 
   ~TextureLoader(); 
}; 

然后,我们将使用stringglew.h库,因为我们将会传递 JPEG 所在文件的路径,STB将从那里加载文件。我们将添加构造函数和析构函数,因为它们是必需的;否则,编译器会给出错误。然后,我们将创建一个名为getTextureID的函数,它接受一个字符串作为输入并返回GLuint,这将作为纹理 ID。

TextureLoader.cpp文件中,我们包含了TextureLoader.h。然后添加以下代码以包含STB

#define STB_IMAGE_IMPLEMENTATION 
#include "Dependencies/stb-master/stb_image.h" 

我们添加#define,因为它在TextureLoader.cpp文件中是必需的,导航到stb_image.h,并将其包含到项目中。然后添加构造函数和析构函数,如下所示:


TextureLoader::TextureLoader(){ 

} 

TextureLoader::~TextureLoader(){ 

} 

接下来,我们创建getTextureID函数,如下所示:

GLuint TextureLoader::getTextureID(std::string texFileName){ 

}  

getTextureID函数中,我们首先创建三个int变量来存储宽度、高度和通道数。图像通常只有三个通道:红色、绿色和蓝色。然而,它可能有一个第四个通道,即 alpha 通道,用于透明度。JPEG 图片只有三个通道,但 PNG 文件可能有三个或四个通道。

在我们的游戏中,我们只会使用 JPEG 文件,因此channels参数始终为三个,如下代码所示:

   int width, height, channels;  

我们将使用stbi_load函数将图像数据加载到无符号字符指针中,如下所示:

stbi_uc* image = stbi_load(texFileName.c_str(), &width, &height,   
                 &channels, STBI_rgb); 

函数接受五个参数。第一个是文件/文件名的字符串。然后,它作为第二、第三和第四个参数返回宽度、高度和通道数,并在第五个参数中设置所需的组件。在这种情况下,我们只想有rgb通道,所以我们指定STBI_rgb

然后,我们必须按照以下方式生成和绑定纹理:

GLuint mtexture; 
glGenTextures(1, &mtexture); 
glBindTexture(GL_TEXTURE_2D, mtexture);    

首先,创建一个名为mtextureGLuint类型的纹理 ID。然后,我们调用glGenTextures函数,传入我们想要创建的对象数量,并传入数组名称,即mtexture。我们还需要通过调用glBindTexture并传入纹理类型来绑定纹理类型,即GL_TEXTURE_2D,指定它是一个 2D 纹理,并声明纹理 ID。

接下来,我们必须设置纹理包裹。纹理包裹决定了当纹理坐标在xy方向上大于或小于1时会发生什么。

纹理可以以四种方式之一进行包裹:GL_REPEATGL_MIRRORED_REPEATGL_CLAMP_TO_EDGEGL_CLAMP_TO_BORDER

如果我们想象一个纹理被应用到四边形上,那么正的s轴水平运行,而t轴垂直运行,从原点(左下角)开始,如下面的截图所示:

让我们看看纹理可以如何被包裹的不同方式,如下列所示:

  • GL_REPEAT 在应用于四边形时只是重复纹理。

  • GL_MIRROR_MIRROR_REPEAT 重复纹理,但下一次也会镜像纹理。

  • GL_CLAMP_TO_EDGE 将纹理边缘的 rgb 值重复应用于整个对象。在下面的截图中,红色边缘像素被重复。

  • GL_CLAMP_TO_BORDER 采用用户特定的值并将其应用于对象的末端,而不是应用边缘颜色,如下面的截图所示:

对于我们的目的,我们需要 GL_REPEAT,这已经是默认设置,但如果你必须设置它,你需要添加以下内容:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);  

你使用 glTexParameteri 函数,它接受三个参数。第一个是纹理类型,即 GL_TEXTURE_2D。下一个参数是你想要应用包裹方向的参数,即 STS 方向与 x 相同,Ty 相同。最后一个参数是包裹参数本身。

接下来,我们可以设置纹理过滤。有时,当你将低质量纹理应用于大四边形时,如果你放大查看,纹理将会出现像素化,如下面截图的左侧所示:

左侧的图片是设置纹理过滤为 GL_NEAREST 的输出,右侧的图片是应用纹理过滤到 GL_LINEAR 的结果。GL_LINEAR 包裹线性插值周围的纹理元素值,与 GL_NEAREST 相比,给出了更平滑的结果。

当纹理被放大时,最好将值设置为 GL_LINEAR 以获得更平滑的图像,而当图像被缩小时,可以将其设置为 GL_NEAREST,因为纹理元素(即纹理元素)将非常小,我们无论如何都看不到它们。

要设置纹理过滤,我们使用相同的 glTexParameteri 函数,但不是将包裹方向作为第二个参数传递,而是指定 GL_TEXTURE_MIN_FILTERGL_TEXTURE_MAG_FILTER 作为第二个参数,并将 GL_NEARESTGL_LINEAR 作为第三个参数,如下所示:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 

加载一个巨大的图像与对象如此之远以至于你甚至看不到它是没有意义的,因此出于优化的目的,你可以创建米普图。米普图基本上是将纹理转换为较低的分辨率。当纹理远离相机时,它将自动将图像转换为较低的分辨率图像。当相机更近时,它也会转换为较高的分辨率图像。

这是我们所使用纹理的米普链:

可以使用glTexParameteri函数再次设置米普图质量。这基本上是用GL_NEAREST替换为GL_NEAREST_MIPMAP_NEARESTGL_LINEAR_MIPMAP_NEARESTGL_NEAREST_MIPMAP_LINEARGL_LINEAR_MIPMAP_LINEAR

最佳选项是GL_LINEAR_MIPMAP_LINEAR,因为它在两个米普图中以及样本之间线性插值了纹理单元的值,同样也在周围的纹理单元之间进行线性插值(纹理单元是图像中最低的单位,就像像素是屏幕上表示颜色的最小单位一样。如果在一台 1080p 的屏幕上显示一张 1080p 的图片,那么 1 个纹理单元就映射到 1 个像素)。

因此,我们将使用以下作为我们新的过滤/米普图值:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 
   GL_LINEAR_MIPMAP_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 

一旦设置完毕,我们就可以最终使用glTexImage2D函数创建纹理,如下所示:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0,GL_RGB, 
    GL_UNSIGNED_BYTE, image); 

glTexImage2D函数接受九个参数。这些参数如下所述:

  • 第一个是纹理类型,它是GL_TEXTURE_2D

  • 第二个是米普图级别。如果我们想使用较低质量的图片,可以将此值设置为123。为了我们的目的,我们将保留此值为0,这是基本级别。

  • 对于第三个参数,我们将指定我们想要从图像中存储的所有全色通道。由于我们想要存储所有三个通道,我们指定GL_RGB

  • 我们指定的第四和第五个参数是图片的宽度和高度。

  • 下一个参数必须设置为0,如文档中指定(文档可以在www.khronos.org/registry/OpenGL-Refpages/gl4/html/glTexImage2D.xhtml找到)。

  • 我们指定的下一个参数是图像源的数据格式。

  • 下一个参数是传入的数据类型,它是GL_UNSIGNED_BYTE

  • 最后,我们设置图像数据。

现在纹理已经创建,我们调用glGenerateMipmap并传入GL_TEXTURE_2D纹理类型,如下所示:

glGenerateMipmap(GL_TEXTURE_2D); 

然后,我们解绑纹理,释放图片,并最终像这样返回textureID函数:

glBindTexture(GL_TEXTURE_2D, 0); 
stbi_image_free(image); 

   return mtexture; 

所有的这些工作完成后,我们最终将我们的纹理添加到游戏对象中。

source.cpp中,通过以下步骤包含MeshRenderer.hTextureLoader.h

  1. 在顶部,创建一个名为球体的MeshRenderer指针对象,如下所示:
Camera* camera; 
LightRenderer* light; 
MeshRenderer* sphere;
  1. init函数中,创建一个新的GLuint类型的着色器程序,名为texturedShaderProgram,如下所示:
GLuint flatShaderProgram = shader.CreateProgram(
                           "Assets/Shaders/FlatModel.vs", 
                           "Assets/Shaders/FlatModel.fs"); 
GLuint texturedShaderProgram = shader.CreateProgram(
                               "Assets/Shaders/TexturedModel.vs",   
                               "Assets/Shaders/TexturedModel.fs");
  1. 我们现在将加载两个名为TexturedModel.vsTexturedModel.fs的着色器,如下所示:
  • 这里是TexturedModel.vs着色器:
#version 450 core 
layout (location = 0) in vec3 position; 
layout (location = 1) in vec2 texCoord; 

out vec2 TexCoord; 

uniform mat4 vp; 
uniform mat4 model; 

void main(){ 

   gl_Position = vp * model *vec4(position, 1.0); 

   TexCoord = texCoord; 
} 

FlatModel.vs的唯一区别是,在这里,第二个位置是一个名为texCoordvec2。我们在main函数中创建一个输出vec2,名为TexCoord,我们将在这个值中存储这个值。

  • 这里是TexturedModel.fs着色器:
 #version 450 core 

in vec2 TexCoord; 

out vec4 color; 

// texture 
uniform sampler2D Texture; 

void main(){ 

         color = texture(Texture, TexCoord);  
} 

我们创建一个新的vec2,名为TexCoord,以接收从顶点着色器传来的值。

然后,我们创建一个新的统一类型sampler2D,并命名为Texture。纹理通过一个采样器接收,该采样器将根据我们在创建纹理时设置的包装和过滤参数来采样纹理。

然后,根据采样器和纹理坐标使用texture函数设置颜色。此函数将采样器和纹理坐标作为参数。根据采样器,在纹理坐标处的 texel 被采样,并返回该颜色值,并将其分配给该纹理坐标处的对象。

让我们继续创建MeshRenderer对象。使用TextureLoader类的getTextureID函数加载globe.jpg纹理文件,并将其设置为名为sphereTextureGLuint,如下所示:

 TextureLoader tLoader; 
GLuint sphereTexture = tLoader.getTextureID("Assets/Textures/globe.jpg");  

创建球体MeshRederer对象,设置网格类型,并传递摄像机。设置程序、纹理、位置和缩放,如下所示:

   sphere = new MeshRenderer(MeshType::kSphere, camera); 
   sphere->setProgram(texturedShaderProgram); 
   sphere->setTexture(sphereTexture); 
   sphere->setPosition(glm::vec3(0.0f, 0.0f, 0.0f)); 
   sphere->setScale(glm::vec3(1.0f)); 

renderScene函数中,按照以下方式绘制sphere对象:

void renderScene(){ 

   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 
   glClearColor(1.0, 1.0, 0.0, 1.0); 

   sphere->draw(); 

}  

运行项目后,您应该会看到带有纹理的地球,如下面的截图所示:

截图

摄像机创建如下,并将其设置为四单位的z位置:


camera = new Camera(45.0f, 800, 600, 0.1f, 100.0f, glm::vec3(0.0f, 
         0.0f, 4.0f)); 

添加 Bullet 物理

要将物理元素添加到我们的游戏中,我们将使用 Bullet 物理引擎。这是一个开源项目,在 AAA 游戏和电影中得到了广泛应用。它用于碰撞检测以及软体和刚体动力学。该库对商业用途免费。

github.com/bulletphysics/bullet3下载源代码,并使用 CMake 构建 x64 的发布版本项目。为了方便起见,该章节的项目中包含了头文件和lib文件。您可以将文件夹复制并粘贴到dependencies文件夹中。

现在我们有了文件夹,让我们看看如何按照以下步骤添加 Bullet 物理:

  1. 如下截图所示,将include文件夹添加到“C/C++ | 一般 | 额外包含目录”:

截图

  1. 在链接器设置中,将lib/win64/Rls文件夹添加到“链接器 | 一般 | 额外库目录”:

截图

  1. BulletCollision.libBulletDynamics.libLinearMath.lib添加到“链接器 | 输入 | 额外依赖项”,如下面的截图所示:

截图

这些库负责根据重力、外力等条件计算游戏对象的运动,进行碰撞检测和内存分配。

  1. 准备工作完成之后,我们就可以开始将物理元素添加到游戏中了。在source.cpp文件中,将btBulletDynamicsCommon.h包含在文件顶部,如下所示:
#include "Camera.h" 
#include "LightRenderer.h" 
#include "MeshRenderer.h" 
#include "TextureLoader.h" 

#include <btBulletDynamicsCommon.h> 
  1. 然后,创建一个新的指向btDiscreteDynamicsWorld的指针对象,如下所示:
btDiscreteDynamicsWorld* dynamicsWorld; 
  1. 此对象跟踪当前场景中所有物理设置和对象。

然而,在创建dynamicWorld之前,Bullet 物理库需要首先初始化一些对象。

这些必需的对象如下列出:

  • btBroadPhaseInerface:碰撞检测实际上分为两个阶段:broadphasenarrowphase。在broadphase阶段,物理引擎消除所有不太可能发生碰撞的对象。这个检查是通过使用对象的边界框来完成的。然后,在narrowphase阶段,使用对象的实际形状来检查碰撞的可能性。具有强烈碰撞可能性的对象对被创建。在以下屏幕截图中,围绕球体的红色框用于broadphase碰撞,而球体的白色线网用于narrowphase碰撞:

图片

  • btDefaultColliusion configuration:这用于设置默认内存。

  • btCollisionDispatcher: 使用实际形状测试具有强烈碰撞可能性的对象对以检测碰撞。这用于获取碰撞检测的详细信息,例如哪个对象与哪个其他对象发生了碰撞。

  • btSequentialImpulseConstraintSolver:你可以创建约束,例如铰链约束或滑块约束,这些约束可以限制一个物体相对于另一个物体的运动或旋转。例如,如果墙壁和门之间存在铰链关节,那么门只能绕着关节旋转,不能移动,因为它在铰链关节处是固定的。约束求解器负责正确计算这一点。计算会重复多次,以接近最优解。

init函数中,在我们创建sphere对象之前,我们将按照以下方式初始化这些对象:

//init physics 
btBroadphaseInterface* broadphase = new btDbvtBroadphase(); 
btDefaultCollisionConfiguration* collisionConfiguration = 
   new btDefaultCollisionConfiguration(); 
btCollisionDispatcher* dispatcher = 
   new btCollisionDispatcher(collisionConfiguration); 
btSequentialImpulseConstraintSolver* solver = 
   new btSequentialImpulseConstraintSolver(); 
  1. 然后,我们将通过将dispatcherbroadphasesolvercollisionConfiguration作为参数传递给btDiscreteDynamicsWorld函数来创建一个新的dynamicWorld,如下所示:
dynamicsWorld = new btDiscreteDynamicsWorld(dispatcher, broadphase, solver, collisionConfiguration); 
  1. 现在我们已经创建了物理世界,我们可以设置物理参数。基本参数是重力。我们将其值设置为现实世界的条件,如下所示:
dynamicsWorld->setGravity(btVector3(0, -9.8f, 0)); 

添加刚体

现在我们可以创建刚体或软体,并观察它们与其他刚体或软体的相互作用。刚体是一个不会改变其形状或物理特性的有生命或无生命物体。另一方面,软体可以是可挤压的,并使其形状发生变化。

在以下示例中,我们将专注于创建刚体。

要创建一个刚体,我们必须指定物体的形状和运动状态,然后设置物体的质量和惯性。形状是通过btCollisionShape定义的。一个物体可以有不同的形状,有时甚至是一个形状的组合,称为复合形状。我们使用btBoxShape来创建立方体和长方体,使用btSphereShape来创建球体。我们还可以创建其他形状,如btCapsuleShapebtCylinderShapebtConeShape,这些形状将由库用于narrowphase碰撞。

在我们的案例中,我们将创建一个球体形状并观察我们的地球球体弹跳。所以,让我们开始吧:

  1. 使用以下代码创建一个btSphere用于创建球形,并将半径设置为1.0,这也是我们渲染的球体的半径:
   btCollisionShape* sphereShape = new btSphereShape(1.0f);   
  1. 接下来,设置btDefaultMotionState,其中我们指定球体的旋转和位置,如下所示:
btDefaultMotionState* sphereMotionState = new btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), btVector3(0, 10.0f, 0))); 

我们将旋转设置为0,并将刚体的位置设置为沿y轴的10.0f距离。我们还应该设置质量和惯性,并计算sphereShape的惯性,如下所示:

btScalar mass = 10.0; 
btVector3 sphereInertia(0, 0, 0); 
sphereShape->calculateLocalInertia(mass, sphereInertia); 

  1. 要创建刚体,我们首先必须创建btRigidBodyConstructionInfo并将其变量传递给它,如下所示:
btScalar mass = 10.0; 
btVector3 sphereInertia(0, 0, 0); 
sphereShape->calculateLocalInertia(mass, sphereInertia); 

btRigidBody::btRigidBodyConstructionInfo sphereRigidBodyCI(mass, 
sphereMotionState, sphereShape, sphereInertia); 

  1. 现在,通过将btRigidBodyConstructionInfo传递给它来创建刚体对象,如下所示:
btRigidBody* sphereRigidBody = new btRigidBody(sphereRigidBodyCI); 
  1. 现在,使用以下代码设置刚体的物理属性,包括摩擦和恢复力:
sphereRigidBody->setRestitution(1.0f); 
sphereRigidBody->setFriction(1.0f);  

这些值介于0.0f1.0.0.0之间,意味着物体非常光滑且没有摩擦,没有恢复力或弹性。另一方面,1.0表示物体外部粗糙且弹性极强,就像一个弹跳球。

  1. 在设置完这些必要的参数后,我们需要将刚体添加到我们创建的dynamicWorld中,如下所示,使用dynamicsWorldaddRigidBody函数:
dynamicsWorld->addRigidBody(sphereRigidBody); 

现在,为了让我们的球体网格真正像球体刚体一样表现,我们必须将刚体传递给球体网格类并做一些小的修改。打开MeshRenderer.h.cpp文件。在MeshRenderer.h文件中,包含btBulletDynamicsCommon.h头文件,并在private部分添加一个名为rigidBody的本地btRigidBody。您还应该将构造函数修改为接受一个刚体,如下所示:

#include <btBulletDynamicsCommon.h> 

   class MeshRenderer{ 

public: 
MeshRenderer(MeshType modelType, Camera* _camera, btRigidBody* _rigidBody); 
         . 
         . 
   private: 
         . 
         . 
         btRigidBody* rigidBody; 
};
  1. MeshRenderer.cpp文件中,将构造函数修改为接受一个rigidBody变量,并将局部rigidBody变量设置为它,如下所示:
MeshRenderer::MeshRenderer(MeshType modelType, Camera* _camera, btRigidBody* _rigidBody) { 

   rigidBody = _rigidBody; 
   camera = _camera; 
   . 
   . 
}
  1. 然后,在draw函数中,我们必须替换设置modelMatrix变量的代码,使用获取球体刚体值的代码,如下所示:
   btTransform t; 

   rigidBody->getMotionState()->getWorldTransform(t); 
  1. 我们使用btTransform变量从刚体的getMotionState函数中获取变换,然后获取WorldTransform变量并将其设置为我们的brTransform变量t,如下所示:
   btQuaternion rotation = t.getRotation(); 
   btVector3 translate = t.getOrigin(); 
  1. 我们创建两个新的 btQuaternion 类型的变量来存储旋转,以及一个 btVector3 类型的变量来存储变换值,使用 btTransform 类的 getRotationgetOrigin 函数,如下所示:
glm::mat4 RotationMatrix = glm::rotate(glm::mat4(1.0f), rotation.getAngle(),glm::vec3(rotation.getAxis().getX(),rotation.getAxis().getY(), rotation.getAxis().getZ())); 

glm::mat4 TranslationMatrix = glm::translate(glm::mat4(1.0f), 
                              glm::vec3(translate.getX(),  
                              translate.getY(), translate.getZ())); 

glm::mat4 scaleMatrix = glm::scale(glm::mat4(1.0f), scale); 
  1. 接下来,我们创建三个 glm::mat4 类型的变量,分别称为 RotationMatrixTranslationMatrixScaleMatrix,并使用 glm::rotateglm::translation 函数设置旋转和变换的值。然后,我们将之前存储的旋转和变换值传递进去,如下所示。我们将保持 ScaleMatrix 变量不变:
   modelMatrix = TranslationMatrix * RotationMatrix * scaleMatrix;  

新的 modelMatrix 变量将是按照顺序缩放、旋转和变换矩阵的乘积。在 draw 函数中,其余的代码将保持不变。

  1. init 函数中,更改代码以反映修改后的 MeshRenderer 构造函数:
   // Sphere Mesh 

   sphere = new MeshRenderer(MeshType::kSphere, camera, 
            sphereRigidBody); 
   sphere->setProgram(texturedShaderProgram); 
   sphere->setTexture(sphereTexture); 
   sphere->setScale(glm::vec3(1.0f)); 
  1. 我们不需要设置位置,因为这将由刚体设置。按照以下代码设置相机,以便我们可以看到球体:
camera = new Camera(45.0f, 800, 600, 0.1f, 100.0f, glm::vec3(0.0f, 
         4.0f, 20.0f)); 
  1. 现在,运行项目。我们可以看到球体正在被绘制,但它没有移动。这是因为我们必须更新物理体。

  2. 我们必须使用 dynamicsWorldstepSimulation 函数来每帧更新模拟。为此,我们必须计算前一个帧和当前帧之间的时间差。

  3. source.cpp 的顶部包含 <chrono>,这样我们就可以计算 tick 更新。现在,我们必须对 main 函数和 while 循环进行如下更改:

auto previousTime = std::chrono::high_resolution_clock::now(); 

while (!glfwWindowShouldClose(window)){ 

         auto currentTime = std::chrono::
                            high_resolution_clock::now(); 
         float dt = std::chrono::duration<float, std::
                    chrono::seconds::period>(currentTime - 
                    previousTime).count(); 

         dynamicsWorld->stepSimulation(dt); 

         renderScene(); 

         glfwSwapBuffers(window); 
         glfwPollEvents(); 

         previousTime = currentTime; 
   } 

while 循环之前,我们创建一个名为 previousTime 的变量,并用当前时间初始化它。在 while 循环中,我们获取当前时间并将其存储在变量中。然后,我们通过减去两个时间来计算前一个时间和当前时间之间的时间差。现在我们有了时间差,所以我们调用 stepSimulation 并传入时间差。然后我们渲染场景,交换缓冲区并轮询事件,就像平常一样。最后,我们将当前时间设置为前一个时间。

现在,当我们运行项目时,我们可以看到球体正在下落,这非常酷。然而,球体没有与任何东西互动。

让我们在底部添加一个盒子刚体,并观察球体如何从它弹起。在球体 MeshRenderer 对象之后,添加以下代码来创建一个盒子刚体:

   btCollisionShape* groundShape = new btBoxShape(btVector3(4.0f, 
                                   0.5f, 4.0f)); 

   btDefaultMotionState* groundMotionState = new  
     btDefaultMotionState(btTransform(btQuaternion
     (0, 0, 0, 1), btVector3(0, -2.0f, 0))); 
   btRigidBody::btRigidBodyConstructionInfo 
    groundRigidBodyCI(0.0f, new btDefaultMotionState(), 
    groundShape, btVector3(0, 0, 0)); 

   btRigidBody* groundRigidBody = new btRigidBody(
                                  groundRigidBodyCI); 

   groundRigidBody->setFriction(1.0); 
   groundRigidBody->setRestitution(0.9); 

   groundRigidBody->setCollisionFlags(btCollisionObject
     ::CF_STATIC_OBJECT); 

   dynamicsWorld->addRigidBody(groundRigidBody);  

在这里,我们首先创建一个 btBoxShape 类型的形状,长度、高度和深度分别设置为 4.00.54.0。接下来,我们将设置运动状态,其中我们将旋转设置为零,并将位置设置为 y 轴上的 -2.0x 轴和 z 轴上的 0。对于构造信息,我们将质量和惯性设置为 0。我们还设置了默认的运动状态并将形状传入。接下来,我们通过将刚体信息传入其中来创建刚体。一旦创建了刚体,我们就设置了恢复力和摩擦值。接下来,我们使用 rigidBodysetCollisionFlags 函数将刚体类型设置为静态。这意味着它将像砖墙一样,不会移动并且不受其他刚体作用力的影响,但其他物体仍然会受到它的影响。

最后,我们将地面刚体添加到世界中,这样盒子刚体也将成为物理模拟的一部分。我们现在必须创建一个用于渲染地面刚体的 MeshRenderer 立方体。在顶部创建一个新的 MeshRenderer 对象,称为 Ground,在其下方你创建了球体 MeshRenderer 对象。在 init 函数中,我们在其中添加了地面刚体的代码,添加以下内容:

   // Ground Mesh 
   GLuint groundTexture = tLoader.getTextureID(
                          "Assets/Textures/ground.jpg"); 
   ground = new MeshRenderer(MeshType::kCube, camera,  
            groundRigidBody); 
   ground->setProgram(texturedShaderProgram); 
   ground->setTexture(groundTexture); 
   ground->setScale(glm::vec3(4.0f, 0.5f, 4.0f));  

我们将通过加载 ground.jpg 创建一个新的纹理,所以请确保你已经将它添加到 Assets/ Textures 目录中。调用构造函数并将 meshtype 设置为 cube,然后设置相机并传入地面刚体。接下来,我们设置着色器程序、纹理和物体的比例。

  1. renderScene 函数中,按照以下方式绘制地面 MeshRenderer 对象:
void renderScene(){ 

   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 
   glClearColor(1.0, 1.0, 0.0, 1.0); 

   sphere->draw(); 
   ground->draw(); 
}
  1. 现在,当你运行项目时,你将看到球体在地面上弹跳:

摘要

在本章中,我们创建了一个名为 MeshRenderer 的新类,它将被用来将纹理化的 3D 对象渲染到场景中。我们创建了一个纹理加载类,它将被用来从提供的图像中加载纹理。然后,我们通过添加 Bullet Physics 库给对象添加了物理效果。然后我们初始化了物理世界,并通过将刚体本身添加到世界中,创建了并添加了刚体到网格渲染器中,使得渲染的物体受到物理影响。

在下一章中,我们将添加游戏循环,以及计分和文本渲染来在视口中显示分数。我们还将向我们的世界添加光照。

第八章:通过碰撞、循环和光照增强您的游戏

在本章中,我们将学习如何添加碰撞以检测球和敌人之间的接触;这将确定失败条件。我们还将检查球和地面之间的接触,以确定玩家是否可以跳跃。然后,我们将完成游戏循环。

一旦游戏循环完成,我们就能添加文本渲染来显示玩家的得分。为了显示必要的文本,我们将使用 FreeType 库。这将从字体文件中加载字符。

我们还将向场景中的对象添加一些基本光照。光照将使用 Phong 光照模型进行计算,我们将介绍如何在实践中实现这一点。为了完成游戏循环,我们必须添加一个敌人。

在本章中,我们将涵盖以下主题:

  • 添加RigidBody名称

  • 添加敌人

  • 移动敌人

  • 检查碰撞

  • 添加键盘控制

  • 游戏循环和得分

  • 文本渲染

  • 添加光照

添加刚体名称

为了识别我们将要添加到场景中的不同刚体,我们将在MeshRenderer类中添加一个属性,该属性将指定每个被渲染的对象。让我们看看如何做到这一点:

  1. MeshRenderer.h类中,该类位于MeshRenderer类内部,将类的构造函数修改为接受一个字符串作为对象的名称,如下所示:
MeshRenderer(MeshType modelType, std::string _name, Camera *  
   _camera, btRigidBody* _rigidBody) 
  1. 添加一个名为name的新公共属性,其类型为std::string,并初始化它,如下所示:
         std::string name = ""; 
  1. 接下来,在MeshRenderer.cpp文件中,修改构造函数的实现,如下所示:
MeshRenderer::MeshRenderer(MeshType modelType, std::string _name,  
   Camera* _camera, btRigidBody* _rigidBody){ 

   name = _name; 
... 
... 

} 

我们已成功将name属性添加到MeshRenderer类中。

添加敌人

在我们将敌人添加到场景之前,让我们稍微整理一下代码,并在main.cpp中创建一个名为addRigidBodies的新函数,以便所有刚体都在一个函数中创建。为此,请按照以下步骤操作:

  1. main.cpp文件的源代码中,在main()函数上方创建一个名为addRigidBodies的新函数。

  2. 将以下代码添加到addRigidBodies函数中。这将添加球体和地面。我们这样做是为了避免将所有游戏代码放入main()函数中:

   // Sphere Rigid Body 

   btCollisionShape* sphereShape = new btSphereShape(1); 
   btDefaultMotionState* sphereMotionState = new 
     btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), 
     btVector3(0, 0.5, 0))); 

   btScalar mass = 13.0f; 
   btVector3 sphereInertia(0, 0, 0); 
   sphereShape->calculateLocalInertia(mass, sphereInertia); 

   btRigidBody::btRigidBodyConstructionInfo sphereRigidBodyCI(mass, 
      sphereMotionState, sphereShape, sphereInertia); 

   btRigidBody* sphereRigidBody = new btRigidBody(
                                  sphereRigidBodyCI); 

   sphereRigidBody->setFriction(1.0f); 
   sphereRigidBody->setRestitution(0.0f); 

   sphereRigidBody->setActivationState(DISABLE_DEACTIVATION); 

   dynamicsWorld->addRigidBody(sphereRigidBody); 

   // Sphere Mesh 

   sphere = new MeshRenderer(MeshType::kSphere, "hero", camera, 
            sphereRigidBody); 
   sphere->setProgram(texturedShaderProgram); 
   sphere->setTexture(sphereTexture); 
   sphere->setScale(glm::vec3(1.0f)); 

   sphereRigidBody->setUserPointer(sphere); 

   // Ground Rigid body 

   btCollisionShape* groundShape = new btBoxShape(btVector3(4.0f, 
                                   0.5f, 4.0f)); 
   btDefaultMotionState* groundMotionState = new 
       btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), 
       btVector3(0, -1.0f, 0))); 

   btRigidBody::btRigidBodyConstructionInfo groundRigidBodyCI(0.0f, 
      groundMotionState, groundShape, btVector3(0, 0, 0)); 

   btRigidBody* groundRigidBody = new btRigidBody(
                                  groundRigidBodyCI); 

   groundRigidBody->setFriction(1.0); 
   groundRigidBody->setRestitution(0.0); 

   groundRigidBody->setCollisionFlags(
       btCollisionObject::CF_STATIC_OBJECT); 

   dynamicsWorld->addRigidBody(groundRigidBody); 

   // Ground Mesh 
   ground = new MeshRenderer(MeshType::kCube, "ground", camera, 
            groundRigidBody); 
   ground->setProgram(texturedShaderProgram); 
   ground->setTexture(groundTexture); 
   ground->setScale(glm::vec3(4.0f, 0.5f, 4.0f)); 

   groundRigidBody->setUserPointer(ground); 

注意,一些值已被更改以适应我们的游戏。我们还将禁用球体的去激活,因为我们不这样做的话,当我们需要球体为我们跳跃时,球体将无响应。

要访问渲染网格的名称,我们可以通过使用RigidBody类的setUserPointer属性将该实例设置为刚体的一个属性。setUserPointer接受一个 void 指针,因此可以传递任何类型的数据。为了方便起见,我们只是传递MeshRenderer类的实例本身。在这个函数中,我们还将添加敌人的刚体到场景中,如下所示:

// Enemy Rigid body 

btCollisionShape* shape = new btBoxShape(btVector3(1.0f, 1.0f, 1.0f)); 
btDefaultMotionState* motionState = new btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), 
btVector3(18.0, 1.0f, 0))); 
btRigidBody::btRigidBodyConstructionInfo rbCI(0.0f, motionState, shape, btVector3(0.0f, 0.0f, 0.0f)); 

   btRigidBody* rb = new btRigidBody(rbCI); 

   rb->setFriction(1.0); 
   rb->setRestitution(0.0); 

//rb->setCollisionFlags(btCollisionObject::CF_KINEMATIC_OBJECT); 

rb->setCollisionFlags(btCollisionObject::CF_NO_CONTACT_RESPONSE); 

   dynamicsWorld->addRigidBody(rb); 

   // Enemy Mesh 
   enemy = new MeshRenderer(MeshType::kCube, "enemy", camera, rb); 
   enemy->setProgram(texturedShaderProgram); 
   enemy->setTexture(groundTexture); 
   enemy->setScale(glm::vec3(1.0f, 1.0f, 1.0f)); 

   rb->setUserPointer(enemy); 
  1. 以与我们添加球体和地面的相同方式添加敌人。由于敌人对象的形状是立方体,我们使用btBoxShape为刚体设置盒子的形状。我们将位置设置为沿X轴 18 个单位距离和沿Y轴 1 个单位距离。然后,我们设置摩擦和恢复值。

对于刚体的类型,我们将它的碰撞标志设置为NO_CONTACT_RESPONSE而不是KINEMATIC_OBJECT。我们本来可以将类型设置为KINEMATIC_OBJECT,但那样的话,当敌人对象与之接触时,它会对其他对象,如球体,施加力。为了避免这种情况,我们使用NO_CONTACT_RESPONSE,它只会检查敌人刚体和另一个物体之间是否有重叠,而不是对其施加力。

您可以取消注释KINEMATIC_OBJECT代码行的注释,并注释掉NO_CONTACT_RESPONSE代码行,以查看使用任一方式如何改变物体在物理模拟中的行为。

  1. 一旦我们创建了刚体,我们将刚体添加到世界中,为敌人对象设置网格渲染器,并将其命名为敌人

移动敌人

为了更新敌人的移动,我们将添加一个由刚体世界调用的tick函数。在这个tick函数中,我们将更新敌人的位置,使其从屏幕的右侧移动到左侧。我们还将检查敌人是否已经超过了屏幕的左侧边界。

如果已经超过,那么我们将将其位置重置为屏幕的右侧。为此,请按照以下步骤操作:

  1. 在这个更新函数中,我们将更新我们的游戏逻辑和得分,以及我们如何检查球体与敌人以及球体与地面的接触。将tick函数回调原型添加到Main.cpp文件的顶部,如下所示:
   void myTickCallback(btDynamicsWorld *dynamicsWorld, 
      btScalar timeStep); 
  1. TickCallback函数中更新敌人的位置,如下所示:
void myTickCallback(btDynamicsWorld *dynamicsWorld, btScalar timeStep) { 

         // Get enemy transform 
         btTransform t(enemy->rigidBody->getWorldTransform()); 

         // Set enemy position 
         t.setOrigin(t.getOrigin() + btVector3(-15, 0, 0) * 
         timeStep); 

         // Check if offScreen 
         if(t.getOrigin().x() <= -18.0f) { 
               t.setOrigin(btVector3(18, 1, 0)); 
         } 
         enemy->rigidBody->setWorldTransform(t); 
         enemy->rigidBody->getMotionState()->setWorldTransform(t); 

} 

myTickCallback函数中,我们获取当前的变换并将其存储在一个变量t中。然后,我们通过获取当前位置,将其向左移动 15 个单位,并将其乘以当前时间步长(即前一个时间和当前时间之间的差异)来设置原点,即变换的位置。

一旦我们得到更新后的位置,我们检查当前位置是否小于 18 个单位。如果是,那么当前位置已经超出了屏幕左侧的边界。因此,我们将当前位置设置回视口的右侧,并使物体在屏幕上环绕。

然后,我们通过更新刚体的worldTransform和物体的运动状态来更新物体本身的位置到这个新位置。

  1. init函数中将tick函数设置为动态世界的默认TickCallback,如下所示:
dynamicsWorld = new btDiscreteDynamicsWorld(dispatcher, broadphase, 
                solver, collisionConfiguration); 
dynamicsWorld->setGravity(btVector3(0, -9.8f, 0));  
dynamicsWorld->setInternalTickCallback(myTickCallback); 
  1. 构建并运行项目,以查看屏幕右侧生成的立方体敌人,然后它穿过球体并向屏幕左侧移动。当敌人离开屏幕时,它将循环回到屏幕右侧,如下面的截图所示:

截图

  1. 如果我们将敌人的collisionFlag设置为KINEMATIC_OBJECT,你会看到敌人不会穿过球体,而是将其推离地面,如下所示:

截图

  1. 这不是我们想要的,因为我们不希望敌人与任何对象进行物理交互。将敌人的碰撞标志改回NO_CONTACT_RESPONSE以修正此问题。

检查碰撞

在 tick 函数中,我们需要检查球体与敌人以及球体与地面的碰撞。按照以下步骤进行:

  1. 要检查对象之间的接触数,我们将使用动态世界对象的getNumManifolds属性。在每次更新周期中,流形将包含有关场景中所有接触的信息。

  2. 我们需要检查联系人数是否大于零。如果是,那么我们检查哪些对象对彼此接触。在更新敌人对象后,添加以下代码以检查英雄与敌人之间的接触:

int numManifolds = dynamicsWorld->getDispatcher()->
  getNumManifolds(); 

   for (int i = 0; i < numManifolds; i++) { 

       btPersistentManifold *contactManifold = dynamicsWorld->
       getDispatcher()->getManifoldByIndexInternal(i); 

       int numContacts = contactManifold->getNumContacts(); 

       if (numContacts > 0) { 

           const btCollisionObject *objA = contactManifold->
           getBody0(); 
           const btCollisionObject *objB = contactManifold->
           getBody1(); 

           MeshRenderer* gModA = (MeshRenderer*)objA->
           getUserPointer(); 
           MeshRenderer* gModB = (MeshRenderer*)objB->
           getUserPointer(); 

                if ((gModA->name == "hero" && gModB->name == 
                  "enemy") || (gModA->name == "enemy" && gModB->
                  name == "hero")) { 
                        printf("collision: %s with %s \n",
                        gModA->name, gModB->name); 

                         if (gModB->name == "enemy") { 
                             btTransform b(gModB->rigidBody-
                             >getWorldTransform()); 
                             b.setOrigin(btVector3(18, 1, 0)); 
                             gModB->rigidBody-
                             >setWorldTransform(b); 
                             gModB->rigidBody-> 
                             getMotionState()-
                             >setWorldTransform(b); 
                           }else { 

                                 btTransform a(gModA->rigidBody->
                                 getWorldTransform()); 
                                 a.setOrigin(btVector3(18, 1, 0)); 
                                 gModA->rigidBody->
                                 setWorldTransform(a); 
                                 gModA->rigidBody->
                                 getMotionState()->
                                 setWorldTransform(a); 
                           } 

                     } 

                     if ((gModA->name == "hero" && gModB->name == 
                         "ground") || (gModA->name == "ground" &&               
                          gModB->name  == "hero")) { 
                           printf("collision: %s with %s \n",
                           gModA->name, gModB->name); 

                     } 
         } 
   } 
  1. 首先,我们获取接触流形或接触对的数量。然后,对于每个接触流形,我们检查接触数是否大于零。如果是大于零,那么这意味着在当前更新中已经发生了接触。

  2. 然后,我们获取两个碰撞对象并将它们分配给ObjAObjB。之后,我们获取两个对象的用户指针并将它们转换为MeshRenderer类型,以访问我们分配的对象的名称。在检查两个对象之间的接触时,对象 A 可以与对象 B 接触,或者反之亦然。如果球体与敌人之间有接触,我们将敌人位置设置回视口的右侧。我们还检查球体与地面的接触。如果有接触,我们只需打印出有接触即可。

添加键盘控制

让我们添加一些键盘控制,以便我们可以与球体交互。我们将设置,当我们按下键盘上的上键时,球体会跳跃。我们将通过向球体应用冲量来添加跳跃功能。为此,请按照以下步骤操作:

  1. 首先,我们将使用GLFW,它有一个键盘回调函数,这样我们就可以为游戏添加键盘交互。在我们开始main()函数之前,我们将设置此键盘回调函数:
void updateKeyboard(GLFWwindow* window, int key, int scancode, int action, int mods){ 

   if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) { 
         glfwSetWindowShouldClose(window, true);    
   } 

   if (key == GLFW_KEY_UP && action == GLFW_PRESS) { 
               if (grounded == true) { 
                     grounded = false; 

sphere->rigidBody->applyImpulse(btVector3(0.0f, 
   100.0f, 0.0f), btVector3(0.0f, 0.0f, 0.0f)); 
                     printf("pressed up key \n"); 
               } 
         } 
} 

我们关注的两个主要参数是键和动作。通过键,我们可以获取哪个键被按下,通过动作,我们可以检索对该键执行了什么操作。在函数中,我们使用glfwGetKey函数检查是否按下了Esc键。如果是,则使用glfwSetWindowShouldClose函数通过传递true作为第二个参数来关闭窗口。

要使球体跳跃,我们检查是否按下了向上键。如果是,我们创建一个新的布尔成员变量grounded,它描述了球体接触地面时的状态。如果是真的,我们将布尔值设置为false,并通过调用rigidbodyapplyImpulse函数在 Y 方向上对球体的刚体原点施加100单位的冲量。

  1. tick函数中,在我们获取流形数量之前,我们将grounded布尔值设置为false,如下所示:
 grounded = false; 

   int numManifolds = dynamicsWorld->getDispatcher()->
                      getNumManifolds(); 
  1. 当球体和地面接触时,我们将grounded布尔值设置为true,如下所示:
   if ((gModA->name == "hero" && gModB->name == "ground") || 
         (gModA->name == "ground" && gModB->name == "hero")) { 

//printf("collision: %s with %s \n", gModA->name, gModB->name); 

         grounded = true; 

   }   
  1. 在主函数中,使用glfwSetKeyCallbackupdateKeyboard设置为回调,如下所示:
int main(int argc, char **argv) { 
...       
   glfwMakeContextCurrent(window); 
   glfwSetKeyCallback(window, updateKeyboard); 
   ... 
   }
  1. 现在,构建并运行应用程序。按下向上键以查看球体跳跃,但只有当它接触地面时,如下所示:

游戏循环和计分

让我们通过添加计分和完成游戏循环来结束这个话题:

  1. 除了grounded布尔值外,再添加另一个布尔值并检查gameover。完成这些后,在main.cpp文件顶部添加一个名为scoreint,并将其初始化为0,如下所示:
GLuint sphereTexture, groundTexture; 

bool grounded = false; 
bool gameover = true; 
int score = 0; 

  1. 接下来,在tick函数中,敌人只有在游戏未结束时才会移动。因此,我们将敌人的位置更新包裹在一个if语句中,以检查游戏是否结束。如果游戏未结束,则更新敌人的位置,如下所示:
void myTickCallback(btDynamicsWorld *dynamicsWorld, btScalar timeStep) { 

   if (!gameover) { 

         // Get enemy transform 
         btTransform t(enemy->rigidBody->getWorldTransform()); 

         // Set enemy position 

         t.setOrigin(t.getOrigin() + btVector3(-15, 0, 0) * 
         timeStep); 

         // Check if offScreen 

         if (t.getOrigin().x() <= -18.0f) { 

               t.setOrigin(btVector3(18, 1, 0)); 
               score++; 
               label->setText("Score: " + std::to_string(score)); 

         } 

         enemy->rigidBody->setWorldTransform(t); 
         enemy->rigidBody->getMotionState()->setWorldTransform(t); 
   } 
... 
} 
  1. 如果敌人超出屏幕的左侧,我们也增加分数。仍然在tick函数中,如果球体和敌人之间有接触,我们将分数设置为0并将gameover设置为true,如下所示:

         if ((gModA->name == "hero" && gModB->name == "enemy") || 
                    (gModA->name == "enemy" && gModB->name ==
                     "hero")) { 

                     if (gModB->name == "enemy") { 
                         btTransform b(gModB->rigidBody->
                         getWorldTransform()); 
                         b.setOrigin(btVector3(18, 1, 0)); 
                         gModB->rigidBody->
                         setWorldTransform(b); 
                         gModB->rigidBody->getMotionState()->
                         setWorldTransform(b); 
                           }else { 

                           btTransform a(gModA->rigidBody->
                           getWorldTransform()); 
                           a.setOrigin(btVector3(18, 1, 0)); 
                           gModA->rigidBody->
                           setWorldTransform(a); 
                           gModA->rigidBody->getMotionState()->
                           setWorldTransform(a); 
                           } 

                           gameover = true; 
                           score = 0; 

                     }   
  1. updateKeyboard函数中,当按下向上键盘键时,我们检查游戏是否结束。如果是,我们将gameover布尔值设置为false,这将开始游戏。现在,当玩家再次按下向上键时,角色将跳跃。这样,相同的键可以用来开始游戏,也可以用来使角色跳跃。

  2. 根据以下要求修改updateKeyboard函数,如下所示:

void updateKeyboard(GLFWwindow* window, int key, int scancode, int action, int mods){ 

   if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) { 
         glfwSetWindowShouldClose(window, true); 
   } 

   if (key == GLFW_KEY_UP && action == GLFW_PRESS) { 

         if (gameover) { 
               gameover = false; 
         } else { 

               if (grounded == true) { 

                     grounded = false; 

sphere->rigidBody->applyImpulse(btVector3(0.0f, 100.0f, 0.0f), 
   btVector3(0.0f, 0.0f, 0.0f)); 
                     printf("pressed up key \n"); 
               } 
         } 
   } 
}   
  1. 虽然我们在计算分数,但用户仍然看不到分数是多少,所以让我们给游戏添加文本渲染。

文本渲染

对于文本渲染,我们将使用一个名为 FreeType 的库,加载字体并从中读取字符。FreeType 可以加载一个流行的字体格式,称为 TrueType。TrueType 字体具有.ttf扩展名。

TTFs 包含称为 glyphs 的矢量信息,可以用来存储任何数据。一个用例当然是使用它们来表示字符。

因此,当我们想要渲染特定的字形时,我们将通过指定其大小来加载字符字形;字符将以不损失质量的方式生成。

FreeType 库的源代码可以从他们的网站 www.freetype.org/ 下载,并从中构建库。也可以从 github.com/ubawurinna/freetype-windows-binaries 下载预编译的库。

让我们将库添加到我们的项目中。由于我们正在为 64 位操作系统开发,我们感兴趣的是 include 目录和 win64 目录;它们包含我们项目版本的 freetype.libfreetype.dll 文件:

  1. 在你的依赖文件夹中创建一个名为 freetype 的文件夹,并将文件提取到其中,如下所示:

图片

  1. 打开项目的属性,在 C/C++ 下的 Additional Include Directory(附加包含目录)中添加 freetype 包含目录的位置,如下所示:

图片

  1. 在 Configuration Properties | Linker | General | Additional Library Directories(配置属性 | 链接器 | 一般 | 附加库目录)下,添加 freetype win64 目录,如下所示:

图片

  1. 在项目目录中,从 win64 目录复制 Freetype.dll 文件并将其粘贴到这里:

图片

在准备工作完成之后,我们可以开始着手进行项目工作了。

  1. 创建一个名为 TextRenderer 的类,以及一个名为 TextRenderer.h 的文件和一个名为 TextRenderer.cpp 的文件。我们将向这些文件添加文本渲染的功能。在 TextRenderer.h 中,包含 GLglm 的常用包含头文件,如下所示:
#include <GL/glew.h> 

#include "Dependencies/glm/glm/glm.hpp" 
#include "Dependencies/glm/glm/gtc/matrix_transform.hpp" 
#include "Dependencies/glm/glm/gtc/type_ptr.hpp"
  1. 接下来,我们将包含 freetype.h 的头文件,如下所示:
#include <ft2build.h> 
#include FT_FREETYPE_H    
  1. FT_FREETYPE_H 宏仅仅在 freetype 目录中包含了 freetype.h。然后,我们将 include <map>,因为我们需要映射每个字符的位置、大小和其他信息。我们还将 include <string> 并将一个字符串传递给要渲染的类,如下所示:
#include <string> 
  1. 对于每个字形,我们需要跟踪某些属性。为此,我们将创建一个名为 Characterstruct,如下所示:
struct Character { 
   GLuint     TextureID;  // Texture ID of each glyph texture 
   glm::ivec2 Size;       // glyph Size 
   glm::ivec2 Bearing;    // baseline to left/top of glyph 
   GLuint     Advance;    // id to next glyph 
}; 

对于每个字形,我们将存储我们为每个字符创建的纹理的纹理 ID。我们存储它的大小、基线,即字形顶部左角到字形基线的距离,以及字体文件中下一个字形的 ID。

  1. 这就是包含所有字符字形的字体文件的外观:

图片

每个字符的信息都是相对于其相邻字符存储的,如下所示:

图片

在加载 FT_Face 类型的字体面之后,我们可以逐个访问这些属性。每个字形的宽度和高度可以通过每个字形的属性访问,即 face->glyph->bitmap.widthface->glyph->bitmap.rows

每个字形的图像数据可以通过 bitmap.buffer 属性访问,我们在创建每个字形的纹理时将使用它。以下代码显示了所有这些是如何实现的。

如果字体是水平对齐的,可以通过字形的 advance.x 属性访问字体文件中的下一个字形。

关于库的理论就到这里。如果你有兴趣了解更多,必要的文档可以在 FreeType 的网站上找到:www.freetype.org/freetype2/docs/tutorial/step2.html#section-1

让我们继续处理 TextRenderer.h 文件,并创建 TextRenderer 类,如下所示:

class TextRenderer{ 

public: 
   TextRenderer(std::string text, std::string font, int size, 
     glm::vec3 color, GLuint  program); 
   ~TextRenderer(); 

   void draw(); 
   void setPosition(glm::vec2 _position); 
   void setText(std::string _text); 

private: 
   std::string text; 
   GLfloat scale; 
   glm::vec3 color; 
   glm::vec2 position; 

   GLuint VAO, VBO, program; 
   std::map<GLchar, Character> Characters; 

};   

在公共部分下的类中,我们添加构造函数和析构函数。在构造函数中,我们传入要绘制的字符串、要使用的文件、要绘制的文本的大小和颜色,以及传入在绘制字体时使用的着色器程序。

然后,我们有 draw 函数来绘制文本,几个设置器来设置位置,以及一个 setText 函数,如果需要,可以设置新的字符串进行绘制。在私有部分,我们有用于文本字符串、缩放、颜色和位置的局部变量。我们还有 VAOVBOprogram 的成员变量,这样我们就可以绘制文本字符串。在类的末尾,我们创建一个映射来存储所有加载的字符,并将每个 GLchar 分配到映射中的字符 struct。这就是 TextRenderer.h 文件需要做的所有事情。

TextRenderer.cpp 文件中,将 TextRenderer.h 文件包含在文件顶部,并执行以下步骤:

  1. 添加 TextRenderer 构造函数的实现,如下所示:
TextRenderer::TextRenderer(std::string text, std::string font, int size, glm::vec3 color, GLuint program){ 

} 

在构造函数中,我们将添加加载所有字符的功能,并为绘制文本准备类。

  1. 让我们初始化局部变量,如下所示:
   this->text = text; 
   this->color = color; 
   this->scale = 1.0; 
   this->program = program; 
   this->setPosition(position); 
  1. 接下来,我们需要设置投影矩阵。对于文本,我们指定正交投影,因为它没有深度,如下所示:
   glm::mat4 projection = glm::ortho(0.0f, static_cast<GLfloat>
                         (800), 0.0f, static_cast<GLfloat>(600)); 
   glUseProgram(program); 
   glUniformMatrix4fv(glGetUniformLocation(program, "projection"), 
      1, GL_FALSE, glm::value_ptr(projection)); 

投影是通过 glm::ortho 函数创建的,它接受原点 x、窗口宽度、原点 y 和窗口高度作为创建正交投影矩阵的参数。我们将使用当前程序并将投影矩阵的值传递到名为 projection 的位置,然后将其传递给着色器。由于这个值永远不会改变,它将在构造函数中调用并赋值一次。

  1. 在加载字体本身之前,我们必须初始化 FreeType 库,如下所示:
// FreeType 
FT_Library ft; 

// Initialise freetype 
if (FT_Init_FreeType(&ft)) 
std::cout << "ERROR::FREETYPE: Could not init FreeType Library" 
          << std::endl; 
  1. 现在,我们可以加载字体本身,如下所示:
// Load font 
FT_Face face; 
if (FT_New_Face(ft, font.c_str(), 0, &face)) 
         std::cout << "ERROR::FREETYPE: Failed to load font" 
                   << std::endl; 

  1. 现在,设置字体大小(以像素为单位)并禁用字节对齐限制。如果我们不对字节对齐进行限制,字体将被绘制得混乱,所以别忘了添加这个:
// Set size of glyphs 
FT_Set_Pixel_Sizes(face, 0, size); 

// Disable byte-alignment restriction 
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); 
  1. 然后,我们将加载我们加载的字体中的前128个字符,并创建和分配纹理 ID、大小、基线和进位。之后,我们将字体存储在字符映射中,如下所示:
   for (GLubyte i = 0; i < 128; i++){ 

         // Load character glyph  
         if (FT_Load_Char(face, i, FT_LOAD_RENDER)){ 
               std::cout << "ERROR::FREETYTPE: Failed to 
                            load Glyph" << std::endl; 
               continue; 
         } 

         // Generate texture 
         GLuint texture; 
         glGenTextures(1, &texture); 
         glBindTexture(GL_TEXTURE_2D, texture); 

         glTexImage2D( 
               GL_TEXTURE_2D, 
               0, 
               GL_RED, 
               face->glyph->bitmap.width, 
               face->glyph->bitmap.rows, 
               0, 
               GL_RED, 
               GL_UNSIGNED_BYTE, 
               face->glyph->bitmap.buffer 
               ); 

         // Set texture filtering options 
         glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, 
         GL_CLAMP_TO_EDGE); 
         glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, 
         GL_CLAMP_TO_EDGE); 
         glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
         GL_LINEAR); 
         glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,
         GL_LINEAR); 

         // Create a character 
         Character character = { 
               texture, 
               glm::ivec2(face->glyph->bitmap.width, 
                           face->glyph->bitmap.rows), 
               glm::ivec2(face->glyph->bitmap_left, 
           face->glyph->bitmap_top), 
               face->glyph->advance.x 
         }; 

         // Store character in characters map 
         Characters.insert(std::pair<GLchar, Character>(i,
         character)); 
   } 
  1. 一旦加载了字符,我们可以解绑纹理并销毁字体外观和 FreeType 库,如下所示:
   glBindTexture(GL_TEXTURE_2D, 0); 

   // Destroy FreeType once we're finished 
   FT_Done_Face(face); 
   FT_Done_FreeType(ft);
  1. 每个字符都将作为一个单独的四边形上的纹理来绘制,因此为四边形设置VAO/VBO,创建一个位置属性并启用它,如下所示:
   glGenVertexArrays(1, &VAO); 
   glGenBuffers(1, &VBO); 

   glBindVertexArray(VAO); 

   glBindBuffer(GL_ARRAY_BUFFER, VBO); 
   glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * 6 * 4, NULL, 
       GL_DYNAMIC_DRAW); 

   glEnableVertexAttribArray(0); 
   glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * 
      sizeof(GLfloat), 0); 

  1. 现在,我们需要解绑VBOVAO,如下所示:
   glBindBuffer(GL_ARRAY_BUFFER, 0); 
   glBindVertexArray(0); 

构造函数就到这里。现在,我们可以继续到绘制函数。让我们看看:

  1. 首先,创建绘制函数的实现,如下所示:
void TextRenderer::draw(){
} 
  1. 我们将向这个函数添加绘制功能。首先,我们将获取文本需要开始绘制的位置,如下所示:
glm::vec2 textPos = this->position; 
  1. 然后,我们必须启用混合。如果我们不启用混合,整个文本的四边形将被着色,而不是仅着色文本存在的区域,如左边的图像所示:

图片

在左边的图像中,S 应该出现的地方,我们可以看到整个四边形被红色着色,包括应该透明的像素。

通过启用混合,我们使用以下方程式将最终颜色值设置为像素:

颜色[最终] = 颜色[源] * Alpha[源] + 颜色[目标] * 1- Alpha[源]

这里,源颜色和源 alpha 是文本在某个像素位置的颜色和 alpha 值,而目标颜色和 alpha 是颜色缓冲区中颜色和 alpha 的值。

在这个例子中,由于我们稍后绘制文本,目标颜色将是黄色,而源颜色,即文本,将是红色。目标 alpha 值为 1.0,而黄色是不透明的。对于文本,如果我们看一下 S 字母,例如,在 S 内部,即红色区域,它是完全不透明的,但它是透明的。

使用这个公式,让我们计算 S 周围透明区域的最终像素颜色,如下所示:

颜色[最终] = (1.0f, 0.0f, 0.0f, 0.0f) * 0.0 + (1.0f, 1.0f, 0.0f, 1.0f) * (1.0f- 0.0f)

= (1.0f, 1.0f, 0.0f, 1.0f);*

这只是黄色背景颜色。

相反,在 S 字母内部,它不是透明的,所以该像素位置的 alpha 值为 1。因此,当我们应用相同的公式时,我们得到最终颜色,如下所示:

颜色[最终] = (1.0f, 0.0f, 0.0f, 1.0f) * 1.0 + (1.0f, 1.0f, 0.0f, 1.0f) * (1.0f- 1.0f)

= (1.0f, 0.0f, 0.0f, 1.0f)

这只是红色文本颜色,如下面的图所示:

图片

让我们看看它在实践中是如何实现的。

  1. blend函数如下:
   glEnable(GL_BLEND); 

现在,我们需要设置源和目标混合因子,即 GL_SRC_ALPHA。对于源像素,我们使用其 alpha 值不变,而对于目标,我们将 alpha 设置为 GL_ONE_MINUS_SRC_ALPHA,即源 alpha 减去一,如下所示:

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 

默认情况下,源值和目标值被添加。你也可以进行减法、加法和除法操作。

  1. 现在,我们需要调用 glUseProgram 函数来设置程序,将文本颜色设置为统一位置,并设置默认纹理,如下所示:
   glUseProgram(program); 
   glUniform3f(glGetUniformLocation(program, "textColor"), 
      this->color.x, this->color.y, this->color.z); 
   glActiveTexture(GL_TEXTURE0); 
  1. 接下来,我们需要绑定 VAO,如下所示:
   glBindVertexArray(VAO); 
  1. 让我们遍历我们要绘制的文本中的所有字符,获取它们的大小、偏移量,以便我们可以设置每个要绘制的字符的位置和纹理 ID,如下所示:
   std::string::const_iterator c; 

   for (c = text.begin(); c != text.end(); c++){ 

         Character ch = Characters[*c]; 

         GLfloat xpos = textPos.x + ch.Bearing.x * this->scale; 
         GLfloat ypos = textPos.y - (ch.Size.y - ch.Bearing.y) * 
         this->scale; 

         GLfloat w = ch.Size.x * this->scale; 
         GLfloat h = ch.Size.y * this->scale; 

         // Per Character Update VBO 
         GLfloat vertices[6][4] = { 
               { xpos, ypos + h, 0.0, 0.0 }, 
               { xpos, ypos, 0.0, 1.0 }, 
               { xpos + w, ypos, 1.0, 1.0 }, 

               { xpos, ypos + h, 0.0, 0.0 }, 
               { xpos + w, ypos, 1.0, 1.0 }, 
               { xpos + w, ypos + h, 1.0, 0.0 } 
         }; 

         // Render glyph texture over quad 
         glBindTexture(GL_TEXTURE_2D, ch.TextureID); 

         // Update content of VBO memory 
         glBindBuffer(GL_ARRAY_BUFFER, VBO); 

         // Use glBufferSubData and not glBufferData 
         glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), 
         vertices);  

         glBindBuffer(GL_ARRAY_BUFFER, 0); 

         // Render quad 
         glDrawArrays(GL_TRIANGLES, 0, 6); 

         // Now advance cursors for next glyph (note that advance 
         is number of 1/64 pixels) 
         // Bitshift by 6 to get value in pixels (2⁶ = 64 (divide 
         amount of 1/64th pixels by 64 to get amount of pixels)) 
         textPos.x += (ch.Advance >> 6) * this->scale;  
   } 

我们现在将绑定 VBO 并使用 glBufferSubData 将所有要绘制的四边形的顶点数据传递进去。一旦绑定,四边形将通过 glDrawArrays 绘制,我们传递 6 作为要绘制的顶点数。

然后,我们计算 textPos.x,这将决定下一个字符将被绘制的位置。我们通过将当前字符的进位乘以缩放并添加到当前文本位置的 x 分量来获取这个距离。对 advance 进行 6 比特的位移,以获取像素值。

  1. 在绘制函数的末尾,我们解绑顶点数组和纹理,然后禁用混合,如下所示:
glBindVertexArray(0); 
glBindTexture(GL_TEXTURE_2D, 0); 

glDisable(GL_BLEND);  
  1. 最后,我们添加 setPOsitonsetString 函数的实现,如下所示:
void TextRenderer::setPosition(glm::vec2 _position){ 

   this->position = _position; 
} 

void TextRenderer::setText(std::string _text){ 
   this->text = _text; 
} 

我们最终完成了 TextRenderer 类。现在,让我们学习如何在我们的游戏中显示文本:

  1. main.cpp 文件中,在文件顶部包含 TextRenderer.h 并创建一个名为 label 的类的新对象,如下所示:
#include "TextRenderer.h" 

TextRenderer* label;  

  1. 为文本着色程序创建一个新的 GLuint,如下所示:
GLuint textProgram 
  1. 然后,创建文本的新着色程序,如下所示:
textProgram = shader.CreateProgram("Assets/Shaders/text.vs", "Assets/Shaders/text.fs"); 
  1. text.vstext.fs 文件放置在 Assets 目录下的 Shaders.text.vs 中,如下所示:
#version 450 core 
layout (location = 0) in vec4 vertex; 
uniform mat4 projection; 

out vec2 TexCoords; 

void main(){ 
    gl_Position = projection * vec4(vertex.xy, 0.0, 1.0); 
    TexCoords = vertex.zw; 
}   

我们从属性中获取顶点位置和投影矩阵作为统一变量。纹理坐标在主函数中设置,并发送到下一个着色器阶段。四边形的顶点位置通过在 main() 函数中将局部坐标乘以正交投影矩阵来设置。

  1. 接下来,我们将进入片段着色器,如下所示:
#version 450 core 

in vec2 TexCoords; 

uniform sampler2D text; 
uniform vec3 textColor; 

out vec4 color; 

void main(){     
vec4 sampled = vec4(1.0, 1.0, 1.0, texture(text, TexCoords).r); 
color = vec4(textColor, 1.0) * sampled; 
} 

我们从顶点着色器获取纹理坐标和纹理以及颜色作为统一变量。创建一个新的 vec4 叫做颜色,用于发送颜色信息。在 main() 函数中,我们创建一个新的 vec4 叫做 sampled,并将 r、g 和 b 值存储为 1。我们还把红色颜色作为 alpha 值来绘制文本的不透明部分。然后,创建一个新的 vec4 叫做颜色,其中将白色颜色替换为我们想要文本绘制的颜色,并分配颜色变量。

  1. 让我们继续实现文本标签。在init函数中的addRigidBody函数之后,初始化label对象,如下所示:
label = new TextRenderer("Score: 0", "Assets/fonts/gooddog.ttf", 
        64, glm::vec3(1.0f, 0.0f, 0.0f), textProgram); 
   label->setPosition(glm::vec2(320.0f, 500.0f)); 

在构造函数中,我们设置要渲染的字符串,传入字体文件的路径,传入文本高度、文本颜色和文本程序。然后,我们使用setPosition函数设置文本的位置。

  1. 接下来,在tick函数中,我们更新分数时,也更新文本,如下所示:
         if (t.getOrigin().x() <= -18.0f) { 

               t.setOrigin(btVector3(18, 1, 0)); 
               score++; 
               label->setText("Score: " + std::to_string(score));
         }
  1. tick函数中,当游戏结束时,我们重置字符串,如下所示:
               gameover = true; 
               score = 0; 
               label->setText("Score: " + std::to_string(score));
  1. render函数中,我们调用draw函数来绘制文本,如下所示:
void renderScene(float dt){ 

   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); 
   glClearColor(1.0, 1.0, 0.0, 1.0); 

   // Draw Objects 

   //light->draw(); 

   sphere->draw(); 
   enemy->draw(); 
   ground->draw(); 

   label->draw(); 
} 

由于 alpha 混合,文本必须在所有其他对象绘制完毕后绘制。

  1. 最后,确保字体文件已经添加到Fonts文件夹下的Assets文件夹中,如下所示:

提供了一些字体文件,您可以进行实验。更多免费字体可以从www.1001freefonts.com/www.dafont.com/下载。构建并运行游戏以查看绘制的文本和更新:

添加光照

最后,让我们给场景中的对象添加一些光照,以使对象看起来更有趣。我们将通过允许光照渲染器在场景中绘制来实现这一点。在这里,光照来自这个球体的中心。使用光源的位置,我们将计算像素是否被照亮,如下所示:

左侧的图片显示了未照亮的场景。相比之下,右侧的场景使用了地球球体照明,地面受到光源的影响。面向光源的表面最亮,例如球体的顶部。这就在球体的顶部创建了一个镜面反射。由于表面离光源较远/与光源成角度,这些像素值逐渐扩散。然后,还有一些完全未面向光源的表面,例如面向我们的地面侧面。然而,它们仍然不是完全黑色,因为它们仍然受到来自光源的光照,这些光照反射并成为环境光的一部分。环境光漫反射镜面反射成为我们想要照亮物体时照明模型的主要部分。照明模型用于在计算机图形中模拟光照,因为我们受限于硬件的处理能力,这与现实世界不同。

根据 Phong 着色模型的像素最终颜色公式如下:

C = ka Lc+ Lc * max(0, n l) + ks * Lc * max(0, v r) p*

这里,我们有以下属性:

  • k[a ]是环境强度。

  • L[c]是光颜色。

  • n是表面法线。

  • l是光方向。

  • k[s]是镜面反射强度。

  • v是视图方向。

  • r 是关于表面法线的反射光方向。

  • p 是 Phong 指数,它将决定表面的光泽度。

对于 nlvr 向量,请参考以下图表:

图片

让我们看看如何在实践中实现这一点:

  1. 所有的光照计算都是在对象的片段着色器中完成的,因为这将影响物体的最终颜色,这取决于光源和相机位置。对于每个要照明的物体,我们还需要传入光颜色、漫反射和镜面强度。在 MeshRenderer.h 文件中,更改构造函数,使其接受光源、漫反射和镜面强度,如下所示:
MeshRenderer(MeshType modelType, std::string _name, Camera * 
   _camera, btRigidBody* _rigidBody, LightRenderer* _light, float 
   _specularStrength, float _ambientStrength);
  1. 在文件顶部包含 lightRenderer.h,如下所示:
#include "LightRenderer.h"
  1. 在类的私有部分添加一个 LightRenderer 对象以及用于存储环境光和镜面强度的浮点数,如下所示:
        GLuint vao, vbo, ebo, texture, program; 
        LightRenderer* light;
        float ambientStrength, specularStrength;
  1. MeshRenderer.cpp 文件中,更改构造函数的实现,并将传入的变量分配给局部变量,如下所示:
MeshRenderer::MeshRenderer(MeshType modelType, std::string _name, 
   Camera* _camera, btRigidBody* _rigidBody, LightRenderer* _light, 
   float _specularStrength, float _ambientStrength) { 

   name = _name; 
   rigidBody = _rigidBody; 
   camera = _camera; 
   light = _light; 
   ambientStrength = _ambientStrength; 
   specularStrength = _specularStrength; 
... 
} 

  1. 在构造函数中,我们还需要添加一个新的法线属性,因为我们需要表面法线信息来进行光照计算,如下所示:
glEnableVertexAttribArray(0); 
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
   (GLvoid*)0); 

 glEnableVertexAttribArray(1); 
 glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
      (void*)(offsetof(Vertex, Vertex::texCoords))); 
 glEnableVertexAttribArray(2);
 glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
     (void*)(offsetof(Vertex, Vertex::normal))); 

  1. Draw 函数中,我们将相机位置、光源位置、光源颜色、镜面强度和环境强度作为统一变量传递给着色器,如下所示:
   // Set Texture 
   glBindTexture(GL_TEXTURE_2D, texture); 

   // Set Lighting 
   GLuint cameraPosLoc = glGetUniformLocation(program, 
                         "cameraPos"); 
   glUniform3f(cameraPosLoc, camera->getCameraPosition().x,
   camera-> getCameraPosition().y, camera->getCameraPosition().z); 

   GLuint lightPosLoc = glGetUniformLocation(program, "lightPos"); 
   glUniform3f(lightPosLoc, this->light->getPosition().x, 
     this-> light->getPosition().y, this->light->getPosition().z); 

   GLuint lightColorLoc = glGetUniformLocation(program, 
                          "lightColor"); 
   glUniform3f(lightColorLoc, this->light->getColor().x, 
     this-> light->getColor().y, this->light->getColor().z); 

   GLuint specularStrengthLoc = glGetUniformLocation(program, 
                                "specularStrength"); 
   glUniform1f(specularStrengthLoc, specularStrength); 

   GLuint ambientStrengthLoc = glGetUniformLocation(
                               program, "ambientStrength"); 
   glUniform1f(ambientStrengthLoc, ambientStrength); 

   glBindVertexArray(vao);        
   glDrawElements(GL_TRIANGLES, indices.size(), 
      GL_UNSIGNED_INT, 0); 
   glBindVertexArray(0); 

  1. 我们还需要为效果创建新的顶点和片段着色器。让我们创建一个新的顶点着色器,称为 LitTexturedModel.vs,如下所示:
#version 450 core 
layout (location = 0) in vec3 position; 
layout (location = 1) in vec2 texCoord; 
layout (location = 2) in vec3 normal; 

out vec2 TexCoord; 
out vec3 Normal; 
out vec3 fragWorldPos; 

uniform mat4 vp; 
uniform mat4 model; 

void main(){ 

   gl_Position = vp * model *vec4(position, 1.0); 

   TexCoord = texCoord; 
   fragWorldPos = vec3(model * vec4(position, 1.0)); 
   Normal = mat3(transpose(inverse(model))) * normal; 

} 
  1. 我们添加新的位置布局以接收法线属性。

  2. 创建一个新的 out vec3,以便我们可以将法线信息发送到片段着色器。我们还将创建一个新的 out vec3 来发送片段的世界坐标。在 main() 函数中,我们通过将局部位置乘以世界矩阵来计算片段的世界位置,并将其存储在 fragWorldPos 变量中。法线也被转换为世界空间。与我们将局部位置相乘的方式不同,用于将法线转换为法线世界空间的模型矩阵需要以不同的方式处理。法线乘以模型矩阵的逆矩阵,并存储在法线变量中。这就是顶点着色器的内容。现在,让我们看看 LitTexturedModel.fs

  3. 在片段着色器中,我们获取纹理坐标、法线和片段世界位置。接下来,我们获取相机位置、光源位置和颜色、镜面和环境强度统一变量,以及作为统一变量的纹理。最终的像素值将存储在名为 colorout vec4 中,如下所示:

#version 450 core 

in vec2 TexCoord; 
in vec3 Normal; 
in vec3 fragWorldPos; 

uniform vec3 cameraPos; 
uniform vec3 lightPos; 
uniform vec3 lightColor; 

uniform float specularStrength; 
uniform float ambientStrength; 

// texture 
uniform sampler2D Texture; 

out vec4 color;    
  1. 在着色器的 main() 函数中,我们添加了光照计算,如下面的代码所示:
 void main(){ 

       vec3 norm = normalize(Normal); 
       vec4 objColor = texture(Texture, TexCoord); 

       //**ambient 
       vec3 ambient = ambientStrength * lightColor; 

       //**diffuse 
       vec3 lightDir = normalize(lightPos - fragWorldPos); 
       float diff = max(dot(norm, lightDir), 0.0); 
       vec3 diffuse = diff * lightColor; 

       //**specular  
       vec3 viewDir = normalize(cameraPos - fragWorldPos); 
       vec3 reflectionDir = reflect(-lightDir, norm); 
       float spec = pow(max(dot(viewDir, 
                    reflectionDir),0.0),128); 
       vec3 specular = specularStrength * spec * lightColor; 

       // lighting shading calculation 
       vec3 totalColor = (ambient + diffuse + specular) * 
       objColor.rgb; 

       color = vec4(totalColor, 1.0f); 

}  
  1. 我们首先获取法线和物体颜色。然后,根据公式方程,我们通过乘以环境强度和光颜色来计算方程的环境部分,并将其存储在名为ambientvec3中。对于方程的漫反射部分,我们通过从世界空间中像素的位置减去两个位置来计算光方向。结果向量被归一化并保存在vec3 lightDir中。然后,我们计算法线和光方向之间的点积。

  2. 之后,我们获取结果值或0中的较大值,并将其存储在名为diff的浮点数中。这个值乘以光颜色并存储在vec3中以获得漫反射颜色。对于方程的镜面反射部分,我们通过从相机位置减去片段世界位置来计算视图方向。

  3. 结果向量被归一化并存储在vec3 specDir中。然后,通过使用反射glsl内建函数并传入viewDir和表面法线来计算相对于表面法线的反射光向量。

  4. 然后,计算视图和反射向量的点积。选择计算值和0中的较大值。将得到的浮点值提高到128次幂。值可以从0256。值越大,物体看起来越亮。通过将镜面反射强度、计算的镜面反射值和存储在镜面vec3中的光颜色相乘来计算镜面反射值。

  5. 最后,通过将三个环境、漫反射和镜面反射值相加,然后乘以对象颜色来计算总的着色。对象颜色是一个vec4,所以我们将其转换为vec3。总颜色通过将totalColor转换为vec4分配给颜色变量。要在项目中实现这一点,创建一个新的着色程序,称为litTexturedShaderProgram

    按照以下方式:

GLuint litTexturedShaderProgram; 
Create the shader program and assign it to it in the init function in main.cpp. 
   litTexturedShaderProgram = shader.CreateProgram(
                              "Assets/Shaders/LitTexturedModel.vs",                 
                              "Assets/Shaders/LitTexturedModel.fs"); 
  1. 在添加rigidBody函数中,按照以下方式更改球体、地面和敌人的着色器:
  // Sphere Rigid Body 

  btCollisionShape* sphereShape = new btSphereShape(1);
  btDefaultMotionState* sphereMotionState = new 
     btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), 
     btVector3(0, 0.5, 0)));

  btScalar mass = 13.0f;
  btVector3 sphereInertia(0, 0, 0);
  sphereShape->calculateLocalInertia(mass, sphereInertia);

  btRigidBody::btRigidBodyConstructionInfo 
     sphereRigidBodyCI(mass, sphereMotionState, sphereShape, 
     sphereInertia);

  btRigidBody* sphereRigidBody = new btRigidBody
                                 (sphereRigidBodyCI);

  sphereRigidBody->setFriction(1.0f);
  sphereRigidBody->setRestitution(0.0f);

  sphereRigidBody->setActivationState(DISABLE_DEACTIVATION);

  dynamicsWorld->addRigidBody(sphereRigidBody);

  // Sphere Mesh

  sphere = new MeshRenderer(MeshType::kSphere, “hero”, 
           camera, sphereRigidBody, light, 0.1f, 0.5f);
  sphere->setProgram(litTexturedShaderProgram);
  sphere->setTexture(sphereTexture);
  sphere->setScale(glm::vec3(1.0f));

  sphereRigidBody->setUserPointer(sphere);

  // Ground Rigid body

  btCollisionShape* groundShape = new btBoxShape(btVector3(4.0f,   
                                  0.5f, 4.0f));
  btDefaultMotionState* groundMotionState = new 
    btDefaultMotionState(btTransform(btQuaternion(0, 0, 0, 1), 
     btVector3(0, -1.0f, 0)));

  btRigidBody::btRigidBodyConstructionInfo 
    groundRigidBodyCI(0.0f, groundMotionState, groundShape, 
    btVector3(0, 0, 0));

  btRigidBody* groundRigidBody = new btRigidBody
                                 (groundRigidBodyCI);

  groundRigidBody->setFriction(1.0);
  groundRigidBody->setRestitution(0.0);

  groundRigidBody->setCollisionFlags(
     btCollisionObject::CF_STATIC_OBJECT);

  dynamicsWorld->addRigidBody(groundRigidBody);

  // Ground Mesh
  ground = new MeshRenderer(MeshType::kCube, “ground”,
           camera, groundRigidBody, light, 0.1f, 0.5f);
  ground->setProgram(litTexturedShaderProgram);
  ground->setTexture(groundTexture);
  ground->setScale(glm::vec3(4.0f, 0.5f, 4.0f));

  groundRigidBody->setUserPointer(ground);

  // Enemy Rigid body

  btCollisionShape* shape = new btBoxShape(btVector3(1.0f, 
                            1.0f, 1.0f));
  btDefaultMotionState* motionState = new btDefaultMotionState(
      btTransform(btQuaternion(0, 0, 0, 1), 
      btVector3(18.0, 1.0f, 0)));
  btRigidBody::btRigidBodyConstructionInfo rbCI(0.0f, 
     motionState, shape, btVector3(0.0f, 0.0f, 0.0f));

  btRigidBody* rb = new btRigidBody(rbCI);

  rb->setFriction(1.0);
  rb->setRestitution(0.0);

  //rb->setCollisionFlags(btCollisionObject::CF_KINEMATIC_OBJECT);

  rb->setCollisionFlags(btCollisionObject::CF_NO_CONTACT_RESPONSE);

  dynamicsWorld->addRigidBody(rb);

  // Enemy Mesh
  enemy = new MeshRenderer(MeshType::kCube, “enemy”, 
          camera, rb, light, 0.1f, 0.5f);
  enemy->setProgram(litTexturedShaderProgram);
  enemy->setTexture(groundTexture);
  enemy->setScale(glm::vec3(1.0f, 1.0f, 1.0f));

  rb->setUserPointer(enemy);

  1. 构建并运行项目以查看光照着色器生效:

图片

作为练习,尝试向背景添加纹理,就像我们在 SFML 游戏中做的那样。

摘要

在本章中,我们看到了如何添加游戏对象之间的碰撞检测,然后通过添加控制和得分来完成了游戏循环。使用字体加载库 FreeType,我们将 TrueType 字体加载到我们的游戏中,以添加得分文本。最后,为了锦上添花,我们通过向对象添加 Phong 光照模型来添加场景中的光照。

在图形上,我们还可以添加很多内容来增加游戏的真实感,例如添加后处理效果的帧缓冲区。我们还可以添加如灰尘和雨的粒子效果。要了解更多信息,我强烈推荐learnopengl.com,如果你希望了解更多关于 OpenGL 的信息,它是一个惊人的资源。

在下一章中,我们将开始探索 Vulkan 渲染 API,并查看它与 OpenGL 的不同之处。

第四部分:使用 Vulkan 渲染 3D 对象

利用我们在上一节中获得的三维图形编程知识,我们现在可以在此基础上开发一个基本的 Vulkan 项目。OpenGL 是一个高级图形库。OpenGL 在后台做了很多用户通常不知道的事情。使用 Vulkan,我们将看到图形库的内部工作原理。我们将了解创建 SwapChains、图像视图、渲染过程、帧缓冲区、命令缓冲区的原因和方法,以及将对象渲染和呈现到场景中的过程。

以下章节包含在本节中:

第九章,Vulkan 入门

第十章,准备清除屏幕

第十一章,创建对象资源

第十二章,绘制 Vulkan 对象

第九章:Vulkan 入门

在前三个章节中,我们使用 OpenGL 进行渲染。虽然 OpenGL 适合开发原型并快速开始渲染,但它确实有其弱点。首先,OpenGL 非常依赖驱动程序,这使得它在性能方面较慢且不可预测,这也是为什么我们更喜欢使用 Vulkan 进行渲染的原因。

在本章中,我们将涵盖以下主题:

  • 关于 Vulkan

  • 配置 Visual Studio

  • Vulkan 验证层和扩展

  • Vulkan 实例

  • Vulkan 上下文类

  • 创建窗口表面

  • 选择物理设备并创建逻辑设备

关于 Vulkan

使用 OpenGL 时,开发者必须依赖 NVIDIA、AMD 和 Intel 等厂商发布适当的驱动程序,以便在游戏发布前提高游戏性能。只有当开发者与厂商紧密合作时,这才能实现。如果不是这样,厂商只能在游戏发布后才能发布优化驱动程序,并且发布新驱动程序可能需要几天时间。

此外,如果你想要将你的 PC 游戏移植到移动平台,并且你使用 OpenGL 作为渲染器,你将需要将渲染器移植到 OpenGLES,它是 OpenGL 的一个子集,其中 ES 代表嵌入式系统。尽管 OpenGL 和 OpenGLES 之间有很多相似之处,但要使其在其他平台上工作,仍然需要做额外的工作。为了减轻这些问题,引入了 Vulkan。Vulkan 通过减少驱动程序的影响并提供明确的开发者控制来提高游戏性能,从而赋予开发者更多的控制权。

Vulkan 是从底层开发的,因此与 OpenGL 不向后兼容。当使用 Vulkan 时,你可以完全访问 GPU。

使用完整的 GPU 访问,你也有完全的责任来实现渲染 API。因此,使用 Vulkan 的缺点在于,当你用它进行开发时,你必须指定一切。

总的来说,这使得 Vulkan 成为一个非常冗长的 API,你必须指定一切。然而,这也使得当 GPU 添加新功能时,很容易为 Vulkan 的 API 规范创建扩展。

配置 Visual Studio

Vulkan 只是一个渲染 API,因此我们需要创建一个窗口并进行数学运算。对于这两者,我们将使用 GLFW 和 GLM,就像我们创建 OpenGL 项目时一样。为此,请按照以下步骤操作:

  1. 创建一个新的 Visual Studio C++项目,并将其命名为VulkanProject

  2. 将 OpenGL 项目中的GLFWGLM文件夹复制到VulkanProject文件夹中,放在名为Dependencies的文件夹内。

  3. 下载 Vulkan SDK。访问 vulkan.lunarg.com/sdk/home 并下载 SDK 的 Windows 版本,如以下截图所示:

  1. 按照以下截图所示安装 SDK:

  1. Dependencies 目录中创建一个名为 Vulkan 的新文件夹。从 Vulkan SDK 文件夹中复制并粘贴 Lib 和包含文件夹到 C:\ 驱动器,如图所示:

图片

  1. 在 Visual Studio 项目中,创建一个新的空白 source.cpp 文件。打开 Vulkan 项目属性,并将 include 目录添加到 C/C+ | 通用 | 额外包含目录。

  2. 确保在配置和平台下拉列表中选择了所有配置和所有平台,如图所示:

图片

  1. 在链接器 | 通用部分下添加库目录,如图所示:

图片

  1. 在链接器 | 输入中设置您想要使用的库,如图所示:

图片

在完成准备工作后,让我们检查我们的窗口创建是否正常工作:

  1. 在 source.cpp 中添加以下代码:

#defineGLFW_INCLUDE_VULKAN 
#include<GLFW/glfw3.h> 

int main() { 

   glfwInit(); 

   glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); 
   glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); 

   GLFWwindow* window = glfwCreateWindow(1280, 720, "HELLO VULKAN ", nullptr, nullptr); 

   while (!glfwWindowShouldClose(window)) { 

         glfwPollEvents(); 
   } 

   glfwDestroyWindow(window); 
   glfwTerminate(); 

   return 0; 
}

首先,我们包含 glfw3.h 并让 GLFW 包含一些与 Vulkan 相关的头文件。然后,在主函数中,我们通过调用 glfwInit() 初始化 GLFW。然后,我们调用 glfwWindowHint 函数。第一个 glfwWindowHint 函数不会创建 OpenGL 上下文,因为它默认由 Glfw 创建。在下一个函数中,我们禁用了即将创建的窗口的调整大小功能。

然后,我们以创建 OpenGL 项目中创建窗口的类似方式创建一个 1,280 x 720 的窗口。我们创建一个 while 循环来检查窗口是否应该关闭。如果窗口不需要关闭,我们将轮询系统事件。一旦完成,我们将销毁窗口,终止 glfw,并返回 0

  1. 这应该会给我们一个可以工作的窗口。以调试模式作为 x64 可执行文件运行应用程序,以查看显示的窗口和显示的 HELLO VULKAN,如图所示:

图片

Vulkan 验证层和扩展

在我们开始创建 Vulkan 应用程序之前,我们必须检查应用程序验证层和扩展。让我们更详细地了解一下:

  • 验证层:由于给予开发者的控制权很大,开发者也有可能以错误的方式实现 Vulkan 应用程序。Vulkan 验证层会检查这些错误,并告知开发者他们正在做错事,需要修复。

  • 扩展:在 Vulkan API 的开发过程中,可能会为新的 GPU 引入新功能。为了保持 Vulkan 的更新,我们需要通过添加扩展来扩展其功能。

这类的一个例子是在 RTX 系列 GPU 中引入光线追踪。在 Vulkan 中,创建了一个新的扩展来支持 NVIDIA 在硬件上的这一变化,即Vk_NV_ray_tracing。如果我们的游戏使用这个扩展,我们可以检查硬件是否支持它。

类似的扩展也可以在应用程序级别添加和检查。其中一个这样的扩展是调试报告扩展,当我们在实现 Vulkan 时出现问题时,我们可以生成这个扩展。我们的第一个类将向应用程序添加此功能,以检查应用程序验证层和扩展。

让我们开始创建我们的第一个类。创建一个名为AppValidationLayersAndExtensions的新类。在AppValidationLayersAndExtensions.h中,添加以下代码:

#pragmaonce 

#include<vulkan\vulkan.h> 
#include<vector> 
#include<iostream> 

#defineGLFW_INCLUDE_VULKAN 
#include<GLFW\glfw3.h> 

classAppValidationLayersAndExtensions { 

public: 
   AppValidationLayersAndExtensions(); 
   ~AppValidationLayersAndExtensions(); 

   const std::vector<constchar*> requiredValidationLayers = { 
         "VK_LAYER_LUNARG_standard_validation" 
   }; 

   bool checkValidationLayerSupport(); 
   std::vector<constchar*>getRequiredExtensions
     (boolisValidationLayersEnabled); 

   // Debug Callback 
   VkDebugReportCallbackEXT callback; 

   void setupDebugCallback(boolisValidationLayersEnabled, 
      VkInstancevkInstance); 
   void destroy(VkInstanceinstance, boolisValidationLayersEnabled); 

   // Callback 

* pCreateInfo, VkResultcreateDebugReportCallbackEXT( 
       VkInstanceinstance, 
       constVkDebugReportCallbackCreateInfoEXT         
       constVkAllocationCallbacks* pAllocator, 
       VkDebugReportCallbackEXT* pCallback) { 

         auto func = (PFN_vkCreateDebugReportCallbackEXT)
                     vkGetInstanceProcAddr(instance, 
                     "vkCreateDebugReportCallbackEXT"); 

         if (func != nullptr) { 
               return func(instance, pCreateInfo, pAllocator, pCallback); 
         } 
         else { 
               returnVK_ERROR_EXTENSION_NOT_PRESENT; 
         } 

   } 

   void DestroyDebugReportCallbackEXT( 
         VkInstanceinstance, 
         VkDebugReportCallbackEXTcallback, 
         constVkAllocationCallbacks* pAllocator) { 

         auto func = (PFN_vkDestroyDebugReportCallbackEXT)
                     vkGetInstanceProcAddr(instance, 
                     "vkDestroyDebugReportCallbackEXT"); 
         if (func != nullptr) { 
               func(instance, callback, pAllocator); 
         } 
   } 

}; 

我们包含vulkan.hiostreamvectorglfw。然后,我们创建一个名为requiredValidationLayers的向量;这是我们将VK_LAYER_LUNARG_standard_validation传递的地方。对于我们的应用程序,我们需要标准验证层,其中包含所有验证层。如果我们只需要特定的验证层,我们也可以单独指定它们。接下来,我们创建两个函数:一个用于检查验证层的支持,另一个用于获取所需的扩展。

为了在发生错误时生成报告,我们需要一个调试回调。我们将向其中添加两个函数:一个用于设置调试回调,另一个用于销毁它。这些函数将调用debugcreatedestroy函数;它们将调用vkGetInstanceProcAddr以获取vkCreateDebugReportCallbackEXTvkDestroyDebugReportCallbackEXT指针函数的指针,以便我们可以调用它们。

如果生成调试报告不那么令人困惑会更好,但不幸的是,这就是必须这样做的方式。然而,我们只需要做一次。让我们继续实施AppValidationLayersAndExtentions.cpp

  1. 首先,我们添加构造函数和析构函数,如下所示:
AppValidationLayersAndExtensions::AppValidationLayersAndExtensions(){} 

AppValidationLayersAndExtensions::~AppValidationLayersAndExtensions(){} 
Then we add the implementation to checkValidationLayerSupport(). 

bool AppValidationLayersAndExtensions::checkValidationLayerSupport() { 

   uint32_t layerCount; 

   // Get count of validation layers available 
   vkEnumerateInstanceLayerProperties(&layerCount, nullptr); 

   // Get the available validation layers names  
   std::vector<VkLayerProperties>availableLayers(layerCount); 
   vkEnumerateInstanceLayerProperties(&layerCount,
   availableLayers.data()); 

   for (const char* layerName : requiredValidationLayers) { //layers we
   require 

         // boolean to check if the layer was found 
         bool layerFound = false; 

         for (const auto& layerproperties : availableLayers) { 

               // If layer is found set the layar found boolean to true 
               if (strcmp(layerName, layerproperties.layerName) == 0) { 
                     layerFound = true; 
                     break; 
               } 
         } 

         if (!layerFound) { 
               return false; 
         } 

         return true; 

   } 

}

要检查支持的验证层,调用vkEnumerateInstanceLayerProperties函数两次。我们第一次调用它以获取可用的验证层数量。一旦我们有了计数,我们再次调用它以填充层的名称。

我们创建一个名为layerCountint,并在第一次调用vkEnumerateInstanceLayerProperties时传入它。该函数接受两个参数:第一个是计数,第二个最初保持为null。一旦函数被调用,我们将知道有多少验证层可用。对于层的名称,我们创建一个新的名为availableLayersVkLayerProperties类型向量,并用layerCount初始化它。然后,函数再次被调用,这次我们传入layerCount和向量作为参数来存储信息。之后,我们在所需层和可用层之间进行检查。如果找到了验证层,函数将返回true。如果没有找到,它将返回false

  1. 接下来,我们需要添加getRequiredInstanceExtensions函数,如下所示:
std::vector<constchar*>AppValidationLayersAndExtensions::getRequiredExtensions(boolisValidationLayersEnabled) { 

   uint32_t glfwExtensionCount = 0; 
   constchar** glfwExtensions; 

   // Get extensions 
   glfwExtensions = glfwGetRequiredInstanceExtensions
                    (&glfwExtensionCount); 

   std::vector<constchar*>extensions(glfwExtensions, glfwExtensions 
     + glfwExtensionCount); 

   //debug report extention is added. 

   if (isValidationLayersEnabled) { 
         extensions.push_back("VK_EXT_debug_report"); 
   } 

   return extensions; 
}

getRequiredInstanceExtensions短语将获取由GLFW支持的扩展。它接受一个布尔值来检查验证层是否启用,并返回一个包含支持扩展名称的向量。在这个函数中,我们创建一个名为glfwExtensionCountunint32_t和一个用于存储扩展名称的const char。我们调用glfwGetRequiredExtensions,传入glfwExtensionCount,并将其设置为等于glfwExtensions。这将把所有必需的扩展存储在glfwExtensions中。

我们创建一个新的扩展向量,并存储glfwExtention名称。如果我们启用了验证层,则可以添加一个额外的扩展层,称为VK_EXT_debug_report,这是用于生成调试报告的扩展。这个扩展向量在函数结束时返回。

  1. 然后,我们添加调试报告回调函数,该函数将在出现错误时生成报告消息,如下所示:
 staticVKAPI_ATTRVkBool32VKAPI_CALL debugCallback( 
   VkDebugReportFlagsEXTflags, 
   VkDebugReportObjectTypeEXTobjExt, 
   uint64_tobj, 
   size_tlocation, 
   int32_tcode, 
   constchar* layerPrefix, 
   constchar* msg, 
   void* userData) { 

   std::cerr <<"validation layer: "<<msg<< std::endl << std::endl; 

   returnfalse; 

} 
  1. 接下来,我们需要创建setupDebugCallback函数,该函数将调用createDebugReportCallbackExt函数,如下所示:
voidAppValidationLayersAndExtensions::setupDebugCallback(boolisValidationLayersEnabled, VkInstancevkInstance) { 

   if (!isValidationLayersEnabled) { 
         return; 
   } 

   printf("setup call back \n"); 

   VkDebugReportCallbackCreateInfoEXT info = {}; 

   info.sType = VK_STRUCTURE_TYPE_DEBUG_REPORT
                _CALLBACK_CREATE_INFO_EXT; 
   info.flags = VK_DEBUG_REPORT_ERROR_BIT_EXT | 
                VK_DEBUG_REPORT_WARNING_BIT_EXT; 
   info.pfnCallback = debugCallback; // callback function 

   if (createDebugReportCallbackEXT(vkInstance, &info, nullptr, 
     &callback) != VK_SUCCESS) { 

         throw std::runtime_error("failed to set debug callback!"); 
   } 

} 

此函数接受一个布尔值,用于检查验证层是否启用。它还接受一个 Vulkan 实例,我们将在本类之后创建它。

在创建 Vulkan 对象时,我们通常必须用所需的参数填充一个结构体。因此,要创建DebugReportCallback,我们首先必须填充VkDebugReportCallbackCreateInfoExt结构体。在这个结构体中,我们传入sType,它指定了结构体类型。我们还传入任何用于错误和警告报告的标志。最后,我们传入callback函数本身。然后,我们调用createDebugReportCallbackExt函数,传入实例、结构体、用于内存分配的空指针和callback函数。尽管我们为内存分配传入了一个空指针,但 Vulkan 将自行处理内存分配。如果你有自己的内存分配函数,此函数是可用的。

  1. 现在,让我们创建destroy函数,以便我们可以销毁调试报告callback函数,如下所示:
voidAppValidationLayersAndExtensions::destroy(VkInstanceinstance, boolisValidationLayersEnabled){ 

   if (isValidationLayersEnabled) { 
         DestroyDebugReportCallbackEXT(instance, callback, nullptr); 
   } 

} 

Vulkan 实例

要使用 AppValidationLayerAndExtension 类,我们必须创建一个 Vulkan 实例。为此,请按照以下步骤操作:

  1. 我们将创建另一个名为 VulkanInstance 的类。在 VulkanInstance.h 中,添加以下代码:
#pragmaonce 
#include<vulkan\vulkan.h> 

#include"AppValidationLayersAndExtensions.h" 

classVulkanInstance 
{ 
public: 
   VulkanInstance(); 
   ~VulkanInstance(); 

   VkInstance vkInstance; 

   void createAppAndVkInstance(,boolenableValidationLayers  
        AppValidationLayersAndExtensions *valLayersAndExtentions); 

};  

我们包含 vulkan.hAppValidationLayersAndExtentions.h,因为我们创建 Vulkan 实例时将需要所需的验证层和扩展。我们添加了构造函数、析构函数以及 VkInstance 的实例,以及一个名为 ceeateAppAndVkInstance 的函数。这个函数接受一个布尔值,用于检查验证层是否启用,以及 AppValidationLayersAndExtensions。这就是头文件的内容。

  1. .cpp 文件中,添加以下代码:
#include"VulkanInstance.h" 

VulkanInstance::VulkanInstance(){} 

VulkanInstance::~VulkanInstance(){}
  1. 然后添加 createAppAndVkInstance 函数,这将允许我们创建 Vulkan 实例,如下所示:
voidVulkanInstance::createAppAndVkInstance(boolenableValidationLayers, AppValidationLayersAndExtensions *valLayersAndExtentions) { 

   // links the application to the Vulkan library 

   VkApplicationInfo appInfo = {}; 
   appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; 
   appInfo.pApplicationName = "Hello Vulkan"; 
   appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0); 
   appInfo.pEngineName = "SidTechEngine"; 
   appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0); 
   appInfo.apiVersion = VK_API_VERSION_1_0; 

   VkInstanceCreateInfo vkInstanceInfo = {}; 
   vkInstanceInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; 
   vkInstanceInfo.pApplicationInfo = &appInfo; 

   // specify extensions and validation layers 
   // these are global meaning they are applicable to whole program 
      not just the device 

   auto extensions = valLayersAndExtentions->
                   getRequiredExtensions(enableValidationLayers); 

   vkInstanceInfo.enabledExtensionCount = static_cast<uint32_t>
      (extensions.size());; 
   vkInstanceInfo.ppEnabledExtensionNames = extensions.data(); 

   if (enableValidationLayers) { 
     vkInstanceInfo.enabledLayerCount = static_cast<uint32_t>
     (valLayersAndExtentions->requiredValidationLayers.size()); 
     vkInstanceInfo.ppEnabledLayerNames = 
     valLayersAndExtentions->requiredValidationLayers.data(); 
   } 
   else { 
         vkInstanceInfo.enabledLayerCount = 0; 
   } 
  if (vkCreateInstance(&vkInstanceInfo, nullptr, &vkInstance) !=
   VK_SUCCESS) {
   throw std::runtime_error("failed to create vkInstance ");
  }
}   

在前面的函数中,我们必须填充 VkApplicationInfostruct,这在创建 VkInstance 时是必需的。然后,我们创建 appInfo 结构体。在这里,我们指定的第一个参数是 struct 类型,它是 VK_STRUCTURE_TYPE_APPLICATION_INFO 类型。下一个参数是应用程序名称本身,我们在这里指定应用程序版本,版本号为 1.0。然后,我们指定引擎名称和版本。最后,我们指定要使用的 Vulkan API 版本。

一旦应用程序 struct 已被填充,我们可以创建 vkInstanceCreateInfo 结构体,这将创建 Vulkan 实例。在我们创建的结构体实例中——就像之前的所有结构体一样——我们必须指定具有 struct 类型的结构体,它是 VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO

然后,我们必须传递应用程序信息结构体。我们必须指定 Vulkan 扩展和验证层以及计数。这些信息是从 AppValidationLayersAndExtensions 类中检索的。验证层仅在类处于调试模式时启用;否则,它不会被启用。

现在,我们可以通过调用 vkCreateInstance 函数来创建 Vulkan 实例。这个函数有三个参数:创建信息实例、分配器和用于存储 Vulkan 实例的实例变量。对于分配,我们指定 nullptr 并让 Vulkan 处理内存分配。如果 Vulkan 实例没有创建,将在控制台打印运行时错误,表示函数未能创建 Vulkan 实例。

为了使用这个 ValidationAndExtensions 类和 Vulkan 实例类,我们将创建一个新的 Singleton 类,名为 VulkanContext。我们这样做是因为在创建我们的 ObjectRenderer 时,我们需要访问这个类中的一些 Vulkan 对象。

Vulkan 上下文类

Vulkan 上下文类将包含我们创建 Vulkan 渲染器所需的所有功能。在这个类中,我们将创建验证层,创建 Vulkan 应用程序和实例,选择我们想要使用的 GPU,创建 swapchain,创建渲染目标,创建渲染通道,并添加命令缓冲区,以便我们可以将我们的绘图命令发送到 GPU。

我们还将添加两个新的函数:drawBegindrawEnd。在drawBegin函数中,我们将添加绘图准备阶段的函数。drawEnd函数将在我们绘制一个对象并准备它以便可以在视口中呈现之后被调用。

创建一个新的.h类文件和.cpp文件。在.h文件中,包含以下代码:

#defineGLFW_INCLUDE_VULKAN 
#include<GLFW\glfw3.h> 

#include<vulkan\vulkan.h> 

#include"AppValidationLayersAndExtensions.h" 
#include"VulkanInstance.h" 

接下来,我们将创建一个布尔值isValidationLayersEnabled。如果应用程序以调试模式运行,则将其设置为true;如果以发布模式运行,则设置为false

#ifdef _DEBUG 
boolconstbool isValidationLayersEnabled = true; 
#else 
constbool isValidationLayersEnabled = false; 
#endif 

接下来,我们创建类本身,如下所示:

classVulkanContext { 

public: 

staticVulkanContextn* instance;   
staticVulkanContext* getInstance(); 

   ~VulkanContext(); 

   void initVulkan(); 

private: 

   // My Classes 
   AppValidationLayersAndExtensions *valLayersAndExt; 
   VulkanInstance* vInstance; 

};

public部分,我们创建一个静态实例和getInstance变量和函数,该函数用于设置和获取这个类的实例。我们添加了析构函数并添加了一个initVulkan函数,该函数将用于初始化 Vulkan 上下文。在private部分,我们创建了AppValidationLayersAndExtensionsVulkanInstance类的实例。在VulkanContext.cpp文件中,我们将实例变量设置为null,并在getInstance函数中检查实例是否已创建。如果没有创建,我们创建一个新的实例,返回它,并添加析构函数:

#include"VulkanContext.h" 

VulkanContext* VulkanContext::instance = NULL; 

VulkanContext* VulkanContext::getInstance() { 

   if (!instance) { 
         instance = newVulkanContext(); 
   } 
   return instance; 
} 

VulkanContext::~VulkanContext(){ 

然后,我们添加initVulkan函数的功能,如下所示:

voidVulkanContext::initVulkan() { 

   // Validation and Extension Layers 
   valLayersAndExt = newAppValidationLayersAndExtensions(); 

   if (isValidationLayersEnabled && !valLayersAndExt->
     checkValidationLayerSupport()) { 
         throw std::runtime_error("Validation Layers 
           Not Available !"); 
   } 

   // Create App And Vulkan Instance() 
   vInstance = newVulkanInstance(); 
   vInstance->createAppAndVkInstance(isValidationLayersEnabled, 
      valLayersAndExt); 

   // Debug CallBack 
   valLayersAndExt->setupDebugCallback(isValidationLayersEnabled, 
     vInstance->vkInstance); 

}  

首先,我们创建一个新的AppValidationLayersAndExtensions实例。然后,我们检查验证层是否启用并检查验证层是否受支持。如果ValidationLayers不可用,将发出运行时错误,表示验证层不可用。

如果验证层受支持,将创建一个新的VulkanInstance类实例并调用createAppAndVkInstance函数,该函数创建一个新的vkInstance

完成这些后,我们通过传递布尔值和vkInstance调用setupDebugCallBack函数。在source.cpp文件中,包含VulkanContext.h文件,并在窗口创建后调用initVulkan,如下所示:

 #defineGLFW_INCLUDE_VULKAN 
#include<GLFW/glfw3.h> 

#include"VulkanContext.h" 

int main() { 

   glfwInit(); 

   glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); 
   glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); 

   GLFWwindow* window = glfwCreateWindow(1280, 720, "HELLO VULKAN ", 
                        nullptr, nullptr); 

   VulkanContext::getInstance()->initVulkan(); 

   while (!glfwWindowShouldClose(window)) { 

         glfwPollEvents(); 
   }               

   glfwDestroyWindow(window); 
   glfwTerminate(); 

   return 0; 
}

希望你在构建和运行应用程序时不会在控制台窗口中遇到任何错误。如果你遇到错误,请逐行检查代码,确保没有拼写错误:

创建窗口表面

我们需要一个针对当前平台创建的窗口的接口,以便我们可以展示我们将要渲染的图像。我们使用VKSurfaceKHR属性来获取对窗口表面的访问权限。为了存储操作系统支持的表面信息,我们将调用glfw函数glfwCreateWindowSurface来创建操作系统支持的表面。

VulkanContext.h中,添加一个名为surface的新变量,类型为VkSurfaceKHR,如下所示:

private: 

   //surface 
   VkSurfaceKHR surface; 

由于我们需要访问在source.cpp中创建的窗口实例,因此更改initVulkan函数,使其接受一个GLFWwindow,如下所示:

   void initVulkan(GLFWwindow* window); 

VulkanContext.cpp中,更改initVulkan的实现,如下所示,并调用glfwCreateWindowSurface函数,该函数接受 Vulkan 实例和窗口。接下来,传入null作为分配器和表面以创建表面对象:

 void VulkanContext::initVulkan(GLFWwindow* window) { 

   // -- Platform Specific 

   // Validation and Extension Layers 
   valLayersAndExt = new AppValidationLayersAndExtensions(); 

   if (isValidationLayersEnabled && !valLayersAndExt->
      checkValidationLayerSupport()) { 
         throw std::runtime_error("Requested Validation Layers
            Not Available !"); 
   } 

   // Create App And Vulkan Instance() 
   vInstance = new VulkanInstance(); 
   vInstance->createAppAndVkInstance(isValidationLayersEnabled, 
     valLayersAndExt); 

   // Debug CallBack 
   valLayersAndExt->setupDebugCallback(isValidationLayersEnabled, 
    vInstance->vkInstance); 

   // Create Surface 
   if (glfwCreateWindowSurface(vInstance->vkInstance, window, 
      nullptr, &surface) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create window 
           surface !"); 
   } 
} 

最后,在source.cpp中更改initVulkan函数,如下所示:

   GLFWwindow* window = glfwCreateWindow(WIDTH, HEIGHT, 
                        "HELLO VULKAN ", nullptr, nullptr); 

   VulkanContext::getInstance()->initVulkan(window); 

选择物理设备并创建逻辑设备

现在,我们将创建Device类,它将用于遍历我们拥有的不同物理设备。我们将选择一个来渲染我们的应用程序。为了检查您的 GPU 是否与 Vulkan 兼容,请检查 GPU 供应商网站上的兼容性列表,或访问en.wikipedia.org/wiki/Vulkan_(API)

基本上,任何来自 Geforce 600 系列以及 Radeon HD 2000 系列及以后的 NVIDIA GPU 都应该得到支持。为了访问物理设备并创建逻辑设备,我们将创建一个新的类,这样我们就可以随时访问它。创建一个名为Device的新类。在Device.h中添加以下包含:

#include<vulkan\vulkan.h> 
#include<stdexcept> 

#include<iostream> 
#include<vector> 
#include<set> 

#include"VulkanInstance.h" 
#include"AppValidationLayersAndExtensions.h" 

为了方便起见,我们还将添加几个结构体。第一个叫做SwapChainSupportDetails;它能够访问VkSurfaceCapabilitiesKHR,其中包含有关表面的所有所需详细信息。我们还将添加VkSurfaceFormatKHR类型的surfaceFormats向量,它跟踪表面支持的所有不同图像格式,以及VkPresentModeKHR类型的presentModes向量,它存储 GPU 支持的显示模式。

渲染的图像将被发送到窗口表面并显示。这就是我们能够使用渲染器(如 OpenGL 或 Vulkan)看到最终渲染图像的原因。现在,我们可以一次显示这些图像,如果我们想永远查看静态图像,这是可以的。然而,当我们运行每 16 毫秒更新一次(每秒 60 次)的游戏时,可能会出现图像尚未完全渲染,但需要显示的情况。在这种情况下,我们会看到半渲染的图像,这会导致屏幕撕裂。

为了避免这种情况,我们使用双缓冲。这允许我们渲染图像,使其具有两个不同的图像,称为前缓冲区和后缓冲区,并在它们之间进行 ping-pong。然后,我们展示已经完成渲染的缓冲区,并在下一个帧仍在渲染时将其显示到视口中,如下面的图所示。还有不同的方式来展示图像。当我们创建 swapchain 时,我们将查看这些不同的呈现模式:

图片

我们需要创建一个结构体来跟踪表面属性、格式和呈现模式,如下所示:

structSwapChainSupportDetails { 

   VkSurfaceCapabilitiesKHR surfaceCapabilities; // size and images 
                                                  in swapchain 
   std::vector<VkSurfaceFormatKHR> surfaceFormats; 
   std::vector<VkPresentModeKHR> presentModes; 
}; 

GPU 也有被称为QueueFamilies的东西。命令被发送到 GPU,然后使用队列执行。有针对不同类型工作的单独队列。渲染命令被发送到渲染队列,计算命令被发送到计算队列,还有用于展示图像的呈现队列。我们还需要知道 GPU 支持哪些队列以及有多少队列存在。

渲染器、计算和呈现队列可以组合,并被称为队列家族。这些队列可以以不同的方式组合,形成多个队列家族。这意味着可以组合渲染和呈现队列形成一个队列家族,而另一个家族可能只包含计算队列。因此,我们必须检查我们是否至少有一个包含图形和呈现队列的队列家族。这是因为我们需要一个图形队列来传递我们的渲染命令,以及一个呈现队列在渲染后展示图像。

我们将添加一个结构体来检查这两个方面,如下所示:

structQueueFamilyIndices { 

   int graphicsFamily = -1; 
   int presentFamily = -1; 

   bool arePresent() { 
         return graphicsFamily >= 0 && presentFamily >= 0; 
   } 
}; 

现在,我们将创建Device类本身。在创建类之后,我们添加构造函数和析构函数,如下所示:

 { 

public: 

   Device(); 
   ~Device();  

然后,我们需要添加一些变量,以便我们可以存储物理设备、SwapChainSupportDetailsQueueFamilyIndices,如下所示:

   VkPhysicalDevice physicalDevice; 
   SwapChainSupportDetails swapchainSupport; 
   QueueFamilyIndices queueFamiliyIndices; 

要创建双缓冲,我们必须检查设备是否支持它。这是通过使用VK_KHR_SWAPCHAIN_EXTENSION_NAME扩展来完成的,该扩展检查 swapchain。首先,我们创建一个char*常量向量,并传入扩展名称,如下所示:

std::vector<constchar*>deviceExtensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME };

然后,我们添加了pickPhysicalDevice函数,该函数将根据设备是否合适来选择。在检查合适性的过程中,我们将检查所选设备是否支持 swapchain 扩展,获取 swapchain 支持详情,以及获取队列家族索引,如下所示:

   void pickPhysicalDevice (VulkanInstance* vInstance, 
     VkSurfaceKHR surface); 

   bool isDeviceSuitable(VkPhysicalDevice device, 
     VkSurfaceKHR surface); 

   bool checkDeviceExtensionSupported(VkPhysicalDevice device) ; 
   SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice 
      device, VkSurfaceKHR surface); 
   QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device, 
      VkSurfaceKHR surface); 

我们还将添加一个获取器函数来获取当前设备的队列家族,如下所示:

 QueueFamilyIndicesgetQueueFamiliesIndicesOfCurrentDevice();  

一旦我们有了想要使用的物理设备,我们将创建一个逻辑设备的实例。逻辑设备是物理设备本身的接口。我们将使用逻辑设备来创建缓冲区等。我们还将存储当前设备的图形和呈现队列,以便我们可以发送图形和呈现命令。最后,我们将添加一个destroy函数,用于销毁我们创建的物理和逻辑设备,如下所示:

   // ++++++++++++++ 
   // Logical device 
   // ++++++++++++++ 

   void createLogicalDevice(VkSurfaceKHRsurface, 
      boolisValidationLayersEnabled, AppValidationLayersAndExtensions 
      *appValLayersAndExtentions); 

   VkDevice logicalDevice; 

   // handle to the graphics queue from the queue families of the gpu 
   VkQueue graphicsQueue; // we can also have seperate queue for 
                            compute, memory transfer, etc. 
   VkQueue presentQueue; // queue for displaying the framebuffer 

   void destroy(); 
}; // End of Device class

Device.h文件的内容就到这里。让我们继续到Device.cpp。首先,我们包含Device.h并添加构造函数和析构函数,如下所示:

#include"Device.h" 

Device::Device(){} 

Device::~Device(){ 

} 

现在,真正的任务开始了。我们需要创建一个pickPhysicalDevice函数,它接受一个 Vulkan 实例和VkSurface,如下所示:


voidDevice::pickPhysicalDevice(VulkanInstance* vInstance, VkSurfaceKHRsurface) { 

   uint32_t deviceCount = 0; 

   vkEnumeratePhysicalDevices(vInstance->vkInstance, &deviceCount, 
      nullptr); 

   if (deviceCount == 0) { 
         throw std::runtime_error("failed to find GPUs with vulkan 
           support !"); 
   } 

   std::cout <<"Device Count: "<< deviceCount << std::endl; 

   std::vector<VkPhysicalDevice>devices(deviceCount); 
   vkEnumeratePhysicalDevices(vInstance->vkInstance, &deviceCount, 
      devices.data()); 

   std::cout << std::endl; 
   std::cout <<"DEVICE PROPERTIES"<< std::endl; 
   std::cout <<"================="<< std::endl; 

   for (constauto& device : devices) { 

         VkPhysicalDeviceProperties  deviceProperties; 

         vkGetPhysicalDeviceProperties(device, &deviceProperties); 

         std::cout << std::endl; 
         std::cout <<"Device name: "<< deviceProperties.deviceName 
                   << std::endl; 

         if (isDeviceSuitable(device, surface)) 
               physicalDevice = device; 

   break; 

   } 

   if (physicalDevice == VK_NULL_HANDLE) { 
         throw std::runtime_error("failed to find suitable GPU !"); 
   } 

} 

在这里,我们创建一个int32来存储物理设备的数量。我们使用vkEnumeratePhysicalDevices获取可用的 GPU 数量,并将 Vulkan 实例、计数和第三个参数的null传递过去。这将检索可用的设备数量。如果deviceCount为零,这意味着没有可用的 GPU。然后,我们将可用的设备数量打印到控制台。

要获取物理设备本身,我们创建一个名为devices的向量,它将存储VkPhysicalDevice数据类型;这将为我们存储设备。我们将再次调用vkEnumeratePhysicalDevices函数,但这次——除了传递 Vulkan 实例和设备计数之外——我们还将设备信息存储在我们传递的第三个参数中。然后,我们将打印出带有DEVICE PROPERTIES标题的设备数量。

要获取可用设备的属性,我们将遍历设备数量,并使用vkGetPhysicalDeviceProperties获取它们的属性,在将它们存储在VkPhysicalDeviceProperties类型的变量中之前。

现在,我们需要打印出设备的名称,并在设备上调用DeviceSuitable。如果设备合适,我们将将其存储为physicalDevice并退出循环。请注意,我们将第一个可用的设备设置为我们将要使用的设备。

如果没有合适的设备,我们将抛出一个运行时错误,表示未找到合适的设备。让我们看看DeviceSuitable函数:

bool Device::isDeviceSuitable(VkPhysicalDevice device, VkSurfaceKHR 
   surface)  { 

   // find queue families the device supports 

   QueueFamilyIndices qFamilyIndices = findQueueFamilies(device, 
                                       surface); 

   // Check device extentions supported 
   bool extensionSupported = checkDeviceExtensionSupported(device); 

   bool swapChainAdequate = false; 

   // If swapchain extension is present  
   // Check surface formats and presentation modes are supported 
   if (extensionSupported) { 

         swapchainSupport = querySwapChainSupport(device, surface); 
         swapChainAdequate = !swapchainSupport.surfaceFormats.empty() 
                             && !swapchainSupport.presentModes.empty(); 

   } 

   VkPhysicalDeviceFeatures supportedFeatures; 
   vkGetPhysicalDeviceFeatures(device, &supportedFeatures); 

   return qFamilyIndices.arePresent() && extensionSupported && 
     swapChainAdequate && supportedFeatures.samplerAnisotropy; 

} 

在这个函数中,我们通过调用findQueueFamilies获取队列家族索引。然后,我们检查是否支持VK_KHR_SWAPCHAIN_EXTENSION_NAMEextension。之后,我们检查设备上的 swapchain 支持。如果表面格式和呈现模式不为空,swapChainAdequateboolean设置为true。最后,我们通过调用vkGetPhysicalDeviceFeatures获取物理设备特性。

最后,如果队列家族存在,swapchain 扩展被支持,swapchain 足够,并且设备支持各向异性过滤,我们将返回true。各向异性过滤是一种使远处的像素更清晰的模式。

各向异性过滤是一种模式,当启用时,有助于从极端角度查看的纹理变得更加清晰。

在以下示例中,右侧的图像启用了各向异性过滤,而左侧的图像未启用。在右侧的图像中,白色虚线在道路下方仍然相对可见。然而,在左侧的图像中,虚线变得模糊且像素化。因此,需要各向异性过滤:

图片

(摘自i.imgur.com/jzCq5sT.jpg)

让我们看看在上一函数中调用的三个函数。首先,让我们看看findQueueFamilies函数:

QueueFamilyIndicesDevice::findQueueFamilies(VkPhysicalDevicedevice, VkSurfaceKHRsurface) { 

   uint32_t queueFamilyCount = 0; 

   vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, 
      nullptr); 

   std::vector<VkQueueFamilyProperties>queueFamilies(queueFamilyCount); 

   vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, 
      queueFamilies.data()); 

   int i = 0; 

   for (constauto& queueFamily : queueFamilies) { 

         if (queueFamily.queueCount > 0 && queueFamily.queueFlags 
           &VK_QUEUE_GRAPHICS_BIT) { 
               queueFamiliyIndices.graphicsFamily = i; 
         } 

         VkBool32 presentSupport = false; 
         vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, 
           &presentSupport); 

         if (queueFamily.queueCount > 0 && presentSupport) { 
               queueFamiliyIndices.presentFamily = i; 
         } 

         if (queueFamiliyIndices.arePresent()) { 
               break; 
         } 

         i++; 
   } 

   return queueFamiliyIndices; 
}

要获取队列家族属性,我们调用vkGetPhysicalDeviceQueueFamilyProperties函数;然后,在物理设备中,我们传递一个int,我们用它来存储队列家族的数量,以及null指针。这将给我们提供可用的队列家族数量。

接下来,对于属性本身,我们创建了一个VkQueueFamilyProperties类型的向量,称为queueFamilies,用于存储必要的信息。然后,我们调用vkGetPhysicalDeviceFamilyProperties并传递物理设备、计数和queueFamilies本身,以填充所需的数据。我们创建一个inti,并将其初始化为0。这将存储图形和演示索引的索引。

for循环中,我们检查每个队列家族是否支持图形队列,通过查找VK_QUEUE_GRAPHICS_BIT。如果支持,我们设置图形家族索引。

然后,我们通过传递索引来检查演示支持。这将检查是否相同的家族也支持演示。如果它支持演示,我们将presentFamily设置为该索引。

如果队列家族支持图形和演示,图形和演示索引将是相同的。

以下截图显示了按设备划分的队列家族数量以及每个队列家族中的队列数量:

图片

我的 GPU 上有三个队列家族。第一个队列家族在 0^(th)索引处有 16 个队列,第二个队列家族在 1^(st)索引处有一个队列,第三个队列家族在 2^(nd)索引处有八个队列。

queueFlags指定队列家族中的队列。支持的队列可以是图形、计算、传输或稀疏绑定。

然后,我们检查是否找到了图形和显示索引,然后退出循环。最后,我们返回queueFamilyIndices。我在 Intel Iris Plus Graphics 650 上运行项目。这个集成的英特尔 GPU 有一个支持图形和显示队列的队列家族。不同的 GPU 有不同的队列家族,每个家族可能支持多种队列类型。接下来,让我们看看支持的设备扩展。我们可以通过使用checkDeviceExtensionSupported函数来检查这一点,该函数接受一个物理设备,如下面的代码所示:

 boolDevice::checkDeviceExtensionSupported(VkPhysicalDevicedevice){ 

   uint32_t extensionCount; 

   // Get available device extentions count 
   vkEnumerateDeviceExtensionProperties(device, nullptr, 
     &extensionCount, nullptr); 

   // Get available device extentions 
   std::vector<VkExtensionProperties>availableExtensions(extensionCount); 

   vkEnumerateDeviceExtensionProperties(device, nullptr,  
     &extensionCount, availableExtensions.data()); 

   // Populate with required device exentions we need 
   std::set<std::string>requiredExtensions(deviceExtensions.begin(), 
     deviceExtensions.end()); 

   // Check if the required extention is present 
   for (constauto& extension : availableExtensions) { 
         requiredExtensions.erase(extension.extensionName); 
   } 

   // If device has the required device extention then return  
   return requiredExtensions.empty(); 
} 

通过调用vkEnumerateDeviceExtensionProperties并传递物理设备、空指针、一个用于存储计数的int和一个空指针来获取设备支持的扩展数量。实际的属性存储在availableExtensions向量中,该向量存储VkExtensionProperties数据类型。通过再次调用vkEnumerateDeviceExtensionProperties,我们获取设备的扩展属性。

我们将所需的扩展添加到requiredExtensions向量中。然后,我们使用所需的扩展检查可用的扩展向量。如果找到所需的扩展,我们就从向量中移除它。这意味着设备支持该扩展,并从函数返回值,如下面的代码所示:

图片

运行在我设备上的设备有 73 个可用的扩展,如下面的代码所示。你可以设置一个断点并查看设备扩展属性以查看设备的支持扩展。我们将要查看的第三个函数是querySwapChainSupport函数,它填充了可用的表面功能、表面格式和显示模式:

SwapChainSupportDetailsDevice::querySwapChainSupport
   (VkPhysicalDevicedevice, VkSurfaceKHRsurface) { 

   SwapChainSupportDetails details; 

   vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, 
      &details.surfaceCapabilities); 

   uint32_t formatCount; 
   vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, 
      nullptr); 

   if (formatCount != 0) { 
         details.surfaceFormats.resize(formatCount); 
         vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, 
            &formatCount, details.surfaceFormats.data()); 
   } 

   uint32_t presentModeCount; 
   vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, 
     &presentModeCount, nullptr); 

   if (presentModeCount != 0) { 

         details.presentModes.resize(presentModeCount); 
         vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, 
           &presentModeCount, details.presentModes.data()); 
   } 

   return details; 
} 

要获取表面功能,我们调用vkGetPhysicalDeviceSurfaceCapabilitiesKHR并将设备(即surface)传递给它以获取表面功能。要获取表面格式和显示模式,我们分别调用vkGetPhysicalDeviceSurfaceFormatKHRvkGetPhysicalDeviceSurfacePresentModeKHR两次。

第一次调用vkGetPhysicalDeviceSurfacePresentModeKHR函数时,我们获取现有格式和模式的数量;我们第二次调用它以获取已填充并存储在结构体向量的格式和模式。

这里是我的设备表面的功能:

图片

因此,最小图像计数是两个,这意味着我们可以添加双缓冲。以下是我的设备支持的表面格式和色彩空间:

图片

这里是我的设备支持的显示模式:

图片

因此,我的设备似乎只支持即时模式。我们将在后续章节中看到它的用法。在获取物理设备属性后,我们为queueFamiliyIndices设置 getter 函数,如下所示:

QueueFamilyIndicesDevice::getQueueFamiliesIndicesOfCurrentDevice() { 

   return queueFamiliyIndices; 
} 

现在,我们可以使用createLogicalDevice函数创建逻辑设备。

要创建逻辑设备,我们必须填充VkDeviceCreateInfo结构体,这需要queueCreateInfo结构体。让我们开始吧:

  1. 创建一个向量,以便我们可以存储VkDeviceQueueCreateInfo和图形和呈现队列所需的任何信息。

  2. 创建另一个int类型的向量,以便我们可以存储图形和呈现队列的索引。

  3. 对于每个队列家族,填充VkDeviceQueueCreateInfo。创建一个局部结构体,传入结构体类型、队列家族索引、队列计数和优先级(为1),然后将它推入queueCreateInfos向量,如下所示:

void Device::createLogicalDevice(VkSurfaceKHRsurface, boolisValidationLayersEnabled, AppValidationLayersAndExtensions *appValLayersAndExtentions) { 

   // find queue families like graphics and presentation 
   QueueFamilyIndices indices = findQueueFamilies(physicalDevice, 
          surface); 

   std::vector<VkDeviceQueueCreateInfo> queueCreateInfos; 

   std::set<int> uniqueQueueFamilies = { indices.graphicsFamily, 
                                       indices.presentFamily }; 

   float queuePriority = 1.0f; 

   for (int queueFamily : uniqueQueueFamilies) { 

         VkDeviceQueueCreateInfo queueCreateInfo = {}; 
         queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE
                                 _QUEUE_CREATE_INFO; 
         queueCreateInfo.queueFamilyIndex = queueFamily; 
         queueCreateInfo.queueCount = 1; // we only require 1 queue 
         queueCreateInfo.pQueuePriorities = &queuePriority; 
         queueCreateInfos.push_back(queueCreateInfo); 
   } 
  1. 要创建设备,指定我们将使用的设备功能。对于设备功能,我们将创建一个VkPhysicalDeviceFeatures类型的变量,并将samplerAnisotropy设置为true,如下所示:
 //specify device features  
   VkPhysicalDeviceFeatures deviceFeatures = {};  

   deviceFeatures.samplerAnisotropy = VK_TRUE; 

  1. 创建VkDeviceCreateInfo结构体,这是创建逻辑设备所必需的。将类型设置为VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,然后设置queueCreateInfos、计数和要启用的设备功能。

  2. 设置设备扩展计数和名称。如果启用了验证层,我们设置验证层的计数和名称。通过调用vkCreateDevice并传入物理设备、创建设备信息和null分配器来创建logicalDevice。然后,创建逻辑设备,如下所示。如果失败,则抛出运行时错误:

   VkDeviceCreateInfo createInfo = {}; 
   createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; 
   createInfo.pQueueCreateInfos = queueCreateInfos.data(); 
   createInfo.queueCreateInfoCount = static_cast<uint32_t>
                                     (queueCreateInfos.size()); 

   createInfo.pEnabledFeatures = &deviceFeatures; 
   createInfo.enabledExtensionCount = static_cast<uint32_t>
     (deviceExtensions.size()); 
   createInfo.ppEnabledExtensionNames = deviceExtensions.data(); 

   if (isValidationLayersEnabled) { 
      createInfo.enabledLayerCount = static_cast<uint32_t>(appValLayersAndExtentions->requiredValidationLayers.size()); 
      createInfo.ppEnabledLayerNames = appValLayersAndExtentions->
                               requiredValidationLayers.data(); 
   } 
   else { 
         createInfo.enabledLayerCount = 0; 
   } 

   //create logical device 

   if (vkCreateDevice(physicalDevice, &createInfo, nullptr, 
      &logicalDevice) != VK_SUCCESS) { 
         throw std::runtime_error("failed to create logical 
            device !"); 
   }
  1. 获取设备图形和呈现队列,如下所示。我们现在完成了Device类的操作:
//get handle to the graphics queue of the gpu 
vkGetDeviceQueue(logicalDevice, indices.graphicsFamily, 0, 
&graphicsQueue); 

//get handle to the presentation queue of the gpu 
vkGetDeviceQueue(logicalDevice, indices.presentFamily, 0, &presentQueue); 

}  
  1. 这完成了Device类的封装。在VulkanContext.h文件中包含Device.h文件,并在VulkanContext类的私有部分添加一个新的Device类型设备对象,如下所示:
// My Classes
   AppValidationLayersAndExtensions *valLayersAndExt; 
   VulkanInstance* vInstance; 
   Device* device; 
  1. VulkanContext.cpp文件中的VulkanInit函数中,在创建表面之后添加以下代码:
device = new Device(); 
device->pickPhysicalDevice(vInstance, surface); 
device->createLogicalDevice(surface, isValidationLayersEnabled,
   valLayersAndExt);   
  1. 这将创建device类的新实例,并从可用的物理设备中选择一个设备。然后,你将能够创建逻辑设备。运行应用程序以查看应用程序将在哪个设备上运行。在我的台式机上,找到了以下设备计数和名称:

图片

  1. 在我的笔记本电脑上,应用程序找到了以下设备名称的设备:

图片

  1. findQueueFamiliescheckDeviceExtensionSupportquerySwapChainSupport函数内部设置断点,以检查队列家族设备扩展的数量以及 GPU 对 swapchain 的支持情况。

摘要

我们已经完成了大约四分之一的渲染到视口的过程。在这一章中,我们设置了验证层和我们需要设置的扩展,以便设置 Vulkan 渲染。我们创建了一个 Vulkan 应用程序和实例,然后创建了一个设备类,以便我们可以选择物理设备。我们还创建了一个逻辑设备,以便我们可以与 GPU 交互。

在下一章中,我们将创建 swapchain 本身,以便我们可以在缓冲区之间进行交换,并且我们将创建渲染和深度纹理来绘制场景。我们将创建一个渲染通道来设置渲染纹理的使用方式,然后创建绘制命令缓冲区,这些缓冲区将执行我们的绘制命令。

第十章:准备清除屏幕

在上一章中,我们启用了 Vulkan 验证层和扩展,创建了 Vulkan 应用程序和实例,选择了设备,并创建了逻辑设备。在这一章中,我们将继续探索创建清晰的屏幕图像并将其呈现到视口的过程。

在绘制图像之前,我们首先使用一个颜色值清除和擦除之前的图像。如果我们不这样做,新的图像将覆盖在之前的图像上,这将产生一种迷幻的效果。

每幅图像被清除和渲染后,然后呈现在屏幕上。当当前图像正在显示时,下一幅图像已经在后台被绘制。一旦渲染完成,当前图像将与新图像交换。这种图像交换由 SwapChain 负责处理。

在我们的案例中,我们正在绘制的 SwapChain 中的每一幅图像仅仅存储颜色信息。这个目标图像被渲染,因此被称为渲染目标。我们也可以有其他的目标图像。例如,我们可以有一个深度目标/图像,它将存储每帧每个像素的深度信息。因此,我们也创建了这些渲染目标。

每帧的每个目标图像被设置为附件并用于创建帧缓冲区。由于我们采用双缓冲(意味着我们有两套图像进行交换),我们为每一帧创建一个帧缓冲区。因此,我们将为每一帧创建两个帧缓冲区——一个用于每一帧——并将图像作为附件添加。

我们给 GPU 的命令——例如,绘制命令——通过每个帧使用命令缓冲区发送到 GPU。命令缓冲区存储所有要使用设备图形队列提交给 GPU 的命令。因此,对于每一帧,我们创建一个命令缓冲区来携带我们所有的命令。

一旦命令提交并且场景被渲染,我们不仅可以将绘制的图像呈现在屏幕上,我们还可以将其保存并添加任何后处理效果,例如运动模糊。在渲染过程中,我们可以指定渲染目标的使用方式。尽管在我们的案例中,我们不会添加任何后处理效果,但我们仍然需要创建一个渲染过程。因此,我们创建了一个渲染过程,它将指定我们将使用多少 SwapChain 图像和缓冲区,它们是什么类型的缓冲区,以及它们应该如何使用。

图像将经历的各个阶段如下:

图片

本章涵盖的主题如下:

  • 创建 SwapChain

  • 创建 Renderpass

  • 使用渲染视图和 Framebuffers

  • 创建 CommandBuffer

  • 开始和结束 Renderpass

  • 创建清除屏幕

创建 SwapChain

当场景渲染时,缓冲区会交换并显示到窗口表面。表面是平台相关的,并且根据操作系统,我们必须相应地选择表面格式。为了正确显示场景,我们根据表面格式、显示模式和图片的范围(即窗口可以支持的宽度和高度)创建 SwapChain

在 第十章 的 绘制 Vulkan 对象 中,当我们选择要使用的 GPU 设备时,我们检索了设备的属性,例如表面格式和它支持的显示模式。当我们创建 SwapChain 时,我们将匹配并检查设备提供的表面格式和显示,以及窗口支持的表面格式,以创建 SwapChain 对象本身。

我们创建了一个新的类,名为 SwapChain,并将以下包含添加到 SwapChain.h

#include <vulkan\vulkan.h> 
#include <vector> 
#include <set> 
#include <algorithm>  

然后,我们创建类,如下所示:

classSwapChain { 
public: 
   SwapChain(); 
   ~SwapChain(); 

   VkSwapchainKHR swapChain; 
   VkFormat swapChainImageFormat; 
   VkExtent2D swapChainImageExtent; 

   std::vector<VkImage> swapChainImages; 

   VkSurfaceFormatKHRchooseSwapChainSurfaceFormat(
     const std::vector<VkSurfaceFormatKHR>&availableFormats); 

   VkPresentModeKHRchooseSwapPresentMode(
     const std::vector<VkPresentModeKHR>availablePresentModes); 

   VkExtent2DchooseSwapExtent(constVkSurfaceCapabilitiesKHR&capabilities); 

   void create(VkSurfaceKHRsurface); 

void destroy(); 
}  

在类的公共部分,我们创建构造函数和析构函数。然后,我们创建 VkSwapchainKHRVkFormatVkExtent2D 类型的变量来存储 swapchain 本身。当我们创建表面时,我们存储支持的图片格式以及图片的范围,即视口的宽度和高度。这是因为,当视口拉伸或改变时,swapchain 图片的大小也会相应地改变。

我们创建了一个 VkImage 类型的向量,称为 swapChainImages,用于存储 SwapChain 图片。创建了三个辅助函数,chooseSwapChainSurfaceFormatchooseSwapPresentModechooseSwapExtent,以获取最合适的表面格式、显示模式和 SwapChain 范围。最后,create 函数接收我们将在其中创建 swapchain 本身的表面。我们还添加了一个用于销毁和释放资源回系统的函数。

SwapChain.h 文件的内容就是这些。我们现在将转到 SwapChain.cpp 以包含函数的实现。

SwapChain.cpp 文件中,添加以下包含:

#include"SwapChain.h" 

#include "VulkanContext.h"

我们需要包含 VulkanContext.h 来获取设备的 SwapChainSupportDetails 结构体,这是我们在上一个章节中在选择了物理设备并创建了逻辑设备时填充的。在我们创建 swapchain 之前,让我们先看看三个辅助函数以及每个是如何创建的。

三个函数中的第一个是 chooseSwapChainSurfaceFormat。这个函数接收一个 VkSurfaceFormatKHR 类型的向量,这是设备支持的可用格式。使用这个函数,我们将选择最合适的表面格式。函数的创建方式如下:

VkSurfaceFormatKHRSwapChain::chooseSwapChainSurfaceFormat(const std::vector<VkSurfaceFormatKHR>&availableFormats) { 

   if (availableFormats.size() == 1 &&availableFormats[0].format == 
     VK_FORMAT_UNDEFINED) { 
         return{VK_FORMAT_B8G8R8A8_UNORM, 
           VK_COLOR_SPACE_SRGB_NONLINEAR_KHR }; 
   } 

   for (constauto& availableFormat : availableFormats) { 
         if (availableFormat.format == VK_FORMAT_B8G8R8A8_UNORM&& 
            availableFormat.colorSpace == 
            VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) { 
                return availableFormat; 
         } 
   } 
   returnavailableFormats[0]; 
} 

首先,我们检查可用的格式是否仅为 1,并且是否由设备未定义。这意味着没有首选格式,因此我们选择对我们最方便的一个。

返回的值是颜色格式和颜色空间。颜色格式指定了颜色本身的格式,VK_FORMAT_B8G8R8A8_UNORM,这告诉我们我们在每个像素中存储 32 位信息。颜色存储在蓝色、绿色、红色和 Alpha 通道中,并且按照这个顺序。每个通道存储 8 位,这意味着 2⁸,即 256 种颜色值。"UNORM"表明每个颜色值是归一化的,所以颜色值不是从 0-255,而是归一化在 0 和 1 之间。

我们选择 SRGB 色彩空间作为第二个参数,因为我们希望有更多的颜色范围被表示。如果没有首选格式,我们将遍历可用的格式,然后检查并返回我们需要的格式。我们选择这个色彩空间,因为大多数表面支持这种格式,因为它广泛可用。否则,我们只返回第一个可用的格式。

下一个函数是chooseSwapPresentMode,它接受一个名为availablePresentModesVkPresentModeKHR向量。展示模式指定了最终渲染的图片如何呈现到视口。以下是可用的模式:

  • VK_PRESENT_MODE_IMMEDIATE_KHR:在这种情况下,图片将在有可呈现的图片时立即显示。图片不会被排队等待显示。这会导致图片撕裂。

  • VK_PRESENT_MODE_FIFO_KHR:要呈现的获取的图片被放入一个队列中。队列的大小是交换链大小减一。在垂直同步(vsync)时,第一个要显示的图片以先进先出(FIFO)的方式显示。由于图片是按照它们被添加到队列中的顺序显示的,并且启用了垂直同步,所以没有撕裂。这种模式需要始终支持。

  • VK_PRESENT_MODE_FIFO_RELAXED_KHR:这是FIFO模式的一种变体。在这种模式下,如果渲染速度超过显示器的刷新率,那是可以的,但如果绘图速度慢于显示器,当下一个可用的图片立即呈现时,将会出现屏幕撕裂。

  • VK_PRESENTATION_MODE_MAILBOX_KHR:图片的展示被放入一个队列中,但它只有一个元素,与FIFO不同,FIFO队列中有多个元素。下一个要显示的图片将等待队列被显示,然后展示引擎将显示图片。这不会导致撕裂。

基于这些信息,让我们创建chooseSwapPresentMode函数:

VkPresentModeKHRSwapChain::chooseSwapPresentMode(
  const std::vector<VkPresentModeKHR>availablePresentModes) { 

   VkPresentModeKHR bestMode = VK_PRESENT_MODE_FIFO_KHR; 

   for (constauto& availablePresentMode : availablePresentModes) { 

         if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) { 
               return availablePresentMode; 
         } 
         elseif (availablePresentMode == VK_PRESENT_MODE_IMMEDIATE_KHR) { 
               bestMode = availablePresentMode; 
         } 

         return bestMode; 
   } 
} 

由于FIFO模式是我们最偏好的模式,我们在函数中将它设置好,以便我们可以将其与设备的可用模式进行比较。如果不可用,我们将选择下一个最佳模式,即MAILBOX模式,这样展示队列至少会多一个图片以避免屏幕撕裂。如果两种模式都不可用,我们将选择IMMEDIATE模式,这是最不希望的模式。

第三个函数是chooseSwapExtent函数。在这个函数中,我们获取我们绘制窗口的分辨率来设置 swapchain 图片的分辨率。它被添加如下:


VkExtent2DSwapChain::chooseSwapExtent(constVkSurfaceCapabilitiesKHR&
   capabilities) { 

   if (capabilities.currentExtent.width != std::numeric_limits<uint32_t>::max()) { 
         returncapabilities.currentExtent; 
   } 
   else { 

         VkExtent2D actualExtent = { 1280, 720 }; 

         actualExtent.width = std::max(capabilities.minImageExtent.
                              width, std::min(capabilities.
                              maxImageExtent. width, 
                              actualExtent.width)); 
         actualExtent.height = std::max(capabilities.minImageExtent.
                               height, std::min(capabilities.
                               maxImageExtent.height, 
                               actualExtent.height)); 

         return actualExtent; 
   } 
} 

这个窗口的分辨率应该与 swapchain 图片匹配。一些窗口管理器允许图片和窗口之间的分辨率不同。这可以通过将值设置为uint32_t的最大值来表示。如果不这样做,那么在这种情况下,我们将返回通过硬件能力检索到的当前范围,或者选择与最大和最小值之间的分辨率最匹配的分辨率,与实际设置的分辨率 1,280 x 720 相比。

现在我们来看一下create函数,在这个函数中我们实际上创建SwapChain本身。为了创建这个函数,我们将添加创建SwapChain的功能:

void SwapChain::create(VkSurfaceKHR surface) { 
... 
} 

我们首先做的事情是获取设备支持详情,这是我们创建Device类时为我们的设备检索到的:

SwapChainSupportDetails swapChainSupportDetails = VulkanContext::getInstance()-> getDevice()->swapchainSupport;

然后,使用我们创建的helper函数,我们获取表面格式、展示模式和范围:

   VkSurfaceFormatKHR surfaceFormat = chooseSwapChainSurfaceFormat
     (swapChainSupportDetails.surfaceFormats); 
   VkPresentModeKHR presentMode = chooseSwapPresentMode
     (swapChainSupportDetails.presentModes); 
   VkExtent2D extent = chooseSwapExtent
      (swapChainSupportDetails.surfaceCapabilities); 

然后我们设置 swapchain 所需的图片的最小数量:

uint32_t imageCount = swapChainSupportDetails.
                      surfaceCapabilities.minImageCount; 

我们还应该确保我们不超过可用的最大图片数量,所以如果imageCount超过了最大数量,我们将imageCount设置为最大计数:

   if (swapChainSupportDetails.surfaceCapabilities.maxImageCount > 0 && 
     imageCount > swapChainSupportDetails.surfaceCapabilities.
     maxImageCount) { 
         imageCount = swapChainSupportDetails.surfaceCapabilities.
                      maxImageCount; 
   } 

要创建 swapchain,我们首先必须填充VkSwapchainCreateInfoKHR结构,所以让我们创建它。创建一个名为createInfo的变量并指定结构体的类型:

   VkSwapchainCreateInfoKHR createInfo = {}; 
   createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; 

在这里,我们必须指定要使用的表面、最小图片计数、图片格式、空间和范围。我们还需要指定图片数组层。由于我们不会创建一个像虚拟现实游戏这样的立体应用,其中会有两个表面,一个用于左眼,一个用于右眼,因此我们只需将其值设置为1。我们还需要指定图片将用于什么。在这里,它将用于使用颜色附件显示颜色信息:

   createInfo.surface = surface; 
   createInfo.minImageCount = imageCount; 
   createInfo.imageFormat = surfaceFormat.format; 
   createInfo.imageColorSpace = surfaceFormat.colorSpace; 
   createInfo.imageExtent = extent; 
   createInfo.imageArrayLayers = 1; // this is 1 unless you are making
   a stereoscopic 3D application 
   createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

我们现在指定图形、展示索引和计数。我们还指定了共享模式。展示和图形家族可以是相同的,也可以是不同的。

如果展示和图形家族不同,共享模式被认为是VK_SHARING_MODE_CONCURRENT类型。这意味着图片可以在多个队列家族之间使用。然而,如果图片在同一个队列家族中,共享模式被认为是VK_SHARING_MODE_EXCLUSIVE类型:

   if (indices.graphicsFamily != indices.presentFamily) { 

         createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT; 
         createInfo.queueFamilyIndexCount = 2; 
         createInfo.pQueueFamilyIndices = queueFamilyIndices; 

   } 
   else { 

         createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; 
         createInfo.queueFamilyIndexCount = 0; 
         createInfo.pQueueFamilyIndices = nullptr; 
   } 

如果我们想,我们可以对图片应用预变换,以翻转或镜像它。在这种情况下,我们只保留当前变换。我们还可以将图片与其它窗口系统进行 alpha 混合,但我们只是保持不透明,忽略 alpha 通道,设置显示模式,并设置如果前面有窗口,像素是否应该被裁剪。我们还可以指定一个旧的SwapChain,如果当前SwapChain在调整窗口大小时变得无效。由于我们不调整窗口大小,所以我们不需要指定旧的 swapchain。

在设置信息结构后,我们可以创建 swapchain 本身:

if (vkCreateSwapchainKHR(VulkanContext::getInstance()->getDevice()->
   logicalDevice, &createInfo, nullptr, &swapChain) != VK_SUCCESS) { 
         throw std::runtime_error("failed to create swap chain !"); 
   } 

我们使用vkCreateSwapchainKHR函数创建 swapchain,该函数接受逻辑设备、createInfo结构、分配器回调和 swapchain 本身。如果由于错误而没有创建SwapChain,我们将发送错误。现在 swapchain 已创建,我们将获取 swapchain 图片。

根据图片数量,我们调用vkGetSwapchainImagesKHR函数,该函数用于首先获取图片数量,然后再次调用该函数以将图片填充到vkImage向量中:

   vkGetSwapchainImagesKHR(VulkanContext::getInstance()->getDevice()->
      logicalDevice, swapChain, &imageCount, nullptr); 
   swapChainImages.resize(imageCount); 
   vkGetSwapchainImagesKHR(VulkanContext::getInstance()->getDevice()->
      logicalDevice, swapChain, &imageCount, swapChainImages.data()); 

图片的创建稍微复杂一些,但 Vulkan 会自动创建彩色图片。我们可以设置图片格式和范围:

   swapChainImageFormat = surfaceFormat.format; 
   swapChainImageExtent= extent; 

然后,我们添加一个destroy函数,通过调用vkDestroySwapchainKHR函数来销毁SwapChain

void SwapChain::destroy(){ 

   // Swapchain 
   vkDestroySwapchainKHR(VulkanContext::getInstance()-> getDevice()->
      logicalDevice, swapChain, nullptr); 

} 

VulkanApplication.h文件中,包含SwapChain头文件并在VulkanApplication类中创建一个新的SwapChain实例。在VulkanApplication.cpp文件中,在initVulkan函数中,在创建逻辑设备之后,创建SwapChain如下:

   swapChain = new SwapChain(); 
   swapChain->create(surface);

构建并运行应用程序以确保SwapChain创建没有错误。

创建 Renderpass

在创建SwapChain之后,我们继续到Renderpass。在这里,我们指定有多少个颜色附件和深度附件,以及每个附件在帧缓冲区中使用的样本数量。

如本章开头所述,帧缓冲区是一组目标附件。附件可以是颜色、深度等类型。颜色附件存储要呈现给视口的颜色信息。还有其他用户看不到但内部使用的附件。这包括深度,例如,它包含每个像素的所有深度信息。在渲染遍历中,除了附件类型外,我们还指定了如何使用附件。

对于这本书,我们将展示场景中渲染到视口的内容,因此我们只需使用单个遍历。如果我们添加后处理效果,我们将对渲染的图片应用此效果,为此我们需要使用多个遍历。我们将创建一个新的类Renderpass,在其中我们将创建渲染遍历。

Renderpass.h文件中,添加以下包含和类:

#include <vulkan\vulkan.h> 
#include <array> 

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

   VkRenderPass renderPass; 

   void createRenderPass(VkFormat swapChainImageFormat); 

   void destroy(); 
};

在类中添加构造函数、析构函数以及VkRenderPassrenderPass变量。添加一个名为createRenderPass的新函数,用于创建Renderpass本身,它接受图片格式。还要添加一个函数,用于在用完后销毁Renderpass对象。

Renderpass.cpp文件中,添加以下包含项,以及构造函数和析构函数:

#include"Renderpass.h" 
#include "VulkanContext.h"
Renderpass::Renderpass(){} 

Renderpass::~Renderpass(){} 

现在我们添加createRenderPass函数,在其中我们将添加创建当前要渲染场景的Renderpass的功能:

voidRenderpass::createRenderPass(VkFormatswapChainImageFormat) { 
... 
} 

当我们创建渲染管线时,我们必须指定我们使用的附加项的数量和类型。因此,对于我们的项目,我们只想有颜色附加项,因为我们只会绘制颜色信息。我们也可以有一个深度附加项,它存储深度信息。我们需要提供子管线,如果有的话,那么有多少,因为我们可能使用子管线为当前帧添加后处理效果。

对于附加项和子管线,我们必须在创建渲染管线时填充结构并传递给它们。

因此,让我们填充结构。首先,我们创建附加项:

   VkAttachmentDescription colorAttachment = {}; 
   colorAttachment.format = swapChainImageFormat; 
   colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT; 
   colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; 
   colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;  
   colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; 
   colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; 

   colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; 
   colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;  

我们创建结构并指定要使用的格式,该格式与swapChainImage格式相同。我们必须提供样本数量为 1,因为我们不会使用多采样。在loadOpstoreOp中,我们指定在渲染前后对数据进行什么操作。我们指定在加载附加项时,我们将数据清除到常量值。在渲染过程之后,我们存储数据,以便我们稍后可以从中读取。然后我们决定在模板操作前后对数据进行什么操作。

由于我们不使用模板缓冲区,我们在加载和存储时指定“不关心”。我们还需要在处理图片前后指定数据布局。图片的先前布局不重要,但渲染后,图片需要改变到布局,以便它准备好进行展示。

现在我们将遍历subpass。每个subpass都引用需要指定为单独结构的附加项:

   VkAttachmentReference colorAttachRef = {}; 
   colorAttachRef.attachment = 0;  
   colorAttachRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;  

subpass引用中,我们指定了附加索引,即 0^(th)索引,并指定了布局,这是一个具有最佳性能的颜色附加。接下来,我们创建subpass结构:

   VkSubpassDescription subpass = {}; 
   subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; 
   subpass.colorAttachmentCount = 1;  
   subpass.pColorAttachments = &colorAttachRef; 

在管线绑定点中,我们指定这是一个图形子管线,因为它可能是一个计算子管线。指定附加数量为1并提供颜色附加。现在,我们可以创建渲染管线信息结构:

   std::array<VkAttachmentDescription, 1> attachments = 
      { colorAttachment }; 

   VkRenderPassCreateInfo rpCreateInfo = {}; 
   rpCreateInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; 
   rpCreateInfo.attachmentCount = static_cast<uint32_t>
                                  (attachments.size()); 
   rpCreateInfo.pAttachments = attachments.data(); 
   rpCreateInfo.subpassCount = 1; 
   rpCreateInfo.pSubpasses = &subpass; 

我们创建一个VkAttachmentDescription类型的单元素数组,然后创建信息结构并传入类型。附加项数量和附加项被传入,然后子管线数量和子管线也被传入。通过调用vkCreateRenderPass并传入逻辑设备、创建信息和分配器回调来创建渲染管线:


 if (vkCreateRenderPass(VulkanContext::getInstance()-> 
   getDevice()->logicalDevice, &rpCreateInfo, nullptr, &renderPass)
   != VK_SUCCESS) { 
         throw std::runtime_error(" failed to create renderpass !!"); 
   }

最后,在 destroy 函数中,我们在完成之后调用 vkDestroyRenderPass 来销毁它:

voidRenderpass::destroy(){ 

   vkDestroyRenderPass(VulkanContext::getInstance()-> getDevice()->
      logicalDevice, renderPass, nullptr); 

} 

VulkanApplication.h 中,包含 RenderPass.h 并创建一个渲染通道对象。在 VulkanApplication.cpp 中,在创建交换链之后,创建渲染通道:

   renderPass = new Renderpass(); 
   renderPass->createRenderPass(swapChain->swapChainImageFormat); 

现在,构建并运行项目以确保没有错误。

使用渲染目标和帧缓冲区

要使用图片,我们必须创建一个 ImageView。图片没有任何信息,例如 mipmap 级别,并且您无法访问图片的一部分。然而,通过使用图片视图,我们现在指定了纹理的类型以及它是否有 mipmap。此外,在渲染通道中,我们指定了每个帧缓冲区的附件。我们将在这里创建帧缓冲区并将图片视图作为附件传递。

创建一个名为 RenderTexture 的新类。在 RenderTexture.h 文件中,添加以下头文件然后创建该类:

 #include <vulkan/vulkan.h> 
#include<array> 

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

   std::vector<VkImage> _swapChainImages; 
   VkExtent2D _swapChainImageExtent; 

   std::vector<VkImageView> swapChainImageViews; 
   std::vector<VkFramebuffer> swapChainFramebuffers; 

   void createViewsAndFramebuffer(std::vector<VkImage> swapChainImages,  
     VkFormat swapChainImageFormat, VkExtent2D swapChainImageExtent, 
     VkRenderPass renderPass); 

   void createImageViews(VkFormat swapChainImageFormat); 
   void createFrameBuffer(VkExtent2D swapChainImageExtent, 
      VkRenderPass renderPass); 

   void destroy(); 

}; 

在类中,我们像往常一样添加构造函数和析构函数。我们将存储 swapChainImages 和要本地使用的范围。我们创建两个向量来存储创建的 ImageViews 和帧缓冲区。为了创建视图和帧缓冲区,我们将调用 createViewsAndFramebuffers 函数,该函数接受图片、图片格式、范围和渲染通道作为输入。该函数将内部调用 createImageViewsCreateFramebuffer 来创建视图和缓冲区。我们将添加 destroy 函数,该函数销毁并释放资源回系统。

RenderTexture.cpp 文件中,我们还将添加以下包含以及构造函数和析构函数:

#include "RenderTexture.h" 
#include "VulkanContext.h" 
RenderTexture::RenderTexture(){} 

RenderTexture::~RenderTexture(){} 

然后,添加 createViewAndFramebuffer 函数:

void RenderTexture::createViewsAndFramebuffer(std::vector<VkImage> swapChainImages, VkFormat swapChainImageFormat,  
VkExtent2D swapChainImageExtent,  
VkRenderPass renderPass){ 

   _swapChainImages =  swapChainImages; 
   _swapChainImageExtent = swapChainImageExtent; 

   createImageViews(swapChainImageFormat); 
   createFrameBuffer(swapChainImageExtent, renderPass); 
}

我们首先将图像和 imageExtent 分配给局部变量。然后,我们调用 imageViews 函数,接着调用 createFramebuffer,以便创建它们。要创建图像视图,使用 createImageViews 函数:

void RenderTexture::createImageViews(VkFormat swapChainImageFormat){ 

   swapChainImageViews.resize(_swapChainImages.size()); 

   for (size_t i = 0; i < _swapChainImages.size(); i++) { 

         swapChainImageViews[i] = vkTools::createImageView
                                  (_swapChainImages[i], 
               swapChainImageFormat,  
               VK_IMAGE_ASPECT_COLOR_BIT); 
   } 
} 

我们首先根据交换链图像的数量指定向量的大小。对于每个图像数量,我们使用 vkTool 命名空间中的 createImageView 函数创建图像视图。createImageView 函数接受图像本身、图像格式和 ImageAspectFlag。这将根据您想要为图像创建的视图类型是 VK_IMAGE_ASPECT_COLOR_BITVK_IMAGE_ASPECT_DEPTH_BITcreateImageView 函数在 Tools.h 文件下的 vkTools 命名空间中创建。Tools.h 文件如下:

#include <vulkan\vulkan.h> 
#include <stdexcept> 
#include <vector> 

namespace vkTools { 

   VkImageView createImageView(VkImage image, VkFormat format, 
       VkImageAspectFlags aspectFlags); 

}

函数的实现创建在 Tools.cpp 文件中,如下所示:

#include "Tools.h" 
#include "VulkanContext.h"

namespace vkTools { 
   VkImageView createImageView(VkImage image, VkFormat format, VkImageAspectFlags aspectFlags) { 

         VkImageViewCreateInfo viewInfo = {}; 
         viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; 
         viewInfo.image = image; 
         viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; 
         viewInfo.format = format; 

         viewInfo.subresourceRange.aspectMask = aspectFlags; 
         viewInfo.subresourceRange.baseMipLevel = 0; 
         viewInfo.subresourceRange.levelCount = 1; 
         viewInfo.subresourceRange.baseArrayLayer = 0; 
         viewInfo.subresourceRange.layerCount = 1; 

         VkImageView imageView; 
         if (vkCreateImageView(VulkanContext::getInstance()->
            getDevice()->logicalDevice, &viewInfo, nullptr, &imageView) 
            != VK_SUCCESS) { 
               throw std::runtime_error("failed to create 
                 texture image view !"); 
         } 

         return imageView; 
   } 

} 

要创建 imageView,我们必须填充 VkImageViewCreateInfo 结构体,然后使用 vkCreateImageView 函数创建视图本身。为了填充视图信息,我们指定结构体类型、图片本身、视图类型,即 VK_IMAGE_VIEW_TYPE_2D、一个 2D 纹理,然后指定格式。我们传递 aspectFlags 用于方面掩码。我们创建的图像视图没有任何 mipmap 级别或层,因此我们将它们设置为 0。如果我们正在制作类似 VR 游戏的东西,我们才需要多个层。

然后,我们创建一个 VkImage 类型的 imageView 并使用 vkCreateImageView 函数创建它,该函数接受逻辑设备、视图信息结构体,然后创建并返回图片视图。这就是 Tools 文件的所有内容。

当我们想要可重用的函数时,我们将使用 Tools 文件并为其添加更多函数。现在,让我们回到 RenderTexture.cpp 文件并添加创建帧缓冲区的函数。

我们将为交换链中的每一帧创建帧缓冲区。createFramebuffer 函数需要图片范围和渲染通道本身:

void RenderTexture::createFrameBuffer(VkExtent2D swapChainImageExtent, VkRenderPass renderPass){ 

   swapChainFramebuffers.resize(swapChainImageViews.size()); 

   for (size_t i = 0; i < swapChainImageViews.size(); i++) { 

         std::array<VkImageView, 2> attachments = { 
               swapChainImageViews[i] 
         }; 

         VkFramebufferCreateInfo fbInfo = {}; 
         fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; 
         fbInfo.renderPass = renderPass;  
         fbInfo.attachmentCount = static_cast<uint32_t>
                                  (attachments.size());; 
         fbInfo.pAttachments = attachments.data();; 
         fbInfo.width = swapChainImageExtent.width; 
         fbInfo.height = swapChainImageExtent.height; 
         fbInfo.layers = 1;  

         if (vkCreateFramebuffer(VulkanContext::getInstance()->
            getDevice()->logicalDevice, &fbInfo, NULL, 
            &swapChainFramebuffers[i]) != VK_SUCCESS) { 

               throw std::runtime_error(" failed to create 
                  framebuffers !!!"); 
         } 
   } 
} 

对于我们创建的每一帧,帧缓冲区首先填充 framebufferInfo 结构体,然后调用 vkCreateFramebuffer 创建帧缓冲区本身。对于每一帧,我们创建一个新的信息结构体并指定结构体类型。然后我们传递渲染通道、附件数量和附件视图,指定帧缓冲区的宽度和高度,并将层设置为 1

最后,我们通过调用 vkCreateFramebuffer 函数创建帧缓冲区:

void RenderTexture::destroy(){ 

   // image views 
   for (auto imageView : swapChainImageViews) { 

         vkDestroyImageView(VulkanContext::getInstance()->getDevice()->
            logicalDevice, imageView, nullptr); 
   } 

   // Framebuffers 
   for (auto framebuffer : swapChainFramebuffers) { 
         vkDestroyFramebuffer(VulkanContext::getInstance()->
            getDevice()->logicalDevice, framebuffer, nullptr); 
   } 

} 

destroy 函数中,我们通过调用 vkDestroyImageViewvkDestroyFramebuffer 销毁我们创建的每个图像视图和帧缓冲区。这就是 RenderTexture 类的所有内容。

VulkanApplication.h 中,包含 RenderTexture.h 并在 VulkanApplication 类中创建一个名为 renderTexture 的其实例。在 VulkanApplication.cpp 文件中,包含 initVulkan 函数并创建一个新的 RenderTexture

   renderTexture = new RenderTexture(); 
   renderTexture->createViewsAndFramebuffer(swapChain->swapChainImages, 
         swapChain->swapChainImageFormat, 
         swapChain->swapChainImageExtent, 
         renderPass->renderPass); 

创建命令缓冲区

在 Vulkan 中,GPU 上执行的所有绘图和其他操作都使用命令缓冲区完成。命令缓冲区包含绘图命令,这些命令被记录并执行。绘图命令需要在每一帧中记录和执行。要创建命令缓冲区,我们必须首先创建命令池,然后从命令池中分配命令缓冲区,然后按帧记录命令。

让我们创建一个新的类来创建命令缓冲池,并分配命令缓冲区。我们还要创建一个用于开始和停止录制以及销毁命令缓冲区的函数。创建一个新的类,命名为 DrawCommandBuffer,以及 DrawCommandBuffer.h 如下所示:

#include <vulkan\vulkan.h> 
#include <vector> 

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

   VkCommandPool commandPool; 
   std::vector<VkCommandBuffer> commandBuffers; 

   void createCommandPoolAndBuffer(size_t imageCount); 
   void beginCommandBuffer(VkCommandBuffer commandBuffer); 
   void endCommandBuffer(VkCommandBuffer commandBuffer); 

   void createCommandPool(); 
   void allocateCommandBuffers(size_t imageCount); 

   void destroy(); 
}; 

在类中,我们创建构造函数和析构函数。我们创建变量来存储命令池和一个向量来存储VkCommandBuffer。我们最初创建一个函数来创建命令池和分配命令缓冲区。接下来的两个函数beginCommandBufferendCommandBuffer将在我们想要开始和停止记录命令缓冲区时被调用。createCommandPoolallocateCommandBuffers函数将由createCommandPoolAndBuffer调用。

当我们想要将资源释放给系统时,我们将创建destroy函数来销毁命令缓冲区。在CommandBuffer.cpp中,添加必要的包含文件和构造函数及析构函数:

#include "DrawCommandBuffer.h" 
#include "VulkanContext.h"

DrawCommandBuffer::DrawCommandBuffer(){} 

DrawCommandBuffer::~DrawCommandBuffer(){}   

然后,我们添加createCommandPoolAndBuffer,它接受图片数量:

void DrawCommandBuffer::createCommandPoolAndBuffer(size_t imageCount){ 

   createCommandPool(); 
   allocateCommandBuffers(imageCount); 
}

createCommandPoolAndBuffer函数将调用createCommandPoolallocateCommandBuffers函数。首先,我们创建createCommandPool函数。命令必须发送到特定的队列。当我们创建命令池时,我们必须指定队列:

void DrawCommandBuffer::createCommandPool() { 

   QueueFamilyIndices qFamilyIndices = VulkanContext::
                                       getInstance()->getDevice()-> 
                                       getQueueFamiliesIndicesOfCurrentDevice(); 

   VkCommandPoolCreateInfo cpInfo = {}; 

   cpInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; 
   cpInfo.queueFamilyIndex = qFamilyIndices.graphicsFamily; 
   cpInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; 

   if (vkCreateCommandPool(VulkanContext::getInstance()->
       getDevice()->logicalDevice, &cpInfo, nullptr, &commandPool) 
       != VK_SUCCESS) { 
          throw std::runtime_error(" failed to create command pool !!"); 
   } 

} 

首先,我们获取当前设备的队列家族索引。为了创建命令池,我们必须填充VkCommandPoolCreateInfo结构体。像往常一样,我们指定类型。然后,我们设置池必须创建的队列家族索引。之后,我们设置VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志,这将每次重置命令缓冲区的值。然后,我们通过传递逻辑设备和信息结构体来使用vkCreateCommandPool函数获取命令池。接下来,我们创建allocateCommandBuffers函数:

void DrawCommandBuffer::allocateCommandBuffers(size_t imageCount) { 

   commandBuffers.resize(imageCount); 

   VkCommandBufferAllocateInfo cbInfo = {}; 
   cbInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; 
   cbInfo.commandPool = commandPool; 
   cbInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; 
   cbInfo.commandBufferCount = (uint32_t)commandBuffers.size(); 

   if (vkAllocateCommandBuffers(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &cbInfo, commandBuffers.data()) 
      != VK_SUCCESS) { 

         throw std::runtime_error(" failed to allocate 
            command buffers !!"); 
   } 

} 

我们调整commandBuffers向量的大小。然后,为了分配命令缓冲区,我们必须填充VkCommandBufferAllocateInfo。我们首先设置结构体的类型和命令池。然后,我们必须指定命令缓冲区的级别。你可以有一个命令缓冲区的链,其中主命令缓冲区包含次级命令缓冲区。对于我们的用途,我们将命令缓冲区设置为主要的。然后,我们设置commandBufferCount,它等于交换链的图片数量。

然后,我们使用vkAllocateCommandBuffers函数分配命令缓冲区。我们传递逻辑设备、信息结构体和要分配内存的命令缓冲区。

然后,我们添加beginCommandBuffer。这个函数接受当前命令缓冲区以开始在其中记录命令:


void DrawCommandBuffer::beginCommandBuffer(VkCommandBuffer commandBuffer){ 

   VkCommandBufferBeginInfo cbBeginInfo = {}; 

   cbBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; 

   cbBeginInfo.flags = VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT; 

   if (vkBeginCommandBuffer(commandBuffer, &cbBeginInfo) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to begin command buffer !!"); 
   } 

}

为了记录命令缓冲区,我们还需要填充VkCommandBufferBeginInfoStruct。再次指定结构体类型和VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT标志。这使我们能够在上一帧仍在使用时调度下一帧的命令缓冲区。通过传递当前命令缓冲区来调用vkBeginCommandBuffer以开始记录命令。

接下来,我们添加endCommandBuffer函数。这个函数只是调用vkEndCommandBuffer来停止向命令缓冲区记录:

void DrawCommandBuffer::endCommandBuffer(VkCommandBuffer commandBuffer){ 

   if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to record command buffer"); 
   } 

} 

我们可以使用Destroy函数销毁命令缓冲区和池。在这里,我们只销毁池,这将销毁命令缓冲区:

void DrawCommandBuffer::destroy(){ 

   vkDestroyCommandPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, commandPool, nullptr); 

} 

VulkanApplication.h文件中,包含DrawCommandBuffer.h并创建此类的对象。在VulkanApplication.cpp文件中,在VulkanInit函数中,在创建renderViewsAndFrameBuffers之后,创建DrawCommandBuffer

   renderTexture = new RenderTexture(); 
   renderTexture->createViewsAndFramebuffer(swapChain->swapChainImages, 
         swapChain->swapChainImageFormat, 
         swapChain->swapChainImageExtent, 
         renderPass->renderPass); 

   drawComBuffer = new DrawCommandBuffer(); 
   drawComBuffer->createCommandPoolAndBuffer(swapChain->
     swapChainImages.size());

开始和结束Renderpass

除了在每一帧中记录的命令外,每一帧的 renderpass 也会被处理,其中颜色和深度信息被重置。因此,由于我们每一帧中只有颜色附加层,我们必须为每一帧清除颜色信息。回到Renderpass.h文件,并在类中添加两个新函数,分别称为beginRenderPassendRenderPass,如下所示:

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

   VkRenderPass renderPass; 

   void createRenderPass(VkFormat swapChainImageFormat); 

   void beginRenderPass(std::array<VkClearValue, 1> 
      clearValues, VkCommandBuffer commandBuffer, VkFramebuffer 
      swapChainFrameBuffer, VkExtent2D swapChainImageExtent); 

   void endRenderPass(VkCommandBuffer commandBuffer); 

   void destroy(); 
}; 

RenderPass.cpp中,添加beginRenderPass函数的实现:

void Renderpass::beginRenderPass(std::array<VkClearValue, 1> clearValues, 
    VkCommandBuffer commandBuffer, VkFramebuffer swapChainFrameBuffer, 
    VkExtent2D swapChainImageExtent) { 

   VkRenderPassBeginInfo rpBeginInfo = {}; 
   rpBeginInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; 
   rpBeginInfo.renderPass = renderPass; 
   rpBeginInfo.framebuffer = swapChainFrameBuffer; 
   rpBeginInfo.renderArea.offset = { 0,0 }; 
   rpBeginInfo.renderArea.extent = swapChainImageExtent; 

   rpBeginInfo.pClearValues = clearValues.data(); 
   rpBeginInfo.clearValueCount = static_cast<uint32_t>(clearValues.size()); 

   vkCmdBeginRenderPass(commandBuffer,&rpBeginInfo,
      VK_SUBPASS_CONTENTS_INLINE); 
} 

我们接下来填充VkRenderPassBeginInfo结构体。在这里,我们指定结构体类型,传入 renderpass 和当前 framebuffer,设置渲染区域为整个 viewport,并传入清除值和计数。清除值是我们想要清除屏幕的颜色值,计数将是1,因为我们只想清除颜色附加层。

要开始Renderpass,我们传入当前命令缓冲区、信息结构体,并将第三个参数指定为VK_SUBPASS_CONTENTS_INLINE,指定 renderpass 命令绑定到主命令缓冲区。

endCommandBuffer函数中,我们完成当前帧的Renderpass

void Renderpass::endRenderPass(VkCommandBuffer commandBuffer){ 

   vkCmdEndRenderPass(commandBuffer); 
} 

要结束Renderpass,调用vkCmdEndRenderPass函数并传入当前命令缓冲区。

我们已经有了所需的类来开始清除屏幕。现在,让我们转到 Vulkan 应用程序类,并添加一些代码行以使其工作。

创建清除屏幕

VulkanApplication.h文件中,我们将添加三个新函数,分别称为drawBegindrawEndcleanupdrawBegin将在传递任何绘图命令之前被调用,drawEnd将在绘图完成后、帧准备好呈现到 viewport 时被调用。在cleanup函数中,我们将销毁所有资源。

我们还将创建两个变量。第一个是uint32_t,用于从 swapchain 获取当前图片,第二个是currentCommandBuffer,类型为VkCommandBuffer,用于获取当前命令缓冲区:

public: 

   static VulkanApplication* getInstance(); 
   static VulkanApplication* instance; 

   ~VulkanApplication(); 

   void initVulkan(GLFWwindow* window); 

   void drawBegin(); 
   void drawEnd(); 
void cleanup(); 

private: 

   uint32_t imageIndex = 0; 
   VkCommandBuffer currentCommandBuffer; 

   //surface 
   VkSurfaceKHR surface; 

VulkanApplication.cpp文件中,我们添加drawBegindrawEnd函数的实现:

void VulkanApplication::drawBegin(){ 

   vkAcquireNextImageKHR(VulkanContext::getInstance()->
      getDevice()->logicalDevice, 
         swapChain->swapChain, 
         std::numeric_limits<uint64_t>::max(), 
         NULL,  
         VK_NULL_HANDLE, 
         &imageIndex); 

   currentCommandBuffer = drawComBuffer->commandBuffers[imageIndex]; 

   // Begin command buffer recording 
   drawComBuffer->beginCommandBuffer(currentCommandBuffer); 

   // Begin renderpass 
   VkClearValue clearcolor = { 1.0f, 0.0f, 1.0f, 1.0f }; 

   std::array<VkClearValue, 1> clearValues = { clearcolor }; 

   renderPass->beginRenderPass(clearValues, 
         currentCommandBuffer, 
         renderTexture->swapChainFramebuffers[imageIndex], 
         renderTexture->_swapChainImageExtent); 

} 

首先,我们从交换链中获取下一张图片。这是通过使用 Vulkan 的vkAcquireNextImageKHR API 调用来完成的。为此,我们传递逻辑设备和交换链实例。接下来,我们需要传递超时时间,因为我们不关心时间限制,所以我们传递最大数值。接下来的两个变量保持为 null。这些需要信号量和栅栏,我们将在后面的章节中讨论。最后,我们传递imageIndex本身。

然后,我们从命令缓冲区向量中获取当前命令缓冲区。我们通过调用beginCommandBuffer开始记录命令缓冲区,命令将被存储在currentCommandBuffer对象中。我们现在开始渲染过程。在这个过程中,我们传递清除颜色值,这是紫色,因为为什么不呢?!传递当前commandbuffer、帧缓冲区和图片范围。

我们现在可以实现drawEnd函数:

void VulkanApplication::drawEnd(){ 

   // End render pass commands 
   renderPass->endRenderPass(currentCommandBuffer); 

   // End command buffer recording 
   drawComBuffer->endCommandBuffer(currentCommandBuffer); 

   // submit command buffer 
   VkSubmitInfo submitInfo = {}; 
   submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; 
   submitInfo.commandBufferCount = 1; 
   submitInfo.pCommandBuffers = &currentCommandBuffer; 

   vkQueueSubmit(VulkanContext::getInstance()->getDevice()->
      graphicsQueue, 1, &submitInfo, NULL); 

   // Present frame 
   VkPresentInfoKHR presentInfo = {}; 
   presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; 
   presentInfo.swapchainCount = 1; 
   presentInfo.pSwapchains = &swapChain->swapChain; 
   presentInfo.pImageIndices = &imageIndex; 

   vkQueuePresentKHR(VulkanContext::getInstance()->
       getDevice()->presentQueue, &presentInfo); 

   vkQueueWaitIdle(VulkanContext::getInstance()->
       getDevice()->presentQueue); 

} 

我们结束渲染过程并停止向命令缓冲区记录。然后,我们必须提交命令缓冲区和呈现帧。要提交命令缓冲区,我们创建一个VkSubmitInfo结构体,并用结构体类型、每帧的缓冲区计数(每帧 1 个)和命令缓冲区本身填充它。通过调用vkQueueSubmit并将图形队列、提交计数和提交信息传递进去,命令被提交到图形队列。

一旦渲染了帧,它就会通过呈现队列呈现到视口。

要在绘制完成后呈现场景,我们必须创建并填充VkPresentInfoKHR结构体。对于呈现,图片被发送回交换链。当我们创建信息和设置结构体的类型时,我们还需要设置交换链、图像索引和交换链计数,该计数为 1。

然后,我们使用vkQueuePresentKHR通过传递呈现队列和呈现信息到函数中来呈现图片。最后,我们使用vkQueueWaitIdle函数等待主机完成给定队列的呈现操作,该函数接受呈现队列。此外,当你完成使用资源时,最好清理资源,因此添加cleanup函数:

void VulkanApplication::cleanup() { 

   vkDeviceWaitIdle(VulkanContext::getInstance()->
     getDevice()->logicalDevice); 

   drawComBuffer->destroy(); 
   renderTexture->destroy(); 
   renderPass->destroy(); 
   swapChain->destroy(); 

   VulkanContext::getInstance()->getDevice()->destroy(); 

   valLayersAndExt->destroy(vInstance->vkInstance, 
      isValidationLayersEnabled); 

   vkDestroySurfaceKHR(vInstance->vkInstance, surface, nullptr);   
   vkDestroyInstance(vInstance->vkInstance, nullptr); 

} 
 delete drawComBuffer;
 delete renderTarget;
 delete renderPass;
 delete swapChain;
 delete device;

 delete valLayersAndExt;
 delete vInstance;

 if (instance) {
  delete instance;
  instance = nullptr;
 }

当我们销毁对象时,我们必须调用vkDeviceWaitIdle来停止使用设备。然后,我们以相反的顺序销毁对象。因此,我们首先销毁命令缓冲区,然后是渲染纹理资源,然后是渲染过程,然后是交换链。然后我们销毁设备、验证层、表面,最后是 Vulkan 实例。最后,我们删除为DrawCommandBufferRenderTargetRenderpassSwapchainDeviceValidationLayersAndExtensionsVulkanInstance创建的类实例。

最后,我们删除VulkanContext的实例,并在删除后将其设置为nullptr

source.cpp文件中的while循环中,调用drawBegindrawEnd函数。然后在循环后调用cleanup函数:

#define GLFW_INCLUDE_VULKAN 
#include<GLFW/glfw3.h> 

#include "VulkanApplication.h" 

int main() { 

   glfwInit(); 

   glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); 
   glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); 

   GLFWwindow* window = glfwCreateWindow(1280, 720, 
                        "HELLO VULKAN ", nullptr, nullptr); 

   VulkanApplication::getInstance()->initVulkan(window); 

   while (!glfwWindowShouldClose(window)) { 

         VulkanApplication::getInstance()->drawBegin(); 

         // draw command  

         VulkanApplication::getInstance()->drawEnd(); 

         glfwPollEvents(); 
   }               

   VulkanApplication::getInstance()->cleanup(); 

   glfwDestroyWindow(window); 
   glfwTerminate(); 

   return 0; 
} 

当你构建并运行命令时,你会看到一个紫色的视口,如下所示:

图片

屏幕看起来没问题,但如果你查看控制台,你会看到以下错误,它说当我们调用vkAcquireNextImageKHR时,信号量和栅栏都不能为NULL

图片

摘要

在本章中,我们探讨了交换链、渲染通道、渲染视图、帧缓冲区和命令缓冲区的创建。我们还探讨了每个的作用以及为什么它们对于渲染清晰的屏幕很重要。

在下一章中,我们将创建资源,使我们能够将几何图形渲染到视口中。一旦我们准备好了对象资源,我们将渲染这些对象。然后我们将探讨信号量和栅栏以及为什么它们是必需的。

第十一章:创建对象资源

在上一章中,我们使清除屏幕功能正常工作并创建了 Vulkan 实例。我们还创建了逻辑设备、交换链、渲染目标和视图,以及绘制命令缓冲区,以记录和提交命令到 GPU。使用它,我们能够得到一个紫色的清除屏幕。我们还没有绘制任何几何形状,但现在我们已准备好这样做。

在本章中,我们将准备好渲染几何形状所需的大部分内容。我们必须创建顶点、索引和统一缓冲区。顶点、索引和统一缓冲区将包含有关顶点属性的信息,例如位置、颜色、法线和纹理坐标;索引信息将包含我们想要绘制的顶点的索引,统一缓冲区将包含如新的视图投影矩阵等信息。

我们需要创建一个描述符集和布局,这将指定统一缓冲区绑定到哪个着色器阶段。

我们还必须生成用于绘制几何形状的着色器。

为了创建对象缓冲区和描述符集以及布局,我们将创建新的类,以便它们被分离开来,我们可以理解它们是如何相关的。在我们跳到对象缓冲区类之前,我们将添加在 OpenGL 项目中创建的 Mesh 类,并且我们将使用相同的类并对它进行一些小的修改。Mesh 类包含有关我们想要绘制的不同几何形状的顶点和索引信息。

本章我们将涵盖以下主题:

  • 更新 Mesh 类以支持 Vulkan

  • 创建 ObjectBuffers

  • 创建 Descriptor

  • 创建 SPIR-V 着色器二进制文件

更新 Mesh 类以支持 Vulkan

Mesh.h 文件中,我们只需添加几行代码来指定 InputBindingDescriptionInputAttributeDescription。在 InputBindingDesciption 中,我们指定绑定位置、数据本身的步长以及输入速率,它指定数据是按顶点还是按实例。在 OpenGL 项目的 Mesh.h 文件中,我们只需向 Vertex 结构体添加函数:

 struct Vertex { 

   glm::vec3 pos; 
   glm::vec3 normal; 
   glm::vec3 color; 
glm::vec2 texCoords; 

}; 

因此,在Vertex结构体中,添加一个用于检索AttributeDescription的函数:

   static VkVertexInputBindingDescription getBindingDescription() { 

         VkVertexInputBindingDescription bindingDescription = {}; 

         bindingDescription.binding = 0;  
         bindingDescription.stride = sizeof(Vertex); 
         bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; 

         return bindingDescription; 
} 

在函数 VertexInputBindingDescriptor 中,指定绑定位于第 0 个索引,步长等于 Vertex 结构体本身的大小,输入速率是 VK_VERTEX_INPUT_RATE_VERTEX,即按顶点。该函数仅返回创建的绑定描述。

由于我们在顶点结构体中有四个属性,我们必须为每个属性创建一个属性描述符。将以下函数添加到 Vertex 结构体中,该函数返回一个包含四个输入属性描述符的数组。对于每个属性描述符,我们必须指定绑定位置,即绑定描述中指定的 0,每个属性的布局位置,数据类型的格式,以及从 Vertex 结构体开始的偏移量:

static std::array<VkVertexInputAttributeDescription, 4> getAttributeDescriptions() { 

   std::array<VkVertexInputAttributeDescription, 4> 
   attributeDescriptions = {}; 

   attributeDescriptions[0].binding = 0; // binding index, it is 0 as 
                                            specified above 
   attributeDescriptions[0].location = 0; // location layout

   // data format
   attributeDescriptions[0].format = VK_FORMAT_R32G32B32_SFLOAT; 
   attributeDescriptions[0].offset = offsetof(Vertex, pos); // bytes             
      since the start of the per vertex data 

   attributeDescriptions[1].binding = 0; 
   attributeDescriptions[1].location = 1; 
   attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT; 
   attributeDescriptions[1].offset = offsetof(Vertex, normal); 

   attributeDescriptions[2].binding = 0; 
   attributeDescriptions[2].location = 2; 
   attributeDescriptions[2].format = VK_FORMAT_R32G32B32_SFLOAT; 
   attributeDescriptions[2].offset = offsetof(Vertex, color); 

   attributeDescriptions[3].binding = 0; 
   attributeDescriptions[3].location = 3; 
   attributeDescriptions[3].format = VK_FORMAT_R32G32_SFLOAT; 
   attributeDescriptions[3].offset = offsetof(Vertex, texCoords); 

   return attributeDescriptions; 
}   

我们还将在 Mesh.h 文件中创建一个新的结构体来组织统一数据信息。因此,创建一个名为 UniformBufferObject 的新结构体:

struct UniformBufferObject { 

   glm::mat4 model; 
   glm::mat4 view; 
   glm::mat4 proj; 

}; 

Mesh.h 文件顶部,我们还将包含两个 define 语句来告诉 GLM 使用弧度而不是度数,并使用归一化深度值:

#define GLM_FORCE_RADIAN 
#define GLM_FORCE_DEPTH_ZERO_TO_ONE 

对于 Mesh.h 文件来说,这就结束了。Mesh.cpp 文件完全没有被修改。

创建 ObjectBuffers

为了创建与对象相关的缓冲区,例如顶点、索引和统一缓冲区,我们将创建一个新的类,称为 ObjectBuffers。在 ObjectBuffers.h 文件中,我们将添加所需的 include 语句:

#include <vulkan\vulkan.h> 
#include <vector> 

#include "Mesh.h"  

然后,我们将创建类本身。在公共部分,我们将添加构造函数和析构函数,并添加创建顶点、索引和统一缓冲区所需的数据类型。我们添加一个数据顶点的向量来设置几何体的顶点信息,创建一个名为 vertexBufferVkBuffer 实例来存储顶点缓冲区,并创建一个名为 vertexBufferMemoryVkDeviceMemory 实例:

  • VkBuffer:这是对象缓冲区的句柄。

  • VkDeviceMemory:Vulkan 通过 DeviceMemory 对象在设备的内存中操作内存数据。

类似地,我们创建一个向量来存储索引,并创建一个 indexBufferindexBufferMemory 对象,就像我们为顶点所做的那样。

对于统一缓冲区,我们只创建 uniformBufferuniformBuffer 内存,因为不需要向量。

我们添加了一个 createVertexIndexUniformBuffers 函数,它接受一个 Mesh 类型,并且顶点和索引将根据它设置。

我们还添加了一个销毁函数来销毁我们创建的 Vulkan 对象。

在私有部分,我们添加了三个函数,createVertexIndexUniformBuffers 将会调用这些函数来创建缓冲区。这就是 ObjectBuffers.h 文件的全部内容。因此,ObjectBuffers 类应该如下所示:

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

   std::vector<Vertex> vertices; 
   VkBuffer vertexBuffer; 
   VkDeviceMemory vertexBufferMemory; 

   std::vector<uint32_t> indices; 
   VkBuffer indexBuffer; 
   VkDeviceMemory indexBufferMemory; 

   VkBuffer uniformBuffers; 
   VkDeviceMemory uniformBuffersMemory; 

   void createVertexIndexUniformsBuffers(MeshType modelType); 
   void destroy(); 

private: 

   void createVertexBuffer(); 
   void createIndexBuffer(); 
   void createUniformBuffers(); 

}; 

接下来,让我们继续转到 ObjectBuffers.cpp 文件。在这个文件中,我们包含头文件并创建构造函数和析构函数:

#include "ObjectBuffers.h" 
#include "Tools.h" 
#include "VulkanContext.h" 

ObjectBuffers::ObjectBuffers(){} 

ObjectBuffers::~ObjectBuffers(){} 

Tools.h 被包含进来,因为我们将会向其中添加一些我们将要使用的功能。接下来,我们将创建 createVertexIndexUniformBuffers 函数:

void ObjectBuffers::createVertexIndexUniformsBuffers(MeshType modelType){ 

   switch (modelType) { 

         case kTriangle: Mesh::setTriData(vertices, indices); break; 
         case kQuad: Mesh::setQuadData(vertices, indices); break; 
         case kCube: Mesh::setCubeData(vertices, indices); break; 
         case kSphere: Mesh::setSphereData(vertices, indices); break; 

   } 

    createVertexBuffer(); 
    createIndexBuffer(); 
    createUniformBuffers(); 

}

与 OpenGL 项目类似,我们将添加一个 switch 语句来根据网格类型设置顶点和索引数据。然后我们调用 createVertexBuffer

createIndexBuffercreateUniformBuffers 函数来设置相应的缓冲区。我们首先创建 createVertexBuffer 函数。

为了创建顶点缓冲区,最好在GPU本身上的设备上创建缓冲区。现在,GPU有两种类型的内存:HOST VISIBLEDEVICE LOCALHOST VISIBLE是 CPU 可以访问的 GPU 内存的一部分。这种内存不是很大,因此用于存储最多 250 MB 的数据。

对于较大的数据块,例如顶点和索引数据,最好使用DEVICE LOCAL内存,CPU 无法访问这部分内存。

那么,如何将数据传输到DEVICE LOCAL内存呢?首先,我们必须将数据复制到GPU上的HOST VISIBLE部分,然后将其复制到DEVICE LOCAL内存。因此,我们首先创建一个称为阶段缓冲区的东西,将顶点数据复制进去,然后将阶段缓冲区复制到实际的顶点缓冲区:

(来源:www.youtube.com/watch?v=rXSdDE7NWmA

让我们在VkTool文件中添加创建不同类型缓冲区的功能。这样,我们就可以创建阶段缓冲区和顶点缓冲区本身。因此,在VkTools.h文件中的vkTools命名空间中,添加一个名为createBuffer的新函数。此函数接受五个参数:

  • 第一项是VkDeviceSize,这是要创建的缓冲区数据的大小。

  • 第二项是usage标志,它告诉我们缓冲区将要用于什么。

  • 第三点是内存属性,这是我们想要创建缓冲区的地方;这里我们将指定我们希望它在 HOST VISIBLE 部分还是 DEVICE LOCAL 区域。

  • 第四点是缓冲区本身。

  • 第五点是缓冲区内存,用于将缓冲区绑定到以下内容:

namespace vkTools { 

   VkImageView createImageView(VkImage image, 
         VkFormat format, 
         VkImageAspectFlags aspectFlags); 

   void createBuffer(VkDeviceSize size, 
         VkBufferUsageFlags usage, 
         VkMemoryPropertyFlags properties, 
         VkBuffer &buffer, 
         VkDeviceMemory& bufferMemory); 
} 

VKTools.cpp文件中,我们添加了创建缓冲区并将其绑定到bufferMemory的功能。在命名空间中添加新的函数:

   void createBuffer(VkDeviceSize size, 
         VkBufferUsageFlags usage, 
         VkMemoryPropertyFlags properties, 
         VkBuffer &buffer, // output 
         VkDeviceMemory& bufferMemory) { 

// code  
} 

在绑定缓冲区之前,我们首先创建缓冲区本身。因此,我们按照以下方式填充VkBufferCreateInfo结构体:

   VkBufferCreateInfo bufferInfo = {}; 
   bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; 
   bufferInfo.size = size; 
   bufferInfo.usage = usage; 
   bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; 

   if (vkCreateBuffer(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &bufferInfo, 
      nullptr, &buffer) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create 
           vertex buffer "); 
   }

结构体首先采用通常的类型,然后我们设置缓冲区大小和用途。我们还需要指定缓冲区共享模式,因为缓冲区可以在队列之间共享,例如图形和计算,或者可能仅限于一个队列。因此,在这里我们指定缓冲区仅限于当前队列。

然后,通过调用vkCreateBuffer并传入logicalDevicebufferInfo来创建缓冲区。接下来,为了绑定缓冲区,我们必须获取适合我们特定缓冲区用途的合适内存类型。因此,首先我们必须获取我们正在创建的缓冲区类型的内存需求。必需的内存需求是通过调用vkGetBufferMemoryRequirements函数接收的,该函数接受逻辑设备、缓冲区和内存需求存储在一个名为VkMemoryRequirements的变量类型中。

我们按照以下方式获取内存需求:

   VkMemoryRequirements memrequirements; 
   vkGetBufferMemoryRequirements(VulkanContext::getInstance()->getDevice()->
     logicalDevice, buffer, &memrequirements);  

要绑定内存,我们必须填充 VkMemoryAllocateInfo 结构体。它需要分配大小和所需内存类型的内存索引。每个 GPU 都有不同的内存类型索引,具有不同的堆索引和内存类型。以下是 1080Ti 的对应值:

图片

我们现在将在 VkTools 中添加一个新函数来获取适合我们缓冲区使用的正确类型的内存索引。因此,在 VkTool.h 中的 vkTools 命名空间下添加一个新函数,称为 findMemoryTypeIndex

uint32_t findMemoryTypeIndex(uint32_t typeFilter, VkMemoryPropertyFlags 
    properties); 

它接受两个参数,即可用的内存类型位和所需的内存属性。将 findMemoryTypeIndex 函数的实现添加到 VkTools.cpp 文件中。在命名空间下,添加以下函数:

uint32_t findMemoryTypeIndex(uint32_t typeFilter, VkMemoryPropertyFlags properties) { 

   //-- Properties has two arrays -- memory types and memory heaps 
   VkPhysicalDeviceMemoryProperties memProperties; 
     vkGetPhysicalDeviceMemoryProperties(VulkanContext::
     getInstance()->getDevice()->physicalDevice, 
     &memProperties); 

   for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) { 

         if ((typeFilter & (1 << i)) &&  
             (memProperties.memoryTypes[i].propertyFlags &                                 
              properties) == properties) { 

                     return i; 
               } 
         } 

         throw std::runtime_error("failed to find 
            suitable memory type!"); 
   } 

此函数使用 vkGetPhysicalDeviceMemoryProperties 函数获取设备的内存属性,并填充物理设备的内存属性。

内存属性获取每个索引的内存堆和内存类型的信息。从所有可用索引中,我们选择我们所需的内容并返回值。一旦函数创建完成,我们就可以回到绑定缓冲区。因此,继续我们的 createBuffer 函数,向其中添加以下内容以绑定缓冲区到内存:

   VkMemoryAllocateInfo allocInfo = {}; 
   allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; 
   allocInfo.allocationSize = memrequirements.size; 
   allocInfo.memoryTypeIndex = findMemoryTypeIndex(memrequirements.
                               memoryTypeBits, properties); 

   if (vkAllocateMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &allocInfo, nullptr, 
      &bufferMemory) != VK_SUCCESS) { 

         throw std::runtime_error("failed to allocate 
            vertex buffer memory"); 
   } 

   vkBindBufferMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, buffer, 
      bufferMemory, 0); 

在所有这些之后,我们可以回到 ObjectBuffers 实际创建 createVertexBuffers 函数。因此,创建函数如下:

void ObjectBuffers::createVertexBuffer() { 
// code 
} 

在其中,我们首先创建阶段缓冲区,将顶点数据复制到其中,然后将阶段缓冲区复制到顶点缓冲区。在函数中,我们首先获取总缓冲区大小,这是顶点数和每个顶点存储的数据大小:

VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size(); 

接下来,我们创建阶段缓冲区和 stagingBufferMemory 以将阶段缓冲区绑定到它:

VkBuffer stagingBuffer; 
VkDeviceMemory stagingBufferMemory; 

然后我们调用新创建的 createBuffervkTools 中创建缓冲区:

vkTools::createBuffer(bufferSize, 
   VK_BUFFER_USAGE_TRANSFER_SRC_BIT,  
   VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
   VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, 
   stagingBuffer, stagingBufferMemory);

在其中,我们传入我们想要的尺寸、使用方式和内存类型,以及缓冲区和缓冲区内存。VK_BUFFER_USAGE_TRANSFER_SRC_BIT 表示该缓冲区将在数据传输时作为源传输命令的一部分使用。

VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 表示我们希望它在 GPU 上的主机可见(CPU)内存空间中分配。

VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 表示 CPU 缓存管理不是由我们完成,而是由系统完成。这将确保映射内存与分配的内存匹配。接下来,我们使用 vkMapMemory 获取阶段缓冲区的宿主指针并创建一个 void 指针称为 data。然后调用 vkMapMemory 获取映射内存的指针:

   void* data; 

   vkMapMemory(VulkanContext::getInstance()->getDevice()->
      logicalDevice, stagingBufferMemory, 
         0, // offet 
         bufferSize,// size 
         0,// flag 
         &data);  

VkMapMemory 接收逻辑设备、阶段缓冲区绑定,我们指定 0 作为偏移量,并传递缓冲区大小。没有特殊标志,因此我们传递 0 并获取映射内存的指针。我们使用 memcpy 将顶点数据复制到数据指针:

memcpy(data, vertices.data(), (size_t)bufferSize);  

当不再需要主机对它的访问时,我们取消映射阶段内存:

vkUnmapMemory(VulkanContext::getInstance()->getDevice()->logicalDevice, 
   stagingBufferMemory); 

现在数据已存储在阶段缓冲区中,接下来创建顶点缓冲区并将其绑定到vertexBufferMemory

// Create Vertex Buffer 
   vkTools::createBuffer(bufferSize, 
         VK_BUFFER_USAGE_TRANSFER_DST_BIT | 
         VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, 
         VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,  
         vertexBuffer, 
         vertexBufferMemory);

我们使用createBuffer函数来创建顶点缓冲区。我们传入缓冲区大小。对于缓冲区用途,我们指定当我们将阶段缓冲区传输到它时,它用作传输命令的目标,并且它将用作顶点缓冲区。对于内存属性,我们希望它在DEVICE_LOCAL中创建以获得最佳性能。传递顶点缓冲区和顶点缓冲区内存以绑定缓冲区到内存。现在,我们必须将阶段缓冲区复制到顶点缓冲区。

在 GPU 上复制缓冲区必须使用传输队列和命令缓冲区。我们可以像检索图形和显示队列一样获取传输队列来完成传输。好消息是,我们不需要这样做,因为所有图形和计算队列也支持传输功能,所以我们将使用图形队列来完成。

我们将在vkTools命名空间中创建两个辅助函数来创建和销毁临时命令缓冲区。因此,在VkTools.h文件中,在命名空间中添加两个函数用于开始和结束单次命令:

VkCommandBuffer beginSingleTimeCommands(VkCommandPool commandPool); 
   void endSingleTimeCommands(VkCommandBuffer commandBuffer, 
   VkCommandPool commandPool);  

基本上,beginSingleTimeCommands为我们返回一个命令缓冲区以供使用,而endSingleTimeCommands销毁命令缓冲区。在VkTools.cpp文件中,在命名空间下添加这两个函数:

   VkCommandBuffer beginSingleTimeCommands(VkCommandPool commandPool) { 

         //-- Alloc Command buffer   
         VkCommandBufferAllocateInfo allocInfo = {}; 

         allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND*BUFFER
*                           ALLOCATE_INFO; 
         allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; 
         allocInfo.commandPool = commandPool; 
         allocInfo.commandBufferCount = 1; 

         VkCommandBuffer commandBuffer; 
         vkAllocateCommandBuffers(VulkanContext::getInstance()->
           getDevice()->logicalDevice, 
           &allocInfo, &commandBuffer); 

         //-- Record command buffer 

         VkCommandBufferBeginInfo beginInfo = {}; 
         beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; 
         beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; 

         //start recording 
         vkBeginCommandBuffer(commandBuffer, &beginInfo); 

         return commandBuffer; 

   } 

   void endSingleTimeCommands(VkCommandBuffer commandBuffer, 
      VkCommandPool commandPool) { 

         //-- End recording 
         vkEndCommandBuffer(commandBuffer); 

         //-- Execute the Command Buffer to complete the transfer 
         VkSubmitInfo submitInfo = {}; 
         submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; 
         submitInfo.commandBufferCount = 1; 
         submitInfo.pCommandBuffers = &commandBuffer; 

         vkQueueSubmit(VulkanContext::getInstance()->
            getDevice()->graphicsQueue, 1, &submitInfo, 
            VK_NULL_HANDLE); 

         vkQueueWaitIdle(VulkanContext::getInstance()->
            getDevice()->graphicsQueue); 

         vkFreeCommandBuffers(VulkanContext::getInstance()->
            getDevice()->logicalDevice, commandPool, 1, 
            &commandBuffer); 

   } 

我们已经探讨了如何创建和销毁命令缓冲区。如果您有任何疑问,可以参考第十一章,准备清屏。接下来,在Vktools.h文件中,我们将添加复制缓冲区的功能。在命名空间下添加一个新函数:

   VkCommandBuffer beginSingleTimeCommands(VkCommandPool commandPool); 
   void endSingleTimeCommands(VkCommandBuffer commandBuffer, 
      VkCommandPool commandPool); 

   void copyBuffer(VkBuffer srcBuffer, 
         VkBuffer dstBuffer, 
         VkDeviceSize size);

copyBuffer函数接受源缓冲区、目标缓冲区和缓冲区大小作为输入。现在,将此新函数添加到VkTools.cpp文件中:

void copyBuffer(VkBuffer srcBuffer, 
         VkBuffer dstBuffer, 
         VkDeviceSize size) { 

QueueFamilyIndices qFamilyIndices = VulkanContext::getInstance()->
   getDevice()->getQueueFamiliesIndicesOfCurrentDevice(); 

   // Create Command Pool 
   VkCommandPool commandPool; 

   VkCommandPoolCreateInfo cpInfo = {}; 

   cpInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; 
   cpInfo.queueFamilyIndex = qFamilyIndices.graphicsFamily; 
   cpInfo.flags = 0; 

if (vkCreateCommandPool(VulkanContext::getInstance()->
   getDevice()->logicalDevice, &cpInfo, nullptr, &commandPool) != 
   VK_SUCCESS) { 
         throw std::runtime_error(" failed to create 
            command pool !!"); 
   } 

   // Allocate command buffer and start recording 
   VkCommandBuffer commandBuffer = beginSingleTimeCommands(commandPool); 

   //-- Copy the buffer 
   VkBufferCopy copyregion = {}; 
   copyregion.srcOffset = 0; 
   copyregion.dstOffset = 0; 
   copyregion.size = size; 
   vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer,
      1, &copyregion); 

   // End recording and Execute command buffer and free command buffer 
   endSingleTimeCommands(commandBuffer, commandPool); 

   vkDestroyCommandPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, commandPool, 
      nullptr); 

} 

在该函数中,我们首先从设备获取队列家族索引。然后我们创建一个新的命令池,然后使用beginSingleTimeCommands函数创建一个新的命令缓冲区。为了复制缓冲区,我们创建VkBufferCopy结构。我们将源和目标偏移量设置为0并设置缓冲区大小。

要实际复制缓冲区,我们调用vlCmdCopyBuffer函数,它接受一个命令缓冲区、源命令缓冲区、目标命令缓冲区、复制区域数量(在这种情况下为1)和复制区域结构。一旦缓冲区被复制,我们调用endSingleTimeCommands来销毁命令缓冲区,并调用vkDestroyCommandPool来销毁命令池本身。

现在,我们可以回到ObjectsBuffers中的createVertexBuffers函数,并将阶段缓冲区复制到顶点缓冲区。我们还会销毁阶段缓冲区和缓冲区内存:

   vkTools::copyBuffer(stagingBuffer, 
         vertexBuffer, 
         bufferSize); 

   vkDestroyBuffer(VulkanContext::getInstance()->
      getDevice()->logicalDevice, stagingBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, stagingBufferMemory, nullptr); 

索引缓冲区的创建方式相同,使用createIndexBuffer函数:


void ObjectBuffers::createIndexBuffer() { 

   VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size(); 

   VkBuffer stagingBuffer; 
   VkDeviceMemory stagingBufferMemory; 

   vkTools::createBuffer(bufferSize, 
       VK_BUFFER_USAGE_TRANSFER_SRC_BIT, 
       VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
       VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, 
       stagingBuffer, stagingBufferMemory); 

   void* data; 
   vkMapMemory(VulkanContext::getInstance()->
     getDevice()->logicalDevice, stagingBufferMemory, 
     0, bufferSize, 0, &data); 
   memcpy(data, indices.data(), (size_t)bufferSize); 
   vkUnmapMemory(VulkanContext::getInstance()->
     getDevice()->logicalDevice, stagingBufferMemory); 

   vkTools::createBuffer(bufferSize, 
        VK_BUFFER_USAGE_TRANSFER_DST_BIT |    
        VK_BUFFER_USAGE_INDEX_BUFFER_BIT, 
        VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 
        indexBuffer,  
        indexBufferMemory); 

   vkTools::copyBuffer(stagingBuffer, 
         indexBuffer, 
         bufferSize); 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, 
     stagingBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, 
      stagingBufferMemory, nullptr); 

}    

创建UniformBuffer比较简单,因为我们只需使用HOST_VISIBLE GPU 内存,因此不需要阶段缓冲区:

void ObjectBuffers::createUniformBuffers() { 

   VkDeviceSize bufferSize = sizeof(UniformBufferObject); 

   vkTools::createBuffer(bufferSize, 
               VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, 
               VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
               VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, 
               uniformBuffers, uniformBuffersMemory); 

} 

最后,我们在destroy函数中销毁缓冲区和内存:

void ObjectBuffers::destroy(){ 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, uniformBuffers, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, uniformBuffersMemory, 
      nullptr); 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, indexBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, indexBufferMemory, 
      nullptr); 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, vertexBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
     getDevice()->logicalDevice, vertexBufferMemory, nullptr); 

} 

创建描述符类

与 OpenGL 不同,在 OpenGL 中我们使用统一缓冲区来传递模型、视图、投影和其他类型的数据,而 Vulkan 使用描述符。在描述符中,我们必须首先指定缓冲区的布局,以及绑定位置、计数、描述符类型以及与之关联的着色器阶段。

一旦使用不同类型的描述符创建了描述符布局,我们必须为数字交换链图像计数创建一个描述符池,因为统一缓冲区将每帧设置一次。

之后,我们可以为两个帧分配和填充描述符集。数据分配将从池中完成。

我们将创建一个新的类来创建描述符集、布局绑定、池以及分配和填充描述符集。创建一个名为Descriptor的新类。在Descriptor.h文件中,添加以下代码:

#pragma once 
#include <vulkan\vulkan.h> 
#include <vector> 

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

   // all the descriptor bindings are combined into a single layout 

   VkDescriptorSetLayout descriptorSetLayout;  
   VkDescriptorPool descriptorPool; 
   VkDescriptorSet descriptorSet; 

   void createDescriptorLayoutSetPoolAndAllocate(uint32_t 
      _swapChainImageCount); 
   void populateDescriptorSets(uint32_t _swapChainImageCount, 
      VkBuffer uniformBuffers); 

   void destroy(); 

private: 

   void createDescriptorSetLayout(); 
   void createDescriptorPoolAndAllocateSets(uint32_t 
      _swapChainImageCount); 

};  

我们包含常用的Vulkan.h和 vector。在公共部分,我们使用构造函数和析构函数创建类。我们还创建了三个变量,分别称为descriptorSetLayoutdescriptorPooldescriptorSets,它们分别对应于VkDescriptorSetLayoutVkDescriptorPoolVkDescriptorSet类型,以便于访问集合。createDescriptorLayoutSetPoolAndAllocate函数将调用私有的createDescriptorSetLayoutcreateDescriptorPoolAndAllocateSets函数,这些函数将创建布局集,然后创建描述符池并将其分配。当我们将统一缓冲区设置为填充集合中的数据时,将调用populateDescriptorSets函数。

我们还有一个destroy函数来销毁已创建的 Vulkan 对象。在Descriptor.cpp文件中,我们将添加函数的实现。首先添加必要的包含,然后添加构造函数、析构函数和createDescriptorLayoutAndPool函数:

#include "Descriptor.h" 

#include<array> 
#include "VulkanContext.h" 

#include "Mesh.h" 

Descriptor::Descriptor(){ 

} 
Descriptor::~Descriptor(){ 

} 

void Descriptor::createDescriptorLayoutSetPoolAndAllocate(uint32_t 
    _swapChainImageCount){ 

   createDescriptorSetLayout(); 
   createDescriptorPoolAndAllocateSets(_swapChainImageCount); 

} 

createDescriptorLayoutSetPoolAndAllocate函数调用createDescriptorSetLayoutcreateDescriptorPoolAndAllocateSets函数。现在让我们添加createDescriptorSetLayout函数:

void Descriptor::createDescriptorSetLayout() { 

   VkDescriptorSetLayoutBinding uboLayoutBinding = {}; 
   uboLayoutBinding.binding = 0;// binding location 
   uboLayoutBinding.descriptorCount = 1; 
   uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;  
   uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;  

   std::array<VkDescriptorSetLayoutBinding, 1> 
      layoutBindings = { uboLayoutBinding }; 

   VkDescriptorSetLayoutCreateInfo layoutCreateInfo = {}; 
   layoutCreateInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR*SET
*                            LAYOUT_CREATE_INFO; 
   layoutCreateInfo.bindingCount = static_cast<uint32_t> 
                                   (layoutBindings.size()); 
   layoutCreateInfo.pBindings = layoutBindings.data();  

   if (vkCreateDescriptorSetLayout(VulkanContext::getInstance()->
     getDevice()->logicalDevice, &layoutCreateInfo, nullptr, 
     &descriptorSetLayout) != VK_SUCCESS) { 

         throw std::runtime_error("failed to create 
           descriptor set layout"); 
   } 
} 

对于我们的项目,布局集将只有一个布局绑定,即包含模型、视图和投影矩阵信息的那个结构体。

我们必须填充VkDescriptorSetLayout结构体,并指定绑定位置索引、计数、我们将传递的信息类型以及统一缓冲区将被发送到的着色器阶段。在创建集合布局后,我们填充VkDescriptorSetLayoutCreateInfo,在其中指定绑定计数和绑定本身。

然后,我们调用vkCreateDescriptorSetLayout函数,通过传递逻辑设备和布局创建信息来创建描述符集布局。接下来,我们添加createDescriptorPoolAndAllocateSets函数:

void Descriptor::createDescriptorPoolAndAllocateSets(uint32_t 
    _swapChainImageCount) { 

   // create pool 
   std::array<VkDescriptorPoolSize, 1> poolSizes = {}; 

   poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; 
   poolSizes[0].descriptorCount = _swapChainImageCount; 

   VkDescriptorPoolCreateInfo poolInfo = {}; 
   poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; 
   poolInfo.poolSizeCount = static_cast<uint32_t>(poolSizes.size());  
   poolInfo.pPoolSizes = poolSizes.data(); 

   poolInfo.maxSets = _swapChainImageCount;  

   if (vkCreateDescriptorPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &poolInfo, nullptr, 
      &descriptorPool) != VK_SUCCESS) { 

         throw std::runtime_error("failed to create descriptor pool "); 
   } 

   // allocate 
   std::vector<VkDescriptorSetLayout> layouts(_swapChainImageCount, 
       descriptorSetLayout); 

   VkDescriptorSetAllocateInfo allocInfo = {}; 
   allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; 
   allocInfo.descriptorPool = descriptorPool; 
   allocInfo.descriptorSetCount = _swapChainImageCount; 
   allocInfo.pSetLayouts = layouts.data(); 

   if (vkAllocateDescriptorSets(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &allocInfo, &descriptorSet) 
      != VK_SUCCESS) { 

         throw std::runtime_error("failed to allocate descriptor
            sets ! "); 
   }  
}

要创建描述符池,我们必须使用VkDescriptorPoolSize指定池大小。我们创建一个数组并命名为poolSizes。由于在布局集中我们只有统一缓冲区,我们设置其类型并将计数设置为与交换链图像计数相等。要创建描述符池,我们必须指定类型、池大小计数和池大小数据。我们还需要设置maxsets,即可以从池中分配的最大集合数,它等于交换链图像计数。我们通过调用vkCreateDescriptorPool并传递逻辑设备和池创建信息来创建描述符池。接下来,我们必须指定描述符集的分配参数。

我们创建一个描述符集布局的向量。然后,我们创建VkDescriptionAllocationInfo结构体来填充它。我们传递描述符池、描述符集计数(等于交换链图像计数)和布局数据。然后,通过调用vkAllocateDescriptorSets并传递逻辑设备和创建信息结构体来分配描述符集。

最后,我们将添加populateDescriptorSets函数,如下所示:

void Descriptor::populateDescriptorSets(uint32_t _swapChainImageCount, 
   VkBuffer uniformBuffers) { 

   for (size_t i = 0; i < _swapChainImageCount; i++) { 

         // Uniform buffer info 

         VkDescriptorBufferInfo uboBufferDescInfo = {}; 
         uboBufferDescInfo.buffer = uniformBuffers; 
         uboBufferDescInfo.offset = 0; 
         uboBufferDescInfo.range = sizeof(UniformBufferObject); 

         VkWriteDescriptorSet uboDescWrites; 
         uboDescWrites.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; 
         uboDescWrites.pNext = NULL; 
         uboDescWrites.dstSet = descriptorSet; 
         uboDescWrites.dstBinding = 0; // binding index of 0  
         uboDescWrites.dstArrayElement = 0;  
         uboDescWrites.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; 
         uboDescWrites.descriptorCount = 1;  
         uboDescWrites.pBufferInfo = &uboBufferDescInfo; // uniforms 
                                                            buffers 
         uboDescWrites.pImageInfo = nullptr; 
         uboDescWrites.pTexelBufferView = nullptr; 

         std::array<VkWriteDescriptorSet, 1> descWrites = { uboDescWrites}; 

         vkUpdateDescriptorSets(VulkanContext::getInstance()->
           getDevice()->logicalDevice, static_cast<uint32_t>
           (descWrites.size()), descWrites.data(), 0, 
           nullptr); 
   } 

} 

此函数接收交换链图像计数和统一缓冲区作为参数。对于交换链的两个图像,需要通过调用vkUpdateDescriptorSets来更新描述符的配置。此函数接收一个VkWriteDescriptorSet数组。现在,VkWriteDescriptorSet接收一个缓冲区、图像结构体或texelBufferView作为参数。由于我们将使用统一缓冲区,我们必须创建它并传递它。VkdescriptorBufferInfo接收一个缓冲区(将是创建的统一缓冲区),接收一个偏移量(在这种情况下为无),然后接收范围(即缓冲区本身的大小)。

创建完成后,我们可以开始指定VkWriteDescriptorSet。这个函数接收类型、descriptorSet和绑定位置(即第 0 个索引)。它没有数组元素,并接收描述符类型(即统一缓冲区类型);描述符计数为1,我们传递缓冲区信息结构体。对于图像信息和纹理缓冲区视图,我们指定为无,因为它没有被使用。

然后,我们创建一个VkWriteDescriptorSet数组,并将我们创建的统一缓冲区描述符写入信息uboDescWrites添加到其中。通过调用vkUpdateDescriptorSets并传递逻辑设备、描述符写入大小和数据来更新描述符集。这就是populateDescriptorSets函数的全部内容。我们最后添加一个销毁函数,该函数销毁描述符池和描述符集布局。添加函数如下:

void Descriptor::destroy(){ 

   vkDestroyDescriptorPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, descriptorPool, nullptr); 
   vkDestroyDescriptorSetLayout(VulkanContext::getInstance()->
      getDevice()->logicalDevice, descriptorSetLayout, nullptr); 
} 

创建 SPIR-V 着色器二进制文件

与 OpenGL 不同,OpenGL 接收可读的 GLSL(OpenGL 着色语言)文件作为着色器,而 Vulkan 接收二进制或字节码格式的着色器。所有着色器,无论是顶点、片段还是计算着色器,都必须是字节码格式。

SPIR-V 也非常适合交叉编译,这使得移植着色器文件变得容易得多。如果你有 Direct3D HLSL 着色器代码,它可以编译成 SPIR-V 格式,并可以在 Vulkan 应用程序中使用,这使得将 Direct3D 游戏移植到 Vulkan 变得非常容易。着色器最初是用 GLSL 编写的,我们对 OpenGL 编写它的方式做了一些小的改动。提供了一个编译器,可以将代码从 GLSL 编译成 SPIR-V 格式。编译器包含在 Vulkan SDK 安装中。基本的顶点着色器 GLSL 代码如下:

#version 450 
#extension GL_ARB_separate_shader_objects : enable 

layout (binding = 0) uniform UniformBufferOBject{ 

mat4 model; 
mat4 view; 
mat4 proj; 

} ubo; 

layout(location = 0) in vec3 inPosition; 
layout(location = 1) in vec3 inNormal; 
layout(location = 2) in vec3 inColor; 
layout(location = 3) in vec2 inTexCoord; 

layout(location = 0) out vec3 fragColor; 

void main() { 

    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0); 
    fragColor = inColor; 

}

着色器看起来应该非常熟悉,有一些小的改动。例如,GLSL 版本仍然在顶部指定。在这种情况下,它是 #version 450。但我们还看到了一些新事物,例如 #extension GL_ARB_seperate_shader_objects: enable。这指定了着色器使用的扩展。在这种情况下,需要一个旧的扩展,它基本上允许我们将顶点着色器和片段着色器作为单独的文件使用。扩展需要由架构评审委员会(ARB)批准。

除了包含扩展之外,你可能还注意到为所有数据类型指定了位置布局。你可能还注意到,在创建顶点和统一缓冲区时,我们必须指定缓冲区的绑定索引。在 Vulkan 中,没有与 GLgetUniformLocation 相当的函数来获取统一缓冲区的位置索引。这是因为获取位置需要相当多的系统资源。相反,我们指定并硬编码索引的值。统一缓冲区以及输入和输出缓冲区都可以分配一个索引 0,因为它们是不同数据类型。由于统一缓冲区将作为包含模型、视图和投影矩阵的结构体发送,所以在着色器中创建了一个类似的结构体,并将其分配给统一布局的 0^(th) 索引。

所有四个属性也被分配了一个布局索引 0123,正如在 Mesh.h 文件下的 Vertex 结构体中设置 VkVertexInputAttributeDescription 为四个属性时所指定的。输出也被分配了一个布局位置索引 0,并且数据类型被指定为 vec3。然后,在着色器的主函数中,我们通过将对象的局部坐标乘以从统一缓冲区结构体接收到的模型、视图和投影矩阵来设置 gl_Position 值。此外,outColor 被设置为接收到的 inColor

片段着色器如下:打开一个 .txt 文件,将着色器代码添加到其中,并将文件命名为 basic. 然后,将扩展名从 *.txt 改为 *.vert

#version 450 
#extension GL_ARB_separate_shader_objects : enable 

layout(location = 0) in vec3 fragColor; 

layout(location = 0) out vec4 outColor; 

void main() { 

   outColor = vec4(fragColor, 1.0f); 

}

在这里,我们指定了 GLSL 版本和要使用的扩展。有 inout,它们都有一个位置布局为 0。请注意,in 是一个名为 fragColorvec3,这是我们从顶点着色器发送出来的,而 outColor 是一个 vec4。在着色器文件的 main 函数中,我们将 vec3 转换为 vec4,并将结果颜色设置为 outColor。将片段着色器添加到名为 basic.frag 的文件中。在 VulkanProject 根目录下,创建一个名为 shaders 的新文件夹,并将两个着色器文件添加到其中:

图片

创建一个名为 SPIRV 的新文件夹,因为这是我们放置编译好的 SPIRV 字节码文件的地方。要编译 .glsl 文件,我们将使用安装 Vulkan SDK 时安装的 glslValidator.exe 文件。现在,要编译代码,我们可以使用以下命令:

glslangValidator.exe -V basic.frag -o basic.frag.spv 

按住键盘上的 Shift 键,在 shaders 文件夹中右键单击,然后点击在此处打开 PowerShell 窗口:

图片

在 PowerShell 中,输入以下命令:

图片

确保将 V 大写,将 o 小写,否则将会出现编译错误。这将在文件夹中创建一个新的 spirv 文件。将 frag 改为 vert 以编译 SPIRV 顶点着色器:

图片

这将在文件夹中创建顶点和片段着色器 SPIRV 二进制文件:

图片

而不是每次都手动编译代码,我们可以创建一个 .bat 文件来自动完成这项工作,并将编译好的 SPIRV 二进制文件放在 SPIRV 文件夹中。在 shaders 文件夹中,创建一个新的 .txt 文件,并将其命名为 glsl_spirv_compiler.bat

.bat 文件中,添加以下内容:

@echo off 
echo compiling glsl shaders to spirv  
for /r %%i in (*.vert;*.frag) do %VULKAN_SDK%\Bin32\glslangValidator.exe -V "%%i" -o  "%%~dpiSPIRV\%%~nxi".spv 

保存并关闭文件。现在双击 .bat 文件以执行它。这将编译着色器并将编译好的二进制文件放置在 SPIRV 着色器文件中:

图片

您可以使用控制台命令删除 shaders 文件夹中我们之前编译的 SPIRV 文件,因为我们将会使用 SPIRV 子文件夹中的着色器文件。

摘要

在本章中,我们创建了渲染几何形状所需的所有资源。首先,我们添加了 Mesh 类,它包含了所有网格类型(包括三角形、四边形、立方体和球体)的顶点和索引信息。然后,我们创建了 ObjectBuffers 类,它用于存储并将缓冲区绑定到 GPU 内存,使用的是 VkTool 文件。我们还创建了一个单独的描述符类,其中包含我们的描述符集布局和池。此外,我们还创建了描述符集。最后,我们创建了从 GLSL 着色器编译的 SPIRV 字节码着色器文件。

在下一章中,我们将使用这里创建的资源来绘制我们的第一个彩色几何形状。

第十二章:绘制 Vulkan 对象

在上一章中,我们创建了绘制对象所需的所有资源。在本章中,我们将创建 ObjectRenderer 类,该类将在视口中绘制对象。这个类被用来确保我们有一个实际的几何对象来绘制和查看,与我们的紫色视口一起。

我们还将学习如何在章节末尾同步 CPU 和 GPU 操作,这将消除我们在第十一章,创建对象资源中遇到的验证错误。

在我们为渲染设置场景之前,我们必须为几何渲染准备最后一件事;那就是图形管线。我们将在下一节开始设置它。

在本章中,我们将涵盖以下主题:

  • 准备 GraphicsPipeline

  • ObjectRenderer

  • VulkanContext 类的更改

  • Camera

  • 绘制一个对象

  • 同步一个对象

准备 GraphicsPipeline 类

图形管线定义了对象绘制时应遵循的管线。正如我们在第二章,数学和图形概念中发现的,我们需要遵循一系列步骤来绘制对象:

图片

在 OpenGL 中,管线状态可以在任何时候更改,就像我们在第八章,通过碰撞、循环和光照增强你的游戏中绘制文本时启用和禁用混合一样。然而,更改状态会占用大量系统资源,这就是为什么 Vulkan 鼓励你不要随意更改状态。因此,你必须为每个对象预先设置管线状态。在你创建管线状态之前,你还需要创建一个管线布局,该布局将使用我们在上一章中创建的描述符集布局。所以,我们首先创建管线布局。

然后,我们还需要提供着色器 SPIR-V 文件,这些文件必须被读取以了解如何创建着色器模块。因此,向类中添加功能。然后我们填充图形管线信息,它将使用我们创建的不同着色器模块。我们还指定了顶点输入状态,它将包含有关我们之前在定义顶点结构时创建的缓冲区绑定和属性的信息。

输入装配状态也需要指定,它描述了将使用顶点绘制的几何类型。请注意,我们可以使用给定的顶点集绘制点、线或三角形。

此外,我们还需要指定视图状态,它描述了将要渲染的帧缓冲区区域,因为如果需要,我们可以将帧缓冲区的一部分显示到视口中。在我们的情况下,我们将显示整个区域到视口中。我们指定了光栅化状态,它将执行深度测试和背面剔除,并将几何形状转换为光栅化线条——这些线条将按照片段着色器中指定的颜色进行着色。

多采样状态将指定是否要启用多采样以启用抗锯齿。深度和模板状态指定是否启用深度和模板测试,并且要在对象上执行。颜色混合状态指定是否启用混合。最后,动态状态使我们能够在不重新创建管线的情况下动态地更改一些管线状态。在我们的实现中,我们不会使用动态状态。设置好所有这些后,我们可以为对象创建图形管线。

让我们先创建一个新的类来处理图形管线。在GraphicsPipeline.h文件中,添加以下内容:

#include <vulkan\vulkan.h> 
#include <vector> 

#include <fstream> 

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

   VkPipelineLayout pipelineLayout; 
   VkPipeline graphicsPipeline; 

   void createGraphicsPipelineLayoutAndPipeline(VkExtent2D 
     swapChainImageExtent, VkDescriptorSetLayout descriptorSetLayout, 
     VkRenderPass renderPass); 

   void destroy(); 

private: 

   std::vector<char> readfile(const std::string& filename); 
   VkShaderModule createShaderModule(const std::vector<char> & code); 

   void createGraphicsPipelineLayout(VkDescriptorSetLayout 
      descriptorSetLayout); 
   void createGraphicsPipeline(VkExtent2D swapChainImageExtent, 
      VkRenderPass renderPass); 

}; 

我们包含了常用的头文件,还包含了fstream,因为我们需要它来读取着色器文件。然后我们创建类本身。在public部分,我们将添加构造函数和析构函数。我们创建了用于存储VkPipelineLayoutVkPipeline类型的pipelineLayoutgraphicsPipeline对象的实例。

我们创建了一个名为createGraphicsPipelineLayoutAndPipeline的新函数,它接受VkExtent2DVkDescriptorSetLayoutVkRenderPass作为参数,因为这是创建布局和实际管线本身所必需的。该函数将内部调用createGraphicsPipelineLayoutcreateGraphicsPipeline,分别创建布局和管线。这些函数被添加到private部分。

public部分,我们还有一个名为destroy的函数,它将销毁所有创建的资源。在private部分,我们还有另外两个函数。第一个是readFile函数,它读取 SPIR-V 文件,第二个是createShaderModule函数,它将从读取的着色器文件创建着色器模块。现在让我们转到GraphicsPipeline.cpp文件:

#include "GraphicsPipeline.h" 

#include "VulkanContext.h" 
#include "Mesh.h" 

GraphicsPipeline::GraphicsPipeline(){} 

GraphicsPipeline::~GraphicsPipeline(){} 

在前面的代码块中,我们包含了GraphicsPipeline.hVulkanContext.hMesh.h文件,因为它们是必需的。我们还添加了构造函数和析构函数的实现。

然后我们添加了createGraphicsPipelineLayoutAndPipeline函数,如下所示:

void GraphicsPipeline::createGraphicsPipelineLayoutAndPipeline(VkExtent2D 
  swapChainImageExtent, VkDescriptorSetLayout descriptorSetLayout, 
  VkRenderPass renderPass){ 

   createGraphicsPipelineLayout(descriptorSetLayout); 
   createGraphicsPipeline(swapChainImageExtent, renderPass); 

} 

createPipelineLayout函数的创建如下。我们必须创建一个createInfo结构体,设置结构类型,设置descriptorLayout和计数,然后使用vkCreatePipelineLayout函数创建管线布局:

void GraphicsPipeline::createGraphicsPipelineLayout(VkDescriptorSetLayout 
  descriptorSetLayout){ 

   VkPipelineLayoutCreateInfo pipelineLayoutInfo = {}; 
   pipelineLayoutInfo.sType = VK_STRUCTURE_TYPEPIPELINE_                              LAYOUT_CREATE_INFO; 

// used for passing uniform objects and images to the shader 

pipelineLayoutInfo.setLayoutCount = 1; 
   pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout; 

   if (vkCreatePipelineLayout(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &pipelineLayoutInfo, nullptr, 
      &pipelineLayout) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create pieline 
            layout !"); 
   } 

} 

在我们添加创建管线函数之前,我们将添加readFilecreateShaderModule函数:

std::vector<char> GraphicsPipeline::readfile(const std::string& filename) { 

   std::ifstream file(filename, std::ios::ate | std::ios::binary); 

   if (!file.is_open()) { 
         throw std::runtime_error(" failed to open shader file"); 
   } 
   size_t filesize = (size_t)file.tellg(); 

   std::vector<char> buffer(filesize); 

   file.seekg(0); 
   file.read(buffer.data(), filesize); 

   file.close(); 

   return buffer; 

}  

readFile 函数接收一个 SPIR-V 代码文件,打开并读取它,将文件内容保存到名为 buffer 的字符向量中,然后返回它。然后我们添加 createShaderModule 函数,如下所示:

VkShaderModule GraphicsPipeline::createShaderModule(const std::vector<char> & code) { 

   VkShaderModuleCreateInfo cInfo = {}; 

   cInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; 
   cInfo.codeSize = code.size(); 
   cInfo.pCode = reinterpret_cast<const uint32_t*>(code.data()); 

   VkShaderModule shaderModule; 
   if (vkCreateShaderModule(VulkanContext::getInstance()->getDevice()->
     logicalDevice, &cInfo, nullptr, &shaderModule) != VK_SUCCESS) { 
     throw std::runtime_error(" failed to create shader module !"); 
   } 

   return shaderModule; 
} 

要创建所需的 ShaderStageCreateInfo 来创建管线,我们需要填充 ShaderModuleCreateInfo,它从缓冲区中获取代码和大小以创建着色器模块。着色器模块是通过 vkCreateShaderModule 函数创建的,它接收设备和 CreateInfo。一旦创建了着色器模块,它就会被返回。要创建管线,我们必须创建以下信息结构体:着色器阶段信息、顶点输入信息、输入装配结构体、视口信息结构体、光栅化信息结构体、多重采样状态结构体、深度模板结构体(如果需要)、颜色混合结构体和动态状态结构体。

因此,让我们依次创建每个结构体,从着色器阶段结构体开始。添加 createGraphicsPipeline 函数,并在其中创建管线:

void GraphicsPipeline::createGraphicsPipeline(VkExtent2D swapChainImageExtent,  VkRenderPass renderPass) { 

... 

} 

在这个函数中,我们现在将添加以下内容,这将创建图形管线。

ShaderStageCreateInfo

要创建顶点着色器 ShaderStageCreateInfo,我们需要首先读取着色器代码并为其创建着色器模块:

   auto vertexShaderCode = readfile("shaders/SPIRV/basic.vert.spv"); 

   VkShaderModule vertexShadeModule = createShaderModule(vertexShaderCode); 

为了读取着色器文件,我们传递着色器文件的路径。然后,我们将读取的代码传递给 createShaderModule 函数,这将给我们 vertexShaderModule。我们为顶点着色器创建着色器阶段信息结构体,并传递阶段、着色器模块以及要在着色器中使用的函数名称,在我们的例子中是 main

   VkPipelineShaderStageCreateInfo vertShaderStageCreateInfo = {}; 
   vertShaderStageCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER
                                     _STAGE_CREATE_INFO; 
   vertShaderStageCreateInfo.stage = VK_SHADER_STAGE_VERTEX_BIT; 
   vertShaderStageCreateInfo.module = vertexShadeModule; 
   vertShaderStageCreateInfo.pName = "main";  

同样,我们将为片段着色器创建 ShaderStageCreateInfo 结构体:

   auto fragmentShaderCode = readfile("shaders/SPIRV/basic.frag.spv"); 
   VkShaderModule fragShaderModule = createShaderModule
                                     (fragmentShaderCode); 

   VkPipelineShaderStageCreateInfo fragShaderStageCreateInfo = {}; 

   fragShaderStageCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINESHADER_                                      STAGE_CREATE_INFO; 
   fragShaderStageCreateInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT; 
   fragShaderStageCreateInfo.module = fragShaderModule; 
   fragShaderStageCreateInfo.pName = "main";

注意,着色器阶段被设置为 VK_SHADER_STAGE_FRAGMENT_BIT 以表明这是一个片段着色器,我们还传递了 basic.frag.spv 作为要读取的文件,这是片段着色器文件。然后我们创建一个 shaderStageCreateInfo 数组,并将两个着色器添加到其中以方便使用:

VkPipelineShaderStageCreateInfo shaderStages[] = {    
    vertShaderStageCreateInfo, fragShaderStageCreateInfo }; 

VertexInputStateCreateInfo

在这个信息中,我们指定了输入缓冲区绑定和属性描述:

auto bindingDescription = Vertex::getBindingDescription(); 
auto attribiteDescriptions = Vertex::getAttributeDescriptions(); 

VkPipelineVertexInputStateCreateInfo vertexInputInfo = {}; 
vertexInputInfo.sType = VK_STRUCTURE_TYPE*PIPELINE
*                           VERTEX_INPUT_STATE_CREATE_INFO;
// initially was 0 as vertex data was hardcoded in the shader 
vertexInputInfo.vertexBindingDescriptionCount = 1; 
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription; 

vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t> 
                                                  (attribiteDescriptions.size());    
vertexInputInfo.pVertexAttributeDescriptions = attribiteDescriptions
                                               .data();

这在 Mesh.h 文件下的顶点结构体中指定。

InputAssemblyStateCreateInfo

在这里,我们指定我们想要创建的几何形状,即三角形列表。添加如下:

   VkPipelineInputAssemblyStateCreateInfo inputAssemblyInfo = {}; 
   inputAssemblyInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*INPUT
*                             ASSEMBLY_STATE_CREATE_INFO; 
   inputAssemblyInfo.topology =  VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; 
   inputAssemblyInfo.primitiveRestartEnable = VK_FALSE; 

RasterizationStateCreateInfo

在这个结构体中,我们指定启用深度裁剪,这意味着如果片段超出了近平面和远平面,不会丢弃这些片段,而是仍然保留该片段的值,并将其设置为近平面或远平面的值,即使该像素超出了这两个平面中的任何一个。

通过将 rasterizerDiscardEnable 的值设置为 true 或 false 来在光栅化阶段丢弃像素。将多边形模式设置为 VK_POLYGON_MODE_FILLVK_POLYGON_MODE_LINE。如果设置为线,则只绘制线框;否则,内部也会被光栅化。

我们可以使用lineWidth参数设置线宽。此外,我们可以启用或禁用背面剔除,然后通过设置cullModefrontFace参数来设置前向面 winding 的顺序。

我们可以通过启用它并添加一个常数到深度,夹断它,或添加一个斜率因子来改变深度值。深度偏差在阴影图中使用,我们不会使用,所以不会启用深度偏差。添加结构体并按如下方式填充它:

   VkPipelineRasterizationStateCreateInfo rastStateCreateInfo = {}; 
   rastStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*RASTERIZATION
*                               STATE_CREATE_INFO; 
   rastStateCreateInfo.depthClampEnable = VK_FALSE; 
   rastStateCreateInfo.rasterizerDiscardEnable = VK_FALSE;  
   rastStateCreateInfo.polygonMode = VK_POLYGON_MODE_FILL; 
   rastStateCreateInfo.lineWidth = 1.0f; 
   rastStateCreateInfo.cullMode = VK_CULL_MODE_BACK_BIT; 
   rastStateCreateInfo.frontFace = VK_FRONT_FACE_CLOCKWISE; 
   rastStateCreateInfo.depthBiasEnable = VK_FALSE; 
   rastStateCreateInfo.depthBiasConstantFactor = 0.0f; 
   rastStateCreateInfo.depthBiasClamp = 0.0f; 
   rastStateCreateInfo.depthBiasSlopeFactor = 0.0f; 

MultisampleStateCreateInfo

对于我们的项目,我们不会启用多采样来抗锯齿。然而,我们仍然需要创建以下结构体:

   VkPipelineMultisampleStateCreateInfo msStateInfo = {}; 
   msStateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*MULTISAMPLE
*                       STATE_CREATE_INFO; 
   msStateInfo.sampleShadingEnable = VK_FALSE; 
   msStateInfo.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

我们通过将sampleShadingEnable设置为 false 并将样本计数设置为1来禁用它。

深度和模板创建信息

由于我们没有深度或模板缓冲区,我们不需要创建它。但是,当你有一个深度缓冲区时,你需要将其添加到使用深度纹理。

ColorBlendStateCreateInfo

我们将颜色混合设置为 false,因为我们的项目不需要。为了填充它,我们必须首先创建ColorBlend附加状态,它包含每个附加中每个ColorBlend的配置。然后,我们创建ColorBlendStateInfo,它包含整体混合状态。

创建ColorBlendAttachment状态如下。在这里,我们仍然指定颜色写入掩码,即红色、绿色、蓝色和 alpha 位,并将附加状态设置为 false,以禁用帧缓冲区附加的混合:

   VkPipelineColorBlendAttachmentState  cbAttach = {}; 
   cbAttach.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | 
                             VK_COLOR_COMPONENT_G_BIT | 
                             VK_COLOR_COMPONENT_B_BIT | 
                             VK_COLOR_COMPONENT_A_BIT; 
   cbAttach.blendEnable = VK_FALSE; 

我们创建实际的混合结构体,它接受创建的混合附加信息,并将附加计数设置为1,因为我们有一个单独的附加:

   cbCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_COLORBLEND_                        STATE_CREATE_INFO; 
   cbCreateInfo.attachmentCount = 1; 
   cbCreateInfo.pAttachments = &cbAttach;  

动态状态信息

由于我们没有任何动态状态,所以没有创建。

ViewportStateCreateInfo

ViewportStateCreateInfo中,我们可以指定输出将被渲染到视口的帧缓冲区区域。因此,我们可以渲染场景,但只显示其中的一部分到视口。我们还可以指定一个裁剪矩形,这将丢弃渲染到视口的像素。

然而,我们不会做任何花哨的事情,因为我们将会以原样渲染整个场景到视口。为了定义视口大小和裁剪大小,我们必须创建相应的结构体,如下所示:

   VkViewport viewport = {}; 
   viewport.x = 0; 
   viewport.y = 0; 
   viewport.width = (float)swapChainImageExtent.width; 
   viewport.height = (float)swapChainImageExtent.height; 
   viewport.minDepth = 0.0f; 
   viewport.maxDepth = 1.0f; 

   VkRect2D scissor = {}; 
   scissor.offset = { 0,0 }; 
   scissor.extent = swapChainImageExtent; 

对于视口大小和范围,我们将它们设置为swapChain图像大小的宽度和高度,从(0, 0)开始。我们还设置了最小和最大深度,通常在01之间。

对于裁剪,由于我们想显示整个视口,我们将偏移设置为(0, 0),这表示我们不想从视口开始的地方开始偏移。相应地,我们将scissor.extent设置为swapChain图像的大小。

现在我们可以创建ViewportStateCreateInfo函数,如下所示:

   VkPipelineViewportStateCreateInfo vpStateInfo = {}; 
   vpStateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*VIEWPORT
*                       STATE_CREATE_INFO; 
   vpStateInfo.viewportCount = 1; 
   vpStateInfo.pViewports = &viewport; 
   vpStateInfo.scissorCount = 1; 
   vpStateInfo.pScissors = &scissor;  

GraphicsPipelineCreateInfo

要创建图形管线,我们必须创建最终的Info结构体,我们将使用迄今为止创建的Info结构体来填充它。所以,添加如下结构体:

   VkGraphicsPipelineCreateInfo gpInfo = {}; 
   gpInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; 

   gpInfo.stageCount = 2; 
   gpInfo.pStages = shaderStages; 

   gpInfo.pVertexInputState = &vertexInputInfo; 
   gpInfo.pInputAssemblyState = &inputAssemblyInfo; 
   gpInfo.pRasterizationState = &rastStateCreateInfo; 
   gpInfo.pMultisampleState = &msStateInfo; 
   gpInfo.pDepthStencilState = nullptr; 
   gpInfo.pColorBlendState = &cbCreateInfo; 
   gpInfo.pDynamicState = nullptr; 

gpInfo.pViewportState = &vpStateInfo; 

我们还需要传递管线布局,渲染通道,并指定是否有任何子通道:

   gpInfo.layout = pipelineLayout; 
   gpInfo.renderPass = renderPass; 
   gpInfo.subpass = 0; 

现在,我们可以创建管线,如下所示:

  if (vkCreateGraphicsPipelines(VulkanContext::getInstance()->
    getDevice()->logicalDevice, VK_NULL_HANDLE, 1, &gpInfo, nullptr, 
    &graphicsPipeline) != VK_SUCCESS) { 
         throw std::runtime_error("failed to create graphics pipeline !!"); 
   } 

此外,请确保销毁着色器模块,因为它们不再需要:

   vkDestroyShaderModule(VulkanContext::getInstance()->
      getDevice()->logicalDevice, vertexShadeModule, nullptr); 
   vkDestroyShaderModule(VulkanContext::getInstance()->
      getDevice()->logicalDevice, fragShaderModule, nullptr); 

createGraphicsPipeline函数的所有内容到此为止。最后,添加destroy函数,该函数将销毁管线和布局:

 void GraphicsPipeline::destroy(){ 

   vkDestroyPipeline(VulkanContext::getInstance()->
      getDevice()->logicalDevice, graphicsPipeline, nullptr); 
   vkDestroyPipelineLayout(VulkanContext::getInstance()->
      getDevice()->logicalDevice, pipelineLayout, nullptr); 

} 

对象渲染器类

在创建了所有必要的类之后,我们最终可以创建我们的ObjectRenderer类,该类将渲染网格对象到场景中。

让我们创建一个新的类,称为ObjectRenderer。在ObjectRenderer.h中添加以下内容:

#include "GraphicsPipeline.h" 
#include "ObjectBuffers.h" 
#include "Descriptor.h" 

#include "Camera.h" 

class ObjectRenderer 
{ 
public: 
  void createObjectRenderer(MeshType modelType, glm::vec3 _position, 
     glm::vec3 _scale); 

   void updateUniformBuffer(Camera camera); 

   void draw(); 

   void destroy(); 

private: 

   GraphicsPipeline gPipeline; 
   ObjectBuffers objBuffers; 
   Descriptor descriptor; 

   glm::vec3 position; 
   glm::vec3 scale; 

};

我们将包含描述符、管线和对象缓冲区头文件,因为它们对于类是必需的。在类的public部分,我们将添加三个类的对象来定义管线、对象缓冲区和描述符。我们添加四个函数:

  • 第一个函数是createObjectRenderer函数,它接受模型类型、对象需要创建的位置以及对象的比例。

  • 然后,我们有了updateUniformBuffer函数,它将在每一帧更新统一缓冲区并将其传递给着色器。这个函数以相机作为参数,因为它需要获取视图和透视矩阵。因此,还需要包含相机头文件。

  • 然后我们有draw函数,它将被用来绑定管线、顶点、索引和描述符以进行绘制调用。

  • 我们还有一个destroy函数来调用管线、描述符和对象缓冲区的destroy函数。

在对象的Renderer.cpp文件中,添加以下includecreateObjectRenderer函数:

#include "ObjectRenderer.h" 
#include "VulkanContext.h" 
void ObjectRenderer::createObjectRenderer(MeshType modelType, glm::vec3 _position, glm::vec3 _scale){ 

uint32_t swapChainImageCount = VulkanContext::getInstance()->
                               getSwapChain()->swapChainImages.size(); 

VkExtent2D swapChainImageExtent = VulkanContext::getInstance()->
                                  getSwapChain()->swapChainImageExtent; 

   // Create Vertex, Index and Uniforms Buffer; 
   objBuffers.createVertexIndexUniformsBuffers(modelType); 

   // CreateDescriptorSetLayout 
     descriptor.createDescriptorLayoutSetPoolAndAllocate
     (swapChainImageCount); 
     descriptor.populateDescriptorSets(swapChainImageCount,
     objBuffers.uniformBuffers); 

   // CreateGraphicsPipeline 
   gPipeline.createGraphicsPipelineLayoutAndPipeline( 
       swapChainImageExtent, 
       descriptor.descriptorSetLayout, 
       VulkanContext::getInstance()->getRenderpass()->renderPass); 

   position = _position; 
   scale = _scale; 

 } 

我们获取交换缓冲区图像的数量及其范围。然后,我们创建顶点索引和统一缓冲区,创建并填充描述符集布局和集,然后创建图形管线本身。最后,我们设置当前对象的位置和缩放。然后,我们添加updateUniformBuffer函数。为了访问SwapChainRenderPass,我们将对VulkanContext类进行一些修改:

void ObjectRenderer::updateUniformBuffer(Camera camera){ 

   UniformBufferObject ubo = {}; 

   glm::mat4 scaleMatrix = glm::mat4(1.0f); 
   glm::mat4 rotMatrix = glm::mat4(1.0f); 
   glm::mat4 transMatrix = glm::mat4(1.0f); 

   scaleMatrix = glm::scale(glm::mat4(1.0f), scale); 
   transMatrix = glm::translate(glm::mat4(1.0f), position); 

   ubo.model = transMatrix * rotMatrix * scaleMatrix; 

   ubo.view = camera.viewMatrix 

   ubo.proj = camera.getprojectionMatrix 

   ubo.proj[1][1] *= -1; // invert Y, in Opengl it is inverted to
                            begin with 

   void* data; 
   vkMapMemory(VulkanContext::getInstance()->getDevice()->
     logicalDevice, objBuffers.uniformBuffersMemory, 0, 
     sizeof(ubo), 0, &data); 

   memcpy(data, &ubo, sizeof(ubo)); 

   vkUnmapMemory(VulkanContext::getInstance()->getDevice()->
     logicalDevice, objBuffers.uniformBuffersMemory); 

}

在这里,我们创建一个新的UniformBufferObject结构体,称为ubo。为此,初始化平移、旋转和缩放矩阵。然后,我们为缩放和旋转矩阵分配值。然后,我们将缩放、旋转和平移矩阵相乘的结果分配给模型矩阵。从camera类中,我们将视图和投影矩阵分配给ubo.viewubo.proj。然后,我们必须在投影空间中反转y轴,因为在 OpenGL 中,y轴已经反转了。现在,我们将更新的ubo结构体复制到统一缓冲区内存中。

接下来是draw函数:

void ObjectRenderer::draw(){ 

   VkCommandBuffer cBuffer = VulkanContext::getInstance()->
                             getCurrentCommandBuffer(); 

   // Bind the pipeline 
   vkCmdBindPipeline(cBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, 
     gPipeline.graphicsPipeline); 

   // Bind vertex buffer to command buffer 
   VkBuffer vertexBuffers[] = { objBuffers.vertexBuffer }; 
   VkDeviceSize offsets[] = { 0 }; 

   vkCmdBindVertexBuffers(cBuffer, 
         0, // first binding index 
         1, // binding count 
         vertexBuffers, 
         offsets); 

   // Bind index buffer to the command buffer 
   vkCmdBindIndexBuffer(cBuffer, 
         objBuffers.indexBuffer, 
         0, 
         VK_INDEX_TYPE_UINT32); 

   //    Bind uniform buffer using descriptorSets 
   vkCmdBindDescriptorSets(cBuffer, 
         VK_PIPELINE_BIND_POINT_GRAPHICS, 
         gPipeline.pipelineLayout, 
         0, 
         1, 
         &descriptor.descriptorSet, 0, nullptr); 

   vkCmdDrawIndexed(cBuffer, 
         static_cast<uint32_t>(objBuffers.indices.size()), // no of indices 
         1, // instance count -- just the 1 
         0, // first index -- start at 0th index 
         0, // vertex offset -- any offsets to add 
         0);// first instance -- since no instancing, is set to 0  

} 

在我们实际进行绘制调用之前,我们必须绑定图形管道,并通过命令缓冲区传入顶点、索引和描述符。为此,我们获取当前命令缓冲区并通过它传递命令。我们还将修改VulkanContext类以获取对它的访问权限。

我们使用vkCmdDrawIndexed进行绘制调用,其中我们传入当前命令缓冲区、索引大小、实例计数、索引的起始位置(为0)、顶点偏移(再次为0)和第一个索引的位置(为0)。然后,我们添加destroy函数,该函数基本上只是调用管道、描述符和对象缓冲区的destroy函数:

 void ObjectRenderer::destroy() { 

   gPipeline.destroy(); 
   descriptor.destroy(); 
   objBuffers.destroy(); 

} 

VulkanContext 类的更改

为了获取对SwapChainRenderPass和当前命令缓冲区的访问权限,我们将在VulkanContext.h文件中VulkanContext类的public部分添加以下函数:

   void drawBegin(); 
   void drawEnd(); 
   void cleanup(); 

   SwapChain* getSwapChain(); 
   Renderpass* getRenderpass(); 
   VkCommandBuffer getCurrentCommandBuffer();  

然后,在VulkanContext.cpp文件中,添加访问值的实现:

SwapChain * VulkanContext::getSwapChain() { 

   return swapChain; 
} 

Renderpass * VulkanContext::getRenderpass() { 

   return renderPass; 
} 

VkCommandBuffer VulkanContext::getCurrentCommandBuffer() { 

   return curentCommandBuffer; 
} 

摄像机类

我们将创建一个基本的摄像机类,以便我们可以设置摄像机的位置并设置视图和投影矩阵。这个类将与为 OpenGL 项目创建的摄像机类非常相似。camera.h文件如下:

#pragma once 

#define GLM_FORCE_RADIAN 
#include <glm\glm.hpp> 
#include <glm\gtc\matrix_transform.hpp> 

class Camera 
{ 
public: 

   void init(float FOV, float width, float height, float nearplane, 
      float farPlane); 

void setCameraPosition(glm::vec3 position); 
   glm::mat4 getViewMatrix(); 
   glm::mat4 getprojectionMatrix(); 

private: 

   glm::mat4 projectionMatrix; 
   glm::mat4 viewMatrix; 
   glm::vec3 cameraPos; 

};

它有一个init函数,该函数接受视口的FOV、宽度和高度以及近平面和远平面来构建投影矩阵。我们有一个setCameraPosition函数,用于设置摄像机的位置,以及两个getter函数来获取摄像机视图和投影矩阵。在private部分,我们有三个局部变量:两个用于存储投影和视图矩阵,第三个是一个vec3用于存储摄像机的位置。

Camera.cpp文件如下:

#include "Camera.h" 

void Camera::init(float FOV, float width, float height, float nearplane, 
   float farPlane) { 

   cameraPos = glm::vec3(0.0f, 0.0f, 4.0f); 
   glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, 0.0f); 
   glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f); 

   viewMatrix = glm::mat4(1.0f); 
   projectionMatrix = glm::mat4(1.0f); 

   projectionMatrix = glm::perspective(FOV, width / height, nearplane, 
                      farPlane); 
   viewMatrix = glm::lookAt(cameraPos, cameraFront, cameraUp); 

} 

glm::mat4 Camera::getViewMatrix(){ 

   return viewMatrix; 

} 
glm::mat4 Camera::getprojectionMatrix(){ 

   return projectionMatrix; 
} 

void Camera::setCameraPosition(glm::vec3 position){ 

   cameraPos = position; 
} 

init函数中,我们设置视图和投影矩阵,然后添加两个getter函数和setCameraPosition函数。

绘制对象

现在我们已经完成了先决条件,让我们绘制一个三角形:

  1. source.cpp中包含Camera.hObjectRenderer.h
#define GLFW_INCLUDE_VULKAN 
#include<GLFW/glfw3.h> 

#include "VulkanContext.h" 

#include "Camera.h" 
#include "ObjectRenderer.h" 
  1. main函数中,初始化VulkanContext之后,创建一个新的摄像机和一个要渲染的对象,如下所示:
int main() { 

   glfwInit(); 

   glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); 
   glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); 

   GLFWwindow* window = glfwCreateWindow(1280, 720, 
                        "HELLO VULKAN ", nullptr, nullptr); 

   VulkanContext::getInstance()->initVulkan(window); 

   Camera camera; 
   camera.init(45.0f, 1280.0f, 720.0f, 0.1f, 10000.0f); 
   camera.setCameraPosition(glm::vec3(0.0f, 0.0f, 4.0f)); 

   ObjectRenderer object; 
   object.createObjectRenderer(MeshType::kTriangle, 
         glm::vec3(0.0f, 0.0f, 0.0f), 
         glm::vec3(0.5f)); 
  1. while循环中,更新对象的缓冲区并调用object.draw函数:
  while (!glfwWindowShouldClose(window)) { 

         VulkanContext::getInstance()->drawBegin(); 

         object.updateUniformBuffer(camera); 
         object.draw(); 

         VulkanContext::getInstance()->drawEnd(); 

         glfwPollEvents(); 
   }               
  1. 当程序完成时,调用object.destroy函数:
   object.destroy(); 

   VulkanContext::getInstance()->cleanup(); 

   glfwDestroyWindow(window); 
   glfwTerminate(); 

   return 0; 
} 
  1. 运行应用程序并看到一个辉煌的三角形,如下所示:

图片

哇哦!终于有一个三角形了。嗯,我们还没有完全完成。还记得我们一直遇到的讨厌的验证层错误吗?看看:

图片

是时候了解为什么我们会得到这个错误以及它实际上意味着什么。这把我们带到了这本书的最后一个主题:同步。

同步对象

绘图的过程实际上是异步的,这意味着 GPU 可能必须等待 CPU 完成当前工作。例如,使用常量缓冲区,我们向 GPU 发送指令以更新模型视图投影矩阵的每一帧。现在,如果 GPU 不等待 CPU 获取当前帧的统一缓冲区,则对象将无法正确渲染。

为了确保 GPU 仅在 CPU 完成其工作后执行,我们需要同步 CPU 和 GPU。这可以通过两种类型的同步对象来完成:

  • 第一类是栅栏。栅栏是同步 CPU 和 GPU 操作的对象。

  • 我们还有一种同步对象,称为信号量。信号量对象同步 GPU 队列。在我们当前渲染的单个三角形场景中,图形队列提交所有图形命令,然后显示队列获取图像并将其呈现到视口。当然,这同样需要同步;否则,我们将看到尚未完全渲染的场景。

此外还有事件和屏障,它们是用于在命令缓冲区或一系列命令缓冲区内部同步工作的其他类型的同步对象。

由于我们没有使用任何同步对象,Vulkan 验证层正在抛出错误并告诉我们,当我们从 SwapChain 获取图像时,我们需要使用栅栏或信号量来同步它。

VulkanContext.h中的private部分,我们将添加要创建的同步对象,如下所示:

   const int MAX_FRAMES_IN_FLIGHT = 2; 
   VkSemaphore imageAvailableSemaphore;  
   VkSemaphore renderFinishedSemaphore;  
   std::vector<VkFence> inFlightFences;  

我们创建了两个信号量:一个用于在图像可供我们渲染时发出信号,另一个用于在图像渲染完成后发出信号。我们还创建了两个栅栏来同步两个帧。在VulkanContext.cpp中的initVulkan函数下,在创建DrawCommandBuffer对象之后创建Synchronization对象:

   drawComBuffer = new DrawCommandBuffer(); 
   drawComBuffer->createCommandPoolAndBuffer(swapChain->
      swapChainImages.size()); 

   // Synchronization 

   VkSemaphoreCreateInfo semaphoreInfo = {}; 
   semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; 

   vkCreateSemaphore(device->logicalDevice, &semaphoreInfo, 
      nullptr, &imageAvailableSemaphore); 
   vkCreateSemaphore(device->logicalDevice, &semaphoreInfo, 
      nullptr, &renderFinishedSemaphore); 

   inFlightFences.resize(MAX_FRAMES_IN_FLIGHT); 

   VkFenceCreateInfo fenceCreateInfo = {}; 
   fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; 
   fenceCreateInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; 

   for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { 

         if (vkCreateFence(device->logicalDevice, &fenceCreateInfo, 
           nullptr, &inFlightFences[i]) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create synchronization 
            objects per frame !!"); 
         } 
   } 

我们首先使用semaphoreCreateInfo结构体创建了信号量。我们只需设置结构体类型;我们可以使用vkCreateSemaphore函数创建它,并传递逻辑设备和信息结构体。

接下来,我们创建我们的栅栏。我们将向量的大小调整为正在飞行的帧数,即2。然后,我们创建fenceCreateInfo结构体并设置其类型。现在我们还发出信号,使栅栏准备好渲染。然后,我们使用vkCreateFence创建栅栏,并通过for循环传递逻辑设备和创建栅栏的信息。在DrawBegin函数中,当我们获取图像时,我们将imageAvailable信号量传递给函数,以便当图像可供我们渲染时,信号量将被发出:

   vkAcquireNextImageKHR(device->logicalDevice, 
         swapChain->swapChain, 
         std::numeric_limits<uint64_t>::max(), 
         imageAvailableSemaphore, // is  signaled 
         VK_NULL_HANDLE, 
         &imageIndex); 

一旦图像可供渲染,我们等待栅栏发出信号,以便我们可以开始编写我们的命令缓冲区:

vkWaitForFences(device->logicalDevice, 1, &inFlightFences[imageIndex], 
   VK_TRUE, std::numeric_limits<uint64_t>::max());

我们通过调用 vkWaitForFences 来等待栅栏,并传入逻辑设备、栅栏计数(为 1)以及栅栏本身。然后,我们传入 TRUE 以等待所有栅栏,并传入超时时间。一旦栅栏可用,我们通过调用 vkResetFence 将其设置为未触发状态,然后传入逻辑设备、栅栏计数和栅栏:

  vkResetFences(device->logicalDevice, 1, &inFlightFences[imageIndex]);  

DrawBegin 函数的重置保持不变,这样我们就可以开始记录命令缓冲区。现在,在 DrawEnd 函数中,当提交命令缓冲区的时间到来时,我们设置 imageAvailableSemaphore 的管道阶段以等待,并将 imageAvailableSemaphore 设置为等待。我们将 renderFinishedSemaphore 设置为触发状态。

submitInfo 结构体相应地进行了更改:

   // submit command buffer 
   VkSubmitInfo submitInfo = {}; 
   submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; 
   submitInfo.commandBufferCount = 1; 
   submitInfo.pCommandBuffers = &currentCommandBuffer; 

   // Wait for the stage that writes to color attachment 
   VkPipelineStageFlags waitStages[] = { VK_PIPELINE*STAGE*COLOR_
                                       ATTACHMENT_OUTPUT_BIT }; 
   // Which stage of the pipeline to wait 
   submitInfo.pWaitDstStageMask = waitStages; 

   // Semaphore to wait on before submit command execution begins 
   submitInfo.waitSemaphoreCount = 1; 
   submitInfo.pWaitSemaphores = &imageAvailableSemaphore;   

   // Semaphore to be signaled when command buffers have completed 
   submitInfo.signalSemaphoreCount = 1; 
   submitInfo.pSignalSemaphores = &renderFinishedSemaphore; 

等待的阶段是 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,以便 imageAvailableSemaphore 从未触发状态变为触发状态。这将在颜色缓冲区被写入时触发。然后,我们将 renderFinishedSemaphore 设置为触发状态,以便图像准备好进行展示。提交命令并传入栅栏以显示提交已完成:

vkQueueSubmit(device->graphicsQueue, 1, &submitInfo, inFlightFences[imageIndex]);

一旦提交完成,我们就可以展示图像。在 presentInfo 结构体中,我们将 renderFinishedSemaphore 设置为等待从未触发状态变为触发状态。我们这样做是因为,当信号量被触发时,图像将准备好进行展示:

   // Present frame 
   VkPresentInfoKHR presentInfo = {}; 
   presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; 

   presentInfo.waitSemaphoreCount = 1; 
   presentInfo.pWaitSemaphores = &renderFinishedSemaphore;  

   presentInfo.swapchainCount = 1; 
   presentInfo.pSwapchains = &swapChain->swapChain; 
   presentInfo.pImageIndices = &imageIndex; 

   vkQueuePresentKHR(device->presentQueue, &presentInfo); 
   vkQueueWaitIdle(device->presentQueue);  

VulkanContext 中的 cleanup 函数中,确保销毁信号量和栅栏,如下所示:

 void VulkanContext::cleanup() { 

   vkDeviceWaitIdle(device->logicalDevice); 

   vkDestroySemaphore(device->logicalDevice, 
      renderFinishedSemaphore, nullptr); 
   vkDestroySemaphore(device->logicalDevice, 
      imageAvailableSemaphore, nullptr); 

   // Fences and Semaphores 
   for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { 

        vkDestroyFence(device->logicalDevice, inFlightFences[i], nullptr); 
   } 

   drawComBuffer->destroy(); 
   renderTarget->destroy(); 
   renderPass->destroy(); 
   swapChain->destroy(); 

   device->destroy(); 

   valLayersAndExt->destroy(vInstance->vkInstance, isValidationLayersEnabled); 

   vkDestroySurfaceKHR(vInstance->vkInstance, surface, nullptr);   
   vkDestroyInstance(vInstance->vkInstance, nullptr); 

} 

现在,以调试模式构建并运行应用程序,看看验证层是否停止了抱怨:

现在,通过更改 source.cpp 文件,绘制其他对象,例如四边形、立方体和球体:

#define GLFW_INCLUDE_VULKAN
#include<GLFW/glfw3.h>
#include "VulkanContext.h"
#include "Camera.h"
#include "ObjectRenderer.h"
int main() {

glfwInit();

glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);

GLFWwindow* window = glfwCreateWindow(1280, 720, "HELLO VULKAN ", 
                     nullptr, nullptr);
VulkanContext::getInstance()->initVulkan(window);

Camera camera;
camera.init(45.0f, 1280.0f, 720.0f, 0.1f, 10000.0f);
camera.setCameraPosition(glm::vec3(0.0f, 0.0f, 4.0f));
ObjectRenderer tri;
tri.createObjectRenderer(MeshType::kTriangle,
glm::vec3(-1.0f, 1.0f, 0.0f),
glm::vec3(0.5f));

ObjectRenderer quad;
quad.createObjectRenderer(MeshType::kQuad,
glm::vec3(1.0f, 1.0f, 0.0f),
glm::vec3(0.5f));

ObjectRenderer cube;
cube.createObjectRenderer(MeshType::kCube,
glm::vec3(-1.0f, -1.0f, 0.0f),
glm::vec3(0.5f));

ObjectRenderer sphere;
sphere.createObjectRenderer(MeshType::kSphere,
glm::vec3(1.0f, -1.0f, 0.0f),
glm::vec3(0.5f));

while (!glfwWindowShouldClose(window)) {

VulkanContext::getInstance()->drawBegin();

// updatetri.updateUniformBuffer(camera);
quad.updateUniformBuffer(camera);
cube.updateUniformBuffer(camera);
sphere.updateUniformBuffer(camera);

// draw command
tri.draw();
quad.draw();
cube.draw();
sphere.draw();
VulkanContext::getInstance()->drawEnd();
glfwPollEvents();
}
tri.destroy();
quad.destroy();
cube.destroy();
sphere.destroy();
VulkanContext::getInstance()->cleanup();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}

这应该是最终的输出,其中所有对象都已渲染:

你还可以添加可以从文件加载的自定义几何形状。

此外,现在你有不同的形状可以渲染,可以添加物理效果并尝试复制在 OpenGL 中制作的物理游戏,并将游戏移植到使用 Vulkan 渲染 API。

此外,代码可以扩展以包括深度缓冲区,向对象添加纹理等。

摘要

因此,这是本书的最终总结。在这本书中,我们从在 Simple and Fast Multimedia Library (SFML) 中创建一个基本游戏开始,该库使用 OpenGL 进行渲染,然后展示了在制作游戏时渲染 API 如何融入整个方案。

我们然后从头开始创建了一个基于物理的完整游戏,使用我们自己的小型游戏引擎。除了使用高级 OpenGL 图形 API 绘制对象外,我们还添加了 bullet 物理引擎来处理游戏物理和游戏对象之间的碰撞检测。我们还添加了一些文本渲染,以便玩家可以看到分数,并且我们还学习了基本的照明,以便为我们的小场景进行照明计算,使场景更加有趣。

最后,我们转向了 Vulkan 渲染 API,这是一个低级图形库。与我们在第三章“设置你的游戏”结束时使用 OpenGL 制作的小游戏相比,在 Vulkan 中,我们到第四章结束时就能渲染基本的几何对象。然而,与 Vulkan 相比,我们能够完全访问 GPU,这给了我们更多的自由度来根据游戏需求定制引擎。

如果你已经走到这一步,那么恭喜你!我希望你喜欢阅读这本书,并且你将继续扩展你对 SFML、OpenGL 和 Vulkan 项目的知识。

祝好运。

进一步阅读

想要了解更多,我强烈推荐访问 Vulkan 教程网站:vulkan-tutorial.com/。该教程还涵盖了如何添加纹理、深度缓冲区、模型加载和米柏映射。本书的代码基于这个教程,因此应该很容易跟随,并有助于你进一步学习书中的 Vulkan 代码库:

图片

Doom 3 Vulkan 渲染器的源代码可在github.com/DustinHLand/vkDOOM3找到——看到实际应用的代码很有趣:

图片

我还推荐阅读www.fasterthan.life/blog上的博客,因为它讲述了将 Doom 3 OpenGL 代码移植到 Vulkan 的过程。在这本书中,我们让 Vulkan 负责分配和释放资源。该博客详细介绍了 Vulkan 中内存管理的实现方式。

posted @ 2025-10-07 17:59  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报