精通-SFML-游戏开发-全-
精通 SFML 游戏开发(全)
原文:
zh.annas-archive.org/md5/db6f393e4cdf77be54bbd2304d4ca8a2译者:飞龙
前言
从零开始设计一款游戏可能是最艰难的旅程之一。投入其中的工作量之大,将其与造车相提并论并不夸张。它结合了许多不同的专业领域,这些领域在其他情况下是不会重叠的,这意味着背后的智囊团往往也必须扮演“万事通”的角色。很少有其他类型的项目能声称找到了一种方法,将高级光照计算、精确的物理模拟以及一个完整、稳定且自给自足的经济模型结合成一个统一体。这些都是必须掌握的一些“技艺”之一,而在快速发展的游戏世界中,新的技艺不断涌现。随着时间的推移,在所有噪音中,一些模式逐渐开始显现。现在,几代人都有机会接触游戏,其中一些人甚至不知道没有游戏的世界,公众意识中开始形成某些期望。昨天令人叹为观止的发明和技术演示已成为今天的普通功能,成为照亮明天的灯塔。跟上这些功能,不被黑暗所抛弃,这就是今天优秀游戏开发者所做的事情,这正是我们所处的位置。虽然这本书不会教你一切,但它会通过不仅扩展你的技术和思想库,而且设定一个清晰的目标,这个目标将始终朝着更大、更好的方向发展,从而为你提供一个优势。
随着前两章的飞逝,你将学习如何设置一个基本但强大的角色扮演游戏(RPG)风格游戏,该游戏基于当今游戏中使用的灵活架构构建。然后,我们将为该游戏添加额外的图形效果,因为我们将介绍构建一个高效且易于扩展的粒子系统,它具有许多不同的图形选项。随后,你将了解创建自定义工具(如地图编辑器)的实用性和好处,这些工具可以修改和管理你的游戏资产。在开始完全移除 SFML 的旅程之前,我们还将涉及 SFML 着色器的使用,这一旅程在第七章, 一步向前,一级向下 - 集成 OpenGL,通过使用原始 OpenGL 并在屏幕上自行渲染某些内容来实现。这之后,我们将探索和实现高级照明技术,例如法线贴图和镜面反射贴图,以动态灯光真正为游戏场景增添图形上的活力。当然,没有不投射阴影的光,这就是为什么第九章, 黑暗的速度 - 照明和阴影,涵盖了并实现了 3D 中的阴影映射,使我们能够拥有逼真的、三维的阴影。所有这些都将通过为游戏进行最终优化来完成,这不仅会使游戏尽可能快地运行,而且还会为你提供所有必要的工具和技能,以便在未来继续进行改进。
尽管这本书旨在激发你成为“万事通”,但它也会通过使你的游戏看起来和运行得尽可能好,让你成为某些领域的专家。我们面前还有很长的路要走,所以请确保你带上你的雄心壮志,希望我们能在终点线再次相见。祝你好运!
本书涵盖的内容
第一章, 内部结构 – 设置后端,涵盖了将为我们游戏提供动力的几个底层架构的使用。
第二章, 游戏时间! – 设计项目,参与构建和运行书中所述的游戏项目,使用第一章, 内部结构 – 设置后端中设置的架构。
第三章, 让它下雨! – 构建粒子系统,处理实现一个高效且可扩展的粒子系统的复杂性。
第四章, 准备好你的装备 - 构建游戏工具,通过设置后端来启动构建自定义游戏工具的过程。
第五章, 填充工具包 - 一些额外的工具,完成了可用来放置、编辑以及其他方式操作地图瓦片、实体和粒子发射器的地图编辑器的实现。
第六章, 添加一些收尾工作 - 使用着色器,解释并使用重新架构的渲染器,通过在我们的游戏中实现日夜循环,允许轻松使用着色器。
第七章, 向前迈一步,向下提升一级 - OpenGL 基础,深入到使用原始 OpenGL 的技术细节中,引导我们通过渲染基本形状、纹理化它们以及创建在世界中移动的手段。
第八章, 有光 - 高级光照的介绍,介绍了并应用了在三维空间中照亮我们的游戏世界的概念,使用法线图添加额外细节的错觉,并添加镜面高光以创建发光表面。
第九章, 黑暗的速度 - 灯光和阴影,通过实现同时向所有方向投射的动态、三维、点光源阴影来扩展灯光引擎。
第十章, 不应跳过的章节 - 最终优化,通过使我们的游戏运行速度提高许多倍,并为你提供进一步优化的工具来结束本书。
你需要为这本书准备的东西
首先且最重要的是,需要一个支持新 C++标准的编译器。实际上的 SFML 库也是必需的,因为它为我们构建的游戏提供动力。第七章及以后的章节需要 GLEW 和 GLM 库的最新版本。本书中可能用到的任何其他工具都在它们使用的相应章节中提到了。
本书面向的对象
这本书是为初学者游戏开发者而写的,他们已经具备一些基本的 SFML 知识、现代 C++的中级技能,并且已经独立构建了一个或多个游戏,无论它们多么简单。现代 OpenGL 的知识不是必需的,但可能是一个加分项。
习惯用法
在本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"我们可以通过使用 include 指令来包含其他上下文。"
代码块设置如下:
class Observer{
public:
virtual ~Observer(){}
virtual void Notify(const Message& l_message) = 0;
};
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
using Subscribtions =
std::unordered_map<EntityMessage,Communicator>;
新术语和重要词汇以粗体显示。屏幕上、菜单或对话框等地方看到的词汇在文本中显示如下:“点击下一步按钮将您带到下一屏幕”。
注意
警告或重要注意事项以这样的框显示。
小贴士
小技巧和窍门以这样的形式出现。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的账户中下载此书的示例代码文件。www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击代码下载与错误清单。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保您使用最新版本解压缩或提取文件夹,如下所示:
-
适用于 Windows 的 WinRAR / 7-Zip
-
适用于 Mac 的 Zipeg / iZip / UnRarX
-
适用于 Linux 的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-SFML-Game-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。查看它们!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/MasteringSFMLGameDevelopment_ColorImages.pdf下载此文件。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
在互联网上侵犯版权材料是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。
第一章. 内部结构 - 设置后端
简介
任何软件的核心是什么?在构建一个完整规模的项目过程中,这个问题的答案会逐渐显现出来,这本身可能是一项艰巨的任务,尤其是从零开始。是后端的设计和能力,要么通过利用其力量全力推动游戏前进,要么因为未实现的能力而使游戏陷入默默无闻。在这里,我们将讨论保持任何项目持续运行和站立的基础。
在本章中,我们将涵盖以下主题:
-
Windows 和 Linux 操作系统的实用函数和文件系统特定信息
-
实体组件系统模式的基本原理
-
窗口、事件和资源管理技术
-
创建和维护应用程序状态
-
图形用户界面基础
-
2D RPG 游戏项目的必备要素
有很多内容要介绍,所以我们不要浪费时间!
速度和源代码示例
我们将要讨论的所有系统都可以有整本书来专门介绍。由于时间和纸张都有限,我们只会简要回顾它们的基本原理,这足以让我们对这里提供的信息感到舒适。
注意
请记住,尽管我们在这章中不会深入细节,但本书附带的代码是一个很好的资源,可以查阅和实验以获得更多细节和熟悉度。强烈建议在阅读本章时回顾它,以便全面掌握。
常用实用函数
让我们从查看一个常见的函数开始,这个函数将用于确定我们的可执行文件所在的目录的完整绝对路径。不幸的是,在所有平台上都没有统一的方法来做这件事,所以我们将不得不为每个平台实现这个实用函数的版本,从 Windows 开始:
#ifdef RUNNING_WINDOWS
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <Shlwapi.h>
首先,我们检查RUNNING_WINDOWS宏是否定义。这是一种基本技术,可以用来让代码库的其余部分知道它正在运行哪个操作系统。接下来,我们定义了另一个特定的定义,针对我们包含的 Windows 头文件。这大大减少了在过程中包含的其他头文件的数量。
在包含了 Windows OS 的所有必要头文件后,让我们看看实际函数是如何实现的:
inline std::string GetWorkingDirectory()
{
HMODULE hModule = GetModuleHandle(nullptr);
if (!hModule) { return ""; }
char path[256];
GetModuleFileName(hModule,path,sizeof(path));
PathRemoveFileSpec(path);
strcat_s(path,"");
return std::string(path);
}
首先,我们获取由我们的可执行文件创建的进程句柄。在构建并填充了临时路径缓冲区、路径字符串、名称和扩展名之后,我们移除可执行文件的名字和扩展名。然后,我们在路径末尾添加一个尾随斜杠,并将其作为std::string返回。
如果有一种方法可以获取指定目录内文件列表,那将非常有用:
inline std::vector<std::string> GetFileList(
const std::string& l_directory,
const std::string& l_search = "*.*")
{
std::vector<std::string> files;
if(l_search.empty()) { return files; }
std::string path = l_directory + l_search;
WIN32_FIND_DATA data;
HANDLE found = FindFirstFile(path.c_str(), &data);
if (found == INVALID_HANDLE_VALUE) { return files; }
do{
if (!(data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY))
{
files.emplace_back(data.cFileName);
}
}while (FindNextFile(found, &data));
FindClose(found);
return files;
}
就像目录函数一样,这也是 Windows 特有的。它返回一个表示文件名和扩展名的字符串向量。一旦构建完成,就会拼接一个路径字符串。l_search参数提供了一个默认值,以防未指定。默认情况下列出所有文件。
在创建一个将保存我们的搜索数据的结构之后,我们将其传递给另一个 Windows 特定函数,该函数将找到目录中的第一个文件。其余的工作在do-while循环中完成,该循环检查找到的项目实际上是否不是目录。然后适当的项被推入一个向量,稍后返回。
Linux 版本
如前所述,这两个先前的函数仅在 Windows 上有效。为了添加对基于 Linux 的操作系统运行系统的支持,我们需要以不同的方式实现它们。让我们首先包括适当的头文件:
#elif defined RUNNING_LINUX
#include <unistd.h>
#include <dirent.h>
幸运的是,Linux 确实提供了一个单次调用的解决方案来找到我们的可执行文件的确切位置:
inline std::string GetWorkingDirectory()
{
char cwd[1024];
if(!getcwd(cwd, sizeof(cwd))){ return ""; }
return std::string(cwd) + std::string("/");
}
注意,我们仍然在末尾添加一个尾随斜杠。
获取特定目录的文件列表这次稍微复杂一些:
inline std::vector<std::string> GetFileList(
const std::string& l_directory,
const std::string& l_search = "*.*")
{
std::vector<std::string> files;
DIR *dpdf;
dpdf = opendir(l_directory.c_str());
if (!dpdf) { return files; }
if(l_search.empty()) { return files; }
std::string search = l_search;
if (search[0] == '*') { search.erase(search.begin()); }
if (search[search.length() - 1] == '*') { search.pop_back(); }
struct dirent *epdf;
while (epdf = readdir(dpdf)) {
std::string name = epdf->d_name;
if (epdf->d_type == DT_DIR) { continue; }
if (l_search != "*.*") {
if (name.length() < search.length()) { continue; }
if (search[0] == '.') {
if (name.compare(name.length() - search.length(),
search.length(), search) != 0)
{ continue; }
} else if (name.find(search) == std::string::npos) {
continue;
}
}
files.emplace_back(name);
}
closedir(dpdf);
return files;
}
我们以之前相同的方式开始,通过创建一个字符串向量。然后通过opendir()函数获取目录流指针。如果它不是NULL,我们就开始修改搜索字符串。与更花哨的 Windows 替代方案不同,我们不能仅仅将一个搜索字符串传递给一个函数,然后让操作系统完成所有的匹配。在这种情况下,它更接近于匹配返回的文件名中的特定搜索字符串,因此需要剪除表示任何含义的星号符号。
接下来,我们利用readdir()函数在一个while循环中,该循环将逐个返回目录条目结构的指针。我们还想排除文件列表中的任何目录,因此检查条目的类型是否不等于DT_DIR。
最后,开始字符串匹配。假设我们不是在寻找任何具有任何扩展名的文件(用"*.*"表示),将首先根据长度比较条目的名称与搜索字符串。如果我们要搜索的字符串长度比文件名本身长,那么可以安全地假设我们没有匹配。否则,将再次分析搜索字符串以确定文件名对于正匹配是否重要。如果第一个字符是点,则表示它不重要,因此将文件名与搜索字符串相同长度的末尾部分与搜索字符串本身进行比较。然而,如果名称很重要,我们只需在文件名中搜索搜索字符串。
一旦程序完成,目录被关闭,表示文件的字符串向量被返回。
其他一些辅助函数
有时候,在读取文本文件时,能够获取一个包含空格的字符串,同时仍然保持空白分隔符,这很方便。在这种情况下,我们可以使用引号以及这个特殊函数,它帮助我们从一个空白分隔的文件中读取整个引号部分:
inline void ReadQuotedString(std::stringstream& l_stream,
std::string& l_string)
{
l_stream >> l_string;
if (l_string.at(0) == '"'){
while (l_string.at(l_string.length() - 1) != '"' ||
!l_stream.eof())
{
std::string str;
l_stream >> str;
l_string.append(" " + str);
}
}
l_string.erase(std::remove(
l_string.begin(), l_string.end(), '"'), l_string.end());
}
流的第一个部分被输入到参数字符串中。如果它确实以双引号开头,则会启动一个while循环,将字符串附加到其中,直到它以另一个双引号结束,或者直到流到达末尾。最后,从字符串中删除所有双引号,得到最终结果。
插值是程序员工具箱中的另一个有用工具。想象一下,在两个不同时间点有两个不同的值,然后想要预测在这两个时间框架之间的某个点值会是什么。这个简单的计算使得这成为可能:
template<class T>
inline T Interpolate(float tBegin, float tEnd,
const T& begin_val, const T& end_val, float tX)
{
return static_cast<T>((
((end_val - begin_val) / (tEnd - tBegin)) *
(tX - tBegin)) + begin_val);
}
接下来,让我们看看几个可以帮助我们更好地居中sf::Text实例的函数:
inline float GetSFMLTextMaxHeight(const sf::Text& l_text) {
auto charSize = l_text.getCharacterSize();
auto font = l_text.getFont();
auto string = l_text.getString().toAnsiString();
bool bold = (l_text.getStyle() & sf::Text::Bold);
float max = 0.f;
for (size_t i = 0; i < string.length(); ++i) {
sf::Uint32 character = string[i];
auto glyph = font->getGlyph(character, charSize, bold);
auto height = glyph.bounds.height;
if (height <= max) { continue; }
max = height;
}
return max;
}
inline void CenterSFMLText(sf::Text& l_text) {
sf::FloatRect rect = l_text.getLocalBounds();
auto maxHeight = Utils::GetSFMLTextMaxHeight(l_text);
l_text.setOrigin(
rect.left + (rect.width * 0.5f),
rect.top + ((maxHeight >= rect.height ?
maxHeight * 0.5f : rect.height * 0.5f)));
}
使用 SFML 文本有时可能很棘手,尤其是在居中非常重要的时候。一些字符,根据字体和其他不同属性,实际上可以超过包围sf::Text实例的边界框的高度。为了解决这个问题,第一个函数遍历特定文本实例的每个字符,并获取表示它的字体字形。然后检查其高度并跟踪,以便确定整个文本的最大高度并返回。
第二个函数可以用来设置sf::Text实例的绝对中心作为其原点,以便实现完美的结果。在获取其局部边界框并计算最大高度后,这些信息被用来将我们的文本的原始点移动到其中心。
生成随机数
大多数游戏都依赖于一定程度上的随机性。虽然简单地使用rand()的经典方法可能很有吸引力,但它只能带你走这么远。生成随机负数或浮点数至少不是那么直接,而且它的范围非常糟糕。幸运的是,C++的新版本提供了统一分布和随机数发生器的形式作为答案:
#include <random>
#include <SFML/System/Mutex.hpp>
#include <SFML/System/Lock.hpp>
class RandomGenerator {
public:
RandomGenerator() : m_engine(m_device()){}
...
float operator()(float l_min, float l_max) {
return Generate(l_min, l_max);
}
int operator()(int l_min, int l_max) {
return Generate(l_min, l_max);
}
private:
std::random_device m_device;
std::mt19937 m_engine;
std::uniform_int_distribution<int> m_intDistribution;
std::uniform_real_distribution<float> m_floatDistribution;
sf::Mutex m_mutex;
};
首先,注意include语句。random库为我们提供了生成数字所需的一切。除此之外,我们还将使用 SFML 的互斥锁和锁,以防止我们的代码被多个独立的线程访问时出现混乱。
std::random_device类是一个随机数生成器,用于初始化引擎,该引擎将用于进一步的生成。引擎本身基于Marsenne Twister算法,并产生高质量的随机无符号整数,这些整数可以通过一个均匀分布对象进行过滤,以获得特定范围内的数字。理想情况下,由于构建和销毁这些对象相当昂贵,我们希望保留这个类的单个副本。正因为如此,我们在同一个类中将整数和浮点数分布放在一起。
为了方便起见,圆括号运算符被重载以接受整数和浮点数类型的数字范围。它们调用Generate方法,该方法也被重载以处理这两种数据类型:
int Generate(int l_min, int l_max) {
sf::Lock lock(m_mutex);
if (l_min > l_max) { std::swap(l_min, l_max); }
if (l_min != m_intDistribution.min() ||
l_max != m_intDistribution.max())
{
m_intDistribution =
std::uniform_int_distribution<int>(l_min, l_max);
}
return m_intDistribution(m_engine);
}
float Generate(float l_min, float l_max) {
sf::Lock lock(m_mutex);
if (l_min > l_max) { std::swap(l_min, l_max); }
if (l_min != m_floatDistribution.min() ||
l_max != m_floatDistribution.max())
{
m_floatDistribution =
std::uniform_real_distribution<float>(l_min, l_max);
}
return m_floatDistribution(m_engine);
}
在生成开始之前,我们必须建立一个锁以确保线程安全。因为l_min和l_max值的顺序很重要,我们必须检查提供的值是否没有反转,如果是,则进行交换。此外,如果需要使用不同的范围,必须重建均匀分布对象,因此也设置了相应的检查。最后,在经历了所有这些麻烦之后,我们准备通过利用分布的圆括号运算符来返回随机数,并将引擎实例传递给它。
服务定位器模式
通常,我们的一个或多个类将需要访问我们的代码库的另一个部分。通常,这不是一个大问题。你只需要传递一个或两个指针,或者可能将它们存储为需要类的数据成员。然而,随着代码量的增加,类之间的关系变得越来越复杂。依赖性可能会增加到某个程度,以至于一个特定的类将具有比实际方法更多的参数/设置器。为了方便起见,有时传递单个指针/引用而不是十个更好。这就是服务定位器模式发挥作用的地方:
class Window;
class EventManager;
class TextureManager;
class FontManager;
...
struct SharedContext{
SharedContext():
m_wind(nullptr),
m_eventManager(nullptr),
m_textureManager(nullptr),
m_fontManager(nullptr),
...
{}
Window* m_wind;
EventManager* m_eventManager;
TextureManager* m_textureManager;
FontManager* m_fontManager;
...
};
如您所见,它只是一个包含多个指向我们项目核心类指针的struct。所有这些类都是提前声明的,以避免不必要的include语句,从而减少编译过程的膨胀。
实体组件系统核心
让我们来看看我们的游戏实体将如何表示的本质。为了实现最高的可维护性和代码模块化,最好使用组合。实体组件系统正是如此。为了保持简洁,我们不会深入探讨实现细节。这只是一个为了熟悉后续将使用的代码的快速概述。
ECS 模式由三个基石组成,使其成为可能:实体、组件和系统。理想情况下,实体只是一个标识符,就像一个整数一样简单。组件是包含几乎没有逻辑的数据容器。会有多种类型的组件,如位置、可移动、可绘制等,它们本身并没有太多意义,但组合起来将形成复杂的实体。这种组合将使在任何给定时间保存任何实体的状态变得极其容易。
实现组件的方式有很多。其中一种就是简单地拥有一个基类组件,并从中继承:
class C_Base{
public:
C_Base(const Component& l_type): m_type(l_type){}
virtual ~C_Base(){}
Component GetType() const { return m_type; }
friend std::stringstream& operator >>(
std::stringstream& l_stream, C_Base& b)
{
b.ReadIn(l_stream);
return l_stream;
}
virtual void ReadIn(std::stringstream& l_stream) = 0;
protected:
Component m_type;
};
Component类型只是一个枚举类,列出了我们可以在项目中拥有的不同类型的组件。除此之外,这个基类还提供了一种从字符串流中填充组件数据的方法,以便在读取文件时更容易地加载它们。
为了正确管理属于实体的组件集合,我们需要某种类型的管理类:
class EntityManager{
public:
EntityManager(SystemManager* l_sysMgr,
TextureManager* l_textureMgr);
~EntityManager();
int AddEntity(const Bitmask& l_mask);
int AddEntity(const std::string& l_entityFile);
bool RemoveEntity(const EntityId& l_id);
bool AddComponent(const EntityId& l_entity,
const Component& l_component);
template<class T>
void AddComponentType(const Component& l_id) { ... }
template<class T>
T* GetComponent(const EntityId& l_entity,
const Component& l_component){ ... }
bool RemoveComponent(const EntityId& l_entity,
const Component& l_component);
bool HasComponent(const EntityId& l_entity,
const Component& l_component) const;
void Purge();
private:
...
};
如您所见,这是一种相当基本的处理我们称之为实体的数据集的方法。EntityId数据类型只是一个无符号整数的类型定义。组件的创建是通过利用工厂模式、lambda 表达式和模板来实现的。这个类还负责从可能看起来像这样的文件中加载实体:
Name Player
Attributes 255
|Component|ID|Individual attributes|
Component 0 0 0 1
Component 1 Player
Component 2 0
Component 3 128.0 1024.0 1024.0 1
Component 4
Component 5 20.0 20.0 0.0 0.0 2
Component 6 footstep:1,4
Component 7
Attributes字段是一个位掩码,其值用于确定实体具有哪些组件类型。实际的组件数据也存储在这个文件中,并且通过组件基类的ReadIn方法进行加载。
ECS 设计中最后一部分是系统。这是所有逻辑发生的地方。就像组件一样,可以有负责碰撞、渲染、移动等多种类型的系统。每个系统都必须继承自系统的基类并实现所有纯虚方法:
class S_Base : public Observer{
public:
S_Base(const System& l_id, SystemManager* l_systemMgr);
virtual ~S_Base();
bool AddEntity(const EntityId& l_entity);
bool HasEntity(const EntityId& l_entity) const;
bool RemoveEntity(const EntityId& l_entity);
System GetId() const;
bool FitsRequirements(const Bitmask& l_bits) const;
void Purge();
virtual void Update(float l_dT) = 0;
virtual void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event) = 0;
protected:
...
};
系统具有它们使用的组件签名,以及满足这些签名要求的实体列表。当一个实体通过添加或删除组件被修改时,每个系统都会运行检查,以便将其添加到或从自身中删除。注意从Observer类继承。这是另一种有助于实体和系统之间通信的图案。
一个Observer类本身只是一个接口,包含一个必须由所有派生类实现的纯虚方法:
class Observer{
public:
virtual ~Observer(){}
virtual void Notify(const Message& l_message) = 0;
};
它利用发送给特定目标所有观察者的消息。这个类的派生对象如何响应消息完全取决于它本身。
形状和大小各异的系统需要像实体一样进行管理。为此,我们还有一个管理类:
class SystemManager{
public:
...
template<class T>
void AddSystem(const System& l_system) { ... }
template<class T>
T* GetSystem(const System& l_system){ ... }
void AddEvent(const EntityId& l_entity, const EventID& l_event);
void Update(float l_dT);
void HandleEvents();
void Draw(Window* l_wind, unsigned int l_elevation);
void EntityModified(const EntityId& l_entity,
const Bitmask& l_bits);
void RemoveEntity(const EntityId& l_entity);
void PurgeEntities();
void PurgeSystems();
private:
...
MessageHandler m_messages;
};
这同样利用了工厂模式,通过使用模板和 lambda 来注册不同类型的类,以便稍后可以通过使用System数据类型(它是一个enum class)来构建它们。开始看到模式了吗?
系统管理器拥有一个类型为MessageHandler的数据成员。这是观察者模式的一部分。让我们看看它做了什么:
class MessageHandler{
public:
bool Subscribe(const EntityMessage& l_type,
Observer* l_observer){ ... }
bool Unsubscribe(const EntityMessage& l_type,
Observer* l_observer){ ... }
void Dispatch(const Message& l_msg){ ... }
private:
Subscribtions m_communicators;
};
消息处理器只是Communicator对象的集合,如下所示:
using Subscribtions =
std::unordered_map<EntityMessage,Communicator>;
每种可能的EntityMessage类型(它只是一个enum class)都与一个负责向所有观察者发送消息的通信器相关联。观察者可以订阅或取消订阅特定消息类型。如果他们订阅了该类型,当调用Dispatch方法时,他们将接收到该消息。
Communicator 类本身相当简单:
class Communicator{
public:
virtual ~Communicator(){ m_observers.clear(); }
bool AddObserver(Observer* l_observer){ ... }
bool RemoveObserver(Observer* l_observer){ ... }
bool HasObserver(const Observer* l_observer) const { ... }
void Broadcast(const Message& l_msg){ ... }
private:
ObserverContainer m_observers;
};
如您所知,它支持添加和删除观察者,并提供了一种向所有观察者广播消息的方法。观察者的实际容器只是一个指针的向量:
// Not memory-owning pointers.
using ObserverContainer = std::vector<Observer*>;
资源管理
在较大的项目中,另一个至关重要的部分是有效管理资源的方法。由于我们将拥有几种不同类型的资源,例如纹理、字体和声音,因此为所有这些资源分别拥有单独的管理器是有意义的。是时候有一个基类了:
template<typename Derived, typename T>
class ResourceManager{
public:
ResourceManager(const std::string& l_pathsFile){
LoadPaths(l_pathsFile);
}
virtual ~ResourceManager(){ ... }
T* GetResource(const std::string& l_id){ ... }
std::string GetPath(const std::string& l_id){ ... }
bool RequireResource(const std::string& l_id){ ... }
bool ReleaseResource(const std::string& l_id){ ... }
void PurgeResources(){ ... }
protected:
bool Load(T* l_resource, const std::string& l_path) {
return static_cast<Derived*>(this)->Load(l_resource, l_path);
}
private:
...
};
这种特定资源管理系统的理念是某些代码段需要并随后释放特定的资源标识符。第一次需要资源时,它将被加载到内存中并保留在那里。之后每次需要时,将简单地增加一个与之存储的整数。这个整数表示依赖于该资源加载的代码实例的数量。一旦它们完成使用资源,它就开始释放,每次都会减少计数器。当它达到零时,资源将从内存中删除。
公平地说,我们的资源管理器基类在创建资源实例之后使用奇特重复的模板模式来设置资源实例。由于管理器类实际上不需要在任何地方存储在一起,因此静态多态比使用虚方法更有意义。由于纹理、字体和声音可能以不同的方式加载,每个后续管理器都必须实现自己的Load方法版本,如下所示:
class TextureManager : public ResourceManager<TextureManager,
sf::Texture>
{
public:
TextureManager() : ResourceManager("textures.cfg"){}
bool Load(sf::Texture* l_resource, const std::string& l_path){
return l_resource->loadFromFile(
Utils::GetWorkingDirectory() + l_path);
}
};
每个单独的管理器都有自己的文件,列出资源名称和它们路径之间的关系。对于纹理,它可以看起来像这样:
Intro media/Textures/intro.png
PlayerSprite media/Textures/PlayerSheet.png
...
它通过将每个资源与一个名称相关联,简单地避免了传递路径和文件名,从而避免了这种需求。
窗口系统
当处理打开的窗口时,幕后有很多事情在进行。从窗口尺寸和标题到跟踪和处理特殊事件,所有这些都在一个指定的窗口类中集中处理:
class Window{
public:
Window(const std::string& l_title = "Window",
const sf::Vector2u& l_size = {640,480},
bool l_useShaders = true);
~Window();
void BeginDraw();
void EndDraw();
void Update();
bool IsDone() const;
bool IsFullscreen() const;
bool IsFocused() const;
void ToggleFullscreen(EventDetails* l_details);
void Close(EventDetails* l_details = nullptr);
sf::RenderWindow* GetRenderWindow();
Renderer* GetRenderer();
EventManager* GetEventManager();
sf::Vector2u GetWindowSize();
sf::FloatRect GetViewSpace();
private:
...
};
注意两个突出显示的方法。它们将在我们即将讨论的事件管理器中用作回调。同时注意对象类型Renderer的返回方法。它是一个实用类,它简单地在一个RenderWindow上调用.draw方法,从而将其本地化并使其使用着色器变得更加容易。关于这一点,将在第六章中详细介绍,添加一些收尾工作 – 使用着色器。
应用程序状态
更复杂的应用程序的一个重要方面是跟踪和管理其状态。无论是玩家正在游戏中深入,还是简单地浏览主菜单,我们都希望它能够无缝处理,更重要的是,它应该是自包含的。我们可以通过首先定义我们将要处理的不同状态类型来开始这个过程:
enum class StateType { Intro = 1, MainMenu, Game, Loading };
为了实现无缝集成,我们希望每个状态都能以可预测的方式表现。这意味着状态必须遵循我们提供的接口:
class BaseState{
friend class StateManager;
public:
BaseState(StateManager* l_stateManager)
:m_stateMgr(l_stateManager), m_transparent(false),
m_transcendent(false){}
virtual ~BaseState(){}
virtual void OnCreate() = 0;
virtual void OnDestroy() = 0;
virtual void Activate() = 0;
virtual void Deactivate() = 0;
virtual void Update(const sf::Time& l_time) = 0;
virtual void Draw() = 0;
...
sf::View& GetView(){ return m_view; }
StateManager* GetStateManager(){ return m_stateMgr; }
protected:
StateManager* m_stateMgr;
bool m_transparent;
bool m_transcendent;
sf::View m_view;
};
游戏中的每个状态都将拥有自己的视图,它可以进行修改。除此之外,它还提供了钩子来实现各种不同场景的逻辑,例如状态的创建、销毁、激活、去激活、更新和渲染。最后,它通过提供m_transparent和m_transcendent标志,使得在更新和渲染过程中能够与其他状态混合。
管理这些状态相当直接:
class StateManager{
public:
StateManager(SharedContext* l_shared);
~StateManager();
void Update(const sf::Time& l_time);
void Draw();
void ProcessRequests();
SharedContext* GetContext();
bool HasState(const StateType& l_type) const;
StateType GetNextToLast() const;
void SwitchTo(const StateType& l_type);
void Remove(const StateType& l_type);
template<class T>
T* GetState(const StateType& l_type){ ... }
template<class T>
void RegisterState(const StateType& l_type) { ... }
void AddDependent(StateDependent* l_dependent);
void RemoveDependent(StateDependent* l_dependent);
private:
...
State_Loading* m_loading;
StateDependents m_dependents;
};
StateManager类是项目中少数几个使用共享上下文的类之一,因为状态本身可能需要访问代码库的任何部分。它还使用工厂模式在运行时动态创建任何与状态类型绑定的状态。
为了保持简单,我们将把加载状态视为一个特殊情况,并始终只允许一个实例存在。加载可能发生在任何状态的转换过程中,因此这样做是有意义的。
关于状态管理值得注意的最后一件事是它维护一个状态依赖项列表。它只是一个从该接口继承的类的 STL 容器:
class StateDependent {
public:
StateDependent() : m_currentState((StateType)0){}
virtual ~StateDependent(){}
virtual void CreateState(const StateType& l_state){}
virtual void ChangeState(const StateType& l_state) = 0;
virtual void RemoveState(const StateType& l_state) = 0;
protected:
void SetState(const StateType& l_state){m_currentState=l_state;}
StateType m_currentState;
};
由于处理声音、GUI 元素或实体管理等事物的类需要支持不同的状态,因此它们还必须定义在状态创建、更改或删除时内部发生的事情,以便正确地分配/释放资源,停止更新不在同一状态中的数据,等等。
加载状态
那么,我们究竟将如何实现这个加载状态呢?嗯,为了灵活性和通过渲染花哨的加载条来轻松跟踪进度,线程将证明是无价的。需要加载到内存中的数据可以在单独的线程中加载,而加载状态本身则继续更新和渲染,以显示确实有事情在进行。仅仅知道应用程序没有挂起,就应该会让人感到温暖和舒适。
首先,让我们通过提供一个任何线程工作者都可以使用的接口来实现这个系统的基本功能:
class Worker {
public:
Worker() : m_thread(&Worker::Work, this), m_done(false),
m_started(false) {}
void Begin() {
if(m_done || m_started) { return; }
m_started = true;
m_thread.launch();
}
bool IsDone() const { return m_done; }
bool HasStarted() const { return m_started; }
protected:
void Done() { m_done = true; }
virtual void Work() = 0;
sf::Thread m_thread;
bool m_done;
bool m_started;
};
它有自己的线程,该线程绑定到名为Work的纯虚方法。每当调用Begin()方法时,线程就会被启动。为了保护数据不被多个线程同时访问,在敏感调用期间使用sf::Mutex类创建一个锁。这个非常基础的类中的其他一切只是为了向外界提供有关工作者状态的信息。
文件加载器
现在线程的问题已经解决,我们可以专注于实际加载一些文件了。这个方法将专注于处理文本文件。然而,使用二进制格式应该以几乎相同的方式工作,只是没有文本处理。
让我们看看任何可想到的文件加载类的基类:
using LoaderPaths = std::vector<std::pair<std::string, size_t>>;
class FileLoader : public Worker {
public:
FileLoader();
void AddFile(const std::string& l_file);
virtual void SaveToFile(const std::string& l_file);
size_t GetTotalLines() const;
size_t GetCurrentLine() const;
protected:
virtual bool ProcessLine(std::stringstream& l_stream) = 0;
virtual void ResetForNextFile();
void Work();
void CountFileLines();
LoaderPaths m_files;
size_t m_totalLines;
size_t m_currentLine;
};
有一个明显的可能性,即在某些时候可能需要加载两个或更多文件。FileLoader类跟踪所有添加到其中的路径,以及代表该文件中行数的数字。这对于确定加载过程中所取得的进度非常有用。除了每个单独文件的行数外,还跟踪总行数。
这个类提供了一个单一的纯虚方法,称为ProcessLine。这将允许派生类定义文件的确切加载和处理方式。
首先,让我们先处理一些基本的事情:
FileLoader::FileLoader() : m_totalLines(0), m_currentLine(0) {}
void FileLoader::AddFile(const std::string& l_file) {
m_files.emplace_back(l_file, 0);
}
size_t FileLoader::GetTotalLines()const {
sf::Lock lock(m_mutex);
return m_totalLines;
}
size_t FileLoader::GetCurrentLine()const {
sf::Lock lock(m_mutex);
return m_currentLine;
}
void FileLoader::SaveToFile(const std::string& l_file) {}
void FileLoader::ResetForNextFile(){}
ResetForNextFile()虚拟方法不是必须实现的,但可以用来清除在文件加载期间需要存在的某些内部数据的状态。由于实现此类的文件加载器将只能够在单个方法中一次处理一行,任何通常在该方法内部作为局部变量存储的临时数据都需要放在其他地方。这就是为什么我们必须确保实际上有一种方式可以知道我们何时完成了一个文件的加载并开始加载另一个文件,以及在必要时执行某种操作。
注意
注意上面两个获取方法中的互斥锁。它们的存在是为了确保那些变量不会被同时写入和读取。
现在,让我们来看看将在不同线程中执行的一段代码:
void FileLoader::Work() {
CountFileLines();
if (!m_totalLines) { Done(); return; }
for (auto& path : m_files) {
ResetForNextFile();
std::ifstream file(path.first);
std::string line;
std::string name;
auto linesLeft = path.second;
while (std::getline(file, line)) {
{
sf::Lock lock(m_mutex);
++m_currentLine;
--linesLeft;
}
if (line[0] == '|') { continue; }
std::stringstream keystream(line);
if (!ProcessLine(keystream)) {
std::cout <<
"File loader terminated due to an internal error."
<< std::endl;
{
sf::Lock lock(m_mutex);
m_currentLine += linesLeft;
}
break;
}
}
file.close();
}
Done();
}
首先调用一个私有方法来计算即将加载的任何文件中的所有行数。如果由于任何原因,总行数为零,继续下去没有意义,因此在返回之前调用Worker::Done()方法。这段代码非常容易忘记,但对于使此功能正常工作至关重要。它所做的只是将Worker基类的m_done标志设置为true,这样外部代码就知道处理已经完成。由于目前没有检查 SFML 线程是否真正完成的方法,这几乎是唯一的选择。
我们开始循环遍历需要加载的不同文件,并在开始工作之前调用重置方法。注意,在尝试打开文件时没有进行检查。这将在介绍下一个方法时进行解释。
在读取文件的每一行时,确保更新所有行数信息非常重要。为了防止两个线程同时访问正在修改的行数,为当前线程建立了一个临时锁。此外,以管道符号开头的行被排除在外,因为这是我们标准的注释说明。
最后,为当前行构造了一个stringstream对象,并将其传递给ProcessLine()方法。为了加分,它返回一个布尔值,可以表示错误并停止当前文件的处理。如果发生这种情况,该特定文件中的剩余行将添加到总行数中,并且循环被中断。
最后一块拼图是这段代码,负责验证文件的有效性并确定我们面前的工作量:
void FileLoader::CountFileLines() {
m_totalLines = 0;
m_currentLine = 0;
for (auto path = m_files.begin(); path != m_files.end();) {
if (path->first.empty()) { m_files.erase(path); continue; }
std::ifstream file(path->first);
if (!file.is_open()) {
std::cerr << “Failed to load file: “ << path->first
<< std::endl;
m_files.erase(path);
continue;
}
file.unsetf(std::ios_base::skipws);
{
sf::Lock lock(m_mutex);
path->second = static_cast<size_t>(std::count(
std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>(), ‘\n’));
m_totalLines += path->second;
}
++path;
file.close();
}
}
在设置行数的初始零值后,遍历所有添加的路径并进行检查。我们首先删除任何空路径。然后尝试打开每个路径,如果操作失败则删除。最后,为了获得准确的结果,文件输入流被设置为忽略空行。在建立锁之后,使用std::count来计算文件中的行数。然后将这个数字添加到总行数中,路径迭代器前进,文件被正确关闭。
由于此方法消除了不存在或无法打开的文件,因此没有必要在其他任何地方再次检查这些文件。
实现加载状态
现在所有东西都已经就绪,我们可以成功实现加载状态:
using LoaderContainer = std::vector<FileLoader*>;
class State_Loading : public BaseState {
public:
...
void AddLoader(FileLoader* l_loader);
bool HasWork() const;
void SetManualContinue(bool l_continue);
void Proceed(EventDetails* l_details);
private:
void UpdateText(const std::string& l_text, float l_percentage);
float CalculatePercentage();
LoaderContainer m_loaders;
sf::Text m_text;
sf::RectangleShape m_rect;
unsigned short m_percentage;
size_t m_originalWork;
bool m_manualContinue;
};
状态本身将保留一个指向不同文件加载类指针的向量,这些类分别有自己的文件列表。它还提供了一种将这些对象添加到其中的方法。此外,请注意Proceed()方法。这是在即将介绍的的事件管理器中将要使用的一个回调。
对于视觉部分,我们将使用图形的最基本要素:一些文本表示进度百分比,以及一个表示加载条的矩形形状。
让我们看看这个类构建后将要进行的所有设置:
void State_Loading::OnCreate() {
auto context = m_stateMgr->GetContext();
context->m_fontManager->RequireResource("Main");
m_text.setFont(*context->m_fontManager->GetResource("Main"));
m_text.setCharacterSize(14);
m_text.setStyle(sf::Text::Bold);
sf::Vector2u windowSize = m_stateMgr->GetContext()->
m_wind->GetRenderWindow()->getSize();
m_rect.setFillColor(sf::Color(0, 150, 0, 255));
m_rect.setSize(sf::Vector2f(0.f, 32.f));
m_rect.setOrigin(0.f, 16.f);
m_rect.setPosition(0.f, windowSize.y / 2.f);
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->AddCallback(StateType::Loading, "Key_Space",
&State_Loading::Proceed, this);
}
首先,通过共享上下文获取字体管理器。需要并使用名为"Main"的字体来设置文本实例。在所有视觉元素设置完毕后,使用事件管理器为加载状态注册回调。这将在稍后介绍,但通过查看参数可以很容易地推断出正在发生的事情。每当按下空格键时,State_Loading类的Proceed方法将被调用。实际的类实例作为最后一个参数传递。
请记住,按照设计,我们需要的资源也必须被释放。对于加载状态来说,一个完美的释放位置就是它被销毁的时候:
void State_Loading::OnDestroy() {
auto context = m_stateMgr->GetContext();
EventManager* evMgr = context->m_eventManager;
evMgr->RemoveCallback(StateType::Loading, "Key_Space");
context->m_fontManager->ReleaseResource("Main");
}
除了释放字体外,空格键的回调也被移除。
接下来,让我们实际编写一些代码,将各个部分组合成一个完整、功能性的整体:
void State_Loading::Update(const sf::Time& l_time)
if (m_loaders.empty()) {
if (!m_manualContinue) { Proceed(nullptr); }
return;
}
auto windowSize = m_stateMgr->GetContext()->
m_wind->GetRenderWindow()->getSize();
if (m_loaders.back()->IsDone()) {
m_loaders.back()->OnRemove();
m_loaders.pop_back();
if (m_loaders.empty()) {
m_rect.setSize(sf::Vector2f(
static_cast<float>(windowSize.x), 16.f));
UpdateText(".Press space to continue.", 100.f);
return;
}
}
if (!m_loaders.back()->HasStarted()) {
m_loaders.back()->Begin();
}
auto percentage = CalculatePercentage();
UpdateText("", percentage);
m_rect.setSize(sf::Vector2f(
(windowSize.x / 100) * percentage, 16.f));
}
第一个检查用于确定是否由于完成而将所有文件加载器从向量中移除。m_manualContinue标志用于让加载状态知道它是否应该等待空格键被按下,或者如果应该自动消失。然而,如果我们仍然有一些加载器在向量中,我们将检查顶部加载器是否已完成其工作。如果是这样,加载器将被弹出,并再次检查向量是否为空,这将需要我们更新加载文本以表示完成。
为了使这个过程完全自动化,我们需要确保在顶文件加载器被移除后,下一个加载器开始工作,这就是下面检查的作用所在。最后,计算进度百分比,并在调整加载条大小以视觉辅助我们之前,更新加载文本以表示该值。
对于此状态,绘图将非常简单:
void State_Loading::Draw() {
sf::RenderWindow* wind = m_stateMgr->GetContext()->
m_wind->GetRenderWindow();
wind->draw(m_rect);
wind->draw(m_text);
}
首先通过共享上下文获取渲染窗口,然后使用它来绘制代表加载条的文本和矩形形状。
Proceed回调方法同样简单:
void State_Loading::Proceed(EventDetails* l_details){
if (!m_loaders.empty()) { return; }
m_stateMgr->SwitchTo(m_stateMgr->GetNextToLast());
}
它必须先进行检查,以确保在所有工作完成之前不切换状态。如果不是这种情况,则使用状态管理器切换到在加载开始之前创建的状态。
其他所有加载状态逻辑基本上由每个方法的单行代码组成:
void State_Loading::AddLoader(FileLoader* l_loader) {
m_loaders.emplace_back(l_loader);
l_loader->OnAdd();
}
bool State_Loading::HasWork() const { return !m_loaders.empty(); }
void State_Loading::SetManualContinue(bool l_continue) {
m_manualContinue = l_continue;
}
void State_Loading::Activate(){m_originalWork = m_loaders.size();}
虽然这看起来相当简单,但Activate()方法扮演着相当重要的角色。由于在这里将加载状态视为一个特殊情况,必须记住一件事:它永远不会在应用程序关闭之前被移除。这意味着每次我们再次使用它时,都必须重置一些东西。在这种情况下,是m_originalWork数据成员,它只是所有加载类数量的计数。这个数字用于准确计算进度百分比,而重置它的最佳位置是在每次状态再次激活时被调用的方法内部。
管理应用程序事件
事件管理是我们提供流畅控制体验的基石之一。任何按键、窗口变化,甚至是稍后我们将要介绍的 GUI 系统创建的定制事件都将由这个系统处理和解决。为了有效地统一来自不同来源的事件信息,我们首先必须通过正确枚举它们来统一它们的类型:
enum class EventType {
KeyDown = sf::Event::KeyPressed,
KeyUp = sf::Event::KeyReleased,
MButtonDown = sf::Event::MouseButtonPressed,
MButtonUp = sf::Event::MouseButtonReleased,
MouseWheel = sf::Event::MouseWheelMoved,
WindowResized = sf::Event::Resized,
GainedFocus = sf::Event::GainedFocus,
LostFocus = sf::Event::LostFocus,
MouseEntered = sf::Event::MouseEntered,
MouseLeft = sf::Event::MouseLeft,
Closed = sf::Event::Closed,
TextEntered = sf::Event::TextEntered,
Keyboard = sf::Event::Count + 1, Mouse, Joystick,
GUI_Click, GUI_Release, GUI_Hover, GUI_Leave
};
enum class EventInfoType { Normal, GUI };
SFML 事件排在首位,因为它们是唯一遵循严格枚举方案的。然后是实时 SFML 输入类型和四个 GUI 事件。我们还枚举了事件信息类型,这些类型将用于此结构中:
struct EventInfo {
EventInfo() : m_type(EventInfoType::Normal), m_code(0) {}
EventInfo(int l_event) : m_type(EventInfoType::Normal),
m_code(l_event) {}
EventInfo(const GUI_Event& l_guiEvent):
m_type(EventInfoType::GUI), m_gui(l_guiEvent) {}
EventInfo(const EventInfoType& l_type) {
if (m_type == EventInfoType::GUI) { DestroyGUIStrings(); }
m_type = l_type;
if (m_type == EventInfoType::GUI){ CreateGUIStrings("", ""); }
}
EventInfo(const EventInfo& l_rhs) { Move(l_rhs); }
EventInfo& operator=(const EventInfo& l_rhs) {
if (&l_rhs != this) { Move(l_rhs); }
return *this;
}
~EventInfo() {
if (m_type == EventInfoType::GUI) { DestroyGUIStrings(); }
}
union {
int m_code;
GUI_Event m_gui;
};
EventInfoType m_type;
private:
void Move(const EventInfo& l_rhs) {
if (m_type == EventInfoType::GUI) { DestroyGUIStrings(); }
m_type = l_rhs.m_type;
if (m_type == EventInfoType::Normal){ m_code = l_rhs.m_code; }
else {
CreateGUIStrings(l_rhs.m_gui.m_interface,
l_rhs.m_gui.m_element);
m_gui = l_rhs.m_gui;
}
}
void DestroyGUIStrings() {
m_gui.m_interface.~basic_string();
m_gui.m_element.~basic_string();
}
void CreateGUIStrings(const std::string& l_interface,
const std::string& l_element)
{
new (&m_gui.m_interface) std::string(l_interface);
new (&m_gui.m_element) std::string(l_element);
}
};
因为我们不仅关心发生的事件类型,还需要有一种良好的方式来存储与之相关的附加数据。C++11 的无限制联合是这一点的完美候选人。唯一的缺点是现在我们必须手动管理联合内部的数据,这包括数据分配和直接调用析构函数。
当事件回调被调用时,向它们提供实际的事件信息是个好主意。因为可以为特定的回调构造更复杂的要求,所以我们这次不能使用联合。任何可能相关的信息都需要被存储,这正是这里所做的事情:
struct EventDetails {
EventDetails(const std::string& l_bindName): m_name(l_bindName){
Clear();
}
std::string m_name;
sf::Vector2i m_size;
sf::Uint32 m_textEntered;
sf::Vector2i m_mouse;
int m_mouseWheelDelta;
int m_keyCode; // Single key code.
std::string m_guiInterface;
std::string m_guiElement;
GUI_EventType m_guiEvent;
void Clear() { ... }
};
这个结构填充了在事件处理过程中可用的所有信息,然后作为参数传递给被调用的回调。它还提供了一个Clear()方法,因为它的创建不仅限于回调期间,而是存在于绑定结构内部:
using Events = std::vector<std::pair<EventType, EventInfo>>;
struct Binding {
Binding(const std::string& l_name) : m_name(l_name),
m_details(l_name), c(0) {}
void BindEvent(EventType l_type, EventInfo l_info = EventInfo())
{ ... }
Events m_events;
std::string m_name;
int c; // Count of events that are "happening".
EventDetails m_details;
};
绑定实际上允许事件被分组在一起,以形成更复杂的要求。从多个键需要同时按下以执行操作的角度来考虑,例如Ctrl + C复制文本。这种情况的绑定将等待两个事件:Ctrl键和C键。
事件管理器接口
在涵盖了所有关键部分之后,剩下的就是正确管理一切。让我们从一些类型定义开始:
using Bindings = std::unordered_map<std::string,
std::unique_ptr<Binding>>;
using CallbackContainer = std::unordered_map<std::string,
std::function<void(EventDetails*)>>;
enum class StateType;
using Callbacks = std::unordered_map<StateType,
CallbackContainer>;
所有绑定都附加到特定的名称上,当应用程序启动时从keys.cfg文件中加载。它遵循一个基本格式,如下所示:
Window_close 0:0
Fullscreen_toggle 5:89
Intro_Continue 5:57
Mouse_Left 9:0
当然,这些都是非常基础的例子。更复杂的绑定会有多个通过空格分隔的事件。
回调也存储在一个无序映射中,以及与它们监视的绑定的名称相关联。然后根据状态对实际的回调容器进行分组,以避免在按下类似键时调用多个函数/方法。正如你可以想象的那样,事件管理器将继承自StateDependent类,正是出于这个原因:
class EventManager : public StateDependent{
public:
...
bool AddBinding(std::unique_ptr<Binding> l_binding);
bool RemoveBinding(std::string l_name);
void ChangeState(const StateType& l_state);
void RemoveState(const StateType& l_state);
void SetFocus(bool l_focus);
template<class T>
bool AddCallback(const StateType& l_state,
const std::string& l_name,
void(T::*l_func)(EventDetails*), T* l_instance)
{ ... }
template<class T>
bool AddCallback(const std::string& l_name,
void(T::*l_func)(EventDetails*), T* l_instance)
{ ... }
bool RemoveCallback(const StateType& l_state,
const std::string& l_name){ ... }
void HandleEvent(sf::Event& l_event);
void HandleEvent(GUI_Event& l_event);
void Update();
sf::Vector2i GetMousePos(sf::RenderWindow* l_wind = nullptr)
const { ... }
private:
...
Bindings m_bindings;
Callbacks m_callbacks;
};
再次强调,这相当简单。由于这是一个状态相关的类,它需要实现ChangeState()和RemoveState()方法。它还跟踪窗口焦点何时获得/丢失,以避免轮询最小化/未聚焦窗口的事件。提供了两种版本的AddCallback:一个用于指定状态,另一个用于当前状态。还有为支持的所有事件类型提供的单独的HandleEvent()方法。到目前为止,我们只有两种:SFML 事件和 GUI 事件。后者将在下一节中使用。
图形用户界面的使用
在一个计算机基本上是每个家庭必需品的时代,以一种友好的方式与应用程序交互是必不可少的。GUI(图形用户界面)的整个主题本身就可以填满多本书,所以为了保持简单,我们只将触及我们必须要处理的部分:
class GUI_Manager : public StateDependent{
friend class GUI_Interface;
public:
...
bool AddInterface(const StateType& l_state,
const std::string& l_name);
bool AddInterface(const std::string& l_name);
GUI_Interface* GetInterface(const StateType& l_state,
const std::string& l_name);
GUI_Interface* GetInterface(const std::string& l_name);
bool RemoveInterface(const StateType& l_state,
const std::string& l_name);
bool RemoveInterface(const std::string& l_name);
bool LoadInterface(const StateType& l_state,
const std::string& l_interface, const std::string& l_name);
bool LoadInterface(const std::string& l_interface,
const std::string& l_name);
void ChangeState(const StateType& l_state);
void RemoveState(const StateType& l_state);
SharedContext* GetContext() const;
void DefocusAllInterfaces();
void HandleClick(EventDetails* l_details);
void HandleRelease(EventDetails* l_details);
void HandleTextEntered(EventDetails* l_details);
void AddEvent(GUI_Event l_event);
bool PollEvent(GUI_Event& l_event);
void Update(float l_dT);
void Render(sf::RenderWindow* l_wind);
template<class T>
void RegisterElement(const GUI_ElementType& l_id){ ... }
private:
...
};
接口管理,不出所料,也依赖于应用程序状态。接口本身也被分配了名称,这就是它们被加载和存储的方式。鼠标输入以及文本输入事件都被用于使 GUI 系统工作,这就是为什么这个类实际上使用了事件管理器,并与之注册了三个回调。与其他我们讨论过的类一样,它也使用工厂方法,以便能够动态创建填充我们接口的不同类型的元素。
接口被描述为元素组,如下所示:
Interface MainMenu MainMenu.style 0 0 Immovable NoTitle "Main menu"
Element Label Title 100 0 MainMenuTitle.style "Main menu:"
Element Label Play 0 32 MainMenuLabel.style "PLAY"
Element Label Credits 0 68 MainMenuLabel.style "CREDITS"
Element Label Quit 0 104 MainMenuLabel.style "EXIT"
每个元素也支持它可能处于的三种不同状态的风格:中性、悬停和点击。一个单独的样式文件描述了元素在这些所有条件下的外观:
State Neutral
Size 300 32
BgColor 255 0 0 255
TextColor 255 255 255 255
TextSize 14
Font Main
TextPadding 150 16
TextOriginCenter
/State
State Hover
BgColor 255 100 0 255
/State
State Clicked
BgColor 255 150 0 255
/State
Neutral样式是其他两种样式的基础,这就是为什么它们只定义与它不同的属性。使用这种模型,可以构建和定制具有高度复杂性的接口,几乎可以完成任何事情。
表示 2D 地图
地图是拥有一个复杂游戏的关键部分之一。就我们的目的而言,我们将表示支持不同层级的 2D 地图,以模拟 3D 深度:
class Map : public FileLoader{
public:
...
Tile* GetTile(unsigned int l_x, unsigned int l_y,
unsigned int l_layer);
TileInfo* GetDefaultTile();
TileSet* GetTileSet();
unsigned int GetTileSize()const;
sf::Vector2u GetMapSize()const;
sf::Vector2f GetPlayerStart()const;
int GetPlayerId()const;
void PurgeMap();
void AddLoadee(MapLoadee* l_loadee);
void RemoveLoadee(MapLoadee* l_loadee);
void Update(float l_dT);
void Draw(unsigned int l_layer);
protected:
bool ProcessLine(std::stringstream& l_stream);
...
};
如您所见,这个类实际上是从我们之前提到的FileLoader继承的。它还支持一种称为MapLoadee*的功能,这些只是将某些数据存储在地图文件中的类,并在加载过程中遇到此类数据时需要通知。它只是一个它们必须实现的接口:
class MapLoadee {
public:
virtual void ReadMapLine(const std::string& l_type,
std::stringstream& l_stream) = 0;
};
地图文件本身相当简单:
SIZE 64 64
DEFAULT_FRICTION 1.0 1.0
|ENTITY|Name|x|y|elevation|
ENTITY Player 715 360 1
ENTITY Skeleton 256.0 768.0 1
|TILE|ID|x|y|layer|solid|
TILE 0 0 0 0 0
TILE 0 0 1 0 0
TILE 0 0 2 0 0
...
这里一个好的MapLoadee候选者是一个处理生成实体的类。两个实体行将直接由它处理,这为不应真正重叠的代码创建了一个很好的分离级别。
精灵系统
由于我们正在开发 2D 游戏,图形最有可能采用的方法是精灵图集。统一处理精灵图集裁剪和动画的方式不仅有助于最小化代码,还能创建一个简单、整洁且易于交互的接口。让我们看看如何实现这一点:
class SpriteSheet{
public:
...
void CropSprite(const sf::IntRect& l_rect);
const sf::Vector2u& GetSpriteSize()const;
const sf::Vector2f& GetSpritePosition()const;
void SetSpriteSize(const sf::Vector2u& l_size);
void SetSpritePosition(const sf::Vector2f& l_pos);
void SetDirection(const Direction& l_dir);
Direction GetDirection() const;
void SetSheetPadding(const sf::Vector2f& l_padding);
void SetSpriteSpacing(const sf::Vector2f& l_spacing);
const sf::Vector2f& GetSheetPadding()const;
const sf::Vector2f& GetSpriteSpacing()const;
bool LoadSheet(const std::string& l_file);
void ReleaseSheet();
Anim_Base* GetCurrentAnim();
bool SetAnimation(const std::string& l_name,
bool l_play = false, bool l_loop = false);
void Update(float l_dT);
void Draw(sf::RenderWindow* l_wnd);
private:
...
Animations m_animations;
};
SpriteSheet类本身并不复杂。它提供了将图集裁剪到特定矩形、更改存储方向、定义不同的属性(如间距、填充等)以及操作动画数据的方法。
动画通过名称存储在这个类中:
using Animations = std::unordered_map<std::string,
std::unique_ptr<Anim_Base>>;
动画类的接口看起来是这样的:
class Anim_Base{
friend class SpriteSheet;
public:
...
void SetSpriteSheet(SpriteSheet* l_sheet);
bool SetFrame(Frame l_frame);
void SetStartFrame(Frame l_frame);
void SetEndFrame(Frame l_frame);
void SetFrameRow(unsigned int l_row);
void SetActionStart(Frame l_frame);
void SetActionEnd(Frame l_frame);
void SetFrameTime(float l_time);
void SetLooping(bool l_loop);
void SetName(const std::string& l_name);
SpriteSheet* GetSpriteSheet();
Frame GetFrame() const;
Frame GetStartFrame() const;
Frame GetEndFrame() const;
unsigned int GetFrameRow() const;
int GetActionStart() const;
int GetActionEnd() const;
float GetFrameTime() const;
float GetElapsedTime() const;
bool IsLooping() const;
bool IsPlaying() const;
bool IsInAction() const;
bool CheckMoved();
std::string GetName() const;
void Play();
void Pause();
void Stop();
void Reset();
virtual void Update(float l_dT);
friend std::stringstream& operator >>(
std::stringstream&l_stream, Anim_Base& a){ ... }
protected:
virtual void FrameStep() = 0;
virtual void CropSprite() = 0;
virtual void ReadIn(std::stringstream& l_stream) = 0;
...
};
首先,Frame数据类型只是一个整型的类型定义。这个类跟踪所有必要的动画数据,并提供了一种设置特定帧范围(也称为动作)的方法,这可以用于实体仅在攻击动画位于该特定动作范围内时攻击某物。
这个类明显的一点是它不代表任何单一类型的动画,而是代表每种类型动画的共同元素。这就是为什么提供了三个不同的纯虚方法,以便不同类型的动画可以定义如何处理帧步进、定义特定方法、裁剪位置以及从文件中加载动画的确切过程。这有助于我们区分方向动画,其中每一行代表一个朝向不同方向的字符,以及简单的、按线性顺序跟随的帧序列动画。
音响系统
最后,但绝对不是最不重要的,音响系统值得简要概述。在这个阶段,了解到声音同样依赖于应用程序状态,可能对任何人来说都不会感到惊讶,这就是为什么我们再次从StateDependent继承:
class SoundManager : public StateDependent{
public:
SoundManager(AudioManager* l_audioMgr);
~SoundManager();
void ChangeState(const StateType& l_state);
void RemoveState(const StateType& l_state);
void Update(float l_dT);
SoundID Play(const std::string& l_sound,
const sf::Vector3f& l_position,
bool l_loop = false, bool l_relative = false);
bool Play(const SoundID& l_id);
bool Stop(const SoundID& l_id);
bool Pause(const SoundID& l_id);
bool PlayMusic(const std::string& l_musicId,
float l_volume = 100.f, bool l_loop = false);
bool PlayMusic(const StateType& l_state);
bool StopMusic(const StateType& l_state);
bool PauseMusic(const StateType& l_state);
bool SetPosition(const SoundID& l_id,
const sf::Vector3f& l_pos);
bool IsPlaying(const SoundID& l_id) const;
SoundProps* GetSoundProperties(const std::string& l_soundName);
static const int Max_Sounds = 150;
static const int Sound_Cache = 75;
private:
...
AudioManager* m_audioManager;
};
AudioManager 类负责管理音频资源,就像纹理和字体在其他地方被管理一样。这里的一个较大差异是,我们实际上可以在 3D 空间中播放声音,因此当需要表示位置时,会使用 sf::Vector3f 结构。声音也可以按特定名称分组,但这个系统有一个小小的转折。SFML 只能同时处理大约 255 个不同的声音,这包括sf::Music实例。正因为如此,我们必须实现一个回收系统,该系统利用被丢弃的声音实例,以及一次允许的最大声音数量的静态限制。
每个加载和播放的不同声音都有特定的设置属性可以调整。它们由以下数据结构表示:
struct SoundProps{
SoundProps(const std::string& l_name): m_audioName(l_name),
m_volume(100), m_pitch(1.f), m_minDistance(10.f),
m_attenuation(10.f){}
std::string m_audioName;
float m_volume;
float m_pitch;
float m_minDistance;
float m_attenuation;
};
audioName 只是加载到内存中的音频资源的标识符。声音的音量显然可以调整,以及其音调。最后两个属性稍微复杂一些。在空间中的某个点,声音会开始变得越来越小,因为我们开始远离它。最小距离属性描述了从声音源的单位距离,在此距离之后,声音开始失去音量。达到该点后音量损失的速度由衰减因子描述。
摘要
这里的信息量相当大。在约四十页的篇幅内,我们成功总结了整个代码库的大部分内容,这些内容足以让任何基础到中级复杂度的游戏运行。请记住,尽管这里涵盖了众多主题,但所有信息都相当精炼。请随意查阅我们提供的代码文件,直到您感到舒适地继续实际构建游戏,这正是下一章将要介绍的内容。我们那里见!
第二章. 游戏时间! - 设计项目
在上一章中,我们介绍了将要用于创建游戏时预建立的代码库的基本部分。现在是时候将我们所学的内容应用于实践,通过关注项目特定的代码来构建我们正在制作的游戏的独特性。
在本章中,我们将讨论以下主题:
-
实现关键实体组件和系统以实现最小化游戏玩法
-
创建几个状态以导航游戏
-
将所有代码整理成一个连贯、可工作的项目
我们有一个完整游戏要设计,让我们开始吧!
版权资源的使用
在本章以及整本书的篇幅中,我们将使用以下资源:
-
由Hyptosis创作的法师城市 Arcanos,遵循CC0许可(公有领域):
opengameart.org/content/mage-city-arcanos -
由William. Thompsonj创作的[LPC] 叶子重着色,遵循CC-BY-SA 3.0和GPL 3.0许可:
opengameart.org/content/lpc-leaf-recolor -
由Wulax创作的[LPC] 中世纪幻想角色精灵,遵循CC-BY-SA 3.0和GPL 3.0许可:
opengameart.org/content/lpc-medieval-fantasy-character-sprites -
由Ravenmore创作的幻想 UI 元素,可在
dycha.net/找到,遵循CC-BY 3.0许可:opengameart.org/content/fantasy-ui-elements-by-ravenmore -
由Arro创作的Vegur 字体,遵循CC0许可(公有领域):
www.fontspace.com/arro/vegur -
由Fantozzi创作的Fantozzi 的足迹(草地/沙地与石头),遵循CC0许可(公有领域):
opengameart.org/content/fantozzis-footsteps-grasssand-stone -
由Snabisch创作的Electrix(NES 版本),遵循CC-BY 3.0许可:
opengameart.org/content/electrix-nes-version -
由cynicmusic创作的城镇主题 RPG,遵循CC-BY 3.0许可:
opengameart.org/content/town-theme-rpg
关于这些资源所适用的所有许可证的信息,可以在此找到:
实体放置和渲染
让我们从基础开始。在我们构建的任何游戏中,大多数(如果不是所有)实体都将位于世界中。现在让我们忽略特殊类型实体的边缘情况。为了表示实体位置,我们将创建一个位置组件,如下所示:
class C_Position : public C_Base{
public:
C_Position(): C_Base(Component::Position), m_elevation(0){}
void ReadIn(std::stringstream& l_stream){
l_stream >> m_position.x >> m_position.y >> m_elevation;
}
sf::Vector2f GetPosition() const { ... }
sf::Vector2f GetOldPosition() const { ... }
unsigned int GetElevation() const { ... }
void SetPosition(float l_x, float l_y){ ... }
void SetPosition(const sf::Vector2f& l_vec){ ... }
void SetElevation(unsigned int l_elevation){ ... }
void MoveBy(float l_x, float l_y){ ... }
void MoveBy(const sf::Vector2f& l_vec){ ... }
private:
sf::Vector2f m_position;
sf::Vector2f m_positionOld;
unsigned int m_elevation;
};
这里只有两件事值得注意。首先,组件类型必须通过C_Base构造函数来设置。如果我们将来要重新设计这个系统,这可以改变,但就目前而言,这是处理方式。我们还必须实现ReadIn方法,以便能够正确地反序列化组件数据。这意味着每次加载实体文件并遇到位置数据时,它将按照这个顺序读取x坐标、y坐标和海拔:
组件本身只持有与其原因相关的数据。这里存储了两个实体位置的数据成员:当前位置m_position和上一个游戏周期的实体位置m_positionOld。如果任何系统需要依赖更新之间的位置变化,这可能是有用的。
事物的可绘制方面
在 ECS 范式下表示事物的视觉方面并没有太大的不同。因为我们可能要处理多种可渲染对象,所以有一个它们都必须遵守和实现的接口是有帮助的:
class C_Drawable : public C_Base{
public:
C_Drawable(const Component& l_type) : C_Base(l_type){}
virtual ~C_Drawable(){}
virtual void UpdatePosition(const sf::Vector2f& l_vec) = 0;
virtual sf::Vector2u GetSize() const = 0;
virtual void Draw(sf::RenderWindow* l_wind) = 0;
};
根据可绘制组件的类型和实现方式,它们可能依赖于不同的方式来表示其位置、大小以及特定的绘制方法。当创建一个新的可绘制类型时,这三个方面都需要被定义,就像这样:
class C_SpriteSheet : public C_Drawable{
public:
C_SpriteSheet(): C_Drawable(Component::SpriteSheet),
m_spriteSheet(nullptr){}
void ReadIn(std::stringstream& l_stream){l_stream>>m_sheetName;}
void Create(TextureManager* l_textureMgr,
const std::string& l_name = "")
{
if (m_spriteSheet) { m_spriteSheet.release(); }
m_spriteSheet = std::make_unique<SpriteSheet>(l_textureMgr);
m_spriteSheet->LoadSheet("media/Spritesheets/" +
(!l_name.empty() ? l_name : m_sheetName) + ".sheet");
}
SpriteSheet* GetSpriteSheet(){ ... }
void UpdatePosition(const sf::Vector2f& l_vec){ ... }
sf::Vector2u GetSize() const { ... }
void Draw(sf::RenderWindow* l_wind){ ... }
private:
std::unique_ptr<SpriteSheet> m_spriteSheet;
std::string m_sheetName;
};
一个精灵图组件利用我们在第一章中介绍过的其中一个类,内部机制 - 设置后端。这个组件的反序列化相当简单。它只需要包含所有大小、填充、空间和动画信息的图文件名。因为这个类依赖于纹理管理器来加载其资源,所以使用了一个特殊的Create()方法来在加载后设置这种关系。
渲染系统
在数据处理方面已经全部处理完毕并移除之后,我们现在可以专注于在屏幕上实际绘制实体。这正是第一个系统类型发挥作用的地方:
S_Renderer::S_Renderer(SystemManager* l_systemMgr)
:S_Base(System::Renderer, l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::SpriteSheet);
m_requiredComponents.push_back(req);
req.Clear();
m_drawableTypes.TurnOnBit((unsigned int)Component::SpriteSheet);
// Other types...
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Direction_Changed,this);
}
目前,渲染系统操作两种不同类型的组件:位置和精灵图。如果提供了更多种类的可绘制组件类型,它当然也需要包括它们。这正是为什么保留了一个名为m_drawableTypes的位掩码数据成员。它跟踪所有可能的可绘制组件类型,并将被用于稍后检索实际组件数据。所有这些类型都应该在这里注册。
这个系统还需要在实体改变方向时得到通知,以便在给定的精灵图集中强制执行这些变化。
系统使用的所有组件通常都需要像这样更新:
void S_Renderer::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities)
{
auto position = entities->
GetComponent<C_Position>(entity, Component::Position);
C_Drawable* drawable = GetDrawableFromType(entity);
if (!drawable) { continue; }
drawable->UpdatePosition(position->GetPosition());
}
}
这相当直接。为了使模拟准确,所有可绘制组件的位置都需要更新。我们使用一个私有方法来获取当前实体具有的可绘制类型的指针,检查它是否不是 nullptr,然后更新其位置。
事件处理也用于这个系统,通过排序实体来实现 深度 效果:
void S_Renderer::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
if (l_event == EntityEvent::Moving_Left ||
l_event == EntityEvent::Moving_Right ||
l_event == EntityEvent::Moving_Up ||
l_event == EntityEvent::Moving_Down ||
l_event == EntityEvent::Elevation_Change ||
l_event == EntityEvent::Spawned)
{
SortDrawables();
}
}
我们在这里需要做的就是调用另一个私有方法,该方法将根据 y 轴对所有系统中的实体进行排序。这只有在实体正在移动、改变高度或刚刚生成时才需要发生。
就实体消息而言,我们只对一种类型感兴趣,正如 S_Renderer 构造函数所明显显示的那样:
void S_Renderer::Notify(const Message& l_message){
if(HasEntity(l_message.m_receiver)){
EntityMessage m=static_cast<EntityMessage>(l_message.m_type);
switch(m){
case EntityMessage::Direction_Changed:
SetSheetDirection(l_message.m_receiver,
(Direction)l_message.m_int);
break;
}
}
}
另一个私有方法也派上了用场。它将在稍后进行介绍,但基本思路是精灵图集需要被告知任何方向变化,以便在视觉上反映出来。
由于这个系统的整个目的是在屏幕上渲染我们的实体,让我们就这样做:
void S_Renderer::Render(Window* l_wind, unsigned int l_layer)
{
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
auto position = entities->
GetComponent<C_Position>(entity, Component::Position);
if(position->GetElevation() < l_layer){ continue; }
if(position->GetElevation() > l_layer){ break; }
C_Drawable* drawable = GetDrawableFromType(entity);
if (!drawable) { continue; }
sf::FloatRect drawableBounds;
drawableBounds.left = position->GetPosition().x -
(drawable->GetSize().x / 2);
drawableBounds.top = position->GetPosition().y -
drawable->GetSize().y;
drawableBounds.width =
static_cast<float>(drawable->GetSize().x);
drawableBounds.height =
static_cast<float>(drawable->GetSize().y);
if (!l_wind->GetViewSpace().intersects(drawableBounds)){
continue;
}
drawable->Draw(l_wind->GetRenderWindow());
}
}
再次强调,这相当简单。实际的渲染是按层进行的,因此传入一个参数表示我们当前正在绘制的特定层。首先获取位置组件,以检查实体的高度是否与当前正在渲染的层匹配。由于游戏实体总是保持排序,我们知道如果任何给定实体的高度超过我们正在处理的层,就可以跳出循环。
最后,获取实体的可绘制组件,并检查它是否在屏幕区域外,以最小化不必要的绘制。
现在我们只剩下私有辅助方法了,从 SetSheetDirection 开始:
void S_Renderer::SetSheetDirection(const EntityId& l_entity,
const Direction& l_dir)
{
EntityManager* entities = m_systemManager->GetEntityManager();
if (!entities->HasComponent(l_entity, Component::SpriteSheet))
{ return; }
auto sheet = entities->
GetComponent<C_SpriteSheet>(l_entity,Component::SpriteSheet);
sheet->GetSpriteSheet()->SetDirection(l_dir);
}
我们之前都见过。检查实体是否有精灵图集组件,然后获取该组件并告知方向变化。
这个系统严重依赖于实体根据它们的 y 坐标和高度进行排序。为此,我们使用以下代码:
void S_Renderer::SortDrawables(){
EntityManager* e_mgr = m_systemManager->GetEntityManager();
std::sort(m_entities.begin(), m_entities.end(),
e_mgr
{
auto pos1 = e_mgr->
GetComponent<C_Position>(l_1, Component::Position);
auto pos2 = e_mgr->
GetComponent<C_Position>(l_2, Component::Position);
if (pos1->GetElevation() == pos2->GetElevation()){
return pos1->GetPosition().y < pos2->GetPosition().y;
}
return pos1->GetElevation() < pos2->GetElevation();
});
}
由于实体标识符存储在 STL 容器中,std::sort 就派上用场了。实际的排序优先考虑高度;然而,如果两个实体在这一方面有共同点,它们将根据 y 坐标进行排序,从小到大。
为了总结一下,这里有一个方法可以节省我们一些打字时间,如果将来添加了额外的可绘制组件类型:
C_Drawable* S_Renderer::GetDrawableFromType(
const EntityId& l_entity)
{
auto entities = m_systemManager->GetEntityManager();
for (size_t i = 0; i < static_cast<size_t>(Component::COUNT);
++i)
{
if (!m_drawableTypes.GetBit(i)) { continue; }
auto component = static_cast<Component>(i);
if (!entities->HasComponent(l_entity, component)){ continue; }
return entities->GetComponent<C_Drawable>(l_entity,component);
}
return nullptr;
}
它所做的只是简单地遍历所有组件类型,寻找与系统构造函数中注册的可绘制类型匹配的类型。一旦找到,就检查实体是否具有该组件。如果它有,就返回它的指针。
实体运动学
我们迄今为止编写的代码只会产生一个静态、不动的场景。由于这并不非常令人兴奋,让我们努力添加实体运动的可能性。由于需要存储更多数据,我们需要另一种组件类型来工作:
class C_Movable : public C_Base{
public:
C_Movable() : C_Base(Component::Movable),
m_velocityMax(0.f), m_direction((Direction)0){}
void ReadIn(std::stringstream& l_stream){
l_stream >> m_velocityMax >> m_speed.x >> m_speed.y;
unsigned int dir = 0;
l_stream >> dir;
m_direction = static_cast<Direction>(dir);
}
...
void SetVelocity(const sf::Vector2f& l_vec){ ... }
void SetMaxVelocity(float l_vel){ ... }
void SetSpeed(const sf::Vector2f& l_vec){ ... }
void SetAcceleration(const sf::Vector2f& l_vec){ ... }
void SetDirection(const Direction& l_dir){ ... }
void AddVelocity(const sf::Vector2f& l_vec){ ... }
void ApplyFriction(const sf::Vector2f& l_vec){ ... }
void Accelerate(const sf::Vector2f& l_vec){ ... }
void Accelerate(float l_x, float l_y){ ... }
void Move(const Direction& l_dir){ ... }
private:
sf::Vector2f m_velocity;
sf::Vector2f m_speed;
sf::Vector2f m_acceleration;
float m_velocityMax;
Direction m_direction;
};
我们将通过速度、速度和加速度之间的关系来模拟运动。为了控制实体,还将强制实施一个最大速度值,以防止无限加速。我们还使用此组件存储方向,以减少某些复杂性和组件间的关系;然而,它也可以是一个独立的组件。
运动系统
为了启动这个系统,让我们首先看看运动系统需要什么才能工作:
S_Movement::S_Movement(SystemManager* l_systemMgr)
: S_Base(System::Movement,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Movable);
m_requiredComponents.push_back(req);
req.Clear();
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Is_Moving,this);
m_gameMap = nullptr;
}
实体的运动状态将直接控制其位置,因此在这里我们需要同时处理位置和可移动组件。还订阅了实体消息类型Is_Moving。其命名方式应该是一个线索,表明这条消息将被用作信息请求,并且发送者期望得到答复。由于该系统负责与运动相关的所有事情,它将处理此类请求。
接下来,让我们更新组件数据:
void S_Movement::Update(float l_dT){
if (!m_gameMap){ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
auto position = entities->
GetComponent<C_Position>(entity, Component::Position);
auto movable = entities->
GetComponent<C_Movable>(entity, Component::Movable);
MovementStep(l_dT, movable, position);
position->MoveBy(movable->GetVelocity() * l_dT);
}
}
获取这两个组件后,它们将被传递到一个处理移动步骤的私有方法中。我们稍后会介绍这一点,但重要的是要注意,它以const值的形式接受位置组件指针,这意味着它只读。这就是为什么实体位置在下一行被单独修改,通过调用其MoveBy()方法。它只是通过提供的唯一参数(向量)推进位置。
对于更复杂的系统任务,显然有更多的事件需要处理:
void S_Movement::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
switch(l_event){
case EntityEvent::Colliding_X:
StopEntity(l_entity, Axis::x); break;
case EntityEvent::Colliding_Y:
StopEntity(l_entity, Axis::y); break;
case EntityEvent::Moving_Left:
SetDirection(l_entity, Direction::Left); break;
case EntityEvent::Moving_Right:
SetDirection(l_entity, Direction::Right); break;
case EntityEvent::Moving_Up:
{
auto mov = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
if(mov->GetVelocity().x == 0){
SetDirection(l_entity, Direction::Up);
}
}
break;
case EntityEvent::Moving_Down:
{
auto mov = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
if(mov->GetVelocity().x == 0){
SetDirection(l_entity, Direction::Down);
}
}
break;
}
}
如果实体实际上与一个固体发生碰撞,我们将希望在其给定轴上停止实体。当讨论碰撞系统时,我们将讨论碰撞事件发射,所以现在我们只需要记住的是,如果一个实体在特定轴上发生碰撞,它需要在该轴上将其速度减少到0。
由于我们还要负责实体的方向,因此处理运动事件并使用它们来更新它。方向优先级给予水平运动,而上下方向仅在x轴上的速度为零时设置。
在此系统的构造函数中,我们订阅了一个请求运动信息的消息。让我们看看如何处理它:
void S_Movement::Notify(const Message& l_message){
EntityManager* eMgr = m_systemManager->GetEntityManager();
EntityMessage m = static_cast<EntityMessage>(l_message.m_type);
switch(m){
case EntityMessage::Is_Moving:
{
if (!HasEntity(l_message.m_receiver)){ return; }
auto movable = eMgr->
GetComponent<C_Movable>(l_message.m_receiver,
Component::Movable);
if (movable->GetVelocity() != sf::Vector2f(0.f, 0.f)){return;}
m_systemManager->AddEvent(l_message.m_receiver,
(EventID)EntityEvent::Became_Idle);
}
break;
}
}
如果请求的实体信息甚至不属于该系统的一部分,则消息将被忽略。否则,将获取可移动组件并检查其速度是否不为绝对零。如果是,则发送实体事件Became_Idle。这将在我们处理实体动画时很有用。
再次强调,所有繁重的工作都在我们的辅助方法中完成。让我们从一个简单的开始,用于获取空间中特定坐标的瓷砖摩擦力:
sf::Vector2f S_Movement::GetTileFriction(unsigned int l_elevation,
unsigned int l_x, unsigned int l_y)
{
Tile* t = nullptr;
while (!t && l_elevation >= 0){
t = m_gameMap->GetTile(l_x, l_y, l_elevation);
--l_elevation;
}
return(t ? t->m_properties->m_friction :
m_gameMap->GetDefaultTile()->m_friction);
}
首先建立一个指向瓷砖的 null 指针。然后使用 while 循环尝试获取实际的瓷砖,从原始高度开始,向下移动直到达到 0。我们最终返回找到的瓷砖的摩擦力,或者如果没有找到,则返回地图的默认摩擦力。它在尝试处理实体的移动步骤时发挥作用:
void S_Movement::MovementStep(float l_dT, C_Movable* l_movable,
const C_Position* l_position)
{
sf::Vector2f f_coefficient = GetTileFriction(
l_position->GetElevation(),
static_cast<unsigned int>(floor(l_position->GetPosition().x /
Sheet::Tile_Size)),
static_cast<unsigned int>(floor(l_position->GetPosition().y /
Sheet::Tile_Size)));
sf::Vector2f friction(l_movable->GetSpeed().x * f_coefficient.x,
l_movable->GetSpeed().y * f_coefficient.y);
l_movable->AddVelocity(l_movable->GetAcceleration() * l_dT);
l_movable->SetAcceleration(sf::Vector2f(0.0f, 0.0f));
l_movable->ApplyFriction(friction * l_dT);
float magnitude = sqrt(
(l_movable->GetVelocity().x * l_movable->GetVelocity().x) +
(l_movable->GetVelocity().y * l_movable->GetVelocity().y));
if (magnitude <= l_movable->GetMaxVelocity()){ return; }
float max_V = l_movable->GetMaxVelocity();
l_movable->SetVelocity(sf::Vector2f(
(l_movable->GetVelocity().x / magnitude) * max_V,
(l_movable->GetVelocity().y / magnitude) * max_V));
}
在从实体所站立的当前瓷砖中获取摩擦系数后,由于摩擦力而损失的速度被计算,由于加速度产生的速度被添加,加速度本身被置零,并应用摩擦力。为了考虑对角线移动,计算速度大小并检查是否超过最大允许值。如果超过了,根据其实际速度和总大小之间的比率重新计算实体的速度,并调整以适应提供的边界。
停止一个实体简单来说就是将其在提供的轴上的速度置零,如下所示:
void S_Movement::StopEntity(const EntityId& l_entity,
const Axis& l_axis)
{
auto movable = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
if(l_axis == Axis::x){
movable->SetVelocity(sf::Vector2f(
0.f, movable->GetVelocity().y));
} else if(l_axis == Axis::y){
movable->SetVelocity(sf::Vector2f(
movable->GetVelocity().x, 0.f));
}
}
更新实体的方向同样简单,但其他系统不能忽视这一点:
void S_Movement::SetDirection(const EntityId& l_entity,
const Direction& l_dir)
{
auto movable = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity,Component::Movable);
movable->SetDirection(l_dir);
Message msg((MessageType)EntityMessage::Direction_Changed);
msg.m_receiver = l_entity;
msg.m_int = static_cast<int>(l_dir);
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
在更新方向后,构建并派发一个新的消息,让相关系统知道实体的方向变化。这也会在处理实体动画时证明非常有用。
处理碰撞
为了让我们所制作的游戏感觉不仅仅是在没有后果的静态背景上移动的实体,我们需要检查和处理碰撞。在 ECS 范式内,这可以通过实现一个可碰撞组件来实现。为了增加灵活性,让我们定义多个碰撞框可以附加的点:
enum class Origin{ Top_Left, Abs_Centre, Mid_Bottom };
TOP_LEFT 原点简单地将碰撞矩形的左上角放置在提供的位置。ABS_CENTRE 将该矩形的中心移动到该位置,而MIDDLE_BOTTOM 原点则将其放置在x轴的中点和y轴的底部。考虑以下插图:

基于这些信息,让我们着手实现可碰撞组件:
class C_Collidable : public C_Base{
public:
C_Collidable(): C_Base(Component::Collidable),
m_origin(Origin::Mid_Bottom), m_collidingOnX(false),
m_collidingOnY(false){}
void ReadIn(std::stringstream& l_stream){
unsigned int origin = 0;
l_stream >> m_AABB.width >> m_AABB.height >> m_offset.x
>> m_offset.y >> origin;
m_origin = static_cast<Origin>(origin);
}
const sf::FloatRect& GetCollidable() const { ... }
bool IsCollidingOnX() const { ... }
bool IsCollidingOnY() const { ... }
void CollideOnX(){ m_collidingOnX = true; }
void CollideOnY(){ m_collidingOnY = true; }
void ResetCollisionFlags(){ ... }
void SetCollidable(const sf::FloatRect& l_rect){ ... }
void SetOrigin(const Origin& l_origin){ ... }
void SetSize(const sf::Vector2f& l_vec){ ... }
void SetPosition(const sf::Vector2f& l_vec){
switch(m_origin){
case(Origin::Top_Left) :
m_AABB.left = l_vec.x + m_offset.x;
m_AABB.top = l_vec.y + m_offset.y;
break;
case(Origin::Abs_Centre):
m_AABB.left = l_vec.x - (m_AABB.width / 2) + m_offset.x;
m_AABB.top = l_vec.y - (m_AABB.height / 2) + m_offset.y;
break;
case(Origin::Mid_Bottom):
m_AABB.left = l_vec.x - (m_AABB.width / 2) + m_offset.x;
m_AABB.top = l_vec.y - m_AABB.height + m_offset.y;
break;
}
}
private:
sf::FloatRect m_AABB;
sf::Vector2f m_offset;
Origin m_origin;
bool m_collidingOnX;
bool m_collidingOnY;
};
首先,让我们看看我们正在保存的数据。sf::FloatRect 代表围绕将要用作碰撞器的实体的基本 AABB 边界框。我们还想能够通过一些值来偏移它,这些值将从实体文件中加载。显然,原点点也被存储,以及两个标志,指示每个轴上是否发生碰撞。
SetPosition() 方法结合了原点的使用,并调整矩形以正确定位,因为原生的 sf::FloatRect 本身不支持原点。
碰撞系统
为了处理碰撞,我们只需要两个组件:
S_Collision::S_Collision(SystemManager* l_systemMgr)
: S_Base(System::Collision,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Collidable);
m_requiredComponents.push_back(req);
req.Clear();
m_gameMap = nullptr;
}
注意m_gameMap数据成员。在某个时候,我们需要向碰撞系统提供一个指向游戏地图的指针,以便能够处理地图碰撞。
接下来,让我们处理更新我们的组件数据:
void S_Collision::Update(float l_dT){
if (!m_gameMap){ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
auto position = entities->
GetComponent<C_Position>(entity, Component::Position);
auto collidable = entities->
GetComponent<C_Collidable>(entity, Component::Collidable);
CheckOutOfBounds(position);
collidable->SetPosition(position->GetPosition());
collidable->ResetCollisionFlags();
MapCollisions(entity, position, collidable);
}
EntityCollisions();
}
首先,检查实体的位置,看它是否在地图边界之外。在可能调整后,使用新的位置信息更新collidable组件,并重置其碰撞标志。然后,这两个组件被传递给一个处理地图碰撞的私有方法。
在检查所有实体与地图的碰撞之后,我们必须检查它们之间的碰撞:
void S_Collision::EntityCollisions(){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto itr = m_entities.begin(); itr!=m_entities.end(); ++itr)
{
for(auto itr2=std::next(itr); itr2!=m_entities.end(); ++itr2){
auto collidable1 = entities->
GetComponent<C_Collidable>(*itr, Component::Collidable);
auto collidable2 = entities->
GetComponent<C_Collidable>(*itr2, Component::Collidable);
if(collidable1->GetCollidable().intersects(
collidable2->GetCollidable()))
{
// Entity-on-entity collision!
}
}
}
}
到目前为止,我们实际上并不需要以任何方式处理实体间的碰撞,但这将是以后功能的入口点。
边界检查相当简单:
void S_Collision::CheckOutOfBounds(C_Position* l_pos){
unsigned int TileSize = m_gameMap->GetTileSize();
if (l_pos->GetPosition().x < 0){
l_pos->SetPosition(0.0f, l_pos->GetPosition().y);
} else if (l_pos->GetPosition().x >
m_gameMap->GetMapSize().x * TileSize)
{
l_pos->SetPosition(
static_cast<float>(m_gameMap->GetMapSize().x * TileSize),
l_pos->GetPosition().y);
}
if (l_pos->GetPosition().y < 0){
l_pos->SetPosition(l_pos->GetPosition().x, 0.0f);
} else if (l_pos->GetPosition().y >
m_gameMap->GetMapSize().y * TileSize)
{
l_pos->SetPosition(
l_pos->GetPosition().x,
static_cast<float>(m_gameMap->GetMapSize().y * TileSize));
}
}
它简单地检查位置是否在负坐标或地图边界之外。
实际处理地图碰撞被进一步分解为更易读的部分:
void S_Collision::MapCollisions(const EntityId& l_entity,
C_Position* l_pos, C_Collidable* l_col)
{
Collisions c;
CheckCollisions(l_pos, l_col, c);
HandleCollisions(l_entity, l_pos, l_col, c);
}
在设置好Collisions数据类型后,它连同位置和可碰撞组件一起传递给两个私有方法,这些方法实际上执行碰撞检查,并随后处理它们。Collisions数据类型只是一个用于存储碰撞信息的容器:
struct CollisionElement{
CollisionElement(float l_area, TileInfo* l_info,
const sf::FloatRect& l_bounds):m_area(l_area), m_tile(l_info),
m_tileBounds(l_bounds){}
float m_area;
TileInfo* m_tile;
sf::FloatRect m_tileBounds;
};
using Collisions = std::vector<CollisionElement>;
让我们接下来关注实际上填充这个结构以包含有用的碰撞信息:
void S_Collision::CheckCollisions(C_Position* l_pos,
C_Collidable* l_col, Collisions& l_collisions)
{
unsigned int TileSize = m_gameMap->GetTileSize();
sf::FloatRect EntityAABB = l_col->GetCollidable();
int FromX = static_cast<int>(floor(EntityAABB.left / TileSize));
int ToX = static_cast<int>(floor((EntityAABB.left +
EntityAABB.width) / TileSize));
int FromY = static_cast<int>(floor(EntityAABB.top / TileSize));
int ToY = static_cast<int>(floor((EntityAABB.top +
EntityAABB.height) / TileSize));
for (int x = FromX; x <= ToX; ++x) {
for (int y = FromY; y <= ToY; ++y) {
for (size_t l = l_pos->GetElevation(); l <
l_pos->GetElevation() + 1; ++l)
{
auto t = m_gameMap->GetTile(x, y, l);
if (!t) { continue; }
if (!t->m_solid) { continue; }
sf::FloatRect TileAABB = static_cast<sf::FloatRect>(
sf::IntRect(x*TileSize, y*TileSize,TileSize,TileSize));
sf::FloatRect Intersection;
EntityAABB.intersects(TileAABB, Intersection);
float S = Intersection.width * Intersection.height;
l_collisions.emplace_back(S, t->m_properties, TileAABB);
break;
}
}
}
}
此方法使用实体碰撞框和地图瓦片大小来确定与之相交的瓦片范围。然后我们使用这个范围逐个获取瓦片,检查它们是否存在且是实心的,构建它们的边界框,测量相交区域的面积,并将所有这些信息添加到碰撞容器中。到目前为止,一切顺利!
这个系统的最终大戏当然是处理收集到的所有碰撞信息:
void S_Collision::HandleCollisions(const EntityId& l_entity,
C_Position* l_pos, C_Collidable* l_col,Collisions& l_collisions)
{
sf::FloatRect EntityAABB = l_col->GetCollidable();
unsigned int TileSize = m_gameMap->GetTileSize();
if (l_collisions.empty()) { return; }
std::sort(l_collisions.begin(), l_collisions.end(),
[](CollisionElement& l_1, CollisionElement& l_2) {
return l_1.m_area > l_2.m_area;
}
);
for (auto &col : l_collisions) {
EntityAABB = l_col->GetCollidable();
if (!EntityAABB.intersects(col.m_tileBounds)) { continue; }
float xDiff = (EntityAABB.left + (EntityAABB.width / 2)) -
(col.m_tileBounds.left + (col.m_tileBounds.width / 2));
float yDiff = (EntityAABB.top + (EntityAABB.height / 2)) -
(col.m_tileBounds.top + (col.m_tileBounds.height / 2));
float resolve = 0;
if (std::abs(xDiff) > std::abs(yDiff)) {
if (xDiff > 0) {
resolve=(col.m_tileBounds.left+TileSize)-EntityAABB.left;
} else {
resolve = -((EntityAABB.left + EntityAABB.width) -
col.m_tileBounds.left);
}
l_pos->MoveBy(resolve, 0);
l_col->SetPosition(l_pos->GetPosition());
m_systemManager->AddEvent(
l_entity, (EventID)EntityEvent::Colliding_X);
l_col->CollideOnX();
} else {
if (yDiff > 0) {
resolve=(col.m_tileBounds.top + TileSize)-EntityAABB.top;
} else {
resolve = -((EntityAABB.top + EntityAABB.height) -
col.m_tileBounds.top);
}
l_pos->MoveBy(0, resolve);
l_col->SetPosition(l_pos->GetPosition());
m_systemManager->AddEvent(
l_entity, (EventID)EntityEvent::Colliding_Y);
l_col->CollideOnY();
}
}
}
首先检查碰撞容器是否为空。如果不为空,我们将碰撞信息排序,以便按降序流动,并使用相交区域的尺寸进行比较。这确保了具有最大相交面积的碰撞(或碰撞)首先被处理。
在处理这些信息的过程中,我们首先需要检查实体的边界框是否仍然与瓦片发生碰撞。在发生多次碰撞的情况下,首先处理的那次碰撞可能会将实体移动到不再与任何物体发生碰撞的位置。
xDiff和yDiff变量用于存储每个轴的穿透信息,而resolve变量将用于存储实体将要被推开的精确距离,以解决碰撞。然后比较这两个变量,以决定在哪个轴上解决碰撞。我们的resolve变量用于根据是左到右还是右到左的碰撞来计算推开的精确距离。
最后,通过调整相关轴上的解析距离,更新可碰撞组件的位置以匹配这些变化,发送出碰撞事件,并调用可碰撞组件的CollideOnX或CollideOnY方法来更新碰撞标志。然后这些事件被其他系统处理,例如我们之前提到的S_Movement。
控制实体
由于我们已经奠定了代码基础,现在可以专注于控制屏幕上的实体。无论是通过键盘控制作为玩家角色,还是通过某种形式的人工智能(AI),它们仍然需要这个基本组件:
class C_Controller : public C_Base{
public:
C_Controller() : C_Base(Component::Controller){}
void ReadIn(std::stringstream& l_stream){}
};
如你所见,到目前为止,我们在这里绝对没有任何数据被存储。现在,它可以简单地被认为是一个特定的签名,让 ECS 知道它可以被控制。
控制系统
为了控制实体,它们必须具有三种基本组件类型:
S_Control::S_Control(SystemManager* l_systemMgr)
: S_Base(System::Control,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::Movable);
req.TurnOnBit((unsigned int)Component::Controller);
m_requiredComponents.push_back(req);
req.Clear();
}
实际的控制是通过事件系统来实现的:
void S_Control::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
switch(l_event){
case EntityEvent::Moving_Left:
MoveEntity(l_entity, Direction::Left); break;
case EntityEvent::Moving_Right:
MoveEntity(l_entity, Direction::Right); break;
case EntityEvent::Moving_Up:
MoveEntity(l_entity, Direction::Up); break;
case EntityEvent::Moving_Down:
MoveEntity(l_entity, Direction::Down); break;
}
}
运动本身只是对可移动组件的修改,如下所示:
void S_Control::MoveEntity(const EntityId& l_entity,
const Direction& l_dir)
{
auto mov = m_systemManager->GetEntityManager()->
GetComponent<C_Movable>(l_entity, Component::Movable);
mov->Move(l_dir);
}
C_Movable组件类型负责实际修改其数据。我们只需要传递一个有效的方向。
实体状态
现在实体能够移动意味着它们要么是静止的,要么是在移动。这迅速引发了实体状态的问题。幸运的是,我们有一个优雅的方式来处理这个问题,通过引入另一个组件类型和一个系统。让我们首先列举所有可能的实体状态,并使用枚举来建立组件类型:
enum class EntityState{ Idle, Walking, Attacking, Hurt, Dying };
class C_State : public C_Base{
public:
C_State(): C_Base(Component::State){}
void ReadIn(std::stringstream& l_stream){
unsigned int state = 0;
l_stream >> state;
m_state = static_cast<EntityState>(state);
}
EntityState GetState() const { ... }
void SetState(const EntityState& l_state){ ... }
private:
EntityState m_state;
};
我们需要在组件类中跟踪的所有内容就是这些。现在是时候继续到系统部分了!
状态系统
因为状态并没有直接绑定到其他任何数据上,所以我们只能要求存在一种组件类型来处理状态:
S_State::S_State(SystemManager* l_systemMgr)
: S_Base(System::State,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::State);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Move,this);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Switch_State,this);
}
这个系统还需要订阅两种不同的消息类型:Move和Switch_State。显然,动作是依赖于状态的,例如,如果一个实体已经死亡,它就不应该能够移动。
使用状态更新实体相当基础,因为我们即将间接利用运动系统:
void S_State::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
auto state = entities->
GetComponent<C_State>(entity, Component::State);
if(state->GetState() == EntityState::Walking){
Message msg((MessageType)EntityMessage::Is_Moving);
msg.m_receiver = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
目前我们关心的是当前实体的状态是否为Walking,但实体已经处于空闲状态。为此,我们可以发送出Is_Moving消息,如果实体已经停止,S_Movement将响应这个事件。然后这个事件在这里被处理:
void S_State::HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event)
{
switch(l_event){
case EntityEvent::Became_Idle:
ChangeState(l_entity,EntityState::Idle,false);
break;
}
}
调用一个私有方法来改变实体的状态,将其设置为Idle。小菜一碟!
接下来,让我们处理这个系统订阅的消息类型:
void S_State::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver)){ return; }
EntityMessage m = static_cast<EntityMessage>(l_message.m_type);
switch(m){
case EntityMessage::Move:
{
auto state = m_systemManager->GetEntityManager()->
GetComponent<C_State>(l_message.m_receiver,
Component::State);
if (state->GetState() == EntityState::Dying){ return; }
EntityEvent e;
Direction dir = static_cast<Direction>(l_message.m_int);
if (dir==Direction::Up){e=EntityEvent::Moving_Up;}
else if (dir==Direction::Down){e=EntityEvent::Moving_Down;}
else if(dir==Direction::Left){e=EntityEvent::Moving_Left;}
else if(dir==Direction::Right){e=EntityEvent::Moving_Right;}
m_systemManager->AddEvent(l_message.m_receiver,
static_cast<EventID>(e));
ChangeState(l_message.m_receiver,
EntityState::Walking,false);
}
break;
case EntityMessage::Switch_State:
ChangeState(l_message.m_receiver,
(EntityState)l_message.m_int,false);
break;
}
}
由于实际实体移动取决于其状态,因此这是决定是否有移动的系统。首先检查实体的状态,以确保它不能移动如果它正在死亡。然后构建一个EntityEvent结构并将其设置为与Move消息的方向匹配。事件派发后,实体的状态将更改为Walking。
ECS 中的其他系统可能关心实体状态的改变。为此,我们需要相应地处理这些更改:
void S_State::ChangeState(const EntityId& l_entity,
const EntityState& l_state, bool l_force)
{
EntityManager* entities = m_systemManager->GetEntityManager();
auto state = entities->
GetComponent<C_State>(l_entity, Component::State);
if (!l_force && state->GetState()==EntityState::Dying){return;}
state->SetState(l_state);
Message msg((MessageType)EntityMessage::State_Changed);
msg.m_receiver = l_entity;
msg.m_int = static_cast<int>(l_state);
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
注意这个方法最后一个参数。它指示状态更改是否应该被强制执行。这样做是为了确保某些状态更改可以定义为非关键性,如果实体正在死亡,则应该忽略。
如果状态最终被更改,组件数据将被更新,并且将派发一个新的State_Changed消息来通知其他系统。
纸张动画系统
对状态更改敏感的对象之一是精灵图集动画系统。如果我们希望应用描述其实际动作的动画,了解实体的状态至关重要:
S_SheetAnimation::S_SheetAnimation(SystemManager* l_systemMgr)
: S_Base(System::SheetAnimation,l_systemMgr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::SpriteSheet);
req.TurnOnBit((unsigned int)Component::State);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::State_Changed,this);
}
如您所见,我们需要的只是两种组件类型和一个订阅State_Changed消息类型。到目前为止,一切顺利!
更新精灵图集可能有点复杂,所以让我们直接深入探讨:
void S_SheetAnimation::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for(auto &entity : m_entities){
auto sheet = entities->
GetComponent<C_SpriteSheet>(entity, Component::SpriteSheet);
auto state = entities->
GetComponent<C_State>(entity, Component::State);
sheet->GetSpriteSheet()->Update(l_dT);
const std::string& animName = sheet->
GetSpriteSheet()->GetCurrentAnim()->GetName();
if(animName == "Attack"){
if(!sheet->GetSpriteSheet()->GetCurrentAnim()->IsPlaying())
{
Message msg((MessageType)EntityMessage::Switch_State);
msg.m_receiver = entity;
msg.m_int = static_cast<int>(EntityState::Idle);
m_systemManager->GetMessageHandler()->Dispatch(msg);
} else if(sheet->GetSpriteSheet()->
GetCurrentAnim()->IsInAction())
{
Message msg((MessageType)EntityMessage::Attack_Action);
msg.m_sender = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
} else if(animName == "Death" &&
!sheet->GetSpriteSheet()->GetCurrentAnim()->IsPlaying())
{
Message msg((MessageType)EntityMessage::Dead);
msg.m_receiver = entity;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
if (sheet->GetSpriteSheet()->GetCurrentAnim()->CheckMoved()){
int frame = sheet->GetSpriteSheet()->
GetCurrentAnim()->GetFrame();
Message msg((MessageType)EntityMessage::Frame_Change);
msg.m_receiver = entity;
msg.m_int = frame;
m_systemManager->GetMessageHandler()->Dispatch(msg);
}
}
}
获取精灵图集和状态组件后,图集将被更新。然后获取其当前动画的名称。请注意,某些实体状态取决于当前动画,并且一旦该动画结束,我们希望切换回空闲状态。例如,首先检查攻击动画是否不再播放。如果是这样,则向状态系统发送消息,让它知道这个实体的状态需要切换到空闲。此外,检查动画的动作范围,这用于确定例如,攻击动画的当前帧是否是角色挥舞剑的正确时机,我们可以造成伤害。
与死亡动画的原理完全相同,只是在完成时发送的消息不同。
最后,必须检查每个动画的帧进度,在这种情况下,将发送消息,通知对该类型感兴趣的系统动画帧已更改。
如前所述,精灵图集需要知道实体的状态是否已更改。这就是我们处理的地方:
void S_SheetAnimation::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver)) { return; }
EntityMessage m = static_cast<EntityMessage>(l_message.m_type);
switch(m){
case EntityMessage::State_Changed:
{
EntityState s = static_cast<EntityState>(l_message.m_int);
switch(s){
case EntityState::Idle:
ChangeAnimation(l_message.m_receiver,"Idle",true,true);
break;
case EntityState::Walking:
ChangeAnimation(l_message.m_receiver,"Walk",true,true);
break;
case EntityState::Attacking:
ChangeAnimation(l_message.m_receiver,"Attack",true,false);
break;
case EntityState::Hurt: break;
case EntityState::Dying:
ChangeAnimation(l_message.m_receiver,"Death",true,false);
break;
}
}
break;
}
}
这实际上只是将特定动画的名称映射到状态。用于设置此状态的私有方法相当简单:
void S_SheetAnimation::ChangeAnimation(const EntityId& l_entity,
const std::string& l_anim, bool l_play, bool l_loop)
{
auto sheet = m_systemManager->GetEntityManager()->
GetComponent<C_SpriteSheet>(l_entity,Component::SpriteSheet);
sheet->GetSpriteSheet()->SetAnimation(l_anim,l_play,l_loop);
}
它接受实体标识符、动画名称、一个标志表示动画是否应该自动播放,以及另一个标志表示是否应该循环。然后请求组件内的精灵图集播放提供的动画。
实体声音
就像状态一样,一个实体可以发出多种不同类型的声音。每种不同类型也必须与某些参数相关联:
enum class EntitySound{ None = -1, Footstep, Attack,
Hurt, Death, COUNT };
struct SoundParameters{
static const int Max_SoundFrames = 5;
SoundParameters(){
for (int i = 0; i < Max_SoundFrames; ++i){ m_frames[i] = -1; }
}
std::string m_sound;
std::array<int, Max_SoundFrames> m_frames;
};
struct SoundParameters仅仅存储声音的名称,以及一个整数数组,表示声音帧的最大数量。声音帧是声音和精灵图集之间的粘合剂,因为它定义了在哪些动画帧期间发出声音。
- 定义了前面的数据结构后,我们可以成功创建一个声音发射器组件类型:
class C_SoundEmitter : public C_Base{
public:
C_SoundEmitter():C_Base(Component::SoundEmitter),m_soundID(-1){}
void ReadIn(std::stringstream& l_stream){
std::string main_delimiter = ":";
std::string frame_delimiter = ",";
for (size_t i=0;i<static_cast<size_t>(EntitySound::COUNT);++i)
{
std::string chunk;
l_stream >> chunk;
if (chunk.empty()){ break; }
std::string sound = chunk.substr(0,
chunk.find(main_delimiter));
std::string frames = chunk.substr(chunk.find(main_delimiter)
+main_delimiter.length());
m_params[i].m_sound = sound;
size_t pos = 0;
unsigned int frameNum = 0;
while (frameNum < SoundParameters::Max_SoundFrames){
pos = frames.find(frame_delimiter);
int frame = -1;
if (pos != std::string::npos){
frame = stoi(frames.substr(0, pos));
frames.erase(0, pos + frame_delimiter.length());
} else {
frame = stoi(frames);
m_params[i].m_frames[frameNum] = frame;
break;
}
m_params[i].m_frames[frameNum] = frame;
++frameNum;
}
}
}
SoundID GetSoundID() const { ... }
void SetSoundID(const SoundID& l_id){ ... }
const std::string& GetSound(const EntitySound& l_snd) const{...}
bool IsSoundFrame(const EntitySound& l_snd, int l_frame) const
{ ... }
SoundParameters* GetParameters() { ... }
private:
std::array<SoundParameters,
static_cast<size_t>(EntitySound::COUNT)> m_params;
SoundID m_soundID;
};
我们在这里存储的唯一数据是每个EntitySound枚举类型的SoundParameter对象数组,以及一个SoundID数据成员,它将在声音系统中使用,以确保同一时间只有一个实体声音正在播放。用于反序列化的长方法只是正确加载声音帧。
在我们可以继续之前,还需要另一个更基本的组件类型,即声音监听器:
class C_SoundListener : public C_Base{
public:
C_SoundListener() : C_Base(Component::SoundListener){}
void ReadIn(std::stringstream& l_stream){}
};
这,就像C_Controller一样,基本上只是一个标志,让声音系统知道拥有它的实体应该被视为监听器。我们需要小心处理这一点,因为任何时候都应该只有一个声音监听器存在。
声音系统
负责管理实体声音的系统使用组件签名掩码的方式,允许识别多种不同的组合:
S_Sound::S_Sound(SystemManager* l_systemMgr)
: S_Base(System::Sound, l_systemMgr),
m_audioManager(nullptr), m_soundManager(nullptr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::SoundEmitter);
m_requiredComponents.push_back(req);
req.ClearBit((unsigned int)Component::SoundEmitter);
req.TurnOnBit((unsigned int)Component::SoundListener);
m_requiredComponents.push_back(req);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Direction_Changed, this);
m_systemManager->GetMessageHandler()->
Subscribe(EntityMessage::Frame_Change, this);
}
我们希望实体具有位置组件,以及发射器或监听器组件。同时订阅了类型为Direction_Changed的消息以及Frame_Change。
更新这些组件看起来是这样的:
void S_Sound::Update(float l_dT){
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities){
auto c_pos = entities->
GetComponent<C_Position>(entity, Component::Position);
auto position = c_pos->GetPosition();
auto elevation = c_pos->GetElevation();
auto IsListener = entities->
HasComponent(entity, Component::SoundListener);
if (IsListener){
sf::Listener::setPosition(
MakeSoundPosition(position, elevation));
}
if (!entities->HasComponent(entity, Component::SoundEmitter))
{ continue; }
auto c_snd = entities->
GetComponent<C_SoundEmitter>(entity,Component::SoundEmitter);
if (c_snd->GetSoundID() == -1){ continue; }
if (!IsListener){
if (!m_soundManager->SetPosition(c_snd->GetSoundID(),
MakeSoundPosition(position, elevation)))
{ c_snd->SetSoundID(-1); }
} else {
if (!m_soundManager->IsPlaying(c_snd->GetSoundID())){
c_snd->SetSoundID(-1);
}
}
}
}
实体被检查是否是一个有效的监听器。如果是,SFML 的监听器位置被设置为实体的位置,包括高度。在这里我们使用一个私有辅助方法来构建一个 3D 向量,这将在稍后进行介绍。
如果实体有一个声音发射器组件,并且其声音标识符不等于-1,这表示没有声音正在播放,那么如果实体不是监听器,将尝试更新声音的位置。如果位置更新失败,或者声音不再播放,其标识符将被设置回-1。
接下来是消息处理:
void S_Sound::Notify(const Message& l_message){
if (!HasEntity(l_message.m_receiver)){ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
auto IsListener = entities->
HasComponent(l_message.m_receiver, Component::SoundListener);
EntityMessage m = static_cast<EntityMessage>(l_message.m_type);
switch (m){
case EntityMessage::Direction_Changed:
{
if (!IsListener){ return; }
Direction dir = static_cast<Direction>(l_message.m_int);
switch (dir){
case Direction::Up:
sf::Listener::setDirection(0, 0, -1); break;
case Direction::Down:
sf::Listener::setDirection(0, 0, 1); break;
case Direction::Left:
sf::Listener::setDirection(-1, 0, 0); break;
case Direction::Right:
sf::Listener::setDirection(1, 0, 0); break;
}
}
break;
case EntityMessage::Frame_Change:
if (!entities->
HasComponent(l_message.m_receiver,Component::SoundEmitter))
{ return; }
auto state = entities->
GetComponent<C_State>(l_message.m_receiver,Component::State)
->GetState();
auto sound = EntitySound::None;
if(state ==EntityState::Walking){sound=EntitySound::Footstep;}
else if (state == EntityState::Attacking){
sound = EntitySound::Attack;
} else if (state == EntityState::Hurt){
sound = EntitySound::Hurt;
} else if (state == EntityState::Dying){
sound = EntitySound::Death;
}
if (sound == EntitySound::None){ return; }
EmitSound(l_message.m_receiver, sound, false,
IsListener, l_message.m_int);
break;
}
}
我们只关心方向改变消息,如果我们的实体是一个监听器,在这种情况下,全局监听器方向简单地更新以反映这些变化。
如果帧发生变化,我们首先确保实体是一个声音发射器。如果是,它的当前状态将与一个将要播放的声音类型相匹配。然后调用私有的EmitSound方法:
void S_Sound::EmitSound(const EntityId& l_entity,
const EntitySound& l_sound, bool l_useId, bool l_relative,
int l_checkFrame)
{
if (!HasEntity(l_entity)){ return; }
if (!m_systemManager->GetEntityManager()->
HasComponent(l_entity, Component::SoundEmitter))
{ return; }
EntityManager* entities = m_systemManager->GetEntityManager();
auto c_snd = entities->GetComponent<C_SoundEmitter>(
l_entity, Component::SoundEmitter);
if (c_snd->GetSoundID() != -1 && l_useId){ return; }
if (l_checkFrame != -1 &&
!c_snd->IsSoundFrame(l_sound, l_checkFrame))
{ return; }
auto c_pos = entities->
GetComponent<C_Position>(l_entity, Component::Position);
auto pos = (l_relative ?
sf::Vector3f(0.f, 0.f, 0.f) :
MakeSoundPosition(c_pos->GetPosition(),c_pos->GetElevation()));
if (l_useId){
c_snd->SetSoundID(m_soundManager->Play(
c_snd->GetSound(l_sound), pos));
} else {
m_soundManager->Play(c_snd->GetSound(l_sound),
pos, false, l_relative);
}
}
在所有组件和实体检查通过之后,检查声音发射器是否已经发出另一个声音,以防我们想使用现有的 ID。然后检查声音帧,并根据实体是否是监听器来计算声音的位置。最后,根据我们是否使用声音 ID,调用声音管理器的 Play 方法,并可能存储其返回的声音 ID。
为了在 ECS 中结束声音主题,以及本章整个 ECS 部分,让我们看看我们是如何根据实体的 x 和 y 位置以及其海拔来构建 3D 声音位置的:
sf::Vector3f S_Sound::MakeSoundPosition(
const sf::Vector2f& l_entityPos, unsigned int l_elevation)
{
return sf::Vector3f(
l_entityPos.x,
static_cast<float>(l_elevation * Sheet::Tile_Size),
l_entityPos.y
);
}
sf::Vector3f 的 z 成员用于存储 高度,这仅仅是海拔乘以瓦片大小。
实现菜单状态
在后端的大部分内容已经介绍完毕后,我们准备转向前端,开始处理项目的更多交互式方面,例如界面。让我们先创建一个主菜单:
void State_MainMenu::OnCreate(){
auto context = m_stateMgr->GetContext();
GUI_Manager* gui = context->m_guiManager;
gui->LoadInterface("MainMenu.interface", "MainMenu");
gui->GetInterface("MainMenu")->SetPosition(
sf::Vector2f(250.f, 168.f));
EventManager* eMgr = context->m_eventManager;
eMgr->AddCallback("MainMenu_Play", &State_MainMenu::Play, this);
eMgr->AddCallback("MainMenu_Quit", &State_MainMenu::Quit, this);
}
所有这些类已经在 [第一章](http://Chapter 1) 中介绍过,内部结构 - 设置后端,但让我们再次快速概述一下它做了什么。在获得共享上下文后,加载主菜单界面并将其放置在屏幕上。然后使用 m_eventManager 将主菜单按钮点击绑定到这个类的函数上。
这些资源/绑定显然在状态销毁时必须移除:
void State_MainMenu::OnDestroy(){
m_stateMgr->GetContext()->m_guiManager->
RemoveInterface(StateType::Game, "MainMenu");
EventManager* eMgr = m_stateMgr->GetContext()->m_eventManager;
eMgr->RemoveCallback(StateType::MainMenu, "MainMenu_Play");
eMgr->RemoveCallback(StateType::MainMenu, "MainMenu_Quit");
}
在激活主菜单状态时,我们将想要检查是否已经添加了游戏状态:
void State_MainMenu::Activate(){
auto& play = *m_stateMgr->GetContext()->m_guiManager->
GetInterface("MainMenu")->GetElement("Play");
if (m_stateMgr->HasState(StateType::Game)){
// Resume
play.SetText("Resume");
} else {
// Play
play.SetText("Play");
}
}
这确保了菜单中的第一个按钮准确地反映了游戏状态的存在/不存在。
最后,这里是主菜单按钮的回调函数:
void State_MainMenu::Play(EventDetails* l_details){
m_stateMgr->SwitchTo(StateType::Game);
}
void State_MainMenu::Quit(EventDetails* l_details){
m_stateMgr->GetContext()->m_wind->Close();
}
如果点击播放按钮,我们将切换到游戏状态,无论它是否存在。另一方面,退出按钮会到达窗口类并关闭它。
实现游戏状态
现在越来越有趣了。游戏状态是所有乐趣发生的地方,因此我们需要确保它被正确设置。让我们像往常一样,从创建状态开始:
void State_Game::OnCreate() {
auto context = m_stateMgr->GetContext();
EventManager* evMgr = context->m_eventManager;
evMgr->AddCallback("Key_Escape", &State_Game::MainMenu, this);
evMgr->AddCallback("Player_MoveLeft",
&State_Game::PlayerMove, this);
evMgr->AddCallback("Player_MoveRight",
&State_Game::PlayerMove, this);
evMgr->AddCallback("Player_MoveUp",
&State_Game::PlayerMove, this);
evMgr->AddCallback("Player_MoveDown",
&State_Game::PlayerMove, this);
sf::Vector2u size = context->m_wind->GetWindowSize();
m_view.setSize(static_cast<float>(size.x),
static_cast<float>(size.y));
m_view.setCenter(static_cast<float>(size.x) / 2,
static_cast<float>(size.y) / 2);
m_view.zoom(0.6f);
auto loading = m_stateMgr->
GetState<State_Loading>(StateType::Loading);
context->m_gameMap->AddFile(
Utils::GetWorkingDirectory() + "media/Maps/map1.map");
loading->AddLoader(context->m_gameMap);
loading->SetManualContinue(true);
context->m_soundManager->PlayMusic("TownTheme", 50.f, true);
}
首先,我们将所有我们感兴趣的相关事件绑定到这个类的函数上。这包括退格键,它只是简单地切换回菜单状态,以及四个玩家移动键。然后设置这个状态的观点,以便稍微放大,以便更好地看到角色。
最后几行获取加载状态,并将游戏地图和瓦片集作为加载器添加到其中,紧随地图和瓦片集文件之后。
自然地,这些回调在状态销毁时将需要解除绑定:
void State_Game::OnDestroy(){
auto context = m_stateMgr->GetContext();
EventManager* evMgr = context->m_eventManager;
evMgr->RemoveCallback(StateType::Game, "Key_Escape");
evMgr->RemoveCallback(StateType::Game, "Key_O");
evMgr->RemoveCallback(StateType::Game, "Player_MoveLeft");
evMgr->RemoveCallback(StateType::Game, "Player_MoveRight");
evMgr->RemoveCallback(StateType::Game, "Player_MoveUp");
evMgr->RemoveCallback(StateType::Game, "Player_MoveDown");
context->m_gameMap->PurgeMap();
context->m_gameMap->GetTileSet()->Purge();
}
注意
注意,游戏地图和瓦片集也在这里被清除。
更新游戏状态仅涉及更新其自己的相机,以及游戏地图和 ECS 系统管理器:
void State_Game::Update(const sf::Time& l_time){
auto context = m_stateMgr->GetContext();
UpdateCamera();
context->m_gameMap->Update(l_time.asSeconds());
context->m_systemManager->Update(l_time.asSeconds());
}
状态的相机(或视图)更新如下:
void State_Game::UpdateCamera(){
if (m_player == -1){ return; }
SharedContext* context = m_stateMgr->GetContext();
auto pos = m_stateMgr->GetContext()->m_entityManager->
GetComponent<C_Position>(m_player, Component::Position);
m_view.setCenter(pos->GetPosition());
context->m_wind->GetRenderWindow()->setView(m_view);
sf::FloatRect viewSpace = context->m_wind->GetViewSpace();
if (viewSpace.left <= 0){
m_view.setCenter(viewSpace.width / 2, m_view.getCenter().y);
context->m_wind->GetRenderWindow()->setView(m_view);
} else if (viewSpace.left + viewSpace.width >
(context->m_gameMap->GetMapSize().x) * Sheet::Tile_Size)
{
m_view.setCenter(
((context->m_gameMap->GetMapSize().x) * Sheet::Tile_Size) -
(viewSpace.width / 2),
m_view.getCenter().y);
context->m_wind->GetRenderWindow()->setView(m_view);
}
if (viewSpace.top <= 0){
m_view.setCenter(m_view.getCenter().x, viewSpace.height / 2);
context->m_wind->GetRenderWindow()->setView(m_view);
} else if (viewSpace.top + viewSpace.height >
(context->m_gameMap->GetMapSize().y) * Sheet::Tile_Size)
{
m_view.setCenter(
m_view.getCenter().x,
((context->m_gameMap->GetMapSize().y) * Sheet::Tile_Size) -
(viewSpace.height / 2));
context->m_wind->GetRenderWindow()->setView(m_view);
}
}
这可能看起来很多,但基本思路是首先获取我们玩家的位置,然后使用这些坐标来使视图居中,或者以这种方式定位,使得地图的边缘正好在视图的边缘。想法是不将状态视图移动到地图之外。
绘制也是相当直接的:
void State_Game::Draw(){
auto context = m_stateMgr->GetContext();
for (unsigned int i = 0; i < Sheet::Num_Layers; ++i){
context->m_gameMap->Draw(i);
m_stateMgr->GetContext()->m_systemManager->Draw(
m_stateMgr->GetContext()->m_wind, i);
}
}
对于游戏地图支持的每个层/海拔,都会启动一个循环。首先绘制该层的地图数据,然后系统管理器在该层上绘制实体。
让我们来看看我们的玩家移动的回调方法:
void State_Game::PlayerMove(EventDetails* l_details){
Message msg((MessageType)EntityMessage::Move);
if (l_details->m_name == "Player_MoveLeft"){
msg.m_int = static_cast<int>(Direction::Left);
} else if (l_details->m_name == "Player_MoveRight"){
msg.m_int = static_cast<int>(Direction::Right);
} else if (l_details->m_name == "Player_MoveUp"){
msg.m_int = static_cast<int>(Direction::Up);
} else if (l_details->m_name == "Player_MoveDown"){
msg.m_int = static_cast<int>(Direction::Down);
}
msg.m_receiver = m_player;
m_stateMgr->GetContext()->m_systemManager->
GetMessageHandler()->Dispatch(msg);
}
每次调用此方法时,都会构建一个新的Move消息。它携带的方向基于实际的事件名称设置。在存储接收实体(玩家)后,消息被分发。
最后,我们有一个回调和状态激活方法:
void State_Game::MainMenu(EventDetails* l_details){
m_stateMgr->SwitchTo(StateType::MainMenu);
}
void State_Game::Activate() {
auto map = m_stateMgr->GetContext()->m_gameMap;
m_player = map->GetPlayerId();
map->Redraw();
}
如果按下Esc键,我们简单地切换到主菜单状态。如果状态随后切换回Game,则调用它的Activate方法。我们使用这个功能来重新获取玩家 ID,以防它已更改。
主游戏类
我们现在要做的就是将所有东西组合在一起。我们将使用一个Game类来完成这个任务,所以让我们来看看它:
class Game{
public:
Game();
~Game();
void Update();
void Render();
void LateUpdate();
sf::Time GetElapsed();
Window* GetWindow();
private:
void SetUpClasses();
void SetUpECS();
void SetUpStates();
void RestartClock();
sf::Clock m_clock;
sf::Time m_elapsed;
SharedContext m_context;
RandomGenerator m_rand;
Window m_window;
TextureManager m_textureManager;
FontManager m_fontManager;
AudioManager m_audioManager;
SoundManager m_soundManager;
GUI_Manager m_guiManager;
SystemManager m_systemManager;
EntityManager m_entityManager;
Map m_gameMap;
std::unique_ptr<StateManager> m_stateManager;
};
这个类包含了我们讨论过的所有类,所以让我们从构造函数开始设置它们:
Game::Game()
: m_window("Chapter 2", sf::Vector2u(800, 600), false),
m_entityManager(&m_systemManager, &m_textureManager),
m_guiManager(m_window.GetEventManager(), &m_context),
m_soundManager(&m_audioManager),
m_gameMap(&m_window, &m_entityManager, &m_textureManager)
{
SetUpClasses();
SetUpECS();
SetUpStates();
m_fontManager.RequireResource("Main");
m_stateManager->SwitchTo(StateType::Intro);
}
Game::~Game(){ m_fontManager.ReleaseResource("Main"); }
初始化列表用于设置我们类中需要在其构造函数内满足的任何依赖项。构造函数体的其余部分用于调用三个私有setup方法,以及要求使用整个游戏的主字体,并切换到Intro状态。
我们还需要从这个类中获取一些基本的设置器和获取器:
sf::Time Game::GetElapsed(){ return m_clock.getElapsedTime(); }
void Game::RestartClock(){ m_elapsed = m_clock.restart(); }
Window* Game::GetWindow(){ return &m_window; }
在处理完这些之后,让我们实际更新所有代码:
void Game::Update(){
m_window.Update();
m_stateManager->Update(m_elapsed);
m_guiManager.Update(m_elapsed.asSeconds());
m_soundManager.Update(m_elapsed.asSeconds());
GUI_Event guiEvent;
while (m_context.m_guiManager->PollEvent(guiEvent)){
m_window.GetEventManager()->HandleEvent(guiEvent);
}
}
在相关管理器更新后,轮询 GUI 事件并将它们传递给事件管理器进行处理。
接下来,让我们看看在Render调用期间需要发生什么:
void Game::Render(){
m_window.BeginDraw();
// Render here.
m_stateManager->Draw();
m_guiManager.Render(m_window.GetRenderWindow());
m_window.EndDraw();
}
这也很基础。由于我们总是想绘制状态,所以将状态管理器的Draw调用放在这里。实际上,我们还将始终绘制 GUI。
一个很好的小功能是后期更新,可以用来处理无法进入常规更新的任何内容:
void Game::LateUpdate(){
m_stateManager->ProcessRequests();
RestartClock();
}
在这里处理状态管理器的移除请求,以及重新启动游戏时钟。
构造函数中调用的三个私有方法之一,帮助我们设置所有类,可以像这样实现:
void Game::SetUpClasses() {
m_clock.restart();
m_context.m_rand = &m_rand;
srand(static_cast<unsigned int>(time(nullptr)));
m_systemManager.SetEntityManager(&m_entityManager);
m_context.m_wind = &m_window;
m_context.m_eventManager = m_window.GetEventManager();
m_context.m_textureManager = &m_textureManager;
m_context.m_fontManager = &m_fontManager;
m_context.m_audioManager = &m_audioManager;
m_context.m_soundManager = &m_soundManager;
m_context.m_gameMap = &m_gameMap;
m_context.m_systemManager = &m_systemManager;
m_context.m_entityManager = &m_entityManager;
m_context.m_guiManager = &m_guiManager;
m_stateManager = std::make_unique<StateManager>(&m_context);
m_gameMap.SetStateManager(m_stateManager.get());
}
在随机数生成器初始化后,我们需要确保将每个类绑定到共享上下文,以便能够在任何依赖服务定位器模式的地方访问它们。
另一个我们调用的设置函数处理的是设置实体组件系统:
void Game::SetUpECS() {
m_entityManager.AddComponentType<C_Position>(
Component::Position);
m_entityManager.AddComponentType<C_SpriteSheet>(
Component::SpriteSheet);
m_entityManager.AddComponentType<C_State>(Component::State);
m_entityManager.AddComponentType<C_Movable>(Component::Movable);
m_entityManager.AddComponentType<C_Controller>(
Component::Controller);
m_entityManager.AddComponentType<C_Collidable>(
Component::Collidable);
m_entityManager.AddComponentType<C_SoundEmitter>(
Component::SoundEmitter);
m_entityManager.AddComponentType<C_SoundListener>(
Component::SoundListener);
m_systemManager.AddSystem<S_State>(System::State);
m_systemManager.AddSystem<S_Control>(System::Control);
m_systemManager.AddSystem<S_Movement>(System::Movement);
m_systemManager.AddSystem<S_Collision>(System::Collision);
m_systemManager.AddSystem<S_SheetAnimation>(
System::SheetAnimation);
m_systemManager.AddSystem<S_Sound>(System::Sound);
m_systemManager.AddSystem<S_Renderer>(System::Renderer);
m_systemManager.GetSystem<S_Collision>(System::Collision)->
SetMap(&m_gameMap);
m_systemManager.GetSystem<S_Movement>(System::Movement)->
SetMap(&m_gameMap);
m_systemManager.GetSystem<S_Sound>(System::Sound)->
SetUp(&m_audioManager, &m_soundManager);
}
在这里,所有组件类型和系统都被添加并设置好以供使用。碰撞和运动系统需要访问游戏地图,而声音系统则依赖于音频和声音管理器。
最后的设置与状态及其依赖项有关:
void Game::SetUpStates() {
m_stateManager->AddDependent(m_context.m_eventManager);
m_stateManager->AddDependent(&m_guiManager);
m_stateManager->AddDependent(&m_soundManager);
m_stateManager->RegisterState<State_Intro>(StateType::Intro);
m_stateManager->RegisterState<State_MainMenu>(
StateType::MainMenu);
m_stateManager->RegisterState<State_Game>(StateType::Game);
}
事件、GUI 和声音管理器都需要保持对所有状态变化的最新了解,因此它们必须被注册为依赖项。此外,我们将使用的三个主要状态类型也已注册,这样它们就可以在状态管理器内部使用工厂方法创建。
代码的最后部分
最后,我们应用程序的主要入口点定义在main函数内部,如下所示:
void main(int argc, void** argv[]){
// Program entry point.
{
Game game;
while(!game.GetWindow()->IsDone()){
game.Update();
game.Render();
game.LateUpdate();
}
}
}
在设置好Game实例后,我们开始一个while循环,该循环会一直运行,直到Window实例被关闭。在循环内部,我们更新游戏,渲染它,并调用后期更新方法,以处理所有那些渲染后的任务。
摘要
因此,现在是收尾的好时机。如果你一直跟到最后,恭喜你!你只用几个状态、一些组件和系统就构建了一个基本且完全功能的游戏。本章,就像之前的一章一样,相当紧凑,所以请随意查看代码,并对其结构感到舒适。
在下一章,我们将专注于实现和使用粒子系统,以真正为我们的基础游戏增添活力。那里见!
第三章. 让它下雨!——构建粒子系统
在任何游戏中拥有适当的交互性至关重要。无论是通过让玩家的后果引发连锁反应最终影响他们的状态来娱乐玩家,还是仅仅意味着控制感和输入管理感觉恰到好处,不可否认的是,这是为数不多的可以使游戏成功或失败的因素之一。虽然后者非常重要,但并不是菜单的流畅导航吸引了大多数玩家,这就是为什么在本章中我们将重点关注环境交互以及通过粒子系统实现的美化。
在本章中,我们将讨论以下主题:
-
数组结构存储模式的优点
-
灵活粒子系统的架构和实现
-
创建不同类型的生成器和更新器对象,允许创建各种效果
有很多东西需要学习,所以我们不要浪费时间,直接深入吧!
版权资源的使用
像往常一样,让我们首先感谢所有慷慨的艺术家,他们通过提供在极其灵活的许可下提供的资产,使这一切成为可能:
-
由dbszabo1创作的misc png,根据CC0许可(公有领域):
dbszabo1.deviantart.com/art/misc-png-316228902 -
由Juan Story创作的Jillcreation-overlay-cloud,根据CC0许可(公有领域):
www.effecthub.com/item/5358 -
由William.Thompsonj创作的[LPC] Leaf Recolor,根据CC-BY-SA 3.0和GPL 3.0许可:
opengameart.org/content/lpc-leaf-recolor
粒子系统基础
在实现粒子系统之前,我们需要先了解一些关键概念,以便使我们的系统按预期工作,首先是从数据存储的方式开始。
结构体数组与数组结构体
起初可能会让人想将粒子拥有的所有数据都放入一个单独的类中,给它一些处理特定情况的自定义方法,并将所有这些对象存储在某个通用容器中,如下所示:

虽然这样做确实更简单,但对性能的提升却微乎其微。请记住,我们可能需要处理成千上万的粒子实例,而且这些实例需要以各种不同的方式更新。一个简单的粒子更新循环可能会使缓存看起来像这样:

从性能角度来看,这真是太糟糕了,因为如果我们只需要处理位置,这意味着缓存中所有额外的空间,本可以用来存储其他粒子的位置,现在却只存储了完全无用的数据,至少现在是这样。反过来,当需要更新另一个粒子的位置并请求它时,它很可能不会在缓存中找到,从而导致缓存未命中和时间的浪费。
一个更好的场景可能看起来像这样:

在性能方面,这要好得多,因为缓存中存在的所有数据都保证会被使用。我们如何实现这样的结果?通过将不同的粒子属性存储在自己的容器中,确保内存的连续性,如下所示:
struct S{
int m_x[50];
int m_y[50];
std::string m_name[50];
...
};
然而,存储并不是一切。我们还必须确保,当我们处理此类结构时,只使用相关和必要的数据。然而,这部分内容将在稍后讨论。
粒子的存储
在解决了关键概念之后,让我们看看如何使用SoA模式存储粒子:
class ParticleContainer {
public:
...
const static size_t Max_Particles = 3000;
sf::Vector3f m_position[Max_Particles];
sf::Vector3f m_velocity[Max_Particles];
sf::Vector3f m_acceleration[Max_Particles];
sf::Vector2f m_startSize[Max_Particles];
sf::Vector2f m_currentSize[Max_Particles];
sf::Vector2f m_finalSize[Max_Particles];
sf::Color m_startColor[Max_Particles];
sf::Color m_currentColor[Max_Particles];
sf::Color m_finalColor[Max_Particles];
std::string m_texture[Max_Particles];
sf::RectangleShape m_drawable[Max_Particles];
float m_startRotation[Max_Particles];
float m_currentRotation[Max_Particles];
float m_finalRotation[Max_Particles];
float m_lifespan[Max_Particles];
float m_maxLifespan[Max_Particles];
bool m_gravity[Max_Particles];
bool m_addBlend[Max_Particles];
bool m_alive[Max_Particles];
size_t m_countAlive;
TextureManager* m_textureManager;
protected:
...
};
存储和计算粒子属性有几种方法。在这里,我们使用 C 风格的数组以及一个表示它们大小的静态常量。所有这些属性以及它们的目的将在本章的后续部分中介绍。
在这里还有一些额外的好处是跟踪粒子的数量,以及指向纹理管理器的指针,因为其中一些可能正在使用需要抓取和释放的纹理。当然,它们在构造函数中设置得很好:
ParticleContainer(TextureManager* l_textureManager)
: m_textureManager(l_textureManager), m_countAlive(0)
{ Reset(); }
~ParticleContainer(){
for (size_t i = 0; i < Max_Particles; ++i){
if (m_texture[i].empty()) { continue; }
m_textureManager->ReleaseResource(m_texture[i]);
}
}
这个容器类的析构函数有一个相当简单的工作。为了不留下任何悬而未决的问题,它只需要遍历存储的每个粒子,并检查它是否正在使用纹理,这只是一个字符串标识符。如果是,则释放纹理。
另一个相当重要的任务留给了构造函数,即重置粒子的所有分配内存。这留给Reset()方法:
void Reset(){
for (size_t i = 0; i < Max_Particles; ++i) { ResetParticle(i); }
m_countAlive = 0;
}
ResetParticle私有方法为列表中的每个单独的粒子调用。它负责将所有数据归零,以确保下一个获得相同标识符的粒子不会携带前一个拥有它的粒子遗留的某些属性。
为了有效地管理 SoA 结构,我们将使用这两个关键方法来启用和禁用特定的 ID:
void Enable(size_t l_id){
if (m_countAlive >= Max_Particles) { return; }
m_alive[l_id] = true;
Swap(l_id, m_countAlive);
++m_countAlive;
}
void Disable(size_t l_id){
if (!m_countAlive) { return; }
ResetParticle(l_id);
Swap(l_id, m_countAlive - 1);
--m_countAlive;
}
首先进行一次合理性检查,以确保我们不会在已经存在最大数量粒子的情况下启用任何粒子,或者在没有活跃粒子的情况下禁用任何粒子。启用粒子只需将其存活标志设置为true,而禁用则需要完全重置。然后,所有存储在l_id的数据都会在启用时与最后一个活跃粒子的元素交换,以使其成为最后一个,或者在禁用时与最后一个粒子交换。请考虑以下说明:

虽然它涵盖了禁用粒子的场景,但同样的基本原理也适用于启用。
实际的数据交换过程并不复杂:
void Swap(size_t l_first, size_t l_second) {
std::swap(m_position[l_first], m_position[l_second]);
std::swap(m_velocity[l_first], m_velocity[l_second]);
std::swap(m_acceleration[l_first], m_acceleration[l_second]);
std::swap(m_startSize[l_first], m_startSize[l_second]);
...
}
它简单地在对每个单个粒子的属性在l_first和l_second索引上调用std::swap。
最后,我们来到了单个粒子的实际重置代码:
void ResetParticle(size_t l_id){
m_alive[l_id] = false;
m_gravity[l_id] = false;
m_addBlend[l_id] = false;
m_lifespan[l_id] = 0.f;
m_maxLifespan[l_id] = 0.f;
m_position[l_id] = { 0.f, 0.f, 0.f };
m_velocity[l_id] = { 0.f, 0.f, 0.f };
m_acceleration[l_id] = { 0.f, 0.f, 0.f };
m_startRotation[l_id] = 0.f;
m_currentRotation[l_id] = 0.f;
m_finalRotation[l_id] = 0.f;
m_startSize[l_id] = { 0.f, 0.f };
m_currentSize[l_id] = { 0.f, 0.f };
m_finalSize[l_id] = { 0.f, 0.f };
m_startColor[l_id] = { 0, 0, 0, 0 };
m_currentColor[l_id] = { 0, 0, 0, 0 };
m_finalColor[l_id] = { 0, 0, 0, 0 };
if (!m_texture[l_id].empty()){
m_textureManager->ReleaseResource(m_texture[l_id]);
m_texture[l_id].clear();
m_drawable[l_id].setTexture(nullptr);
}
}
预计,每个粒子的参数都会重置到提供的索引处的适当默认值。如果纹理标识符不为空,资源也会被释放,因为不再需要它。
粒子系统架构
为了适应粒子的存储方式,同时为系统提供更新、交互和灵活性的手段,我们必须仔细考虑其架构。让我们先将其分解成更小的部分,这样更容易单独管理:
-
发射器:存在于世界中的一个对象,充当粒子生成器。它有权访问一个生成器列表,每个发射出的粒子在生成之前都会通过这些生成器。
-
生成器:这属于其他具有直接访问粒子属性并根据它们自己的、预先定义的规则修改它们以实现某种外观的类似对象的列表。
-
更新器:粒子系统拥有的许多对象之一,旨在仅使用特定任务所需的数据,该任务始终与以特定方式更新粒子相关。
-
力应用器:一个小型数据结构,由一个更新器使用,以在世界上创建力,这些力与粒子进行物理交互。
让我们花些时间深入探讨每个单独的部分。
生成器
在这个语境中,发生器将充当一种盖章的作用。它将接收一系列粒子,这些粒子的属性将根据接收它们的生成器的类型进行调整。请考虑以下说明:

一个特定的生成器几乎可以被视为一种盖章。它印在粒子上的某些属性可能是随机的,而其他属性则是恒定的。无论如何,一旦将几个粒子输入其中,它们就会带有生成器负责的属性而被“盖章”。
我们将要实现的所有生成器都需要是通用的,这就是为什么它们都必须遵守提供的接口:
class BaseGenerator {
public:
virtual ~BaseGenerator() {}
virtual void Generate(Emitter* l_emitter,
ParticleContainer* l_particles, size_t l_from, size_t l_to)=0;
friend std::stringstream& operator >> (
std::stringstream& l_stream, BaseGenerator& b)
{
b.ReadIn(l_stream);
return l_stream;
}
virtual void ReadIn(std::stringstream& l_stream){}
};
首先,需要解释一下Generate()方法。它接受一个指向拥有它的Emitter实例的指针。它还接受一个指向它将要与之一起工作的粒子容器的指针。最后两个参数是构成范围的粒子 ID,这代表容器内将启用的粒子。范围本身将在拥有生成器的发射器内部计算。
这个基类还允许派生生成器实现如何从文件中加载它们的属性。这将在我们开始实际创建不同类型的生成器时变得很重要。
发射器
如前所述,发射器只是一个拥有生成器列表的类,以便生成特定类型的粒子。它可以在世界中定位,并负责通过跟踪其发射速率来计算发射的粒子 ID 范围。让我们看看Emitter类的头文件:
class Emitter {
public:
Emitter(const sf::Vector3f& l_position,int l_maxParticles = -1);
void Update(float l_dT, ParticleContainer* l_particles);
void SetPosition(const sf::Vector3f& l_position);
sf::Vector3f GetPosition() const;
size_t GetEmitRate() const;
void SetEmitRate(size_t l_nPerSecond);
void SetParticleSystem(ParticleSystem* l_system);
void SetGenerators(const std::string& l_generators);
std::string GetGenerators() const;
ParticleSystem* GetParticleSystem() const;
private:
std::string m_generators;
size_t m_emitRate;
int m_maxParticles;
sf::Vector3f m_position;
float m_accumulator;
ParticleSystem* m_system;
};
如您所见,这个类实际上并不存储生成器实例的列表。相反,它存储一个字符串标识符,该标识符将用于从粒子系统获取特定风格的粒子列表。
这个类中的所有设置器和获取器都是简单的单行方法,它们正好按照广告宣传的那样执行,所以我们不会涉及它们。
除了所有其他明显的数据成员之外,它还存储一个名为m_accumulator的浮点值,它将与发射速率一起使用。我们很快就会详细介绍它。它还存储一个m_maxParticles数据成员,以便知道是否应该无限期地发射粒子,或者发射器在创建了一定数量的粒子后需要停止。
实现发射器
让我们从将所有数据成员初始化为其默认值的基本开始:
Emitter::Emitter(const sf::Vector3f& l_position,
int l_maxParticles) : m_position(l_position),
m_maxParticles(l_maxParticles), m_emitRate(0),
m_accumulator(0.f), m_system(nullptr){}
这个类中唯一真正重要的方法是Update()方法。它负责在粒子发射时进行所有实际的重负载工作:
void Emitter::Update(float l_dT, ParticleContainer* l_particles){
if (m_generators.empty()) { return; }
auto generatorList = m_system->GetGenerators(m_generators);
if (!generatorList) { return; }
m_accumulator += l_dT * m_emitRate;
if (m_accumulator < 1.f) { return; }
auto num_particles = static_cast<int>(m_accumulator);
m_accumulator -= num_particles;
if (m_maxParticles != -1) {
if (num_particles > m_maxParticles) {
num_particles = m_maxParticles;
m_maxParticles = 0;
}
else { m_maxParticles -= num_particles; }
}
size_t from = l_particles->m_countAlive;
size_t to = (l_particles->m_countAlive + num_particles >
l_particles->Max_Particles ? l_particles->Max_Particles - 1
: l_particles->m_countAlive + num_particles - 1);
for (auto& generator : *generatorList){
generator->Generate(this, l_particles, from, to);
}
for (auto i = from; i <= to; ++i){ l_particles->Enable(i); }
if (!m_maxParticles) { m_system->RemoveEmitter(this); }
}
显然,如果生成器标识符为空,或者我们无法从粒子系统中获取生成器列表,更新将不会发生。只要不是这种情况,m_accumulator 数据成员就会根据发射速率和delta time进行增加。它保存了尚未发射的粒子总数。由于显然我们不能发射半个粒子或任何其他分数,因此会检查累加器数据成员是否小于一。如果是这种情况,就没有东西可以发射/更新。
需要发射的粒子数量通过将累加器值转换为整数来计算。然后从累加器中减去,保留剩余的粒子分数,以便在下一次滴答时使用。
我们知道发射器应该无限期地喷射粒子的方式是,如果其m_maxParticles数据成员被设置为-1。如果不是这样,这一刻要发射的粒子数量将检查是否没有超过限制。
最后,我们来到了有趣的部分。首先,计算将要复活的所有 ID 的范围,确保它不超过允许的最大粒子数。然后遍历发射器喷射出的粒子类型的生成器列表,将当前发射器的指针和粒子列表传递给它们的Generate()方法,同时传递计算出的范围。然后遍历粒子范围,重新启用以再次显示,并检查发射器是否需要被移除,如果m_maxParticles数据成员达到零。
更新器
为了让我们的粒子系统充满活力,我们必须不断更新它。考虑到性能,我们必须坚持 SoA 模式,并且只处理特定场景绝对必要的数据,如下所示:

考虑到这一点,可以为所有更新器创建一个非常基本的接口,看起来像这样:
class BaseUpdater {
public:
virtual ~BaseUpdater() {}
virtual void Update(float l_dT,
ParticleContainer* l_particles) = 0;
};
如您所见,所有更新器应该关注的只是 delta 时间以及它们正在操作的粒子容器的指针。没有必要提供范围,因为它将操作所有存活粒子。
力应用器
因为我们不希望我们的角色在静态、死寂的环境中四处奔跑,所以需要事件和粒子之间的一些交互性。这种关系是通过力的方式建立的,它影响可达粒子动力学状态,如下所示:

力应用器不是粒子系统的基本组成部分。我们为了使它们通用化所需做的只是存储一些数据,并让适当的更新器处理逻辑。话虽如此,让我们看看我们需要存储什么:
struct ForceApplicator {
ForceApplicator(const sf::Vector3f& l_center,
const sf::Vector3f& l_force, float l_radius)
: m_center(l_center), m_force(l_force), m_radius(l_radius){}
sf::Vector3f m_center;
sf::Vector3f m_force;
float m_radius;
};
力可以在世界中定位,因此需要存储其中心。此外,还需要知道力的半径,以便确定影响区域。最后,没有首先知道其强度,就无法产生影响。这就是m_force发挥作用的地方,它允许在所有三个轴向上定义力。
构建粒子系统类
在所有构建块就绪之后,是时候实际构建粒子系统类了。让我们从一些类型定义开始:
using Updaters = std::unordered_map<std::string,
std::unique_ptr<BaseUpdater>>;
using EmitterList = std::vector<std::unique_ptr<Emitter>>;
using Emitters = std::unordered_map<StateType, EmitterList>;
using GeneratorList = std::vector<std::unique_ptr<BaseGenerator>>;
using Generators = std::unordered_map<std::string,GeneratorList>;
using RemovedEmitters = std::vector<Emitter*>;
using Particles = std::unordered_map<StateType,
std::unique_ptr<ParticleContainer>>;
using ForceApplicatorList = std::vector<ForceApplicator>;
using ForceApplicators = std::unordered_map<StateType,
ForceApplicatorList>;
using GeneratorFactory = std::unordered_map<std::string,
std::function<BaseGenerator*(void)>>;
为了访问我们想要的任何更新器,我们可以将它们映射到string标识符。虽然更新器不是状态特定的,但发射器是。它们的列表必须与特定的状态相关联,以在整个应用程序中维护粒子。生成器,就像更新器一样,也不是特定于任何特定状态的,我们希望能够通过Emitter类中的字符串标识符来访问它们。说到这里,正如我们从已经覆盖的代码中可以看出,发射器可以请求移除自身,以防它们应该停止发射粒子。由于这发生在更新周期中,而类仍在使用中,因此必须保留一个单独的发射器指针列表,以便稍后移除。
粒子本身显然存储在指定的ParticleContainer类中,但这些容器显然可以被不同的状态拥有。这与之前类似的想法,我们将状态类型映射到不同的粒子容器,以维护应用程序范围内的粒子支持。相同的原理也适用于力应用器。
我们拥有的最后一种数据类型应该是一个明显的迹象,表明我们将使用工厂设计模式来生产不同类型的粒子生成器。这些类型也将与字符串标识符相关联。
考虑到所有这些,现在是时候讨论如何实现ParticleSystem类了,从其头文件开始:
class ParticleSystem : public FileLoader, public StateDependent,
public MapLoadee
{
public:
ParticleSystem(StateManager* l_stateManager,
TextureManager* l_textureMgr, RandomGenerator* l_rand,
Map* l_map);
void AddEmitter(std::unique_ptr<Emitter> l_emitter,
const StateType& l_state = StateType(0));
void AddForce(ForceApplicator l_force,
const StateType& l_state = StateType(0));
void RemoveEmitter(Emitter* l_emitter);
GeneratorList* GetGenerators(const std::string& l_name);
TextureManager* GetTextureManager() const;
RandomGenerator* GetRand() const;
void CreateState(const StateType& l_state);
void ChangeState(const StateType& l_state);
void RemoveState(const StateType& l_state);
void ReadMapLine(const std::string& l_type,
std::stringstream& l_stream);
void Update(float l_dT);
void ApplyForce(const sf::Vector3f& l_center,
const sf::Vector3f& l_force, float l_radius);
void Draw(Window* l_window, int l_elevation);
private:
bool ProcessLine(std::stringstream& l_stream);
void ResetForNextFile();
template<class T>
void RegisterGenerator(const std::string& l_name) { ... }
std::string m_loadingGenerator;
Particles m_container;
Particles::iterator m_stateItr;
Emitters::iterator m_emitterItr;
Updaters m_updaters;
Emitters m_emitters;
Generators m_generators;
GeneratorFactory m_factory;
ForceApplicators m_forces;
RemovedEmitters m_removedEmitters;
TextureManager* m_textureMgr;
RandomGenerator* m_rand;
Map* m_map;
};
首先,让我们来探讨这个类的继承细节。因为我们将在文本文件中保存粒子的属性,所以从FileLoader继承是有用的,更不用说我们可以将工作卸载到单独的线程上。此外,回想一下,不同的状态将需要访问我们的粒子系统,这意味着粒子管理器必须实现添加、更改和删除状态的方法。最后,请记住,粒子发射器和影响它们的力可能是游戏地图包含的内容,因此我们也在继承MapLoadee类。
这个类本身显然需要访问纹理管理器,稍后会将一个指向它的指针传递给需要它的类。对于随机数生成器也是如此,以及地图实例的指针。
最后,请注意这个类的两个突出显示的数据成员,它们都是迭代器。这些被保留下来是为了在更新/渲染粒子时更容易地访问特定状态的数据。
实现粒子系统
让我们先看看这个类的构造函数:
ParticleSystem::ParticleSystem(StateManager* l_stateManager,
TextureManager* l_textureManager, RandomGenerator* l_rand,
Map* l_map)
: m_stateManager(l_stateManager),m_textureMgr(l_textureManager),
m_rand(l_rand), m_map(l_map)
{
m_updaters.emplace("Lifespan",
std::make_unique<LifespanUpdater>());
...
RegisterGenerator<PointPosition>("PointPosition");
...
}
除了执行所有数据成员设置任务的初始化列表之外,构造函数在这里的另一个目的就是设置所有的更新器和生成器类型。我们上面的代码被大量缩减,但基本思想保持不变。我们想要使用的所有更新器都通过适当的字符串标识符插入到它们的容器中。在生成器方面,我们调用一个私有模板方法,将特定类型的生成器与字符串标识符关联起来。再次强调,这里我们使用了工厂模式。
将发射器对象添加到我们的粒子系统中相对简单:
void ParticleSystem::AddEmitter(
std::unique_ptr<Emitter> l_emitter, const StateType& l_state)
{
l_emitter->SetParticleSystem(this);
if (!GetGenerators(l_emitter->GetGenerators())) {
return;
}
if (l_state == StateType(0)) {
if (m_emitterItr == m_emitters.end()) { return; }
m_emitterItr->second.emplace_back(std::move(l_emitter));
return;
}
auto itr = m_emitters.find(l_state);
if (itr == m_emitters.end()) { return; }
itr->second.emplace_back(std::move(l_emitter));
}
首先,为发射器提供一个指向粒子系统的指针,以便稍后访问。然后我们检查发射器的生成器列表名称是否有效。拥有一个将要产生 空 粒子的发射器是没有意义的。
如类头所示,此方法第二个参数提供了一个默认值。这为我们提供了一个很好的方法来区分用户是否希望将发射器添加到特定状态,或者只是当前选定的状态。这两种可能性随后在代码的剩余部分得到处理。
强制应用器以非常相似的方式处理:
void ParticleSystem::AddForce(ForceApplicator l_force,
const StateType& l_state)
{
if (l_state == StateType(0)) {
if (m_stateItr == m_container.end()) { return; }
m_forces[m_currentState].emplace_back(l_force);
return;
}
auto itr = m_forces.find(l_state);
if(itr == m_forces.end()) { return; }
itr->second.emplace_back(l_force);
}
再次强调,第二个参数有一个默认值,因此我们在尝试将强制应用器数据插入适当的容器之前处理了这两种可能性。
如数据类型部分所述,移除发射器有两个阶段。第一阶段是将发射器的指针放入指定的列表中:
void ParticleSystem::RemoveEmitter(Emitter* l_emitter) {
m_removedEmitters.push_back(l_emitter);
}
实际的移除操作在其他地方处理。我们很快就会介绍这一点。
获取生成器列表对于发射过程非常重要,因此,我们自然也需要一个方法来获取它:
GeneratorList* ParticleSystem::GetGenerators(
const std::string& l_name)
{
auto& itr = m_generators.find(l_name);
if (itr == m_generators.end()) {
return nullptr;
}
return &itr->second;
}
现在,我们遇到了粒子系统的状态相关部分,从状态创建开始:
void ParticleSystem::CreateState(const StateType& l_state) {
if (m_container.find(l_state) != m_container.end()) { return; }
m_container.emplace(l_state,
std::make_unique<ParticleContainer>(m_textureMgr));
m_emitters.emplace(l_state, EmitterList());
m_forces.emplace(l_state, ForceApplicatorList());
ChangeState(l_state);
}
首先,需要确定正在创建的状态是否已经因为某种原因分配了自己的粒子容器。如果没有,就创建一个并将其插入到状态粒子的容器中,以及发射器列表和相同状态的强制应用器列表。
注意
StateDependent 类的 CreateState() 方法是唯一需要手动调用的代码,以防某些状态不需要使用特定的状态相关资源。
接下来,让我们讨论如何在粒子系统中改变状态:
void ParticleSystem::ChangeState(const StateType& l_state) {
SetState(l_state);
m_stateItr = m_container.find(m_currentState);
m_emitterItr = m_emitters.find(m_currentState);
auto c = static_cast<CollisionUpdater*>(
m_updaters["Collision"].get());
if (l_state == StateType::Game) { c->SetMap(m_map); }
else { c->SetMap(nullptr); }
auto f = static_cast<ForceUpdater*>(m_updaters["Force"].get());
auto& forceItr = m_forces.find(m_currentState);
if (forceItr == m_forces.end()) {
f->SetApplicators(nullptr); return;
}
f->SetApplicators(&forceItr->second);
}
在调用一个用于改变其自身内部状态的私有方法之后,持有当前状态粒子迭代器的数据成员被更新。对发射器迭代器执行相同的操作。
在这个上下文中,接下来的几行代码可能没有太多意义,因为我们还没有处理任何更新器,但无论如何让我们来覆盖它们。在接下来的步骤中,我们将拥有粒子碰撞和力的更新器。就碰撞而言,更新器只需要指向游戏地图的指针,假设当前状态是Game。另一方面,ForceUpdater需要能够访问当前状态的力量应用者列表。这两种类型的更新器都包含在这里。
让我们通过查看在从粒子系统中删除状态时内部发生的情况来总结状态修改的主题:
void ParticleSystem::RemoveState(const StateType& l_state) {
if (m_stateItr->first == l_state) {
m_stateItr = m_container.end();
m_emitterItr = m_emitters.end();
}
m_emitters.erase(l_state);
m_forces.erase(l_state);
m_container.erase(l_state);
}
我们在这里所做的只是从状态绑定容器中删除数据。由于保留了两个迭代器数据成员,因此如果被删除的状态与当前状态匹配,也必须重置它们。由于我们的状态系统的工作方式和ChangeState与RemoveState的顺序,我们不需要担心迭代器被无效化。
我们的粒子系统肯定会有很多从文本文件中加载的数据,这就是为什么它继承自文件加载类。让我们看看每行流将被喂入的方法:
bool ParticleSystem::ProcessLine(std::stringstream& l_stream) {
std::string type;
l_stream >> type;
if (type == "Name") {
if (!(l_stream >> m_loadingGenerator)) { return false; }
auto generators = GetGenerators(m_loadingGenerator);
if (generators) { return false; }
} else {
if (m_loadingGenerator.empty()) { return false; }
auto itr = m_factory.find(type);
if (itr == m_factory.end()) { return true; }
std::unique_ptr<BaseGenerator> generator(itr->second());
l_stream >> *generator;
m_generators[m_loadingGenerator].emplace_back(
std::move(generator));
}
return true;
}
每行的第一个字符串,后来被称为类型,被提取出来。如果我们有一个名称,另一个字符串将被尝试提取,并在生成器列表中进行匹配检查,以避免重复。生成器列表的名称存储在m_loadingGenerator数据成员中。
如果遇到其他类型,可以安全地假设我们正在处理特定类型的生成器。如果是这种情况,首先会检查生成器列表名称是否为空,这会表明存在文件格式问题。然后,在生成器工厂中搜索从文件中加载的类型对应的生成器。如果找到了,就会通过它创建一个新的生成器实例,将流对象传递给它,通过>>操作符进行自己的加载,并将最终的实例插入到当前类型m_loadingGenerator的生成器列表中。
由于我们使用数据成员来保留文件信息,因此在尝试加载另一个文件之前必须重置它。我们的FileLoader接口提供了这样的功能,因为此方法被重载:
void ParticleSystem::ResetForNextFile() {
m_loadingGenerator.clear();
}
粒子系统最终继承的最终基类MapLoadee要求我们实现一个方法,该方法将处理具有自定义类型的地图文件条目:
void ParticleSystem::ReadMapLine(const std::string& l_type,
std::stringstream& l_stream)
{
if (l_type == "ParticleEmitter") {
sf::Vector3f position;
size_t emitRate;
std::string generatorType;
l_stream >> generatorType >> position.x >> position.y >>
position.z >> emitRate;
auto emitter = std::make_unique<Emitter>(position);
emitter->SetEmitRate(emitRate);
emitter->SetGenerators(generatorType);
AddEmitter(std::move(emitter), StateType::Game);
} else if (l_type == "ForceApplicator") {
sf::Vector3f position;
sf::Vector3f force;
float radius;
l_stream >> position.x >> position.y >> position.z >>
force.x >> force.y >> force.z >> radius;
AddForce(ForceApplicator(position, force, radius),
StateType::Game);
}
}
如您所见,粒子系统支持两种不同的地图条目类型:ParticleEmitter和ForceApplicator。在两种情况下,所有适当的数据都会流式传输并应用于新构造的对象,然后添加到Game状态中。
接下来,让我们专注于使一切移动的方法,即所谓的Update()方法:
void ParticleSystem::Update(float l_dT) {
if (m_stateItr == m_container.end()) { return; }
for (auto& emitter : m_emitterItr->second) {
emitter->Update(l_dT, m_stateItr->second.get());
}
for (auto& updater : m_updaters){
updater.second->Update(l_dT, m_stateItr->second.get());
}
if (!m_removedEmitters.size()) { return; }
for (auto& removed : m_removedEmitters) {
m_emitterItr->second.erase(
std::remove_if(
m_emitterItr->second.begin(),
m_emitterItr->second.end(),
removed {
return emitter.get() == removed;
}
));
}
m_removedEmitters.clear();
}
它实际上只由三个基本部分组成:更新发射器,更新所有不同的BaseUpdater实例,以及处理移除的发射器。如果当前状态迭代器无效,则这些都不会发生。没有粒子可供工作意味着我们没有任何工作要做。
更新发射器和更新器相对简单。移除已处理的发射器也不是什么复杂的事情。通过迭代移除的发射器指针容器,并对每个条目,如果发射器仍然在世界上并且具有相同的内存地址,则将其从容器中移除。
最后,我们来到了负责将所有美丽的粒子显示在屏幕上的代码:
void ParticleSystem::Draw(Window* l_window, int l_elevation) {
if (m_stateItr == m_container.end()) { return; }
auto container = m_stateItr->second.get();
auto& drawables = container->m_drawable;
auto& positions = container->m_position;
auto& blendModes = container->m_addBlend;
auto view = l_window->GetRenderWindow()->getView();
auto renderer = l_window->GetRenderer();
auto state = m_stateManager->GetCurrentStateType();
if (state == StateType::Game || state == StateType::MapEditor) {
renderer->UseShader("default");
} else {
renderer->DisableShader();
}
for (size_t i = 0; i < container->m_countAlive; ++i) {
if (l_elevation >= 0) {
if (positions[i].z < l_elevation * Sheet::Tile_Size) {
continue;
}
if (positions[i].z >= (l_elevation + 1) * Sheet::Tile_Size){
continue;
}
} else if (positions[i].z <
Sheet::Num_Layers * Sheet::Tile_Size)
{ continue; }
renderer->AdditiveBlend(blendModes[i]);
renderer->Draw(drawables[i]);
}
renderer->AdditiveBlend(false);
}
自然地,如果我们处于没有粒子容器的状态,则不需要进行绘制。否则,我们获取可绘制数组的引用,它们的位子和混合模式。由于我们希望粒子支持分层以增加深度,因此此方法的第二个参数接受当前正在绘制的层。
注意
注意这段代码中的状态检查和着色器的使用。我们实际上在这里控制粒子着色的状态。地图编辑器状态将在下一章中介绍。
如果层/高度测试通过,我们还需要进行一项额外的检查,以便能够渲染粒子,那就是粒子是否当前在屏幕的视图中。
小贴士
这个简单的 AABB 碰撞检测显然没有考虑到粒子可能被旋转的情况。虽然被检查的边界仍然包含粒子的整个身体,但某些特殊情况可能会导致粒子被渲染,而它以某种方式旋转,使得它应该是不可见的,但边界框仍然在视图中。这可以通过应用更复杂的碰撞检测算法来解决,但这里不会涉及。
最后,在所有测试都通过之后,是时候渲染粒子了。记住,对我们来说,在渲染时支持两种混合模式是最有益的:加法和 alpha 混合。幸运的是,SFML 使这变得很容易,我们只需要向窗口实例的 draw 方法传递一个额外的参数来决定如何绘制某个对象。
在渲染某些粒子类型时,能够切换混合模式可能很有用,因为这样可以使它们看起来更真实。例如,看看使用加法混合渲染的相同类型的粒子,与默认模式 alpha 混合相比:

虽然并非所有粒子都会利用这种混合模式,但对于需要额外动力的粒子来说,这确实是一个很好的选项。
创建更新器
核心粒子系统已经全部构建完毕,现在是时候关注那些将赋予我们系统功能性和润色的个别部分了。完成之后,以下是一些可能实现的效果:

唯一的方法是继续前进,让我们继续吧!
空间更新器
首先,可能是最明显的一项任务是根据粒子的运动状态调整粒子位置。尽管它们可能很小,但它们仍然基于速度、加速度和位置的变化来操作:
class SpatialUpdater : public BaseUpdater {
public:
void Update(float l_dT, ParticleContainer* l_particles) {
auto& velocities = l_particles->m_velocity;
auto& accelerations = l_particles->m_acceleration;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
velocities[i] += accelerations[i] * l_dT;
}
auto& positions = l_particles->m_position;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
positions[i] += velocities[i] * l_dT;
}
}
};
到目前为止,一切顺利!其中一些更新器将具有相当小的足迹,因为它们执行的任务极其简单。我们在这里所做的一切只是获取速度、加速度和位置容器的引用。然后,通过两个独立的循环来操作数据,以最小化缓存未命中。
可绘制更新器
接下来,让我们更新粒子的可绘制部分。这正是名为 DrawableUpdater 的合适之处:
class DrawableUpdater : public BaseUpdater {
public:
void Update(float l_dT, ParticleContainer* l_particles) {
auto& positions = l_particles->m_position;
auto& drawables = l_particles->m_drawable;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
drawables[i].setPosition(positions[i].x, positions[i].y);
}
auto& sizes = l_particles->m_currentSize;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
drawables[i].setSize(sizes[i]);
}
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
float ScaleFactor = std::max(
(positions[i].z / Sheet::Tile_Size) *
ScaleToElevationRatio, 1.f);
drawables[i].setScale(ScaleFactor, ScaleFactor);
}
auto& colors = l_particles->m_currentColor;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
drawables[i].setFillColor(colors[i]);
}
auto& rotations = l_particles->m_currentRotation;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
drawables[i].setRotation(rotations[i]);
}
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
drawables[i].setOrigin(
drawables[i].getLocalBounds().width / 2,
drawables[i].getLocalBounds().height / 2);
}
}
static const float ScaleToElevationRatio;
};
const float DrawableUpdater::ScaleToElevationRatio = 1.5f;
这段代码相当多,但其本质非常简单。使用独立的循环来设置所有相关的可绘制属性。然而,我们还有一些更有趣的事情在进行。注意代码中突出显示的部分,所有这些都与缩放相关。你可能已经猜到了,当涉及到视觉效果时,SFML 只处理两个维度。为了模拟 3D 粒子在周围飞行的效果,我们可以利用可绘制缩放。缩放因子本身被限制在 1.f,所以我们不会小于默认粒子大小。这里的缩放与高度比设置为 1.5f,这可能是最佳值,但显然可以调整。这只是简单的比例,粒子所谓的 高度 乘以以获得缩放值,当使用时,应该产生粒子向摄像机飞行的错觉。
生命周期更新器
由于计算机资源,至少在撰写本书的时候是有限的,我们需要有一种好的方法来处理粒子,当它们需要被处理的时候。一个不错的想法是为粒子附加一个生命周期,这样在它应该过期后,粒子就可以优雅地从群体中移除:
class LifespanUpdater : public BaseUpdater {
public:
void Update(float l_dT, ParticleContainer* l_particles) {
auto& lifespans = l_particles->m_lifespan;
auto& maxLifespans = l_particles->m_maxLifespan;
for (size_t i = 0; i < l_particles->m_countAlive;) {
lifespans[i] += l_dT;
if (lifespans[i] < maxLifespans[i]) { ++i; continue; }
l_particles->Disable(i);
}
}
};
这是一个相当简单的更新器。将时间增量添加到每个活动粒子中,然后检查该粒子是否已经超过了其生命周期。如果超过了,粒子将被禁用。请注意,禁用粒子将减少粒子容器中的 m_countAlive 数据成员。正因为如此,我们在递增局部变量 i 时必须小心,以免在过程中跳过任何数据。
插值器
粒子的大量属性在其生命周期内不会保持静态。以粒子颜色为例,我们可能想要将粒子淡化到完全透明,或者甚至循环一系列颜色。所有这些都可以通过插值来实现。这个过程是它自己的更新器类的良好候选者:
class Interpolator : public BaseUpdater {
public:
void Update(float l_dT, ParticleContainer* l_particles) {
auto& startColors = l_particles->m_startColor;
auto& currentColors = l_particles->m_currentColor;
auto& finalColors = l_particles->m_finalColor;
auto& lifespans = l_particles->m_lifespan;
auto& maxLifespans = l_particles->m_maxLifespan;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
if (startColors[i] == finalColors[i]) { continue; }
currentColors[i].r = Utils::Interpolate<sf::Uint8>(0.f,
maxLifespans[i], startColors[i].r, finalColors[i].r,
lifespans[i]);
currentColors[i].g = Utils::Interpolate<sf::Uint8>(0.f,
maxLifespans[i], startColors[i].g, finalColors[i].g,
lifespans[i]);
currentColors[i].b = Utils::Interpolate<sf::Uint8>(0.f,
maxLifespans[i], startColors[i].b, finalColors[i].b,
lifespans[i]);
currentColors[i].a = Utils::Interpolate<sf::Uint8>(0.f,
maxLifespans[i], startColors[i].a, finalColors[i].a,
lifespans[i]);
}
auto& startRotations = l_particles->m_startRotation;
auto& currentRotations = l_particles->m_currentRotation;
auto& finalRotations = l_particles->m_finalRotation;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
if (startRotations[i] == finalRotations[i]) { continue; }
currentRotations[i] = Utils::Interpolate<float>(0.f,
maxLifespans[i], startRotations[i], finalRotations[i],
lifespans[i]);
}
auto& startSizes = l_particles->m_startSize;
auto& currentSizes = l_particles->m_currentSize;
auto& finalSizes = l_particles->m_finalSize;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
if (startSizes[i] == finalSizes[i]) { continue; }
currentSizes[i] = sf::Vector2f(
Utils::Interpolate<float>(0.f, maxLifespans[i],
startSizes[i].x, finalSizes[i].x, lifespans[i]),
Utils::Interpolate<float>(0.f, maxLifespans[i],
startSizes[i].y, finalSizes[i].y, lifespans[i]));
}
}
};
再次强调,我们有很多代码,但它的本质基本上是一致的。存储在我们 Utilities.h 头文件中的 Interpolate 函数,接受一个时间范围,在这个范围内我们需要进行插值,以及应该进行插值的值范围,和当前的时间值,它决定了输出。
插值属性包括粒子颜色、旋转和大小。对于这三者,我们首先检查起始值是否与最终值相同,以避免无用的计算。
力更新器
在这个粒子系统的规划阶段,我们讨论了在世界上有不同类型的力会影响粒子。除了有自定义力的可能性之外,我们还希望有基本的重力,这样任何类型的粒子只要有被重力影响的属性,就会开始下落。让我们实现一个更新器,使我们能够做到这一点:
class ForceUpdater : public BaseUpdater {
friend class ParticleSystem;
public:
ForceUpdater() : m_applicators(nullptr) {}
void Update(float l_dT, ParticleContainer* l_particles) {
auto& velocities = l_particles->m_velocity;
auto& gravity = l_particles->m_gravity;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
if (!gravity[i]) { continue; }
velocities[i].z -= Gravity * l_dT;
}
if (!m_applicators) { return; }
auto& positions = l_particles->m_position;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
for (auto& force : *m_applicators) {
Force(force.m_center, force.m_force * l_dT,
force.m_radius, positions[i], velocities[i]);
}
}
}
void SetApplicators(ForceApplicatorList* l_list) {
m_applicators = l_list;
}
static const float Gravity;
private:
void Force(const sf::Vector3f& l_center,
const sf::Vector3f& l_force, float l_radius,
sf::Vector3f& l_position, sf::Vector3f& l_velocity)
{ ... }
ForceApplicatorList* m_applicators;
};
const float ForceUpdater::Gravity = 128.f;
这个特定更新器的第一个,也是最重要的功能,是将重力应用于所有需要重力的粒子。我们希望能够给某些类型的粒子,如烟雾或火焰,赋予不受重力影响的特性,所以这完全取决于一个可以设置的标志。实际的引力定义为静态 const 数据成员,并在类定义下方设置。
接下来是我们在世界中与力应用器打交道的事情。如果力更新器没有指向力应用器列表的指针,显然就没有什么可做的了,所以我们从更新方法中返回。否则,将调用一个私有的 Force() 方法,该方法使用力的中心、调整后的力大小(考虑了时间增量)、其半径,以及作为参数传递的粒子的位置和速度引用:
void Force(const sf::Vector3f& l_center,
const sf::Vector3f& l_force, float l_radius,
sf::Vector3f& l_position, sf::Vector3f& l_velocity)
{
sf::Vector3f from(l_center.x - l_radius,
l_center.y - l_radius, l_center.z - l_radius);
sf::Vector3f to(l_center.x + l_radius,
l_center.y + l_radius, l_center.z + l_radius);
if (l_position.x < from.x) { return; }
if (l_position.y < from.y) { return; }
if (l_position.z < from.z) { return; }
if (l_position.x > to.x) { return; }
if (l_position.y > to.y) { return; }
if (l_position.z > to.z) { return; }
sf::Vector3f distance = l_center - l_position;
sf::Vector3f a_distance = sf::Vector3f(std::abs(distance.x),
std::abs(distance.y), std::abs(distance.z));
float magnitude = std::sqrt(std::pow(a_distance.x, 2) +
std::pow(a_distance.y, 2) + std::pow(a_distance.z, 2));
sf::Vector3f normal = sf::Vector3f(
a_distance.x / magnitude,
a_distance.y / magnitude,
a_distance.z / magnitude
);
sf::Vector3f loss = sf::Vector3f(
std::abs(l_force.x) / (l_radius / a_distance.x),
std::abs(l_force.y) / (l_radius / a_distance.y),
std::abs(l_force.z) / (l_radius / a_distance.z)
);
sf::Vector3f applied = sf::Vector3f(
(l_force.x > 0 ? l_force.x - loss.x : l_force.x + loss.x),
(l_force.y > 0 ? l_force.y - loss.y : l_force.y + loss.y),
(l_force.z > 0 ? l_force.z - loss.z : l_force.z + loss.z)
);
applied.x *= normal.x;
applied.y *= normal.y;
applied.z *= normal.z;
if (distance.x < 0) { applied.x = -applied.x; }
if (distance.y < 0) { applied.y = -applied.y; }
if (distance.z < 0) { applied.z = -applied.z; }
l_velocity += applied;
}
使用力的中心和半径计算出距离范围后,测试粒子的位置,看它是否在力影响区域内。如果所有测试都通过,就计算力中心和粒子之间的距离。然后,它被用来计算它们之间的绝对距离,确定力的大小,并归一化向量。基于所有三个轴上的半径和距离,计算力损失,并从实际施加的力中减去,然后将结果乘以法线,以得到最终产品。根据距离的符号,我们可以确定力应该施加的方向,这就是下面三行的作用。最后,在完成所有这些工作之后,我们准备将施加的力添加到粒子的速度上。
在这个更新器的帮助下,我们实际上可以从类外对粒子应用力,如下所示:
void ParticleSystem::ApplyForce(const sf::Vector3f& l_center,
const sf::Vector3f& l_force, float l_radius)
{
if (m_stateItr == m_container.end()) { return; }
auto f = static_cast<ForceUpdater*>(m_updaters["Force"].get());
auto container = m_stateItr->second.get();
auto& positions = container->m_position;
auto& velocities = container->m_velocity;
for (size_t i = 0; i < container->m_countAlive; ++i) {
f->Force(l_center, l_force, l_radius,
positions[i], velocities[i]);
}
}
虽然这不如在世界上有恒定力那么有用,但它仍然可以用于测试目的。
碰撞更新器
粒子与世界交互的另一个重要方面是处理它们的碰撞。到目前为止,我们唯一真正需要担心的碰撞是粒子撞击地面;然而,借助这个类,实际地图碰撞可以很容易地实现:
class CollisionUpdater : public BaseUpdater {
public:
void Update(float l_dT, ParticleContainer* l_particles) {
auto& positions = l_particles->m_position;
auto& velocities = l_particles->m_velocity;
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
if (positions[i].z > 0.f) { continue; }
positions[i].z = 0.f;
velocities[i].z = 0.f;
}
if (!m_map) { return; }
for (size_t i = 0; i < l_particles->m_countAlive; ++i) {
if (positions[i].z > 0.f) { continue; }
ApplyFriction(l_dT, positions[i], velocities[i]);
}
}
void SetMap(Map* l_map) { m_map = l_map; }
private:
void ApplyFriction(float l_dT, sf::Vector3f& l_position,
sf::Vector3f& l_velocity) { ... }
Map* m_map;
};
我们在这里需要担心的是检查粒子在z轴上的位置是否低于零。如果是这样,该轴上的位置将被重置为零,以及它的速度。此外,如果更新器已经提供了一个指向地图实例的指针,我们希望处理粒子与地图的摩擦,前提是它们接触地面。如果情况如此,delta 时间将被传递到一个私有的ApplyFriction()方法中,以及粒子的位置和速度向量:
void ApplyFriction(float l_dT, sf::Vector3f& l_position,
sf::Vector3f& l_velocity)
{
sf::Vector2i tileCoords = sf::Vector2i(
static_cast<int>(floor(l_position.x / Sheet::Tile_Size)),
static_cast<int>(floor(l_position.y / Sheet::Tile_Size)));
auto tile = m_map->GetTile(tileCoords.x, tileCoords.y, 0);
sf::Vector2f friction;
if (!tile) { friction = m_map->GetDefaultTile()->m_friction; }
else { friction = tile->m_properties->m_friction; }
friction.x *= std::abs(l_velocity.x);
friction.y *= std::abs(l_velocity.y);
friction *= l_dT;
if (l_velocity.x != 0.f && friction.x != 0.f) {
if (std::abs(l_velocity.x) - std::abs(friction.x) < 0.f) {
l_velocity.x = 0.f;
} else {
l_velocity.x += (l_velocity.x > 0.f ?
friction.x * -1.f : friction.x);
}
}
if (l_velocity.y != 0.f && friction.y != 0.f) {
if (std::abs(l_velocity.y) - std::abs(friction.y) < 0.f) {
l_velocity.y = 0.f;
} else {
l_velocity.y += (l_velocity.y > 0.f ?
friction.y * -1.f : friction.y);
}
}
}
在确定粒子接触的瓦片坐标后,检查瓦片是否确实存在。如果不存在,则使用默认的摩擦系数。一旦所有这些都被整理好,就会计算由于摩擦而失去的速度,然后乘以 delta 时间以获得当前帧的准确结果。从这一点开始的所有其他内容都与确保添加的值具有正确的符号,并且不会导致越过绝对零进入相反符号域有关。
粒子生成器
有了所有这些更新器,除非为粒子生成某些基本值,否则实际上什么也不会发生。无论是粒子的初始位置、颜色的范围,还是附着在我们飞行小数据结构上的纹理名称,基于某种预先设定的概念来拥有这个初始数据集是很重要的。我们支持相当多的生成器,更不用说还有大量新的生成器候选者,以及由此产生的新类型粒子。话虽如此,让我们来看看一些基本内容,这样我们就可以开始实现一些基本效果。
点位置
在整个系统中,我们可能拥有的最简单的生成器是一个点位置。本质上,它只是将输入粒子的所有位置设置到空间中的一个静态点上:
class PointPosition : public BaseGenerator {
public:
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& positions = l_particles->m_position;
auto center = l_emitter->GetPosition();
for (auto i = l_from; i <= l_to; ++i) {positions[i] = center;}
}
};
所有粒子定位的中心点是从发射器那里获取的。其位置将始终用于确定粒子应该在哪里生成。
区域位置
将所有粒子的位置设置到同一个点可能会相当无聊,更不用说在视觉上也很奇怪。如果我们处理的是烟雾或火焰这样的粒子,那么在指定区域内分散粒子可能更有意义。这就是AreaPosition发挥作用的地方:
class AreaPosition : public BaseGenerator {
public:
AreaPosition() = default;
AreaPosition(const sf::Vector3f& l_deviation)
: m_deviation(l_deviation) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& positions = l_particles->m_position;
auto center = l_emitter->GetPosition();
auto rangeFrom = sf::Vector3f(center.x - m_deviation.x,
center.y - m_deviation.y, center.z - m_deviation.z);
auto rangeTo = sf::Vector3f(center.x + m_deviation.x,
center.y + m_deviation.y, center.z + m_deviation.z);
auto& rand = *l_emitter->GetParticleSystem()->GetRand();
for (auto i = l_from; i <= l_to; ++i) {
positions[i] = sf::Vector3f(
rand(rangeFrom.x, rangeTo.x),
rand(rangeFrom.y, rangeTo.y),
rand(rangeFrom.z, rangeTo.z)
);
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_deviation.x >> m_deviation.y >> m_deviation.z;
}
private:
sf::Vector3f m_deviation;
};
这个特定的位置生成器仍然使用发射器位置作为中心点,但对其应用一个随机偏差范围。偏差值可以直接从粒子文件中读取,或者简单地通过这个生成器的构造函数设置。
行位置
面积位置的一个轻微变化是线位置。它的工作方式与面积位置相同,只是仅限于一个轴,这个轴可以通过构造函数提供或从粒子文件中加载:
enum class LineAxis{ x, y, z };
class LinePosition : public BaseGenerator {
public:
LinePosition() : m_axis(LineAxis::x), m_deviation(0.f) {}
LinePosition(LineAxis l_axis, float l_deviation)
: m_axis(l_axis), m_deviation(l_deviation) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& positions = l_particles->m_position;
auto center = l_emitter->GetPosition();
auto& rand = *l_emitter->GetParticleSystem()->GetRand();
for (auto i = l_from; i <= l_to; ++i) {
if (m_axis == LineAxis::x) {
center.x = rand(center.x - m_deviation,
center.x + m_deviation);
} else if (m_axis == LineAxis::y) {
center.y = rand(center.y - m_deviation,
center.y + m_deviation);
} else {
center.z = rand(center.z - m_deviation,
center.z + m_deviation); }
positions[i] = center;
}
}
void ReadIn(std::stringstream& l_stream) {
std::string axis;
l_stream >> axis >> m_deviation;
if (axis == "x") { m_axis = LineAxis::x; }
else if (axis == "y") { m_axis = LineAxis::y; }
else if (axis == "z") { m_axis = LineAxis::z; }
else { std::cout << "Faulty axis: " << axis << std::endl; }
}
private:
LineAxis m_axis;
float m_deviation;
};
这里的随机偏差仅应用于一个轴。可以说,使用面积位置生成器可以达到相同的效果,但这并不妨碍有一些多样性。
粒子属性
粒子拥有的某些属性可能根本不需要自己的生成器。例如,粒子的重力和混合模式标志可以简单地汇总到单个类型的生成器中:
class PropGenerator : public BaseGenerator {
public:
PropGenerator(bool l_gravity = true, bool l_additive = false)
: m_gravity(l_gravity), m_additive(l_additive) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& gravity = l_particles->m_gravity;
for (auto i = l_from; i <= l_to; ++i) {
gravity[i] = m_gravity;
}
auto& additive = l_particles->m_addBlend;
for (auto i = l_from; i <= l_to; ++i) {
additive[i] = m_additive;
}
}
void ReadIn(std::stringstream& l_stream) {
int gravity = 1;
int additive = 0;
l_stream >> gravity >> additive;
m_gravity = (gravity != 0);
m_additive = (additive != 0);
}
private:
bool m_gravity;
bool m_additive;
};
重力和混合模式标志,就像所有之前的生成器一样,可以从文件中加载,或者通过类的构造函数设置。
随机颜色
随机化所有发射粒子的颜色可能是人们想要做的事情,无论是对于像不同阴影的水粒子这样的轻微随机变化,还是对于像彩虹糖喷泉这样的完全随机。所有这些以及更多都可以通过这个类来完成:
class RandomColor : public BaseGenerator {
public:
RandomColor() = default;
RandomColor(const sf::Vector3i& l_from,const sf::Vector3i& l_to)
: m_from(l_from), m_to(l_to) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& rand = *l_emitter->GetParticleSystem()->GetRand();
auto& colors = l_particles->m_currentColor;
for (auto i = l_from; i <= l_to; ++i) {
sf::Color target{
static_cast<sf::Uint8>(rand(m_from.x, m_to.x)),
static_cast<sf::Uint8>(rand(m_from.y, m_to.y)),
static_cast<sf::Uint8>(rand(m_from.z, m_to.z)),
255
};
colors[i] = target;
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_from.x >> m_to.x >> m_from.y >> m_to.y >>
m_from.z >> m_to.z;
}
private:
sf::Vector3i m_from;
sf::Vector3i m_to;
};
生成器存储范围,这些范围将被用来生成随机结果。它们可以从粒子文件中加载,或者通过构造函数设置。由于三个颜色通道中的范围可能不同,它们被分别随机化。
颜色范围
当随机颜色生成器只是简单地分配粒子的当前颜色时,颜色范围提供了一系列颜色,粒子在其生命周期内会通过插值逐渐过渡到这些颜色。这整个过程就像分配这些值一样简单:
class ColorRange : public BaseGenerator {
public:
ColorRange() = default;
ColorRange(const sf::Color& l_start, const sf::Color& l_finish)
: m_start(l_start), m_finish(l_finish) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& beginning = l_particles->m_startColor;
auto& current = l_particles->m_currentColor;
auto& ending = l_particles->m_finalColor;
for (auto i = l_from; i <= l_to; ++i) {
beginning[i] = m_start;
current[i] = m_start;
ending[i] = m_finish;
}
}
void ReadIn(std::stringstream& l_stream) {
int s_r = 0, s_g = 0, s_b = 0, s_a = 0;
int f_r = 0, f_g = 0, f_b = 0, f_a = 0;
l_stream >> s_r >> s_g >> s_b >> s_a;
l_stream >> f_r >> f_g >> f_b >> f_a;
m_start = {
static_cast<sf::Uint8>(s_r), static_cast<sf::Uint8>(s_g),
static_cast<sf::Uint8>(s_b), static_cast<sf::Uint8>(s_a)
};
m_finish = {
static_cast<sf::Uint8>(f_r), static_cast<sf::Uint8>(f_g),
static_cast<sf::Uint8>(f_b), static_cast<sf::Uint8>(f_a)
};
}
private:
sf::Color m_start;
sf::Color m_finish;
};
就像之前一样,范围可以从粒子文件中读取或通过构造函数设置。粒子的初始颜色和当前颜色都设置为起始颜色。
注意反序列化方法。因为我们是从文本文件中读取整数,所以变量类型最初必须反映这一点。读取所有值之后,它们被转换为sf::Uint8并存储为范围。这显然包括 alpha 通道,以便在粒子即将消失时能够实现淡出效果。
随机生命周期
为粒子生成生命周期与到目前为止我们所做的一切相当相似,所以我们就直接进入正题吧:
class RandomLifespan : public BaseGenerator {
public:
RandomLifespan() : m_from(0.f), m_to(0.f) {}
RandomLifespan(float l_from, float l_to)
: m_from(l_from), m_to(l_to) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& rand = *l_emitter->GetParticleSystem()->GetRand();
auto& lifespans = l_particles->m_maxLifespan;
for (auto i = l_from; i <= l_to; ++i) {
lifespans[i] = rand(m_from, m_to);
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_from >> m_to;
}
private:
float m_from;
float m_to;
};
预计生命周期也会以范围的形式存储,可以从粒子文件中加载或通过构造函数设置。之后,粒子的生命周期将在指定的范围内随机化。这可以通过消除视觉上容易突出的“死亡线”,提供某些效果的视觉多样性。
随机大小
随机化粒子大小是我们视觉工具箱中另一个有用的工具。让我们来看看:
class RandomSize : public BaseGenerator {
public:
RandomSize() : m_from(0), m_to(0) {}
RandomSize(int l_from, int l_to): m_from(l_from), m_to(l_to) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& rand = *l_emitter->GetParticleSystem()->GetRand();
auto& sizes = l_particles->m_currentSize;
for (auto i = l_from; i <= l_to; ++i) {
float size = static_cast<float>(rand(m_from, m_to));
sizes[i] = sf::Vector2f(size, size);
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_from >> m_to;
}
private:
int m_from;
int m_to;
};
与往常一样,范围作为数据成员存储,可以从文件中读取,或通过构造函数设置。尺寸本身随机化一次,然后作为两个轴的相同尺寸应用。到目前为止,我们没有理由生成具有不匹配尺寸的矩形粒子。
随机速度
如果我们不从这些粒子的出生开始推动它们,那么我们迄今为止投入系统的所有努力都将陷入停滞。应用随机速度值可以达到这个目的:
class RandomVelocity : public BaseGenerator {
public:
RandomVelocity() = default;
RandomVelocity(const sf::Vector3f& l_from,
const sf::Vector3f& l_to) : m_from(l_from), m_to(l_to) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& rand = *l_emitter->GetParticleSystem()->GetRand();
auto& velocities = l_particles->m_velocity;
for (auto i = l_from; i <= l_to; ++i) {
sf::Vector3f target{
rand(m_from.x, m_to.x),
rand(m_from.y, m_to.y),
rand(m_from.z, m_to.z)
};
velocities[i] = target;
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_from.x >> m_to.x >> m_from.y >> m_to.y >>
m_from.z >> m_to.z;
}
private:
sf::Vector3f m_from;
sf::Vector3f m_to;
};
速度,如前面所述,在三维空间中工作,所以我们必须这样存储它们。它们的范围要么从粒子文件中加载,要么通过此生成器的构造函数设置。然后,它们将分别随机化并应用。
旋转范围
粒子的旋转可以用于我们想到的许多不同效果。在它们的寿命期间略微旋转它们可以提供一些很好的多样性,所以让我们在下一个生成器中反映这一点:
class RotationRange : public BaseGenerator {
public:
RotationRange() : m_start(0.f), m_finish(0.f) {}
RotationRange(float l_start, float l_finish)
: m_start(l_start), m_finish(l_finish) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
auto& beginning = l_particles->m_startRotation;
auto& ending = l_particles->m_finalRotation;
for (auto i = l_from; i <= l_to; ++i) {
beginning[i] = m_start;
ending[i] = m_finish;
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_start >> m_finish;
}
private:
float m_start;
float m_finish;
};
由于旋转值将在粒子的寿命期间进行插值,我们使用起始和结束值来反映这一点。
尺寸范围
粒子尺寸与我们迄今为止处理的所有其他数据没有不同,所以让我们来看看:
class SizeRange : public BaseGenerator {
public:
SizeRange() : m_start(0), m_finish(0) {}
SizeRange(float l_start, float l_finish)
: m_start(l_start), m_finish(l_finish) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
if (m_start == m_finish) {
auto& sizes = l_particles->m_currentSize;
for (auto i = l_from; i <= l_to; ++i) {
sizes[i] = sf::Vector2f(m_start, m_start);
}
} else {
auto& beginning = l_particles->m_startSize;
auto& ending = l_particles->m_finalSize;
for (auto i = l_from; i <= l_to; ++i) {
beginning[i] = sf::Vector2f(m_start, m_start);
ending[i] = sf::Vector2f(m_finish, m_finish);
}
}
}
void ReadIn(std::stringstream& l_stream) {
l_stream >> m_start >> m_finish;
}
private:
float m_start;
float m_finish;
};
提供给此生成器的范围首先会被检查,看它们是否不相等。然而,如果它们不相等,我们可以将粒子的尺寸视为常数,并将其当前尺寸简单地设置为该值,以节省插值器的工作量。否则,将填充尺寸的起始和结束值。
纹理生成器
最后,但绝对是最重要的,我们可以通过纹理化使我们的粒子看起来大约有一百万倍吸引人。幸运的是,我们的系统在这个阶段使这变得相当容易。让我们来看看:
class TextureGenerator : public BaseGenerator {
public:
TextureGenerator() = default;
TextureGenerator(const std::string& l_texture)
: m_texture(l_texture) {}
void Generate(Emitter* l_emitter,ParticleContainer* l_particles,
size_t l_from, size_t l_to)
{
if (m_texture.empty()) { return; }
TextureManager* manager = l_emitter->
GetParticleSystem()->GetTextureManager();
if (!manager->RequireResource(m_texture)) { return; }
auto& textures = l_particles->m_texture;
auto& drawables = l_particles->m_drawable;
auto resource = manager->GetResource(m_texture);
auto size = resource->getSize();
for (auto i = l_from; i <= l_to; ++i) {
textures[i] = m_texture;
manager->RequireResource(m_texture);
drawables[i].setTexture(resource);
drawables[i].setTextureRect(sf::IntRect(0,0,size.x,size.y));
}
manager->ReleaseResource(m_texture);
}
void ReadIn(std::stringstream& l_stream){l_stream >> m_texture;}
private:
std::string m_texture;
};
需要用于粒子类型的纹理的字符串标识符首先要么从文件中加载,要么通过构造函数传入。然后,在Generate方法内部检查该字符串,以确保它不为空。在获得纹理管理器的指针后,通过尝试获取资源句柄来检查资源句柄的有效性。
注意
记住,RequireResource行实际上声明资源正在被使用,直到它被释放。
正在被生成的所有粒子都将提供纹理的资源句柄。对于每个粒子,资源需要一次,然后最终传递到粒子的可绘制对象中,并根据粒子大小进行适当的裁剪。
最后,注意Generate()方法中最后高亮显示的代码行。因为我们已经通过在验证目的和获取资源引用时在非常开始时要求它,增加了一次内部资源计数器,现在必须释放它,使内部资源计数器的值与实际使用此纹理的粒子数量相同。
使用粒子系统
在我们开始使用粒子系统之前,需要进行一些基本的设置。首先,由于粒子系统依赖于状态和地图加载,它们之间的关系必须在主Game类内部设置,如下所示:
void Game::SetUpStates() {
...
m_stateManager->AddDependent(m_particles.get());
...
}
void Game::SetUpClasses() {
...
m_gameMap.AddLoadee(m_particles.get());
}
接下来,让我们构建一种实际类型的粒子,它将在主菜单中使用,使其看起来非常壮观:
Name MenuFlame
PointPosition
SizeRange 32 8
|ColorRange 255 255 0 100 0 255 255 0
RandomColor 100 255 100 255 100 255
RandomLifespan 6 6
RandomVelocity -10 10 -50 -80 5 10
RotationRange 0 45
Texture Flame
Properties 0 1
所有这些生成器参数都已经介绍过了,所以如果这个格式看起来有问题,那么再次回顾生成器部分可能是明智的。
让我们把所有这些壮观的视觉效果添加到菜单状态中,使其比目前看起来更加引人注目。我们可以先设置几个发射器:
void State_MainMenu::OnCreate() {
...
auto w_size = context->m_wind->GetWindowSize();
context->m_particles->CreateState(StateType::MainMenu);
auto emitter = std::make_unique<Emitter>(sf::Vector3f(
static_cast<float>(w_size.x) / 3.f,
static_cast<float>(w_size.y) - 64.f,
33.f));
emitter->SetEmitRate(25);
emitter->SetGenerators("MenuFlame");
context->m_particles->AddEmitter(std::move(emitter));
emitter = std::make_unique<Emitter>(sf::Vector3f(
(static_cast<float>(w_size.x) / 3.f) * 2.f,
static_cast<float>(w_size.y) - 64.f,
33.f));
emitter->SetEmitRate(25);
emitter->SetGenerators("MenuFlame");
context->m_particles->AddEmitter(std::move(emitter));
emitter = std::make_unique<Emitter>(sf::Vector3f(
0.f,
static_cast<float>(w_size.y) / 2.f,
0.f));
emitter->SetEmitRate(60);
emitter->SetGenerators("MenuSmoke");
context->m_particles->AddEmitter(std::move(emitter));
auto f = ForceApplicator(
sf::Vector3f(static_cast<float>(w_size.x) / 2.f,
static_cast<float>(w_size.y) / 2.f, 64.f),
sf::Vector3f(500.f, 500.f, 500.f), 256.f);
context->m_particles->AddForce(f);
}
注意代码中高亮的那一行。为了能够使用它,必须在粒子系统中创建一个状态。接下来,创建了两个发射器。一个位于屏幕宽度的三分之一处,另一个位于三分之二处。它们都被添加到系统中,还有一个位于左侧的发射器,它将产生烟雾。最后,在两个火焰之间添加了一个力应用器,也添加到粒子系统中。通过这种深思熟虑的定位,我们将创建一个即将展示的非常漂亮的效果。
我们显然需要更新粒子以使其正常工作:
void State_MainMenu::Update(const sf::Time& l_dT) {
m_stateMgr->GetContext()->m_particles->Update(l_dT.asSeconds());
}
最后,让我们在屏幕上绘制它们:
void State_MainMenu::Draw() {
auto context = m_stateMgr->GetContext();
for (unsigned int i = 0; i < Sheet::Num_Layers; ++i) {
m_stateMgr->GetContext()->m_particles->Draw(
*m_stateMgr->GetContext()->m_wind, i);
}
m_stateMgr->GetContext()->m_particles->Draw(
*m_stateMgr->GetContext()->m_wind, -1);
}
由于Draw()方法接受我们当前正在绘制的层,并且对于这个状态来说层是不相关的,所以我们只是遍历层的数量,对每一层调用Draw()方法。最后,使用参数-1再次调用Draw()方法,表示我们想要绘制所有超出最大层的粒子。
最终结果,连同烟雾效果,看起来有点像这样:

这远远不能展示出粒子系统真正能做什么。本章中的代码包含了存在于游戏状态中的示例,可以通过编译项目并在场景中漫步轻松找到。
摘要
可能有很多东西需要吸收,但如果你在这里,恭喜你!通过一些精心的设计、经过计算的决策和一点品味,我们不仅制作了一个使游戏看起来好十倍的粒子系统,而且还形成了知识的基础,这可以作为向更好设计和提高性能迈进的垫脚石。
在接下来的几章中,我们将介绍制作我们自己的工具的基础知识,这些工具将用于实际设计我们正在工作的游戏及其资产。那里见!
第四章. 准备好你的装备 - 构建游戏工具
制作游戏是一门艺术。当然,使用最基本的工具制作艺术是完全可能的,但通常,开发者需要一个强大的工具集来支持他们,以便高效和专业地快速进行游戏编辑,而无需痛苦。构建这个工具集可以说与构建实际游戏一样困难,但投入在适当工具上的工作抵消了直接文件编辑带来的困难和挫折。
在本章中,我们将讨论以下主题:
-
构建图形化的文件管理系统
-
在单独的线程中加载文件
-
建立地图编辑的状态和控制系统
有很多内容需要覆盖,所以让我们开始吧!
版权资源的利用
如往常一样,让我们向艺术家及其资产表示感谢,是他们使这一切成为可能:
-
Folder Orange 由 sixsixfive 根据 CC0 许可(公有领域):
openclipart.org/detail/212337/folder-orange -
Generic Document 由 isendrak 根据 CC0 许可(公有领域):
openclipart.org/detail/212798/generic-document -
Tango Media Floppy 由 warszawianka 根据 CC0 许可(公有领域):
openclipart.org/detail/34579/tango-media-floppy -
Close 由 danilo 根据 CC0 许可(公有领域):
openclipart.org/detail/215431/close -
Hand Prints 由 kattekrab 根据 CC0 许可(公有领域):
openclipart.org/detail/16340/hand-prints -
Paint Brush with Dark Red Dye 由 Astro 根据 CC0 许可(公有领域):
openclipart.org/detail/245360/Paint-Brush-with-Dye-11 -
Primary Eraser 由 dannya 根据 CC0 许可(公有领域):
openclipart.org/detail/199463/primary-eraser -
Mono Tool Rect Selection 由 dannya 根据 CC0 许可(公有领域):
openclipart.org/detail/198758/mono-tool-rect-selection -
Color Bucket Red 由 frankes 根据 CC0 许可(公有领域):
openclipart.org/detail/167327/color-bucket-red
文件管理
地图编辑工具的成功和可用性将严重依赖于这里的一个特定接口元素,即文件访问和管理。为了提供高效的文件访问、加载和保存方法,我们将致力于开发引导用户通过文件系统的视觉引导手段。整个系统由几个部分组成。目前,让我们只关注这个想法的界面方面。
文件管理器接口
在我们能够成功处理任何类型的地图数据之前,有一个舒适的方法来加载和保存是很重要的。这可以委托给文件管理器接口,该接口将负责显示目录信息。让我们看看我们的样子:

带着这个目标,让我们开始规划这个类的实现,从头部开始:
class GUI_FileManager {
public:
GUI_FileManager(std::string l_name, GUI_Manager* l_guiMgr,
StateManager* l_stateMgr);
~GUI_FileManager();
void SetDirectory(std::string l_dir);
void ParentDirCallback(EventDetails* l_details);
void HandleEntries(EventDetails* l_details);
void ActionButton(EventDetails* l_details);
void CloseButton(EventDetails* l_details);
void Hide();
void Show();
void LoadMode();
void SaveMode();
bool IsInSaveMode() const;
template<class T>
void SetActionCallback(
void(T::*l_method)(const std::string&), T* l_instance)
{...}
private:
void ListFiles();
GUI_Interface* m_interface;
std::string m_name;
std::string m_dir;
std::string m_folderEntry;
std::string m_fileEntry;
GUI_Manager* m_guiManager;
StateManager* m_stateMgr;
StateType m_currentState;
std::function<void(std::string)> m_actionCallback;
bool m_saveMode;
};
显然,这个类是GUI_Interface实例包装的一个稍微复杂的表现形式。它负责跟踪我们当前所在的目录,以及在选中文件进行加载或保存时调用回调函数/方法。回调函数只接受一个字符串参数,该参数携带要加载或保存的文件的完整路径,并且可以像这样注册:
void SetActionCallback(
void(T::*l_method)(const std::string&), T* l_instance)
{
m_actionCallback =
l_instance, l_method -> void
{ (l_instance->*l_method)(l_str); };
}
目前还没有什么太复杂的。让我们继续进行类的实际实现!
实现文件管理器
在处理完类定义之后,是时候看看使文件管理器工作的实际代码了。让我们从这个类的构造函数开始实现:
GUI_FileManager::GUI_FileManager(std::string l_name,
GUI_Manager* l_guiMgr, StateManager* l_stateMgr):
m_guiManager(l_guiMgr), m_stateMgr(l_stateMgr), m_name(l_name),
m_saveMode(false)
{
m_guiManager->LoadInterface(""FileManager.interface"", l_name);
m_interface = m_guiManager->GetInterface(l_name);
m_currentState = m_stateMgr->GetCurrentStateType();
m_folderEntry = m_interface->GetElement("FolderEntry")->
GetStyleName();
m_fileEntry = m_interface->GetElement("FileEntry")->
GetStyleName();
m_interface->RemoveElement("FolderEntry");
m_interface->RemoveElement("FileEntry");
m_interface->SetContentRectSize({ 300, 260 });
m_interface->SetContentOffset({ 0.f, 16.f });
m_interface->PositionCenterScreen();
auto mgr = m_stateMgr->GetContext()->m_eventManager;
mgr->AddCallback<GUI_FileManager>("FileManager_Parent",
&GUI_FileManager::ParentDirCallback, this);
mgr->AddCallback<GUI_FileManager>("FileManager_Entries",
&GUI_FileManager::HandleEntries, this);
mgr->AddCallback<GUI_FileManager>("FileManager_ActionButton",
&GUI_FileManager::ActionButton, this);
mgr->AddCallback<GUI_FileManager>("FileManager_Close",
&GUI_FileManager::CloseButton, this);
SetDirectory(Utils::GetWorkingDirectory());
}
首先,我们加载界面并将指针存储在指定的数据成员中。我们还想存储应用程序的当前状态,并获取元素的风格名称,称为FolderEntry和FileEntry,然后将其移除。这使得界面文件成为一种模板,稍后将被所有正确的信息填充。
一旦设置了适当的内容大小和偏移量,界面就被定位在屏幕中央。然后我们订阅相关的 GUI 界面事件,并将我们的文件管理器目录设置为应用程序当前所在的目录。
在这个类中创建的回调和接口显然在不再使用时需要被移除。这就是析构函数发挥作用的地方:
GUI_FileManager::~GUI_FileManager() {
m_guiManager->RemoveInterface(m_currentState, m_name);
auto events = m_stateMgr->GetContext()->m_eventManager;
events->RemoveCallback(m_currentState, ""FileManager_Parent"");
events->RemoveCallback(m_currentState, ""FileManager_Entries"");
events->RemoveCallback(m_currentState,
""FileManager_ActionButton"");
events->RemoveCallback(m_currentState, ""FileManager_Close"");
}
接下来,对于文件管理类来说,有一个方便地更改当前目录的方法是很重要的:
void GUI_FileManager::SetDirectory(std::string l_dir) {
m_dir = l_dir;
std::replace(m_dir.begin(), m_dir.end(), '''', ''/'');
m_interface->RemoveElementsContaining(""Entry_"");
ListFiles();
}
发生了一些有趣的事情。在参数存储后不久;目录字符串中的所有反斜杠都被替换为正斜杠,以便与不兼容前者的多个其他操作系统保持兼容。然后,界面被指示销毁所有以"Entry_"字符串开头的元素。这样做是为了清除可能已经存在的所有文件和目录条目。最后,调用ListFiles()方法,该方法将新目录中的所有文件和文件夹填充到文件管理器中。让我们看看这是如何实现的:
void GUI_FileManager::ListFiles() {
m_interface->GetElement(""Directory"")->SetText(m_dir);
auto list = Utils::GetFileList(m_dir, ""*.*"", true);
Utils::SortFileList(list);
auto ParentDir = m_interface->GetElement(""ParentDir"");
float x = ParentDir->GetPosition().x;
float y = ParentDir->GetPosition().y+ParentDir->GetSize().y+1.f;
size_t i = 0;
for (auto& file : list) {
if (file.first == ""."" || file.first == "".."") { continue; }
std::string entry = (file.second ? "FEntry_"" : ""Entry_");
m_interface->AddElement(GUI_ElementType::Label,
entry + std::to_string(i));
auto element = m_interface->GetElement(
entry + std::to_string(i));
element->SetText(file.first);
element->SetPosition({ x, y });
m_guiManager->LoadStyle((file.second ?
m_folderEntry : m_fileEntry), element);
y += ParentDir->GetSize().y + 4.f;
++i;
}
}
首先,获取Directory元素以更改其文本。它表示当前工作目录的完整路径。然后获取该目录中包括其他文件夹在内的完整文件列表。在按字母顺序和类型排序后,获取父目录元素以计算列表中第一个元素的起始坐标,然后迭代它。忽略非物理目录,例如"."或".."。然后向界面添加一个新元素,其名称根据我们是在处理文件还是文件夹而变化。然后更新该元素,使其具有条目名称,处于正确的位置,并附加正确的样式。最后,为列表中的下一个元素递增y坐标。
当目录结构以可视化的形式呈现时,让我们看看当点击其条目之一时需要发生什么:
void GUI_FileManager::HandleEntries(EventDetails* l_details) {
if(l_details->m_guiElement.find("FEntry_")!= std::string::npos){
std::string path = m_dir +
m_interface->GetElement(l_details->m_guiElement)->GetText()
+ "";
SetDirectory(path);
m_interface->UpdateScrollVertical(0);
} else if (l_details->m_guiElement.find("Entry_") !=
std::string::npos)
{
m_interface->GetElement("FileName")->SetText(
m_interface->GetElement(l_details->m_guiElement)->GetText());
}
}
这里的第一个检查告诉我们点击的项是目录还是文件。在文件夹点击的情况下,我们希望能够通过取其名称并将其添加到现有的目录路径中来遍历文件系统。然后,界面的垂直滚动被设置回零,以便在执行任何滚动后,将内容移回顶部。
文件点击是一个简单的问题。在这种情况下,我们只需要获取包含文件名的文本字段元素,并将其内容更改为刚刚点击的文件名。
所有这些对于向前遍历都工作得很好,但如果我们想向后移动呢?父目录元素在这里帮了我们大忙:
void GUI_FileManager::ParentDirCallback(EventDetails* l_details) {
auto i = m_dir.find_last_of("/", m_dir.length() - 2);
if (i != std::string::npos) {
std::string dir = m_dir.substr(0U, i + 1);
SetDirectory(dir);
}
}
这里,这仅仅归结为基本的字符串操作。首先尝试在目录字符串中找到最后一个正斜杠字符。如果找到了,字符串就简单地在这个点被剪切,以丢弃其后的所有内容。然后将缩短的路径设置为当前目录,在那里发生我们之前已经覆盖的其余魔法。
使这一切工作起来的最后一部分是处理按钮点击动作:
void GUI_FileManager::ActionButton(EventDetails* l_details) {
if (m_actionCallback == nullptr) { return; }
auto filename = m_interface->GetElement("FileName")->GetText();
m_actionCallback(m_dir + filename);
}
首先,我们需要确保动作回调确实被设置。如果设置了,它将使用当前选中文件的路径作为其参数来调用。在此之后,我们唯一需要担心的其他动作按钮就是关闭按钮:
void GUI_FileManager::CloseButton(EventDetails* l_details){
Hide();
}
它只是调用Hide()方法,该方法在本节中有所介绍,以及其对应的方法:
void GUI_FileManager::Hide() { m_interface->SetActive(false); }
void GUI_FileManager::Show() {
m_interface->SetActive(true);
m_interface->PositionCenterScreen();
ListFiles();
m_interface->Focus();
}
当一个界面被隐藏时,它只是被设置为不活动状态。显示它需要将其重新设置为活动状态,但在这个例子中,我们还想将其定位在屏幕的绝对中心。此外,刷新其内容也是一个好主意,因为文件结构可能在它被隐藏时发生了变化。最后,界面被聚焦,以便将其带到绘图队列的前面。
这个类的最后一些有用的代码包括这些方法:
bool GUI_FileManager::IsInSaveMode() const { return m_saveMode; }
void GUI_FileManager::LoadMode() {
m_interface->GetElement("ActionButton")->SetText("Load");
m_saveMode = false;
}
void GUI_FileManager::SaveMode() {
m_interface->GetElement("ActionButton")->SetText("Save");
m_saveMode = true;
}
它们通过允许它们确定文件管理器是否处于保存或加载模式,并在两者之间切换,帮助我们的其他类更容易地与这个类接口。
在单独的线程中加载文件
我们在第一章中介绍了线程工作基类,内部机制 - 设置后端。这正是它将派上用场的地方。为了使应用程序看起来更用户友好,我们想要渲染一个漂亮的加载条,在文件加载时显示进度。让我们首先定义一个数据类型,用于存储需要加载的文件路径:
using LoaderPaths = std::vector<std::pair<std::string, size_t>>;
这里的size_t表示文件中的行数,这使得我们很容易确定当前的加载进度。处理完这一点后,让我们来处理头文件:
class FileLoader : public Worker {
public:
FileLoader();
void AddFile(const std::string& l_file);
virtual void SaveToFile(const std::string& l_file);
size_t GetTotalLines() const;
size_t GetCurrentLine() const;
private:
virtual bool ProcessLine(std::stringstream& l_stream) = 0;
virtual void ResetForNextFile();
void Work();
void CountFileLines();
LoaderPaths m_files;
size_t m_totalLines;
size_t m_currentLine;
};
我们代码库中的任何FileLoader类都需要实现ProcessLine方法,该方法简单地定义了在解析文件的每一行时需要发生什么。如果需要,它还可以利用SaveToFile,正如其名称所表明的,它定义了将类数据写出的过程,以及ResetForNextFile。后者方法在加载完每个文件后调用,以便给派生类一个清理其内部状态的机会。
就数据成员而言,我们有一个要加载的加载器路径列表,要解析的所有文件的行总数,以及正在读取的当前行数。
实现文件加载器
让我们从简单开始,首先介绍单行方法:
FileLoader::FileLoader() : m_totalLines(0), m_currentLine(0) {}
void FileLoader::AddFile(const std::string& l_file) {
m_files.emplace_back(l_file, 0);
}
size_t FileLoader::GetTotalLines() const{ return m_totalLines; }
size_t FileLoader::GetCurrentLine() const{ return m_currentLine; }
void FileLoader::SaveToFile(const std::string& l_file) {}
void FileLoader::ResetForNextFile() {}
构造函数只是将一些类数据成员初始化为其默认值。AddFile() 方法将参数插入到文件容器中,行计数为零。接下来的两个方法是简单的获取器,而最后的两个方法甚至尚未实现,因为它们是可选的。
接下来,让我们来处理将在线程中实际运行的解析文件信息的方法:
void FileLoader::Work() {
CountFileLines();
if (!m_totalLines) { return; }
for (auto& path : m_files) {
ResetForNextFile();
std::ifstream file(path.first);
std::string line;
std::string name;
auto linesLeft = path.second;
while (std::getline(file, line)) {
{
sf::Lock lock(m_mutex);
++m_currentLine;
--linesLeft;
}
if (line[0] == '|') { continue; }
std::stringstream keystream(line);
if (!ProcessLine(keystream)) {
{
sf::Lock lock(m_mutex);
m_currentLine += linesLeft;
}
break;
}
}
file.close();
}
m_files.clear();
Done();
}
首先,调用一个用于计算所有文件行数的私有方法。这是必要的,因为我们想要能够计算我们的进度,而知道总工作量是必要的。如果调用此方法后,总行数为零,我们简单地返回,因为没有要处理的内容。
然后,我们进入一个循环,为列表中的每个文件运行一次。类被重置以进行新的文件迭代,并从输入流中创建一个行。创建剩余要处理的行数,然后进入另一个循环,该循环为文件中的每行执行一次。然后锁定我们的sf::Mutex对象,以安全地操作用于进度跟踪的两个行数据成员。
如果我们行的第一个字符是一个管道|,这意味着我们遇到了一条注释行,应该跳过当前迭代。否则,创建一个包含当前行的std::stringstream并将其传递给封装在if语句中的纯虚ProcessLine()方法,以捕获可能出现的失败情况,在这种情况下,当前文件中剩余的行将简单地添加到当前行计数器中,并跳出循环。
一旦处理完所有文件,将调用Done()方法来终止线程,并让外部代码知道我们已经完成。
另一个同样重要的过程是计算这个类中所有文件条目的行数:
void FileLoader::CountFileLines() {
m_totalLines = 0;
m_currentLine = 0;
for (auto path = m_files.begin(); path != m_files.end();) {
if (path->first.empty()) {
path = m_files.erase(path);
continue;
}
std::ifstream file(path->first);
if (!file.is_open()) {
path = m_files.erase(path);
continue;
}
file.unsetf(std::ios_base::skipws);
{
sf::Lock lock(m_mutex);
path->second = static_cast<size_t>(std::count(
std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>(),
'\n'));
m_totalLines += path->second;
}
++path;
file.close();
}
}
这个方法相当直接。在两个计数器归零后,我们开始遍历文件列表中的每个路径。如果它的名字为空,则移除该元素。否则,我们尝试打开文件。如果失败,则也删除该路径。否则,请求文件流不要跳过空白字符,并进入一个sf::Mutex锁,其中使用std::count计算文件流中的行数,并将其添加到总行计数器中。然后,路径迭代器向前移动,文件被关闭。
加载状态
线程化文件加载的最后一块是加载状态。为了避免其他逻辑的干扰并专注于图形进度表示,切换到一个专门的状态来处理其中的所有加载逻辑是个好主意。让我们首先定义一个用于存储指向FileLoader*实例指针的数据类型:
using LoaderContainer = std::vector<FileLoader*>;
实际的加载状态头文件最终看起来可能像这样:
class State_Loading : public BaseState {
public:
... // Other typical state methods.
void SetManualContinue(bool l_continue);
void Proceed(EventDetails* l_details);
private:
void UpdateText(const std::string& l_text, float l_percentage);
float CalculatePercentage();
LoaderContainer m_loaders;
sf::Text m_text;
sf::RectangleShape m_rect;
unsigned short m_percentage;
size_t m_originalWork;
bool m_manualContinue;
};
如您所见,我们有一个事件回调方法,几个辅助方法,用于存储加载指针的容器,一个sf::Text和sf::RectangleShape的实例来表示加载条,一个表示进度百分比的数字,以及我们最初开始的所有文件中的行数。
实现加载状态
在使用之前,所有这些数据都需要初始化,因此让我们看看OnCreate()方法:
void State_Loading::OnCreate() {
auto context = m_stateMgr->GetContext();
context->m_fontManager->RequireResource("Main");
m_text.setFont(*context->m_fontManager->GetResource("Main"));
m_text.setCharacterSize(14);
m_text.setStyle(sf::Text::Bold);
sf::Vector2u windowSize = m_stateMgr->GetContext()->
m_wind->GetRenderWindow()->getSize();
m_rect.setFillColor(sf::Color(0, 150, 0, 255));
m_rect.setSize(sf::Vector2f(0.f, 16.f));
m_rect.setOrigin(0.f, 8.f);
m_rect.setPosition(0.f, windowSize.y / 2.f);
EventManager* evMgr = m_stateMgr->GetContext()->m_eventManager;
evMgr->AddCallback(StateType::Loading,
"Key_Space", &State_Loading::Proceed, this);
}
由于我们将使用文本,我们需要有一个字体来工作。在获取一个字体并处理所有样式文本设置后,我们将矩形设置为正好位于屏幕中央,并注册一个事件回调以从加载状态中继续,如果手动继续标志设置为true。
销毁此状态也意味着需要释放事件回调和字体:
void State_Loading::OnDestroy() {
auto context = m_stateMgr->GetContext();
EventManager* evMgr = context->m_eventManager;
evMgr->RemoveCallback(StateType::Loading, "Key_Space");
context->m_fontManager->ReleaseResource("Main");
}
接下来,让我们看看更新的逻辑:
void State_Loading::Update(const sf::Time& l_time) {
if (m_loaders.empty()) {
if (!m_manualContinue) { Proceed(nullptr); }
return;
}
auto windowSize = m_stateMgr->GetContext()->
m_wind->GetRenderWindow()->getSize();
if (m_loaders.back()->IsDone()) {
m_loaders.back()->OnRemove();
m_loaders.pop_back();
if (m_loaders.empty()) {
m_rect.setSize(sf::Vector2f(
static_cast<float>(windowSize.x), 16.f));
UpdateText(".Press space to continue.", 100.f);
return;
}
}
if (!m_loaders.back()->HasStarted()){m_loaders.back()->Begin();}
auto percentage = CalculatePercentage();
UpdateText("", percentage);
m_rect.setSize(sf::Vector2f(
(windowSize.x / 100) * percentage, 16.f));
}
首先,进行一次检查以确定我们是否准备好退出状态,考虑到已经完成的所有工作。如果手动继续标志设置为false,我们只需直接通过传递nullptr作为EventDetails指针来调用 Proceed 回调,因为那里根本不需要使用它。然后从更新方法返回。
如果我们还有一些工作要做,就会检查加载列表中的第一个元素,看它是否已完成。如果已完成,则从列表中移除加载器,如果它是最后一个,则将矩形的尺寸设置为与窗口在 x 轴上的完整尺寸相匹配,这表明已完全完成。中间的文本也会更新,以告知用户他们需要按空格键继续。最后,再次从更新方法返回,以防止执行更多逻辑。
如果上述条件都不满足,就会检查加载器列表中的第一个元素是否已经开始工作。如果没有开始,就会调用其Begin方法。紧接着是百分比计算,然后用于更新屏幕中间的文本并调整进度条矩形的尺寸以匹配该百分比。
在此状态下绘图简化为两个调用:
void State_Loading::Draw() {
sf::RenderWindow* wind = m_stateMgr->
GetContext()->m_wind->GetRenderWindow();
wind->draw(m_rect);
wind->draw(m_text);
}
我们在这里需要做的只是渲染矩形和文本实例。
接下来,让我们看看更新我们的文本实例的辅助方法:
void State_Loading::UpdateText(const std::string& l_text,
float l_percentage)
{
m_text.setString(std::to_string(
static_cast<int>(l_percentage)) + "%" + l_text);
auto windowSize = m_stateMgr->GetContext()->
m_wind->GetRenderWindow()->getSize();
m_text.setPosition(windowSize.x / 2.f, windowSize.y / 2.f);
Utils::CenterSFMLText(m_text);
}
在文本字符串更新后,其位置被更新为屏幕正中央。由于更新其内容可能会改变边界框,从而影响其居中方式,我们在Utils命名空间内部使用一个辅助函数来正确地居中它。
接下来,让我们计算加载过程的实际进度:
float State_Loading::CalculatePercentage() {
float absolute = 100.f;
if (m_loaders.back()->GetTotalLines()) {
float d = (100.f * (m_originalWork - m_loaders.size())) /
static_cast<float>(m_originalWork);
float current = (100.f * m_loaders.back()->GetCurrentLine()) /
static_cast<float>(m_loaders.back()->GetTotalLines());
float totalCurrent = current /
static_cast<float>(m_originalWork);
absolute = d + totalCurrent;
}
return absolute;
}
在创建绝对值100.f之后,首先确定已加载的文件数量与开始时的数量之间的进度,然后计算当前文件的进度并用于确定绝对进度,然后返回。
一旦所有工作完成,就会调用Proceed()方法返回到上一个状态:
void State_Loading::Proceed(EventDetails* l_details) {
if (!m_loaders.empty()) { return; }
m_stateMgr->SwitchTo(m_stateMgr->GetNextToLast());
}
显然,它需要首先检查文件加载器列表是否实际上为空。如果是,则指示状态管理器切换到紧接此状态之前的状态,这意味着它是启动加载过程的状态。
最后,一个类如果没有一些辅助方法会是什么样子?现在让我们看看它们:
void State_Loading::AddLoader(FileLoader* l_loader) {
m_loaders.emplace_back(l_loader);
l_loader->OnAdd();
}
bool State_Loading::HasWork() const { return !m_loaders.empty(); }
void State_Loading::Activate() {
m_originalWork = m_loaders.size();
}
创建地图编辑状态
现在我们终于准备好实际处理状态了,在这个状态下,所有的地图编辑都将发生。让我们看看它的头文件:
class State_MapEditor : public BaseState {
public:
...
void ResetSavePath();
void SetMapRedraw(bool l_redraw);
void MainMenu(EventDetails* l_details);
void MapEditorNew(EventDetails* l_details);
void MapEditorLoad(EventDetails* l_details);
void MapEditorSave(EventDetails* l_details);
void MapEditorSaveAs(EventDetails* l_details);
void MapEditorExit(EventDetails* l_details);
void MapAction(const std::string& l_name);
private:
void SaveMap(const std::string& l_path);
void LoadMap(const std::string& l_path);
GUI_FileManager m_files;
MapControls m_mapControls;
std::string m_mapSavePath;
bool m_mapRedraw;
};
这个State_MapEditor类将成为处理最一般编辑事件的先锋。注意这里突出显示的数据成员。我们还没有介绍这个类,但它负责处理这个应用程序的更精细的控制方面。它将在下一章中介绍。
除了MapControls类之外,我们还有文件管理器、一个字符串,用于当前正在处理的文件的路径,以及一个布尔标志,用于跟踪游戏地图是否应该被重新绘制。
实现状态
和往常一样,让我们从处理这个状态中所有重要数据的构建开始:
void State_MapEditor::OnCreate() {
auto context = m_stateMgr->GetContext();
auto evMgr = context->m_eventManager;
evMgr->AddCallback("Key_Escape",
&State_MapEditor::MainMenu, this);
evMgr->AddCallback("MapEditor_New",
&State_MapEditor::MapEditorNew, this);
evMgr->AddCallback("MapEditor_Load",
&State_MapEditor::MapEditorLoad, this);
evMgr->AddCallback("MapEditor_Save",
&State_MapEditor::MapEditorSave, this);
evMgr->AddCallback("MapEditor_SaveAs",
&State_MapEditor::MapEditorSaveAs, this);
evMgr->AddCallback("MapEditor_Exit",
&State_MapEditor::MapEditorExit, this);
m_files.SetActionCallback(&State_MapEditor::MapAction, this);
m_files.SetDirectory(Utils::GetWorkingDirectory() +
"media/maps/");
m_files.Hide();
context->m_guiManager->LoadInterface(
"MapEditorTop.interface", "MapEditorTop");
context->m_guiManager->GetInterface("MapEditorTop")->
SetPosition({ 200, 0 });
context->m_particles->CreateState(StateType::MapEditor);
}
在所有事件回调设置完毕后,文件管理器类被提供了它自己的回调,用于加载或保存文件,以及它需要所在的起始目录。在这种情况下,恰如其分,它是地图文件夹。然后管理器被隐藏,另一个界面被加载并放置在屏幕上。MapEditorTop是屏幕顶部的控制条,上面有创建新地图、加载、保存和退出应用程序的按钮:

一旦状态完成并且即将被销毁,它需要移除它设置的所有回调。这可以在OnDestroy()方法中完成:
void State_MapEditor::OnDestroy() {
auto context = m_stateMgr->GetContext();
auto textureMgr = context->m_textureManager;
auto evMgr = context->m_eventManager;
evMgr->RemoveCallback(StateType::MapEditor, "Key_Escape");
evMgr->RemoveCallback(StateType::MapEditor, "MapEditor_New");
evMgr->RemoveCallback(StateType::MapEditor, "MapEditor_Load");
evMgr->RemoveCallback(StateType::MapEditor, "MapEditor_Save");
evMgr->RemoveCallback(StateType::MapEditor, "MapEditor_SaveAs");
evMgr->RemoveCallback(StateType::MapEditor, "MapEditor_Exit");
context->m_gameMap->PurgeMap();
context->m_gameMap->GetTileMap()->SetMapSize({ 0,0 });
}
除了回调之外,地图在其大小被设置回绝对零之前也会被清除。既然我们提到了回调,让我们一次性覆盖它们中的大多数:
void State_MapEditor::MapEditorNew(EventDetails* l_details) {
m_mapControls.NewMap();
}
void State_MapEditor::MapEditorLoad(EventDetails* l_details) {
m_files.LoadMode();
m_files.Show();
}
void State_MapEditor::MapEditorSave(EventDetails* l_details) {
if (m_mapSavePath.empty()) { MapEditorSaveAs(nullptr); return; }
SaveMap(m_mapSavePath);
}
void State_MapEditor::MapEditorSaveAs(EventDetails* l_details) {
m_files.SaveMode();
m_files.Show();
}
void State_MapEditor::MapEditorExit(EventDetails* l_details) {
m_stateMgr->SwitchTo(StateType::MainMenu);
m_stateMgr->Remove(StateType::MapEditor);
}
当点击新建地图按钮时,我们希望调用MapControls类的一个特殊方法来处理它。如果点击加载按钮,我们只需将文件管理器的模式切换到加载,并在屏幕上显示它。
点击保存按钮可能会有两种行为。首先,如果我们正在处理一个全新的地图,尚未保存,那么这和点击另存为...按钮是一样的,它会切换文件管理器到保存模式并在屏幕上显示。然而,如果我们已经加载了一个地图或者之前保存了一个新的地图,状态会记住它保存的位置以及它的名称。提示用户再次输入文件名在这里是毫无意义的,所以地图将简单地写入到完全相同的地点,使用完全相同的名称。
最后,如果点击退出按钮,我们只需切换回主菜单状态并移除这个状态。
在 UI 代码处理完毕后,让我们看看在加载地图时需要发生什么:
void State_MapEditor::LoadMap(const std::string& l_path) {
auto context = m_stateMgr->GetContext();
auto loading = m_stateMgr->
GetState<State_Loading>(StateType::Loading);
context->m_particles->PurgeCurrentState();
context->m_gameMap->PurgeMap();
context->m_gameMap->ResetWorker();
context->m_gameMap->GetTileMap()->GetTileSet().ResetWorker();
context->m_gameMap->AddFile(l_path);
loading->AddLoader(context->m_gameMap);
loading->SetManualContinue(false);
m_mapRedraw = true;
m_mapSavePath = l_path;
m_stateMgr->SwitchTo(StateType::Loading);
}
由于我们希望在读取地图时出现一个漂亮的加载条,我们将使用加载状态。在获取到它之后,粒子系统和地图都会被清除。地图继承自FileLoader类,然后被重置。提供的文件路径随后被添加到其中以进行加载,加载状态本身被设置为在加载完成后自动消失。同时,我们确保地图将在地图编辑器状态恢复时重新绘制,并且如果将来要保存,它将记住地图的路径。最后,我们可以切换到加载状态。
接下来,让我们看看负责保存地图的代码:
void State_MapEditor::SaveMap(const std::string& l_path) {
m_stateMgr->GetContext()->m_gameMap->SaveToFile(l_path);
m_mapSavePath = l_path;
}
这比之前的方法简单得多。路径只是简单地传递给游戏地图类的SaveToFile方法,并存储起来以供以后使用。
文件管理器的实际回调函数,它介于加载和保存方法之间,可以像这样实现:
void State_MapEditor::MapAction(const std::string& l_path) {
if(m_files.IsInSaveMode()) { SaveMap(l_path); }
else { LoadMap(l_path); }
m_files.Hide();
}
根据文件管理器所处的模式,将调用适当的方法,并将路径作为参数传递。实际的接口随后被隐藏。
由于我们希望在加载后重新绘制地图,因此逻辑的最佳位置是在Activate()方法内部,因为它在状态切换时立即被调用:
void State_MapEditor::Activate() {
if (!m_mapRedraw) { return; }
auto map = m_stateMgr->GetContext()->m_gameMap;
map->Redraw();
m_mapControls.SetTileSheetTexture(
map->GetTileSet()->GetTextureName());
m_mapRedraw = false;
}
如果m_mapRedraw标志未开启,此时无需做任何事情。否则,我们希望重新绘制地图,并向mapControls类提供瓦片纹理的名称,以便它能够执行自己的逻辑,例如,例如,瓦片选择。
接下来,让我们看看在应用程序处于此状态时需要更新什么:
void State_MapEditor::Update(const sf::Time& l_time) {
auto context = m_stateMgr->GetContext();
m_mapControls.Update(l_time.asSeconds());
context->m_gameMap->Update(l_time.asSeconds());
context->m_systemManager->Update(l_time.asSeconds());
context->m_particles->Update(l_time.asSeconds());
}
除了mapControls类之外,游戏地图、ECS 系统管理器和粒子系统也需要更新,因为我们在构建地图时将使用所有这些类。不出所料,这些也是需要绘制相同对象:
void State_MapEditor::Draw() {
auto context = m_stateMgr->GetContext();
auto window = context->m_wind->GetRenderWindow();
auto from = (m_mapControls.DrawSelectedLayers() ?
m_mapControls.GetSelectionOptions()->GetLowestLayer() : 0);
auto to = (m_mapControls.DrawSelectedLayers() ?
m_mapControls.GetSelectionOptions()->GetHighestLayer()
: Sheet::Num_Layers - 1);
for (auto i = from; i <= to; ++i) {
context->m_gameMap->Draw(i);
context->m_systemManager->Draw(context->m_wind, i);
context->m_particles->Draw(*window, i);
}
if(!m_mapControls.DrawSelectedLayers()) {
context->m_particles->Draw(*window, -1);
}
m_mapControls.Draw(window);
}
注意from和to变量。mapControl类将为我们提供在层/海拔之间切换的方法,因此我们需要在渲染任何内容之前获取这些信息,以确保只绘制屏幕上的适当层。DrawSelectedLayers简单地返回一个布尔值,以确定是否应该绘制所有层或仅绘制选定的层。一旦循环遍历了适当的层,我们确保绘制所有高于最大海拔的剩余粒子,当然,前提是所有内容都需要被渲染。地图控制被绘制在其他所有内容之上,作为收尾。
对于与其他类的外部通信,我们提供了两个基本的设置方法:
void State_MapEditor::ResetSavePath() { m_mapSavePath = ""; }
void State_MapEditor::SetMapRedraw(bool l_redraw) {
m_mapRedraw = l_redraw;
}
这些将在控制类内部使用,用于通信事件,例如创建新地图或需要重新绘制。
构建控制机制
在构建地图时,用户往往会遇到需要不仅仅是在鼠标点击的位置放置瓦片的情况。拥有能够让他们自由平移、选择地图的一部分进行删除或复制、擦除等的工具肯定会很有用。我们的控制类将正好起到这个作用。它将提供一套可用于多种不同情况下的工具:
enum class ControlMode{None, Pan, Brush, Bucket, Eraser, Select};
前面的控制模式枚举代表了一些在多种不同软件中常见的工具。我们将在这里实现其中一些,其余的留给你们!最终,我们应该有一个看起来像这样的控制界面:

让我们开始编写控制类的头文件。为了清晰起见,我们将分别讨论其方法和数据成员,从成员函数开始:
class MapControls {
public:
MapControls(Window* l_window, EventManager* l_eventManager,
StateManager* l_stateManager, Map* l_map, GUI_Manager* l_gui,
EntityManager* l_entityMgr, ParticleSystem* l_particles,
sf::View& l_view);
~MapControls();
void Update(float l_dT);
void Draw(sf::RenderWindow* l_window);
void NewMap();
void SetTileSheetTexture(const std::string& l_sheet);
ControlMode GetMode() const;
bool IsInAction() const;
bool IsInSecondary() const;
GUI_SelectionOptions* GetSelectionOptions();
sf::Vector2i GetMouseTileStart()const;
sf::Vector2i GetMouseTile()const;
sf::Vector2f GetMouseDifference()const;
bool DrawSelectedLayers()const;
void ToggleDrawingSelectedLayers();
void MouseClick(EventDetails* l_details);
void MouseRelease(EventDetails* l_details);
void MouseWheel(EventDetails* l_details);
void ToolSelect(EventDetails* l_details);
void DeleteTiles(EventDetails* l_details);
void NewMapCreate(EventDetails* l_details);
void NewMapClose(EventDetails* l_details);
void SelectMode(ControlMode l_mode);
void RedrawBrush();
private:
void UpdateMouse();
void PanUpdate();
void BrushUpdate();
void BucketUpdate();
void EraserUpdate();
void SelectionUpdate();
void PlaceBrushTiles();
void ResetZoom();
void ResetTools();
...
};
除了设置和获取类参数的所有辅助方法之外,我们还有一大堆事件回调,以及针对我们将要使用的每种地图工具的单独更新方法。接下来,让我们看看我们将要工作的数据成员:
class MapControls {
private:
...
// Mode and mouse/layer flags.
ControlMode m_mode;
bool m_action;
bool m_secondaryAction;
bool m_rightClickPan;
bool m_drawSelectedLayers;
// Mouse information.
sf::Vector2i m_mousePosition;
sf::Vector2i m_mouseStartPosition;
sf::Vector2f m_mouseDifference;
sf::Vector2i m_mouseTilePosition;
sf::Vector2i m_mouseTileStartPosition;
float m_zoom;
// Brush information, and map bounds.
TileMap m_brush;
sf::RenderTexture m_brushTexture;
sf::RectangleShape m_brushDrawable;
sf::RectangleShape m_mapBoundaries;
// Other interfaces used here.
GUI_MapTileSelector m_tileSelector;
GUI_SelectionOptions m_selectionOptions;
GUI_Interface* m_mapSettings;
// Ties to other classes.
Window* m_window;
EventManager* m_eventManager;
StateManager* m_stateManager;
Map* m_map;
GUI_Manager* m_guiManager;
EntityManager* m_entityManager;
ParticleSystem* m_particleSystem;
sf::View& m_view;
};
除了这个类当前所在的ControlMode之外,我们还将存储几个标志。m_action标志将与工具一起使用,以及m_secondaryAction。前者简单地表示左鼠标按钮是否被按下,而后者用于只有在鼠标位置改变后才能发生的行为。这将在我们尝试优化某些事情以避免它们发生时非常有用。最后两个标志表示我们是否正在使用右键进行平移,以及是否只应在屏幕上绘制选定的图层。
在下面,有一些用于存储鼠标信息的二维向量,例如其当前位置、左键第一次点击的位置、鼠标位置当前帧与上一帧之间的差异、其在瓦片坐标中的当前位置以及其在瓦片坐标中的起始位置。此外,我们还有一个用于当前缩放因子的浮点值。
对于将要用来绘画的刷子,我们简单地使用一个TileMap结构,就像游戏地图类所做的那样。由于刷子将要被绘制在屏幕上,我们需要为其存储一个纹理,以及另一个将被用来显示它的可绘制对象。最后,一个sf::RectangleShape类型将足以显示地图在屏幕上的边界。
当代码变得相当长时,进行额外的代码分离总是一个好主意。为此,其他非通用控制逻辑将被分散到两个额外的接口类中:一个瓦片选择器和选择选项。瓦片选择器是一个简单的窗口,显示整个瓦片图集,并允许用户选择他们想要绘制的瓦片,而选择选项是一个单独的接口,它为我们提供了一系列设置,可以在屏幕上选择特定事物时进行调整。这两个类都将在下一章中介绍。
最后,我们还有一个名为 m_mapSettings 的接口,其逻辑将在 MapControls 类中处理。当创建新地图时,我们需要一个整洁的小窗口,它将允许我们配置地图的大小、默认摩擦值以及它将要使用的瓦片图集的名称。这正是地图设置接口要服务的目的。
实现控制
有很多数据成员需要初始化,让我们看看构造函数是如何管理它们的:
MapControls::MapControls(Window* l_window, EventManager* l_eventManager,
StateManager* l_stateManager, Map* l_map, GUI_Manager* l_gui,
EntityManager* l_entityMgr, ParticleSystem* l_particles,
sf::View& l_view):
/* Storing arguments first. */
m_window(l_window), m_eventManager(l_eventManager),
m_stateManager(l_stateManager), m_map(l_map),
m_guiManager(l_gui), m_entityManager(l_entityMgr),
m_particleSystem(l_particles), m_view(l_view),
/* Setting up initial data member values. */
m_mode(ControlMode::Pan), m_action(false),
m_secondaryAction(false), m_rightClickPan(false),
m_zoom(1.f), m_brush(sf::Vector2u(1, 1), *l_map->GetTileSet()),
m_drawSelectedLayers(false),
/* Initializing other interface classes. */
m_tileSelector(l_eventManager, l_gui,
l_gui->GetContext()->m_textureManager),
m_mapSettings(nullptr),
m_selectionOptions(l_eventManager, l_gui, this,
&m_tileSelector, l_map, &m_brush, l_entityMgr, l_particles)
{ ... }
如您所见,这里有很多事情在进行。让我们逐节快速浏览一下。在处理完构造函数的参数后,我们设置这个类的数据成员以保存它们的初始值。在那之后不久,自定义接口类被设置,所有必要的参数都传递给了它们的构造函数。目前,我们不会担心它们,因为它们将在下一章中介绍。
让我们看看实际的构造函数体:
MapControls::MapControls(...)
{
... // All of the callbacks gets set up.
m_guiManager->LoadInterface("MapEditorTools.interface",
"MapEditorTools");
m_guiManager->GetInterface("MapEditorTools")->
SetPosition({ 0.f, 16.f });
m_guiManager->LoadInterface("MapEditorNewMap.interface",
"MapEditorNewMap");
m_mapSettings = m_guiManager->GetInterface("MapEditorNewMap");
m_mapSettings->PositionCenterScreen();
m_mapSettings->SetActive(false);
m_brush.SetTile(0, 0, 0, 0);
m_brushDrawable.setFillColor({ 255, 255, 255, 200 });
m_brushDrawable.setOutlineColor({ 255, 0, 0, 255 });
m_brushDrawable.setOutlineThickness(-1.f);
m_mapBoundaries.setPosition({ 0.f, 0.f });
m_mapBoundaries.setFillColor({ 0,0,0,0 });
m_mapBoundaries.setOutlineColor({255, 50, 50, 255});
m_mapBoundaries.setOutlineThickness(-1.f);
auto dropdown = static_cast<GUI_DropDownMenu*>(
m_mapSettings->GetElement("SheetDropdown"))->GetMenu();
dropdown->PurgeEntries();
auto names = Utils::GetFileList(Utils::GetWorkingDirectory() +
"media/Tilesheets/", "*.tilesheet");
for (auto& entity : names) {
dropdown->AddEntry(entity.first.substr(
0, entity.first.find(".tilesheet")));
}
dropdown->Redraw();
}
在所有事件回调设置完毕后,我们开始处理接口。实际的工具界面被加载并放置在屏幕上,以及新的地图设置窗口,我们将通过将其指针存储为我们数据成员之一来跟踪它。它被放置在屏幕中央,并暂时设置为不活动状态。
下一个部分仅仅处理画笔可绘制的风格方面以及地图边界矩形。显然,这些值可以被定制以看起来完全不同。
最后,我们需要确保在新的地图设置界面中填充用于选择工作表的下拉元素。在获取元素并清除所有其他条目后,从适当位置获取所有类型为 .tilesheet 的文件名列表,并对每个文件名进行迭代,移除文件格式并将其添加到下拉列表中,然后重新绘制以反映所有更改。
请记住,这里创建的所有接口和回调都需要被移除,这就是析构函数中发生的一切。正因为如此,我们在这里不会介绍它,因为它是不必要的。
让我们看看当这个类被更新时需要发生什么:
void MapControls::Update(float l_dT) {
m_mapBoundaries.setSize(sf::Vector2f(
m_map->GetTileMap()->GetMapSize() *
static_cast<unsigned int>(Sheet::Tile_Size)));
UpdateMouse();
if (m_mode == ControlMode::Pan || m_rightClickPan){PanUpdate();}
else if (m_mode == ControlMode::Brush) { BrushUpdate(); }
else if (m_mode == ControlMode::Bucket) { BucketUpdate(); }
else if (m_mode == ControlMode::Eraser) { EraserUpdate(); }
else if (m_mode == ControlMode::Select) { SelectionUpdate(); }
}
首先,我们处理地图类大小的任何可能变化。地图边界矩形在这里更新以反映这些变化。接下来,我们必须确保鼠标被正确更新。所有这些逻辑都包含在UpdateMouse方法中,该方法在此处被调用。最后,根据当前的ControlMode,我们需要调用所选特定工具的适当更新方法。平移工具是特殊的,因为它在选择为工具时以及当按下鼠标右键时都会被更新。
绘制所有这些对象可能比你想象的要简单:
void MapControls::Draw(sf::RenderWindow* l_window) {
l_window->draw(m_mapBoundaries);
if (m_mode == ControlMode::Brush) {
l_window->draw(m_brushDrawable);
}
m_selectionOptions.Draw(l_window);
}
在这个特定实例中,我们只需要渲染mapBoundaries的矩形、画笔,如果ControlMode设置为Brush,以及具有自己Draw方法的SelectionOptions类。关于这一点将在下一章中详细说明。
接下来,让我们实现所有必要的功能来跟踪所有相关的鼠标信息:
void MapControls::UpdateMouse() {
auto mousePos = m_eventManager->GetMousePos(
m_window->GetRenderWindow());
m_mouseDifference = sf::Vector2f(mousePos - m_mousePosition);
m_mouseDifference *= m_zoom;
m_mousePosition = mousePos;
auto view = m_window->GetRenderWindow()->getView();
auto viewPos = view.getCenter() - (view.getSize() * 0.5f);
auto mouseGlobal=viewPos+(sf::Vector2f(m_mousePosition)*m_zoom);
auto newPosition = sf::Vector2i(
floor(mouseGlobal.x / Sheet::Tile_Size),
floor(mouseGlobal.y / Sheet::Tile_Size)
);
if (m_mouseTilePosition != newPosition && m_action) {
m_secondaryAction = true;
}
m_mouseTilePosition = newPosition;
}
获得当前鼠标位置后,它被用来计算当前帧和前一帧之间的坐标差异。
注意
由于鼠标差异是用全局坐标表示的,我们必须记住将它们乘以缩放因子。
然后将鼠标位置存储到下一帧,这样这个过程可以再次发生。然后获取当前的sf::View以计算摄像机的当前全局位置。从这一点,我们可以计算全局鼠标位置(当然,调整了缩放),以及鼠标瓦片位置,这仅仅是当前指向的瓦片。
然后检查当前鼠标瓦片位置是否与计算结果不同。如果是,并且当前左鼠标按钮被按下(如m_action数据成员所示),则次要动作标志被打开。然后鼠标瓦片位置被存储以供下一帧使用。
鼠标类中的下一个方法处理左右点击,可以像这样实现:
void MapControls::MouseClick(EventDetails* l_details) {
if (l_details->m_hasBeenProcessed) { return; }
if (l_details->m_keyCode !=
static_cast<int>(MouseButtonType::Left))
{
m_rightClickPan = true;
return;
}
m_mousePosition = m_eventManager->GetMousePos(
m_window->GetRenderWindow());
m_mouseStartPosition = m_mousePosition;
auto view = m_window->GetRenderWindow()->getView();
auto viewPos = view.getCenter() - (view.getSize() * 0.5f);
auto mouseGlobal = viewPos + (sf::Vector2f(m_mousePosition)
* m_zoom);
m_mouseTileStartPosition = sf::Vector2i(
floor(mouseGlobal.x / Sheet::Tile_Size),
floor(mouseGlobal.y / Sheet::Tile_Size)
);
if (!m_selectionOptions.MouseClick(mouseGlobal)) { return; }
m_action = true;
m_secondaryAction = true;
}
因为可能已经有其他东西处理了鼠标事件,我们需要检查作为参数提交的事件细节。如果我们只是与界面交互,我们不希望意外地在地图上绘制一些瓦片。接下来,检查事件的关键代码以确定它是否是左鼠标按钮。如果不是,我们只需要担心将右键点击平移标志设置为true并返回。
如果我们确实有一个左键点击,另一方面,当前鼠标位置被存储为起始位置和当前位置。这里发生了一个与更新鼠标位置非常相似的过程,导致全局鼠标坐标的计算。然后,这些坐标被传递到选择选项类的MouseClick()方法中,该方法返回一个布尔标志,表示是否选择了任何实体或粒子发射器。我们将在下一章中处理这个问题。然而,如果情况不是这样,动作标志和次要动作标志都将设置为true,以便使用当前选定的工具。
正如每个动作都有一个相等且相反的反应一样,对于每个点击,我们都需要有一个释放:
void MapControls::MouseRelease(EventDetails* l_details) {
if (l_details->m_keyCode !=
static_cast<int>(MouseButtonType::Left))
{
m_rightClickPan = false;
return;
}
m_action = false;
m_secondaryAction = false;
m_selectionOptions.MouseRelease();
}
在这里,我们只需要重置在鼠标活动期间使用的所有动作标志。这包括右键平移和两个动作标志。选择选项接口还需要通知释放事件。
一个非常实用的功能是能够放大和缩小。这里将其处理为一个事件:
void MapControls::MouseWheel(EventDetails* l_details) {
if (l_details->m_hasBeenProcessed) { return; }
float factor = 0.05f;
factor *= l_details->m_mouseWheelDelta;
factor = 1.f - factor;
m_view.zoom(factor);
m_zoom *= factor;
}
如果这个事件还没有被其他东西处理,我们就继续计算需要发生的缩放量。这里定义了一个float factor值,并将其乘以鼠标滚轮位置的变化。为了将其视为一个缩放因子,我们从1.f中减去它,然后用于放大视图。最后,为了跟踪当前的缩放值,我们必须将其乘以这个缩放因子。
我们需要关注的下一个事件是选择其中一个工具:
void MapControls::ToolSelect(EventDetails* l_details) {
auto mode = ControlMode::None;
if (l_details->m_name == "MapEditor_PanTool") {
mode = ControlMode::Pan;
} else if (l_details->m_name == "MapEditor_BrushTool") {
mode = ControlMode::Brush;
} else if (l_details->m_name == "MapEditor_PaintTool") {
mode = ControlMode::Bucket;
} else if (l_details->m_name == "MapEditor_EraserTool") {
mode = ControlMode::Eraser;
} else if (l_details->m_name == "MapEditor_SelectTool") {
mode = ControlMode::Select;
}
SelectMode(mode);
}
这相当简单,因为我们基本上将元素的名称映射到它们的ControlMode对应物。然后在底部选择适当的模式。
说到工具,每个工具都有自己的更新方法。让我们首先看看平移工具是如何更新的:
void MapControls::PanUpdate() {
if (!m_action && !m_rightClickPan) { return; }
if (m_mouseDifference == sf::Vector2f(0.f, 0.f)) { return; }
m_view.setCenter(m_view.getCenter() +
(sf::Vector2f(0.f, 0.f) - sf::Vector2f(m_mouseDifference)));
}
显然,我们不想在鼠标没有被点击,或者鼠标位置在帧之间的变化是绝对零的情况下移动屏幕。然而,如果这两个条件都满足,我们只需要将视图的中心移动到不同的位置。这个位置是通过将其当前位置与鼠标位置差异相加来计算的,这个差异的符号需要翻转。我们这样做是因为,当鼠标点击并移动到左边,例如,视图需要向右移动以感觉自然。对于x轴也是同样的道理。
对于画笔工具,逻辑是这样的:
void MapControls::BrushUpdate() {
auto tilePos = sf::Vector2f(
static_cast<float>(m_mouseTilePosition.x * Sheet::Tile_Size),
static_cast<float>(m_mouseTilePosition.y * Sheet::Tile_Size)
);
m_brushDrawable.setPosition(tilePos);
PlaceBrushTiles();
}
首先,计算鼠标当前所在的瓦片的全局位置,并将画笔可绘制设置为匹配。这样做会给人一种画笔被锁定到网格上的感觉。然后调用另一个方法来放置瓦片:
void MapControls::PlaceBrushTiles() {
if (!m_action || !m_secondaryAction) { return; }
m_map->GetTileMap()->PlotTileMap(m_brush,
m_mouseTilePosition, m_selectionOptions.GetLowestLayer());
auto size = m_brush.GetMapSize();
auto from = sf::Vector3i(m_mouseTilePosition.x,
m_mouseTilePosition.y, m_selectionOptions.GetLowestLayer());
auto to = sf::Vector3i(m_mouseTilePosition.x + size.x - 1,
m_mouseTilePosition.y + size.y - 1,
m_selectionOptions.GetHighestLayer());
m_map->Redraw(from, to);
m_secondaryAction = false;
// Set it to false in order to avoid multiple placements.
}
在这里,首先和最明显的检查是确保主操作和辅助操作都处于开启状态。我们不希望在鼠标未被点击或已经点击但仍在同一位置时放置瓦片。否则,我们可以开始绘画,这从将画笔瓦片图放置在当前鼠标瓦片位置的游戏地图瓦片图上开始,从当前由选择选项选择的最低层开始。尽管我们可以轻松地切换高度,但我们仍然需要告诉这个方法关于当前选定的最低高度,因为画笔瓦片图本身仍然从高度0开始。
地图更新后,计算要重新绘制的瓦片坐标范围,并将其传递给MapControls类以在屏幕上渲染。我们不希望重新绘制整个地图,因为这会花费更多时间并引入延迟。最后,将辅助操作标志设置为false,以指示在这些坐标上已经进行了放置。
我们需要更新的下一个工具是选择框:
void MapControls::SelectionUpdate() {
m_selectionOptions.SelectionUpdate();
}
如您所见,所有这些逻辑都是由SelectionOptions类处理的。目前,我们只需要关注调用这个方法。
同样的SelectionOptions接口可能负责操作我们的画笔,这意味着我们需要一个方法来重新绘制它以反映更改:
void MapControls::RedrawBrush() {
auto brushSize = m_brush.GetMapSize();
auto brushRealSize = brushSize *
static_cast<unsigned int>(Sheet::Tile_Size);
auto textureSize = m_brushTexture.getSize();
if (brushRealSize.x != textureSize.x ||
brushRealSize.y != textureSize.y)
{
if (!m_brushTexture.create(brushRealSize.x, brushRealSize.y))
{ /* Error Message. */ }
}
m_brushTexture.clear({ 0, 0, 0, 0 });
for (auto x = 0; x < brushSize.x; ++x) {
for (auto y = 0; y < brushSize.y; ++y) {
for (auto layer = 0; layer < Sheet::Num_Layers; ++layer) {
auto tile = m_brush.GetTile(x, y, layer);
if (!tile) { continue; }
auto info = tile->m_properties;
if (!info) { continue; }
info->m_sprite.setPosition(sf::Vector2f(
x * Sheet::Tile_Size, y * Sheet::Tile_Size));
m_brushTexture.draw(info->m_sprite);
}
}
}
m_brushTexture.display();
m_brushDrawable.setTexture(&m_brushTexture.getTexture());
m_brushDrawable.setSize(sf::Vector2f(brushRealSize));
m_brushDrawable.setTextureRect(
sf::IntRect(sf::Vector2i(0, 0), sf::Vector2i(brushRealSize)));
}
首先,真正的像素画笔大小是从其瓦片图的尺寸计算得出的。如果它与表示它的纹理的当前维度不匹配,则需要重新创建纹理。一旦处理完毕,纹理就会被清除到所有透明像素,然后我们开始遍历画笔内部的每个瓦片和层。鉴于这是一个有效的瓦片,并且与包含其精灵以供渲染的信息结构有适当的关联,后者被设置为纹理上的正确位置并绘制到上面。
完成这些操作后,将调用纹理的显示方法以显示所有更改,并将画笔的可绘制对象再次绑定到纹理上。同时,可绘制对象的大小和纹理矩形也会重置,因为纹理的尺寸可能已经改变。
在这类应用程序中,有一个快速且简单的方法来删除当前选定的内容非常重要。为此,我们将处理绑定到键盘上的Delete键的事件:
void MapControls::DeleteTiles(EventDetails* l_details) {
if (m_mode != ControlMode::Select) { return; }
m_selectionOptions.RemoveSelection(l_details);
}
这是一个非常简单的回调。它只是检查当前的ControlMode是否被选中,并将详细信息传递给属于selectionOptions类的另一个回调。它将处理所有删除操作。
当选择新工具时,我们必须将我们与之工作的所有数据成员重置为其初始值,以避免奇怪的错误。这就是ResetTools()方法的作用所在:
void MapControls::ResetTools() {
auto defaultVector = sf::Vector2i(-1, -1);
m_mouseTilePosition = defaultVector;
m_mouseTileStartPosition = defaultVector;
m_selectionOptions.Reset();
m_tileSelector.Hide();
}
它只是将某些鼠标数据重置为默认未初始化状态。同时也会调用m_selectionOptions Reset()方法,以便它可以处理自己的重置。最后,也在这里隐藏了tileSelector界面。
另一个有用的简单方法是重置当前视图的缩放到正常水平:
void MapControls::ResetZoom() {
m_view.zoom(1.f / m_zoom);
m_zoom = 1.f;
}
通过将1.f除以当前的缩放因子,我们获得一个缩放值,当缩放时,视图会返回到正常状态。
接下来,让我们看看这个类要改变其ControlMode需要发生什么:
void MapControls::SelectMode(ControlMode l_mode) {
ResetTools();
m_mode = l_mode;
if (m_mode == ControlMode::Brush) { RedrawBrush(); }
m_selectionOptions.SetControlMode(m_mode);
}
在工具被重置后,作为参数传入的模式被存储。如果应用的模式是画笔,则需要重新绘制。最后,通知selectionOptions类模式已更改,以便它可以执行自己的逻辑。
最后,最后一部分关键代码是创建一个新的地图:
void MapControls::NewMapCreate(EventDetails* l_details) {
auto s_x = m_mapSettings->GetElement("Size_X")->GetText();
auto s_y = m_mapSettings->GetElement("Size_Y")->GetText();
auto friction = m_mapSettings->
GetElement("Friction")->GetText();
auto selection = static_cast<GUI_DropDownMenu*>(
m_mapSettings->GetElement("SheetDropdown"))->
GetMenu()->GetSelected();
if (selection.empty()) { return; }
auto context = m_guiManager->GetContext();
auto editorState = m_stateManager->
GetState<State_MapEditor>(StateType::MapEditor);
m_particleSystem->PurgeCurrentState();
m_map->PurgeMap();
editorState->ResetSavePath();
m_map->GetTileMap()->SetMapSize
sf::Vector2u(std::stoi(s_x), std::stoi(s_y)));
m_map->GetDefaultTile()->m_friction =
sf::Vector2f(std::stof(friction), std::stof(friction));
m_map->GetTileSet()->ResetWorker();
m_map->GetTileSet()->AddFile(Utils::GetWorkingDirectory() +
"media/Tilesheets/" + selection + ".tilesheet");
m_map->GetTileSet()->SetName(selection + ".tilesheet");
auto loading = m_stateManager->
GetState<State_Loading>(StateType::Loading);
loading->AddLoader(context->m_gameMap->GetTileSet());
loading->SetManualContinue(false);
editorState->SetMapRedraw(true);
m_mapSettings->SetActive(false);
m_stateManager->SwitchTo(StateType::Loading);
}
首先,我们从地图设置界面的文本框中获取大小值。此外,我们还获取了摩擦值,以及当前选中的瓦片集下拉菜单中的选择。如果后者为空,我们直接返回,因为没有选择瓦片集。
如果我们继续进行,粒子系统和地图都需要进行清理。然后通知MapEditor状态重置其保存路径,这会强制用户在保存时重新输入文件名。
然后设置地图的大小,以及默认的摩擦值。选定的瓦片集文件被添加到单独的线程中进一步加载,并且其名称被注册在游戏地图内部TileSet数据成员中。
最后,获取加载状态,将瓦片集添加到其中,并将手动继续标志设置为false,以便在加载完成后加载屏幕简单地返回到当前状态。然后隐藏新的地图设置界面,我们最终可以切换到加载状态。
如果发生错误,用户必须有一种方法来关闭新的m_mapSettings界面:
void MapControls::NewMapClose(EventDetails* l_details) {
m_mapSettings->SetActive(false);
}
当界面上的关闭按钮被按下时,此回调会被调用。它所做的只是简单地隐藏它。
最后,我们有一系列设置器和获取器,它们单独看起来并不重要,但从长远来看是有用的:
void MapControls::NewMap() { m_mapSettings->SetActive(true); }
void MapControls::SetTileSheetTexture(const std::string& l_sheet) {
m_tileSelector.SetSheetTexture(l_sheet);
}
ControlMode MapControls::GetMode() const { return m_mode; }
bool MapControls::IsInAction() const { return m_action; }
bool MapControls::IsInSecondary() const{return m_secondaryAction;}
GUI_SelectionOptions* MapControls::GetSelectionOptions() {
return &m_selectionOptions;
}
sf::Vector2i MapControls::GetMouseTileStart() const {
return m_mouseTileStartPosition;
}
sf::Vector2i MapControls::GetMouseTile() const {
return m_mouseTilePosition;
}
sf::Vector2f MapControls::GetMouseDifference() const {
return m_mouseDifference;
}
bool MapControls::DrawSelectedLayers() const {
return m_drawSelectedLayers;
}
void MapControls::ToggleDrawingSelectedLayers() {
m_drawSelectedLayers = !m_drawSelectedLayers;
}
你可能已经注意到,我们尚未介绍桶和橡皮擦工具。这通常被称为作业,应该作为良好的练习:
void MapControls::BucketUpdate() { /* IMPLEMENT */ }
void MapControls::EraserUpdate() { /* IMPLEMENT */ }
请记住,由于我们尚未实现使地图编辑器正常工作的所有功能,这可能需要等到下一章完成后再进行。
摘要
在本章中,我们介绍了并实现了图形文件管理的概念,并为小型 RPG 风格游戏使用的重要工具奠定了基础。在我们能够开始享受拥有适当工具的好处之前,还有很多工作要做。在下一章中,我们将介绍地图编辑器的收尾工作,以及实现用于管理实体的不同工具。那里见!
第五章. 填充工具带 - 更多的小工具
上一章为我们打下了坚实的基础。现在是时候充分利用它,完成我们开始的工作,通过构建一套强大的工具集,准备好应对各种设计问题。
在本章中,我们将讨论以下主题:
-
选择选项的实现
-
瓦片选择窗口的设计和编程
-
实体管理
有很多代码需要覆盖,所以让我们直接进入正题吧!
规划选择选项
在创建一个响应和有用的应用程序时,灵活的选择选项非常重要。没有它们,任何软件最多只能感觉不直观、笨拙或无响应。在这种情况下,我们将处理选择、复制和放置瓦片、实体和粒子发射器。
让我们看看这样一个界面可能是什么样子:

为了达到这个目标,我们需要创建一个灵活的类,设计成能够处理任何可能的选项和控制组合。让我们从回顾在开发这个系统时将非常有用的最基本的数据类型开始:
enum class SelectMode{ Tiles, Entities, Emitters };
using NameList = std::vector<std::pair<std::string, bool>>;
首先,需要枚举选择模式。如前所述的片段所示,我们现在将处理三种模式,尽管这个列表可以很容易地在未来扩展。NameList数据类型将被用来存储实体和粒子目录的内容。这是我们将依赖的实用函数的返回格式。
数据类型的问题已经解决,现在让我们尝试创建SelectionOptions类的蓝图:
class GUI_SelectionOptions {
public:
GUI_SelectionOptions(EventManager* l_eventManager,
GUI_Manager* l_guiManager, MapControls* l_controls,
GUI_MapTileSelector* l_selector, Map* l_map, TileMap* l_brush,
EntityManager* l_entityMgr, ParticleSystem* l_particles);
~GUI_SelectionOptions();
void Show();
void Hide();
void SetControlMode(ControlMode l_mode);
void SetSelectMode(SelectMode l_mode);
SelectMode GetSelectMode()const;
void SelectEntity(int l_id);
void SelectEmitter(Emitter* l_emitter);
sf::Vector2i GetSelectXRange() const;
sf::Vector2i GetSelectYRange() const;
unsigned int GetLowestLayer() const;
unsigned int GetHighestLayer() const;
void Update();
void Draw(sf::RenderWindow* l_window);
bool MouseClick(const sf::Vector2f& l_pos);
void MouseRelease();
void Reset();
void SelectModeSwitch(EventDetails* l_details);
void OpenTileSelection(EventDetails* l_details);
void SolidToggle(EventDetails* l_details);
void CopySelection(EventDetails* l_details);
void PlaceSelection(EventDetails* l_details);
void RemoveSelection(EventDetails* l_details);
void ToggleLayers(EventDetails* l_details);
void SelectionOptionsElevation(EventDetails* l_details);
void SaveOptions(EventDetails* l_details);
private:
void SelectionElevationUpdate();
void UpdateSelectDrawable();
void UpdateTileSelection();
void UpdateEntitySelection();
void UpdateEmitterSelection();
void DeleteSelection(bool l_deleteAll);
...
};
为了保持简单,让我们先讨论我们需要的方法,然后再讨论数据成员。就公共方法而言,我们几乎拥有任何人都会期望的集合。除了Show()和Hide()方法,这些方法将被用来操作这个类封装的界面之外,我们几乎只有几个设置器和获取器,用于操作ControlMode和SelectMode,选择特定的实体或粒子发射器,以及获取瓦片选择范围,以及层可见性/选择的范围。此外,这个类还需要为我们在工作的界面中的许多控件提供大量的回调方法。
私有方法主要是由用于更新界面及其在屏幕上选择视觉表示的代码组成,以及用于更新选择界面可能处于的每个可能模式的代码。它还包括一个私有方法DeleteSelection(),当删除瓦片、实体或粒子发射器时将非常有用。
最后,让我们看一下将要用来保存这个类状态的各个数据成员:
class GUI_SelectionOptions {
private:
...
// Selection data.
SelectMode m_selectMode;
sf::RectangleShape m_selectDrawable;
sf::Color m_selectStartColor;
sf::Color m_selectEndColor;
sf::Color m_entityColor;
sf::Color m_emitterColor;
sf::Vector2i m_selectRangeX;
sf::Vector2i m_selectRangeY;
bool m_selectUpdate;
// Entity and emitter select info.
int m_entityId;
C_Position* m_entity;
Emitter* m_emitter;
NameList m_entityNames;
NameList m_emitterNames;
// Selection range.
unsigned int m_layerSelectLow;
unsigned int m_layerSelectHigh;
// Interfaces.
GUI_Interface* m_selectionOptions;
MapControls* m_mapControls;
GUI_MapTileSelector* m_tileSelector;
// Class ties.
EventManager* m_eventManager;
GUI_Manager* m_guiManager;
Map* m_map;
TileMap* m_brush;
EntityManager* m_entityManager;
ParticleSystem* m_particleSystem;
};
我们首先存储当前的选取模式,以及用于视觉表示正在进行的选取的RectangleShape对象。为了使我们的工具感觉更加响应和生动,我们将提供多种不同的颜色,用于表示不同的选取状态。例如,m_selectStartColor和m_selectEndColor数据成员用于区分仍在进行的瓦片选取以及鼠标按钮释放时的最终状态。除了颜色之外,我们还有两种向量类型,用于存储两个轴的瓦片选取范围,以及一个布尔标志,用于确定何时更新矩形形状。
对于其他两种状态,我们需要存储实体标识符及其位置组件,因为我们处于实体选取模式,以及指向粒子发射器的指针,因为我们目前正在处理粒子。这也是粒子目录和实体目录的内容将被存储的地方,以便用适当的值填充下拉列表。
此外,我们还需要跟踪层选取范围,以及指向selectionOptions接口、上一章中提到的MapControl类和一个即将介绍的地图瓦片选择类的指针。请注意,只有m_selectionOptions接口在技术上属于这个类。其他两个类封装了自己的接口,因此管理它们的销毁。
最后,我们需要能够访问eventManager、guimanager、游戏map实例、瓦片brush、entityManager和particleSystem。
实现选择选项
在所有这些数据都得到适当初始化的情况下,我们在构造函数中有很多工作要做:
GUI_SelectionOptions::GUI_SelectionOptions(
EventManager* l_eventManager, GUI_Manager* l_guiManager,
MapControls* l_controls, GUI_MapTileSelector* l_selector,
Map* l_map, TileMap* l_brush, EntityManager* l_entityMgr,
ParticleSystem* l_particles) :
/* Processing arguments. */
m_eventManager(l_eventManager), m_guiManager(l_guiManager),
m_mapControls(l_controls), m_tileSelector(l_selector),
m_map(l_map), m_brush(l_brush), m_entityManager(l_entityMgr),
m_particleSystem(l_particles),
/* Initializing default values of data members. */
m_selectRangeX(-1, -1), m_selectRangeY(-1, -1),
m_layerSelectLow(0), m_layerSelectHigh(0),
m_selectMode(SelectMode::Tiles), m_entityId(-1),
m_entity(nullptr), m_emitter(nullptr), m_selectUpdate(true)
{...}
在所有参数都妥善存储之后,所有数据成员的默认值被设置。这确保了选择初始状态的定义。构造函数的主体用于适当地处理其他任务:
GUI_SelectionOptions::GUI_SelectionOptions(...)
{
... // Setting up callbacks.
m_guiManager->LoadInterface(
"MapEditorSelectionOptions.interface",
"MapEditorSelectionOptions");
m_selectionOptions =
m_guiManager->GetInterface("MapEditorSelectionOptions");
m_selectionOptions->SetPosition({ 0.f, 164.f });
m_selectionOptions->SetActive(false);
m_selectStartColor = sf::Color(0, 0, 150, 120);
m_selectEndColor = sf::Color(0, 0, 255, 150);
m_entityColor = sf::Color(255, 0, 0, 150);
m_emitterColor = sf::Color(0, 255, 0, 150);
m_entityNames = Utils::GetFileList(Utils::GetWorkingDirectory()
+ "media/Entities/", "*.entity");
m_emitterNames = Utils::GetFileList(Utils::GetWorkingDirectory()
+ "media/Particles/", "*.particle");
}
在这里,所有适当的回调都已设置,类拥有的接口被加载、定位并隐藏,颜色值也被初始化。最后,实体和粒子发射器目录的内容被获取并存储。
我们在这里不会介绍析构函数,因为它只是简单地处理移除所有回调和设置的接口。
说到接口,外部代码需要能够轻松地显示和隐藏selectionOptions窗口:
void GUI_SelectionOptions::Show() {
m_selectionOptions->SetActive(true);
m_guiManager->BringToFront(m_selectionOptions);
}
void GUI_SelectionOptions::Hide() {
m_selectionOptions->SetActive(false);
}
通过将接口设置为活动或非活动状态,可以达到预期的效果。在前者的情况下,guiManager也被用来将selectionOptions接口置于所有其他元素之上,将其带到前台。
由于这个接口/类是一种辅助工具,它依赖于我们编辑器的控制模式。这种关系要求selectionOptions类通知controlMode的变化:
void GUI_SelectionOptions::SetControlMode(ControlMode l_mode) {
if (l_mode != ControlMode::Brush && l_mode
!= ControlMode::Select)
{ return; }
SetSelectMode(SelectMode::Tiles);
if (l_mode == ControlMode::Brush) {
m_selectionOptions->SetActive(true);
m_selectionOptions->Focus();
m_selectionOptions->GetElement("TileSelect")->SetActive(true);
} else if (l_mode == ControlMode::Select) {
m_selectionOptions->SetActive(true);
m_selectionOptions->Focus();
m_selectionOptions->GetElement("SolidToggle")->
SetActive(true);
m_selectionOptions->GetElement("CopySelection")->
SetActive(true);
}
}
只需要担心Brush和Select模式,因为这个界面甚至不需要用于其他任何事情。如果选择Brush,界面被启用并聚焦,同时其TileSelect元素也被启用。这确保我们可以选择想要绘制的瓷砖。如果选择选择工具,我们希望启用切换实体固态和选择复制的按钮。
实际的选择模式切换也需要处理,可以这样做:
void GUI_SelectionOptions::SetSelectMode(SelectMode l_mode) {
Reset();
m_selectMode = l_mode;
m_selectionOptions->SetActive(true);
m_selectionOptions->Focus();
if (l_mode == SelectMode::Tiles) {
... // GUI Element manipulation.
} else if(l_mode == SelectMode::Entities) {
... // GUI Element manipulation.
auto dropdown = static_cast<GUI_DropDownMenu*>(
m_selectionOptions->GetElement("SelectDropdown"))->
GetMenu();
dropdown->PurgeEntries();
for (auto& entity : m_entityNames) {
dropdown->AddEntry(
entity.first.substr(0, entity.first.find(".entity")));
}
dropdown->Redraw();
} else if (l_mode == SelectMode::Emitters) {
... // GUI Element manipulation.
auto dropdown = static_cast<GUI_DropDownMenu*>(
m_selectionOptions->GetElement("SelectDropdown"))->
GetMenu();
dropdown->PurgeEntries();
for (auto& emitter : m_emitterNames) {
dropdown->AddEntry(
emitter.first.substr(0, emitter.first.find(".particle")));
}
dropdown->Redraw();
}
}
首先,调用Reset()方法。它用于禁用所有不必要的界面元素,并将选择数据成员重置为其默认值。在存储实际选择模式并将界面设置为活动状态后,我们开始处理实际的模式特定逻辑。
如果我们处于瓷砖选择模式,它仅仅涉及启用一些界面元素,以及将它们的文本设置为匹配上下文。为了简单起见,此方法中省略了所有元素操作。
处理实体和发射器模式类似,但包括一个额外的步骤,即用适当的值填充下拉菜单。在这两种情况下,都获取下拉元素并清除其当前条目。然后遍历适当的目录列表;将每个条目添加到下拉菜单中,确保移除文件类型。完成此操作后,指示下拉菜单重新绘制。
让我们看看当我们的选择选项类被指示选择特定实体时需要发生什么:
void GUI_SelectionOptions::SelectEntity(int l_id) {
if (l_id == -1) {
m_entityId = -1;
m_selectionOptions->GetElement("CopySelection")->
SetActive(false);
m_selectionOptions->GetElement("PlaceSelection")->
SetText("Place");
m_selectionOptions->GetElement("RemoveSelection")->
SetActive(false);
m_entity = nullptr;
return;
}
auto pos = m_entityManager->
GetComponent<C_Position>(l_id, Component::Position);
if (!pos) {
m_entityId = -1;
m_selectionOptions->GetElement("CopySelection")->
SetActive(false);
m_selectionOptions->GetElement("PlaceSelection")->
SetText("Place");
m_selectionOptions->GetElement("RemoveSelection")->
SetActive(false);
m_entity = nullptr;
return;
}
m_selectionOptions->GetElement("CopySelection")->
SetActive(true);
m_selectionOptions->GetElement("PlaceSelection")->
SetText("Edit");
m_selectionOptions->GetElement("RemoveSelection")->
SetActive(true);
m_entityId = l_id;
m_entity = pos;
m_selectionOptions->GetElement("InfoText")->
SetText(std::to_string(m_entityId));
m_selectUpdate = true;
}
首先,该参数可用于取消选择实体,以及选择它。如果传递了适当的取消选择值,或者未找到提供的标识符的实体位置组件,则相关的界面元素被调整以匹配该情况。
如果提供的 ID 对应的实体存在,则适当的元素被启用并调整。实体位置组件及其标识符被存储以供以后使用,并且选择选项界面中的信息文本元素被更改以反映所选实体的 ID。它也被标记为更新,通过操作布尔标志m_selectUpdate。
选择发射器时发生的过程非常相似:
void GUI_SelectionOptions::SelectEmitter(Emitter* l_emitter) {
m_emitter = l_emitter;
if (!l_emitter) {
m_selectionOptions->GetElement("CopySelection")->
SetActive(false);
m_selectionOptions->GetElement("PlaceSelection")->
SetText("Place");
m_selectionOptions->GetElement("RemoveSelection")->
SetActive(false);
return;
}
m_selectionOptions->GetElement("CopySelection")->
SetActive(true);
m_selectionOptions->GetElement("PlaceSelection")->
SetText("Edit");
m_selectionOptions->GetElement("RemoveSelection")->
SetActive(true);
m_selectionOptions->GetElement("InfoText")->SetText(m_emitter->
GetGenerators());
m_selectionOptions->GetElement("EmitRate")->
SetText(std::to_string(m_emitter->GetEmitRate()));
m_selectUpdate = true;
}
在某种意义上,我们只处理一个指向粒子发射器的指针。如果传入nullptr,则适当的元素被禁用并调整。否则,界面更新以反映所选发射器的信息,之后还标记selectionOptions界面已正确更新。
我们显然还需要一种在不同的选择模式之间切换的方法,因此有了这个回调函数:
void GUI_SelectionOptions::SelectModeSwitch(
EventDetails* l_details)
{
if (m_selectMode == SelectMode::Tiles) {
if (m_mapControls->GetMode() != ControlMode::Select) {
m_mapControls->SelectMode(ControlMode::Select);
}
SetSelectMode(SelectMode::Entities);
} else if (m_selectMode == SelectMode::Entities) {
SetSelectMode(SelectMode::Emitters);
} else { SetSelectMode(SelectMode::Tiles); }
}
它简单地遍历所有选择选项。这里值得指出的一点是,如果循环之前的界面处于瓷砖模式,我们想要确保将ControlMode切换到Select。
我们还希望工作的另一个特性是打开和处理从瓦片表中选中的瓦片:
void GUI_SelectionOptions::OpenTileSelection(
EventDetails* l_details)
{
if (!m_tileSelector->IsActive()) {
m_tileSelector->Show();
return;
}
m_mapControls->SelectMode(ControlMode::Brush);
if (m_tileSelector->CopySelection(*m_brush)) {
m_selectionOptions->GetElement("Solidity")->SetText("False");
m_mapControls->RedrawBrush();
}
m_selectionOptions->GetElement("InfoText")->SetText(
std::to_string(m_brush->GetTileCount()));
}
首先,我们处理打开 tileSelector 接口,前提是它尚未设置为激活状态。另一方面,如果接口已打开,被按下的选择按钮表示用户试图将他们的选择复制到画笔中。mapControls 类被指示将其模式切换到 Brush,然后传递给 tileSelector 类的 CopySelection() 方法,该方法负责复制实际的瓦片数据。由于它返回一个指示其成功的 布尔 值,该方法在 if 语句内被调用,这使得我们能够在复制过程成功的情况下更新界面的实体性元素并请求画笔重新绘制。无论如何,selectionOptions 接口的信息文本元素随后被更新,以保存已选并复制到画笔中的瓦片总数。
在我们的瓦片编辑器中,切换当前被选中的地图部分或画笔本身的实体性也是可能的:
void GUI_SelectionOptions::SolidToggle(EventDetails* l_details) {
auto mode = m_mapControls->GetMode();
if (m_mapControls->GetMode() != ControlMode::Brush
&& mode != ControlMode::Select)
{ return; }
auto element = m_selectionOptions->GetElement("Solidity");
auto state = element->GetText();
bool solid = false;
std::string newText;
if (state == "True") { newText = "False"; }
else { solid = true; newText = "True"; }
element->SetText(newText);
sf::Vector2u start;
sf::Vector2u finish;
TileMap* map = nullptr;
if (mode == ControlMode::Brush) {
map = m_brush;
start = sf::Vector2u(0, 0);
finish = map->GetMapSize() - sf::Vector2u(1, 1);
} else if (mode == ControlMode::Select) {
map = m_map->GetTileMap();
start = sf::Vector2u(m_selectRangeX.x, m_selectRangeY.x);
finish = sf::Vector2u(m_selectRangeX.y, m_selectRangeY.y);
}
for (auto x = start.x; x <= finish.x; ++x) {
for (auto y = start.y; y <= finish.y; ++y) {
for (auto layer = m_layerSelectLow;
layer < m_layerSelectHigh; ++layer)
{
auto tile = map->GetTile(x, y, layer);
if (!tile) { continue; }
tile->m_solid = solid;
}
}
}
}
首先,显然不能切换选择范围的实体性,如果控制模式没有设置为 Brush 或 Select 模式。覆盖这一点后,我们获取实体状态标签及其文本。翻转其值到其相反,并更新元素的文本后,我们建立一个将要修改的瓦片范围。在画笔的实体性被切换的情况下,范围包含整个结构。另一方面,当处理选择模式时,使用地图选择范围。
注意
m_selectRangeX 和 m_selectRangeY 数据成员表示地图瓦片的选取范围。每个范围负责其自身的轴。例如,m_selectRangeX.x 是 起始 X 坐标,而 m_selectRangeX.y 是 结束 X 坐标。
在范围被正确建立后,我们只需遍历它,并从适当的 TileMap 获取瓦片,将它们的实体性设置为适当的值。
将地图的某个部分复制到画笔也可能是一个有用的特性:
void GUI_SelectionOptions::CopySelection(EventDetails* l_details)
{
if (m_selectRangeX.x == -1) { return; }
auto size = sf::Vector2u(
m_selectRangeX.y - m_selectRangeX.x,
m_selectRangeY.y - m_selectRangeY.x);
size.x += 1;
size.y += 1;
m_brush->Purge();
m_brush->SetMapSize(size);
unsigned int b_x = 0, b_y = 0, b_l = 0;
bool solid = false, mixed = false;
unsigned short changes = 0;
for (auto x = m_selectRangeX.x; x <= m_selectRangeX.y; ++x) {
for (auto y = m_selectRangeY.x; y <= m_selectRangeY.y; ++y) {
for (auto layer = m_layerSelectLow;
layer <= m_layerSelectHigh; ++layer)
{
auto tile = m_map->GetTile(x, y, layer);
if (!tile) { ++b_l; continue; }
auto newTile = m_brush->SetTile(
b_x, b_y, b_l, tile->m_properties->m_id);
if (!newTile) { continue; }
if (!mixed) {
if (tile->m_solid && !solid) {
solid = true; ++changes;
} else if (solid) {
solid = false; ++changes;
}
if (changes >= 2) { mixed = true; }
}
*newTile = *tile;
++b_l;
}
b_l = 0;
++b_y;
}
b_y = 0;
++b_x;
}
m_layerSelectHigh = m_layerSelectLow +
m_brush->GetHighestElevation();
if (m_layerSelectHigh >= Sheet::Num_Layers) {
auto difference = (m_layerSelectHigh - Sheet::Num_Layers) + 1;
m_layerSelectHigh = Sheet::Num_Layers - 1;
m_layerSelectLow -= difference;
}
SelectionElevationUpdate();
m_mapControls->SelectMode(ControlMode::Brush);
m_selectionOptions->GetElement("InfoText")->
SetText(std::to_string(m_brush->GetTileCount()));
m_selectionOptions->GetElement("Solidity")->
SetText((mixed ? "Mixed" : (solid ? "True" : "False")));
}
我们首先检查是否确实进行了选择,这可以通过检查任何选择范围的数据成员来完成。之后,通过从选择范围的起点减去终点,并在两个轴上各增加一个单位来计算选择的大小。这样做是为了补偿那些开始和结束在同一确切瓦片编号上的包含范围。
一旦清除并调整画笔瓦片地图的大小,就会设置一些局部变量以帮助其余代码。三个无符号整数将被用作画笔瓦片地图的索引坐标,以便正确映射复制的瓦片。两个布尔标志和无符号短整型变化将用于跟踪坚固性变化,以便更新表示选择处于何种坚固状态的用户界面元素。
接下来,进入瓦片循环。在获取特定坐标处的地图瓦片并通过有效性检查后,当前坐标由b_x、b_y和b_l表示的画笔瓦片被设置为持有相同的瓦片 ID。然后检测并记录瓦片的坚固性变化,以确定我们是否有一个混合的坚固性选择。最后,通过使用重载的=运算符,将所有其他瓦片属性转移到画笔上。
为了使界面与我们的操作保持同步,当前图层选择范围会被检查是否超出了应用程序支持的总图层实际范围。例如,如果我们支持四个总图层,而当前选中的图层是第二个,同时画笔的所有图层都已填充,我们希望通过计算图层差异,调整所选最高图层以匹配应用程序支持的最大图层,并从最低图层中减去这个差异,从而保持画笔的正确范围。
最后,调用一个用于更新选择选项高度选择文本的方法,指示地图控制类切换到画笔模式,并使用画笔瓦片计数和坚固性信息更新选择选项界面。
让我们暂时放下放置、编辑或复制瓦片的话题,来谈谈当按下放置按钮时实际放置实体或发射器的情况:
void GUI_SelectionOptions::PlaceSelection(EventDetails* l_details)
{
if (m_selectMode == SelectMode::Tiles) { return; }
auto dropdownValue = static_cast<GUI_DropDownMenu*>(
m_selectionOptions->GetElement("SelectDropdown"))->
GetMenu()->GetSelected();
if (dropdownValue.empty()) { return; }
if (m_selectMode == SelectMode::Entities) {
if (!m_entity || m_entityId == -1) {
// New entity.
auto id = m_entityManager->AddEntity(dropdownValue);
if (id == -1) { return; }
SelectEntity(id);
}
SaveOptions(nullptr);
} else if (m_selectMode == SelectMode::Emitters) {
if (!m_emitter) {
// New emitter.
auto text = m_selectionOptions->
GetElement("EmitRate")->GetText();
auto rate = std::stoi(text);
auto emitter = m_particleSystem->AddEmitter(
sf::Vector3f(0.f, 0.f, 0.f), dropdownValue, rate,
StateType::MapEditor);
SelectEmitter(emitter);
}
SaveOptions(nullptr);
}
}
我们不会使用这个功能对瓦片进行任何操作,因为那是鼠标的指定用途。如果selectionOptions界面处于适当的选择模式,会获取下拉菜单的值并检查其是否为空。在适当的情况下,例如选中实体或粒子发射器时,放置按钮也可以充当编辑按钮,因此在这两种情况下,都会检查适当的值以表示选择或未选择。如果没有选择任何内容,则使用下拉值添加所选类型的新实体或发射器。然后调用SaveOptions()方法,因此在这种情况下,当前存储在selectionOptions界面中的信息将被保存到新创建的对象或已选中的对象中。
按下删除按钮可以这样处理:
void GUI_SelectionOptions::RemoveSelection(
EventDetails* l_details)
{
DeleteSelection(l_details->m_shiftPressed);
}
如您所见,这里调用了一种不同的方法,传递了一个布尔标志,表示是否按下了Shift键,控制当前选择中删除多少。让我们看看实际的删除方法:
void GUI_SelectionOptions::DeleteSelection(bool l_deleteAll) {
if (m_selectMode == SelectMode::Tiles) {
if (m_selectRangeX.x == -1) { return; }
auto layerRange = (l_deleteAll ?
sf::Vector2u(0, Sheet::Num_Layers - 1) :
sf::Vector2u(m_layerSelectLow, m_layerSelectHigh));
m_map->GetTileMap()->RemoveTiles(
sf::Vector2u(m_selectRangeX),
sf::Vector2u(m_selectRangeY),
layerRange);
m_map->ClearMapTexture(
sf::Vector3i(m_selectRangeX.x,
m_selectRangeY.x, layerRange.x),
sf::Vector3i(m_selectRangeX.y,
m_selectRangeY.y, layerRange.y));
} else if (m_selectMode == SelectMode::Entities) {
if (!m_entity || m_entityId == -1) { return; }
m_entityManager->RemoveEntity(m_entityId);
SelectEntity(-1);
} else if (m_selectMode == SelectMode::Emitters) {
if (!m_emitter) { return; }
m_particleSystem->RemoveEmitter(m_emitter);
SelectEmitter(nullptr);
}
}
再次处理所有三种不同的选择类型:瓦片、实体和粒子发射器。如果我们正在处理瓦片,检查选择范围。如果实际上有选择,根据参数是否表示应该删除一切,定义层范围。然后指示地图在计算出的范围内删除瓦片并清除其渲染纹理。
在实体和粒子发射器的情况下,事情要简单得多。所选的实体/发射器被简单地删除,并在不久之后调用适当的SelectX方法,传入一个没有选择值的参数。
接下来,让我们处理控制海拔选择的+和-按钮:
void GUI_SelectionOptions::SelectionOptionsElevation(
EventDetails* l_details)
{
int low = 0, high = 0;
bool shift = sf::Keyboard::isKeyPressed(sf::Keyboard::LShift);
if (l_details->m_name == "MapEditor_SelectOptionsPlus") {
if (shift) { high = 1; } else { low = 1; }
} else if(l_details->m_name == "MapEditor_SelectOptionsMinus") {
if (shift) { high = -1; } else { low = -1; }
}
auto mode = m_mapControls->GetMode();
if (mode == ControlMode::Brush) {
if (high != 0) { return; } // only working with low values.
int l = m_layerSelectLow + low;
if (l < 0 || l >= Sheet::Num_Layers) { return; }
if (l + m_brush->GetHighestElevation() >=
Sheet::Num_Layers)
{ return; }
m_layerSelectLow = l;
m_layerSelectHigh = l + m_brush->GetHighestElevation();
SelectionElevationUpdate();
} else if (mode == ControlMode::Select) {
int l = m_layerSelectLow + low;
int h = m_layerSelectHigh + high;
if (l < 0 || l >= Sheet::Num_Layers) { return; }
if (h < 0 || h >= Sheet::Num_Layers) { return; }
if (m_layerSelectLow == m_layerSelectHigh && !shift) {
m_layerSelectLow += low;
m_layerSelectLow += high;
m_layerSelectHigh = m_layerSelectLow;
} else {
m_layerSelectLow = l;
m_layerSelectHigh = h;
}
if (m_layerSelectLow > m_layerSelectHigh) {
std::swap(m_layerSelectLow, m_layerSelectHigh);
}
SelectionElevationUpdate();
}
}
在这里,我们希望以特定的方式处理按钮点击。请记住,支持选择层范围也是非常重要的。考虑以下插图:

单击加号或减号会影响低数,它代表所选最低海拔。按住Shift键会增加高数,控制最高海拔。为此,设置了两个整数low和high,以及一个布尔标志,用于确定是否按下了Shift键。根据这一点和事件名称,数字被调整以表示海拔的变化。
接下来,我们再次分支逻辑。如果选择了Brush模式,我们根本不想处理任何高海拔的变化。相反,这里只使用低层选择。通过将层增量添加到已选择的低海拔,确定新的值,检查范围是否超过 0;Sheet::NumLayers)的边界。如果通过,低海拔选择将使用新值更新,高值也更新,它简单地取低海拔并加上画笔的厚度,这由画笔的最高海拔表示。
Select模式遵循相同的基本原则,只有一个例外:它还处理高海拔。通过适当地将增量添加到当前值中,检查范围是否超过允许的限制。下一个检查处理的是,当低值和高值都相同时,我们如何根据情况控制 shift-clicks。如果它们相同,增量将简单地添加到低值,并将其复制到高海拔,以保持相等。否则,低值和高值都将简单地用先前计算的范围覆盖。
在这两种情况下,调用 SelectionElevationUpdate() 方法也很重要,它确保界面元素保持最新,如下所示:
void GUI_SelectionOptions::SelectionElevationUpdate() {
if (!m_selectionOptions->IsActive()) { return; }
m_selectionOptions->GetElement("Elevation")->SetText(
std::to_string(m_layerSelectLow) +
(m_layerSelectLow != m_layerSelectHigh ?
" - " + std::to_string(m_layerSelectHigh) : "")
);
SaveOptions(nullptr);
}
确保选择选项界面实际上处于活动状态后,高度标签会更新为适当的层范围。然后,使用 nullptr 作为其参数调用 SaveOptions() 回调。它负责将界面信息保存到所选的任何对象。现在让我们看看这个方法:
void GUI_SelectionOptions::SaveOptions(EventDetails* l_details) {
if (m_selectMode == SelectMode::Tiles) { return; }
auto x = m_selectionOptions->GetElement("Pos_X")->GetText();
auto y = m_selectionOptions->GetElement("Pos_Y")->GetText();
auto z = m_selectionOptions->GetElement("Pos_Z")->GetText();
auto c_x = std::stoi(x);
auto c_y = std::stoi(y);
auto c_z = std::stoi(z);
if (m_selectMode == SelectMode::Entities) {
if (!m_entity || m_entityId == -1) { return; }
m_entity->SetPosition(sf::Vector2f(c_x, c_y));
m_entity->SetElevation(m_layerSelectLow);
} else if (m_selectMode == SelectMode::Emitters) {
if (!m_emitter) { return; }
auto emitRate = m_selectionOptions->
GetElement("EmitRate")->GetText();
auto c_rate = std::stoi(emitRate);
m_emitter->SetPosition(sf::Vector3f(c_x, c_y, c_z));
m_emitter->SetEmitRate(c_rate);
}
}
最明显的第一步检查是确保我们不在瓦片模式下,因为那里没有可以保存的内容。之后,代表 X、Y 和 Z 坐标的文本字段中的值被获取并转换为数字。这就是我们的逻辑再次分支的地方。
在处理实体的情况下,我们首先必须确保已经选择了一个实体。如果是的话,它的位置将改变为从界面获取的值的那个位置。这里我们不需要使用 Z 坐标,因为那被高度所取代。
然而,当处理粒子发射器时,会使用到 Z 坐标。从界面获取额外的发射率值并将其转换为适当的数字后,所有这些值都应用于当前选定的粒子发射器。
现在,是使其他一切正常工作的代码片段:
void GUI_SelectionOptions::Update() {
if (m_selectUpdate) { UpdateSelectDrawable(); }
if (!m_mapControls->IsInAction()) { return; }
if (m_selectMode == SelectMode::Tiles) {UpdateTileSelection();}
else if (m_selectMode == SelectMode::Entities) {
UpdateEntitySelection();
} else if (m_selectMode == SelectMode::Emitters) {
UpdateEmitterSelection();
}
}
在这一点上,我们想要确保选择可绘制的内容被更新,前提是 m_selectUpdate 标志被启用。如果 mapControls 类没有告诉我们左鼠标按钮被按下,则可以跳过其余的代码。然而,如果是的话,会根据界面中的 selectMode 调用适当的方法进行更新。
保持应用程序看起来整洁且响应迅速的一个好方法是拥有某些选择清晰的指示,如下所示:

这个界面,就像我们一直在使用的其他大多数界面一样,当封装在自己的类中时,将更容易管理:
class GUI_MapTileSelector {
public:
GUI_MapTileSelector(EventManager* l_eventManager,
GUI_Manager* l_guiManager, TextureManager* l_textureManager);
~GUI_MapTileSelector();
void Show();
void Hide();
bool IsActive() const;
void SetSheetTexture(const std::string& l_texture);
void UpdateInterface();
bool CopySelection(TileMap& l_tileMap) const;
void TileSelect(EventDetails* l_details);
void Close(EventDetails* l_details);
private:
EventManager* m_eventManager;
GUI_Manager* m_guiManager;
TextureManager* m_textureManager;
GUI_Interface* m_interface;
sf::RenderTexture m_selectorTexture;
sf::Sprite m_tileMapSprite;
sf::RectangleShape m_shape;
std::string m_sheetTexture;
sf::Vector2u m_startCoords;
sf::Vector2u m_endCoords;
bool m_selected;
};
就像之前一样,我们有Show()和Hide()方法来管理其可见性,以及一些回调。注意突出显示的方法。它将被用来设置地图使用的瓦片表的纹理。
对于这样的类,数据成员相当可预测。除了这个对象所依赖的类之外,我们还跟踪一个指向它将要操作的界面指针,一个我们将要绘制到的sf::RenderTexture实例,一个用于显示渲染纹理的精灵,一个矩形形状,起始和结束坐标,以及一个表示实际选择可绘制的布尔标志。最后,m_sheetTexture将简单地跟踪纹理标识符,直到需要释放它的时候。
实现瓦片选择器
让我们从在构造函数内设置所有这些数据开始:
GUI_MapTileSelector::GUI_MapTileSelector(
EventManager* l_eventManager, GUI_Manager* l_guiManager,
TextureManager* l_textureManager) :
m_eventManager(l_eventManager), m_guiManager(l_guiManager),
m_textureManager(l_textureManager), m_selected(false)
{
m_eventManager->AddCallback(StateType::MapEditor,
"MapEditor_TileSelectClick",
&GUI_MapTileSelector::TileSelect, this);
m_eventManager->AddCallback(StateType::MapEditor,
"MapEditor_TileSelectRelease",
&GUI_MapTileSelector::TileSelect, this);
m_eventManager->AddCallback(StateType::MapEditor,
"MapEditor_TileSelectClose",
&GUI_MapTileSelector::Close, this);
m_guiManager->LoadInterface("MapEditorTileSelect.interface",
"MapEditorTileSelect");
m_interface = m_guiManager->GetInterface("MapEditorTileSelect");
m_interface->SetContentRectSize(
sf::Vector2i(m_interface->GetSize()-sf::Vector2f(32.f,32.f)));
m_interface->SetContentOffset({ 16.f, 16.f });
m_interface->PositionCenterScreen();
m_interface->SetActive(false);
m_shape.setFillColor({ 0, 0, 150, 150 });
m_shape.setSize({ Sheet::Tile_Size, Sheet::Tile_Size });
m_shape.setPosition(0.f, 0.f);
}
在处理完所有争论之后,我们需要设置三个回调方法。然后加载界面并将其存储为数据成员之一,就在其内容矩形的大小和偏移量改变之前,以便为控制元素留出空间,例如将关闭按钮放置得舒适。接着,界面在屏幕上居中并设置为非活动状态。最后,用于表示瓦片选择的矩形形状被初始化为其默认状态。
让我们来看看这个类的析构函数,以确保我们没有忘记释放某些资源:
GUI_MapTileSelector::~GUI_MapTileSelector() {
... // Callbacks and interface removal.
if (!m_sheetTexture.empty()) {
m_textureManager->ReleaseResource(m_sheetTexture);
}
}
在释放所有三个回调之后,必须确保如果其标识符不为空,则还删除了瓦片纹理。
谈到瓦片纹理,让我们看看如何将一个分配给这个类:
void GUI_MapTileSelector::SetSheetTexture(
const std::string& l_texture)
{
if (!m_sheetTexture.empty()) {
m_textureManager->ReleaseResource(m_sheetTexture);
}
m_sheetTexture = l_texture;
m_textureManager->RequireResource(m_sheetTexture);
m_tileMapSprite.setTexture(
*m_textureManager->GetResource(m_sheetTexture));
m_tileMapSprite.setPosition({ 0.f, 0.f });
auto size = m_tileMapSprite.getTexture()->getSize();
m_selectorTexture.create(size.x, size.y);
m_selectorTexture.clear({ 0,0,0,0 });
m_selectorTexture.draw(m_tileMapSprite);
m_selectorTexture.display();
auto element = static_cast<GUI_Sprite*>(
m_interface->GetElement("TileSprite"));
element->SetTexture(m_selectorTexture);
}
在适当地释放当前的瓦片纹理之后,新的纹理被分配并检索。因此,实际将传递给界面主 GUI 元素的选取纹理需要重新绘制并传递到该元素中。
当界面需要更新时,发生类似的程序:
void GUI_MapTileSelector::UpdateInterface() {
m_selectorTexture.clear({ 0,0,0,0 });
m_selectorTexture.draw(m_tileMapSprite);
m_selectorTexture.draw(m_shape);
m_selectorTexture.display();
m_interface->RequestContentRedraw();
}
它仅由瓦片纹理以及绘制到渲染纹理上的选择矩形组成。然后指示界面重新绘制其内容,因为它已经发生了变化。
接下来,让我们提供一个方法,以便外部类可以将当前的瓦片纹理选择复制到TileMap结构中:
bool GUI_MapTileSelector::CopySelection(TileMap& l_tileMap) const{
if (!m_selected) { return false; }
l_tileMap.Purge();
auto TileCoordsStart = m_startCoords /
static_cast<unsigned int>(Sheet::Tile_Size);
auto TileCoordsEnd = m_endCoords /
static_cast<unsigned int>(Sheet::Tile_Size);
auto size = TileCoordsEnd - TileCoordsStart;
l_tileMap.SetMapSize(size + sf::Vector2u(1,1));
auto sheetSize = m_textureManager->GetResource(
l_tileMap.GetTileSet().GetTextureName())->getSize();
auto nPerRow = sheetSize.x / Sheet::Tile_Size;
auto t_x = 0, t_y = 0;
for (auto x = TileCoordsStart.x; x <= TileCoordsEnd.x; ++x) {
for (auto y = TileCoordsStart.y; y <= TileCoordsEnd.y; ++y) {
auto coordinate = (y * nPerRow) + x;
auto tile = l_tileMap.SetTile(t_x, t_y, 0, coordinate);
// Always layer 0\.
if (!tile) { ++t_y; continue; }
tile->m_solid = false;
++t_y;
}
t_y = 0;
++t_x;
}
return true;
}
显然,如果没有选择任何内容,我们无法复制任何东西。第一个检查处理了这个问题。然后,作为参数传递的TileMap被清除,以备覆盖。然后计算瓦片坐标范围,并将TileMap参数调整到选择的大小。在建立几个局部变量以帮助我们计算1D坐标索引之后,我们开始逐个迭代计算出的瓦片范围,并将它们添加到瓦片图中。因为我们处理瓦片纹理时没有深度,所以层始终设置为值0。
以下代码处理鼠标点击和鼠标释放事件,这在进行选择时至关重要:
void GUI_MapTileSelector::TileSelect(EventDetails* l_details) {
if (l_details->m_name == "MapEditor_TileSelectClick") {
m_startCoords = sf::Vector2u(l_details->m_mouse);
m_endCoords = sf::Vector2u(l_details->m_mouse);
m_selected = false;
} else {
if (l_details->m_mouse.x < 0 || l_details->m_mouse.y < 0) {
m_endCoords = sf::Vector2u(0, 0);
return;
}
m_endCoords = sf::Vector2u(l_details->m_mouse);
m_selected = true;
}
if (m_startCoords.x > m_endCoords.x) {
std::swap(m_startCoords.x, m_endCoords.x);
}
if (m_startCoords.y > m_endCoords.y) {
std::swap(m_startCoords.y, m_endCoords.y);
}
auto start = sf::Vector2i(m_startCoords.x / Sheet::Tile_Size,
m_startCoords.y / Sheet::Tile_Size);
start *= static_cast<int>(Sheet::Tile_Size);
auto end = sf::Vector2i(m_endCoords.x / Sheet::Tile_Size,
m_endCoords.y / Sheet::Tile_Size);
end *= static_cast<int>(Sheet::Tile_Size);
m_shape.setPosition(sf::Vector2f(start));
m_shape.setSize(sf::Vector2f(end - start) +
sf::Vector2f(Sheet::Tile_Size, Sheet::Tile_Size));
UpdateInterface();
}
如果我们正在处理鼠标左键点击,我们只需记录此时鼠标的坐标,并将m_selected标志重置为false。另一方面,如果左鼠标按钮已经被释放,首先检查最终鼠标位置是否在两个轴上都没有进入负值。然后存储最终坐标,并将m_selected标志设置为true。
剩余的代码块仅处理确保起始和结束坐标按升序存储,并计算选择矩形的位置和大小。然后调用UpdateInterface()方法,确保一切重新绘制。
让我们快速浏览一下这个类的辅助方法:
void GUI_MapTileSelector::Close(EventDetails* l_details){ Hide();}
void GUI_MapTileSelector::Show() {
m_interface->SetActive(true);
m_interface->Focus();
}
void GUI_MapTileSelector::Hide() {m_interface->SetActive(false);}
bool GUI_MapTileSelector::IsActive() const{
return m_interface->IsActive();
}
Show()和Hide()方法简单地操作界面的活动,而Close回调仅调用Hide。就这样,所有的部件都拼凑在一起,我们得到了一个完全功能性的地图编辑器!
摘要
为游戏构建工具可能不是世界上最容易或最愉快的工作,但最终,它总是值得的。处理文本文件、无尽的复制粘贴或其他糟糕的解决方案可能在短期内效果不错,但没有任何东西能比得上一个全副武装的工具集,只需点击一下按钮就能应对任何项目!虽然我们构建的编辑器针对的是一个非常具体的工作,但只要投入足够的时间和精力,这个想法可以应用于任何一组生产问题。
在下一章中,我们将介绍 SFML 中着色器的基本用法和一般用途。OpenGL 着色语言,加上 SFML 内置对着色器的支持,将使我们能够创建一个基本的昼夜循环。那里见!
第六章. 添加一些收尾工作 - 使用着色器
对于任何游戏来说,拥有好的艺术作品都是重要的,因为它极大地补充了游戏设计师带来的内容。然而,仅仅将任何图形逻辑和逻辑结合起来,称之为一天的工作,已经不再足够了。现在,游戏的良好视觉美学是由出色的艺术和适当的后期处理共同合作形成的。将图形处理成剪纸的感觉已经过时,而将它们融入游戏世界的动态宇宙中,并确保它们通过适当的着色来对周围环境做出反应,已经成为新的标准。让我们暂时放下游戏玩法,讨论一下这种特殊类型的后期处理技术,即着色。
在本章中,我们将要介绍:
-
SFML 着色器类的基础
-
实现统一绘制对象的方式
-
为游戏添加日夜循环
让我们开始为我们的项目添加额外的图形增强吧!
理解着色器
在现代计算机图形的世界中,许多不同的计算都转移到了 GPU 上。从简单的像素颜色计算到复杂的照明效果,都可以并且应该由专门为此目的设计的硬件来处理。这就是着色器的作用所在。
着色器是一个在您的显卡上而不是 CPU 上运行的程序,它控制着形状的每个像素的渲染方式。正如其名所示,着色器的主要目的是执行照明和着色计算,但它们可以用于更多的事情。由于现代 GPU 的强大功能,存在一些库,它们旨在在 GPU 上执行通常在 CPU 上执行的计算,以显著减少计算时间。从物理计算到破解密码散列,任何东西都可以在 GPU 上完成,而进入这种强大马力的入口就是着色器。
小贴士
GPU 擅长同时并行执行大量非常具体的计算。在 GPU 上使用不可预测或不并行的算法非常低效,这正是 CPU 擅长的。然而,只要数据可以并行处理,这项任务就被认为值得推送到 GPU 进行进一步处理。
SFML 提供了两种主要的着色器类型:顶点和片段。SFML 的新版本(2.4.0及以上)还增加了对几何着色器的支持,但根据我们的目的,不需要涵盖这一点。
顶点着色器对每个顶点执行一次。这个过程通常被称为顶点着色。例如,任何给定的三角形有三个顶点。这意味着着色器将为每个顶点执行一次,总共执行三次。
片段着色器对每个像素(也称为片段)执行一次,这导致这个过程被称为逐像素着色。这比简单地执行逐顶点计算要耗费更多资源,但更准确,通常会产生更好的视觉效果。
这两种类型的着色器可以同时用于单个绘制的几何体上,并且还可以相互通信。
着色器示例
OpenGL 着色语言(GLSL)与C或C++非常相似。它甚至使用相同的基语法,如这个顶点着色器示例所示:
#version 450
void main()
{
gl_Position = gl_ProjectionMatrix * gl_ModelViewMatrix
* gl_Vertex;
gl_FrontColor = gl_Color; // Send colour to fragment shader.
}
注意第一行上的版本号。数字450表示应使用的 OpenGL 版本,在本例中为4.5。较新版本的 SFML 支持 OpenGL 版本3.3+;然而,运行它的成功也取决于您显卡的能力。
目前,只需忽略main函数的第一行。它与从一个坐标系到另一个坐标系的位移动作有关,并且特定于一些可能的着色方法。这些概念将在下一章中介绍。
GLSL 提供了许多允许直接控制顶点和像素信息的钩子,例如gl_Position和gl_Color。前者只是将在后续计算中使用的顶点位置,而后者是顶点颜色,它被分配给gl_FrontColor,确保颜色被传递到片段着色器中。
说到片段着色器,这里有一个非常简单的示例,它可能看起来是这样的:
#version 450
void main()
{
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); // White pixel.
}
在这个特定的例子中,gl_FragColor用于设置正在渲染的像素的静态值。在使用此着色器渲染的任何形状都将呈现为白色。
注意
这个向量的值是归一化的,这意味着它们必须落在0.f < n <= 1.0f的范围内。
请记住,这里可以使用gl_Color来采样从顶点着色器传递下来的颜色。然而,由于顶点之间可能存在多个像素,每个片段的颜色都会进行插值。在一个三角形每个顶点被设置为红色、绿色和蓝色的情况下,插值结果将如下所示:

关于任何着色器需要注意的最后一点是,它们支持来自外部源通信。这是通过使用uniform关键字,后跟变量类型,并以其名称结尾来完成的:
#version 450
uniform float r;
uniform float g;
uniform float b;
void main()
{
gl_FragColor = vec4(r, g, b, 1.0);
}
在这个特定的示例中,外部代码传入三个float,这些值将被用作片段的颜色值。统一体(Uniforms)仅仅是全局变量,可以在使用着色器之前由外部代码进行操作。
SFML 和着色器
通过引入sf::Shader类,在 SFML 中存储和使用着色器变得简单。尽管大多数设备都支持着色器,但仍然是一个好主意,要检查正在执行代码的系统是否也支持着色器:
if(!sf::Shader::isAvailable()){ // Shaders not available! }
这个着色器类可以仅通过自身持有两种类型中的任何一种着色器,或者同时持有每种类型的一个实例。着色器可以通过两种方式之一加载。第一种是简单地读取一个文本文件:
sf::Shader shader; // Create a shader instance.
// Loading a single type of shader.
if (!shader.loadFromFile("shader.vert", sf::Shader::Vertex)) {
// Failed loading.
}
// OR
if (!shader.loadFromFile("shader.frag", sf::Shader::Fragment)) {
// Failed loading.
}
// load both shaders
if (!shader.loadFromFile("shader.vert", "shader.frag")) {
// Failed loading.
}
注意
这些着色器的文件扩展名不必与前面的匹配。因为我们正在处理文本文件,扩展名只是为了清晰。
加载着色器的第二种方式是通过解析内存中加载的字符串:
sf::Shader shader;
const std::string code = "...";
// String that contains all shader code.
if (!shader.loadFromMemory(code, sf::Shader::Vertex)) {
// Failed loading.
}
使用着色器也很简单。其地址只需在渲染到它时作为第二个参数传递给渲染目标的draw()调用:
window.draw(drawable, &shader);
由于我们的着色器可能需要通过uniform变量进行通信,因此必须有一种方法来设置它们。进入sf::Shader::setUniform(...):
shader.setUniform("r", 0.5f);
这段简单的代码操作了shader实例中加载的任何着色器中的r统一变量。该方法本身支持除了float之外许多其他类型,我们将在下一章中介绍。
本地化渲染
着色是一个强大的概念。目前向我们的游戏注入额外的图形华丽效果的唯一问题是它根本不是为高效使用着色器而设计的。我们大多数(如果不是所有)进行任何类型绘制的类都是通过直接访问sf::RenderWindow类来完成的,这意味着它们必须将它们自己的着色器实例作为参数传递。这既不高效,也不可重用,也不灵活。一个更好的方法,比如一个专门用于渲染的独立类,是必需的。
为了能够相对容易地在着色器之间切换,我们必须在类中正确地存储它们:
using ShaderList = std::unordered_map<std::string,
std::unique_ptr<sf::Shader>>;
由于sf::Shader类是一个不可拷贝的对象(继承自sf::NonCopyable),它被存储为唯一指针,从而避免了任何和所有的移动语义。这个着色器列表直接由将要执行所有渲染的类拥有,让我们看看它的定义:
class Renderer {
public:
Renderer(Window* l_window, bool l_useShaders = true);
void AdditiveBlend(bool l_flag);
bool UseShader(const std::string& l_name);
void DisableShader();
sf::Shader* GetShader(const std::string& l_name);
void BeginDrawing();
bool IsDrawing()const;
void Draw(const sf::Shape& l_shape,
sf::RenderTarget* l_target = nullptr);
void Draw(const sf::Sprite& l_sprite,
sf::RenderTarget* l_target = nullptr);
void Draw(const sf::Drawable& l_drawable,
sf::RenderTarget* l_target = nullptr);
void EndDrawing();
private:
void LoadShaders();
Window* m_window;
ShaderList m_shaders;
sf::Shader* m_currentShader;
bool m_addBlend;
bool m_drawing;
bool m_useShaders;
unsigned int m_drawCalls; // For debug purposes.
};
由于着色器需要作为参数传递给窗口的draw()调用,因此渲染器必须能够访问Window类。除了这些以及任何给定时间可以使用的着色器列表之外,我们还保留了对当前正在使用的着色器的指针,以减少容器访问时间,以及一些将在选择合适的着色器或确定是否正在绘制时使用的标志。最后,一个相当有用的调试特性是记录每次更新期间发生的绘制调用次数。为此,将使用一个简单的unsigned integer。
该类本身提供了启用/禁用加法混合而不是常规着色器的基本功能,在所有可用的着色器之间切换,禁用当前着色器以及获取它。BeginDrawing()和EndDrawing()方法将由Window类使用,以为我们提供有关渲染过程的信息的钩子。注意重载的Draw()方法。它被设计为接受任何可绘制类型,并将其绘制在当前窗口或作为第二个参数提供的适当渲染目标上。
最后,在类的初始化阶段将使用LoadShaders()私有方法。它包含加载每个着色器所需的所有逻辑,并将它们存储以供以后使用。
实现渲染器
让我们先快速回顾一下Renderer对象的构建以及所有数据成员的初始化:
Renderer::Renderer(Window* l_window, bool l_useShaders)
: m_window(l_window), m_useShaders(l_useShaders),
m_drawing(false), m_addBlend(false), m_drawCalls(0),
m_currentShader(nullptr) {}
一旦安全地存储了Window*实例的指针,这个类的所有数据成员都将初始化为其默认值。构造函数的主体仅由一个私有方法调用组成,负责实际加载和存储所有着色器文件:
void Renderer::LoadShaders() {
if(!m_useShaders) { return; }
auto directory = Utils::GetWorkingDirectory() +"media/Shaders/";
auto v_shaders = Utils::GetFileList(directory, "*.vert", false);
auto f_shaders = Utils::GetFileList(directory, "*.frag", false);
for (auto& shader : v_shaders) {
auto& file = shader.first;
auto name = file.substr(0, file.find(".vert"));
auto fragShader = std::find_if(
f_shaders.begin(), f_shaders.end(),
&name {
return l_pair.first == name + ".frag";
}
);
auto shaderItr = m_shaders.emplace(name,
std::move(std::make_unique<sf::Shader>()));
auto& shader = shaderItr.first->second;
if (fragShader != f_shaders.end()) {
shader->loadFromFile(directory + name + ".vert",
directory + name + ".frag");
f_shaders.erase(fragShader);
} else {
shader->loadFromFile(directory + name + ".vert",
sf::Shader::Vertex);
}
}
for (auto& shader : f_shaders) {
auto& file = shader.first;
auto name = file.substr(0, file.find(".frag"));
auto shaderItr = m_shaders.emplace(name,
std::move(std::make_unique<sf::Shader>()));
auto& shader = shaderItr.first->second;
shader->loadFromFile(directory + name + ".frag",
sf::Shader::Fragment);
}
}
我们首先创建一个局部变量,用于保存我们的shader目录的路径。然后使用它来获取两个具有.vert和.frag扩展名的文件列表。这些将是将要加载的顶点和片段着色器。这里的目的是将具有相同名称的顶点和片段着色器分组,并将它们分配给sf::Shader的单个实例。任何没有顶点或片段对应物的着色器将单独加载到单独的实例中。
顶点着色器是一个很好的开始地方。在获取文件名并去除扩展名后,尝试找到具有相同名称的片段着色器。同时,一个新的sf::Shader实例被插入到着色器容器中,并获取对其的引用。如果找到了片段对应物,这两个文件都会被加载到着色器中。然后,片段着色器名称将从列表中移除,因为它将不再需要单独加载。
作为代码的第一部分负责所有的配对,此时真正需要做的只剩下加载片段着色器。可以安全地假设片段着色器列表中的任何内容都是一个独立的片段着色器,而不是与顶点着色器相关联。
由于着色器可能有需要初始化的统一变量,因此外部类需要能够访问它们所使用的着色器:
sf::Shader* Renderer::GetShader(const std::string& l_name) {
if(!m_useShaders) { return nullptr; }
auto shader = m_shaders.find(l_name);
if (shader == m_shaders.end()) { return nullptr; }
return shader->second.get();
}
如果没有找到提供的名称的着色器,则返回nullptr。另一方面,从智能指针中获取sf::Shader*实例的原始指针并返回。
同样的外部类需要能够在特定着色器应该被使用时指导Renderer。为此,UseShader()方法派上了用场:
bool Renderer::UseShader(const std::string& l_name) {
if(!m_useShaders) { return false; }
m_currentShader = GetShader(l_name);
return (m_currentShader != nullptr);
}
由于GetShader()方法已经为我们做了错误检查,因此在这里也使用了它。从它返回的值被存储为当前着色器的指针(如果有的话),然后对其进行评估以返回一个布尔值,表示成功/失败。
实际绘制几何图形是我们这里的主要内容,让我们来看看重载的Draw()方法:
void Renderer::Draw(const sf::Shape& l_shape,
sf::RenderTarget* l_target)
{
if (!l_target) {
if (!m_window->GetViewSpace().intersects(
l_shape.getGlobalBounds()))
{ return; }
}
Draw((const sf::Drawable&)l_shape, l_target);
}
void Renderer::Draw(const sf::Sprite& l_sprite,
sf::RenderTarget* l_target)
{
if (!l_target) {
if (!m_window->GetViewSpace().intersects(
l_sprite.getGlobalBounds()))
{ return; }
}
Draw((const sf::Drawable&)l_sprite, l_target);
}
不论是渲染sf::Sprite还是sf::Shape,背后的实际想法是完全相同的。首先,我们检查方法调用背后的意图是否确实是为了渲染到主窗口,通过查看l_target参数。如果是这样,这里一个合理的事情是确保可绘制对象实际上是在屏幕上的。如果不在,绘制它就没有意义。如果测试通过,主Draw()方法重载将被调用,并将当前参数传递下去:
void Renderer::Draw(const sf::Drawable& l_drawable,
sf::RenderTarget* l_target)
{
if (!l_target) { l_target = m_window->GetRenderWindow(); }
l_target->draw(l_drawable,
(m_addBlend ? sf::BlendAdd : m_currentShader && m_useShaders ?
m_currentShader : sf::RenderStates::Default));
++m_drawCalls;
}
这里发生所有的实际魔法。再次检查l_target参数是否等于nullptr。如果是,渲染窗口将被存储在参数指针中。无论目标是什么,此时它的Draw()方法将被调用,将可绘制对象作为第一个参数传递,以及适当的着色器或混合模式作为第二个参数传递。显然,加法混合在这里具有优先权,通过简单地使用AdditiveBlend()方法,可以更快地在使用着色器和加法混合模式之间切换。
绘制完成后,m_drawCalls数据成员将增加,这样我们就可以在每次循环结束时跟踪总共渲染了多少个可绘制对象。
最后,我们可以通过查看一些基本但重要的 setter/getter 代码来结束这个类的封装:
void Renderer::AdditiveBlend(bool l_flag) { m_addBlend = l_flag; }
void Renderer::DisableShader() { m_currentShader = nullptr; }
void Renderer::BeginDrawing(){ m_drawing = true; m_drawCalls = 0;}
bool Renderer::IsDrawing() const { return m_drawing; }
void Renderer::EndDrawing() { m_drawing = false; }
如您所见,禁用当前绘制内容的着色器使用只需将m_currentShader数据成员设置为nullptr。还要注意BeginDrawing()方法。它方便地重置了m_drawCalls计数器,这使得管理起来更容易。
集成 Renderer 类
如果Renderer类不在适当的位置或根本不被使用,那么甚至没有拥有它的意义。由于它的唯一任务是使用正确的效果在屏幕上绘制东西,因此它合适的地点应该是Window类内部:
class Window{
public:
...
Renderer* GetRenderer();
...
private:
...
Renderer m_renderer;
};
由于外部类也依赖于它,因此提供一个用于轻松检索此对象的 getter 方法是个好主意。
实际将其集成到其余代码中出奇地简单。一个不错的开始是让Renderer类访问Window类,如下所示:
Window::Window(...) : m_renderer(this, l_useShaders) { ... }
渲染器也有用于知道何时开始和结束绘图过程的钩子。幸运的是,Window类已经支持这个想法,所以实际上很容易利用它:
void Window::BeginDraw() {
m_window.clear(sf::Color::Black);
m_renderer.BeginDrawing();
}
void Window::EndDraw() {
m_window.display();
m_renderer.EndDrawing();
}
最后,为了使用最新的 OpenGL 版本,需要指示窗口创建最新可用的上下文版本:
void Window::Create() {
...
sf::ContextSettings settings;
settings.depthBits = 24;
settings.stencilBits = 8;
settings.antialiasingLevel = 0;
settings.majorVersion = 4;
settings.minorVersion = 5;
m_window.create(sf::VideoMode(m_windowSize.x, m_windowSize.y,
32), m_windowTitle, style, settings);
if (!m_shadersLoaded) {
m_renderer.LoadShaders();
m_shadersLoaded = true;
}
}
注意这段代码末尾的着色器加载部分。Renderer类被指示加载指定目录中可用的着色器,前提是首先使用着色器。这些几个简单的添加完成了Renderer类的集成。
适配现有类
到目前为止,在屏幕上渲染某物就像将其作为可绘制对象传递给Window类的Draw()方法一样简单。虽然这对小型项目来说很好,但这对我们来说是个问题,因为它严重限制了着色器的使用。一个很好的升级方法是简单地接受Window指针:
class ParticleSystem : ... {
public:
...
void Draw(Window* l_window, int l_elevation);
};
class S_Renderer : ... {
public:
...
void Render(Window* l_wind, unsigned int l_layer);
};
class SpriteSheet{
public:
...
void Draw(Window* l_wnd);
};
让我们逐一查看这些类,看看为了添加对着色器的适当支持需要做哪些更改。
更新粒子系统
回到第三章,让天下雨! - 构建粒子系统,我们已经在不知情的情况下使用了一定数量的着色技巧!用于火焰效果的添加混合是一个很好的特性,为了保留它而无需为它编写单独的着色器,我们可以简单地使用Renderer类的AdditiveBlend()方法:
void ParticleSystem::Draw(Window* l_window, int l_elevation) {
...
auto state = m_stateManager->GetCurrentStateType();
if (state == StateType::Game || state == StateType::MapEditor) {
renderer->UseShader("default");
} else {
renderer->DisableShader();
}
for (size_t i = 0; i < container->m_countAlive; ++i) {
...
renderer->AdditiveBlend(blendModes[i]);
renderer->Draw(drawables[i]);
}
renderer->AdditiveBlend(false);
}
首先,注意检查当前应用程序的状态。目前,我们实际上并不需要在Game或MapEditor以外的任何状态中使用着色器。只要我们处于其中之一,就使用默认的着色器。否则,着色器将被禁用。
当处理实际粒子时,会调用AdditiveBlend()方法,并将混合模式标志作为其参数传递,要么启用要么禁用它。然后粒子可绘制对象将在屏幕上绘制。处理完所有粒子后,将关闭添加混合。
更新实体和地图渲染
默认着色器不仅在渲染粒子时使用。实际上,我们希望至少在一定程度上能够将统一的着色应用到所有世界对象上。让我们从实体开始:
void S_Renderer::Render(Window* l_wind, unsigned int l_layer)
{
EntityManager* entities = m_systemManager->GetEntityManager();
l_wind->GetRenderer()->UseShader("default");
for(auto &entity : m_entities) {
...
drawable->Draw(l_wind);
}
}
void SpriteSheet::Draw(Window* l_wnd) {
l_wnd->GetRenderer()->Draw(m_sprite);
}
对渲染系统的唯一真正改变是调用UseShader()方法,以及将Window类的指针作为参数传递给精灵图集的Draw()调用,而不是通常的sf::RenderWindow。反过来,SpriteSheet类也被修改为使用Renderer类,尽管它实际上并不与或修改着色器进行交互或修改。
游戏地图也应该以完全相同的方式进行着色:
void Map::Draw(unsigned int l_layer) {
if (l_layer >= Sheet::Num_Layers) { return; }
...
m_window->GetRenderer()->UseShader("default");
m_window->GetRenderer()->Draw(m_layerSprite);
}
这里唯一的真正区别是Map类已经内部访问了Window类,因此不需要将其作为参数传递。
创建昼夜循环
在我们的游戏中统一多个不同世界对象的着色,为我们提供了一个非常优雅的方式来操纵场景的实际表示方式。现在可以实现许多有趣的效果,但我们将专注于一个相对简单而有效的一种光照。关于光照主题的实际微妙之处将在后面的章节中介绍,但我们现在可以构建一个系统,使我们能够根据当前的时间来以不同的方式着色世界,如下所示:

如你所见,这种效果可以为游戏增添很多,使其感觉非常动态。让我们看看它是如何实现的。
更新Map类
为了准确表示昼夜循环,游戏必须保持一个时钟。因为它与世界相关,所以跟踪这些信息的最佳位置是Map类:
class Map : ... {
...
protected:
...
float m_gameTime;
float m_dayLength;
};
为了拥有动态和可定制的代码,存储了两个额外的数据成员:当前游戏时间和一天的总长度。后者允许用户创建具有可变一天长度的地图,这可能为游戏设计师提供一些有趣的机会。
使用这些值相当简单:
void Map::Update(float l_dT) {
m_gameTime += l_dT;
if (m_gameTime > m_dayLength * 2) { m_gameTime = 0.f; }
float timeNormal = m_gameTime / m_dayLength;
if(timeNormal > 1.f){ timeNormal = 2.f - timeNormal; }
m_window->GetRenderer()->GetShader("default")->
setUniform("timeNormal", timeNormal);
}
实际游戏时间首先通过添加帧时间来操作。然后检查它是否超过了天数长度的两倍,在这种情况下,游戏时间被设置为0.f。这种关系表示一天长度和夜晚长度之间的 1:1 比例。
最后,为了确保光线在白天和夜晚之间正确地渐变,我们建立了一个名为timeNormal的局部变量,并使用它来计算应该投射到场景中的黑暗量。然后检查它是否超过了1.f的值,如果是,则将其调整以开始向下移动,表示从黑暗到黎明的渐变。然后将该值传递给默认的着色器。
注意
重要的是要记住,着色器大多数时候都使用归一化值。这就是我们努力提供介于0.f到1.f之间的值的原因。
最后一部分实际上是初始化我们的两个额外数据成员到它们的默认值:
Map::Map(...) : ..., m_gameTime(0.f), m_dayLength(30.f)
{ ... }
如您所见,我们给白天长度赋值为 30.f,这意味着全天/夜间周期将持续一分钟。这显然对游戏来说不会很有用,但在测试着色器时可能会派上用场。
编写着色器
将所有 C++ 代码移除后,我们终于可以专注于 GLSL。让我们首先实现默认的顶点着色器:
#version 450
void main()
{
// transform the vertex position
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
// transform the texture coordinates
gl_TexCoord[0] = gl_TextureMatrix[0] * gl_MultiTexCoord0;
// forward the vertex color
gl_FrontColor = gl_Color;
}
这与本章介绍阶段所使用的例子并无不同。现在添加顶点着色器的目的只是为了避免将来在需要对其进行操作时再次编写它。话虽如此,让我们继续转向片段着色器:
#version 450
uniform sampler2D texture;
uniform float timeNormal;
void main()
{
// lookup the pixel in the texture
vec4 pixel = texture2D(texture, gl_TexCoord[0].xy);
if(pixel == vec4(0.0, 0.0, 0.0, 1.0))
pixel = vec4(1.0, 1.0, 1.0, 1.0);
// multiply it by the color
gl_FragColor = gl_Color * pixel;
gl_FragColor[0] -= timeNormal;
gl_FragColor[1] -= timeNormal;
gl_FragColor[2] -= timeNormal;
gl_FragColor[2] += 0.2;
}
注意
在这个例子中,sampler2D 类型仅仅是 SFML 传递给着色器的纹理。其他纹理也可以通过使用 shader.setUniform("texture", &texture); 调用手动传递给着色器。
为了正确绘制一个像素,片段着色器需要采样当前绘制对象的纹理。如果正在绘制一个简单的形状,则检查从纹理中采样的像素是否完全为黑色。如果是这样,它就简单地被设置为白色像素。除此之外,我们还需要之前讨论过的 timeNormal 值。在采样当前纹理的当前像素之后,它被乘以从顶点着色器传入的颜色,并存储为 gl_FragColor。然后从所有三个颜色通道中减去 timeNormal 值。最后,在像素的末尾添加一点蓝色调。这给我们的场景添加了蓝色调,纯粹是一个美学选择。
摘要
许多人认为图形应该是一个游戏开发者次要的关注点。虽然很明显,项目的视觉方面不应该成为其主要关注点,但视觉效果可以比仅仅作为漂亮的背景更有助于玩家。图形增强甚至可以通过让玩家更加沉浸在环境中、使用巧妙的视觉提示或简单地控制整体氛围和气氛来更好地讲述故事。在本章中,我们迈出了建立系统的第一步,该系统将成为征服特效世界时的巨大助手。
在下一章中,我们将深入探讨图形增强的底层。那里见!
第七章. 一步一步前进,一层层深入 - OpenGL 基础
很多人往往容易将像 SFML 这样的库视为理所当然。毕竟,它提供的想法和概念看起来相当直观。构建一些相对简单的东西可能只需要几分钟,而且没有太多头疼的问题需要处理。在一个完美的世界里,我们可以将那些麻烦推给其他人,并简单地依赖越来越高的抽象层次来完成工作。然而,当某些限制让我们一头撞进砖墙时会发生什么呢?为了知道如何绕过它们,有必要了解 SFML 建立在其基础上的基本原理。换句话说,在那个时刻,向下是唯一的出路。
在本章中,我们将介绍:
-
使用 SFML 窗口设置和使用 OpenGL
-
形成并向 GPU 提交数据
-
创建、构建和使用着色器进行渲染
-
将纹理应用到几何体上
-
查看各种坐标系和模型变换
-
实现摄像机
这是一份相当长的待办事项清单,所以我们不要浪费时间,直接开始吧!
版权资源的使用
像往常一样,让我们承认那些应该得到认可的人,并给予应有的赞誉。以下是本章使用的资源:
-
由
texturelib.com在 CC0 许可下提供的旧墙纹理:许可:http://texturelib.com/texture/?path=/Textures/brick/medieval/brick_medieval_0121 -
由 Sean Barrett 在 CC0 许可下提供的 STB 公共领域图像加载器:
github.com/nothings/stb/blob/master/stb_image.h
设置 OpenGL
-
为了访问最新的 OpenGL 版本,我们需要下载两个库。一个是名为 OpenGL Extension Wrangler Library 的库。它加载并使目标平台上支持的所有 OpenGL 扩展可用。该库可以从以下位置下载
glew.sourceforge.net/。 -
我们还需要另一个名为 OpenGL Mathematics 或 GLM 的库。这是一个仅包含头文件的库,它添加了许多额外的数据类型和函数,这些在大多数情况下都很有用。从简单的向量数据类型到用于计算叉积的函数,这个库都添加了进来。它可以在以下位置找到
glm.g-truc.net/0.9.8/index.html。
设置 Visual Studio 项目
除了我们仍然需要用于创建窗口的常用 SFML 包含文件外,我们还需要在 VC++ Directories 下的 Include Directories 字段中添加 GLEW 和 GLM 的 include 文件夹。
在链接器下的通用部分,必须将 GLEW 的附加库目录也添加进去。库文件位于Release文件夹中,该文件夹包含几个目录:Win32和x64。这些需要为不同的构建配置正确设置。
最后,必须在链接器下的输入部分的附加依赖字段中添加glew32.lib文件,以及OpenGL32.lib文件。它可以静态链接,在这种情况下,需要添加glew32s.lib而不是常规的glew32.lib。如果静态链接,还需要在C/C++下的预处理器部分添加GLEW_STATIC的预处理器定义。
使用 GLEW
如果我们要使用 OpenGL,我们首先需要的是一个窗口。幸运的是,窗口创建不是 OpenGL 特有的,因此可以使用几乎任何支持它的库来创建一个窗口,包括 SFML。为了我们的目的,我们将重用 Window 类并对它进行一些小的调整,包括实际的 SFML 窗口类型:
class GL_Window {
...
private:
...
sf::Window m_window;
...
};
注意m_window数据成员的数据类型。如果实际没有使用 SFML 来绘制任何内容,我们不需要sf::RenderWindow的实例,而是可以使用sf::Window。这意味着任何与实际窗口无关的任务都必须单独处理。这甚至包括清除窗口:
void GL_Window::BeginDraw() {
glClearColor(0.f, 0.f, 0.f, 1.f); // BLACK
glClear(GL_COLOR_BUFFER_BIT);
}
在这里,我们可以看到我们将要使用的第一个两个 GL 函数。因为 GLEW 是一个 C API,所以看起来像这样的代码将会很常见。没有类需要管理,因为每个任务都是通过函数调用和共享状态来执行的。以我们的第一个函数glClearColor()为例,它实际上设置了屏幕清除时使用的颜色,包括 alpha 通道。
注意
这个特定的函数,以及许多其他函数,接受一个所谓的归一化向量。当表示比例时很有用。例如,将屏幕清除到紫色意味着传递值0.5f作为第一个和第三个参数,这意味着颜色的一半是红色,另一半是蓝色。
第二个函数调用实际上使用存储的值执行清除。它接受一个参数,本质上只是一个位掩码,使用#define预处理器指令定义。这个特定的实现细节允许通过使用位或操作(用管道符号|表示)将更多的掩码传递给函数调用。我们最终会回顾这个概念。
在处理完这些之后,让我们实际创建窗口并初始化GLEW库:
Game::Game() : m_window("Chapter 7", sf::Vector2u(800, 600))
{
...
std::cout << glGetString(GL_VERSION) << std::endl;
GLenum status = glewInit();
if (status != GLEW_OK) {
std::cout << "GLEW failed!" << std::endl;
}
...
}
为了初始化 GLEW,我们只需要调用一个函数glewInit()。它返回一个值,表示操作的成功或失败。另一个有用的函数是glGetString()。它返回一个静态字符串,表示在执行该字符串的计算机上支持的 OpenGL 版本的具体信息。在这种情况下,我们特别想检查 OpenGL 的版本并将其打印出来,但它也可以用来确定 OpenGL 扩展、支持的 GLSL 版本、硬件渲染平台的名称等等。
渲染管线
在屏幕上绘制东西时,必须遵循一定的步骤来提交几何形状,将其转换为像素,并适当地着色。这个特定的步骤序列通常被称为渲染管线。其工作方式完全取决于你使用的 OpenGL 版本。低于3.0的版本使用所谓的固定功能管线,而3.0+的新 OpenGL 版本使用可编程管线。前者现在已弃用,被称为传统OpenGL,而后者被广泛使用和应用,甚至在移动设备上,已成为标准。
固定功能管线
实际上,使用固定功能管线在屏幕上绘制东西比现代方法要简单得多,但这也需要付出代价。考虑以下示例:
glBegin(GL_TRIANGLES);
glColor3f(1.0f, 0.0f, 0.0f); // Red
glVertex3f(-0.5f, -0.5f, 0.5f);
glColor3f(0.0f, 1.0f, 0.0f); // Green
glVertex3f(-0.5f, 0.5f, 0.5f);
glColor3f(0.0f, 0.0f, 1.0f); // Blue
glVertex3f(0.5f, 0.5f, 0.5f);
glEnd();
这段特定的代码非常易于阅读,这是传统方法的一个优点。我们首先调用glBegin()方法并传入一个值,这个值表示实际顶点在提交时应如何被解释。我们正在处理三角形,这意味着每行提交的前三个顶点将被连接并形成一个三角形。注意glColor3f的调用。顶点的颜色在提交时设置,同样也可以对纹理坐标进行相同的操作。最后调用glEnd()方法将所有提交的数据刷新到 GPU 进行渲染。
虽然这对新手来说非常易于阅读和理解,但顶点数据必须每帧重新提交到 GPU,这会严重影响性能。小型应用程序可能不会注意到差异,但提交大量原语后的内存传输开销确实开始累积。
这种方法的另一个问题是它的局限性。某些效果,如果可能的话,使用固定功能管线实现可能会非常慢。
可编程管线
对于小任务来说,使用可编程管线要复杂得多,但对于大型项目来说却非常有价值。就像固定功能管线一样,有一些步骤是静态的且不会改变。然而,可编程管线确实提供了一种方法来定制提交给 GPU 的数据的处理方式。这就是着色器的用武之地。着色器已经在上一章中简要介绍过;然而,它们还有很多尚未解释的内容。它们是可以用类似 C 的语言编写的程序,可以在 GPU 上而不是 CPU 上执行。实际上,着色器用于定制可编程管线的一部分。考虑以下图示:

就像固定功能管线一样,顶点数据被提交到 GPU。然而,这些数据并不是每帧都重新提交。相反,顶点数据存在于 GPU 上,当需要渲染时可以引用。一旦调用绘制特定顶点集,它们就会被传递进来进行处理,并传递给顶点着色器。
顶点着色器是管线中少数可编程部分之一。它通常用于计算顶点在适当的坐标系中的位置,并将这些顶点传递到管线中以便进一步处理。
镶嵌阶段本质上负责将我们现有的几何形状细分为更小的基本形状。它实际上连接了顶点并将这些基本形状进一步传递到管线中。在这个阶段有两个着色器可以编写和使用;然而,我们不会这么做。
所有基本形状数据随后被传递到几何着色器,就像两个镶嵌着色器一样,它是可选的。它可以用来从现有几何形状生成更多顶点。
在基本形状被正确组装后,它们会被进一步传递并由光栅器处理。
光栅化是将顶点和基本形状信息转换为像素数据的过程。然后这些像素被进一步传递到管线中。
这个管线的最后一个可编程部分接收来自前一阶段的全部像素信息。它被称为片段着色器(即像素着色器),可以用来确定我们正在渲染的几何形状中每个单独像素的值。从分配特定颜色到实际采样纹理的像素,这一阶段都会完成。然后这些像素被进一步推送到其他阶段进行处理。
深度与模板阶段执行各种测试,以剪裁不应在屏幕上绘制的无需像素。如果一个像素在窗口区域之外,甚至在其他几何形状之后,它在这个阶段就会被丢弃。
未裁剪的像素随后被混合到现有的帧缓冲区中,用于在屏幕上绘制一切。然而,在混合之前,抖动过程发生,确保如果渲染图像的精度低于或高于我们拥有的值,像素被正确地四舍五入。
虽然一开始可能难以理解这个概念,但可编程管线是现代渲染的优越方法。在所有这些阶段中,我们实际上只需要编写顶点和片段着色器就可以开始。我们很快就会介绍这一点。
存储和绘制原语
我们的所有原语数据都必须表示为一组顶点。无论我们是在处理屏幕上的三角形或精灵,还是处理一个巨大的、复杂的怪物模型,它们都可以分解为这种基本类型。让我们看看一个表示它的类:
enum class VertexAttribute{ Position, COUNT };
struct GL_Vertex {
GL_Vertex(const glm::vec3& l_pos): m_pos(l_pos) {}
glm::vec3 m_pos; // Attribute 1\.
// ...
};
如你所见,它只是一个简单的struct,它包含一个表示位置的 3D 向量。稍后,我们可能想要存储有关顶点的其他信息,例如纹理坐标、其颜色等等。关于特定顶点的这些不同信息通常被称为属性。为了方便起见,我们还枚举了不同的属性,以使我们的代码更加清晰。
顶点存储
在任何原语可以绘制之前,其数据必须在 GPU 上存储。在 OpenGL 中,这项任务是通过利用顶点数组对象(VAO)和顶点缓冲对象(VBO)来完成的。
顶点缓冲对象可以简单地理解为在 GPU 上分配的空间,用于存储数据。这些数据可以是任何东西。它可以是顶点位置、颜色、纹理坐标等等。我们将使用 VBO 来存储所有我们的原语信息。
顶点数组对象就像 VBO 的父对象,甚至可以是一个或多个 VBO。它存储有关 VBO 内部数据如何访问的信息,如何将信息传递到各种着色器阶段,以及许多其他细节,这些细节共同形成状态。如果 VBO 是实际的数据池,VAO 可以被视为访问该数据的指令集。
VAO 和 VBO 实例都通过简单的整数来标识,这些整数在空间分配后返回。这些整数将被用来区分不同的缓冲区和数组对象。
模型类
在处理完这些信息后,我们终于可以着手实现模型类了!在我们的情况下,模型是任何可以形成形状的三角形集合。有了足够的三角形,任何形状都可以建模。让我们看看类的头文件:
class GL_Model {
public:
GL_Model(GL_Vertex* l_vertices, unsigned int l_vertCount);
~GL_Model();
void Draw();
private:
GLuint m_VAO;
GLuint m_vertexVBO;
unsigned int m_drawCount;
};
如你所见,它相当简单。构造函数目前接受两个参数:一个指向顶点第一个实例的指针,以及我们实际提交的顶点数量。这使得我们可以快速从简单的顶点数组中加载模型,尽管这可能不是加载更复杂网格的最佳方式。
注意,该类还有一个Draw()方法,稍后将会使用它将顶点提交到渲染管线并开始绘制过程。
最后,我们有两种GL 无符号整数类型:m_VAO和m_vertexVBO。这些整数将引用与该模型一起使用的实际顶点数组对象以及用于存储所有顶点信息的顶点缓冲对象。我们还有一个无符号整数,m_drawCount,它将存储特定模型中顶点的数量,以便绘制它们。
实现模型类
在处理完这些之后,让我们开始分配和填充我们的数据结构!GL_Model类的构造函数将帮助我们完成这项任务:
GL_Model::GL_Model(GL_Vertex* l_vertices,
unsigned int l_vertCount)
{
m_drawCount = l_vertCount;
glGenVertexArrays(1, &m_VAO);
glBindVertexArray(m_VAO);
glGenBuffers(1, &m_vertexVBO);
glBindBuffer(GL_ARRAY_BUFFER, m_vertexVBO);
glBufferData(GL_ARRAY_BUFFER,
l_vertCount * sizeof(l_vertices[0]),
l_vertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(
static_cast<GLuint>(VertexAttribute::Position));
glVertexAttribPointer(
static_cast<GLuint>(VertexAttribute::Position), 3, GL_FLOAT,
GL_FALSE, 0, 0);
glBindVertexArray(0);
}
我们首先将顶点数量复制到m_drawCount数据成员中。这将在稍后变得很有用,因为我们需要确切知道在渲染之前需要绘制多少个顶点。然后使用glGenVertexArrays函数为 VAO 分配一些空间。它的第一个参数是需要创建的对象数量,而第二个参数是一个指向将要存储返回标识符的变量的指针。
下一个函数调用,glBindVertexArray(),实际上是通过提供的标识符启用一个顶点数组对象,这样任何在此之后的函数调用都会修改作为参数传入的顶点数组对象。从这一点开始,任何对顶点数组对象的操作都将在这个具有标识符m_VAO的 VAO 上执行。
注意
由于 GLEW 是一个 C API,绑定和解绑的概念在它的大多数方面都占主导地位。为了修改或与 GPU 上存在的数据进行任何操作,必须首先绑定适当的缓冲区。
就像 VAO 一样,顶点缓冲对象也需要生成。glGenBuffers函数正是用来做这个的。在这种情况下,我们只需要一个缓冲对象,这就是第一个参数所表示的。一旦生成,就像 VAO 一样,我们需要绑定到这个缓冲区以便修改它。这就是glBindBuffer函数发挥作用的地方。因为它被绑定,我们还需要指定我们将要将其视为哪种类型的缓冲区。因为我们只想有一个数据数组,所以使用GL_ARRAY_BUFFER。
现在我们已经创建了一个缓冲区,我们可以向其中推送一些数据了!调用glBufferData函数正是如此。第一个参数,就像之前的函数一样,决定了我们正在处理哪种类型的缓冲区。第二个参数是我们想要提交的数据块的字节大小,OpenGL 需要知道这一点,以便为缓冲区分配足够的空间来存储所有数据。在这种情况下,它只是顶点数量的乘积乘以第一个元素占用的字节数。第三个参数是指向我们想要提交的实际数据结构的指针。读取多少数据由第二个参数确定,在这种情况下,是全部。最后,最后一个参数用作 OpenGL 管理数据存储的提示,以便尽可能高效地根据其使用情况。它根据我们如何使用它来存储数据的方式不同。GL_STATIC_DRAW表示我们不会修改数据,因此它可以以最有效的方式存储数据。
在所有数据缓冲后,我们可以再次开始使用 VAO,并给它提供有关如何访问顶点信息的信息。因为顶点位置必须传递给片段着色器,所以我们需要将其作为属性启用,并在 VAO 中存储有关其处理方式的信息。这就是glEnableVertexAttribArray()和glVertexAttribPointer()函数发挥作用的地方。
前者函数简单地启用某个属性以便由顶点着色器使用。VertexAttribute::Position计算结果为0,所以顶点着色器中的0th属性被启用以供使用。然而,后者实际上指定了在数据被传递到顶点着色器之前如何读取和处理这些数据。在这种情况下,0th属性被定义为三个变量的集合,它们都是浮点数。下一个参数在我们在将数据发送到顶点着色器之前想要归一化数据时可能很有用。在这种情况下,我们不需要这样做,所以传入GL_FALSE。最后两个参数是我们对缓冲区中感兴趣的数据的字节步长和字节偏移量。因为我们到目前为止只存储了GL_Vertex结构中的顶点位置,所以这两个值都是0。然而,如果我们有更多的属性会发生什么?考虑以下图表:

假设我们有一个缓冲区内的所有数据,这在前面的示例中已经展示过。对于其中的每个顶点,其位置后面跟着其颜色,然后是另一个顶点的位置。如果我们只想过滤出位置数据,例如,步长和偏移量可以非常有用。步长参数是从一个数据段的开头跳到另一个数据段的开头所需的字节数。实际上,步长可以被视为整个顶点数据结构的大小,在这个例子中,是位置向量和颜色向量大小的总和。简单来说,它是从一个顶点的开头到另一个顶点开头的字节数。
相反,偏移量只是我们需要从正在读取的结构的开头移动的字节数,以便到达所需的元素。访问颜色元素意味着偏移量必须是位置向量的大小。简单来说,偏移量是从结构开头到所需元素开头的字节数。
在我们的数据提交并计入之后,我们可以再次使用glBindVertexArray来绑定到0,这表明我们已经完成了 VAO 的操作。
所有这些分配的数据实际上在不再需要时都必须被释放。析构函数可以在这里帮助我们:
GL_Model::~GL_Model() {
glDeleteBuffers(1, &m_vertexVBO);
glDeleteVertexArrays(1, &m_VAO);
}
首先,需要释放顶点缓冲对象。我们向glDeleteBuffers函数传递 VBO 的数量以及第一个标识符的指针,该函数会清除 GPU 上的所有缓冲数据。VAO 随后遵循类似的程序。
最后,我们可以实现我们的Model类的Draw方法:
void GL_Model::Draw() {
glBindVertexArray(m_VAO);
glDrawArrays(GL_TRIANGLES, 0, m_drawCount);
glBindVertexArray(0);
}
在绘制任何东西之前,我们需要指定管线应该使用哪些数据。所有的顶点信息都安全地存储在我们的缓冲对象中,该对象由 VAO 管理,所以我们将其绑定。然后调用glDrawArrays函数。正如其名称所示,它绘制顶点数组。它的第一个参数是我们想要绘制的原语类型,在这个例子中是三角形。线条、点和其他类型也可以这样绘制。第二个参数是缓冲数组对象内的起始索引。由于我们想要从开始绘制一切,所以这个值被设置为0。最后,传递要绘制的顶点数量。对这个函数的调用实际上启动了渲染管线,将所有顶点数据发送到顶点着色器。最后的调用是glBindVertexArray()函数,它只是简单地解绑我们的 VAO。
使用着色器
可编程管道的标准化现在意味着必须为某些任务编写着色器,包括基本任务。这意味着仅仅提交我们的顶点数据并渲染它将不会产生任何效果,因为渲染管线中的两个基本部分,即顶点着色器和片段着色器,是不存在的。在本节中,我们将介绍着色器是如何被加载、构建并应用到我们的虚拟几何形状上的,从而在屏幕上产生那些辉煌的像素。
加载着色器文件
在我们能够使用着色器之前,我们首先需要讨论它们是如何被加载的。技术上,我们只需要一个字符串,其中包含着色器所有的代码。可以编写一个非常简单的辅助函数来解析文件,并将其作为字符串返回,如下所示:
inline std::string ReadFile(const std::string& l_filename) {
std::ifstream file(l_filename);
if (!file.is_open()) { return ""; }
std::string output;
std::string line;
while (std::getline(file, line)) {
output.append(line + "\n");
}
file.close();
return output;
}
当涉及到文件读取和解析时,这并不是我们第一次看到。创建一个字符串,然后逐行读取文件内容并将其追加到字符串中,最后返回。
创建着色器程序
OpenGL 着色器本身是渲染管线中使用的程序的一部分。如果我们有一个想要使用的顶点着色器和片段着色器,这两个着色器实际上会被合并成一个程序,然后绑定到管线中,以便在正确的时间使用适当的着色器。这一点很重要,因为它决定了GL_Shader数据结构的构建方式:
enum class ShaderType{ Vertex, Fragment, COUNT };
class GL_Shader {
public:
GL_Shader(const std::string& l_fileName);
~GL_Shader();
void Bind() const;
private:
static void CheckError(GLuint l_shader, GLuint l_flag,
bool l_program, const std::string& l_errorMsg);
static GLuint BuildShader(const std::string& l_src,
unsigned int l_type);
GLuint m_program;
GLuint m_shader[static_cast<unsigned int>(ShaderType::COUNT)];
};
首先,我们列举出我们将要使用的着色器类型。对于基本用途来说,顶点着色器和片段着色器已经足够了。
类的构造函数接受将要加载的着色器(们)的文件名。还有一个Bind()方法,它将在渲染开始之前用于启用特定的着色器程序。
我们还有两个静态辅助方法,用于在着色器内部打印错误,并实际构建它们。是的,着色器在使用之前需要编译和链接,就像 C/C++一样。
最后,我们需要两个GL 无符号整数数据成员,后者是一个数组。第一个整数将代表着色器程序,其中包含所有附加的着色器。整数数组跟踪程序中所有类型着色器的标识符。
实现着色器类
让我们着手实际创建一些着色器!和往常一样,一个好的开始是从构造函数开始的:
GL_Shader::GL_Shader(const std::string& l_fileName) {
auto src_vert = Utils::ReadFile(l_fileName + ".vert");
auto src_frag = Utils::ReadFile(l_fileName + ".frag");
if (src_vert.empty() && src_frag.empty()) { return; }
m_program = glCreateProgram(); // Create a new program.
m_shader[static_cast<GLuint>(ShaderType::Vertex)] =
BuildShader(src_vert, GL_VERTEX_SHADER);
m_shader[static_cast<GLuint>(ShaderType::Fragment)] =
BuildShader(src_frag, GL_FRAGMENT_SHADER);
for (GLuint i = 0;
i < static_cast<GLuint>(ShaderType::COUNT); ++i)
{
glAttachShader(m_program, m_shader[i]);
}
glBindAttribLocation(m_program,
static_cast<GLuint>(VertexAttribute::Position), "position");
glLinkProgram(m_program);
CheckError(m_program,GL_LINK_STATUS,true,"Shader link error:");
glValidateProgram(m_program);
CheckError(m_program,GL_VALIDATE_STATUS,true,"Invalid shader:");
}
在进行任何着色器编译之前,我们首先需要在内存中加载实际的源代码。OpenGL 不会为你做这件事,所以我们将利用之前实现的ReadFile函数。一旦两种类型的着色器都被加载并检查不为空,就会使用glCreateProgram()创建一个新的着色器程序。它返回一个标识符,如果我们想在渲染时使用着色器,我们需要跟踪这个标识符。
对于实际的顶点和片段着色器,调用静态BuildShader()方法,并将返回的标识符存储在m_shader数组中,对应于相关类型的着色器。注意传递给方法调用的GL_VERTEX_SHADER和GL_FRAGMENT_SHADER定义。这些是 OpenGL 构建着色器所需的着色器类型。
在着色器构建完成后,它们需要附加到我们创建的程序上。为此,我们可以简单地使用循环并调用glAttachShader,该函数接受程序的 ID 以及要附加到该程序的着色器 ID。
着色器在执行时需要某种形式的输入。记住,我们的模型渲染是从绑定到一个 VAO 开始的,该 VAO 包含有关如何访问 VBO 中某些属性的信息,然后是绘制调用。为了确保数据传输正常工作,我们的着色器类需要绑定一个名称和属性位置。这可以通过调用glBindAttribLocation并传入程序 ID、实际的属性位置(枚举为VertexAttribute)以及将在着色器程序内部使用的属性变量名称来实现。这一步骤确保输入到顶点着色器中的数据将通过一个位置变量可访问。这将在编写基本着色器部分中进一步介绍。
在着色器构建并绑定其属性之后,我们剩下的只是链接和验证,后者确定着色器可执行文件是否可以在当前的 OpenGL 状态下运行。glLinkProgram和glValidateProgram都只需传入程序的 ID。在这些函数调用之后,我们还会调用其他静态辅助方法CheckError。该方法负责获取与链接和编译阶段中任何错误相关的信息字符串。此方法接受程序 ID、一个用于确定我们实际上感兴趣的着色器构建过程的哪个阶段的标志、一个表示是否正在检查整个着色器程序或只是单个着色器的布尔值,以及一个字符串,在打印实际错误之前将其拆分到控制台窗口中。
着色器,就像任何资源一样,一旦使用完毕就需要进行清理:
GL_Shader::~GL_Shader() {
for (GLuint i = 0;
i < static_cast<GLuint>(ShaderType::COUNT); ++i)
{
glDetachShader(m_program, m_shader[i]);
glDeleteShader(m_shader[i]);
}
glDeleteProgram(m_program);
}
多亏了ShaderType枚举,我们确切地知道我们支持多少种着色器类型,因此我们可以在清理过程中简单地为每个类型运行一个循环。对于每种着色器类型,我们首先必须使用glDetachShader将其从着色器程序中分离出来,该函数接受程序 ID 和着色器 ID,然后使用glDeleteShader将其删除。一旦所有着色器都被移除,程序本身将通过glDeleteProgram()函数调用被删除。
如前所述,OpenGL 使用函数调用和共享状态来操作。这意味着某些资源,例如着色器,在使用于渲染之前必须绑定:
void GL_Shader::Bind() const { glUseProgram(m_program); }
为了使用着色器绘制特定的一组原语,我们只需调用glUseProgram并传入着色器程序的 ID。
让我们看看我们的一个辅助方法,用于确定在着色器程序设置的各种阶段是否有任何错误:
void GL_Shader::CheckError(GLuint l_shader, GLuint l_flag,
bool l_program, const std::string& l_errorMsg)
{
GLint success = 0;
GLchar error[1024] = { 0 };
if (l_program) { glGetProgramiv(l_shader, l_flag, &success); }
else { glGetShaderiv(l_shader, l_flag, &success); }
if (success) { return; }
if (l_program) {
glGetProgramInfoLog(l_shader, sizeof(error), nullptr, error);
} else {
glGetShaderInfoLog(l_shader, sizeof(error), nullptr, error);
}
std::cout << l_errorMsg << error << std::endl;
}
首先,设置一些局部变量来存储状态信息:一个成功标志和一个用于放置错误消息的缓冲区。如果l_program标志为真,这意味着我们正在尝试获取有关实际着色器程序的信息。否则,我们只对单个着色器感兴趣。为了获取表示着色器/程序链接/验证/编译阶段成功或失败的参数,我们需要使用glGetProgramiv或glGetShaderiv。这两个函数都接受一个被检查的着色器或程序的 ID,一个我们感兴趣的参数的标志,以及一个用于覆盖返回值的指针,在这种情况下要么是GL_TRUE要么是GL_FALSE。
如果我们感兴趣的着色器构建过程的任何阶段成功完成,我们只需从方法中返回。否则,我们调用glGetProgramInfoLog()或glGetShaderInfoLog()来获取程序或单个着色器的错误信息。这两个函数都接受被检查的程序或着色器的标识符、我们已分配的错误消息缓冲区的大小、一个用于存储返回字符串长度的变量的指针,我们实际上不需要它,所以传入nullptr,以及一个指向要写入的错误消息缓冲区的指针。之后,就像打印出我们的l_errorMsg前缀,然后是实际写入到error消息缓冲区的错误一样简单。
最后,但绝对是最重要的,让我们看看构建单个着色器需要什么:
GLuint GL_Shader::BuildShader(const std::string& l_src,
unsigned int l_type)
{
GLuint shaderID = glCreateShader(l_type);
if (!shaderID) {
std::cout << "Bad shader type!" << std::endl; return 0;
}
const GLchar* sources[1];
GLint lengths[1];
sources[0] = l_src.c_str();
lengths[0] = l_src.length();
glShaderSource(shaderID, 1, sources, lengths);
glCompileShader(shaderID);
CheckError(shaderID, GL_COMPILE_STATUS, false,
"Shader compile error: ");
return shaderID;
}
首先,必须使用glCreateShader()方法创建单个着色器。它接受一个着色器类型,例如GL_VERTEX_SHADER,这是我们在这个类的构造函数中使用的。如果由于某种原因着色器创建失败,错误消息将被写入控制台窗口,并且方法返回0。否则,将设置两个 GL 类型数组:一个用于可能多个着色器的源,另一个用于每个源字符串的长度。目前我们只将处理每个着色器的一个源,但如果我们以后想要处理多个源,这是可能的。
在将源代码及其长度写入我们刚刚设置的数组之后,使用glShaderSource将代码提交到缓冲区,以便在编译之前。该函数接受新创建的着色器的 ID,我们传递的源字符串的数量,源数组的指针,以及源长度数组的指针。然后,使用glCompileShader实际编译着色器,并调用CheckError辅助方法来打印出任何可能的编译错误。注意传递的GL_COMPILE_STATUS标志以及 false 标志,这表明我们感兴趣的是检查单个着色器的状态,而不是整个着色器程序的状态。
编写基本着色器
由于我们的GL_Shader类已经完成,我们最终可以开始为我们的应用程序编写一些基本的着色器了!让我们先看看一个名为basic.vert的文件,这是我们顶点着色器:
#version 450
attribute vec3 position;
void main(){
gl_Position = vec4(position, 1.0);
}
首先,我们设置 OpenGL 将要写入的着色器attribute。它是一个vec3类型的属性,并将代表我们一个接一个输入到这个着色器的顶点位置信息。这个类型是在GL_Model类的构造函数中使用glVertexAttribPointer设置的,然后在GL_Shader类的构造函数中使用glBindVertexAttribLocation函数命名。
着色器的主体必须有一个主函数,所有的魔法都在这里发生。在这种情况下,我们只需要将内部 OpenGL 变量gl_Position设置为我们的顶点想要的位置。它需要一个vec4类型,因此位置属性被转换为它,最后一个向量值,用于裁剪目的,被设置为1.0。目前,我们不需要担心这一点。只需记住,实际的顶点位置在归一化设备坐标(范围在(-1,-1)和(1,1))中设置在这里。
小贴士
注意第一行上的版本号。如果你的计算机不支持 OpenGL 4.5,可以将其更改为任何其他版本,特别是因为我们没有做任何旧版本不支持的事情。
在处理顶点信息之后,我们还需要担心正确着色组成我们的几何体的单个像素。这就是片段着色器发挥作用的地方:
#version 450
void main(){
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); // White.
}
这个着色器也使用了一个内部 OpenGL 变量。这次它被命名为gl_FragColor,并且,如预期的那样,用于设置我们正在处理的像素的颜色。目前,让我们将我们的几何体的所有像素都着色为白色。
绘制我们的第一个三角形
我们有我们的模型类,它处理所有的几何数据,以及着色器类,它处理我们在可编程渲染管道的各个点的数据处理。把这些都处理完毕后,我们剩下的就是实际设置和使用这些类。让我们先从将它们作为数据成员添加到Game对象开始:
class Game{
...
private:
...
std::unique_ptr<GL_Shader> m_shader;
std::unique_ptr<GL_Model> m_model;
...
};
它们可以在我们的Game类的构造函数中设置:
Game::Game() ... {
...
m_shader = std::make_unique<GL_Shader>(
Utils::GetWorkingDirectory() + "GL/basic");
GL_Vertex vertices[] = {
// |-----POSITION----|
// X Y Z
GL_Vertex({ -0.5, -0.5, 0.5 }, // 0
GL_Vertex({ -0.5, 0.5, 0.5 }, // 1
GL_Vertex({ 0.5, 0.5, 0.5 }, // 2
};
m_model = std::make_unique<GL_Model>(vertices, 3);
}
首先,创建一个着色器类,并传入一个带有文件名的路径,以便加载可执行目录GL目录下的basic.vert和basic.frag着色器。然后设置一个顶点数组,每个顶点被初始化到特定的归一化设备坐标位置。这种特定的排列在屏幕中间创建三个顶点,它们将被连接成一个三角形。这里的坐标位于所谓的归一化设备坐标范围内。这是窗口使用的坐标系,如下所示:

然后创建一个GL_Model对象,将顶点数组和顶点计数作为参数传入。然后GL_Model将像之前讨论的那样将此数据推送到 GPU。
最后,让我们看看我们如何在屏幕上渲染我们的三角形:
void Game::Render() {
m_window.BeginDraw();
// Render here.
m_shader->Bind();
m_model->Draw();
// Finished rendering.
m_window.EndDraw();
}
在BeginDraw()方法内部清除窗口之后,着色器程序被绑定,这样我们之前编写的顶点和片段着色器就可以在推送GL_Model的顶点数据到渲染管线时使用。然后调用模型的Draw()方法,开始渲染过程。在程序成功编译和执行后,屏幕上应该显示如下内容:

哈喽!经过大约 20 页的理论学习,我们得到了一个三角形。这可能会有些令人沮丧,但请记住,从现在开始,一切都将变得容易得多。恭喜你走到了这一步!
使用纹理
一个基本的白色三角形看起来并不令人兴奋。接下来,我们代码的下一个明显改进是使纹理对片段着色器可用,以便它们可以被采样并应用到我们的几何体上。不幸的是,OpenGL 并没有提供实际加载图像数据的方法,特别是考虑到有如此多的不同格式需要处理。为此,我们将使用本章开头列出的资源之一,即 STB 图像加载器。它是一个小型单头 C 库,用于将图像数据加载到缓冲区中,该缓冲区可以稍后由 OpenGL 或其他任何库使用。
纹理类
记得之前提到的,从这一点开始一切都会变得容易得多?这是真的。让我们快速浏览一下纹理处理过程,从纹理对象的类定义开始:
class GL_Texture {
public:
GL_Texture(const std::string& l_fileName);
~GL_Texture();
void Bind(unsigned int l_unit);
private:
GLuint m_texture;
};
虽然 OpenGL 实际上并不处理加载纹理数据,但它仍然会在本类的范围内进行处理。因此,我们的纹理类构造函数仍然需要传入要加载的纹理文件的路径。同样,就像着色器类一样,在渲染几何体之前,我们需要绑定到特定的纹理上。目前,忽略它所接受的参数。稍后将会解释。
与着色器或几何数据一样,OpenGL 纹理必须存储在 GPU 上。因此,可以合理地推断纹理数据将通过GLuint标识符来引用,就像着色器或缓冲区一样。
实现纹理类
让我们看看为了成功从硬盘加载纹理并将它们推送到 GPU 需要做什么:
GL_Texture::GL_Texture(const std::string& l_fileName) {
int width, height, nComponents;
unsigned char* imageData = stbi_load(l_fileName.c_str(),
&width, &height, &nComponents, 4);
if (!imageData) { return; }
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, imageData);
stbi_image_free(imageData);
}
首先,创建几个整数以便填充即将加载的纹理的信息。然后,调用stbi_load()函数,它是 STB 图像加载库的一部分,传递纹理文件的路径,指向即将写入的宽度、高度和组件计数变量的指针,以及文件预期的组件数量。数据以无符号字符的形式存储,该函数返回指向数据的指针。如果返回nullptr,显然需要返回,因为加载过程失败。
图像所包含的组件数量简单来说就是颜色通道的数量。传入值为 0 意味着图像数据将按原样加载,而任何其他值都会强制数据包含其他颜色通道信息。组件数量到通道配置的评估方式如下:
| 组件 | 通道 |
|---|---|
| 1 | 灰色 |
| 2 | 灰色,alpha |
| 3 | 红色,绿色,蓝色 |
| 4 | 红色,绿色,蓝色,alpha |
从现在开始,我们将遵循现在应该已经熟悉的模式。首先,使用glGenTextures生成一个纹理对象,将我们想要的纹理数量作为第一个参数传递,并将纹理标识符或它们的列表的指针作为第二个参数传递。然后,我们使用glBindTexture绑定到新创建的纹理。这个函数的第一个参数简单地让 OpenGL 知道我们正在处理什么类型的纹理。在这种情况下,使用GL_TEXTURE_2D,因为它是一个基本的 2D 图像。
提示
OpenGL 支持多种不同类型的纹理,用于各种任务,包括 3D 纹理、立方体贴图等。
一旦纹理被绑定,我们就可以操作它所附带的各种细节。对于纹理,参数操作函数名为glTexParameter()。这个函数有多个不同类型,所有这些类型都以不同的后缀结尾,为程序员提供了关于它期望的数据类型的提示。就我们的目的而言,我们将使用两种类型:整数和浮点数,分别以字母i和f结尾。
前两行处理的是当纹理数据在其大小边界之外读取时的行为定义,即纹理如何包裹。GL_TEXTURE_WRAP_S参数处理的是在X轴上的包裹,而GL_TEXTURE_WRAP_T参数处理的是Y轴。为什么是S和T?答案很简单。位置向量、颜色数据和纹理坐标的枚举方式不同,但它们都大致意味着相同的东西。考虑以下表格:
| 1 | 2 | 3 | 4 | |
|---|---|---|---|---|
| 位置 | X | Y | Z | W |
| 颜色 | R | G | B | A |
| 纹理 | S | T | P | Q |
它们都是四个值的向量。访问位置X值与访问颜色结构的红色通道相同,依此类推。
下两个函数调用处理了纹理在缩放时如何插值的问题。两种情况都指定了GL_LINEAR参数,这意味着像素将进行线性插值。
最后,我们通过调用glTexImage2D()方法实际上将加载的像素信息提交给 GPU。它的第一个参数再次让 OpenGL 知道我们正在提交哪种类型的纹理。第二个参数是纹理的细节级别,它将用于 Mip 映射。值0简单地意味着它是基本级别的纹理。
注意
Mip 映射是一种可选技术,OpenGL 可以利用它,其中加载并提交给 GPU 的是同一纹理的多个版本,但具有不同的分辨率,并且根据它距离观察者的距离来应用几何体。如果它更远,则使用较低分辨率的纹理(具有更高的 Mip 映射级别)。这可以出于性能原因,在必要时进行。
第三个参数让 OpenGL 知道像素信息数据的排列方式。这是必要的,因为某些格式可能以不同的配置存储它。接下来传递宽度和高度信息,以及可以用来为纹理添加边框的像素数量。我们不会使用该功能,因此传递了0。下一个参数再次是一个标志,用于特定的像素排列。这次它让 OpenGL 知道我们希望它将像素数据存储在哪种排列中。最后,传递了一个标志,用于我们加载的纹理的类型,以及指向实际纹理数据的指针。我们使用GL_UNSIGNED_BYTE参数,因为这是 STB 图像加载器返回的,而char类型正好是一个字节长。
在将纹理信息提交给 GPU 之后,我们不再需要保留图像数据缓冲区。通过调用stbi_image_free并传入缓冲区指针来销毁它。
我们提交给 GPU 的数据,一旦不再需要纹理,就需要释放:
GL_Texture::~GL_Texture() { glDeleteTextures(1, &m_texture); }
glDeleteTextures函数接收我们想要丢弃的纹理数量,以及一个指向GLuint标识符数组的指针。
最后,让我们实现Bind()方法,这将使我们能够在渲染时使用纹理:
void GL_Texture::Bind(unsigned int l_unit) {
assert(l_unit >= 0 && l_unit < 32);
glActiveTexture(GL_TEXTURE0 + l_unit);
glBindTexture(GL_TEXTURE_2D, m_texture);
}
OpenGL 实际上支持在渲染时同时绑定多个纹理的能力,这样就可以更有效地对复杂几何体进行纹理处理。确切的数量,至少在撰写本文时,是32个单位。大多数时候我们不需要那么多,但有一个选项是很好的。我们想要使用的单位标识符作为参数传递给Bind()方法。为了避免混淆,我们将执行一个assert()方法并确保l_unit值在正确的范围内。
为了启用特定纹理的单位,需要调用glActiveTexture()方法。它接受一个参数,即枚举的纹理单位。它从GL_TEXTURE0一直延伸到GL_TEXTURE31。因为这些值是连续的,一个巧妙的方法是简单地将l_unit添加到GL_TEXTURE0定义中,这将给我们正确的单位枚举。之后,我们就像之前一样绑定到纹理,使用glBindTexture()方法,并传递我们拥有的纹理类型及其标识符。
模型和着色器类更改
要添加对纹理几何体的支持,我们首先需要对存储的顶点信息进行一些修改。让我们看看GL_Vertex结构,看看需要添加什么:
enum class VertexAttribute{ Position, TexCoord, COUNT };
struct GL_Vertex {
GL_Vertex(const glm::vec3& l_pos,
const glm::vec2& l_texCoord)
: m_pos(l_pos), m_texCoord(l_texCoord) {}
glm::vec3 m_pos; // Attribute 1\.
glm::vec2 m_texCoord; // Attribute 2\.
// ...
};
如您所见,我们需要一个额外的顶点属性,即与顶点关联的纹理坐标。它是一个简单的二维向量,表示纹理坐标,如下所示:

以这种方式表示纹理坐标的优点是它使得坐标分辨率无关。在较小的纹理上,点(0.5,0.5)将与其较大的对应点完全相同。
由于我们现在需要存储和访问单个顶点的更多信息,VAO 需要确切知道如何做到这一点:
GL_Model::GL_Model(GL_Vertex* l_vertices,unsigned int l_vertCount)
{
...
auto stride = sizeof(l_vertices[0]);
auto texCoordOffset = sizeof(l_vertices[0].m_pos);
glBindBuffer(GL_ARRAY_BUFFER, m_vertexVBO);
glBufferData(GL_ARRAY_BUFFER,
l_vertCount * sizeof(l_vertices[0]),
l_vertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(
static_cast<GLuint>(VertexAttribute::Position));
glVertexAttribPointer(
static_cast<GLuint>(VertexAttribute::Position), 3, GL_FLOAT,
GL_FALSE, stride, 0);
glEnableVertexAttribArray(
static_cast<GLuint>(VertexAttribute::TexCoord));
glVertexAttribPointer(
static_cast<GLuint>(VertexAttribute::TexCoord), 2, GL_FLOAT,
GL_FALSE, stride, (void*)texCoordOffset);
...
}
我们现在可以开始使用之前讨论过的步进和偏移参数了!步进当然是GL_Vertex结构的大小,而获取纹理坐标的偏移量是顶点位置向量的大小,因为这是指针需要偏移的量。
在数据提交到缓冲区之后,我们启用顶点位置属性,并使用stride提供其指针。偏移量保持为0,因为Position是第一个属性。
我们还需要启用TexCoord属性,因为它也将传递到着色器中。它的指针设置方式与位置类似,但我们有2个浮点数而不是3,现在需要应用偏移,以便跳过位置数据。
注意
注意最后一个参数的void*转换。这是因为偏移实际上接受一个指针,而不是字节数。这是遗留的细节之一,在新版本中仅表示字节数。
我们 C++代码的最终更改是更新GL_Shader类,以便注册将要传递到顶点着色器的新属性:
GL_Shader::GL_Shader(const std::string& l_fileName) {
...
glBindAttribLocation(m_program,
static_cast<GLuint>(VertexAttribute::Position), "position");
glBindAttribLocation(m_program,
static_cast<GLuint>(VertexAttribute::TexCoord),
"texCoordVert");
...
}
它只是为我们的纹理坐标属性建立了一个名称,现在命名为"texCoordVert"。
更新着色器
实际的纹理采样发生在片段着色器内部。然而,由于数据实际上首先在顶点着色器中接收,让我们看看它需要如何更新以满足我们的需求:
#version 450
attribute vec3 position;
attribute vec2 texCoordVert;
varying vec2 texCoord; // Pass to fragment shader.
void main(){
gl_Position = vec4(position, 1.0);
texCoord = texCoordVert; // Pass to fragment shader.
}
如您所见,这里建立了texCoordVert属性,以及一个名为texCoord的 2D 向量varying。varying类型仅仅意味着其数据将被传递到渲染管线中,并被下一个着色器接收。在我们的例子中,texCoord将在片段着色器内部可访问。其值设置为texCoordVert的输入属性。为什么?因为任何着色器接收到的varying数据都是插值的。没错。看看下面的图示:

为了准确采样我们几何体每个像素的颜色信息,我们实际上并不需要自己进行任何数学运算。插值,或者加权平均,为我们处理了这一切。如果一个顶点的纹理坐标是,比如说(1,1),而相对的顶点坐标是(0,0),那么在两个顶点之间的某个像素上执行的片段着色器将接收插值值为(0.5, 0.5)。这使得给像素上色变得如此简单:
#version 450
uniform sampler2D texture;
varying vec2 texCoord; // Receiving it from vertex shader.
void main(){
gl_FragColor = texture2D(texture, texCoord);
}
首先,注意类型为sampler2D的uniform变量,名为texture。我们不需要手动将其传递到着色器中,因为这是在幕后完成的。它只是提供了访问绑定到当前纹理的数据的权限。接下来,我们设置变量texCoord,这完成了从顶点着色器到片段着色器的数据管道。然后,将片段颜色设置为vec4,它由texture2D()函数返回,该函数接收片段着色器接收到的纹理以及我们想要采样的坐标。由于返回的vec4代表像素的颜色,这就足以纹理化几何体了!
使用纹理
在这个阶段,将纹理应用到我们的几何体上相当简单。首先,需要将GL_Texture类添加为Game对象的数据成员。然后我们可以按照以下步骤设置其他一切:
Game::Game() ... {
...
GL_Vertex vertices[] = {
// |---POSITION----| |TEXTURE|
// X Y Z X Y
GL_Vertex({ -0.5, -0.5, 0.5 }, { 0, 0 }), // 0
GL_Vertex({ -0.5, 0.5, 0.5 }, { 0, 1 }), // 1
GL_Vertex({ 0.5, 0.5, 0.5 }, { 1, 1 }), // 2
};
m_texture = std::make_unique<GL_Texture>(
Utils::GetWorkingDirectory() + "GL/brick.jpg");
...
}
void Game::Render() {
m_window.BeginDraw();
// Render here.
m_texture->Bind(0);
m_shader->Bind();
m_model->Draw();
m_window.EndDraw();
}
GL_Vertex对象现在接受一个额外的参数,它代表顶点的纹理坐标。我们还在构造函数中加载了砖块纹理,然后在Render()方法中将其绑定,就在着色器之前。当我们的模型被渲染时,它应该看起来如下:

我们现在有一个应用了纹理的静止模型。仍然不太令人兴奋,但我们正在接近目标!
应用变换
移动、旋转以及其他方式操纵顶点数据可能看起来相当直接。甚至有人可能会倾向于简单地更新顶点位置信息,并将这些数据重新提交到 VBO。虽然过去可能一直是这样做的,但执行此任务有更高效、尽管更数学密集的方法。现在,通过简单地乘以称为矩阵的东西,顶点的位移现在在顶点着色器中完成。
矩阵基础
矩阵在图形编程中极其有用,因为它们可以表示应用于向量的任何类型的旋转、缩放或位移操作。存在许多不同类型的矩阵,但它们都是类似这样的信息块:

这个特定的矩阵是一个 4x4 的单位矩阵,但存在各种不同大小的矩阵,例如 3x3、2x3、3x2 等等。在加法、减法、乘法或除法时存在规则。我们实际上不会深入探讨这一点,因为它超出了本章的范围。好事是glm库为我们抽象了所有这些,所以现在我们不必绝对了解这些。从这个中我们可以得到的一个要点是,位置向量可以在加到或乘以矩阵时进行变换。
世界空间
到目前为止,我们一直在使用在归一化设备坐标空间中指定的顶点位置。这意味着每个顶点坐标实际上相对于屏幕中心。然而,为了正确处理变换;我们希望将我们的几何形状视为相对于模型空间中的某个原点,如下所示:

如果一个模型有一个原点,它也可以在我们的世界中有一个全局位置,其中其原点相对于我们构建的游戏世界中的某个任意点。这个全局位置以及一些其他属性,如对象的缩放和旋转,可以用一个矩阵来表示。将这些属性应用于模型空间中的顶点坐标,这正是当它们乘以模型矩阵时发生的情况,使我们能够将这些坐标带入所谓的世界空间,如下所示:

这种变换简单来说就是顶点现在相对于世界的原点,而不是模型原点,这使得我们能够在屏幕上绘制之前,准确地在我们自己的坐标系中表示模型。
变换类
在应用任何变换之前,它们应该被适当地分组并由一个单一的数据结构表示。GL_Transform类将为我们做这件事:
#include <glm.hpp>
#include <gtx/transform.hpp>
class GL_Transform {
public:
GL_Transform(const glm::vec3& l_pos = { 0.f, 0.f, 0.f },
const glm::vec3& l_rot = { 0.f, 0.f, 0.f },
const glm::vec3& l_scale = { 1.f, 1.f, 1.f });
glm::vec3 GetPosition()const;
glm::vec3 GetRotation()const;
glm::vec3 GetScale()const;
void SetPosition(const glm::vec3& l_pos);
void SetRotation(const glm::vec3& l_rot);
void SetScale(const glm::vec3& l_scale);
glm::mat4 GetModelMatrix();
private:
void RecalculateMatrix();
glm::vec3 m_position;
glm::vec3 m_rotation;
glm::vec3 m_scale;
};
首先,注意顶部的包含头文件。这些是本类中将使用的数据类型和变换函数所必需的。除此之外,我们还有三个向量,将代表模型的位置、旋转和缩放,这些将被用于计算将顶点转换成世界坐标的模型矩阵。
实现变换类
构造函数简单地接受适当的参数并使用初始化列表设置一些数据成员:
GL_Transform::GL_Transform(const glm::vec3& l_pos,
const glm::vec3& l_rot, const glm::vec3& l_scale)
: m_position(l_pos), m_rotation(l_rot), m_scale(l_scale)
{}
这个类的核心是GetModelMatrix()方法,因为它处理所有必要的数学运算:
glm::mat4 GL_Transform::GetModelMatrix() {
glm::mat4 matrix_pos = glm::translate(m_position);
glm::mat4 matrix_scale = glm::scale(m_scale);
// Represent each stored rotation as a different matrix, because
// we store angles.
// x y z
glm::mat4 matrix_rotX = glm::rotate(m_rotation.x,
glm::vec3(1, 0, 0));
glm::mat4 matrix_rotY = glm::rotate(m_rotation.y,
glm::vec3(0, 1, 0));
glm::mat4 matrix_rotZ = glm::rotate(m_rotation.z,
glm::vec3(0, 0, 1));
// Create a rotation matrix.
// Multiply in reverse order it needs to be applied.
glm::mat4 matrix_rotation = matrix_rotZ*matrix_rotY*matrix_rotX;
// Apply transforms in reverse order they need to be applied in.
return matrix_pos * matrix_rotation * matrix_scale;
}
模型矩阵将是许多其他矩阵相乘的结果,因此它将包含所有必要的变换信息。我们首先创建一个称为平移矩阵的东西。调用glm::translate为我们创建一个,带有m_position的位置信息。它用于将我们的顶点位置带入世界空间。
然后我们创建一个缩放矩阵,它负责表示模型的缩放或缩小。例如,如果一个模型应该绘制为存储在 GPU 上的两倍大小,那么缩放矩阵将用于调整所有顶点的位置,使其看起来是这样。使用glm::scale并传入缩放向量作为参数将为我们构建一个。
我们需要的最后一种矩阵是旋转矩阵。它显然代表了物体的不同旋转值,因此将所有顶点围绕一个原点进行位移。然而,由于我们将旋转信息存储为度数向量,所以这个矩阵并不那么直接。因此,每个轴的矩阵都需要使用glm::rotate函数来创建,该函数接受旋转角度以及一个方向向量,表示期望旋转的轴。这意味着根据我们处理的哪个轴,只需为x、y或z分量设置值为1。最终的旋转矩阵是通过将所有三个之前的矩阵相乘来计算的。使用不同的乘法顺序会产生不同的结果。一般来说,一个经验法则是按照应用的相反顺序乘以所有矩阵。
最后,我们可以通过以下方式将所有之前的矩阵相乘来计算模型矩阵:

然后返回得到的模型矩阵。
本节课的剩余部分相当直接,因为除了设置器和获取器之外,没有其他内容:
glm::vec3 GL_Transform::GetPosition() const { return m_position; }
glm::vec3 GL_Transform::GetRotation() const { return m_rotation; }
glm::vec3 GL_Transform::GetScale() const { return m_scale; }
void GL_Transform::SetPosition(const glm::vec3& l_pos)
{ m_position = l_pos; }
void GL_Transform::SetRotation(const glm::vec3& l_rot)
{ m_rotation = l_rot; }
void GL_Transform::SetScale(const glm::vec3& l_scale)
{ m_scale = l_scale; }
更新着色器类
再次强调,我们将使用着色器类来提交必要的矩阵信息到顶点着色器,在那里它将被使用。这样做是在顶点着色器中进行的原因是因为 GPU 针对此类操作进行了优化。让我们看看我们需要更改的内容:
enum class UniformType{ Transform, COUNT };
class GL_Shader {
public:
...
void Update(GL_Transform& l_transform);
...
private:
...
GLuint m_uniform[static_cast<unsigned int>(UniformType::COUNT)];
};
首先,请注意我们建立的一个新枚举。它列出了我们的着色器需要的所有均匀变量类型,目前只包含一个。
注意
均匀变量执行的任务与通常的着色器属性或变化变量不同。属性在 OpenGL 幕后由 OpenGL 填充,使用来自 VBO 的数据。变化的着色器变量在着色器之间传递。均匀变量实际上是通过我们的 C++代码传递到着色器的,这就是为什么我们需要以不同的方式处理它。
GL_Shader类现在还需要一个Update()方法,它将接受对GL_Transform类的引用,并使用它将模型矩阵传递到顶点着色器。最后,我们需要存储用于在着色器中定位均匀变量的标识符,以便它们可以被使用。m_uniform数据成员就是为了这个目的存在的。
让我们看看如何获取和存储均匀变量位置:
GL_Shader::GL_Shader(const std::string& l_fileName) {
...
m_uniform[static_cast<unsigned int>(UniformType::Transform)] =
glGetUniformLocation(m_program, "transform");
}
如您所见,OpenGL 提供了一个很好的函数来完成这个任务,称为glGetUniformLocation。它接受我们正在使用的程序的标识符,以及着色器内部均匀变量的名称,即"transform"。
设置均匀变量的值也归结为单个函数调用:
void GL_Shader::Update(GL_Transform& l_transform) {
glm::mat4 modelMatrix = l_transform.GetModelMatrix();
glUniformMatrix4fv(static_cast<GLint>(
m_uniform[static_cast<unsigned int>(UniformType::Transform)]),
1, GL_FALSE, &modelMatrix[0][0]);
}
首先,我们从变换类中获取模型矩阵。然后调用glUniform函数。它有一个后缀,表示我们正在提交的确切数据类型,在这种情况下,是一个 4x4 的浮点矩阵。我们之前存储的均匀 ID 用作第一个参数。正在提交的数据量是第二个参数,在这种情况下是1,因为我们只提交了一个矩阵。第三个参数是一个标志,允许我们转置矩阵。我们不需要这样做,所以传入GL_FALSE。最后,将矩阵的第一个元素的指针传递为最后一个参数。OpenGL 知道矩阵的确切大小,因为我们调用了适当的函数,这使得它可以读取整个矩阵。
最后,我们需要修改顶点着色器以实际执行变换:
#version 450
attribute vec3 position;
attribute vec2 texCoordVert;
varying vec2 texCoord; // Pass to fragment shader.
uniform mat4 transform; // Passed in by the shader class.
void main(){
gl_Position = transform * vec4(position, 1.0);
texCoord = texCoordVert; // Pass to fragment shader.
}
注意添加的uniform类型为mat4。我们只需要在主函数中将它乘以位置,这样就得到了我们的变换后的顶点位置。
操作三角形
再次强调,为了应用我们编写的代码,我们剩下的唯一事情是将它添加到Game类中:
class Game{
...
private:
...
GL_Transform m_transform;
...
};
实际上不需要更多的设置。我们可以直接跳转到通过编辑Update()方法来操作变换的属性:
void Game::Update() {
...
auto rotation = m_transform.GetRotation();
rotation.x += 0.001f;
rotation.y += 0.0002f;
rotation.z += 0.002f;
if (rotation.x >= 360.f) { rotation.x = 0.f; }
if (rotation.y >= 360.f) { rotation.y = 0.f; }
if (rotation.z >= 360.f) { rotation.z = 0.f; }
m_transform.SetRotation(rotation);
m_shader->Update(m_transform);
}
在这种情况下,我们只是在所有轴上玩旋转。在做出这些修改后,将变换对象传递给GL_ShaderUpdate()方法非常重要,这样顶点才能被正确变换,从而得到以下结果旋转:

现在我们正在取得进展!然而,我们仍然没有与场景进行交互。在这整个过程中,我们只是静静地坐着,而几何体只是旋转。这最多只是一个非常复杂的屏保。让我们实际实现一些能够给我们带来一些移动性的功能。
创建摄像机
与 SFML 不同,OpenGL 不提供移动视图或摄像机的方法。虽然这乍一看可能有些奇怪,但这主要是因为没有摄像机或视图可以移动。是的,你听对了。没有摄像机,没有视图,只有顶点数据、着色器和原始数学来拯救。如何?让我们看看!
视图投影基础
大多数库抽象掉的渲染和编程技巧实际上就是那些技巧。当涉及到在游戏世界中移动时,并没有真正的摄像机方便地拍摄要渲染的几何体的正确侧面。摄像机只是一个幻觉,用于抽象那些不直观的概念。在游戏世界中移动除了在顶点上执行额外的矩阵数学运算之外,不涉及其他任何事情。围绕场景旋转摄像机的行为实际上正好相反:围绕被称为摄像机的空间中的点旋转场景。再一次,我们将把顶点转换成相对于另一个原点,这次是摄像机本身。考虑以下图示:

为了实现摄像机类并能够在世界中移动,我们需要了解一些基础知识。首先,我们必须决定摄像机的视场角度应该有多宽。这会影响我们实际上能看到多少。另一个重要的细节是正确设置视锥体。想象一下,它是一个定义摄像机视场范围的金字塔形几何体。它决定了某些事物可以多近直到它们不再被看到,以及物体从摄像机到不再被渲染的最大距离。
我们窗口的宽高比、视场、视锥体的近/远距离以及摄像机的位置共同构成了我们将要计算的两个矩阵:视图矩阵和投影矩阵。前者处理将顶点相对于摄像机位置进行定位,而后者则根据它们与视锥体、视场和其他属性的远近进行调整和扭曲。
我们可以主要使用两种投影类型:透视和正交。透视投影提供了一种逼真的结果,其中对象可以看起来离相机更远,而正交投影则更像是一种固定深度感,使得无论对象的距离如何,看起来大小都相同。我们将使用透视投影来完成我们的任务。
相机类
在覆盖了所有这些信息之后,我们终于准备好了解GL_Camera类这个烟雾和镜子了。让我们看看要在这个世界中操纵需要什么:
class GL_Camera {
public:
GL_Camera(const glm::vec3& l_pos, float l_fieldOfView,
float l_aspectRatio, float l_frustumNear, float l_frustumFar);
glm::mat4 GetViewProjectionMatrix();
private:
void RecalculatePerspective();
float m_fov;
float m_aspect;
float m_frustumNear;
float m_frustumFar;
glm::vec3 m_position;
glm::vec3 m_forwardDir;
glm::vec3 m_upDir;
glm::mat4 m_perspectiveMatrix;
};
如您所见,我们存储了所有已覆盖的细节,以及一些新的内容。除了视场角度、纵横比以及近远视锥值之外,我们还需要保留位置、一个前进方向向量以及一个向上方向向量。m_forwardDir是一个归一化的方向向量,表示相机所看的方向。m_upDir也是一个归一化的方向向量,但它仅仅存储了向上的方向。这一切很快就会变得有意义。
实现相机类
让我们看看这个类构造器长什么样:
GL_Camera::GL_Camera(const glm::vec3& l_pos, float l_fieldOfView,
float l_aspectRatio, float l_frustumNear, float l_frustumFar)
: m_position(l_pos), m_fov(l_fieldOfView),
m_aspect(l_aspectRatio), m_frustumNear(l_frustumNear),
m_frustumFar(l_frustumFar)
{
RecalculatePerspective();
m_forwardDir = glm::vec3(0.f, 0.f, 1.f);
m_upDir = glm::vec3(0.f, 1.f, 0.f);
}
除了初始化数据成员之外,构造函数有三个任务。它重新计算透视矩阵,这通常只需要做一次,除非窗口大小发生变化,并且它设置前进方向和向上方向。相机最初是朝向正Z轴,如果你这样想象的话,就是朝向屏幕的方向。向上的方向是正 Y 轴。
由于有glm库的帮助,计算透视矩阵相当简单:
void GL_Camera::RecalculatePerspective() {
m_perspectiveMatrix = glm::perspective(m_fov, m_aspect,
m_frustumNear, m_frustumFar);
}
我们的矩阵是通过glm::perspective函数构建的,它接受视场、纵横比以及两个视锥距离。
最后,我们可以获得视图投影矩阵,它仅仅是视图矩阵和投影矩阵的组合:
glm::mat4 GL_Camera::GetViewProjectionMatrix() {
glm::mat4 viewMatrix = glm::lookAt(m_position,
m_position + m_forwardDir, m_upDir);
return m_perspectiveMatrix * viewMatrix;
}
我们首先使用glm::lookAt函数计算视图矩阵。它接受相机的位置、相机所看的点以及向上的方向。之后,我们的透视矩阵和视图矩阵的乘积得到视图投影矩阵,该矩阵随后被返回以供后续使用。
更新其余的代码
由于我们的几何形状需要相对于另一个原点进行变换,我们需要更新GL_Shader类:
void GL_Shader::Update(GL_Transform& l_transform,
GL_Camera& l_camera)
{
glm::mat4 modelMatrix = l_transform.GetModelMatrix();
glm::mat4 viewProjMatrix = l_camera.GetViewProjectionMatrix();
glm::mat4 modelViewMatrix = viewProjMatrix * modelMatrix;
glUniformMatrix4fv(static_cast<GLint>(
m_uniform[static_cast<unsigned int>(UniformType::Transform)]),
1, GL_FALSE, &modelViewMatrix[0][0]);
}
因为顶点着色器已经通过变换乘以其位置,我们可以在Update()方法内部简单地更改它使用的矩阵。在获得模型矩阵后,我们也获取视图投影矩阵并将它们相乘。然后,得到的模型视图矩阵被传递到顶点着色器。
最后,需要在Game类内部创建相机:
class Game{
...
private:
...
std::unique_ptr<GL_Camera> m_camera;
};
它也需要用适当的信息进行设置:
Game::Game() ... {
...
float aspectRatio =
static_cast<float>(m_window.GetWindowSize().x) /
static_cast<float>(m_window.GetWindowSize().y);
float frustum_near = 1.f;
float frustum_far = 100.f;
m_camera = std::make_unique<GL_Camera>(
glm::vec3(0.f, 0.f, -5.f), 70.f, aspectRatio,
frustum_near, frustum_far);
}
我们首先计算窗口的宽高比,即其宽度除以高度。在设置frustum_near和frustum_far值后,它们被传递给相机的构造函数,包括其初始位置、视场角度和窗口的宽高比。
最后,我们只需要更新着色器类以包含相机的信息:
void Game::Update() {
...
m_shader->Update(m_transform, *m_camera);
}
在成功编译和执行后,我们应该看到我们的三角形稍微远离相机,因为它的位置被设置为Z轴上的-5.f。
在相机周围移动
拥有一个可编程的相机很棒,但它仍然不能让我们自由地在场景中漫游。让我们实际上给我们的相机类赋予实时操作的能力,这样我们就可以产生在世界中漂浮的错觉:
enum class GL_Direction{ Up, Down, Left, Right, Forward, Back };
class GL_Camera {
public:
...
void MoveBy(GL_Direction l_dir, float l_amount);
void OffsetLookBy(float l_speed, float l_x, float l_y);
...
};
如您所见,我们将为此使用两种方法:一种用于移动相机,另一种用于旋转它。我们还定义了一个有用的枚举,包含所有六个可能的方向。
移动位置向量相当简单。假设我们有一个表示相机速度的标量值。如果我们将其乘以一个方向向量,我们就会得到基于向量指向方向的比例位置变化,如下所示:

考虑到这一点,让我们实现MoveBy()方法:
void GL_Camera::MoveBy(GL_Direction l_dir, float l_amount) {
if (l_dir == GL_Direction::Forward) {
m_position += m_forwardDir * l_amount;
} else if (l_dir == GL_Direction::Back) {
m_position -= m_forwardDir * l_amount;
} else if (l_dir == GL_Direction::Up) {
m_position += m_upDir * l_amount;
} else if (l_dir == GL_Direction::Down) {
m_position -= m_upDir * l_amount;
} ...
}
如果我们在前后移动相机,l_amount标量值会乘以前进方向。上下移动相机同样简单,因为可以使用向上方向来实现这一点。
左右移动稍微复杂一些。我们不能只是静态地改变位置,因为相机的左或右的概念取决于我们朝哪个方向看。这就是叉积发挥作用的地方:

两个向量的叉积是一个稍微难以记忆的公式,但它非常有用。它给我们一个与向量a和b正交的向量。考虑以下图示:

正交向量是表示该向量方向与另外两个向量形成的平面垂直的一种说法。了解这一点后,我们可以相对容易地实现左右侧滑:
} else if (l_dir == GL_Direction::Left) {
glm::vec3 cross = glm::cross(m_forwardDir, m_upDir);
m_position -= cross * l_amount;
} else if (l_dir == GL_Direction::Right) {
glm::vec3 cross = glm::cross(m_forwardDir, m_upDir);
m_position += cross * l_amount;
} ...
在获得前进和向上向量的叉积后,我们只需将其乘以标量并加到相机的位置上,从而产生左右移动。
旋转相机稍微复杂一些,但并非微不足道:
void GL_Camera::OffsetLookBy(float l_speed, float l_x, float l_y)
{
glm::vec3 rotVector = glm::cross(m_forwardDir, m_upDir);
glm::mat4 rot_matrix = glm::rotate(-l_x * l_speed, m_upDir) *
glm::rotate(-l_y * l_speed, rotVector);
m_forwardDir = glm::mat3(rot_matrix) * m_forwardDir;
}
再次使用叉积来获得前进方向和向上方向向量平面的正交向量。然后通过乘以x轴和y轴的两个旋转矩阵来计算旋转矩阵。对于x轴,我们只是围绕向上方向向量旋转,如下所示:

Y轴旋转是通过绕着视图方向的正交向量和向上向量的平面旋转来实现的:

现在有了这个功能,我们可以编写实际的相机移动代码,如下所示:
void Game::Update() {
...
m_mouseDifference = sf::Mouse::getPosition(
*m_window.GetRenderWindow()) - m_mousePosition;
m_mousePosition = sf::Mouse::getPosition(
*m_window.GetRenderWindow());
float moveAmount = 0.005f;
float rotateSpeed = 0.004f;
if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) {
// Forward.
m_camera->MoveBy(GL_Direction::Forward, moveAmount);
} else if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) {
// Back.
m_camera->MoveBy(GL_Direction::Back, moveAmount);
}
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) {
// Left.
m_camera->MoveBy(GL_Direction::Left, moveAmount);
} else if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) {
// Right.
m_camera->MoveBy(GL_Direction::Right, moveAmount);
}
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Q)) {
// Up.
m_camera->MoveBy(GL_Direction::Up, moveAmount);
} else if (sf::Keyboard::isKeyPressed(sf::Keyboard::Z)) {
// Down.
m_camera->MoveBy(GL_Direction::Down, moveAmount);
}
if (sf::Mouse::isButtonPressed(sf::Mouse::Left)) {
m_camera->OffsetLookBy(rotateSpeed,
static_cast<float>(m_mouseDifference.x),
static_cast<float>(m_mouseDifference.y));
}
...
}
我们使用键盘上的W、S、A和D键来移动相机,当按下左鼠标按钮时,鼠标位置的变化作为标量值来旋转相机。
使用顶点索引绘制
在继续之前,有一件对我们来说相当重要的事情是介绍一种更高效的形状渲染方法。我们当前的方法对于渲染单个三角形来说很好,但当渲染更复杂的形状,如立方体时,它可能会变得非常低效。如果我们只使用顶点,渲染六个立方体面就需要总共36个顶点。显然,一个更有效的方法是提交每个立方体角落的八个顶点,然后重新使用它们来绘制每个面。幸运的是,我们可以通过使用索引数组来实现这一点。
使用索引简单来说,就是对于我们要绘制的每个模型,我们还需要存储一个表示顶点绘制顺序的索引数组。模型中的每个顶点都被赋予了一个索引,从0开始。然后,这些索引的数组会被用来连接顶点,而不是重新提交它们。让我们实现这个功能,从GL_Model类开始:
class GL_Model {
...
private:
...
GLuint m_indexVBO;
...
};
如新的数据成员所暗示的,我们需要将这些索引存储在自己的 VBO 中,所有这些操作都在构造函数内部完成:
GL_Model::GL_Model(GL_Vertex* l_vertices,
unsigned int l_vertCount, unsigned int* l_indices,
unsigned int l_indexCount)
{
m_drawCount = l_indexCount;
glGenVertexArrays(1, &m_VAO);
glBindVertexArray(m_VAO);
glGenBuffers(1, &m_vertexVBO);
glGenBuffers(1, &m_indexVBO);
...
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_indexVBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
l_indexCount * (sizeof(l_indices[0])),
l_indices, GL_STATIC_DRAW);
...
}
构造函数需要额外接受两个参数:一个指向索引数组的指针,以及该数组中索引的数量。注意,m_drawCount现在被设置为l_indexCount。这是因为我们只需要八个顶点来构建一个立方体模型,但是描述如何绘制它的索引有36个。
在为索引生成新的 VBO 之后,我们将其绑定并提交索引数据,基本上与之前相同。这里的主要区别是GL_ELEMENT_ARRAY_BUFFER标志。我们不能使用GL_ARRAY_BUFFER,因为索引实际上指的是位于另一个 VBO 内部的顶点数据。
显然,一旦模型不再需要,就需要释放这些新数据:
GL_Model::~GL_Model() {
glDeleteBuffers(1, &m_vertexVBO);
glDeleteBuffers(1, &m_indexVBO);
glDeleteVertexArrays(1, &m_VAO);
}
使用索引绘制我们的模型需要完全不同的Draw()调用:
void GL_Model::Draw() {
glBindVertexArray(m_VAO);
glDrawElements(GL_TRIANGLES, m_drawCount, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
调用glDrawElements()方法需要四个参数:我们将要绘制的原语类型、索引的总数、这些索引表示的数据类型,以及可以用来跳过的偏移量。
这就是使用索引绘制几何形状的全部内容!现在让我们设置一个更令人兴奋的模型来展示它:
Game::Game() ... {
...
GL_Vertex vertices[] = {
// |---POSITION----| |TEXTURE|
// X Y Z X, Y
GL_Vertex({ -0.5, -0.5, 0.5 }, { 0, 0 }), // 0
GL_Vertex({ -0.5, 0.5, 0.5 }, { 0, 1 }), // 1
GL_Vertex({ 0.5, 0.5, 0.5 }, { 1, 1 }), // 2
GL_Vertex({ 0.5, -0.5, 0.5 }, { 1, 0 }), // 3
GL_Vertex({ -0.5, -0.5, -0.5f }, { 1, 0 }), // 4
GL_Vertex({ -0.5, 0.5, -0.5f }, { 1, 1 }), // 5
GL_Vertex({ 0.5, 0.5, -0.5f }, { 0, 0 }), // 6
GL_Vertex({ 0.5, -0.5, -0.5f }, { 0, 1 }) // 7
};
unsigned int indices[] = {
2, 1, 0, 0, 3, 2, // Back
5, 4, 0, 0, 1, 5, // Right
3, 7, 6, 6, 2, 3, // Left
6, 7, 4, 4, 5, 6, // Front
1, 2, 6, 6, 5, 1, // Top
0, 4, 7, 7, 3, 0 // Bottom
};
m_model = std::make_unique<GL_Model>(vertices, 8, indices, 36);
...
}
如您所见,我们现在已经设置了8个顶点,并为索引创建了一个新的数组。一旦模型渲染完成,我们会看到类似这样的效果:

注意,由于某种原因,底面实际上被渲染在了上面。这是由于 OpenGL 不知道应该渲染哪个几何体在顶部,这个问题将在下一节中解决。
面剔除和深度缓冲区
解决绘制顺序问题的方法之一是使用深度缓冲区。简单来说,深度缓冲区,也常被称为Z 缓冲区,是 OpenGL 在后台管理的一个纹理,它包含每个像素的深度信息。当渲染一个像素时,它的深度(Z值)会与深度缓冲区中的值进行比较。如果一个像素的Z值更低,那么这个像素会被覆盖,因为它显然在顶部。
启用深度缓冲区只需要一个glEnable()方法调用:
Game::Game() ... {
...
glEnable(GL_DEPTH_TEST);
...
}
请记住,深度缓冲区是一个纹理。确保在创建窗口时分配它,并且它有足够的数据来工作是非常重要的。我们可以通过创建一个sf::ContextSettings结构并在将其传递给 SFML 窗口的Create()方法之前填充其depthBits数据成员来确保这一点:
void GL_Window::Create() {
...
sf::ContextSettings settings;
settings.depthBits = 32; // 32 bits.
settings.stencilBits = 8;
settings.antialiasingLevel = 0;
settings.majorVersion = 4;
settings.minorVersion = 5;
m_window.create(sf::VideoMode(m_windowSize.x,
m_windowSize.y, 32), m_windowTitle, style, settings);
}
如果我们直接运行代码,屏幕会完全变空白。为什么?记住,Z 缓冲区是一个纹理。就像显示一样,每个周期都需要清除纹理。我们可以这样完成:
void GL_Window::BeginDraw() {
glClearColor(0.f, 0.f, 0.f, 1.f); // BLACK
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
添加管道符号允许我们对glClear的参数执行位或操作,加入GL_DEPTH_BUFFER_BIT定义。这确保了深度缓冲区也被清空为黑色,我们最终可以享受我们的立方体:

背面剔除
为了提高性能,一个好的做法是让 OpenGL 知道我们希望剔除从当前视角不可见的面。这个功能可以通过以下方式启用:
Game::Game() ... {
...
glEnable(GL_DEPTH_TEST);
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
...
}
在我们调用glEnable启用面剔除之后,glCullFace函数被调用以让 OpenGL 知道哪些面需要剔除。这会直接生效,但如果我们的模型数据设置不正确,我们可能会注意到一些奇怪的效果,如下所示:

这是因为我们渲染顶点的顺序实际上定义了一个几何体面的朝向是向内还是向外。例如,如果一个面的顶点以顺时针顺序渲染,那么默认情况下,这个面被认为是向模型内部朝向的,反之亦然。考虑以下图示:

正确设置模型绘制顺序可以让我们通过不绘制不可见面来节省性能,并且让我们的立方体恢复原状。
概述
这可能需要吸收很多信息,但如果你已经坚持到了最后,恭喜你!艰难的部分现在已经结束,你对现代版本的 OpenGL、可编程管道和通用渲染已经非常熟悉。甚至 SFML 本身也是基于我们讨论过的基本原理构建的,其中一些我们已经进行了广泛的探讨。
在下一章中,我们将介绍光照的基础知识,以使我们的世界更具动态感。那里见!
第八章. 光之诞生 - 高级光照简介
在这个时代,对游戏有一定的标准期望。随着技术的进步和任何给定计算单元中晶体管数量的增加,我们可用的能力越来越多,可以做到以前无法想象的事情。肯定利用了所有这些额外马力的就是动态光照。由于其惊人的视觉效果,它已经成为大多数视频游戏的一个基本组成部分,现在也是人们期望与之一起到来的核心技术之一。
在本章中,我们将涵盖以下主题:
-
使用延迟渲染/着色技术
-
实现多遍光照着色器
-
使用法线图伪造几何复杂性
-
使用高光图创建闪亮的表面
-
使用高度图使光照感觉更立体
让我们开始探讨这个主题的细节!
使用第三方软件
本章中使用的所有材料图都不是手工绘制的。为了生成某些材料图,使用了Crazybump,可以在crazybump.com/找到。
在网上还可以找到其他免费替代方案。
延迟渲染
延迟渲染/着色是一种技术,它通过不在第一次遍历中启用某些效果来让我们对场景中某些效果的应用有更大的控制权。相反,场景可以被渲染到一个离屏缓冲区中,以及其他包含相同图像的其他材料类型的缓冲区,然后在效果应用之后,可能是在多个遍历之后,绘制到屏幕上。使用这种方法允许我们将某些逻辑与我们的主渲染代码分离和模块化。它还给我们提供了将尽可能多的效果应用到最终图像的机会。让我们看看实现这项技术需要什么。
修改渲染器
为了支持我们即将利用的所有新技巧,我们需要对我们的渲染器做一些修改。它应该能够保持一个缓冲纹理,并在多个遍历中渲染到它,以创建我们想要的光照:
class Renderer {
friend Window;
public:
...
void EnableDeferredRendering();
void BeginSceneRendering();
void BeginTextureRendering();
sf::RenderTexture* GetCurrentTexture();
sf::RenderTexture* GetFinishedTexture();
void SwapTextures();
void ClearCurrentTexture();
void ClearFinishedTexture();
void ClearTextures();
void DrawBufferTexture();
void DisableDeferredRendering();
...
private:
void CreateTextures();
...
sf::Shader* m_currentShader;
sf::RenderTexture* m_currentTexture;
sf::RenderTexture m_texture1;
sf::RenderTexture m_texture2;
...
bool m_deferred;
};
为了方便起见,我们有一些方法可以切换延迟渲染过程。此外,由于渲染场景到纹理与渲染纹理到另一个纹理略有不同,因为相机的(视图)位置不同,我们将使用BeginSceneRendering()和BeginTextureRendering()方法来正确处理这项任务。
注意这个类中使用了两个纹理,以及一个指向当前正在使用的纹理的指针。多遍方法的核心是能够在绘制当前渲染目标的同时采样包含前一次渲染遍历信息的纹理。
最后,我们将讨论三种清除当前纹理、前一个渲染通道的纹理以及这两个纹理的方法。然后,可以通过调用DrawBufferTexture()方法来渲染最新的渲染通道纹理。
在Renderer类中实现更改
让我们从简单的事情开始。实现延迟渲染切换方法;它们将帮助您跟踪当前的渲染状态:
void Renderer::EnableDeferredRendering() {
if (!m_useShaders) { return; }
m_deferred = true;
}
void Renderer::DisableDeferredRendering() { m_deferred = false; }
如您所见,这就像翻转一个标志一样简单。在启用延迟渲染的情况下,我们还需要检查是否允许使用着色器。
此外,我们用作缓冲区的纹理显然需要被创建:
void Renderer::CreateTextures() {
if (!m_useShaders) { return; }
m_texture1.create(m_screenSize.x, m_screenSize.y);
m_texture2.create(m_screenSize.x, m_screenSize.y);
ClearTextures();
m_texture1.display();
m_texture2.display();
m_currentTexture = &m_texture1;
}
这种特定方法是在Renderer构造函数中调用的。
接下来,我们有一些同样简单但更为重要的内容:
void Renderer::BeginSceneRendering() {
auto& view = m_window->GetRenderWindow()->getView();
m_currentTexture->setView(view);
}
void Renderer::BeginTextureRendering() {
auto& view = m_window->GetRenderWindow()->getDefaultView();
m_currentTexture->setView(view);
}
通过在适当的时间使用这些方法,我们可以成功绘制出具有世界坐标的形状,这些形状对于一个独立纹理缓冲区来说大小为窗口大小。我们也可以简单地从另一个窗口大小的纹理中绘制信息到缓冲区。
一些有用的获取器方法总是很有用的:
sf::RenderTexture* Renderer::GetCurrentTexture() {
if (!m_useShaders) { return nullptr; }
return m_currentTexture;
}
sf::RenderTexture* Renderer::GetFinishedTexture() {
if (!m_useShaders) { return nullptr; }
if (!m_currentTexture) { return nullptr; }
return (m_currentTexture == &m_texture1 ?
&m_texture2 : &m_texture1);
}
虽然第一个方法只是返回当前正在使用的缓冲纹理的指针,但第二个方法则正好相反。它确定哪个纹理不是当前缓冲区;一旦确定,它就返回该对象的指针。为什么这会如此有用,我们很快就会明白。
清除这些纹理就像人们想象的那样简单:
void Renderer::ClearCurrentTexture() {
if (!m_useShaders) { return; }
if (!m_currentTexture) { return; }
m_currentTexture->clear();
}
void Renderer::ClearFinishedTexture() {
if (!m_useShaders) { return; }
auto texture = GetFinishedTexture();
if (!texture) { return; }
texture->clear();
}
void Renderer::ClearTextures() {
if (!m_useShaders) { return; }
m_texture1.clear();
m_texture2.clear();
}
为了准备另一个渲染通道并显示对第一个缓冲区所做的所有更改,纹理必须这样交换:
void Renderer::SwapTextures() {
if (!m_useShaders) { return; }
if (m_currentTexture) { m_currentTexture->display(); }
if (m_currentTexture != &m_texture1) {
m_currentTexture = &m_texture1;
} else {
m_currentTexture = &m_texture2;
}
}
注意对纹理的display方法的调用。调用display是必需的,因为我们希望纹理所做的所有更改都能反映出来。如果不调用此方法,我们的进度就不会显现。
对这个类的一个关键修改是确保在启用延迟渲染时使用缓冲纹理:
void Renderer::Draw(const sf::Drawable& l_drawable,
sf::RenderTarget* l_target)
{
if (!l_target) {
if (!m_deferred || !m_useShaders) {
l_target = m_window->GetRenderWindow();
} else { l_target = m_currentTexture; }
}
sf::RenderStates states = sf::RenderStates::Default;
if (m_addBlend) { states.blendMode = sf::BlendAdd; }
if (m_useShaders && m_currentShader) {
states.shader = m_currentShader;
}
l_target->draw(l_drawable, states);
++m_drawCalls;
}
在进行几项检查以确保我们不会覆盖已提供的渲染目标并且着色器使用已启用之后,我们通过覆盖l_target指针的地址来选择缓冲纹理。
最后,包含所有渲染通道信息的缓冲纹理可以这样绘制到屏幕上:
void Renderer::DrawBufferTexture() {
if (!m_useShaders) { return; }
auto texture = GetFinishedTexture();
if (!texture) { return; }
m_sprite.setTexture(texture->getTexture());
Draw(m_sprite);
}
这种简单而强大的设计为我们提供了实现几乎所有可想象的后处理效果的可行性。
一个最小示例
偶然的一个效果,本章的重点是动态照明。在我们进一步实现更高级的功能或深入研究更复杂的概念之前,让我们逐步了解使用新实现的渲染器功能的过程。让我们一步一步来。
首先,场景应该像往常一样绘制到纹理缓冲区:
renderer->EnableDeferredRendering();
renderer->UseShader("default");
renderer->BeginSceneRendering();
for (unsigned int i = 0; i < Sheet::Num_Layers; ++i) {
context->m_gameMap->Draw(i);
context->m_systemManager->Draw(window, i);
particles->Draw(window, i);
}
particles->Draw(window, -1);
renderer->SwapTextures();
如你所见,一旦启用延迟渲染,将使用默认的着色器,场景渲染过程开始。对于每一层,地图、实体和粒子都像往常一样绘制。现在唯一的区别是,在幕后使用缓冲纹理。一旦一切都被渲染,纹理被交换;这允许当前的背缓冲纹理显示所有变化:
renderer->BeginTextureRendering();
if(renderer->UseShader("LightPass")) {
auto shader = renderer->GetCurrentShader();
auto time = context->m_gameMap->GetTimeNormal();
shader->setUniform("AmbientLight",
sf::Glsl::Vec3(time, time, time));
sf::Vector3f lightPos(700.f, 300.f, 10.f);
sf::Vector2i screenPos = window->GetRenderWindow()->
mapCoordsToPixel({ lightPos.x, lightPos.y });
shader->setUniform("LightPosition",
sf::Glsl::Vec3(screenPos.x, window->GetWindowSize().y -
screenPos.y, lightPos.z));
shader->setUniform("LightColor",
sf::Glsl::Vec3(0.1f, 0.1f, 0.1f));
shader->setUniform("LightRadius", 128.f);
shader->setUniform("texture",
renderer->GetFinishedTexture()->getTexture());
auto size = context->m_wind->GetWindowSize();
sf::VertexArray vertices(sf::TrianglesStrip, 4);
vertices[0] = sf::Vertex(sf::Vector2f(0, 0),
sf::Vector2f(0, 1));
vertices[1] = sf::Vertex(sf::Vector2f(size.x, 0),
sf::Vector2f(1, 1));
vertices[2] = sf::Vertex(sf::Vector2f(0, size.y),
sf::Vector2f(0, 0));
vertices[3] = sf::Vertex(sf::Vector2f(size),
sf::Vector2f(1, 0));
renderer->Draw(vertices);
renderer->SwapTextures();
}
一旦场景被渲染,我们就进入从现在开始将被称作光照通道的阶段。这个特殊的通道使用它自己的着色器,并负责场景的照明。它设置了所谓的环境光以及常规的全向光。
注意
环境光是一种没有位置的光。它均匀地照亮场景的任何部分,无论距离如何。
如前述代码所示,点光源首先将它的世界坐标转换为屏幕空间坐标,然后作为统一变量传递给着色器。然后,将光颜色和半径传递给着色器,以及前一个通道的纹理,在这个例子中,仅仅是场景的颜色(漫反射)图。
注意
点光源是一种从单个点向所有方向(全向)发射光的光源,形成一个照明球体。
小贴士
屏幕空间坐标系中的Y轴与世界坐标格式相反,这意味着正的Y值向上而不是向下。这就是为什么在传递给着色器之前必须调整光位置Y坐标的原因。
代码的下一部分主要是用来触发将漫反射纹理完整重绘到缓冲纹理上的操作。我们创建了一个由两个三角形条组成的四边形,表示为sf::VertexArray。它的大小被设置为整个窗口的大小,以确保所有像素都能被重绘。一旦四边形被绘制,纹理再次交换以反映所有变化:
renderer->DisableDeferredRendering();
window->GetRenderWindow()->setView(
window->GetRenderWindow()->getDefaultView());
renderer->DrawBufferTexture();
window->GetRenderWindow()->setView(currentView);
renderer->DisableShader();
这个例子中的最后一部分只是关闭了延迟渲染,这样从现在开始所有的渲染操作都将对窗口进行。然后设置窗口视图为默认状态,以便缓冲纹理可以轻松地绘制到屏幕上。最后,在禁用任何仍然活跃的着色器之前,我们重新设置了视图。
着色器代码
我们几乎完成了!这个谜题中最后但绝对不是最不重要的一个部分是正确编写光照通道着色器以获得正确的结果。鉴于我们已经在 C++代码中对光照过程有所了解,让我们看看 GLSL 能提供什么:
uniform sampler2D texture;
uniform vec3 AmbientLight;
uniform vec3 LightPosition;
uniform vec3 LightColor;
uniform float LightRadius;
void main()
{
vec4 pixel = texture2D(texture, gl_TexCoord[0].xy);
float dist = sqrt(
pow(LightPosition.x - gl_FragCoord.x, 2) +
pow(LightPosition.y - gl_FragCoord.y, 2) +
pow(LightPosition.z - gl_FragCoord.z, 2));
vec4 finalPixel;
if(dist <= LightRadius)
finalPixel = (gl_Color * pixel) +
(pixel * vec4(LightColor, 1.0));
else
finalPixel = (gl_Color * pixel) * vec4(AmbientLight, 1.0);
gl_FragColor = finalPixel;
}
如预期的那样,我们需要在这个通道中处理漫反射纹理以保留颜色信息。其他统一值包括表示环境颜色的 3D 向量,两个表示常规光源位置和颜色的 3D 向量,以及表示相同光源半径的浮点值。
传递给着色器的纹理在适当的插值纹理坐标处采样,并存储在 pixel 变量中。然后使用勾股定理距离公式计算正在处理的像素与光源中心的距离:

注意
gl_FragCoord 参数在屏幕空间中持有像素坐标。它的 Z 分量是一个深度值,目前我们不会使用它。
小贴士
pow 函数简单地返回一个值,该值是其第二个参数的幂。
在计算距离之后,会进行检查以确定我们正在处理的光与像素之间的距离是否在光源的半径内。如果是,我们的像素的颜色信息将与光颜色相乘并添加到最终要写入的像素中。否则,颜色信息将简单地乘以环境颜色。
这个相当基本的原则给我们带来了,正如预期的那样,相当基本和非现实的光照效果:

虽然它可行,但在现实中,光线是向所有方向发射的。它也会逐渐失去亮度。让我们看看在我们的游戏中实现这一效果需要什么。
光线衰减
光线衰减,也称为强度逐渐减弱,是我们将在创建光源逐渐减弱效果时使用的技术。它本质上归结为在光线通过着色器中使用另一个公式。有许多衰减光线的变体适用于不同的目的。让我们看看:
uniform sampler2D texture;
uniform vec3 AmbientLight;
uniform vec3 LightPosition;
uniform vec3 LightColor;
uniform float LightRadius;
uniform float LightFalloff;
void main()
{
vec4 pixel = texture2D(texture, gl_TexCoord[0].xy);
// Nornalized light vector and distance to the light surface.
vec3 L = LightPosition - gl_FragCoord.xyz;
float distance = length(L);
float d = max(distance - LightRadius, 0);
L /= distance;
// calculate basic attenuation
float attenuation = 1 / pow(d/LightRadius + 1, 2);
attenuation = (attenuation - LightFalloff) / (1 - LightFalloff);
attenuation = max(attenuation, 0);
vec4 finalPixel = (gl_Color * pixel);
finalPixel *= vec4(AmbientLight, 1.0); // IF FIRST PASS ONLY!
finalPixel += (pixel * vec4(LightColor, 1.0) * attenuation);
gl_FragColor = finalPixel;
}
再次强调,我们处理的是相同的统一值,但增加了一个额外的 LightFalloff 值。这是一个介于 0 和 1 之间的因子,决定了光源亮度衰减的速度。
在 main() 函数内部,漫反射像素按常规采样。这在我们计算表示像素与光源中心位置差异的向量 L 之前完成。然后使用 length 函数将此向量转换为距离。这是我们在这个着色器的第一次迭代中手动计算的那种类型的距离。然后使用浮点数变量 d 通过从光源半径中减去它来计算片段与光源外部的距离。max() 函数确保如果像素在光源的气泡内部,我们不会得到一个负值。
如前所述,衰减本身可以有多种变体。这种特定的变体在视觉上最适合我们正在处理的类型游戏。
在计算完成后,最终输出像素乘以环境光(如果有多遍光遍历,则仅在第一遍执行)。此外,光信息乘以漫反射像素,并将衰减因子加到它上面。这次乘法确保了,如果像素在有效光范围内,则不会添加额外的光。结果是稍微更吸引人:

在这个阶段,你可以提出一个非常好的问题:“究竟如何才能处理多个光源输入?”幸运的是,这比想象中要简单一些。
多遍着色
就像 C/C++ 代码一样,GLSL 也支持使用数据数组。使用它们似乎是一个明显的选择,只需将关于多个光流的信息推送到着色器,并在一次遍历中完成所有操作。然而,与 C++ 不同的是,GLSL 需要在编译时知道这些数组的大小,这非常类似于 C。截至写作时,动态大小数组还不受支持。虽然这个信息可能会让处理多个光源的简单计划变得困难,但显然还是有其他选择。
一种对抗这种情况的方法可能是使用一个非常大的、静态大小的数据数组。只有其中的一部分数据会被填充,着色器会通过遍历数组来处理它,同时使用一个统一的整数来告诉它实际传递给它多少个光源。这个想法有几个明显的瓶颈。首先,屏幕上允许的最大光流数量会有一个阈值。第二个问题是性能。将数据发送到 GPU 是昂贵的,如果我们一次性发送过多的信息,这会迅速变得低效。
尽管第一个想法有缺陷,但它有一个在考虑更好的策略时很有用的组成部分:允许的最大光流数量。为什么不一次只发送一小部分数据,在不同的遍历中进行呢?如果每次发送正确数量的光流,CPU 和 GPU 的性能瓶颈都可以最小化。然后,可以将每个遍历的结果混合成一个单一的纹理。
修改光遍历着色器
为了正确混合多个遍历的缓冲区纹理,我们需要克服一些挑战。首先,由于环境光,信息会有损失。如果光线太暗,后续的遍历就会越来越不明显。为了解决这个问题,除了最后渲染遍历的颜色信息外,我们还需要访问实际的漫反射图。
第二个问题是选择每个着色器传递的光流数量。这可以通过基准测试或简单地通过试错得到正确答案。就我们的目的而言,我们将选择每个传递 3-4 条光流。让我们看看如何修改光着色器以实现这一点:
uniform sampler2D LastPass;
uniform sampler2D DiffuseMap;
uniform vec3 AmbientLight;
uniform int LightCount;
uniform int PassNumber;
struct LightInfo {
vec3 position;
vec3 color;
float radius;
float falloff;
};
const int MaxLights = 3;
uniform LightInfo Lights[MaxLights];
首先,注意新的 sampler2D 统一类型,它被传递给漫反射图。这将非常有价值,以避免在额外的传递中光颜色被冲淡。我们将需要的其他两块额外信息是确定发送到当前传递的着色器的光流数量的值,以及我们目前正在处理的传递。
实际的光信息现在被整洁地存储在一个 struct 中,该 struct 包含我们期望的常规数据。在其下方,我们需要声明一个表示每个着色器传递最大光流数量的常量整数,以及将被我们的 C++代码填充光信息的统一数组。
让我们看看着色器主体需要经历哪些变化才能支持这一点:
void main()
{
vec4 pixel = texture2D(LastPass, gl_TexCoord[0].xy);
vec4 diffusepixel = texture2D(DiffuseMap, gl_TexCoord[0].xy);
vec4 finalPixel = gl_Color * pixel;
if(PassNumber == 1) { finalPixel *= vec4(AmbientLight, 1.0); }
// IF FIRST PASS ONLY!
for(int i = 0; i < LightCount; ++i) {
vec3 L = Lights[i].position - gl_FragCoord.xyz;
float distance = length(L);
float d = max(distance - Lights[i].radius, 0);
L /= distance;
float attenuation = 1 / pow(d/Lights[i].radius + 1, 2);
attenuation = (attenuation - Lights[i].falloff) /
(1 - Lights[i].falloff);
attenuation = max(attenuation, 0);
finalPixel += diffusepixel *
((vec4(Lights[i].color, 1.0) * attenuation));
}
gl_FragColor = finalPixel;
}
首先,我们需要采样漫反射像素以及来自前一个着色器传递的像素。finalPixel 变量在早期就建立了,并使用前一个着色器传递的信息。请注意这一点,因为否则前一个传递将会丢失。由于我们现在可以在着色器中访问传递编号,因此我们可以在第一个传递期间仅对像素应用环境光。
然后,我们可以跳入一个 for 循环,该循环使用从 C++端传入的 LightCount 常量。这种设计让我们能够只使用发送到着色器的数据,而不会过度使用,如果最后一个着色器传递的光流数量少于允许的最大数量。
最后,让我们看看在片段的实际着色方面需要做出哪些改变。我们的所有计算保持不变,只是现在使用光数据。灯光常量现在通过方括号访问,以在循环的每次迭代中获取正确的信息。注意循环底部的最终像素计算。现在它使用漫反射像素而不是前一个着色器传递的像素。
C++代码中的更改
我们刚刚完成的 GLSL 中的所有花哨之处,如果没有我们实际代码库的适当支持,都是不完整的。首先,让我们从简单的东西开始,并方便地在一个合适的 struct 中表示光流:
struct LightBase {
LightBase(const sf::Vector3f& l_pos,
const sf::Vector3f& l_color, float l_rad, float l_fall)
: m_lightPos(l_pos), m_lightColor(l_color), m_radius(l_rad),
m_falloff(l_fall) {}
LightBase(const sf::Vector3f& l_color): m_lightColor(l_color) {}
sf::Vector3f m_lightPos;
sf::Vector3f m_lightColor;
float m_radius;
float m_falloff;
};
现在好了!现在让我们开始将所有额外的信息传递给着色器本身:
... // Diffuse pass.
renderer->SwapTextures();
auto DiffuseImage = renderer->GetFinishedTexture()->
getTexture().copyToImage();
DiffuseImage.flipVertically();
auto DiffuseTexture = sf::Texture();
DiffuseTexture.loadFromImage(DiffuseImage);
renderer->BeginTextureRendering();
...
std::vector<LightBase> lights;
// {Position}, {Color}, Radius, Falloff
lights.push_back({ { 700.f, 350.f, 10.f }, { 1.f, 0.f, 0.f },
128.f, 0.005f });
lights.push_back({ { 600.f, 350.f, 10.f }, { 0.f, 1.f, 0.f },
128.f, 0.005f });
lights.push_back({ { 500.f, 350.f, 10.f }, { 0.f, 0.f, 1.f },
128.f, 0.005f });
lights.push_back({ { 400.f, 600.f, 10.f },{ 1.f, 0.f, 0.f },
128.f, 0.005f });
lights.push_back({ { 300.f, 600.f, 10.f },{ 0.f, 1.f, 0.f },
128.f, 0.005f });
lights.push_back({ { 200.f, 600.f, 10.f },{ 0.f, 0.f, 1.f },
128.f, 0.005f });
lights.push_back({ { 600.f, 550.f, 0.f }, { 1.f, 1.f, 1.f },
128.f, 0.005f });
const int LightsPerPass = 3;
在我们完成绘制到漫反射纹理之后,它被复制并存储在一个单独的缓冲区中。然后它沿 Y 轴翻转,因为复制过程会反转它。
小贴士
这里纹理的复制和翻转只是一个概念验证。它不应该在生产代码中执行,因为它非常低效。
到目前为止,我们已经准备好开始光照遍历。在我们开始之前,确保添加了一些光照流到std::vector中,并等待传递。此外,在底部声明一个常量,表示每次传递给着色器的光照流数量。这个数字必须与着色器内部的常量匹配。
让我们从实际的轻量级遍历开始,看看它包含哪些内容:
if (renderer->UseShader("LightPass")) {
renderer->BeginTextureRendering();
auto shader = renderer->GetCurrentShader();
shader->setUniform("AmbientLight",
sf::Glsl::Vec3(0.f, 0.f, 0.2f));
int i = 0;
int pass = 0;
auto lightCount = lights.size();
for (auto& light : lights) {
std::string id = "Lights[" + std::to_string(i) + "]";
sf::Vector2i screenPos = window->GetRenderWindow()->
mapCoordsToPixel({light.m_lightPos.x, light.m_lightPos.y});
shader->setUniform(id + ".position", sf::Glsl::Vec3(
screenPos.x, window->GetWindowSize().y - screenPos.y,
light.m_lightPos.z));
shader->setUniform(id + ".color",
sf::Glsl::Vec3(light.m_lightColor));
shader->setUniform(id + ".radius", light.m_radius);
shader->setUniform(id + ".falloff", light.m_falloff);
++i;
if (i < LightsPerPass && (pass * LightsPerPass) + i
< lightCount)
{ continue; }
shader->setUniform("LightCount", i);
i = 0;
shader->setUniform("PassNumber", pass + 1);
shader->setUniform("LastPass",
renderer->GetFinishedTexture()->getTexture());
shader->setUniform("DiffuseMap", DiffuseTexture);
renderer->Draw(vertices);
renderer->SwapTextures();
renderer->BeginTextureRendering();
++pass;
}
}
...
环境光照首先设置,因为它在迭代之间不会改变。在这种情况下,我们给它一点蓝色调。此外,还创建了几个用于迭代和遍历的局部变量,以便随时获取这些信息。
当我们遍历每个光照流时,会创建一个名为id的字符串,其中包含每次迭代的整数。这是为了表示着色器内部光照流的统一数组访问分析,它将作为帮助我们访问和覆盖这些数据的有用方式。然后使用带有附加点操作符的id字符串和struct数据成员的名称,通过id字符串传递光照信息。不久之后,光照标识符i会增加。在这个时候,我们需要决定是否已经处理了所需数量的光照流,以便调用着色器。如果已经添加了遍历的最后一个光照流,或者我们正在处理场景的最后一个光照流,那么将初始化其余的统一变量,并绘制我们之前提到的全屏sf::VertexArray四边形,为每个可见像素调用一个着色器。这实际上给我们一个这样的结果:

现在我们有进展了!这个方法的唯一缺点是我们必须处理 C++代码中的所有混乱,因为所有这些数据都没有得到妥善管理。现在就让我们来解决这个问题吧!
管理光照输入
在软件设计的各个方面,良好的数据组织都很重要。很难想象一个运行快速且高效的程序,同时希望在后台运行一个强大、强大且灵活的框架。到目前为止,我们的情况相当容易管理,但想象一下,如果你想为地图、实体以及所有的粒子绘制额外的纹理。这很快就会变得令人厌烦,难以维护。是时候利用我们的工程智慧,提出一个更好的系统了。
适用于轻量级用户
首要的是,任何希望使用我们的光照引擎的类都需要实现它们自己的版本,将某些类型的纹理绘制到缓冲区(s)。对于漫反射贴图,我们已经有普通的Draw调用,但即使它们都有相同的签名,这也还不够好。这些类需要一个通用的接口,以便它们成为光照家族中成功的部分:
class LightManager;
class Window;
class LightUser {
friend class LightManager;
virtual void Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer) = 0;
};
LightUser类强制任何派生类实现一个特殊的Draw方法,该方法使用材料容器。它还可以访问Window类,并知道它试图绘制到哪个层。你可能会问:“什么是材料容器?”让我们通过进一步探讨这个设计来找出答案。
光管理器类
在我们设计一个可以处理所有照明需求的大类之前,让我们谈谈材料。碰巧的是,我们已经处理过一种类型的材料:漫反射贴图。我们还将使用许多其他可能的材料,所以我们不再绕弯子,来看看它们是什么:
enum class MaterialMapType { Diffuse, Height, Normal,
Specular, COUNT };
using MaterialMapContainer = std::unordered_map<
MaterialMapType, std::unique_ptr<sf::RenderTexture>>;
除了漫反射贴图,我们还将构建高度、法线和镜面贴图。现在这些术语可能都不太容易理解,但没关系。随着我们一步步深入,每个术语都会被详细解释。
材料贴图容器类型只是一个将类型链接到sf::RenderTexture的映射。这样,我们可以为每种材料类型拥有一个单独的纹理。
对于光管理器,我们只需要两个类型定义:
using LightContainer = std::vector<LightBase>;
using LightUserContainer = std::vector<LightUser*>;
如您所见,它们极其简单。我们将存储光流本身以及指向光用户类的指针,因为这里不需要更复杂的东西。有了这个,让我们看看LightManager类的实际定义:
class Window;
class LightManager {
public:
LightManager(Window* l_window);
void AddLight(const LightBase& l_light);
void AddLightUser(LightUser* l_user);
LightBase* GetAmbientLight();
void RenderMaterials();
void RenderScene();
const unsigned int LightsPerPass = 4;
protected:
MaterialMapContainer m_materialMaps;
private:
void ClearAll();
void SetViews();
void DisplayAll();
LightBase m_ambientLight;
LightContainer m_lights;
LightUserContainer m_users;
sf::VertexArray m_fullScreenQuad;
Window* m_window;
};
如您所见,这已经是最基本的了。构造函数接收一个指向Window类的指针。我们为轻度用户提供了几个add方法,以及轻量级流本身。我们还为特定任务提供了一些渲染方法。注意这个类定义的常量整数,它表示每个着色器通道允许的最大光流数量。像我们之前那样只渲染三个光流有点浪费,所以这个数字可以进一步提高,前提是不会对处理过程的性能产生不利影响。
其中有三个辅助方法--处理清除缓冲区纹理、设置它们的视图以及显示对它们的更改。我们还存储了我们将用于执行光通操作的四边形的sf::VertexArray。
实现光管理器
和往常一样,让我们先看看当创建光管理器时需要构建什么:
LightManager::LightManager(Window* l_window) : m_window(l_window),
m_ambientLight({ 0.f, 0.f, 0.f })
{
auto windowSize = l_window->GetWindowSize();
for (auto i = 0;
i < static_cast<int>(MaterialMapType::COUNT); ++i)
{
auto pair = m_materialMaps.emplace(
static_cast<MaterialMapType>(i),
std::move(std::make_unique<sf::RenderTexture>()));
auto& texture = pair.first->second;
texture->create(windowSize.x, windowSize.y);
}
m_fullScreenQuad = sf::VertexArray(sf::TriangleStrip, 4);
m_fullScreenQuad[0] = sf::Vertex(
sf::Vector2f(0, 0), sf::Vector2f(0, 1));
m_fullScreenQuad[1] = sf::Vertex(
sf::Vector2f(windowSize.x, 0), sf::Vector2f(1, 1));
m_fullScreenQuad[2] = sf::Vertex(
sf::Vector2f(0, windowSize.y), sf::Vector2f(0, 0));
m_fullScreenQuad[3] = sf::Vertex(
sf::Vector2f(windowSize), sf::Vector2f(1, 0));
}
初始化列表对于存储Window指针以及将环境光照初始化为绝对黑色很有用。一旦完成,就获取窗口大小并创建所有材料纹理。最后,设置窗口大小的四边形以供以后使用。
添加器和获取器方法相当简单,但它们是必要的:
void LightManager::AddLight(const LightBase& l_light) {
m_lights.push_back(l_light);
}
void LightManager::AddLightUser(LightUser* l_user) {
m_users.emplace_back(l_user);
}
LightBase* LightManager::GetAmbientLight() {
return &m_ambientLight;
}
处理所有材料贴图一次在打字上可能会相当浪费,因此我们需要一些方法来帮助我们快速完成这项工作:
void LightManager::ClearAll() {
for (auto& map : m_materialMaps) { map.second->clear(); }
}
void LightManager::SetViews() {
auto view = m_window->GetRenderWindow()->getView();
for (auto& map : m_materialMaps) { map.second->setView(view); }
}
void LightManager::DisplayAll() {
for (auto& map : m_materialMaps) { map.second->display(); }
}
注意SetViews()中使用的视图。由于这些材料贴图将代替窗口使用,它们必须使用窗口的视图来处理所有被绘制视觉的世界坐标。
说到材质贴图,任何希望使用我们的光照管理器的类都应该能够绘制到其中的每一个。幸运的是,我们已经通过使这些类实现一个纯虚Draw方法来简化了这一点:
void LightManager::RenderMaterials() {
ClearAll();
SetViews();
// Render each elevation in proper order.
for (auto i = 0; i < Sheet::Num_Layers; ++i) {
for (auto& user : m_users) {
user->Draw(m_materialMaps, *m_window, i);
}
}
// Render everything above allowed height.
for (auto& user : m_users) {
user->Draw(m_materialMaps, *m_window, -1);
}
DisplayAll();
}
在所有纹理都被清除并设置其视图之后,每个光照用户都需要为游戏引擎支持的每个允许的层绘制一些内容。实际上,在这些高度之上的任何视觉元素也需要有机会被渲染,这可以通过使用第二个循环来实现。然后通过调用DisplayAll()方法更新所有材质纹理。
材质绘制完成后,我们需要进行与我们在最小代码示例中所做的多通道着色相同的处理过程:
void LightManager::RenderScene() {
auto renderer = m_window->GetRenderer();
auto window = m_window->GetRenderWindow();
auto size = window->getSize();
auto currentView = window->getView();
renderer->EnableDeferredRendering();
if (renderer->UseShader("LightPass")) {
// Light pass.
auto shader = renderer->GetCurrentShader();
shader->setUniform("AmbientLight",
sf::Glsl::Vec3(m_ambientLight.m_lightColor));
shader->setUniform("DiffuseMap",
m_materialMaps[MaterialMapType::Diffuse]->getTexture());
...
int LightID = 0;
int pass = 0;
for (auto& light : m_lights) {
std::string id = "Lights[" + std::to_string(LightID) + "]";
sf::Vector2i screenPos = window->mapCoordsToPixel(
{ light.m_lightPos.x, light.m_lightPos.y }, currentView);
float y = static_cast<float>(
static_cast<int>(size.y) - screenPos.y);
shader->setUniform(id + ".position",
sf::Glsl::Vec3(screenPos.x, y, light.m_lightPos.z));
shader->setUniform(id + ".color",
sf::Glsl::Vec3(light.m_lightColor));
shader->setUniform(id + ".radius", light.m_radius);
shader->setUniform(id + ".falloff", light.m_falloff);
++LightID;
if (LightID < LightsPerPass && (pass * LightsPerPass)
+ LightID < m_lights.size())
{ continue; }
renderer->BeginTextureRendering();
shader->setUniform("LightCount", LightID);
LightID = 0;
shader->setUniform("PassNumber", pass + 1);
if (pass == 0) {
shader->setUniform("LastPass",
m_materialMaps[MaterialMapType::Diffuse]->getTexture());
} else {
shader->setUniform("LastPass",
renderer->GetFinishedTexture()->getTexture());
}
renderer->Draw(m_fullScreenQuad);
renderer->SwapTextures();
++pass;
}
}
renderer->DisableDeferredRendering();
renderer->DisableShader();
window->setView(window->getDefaultView());
renderer->DrawBufferTexture();
window->setView(currentView);
}
这与之前讨论的已建立模型非常相似。这里需要注意的是一些更改:使用一个名为m_materialMaps的内部数据成员来将材质信息传递给光照通道着色器,以及在底部附近检查的地方,如果这是第一个着色器通道,则将漫反射纹理作为"LastPass"统一变量传递。否则,我们将采样一个完全黑色的纹理。
集成光照管理器类
光照管理器实现后,我们可以将其添加到使用它的所有类的列表中:
State_Game::State_Game(StateManager* l_stateManager)
: BaseState(l_stateManager),
m_lightManager(l_stateManager->GetContext()->m_wind)
{
auto context = m_stateMgr->GetContext();
m_lightManager.AddLightUser(context->m_gameMap);
m_lightManager.AddLightUser(context->m_systemManager);
m_lightManager.AddLightUser(context->m_particles);
}
在这种情况下,我们只与游戏地图、系统管理器和粒子管理器类作为光照用户一起工作。
设置我们之前的光照信息现在与之前一样简单:
void State_Game::OnCreate() {
...
m_lightManager.GetAmbientLight()->m_lightColor =
sf::Vector3f(0.2f, 0.2f, 0.2f);
m_lightManager.AddLight({ { 700.f, 350.f, 32.f },
{ 1.f, 0.f, 0.f }, 128.f, 0.005f });
m_lightManager.AddLight({ { 600.f, 350.f, 32.f },
{ 0.f, 1.f, 0.f }, 128.f, 0.005f });
m_lightManager.AddLight({ { 500.f, 350.f, 32.f },
{ 0.f, 0.f, 1.f }, 128.f, 0.005f });
m_lightManager.AddLight({ { 400.f, 600.f, 32.f },
{ 1.f, 0.f, 0.f }, 128.f, 0.005f });
m_lightManager.AddLight({ { 300.f, 600.f, 32.f },
{ 0.f, 1.f, 0.f }, 128.f, 0.005f });
m_lightManager.AddLight({ { 200.f, 600.f, 32.f },
{ 0.f, 0.f, 1.f }, 128.f, 0.005f });
m_lightManager.AddLight({ { 600.f, 550.f, 33.f },
{ 1.f, 1.f, 1.f }, 128.f, 0.01f });
}
最后,我们只需确保材质贴图被绘制,就像场景本身一样:
void State_Game::Draw() {
m_lightManager.RenderMaterials();
m_lightManager.RenderScene();
}
现在,唯一剩下的事情就是将这些讨厌的类适配到我们在这里设置的新光照模型中。
将类适配以使用光照
显然,我们游戏中每个进行渲染的类都有其独特的渲染方式。将相同的图形渲染到不同类型的材质贴图上也不例外。让我们看看每个支持光照的类应该如何实现它们各自的Draw方法,以确保与我们的光照系统保持同步。
Map类
我们需要首先处理的是Map类。由于它处理瓦片绘制的方式不同,所以会有所不同。让我们看看需要添加什么:
class Map : ..., public LightUser {
public:
...
void Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer);
protected:
...
Void CheckTextureSizes(int l_fromZ, int l_toZ);
std::array<sf::RenderTexture, Sheet::Num_Layers> m_textures;
...
};
到目前为止,一切顺利!Map类现在正在使用LightUser接口。m_textures数据成员是一个在所有这些之前就存在的已建立数组,它简单地存储每个支持高度的纹理。尽管如此,还是增加了一个新的受保护成员函数,名为CheckTextureSizes:
void Map::CheckTextureSizes(int l_fromZ, int l_toZ) {
auto realMapSize = m_tileMap.GetMapSize() *
static_cast<unsigned int>(Sheet::Tile_Size);
for (auto layer = l_fromZ; layer <= l_toZ; ++layer) {
if (m_textures[layer].getSize() != realMapSize) {
... // Information printed to the console.
if (!m_textures[layer].create(realMapSize.x, realMapSize.y))
{ ... } // Error message.
}
... // Other textures.
}
}
这仅仅是一种确保所有未来的纹理,以及当前的漫反射贴图,都有适当大小的便捷方式。
让我们看看Redraw方法现在需要做什么,以便完全支持光照管理器:
void Map::Redraw(sf::Vector3i l_from, sf::Vector3i l_to) {
...
CheckTextureSizes(l_from.z, l_to.z);
ClearMapTexture(l_from, originalTo);
auto renderer = m_window->GetRenderer();
if (renderer->UseShader("default")) {
// Diffuse pass.
for (auto x = l_from.x; x <= l_to.x; ++x) {
for (auto y = l_from.y; y <= l_to.y; ++y) {
for (auto layer = l_from.z; layer <= l_to.z; ++layer) {
auto tile = m_tileMap.GetTile(x, y, layer);
if (!tile) { continue; }
auto& sprite = tile->m_properties->m_sprite;
sprite.setPosition(
static_cast<float>(x * Sheet::Tile_Size),
static_cast<float>(y * Sheet::Tile_Size));
renderer->Draw(sprite, &m_textures[layer]);
}
}
}
}
... // Other passes.
renderer->DisableShader();
DisplayAllTextures(l_from.z, l_to.z);
}
只需添加几行额外的代码即可添加支持。我们只需确保在绘图发生时渲染器被涉及,因为这允许在过程中使用正确的着色器。
由于我们很快就会添加更多的材质图,因此这些纹理的清除也需要集成到现有代码中:
void Map::ClearMapTexture(sf::Vector3i l_from, sf::Vector3i l_to){
...
if (l_to.x == -1 && l_to.y == -1) {
// Clearing the entire texture.
for (auto layer = l_from.z; layer <= toLayer; ++layer) {
m_textures[layer].clear({ 0,0,0,0 });
... // Other textures.
}
return;
}
// Portion of the map needs clearing.
...
for (auto layer = l_from.z; layer <= toLayer; ++layer) {
m_textures[layer].draw(shape, sf::BlendMultiply);
... // Other textures.
}
DisplayAllTextures(l_from.z, toLayer);
}
用于此目的的空间用注释标记,这与辅助方法完全相同,这些方法有助于显示对这些缓冲区纹理所做的所有更改:
void Map::DisplayAllTextures(int l_fromZ, int l_toZ) {
for (auto layer = l_fromZ; layer <= l_toZ; ++layer) {
m_textures[layer].display();
... // Other textures.
}
}
LightUser 类的实际 Draw 方法可以像这样实现:
void Map::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
if (l_layer < 0) { return; }
if (l_layer >= Sheet::Num_Layers) { return; }
auto rect = sf::IntRect(sf::Vector2i(0, 0),
sf::Vector2i(m_textures[l_layer].getSize()));
m_layerSprite.setTextureRect(rect);
// Diffuse.
m_layerSprite.setTexture(m_textures[l_layer].getTexture());
m_window->GetRenderer()->Draw(m_layerSprite,
l_materials[MaterialMapType::Diffuse].get());
... // Other textures.
}
由于 Map 类的工作方式,我们只需设置我们正在处理的精灵使用适当的材质类型的正确纹理。在这种情况下,我们只需要漫反射纹理。
实体渲染系统
如果你还记得,SystemManager 类是我们添加到 LightManager 作为 LightUser 的。尽管现在只有一个系统负责渲染,我们仍然希望保持这种方式,并且简单地转发传递给 SystemManager 的所有参数。这保留了未来添加执行相同任务的其他系统的选项:
void SystemManager::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
auto system = dynamic_cast<S_Renderer*>(itr->second.get());
system->Draw(l_materials, l_window, l_layer);
}
转发的参数被发送到 S_Renderer 并可以像这样使用:
void S_Renderer::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("default")) {
// Diffuse pass.
for (auto &entity : m_entities) {
auto position = entities->GetComponent<C_Position>(
entity, Component::Position);
if (position->GetElevation() < l_layer) { continue; }
if (position->GetElevation() > l_layer) { break; }
C_Drawable* drawable = GetDrawableFromType(entity);
if (!drawable) { continue; }
drawable->Draw(&l_window,
l_materials[MaterialMapType::Diffuse].get());
}
}
... // Other passes.
renderer->DisableShader();
}
这与 Map 类处理其重绘过程的方式相当相似。我们只需确保使用 Renderer 类将绘图绘制到漫反射纹理上,这是在底层发生的,因为 C_Drawable 简单地将这些参数传递下去:
class C_Drawable : public C_Base{
...
virtual void Draw(Window* l_wind,
sf::RenderTarget* l_target = nullptr) = 0;
};
class C_SpriteSheet : public C_Drawable{
...
void Draw(Window* l_wind, sf::RenderTarget* l_target = nullptr){
if (!m_spriteSheet) { return; }
m_spriteSheet->Draw(l_wind, l_target);
}
...
};
void SpriteSheet::Draw(Window* l_wnd, sf::RenderTarget* l_target) {
l_wnd->GetRenderer()->Draw(m_sprite, l_target);
}
粒子系统
以这种方式绘制粒子与其他 LightUser 的方式没有太大区别:
void ParticleSystem::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("default")) {
// Diffuse pass.
for (size_t i = 0; i < container->m_countAlive; ++i) {
if (l_layer >= 0) {
if (positions[i].z < l_layer * Sheet::Tile_Size)
{ continue; }
if (positions[i].z >= (l_layer + 1) * Sheet::Tile_Size)
{ continue; }
} else if(positions[i].z<Sheet::Num_Layers*Sheet::Tile_Size)
{ continue; }
renderer->AdditiveBlend(blendModes[i]);
renderer->Draw(drawables[i],
l_materials[MaterialMapType::Diffuse].get());
}
}
renderer->AdditiveBlend(false);
... // Other passes.
renderer->DisableShader();
}
再次强调,这一切都是为了确保材质通过 Renderer 传递。
准备额外的材质
以这种方式绘制基本的光流相当巧妙。但让我们面对现实,我们想要做的不仅仅是这些!任何额外的处理都需要关于我们正在处理的表面的进一步材质信息。至于存储这些材质,Map 类需要为用于此目的的纹理分配额外的空间:
class Map : ..., public LightUser {
public:
...
void Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer);
protected:
...
std::array<sf::RenderTexture, Sheet::Num_Layers> m_textures;
std::array<sf::RenderTexture, Sheet::Num_Layers> m_normals;
std::array<sf::RenderTexture, Sheet::Num_Layers> m_speculars;
std::array<sf::RenderTexture, Sheet::Num_Layers> m_heightMap;
...
};
这些纹理还需要检查其尺寸是否正确,并在必要时进行调整:
void Map::CheckTextureSizes(int l_fromZ, int l_toZ) {
auto realMapSize = m_tileMap.GetMapSize() *
static_cast<unsigned int>(Sheet::Tile_Size);
for (auto layer = l_fromZ; layer <= l_toZ; ++layer) {
...
if (m_normals[layer].getSize() != realMapSize) {
if (!m_normals[layer].create(realMapSize.x, realMapSize.y))
{ ... }
}
if (m_speculars[layer].getSize() != realMapSize) {
if (!m_speculars[layer].create(realMapSize.x,realMapSize.y))
{ ... }
}
if (m_heightMap[layer].getSize() != realMapSize) {
if (!m_heightMap[layer].create(realMapSize.x,realMapSize.y))
{ ... }
}
}
}
清除材质图同样简单;我们只需添加几行额外的代码:
void Map::ClearMapTexture(sf::Vector3i l_from, sf::Vector3i l_to)
{
...
if (l_to.x == -1 && l_to.y == -1) {
for (auto layer = l_from.z; layer <= toLayer; ++layer) {
...
m_normals[layer].clear({ 0,0,0,0 });
m_speculars[layer].clear({ 0,0,0,0 });
m_heightMap[layer].clear({ 0,0,0,0 });
}
return;
}
...
for (auto layer = l_from.z; layer <= toLayer; ++layer) {
...
m_normals[layer].draw(shape, sf::BlendMultiply);
m_speculars[layer].draw(shape, sf::BlendMultiply);
m_heightMap[layer].draw(shape, sf::BlendMultiply);
}
DisplayAllTextures(l_from.z, toLayer);
}
显示对缓冲区纹理所做的更改遵循相同简单且易于管理的做法:
void Map::DisplayAllTextures(int l_fromZ, int l_toZ) {
for (auto layer = l_fromZ; layer <= l_toZ; ++layer) {
m_textures[layer].display();
m_normals[layer].display();
m_speculars[layer].display();
m_heightMap[layer].display();
}
}
最后,将此信息绘制到 LightManager 的内部缓冲区,对于 Map 类,可以这样做:
void Map::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
... // Diffuse.
// Normal.
m_layerSprite.setTexture(m_normals[l_layer].getTexture());
m_window->GetRenderer()->Draw(m_layerSprite,
l_materials[MaterialMapType::Normal].get());
// Specular.
m_layerSprite.setTexture(m_speculars[l_layer].getTexture());
m_window->GetRenderer()->Draw(m_layerSprite,
l_materials[MaterialMapType::Specular].get());
// Height.
m_layerSprite.setTexture(m_heightMap[l_layer].getTexture());
m_window->GetRenderer()->Draw(m_layerSprite,
l_materials[MaterialMapType::Height].get());
}
容易吗?很好!让我们继续前进,构建可以处理绘制这些材质图的过程的着色器。
准备纹理管理器
为了在加载漫反射图像时自动加载额外的材质图,我们需要对 ResourceManager 和 TextureManager 类进行一些非常快速且不痛苦的修改:
class ResourceManager{
public:
bool RequireResource(const std::string& l_id,
bool l_notifyDerived = true)
{
...
if (l_notifyDerived) { OnRequire(l_id); }
return true;
}
bool ReleaseResource(const std::string& l_id,
bool l_notifyDerived = true)
{
...
if (l_notifyDerived) { OnRelease(l_id); }
return true;
}
protected:
...
virtual void OnRequire(const std::string& l_id) {}
virtual void OnRelease(const std::string& l_id) {}
};
class TextureManager : ...{
public:
...
void OnRequire(const std::string& l_id) {
if (RequireResource(l_id + "_normal", false)) { ... }
if (RequireResource(l_id + "_specular", false)) { ... }
}
void OnRelease(const std::string& l_id) {
if (ReleaseResource(l_id + "_normal", false)) { ... }
if (ReleaseResource(l_id + "_specular", false)) { ... }
}
};
通过添加OnRequire()和OnRelease()方法,并正确地将它们与l_notifyDerived标志集成以避免无限递归,当加载漫反射纹理时,TextureManager可以安全地加载法线和镜面材质图,前提是它们被找到。请注意,当纹理管理器需要这些图时,实际上会将false作为第二个参数传递,以避免无限递归。
材质通道着色器
我们将使用两种类型的材质通道着色器。一种类型,简单地称为MaterialPass,将从纹理中采样材质颜色:
uniform sampler2D texture;
uniform sampler2D material;
void main()
{
vec4 pixel = texture2D(texture, gl_TexCoord[0].xy);
vec4 materialPixel = texture2D(material, gl_TexCoord[0].xy);
materialPixel.a *= pixel.a;
gl_FragColor = gl_Color * materialPixel;
}
它检索漫反射像素和材质纹理像素,并使用漫反射 alpha 值来显示正确的颜色。这意味着,如果我们处理的是漫反射图上的透明像素,则不会为其渲染材质颜色。否则,材质颜色与漫反射像素完全独立。这对于绘制同时具有位于不同纹理中的材质图的图像非常有用。
第二种类型的材质着色器,从现在起称为MaterialValuePass,也将采样漫反射像素。然而,它不会使用材质纹理,而是简单地为所有非透明像素使用静态颜色值:
uniform sampler2D texture;
uniform vec3 material;
void main()
{
vec4 pixel = texture2D(texture, gl_TexCoord[0].xy);
float alpha = 0.0;
if(pixel == vec4(0.0, 0.0, 0.0, 1.0))
alpha = gl_Color.a;
else
alpha = pixel.a;
gl_FragColor = gl_Color * vec4(material.rgb, alpha);
}
在这里,我们首先验证采样像素不是完全黑色的。如果是,则使用gl_Color的alpha值而不是像素的alpha值。然后,我们只需将静态材质颜色值写入片段。这种着色器对于没有材质图且每个像素都使用静态颜色的可绘制对象非常有用。
法线图
灯光可以用来创建视觉复杂且令人叹为观止的场景。拥有照明系统的一个巨大好处是它提供了添加额外细节的能力,这在其他情况下是不可能的。实现这一目标的一种方法就是使用法线贴图。
从数学的角度讲,在表面的上下文中,法线这个词简单地是一个垂直于该表面的方向向量。考虑以下插图:

在这种情况下,法线朝上,因为那是垂直于平面的方向。这有什么帮助呢?好吧,想象一下你有一个非常复杂的模型,有很多顶点;渲染这个模型会非常耗时,因为需要处理大量的几何形状。一个聪明的技巧,称为法线贴图,就是将所有这些顶点的信息保存到一个看起来类似这样的纹理上:

这可能看起来非常奇特,尤其是在以灰度形式查看这本书的物理版本时,但请尽量不要从颜色的角度考虑,而是从方向的角度考虑。法线贴图的红色通道编码了 -x 和 +x 值。绿色通道对 -y 和 +y 值做同样的处理,而蓝色通道用于 -z 到 +z。现在回顾一下之前的图像,更容易确认每个单独像素的方向。在完全平坦的几何体上使用这些信息仍然可以让我们以这种方式对其进行照明,使其看起来具有所有这些细节;然而,它仍然保持平坦且对性能影响不大:

这些法线贴图可以是手工绘制,或者简单地使用如 Crazybump 这样的软件生成。让我们看看如何在我们的游戏引擎中实现这一切。
实现法线贴图渲染
在地图的情况下,实现法线贴图渲染非常简单。我们已经有所有材质贴图集成并准备就绪,所以现在这只是一个采样瓦片贴图法线纹理的问题:
void Map::Redraw(sf::Vector3i l_from, sf::Vector3i l_to) {
...
if (renderer->UseShader("MaterialPass")) {
// Material pass.
auto shader = renderer->GetCurrentShader();
auto textureName = m_tileMap.GetTileSet().GetTextureName();
auto normalMaterial = m_textureManager->
GetResource(textureName + "_normal");
for (auto x = l_from.x; x <= l_to.x; ++x) {
for (auto y = l_from.y; y <= l_to.y; ++y) {
for (auto layer = l_from.z; layer <= l_to.z; ++layer) {
auto tile = m_tileMap.GetTile(x, y, layer);
if (!tile) { continue; }
auto& sprite = tile->m_properties->m_sprite;
sprite.setPosition(
static_cast<float>(x * Sheet::Tile_Size),
static_cast<float>(y * Sheet::Tile_Size));
// Normal pass.
if (normalMaterial) {
shader->setUniform("material", *normalMaterial);
renderer->Draw(sprite, &m_normals[layer]);
}
}
}
}
}
...
}
这个过程与将法线瓦片绘制到漫反射贴图上的过程完全相同,只是在这里我们必须向材质着色器提供瓦片贴图法线的纹理。同时请注意,我们现在正在绘制到法线缓冲区纹理。
对于绘制实体也是如此:
void S_Renderer::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("MaterialPass")) {
// Material pass.
auto shader = renderer->GetCurrentShader();
auto textures = m_systemManager->
GetEntityManager()->GetTextureManager();
for (auto &entity : m_entities) {
auto position = entities->GetComponent<C_Position>(
entity, Component::Position);
if (position->GetElevation() < l_layer) { continue; }
if (position->GetElevation() > l_layer) { break; }
C_Drawable* drawable = GetDrawableFromType(entity);
if (!drawable) { continue; }
if (drawable->GetType() != Component::SpriteSheet)
{ continue; }
auto sheet = static_cast<C_SpriteSheet*>(drawable);
auto name = sheet->GetSpriteSheet()->GetTextureName();
auto normals = textures->GetResource(name + "_normal");
// Normal pass.
if (normals) {
shader->setUniform("material", *normals);
drawable->Draw(&l_window,
l_materials[MaterialMapType::Normal].get());
}
}
}
...
}
您可以通过纹理管理器尝试获取一个正常纹理。如果您找到了,您可以将它绘制到法线贴图材质缓冲区中。
处理粒子与我们已经看到的方法没有太大区别,除了一个小细节:
void ParticleSystem::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("MaterialValuePass")) {
// Material pass.
auto shader = renderer->GetCurrentShader();
for (size_t i = 0; i < container->m_countAlive; ++i) {
if (l_layer >= 0) {
if (positions[i].z < l_layer * Sheet::Tile_Size)
{ continue; }
if (positions[i].z >= (l_layer + 1) * Sheet::Tile_Size)
{ continue; }
} else if (positions[i].z <
Sheet::Num_Layers * Sheet::Tile_Size)
{ continue; }
// Normal pass.
shader->setUniform("material",
sf::Glsl::Vec3(0.5f, 0.5f, 1.f));
renderer->Draw(drawables[i],
l_materials[MaterialMapType::Normal].get());
}
}
...
}
正如您所看到的,我们实际上正在使用材质值着色器来给粒子提供静态法线,这些法线总是指向相机。在将所有法线贴图渲染到它上面后,法线贴图缓冲区应该看起来像这样:

修改照明着色器
现在我们已经拥有了所有这些信息,让我们在计算光照着色器内部的像素照明时实际使用它:
uniform sampler2D LastPass;
uniform sampler2D DiffuseMap;
uniform sampler2D NormalMap;
uniform vec3 AmbientLight;
uniform int LightCount;
uniform int PassNumber;
struct LightInfo {
vec3 position;
vec3 color;
float radius;
float falloff;
};
const int MaxLights = 4;
uniform LightInfo Lights[MaxLights];
void main()
{
vec4 pixel = texture2D(LastPass, gl_TexCoord[0].xy);
vec4 diffusepixel = texture2D(DiffuseMap, gl_TexCoord[0].xy);
vec4 normalpixel = texture2D(NormalMap, gl_TexCoord[0].xy);
vec3 PixelCoordinates =
vec3(gl_FragCoord.x, gl_FragCoord.y, gl_FragCoord.z);
vec4 finalPixel = gl_Color * pixel;
vec3 viewDirection = vec3(0, 0, 1);
if(PassNumber == 1) { finalPixel *= vec4(AmbientLight, 1.0); }
// IF FIRST PASS ONLY!
vec3 N = normalize(normalpixel.rgb * 2.0 - 1.0);
for(int i = 0; i < LightCount; ++i) {
vec3 L = Lights[i].position - PixelCoordinates;
float distance = length(L);
float d = max(distance - Lights[i].radius, 0);
L /= distance;
float attenuation = 1 / pow(d/Lights[i].radius + 1, 2);
attenuation = (attenuation - Lights[i].falloff) /
(1 - Lights[i].falloff);
attenuation = max(attenuation, 0);
float normalDot = max(dot(N, L), 0.0);
finalPixel += (diffusepixel *
((vec4(Lights[i].color, 1.0) * attenuation))) * normalDot;
}
gl_FragColor = finalPixel;
}
首先,需要将法线贴图纹理传递给它,并进行采样,这就是前两行高亮代码的作用所在。一旦完成,对于我们在屏幕上绘制的每个光源,都会计算法线方向向量。这是通过首先确保它可以进入负范围,然后归一化它来完成的。归一化向量只表示一个方向。
注意
由于颜色值范围从 0 到 255,负值不能直接表示。这就是为什么我们首先通过乘以 2.0 并减去 1.0 来将它们带入正确的范围。
然后计算法线向量和归一化L向量之间的点积,现在它代表从光到像素的方向。一个像素从特定光源照亮的程度直接取决于点积,这是一个从1.0到0.0的值,代表大小。
注意
点积是一种代数运算,它接受两个向量以及它们之间角度的余弦值,并产生一个介于0.0和1.0之间的标量值,这基本上代表了它们有多“正交”。我们利用这个特性,随着它们法线与光之间的角度越来越大,逐渐减少对像素的照明。
最后,在计算最终像素值时再次使用点积。整个光的影响都乘以它,这使得每个像素都可以以不同的方式绘制,就像它有一些指向不同方向的底层几何形状一样。
现在最后要做的就是将正常映射缓冲区传递到我们的 C++代码中的着色器:
void LightManager::RenderScene() {
...
if (renderer->UseShader("LightPass")) {
// Light pass.
...
shader->setUniform("NormalMap",
m_materialMaps[MaterialMapType::Normal]->getTexture());
...
}
...
}
这有效地启用了正常映射,并给我们带来了如下的美丽效果:

树叶、角色以及这张图片中的几乎所有东西,现在看起来都有定义、棱角和凹槽;它被照亮,就像它有几何形状一样,尽管它是纸薄的。注意这个特定实例中每个瓦片周围的线条。这就是为什么像素艺术(如瓦片图集)的正常映射不应该自动生成的原因之一;它可以采样它旁边的瓦片,并错误地添加斜边。
镜面映射
虽然正常映射为我们提供了模拟表面凹凸度的可能性,但镜面映射允许我们以相同的方式处理表面的光泽度。这就是我们用作正常映射示例的瓦片图集的相同段在镜面映射中的样子:

它没有普通映射那么复杂,因为它只需要存储一个值:光泽度因子。我们可以让每个光照决定它将在场景上产生多少光泽,让它有自己的值:
struct LightBase {
...
float m_specularExponent = 10.f;
float m_specularStrength = 1.f;
};
添加对镜面反射的支持
与正常映射类似,我们需要使用材质通道着色器将渲染输出到镜面反射缓冲纹理:
void Map::Redraw(sf::Vector3i l_from, sf::Vector3i l_to) {
...
if (renderer->UseShader("MaterialPass")) {
// Material pass.
...
auto specMaterial = m_textureManager->GetResource(
textureName + "_specular");
for (auto x = l_from.x; x <= l_to.x; ++x) {
for (auto y = l_from.y; y <= l_to.y; ++y) {
for (auto layer = l_from.z; layer <= l_to.z; ++layer) {
... // Normal pass.
// Specular pass.
if (specMaterial) {
shader->setUniform("material", *specMaterial);
renderer->Draw(sprite, &m_speculars[layer]);
}
}
}
}
}
...
}
镜面反射的纹理再次尝试获取;如果找到,则将其传递到材质通道着色器。当你渲染实体时也是如此:
void S_Renderer::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("MaterialPass")) {
// Material pass.
...
for (auto &entity : m_entities) {
... // Normal pass.
// Specular pass.
if (specular) {
shader->setUniform("material", *specular);
drawable->Draw(&l_window,
l_materials[MaterialMapType::Specular].get());
}
}
}
...
}
另一方面,粒子也使用材质值通道着色器:
void ParticleSystem::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("MaterialValuePass")) {
// Material pass.
auto shader = renderer->GetCurrentShader();
for (size_t i = 0; i < container->m_countAlive; ++i) {
... // Normal pass.
// Specular pass.
shader->setUniform("material",
sf::Glsl::Vec3(0.f, 0.f, 0.f));
renderer->Draw(drawables[i],
l_materials[MaterialMapType::Specular].get());
}
}
}
目前,我们不想让任何东西都有镜面反射。这显然可以在以后进行调整,但重要的是我们有了这个功能,并且它产生了以下这样的结果:

这种具有光泽的纹理需要在光照通道内进行采样,就像普通纹理一样。让我们看看这涉及到什么。
修改光照着色器
就像之前一样,需要添加一个统一的sampler2D来采样特定片段的镜面度:
uniform sampler2D LastPass;
uniform sampler2D DiffuseMap;
uniform sampler2D NormalMap;
uniform sampler2D SpecularMap;
uniform vec3 AmbientLight;
uniform int LightCount;
uniform int PassNumber;
struct LightInfo {
vec3 position;
vec3 color;
float radius;
float falloff;
float specularExponent;
float specularStrength;
};
const int MaxLights = 4;
uniform LightInfo Lights[MaxLights];
const float SpecularConstant = 0.4;
void main()
{
...
vec4 specularpixel = texture2D(SpecularMap, gl_TexCoord[0].xy);
vec3 viewDirection = vec3(0, 0, 1); // Looking at positive Z.
...
for(int i = 0; i < LightCount; ++i){
...
float specularLevel = 0.0;
specularLevel =
pow(max(0.0, dot(reflect(-L, N), viewDirection)),
Lights[i].specularExponent * specularpixel.a)
* SpecularConstant;
vec3 specularReflection = Lights[i].color * specularLevel *
specularpixel.rgb * Lights[i].specularStrength;
finalPixel +=
(diffusepixel * ((vec4(Lights[i].color, 1.0) * attenuation))
+ vec4(specularReflection, 1.0)) * normalDot;
}
gl_FragColor = finalPixel;
}
我们还需要将每个光线的struct中的镜面指数和强度添加进去,因为现在它是它的一部分。一旦采样了镜面像素,我们还需要设置摄像机的方向。由于这是静态的,我们可以在着色器中保持原样。
然后,通过考虑像素的法线与光之间的点积、镜面像素本身的颜色和光的镜面强度来计算像素的镜面度。注意计算中使用了镜面常数。这是一个可以,并且应该调整以获得最佳结果的价值,因为 100%的镜面度很少看起来很好。
然后,剩下的就是确保将镜面纹理也发送到光通道着色器,除了光线的镜面指数和强度值:
void LightManager::RenderScene() {
...
if (renderer->UseShader("LightPass")) {
// Light pass.
...
shader->setUniform("SpecularMap",
m_materialMaps[MaterialMapType::Specular]->getTexture());
...
for (auto& light : m_lights) {
...
shader->setUniform(id + ".specularExponent",
light.m_specularExponent);
shader->setUniform(id + ".specularStrength",
light.m_specularStrength);
...
}
}
}
结果可能不会立即可见,但通过仔细检查移动光流,我们可以看到正确映射的表面将会有一个随着光线移动的闪光:

虽然这几乎是完美的,但仍有改进的空间。
高度图
照亮世界的要点是使所有视觉细节以逼真的方式突出。我们已经添加了人工动态照明、假 3D 几何形状和光泽,那么剩下什么呢?嗯,还没有显示场景正确高度的东西。直到现在,我们在计算照明距离时一直将场景视为完全平坦。相反,我们需要处理被称为高度图的东西,它将存储像素的高度。
适配现有代码
正确绘制高度可能相当棘手,尤其是在瓦片图的情况下。我们需要知道在绘制逼真高度时瓦片面向哪个方向。考虑以下插图:

点A旁边的瓦片没有与之关联的法线,而点B旁边的瓦片都面向摄像机。我们可以通过进行这些简单的修改在地图文件中存储法线数据:
struct Tile {
...
sf::Vector3f m_normal;
};
void TileMap::ReadInTile(std::stringstream& l_stream) {
...
sf::Vector3f normals(0.f, 1.f, 0.f);
l_stream >> normals.x >> normals.y >> normals.z;
tile->m_normal = normals;
...
}
TILE 57 15 3 1 1 // Tile entry without a normal.
TILE 144 15 8 1 1 0 0 1 // Tile entry with a normal 0,0,1
Tile结构本身现在保留了一个正常值,这个值将在以后使用。当从文件中读取瓦片时,附加信息将在最后加载。这里最后两行显示了来自地图文件的实际条目。
根据这些瓦片法线绘制高度的所有操作都在适当的着色器中完成,所以让我们传递它所需的所有信息:
void Map::Redraw(sf::Vector3i l_from, sf::Vector3i l_to) {
...
if (renderer->UseShader("HeightPass")) {
// Height pass.
auto shader = renderer->GetCurrentShader();
for (auto x = l_from.x; x <= l_to.x; ++x) {
for (auto y = l_from.y; y <= l_to.y; ++y) {
for (auto layer = l_from.z; layer <= l_to.z; ++layer) {
auto tile = m_tileMap.GetTile(x, y, layer);
if (!tile) { continue; }
auto& sprite = tile->m_properties->m_sprite;
sprite.setPosition(
static_cast<float>(x * Sheet::Tile_Size),
static_cast<float>(y * Sheet::Tile_Size));
shader->setUniform("BaseHeight",
static_cast<float>(layer * Sheet::Tile_Size));
shader->setUniform("YPosition", sprite.getPosition().y);
shader->setUniform("SurfaceNormal",
sf::Glsl::Vec3(tile->m_normal));
renderer->Draw(sprite, &m_heightMap[layer]);
}
}
}
}
...
}
高度通道着色器使用可绘制基高度的一个值,在这个例子中,它只是世界坐标中的高程。它还使用Drawable类的Y世界坐标并获取表面法线。同样,实体也需要设置相同的值:
void S_Renderer::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("HeightPass")) {
// Height pass.
auto shader = renderer->GetCurrentShader();
shader->setUniform("BaseHeight",
static_cast<float>(l_layer * Sheet::Tile_Size));
shader->setUniform("SurfaceNormal",
sf::Glsl::Vec3(0.f, 0.f, 1.f));
for (auto &entity : m_entities) {
auto position = entities->GetComponent<C_Position>(
entity, Component::Position);
if (position->GetElevation() < l_layer) { continue; }
if (position->GetElevation() > l_layer) { break; }
C_Drawable* drawable = GetDrawableFromType(entity);
if (!drawable) { continue; }
if (drawable->GetType() != Component::SpriteSheet)
{ continue; }
auto sheet = static_cast<C_SpriteSheet*>(drawable);
shader->setUniform("YPosition", position->GetPosition().y);
drawable->Draw(&l_window,
l_materials[MaterialMapType::Height].get());
}
}
...
}
然而,在这种情况下,我们为所有实体使用相同的法线。这是因为我们希望它们面向相机,并且像垂直于地面站立一样被照亮。另一方面,粒子没有面向相机,而是法线指向正Y轴:
void ParticleSystem::Draw(MaterialMapContainer& l_materials,
Window& l_window, int l_layer)
{
...
if (renderer->UseShader("HeightPass")) {
// Height pass.
auto shader = renderer->GetCurrentShader();
shader->setUniform("SurfaceNormal",
sf::Glsl::Vec3(0.f, 1.f, 0.f));
for (size_t i = 0; i < container->m_countAlive; ++i) {
if (l_layer >= 0) {
if (positions[i].z < l_layer * Sheet::Tile_Size)
{ continue; }
if (positions[i].z >= (l_layer + 1) * Sheet::Tile_Size)
{ continue; }
} else if (positions[i].z <
Sheet::Num_Layers * Sheet::Tile_Size)
{ continue; }
shader->setUniform("BaseHeight", positions[i].z);
shader->setUniform("YPosition", positions[i].y);
renderer->Draw(drawables[i],
l_materials[MaterialMapType::Height].get());
}
}
...
}
编写高度传递着色器
高度传递是我们迄今为止编写的唯一一个同时使用顶点和片段着色器的程序。
让我们看看顶点着色器中需要发生什么:
uniform float YPosition;
out float Height;
void main()
{
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
gl_TexCoord[0] = gl_TextureMatrix[0] * gl_MultiTexCoord0;
gl_FrontColor = gl_Color;
Height = gl_Vertex.y - YPosition;
}
这里只有一行不是传统意义上的顶点着色器标准,当然除了统一变量和输出变量之外。顶点着色器输出一个名为Height的浮点值到片段着色器。它只是形状在世界坐标中Y分量和相同形状的基础Y位置之间的高度。然后,高度在所有片段之间进行插值,从而得到一个很好的,渐变分布。
注意
gl_Vertex信息存储在世界坐标中。底部Y坐标始终从可绘制对象的相同高度开始,这使得顶部Y坐标等于其位置和高度的之和。
最后,我们可以看看片段着色器,并实际填充一些片段:
uniform sampler2D texture;
uniform vec3 SurfaceNormal;
uniform float BaseHeight;
in float Height;
void main()
{
vec4 pixel = texture2D(texture, gl_TexCoord[0].xy);
float value = (BaseHeight - (Height * SurfaceNormal.z)) / 255.0;
gl_FragColor = vec4(value, value, value, pixel.a);
}
如前所述,它接受漫反射纹理、表面法线、可绘制对象的基础高度以及从顶点着色器中插值的Height值。然后对漫反射像素进行采样,以便使用其 alpha 值进行透明度。高度值本身是通过从可绘制对象的基础高度中减去像素高度乘以表面法线的Z分量来计算的。最后,整个值除以255,因为我们希望以归一化格式存储颜色信息。
更改光照着色器
最后,可以通过采样高度图来更改光照传递着色器:
...
uniform sampler2D HeightMap;
...
void main()
{
...
float pixelheight = texture2D(HeightMap, gl_TexCoord[0].xy).r
* 255;
vec3 PixelCoordinates =
vec3(gl_FragCoord.x, gl_FragCoord.y, pixelheight);
...
gl_FragColor = finalPixel;
}
一旦采样像素高度并将其乘以255以将其恢复到世界坐标,我们只需要在计算像素和片段之间的距离时,将gl_FragCoord.z值替换为pixelHeight。是的,这真的只需要这么简单!
然后,HeightMap可以实际传递给着色器进行采样,如下所示:
void LightManager::RenderScene() {
...
if (renderer->UseShader("LightPass")) {
// Light pass.
...
shader->setUniform("HeightMap",
m_materialMaps[MaterialMapType::Height]->getTexture());
...
}
...
}
这给我们提供了一个非常棒的效果,实际上可以展示特定结构的高度,前提是它被正确地提升并且具有正确的法线:

左侧的灯柱没有法线,而右侧的灯柱的法线朝向+Z方向。在这两张图片中,灯光的位置完全相同。
摘要
如果你还在这里,恭喜你!你已经吸收了相当多的信息,但正如我们的世界终于开始在视觉上成形一样,我们即将踏上下一章将讨论的更加惊人的特性。那里见!
第九章. 黑暗的速度 - 光照与阴影
对比差异是存在的本质,正如阴阳符号恰当地说明了这一点。光明与黑暗是相反的,但又是互补的,因为它们相互抵消并通过多样性赋予意义。没有黑暗就没有光明,因为它们从未真正分离。通过向我们的世界注入光明,我们不可避免地被迫添加它所创造的黑暗。让我们跟随上一章,真正通过重新引入黑暗的概念来完善我们的光照引擎。
本章我们将涵盖以下主题:
-
使用 OpenGL 渲染到和从立方体贴图纹理采样
-
针对全向点光源的高级阴影映射
-
使用百分比更近过滤来平滑阴影边缘
-
应对阴影映射的常见和令人沮丧的问题
有很多理论需要先解决,所以让我们开始吧!
使用第三方软件
在深入研究这样一个难以调试的主题之前,拥有合适的工具总是很令人高兴,这些工具可以减轻头痛并减少在开发过程中可能会问自己的问题的数量。虽然正常代码在CPU上可以执行并分析,但着色器代码和 OpenGL 资源,如纹理,处理起来要困难得多。大多数,如果不是所有,C++编译器都没有处理GPU-bound问题的原生支持。幸运的是,有软件可以更容易地处理这种情况。
在为数不多的可以缓解此类头痛的工具中,AMD 开发者工具团队的CodeXL脱颖而出。这是一款免费软件,可以作为 Windows 和 Linux 的独立应用程序使用,甚至可以作为 Visual Studio 的插件。其最显著的特点包括在程序运行时查看 OpenGL 资源(包括纹理),分析代码并找到瓶颈,甚至可以在执行时逐步执行着色器代码(前提是有合适的硬件)。您可以通过以下链接找到并下载此工具:gpuopen.com/compute-product/codexl/。
影子技术背后的理论
在游戏中实现看起来逼真的阴影时,可以使用几种不同的技术。选择正确的一种不仅可以影响应用程序将要展示的性能类型,还可以极大地影响最终效果的好坏。
在 2D 中并不罕见的一种方法被称为光线追踪。根据光线的类型,以适当的方向发射一定数量的光线。然后根据这些光线实际与哪些固体相交来实现阴影。一些简单的游戏倾向于创建一个叠加蒙版,并在几何上填充其“在阴影中”的部分。这个蒙版随后叠加在通常的场景上,并混合以创建代表阴影的暗化区域的美学效果。更高级的 3D 游戏倾向于允许光线在场景中反弹,携带有关它们与特定片段相交的不同信息。当光线到达相机时,它将拥有足够的信息来完成不仅仅是创建简单阴影的任务。需要极其高级光照的场景通常使用这种技术,这是正确的,因为它模仿了现实生活中光线从物体上反弹并击中观察者眼睛的方式。
一种较老但仍然广泛使用的方法是专门创建阴影的阴影映射。这种技术的本质是将场景简单地渲染到离屏缓冲区中,从光的角度来看。所有固体的深度信息,而不是颜色信息,都作为像素数据写入此缓冲区。当渲染真实场景时,然后使用一些矩阵运算来采样阴影映射的正确像素,以确定它们是否可以直接被光线看到,从而被照亮,或者是否被某物遮挡,因此处于阴影中。
阴影映射
创建阴影映射背后的主要思想是从光的角度渲染场景,并有效地将渲染的特定几何形状的深度编码为颜色值,该颜色值可以在以后进行采样。深度值本身不过是光的位置与顶点位置之间的距离。考虑以下图示:

光与给定顶点之间的距离将通过简单地除以视锥体远距离来转换为颜色值,得到的结果在范围[0;1]内。视锥体远值简单地表示光/相机可以看到多远。
全向点光源
在上一章中,我们成功创建了从中心点向所有方向发射光线的光源。这类光源有一个非常合适的名字:全向点光源。对于这些光源处理阴影映射带来了一定的复杂性,因为现在需要从六个方向绘制场景,而不是像处理方向光时那样只从一个方向。这意味着我们需要一种良好的方式来存储这个过程的结果,以便可以相对容易地访问。幸运的是,OpenGL 提供了一种我们可以使用的新类型的纹理,即立方体贴图。
立方体贴图纹理
立方体贴图基本上就是它名字的直译。它是一种特殊的纹理,实际上为立方体的每个面存储了六个纹理。这些纹理以展开的方式内部存储,如图所示:

由于这个特性,为全向光渲染阴影贴图可以简单到只需为立方体贴图的每个方向渲染一次场景。采样它们也非常简单。立方体的形状使其具有一些有用的特性,我们可以利用。如果立方体的所有顶点都与它的绝对中心相关联,那么这些顶点的坐标也可以被视为方向向量:


从立方体中心出发的方向(0, 1, 0)将直接指向+Y面的中间,例如。由于立方体贴图纹理的每个面也包含一个代表场景视图的纹理,因此可以使用这些坐标轻松采样。对于 2D 纹理,我们的着色器必须使用sampler2D类型并提供采样位置的 2D 坐标。立方体贴图有自己的采样器类型samplerCube,并使用 3D 向量进行采样。结果是,3D 向量的最大成员用于确定要采样的面,而其他两个成员成为该特定 2D 纹理/面的 UV 纹理坐标。
注意
立方体贴图不仅可以用于阴影映射。3D 环境在实现天空盒和反射/折射材料等时可以利用它们,仅举几个技术。
渲染准备
可以肯定地说,所有这些功能都超出了 SFML 的范围,因为 SFML 旨在处理简单的二维概念。虽然我们仍然会使用 SFML 来渲染我们的精灵,但场景的光照和阴影将不得不回退到原始 OpenGL。这包括设置和采样立方体贴图纹理,以及创建、上传和绘制用于表示投射阴影的对象的 3D 原语。
表示阴影投射者
虽然 SFML 非常适合渲染精灵,但我们必须记住这些是二维对象。在 3D 空间中,我们的角色将实际上是纸薄的。这意味着我们游戏中的所有阴影投射者都需要一些 3D 几何体作为支撑。记住,这些基本的渲染概念已经在第七章,向前迈一步,向下提升一级 - OpenGL 基础中已经介绍过了。让我们首先创建一些系统将使用的通用定义:
static const glm::vec3 CubeMapDirections[6] = {
{ 1.f, 0.f, 0.f }, // 0 = Positive X
{ -1.f, 0.f, 0.f }, // 1 = Negative X
{ 0.f, 1.f, 0.f }, // 2 = Positive Y
{ 0.f, -1.f, 0.f }, // 3 = Negative Y
{ 0.f, 0.f, 1.f }, // 4 = Positive Z
{ 0.f, 0.f, -1.f } // 5 = Negative Z
};
这将是我们常用的查找数组,并且在这里正确地定义方向向量非常重要。它代表指向立方体贴图纹理每个面的方向。
我们还将使用另一种常见的数据结构,即用于绘制代表我们的阴影投射者的立方体/3D 矩形的索引列表:
static const int ShadowMeshIndices = 36;
static const GLuint CubeIndices[ShadowMeshIndices] = {
0, 4, 7, 7, 3, 0, // Front
0, 1, 5, 5, 4, 0, // Left
3, 7, 6, 6, 2, 3, // Right
1, 2, 6, 6, 5, 1, // Back
7, 4, 5, 5, 6, 7, // Up
1, 0, 3, 3, 2, 1 // Down
};
由于立方体有 6 个面,每个面使用 6 个索引来枚举构成它们的两个三角形,因此我们总共有 36 个索引。
最后,我们需要为每个立方体贴图纹理的方向提供一个向上向量:
static const glm::vec3 CubeMapUpDirections[6] = {
{ 0.f, -1.f, 0.f }, // 0 = Positive X
{ 0.f, -1.f, 0.f }, // 1 = Negative X
{ 0.f, 0.f, -1.f }, // 2 = Positive Y
{ 0.f, 0.f, -1.f }, // 3 = Negative Y
{ 0.f, -1.f, 0.f }, // 4 = Positive Z
{ 0.f, -1.f, 0.f } // 5 = Negative Z
};
为了为几何体获得正确的阴影映射,我们需要在渲染到阴影立方体贴图时使用这些向上方向。请注意,除非我们正在渲染到立方体贴图的Y面,否则Y方向始终用作向上方向。这允许渲染的几何体被相机正确地看到。
实现阴影投射者结构
表示我们游戏中无形的实体是我们将要解决的问题。为了最小化这种方法的内存使用,它将被分为两部分:
-
原型:这是一个包含 OpenGL 使用的上传几何体的句柄的结构。这种类型的对象代表一个独特、独一无二的模型。
-
投射者:这是一个包含它所使用的原型的指针以及它自己的变换的结构,用于正确地定位、旋转和缩放。
原型结构需要保留它分配的资源,如下所示:
struct ShadowCasterPrototype {
...
glm::vec3 m_vertices[ShadowMeshVertices];
GLuint m_VAO;
GLuint m_VBO;
GLuint m_indexVBO;
};
这个结构的构造函数和析构函数将负责这些资源的分配/释放:
ShadowCasterPrototype() : m_VAO(0), m_VBO(0), m_indexVBO(0) {}
~ShadowCasterPrototype() {
if (m_VBO) { glDeleteBuffers(1, &m_VBO); }
if (m_indexVBO) { glDeleteBuffers(1, &m_indexVBO); }
if (m_VAO) { glDeleteVertexArrays(1, &m_VAO); }
}
一旦内部m_vertices数据成员被正确填充,几何体就可以按照以下方式提交给 GPU:
void UploadVertices() {
if (!m_VAO) { glGenVertexArrays(1, &m_VAO); }
glBindVertexArray(m_VAO);
if (!m_VBO) { glGenBuffers(1, &m_VBO); }
if (!m_indexVBO) { glGenBuffers(1, &m_indexVBO); }
glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
glBufferData(GL_ARRAY_BUFFER,
ShadowMeshVertices * sizeof(m_vertices[0]), m_vertices,
GL_STATIC_DRAW);
// Position vertex attribute.
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
sizeof(glm::vec3), 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_indexVBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
ShadowMeshIndices * sizeof(CubeIndices[0]), CubeIndices,
GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
一旦正确创建了顶点数组对象以及用于顶点和索引的两个缓冲区,它们就会被绑定并用于推送数据。注意代码中处理顶点属性的突出部分。由于这个几何体仅用于生成阴影,我们实际上不需要除了顶点位置之外的其他任何东西。将所有这些信息转换为表示光源距离的颜色值的必要数学计算将在着色器内部完成。
此外,请注意这里渲染此几何体时使用的索引。这样做可以节省空间,因为我们不需要像其他情况下那样将两倍的顶点上传到 GPU。
阴影原语的绘制就像人们想象的那样简单:
void Draw() {
glBindVertexArray(m_VAO);
glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_indexVBO);
glDrawElements(GL_TRIANGLES, ShadowMeshIndices,
GL_UNSIGNED_INT, 0); // 0 = offset.
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
一旦所有缓冲区都绑定,我们就调用glDrawElements。让它知道我们在绘制三角形,给方法提供要使用的索引计数,指定它们的数据类型,并提供这些索引的正确偏移量,在这种情况下是0。
最后,因为我们使用原型来存储独特的几何体片段,所以为方便检查匹配的形状而重载==运算符肯定是有用的:
bool operator == (const ShadowCasterPrototype& l_rhs) const {
for (unsigned short i = 0; i < ShadowMeshVertices; ++i) {
if (m_vertices[i] != l_rhs.m_vertices[i]) { return false; }
}
return true;
}
遍历并比较阴影原语中的每个顶点与提供的参数的等效顶点。到目前为止,没有什么异常之处!
当原型被存储时,需要以某种方式识别它们。在这种情况下,使用字符串标识符可以非常直观,因此让我们为这个结构定义一个合适的存储容器类型:
using ShadowCasterPrototypes = std::unordered_map<std::string,
std::unique_ptr<ShadowCasterPrototype>>;
在处理完这些之后,我们可以实现我们的简单 ShadowCaster 结构,它将包含有关原型的所有变量信息:
struct ShadowCaster {
ShadowCaster() : m_prototype(nullptr) { }
ShadowCasterPrototype* m_prototype;
GL_Transform m_transform;
};
如您所见,这是一个非常简单的数据结构,它包含一个指向它使用的原型的指针,以及它自己的 GL_Transform 成员,该成员将存储对象的位移信息。
影子投射器也需要一个合适的存储数据类型:
using ShadowCasters = std::vector<std::unique_ptr<ShadowCaster>>;
这样,我们就有了创建和以内存保守的方式操作不同类型阴影投射原型的手段。
创建变换类
我们使用的变换类与第七章,向前迈一步,向下迈一级 - OpenGL 基础中的完全相同。为了快速复习,让我们看看这个过程中我们需要的重要部分——模型的生成矩阵:
glm::mat4 GL_Transform::GetModelMatrix() {
glm::mat4 matrix_pos = glm::translate(m_position);
glm::mat4 matrix_scale = glm::scale(m_scale);
// Represent each stored rotation as a different matrix,
// because we store angles.
// Directional vector x, y, z
glm::mat4 matrix_rotX =
glm::rotate(m_rotation.x, glm::vec3(1, 0, 0));
glm::mat4 matrix_rotY =
glm::rotate(m_rotation.y, glm::vec3(0, 1, 0));
glm::mat4 matrix_rotZ =
glm::rotate(m_rotation.z, glm::vec3(0, 0, 1));
// Create a rotation matrix. Multiply in reverse order it
// needs to be applied.
glm::mat4 matrix_rotation = matrix_rotZ*matrix_rotY*matrix_rotX;
// Apply transforms in reverse order they need to be applied in.
return matrix_pos * matrix_rotation * matrix_scale;
}
到现在为止,所有这些都应该很熟悉,如果不熟悉,快速浏览一下第七章,向前迈一步,向下迈一级 - OpenGL 基础,这绝对是有序的。然而,主要思想是按照正确的顺序组合平移、缩放和旋转矩阵,以获取一个包含将顶点从对象空间转换到世界空间所需的所有信息的单个矩阵。
创建相机类
与 GL_Transform 类类似,我们还将从第七章,向前迈一步,向下迈一级 - OpenGL 基础中引入 GL_Camera 类。当我们渲染阴影贴图时,所有六个方向的投影和视图矩阵都需要提交给相应的着色器。这使得 GL_Camera 类非常适合表示场景中的光源,该光源需要将其看到的图像绘制到立方体贴图中。再次强调,这已经讨论过了,所以我们只是快速浏览一下:
GL_Camera::GL_Camera(const glm::vec3& l_pos, float l_fieldOfView,
float l_aspectRatio, float l_frustumNear, float l_frustumFar)
:m_position(l_pos),m_fov(l_fieldOfView),m_aspect(l_aspectRatio),
m_frustumNear(l_frustumNear), m_frustumFar(l_frustumFar)
{
RecalculatePerspective();
m_forwardDir = glm::vec3(0.f, 0.f, 1.f);
m_upDir = glm::vec3(0.f, 1.f, 0.f);
}
很合适地,阴影贴图将使用透视投影来绘制。在收集了所有必要的视图截锥体信息之后,我们可以开始构建将那些顶点从世界空间转换到光源视图空间以及裁剪空间的矩阵:
glm::mat4 GL_Camera::GetViewMatrix() {
return glm::lookAt(m_position, m_position + m_forwardDir,
m_upDir);
}
glm::mat4& GL_Camera::GetProjectionMatrix() {
return m_perspectiveMatrix;
}
void GL_Camera::RecalculatePerspective() {
m_perspectiveMatrix = glm::perspective(glm::radians(m_fov),
m_aspect, m_frustumNear, m_frustumFar);
}
我们使用 glm::lookAt 来构造光摄像机的视图矩阵。然后,在另一个方法中使用 glm::perspective 来创建摄像机的透视投影矩阵。
注意
非常重要的是要记住,glm::perspective 函数将视锥体的视场角度作为第一个参数。它期望这个参数是以弧度为单位,而不是以度为单位!因为我们是以度为单位存储的,所以使用了 glm::radians 来转换这个值。这是一个很容易犯的错误,很多人最终会遇到他们的阴影映射不正确的问题。
定义立方体贴图纹理类
现在我们已经弄清楚了几何存储和光视锥体的表示,是时候创建我们将用于实际渲染场景的立方体贴图了。
让我们从为它创建一个简单的类定义开始:
class CubeTexture {
public:
CubeTexture();
~CubeTexture();
void RenderingBind();
void RenderingUnbind();
void SamplingBind(unsigned int l_unit);
void SamplingUnbind(unsigned int l_unit);
GLuint GetTextureHandle()const;
void RenderToFace(unsigned int l_face);
void Clear();
static const unsigned int TextureWidth = 1024;
static const unsigned int TextureHeight = 1024;
private:
void Create();
void CreateBuffers();
void CreateFaces();
GLuint m_textureID; // Texture handle.
GLuint m_fbo; // Frame-buffer handle.
GLuint m_rbo; // Render-buffer handle.
};
这个纹理将被用于两个独特的操作:渲染到纹理和从纹理采样。这两个过程都有一个用于绑定和解绑纹理的方法,值得注意的是,采样步骤还需要一个纹理单元作为参数。我们很快就会介绍这一点。这个类还需要有一个单独的方法,当六个面被渲染时需要调用。
虽然立方体贴图可以用于很多事情,但在这个特定的例子中,我们只是将它们用于阴影映射。因此,纹理的尺寸被定义为 1024px 的常量。
小贴士
立方体贴图纹理的大小非常重要,如果太小,可能会导致伪影。较小的纹理会导致采样不准确,并造成阴影边缘参差不齐。
最后,除了创建纹理和所有必要的缓冲区时使用的辅助方法之外,我们还存储了纹理本身、帧缓冲对象和渲染缓冲对象的句柄。直到这一点,最后两个对象还没有被介绍,所以让我们直接深入了解它们的作用!
实现立方体贴图类
让我们从始终如一地介绍这个特定的 OpenGL 资产的构建和销毁开始:
CubeTexture::CubeTexture() : m_textureID(0), m_fbo(0), m_rbo(0)
{ Create(); }
CubeTexture::~CubeTexture() {
if (m_fbo) { glDeleteFramebuffers(1, &m_fbo); }
if (m_rbo) { glDeleteRenderbuffers(1, &m_rbo); }
if (m_textureID) { glDeleteTextures(1, &m_textureID); }
}
与几何类类似,句柄被初始化为 0 的值,以指示它们的状态尚未设置。析构函数会检查这些值,并调用用于缓冲区/纹理的适当 glDelete 方法。
创建立方体贴图与常规 2D 纹理非常相似,所以让我们看看:
void CubeTexture::Create() {
if (m_textureID) { return; }
glGenTextures(1, &m_textureID);
CreateFaces();
glTexParameteri(GL_TEXTURE_CUBE_MAP,
GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP,
GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP,
GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP,
GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP,
GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
CreateBuffers();
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
}
首先,进行一个检查以确保我们没有已经分配这个对象。如果情况不是这样,就像为 2D 纹理一样使用 glGenTextures 来为纹理对象创建空间。然后调用我们的第一个私有辅助方法来创建立方体贴图的所有六个面,这把我们带到了参数设置。Min/Mag 过滤器被设置为使用最近邻插值,但必要时可以转换为 GL_LINEAR 以获得更平滑的结果。然后设置纹理包装参数,使它们被夹在边缘,从而在面之间提供无缝过渡。
注意
注意,纹理包裹有三个参数:R、S 和 T。这是因为我们现在处理的是三维纹理类型,所以每个轴都必须考虑在内。
最后,在解绑纹理之前,我们调用另一个辅助方法来创建缓冲区。
立方体贴图面的创建,再次,与我们在第七章中设置其 2D 对应物的方式相似,即《向前一步,向下一个级别 - OpenGL 基础》,但技巧是针对每个面只做一次:
void CubeTexture::CreateFaces() {
glBindTexture(GL_TEXTURE_CUBE_MAP, m_textureID);
for (auto face = 0; face < 6; ++face) {
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0,GL_RGBA,
TextureWidth, TextureHeight, 0, GL_RGBA,
GL_UNSIGNED_BYTE, nullptr);
}
}
一旦纹理被绑定,我们就遍历每个面,并使用glTexImage2D来设置面。每个面都被视为一个 2D 纹理,所以这部分应该不会有什么新内容。注意,然而,使用GL_TEXTURE_CUBE_MAP_POSITIVE_X定义的使用是第一个参数。2D 纹理会接受一个GL_TEXTURE_2D定义,但由于立方体贴图以展开的方式存储,正确获取这部分内容非常重要。
注意
GL_TEXTURE_CUBE_MAP_有六个定义。它们都定义在一行中,即+X、-X、+Y、-Y、+Z和-Z*,这就是为什么我们可以通过简单地将一个整数加到定义中,使用一些基本的算术来将正确的面传递给函数。
清除立方体贴图纹理相对简单:
void CubeTexture::Clear() {
glClearColor(1.f, 1.f, 1.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
注意,我们指定清除颜色为白色,因为这代表在阴影图中从光源到无穷远的距离。
最后,采样立方体贴图实际上与采样常规 2D 纹理没有区别:
void CubeTexture::SamplingBind(unsigned int l_unit) {
assert(l_unit >= 0 && l_unit <= 31);
glActiveTexture(GL_TEXTURE0 + l_unit);
glEnable(GL_TEXTURE_CUBE_MAP);
glBindTexture(GL_TEXTURE_CUBE_MAP, m_textureID);
}
void CubeTexture::SamplingUnbind(unsigned int l_unit) {
assert(l_unit >= 0 && l_unit <= 31);
glActiveTexture(GL_TEXTURE0 + l_unit);
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
glDisable(GL_TEXTURE_CUBE_MAP);
}
对于采样,绑定和解绑都需要我们传入我们想要使用的纹理单元。一旦单元被激活,我们应该启用立方体贴图的使用,然后绑定立方体贴图纹理句柄。解绑纹理时应该遵循此过程的逆过程。
注意
请记住,片段着色器中的相应sampler2D/samplerCube统一变量被设置为保存它们所采样的纹理的单元 ID。当纹理被绑定时,该单元的特定 ID 将从那时起在着色器中用于访问,而不是实际的纹理句柄。
渲染到离屏缓冲区
我们在第七章中没有涵盖的内容是,将场景渲染到缓冲区图像,而不是直接在屏幕上绘制。幸运的是,因为 OpenGL 作为一个巨大的状态机运行,这只是一个在正确的时间调用正确函数的问题,并不需要我们以任何方式重新设计渲染过程。
为了渲染到纹理对象,我们必须使用所谓的帧缓冲区。这是一个非常基本的对象,它将绘制调用指向 FBO 绑定的纹理。虽然 FBO 对于颜色信息很有用,但它们并不携带深度分量。一个渲染缓冲区对象用于将附加分量附加到 FBO 的目的。
绘制离屏内容的第一个步骤是创建一个FRAMEBUFFER对象和一个RENDERBUFFER对象:
void CubeTexture::CreateBuffers() {
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glGenRenderbuffers(1, &m_rbo);
glBindRenderbuffer(GL_RENDERBUFFER, m_rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24,
TextureWidth, TextureHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
GL_RENDERBUFFER, m_rbo);
auto status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) { ... } // Print status.
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
在生成缓冲区之后,渲染缓冲区需要为其将提供的任何附加分量分配一些存储空间。在这种情况下,我们只是处理深度分量。
提示
GL_DEPTH_COMPONENT24简单地表示每个深度像素的大小为 24 位。这个定义可以被一个基本的GL_DEPTH_COMPONENT所替代,这将允许应用程序选择像素大小。
然后将深度渲染缓冲区作为深度附加组件附加到 FBO 上。最后,如果在这次过程中出现任何错误,glCheckFramebufferStatus用于捕获它们。下一行简单地使用std::cout打印出状态变量。
注意
当不再使用时,帧缓冲区应该始终使用glBindFramebuffer(GL_FRAMEBUFFER, 0)解绑!这是我们回到将后续几何图形绘制到屏幕上而不是缓冲区纹理的唯一方法。
现在我们已经设置了缓冲区,让我们使用它们!当想要向缓冲区纹理绘制时,首先必须绑定帧缓冲区:
void CubeTexture::RenderingBind() {
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
}
void CubeTexture::RenderingUnbind() {
glBindFramebuffer(GL_FRAMEBUFFER, 0); // Render to screen.
}
在完成使用后,解绑 FBO 是必要的。使用RenderingUnbind()意味着任何后续的几何图形都将绘制到屏幕上。
当然,仅仅因为 FBO 被绑定,并不意味着我们会神奇地开始向立方体贴图绘制。为了做到这一点,我们必须一次绘制立方体贴图的一个面,通过将帧缓冲区绑定到立方体贴图所需的面:
void CubeTexture::RenderToFace(unsigned int l_face) {
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + l_face, m_textureID, 0);
Clear();
}
glFramebufferTexture2D的第一个参数简单地表示我们正在处理一个 FBO。然后我们指定我们想要使用GL_COLOR_ATTACHMENT0。帧缓冲区可以有多个附加组件,并使用着色器将不同的数据输出到每个附加组件。就我们的目的而言,我们只需要使用一个附加组件。
由于我们一次渲染立方体贴图的一个面,基本的定义算术再次被用来选择正确的立方体面进行渲染。最后,在调用Clear()清除当前绑定的面之前,将纹理句柄和米级映射级别传递进去,以完成白色面的清除。
渲染阴影贴图
现在我们已经拥有了开始渲染场景阴影贴图所需的一切。为了支持这个功能,需要对LightManager类进行一些相当重大的修改,更不用说在后续过程中正确存储和使用这些阴影贴图纹理了。让我们看看我们需要做出哪些修改才能实现这一点。
修改光管理器
首先,让我们对光照管理器类定义做一些调整。我们需要几个方法来添加阴影投射者原型、添加实际的阴影投射对象以及渲染阴影贴图:
class LightManager {
public:
...
const std::string& AddCasterPrototype(const std::string& l_name,
std::unique_ptr<ShadowCasterPrototype> l_caster);
ShadowCaster* AddShadowCaster(const std::string& l_prototypeName);
ShadowCasterPrototype* GetPrototype(const std::string& l_name);
...
private:
...
void DrawShadowMap(GLuint l_shadowShader, LightBase& l_light,
unsigned int l_texture);
...
ShadowCasterPrototypes m_casterPrototypes;
ShadowCasters m_shadowCasters;
GL_Camera m_perspectiveCamera;
std::unique_ptr<CubeTexture> m_cubeTextures[LightsPerPass];
...
};
除了上述方法之外,LightManager类还需要存储额外的信息来支持这些更改。需要使用阴影原语原型和原语本身的一个列表来管理必须投射阴影的实体。此外,我们还需要有将用作光照视点的相机类。
最后,需要一个立方体贴图纹理的数组,因为屏幕上的每个光线都可能从完全不同的视角看到场景。这个数组的大小仅仅是每个着色器通道中我们处理的灯光数量,因为这些立方体贴图纹理只需要在它们被采样时存在。一旦完成特定灯光的照明通道,这些纹理就可以被重新用于下一批。
实现光照管理器更改
对LightManager类构造函数的调整相当简单,以便实现这一点:
LightManager::LightManager(...) : ...,
m_perspectiveCamera({0.f, 0.f, 0.f}, 90.f,
CubeTexture::TextureWidth / CubeTexture::TextureHeight,
1.f, 200.f)
{
...
for (auto i = 0; i < LightsPerPass; ++i) {
m_cubeTextures[i] = std::make_unique<CubeTexture>();
}
}
我们首先需要关注的是正确设置透视相机。它被初始化为位于世界中的绝对零坐标,并且其视场角设置为90 度。透视相机的纵横比显然将是1,因为我们用于渲染阴影投射者的纹理的宽度和高度是相同的。视图视锥的最小值设置为1.f,这确保了当光线与面相交时,几何体不会被渲染。然而,最大值将根据每个光线的半径而变化。这个默认值实际上并不重要。
注意
将场景渲染到立方体贴图纹理的视场角设置为90度对于渲染非常重要,因为这是场景能够被完全捕捉到每个相机所看的方向的唯一方式。这个值设置得太低意味着会出现盲点,而设置得太高则会导致重叠。
在构造函数中最后需要确保所有立方体贴图纹理都正确分配。
接下来,让我们关注向光照管理器添加阴影投射者原型:
const std::string& LightManager::AddCasterPrototype(
const std::string& l_name,
std::unique_ptr<ShadowCasterPrototype> l_caster)
{
auto itr = m_casterPrototypes.find(l_name);
if (itr != m_casterPrototypes.end()) {
l_caster.release(); return l_name;
}
for (auto& prototype : m_casterPrototypes) {
if (*prototype.second == *l_caster) {
l_caster.release(); return prototype.first;
}
}
m_window->GetRenderWindow()->setActive(true);
l_caster->UploadVertices();
m_casterPrototypes.emplace(l_name, std::move(l_caster));
return l_name;
}
当添加原型时,调用此特定方法的调用者将提供一个字符串标识符,以及将已建立和分配的智能指针移动到第二个参数之后,在顶点被正确加载之后。首先,我们确保提供的参数名称尚未被占用。如果是,则在释放作为参数提供的原型的内存之后立即返回相同的字符串。
第二个测试确保没有以不同的名称存在具有精确顶点排列的原型,通过遍历每个存储的原型并使用我们之前实现的==运算符来比较这两个原型。如果找到,则在释放l_caster之后返回该原型的名称。
最后,由于我们可以确信我们添加的原型是完全独特的,渲染窗口被设置为活动状态。对象上的UploadVertices被调用以将数据发送到 GPU,并将原型放置在指定的容器中。
注意
使用sf::RenderWindow::setActive(true)确保在上传顶点时使用主上下文。OpenGL 不在不同上下文之间共享其状态,由于 SFML 喜欢在内部保持多个不同的上下文活跃,因此在所有操作期间选择主上下文是至关重要的。
添加阴影投射器本身也相对简单:
ShadowCaster* LightManager::AddShadowCaster(
const std::string& l_prototypeName)
{
auto prototype = GetPrototype(l_prototypeName);
if (!prototype) { return nullptr; }
m_shadowCasters.emplace_back();
auto& caster = m_shadowCasters.back();
caster = std::make_unique<ShadowCaster>();
caster->m_prototype = prototype;
return caster.get();
}
此方法仅需要一个用于原型的字符串标识符,并在原型存在的情况下为新的阴影投射器对象分配空间。注意return语句之前的行。它确保找到的原型被传递给阴影投射器,以便它可以在以后使用该原型。
获取原型非常简单,只需要在unordered_map容器中进行查找:
ShadowCasterPrototype* LightManager::GetPrototype(
const std::string& l_name)
{
auto itr = m_casterPrototypes.find(l_name);
if (itr == m_casterPrototypes.end()) { return nullptr; }
return itr->second.get();
}
我们现在只有一个任务,那就是绘制阴影图!
绘制实际的阴影图
为了保持可管理和模块化,我们将DrawShadowMap方法分解成更小的部分,这样我们就可以独立于其他代码讨论它们。让我们首先看看该方法的实际蓝图:
void LightManager::DrawShadowMap(GLuint l_shadowShader,
LightBase& l_light, unsigned int l_texture)
{
...
}
首先,它接受一个用于阴影传递着色器的句柄。这几乎是原始的,因为句柄是一个简单的无符号整数,我们将在绘制之前将其绑定。第二个参数是我们当前正在绘制阴影图的光的引用。最后,我们有一个作为当前传递中渲染的光的 ID 的无符号整数。在每款着色器传递有 4 个光的情况下,此值将在 0 到 3 之间变化,然后在下一个传递中重置。它将被用作立方体贴图纹理查找的索引。
现在,是时候真正深入到阴影图的渲染中了,从启用必要的 OpenGL 功能开始:
glEnable(GL_DEPTH_TEST);
glEnable(GL_CULL_FACE);
glCullFace(GL_FRONT);
我们在这里将要使用的第一和最明显的特性是深度测试。这确保了不同的阴影投射几何体不会被错误地渲染,避免相互重叠。然后,我们将执行一些面剔除。然而,与正常几何体不同,我们将只剔除前向面。绘制阴影几何体的背面将确保我们使用的精灵的前面会被照亮,因为阴影图中存储的深度是阴影投射原型的最背面。
glUseProgram(l_shadowShader);
auto u_model = glGetUniformLocation(l_shadowShader, "m_model");
auto u_view = glGetUniformLocation(l_shadowShader, "m_view");
auto u_proj = glGetUniformLocation(l_shadowShader, "m_proj");
auto u_lightPos = glGetUniformLocation(l_shadowShader,"lightPos");
auto u_frustumFar = glGetUniformLocation(l_shadowShader,
"frustumFar");
接下来这部分处理的是实际绑定阴影传递着色器并获取不同着色器统一变量的位置。我们有一个模型矩阵统一变量、一个视图矩阵统一变量、一个投影矩阵统一变量、一个灯光位置统一变量以及一个用于更新的视锥体远端统一变量。
auto& texture = m_cubeTextures[l_texture];
auto l_pos = l_light.m_lightPos;
m_perspectiveCamera.SetPosition({ l_pos.x, l_pos.z, l_pos.y });
glViewport(
0, 0, CubeTexture::TextureWidth, CubeTexture::TextureHeight);
texture->RenderingBind();
glUniform3f(u_lightPos, l_pos.x, l_pos.z, l_pos.y);
代码的这一部分获取了特定灯光的适当立方体贴图纹理的引用,存储了灯光位置,并将透视摄像机定位在该确切位置。
注意
注意交换的 Z 和 Y 坐标。默认情况下,OpenGL 处理右手坐标系。它还处理默认的 up 方向是 +Y 轴。我们的灯光使用 +Z 轴作为 up 方向来存储坐标。
在设置好摄像机后,glViewport 被调用以调整渲染目标的大小为立方体贴图纹理的大小。然后,立方体贴图被绑定用于渲染,并将灯光位置统一变量提交给着色器。就像之前一样,这里的 Z 和 Y 方向被交换。
在设置完成后,我们可以开始为立方体贴图的每个面渲染场景:
for (auto face = 0; face < 6; ++face) {
texture->RenderToFace(face);
m_perspectiveCamera.SetForwardDir(CubeMapDirections[face]);
m_perspectiveCamera.SetUpDir(CubeMapUpDirections[face]);
m_perspectiveCamera.SetFrustumFar(l_light.m_radius);
m_perspectiveCamera.RecalculatePerspective();
auto viewMat = m_perspectiveCamera.GetViewMatrix();
auto& projMat = m_perspectiveCamera.GetProjectionMatrix();
glUniformMatrix4fv(u_view, 1, GL_FALSE, &viewMat[0][0]);
glUniformMatrix4fv(u_proj, 1, GL_FALSE, &projMat[0][0]);
glUniform1f(u_frustumFar, m_perspectiveCamera.GetFrustumFar());
for (auto& caster : m_shadowCasters) {
auto modelMat = caster->m_transform.GetModelMatrix();
glUniformMatrix4fv(u_model, 1, GL_FALSE, &modelMat[0][0]);
caster->m_prototype->Draw();
}
}
首先告诉立方体贴图纹理我们希望渲染哪个面,以正确设置 FBO。然后,将那个特定面的前向和向上方向传递给灯光的摄像机,以及视锥体远值,即灯光的半径。然后重新计算透视投影矩阵,并从 GL_Camera 获取视图和投影矩阵,将它们以及视锥体远值传递给着色器。
最后,对于立方体贴图的 6 个面,我们遍历所有的阴影投射对象,检索它们的模型矩阵,将它们传递到着色器中,并调用原型的 Draw() 方法,该方法负责渲染。
在绘制完所有纹理面之后,我们需要将状态设置回渲染阴影图之前的状态:
texture->RenderingUnbind();
glViewport(
0, 0, m_window->GetWindowSize().x, m_window->GetWindowSize().y);
glDisable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
glCullFace(GL_BACK);
首先将纹理从渲染中解绑,这会将 FBO 设置为 0,并允许我们再次绘制到屏幕上。然后,视口被调整回窗口原始大小,深度测试和面剔除都被禁用。
阴影传递着色器
阴影映射的 C++ 方面已经完成,但我们还有一些逻辑需要覆盖。这里的着色器实际上起着将顶点信息转换为深度的关键作用。让我们首先看看顶点着色器:
in vec3 position;
uniform mat4 m_model;
uniform mat4 m_view;
uniform mat4 m_proj;
uniform vec3 lightPos;
uniform float frustumFar;
out float distance;
void main() {
vec4 worldCoords = m_model * vec4(position, 1.0);
float d = length(worldCoords.xyz - lightPos);
d /= frustumFar;
gl_Position = m_proj * m_view * worldCoords;
distance = d;
}
我们在 GPU 上接收到的顶点位置vec3输入坐标是在局部空间中,这意味着它们必须通过多个矩阵传递,以便按顺序转换到世界、视图和裁剪空间。首先计算世界坐标并单独存储,因为它们用于确定顶点与光源之间的距离。这个距离存储在局部变量d中,然后除以视锥体的远值,将其转换为范围[0;1]。然后使用世界、视图和投影矩阵将顶点位置转换为裁剪空间,并将距离值传递到片段着色器,在那里它被存储为特定像素的颜色:
in float distance;
void main() {
gl_FragColor = vec4(distance, distance, distance, 1.0);
}
记住,顶点着色器的输出变量在顶点之间进行插值,所以这些顶点之间的每个片段将以渐变的方式着色。
结果
尽管我们在这个项目中还没有任何实际的几何形状来查看这个结果,一旦完成,它将看起来像以下截图:

在这个特定的情况下,原始形状非常接近光源,因此它们被着色得非常暗。对于更大的距离,阴影贴图的一个特定面看起来可能像这样,其中#1是靠近摄像机的原始形状,#2更远,而#3接近视锥体的远端:

适配光照阶段
在渲染出阴影贴图后,可能会非常诱人地想要在我们的现有代码中尝试采样它们,因为困难的部分已经过去了,对吧?然而,并非完全如此。虽然我们之前的方案非常接近,但遗憾的是,由于 SFML 的限制,我们无法进行立方体贴图的采样。采样本身并不是问题,真正的问题是绑定立方体贴图以便进行采样。记住,采样是通过在着色器内部将采样器的统一值设置为纹理单元 ID来实现的,这个 ID 绑定到我们在 C++代码中的纹理上。SFML 每次在屏幕上渲染或在渲染纹理上渲染时都会重置这些单元。我们之前没有遇到这个问题是因为我们可以通过 SFML 的sf::Shader类设置着色器的统一变量,该类跟踪纹理的引用,并在使用着色器进行渲染时将它们绑定到适当的单元。这都很好,但是当需要采样 SFML 不支持的其他类型的纹理时,比如立方体贴图,问题就出现了。这是唯一一个需要我们在光照阶段完全排除 SFML,并使用原始 OpenGL 的问题。
替换 m_fullScreenQuad
首先,替换LightManager类内部的sf::VertexArray对象,该对象用于重新绘制整个缓冲区纹理,这是我们用于多通道渲染的。由于在这里必须完全排除 SFML,我们无法使用其内置的顶点数组类并渲染一个覆盖整个屏幕的四边形。否则,SFML 将在渲染之前强制其自己的状态,这不会与我们的系统正确配合,因为它每次都会重新分配自己的纹理单元。
定义一个通用的帧缓冲区对象
就像之前一样,我们需要创建一个帧缓冲区对象以便将渲染到纹理而不是屏幕上。由于我们已经为立方体贴图做过一次,让我们快速浏览一下用于 2D 纹理的通用 FBO 类的实现:
class GenericFBO {
public:
GenericFBO(const sf::Vector2u& l_size);
~GenericFBO();
void Create();
void RenderingBind(GLuint l_texture);
void RenderingUnbind();
private:
sf::Vector2u m_size;
GLuint m_FBO;
GLuint m_RBO;
};
主要区别在于我们现在使用纹理的变量大小。它们可能在某个时刻发生变化,因此将大小内部存储是一个好主意,而不是使用常量值。
实现一个通用的帧缓冲区对象
这个类的构造函数和析构函数再次处理资源管理:
GenericFBO::GenericFBO(const sf::Vector2u& l_size) :
m_size(l_size), m_FBO(0), m_RBO(0) {}
GenericFBO::~GenericFBO() {
if (m_FBO) { glDeleteFramebuffers(1, &m_FBO); }
if (m_RBO) { glDeleteRenderbuffers(1, &m_RBO); }
}
我们没有存储纹理句柄,因为这也将根据情况而变化。
为此类创建缓冲区与之前我们所做的大致相同:
void GenericFBO::Create() {
if (!m_FBO) { glCreateFramebuffers(1, &m_FBO); }
glBindFramebuffer(GL_FRAMEBUFFER, m_FBO);
if (!m_RBO) { glCreateRenderbuffers(1, &m_RBO); }
glBindRenderbuffer(GL_RENDERBUFFER, m_RBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24,
m_size.x, m_size.y);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
GL_RENDERBUFFER, m_RBO);
auto status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) { ... } // Print status.
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
}
就像立方体贴图纹理一样,我们需要将深度渲染缓冲区附加到 FBO 上。在分配和绑定后,FBO 会检查错误,并且两个缓冲区都会解绑。
将 FBO 渲染到 2D 纹理要容易得多。渲染绑定需要获取一个纹理句柄,因为内部没有存储,因为这个类是一个通用的类,它将用于许多不同的纹理:
void GenericFBO::RenderingBind(GLuint l_texture) {
glBindFramebuffer(GL_FRAMEBUFFER, m_FBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, l_texture, 0);
}
void GenericFBO::RenderingUnbind() {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
一旦将 FBO 绑定,我们再次调用glFramebufferTexture2D。然而,这次我们使用GL_TEXTURE_2D作为纹理的类型,并将l_texture参数传递给函数。
在 OpenGL 中从缓冲区渲染到另一个缓冲区
在我们可能众多的光照通道中,我们需要一种方法来重新绘制屏幕上的每个像素到缓冲区纹理,就像我们之前做的那样,但这次不使用 SFML。为此,我们将构建一个具有四个顶点的四边形,所有顶点都位于屏幕坐标中,并覆盖整个屏幕。这些顶点还将具有用于采样缓冲区纹理的纹理坐标。这样的顶点的基本结构,类似于我们在第七章中创建的结构,向前迈一步,向下提升一级 - OpenGL 基础看起来是这样的:
struct BasicVertex {
glm::vec3 m_pos;
glm::vec2 m_tex;
};
这个小的结构将被用于覆盖整个屏幕的四边形原语。
创建一个基本的四边形原语
四边形原语,就像任何其他几何体一样,必须推送到 GPU 以供以后使用。让我们构建一个非常基本的类,将此功能分解成我们可以轻松从其他类中调用的可管理方法:
class BasicQuadPrimitive {
public:
BasicQuadPrimitive();
~BasicQuadPrimitive();
void Create();
void Bind();
void Render();
void Unbind();
private:
GLuint m_VAO;
GLuint m_VBO;
GLuint m_indices;
};
再次强调,我们拥有创建、渲染、绑定和解绑原语的方法。该类存储了这个原语的m_VAO、m_VBO和m_indices,所有这些都需要填写完整。
实现四边形原语类
这个类的构造和析构,再次强调,都负责资源分配/释放:
BasicQuadPrimitive::BasicQuadPrimitive() : m_VAO(0),
m_VBO(0), m_indices(0) {}
BasicQuadPrimitive::~BasicQuadPrimitive() {
if (m_VAO) { glDeleteVertexArrays(1, &m_VAO); }
if (m_VBO) { glDeleteBuffers(1, &m_VBO); }
if (m_indices) { glDeleteBuffers(1, &m_indices); }
}
创建并将原语上传到 GPU 的过程与之前完全相同:
void BasicQuadPrimitive::Create() {
glGenVertexArrays(1, &m_VAO);
glBindVertexArray(m_VAO);
glGenBuffers(1, &m_VBO);
glGenBuffers(1, &m_indices);
glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
BasicVertex vertices[4] = {
// x y z u v
{ { -1.f, 1.f, 0.f }, { 0.f, 1.f } }, // Top-left.
{ { 1.f, 1.f, 0.f }, { 1.f, 1.f } }, // Top-right.
{ { 1.f, -1.f, 0.f }, { 1.f, 0.f } }, // Bottom-right.
{ { -1.f, -1.f, 0.f }, { 0.f, 0.f } } // Bottom-left.
};
auto stride = sizeof(vertices[0]);
auto texCoordOffset = sizeof(vertices[0].m_pos);
glBufferData(GL_ARRAY_BUFFER, 4 * sizeof(vertices[0]),
&vertices[0], GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, 0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride,
(void*)texCoordOffset);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_indices);
unsigned int indices[6] = { 0, 1, 2, 2, 3, 0 }; // CW!
glBufferData(GL_ELEMENT_ARRAY_BUFFER, 6 * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);
Unbind();
}
这里的主要区别在于我们定义了方法内部的顶点,因为它们永远不会改变。顶点属性指针在数据推送到 GPU 后设置;索引以顺时针方向定义(SFML 的默认方式),并推送到 GPU。
绑定和解绑用于渲染的缓冲区,再次强调,与 OpenGL 中所有其他几何体完全相同:
void BasicQuadPrimitive::Bind() {
if (!m_VAO) { return; }
glBindVertexArray(m_VAO);
glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_indices);
}
void BasicQuadPrimitive::Unbind() {
if (!m_VAO) { return; }
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
由于我们使用索引,渲染四边形是通过调用glDrawElements来实现的,就像之前一样:
void BasicQuadPrimitive::Render() {
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
}
这就完成了从离屏缓冲区到屏幕渲染的必要准备工作。
对光管理器进行更改
由于我们对阴影渲染过程的完全重构,显然LightManager类内部的一些东西将不得不改变。首先,让我们从我们需要存储的一些新数据开始:
using MaterialHandles = std::unordered_map<
MaterialMapType, unsigned int>;
using MaterialUniformNames = std::unordered_map<
MaterialMapType, std::string>;
MaterialHandles和MaterialUniformNames容器将用于存储我们的光通行着色器中均匀量的名称和位置。这是完全为了通过自动化使新材质映射类型和均匀量的映射变得更加容易而做出的努力。
在处理完这些之后,让我们来看看LightManager类的定义以及我们需要对其进行的更改:
class LightManager {
...
private:
void GenerateMaterials();
void Bind2DTextures(GLuint l_program, int l_pass);
void Unbind2DTextures();
void SubmitLightUniforms(GLuint l_program,
unsigned int l_lightID, const LightBase& l_light);
...
MaterialHandles m_materialHandles;
MaterialUniformNames m_materialNames;
//sf::VertexArray m_fullScreenQuad;
GenericFBO m_rendererFBO;
BasicQuadPrimitive m_fullScreenQuad;
...
};
除了创建一些新的辅助方法来生成材质名称、绑定和解绑光通行采样所需的所有必要的 2D 纹理,以及将给定光的均匀量提交到光通行着色器之外,我们还存储了材质名称和句柄。m_fullScreenQuad类被我们自己的类所取代,与之相伴的是GenericFBO对象,它将帮助我们渲染到离屏缓冲区。
实现光管理器更改
我们LightManager类的构造函数现在在设置我们添加的所有新数据成员时需要做更多的工作:
LightManager::LightManager(...) : ...,
m_rendererFBO(l_window->GetWindowSize()), ...
{
m_window->GetRenderWindow()->setActive(true);
GenerateMaterials();
m_materialNames[MaterialMapType::Diffuse] = "DiffuseMap";
m_materialNames[MaterialMapType::Normal] = "NormalMap";
m_materialNames[MaterialMapType::Specular] = "SpecularMap";
m_materialNames[MaterialMapType::Height] = "HeightMap";
m_window->GetRenderWindow()->setActive(true);
m_rendererFBO.Create();
m_window->GetRenderWindow()->setActive(true);
m_fullScreenQuad.Create();
...
}
首先,我们将使用 FBO 在初始化列表中设置窗口的大小。然后我们通过激活我们的窗口来确保主 OpenGL 上下文是活动的,并调用GenerateMaterials方法,该方法将负责材质纹理的分配和存储相应的纹理句柄。
然后将所有材质类型的均匀 sampler2D 名称存储在适当的容器中。这些名称必须与光通行着色器内部的一致!
最后,再次选择主 OpenGL 上下文并创建 FBO。我们也为m_fullScreenQuad类做同样的事情。
GenerateMaterials()方法可以像这样实现:
void LightManager::GenerateMaterials() {
auto windowSize = m_window->GetWindowSize();
for (auto i = 0; i <
static_cast<int>(MaterialMapType::COUNT); ++i)
{
auto type = static_cast<MaterialMapType>(i);
auto pair = m_materialMaps.emplace(type,
std::move(std::make_unique<sf::RenderTexture>()));
auto& texture = pair.first->second;
texture->create(windowSize.x, windowSize.y);
m_materialHandles[type] = texture->
getTexture().getNativeHandle();
}
}
它遍历每种材料类型并为它创建一个新的纹理,就像我们之前做的那样。这里唯一的区别是我们还把新创建的纹理句柄存储在 m_materialHandles 中,试图将特定的 MaterialMapType 与现有的纹理关联起来。我们仍然使用 SFML 的渲染纹理,因为它们在管理 2D 资源方面做得很好。
在灯光遍历着色器中绑定所有必要的纹理如下所示:
void LightManager::Bind2DTextures(GLuint l_program, int l_pass) {
auto finishedTexture = m_window->GetRenderer()->
GetFinishedTexture()->getTexture().getNativeHandle();
auto lastPassHandle = (l_pass == 0 ?
m_materialHandles[MaterialMapType::Diffuse] :
finishedTexture);
m_window->GetRenderWindow()->setActive(true);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, lastPassHandle);
glUniform1i(glGetUniformLocation(l_program, "LastPass"), 0);
for (int i = 0;i<static_cast<int>(MaterialMapType::COUNT);++i) {
auto type = static_cast<MaterialMapType>(i);
glActiveTexture(GL_TEXTURE1 + i);
glBindTexture(GL_TEXTURE_2D, m_materialMaps[type]->
getTexture().getNativeHandle());
auto uniform = glGetUniformLocation(l_program,
m_materialNames[type].c_str());
glUniform1i(uniform, i + 1);
}
}
这个特定的方法将在 RenderScene 方法内部用于渲染灯光。它接受两个参数:灯光遍历着色器的句柄和当前遍历的 ID。
完成的纹理句柄随后从 Renderer 类中获取。就像之前一样,我们必须在灯光遍历着色器中将正确的纹理作为 "LastPass" 常量传递。如果我们仍然处于第一次遍历,则使用漫反射纹理。
注意
将纹理传递给着色器进行采样简单来说就是向着色器发送一个整数。这个整数代表我们想要采样的纹理单元。
然后将渲染窗口再次设置为活动状态,以确保主 OpenGL 上下文是活动的。我们接着绑定到纹理单元 0 并使用它来设置 "LastPass" 常量。所有其他材料都在一个 for 循环中处理,该循环为每种材料类型运行一次。激活纹理单元 GL_TEXTURE1 + i,这确保我们从单元 1 开始并向上,因为单元 0 已经在使用。然后绑定适当的纹理,并定位该材料类型的正确采样器常量。然后将常量设置为刚刚激活的纹理单元。
解绑这些纹理则更为简单:
void LightManager::Unbind2DTextures() {
for (int i = 0; i <=
static_cast<int>(MaterialMapType::COUNT); ++i)
{
glActiveTexture(GL_TEXTURE0 + i);
glBindTexture(GL_TEXTURE_2D, 0);
}
}
注意,我们现在从 0 开始迭代,包括材料类型计数。这确保了即使纹理单元 0 也会被解绑,因为我们正在激活 GL_TEXTURE0 + i。
重新处理灯光遍历
最后,我们将查看 RenderScene() 方法。为了清晰起见,我们将像之前一样将其分解成更小的部分:
void LightManager::RenderScene() {
...
}
首先,让我们从方法顶部开始设置一些将在整个方法中使用的变量:
... // Inside the RenderScene() method.
auto renderer = m_window->GetRenderer();
auto passes = static_cast<int>(
std::ceil(static_cast<float>(m_lights.size()) / LightsPerPass));
auto& beginning = m_lights.begin();
auto LightPassShaderHandle = renderer->
GetShader("LightPass")->getNativeHandle();
auto ShadowPassShaderHandle = renderer->
GetShader("ShadowPass")->getNativeHandle();
auto CurrentShaderHandle = (renderer->GetCurrentShader() ?
renderer->GetCurrentShader()->getNativeHandle() : 0);
auto window = m_window->GetRenderWindow();
passes 变量计算出我们需要多少次遍历才能处理给定的灯光数量。然后,我们获取到灯光容器开始位置的引用、灯光遍历着色器句柄、阴影遍历着色器句柄、以及 Renderer 对象内部设置的当前使用的着色器句柄(如果有的话)。最后,获取 window 指针以便于访问。
仍然在 RenderScene 方法内部,我们进入一个 for 循环,该循环将遍历每个遍历:
... // Inside the RenderScene() method.
for (int pass = 0; pass < passes; ++pass) {
auto& first = beginning + (pass * LightsPerPass);
auto LightCount = 0;
...
}
获取对灯光容器迭代器的另一个引用。这次,它指向当前遍历的第一个灯光。同时,设置一个 LightCount 变量来跟踪当前遍历到目前为止渲染的灯光数量。
在我们进行任何实际的光照渲染之前,我们需要为这次渲染过程中将要使用到的灯光绘制阴影贴图:
... // Inside the pass loop.
for (int lightID = 0; lightID < LightsPerPass; ++lightID) {
// Drawing shadow maps.
auto& light = first + lightID;
if (light == m_lights.end()) { break; }
window->setActive(true);
DrawShadowMap(ShadowPassShaderHandle, *light, lightID);
++LightCount;
}
在这里,我们遍历属于此通道的每个灯光。需要检查以确保我们没有到达容器的末尾。如果情况不是这样,通过调用setActive(true)启用主 OpenGL 上下文,并将当前灯光的阴影贴图绘制到立方体贴图缓冲区纹理上。然后增加LightCount,让其余的代码知道在这个通道中我们处理了多少灯光。
在阴影贴图渲染完成后,现在是时候绑定光照通道着色器并开始向其传递信息了:
... // Inside the pass loop.
glUseProgram(LightPassShaderHandle);
Bind2DTextures(LightPassShaderHandle, pass);
glUniform3f(glGetUniformLocation(LightPassShaderHandle,
"AmbientLight"),
m_ambientLight.m_lightColor.x,
m_ambientLight.m_lightColor.y,
m_ambientLight.m_lightColor.z);
glUniform1i(glGetUniformLocation(LightPassShaderHandle,
"LightCount"), LightCount);
glUniform1i(glGetUniformLocation(LightPassShaderHandle,
"PassNumber"), pass);
在光照通道着色器绑定后,我们还必须绑定所有必要的材质贴图的 2D 纹理。这之后是提交环境光统一变量,包括灯光数量和当前通道统一变量。
所有这些都很好,但我们还没有解决最初导致这次大规模重新设计的主要概念,即立方体贴图纹理:
... // Inside the pass loop.
auto BaseCubeMapUnit = static_cast<int>(MaterialMapType::COUNT)+1;
for (int lightID = 0; lightID < LightCount; ++lightID) {
auto& light = first + lightID; // Verified by previous loop.
SubmitLightUniforms(LightPassShaderHandle, lightID, *light);
// Bind the CUBE texture of the light.
m_cubeTextures[lightID]->SamplingBind(BaseCubeMapUnit +lightID);
auto ShadowMapName = "ShadowMap["+std::to_string(lightID)+"]";
glUniform1i(glGetUniformLocation(LightPassShaderHandle,
ShadowMapName.c_str()), BaseCubeMapUnit + lightID);
}
绑定第一个立方体贴图纹理的纹理单元是通过简单地将材质贴图类型的数量加 1 来定义的。目前我们有四种类型,并且将单元0专门用于LastPass纹理,这意味着单元 1-4 将用于材质贴图纹理。这留下了单元 5 及以上用于其他采样器。
进入另一个for循环,这次使用LightCount变量作为最大值。我们已经确定了在阴影通道中我们处理了多少灯光,因此在这里不需要再次进行那个检查。
获取一个灯光的引用并将其传递到SubmitLightUniforms()方法中,同时传递光照通道着色器句柄和当前正在使用的灯光编号。然后为该特定灯光绑定立方体贴图纹理进行采样。注意使用BaseCubeMapUnit + lightID。这确保了每个灯光都拥有自己的纹理单元。
在光照通道着色器内部,阴影贴图采样器将被存储在一个数组中。因此,基于当前我们正在处理的当前灯光 ID,为数组的每个元素构造一个字符串名称,并将纹理单元的统一变量发送到着色器。
最后,因为所有统一变量和纹理都已正确绑定和更新,我们可以通过渲染m_fullScreenQuad来实际调用光照通道着色器:
... // Inside the pass loop.
m_rendererFBO.RenderingBind(renderer->GetCurrentTexture()->
getTexture().getNativeHandle());
m_fullScreenQuad.Bind();
m_fullScreenQuad.Render(); // This is where the magic happens!
m_fullScreenQuad.Unbind();
m_rendererFBO.RenderingUnbind();
Unbind2DTextures();
首先,将 FBO 绑定到当前用作缓冲区的纹理的句柄上。然后绑定四边形,渲染,并再次解绑。这所有的一切都是为了将整个完成的缓冲区纹理重新绘制到当前缓冲区纹理上,因此 FBO 被解绑。在这个时候,2D 纹理也被解绑,因为光照通道着色器刚刚开始执行。
说到解绑,所有这些立方体贴图纹理也需要被解绑:
... // Inside the pass loop.
for (int lightID = 0; lightID < LightCount; ++lightID) {
m_cubeTextures[lightID]->SamplingUnbind(
BaseCubeMapUnit + lightID);
}
在这个阶段,在光照过程循环中最后要做的就是在 Renderer 类内部交换缓冲区纹理:
... // Inside the pass loop.
renderer->SwapTextures();
这确保了最新的缓冲区始终被存储为完成的纹理。
最后,一旦光照过程开始,我们必须清理所有状态并实际渲染完成的缓冲区纹理:
... // Right after the pass loop, inside RenderScene().
glUseProgram(CurrentShaderHandle);
window->resetGLStates();
auto currentView = window->getView();
window->setView(window->getDefaultView());
renderer->DrawBufferTexture();
window->setView(currentView);
着色器程序首先被重置为执行光照过程之前的状态。SFML 窗口本身的 OpenGL 状态也被重置,因为我们使用 OpenGL 函数很可能会改变它们。之后,我们获取当前窗口视图,将窗口重置为其默认视图,绘制缓冲区纹理,并交换回之前的视图,就像在第八章 光之诞生!高级光照简介中描述的那样。
将光照统一变量提交给着色器
我们还没有介绍的一个小段代码是实际将光照统一变量提交给光照过程着色器:
void LightManager::SubmitLightUniforms(GLuint l_program,
unsigned int l_lightID, const LightBase& l_light)
{
auto window = m_window->GetRenderWindow();
auto id = "Lights[" + std::to_string(l_lightID) + "].";
sf::Vector2i screenPos = window->mapCoordsToPixel(
{ l_light.m_lightPos.x, l_light.m_lightPos.y },
window->getView());
float y = static_cast<float>(
static_cast<int>(window->getSize().y) - screenPos.y);
glUniform3f(glGetUniformLocation(l_program,
(id + "position").c_str()),
screenPos.x, y, l_light.m_lightPos.z);
glUniform3f(glGetUniformLocation(l_program,
(id + "color").c_str()),
l_light.m_lightColor.x,
l_light.m_lightColor.y,
l_light.m_lightColor.z);
glUniform1f(glGetUniformLocation(l_program,
(id + "radius").c_str()), l_light.m_radius);
glUniform1f(glGetUniformLocation(l_program,
(id + "falloff").c_str()), l_light.m_falloff);
glUniform1f(glGetUniformLocation(l_program,
(id + "specularExponent").c_str()),
l_light.m_specularExponent);
glUniform1f(glGetUniformLocation(l_program,
(id + "specularStrength").c_str()),
l_light.m_specularStrength);
}
这段代码与第八章 光之诞生!高级光照简介中的代码几乎完全相同,只是它使用原始的 OpenGL 函数提交统一变量。
新的改进后的光照过程着色器
由于光照过程必须完全重写以使用原始的现代 OpenGL,着色器也需要反映这些变化。首先,顶点着色器现在要简单得多,因为它不再使用过时和已弃用的方法来获取和变换顶点信息、纹理坐标等:
in vec3 position;
in vec2 texCoordIn;
out vec2 texCoords;
void main()
{
texCoords = texCoordIn;
gl_Position = vec4(position, 1.0);
}
传递给这个着色器的位置是 m_fullScreenQuad 的位置,因此它已经在裁剪空间中。没有必要对其进行变换。纹理坐标直接传递到片段着色器,在那里它们在顶点之间进行插值,确保对每个像素进行采样:
const int MaxLights = 4;
const float LightHeightOffset = 16.0;
in vec2 texCoords;
uniform sampler2D LastPass;
uniform sampler2D DiffuseMap;
uniform sampler2D NormalMap;
uniform sampler2D SpecularMap;
uniform sampler2D HeightMap;
uniform samplerCube ShadowMap[MaxLights];
uniform vec3 AmbientLight;
uniform int LightCount;
uniform int PassNumber;
光照过程片段着色器顶部有几个新的值。我们有一个常数,它将被用来偏移光照的高度,我们将在稍后进行介绍。还有从顶点着色器输入的纹理坐标值,这是我们将要采样的。最后,我们使用 samplerCube 统一变量的数组来访问阴影图信息。
让我们看看光照过程片段着色器的主要内容:
void main()
{
vec4 pixel = texture2D(LastPass, texCoords);
vec4 diffusepixel = texture2D(DiffuseMap, texCoords);
vec4 normalpixel = texture2D(NormalMap, texCoords);
vec4 specularpixel = texture2D(SpecularMap, texCoords);
float pixelheight = texture2D(HeightMap, texCoords).r * 255.0;
vec3 PixelCoordinates =
vec3(gl_FragCoord.x, gl_FragCoord.y, pixelheight);
vec4 finalPixel = pixel;
...
if(PassNumber == 0) { finalPixel *= vec4(AmbientLight, 1.0); }
for(int i = 0; i < LightCount; ++i){
...
float ShadowValue = CalculateShadow(
PixelCoordinates, Lights[i].position, i);
finalPixel += (diffusepixel *
(vec4(Lights[i].color, 1.0) * attenuation) +
vec4(specularReflection, 1.0))
* normalDot * ShadowValue;
}
gl_FragColor = finalPixel;
}
虽然有所变化,但奇怪的是,一切似乎都保持不变。我们像以前一样从不同的纹理中采样所有值,但现在我们使用从顶点着色器传递下来的 texCoords 变量。
另一个小变化是检查环境光照的通行编号。在上一章中,为了清晰起见,它曾是 1。现在它已更改为 0。
最后,我们今天在这里的真正原因是进行阴影计算。从CalculateShadow函数中获取一个浮点值,该函数接受当前片段的坐标、当前光的位置以及当前光的编号。这个值随后在计算最终像素颜色时使用。最终像素简单地乘以ShadowValue,这决定了它处于阴影中的程度。
此函数用于计算片段的阴影值,在着色器顶部实现如下:
float CalculateShadow(vec3 fragment, vec3 light, int lightID) {
light.z += LightHeightOffset;
vec3 difference = fragment - light;
float currentDepth = length(difference);
difference.y *= -1.0;
float nearestDepth = texture(ShadowMap[lightID],
difference.xzy).r;
return (currentDepth > nearestDepth * Lights[lightID].radius
? nearestDepth : 1.0);
}
看起来很简单,对吧?确实如此。首先,光的高度通过我们在着色器顶部定义的高度偏移常量进行偏移。这只是进一步调整的细节,以确保光照看起来尽可能好,并且可以完全改变。当前的值看起来比默认的 0 要好。
然后通过从另一个中减去来计算片段位置与光位置之间的差值。这里的顺序很重要,因为这将用作方向向量,以确定应该从立方体贴图纹理的哪个面进行采样。
注意
请记住,我们的片段和光的位置使用Z组件作为高度。这有效地使Y成为深度轴,可以将其可视化为由屏幕向外的方向,而不是X的左右方向,以及Z的上下方向。
currentDepth变量是从光到被采样的片段的距离。然后,差分向量的Y分量被反转,因为在 OpenGL 使用的右手坐标系中,指向屏幕意味着进入负值。
现在是时候实际采样阴影贴图纹理并获取特定片段的最近深度了。这是通过传递差分向量作为方向向量来完成的。不用担心它没有被归一化,因为它不需要。还要注意Z和Y组件的交换。再次,我们使用Z作为高度,而 OpenGL 使用Y。最后,我们检查片段与光之间的深度是否大于从当前阴影贴图采样的深度,如果是,则意味着片段处于阴影中。可以返回 0,但为了创建随着距离逐渐变淡的阴影,我们返回nearestDepth。这是最终像素乘以的值,因为它在[0;1]范围内,所以我们得到与距离成线性关系的渐变。
注意
注意在检查时将nearestDepth乘以光半径,这代表视锥体的远值。这将其从[0;1]范围转换为阴影原语实际距离光的真实距离。
考虑以下图表以帮助说明问题:

在这里,从样本点到光源的主要箭头是currentDepth,而nearestDepth在乘以光源半径后,是从中间黑色盒子到光源的箭头。
将阴影投射组件添加到实体中
现在我们已经解决了所有渲染问题,我们还需要确保实体可以投射阴影。这将通过实际上将特殊组件附加到实体来实现,这些组件将包含在阴影传递期间使用的 3D 几何形状的指针。显然,这些几何形状需要更新以匹配它们所代表的实体的位置,这就是为什么组件数据将伴随一个单独的系统,用于实际保持一切同步。
添加阴影投射组件
首先,因为我们的实体存在于 ECS 范式内,我们需要添加一个表示实体阴影体积的组件:
class C_ShadowCaster : public C_Base {
public:
C_ShadowCaster() : C_Base(Component::ShadowCaster),
m_shadowCaster(nullptr) {}
void SetShadowCaster(ShadowCaster* l_caster) {
m_shadowCaster = l_caster;
}
void UpdateCaster(const glm::vec3& l_pos) {
m_shadowCaster->m_transform.SetPosition(l_pos);
}
void ReadIn(std::stringstream& l_stream) {
m_shadowPrimitive = std::make_unique<ShadowCasterPrototype>();
for (auto i = 0; i < ShadowMeshVertices; ++i) {
l_stream >> m_shadowPrimitive->m_vertices[i].x >>
m_shadowPrimitive->m_vertices[i].y >>
m_shadowPrimitive->m_vertices[i].z;
}
}
std::unique_ptr<ShadowCasterPrototype> m_shadowPrimitive;
private:
ShadowCaster* m_shadowCaster;
};
此组件将用于从实体文件中加载实体阴影投射原始形状,以及更新相应的ShadowCaster实例。例如,玩家实体文件在添加新组件后可能看起来像这样:
Name Player
Attributes 511
|Component|ID|Individual attributes|
...
Component 8 -0.5 0.0 0.5 -0.5 0.0 -0.5 0.5 0.0 -0.5 ...
创建阴影系统
更新这些组件应该在为该目的专门设计的独立系统中完成。因为我们之前已经这样做了很多次,让我们看看代码的相关部分:
S_Shadow::S_Shadow(SystemManager* l_systemMgr)
: S_Base(System::Shadow, l_systemMgr),
m_lightManager(nullptr)
{
Bitmask req;
req.TurnOnBit((unsigned int)Component::Position);
req.TurnOnBit((unsigned int)Component::ShadowCaster);
m_requiredComponents.push_back(req);
req.Clear();
}
此系统的构造函数只是设置实体需求以属于这里。显然,它需要位置和阴影投射组件。
更新这些组件同样简单:
void S_Shadow::Update(float l_dT) {
if (!m_lightManager) { return; }
EntityManager* entities = m_systemManager->GetEntityManager();
for (auto &entity : m_entities) {
auto position = entities->GetComponent<C_Position>(
entity, Component::Position);
auto caster = entities->GetComponent<C_ShadowCaster>(
entity, Component::ShadowCaster);
float height = static_cast<float>(
(position->GetElevation() * Sheet::Tile_Size) -
Sheet::Tile_Size);
caster->UpdateCaster({
position->GetPosition().x,
height,
position->GetPosition().y - 8.f });
}
}
对于属于此系统的每个实体,获取其位置和阴影投射组件。然后调用阴影投射的UpdateCaster方法,传入 2D 位置和高度。8.f的常量值仅用于偏移阴影原始形状,以便正确居中。
注意
注意,Y 和 Z 值再次被交换。
最后,因为我们希望在灯光管理器中正确地放置和管理唯一的阴影投射原型,阴影系统必须实现一个方法,该方法将在实体加载完成并即将添加时被调用,以便正确设置一切:
void S_Shadow::OnEntityAdd(const EntityId& l_entity) {
auto component = m_systemManager->GetEntityManager()->
GetComponent<C_ShadowCaster>(l_entity,Component::ShadowCaster);
if (!component) { return; }
std::string entityType;
if (!m_systemManager->GetEntityManager()->
GetEntityType(l_entity, entityType))
{
... // Error
return;
}
auto name = m_lightManager->AddCasterPrototype("Entity_" +
entityType, std::move(component->m_shadowPrimitive));
auto caster = m_lightManager->AddShadowCaster(name);
if (!caster) { return; } // Error
component->SetShadowCaster(caster);
caster->m_transform.SetScale({ 16.f, 16.f, 16.f });
}
一旦获取到阴影投射组件,就会从实体管理器中获取实体类型名称。这仅仅是实体原型的名称,例如玩家、骨骼等。然后尝试添加具有适当名称的原始原型,如果LightManager中已经存在一个完全相同的阴影投射原型,则返回该名称。随后创建阴影投射本身,传递给C_ShadowCaster组件,并调整到合适的大小。目前,这是一个常量值,但显然可以根据实体类型进行更改,如果它在实体文件中与组件数据一起存储的话。
整合所做的更改
最后,我们为了使这个功能正常工作,只剩下将新创建的组件和系统类型添加到 ECS 中:
void Game::SetUpECS() {
...
m_entityManager->AddComponentType<C_ShadowCaster>
(Component::ShadowCaster);
...
m_systemManager->AddSystem<S_Shadow>(System::Shadow);
...
m_systemManager->GetSystem<S_Shadow>(System::Shadow)->
SetLightManager(m_lightManager.get());
}
由于显而易见的原因,阴影系统本身也需要指向灯光管理器的指针。现在运行游戏,所有灯光都正确设置,阴影投射器正确加载,我们应该有三个维度的阴影!

因为实体可以跳跃高度,灯光可以被设置为改变其高度,场景的实际灯光过渡包含了不同高度的瓦片层。移动灯光实际上在三维空间中创造出结果,如果角度正确,阴影可以流动在墙上。经过所有这些艰苦的工作,效果绝对令人惊叹!
可能的问题及其解决方法
尽管我们现在并没有面临这些问题,但大多数 3D 游戏在使用这种方法建立基本阴影后,将不得不处理这些问题。
阴影痤疮是一种可以概括为可怕的撕裂的图形瑕疵,其中被照亮的区域被黑暗和白色线条紧密地覆盖。这是因为阴影图是有限大小的,相邻的像素最终会在实际被阴影的几何体上跨越一小段距离。这可以通过在灯光过渡着色器中简单地向或从阴影图的深度样本中添加或减去一个简单的偏差浮点值来修复。这个浮点值理想情况下不应该是一个常数,而应该取决于几何体上的点和光源之间的坡度。
彼得·潘效应可以被描述为看起来像漂浮离开投射它们的几何体的阴影。添加浮点偏差以修复阴影痤疮通常会使这个问题变得更糟,尤其是在处理极其薄的几何体时。这个问题的常见且简单的修复方法是简单地避免薄几何体,并在阴影过渡期间使用前向面剔除,就像我们做的那样。
百分比更近过滤
你可能已经注意到,我们几何体产生的阴影相当硬,边缘并不完全平滑。就像往常一样,有一个解决方案可以解决这个问题,它涉及到每个像素对阴影图进行几次额外的采样。
通过采样不仅计算出的阴影图像素,还包括周围的像素,我们可以轻松地取所有这些像素的平均值,并使用它来平滑边缘。例如,如果我们的采样像素在阴影中,但周围所有其他采样像素的50%都是照亮的,那么中心像素本身应该只有50%不透明。通过消除像素要么完全照亮要么完全黑暗的二进制规则,我们可以成功地使用这种技术实现软阴影。周围像素的数量越多,显然结果越平滑,但也会降低性能。
摘要
恭喜你完成了这一章节!虽然重新架构我们的照明引擎花费了相当长的时间,但结果不容小觑。这种方法产生的阴影为我们的世界增添了大量的图形多样性。在下一章中,我们将讨论可以应用以使游戏尽可能快速运行的优化方法,这是在本书中使用了所有花哨的、消耗时钟周期的技术之后。在那里见!
第十章。你不应该跳过的章节 - 最终优化
任何游戏最重要的方面是什么?根据一位非常著名的网络名人,那便是能够玩它。华丽的图形和先进的技术无疑为像视频游戏这样视觉和交互式的媒介增添了必要的精致感,但如果这些都妨碍了享受最基本的无缝游戏体验,那么整个游戏可能就只是一个花哨的屏幕保护程序了。即使应用程序在高端机器上运行良好,优化代码也是极其重要的,因为每一次迭代都会排除掉一些可能较旧但仍然可以用来扩大游戏粉丝基础的机器。
在本章中,我们将涵盖以下主题:
-
性能分析和代码指标的基础
-
分析和修复我们代码中的低效之处
-
光线剔除的基础
让我们不要浪费更多的时钟周期,开始清理一些这些低效之处!
使用第三方软件
如预期的那样,我们无法在没有额外工具的情况下完成所有这些工作。性能分析应用程序是一个需要成熟软件后端支持的课题,用于整洁地组织和展示给我们性能细节的数据。"CodeXL"是我们已经在第九章中介绍过的应用程序,《黑暗的速度 - 灯光与阴影》,虽然我们用它来查看运行时的 OpenGL 状态,但它也提供了一系列用于分析 CPU 和 GPU 代码的选项。您可以在以下链接找到并下载它:gpuopen.com/compute-product/codexl/。
当然,如果我们没有 AMD 硬件,可用的性能分析工具集非常有限。虽然我们可以通过有限的 CPU 性能分析选项来应对,但例如在 Nvidia 显卡上进行 GPU 性能分析就需要不同的工具。市面上有一些选择,但一个值得注意的选项是Nvidia Nsight:www.nvidia.com/object/nsight.html。
然而,值得一提的是,Nsight 的最新版本不支持 SFML 调用的某些旧版功能,因此功能再次受到限制。
细节决定成败
人们常说,一位大师工匠不仅知道如何使用工具,还知道何时使用工具。许多程序员常常得出一个错误的结论,认为他们必须不断编写美丽、高效且总体上完美的代码,这样的代码永远不会失败。在实践中,这离事实相差甚远。许多人都是通过艰难的方式才意识到这一点。正如唐纳德·克努特所说:
"程序员在思考或担心程序非关键部分的运行速度上浪费了大量的时间,而这些关于效率的尝试实际上在调试和维护时会产生强烈的负面影响。我们应该忘记关于小效率的事情,比如说 97%的时间:过早优化是万恶之源。"
这并不意味着不应该考虑性能。例如,设计具有后续功能的类,或者为任务选择正确的算法,这些都属于剩余的 3%。简单来说,除非应用程序明显运行缓慢,否则在代码中解决性能问题始终应该是最后的任务之一。
程序员常犯的另一个错误是在评估性能时依赖直觉。很容易忘记程序具有大量的底层复杂性和动态部分,这也是为什么除非经过适当测试,否则很难确切知道特定代码块将如何表现。关键在于始终进行性能分析!游戏运行缓慢吗?拿出分析器来试一试。感觉敌人的路径查找代码真的拖累了性能吗?不要只是感觉,要分析!优化后的代码状态也是如此。不要只是替换大量代码并假设它会运行得更快。先进行基准测量,做出适当的更改,然后分析最终结果以确保新代码运行得更快。开始看到这幅图了吗?很好。现在,让我们直接进入性能分析的基本知识!
性能分析基础知识
应用程序可以以多种不同的方式进行性能分析。从分支和单个指令,到缓存的使用和数据访问模式,都可以在项目中跟踪。然而,由于我们的游戏并不特别复杂,我们实际上只需要关注基于时间的性能分析。
分析器可以以三种基本方式收集有关应用程序的信息:
-
采样: 这是一种周期性的应用程序堆栈捕获,结果相对不准确,但开销非常小。
-
事件收集: 这涉及到利用编译过程,并对其进行配置,以便将某些信息发送到性能分析 DLL。具有更高的开销和更高的精度。
-
仪器设备: 这涉及到在运行时直接将代码注入到应用程序中,从而允许获得最精确的结果,但同时也具有最高的开销。
可以根据所使用的软件和需要收集的数据来利用这些技术中的任何一种。由于我们实际上并不需要极其精确的结果来定位代码的热点,因此最好采用采样方法。
小贴士
正如我们已经建立的,分析并不是一项免费的任务。在某些情况下,它可能会使应用程序的速度慢到几乎无法运行,这取决于任务,可能是完全正常的。
基于时间的采样
使用基于时间的采样技术将对应用程序的所有函数/方法调用、初始化/销毁、几乎可以创建或调用的任何内容进行粗略估计,并为它们分配样本值。这甚至包括底层库,如 STL、SFML、OpenGL 等。如果你的代码使用了它们,它们将会出现在列表中。
这个样本值表示执行某一行代码所花费的时间。有两种类型的时间样本:
-
包含:这涉及到在特定行/代码块内花费的所有时间,包括执行可能被调用的其他函数所需的时间。
-
排除:这仅涉及特定行/代码块执行本身所花费的时间。
我们可能不会处理排他性样本计数,但理解这些术语仍然很重要。
最后,重要的是要理解样本是相对的。如果一个程序运行缓慢,那么整个程序捕获的样本将更少。这个特定的基准不应该根据数量来解释,而应该与代码的其他部分进行比较。
注意
采样应该始终在所有相关的项目优化都启用的情况下进行,因为它会移除用于调试的冗余代码,这可能会干扰结果。在 Visual Studio 的情况下,采样时应使用发布模式。
采样我们的应用程序
现在我们已经掌握了基础知识,让我们实际启动一个配置文件并开始吧!这个过程的第一重要方面实际上是花足够的时间采样所需应用程序的状态。在我们的案例中,采样应该在游戏状态下进行,并且至少持续 20 秒,以捕获足够的信息。如果大部分应用程序采样时间都花在菜单状态下,那么这并不能帮助我们理解实体组件系统的时间复杂度,例如。
其次,我们应该在压力状态下测试我们的应用程序,以确保它在非理想条件下表现良好。为了我们的目的,我们将简单地添加更多的实体、粒子发射器和灯光到场景中,直到构建出类似以下内容的压力测试:

它可能看起来并不美观,但同样,性能问题也不美观。
一旦应用程序采样足够并且终止,大多数分析器都会显示采样过程中运行的所有进程的概览。重要的是只通过点击它来选择我们的游戏,因为我们对此感兴趣。
导航到函数选项卡后,我们应该得到类似以下的内容:

通过点击计时器选项卡并按降序排序条目,我们可以查看采样量最多的函数,从而运行时间最长。这就是你将找到使用通用库如 SFML 并牺牲一些性能的地方。虽然编写特定情况的代码在性能方面可能更优,但考虑到 SFML 在小型到中型项目中的多功能性,这仍然是一个值得付出的代价。
虽然很明显我们的 SFML 渲染代码可能需要一些改进,并在渲染精灵和瓦片时使用顶点数组来减少这个瓶颈,但我们这次不会关注 SFML 特定的优化。相反,让我们首先分析列表上突出显示的条目。正如 glm:: 命名空间所暗示的,我们用于各种计算的 OpenGL 数学库是罪魁祸首。通过右键单击条目,我们可以在调用图中查看它。
注意
调用图是一种帮助我们定位代码中使用特定函数的所有点的工具。
通过简单地分析屏幕上的信息,我们现在能够看到哪些代码以这种方式使用 GLM 矩阵,从而引起性能问题:

如父级部分所示,关于这个特定瓶颈的大多数时间采样都位于 GL_Transform::GetModelMatrix() 方法内部。双击该函数可以让我们查看代码和每行特定的热点:

到现在为止,所有这些都应该开始累积起来。与矩阵相关的两个最常被采样的是 glm::tmat4x4<float,0>::tmat4x4<float,0>(float const&),这是矩阵构造函数,以及 glm::rotate,我们调用了三次。每次我们想要从这个类中获取一个模型矩阵(每帧每个阴影投射器都会调用一次),都会构造并填充许多新的矩阵,使用相当昂贵的 GLM 函数调用,更不用说之后的乘法运算了。
寻找 GPU 瓶颈
寻找 GPU 瓶颈与我们在 CPU 上所做的是相当相似的。它也利用时间采样,并将生成类似的外观报告,列出基于执行时间的 OpenGL 代码,如下所示:

我们在这里不会深入讨论 GPU 优化,但思路是相同的:找到瓶颈,以更有效的方式重新实现代码,并再次测试。
注意
一些 GPU 分析工具,如 Nvidia Nsight,不支持 SFML 制作的旧版 OpenGL API 调用。
提高 CPU 代码性能
在建立基线读取后,我们可以开始对我们的代码进行更改。其中一些更改涉及简单地理解我们使用的库,并更加谨慎地部署它们,而其他更改则围绕做出更好的设计选择,应用更快、更合适的算法,设计更好的数据结构,以及使用 C++标准的最新功能。让我们先看看我们可以对代码进行的一些简单更改。
优化三个最明显的瓶颈
根据分析器的结果,我们之前编写的代码有很多改进的空间。在本节中,我们将解决三个最不高效的实现及其修复方法。
GL_Transform 优化
我们用来说明时间采样如何工作的第一个例子是改进的完美候选。它真的没有太多微妙之处。首先,每次请求模型矩阵时都要重新计算所有涉及的矩阵是非常低效的。更糟糕的是,所有7个矩阵都必须重新创建。这浪费了大量的时钟周期,却没有理由。让我们看看如何快速改进这一点:
class GL_Transform {
public:
...
const glm::mat4& GetModelMatrix();
private:
...
bool m_needsUpdate;
glm::mat4 m_matPos;
glm::mat4 m_matScale;
glm::mat4 m_matRotX;
glm::mat4 m_matRotY;
glm::mat4 m_matRotZ;
glm::mat4 m_matRotCombined;
glm::mat4 m_modelMatrix; // Final matrix.
};
首先,注意GetModelMatrix的返回参数从const 引用改变。这确保了我们每次都不是返回一个新构造的矩阵。此外,我们增加了一个布尔标志,帮助我们跟踪对象的位置、缩放或旋转是否已更改,以及模型矩阵是否需要更新以反映这一点。最后,我们现在将所有 7 个矩阵存储在变换对象中,这样它们就只创建一次。这很重要,因为我们不希望仅仅因为对象的位置发生了变化,就重新计算三个旋转矩阵及其组合矩阵。
接下来,让我们实际实施这些更改,从本类的 setter 开始:
void GL_Transform::SetPosition(const glm::vec3& l_pos) {
if (l_pos == m_position) { return; }
m_position = l_pos;
m_matPos = glm::translate(m_position);
m_needsUpdate = true;
}
这里的一般思路是首先检查提供给 setter 方法的参数是否已经是它应该覆盖的任何参数的当前值。如果不是,则改变位置,并更新位置矩阵,同时将m_needsUpdate标志设置为true。这将确保稍后模型矩阵得到更新。
旋转遵循完全相同的原理:
void GL_Transform::SetRotation(const glm::vec3& l_rot) {
if (l_rot == m_rotation) { return; }
if (l_rot.x != m_rotation.x) {
m_matRotX = glm::rotate(m_rotation.x, glm::vec3(1, 0, 0));
}
if (l_rot.y != m_rotation.y) {
m_matRotY = glm::rotate(m_rotation.y, glm::vec3(0, 1, 0));
}
if (l_rot.z != m_rotation.z) {
m_matRotZ = glm::rotate(m_rotation.z, glm::vec3(0, 0, 1));
}
m_matRotCombined = m_matRotZ * m_matRotY * m_matRotX;
m_rotation = l_rot;
m_needsUpdate = true;
}
然而,在提交赋值之前,我们必须检查向量类的每个成员,因为它们各自都有自己的矩阵。现在越来越清楚的是,我们的目标是只计算我们绝对必须计算的内容。
缩放,再次,完全遵循这个想法:
void GL_Transform::SetScale(const glm::vec3& l_scale) {
if (l_scale == m_scale) { return; }
m_scale = l_scale;
m_matScale = glm::scale(m_scale);
m_needsUpdate = true;
}
GetModelMatrix方法现在应该这样实现:
const glm::mat4& GL_Transform::GetModelMatrix() {
if (m_needsUpdate) {
m_modelMatrix = m_matPos * m_matRotCombined * m_matScale;
m_needsUpdate = false;
}
return m_modelMatrix;
}
首先,检查更新标志以确定矩阵是否需要更新。如果需要,则将所有三个相关矩阵相乘,并将标志重置回 false。然后我们返回 m_modelMatrix 数据成员的 const 引用,确保不会创建一个只是为了后来丢弃的对象。
让我们遵循自己的建议,再次分析应用程序以确保我们的更改有效:

所有与 glm:: 相关的之前突出显示的行现在都已经完全从列表顶部消失!在这个说明中突出显示的异常是在采样 GL_Transform::GetModelMatrix() 时拍摄的,没有通过 const 引用返回,只是为了表明我们的方法确实有效。当方法返回 const 引用时,甚至突出显示的函数也会完全消失。这完美地说明了避免无用的数据副本如何极大地提高整体性能。
粒子系统优化
样本列表顶部的另一个巨大瓶颈正是 ParticleSystem::Draw 方法。实际上,这是我们编写的代码中采样最高的部分。理解渲染这么多粒子会很有压力是合理的,但在这个例子中,未优化的这个方法将我们游戏的帧率降低到 10 FPS:

小贴士
Fraps 是一款免费的屏幕捕获软件,可以录制视频、截图,最重要的是对我们来说,可以显示帧率!虽然它是针对 Windows 的,但还有其他类似工具适用于 Linux 和 OSX。帧率计数器也可以通过简单地计算我们的代码中的帧数并使用 SFML 显示结果来轻松实现。
这绝对是不可原谅的,所以让我们打开性能分析器并剖析一下 Draw 方法:

根据样本计数,主要的不效率似乎在材质值着色器传递内部,其中每个粒子都为正常和漫反射传递进行渲染。然而,还有一些奇怪的事情发生,那就是正常传递的样本似乎非常低,但当渲染漫反射传递时,它们突然大幅增加。考虑到我们只是设置一个 vec3 通用变量并将绘制到渲染纹理中,这看起来可能特别奇怪。这就是为什么需要进一步深入函数堆栈并理解 SFML 在幕后如何处理事情的原因:

由于上下文切换和渲染纹理在幕后工作的方式,以我们这样做的方式渲染两种不同类型的材质图是非常低效的。在运行时切换纹理次数过多会导致严重的性能瓶颈,这也是为什么游戏使用精灵和瓦片图而不是单个图像的原因。
让我们尝试将这些两种不同类型分开,确保一次只渲染一个纹理:
void ParticleSystem::Draw(MaterialMapContainer& l_materials, ...) {
...
if (renderer->UseShader("MaterialValuePass")) {
auto shader = renderer->GetCurrentShader();
// Normal pass.
auto texture = l_materials[MaterialMapType::Normal].get();
shader->setUniform("material",
sf::Glsl::Vec3(0.5f, 0.5f, 1.f));
for (size_t i = 0; i < container->m_countAlive; ++i) {
...
renderer->Draw(drawables[i], texture);
}
// Specular pass.
texture = l_materials[MaterialMapType::Specular].get();
shader->setUniform("material", sf::Glsl::Vec3(0.f, 0.f, 0.f));
for (size_t i = 0; i < container->m_countAlive; ++i) {
...
renderer->Draw(drawables[i], texture);
}
}
...
}
注意,材质统一变量也被移出循环,以防止每次都构建不必要的副本并将其发送到着色器。现在只需运行应用程序,性能的明显提升将很快变得明显。让我们看看通过将我们已有的少量代码分成两部分,性能提高了多少:

我们只是通过将正常和漫反射材质通道分开,就从 10 FPS 跳到了 65 FPS!这才像样!你会注意到这种性能的突然提升将样本计数急剧增加:

这是因为游戏现在运行得更快,并没有表明函数执行时间更长。记住,样本是相对的。在查看列表后,之前高亮的两个代码片段现在出现在列表的下方,样本计数在 20s 左右。这比之前略低,但由于样本是相对的,并且它们都上升了大约 6 倍,这表明性能有了巨大的提升。
光线剔除
我们必须解决的最后一个主要低效问题与第八章中实现的照明系统有关,即让光亮起来!——高级照明简介和第九章黑暗的速度——照明与阴影。
通过使用多通道着色/渲染处理场景中的多个光源是一种很好的技术,但当这些通道开始累积时,它可能会迅速变得低效。解决这个问题的第一步显然是不渲染那些不会影响最终图像的光源。通过消除那些不能直接被场景的视锥体观察到的对象来减少渲染对象数量的技术,也称为剔除,将有助于解决这个问题。
由于我们目前只处理全向点光源,可以通过简单地检查圆形与矩形的碰撞来实现光线剔除。
让我们设置一些辅助函数来帮助我们完成这项工作:
inline float GetDistance(const sf::Vector2f& l_1,
const sf::Vector2f& l_2)
{
return std::sqrt(std::pow(l_1.x - l_2.x, 2) +
std::pow(l_1.y - l_2.y, 2));
}
inline bool CircleInView(const sf::View& l_view,
const sf::Vector2f& l_circleCenter, float l_circleRad)
{
auto HalfSize = l_view.getSize() / 2.f;
float OuterRadius = std::sqrt((HalfSize.x * HalfSize.x) +
(HalfSize.y * HalfSize.y));
float AbsoluteDistance = GetDistance(l_view.getCenter(),
l_circleCenter);
if (AbsoluteDistance > OuterRadius + l_circleRad) {
return false;
}
float InnerRadius = std::min(l_view.getSize().x,
l_view.getSize().y) / 2.f;
if (AbsoluteDistance < InnerRadius + l_circleRad){return true;}
glm::vec2 dir = {
l_circleCenter.x - l_view.getCenter().x,
l_circleCenter.y - l_view.getCenter().y
};
dir = glm::normalize(dir);
sf::Vector2f point = l_circleCenter +
sf::Vector2f(l_circleRad * dir.x, l_circleRad * dir.y);
auto rect = sf::FloatRect(
l_view.getCenter() - HalfSize,
l_view.getSize());
return rect.contains(point);
}
该函数首先在视图矩形周围创建一个外半径,这样我们就可以默认为大多数情况下光线远离视图视锥体的圆形-圆形碰撞检查。获取视图中心和圆形中心的距离,并检查是否超过视图外边界圆的半径加上圆形半径的总和。这是检查光线圆形是否接近视图矩形的最简单方法。
如果光线更靠近视图,就会为视图的矩形构造另一个圆的半径。这次,圆在视图内部,并且只有矩形尺寸较小的一维的半径。如果光线和视图中心之间的距离低于内半径和圆的半径之和,我们就可以确定发生了碰撞。这又是一个我们可以从列表中划掉的常见情况,在默认使用更复杂的算法之前。
最后,如果我们知道光线可能与某个角落相交,我们就将光线的方向向视图归一化,并使用它来获取最近点,然后检查该点是否与表示我们视图的构造的sf::FloatRect相交。
对光线管理类中的RenderScene()方法的实际更改仅涉及存储一个新列表,其中包含肯定影响屏幕上某些内容的光线,以便可以将它们传递给着色器:
void LightManager::RenderScene() {
...
std::vector<LightBase*> unculled;
for (auto& light : m_lights) {
if (!Utils::CircleInView(currentView,
{ light.m_lightPos.x, light.m_lightPos.y },
light.m_radius))
{ continue; }
unculled.emplace_back(&light);
}
auto& beginning = unculled.begin();
auto passes = static_cast<int>(std::ceil(
static_cast<float>(unculled.size()) / LightsPerPass));
if (passes == 0) { passes = 1; }
for (int pass = 0; pass < passes; ++pass) {
...
for (int lightID = 0; lightID < LightsPerPass; ++lightID) {
...
DrawShadowMap(ShadowPassShaderHandle, **light, lightID);
...
}
...
for (int lightID = 0; lightID < LightCount; ++lightID) {
...
SubmitLightUniforms(LightPassShaderHandle,lightID, **light);
...
}
...
renderer->SwapTextures();
}
...
}
注意,我们没有考虑光线的衰减或它在着色器中的衰减情况,以确定是否应该剪裁它。
在剪裁掉所有不必要的灯光后,只有非常繁忙的区域才会经历一些性能损失。此时,应该引起关注的是关卡设计区域,并改进地图的架构。
摘要
恭喜你一路走到最后!这是一段相当漫长的旅程,我们确实可以说,这里涵盖了应该能够激发任何人对高级游戏开发信心的大量内容。即便如此,一如既往地,还有很多特性、优化、技术和主题我们只是简要提及,或者甚至还没有承认。利用这一点作为灵感去追求卓越,因为,正如我们已经确立的,大师级工匠不仅知道如何使用工具,也知道何时使用工具。虽然我们已经涵盖了基础知识,但还有很多更多的工具可以添加到你的工具箱中。使用它们,滥用它们,打破它们并替换它们。做任何需要的事情,但始终记得从中吸取经验,并在下次做得更好。
就这样,愿你的下一个项目展现出额外的打磨水平,并且运行得更快!感谢阅读!


浙公网安备 33010602011771号