精通-C---游戏动画编程-全-
精通 C++ 游戏动画编程(全)
原文:
zh.annas-archive.org/md5/9b794c392ef298ad048c90ced90ebc76译者:飞龙
前言
你是否曾发现自己在一款第一人称或第三人称游戏中,看着你的玩家角色和敌人四处奔跑、跳跃、射击和躲避,而你正按着按钮,对着目标射击,并问自己:
等等...所有角色是如何保持在地面上的?他们是如何知道墙壁在哪里?按钮又是如何知道是我而不是我的队友按下的?
为了回答这些问题,你需要启动一个现代的 3D 引擎,如 Unreal Engine、Unity 或 Godot。你会玩弄模型和动画、关卡、资产、菜单和其他对象。通过完成教程和视频,你会学习如何构建一个类似于你喜爱的游戏的虚拟世界,并对你所取得的成就感到满意。
但如果你的对底层细节、对几个功能的实现的需求仍然存在?即使从可视化编程转向 3D 引擎的底层编程语言,你仍然不满意。代码的复杂性太高,仅从中学到基础知识。所以,你又走上了寻找那些问题的答案的道路...
这听起来熟悉吗?许多游戏程序员都是从这种好奇心开始的,他们想要探究他们所看到事物背后的机制,并迫切想要了解模型是如何被动画化和动画是如何融合的。这可能包括面部动画的工作原理,或者简单的“点头”动作是如何完成的,或者如何让角色在关卡的地形多边形上行走和奔跑,而不是穿墙而过。
本书旨在回答你关于 3D 游戏角色动画实现细节的问题。你将从最基本的 OpenGL 或 Vulkan 渲染器开始,它只用来在屏幕上绘制三角形——然后你将被引导一路向上,从从文件中加载单个角色模型到多个不同模型在游戏地图上漫游,检测和避开虚拟世界中的墙壁和其他实例,遵循预定义的路径,并能够与其他实例交互。
通过本书获得的知识,你将以不同的视角看待游戏中的动画,你会经常微笑,因为你知道它们是如何制作的。
本书面向的对象
如果你已经熟悉 C++和角色动画,但想了解更多关于实现细节和高级角色动画主题,类似于 3D 游戏中的动画,这本书适合你。
本书涵盖的内容
第一章,使用 Open Asset Import Library,概述了Open Asset Import Library(或assimp)的数据结构,并解释了如何从文件中加载角色模型。文件加载过程将通过添加基于 ImGui 的打开文件对话框来增强。此外,还将涵盖在运行时添加和删除模型和实例的代码。
第二章,将动画计算从 CPU 迁移到 GPU,介绍了计算着色器以将计算卸载到图形处理器并将查找数据存储在 GPU 内存中。通过使用 GPU 的巨大并行架构,计算所有实例的节点位置将得到加速,CPU 将再次空闲以执行其他任务。
第三章,添加视觉选择,解释了如何支持应用程序用户在角色实例选择任务中的操作。除了在屏幕上突出显示当前选定的实例外,还将添加通过使用鼠标选择任何实例的能力。
第四章,增强应用程序处理,介绍了将应用程序分为视图模式和编辑模式,这两种模式具有部分不同的配置。此外,还将实现撤销和重做操作。
第五章,保存和加载配置,涵盖了将应用程序的当前配置以及加载的模型和创建的实例存储在 YAML 文件中,以及从文件中加载配置回应用程序。
第六章,扩展相机处理,展示了如何添加不同类型的相机和配置。新的相机设置包括正交投影和第一人称和第三人称视角。
第七章,增强动画控制,涵盖了基本的动画混合、动作状态以及将动作状态与动画剪辑相连接。到本章结束时,您将能够使用键盘和鼠标控制实例,包括额外的动作,如跳跃、翻滚或挥手。
第八章,碰撞检测简介,基于四叉树、轴对齐边界框和边界球体,解释了实例/实例碰撞检测的路径以及碰撞的反应。
第九章,添加行为和交互,添加了一个基于节点的图形编辑器,通过创建和连接简单的节点来控制实例行为。节点将被扩展以覆盖实例之间的可配置交互。
第十章,高级动画混合,介绍了面部动画以表达情感,以及独立于角色模型身体其他部分的加性动画混合来移动头部。
第十一章,加载游戏地图,解释了动态角色模型实例与静态水平几何形状之间的区别,以及如何加载和处理水平数据以实现良好的性能。
第十二章,高级碰撞检测,将碰撞检测扩展到在第第十一章中添加的水平几何形状。将添加简单的重力以保持模型实例位于水平地板上,您将看到如何避免实例与墙壁的碰撞。
第十三章,添加简单导航,涵盖了在加载的游戏地图内的路径查找和导航。将使用一个著名的路径查找算法,以便实例在关卡中自由漫游。
第十四章,创建沉浸式交互式世界,提供了有关如何向角色模型编辑器添加更多酷炫功能的提示和资源,逐步将代码推进到一个简单的游戏引擎。
要充分利用这本书
要充分利用这本书,你应该至少具备中级 C++经验以及向量/矩阵数学知识,以及了解骨骼动画的基础。任何特殊或高级功能都将进行解释,并在它们首次使用章节中包含学习这些功能的资源。但你应该能够自己调试简单的 C++问题(即,通过使用日志语句或通过将调试器附加到应用程序)。
代码是为 OpenGL 4.6 Core 和 Vulkan 1.1+编写的。这两个图形 API 在现代 GPU 中得到了广泛的支持。已知可以与这些 API 版本一起工作的最老图形卡是大约 10 年前制造的英特尔 HD 4000 系列。
| 书中涵盖的软件 | 操作系统要求 | |
|---|---|---|
| OpenGL 4.6 和 Vulkan 1.1+ | Windows 或 Linux | C++17 及以上(至 C++26) |
书中提供的示例代码应在任何运行最新版本 Windows 和 Linux 的台式计算机或笔记本电脑上编译。代码已经与以下组合进行了测试:
-
Windows 10 与 Visual Studio 2022
-
Windows 10 与 Eclipse 2024-09,使用 MSYS2 中的 GCC
-
Ubuntu 24.04 LTS 与 Eclipse 2024-09,使用 GCC 或 Clang
-
Ubuntu 24.04 LTS,使用 GCC 或 Clang 在命令行上编译
如果你使用的是这本书的数字版,我们建议你亲自输入代码或从书的 GitHub 仓库中获取代码(下一节中提供了链接)。这样做将帮助你避免与代码复制粘贴相关的任何潜在错误。
示例代码的完整源代码可在书的 GitHub 仓库中找到(下一节中提供了链接)。书中章节仅包含代码的摘录,涵盖了重要的部分。
下载示例代码文件
你可以从 GitHub 下载这本书的示例代码文件github.com/PacktPublishing/Mastering-Cpp-Game-Animation-Programming。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing找到。查看它们吧!
下载彩色图像
我们还提供了一份包含书中使用的截图/图表彩色图像的 PDF 文件。你可以从这里下载:packt.link/gbp/9781835881927。
使用的约定
本书使用了多种文本约定:
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“通常,你会使用nullptr来表示对象实例不存在。”
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“导入模型按钮可能看起来有点放错位置,但现在我们有改变功能的机会。”
代码块设置如下:
std::shared_ptr<AssimpModel> nullModel = std::make_shared<AssimpModel>();
mModelInstData.miModelList.emplace_back(nullModel)
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
**mat4** **worldPosSkinMat = worldPos[****gl_InstanceID****] * skinMat;**
gl_Position = projection * view * **worldPosSkinMat** * vec4(aPos.x, aPos.y, aPos.z, 1.0);
...
normal = transpose(inverse(**worldPosSkinMat**)) * vec4(aNormal.x, aNormal.y, aNormal.z, 1.0);
任何命令行输入或输出都按以下方式编写:
$ cd chapter01/01_assimp_opengl
$ mkdir build && cd build
$ cmake -G Ninja .. && ninja && ./Main
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果你对此书的任何方面有疑问,请发送电子邮件至questions@packtpub.com。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在此书中发现错误,我们将不胜感激,如果你能向我们报告,请访问www.packtpub.com/submit-errata,点击提交勘误表并填写表格。
盗版:如果你在互联网上发现我们作品的任何形式的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《精通 C++游戏动画编程》,我们很乐意听听你的想法!请点击此处直接访问此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢你购买此书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?
你的电子书购买是否与你的选择设备不兼容?
别担心,现在,随着每本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何时间、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠远不止于此,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-83588-192-7
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件
第一部分
用游戏角色模型填充世界
本书的第一部分从对Assimp,即 Open Asset Import Library 的介绍开始。你将学习如何从文件中加载角色模型,如何处理角色的不同元素,如网格、纹理和节点,以及如何将模型绘制到屏幕上。你还将学习如何通过使用计算着色器将计算负载移动到 GPU,从而释放 CPU 用于本书中介绍的功能。最后,你将探索视觉选择的想法和实现,使你能够通过点击鼠标来在屏幕上选择模型实例。
本部分包含以下章节:
-
第一章,使用 Open Asset Import Library 进行操作
-
第二章,将动画计算从 CPU 迁移到 GPU
-
第三章,添加视觉选择
第一章:使用 Open Asset Import Library 进行工作
欢迎来到《精通 C++游戏动画》!你是一个那种看着电脑或控制台游戏中的动画模型,或者 3D 动画工具,并对自己提出问题的人吗?
这是怎么工作的?他们是怎么做到的?我自己也能做到吗?
如果是这样,这本书将指引你走向实现这一目标的方向。在接下来的 14 章中,你将学习如何创建自己的小游戏角色模型查看器。
本书从使用 Open Asset Import Library 加载文件开始,将导入库中的数据结构转换为更高效的渲染数据结构,并使用简单的 OpenGL 或 Vulkan 渲染器渲染角色模型。你还将学习如何通过将计算负载转移到 GPU 上的基于 GPU 的查找表和计算着色器来优化数据更新和渲染。
对于角色动画,你不仅将深入了解正常的动画混合,还将介绍基于状态的动画控制、添加动画混合以独立于身体其他部分移动头部,以及面部动画。你还将学习如何使用简化版的行为树来控制实例的行为,并在屏幕上的实例之间实现交互。
为了给游戏角色一个合适的家园,你将学习如何将游戏地图加载到应用程序中。在游戏地图中移动将通过添加碰撞检测、角色脚部的逆运动学以及简单的导航来增强,让实例能够在虚拟世界中完全自主地四处移动。
除了动画之外,还介绍了使用鼠标进行交互式选择、将配置保存到文件以允许在更大的虚拟世界中工作,以及处理虚拟世界中的不同摄像头等功能。此外,还将实现基于图形的、基于节点的配置,使你能够以非编程的方式更改实例的行为。
通过结合所有这些步骤,你虚拟世界中的虚拟角色将更接近真实的游戏角色。
加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation。
每一次旅程都始于第一步,所以欢迎来到第一章!本章将为动画应用程序奠定基础,因为你将了解如何将模型文件从你的电脑加载到程序中,在虚拟世界的浩瀚空虚中定位实例,并播放文件中包含的动画。到本章结束时,你的游戏角色模型将能够在屏幕上跳跃、奔跑或行走,可能周围围绕着非动画模型或其他静态对象。
在本章中,我们将涵盖以下主题:
-
游戏角色动画——入门
-
Open Asset Import Library 是什么?
-
加载模型文件
-
使用 Open File 对话框扩展 UI
-
动态添加和删除模型实例
由于本书将使用开源软件和平台无关的库,因此您应该能够在 Windows 和 Linux 上“即开即用”地编译和运行代码。您将在以下 技术要求 部分找到所需软件和库的详细列表,以及它们的安装说明。
技术要求
对于本章,您需要以下内容:
-
拥有 Windows 或 Linux 的 PC,以及本节后面列出的工具
-
Git 用于源代码管理
-
文本编辑器(如 Notepad++ 或 Kate)或完整的 IDE(如 Windows 的 Visual Studio 2022 或 Linux 的 Eclipse/KDevelop)
重要提示
编译代码需要最近版本的 C++ 编译器。在当前的 CMake 构建系统中,已配置为 C++17,但已知代码可以与更新的 C++ 标准兼容,包括但不限于 C++26(尽管编译器必须支持这些标准)。
现在,让我们获取这本书的源代码并开始解压代码。
获取源代码和基本工具
本书代码托管在 GitHub 上,您可以通过以下链接找到:
github.com/PacktPublishing/Mastering-Cpp-Game-Animation-Programming
由于构建系统使用 Git 下载示例中使用的第三方项目,因此您需要安装 Git。
在 Linux 系统上,使用您的包管理器。对于 Ubuntu,以下行安装 Git:
sudo apt install git
在 Windows 上,您可以从这里下载 Git:git-scm.com/downloads。
要解压代码,您可以使用以下两种方法中的任何一种。
使用 Git 获取代码
要获取书中的代码,您应该使用 Git。使用 Git 可以为您提供额外的功能,例如为您的更改创建本地分支,跟踪您的进度,并将您的更新与示例代码进行比较。此外,如果您在探索源代码或在实际章节末尾的实践环节中破坏了代码,您可以轻松地撤销更改。
您可以通过 Git GUI、在 Visual Studio 2022 中克隆存储库或在 CMD 中执行以下命令来在系统中的特定位置获取代码的本地签出:
git clone https://github.com/PacktPublishing/Mastering-Cpp-Game-Animation-Programming
请确保您使用没有空格或特殊字符(如重音符号)的路径,因为这可能会使某些编译器和开发环境产生混淆。
以 ZIP 文件形式获取代码
虽然推荐使用 Git,但您也可以从 GitHub 下载代码的 ZIP 文件。您需要将 ZIP 文件解压到系统上的某个位置。此外,请确保解压 ZIP 文件的路径中不包含空格或特殊字符。
在我们能够使用书中的代码之前,必须安装一些工具和库。我们将从 Windows 安装开始,然后是 Linux 安装。
安装 Windows 所需的工具和库
要在 Windows 机器上编译示例代码,我建议使用 Visual Studio 2022 作为 IDE,因为它包含了快速开始所需的所有内容。使用其他 IDE,如 Eclipse、Rider 或 KDevelop,也没有问题,因为构建由 CMake 管理,但您可能需要安装一个 C++ 编译器,如 MSYS2,以及编译器包作为附加依赖项。
在 Windows 上安装 Visual Studio 2022
如果您想使用 Visual Studio 处理示例文件但尚未安装,请从 visualstudio.microsoft.com/de/downloads/ 下载免费的 Visual Studio 社区版。
然后,按照以下步骤操作:
- 选择使用 C++ 进行桌面开发选项,以便将 C++ 编译器和其他所需工具安装到您的计算机上:

图 1.1:在 Visual Studio 2022 中安装 C++ 桌面开发
- 然后,在单独的组件下,也勾选C++ CMake 工具为 Windows选项:

图 1.2:勾选 CMake 工具为 Windows 安装在 Visual Studio 2022 中的复选框
- 完成 Visual Studio 的安装,启动它,并跳过初始项目选择屏幕。
在 Windows 上启用长路径名
当使用 Windows 10 或 11 的全新安装时,文件的路径最大长度为 260 个字符。根据包含本书代码的文件夹位置,Visual Studio 2022 可能会遇到由于临时构建文件夹路径超过 260 个字符限制而导致的错误。
要启用长路径名,需要调整Windows 注册表。一种简单的方法是创建一个具有 .reg 扩展名的文本文件,例如,long-paths.reg,并将以下内容复制到文件中:
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem]
"LongPathsEnabled"=dword:00000001
双击文件将自动启动Windows 注册表编辑器以将设置导入 Windows 注册表。通过单击是确认UAC对话框和以下警告对话框后,注册表编辑器将导入新设置。
现在,重新启动计算机以激活长路径名并继续安装。
下载 Open Asset Import Library
对于 Windows,Open Asset Import Library 必须从源文件构建和安装。如图 1.3所示,从 github.com/assimp/assimp 克隆仓库到一个新的 Visual Studio 2022 项目中:

图 1.3:在 Visual Studio 2022 中克隆资产导入 GitHub 仓库
作为替代,您可以从 Git Bash 或通过 Git GUI 创建克隆:
git clone https://github.com/assimp/assimp
配置构建
我们需要做一些调整来创建静态库而不是动态库。使用静态库使我们的构建过程更容易,因为我们不需要担心额外的 DLL 文件。
要更改 CMake 设置,在 CMakeLists.txt 文件上右键单击后选择以下选项:

图 1.4:更改资产导入器的 CMake 设置
在出现的 Visual Studio 2022 的 配置 选项卡中,将配置名称更改为 x64-RelWithDebInfo,并将配置类型更改为 RelWithDebInfo:

图 1.5:修改资产导入器的当前配置
使用 RelWithDebInfo,将创建一个带有调试信息的发布版本。编译器将优化生成的可执行文件,但文件仍然包含数据,以便在出现问题时允许调试程序。
接下来,在 CMake 设置中更改以下设置。您可以使用左下角的搜索字段,命名为 Filter variables...,来搜索指定的设置:
- 禁用构建共享库:

图 1.6:切换设置以创建静态库
- 更改 C 运行时的链接:

图 1.7:静态链接 C 运行时
- 移除库后缀以创建不带编译器版本的文件名:

图 1.8:移除创建的文件的后缀
接下来,在 CMakeLists.txt 文件的上下文菜单中选择 Build 和 Install。
安装完成后,将生成以下文件夹结构:

图 1.9:资产导入器库和包含文件
我们必须使本节中讨论的所有文件对书中的所有示例都可用。为此,有两种选择 - 将文件复制到固定路径或添加环境变量。
复制 Assimp 文件
首先,在您的计算机上创建此文件夹:
C:\Program Files\assimp
然后,将两个文件夹 lib 和 include 复制进去:

图 1.10:两个文件夹已复制到 Program Files 文件夹
CMake 搜索脚本将尝试在此文件夹中查找 Assimp 的静态库和头文件。
添加环境变量以帮助 CMake 查找文件
作为另一种解决方案,您可以在您的 PC 上创建一个文件夹,例如,到 D:\assimp。然后,将文件夹 lib 和 include 复制到该文件夹中,并将环境变量 ASSIMP_ROOT 设置为创建的文件夹位置:

图 1.11:环境变量 ASSIMP_ROOT 指向 PC 上的文件夹
请记住,您必须在设置环境变量后重新启动 Visual Studio 2022。
在 Windows 上安装 Vulkan SDK
对于 Vulkan 支持,你还需要安装 Vulkan SDK。在此处获取:vulkan.lunarg.com/sdk/home。
进行默认安装,并确保添加 GLM 头文件 和 Vulkan 内存分配器头文件,因为如果安装了 Vulkan SDK,CMake 搜索脚本将使用它们:

图 1.12:在 Vulkan SDK 安装过程中添加 GLM 和 VMA
在安装 Vulkan SDK 后,请确保重启 Visual Studio 2022,以便检测 Vulkan SDK 头文件和环境变量。
编译和启动示例代码
运行示例可以通过两种不同的方式完成:按照书中的示例逐个进行,或者一次性编译所有代码以浏览所有示例。
使用以下步骤编译代码:
-
要打开示例项目,从 Visual Studio 2022 的启动屏幕中选择打开本地文件夹,或从 Visual Studio 2022 的文件菜单中选择打开 CMake,然后导航到要编译的示例代码所在的文件夹,或者如果您想一次性编译所有示例,则导航到示例代码的顶级文件夹。Visual Studio 将自动检测并为您配置所选文件夹中的 CMake。输出窗口的最后一行应如下所示:
1> CMake generation finished. -
这确认了 CMake 文件生成的成功运行。
-
现在,通过右键单击
CMakeLists.txt文件设置启动项——这一步是构建和运行项目所必需的:

图 1.13:在 Visual Studio 2022 中配置启动项
- 设置启动项后,我们可以构建当前项目。右键单击
CMakeLists.txt文件并选择构建:

图 1.14:在 Visual Studio 2022 中构建项目
- 编译成功后,使用未填充的绿色箭头以非调试构建启动程序:

图 1.15:在 Visual Studio 2022 中启动编译后的程序,不进行调试
如果你是一名 Linux 用户,你可以按照以下章节的解释将所有工具和库安装到你的系统上。
为 Linux 安装所需的工具和库
现代 Linux 发行版已经包含了编译本书示例代码所需的大部分工具。
下载 Open Asset Import Library
对于常见的 Linux 发行版,Assimp 应该可以从包管理器中获取。对于 Ubuntu,你需要安装 Assimp 开发包:
sudo apt install libassimp-dev
在 Linux 上安装 C++ 编译器和所需的库
如果你使用 Ubuntu Linux,所有必需的依赖项都可以通过集成包管理器安装。使用此命令安装基于 OpenGL 的示例的包:
sudo apt install git gcc g++ cmake ninja-build
libglew-dev libglm-dev libglfw3-dev zlib1g-dev
要使用 Clang 作为编译器而不是 GCC,可以使用此命令:
sudo apt install git llvm clang cmake ninja-build
libglew-dev libglm-dev libglfw3-dev zlib1g-dev
如果你计划构建 Vulkan 示例,则需要这些额外的包,并且应该安装它们以充分利用 Vulkan 代码:
sudo apt install glslang-tools glslc libvulkan-dev vulkan-validationlayers
如果您想使用最新的 Vulkan SDK 而不是 Ubuntu 版本,可以从 LunarG 网站下载该包:
vulkan.lunarg.com/sdk/home#linux
对于其他 Linux 发行版,包管理器和包的名称可能不同。例如,在基于 Arch 的系统上,以下命令行将安装构建 OpenGL 示例所需的所有包:
sudo pacman -S git cmake gcc ninja glew glm glfw assimp zlib
对于 Vulkan 示例,在基于 Arch 的安装上还需要以下额外的包:
sudo pacman –S vulkan-devel glslang
在 Linux 上通过命令行编译示例
示例可以直接在命令行编译,无需使用 IDE 或编辑器。要构建单个示例,切换到包含克隆仓库的文件夹的章节和示例子文件夹,创建一个名为 build 的新子文件夹,并切换到新子文件夹:
$ cd chapter01/01_assimp_opengl
$ mkdir build && cd build
要一次性编译所有示例,请在示例代码的顶级文件夹中创建一个 build 文件夹,然后切换到新的子文件夹。
然后,运行 CMake 以创建使用 ninja 构建工具构建代码所需的文件:
$ cmake -G Ninja ..
末尾的两个点是需要保留的;CMake 需要指向 CMakeLists.txt 文件的路径。
如果构建单个示例,让 ninja 编译代码并运行生成的可执行文件:
$ ninja && ./Main
如果所有必需的工具和库都已安装且编译成功,应该会打开一个应用程序窗口。
一次性构建所有示例时,在顶级文件夹内将创建一个名为 bin 的新文件夹,其中包含每个章节的子文件夹,每个章节的文件夹中包含该章节的两个示例的子文件夹,类似于源代码结构。
如果出现构建错误,您需要再次检查需求。
如果您想使用 IDE,可以继续安装 Eclipse。
在 Linux 上安装 Eclipse
如果您想在 Linux 上的 Eclipse IDE 中编译示例代码,需要执行一些额外的步骤:
-
从
www.eclipse.org/downloads/packages/下载并安装 Eclipse IDE for C/C++ Developers。 -
安装 Eclipse 后,转到 帮助 下的市场:

图 1.16:访问 Eclipse 市场 place
- 安装 cmake4eclipse 和 CMake 编辑器 包。第一个包在 Eclipse 中启用 CMake 支持,包含我们需要的所有功能,第二个包为 CMake 文件添加语法高亮。额外的颜色使得编辑文件更加方便:

图 1.17:安装 CMake 编辑器和 cmake4eclipse
编译和启动示例代码可以按照以下步骤进行:
-
从 文件 菜单中选择 从文件系统打开项目。
-
选择 目录... 并导航到包含源代码的文件夹:
-
如果你想要一次性构建所有示例,请选择顶级源文件夹,按下取消选择所有,然后仅选择第一个项目。
-
如果你只想构建单个示例,你可以要么在顶级文件夹中使用取消选择所有并仅选择你想要构建的示例,要么进入特定示例的文件夹。
-
-
点击完成以打开项目。
-
接下来,从项目文件夹的上下文中选择构建项目。
-
你可能需要切换控制台输出以显示当前的构建消息。使用带有工具提示显示所选控制台的小箭头:

图 1.18:选择正确的输出以查看构建消息
-
如果 Eclipse 在构建后没有刷新项目内容,请从项目文件夹的上下文菜单中选择刷新,或者按F5。
-
选择运行方式,然后选择第二个选项,本地 C/C++应用程序。
-
从窗口中选择主可执行文件以运行程序。

图 1.19:选择主可执行文件以运行编译的应用程序
作为准备工作的最后一步,我们来看看本书 GitHub 仓库中代码的组织结构。
本书中的代码组织
每一章的代码都存储在 GitHub 仓库中,在一个与相关章节编号对应的单独文件夹中。编号使用两位数字以确保正确的排序。在每个文件夹内部,可以找到一个或多个子文件夹。这些子文件夹包含该章节的代码,具体取决于该章节的进度。
对于所有章节,我们将Main.cpp类和 CMake 配置文件CMakeLists.txt放入项目根文件夹中。在cmake文件夹中,存储了 CMake 的辅助脚本。这些文件是查找额外的头文件和库文件所必需的。
所有 C++类都位于文件夹内部,收集我们创建的对象的类。Window类将被存储在window子文件夹中,以保存与该类本身相关的所有文件,工具也是如此——记录器、模型类和与渲染器相关的类。在你安装了所有必需的代码和工具之后,让我们来了解一下游戏角色动画是什么。
游戏角色动画入门
在一个拥有众多不同动画、可更换服装、其他角色和环境碰撞检测系统,甚至可能与其他角色互动的虚拟世界中移动游戏角色,当玩游戏时看起来既美观又简单。
但是,背后使游戏角色平滑动画的数学和技术是广泛且复杂的。角色的每一个动作、动画、行为或状态变化都涉及一段漫长的旅程,直到最终图像渲染到屏幕上。
首先让我们来看一下动画的高级解释。如果你已经了解了细节,你可以跳到下一节。
关于节点、骨骼、骨骼动画和蒙皮
动画三维角色模型的构建块是所谓的节点。节点可以比作角色虚拟身体中的关节,例如肩膀或臀部。
角色模型中的所有节点以虚拟骨骼的形式连接,形成模型的骨骼。通过将子节点附加到节点上,可以建模带有手和手指的臂部,或带有脚和脚趾的腿部——甚至整个类似人类的骨骼——这都不是问题。虚拟骨骼的起点是所谓的根节点。根节点没有父节点,并用作动画的起点。
通常,细节级别不会达到真实世界中骨骼的细节,因为许多真实骨骼在动画中是静态的或在肌肉或姿势变化中只起次要作用。
角色模型的虚拟骨骼可以通过围绕其中心点旋转节点来动画化——从而旋转骨骼,使其所有附加的子节点围绕此节点的中心点旋转。只需想象稍微抬起手臂:上臂将围绕肩膀关节旋转,而前臂、手和手指则跟随肩膀的旋转。这种动画称为骨骼动画。
角色需要在文件中以或多或少自然的姿势存储,这被称为参考姿势或绑定姿势。你会在大多数模型中发现T-姿势,其中双臂形成一条水平线,有时会看到A-姿势,其中骨骼手臂的位置类似于大写字母 A。
要动画化一个角色,需要在绑定姿势位置和动画姿势中期望的位置之间改变每个节点的变换。由于节点的变换需要在特定节点的局部坐标中进行计算,因此每个节点存在一个逆绑定矩阵,用于在局部坐标和世界坐标之间进行转换。
动画本身存储在动画剪辑中。一个动画剪辑不包含动画每个可能时间点的节点变换,而只包含特定时间点的变换。只有所谓的关键帧处的节点变换存储在动画剪辑数据中,从而减少了数据使用。两个关键帧之间的节点位置使用线性插值进行平移和缩放,以及球面线性插值(SLERP)进行旋转。
通过在两个关键帧之间进行插值,可以将骨骼带入两个存储姿势之间的任何虚拟姿势。通过在关键帧之间或不同动画剪辑的插值姿势之间进行插值,可以实现两种姿势之间的混合。混合可以用来改变模型的动画剪辑,而不会产生视觉扭曲,例如,在行走和跑步动画剪辑之间创建平滑过渡。
角色模型的虚拟皮肤被称为网格,在渲染管道的顶点着色器中将网格应用于骨骼称为蒙皮。为了使虚拟皮肤看起来自然,网格的每个顶点都使用权重来处理周围节点的影响。
这些权重被用作节点变换的因素:节点权重越高,该节点的变换应用到顶点的次数就越多,反之亦然。通过使用节点权重,可以以良好的精度模拟虚拟身体皮肤和下肌肉的膨胀和压缩效果。
在 glTF 文件格式中,每个顶点使用四个权重,但其他具有更多权重的文件格式也存在。
有一种特殊的动画称为形变动画。在形变动画中,网格的部分被替换,顶点位置可以在不同的网格之间进行插值。形变动画用于建模面部表情,仅更新角色模型面部的一部分而不是整个模型。通过仅替换网格的一部分但保持骨骼信息不变,可以轻松地将形变动画添加到骨骼动画中。
另一种动画形式被称为加法动画。加法动画是骨骼动画和形变动画之间的一种混合:通过将当前姿势与绑定姿势之间的差异添加到骨骼动画剪辑中,加法剪辑的动画是在骨骼动画之上建模的,但仅限于在加法动画剪辑中发生变化的节点。
加法动画用于仅独立移动角色的特定部分,而不依赖于主要的骨骼动画。例如,骨骼动画仅包含身体的行走或跑步部分,而加法动画剪辑仅改变头部或手部。现在,角色可以移动头部四处张望,而无需创建包含所有可能头部动作的行走和跑步动画。
骨骼、形变和加法动画的结合使我们能够为虚拟世界构建强大且自然的人物,使模型能够在我们身边行走或奔跑,用头部跟随我们的动作,并使用面部形变动画同时说话。
现在,让我们看看创建角色模型动画的一般工作流程。我们可以将动画工作流程分为两部分:准备和更新。虽然准备部分在加载模型时只需要一次,但更新通常用于每帧绘制到屏幕上的内容。
我们首先将深入了解模型的准备过程。
准备数据以实现高效使用
游戏角色模型存储在单个文件中,或作为一组文件,每个文件用于特定目的。例如,模型和纹理数据可以存储在单独的文件中,允许艺术家独立于模型顶点更改图像。
在模型数据文件可用于动画和渲染之前,必须在应用程序中完成以下步骤:
-
在准备阶段的第一步,这些文件必须加载到计算机的内存中。根据游戏中的实现,可以实现部分加载,仅添加特定级别或虚拟世界特定部分所需的字符模型元素。
-
然后,需要对数据进行预处理。磁盘上文件中的数据表示可能在节省空间方面进行了优化,但为了高效的操作和渲染,需要不同的优化方式。
例如,不同的渲染 API,如 OpenGL、Vulkan 和 DirectX,可能需要稍微不同的顶点或纹理数据表示,或者需要上传到 GPU 的着色器代码。而不是在模型文件中存储不同的版本,可以使用通用表示。所需的调整或转换将在加载后进行。
- 作为最后一步,静态数据如顶点数据或纹理将被上传到 GPU,其他静态和变量数据部分存储在 C++类和对象中。
在这一点上,模型准备就绪可以使用。随着该角色在屏幕上的首次出现,需要持续运行的数据更新任务。这些每帧任务对于在运行时改变的状态,如位置或动画姿态,是必需的。
更新角色数据
由于角色的数据分布在主内存和 GPU 内存之间,游戏或动画程序必须为角色在屏幕上绘制的每一帧采样、提取、转换和上传数据。
例如,动画的关键帧数据需要根据要显示的动画片段和自上一帧以来经过的时间进行更新。
在每一帧中必须执行以下步骤以创建特定动画片段选定时间点的单个模型实例的像素:
-
程序流程可能要求在不同动画片段之间进行混合,可能需要额外的动画部分,如头部或手部的加法混合,或面部动画以允许角色有面部表情。因此,我们从所有动画片段中提取指定回放时间点的所有节点的旋转、平移和缩放,并将每个片段的节点变换组合成每个节点的最终变换矩阵。
-
在动画数据被采样和组合之后,需要调整角色的骨骼。根据动画数据,每个虚拟骨骼必须进行平移、旋转和缩放,以达到预期的目标位置。
-
此外,角色的世界位置可能需要更新。世界位置的变化可能以不同的形式发生,如跑步、跳跃或跌倒。知道所有角色的确切位置是剩余步骤中的重要部分。
-
一旦确定了骨骼位置和角色的世界位置,就可以运行碰撞检测。碰撞检测会检查角色是否与其它角色或环境对象(如地板和墙壁)相交,或者角色是否被投射物击中。作为对碰撞检测结果的反应,可能会触发对角色属性(如位置)或动画剪辑的调整。
-
在手头有碰撞数据的情况下,可以运行逆运动学调整。调整角色骨骼数据可能是必要的,以避免角色肢体与墙壁或地板相交,或者为了在凹凸不平的地面上调整脚部位置。
-
现在,角色更新的纯 CPU 部分几乎已经完成。作为 CPU 端的最后一步,更新的动画数据被上传到 GPU,并发出渲染命令。通过在 GPU 中存储动态角色数据,渲染本身可以运行而无需从 CPU 获得大量额外的工作负载。
-
在 GPU 上,顶点着色器根据当前视图的属性(如距离或透视扭曲)转换传入的顶点数据。在可能的着色器阶段之后,片段着色器从 GPU 的栅格化阶段接收数据,并将像素绘制到输出帧缓冲区。
-
在将所有层级数据、字符、HUD 以及可能的其他调试数据发送到 GPU 之后,可见和绘图缓冲区被交换——在这个时候,角色出现在屏幕上,位于我们期望看到的那个位置,并以我们期望的动画姿态出现。
在所有这些更新步骤之间,计算着色器可能会在 GPU 上运行以计算数据。运行计算着色器允许程序将多个角色的计算工作卸载,从而释放 CPU 来处理游戏的其它部分。
正如你所见,直到你在虚拟世界中看到游戏角色四处奔跑,还有很多工作要做。现在,让我们通过 Open Asset Import Library 的概述开始角色动画之旅。
Open Asset Import Library 是什么?
Open Asset Import Library,简称Assimp,是一个跨平台库,用于导入和转换 3D 模型文件。不同的文件格式被转换为层次化数据结构,使程序员能够以单一、综合的方式支持更广泛范围的模型格式。
图 1.20 展示了关键元素及其关系:

图 1.20:Assimp 数据结构的简化版本
让我们更仔细地看看这些数据结构和它们的功能:
-
aiScene是 Assimp 数据结构的核心元素。根节点条目、关于多边形网格、材质和动画的所有信息都存储在aiScene元素中。 -
aiScene的根节点指向一个名为aiNode的结构。在每一个aiNode中,存储了可能的子节点,最终创建了一个节点树。此外,aiNode结构中还有一个变换矩阵,定义了相对于父节点的局部变换。这个矩阵被称为 TRS 矩阵,用于三种可能的变换:平移、旋转和缩放,顺序如下。通过将根节点到骨架中每个节点的 TRS 变换组合起来,可以仅使用根节点的世界位置和骨架层次结构中每个节点的局部变换来计算节点的最终世界位置。 -
节点名用于其他结构,如骨骼或动画,以引用此特定节点。对于节点的顶点,存储了
aiScene中对应aiMesh结构的索引。 -
所有将在屏幕上绘制的数据都存储在
aiMesh结构中。每个网格由所谓的面组成,通常是三角形。对于每个面的每个顶点,重要的数据如顶点、法线和纹理坐标都直接存储在aiMesh中。顶点的绘制顺序作为索引存储在其他结构中,这允许像节省空间地重用顶点这样的特性。 -
对于动画,模型“骨架”由骨骼组成,存储在
aiBone结构中。在这里,偏移矩阵定义了网格空间中的位置与骨骼空间中的绑定姿态(在 glTF 文件格式中,这是“逆绑定矩阵”)之间的偏移。此外,每个顶点存储了几个值对,每个值对包含一个节点索引和一个权重值。在每一对中,权重值指定了应用于顶点的节点移动的分数。 -
Assimp网格中每个顶点的位置可以绑定到最多四个骨骼上,并且可以通过介于0和1之间的权重值来控制这些四个骨骼对最终顶点位置的影响。权重用作指定骨骼变换的缩放因子——值为1表示顶点执行与骨骼相同的变换,而对于值为0,顶点忽略骨骼的变换。 -
在 图 1.20 的右侧,显示了动画的数据。
aiAnimation结构包含骨骼和网格的动画通道、特定动画的总持续时间以及每秒的帧数。 -
作为动画的示例,我们将查看
aiNodeAnim结构。这个结构由应用于特定节点的旋转、平移或缩放的关键帧组成。aiNode结构中的节点名用于找到要动画化的对应骨骼。
目前,Assimp 已知超过 50 种不同的文件格式。以下是一些显著的例子:
-
Autodesk 3D Studio (
.3ds)、AutoCAD (.dxf) 和 FBX (.fbx) -
Collada (
.dae) 和 glTF (.gltf和.glb), 由 Khronos Group 管理 -
Wavefront 顶点 (
.obj) 加上材质 (.mat) -
STL 文件,主要来自 3D 打印 (
.stl) -
游戏引擎格式,即来自 Quake (
.md3/.md5) 或 Half-Life (.mdl)
尽管格式数量令人印象深刻,但需要指出的是,并非所有找到的模型都可以通过使用 Assimp 导入到应用程序中。
这些文件格式中的几个是从封闭源应用程序中逆向工程得到的,并且只有版本的一个子集可以工作。其他格式是开源的,如 Collada 或 glTF,但其中一些格式也在不断演变。此外,Assimp 中尚未实现所有新功能。因此,即使有像 Assimp 这样通用的资产导入库,你也可能遇到无法导入的模型,屏幕上产生“顶点垃圾”,或者丢失一些属性的情况。尽管如此,Assimp 目前是加载许多不同游戏和非游戏 3D 模型到您自己的应用程序中的最佳开源解决方案。
使用 Assimp 加载模型文件归结为导入文件的 aiScene 对象,检查其他数据类型的存在,并将该数据导入应用程序。在下一节中,我们将简要介绍使用 Assimp 加载模型文件的步骤。
应用程序的完整源代码可以在 chapter01 文件夹中找到,OpenGL 的子文件夹为 01_opengl_assimp,Vulkan 的子文件夹为 02_vulkan_assimp。
加载模型文件
要使用 Open Asset Import Library 加载模型文件,我们必须在 model 文件夹中 AssimpModel 模型加载类的实现文件中包含以下三个头文件:
#include <assimp/scene.h>
#include <assimp/Importer.hpp>
#include <assimp/postprocess.h>
接下来,我们在 loadModel() 方法中创建一个 Assimp::Importer 实例,并使用导入器从磁盘加载文件:
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(modelFilename,
aiProcess_Triangulate | aiProcess_GenNormals);
我们传递要加载的资产文件的文件名,以及两个值 aiProcess_Triangulate 和 aiProcess_GenNormals 作为可选的后处理标志。
第一个标志 aiProcess_Triangulate 指示 Assimp 将文件中所有具有超过三个顶点的多边形进行三角化。由于我们的基本渲染器只理解三角形,具有超过三个顶点的多边形将导致图形错误。
使用 aiProcess_GenNormals 作为导入标志确保所有三角形都有法向量。只有在没有找到法向量的情况下,才会创建默认的法向量,指向三角形表面向上。现有的法向量不会被标志更改。
接下来,我们必须检查导入是否成功:
if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE ||
!scene->mRootNode) {
return false;
}
如果场景指针本身是 nullptr,导入器将场景标记为不完整,或者场景没有定义根节点,我们假设导入失败。
模型文件成功加载后,我们将扫描模型以查找嵌入的纹理。
加载嵌入的纹理
一些模型格式可以将纹理嵌入到对象文件中。如果对HasTextures()函数的调用返回true,我们将遍历模型文件中的所有纹理。
if (scene->HasTextures()) {
unsigned int numTextures = scene->mNumTextures;
for (int i = 0; i < scene->mNumTextures; ++i) {
在循环内部,我们提取纹理名称、高度和宽度,以及纹理的像素数据。纹理名称仅用于信息目的,因为数据已嵌入到模型文件中,但拥有名称在调试问题时很有帮助:
std::string texName =
scene->mTextures[i]->mFilename.C_Str();
int height = scene->mTextures[i]->mHeight;
int width = scene->mTextures[i]->mWidth;
aiTexel* data = scene->mTextures[i]->pcData;
现在,我们创建一个共享指针并尝试导入纹理数据。如果导入因意外错误而失败,我们将停止加载模型:
std::shared_ptr<Texture> newTex =
std::make_shared<Texture>();
if (!newTex->loadTexture(texName, data,
width, height)) {
return false;
}
嵌入的纹理是格式为*10的引用——一个星号加上纹理的索引号。因此,我们创建内部名称并将共享指针插入到std::unordered_map中,将纹理名称映射到包含纹理数据的我们的数据对象:
std::string internalTexName = "*" + std::to_string(i);
mTextures.insert({internalTexName, newTex});
}
除了任何嵌入的纹理外,还将添加一个占位符纹理:
mPlaceholderTexture = std::make_shared<Texture>();
std::string placeholderTex = "textures/missing_tex.png";
if (!mPlaceholderTexture->loadTexture(placeholderTex)) {
return false;
}
在许多游戏引擎中,对象占位符纹理很常见,用于显示缺少纹理的对象,而不是一个黑色对象或 GPU 内存中的随机数据。
解析节点层次结构
在检查了嵌入的纹理之后,我们继续处理所有节点。由于具有层次结构,我们在这里采用递归方法。作为第一步,我们为根节点创建一个对象:
std::string rootNodeName = rootNode->mName.C_Str();
mRootNode = AssimpNode::createNode(rootNodeName);
AssimpNode对象包含有关虚拟世界中模型部分之一的定位、旋转和缩放的数据。这种转换数据还包括其父节点的位置、旋转和缩放,将所有节点移动到它们预期的位置。
然后,这个新的根节点将被用作收集所有子节点、孙子节点等的基础:
processNode(mRootNode, rootNode, scene, assetDirectory);
在processNode()方法内部,对每个节点执行四个任务:
-
收集网格数据本身,如顶点位置、法线或纹理坐标
-
从材质中收集外部纹理
-
收集用于骨骼动画的骨骼
-
进入层次结构以处理此节点的子节点
我们首先遍历节点的所有网格:
unsigned int numMeshes = aNode->mNumMeshes;
for (unsigned int i = 0; i < numMeshes; ++i) {
aiMesh* modelMesh = scene->mMeshes[aNode->mMeshes[i]];
AssimpMesh类包含提取顶点数据、纹理和骨骼的逻辑。我们简单地创建一个本地的mesh实例,并让processMesh()方法为我们完成所有提取工作:
AssimpMesh mesh;
mesh.processMesh(modelMesh, scene, assetDirectory);
在处理完 Assimp 网格后,我们将转换后的网格数据、收集到的纹理和骨骼添加到模型本身的数据结构中:
mModelMeshes.emplace_back(mesh.getMesh());
mTextures.merge(mesh.getTextures());
std::vector<std::shared_ptr<AssimpBone>> flatBones =
mesh.getBoneList();
mBoneList.insert(mBoneList.end(),
flatBones.begin(), flatBones.end());
}
}
现在,我们将当前节点存储在节点映射和节点列表中:
mNodeMap.insert({nodeName, node});
mNodeList.emplace_back(node);
为了在程序的不同阶段加快访问速度,需要在两个不同的数据结构中保存相同的节点:
mNodeMap以std::unordered_map的形式包含节点,允许我们以常数时间通过节点名称访问任何节点。但一个巨大的缺点是std::unordered_map默认不保留插入顺序。使用std::map也不会有帮助,因为std::map的所有条目都将按键的升序排序。我们可以通过为映射使用自定义比较函数来解决排序问题,但由于我们也是根据索引位置访问节点,因此将使用第二个数据结构:mNodeList。
在mNodeList中,所有节点以平坦但层次化的顺序存储,这保证了我们可以在其子节点之前访问任何节点。这样,当需要对所有节点进行更新时,mNodeList运行速度快。我们可以简单地通过向量从开始到结束迭代。
在processNode()的末尾,我们检查子节点,并以递归方式处理找到的任何子节点:
unsigned int numChildren = aNode->mNumChildren;
for (unsigned int i = 0; i < numChildren; ++i) {
std::string childName =
aNode->mChildren[i]->mName.C_Str();
std::shared_ptr<AssimpNode> childNode =
node->addChild(childName);
processNode(childNode, aNod->mChildren[i],
scene, assetDirectory);
}
在处理完层次结构中的所有节点后,我们从模型中收集了所有网格、纹理和骨骼数据。
为网格添加顶点缓冲区
在loadModel()方法中,我们为每个网格创建一个组合的顶点和索引缓冲区对象,上传提取的顶点数据,并将缓冲区存储在名为mVertexBuffers的std::vector中:
for (auto mesh : mModelMeshes) {
VertexIndexBuffer buffer;
buffer.init();
buffer.uploadData(mesh.vertices, mesh.indices);
mVertexBuffers.emplace_back(buffer);
}
要绘制导入的模型,我们现在可以简单地遍历mModelMeshes向量,并使用VertexIndexBuffer类的drawIndirect()调用,通过单个绘制命令绘制该特定网格的所有三角形。该方法被称为“间接”,因为 Assimp 将模型数据内部存储为顶点加索引,我们通过索引以间接模式绘制三角形。
此外,还有一个名为drawIndirectInstanced()的实例化绘制调用版本。实例化绘制允许我们在屏幕的不同位置绘制相同网格的多个实例,但创建额外三角形的负载由 GPU 而不是 CPU 完成。
导入动画
作为模型加载过程的最后一步,我们检查动画并遍历模型文件中动画的内部数据结构:
unsigned int numAnims = scene->mNumAnimations;
for (unsigned int i = 0; i < numAnims; ++i) {
const auto& animation = scene-> mAnimations[i];
对于我们找到的每个动画,我们创建一个AssimpAnimClip类的对象,并添加当前动画剪辑的所有通道:
std::shared_ptr<AssimpAnimClip> animClip =
std::make_shared<AssimpAnimClip>();
animClip->addChannels(animation);
一些模型没有为动画剪辑指定名称,因此如果名称为空,我们将名称设置为剪辑的编号:
if (animClip->getClipName().empty()) {
animClip->setClipName(std::to_string(i));
}
具有独特的剪辑名称是 UI 通过名称选择动画的要求。
最后,我们将剪辑存储在mAnimClips向量中:
mAnimClips.emplace_back(animClip);
}
到目前为止,模型的所有相关数据已经加载、提取和转换。正如在什么是 Open Asset Import Library?部分结束时所述,导入数据的质量取决于各种因素,尤其是与Assimp的兼容性。
检查代码以获取所有细节
您应该查看model文件夹中带有Assimp前缀的其他类的实现,以及这些类中提取方法的实现。一般来说,这些方法只是从Assimp中读取 C 风格的数据结构,构建自定义的 C++类。网格数据被转换为 GLM 类型,这使得我们可以简单地将其上传到 GPU,而不是在每次绘制时进行耗时的更改。
现在我们已经准备好了基本功能来打开模型文件,我们遇到了一个简单但基本的问题:模型文件的文件名是硬编码的。
而不是硬编码我们的模型文件路径以进行加载,让我们添加浏览文件的能力。将特定模型从文件加载到应用程序中的简单方法是通过“打开文件”对话框,允许我们选择要导入的文件。为了实现此类对话框与程序的无缝集成,我们将使用基于 ImGui 的解决方案,而不是使用原生操作系统对话框。
通过打开文件对话框扩展 UI
ImGui 可以用来创建简单的 UI,就像我们在动画应用程序中做的那样,但代码也可以扩展来构建不同类型的工具。对于我们的应用程序,一个用于在系统中选择文件的对话框有助于在运行时加载模型,使我们免于在代码中硬编码模型名称或在使用命令行参数启动应用程序时使用模型。
存在着各种基于 ImGui 的文件对话框;一个既好又易于集成的文件对话框可以在 Stephane Cuillerdier 的 GitHub 仓库github.com/aiekick/ImGuiFileDialog中找到。
对于文件对话框的简单集成,我们将使用 CMake 的FetchContent来下载和构建代码。
将文件对话框集成到 CMakeLists.txt 中
首先,我们在CMakeLists.txt文件中添加一个新的FetchContent块,位于 ImGui 获取的末尾下方:
FetchContent_Declare(
filedialog
GIT_REPOSITORY https://github.com/aiekick/ImGuiFileDialog
GIT_TAG v0.6.7
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
)
我们在这里“简化”了配置和构建命令,因为我们只想让源代码可用,而不是构建一个独立的文件对话框版本。
不幸的是,初始配置仍然需要 ImGui 头文件的路径。由于FetchContent块不允许设置额外的编译选项,我们需要一个小技巧来在获取过程中更改 CMake 属性COMPILE_OPTIONS的值。
要做到这一点,我们将COMPILE_OPTIONS的当前状态保存在一个新的 CMake 变量current_compile_options中。然后,我们调整编译选项以将 ImGui 源文件夹中的头文件包含在搜索列表中:
get_property(current_compile_options DIRECTORY
PROPERTY COMPILE_OPTIONS)
set_property(DIRECTORY PROPERTY
COMPILE_OPTIONS -I ${imgui_SOURCE_DIR})
现在,我们可以触发文件对话框源代码的下载和初始化:
FetchContent_MakeAvailable(filedialog)
为了避免在构建系统中造成进一步的混淆,我们将COMPILE_OPTIONS属性恢复到其保存的状态,并取消设置我们用来保存状态的变量:
set_property(DIRECTORY PROPERTY
COMPILE_OPTIONS ${current_compile_options})
unset(current_compile_options)
在 Visual Studio 2022 中,新的 CMake 配置运行会自动触发。在 Eclipse 中,必须通过项目右键上下文菜单中的构建项目选项来手动触发 CMake 的新运行。
包含文件对话框的功能很简单;UI 类只需要扩展几行代码。
使用 ImGui 文件对话框
我们可以在UserInterface类在UserInterface.cpp文件中的createFrame()方法代码的任何位置放置对话框代码。首先,我们添加一个名为“导入模型”的ImGui::Button。此按钮将打开对话框的模态版本:
if (ImGui::Button("Import Model")) {
IGFD::FileDialogConfig config;
config.path = ".";
config.countSelectionMax = 1;
config.flags = ImGuiFileDialogFlags_Modal;
ImGuiFileDialog::Instance()->OpenDialog(
"ChooseModelFile", "Choose Model File", ".*",
config);
}
此按钮配置文件对话框的特殊FileDialogConfig属性。config.path条目将打开路径设置为可执行文件启动的当前路径,而countSelectionMax告诉对话框只接受单个文件被选中。将flags设置为ImGuiFileDialogFlags_Modal将文件对话框显示在所有其他 ImGui 窗口之上。
当设置标志时,调用OpenDialog()以内部名称为第一个参数"ChooseModelFile"打开对话框实例;窗口标题为第二个参数"选择模型文件";第三个参数为显示所有文件(无论文件扩展名)的过滤器;最后一个参数为配置属性。
在Display()调用中将对话框的内部名称设置为打开,允许我们根据特定情况下程序的需求定义多个打开文件对话框。
在按钮之后,文件对话框本身被定义:
if (ImGuiFileDialog::Instance()->Display(
"ChooseModelFile")) {
if (ImGuiFileDialog::Instance()->IsOk()) {
...
}
ImGuiFileDialog::Instance()->Close();
}
文件对话框的代码遵循 ImGui 编码风格。围绕Display()调用的第一个if语句返回true表示对话框被显示,即在上面的“导入模型”按钮被点击之后。然后,对话框代码通过在对话框的“确定”按钮被点击后设置IsOk()为true来做出反应,允许我们在选定的文件上插入要执行的操作。最后的Close()调用在选择了文件后关闭对话框。
在检查文件对话框的返回值之前,我们先看看文件扩展名过滤器。显示所有文件可能会使得找到我们想要加载的文件变得困难。
添加过滤器以仅显示支持的文件类型
文件对话框的filter字段允许相当复杂的配置,但我们将检查其中的三个:单个过滤器、一组扩展名和正则表达式风格的过滤器。
添加单个过滤器
在使用 ImGui 文件对话框部分中的代码已经展示了单个过滤器:
".*"
这个过滤器简单来说就是“文件名中最后一个点之后的所有内容”,所以你会看到当前文件夹中所有的可见文件。
你也可以在这里指定单个扩展名,并且只显示具有此扩展名的文件:
".jpg"
关于大小写敏感性的说明
在 Linux 中,过滤是大小写敏感的。
因此,.jpg过滤器将不会显示名为IMAGE.JPG的文件!
添加一组过滤器
通过用逗号分隔将文件扩展名分组到一个过滤器中:
".jpg,.jpeg,.png"
然而,这可能不会按预期工作——你仍然只能从组中选择一个扩展名,只显示当前文件夹中具有特定扩展名的文件。仅允许从组中选择一个文件扩展名可以用于保存文件对话框,强制用户从可用格式列表中选择一个特定文件格式。
添加正则表达式风格的过滤器
对于过滤器来说,最有用的变体是正则表达式风格:
"Supported Types{.gltf,.glb,.obj,.fbx,.dae}"
这里,字符串"支持类型"将作为对话框中的过滤器显示,并显示所有具有括号中命名的文件扩展名的文件。
你也可以添加多个正则表达式过滤器,用逗号分隔,以创建一个包含各种文件类型的下拉列表。这一行将允许你从几幅图片、文本和二进制 glTF 格式以及所有文件中进行选择:
"Pictures{.jpg,.png},Models{.gltf,.glb},All Files{.*}"
由于OpenDialog()调用的过滤器字段是一个指向字符数组的指针,过滤器列表甚至可以动态创建。根据程序的状态、用户想要选择的文件类型等因素,你可以展示各种过滤器选项。
当文件对话框打开,向用户展示(可能有限制的)文件类型时,让我们完成处理用户选择的文件的代码。
加载模型文件
如果选择了文件并按下了OK按钮,或者文件名被双击,可以通过调用GetFilePathName()获取所选文件的完整路径名:
std::string filePathName =
ImGuiFileDialog::Instance()->GetFilePathName();
为了清晰地区分关注点,UI 代码本身不处理加载过程。相反,创建了一个基于 lambda 的简单回调,我们用模型名称调用这个回调:
if (modInstData.miModelAddCallbackFunction(
filePathName)) {
...
}
添加新模型的回调函数定义在model文件夹中的ModelAndInstanceData.h文件里:
using modelAddCallback = std::function<bool(std::string)>;
在渲染器初始化期间,回调函数通过 lambda 绑定到渲染器类的addModel()方法:
mModelInstData.miModelAddCallbackFunction =
this {
return addModel(fileName);
};
现在,当文件被选中时,渲染器正在做所有导入新模型的“脏活”,如果模型导入成功,则返回信号。
目前,回调函数的返回值仅调整模型列表中的位置。但它可以用来向用户反馈——如果模型文件无法打开,可以显示错误消息弹出窗口,或者相同的模型文件已经被加载。
将模型绘制到屏幕上
在渲染器中绘制模型的流程很简单:
-
获取指向
Assimp模型的智能指针。 -
如果模型是动画的,更新动画,使用上一帧和当前帧之间的时间差,并收集新的骨骼矩阵。
-
如果模型不是动画的,只需获取实例的节点矩阵。
-
将矩阵数据上传到着色器上的着色器存储缓冲区。
-
向图形 API 发出绘制调用。
自定义类处理绘制模型所需的所有其他步骤,例如上传顶点数据或绑定正确的纹理以绘制下一个网格。
加载和绘制单个文件已经非常酷了。但为了充分利用 Assimp 的功能,我们将在程序运行时允许添加和删除不同的模型和模型实例。
动态添加和删除模型实例
通过在 model 文件夹中创建 AssimpInstance 类来支持来自多个模型的多实例。每个 AssimpInstance 包含一个智能指针以访问其基础模型,包括节点和骨骼。添加相同模型的多个实例需要处理节点和骨骼的两个选项之一:在每个实例中使用节点数据结构的副本,或者在整个实例之间共享模型的节点。
为了避免在每个实例中重复所有节点,我们将在计算每个最终节点位置时重用模型中的节点。
为了简化起见重用骨骼
在 updateAnimation() 方法中计算动画期间的节点,我们遍历剪辑的通道并使用模型的对应节点:
for (const auto& channel : animChannels) {
std::string nodeNameToAnimate =
channel->getTargetNodeName();
std::shared_ptr<AssimpNode> node =
mAssimpModel->getNodeMap().at(nodeNameToAnimate);
每个节点的位置、旋转或缩放发生变化:
node->setRotation(channel->getRotation(
mInstanceSettings.isAnimPlayTimePos));
node->setScaling(channel->getScaling(
mInstanceSettings.isAnimPlayTimePos));
node->setTranslation(channel->getTranslation(
mInstanceSettings.isAnimPlayTimePos));
}
对于根节点,实例的局部变换应用于模型的根变换矩阵:
mAssimpMode->getNodeMap().at(
mAssimpModel→getBoneList().at(0)->getBoneName()
->setRootTransformMatrix(
mLocalTransformMatrix *
mAssimpModel->getRootTranformationMatrix());
然后,使用存储的对应骨骼的偏移矩阵和节点的局部变换矩阵,利用节点属性生成骨骼的最终位置。
首先,我们获取模型中对应骨骼的节点:
mBoneMatrices.clear();
for (auto& bone : mAssimpModel->getBoneList()) {
std::string nodeName = bone->getBoneName();
std::shared_ptr<AssimpNode> node =
mAssimpModel->getNodeMap().at(nodeName);
接下来,我们更新包含节点平移、旋转和缩放属性的矩阵(因此名称中的三个字母 TRS 代表变换、旋转和缩放):
node->updateTRSMatrix();
调用 updateTRSMatrix() 也会检索父节点的 TRS 矩阵,并将局部 TRS 矩阵与父节点的 TRS 矩阵相乘。将局部 TRS 矩阵与其父节点矩阵结合确保所有节点都会从模型骨架层次结构中的所有先前节点继承变换。
最后,我们将当前节点的 TRS 矩阵与节点的骨骼偏移矩阵相乘,以计算每个节点的最终世界位置:
if (mAssimpModel->getBoneOffsetMatrices().count(
nodeName) > 0) {
mBoneMatrices.emplace_back(
mAssimpModel->getNodeMap().at(
nodeName)->getTRSMatrix() *
mAssimpModel
->getBoneOffsetMatrices().at(nodeName));
}
}
除非你计划添加实例动画的并行(多线程)计算,否则重用模型节点是可行的:如果有多个线程同时访问模型的节点,至少有一个线程正在修改节点属性,而其他线程正在读取数据,因此可能会发生所谓的数据竞争,导致新旧数据的混淆。
因此,当使用代码的多线程版本时,需要一个节点的本地副本。可以通过简单遍历列表并按名称将节点添加到映射中来生成额外的节点映射。
存储实例特定的设置
剩余的每个实例设置存储在InstanceSettings结构体中,该结构体在model文件夹中的InstanceSettings.h文件中定义:
struct InstanceSettings {
glm::vec3 isWorldPosition = glm::vec3(0.0f);
glm::vec3 isWorldRotation = glm::vec3(0.0f);
float isScale = 1.0f;
bool isSwapYZAxis = false;
unsigned int isAnimClipNr = 0;
float isAnimPlayTimePos = 0.0f;
float isAnimSpeedFactor = 1.0f;
};
在前三个变量isWorldPosition、isWorldRotation和isScale中,存储了实例的旋转、平移和均匀缩放。这里的is前缀并不表示“它是”,而是结构体名称的缩写,以具有不同的变量名。
第四个变量isSwapYZAxis是为了使用不同坐标系统的工具而添加的。虽然我们使用 Y 轴作为垂直轴,但一些工具(以及仍在使用)使用一个坐标系统,其中 Z 轴是垂直的,而 Y 轴是水平轴之一。如果将isSwapYZAxis设置为true,则将应用一个简单的旋转矩阵来更改坐标系统。
剩余的三个变量isAnimClipNr、isAnimPlayTimePos和isAnimSpeedFactor也非常直观。这些变量用于控制实例的动画参数。
使用AssimpModel和AsssimpInstance类将帮助我们以简单的方式添加和删除模型和实例。
动态模型和实例管理
动态管理的第一个构建块是ModelAndInstanceData结构体,该结构体在model文件夹中的ModelAndInstanceData.h文件中定义。该结构体的一个变量由渲染器维护,在draw()调用期间使用,并且也传递给 UI:
struct ModelAndInstanceData {
std::vector<std::shared_ptr<AssimpModel>> miModelList{};
int miSelectedModel = 0;
第一个向量miModelList包含所有按添加顺序加载的模型。此列表在 UI 中以当前加载的模型列表的形式显示。通过使用miSelectedModel,我们跟踪 UI 中选定的模型。
接下来,我们维护两个独立的数据结构来存储实例:
std::vector<std::shared_ptr<AssimpInstance>>
miAssimpInstances{};
std::unordered_map<std::string,
std::vector<std::shared_ptr<AssimpInstance>>>
miAssimpInstancesPerModel{};
int miSelectedInstance = 0;
实例存储在两种不同的结构中,原因与节点图和节点列表相同——根据需求,访问一个或另一个数据结构中的实例将更快或更简单。
miAssimpInstances是一个普通的std::vector,其中存储了所有模型的全部实例。实例向量用于在 UI 中创建实例列表,保留添加顺序。如果删除实例或模型,实例向量将被清理,但仍保持剩余模型的顺序。
对于miAssimpInstancesPerModel,原因不同。当我们想在渲染器中绘制模型时,我们需要收集特定模型的全部实例的骨骼矩阵(对于动画模型)和普通节点矩阵(对于非动画模型)。在每次绘制调用中对miAssimpInstances向量进行排序或过滤在 CPU 端会相当昂贵,因此单独的结构体在这里帮助我们。
在ModelAndInstanceData结构体的末尾,定义了一些回调变量:
modelCheckCallback miModelCheckCallbackFunction;
modelAddCallback miModelAddCallbackFunction;
modelDeleteCallback miModelDeleteCallbackFunction;
instanceAddCallback miInstanceAddCallbackFunction;
instanceDeleteCallback miInstanceDeleteCallbackFunction;
};
这些回调用于将创建或删除模型和实例的工作从 UI 返回到渲染器。UI 不是调整模型和实例数据结构的正确地方,因此这些任务将被转发到渲染器。
回调本身是 C++ 风格的函数指针,使用 std::function 创建:
using modelCheckCallback = std::function<bool(std::string)>;
using modelAddCallback = std::function<bool(std::string)>;
using modelDeleteCallback =
std::function<void(std::string)>;
using instanceAddCallback =
std::function<std::shared_ptr<
AssimpInstance>(std::shared_ptr<AssimpModel>)>;
using instanceDeleteCallback =
std::function<void(std::shared_ptr<AssimpInstance>)>;
回到渲染器,我们查看在 加载模型文件 部分中提到的从 UI 调用的 addModel() 方法。
OpenGL 渲染器的 addModel() 方法看起来像这样:
bool OGLRenderer::addModel(std::string modelFileName) {
if (hasModel(modelFileName)) {
return false;
}
首先,我们检查模型是否已经被加载。为了避免混淆,模型文件只能加载一次。将完全相同的模型文件加载两次(或更多次)几乎没有意义。
现在,我们尝试加载指定的模型文件:
std::shared_ptr<AssimpModel> model =
std::make_shared<AssimpModel>();
if (!model->loadModel(modelFileName)) {
return false;
}
当加载失败时,模型也会将 false 返回给调用者,尽管当前没有使用返回值。
如果模型文件可以加载,它将被放置到模型的 std::vector 中:
mModelInstData.miModelList.emplace_back(model);
从这个列表中,UI 生成当前加载的模型的组合框。
我们还添加了一个模型实例,并将成功加载的模型的信息返回给调用者:
addInstance(model);
return true;
为了使加载的模型有用,我们需要至少创建一个实例,这样我们就有东西可以在世界中渲染。我们使用已经实现的方式来创建第一个实例,而不是有一个单独的解决方案。
addInstance() 方法也只有几行长:
std::shared_ptr<AssimpInstance>
OGLRenderer::addInstance(std::shared_ptr<AssimpModel>
model) {
方法的签名显示,我们将创建的实例返回给调用者。尽管实例将被添加到内部数据结构中,但从方法中获取新实例可能很方便,即当实例需要进一步调整时。
首先,我们创建一个 AssimpInstance 对象的新智能指针:
std::shared_ptr<AssimpInstance> newInstance =
std::make_shared<AssimpInstance>(model);
在这里,基础模型作为唯一参数给出。AssimpInstance 类的构造函数有三个额外的参数,并设置了默认值:初始位置、旋转和缩放。设置这些参数可能在将来是有用的,但为了创建单个实例,它们可以被省略。
现在,新实例被插入到 ModelAndInstanceData 结构体的两个数据结构 miAssimpInstances 和 miAssimpInstancesPerModel 中:
mModelInstData.miAssimpInstances.emplace_back(newInstance);
mModelInstData.miAssimpInstancesPerModel[
model->getModelFileName()
].emplace_back(newInstance);
作为最后几步,我们更新 UI 中显示的三角形计数,并返回新创建的实例
updateTriangleCount();
return newInstance;
}
删除模型和实例大致遵循相同的路径。UI 通过回调触发操作;渲染器搜索实例,或该模型的所有实例,并将它们从数据结构中删除。
绘制所有实例
绘制 Assimp 模型实例的过程没有太大变化。我们需要遍历所有模型实例,而不仅仅是单个模型。我们使用一种“实例化”类型的图形 API 调用,直接在 GPU 上绘制一个 Assimp 模型的所有实例:
-
遍历
miAssimpInstancesPerModel映射以找到特定模型的全部实例。 -
如果模型是动画的,则使用上一帧和当前帧之间的时间差更新动画,并收集新的骨骼矩阵。
-
如果模型不是动画的,则只需获取实例的节点矩阵。
-
将矩阵数据上传到着色器上的着色器存储缓冲区。
-
向图形 API 发出实例绘制调用。
对于列表中的第 5 步,必须尽快获得每个模型实例的确切数量。获取实例数量的最快方法是通过测量 miAssimpInstancesPerModel 映射中每个向量的大小。
如果我们使用未排序的 miAssimpInstances 向量来绘制实例而不是实例,则需要额外的工作来收集相同模型类型的所有实例。遍历 miAssimpInstances 向量以找到相同模型的实例并将这些实例添加到临时数据结构中会花费宝贵的时间。这种策略会降低我们能够达到的最大每秒帧数。为了使用实例绘制调用,我们需要按关联的模型分组处理实例。因此,我们使用 miAssimpInstancesPerModel 向量绘制所有实例。
图 1.21 显示了从 assets 文件夹加载示例模型并生成多个实例后的应用程序:

图 1.21:示例动画角色模型的多个实例
摘要
在本章中,我们通过使用Open Asset Importer Library,或 Assimp,迈出了掌握 C++ 游戏动画编程的第一步。Assimp使我们能够简化并加速动画和渲染模型实例的路径。
我们首先查看 Assimp 库的一般数据结构,如何解析文件的不同部分以及顺序。接下来,我们在代码中添加了一个基于 ImGui 的文件对话框,允许我们以交互式方式选择文件,而不是必须硬编码一个或多个我们希望打开的模型文件。在章节末尾,我们探讨了如何在运行时添加或删除模型和实例,使我们能够创建一个更“拥挤”的虚拟世界。
在第二章中,我们将计算模型矩阵的计算负载从 CPU 转移到 GPU,这样我们就可以保留更多的 CPU 力量来在我们的虚拟世界中完成令人惊叹的事情。
实践课程
你将在本书每一章的末尾看到这个部分。在这里,我将添加一些建议和练习,你可以使用 GitHub 上的代码尝试。
你可以尝试以下操作:
-
向动画添加更多控件,如播放方向、单次/循环播放,甚至乒乓回放,在所选动画的前进和后退播放之间交替。
-
添加一个动画滑块,让您可以选择显示动画中某个时间点的帧。
-
在互联网上搜索模型文件。尝试不同的模型,看看它们的效果如何。你不必将搜索范围仅限于游戏角色模型;也可以搜索兼容格式的游戏地图或 3D 打印对象。记得调整文件对话框过滤器以显示额外的文件格式。
其他资源
为了进一步阅读,请查看以下资源:
-
微软关于路径长度限制的信息:
learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry -
Assimp GitHub 仓库:
github.com/assimp/assimp -
Assimp 文档:
assimp-docs.readthedocs.io/en/latest/ -
Assimp 在 Learn OpenGL 中的部分:
learnopengl.com/Model-Loading/Assimp
第二章:将动画计算从 CPU 迁移到 GPU
欢迎来到第二章!在前一章中,我们探讨了使用 Open Assimp 导入库(简称 Assimp)加载和动画 3D 模型的步骤。生成的应用程序可以渲染大量的模型实例。但是,根据你的处理器类型和速度,模型矩阵的计算部分很快就会变得占主导地位。结果,我们无法在应用程序中达到每秒 60 帧。
在本章中,我们将矩阵计算移动到计算着色器中,完全在 GPU 上运行。我们首先简要回顾一下独立于应用程序主代码进行计算的方法的历史,以及 CPU 和 GPU 中并行性的增长。接下来,我们考察当前矩阵计算的状态。然后,我们制定一个计划,说明我们应该将哪些内容移动到计算着色器中,以及这种迁移如何实现。最后一步,我们检查迁移的结果,并简要看看应用程序的其他哪些部分可能可以利用卸载计算密集型工作。
在本章中,我们将涵盖以下主题:
-
计算着色器是什么,为什么我们应该喜欢它们?
-
分析动画性能
-
将节点计算迁移到 GPU
-
通过扩展测试实现
-
如何调试计算着色器
技术要求
要使用计算着色器,需要一个至少支持 OpenGL 4.3 和/或 Vulkan 1.0 的 GPU。由于本书的源代码是为 OpenGL 4.6 和 Vulkan 1.1 编写的,所以我们在这里是安全的。
你可以在chapter02文件夹中找到示例代码,对于 OpenGL,在01_opengl_computeshader子文件夹中,对于 Vulkan,在02_vulkan_computeshader子文件夹中。
计算着色器是什么,为什么我们应该喜欢它们?
让我们简要回顾一下家用电脑的历史,看看并发是如何处理的。在服务器上,自 1960 年代中期以来,并发程序一直是常态,但对于家用电脑和游戏机来说,其发展轨迹略有不同。
著名的光栅中断
虽然中断的一般概念自计算机诞生以来就存在于计算机系统中,但家用电脑中的中断通常由操作系统用来响应外部事件(尽管在 1950 年代就引入了具有中断的第一台机器)。其中一种中断标志着向旧“阴极射线管”电视输出新图像的开始:光栅中断。
当阴极射线管重置到电视屏幕的左上角后,光栅中断就会触发。这个每秒发生 50 次(在欧洲;在美国为每秒 60 次)的稳定事件,很快就成了程序员的兴趣点。通过将中断处理程序重定向到自己的代码,机器可以完成需要按固定时间表执行的工作,比如播放音乐或屏幕上特定位置应发生的图形变化。这些程序甚至比机器的架构师所能想象的更能发挥家用电脑的能力,比如在屏幕上添加比机器可用的更多精灵,在屏幕边界内绘制精灵,甚至在 8 位 CPU 上实现简单形式的并行处理。
迄今为止,复古程序员甚至能在老式家用电脑上施展更多魔法。请参阅附加资源部分,获取演示链接以及关于如何随着时间的推移接受硬件限制的教程。
然后,很长一段时间内,没有发生什么特别的事情。8 位和 16 位家用电脑的时代结束了,x86 机器接管了市场。然而,总体系统布局保持不变——一个处理器核心通过中断的时间共享来呈现同时运行多个程序的感觉。
多核机器的兴起
到 2000 年初,常见的台式机已经能够处理多个 CPU 核心:推出了 Windows 2000(Linux 长期以来能够利用多个 CPU,但在 2000 年的台式机上是一个利基系统)。
五年后,第一个面向桌面用户的具有多个计算核心的处理器出现了:Pentium D 和 AMD 64 X2。这些新 CPU 被视为编程新时代的开始,因为可以同时运行多个进程。这也标志着程序员烦恼时代的开始——两个线程可以真正并行运行,需要新的同步思考。
目前,台式机的平均 CPU 核心数在 4 到 8 之间。考虑到现代 CPU 的并行多线程,许多台式机甚至可以并行处理 28 到 32 个线程。遗憾的是,程序员的烦恼和 20 年前一样——利用大量核心仍然是一个复杂且容易出错的流程。
在处理器核心升级的背后,另一种拥有更多核心数量的技术也在发展:图形处理器。
隐藏的多核冠军
在处理器核心升级的阴影下,显卡也增加了并行核心的数量。他们在这方面做得更大。从 2009 年和 2010 年只有几个着色器核心开始,数量的增长是惊人的:
NVIDIA GeForce RTX 4090 拥有 16,384 个着色器核心,而 AMD Radeon RX 7900 XTX 则有 6,144 个着色器核心。
由于这两款 GPU 之间的内部差异,这两个数字不能直接比较,但原始数字显示了一件事:如果我们能够使用一些着色器核心来计算动画帧的模型矩阵,计算将会快得多。同时,我们的 CPU 将需要做更少的工作,使我们能够在 GPU 计算模型矩阵的同时执行其他任务。
感谢图形 API 设计师和 GPU 供应商,使用这些着色器核心就像编写一个小型的 C 语言程序:一个计算着色器。
欢迎来到计算着色器的奇妙世界
直到 OpenGL 4.2,通过利用其他着色器类型(如顶点着色器和片段着色器)在 GPU 上进行计算已经是可能的。类似于通过纹理缓冲区对象将任意数据上传到 GPU,着色器可以用来进行大规模并行计算,将结果保存到纹理缓冲区。最终的纹理可以被读取回 CPU 可访问的内存——就这样:GPU 帮助我们做了昂贵的计算。
随着 OpenGL 4.3 的引入,这个过程通过正式添加计算着色器和着色器存储缓冲区对象(SSBOs)而简化。在 Vulkan 1.0 中,对计算着色器和 SSBOs 的支持已经是强制性的,使新的图形 API 与 OpenGL 4.3+相当。
SSBOs 的优势很大:着色器可以读写 SSBO,而不仅仅是只读的 uniform 缓冲区。对 SSBO 的通用访问也简化了,因为它没有硬性限制的最大大小。结合对float和vec2数据类型的轻微不同的填充,在 SSBO 中获取或设置一个值就像使用 C 风格的数组一样简单:
layout (std430, binding = 0) readonly buffer Matrices {
mat4 matrix[];
};
...
void main() {
...
mat4 boneMat = matrix[index];
...
}
另一方面,使用计算着色器,你可以完全控制你想要启动的着色器实例数量。着色器调用的总数取决于计算着色器中的设置和调度调用。
假设我们使用以下计算着色器设置:
layout(local_size_x = 16, local_size_y = 32,
local_size_z = 1) in;
然后,运行这个 OpenGL 调度调用:
glDispatchCompute(10, 10, 1);
这意味着我们将向 GPU 驱动程序发送一个请求,启动 51,200 个着色器实例:
16*32*1*10*10*1 = 51200
关于计算着色器的更多详细信息,OpenGL 和 Vulkan 的教程链接可在附加资源部分找到。
虽然有一些额外的限制,比如为了简化内部管理而一起使用的着色器核心数量(在 AMD GPU 上称为 wave,在 NVIDIA GPU 上称为 warp),但调用次数显示了计算着色器的用户友好性。
你,作为程序员,不需要在代码中关心生成大量的线程或在程序结束时将它们连接起来。也没有必要创建互斥锁或原子变量来控制对数据的访问。所有这些步骤都深深地隐藏在图形驱动程序的深处。
虽然你仍有责任在身——你仍需确保只有一个着色器调用读取或写入单个缓冲区地址。但是,借助 GPU 设置的控制变量,如全局和局部调用 ID,这部分工作也很容易——相比在 CPU 上手动多线程,要容易得多。
那么,我们如何在程序中使用计算着色器的魔法?第一步是分析代码中的热点,并制定一个计划,以确定相同数据如何在 GPU 上进行计算。
性能分析动画性能
要测试系统上应用程序的性能,你可以将名为 Woman.gltf 的测试模型导入 assets 文件夹中的 woman 子文件夹,将 创建多个实例按钮旁边的滑块移动到 100,然后多次点击 创建多个实例按钮。每次点击都会添加 100 个模型的另一个实例,这些实例在虚拟世界中随机分布。
或者,你可以更改 opengl 文件夹中 UserInterface 类的 createFrame() 方法中实例滑块的代码。调整调用中的第四个参数,以控制滑块的最大值:
ImGui::SliderInt("##MassInstanceCreation",
&manyInstanceCreateNum, 1, **100**, "%d", flags);
在添加了数百个实例后,你应该会看到一个类似于 图 2.1 的图像。用户界面的 计时器部分已经被放大,以显示生成模型矩阵所需的时间值:

图 2.1:在屏幕上有 1,601 个实例时的模型矩阵生成时间
在这里,1,601 个实例需要超过 20 毫秒来创建模型矩阵——如果我们计算原始数字,这仍然是一个较小的值。
每个模型有 41 个动画骨骼。对于每个骨骼,每帧都会读取每个 平移、旋转和缩放(TRS)的两个值。这些值通过线性插值混合在一起,用于平移和缩放,而 球面线性插值(SLERP)用于旋转:
1601*41*3*2 = 393846
在这些近 40 万次向量乘法之上,每个骨骼都需要创建的结果 TRS 矩阵,并将其与父矩阵相乘。每次矩阵乘法都包含 16 次浮点乘法,所以我们还有大约 10 万次乘法:
1601*4*16 = 102464
这对于 CPU 来说在每一帧中要完成的大量工作。这些数字也反映在 Windows 和 Linux 的性能分析输出中。
让我们验证关于 CPU 工作负载的假设。
定位代码中的热点
通过使用 Visual Studio 2022 的内置性能分析器,我们可以看到动画的函数调用以及单个函数内部花费最多执行时间的函数之间的矩阵乘法:

图 2.2:Visual Studio 2022 性能分析中的动画调用
在 Linux 上使用额外的标志 -pg 编译可执行文件,运行应用程序,并启动 gprof 后,结果类似:

图 2.3:Linux 中的动画调用
需要大量的 CPU 时间来计算每个节点的新的平移、旋转、缩放和模型矩阵。因此,让我们看看如何更改数据表示,以便允许简单地将数据上传到计算着色器。
分析当前的数据表示
在当前实现中,矩阵工作是在 AssimpInstance 类的 updateAnimation() 方法中完成的。对于渲染器绘制到屏幕上的每一帧,必须执行以下步骤:
-
首先,我们遍历所有动画通道,获取模型的相应节点,并使用动画数据中的骨骼局部变换来更新每个节点的平移、缩放和旋转:
for (const auto& channel : animChannels) { std::string nodeNameToAnimate = channel->getTargetNodeName(); std::shared_ptr<AssimpNode> node = mAssimpModel->getNodeMap().at(nodeNameToAnimate); node->setRotation( channel->getRotation( mInstanceSettings.isAnimPlayTimePos)); node->setScaling( channel->getScaling( mInstanceSettings.isAnimPlayTimePos)); node->setTranslation( channel->getTranslation( mInstanceSettings.isAnimPlayTimePos)); } -
然后,我们遍历所有骨骼,并更新每个节点的 TRS 矩阵,计算节点局部变换:
mBoneMatrices.clear(); for (auto& bone : mAssimpModel->getBoneList()) { std::string nodeName = bone->getBoneName(); std::shared_ptr<AssimpNode> node = mAssimpModel->getNodeMap().at(nodeName); node->updateTRSMatrix();
节点的 TRS 矩阵更新包括与父节点 TRS 矩阵的乘法。
-
在这一点上,我们可以收集节点的最终 TRS 矩阵,并将其与相应的骨骼偏移节点相乘,生成包含每个节点的世界位置的
mBoneMatrices向量:if (mAssimpModel->getBoneOffsetMatrices().count( nodeName) > 0) { mBoneMatrices.emplace_back( mAssimpModel->getNodeMap().at( nodeName)->getTRSMatrix() * mAssimpModel->getBoneOffsetMatrices().at(nodeName)); } }
对骨骼偏移矩阵进行额外的 .count() 检查是为了避免访问无效的矩阵。骨骼偏移矩阵应该对动画中的每个节点都有效,但为了安全起见,最好是谨慎行事。
-
然后,在我们的渲染器的
draw()调用中,即OGLRenderer类中,为每个实例更新动画。在动画更新后,检索mBoneMatrices向量并将其添加到本地mBoneMatrices向量中:for (unsigned int i = 0; i < numberOfInstances; ++i) { modelType.second.at(i)->updateAnimation( deltaTime); std::vector<glm::mat4> instanceBoneMatrices = modelType.second.at(i)->getBoneMatrices(); mModelBoneMatrices.insert( mModelBoneMatrices.end(), instanceBoneMatrices.begin(), instanceBoneMatrices.end()); } -
作为下一步,将本地的
mBoneMatrices向量上传到 SSBO 缓冲区:mShaderBoneMatrixBuffer.uploadSsboData( mModelBoneMatrices, 1);
在 shader 文件夹中的 assimp_skinning.vert 顶点着色器中,骨骼矩阵作为 readonly 缓冲区可见:
layout (std430, binding = 1) readonly buffer BoneMatrices {
mat4 boneMat[];
};
-
我们使用每个顶点的骨骼编号作为索引,进入骨骼矩阵 SSBO,以计算名为
skinMat的最终顶点皮肤矩阵:mat4 skinMat = aBoneWeight.x * boneMat[int(aBoneNum.x) + gl_InstanceID * aModelStride] + aBoneWeight.y * boneMat[int(aBoneNum.y) + gl_InstanceID * aModelStride] + aBoneWeight.z * boneMat[int(aBoneNum.z) + gl_InstanceID * aModelStride] + aBoneWeight.w * boneMat[int(aBoneNum.w) + gl_InstanceID * aModelStride]; -
作为最后一步,我们使用
skinMat矩阵将顶点移动到特定动画帧的正确位置:gl_Position = projection * view * skinMat * vec4(aPos, 1.0);
如您所见,对于我们要渲染的动画的每一帧,都需要进行大量的计算。让我们将计算负载转移到显卡上。
调整数据模型
为了将计算移动到 GPU,我们在 opengl 文件夹中的 OGLRenderData.h 文件中创建一个新的结构体 NodeTransformData:
struct NodeTransformData {
glm::vec4 translation = glm::vec4(0.0f);
glm::vec4 scale = glm::vec4(1.0f);
glm::vec4 rotation = glm::vec4(1.0f, 0.0f, 0.0f, 0.0f);
}
对于 Vulkan 渲染器,需要在 vulkan 文件夹中的 VkRenderData.h 文件中创建该结构体。
在这个新的 struct 中,我们将按节点保存变换值。我们使用 glm::vec4,这是一个包含四个 float 元素的向量类型,用于平移和缩放,以避免额外的填充值以实现正确的对齐,并简单地忽略最后一个元素在着色器中的使用。
GPU/CPU 内存对齐可能不同
由于 GPU 优化了快速内存访问,缓冲区中的数据必须在内存中对齐,在大多数情况下是对 16 字节的多倍数对齐。当将数据上传到 GPU 时,将自动创建这种对齐。在 CPU 端,可能使用不同的对齐方式,例如对于 3 元素向量类型,如glm::vec3,它长度为 12 字节。为了使用glm::vec3向量,需要一个额外的float作为填充以匹配 16 字节的对齐,因为上传未对齐的数据最终会导致图像扭曲和结果不正确。
我们还使用一个glm::vec4向量来表示旋转,这在AssimpChannel类中是一个glm::quat四元数。做出这个决定的原因很简单:GLSL(OpenGL 着色语言)不知道四元数是什么,也不知道如何处理四元数。我们将不得不在计算着色器中自行实现四元数函数。因此,我们利用普通的 4 元素向量来将旋转四元数的四个元素传输到着色器中。
现在,我们可以简化动画更新。首先,我们在类中添加一个局部std::vector,它包含我们新的NodeTransformData类型:
std::vector<NodeTransformData> mNodeTransformData{};
我们再次遍历所有通道,但这次不是修改模型的节点,而是将转换数据填充到一个局部的NodeTransformData变量中:
for (const auto& channel : animChannels) {
NodeTransformData nodeTransform;
nodeTransform.translation =
channel->getTranslation(
mInstanceSettings.isAnimPlayTimePos);
nodeTransform.rotation =
channel->getRotation(
mInstanceSettings.isAnimPlayTimePos);
nodeTransform.scale =
channel->getScaling(
mInstanceSettings.isAnimPlayTimePos);
然后,在检查以避免访问无效骨骼之后,我们使用收集到的转换数据设置相应骨骼的节点转换:
int boneId = channel->getBoneId();
if (boneId >= 0) {
mNodeTransformData.at(boneId) = nodeTransform;
}
}
在我们的渲染器的draw()调用期间,我们仍然需要以相同的方式更新动画:
for (unsigned int i = 0; i < numberOfInstances; ++i) {
modelType.second.at(i)->updateAnimation(deltaTime);
然后,我们从实例中获取节点转换,并将它们收集到一个局部数组中:
std::vector<NodeTransformData> instanceNodeTransform =
modelType.second.at(i)->getNodeTransformData();
std::copy(instanceNodeTransform.begin(),
instanceNodeTransform.end(),
mNodeTransFormData.begin() + i * numberOfBones);
}
作为最后一步,我们必须将节点转换上传到 SSBO:
mNodeTransformBuffer.uploadSsboData(mNodeTransFormData, 0);
NodeTransformData结构体的元素不是 4x4 矩阵,而每个节点只有三个glm::vec4元素。因此,在这一步中,我们需要上传 25%更少的数据到 SSBO。
在 GPU 上拥有节点转换是一个很酷的第一步。但是,如果我们进一步分析数据流,我们会发现我们需要在计算着色器中计算最终模型矩阵时需要更多的数据。让我们看看还需要什么来从骨骼局部转换数据计算世界空间位置。
添加计算着色器缺失的数据
最明显且首先缺失的数据部分是骨骼偏移矩阵的数组。在 CPU 实现中,我们每个节点将最终 TRS 矩阵与相同节点的骨骼偏移矩阵相乘:
mBoneMatrices.emplace_back(
mAssimpModel->getNodeMap().at(
nodeName)->getTRSMatrix() *
**mAssimpModel->****getBoneOffsetMatrices****().****at****(nodeName)**);
由于骨骼偏移矩阵是基于每个模型的,我们可以在AssimpModel类中添加一个 SSBO(存储缓冲对象),并在模型加载期间上传数据。我们只需在model文件夹中的AssimpModel.h头文件中简单地添加一个 SSBO:
ShaderStorageBuffer mShaderBoneMatrixOffsetBuffer{};
然后,在loadModel()方法中,我们填充一个局部向量,包含偏移矩阵,并将数据上传到 SSBO:
std::vector<glm::mat4> boneOffsetMatricesList{};
for (const auto& bone : mBoneList) {
boneOffsetMatricesList.emplace_back(
bone->getOffsetMatrix());
}
mShaderBoneMatrixOffsetBuffer.uploadSsboData(
boneOffsetMatricesList);
在我们为计算着色器准备数据之后,我们将包含骨骼偏移矩阵的 SSBO 绑定到我们在矩阵乘法计算着色器中配置的相同绑定点(binding = 2):
modelType.second.at(0)->getModel()
->bindBoneMatrixOffsetBuffer(2);
初看之下较为隐蔽的是需要父矩阵。在AssimpNode类的updateTRSMatrix()方法中,我们从父节点(如果有父节点)检索 TRS 矩阵。然后,我们使用父节点来计算节点的自身 TRS 矩阵:
if (std::shared_ptr<AssimpNode> parentNode =
mParentNode.lock()) {
mParentNodeMatrix = parentNode->getTRSMatrix();
}
mLocalTRSMatrix = mRootTransformMatrix *
mParentNodeMatrix * mTranslationMatrix *
mRotationMatrix * mScalingMatrix;
在AssimpInstance类的updateAnimation()方法中,我们首先更新根节点的 TRS 矩阵,然后进入子节点,收集包含所有变换矩阵直到模型根节点的父矩阵节点。
对于计算着色器,我们需要不同的方法。由于所有着色器调用都是并行运行的,我们需要将调用次数减少到每个模型一个,以便在模型矩阵上实现已知的线性进展。为了使用更多的着色器调用,我们将创建一个包含每个位置父节点编号的int向量。这个“父节点向量”使我们能够在着色器中“向后行走”模型骨骼,沿途收集所有父节点矩阵。
我们在循环中创建父节点向量,并使用骨骼偏移矩阵。首先,我们获取当前骨骼的父节点,然后使用一个小 lambda 函数获取同一骨骼列表中父骨骼的位置:
std::string parentNodeName = mNodeMap.at(
bone->getBoneName())->getParentNodeName();
const auto boneIter = std::find_if(mBoneList.begin(),
mBoneList.end(),
parentNodeName
{ return bone->getBoneName() == parentNodeName; });
如果我们在骨骼列表中找不到父节点,我们就找到了模型的根节点。在这种情况下,我们添加一个-1来标识根节点。在所有其他情况下,我们添加父骨骼的索引编号:
if (boneIter == mBoneList.end()) {
boneParentIndexList.emplace_back(-1);
} else {
boneParentIndexList.emplace_back(
std::distance(mBoneList.begin(), boneIter));
}
boneParentIndexList现在包含模型中所有节点的父节点列表,对于根节点使用特殊父节点-1。通过重复查找父节点,我们可以从每个节点向上遍历骨骼树,直到我们到达具有特殊父节点编号-1的根节点。
为了使父骨骼列表在计算着色器中可用,我们在AssimpModel类中创建另一个 SSBO,并将boneParentIndexList上传到 GPU:
mShaderBoneParentBuffer.uploadSsboData(boneParentIndexList);
在渲染器中,父骨骼缓冲区将被绑定到计算着色器的绑定点上:
modelType.second.at(0)->getModel()
->bindBoneParentBuffer(1);
我们还没有完成将工作量转换到 GPU 的工作。在使用计算着色器时,一些数据需要以不同的方式处理。
将数据重新定位到另一个着色器
现在的计算中还缺少实例世界位置。updateAnimation()方法包含以下行来设置模型根节点的变换矩阵:
mAssimpModel->getNodeMap().at(
mAssimpModel->getBoneList().at(0)->getBoneName())
->setRootTransformMatrix(mLocalTransformMatrix *
mAssimpModel->getRootTranformationMatrix());
模型的根变换矩阵包含将应用于整个模型的一般变换,例如模型的全球缩放。另一个矩阵mLocalTransformMatrix用于设置模型实例的用户可控参数。局部变换矩阵允许我们在虚拟世界中旋转和移动模型实例。
与骨骼偏移矩阵不同,根节点的变换将被移动到assimp_skinning.vert顶点着色器中,而不是计算着色器中。哪个着色器执行矩阵乘法并不重要,但将根节点变换移动到顶点着色器可能会稍微降低计算着色器的负载。此外,顶点着色器只对绘制到屏幕上的对象运行,而不是在渲染本身之前被剔除的实例或不可见的实例,这可能会降低 GPU 的整体计算负载。
进行最后的准备
最后,我们还可以决定需要多少个不同的计算着色器:
我们至少需要两个计算着色器。
为了计算节点的最终 TRS 矩阵,我们需要所有父级 TRS 矩阵都已完成,并且所有矩阵都从当前节点乘到模型根。由于我们只能控制启动着色器调用的数量,但不能控制其何时或运行多长时间,我们需要在节点 TRS 矩阵的计算和收集骨骼上的矩阵过程中设置某种类型的屏障。
在 CPU 端创建这样的屏障是唯一的方法。在提交计算着色器到图形 API 时,将添加一个屏障,告诉 GPU 在开始第二个批次之前等待第一个着色器完成。
因此,我们必须从节点变换开始,等待所有节点变换矩阵完成,然后开始计算最终节点矩阵。
理论部分完成后,我们可以开始着色器相关的实现。
将节点计算移动到 GPU
加载计算着色器的过程与顶点或片段着色器略有不同。对于 OpenGL,我们必须在glCreateShader()调用中设置着色器类型:
glCreateShader(**GL_COMPUTE_SHADER**);
对于 Vulkan,我们必须在创建VkShaderModule时设置正确的着色器阶段:
VkPipelineShaderStageCreateInfo computeShaderStageInfo{};
computeShaderStageInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; computeShaderStageInfo.stage =
**VK_SHADER_STAGE_COMPUTE_BIT**;
加载着色器代码、链接或创建着色器模块的所有其他步骤保持不变。因为我们只有一个着色器文件,所以已经向Shader类中添加了额外的功能。现在可以通过调用Shader类的loadComputeShader()方法并传入着色器源文件的相对路径来在 OpenGL 中加载计算着色器:
if (!mAssimpTransformComputeShader.loadComputeShader(
"shader/assimp_instance_transform.comp")) {
return false;
}
Vulkan 使用标准可移植中间表示(SPIR-V)格式进行着色器。对于 Vulkan 渲染器,必须将预编译的着色器代码加载到Shader类中,而不是着色器源代码。
随着我们计算新的矩阵在计算着色器中,并且我们必须在不同着色器之间移动这些矩阵,因此需要两个额外的 SSBO。
添加更多的着色器存储缓冲区
第一个 SSBO 将存储我们从节点变换创建的 TRS 矩阵。这个 SSBO 是一个简单的缓冲区,定义在渲染器的头文件中:
ShaderStorageBuffer mShaderTRSMatrixBuffer{};
第二个 SSBO 将包含用于皮肤着色器的最终骨骼矩阵。骨骼矩阵缓冲区也被添加为渲染器头文件中的正常 SSBO 声明:
ShaderStorageBuffer mShaderBoneMatrixBuffer{};
使用 SSBO 在着色器中的一个重要步骤是设置正确的大小。如果 SSBO 太小,则不是所有数据都会存储在计算着色器中,实例或实例的部位可能会缺失。错误的缓冲区大小可能很难调试——你可能甚至不会收到一个警告,表明着色器写入了缓冲区末尾之外。我们必须根据骨骼数量、实例数量和 4x4 矩阵的大小来计算缓冲区大小,如下所示:
size_t trsMatrixSize = numberOfBones *
numberOfInstances * sizeof(glm::mat4);
然后,我们将两个 SSBO 的大小调整为最终矩阵大小:
mShaderBoneMatrixBuffer.checkForResize(trsMatrixSize);
mShaderTRSMatrixBuffer.checkForResize(trsMatrixSize);
当绘制多个模型时,两个缓冲区最终都会达到所有模型的最大大小。但这并不会造成任何伤害,因为缓冲区将被用于下一个模型,并且只填充到新模型实际使用的数据量。
在着色器中计算节点变换
对于第一个计算着色器,我们必须将节点变换数据上传到第一个计算着色器。我们将存储从节点变换创建的新 TRS 矩阵的 SSBO 绑定到计算着色器的正确绑定点:
mAssimpTransformComputeShader.use();
mNodeTransformBuffer.uploadSsboData(
mNodeTransFormData, 0);
mShaderTRSMatrixBuffer.bind(1)
计算着色器本身命名为assimp_instance_transform.comp,位于shader文件夹中。计算着色器的第一行是通常的版本定义;第二行定义了局部调用大小:
#version 460 core
layout(local_size_x = 1, local_size_y = 32,
local_size_z = 1) in;
在这里,我们默认创建 32 个着色器调用。你可能需要尝试不同的局部大小以实现最佳性能。着色器以固定大小的组启动,以简化 GPU 内部管理。常见的值是 32(称为“warps”,针对 NVIDIA GPU)或 64(称为“waves”,针对 AMD GPU)。对于 NVIDIA 或 AMD GPU,将所有局部大小设置为 1 是有点无用的,因为剩余的 31 个 warps 或相应的 63 个 waves 将不会被使用。
接下来,我们必须添加与我们在OGLRenderData.h中声明类型时使用的相同数据类型的NodeTransformData:
struct NodeTransformData {
vec4 translation;
vec4 scale;
vec4 rotation;
};
提醒一下:rotation元素是一个四元数,伪装成vec4。
现在,我们定义两个 SSBO,使用与渲染器代码中相同的绑定点:
layout (std430, binding = 0) readonly restrict
buffer TransformData {
NodeTransformData data[];
};
layout (std430, binding = 1) writeonly restrict
buffer TRSMatrix {
mat4 trsMat[];
};
我们将节点变换数据标记为readonly,将 TRS 矩阵标记为writeonly。这两个修饰符可以帮助着色器编译器优化缓冲区的访问,因为某些操作可以被省略。另一个修饰符restrict也有助于着色器编译器优化着色器代码。通过添加restrict,我们告诉着色器编译器我们不会从另一个变量中读取我们之前用变量写入的值。消除读后写依赖将使着色器编译器的生命变得更加轻松。
为了从TransformData缓冲区读取数据,已添加了三种方法。在这三个方法中,称为getTranslationMatrix()、getScaleMatrix()和getRotationMatrix(),我们读取缓冲区中的数据元素并创建相应的变换的 4x4 矩阵。
例如,查看getTranslationMatrix()方法的实现:
mat4 getTranslationMatrix(uint index) {
return mat4(1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
data[index].translation[0],
data[index].translation[1],
data[index].translation[2],
1.0);
}
生成的 4x4 矩阵是一个单位矩阵,通过TransformData缓冲区中特定index的平移数据进行了丰富。getScaleMatrix()方法创建一个缩放矩阵,将主对角线的第一个三个元素设置为缩放值。最后,getRotationMatrix()方法类似于 GLM 中的mat3_cast算法的精神,将四元数转换为 4x4 旋转矩阵。
在第一个计算着色器的main()方法中,我们获取着色器调用的x和y维度:
void main() {
uint node = gl_GlobalInvocationID.x;
uint instance = gl_GlobalInvocationID.y;
我们将使用模型中的骨骼数量作为x维度,简化着色器代码的其余部分:
uint numberOfBones = gl_NumWorkGroups.x;
通过组合骨骼数量、着色器实例(调用)和我们将要处理的节点来定位缓冲区中的正确索引:
uint index = node + numberOfBones * instance;
计算着色器的主要逻辑按照 TRS 顺序乘以平移、旋转和缩放矩阵,并将结果保存在 TRS 矩阵的缓冲区中,与节点变换的相同index:
trsMat[index] = getTranslationMatrix(index) *
getRotationMatrix(index) * getScaleMatrix(index);
}
在 GLM 中,矩阵是从右到左相乘的,这一点一开始可能会让人困惑。因此,尽管矩阵的名称是“TRS”,但乘法是按照名称的反序进行的:首先应用模型缩放,然后是旋转,最后是平移。其他数学库或不同的矩阵打包可能使用不同的乘法顺序。在附加资源部分列出了两个广泛的矩阵教程。
将 TRS 矩阵保存在与节点变换相同的地点保留了模型中节点和所有模型实例中节点的顺序。
要触发着色器执行,我们为 OpenGL 渲染器调用glDispatchCompute(),并添加一个等待 SSBO 的内存屏障:
glDispatchCompute(numberOfBones,
std::ceil(numberOfInstances / 32.0f), 1);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
内存屏障确保 CPU 等待 GPU 达到特定状态。在这种情况下,我们必须等待所有 SSBO 写入完成,因此我们设置了着色器存储缓冲区的位。对glMemoryBarrier()的调用简单地阻塞执行,只有在 GPU 达到所需状态后才会返回。
在我们继续之前,让我们看看当调用glDispatchCompute()或vkCmdDispatch()时计算着色器内部发生了什么。图 2.4显示了计算着色器调用的内部元素:

图 2.4:计算着色器的全局工作组和局部调用结构
当我们使用参数4,4,2调用调度命令时,总共将启动4*4*2 = 32个工作组,如图 2.4 左边的所示。工作组的总数是全球计算空间三个维度X、Y和Z的乘积。
在每个 32 个工作组中,总共运行了四个着色器调用,如图 2.4中间的工作组[3,0,0]所示。所谓的局部大小由三个着色器布局值local_size_x、local_size_y和local_size_z定义。工作组的局部大小是通过将X、Y和Z维度的三个值相乘来计算的:2*2*1 = 4。
如果着色器实例需要相互通信,那么将它们分离到工作组中是很重要的,因为通信只能在同一工作组内进行。来自不同工作组的着色器调用实际上是隔离的,无法通信。
如您所见,着色器调用的总数可以非常快地变得非常大,因为单个工作组的局部大小和总工作组的数量是相乘的。这种巨大的并行性是 GPU 原始功率的秘密所在。
因此,对于x维度,我们使用之前提到的numberOfBones。通过计算numberOfInstances除以 32 的std::ceil值作为y维度,我们确保以 32 个着色器调用为一组开始,一次计算多达 32 个实例的矩阵,正如在着色器代码中配置的局部y维度。如果我们有小于 32 的倍数的实例计数,额外的波或 warp 仍然在运行,但结果会被忽略。技术上,我们是在缓冲区边界之外进行读写,但 GPU 驱动程序应该处理这种情况,即通过丢弃写入。
对于 Vulkan,我们必须调用VkCmdDispatch():
vkCmdDispatch(commandBuffer, numberOfBones,
std::ceil(numberOfInstances / 32.0f), 1);
Vulkan 中计算着色器的 Shader Storage Buffer Object 的大小也应该四舍五入,以容纳 32 的倍数个骨骼,以避免意外覆盖缓冲区数据:
boneMatrixBufferSize +=
numberOfBones * ((numberOfInstances - 1) / 32 + 1) * 32;
在 Vulkan 中同步着色器的障碍必须设置为等待队列的结果。为了在计算着色器和顶点着色器之间进行同步,我们需要设置计算着色器写入和顶点着色器第一次读取操作之间的障碍,如下所示:
VkMemoryBarrier memoryBarrier {}
...
memoryBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT:
memoryBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(...
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_VERTEX_INPUT_BIT,
1, &memoryBarrier, ...);
现在,Vulkan 在开始顶点着色器中的绘制调用之前,会等待计算着色器完成所有计算。
TRS 矩阵缓冲区现在包含了每个节点的矩阵,但没有父节点、根节点变换矩阵或任何偏移矩阵。
创建最终的节点矩阵
在我们可以开始下一个计算着色器之前,我们必须绑定所有将在着色器运行期间使用的缓冲区。我们总共有四个 SSBO:
mAssimpMatrixComputeShader.use();
mShaderTRSMatrixBuffer.bind(0);
modelType.second.at(0)->getModel()
->bindBoneParentBuffer(1);
modelType.second.at(0)->getModel()
->bindBoneMatrixOffsetBuffer(2);
mShaderBoneMatrixBuffer.bind(3);
由于所有数据已经驻留在 GPU 上,我们在这里不需要任何类型的上传。
第二个计算着色器本身被称为 assimp_instance_matrix_mult.comp,可以在 shader 文件夹中找到。着色器代码再次从版本和局部大小定义开始:
#version 460 core
layout(local_size_x = 1, local_size_y = 32,
local_size_z = 1) in;
由于代码是在具有 NVIDIA GPU 的机器上开发的,因此使用了本地大小为 32。对于 AMD GPU,应使用本地大小为 64,如 在着色器中计算节点变换 部分所述。
与第一个计算着色器类似,接下来是 SSBOs:
layout (std430, binding = 0) readonly restrict
buffer TRSMatrix {
mat4 trsMat[];
};
layout (std430, binding = 1) readonly restrict
buffer ParentMatrixIndices {
int parentIndex[];
};
layout (std430, binding = 2) readonly restrict
buffer BoneOffsets {
mat4 boneOffset[]
};
layout (std430, binding = 3) writeonly restrict
buffer BoneMatrices {
mat4 boneMat[];
};
第一个缓冲区 TRSMatrix 包含第一个计算着色器中的 TRS 矩阵。在 ParentMatrixIndices 缓冲区中,着色器可以找到包含每个节点的父节点的列表。每个节点的骨骼矩阵偏移量在第三个缓冲区 BoneOffsets 中提供,最终节点矩阵将存储在最后一个缓冲区 BoneMatrices 中。readonly 和 writeonly 修饰符根据缓冲区的使用情况设置。
由于我们使用与第一个计算着色器相同的设置,因此在第二个计算着色器的 main() 方法中几乎有相同的第一行代码应该不会让人感到惊讶:
void main() {
uint node = gl_GlobalInvocationID.x;
uint instance = gl_GlobalInvocationID.y;
uint numberOfBones = gl_NumWorkGroups.x;
uint index = node + numberOfBones * instance;
现在,我们获取我们将要工作的骨骼的 TRS 矩阵:
mat4 nodeMatrix = trsMat[index];
接下来,我们引入一个名为 parent 的变量,用于存储父节点的 index:
uint parent = 0;
在遍历节点骨架到根节点时,我们需要 parent 节点索引来获取正确的父矩阵。
作为骨架遍历的第一步,我们获取我们正在工作的节点的父节点:
int parentNode = parentIndex[node];
在下面的 while 循环中,我们获取节点的父矩阵并相乘这两个矩阵。然后我们查找父节点的父节点,依此类推:
while (parentNode >= 0) {
parent = parentNode + numberOfBones * instance;
nodeMatrix = trsMat[parent] * nodeMatrix;
parentNode = parentIndex[parentNode];
}
上述代码可能让你皱眉,因为我们显然违反了 GLSL 着色器代码的一个基本规则:循环的大小必须在编译时已知。幸运的是,这个规则不适用于 while 循环。我们可以在循环体内自由更改循环控制变量,创建各种长度的循环。
然而,这段代码可能会影响着色器性能,因为 GPU 优化了在每条线程上执行相同的指令。您可能需要在不同 GPU 上检查着色器代码,以确保您看到预期的加速。
还要注意,意外创建无限循环可能会导致系统锁定,因为着色器代码永远不会将波或变形返回到池中。确保 CPU 侧的 while 循环有一个有效的退出条件是个好主意,因为 GPU 锁定可能只能通过强制重启计算机来解决。
只要父节点列表中没有错误或循环,我们就会在每个节点的最后一个块结束:
if (parentNode == -1) {
nodeMat[index] = nodeMatrix * boneOff[node];
}
}
在这里,我们将包含所有矩阵(直到根节点)的结果节点矩阵与节点的骨骼偏移矩阵相乘,并将结果存储在可写的 NodeMatrices 缓冲区中。
计算的启动方式与第一个着色器完全相同。对于 OpenGL,运行 glDispatchCompute(),然后是 glMemoryBarrier():
glDispatchCompute(numberOfBones,
std::ceil(numberOfInstances / 32.0f), 1);
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
对于 Vulkan,使用VkCmdDispatch():
vkCmdDispatch(commandBuffer, numberOfBones,
std::ceil(numberOfInstances / 32.0f), 1);
到目前为止,NodeMatrices缓冲区包含所有节点的 TRS 矩阵,接近于第一章中基于 CPU 的代码在updateAnimation()调用后的结果——除了实例的模型根矩阵。
完成计算重定位
因此,让我们将缺少的矩阵计算添加到顶点皮肤着色器中。首先,我们在遍历模型的所有实例时收集包含世界位置的矩阵:
mWorldPosMatrices.resize(numberOfInstances);
for (unsigned int i = 0; i < numberOfInstances; ++i) {
...
**mWorldPosMatrices.****at****(i) =**
** modelType.second.****at****(i)->****getWorldTransformMatrix****();**
}
然后,将世界位置矩阵上传到 SSBO,并绑定到顶点皮肤着色器:
mAssimpSkinningShader.use();
mAssimpSkinningShader.setUniformValue(
numberOfBones);
mShaderBoneMatrixBuffer.bind(1);
**mShaderModelRootMatrixBuffer.****uploadSsboData****(**
**mWorldPosMatrices,** **2****);**
在顶点皮肤着色器本身中,引入了新的缓冲区:
layout (std430, binding = 2) readonly restrict
buffer WorldPosMatrices {
mat4 worldPos[];
};
最后,我们创建一个由世界位置和顶点皮肤矩阵组成的组合矩阵,并使用新的矩阵来计算顶点的位置和法线:
**mat4** **worldPosSkinMat = worldPos[****gl_InstanceID****] * skinMat;**
gl_Position = projection * view * **worldPosSkinMat** *
vec4(aPos.x, aPos.y, aPos.z, 1.0);
...
normal = transpose(inverse(**worldPosSkinMat**)) *
vec4(aNormal.x, aNormal.y, aNormal.z, 1.0);
从第二章编译并运行示例应该会产生与第一章中的示例相同的功能。我们可以加载模型并创建大量实例,但我们仍然能够控制每个模型的每个实例的参数。主要区别应该是创建变换矩阵所需的时间——我们应该看到与基于 CPU 的版本相比有大幅下降,并且最终可能低于 10 毫秒。根据您的 CPU 和 GPU 类型,速度提升会有所不同。但在所有情况下,GPU 着色器应该比纯 CPU 计算明显更快。
让我们看看我们通过使用计算着色器所实现的加速效果。
通过扩展规模来测试实现
所有功能和用户界面都与第一章相同。但通过添加越来越多的实例,我们可以使我们的更改变得可见。如果你添加与图 2.1中相同的 1,600 个实例,你将看到更小的矩阵生成时间。数值可能类似于图 2.5:

图 2.5:具有 1,600 个实例的计算着色器版本
通过使用计算着色器,几乎相同的矩阵操作时间从 CPU 上的约 24 毫秒下降到不到 6 毫秒。我们在每一帧中赢得了大约 18 毫秒的 CPU 时间!
现在,让我们添加更多的模型——许多模型。比如说,我们添加了 4,000 个示例模型的实例。在您的机器上生成的矩阵时间可能与图 2.6中的数字相似:

图 2.6:具有 4,000 个实例的计算着色器版本
即使实例数量增加了 2.5 倍,计算着色器代码的平均矩阵生成时间仍然大约是 CPU 版本的二分之一。你甚至可能会看到更大的、非线性的性能提升,尤其是在更强大的 GPU 上。最近的 GPU 不仅拥有数千个并行工作的核心来处理矩阵乘法,而且下一个最大的型号几乎将核心数量翻倍,从而实现更多的并行化。
我们可以大幅增加实例的数量,或者处理更复杂的模型,同时仍然保持较低的矩阵生成时间。在某个任意的实例数量时,应用程序的帧率仍然会低于 60 FPS。根据你的系统,这可能会发生在达到图 2.6的 4,000 个实例之前,或者更晚。
如果你将分析器附加到应用程序上,你会注意到我们计算的新瓶颈:AssimpAnimChannel类中getRotation()方法末尾的四元数 SLERP:
glm::quat rotation =
glm::normalize(glm::slerp(mRotations.at(timeIndex),
mRotations.at(timeIndex + 1), interpolatedTime));
此外,AssimpAnimChannel类中getTranslation()和getScale()的两次mix()调用将是分析器的前几个发现之一。
在这一点上,你可以尝试将更多的操作移动到计算着色器中。但请注意,你的效果可能会有所不同。一些更改可能会增加 GPU 的计算负载,而 CPU 负载则会降低。这就是你应该拿起一本关于着色器编程的好书,或者观看一些会议演讲,如果你想继续你的计算着色器之旅的时候。进入 GPU 计算的最佳方式仍然是“实践学习”,并且如果着色器没有给出预期的结果,不要放弃。但警告:这里会有龙,吞噬你的时间...
在我们关闭这一章之前,让我们简要地谈谈计算着色器调试。
如何调试计算着色器
计算着色器很酷——至少,直到你遇到某种麻烦。
虽然你可以轻松地将调试器附加到 CPU 代码中查看发生了什么,但 GPU 端更难检查。片段着色器中的错误可能会导致图形扭曲,提供一些关于错误位置的线索,但在其他情况下,你可能会什么也看不到。除了撤销最新的更改外,你还可以始终附加调试工具如RenderDoc,并检查通常的着色器类型中出了什么问题。
但是,尽管 RenderDoc 对计算着色器调试有实验性支持,但这种支持仍然有限。因此,与其他着色器类型相比,计算着色器对我们来说主要是“黑盒”——一个接收和输出不透明数据的程序。
根据你的 GPU,你可能想尝试 NVIDIA Nsight(适用于 NVIDIA GPU)或 AMD Radeon GPU Profiler(适用于 AMD GPU)。所有三个工具的链接都可在附加资源部分找到。
然而,在许多情况下,计算着色器中的问题源于简单的错误。将错误或不完整的数据上传到 SSBO,步进或填充问题,元素顺序错误,意外地交换(非交换)矩阵乘法的顺序……这些简单但令人烦恼的错误可能需要花费很长时间才能找到。
通过读取 SSBO 的内容,可以很容易地看到计算着色器阶段做了什么。例如,对于 OpenGL,这些行将buffer SSBO 中的数据读取到名为bufferVector的std::vector中:
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
buffer, bufferVector.data());
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0)
SSBO 的内容可以与在 CPU 上执行相同计算的结果进行比较。逐步逐缓冲区地缩小问题,直到找到错误。
从 SSBO 读取可能不是进行计算着色器调试的明显解决方案,但这里任何一点帮助都是受欢迎的。但是,根据着色器的复杂性,你可能需要手动遍历代码。此外,尝试使用简单的数据集以简化调试。
摘要
在本章中,我们将大量计算从 CPU 移动到 GPU 上的计算着色器。在简要回顾了并发代码执行的历史之后,我们制定了一个将节点变换计算移动到 GPU 的计划,并最终执行了该计划。在章节末尾,我们检查了实现的应用程序以确认我们获得的速度提升。
在下一章中,我们将探讨为模型视图应用程序添加视觉选择的方法。能够创建成千上万的模型实例是件好事,但在众多实例中找到特殊的一个几乎是不可能的。我们将讨论两种不同的方法并实现其中之一。
实践课程
你可以在代码中添加一些内容:
- 将“可编程顶点拉取”添加到代码中。
使用可编程顶点拉取,顶点数据将不再通过顶点缓冲区来推送。相反,顶点数据将被上传到 GPU 的 UBO 或 SSBO,并且顶点着色器用于从该缓冲区中提取每个顶点的所有数据。
- 将
mix()和slerp()从AssimpAnimChannel移动到 GPU。
当从通道向量中提取了平移、旋转和缩放的时序数据值后,需要为平移和缩放进行线性插值,以及为旋转进行 SLERP。这两种插值类型每帧都称为数千项——也许 GPU 更快。
- 在计算着色器中混合两个动画。
这个任务与之前的实践课程类似。但是,不是在 GPU 上对单个动画剪辑的动画键进行插值,而是在两个不同的动画剪辑的变换同时进行插值。
额外难度:将两个任务结合起来,在计算着色器中对两个动画剪辑的节点变换的 4 个值进行插值。
- 使用 RenderDoc 查看缓冲区内容。
由于 RenderDoc 中显示的缓冲区数据类型是 RGB 值,你可能在缓冲区中看到一些有趣且重复的模式。
额外资源
-
Atari ST 演示场景:
democyclopedia.wordpress.com -
pouët.net 演示场景存档:
www.pouet.net -
LearnOpenGL 上的计算着色器教程:
learnopengl.com/Guest-Articles/2022/Compute-Shaders/Introduction -
计算着色器的 Vulkan 教程:
vulkan-tutorial.com/Compute_Shader -
由 Kenwright 撰写的《Vulkan Compute: High-Performance Compute Programming with Vulkan and Compute Shaders》,作者自出版,ISBN:979-8345148280
-
GLSL 接口块限制:
www.khronos.org/opengl/wiki/Interface_Block_(GLSL) -
矩阵乘法指南:
blog.mecheye.net/2024/10/the-ultimate-guide-to-matrix-multiplication-and-ordering/ -
不同矩阵乘法的教程:
tomhultonharrop.com/mathematics/matrix/2022/12/26/column-row-major.html -
OpenGL 内存屏障:
registry.khronos.org/OpenGL-Refpages/gl4/html/glMemoryBarrier.xhtml -
RenderDoc 主页:
renderdoc.org -
NVIDIA Nsight:
developer.nvidia.com/tools-overview -
AMD Radeon GPU 分析器:
gpuopen.com/rgp/
第三章:添加视觉选择
欢迎来到第三章!在前一章中,我们将大部分矩阵和向量计算任务卸载到了 GPU 上。现代显卡的计算核心(以及更专业的计算核心)比桌面 CPU 要多,因此,将计算负载移至 GPU 将释放主 CPU 的大部分动画工作。
在本章中,我们将添加一些简化操作,以便在处理大量模型实例时使用。在前一章的更改之后,我们能够在屏幕上显示数千个模型实例,但选择特定的实例仍然很困难。我们将首先添加坐标箭头以识别当前选定的实例。接下来,我们将添加一个允许我们将指定实例居中显示在屏幕中间的功能。然后,我们将创建一个图形高亮显示,进一步帮助我们找到所有实例中的选定实例。作为最后一步,我们将添加一个没有三角形的模型,以及从这个空模型中创建的一个实例,允许我们取消选择可见的实例。
在本章中,我们将涵盖以下主题:
-
实现一个“移动到实例”功能
-
为选定的实例添加高亮显示
-
使用点选选择模型实例
-
实现一个空对象以允许取消选择
初看,这些主题似乎与动画编程无关。但适当的工具是创建用户友好应用程序的重要组成部分。好的工具将帮助用户简化应用程序的处理。
在本书的后续章节中,当你创建了数十个甚至数百个实例,它们在屏幕上快乐地跳跃和随机移动时,只需用鼠标单击一个实例即可选择它,使用 UI 按钮将实例居中显示在屏幕上,或使用鼠标移动和旋转实例,这将使你的生活变得更加轻松。你甚至可能会忘记前两章在选择实例或更改实例属性时是多么繁琐。
技术要求
我们需要从第二章中的应用代码:github.com/PacktPublishing/Mastering-Cpp-Game-Animation-Programming。
本章的示例源代码可以在文件夹chapter03的子文件夹01_opengl_selection(用于 OpenGL)和02_vulkan_selection(用于 Vulkan)中找到。
实现一个“移动到实例”功能
作为“移动到实例”功能的第一个更改,我们将添加一组小坐标箭头,出现在绘制的模型的原点,以识别当前选定的实例。我们还将添加一个按钮以居中当前选定的实例。让我们从坐标箭头的实现开始。
添加坐标箭头
由于我们将使用线条而不是三角形在所选实例的中心绘制坐标箭头,我们需要一些额外的数据结构、对象和着色器。为了存储顶点和颜色数据,我们在 opengl 文件夹中的 OGLRenderData.h 文件声明中添加了两个新的结构体:
struct OGLLineVertex {
glm::vec3 position = glm::vec3(0.0f);
glm::vec3 color = glm::vec3(0.0f);
};
struct OGLLineMesh {
std::vector<OGLLineVertex> vertices{};
};
对于 Vulkan,新的结构体命名为 VkLineVertex 和 VkLineMesh,位于 vulkan 文件夹中的 VkRenderData.h 文件中。
将坐标箭头数据上传到 GPU 时,将在 opengl 文件夹中添加一个新的类 LineVertexBuffer。获取新类文件的一个简单方法是将 VertexIndexBuffer 类的两个源文件(VertexIndexBuffer.h 和 VertexIndexBuffer.cpp)复制到 opengl 文件夹中,然后调整 init() 方法以将 position 和 color 数据发送到显卡:
glVertexAttribPointer(0, 3, GL_FLOAT,
GL_FALSE, sizeof(OGLLineVertex),
(void*) offsetof(OGLLineVertex, position));
glVertexAttribPointer(1, 3, GL_FLOAT,
GL_FALSE, sizeof(OGLLineVertex),
(void*) offsetof(OGLLineVertex, color));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
我们还必须通过使用 glEnableVertexAttribArray() 并提供相应的索引值(glVertexAttribPointer 的第一个参数)来启用 position 和 color 属性,以便将这两个属性的数据发送到顶点着色器。
在 GPU 端,需要两个简单的透射着色器——顶点着色器和片段着色器将只传递数据,而不进行额外的变换,除了所需的视图和投影矩阵变换。名为 line.vert 的顶点着色器位于 shader 文件夹中,它使用相机位置的视图和投影矩阵来计算最终的顶点位置。然后,将线条端点的位置和颜色传递给也位于 shader 文件夹中的名为 line.frag 的片段着色器。
我们坐标箭头的顶点是从名为 CoordArrowsModel 的静态模型文件中获取的,该文件位于 model 文件夹中。我们可以通过硬编码顶点位置和颜色来简化初始化过程:
/* X axis - red */
mVertexData.vertices[0].position =
glm::vec3(0.0f,0.0f, 0.0f);
mVertexData.vertices[1].position =
glm::vec3(1.0f, 0.0f, 0.0f);
mVertexData.vertices[2].position =
glm::vec3(1.0f, 0.0f, 0.0f);
...
mVertexData.vertices[0].color =
glm::vec3(0.8f, 0.0f, 0.0f);
mVertexData.vertices[1].color =
glm::vec3(0.8f, 0.0f, 0.0f);
mVertexData.vertices[2].color =
glm::vec3(0.8f, 0.0f, 0.0f);
...
坐标箭头顶点的最终位置是在渲染的 draw() 调用中设置的。作为第一步,行计数器将被置零,用于坐标箭头顶点的 mLineMesh 向量将被清空:
mCoordArrowsLineIndexCount = 0;
mLineMesh->vertices.clear();
接下来,我们检索当前所选实例的设置,包含实例的位置和旋转:
InstanceSettings instSettings =
mModelInstData.miAssimpInstances.at(
mModelInstData.miSelectedInstance)
->getInstanceSettings();
然后,我们将顶点数添加到行计数器变量 mCoordArrowsLineIndexCount 中,并使用 std::for_each 迭代每个顶点:
mCoordArrowsLineIndexCount +=
mCoordArrowsMesh.vertices.size();
std::for_each(mCoordArrowsMesh.vertices.begin(),
mCoordArrowsMesh.vertices.end(),
={
n.color /= 2.0f;
n.position =
glm::quat(glm::radians(
instSettings.isWorldRotation)) * n.position;
n.position += instSettings.isWorldPosition;
});
通过使用 lambda 函数,顶点的位置数据被修改以匹配实例的位置和旋转。此外,我们通过将颜色向量除以 2 的值来降低坐标箭头的颜色。
每个顶点的结果数据被收集在 mLineMesh 向量中:
mLineMesh->vertices.insert(mLineMesh->vertices.end(),
mCoordArrowsMesh.vertices.begin(),
mCoordArrowsMesh.vertices.end());
然后,我们将顶点数据上传到 GPU,并绘制坐标线:
mLineVertexBuffer.uploadData(*mLineMesh);
if (mCoordArrowsLineIndexCount > 0) {
mLineShader.use();
mLineVertexBuffer.bindAndDraw(GL_LINES, 0,
mCoordArrowsLineIndexCount);
}
在这里,mCoordArrowsLineIndexCount用于检查是否存在坐标线,并且作为绘制正确数量点的参数。使用线数作为检查值和计数器可以帮助我们,如果我们根本不想绘制任何坐标线:我们可以简单地跳过填充mLineMesh和计数坐标线,自动跳过线绘制。或者,我们可以在多选场景中绘制多个坐标箭头。
在 Vulkan 中上传顶点数据比 OpenGL 复杂得多,因为 Vulkan API 的显式性。创建不同着色器和上传顶点数据的完整过程在 Vulkan 中需要以下步骤:
-
在 GLSL 或 HLSL(DirectX 的高级着色语言)中创建一对透射着色器。对于 GLSL 着色器,语法只有细微的差别——大多数情况下是在使用
layout语句时更加明确。 -
使用新的着色器和相应的属性定义创建一个新的管线。Vulkan 需要一个新管线,因为创建后管线本身将变为不可变的(除了少数几个显式动态可配置的子对象,如视口)。Vulkan 的着色器不能像 OpenGL 那样在运行时交换;我们需要绑定另一个管线来使用不同的着色器绘制顶点。
-
通过使用阶段缓冲区将顶点数据上传到 GPU。为了在 Vulkan 中获得最佳性能,顶点数据应存储在只有 GPU 可以访问的优化格式内存区域中。使用 CPU 和 GPU 之间共享的缓冲区需要驱动程序进行额外的同步,并且数据可能不是 GPU 绘制的最佳格式,从而导致性能损失。
-
在记录要发送到 GPU 的渲染传递命令时,我们必须使用
vkCmdBindPipeline()方法绑定新管线,并使用vkCmdBindVertexBuffers()方法绑定顶点缓冲区。在提交命令缓冲区到驱动程序后,使用新着色器绘制顶点。
您可以在示例代码的vulkan文件夹中查看Shader、Pipeline和VertexBuffer类的实现细节。此外,在附加资源部分提供了一个指向 Vulkan 教程的链接。该教程有一个关于顶点缓冲区创建和数据上传的单独部分。
现在,在所选实例上添加了三个小箭头,如图图 3.1所示:

图 3.1:新的坐标箭头以识别所选实例
红色箭头指向正x轴的方向,蓝色箭头指向正z轴,绿色箭头指向正y轴。
作为“移动到实例”功能的第二步,将添加新的 UI 按钮。
创建一个按钮以居中选择的实例
对于用户界面中的新按钮,我们将遵循之前的实现,并为UserInterface类添加一个回调。该回调调用渲染器类中的方法,将用户界面中的相机计算相关部分移动到渲染器。
在UserInterface类中,我们添加了一个新的 ImGui 按钮,以及新的回调,使用当前实例作为参数:
if (ImGui::Button("Center This Instance")) {
std::shared_ptr<AssimpInstance> currentInstance =
modInstData.miAssimpInstances.at(
modInstData.miSelectedInstance);
modInstData.miInstanceCenterCallbackFunction(
currentInstance);
}
当渲染器初始化时,回调miInstanceCenterCallbackFunction将通过 lambda 函数绑定到渲染器的新的centerInstance()方法:
mModelInstData.miInstanceCenterCallbackFunction =
this
{ centerInstance(instance); };
centerInstance()方法提取实例的位置,在所有轴向上添加 5 个单位的静态偏移量,并调用相机对象的moveCameraTo()方法:
void OGLRenderer::centerInstance(
std::shared_ptr<AssimpInstance> instance) {
InstanceSettings instSettings =
instance->getInstanceSettings();
mCamera.moveCameraTo(mRenderData,
instSettings.isWorldPosition + glm::vec3(5.0f));
}
最后,moveCameraTo()将相机移动到渲染器中给出的实例位置加上偏移量,并使用固定的方位角和仰角值将选定的实例居中在屏幕中间:
void Camera::moveCameraTo(OGLRenderData& renderData,
glm::vec3 position) {
renderData.rdCameraWorldPosition = position;
renderData.rdViewAzimuth = 310.0f;
renderData.rdViewElevation = -15.0f;
}
使用硬编码的方位角和仰角值使过程变得稍微容易一些,因为从像glm::lookAt()这样的方法生成的矩阵中提取这两个值要复杂一些。您可能尝试自己通过变换矩阵设置相机角度 - 请参阅实践课程部分。
您可以在实例的折叠标题中的任何位置添加新的 ImGui 居中按钮。在示例代码中,按钮被放置在箭头下方以选择当前实例,如图图 3.2所示:

图 3.2:当前选定的实例已被居中
将当前选定的实例居中是朝着更好的外观和感觉迈出的巨大一步。我们不需要搜索闪烁的实例或坐标箭头以找到选定的实例;现在,我们实际上只需点击一下鼠标即可到达实例。
然而,这个解决方案还有一些缺点。如果我们不想居中选定的实例,可能是因为我们希望保持相机位置固定,那该怎么办?所以,让我们在代码中添加另一个函数,使得当前选定的实例在屏幕上显示的所有实例中更容易找到。
为选定的实例添加高亮
初看起来,通过向顶点和顶点缓冲区添加更多字段,添加某种类型的高亮似乎很简单。遗憾的是,我们出于性能原因正在使用实例渲染。这意味着所有实例共享相同的顶点数据。因此,这种方法不可行。
下一个想法可能是实例放置和动画数据。这些矩阵完全由我们的计算着色器从第二章计算得出,由节点的节点变换数据提供。将模型相关数据添加到每个节点似乎有点过度,因为高亮的数据只需要每个实例一次,而不是每个节点一次。
一个更好的想法是另一个 SSBO,在从实例中检索节点变换数据后,立即在渲染器的draw()调用中填充正确的数据。在实例循环中,我们直接访问模型的所有实例,并可以简单地向std::vector推送一个值,表示这是否是选定的实例。在将向量的数据上传到 SSBO 之后,着色器实例可以检查缓冲区数据,以确定是否应该将其高亮添加到它正在处理的实例中,或者不添加。
准备渲染器以支持高亮显示
作为向选定实例添加高亮的第一个步骤,我们向渲染器头文件添加一个包含float的向量和,对于 OpenGL 渲染器,一个 SSBO:
std::vector<float> mSelectedInstance{};
ShaderStorageBuffer mSelectedInstanceBuffer{};
由于不同的数据逻辑,对于 Vulkan,文件VkRenderData.h中的VkRenderData结构体将使用VkShaderStorageBufferData对象代替。
在渲染器的draw()调用中,我们在开始遍历模型和实例之前,保存当前选定实例的智能指针:
std::shared_ptr<AssimpInstance> currentSelectedInstance =
nullptr;
...
currentSelectedInstance =
mModelInstData.miAssimpInstances.at(
mModelInstData.miSelectedInstance);
除了存储实例之外,我们通过添加缩放的deltaTime并重置值,一旦达到2.0f,在OGLRenderData相应的VkRenderData结构体中更改一个浮点值:
mRenderData.rdSelectedInstanceHighlightValue +=
deltaTime * 4.0f;
if (mRenderData.rdSelectedInstanceHighlightValue > 2.0f) {
mRenderData.rdSelectedInstanceHighlightValue = 0.1f
}
变量rdSelectedInstanceHighlightValue的值将在着色器中使用,以放大或缩小选定实例的颜色。通过在每次draw()调用中添加deltaTime的值,并在达到2.0f时将高亮变量重置为0.1f,选定的实例将从非常暗变为非常亮,闪烁的实例在屏幕上更容易被发现,比仅仅的坐标箭头更容易识别。
在实例循环内部,我们比较我们正在处理的实例的智能指针和保存的选定实例的智能指针。如果它们相同,变量rdSelectedInstanceHighlightValue的交替值将被设置为mSelectedInstance向量中当前实例的索引:
if (currentSelectedInstance ==
modelType.second.at(i)) {
mSelectedInstance.at(i) =
mRenderData.rdSelectedInstanceHighlightValue;
} else {
mSelectedInstance.at(i) = 1.0f;
}
如果我们在循环中处理任何其他实例,我们只需将x值设置为1.0f,这将导致着色器中实例的颜色保持不变。
然后将mSelectedInstance向量的收集数据上传到 SSBO。例如,OpenGL 渲染器使用ShaderStorageBuffer类的uploadSsboData()方法将向量数据上传到 GPU:
mSelectedInstanceBuffer.uploadSsboData(
mSelectedInstance, 3);
调整着色器和 UI 的逻辑
作为向选定实例添加高亮的下一个步骤,着色器需要进行调整。在shader文件夹中的assimp_skinning.vert顶点着色器中,必须添加新的 SSBO:
layout (std430, binding = 3) readonly restrict
buffer InstanceSelected {
float selected[];
};
在shader文件夹中用于非动画模型实例的assimp.vert着色器中也需要进行相同的添加。
请检查绑定编号——由于缺少动画数据,动画和非动画实例的着色器中 SSBO 的数量不同:动画模型的着色器在绑定点3上绑定实例选择数据,因为绑定点2已经被世界位置矩阵使用:
mShaderBoneMatrixBuffer.bind(1);
mShaderModelRootMatrixBuffer.uploadSsboData(
mWorldPosMatrices, 2);
mSelectedInstanceBuffer.uploadSsboData(
mSelectedInstance, 3);
相比之下,非动画模型的着色器只绑定两个缓冲区:
mShaderModelRootMatrixBuffer.uploadSsboData(
mWorldPosMatrices, 1);
mSelectedInstanceBuffer.uploadSsboData(
mSelectedInstance, 2);
现在我们可以使用着色器的内部变量gl_InstanceID来调整所选实例的颜色,检索selected缓冲区中实例位置的数据:
color = aColor * selected[gl_InstanceID];
作为可选的更改,我们还可以将所选实例的深度值减去1.0f:
if (selected[gl_InstanceID] != 1.0f) {
gl_Position.z -= 1.0f;
}
降低内部变量gl_Position的z元素将调整三角形的深度值到可能的最小值。这种深度调整使得高亮显示的实例即使在其他实例更靠近摄像机位置时也能可见。
最后,我们在OGLRenderData和VkRenderData结构体中添加了一个名为rdHighlightSelectedInstance的Boolean变量,使我们能够开关高亮显示。这个新变量将被附加到UserInterface类中的 ImGui 复选框:
ImGui::Text("Hightlight Instance:");
ImGui::SameLine();
ImGui::Checkbox("##HighlightInstance",
&renderData.rdHighlightSelectedInstance);
在图 3.3中,展示了在顶点着色器中结合高亮显示和z位置调整的效果:

图 3.3:在靠近摄像机的实例上绘制的高亮显示实例
根据较亮实例的相对大小,这个实例至少部分地隐藏在靠近摄像机的其他实例后面。然而,z位置的调整将所选实例绘制在屏幕上所有实例的顶部。
图片中无法展示的是所选实例的交替亮度。如渲染器的draw()调用中设置的那样,所选实例的颜色从只有原始颜色的 10%(0.1f的起始值)增加到原始颜色的 200%(限制在2.0f)。
实例的闪烁颜色将使在屏幕上找到当前所选实例变得相当容易。但应用程序中仍有一块缺失的部分:能够通过点击窗口而不是使用实例编号箭头搜索所有实例来选择所需的实例。现在让我们处理视觉选择。
使用点和点击选择模型实例
在我们开始实现之前,我们将探讨向应用程序添加视觉选择的不同方法:通过“射击”一个光线到虚拟场景中,以及使用包含实例索引的纹理。
射击虚拟光线的优缺点
你可能会发现将虚拟光线射入场景中的以下想法很有吸引力:
我们已经拥有了虚拟世界中的摄像机位置作为第一个端点,通过将鼠标指针位置从屏幕位置映射回场景坐标,你将得到第二个端点。将坐标映射回场景只需要进行几个矩阵的逆运算和乘法运算。
听起来很有希望且简单,不是吗?
很遗憾,在这个阶段低估最终复杂性的情况很常见。只要世界上只有一个模型,或者两个,一切都会好。你将虚拟射线射入场景,然后遍历每个实例的三角形,以找到射线与实例三角形之间的最近交点。
但当你有,比如说,1,000 个实例时会发生什么呢?
每次你点击鼠标上的选择按钮时,你都需要遍历所有实例的所有三角形,希望至少找到一个匹配项。书中提到的测试模型大约有 2,000 个三角形,因此你将需要在包含 1,000 个实例的虚拟世界中检查 2,000,000 个可能的交点。即使使用大规模并行计算机着色器,这么多的计算量对于现代显卡来说也是相当大的。
有几种方法可以排除虚拟世界中大片区域参与碰撞测试。结合节点级别的其他分层方法,可以通过几个数量级降低交点检查的数量。我们将在处理实例碰撞时,在第八章中介绍该过程的优化。
那么,关于使用纹理的替代想法呢?
将实例索引绘制到纹理中的优势
一个额外的纹理用于实例选择的基点理念来源于延迟渲染。在延迟渲染中,像光照这样的计算不是在片段着色器中完成的,而是在将所需信息存储在纹理中之后“延迟”执行。包含屏幕上所有像素信息的纹理集合被称为G-Buffer,简称几何缓冲区。
通过使用 G-Buffer 中纹理的数据,将光照应用于场景的复杂性从“三角形数量 * 场景中所有灯光的数量”降低到“G-Buffer 的像素数量 * 附近灯光的数量”。
即使是 4K 或 8K 的图形分辨率,创建光照信息所需的操作数量也会大幅降低。此外,通过使用渲染过程中的其他信息,使用延迟渲染可以轻松实现其他效果,如阴影映射或间接光照。
对于简单的光线投射,选择复杂性随着虚拟世界中实例数量的增加而增加,即使这些实例在屏幕上不可见。当我们应用延迟渲染方法到视觉选择时,我们在绘制一些像素到单独纹理上有一个恒定的开销。我们的选择过程不再依赖于世界中实例的变量数量。此外,缓冲区的分辨率可能只会对实例选择性能产生轻微的影响。
添加具有单独纹理的视觉选择所需的更改量出人意料地低。让我们看看我们必须采取的步骤。
调整帧缓冲区
我们从FrameBuffer类开始,并添加一个新的颜色附加组件。对于 OpenGL 版本,新的颜色附加组件创建如下。
glGenTextures(1, &mSelectionTex);
glBindTexture(GL_TEXTURE_2D, mSelectionTex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_R32F, width, height,
0, GL_RED, GL_FLOAT, NULL);
glBindTexture(GL_TEXTURE_2D, 0);
glFramebufferTexture(GL_FRAMEBUFFER,
GL_COLOR_ATTACHMENT1, mSelectionTex, 0);
对于选择纹理,我们添加一个只包含单个元素的纹理,即红色。但我们使用 32 位宽的红色,而不仅仅是 8 位,这使得我们可以存储更多的实例索引。然后,新的纹理作为索引为 1 的颜色附加组件添加到帧缓冲区中。
关于纹理数据类型(float)的说明
有可能创建一个类型为GL_R32UI的纹理,每个像素包含一个 32 位宽的无符号整数。但所有整数的纹理版本都使用一个转换因子来处理组件,这增加了选择过程的复杂性,因为读取和写入时需要进行额外的计算。相比之下,GL_R32F缓冲区存储和检索未更改的浮点值。通过在 GPU 端使用浮点数,我们仍然能够存储约 1670 万个实例索引(2²⁴),在 32 位浮点数的精度可能导致整数和浮点值转换时的舍入误差之前。有关浮点精度的更多详细信息,请参阅附加资源部分中的博客文章链接。
此外,在创建帧缓冲区时,我们必须确保我们的着色器写入两个颜色附加组件:
const GLenum buffers[] = { GL_COLOR_ATTACHMENT0,
GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, buffers);
如果我们不更改要绘制的缓冲区,则只有模型将在屏幕上绘制,选择纹理永远不会被填充。
为了使用不同的值清除颜色缓冲区和选择缓冲区,已向FrameBuffer类添加了一个名为clearTextures()的新方法:
void Framebuffer::clearTextures() {
static GLfloat colorClear[] =
{ 0.25f, 0.25f, 0.25f, 1.0f };
glClearBufferfv(GL_COLOR, 0, colorClear);
static GLfloat selectionClearColor = -1.0f;
glClearBufferfv(GL_COLOR, 1, &selectionClearColor);
static GLfloat depthValue = 1.0f;
glClearBufferfv(GL_DEPTH, 0, &depthValue);
}
选择纹理可以存储任意浮点值,使用-1.0f来清除选择纹理,这是一个为空背景拥有唯一值的好方法。
我们还避免了创建另一个 SSBO 来存储实例索引,并将mSelectedInstance向量从float扩展到glm::vec2:
std::vector<glm::vec2> mSelectedInstance{};
通过将现有的高亮颜色移动到glm::vec2向量的x元素,我们有一个免费的float类型位置,并且可以将实例索引存储在y元素中。
创建选择着色器
向帧缓冲器添加新的颜色缓冲区也需要两对新的顶点/片段着色器:一对用于动画模型,另一对用于非动画模型。但由于我们已将实例高亮选取信息发送到着色器,因此只需对现有着色器代码进行少量添加。
由于我们可以重用着色器,第一步是复制现有文件。对于非动画模型,将着色器assimp.vert复制到assimp_selection.vert,将assimp.frag复制到assimp_selection.frag。相同的名称添加也将用于动画模型着色器:将文件assimp_skinning.vert复制到assimp_skinning_selection.vert,将assimp_skinning.frag复制到assimp_skinning_selection.frag。
我们还需要在渲染器中添加两个新的着色器对象,因此我们在文件OGLRenderer.h中添加名为mAssimpSelectionShader和mAssimpSkinningSelectionShader的private Shader成员变量:
Shader mAssimpSelectionShader{};
Shader mAssimpSkinningSelectionShader{};
与现有的着色器一样,两个新的着色器在渲染器的init()方法中加载。
然后,必须向新的顶点着色器中添加两行代码。第一行新代码向顶点着色器添加一个名为selectInfo的新输出变量,使我们能够将当前三角形的选取数据传递给片段着色器:
...
layout (location = 2) out vec2 texCoord;
**layout** **(****location** **=** **3****)** **out****float** **selectInfo;**
main()方法的最后一行负责实际转发到片段着色器:
selectInfo = selected[gl_InstanceID].y;
}
对于两个新的片段着色器,需要类似的更改。在着色器代码之上,我们必须添加新的输入变量selectInfo:
layout (location = 2) in vec2 texCoord;
**layout** **(****location** **=** **3****)** **flat****in****float** **selectInfo;**
此外,片段着色器的输出也需要调整。将单个FragColor输出行替换为以下两行:
**layout** **(****location** **=** **0****)** **out****vec4** **FragColor;**
**layout** **(****location** **=** **1****)** **out****float** **SelectedInstance;**
现在,我们写入两个不同的输出,每个颜色缓冲区一个:帧缓冲器的颜色缓冲区将填充每个像素的屏幕颜色 RGBA 值,就像之前一样,在main()方法的末尾,从顶点着色器传递的实例索引将被写入第二个颜色缓冲区:
SelectedInstance = selectInfo;
如果现在在绘制实例时使用新的选取着色器,实例的索引将添加到每个实例屏幕上的选取缓冲区中的每个像素。
从纹理中读取像素
在给定位置读取像素的颜色将在FrameBuffer类的readPixelFromPos()方法中完成。
首先,我们使用特殊值初始化指定的返回变量,以便在 OpenGL 由于配置问题拒绝读取像素颜色时容易找到错误:
float Framebuffer::readPixelFromPos(unsigned int xPos,
unsigned int yPos) {
float pixelColor = -444.0f;
接下来,我们将帧缓冲对象绑定为缓冲区以读取,并选择帧缓冲区的颜色附加1,其中包含选取纹理:
glBindFramebuffer(GL_READ_FRAMEBUFFER, mBuffer);
glReadBuffer(GL_COLOR_ATTACHMENT1);
然后,我们调整读取过程中使用的内部对齐方式,并在给定的xPos和yPos位置读取单个像素的颜色值:
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glReadPixels(xPos, yPos, 1, 1, GL_RED, GL_FLOAT,
&pixelColor);
最后,我们将帧缓冲区切换回颜色附加0,解绑缓冲区并返回像素颜色:
glReadBuffer(GL_COLOR_ATTACHMENT0);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
return pixelColor;
}
添加鼠标按钮处理
将实例选择添加到左鼠标按钮。点击实例来选择它感觉很自然。为了存储选择状态,我们在渲染器中添加了一个名为 mMousePick 的布尔成员变量。
然后,必须在渲染器的 handleMouseButtonEvents() 方法中添加以下行:
if (button == GLFW_MOUSE_BUTTON_LEFT &&
action == GLFW_RELEASE) {
mMousePick = true;
}
使用 GLFW_RELEASE action 在这里,当左鼠标按钮释放时做出反应,与许多应用程序的选择风格相匹配。如果您想更改行为,可以使用 GLFW_PRESS 动作。
我们仅在用户通过选择事件触发选择时,使用 mMousePick 值在绘图过程中触发不同的动作。将正常绘制和选择额外操作分开,有助于保持应用程序的最大速度,避免仅在选择期间需要的操作。
例如,只有当触发选择事件时,具有将内容绘制到选择纹理中的逻辑的着色器才会被调用:
if (mMousePick) {
mAssimpSkinningSelectionShader.use();
} else {
mAssimpSkinningShader.use();
}
为每个实例分配一个索引
为了确保我们始终为每个实例维护一个唯一的索引,每次添加或删除实例时,我们都会分配其整体索引。使用每个实例的唯一索引也有助于我们在 miAssimpInstances 向量中访问当前实例。
我们在 model 文件夹中 InstanceSettings.h 文件所在的 InstanceSettings 结构体中添加了一个新变量 isInstanceIndexPosition:
struct InstanceSettings {
...
int isInstanceIndexPosition = -1;
};
变量 isInstanceIndexPosition 将在 assignInstanceIndices() 方法中设置,该方法会遍历所有实例:
void OGLRenderer::assignInstanceIndices() {
for (size_t i = 0;
i < mModelInstData.miAssimpInstances.size(); ++i) {
InstanceSettings instSettings =
mModelInstData.miAssimpInstances.at(i)
->getInstanceSettings();
instSettings.isInstanceIndexPosition = i;
mModelInstData.miAssimpInstances.at(i)
->setInstanceSettings(instSettings);
}
}
当实例被添加或删除时,会调用 assignInstanceIndices() 方法。
手头有一个易于访问的实例编号有助于我们在遍历模型的所有实例时填充 mSelectedInstance 向量的 y 元素:
if (mMousePick) {
InstanceSettings instSettings =
modelType.second.at(i)->getInstanceSettings();
mSelectedInstance.at(i).y =
static_cast<float>(
instSettings.isInstanceIndexPosition);
}
在鼠标位置选择实例
作为视觉选择的最后一步,我们在渲染器中的 draw() 调用结束时触发像素读取:
if (mMousePick) {
glFlush();
glFinish();
通过在像素读取周围检查 mMousePick 变量,我们确保只有在选择事件上才会调用读取像素的函数。
这种保护检查对于调用 glFlush() 和 glFinish() 尤为重要。这两个命令都是必需的,以确保所有着色器运行都已结束,选择纹理中的数据已完整。虽然 glFlush() 清空内部缓冲区并触发渲染本身,但 glFinish() 会阻塞渲染器并等待所有之前的 OpenGL 调用完成。在大多数实现中,强制这些 OpenGL 命令在每个帧上都会降低每秒帧数。
接下来,我们在 FrameBuffer 实例上调用 readPixelFromPos():
float selectedInstanceId =
mFramebuffer.readPixelFromPos(mMouseXPos,
(mRenderData.rdHeight - mMouseYPos - 1));
由于 OpenGL 中的 y 轴方向在 Windows 和 Linux 上的不同,我们需要在读取像素时反转 y 位置。
在我们检索像素颜色之后,我们检查是否有任何实例被选中,或者鼠标点击是在屏幕的背景上完成的:
if (selectedInstanceId >= 0.0f) {
mModelInstData.miSelectedInstance =
static_cast<int>(selectedInstanceId);
}
mMousePick = false;
}
作为最后一步,我们将 mMousePick 设置为 false,立即停止选择模式。
在 图 3.4 中,展示了较大组模型实例的选择纹理:

图 3.4:选择纹理
图 3.4 中的背景颜色已被改为白色。在实际的选择纹理中,清晰的值 -1.0f 将被钳位到零,从而得到一个黑色背景。此外,索引值已调整以增强实例的可见性。如果我们用实际的浮点值渲染选择纹理,所有索引值都将被钳位到 1.0f,从而得到一个所有实例都显示为鲜红色的图片。
使用鼠标选择单个实例现在工作得很好。作为 实践课程 部分中的一个想法,你可以尝试添加更多功能,比如同时选择多个实例。例如,在选中实例时按住 Ctrl 或 Shift 键,新选中的实例将被添加,显示所有选中实例的坐标箭头和高亮。
但还有一件事可能让你感到烦恼:没有方法可以完全不选择任何实例。我们将在本章的最后部分添加一个空选择的解决方案。
实现空对象以允许取消选择
在应用程序窗口的背景上点击以取消选择带来了一系列有趣的含义。例如,位置 0 可能是 miAssimpInstance 向量中的一个有效索引,表示已选择了第一个实例。所以,简单地使用零来表示没有实例被选中是不行的。更糟糕的是:将零作为第一个索引可能会让应用程序的用户感到困惑,因为从零开始计数并不是有意为之,而是从一开始的。
此外,-1 的默认缓冲区背景值是一个无效的数组索引。使用 -1 作为 miAssimpInstance 向量的索引将简单地导致应用程序崩溃。由于我们将在代码中频繁使用实例向量,为每次单独访问添加范围检查将至关重要,因为即使错过一次检查也会导致崩溃。
因此,我们需要另一种信号机制来在两种选择变体之间切换,并简化范围检查。为了用一个解决方案来捕捉这两个问题,我们将使用一个空模型作为 空对象。
什么是空对象?
使用空对象是面向对象编程中众所周知的设计模式。通常,你会使用 nullptr 来表示对象实例的缺失。但是,使用 nullptr 需要在每次使用实例之前进行额外的检查,以确定其实例是否有效。取消引用一个不存在的实例会导致运行时错误,并使应用程序崩溃。
空对象是一个有效的对象实例,提供了一种定义良好但中性的行为。空对象实例中的函数调用是有效的,但可能返回无用的结果,例如空列表或一些默认值。这种行为使得代码无需进行额外的检查,因为实例本身是有效的。
对于我们的选择问题,我们简单地创建一个返回无顶点的 AssimpModel 类实例,以及节点、网格等空列表或向量。然后,我们将特殊模型作为第一个模型添加到 miModelList 向量中,并将一个“空模型”实例作为第一个实例添加到 miAssimpInstances 向量以及 miAssimpInstancesPerModel 映射中。
如果我们现在选择第一个模型实例,我们将有一个有效的对象,只是屏幕上没有绘制任何三角形。关闭坐标箭头或调整用户界面以禁用未选择实例的控制,无需额外的信号变量。我们只需要测试 miSelectedInstance 是否为 0。
我们将稍微改变原始模式,不使用派生类。相反,我们将提供一个空的 AssimpModel 类对象。
创建和使用 AssimpModel 空对象
由于我们已经在头文件 AssimpModel.h 中使用默认值初始化了类成员变量,因此创建一个空对象可以通过创建一个空实例来实现。创建 AssimpModel 类实例而不设置任何数据的最简单方法是通过使用隐式创建的默认构造函数。我们甚至不需要定义自定义构造函数;C++ 编译器将在后台处理我们需要的所有内容。
在渲染器的 init() 方法中,我们创建了一个指向空模型的智能指针,并将模型指针添加到 miModelList 向量中:
std::shared_ptr<AssimpModel> nullModel =
std::make_shared<AssimpModel>();
mModelInstData.miModelList.emplace_back(nullModel)
然后,我们可以从空模型创建一个 AssimpInstance 实例,并将其放入 miAssimpInstancesPerModel 映射和 miAssimpInstances 向量中:
std::shared_ptr<AssimpInstance> nullInstance =
std::make_shared<AssimpInstance>(nullModel);
mModelInstData.miAssimpInstancesPerModel[nullModel
->getModelFileName()].emplace_back(nullInstance);
mModelInstData.miAssimpInstances.emplace_back(
nullInstance);
作为最后的初始化步骤,我们更新实例的索引号:
assignInstanceIndices();
现在,nullModel 模型实例的索引号为 0。任何在应用程序中添加的实例现在将从索引 1 开始。匹配实例总数和实例索引号将避免对实例编号方案的混淆。
为了在渲染器的 draw() 调用中跳过模型进行顶点处理,添加了一个对三角形数量的检查:
if (numberOfInstances > 0 &&
modelType.second.at(0)->getModel()
->getTriangleCount() > 0) {
...
如果模型中没有三角形,将跳过该特定模型的全部实例。
此外,我们在生成坐标箭头的最终顶点位置之前检查实例号 0:
if (mModelInstData.miSelectedInstance > 0) {
....
当选择第一个实例——空模型实例——时,屏幕上不会绘制任何坐标箭头。我们甚至可以在这里移除 miAssimpInstances 向量的 size() 检查,因为我们知道至少有一个有效的实例可用。
调整用户界面
在用户界面中,我们将稍微作弊一下,以保持实例选择字段在选中空实例时禁用:
bool modelListEmtpy =
modInstData.miModelList.size() == 1;
bool nullInstanceSelected =
modInstData.miSelectedInstance == 0;
size_t numberOfInstances =
modInstData.miAssimpInstances.size() - 1;
通过从 miModelList 向量的尺寸中减去 1,我们忽略了该向量中的空模型。我们还忽略了空模型的空实例,以计算 numberOfInstances 中的实例数量。额外的布尔值 nullInstanceSelected 帮助我们在模型和实例可用但未选择任何实例时禁用用户界面的部分。
在 图 3.5 中,展示了使用空对象取消选择的效果:

图 3.5:当没有选择任何内容时,用户界面部分禁用
模型实例的脚下不绘制坐标箭头,因为我们选择空实例时隐藏箭头。此外,用户界面部分禁用,这是在计算实例数量时忽略空实例的效果。
摘要
在本章中,我们增强了代码中的实例选择方法,以更好地处理在屏幕上找到所选实例。此外,我们添加了使用鼠标选择实例的能力。首先,我们实现了一个按钮,将我们的虚拟相机中心对准所选实例。接下来,我们添加了突出显示所选模型的能力,使其更容易在屏幕上找到。然后,我们实现了视觉选择,允许用户通过点击任何实例来选择实例。最后,我们创建了选择零个实例的可能性,以避免意外更改。
在下一章中,我们将为将更多游戏引擎功能适配到模型查看器打下一些基础。除了将查看器的行为分为编辑模式和纯查看功能外,我们还将添加将正在进行中的更改还原到实例的能力。在下一章的结尾,我们将实现撤销/重做功能,使用户能够撤销更改,或者重新应用之前的更改。
实践课程
你可以在代码中添加一些内容:
- 在移动相机时计算方位角和仰角。
目前,方位角和仰角的值是硬编码的。你可以尝试从变换矩阵中计算这两个值。
- 实现视觉多选。
增强选择功能,以便在选择点击时按住 Ctrl 或 Shift,将新选定的实例添加到其他选定的实例中,而不是替换当前选定的实例。
- 额外难度:通过坐标箭头实现实例移动。
由于选择用的坐标箭头是在一个单独的着色器中绘制的,你可以尝试向箭头添加一组额外的选择索引,并将箭头添加到选择纹理中。当用户点击箭头而不是模式时,你可以将应用程序切换到允许实例沿选定轴移动的模式。这种行为类似于在任何常见的 3D 编辑器中移动实例。
其他资源
-
OpenGL 着色器编程:
learnopengl.com/Getting-started/Shaders -
Vulkan 教程:
vulkan-tutorial.com -
浮点精度揭秘:
blog.demofox.org/2017/11/21/floating-point-precision/
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation

第二部分
将模型查看器转换为动画编辑器
在这部分,你将通过将模型查看器增强为动画编辑器来为游戏角色动画构建基础。你将学习如何添加一个单独的查看模式,允许你禁用某些时间可能不需要或希望激活的代码部分,例如录制视频时的用户界面。你还将了解到对模型实例所做的更改可以撤销的一般概念,最终实现基本的撤销/重做功能。你还将学习如何将当前加载的模型和实例以及各种设置存储到 YAML 文件中,以及如何将数据重新加载到应用程序中,允许你在任何时间继续编辑模型,或者创建多个配置。最后,你将探索虚拟世界中的不同相机类型,让你有机会从所有可能想到的角度和视角查看角色动画。
本部分包含以下章节:
-
第四章,增强应用程序处理
-
第五章,保存和加载配置
-
第六章,扩展相机处理
第四章:提高应用程序处理能力
欢迎来到第四章!在前一章中,我们添加了在可能的大量模型和实例中选择单个实例的能力。我们从简单的“移动到”功能开始,并在下一步添加了对当前实例的高亮显示。然后,我们通过使用鼠标实现了视觉选择。最后,我们创建了一个空对象,以允许选择没有任何实例。
在本章中,我们将重点关注编辑模式。首先,我们将通过创建一个单独的视图模式来添加关闭所有控制和菜单的能力。编辑和视图模式之间的分离将帮助我们后续章节在配置实例设置时停止所有自动操作。接下来,我们将实现撤销功能的简化版本,允许我们在应用更改后重置模型实例的设置。最后一步,我们将实现实例级别的设置撤销和重做。
在本章中,我们将涵盖以下主题:
-
在编辑和视图模式之间切换
-
在应用更改之前撤销更改
-
实现撤销和重做功能
技术要求
本章的示例源代码位于chapter04文件夹中,对于 OpenGL 位于01_opengl_edit_view_mode子文件夹,对于 Vulkan 位于02_vulkan_edit_view_mode。
在第三章中使实例选择变得容易之后,接下来要解决的问题是更改实例的设置。目前,屏幕上有一个控制菜单,如果指针放在 ImGui 窗口上,则会捕获鼠标输入。我们做出的任何更改都将保留在当前应用程序会话中。将任何意外的旋转或缩放撤销到与之前完全相同的确切值可能非常困难,因为我们必须记住之前的值来撤销更改。
我们现在将应用程序更改为切换到一个没有任何控制或选择模式的模式,并添加一个相当简单但实用的撤销和重做功能,以便将更改回滚到实例或向前推进。
在编辑和视图模式之间切换
如果我们启动当前版本的应用程序,我们会看到,每当我们要更改实例时,用户界面都会阻塞屏幕的大部分区域。
图 4.1显示了当所有标题都展开时用户界面窗口的外观:

图 4.1:用户界面遮挡屏幕部分
随着本书后面将添加越来越多的选项,用户界面可能会占用更多的可用窗口空间。此外,如果高亮显示处于活动状态,并且坐标箭头位于所选实例下方,则所选实例会闪烁。
要改变整体外观,我们必须检查在单独的视图模式中应该禁用哪些功能。
决定在视图模式下应该切换什么
我们本节的主要目标是移除 ImGui 控制窗口,以便整个应用程序可以用于与模型实例交互。我们本可以简单地关闭用户界面的渲染,但如果我们不显示用户界面窗口,就没有必要进行任何计算,如计时器和 FPS 更新。因此,对于第一个决定,我们将跳过查看模式中的用户界面。
我们还必须添加一个快捷方式,以避免在用户界面不活跃时将鼠标按钮事件转发到 ImGui。ImGui 保存所有控制元素的位置,如果我们切换用户界面而鼠标指针位于 ImGui 窗口上方,我们仍然会得到不希望出现的副作用,例如无法移动相机。
即使没有用户界面,所有与实例选择相关的部分仍然处于活动状态。这意味着在查看模式下我们不需要实例选择,因为没有用户界面就无法调整实例参数。我们在这里的第二个决定是关闭与实例选择相关的一切。我们将禁用鼠标拾取,包括选择着色器、坐标箭头和高亮计算。
我们还将调整窗口标题,以反映应用程序当前的查看/编辑状态。从渲染器更改窗口属性需要一点回调“魔法”,但这有助于立即看到应用程序处于何种模式。
大多数查看和编辑模式的切换实现都很简单。我们只需要一个变量来保存当前状态,以及一些分支来为两种模式启用或禁用上述指定的操作。
添加状态变量及代码
作为第一步实现,我们在opengl文件夹中的OGLRenderData.h文件中创建一个新的enum类,命名为appMode:
enum class appMode {
edit = 0,
view
};
对于 Vulkan 渲染器,appMode enum类将位于vulkan文件夹中的VkRenderData.h文件中。
此外,必须为 OpenGL 渲染器添加一个名为rdApplicationMode的变量到OGLRenderData结构体中,以及为 Vulkan 渲染器相应的VkRenderData结构体:
appMode rdApplicationMode = appMode::edit;
我们将rdApplicationMode初始化为编辑模式的值,以避免在启动时对应用程序功能产生可见的变化。
根据模式,要启用或禁用渲染器中的功能,我们可以简单地检查rdApplicationMode变量的值:
if (mRenderData.rdApplicationMode == appMode::edit) {
...
}
例如,要在具有和没有选择支持的动画着色器之间切换,使用以下行:
if (mMousePick &&
mRenderData.rdApplicationMode == appMode::edit) {
mAssimpSkinningSelectionShader.use();
} else {
mAssimpSkinningShader.use();
}
要在查看模式下禁用用户界面的渲染,我们在渲染器中对用户界面的render()调用周围添加一个检查应用程序模式:
if (mRenderData.rdApplicationMode == appMode::edit) {
mUIDrawTimer.start();
mUserInterface.render();
mRenderData.rdUIDrawTime = mUIDrawTimer.stop();
}
我们也在查看模式下禁用了计时器调用——没有屏幕上的用户界面,填充计时器值将是计算资源的浪费。
通过按热键从编辑模式切换到查看模式并返回。如果我们完全禁用用户界面,我们无法在这里使用 ImGui 按钮,因为我们需要按钮来切换回编辑模式。
在两种模式之间切换并更改标题
对于模式切换,在渲染器的handleKeyEvents()方法中执行了对F10键的简单检查:
if (glfwGetKey(mRenderData.rdWindow, GLFW_KEY_F10) ==
GLFW_PRESS) {
mRenderData.rdApplicationMode =
mRenderData.rdApplicationMode == appMode::edit ?
appMode::view : appMode::edit;
setModeInWindowTitle();
}
任何其他未使用的键可以通过将相应的 GLFW 键名作为glfwGetKey()调用的第二个参数放置来实现。
对setModeInWindowTitle()的调用需要进一步解释,因为我们正在使用回调函数来更改应用程序窗口的标题字符串。函数调用本身简短且简单:
void OGLRenderer::setModeInWindowTitle() {
if (mRenderData.rdApplicationMode == appMode::edit) {
setWindowTitle(mOrigWindowTitle + " (Edit Mode)");
} else {
setWindowTitle(mOrigWindowTitle + " (View Mode)");
}
}
我们在渲染器的init()方法中保存原始窗口标题,并将默认模式(编辑模式)追加到窗口标题中:
mOrigWindowTitle = getWindowTitle();
setModeInWindowTitle();
由于窗口类初始化渲染器类,我们必须将窗口标题更改请求向后移动,从渲染器到应用程序窗口。
首先,在渲染器头文件OGLRenderer.h(用于 OpenGL)和VkRenderer.h(用于 Vulkan)中创建了两个std::function别名:
using GetWindowTitleCallback =
std::function<std::string(void)>;
using SetWindowTitleCallback =
std::function<void(std::string)>
我们还在渲染器的声明中添加了两个public方法:
SetWindowTitleCallback setWindowTitle;
GetWindowTitleCallback getWindowTitle;
使用别名使得处理对std::function的调用更加容易。
接下来,我们在window文件夹中的Window类头文件window.h中创建了两个与回调签名匹配的方法:
std::string getWindowTitle();
void setWindowTitle(std::string newTitle);
此外,还添加了一个名为mWindowTitle的private成员变量,用于存储窗口的当前标题:
std::string mWindowTitle;
将窗口标题存储在变量中可能对调试日志打印很有用。
然后,在Window类的init()方法中初始化渲染器之后,使用了两个 lambda 函数将Window类函数调用转发给渲染器:
mRenderer->getWindowTitle = [this]()
{ return getWindowTitle(); };
mRenderer->setWindowTitle = this { setWindowTitle(windowTitle); };
现在我们按F10时,渲染器将模式字符串追加到原始窗口标题中,并通过回调将创建的字符串转发给窗口类。这样,当前应用程序模式就显示在应用程序窗口的标题文本中。
在图 4.2中,用户界面和选择已被禁用。检查窗口标题以查看当前模式:

图 4.2:查看模式中没有用户界面
在查看模式下没有用户界面窗口时,整个应用程序窗口都可以用来在虚拟世界中飞行。通过按F10,我们可以回到编辑模式以调整模型实例的参数。
对未来变化的展望
对于本书后面添加的每个新功能,我们必须决定如何在编辑和查看模式下处理该功能。其中一些新功能仅在编辑模式下可行,例如在第六章中添加和配置不同的摄像机。另一方面,使用摄像机最好在查看模式下进行,因为我们只需要在我们创建的虚拟世界中漫游时在不同的摄像机之间切换。每次我们都要考虑新功能的用法。
在添加了单独的视图模式后,让我们回到基本的应用处理。每次我们选择一个实例并更改其中一个设置时,我们都会被新的值所困扰,没有适当的方法来将实例状态重置为“最后已知良好”的设置。在下一节中,我们将添加一个基本机制,至少将模型实例的最后更改撤销。
在应用更改之前撤销更改
当前模型查看器应用的主要目的是查看模型实例。但是,在纯模型查看旁边,调整实例属性和设置将在整本书中成为应用的一个更大部分。而更改设置可能会出错。一个预览更改的解决方案听起来像是一个很好的功能来添加。
基本思想
一个简单的回滚方法将允许我们接受实例设置的更改,或者将相同的设置撤销到之前的值。我们可以尝试实验值,尝试将实例移动到正确的目的地,或者调整其他参数以满足我们的需求。当我们对结果满意时,我们按下应用按钮,实例设置就被永久化了。相同的流程可以用于下一个实例,依此类推,直到我们将所有实例放置到我们想象中的位置。
添加代码和用户界面元素
首先,我们在UserInterface类的UserInterface.h头文件中添加了两个新的private成员:
std::shared_ptr<AssimpInstance> mCurrentInstance;
InstanceSettings mSavedInstanceSettings{};
在mCurrentInstance智能指针中,我们存储当前选定的实例,而在mSavedInstanceSettings变量中,我们将保存当前选定实例的原始设置。
由于我们在用户界面中多次检索当前实例,我们可以在CollapsingHeader块内的所有其他地方简单地删除currentInstance声明。
读取当前选定实例的设置保持不变:
if (numberOfInstances > 0) {
settings = modInstData.miAssimpInstances.at(
modInstData.miSelectedInstance)
->getInstanceSettings();
此外,我们还要检查当前选定的实例是否与新的mCurrentInstance指针不同:
if (mCurrentInstance !=
modInstData.miAssimpInstances.at(
modInstData.miSelectedInstance)) {
mSavedInstanceSettings = settings;
mCurrentInstance =
modInstData.miAssimpInstances.at(
modInstData.miSelectedInstance);
}
}
每当当前实例更改时,我们将新的选定实例存储在mCurrentInstance指针中,同时我们也将刚刚检索到的实例设置保存到mSavedInstanceSettings变量中。
mSavedInstanceSetting变量现在使我们能够撤销实例的设置,撤销任何更改。为了切换设置撤销,我们在现有的Reset Values to Zero按钮下方添加了一个名为Reset Values to Previous的新按钮:
ImGui::SameLine();
if (ImGui::Button("Reset Values to Previous")) {
settings = mSavedInstanceSettings;
}
要撤销任何更改,我们将保存的实例设置复制回当前设置变量。在CollapsingHeader 实例的末尾,设置中的值被保存到当前选定的实例中。Et voilà,我们已经将设置撤销到了选择实例时的状态。
应用更改只是执行相反的复制操作:
ImGui::SameLine();
if (ImGui::Button("Apply Changes")) {
mSavedInstanceSettings = settings;
}
在这里,保存的设置被实例的当前设置覆盖,使得当前设置成为撤销操作的新默认值。
图 4.3 展示了两个新按钮的位置:

图 4.3:撤销实例设置或永久应用的按钮
通过使用应用更改按钮,将当前对模型实例的更改永久化。而使用重置值到之前按钮,我们可以将实例的任何更改撤销到我们按下应用更改之前的那个状态。
当前解决方案的缺点
使用两个按钮来应用或撤销每个实例设置更改既耗时又繁琐。一个自动应用更改且更频繁的版本将生成在编辑实例属性时的更好工作流程。本节的一般思想将保持不变;我们只是添加了一些更多的自动行为。
让我们创建一个真实的撤销和重做解决方案。
实现撤销和重做功能
几乎每个应用程序都有一种方法可以撤销一个或多个操作,许多应用程序还允许撤销已撤销的步骤。两种方式都可能发生意外;撤销不想要的撤销可以节省用户大量时间和压力。
我们需要什么来创建撤销和重做?
要能够撤销一个改变对象属性的简单操作,我们需要保存前一个值。然后可以通过将“旧”值应用到相同的对象上来撤销该更改,并恢复该对象在更改之前的状态。
对于更复杂的撤销操作,存储操作类型也是必需的。如果我们删除了某些内容,撤销步骤必须重新创建具有相同属性的对象,反之亦然——撤销创建新对象的操作将删除该对象。
还可以考虑其他选项。我们是否想要存储绝对的前一个值,还是只存储与新值之间的差异?通过存储相对值,在撤销操作之后重做更改可以使用调整后的值作为基础,而绝对值将覆盖中间的调整。并且我们是否存储该对象的完整设置,还是只存储更改的参数?其含义与之前的绝对值和相对值类似。
从技术角度来看,我们的实现将使用两个堆栈:一个用于可能的撤销操作,另一个用于可能的重做操作。每次配置更改都会将新的和旧的设置推送到撤销堆栈,通过应用旧的设置来允许撤销更改。
在执行撤销操作之后,将直接从撤销堆栈中取出完全相同的设置组合并将其推送到重做堆栈。如果我们现在对相同的操作进行重做,新设置将被应用,并且设置将移回撤销堆栈。
这个简单的流程将使我们几乎可以无限地撤销和重做,主要受限于计算机内存的容量。而且,由于设置很小,用这两个堆栈填满计算机内存将需要很长时间。
在撤销和重做的道路上迈出的第一步是创建一个存储容器类,该类封装了我们需要的所有信息,以便在任一方向上撤销设置。
创建设置存储类
新的类称为 AssimpSettingsContainer,其头文件和实现文件位于模型文件夹中。在头文件中,声明了一个结构体来存储我们为撤销和重做所需的所有设置:
struct AssimpInstanceSettings {
std::weak_ptr<AssimpInstance> aisInstance;
InstanceSettings aisInstanceSettings{};
InstanceSettings aisSavedInstanceSettings{};
};
AssimpInstanceSettings 结构体保存了实例的先前和当前设置,以及指向实例的弱指针。在这里使用 std::weak_ptr 而不是 std::shared_ptr 有两个重要原因:
-
弱指针打破了包含实例
std::shared_ptr的miAssimpInstances向量与存储撤销/重做的设置之间的依赖关系,因为弱指针不会被添加到实例智能指针的引用计数器中。如果我们使用另一个共享指针来存储撤销/重做信息,那么删除实例的内存可能不会被释放,因为可能存在另一个活动引用存储在撤销或重做栈中的某个地方。 -
当使用
lock()调用请求通过弱指针返回共享指针时,我们可以轻松地找到已删除的实例并移除保存的设置。如果共享指针不再可用,lock()调用将返回nullptr,我们只需在相应的栈上使用pop()调用来移除无效的设置。
如果我们想要保存实例的设置实例,我们调用 AssimpSettingsContainer 类的 apply() 方法。apply() 方法创建一个新的 AssimpInstanceSettings 对象,并将设置推送到撤销栈。
我们在通过 apply() 保存新设置的同时也清空重做栈。在我们撤销了几次更改并应用了最近更改之后,使用重做操作可能毫无意义,因为重做操作可能会与最新更改产生冲突,甚至覆盖最新更改。从重做栈中移除所有设置是避免最新更改应用后副作用的一种快速且安全的方法。
undo() 和 redo() 方法的实现简短且简单:
-
通过从弱指针请求共享指针来检查任何已删除的实例,如果实例指针无效,则移除设置结构体。检查将在
while()循环中完成,以找到所有已删除的实例。 -
如果栈为空(即,因为所有为撤销或重做保存了设置的实例都已消失),我们立即从操作中返回。
-
从栈中获取顶部条目,并在撤销操作的情况下应用保存的设置到实例,或者在重做操作的情况下应用新设置。
-
将栈顶条目推送到相反的栈中,并移除栈顶条目。
现在,所有相关的实例更改都可以保存到撤销堆栈中。如果我们选择撤销对实例的更改,我们也可以立即重做完全相同的更改。在后台,包含旧的和新的设置以及实例指针的结构体只是在撤销和重做操作的两个堆栈之间移动。
将新操作添加到渲染器需要一些额外的工作。
将存储类连接到渲染器
渲染器将通过两个新方法扩展与撤销/重做相关代码,分别称为undoLastOperation()和redoLastOperation()。
在每种方法的开始,我们都在设置容器上调用相应的操作:
mModelInstData.miSettingsContainer->undo();
assignInstanceIndices();
此外,还将发出对assignInstanceIndices()的调用。在撤销和重做操作之后枚举所有实例至关重要。从堆栈中取出的设置在删除其他实例后,isInstanceIndexPosition变量中的实例索引可能无效,导致访问miAssimpInstance向量之外的内容。
枚举的一个副作用是在miAssimpInstances向量中的位置变化。因此,我们不能仅仅使用新的isInstanceIndexPosition来选择被撤销或重做操作更改的实例。相反,我们从AssimpInstanceSettings中检索更改实例的实例指针,并使用std::find_if在miAssimpInstance向量中搜索匹配的指针。如果我们找不到正确的实例,将选择空实例,导致没有实例被选中。
定义撤销和重做的快捷键
要通过在键盘上使用已知的关键组合来使用撤销和重做功能,我们在渲染器中的handleKeyEvents()方法中添加了新键:
if (mRenderData.rdApplicationMode == appMode::edit) {
if (glfwGetKey(mRenderData.rdWindow,
GLFW_KEY_Z) == GLFW_PRESS &&
(glfwGetKey(mRenderData.rdWindow,
GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS ||
glfwGetKey(mRenderData.rdWindow,
GLFW_KEY_RIGHT_CONTROL) == GLFW_PRESS)) {
undoLastOperation();
}
...
}
在这里,只展示了撤销部分,使用键盘组合CTRL + Z,就像许多其他应用程序一样。当按下CTRL + Y时,会调用重做功能,这也是一个众所周知的关键组合,用于重做更改。
撤销和重做功能仅在编辑模式下激活。我们无法在查看模式下调整实例设置,因此我们也不需要撤销或重做更改。
现在,我们可以通过键盘组合调用撤销和重做功能。然而,为了更方便地访问,通过菜单栏访问这两个操作会更好。
添加 ImGui 菜单以允许直接访问
幸运的是,可以通过几个 ImGui 调用来添加菜单栏:
if (ImGui::BeginMainMenuBar()) {
if (ImGui::BeginMenu("Edit")) {
if (ImGui::MenuItem("Undo", "CTRL+Z")) {
modInstData.miUndoCallbackFunction();
}
if (ImGui::MenuItem("Redo", "CTRL+Y")) {
modInstData.miRedoCallbackFunction();
}
ImGui::EndMenu();
}
ImGui::EndMainMenuBar();
}
就像其他 ImGui 小部件一样,主菜单栏本身以及菜单栏中的所有菜单都从ImGui::Begin*()行开始,并以ImGui::End*()行结束。与其他 ImGui 小部件一样,ImGui 菜单命令在激活时(即通过在菜单项上单击左鼠标按钮)返回一个布尔值true。
此外,我们再次在ModelAndInstanceData结构体中使用回调函数,将撤销和重做操作的工作负载从用户界面移动到渲染器。
在图 4.4中,显示了应用程序的新 ImGui 菜单栏:

图 4.4:主菜单栏,包含带有撤销和重做条目的编辑菜单
Practical sessions section.
我们撤销/重做实现的限制和增强
当前撤销/重做实现的最大缺点是限制了对单个实例配置更改的反应。我们没有捕获其他功能,如加载或删除模型,或一次创建多个模型实例。
为多个实例添加撤销/重做支持需要对AssimpSettingsContainer类进行扩展,以在单个组中存储所有受影响的实例。在撤销或重做操作中,我们不想逐个实例进行操作,但所有实例应同时出现或消失。
包括对撤销/重做堆栈的模型更改需要重新设计设置容器类。现在,我们必须检查我们正在处理哪种类型的对象,并添加模型更改以及受影响的实例更改。删除一个模型也会删除该模型的所有实例;撤销操作需要恢复模型及其所有实例及其相应的设置。
将当前实现的撤销/重做功能扩展到支持多个实例和模型更改,可以在本章末尾的实践课程部分找到一个任务。但请注意,构建一个成熟的撤销/重做系统并非易事,并且需要大量的时间。
如您所见,保持一致的撤销/重做功能会增加应用程序的很多开销,但投入在良好的撤销和重做处理上的时间是值得的。任何应用程序的未来用户都会感谢程序员能够从不受欢迎的更改中恢复,比如意外的更改,甚至元素的删除。
概述
在本章中,我们添加了第二种应用程序模式,以禁用所有不适用于虚拟世界访问的功能。用户界面、选择以及属于这些主题的所有部分现在可以通过热键禁用。接下来,我们测试了实例设置更改的基本回滚操作和启用简单的撤销。最后,我们将回滚增强为实例级别任何设置更改的撤销/重做功能。
在下一章中,我们将实现将所有模型和实例的当前配置保存到文件中的功能,以及从保存的文件中恢复所有模型和实例。有了工作的保存和加载功能,我们可以在任何时候停止更改虚拟世界,并在稍后的时间点以完全相同的状态继续。
实践课程
您可以在代码中添加一些内容:
- 将导入模型按钮移动到菜单栏。
导入模型按钮可能从一开始就感觉有点放错位置,但现在我们有机会改变其功能。将按钮的功能移动到菜单栏的子菜单中,使其更容易理解如何将模型导入到应用程序中。
- 当应用程序关闭时添加一个确认对话框。
如果我们现在使用像 ALT + F4 这样的快捷键组合或点击图标来关闭窗口,应用程序会立即结束。添加一个简单的弹出对话框,包含是/否问题,允许用户停止另一个可能的事故性操作。由于我们现在有了菜单栏,你也可以创建一个 File->Exit(文件->退出)的条目,调用相同的逻辑在关闭应用程序前请求确认。哦,别忘了当请求应用程序退出时切换回编辑模式。一个隐藏的退出对话框几乎不可用。
- 增强难度:添加一个功能齐全的撤销/重做。
目前,我们只存储简单的设置更改。扩展AssimpSettingContainer类以存储实例的添加和删除事件。你可能需要一个enum来存储操作,因为在撤销和重做操作期间,你需要执行相反的操作。你可能需要调整两个栈以存储实例设置的向量。对实例进行大量添加应该可以通过单个撤销或重做调用在两个方向上回滚,而不是对每个组中的单个实例进行操作。
其他资源
-
用于撤销/重做操作的命令模式:
gameprogrammingpatterns.com/command.html -
《C++游戏编程》由Sanjay Madhav著,由Pearson Addison-Wesley出版,ISBN 978-0134597201
第五章:保存和加载配置
欢迎来到第五章!在前一章中,我们向应用程序添加了一个单独的视图模式。在这个仅查看模式下,用户界面和选择功能被禁用。然后,我们添加了实例的简化版和最终的全版本撤销/重做。现在,实例的设置更改可以被撤销或重新应用。
在本章中,我们将添加将应用程序配置保存到文件的能力。首先,我们将探索不同的文件类型来存储数据。在考虑每种文件类型的优缺点并确定合适的文件格式后,我们将深入研究文件格式的结构。然后,我们将实现一个解析器类,使我们能够加载和保存配置。最后,我们将在应用程序启动时加载默认配置,使用户能够尝试使用应用程序。
对于任何更大的应用程序,能够保存创建或更改的数据的当前状态,停止应用程序,然后再次加载数据以继续工作至关重要。从挂起或崩溃的应用程序中恢复到最新保存版本也同样重要。你不希望因为应用程序无法正确地将数据保存到本地或远程存储而丢失数小时的工作。
在本章中,我们将涵盖以下主题:
-
文本或二进制文件格式——优缺点
-
选择一种文本格式来保存我们的数据
-
探索 YAML 文件的结构
-
添加 YAML 解析器
-
保存和加载配置文件
-
启动时加载默认配置文件
技术要求
本章的示例源代码位于chapter05文件夹中,在01_opengl_load_sve子文件夹中为 OpenGL,在02_vulkan_load_save中为 Vulkan。
在我们向应用程序添加保存和加载功能之前,让我们看看一些将数据保存到存储设备的方法。
文本或二进制文件格式——优缺点
每当你玩游戏、剪辑视频、编辑照片、编写文本或书籍时,你都会使用与所使用的软件集成的集成加载和保存功能。在安全位置保存游戏、在大量编辑后存储视频序列,或者在文本编辑器中不时按下Ctrl + S已经变得很正常了。
但是,你有没有想过保存和加载数据?你可能对以下功能有一些疑问,比如:
-
需要存储什么才能完全恢复你的最新状态?
-
应该使用什么格式来保存数据?
-
如果程序更新了会发生什么?我能否加载保存的数据?
-
我能否在没有原始程序的情况下读取或更改保存的文件?
对于你使用的所有程序,关于格式和数据量的决定在于它们的开发者。但是,对于我们的应用程序,我们必须决定哪些数据需要保存,以及如何将数据保存到存储中。
所有类型的数据格式都有其优缺点;以下是一个简要总结。
保存和加载二进制数据
在计算机时代的早期,存储空间和计算时间都很昂贵、宝贵且稀缺。为了在保存和恢复过程中最小化空间和时间,数据基本上只是内存转储。
要保存的数据存储在内部数据类型中,进入计算机的内存区域,然后逐字节地复制到软盘或硬盘上。
加载相同的数据与保存它一样简单快捷:从存储设备读取数据到计算机内存(再次,逐字节)并将其解释为保存时使用的相同内部数据类型。
让我们来看看二进制数据的优点和缺点:
-
优点:
-
文件较小。
-
通过仅复制数据即可保存和加载数据,这导致保存和加载操作的速度更快。
-
-
缺点:
-
要在应用程序外部更改数据,需要特殊知识。
-
损坏的资料可能会引起不可预测的副作用或崩溃。
-
保存文件格式的更新可能很困难。需要使用魔数来找到实际版本,并将加载的数据映射到正确的内部数据类型。
-
由于不同的字节序,二进制数据可能无法在不同的架构之间移植。因此,除非绝对必要,否则通常建议避免使用二进制保存文件。我们仍然可以读取 30 年前在 MS-DOS 系统上创建的CONFIG.SYS和AUTOEXEC.BAT文件,但同一时期的电子表格计算器或文字处理器的二进制保存文件是无法使用的,至少在没有正确工具或进行逆向工程文件格式的艰苦工作的情况下是这样。在像图片或声音文件这样有良好文档和标准化的格式之外,保存二进制数据将会引起麻烦,因为你可能无法在不同的操作系统上打开二进制文件,甚至可能无法打开同一系统的较新版本。
由于 CPU 时间和存储空间不再受限,文本格式的优势现在明显超过了二进制保存的优势。
保存和加载文本数据
随着 CPU 性能、网络带宽和存储空间的不断增加,文本格式开始成为保存数据的首选。当你可以使用简单的文本编辑器创建或调整保存文件,或者可以通过将文本行打印到日志文件中查找错误时,编写代码会变得容易得多。
对于基于文本的保存文件,条件与二进制保存文件的条件不同:
-
文件较大,除非它们被压缩成.zip 文件或类似的格式。那时,还需要另一个转换步骤(打包/解包)。
-
每次加载数据和保存数据时,数据都必须从二进制表示形式转换为文本,然后再转换回来。
-
对于较大的更改或从头创建保存文件,可能需要特定于域的文件格式知识。但对于简单的值更改,我们只需要一个文本编辑器。
-
损坏的文件可以修复,或者可以直接从文本文件中删除损坏的数据元素。宁愿丢失一些数据,也不要全部丢失。
-
通过在文件中增加版本号,可以检测文件格式的更新,帮助应用程序使用正确的转换。
-
在具有不同架构或操作系统的计算机上加载相同的文件根本不是问题。文本表示法是相同的,并且由于从文本到二进制数据类型的转换,字节序或数据类型长度并不重要。
-
对于跨平台使用,仍然存在一些注意事项,例如 Windows 和 Linux 中的不同路径分隔符,或者系统区域设置中点号和逗号的不同解释。
-
如果需要将配置分割成多个文件,将所有文件打包到压缩文件中是最常见的方式。通过将所有配置文件添加到
.zip或.tar.gz文件中,最终得到一个单独的文件,并且由于压缩节省了一些磁盘空间。
使用文本表示法来保存文件是可行的。通过定义简单的文件格式,甚至可以手动创建配置文件。
但是,在我们自己创建文件格式之前,让我们检查一些可用的文件格式。通过使用众所周知的文件格式,我们可以节省大量时间,因为我们不需要创建解析和写入文件的函数。
选择文本格式来保存我们的数据
在本节中,我们将探讨三种流行的配置文件格式:
-
INI
-
JSON
-
YAML
所有三种格式在某些领域都有应用,但可能不适合其他领域。
INI 文件格式
存储配置数据的最古老格式之一是所谓的 INI 格式。这个名字来自文件扩展名 .ini,它是初始化的三个字母缩写。INI 文件主要用于配置程序。
在 INI 文件中,简单的键/值对存储并组织在可选的部分中。部分名称用方括号括起来,部分作用域从部分的开始到另一个部分的开始,或文件的末尾。
这里是一个数据库连接的示例部分和一些键/值对:
[database-config]
type = mysql
host = mysql
port = 3306
user = app1
password = ASafePassWord1#
通过使用特殊字符(如点.或反斜杠\)分隔部分和子部分,可以嵌套部分以创建某种层次结构。对于解析器识别这些部分划分至关重要;否则,每个部分都将独立处理,不考虑它们之间的层次关系。
缺乏重复键名使得存储具有重复键的分层或非平凡数据变得困难,例如模型文件或实例配置。
JSON 文件格式
JSON,即 JavaScript 对象表示法,在 2000 年代初首次亮相。与 INI 文件一样,键/值对存储在 JSON 文件中。与 INI 文件部分类似的部分不存在;相反,JSON 文件允许创建复杂、树状的结构。此外,可以定义相同数据类型的数组。
JSON 文件的主要用途是电子数据交换,例如,在 Web 应用程序和后端服务器之间。文件格式的良好可读性只是副作用;JSON 文件主要是由应用程序读取和编写的。
这是一个 JSON 文件的示例,包含与 INI 文件示例相同的数据:
{
"database-config": {
"type": "mysql",
"host": "mysql",
"port": 3306,
"user": "app1",
"password": "ASafePassWord1#"
}
}
很遗憾,由于大括号数量众多,第一次尝试正确编写文件格式是困难的。此外,不允许注释,因此只能在保存原始文件的副本并调整内容的情况下,实时 测试不同的选项。
YAML 文件格式
YAML 的名字最初是一个缩写,代表 Yet Another Markup Language。在 2000 年代初,产品名称前缀的 yet another 被用作与计算机相关的幽默,表明原创想法的不断重复。但是,由于 YAML 不是一个像 HTML 或 XML 那样的标记语言,因此名字的含义被改为递归缩写 YAML Ain’t Markup Language。
YAML 和 JSON 密切相关。一个 JSON 文件可以被转换成 YAML 文件,反之亦然。这两种格式之间的主要区别是,YAML 中使用缩进来创建层次结构,而不是使用大括号。
YAML 的主要目标是使人类可读。YAML 格式广泛用于创建和维护结构化和层次化的配置文件(例如,在配置管理系统和云环境中)。
下面是一个 YAML 文件格式的示例,再次使用与 INI 和 JSON 相同的数据:
database-config:
type: mysql
host: mysql
port: 3306
user: app1
password: ASafePassWord1#
由于 YAML 格式简单且强大,具备我们所需的所有功能,并且可以像 JSON 一样无需在缺失的大括号上卡壳进行读写,因此我们将使用 YAML 文件来存储我们应用程序的配置数据。
探索 YAML 文件的结构
让我们看看 YAML 文件的主要三个组成部分:
-
节点
-
映射
-
序列
让我们从节点开始。
YAML 节点
YAML 文件的主要对象是一个所谓的节点。节点代表其下面的数据结构,可以是标量值、映射或序列。
of a configuration file as an example:
database-config:
type: mysql
database-config 和 type 都是 YAML 节点。虽然 database-config 节点包含一个包含键 type 和值 mysql 的映射,但 type 节点中仅包含标量值 mysql。
YAML 映射
一个 YAML 映射包含零个或任意数量的键/值对。此外,节点和映射之间存在一个有趣的关联:键和值可能又是另一个节点,从而在文件中创建层次结构。
让我们扩展节点部分之前的配置片段:
database-config:
type: mysql
host: mysql
port: 3306
如前所述,database-config 既是节点也是地图。名为 database-config 的地图的键是名称 database-config,值是另一个包含三个键值对的地图。
YAML 序列
为了列举相似元素,使用 YAML 序列。序列可以看作是一个 C++ std::vector,其中所有元素必须是同一类型。像 C++ 向量一样,你遍历序列,逐个读取数据元素。
序列有两种不同的风格:块风格和流风格。
在块风格中,缩进的破折号(-)用作元素的指示符:
colors:
- red
- green
- blue
相反,流风格使用方括号,元素之间用逗号分隔:
colors: [red, green, blue]
这两种风格表示相同的数据。这取决于个人喜好和可读性。
通过组合地图和序列,可以创建复杂的数据结构。
地图和序列的组合
通过混合地图和序列,可以实现表示数据的一种强大方式。例如,我们可以像这样存储所有模型实例的 position 和 rotation:
instances:
- position: [0, 0, 0]
rotation: [0, 0, 0]
- position: [1, 0, -3]
rotation: [0, 90, 0]
- position: [3, 0, 3]
rotation: [0, -90, 0]
在这里,我们使用了地图和序列的组合。
首先,我们创建一个由键 position 和 rotation 组成的地图;值是表示 glm::vec3 的流式序列数字。YAML 总是存储标量数字的最短可能表示形式。因此,只要值没有小数部分,就会使用整数值,即使对于 float 和 double 类型也是如此。然后,使用 position 和 rotation 的地图在块序列中创建模型实例的数组式表示。
要将实例的数据读入我们的应用程序,我们必须首先遍历模型实例序列,并且对于每个实例,我们可以提取位置和旋转值。
在对 YAML 文件格式的基本介绍之后,我们现在将为我们的应用程序实现一个 YAML 解析器和写入器类,以便保存和加载其配置。将配置存储在磁盘上就像保存一个文本文档一样——我们可以退出应用程序,稍后再继续在虚拟世界中工作。我们还可以使用保存的文件返回到虚拟世界的先前状态。
添加 YAML 解析器
与我们正在使用的其他工具(如 Open Asset Import Library、GLFW 或 ImGui)一样,我们将使用一个免费的开源解决方案:yaml-cpp。
通过集成 yaml-cpp,我们可以用最小的努力从 C++ 中读取和写入 YAML 文件。最大的步骤是确保我们的自定义数据类型为 yaml-cpp 所知。此外,我们必须考虑数据文件的正确结构。
让我们先探讨如何将 yaml-cpp 集成到我们的项目中。
获取 yaml-cpp
对于 Linux 系统,获取 yaml-cpp 很容易。与其他工具类似,大多数发行版已经包含了 yaml-cpp 库和头文件。例如,在 Ubuntu 22.04 或更高版本中,可以使用以下命令安装 yaml-cpp 和其开发文件:
sudo apt install libyaml-cpp-dev
如果你使用的是基于 Arch 的 Linux 发行版,可以使用以下命令安装 yaml-cpp:
sudo pacman –S yaml-cpp
对于 Windows,我们也同样幸运。yaml-cpp 使用 CMake,通过使用 CMake 的 FetchContent 命令,只需几行代码就可以将 yaml-cpp 添加到项目中。首先,我们在项目根目录下的 CMakeLists.txt 文件中添加 FetchContent 声明。我们使用 yaml-cpp 的 0.8.0 版本:
FetchContent_Declare(
yaml-cpp
GIT_REPOSITORY https://github.com/jbeder/yaml-cpp
GIT_TAG 0.8.0
)
确保我们在 CMakeLists.txt 文件的 WIN32 部分内部。在 Linux 上我们不需要下载库。
然后,我们触发 yaml-cpp 的下载并添加目录变量:
FetchContent_MakeAvailable(yaml-cpp)
FetchContent_GetProperties(yaml-cpp)
if(NOT yaml-cpp_POPULATED)
FetchContent_Populate(yaml-cpp)
add_subdirectory(${yaml-cpp_SOURCE_DIR}
${yaml-cpp_BINARY_DIR} EXCLUDE_FROM_ALL)
endif()
Windows 还需要一个脚本来检测下载的依赖项。检测脚本必须命名为 Findyaml-cpp.cmake 并放置在 cmake 文件夹中。
脚本的主要功能归结为这两个 CMake 函数:
find_path(YAML-CPP_INCLUDE_DIR yaml-cpp/yaml.h
PATHS ${YAML-CPP_DIR}/include/)
find_library(YAML-CPP_LIBRARY
NAMES ${YAML-CPP_STATIC} yaml-cpp
PATHS ${YAML-CPP_DIR}/lib)
感谢 CMake 的 FetchContent,YAML-CPP_DIR 变量被填充为下载的 yaml-cpp 代码的路径。因此,脚本只需检查头文件和库是否可以找到。
将 yaml-cpp 集成到 CMake 构建
对于 Linux 和 Windows,我们必须为编译器设置正确的包含路径,并将 yaml-cpp 库添加到链接库列表中。
要更新 include 路径,将 YAML_CPP_INCLUDE_DIR 变量添加到 include_directories 指令中:
include_directories(... ${YAML_CPP_INCLUDE_DIR})
对于链接器,需要在 Windows 中将 yaml-cpp::yaml-cpp 添加到 target_link_libraries:
target_link_libraries(... yaml-cpp::yaml-cpp)
对于 Linux,只需要共享库的名称 yaml-cpp:
target_link_libraries(... yaml-cpp)
再次运行 CMake 后,yaml-cpp 将被下载并可供代码的其他部分使用。
添加解析器类
解析 YAML 文件以加载和创建写入内容将在一个名为 YamlParser 的新类中完成,该类位于 tools 目录中。我们可以在包含头文件后使用 yaml-cpp:
#include <yaml-cpp/yaml.h>
在加载或创建用于保存到磁盘的数据结构时,需要两个额外的私有成员来存储中间数据:
YAML::Node mYamlNode{};
YAML::Emitter mYamlEmit{};
YAML::Node 将 YAML 文件的节点从磁盘转换为 C++ 数据结构,简化了对已加载数据的访问。YAML::Emitter 用于通过追加数据元素在内存中创建 YAML 文件,最终将结构化数据写入文件。
使用 yaml-cpp 的节点类型
通过使用节点名称作为 YAML::Node 类中存储的 C++ 映射的索引来访问 yaml-cpp 节点的结构化或标量数据:
mYamlNode = YAML::LoadFile(fileName);
YAML::Node settingsNode = mYamlNode["settings"];
要检索简单键/值映射的标量值,存在特殊运算符 as:
int value = dataNode.as<int>();
由于 YAML 文件不了解存储在值中的数据类型,我们必须明确告诉 yaml-cpp 如何解释传入的数据。在这里,yaml-cpp 将尝试获取 dataNode 节点的值为 int。
在定义了转换模板之后,也可以直接将自定义数据类型如结构体读入相同类型的变量中:
InstanceSetting instSet = instNode.as<InstanceSettings>();
我们将在保存和加载配置文件部分处理转换模板。
访问序列和映射
在 yaml-cpp 中,可以通过使用 for 循环遍历它们来读取序列,并通过索引访问元素:
for (size_t i = 0; i < instNode.size(); ++i) {
instSettings.emplace_back(
instNode[i].as<InstanceSettings>());
}
序列的元素以它们在 YAML 文件中出现的顺序提供。
对于映射,需要一个迭代风格的 for 循环:
for(auto it = settingsNode.begin();
it != settingsNode.end(); ++it) {
if (it->first.as<std::string>() == "selected-model") {
....
}
}
映射元素的键可以通过 C++ 映射容器的 first 访问器读取。同样,我们必须告诉 yaml-cpp 映射键的数据类型。然后,可以使用 C++ 映射容器的 second 访问器检索值。
如果读取值失败,例如,因为类型错误或不存在这样的节点,将抛出异常。为了避免我们的程序被终止,我们必须处理所有抛出的异常。
处理由 yaml-cpp 抛出的异常
yaml-cpp 在出错时抛出异常,而不是返回错误代码。默认情况下,任何未处理的异常都会终止程序。在 C++ 中处理异常的方式与其他语言类似:
try {
mYamlNode = YAML::LoadFile(fileName);
} catch(...) {
return false;
}
可能引发异常的调用将被包含在 try 块中,如果发生异常,则执行 catch 块。
我们可以简单地捕获所有异常,因为任何解析失败可能会导致配置文件为空或不完整。如果您想要更详细的异常处理,可以探索 yaml-cpp 的源代码。
借助 yaml-cpp 的基础知识,我们可以开始实现保存和加载 YAML 文件的代码。我们将从保存功能开始,因为在我们已经在磁盘上创建了一个文件之后,将数据元素重新加载到应用程序中将会比首先手工制作配置文件要容易得多。
保存和加载配置文件
构建我们的配置文件始于决定需要存储什么以及我们希望如何存储元素。通过重用我们的自定义数据类型,如 InstanceSettings,创建保存和加载文件的函数可以简化。现在我们不再需要逐个读取每个值,而是可以使用来自 AssimpInstance 类的 getInstanceSettings() 和 setInstanceSettings() 调用来直接在解析器和实例之间传递值。
我们将首先探索我们想要保存的内容,在添加将我们的自定义数据写入文件的代码之后,将添加一个用户界面对话框,允许以简单的方式将文件保存到磁盘。最后,我们将逐步通过将配置重新加载到应用程序中的过程。
决定在配置文件中存储什么
如保存和加载文本数据部分所述,添加版本号可以在应用程序的开发过程中非常有帮助。如果我们需要更改数据格式,即使只是轻微的更改,提高版本号可以帮助我们在读取文件时简化在旧格式和新格式之间的分支。
接下来,我们应该存储有关选择的信息。恢复选定的模型和实例后,我们可以从保存配置文件的地方继续。
此外,我们还应该存储相机信息。在本书后面处理更复杂的场景时,将相机恢复到默认位置和角度可能会让应用程序用户感到困惑。
作为最重要的部分,我们必须存储所有关于模型和所有屏幕上所需的信息,以便将应用程序恢复到保存配置时的相同状态。
对于模型,文件名和路径就足够了,因为我们使用文件名作为模型的名称。模型文件的路径将相对于应用程序的可执行文件保存,而不是绝对路径,至少在模型位于与可执行文件相同的分区上时是这样(仅限 Windows)。这两种方法都有其优缺点:
-
相对路径允许用户从系统上的任何位置检出代码,能够直接使用示例配置文件和示例模型。然而,将可执行文件移动到另一个目录或分区时,需要移动所有配置数据和模型,或者必须手动调整配置文件。
-
使用绝对路径可能便于在 PC 上的固定位置(例如,在用户的家目录中)存储新的配置。这样,应用程序可以从 PC 上的任何位置启动,并且仍然可以找到配置文件和模型。
为了恢复所有实例,我们需要存储在 InstanceSettings 结构中的所有信息以及模型名称。为了简化通过模型名称恢复实例,我们将模型名称作为 std::string 添加到 InstanceSettings 结构中。在结构中拥有模型名称允许我们将从 YAML 解析器传递给渲染类的一个 InstanceSettings 值的 std::vector;我们不需要更复杂的数据结构。
让我们从创建自定义元素写入器重载开始实现。
重载发射器的输出运算符
yaml-cpp 的创建者增加了一个很好的方法,可以将复杂结构的输出内容添加到 YAML::Emitter 中。我们只需在 tools 文件夹中的 YamlParser.cpp 文件中重载 operator<< 即可:
YAML::Emitter& operator<<(YAML::Emitter& out,
const glm::vec3& vec) {
out << YAML::Flow;
out << YAML::BeginSeq;
out << vec.x << vec.y << vec.z;
out << YAML::EndSeq;
return out;
}
对于 glm::vec3 数据类型,我们添加一个流类型序列,然后向流中添加向量的三个元素。在最终的文件中,将出现一个默认的 YAML 序列,包含 glm::vec3 向量的值,如下例所示:
[1, 2, 3]
我们的 InstanceSettings 结构必须作为 YAML 映射的键/值对添加。映射的开始和结束在存储 InstanceSettings 的函数中设置:
YAML::Emitter& operator<<(YAML::Emitter& out,
const InstanceSettings& settings) {
out << YAML::Key << "model-file";
out << YAML::Value << settings.isModelFile;
out << YAML::Key << "position";
out << YAML::Value << settings.isWorldPosition;
out << YAML::Key << "rotation";
out << YAML::Value << settings.isWorldRotation
...
}
生成的映射将被添加为 YAML 序列的值,将所有相关实例数据存储在配置文件中。
创建和写入配置文件
要创建配置文件,渲染器将实例化我们的 YamlParser 类的一个本地对象,并对其调用 createConfigFile():
YamlParser parser;
if (!parser.createConfigFile(mRenderData, mModelInstData)) {
return false;
}
在 createConfigFile() 方法中,YAML::Emitter 将填充我们的数据结构。例如,我们将在文件顶部添加一条注释,并在第二行保存版本号:
mYamlEmit << YAML::Comment("Application viewer
config file");
mYamlEmit << YAML::BeginMap;
mYamlEmit << YAML::Key << "version";
mYamlEmit << YAML::Value << 1.0f;
mYamlEmit << YAML::EndMap;
...
在最终的 YAML 文件中,以下第一行将出现:
# Application viewer config file
version: 1
作为另一个示例,为了存储实例设置,我们创建一个名为 instances 的映射,并开始创建一个序列作为其值:
mYamlEmit << YAML::BeginMap;
mYamlEmit << YAML::Key << "instances";
mYamlEmit << YAML::Value;
mYamlEmit << YAML::BeginSeq;
然后,我们可以创建一个遍历实例的 for 循环,并使用实例的 getInstanceSettings() 调用来直接将实例设置存储到发射流中:
for (const auto& instance : modInstData.miAssimpInstances) {
mYamlEmit << YAML::BeginMap;
mYamlEmit << instance->getInstanceSettings();
mYamlEmit << YAML::EndMap;
}
多亏了 operator<< 重载,循环内不需要复杂的处理。
作为最后一步,我们关闭实例设置的序列,并关闭 instances 键的映射:
mYamlEmit << YAML::EndSeq;
mYamlEmit << YAML::EndMap
最终的 YAML 文件将包含所有实例的序列,包括新添加的模型文件名:
instances:
- model-file: Woman.gltf
position: [0, 0, 0]
rotation: [0, 0, 0]
scale: 1
swap-axes: false
anim-clip-number: 0
anim-clip-speed: 1
如果我们想在执行任何磁盘写入之前查看创建的配置文件内容,我们可以创建一个 C 字符串并通过日志类输出该字符串:
Logger::log(1, "%s\n", mYamlEmit.c_str());
将文件写入磁盘将使用 std::ostream 完成。为了简洁,以下列表中省略了流的错误处理,但将文件保存到磁盘实际上只需三行代码:
bool YamlParser::writeYamlFile(std::string fileName) {
std::ofstream fileToWrite(fileName);
fileToWrite << mYamlEmit.c_str();
fileToWrite.close();
return true;
}
首先,我们使用给定的文件名创建输出流。然后,我们将 YAML::Emitter 的 std::string 转换为 C 字符串,并将字符串写入输出流。通过关闭流,文件将被刷新到存储设备。
向用户界面添加文件对话框
为了允许用户将配置文件存储在任意位置并使用自定义名称,我们将在用户界面中添加一个文件对话框。我们已经在使用基于 ImGui 的文件对话框来加载模型文件,并且我们可以重用相同的对话框实例向用户展示一个 保存文件 对话框。
要创建一个允许用户选择文件名和位置的对话框,必须对名为 config 的 IGFD::FileDialogConfig 变量进行三项更改。
首先,通过选择现有文件,我们需要一个额外的对话框来确认文件覆盖。幸运的是,文件对话框已经内置了这样的确认对话框。我们只需添加标志 ImGuiFileDialogFlags_ConfirmOverwrite:
config.flags = ImGuiFileDialogFlags_Modal |
ImGuiFileDialogFlags_ConfirmOverwrite;
如果我们选择一个现有文件,将显示一个新对话框,询问用户是否要替换现有文件。
接下来,我们将展示默认路径和文件名用于配置:
const std::string defaultFileName = "config/conf.acfg";
config.filePathName = defaultFileName.c_str();
在这里,我们使用 config 文件夹和一个名为 config.acfg 的文件向用户展示一个默认文件。文件对话框代码将自动进入 config 文件夹并填写文件名和扩展名。
作为最后一步,我们将 .acfg 作为对话框的唯一文件扩展名添加:
ImGuiFileDialog::Instance()->OpenDialog(
"SaveConfigFile", "Save Configuration File",
".acfg", config);
通过为配置文件使用新的扩展名,我们避免了麻烦,例如尝试加载不同的文件格式或覆盖系统上的其他文件。
文件对话框中的 OK 按钮获取选定的文件名,并调用负责将配置保存到磁盘的回调函数:
if (ImGuiFileDialog::Instance()->IsOk()) {
std::string filePathName =
ImGuiFileDialog::Instance()->GetFilePathName();
saveSuccessful =
modInstData.miSaveConfigCallbackFunction(
filePathName);
}
我们将回调函数的结果存储在布尔变量 saveSuccessful 中。这样,我们可以检查任何错误,并在保存配置不成功的情况下向用户显示对话框。
为了通知用户保存错误,仅实现了一个简单的对话框,提示用户检查应用程序的输出消息以获取关于写入错误原因的详细信息。
如果你现在加载一些模型,创建实例或克隆,并保存配置,你可以检查创建的配置文件。所有来自 决定在配置文件中存储什么 部分的数据都应该包含在配置文件中。
将数据保存到磁盘只是工作的一半。为了从保存文件的位置继续工作,我们需要将配置文件重新加载到应用程序中。
重新加载配置文件并解析节点
为了支持在 YAML 文件中解析自定义数据类型,yaml-cpp 允许我们定义一个位于 YAML namespace 中的名为 convert 的 C++ 模板结构体。convert 结构体必须实现两个方法,即 encode 和 decode,这两个方法分别负责将 C++ 类型序列化为 YAML (encode) 以及从 YAML 反序列化回 C++ (decode)。通过使用这两个方法,yaml-cpp 允许在 C++ 类型与 YAML 条目之间实现无缝转换。encode 方法从一个原始或自定义数据类型创建一个新的 YAML 节点,而 decode 方法读取 YAML 节点数据并返回原始或自定义数据类型。
对于将 glm::vec3 元素写入 YAML 节点并将 YAML 节点读取回 glm::vec3,必须在头文件中实现以下模板代码:
namespace YAML {
template<>
struct convert<glm::vec3> {
static Node encode(const glm::vec3& rhs) {
Node node;
node.push_back(rhs.x);
node.push_back(rhs.y);
node.push_back(rhs.z);
return node;
}
要从 glm::vec3 保存数据,我们创建一个新的 YAML 节点称为 node,并将 glm::vec3 的三个元素 x、y 和 z 添加到节点中。然后,节点被返回给 encode() 方法的调用者。
使用 decode() 方法将数据从节点读取到 glm::vec3 变量中:
static bool decode(const Node& node, glm::vec3& rhs) {
if(!node.IsSequence() || node.size() != 3) {
return false;
}
检查节点类型以获取正确类型和大小是可选的,但这是一个良好的风格,确保我们有正确的自定义数据类型数据,以防止运行时错误。跳过此检查并尝试解析错误的数据类型将导致异常,如果未处理,则终止整个程序。
然后,我们通过序列索引从节点中读取数据,并将 glm::vec3 的三个元素 x、y 和 z 设置为节点中的浮点值:
rhs.x = node[0].as<float>();
rhs.y = node[1].as<float>();
rhs.z = node[2].as<float>();
return true;
}
};
在定义了 encode() 和 decode() 方法之后,我们可以通过正常赋值在 YAML node 和 glm::vec3 之间交换数据:
glm::vec3 data;
node["rotation"] = data.isWorldRotation;
data.isWorldRotation = node["rotation"].as<glm::vec3>();
对于InstanceSettings结构,实现了相同的方法,帮助我们直接将实例设置读回到InstanceSettings类型的变量中。为了避免污染我们的解析器类头文件,已在tools文件夹中创建了一个新的头文件YamlParserTypes.h。YamlParserTypes.h头文件将被包含在YamlParser类的头文件中,以便新的转换可用。
一旦配置文件成功解析,所有设置、模型路径和实例设置都被提取出来。但在我们可以加载模型和创建新实例之前,我们必须首先清除当前模型和实例列表。
从保存的值中清理和重新创建场景
移除所有模型和实例是一个简单直接的过程。在渲染器中,我们必须执行以下步骤以获得一个新鲜的环境:
-
将包含当前选定的实例和模型的
miSelectedInstance和miSelectedModel设置为零。从本步骤以及步骤 2 和 3中引入的变量在第一章的动态模型和实例管理部分中介绍。然后,在索引零处,将创建新的空模型和空实例。 -
删除
miAssimpInstances向量和清除miAssimpInstancesPerModel映射。现在,所有模型都未使用。 -
删除
miModelList向量。由于所有实例都已删除,模型的共享指针将不再被引用,模型将被删除。 -
添加一个新的空模型和一个空实例。空模型和空实例必须是模型列表和实例向量以及映射中的第一个元素。
-
清除撤销和重做栈。在栈中,我们只使用了弱指针,因此这一步可以在任何时候进行。
-
更新三角形计数。在所有模型和实例被移除后,三角形计数应为零。
清理所有模型和实例的整个流程已被添加到渲染类的新removeAllModelsAndInstances()方法中,简化了每次需要干净且新鲜环境时的使用。
现在,我们可以从磁盘加载模型文件,但不需要创建默认实例。在所有模型加载完毕后,我们从模型列表中的InstanceSettings中搜索模型,创建一个新的实例,并应用配置文件中的设置。
接下来,我们应该列举实例,因为实例索引号并未存储在InstanceSettings中。但由于在创建 YAML 发射器时对miAssimpInstances向量的线性读取以及解析 YAML 文件时对节点的相同线性读取,实例应保持其在保存时的相同索引。
最后,我们从解析器中恢复相机设置、选定的模型、实例以及选择高亮的状态。
到目前为止,配置应该已经完全加载,应用程序应包含与保存操作时相同的模型、实例和设置。
对于加载过程,还有一个问题:如果配置文件解析只部分失败,我们应该怎么办?可能是一个模型文件被重命名或删除,或者文件被截断或损坏,导致最后一个实例的设置不完整。
严格或宽松配置文件加载
一种克服解析错误的方法是在删除应用程序当前所有内容之前丢弃整个配置。这种严格的加载类型易于实现;任何类型的解析错误都会在解析时使配置文件无效。我们忽略加载请求,并向用户显示错误信息。
另一个选项是宽松解析。我们尽力加载有效的模型,并用默认值填充缺失的配置部分,同时也会告知用户配置文件的部分内容无法加载。
在这两种情况下,错误信息应该给出详细的提示,说明解析失败的位置。因此,异常处理可以扩展为确切知道出了什么问题,以及在哪里。对于宽松处理,应尽可能向用户展示受影响模型、实例或设置的相关附加信息。
应用程序的创建者决定哪种策略最适合。通常,应尝试尽可能恢复数据。只丢失创建工作的很小一部分比丢失所有数据要好。
导致文件损坏的常见错误
几个因素可能导致保存的配置文件损坏。以下列出了一些常见原因:
-
在写入文件时磁盘或分区已满至 100%:即使我们今天有大量的存储空间,这也可能发生,您只能保存部分数据。
-
权限问题:有时,您可能有创建文件的权限,但没有写入文件内容的权限。因此,您的文件看起来已经保存,但文件长度为零字节。
-
保存到远程位置时出现的连接错误:在写入较大文件时,您的连接可能会中断,导致文件只部分写入。
-
转换错误,例如通过电子邮件发送文件:邮件程序或邮件服务器可能会以错误的方式转换文件,导致部分损坏的文件,其中一些字符被替换。
-
不兼容的区域设置:保存文件的机器可能使用逗号作为小数分隔符,而您的计算机使用点作为小数分隔符。文件中的数字可能会被误解,甚至在解析失败时被设置为零。这个问题很难找到,并且很容易被忽视。
-
编程错误,如版本处理错误、转换错误或不完整的错误/异常处理:您可能无法保存所有数据,意外地将数据转换为错误的格式,或者错过解析文件中的某些数据。您应该尽可能多地测试代码的文件读取和写入功能,以找到此类错误。
-
您应该意识到,您的保存文件可能在您的机器上或从您那里到那里的路上损坏。因此,经常保存您的作品,使用版本控制系统如 Git 存储文件的不同版本,并定期备份所有配置文件。
现在我们有了保存和加载应用程序状态的代码,我们可以在应用程序启动时提供一个预定义的默认配置。
在启动时加载默认配置文件
为了帮助用户探索新的应用程序,除了广泛的教程外,还可以在第一次启动时或在任何启动时加载使用应用程序创建的内容的简单示例。通过调整可用的选项可以帮助我们了解应用程序的工作方式,以及可能进行的内容操作。
在启动时加载默认配置可以通过不同的方式实现。可以在编译时添加配置文件(嵌入到应用程序中),或者在一个可访问的文件夹中放置一个或多个示例文件,并在启动时加载示例文件。通常,应用程序有一个单独的配置设置,可以用来禁用自动加载示例文件。
例如,我们将在应用程序启动时从加载和保存对话框中加载配置文件config/conf.acfg。多亏了已经实现的 YAML 解析器和文件加载代码,对渲染类所做的更改只需几行代码即可完成。
首先,我们将默认配置文件定义为渲染类的一个新的private成员变量mDefaultConfigFileName:
const std::string mDefaultConfigFileName =
"config/conf.acfg";
通常应避免硬编码文件路径或文件名,但对于第一个配置文件,我们最终陷入了一个鸡生蛋的问题。如果我们想将默认配置的名称存储在另一个配置文件中,而不是在代码中硬编码文件名,我们需要另一个硬编码的文件名。这种启动问题只能通过硬编码第一个值来解决。
然后,在渲染器的init()方法中,我们尝试加载默认配置文件:
if (!loadConfigFile(mDefaultConfigFileName)) {
addNullModelAndInstance();
}
如果找不到文件或加载失败,我们只会创建空模型和空实例。由于所有其他值在第一次启动时都设置为默认值,所以我们最终得到的应用程序与完全没有默认配置的情况相同。
将加载和保存功能实现到应用程序中需要一些研究来确定合适的保存文件类型,并且还需要更多的工作来实现这些功能到现有代码中。所有更改和新功能都应该反映在应用程序的保存文件中,因此需要更多的工作来保持加载和保存代码与应用程序功能的更新同步。通过在配置文件中添加版本控制方案,我们甚至能够从应用程序的不同开发阶段加载配置。
摘要
在本章中,我们添加了将应用程序当前配置保存到文件并重新加载相同配置到应用程序中的功能。首先,我们评估了二进制和文本保存文件的优缺点,并检查了三种常见的文本文件类型,以找到适合我们保存文件的格式。接下来,我们探讨了选择的 YAML 文件格式并实现了保存和加载功能。最后,我们添加了一个默认文件,在应用程序启动时加载,以帮助用户处理应用程序的第一步。
在下一章中,我们将处理应用程序中的自定义相机。目前,我们只使用 内部 相机在虚拟世界中飞行。通过添加自定义相机类型,可以为虚拟世界提供更多的可视化选项。我们将添加一个第三人称风格的相机,类似于动作游戏中的跟随一个实例,以及一个固定相机,它跟随一个实例。此外,还将添加一个简单的相机管理器,相机的配置也将保存在配置文件中。
实践课程
您可以在代码中添加一些内容:
- 添加一个菜单项以创建一个新的空场景。
目前,我们只能加载和保存配置文件。移除所有模型和实例仍然需要手动完成。添加一个菜单项和代码,以便一次性移除所有模型和实例,为用户提供一种简单的方法从头开始。
- 如果更改了设置,添加一个标志和确认对话框。
如果更改了一个模型的设置,设置一个 dirty 标志以记住应用程序用户更改了加载的模型实例或保存的状态。然后,如果用户想要加载另一个配置文件,可以从一个空配置开始,或者退出应用程序并显示一个确认对话框以确保有机会保存当前设置。
- 在标题中添加一个
dirty标记。
几个其他应用程序向用户显示了一些通知,说明自上次保存以来已进行了更改。应用程序窗口的标题会相应调整以显示我们是在编辑模式还是查看模式,因此向窗口标题添加一个星号 (*) 或一些像“未保存”这样的词应该很容易。
其他资源
这里是 yaml-cpp 的 GitHub 仓库:github.com/jbeder/yaml-cpp。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation

第六章:扩展相机处理
欢迎来到第六章!在第五章中,我们添加了保存和加载应用程序配置的功能。首先,我们探讨了数据类型、文件格式以及要保存到文件中的数据。然后,我们实现了一个解析类来以 YAML 文件格式编写和读取配置文件。在本章末尾,所有模型和实例以及全局设置都通过使用yaml-cpp库存储在一个 YAML 文件中,并且所有数据都可以被读取回应用程序,使我们能够继续构建虚拟世界。
在本章中,我们将增强相机配置。作为前两个步骤,我们将扩展应用程序以处理多个相机并添加多种相机类型。然后,我们将实现第一人称和第三人称视图的相机类型,就像在真实游戏中跟随所选实例一样。接下来,我们将添加固定相机,允许以监控风格的视图查看虚拟场景。作为最后一步,我们将添加一个热键来切换相机,以及正交投影和基于鼠标滚轮的视野调整。
在本章中,我们将涵盖以下主题:
-
添加多个相机
-
创建不同类型的相机
-
实现第一人称和第三人称相机
-
添加固定相机
-
在相机和配置之间切换
技术要求
示例代码位于chapter06文件夹中,在01_opengl_cameras子文件夹中为 OpenGL,在02_vulkan_cameras子文件夹中为 Vulkan。
添加多个相机
在第三章中,我们在用户界面中添加了一个按钮,可以跳转到任何实例。但我们仍然以相同的角度和距离选择每个实例,并且返回到地图上不同模型的精彩组合几乎是不可能的。你可能需要写下相机值或截图,但这远远不够完美。
能够向场景中添加几乎无限数量的相机,使我们能够创建令人惊叹的地图和模型组合,并且可以随时返回该视图。通过添加不同类型的相机,我们甚至可以更进一步——一个相机以第三人称追逐一个实例;另一个相机以等距视图展示整个地图;还有一个相机通过实例的虚拟眼睛观察虚拟世界——所有这些都可以通过按热键或选择菜单来实现。
所有这些点将在本章末尾实现。因此,让我们从第一步开始,向应用程序添加多个相机对象。
从单个相机到多个相机的阵列
目前,应用程序中只有一个相机,定义在 tools 文件夹中的 Camera 类中。这个相机提供了一个对虚拟世界的自由视图。我们可以沿所有三个轴移动,并围绕三个轴中的两个轴旋转。在这个阶段,对于模型和动画查看器应用程序来说,围绕指向屏幕内部的轴(翻滚)旋转对于模型和动画查看器应用来说不太有用,因为我们只会看到头部向一侧倾斜的效果。此外,在没有固定参考(如地平线)的情况下,在三维空间中导航相机可能相当困难。因此,我们只实现了上下(仰角)和围绕垂直轴(方位角)的旋转。升级相机旋转和添加鼠标或键盘控制以围绕第三个轴旋转留给你作为练习。
相机位置和两个旋转角度的值存储在 OpenGL 的 OGLRenderData 结构体中,以及 Vulkan 的 VkRenderData 结构体中:
float rdViewAzimuth = 330.0f;
float rdViewElevation = -20.0f;
glm::vec3 rdCameraWorldPosition =
glm::vec3(2.0f, 5.0f, 7.0f);
为了支持多个相机,我们需要一个简单的 std::vector 来存储 Camera 类的元素和一个 int 值,表示当前选中的相机是哪一个。由于这些设置比渲染更接近模型和模型实例,我们将新的相机向量存储在 ModelAndInstanceData 结构体中。为了匹配新的内容,我们将 ModelAndInstanceData 结构体重命名为 ModelInstanceCamData:
struct ModelInstanceCamData {
...
std::vector<std::shared_ptr<Camera>> micCameras{};
int micSelectedCamera = 0;
...
通过使用 IDE 的重构功能,重命名 ModelAndInstanceData 结构体以及类和函数中的变量,只需几鼠标点击和一些文本编辑即可完成。
除了新的结构体名称外,我们还将文件从 ModelAndInstanceData.h 重命名为 ModelInstanceCamData.h,并将文件从 model 文件夹移动到 opengl 文件夹(对于 Vulkan 是 vulkan 文件夹)。最后,将头文件存储在包含渲染器的文件夹中是一个个人偏好的问题,但考虑到我们主要从渲染器访问结构体,这样做是有很多意义的。
在 UserInterface 类中,我们在 ImGui::CollapsingHeader 的定义中添加了一个包含可用相机名称的组合框,命名为 Cameras。组合框的代码可以从模型或动画剪辑选择中提取并调整。
提取相机设置
与实例设置类似,我们将主要相机设置提取到一个单独的结构体中,称为 CameraSettings。包含相机变量的单独结构体使得读取或一次性应用所有相机相关设置变得更加容易,而不是通过设置器和获取器访问所有设置。
CameraSettings 结构体位于 tools 文件夹中的头文件 CameraSettings.h 中:
struct CameraSettings{
std::string csCamName = "Camera";
glm::vec3 csWorldPosition = glm::vec3(0.0f);
float csViewAzimuth = 0.0f;
float csViewElevation = 0.0f
};
在相机名称旁边,我们开始定义世界位置和相机的两个视角:方位角和仰角。
在Camera类中,必须包含新的CameraSettings.h头文件,并添加一个新的私有成员变量mCamSettings。可以移除包含位置、方位角和仰角的三个旧变量。所有访问这三个变量(位置和视图角度)的方法都必须更改为在新的mCamSettings变量中存储和检索值。
我们必须为新的CameraSettings添加一个获取器和设置器方法。获取器和设置器将允许我们像处理模型实例一样处理相机,通过简单的变量赋值来操作相机设置。
调整渲染器
由于渲染器需要更新相机的位置和视图,我们还需要升级一些方法以使用选定的相机。
第一步始终是获取当前相机的指针并读取CameraSettings,以便更容易访问和更改:
std::shared_ptr<Camera> cam =
mModelInstCamData.micCameras.at(
mModelInstCamData.micSelectedCamera);
CameraSettings camSettings = cam->getCameraSettings();
如果我们更改了任何值,我们必须将设置存储回相机:
cam->setCameraSettings(camSettings);
然后,在handleMousePositionEvents()方法中,我们将所有变量从旧的mRenderData变量更改为,如下面的代码所示:
**mRenderData.rdViewAzimuth** += mouseMoveRelX / 10.0;
包含新相机设置的新camSettings变量看起来像这样:
**camSettings.csViewAzimuth** += mouseMoveRelX / 10.0f;
在渲染器的draw()方法中也需要进行类似的更改。
首先,我们从渲染器类中移除私有的mCamera成员变量,因为我们永远不会再次使用单个相机。然后,我们获取相机的指针并读取当前的相机设置。
现在,相机的更新将不再使用旧的mCamera变量:
**mCamera.**updateCamera(mRenderData, deltaTime);
相反,我们通过cam指针更新当前选定的相机:
**cam->**updateCamera(mRenderData, deltaTime);
对于投影矩阵,我们使用新的camSettings变量来读取当前配置的视场。
mProjectionMatrix = glm::perspective(
glm::radians(static_cast<float>(
**camSettings.**csFieldOfView)),
static_cast<float>(mRenderData.rdWidth) /
static_cast<float>(mRenderData.rdHeight),
0.01f, 500.0f);
我们通过访问cam指针来读取更新的视图矩阵:
mViewMatrix = **cam->**getViewMatrix();
最后,在渲染器的centerInstance()方法中,对相机moveCameraTo()方法的调用也需要调整。我们不再使用旧的mCamera变量,如下面的代码所示:
**mCamera.**moveCameraTo(...);
现在,我们直接在micCameras向量中访问当前相机:
**mModelInstCamData.micCameras.****at****(**
**mModelInstCamData.micSelectedCamera)->**moveCameraTo(...);
在这里提取当前相机的指针没有意义,因为这只是在camera实例上的一次单一操作。
将自由相机定义为默认相机
就像空模型和空实例一样,我们应该确保在micCameras向量中始终至少有一个相机。避免空数组可以让我们摆脱很多边界检查,并且始终可用的自由相机在新配置或所有现有相机被移除后是一个很好的特性。
为了简化默认自由相机,将在渲染器类中添加一个名为loadDefaultFreeCam()的新方法:
void OGLRenderer::loadDefaultFreeCam() {
mModelInstCamData.micCameras.clear();
首先,我们清除包含所有相机的向量。然后,我们创建一个新的具有一些默认值的相机设置对象,将设置应用到相机上,并将相机作为第一个实例添加:
std::shared_ptr<Camera> freeCam =
std::make_shared<Camera>();
CameraSettings freeCamSettings{};
freeCamSettings.csCamName = "FreeCam";
freeCamSettings.csWorldPosition = glm::vec3(5.0f);
freeCamSettings.csViewAzimuth = 310.0f;
freeCamSettings.csViewElevation = -15.0f;
freeCam->setCameraSettings(freeCamSettings);
mModelInstCamData.micCameras.emplace_back(freeCam);
mModelInstCamData.micSelectedCamera = 0;
}
您可以根据需要调整默认自由相机的设置。前一个代码片段中显示的设置只是将世界原点居中,使加载的第一个模型出现在屏幕中央。
最后,我们将选定的相机设置为零,即我们新添加的相机的索引。
每当我们需要删除所有相机并添加默认相机(即创建新配置时),我们只需调用 loadDefaultFreeCam() 即可。
对于用户界面,当选择相机实例 0 时,我们应该通过在名称字段周围调用 ImGui::BeginDisabled() 和 ImGui::EndDisabled() 来禁用对默认自由相机名称的更改。
图 6.1 展示了相机部分的最终用户界面:

图 6.1:新的相机设置
在相机之间切换现在就像选择新的基础模型或新的动画剪辑一样简单。除了组合框外,还添加了两个箭头,允许我们直接选择上一个和下一个相机。
对于真正的相机管理,缺少两个函数:创建新相机和删除现有相机(默认相机除外)。
添加和删除相机
要创建相机,可以采取两种路径:
-
在原点添加相机,使用默认值
-
克隆当前选定的相机及其所有设置(除名称外)
强迫用户返回原点并将新相机移动到虚拟世界中的期望位置感觉有些不合适。一个更好的解决方案是能够克隆当前选定的相机,因为用户最可能希望使当前虚拟世界的视图保持不变。
克隆和删除功能都由新的回调函数处理,将检查和所有工作的负担从用户界面转移到渲染器。新的渲染方法,称为 cloneCamera() 和 deleteCamera(),简短且简单,因此这里省略了列表。
然而,处理相机名称需要一些额外的注意。组合框中的重复名称会令人困惑,因此我们必须找到在克隆相机时创建唯一名称的解决方案。创建新名称的一个简单方法是在名称后附加一个数字,并在进一步的克隆中递增该数字。
处理新名称的方法称为 generateUniqueCameraName(),定义在渲染类中。该方法只有一个参数,即相机基础名称。
首先,我们复制基础名称,因为如果相机名称已被使用,我们将在 while 循环中调整名称,并定义一个名为 matches 的字符串,包含从零到九的所有数字:
std::string camName = camBaseName;
std::string matches("01234567890");
while (checkCameraNameUsed(camName)) {
在循环中,我们检查相机名称是否已有一个数字作为后缀。如果没有,我们简单地附加一个 1:
const auto iter = std::find_first_of(camName.begin(),
camName.end(), matches.begin(), matches.end());
if (iter == camName.end()) {
camName.append("1");
} else {
如果我们找到一个数字,那么我们将不带尾随数字的相机名称保存到 cameraNameString 中,将现有的相机编号转换为 int,将数字增加一,将新数字转换回字符串,并将原始相机名称和新数字组合起来:
std::string cameraNameString = camName.substr(0,
std::distance(camName.begin(), iter));
std::string cameraNumString = camName.substr(
std::distance(camName.begin(), iter));
int cameraNumber = std::stoi(cameraNumString);
camName = cameraNameString +
std::to_string(++cameraNumber);
}
}
这样,当我们克隆现有相机时,我们可以创建唯一但仍然可理解的相机名称。
generateUniqueCameraName() 中的 while 循环使用另一个新方法 checkCameraNameUsed()。通过遍历现有相机并比较相机名称与建议的新名称来检查相机名称是否已被使用:
bool OGLRenderer::checkCameraNameUsed(std::string
cameraName) {
for (const auto& cam : mModelInstCamData.micCameras) {
if (cam->getCameraSettings().csCamName == cameraName) {
return true;
}
}
return false;
}
同样的 checkCameraNameUsed() 方法将在用户界面中用于检测重命名相机时的重复名称。与用户界面中的大多数地方一样,使用对渲染器的回调来进行名称检查,将工作移至渲染器类。
在 图 6.2 中,显示了用于克隆和删除相机的新的按钮,以及多次按下 克隆当前相机 按钮的结果:

图 6.2:新的克隆和删除按钮,以及一些新的相机
作为从单一相机过渡的最后一步,我们必须将基于 YAML 的配置文件更改为反映新的相机配置。
调整相机配置的加载和保存
为了加载和保存新的相机设置,我们必须更改 tools 文件夹中的 YamlParser 类中的 YAML 解析和输出。
解码 YAML 相机节点可以通过类似解码 glm::vec3 或 InstanceSettings: 的方式实现,通过在头文件 YamlParserTypes.h 中的新 convert 模板块中添加一个新的 decode 方法:
Template<>
struct convert<CameraSettings> {
static bool decode(const Node& node,
CameraSettings& rhs) {
CameraSettings defaultSettings = CameraSettings{};
rhs.csCamName =
node["camera-name"].as<std::string>();
try {
rhs.csWorldPosition =
node["position"].as<glm::vec3>();
} catch (...) {
rhs.csWorldPosition =
defaultSettings.csWorldPosition;
}
try {
rhs.csViewAzimuth =
node["view-azimuth"].as<float>();
} catch (...) {
rhs.csViewAzimuth = defaultSettings.csViewAzimuth;
}
try {
rhs.csViewElevation =
node["view-elevation"].as<float>();
} catch (...) {
rhs.csViewElevation =
defaultSettings.csViewElevation;
}
在解析节点时处理抛出的异常确保在出现格式错误或缺失键的情况下尽可能恢复相机配置。如果无法解析相机名称,则跳过整个相机以避免命名问题。
我们还添加了 encode 方法以实现模板的完整实现。有了 decode 函数,我们可以通过将 CameraSettings 类型传递给 .as<>() 调用来简单地读取包含相机设置的节点:
... camNode.as<CameraSettings>();
为了将相机配置输出到保存文件,我们创建一个序列并为每个可用的相机添加一个映射:
...
mYamlEmit << YAML::BeginMap;
mYamlEmit << YAML::Key << "cameras";
mYamlEmit << YAML::Value;
mYamlEmit << YAML::BeginSeq;
for (const auto& cam : modInstCamData.micCameras) {
CameraSettings settings = cam->getCameraSettings();
mYamlEmit << YAML::BeginMap;
mYamlEmit << YAML::Key << "camera-name";
mYamlEmit << YAML::Value << settings.csCamName;
...
}
mYamlEmit << YAML::EndSeq;
mYamlEmit << YAML::EndMap;
在保存的 YAML 配置文件中,将为每个相机出现一个新的序列,将所有相机设置作为键/值对存储:
cameras:
- camera-name: FreeCam
position: [2.9061296, 11.1587305, 64.1114578]
view-azimuth: 289.300262
view-elevation: -34.4999695
由于我们对配置文件进行了重要更改,我们应该通过新的版本号来反映新的磁盘格式。
提高配置文件版本
在输出器中调整配置文件版本号很容易;我们只需将版本号字符串从 1.0 提高到类似 1.1 或 2.0 的值。从现在起,所有保存的配置文件都将具有新的版本号。
加载配置文件现在变得更加复杂。如果我们想支持旧文件格式,我们必须保留所有用于解析先前文件版本内容的方法。在读取要解析的文件版本号之后,我们必须决定是否要解析多相机的新的序列风格,或者获取单相机的设置并将这些设置应用到始终可用的默认free相机上。保存配置可以在新版本中完成,从而实现从旧版本到新版本的配置文件迁移。
通过支持读取旧版本来更新配置是实践课程部分的任务之一。
现在我们可以创建一些自由浮动的相机,将相机放置在我们的虚拟世界中,并通过用户界面在相机之间切换。这听起来还是有点无聊,不是吗?让我们通过添加不同的相机类型来增强应用程序。
创建不同的相机类型
在虚拟世界中能够添加多个相机是添加一些预定义相机设置的良好基础。不同的相机类型在视图移动和相机本身的移动方面表现不同。
我们在Enums.h文件中定义了一个名为cameraType的新enum类:
enum class cameraType {
free = 0,
firstPerson,
thirdPerson,
stationary,
stationaryFollowing
};
在CameraSettings结构体中,必须添加一个cameraType类型的新变量:
cameraType csCamType = cameraType::free;
名称已经很好地说明了相机类型的目的。以下是所有类型的相关信息:
-
free相机在之前的代码示例中已经介绍过了。我们可以自由地在五个自由度内移动:三个方向和两个旋转。对于相机或视图,没有任何运动限制。 -
firstPerson相机做的是第一人称游戏中相机的功能:它允许你通过主角的眼睛看到虚拟世界。在我们的应用程序中,我们将相机附着到实例的头部,并像实例一样移动相机和视图。然而,将有一个选项解锁视图,以避免奇怪的视角或运动病。 -
thirdPerson相机像另一个人或无人机一样跟随选定的实例,显示与实例看到相同的虚拟世界角度,但来自实例身体的外部。可以进行一些视图调整,如与实例的距离,但相机的运动和视图都由实例控制。 -
stationary相机可以与固定式监控相机相比较。stationary相机可以放置在世界上的任何位置和任何角度,但一旦相机被锁定,除了视野之外,不可能进行任何移动或视图调整。视野设置保持解锁状态,以便可以进行缩放,就像普通监控相机一样。 -
最后,
stationaryFollowing相机是一种特殊的监控相机。虽然用户无法控制(再次,除视场设置外)移动和视图,但这种类型的相机将自动将选定的实例居中,无论实例是否直接可见。如果实例移动,相机将在虚拟世界中跟踪该实例。
根据相机类型,我们必须在渲染器中限制相机设置的一部分。每次我们无法更改特定的相机设置,如位置时,我们也将禁用相同设置的用户界面控制。
我们将从第一人称和第三人称相机开始,因为这两种相机类型在行为变化和数据检索方面有大量共同点。
实现第一人称和第三人称相机
对于第一人称相机,tools 文件夹中 CameraSettings.h 头文件中的 CameraSettings 结构体需要一些额外的变量:
bool csFirstPersonLockView = true;
int csFirstPersonBoneToFollow = 0;
std::vector<std::string> csFirstPersonBoneNames{};
glm::mat4 csFirstPersonBoneMatrix = glm::mat4(1.0f);
glm::vec3 csFirstPersonOffsets = glm::vec3(0.0f);
新变量 csFirstPersonLockView 用于控制第一人称视角是否跟随实例骨骼的运动,或者放松该限制并启用自由视角,但仍然保持在固定位置。我们需要一个模型骨骼的骨骼来将相机连接到;变量 csFirstPersonBoneToFollow 允许我们设置我们将要跟随的骨骼。在用户界面中,csFirstPersonBoneNames 向量的内容用于显示包含所有骨骼名称的组合框,所选骨骼将被保存在 csFirstPersonBoneToFollow 中。最后一个变量 csFirstPersonOffsets 可以用来稍微移动相机位置,防止相机被放置在实例头部或其他奇怪的位置。
由于我们正在进行骨骼动画,我们需要访问包含相机虚拟连接的骨骼的平移、旋转和缩放矩阵。我们可以通过重新实现第一章中的动画代码来获取矩阵,或者我们可以在第二章的计算着色器完成计算最终矩阵数据后从着色器存储缓冲对象中提取骨骼矩阵。
对于实际应用,你应该使用分析器来检查两种版本的成本。在示例应用中,我们将使用计算着色器结果来避免再次进行相同的动画计算。
关于第一人称模型注意事项
对于许多第一人称风格的游戏,第一人称模型将与同一角色的第三人称模型不同。
有时,头部元素被移除或旋转掉。如果玩家在头部移除以避免干扰视图的同时看到手臂、手和腿,这会产生拥有虚拟身体的感觉。在其他游戏中,身体被完全移除,只绘制部分模型,如从肘部以下的下半臂或从肩部以上的手臂。这种视图给出了简单但仍然足够好的虚拟身体表示,对玩家来说。
在这里,我们为所有相机类型使用相同的模型,仅仅是因为在相机切换时动态交换模型将导致大量的额外开销。如果你对在第一人称视角中使用另一个模型所需的努力感兴趣,你被鼓励将切换模型的逻辑并行添加到相机类型中。
CameraSettings 结构体的另一个新增功能是跟踪实例的弱指针:
std::weak_ptr<AssimpInstance> csInstanceToFollow{};
与撤销/重做设置类似,我们使用 std::weak_ptr 而不是 std::shared_ptr,以避免如果跟踪的实例被移除时出现的问题。
通过添加一个按钮将当前所选实例存储在相机设置的 csInstanceToFollow 变量中,可以在用户界面中捕获实例。但请注意检查空实例,否则你将得到不希望的结果,并且相机将位于原点。
获取第一人称视图的骨骼矩阵
第一人称魔法的第一个部分是我们想要将相机附加到的骨骼的最终 TRS 的检索。在渲染器的 draw() 调用中,紧随第二个计算机着色器之后,我们从 mShaderBoneMatrixBuffer SSBO 中提取骨骼矩阵:
glm::mat4 boneMatrix =
mShaderBoneMatrixBuffer.getSsboDataMat4(
selectedInstance * numberOfBones + selectedBone, 1).at(0);
我们使用重载的 getSsboDataMat4() 方法来读取所选实例所选骨骼位置的单个 glm::mat4。为了能够从 SSBO 读取多个矩阵,getSsboDataMat4() 返回一个 glm::mat4 的 std::vector。但由于我们只需要一个矩阵,我们通过使用 .at(0) 提取向量的第一个矩阵。
相机偏移矩阵通过简单的 glm::translate() 调用计算:
glm::mat4 offsetMatrix = glm::translate(glm::mat4(1.0f),
camSettings.csFirstPersonOffsets);
最后,使用一个稍微有些怪异的调用来计算骨骼的位置:
cam->setBoneMatrix(
mWorldPosMatrices.at(selectedInstance) *
boneMatrix * offsetMatrix *
glm::inverse(modelType.second.at(0)
->getModel()->getBoneList().at(selectedBone)
->getOffsetMatrix()));
通过使用正确的矩阵乘法顺序(根据库的不同,GLM 使用从右到左),执行以下操作:
-
从模型中获取所选骨骼的偏移矩阵并计算偏移矩阵的逆。骨骼的偏移矩阵包含该骨骼在皮肤位置和默认 T-pose 之间的偏移。
-
将骨骼偏移矩阵的逆矩阵与相机位置偏移矩阵相乘,将相机稍微移离骨骼偏移。
-
将最终的骨骼 TRS 矩阵乘以之前骨骼偏移和相机矩阵的乘积,将相机移动到相对于模型根的正确位置。
-
将所选实例的世界位置偏移量乘以,将相机移动到所选实例所选的骨骼。
在计算完骨骼矩阵后,我们需要更新相机并重新上传视图和投影矩阵到 GPU:
cam->updateCamera(mRenderData, deltaTime);
mViewMatrix = cam->getViewMatrix();
std::vector<glm::mat4> matrixData;
matrixData.emplace_back(mViewMatrix);
matrixData.emplace_back(mProjectionMatrix);
mUniformBuffer.uploadUboData(matrixData, 0);
现在,接下来的魔法部分:计算位置、方位角和仰角。
计算第一人称相机参数
要从骨骼的旋转矩阵中获得世界位置和方位角以及仰角,需要一些技巧:
-
首先,我们从矩阵中设置相机位置:
mCamSettings.csWorldPosition = mFirstPersonBoneMatrix[3];
在行主序旋转矩阵中,平移存储在最后一行的前三个列中。通过提取整个行并丢弃第四个值,我们立即得到骨骼的平移。
-
对于仰角视图角度,我们通过将骨骼 TRS 矩阵与向上指的向量相乘来旋转:
glm::vec3 elevationVector = glm::mat3(mFirstPersonBoneMatrix) * mSideVector;
矩阵-向量乘法通过骨骼 TRS 矩阵上存储的角度旋转参考向量 mSideVector。在理论上,我们可以使用任何向量作为参考;在这里,我们使用指向正 Z 轴的单位向量。
结果向量指向在动画计算过程中应用于所选骨骼的方向。
-
接下来,我们计算仰角:
mCamSettings.csViewElevation = glm::degrees(std::atan2(glm::length( glm::cross(elevationVector, mWorldUpVector)), glm::dot(elevationVector, -mWorldUpVector))) - 90.0f
通过使用双元素反正切,我们可以计算两个给定向量之间的角度。在这里,我们使用向上指的 mWorldUpVector 作为第二个参数,因为我们对相对于垂直轴的旋转角度感兴趣。反正切计算结合了两个角度之间的余弦和正弦角度计算。有关公式的详细信息,在 附加资源 部分有一个链接。
将结果角度从弧度转换为度后,我们减去 90 度以获得相对于地平线的角度(mWorldUpVector 指向上方)。与通常用于计算两个向量之间角度的余弦加点积方法相比,这种方法在接近参考向量的角度上数值上更稳定。
仰角限制
注意仰角的一个重要限制:仰角的总范围仅为 180 度(从-90 度到 90 度)。你不能比直接向上看更多,也不能比直接向下看更多。试图调整超出限制的仰角会导致围绕 X 轴旋转 180 度——方位角将进行调整,而仰角仍然在限制范围内。
在这一点上,相机具有与所选骨骼相同的上下角度。
我们可以以类似的方式计算方位角:
-
首先,我们通过将骨骼 TRS 矩阵与向量相乘来旋转一个向量。我们再次使用指向正 Z 轴的向量
mSideVector:glm::vec3 azimuthVector = glm::mat3(mFirstPersonBoneMatrix) * mSideVector; -
然后,我们计算旋转后的向量与另一个单位向量之间的角度:
mCamSettings.csViewAzimuth = glm::degrees( glm::acos( glm::dot( glm::normalize( glm::vec3( azimuthVector.x, 0.0f, azimuthVector.z)), -mSideVector ) ) );
与仰角相比,这里需要两个主要差异:
-
点积不是从整个旋转角度计算的,而是通过将
Y坐标设置为零将其映射到二维。这种映射的原因可能并不明显——当使用所有三个维度时,仰角部分(上下)可能小于方位角部分(围绕垂直轴旋转)并且点积计算了错误的角度。通过消除仰角部分,我们得到正确的结果。 -
我们计算与指向相反方向的向量的点积。这种符号变化是获取方位角正确值所必需的。或者,我们可以取点积结果的相反数以获得相同的效果。
-
作为最后一步,我们检查最初计算的
azimuthVector向量的x分量是否小于零,并相应地调整旋转角度:if (azimuthVector.x < 0.0f) { mCamSettings.csViewAzimuth = 360.0f - mCamSettings.csViewAzimuth; }
通常情况下,我们只从调用反余弦函数 std::acos() 获得介于 0 到 180 度之间的角度。通过考虑结果旋转向量的方向,我们可以修改计算以检索 0 到 360 度之间的完整角度。
如果我们现在从计算出的仰角和方位角值创建视图方向,相机将正好朝向选定的骨骼:
updateCameraView(renderData, deltaTime);
图 6.3 展示了模型进行拾取动画的第一人称视角:

图 6.3:拾取动画期间的第一人称视角
你可以看到实例蹲下并试图从地上取东西,但你看到的是实例的眼睛直接看到的动画,而不是从外部看到的。请注意,相机可能最终位于模型内部某个位置,由于我们只绘制三角形的侧面,这可能导致图形伪影。对于第三人称视角,需要采用不同的方法。
在第三人称视角中移动相机
在第三人称视角中,我们不需要跟随模型的任何骨骼。我们只需要跟随模型的位置。位置可以通过实例的 InstanceSettings 获取。但有一个问题:我们必须将相机的位置设置在实例后面。
首先,我们必须通过使用实例的旋转来计算方位角:
float rotationAngle = 180.0f -
instSettings.isWorldRotation.y;
由于实例和相机角度范围的不同,需要 180 度的偏移。虽然实例的范围是-180 到 180 度,但相机使用的是 0 到 360 度的范围。我们还反转了实例旋转的方向。
接下来,我们通过使用简单的三角学来计算相机与实例的偏移量:
glm::vec3 offset = glm::vec3(
-glm::sin(glm::radians(rotationAngle)),
1.0f,
glm::cos(glm::radians(rotationAngle))
) * mCamSettings.csThirdPersonDistance;
使用正弦和余弦函数可以使相机围绕一个假想单位圆的中心旋转。单位圆的半径为 1,因此使用 1.0f 作为偏移量的 Y 轴会导致与地面相同的高度距离,与假想圆的半径相同。通过使用与 csThirdPersonDistance 变量一致的缩放,我们可以控制相机与实例的距离。并且通过 csThirdPersonHeightOffset 变量,我们可以单独调整相机的高度。
接下来,我们添加单独的相机高度偏移,并将相机的世界位置设置为实例世界位置加上偏移:
offset.y += mCamSettings.csThirdPersonHeightOffset;
mCamSettings.csWorldPosition =
instSettings.isWorldPosition + offset;
现在,相机始终放置在实例后面,我们可以控制视图距离和额外的相机高度偏移。
对于视图的仰角,我们再次使用点积,这次是在从相机到实例的向量和向上指的 mWorldUpVector 之间:
glm::vec3 viewDirection =
instSettings.isWorldPosition -
mCamSettings.csWorldPosition;
mCamSettings.csViewElevation = (90.0f -
glm::degrees(
glm::acos(
glm::dot(
glm::normalize(viewDirection), mWorldUpVector
)
)
)) / 2.0f;
因此,我们得到 mWorldUpVector 和视图方向之间角度的一半。相机略微向下指向实例的世界位置,但仍然向上足够,可以越过实例的肩膀。
图 6.4 展示了所选模型的第三人称视图:

图 6.4:第三人称视图
除了计算相机位置和视图角度之外,我们还应该禁用基于用户的相机移动。忽略控制相机的请求比在每一帧中明显重置相机变化给用户更好的反馈。
禁用手动相机移动
忽略更改相机角度的请求可以通过简单的添加来实现。我们只需要获取相机设置并围绕方块,在 handleMouseButtonEvents() 函数中将 mMouseLock 变量替换为以下条件:
if (!(camSettings.csCamType ==
cameraType::thirdPerson ||
(camSettings.csCamType == cameraType::firstPerson &&
camSettings.csFirstPersonLockView)) &&
camSettings.csInstanceToFollow.lock()) {
...
这个复杂条件可以分解为以下规则:只有当以下条件都满足时,才允许锁定鼠标并手动移动相机:
-
相机类型不是第一人称或第三人称。
-
对于第一人称相机,视图是解锁的。
-
对于两种相机类型,都设置了要跟随的目标实例。
这意味着相机可以在自由相机类型上自由移动,只要没有实例被设置为第一人称和第三人称的目标,并且第一人称相机的视图没有被设置为锁定。
条件行可能看起来有点吓人,但通过使用相反的逻辑元素来创建相同的条件,以避免初始感叹号否定,可以创建出类似复杂性的布尔表达式。因此,我们可以高兴地坚持这个表达式。
由于我们没有实现 翻滚 旋转,即指向屏幕轴的旋转,这两个新相机可能会出现意外的行为。
当前第一人称和第三人称相机的限制
如果您在一个第一人称摄像头中围绕 X 和/或 Z 轴旋转当前锁定实例,视图将疯狂地旋转,只跟随所选骨骼的位置而不是旋转。这种行为可以通过向摄像头添加第三个旋转并在指向屏幕的轴上进行翻滚来修复。在添加第六个自由度后,所有计算都必须调整以包括新的角度。
此外,第三人称摄像头可能被放置在旋转角度不同的模型上,或者可能需要交换 Z 和 Y 轴的模型上。
虽然添加最后一个旋转是可能的,但在应用程序中的使用仅限于第一人称视图等案例,因为您可以通过反转翻滚旋转并旋转地图而不是模型来获得相同的结果。对于 错误 旋转的第三人称模型,可能需要额外的固定旋转复选框或更多的偏移旋转。
鼓励您扩展当前的代码库,并添加符合本节所述边缘情况的实例设置。
在直接添加与实例位置相关的摄像头后,让我们继续添加固定摄像头。
添加固定摄像头
固定摄像头可以在几种情况下使用。您制作了一个看起来很棒的字符模型和道具的构图。或者,您想查看您加载的整个游戏地图的俯视图,并看到每个角色的位置。也许您甚至想跟随在地图上四处游荡的某个实例。固定摄像头是一个完美的选择。
对于纯固定摄像头,配置很简单。只需将摄像头类型添加到渲染器的 handleMouseButtonEvents() 方法中 mMouseLock 布尔值的激活状态:
... && camSettings.csCamType != cameraType::stationary ...
在 Camera 类的 updateCamera() 方法中,在检查零 deltaTime 之后添加以下行:
if (mCamSettings.csCamType == cameraType::stationary) {
return;
}
就这样!一旦您选择了一个固定摄像头,鼠标的右键点击将被忽略,并且摄像头也将永远不会接收任何更新。
如果一个固定摄像头应该跟随一个实例,则需要更多的代码。
创建固定跟随摄像头
创建固定实例跟随摄像头的第一步与纯固定摄像头相同——将摄像头类型添加到渲染器类的 handleMouseButtonEvents() 方法中:
if (!((camSettings.csCamType ==
cameraType::stationaryFollowing
由于我们只想在已配置要跟随的实例时禁用摄像头手动移动,因此我们必须将摄像头类型添加到第一人称和第三人称类型检查的括号中。
对于摄像头位置和视图的更新,将创建一个结合第一人称和第三人称算法的混合。您将识别出来自上一节中的代码示例。
首先,我们获取实例并从摄像头到实例位置计算视图方向:
std::shared_ptr<AssimpInstance> instance =
mCamSettings.csInstanceToFollow.lock();
glm::vec3 viewDirection = instance->getWorldPosition() -
mCamSettings.csWorldPosition;
接下来,我们将视图的仰角设置为视图方向与向上指向的 mWorldUpVector 之间角度的余弦值:
mCamSettings.csViewElevation = 90.0f -
glm::degrees(
glm::acos(
glm::dot(
glm::normalize(viewDirection), mWorldUpVector
)
)
);
在第一人称和第三人称相机中,使用视图方向与向上指向的向量的点积已被非常相似地使用。
然后,我们将相同的视图方向向量映射到二维,并计算展开的视图方向向量与指向负 z 轴的向量之间的角度:
float rotateAngle =
glm::degrees(
glm::acos(
glm::dot(
glm::normalize(
glm::vec3(viewDirection.x, 0.0f, viewDirection.z)),
glm::vec3(0.0f, 0.0f, -1.0f)
)
)
);
最后,我们将方位角扩展到完整的 360 度:
if (viewDirection.x < 0.0f) {
rotateAngle = 360.0f – rotateAngle;
}
mCamSettings.csViewAzimuth = rotateAngle
2D 映射向量、点积以及将方位角扩展到 360 度而不是仅点积的 180 度,对于第一人称相机来说完全相同。
当我们现在使用stationaryFollowing类型为相机并选择一个实例作为目标时,相机视图将自动跟随实例,无论我们将其移动到何处。但手动移动相机和相机视图是被禁止的。
处理新的相机类型已经工作得很好,但仍有改进的空间。所以,让我们给应用程序添加一些更多功能。
在相机和配置之间切换
为了在micCameras向量中更快地在可用的相机之间移动,键盘快捷键很有用。
配置相机选择的快捷键
我们要做的只是向渲染器的handleKeyEvents()方法中添加一小段代码来添加键盘快捷键:
if (glfwGetKey(mRenderData.rdWindow,
GLFW_KEY_LEFT_BRACKET) == GLFW_PRESS) {
if (mModelInstCamData.micSelectedCamera > 0) {
mModelInstCamData.micSelectedCamera--;
}
}
if (glfwGetKey(mRenderData.rdWindow,
GLFW_KEY_RIGHT_BRACKET) == GLFW_PRESS) {
if (mModelInstCamData.micSelectedCamera <
mModelInstCamData.micCameras.size() - 1) {
mModelInstCamData.micSelectedCamera++;
}
}
the number keys *1* to *9*).
我们也没有将相机更改限制在编辑模式中,以允许在视图模式之间切换相机。
另一个酷炫的添加是正交投影。虽然透视投影试图模仿远离相机的物体尺寸缩小,但正交投影将保留物体的尺寸。
添加正交投影
你可能会在图 6.5中认出与图片相似的游戏:

图 6.5:正交投影的小型关卡
这个版本的《半条命》地图由用户 pancakesbassoondonut 在 Sketchfab 上创建。该地图可在skfb.ly/6ACOx找到,并授权于 Creative Commons Attribution 许可 CC BY 4.0 (creativecommons.org/licenses/by/4.0/)。
早期的游戏仅出于美学原因使用了正交投影。在不缩放纹理或改变面角的情况下,创建看起来令人惊叹的游戏所需的计算能力非常有限。
为了支持正交投影,我们在Enums.h文件中创建了一个名为cameraProjection的新enum类:
enum class cameraProjection {
perspective = 0,
orthogonal
};
在CameraSettings结构体中,我们添加了两个新变量,分别命名为csCamProjection和csOrthoScale:
cameraProjection csCamProjection =
cameraProjection::perspective;
float csOrthoScale = 20.0f;
虽然csCamProjection用于在透视和正交投影之间切换,但csOrthoScale变量将定义正交投影的缩放级别样式设置,类似于透视投影的视野设置。
当前创建投影矩阵的代码将移动到对新投影设置的检查中:
if (camSettings.csCamProjection ==
cameraProjection::perspective) {
...
} else {
如果选择了正交投影,我们将使用glm::ortho()而不是glm::perspective()来创建投影矩阵。首先,我们读取csOrthoScale并使用该值来缩放纵横比以及左右和远近平面的距离:
float orthoScaling = camSettings.csOrthoScale;
float aspect = static_cast<float>(mRenderData.rdWidth)/
static_cast<float>(mRenderData.rdHeight) *
orthoScaling;
float leftRight = 1.0f * orthoScaling;
float nearFar = 75.0f * orthoScaling;
glm::ortho()的调用创建正交投影矩阵,将虚拟世界的原点移动到屏幕的原点:
mProjectionMatrix = glm::ortho(-aspect, aspect,
-leftRight, leftRight, -nearFar, nearFar);
通过使用虚拟世界的中心作为投影矩阵的中心,我们在屏幕上得到了一个很好地缩放的结果。由于视图矩阵中的相机位置,我们甚至能够在正交绘制的虚拟世界中移动相机。
这里的唯一限制是向屏幕内部移动:当左右或上下移动相机时,视图会按预期调整,但向前或向后移动最初不会显示任何变化。这种对 z 轴方向相机移动的缺失反应是由正交投影的基本原理造成的。我们没有像透视投影那样的视锥体:投影矩阵创建了一个巨大的矩形盒,盒子内的每个三角形都会被绘制。
当相机到达一个点,其中一些三角形位于矩形盒子的远 z 平面之后时,我们需要将相机远离虚拟世界的原点移动。你可以自己尝试到达这样的点,但这将花费很多时间,即使使用更快的相机移动。
尽管正交投影看起来很酷,但请注意在这种情况下深度感知可能会很棘手。我们的大脑已经学会了,如果一个物体离我们越远,它的尺寸就会减小,并且具有相同视尺寸但距离不同的物体在现实生活中并不具有相同的尺寸。由于屏幕上所有实例的尺寸都相同,你可能会完全猜错哪些实例更靠近相机。
投影设置的用户界面控制
为了控制将使用哪种投影,我们在UserInterface类中添加了两个单选按钮。第一个单选按钮用于激活透视投影:
ImGui::Text("Projection: ");
ImGui::SameLine();
if (ImGui::RadioButton("Perspective",
settings.csCamProjection ==
cameraProjection::perspective)) {
settings.csCamProjection =
cameraProjection::perspective;
}
第二个单选按钮激活正交投影:
ImGui::SameLine();
if (ImGui::RadioButton("Orthogonal",
settings.csCamProjection ==
cameraProjection::orthogonal)) {
settings.csCamProjection =
cameraProjection::orthogonal;
}
在视场范围滑块下方,将创建一个用于正交缩放的滑块:
ImGui::Text("Ortho Scaling: ");
ImGui::SameLine();
ImGui::SliderFloat("##CamOrthoScale",
&settings.csOrthoScale, 1.0f, 50.0f, "%.3f", flags);
复选框和滑块也被用于检查相机类型或冲突设置。禁用甚至隐藏可能会让用户困惑的控制比试图解释为什么更改设置不会得到预期的结果更好。
通过拥有多个相机和扩展的相机处理功能,探索虚拟世界和模型实例得到了提升。我们现在可以在世界的不同区域放置相机,并通过使用第一人称或第三人称相机,可以像在真实游戏中一样查看实例和实例动画。
关于相机和大型模型的注意事项
当处理大型模型或具有高缩放因子的模型时,由于模型的一些部分位于场景深度的近裁剪面和远裁剪面之外,不同类型的摄像机可能会出现裁剪问题。靠近摄像机位置的模型部分看起来有洞,而远离屏幕的模型部分可能会消失。在这些情况下,你需要调整模型的缩放或渲染器中投影矩阵的近裁剪面和远裁剪面的配置。
摘要
在本章中,实现了新的摄像机功能。首先,我们扩展了当前的摄像机处理以支持多个摄像机对象。接下来,定义并实现了几种摄像机类型,如第一人称和第三人称摄像机。最后,为处理摄像机添加了一些方便的功能。通过使用键盘快捷键在定义的摄像机列表之间切换,有助于简化访问,而正交投影为查看实例创造了有趣的结果。
在下一章中,我们将通过增强实例动画来为我们的虚拟世界增添更多活力。通过向实例的不同内部状态添加动画,如行走、跑步或跳跃,我们能够在虚拟世界中移动实例。并且通过在这些状态之间混合动画,不同状态之间的过渡将变得平滑。
实践课程
你可以在代码中添加一些内容:
- 扩展 YAML 加载器以迁移配置文件。
如在提升配置文件版本部分所述,添加功能以加载配置文件的旧版和新版。保存文件可以使用最新版本。
- 将振荡添加到静止的摄像机中。
就像现实生活中的监控摄像头一样,它可以有额外的能力自动左右移动,或者上下移动。可能的控制是移动的范围和速度。
- 在第三人称摄像机周围添加垂直轴的旋转。
除了只能从后面查看实例之外,添加另一个包含围绕垂直轴旋转的属性。通过将摄像机旋转 90 度,你可以创建一个三维侧滚动游戏,而通过将摄像机旋转 180 度,实例在行走或跑步时会面向你。
- 将翻滚旋转添加到摄像机中。
目前,摄像机仅支持仰角(向上和向下看)和方位角(围绕垂直轴旋转)。实现第三个旋转轴,以便摄像机可以移动到任何任意角度。
- 扩展第三人称摄像机以使其更具动作感。
你可以尝试为第三人称相机添加更多功能。比如,一个安装在弹簧上的相机,仅松散地跟随实例的运动?或者当向前移动时稍微放大一些,跟随旋转时有一定的延迟,甚至在向侧面走时稍微倾斜?或者你可以在碰撞时添加一些震动,比如实例撞到障碍物。此外,为这些不同类型设置预设也会很方便。
- 将相机实现改为四元数。
在上一个任务中添加第三维度很可能会导致旋转,从而产生万向节锁,因此再次失去一个自由度,因为一个轴旋转或接近另一个轴。将整个相机旋转改为使用四元数而不是欧拉角应该可以解决万向节锁问题。
- 添加基于样条的相机和目标路径。
要创建飞越场景,你可以添加相机位置的样条曲线,包括可配置的速度和乒乓运动。通过将样条作为目标,你可以创建虚拟世界的相机镜头。
- 在编辑模式下将相机显示为单独的对象。
与用于模型操作的箭头类似,你可以添加一个视锥体和一个小盒子来描绘虚拟世界中相机的位置和方向。不同的相机类型可以通过不同的线条颜色来显示。
- 使用鼠标使相机符号可选中。
在完成上一个任务后,你还可以将相机符号添加到选择纹理中。只需为相机保留一个专用的值范围,将盒子和/或视锥体线条绘制到选择纹理中,并根据从图形卡缓冲区返回的值在实例和相机之间切换。
- 高级难度:添加画中画窗口。
大多数游戏引擎都有在应用程序屏幕的小窗口中显示另一个相机最小化版本的能力。创建单独的相机显示需要向渲染器类添加一些低级功能,因为整个场景必须使用两个相机的视图和投影矩阵绘制两次。
ImGui 有能力将纹理绘制到屏幕上 - 你可以将第二个相机的视图渲染到纹理中,并在 ImGui 窗口中显示图像。将相机名称和类型等信息添加到窗口标题中可以进一步增强画中画模式。
为了避免在两种不同的相机类型都激活时产生混淆,将基于类型的限制仅限于编辑模式可能是一个好主意。
其他资源
-
3D 图形和游戏开发数学入门:
gamemath.com/book/ -
如何计算三维空间中两个向量的角度:
www.quora.com/How-do-I-calculate-the-angle-between-two-vectors-in-3D-space-using-atan2
第三部分
调整角色动画
在本部分,你将深入探索角色动画的奥秘。你将从计算动画帧的查找表并将结果存储在 GPU 内存中开始,这样你就可以在不首先上传到 GPU 的情况下访问任何动画帧。你还将学习如何在动画剪辑之间进行混合以创建更好的过渡。然后,你将了解实例之间的碰撞检测,并基于轴对齐的边界框和球体添加碰撞检测和反应。你还将探索图形化、基于节点的模型编辑器扩展,通过添加和连接预定义的执行块来控制实例的行为。最后,你将学习面部动画和加性混合的工作原理,并将这两种新的动画类型实现到动画编辑器中。
本部分包含以下章节:
-
第七章,增强动画控制
-
第八章,碰撞检测简介
-
第九章,添加行为和交互
-
第十章,高级动画混合
第七章:提升动画控制
欢迎来到第七章!在前一章中,我们添加了一些相机功能。我们首先实现了对多个相机对象的支持,并添加了新的相机类型。我们还设置了键盘快捷键,以便简单地选择现有的相机。作为最后一步,我们添加了一个正交相机配置,这使得我们能够创建模型实例和虚拟世界的完全不同的视图。
在本章中,我们将更新动画混合和控制到一个新的水平。首先,我们将在现有的变换计算着色器中实现两个动画之间的混合。此外,我们将每个节点的平移、缩放和旋转的计算移动到查找表上,并在 GPU 上执行。接下来,我们将向代码中添加新的实例状态,存储实例可能执行的动作类型,如行走、跑步和跳跃,以及移动方向。然后,我们将创建用户界面控件,使我们能够将现有的动画剪辑映射到实例动作。最后,我们将添加动画和动作之间的映射逻辑。
在本章中,我们将涵盖以下主题:
-
以风格混合动画
-
向代码中添加新状态
-
链接状态和动画
-
保存和加载状态
技术要求
在chapter07文件夹中的01_opengl_animations文件夹和02_vulkan_animations文件夹中的示例代码。
以风格混合动画
如果你解决了第二章中“实践环节”部分的第二个和第三个任务,并将动画混合的部分转移到 GPU 上,那么本节的部分内容可能对你来说很熟悉。但如果你跳过了这些任务,也不要担心,因为将变换数据移动到查找表并在 GPU 上进行插值计算的过程非常直接。
让我们从查找表数据开始。
查找表的力量
目前,动画关键帧和相应的节点变换的数据在模型加载时提取,并且所有数据都存储在AssimpAnimChannel对象内部的数组中。
对于每一帧中的每个节点,需要六次查找来提取前一个和当前关键帧时间的平移、缩放和旋转。然后,这些值成对插值以计算指定节点的最终变换。
然而,重复进行相同的计算是耗时的。更好的解决方案是在模型加载时生成所有插值变换,并且在播放动画时只进行最终变换值的查找。
这里的权衡很明显:GPU 内存与 CPU 计算能力。将查找表的大小设置得太小会导致可见的伪影,而将查找表数据的大小设置得太大则会浪费宝贵的 GPU 内存,而没有任何视觉上的好处。您可以尝试查找表的大小,但对于变换值来说,大约 1,000 个元素应该在视觉效果和内存大小之间达到良好的平衡。
一种替代的查找解决方案
通过为关键帧时间创建一个表并使用提取的数据作为原始变换数据的索引,可以创建一个更节省内存的查找表版本。如果您有很多节点和动画片段需要节省 GPU 内存,可以使用这种变体。或者,如果您每个动画只有少数关键帧,也可以使用这些稀疏的查找。然而,这种版本的缺点是需要再次计算每个关键帧之间的成对插值,这会增加 GPU 的计算负担。
除了新的变换数据存储之外,我们还将所有动画片段的时间长度进行缩放,使其具有相同的时间长度。无需缩放两个动画片段的长度,两个片段之间的混合变得更加简单。
创建查找表
作为查找表的准备,我们必须找到所有动画片段的最大长度。在将动画片段添加到AssimpModel类的loadModel()方法之前,我们遍历所有动画并将最大长度存储在一个新的private成员变量mMaxClipDuration中:
unsigned int numAnims = scene->mNumAnimations;
for (unsigned int i = 0; i < numAnims; ++i) {
const auto& animation = scene->Animations[i];
mMaxClipDuration =
std::max(mMaxClipDuration,
static_cast<float>(animation->mDuration));
}
然后,最大值被用作addChannels()调用的附加参数:
for (unsigned int i = 0; i < numAnims; ++i) {
...
animClip->addChannels(animation, mMaxClipDuration,
mBoneList);
...
}
最后,在addChannels()方法内部,我们将最大持续时间传递给我们要提取的每个通道:
for (unsigned int i = 0; i < animation->mNumChannels; ++i) {
...
channel->loadChannelData(animation->Channels[i],
maxClipDuration);
...
}
创建查找表数据本身将通过以下代码片段展示,以创建平移数据作为示例。对于缩放和旋转,同样适用。
每个查找表的第一步是提取最小和最大关键帧时间:
mMinTranslateTime =
static_cast<float>(nodeAnim->mPositionKeys[0].mTime);
mMaxTranslateTime =
static_cast<float>(
nodeAnim->mPositionKeys[mNumTranslations - 1].mTime);
然后,我们计算三个缩放因子:
float translateScaleFactor = maxClipDuration /
mMaxTranslateTime;
mTranslateTimeScaleFactor = maxClipDuration /
static_cast<float>(LOOKUP_TABLE_WIDTH);
mInvTranslateTimeScaleFactor = 1.0f /
mTranslateTimeScaleFactor;
这些包括:
-
第一个变量
translateScaleFactor存储最大片段持续时间与最大关键帧时间的比率。在查找表数据创建中前进时间时,我们需要第一个缩放因子。 -
在
mTranslateTimeScaleFactor中,我们计算最大片段持续时间与我们的查找表大小的比率。第二个缩放因子是查找表条目关键帧时间步宽度的简单值。 -
作为最后一个缩放因子,
mInvTranslateTimeScaleFactor存储了mTranslateTimeScaleFactor值的倒数。我们将使用计算着色器中的第三个缩放因子来根据关键帧时间计算查找表中的正确索引位置。
接下来,我们将一个名为timeIndex的辅助变量设置为0,并遍历我们的查找表条目:
int timeIndex = 0;
for (int i = 0; i < LOOKUP_TABLE_WIDTH; ++i) {
对于每个查找表条目,我们从 aNodeAnim 对象的 mPositionKeys 数组中提取当前和下一个关键帧时间的平移数据到一个 glm::vec4:
glm::vec4 currentTranslate = glm::vec4(
nodeAnim->mPositionKeys[timeIndex].mValue.x,
nodeAnim->mPositionKeys[timeIndex].mValue.y,
nodeAnim->mPositionKeys[timeIndex].mValue.z, 1.0f);
glm::vec4 nextTranslate = glm::vec4(
nodeAnim->mPositionKeys[timeIndex + 1].mValue.x,
nodeAnim->mPositionKeys[timeIndex + 1].mValue.y,
nodeAnim->mPositionKeys[timeIndex + 1].mValue.z,
1.0f);
尽管我们只需要平移的前三个值,但使用四元素向量是为了在着色器存储缓冲对象中正确对齐数据。
现在,我们提取当前和下一个关键帧的时间值,以及动画的当前时间:
float currentKey = static_cast<float>(
nodeAnim->mPositionKeys[timeIndex].mTime);
float nextKey = static_cast<float>(
nodeAnim->mPositionKeys[timeIndex + 1].mTime);
float currentTime = i * mTranslateTimeScaleFactor /
translateScaleFactor;
对于当前时间,使用两个缩放因子。
通过使用两个平移向量和时间值,我们可以在查找表条目的时间戳处创建两个平移的插值 glm::vec4:
mTranslations.emplace_back(glm::mix(
currentTranslate,
nextTranslate,
(currentTime - currentKey) / (nextKey - currentKey)));
最后,我们检查查找表条目的当前时间是否长于下一个关键帧的时间。如果是,我们增加我们的时间索引:
if (currentTime > nextKey) {
if (timeIndex < mNumTranslations - 1) {
++timeIndex;
}
}
}
mTranslations 向量现在包含了动画剪辑每个时间点的插值平移值,步长由 mTranslateTimeScaleFactor 定义,通过使用倒数值 mInvTranslateTimeScaleFactor,如果我们知道剪辑的回放时间,我们可以访问相应的查找表条目。
将数据表上传到 GPU
一旦所有动画剪辑都转换为查找表,我们就可以将数组数据上传到 SSBO。缓冲区准备部分较长,因为我们必须确保所有节点都正确初始化,即使是非动画节点。在这里,我们只探讨平移步骤,因为缩放和旋转的逻辑大部分是相同的。最大的不同是,已经知道使用四元素向量来传输旋转的四元数数据到计算着色器。
作为第一步,我们创建一个 std::vector 的 glm::vec4 来存储所有节点变换的数据:
std::vector<glm::vec4> animLookupData{};
然后,我们定义查找表数据的大小:
const int LOOKUP_SIZE = 1023 + 1;
在 1023 和 1 而不是数字 1024 的添加是一个暗示接下来会发生什么:
std::vector<glm::vec4> emptyTranslateVector(
LOOKUP_SIZE, glm::vec4(0.0f));
emptyTranslateVector.at(0) = glm::vec4(0.0f);
我们创建一个长度为 LOOKUP_SIZE 的空向量,并用一个零值的四元素向量初始化该向量。使用零值进行平移确保非动画节点不会有平移变换。
在向量的第一个位置,我们为了文档目的再次显式地将值设置为零,因为我们将在每个查找表的第一个位置的 x 分量中存储 mTranslateTimeScaleFactor。在每个向量中存储倒数缩放因子可能看起来有点冗余,但因为我们直接将值集成到查找数据中,计算着色器将在一个地方找到所有数据。
在创建适当的空向量用于缩放和旋转之后,我们为骨骼列表中的每个骨骼创建一个平移、旋转和缩放的组合:
for (int i = 0; i < mBoneList.size() *
mAnimClips.at(0)->getNumChannels(); ++i) {
animLookupData.insert(animLookupData.end(),
emptyTranslateVector.begin(),
emptyTranslateVector.end());
animLookupData.insert(animLookupData.end(),
emptyRotateVector.begin(),
emptyRotateVector.end());
animLookupData.insert(animLookupData.end(),
emptyScaleVector.begin(),
emptyScaleVector.end());
}
通过使用数组中的全部骨骼数量,我们可能会浪费几个千字节,但不需要在计算着色器内部添加额外的逻辑来选择动画和非动画逻辑。
现在,我们遍历所有动画剪辑,并对每个剪辑的所有通道进行遍历。由于我们已使用默认值初始化了所有数据,我们只需上传动画骨骼的数据:
int boneId = channel->getBoneId();
if (boneId >= 0) {
offset值是通过使用骨骼列表大小和LOOKUP_SIZE来找到当前动画剪辑中通道骨骼的平移数据位置来计算的:
int offset = clipId * mBoneList.size() *
LOOKUP_SIZE * 3 + boneId * LOOKUP_SIZE * 3;
接下来,我们设置第一个位置x组件的通道的mTranslateTimeScaleFactor值,获取通道的平移数据,并将数据复制到查找数据向量中:
animLookupData.at(offset) =
glm::vec4(channel->getInvTranslationScaling(),
0.0f, 0.0f, 0.0f);
const std::vector<glm::vec4>& translations =
channel->getTranslationData();
std::copy(translations.begin(),
translations.end(),
animLookupData.begin() + offset + 1);
然后,将offset值推进到下一个查找数据位置,在存储下一个变换数据之前:
offset += LOOKUP_SIZE;
在将所有平移、缩放和旋转数据存储在animLookupData向量之后,我们可以将数据上传到 SSBO:
mAnimLookupBuffer.uploadSsboData(animLookupData);
现在,加载的模型的动画查找数据已可在 GPU 上使用。当我们需要在计算着色器中访问变换数据时,我们可以简单地绑定 SSBO。
调整渲染器代码和计算着色器
为了能够告诉计算着色器播放哪个动画以及/或混合,我们在opengl文件夹中的OGLRenderData.h文件中定义了一个新的struct,称为PerInstanceAnimData,用于 OpenGL:
struct PerInstanceAnimData {
uint32_t firstAnimClipNum;
uint32_t secondAnimClipNum;
float firstClipReplayTimestamp;
float secondClipReplayTimestamp;
float blendFactor;
};
对于 Vulkan,文件名为VkRenderData.h,位于vulkan文件夹中。
在这里,我们简单地存储第一个剪辑编号加上我们想要从第一个剪辑渲染的当前帧的时间戳。此外,还可以发送可能的第二个动画剪辑、第二个剪辑的时间戳以及两个剪辑之间的混合因子到计算着色器。
在渲染器中,我们定义了两个新的private数据成员:
std::vector<PerInstanceAnimData> mPerInstanceAnimData{};
ShaderStorageBuffer mPerInstanceAnimDataBuffer{};
mPerInstanceAnimData变量存储每个实例的剪辑编号、时间戳和混合因子,而mPerInstanceAnimDataBuffer是动画数据 SSBO 的 CPU 端句柄。
然后,在渲染器的draw()调用中的实例循环中,我们使用实例的值更新每个实例的动画数据:
PerInstanceAnimData animData{};
animData.firstAnimClipNum =
instSettings.isFirstAnimClipNr;
animData.secondAnimClipNum =
instSettings.isSecondAnimClipNr;
animData.firstClipReplayTimestamp =
instSettings.isFirstClipAnimPlayTimePos;
animData.secondClipReplayTimestamp =
instSettings.isSecondClipAnimPlayTimePos;
animData.blendFactor =
instSettings.isAnimBlendFactor;
mPerInstanceAnimData.at(i) = animData;
当准备第一个计算着色器时,我们绑定动画查找数据并上传实例动画数据:
mAssimpTransformComputeShader.use();
mUploadToUBOTimer.start();
modelType.second.at(0->getModel()
->bindAnimLookupBuffer(0);
mPerInstanceAnimDataBuffer.uploadSsboData(
mPerInstanceAnimData, 1);
mShaderTRSMatrixBuffer.bind(2);
现在,我们为计算着色器所需的所有数据都已准备好进行计算。
在assimp_instance_transform.comp计算着色器中,在shader文件夹中,我们还需要定义PerInstanceAnimData struct以能够访问 SSBO:
struct PerInstanceAnimData {
uint firstAnimClipNum;
uint secondAnimClipNum;
float firstClipReplayTimestamp;
float secondClipReplayTimestamp;
float blendFactor;
};
并且我们使用与渲染器代码中相同的绑定点声明两个缓冲区绑定:
layout (std430, binding = 0) readonly restrict
buffer AnimLookup {
vec4 lookupData[];
};
layout (std430, binding = 1) readonly restrict
buffer InstanceAnimData {
PerInstanceAnimData instAnimData[];
};
在计算着色器的main()方法中,我们定义与AssimpModel类中相同的查找表大小和偏移量计算:
**int** **lookupWidth =** **1023** **+** **1****;**
uint node = gl_GlobalInvocationID.x;
uint instance = gl_GlobalInvocationID.y;
uint numberOfBones = gl_NumWorkGroups.x;
**uint** **boneOffset = lookupWidth *** **3**;
**uint** **clipOffset = numberOfBones * boneOffset**;
现在,我们可以通过使用instance变量作为InstanceAnimData SSBO 中的索引来访问每个实例的所有动画设置:
uint firstClip = instAnimData[instance].firstAnimClipNum;
uint secondClip =
instAnimData[instance].secondAnimClipNum;
float blendFactor = instAnimData[instance].blendFactor;
例如,要获取平移数据的mTranslateTimeScaleFactor值,我们必须使用与 C++中相同的公式来访问剪辑的平移查找数据的第一个元素:
float firstTransInvScaleFactor = lookupData[firstClip *
clipOffset + node * boneOffset].x;
即使 C++和着色器公式或数据类型之间有细微的差异,也可能导致传输数据的不一致,因此我们需要在这里非常严格,确保执行完全相同的操作并使用相同的数据类型。
然后,我们使用逆时间缩放因子来计算在平移查找数据中的正确缩放索引:
int firstTransLookupIndex =
int(instAnimData[instance].firstClipReplayTimestamp *
transInvScaleFactor) + 1;
第一和第二个动画剪辑的每个节点的平移数据也可以像在 C++中那样计算:
vec4 firstTranslation = lookupData[firstClip *
clipOffset + node * boneOffset +
firstTransLookupIndex];
...
vec4 secondTanslation = lookupData[secondClip *
clipOffset + node * boneOffset +
secondTransLookupIndex];
现在,我们可以在两个动画剪辑的平移之间进行插值:
vec4 finalTranslation = mix(firstTanslation,
secondTanslation, blendFactor);
我们对缩放值进行相同的查找和插值。对于旋转,使用与 GLM 实现中 SLERP 相同的代码。
最后,所有三个变换矩阵的乘积存储在包含所有实例结果的 TRS 矩阵的 SSBO 中:
uint index = node + numberOfBones * instance;
trsMat[index] =
createTranslationMatrix(finalTranslation) *
createRotationMatrix(finalRotation) *
createScaleMatrix(finalScale);
到目前为止,渲染器中的mShaderTRSMatrixBuffer SSBO 包含与我们使用基于 CPU 的变换计算时相同的数据。
但我们只需要上传到计算着色器的就是我们想要绘制的动画数据。因此,AssimpInstance类的updateAnimation()方法中的变换计算可以移除,留下以下三条在更新实例动画时执行的代码:
mInstanceSettings.isFirstClipAnimPlayTimePos +=
deltaTime *
mInstanceSettings.isAnimSpeedFactor * 1000.0f;
mInstanceSettings.isAnimPlayTimePos =
std::fmod(mInstanceSettings.isFirstClipAnimPlayTimePos,
mAssimpMode->getMaxClipDuration());
mModelRootMatrix = mLocalTransformMatrix *
mAssimpModel->getRootTranformationMatrix();
我们简单地通过时间增量来推进剪辑时间,并更新实例根矩阵。
如果你现在编译并运行代码,唯一可见的差异是当实例数量增加时矩阵生成时间的显著降低。对于最新的计算机模型,达到 10,000 个甚至 20,000 个基本模型的动画实例应该没有问题。在虚拟世界中我们不需要这么多实例,但动画和动画混合的 CPU 使用率降低,这让我们在本书剩余章节中实现更多功能有了更多的自由度。
现在我们有了快速且易于使用的动画计算,我们可以实现一个基于状态的系统来组织每个实例的行为。通过定义不同的状态,如移动方向或实例将要执行的动作,我们迈出了通往可以像在简单游戏中一样控制实例的动画系统的第一步。
因此,让我们继续添加新的实例状态。
在代码中添加新状态
我们未来的游戏角色应该能够执行游戏角色典型的动作:等待玩家输入,向四个主要方向行走,向前奔跑,以及许多其他动作。根据可用的动画,我们可以添加跳跃或翻滚、出拳或挥手的动作状态。
为了获得最大的灵活性,我们将允许所有模型的角色执行所有配置的动作。然后我们可以使用 UI 将动画剪辑映射到我们想要为特定模型使用的每个动作。如果没有映射的动画剪辑,请求的动作将被简单地忽略。
使用位字段和平面枚举
我们将在Enums.h文件中添加两个不同的enum class定义。第一个名为moveDirection的enum是一个位字段:
enum class moveDirection : uint8_t {
none = 0x00,
forward = 0x01,
back = 0x02,
right = 0x04,
left = 0x08,
any = 0xff
};
对于每个方向,可以在一个变量中设置一个不同的位。当多个值可以同时出现时,需要位字段。在我们的情况下,角色同时向前和向左跑是正常的。
两个额外的enum值none和any是特殊的占位符。如果角色只是闲置,强制为闲置状态设置方向会显得很奇怪,因为角色不能“向前闲置”或“向左闲置”。因此,为完全不移动设置单独的enum值none将有助于使代码更简单。值any可以用作通配符或回退值,用于行走状态。例如,我们可以为所有方向设置一个通用的行走动画,而不是为所有四个方向配置相同的动画剪辑,我们使用any方向来使用这个剪辑的所有行走动作。
为了能够在真正的位字段方式下使用moveDirection enum的值,我们必须为新的数据类型定义位运算符OR和AND。声明是简短且简单的,如下面的代码所示,这是两个移动方向之间的逻辑AND运算符:
inline moveDirection operator | (moveDirection lhs,
moveDirection rhs) {
using T = std::underlying_type_t <moveDirection>;
return static_cast<moveDirection>(static_cast<T>(lhs) |
static_cast<T>(rhs));
}
另一个名为moveState的enum class负责处理可能的角色动作:
enum class moveState {
idle = 0,
walk,
run,
hop,
jump,
...
wave,
NUM
};
在这里,不需要位字段。我们可能能够同时run和jump,这些情况将在代码中处理。但大多数动作不能同时执行。
我们简单地列出所有可能的移动状态在moveState enum class中。最终的值NUM可以被用来在for循环中迭代所有的enum值,即从第一个动作(值为零的idle)开始,到最后一个有效的动作wave结束。
除了数字之外,对于移动方向和状态,在 UI 和调试消息中将变得很有用,所以我们向ModelInstanceCamData.h文件中的ModelInstanceCamData结构体添加了两个从enum class值到字符串的新映射:
std::unordered_map<moveDirection, std::string>
micMoveDirectionMap{};
std::unordered_map<moveState, std::string>
micMoveStateMap{};
我们将在渲染器的init()方法中填充两个状态映射,使用适当的字符串值。接下来,我们必须扩展实例和模型的设置结构体。
扩展模型和实例设置
闲置、行走和跑步动画剪辑的特殊处理需要一个名为IdleWalkRunBlending的新struct,位于model文件夹中的新ModelSettings.h文件中:
struct IdleWalkRunBlending {
int iwrbIdleClipNr = 0;
float iwrbIdleClipSpeed = 1.0f;
int iwrbWalkClipNr = 0;
float iwrbWalkClipSpeed = 1.0f;
int iwrbRunClipNr = 0;
float iwrbRunClipSpeed = 1.0f;
};
在这里,我们简单地存储三个动作的剪辑编号和回放速度。
新的IdleWalkRunBlending结构体将被添加到另一个新的struct中,称为ModelSettings,以及模型名称和文件名:
struct ModelSettings {
std::string msModelFilenamePath;
std::string msModelFilename;
std::map<moveDirection, IdleWalkRunBlending>
msIWRBlendings;
};
AssimpModel类需要一个新类型的private数据成员ModelSettings:
ModelSettings mModelSettings;
模型的文件名和名称将从AssimpModel类重新定位到新的ModelSettings struct,以便可以通过简单的 getter 和 setter 调用访问模型的“变量”部分,类似于实例。
在我们可以在实例中使用新的状态和动画功能之前,我们必须将两个新的enum class类型变量添加到model文件夹中InstanceSettings.h文件中的InstanceSettings struct中:
moveDirection isMoveDirection = moveDirection::none;
moveState isMoveState = moveState::idle;
此外,我们还必须调整动画剪辑的实例设置。由于我们可以有两个不同的动画加上动画混合,我们需要两个剪辑变量而不是一个,以及作为每个实例设置的混合因子:
unsigned int isFirstAnimClipNr = 0;
unsigned int isSecondAnimClipNr = 0;
float isAnimBlendFactor = 0.0f;
我们还存储实例的速度和加速度值:
glm::vec3 isAccel = glm::vec3(0.0f);
glm::vec3 isSpeed = glm::vec3(0.0f);
特定实例的速度将用于选择正确的空闲、行走或跑步动画剪辑。通过使用基于加速度的运动,我们实现了角色实例更自然的外观。在现实生活中,我们在移动时也会加速和减速,而不是直接从空闲状态跳到跑步速度。
添加空闲/行走/跑步逻辑
空闲/行走/跑步动画剪辑的混合逻辑定义在AssimpInstance类中名为playIdleWalkRunAnimation()的方法中。
在对实例模型进行合理性检查之后,我们计算实例的绝对速度,读取包含混合设置的映射模型设置,并创建一个新的、空的IdleWalkRunBlending类型变量blend:
float instanceSpeed =
glm::length(mInstanceSettings.isSpeed);
ModelSettings modSettings =
mAssimpMode->getModelSettings();
IdleWalkRunBlending blend;
接下来,我们检查是否已配置方向特定的动画剪辑:
if (modSettings.msIWRBlendings.count(
mInstanceSettings.isMoveDirection) > 0 ) {
blend = modSettings.msIWRBlendings[
mInstanceSettings.isMoveDirection];
} else if (modSettings.msIWRBlendings.count(
moveDirection::any) > 0) {
blend = modSettings.msIWRBlendings[moveDirection::any];
} else if (modSettings.msIWRBlendings.count(
moveDirection::none) > 0) {
blend = modSettings.msIWRBlendings[moveDirection::none];
} else {
return;
}
如果找到了这样的方向剪辑,混合变量将填充适当的设置。如果没有找到特定方向剪辑,我们也检查特殊的any和none方向,尝试为所有方向使用通用动画。如果我们找不到通用动画剪辑,我们就从方法中返回。如果没有配置空闲/行走/跑步的剪辑,播放任何动画都没有意义。
通过使用instanceSpeed变量来控制是否在空闲和行走之间或行走和跑步动画之间进行混合:
if (instanceSpeed <= 1.0f) {
mInstanceSettings.isFirstAnimClipNr =
blend.iwrbIdleClipNr;
mInstanceSettings.isSecondAnimClipNr =
blend.iwrbWalkClipNr;
mInstanceSettings.isAnimSpeedFactor = glm::mix(
blend.iwrbIdleClipSpeed,
blend.iwrbWalkClipSpeed, instanceSpeed);
mInstanceSettings.isAnimBlendFactor = instanceSpeed;
} else {
在这里,我们根据instanceSpeed值缩放速度因子,并在空闲和行走动画之间进行混合。通过使用空闲的0.0f值和完整行走速度的1.0f(包括)值,动画将在实例静止不动和四处走动之间平滑混合。
要混合从行走到跑步的实例动画,我们使用一个instanceSpeed范围在1.0f(不包括)和2.0f之间。逻辑保持不变;我们只需从速度因子和行走与跑步剪辑之间的线性插值混合中减去1.0f:
mInstanceSettings.isFirstAnimClipNr =
blend.iwrbWalkClipNr;
mInstanceSettings.isSecondAnimClipNr =
blend.iwrbRunClipNr;
mInstanceSettings.isAnimSpeedFactor = glm::mix(
blend.iwrbWalkClipSpeed,
blend.iwrbRunClipSpeed, instanceSpeed – 1.0f);
mInstanceSettings.isAnimBlendFactor =
instanceSpeed – 1.0f;
}
选择行走速度的1.0f和跑步速度的2.0f的instanceSpeed值是因为它们在剪辑之间的线性插值中效果最佳。任何其他范围都是可能的;你只需相应地调整动画混合的缩放即可。
使用加速度和减速度
由于我们的实例应在虚拟世界中移动,我们需要控制我们所控制的实例的速度。但不是直接使用速度,我们将采用基于加速度的速度模型。使用单独的加速度值来加速或减速实例可以产生更自然的结果。像真实的物理物体一样,我们的虚拟世界中的模型实例具有惯性,这种惯性会抵抗速度变化。
首先,AssimpInstance 类需要三个新的 private float 变量,分别命名为 MAX_ACCEL、MAX_ABS_SPEED 和 MIN_STOP_SPEED:
const float MAX_ACCEL = 4.0f;
const float MAX_ABS_SPEED = 1.0f;
const float MIN_STOP_SPEED = 0.01f;
在 MAX_ACCEL 中,我们存储模型可以达到的最大加速度,而 MAX_ABS_SPEED 限制了实例的速度。加速度和速度是三分量向量,向不同方向移动可能会产生更大的值。限制这两个值有助于防止实例在虚拟世界中移动得太快。
基于加速度的模型有一个主要缺点:停止实例可能变得困难。因为我们只从速度中添加和减去加速度值,达到精确的零值是困难的。为了实现完全停止,我们在 MIN_STOP_SPEED 变量中定义一个最小速度。如果实例的当前速度低于 MIN_STOP_SPEED 的值,我们将加速度和速度设置为零,最终停止实例。
实例加速度由 AssimpInstance 类的 updateInstanceState() 方法控制。基本上,我们检查移动方向并设置 isAccel 的相应 x 或 z 分量:
if (state == moveState::walk || state == moveState::run) {
if ((dir & moveDirection::forward) ==
moveDirection::forward) {
mInstanceSettings.isMoveKeyPressed = true;
mInstanceSettings.isAccel.x = 5.0f;
}
...
如果按下方向键,我们将 isMoveKeyPressed 设置为 true 并应用加速度。
当我们释放所有移动键时,实例的减速开始。减速逻辑发生在 AssimpInstance 类的 updateInstanceSpeed() 方法中:
float currentSpeed =
glm::length(mInstanceSettings.isSpeed);
static float maxSpeed = MAX_ABS_SPEED;
我们还本地保存实例的最大速度。使用 static 变量有助于在步行和奔跑速度之间跟踪加速和减速实例。
首先,我们计算三分量 isSpeed 向量的长度。然后,使用这个结果长度来检查在未按下任何移动键时我们是否仍在移动:
if (!mInstanceSettings.isMoveKeyPressed) {
if (currentSpeed > 0.0f) {
if (mInstanceSettings.isSpeed.x > 0.0f) {
mInstanceSettings.isAccel.x = -2.5f;
}
...
对于实例的可能移动方向,我们检查是否还有剩余速度,并使用与速度相反方向的加速度来减速实例。
如果我们的速度低于 MIN_STOP_SPEED,我们将强制将速度和加速度设置为零,移动状态设置为 idle,移动方向设置为 none:
if (currentSpeed < MIN_STOP_SPEED) {
currentSpeed = 0.0f;
mInstanceSettings.isAccel = glm::vec3(0.0f);
mInstanceSettings.isSpeed = glm::vec3(0.0f);
mInstanceSettings.isMoveState = moveState::idle;
mInstanceSettings.isMoveDirection =
moveDirection::none;
}
}
限制速度和加速度到最大值是一个两步过程。如果我们超过最大值,我们将向量归一化:
float currentAccel =
glm::length(mInstanceSettings.isAccel);
if (currentAccel > MAX_ACCEL) {
mInstanceSettings.isAccel =
glm::normalize(mInstanceSettings.isAccel);
现在 isAccel 向量的长度等于 1.0f。然后,我们将向量缩放到最大长度:
mInstanceSettings.isAccel *= MAX_ACCEL;
在乘法之后,isAccel 的所有三个分量都缩放到结果长度 MAX_ACCEL。
更新实例速度是通过将 isAccel 中的加速度的 deltaTime 分数加到 isSpeed 上来完成的:
mInstanceSettings.isSpeed +=
mInstanceSettings.isAccel * deltaTime;
要达到跑步速度,我们将maxSpeed值加倍:
if (mInstanceSettings.isMoveState == moveState::run) {
maxSpeed = MAX_ABS_SPEED * 2.0f;
}
如果我们不再跑步但超过maxSpeed,我们将减速到行走速度:
if (currentSpeed > maxSpeed) {
if (mInstanceSettings.isMoveState != moveState::run) {
maxSpeed -=
glm::length(mInstanceSettings.isAccel) * deltaTime;
if (maxSpeed <= MAX_ABS_SPEED) {
maxSpeed = MAX_ABS_SPEED;
}
}
示例代码使用简单的线性插值来达到所需的实例速度。您可能想尝试其他技术,如缓动/非缓动曲线或三次曲线,以调整不同实例速度之间的插值。
如果我们在加速到行走或跑步速度时比maxSpeed更快,我们将实例速度isSpeed限制为maxSpeed:
mInstanceSettings.isSpeed =
glm::normalize(mInstanceSettings.isSpeed);
mInstanceSettings.isSpeed *= maxSpeed;
到目前为止,当前选定的实例将从空闲动画片段平滑地移动到行走动画片段,甚至如果我们切换到run状态,还可以加速到跑步动画。遗憾的是,我们目前什么也看不到,因为没有在运动状态、方向和动画片段之间建立映射。
因此,让我们将动画片段链接到现有状态。
链接状态和动画
我们从三个状态(空闲、行走和跑步)之间的连接开始,并将相应的模型动画片段映射到这三个状态。IdleWalkRunBlending struct和ModelSettings struct中的msIWRBlendings成员变量已经就位,所以我们只需要注意 UI。
空闲/行走/跑步动画映射
图 7.1 展示了控制窗口中三个状态(空闲、行走和跑步)的映射部分:

图 7.1:移动方向、移动状态和动画片段之间的映射
我们将从上到下逐步浏览控制元素,并探索UserInterface类中的createSettingsWindow()方法中的新代码:
-
方向组合框显示了
moveDirectionenumclass的可用条目名称。组合框是通过使用ModelInstanceCamDatastruct中的micMoveDirectionMap映射来填充的。我们将选中的条目存储在一个staticmoveDirection类型变量中,以保留该值。 -
对于名为空闲、行走和跑步的三个组合框,我们从实例模型中提取动画片段,并将每个状态选中的片段存储在一个
staticint中。回放速度滑块允许我们为每个片段设置单独的回放速度。我们曾在(现已删除的)控制窗口的动画部分有相同的组合框/浮点滑块组合。 -
要存储当前的方向、片段和速度组合,请按保存按钮。
UserInterface代码只是简单地创建或更新所选方向的msIWRBlendings条目。 -
在组合框下方,列出了所有已保存的映射。如果您按其中一个条目的编辑按钮,相应的行将被加载到组合框和速度滑块中。通过按删除,条目将被删除。
-
要启用当前映射的预览,请勾选启用预览复选框。在测试模式下,活动映射设置用于动画当前选定的实例并在三种状态之间混合。一旦找到了映射的良好设置,请确保禁用预览模式。
-
在测试滑块上方,显示了三种状态(空闲/行走/跑步)的三个动画剪辑的名称。如果您在任一组合框中选择不同的动画剪辑,您可以看到名称的变化。测试滑块允许您在模型部分启用测试模式时预览三个选定剪辑之间的动画混合。将滑块向左移动播放空闲动画剪辑;如果滑块位于中间位置,则显示行走动画剪辑;在右侧,播放跑步动画。在这三个滑块位置之间,生成并绘制空闲和行走之间以及行走和跑步之间的线性插值。
UserInterface代码中的一个有趣新增是使用ImGui::PushID()和ImGui::PopID()自动为每个映射创建按钮 ID:
ImGui::PushID(buttonId++);
if (ImGui::Button("Edit##Blending")) {
...
}
ImGui::PopID();
ImGui 需要为每个控件元素提供一个唯一的标识符。未能提供唯一 ID 会导致不希望的结果,因为触发一个控件元素也会触发包含相同 ID 的其他元素。
通过使用递增的整数值作为标识符,每个映射行都将有唯一的编辑和删除按钮,并且按钮只会影响它们的映射行。
另一个 ImGui 的不错特性是能够从代码的另一部分关闭CollapsingHeader元素:
ImGui::GetStateStorage()->SetInt(
ImGui::GetID("Model Animation Mappings"), 0);
ImGui 的内部状态存储保存了当前 ImGui 窗口中元素的信息。通过SetInt()调用和CollapsingHeader名称,我们可以控制当前 ImGui 窗口中任何标题的打开/关闭状态。
这样,我们强制关闭了其他两个映射CollapsingHeader元素。我们在所有三个映射标题中更改动画剪辑设置,ImGui 多次应用这些设置,导致不希望的结果。
在设置完所有空闲/行走/跑步动画剪辑后,我们继续处理动作。
将动作映射到动画剪辑
在moveState enum class中,我们在实例旁边定义了几个动作,包括已经配置的idle、walk和run状态。根据模型文件中的动画,并非所有动作都适用于所有模型。但无需担心:代码会忽略未配置动画的动作。按下动作键将不会产生动画或动作。
所有配置的动作映射都保存到ModelSettings.h文件中的ModelSettings struct的新映射msActionClipMappings中:
std::map<moveState, ActionAnimation>
msActionClipMappings;
新的ActionAnimation struct包含剪辑编号和重放速度:
struct ActionAnimation {
int aaClipNr = 0;
float aaClipSpeed = 1.0f;
};
我们将再次从 UI 部分开始。在图 7.2中,展示了动作映射的CollapsingHeader:

图 7.2:将运动状态映射到动画剪辑和速度
对于这种映射类型,UserInterface代码与空闲/行走/跑步映射类似,因为我们实际上执行的是同种类型的映射。我们有以下元素:
-
用于
moveStateenumclass元素的组合框,填充了micMoveStateMap映射的值 -
第二个用于剪辑的组合框,由模型的动画剪辑生成
-
一个定义剪辑重放速度的速度滑块
-
一个保存按钮,用于将当前映射添加到
msActionClipMappings映射中 -
一个保存的映射列表,并且每个映射行都有两个按钮来编辑或删除当前行
空闲/行走/跑步映射的说明也适用于此处。当模型空闲/行走/跑步混合部分中存在none移动方向的映射时,当前选定的剪辑不会播放。要预览动画剪辑,您需要暂时移除none方向。
要在空闲/行走/跑步状态之间以及AssimpInstance类中的任何动作之间进行混合,我们可以使用msActionClipMappings映射中的剪辑编号和重放速度,并将剪辑用作混合操作的目标剪辑:
mInstanceSettings.isSecondAnimClipNr =
modSettings.msActionClipMappings[mActionMoveState]
.aaClipNr;
float animSpeed =
modSettings.msActionClipMappings[mActionMoveState]
.aaClipSpeed;
如果我们想让我们的模型改变到一个动作,我们调用AssimpInstance类的setNextInstanceState()方法。渲染器在handleMovementKeys()方法中使用此调用来请求所选实例的状态变化:
moveState nextState = moveState::idle;
..
if (glfwGetKey(mRenderData.rdWindow, GLFW_KEY_E) ==
GLFW_PRESS) {
nextState = moveState::punch;
}
...
currentInstance->setNextInstanceState(nextState);
在我们深入研究动画混合逻辑之前,让我们通过查看如何设置允许的剪辑序列来完成 UI 部分。
定义允许的状态变化
在预览模型的不同的动画剪辑时,您会注意到有些剪辑只有在实例静止时才有效,而其他动画只有在实例行走或跑步时才可用。
为了防止剪辑之间出现不希望和不自然的过渡,例如,从空闲模型到全速跳跃动画,我们定义哪些动作状态变化是允许的。目标状态只能在源状态是当前活动状态时触发。
图 7.3 展示了一些允许的状态变化:

图 7.3:模型的允许状态变化
状态顺序被保存在使用包含std::pair元素的moveState条目的std::set的ModelSettings struct中:
std::set<std::pair<moveState, moveState>>
msAllowedStateOrder;
一个std::map在这里不起作用,因为我们需要为相同的源状态配置多个目标状态。
在所有状态都映射到动画剪辑并且配置了状态之间的依赖关系之后,我们将探讨模型动作如何连接起来以给出(主要是)平滑的混合和过渡效果。
使用有限状态机来控制动画流程
维护状态随时间的变化以及事件发生后从一个状态到另一个状态的转换,最佳方法是使用有限状态机。在 C++中,这样的状态机可以用简单的switch/case语句来建模。
在开始实现之前,让我们看看图 7.4中状态机的状态和转换:

图 7.4:AssimpInstance类中mAnimState变量的状态机
这五个状态以及状态之间的转换可以解释如下:
-
我们从
playIdleWalkRun状态机开始。在这里,默认的、基于实例速度的移动状态(空闲、行走和奔跑)之间的混合被播放。 -
一旦请求了动作,我们检查源(当前状态)和目标(动作状态)是否在
msAllowedStateOrder映射中。如果是,我们准备方向剪辑并切换到transitionFromIdleWalkRun状态。 -
transitionFromIdleWalkRun状态是必要的,用于平滑地从空闲/行走/奔跑到请求的动作的过渡。大多数动画剪辑都是从身体、手臂和腿的类似姿势开始的。通过将当前的空闲、行走或奔跑动画与相同动画剪辑的起始点混合,实例被调整回其起始姿势。达到起始姿势后,我们进入transitionToAction状态。 -
在
transitionToAction中,我们从空闲、行走或奔跑动画剪辑的初始姿势混合到动作状态动画剪辑。这种转换只有几帧长,并为动作剪辑添加了平滑的混合。一旦动作剪辑的转换完成,我们就切换到playActionAnim。 -
一旦我们处于
playActionAnim状态,请求的动作状态动画将在配置的重放速度下播放,直到剪辑结束。当动作动画剪辑播放时,键盘请求切换到其他动画剪辑或返回到空闲/行走/奔跑循环将被忽略。一旦动作动画剪辑完成且没有发出新的动作请求,状态将更改为transitionToIdleWalkRun。
与transitionToAction类似,transitionToIdleWalkRun状态用于在动作剪辑和空闲/行走/奔跑剪辑之间混合。目标剪辑(空闲、行走或奔跑)通过实例速度选择。为了实现平滑的动画循环,身体、手臂和腿的位置与剪辑的开始和结束相同。通过将动作剪辑的结束与空闲/行走/奔跑剪辑的开始混合,我们实现了回到初始状态的平滑过渡。混合完成后,我们将状态改回playIdleWalkRun。
对于动画剪辑的过渡,我们在Enums.h文件中创建了一个新的enum struct,名为animationState,其中包含有限状态机的状态:
enum class animationState : uint8_t {
playIdleWalkRun = 0,
transitionFromIdleWalkRun,
transitionToAction,
playActionAnim,
transitionToIdleWalkRun
};
状态机在AssimpInstance类的updateAimStateMachine()方法中定义。状态是通过在图 7.4中概述的条件上的 switch/case 语句构建的:
switch (mAnimState) {
case animationState::playIdleWalkRun:
playIdleWalkRunAnimation();
...
break;
case animationState::transitionFromIdleWalkRun:
blendIdleWalkRunAnimation(deltaTime);
break;
case animationState::transitionToAction:
blendActionAnimation(deltaTime);
break;
case animationState::playActionAnim:
playActionAnimation();
...
break;
case animationState::transitionToIdleWalkRun:
blendActionAnimation(deltaTime, true);
break;
}
你可以在AssimpInstance类的updateAnimStateMachine()方法中查看有限状态机的详细信息。代码简单直接:我们只是重放并混合各种动画片段。
在放置了混合代码和有限状态机之后,你可以通过按F10键切换到应用程序的视图模式。如果你在允许的源状态下按下一个动作键,动作片段将被播放。在动作片段之后,实例将回到之前的空闲/行走/跑步状态。
为了完成本章的功能,我们需要通过将ModelSettings struct的内容和所有相关自定义数据类型添加到配置文件中,使状态和剪辑映射永久化。
保存和加载状态
当保存和加载自定义数据类型的当前状态时,我们可以重用之前章节中创建的一些保存和加载代码。moveState和moveDirection enum class类型将被存储为整数值在应用程序的 YAML 配置文件中。三个新的struct类型,IdleWalkRunBlending、ActionAnimation和ModelSettings,在保存到配置文件时被分解为其元素,在读取值时重新组装。C++的 map 和 set 是通过使用已知的 YAML 序列和 YAML 映射的组合来创建的。
存储新的数据类型
对于moveState和moveDirection struct,我们在tools文件夹中的YamlParserTypes.h文件中创建了一个新的convert模板,它将简单地转换为int并返回:
template<>
struct convert<moveState> {
static Node encode(const moveState& rhs) {
Node node;
node = static_cast<int>(rhs);
return node;
}
static bool decode(const Node& node, moveState& rhs) {
rhs = static_cast<moveState>(node.as<int>());
return true;
}
};
tools文件夹中的YamlParser.cpp文件中moveState和moveDirection的 YAML 发射器重载也将值转换为int:
YAML::Emitter& operator<<(YAML::Emitter& out,
const moveState& state) {
out << YAML::Flow;
out << static_cast<int>(state);
return out;
}
现在当我们在生成或解析 YAML 配置文件时,我们可以像处理其他类型一样读取和写入新的数据类型。
两种struct类型,IdleWalkRunBlending和ActionAnimation,只使用 int 和 float 值,所以这里的任务是找到好的 YAML 节点名称。例如,ActionAnimation值在 YAML 文件中存储为clip和clip-speed:
static bool decode(const Node& node, ActionAnimation& rhs) {
rhs.aaClipNr = node["clip"].as<int>();
rhs.aaClipSpeed = node["clip-speed"].as<float>();
return true;
}
保存ModelSettings struct的内容也是简单的。我们只是输出所有值,并使用for循环遍历msIWRBlendings和msActionClipMappings映射以及msAllowedStateOrder集合:
...
if (settings.msActionClipMappings.size() > 0) {
out << YAML::Key << "action-clips";
out << YAML::Value;
out << YAML::BeginSeq;;
for (auto& setting : settings.msActionClipMappings) {
out << YAML::BeginMap;
out << YAML::Key << setting.first;
out << YAML::Value << setting.second;
out << YAML::EndMap;
}
out << YAML::EndSeq;
}
...
在YamlParser类的getModelConfigs()方法中读取ModelSettings。getModelConfigs()方法中的 YAML 解析与InstanceSettings或CameraSettings struct的解析相同。通过使用convert模板,我们只需指示yaml-cpp在填充临时的modeSettings向量时使用正确的数据类型:
modSettings.emplace_back(modelsNode[i].
as<ModelSettings>());
解析 C++的映射和集合有点棘手。我们需要遍历包含序列的节点,将每个条目作为std::map获取,并将映射添加到ModelSettings类型的相应映射中:
if (Node clipNode = node["idle-walk-run-clips"]) {
for (size_t i = 0; i < clipNode.size(); ++i) {
std::map<moveDirection, IdleWalkRunBlending> entry =
clipNode[i].as<std::map<moveDirection,
IdleWalkRunBlending>>();
rhs.msIWRBlendings.insert(entry.begin(),
entry.end());
}
}
在渲染器中回读模型设置
保存模型设置完全由 YAML 发射器完成;不需要对渲染器的saveConfigFile()方法进行任何更改。要在loadConfigFile()方法中将模型设置恢复到应用程序中,我们使用 YAML 解析器的getModelConfigs()方法:
std::vector<ModelSettings> savedModelSettings =
parser.getModelConfigs();
然后,我们遍历向量的内容,并尝试使用在加载的模型设置中找到的文件名和路径添加模型:
for (const auto& modSetting : savedModelSettings) {
if (!addModel(modSetting.msModelFilenamePath,
false, false)) {
return false;
}
如果模型无法加载,我们停止加载过程。如果我们希望在出错时对文件处理更加宽松,我们可以存储加载失败的模型,跳过失败模型的实例,并在对话框中通知用户发生了错误。
接下来,我们再次通过文件名获取模型,并恢复模型设置:
std::shared_ptr<AssimpModel> model =
getModel(modSetting.msModelFilenamePath);
model->setModelSettings(modSetting);
最后,我们恢复保存配置时选择的模型:
mModelInstCamData.micSelectedModel =
parser.getSelectedModelNum();
现在,所有动画剪辑映射和状态序列都已恢复。应用程序用户可以在任何时候暂停创建虚拟世界而不会丢失进度。
通过本章的添加,我们可以使屏幕上的实例栩栩如生。根据模型文件中可用的动画,实例不仅可以行走和奔跑,还可以执行额外的动作,如跳跃、向前冲、滚动、击打目标、挥手或与环境交互。这些动画还会在不同的运动和动作之间混合,营造出类似游戏的感觉。
摘要
在本章中,我们提高了动画处理效率,并添加了类似游戏玩法控制,将动画剪辑映射到当前动画状态。我们首先将动画混合的计算工作从 CPU 移动到 GPU,并创建了查找表以减少 GPU 的工作量(以内存使用为代价)。然后,我们将不同的运动状态添加到代码中,包括基于 UI 的状态与动画剪辑之间的映射。最后一步,我们将新的映射添加到 YAML 配置文件中,使我们能够保存和恢复映射。
在下一章中,我们将更详细地探讨碰撞检测。在我们能够在虚拟世界中移动实例之后,我们需要避免只是穿过屏幕上的其他实例。作为第一步,我们将探讨碰撞检测的理论背景,并讨论简单实现中存在的问题。然后,我们将空间分区添加到虚拟世界中,并将实例简化以减少碰撞检测检查的复杂性。最后,我们将实现多级碰撞检测,以最小成本检测实例之间的碰撞。
实践环节
你可以在代码中添加一些内容:
- 使用根运动来控制实例的移动。
与在虚拟世界中移动实例的位置并以特定速率播放动画不同,所谓的根运动可以用来让动画片段控制角色的移动。在根运动中,模型的根骨骼的运动是由动画协调的,而不是由我们控制,这允许地面上的脚步有更好的同步。然而,根运动数据必须烘焙到模型动画中才能工作。更新动画超出了本书的范围。
- 添加空闲/行走/跑步映射的第二个方向。
目前,只能配置四个主要方向的动画(向前、向后、向左、向右)以及特殊的通配符none和any。当脚步没有与实例移动同步时,以对角线移动可能会显得有些笨拙。
- 添加对角运动的动画混合。
与上一个任务类似,而不是添加多个方向,当实例以对角方向移动时,你可以尝试在正向/反向和左/右动画片段之间进行混合。
- 添加对状态顺序配置的预览。
为了预览从源状态到目标状态的过渡,有限状态机需要通过某种测试模式进行调整。源状态必须设置并播放一段时间,即直到动画片段重新开始。然后,必须触发到目标状态的过渡。此外,我们需要禁止实例移动;所有过渡都必须在不改变实例世界位置的情况下发生。
- 允许动画片段以反向方向播放。
女性模型有一个包含坐下动画的片段。为了使实例再次站立,你可以添加一个布尔值来播放片段的正向或反向方向。
- 添加更多速度插值函数,并使它们可选择。
如同在使用加速和减速章节中提到的,不同速度之间的插值是通过简单的线性插值完成的。尝试在此处添加更多高级的插值变体,例如基于曲线的独立加速和减速函数。你还可以在模型设置中添加一个额外的字段来存储插值函数,并在 UI 中添加额外的字段来选择要使用的插值。
- 使用图形工具绘制节点和连接。
ImGuizmo(见附加资源章节中的链接)的图形编辑器向 ImGui 添加节点和连接。你可以更改映射配置,以便有状态和动画片段的节点,并使用连接来定义映射。
附加资源
ImGuizmo GitHub 仓库:github.com/CedricGuillemet/ImGuizmo
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation

第八章:碰撞检测简介
欢迎来到第八章!在前一章中,我们扩展了实例动画系统。我们首先为动画转换添加了查找表,并将计算移动到 GPU 上。接下来,我们向应用程序中添加了运动状态和 UI 控件,以创建状态和动画片段之间的映射。最后一步,我们更新了 YAML 解析器以保存和恢复动画片段映射。
在本章中,我们将为实例实现双层碰撞检测。我们将从探索碰撞检测的复杂性以及如何通过根据实例的距离移除实例和简化实例表示来降低复杂性开始。然后,我们将讨论简化实例的方法,以最大限度地减少交点检查的数量。接下来,我们将实现四叉树以限制要检查的实例数量,最后,我们将向实例添加边界球体以创建碰撞检测的两层。
在本章中,我们将涵盖以下主题:
-
碰撞检测的复杂性
-
使用空间分区来降低复杂性
-
简化实例以加快碰撞检测
-
添加四叉树以存储附近的模型实例
-
实现边界球体
技术要求
示例代码位于chapter08文件夹中,子文件夹01_opengl_collisions用于 OpenGL,02_vulkan_collisions用于 Vulkan。
碰撞检测的复杂性
当决定如何实现视觉选择时,我们已经在第三章中讨论了找到碰撞的复杂性,无论是使用光线投射还是缓冲区绘制。我们选择将实例绘制到单独的缓冲区中,从而完全避免碰撞检测。
现在是回顾这个复杂主题并展示加速实例间碰撞查找解决方案的合适时机。
避免天真方法
如果我们在虚拟世界中检查每个实例的每个三角形与所有其他实例的所有三角形之间的碰撞,这将带来巨大的处理成本。这些简单、蛮力的碰撞检查将以指数级增长,使得在添加更多实例时保持合理的帧时间变得不可能。
在实现任何类型的碰撞检测之前,我们应该退一步思考可能的简化类型,而不是使用天真解决方案。
一个想法是减少我们必须检查的实例数量。为什么要在虚拟世界的遥远部分检查实例呢?即使我们的物体是某种子弹、火箭或其他飞行实体,我们可能击中的所有东西都需要在我们“一臂之长”以内。所有其他物体都可以安全忽略。
为了实现这种减少,我们可以将世界划分为不同的区域。突然之间,我们只需要检查我们所在的区域。也许我们还需要检查相邻的区域,这取决于我们使用的算法,但我们需要检查的实例总数可以大幅减少。
另一方面,我们可以通过减少需要测试的表面元素数量来降低碰撞检测的计算工作量,前提是我们接近实例中的任何三角形。任何简化方法都将变得方便;需要测试的交点越少,效果越好。
将实例表示为盒子或球体可能会产生许多错误结果,但如果与实例周围的盒子或球体的碰撞检查已经失败,我们就可以立即从这个可能发生碰撞的目标列表中排除这个实例。
结合两种想法——减少需要检查的实例数量和降低实例检查的复杂性——有助于在实时中进行碰撞检测,即使是有更多详细模型的情况。
让我们从虚拟世界的空间分区开始。
使用空间分区来降低复杂性
在本节中,我们将探讨一些方法来将我们的世界空间划分为不同的部分,降低每个部分中的实例数量。我们从二维或三维空间分区最简单的变体,即网格开始。
网格
在网格中,虚拟线将虚拟世界划分为等大小的正方形或矩形,或者等大小的立方体和长方体。

图 8.1:2D 和 3D 网格
虽然网格易于创建,但图 8.1已经显示了其中的一些问题。大于网格间距的对象必须放置在所有重叠的网格字段中,需要检查所有受影响的字段以确定其他实例。
虽然网格的大部分将保持空置,但虚拟世界内的“拥挤区域”可能导致单个网格字段内存在许多实例。许多实例意味着许多检查,实例的不均匀分布可能会导致由于计算数量增加而导致的减速。
四叉树是网格的一种继承形式。四叉树解决了网格的不均匀分布问题。
四叉树
四叉树的基本元素是一个单独的单元格,可以是正方形或矩形。我们将仅使用正方形单元格进行描述,但所有内容也适用于矩形形状的单元格。对象通过其位置和大小插入到根单元格中,通常使用一个二维边界框来覆盖对象的范围。
四叉树的神奇之处在于当每个单元格中对象的配置阈值被达到时开始。受影响的单元格被细分为四个大小相等的子单元格。任何重叠一个或多个子单元格的对象可以保留在父单元格中,或者根据实现方式添加到所有受影响的子单元格中。所有其他对象都移动到相应的子单元格中。参见图 8.2,了解四叉树的示例:

图 8.2:具有不同细分结构的四叉树
将正方形划分为四个子单元格,并将父单元格中的对象移动到子单元格中,可以减少每个单元格中的对象数量,使其低于配置的阈值,从而最小化需要测试碰撞的对象数量。
如果四个子单元格中所有对象的和低于阈值,则所有对象再次移动到父单元格中,现在为空的子单元格被删除。这种动态行为有助于保持每个单元格中对象的数量在零和阈值之间,独立于单元格的大小、父单元格的数量或位置。
四叉树只能存储关于对象位置和大小的二维信息。要将相同的逻辑扩展到三维,可以使用八叉树。
Octree
四叉树和八叉树的基本功能相同。唯一的区别是树元素使用的维度数量。四叉树使用正方形或矩形作为单元格,而八叉树由立方体或长方体组成。在图 8.3中,显示了一个简单的八叉树:

图 8.3:具有细分结构的八叉树
插入的对象内部以三维轴对齐的边界框形式维护,表示对象的范围。当达到阈值时,分割操作会导致创建八个子立方体作为子单元(或八个子长方体)。
八叉树是检查可能碰撞时有效移除三维空间大部分区域的方法。另一种处理二维和三维空间划分的数据结构是二叉空间划分(BSP)。
二叉空间划分
你可能听说过来自老游戏的三个字母 BSP。第一个使用 BSP 树来维护关卡数据的游戏是 1993 年由Id Software开发的Doom游戏。
尽管 Doom 中的关卡数据只有二维,但游戏引擎创造了一个完全三维游戏的错觉。
BSP 树是通过递归地使用线(2D)或平面(3D)作为超平面来划分世界空间创建的,创建前后两个面。前后面的划分会继续进行,直到剩余的分区满足某些退出条件;对于游戏来说,这个条件通常是分区完全填满或为空。
如果细分时遇到任何其他线或平面,这些线或平面将被分割成两部分,一部分位于前方,另一部分位于后方。
图 8.4 展示了空间的细分和由此产生的 BSP 树:

图 8.4:一个示例对象及其生成的 BSP 树
在图 8.4中,线 A 用作起点,前端向下。将空间分成两半也分割了线 B 和 C,从而产生了线 B1、B2、C1 和 C2。线 B1 位于 A 的后方,将被添加为 A 的左子节点,而 B2 位于 A 的后方,作为 A 的右子节点。线 C1 和 C2 都位于 B1 和 B2 的后方,因此它们被添加为 B1 和 B2 的左子节点。
解析 BSP 树以找到分区确实非常快,但生成相同的 BSP 树是一个耗时的任务。大多数情况下,树生成是在离线完成的,预计算的树与游戏或应用程序一起分发。检查所有线或平面与所有其他线或平面的过程与碰撞检测中遇到的问题相同。
BSP 树元素无法快速更改或更新的能力使得这种树仅适用于静态数据(即,用于游戏的关卡数据)。像门或玩家这样的动态游戏元素需要使用不同的数据结构,例如八叉树。
与 BSP 树类似,k-d 树在搜索元素时速度快,但在创建或更新时速度慢。
K-d 树
k-d 树存储 k 维空间中对象的信息。与之前的树相比,该算法要复杂一些。在每次数据点插入时,剩余空间被分成两部分,分割后,受影响的维度会改变。
对于二维 k-d 树,分割维度在 X 轴和 Y 轴之间交替;对于三维 k-d 树,它按照 X、Y、Z 的顺序交替;依此类推。图 8.5 展示了二维和三维 k-d 树的外观:

图 8.5:二维和三维 k-d 树
红线和相应的蓝线是二维中的分割维度:红色代表 X 分割,蓝色代表 Y 分割。在三维中,相同的模式适用,我们在连续分割时使用“下一个”维度。由于剩余空间的部分被移除,在 k-d 树中搜索元素是快速的。k-d 树的主要用途是点云和搜索给定点的最近邻。
空间分割的另一种方法是使用边界体积层次结构。
边界体积层次结构
与之前的树变体相比,边界体积层次结构可以通过不同类型的几何表示来实现。例如,我们使用二维边界圆,如图 8.6所示:

图 8.6:由圆组成的边界体积层次结构
通过将两个或多个边界圆包围在一个更大的边界圆中,可以降低碰撞测试的数量。如果可能发生碰撞的对象没有击中外部圆,则不需要检查任何内部圆以确定可能的碰撞。对于碰撞对象来说,内部圆是无法到达的。
只有当我们击中外部圆圈时,才需要进行更深入的检查。与其他树类似,边界体积层次结构可以移除世界空间中更大的一部分,从而减少进一步的碰撞检查。
在我们开始实现空间划分算法之前,我们需要探索加速碰撞检测的第二种方法:使用实例的简化表示以进行更快的检查。
简化实例以进行更快的碰撞检查
在减少整体数量后,我们不必检查实例的每个三角形,如果我们使用模型抽象,可以大大提高碰撞检查的性能。这些抽象仅由几个几何元素组成,如盒子、长方体或圆,它们包围着实例。如果这些抽象不相交,实例之间不可能发生碰撞,我们可以从我们的候选列表中移除该实例。
对象最快的抽象之一是轴对齐边界框。
轴对齐边界框
轴对齐边界框(AABB)是一个矩形或长方体,大小刚好足以包含对象,并且矩形的所有线或长方体的所有平面都与笛卡尔坐标系中的轴对齐。在实现四叉树时,我们将使用 AABB 的二维变体,即边界框。图 8.7展示了二维边界框:

图 8.7:二维边界框
AABB 最适合形状接近正方形、矩形、立方体或长方体的对象。对于圆形对象,AABB 将在对象和箱体范围之间产生很多空间,导致在检查可能的碰撞时出现更多的“假阳性”。
检查 AABB 之间的交集是一个快速且简单的工作。由于所有四条线或六个侧面都与轴对齐,因此只需检查每条线或每个平面与第二个实例的对应线或平面的一个维度。
例如,实例一的底部平面和实例二的顶部平面可以通过仅比较 Y 值(如果 Y 轴向上)来检查。如果这个检查表明实例没有发生碰撞(即,第二个实例的顶部平面不在第一个实例的底部平面之上),则可以立即结束碰撞检查——这两个实例根本不可能相交。因此,我们可以通过使用 AABB 进行快速预检。
当我们旋转 AABB 与对象一起时,将创建一个定向边界框(OBB)。
定向边界框
仅旋转与对象一起的 AABB 可能很有吸引力,因为我们仍然保持了良好的对象数据与盒子额外空间之间的比率。图 8.8展示了示例:

图 8.8:定向边界框
但请注意,OBBs(轴对齐边界框)并不只是 AABBs(轴对齐边界框)的伪装!旋转平面将不再允许我们进行简单的坐标检查,我们现在需要解决大量的平面方程或使用所谓的分离轴定理等高级方法来检查两个 OBBs 的侧面平面是否相交。此外,OBBs 的创建和旋转可能很复杂且模糊。因此,这里的建议是远离 OBBs;在大多数情况下,它们不值得额外的复杂性。
对于圆形(或近似圆形)对象,边界圆和球体是一种很好的简化类型。
边界圆和球体
在二维空间中,边界圆相对简单;在三维空间中,边界球也相当简单。我们只需要一个中心点和半径,任务就完成了。图 8.9显示了围绕一个对象的边界圆:

图 8.9:围绕一个对象的边界圆
与边界圆或球体进行碰撞检测也很快且简单。勾股定理帮助我们计算从世界中的任何点到球体中心的距离。只有当计算出的距离小于半径时,才会发生碰撞。
请记住,勾股定理包括计算平方根,如果你不使用现代单指令多数据(SIMD)CPU 扩展,如流式 SIMD 扩展(SSE)或高级向量扩展(AVX),这是一个相当昂贵的操作。这里的一种可能的优化是使用半径的平方,并在计算距离时跳过平方根。由于现在两个结果都是平方的,比较的结果与平方根版本相同。
边界圆或边界球的一个有趣的变化是胶囊。
胶囊
胶囊看起来是圆形或球体的拉伸版本,其中中间部分呈矩形或圆柱形。胶囊用于人体,因为胶囊的整体形状与球体或矩形边界框相比更接近人体形状。图 8.10显示了胶囊的示例:

图 8.10:围绕简单角色的胶囊
当使用相同的值作为结束圆或球体的半径,以及中心矩形或圆柱体时,我们只需要存储胶囊的中心线和半径。计算可能的碰撞点的距离并不比计算边界圆或球体的距离更昂贵。
在碰撞检测的成本中,凸包位于高端。
凸包
凸包被定义为包含对象的最小凸体积。强调凸包的凸性是有意为之,因为这种属性在一定程度上允许算法优化。然而,复杂凸包的平面数量可能会很高,构建可能并不简单。参见图 8.11中的凸包:

图 8.11:围绕对象的一个凸包
检查凸包的碰撞需要大量测试,这可能会降低我们通过模型简化想要实现的效果。只要你的模型没有很多三角形,凸包可能就不在考虑范围内。
另一方面,如果可以容忍小的碰撞错误,凸包可以用作高多边形模型的替代品,并且所有碰撞检查都可以仅针对凸包进行。
作为最后一种方法,可以使用边界体积层次结构。层次模型不仅适用于空间划分,也适用于模型简化。
边界体积层次结构
空间划分的边界体积层次结构的一般思想也适用于简化实例。但在这里,模型部分被包含在越来越大的体积中。图 8.12显示了围绕角色模型的边界体积层次结构的简化示例:

图 8.12:围绕对象的一组边界球体
对较大体积中的一个失败检查使我们能够完全丢弃体积内的身体部分。通过使用精心设计的配置,只需要少量测试就可以决定实例是否应该保留在列表中进行更深入的检查,或者是否可以忽略。
关于凸对象之间碰撞检查的详细信息,你可以查看吉尔伯特-约翰逊-基尔蒂(GJK)距离算法。GJK 算法通过使用所谓的 Minkowski 差来降低碰撞检测的复杂性。在附加资源部分有一个解释 GJK 算法原理的网页链接。
为了简化起见,由于更新速度快,以及基于对要检查的实例数量的限制,我们将实现一个四叉树到应用程序中,并为实例使用边界球体。四叉树将使我们能够比原始方法中所有模型之间的“蛮力”成对比较更快地完成碰撞检测的第一步。边界球体在碰撞检查的数量和由于球体与实例三角形之间的自由空间而产生的假阳性数量之间提供了很好的平衡。
接下来,让我们跳入四叉树的实现。
添加四叉树以存储附近的模型实例
正确编写复杂代码很难,因此使用四叉树的开源实现是一个可行的选择。我找到的最佳四叉树版本来自 Pierre Vigier,可以在 GitHub 上找到:github.com/pvigier/Quadtree。
Pierre 的代码基于 C++ 模板,以在四叉树中存储的数据上具有极大的灵活性。此外,他使用自定义模板化的二维向量类型来存储位置和大小。
我们不需要这种灵活性,因为我们将在四叉树中仅存储一个包含实例索引位置的 int。由于我们在所有其他需要二维向量类型的情况下都使用 GLM,我们将更改边界框实现以使用 glm::vec2 来存储框的位置和大小。
调整边界框代码
作为新而闪亮的四叉树的第一步,我们需要调整边界框的代码。我们不再存储顶部和左边的位置以及宽度和高度作为单个值,而是使用 vec2 来存储位置和大小。将 GitHub 仓库 include 文件夹中的 Box.h 文件中的模板化 C++ 代码转换为四叉树相对容易,最终我们在新创建的 quadtree 文件夹中的 BoundingBox2D.h 和 BoundingBox2D.cpp 文件中得到了构造函数、几个获取器以及 contains() 和 intersects() 这两个方法。
最复杂的方法是 intersects(),用于检查两个边界框是否相交:
bool BoundingBox2D::intersects(BoundingBox2D otherBox) {
return !(
mPosition.x >= otherBox.getRight() ||
otherBox.getTopLeft().x >= getRight() ||
mPosition.y >= otherBox.getBottom() ||
otherBox.getTopLeft().y >= getBottom()
);
}
在 AssimpInstance 类中,将添加一个名为 mBoundingBox 的新 private 成员,用于存储实例的边界框:
BoundingBox2D mBoundingBox{};
我们不需要加载或保存边界框,因此它可以直接存储在 AssimpClass 中,在 InstanceSettings 结构体之外。
我们需要两个简单的 public 方法来存储和检索边界框:
BoundingBox2D getBoundingBox();
void setBoundingBox(BoundingBox2D box);
作为边界框实现的最后一步,我们必须在项目根文件夹中的 CMakeLists.txt 文件中添加新的 quadtree 文件夹,以便在其他类中使用边界框和四叉树。首先,将包含所有 .cpp 文件的文件夹添加到 SOURCES 文件搜索中:
file(GLOB SOURCES
...
**quadtree/*.cpp**
...
)
然后,将 quadtree 文件夹追加到包含目录列表中:
target_include_directories(${PROJECT_NAME} ... **quadtree**)
在重新创建 CMake 文件后,这应该在保存 CMakeLists.txt 中的更改后由 Visual Studio 和其他 IDE 自动完成,我们可以继续进行四叉树的实现。
重写四叉树代码以适应我们的需求
我们将新的 Quadtree 类存储在 quadtree 文件夹中的 Quadtree.h 和 Quadtree.cpp 文件中。将四叉树的 C++ 模板转换为 C++ 类与边界框代码相比需要一些额外的步骤。
在翻译 C++ 模板代码时,我们需要进行以下调整:
-
将自定义的
Vector2类型替换为glm::vec2。 -
将自定义的
Box类型替换为BoundingBox2D类型,并将参数更改为glm::vec2。 -
将
assert()检查替换为日志输出。 -
添加一个
clear()方法来移除四叉树的所有内容。 -
添加代码以清理具有交换 ID 的碰撞实例对。
-
使用返回值作为结果,而不是输出参数。
-
使用回调函数而不是
GetBox函数参数。
对于回调函数,我们在Callbacks.h文件中添加另一个回调,它只接受一个int参数并返回一个BoundingBox2D:
using instanceGetBoundingBox2D =
std::function<BoundingBox2D(int)>;
四叉树将作为名为mQuadtree的private成员变量添加到渲染器中:
std::shared_ptr<QuadTree> mQuadtree = nullptr;
在initQuadTree()方法的初始化过程中,我们将回调函数绑定到getBoundingBox()方法上,使用实例索引作为参数:
mQuadtree->instanceGetBoundingBox2DCallback =
this {
return mModelInstCamData.micAssimpInstances.at(
instanceId)->getBoundingBox();
};
在渲染器的draw()调用中,在遍历模型实例时填充四叉树。对于每个实例,我们创建边界框并将实例添加到四叉树中:
BoundingBox2D box{position, size};
instances.at(i)->setBoundingBox(box);
mQuadtree->add(instSettings.isInstanceIndexPosition);
为每个实例创建边界框将在计算实例边界框这一节中处理。
对于四叉树的使用,还有一个重要问题:我们应该在每一帧更新树中的实例位置,还是应该清除整个四叉树并再次添加所有实例?
更新四叉树内的对象需要通过删除和重新添加来实现。因此,如果我们四叉树中大部分是静态对象,更新将是可行的。但鉴于我们的计划是要有多个实例在移动和/或播放动画,位置和/或边界框几乎在每一帧都会改变。在这种环境中,实例的删除和添加将比清除四叉树并使用新鲜数据添加所有实例更昂贵。
在渲染器的draw()调用中重新开始使用一个全新的四叉树很容易。在进入实例循环之前,清除四叉树的内容:
mQuadtree->clear();
四叉树准备就绪后,我们必须解决如何为实例生成边界框的问题。答案可能令人惊讶:我们需要在 GPU 和 CPU 之间分配工作。
计算实例边界框
当涉及到所有实例的大量计算时,第一个想法很可能是“我将使用计算着色器!”,尤其是在我们通过将工作卸载到 GPU 来解决了一些计算密集型问题之后。但在这个案例中,CPU 必须完成部分工作。
虽然计算着色器在以大规模并行方式计算独立结果方面很出色,但它们并不适合像创建边界框这样的简单任务。为了计算实例的边界框,我们需要存储虚拟世界中每个实例的所有节点的最小和最大坐标,结合中间结果以创建最终的边界框。在 CPU 上,我们可以轻松地从数据结构中逐个获取顶点位置,进行必要的计算,并将最终的 AABB 存储回内存中。
但如果我们想在着色器内部计算 AABB 坐标,我们可能会覆盖其他着色器调用的结果,如果 AABB 结果使用相同的缓冲区位置。或者我们将有一个巨大的坐标列表,之后必须对其进行排序。排序坐标也可以在另一个计算机着色器中完成,但并行排序算法通常很复杂,因为它们面临着相同的约束,即不能写入相同的缓冲区位置。
因此,我们不必担心计算着色器中的数据同步问题,我们使用它们来做它们能非常快速完成的事情:创建查找表。
添加三维边界框类
为了存储生成的查找数据,我们将添加一个用于三维 AABB 的 C++类。在这里使用 AABB 的优势是可以在渲染器中绘制调试线条,但第三组坐标只增加了很少的存储和生成时间开销。此外,升级到八叉树以在三个维度上快速进行碰撞检测将是简单的。
新的类称为AABB,并将位于tools文件夹中。除了构造函数外,还有一些public的获取器和设置器,以及两个private的浮点数mMinPos和mMaxPos,AABB类还有两个public方法,称为create()和addPoint()。
通过使用create()方法,我们在glm::vec3参数指定的位置添加一个点,作为边界框的最小和最大范围。使用addPoint()方法,我们可以扩展边界框以包含新点:
void AABB::addPoint(glm::vec3 point) {
mMinPos.x = std::min(mMinPos.x, point.x);
mMinPos.y = std::min(mMinPos.y, point.y);
mMinPos.z = std::min(mMinPos.z, point.z);
mMaxPos.x = std::max(mMaxPos.x, point.x);
mMaxPos.y = std::max(mMaxPos.y, point.y);
mMaxPos.z = std::max(mMaxPos.z, point.z);
}
如果新点在边界框内,则不会发生任何事情。但如果点在边界框外,则框将在一个、两个或三个维度上扩展以包含新点。最终,我们将有一个围绕我们添加的所有点的框,由三个维度的最小和最大值定义。
为了在渲染器中绘制调试线条,使用public方法getAABBlines()。在getAABBLines()中,我们创建mMinPos和mMaxPos的x、y和z元素的最小和最大位置之间可能的 8 种组合的 12 条线:
mAabbMesh->vertices.at(0) =
{{mMinPos.x, mMinPos.y, mMinPos.z}, color};
mAabbMesh->vertices.at(1) =
{{mMaxPos.x, mMinPos.y, mMinPos.z}, color};
...
mAabbMesh->vertices.at(22) =
{{mMaxPos.x, mMaxPos.y, mMinPos.z}, color};
mAabbMesh->vertices.at(23) =
{{mMaxPos.x, mMaxPos.y, mMaxPos.z}, color};
创建 AABB 查找表
AABB 查找表的一般思想是为每个模型中每个动画剪辑的固定帧数预先计算 AABB。在查找表中拥有边界框允许我们在动画剪辑的帧中检索模型 AABB 的良好近似值。
通过在两个不同剪辑中同一帧号的 AABB 坐标之间进行混合,并根据模型的变换转换最终的 AABB,我们可以在低计算成本下计算出适合实例的 AABB,即使实例处于动画混合的中间。
创建查找表是计算着色器和 CPU 工作的混合,在渲染器的createAABBLookup()方法中完成。查找表在渲染器类中生成,有两个原因:
-
加载模型的主要方法放在渲染器中,我们还通过回调将模型加载方法暴露给用户界面。
-
渲染器已经加载了计算着色器。我们可以直接使用着色器,而不是在
AssimpModel类中使用另一组相同的着色器。
对于计算着色器部分,我们可以重用我们在第二章中制作的渲染器draw()调用实例循环中的代码。
为了找到模型的最大范围,我们将使用节点。为了计算每个实例的节点数,可以使用两个计算着色器assimp_instance_transform.comp和assimp_instance_matrix_mult.comp。在包含节点平移、旋转和缩放的矩阵的计算部分,存储了每个节点的位置。这里唯一的区别是节点偏移矩阵。
虽然这些偏移矩阵对于顶点皮肤变形是必需的,但它们会对节点位置给出错误的结果。我们可以通过绑定一个充满单位矩阵的 SSBO 来从计算中移除偏移矩阵,这样我们只有节点的 TRS 矩阵。
通过提取每个实例的节点位置并从位置创建每个节点的 AABB,我们有一个简单的方法来计算我们需要的用于四叉树和调试显示的数据。对于 2D 边界框,我们只需提取 AABB 的x和z元素,生成 AABB 的“从上到下”视图。
AABB 计算是按动画剪辑和模型节点进行的。生成的std::vector将存储在我们刚刚加载的模型的AssimpModel数据中。为了检索动画剪辑中时间位置的正确 AABB 数据,我们使用我们已有的生成顶点皮肤变形矩阵的逻辑,并将缩放的时间位置作为查找数据的索引。如果我们混合两个动画,我们甚至可以在两个动画的 AABB 之间进行混合。
在模型和渲染器中使用 AABB
当在渲染器的draw()调用中遍历实例时,现在可以通过调用getAABB()来检索动画剪辑和时间位置的 AABB 数据:
AABB instanceAABB = model->getAABB(instSettings);
在getAABB()内部,动画和实例平移、旋转和缩放的数据被用来创建适合实例的 AABB。具体来说,getAABB()将:
-
查找第一和第二个动画剪辑的 AABB
-
根据动画混合因子在两个 AABB 之间进行混合
-
将结果 AABB 按实例缩放因子缩放
-
如果模型激活了轴交换,则旋转 AABB
-
根据实例旋转(现在是一个 OBB)旋转 AABB
-
从旋转的 ABB 生成一个新的 AABB
-
将 AABB 平移到实例位置
到这一点,AABB 包围了实例。
由于混合和旋转仍然是昂贵的操作,可能会在具有许多实例的场景中损害性能,通过使用现代处理器的 SIMD 操作(如 SSE 或 AVX)并行化计算可以加快速度。但即使将 AABB 查找数据保留在 GPU 上也可能有益。使用计算着色器计算所有实例的边界框并将结果下载下来可能比纯 CPU 计算更快,尽管有额外的结果下载。
为了达到最佳性能,你可能想要实现 getAABB() 方法的不同版本,并使用各种场景大小和复杂度来分析应用程序。
在对实例的 AABB 进行调整后,我们计算四叉树中二维边界框的位置和大小:
glm::vec2 position =
glm::vec2(instanceAABB.getMinPos().x,
instanceAABB.getMinPos().z);
glm::vec2 size =
glm::vec2(std::fabs(instanceAABB.getMaxPos().x -
instanceAABB.getMinPos().x),
std::fabs(instanceAABB.getMaxPos().z -
instanceAABB.getMinPos().z));
现在,我们有了所有数据来将实例插入到四叉树中:
BoundingBox2D box{position, size};
instances.at(i)->setBoundingBox(box);
mQuadtree->add(instSettings.isInstanceIndexPosition);
为了确保我们创建了一个工作的四叉树,我们将添加一个新的 ImGui 窗口,包含四叉树及其所有子分区和实例。看到四叉树的实际运行情况对于发现任何实现错误非常有帮助。
创建一个窗口来显示四叉树及其内容
对于 ImGui 窗口,我们在 UserInterface 类中创建了一个名为 createPositionsWindow() 的新方法。在这个方法内部,我们使用 ImGui::Begin() 创建一个新的窗口。接下来,我们检索世界边界以获取原点、大小和中心点进行绘制。
然后,我们遍历所有实例,使用边界框的位置和大小在实例的二维世界位置处绘制 ImGui 矩形。我们还使用 micInstanceCollisions 变量的成对信息以不同颜色绘制非碰撞和碰撞实例。
最后,我们检索四叉树及其所有子分区的边界框。为了获取四叉树所有级别的边界框,我们在 Quadtree 类中添加了一对 public 和 private 方法,称为 getTreeBoxes()。getTreeBoxes() 方法递归遍历四叉树节点及其子节点,将边界框存储在 std::vector 的 BoundingBox2D 元素中。我们使用白色线条绘制所有四叉树框。
图 8.13 展示了四叉树窗口的一个示例:

图 8.13:包含子分区、实例和检测到的碰撞的四叉树
在 图 8.13 中,四叉树及其所有子分区都以白色绘制。正常实例的边界框以黄色线条绘制,碰撞实例以红色线条绘制。
填充四叉树并在屏幕上绘制结果只是碰撞检测的第一步;我们还需要对虚拟世界中碰撞的实例信息进行处理。
获取碰撞实例并响应碰撞
从四叉树中获取所有碰撞实例的列表是通过调用 mQuadtree 对象的 findAllIntersections() 实现的:
mModelInstCamData.micInstanceCollisions =
mQuadtree->findAllIntersections();
我们将结果存储在ModelInstanceCamData结构体中的新变量micInstanceCollisions中,以便其他部分的代码(即,用于调试绘制线条)可以使用实例碰撞。碰撞的实例以实例对的形式提供给我们。
要在屏幕上看到碰撞的结果,我们可以遍历实例的对,并旋转一个或两个实例:
for (const auto& instPairs :
mModelInstCamData.micInstanceCollisions) {
instances.at(instPairs.first)->rotateInstance(6.5f);
instances.at(instPairs.second)->rotateInstance(-5.3f);
}
走进或跑进另一个实例现在应该旋转你的实例,或者你的实例和碰撞实例。尽管角度很小,但它将在检测到这些实例之间的碰撞的每一帧中增加。在正常的 60 或 75 FPS 帧率下,你会立刻看到自己远离其他实例,突然朝不同的方向跑去。
如果你不喜欢实例在碰撞时只是旋转,你可以尝试实现更复杂的碰撞反应。查看实践课程部分有关添加碰撞解决的任务,以及附加资源部分有关避免碰撞的资源链接。
绘制 AABB 调试线条
如果你的实例或另一个实例没有旋转,你仍然可以穿过其他实例,此时在屏幕上绘制 AABB 线条将变得很有帮助。
在渲染器中绘制调试线条,我们可以使用micInstanceCollisions中的配对,从受影响的实例中检索 AABB 线条,并将线条的顶点添加到名为mAABBMesh的private变量中。然后,我们将顶点数据上传到线条顶点缓冲区,并通过使用线条着色器,将 AABB 绘制到屏幕上:
mLineVertexBuffer.uploadData(*mAABBMesh);
mLineShader.use();
mLineVertexBuffer.bindAndDraw(GL_LINES, 0,
mAABBMesh->vertices.size());
作为另一种解决方案,我们可以遍历所有实例,并使用micInstanceCollisions中对中的实例索引来切换 AABB 的绘制颜色。
为了对 AABB 线条有更多的控制,将在 ImGui 控制窗口中创建一个单独的部分。结合名为collisionDebugDraws的新enum类和渲染器中drawAABBs()方法中的更多代码,你可以在三种不同的绘制模式之间切换:
-
没有 AABB
-
只有碰撞的 AABB 用红色表示
-
所有碰撞的 AABB 用红色表示,所有其他 AABB 用黄色表示
图 8.14展示了检测到的碰撞示例,使用黄色线条围绕非碰撞实例,红色线条围绕两个碰撞实例:

图 8.14:在非碰撞实例中的两个碰撞实例
如果一些实例的行为异常,例如不断围绕自身旋转,请不要惊慌。与其他实例和世界边界的碰撞反应仍然非常基础(只是旋转)。我们将在第九章中增强碰撞处理。
在四叉树工作并减少需要检查的实例数量到可配置的最小值后,我们将开始实施列表中列出的简化之一:通过利用游戏角色模型的节点作为锚点,边界球体在创建和检查复杂度之间有一个良好的权衡。
因此,让我们去给模型添加一些球体。
实现边界球体
边界球体作为更详细模型的抽象可以用不同的方式使用。一种可能的方式在图 8.9中显示,其中球体包围了整个模型。但是,正如图 8.9中所示,我们会在模型周围有大量的空空间,导致更多的错误肯定。此外,我们已经有了一种方法来进行更广泛的碰撞检查:边界框。
相反,我们将使用球体在更详细的级别上简化实例,通过向模型的节点添加可配置的边界球体。尽管我们现在需要检查几十个球体与另一个实例的球体,但我们仍然远远低于检查两个模型中每个三角形的计算能力。
错误肯定的数量也将保持在可接受的水平。正如在简化实例以加快碰撞检查部分中广泛陈述的那样,在更详细的简化与更多计算或更简单的抽象和更多错误肯定碰撞检测之间进行权衡是我们必须接受的,但我们可以战略性地做出决定。
创建边界球体的数据
要创建球体数据,我们使用与边界框计算相同的第一个步骤。我们使用计算着色器assimp_instance_transform.comp和assimp_instance_matrix_mult.comp,结合用单位矩阵替换骨骼偏移矩阵。这个着色器运行的结果是一个 SSBO,包含我们想要装备边界球体的所有实例的节点位置。
但是,与边界框不同,我们可以使用另一个计算着色器来计算节点的边界球体。第三个计算着色器,称为assimp_instance_bounding_spheres.comp,使用先前计算着色器创建的节点的 TRS 矩阵、实例的世界位置矩阵以及父节点索引来为每个实例的每个节点创建一个边界球体。
另一个名为SphereAdjustment的 SSBO 也被使用,每个节点包含一个vec4。这些球体调整可以通过一个 UI 扩展来设置,该扩展将模型的节点名称映射到一个SliderFloat和一个SliderFloat3,允许我们调整和移动由着色器创建的边界球体。通过仔细放置边界球体,我们可以确保模型周围尽可能少的空空间被检测为碰撞。
图 8.15显示了新的 UI 部分:

图 8.15:微调模型的边界球体
SphereAdjustments SSBO 由一个名为 msBoundingSphereAdjustments 的 glm::vec4 元素 std::vector 支持,放置在 ModelSettings 结构体中。在调整向量中,每个 vec4 被分割,使用前三个元素作为每个球体的位置,最后一个元素作为球体的半径。
由于在每次应用程序重启时都不必调整边界球体是一个好主意,因此将 msBoundingSphereAdjustments 缓冲区的内容添加到 YamlParser 类中,以便能够保存和恢复调整:
向用户界面和 YAML 解析器添加的功能更多或更少是平凡的,复制自已经存在的 ImGui 和 YAML 解析代码部分。您可以探索 UserInterface 和 YamlParser 类来检查代码更改:
我们的第三个计算着色器 assimp_instance_bounding_spheres.comp 也重用了其他着色器的一部分。main() 方法的顶部与矩阵乘法计算着色器相同:
void main() {
uint node = gl_GlobalInvocationID.x;
uint instance = gl_GlobalInvocationID.y;
uint numberOfBones = gl_NumWorkGroups.x;
uint index = node + numberOfBones * instance;
我们根据着色器的调用 ID 选择要处理的节点和实例,而模型中的节点数量来自我们在调度计算着色器时使用的组数的 X 维度:
接下来,我们通过从实例特定节点的 TRS 矩阵中提取平移部分并添加来自球体调整缓冲区的位置来获取节点位置:
vec3 nodePos =
(worldPosMat[instance] * trsMat[index])[3].xyz;
nodePos += sphereAdjustment[node].xyz;
float radius = 1.0;
我们还声明了球体半径,并用默认值初始化它:
然后,我们从父索引缓冲区中提取当前节点的父节点 ID:
int parentNode = parentIndex[node];
父节点用于在具有父节点和父节点或未连接的独立节点之间切换。如果我们找到一个有效的父节点,我们将计算父节点的世界位置,包括位置调整:
if (parentNode >= 0) {
uint parentIndex = parentNode +
numberOfBones * instance;
vec3 parentPos =
(worldPosMat[instance] * trsMat[parentIndex])[3].xyz;
parentPos += sphereAdjustment[parentNode].xyz;
通过使用节点和父节点位置,我们计算节点与其父节点之间的中点。我们计算球体的半径——同样,可以通过球体调整值进行调整:
vec3 center = mix(nodePos, parentPos, 0.5);
radius = length(center - nodePos) *
sphereAdjustment[node].w;
对于根节点,我们将球体的半径设置为可调整的值:
} else {
radius = sphereAdjustment[node].w;
}
在 main() 方法的末尾,我们添加了一个小的检查来禁用已经在着色器中的小球体:
if (radius < 0.05) {
sphereData[index] = vec4(0.0);
} else {
sphereData[index] = vec4(nodePos, radius);
}
}
由于我们需要在渲染器代码的几个不同位置运行三个计算着色器,我们通过将填充和运行着色器的代码添加到名为 runBoundingSphereComputeShaders 的新方法中来简化我们的工作:
绘制边界球体
对于边界球体的计算着色器的第二次使用是调试显示,类似于 AABB。在运行计算着色器之后,我们可以使用另一对新的顶点和片段着色器来将球体绘制到屏幕上:
在名为 sphere_instanced.vert 的新球体绘制顶点着色器的 main() 方法中,我们提取球体的中心和半径:
void main() {
vec3 boneCenter = sphereData[gl_InstanceID].xyz;
float radius = sphereData[gl_InstanceID].w;
为了加快绘图速度,我们将使用渲染 API 的实例化绘图调用,这样我们就可以在这里使用特殊变量gl_InstanceID(在 Vulkan 中将变量gl_InstanceID重命名为gl_InstanceIndex)。OpenGL 和 Vulkan 在内部递增变量gl_InstanceID和gl_InstanceIndex的值,每增加一个实例,这样我们就可以从单个顶点集中绘制成千上万的边界球体。
通过在着色器中调用名为createScaleMatrix()的小 GLSL 函数来调整球体的大小到正确的半径,该函数实际上是在主对角线元素中创建一个具有radius值的缩放矩阵:
mat3 scaleMat = createScaleMatrix(radius);
然后,我们将球体顶点的原始位置通过缩放矩阵进行缩放,添加球体调整,通过乘以view和projection矩阵创建最终的着色器矩阵,并设置线条颜色:
gl_Position = projection * view *
vec4(scaleMat * aPos + boneCenter, 1.0);
lineColor = vec4(aColor, 1.0);
}
通过调用Shader类的实例化版本绘图命令来运行球体着色器:
mLineVertexBuffer.uploadData(mSphereMesh);
mSphereShader.use();
mBoundingSphereBuffer.bind(1);
mLineVertexBuffer.bindAndDrawInstanced(GL_LINES, 0,
mSphereMesh.vertices.size(), numberOfSpheres);
对于 Vulkan 渲染器,使用等效的绘图调用(vkCmdBindPipeline()、vkCmdBindVertexBuffers()、vkCmdBindDescriptorSets()和VkCmdDraw())。
由于用于碰撞检测的边界球体可能与我们想要在屏幕上绘制的边界球体不完全相同,因此我们使用单独的方法来绘制调试球体。我们调试绘图方法之间的主要区别是创建一个实例列表,以供计算着色器使用。
我们可以通过调用drawSelectedBoundingSpheres()仅绘制所选实例的边界球体,通过调用drawCollidingBoundingSpheres()显示发生碰撞的实例的边界框,或者通过调用drawAllBoundingSpheres()在屏幕上的所有实例周围创建“蓬松的白色雪球”,如图 8.16 所示。

图 8.16:女性模型节点上的调整后的边界球体
现在我们能够计算和绘制模型边界球体,让我们将球体作为第二层添加到碰撞检测代码中。
使用边界球体进行碰撞检测
如在检索碰撞实例并响应碰撞部分所述,通过调用findAllIntersections()收集来自四叉树的碰撞实例,并保存到micInstanceCollisions中:
mModelInstCamData.micInstanceCollisions =
mQuadtree->findAllIntersections();
为了使碰撞检测代码更容易维护,我们将交集提取调用移动到一个名为checkForInstanceCollisions()的新方法中。这个新方法将成为所有与碰撞检测和处理相关的代码的起点。
我们扩展碰撞检测的第一步是通过调用上述的findAllIntersections()方法来从四叉树中获取所有碰撞实例。然后,我们根据模型将这些实例分成单独的int集合。对每个模型使用单独的碰撞实例列表是必要的,因为不同的模型可能有不同数量的节点。
然后,对于每一组实例,我们使用边界球创建 SSBO,并将球体数据提取到glm::ve4向量的映射中。我们将使用实例索引位置作为映射的键,将 SSBO 分割成一个包含每个实例所有球体的映射值。
实际上,边界球的碰撞检查是通过比较一个碰撞实例的所有球体与第二个碰撞实例的所有球体来完成的。我们已经通过只保留由四叉树提供的实例对来大量减少了这些检查的数量。
即使检查球体碰撞是一个简单的任务,我们只需要比较两个球体中心的距离与两个球体半径之和,比较的数量也会使这个检查变得缓慢。
即使通过移除所有半径为零的球体并停止比较以检测到碰撞来简化操作,两个刚刚未发生碰撞的实例之间,每个模型有 30 个活动球体,也需要 900 次这样的比较。
使用计算着色器加快球体比较操作是可能的,但像每个实例对中每个实例的不同节点数这样的问题会增加计算着色器的复杂度并降低其效率。
虽然在 GPU 上的并行计算可能很快,但上传和下载数据以及为单个实例对运行着色器可能会增加显著的延迟,从而抵消了计算着色器的加速效果。
因此,我们继续使用基于 CPU 的解决方案来计算边界框之间的第二层碰撞。除非我们在一个非常小的虚拟世界中拥有数千个实例,或者在世界的某个小部分中有许多实例,否则碰撞检查的总数将保持较低。
图 8.17展示了通过边界框(用红色线条绘制)检测到的碰撞示例,以及任何边界球的附加碰撞(也用红色表示):

图 8.17:检测到实例的边界球发生了碰撞
处理碰撞与检索碰撞实例并响应碰撞部分中处理边界框碰撞的反应相同——我们只需在每一帧中围绕一个固定角度旋转碰撞实例。我们将在第九章和第十二章中增强对碰撞的反应。
碰撞检测是一个复杂的话题,有许多算法和选择,这些选择基于你应用程序的具体需求。我们需要知道我们需要检查哪些类型的对象来选择一个抽象的好形状,并且根据抽象的复杂性,我们可能需要调整在尝试找到碰撞时使用的算法(或算法)。我们甚至没有触及到碰撞解决部分,该部分在检测到交点后通过移动实例来解决问题。如果你想深入了解游戏物理、碰撞检测和碰撞解决的世界,请查看附加资源部分中的书籍。
摘要
在本章中,我们探讨了碰撞检测并为应用程序创建了两级碰撞检测。我们首先讨论了简单解决方案的不足,然后探讨了空间划分方法和模型简化以减少我们必须进行的检查次数,直到我们非常确定哪些实例确实发生了碰撞。最后,我们实现了带有边界框和边界球的四叉树以找出哪些实例发生了碰撞。
在下一章中,我们将创建“真实”的非玩家角色(NPCs)并通过添加可配置的行为让实例变得生动。我们将从探索行为树的本质及其与 NPC 决策的关系开始,并实现支持行为树代码以供我们的实例使用。作为最后一步,我们将研究模型之间的交互,将其视为一般行为的专用集合。
实践课程
这里是一些你可以添加到代码中的改进:
- 添加用于四叉树配置的用户界面控件。
目前,四叉树在渲染器init()调用期间静态初始化。添加用户界面控件和设置回调函数来调整在分割盒子之前实例的最大数量和树的最大深度。
- 添加用户界面控件来配置世界边界。
实例在虚拟世界中四处移动,如果一个实例达到在mWorldBoundaries中设置的虚拟边界,它将被旋转以保持在边界内。添加一些滑块和回调函数来控制虚拟世界的原点和大小,并确保实例位置窗口也会更新。
- 实现八叉树加上三维轴对齐包围盒(AABB)检查。
目前,我们只为实例使用二维边界框,就像实例是从上方看到的。将四叉树扩展为八叉树,并在实例的 AABB 之间添加三维交点检查。
- 加速边界球碰撞检测。
由于检查的复杂性——我们必须将第一个实例的每个球体与第二个实例的每个球体进行比较——碰撞检查相当慢。也许计算着色器可以在这里有所帮助。与边界框生成不同,我们只需要对每个实例给出一个是/否的答案来表示是否发生了碰撞。使用每个球体或实例的原子计数器可以帮助避免计算着色器工作完成后进行长时间的后处理工作。
- 添加一个简单的边界体积层次结构。
而不是检查两个实例的所有球体,你可以在模型中添加一些未使用的节点,并将更大的边界球体添加到这些节点上,包围一些较小的球体。从两个实例中最大的球体开始检查球体。如果这些大球体没有发生碰撞,那么大球体内部的球体永远不会发生碰撞,因此整个身体部分可以跳过下一次碰撞检查。
- 增加难度:在实例之间进行真正的三角形到三角形的检查。
这是碰撞检测的最终目标。通过检查实例的真实交点,而不仅仅是某些 AABB 或边界球体,可以实现自然的外观碰撞行为。同时,注意这种方法的好处,并看看额外的精度是否有利于运行时行为。
- 增加难度:添加碰撞解决。
当碰撞检查信号表示两个实例之间发生碰撞时,已经太晚了——实例已经部分相交。良好的碰撞检测伴随着碰撞解决,当发现碰撞时,实例会被分开。已经有许多关于碰撞检测和碰撞解决的书籍被撰写;请参阅“其他资源”部分中的一些知名标题。在“做得正确”的道路上仍然存在许多注意事项。
其他资源
-
分离轴定理:
dyn4j.org/2010/01/sat/ -
吉尔伯特-约翰逊-基尔蒂距离算法:
cse442-17f.github.io/Gilbert-Johnson-Keerthi-Distance-Algorithm/ -
基于模板的四叉树实现:
github.com/pvigier/Quadtree -
交互式 3D 环境中的碰撞检测:ISBN 978-1558608016
-
实时碰撞检测:ISBN 978-1558607323
-
游戏物理引擎开发:ISBN 978-0123819765
-
游戏物理:ISBN 978-0123749031
-
碰撞避免:
code.tutsplus.com/understanding-steering-behaviors-collision-avoidance--gamedev-7777t -
更多关于碰撞避免的内容:
www.gameaipro.com/GameAIPro2/GameAIPro2_Chapter19_Guide_to_Anticipatory_Collision_Avoidance.pdf
第九章:添加行为和交互
欢迎来到第九章!在前一章中,我们深入探讨了碰撞检测。在讨论了在虚拟世界中找到碰撞实例的复杂性之后,我们探索了通过添加世界分区和模型简化来加速碰撞搜索的方法。然后,我们实现了一个四叉树来将世界分割成更小的区域,并为实例添加了边界框和边界球。最后,我们使用四叉树和实例边界来检测实例之间的碰撞。
在本章中,我们将为实例添加一些“现实生活”的行为,使它们能够自己绕着虚拟世界行走,并对之前章节中添加的碰撞等事件做出反应。首先,我们将简要了解行为树和状态机以及它们是如何工作的。然后,我们将添加一个可视化编辑器来直观地表示实例自身的状态机。本章结束时,我们将扩展代码以执行从创建的节点树中产生的行为变化,并将交互添加为行为的一种附加形式。
在本章中,我们将涵盖以下主题:
-
控制实例行为的结构
-
添加可视化节点编辑器
-
扩展代码以支持行为变化
-
在实例之间添加交互
技术要求
示例代码可以在文件夹chapter09中找到,对于 OpenGL 在子文件夹01_opengl_behavior中,对于 Vulkan 在子文件夹02_vulkan_behavior中。
控制实例行为的结构
在计算机游戏初期,敌人和其他非玩家角色(NPC)的行为相当简单。基于仅游戏状态或几个属性,例如“玩家吃了一个大点,我必须远离他们!”(吃豆人)或“我看到玩家,所以我将攻击他们!”(许多第一人称射击游戏),计算机对手仅通过一小套规则行事。
之后,像“普通”状态机、分层状态机和行为树这样的控制结构在很大程度上改变了游戏中的人工智能(AI)。因为现在可以轻松地为敌人和 NPC 建模复杂任务。现在,不仅可以为行为选择创建更多替代方案,还可以让由计算机控制的世界居民表现得更加像智能生物。根据世界因素、它们自身的属性以及一点随机性,发现玩家可能导致 NPC 出现多种不同的结果。
行为树简化了 NPC 行为的创建,并有助于推理 NPC 在任何给定时间可能处于的状态。然而,行为树只是一个具有节点和节点之间链接的增强型有限状态机。行为子系统会记住树当前的状态,并根据节点中配置的因素,执行状态到树中后续节点的变化。行为树没有巨大的复杂性,易于实现,但在游戏中仍然非常强大。
创建行为树的一种常见方法是使用专用节点,例如:
-
选择器,从一组选项中选择一个
-
序列,按特定顺序执行任务
-
条件,使用先前节点的结果来选择下一个节点
-
循环,重复树的一部分,直到满足条件
-
动作,影响角色状态或执行动作,如攻击
-
世界感知,根据事件或世界属性改变行为
使用特殊节点在构建灵活的行为树中非常有帮助。敌人或 NPC 可以收集关于世界及其中的其他对象的信息,并在周围环境发生变化时,在下一次树执行中选择不同的路径,从而通过不重复相同的动作和行动,实现更反应灵敏、更可信、更少机械的行为。
图 9.1展示了 NPC 行为的简单想法:

图 9.1:简单的行为计划
尽管计划看起来很简单,但它封装了复杂的行为。在扫描计划以寻找所需行为时,考虑到敌人的距离和健康状况,应该会产生相当令人印象深刻的 NPC 行动。通过随机变化,在躲避和远程攻击之间切换,直接攻击接近的敌人,或者作为最后的手段尝试隐藏或逃跑,观看这些 NPC 群体对抗来犯敌人将会很有趣。
我们将为本章中的实例添加一个简单的状态机来控制行为。为了简化使用状态机创建和控制行为,我们还将添加一个创建节点和链接的工具。因此,让我们短暂偏离一下,为应用程序添加一个可视化的节点编辑器。
添加可视化节点编辑器
与前几章一样,我们将使用开源工具来构建我们的可视化节点编辑器,而不是手动构建自己的解决方案。有几个基于 ImGui 的节点编辑器可用。你可以在附加资源部分找到精选列表。
在本章中,我们将使用 Johann “Nelarius” Muszynski 扩展的imnodes。imnodes 的源代码可在 GitHub 上找到:
与前几章一样,我们将使用 CMake 为我们获取 imnodes。
通过使用 CMake 集成 imnodes
为了让 CMake 将 imnodes 作为一个依赖项管理,在项目根目录中的CMakeLists.txt文件中添加一个新的FetchContent块,位于stbi的FetchContent块和WIN32-only 区域之间:
FetchContent_Declare(
imnodes
GIT_REPOSITORY https://github.com/Nelarius/imnodes
GIT_TAG v0.5
)
我们在这里将使用 Git 标签v0.5,以避免master分支上的新提交引入的问题,导致找不到 ImGui 源代码。
接下来,我们使 imnodes 代码可用,并让 CMake 填充源文件夹变量:
FetchContent_MakeAvailable(imnodes)
FetchContent_GetProperties(imnodes)
if(NOT imnodes_POPULATED)
FetchContent_Populate(imnodes)
add_subdirectory(${imnodes_SOURCE_DIR} EXCLUDE_FROM_ALL)
endif()
然后,我们将 imnodes 的源目录变量追加到已存在的外部源文件和包含目录列表中:
file(GLOB SOURCES
...
**${imnodes_SOURCE_DIR}****/imnodes.cpp**
)
...
**target_include_directories****(...** **${imnodes_SOURCE_DIR}****)**
作为最后一步,我们在现有的add_definitions行中添加一个编译器定义:
add_definitions(... -DIMGUI_DEFINE_MATH_OPERATORS)
通过设置IMGUI_DEFINE_MATH_OPERATORS,ImGui 将一些内部数学运算暴露给外部模块,如 imnodes。
运行 CMake 之后,大多数 IDE 在保存CMakeLists.txt文件后都会自动执行此操作,imnodes 就可用,并可以包含在用户界面中。现在,让我们学习如何使用 imnodes。
使用 imnodes 创建 UI 元素
由于 imnodes 基于 ImGui,imnodes 函数调用类似于 ImGui 调用,其中 imnodes 充当一个容器,将 UI 元素封装在图形节点表示中。如图图 9.2中所示的图形节点,可以动态地添加和删除到节点编辑器窗口中,并且可以在输入和输出引脚之间创建链接。

图 9.2:简单 imnodes 等待节点的元素
节点的元素包括标题栏和任意数量的输入引脚、输出引脚和静态元素。在标题栏中,显示一个描述性名称以识别节点类型,如图 9.2中的单词等待。等待节点的输入引脚用于将父节点连接到这个特定的节点,而在输出引脚上,可以连接一个或多个子节点。所有用户控件都是使用静态元素构建的,例如等待时间的滑块,或者如果等待节点被激活,显示时间向 0 运行的文本字段。
但是 imnodes 只维护节点和链接。我们必须根据我们的需求实现节点编辑器背后的逻辑。为了更好地了解创建这样一个节点编辑器需要多少——或者多少的——努力,我们将逐步展示如何在屏幕上绘制类似于图 9.2中的节点。
创建 imnodes 上下文
与 ImGui 一样,imnodes 需要一个关于当前会话的基本数据集,即上下文。imnodes 上下文必须在 ImGui 上下文之后创建,因为 imnodes 依赖于 ImGui 的数据。因此,UserInterface类的init()方法将通过 imnodes 的CreateContext()调用进行扩展:
ImGui::CreateContext();
**ImNodes::****CreateContext****();**
imnodes 的上下文需要在 ImGui 的上下文之前被销毁。为此,我们在UserInterface类的cleanup()方法中调用 imnodes 的DestroyContext(),在 ImGui 的DestroyContext()之前:
**ImNodes::****DestroyContext****();**
ImGui::DestroyContext();
在上下文准备就绪后,我们可以开始使用对 imnodes 的函数调用。类似于ImGui命名空间,ImNodes命名空间将用作前缀。
为 imnodes 设置默认值
选择全局颜色样式可以像在 ImGui 中一样进行。例如,要在init()方法中设置 ImGui 和 imnodes 的“light”颜色样式,使用以下行:
ImGui::StyleColorsLight();
ImNodes::StyleColorsLight();
可以在UserInterface类的init()方法中再次调用,以设置用于从节点断开链接的修改键。默认情况下,imnodes 使用Alt键来断开链接。通过以下两行,我们可以将键绑定设置为使用Control键:
ImNodesIO& io = ImNodes::GetIO();
io.LinkDetachWithModifierClick.Modifier =
&ImGui::GetIO().KeyCtrl;
在我们更改默认值(或未更改)之后,节点编辑器窗口本身将被创建。
创建节点编辑器
就像任何其他 ImGui 元素一样,节点编辑器必须在 ImGui 窗口内部创建。我们会在新的 ImGui 窗口开始后立即调用BeginNodeEditor():
ImGui::Begin("Node editor");
ImNodes::BeginNodeEditor();
现在,你应该会看到一个带有可见网格的窗口——节点编辑器。所有 imnodes 和 ImGui 调用都在此上下文中可用,尽管无法直接在节点编辑器上绘制 ImGui 元素,因为它们将覆盖节点编辑器元素。
要结束节点编辑器和 ImGui 窗口,我们在结束 ImGui 窗口之前调用EndNodeEditor()。如果我们想在窗口的左上角显示整个节点编辑器窗口的透明迷你图,我们可以在结束编辑器之前调用MiniMap():
ImNodes::MiniMap();
ImNodes::EndNodeEditor();
ImGui::End();
到这一点,节点编辑器窗口将看起来像图 9.3:

图 9.3:一个全新的节点编辑器窗口
在节点编辑器窗口内部,可以创建新的节点。作为一个简单的例子,我们将所有属性硬编码到节点中。在扩展节点编辑器部分,将添加一个上下文菜单,允许我们在运行时创建和删除节点。
添加一个简单的节点
可以通过调用BeginNode()来开始一个新的节点,使用一个int值作为节点的唯一标识:
const int nodeId = 1;
ImNodes::BeginNode(nodeId);
ImGui::Text("Sample Node");
ImNodes::EndNode();
就像在 ImGui 本身中一样,所有 imnodes UI 元素都必须设置一个唯一的 ID,以便 ImGui 可以区分。内部,imnodes 使用ImGui::PushID()调用为包含节点数据的组设置 ID。我们只需要确保节点编辑器中的所有元素都设置了唯一的标识。
在节点内部,我们可以使用所有 ImGui 元素来创建控制元素。一些 ImGui 元素可能会出现意外的行为。例如,ImGui::Separator()调用会在节点编辑器的右端绘制一条线。但通常的元素,如文本、按钮或滑块,都按预期工作。调用BeginDisable()和EndDisable()也正常工作,允许我们使节点的一部分不可用。
在节点中禁用 ImGui 元素时要小心
虽然可能很有诱惑力,但通过在 if 语句中包围它们来完全删除节点中不需要的 ImGui 元素,但请注意,imnodes 从分配给属性的 ID 计算输入和输出引脚的位置。如果您想删除属性或 ImGui 元素,请确保保留属性和剩余可见元素相同的 ID,否则链接将连接到错误的位置。
图 9.4 显示了之前找到的代码行的结果:

图 9.4:示例节点
除了可以通过按住左鼠标按钮移动之外,我们的新节点并没有什么用处。因此,让我们向节点添加一些元素,在 imnodes 中称为“属性”。
在节点中创建 imnodes 属性和 ImGui 元素
节点的所有属性必须在 BeginNode() 和 EndNode() 之间定义。确保跳过 添加简单节点 部分的 ImGui::Text() 行,因为它将破坏节点的布局。
我们首先添加的第一个 imnodes 属性是一个节点标题,通过使用 BeginNodeTitleBar():
ImNodes::BeginNodeTitleBar();
ImGui::TextUnformatted("A cool node title");
ImNodes::EndNodeTitleBar();
节点的标题显示在节点顶部的独立区域,并显示它是否未选中、悬停或选中。
接下来,我们通过调用 BeginInputAttribute() 创建一个输入引脚:
const int inputId = 2;
ImNodes::BeginInputAttribute(inputId);
ImGui::Text("in");
ImNodes::EndInputAttribute();
输入节点定义在节点的左侧创建一个“磁性”点,允许我们稍后连接链接。一个节点可以有从零到几乎无限的输入引脚,但最可能的情况是,只有少数引脚会被使用。
在输入引脚之后,我们使用 BeginStaticAttribute() 创建一个静态属性:
const int staticId = 3;
static bool checkboxState = false;
ImNodes::BeginStaticAttribute(staticId);
ImGui::Checkbox("A fancy checkbox", &checkboxState);
ImNodes::EndStaticAttribute();
静态属性在节点的两侧没有输入或输出连接器。您可以使用静态属性就像在 ImGui 窗口中使用其他任何控件元素一样。
最后,我们使用 BeginOutputAttribute() 添加一个输出引脚:
const int outId = 4;
ImNodes::BeginOutputAttribute(outId);
ImGui::Text(" out");
ImNodes::EndOutputAttribute();
与输入引脚一样,输出引脚创建一个连接器以停靠链接。但对于输出引脚,这个连接器位于节点的右侧。
在 图 9.5 中,显示了本节中代码行创建的结果节点:

图 9.5:更新的示例节点
更新的节点看起来已经很好了。我们有一个标题、输入和输出引脚,以及由状态变量控制的复选框——所有这些都在大约二十行代码中完成。
使用链接连接节点也很简单,正如我们现在将看到的。
维护节点之间的链接
imnodes 中的链接有三个属性:一个唯一的链接 ID、一个起始引脚 ID 和一个结束引脚 ID。唯一的链接 ID 用于识别和绘制链接本身,而引脚 ID 来自父节点(或源节点)的输出引脚相对于子节点(或目标节点)的输入引脚。
通过结合这三个 ID,imnodes 中的链接最好作为链接 ID 映射中引脚 ID 的对来管理:
std::map<int, std::pair<int, int>> links;
内部的 std::pair 存储了链接的起始引脚(输出)和结束引脚(输入)的 ID,顺序如下。通过外部的 std::map,我们创建了一个连接,将链接 ID 与引脚 ID 对连接起来。
首先输出,然后输入
值得注意的是,imnodes 严格遵循此顺序。第一个报告的引脚始终是父节点的输出引脚,第二个引脚是子节点的输入引脚。遵循此规则使得使用节点和链接创建有限状态机变得容易,并通过一些简单的规则维护节点树的状态。
通过调用 Link() 并使用本节前面提到的确切三个链接属性来绘制现有节点:
for (const auto& link : links) {
ImNodes::Link(link.first,
link.second.first, link.second.second);
}
现在映射中的所有链接都绘制为两端带有曲线的线条,连接父节点(对的第一元素)的输出引脚和子节点(对的第二元素)的输入引脚。
新节点在通过调用 EndNodeEditor() 结束编辑器后由 imnodes 通知。然后,可以使用两个 imnodes 调用来创建或删除链接。
可以通过调用 IsLinkCreated() 来请求当前帧中编辑器是否创建了新的链接:
int startId, endId;
if (ImNodes::IsLinkCreated(&startId, &endId)) {
linkId = findNextFreeLinkId();
links[linkId] = (std::make_pair(startId, endId));
}
如果 IsLinkCreated() 返回 true,则用户在两个节点之间创建了一个新的链接,并且我们可以将新的链接保存到映射中。findNextFreeLinkId() 如何搜索新的链接 ID 取决于应用程序的需求。您可以查看示例代码,这是一个简单的实现,它重新使用已删除链接的 ID。
如果一个现有的链接被断开然后删除,IsLinkDestroyed() 返回 true,并将删除的链接的 ID 作为输出参数返回:
int linkId;
if (ImNodes::IsLinkDestroyed(&linkId)) {
links.erase(linkId);
}
imnodes 有一些其他函数可以启用自定义链接管理,但对我们节点的编辑器来说,IsLinkCreated() 和 IsLinkDestroyed() 将足够使用。
复制我们示例节点的代码(请记住为每个属性使用唯一的 ID),并在第一个节点的输出引脚 ID 和第二个节点的输入 ID 之间添加一个链接,将得到类似于 图 9.6 的结果:

图 9.6:通过链接连接的两个示例节点
由于所有新节点默认情况下都创建在相同的位置,因此您必须将节点分开。在创建节点时设置初始位置旁边,imnodes 允许我们使用 SaveCurrentEditorStateToIniString() 将当前编辑器会话中所有节点的位置存储起来。可以通过调用 LoadCurrentEditorStateFromIniString() 在以后恢复位置。
在重新打开编辑器时,将节点放在相同的位置有助于创建出色的用户体验。
现在我们已经探讨了如何使用 imnodes 管理节点编辑器的图形部分,我们需要创建一个类来存储状态信息和 imnodes 绘图调用。这些新类是驱动实例行为的有限状态机的构建块。
创建图节点类
节点树类的基本元素是一个名为 GraphNodeBase 的抽象类,位于新的文件夹 graphnodes 中。所有其他节点类都将从基类继承,为节点必须处理的特定任务添加属性和逻辑。
与功能齐全的行为树相比,我们的简化版本将在节点本身中存储节点状态。在代码的单独部分维护节点状态会使实现更加复杂且难以理解。使用集成状态的唯一缺点是,对于每个实例,都需要复制整个节点树,因为节点树本身用作有限状态机。但节点对象很小;每个实例添加几个节点在内存空间和计算时间上只会产生微不足道的开销。
在我们深入更多细节之前,这里是一个列表,列出了本章示例代码中创建的所有节点类型:
- 根节点
根节点是每个节点树的起点,默认创建。由于它是整个树的起点,因此无法删除根节点。在有限状态机的执行开始时,首先激活根节点。此外,当没有其他节点处于活动状态时,根节点会再次触发以重新开始。由于不需要其他节点触发根节点,因此根节点只有一个输出引脚而没有输入引脚。
- 测试节点
测试节点有助于开发和调试节点树。与根节点一样,只有一个输出引脚,还有一个按钮来激活输出引脚。
- 调试日志节点
这是一个帮助构建节点树的另一个节点。目前,附加到实例的节点树无法加载到编辑器中进行实时调试会话。在“实践会话”部分中有一个任务,是实现将现有节点树加载到编辑器窗口的能力。要观察特定操作,可以添加一个调试日志节点,当激活时,会在系统控制台打印一行。
- 等待节点
等待节点已在图 9.2中作为示例展示。该节点将通过可配置的时间延迟在两个其他节点之间延迟执行。
- 随机等待节点
这类似于等待节点,但可以设置延迟时间的上限和下限,从而实现更随机的行为。
- 选择节点
选择节点也有一个固定的延迟,延迟时间过后,会激活一个随机的输出引脚。
- 顺序节点
顺序节点依次激活输出引脚,从引脚编号一开始。作为一个额外功能,顺序节点将等待活动输出上的子节点完成。目前,等待子节点仅实现了两种等待节点类型;可以添加更多类型或与孙节点一起级联。有关想法,请参阅“实践会话”部分。
- 动作节点
我们在第七章中添加了动作。动作节点允许我们在使用键盘和鼠标控制实例的同时触发动作,就像我们在第七章中所做的那样,使实例不仅能够四处走动,还能根据节点树的状态跳跃、翻滚、出拳或挥手。
- 事件节点
事件节点由发送到实例的外部事件触发,例如,当发生碰撞时。事件节点还有一个冷却计时器,以忽略相同事件的一小段时间,避免由于在树的每次更新中重复执行相同动作而导致的异常行为。
- 实例移动节点
实例节点允许我们在节点树中控制移动状态和移动方向、实例速度和实例旋转。使用实例节点是改变主移动状态(如空闲、行走和跑步)所必需的。此外,速度和旋转可以在上限和下限内随机化,以实现更非确定性行为。
在 图 9.7 中,显示了所有这些节点:

图 9.7:示例代码创建的所有节点的概述
节点类型旁边的数字是数值节点 ID。在出现错误或使用 DebugLog 节点时,拥有节点 ID 可能会有所帮助。
现在让我们检查 GraphNodeBase 类最重要的部分。
探索图节点的基础类
为了确保派生节点类实现最小功能集,GraphNodeBase 类在 graphnodes 文件夹中的头文件 GraphNodeBase.h 中声明了几个纯虚方法:
virtual void update(float deltaTime) = 0;
virtual void draw(ModelInstanceCamData modInstCamData) = 0;
virtual void activate() = 0;
virtual void deactivate(bool informParentNodes = true) = 0;
virtual bool isActive() = 0;
virtual std::shared_ptr<GraphNodeBase> clone() = 0;
virtual std::optional<std::map<std::string, std::string>>
exportData() = 0;
virtual void importData(
std::map<std::string, std::string> data) = 0;
第一种方法,update(),用于根据两个帧之间的时间差来改变节点的内部状态。例如,所有具有延迟的节点都会减少内部计数器,一旦时间达到 0,就会触发输出引脚。
在 draw() 方法内部,设置节点的外观。我们创建了一个示例节点,从 添加简单节点 这一部分开始。draw() 调用包含所有针对特定节点类型视觉的所有命令。
当计时节点的内部计时器达到 0,或者当连接到当前节点输入引脚的节点完成其执行时,将触发 activate()。在这里,设置节点功能的主要逻辑。
当需要从实例中移除控制实例行为的节点树时,我们可以通过调用 deactivate() 确保停止所有节点。如果不停止当前实例的有限状态机,如果节点为速度或移动状态等属性设置了值,可能会导致有趣的副作用和不受欢迎的行为。参数 informParentNodes 用于区分节点的有序关闭,即通知父节点(类型为 Sequence)子节点已完成执行,以及完全停止,此时应避免所有进一步的操作。
通过使用 isActive(),有限状态机的控制代码可以检查是否至少有一个节点仍在积极地进行某些操作,例如等待计时器达到 0。一旦没有活跃的节点,根节点将被触发。
需要使用 clone() 方法来创建当前节点实例的副本,包含所有设置。由于我们正在使用继承,使用复制构造函数来实现相同的结果会很难,因为无法访问 private 成员。一个虚拟复制方法使我们的生活变得更加容易,从而实现 1:1 的复制。
最后,exportData() 和 importData() 用于获取和设置任何节点实例的当前状态,这些实例设置了值得保存和恢复的任何值。内部,节点类型的值存储在 std::string 映射中,避免了映射中超过一个数据类型。此外,在 YAML 解析器中使用简单的字符串可以消除与磁盘上的原始文本数据交互时的任何转换。图节点类型知道如何编码和解码数据,将节点特定的保存和加载逻辑从 YAML 解析器中移除。
除了纯虚拟方法之外,在 GraphNodeBase.h 头文件中还声明和定义了一些非纯虚拟函数。这些方法仅对节点类型的一个小子集有用。因此,强迫所有节点类型实现该功能是没有意义的。
前三个方法,addOutputPin、delOutputPin 和 getNumOutputPins,用于处理序列和选择节点上输出引脚的动态数量:
virtual void addOutputPin() {};
virtual int delOutputPin() { return 0; };
virtual int getNumOutputPins() { return 0; };
所有三个方法名都一目了然。只有 delOutputPin() 的返回值可能需要解释:当从节点中删除输出引脚时,我们必须检查是否有任何链接连接到该引脚。通过返回刚删除的输出引脚的引脚 ID,可以通过搜索链接映射中的特定输出节点并删除所有受影响的链接来删除所有连接的链接。
其他三个虚拟方法,childFinishedExecution、listensToEvent 和 handleEvent,更是特别:
virtual void childFinishedExecution() {};
virtual bool listensToEvent(nodeEvent event)
{ return false; };
virtual void handleEvent() { };
序列节点类型等待连接到其输出引脚的子节点报告它们已完成执行。Wait 和 RandomWait 节点将通过调用 childFinishedExecution() 来通知其父节点状态变化。
只有事件节点实现了最后两个方法,listensToEvent() 和 handleEvent()。这两个方法名应该很容易理解。如果需要在检查节点是否会处理事件和实际事件执行之间进行一些准备工作,将它们分成两个单独的方法可能很有用。
作为派生类的示例,我们将检查等待节点的某些实现细节。
创建等待节点
当构建一个新的等待节点时,会设置一些默认值:
WaitNode::WaitNode(int nodeId, float waitTime) :
GraphNodeBase(nodeId) {
int id = nodeId * 1000;
mInId = id;
mStaticIdStart = id + 100;
mOutId = id + 200;
mWaitTime = waitTime;
mCurrentTime = mWaitTime;
}
我们在这里使用预定义的属性 ID 范围来简化进一步的编码并帮助调试问题。通过将节点 ID 乘以 1000,每个节点为每个节点创建最多 1000 个 ID 的空间,可用于输入和输出引脚,或静态元素,如滑块和按钮。每个树中的节点数量仅受 int 的存储容量的限制,我们还回收删除的节点 ID - 这对于节点和引脚来说已经足够多了,即使对于非常大的节点树也是如此。此外,通过简单地对任何引脚 ID 进行 1000 的整数除法,我们可以得到包含该特定引脚的节点 ID。这是在向引脚发送信号时识别节点的完美解决方案。
克隆等待节点只需要一行代码:
std::shared_ptr<GraphNodeBase> WaitNode::clone() {
return std::make_shared<WaitNode>(*this);
}
这种虚拟克隆被广泛使用,并使我们能够精确复制该等待节点,包括所有节点特定的设置。
draw() 方法代码可以跳过,因为我们只是使用滑块作为控制元素,而不是复选框。大多数的 draw() 代码与 添加简单节点 和 在节点中创建 imnodes 属性和 ImGui 元素 部分的代码相同,因此我们在这里可以跳过细节。
调用 activate() 开始等待计时器。将 private 布尔成员变量 mActive 设置为 true,并将另一个名为 mFired 的 private 布尔设置为 false:
void WaitNode::activate() {
if (mActive) {
return;
}
mActive = true;
mFired = false;
}
我们仅使用 mFired 来改变输出引脚的颜色,从白色变为绿色,在编辑器窗口中指示等待节点已通知连接到输出引脚的任何节点。
一旦将 mAcive 设置为 true,update() 方法开始递减 mCurrentTime 中的等待时间:
void WaitNode::update(float deltaTime) {
if (!mActive) {
return;
}
mCurrentTime -= deltaTime;
当等待时间低于 0 时,触发输入和输出引脚的信号被发送出去,等待时间被重置,节点被停用,输出引脚的颜色发生变化:
if (mCurrentTime <= 0.0f)
fireNodeOutputTriggerCallback(mOutId);
fireNodeOutputTriggerCallback(mInId);
mCurrentTime = mWaitTime;
mActive = false;
mFired = true;
}
}
在 创建图节点类 部分中讨论了触发输入引脚的原因:如果等待节点是序列节点的子节点,父序列节点需要知道等待节点不再活跃。
最后,通过 exportData() 和 importData() 这两个方法完成数据的导出和导入:
std::optional<std::map<std::string, std::string>>
WaitNode::exportData() {
std::map<std::string, std::string> data{};
data["wait-time"] = std::to_string(mWaitTime);
return data;
}
void WaitNode::importData(
std::map<std::string, std::string> data) {
mWaitTime = std::stof(data["wait-time"]);
mCurrentTime = mWaitTime;
}
这两种方法都很直接。我们只是在导出时将 float 存储在 std::string 中,并在导入时读取 float 值。
所有其他专用节点都是以类似的方式创建的,实现所需的方法,并在需要时增强其他虚拟方法。
应用程序中另一个著名的编码风格是使用回调来调用其他类的函数。GraphNodeBase 使用两个回调来处理事件。
使用回调来传播变化
通过激活第一个回调,节点通知有限状态机它已完成其执行,并且控制权应交给连接到输出引脚(s)的节点:
fireNodeOutputCallback mNodeCallbackFunction;
处理此回调的方法是管理节点状态进度的状态管理的主要部分。
真实的行为变化发生在第二个回调执行的方法中:
nodeActionCallback mNodeActionCallbackFunction;
此回调由实例和动作节点类型使用,在经过相当长的回调级联之后,通知渲染器类操作拥有节点树的实例的单个属性。
这里发生所有的魔法,允许实例模式改变移动状态为行走或奔跑,或者开始跳跃或挥手等动作。
现在,我们将更详细地查看将使用两个回调的更多代码。
创建行为 struct 和实例存储类
为了存储节点、链接和动作回调,将使用新的 struct BehaviorData,它位于 model 文件夹中的 BehaviorData.h 文件中:
struct BehaviorData {
std::vector<std::shared_ptr<GraphNodeBase>>
bdGraphNodes{};
std::unordered_map<int, std::pair<int, int>>
bdGraphLinks{};
std::string bdEditorSettings;
nodeActionCallback bdNodeActionCallbackFunction{};
};
节点和链接存储在基本的 STL 容器中,我们还在 struct 中存储编辑设置字符串,使我们能够在节点编辑器窗口中保存和恢复节点位置。nodeActionCallback 回调仅在中间形式下需要,以在节点中的回调和存储行为数据的 struct 类本身之间的回调之间创建链接链。
新的类 SingleInstanceBehavior 用于收集单个实例的行为控制的所有数据和方法。在这里,我们通过将 BehaviorData struct 的回调函数设置为本地方法 nodeActionCallback() 来创建 nodeActionCallback 回调的链:
mBehaviorData->bdNodeActionCallbackFunction = [this]
(graphNodeType nodeType, instanceUpdateType updateType,
nodeCallbackVariant data, bool extraSetting) {
nodeActionCallback(nodeType, updateType, data,
extraSetting);
};
当创建需要更改实例数据的类型节点时,将 BehaviorData struct 的 nodeActionCallback 设置在新的节点中:
newNode->setNodeActionCallback(
newBehavior.bdNodeActionCallbackFunction);
最后,在 nodeActionCallback() 方法中,传入的数据被转换以包含实例 ID,并执行另一个回调函数:
mInstanceNodeActionCallback(mInstanceId, nodeType,
updateType, data, extraSetting);
回调链将内部实例和动画状态与其控制的状态机解耦。通过解耦状态,节点运动或动作类型的节点可以请求更改位于某个行为中的实例的属性,而无需了解该节点是哪个实例。
节点动作的其余部分将在 扩展代码以支持行为变化 的部分实现,完成从节点树中的单个节点到渲染器的链。
对于第二个回调函数,SingleInstanceBehavior 类中使用了一个 lambda 表达式来将状态更新请求连接到本地方法:
mFireNodeOutputCallback = this
{ updateNodeStatus(pinId); };
在 updateNodeStatus() 方法内部,一些简单的逻辑通过使用作为参数提供的 pinId 来改变节点的活动状态。遵循“先输出,后输入”的规则以及 imnodes 的链接 ID 和 pin ID 的整数除法,我们可以检测信号是否来自输入或输出引脚,甚至找到调用 updateNodeStatus() 的节点。
如果updateNodeStatus()方法接收到一个带有输出引脚 ID 的参数的调用,所有连接的子节点将被激活。如果在输出引脚上找不到连接的子节点,我们将通知父节点执行已完成。这种特殊处理目前只与序列节点相关,使我们能够跳过没有连接的输出引脚。
对于一个输入引脚作为参数,将通知连接到该输入引脚的相应节点。目前这仅适用于序列节点子节点的执行结束时,但该功能可以扩展到新的节点类型。
没有日志调用和注释,整个updateNodeStatus()方法大约只有四十行,但它仍然完成了整个行为树实现的主要工作。
为了简化新节点的创建,我们将使用工厂模式。节点工厂封装了创建指定类型新节点所需的所有逻辑。此外,我们还将通过上下文菜单和在不同节点树之间切换的能力来增强编辑器。
添加节点工厂
工厂模式是创建从单个基类派生出的对象的一个很好的解决方案,它将所有创建逻辑保持在单一位置。
graphnodes文件夹中的工厂类GraphNodeFactory小巧简单,类似于其他工厂类。在创建工厂对象时,适当的fireNodeOutputCallback被注入到构造函数中,帮助我们为所有新节点添加正确的回调目标。构造函数还添加了所有节点类型与节点标题区域中名称之间的映射。这种名称映射使我们免去了在节点创建时添加节点名称的需要;我们只需要节点类型和一个唯一的节点 ID 来构建一个新节点。
makeNode()方法通过根据给定的节点类型创建一个新的派生类来完成所有工作,添加回调、映射的节点名称和所选派生类的节点类型。像所有工厂一样,返回的智能指针是基类类型,允许我们将所有新节点存储在基类类型的 STL 容器中。
扩展节点编辑器
为了更好地处理节点,我们在编辑器中创建了一个上下文菜单。可以通过在节点编辑器窗口中按下鼠标右键来打开上下文菜单:
const bool openPopup = ImGui::IsWindowFocused(
ImGuiFocusedFlags_RootAndChildWindows) &&
ImNodes::IsEditorHovered() &&
ImGui::IsMouseClicked(ImGuiMouseButton_Right);
实际上,我们创建了两个不同的上下文菜单。显示哪个菜单取决于我们是否悬停在节点上:
if (openPopup) {
if (ImNodes::IsNodeHovered(&mHoveredNodeId)) {
ImGui::OpenPopup("change node");
} else {
ImGui::OpenPopup("add node")
}
}
如果在按下鼠标右键时没有悬停在现有节点上,将创建一个 ImGui 弹出窗口以添加新节点,列出所有可用的节点类型(除了仅存在一次的根节点)。如果悬停在节点上,将显示不同的 ImGui 弹出窗口,其中包含可以对悬停节点执行的操作。
图 9.8显示了并排的两个上下文菜单:

图 9.8:编辑器的两个上下文菜单
更改节点菜单中显示哪些选项取决于节点类型。对于序列和选择节点,输出引脚的数量可以动态更改,因此这两个选项出现,而对于所有其他节点,只显示停用和删除。停用和删除节点的可用性基于节点的活动状态。例如,在等待时间到期之前,你不能删除一个活动等待节点。
要将现有的节点树编辑到现有的编辑器窗口中,我们添加了一个名为loadData()的新方法:
void loadData(std::shared_ptr<BehaviorData> data);
在loadData()内部,创建了一个新的SingleInstanceBehavior和一个新的GraphNodeFactory对象,以便以相同的方式编辑节点树,就像一个全新的节点树一样。
编辑器还有一个update()方法,使其表现得像一个正在运行的节点树,根据节点的状态更新所有节点的属性:
void updateGraphNodes(float deltaTime);
最后,我们添加了一个名为mShowEditor的标志来控制编辑器的可见性。当创建一个新的节点树或编辑现有的树时,窗口会出现在屏幕上。通过点击编辑器的关闭按钮,或者当当前编辑的节点树被删除时,我们隐藏编辑器窗口。
完成基于 imnodes 的视觉节点编辑器的实现最后一步是向YamlParser类添加所有必要的数据。我们不希望在每次应用程序启动时都从一个空编辑器开始。
保存和加载节点树
为新行为数据创建所需的模板和重载与我们在前面的章节中所做的大致相同。
要将行为数据添加到 YAML 文件中,需要在YamlParser.cpp文件中添加一个输出流操作符的重载。该文件位于tools文件夹中:
YAML::Emitter& operator<<(YAML::Emitter& out,
const BehaviorData& behavior) {
大部分代码可以复制自之前定义的重载。通过exportData()创建的字符串映射使得保存节点的状态变得容易:
if (node->exportData().has_value()) {
std::map<std::string, std::string> exportData =
node->exportData().value();
...
}
通过在exportData()的返回值中使用std::optional,我们可以轻松地跳过 YAML 文件中没有任何状态要保存的整个部分。如果没有optional关键字,我们需要额外的检查来确定节点状态是否需要保存。
对于新行为数据的加载部分,我们必须在YamlParserTypes.h文件中添加一个新的convert模板:
template<>
struct convert<ExtendedBehaviorData> {
static Node encode(const ExtendedBehaviorData& rhs) {
...
}
static bool decode(const Node& node,
ExtendedBehaviorData& rhs) {
...
}
}
我们使用BehaviorData struct的扩展版本,因为我们只存储节点类型,而 YAML 解析器不是创建新节点的正确地方:
struct PerNodeImportData {
int nodeId;
graphNodeType nodeType;
std::map<std::string, std::string> nodeProperties{};
};
struct ExtendedBehaviorData : BehaviorData {
std::vector<PerNodeImportData> nodeImportData;
};
在加载保存的文件时,节点将在渲染器类中重新创建,就像创建模型、实例和相机一样。
大部分的convert代码可以来自之前实现的模板。但decode()方法有一个需要注意的地方。与节点数据的类型(字符串映射)不同,yaml-cpp库要求我们使用包含每行节点数据单个条目的向量映射。以下代码展示了如何进行解析:
std::vector<std::map<std::string, std::string>> entry =
nodeDataNode.as<std::vector<std::map<std::string,
std::string>>>();
所有向量条目都将添加到中间PerNodeImportData struct的nodeProperties变量中:
if (entry.size() > 0) {
for (const auto& mapEntry : entry) {
nodeData.nodeProperties.insert(mapEntry.begin(),
mapEntry.end());
}
}
在渲染器中,我们通过创建一个新的SingleInstanceBehavior实例、一个新的BehaviorData struct以及保存类型和 ID 的新节点,以及重新加载保存的属性来恢复行为节点树。在重新创建链接并将链接和编辑设置导入新的BehaviorData之后,节点树的状态与保存时相同。
恢复顺序:行为在实例之前
在恢复实例之前,我们必须恢复行为节点树数据,因为如果在保存配置数据之前设置了行为,行为数据将被复制到实例中。
一旦节点编辑器本身准备就绪,我们需要连接节点树和实例。因此,让我们添加缺失的部分,让实例变得活跃起来。
扩展代码以支持行为变化
要完全支持在实例中实现计算机控制的行为,还有一些步骤要做。首先,我们需要每个实例中都有一个节点树的副本。
为每个实例创建节点树副本
我们不能只是复制节点,因为原始副本会访问共享指针后面的相同节点。对于多个实例重用相同的节点会导致混乱,因为所有实例的碰撞事件都会被触发,导致所有共享节点的实例执行相同的步骤。
要为实例之一创建节点树的副本,已经为SingleInstanceBehavior类创建了一个自定义的复制构造函数。复制构造函数设置了所需的回调,复制了链接,并通过遍历现有节点的向量来创建每个节点的克隆。对于改变实例行为的节点,将设置额外的节点操作回调。
接下来,必须完成到渲染器的回调链。目前,只有SingleInstanceBehavior类对象被告知一个节点想要向实例发送属性更改请求。
连接 SingleInstanceBehavior 和渲染器
实例行为的副本将由位于model文件夹中的新BehaviorManager类来管理。BehaviorManager类维护实例 ID 与其使用的节点树副本之间的映射。此外,我们还有一个新的回调来保持渲染器在循环中更新节点树的节点属性:
std::map<int, SingleInstanceBehavior>
mInstanceToBehaviorMap{};
instanceNodeActionCallback mInstanceNodeActionCallback;
为了更新实例的所有节点树的状态,BehaviorManager类中存在一个update()方法:
void BehaviorManager::update(float deltaTime) {
for (auto& instance : mInstanceToBehaviorMap) {
instance.second.update(deltaTime);
}
}
我们只是遍历所有实例并调用SingleInstanceBehavior对象的update()方法,该方法更新节点树的所有节点。
在渲染器中,在渲染器的init()方法中添加并初始化了一个名为mBehaviorManager的private成员:
mBehaviorManager = std::make_shared<BehaviorManager>();
mInstanceNodeActionCallback = this {
updateInstanceSettings(instanceId, nodeType, updateType,
data, extraSetting);
};
mBehaviorManager->setNodeActionCallback(
mInstanceNodeActionCallback);
可以通过传递实例 ID 和节点树到BehaviorManager类的addInstance()方法来添加模型实例:
void addInstance(int instanceId,
std::shared_ptr<SingleInstanceBehavior> behavior);
在addInstance()方法中,我们复制树中的所有节点,将回调设置为渲染器,并将实例 ID 添加到SingleInstanceBehavior对象中。
现在,任何实例或动作节点都可以调用绑定到其nodeActionCallback成员的函数。请求沿着回调链向上传递,最终结束于渲染器的updateInstanceSettings()方法,其中包含所有用于渲染器更改实例属性的信息。
最后,我们必须定义所有我们想要发送到实例的事件,以及触发这些事件的代码放置位置。
添加事件
为了支持事件,在opengl文件夹(或 Vulkan 的vulkan文件夹)中的Enums.h文件中创建了一个新的enum类nodeEvent:
enum class nodeEvent : uint8_t {
none = 0,
instanceToInstanceCollision,
instanceToEdgeCollision,
NUM
};
首先,我们定义两个值,称为instanceToInstanceCollision和instanceToEdgeCollision,用于通知实例发生了与其他实例或世界边界的碰撞。第一个值none用于忽略事件节点中的事件,而NUM值在for循环中需要遍历所有值。
在ModelInstanceCamData结构体中,添加了一个名为micNodeUpdateMap的映射,用于将nodeEvent值转换为可读的字符串,用于事件:
std::unordered_map<nodeEvent, std::string>
micNodeUpdateMap{};
渲染器不仅会在init()方法中添加字符串,而且所有触发事件的调用都会从渲染器发送到节点树:
mModelInstCamData.micNodeEventCallbackFunction(
instSettings.isInstanceIndexPosition,
nodeEvent::instanceToEdgeCollision);
这个新的micNodeEventCallbackFunction回调绑定到渲染器的addBehaviorEvent()方法,并在将请求链接到现有对象后,调用结束于SingleInstanceBehavior类。在那里,所有请求的事件都被添加到一个向量中:
std::vector<nodeEvent> mPendingNodeEvents;
事件在SingleInstanceBehavior类的update()调用中处理,调用节点的handleEvent()方法。handleEvent()方法在探索图节点基类部分中引入。它只是激活指定实例图中所有监听此事件类型的节点事件。
包含事件的示例节点树如下所示:

图 9.9:女性模型的节点树概述
我们将节点树的这个图像分成了两部分,在图 9.10 和图 9.11中,以便更好地理解。
图 9.10显示了节点树中的默认路径,从根节点开始。

图 9.10:女性模型节点树中的默认路径
图 9.11显示了事件节点以及事件反应。

图 9.11:女性模型节点树中的事件
在图 9.12中,展示了图 9.9中女性模型的树形结构以及男性模型类似树形结构的结果行为:

图 9.12:生活在虚拟生活中的实例
默认情况下,模型正在四处走动,通过稍微旋转来响应与其他实例的碰撞。在与世界边界碰撞后,实例会转身。经过随机时间后,实例停止移动,并播放一个随机的动画片段。此时,正常执行结束,并再次触发根节点。
虽然节点编辑器看起来真的很酷,我们可以添加不同类型的节点,创建链接,并保存和加载状态,但应该注意当前实现的某些限制。
当前实现的限制
我们的基本实现缺少其他行为树的一些功能。
首先,状态直接存储在节点中。对于我们所创建的应用程序的使用案例,在节点中存储状态数据是足够的。但对于更大的树,单独的状态存储变得方便,主要是因为我们不再需要将节点数据复制到实例中。
此外,编辑会话中节点树的变化不会自动复制到实例中。您需要选择一个实例或模型,并在每次更改后应用当前状态。
由于只将编辑会话的原节点树数据保存到 YAML 文件中,而不是各个实例的节点树数据,因此重新启动应用程序或重新加载配置会将编辑状态应用到使用特定节点树的所有实例。
最后,节点无法访问其实例或其他虚拟世界的数据。对位置或旋转等属性做出反应,或查看您是否是碰撞的主要或次要候选者以调整旋转角度,是不可能的。
许多这些限制可以通过一些努力和额外的编码来消除。请参阅实践课程部分以获取想法。
在本章的最后部分,我们将使用到目前为止添加的代码来创建一个新功能:实例之间的交互。
在实例之间添加交互
我们已经有一个名为Interaction的动作,用于启动男性模型的交互动画片段(而对于女性模型,由于模型没有显示某种交互的动画片段,因此对女性模型没有可见的操作)。
这个交互动作将被扩展,向附近的实例发送一个事件,表示我们想要与该实例交互。对交互请求的可能反应可以是附近实例上男性模型 waving 动画片段的重放,从而在视觉上确认事件已被处理。
创建交互控制属性
渲染器需要一些变量和一些代码来支持交互。我们在opengl文件夹中的OGLRenderData.h struct中添加了这些新变量:
bool rdInteraction = false;
float rdInteractionMaxRange = 10.0f;
float rdInteractionMinRange = 1.5f;
float rdInteractionFOV = 45.0f;
std::set<int> rdInteractionCandidates{};
int rdInteractWithInstanceId = 0;
和往常一样,对于 Vulkan,变量必须添加到VkRenderData struct中,该struct位于VkRenderData.h文件中。
使用这些新变量,我们可以开启和关闭交互,以及为其他实例设置最小和最大扫描范围和一个对等实例需要在其中的视场角。视场角允许我们仅选择我们正在看的实例,以及朝向我们看的实例。
在std::set的int值中,我们存储所有在配置范围内的实例和正确的视角。最后,我们存储我们选择与之交互的实例。
交互候选者选择在渲染器的findInteractionInstances()中处理。像碰撞检测一样,我们通过缩小选择属性从所有实例钻取到单个实例。
使用一个简单的算法来找到适合交互的实例:
-
使用最大尺寸的
BoundingBox2D对象,并调用 quadtree 的query()方法。现在我们有了该区域的所有实例。 -
所有在最小范围内的实例都被筛选出来,包括我们自己。
-
通过使用我们自己的旋转角度与第 2 步中我们与剩余实例之间的距离向量的点积,移除所有朝向不正确的实例。
-
在第 3 步中留下的所有实例与我们之间的距离按升序排序,最近的实例被设置为交互实例。
如果任何步骤没有返回有效的候选者,交互请求将被忽略。
扩展处理代码
多亏了我们的所有准备工作,添加新事件现在变得极其简单。首先,我们通过新的交互值扩展nodeEvent enum:
enum class nodeEvent : uint8_t {
...
**interaction,**
NUM
};
然后,当按下交互键时,我们将交互事件发送到中央行为类,并且我们的模型播放交互动画片段:
if (glfwGetKey(mRenderData.rdWindow, GLFW_KEY_U) ==
GLFW_PRESS) {
if (mRenderData.rdInteractWithInstanceId > 0) {
mBehaviorManager->addEvent(
mRenderData.rdInteractWithInstanceId,
nodeEvent::interaction);
}
}
最后,我们在人的模型节点树中创建一个新的事件节点,设置波浪动作。新的波浪动作被两个其他动作包围,这两个动作设置实例的空闲状态,以便实现平滑的动画混合,以及两个等待节点,使我们能够完成动画(我们还没有从实例到节点树的反馈)。
节点树的结果部分可以在图 9.13中看到:

图 9.13:通过播放挥手动画对交互事件做出反应
那就结束了!切换到查看模式并按另一个人的实例附近的U键将指示该实例停止并挥手向我们。在挥手动画之后,节点树的这部分将结束,恢复正常行为。
绘制调试信息
与所有之前的添加一样,在没有适当的调试信息的情况下使新功能工作可能会非常耗时。对于交互,我们可以利用 AABB 绘制代码在实例候选者周围绘制边界框,并且我们可以使用线网格在地面绘制一个正方形,显示最小和最大搜索区域的尺寸。
图 9.14显示了调试线的示例,使用第一人称摄像机捕获:

图 9.14:用户与另一个实例的交互
在行为和交互到位后,虚拟世界看起来和感觉比以往任何时候都更加生动。实例独自四处走动,对碰撞做出反应,并在随机时间执行各种动作。我们甚至可以问候一些实例——它们也会向我们问候。在虚拟世界中飞翔,观看实例,非常有趣。
概述
在本章中,我们在虚拟世界中创建了一种简单的 NPC 形式。我们首先探索了行为树和状态机的基本性质,为了有一个创建控制行为的状态机的工具,我们添加了一个基于 ImGui 的可视节点编辑器。然后,我们添加了不同节点类型的类,并将这些新类集成到现有代码中,包括节点工厂和节点编辑器扩展。最后,我们添加了与其他虚拟世界实例的交互。
在下一章中,我们将回到动画方面:我们将添加形态动画形式的加法混合来创建面部动画。首先,我们将讨论什么是形态动画,以及我们必须注意哪些约束。然后,我们将导入 glTF 模型中的现有形态动画,并将动画工作卸载到 GPU 上,类似于其他动画。最后一步,我们将结合面部动画和交互,创建可以显示它们是否同意或不同意我们的实例。
实践课程
这里是一些你可以添加到代码中的改进:
-
将撤销/重做功能扩展到节点树。
-
除了现有的撤销/重做操作外,添加代码以支持在节点树中回滚和重新回滚任何操作。能够撤销意外更改,例如删除一个连接或节点,将会很有帮助。
-
创建一个应用更改按钮以更新实例。
实现一种更简单的方法来更新使用此树的所有实例中更改的节点树的副本。
- 从运行实例中加载节点树到编辑器中。
这对于调试目的来说是个好主意,但也许也很有趣,可以实时观察实例的节点树中发生了什么。
- 添加加载的节点树的实时调试可视化。
可视调试可以帮助理解节点树中发生了什么。你可以从突出显示所选实例的图中当前活动的节点开始(参见上一个任务),稍后也可以突出显示当前活动的输出引脚以及指向其他节点的输出连接。
- 对 imnodes 中创建的新链接做出反应。
而不是创建一个新的节点然后放置一个链接,当创建一个新链接但未连接时添加上下文菜单以创建节点。将悬空链接直接连接到新节点的输入或输出引脚。
- 让序列节点也等待孙子节点。
你可以添加关于整个子序列执行完成的通知级联,而不仅仅是等待和随机等待节点——也许可以添加一个额外的复选框来控制链中哪个子节点是最后一个等待的。
- 添加条件和循环节点。
增加更多控制。条件节点需要访问它应该对其做出反应的数据,无论是来自实例、世界还是来自其他节点。循环节点也必须等待子节点完成。
- 允许重命名和复制/粘贴整个节点树,或者在不同编辑器窗口之间进行。
通过添加重命名现有节点树的能力来扩展当前实现。创建整个节点树的副本可能有助于复制和调整现有行为,并且两个编辑器会话之间的节点或整个选择复制/粘贴操作将非常方便。
- 在编辑器窗口中显示多个树节点。
要么通过使用可停靠的 ImGui 窗口,要么只是打开更多的编辑器窗口,让用户能够同时处理多个节点树。
注意:如果显示多个窗口,则内部 ImGui IDs 必须在所有窗口的 UI 元素中是唯一的。
- 增强难度:集成完整的 C++ 行为树库。
而不是这个小型且手工制作的节点树版本,添加一个功能齐全的行为树库,并为树管理添加 imnodes 支持。
其他资源
-
行为树:打破误用的循环:
takinginitiative.net/wp-content/uploads/2020/01/behaviortrees_breaking-the-cycle-of-misuse.pdf -
一个简单的 C++ 行为树示例:
lisyarus.github.io/blog/posts/behavior-trees.html -
带有 ImGui 的节点图编辑器:
github.com/ocornut/imgui/issues/306 -
ImGui 的 imnodes 扩展:
github.com/Nelarius/imnodes
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation

第十章:高级动画混合
欢迎来到第十章!在前一章中,我们为实例添加了一些更多的现实生活行为。在简要概述了行为树之后,我们通过使用简单的有限状态机,以可视化的方式绘制实例的行为。在章节末尾,我们扩展了代码并实现了交互作为行为的一种附加形式。
在本章中,实例将更加栩栩如生。我们首先简要探索由形态目标动画制作的面部动画世界。然后我们将添加额外的功能,将形态网格加载到应用程序中,并能够控制实例的面部动画。接下来,我们将添加一个图节点,以便能够在节点树中使用面部动画。在章节末尾,我们将实现加法混合,使我们能够独立于骨骼和面部动画移动实例的头部。
在本章中,我们将涵盖以下主题:
-
如何动画面部表情
-
将面部动画添加到代码和 GPU 着色器中
-
在节点树中使用面部动画
-
实现加法混合
技术要求
示例代码位于chapter10文件夹中,在01_opengl_morphanim子文件夹中用于 OpenGL,在02_vulkan_morphanim子文件夹中用于 Vulkan。
如何动画面部表情
在第九章中实现行为后,我们虚拟世界中的生活变得更加生动。实例可以自己行走或奔跑,在随机时间执行简单任务,对与其他实例的碰撞做出反应,并且不会离开虚拟世界的无形边界。
但是,实例仍然显得有些单调和无生气。它们像机器人一样四处游荡,总是向前看,保持直面的表情。没有可见的情感,也没有对其他交互的反应,除了播放挥手动画。
因此,让我们通过添加面部表情来赋予实例展示情感的能力。为任何类型的活生生虚拟对象添加面部动画最常见的方式是形态目标动画。
为了了解形态目标动画的概念,这里有一个简单的例子。在图 10.1中,显示了形态目标动画的三个不同权重:

图 10.1:愤怒形态目标动画过程中的三个不同权重
左侧的面部应用了 0%的愤怒形态目标动画,只显示原始面部。中间的面部将形态目标应用于 50%,在原始面部和完整形态之间进行半混合,而对于右侧的面部,已经应用了完整的形态目标动画。
如您所见,眉毛已经旋转,嘴巴顶点在最终状态中略微向上移动,顶点位置仅在原始网格和最终网格之间进行插值。但这些微小的顶点位置变化为模型创造了一个完全不同的面部表情。
变形目标动画在你的工具中可能有不同的名称,例如顶点动画、形状插值、形状键或混合形状。所有这些名称都描述了相同的技巧:将多个变形版本的网格存储在关键帧中,而网格的动画是通过在关键帧的位置之间插值顶点位置来完成的。
当涉及到成本时,骨骼动画和变形目标动画之间存在一个重要的区别:骨骼动画仅影响模型节点的属性,而变形动画则替换了模型虚拟皮肤的整个网格,并且需要为模型应播放的每个变形动画复制网格,从而增加了模型在磁盘和内存中的整体大小。
模型有相当少的骨骼但有很多顶点用于皮肤,所以每帧重新计算大量顶点的位置会增加变形动画的计算成本。幸运的是,变形动画完全发生在顶点着色器中,并且只是两个保存为向量的位置之间的线性插值。因此,变形动画的额外计算负担在我们的示例代码中仍然是微不足道的。
在 Blender 中,可以通过形状键选项在数据标签页上控制变形目标动画。图 10.2显示了用于图 10.1右侧面的设置,值设置为1.000以应用 100%的愤怒变形:

图 10.2:Blender 中控制变形目标动画的形状键
在 Blender 中创建和修改基于形状键的变形目标动画超出了本书的范围。Blender 有一些关于形状键的基本文档,链接包含在附加资源部分,并且有很多视频展示了如何在 Blender 中使用形状键。
如果你有一个包含变形目标动画的模型文件可用,比如第十章中assets文件夹中的两个模型,或者如果你在任何一个现有的模型中创建了你自己的一套变形动画,你就可以进行下一步:使用 Open Asset Importer 库导入这些额外的动画。
现在我们来学习如何将变形目标动画添加到我们的应用程序中。
将面部动画添加到代码和 GPU 着色器中
变形目标动画数据在 Assimp 模型文件中存储在两个地方。
变形动画数据的第一部分,即网格,位于aiMesh节点的mAnimMeshes数组中,网格的数量存储在aiMesh的mNumAnimMeshes变量中。mAnimMeshes数组的每个元素都包含与原始网格完全相同的顶点数,这使得我们能够在不同版本的网格之间插值顶点位置。
这种插值不仅限于在原始网格和形态目标网格之一之间混合。还可能混合两个形态目标网格,或者混合超过两个网格的位置。请注意,混合网格的结果可能并不总是像预期的那么好,因为形态目标动画的效果很大程度上取决于动画师的意图。
形态动画数据的第二部分,关键帧数据,位于aiAnimation节点的mMorphMeshChannels数组中,该数组中存储了关键帧的数量,在变量mNumMorphMeshChannels中。每个关键帧中的键包含特定关键的时间点,以及要使用的形态网格编号和线性插值中的形态网格权重。
我们将只使用网格数据在不同的面部表情之间进行插值,因此我们忽略了形态网格的动画数据。但很容易在本书的代码之上添加对形态目标动画的支持。
作为走向形态动画的第一步,我们将学习如何加载额外的网格数据并提取顶点。
加载形态网格
由于形态网格中的每个顶点都替换了原始网格中相同顶点的位置和法线,因此只需要替换顶点的子集数据。我们将在opengl文件夹中的OGLRenderData.h文件中创建一个名为OGLMorphVertex的轻量级顶点版本,只包含位置和法线:
struct OGLMorphVertex {
glm::vec4 position = glm::vec4(0.0f);
glm::vec4 normal = glm::vec4(0.0f);
};
为了将替换的形态顶点收集到一个网格中,我们还创建了一个新的struct,名为OGLMorphMesh,它包含一个std::vector中的所有顶点:
struct OGLMorphMesh {
std::vector<OGLMorphVertex> morphVertices{};
};
由于所有形态网格都依赖于原始网格,我们在默认网格struct OGLMesh中添加了一个OGLMorphMesh向量:
struct OGLMesh {
...
**std::vector<OGLMorphMesh> morphMeshes{};**
};
对于 Vulkan,这两个新的struct分别命名为VkMorphVertex和VkMorphMesh,位于vulkan文件夹中的VkRenderData.h文件中。VkMorphMesh向量被添加到VkMesh struct中。
在AssimpMesh类的processMesh()方法结束前,我们添加了一个新的代码块来从模型文件中提取形态网格数据。首先,我们检查当前网格是否附加了任何形态网格:
int animMeshCount = mesh->mNumAnimMeshes;
if (animMeshCount > 0) {
如果我们找到了形态网格,我们将遍历所有形态网格,提取网格数据和顶点数:
for (unsigned int i = 0; i < animMeshCount; ++i) {
aiAnimMesh* animMesh = mesh->mAnimMeshes[i];
unsigned int mAninVertexCount =
animMesh->mNumVertices;
根据定义,形态网格中的顶点数必须与原始网格中的顶点数相匹配。在这里进行额外的检查并不会造成伤害,如果在检测到顶点数不匹配时跳过整个形态网格并打印错误信息:
if (animVertexCount != mVertexCount) {
Logger::log(1, "%s error: morph mesh %i vertex
count does not match (orig mesh has %i vertices,
morph mesh %i)\n",
__ FUNCTION__, i, mVertexCount, animVertexCount);
continue;
}
接下来,我们检查形态网格是否包含位置数据,如果检查成功,则创建一个临时的OGLMorphMesh:
if (animMesh->HasPositions()) {
OGLMorphMesh newMorphMesh{};
检查形态网格是否有顶点位置可能听起来很愚蠢,但形态网格也可以覆盖其他数据,例如法线、颜色或纹理位置。可能会遇到没有位置数据的形态网格。
然后,我们遍历所有顶点并提取顶点位置:
for (unsigned int i = 0; i < animVertexCount; ++i) {
OGLMorphVertex vertex;
vertex.position.x = animMesh->mVertices[i].x;
vertex.position.y = animMesh->mVertices[i].y;
vertex.position.z = animMesh->mVertices[i].z;
如果形态网格中也存储了法线数据,我们提取法线。如果没有法线数据,我们将顶点法线设置为零:
if (animMesh->HasNormals()) {
vertex.normal.x = animMesh->mNormals[i].x;
vertex.normal.y = animMesh->mNormals[i].y;
vertex.normal.z = animMesh->mNormals[i].z;
} else {
vertex.normal = glm::vec4(0.0f);
}
最后,我们将顶点放入临时的OGLMorphMesh中,处理完所有顶点后,将OGLMorphMesh添加到OGLMesh对象的morphMeshes向量中,对于这个AssimpMesh对象:
newMorphMesh.morphVertices.emplace_back(vertex);
}
mMesh.morphMeshes.emplace_back(newMorphMesh);
}
}
}
现在访问任何替代形态网格就像检查morphMeshes向量的大小是否大于零一样简单,如果我们有任何形态网格,我们可以提取顶点数据并在原始顶点的位置和法线与所选形态网格的顶点之间进行插值。
到目前为止,在模型文件中找到的所有有效形态网格都作为AssimpMesh网格数据的一部分可用。为了使用这些形态网格进行面部动画,我们必须在应用程序中添加一些代码和逻辑。
将所有形态网格存储在单个缓冲区中
要在着色器中使用形态网格的顶点,我们将所有形态网格的顶点数据存储到一个单独的 SSBO 中。使用单个 SSBO 是必要的,因为模型实例在屏幕上的实例化渲染需要我们始终能够访问所有网格的顶点数据——因为我们无法确定何时特定的模型实例会被绘制到屏幕上。根据选定的形态网格分割渲染也是可能的,但这将是一个相当昂贵的替代方案,因为我们必须在每次绘制调用中过滤实例。
形态网格 SSBO 和一些相关变量被添加到AssimpModel类中。首先,在AssimpModel.h头文件中添加了三个新的private变量:mNumAnimatedMeshes、mAnimatedMeshVertexSize和mAnimMeshVerticesBuffer:
unsigned int mNumAnimatedMeshes = 0;
unsigned int mAnimatedMeshVertexSize = 0;
ShaderStorageBuffer mAnimMeshVerticesBuffer{};
在mNumAnimatedMeshes中,我们存储该模型形态网格的数量。目前,代码只支持包含形态网格的单个网格,所以mNumAnimatedMeshes中的数字等于这个特定网格中形态网格的数量。
但由于我们只进行面部动画,单个网格的形态网格限制并不成问题。此外,在实践课程部分有一个任务,可以相应地扩展代码并添加对包含形态网格的多网格的支持。
mAnimatedMeshVertexSize中的值用于在 SSBO 中找到所选形态剪辑的顶点数据的起始位置。通过乘以网格顶点大小和形态剪辑的索引,我们可以直接跳转到形态剪辑的第一个顶点。
最后,所有顶点数据都存储在mAnimMeshVerticesBuffer SSBO 中。
我们还在AssimpModel.cpp类的实现文件中添加了两个名为hasAnimMeshes()和getAnimMeshVertexSize()的public方法。多亏了描述性的方法名称,不需要进一步解释。
在AssimpModel类的loadModel()方法中完成填充 SSBO。当所有网格都收集到mModelMeshes向量中时,我们可以遍历所有网格,将顶点数据添加到新的缓冲区:
for (const auto& mesh : mModelMeshes) {
if (mesh.morphMeshes.size() == 0) {
continue;
}
在mAnimMeshVerticesBuffer SSBO 中收集顶点的第一步是检查这个网格中是否有任何形态网格。如果没有额外的形态网格的网格,我们就简单地继续到下一个网格。
然后,我们创建一个临时的OGLMorphMesh,名为animMesh,以收集所有顶点并调整animMesh中的morphVertices向量的大小:
OGLMorphMesh animMesh;
animMesh.morphVertices.resize(
mesh.vertices.size() * mNumAnimatedMeshes);
现在,我们可以将形态网格的所有顶点复制到animMesh中,使用顶点数来计算正确的位置:
for (unsigned int i = 0; i < mNumAnimatedMeshes; ++i) {
unsigned int vertexOffset = mesh.vertices.size() * i;
std::copy(mesh.morphMeshes[i].morphVertices.begin(),
mesh.morphMeshes[i].morphVertices.end(),
animMesh.morphVertices.begin() + vertexOffset);
mAnimatedMeshVertexSize = mesh.vertices.size();
}
最后,我们将收集到的顶点上传到 SSBO:
mAnimMeshVerticesBuffer.uploadSsboData(
animMesh.morphVertices);
}
现在我们需要这个模型的形态动画时,我们可以将缓冲区绑定到指定的着色器绑定点。这种绑定可以通过使用新的public方法bindMorphAnimBuffer()来实现:
void AssimpModel::bindMorphAnimBuffer(int bindingPoint) {
mAnimMeshVerticesBuffer.bind(bindingPoint);
}
要在实例级别上使用面部动画,我们必须添加一些变量和方法,并扩展一些数据结构。
将面部形态设置添加到代码中
启用面部动画最重要的更改是名为faceAnimation的新enum class,它位于Enums.h文件中:
enum class faceAnimation : uint8_t {
none = 0,
angry,
worried,
surprised,
happy
};
所有支持的形态动画都存储在faceAnimation enum中,与动作或节点类型相同。我们不会使用模型文件中的形态动画,而只会在代码中使用一组固定的面部动画。
与其他数据类型类似,我们添加了一个从enum值到字符串的映射。在代码中使用enum值并显示用户友好的字符串在 UI 中要容易得多。新的映射名为micFaceAnimationNameMap,将被添加到ModelInstanceCamData.h文件中的ModelInstanceCamData struct:
std::unordered_map<faceAnimation, std::string>
micFaceAnimationNameMap{};
在渲染器类文件OGLRenderer.cpp或VkRenderer.cpp的init()方法中填充映射,最佳位置是现有映射代码旁边。
固定形态映射与从模型动态加载
在faceAnimation enum和micFaceAnimationNameMap中硬编码所有形态目标动画剪辑的原因是为了保持代码简单。
从模型文件中填充形态目标剪辑列表很容易,但在 UI 中维护动态列表变得相当复杂。例如,向节点树中添加选择形态剪辑的代码将创建树与单个模型之间的硬依赖 - 使用相同的树为其他模型将变得不可能。
为了避免模型树依赖,将只使用预定义的形态动画集。任何模型都可以支持所有形态动画,没有任何动画,或者部分剪辑与匹配的索引,用空形态动画填充任何空白。
对于实例部分,我们向InstanceSettings struct中添加了两个新变量,名为isFaceAnimType和isFaceAnimWeight:
faceAnimation isFaceAnimType = faceAnimation::none;
float isFaceAnimWeight = 0.0f;
在 isFaceAnimType 中,我们存储当前的 facial animation 裁剪。由于 faceAnimation 枚举中存在额外的 none 值,我们不需要另一个布尔值来切换 facial animations 的开关。默认 facial 网格和 facial animation 之间的插值权重可以通过 isFaceAnimWeight 控制,其中 0.0f 表示仅显示默认网格,而 1.0f 表示变形网格。
is 代表 “InstanceSettings”,而不是 “it is”
再次提出旁注,并避免混淆:InstanceSettings 变量名中的 is 只是 struct 名称的缩写,而不是定义状态的东西。因此,isFaceAnimType 代表 “Instance Settings Face Animation Type”,而不是控制 facial animation 是否启用的布尔值。
我们还需要为每个实例的渲染器提供有关 facial animations 的信息。让我们在下一步扩展渲染器。
在渲染器中填充每个实例的缓冲区数据
至于所有其他与着色器相关的数据,我们需要一个 SSBO 来将数据传递给 GPU。要填充 SSBO,需要一个某种数据类型的 std::vector。因此,渲染器类头文件 OGLRenderer.h 将获得两个新的 private 变量,分别称为 mFaceAnimPerInstanceData 和 mFaceAnimPerInstanceDataBuffer:
std::vector<glm::vec4> mFaceAnimPerInstanceData{};
ShaderStorageBuffer mFaceAnimPerInstanceDataBuffer{};
对于 Vulkan 渲染器,缓冲区的数据类型不同。我们需要在 VkRenderer.h 文件中添加以下行:
std::vector<glm::vec4> mFaceAnimPerInstanceData{};
VkShaderStorageBufferData mFaceAnimPerInstanceDataBuffer{};
即使我们需要仅三个值,我们也会在这里使用 glm::vec4 来处理可能的数据对齐问题。你应该尽量避免使用三元素向量 (glm::vec3) 通过 SSBO 将数据传输到 GPU,因为您可能会在 CPU 侧的向量或结构体和 GPU 侧的缓冲区之间得到不匹配。
每个实例的 facial animation SSBO 将添加到渲染器类文件 OGLRenderer.cpp 或 VkRenderer.cpp 中的 draw() 调用,更具体地说是在遍历模型实例的循环中:
for (const auto& model : mModelInstCamData.micModelList) {
...
**mFaceAnimPerInstanceData.****resize****(numberOfInstances);**
...
for (size_t i = 0; i < numberOfInstances; ++i) {
我们还在循环之前添加了包含变形数据的向量的调整大小;请参阅前一个代码片段中的高亮部分。
由于我们已经在循环中提取了当前实例的 InstanceSettings 结构体,因此添加 facial animation 只需要几行代码。
首先,我们添加一个名为 morphData 的空 glm::vec4,并检查实例是否设置了 facial animation:
glm::vec4 morphData = glm::vec4(0.0f);
if (instSettings.isFaceAnimType !=
faceAnimation::none) {
如果我们应该对实例的 facial 进行动画处理,我们填充 morphData 的三个元素:
morphData.x = instSettings.isFaceAnimWeight;
morphData.y =
static_cast<int>(instSettings.isFaceAnimType) – 1;
morphData.z = model->getAnimMeshVertexSize();
现在,我们设置 facial animation 的权重,clip 数量减一以尊重 faceAnimation 枚举中的 none 元素,以及两个变形网格之间跳过的顶点数。
当前着色器代码使用顶点数和 clip 数来计算所需变形动画的第一个顶点,但这里也可以使用绝对值。如果计划扩展代码以支持具有变形目标动画的多个网格,绝对值可能会很有用(参见 实践课程 部分)。
最后,我们将morphData存储在用于填充 SSBO 的向量中:
}
mFaceAnimPerInstanceData.at(i) = morphData;
作为面部动画的最后一步,顶点着色器必须知道新的缓冲区。
扩展着色器以绘制面部动画
由于变形目标动画仅更改顶点数据,我们需要在顶点着色器中添加新的 SSBO 和一些逻辑。不需要触摸计算或片段着色器,这大大简化了面部动画的实现。
为了防止没有面部动画的模型出现扭曲,我们将使用一组单独的着色器来绘制包含变形动画的网格。首先,我们在渲染器头文件OGLRenderer.h中添加两个新的private着色器变量:
Shader mAssimpSkinningMorphShader{};
Shader mAssimpSkinningMorphSelectionShader{};
对于 Vulkan 渲染器,这里需要做更多的工作,因为着色器是管线的一部分。我们需要在VkRenderData.h文件中添加两个VkPipelineLayout句柄、两个VkPipeline句柄、两个VkDescriptorSetLayout句柄和两个VkDescriptorSet句柄:
VkPipelineLayout rdAssimpSkinningMorphPipelineLayout;
VkPipelineLayout
rdAssimpSkinningMorphSelectionPipelineLayout;
VkPipeline rdAssimpSkinningMorphPipeline;
VkPipeline rdAssimpSkinningMorphSelectionPipeline;
VkDescriptorSetLayout
rdAssimpSkinningMorphSelectionDescriptorLayout;
VkDescriptorSetLayout
rdAssimpSkinningMorphPerModelDescriptorLayout;
VkDescriptorSet rdAssimpSkinningMorphDescriptorSet;
VkDescriptorSet
rdAssimpSkinningMorphSelectionDescriptorSet;
使用这些句柄,创建了两个新的 Vulkan 管线,用于绘制带有和没有特殊选择处理的模型。有关进一步的 Vulkan 实现细节,请查看VkRenderer.cpp文件。
我们需要调整选择顶点着色器以在选择缓冲区中绘制变形的面部网格。如果没有选择着色器,实例的头部将无法通过点击鼠标按钮进行选择。
对于着色器代码本身,我们可以重用和扩展现有的文件。将以下四个文件复制到新的文件名中:
-
将
assimp_skinning.vert重命名为assimp_skinning_morph.vert -
将
assimp_skinning.frag重命名为assimp_skinning_morph.frag -
将
assimp_skinning_selection.vert重命名为assimp_skinning_morph_selection.vert -
将
assimp_skinning_selection.frag重命名为assimp_skinning_morph_selection.frag
带有.frag扩展名的片段着色器将不会更改,但为了进一步的更改或调试,始终最好为新的着色器使用单独的文件。
在以.vert结尾的两个顶点着色器文件中,添加以下行以定义新的MorphVertex struct,与在opengl文件夹中的OGLRenderData.h文件中定义的OGLMorphVertex struct相匹配:
struct MorphVertex {
vec4 position;
vec4 normal;
};
对于 Vulkan,原始struct的名称是VkMorphVertex,在vulkan文件夹中的VkRenderData.h文件中定义。
然后,在绑定点四和五上添加两个新的 SSBO(Shader Storage Buffer Objects),分别命名为AnimMorphBuffer和AnimMorphData:
layout (std430, binding = 4) readonly restrict
buffer AnimMorphBuffer {
MorphVertex morphVertices[];
};
layout (std430, binding = 5) readonly restrict
buffer AnimMorphData {
vec4 vertsPerMorphAnim[];
};
第一个缓冲区AnimMorphBuffer包含所有可用的变形动画的顶点。在第二个缓冲区AnimMorphData中,所有实例设置从 CPU 传递到 GPU。
在顶点着色器的main()方法内部,我们通过乘以每个实例的顶点数和面部动画剪辑的索引来计算所需面部动画的顶点偏移量:
int morphAnimIndex =
int(vertsPerMorphAnim[gl_InstanceID].y *
vertsPerMorphAnim[gl_InstanceID].z);
在这里将 float 类型的值转换为 int 没有问题,这样可以避免使用一个包含单独整数和浮点值的不同结构体,并使用 glm::vec4 作为“传输对象”。第一个由浮点数表示的不准确整数将是 2²⁴+1,这个值足够大,即使是带有许多面动画的大网格(2²⁴+1 将是 ~16 MiB 的顶点数据)。
然后我们通过使用 gl_VertexID 内部着色器变量(对于 Vulkan 是 gl_VertexIndex)从变形网格中提取顶点:
vec4 origVertex = vec4(aPos.x, aPos.y, aPos.z, 1.0);
vec4 morphVertex = vec4(morphVertices[gl_VertexID +
morphAnimOffset].position.xyz, 1.0)
现在我们可以根据权重因子混合原始顶点和从变形网格中替换的顶点的位置:
gl_Position = projection * view * worldPosSkinMat *
mix(origVertex, morphVertex,
vertsPerMorphAnim[gl_InstanceID].x);
由于顶点的位置变化也可能影响顶点所在的三角形的法线,因此我们对顶点法线也进行相同的提取和计算工作:
vec4 origNormal =
vec4(aNormal.x, aNormal.y, aNormal.z, 1.0);
vec4 morphNormal =
vec4(morphVertices[gl_VertexID +
morphAnimOffset].normal.xyz, 1.0);
normal = transpose(inverse(worldPosSkinMat)) *
mix(origNormal, morphNormal,
vertsPerMorphAnim[gl_InstanceID].x);
为了更好地选择带有和没有变形动画的网格,我们还需要在 AssimpModel 类和渲染器中进行一些小的更改。
完成面部动画代码
在 AssimpModel 类中,添加了两个新的绘制方法:
void drawInstancedNoMorphAnims(int instanceCount);
void drawInstancedMorphAnims(int instanceCount);
方法名说明了它们的功能:drawInstancedNoMorphAnims() 绘制所有没有变形动画的网格,而 drawInstancedMorphAnims() 仅绘制带有变形动画的网格。
为了在两种方法中过滤网格,我们使用了一个相当简单的检查 - 我们遍历所有网格,查看网格内部 morphMeshes 向量的大小。对于仅绘制非变形网格的 drawInstancedNoMorphAnims() 方法,我们简单地跳过没有额外变形网格的网格:
for (unsigned int i = 0; i < mModelMeshes.size(); ++i) {
if (mModelMeshes.at(i).morphMeshes.size() > 0) {
continue;
}
OGLMesh& mesh = mModelMeshes.at(i);
drawInstanced(mesh, i, instanceCount);
}
对于仅变形网格的版本,我们反转检查:
...
if (mModelMeshes.at(i).morphMeshes.size() == 0) {
continue;
...
}
使用两个单独的方法的原因在于渲染器类文件 OGLRenderer.cpp 或 VkRenderer.cpp 中的 draw() 方法。在那里,我们将动画模型的正常实例绘制调用替换为无变形版本:
**model->****drawInstancedNoMorphAnims****(numberOfInstances);**
在检查模型是否包含任何变形网格后,绘制带有变形动画的网格,如下面的 OpenGL 渲染器的代码所示:
if (model->hasAnimMeshes()) {
mAssimpSkinningMorphShader.use();
...
model->bindMorphAnimBuffer(4);
mFaceAnimPerInstanceDataBuffer.uploadSsboData(
mFaceAnimPerInstanceData, 5);
model->drawInstancedMorphAnims(numberOfInstances);
}
在这种情况下,我们使用新的变形着色器,绑定包含顶点数据和实例设置的 SSBO,并使用仅绘制带有变形动画的网格的绘制调用。
对于简单的测试,你可以在渲染器中将 morphData 值设置为一些固定值,并检查实例是否在愤怒或微笑地四处移动。但要完全控制新的设置,我们还将添加一个组合框和滑块到 UI 中。
添加 UI 元素以控制面部动画
对于 UI,代码量很小。我们只需要一个组合框,将 InstanceSettings 的 isFaceAnimType 值映射到 micFaceAnimationNameMap 中的字符串,以及一个与 isFaceAnimWeight 链接的浮点滑块。对于没有面部动画的模型,我们简单地禁用组合框和滑块。
图 10.3 显示了扩展的组合框,包含四个面部动画以及禁用面部动画的 None 设置:

图 10.3:控制实例面部动画的 UI 设置
我们现在可以选择面部动画剪辑,并通过使用权重滑块来控制面部动画形态的使用量。
作为面部动画实现的最后一步,我们将介绍如何将新设置添加到 YAML 配置文件中。
保存和加载新的实例设置
幸运的是,当前 YAML 解析器和发射器代码的状态允许我们几乎不费吹灰之力地添加面部动画。由于面部动画设置是按实例设置的,我们需要扩展tools文件夹中YamlParser.cpp文件中的InstanceSettings的 YAML 发射器输出操作符。
在可选节点树设置输出之后,我们检查是否配置了面部动画,如果设置了剪辑,则输出实例设置:
if (settings.isFaceAnimType != faceAnimation::none) {
out << YAML::Key << "face-anim-index";
out << YAML::Value << settings.isFaceAnimType;
out << YAML::Key << "face-anim-weight";
out << YAML::Value << settings.isFaceAnimWeight;
}
为了输出faceAnimation enum,我们还需要为新数据类型定义输出操作符:
YAML::Emitter& operator<<(YAML::Emitter& out,
const faceAnimation& faceAnim) {
out << YAML::Flow;
out << static_cast<int>(faceAnim);
return out;
}
在YamlParserTypes.h文件中,我们还需要为新的faceAnimation数据类型提供一个简单的decode()方法:
template<>
struct convert<faceAnimation> {
static bool decode(const Node& node, faceAnimation& rhs) {
rhs = static_cast<faceAnimation>(node.as<int>());
return true;
}
};
encode()方法在这里没有展示,它本质上与所有其他enum encode()方法做的是一样的事情:将节点数据转换为int。
最后,我们只需扩展ExtendedInstanceSettings的decode()方法,添加两个新值:
if (node["face-anim"]) {
rhs.isFaceAnimType =
node["face-anim"].as<faceAnimation>();
rhs.isFaceAnimWeight =
node["face-anim-weight"].as<float>();
}
encode()方法扩展也非常简单:
if (rhs.isFaceAnimType != faceAnimation::none) {
node["face-anim"] = rhs.isFaceAnimType;
node["face-anim-weight"] = rhs.isFaceAnimWeight;
}
确保提高配置文件的版本,因为我们向其中添加了新数据。在这种情况下,更改文件版本不太关键,因为前几章的解析器版本根本不知道新的设置。
就这样!现在当你选择一个实例时,你可以改变面部表情的类型和强度,如图 10.4 所示:

图 10.4:一个“担忧”的实例在默认面部网格旁边
面部动画可以控制每个实例,一个实例的变化不会影响其他实例。是否想让实例显得愤怒、担忧、快乐,甚至惊讶,以及这种表情显示的程度,完全取决于你。
向应用程序添加更多形态剪辑也很容易。对于新剪辑来说,最复杂的事情可能是使用 Blender 等工具中的顶点动画。在代码中,只需向faceAnimation enum class添加一个新值,并在ModelInstanceCamData struct的micFaceAnimationNameMap变量中添加一个新的字符串映射即可。
要能够在节点树中使用新的面部动画,我们需要创建一个新的节点类型,允许我们控制所需的形态目标动画的动画剪辑和权重。所以,让我们添加全新的FaceAnim节点。
在节点树中使用面部动画
创建新的节点类型很简单。首先,我们在 graphnodes 文件夹中添加一个新的类,FaceAnimNode,由 FaceAnimNode.h 头文件和实现文件 FaceAnimNode.cpp 组成。我们可以从 WaitNode 中借用大部分实现,添加 ImGui 元素和一些逻辑来控制执行时间内的面部动画。图 10.5 显示了 FaceAnim 节点的最终布局:

图 10.5:新的 FaceAnim 节点
该节点允许我们选择一个面部动画剪辑,包括 None 设置以禁用实例上的面部动画,动画在两个播放方向上的起始和结束权重,以及一个计时器来控制动画重放将持续多长时间。
在我们能够添加新的 FaceAnim 节点之前,我们必须扩展包含节点类型和图节点工厂类的 enum:
调整新 FaceAnim 节点的代码
与 WaitNode 类似,FaceAnim 节点延迟控制流,直到计时器达到零,并在动画重放结束后通知父节点。
在添加两个新文件之后,创建新的节点类型需要额外两个步骤。
首先,我们必须在 Enums.h 文件中扩展 graphNodeType enum:
enum class graphNodeType : int8_t {
...
**faceAnim,**
NUM
};
接下来,GraphNodeFactory 类的构造函数和 makeNode() 方法必须知道新的节点。在构造函数中,我们将节点标题字符串添加到 mGraphNodeTypeMap:
mGraphNodeTypeMap[graphNodeType::faceAnim] = "FaceAnim";
在 makeNode() 中,我们为新的节点类型添加一个情况块:
case graphNodeType::faceAnim:
newNode = std::make_shared<FaceAnimNode>(nodeId);
break;
现在我们可以调整新 FaceAnimNode 类的实现。
添加 FaceAnim 节点
由于我们将手动在两个权重值之间混合,一旦节点激活,我们将在 update() 方法中将混合时间映射到零到一的范围:
float morphTimeDiff = 1.0f;
if (mFaceAnimBlendTime != 0.0f) {
morphTimeDiff = std::clamp(mCurrentTime /
mFaceAnimBlendTime, 0.0f, 1.0f);
}
通过一个简单的除法,周围带有避免除以零的检查,morphTimeDiff 中的时间差将从一变为零。
然后我们通过时间差和权重差的乘积来插值最终的权重:
float morphWeightDiff =
mFaceAnimEndWeight – mFaceAnimStartWeight;
float currentWeight =
mFaceAnimEndWeight - morphWeightDiff * morphTimeDiff;
在每次 update() 方法的运行中,我们通过 fireNodeOutputCallback 持续发送新的权重到渲染器:
instanceUpdateType updateType =
instanceUpdateType::faceAnimWeight;
nodeCallbackVariant result;
bool extra = false;
result = currentWeight;
fireNodeActionCallback(getNodeType(), updateType,
result, extra);
在进行权重更新之前,我们在 activate() 方法中发送所需的动画剪辑索引:
instanceUpdateType updateType =
instanceUpdateType::faceAnimIndex;
nodeCallbackVariant result;
bool extra = false;
result = mFaceAnim;
fireNodeActionCallback(getNodeType(), updateType,
result, extra);
为了将面部动画值信号传递给渲染器,需要通过两个新值 faceAnimIndex 和 faceAnimWeight 扩展 instanceUpdateType enum:
enum class instanceUpdateType : uint8_t {
...
**faceAnimIndex,**
**faceAnimWeight**
};
我们还需要在 nodeCallbackVariant 变体中添加 faceAnimation 类型,以便在类之间的回调中使用新的数据类型:
using nodeCallbackVariant = std::variant<float, moveState, moveDirection, **faceAnimation**>;
由于我们在节点中使用了 fireNodeOutputCallback,因此需要扩展 GraphEditor 和 SingleInstanceBehavior 类:
在 graphnodes 文件夹中的 GraphEditor.cpp 文件中,必须在 createNodeEditorWindow() 方法中添加 faceAnim 节点类型,以将节点动作回调绑定到新创建的 faceAnim 节点:
if (nodeType == graphNodeType::instance ||
nodeType == graphNodeType::action **||**
**nodeType == graphNodeType::faceAnim** {
newNode->setNodeActionCallback(
behavior->bdNodeActionCallbackFunction);
}
在SingleInstanceBehavior复制构造函数中存在类似的检查;我们还需要在这里添加faceAnim节点类型以绑定节点动作回调:
if (node->getNodeType() == graphNodeType::instance ||
node->getNodeType() == graphNodeType::action **||**
**node->****getNodeType****() == graphNodeType::faceAnim** {
newNode->setNodeActionCallback(
mBehaviorData->bdNodeActionCallbackFunction);
}
除了在更改面部动画设置时操作InstanceSettings变量之外,我们还向AssimpInstance类添加了两个新的设置器,以简化对新的InstanceSettings变量的访问。
启用实例和渲染器对面部动画更改做出反应
如果我们需要调整多个值,通过先读取所有数据然后在最后写回所有数据来更新InstanceSettings是好的。对于单个更改,单独的设置器更容易使用。我们向AssimpInstance类添加了两个新的public方法setFaceAnim()和setFaceAnimWeight():
void setFaceAnim(faceAnimation faceAnim);
void setFaceAnimWeight(float weight);
两种方法都会更新实例的InstanceSettings数据中的两个值,以及一些额外的逻辑来处理faceAnimation enum的none值。
作为新节点的最后一步,渲染器类的updateInstanceSettings()方法(OGLRenderer.cpp或VkRenderer.cpp)需要知道当实例想要更改面部动画设置时应该做什么。
为了做到这一点,在节点类型的switch块中,必须添加一个新的case块来处理新的faceAnim节点类型:
switch (nodeType) {
...
**case** **graphNodeType::faceAnim:**
然后,我们检查收到的面部动画更新类型。由于我们需要对面部动画剪辑索引和剪辑权重的变化做出反应,因此添加了一个新的switch/case语句,包含两种更新类型:
switch (updateType) {
case instanceUpdateType::faceAnimIndex:
instance->setFaceAnim(
std::get<faceAnimation>(data));
break;
case instanceUpdateType::faceAnimWeight:
instance->setFaceAnimWeight(
std::get<float>(data));
break;
default:
break;
我们还需要关闭faceAnim节点类型的case块:
}
break;
对于两种新的更新类型faceAnimIndex和faceAnimWeight,AssimpInstance类中新增的方法将使用nodeCallbackVariant变体中的数据作为参数被调用。
这最后一步完成了从新节点到渲染器的链路,使我们能够在节点编辑器中使用面部动画。可以将 FaceAnim 节点添加到男人的模型节点树中,以将所有实例的波浪动画更改为带有微笑的面部波浪,如图图 10.6所示:

图 10.6:节点树中结合“波浪”动作和“笑脸”动画
节点树中还可以添加更多内容。你可以在打或踢之前让模型生气,或者在播放拾取动画之前惊讶,以模拟实例在地面上看到了某物。而且,随着骨骼和面部动画剪辑的增多,可以创造出更多有趣和疯狂的组合。
能够从一个人的面部表情中看到他们的情绪有助于我们人类评估可能的下一步行动,将面部表情引入应用程序使得与实例的交互方式更加广泛。通过使用形变目标动画,即使是我们的基本低多边形模型也展现出更多的个性。
但是,形变目标动画有三个严重的限制,我们将在下一节讨论。
形变目标动画的限制
在使用变形目标动画时,我们必须注意这三个限制:
-
网格中的顶点数必须在所有动画和帧中相同。在帧内或动画过程中无法添加或删除顶点,也不能更改顶点分配到三角形的分配。你只能移动顶点。
-
模型的变形部分整个网格必须在动画中的每个变形键中复制。对于模型身体的小部分,这可能没问题,但身体的高细节部分在内存中多次出现可能会造成明显的开销。
-
只支持顶点位置的变化,变形通常通过简单的线性插值来完成。小旋转可以通过位置变化来模拟,但通过大旋转或缩放移动顶点,例如转动头部或移动手部,将在插值过程中产生视觉扭曲。
你可以在 Blender 中自己测试第三个限制。为此,添加一个基于变形目标的头部旋转,你将看到旋转也影响了头部的体积。旋转角度越大,动画中的扭曲就越大。
图 10.7 展示了大约 50%的头部旋转 180 度的结果:

图 10.7:使用变形目标动画旋转头部时的扭曲体积
在对模型进行动画时,必须仔细创建和测试变形目标动画。它们是动画的有价值补充,但仍然存在一些缺点。
我们如何创建可能需要旋转的高级动画,而不使用变形目标动画,例如一个复杂的头部运动动画,让模型在虚拟世界中四处张望?
随着我们深入探讨加法混合,这就是我们将要学习的内容。
实现加法混合
加法混合是动画混合的另一种方法。虽然 正常 动画混合用于在两个骨骼动画剪辑之间进行插值,而变形目标动画则是改变网格的顶点位置,但加法混合 堆叠 两种不同的骨骼动画。
加法混合的技术部分惊人地简单,但通过组合两种不同的骨骼动画所达到的效果,使得 3D 模型的外观更加自然。
让我们探讨加法混合和我们已经知道的动画混合方法之间的相似之处和不同之处。
加法混合是如何工作的
加法混合的基本思想源于将模型动画分割成多个,尤其是独立部分的需求。
骨骼动画通常为整个模型提供动画,使模型能够奔跑、行走、出拳或跳跃。在骨骼动画之间进行混合将平滑这两个剪辑之间的过渡,但不会添加新的动作。因此,有两种不同的方法:分割骨骼或堆叠动画。
将骨骼分割成两个或更多动画域,并为骨骼的每个部分播放不同的剪辑,这被称为分层混合。分层混合是一种简单且成本效益高的混合动画方法,因为骨骼的每个节点只受单个动画剪辑的变换影响,只是对于节点来说,动画剪辑是不同的。
但是,将模型骨骼分割成多个部分,每个部分播放不同的骨骼动画,可能会导致在全身同步剪辑时需要额外的努力。在分割骨骼动画上的同步回放失败可能会导致视觉伪影,想想不同回放速度的剪辑。
我们在书中没有处理分层混合,但在实践环节部分有一个任务可以实现模型的分层动画混合。相比之下,加性混合允许在另一个骨骼动画之上叠加骨骼动画。当基本骨骼动画创建的基本动作正常应用时,一个或更多其他骨骼动画的属性变化被添加到模型的节点上,从而创建一个与基本动画提供的动作连接的运动。与分层混合相比,加性混合的计算成本更高,因为我们需要计算多个动画,并且我们还需要混合所有动画。例如,这种简单的属性变化添加允许我们向正常骨骼动画中添加头部运动。模型将能够从骨骼动画中跑步、行走、出拳或跳跃,并且同时移动头部。作为额外的好处,面部动画不受加性混合的影响,因此模型可以行走、向右看并微笑,所有三个动画都在并行运行。
在技术实现部分,通过将当前姿态与参考姿态之间的节点变换差异添加到另一个骨骼动画中,来完成加性动画。
让我们用另一个例子来解释技术方面。图 10.8显示了仅将模型头部节点向右旋转(从模型的角度看),而所有其他节点保持 T 形姿态的动画的第一个和最后一个关键帧:

图 10.8:加性动画“头部向右看”的起始和结束姿态
作为参考姿态,我们使用第一个关键帧,整个模型处于 T 形姿态。为了计算加性动画的值,我们取所需的关键帧,并简单地从目标姿态中减去参考姿态的平移、旋转和缩放值。
如果模型保持 T 形姿态,加性动画的所有值都将为零。并且不会向正在运行的骨骼动画中添加任何内容,例如,行走周期。
当我们在图 10.8的动画片段中进一步前进时,头部的旋转会导致当前姿态和参考姿态之间头部节点旋转值的差异更大。但我们将只得到头部节点的差异,所有其他节点的变换仍然与参考姿态相同。
将头部节点旋转的差异添加到当前正在运行的骨骼动画剪辑中很容易。由于我们收集了所有节点的变换属性,两个骨骼动画的组合只是简单地按节点添加平移和缩放值,以及旋转值的四元数乘法。
这种添加只更改了与参考姿态相比在添加动画剪辑的动画剪辑中发生变化的节点值。在添加动画中没有变化的节点将在骨骼动画中保持不变。
如何创建合适的动画
创建用于添加混合的动画超出了本书的范围,类似于创建面部动画。您可以使用像 Blender 这样的工具,或者使用第十章中的人模型,该模型已经包含四个额外的动画,仅改变头部。
如果您自己创建额外的动画,请确保在剪辑名称前加上一些常见的标识,例如下划线,或者字母ZZZ_,以将它们分组在一起。至少 Blender 在导出时会按名称排序剪辑,而且我们根据剪辑在 YAML 配置文件中的索引号存储了多个剪辑映射,将新剪辑添加到现有剪辑的开始或中间会导致配置文件损坏。
在我们的应用程序中实现添加动画也非常简单。
扩展代码以支持添加动画
为了将附加数据带到 GPU 上,我们在opengl文件夹中的OGLRenderData.h文件中为struct PerInstanceAnimData添加了四个新变量:
struct PerInstanceAnimData {
...
**unsigned****int** **headLeftRightAnimClipNum;**
**unsigned****int** **headUpDownAnimClipNum;**
...
**float** **headLeftRightReplayTimestamp;**
**float** **headUpDownReplayTimestamp**;
};
对于 Vulkan,文件名为VkRenderData.h,位于vulkan文件夹中。
我们将头部动画分为两部分,并使用单独的变量来控制头部的左右动画和上下动画。同时向左和向右移动头部是不可能的,因此我们可以将这两个方向组合成一个控制变量。对于向上和向下移动头部也是如此。
然后,我们在渲染器类文件OGLRanderer.cpp中创建了一个新的private计算着色器,名为mAssimpTransformHeadMoveComputeShader:
Shader mAssimpTransformHeadMoveComputeShader{};
对于 Vulkan,我们在VkRenderData.h文件中添加了一个新的VkPipeline句柄,名为rdAssimpComputeHeadMoveTransformPipeline:
VkPipeline rdAssimpComputeHeadMoveTransformPipeline;
由于并非所有模型都可能具有加法头部动画,如果没有设置头部动画,我们可以跳过额外的计算。我们还将在PerInstanceAnimData struct中添加新变量到计算着色器文件assimp_instance_transform.comp。新的头部动画变量将被忽略,但我们需要在两个着色器中将结构体扩展到相同的大小。
接下来,我们将assimp_instance_transform.comp文件复制到assimp_instance_headmove_transform.comp,并在OGLRenderer.cpp的init()方法中加载新文件到新的mAssimpTransformHeadMoveComputeShader计算着色器。对于 Vulkan,我们在VkRenderer.cpp的createPipelines()方法中创建新的渲染管线,加载头部变换计算着色器。
在新的着色器文件中,大部分新增内容只是复制粘贴的工作。我们必须在扩展的计算着色器中执行以下步骤,以左侧/右侧头部运动的旋转部分的代码为例:
-
提取两个头部动画的动画剪辑编号:
uint headLeftRightClip = instAnimData[instance].headLeftRightAnimClipNum; -
提取两个头部动画的逆缩放因子:
float headLeftRightRotInvScaleFactor = lookupData[headLeftRightClip * clipOffset + node * boneOffset + lookupWidth].x; -
计算访问头部动画查找数据的索引值:
int headLeftRightRotLookupIndex = clamp(int(instAnimData[instance] .headLeftRightReplayTimestamp * headLeftRightRotInvScaleFactor) + 1, 0, lookupWidth - 1); -
获取参考姿态在第一个查找位置和所需头部动画剪辑时间戳的平移、旋转和缩放值:
vec4 headLeftRightBaseRotation = lookupData[headLeftRightClip * clipOffset + node * boneOffset + lookupWidth + 1]; ... vec4 headLeftRightRotation = lookupData[headLeftRightClip * clipOffset + node * boneOffset + lookupWidth + HeadLeftRightRotLookupIndex]; -
使用四元数乘法计算当前姿态变换值与参考姿态变换值之间的差异:
vec4 headLeftRightRotationDiff = qMult(qInverse(headLeftRightBaseRotation), headLeftRightRotation); -
将两个头部动画的平移、旋转和缩放差异累加到每个变换的单个值中:
vec4 headRotationDiff = qMult(headUpDownRotationDiff, headLeftRightRotationDiff); -
将累加的差异添加到第一个和第二个剪辑变换中,再次使用四元数乘法进行旋转:
vec4 finalRotation = slerp(qMult(headRotationDiff, firstRotation), qMult(headRotationDiff, secondRotation), blendFactor);
实例还需要携带有关头部运动的信息,因此我们在InstanceSettings.h文件中的InstanceSettings struct中添加了两个新变量isHeadLeftRightMove和isHeadUpDownMove:
**float** **isHeadLeftRightMove =** **0.0f****;**
**float** **isHeadUpDownMove =** **0.0f****;**
我们将0.0f到1.0f之间的正范围映射到向左和向上的头部运动,将0.0f到-1.0f的负范围映射到向右或向下的头部运动。移动值为零时将使用两个动画的参考姿态的值,导致没有任何头部运动。
在OGLRenderer或VKRenderer类的draw()调用中填充PerInstanceAnimData struct中的新数据,这部分代码与面部动画相同。按照之前解释的映射方法,选择剪辑编号的操作如下所示:
if (instSettings.isHeadLeftRightMove > 0.0f) {
animData.headLeftRightAnimClipNum =
modSettings.msHeadMoveClipMappings[
headMoveDirection::left];
} else {
animData.headLeftRightAnimClipNum =
modSettings.msHeadMoveClipMappings[
headMoveDirection::right];
}
对于头部运动,我们使用时间戳的绝对值:
animData.headLeftRightReplayTimestamp =
std::fabs(instSettings.isHeadLeftRightMove) *
model->getMaxClipDuration();
在代码中硬编码剪辑编号是一个坏主意,因为不同的模型在这些新的动画上可能有不同的剪辑索引,或者根本没有。让我们添加另一个映射,这次是在加法混合剪辑编号和头部可能移动的四个方向之间。
为新的头部动画创建映射
对于剪辑和头部动画之间的映射,在 Enums.h 文件中创建了一个新的 enum class 叫做 headMoveDirection:
enum class headMoveDirection : uint8_t {
left = 0,
right,
up,
down,
NUM
};
对应的字符串映射 micHeadMoveAnimationNameMap 用于在 UI 中显示名称,被添加到 ModelInstanceCamData 结构体:
std::unordered_map<headMoveDirection, std::string>
micHeadMoveAnimationNameMap{};
由于映射与模型相关,新的 msHeadMoveClipMappings 映射被添加到 ModelSettings.h 文件中的 ModelSettings 结构体:
std::map<headMoveDirection, int> msHeadMoveClipMappings{};
AssimpModel 类也获得了一个新的 public 方法来检查 msHeadMoveClipMappings 中的所有映射是否都处于活动状态:
bool hasHeadMovementAnimationsMapped();
如果找不到至少一个头部动画,会导致添加式头部动画被禁用。
在 OGLRenderer.cpp 文件的 draw() 调用中,我们根据所有头部动画映射的可用性切换计算着色器:
if (model->hasHeadMovementAnimationsMapped()) {
mAssimpTransformHeadMoveComputeShader.use();
} else {
mAssimpTransformComputeShader.use();
}
对于 Vulkan,我们在 VkRenderr.cpp 文件的 runComputeShaders() 方法中使用头部移动动画的可用性来选择绑定到计算着色器的管道:
if (model->hasHeadMovementAnimationsMapped()) {
vkCmdBindPipeline(mRenderData.rdComputeCommandBuffer,
VK_PIPELINE_BIND_POINT_COMPUTE,
mRenderData.rdAssimpComputeHeadMoveTransformPipeline);
} else {
vkCmdBindPipeline(mRenderData.rdComputeCommandBuffer,
VK_PIPELINE_BIND_POINT_COMPUTE,
mRenderData.rdAssimpComputeTransformPipeline);
}
头部动画的 UI 部分可以主要从 UserInterface 类中的其他部分复制代码。一个用于选择剪辑的组合框,一个遍历 headMoveDirection enum 中所有四个值的循环,两个按钮和两个滑块来测试动画,这些都是我们创建一个新 UI 部分(如图 10.9 所示)所需的所有内容:

图 10.9:头部运动/动画剪辑映射的 UI 控制
裁剪映射和裁剪来自当前选定实例的模型,这使得为所有模型配置添加式头部动画变得容易。
要在节点树中使用头部动画,还需要另一个新的节点类型。
添加头部动画节点
多亏了之前的 FaceAnimNode 节点,添加新的 HeadAnimNode 只需几分钟。你可以按照 使用节点树中的面部动画 部分的步骤创建新节点,因为你必须执行与 FaceAnimNode 相同的操作。只需要进行一些小的更改,比如 enum class 条目的名称。
对于新节点的 UI 部分,你可以重用 FaceAnimNode 类代码进行控制,并将代码从 InstanceNode 类代码复制到切换两个部分的开或关。
在节点树中使用的最终 头部动画 节点看起来像 图 10.10:

图 10.10:头部动画节点
与实例节点一样,我们可以控制我们想要更改的头部动画值,并且对于每个动画,我们可以调整起始和结束权重以及两个权重值之间混合所需的时间。并且像 FaceAnimNode 节点一样,HeadAnim 节点延迟控制流直到两个计时器都过期,并向父节点发出执行结束的信号。
我们以保存和加载添加式头部动画设置所需的变化来结束本章。
保存和加载头部动画设置
与新的树节点类似,实现 YAML 配置文件更改以保存和加载头部动画设置只需几分钟。
对于YamlParser.cpp文件中的ModelSettings YAML 发射器输出,如果所有四个剪辑都配置了,我们直接从映射中添加剪辑映射。我们还需要为headMoveDirection enum添加一个新的发射器输出,将值转换为int:
YAML::Emitter& operator<<(YAML::Emitter& out,
const headMoveDirection& moveDir) {
out << YAML::Flow;
out << static_cast<int>(moveDir);
return out;
}
要加载映射回,我们在YamlParserTypes.h文件中的ModelSettings的decode()方法中添加了一个部分,逐个读取映射值。这里还需要一个新的decode()方法用于headMoveDirection enum:
static bool decode(const Node& node, headMoveDirection& rhs) {
rhs = static_cast<headMoveDirection>(node.as<int>());
}
对于实例设置,InstanceSettings中的isHeadLeftRightMove和isHeadUpDownMove存储的两个float值被添加到YamlParser.cpp中的发射器中:
if (settings.isHeadLeftRightMove != 0.0f) {
out << YAML::Key << "head-anim-left-right-timestamp";
out << YAML::Value << settings.isHeadLeftRightMove;
}
if (settings.isHeadUpDownMove != 0.0f) {
out << YAML::Key << "head-anim-up-down-timestamp";
out << YAML::Value << settings.isHeadUpDownMove;
}
这两个值也被添加到YamlParserTypes.h文件中ExtendedInstanceSettings数据类型的decode()方法中:
if (node["head-anim-left-right-timestamp"]) {
rhs.isHeadLeftRightMove =
node["head-anim-left-right-timestamp"].as<float>();
}
if (node["head-anim-up-down-timestamp"]) {
rhs.isHeadUpDownMove =
node["head-anim-up-down-timestamp"].as<float>();
}
在所有这些添加之后,你可以在男人的模型节点树中添加一些 HeadAnim 节点,创建如图图 10.11所示的动画:

图 10.11:实例在挥手和微笑时抬头
现在实例可以以自然的方式在任何时候转动头部,我们只需要将新的 HeadAnim 节点添加到控制流程中。如果你回到图 10.6,你会看到像头部运动这样的小添加对实例外观的影响是巨大的。
你可以让你的想象力在新的头部运动上自由发挥。你希望头部跟随相机或任何其他附近的实例吗?你希望点头表示是,如果问题的答案是否,则轻微摇动头部吗?你希望让玩家向上或向下看,以将玩家的兴趣点移动到天空或地板吗?如果你想要扩展代码,实践课程部分中列出了一些想法。
摘要
在本章中,我们将面部表情和单独的头部动画添加到实例中。我们首先对面部动画进行了简要探索。然后,我们将面部动画以形态目标动画的形式实现到代码和着色器中,使实例能够微笑或愤怒。接下来,我们为面部动画添加了一个树节点,使我们能够在节点树中使用新的面部表情。最后,我们研究了加法动画混合,并使用加法混合添加了头部运动,包括一个新的树节点和 UI 控件。
在下一章中,我们将暂时放下动画控制,通过向虚拟世界添加关卡数据(如游戏关卡)来让实例真正有一个生活空间。我们首先检查 Open Assimp 导入库支持的格式,并搜索可用的关卡文件。然后,我们探讨为什么我们应该将关卡数据从模型和实例中分离出来。最后,我们从文件中加载关卡数据,并将与关卡相关的数据添加到应用程序和渲染器中。
实践环节
这里是一些你可以添加到代码中的内容:
-
添加对包含形态目标动画的多个网格的支持。
-
目前,一个模型只能有一个网格具有形态目标动画。对于简单的头部动画,这个限制是可以的。但如果你想要控制脸部超过一个部分,使用多个形态目标可能会有所帮助。
-
在两个形态目标之间添加混合。为了更好的自然面部表情,两个形态目标之间的直接混合会很棒。不再需要通过中性位置绕道,愤怒和担忧之间的直接路径现在可用。
-
添加更多的形态目标。
-
你可以尝试让模型说话。你可以为不同的元音和辅音添加表情,包括从上一个任务中直接混合。有了这样的动画,你可以在交互时模仿实例对你说话。
-
添加分层/遮罩动画。
与附加动画混合不同,分层混合使用不同的骨骼动画剪辑来处理模型虚拟身体的各个部分。例如,除了右臂之外的所有部分都使用跑步动画,只有右臂播放挥动手臂的动画。正如在如何实现附加混合部分中提到的,分层动画可能需要额外的努力来同步两个动画剪辑。你需要添加一些逻辑来遮罩模型骨骼的某些部分。
-
让实例在交互时将头转向你。这是许多游戏的一个特性:如果你开始与一个实例交互,它们会转身直接朝向你。
-
让附近的实例评判你。这就像之前的任务:你也可以为靠近你的实例添加附加的头部转向动画。还要添加一个随机的面部表情,让实例在经过你附近时表现出某种情绪。
-
让实例对经过的最近实例微笑和挥手。
这是前两个任务的组合和扩展:使用交互逻辑找到虚拟世界中每个实例的最近实例,然后将头部移动向那个实例。播放微笑的形态动画和只有右臂挥动的新的附加动画。你可能想在这里为手臂使用分层动画。
- 添加更多的附加混合动画。
转动头部是一个好的开始,但如果是制作一个人四处张望的整个动画呢?尝试添加更多层的叠加混合动画,使实例在交互中做出手势。
- 优化 C++和着色器代码以获得更好的性能。
当前在 CPU 和 GPU 上的 C++和 GLSL 着色器代码以及数据结构是为了解释和探索我们在这里添加的功能,如形变目标动画和面部表情。在 CPU 和 GPU 上都有很大的优化空间。例如,你可以尝试从应用程序中挤出更多的每秒帧数,通过优化发送到 GPU 的数据类型,将更多的工作转移到计算着色器,或者通过在 Vulkan 上移除对着色器结果的忙碌等待。你也可以检查数据压缩对帧时间是否有积极或消极的影响。为了便于比较,可以在 UI 中添加一个复选框来在默认代码和优化版本之间切换。
其他资源
-
在虚幻引擎中的分层动画:
dev.epicgames.com/documentation/en-us/unreal-engine/using-layered-animations-in-unreal-engine -
虚幻引擎中的叠加动画:
dev.epicgames.com/documentation/en-us/unreal-engine/additive-vs.-full-body?application_version=4.27 -
Godot 动画树:
docs.godotengine.org/en/latest/tutorials/animation/animation_tree.html -
Blender:
www.blender.org -
Blender 形状键:
docs.blender.org/manual/en/latest/animation/shape_keys/introduction.html
第四部分
提升你的虚拟世界
在本书的最后一部分,你将为你的虚拟世界居民提供一个居住的地方。你将探索实例数据和关卡数据之间的区别,并实现一个用于关卡数据的加载器,无论是从互联网上找到的还是你自己创建的。你还将了解实例与关卡几何体之间的碰撞检测是如何处理的,使得实例不仅能避开墙壁,还能在加载的关卡的地板上行走。然后,你将了解到在关卡内进行路径查找和导航的方法,使得实例能够在游戏地图中的航标之间自由移动。最后,你将获得一系列的想法和资源,以将动画编辑器升级为一个小型游戏引擎。
本部分包含以下章节:
-
第十一章,加载游戏地图
-
第十二章,高级碰撞检测
-
第十三章,添加简单导航
-
第十四章,创建沉浸式交互式世界
第十一章:加载游戏地图
欢迎来到第十一章!在前一章中,我们为实例添加了面部表情。在简要介绍了形态目标动画之后,我们将应用扩展到加载形态网格,并为控制实例的面部动画添加了 UI 元素。此外,还添加了一种新的图节点类型,允许在节点树中使用面部动画。最后,我们实现了加法混合,以便独立于任何骨骼和面部动画移动实例的头。
在本章中,我们将暂时从角色控制中休息一下,并将游戏级别和级别资产添加到虚拟世界中。我们将首先探讨为什么级别数据应该与模型和实例的处理方式不同;此外,我们还将查看使用 Open Asset Importer Library 导入级别数据的合适文件格式以及在哪里可以找到游戏级别。然后,我们将从文件中加载级别数据和资产到应用程序中,并更新四叉树以成为八叉树。作为最后一步,我们将添加与级别相关的数据到渲染器中,以便将游戏地图绘制到屏幕上,让我们对虚拟世界居民的家有一个概念。在本章中,我们将涵盖以下主题:
-
地图数据与模型数据之间的差异
-
为地图选择文件格式
-
导入游戏地图
-
将地图数据发送到 GPU
技术要求
本章的示例代码位于chapter11文件夹中,在01_opengl_level子文件夹中为 OpenGL,在02_vulkan_level子文件夹中为 Vulkan。
地图数据与模型数据之间的差异
在处理模型和级别数据之间存在一些有趣的差异,使我们能够在数据处理中应用优化。在本书的代码中,我们将在加载级别数据后进行这些优化。对于较大的级别,在级别创建期间进行预计算是更好的方法。
让我们更详细地看看一些差异。
级别数据不会移动
模型和级别之间最显著的区别很简单:虽然模型实例的属性可以改变,例如位置、旋转和速度,并且它们播放动画、对事件做出反应等,但级别的架构通常保持不变。
级别中不可移动和不可动画的多边形具有一个很大的优势:一些数据可以在创建时或在加载时预先计算,例如用于碰撞检测或照明。在运行时,只需要查找预先计算的数据。此外,我们不需要在每一帧都将数据上传到 GPU。所有三角形都可以在加载时处理,然后一次性上传到 GPU 内存中,直到用户可能从应用程序中删除级别数据。
那么,门、按钮、电梯或任何在级别中移动的东西怎么办?
水平面的可移动部分,如滑动门、旋转门、电梯、按钮和开关、储物柜门、机械平台等,简而言之,任何可以在水平面内移动的东西,通常都是用动画模型来建模和使用,而不是静态水平数据。
只需将旋转门想象成一个门的三维模型,门轴上有一个单独的节点。在交互时,模型围绕门轴旋转。或者,对于滑动门,门模型向一侧移动特定距离,打开通往另一个房间的通道。与这些门相比,一个水平面上的静态墙壁或静态地板永远不会移动或旋转。
对静态数据进行分割碰撞检测也有助于我们提高性能。
使用单独的碰撞检测来处理水平数据
对于碰撞检测,我们可以添加一个仅包含水平数据的四叉树或八叉树。当加载水平面时,必须重新创建此树,并且在运行时可以保持只读状态,跳过添加和删除实例的昂贵操作。然后我们使用实例的 AABB 来检查水平数据树,以确定实例是否与水平几何体发生碰撞。使用不同的树结构来处理实例和水平数据也允许我们根据特定需求配置树。由于地图中有许多三角形,水平八叉树可能需要完全不同的最大深度和每个节点的三角形数量,而在虚拟世界中运行的实例却只有几个。除了对静态数据的改进之外,游戏水平面可能还包含其他不需要用于模型的数据。
水平数据可能包含额外的数据
在运行时,CPU 和 GPU 时间都是稀缺资源,任何可以放在查找表或通过着色器计算的数据都可以在创建下一帧时节省宝贵的时间。我们在第二章中将动画计算的部分移动到计算着色器中看到了这种效果,同样在第七章中添加动画查找表到 GPU 内存后也看到了这种效果。在这两章中,屏幕上相同数量的实例都实现了显著的帧时间提升。
对于水平数据,也可以进行类似的加速。以下是一些此类附加数据的例子:空间划分、光照贴图、导航网格和层次细节级别。让我们简要地看看这些额外数据类型。更详细的信息可以在本章末尾的附加资源部分找到。
空间划分
当我们在第八章中深入讨论碰撞检测时,我们讨论了水平面的空间划分。将空间划分数据保存到水平文件中是必要的,以避免在加载时或游戏运行时进行相同的计算。
创建二叉空间划分(BSP)树或将虚拟世界划分为八叉树可能需要很长时间,这取决于一个级别中的三角形数量以及整个级别的复杂性。这种计算可以移至级别创建时间,只需在最终级别文件中添加一个优化的查找版本即可。
光照贴图
尽管光照贴图的原则在id Software的Quake中近三十年前就已经提出,但这种技术至今仍在使用。在级别创建过程中,静态光源的光照效果被“烘焙”到纹理中,明亮的像素表示静态光源的光照到表面的级别几何形状的部分,而暗像素表示级别表面上的阴影。
然后将光照贴图纹理作为二级纹理添加,使那些来自光源的光无法到达级别几何形状的区域变暗,并模拟出阴影。使用光照贴图可以通过保持合理的视觉效果来显著加快光照计算,因为需要的每像素计算成本较低。
导航网格
导航网格,或导航网格,是为敌人、NPC 或任何其他由计算机控制的对象添加的附加功能。级别几何形状将被覆盖在一个额外的三角形或其他多边形组成的网格上,但仅限于计算机控制对象能够移动的地方。导航网格可以加速对象的路径查找,并且当放置正确时,可以帮助防止碰撞检查。
我们将在第十三章中回到导航网格,当我们向实例添加简单的导航时。
分层细节级别
模型文件可以包括所谓的细节级别网格。当绘制远离摄像机的模型时,网格复杂性可以降低,而不会影响视觉质量,因为模型将只覆盖屏幕上的一小部分像素。通过使用不同的网格分辨率,绘制模型所需的三角形总数可以减少。
级别数据可以利用细节级别网格更多,用更简单的表示替换对象组。例如,而不是在远处绘制大量视觉上无法区分的岩石,同一区域的分层细节级别(HLOD)版本可以合并到一个带有调整纹理的单个网格中,以较少的多边形和纹理提供类似的视觉质量。
级别数据可能是不完整或不完整的。
您的精美动画 3D 模型应该始终完全可用,而不仅仅是模型的一半,甚至更少。但对于关卡数据来说,一个关卡的大小可能对 PC 或游戏机来说一次性处理过于庞大,尤其是在考虑额外的关卡数据时,如光照、导航或碰撞检测。此外,纹理大小和质量,或者计算机控制角色的数量和分布,总体上会增加内存需求。此外,当前加载的关卡部分可能具有更多细节,使用可用资源绘制可见区域,而不是在内存中保留不可见和未使用的数据。
保持玩家的沉浸感是关卡设计的一部分。关卡可能隐藏在蜿蜒的通道后面,其中两个关卡部分都不可见,允许游戏引擎丢弃玩家来自的区域,并加载他们前往的部分。另一个广泛使用的关卡切换示例是使用电梯,并在建筑、宇宙飞船或类似结构的下一层加载新的关卡数据。
通过明智地使用预计算数据,可以将渲染单个帧的时间减少,从而为玩家提供更丰富的体验。或者,可以使用现在未使用的 CPU 功率调整视觉细节,允许在屏幕上显示更多对象,或者更详细的对象,同时仍然保持相同的帧时间。
在地图和实例之间的差异变得清晰之后,让我们看看哪些文件格式主要用于关卡数据,以及如何获取游戏地图。
选择地图的文件格式
Open Asset Importer 库了解多种旧的和新的 3D 角色模型格式,但遗憾的是,对关卡数据的支持相当有限。
我们将首先探索互联网上最常见的关卡文件格式,然后如果现有格式不符合我们的需求,我们将考虑替代方案。
使用由 Assimp 支持的文件格式的关卡
几种文件格式用于创建游戏关卡数据,无论是从头开始创建数据还是使用其他游戏中的建筑和景观作为模板。
如果您想获取一些游戏地图进行导入,应该查看这些网站:
-
Sketchfab:
sketchfab.com/ -
Free3D:
free3d.com
在这两个网站上,可以搜索和下载大量免费和付费的动画和非动画模型、关卡和资产。一些模型附带 Creative Commons 许可证,允许您在免费项目甚至商业项目中使用模型。
通常,您会发现以下格式的关卡:
-
Khronos Group glTF (
.gltf/.glb): 开源 glTF 格式不仅可以用于我们章节中使用的动画女性和男性模型,还可以将整个关卡导出为 glTF 文件。 -
Collada(
.dae):Collada 是一个老式的但完全基于 XML 的文件格式。Collada 也由 Khronos Group 管理,甚至已经为该文件格式创建了一个 ISO 标准。 -
Wavefront(
.obj+.mtl):许多关卡都可以在 Wavefront 文件格式中找到。Wavefront 格式的文件是纯文本(没有二进制组件)且格式有很好的文档记录和广泛的支持。 -
通用场景描述(
.usd/.usdz):与其它文件格式相比,通用场景描述(USD)格式相当新。USD 也是开源的,并且有很好的文档记录,但由于文件格式的复杂性,Assimp 中的支持仍然是实验性的。 -
Autodesk FBX(
.fbx):“Filmbox”格式是专有的且大部分未记录,但 Blender 和 Assimp 等工具可以读取和写入此文件格式。使用 FBX 的风险自负,因为它可能只有特定版本才能按预期工作。
如果这些模型中没有一种符合你的需求,你可能需要扩展文件格式,甚至构建一个自定义文件格式。
扩展现有格式或创建自定义格式
从头创建新的游戏关卡或修改现有游戏关卡可能需要一些原始文件格式中不包含的额外信息,例如内嵌的光照贴图、树木数据、导航网格或细节级别数据。有关更多信息,请参阅关卡数据可能包含额外数据部分。
类似于 glTF 这样的文件格式具有内置的创建扩展的能力,而其他文件格式可能很难或无法扩展而不破坏现有的导入器。在这种情况下,你可以从头开始创建自己的文件格式,或者使用标准文件格式之一来存储关卡数据,并添加一个自定义格式来存储额外数据。
在本书的添加 YAML 解析器部分中,之前已经创建了一个自定义文件格式:用于存储模型、实例、相机、碰撞检测和节点树的所有设置的 YAML 配置文件。尽管我们依赖于标准文本格式来在磁盘上存储数据,但文件内容是根据我们示例应用程序的需求定制的。以二进制格式存储相同的数据也是可能的,例如,当解码文本信息需要花费太多时间时。
但创建全新的文件格式应该是最后的手段,因为你将不得不编写所有读取和写入数据的代码,在读写操作中跟踪文件格式的不同版本,甚至可能还需要支持不同的操作系统和硬件架构。维护这样一个自然生长的文件格式可能会变得非常困难。
一个更好的方法是使用标准格式并将所有文件打包到一个存档中,例如 ZIP 文件。当关卡分发到玩家或其他开发者时,你不必担心缺失的文件,但与此同时,你也不需要通过创建一个新的、全面的文件格式来重新发明轮子。
这样的存档比你想象的要常见。例如,DOOM 原始版本中的 WAD 格式和 Quake 系列中的 PAK/PK2/PK3 格式都是为了将所有游戏数据收集到一个文件中而创建的,这些文件类型甚至支持补丁,因为新存档中的同名文件会替换旧存档中的同名文件。
构建你自己的关卡
如果你在互联网上找不到合适的游戏关卡地图,你仍然可以选择自己创建一个小地图,例如使用 Blender。创建地图超出了本书的范围,但你可以在互联网上找到合适的教程和视频。你可以在附加资源部分找到两个示例视频的链接。
在我们探讨了为什么在应用程序中分离模型和关卡数据的原因之后,我们现在将实现加载和处理关卡数据的新代码。
导入游戏地图
作为加载关卡的第一步,我们将添加一个名为AssimpLevel的新 C++类。你可以将AssimpLevel类视为AssimpModel和AssimpInstance两个类的混合体,包含模型类的静态顶点数据以及实例类的动态属性,如位置、旋转或缩放。
AssimpLevel类由两个新文件组成,AssimpLevel.h和AssimpLevel.cpp。这两个文件都在model文件夹中,但我们将借用AssimpModel和AssimpInstance两个类的大多数方法和成员。
让我们简要地浏览一下AssimpLevel类。
添加一个 C++类来存储关卡数据
由于关卡和模型/实例数据非常相似,我们可以重用AssimpModel和AssimpInstance类中已有的功能的一部分,例如加载模型文件或执行矩阵运算。
对于静态数据,我们可以从AssimpModel类复制以下方法和成员,将名称中的model部分替换为level以与类名保持一致:
-
loadModel()方法,但不包括骨骼、动画和查找表创建 -
processNode()方法,再次不包括骨骼特定的部分 -
整个
draw()方法 -
getTriangleCount()、getModelFileName()和getModelFileNamePath()方法 -
setModelSettings()和getModelSettings()方法 -
mTriangleCount和mVertexCount成员变量 -
用于存储有效关卡数据的
mRootNode、mNodeList、mRootTransformMatrix、mModelMeshes、mVertexBuffers和mModelSettings成员变量 -
mTextures和mPlaceholderTexture成员变量用于纹理
对于动态数据,以下方法和成员可以从AssimpInstance类中复制,再次在名称中将model替换为level:
-
updateModelRootMatrix()和getWorldTransformMatrix()方法 -
成员变量
mLocalTranslationMatrix、mLocalRotationMatrix、mLocalScaleMatrix、mLocalSwapAxisMatrix、mLocalTransformMatrix和mModelRootMatrix
为了将所有变量级别数据集中在一个位置,我们在model文件夹中创建了LevelSettings.h文件,其中包含LevelSettings struct:
struct LevelSettings {
std::string lsLevelFilenamePath;
std::string lsLevelFilename;
glm::vec3 lsWorldPosition = glm::vec3(0.0f);
glm::vec3 lsWorldRotation = glm::vec3(0.0f);
float lsScale = 1.0f;
bool lsSwapYZAxis = false;
};
如您所见,这些级别设置也是部分从模型设置(文件名)和实例设置(位置、旋转、缩放、轴交换)中取出的混合。LevelSettings数据类型将被用于简化用户界面中的级别设置以及保存和加载与级别相关的数据。
我们还通过添加名为micLevels的AssimpLevel共享指针向量和名为micSelectedlevel的整数,后者保存当前从向量中选择的级别,将加载的级别数据提供给应用程序的其他部分:
std::vector<std::shared_ptr<AssimpLevel>> micLevels{};
int micSelectedLevel = 0;
管理在micLevels向量中的AssimpLevel对象的主要工作将由渲染器类处理,因此我们作为第二步添加方法和回调。
添加回调和渲染器代码
新的级别功能与现有模型函数之间的相似性也体现在回调中。必须将三个新的回调定义levelCheckCallback、levelAddCallback和levelDeleteCallback添加到Callbacks.h文件中:
using levelCheckCallback = std::function<bool(std::string)>;
using levelAddCallback = std::function<bool(std::string)>;
using levelDeleteCallback =
std::function<void(std::string)>;
对于模型,我们有一组相同的回调函数。第一个回调函数levelCheckCallback用于检查是否存在同名级别的文件,而其他两个回调函数levelAddCallback和levelDeleteCallback则用于从文件中加载新的级别并删除现有的级别对象。
对于大多数回调函数,我们也将函数在ModelInstanceCamData.h中的ModelInstanceCamData结构体中可用:
levelCheckCallback micLevelCheckCallbackFunction;
levelAddCallback micLevelAddCallbackFunction;
levelDeleteCallback micLevelDeleteCallbackFunction;
在渲染器类中,添加了四个新方法来处理级别管理。同样,新方法hasLevel()、getLevel()、addLevel()和deleteLevel()主要复制自等效模型方法:
bool hasLevel(std::string levelFileName);
std::shared_ptr<AssimpLevel> getLevel(std::string
levelFileName);
bool addLevel(std::string levelFileName);
void deleteLevel(std::string levelFileName);
当hasLevel()正在检查micLevels向量以查看是否已加载同名级别的文件时,getLevel()返回现有AssimpLevel对象的共享指针,如果不存在请求的文件名对应的级别,则返回nullptr。
如其名称所示,addLevel()将尝试从本地存储加载级别数据文件并将新的AssimpLevel对象添加到micLevels中,而deleteLevel()如果存在,将从micLevels中删除请求的级别。
我们还添加了一个null级别以防止micLevels为空:
void OGLRenderer::addNullLevel() {
std::shared_ptr<AssimpLevel> nullLevel =
std::make_shared<AssimpLevel>();
mModelInstCamData.micLevels.emplace_back(nullLevel);
}
在渲染器的 init() 方法中创建三个新回调与 hasLevel()、addLevel() 和 deleteLevel() 方法的连接,使我们能够在 UserInterface 类中使用级别调用。
因此,让我们继续第三步,在级别数据管理的路径上添加新的用户界面元素。
使用级别属性控件扩展 UI
就像对 AssimpLevel 类一样,我们可以简单地从 UserInterface 类的 createSettingsWindow() 方法的其他部分复制并调整现有的控制元素,以创建名为 Levels 的新 CollapsingHeader。
在 图 11.1 中,显示了级别数据的用户界面部分的结果:

图 11.1:级别数据的用户界面控件
Levels 组合框从 ModelInstanceCamData 结构体中的 micLevels 向量内的级别文件名称填充。级别数据的 删除级别 按钮与 UI 的 模型 部分的 删除模型 按钮具有相同的功能,删除当前选定的级别,并且轴交换、位置、旋转和缩放的控制来自 实例 UI 部分,以及 重置值到零 按钮以将所有控件设置为默认值。
除了新的控制元素外,还创建了一个名为 Levels 的新主菜单项。图 11.2 显示了此时主菜单的所有元素:

图 11.2:新的 Levels 主菜单项
点击 加载级别... 将打开著名的基于 ImGui 的文件对话框,配置了支持级别数据的文件格式列表。有关过滤器中的扩展名,请参阅 选择地图的文件格式 部分。
删除模型 和 加载级别... UI 元素使用回调函数从应用程序中添加和删除一个 AssimpLevel 对象,当向虚拟世界添加级别数据时,为用户创建了一个无缝的工作流程。
在用户体验方面,级别数据和模型实例之间的显著区别是缺少对级别数据的视觉选择和修改功能。由于您将调整级别数据几次,直到对位置、旋转和缩放满意,因此用于像实例一样连续移动级别数据的额外代码将只使用一次或两次。如果当期望的实例因像素偏差而错过时选择级别数据,视觉选择甚至更有可能损害工作流程。
作为级别数据管理的最后一步,我们将加载的级别文件名称和每级设置添加到 YAML 配置文件中。
保存和加载级别配置
将加载的级别和级别设置存储在 YAM 配置文件中既快又简单。
在为 LevelSettings 数据类型添加了 Emitter 输出操作符重载之后,我们可以复制并调整 tools 文件夹中 YamlParser 类的 createConfigFile() 方法中用于模型或相机的发射代码块,以保存级别数据。此外,我们还必须在配置文件的 settings 映射中发射所选的级别编号。
此外,还向 YamlParser 类添加了两个名为 getLevelConfigs() 和 getSelectedLevelNum() 的新方法:
std::vector<LevelSettings> getLevelConfigs();
int getSelectedLevelNum();
这两种方法遵循与模型和相机类似的过程。第一种方法 getLevelConfigs() 尝试从 YAML 文件中加载级别数据,而 getSelectedLevelNum() 返回在保存配置时所选级别的索引。
在 YamlParserTypes.h 文件中,必须为 LevelSettings 数据类型添加一对简单的 encode() 和 decode() 方法,以便从 YAML 文件中读取数据。我们还应该增加 mYamlConfigFileVersion 的值,因为配置结构已更改。
现在,我们可以从文件中添加级别数据,将级别放置在虚拟世界中,并存储和重新加载配置。将级别数据添加到撤销/重做堆栈的工作留给你作为练习,但基本原理级别的撤销/重做与模型的撤销/重做功能相同。
由于级别数据可能包含重叠的元素,例如桥梁、隧道或建筑物的多层,实例可能在二维空间中的同一位置。即使实例在级别中行走的高度不同,现有的碰撞检测也会被触发,导致对不存在的碰撞产生错误反应。
我们必须将四叉树扩展为八叉树以支持三维空间中的碰撞检测。让我们看看如何升级四叉树。
将四叉树转换为八叉树
将四叉树更新为八叉树非常简单,大部分工作可以通过使用 IDE 的重构功能中的 Rename 函数来触发。为了简洁,我们在这里只简要说明所需更改。请检查 chapter11 文件夹内的子文件夹中的示例代码,以获取完整的八叉树代码。
首先,我们将 quadtree 文件夹的名称更改为 octree。在 CMakeLists.txt 文件中,必须将两个 quadtree 的出现也重命名为 octree 以匹配文件夹名称。然后我们将 QuadTree 类重命名为 Octree,并将 BoundingBox2D 改为 BoundingBox3D。
接下来,我们通过使用八个而不是四个子节点来扩展 Octree 类。图 11.3 展示了八叉树的象限 ID:

图 11.3:八叉树中的象限 ID
四个现有的子象限 ID,0 到 3,将用于四个面向前方的八分体,而四个面向后方的八分体将接收新的 ID 4 到 7。将四个新的八分体移动到八叉树立方体的后面,使我们能够保留大部分来自四叉树的逻辑。添加新的逻辑以找到正确的八分体只是一个复制和粘贴代码的行为,同时考虑到面向前和面向后的八分体。
然后,我们将代码中所有与QuadTree相关的include语句、类和方法调用更新为Octree,并将所有BoundingBox2D出现更改为BoundingBox3D。边界框升级包括将旧BoundingBox2D参数的所有glm::vec2类型更改为使用glm::vec3。我们还更改了getTopLeft()的名称为getFrontTopLeft(),并添加了getBack()方法以反映第三维度的可用性。
最后,代码中所有回调函数类型将从quadTree重命名为octree,以反映代码中其他所有位置的函数变化。
现在我们可以使用八叉树来检测三维中的碰撞。遗憾的是,实例位置窗口仍然显示实例的俯视图,我们无法看到是否有实例放置在不同的高度。我们必须调整包含实例的迷你窗口的渲染,以便我们可以看到放置在不同高度的任何实例。
创建交互式八叉树视图
新的八叉树视图创建分为三个步骤:
-
为了更好地了解八叉树和实例,可以使用鼠标进行缩放、旋转和平移来调整新的视图。可能还需要一些额外的旋转来使八叉树与相机视图对齐,但由于正交显示和八叉树的对称性,这很难做到。淡出八叉树远处的部分或突出显示包含相机的八分体可能有助于集中注意当前感兴趣的区域。另一方面,使用透视投影的八叉树可能更容易找到正确的对齐,但透视会扭曲实例之间的距离,我们只是创建了一个主要级别渲染的副本。
-
八叉树象限和实例的线条收集到一个临时数据结构中。我们将在这里使用
OGLLineMesh,因为这个数据类型只包含最基本的内容;我们需要绘制 ImGui 线条。 -
为了达到期望的视图,必须将八叉树象限的所有点和实例边界框通过第一步中的缩放、旋转和平移进行变换。这种变换与我们对相机和实例所做的方式相同:为每个点创建一个矩阵和一个矩阵-向量乘法。矩阵运算中不应有任何意外;可能只有平移需要解释。
让我们先逐步了解交互部分。
添加交互性
为了能够通过鼠标按钮和鼠标移动来更改八叉树视图,我们需要在 UserInterface 类中添加三个新的私有变量,分别命名为 mOctreeZoomFactor、mOctreeRotation 和 mOctreeTranslation:
float mOctreeZoomFactor = 1.0f;
glm::vec3 mOctreeRotation = glm::vec3(0.0f);
glm::vec3 mOctreeTranslation = glm::vec3(0.0f);
变量的名称是自解释的,因此我们不需要深入细节。
在 UserInterface 类的 createPositionsWindow() 方法中创建 实例位置 窗口后,我们添加一个检查以查看当前窗口是否被悬停:
if (ImGui::IsWindowHovered(
ImGuiHoveredFlags_RootAndChildWindows)) {
没有检查,鼠标按钮和动作会在所有窗口中触发八叉树视图变化,导致不希望的结果。
然后,我们获取 ImGui 内部 io 结构的引用:
ImGuiIO& io = ImGui::GetIO();
mOctreeZoomFactor += 0.025f * io.MouseWheel;
mOctreeZoomFactor = std::clamp(mOctreeZoomFactor,
0.1f, 5.0f);
在 ImGui 的 io 结构中,有许多内部状态可用,如鼠标滚轮或鼠标位置所做的更改。
我们在这里使用鼠标滚轮来调整八叉树视图的缩放因子。为缩放因子设置上下限有助于避免失去整体概览。
接下来,我们检查是否按下了右鼠标按钮,并在右鼠标按钮仍然按下的同时根据鼠标移动旋转视图:
if (ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
mOctreeRotation.y += io.MouseDelta.x;
mOctreeRotation.x += io.MouseDelta.y;
}
使用右鼠标按钮来更改旋转也用于相机,因此这种视图变化应该从与应用程序一起工作时了解。
最后,我们检查中间鼠标按钮,通过鼠标移动移动八叉树:
if (ImGui::IsMouseDown(ImGuiMouseButton_Middle)) {
mOctreeTranslation.x += io.MouseDelta.x;
mOctreeTranslation.y += io.MouseDelta.y;
}
}
如果你想知道为什么这里不使用左鼠标按钮:在 ImGui 窗口上方按下左鼠标按钮会激活内部窗口移动。我们只剩下右键和中间鼠标按钮来实现两种不同的视图变化。
一旦我们得到了想要看到的变换,我们就可以从八叉树和实例中获取线条。
收集线条
为了在绘制之前存储线条,我们在 UserInterface 类中添加一个名为 mOctreeLines 的 private 变量:
OGLLineMesh mOctreeLines{};
在清除 mOctreeLines 内部的顶点向量后,我们得到八叉树线:
mOctreeLines.vertices.clear();
const auto treeBoxes =
modInstCamData.micOctreeGetBoxesCallback();
对于每个八叉树象限,我们获取一个 BoundingBox3D 对象,包含最小和最大点位置。将其转换为 AABB 是简单的:
AABB boxAABB{};
boxAABB.create(box.getFrontTopLeft());
boxAABB.addPoint(box.getFrontTopLeft() +
box.getSize());
然后,我们使用 getAABBLines() 方法创建所有 AABB 线作为 OGLLineMesh,并将线条添加到 mOctreeLines 网格中:
std::shared_ptr<OGLLineMesh> instanceLines =
boxAABB.getAABBLines(white);
mOctreeLines.vertices.insert(
mOctreeLines.vertices.end(),
instanceLines->vertices.begin(),
instanceLines->vertices.end());
接下来,我们为每个实例获取 AABB 线,将碰撞的实例着色为红色,其他所有实例着色为黄色。当前选中的实例还会额外获得一个绿色边框,以便能够轻松停止实例。
在对八分体和实例的循环结束后,mOctreeLines 包含了应该绘制到 ImGui 窗口的所有线的顶点。我们现在需要将这些顶点转换以匹配所选的缩放、旋转和平移。
计算视图和绘制线条
由于存储中间结果比在每一帧中为八叉树线分配和计算新的变换矩阵要快,我们添加了三个名为 mScaleMat、mRotationMat 和 mOctreeViewMat 的 private 成员变量:
glm::mat3 mOctreeViewMat = glm::mat3(1.0f);
glm::mat4 mRotationMat = glm::mat4(1.0f);
glm::mat4 mScaleMat = glm::mat4(1.0f);
然后,应用缩放和两个旋转以创建最终的视图矩阵:
mScaleMat = glm::scale(glm::mat4(1.0f),
glm::vec3(mOctreeZoomFactor));
mRotationMat = glm::rotate(mScaleMat,
glm::radians(mOctreeRotation.x),
glm::vec3(1.0f, 0.0f, 0.0f));
mOctreeViewMat = glm::rotate(mRotationMat,
glm::radians(mOctreeRotation.y),
glm::vec3(0.0f, 1.0f, 0.0f));
现在我们以两组为单位遍历 mOctreeLines 中的顶点,因为每条线需要一个起点和终点:
for (int i = 0; i < mOctreeLines.vertices.size(); i += 2) {
在循环内部,我们提取每条线的顶点对,并通过与 mOctreeViewMat 相乘来变换顶点位置:
OGLLineVertex startVert = mOctreeLines.vertices.at(i);
OGLLineVertex endVert = mOctreeLines.vertices.at(i+1);
glm::vec3 startPos = mOctreeViewMat *
startVert.position;
glm::vec3 endPos = mOctreeViewMat * endVert.position;
接下来,我们将点坐标添加到绘制中心,并添加平移部分:
ImVec2 pointStart =
ImVec2(drawAreaCenter.x + startPos.x +
mOctreeTranslation.x,
drawAreaCenter.y + startPos.z +
mOctreeTranslation.y);
ImVec2 pointEnd =
ImVec2(drawAreaCenter.x + endPos.x +
mOctreeTranslation.x,
drawAreaCenter.y + endPos.z +
mOctreeTranslation.y)
我们在这里不需要单独的平移矩阵,因为八叉树线的移动仅与 ImGui 窗口有关,而不是三维中顶点的位置。
在循环结束时,我们使用线的颜色和固定的 alpha 值,从起点到终点绘制一个 ImGui 线:
drawList->AddLine(pointStart, pointEnd,
ImColor(startVert.color.r, startVert.color.g,
startVert.color.b, 0.6f));
如果在这些更改之后启动应用程序,可以通过按下鼠标右键旋转八叉树视图,通过按下鼠标中键移动,以及使用鼠标滚轮进行缩放和缩小:
图 11.4 展示了在 实例位置 窗口中的八叉树视图:

图 11.4:一个旋转的八叉树,包含实例和两个细分级别
你可以看到所有实例在三维空间中的边界框。碰撞实例用红色绘制,所有其他实例用黄色(与 AABB 调试线的颜色相同),当前选定的实例有额外的绿色轮廓。
另一个需要看到的重要事实是八叉树的细分级别。在八叉树的根级别,一次细分被分成八个八分体——这是由于实例数量的原因。在远右下角的八分体中,需要另一个细分来限制每个八分体的实例数量。
当应用程序运行时,可以实时看到实例的移动和八叉树细分的更改。并且可以更改视图以聚焦于八叉树的任何有趣区域。
默认八叉树实现的一个缺点是不断增长的额外开销。细分可能会产生许多空节点,特别是如果每个节点的阈值较小且树深度较高时。对于我们的实现,每次将八叉树细分到八个八分体时,都会在内存使用上增加大约一千字节的开销,即使我们只需要在其中一个子八分体中存储一个或两个实例的 AABB。
此外,每次细分都会增加遍历成本的一个步骤。尽管遍历复杂度仅以对数为基础增长,但在较大级别中,内存和遍历时间上的开销可能会变得显著。在这种情况下,可以使用诸如稀疏体素八叉树之类的数据结构。在 附加资源 部分提供了一个描述稀疏体素八叉树原理的论文链接。
当前级别加载的一个缺点是缺少对加载的级别数据尺寸的“感觉”。如果级别不是平的,而是包含不同高度的区域,那么想象级别数据的边缘是很困难的。
为了获得更好的方向,我们还将为级别数据添加一个 AABB。
为级别数据构建 AABB
轴对齐边界框是避免估计或猜测对象尺寸的一个很好的工具。我们使用模型实例 AABB 作为解决方案,通过允许应用程序比较两个实例的最大范围来检测碰撞。
虽然实例顶点和边界框之间的未使用区域在碰撞检测的速度和复杂性之间是一个权衡,但对于级别数据来说,情况是不同的。AABB 线将帮助我们看到级别在所有三个维度上的最大范围,特别是当级别数据有大量未使用区域时。
创建 AABB 的过程既快又简单。首先,我们在 AssimpLevel.h 文件中添加一个新的私有成员变量 mLevelAABB 来存储 AABB:
AABB mLevelAABB{};
为了生成和检索边界框数据,我们添加了两个新的公共方法,generateAABB() 和 getAABB():
void generateAABB();
AABB getAABB();
将 AABB 数据的生成与检索分开是一个巧妙的主意,因为级别可能包含大量的网格和顶点。只有在调整级别属性(如缩放或位置)时才需要重新计算,因为级别数据本身不会移动或改变其他属性。
我们已经有了级别数据的变换矩阵,因此计算 generateAABB() 中级别的边界框只需要遍历所有网格的所有顶点:
updateLevelRootMatrix();
mLevelAABB.clear();
for (const auto& mesh : mLevelMeshes) {
for (const auto& vertex : mesh.vertices) {
mLevelAABB.addPoint(mLevelRootMatrix *
glm::vec4(glm::vec3(vertex.position), 1.0f));
}
}
在这里的一个关键步骤是改变顶点位置中的 w 元素。我们使用位置的最后元素来传输一个纹理坐标到着色器。为了正确的矩阵乘法,我们必须将位置的最后元素设置为 1.0f。
为了在级别属性变化时触发级别 AABB 的自动更新,我们在 Callbacks.h 中添加了一个简单的回调:
using levelGenerateAABBCallback = std::function<void(void)>;
在 ModelInstanceCamData 结构体中,添加了另一个回调函数:
levelGenerateAABBCallback
micLevelGenerateAABBCallbackFunction;
我们在渲染器的 init() 方法中将 micLevelGenerateAABBCallbackFunction 回调绑定到 generateLevelAABB() 方法,以及其他级别数据回调。
为了在用户界面中获得良好的交互式显示,我们在 Levels 部分添加了一个名为 settingChangedForAABB 的布尔值。在每次滑块或复选框更改时,或者在按下 重置值到零 按钮时,我们触发级别 AABB 的重新计算。
那其他静态级别元素呢?这些对象被称为 assets,并且可以被添加到级别中。资产也需要碰撞检查以防止实例直接穿过彼此,但资产大多数情况下不是动画化的;它们保持在级别设计师意图的位置。
我们在这里将使用快捷方式,并利用非动画模型来模拟静态级别资产。对于动态资产,如按钮或门,可以使用动画模型;请参阅级别数据不会移动部分以获得简要说明。在实际操作部分,有一个将动态资产添加到虚拟世界的任务。
那么,让我们看看非动画模型需要哪些更改。
使用非动画模型作为资产
为了避免添加一个与大多数非动画模型具有相同功能的新AssimpAsset类,我们将稍微扩展当前的AssimpModel类和渲染器。
对于模型类,我们将getAABB()方法改为返回具有动画的模型的动态 AABB 或没有动画的模型的静态 AABB:
AABB AssimpModel::getAABB(InstanceSettings instSettings) {
if (mNumAnimatedMeshes > 0) {
return getAnimatedAABB(instSettings);
} else {
return getNonAnimatedAABB(instSettings);
}
}
新的getAnimatedAABB()方法只是旧getAABB()方法的新名称,它仍然像以前一样从查找数据中计算 AABB。另一个新方法getNonAnimatedAABB()主要是AssimpInstance类中的updateModelRootMatrix()方法。
首先,我们计算一个用于缩放、旋转、轴交换和平移的单独矩阵:
glm::mat4 localScaleMatrix = glm::scale(glm::mat4(1.0f),
glm::vec3(instSettings.isScale));
glm::mat4 localSwapAxisMatrix;
if (instSettings.isSwapYZAxis) {
glm::mat4 flipMatrix = glm::rotate(glm::mat4(1.0f),
glm::radians(-90.0f), glm::vec3(0.0f, 0.0f, 1.0f));
localSwapAxisMatrix = glm::rotate(flipMatrix,
glm::radians(-90.0f), glm::vec3(0.0f, 1.0f, 0.0f));
} else {
localSwapAxisMatrix = glm::mat4(1.0f);
}
glm::mat4 localRotationMatrix = glm::mat4_cast(
glm::quat(glm::radians(instSettings.isWorldRotation)));
glm::mat4 localTranslationMatrix =
glm::translate(glm::mat4(1.0f),
instSettings.isWorldPosition);
然后,将模型文件中的变换矩阵和根变换矩阵组合成一个单一的变换矩阵:
glm::mat4 localTransformMatrix =
localTranslationMatrix * localRotationMatrix *
localSwapAxisMatrix * localScaleMatrix *
mRootTransformMatrix;
要创建边界框数据,将模型网格的所有顶点转换到新位置,并将位置添加到名为modelAABB的局部变量中:
AABB modelAABB{};
for (const auto& mesh : mModelMeshes) {
for (const auto& vertex : mesh.vertices) {
modelAABB.addPoint(localTransformMatrix *
glm::vec4(glm::vec3(vertex.position), 1.0f));
}
}
对于顶点位置中的w元素,需要与级别数据 AABB 相同的调整。通过将最后一个值设置为1.0f,我们确保在计算最终位置时进行正确的矩阵乘法。
使用得到的 AABB,非动画实例顶点的范围能够检测与动画模型的 AABB 碰撞并绘制调试线。
资产 AABB 的性能考虑
目前,非动画实例的 AABB 在每一帧的每个实例上都会计算,就像动画实例的 AABB 一样。如果计算成为瓶颈,例如,如果虚拟世界中放置了大量的静态资产,可以在 UI 属性更改时轻松地仅更改 AABB 计算。
一旦级别数据被加载和处理,AABB 被计算,我们就准备好将级别三角形绘制到屏幕上。
让我们扩展渲染器以将级别数据添加到渲染过程中。
将级别数据发送到 GPU
随着新功能的不断增加,在应用程序中重用代码变得更加简单。在级别数据的情况下,网格数据封装在AssimpLevel类中,并且可以通过遍历所有加载的级别的小循环来绘制级别的三角形。
作为第一步,我们创建一个新的着色器来绘制级别数据。
创建一个新的着色器
由于我们只需要绘制等级三角形一次,因此为等级数据使用单独的着色器是一个巧妙的主意。将名为 mAssimpLevelShader 的 private 着色器添加到渲染器类的头文件中:
Shader mAssimpLevelShader{};
着色器将使用两个新文件,assimp_level.vert 用于顶点着色器,assimp_level.frag 用于片段着色器。我们在渲染器的 init() 方法中加载这些文件以及其他着色器:
if (!mAssimpLevelShader.loadShaders(
"shader/assimp_level.vert",
"shader/assimp_level.frag")) {
return false;
}
片段着色器文件 assimp_level.frag 只是 assimp.frag 文件的副本,没有任何更改。对于顶点着色器文件 assimp_level.vert,我们可以复制 assimp.vert 文件,并保留 in 和 out 布局部分以及 Matrices 通用缓冲区。
由于等级数据尚未实现视觉选择,我们不需要选择缓冲区,并且可以将绑定点 1 的缓冲区更改为仅包含单个 4x4 矩阵:
layout (std430, binding = 1) readonly restrict
buffer WorldTransformMatrix {
mat4 worldTransformMat;
};
仅将小型数据元素,如单个矩阵,上传到 GPU 上的统一缓冲区并不是最佳方案,因为每次上传都可能给帧添加一小段延迟。对于真正的游戏或游戏引擎,世界变换矩阵将是更大缓冲区的一部分,但为了简单起见,我们在这里只进行单次上传。
在顶点着色器的 main() 方法中,我们使用 worldTransformMat 将位置和法线顶点变换到由等级属性创建的矩阵给出的最终位置:
void main() {
gl_Position = projection * view * worldTransformMat *
vec4(aPos.x, aPos.y, aPos.z, 1.0);
color = aColor;
normal = transpose(inverse(worldTransformMat)) *
vec4(aNormal.x, aNormal.y, aNormal.z, 1.0);
texCoord = vec2(aPos.w, aNormal.w);
}
然后,在渲染器的 draw() 方法中,我们遍历所有等级,并通过检查是否存在任何三角形来跳过空等级:
for (const auto& level : mModelInstCamData.micLevels) {
if (level->getTriangleCount() == 0) {
continue;
}
在等级显示过程的最后一步,触发顶点绘制:
mAssimpLevelShader.use();
mShaderModelRootMatrixBuffer.uploadSsboData(
level->getWorldTransformMatrix(), 1);
level->draw();
}
我们使用新的 mAssimpLevelShader,将等级的变换矩阵上传到着色器,并调用 AssimpLevel 对象的 draw() 方法。
在屏幕上绘制等级 AABB 需要在渲染器中添加更多方法和成员变量。
绘制等级 AABB
等级 AABB 顶点存储在渲染器类中的一个名为 mAllLevelAABB 的新 private 成员变量中:
AABB mAllLevelAABB{};
此外,还向渲染器类中添加了两个名为 generateLevelAABB() 和 drawLevelAABB() 的新私有方法:
void generateLevelAABB();
void drawLevelAABB(glm::vec4 aabbColor);
我们在这里也将生成和绘制 AABB 线分开,以避免在每帧绘制时进行昂贵的计算。
在 generateLevelAABB() 中生成等级 AABB 是通过一个简单的循环完成的。在清除等级 AABB 数据后,我们遍历所有已加载的等级:
mAllLevelAABB.clear();
for (const auto& level : mModelInstCamData.micLevels) {
if (level->getTriangleCount() == 0) {
continue;
}
检查没有三角形的等级会跳过空等级,因为屏幕上没有东西可以绘制。然后,我们为每个等级生成 AABB 并将每个等级的最小和最大范围添加到 mAllLevelAABB:
level->generateAABB();
mAllLevelAABB.addPoint(level->getAABB().getMinPos());
mAllLevelAABB.addPoint(level->getAABB().getMaxPos());
}
生成的 AABB 包含所有已加载等级的所有等级数据。如果您想为每个等级单独拥有 AABB,则可以使用 AABB 向量而不是单个 AABB。
在 drawLevelAABB() 方法中绘制等级 AABB 需要很少的解释:
mAABBMesh = mAllLevelAABB.getAABBLines(aabbColor);
mLineVertexBuffer.uploadData(*mAABBMesh);
mLineShader.use();
mLineVertexBuffer.bindAndDraw(GL_LINES, 0,
mAABBMesh->vertices.size());
我们使用 AABB 的 getAABBLines() 方法创建线数据,将线上传到 mLineVertexBuffer,在 LineShader 上调用 use(),然后对 mLineVertexBuffer 对象调用 bindAndDraw() 以绘制级别数据的轴对齐边界框。
然后,在渲染器的 draw() 方法中,当 rdDrawLevelAABB 设置为 true 时,我们调用 drawLevelAABB():
if (mRenderData.rdDrawLevelAABB) {
glm::vec4 levelAABBColor =
glm::vec4(0.0f, 1.0f, 0.5, 0.5f);
drawLevelAABB(levelAABBColor);
}
颜色是随机选择的,你可以更改颜色值,甚至可以向 UI 添加一个 3 元素浮点滑块来控制颜色值。
在屏幕上显示级别 AABB 线是通过在 OGLRenderData.h 文件中的 OGLRenderData struct 中的新布尔变量 rdDrawLevelAABB 实现的:
bool rdDrawLevelAABB = false;
对于代码的 Vulkan 版本,该变量将在 VkRenderData.h 文件中的 VKRenderData struct 中创建。
在 UserInterface 类中,我们在 createSettingsWindow() 方法的 Levels 部分添加了一个复选框 rdDrawLevelAABB,允许我们切换级别的 AABB 线。
就这些!在 图 11.5 中,一个来自 Sketchfab 的示例地图被加载在女性模型旁边,并且激活了级别的 AABB:

图 11.5:示例地图(未缩放)和女性模型的实例(来源:Counter Strike 1.6 地图“de_dust”由 Zarudko 制作,链接:https://skfb.ly/6QYJw)
作为一名玩家,你可能知道这个地图:它是 Counter Strike 1.6 中的“de_dust”。这个地图在 Sketchfab 上有多个版本和格式,以及其他流行的游戏地图。
围绕级别数据的边界框帮助我们看到级别的尺寸。特别是对于如图 11.5 前右部所示的未使用区域,没有 AABB 线,很难找到级别数据边界。
你还会注意到地图实例之间不寻常的大小比例。地图和模型已经使用它们的默认缩放值加载,大小值是由文件作者任意选择的。由于模型和级别数据的缩放滑块,调整一个或两个对象的大小很容易,但要从不同来源和艺术家那里获取合理的级别、模型和其他对象的比例可能具有挑战性。
为了创建一个合理的虚拟世界,游戏角色模型的大小必须与家具、建筑、环境对象等的大小相匹配。即使不同对象的比例有微小的不匹配,当比较游戏角色和现实世界中的对象时,也会变得明显。调整不同元素的一个好方法是使用一个已知大小的固定大小对象,例如一个边长为一米的 Minecraft 块,或者虚拟世界中一个定义长度的简单线条。通过将所有对象调整到与已知对象的真实世界大小相比,来自不同来源的模型可以在视觉上匹配。
摘要
在本章中,我们将静态层级数据添加到虚拟世界中。在探索了层级图和模型之间的差异后,我们研究了适合层级数据的文件格式以及如何找到酷炫的游戏地图。然后,我们将对层级数据的支持添加到应用程序中,并用八叉树替换四叉树以支持三维碰撞检测。最后,我们将新的层级数据和层级 AABB 添加到渲染器中。在接下来的两个章节中,我们将扩展层级数据,以创建一个实例可以自由漫游的虚拟世界。
在下一章中,我们将继续从第八章的碰撞检测。首先,我们将扩展现有代码以支持实例和层级几何之间的碰撞。为了确保实例始终在地面,我们将在虚拟世界中引入重力。作为最后一步,我们将逆运动学添加到实例的腿部,允许模型以自然运动爬楼梯或斜坡。
实践课程
这里是一些你可以添加到代码中的改进:
- 向层级中添加一个门或按钮。
由于动画层级数据更像是放置在层级固定位置的动画角色模型,而不是静态层级数据,你可以尝试在层级中添加门、开关或按钮,并且在与交互时,按钮可以播放“按下”动画,门可以绕着铰链旋转或向一侧滑动。
- 添加动态层级加载。
你可以使用关于可玩角色当前位置的信息来决定何时加载或卸载层级的一部分。也许可以添加一个新的坐标映射,其中指定加载层级数据文件,另一个坐标用于从应用程序中移除层级数据。如果你已经从第一个任务中添加了动画层级对象到动态层级部分,确保在卸载时保存这些对象的状态,并在重新加载层级数据时恢复状态。玩家可以打开门或激活开关,当返回到层级部分时,玩家可以看到对象处于他们离开时的确切状态。为了防止由于未完全加载或销毁的资产导致的崩溃或数据损坏,请使用原子操作或互斥锁等锁定机制。此外,加载和层级数据以及资产可能需要同步以恢复所有元素的正确状态。
- 专家难度:异步加载动态层级部分。
加载层级文件、处理数据和添加顶点数据需要一些时间,导致应用程序中出现明显的卡顿。当满足加载条件时,例如玩家位于特定的世界位置时,使用std::async或完整的工人线程来触发加载。请注意,添加任何异步代码都需要采取额外措施以防止数据竞争。
补充资源
-
Blender 地图创建 1:
www.youtube.com/watch?v=IKkOLeAuEHI -
Blender 地图创建 2:
www.youtube.com/watch?v=hdyBgQ77Sdg -
什么是光照贴图?:
wiki.polycount.com/wiki/Light_map -
导航网格解释:
medium.com/@mscansian/a-with-navigation-meshes-246fd9e72424 -
如何编写 glTF 自定义扩展:
gltf-transform.dev/extensions -
itch.io 资产:
itch.io/game-assets -
Sketchfab 用于模型、地图和资产:
sketchfab.com -
Free3D:
free3d.com -
Zarudko 制作的 Counter Strike 1.6 地图“de_dust”:
skfb.ly/6QYJw
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation

第十二章:高级碰撞检测
欢迎来到第十二章!在前一章中,我们向虚拟世界添加了加载静态关卡数据的能力。现在实例不再需要在空中运行;它们可以拥有一个虚拟的家。首先,我们探讨了模型数据和关卡数据之间的差异,以及主要用于关卡数据的文件格式,以及在哪里可以找到互联网上的游戏地图。然后,我们添加了加载关卡数据的代码,并用三维八叉树替换了二维四叉树。最后,我们实现了关卡数据的渲染,包括线框、八叉树和关卡 AABB 线的调试数据。
在本章中,我们将扩展前一章中的关卡数据。首先,我们将为关卡数据添加一个专门的八叉树,并更新代码以支持实例和关卡几何之间的碰撞检测。然后,我们将向虚拟世界添加一种简化的重力形式,以使实例保持在地面水平,而不是在空中漂浮。作为最后一步,我们将为实例腿部引入逆运动学,以允许实例以更自然的腿部动作爬坡和上楼梯,并防止脚部嵌入地面或漂浮在空中。
在本章中,我们将涵盖以下主题:
-
提升关卡数据的碰撞检测
-
使用重力使实例保持在地板水平
-
添加逆运动学
技术要求
本章的示例代码位于chapter12文件夹中,对于 OpenGL 位于01_opengl_adv_collision子文件夹,对于 Vulkan 位于02_vulkan_adv_collision子文件夹。
提升关卡数据的碰撞检测
为了加速实例和关卡几何之间的碰撞检测,我们将为关卡数据创建空间分区,例如八叉树。但是,我们不会将关卡三角形添加到实例八叉树中,而是为三角形数据单独构建一个专门的八叉树。
添加新的八叉树类型
使用单独的数据结构来处理关卡数据比试图将两种数据类型混合到现有的八叉树中更有意义,原因如下:
-
关卡数据是静态的,而实例位置频繁变化。我们需要在每次实例位置变化时对高度使用的八叉树进行昂贵的更新,这可能导致在移除和重新添加实例时进行大量的分割和合并操作。
-
关卡数据和实例的细分数量可能完全不同,这取决于关卡复杂性和实例数量。在详细关卡周围只有少数实例漫游可能会导致在搜索附近的三角形或实例时产生巨大的开销。
-
我们为了简化,使用八叉树来处理关卡数据,但其他数据结构,如 BSP 树或边界体积层次(BVHs),更为常见。由于 BSP 树和 BVHs 不能像我们的八叉树那样快速动态更新,因此在关卡数据和实例之间仍然需要进行分割。
通过使用两个不同的八叉树,我们可以克服上述提到的问题。在添加所有级别三角形后,级别数据八叉树保持不变,两个八叉树都有它们自己的细分,这取决于每个八分体的数据量,但我们可以通过使用级别八叉树中的实例边界框来组合信息。
作为级别八叉树的第一步,我们在 opengl 文件夹中的 OGLRenderData.h 文件中添加了一个名为 MeshTriangle 的新 struct:
struct MeshTriangle {
int index;
std::array<glm::vec3, 3> points;
BoundingBox3D boundingBox;
};
对于 Vulkan,三角形 struct 将添加到 vulkan 文件夹中的 VkRenderData.h 文件中。
index 成员主要用于调试目的,如果某些三角形无法添加到八叉树中,它将被添加到日志输出行中。在 points 数组中,我们保存三角形三个点的世界位置。世界位置用于创建三角形适当边界框,我们稍后也将使用世界位置进行碰撞检测。boundingBox 成员包含级别数据网格中每个三角形的 AABB。
使用边界框而不是八叉树中的实际三角形数据大大简化了查询操作,因为我们不需要在搜索碰撞时检查每个三角形的精确轮廓。我们可能会通过使用 AABB 检查出更多的三角形,但由于我们只需要进行最多六个简单的 float 比较即可,因此 AABB 检查的成本很低。由于大多数级别的几何部分要么是墙壁要么是地面,因此 AABB 的额外大小并不重要。
接下来,我们在 octree 文件夹中添加了新的八叉树类 TriangleOctree。新的三角形八叉树将在两个新文件 TriangleOctree.h 和 TriangleOctree.cpp 中实现。
TriangleOctree 类是正常 Octree 类的一个副本,但有几点例外:
-
我们在树中存储三角形数据而不是实例索引。
-
由于级别数据八叉树将保持只读状态,我们不需要更新或删除对象或合并八分体的方法。
-
在三角形八叉树中,我们只处理静态数据,对同一级别的三角形进行交点搜索不会为我们提供任何有用的信息。因此,
findAllIntersections()和findIntersectionsInDescendants()方法也可以省略。
除了在 添加新的八叉树类型 部分中提到的使用单独八叉树存储级别数据的理由外,我们还为不适合单个八分体的对象使用不同的方法。
在实例八叉树中,实例的边界框只有在罕见情况下才会大于单个八分体,例如实例通过大因子缩放时。但在级别八叉树中,许多三角形的边界框可能无法适应单个细分八分体。级别的创建者会尝试最小化级别中的三角形数量,以获得良好的渲染性能,从而产生仅由少数大三角形组成的地形区域。
我们可以通过以下三种方法之一克服大小问题:
-
保持一个足够大的三角形在八分界中,以包含整个三角形。这种解决方案将在父节点中存储额外的对象,而不仅仅是叶子节点。
-
将三角形添加到所有受影响的细分八分界中。我们只有在最坏的情况下才会重复 8 次三角形数据,但叶子节点中只有数据。
-
在八分界线上分割三角形,并将子三角形添加到每个八分界中。同样,每个受影响的八分界将增加一个额外的三角形,并且可能在分割线上存在舍入误差的问题。
为了保持代码简单,我们将使用第一种方法,并且只在父八分界中添加超出细分八分界尺寸的任何三角形。
我们可以通过在add()和split()方法中进行两步检查来实现超大三角形的存储过程。首先,我们遍历所有子八分界以找到三角形与子八分界边界的可能交点:
int intersectingChildren = 0;
for (int i = 0; i < node->childs.size(); ++i) {
BoundingBox3D childBox = getChildOctant(box, i);
if (childBox.intersects(triangle.boundingBox)) {
intersectingChildren++;
}
}
如果我们与子八分界相交,我们增加intersectingChildren变量。然后,对于add()方法,我们检查三角形将与多少个子八分界相交。如果超过一个八分界,则保持三角形在当前八分界中:
if (intersectingChildren > 1) {
node->triangles.emplace_back(triangle);
} else {
int i = getOctantId(box, triangle.boundingBox);
if (i != -1) {
add(node->childs.at(i), depth + 1,
getChildOctant(box, i), triangle);
}
}
如果我们只与单个子八分界相交,我们将递归地将三角形交给子八分界。
对于split()方法,我们做同样的事情,如果在当前八分界中找到与未来子八分界的多个交点,则保持三角形在当前八分界中:
if (intersectingChildren > 1) {
newTriangles.emplace_back(triangle);
} else {
int i = getOctantId(box, triangle.boundingBox);
if (i != -1) {
node->childs.at(i)
->triangles.emplace_back(triangle);
}
}
查询三角形八叉树以检测与边界框的碰撞的query()方法和显示八叉树调试线的getTreeBoxes()方法与原始八叉树相同,只需调整private query()方法的数据类型。
在TriangleOctree准备就绪后,我们可以将级别数据添加到新的八叉树中,并查询树以检测碰撞。
填充级别数据八叉树
如同实例八叉树一样,我们需要将TriangleOctree.h头文件添加到渲染器头文件中,然后添加一个名为mTriangleOctree的新private成员和两个private方法initTriangleOctree()和generateLevelOctree():
std::shared_ptr<TriangleOctree> mTriangleOctree = nullptr;
void initTriangleOctree(int thresholdPerBox, int maxDepth);
void generateLevelOctree();
为了有默认的阈值和深度值,并且能够通过 UI 稍后控制设置,在opengl文件夹中的OGLRenderData.h文件中的OGLRenderData struct中存储了两个新变量rdLevelOctreeThreshold和rdLevelOctreeMaxDepth:
int rdLevelOctreeThreshold = 10;
int rdLevelOctreeMaxDepth = 5;
对于 Vulkan,在vulkan文件夹中的VkRenderData.h文件中添加了两个变量到VkRenderData struct。
在渲染器的init()方法中,调用initTriangleOctree()来创建具有给定阈值和最大深度的八叉树:
void OGLRenderer::initTriangleOctree(int thresholdPerBox,
int maxDepth) {
mTriangleOctree = std::make_shared<TriangleOctree>(
mWorldBoundaries, thresholdPerBox, maxDepth);
}
在生成级别 AABB 期间更新世界边界,因此我们的三角形八叉树在加载级别后与级别数据大小完全相同。
在generateLevelOctree()方法中完成填充级别数据八叉树。我们在这里只是走过重要的部分,因为外部代码只是遍历ModelInstanceCamData结构体中的micLevels向量的所有级别。
对于micLevels中的每个级别,我们以绘制级别的优化网格形式获取级别网格。然后,我们遍历级别网格的所有索引:
std::vector<OGLMesh> levelMeshes =
level->getLevelMeshes();
glm::mat4 transformMat =
level->getWorldTransformMatrix();
glm::mat3 normalMat =
level->getNormalTransformMatrix();
for (const auto& mesh : levelMeshes) {
int index = 0;
for (int i = 0; i < mesh.indices.size(); i += 3) {
对于 Vulkan,levelMeshes向量将包含VkMesh数据类型。
我们必须使用索引来绘制三角形,因为三角形数据是根据索引存储的;直接使用顶点将不会给我们提供正确的信息来推断三角形的顶点。我们还从级别中检索世界和法线变换矩阵。法线变换矩阵只是世界变换矩阵的逆矩阵的转置,已经添加了getNormalTransformMatrix()方法来保持AssimpLevel类中的额外变换。
接下来,我们创建一个空的MeshTriangle并使用级别的变换矩阵将级别顶点变换到世界位置:
MeshTriangle tri{};
tri.points.at(0) = transformMat *
glm::vec4(glm::vec3(mesh.vertices.at(
mesh.indices.at(i)).position), 1.0f);
tri.points.at(1) = transformMat *
glm::vec4(glm::vec3(mesh.vertices.at(
mesh.indices.at(i + 1)).position), 1.0f);
tri.points.at(2) = transformMat *
glm::vec4(glm::vec3(mesh.vertices.at(
mesh.indices.at(i + 2)).position), 1.0f);
现在是时候为每个三角形创建边界了:
AABB triangleAABB;
triangleAABB.clear();
triangleAABB.addPoint(tri.points.at(0));
triangleAABB.addPoint(tri.points.at(1));
triangleAABB.addPoint(tri.points.at(2));
使用AABB可以轻松计算边界框坐标。从这个AABB中,我们创建一个BoundingBox3D并将结果存储在MeshTriangle struct的boundingBox成员中:
tri.boundingBox = BoundingBox3D(
triangleAABB.getMinPos() -
glm::vec3(0.0001f),
triangleAABB.getMaxPos() -
triangleAABB.getMinPos() + glm::vec3(0.0002f));
为了保持与X、Y或Z平面共面的三角形,需要添加一个小偏移量。如果没有偏移量,三角形的边界框在某个或多个维度上的尺寸可能会变成零,这将使我们无法检测与该三角形的碰撞。
最后,我们存储并增加调试索引号并将三角形添加到级别数据八叉树:
tri.index = index++;
mTriangleOctree->add(tri);
}
通过在添加或删除级别数据时调用generateLevelOctree,我们确保我们的八叉树具有所有三角形的正确世界位置。在级别变化时实现更新的最佳方式是将八叉树更新与已经实现的级别数据AABB更新绑定。
为了实现这种耦合,我们添加了一个名为generateLevelVertexData()的新private方法,并在那里调用AABB和八叉树生成:
void OGLRenderer::generateLevelVertexData() {
generateLevelAABB();
generateLevelOctree();
}
然后,所有generateLevelAABB()的出现都被新的generateLevelVertexData()方法所替换,并且每当级别数据或其他属性,如旋转或缩放,发生变化时,级别八叉树也会更新。
使用最新的级别八叉树,我们最终可以检查实例是否与级别几何体发生碰撞。
检测实例/级别碰撞
通过使用与我们在第八章中“添加四叉树以存储附近的模型实例”部分相同的策略,对于实例/实例碰撞,找到实例与关卡三角形的碰撞很容易。我们只需遍历所有实例,获取实例的边界框,并查询三角形八叉树以获取与该边界框的碰撞。八叉树为我们提供了所有三角形,其中三角形的边界框与实例的边界框发生碰撞,即使实例未存储在三角形八叉树中。
要实现实例和关卡数据之间的碰撞检测,请按照以下步骤操作:
-
首先,我们在
InstanceSettings.h文件中的InstanceSettingsstruct中添加一个新的成员isCollidingTriangles,用于存储碰撞的三角形:std::vector<MeshTriangle> isCollidingTriangles{}; -
然后,在
AssimpInstance类中创建一个新的public方法setCollidingTriangles(),将传入的三角形数据存储在实例设置中:void AssimpInstance::setCollidingTriangles( std::vector<MeshTriangle>& collidingTriangles) { mInstanceSettings.isCollidingTriangles = collidingTriangles; } -
接下来,在渲染器中创建一个新的私有方法
checkForLevelCollisions()。我们首先获取实例设置并跳过空实例:void OGLRenderer::checkForLevelCollisions() { for (const auto& instance : mModelInstCamData.micAssimpInstances) { InstanceSettings instSettings = instance->getInstanceSettings(); if (instSettings.isInstanceIndexPosition == 0) continue; } -
然后,我们简单地使用实例的边界框查询三角形八叉树:
std::vector<MeshTriangle> collidingTriangles = mTriangleOctree→query(instance->getBoundingBox()); -
仅知道存在碰撞是好的,但我们希望存储碰撞数据以供进一步操作,例如响应碰撞或绘制调试线。因此,我们将
MeshTriangles的向量存储在实例设置中:instances.at(i)->setCollidingTriangles( collidingTriangles); } }
在实例渲染完毕且在 UI 绘制之前,调用checkForLevelCollisions()方法到渲染器的draw()方法。现在,每一帧都会检查所有实例与关卡几何体的碰撞,如果找到碰撞的三角形,我们将它们存储在实例的InstanceSettings中。
虽然你可以信任碰撞检测的一般功能能够工作,但眼见为实。我们现在为各种关卡数据添加额外的调试线,允许我们在屏幕上作为叠加线绘制有效信息。
绘制调试线
如果我们想在屏幕上实时看到碰撞,我们可以突出显示关卡几何体中受影响的三角形。为了实现这种突出显示,我们将遵循以下步骤:
-
我们向渲染器添加一个新的
private成员mLevelCollidingTriangleMesh,用于存储碰撞的三角形网格:std::shared_ptr<OGLLineMesh> mLevelCollidingTriangleMesh = nullptr; -
然后,在
checkForLevelCollisions()中,我们清除网格:mLevelCollidingTriangleMesh->vertices.clear(); -
然后,我们遍历所有碰撞的三角形,将顶点成对添加到网格存储中,为三角形的每一边创建一条线:
for (const auto& tri : collidingTriangles) { OGLLineVertex vert; vert.color = glm::vec3(1.0f, 0.0f, 0.0f); vert.position = glm::vec4(tri.points.at(0), 1.0f); mLevelCollidingTriangleMesh->vertices.push_back(vert); vert.position = glm::vec4(tri.points.at(1), 1.0f); mLevelCollidingTriangleMesh->vertices.push_back(vert); vert.position = glm::vec4(tri.points.at(1), 1.0f); mLevelCollidingTriangleMesh->vertices.push_back(vert); vert.position = glm::vec4(tri.points.at(2), 1.0f); mLevelCollidingTriangleMesh->vertices.push_back(vert); vert.position = glm::vec4(tri.points.at(2), 1.0f); mLevelCollidingTriangleMesh->vertices.push_back(vert); vert.position = glm::vec4(tri.points.at(0), 1.0f); mLevelCollidingTriangleMesh->vertices.push_back(vert); } -
现在我们已经保存了所有实例检测到的三角形的轮廓。将三角形渲染到屏幕上可以通过简单的线绘制调用完成:
mLineVertexBuffer.uploadData(*mLevelCollidingTriangleMesh); if (mLevelCollidingTriangleMesh->vertices.size() > 0) { mLineShader.use(); mLineVertexBuffer.bindAndDraw(GL_LINES, 0, mLevelCollidingTriangleMesh->vertices.size()); }
运行带有实例/关卡碰撞和调试绘制的应用程序,结果类似于图 12.1:

图 12.1:实例与关卡几何体的碰撞
在图 12.1中,实例与地面和几堵墙发生了碰撞。所有三角形都是通过使用它们的边界框被检测到的,所以当实例仍然离三角形本身有点距离时,如果三角形被高亮显示,请不要感到惊讶。在这种情况下,三角形的边界框被击中,覆盖了一个比三角形本身更大的区域。但是,检测到的碰撞中的误报数量很低,可能只会对碰撞检测的性能产生很小的影响。
为了让实例对与关卡几何形状的碰撞有适当的反应,我们将增强节点树。
扩展节点树以支持关卡几何形状碰撞
感谢在前几章中建立的良好基础,通过遵循这些步骤,添加新的碰撞类型只需几分钟:
-
首先,我们将新的事件类型添加到
Enums.h文件中的nodeEventenum:enum class nodeEvent : uint8_t { ... **instanceToLevelCollision,** NUM }; -
我们还在渲染器的
init()方法期间向micNodeUpdateMap添加了一些文本,以便在节点树中添加一个名称:mModelInstCamData.micNodeUpdateMap[ nodeEvent::instanceToLevelCollision] = “Inst to Level collision”; -
然后,在
checkForLevelCollisions()函数中,如果有至少一个碰撞的三角形,就触发新的事件:if (collidingTriangles.size() > 0) { mModelInstCamData.micNodeEventCallbackFunction( instSettings.isInstanceIndexPosition, nodeEvent::instanceToLevelCollision); } -
事件通知实例节点发生了与关卡几何形状的碰撞。例如,我们可以让实例通过 180 度转身,如图图 12.2所示:

图 12.2:一个响应实例/关卡碰撞的事件节点
在图 12.2中的事件节点中,实例在遇到关卡碰撞时会立即转身。此外,还添加了 250 毫秒的冷却时间。使用冷却时间应该会给实例一些时间,让它足够远离受影响的关卡几何形状,以避免立即重新触发关卡碰撞。
但目前,碰撞检测有一个巨大的缺点:实例始终在相同的高度水平上行走,因此在海拔变化时,会穿过山丘或在空中行走。
为了让实例保持在地面,让我们给应用程序添加一种简单的重力形式。
使用重力来保持实例在地板上
重力实际上始终在我们周围,加速物体向下直到它们撞击某个其他不可移动的物体,例如地面。我们在四处走动或站立时通常不会意识到重力的作用;我们只有在某物掉落并很可能在撞击地面时破碎时才会意识到它的力量。
在我们可以添加重力之前,我们需要找到一种方法来检测关卡几何形状中的三角形是否属于地面。
在关卡数据中找到地面三角形
对于现实世界的关卡,可以使用额外的属性来标记关卡区域是否可通行。由于我们希望将地面级别的技术尽可能通用,我们将采用不同的方法,并使用三角形的法线来检查我们周围的区域是否属于地板或墙壁,以及斜面区域可以行走到哪个角度。
我们将每个三角形的normal作为 3 元素向量存储在MeshTriangle struct中,以及其他三角形数据:
struct MeshTriangle {
...
**glm::vec3 normal;**
BoundingBox3D boundingBox;
};
在generateLevelOctree()中生成三角形八叉树时,取三角形第一个顶点的法线:
tri.normal = glm::normalize(normalMat *
glm::vec3(mesh.vertices.at(
mesh.indices.at(i)).normal));
由于级别数据的索引结构,使用顶点的一个法线可能会导致由于为不同的三角形重复使用相同的顶点而产生的伪影。在这种情况下,我们可以改变法线的计算方法,使其成为每个三角形两条边的叉积:
glm::vec3 edge1 = glm::vec3(mesh.vertices.at(
mesh.indices.at(i + 1)).position -
mesh.vertices.at(mesh.indices.at(i)).position);
glm::vec3 edge2 = glm::vec3(mesh.vertices.at(
mesh.indices.at(i + 2)).position -
mesh.vertices.at(mesh.indices.at(i)).position);
tri.normal = glm::normalize(normalMat *
glm::cross(edge1, edge2));
为了能够在用户界面中控制地面坡度,我们在OGLRenderData struct中添加了一个名为rdMaxLevelGroundSlopeAngle的新变量:
float rdMaxLevelGroundSlopeAngle = 0.0f;
对于 Vulkan,像往常一样,变量将被添加到VkRenderData struct中。
然后,在checkForLevelCollisions()中,我们添加了一个新的检查,以确定向上指向的向量与三角形法线之间的角度是否大于可配置的最大地面坡度:
if (glm::dot(tri.normal, glm::vec3(0.0f, 1.0f, 0.0f))
>= glm::cos(glm::radians(
mRenderData.rdMaxLevelGroundSlopeAngle))) {
在if块内部,我们现在可以将事件发送代码的限制条件改为仅触发墙壁,而不是地面三角形。我们还更改了检测为地面三角形的三角形的调试线条颜色为蓝色,而墙壁三角形的颜色保持为红色:
vertexColor = glm::vec3(0.0f, 0.0f, 1.0f);
} else {
vertexColor = glm::vec3(1.0f, 0.0f, 0.0f);
mModelInstCamData.micNodeEventCallbackFunction(
instSettings.isInstanceIndexPosition,
nodeEvent::instanceToLevelCollision);
}
在UserInterface类的级别...部分,我们添加了一个浮点滑块以交互式地控制最大地面坡度:
ImGui::Text("Max Ground Slope:");
ImGui::SameLine();
ImGui::SliderFloat("##MaxSlope",
&renderData.rdMaxLevelGroundSlopeAngle,
0.0f, 45.0f, "%.2f", flags);
现在应用程序以不同的颜色绘制墙壁和地面碰撞,如图图 12.3所示:

图 12.3:地面和墙壁的独立碰撞
如图 12.3所示,墙壁碰撞现在由红色三角形边缘标记,而地面碰撞则由蓝色三角形边缘突出显示。可以通过滑块配置斜率角度,允许微调哪些斜坡或边缘仍被视为地面。
除了实例与墙壁或地面之间的碰撞具有不同的颜色之外,事件报告也有所不同。对于地面碰撞,不会生成实例/级别几何碰撞事件,允许实例在级别中运行并仅与墙壁发生碰撞。
在我们可以检查实例是否与地面多边形发生碰撞后,是时候将重力添加到代码中。
添加基本重力
对于我们的应用,我们只使用简单的重力。我们只是在每一帧中将实例向下移动一定的距离,而不考虑更复杂的模式,例如随时间变化的加速度。如果你想实现更多细节,附加资源部分包含了深入探讨高级物理和碰撞检测主题的书籍的 ISBN。此外,实践环节部分的一个任务实现了增强重力。
为了能够向实例添加重力效果,我们在AssimpInstance类中创建了一个名为applyGravity()的新public方法:
void AssimpInstance::applyGravity(float deltaTime) {
glm::vec3 gravity =
glm::vec3(0.0f, GRAVITY_CONSTANT * deltaTime, 0.0f);
mInstanceSettings.isWorldPosition -= gravity;
}
在AssimpInstance.h头文件中定义了私有变量GRAVITY_CONSTANT并将其设置为9.81,类似于地球上的真实重力。然后,在渲染器的draw()方法中的实例循环中应用重力:
if (mRenderData.rdEnableSimpleGravity) {
instances.at(i)->applyGravity(deltaTime);
}
在OGLRenderData.h文件中,为 OpenGL 添加了rdEnableSimpleGravity变量到OGLRenderData struct,在VkRenderData.h头文件中为 Vulkan 添加了到VkRenderData struct。
在UserInterface类的createFrame()方法的Levels...折叠标题中,使用复选框来控制重力:
ImGui::Text("Simple Gravity: ");
ImGui::SameLine();
ImGui::Checkbox("##EnableGravity",
&renderData.rdEnableSimpleGravity);
我们将使用这个复选框来防止在没有加载级别几何体时所有实例掉落。
最后一步是使实例能够在级别的地板上四处游走。让我们创建代码的最终部分来完成实例的地面处理。
保持实例在地面三角形上
通过本章前面部分的代码,我们可以检测实例与级别墙壁和地板之间的碰撞,并处理墙壁碰撞。对于地面碰撞,我们需要特殊处理,因为实例大多数时候将停留在级别的地板上。
对于地面碰撞的一个简单想法是在应用重力后向上移动实例一点。遗憾的是,如果垂直移动量不相同,应用重力并将实例移回会导致振荡。但我们可以避免首先应用重力,一旦检测到碰撞,就将实例留在地面三角形上。
通过以下步骤实现保持实例在级别地面三角形上的功能:
-
对于每个实例,我们使用名为
isInstanceOnGround的新布尔变量在InstanceSettings.h文件中的InstanceSettingsstruct中存储“在地面上”的状态:bool isInstanceOnGround = false; -
在
AssimpInstance类中,也将添加一个public的设置器用于状态:void AssimpInstance::setInstanceOnGround(bool value) { mInstanceSettings.isInstanceOnGround = value; } -
然后我们更新
applyGravity()方法,如果发现实例位于地面上,则禁用在垂直位置添加重力:if (!mInstanceSettings.isInstanceOnGround) { mInstanceSettings.isWorldPosition -= gravity; }
要在渲染器中设置isInstanceOnGround实例变量,需要在渲染器的draw()方法中添加新的检查代码,紧接在调用三角形八叉树的query()方法下方。这样,我们确保使用的是最新的三角形碰撞数据。
-
首先,我们将局部
instanceOnGround布尔值设置为true,这样如果禁用重力,实例将不会掉落。接下来,我们使用与实例相同的公式计算重力,并将实例的当前footPoint初始化为世界位置:bool instanceOnGround = true; if (mRenderData.rdEnableSimpleGravity) { glm::vec3 gravity = glm::vec3(0.0f, 9.81 * deltaTime, 0.0f); glm::vec3 footPoint = instSettings.isWorldPosition;
在下一步中,我们需要脚点有一个有效的位置,以防地面检测失败。
-
现在,我们将
instanceOnGround设置为默认设置false用于地面级检测,并遍历所有碰撞的三角形:instanceOnGround = false; for (const auto& tri : collidingTriangles) { -
然后,使用与
checkForLevelCollisions()中相同的检查角度,检查三角形的法向量与一个向上指的向量之间的角度,以确定三角形是否是地面三角形,从而被认为是可走的:if (glm::dot(tri.normal, glm::vec3(0.0f, 1.0f, 0.0f)) >= glm::cos(glm::radians( mRenderData.rdMaxLevelGroundSlopeAngle))) { -
如果三角形是可走的地面,我们尝试获取一个虚拟射线与地面的交点,该射线从实例向上指,且实例已经稍微沉入地面:
std::optional<glm::vec3> result = Tools::rayTriangleIntersection( instSettings.isWorldPosition – gravity, glm::vec3(0.0f, 1.0f, 0.0f), tri); if (result.has_value()) { footPoint = result.value(); instances.at(i)->setWorldPosition( footPoint); instanceOnGround = true; } }
在应用重力值后检查实例是必要的,因为如果实例与地面三角形完全处于同一水平,则不会报告任何碰撞。
Tools类中的辅助函数rayTriangleIntersection()实现了 Möller-Trumbore 算法,以找到射线和三角形之间的交点。该算法检测射线是否与三角形相交,并返回交点的确切位置。
在图 12.4中展示了算法的可视化:

图 12.4:Möller-Trumbore 算法的可视化
Möller-Trumbore 算法使用由三个顶点V0、V1和V2创建的三角形的重心坐标来检查射线是否在三角形内部或外部。为了简化检测过程,该算法将三角形顶点和射线都进行了转换,使得三角形的两个边映射到笛卡尔坐标系统的X和Y轴上,定义了一个单位三角形。交点的重心坐标在这些转换过程中保持不变。最终测试交点只需要检查交点的x和y坐标是否在三角形内部,通过测试有效的重心坐标来完成。在附加资源部分提供了一个链接,可以了解该算法的数学背景。
-
在新渲染器代码的
draw()调用部分的最后一步,我们将地面标志设置为实例并应用重力:instances.at(i)-setInstanceOnGround(instanceOnGround); instances.at(i)->applyGravity(deltaTime);
在“添加基本重力”部分添加的针对rdEnableSimpleGravity的额外检查可以在applyGravity()周围被移除。我们通过将instanceOnGround的默认值设置为true,已经确保如果rdEnableSimpleGravity为false时不会添加重力。
在启用碰撞三角形调试绘制并调整地面坡度的情况下运行应用程序,将得到类似于图 12.5的图像:

图 12.5:不同高度的层级上的实例行走
在图 12.5中,我们可以看到实例正在它们自己的层级上行走,左侧的实例正在爬上一个小的斜坡。在运行的应用程序中,你将看到下山也是按预期工作的。
在启用水平重力后,实例在水平地面上运行。但在不平的地面上,一个实例在爬坡时可能会与地面相交,或者在下坡时其中一只脚仍然悬在空中。你可以在左侧实例的右脚上看到这个效果:整个脚都嵌入到地面三角形中。
让我们通过添加逆向运动学来固定实例的脚。
添加逆向运动学
“运动学”这个词被定义为物体运动背后的力学,但不涉及引起这种运动的力量。因此,我们日常的每一个动作都可以用运动学的术语来描述,即我们骨骼的运动。
两种类型的运动学
之前章节中我们角色的动画类型被称为 正向运动学。正向运动学的例子在 图 12.6 中展示:

图 12.6:通过正向运动学抬起简单骨骼的手
图 12.6 中的骨骼通过在第一根骨头(肩部)和第二根骨头(肘部)旋转来抬起其简化的手。
在骨骼骨的运动或旋转过程中,所有连接到它的其他节点也会受到影响。围绕肩部旋转手臂不会改变肘部或前臂,因为我们一次只改变一根骨头。接下来,前臂围绕肘部旋转,将手带到最终位置。手的这个最终位置是由手之前所有骨骼的变化的连接定义的。
但是……如果我们只知道手期望的最终位置会怎样?
如果我们想将 图 12.7 中的骨骼手移动到绿色目标点,或者想将脚放到绿色目标块上,使用正向运动学我们唯一的选项就是试错。

图 12.7:如何将手移动到目标点,或者将脚放到盒子上?
我们可能需要反复调整手臂或腿上的所有节点,直到达到匹配的位置。这就是 逆向运动学 发挥作用的地方。我们不是直接旋转节点,而是计算与最终运动匹配的骨骼位置,并从最终骨骼位置读取节点旋转,
在逆向运动学中,执行器这个名词用来描述骨骼中应该到达 目标 的部分。如果目标太远,执行器节点无法触及,我们至少应该尝试找到一个尽可能接近目标的位置。除了执行器和目标之外,还必须选择一个 根节点。根节点是骨骼的第一个未改变的节点。
虽然已经创建了多种逆向运动学求解器算法,但我们将使用其中一种基本算法:FABRIK,即 正向和反向到达逆向运动学 求解器。FABRIK 容易理解且易于实现,并且为节点找到良好的解决方案。因此,让我们通过 FABRIK 深入了解逆向运动学。
理解 FABRIK 基础知识
FABRIK 算法由 Andreas Aristidou 博士于 2011 年提出。FABRIK 算法通过逐个移动骨骼链的节点,使其逐渐接近目标,然后重新缩放骨骼回到原始长度来解决逆运动学问题。此外,FABRIK 沿着骨骼链在两个方向上移动,即正向和反向,因此得名。
我们将使用一个简单的机器人臂来描述使用 FABRIK 解决逆运动学问题的步骤。手臂的四个节点定义了三个骨骼,目标和效应节点在每一步都被绘制出来,蓝色节点连接到地面作为根节点,外部的红色节点用作效应节点。所有步骤都显示在图 12.8到图 12.11中。
让我们逐步分析算法的单次迭代。首先,我们将检查 FABRIK 的正向求解部分。

图 12.8:使用 FABRIK 正向迭代进行逆运动学计算 – 第一部分
起始情况如图 12.8(1)所示:
-
如图 12.8(2)所示,我们将第一步移动效应节点到目标的位置。你可以看到移动节点会使红色骨骼远远超出其原始长度。
-
我们必须纠正红色骨骼的长度,因此我们需要在移动效应节点之前保存骨骼的长度。通过使用保存的长度,我们将红色骨骼缩回到原始长度,正如图 12.8(3)所示。将红色骨骼缩回到之前的长度会撕裂我们的机器人臂,正如图 12.8(3)所示,但在 FABRIK 迭代过程中这是一种预期的行为。
-
然后,我们将紫色骨骼的外节点移回到红色骨骼的末端,再次将其缩放到任意长度。图 12.8(4)显示了在机器人臂重新连接后的结果。
-
紫色骨骼缩回到其之前的长度,如图 12.9(5)所示,将末端节点移离蓝色骨骼。

图 12.9:使用 FABRIK 正向迭代进行逆运动学计算 – 第二部分
- 最后,我们将重复紫色骨骼移动的步骤 4和步骤 5,使用蓝色骨骼。我们将在每次重新连接手臂并将骨骼缩回到原始长度,正如图 12.9(6)和图 12.9(7)所示。
图 12.9(8)显示了 FABRIK 算法的正向步骤完成后得到的结果。但是,机器人臂与地面断开连接并不是我们想要的结果。为了修复手臂,我们将重复相同的步骤,但这次是在同一骨骼链上反向进行。
在 FABRIK 的反向部分,我们将交换目标节点、效应节点和根节点。我们使用手臂的原始连接点作为目标,蓝色骨骼的末端成为效应节点,原始效应节点成为新的根节点。
- 在反向操作的第一步,我们将手臂重新连接到地面,如图 12.10(9)所示。

图 12.10:使用 FABRIK 向后迭代进行逆运动学 – 第一部分
-
然后,我们将蓝色骨骼缩放到其原始大小,并以与最初在步骤 2和步骤 3中相同的方式移动紫色骨骼。在图 12.10(10)、图 12.10(11)和图 12.10(12)中,展示了调整蓝色和紫色骨骼的结果。
-
现在,红色骨骼的较低节点将移动,红色骨骼缩回到其原始大小,如图图 12.11(13)和图 12.11(14)所示。

图 12.11:使用 FABRIK 向后迭代进行逆运动学 – 第二部分
图 12.11(14)将执行器从目标位置移开,但再次强调,这是 FABRIK 中目标无法到达时的预期行为。在图 12.11(15)中,一个 FABRIK 迭代已经结束。
对于 FABRIK 的后续迭代,重复步骤 2到步骤 9,直到执行器节点达到目标位置,或者达到最大迭代次数。
通过使用简单的伪代码,FABRIK 可以简化为以下过程:
NodePositions := OrigNodePositions
StoreOriginalBoneLengths
RootNodePos := NodePositions[LastPosition];
For NumIterations; Do
EffectorPos := NodePositions[0]
If TargetIsCloseToEffector
Return
SolveForward(TargetNodePos)
SolveBackwards(RootNodePos)
EndFor
SolveForward和SolveBackwards方法类似,所以我们只需查看 FABRIK 前向求解部分的伪代码:
NodePositions[0] = TargetNodePos
For i = 1; i < NodePositions.Size; i += 1
BoneDirection = NodePositions[i] - NodePositions[i-1]
NewOffset = BoneDirection * BoneLengths[i - 1]
NodePositions[i] = NodePositions[i-1] + NewOffset
EndFor
向后求解部分仅从保存的根节点位置开始,以相反的方向遍历骨骼。
如您所见,步骤数量很少,这些步骤中的动作也很简单。掌握了新知识后,让我们添加一个 FABRIK 求解器。
实现 FABRIK 逆运动学算法
使用 FABRIK 算法的逆运动学求解器通过以下步骤实现:
-
对于 FABRIK 逆运动学求解器,我们在
tools文件夹中添加IKSolver.h和IKSolver.cpp文件来创建一个新的类IKSolver。 -
除了构造函数和设置迭代次数的 setter 外,还添加了一个名为
solveFABRIK()的public方法:std::vector<glm::vec3> solveFARBIK( std::vector<glm::mat4>& nodeMatrices, glm::vec3 targetPos); -
我们还添加了两个
private成员mNodePositions和mBoneLengths,以及三个private方法solveFABRIKForward()、solveFABRIKBackwards()和calculateOrigBoneLengths():std::vector<glm::vec3> mNodePositions{}; std::vector<float> mBoneLengths{}; void solveFABRIKForward(glm::vec3 targetPos); void solveFABRIKBackwards(glm::vec3 rootPos); void calculateOrigBoneLengths(); -
在
mNodePositions中,我们存储计算过程中节点的世界位置,在mBoneLengths中保存所需的原始骨骼长度。calculateOrigBoneLengths()方法的名字本身就说明了问题;我们不需要深入细节。但让我们快速浏览一下剩下的三个方法。 -
FABRIK 的前向部分由
solveFABRIKForward()处理。首先,我们将mNodePositions的第一个元素设置为目标位置:void IKSolver::solveFABRIKForward(glm::vec3 targetPos) { mNodePositions.at(0) = targetPos; for (size_t i = 1; i < mNodePositions.size(); ++i) { glm::vec3 boneDirection = glm::normalize(mNodePositions.at(i) - mNodePositions.at(i – 1)); glm::vec3 offset = boneDirection * mBoneLengths.at(i – 1); mNodePositions.at(i) = mNodePositions.at(i - 1) + offset; } } -
然后我们遍历剩余的节点,在移动前一个节点后计算骨骼的新方向,并使用原始长度计算节点的新位置。最后,我们将节点移动到新位置,保持长度不变。
-
在
solveFABRIKBackwards()中,我们执行与步骤 5 和 6相同的操作,但沿着节点链相反的方向。我们以根节点位置为目标,逐个调整节点位置:void IKSolver::solveFABRIKBackwards(glm::vec3 rootPos) { mNodePositions.at(mNodePositions.size() - 1) = rootPos; for (int i = mNodePositions.size() - 2; i >= 0; --i) { glm::vec3 boneDirection = glm::normalize(mNodePositions.at(i) - mNodePositions.at(i + 1)); glm::vec3 offset = boneDirection * mBoneLengths.at(i); mNodePositions.at(i) = mNodePositions.at(i + 1) + offset; } } -
最后一个方法
solveFARBIK()用于控制 FABRIK 的迭代以及效应节点和目标位置的比较。我们从一个简单的空节点矩阵向量的检查开始。在这种情况下,我们返回一个空向量:std::vector<glm::vec3> IKSolver::solveFARBIK( std::vector<glm::mat4>& nodeMatrices, glm::vec3 targetPos) { if (nodeMatrices.size() == 0) { return std::vector<glm::vec3>{}; } -
然后,我们调整并填充
mNodePositions向量:mNodePositions.resize(nodeMatrices.size()); for (int i = 0; i < nodeMatrices.size(); ++i) { mNodePositions.at(i) = Tools::extractGlobalPosition( nodeMatrices.at(i)); } -
在辅助方法
extractGlobalPosition()中,提取节点矩阵的平移部分并返回。现在我们可以计算骨骼长度,并存储根节点位置以用于 FABRIK 的反向部分:calculateOrigBoneLengths(); glm::vec3 rootPos = mNodePositions.at(mNodePositions.size() - 1); -
我们需要存储根节点位置,因为我们会在前向计算中更改
mNodePositions向量。如果没有保存原始根节点,反向部分将无法解决。 -
然后,我们启动一个循环,最大迭代次数为:
for (unsigned int i = 0; i < mIterations; ++i) { glm::vec3 effector = mNodePositions.at(0); if(glm::length(targetPos - effector) < mCloseThreshold) { return mNodePositions; }
在循环开始时,我们比较效应节点和目标位置。如果位置接近,则返回当前节点位置作为解决方案。
-
现在是 FABRIK 解决部分。我们使用目标位置调用前向求解方法,然后使用保存的根节点位置调用反向求解方法:
solveFABRIKForward(targetPos); solveFABRIKBackwards(rootPos); } -
在解决两个部分之后,我们继续下一个迭代。最后,我们返回 FABRIK 找到的节点位置:
return mNodePositions; }
可能效应节点无法达到目标位置,即因为节点链太短。在这种情况下,FABRIK 能找到的最佳位置将被返回。
在我们可以继续 FABRIK 的最后部分之前,我们需要确保配置模型的根节点和效应节点。在这里,我们将仅使用脚部来创建在上下坡时自然的外观。
定义实例脚部的节点链
为了能够配置脚部的节点,我们在ModelSettings.h文件中的ModelSettings结构体中添加了两个新成员:
std::array<std::pair<int, int>, 2> msFootIKChainPair{};
std::array<std::vector<int>, 2> msFootIKChainNodes{};
在msFootIKChainPair数组中,我们存储左脚和右脚的效应节点和根节点的节点 ID。我们只需要这对用户界面,因为msFootIKChainNodes数组的内容将根据效应节点和根节点 ID 计算得出。
对于用户界面,我们使用实例模型骨骼名称列表中的名称。由于骨骼 ID 和名称之间存在 1:1 的关系,因此遍历列表以创建组合框很容易。
与一个布尔变量一起,用于启用或禁用逆运动学以及一个迭代滑块,关于模型脚部逆运动学的部分用户界面看起来像图 12.12:

图 12.12:为女性模型配置的逆运动学
对于图 12.12,使用了Women模型作为示例。对于其他模型,节点名称可能不同。
逆运动学依赖于模型
配置模型脚部逆运动学的能力在很大程度上取决于模型的骨骼。很可能你在互联网上找到的一些模型可能缺少骨骼节点之间的某些父子关系,例如腿部和脚部节点之间的关系,导致逆运动学无法正常工作。
FABRIK 算法还有另一部分通过使用计算出的位置调整原始节点的世界位置。我们在实现 FABRIK 逆运动学算法部分跳过了最终部分的代码,因为出于技术原因我们必须拆分 FABRIK 算法——FABRIK 算法使用的节点数据位于 GPU 上,但一些计算在 CPU 上更容易处理。
现在我们来合并节点位置。
调整节点位置
由于使用了计算着色器,包含旋转、平移、缩放以及节点世界位置的矩阵仅存在于 GPU 内存中。此外,为了性能原因,节点位置在计算着色器中计算。我们需要在 CPU 和 GPU 之间来回复制数据,以调整节点旋转以适应 FABRIK 求解器提供的结果。使用 FABRIK 计算所有实例的最终节点位置后,每个节点从目标到执行器的旋转数据都会更新。结果变换矩阵被上传到 GPU,并使用计算着色器计算新的变换矩阵。由于一个节点的所有子节点都受变换矩阵的影响,我们必须为每个节点单独执行这些步骤。
保持节点变换的分离
为了简化使用计算着色器中计算出的数据,我们不会将 TRS 矩阵的旋转、平移和缩放数据合并成一个单一的矩阵。相反,我们将值存储在一个新的struct中。
在三个着色器文件assimp_instance_matrix_mult.comp、assimp_instance_transform.comp和assimp_instance_headmove_transform.comp中,我们在顶部添加以下TRSMat struct:
struct TRSMat {
vec4 translation;
vec4 rotation;
vec4 scale;
};
注意,rotation元素是一个四元数;我们只是使用vec4将其传输到着色器,因为 GLSL 不支持直接使用四元数。
为了避免在一个着色器中添加平移、旋转和缩放到一个矩阵,然后在另一个着色器中再次提取值,我们将分解的变换数据作为单独的值保存在 SSBO 中:
uint index = node + numberOfBones * instance;
trsMat[index].translation = finalTranslation;
trsMat[index].rotation = finalRotation;
trsMat[index].scale = finalScale;
将创建最终的 TRS 矩阵的操作移动到矩阵乘法着色器中,包括createTRSMatrix()、createTranslationMatrix()、createScaleMatrix()和createRotationMatrix()辅助方法。TRS 矩阵将实时创建,而不是在assimp_instance_matrix_mult.comp着色器的main()方法中进行查找:
mat4 nodeMatrix = **createTRSMatrix(****index****)**;
...
nodeMatrix = **createTRSMatrix(parent)** * nodeMatrix;
由于计算完全在 GPU 上完成,渲染器代码中反映 TRS 数据拆分的唯一更改是mShaderTRSMatrixBuffer SSBO 的大小计算:
size_t trsMatrixSize = numberOfBones *
numberOfInstances * 3 * sizeof(glm::vec4);
与使用完整的 TRS 矩阵的glm::mat4不同,这里我们使用三个glm::vec4实例,将节点变换拆分为平移、旋转和缩放,就像在计算着色器中一样。
在OGLRenderData.h文件中,添加了一个匹配的TRSMatrixData struct:
struct TRSMatrixData{
glm::vec4 translation;
glm::quat rotation;
glm::vec4 scale;
};
对于 Vulkan,TRSMatrixData的添加发生在VkRenderData.h文件中。
在计算着色器计算出最终节点位置之后,但在实例渲染之前,将添加新的代码以处理动画实例。
添加动画实例的代码
我们首先检索 TRS 缓冲区的当前内容:
mTRSData =
mShaderTRSMatrixBuffer.getSsboDataTRSMatrixData();
然后,我们遍历两只脚并计算每只脚的节点链大小:
for (int foot = 0; foot <
modSettings.msFootIKChainPair.size(); ++foot) {
int nodeChainSize =
modSettings.msFootIKChainNodes[foot].size();
if (nodeChainSize == 0) {
continue;
}
如果脚节点链为空,我们将立即继续,因为我们在这里没有要计算的内容。但是,如果我们有一个有效的脚节点链,我们将遍历节点链,并在每个节点链中,我们遍历所有实例:
for (int index = nodeChainSize - 1; index > 0;
--index) {
for (size_t i = 0; i < numberOfInstances; ++i) {
循环的顺序——先脚,然后节点链,最后实例——有很好的理由:处理一个脚节点链为空或脚节点链大小不同的情况要容易得多,因为我们可以跳过一个整个循环或使用不同数量的迭代。反向遍历节点链是必要的,因为脚链向量包含从效应器到根节点的节点顺序,但世界位置更新是从根节点作为第一个节点开始的。
在内循环中,我们从脚节点链中提取当前节点和前一个节点的节点 ID:
int nodeId =
modSettings.msFootIKChainNodes[foot].at(index);
int nextNodeId =
modSettings.msFootIKChainNodes[foot]
.at(index - 1);
通过使用两个节点 ID,我们提取节点的世界位置:
glm::vec3 position = Tools::extractGlobalPosition(
mWorldPosMatrices.at(i) *
mShaderBoneMatrices.at(i * numberOfBones +
nodeId) *
model->getInverseBoneOffsetMatrix(nodeId));
glm::vec3 nextPosition =
Tools::extractGlobalPosition(
mWorldPosMatrices.at(i) *
mShaderBoneMatrices.at(i * numberOfBones +
nextNodeId) *
model->getInverseBoneOffsetMatrix(nextNodeId));
在乘以骨骼偏移节点的逆矩阵、当前节点位置和实例世界位置之后,我们得到脚节点链的两个相邻节点的世界位置。
使用这两个位置,我们计算归一化的toNext向量:
glm::vec3 toNext = glm::normalize(nextPosition -
position);
为了计算节点的期望旋转,我们需要找到一个四元数,该四元数将骨骼从当前世界旋转旋转到由 FABRIK 算法计算的旋转。
创建新的世界位置
我们提取由 FABRIK 计算的同节点的新世界位置,计算新世界位置之间的归一化toDesired向量,并计算toNext和toDesired向量之间的旋转角度作为四元数,nodeRotation:
int newNodePosOffset = i * nodeChainSize + index;
glm::vec3 toDesired = glm::normalize(
mNewNodePositions.at(foot)
.at(newNodePosOffset - 1) –
mNewNodePositions.at(foot)
.at(newNodePosOffset));
glm::quat nodeRotation = glm::rotation(toNext,
toDesired);
到目前为止,我们有了骨骼当前位置和模型骨骼相同骨骼的新位置之间的世界级旋转。
现在,我们提取当前节点的世界级旋转并计算匹配节点世界级旋转所需的局部旋转:
glm::quat rotation = Tools::extractGlobalRotation(
mWorldPosMatrices.at(i) *
mShaderBoneMatrices.at(i * numberOfBones +
nodeId) *
model->getInverseBoneOffsetMatrix(nodeId));
glm::quat localRotation = rotation *
nodeRotation * glm::conjugate(rotation);
最后,我们读取节点的当前局部旋转,连接新的旋转,并将旋转存储回 TRS 数据:
glm::quat currentRotation = mTRSData.at(i *
numberOfBones + nodeId).rotation;
glm::quat newRotation = currentRotation *
localRotation;
mTRSData.at(i*numberOfBones + nodeId).rotation =
newRotation;
}
通过运行mAssimpMatrixComputeShader并上传更新的 TRS 数据,重新计算脚节点链中所有节点的新的节点矩阵。作为最后一步,我们读取mShaderBoneMatrixBuffer的内容,以便将新的节点矩阵用于脚节点链中的下一个节点。
我们离一个工作的逆运动学实现只有一步之遥。现在剩下的只是检测脚位置与地面三角形之间的碰撞。让我们处理本章的最后部分。
检测脚与地面的碰撞
检测实例脚与地面之间碰撞的一般思想与实例相同。我们使用射线到三角形的检查来找到射线与定义的脚节点下方或上方的三角形的交点。然后,这个交点被用作 FABRIK 求解器的目标位置。
对于每个实例的两个脚,我们使用与 FABRIK 求解器代码的最后一步相同的方法来提取世界位置:
int footNodeId =
modSettings.msFootIKChainPair.at(foot).first;
glm::vec3 footWorldPos =
Tools::extractGlobalPosition(
mWorldPosMatrices.at(i) *
mShaderBoneMatrices.at(i * numberOfBones +
footNodeId) *
model->getInverseBoneOffsetMatrix(footNodeId));
我们还计算脚节点与地面的偏移量:
float footDistAboveGround = std::fabs(
instSettings.isWorldPosition.y - footWorldPos.y);
这个footDistAboveGround偏移量是必要的,以便让脚在斜地面三角形上以与平地面三角形上相同的距离悬浮。然后,我们使用实例的AABB来计算边界框的完整高度和半高度:
AABB instanceAABB = model->getAABB(instSettings);
float instanceHeight = instanceAABB.getMaxPos().y -
instanceAABB.getMinPos().y;
float instanceHalfHeight = instanceHeight / 2.0f
使用两个高度值,我们创建一个射线来检测与地面三角形的交点。接下来,我们为交点的最终hitPoint设置一个默认值,并遍历碰撞的三角形:
glm::vec3 hitPoint = footWorldPos;
for (const auto& tri :
instSettings.isCollidingTriangles) {
std::optional<glm::vec3> result{};
然后,我们检查每个碰撞的三角形是否与从实例半高度开始的射线相交,指向最多整个实例高度向下:
result = Tools::rayTriangleIntersection(
footWorldPos +
glm::vec3(0.0f, instanceHalfHeight, 0.0f),
glm::vec3(0.0f, -instanceHeight, 0.0f), tri);
使用射线的一个上限和下限有助于防止误检测任何附近的三角形。接下来,我们检查是否找到了交点:
if (result.has_value()) {
hitPoint = result.value() +
glm::vec3(0.0f, footDistAboveGround, 0.0f);
}
在这里,我们将footDistAboveGround添加到结果值中,以保持动画与地面之间的原始距离。图 12.13展示了保持脚与地面距离如何影响实例的简化示例:

图 12.13:保持脚在地面上的距离
现在我们可以运行逆运动学求解器来计算调整后的节点位置。
运行 FABRIK 求解器
首先,我们清除包含要解决的位置的向量。然后,我们将当前脚节点链中节点的矩阵放置在mIKWorldPositionsToSolve中:
mIKWorldPositionsToSolve.clear();
for (int nodeId :
modSettings.msFootIKChainNodes[foot]) {
mIKWorldPositionsToSolve.emplace_back(
mWorldPosMatrices.at(i) *
mShaderBoneMatrices.at(i * numberOfBones +
nodeId) *
model->getInverseBoneOffsetMatrix(nodeId));
}
作为逆运动学的最后一步,我们让 FABRIK 解决我们找到的交点处的节点位置,并将结果插入到我们正在工作的脚的mNewNodePositions向量中:
mIKSolvedPositions = mIKSolver.solveFARBIK(
mIKWorldPositionsToSolve, hitPoint);
mNewNodePositions.at(foot).insert(
mNewNodePositions.at(foot).end(),
mIKSolvedPositions.begin(),
mIKSolvedPositions.end());
到这一点,脚的位置已经准备好从检测脚与地面碰撞部分进行节点矩阵和 TRS 更新。
在加载关卡后启用和配置逆运动学会在屏幕上渲染出类似于图 12.14的图像:

图 12.14:右侧实例的右脚在斜坡地面上的交点较少
如你在图 12.14右侧的实例中看到的那样,右侧的脚不再完全与地面三角形相交。此外,右侧膝盖的角度已经被调整,以便脚能够放置在地面三角形的较高部分。
仍然有一些交叉点在左侧的脚趾上。在实践环节部分,通过将脚趾对齐到地面斜坡来修复剩余的交叉点是一个任务。但即使有这种最小的交叉点,观看实例在山丘上上下下,更像真实的人类,也是非常有趣的。
FABRIK 的限制
使用 FABRIK 求解器创建的逆运动学动画通常看起来很好,但该算法有一些限制需要解决。
当节点旋转创建出不自然的骨骼位置时,会出现最大的问题。例如,人类不能向前弯曲膝盖,但屏幕上的实例可以这样做。如果不检查和限制节点的旋转,实例的骨骼可能会最终处于几乎任何位置。在实践环节部分有一个任务通过限制节点旋转来处理这个问题。
如果目标点离效应器太远,就会变得无法触及,这时就会出现另一个问题。FABRIK 通过将整个骨骼链从根节点拉伸到效应器节点成一条直线来解决这个问题,导致实例像机器人一样用僵硬的腿行走。由于我们可以检测到效应器无法触及目标,因此处理这些情况比无限旋转节点要容易。在实践环节部分有一个任务是用来解决无法触及目标的问题。
概述
在本章中,我们增强了我们关卡中的碰撞检测。首先,我们为关卡数据添加了一个单独的、专门的八叉树,使我们能够快速检查实例和关卡几何之间的碰撞。然后,我们在虚拟世界中添加了简单的重力,并调整了碰撞检测以允许实例在关卡地面上行走。最后,我们为实例的脚添加了逆运动学,通过避免脚在地面上方漂浮或切入关卡地面三角形的地面,使得在斜坡地面上有更自然的脚和腿的运动。
在下一章中,我们将对关卡数据进行最终扩展并添加简单的导航。我们将首先简要回顾几种在游戏中实现导航的方法。接下来,我们将探讨 A* 寻路算法并将其实现到应用中。然后,我们将向地图中添加航点,作为 A* 计算的目标。最后,我们将实现航点导航到实例,使实例能够移动到随机的航点或巡逻于一系列定义好的航点之间。
实践课程
下面是一些你可以添加到代码中的改进:
- 添加更完整的重力。
而不是仅仅操纵实例的垂直位置,使用垂直加速度以更复杂的方式应用重力。由于重力在两个地方使用,一个是在实例中用于速度,另一个是在渲染器中用于碰撞检测,因此需要在这两部分代码之间进行同步。
- 为第三人称摄像机添加碰撞检测。
在真实游戏中,摄像机也是虚拟世界中的一个普通对象。并且摄像机不会穿过关卡数据,它总是保持在跟随的角色相同的边界内,通过弹簧臂结构连接,以便在摄像机无法保持角色精确后方位置时进行调整。你可以尝试为第三人称摄像机添加边界框,并检查边界与关卡八叉树的对齐。记住,如果发生碰撞,也要检查和更改摄像机的距离和高度。
- 增强楼梯和悬崖检测。
为了使实例能够爬楼梯,代码中已经添加了基本的逻辑,该逻辑比较楼梯的步高与实例的位置。这个想法可能不适用于不同类型的游戏地图,而且当楼梯检测返回错误结果时,实例可能会从悬崖上掉下来。增强逻辑以实现更好的楼梯检测,并防止实例在“认为”它站在台阶前面时实际上站在深渊边缘时掉下来。
- 将实例的脚对齐以跟随斜坡和法线,如果地面的话。
目前,实例的脚可能仍然会与地面相交,这取决于地面的斜坡和实例的位置。添加一些代码,甚至可能向模型添加更多节点,以将实例的脚对齐到地面三角形。这种调整应该适用于向前/向后倾斜以及脚的侧向旋转以匹配地面。
- 处理无法到达的目标。
实现逻辑以检测效应器是否无法到达目标,并为节点添加一些默认旋转,例如,从当前动画剪辑帧中获取。
- 将 FABRIK 的节点更新部分移动到计算着色器中。
为了使 FABRIK 代码更新最终节点旋转简单易懂,使用了 CPU/GPU 组合。当前代码的缺点是在每次更新节点链的开始时,需要从 GPU 内存中复制 TRS 矩阵数据和最终节点位置。通过创建用于节点旋转更新的计算着色器,可以将整个节点更新过程保持在 GPU 上。
- 扩展难度:编写一个用于逆运动学的计算着色器。
FABRIK 逆运动学算法简单直接。在计算着色器中实现相同的算法不应太难。你可以使用矩阵乘法计算着色器作为如何在 GLSL 代码中执行循环的示例,而不需要硬编码循环变量。
- 扩展难度:将节点限制添加到逆运动学算法中。
在默认版本中,逆运动学在求解位置时不会担心不自然的骨骼旋转。对于一个游戏角色来说,如果膝盖或肘部向错误的方向弯曲,看起来会很奇怪。通过为所有三个旋转轴添加每个节点的限制,目标可能无法达到,但角色仍然看起来像人类。但请注意:添加节点限制可能会影响逆运动学算法的稳定性。
- 扩展难度:使用边界体积层次结构(BVH)进行碰撞检测。
在第八章的“使用空间划分来降低复杂性”部分提到了边界体积层次结构的概念,而克里斯蒂安·埃里克森的《实时碰撞检测》一书有一个关于 BHV 的完整章节。用 BHV 替换八叉树来检测碰撞可能是一项长期任务,因为创建层次结构已经给碰撞检测增加了相当大的复杂性。
补充资源
-
雅可比矩阵简介:
medium.com/unity3danimation/overview-of-jacobian-ik-a33939639ab2 -
《交互式 3D 环境中的碰撞检测》由吉诺·范登伯格著,由CRC 出版社出版:ISBN 978-1558608016
-
《游戏物理引擎开发》由伊恩·米林顿著,由CRC 出版社出版:ISBN 978-0123819765
-
《游戏物理》由大卫·H·埃伯利著,由摩根考夫曼出版社出版:ISBN 978-0123749031
-
《实时碰撞检测》由克里斯蒂安·埃里克森著,由CRC 出版社出版:ISBN 9781000750553
-
FABRIK 出版物:
www.andreasaristidou.com/FABRIK.html -
Unreal Engine 中的逆运动学:
dev.epicgames.com/documentation/en-us/unreal-engine/ik-setups?application_version=4.27 -
全身逆运动学:
dev.epicgames.com/documentation/en-us/unreal-engine/control-rig-full-body-ik-in-unreal-engine -
Möller-Trumbore 算法用于光线-三角形交点检测:
www.lighthouse3d.com/tutorials/maths/ray-triangle-intersection/
第十三章:添加简单导航
欢迎来到 第十三章!在前一章中,我们创建了一个单独的八叉树来增强碰撞检测,使我们能够快速且计算成本低廉地检测实例与关卡几何之间的碰撞。然后我们向应用程序添加了简单的重力,以使实例保持在地图的地面上,最终导致实例在关卡地板和小山上行走。最后,我们使用逆运动学在实例的脚上,以保持两脚在爬坡或地图斜坡区域时都保持在地面。
在本章中,我们将添加路径查找和导航功能。我们首先简要概述计算机游戏中用于导航的方法,然后探索并实现 A* 路径查找算法。接下来,我们将导航目标添加到应用程序中,使得在虚拟世界中放置路径目的地变得简单。本章的最后,我们将实现向航点导航,使得实例能够向定义的目标行走或奔跑。
在本章中,我们将涵盖以下主题:
-
不同导航方式的概述
-
A* 路径查找算法
-
将导航目标添加到地图中
-
将实例导航到目标
技术要求
本章的示例代码位于 chapter13 文件夹中,对于 OpenGL 在 01_opengl_navigation 子文件夹中,对于 Vulkan 在 02_vulkan_navigation 子文件夹中。
不同导航方式的概述
路径查找和导航在视频游戏中被使用的时间比人们想象的要长。让我们探索几种导航方法。
基于距离的导航
使用简单算法来模拟敌人智能行为的最早游戏之一是 Namco 的 Pac-Man。四个幽灵(Blinky、Pinky、Inky 和 Clyde)中的每一个都有一种略有不同的“性格”,仅由幽灵移动的目标点创建。
当红色幽灵(Blinky)直接追逐 Pac-Man 时,粉红色幽灵(Pinky)和蓝色幽灵(Inky)会试图在 Pac-Man 前面,实际上是在试图包围玩家。第四个幽灵(橙色的 Clyde)有“自己的想法”,在追逐玩家和逃跑之间切换。
关于选择新路径的决定仅发生在游戏迷宫的交叉路口,并且完全基于交叉路口所有可能路径到目标格子的距离。游戏没有使用更高级的预览路径规划,有时会导致不良决策。图 13.1 展示了交叉路口的一个这样的决策情况:

图 13.1:Pac-Man 中红色幽灵的导航决策
在图 13.1中,绿色方框是触发向左或向右决策的区域,两条虚线绿色线是到红色轮廓目标格子的直接距离。尽管右边的路径更短,但由于左边的决策距离更短,所以会选择左边的路径,从而产生幽灵的随机行为。在附加资源部分提供了一个链接,提供了关于幽灵导航内部深入信息的链接。
简单的基于距离的导航至今仍在游戏中使用,例如,根据两个实体的速度和方向,找到敌人可能拦截玩家的位置。然后使用基于图的导航算法规划到玩家的路径。
基于图的导航
在图中,搜索算法使用图的节点来描述游戏地图上的位置,使用边来描述节点之间的连接。通过构建地图的图,可以以有组织的方式找到两个位置之间的最短路径。
在导航中使用了多个图算法。最常见的是:
-
深度优先搜索(DFS)
-
广度优先搜索(BFS)
-
迪杰斯特拉算法
-
A*(发音为“A star”)
DFS 和 BFS 算法
这两个都是简单的算法。虽然 DFS 遍历图“深度优先”,从起始节点到最远的节点,但 BFS 首先访问最近的节点,然后以“环”的形式前进到下一个节点。图 13.2显示了由六个节点 A 到 F 组成的示例图:

图 13.2:BFS 和 DFS
在图 13.2的左侧,BFS 算法从根节点(A)之后的最近节点(B、C 和 D)开始,然后遍历到节点 E 和 F。在图 13.2的右侧,DFS 算法首先遍历子节点 D 和 D 的子节点(F),然后前进到节点 C 和 E,最后到节点 B。
迪杰斯特拉算法
迪杰斯特拉算法为图的边添加权重。权重可以看作是从一个节点到另一个节点的成本或距离,具体取决于要解决的问题。迪杰斯特拉算法遍历整个图,构建一个包含从起始节点到图中所有其他节点的最短路径的表。
图 13.3显示了算法所有步骤后的起始图和结果:

图 13.3:一个图和从 A 到所有其他节点的最短距离
通过使用迪杰斯特拉算法,任何加权图都可以遍历以找到从节点到所有其他节点的最低成本(或距离)路径,但算法必须为每个起始节点重新运行。关于算法的完整描述,在附加资源部分提供了一个链接,展示了在图 13.3中找到图的最短距离的步骤。
A*算法
A*算法以迪杰斯特拉算法为基础,但增加了两个改进:
-
每个节点都添加了一个所谓的启发式,表示从每个节点到目标节点的估计距离。
-
算法从起始节点搜索到指定的目标节点,通常在目标节点被到达时终止。
通过结合从起始节点到节点的距离和从同一节点到目标节点的估计距离,并在遍历到下一个节点时使用最短总和,A进行有向搜索以到达目标节点。因此,与 BFS 或 DFS 这样的无向搜索不同,A始终朝向目标节点的方向前进。我们将在A路径查找算法部分深入讨论 A。
本节算法的一个缺点是,如果目标是动态的,它们必须重新创建整个路径。对于动态目标,LPA、D或 D-Lite 等算法可能能提供更好的结果。此外,自 A算法引入以来,还创建了其他几种路径查找算法,旨在针对特殊环境,如机器人技术,或进一步优化路径查找过程,如 Theta*。
在探索 A*之前,让我们看看视频游戏中另一种流行的导航类型,使用三角形或其他多边形来描述机器人和 NPC 的可行走区域,并简要了解一下机器学习作为创建导航数据的替代版本。
网格导航
简单的游戏,如 Pac-Man 和许多策略游戏,通过将世界划分为网格(通常由矩形或六边形结构构建)来使用基于距离的导航。但三维游戏(如开放世界)或第一人称和第三人称探索和战斗游戏的需求不同。由于虚拟世界的重叠部分,需要一个三维结构来引导计算机控制的角色通过地图。
大多数使用三维地图的游戏要么使用导航网格,要么使用区域感知,或者两者结合使用。
导航网格
导航网格(也称为NavMeshes)大约在 2000 年左右在游戏中被引入。导航网格是由多边形(大多数实现中为三角形)组成的一个附加数据结构,覆盖在关卡几何之上。导航网格中的多边形标记了关卡的可行走区域,省略了计算机控制的角色可能与之碰撞的任何对象和结构。
通过使用导航网格,机器人或 NPC 可以在虚拟世界中行走,如果角色保持在导航网格上,则无需进行昂贵的与静态关卡几何的碰撞检查。只有当角色可以离开导航网格时,才需要进行碰撞检查。与基于图的算法(如 A)结合使用时,可以实现计算机控制角色的精细行为控制。在附加资源部分中有一个关于使用导航网格进行路径查找的全面介绍链接。图 13.4*展示了简单的一个例子:

图 13.4:带有起点(绿色)、目标(红色)和从起点到目标路径的导航网格
在图 13.4中,顶部图片显示了从起始三角形(绿色)到目标三角形(红色)的最短路径,该路径通过使用视线中的下一个顶点到网格下一个尖锐角的下一点。
相比之下,中间图片中的路径使用三角形中心作为路径查找算法的图节点,而底部图片使用内部三角形边作为图节点。
路径的质量取决于网格以及将用于图节点的是三角形的哪一部分或几部分。图 13.4中所示方法的组合是可能的,因此三角形的中心和边的中点都可以用作图节点。生成的路径还可以通过跳到下一个直接可见的节点和使用样条曲线进行平滑处理。
注意,如果边缘导航网格在狭窄通道的墙壁或边界附近太近,实例可能会与墙壁碰撞,产生额外的运动校正,或者导致网格的一部分无法通行。层级中的静态障碍物也应保持在安全距离之外。
按照经验法则,使用实例的边缘和轴对齐边界框中心之间的距离作为导航网格边缘和相邻层级几何之间的最小距离。通过在任何时候将实例远离墙壁,可以在正常导航期间避免碰撞。
从层级数据生成导航网格
通常,导航网格会手动创建并添加到层级数据中。但我们需要一个解决方案来处理在互联网上找到的层级,因此我们将在代码中采取捷径,并使用与检测实例是否与层级地面碰撞时相同的向上三角形作为“可能可通行地面”。
结合所有地面三角形之间的相邻关系,可以估算出层级中的可通行区域。在实践课程部分中,有几个用于地面区域创建代码的改进作为任务提供。
通过使用导航网格,可以实现两种类型的导航:在虚拟世界中自由漫游和在路点之间巡逻。
自由导航
在自由导航中,地图上的任何一点都可以作为起点和目的地的目标点。从一个层级的一部分移动到另一部分可能需要计算成本;在最坏的情况下,必须在路径查找过程中检查整个网格。此外,根据起点和目标的确切位置,两次路径规划之间的角色路径可能完全不同。
路点导航
对于基于网格的导航,更好的方法是在导航网格上定义彼此可见的航点。例如,每个房间组中的每扇门都会是一个航点,或者每条道路的每个分叉点。当一个机器人穿过虚拟世界时,在达到期望的航点后,将设置航点作为下一个目标。如果玩家被发现然后失踪,机器人可以返回到最近的航点。通过确保计算机控制的角色总能“看到”至少一个航点,到下一个航点的路径规划变得便宜且易于计算。
区域感知系统
1999 年,id Software在Quake III Arena中使用了名为区域感知的系统。它不是使用二维图表,而是创建了一个简化的三维水平表示,其中包含有关水平结构、其他机器人和玩家的所有信息。
机器人不仅可以通过行走、跳跃或游泳穿越感知区域,还可以使用传送门、跳跃垫,甚至火箭跳跃。有了这样丰富的动作库,机器人可以轻松地跟随玩家在水平中移动,或者试图切断玩家的路径。
在附加资源部分的 PDF 文档链接中可以找到区域感知系统的完整描述。
使用机器学习生成导航数据
为计算机控制的角色创建导航数据的一种更近的方法是机器学习,主要是所谓的强化学习。在强化学习过程中,代表角色的代理在大量“尝试错误”风格的回合中自行探索虚拟世界,但会因完成定义的任务而获得奖励,或因未能完成任务而受到惩罚。
这样的任务可能类似于“以最大健康值到达定义的目标点”,“不要从水平掉落”,或者“以最短时间完成水平”。通过考虑先前探索的奖励和惩罚,代理优化其行为以最大化奖励并最小化惩罚。当这些代理生成数据用于游戏时,敌人可以利用机器学习中的策略,在虚拟世界中移动时显得更加自然。
机器学习的两个挑战是使使用时间成本高昂:
-
目标、奖励和惩罚必须在计算过程中明确定义和调整。即使我们认为目标和奖励已经定义得很好,机器学习算法也可能找到意想不到的方式来最大化奖励。设置失败可能会导致丢弃数据,并重新启动整个机器学习周期。
-
由于机器学习是通过试错探索虚拟世界,因此其进展是非确定性的,并且可能只以微不足道的数量发生。即使是简单的任务,在游戏运行期间也必须玩数千轮游戏才能达到期望的结果。创建一个探索游戏大级别的复杂 AI 可能需要大量的计算资源,从而导致过高的开发成本。
尽管基于机器学习的导航可能比基于算法的导航产生更好的结果,但建议检查可能改进和额外成本之间的权衡。在附加资源部分有一个视频展示了让机器学习如何驾驶汽车的进展。
在简要回顾了导航方法之后,让我们接下来深入探讨 A*算法。
A*路径查找算法
A算法是在 1968 年计算机的早期阶段发表的。A算法是针对一个名为Shakey的由人工智能控制的移动机器人的路径规划的结果。该机器人是在斯坦福研究学院开发的。其软件包括计算机视觉和自然语言处理,并且它能够执行简单的任务,比如在没有提前描述每个单独动作的情况下,自己驾驶到实验室的某个地方。在附加资源部分有一个链接,提供了更多关于项目和机器人的详细信息。
但是什么让 A*算法与迪杰斯特拉算法(Dijkstra’s algorithm)不同呢?
估算目标距离
虽然迪杰斯特拉算法只使用节点之间的权重,但 A*算法给每个节点添加一个启发式值。启发式函数计算从每个节点到所选目标的最低成本路径的估计成本。在许多情况下,例如对于大型世界和许多节点,计算每对节点之间的最小成本是计算上昂贵的,因此对成本的“有根据的猜测”更容易计算,并且也足够好。
对于启发式函数,可以使用任何距离计算函数。在大多数情况下,要么使用所谓的L1 范数,也称为曼哈顿距离,要么使用L2 范数,也称为欧几里得距离。图 13.5展示了这两种距离计算背后的思想:

图 13.5:从起点到目标的曼哈顿和欧几里得距离
曼哈顿距离是模仿曼哈顿的出租车路线。街道组织成平行的线条,以 90 度角相交。就像曼哈顿的出租车一样,图 13.5中的蓝色路径只能使用起点和目标之间的网格线。我们使用直线、直接线条,如深蓝色路径,或者楼梯式版本,如浅蓝色路径,都没有关系;两条路径的距离是相同的。
相比之下,图 13.5中绿色路径的欧几里得距离是通过使用勾股定理计算的。这意味着将两个方向上距离的长度的平方相加,然后计算总和的平方根。
对于 A*算法的启发式函数,是使用曼哈顿距离、欧几里得距离还是其他距离计算,这严重取决于应用的需求。例如,在游戏地图中,可能需要考虑障碍物、丘陵或敌人来估计距离。找到最佳函数的一种直观方法是绘制每个启发式函数以及起始节点和目标节点的一组组合路径,并比较结果。
通过使用目标节点的启发式值,A*算法试图在每次迭代中使到达目标节点的路径成本最小化。
最小化路径成本
在每次迭代中,A*算法使用从起始节点到当前节点所有邻居的已知路径成本和估计成本的总和来计算到达目标节点的最小成本。然后,算法选择具有最小成本的邻居节点,将该邻居节点设为当前节点,并开始下一次迭代。
此外,如果从起始节点到目标节点的总成本在所有邻居节点中是最小的,A*算法会在每个访问的邻居节点中保存当前节点的引用。在节点中存储父节点允许在到达目标节点后回溯到从目标到源的最佳路径。
为了可视化算法,让我们逐步通过一个小例子。
探索 A*算法
图 13.6显示了一个包含节点 A、B、C 和 D 的图,以及从每个节点到目标节点 D 的启发式表。在这个例子中,起始节点是节点 A。

图 13.6:用于遍历的图以及到目标节点 D 的估计距离
我们可以立即看到从节点 A 到节点 D 的最短路径:ABCD。但对于计算机来说,A*算法必须遍历节点来找到这条路径。
对于第一次迭代,我们访问节点 A 的邻居,如图图 13.7所示:

图 13.7:访问节点 B 和 C
在这里,我们计算估计距离为从 A 到已知的距离加上每个节点的启发式值。节点 B 的总和较低,所以我们继续到节点 B。我们还记录了 B 的父节点,因为 A 是 B 的直接前驱。
然后,我们查看节点 B 的所有邻居,如图图 13.8所示:

图 13.8:访问节点 C 和 D
我们对节点 C 进行相同的计算,并将从 A 到 B、B 到 C 的成本以及从 C 到 D 的启发式值相加。在 A*算法的优化版本中,我们的搜索可能已经结束,因为我们已经到达了目标节点 D。
但我们在这里继续检查节点 D 的剩余邻居,以防有更短的路径可用。因此,我们更新了节点 C 和 D 的父节点,并访问节点 C 作为节点 D 的最后一个未访问的邻居,如图 图 13.9 所示:

图 13.9:从 A 到 D 的最短路径是 ABCD
事实上,通过节点 B 和 C 到节点 D 的路径比通过 B 的路径要短得多。因此,我们将节点 C 设置为节点 D 的新父节点。在访问了目标节点 D 的所有相邻节点之后,A* 已经完成了从节点 A 到节点 D 的路径查找工作。
通过回溯父节点,我们得到从目标到起点的路径。通过反转节点顺序,我们现在有了从起点到目标的最近路径:ABCD。
如您所见,即使在这样一个非常简单的例子中,A* 在选择要处理的节点时,也会关注到达目标的总估计成本。如果在处理节点时算法了解到更短的路径,则用于回溯最佳路径的父节点也会更新。在 附加资源 部分中,有一个链接到深入探讨 A* 的网站。
在概述了算法之后,让我们添加路径查找代码。
实现基于 A* 的导航
由于我们的大多数地图中都不会有简单的二维地形,因此不能使用二维网格进行导航。相反,我们将使用导航网格在虚拟世界中从源对象到目标对象查找路径。
如 导航网格 部分所述,创建网格至少是部分手动工作,这取决于用于创建游戏地图的编辑器。一些编辑器可以根据地图元素创建导航网格,但在大多数情况下,生成的网格必须手动进行修正。导航网格必须存储在包含其余关卡数据的同一张地图上或在单独的数据文件中。
为了支持地图内和独立的导航网格,当涉及到可通行地面网格时,路径查找类保持模块化。例如,如果您的导航网格在地图文件中以特殊名称保存,则可以将导航多边形及其相邻属性导入路径查找类。您还必须将导航三角形导入一个单独的三角形八叉树,并进行额外的光线到三角形的交点检测,以找到地面级别的三角形和导航网格三角形。A* 路径查找算法也已作为单独的方法实现,允许您轻松添加其他算法或不同的启发式函数。
在本章的示例代码中,我们将使用与地面检测相同的思想,并使用每个网格三角形的法线来判断它是否可通行。这种方法会导致导航网格中所有朝上的三角形,即使这些三角形可能无法通过任何实例到达。但为了展示在游戏地图中路径查找和导航的一般思想,从地图的地面三角形创建导航网格是足够的,并且可以得到合理的结果。
我们将使用欧几里得距离来计算节点之间的距离,以及用于启发式函数,因为地图中的三角形最可能不是按矩形网格排列的。为了加快距离计算,我们将扩展网格三角形数据结构。
准备网格三角形
MeshTriangle结构体定义在OGLRenderData.h文件中,用于 OpenGL,在VkRenderData.h文件中,用于 Vulkan。在MeshTriangle结构体的末尾,我们添加了两个新的数组,edges和edgeLengths:
struct MeshTriangle {
**std::array<glm::vec3, 3> edges{};**
**std::array<****float****, 3> edgeLengths{};**
};
在edges数组中,我们存储每个三角形的三个边。我们按照与原始三角形相同的顺序来排序边。由于在计算任何相邻三角形时我们需要边的长度,因此我们将每条边的长度存储在相应的edgeLengths元素中。
现在我们可以直接进入路径查找类的实现。
添加路径查找类
路径查找类(命名为PathFinder)将驻留在tools文件夹中,其中存储了之前章节中创建的所有其他辅助类,如AABB或IKSolver。为了保持命名的一致性,头文件命名为PathFinder.h,实现将放入PathFinder.cpp文件中。
在PathFinder.h头文件中,在所有#include指令之后,我们添加了两个struct条目。第一个新的结构体命名为NavTriangle:
struct NavTriangle {
int index;
std::array<glm::vec3, 3> points{};
glm::vec3 center{};
glm::vec3 normal{};
std::unordered_set<int> neighborTris{};
};
我们在这里不重用MeshTriangle,因为我们需要几个不同的变量。虽然index、points数组和normal向量是相同的,但我们还需要在center变量中存储每个三角形的中心的世界位置,以及在neighborTris中存储周围的三角形。实例将从三角形中心导航到下一个三角形,而存储在neighborTris中的三角形用于找到距离目标最近的三角形。
对于相邻三角形,我们选择使用std::unordered_set而不是普通的std::vector,以便自动删除重复条目。
第二个结构体称为NavData,包含 A*算法的数据:
struct NavData {
int triIndex;
int prevTriIndex;
float distanceFromSource;
float heuristicToDest;
float distanceToDest;
};
在triIndex变量中,我们存储对应NavTriangle的三角形索引。通过使用索引,我们可以进行简单的查找以找到三角形数据,如位置或相邻三角形。一旦我们访问到三角形的相邻节点,我们就将迄今为止最短路径的三角形索引添加到prevTriIndex中,这样我们就可以在路径查找运行结束时回溯最短路径。
剩余的三个变量(distanceFromSource、heuristicToDest和distanceToDest)是 A算法的功臣。在这里,我们存储从源到当前节点的聚合距离,当前节点和目标之间启发式函数的结果,以及这两个距离的总和。通过比较所有相邻节点的distanceToDest值,A选择向目标节点移动的下一个节点。
在两个新的结构体之后,声明了PathFinder类,从两个公共方法generateGroundTriangles()和findPath()开始:
void generateGroundTriangles(OGLRenderData& renderData,
std::shared_ptr<TriangleOctree> octree,
BoundingBox3D worldbox);
std::vector<int> findPath(int startTriIndex,
int targetTriIndex);
通过调用generateGroundTriangles(),在三角形八叉树中找到所有向上面对的三角形,并为每个“可通行”三角形创建相邻信息。一旦地面数据准备就绪,就可以使用findPath()从起始三角形找到目标三角形。findPath()的结果是 A*找到的路径,以三角形索引的向量形式表示,按从起始三角形到目标三角形的顺序排列,或者如果不存在有效路径,则为空向量。
在PathFinder类中还有一个名为mNavTriangles的private成员:
std::unordered_map<int, NavTriangle> mNavTriangles{};
我们将计算出的地面三角形存储在mNavTriangles映射中。通过使用三角形索引(也作为NavData元素的一部分存储)进行索引和三角形数据的映射,以实现快速访问。
让我们接下来逐步分析地面三角形生成代码。
生成地面三角形
地面三角形是通过利用渲染器生成的三角形八叉树生成的。由于我们将对三角形八叉树进行大量请求,它将作为generateGroundTriangles()方法的第二个参数提供。
在清除之前生成的任何导航三角形后,我们通过使用作为第三个参数提供的世界边界进行查询,从八叉树中获取所有三角形:
mNavTriangles.clear();
std::vector<MeshTriangle> levelTris =
octree->query(worldbox);
然后我们遍历所有级别三角形以找到向上面对的三角形子集:
std::vector<MeshTriangle> groundTris{};
NavTriangle navTri;
for (const auto& tri: levelTris) {
if (glm::dot(tri.normal,
glm::vec3(0.0f, 1.0f, 0.0f)) >=
std::cos(glm::radians(
renderData.rdMaxLevelGroundSlopeAngle))) {
groundTris.emplace_back(tri);
通过比较三角形法向量和向上面对向量的点积与rdMaxLevelGroundSlopeAngle值的余弦,我们可以从第十二章中的碰撞检测中得知。如果当前三角形满足检查条件,我们就将其添加到groundTris向量中。
在groundTris中的地面三角形旁边,我们用最少的数据填充名为navTri的NavTriangle,并将navTri添加到mNavTriangles映射中:
navTri.points = tri.points;
navTri.normal = tri.normal;
navTri.index = tri.index;
navTri.center = (tri.points.at(0) +
tri.points.at(1) + tri.points.at(2)) / 3.0f;
mNavTriangles.insert(std::make_pair(tri.index,
navTri))
}
}
在这里,我们在groundTris向量和mNavTriangles映射中使用不同的数据集,因为对三角形八叉树的查询返回一个MeshTriangles向量,但我们维护一个更适合地面三角形的NavTriangles映射。
现在,我们可以遍历所有地面三角形,并查询该级别的三角形八叉树以获取所有碰撞的三角形:
for (const auto& tri : groundTris) {
std::vector<MeshTriangle> nearbyTris =
octree->query(tri.boundingBox);
这个查询工作得很好,因为在第十二章中,我们不得不将渲染类OGLRenderer或VkRenderer的generateLevelOctree()方法中每个三角形的边界框稍微放大,以避免三个维度中的任何一个尺寸为零。通过这种最小尺寸的变化,级别数据中相邻三角形的边界框现在发生了碰撞,三角形八叉树返回所有相邻三角形。
然后我们获取mNavTriangles映射中相同地面三角形的引用,并遍历八叉树查询报告的所有三角形:
NavTriangle& navTri = mNavTriangles.at(tri.index);
for (const auto& peer : nearbyTris) {
使用地面三角形的引用很重要,因为我们将在映射中的NavTriangle对象上直接更新相邻三角形。
尽管这两个嵌套循环看起来很糟糕,但整体计算时间仍然很小,因为八叉树查询只报告少量相邻三角形。我们甚至可以通过简单的检查排除更多的三角形:
if (tri.index == peer.index) {
continue;
}
if (glm::dot(peer.normal,
glm::vec3(0.0f, 1.0f, 0.0f)) <
std::cos(glm::radians(
renderData.rdMaxLevelGroundSlopeAngle))) {
continue;
}
查询碰撞三角形时,也可能报告我们目前正在检查的地面三角形,因此如果我们发现它在结果中,我们将立即返回。我们还从可能的邻居列表中删除所有不面向上方的三角形,因为我们只对相邻地面三角形感兴趣。
在Assimp生成的三角剖分错误的情况下,我们还需要检查相邻三角形是否在mNavTriangles中:
if (mNavTriangles.count(peer.index) == 0) {
continue;
}
如果我们找到一个有效的相邻三角形,我们就从mNavTriangles映射中获取相邻三角形:
NavTriangle peerNavTri =
mNavTriangles.at(peer.index);
最后,我们可以遍历两个三角形的所有三个顶点来检查相邻性:
for (int i = 0; i < 3; ++i) {
for (int j = 0; j < 3; ++j) {
在循环内部,我们计算每个顶点到另一个三角形边的距离:
glm::vec3 pointToPeerLine =
glm::cross(tri.points.at(j) -
peer. points.at(i), tri.points.at(j) -
peer.points.at((i + 1) % 3));
float pointDistance =
glm::length(pointToPeerLine) /
peer.edgeLengths.at(i);
glm::vec3 peerPointToTriLine =
glm::cross(peer.points.at(j) -
tri. points.at(i), peer.points.at(j) -
tri.points.at((i + 1) % 3));
float peerPointDistance =
glm::length(peerPointToTriLine) /
tri.edgeLengths.at(i);
通过计算所有顶点到另一个三角形每条边的距离,我们可以检测三角形是否共享一个顶点或一条边,或者一个三角形的顶点是否位于另一个三角形的边上。
如果距离足够小,我们将当前检查的地面三角形的索引添加到该地面三角形的相邻三角形集合中:
if ((pointDistance < 0.01f ||
peerPointDistance < 0.01f)) {
navTri.neighborTris.insert(peerNavTri.index);
}
在generateGroundTriangles()方法完成计算后,mNavTriangles映射包含所有可能“可通行”的地面三角形,并且对于每个三角形,都有一个至少共享一条边或一个顶点的三角形列表。
关于导航网格质量的说明
生成的地面三角形网格的质量很大程度上取决于地图:由于地图中向上的三角形可能不会形成一个完美的三角形网格,这些三角形之间共享顶点,因此在mNavTriangles映射中生成的地面三角形可能比单独的导航网格有更多的邻居。在级别中通常更多的三角形也会带来更好的质量。
在手头有地面三角形导航网格后,我们可以进行 A*算法。
在两个地面三角形之间寻找路径
findPath()方法的实现遵循 A*算法,应该容易理解。
准备数据
我们首先提取目标和起点的中心点:
NavTriangle targetTri = mNavTriangles.at(targetTriIndex);
glm::vec3 targetPoint = targetTri.center;
NavTriangle startTri = mNavTriangles.at(startTriIndex);
glm::vec3 startPoint = startTri.center;
我们需要中心点来计算节点之间的距离。
接下来,我们创建两个集合navOpenList和navClosedList以及一个名为navPoints的映射:
std::unordered_set<int> navOpenList{};
std::unordered_set<int> navClosedList{};
std::unordered_map<int, NavData> navPoints{};
在navOpenList变量中,我们存储所有候选节点的邻居,在navClosedList中保存所有已经完全探索的节点。navPoints映射包含有关所有已知节点的数据,如距离和父节点。
在 A*算法的第一次迭代之前,我们将currentIndex变量设置为起点三角形,并为起点三角形创建导航数据:
int currentIndex = startTriIndex;
NavData navStartPoint{};
navStartPoint.triIndex = startTriIndex;
navStartPoint.prevTriIndex = -1;
navStartPoint.distanceFromSource = 0;
navStartPoint.heuristicToDest =
glm::distance(startPoint, targetPoint);
navStartPoint.distanceToDest =
navStartPoint.distanceFromSource +
navStartPoint.heuristicToDest;
navPoints.emplace(std::make_pair(startTriIndex,
navStartPoint));
navOpenList.insert(startTriIndex);
目标点的距离通过调用glm::distance计算为欧几里得距离,而起点到起点的距离设置为零,因为我们仍然处于起点。我们还将起点节点添加到开放节点列表和包含导航数据的映射中。
运行主循环
对于 A*算法的主循环,我们启动一个while循环,该循环在遇到目标三角形时结束。循环不保证在某个时间点结束(例如,如果目标三角形位于可到达的网格之外),因此我们需要在循环末尾添加一个退出条件:
while (currentIndex != targetTriIndex) {
NavTriangle currentTri = mNavTriangles.at(currentIndex);
glm::vec3 currentTriPoint = currentTri.center;
std::unordered_set<int> neighborTris =
currentTri.neighborTris;
对于每次循环迭代,我们从mNavTriangles映射中提取当前三角形,并获取世界坐标中的中心点和相邻三角形。
接下来,我们遍历所有相邻的三角形,并提取三角形的中心点:
for (const auto& navTriIndex : neighborTris) {
NavTriangle navTri = mNavTriangles.at(navTriIndex);
glm::vec3 navTriPoint = navTri.center;
如果相邻的节点尚未完全探索甚至尚未访问,我们继续在两个if条件内部,并将该节点添加到开放列表中:
if (navClosedList.count(navTriIndex) == 0) {
if (navOpenList.count(navTriIndex) == 0) {
navOpenList.insert(navTriIndex);
作为提醒,关闭列表包含所有已完全探索的节点,开放列表包含所有已知邻居但尚未完全探索的任何节点的所有邻居(即使是已经关闭的节点)。在此阶段,我们知道这是一个新的要访问的节点,因此我们创建新的导航数据:
NavData navPoint{};
navPoint.triIndex = navTriIndex;
navPoint.prevTriIndex = currentIndex;
对于距离计算,我们从当前索引(我们的父节点)获取距离,并将两个节点之间的距离添加到源距离中:
NavData prevNavPoint =
navPoints.at(navPoint.prevTriIndex);
navPoint.distanceFromSource =
prevNavPoint.distanceFromSource +
glm::distance(currentTriPoint, navTriPoint);
navPoint.heuristicToDest =
glm::distance(navTriPoint, targetPoint);
navPoint.distanceToDest =
navPoint.distanceFromSource +
navPoint.heuristicToDest;
navPoints.emplace(
std::make_pair(navTriIndex, navPoint));
}
作为代码块的最后一个步骤,我们将新的导航数据添加到包含有关所有当前已知节点的navPoints映射中。
如果相邻节点已经在开放节点的列表中,我们检查是否需要更新现有的导航数据:
} else {
NavData& navPoint = navPoints.at(navTriIndex);
在这里,我们获取导航数据的引用,以便能够就地更新信息。对于现有的导航点,我们计算从已知距离到源点和到目标点的启发式值的新估计距离:
NavData possibleNewPrevNavPoint =
navPoints.at(currentIndex);
float newDistanceFromSource =
possibleNewPrevNavPoint.distanceFromSource +
glm::distance(currentTriPoint, navTriPoint);
float newDistanceToDest = newDistanceFromSource +
navPoint.heuristicToDest;
如果通过此节点的新路径比之前已知路径短,我们更新导航数据以反映新的、较短的路径:
if (newDistanceToDest < navPoint.distanceToDest) {
navPoint.prevTriIndex = currentIndex;
navPoint.distanceFromSource =
newDistanceFromSource;
navPoint.distanceToDest = newDistanceToDest;
}
在检查完所有相邻节点之后,我们将当前节点添加到封闭列表中,并标记为已完全探索:
navClosedList.insert(currentIndex);
如果我们的开放列表变为空,我们返回一个空向量:
if (navOpenList.empty()) {
return std::vector<int>{};
}
在进行下一步之前,我们检查开放列表是否为空,因为下一步涉及到遍历开放列表的所有元素。
提取最佳节点
一旦收集了所有新节点并更新了所有现有节点的距离,我们需要找到从起点到目标的最短组合距离的节点。在这里,我们将使用优先队列来最小化访问具有最小距离的节点的成本。优先队列将根据比较函数将所有节点排序成树结构,并允许访问最大或最小节点作为最顶层元素。
首先,我们为优先队列创建比较函数:
auto cmp = [](NavData left, NavData right) {
return left.distanceToDest > right.distanceToDest;
};
默认情况下,优先队列使用std::less作为比较函数,导致最大值作为顶层元素。但通过使用cmp函数,我们将具有最小距离到目的地的元素作为顶层元素。
现在我们可以通过将开放列表中的所有三角形推入队列来填充队列:
std::priority_queue<NavData, std::vector<NavData>,
decltype(cmp)> naviDataQueue(cmp);
for (const auto& navTriIndex : navOpenList) {
NavData navPoint = navPoints.at(navTriIndex);
naviDataQueue.push(navPoint);
}
接下来,我们声明一个空的导航数据变量,并通过调用top()从队列中提取具有最小距离的索引:
NavData nextPointToDest{};
nextPointToDest = naviDataQueue.top();
currentIndex = nextPointToDest.triIndex;
新的三角形索引将用于下一个while循环的迭代,循环直到找到目标三角形作为具有最小距离的三角形。
作为 A*算法的最终步骤,我们从开放列表中删除该节点:
navOpenList.erase(currentIndex);
}
一旦找到目标三角形,外部的while循环结束,我们可以收集并返回最短路径
追溯最短路径
由于我们已经为每个三角形在导航数据中保存了到目前为止具有最短距离的父节点,我们只需沿着父节点链追踪,从目标节点开始,直到遇到起始节点。起始节点由父节点-1标记,因此我们知道何时停止。
首先,我们创建一个名为foundPath的新向量,并将currentIndex存储在其中:
std::vector<int> foundPath{};
foundPath.emplace_back(currentIndex);
当currentIndex与请求的targetIndex相同,主要的while循环结束,因此我们可以使用这两个变量中的任何一个。
然后,我们获取当前三角形的导航数据,并遍历所有父节点,直到遇到起始三角形:
NavData navPoint = navPoints.at(currentIndex);
while (navPoint.prevTriIndex != -1) {
foundPath.emplace_back(navPoint.prevTriIndex);
navPoint = navPoints.at(navPoint.prevTriIndex);
}
由于我们从目标三角形开始回溯,并在起始三角形结束,因此foundPath中三角形的顺序也是从目标到起始。为了修正顺序,我们反转了向量:
std::reverse(foundPath.begin(), foundPath.end());
最后,我们返回反转的路径:
return foundPath;
现在,我们可以使用任何组合的起始和目标三角形调用findPath(),如果存在这样的路径,则会返回一条路径。如果没有从起始点到目标点的路径,findPath()通过检查空开放列表返回一个空向量。
路径查找的起点是已知的;它是实例。但目标呢?让我们在虚拟世界中添加一些可配置的导航目标。
向地图添加导航目标
在我们开始之前,让我们想象一下可能的导航目标必须具备哪些属性:
-
它可以以任何形状和数量出现。
-
它应该容易选择和移动。
-
它可以放置在地面上任何位置。
-
理想情况下,它应该能够自行移动。
因此,我们的理想目标是模型实例!既然我们已经有这个列表的所有成分,实现导航目标就变得容易了。
调整模型和实例
首先,我们在ModelSettings结构体中添加了一个名为msUseAsNavigationTarget的新布尔变量:
bool msUseAsNavigationTarget = false;
AssimpModel类也需要两个简单的public方法,名为setAsNavigationTarget()和isNavigationTarget(),用于设置和查询新变量:
void setAsNavigationTarget(bool value);
bool isNavigationTarget();
在UserInterface类中,将添加一个复选框,通过简单的鼠标点击设置模型的状态。图 13.10显示了带有新复选框的模型布局:

图 13.10:UI 的模型部分,带有新的导航目标复选框
通过在图 13.10中设置用作导航目标复选框,我们可以控制模型是否应该用作导航目标。
对于实例,我们将五个变量添加到InstanceSettings结构体中:
bool isNavigationEnabled = false;
int isPathTargetInstance = -1;
int isPathStartTriangleIndex = -1;
int isPathTargetTriangleIndex = -1;
std::vector<int> isPathToTarget{};
为了按实例控制导航,使用isNavigationEnabled。两个变量isPathTargetInstance和isPathToTarget不需要解释;它们的名称说明了它们的作用。在isPathStartTriangleIndex中,保存了实例当前所在的三角形的索引,而isPathTargetTriangleIndex则填充了目标实例的三角形索引。
在AssimpInstance类中,添加了新变量的简单public设置器和获取器方法:
void setPathStartTriIndex(int index);
void setPathTargetTriIndex(int index);
int getPathTargetTriIndex();
void setPathTargetInstanceId(int instanceId);
void setPathToTarget(std::vector<int> indices);
std::vector<int> getPathToTarget();
在UserInterface类的实例折叠标题中,也添加了一些新的控件:

图 13.11:实例的新导航控件
当检查 启用导航 复选框时,导航控制的其余部分被启用。如果存在任何导航目标模型,导航目标 组合框将填充实例的索引,允许我们设置要使用的实例编号作为目标。如果已设置目标,导航目标 将显示当前目标的索引,或如果未选择任何目标,则显示 -1。要找到目标而无需切换实例,可以按 Center Target,将目标居中在屏幕中间。
对于具有导航目标的组合框,将在 Callbacks.h 文件中添加一个名为 getNavTargetsCallback 的回调:
using getNavTargetsCallback = std::function<std::vector<int>(void)>;
在渲染类文件 OGLRenderer.cpp 和 VkRenderer.cpp 中的对应方法称为 getNavTargets(),并收集所有启用为导航目标的模型的索引:
std::vector<int> targets;
for (const auto& model : mModelInstCamData.micModelList) {
if (!model->isNavigationTarget()) {
continue;
}
我们遍历所有模型,如果模型不是导航目标,则继续下一个模型。如果模型标记为导航目标,我们将所有索引存储在一个名为 targets 的向量中,并返回该向量:
std::string modelName = model->getModelFileName();
for (auto& instance : mModelInstCamData.
micAssimpInstancesPerModel[modelName]) {
InstanceSettings settings =
instance->getInstanceSettings();
targets.emplace_back(
settings.isInstanceIndexPosition);
}
}
return targets;
为了将目标放置在地面上,我们还需要应用重力。
为非动画实例添加重力
由于我们需要动画实例保持在地面进行碰撞检测和逆运动学,因此重力目前仅影响任何动画实例。由于动画和非动画模型的代码保持大部分相似,因此为非动画实例添加重力只需进行几个小的更改。
首先,复制包含 mLevelCollisionTimer.start() 和 mLevelCollisionTimer() 调用的整个代码块,并将其放置在将当前实例添加到实例八叉树调用下方,如下面的代码块所示:
mOctree->add(
instSettings.isInstanceIndexPosition);
**mLevelCollisionTimer.****start****();**
**...**
**mRenderData.rdLevelCollisionTime +=**
**mLevelCollisionTimer.****stop****();**
然后,在新代码下方添加对 updateInstancePosition() 的调用:
**instances.****at****(i)->****updateInstancePosition****(**
**deltaTime);**
最后,将包含世界变换矩阵的检索从实例位置更新后的代码块开始处移动:
mWorldPosMatrices.at(i) =
instances.at(i)->getWorldTransformMatrix();
那就结束了!
现在重力和非动画实例的地面碰撞也被计算,实例位置被更新,最近的世界变换被收集并添加到 mWorldPosMatrices 数组中,然后上传到着色器存储缓冲区对象。
保存和加载新的模型和实例数据
将新值存储在 YAML 配置文件中并恢复设置无需进一步解释。使用模型作为导航目标的布尔值存储在 ModelSettings 结构体中,所有新的路径查找和导航变量存储在 InstanceSettings 结构体中。将新值添加到 YAML 发射器和解析器中只需复制粘贴即可。YAML 配置版本也应更新,以反映新值。
我们有地面三角形、路径查找算法以及路径的起始和目标点。剩下的就是让实例跟随路径的逻辑。所以,现在让我们添加最后一部分。
将实例导航到目标
为了计算或更新到达目标实例的路径,我们需要向渲染器添加更多代码。我们首先添加从实例到目标位置的路径计算代码。
计算到达目标路径
路径更新代码的最佳位置是在渲染类OGLRenderer.cpp或VKRenderer.cpp的draw()调用中所有动画实例的循环中,紧接在为非动画实例复制的地面和碰撞检测代码之后。我们有实例的最终世界位置可用,包括任何重力更新,并可以使用这个位置作为路径查找算法的起点。
首先,我们检查实例是否启用了导航,并获取目标实例的索引:
if (instSettings.isNavigationEnabled) {
int pathTargetInstance =
instSettings.isPathTargetInstance;
然后,我们对目标实例进行合理性检查,以避免在访问micAssimpInstances向量时崩溃:
if (pathTargetInstance >=
mModelInstCamData.micAssimpInstances.size()) {
pathTargetInstance = -1;
instances.at(i)->setPathTargetInstanceId(
pathTargetInstance);
}
接下来,提取目标当前所在三角形的索引以及目标的世界位置:
int pathTargetInstanceTriIndex = -1;
glm::vec3 pathTargetWorldPos = glm::vec3(0.0f);
if (pathTargetInstance != -1) {
std::shared_ptr<AssimpInstance>
targetInstance =
mModelInstCamData.micAssimpInstances.at(
pathTargetInstance);
pathTargetInstanceTriIndex =
targetInstance->
getCurrentGroundTriangleIndex();
pathTargetWorldPos =
targetInstance->getWorldPosition();
}
由于目标实例可能自行移动或被用户移动,必须在每次路径更新之前检索目标三角形索引。这个三角形索引更新确保实例在跟随目标,无论目标是一个静态的航标点还是在该级别周围游荡的另一个实例。
现在我们检查当前实例和目标实例是否都有一个有效的地面三角形,以及我们或目标是否已经远离了保存的三角形。只有当所有条件都满足时,我们才重新计算路径,避免在源或目标没有变化时进行昂贵的计算:
if ((instSettings.isCurrentGroundTriangleIndex > -1 &&
pathTargetInstanceTriIndex > -1) &&
(instSettings.isCurrentGroundTriangleIndex !=
instSettings.isPathStartTriangleIndex ||
pathTargetInstanceTriIndex !=
instSettings.isPathTargetTriangleIndex)) {
instances.at(i)->setPathStartTriIndex(
instSettings.isCurrentGroundTriangleIndex);
instances.at(i)->setPathTargetTriIndex(
pathTargetInstanceTriIndex);
在任何变化的情况下,我们调整当前实例的起始和目标三角形。有了三角形的最最新数据,我们可以调用findPath():
std::vector<int> pathToTarget =
mPathFinder.findPath(
instSettings.isCurrentGroundTriangleIndex,
pathTargetInstanceTriIndex);
如果没有找到有效的路径,结果可能为空。在这种情况下,我们禁用实例的导航,并通过将其设置为-1使目标实例无效:
if (pathToTarget.size() == 0) {
instances.at(i)->setNavigationEnabled(false);
instances.at(i)->setPathTargetInstanceId(-1);
} else {
instances.at(i)->setPathToTarget(pathToTarget);
}
如果路径有效,我们在实例中设置路径索引。
由于路径仅在变化时更新,我们现在获取实例保存的或刚刚更新的路径:
std::vector<int> pathToTarget =
instances.at(i)->getPathToTarget();
为了避免在开始或结束路径到目标时出现尴尬的运动,我们从实例路径中删除起始和目标三角形:
if (pathToTarget.size() > 1) {
pathToTarget.pop_back();
}
if (pathToTarget.size() > 0) {
pathToTarget.erase(pathToTarget.begin());
}
路径是在实例当前站立的地形三角形的中心与目标所在的地形三角形之间创建的。如果实例已经比三角形中心更近,路径将指向后方,实例可能永远不会离开当前的地形三角形。我们也可以从PathFinder类中删除元素,但如果我们要在其他地方使用findPath()生成的数据,可能需要三角形。因此,我们在这里截断三角形索引。
作为路径查找的最后一步,我们将实例旋转到下一个路径点或目标,具体取决于我们是否在pathToTarget中还有路径:
if (pathToTarget.size() > 0) {
int nextTarget = pathToTarget.at(0);
glm::vec3 destPos =
mPathFinder.getTriangleCenter(nextTarget);
instances.at(i)->rotateTo(destPos, deltaTime);
} else {
instances.at(i)->rotateTo(pathTargetWorldPos,
deltaTime);
}
PathFinder类的getTriangleCenter()调用返回请求三角形在世界坐标中的中心。然后,这个中心点被输入到实例的rotateTo()方法中,所以接下来让我们看看旋转方法的实现。
将实例旋转到目标位置
必须在AssimpInstance类中添加一个名为rotateTo()的新public方法。
首先,我们检查实例现在是否在行走或奔跑。如果实例在地面静止时旋转,可能会显得很奇怪:
if (mInstanceSettings.isMoveState != moveState::walk &&
mInstanceSettings.isMoveState != moveState::run) {
return;
}
然后,我们获取当前实例的旋转向量和从我们的位置指向目标位置的向量:
glm::vec3 myRotation = get2DRotationVector();
glm::vec3 twoDimWorldPos =
glm::vec3(mInstanceSettings.isWorldPosition.x,
0.0f, mInstanceSettings.isWorldPosition.z);
glm::vec3 toTarget = glm::normalize(glm::vec3(
targetPos.x, 0.0f, targetPos.z) - twoDimWorldPos);
我们只对围绕 Y 轴的旋转感兴趣,因此我们使用实例的二维旋转向量,并将向量减少到目标和 X、Z 维度的值。
通过使用两个向量myRotation和toTarget,我们可以通过点积计算出两个向量之间的角度:
float angleDiff = glm::degrees(std::acos(
glm::dot(myRotation, toTarget)));
最后,我们计算包含两个向量之间旋转的四元数,提取欧拉角,并使用角度的y元素来旋转实例:
if (angleDiff > 6.0f) {
glm::quat destRoation =
glm::rotation(toTarget, myRotation);
glm::vec3 angles = glm::eulerAngles(destRoation);
rotateInstance(glm::degrees(angles.y) *
deltaTime * 2.0f);
}
与deltaTime的乘法使得实例旋转变得平滑,因为每一帧的角度都很小。angleDiff值的初始比较确保我们留下一个小“死区”,如果路径几乎直线,则会导致旋转次数减少,并避免旋转过度时的振荡。
如果目标在关卡中移动,当路径实例被重新创建时,死区也会减少校正次数。通过仔细调整过度转向量和死区角度,追逐实例的行为将更加自然,因为这两个参数都可以在跟随目标时减少方向变化次数。
为了使计算出的路径可见,我们还应该为渲染器添加一个可视化输出。
为路径添加调试线条
将路径绘制到屏幕上非常简单。除了在OGLRenderData结构体中添加一个名为rdDrawInstancePaths的控制布尔值,对于 OpenGL,相应地,在VkRenderData结构体中添加一个新行网格在渲染器中,以及在UserInterface类中添加一个复选框,创建所有路径点之间的线条很容易。新代码的最佳位置是在计算到达目标路径部分添加的代码之后。
首先,我们检查是否需要创建线条,以及是否有有效的目标:
if (mRenderData.rdDrawInstancePaths &&
pathTargetInstance > -1) {
然后,我们为路径设置所需的颜色和高度偏移,并创建一个顶点来绘制线条:
glm::vec3 pathColor = glm::vec3(0.4f, 1.0f, 0.4f);
glm::vec3 pathYOffset = glm::vec3(0.0f, 1.0f, 0.0f);
OGLLineVertex vert;
vert.color = pathColor;
接下来,我们将当前实例的世界位置作为线条的起始点:
vert.position = instSettings.isWorldPosition +
pathYOffset;
mInstancePathMesh->vertices.emplace_back(vert);
如果我们有有效的路径,我们将提取路径的第一个三角形中心的世界位置作为第一条线的第二个点。由于我们已经移除了起始三角形,因此线条将绘制到路径中的下一个三角形:
if (pathToTarget.size() > 0) {
vert.position = mPathFinder.getTriangleCenter(
pathToTarget.at(0)) + pathYOffset;
mInstancePathMesh->vertices.emplace_back(vert);
然后,我们创建一个新的临时线网格,并通过调用PathFinder类的getAsLineMesh()方法检索路径段的多边形顶点:
std::shared_ptr<OGLLineMesh> pathMesh =
mPathFinder.getAsLineMesh(pathToTarget,
pathColor, pathYOffset);
mInstancePathMesh->vertices.insert(
mInstancePathMesh->vertices.end(),
pathMesh->vertices.begin(),
pathMesh->vertices.end());
辅助方法getAsLineMesh()仅提取路径上地面三角形的中点,将所需的偏移量添加到顶点的世界位置,并从顶点创建线条。
现在,我们将最后一个位置添加为最后一条线的可能起始点:
vert.position = mPathFinder.getTriangleCenter(
pathToTarget.at(pathToTarget.size() - 1)) +
pathYOffset;
mInstancePathMesh->vertices.emplace_back(vert);
}
作为创建线条的最后一步,我们添加目标的世界位置:
vert.position = pathTargetWorldPos + pathYOffset;
mInstancePathMesh->vertices.emplace_back(vert);
如果在移除第一个和/或最后一个元素后路径为空,我们将跳过if条件内的代码,并只从我们的位置画到目标的一条线。这仅在我们有一个有效的目标,并且起始和目标三角形直接相邻时发生。如果在路径寻找过程中发生错误且路径为空,我们将重置目标实例,并且不再绘制线条。
要绘制创建的线条,在渲染器类OGLRendere.cpp或VkRender.cpp中创建一个新的private方法drawInstancePaths(),该方法仅将线条发送到着色器。通过在导航标题下启用启用导航复选框以及在级别标题下启用绘制实例路径复选框来启用导航调试绘制后,实例的导航路径将以绿色线条绘制,类似于图 13.12:

图 13.12:显示实例路径的调试线条
在图 13.12中,添加了交通锥体的模型并将其标记为导航目标。然后,指示实例走向它们的目标。
实例和目标之间锯齿状的路径是所选级别地图中大型地面三角形的结果。由于实例是从三角形中心移动到三角形中心,因此随着中心点之间距离的增大,路径的角度也更大。具有较小地面三角形的级别将为实例提供更平滑的路径。
摘要
在本章中,我们实现了一种简单的路径寻找导航。在概述了允许计算机控制的实例在游戏地图中导航的方法之后,我们探讨了并实现了 A*路径寻找算法,该算法由自定义导航网格创建支持。然后,我们修改了模型,以便在虚拟世界中将其用作导航目标,并使实例能够使用特殊模型的实例作为路径目标。作为最后一步,我们为实例添加了导航功能,允许它们走向或跑向一个随机目标。
在本书的下一章和最后一章中,我们将从实现方面退后一步,看看不同的方法来增强沉浸感和视觉质量。我们将从可听方面开始,讨论向应用程序添加音效和音乐的方法和工具。然后,我们将探讨使世界充满更多生命力的想法,接着讨论增强虚拟世界视觉效果的创意。我们将研究任务和实例之间的交互,最后探索白天和天气变化的影响。
实践课程
这里是一些你可以添加到代码中的内容:
- 即使是大型地面三角形,也要平滑路径。
通过查找接下来的几个路径段,可能可以创建这些段落的平均值。对于更尖锐的转弯,可以通过使用样条曲线来平滑三角形之间的过渡。
- 清理生成的三角形邻接列表。
目前,相邻三角形的列表相当大。这一点可以在激活级别邻接网格调试绘图时看到。尝试找到一种解决方案,只包括共享部分边界的三角形。
- 在墙壁和路径线之间添加最小距离。
对于生成的地面三角形,一些中心位置可能非常接近墙壁,以至于在跟随路径时实例会发生碰撞。在某些情况下,当三角形被视为相邻时,路径甚至会穿过水平几何体的边缘。在创建地面三角形时,尝试找到网格的轮廓,并通过可配置的数量减小其大小。
- 使用实例 AABB 来检测狭窄的通道。
一些地图可能有需要精确导航的通道。在没有真实导航网格的情况下,你可以尝试使用沿路径的实例边界框来寻找可能的碰撞,并调整路径以避免与级别结构发生碰撞。
- 高级难度:在一个编辑器中创建导航网格并将其加载。
如果你熟悉像 Blender 这样的工具,你可以尝试创建一个作为级别单独网格的导航网格,甚至作为一个单独的文件,保存在级别文件旁边。然后,将导航网格加载到 PathFinder 类中,以便为实例提供一个无碰撞的地面。
其他资源
-
Pac-Man 中的幽灵导航:
gameinternals.com/understanding-pac-man-ghost-behavior -
Dijkstra 算法:
graphicmaths.com/computer-science/graph-theory/dijkstras-algorithm/ -
Quake III Arena 的区域感知系统:
www.kbs.twi.tudelft.nl/docs/MSc/2001/Waveren_Jean-Paul_van/thesis.pdf -
Shakey 机器人:
www.sri.com/hoi/shakey-the-robot/ -
A* 简介:
www.redblobgames.com/pathfinding/a-star/introduction.html -
使用 A* 与导航网格:
medium.com/@mscansian/a-with-navigation-meshes-246fd9e72424 -
Unreal Engine 中的导航网格:
dev.epicgames.com/documentation/en-us/unreal-engine/basic-navigation-in-unreal-engine -
在 Unreal Engine 项目中实现 A*:
www.youtube.com/watch?v=xakl29fupCA -
在 Trackmania 中训练无敌的 AI:
www.youtube.com/watch?v=Dw3BZ6O_8LY -
Recast Navigation:
github.com/recastnavigation/recastnavigation -
平滑路径优化:
www.gameaipro.com/GameAIPro3/GameAIPro3_Chapter20_Optimization_for_Smooth_Paths.pdf -
向更真实的寻路方法迈进:
www.gamedeveloper.com/programming/toward-more-realistic-pathfinding
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation

第十四章:创建沉浸式交互式世界
欢迎来到第十四章!在前一章中,我们为实例添加了路径寻找和导航。我们从一个关于计算机游戏中导航方法的简要概述开始。然后我们探讨了并实现了 A*算法,以在实例位置和虚拟世界中的一个点之间找到路径。接下来,我们增强了模型以作为导航目标,并扩展了实例以找到通往其中一个目标的路。在本章的结尾,我们添加了实例能够跟随路径并达到期望目标的能力。
在本章中,我们将探讨不同的想法来增强示例代码,以实现更多的沉浸感和视觉质量。我们将查看可听部分,寻找添加声音效果和音乐到应用程序的工具和方法。此外,我们将实现一个简单的音频管理类来播放声音和背景音乐。为了取悦耳朵和眼睛,我们还将收集一些关于如何增强视觉外观的想法,并实现应用程序的两个图形增强。我们将通过查看白天和天气变化对虚拟世界的影响,并在应用程序中添加基本的日夜循环来结束本书。
在本章中,我们将涵盖以下主题:
-
添加声音效果和背景音乐
-
提升视觉效果
-
通过日夜和天气增强沉浸感
技术要求
本章的示例代码位于chapter14文件夹中:OpenGL 的01_opengl_ideas子文件夹和 Vulkan 的02_vulkan_ideas子文件夹。
添加声音效果和背景音乐
从第十三章中的示例代码运行提供了许多功能,例如面部动画、关卡加载、碰撞检测以及路径寻找和导航。但遗憾的是,屏幕上的所有动画都在无声中进行。没有哔哔声或嘟嘟声,没有声音,也没有音乐。只有寂静。
但声音和音乐是游戏的重要组成部分,原因非常充分。无论是你在享受 Minecraft 的宁静草地或深邃洞穴,随着超级马里奥系列中欢快的音乐摇摆,听着我们行动的声学反馈音效,还是偏好漫游在《死亡空间》或《寂静岭》系列中那些令人毛骨悚然的世界上,仔细聆听环境声音以了解你的周围环境,或者在《极品飞车》系列等赛车游戏中驾驶汽车,听着节奏强劲的音乐和低沉的声音——没有音乐和声音,游戏就无法为玩家提供同样的体验。
要添加声音输出,我们可以包含一个免费可用的声音库,使我们能够轻松地播放声音效果或音乐。让我们首先看看一些库。
使用音频库
大多数音频库是用 C 编写的,但可以找到 C++ 的绑定,以面向对象的方式封装操作系统特定的低级函数调用。仅使用 C 函数并构建自定义抽象也是可能的,类似于 GLFW、OpenGL 和 Vulkan。
Simple DirectMedia Layer
Simple DirectMedia Layer (SDL) 是一个跨平台库,用于计算机的多媒体硬件组件。SDL 管理音频,可以作为窗口功能、图形上下文和输入处理的框架,如 GLFW。此外,还有几个官方库提供对导入和导出图像、自定义网络和字体渲染以在屏幕上显示文本的支持。
OpenAL
OpenAL 是一个专注于多通道、三维音频的跨平台库。通过使用三维音频,可以将声音建模为在玩家前方或后方,而不仅仅是左右,从而加深沉浸感。
PortAudio
PortAudio 是另一个跨平台音频库。它针对实时音频播放和录制。如果 SDL 和 OpenAL 的范围对于项目来说太大,可以使用 PortAudio,目标是仅仅播放一些音频。
FMOD
虽然 FMOD 是一个专有音效引擎,但由于存在非商业许可,它可以被列入免费可用的库列表,允许我们免费使用 FMOD。只有当最终的应用程序或游戏将商业分发时,才需要付费的 FMOD 许可证。FMOD 支持如 Unreal 和 Unity 这样的 3D 引擎,所以如果你在某个时间点正在制作游戏,甚至可能会接触到 FMOD。
在探索了可以包含哪些软件用于声音和音乐回放之后,让我们来看看可以播放以及应该播放的内容。
播放音效
由于声音在我们的生活中扮演着关键角色,我们会对在游戏或模拟中应该听到的内容有所期望。未能满足这些期望可能会损害沉浸感。
游戏角色的脚步声
可能玩家最想听到的最重要的音效是角色在地面上行走的声音。通过调整与地面材料和角色速度相关的声音,向玩家提供关于环境的即时反馈。在无声地穿过草地或在地面上奔跑之间有显著的区别,玩家应该意识到角色的“响度”,并且通过使用碰撞检测和地面三角形发现,如第十二章中的在关卡数据中查找地面三角形部分所述,可以轻松找到地面三角形的材质类型。
其他角色声音
角色会发出更多的声音,不仅仅是脚步声。跳跃和着陆、爬梯子、游泳或被敌人角色伤害也应该给玩家提供可听见的反馈,以提供关于虚拟世界中角色发生情况的额外信息。
本地声音源
不仅玩家控制的角色需要声音效果,虚拟世界中的计算机控制的角色和静止物体也应该播放音频效果。听到门开关的声音、铁匠的锤击声,或鸟儿的鸣叫,以及听到壁炉的噼啪声、瀑布声或世界高处风的声音,将极大地增强沉浸感。
环境声音
环境声音是由来自遥远地点的不同声音和频率混合而成,在传递到听者过程中会丢失一些信息。一大群人、森林中的风,或者几米外的街道都会产生我们熟知的声响,这些声响应该被添加到虚拟世界的相应位置。
天气效果
如果世界上存在某种天气系统,那么相应的声音和效果也应该被添加。远处的雷暴或雪后地面的寂静都可以改变玩家对虚拟世界的感知。
但声音效果不仅有助于沉浸感;播放音乐也有助于保持玩家的注意力。
播放音乐
自从计算机游戏早期开始,音乐就被添加到游戏中以保持玩家的兴趣。因此,当我们看到游戏标题时,我们可能仍然记得游戏的音乐,而不是游戏玩法或游戏角色的细节。
菜单音乐
玩家启动应用程序后看到的第一个屏幕很可能是某种主菜单。直接将玩家投入战斗,而无法首先配置虚拟角色的控制、视觉效果或音量,可能不是最好的选择。当玩家在菜单中探索选项时,音乐可以帮助防止玩家立即感到迷茫或无聊。
环境音乐
与环境声音类似,环境音乐有助于防止游戏中的死寂。完全不播放声音可能是为了建立紧张感,但长时间在寂静中玩游戏可能会变得无聊。添加一个风格和节奏适合游戏玩法的音乐曲目,可以帮助玩家享受游戏的缓慢段落。
自适应音乐播放
当玩家进入开放世界中的不同区域,或进入具有不同风格的场景,或另一个房间时,改变音乐以匹配新地方是一个好主意。进入房间时著名的“听到 Boss 音乐”的时刻,结合门锁的声音,将为玩家创造难忘的体验。
允许自定义音乐
音乐选择的一个有趣选项是让玩家将本地音乐添加到游戏中播放的曲目列表中,甚至完全替换音乐。看到游戏角色在听一些喜欢的音乐曲目时探索虚拟世界,可能会为玩家增添乐趣。
为了体验音频回放可以带来的差异,我们将添加一个 C++类来管理声音效果和音乐回放。
实践:实现音频管理器
音频管理类由两个不同的部分组成。一方面,我们需要音频管理器和渲染器以及其他类之间的高层接口。另一方面,封装的底层接口将音频管理器功能与操作系统的音频库链接起来,这将允许我们用另一个变体替换音频库。
我们将首先简要概述高层接口,然后深入到底层实现。
定义高层接口
新的AudioManager类,包括AudioManager.h头文件和AudioManager.cpp实现文件,将被添加到tools文件夹中。我们将添加通常音乐/声音播放器应具备的基本功能:
-
初始化和清理音频库
-
从文件系统上的文件加载音乐曲目
-
从文件系统上的文件加载音效
-
播放和停止音乐和音效
-
暂停、恢复和跳转到下一首和上一首音乐曲目
-
获取、随机播放和清除当前播放列表
-
改变音乐和音效的音量
例如,向AudioManager类中添加了public的setMusicVolume()和getMusicVolume()方法来控制音乐音量:
void setMusicVolume(int volume);
int getMusicVolume();
此外,名为mMusicVolume的private成员变量用于存储当前的音乐音量:
int mMusicVolume = 64;
对于功能列表中剩余的功能,创建了public方法和private成员变量。请查看AudioManager.h文件以了解AudioManager类中可用的方法和成员变量的完整数量。
现在,我们必须将AudioManager类的对象成员变量以及音频功能的初始化和清理调用添加到应用程序代码中。为了仅保留视频部分的渲染器,AudioManager成员变量和方法将被添加到Window类中。
将AudioManager类添加到Window类中
将音频功能添加到Window类的两个主要步骤与其他所有类相同:在Window.h头文件的顶部包含AudioManager.h头文件,并声明名为mAudioManager的private成员变量:
**#****include****"****AudioManager.h"**
...
**AudioManager mAudioManager{};**
然后,在Window.cpp文件中Window类的init()方法中,我们尝试初始化AudioManager对象:
if (!mAudioManager.init()) {
Logger::log(1, "%s error: unable to init audio,
skipping\n", __FUNCTION__);
}
注意,我们使用Logger类来打印错误信息,但如果AudioManager初始化失败,我们不会结束窗口初始化。音频回放应保持可选,我们将继续进行,而不是在音乐和声音回放失败时终止应用程序启动过程。
在初始化调用后立即将音乐曲目加载到AudioManager中:
if (mAudioManager.isInitialized()) {
mAudioManager.loadMusicFromFolder("assets/music", "mp3"));
}
这里,我们尝试在初始化成功的情况下将 assets/music 文件夹中的所有 MP3 文件加载到 AudioManager 中。同样,如果在资产文件夹中没有找到音乐文件,我们不会失败。
将音乐曲目添加到播放列表
在 AudioManager 类中,已经实现了从本地文件夹 assets/music 加载 MP3 和 OGG 文件到播放列表的支持。您可以将自己的音乐添加到 assets/music 文件夹中,因为当应用程序启动时播放列表将被填充。在 实践课程 部分中,有一个任务可以添加一个 刷新 按钮到 UI 中,允许您在应用程序运行时添加或删除音乐曲目。
最后,我们需要在关闭应用程序时清理 AudioManager。因此,我们将对 Window 类的 cleanup() 方法添加对 AudioManager 的 cleanup() 方法的调用:
void Window::cleanup() {
mRenderer->cleanup();
**mAudioManager.****cleanup****();**
...
}
由于我们在 Window 类中创建了 AudioManager,我们需要创建回调函数以从渲染器或用户界面访问音乐和音效函数。
使用回调使 AudioManager 类在所有地方可用
通过使用这些回调函数,可以从代码的任何地方更改音乐曲目或播放脚步声。作为一个例子,我们将在这里使用播放特定音乐曲目的功能,给定曲目名称作为参数。
首先,我们将名为 playMusicTitleCallback 的回调函数签名添加到 Callbacks.h 文件中:
using playMusicTitleCallback =
std::function<void(std::string)>;
接下来,我们在 ModelInstanceCamData.h 文件中为 ModelInstanceCamData struct 创建一个名为 micPlayMusicTitleCallbackFunction 的变量:
playMusicTitleCallback micPlayMusicTitleCallbackFunction;
我们使用 ModelInstanceCamData struct 来避免在代码中到处传播回调函数。
然后,回调变量将被绑定到 Window 类的 init() 方法中对应的 AudioManager 函数:
ModelInstanceCamData& rendererMICData =
mRenderer->getModInstCamData();
RendererMICData.micPlayMusicTitleCallbackFunction =
this {
mAudioManager.playTitle(title);
};
这里,我们获取渲染器类中包含 ModelInstanceCamData struct 的变量的引用,并使用 lambda 函数分配 AudioManager 类的 playTitle() 方法。
创建回调函数签名、将变量添加到 ModelInstanceCamData struct 以及将回调函数绑定到 AudioManger 类的方法这三个相同的步骤必须为渲染器中应可用的每个音频方法重复执行。
现在,让我们看看音频管理器低级部分的实现。我们将使用 SDL,因为音频函数易于使用。
在低级层使用 SDL
虽然 SDL 可以处理比音频更多的功能,但仍然可以使用 SDL 库提供的函数子集。因此,我们不会使用 SDL 来处理窗口和键盘,这由应用程序中的 GLFW 完成,我们只会使用 SDL 的声音回放和混音功能。
要使用 SDL 进行音频回放,我们需要核心 SDL 库以及名为 SDL_mixer 的单独混音库。
在 Windows 上安装 SDL 和 SDL_mixer
对于 Windows,GitHub 上提供了 SDL 和 SDL_mixer 的预编译版本。访问以下两个 URL 的发布页面,下载最新的稳定开发 zip 文件,文件名中包含 devel 和 VC,用于 Visual Studio。例如,当前 Visual Studio 的主 SDL 库开发包名为 SDL2-devel-2.30.9-VC.zip。以下是 URL:
现在,将两个 zip 文件解压到您的计算机上,但请注意避免在路径名称中使用空格和特殊字符,如德语的重音符号,以避免问题,因为即使在 2024 年,许多工具仍然在路径中的特殊字符上存在问题。
接下来,添加两个 环境变量 以帮助 CMake 搜索脚本找到头文件和库:
-
第一个变量名为
SDL2DIR,必须指向主 SDL 库解压的文件夹。 -
第二个变量名为
SDL2MIXERDIR,必须指向SDL_mixer库的文件夹。
图 14.1 展示了环境变量的示例:

图 14.1:SDL 的示例环境变量
安装两个库并添加环境变量后,可以编译本章的代码。CMake 构建脚本负责将两个 DLL 文件放置在可执行文件旁边。
在 Linux 上安装 SDL 和 SDL_mixer
在 Linux 上,可以通过集成软件包管理器添加 SDL 库。在 Ubuntu 或 Debian 上,您可以使用 apt 软件包管理器通过以下命令安装主 SDL 库、SDL_mixer 库以及所有开发头文件和库:
sudo apt install libsdl2-dev libsdl2-mixer-dev
对于基于 Arch 的发行版,使用 pacman 软件包管理器通过以下命令将两个库添加到系统中:
sudo pacman –S sdl2 sdl2_mixer
为了将 SDL 和 SDL_mixer 添加到 AudioManager 类中以便使用 SDL 函数,我们必须在 AudioManager.h 头文件中现有的 #include 指令之后包含两个 SDL 头文件:
#include <SDL.h>
#include <`SDL_mixer`.h>
由于 SDL 是一个 C 库,所有结构只使用原始指针。音频管理器是您将在代码中找到带有原始指针变量的地方之一:
std::unordered_map<std::string, Mix_Music*> mMusicTitles{};
Mix_Chunk* mWalkFootsteps;
mMusicTitles 变量是一个包含多个 SDL 音乐对象的映射,可以通过轨道名称作为映射的键访问。每个轨道都保存在名为 Mix_Music 的变量中,前缀 Mix 表示这是一个由 SDL_mixer 库使用的变量。
在 mWalkFootsteps 变量中,存储了一个所谓的音频块(使用 SDL 的术语)。可以通过调用相应音效的回放函数来播放 SDL_mixer 音频块。
在播放音效或音乐之前,SDL 的音频部分必须正确初始化,并在应用程序结束时结束音频功能。
初始化和关闭 SDL 及 SDL_mixer
作为 AudioManager 类的 init() 方法的第一步,我们尝试初始化 SDL 的音频部分:
if (SDL_Init(SDL_INIT_AUDIO) < 0) {
return false;
}
SDL_Init() 和 SDL_INIT_AUDIO 标志的 SDL 前缀表示我们正在使用 SDL 的核心功能。如果我们无法初始化 SDL 的音频部分,我们将在这里停止 AudioManager 的初始化。请注意,音频设备的初始化可能会因各种原因而失败,例如,如果音频设备被另一个应用程序使用并禁止共享该设备。
接下来,我们尝试通过调用 Mix_OpenAudio() 来设置音频设备的参数:
if (Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 8192) < 0) { return false;
}
现在,我们进入了 SDL_mixer 的领域,这在 Mix_OpenAudio 的 Mix 前缀中可见。Mix_OpenAudio 的第一个参数是重放频率(Hz)。我们使用由索尼的 光盘 规范引入的知名值 44.1 kHz。作为第二个参数,配置了内部音频格式。MIX_DEFAULT_FORMAT 代表 16 位有符号整数值,这在音频领域也是一个常见值。
第三个参数是要使用的输出通道数。一个输出通道使用单声道重放,初始化调用中的两个通道用于立体声输出。根据音频硬件和驱动程序支持,对于 7.1 系统来说,可能最多有八个输出通道。
通过第四个和最后一个参数,配置了 SDL_mixer 库中的内部音频缓冲区。如果 CPU 负载过高而填充缓冲区时,较小的缓冲区值可能会导致音频中断,而较大的缓冲区会导致延迟,因为缓冲区需要被填充后才能播放样本。缓冲区参数的单位是“样本帧”,因此我们配置 SDL_mixer 为保留 2,048 个帧的空间,每个帧包含两个 16 位有符号整数,每个输出通道一个。
在应用程序的终止时间,Window 类的 cleanup() 方法会调用 AudioManager 的 cleanup() 方法。在 AudioManager 类的 cleanup() 方法内部,我们关闭音频设备,并通知 SDL_mixer 和 SDL 运行它们各自的清理代码:
Mix_CloseAudio();
Mix_Quit();
SDL_Quit();
注意,我们在 init() 方法中没有调用 Mix_Init(),但我们必须在这里调用 Mix_Quit()。SDL 在没有显式调用 Mix_Init() 的情况下为我们管理混音初始化。
现在我们准备播放音乐和音效。让我们先看看音乐重放代码。
控制音乐重放
通过调用带有 C 风格字符数组作为参数的 Mix_LoadMUS() 来加载音乐曲目:
Mix_Music* music = Mix_LoadMUS(fileName.c_str());
SDL_mixer 支持多种音乐格式,如 WAV、MP3 和 OGG。Mix_LoadMUS() 的返回值是指向 Mix_Music struct 的指针。
要清理加载的音乐曲目,必须使用指向上述 Mix_Music struct 的指针调用 Mix_FreeMusic():
Mix_FreeMusic(music);
一条音乐曲目可以通过调用带有两个参数的 Mix_PlayMusic() 来播放:
Mix_PlayMusic(music, 0);
第一个参数是指向有效 Mix_Music 结构体 的指针,而第二个参数告诉 SDL_mixer 要播放的循环次数。循环值为 0 将禁用循环,而值为 -1 将无限循环音乐。
通过调用 Mix_HaltMusic() 来停止音乐:
Mix_HaltMusic();
由于一次只播放一个音乐曲目,因此不需要参数,并且如果当前曲目应该暂停或恢复,则有两个 Mix_PauseMusic() 和 Mix_ResumeMusic() 调用可用:
Mix_PauseMusic();
Mix_ResumeMusic();
最后,通过 Mix_VolumeMusic() 控制音乐音量:
Mix_VolumeMusic(volume);
Mix_VolumeMusic() 的参数是一个介于 0 和 128 之间的 int 值,用于设置新的音量,或者值为 -1 用于查询音乐的当前音量。
通过使用默认的 SDL_mixer 调用,我们只能播放一个音乐曲目一次或使用无限循环。如果需要动态音乐系统,则需要手动实现,甚至可能需要考虑不同的声音重放 API。对于在曲目结束时简单前进到播放列表中下一个曲目的简单能力,我们只需要实现一个回调。
添加用于连续音乐播放的回调
SDL_mixer 支持一个回调,用于通知应用程序当前音乐曲目已完成指定次数的循环。在这个回调中,我们可以简单地转发到播放列表中的下一个曲目。
很遗憾,SDL_mixer 是一个 C 音频库,回调必须满足 C 风格的调用约定。C++ 调用约定与 C 调用约定不兼容,并且只允许调用 AudioManager 类的静态成员函数。我们需要添加一个小技巧,以便能够调用具有访问播放列表和播放列表当前位置的 AudioManager 类的非静态方法。
首先,我们声明一个名为 mCurrentManager 的原始指针作为 private 成员变量,加上用于 C 风格回调的 private 静态 staticMuscFinishedCallback() 成员方法和用于翻译回调的 private 非静态成员 musicFinishedCallback():
static AudioManager* mCurrentManager;
static void staticMusicFinshedCallback();
void musicFinishedCallback();
在 AudioManager 类的 init() 方法中,我们将 mCurrentManager 指针设置为当前实例,并使用静态回调方法调用回调钩子设置方法 Mix_HookMusicFinished():
mCurrentManager = this;
Mix_HookMusicFinished(staticMusicFinshedCallback);
现在,每当音乐曲目结束时,SDL_mixer 会调用 staticMusicFinishedCallback()。要将回调转换为 C++,我们使用存储在 mCurrentManager 中的当前 AudioManager 实例的指针来调用非静态回调 musicFinishedCallback() 方法:
if (mCurrentManager) {
mCurrentManager->musicFinishedCallback();
}
在 musicFinishedCallback() 内部,我们现在可以添加代码以在播放列表中前进一个曲目,从而实现所有曲目的连续重放:
if (mMusicPlaying) {
playNextTitle();
}
在音乐重放实现后,让我们转到声音效果重放代码。
播放声音效果
与播放音乐相比,使用SDL_mixer播放音效有一些细微的差别。主要区别是默认情况下可以同时播放八个音效,因为SDL_mixer为音效输出分配了八个内部音效通道。
注意,这些内部音效通道与我们初始化音频设备时配置的两个输出音效通道不同。
可以通过调用Mix_AllocateChannels()并传入期望的通道数作为参数来分配更多或更少的这八个音效通道:
Mix_AllocateChannels(1);
目前我们将在AudioManager中仅使用一个通道,以便简单实现脚步声回放的功能。
由于SDL_mixer目前只有一个通道可用,如果另一个音效仍在播放,则无法播放第二个音效。因此,通过将通道数限制为只有一个,我们可以避免在示例实现中创建一个复杂的系统来在行走和跑步脚步声之间切换。
从文件系统中加载音文件的过程与音乐加载过程类似。我们通过调用Mix_LoadWav()并传入文件名的 C 风格字符数组来加载音文件,并将返回的结果存储在Mix_Chunk结构体中:
Mix_Chunk* mWalkFootsteps;
mWalkFootsteps = Mix_LoadWAV(fileName.c_str());
通过调用Mix_FreeChunk()来清理音效:
Mix_FreeChunk(mWalkFootsteps);
播放和停止音效回放的方式与播放或停止音乐类似。通过使用Mix_PlayChannel()来播放音效:
mSoundChannel = Mix_PlayChannel(-1, mRunFootsteps, 0);
Mix_PlayChannel()的第一个参数是要使用的音效通道。特殊值-1将使用下一个可用的音效通道进行音效回放。第二个参数是播放的Mix_Chunk结构体的指针,第三个参数是循环次数。
作为Mix_PlayChannel()的返回参数,我们得到播放此音效的音效通道号。我们将通道号保存在mSoundChannel成员变量中,以便能够通过Mix_HaltChannel()停止回放:
Mix_HaltChannel(mSoundChannel);
就像音乐一样,我们可以通过调用Mix_Volume()并传入通道号和期望的音量作为参数来控制音效通道的音量:
Mix_Volume(mSoundChannel, volume);
在我们知道如何播放音效之后,我们需要在渲染器中添加一些代码,以便在实例行走或跑步时播放脚步声。
在渲染器中使用脚步声效果
脚步声回放的实现将是最基本的,以展示如何以及在哪里添加音效的回调函数。一个功能齐全的音效回放系统需要更多的工作,并且超出了本书的范围。
要添加脚步声,需要在渲染器的handleMovementKeys()方法中添加以下代码片段,紧接在通过调用setNextInstanceState()设置实例的下一个状态之后。此时,我们已经拥有了实例当前运动状态的所有数据。
首先,我们检索当前实例的当前动画状态,并检查实例是否处于空闲/行走/跑步循环:
if (currentInstance->getAnimState() ==
animationState::playIdleWalkRun) {
我们只想在行走和跑步动画回放的同时添加音效。
然后我们检查实例的运动状态,并调用适当的状态回调:
switch (state) {
case moveState::run:
mModelInstCamData.
micPlayRunFootstepCallbackFunction();
break;
case moveState::walk:
mModelInstCamData.
micPlayWalkFootstepCallbackFunction();
break;
当实例处于运行状态时,我们播放跑步脚步声音效。如果实例处于行走状态,则播放行走脚步声。
如果实例既不运行也不行走,我们停止脚步声音效:
default:
mModelInstCamData.
micStopFootstepCallbackFunction();
break;
}
如果实例不在空闲/行走/跑步循环中,我们也会停止任何声音。这样,我们可以捕捉到所有尚未提供音效的动作,如跳跃或出拳:
} else {
mModelInstCamData.
micStopFootstepCallbackFunction();
}
要听到脚步声,需要执行以下步骤:
-
启动应用程序并加载具有动画映射的配置。
-
选择(或创建)一个第一人称或第三人称相机。
-
在 UI 的控制窗口的相机部分中,选择实例并通过点击使用所选实例来设置实例。
-
按
F10键切换到查看模式。
如果你现在通过使用鼠标和W/A/S/D键移动受控实例,你应该听到两种不同的脚步声,这取决于实例在虚拟世界中行走或跑步。
扩展音频管理器类
为了概述AudioManager类可能的扩展,以支持更多通道以实现更类似游戏的声音管理,我们需要跟踪Mix_PlayChannel()调用返回的播放脚步声音通道。通过为本地脚步声保留一个专用通道,我们可以实现相同的行为,但我们将能够同时播放更多音效。
通过创建一个声音通道池并向Mix_ChannelFinished() SDL 函数添加回调,可以实现对多个音效的处理,类似于Mix_HookMusicFinished()。SDL 在通道完成当前音剪辑或调用Mix_HaltChannel()时触发Mix_ChannelFinished()回调,并在回调中传递完成的声音通道编号。
当播放音效和音效回放完成后,可以更新声音通道池。通过使用产生音效的对象的距离并使用Mix_Volume()或Mix_SetDistance()缩小通道音量,可以模拟不同源的距离。此外,可以使用Mix_SetPanning()调整声音源的位置到左右。
在“实际操作”部分中,有几个任务可用于从当前状态演变声音回放。
作为如何使用UserInterface类的AudioManager回调的示例,一个简单的音乐重放控制已被添加到用户界面中。在控制窗口的音乐与音效部分,您将找到一个组合框和一些按钮,可以播放从assets/music文件夹创建的播放列表中的音乐。
在 UI 中使用音乐播放器
将音乐重放功能添加到UserInterface类是一个快速且简单的工作。通过使用对AudioManager类的回调,仅用几个代码块就实现了基本的音乐播放器。
在图 14.2中,展示了使用AudioManager类的基本音乐播放器的 ImGui 部分:

图 14.2:添加到用户界面的基于 AudioManager 的音乐播放器
可以通过使用曲目组合框来选择要播放的音乐曲目,通过按下播放按钮播放当前曲目,或者通过按下播放随机按钮从随机播放列表中选择一个曲目。其他四个按钮,上一曲、暂停、停止和下一曲,正好执行标签上所指示的操作,并且通过使用滑块,可以将音乐和音效的音量设置为介于0(静音)和128之间的值。
不同的声音管理器实现
应用程序中使用的AudioManager类采用了一种简单的实现方式,通过 C++回调函数直接控制。对于高级声音系统,可以使用基于事件或消息的实现。这种实现可以使用游戏中已有的事件管理代码,并且事件或消息还可以将音效重放与请求重放音效或更改音乐的代码解耦。
在虚拟世界中有了声音和音乐之后,应用程序图形的更新也可能出现在待办事项列表中。现在让我们探索一些视觉增强功能。
增强视觉效果
示例代码中的 OpenGL 和 Vulkan 渲染器仅支持最小功能集,以将图像渲染到屏幕上:两个渲染器都只能绘制纹理三角形和彩色线条。添加以下增强功能之一将极大地提升图像质量。
通过使用基于物理的渲染为世界带来色彩
目前,我们只使用 Assimp 对象的纹理来将模型实例和关卡几何渲染到屏幕上。通过使用基于物理的渲染,简称PBR,我们还可以以材料的形式模拟表面属性。使用这种 PBR 材料,可以轻松创建具有金属光泽和反射性的表面,或者使混凝土或砖块等表面看起来更加自然。
图 14.3展示了 Sascha Willems(代码可在github.com/SaschaWillems/Vulkan/tree/master/examples/pbribl)的 Vulkan PBR 示例代码绘制的球体:

图 14.3:不同的 PBR 材质
在图 14.3的左侧,环境的反光性被设置为高值,结果产生了一个金色球体。在球体之间,反射设置逐渐变化,在右侧则完全没有设置反光性。
PBR 渲染和其他示例的源代码链接可在附加资源部分找到。
添加透明度
在现实世界中,我们周围充满了透明物体,因此一个没有窗户和由透明材料制成的物体的虚拟世界可能会感觉有点奇怪。然而,由于光线在通过多个透明物体时颜色变化背后的物理原理,渲染透明度是复杂的。这需要在同一屏幕位置从后向前绘制多个透明像素,以计算像素的正确最终颜色。
存在两种不同的方法,称为有序透明度和无序透明度。有序透明度要求所有透明物体从后向前排序,而无序透明度会自动重新排列要绘制的像素以进入正确的顺序。两种方法都有优缺点,因此最好的选择是测试两种版本。
图 14.4展示了 LearnOpenGL(learnopengl.com/)中 Joey de Vries(x.com/JoeyDeVriez)提供的两个透明度示例:

图 14.4:透明植物(左侧)和半透明红色玻璃(右侧)
在图 14.4的左侧,展示了部分透明的植物。通过丢弃纹理中的空白区域像素,可以实现对植物的逼真渲染。在图 14.4的右侧,渲染了彩色玻璃。通过使用透明纹理来模仿窗户和其他半透明物体,有助于创建更逼真的现实世界复制品。
仰望美丽的天空
如果你启动了使用本书示例代码创建的虚拟世界,并加载一个部分设计在建筑之外的关卡,你将看不到某种天空,而只是默认的背景颜色。向场景添加美丽的天空不仅需要简单的天空纹理,还需要所谓的立方体贴图和扭曲的天空纹理。
立方体贴图是一种特殊的渲染对象,它表示立方体的六个面,天空纹理被投影到立方体贴图上。生成的天空盒将创建一个无缝的背景,跟随虚拟摄像机的视角,将关卡几何体和实例放置在逼真的环境中。
图 14.5展示了由 Joey de Vries 使用 LearnOpenGL(learnopengl.com/)代码创建的木箱周围的天空盒环境,Joey de Vries:

图 14.5:以天空盒为背景的木箱
当移动视图时,可以看到使用天空盒的效果。在静态图片如图 14.5中,可以看到木质盒子与天空盒之间的区别。
我们将在本节结束时在我们的虚拟世界中实现一个天空盒。
玩转光与阴影
当前的顶点着色器正在使用一个硬编码的光源作为虚拟太阳,从固定位置向世界发射白光。这种基本照明有助于识别关卡几何形状和实例的尺寸,即使只使用平面着色也能做到。通过向虚拟世界添加更多灯光,可以以更逼真的方式模拟其他发光物体,例如灯具、灯笼、火焰或火炬。在黑暗的地方,火焰的闪烁灯光可以用来创造各种类型的紧张感,因为它可能意味着一个可以过夜的安心地方或敌人的位置。
图 14.6中的场景由几千种不同颜色的光源照亮:

图 14.6:虚拟世界中的多光源(图片由汉内斯·内瓦莱宁提供)
图 14.6中的图像是由汉内斯·内瓦莱宁制作的 jMonkeyEngine 技术演示,可以看到许多单独灯光的效果。完整的视频可在附加资源部分查看。
当添加灯光时,也应该实现阴影。作为一个简单的开始,可以通过所谓的阴影映射将虚拟太阳投射出的物体阴影投影到地面上,从而产生从现实世界获取的光影效果。也可以为其他灯光添加阴影,尽管实现起来更复杂。但视觉效果将弥补这种努力,因为只有将窗户的亮部投射到地面的灯会为程序员带来大大的微笑。
图 14.7是由萨沙·威尔伦斯制作的 Vulkan 示例代码创建的,代码可在github.com/SaschaWillems/Vulkan/tree/master/examples/pbribl找到),展示了使用级联阴影映射创建的树木阴影:

图 14.7:树木的级联阴影映射
与天空盒类似,当光、物体和/或相机移动时,使用阴影映射的效果可以看得更清楚。如图 14.7 所示的阴影是虚拟世界的一个很好的补充。
在逼真的水中游泳
如果将水作为扩展关卡环境的一部分添加,也应该检查并增强其视觉外观。虽然使用简单的静态透明水纹理可能对第一次实现来说足够好,但最终可能会产生创建更好水的需求。
通过使用反射、折射和虚拟水面受到的光线扭曲的组合,可以创建逼真的水,如果玩家应该能够潜入水中,可以利用不同类型的扭曲来创造水下错觉。
图 14.8 展示了由 Evan Wallace(代码可在github.com/evanw/webgl-water/blob/master/index.html获取)使用 WebGL 制作的水模拟:

图 14.8:带有波浪和水下全息图的逼真水(由 Evan Wallace 提供)
在图 14.8中,可以看到球体落入水中的波浪。此外,水造成的墙壁折射和池底的光线折射都清晰可见。
添加令人惊叹的后处理效果
为了在虚拟世界中获得更多的现实主义,可以在渲染器中添加后处理效果。可能的效果列表很长,所以这里只简要列出了一些可以在创建出色视觉效果的同时快速实施的想法:
-
镜头光晕以模拟通过相机看的效果
-
光束:当直接看到太阳被阻挡时,在雾中的可见光线
-
模拟发光物体的光晕效果
-
移动模糊在视图移动时模糊图像
-
景深模糊使尖锐中心周围的场景模糊
-
屏幕空间环境光遮蔽使缝隙和边缘变暗
-
屏幕空间反射,一种创建反射表面的廉价方法
所有这些效果都是通过使用着色器创建的,并且有不同的性能影响。尽管如此,即使这里列出的效果中的一小部分也会极大地提升视觉效果。
图 14.9展示了两个后处理效果,光晕和屏幕空间环境光遮蔽(SSAO)。光晕效果是用 Joey de Vries 从 LearnOpenGL(learnopengl.com/)的代码创建的,SSAO 图片是用 Sascha Willems 的 Vulkan 示例中的代码制作的(代码可在github.com/SaschaWillems/Vulkan/tree/master/examples/pbribl获取):

图 14.9:光晕(左)和 SSAO(右)
在图 14.9的左侧,展示了光晕效果。光晕效果的特征是光源周围的光环效果。对于绿色光源,光线甚至重叠在木箱的左上角。
在图 14.9的右侧,展示了 SSAO(屏幕空间环境光遮蔽)。SSAO 效果可能很微妙,但它是可见的:看看白色线条右侧窗帘下面的地板。阴影创造出窗帘后面更暗房间的错觉。
升级到光线追踪
作为向现实主义的下一步,可以添加光线追踪作为可选增强。
光线追踪使用从相机发出的虚拟光线,通过追踪光线与虚拟世界中对象的碰撞来计算结果像素颜色。而不是仅仅使用对象颜色,虚拟光线遵循物理规则并跟随,直到添加的光量低于阈值。
使用光线追踪,可以实现诸如全局光照、由物体反射的光照亮暗区,或逼真的反射等效果。想象一下在满是镜子的房间里奔跑,看到你的角色多次以正确的方式绘制出来。
图 14.10展示了由 Sascha Willems 的 Vulkan 代码中的光线追踪示例创建的场景(代码可在github.com/SaschaWillems/Vulkan/tree/master/examples/pbribl找到):

图 14.10:实时光线追踪场景
在图 14.10中,地板、球体和茶壶上的反射是通过在 Nvidia RTX 4000 系列 GPU 上使用 Vulkan API 进行光线追踪实时计算的。
包含了 FPS 计数器以显示当前 GPU 代绀创建光线追踪图像的速度。仅仅几十年前,同一张图片的一帧渲染就需要几天时间。
光线追踪应该是可选的
注意,光线追踪的计算需要大量的计算能力,实时创建复杂场景需要 GPU 和支持光线追踪的图形 API。目前,只有 Vulkan 和 DirectX 12 能够使用现代 GPU 的光线追踪功能。在切换到启用光线追踪的图形管线之前,需要检查硬件和软件支持的情况。
潜入虚拟现实
尽管虚拟现实(VR)仍然是一个小众领域,但实现 VR 支持可以是一个向前迈出的一大步,以增强沉浸感。不仅能够看到虚拟世界在平面屏幕上,而且能够站在世界的正中央,这可以成为玩家难忘的时刻。使用头部追踪将虚拟摄像头同时移动到玩家的头部,并为 VR 控制器添加虚拟手,为交互创造了大量新机会。
图 14.11展示了 Godot XR 工具演示中的一个场景(代码可在github.com/GodotVR/godot-xr-template找到):

图 14.11:Godot XR 工具演示
图 14.11最引人注目的细节是两只虚拟手。通过使用 Valve Index®控制器的集成传感器,不仅可以推断出控制器在所有 6 个自由度中的位置,还可以跟踪每个手指,以允许根据手指的位置进行手势或动作。
在本节的理沦部分之后,我们现在将向应用程序添加一个天空盒,作为全局背景。
实践:将天空盒添加到虚拟世界
虚拟天空是虚拟世界的一个很好的补充,尤其是在任何开阔区域上方,如图 14.5 所示。
探索技术细节
从技术角度来看,天空盒是从应用于单位立方体内部的纹理绘制的。图 14.12 显示了立方体和放置在立方体中心虚拟相机周围的坐标:

图 14.12:从立方体贴图内部采样的区域
在图 14.1 中,立方体的红色区域将被采样以创建当前帧的背景。请注意,该区域在立方体的角落处环绕,但这不是什么值得担心的事情。两个图形库都处理这类边缘情况(有意为之),并将采样两个受影响立方体贴图面的相应区域。
立方体贴图通常存储为六张单独的图像或作为一张单图,其中立方体的侧面位于图像中的特定位置。图 14.13 显示了由 Jockum Skoglund(又名 hipshot – opengameart.org/content/stormy-days-skybox)制作的常见格式天空盒纹理以及立方体贴图面:

图 14.13:立方体贴图纹理和每张图片的立方体贴图面
在图 14.13 左侧,显示了用于天空图的示例立方体贴图纹理。你将找到许多这种类型的交叉立方体贴图纹理。在图 14.13 右侧,列出了立方体贴图纹理中每个较小子图像的立方体贴图面。请记住,OpenGL 和 Vulkan 使用反转视口时,负 Z 轴指向虚拟世界,因此前图像为-Z。
另一个值得注意的细节是立方体贴图图像的畸变。由于立方体贴图纹理应用于立方体,但围绕我们的虚拟天空可以看作是一个球体,因此立方体贴图纹理的像素必须调整,以使其看起来像是从球体内部拍摄的图像。图 14.14 显示了立方体内部和球体内部的投影差异:

图 14.14:立方体的采样点与球体校正投影的比较
如图 14.14 左侧所示,从立方体贴图取样的采样点与球体表面的位置差异随着我们接近立方体贴图一侧的边缘而增大。在图 14.14 右侧,从立方体贴图采样的区域和投影到球体表面的所需区域以黑色条带突出显示。你可以看到这两个区域的尺寸和角度都不同。在为天空盒构建立方体贴图纹理时,必须对每个立方体贴图面的图像应用球形畸变,以创建一个合理的天空。
实现天空盒
将天空盒添加到代码中只需要几个组件:
-
新的顶点和网格类型
-
新的顶点缓冲区
-
包含面坐标的单位立方体模型。
-
新的着色器
-
从文件中加载立方体贴图纹理
我们在opengl文件夹中的OGLRenderData.h文件中创建一个新的顶点类型OGLSkyboxVertex和一个新的网格类型OGLSkyboxMesh:
struct OGLSkyboxVertex {
glm::vec4 position = glm::vec4(0.0f);
};
struct OGLSkyboxMesh {
std::vector<OGLSkyboxVertex> vertices{};
};
对于 Vulkan,必须将两个新的struct元素添加到vulkan文件夹中的VkRenderData.h文件中。有关 Vulkan 实现的详细信息,请查看渲染器类文件VkRenderer.cpp。
尽管我们可以重用模型的顶点缓冲区,但这样做会浪费大量资源,因为立方体只需要顶点的位置数据。新的顶点缓冲区类SkyboxBuffer仅使用新顶点的position元素,struct OGLSkyboxVertex:
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE,
sizeof(OGLSkyboxVertex),
(void*) offsetof(OGLSkyboxVertex, position));
glEnableVertexAttribArray(0);
对于立方体模型,我们创建一个名为SkyboxModel的类,返回一个由 36 个顶点组成的OGLSkyboxMesh,每个侧面使用两个三角形。在所有三个轴上,模型坐标要么是1.0要么是-1.0,定义了一个单位立方体。
新的顶点着色器skybox.vert输出一个包含三维纹理坐标的vec3。在这里我们需要三维坐标,因为我们处于一个立方体内部:
layout (location = 0) out vec3 texCoord;
在顶点着色器的main()方法中,我们首先反转投影矩阵并转置视图矩阵:
mat4 inverseProjection = inverse(projection);
mat3 inverseView = transpose(mat3(view));
对于视图矩阵,我们可以使用更便宜的转置操作而不是取逆矩阵,因为我们需要去除平移部分以停止立方体随摄像机移动。
通过将逆矩阵与立方体的传入点位置相乘,我们计算世界空间中的纹理坐标:
texCoord = inverseView * (inverseProjection * aPos).xyz;
现在,要采样的纹理坐标来自立方体的内部,如图14.12所示。
作为顶点着色器的最后一步,我们将 GLSL 内部变量gl_Position设置为传入的顶点位置:
gl_Position = aPos.xyww;
通过将gl_Position的z分量设置为w分量的值,我们确保在远-Z 平面上绘制立方体贴图的像素,从而创建一个不会被其他像素覆盖的背景。
新的片段着色器skybox.frag使用传入的纹理坐标在立方体贴图中查找纹理数据:
layout (location = 0) in vec3 texCoord;
layout (location = 0) out vec4 FragColor;
uniform samplerCube tex;
void main() {
FragColor = texture(tex, texCoord);
}
请注意,纹理tex的类型是samplerCube而不是其他片段着色器中的sampler2D。
立方体贴图纹理的加载在Texture类中完成。在新的loadCubemapTexture()方法中,我们加载如图14.13 所示的图像并提取六个图像。由于纹理映射侧面定义的值是按升序排列的,我们可以直接使用第一个侧面定义GL_TEXTURE_CUBE_MAP_POSITIVE_X并为剩余的侧面添加整数值以上传:
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, GL_SRGB8_ALPHA8, cubeFaceWidth, cubeFaceHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, subImage.data());
对于立方体贴图纹理加载的完整实现,请查看Texture类的详细信息。
绘制天空盒
将天空盒显示在屏幕上现在分为两个步骤。首先,我们在OGLRenderer.cpp渲染类文件的init()方法中将单位立方体模型数据上传到顶点缓冲区:
mSkyboxModel.init();
OGLSkyboxMesh skyboxMesh = mSkyboxModel.getVertexData();
mSkyboxBuffer.uploadData(skyboxMesh.vertices);
然后,在绘制级别数据之前,我们使用新的着色器绘制天空盒,绑定纹理并从顶点缓冲区绘制立方体:
mSkyboxShader.use();
mSkyboxTexture.bindCubemap();
mSkyboxBuffer.bindAndDraw();
mSkyboxTexture.unbindCubemap();
对于 Vulkan,我们也在VkRenderData.cpp文件的init()方法中上传天空盒模型和纹理,绑定天空盒管线,并在draw()方法中绘制级别数据之前绘制天空盒模型。
对于 OpenGL 和 Vulkan 渲染器,在绘制天空盒作为第一个对象时,应进行简单的更改:禁用深度测试功能。没有深度测试,天空盒纹理将覆盖先前颜色缓冲区中的所有值,作为剩余对象(如级别数据和实例)的全局背景。就这样。
如果一切添加正确,可以在图 14.15中看到级别上方的新鲜和壮丽的天空(该图像由 Zarudko 使用 glTF 级别创建,skfb.ly/6QYJw,使用 Jockum Skoglund(又名 hipshot)创建的纹理作为天空盒):

图 14.15:激活了天空盒的示例级别
如图 14.15所示,天空盒为开放区域级别的场景带来了真实世界的感受。由于立方体在视图变化时不会像级别数据那样移动,因此创造了一种无限远的天空幻觉。
即使有出色的图形和酷炫的声音效果和音乐,仍有改进的空间。虚拟世界的环境仍然有些静态,所以让我们给世界本身添加一些变化。
通过白天和天气增强沉浸感
虽然昼夜交替、天气效果和不同季节在我们的生活中是自然现象,但在计算机游戏中这些方面很少被使用。但所有这些都可以极大地增强游戏的沉浸感。根据内部时钟对虚拟世界进行轻微的变化,可能有助于创造一个玩家愿意长时间停留以体验完整周期的环境。
添加白天/黑夜循环
白天/黑夜循环为游戏增添了一种熟悉感。看到太阳升起或欣赏绚丽的日落,观察中午或深夜周围世界的变换……每一个细节都会让玩家感觉自己身处一个更加真实的世界。如果不仅光线发生变化,其他角色和动物也会对一天中的时间做出反应,那么沉浸感会越来越好。例如,可能只在夜间看到某些动物,只在早晨看到孩子们,某些角色可能在某个时间出现在工作场所,下午则去酒吧。
游戏中的时间可能比真实时间快得多,将整整一天缩短为几分钟。这种加速可以帮助如果需要的事件只在一天中的特定时间发生。
白天/夜晚循环中最受欢迎的例子之一是 Minecraft。Minecraft 中的默认一天持续 20 分钟,分为 10 分钟的白天和 10 分钟的夜晚。由于光照水平的变化,敌人在白天和夜晚的生成、食物和树木的生长行为完全不同。
图 14.16 展示了我们的应用程序中午和夜晚的同一地点:

图 14.16:示例代码中第十四章的白天和夜晚
如您在 图 14.16 中所见,虚拟世界的整体亮度的一个简单变化就能产生巨大的差异。根据一天中的时间改变世界的更多属性将为玩家带来更多的沉浸感。
允许向前时间旅行
当处理时间时,一个事实不应被低估:等待事件发生的时间可能会很无聊,而且过得很慢。与其让玩家等待一整天或整夜过去,不如添加特殊的“时间旅行”事件,例如整夜睡觉,或者在壁炉旁等待时间流逝,让游戏快进游戏时间。
实时游戏
在游戏中思考时间,将虚拟世界中的时间与真实世界的时间同步的想法可以是一个有趣的选择。只有少数游戏利用了这样的功能。此外,如果任务或任务与一天中的特定时间绑定,解决它们可能会更加复杂。但在游戏中“现在”可能会很有趣。
崇拜天气之神
不仅白天的时间可以改变世界,天气的变化也可以极大地影响人物、动物和环境。在雷雨天气中,最好的建议是待在室内,大雨可能如此响亮,以至于其他声音永远不会传到玩家的耳朵里,而新鲜的白雪也会吸收噪音。因此,在不同的天气状态下漫游可以给同一个世界带来完全不同的印象。
环境变化,如雾的添加,也会改变虚拟世界的感知。
图 14.17 展示了我们应用程序中不同类型的雾:

图 14.17:示例代码中第十四章中雾的效果
在 图 14.17 的左侧,虚拟世界完全没有雾,显示了一个晴朗的白天。在 图 14.17 的中间,添加了浓雾,导致世界在短距离后变得模糊。在早期游戏引擎的渲染限制下,雾被用于隐藏由于渲染限制而出现的物体和消失的物体。最后,在 图 14.17 的右侧,调整了光线的颜色,创造出地图街道上有毒雾的错觉。
倾听季节的先知
在昼夜循环和天气之后,下一个进化步骤是实现四个季节。春、夏、秋、冬之间的旋转是带来更多现实感的绝佳机会,因为角色的行为或动物的外观将在一年中发生变化。
就像游戏中的白天长度一样,一个虚拟年应该缩短到合理的时间量,或者如果当前季节的所有任务都已经完成,应该允许向前时间旅行功能。让玩家无理由地等待将会扼杀他们的热情。
一个根据季节变化世界属性的好例子是任天堂为 GameBoy Color 开发的《塞尔达传说:时之笛》。尽管游戏年代久远,但季节的变化已经以极大的细节实现,允许玩家仅在特定季节进入世界的某些区域。例如,河流在冬季会结冰,雪地会抬高景观的部分,而只有在夏季,花朵的卷须才能让林克攀爬墙壁。
在 Eric Barone 的《星露谷物语》中,季节也是游戏玩法的关键元素。每个季节都会影响游戏的不同部分,如作物生长。一个季节是 28 个游戏日,通过加速时间,星露谷的一年将在 26 个小时的游戏时间内流逝。
对于应用程序,我们将创建简单的昼夜光线变化和雾作为示例。让我们从昼夜变化开始。
亲身体验:添加昼夜变化
改变光源的颜色和位置很容易。大多数片段着色器已经包含一个固定的光源定义,以便更好地可视化实例和关卡数据。
实现光线控制
作为第一步,我们在所有存在Matrices的顶点和片段着色器中的Matrices``uniform中添加了两个新变量,分别称为lightPos和lightColor:
layout (std140, binding = 0) uniform Matrices {
...
**vec4** **lightPos;**
**vec4** **lightColor;**
};
在lightPos中,我们将传递光源的当前位置,使我们能够模拟虚拟世界中太阳或月亮的位置,而在lightColor中,我们将光源的颜色传输到 GPU,使我们能够模拟日出、正午、日落等。
由于我们在 CPU 上定义了包含视图和投影矩阵的统一缓冲区,并使用glm::mat4作为数据类型,我们必须添加两个glm::vec4向量作为glm::mat4的前两个元素,这些元素将在绘制帧之前上传到 GPU:
glm::mat4 lightMatrix = glm::mat4(lightPos, lightColor,
glm::vec4(), glm::vec4());
matrixData.emplace_back(lightMatrix);
现在所有包含光源定义的片段着色器都可以通过更好的表面光线控制进行调整。
我们在main()方法中开始编写新的着色器代码,通过定义环境光级别来启动:
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * max(vec3(lightColor),
vec3(0.05, 0.05, 0.05));
环境光水平用于模拟来自其他表面的光散射,使虚拟世界中能够实现某种最小光照。通过限制最小环境光,我们还可以防止生成的图片变成漆黑一片。
接下来,我们通过使用三角形法线与光源方向之间的角度来计算漫反射部分:
vec3 norm = normalize(vec3(normal));
vec3 lightDir = normalize(vec3(lightPos));
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * vec3(lightColor);
表面的漫反射光照随着表面与光源之间角度的变化而变化。由于两个归一化向量的点积等于两个向量之间角度的余弦值,我们可以直接将dot()调用的结果用作光源颜色的乘数。
作为片段着色器代码更改的最后一步,我们将环境光和漫反射光照相加,并将结果光照值乘以纹理数据和顶点颜色:
FragColor = vec4(min(ambient + diffuse, vec3(1.0)), 1.0)*
texture(tex, texCoord) * color;
现在,我们可以从应用程序中控制光源的位置和颜色,通过发送球面位置和颜色,可以模拟一天中的不同时间。
添加 UI 控件
为了控制光照值,将创建一个新的 UI 部分,允许对所有与光照相关的值进行精细设置。图 14.18显示了UserInterface类中的新环境部分:

图 14.18:光参数和天空盒的 UI 控件
通过将光照角度分为东西和南北,我们可以将光照移动到虚拟天空的任何位置。光照强度可以用来降低整体亮度,而无需手动调整颜色值。
使用单独的光颜色值,我们可以调整光照以匹配太阳或月亮的自然颜色,至少在可用的颜色空间限制内,通过使用一组预定义的颜色值,我们可以立即将光照颜色、强度和位置设置为一天中的特定时间。
图 14.19显示了虚拟世界的三种不同光照设置(此图像使用 Zarudko 的地图创建skfb.ly/6QYJw,天空盒图像由 Jockum Skoglund(又名 hipshot)提供opengameart.org/content/stormy-days-skybox):

图 14.19:早晨、中午和傍晚的虚拟世界
您可以在图 14.19中看到,只需改变光源的颜色,就可以在虚拟世界中创造出完全不同的氛围。结合增强视觉效果部分中的天空盒,您已经可以创建真正沉浸式的世界。
摘要
在本章中,我们探讨了想法和工具,展示了如何将当前的动画编辑器升级为一个具有内置编辑器的小型游戏引擎。
首先,我们探讨了几个音频库以及何时在游戏中播放音效和音乐。音效和音乐是玩家体验的重要组成部分,它们创造了一种基本的沉浸感,因为现实世界的体验将被转移到虚拟世界中。
然后,我们探讨了应用程序图形的视觉增强。通过扩展基本渲染器以实现透明度、天空盒、动态光影、逼真的水面以及后处理效果,如上帝射线和运动模糊,将视觉提升到下一个水平。尽管由于目标群体有限,添加对光线追踪和 VR 的支持是可选的,但这两个功能在视觉质量和沉浸感方面都是一大步,因此可能成为可行的选项。
作为最后一步,我们探讨了日/夜循环、天气和季节作为改变虚拟世界环境元素。仅仅是一个简单的日循环就为虚拟世界增加了许多有趣的机会,例如在一天中的特定时间只遇到计算机控制的角色或动物,或者只在夜间出现的敌人。天气效果和季节也增加了根据当前环境改变行为的选择。
那么...接下来该做什么呢?嗯,这完全取决于你!
你可以从学习如何使用Blender来创建自己的动画角色、动物、物体和关卡开始。创建的资产不必达到最近游戏的品质;即使是低多边形世界也很迷人,并且拥有许多粉丝。有许多书籍、教程和视频可供选择,涵盖了虚拟世界中的许多不同艺术风格。
关于添加更多物体?车辆和动物将丰富虚拟世界,门和按钮将带来更多与世界互动的方式。为了更好的重力,你甚至可能需要向应用程序中添加物理引擎,使物体之间的碰撞产生力分布等酷炫功能。
你还可以通过使用具有正交投影的相机来探索世界。以等距投影或仅从侧面观察玩家、其他角色和环境可以开辟新的表达故事的方式。由于等距投影和侧面视图只是三维世界的不同视角,因此本章中提到的所有选项在使用正交投影时也适用。
另一个想法可能是为玩家创建故事情节和任务。强迫玩家在各个地点之间移动并与不同角色互动以解锁更多任务、物品或门,或者只是接收关于任务的心理谜题的下一部分,这可以让玩家在你的游戏中停留更长时间。
或者你可以深入研究 SDL 的网络部分,创建一个小的网络服务器,让你能够与朋友一起探索虚拟世界。作为团队在各个关卡中漫游和解决任务既令人满意,也为任务系统带来了新的复杂性,因为你可以选择是否允许玩家在长距离内使用他们对任务的共同知识。
但这是最重要的下一步:保持好奇心并尝试实验代码。
实践课程
这里是一些你可以添加到代码中的改进:
- 添加一个按钮从资源文件夹重新加载音乐音轨。
一个很棒的功能是动态添加或删除音轨。
- 为实例添加更多声音效果。
你可以将声音剪辑映射到实例的每个动作,在控制所选实例时创建更逼真的效果。
- 根据地板材质播放脚步声。
在这里,你需要为行走和跑步加载多对脚步声。可以通过使用地面三角形的网格类型作为索引来选择合适的音效。
- 为附近的实例添加声音。
SDL_mixer库允许我们为每个通道设置音量和平衡。可以使用周围实例的距离和角度来计算声音的有效音量和方向。
- 在地图的特殊位置播放环境声音。
与航点模型类似,你可以使用小型模型作为标记来播放环境声音。并且像附近实例的声音一样,环境声音的音量和平衡可以根据预期进行调整。
- 为音乐变化添加标记。
这个任务与环境声音密切相关。你可以使用模型和/或节点在控制实例进入地图的特定区域或与其他实例交互时切换音乐。
- 根据关卡位置调整关卡颜色和雾气。
不仅在漫游关卡时可以更改音乐;还可以调整光线和雾气设置。例如,进入洞穴时,环境光线可以降低,雾密度可以增加。
其他资源
-
SDL:
www.libsdl.org -
OpenAL Soft:
github.com/kcat/openal-soft -
PortAudio:
www.portaudio.com -
FMOD:
www.fmod.com -
由 Ian Millington 所著,CRC Press 出版的《游戏物理引擎开发》:ISBN 978-0123819765
-
由 Morgan Kaufmann 出版的 David H. Eberly 所著的《游戏物理》:ISBN 978-0123749031
-
Sascha Willems 的 Vulkan 示例:
github.com/SaschaWillems/Vulkan -
无序透明度:
github.com/nvpro-samples/vk_order_independent_transparency -
OpenGL 天空盒:
learnopengl.com/Advanced-OpenGL/Cubemaps -
如何创建立方体贴图:
paulbourke.net/panorama/cubemaps/ -
OpenGL 多光源:
learnopengl.com/Lighting/Multiple-lights -
汉斯·内瓦莱宁的多光源演示:
www.youtube.com/watch?v=vooznqE-XMM -
OpenGL 阴影贴图:
learnopengl.com/Guest-Articles/2021/CSM -
WebGL 水模拟:
madebyevan.com/webgl-water/ -
OpenGL 真实水渲染:
medium.com/@vincehnguyen/simplest-way-to-render-pretty-water-in-opengl-7bce40cbefbe -
OpenGL 水衍射:
medium.com/@martinRenou/real-time-rendering-of-water-caustics-59cda1d74aa -
OpenGL 镜头光晕:
john-chapman.github.io/2017/11/05/pseudo-lens-flare.html -
OpenGL 上帝射线:
github.com/math-araujo/screen-space-godrays -
OpenGL 光晕效果:
learnopengl.com/Advanced-Lighting/Bloom -
OpenGL 运动模糊:
www.nvidia.com/docs/io/8230/gdc2003_openglshadertricks.pdf -
OpenGL 景深:
lettier.github.io/3d-game-shaders-for-beginners/depth-of-field.html -
OpenGL SSAO:
lettier.github.io/3d-game-shaders-for-beginners/ssao.html -
OpenGL SSR:
lettier.github.io/3d-game-shaders-for-beginners/screen-space-reflection.html -
Vulkan 光线追踪:
nvpro-samples.github.io/vk_raytracing_tutorial_KHR/ -
OpenXR:
www.khronos.org/openxr/ -
Godot XR 演示模板:
github.com/GodotVR/godot-xr-template -
Blender:
www.blender.org -
开放游戏艺术资源:
opengameart.org/
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/cppgameanimation



浙公网安备 33010602011771号