现代-Vulkan-秘籍-全-
现代 Vulkan 秘籍(全)
原文:
zh.annas-archive.org/md5/e752b728e407e83234527183d10e668b译者:飞龙
前言
这本书是为经验丰富的计算机图形软件工程师设计的,他们可能对较旧的图形 API,如 OpenGL 和 DirectX 10 非常熟悉,但对现代图形 API 相对较新。就像你一样,我们发现我们需要学习 Vulkan,但因为我们不理解 Vulkan 的基本概念,这是一个复杂且庞大的 API,所以我们很难实现实用的解决方案。本书的目标是向您介绍 API 的基本概念,然后通过精选的、经过验证的算法引导您了解 Vulkan 在每个配方中的新和细微之处。
本书分为五部分,按以下顺序涵盖以下内容:
-
基本 Vulkan 概念,如何以及为什么使用它们,以及它们如何融入使用 Vulkan API 编写应用程序的更大图景中
-
使用 Vulkan 实现的通用图形算法,包括它们的特性以及它们如何被用来完成与图形应用程序相关的各种任务
-
使用 Vulkan 构建 GPU 光线追踪器和混合渲染器
-
如何使用 Vulkan 和 OpenXR 编写适用于令人难以置信的新世界 VR、MR 和 AR 的应用程序
-
调试和性能分析你的 Vulkan 实现的方法
有许多资源涵盖了本书探讨的第一部分,但在这本书中,我们提供了一个两部分的叙事,逐步通过提供简单实用的示例帮助你理解基本 Vulkan 概念。随后的部分展示了可行的实现,可以作为你自己的图形引擎实现的参考。
本书附带存储库的主要目标不是实现最高性能,找到巧妙的方法来实现事物,甚至实现去年 GDC 或 SIGGRAPH 展示的最闪亮的新算法。我们的目标是简单和完整。我们提供了一些 Vulkan 功能如何以及必须使用的技巧和窍门,并且大多数配方都已经过测试和调试,为你提供了一个自包含的工作解决方案,你可以改进并在自己的代码库中使用。每个配方都是一个可执行的程序,可以独立于其他配方进行检查,这使得它们易于阅读。这也使得理解每个算法从开始到结束的所有细节变得容易。
Vulkan 是所有设备和类型应用程序的未来事实上的图形 API。它是一个成熟、功能丰富且得到维护的 API,得到了所有 GPU 供应商、操作系统、框架和语言的支持。如果你打算编写图形应用程序或编写仅使用 Vulkan 计算设施的程序,了解这个 API 是必须的,而且你需要立即学习它!
本书面向的对象
本书是为那些希望了解更多关于 Vulkan 的资深图形软件工程师所写。建议至少了解一个其他图形 API,如 OpenGL,以及其变体之一,如 WebGL、OpenGL ES 或 DirectX(版本 9 或 10),但这不是严格必要的。了解图形原理,如变换、图形管线、着色器、光照、光线追踪的基础以及深度和模板缓冲区等,是很重要的。更高级的话题,如 PBR 及其所有概念,不是必需的,但可能会有所帮助。
最后,代码完全使用 C++编写,因为它是编写图形应用程序最广泛使用的编程语言。尽管书中使用了 C++20 的一些特性,因为它们有助于减少 Vulkan 的冗长,但你不需要对最新的 C++特性有深入了解。
本书涵盖的内容
第一章,Vulkan 核心概念,提供了对最基本的 Vulkan 概念和对象的概述,以及如何创建和使用它们。本章涵盖了如何使用和初始化 Vulkan,以及其对象如何与应用程序交互和管理。在章节末尾,我们提供了一个如何使用这些概念在 Vulkan 中创建最简单图形应用程序的配方。
第二章,使用现代 Vulkan,介绍了更高级的 Vulkan 概念和对象,例如屏障、描述符和管线,这些都可以让你开始使用除了直接在着色器代码中提供的数据之外的其他资源。本章还简要概述了现代渲染技术,如可编程顶点提取(PVP)和多绘制间接(MDI)。
第三章,实现 GPU 驱动渲染,提供了实现所谓的 GPU 驱动渲染的配方:直接从着色器生成数据,而几乎不需要 CPU 输入的技术。生成后的数据保留在 GPU 中,可以用来指导如何渲染场景;或者,这些数据可以直接在屏幕上作为调试信息显示。
第四章,探索光照、着色和阴影的技术,从 Vulkan 的角度探讨了已知的图形技术。配方包括延迟渲染、屏幕空间反射、阴影贴图和屏幕空间环境遮挡的实现,展示了如何在导航 Vulkan 的复杂性同时实现实际效果。
第五章, 解码顺序无关透明度,包含了使用 Vulkan 实现透明度的配方,从简单的算法(如深度剥离和双深度剥离)到更高级且符合物理的实现在内,这些实现仅运行在 GPU 上。虽然其中一些配方可能看起来过时,但它们实际上展示了 Vulkan API 的强大功能。
第六章, 抗锯齿技术,提供了可用于 Vulkan 的全面抗锯齿技术列表。第一个配方使用了 Vulkan 提供的多采样抗锯齿功能,而其他配方则提供了不使用 API 提供的功能来实现抗锯齿的替代方法。
第七章, 光线追踪和混合渲染,是使用 Vulkan 进行光线追踪的介绍,并使用了新的 Vulkan 光线追踪扩展。本章的大部分内容将指导您如何在应用程序中实现路径追踪器,并使用它来渲染光线追踪场景并在屏幕上显示。本章的最后几页展示了如何使用混合方法在现有的光栅化引擎中添加一些光线追踪特性,使其更加生动。
第八章, 使用 OpenXR 的扩展现实,介绍了扩展现实领域,包括虚拟现实、混合现实和增强现实。本章介绍了 OpenXR,包括其主要特性和使用方法。然后本章解释了如何使用 Vulkan 和 OpenXR 功能(如多视图渲染和动态视野)来提高渲染引擎的性能。
第九章, 调试和性能测量技术,通过指导您了解一些调试 Vulkan 应用程序和测量其性能的方法来结束本书。
要充分利用本书
您需要一个配备最新显卡的计算机(例如,为了使用一些较新的扩展,如光线追踪,您需要一个与 NVIDIA 3050 相当的显卡)。仓库中和本书中的代码已使用 Visual Studio 2022 和 Vulkan SDK 1.3.268 编译和测试。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Vulkan SDK | Windows; Quest 设备上的 Android |
| CMake 和 Visual Studio 2022 | Windows |
| Android Studio Hedgehog 2023.1.1 | Windows |
| RenderDoc | Windows |
| Tracy Profiler 0.9.1 | Windows |
我们建议您从本书的 GitHub 仓库克隆代码(下一节中提供了链接)。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
重要提示:
本书包含许多水平较长的截图。这些截图是为了向您展示各种 Vulkan 概念的执行计划概览。因此,在这些图像中,100%缩放时文本可能看起来很小。
下载示例代码文件
您可以从 GitHub(github.com/PacktPublishing/The-Modern-Vulkan-Cookbook)克隆本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“对于主机可见内存,使用vmaMapMemory检索目标指针并使用memcpy复制数据就足够了。”
代码块设置如下:
const VmaAllocationCreateInfo allocCreateInfo = {
.flags = VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT,
.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE,
.priority = 1.0f,
};
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
samplerShadowMap_ = context.createSampler(
VK_FILTER_NEAREST, VK_FILTER_NEAREST,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f,
true, VK_COMPARE_OP_LESS_OR_EQUAL,
"lighting pass shadow");
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“为了保证部分更新能正常工作,我们需要首先将最后一个活动的缓冲区Buffer 0复制到Buffer 1,然后更新视口矩阵。”
小贴士或重要提示
看起来像这样。
章节
在本书中,您将找到一些频繁出现的标题(准备就绪、如何操作...、它是如何工作的...、更多内容...和参见)。
为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:
准备就绪
本节告诉您在食谱中可以期待什么,并描述了如何设置任何软件或任何为食谱所需的初步设置。
如何操作……
本节包含遵循食谱所需的步骤。
它是如何工作的……
本节通常包含对前节发生事件的详细解释。
更多内容……
本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。
参见
本节提供了指向其他有用信息的链接,这些信息对食谱很有帮助。
联系我们
欢迎读者反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 给我们发邮件。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为什么不在此购买网站上发表评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packtpub.com。
分享您的想法
一旦您阅读过《现代 Vulkan 食谱》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买这本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心!现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取优惠:
- 扫描二维码或访问以下链接:

packt.link/free-ebook/9781803239989
-
提交您的购买证明。
-
就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:Vulkan 核心概念
我们本章的目标是实现一个简单的程序,该程序在屏幕上显示一个着色三角形,三角形的顶点和属性直接来自着色器。在实现渲染此三角形的代码的过程中,我们将涵盖 Vulkan 的大多数基本对象,这些对象是您创建一个非常简单的应用程序所需的。尽管这个最小示例所需的代码量很大,但其中大部分可以重用并调整用于其他应用程序。到本章结束时,您将了解如何启动与驱动程序的通信,如何创建和管理基本 Vulkan 对象,以及如何向 GPU 发出渲染命令。
在本章中,在简要介绍 Vulkan API 之后,我们将涵盖以下配方:
-
调用 API 函数
-
了解 Vulkan 对象
-
使用 Volk 加载 Vulkan 函数和扩展
-
正确使用 Vulkan 扩展
-
使用验证层进行错误检查
-
列出可用的实例层
-
列出可用的实例扩展
-
初始化 Vulkan 实例
-
创建一个表面
-
列出 Vulkan 物理设备
-
缓存队列家族属性
-
列出物理设备扩展
-
预留队列家族
-
创建 Vulkan 逻辑设备
-
获取队列对象句柄
-
创建命令池
-
分配、记录和提交命令
-
重复使用命令缓冲区
-
创建渲染通道
-
创建帧缓冲区
-
创建图像视图
-
Vulkan 图形管线
-
将着色器编译为 SPIR-V
-
动态状态
-
创建图形管线
-
Swapchain
-
理解 swapchain 中的同步——栅栏和信号量
-
填充用于呈现的提交信息
-
展示图像
-
绘制三角形
技术要求
要成功运行本章及后续章节中的代码,您的系统必须满足以下要求:
一台配备支持 Vulkan 1.3 的 GPU 的 Windows 计算机。我们建议使用至少拥有 16 GB RAM 和现代显卡的机器。各种章节的代码已在 GTX 1080、GTX 1060、RTX 3050 和 RTX 4060 上进行了测试。请注意,第七章“光线追踪和混合渲染”,需要 RTX 3050/4060 系列显卡,因为它演示了光线追踪的使用。
要开始,请按照以下步骤操作:
-
下载并安装 Vulkan SDK 1.3.268:访问 LunarG 网站
sdk.lunarg.com/sdk/download/1.3.268.0/windows/VulkanSDK-1.3.268.0-Installer.exe,并下载 Vulkan SDK 1.3.268 安装程序。运行安装程序以完成安装过程。 -
安装 Python 3.12:从官方 Python 网站下载 Python 3.12 的最新版本,并按照提供的安装说明进行安装。
-
克隆仓库:确保你的计算机上已安装 Git。如果没有,请从
git-scm.com/downloads下载并安装 Git。安装 Git 后,打开命令提示符或终端,并执行 git clonegithub.com/PacktPublishing/The-Modern-Vulkan-Cookbook以克隆仓库。 -
在 Visual Studio 2022 中打开项目:启动 Visual Studio 2022。导航到 文件 | 打开 | 文件夹 并选择你克隆的仓库所在的文件夹。此操作将项目加载到 Visual Studio 中。
-
构建项目:在 Visual Studio 中,你可以选择为调试或发布构建项目。出于学习和修改代码的目的,建议使用 调试 构建配置。这允许你逐步执行代码并理解其执行流程。如果要简单地运行可执行文件,可以使用 发布 构建配置。
项目结构旨在方便导航和理解每章提供的代码示例。以下是如何定位和使用代码的详细指南:
项目组织成几个关键目录,每个目录都服务于特定的目的:
-
source/chapterX: 此目录包含每章的主要源代码。将 X 替换为你正在工作的章节编号。例如,本章的源代码位于source/chapter1。 -
source/vulkancore: 此目录专门用于存放与 Vulkan 相关的代码和组件。它包括工具、包装器以及其他在整个项目中使用的 Vulkan 相关功能。 -
source/enginecore: 此目录包含多个章节共享的核心引擎组件。这些组件提供基础功能,在项目的各个部分被重复使用。
通过启动 Chapter01_Traingle.exe 可执行文件来运行本章的配方。
了解 Vulkan API
Vulkan API 由 Khronos Group 于 2016 年推出,是一个低开销、跨平台的计算 API,是 OpenGL 及其变体(WebGL 和 OpenGL ES)的后继者。实际上,在正式命名为 Vulkan 之前,Vulkan 被称为 下一代 OpenGL(或 glNext)。OpenGL 自 1992 年以来一直存在,并且一直是每个人学习(并且至今仍在学习)的事实上的入门级图形 API。与它的简单性相结合,OpenGL 即使在今天也非常普遍。
那么,Vulkan 与 OpenGL 有何不同?它始于其复杂性。Vulkan 的目的是为应用程序作者提供对图形硬件的更多控制,以便他们可以实施满足其需求的解决方案。应用程序可以实现尽可能简单或尽可能复杂的解决方案。在实践中,这意味着应用程序现在负责控制硬件,这使得它更加复杂。另一方面,驱动程序变得简单。例如,如果一个应用程序非常关注资源管理,它可以实现自己的资源管理算法,而不依赖于驱动程序的实现。
简而言之,由于 Vulkan 的底层特性,与 OpenGL 相比,它对 GPU 的控制更加精细。它赋予应用程序处理传统上由图形驱动程序管理的任务的能力,例如启动应用程序与硬件之间的通信。然而,这种增加的控制也带来了额外的复杂性。Vulkan 抽象了 GPU 特定实现的大部分内容,使得相同的代码可以在广泛的 GPU 上运行。虽然可以使用特定于设备的扩展来最大化特定 GPU 的计算潜力,但这些不是必需的,而是优化性能的可选选择。在桌面和移动环境中,由于可能性众多,管理这种复杂性以充分利用 GPU 可能具有挑战性。
调用 API 函数
由于 Vulkan 提供了众多旋钮来控制硬件可以做的每一件小事,因此 Vulkan 通常比 OpenGL 更冗长。由于渲染过程的每个方面现在都暴露给应用程序,因此需要与图形驱动程序(请注意,图形驱动程序仍然存在;它只是比以前简单)通信的信息更多。
Vulkan API 使用的最显著模式是结构体作为参数。它用于创建和分配对象、查询其功能和信息、描述布局以及更多。在这个模式中,您不需要将创建对象所需的所有可能值作为函数的参数传递,而是将所有这些信息放入由 Vulkan SDK 提供的结构体中,然后将该结构体作为参数传递给函数。
在这个菜谱中,您将学习如何调用 Vulkan 函数以及如何检查它们的返回值。
准备工作
在 Vulkan 中创建对象需要您填充一个特殊结构体的实例(每个要创建的对象都有一个),并将其传递给创建函数,该函数接收一个指向变量的指针,该变量将在返回时存储对象的句柄。API 中的大多数函数返回一个结果,可以用来检测错误,通常这是一个非常好的主意,以便尽快捕获错误。
如何操作...
这个菜谱将展示如何通过调用vkCreateSampler函数创建一个 Vulkan 采样器(VkSampler),以及如何创建一个宏,可以用来检查 Vulkan 函数调用的返回值,而无需重复相同的代码。
-
以下代码演示了如何创建一个
VkSampler采样器,这是一个 Vulkan 对象,它决定了在着色器中如何采样纹理。 -
在调用创建采样器的
vkCreateSampler函数之前,你需要填充一个名为VkSamplerCreateInfo的结构,其中包含你希望新采样器拥有的所有参数。在示例中,我们正在设置其缩小和放大过滤器类型,纹理坐标在采样纹理之前的处理方式,以及 Vulkan 允许在此类型对象中控制的其它一切:
VkDevice device; const VkSamplerCreateInfo samplerInfo = { .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR, .minFilter = VK_FILTER_LINEAR, .addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT, .addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT, .addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT, .mipLodBias = 0, .anisotropyEnable = VK_FALSE, .minLod = 0, .maxLod = maxLod, }; VkSampler sampler = VK_NULL_HANDLE; const VkResult result = vkCreateSampler(device, &samplerInfo, nullptr, &sampler); assert(result == VK_SUCCESS); VkStructureType declared by the Vulkan SDK. If you use the wrong value for sType, you won’t get a compilation error, and maybe not even a runtime error. Maybe not even an error at all! Well, at least not while running it on your development machine. As soon as someone else tries your code on their device, then it could crash. Luckily, there is a mechanism that helps us detect this kind of mistake at runtime. It’s called the Validation Layer, and we’re going to talk more about it in this chapter.关于列表的最后一件事是,在 Vulkan 中创建对象时,对象的句柄不是从函数返回,而是存储在传递给函数的指针中。在我们的前一个例子中,新采样器的句柄将被存储在变量
sampler(vkCreateSampler函数的最后一个参数)中。这就是为什么我们要将局部变量的地址传递给函数的原因。原因是 API 中的大多数函数返回一个表示操作是否成功的结果。在大多数情况下,将返回值(类型为
VkResult)与VK_SUCCESS进行比较就足够了(并在屏幕上显示消息或终止),但在少数情况下,结果可能并不代表不可恢复的错误,而是一种在我们继续之前需要纠正的情况。 -
这种模式非常常见,以至于我们使用了一个简单的实用宏来检查返回值。如果结果不是
VK_SUCCESS,它会打印一条消息,包括字符串化的错误代码,并断言。查看一下:#define VK_CHECK(func) \ { \ const VkResult result = func; \ if (result != VK_SUCCESS) { \ std::cerr << "Error calling function " << #func \ << " at " << __FILE__ << ":" \ << __LINE__ << ". Result is " \ << string_VkResult(result) \ << std::endl; \ assert(false); \ } \ }
Vulkan 对象几乎总是以相同的方式创建:通过在结构中提供它们的属性,调用一个create函数,并提供一个指针来存储新创建对象的句柄。大多数函数返回一个VkResult值,可以用来检查函数是否成功。
学习关于 Vulkan 对象
Vulkan API 非常广泛,很多时候比 OpenGL(以任何你想测量的方式)都要大。尽管如此,只有少数非常重要的对象对于编写许多类型的应用程序是必要的。如本章开头所述,Vulkan API 是为了应对最苛刻的应用程序而设计的,那些需要控制硬件的每一个细节以提取最大性能的应用程序。但大多数应用程序不需要所有那种灵活性,只需要基础即可。
在这个菜谱中,你将了解 Vulkan 对象是什么以及它们是如何相互关联的。
准备工作
Vulkan 中的对象是不透明的句柄,它们的类型以字母Vk开头。一个 Vulkan 实例称为VkInstance,一个 Vulkan 设备称为VkDevice,依此类推。一些对象需要其他对象的实例来创建或从其分配。这种依赖关系创建了一个隐式的逻辑顺序,即对象创建的顺序。一个代表系统上 GPU 的 Vulkan 物理设备VkPhysicalDevice,只有在已经存在一个 Vulkan 实例VkInstance的情况下才能创建。下一节将展示一个可能有助于理解 Vulkan 功能和何时可以创建对象的图。
如何做到这一点...
图 1.1是我们认为在 Vulkan 中最重要对象的总结;我们在这本书中涵盖的对象,以及将满足大多数图形应用程序的对象。它们也是简单但灵活程序的最基本要求。

图 1.1 – Vulkan 中的对象依赖关系
-
在前面的图中,每个节点都是一个 Vulkan 对象,其名称位于上半部分,其 Vulkan 类型位于下半部分。该图还编码了对象之间的依赖关系,显式和隐式。连接对象的箭头表示对象需要创建的内容(除了它们的参数,这些参数没有在图中表示)。
-
实线箭头是显式依赖关系:一个对象需要引用离开其节点的所有由箭头指向的对象。例如,一个设备需要引用物理设备才能创建;缓冲区视图需要引用缓冲区和设备。虚线箭头表示隐式依赖关系:队列对象需要一个对设备的引用,但它不需要显式地引用物理设备,只需要一个队列索引到一个队列族,这个索引是从物理设备获得的。它不需要物理设备,但它需要由物理设备提供的东西。
-
带有末端开放箭头的实线表示从其他对象分配的对象,通常是这些类型对象的池。命令缓冲区不是创建的;它是从命令池(反过来,在某个时候需要创建)分配的。
-
此图对初学者很有用,因为它有助于可视化那些并不完全明显的多个依赖关系。描述符集就是那些对象之一:要获得一个描述符集,你需要一个描述符集布局的引用。它们不是由应用程序创建的;它们是从描述符池中分配的。最后,描述符集引用缓冲区、图像视图和采样器。它们不是必需的,这就是为什么图中那种类型的关联表示一个可选引用。我们将在第二章**,使用现代 Vulkan中更多地讨论描述符集。
在本章的剩余部分和下一章中,我们将按照通常实现它们的顺序介绍图中所有对象的创建。这意味着从顶部开始,即 Vulkan 实例,然后向下移动,满足图中表示的依赖关系。
使用 Volk 加载 Vulkan 函数和扩展
Volk 是由阿列克谢·卡波卢金(Arseny Kapoulkine)创建的开源库,它为加载 Vulkan 函数提供了简单的跨平台支持。该库提供了一些关键特性,其中最重要的包括自动加载 Vulkan 的函数指针并提供跨平台支持。
在本食谱中,您将学习如何使用 Volk 加载 Vulkan 函数及其扩展。
准备工作
从 github.com/zeux/volk 下载 Volk,并将 volk.c 添加到您的项目中,在包含 volk.h 之前,启用您平台的预处理器定义,例如 VK_USE_PLATFORM_WIN32_KHR、VK_USE_PLATFORM_XLIB_KHR、VK_USE_PLATFORM_MACOS_MVK 等。
如何操作…
Volk 自动加载 Vulkan 的函数指针,因此您不需要手动处理加载它们的细节以及检查可用的扩展。如果您在您的应用程序中使用 Volk,请不要链接到 Vulkan 库的静态版本(例如 Windows 上的 VKstatic.1.lib)或直接加载共享库(例如 Windows 上的 vulkan-1.dll)。Volk 会为您完成这些操作。
-
在使用任何其他 Vulkan 函数之前,在应用程序启动过程中调用
volkInitialize()。 -
在创建 Vulkan 实例之后调用
volkLoadInstance。它用通过vkGetInstanceProcAddr获取的函数替换全局函数指针。 -
在创建 Vulkan 逻辑设备之后调用
volkLoadDevice。它用通过vkGetDeviceProcAddr获取的函数替换全局函数指针。
正确使用 Vulkan 扩展
Vulkan 严重依赖于扩展。扩展是 Vulkan 规范 的一部分,它们除了核心 API 之外还提供,但并不保证在特定版本的 API 中存在。它们可能是实验性的,或者是供应商和卡特定的,并且不能保证在编译时或运行时都存在。官方扩展已在 Khronos Group 注册,并成为规范的一部分,因此您可以在那里找到它们的文档。
扩展可能被引入到 Vulkan 规范 版本,并在较新版本中提升为核心功能集。或者根本不提升!例如,将渲染结果呈现到表面(如 GUI 上的窗口)的功能,即使在 Vulkan 1.3(本书撰写时的最新版本)中仍然是一个扩展。如果您对此好奇,这里有一个链接,即 VK_KHR_surface 设备扩展:registry.khronos.org/vulkan/specs/1.3-extensions/man/html/VK_KHR_surface.html。
图 1.2 提供了该过程的概述:

图 1.2 – Vulkan 扩展
例如,Vulkan 版本 1.1 包含其核心功能——该版本中存在的函数和类型——以及扩展。其中一些、所有或没有任何这些扩展可能被提升到 Vulkan 1.2 的核心功能集中。一些可能被认为已过时并被删除。当规范更新到 1.3 版本时,发生同样的情况:其中一些、所有或没有任何这些扩展可能从 1.2 版本提升到新版本,一些可能被过时。
在这个菜谱中,我们将展示在编译时和运行时处理扩展的正确方法。
准备工作
Vulkan 有两种类型的扩展:实例级和设备级扩展。在使用扩展之前,您需要检查它在编译时是否可用,并且只有当扩展可用时才添加使用该扩展的代码。一般来说,您不需要在运行时检查扩展。您还需要通过提供扩展的名称作为字符串来请求实例或设备启用扩展。
如何做到这一点...
除了在编译时存在扩展之外,您还需要在正确的级别(实例或设备)上启用它,并在运行时使用它之前检查它是否已启用。
-
检查特定扩展在编译时和运行时是否可用的模式如下:
bool isEnabledForDevice(VkDevice device, const std::string &extName) { // std::unordered_map<std::string> deviceExtensions; return deviceExtensions.contains(extName); } VkDevice device; // Valid Vulkan Device #if defined(VK_KHR_win32_surface) // VK_KHR_WIN32_SURFACE_EXTENSION_NAME is defined as the string // "VK_KHR_win32_surface" if (isEnabledForDevice(device, VK_KHR_WIN32_SURFACE_EXTENSION_NAME)) { // VkWin32SurfaceCreateInfoKHR struct is available, as well as the // vkCreateWin32SurfaceKHR() function VkWin32SurfaceCreateInfoKHR surfaceInfo; } #endif -
除了新的功能和类型之外,Vulkan SDK 为每个扩展提供了宏。这些宏可以用来检查它们是否存在,它们的名称和版本。在先前的列表中,定义了一个
VK_KHR_win32_surface宏,并将其设置为1,如果扩展可用。VK_KHR_WIN32_SURFACE_EXTENSION_NAME宏定义了一个const char *作为扩展的名称(在这种情况下,它是VK_KHR_win32_surface),以及一个VK_KHR_WIN32_SURFACE_SPEC_VERSION宏,它被定义为整数,指定了其版本号。 -
在创建
VkWin32SurfaceCreateInfoKHR实例之前,我们检查VK_KHR_win32_surface设备扩展是否存在并已启用。代码由+#if+指令保护,如果扩展存在,我们继续检查它在运行时是否已启用,使用VK_KHR_WIN32_SURFACE_EXTENSION_NAME宏。
如果你在编写跨平台代码,这个检查尤为重要。虽然可能很明显某些扩展应该可用,但它们可能不是你计划支持的 所有平台或显卡上都有。
使用验证层进行错误检查
在高性能、低开销 API 的精神下,Vulkan 默认不执行错误检查。这样做可能会造成性能损失,这可能对某些应用程序来说是无法接受的。另一方面,由于 Vulkan 的复杂性,应用程序很容易出错。
为了帮助应用程序作者检测错误,Vulkan 提供了层,这些层可以在开发期间启用,并在以后禁用以进行发布。这种组合不是强制性的,因为开发者不需要启用错误检测层进行测试,也不需要禁用它们以进行发布,尽管这是最常见的情况。
在这个菜谱中,我们将介绍 Vulkan 层是什么以及它们的消息是如何呈现的,并提供有关如何了解更多关于这些消息含义的技巧。
准备工作
层由 Vulkan SDK 提供,所以如果你在使用 Vulkan,那么你默认也有权访问层。
如何做…
层是 Vulkan 函数的实现,可以在调用链中插入,拦截进入 API 的入口点。这些实现可以执行错误检查、性能测量,甚至检测可能的优化。
Vulkan SDK 提供了一些即插即用(PnP)的层。你需要做的唯一工作就是找到哪些层存在,并启用它们以用于 Vulkan 实例。之后,在运行时,层应该在你开始调用 Vulkan 函数时立即开始执行它们的工作。
-
SDK 中可用的最重要的层是验证层。这个层将验证所有 Vulkan 函数调用及其参数。它还维护一个内部状态——这是 Vulkan 所不具备的——以确保你的应用程序没有缺少同步步骤或使用错误的图像布局。
-
例如,以下消息显示了验证层显示的真实消息。尽管有些晦涩难懂,但这个消息非常有用:它首先显示错误 ID(
VUID-VkSamplerCreateInfo-sType-sType),你可以用它在网上搜索;它还显示了与错误关联的设备;最后,它显示了消息 ID 和文本,告诉我们在这个例子中,我们用来创建采样器(VkSamplerCreateInfo)的结构需要其sType成员等于VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO:VUID-VkSamplerCreateInfo-sType-sType(ERROR / SPEC): msgNum: -129708450 - Validation Error: [ VUID-VkSamplerCreateInfo-sType-sType ] Object 0: handle = 0x1fbd501b6e0, name = Device, type = VK_OBJECT_TYPE_DEVICE; | MessageID = 0xf844ce5e | vkCreateSampler: parameter pCreateInfo->sType must be VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO. The Vulkan spec states: sType must be VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO (https://vulkan.lunarg.com/doc/view/1.3.236.0/windows/1.3-extensions/vkspec.html#VUID-VkSamplerCreateInfo-sType-sType) Objects: 1 [0] 0x1fbd501b6e0, type: 3, name: Device
即使是最有经验的图形程序员也会遇到验证层错误。习惯于它们的外观以及如何弄清楚它们的含义是编写无验证层错误的 Vulkan 应用程序的第一步。
列出可用的实例层
启用实例层就像将其名称作为const char *提供给实例创建函数一样简单。不幸的是,并非所有层在所有实现中都存在,在尝试启用它们之前,我们需要检查可用的层。
在这个菜谱中,您将学习如何枚举可用实例层,以及如何将它们转换为字符串,以便更容易管理。
准备工作
本节中展示的代码片段是Context类的一部分。它封装了大多数初始化和对象创建函数。
如何操作…
检查可用扩展很容易做到:
-
首先,您需要使用
vkEnumerateInstanceLayerProperties函数查询扩展的数量,创建一个足够大的VkLayerProperties数组来存储所有扩展,并通过再次调用同一函数来请求它们的数据,如下所示:uint32_t instanceLayerCount{0}; VK_CHECK(vkEnumerateInstanceLayerProperties( &instanceLayerCount, nullptr)); std::vector<VkLayerProperties> layers( instanceLayerCount); VK_CHECK(vkEnumerateInstanceLayerProperties( &instanceLayerCount, layers.data()));第二次调用
vkEnumerateInstanceLayerProperties将所有可用层存储在layers向量中,然后可以用于查询、诊断等。 -
拥有这些信息后,始终验证您试图启用的层是否可用是个好主意。由于实例创建函数接受以
const char *格式表示的层名,我们需要将扩展名转换为字符串:std::vector<std::string> availableLayers; std::transform( layers.begin(), layers.end(), std::back_inserter(availableLayers), [](const VkLayerProperties& properties) { return properties.layerName; }); -
最后,需要根据可用的层对请求的层进行过滤。使用两个字符串向量,一个用于可用层,一个用于请求层,我们可以使用以下实用函数来执行过滤:
std::unordered_set<std::string> filterExtensions( std::vector<std::string> availableExtensions, std::vector<std::string> requestedExtensions) { std::sort(availableExtensions.begin(), availableExtensions.end()); std::sort(requestedExtensions.begin(), requestedExtensions.end()); std::vector<std::string> result; std::set_intersection( availableExtensions.begin(), availableExtensions.end(), requestedExtensions.begin(), requestedExtensions.end(), std::back_inserter(result)); return std::unordered_set<std::string>( result.begin(), result.end()); }
这个函数非常方便,因为实例层、实例和设备扩展都通过它们的名称作为const char*来引用。这个函数可以应用于过滤 Vulkan 中所需的所有层和扩展。
列举可用实例扩展
对请求的层与可用的层进行过滤的过程应该重复应用于实例扩展。
在这个菜谱中,您将学习如何获取可用实例扩展,如何将它们存储为字符串,以及如何将它们转换为字符指针,以便可以将它们传递给 Vulkan API。
准备工作
这个过程与之前描述的过程非常相似,也包括一个实用函数来执行可用层和请求层的交集。
如何操作…
获取扩展列表与获取可用层一样简单。
-
首先,调用
vkEnumerateInstanceExtensionProperties两次,一次确定有多少扩展可用,然后再次调用以获取扩展:uint32_t extensionsCount{0}; vkEnumerateInstanceExtensionProperties( nullptr, &extensionsCount, nullptr); std::vector<VkExtensionProperties> extensionProperties(extensionsCount); vkEnumerateInstanceExtensionProperties( nullptr, &extensionsCount, extensionProperties.data()); std::vector<std::string> availableExtensions; std::transform( extensionProperties.begin(), extensionProperties.end(), std::back_inserter(availableExtensions), [](const VkExtensionProperties& properties) { return properties.extensionName; }); -
最后,我们可以使用之前步骤中的可用层和扩展列表来过滤请求的层和扩展。注意,我们正在请求验证层,并使用条件预处理器块保护所有扩展:
const std::vector<std::string> requestedInstanceLayers = { "VK_LAYER_KHRONOS_validation"}; const std::vector<std::string> requestedInstanceExtensions = { #if defined(VK_KHR_win32_surface) VK_KHR_WIN32_SURFACE_EXTENSION_NAME, #endif #if defined(VK_EXT_debug_utils), VK_EXT_DEBUG_UTILS_EXTENSION_NAME, #endif #if defined(VK_KHR_surface) VK_KHR_SURFACE_EXTENSION_NAME, #endif }; const auto enabledInstanceLayers = filterExtensions(availableLayers, requestedInstanceLayers); const auto enabledInstanceExtensions = filterExtensions(availableExtensions, requestedInstanceExtensions); -
要将字符串向量传递给 API,我们需要将它们转换为
const char*向量,因为 API 只接受const char*参数。我们还需要对实例层的向量执行相同的转换(这里为了简洁省略):std::vector<const char*> instanceExtensions( enabledInstanceExtensions.size()); std::transform(enabledInstanceExtensions.begin(), enabledInstanceExtensions.end(), instanceExtensions.begin(), std::mem_fn(&std::string::c_str));
重要提示
instanceExtensions 向量不能比 enabledInstanceExtensions 向量存在时间更长。因为 instanceExtensions 包含指向 enabledInstanceExtensions 中字符串的指针,一旦后者被销毁,instanceExtensions 中的所有指针都将悬空。
初始化 Vulkan 实例
要开始使用 Vulkan,我们需要创建一个 Vulkan 实例。可以将 Vulkan 实例视为初始化 Vulkan 库的一种方式。要创建一个实例,你需要提供一组所需和可选信息,例如应用程序名称、引擎名称、版本以及所需层和扩展的列表。
在这个菜谱中,你将学习如何创建 Vulkan 实例。
准备工作
创建用于创建实例的 VkApplicationInfo 结构体需要应用程序版本和 Vulkan API 版本。前者可以使用 VK_MAKE_VERSION 宏创建,而后者可以作为 SDK 中可用的预处理器定义之一提供。
如何操作...
拥有所有这些,我们只需要创建一个 Vulkan 实例:
-
首先创建
VkApplicationInfo结构体的实例:const VkApplicationInfo applicationInfo_ = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "Essential Graphics With Vulkan", .applicationVersion = VK_MAKE_VERSION(1, 0, 0), .apiVersion = VK_API_VERSION_1_3, }; -
你还需要一个包含所需实例层和扩展的
VkInstanceCreateInfo结构体实例。然后,调用vkCreateInstance:const VkInstanceCreateInfo instanceInfo = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &applicationInfo_, .enabledLayerCount = static_cast<uint32_t>( requestedLayers.size()), .ppEnabledLayerNames = requestedLayers.data(), .enabledExtensionCount = static_cast<uint32_t>( instanceExtensions.size()), .ppEnabledExtensionNames = instanceExtensions.data(), }; VkInstance instance_{VK_NULL_HANDLE}; VK_CHECK(vkCreateInstance(&instanceInfo, nullptr, &instance_));
一旦创建了 Vulkan 实例,你应该安全地存储它,因为在你应用程序退出之前需要销毁它。
创建表面
正如 OpenGL 一样,将最终渲染输出呈现到屏幕上需要窗口系统的支持,并且依赖于平台。因此,Vulkan 核心 API 不包含将最终图像渲染到屏幕上的函数。这些函数和类型是扩展。对于这个菜谱,我们将使用 VK_KHR_surface 和 VK_KHR_swapchain 扩展。在这里,我们只涵盖 Windows 的情况,并使用 VK_KHR_win32_surface 扩展。
在这个菜谱中,你将学习如何创建一个用于呈现渲染最终输出的表面。
准备工作
将图像渲染到屏幕上的过程的第一步是创建一个 VkSurfaceKHR 对象。由于在从物理设备预留队列时需要此对象,因此此步骤是在创建实例之后、枚举物理设备之前以及创建设备之前完成的,因为设备需要有关我们将使用哪些队列家族的信息。
如何操作...
创建 VkSurfaceKHR 对象很简单,但需要窗口系统的支持。
-
在 Windows 上,你需要可执行文件的实例句柄(
HINSTANCE)和用于显示图像的窗口句柄(HWND)。我们使用 GLFW,因此可以通过glfwGetWin32Window(GLFWwindow*)获取VkWin32SurfaceCreateInfoKHR结构体所使用的窗口。VkSurfaceKHR对象的句柄存储在Context::surface_中:const auto window = glfwGetWin32Window(glfwWindow); #if defined(VK_USE_PLATFORM_WIN32_KHR) && \ defined(VK_KHR_win32_surface) if (enabledInstanceExtensions_.contains( VK_KHR_WIN32_SURFACE_EXTENSION_NAME)) { if (window != nullptr) { const VkWin32SurfaceCreateInfoKHR ci = { .sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR, .hinstance = GetModuleHandle(NULL), .hwnd = (HWND)window, }; VK_CHECK(vkCreateWin32SurfaceKHR( instance_, &ci, nullptr, &surface_)); } } #endif
表面创建在不同平台之间略有不同,但过程非常相似。
枚举 Vulkan 物理设备
在我们能够创建 Vulkan 设备之前,我们需要选择一个合适的物理设备,因为一个系统可能拥有多个支持 Vulkan 的 GPU,而我们希望选择一个符合我们应用程序所需功能的 GPU。为此,我们需要枚举系统上所有可用的物理设备。这可以通过调用 vkEnumeratePhysicalDevices 函数来实现,该函数返回系统上支持 Vulkan API 的所有物理设备的列表。一旦我们有了物理设备的列表,我们可以使用 vkGetPhysicalDeviceProperties 和 vkGetPhysicalDeviceFeatures 函数来检查它们的属性和功能,以确定它们是否具有所需的功能。最后,我们可以选择最合适的物理设备,并通过 vkCreateDevice 函数使用它来创建逻辑设备。
在本食谱中,你将学习如何枚举系统中所有具有 Vulkan 功能的设备,以便你可以选择最适合你需求的设备。
准备工作
在我们的代码中,我们使用一个名为 VulkanCore::PhysicalDevice 的类封装物理设备,该类检索物理设备的属性并将它们存储以供以后使用。
此外,如果你想在拥有多个支持 Vulkan 的设备上使用更好的启发式方法来选择一个物理设备,请确保查看 Context::choosePhysicalDevice() 方法。
如何做到这一点...
枚举物理设备使用的是 API 中通用的模式,这要求我们首先请求可用项的数量,然后获取并将它们存储到一个向量中:
-
vkEnumeratePhysicalDevices被调用两次,第一次是为了查询有多少对象可用,第二次是为了获取VkPhysicalDevice对象的句柄:std::vector<PhysicalDevice> Context::enumeratePhysicalDevices( const std::vector<std::string>& requestedExtensions) const { uint32_t deviceCount{0}; VK_CHECK(vkEnumeratePhysicalDevices( instance_, &deviceCount, nullptr)); ASSERT(deviceCount > 0, "No Vulkan devices found"); std::vector<VkPhysicalDevice> devices( deviceCount); VK_CHECK(vkEnumeratePhysicalDevices( instance_, &deviceCount, devices.data())); std::vector<PhysicalDevice> physicalDevices; for (const auto device : devices) { physicalDevices.emplace_back(PhysicalDevice( device, surface_, requestedExtensions, printEnumerations_)); } return physicalDevices; }
此方法返回一个 PhysicalDevice 对象的向量。在代码中,此列表被传递给 Context::choosePhysicalDevice() 辅助方法,该方法可以根据请求的扩展和其他可能需要的 GPU 功能来选择一个合适的物理设备。为了简化,我们始终从列表中选择第一个物理设备。
缓存队列家族的属性
在 Vulkan 中,一个物理设备可以有一个或多个队列家族,其中每个队列家族代表一组具有某些属性(如功能或用途)的命令队列。图 1**.3 描述了一个虚构的家族集合及其队列:

图 1.3 – 队列家族及其队列
每个队列家族支持一组特定的操作和命令,这些操作和命令可以并行执行。例如,可能有一个图形队列家族、一个计算队列家族和一个传输队列家族,每个家族针对不同类型的操作进行了优化。
在这个菜谱中,你将学习如何检索队列家族的属性以及它们在代码存储库中的存储位置。
准备工作
在本书提供的存储库中,队列家族及其属性由VulkanCore::PhysicalDevice类存储和管理。
如何操作…
每个队列家族都有自己的属性集,例如队列数量、它可以执行的操作类型以及队列的优先级。当创建逻辑设备时,我们必须指定我们想要使用的队列家族以及每种类型的队列数量。
-
要查询可用的队列家族及其属性,请使用
vkGetPhysicalDeviceQueueFamilyProperties函数:uint32_t queueFamilyCount{0}; vkGetPhysicalDeviceQueueFamilyProperties( physicalDevice_, &queueFamilyCount, nullptr); queueFamilyProperties_.resize(queueFamilyCount); vkGetPhysicalDeviceQueueFamilyProperties( physicalDevice_, &queueFamilyCount, queueFamilyProperties_.data());
家族的属性存储在std::vector<VkQueueFamilyProperties> PhysicalDevice::queueFamilyProperties_中。
枚举物理设备扩展
物理设备扩展必须由应用程序显式启用,并且可能仅在特定的物理设备或设备驱动程序上可用。检查所需扩展的可用性以及优雅地处理不支持扩展的情况非常重要。
在这个菜谱中,你将学习如何枚举所有物理设备扩展以及如何将它们转换为字符串以便以后使用。
准备工作
物理设备扩展的枚举由VulkanCore::PhysicalDevice类管理。
如何操作…
获取物理设备的所有物理设备扩展很简单。在这里,我们还提供了将它们作为字符串存储的代码,以便更容易处理。
-
枚举所有物理设备扩展是通过使用
vkEnumerateDeviceExtensionProperties函数完成的。结果是VkExtensionProperties结构体的数组。此结构体包含有关扩展名称、版本以及扩展用途的简要描述:uint32_t propertyCount{0}; VK_CHECK(vkEnumerateDeviceExtensionProperties( physicalDevice_, nullptr, &propertyCount, nullptr)); std::vector<VkExtensionProperties> properties( propertyCount); VK_CHECK(vkEnumerateDeviceExtensionProperties( physicalDevice_, nullptr, &propertyCount, properties.data())); -
将扩展的名称转换为
std::string:std::transform( properties.begin(), properties.end(), std::back_inserter(extensions_), [](const VkExtensionProperties& property) { return std::string(property.extensionName); }); -
此数组经过处理,最终我们只得到扩展名称的字符串。进一步处理使用我们的
filterExtensions实用函数将请求的扩展与可用的扩展进行筛选,并将它们存储在std::unordered_set<std::string>PhysicalDevice::enabledExtensions_中:enabledExtensions_ = util::filterExtensions( extensions_, requestedExtensions);
总结来说,掌握物理设备扩展的枚举是 Vulkan 的一个重要方面。它确保了设备能力的最佳利用。
预留队列家族
在 Vulkan 中,队列家族是一组一个或多个共享共同属性(例如它们可以执行的操作类型)的队列。当创建 Vulkan 设备时,我们必须指定我们想要使用的队列家族以及每个家族需要多少个队列。
对于渲染和展示,我们通常需要一个至少包含一个图形队列家族,该家族负责执行图形命令。此外,我们可能需要一个计算队列家族来执行计算工作负载,以及一个传输队列家族来处理数据传输。
在这个菜谱中,你将学习如何根据队列的特性找到队列家族,以及如何选择一个支持展示的队列家族,这可以用于在屏幕上展示最终的渲染输出。
准备工作
在仓库中,预留队列被 VulkanCore::PhysicalDevice 类封装。
如何操作…
在创建 Vulkan 设备之前,一个必要的额外步骤是收集我们想要使用的队列家族的索引。为此,我们在 PhysicalDevice 类中创建了一个 reserveQueues() 方法来处理这个过程,它接受我们想要预留的队列类型作为参数。它还接受一个指向 Vulkan 表面(VkSurfaceKHR)的句柄,我们将在以后使用它来验证队列是否支持展示,这是在屏幕上显示最终渲染所必需的。
-
我们遍历存储在
queueFamilyProperties_中的队列家族属性,如果其类型已被请求,则存储队列家族的索引:uint32_t graphicsFamilyIndex{UINT32_MAX}; uint32_t presentationFamilyIndex{UINT32_MAX}; for (uint32_t queueFamilyIndex = 0; queueFamilyIndex < queueFamilyProperties_.size() && requestedQueueTypes != 0; ++queueFamilyIndex) { if (graphicsFamilyIndex == UINT32_MAX && (queueFamilyProperties_[queueFamilyIndex] .queueFlags & VK_QUEUE_GRAPHICS_BIT)) { graphicsFamilyIndex = queueFamilyIndex; } -
要检测一个队列家族是否支持展示,我们使用
vkGetPhysicalDeviceSurfaceSupportKHR函数,该函数由预处理宏保护:#if defined(VK_KHR_surface) if (enabledInstanceExtensions_.contains( VK_KHR_SURFACE_EXTENSION_NAME)) { if (presentationFamilyIndex == UINT32_MAX && surface != VK_NULL_HANDLE) { VkBool32 supportsPresent{VK_FALSE}; vkGetPhysicalDeviceSurfaceSupportKHR( physicalDevice_, queueFamilyIndex, surface, &supportsPresent); if (supportsPresent == VK_TRUE) { presentationFamilyIndex = queueFamilyIndex; } } } #endif }
其他类型队列家族的索引可以通过类似的方式获得。
创建 Vulkan 逻辑设备
Vulkan 设备是物理 GPU 的逻辑表示。它是一个与所选物理设备(系统中的现有 GPU)相关联的对象,用于执行所有图形和计算操作。设备还通过队列提供对物理 GPU 功能的访问。队列用于向 GPU 提交命令,例如绘制调用或内存传输。设备还提供对其他 Vulkan 对象的访问,例如管线、缓冲区和图像。
在这个菜谱中,你将学习如何创建一个 Vulkan 逻辑设备。
准备工作
这个菜谱中的代码作为 VulkanCore::Context 类的一部分在仓库中可用。Context 类代表一个 Vulkan 逻辑设备。
如何操作…
要创建一个 Vulkan 设备,我们需要提供一个物理设备和我们想要使用的队列家族的索引。使用这些信息,我们可以创建一个 VkDeviceQueueCreateInfo 结构体的向量,这决定了我们想要从每个家族中使用的队列数量及其相应的优先级。
-
创建设备的最常见的用例是每个家族使用一个队列,并将其优先级设置为
1:auto physicalDevice_ = enumeratePhysicalDevices( requestedExtensions)[0]; // Retrieves a vector of (queue family indices and // their number) const vector<uint32_t> familyIndices = physicalDevice_.reservedFamilies(); std::vector<VkDeviceQueueCreateInfo> queueCreateInfos; float priority{1.0f}; for (const auto& queueFamilyIndex : familyIndices) { queueCreateInfos.emplace_back( VkDeviceQueueCreateInfo{ .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, .queueFamilyIndex = queueFamilyIndex, .queueCount = 1, .pQueuePriorities = &priority, }); ++index; } -
请求的设备扩展列表被转换为
const char*,与可用的扩展进行过滤,并添加到VkDeviceCreateInfo结构中,包括我们想要使用的队列家族的索引和想要启用的层:std::vector<const char*> deviceExtensions( physicalDevice_.enabledExtensions().size()); std::transform( physicalDevice_.enabledExtensions().begin(), physicalDevice_.enabledExtensions().end(), deviceExtensions.begin(), std::mem_fn(&std::string::c_str)); const VkDeviceCreateInfo dci = { .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, .queueCreateInfoCount = static_cast<uint32_t>( queueCreateInfos.size()), .pQueueCreateInfos = queueCreateInfos.data(), .enabledLayerCount = static_cast<uint32_t>( requestedLayers.size()), .ppEnabledLayerNames = requestedLayers.data(), .enabledExtensionCount = static_cast<uint32_t>( deviceExtensions.size()), .ppEnabledExtensionNames = deviceExtensions.data(), }; VK_CHECK(vkCreateDevice( physicalDevice_.vkPhysicalDevice(), &dci, nullptr, &device_));
Vulkan 设备是你需要的最重要的对象之一,因为它几乎需要创建所有其他 Vulkan 对象。
获取队列对象句柄
一旦创建了逻辑设备,我们需要获取队列的句柄。这是通过vkGetDeviceQueue函数实现的。这个句柄将用于将命令缓冲区提交到 GPU 进行处理。
在本配方中,你将学习如何获取 Vulkan 队列的句柄。
准备工作
在存储库中,所有队列都是通过VulkanCore::Context类检索和存储的。该类为每种类型的队列维护一个列表:图形、计算、传输和稀疏,以及一个用于演示的特殊队列。
如何做…
要获取队列的句柄,只需使用队列家族索引和队列索引调用vkGetDeviceQueue函数:
VkQueue queue{VK_NULL_HANDLE};
uint32_t queueFamilyIndex; // valid queue family
vkGetDeviceQueue(device, queueFamilyIndex, 0, &queue);
仅知道哪些队列家族可用是不够的。一旦我们确定了哪些队列可用以及我们需要的队列,我们将使用本配方中介绍的 API 从家族中请求一个队列的句柄。
创建命令池
命令缓冲区提供了记录图形和计算命令的能力,而命令队列允许将这些缓冲区提交到硬件。记录在命令缓冲区中的命令随后将由 GPU 执行。
每个队列都与一个特定的队列家族相关联,这定义了队列的能力。例如,一个队列家族可能只支持图形操作,或者它可能同时支持图形和计算操作。可以使用vkGetPhysicalDeviceQueueFamilyProperties函数检索家族的数量及其能力,该函数在缓存队列家族属性配方中进行了讨论。一个队列家族可能包含一个或多个队列。
命令缓冲区是 GPU 实际执行的命令的容器。要记录命令,你需要分配一个命令缓冲区,然后使用vkCmd*函数系列将命令记录到其中。一旦命令被记录,命令缓冲区就可以提交到命令队列以执行。
命令缓冲区是从命令池中分配的,该命令池又是由设备和特定的队列家族创建的。
在本配方中,你将学习如何创建命令池。
准备工作
命令池以及分配和提交命令缓冲区的管理由VulkanCore::CommandQueueManager类负责。
如何做…
创建命令池非常简单。你只需要队列家族索引和一个创建标志。对于我们的目的,VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志就足够了。
要创建命令池,请使用vkCreateCommandPool函数。VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志意味着从这个池中分配的每个命令缓冲区都可以单独或隐式地通过调用vkCmdBeginCommandBuffer来重置:
uint32_t queueFamilyIndex; // Valid queue family index
const VkCommandPoolCreateInfo commandPoolInfo = {
.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
.flags =
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
.queueFamilyIndex = queueFamilyIndex,
};
VkCommandPool commandPool{VK_NULL_HANDLE};
VK_CHECK(
vkCreateCommandPool(device, &commandPoolInfo,
nullptr, &commandPool));
使用命令池对象,你可以开始为记录命令分配命令缓冲区。
分配、记录和提交命令
命令缓冲区是通过使用 vkAllocateCommandBuffers 函数从命令池中分配的。在将命令记录到缓冲区并准备提交给 vkEndCommandBuffer 之前,必须使用 vkBeginCommandBuffer 函数初始化命令缓冲区。命令在那些函数调用之间记录,并且只有在命令缓冲区通过 vkQueueSubmit 提交到设备后才会执行。
在这个菜谱中,你将学习如何分配命令缓冲区,如何在命令缓冲区中记录命令,以及如何提交它们以在 GPU 上执行。
准备工作
命令缓冲区是从 VulkanCore::CommandQueueManager 类中分配的,并使用相同的类提交。VulkanCore::CommandQueueManager 提供了基本功能来维护一组用于处理的命令缓冲区。
如何操作…
命令缓冲区的生命周期始于从命令池中分配它。一旦开始,就可以将命令记录到其中。在提交之前,需要明确地通知它们记录已结束。然后可以提交它们以执行:
-
要分配命令缓冲区,你调用
vkAllocateCommandBuffers,传入命令池、你想要分配的缓冲区数量以及指向一个指定命令缓冲区属性的结构的指针:const VkCommandBufferAllocateInfo commandBufferInfo = { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = commandPool_, .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1, }; VkCommandBuffer cmdBuffer{VK_NULL_HANDLE}; VK_CHECK(vkAllocateCommandBuffers( device, &commandBufferInfo, &cmdBuffer)); -
在成功分配命令缓冲区后,可以开始记录 Vulkan 命令。记录过程通过调用
vkBeginCommandBuffer函数启动,该函数的参数包括命令缓冲区和指向一个定义记录属性的结构的指针。一旦记录完成,就调用vkEndCommandBuffer函数来最终化这个过程:const VkCommandBufferBeginInfo info = { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, }; VK_CHECK(vkBeginCommandBuffer(cmdBuffer, &info));这里有一些可以在 Vulkan 命令缓冲区中记录的常用命令的示例:
-
vkCmdBindPipeline:将管道绑定到命令缓冲区。此命令设置后续绘制调用的当前管道状态。 -
vkCmdBindDescriptorSets:将描述符集绑定到命令缓冲区。描述符集持有可以由着色器使用的缓冲区和图像资源的引用。 -
vkCmdBindVertexBuffers:将顶点缓冲区绑定到命令缓冲区。顶点缓冲区包含网格的顶点数据。 -
vkCmdDraw:执行绘制调用,处理顶点并将生成的像素进行光栅化。 -
vkCmdDispatch:执行计算着色器。 -
vkCmdCopyBuffer:将数据从一个缓冲区复制到另一个缓冲区。 -
vkCmdCopyImage:将数据从一个图像复制到另一个图像。
-
-
一旦完成命令的记录,你必须调用
vkEndCommandBuffer:VK_CHECK(vkEndCommandBuffer(cmdBuffer)); -
一旦命令缓冲区被记录,它仍然存在于你的应用程序中,需要提交给 GPU 进行处理。这是通过
vkQueueSubmit函数完成的:VkDevice device; // Valid Vulkan Device VkQueue queue; // Valid Vulkan Queue VkFence fence{VK_NULL_HANDLE}; const VkFenceCreateInfo fenceInfo = { .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, .flags = VK_FENCE_CREATE_SIGNALED_BIT, }; VK_CHECK(vkCreateFence(device, &fenceInfo, nullptr, &fence)); const VkSubmitInfo submitInfo = { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = cmdBuffer, }; VK_CHECK( vkQueueSubmit(queue, 1, submitInfo, fence));
在前面的代码中,栅栏是一个特定的 Vulkan 对象,它促进了 GPU 和 CPU 之间的同步。vkQueueSubmit 函数是一个异步操作,不会阻塞应用程序。因此,一旦提交了一个命令缓冲区,我们只能通过检查栅栏的状态(例如使用 vkGetFenceStatus 或 vkWaitForFences 函数)来确定它是否已被处理。请参阅 理解 swapchain 中的同步 – 栅栏和信号量 食谱,了解如何使用栅栏来同步您的应用程序和提交给 GPU 的命令的执行。
重复使用命令缓冲区
命令缓冲区可以记录一次并多次提交。它们也可以使用一次,在下次使用前重置,或者只是记录、提交然后丢弃。
在本食谱中,你将学习如何在不创建应用程序和 GPU 之间的竞态条件的情况下重复使用命令缓冲区。
准备工作
在 VulkanCore::CommandQueueManager 中提供的代码不同步命令缓冲区,但提供了帮助您同步的函数,例如 goToNextCmdBuffer、waitUntilSubmitIsComplete 和 waitUntilAllSubmitsAreComplete。
如何操作...
使用命令缓冲区可以通过两种方式实现:
-
创建一个命令缓冲区并无限期地重复使用它。在这种情况下,一旦命令缓冲区被提交,就必须等待它被处理,然后才能开始记录新的命令。确保缓冲区已完成的 一种方式是检查与其关联的栅栏状态。如果栅栏要被重复使用,还需要重置其状态:
VkDevice device; // Valid Vulkan Device VK_CHECK(vkWaitForFences(device, 1, &fences, true, UINT32_MAX)); VK_CHECK(vkResetFences(device, 1, &fences));图 1**.4 展示了在 GPU 上提交处理后的命令缓冲区被立即重复使用的场景。如果没有任何形式的同步,重复使用命令缓冲区将导致竞态条件,因为它可能仍在 GPU 中处理:

图 1.4 – 不使用栅栏记录和提交命令缓冲区
通过使用栅栏,如图 图 1**.5 所示,在重复使用命令缓冲区之前检查与命令缓冲区关联的栅栏状态,可以防止竞态条件。如果栅栏已被触发,则不需要等待,但如果在重复使用命令缓冲区之前栅栏尚未被触发,则应用程序必须在继续之前等待它被触发:

图 1.5 – 使用栅栏记录和提交命令缓冲区
- 按需分配命令缓冲区。这是一种最简单的方法。每次你需要记录和提交命令时,只需从池中分配一个新的命令缓冲区,记录命令,提交它,然后忘记它。在这种情况下,你需要在创建命令池时传递
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT标志。如果你需要跟踪该缓冲区中命令使用的资源的状态,你可能仍然需要一个与该缓冲区关联的栅栏。
限制你的应用程序使用的命令缓冲区数量是一种良好的实践,这有助于减少程序所需的内存量。
创建渲染通道
渲染通道对象代表一系列读取和写入图像的渲染操作。它是一个高级抽象,有助于 GPU 优化渲染过程。在 Vulkan 中,附加物是用于渲染通道期间作为目标的图像的引用。附加物可以是颜色附加物(用于存储颜色信息)或深度或模板附加物(用于存储深度/模板信息)。图 1.6展示了渲染通道对象包含的概述:

图 1.6 – 渲染通道和帧缓冲区组成
在 Vulkan 中创建渲染通道时,VkAttachmentDescription结构用于定义每个附加物的属性。initialLayout和finalLayout字段在优化渲染通道执行期间附加物使用和布局转换方面起着至关重要的作用。通过正确设置初始和最终布局,你可以避免使用额外的管道屏障来转换图像布局,因为这些转换将由渲染通道执行自动管理。例如,如果你有一个初始布局为VK_IMAGE_LAYOUT_UNDEFINED的颜色附加物,并且应该在渲染通道结束时转换为VK_IMAGE_LAYOUT_PRESENT_SRC_KHR布局,你可以相应地设置initialLayout和finalLayout字段。这消除了显式管道屏障处理转换的需要,因为渲染通道将自动在其执行过程中执行布局转换。
子通道是渲染通道的一部分,它执行特定的渲染操作。每个子通道都会加载附件,读取和/或写入,并在子通道结束时存储。加载和存储操作定义了在加载时附件的内容是否应该被加载、清除或无需关注(这意味着驱动器/硬件可以自由选择要做什么——或不要做什么),以及当存储在通道结束时是否应该存储或无需关注。它们对性能有重大影响,尤其是在移动 GPU 上。对于移动 GPU,最小化加载/存储操作的数量可以导致显著的性能提升。在可能的情况下,使用VK_ATTACHMENT_LOAD_OP_DONT_CARE和VK_ATTACHMENT_STORE_OP_DONT_CARE,我们可以避免不必要的内存带宽使用,这在移动设备上是一个常见的瓶颈。
子通道依赖关系描述了子通道应该执行的顺序以及它们之间所需的同步。在移动 GPU 上,使用多个子通道可以通过保持中间数据在片上内存(基于瓦片的渲染)中,从而帮助减少内存带宽使用。这避免了从主内存中写入和读取数据的需求,这在功耗和性能方面可能是昂贵的。
Vulkan 还支持渲染通道兼容性,允许为某个渲染通道创建的帧缓冲区与另一个兼容的渲染通道一起使用,从而提高资源利用率和性能。兼容性要求匹配附件数量、格式、加载/存储操作、样本数量和兼容布局;然而,子通道结构可以不同。
在本食谱中,你将学习如何创建渲染通道。
准备工作
创建渲染通道并不复杂,但需要一系列信息,如果封装在其自己的类中,则更容易管理。这样,类的析构函数可以在适当的时候处理对象的销毁,而无需我们添加代码来处理其销毁。
在本书提供的代码中,渲染通道由VulkanCore::RenderPass类封装。
如何操作…
创建渲染通道需要一个列表,其中包含在该通道中将使用的所有附件,以及它们的加载和存储操作以及每个附件期望的最终布局。渲染通道必须与某种类型的管道(图形、计算等)相关联,因此构造函数还接受一个类型为VkPipelineBindPoint的值。
以下代码示例显示了VulkanCore::RenderPass类的一个构造函数。请注意,我们尚未介绍 Vulkan 图像(在代码中封装在Texture类中)。我们将在第二章**,使用现代 Vulkan,的创建图像(**纹理)食谱中更详细地讨论图像。
-
构造函数会遍历所有将在渲染通道中使用的附件,并为每个附件创建一个
VkAttachmentDescription结构体。这个结构体包含了从附件本身提取的基本信息(例如格式和初始布局),同时也记录了在加载和存储每个附件时应该做什么。在遍历渲染通道中使用的所有附件时,我们创建了另外两个辅助变量:一个包含颜色附件索引的列表(colorAttachmentReferences)和一个存储深度/模板附件索引的变量(depthStencilAttachmentReference),因为渲染通道只支持一个深度/模板附件:RenderPass::RenderPass( const Context& context, const std::vector<std::shared_ptr<Texture>> attachments, const std::vector<VkAttachmentLoadOp>& loadOp, const std::vector<VkAttachmentStoreOp>& storeOp, const std::vector<VkImageLayout>& layout, VkPipelineBindPoint bindPoint, const std::string& name) : device_{context.device()} { ASSERT(attachments.size() == loadOp.size() && attachments.size() == storeOp.size() && attachments.size() == layout.size(), "The sizes of the attachments and their load " "and store operations and final layouts " "must match"); std::vector<VkAttachmentDescription> attachmentDescriptors; std::vector<VkAttachmentReference> colorAttachmentReferences; std::optional<VkAttachmentReference> depthStencilAttachmentReference; -
对于每个附件,创建一个
VkAttachmentDescription结构体并将其追加到attachmentDescriptors向量中:for (uint32_t index = 0; index < attachments.size(); ++index) { attachmentDescriptors.emplace_back( VkAttachmentDescription{ .format = attachments[index]->vkFormat(), .samples = VK_SAMPLE_COUNT_1_BIT, .loadOp = attachments[index]->isStencil() ? VK_ATTACHMENT_LOAD_OP_DONT_CARE : loadOp[index], .storeOp = attachments[index]->isStencil() ? VK_ATTACHMENT_STORE_OP_DONT_CARE : storeOp[index], .stencilLoadOp = attachments[index]->isStencil() ? loadOp[index] : VK_ATTACHMENT_LOAD_OP_DONT_CARE, .stencilStoreOp = attachments[index]->isStencil() ? storeOp[index] : VK_ATTACHMENT_STORE_OP_DONT_CARE, .initialLayout = attachments[index]->vkLayout(), .finalLayout = layout[index], }); -
如果附件是深度或模板纹理,为它创建一个
VkAttachmentReference结构体并将其存储在depthStencilAttachmentReference辅助变量中。否则,附件是一个颜色附件,我们创建并存储一个VkAttachmentReference结构体到colorAttachmentReferences向量中:if (attachments[index]->isStencil() || attachments[index]->isDepth()) { depthStencilAttachmentReference = VkAttachmentReference{ .attachment = index, .layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, }; } else { colorAttachmentReferences.emplace_back( VkAttachmentReference{ .attachment = index, .layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, }); } } -
RenderPass类只创建一个子通道,该子通道存储颜色附件引用和深度/模板附件引用:const VkSubpassDescription spd = { .pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = static_cast<uint32_t>( colorAttachmentReferences.size()), .pColorAttachments = colorAttachmentReferences.data(), .pDepthStencilAttachment = depthStencilAttachmentReference.has_value() ? &depthStencilAttachmentReference .value() : nullptr, }; -
我们使用的唯一子通道依赖于一个外部子通道(因为只有一个子通道,它必须依赖于外部的一个):
const VkSubpassDependency subpassDependency = { .srcSubpass = VK_SUBPASS_EXTERNAL, .srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT, .dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT, .dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, }; -
最后,所有这些信息都存储在类型为
VkRenderPassCreateInfo的结构体中,该结构体与设备一起传递,以使用vkCreateRenderPass创建渲染通道。句柄存储在RenderPass::renderPass_成员变量中:const VkRenderPassCreateInfo rpci = { .sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = static_cast<uint32_t>( attachmentDescriptors.size()), .pAttachments = attachmentDescriptors.data(), .subpassCount = 1, .pSubpasses = &spd, .dependencyCount = 1, .pDependencies = &subpassDependency, }; VK_CHECK(vkCreateRenderPass(device_, &rpci, nullptr, &renderPass_)); context.setVkObjectname(renderPass_, VK_OBJECT_TYPE_RENDER_PASS, "Render pass: " + name); } -
销毁渲染通道发生在析构函数中,通过调用
vkDestroyRenderPass函数:RenderPass::~RenderPass() { vkDestroyRenderPass(device_, renderPass_, nullptr); }
渲染通道存储有关如何处理附件(加载、清除、存储)的信息,并描述子通道依赖关系。它们还描述了哪些附件是解析附件(参见第六章**,抗锯齿技术中的“启用和使用 Vulkan 的 MSAA”配方,了解更多关于解析附件及其在 Vulkan 中如何用于实现 MSAA 的信息)。
创建帧缓冲区
虽然渲染通道对象包含了关于每个附件及其初始和最终布局应该做什么的信息,但帧缓冲区包含实际用于渲染通道的附件的引用,这些引用以VkImageViews的形式提供。
在这个配方中,你将学习如何创建帧缓冲区对象。
准备工作
在存储库中,Vulkan 帧缓冲区被VulkanCore::Framebuffer类封装。
如何操作…
帧缓冲区引用附件(它回答了“我们将为这个渲染通道使用哪些附件?”的问题)。
-
这些引用是图像视图,并以列表的形式传递,包括渲染通道的句柄,到
vkCreateFramebuffer帧缓冲区创建函数:uint32_t width, height; // Width and height of attachments VkDevice device; // Valid Vulkan Device std::vector<VkImageView> imageViews; // Valid Image Views const VkFramebufferCreateInfo framebufferInfo = { .sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = renderPass, .attachmentCount = static_cast<uint32_t>(attachments.size()), .pAttachments = imageViews.data(), .width = attachments[0]->vkExtents().width, .height = attachments[0]->vkExtents().height, .layers = 1, }; VK_CHECK( vkCreateFramebuffer(device_, &framebufferInfo, nullptr, &framebuffer_));
创建帧缓冲区很简单,如果你使用动态渲染,它们就不再是严格必要的了。
创建图像视图
在 Vulkan 中,图像视图是一种指定 GPU 如何解释和访问图像的方式。它提供了对图像内存的视图,并定义了其格式、尺寸和数据布局。
可以将图像视图视为一个窗口,它描述了如何访问图像的内存。它允许以多种方式使用图像,例如作为渲染命令的源或目标,或作为着色器中的纹理。
图像视图通过指定它们将要关联的图像以及一组定义图像格式、纵横比和范围的参数来创建。一旦创建,图像视图就可以绑定到管线或着色器,用于渲染或其他操作。它们由 VkImage 类型表示。
在这个菜谱中,你将学习如何创建图像视图。
准备工作
在存储库中,图像视图由 VulkanCore::Texture 类存储,没有专门的包装器。
如何操作…
在创建图像视图之前,你需要一个指向 Vulkan 图像对象的句柄:
-
创建图像视图很简单;你只需要一个指向 Vulkan 图像对象(
VkImage)的句柄以及一些参数,这些参数决定了如何访问底层的图像:VkImage image; // Valid VkImage const VkImageAspectFlags aspectMask = isDepth() ? VK_IMAGE_ASPECT_DEPTH_BIT : VK_IMAGE_ASPECT_COLOR_BIT; const VkImageViewCreateInfo imageViewInfo = { .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = image_, .viewType = viewType, .format = format, .components = { .r = VK_COMPONENT_SWIZZLE_IDENTITY, .g = VK_COMPONENT_SWIZZLE_IDENTITY, .b = VK_COMPONENT_SWIZZLE_IDENTITY, .a = VK_COMPONENT_SWIZZLE_IDENTITY, }, .subresourceRange = { .aspectMask = aspectMask, .baseMipLevel = 0, .levelCount = numMipLevels, .baseArrayLayer = 0, .layerCount = layers, }}; VK_CHECK(vkCreateImageView(context_.device(), &imageViewInfo, nullptr, &imageView_));
图像视图可以跨越整个图像(mip 级别和层)、仅一个元素(mip 级别或层),甚至只是图像的一部分。
Vulkan 图形管线
图形管线是 Vulkan 应用程序中渲染图形的关键概念。管线由一系列阶段组成,每个阶段都有特定的目的,它将原始数据转换成屏幕上完全渲染的图像。虽然管线的一些阶段比较明显,例如视口或光栅化,但其他阶段如着色器阶段、顶点输入和动态状态则不那么明显,但同样重要。在接下来的菜谱中,我们将探讨一些不太明显的管线阶段,并解释它们在渲染过程中的重要性。图 1.7 展示了创建图形管线所需填充的所有结构及其属性:

图 1.7 – Vulkan 图形管线
在这个菜谱中,你将了解 Vulkan 中管线的一些更多知识和它们最重要的特性。
如何操作…
这里是 Vulkan 中管线最重要的特性:
-
在 Vulkan 中,图形管线主要是不可变对象,这意味着一旦创建,除非在特定情况下,否则不能修改。这就是为什么如果你希望重用管线来绘制不同的形状,需要创建一个新的具有不同拓扑的管线。然而,某些管线属性可以在运行时动态更改,例如视口和裁剪矩形,这些被称为动态状态。
-
本书不会涵盖的一个重要的管线阶段例外是顶点输入状态。尽管创建它并不完全直接,但我们不会在这里讨论它,因为我们专门使用可编程顶点提取(PVP)方法在顶点着色器阶段访问索引和顶点。有关 PVP 的更多信息,请参阅第二章**,使用现代 Vulkan中的实现可编程顶点提取和多绘制间接配方。
-
类似地,管线布局,是图形管线的一个属性(而不是一个阶段),是一个数据结构,概述了着色器预期使用的资源的布局,包括它们的位置、数量和类型,以及与推送常量相关的详细信息。由于本章没有向着色器提供任何资源,因此管线布局使用默认值初始化。描述符集和推送常量将在第二章**,使用现代 Vulkan中介绍。
将着色器编译成 SPIR-V
与 OpenGL 不同,OpenGL 通常在运行时将着色器从高级语言编译成二进制格式,而 Vulkan 只支持一种称为 SPIR-V 的中间表示形式。SPIR-V 是一种跨平台的低级中间表示形式,可以从各种着色语言生成。
在这个配方中,你将学习如何使用glslang库将 GLSL 编译成 SPIR-V。
准备工作
在这个配方中,我们使用一个第三方库,该库在运行时将 GLSL 代码编译成 SPIR-V,称为glslang。可以从github.com/KhronosGroup/glslang.git下载。
在我们的代码中,我们提供了VulkanCore::ShaderModule类,它封装了着色器。它提供了ShaderModule::glslToSpirv方法(及其重载),可以将着色器源代码从 GLSL 编译成 SPIR-V。
如何做到这一点...
这里展示的步骤是ShaderModule::glslToSpirv()方法的一部分。以下是它是如何工作的:
-
glslang库需要通过调用glslang::InitializeProcess()进行一次初始化,因此其初始化被一个静态布尔变量保护:std::vector<char> ShaderModule::glslToSpirv( const std::vector<char>& data, EShLanguage shaderStage, const std::string& shaderDir, const char* entryPoint) { static bool glslangInitialized = false; if (!glslangInitialized) { glslang::InitializeProcess(); glslangInitialized = true; } -
TShader对象由一个函数实例化,用于包含着色器和生成 SPIR-V 字节码所必需的各种其他参数。这些参数包括输入客户端和 GLSL 版本,以及着色器的入口点:glslang::TShader tshader(shaderStage); const char* glslCStr = data.data(); tshader.setStrings(&glslCStr, 1); glslang::EshTargetClientVersion clientVersion = glslang::EShTargetVulkan_1_3; glslang::EShTargetLanguageVersion langVersion = glslang::EShTargetSpv_1_3; tshader.setEnvInput(glslang::EShSourceGlsl, shaderStage, glslang::EShClientVulkan, 460); tshader.setEnvClient(glslang::EShClientVulkan, clientVersion); tshader.setEnvTarget(glslang::EShTargetSpv, langVersion); tshader.setEntryPoint(entryPoint); tshader.setSourceEntryPoint(entryPoint); -
之后,我们收集系统中对着色器通常可用的资源约束,例如最大纹理数量或顶点属性数量,并建立编译器应呈现的消息。最后,我们将着色器编译成 SPIR-V 并验证结果:
const TBuiltInResource* resources = GetDefaultResources(); const EShMessages messages = static_cast<EShMessages>( EShMsgDefault | EShMsgSpvRules | EShMsgVulkanRules | EShMsgDebugInfo | EShMsgReadHlsl); CustomIncluder includer(shaderDir); std::string preprocessedGLSL; if (!tshader.preprocess( resources, 460, ENoProfile, false, false, messages, &preprocessedGLSL, includer)) { std::cout << "Preprocessing failed for shader: " << std::endl; printShader(data); std::cout << std::endl; std::cout << tshader.getInfoLog() << std::endl; std::cout << tshader.getInfoDebugLog() << std::endl; ASSERT(false, "includes are forbidden"); return std::vector<char>(); } -
在最后一个阶段,为调试和发布构建都建立了链接选项。在调试构建中,启用了常规的调试信息,同时禁用了优化和调试信息剥离。相反,在发布构建中,启用了优化器,这可能会导致未使用着色器变量的移除,包括结构体成员。然而,由于结构体大小的不一致可能会导致如果不对 C++代码应用相同的优化,则会出现问题,因此在发布构建中优化也被禁用:
glslang::SpvOptions options; #ifdef _DEBUG tshader.setDebugInfo(true); options.generateDebugInfo = true; options.disableOptimizer = true; options.optimizeSize = false; options.stripDebugInfo = false; options.emitNonSemanticShaderDebugSource = true; #else options.disableOptimizer = true; // Special care! options.optimizeSize = true; options.stripDebugInfo = true; #endif glslang::TProgram program; program.addShader(&tshader); if (!program.link(messages)) { std::cout << "Parsing failed for shader " << std::endl; std::cout << program.getInfoLog() << std::endl; std::cout << program.getInfoDebugLog() << std::endl; ASSERT(false, "link failed"); } std::vector<uint32_t> spirvData; spv::SpvBuildLogger spvLogger; glslang::GlslangToSpv( program.getIntermediate(shaderStage), spirvData, &spvLogger, &options); std::vector<char> byteCode; byteCode.resize(spirvData.size() * (sizeof(uint32_t) / sizeof(char))); std::memcpy(byteCode.data(), spirvData.data(), byteCode.size()); return byteCode; }
对于真正高性能的应用程序,着色器不是在运行时从 GLSL 编译的。它们是在构建时编译的,并在应用程序启动时从磁盘加载 SPIR-V 格式。
动态状态
虽然管道状态对象(PSOs)包括不可变状态,如着色器程序和顶点输入绑定,但管道状态的一些属性可以在绘制时使用动态状态对象动态更改。这个特性提供了更大的灵活性,并可以最小化重新创建管道的必要性。动态状态对象可以用来更改属性,如视口和剪裁矩形、线宽、混合常数和模板参考值。然而,并非所有管道属性都可以动态更改,使用动态状态可能会产生轻微的性能开销。
在不使用动态状态的情况下,应用程序有一些可用的替代方案:
-
在应用程序启动时创建管道。如果你知道需要哪些管道,它们可以在更高的启动成本下预先创建。
-
利用管道缓存。图形驱动程序具有内置的管道缓存机制,可以自动为你生成缓存。
一些参数,如视口、线宽和深度偏移,可以动态修改。虽然一些动态状态包含在 Vulkan 1.0 中,但其他一些作为扩展添加或包含在 Vulkan 1.3 中。如果一个参数被标记为动态(使用适当的结构),则在管道创建期间忽略其值。
在这道菜谱中,你将了解动态状态,它允许在创建管道后动态设置一些管道参数。
准备工作
动态状态是通过使用VkPipelineDynamicStateCreateInfo结构体创建的。这个结构体的一个实例被填充了你希望设置为动态的状态,随后被插入到管道的创建过程中,我们将在下一道菜谱中介绍。
如何做到这一点...
要允许参数动态设置,我们需要创建一个VkPipelineDynamicStateCreateInfo结构体的实例。
-
下面的代码片段展示了如何启用视口参数的动态状态:
const std::array<VkDynamicState, 1> dynamicStates = { VK_DYNAMIC_STATE_VIEWPORT, }; const VkPipelineDynamicStateCreateInfo dynamicState = { .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO, .dynamicStateCount = static_cast<uint32_t>(dynamicStates.size()), .pDynamicStates = dynamicStates.data(), }; dynamicStates array contains only the VK_DYNAMIC_STATE_VIEWPORT value, but it may contain a much larger set of values from VkDynamicState.
之前创建的实例将在下一个菜谱中使用。
创建图形管线
一旦收集并实例化了所有必需的状态和管线属性,在 Vulkan 中创建图形管线就是一个简单的过程。这涉及到填充VkGraphicsPipelineCreateInfo结构并调用vkCreateGraphicsPipelines。
在这个菜谱中,你将学习如何在 Vulkan 中创建图形管线对象。
准备工作
如需更多信息,请参阅存储库中VulkanCore::Pipeline类的构造函数。
如何操作…
填充由VkGraphicsPipelineCreateInfo引用的结构并不复杂,但是一项繁琐的任务。
-
一旦所有状态的所有结构都已实例化,我们只需要创建一个
VkGraphicsPipelineCreateInfo实例并调用vkCreateGraphicsPipelines:const VkGraphicsPipelineCreateInfo pipelineInfo = { .sType=VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = uint32_t(shaderStages.size()), .pStages = shaderStages.data(), .pVertexInputState = &vinfo, .pInputAssemblyState = &inputAssembly, .pViewportState = &viewportState, .pRasterizationState = &rasterizer, .pMultisampleState = &multisampling, .pDepthStencilState = &depthStencilState, // Optional .pColorBlendState = &colorBlending, .pDynamicState = &dynamicState, .layout = layout, .renderPass = renderPass, .basePipelineHandle = VK_NULL_HANDLE, // Optional .basePipelineIndex = -1, // Optional }; VkPipeline gfxPipeline = VK_NULL_HANDLE; VK_CHECK(vkCreateGraphicsPipelines( device_, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &gfxPipeline));
创建图形管线是一个昂贵的操作。避免创建管线惩罚的一种方法是在应用程序下次运行时缓存并重用它们。
Swapchain
Vulkan 中的 swapchain 模仿了 OpenGL 中的双缓冲和三缓冲功能,但应用在管理 swapchain 缓冲区方面有更明确的作用。这种方法提供了更好的控制,可以配置、同步和呈现图像。
Vulkan swapchain 是与表面(VkSurfaceKHR)相关联的图像集合,用于在窗口中显示渲染输出。尽管它是 Vulkan API 的关键部分,但用于创建和管理 swapchain 的函数和类型是VK_KHR_swapchain扩展的一部分。
swapchain 对象中的图像数量必须在构造时确定,但必须介于设备提供的最小(minImageCount)和最大(maxImageCount)可能值之间。这些值可以从 Vulkan 物理设备的VkSurfaceCapabilitiesKHR结构中检索。
Swapchain 图像(VkImage)由 swapchain 对象创建和拥有,因此它们的内存不由应用程序提供或分配。图像视图(VkImageView)不是由 swapchain 对象创建的,因此必须单独创建。
在这个菜谱中,你将学习如何创建、管理和销毁 swapchain 图像。
准备工作
代码中由VulkanCore::Swapchain类管理 swapchain。
如何操作…
swapchain 扩展提供了一组用于创建、管理和销毁 swapchain 的函数和类型。一些关键函数和类型包括以下内容:
-
vkCreateSwapchainKHR:此函数用于创建 swapchain。你需要提供一个包含有关表面、图像数量、格式、尺寸、使用标志和其他 swapchain 属性的VkSwapchainCreateInfoKHR结构。 -
vkGetSwapchainImagesKHR: 在创建交换链之后,此函数用于检索交换链中图像的句柄。然后,您可以创建用于渲染和展示的图像视图和帧缓冲区。 -
vkAcquireNextImageKHR: 此函数用于从交换链中获取一个可用的图像进行渲染。它还需要提供一个信号量或栅栏来指示图像何时准备好进行渲染。 -
vkQueuePresentKHR: 一旦渲染完成,此函数用于将交换链图像提交到显示设备进行展示。 -
vkDestroySwapchainKHR: 此函数负责销毁交换链并清理与其相关的资源。
理解交换链中的同步——栅栏和信号量
应用程序和 GPU 进程是并行运行的;除非另有说明,否则命令缓冲区和它们的命令也在 GPU 上并行运行。为了在 CPU 和 GPU 之间以及 GPU 中处理的命令缓冲区之间强制执行顺序,Vulkan 提供了两种机制:栅栏和信号量。栅栏用于在 GPU 和 CPU 之间同步工作,而信号量用于同步在 GPU 上执行的工作负载。
在这个菜谱中,您将了解栅栏和信号量:为什么它们是必要的,如何使用(以及何时使用),以及如何使用信号量与交换链一起使用。
准备工作
信号量的示例可以在VulkanCore::Swapchain类中找到,而栅栏的示例可以在VulkanCore::CommandQueueManager类中找到。
如何做到这一点…
栅栏和信号量有不同的用途。让我们探索这些元素中的每一个,以及如何使用信号量与交换链一起使用。
- 图 1**.8 展示了一个在 CPU 上运行的应用程序如何提交命令到 GPU,并在提交后立即继续其工作(无需同步)。这可能是预期的,但如果您希望在 GPU 上的命令处理完成后再继续,可以使用栅栏来指示 GPU 上的工作已完成。一旦 GPU 上的命令处理完成,栅栏被触发,应用程序可以继续:

图 1.8 – 在设备上无同步的命令缓冲区记录和执行
- 信号量以类似的方式工作,但用于 GPU 上运行的命令或作业之间。图 1**.10 说明了使用信号量来同步 GPU 上正在处理的命令。应用程序负责在提交缓冲区进行处理之前创建信号量,并在命令缓冲区和信号量之间添加依赖关系。一旦 GPU 上处理了一个任务,信号量被触发,下一个任务可以继续。这强制命令之间有一个顺序:

图 1.9 – 栅栏
获取图像、渲染和展示的过程都是异步的,需要同步。在这个菜谱中,我们将使用两个信号量进行同步:imageAvailable 和 imageRendered。图 1**.10 说明了信号量如何影响设备上命令的执行:

图 1.10 – 信号量
当获取到的图像可用时,imageAvailable_ 会发出信号,提示将要渲染到图像中的命令队列开始处理。一旦命令缓冲区完成,它将发出另一个信号量,即 imageRendered,这反过来又允许开始展示该图像。图 1**.11 展示了如何使用两个信号量实现同步:

图 1.11 – 交换链的同步
栅栏和信号量并不难理解,但在 Vulkan 中它们对于同步至关重要。在继续之前,请确保您理解它们是如何被使用的。
填充展示的提交信息
提交命令缓冲区需要一个 VkSubmitInfo 结构的实例,这允许指定用于等待(以开始处理)和信号(一旦命令缓冲区执行完成)的信号量。这些信号量是可选的,通常不需要。但当提交命令缓冲区以在屏幕上展示图像时,这些信号量允许 Vulkan 将缓冲区的执行与显示引擎同步。
在这个菜谱中,您将学习如何在记录后提交命令缓冲区以供 GPU 处理。
准备工作
仓库中的 VulkanCore::Swapchain 类提供了一个实用函数来为您填充 VkSubmitInfo 结构,因为用于与显示引擎同步执行的分发信号量存储在交换链中。如果结构中不需要信号量,则 waitForImageAvailable 和 signalImagePresented 参数应设置为 false。
如何做到这一点...
用于提交需要与显示引擎同步的命令缓冲区的同步信息由 VkSubmitInfo 结构的实例提供,并包含用于设备中同步的信号量的引用。它还包含将要提交的命令缓冲区。
-
栅栏与命令缓冲区相关联,并不是用于同步交换链的特定栅栏:
const VkSubmitInfo submitInfo = { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .waitSemaphoreCount = 1, .pWaitSemaphores = &imageAvailable, .pWaitDstStageMask = submitStageMask, .commandBufferCount = 1, .pCommandBuffers = buffer, .signalSemaphoreCount = 1, .pSignalSemaphores = &imagePresented, }; VK_CHECK(vkQueueSubmit(queue_, 1, &submitInfo, fence));
一旦提交了用于处理的命令缓冲区,驱动程序和 GPU 就会执行其中记录的命令。唯一知道命令缓冲区是否已处理完成的方法是检查提供给 vkQueueSubmit 的栅栏。
展示图像
在 Vulkan 中将图像展示到屏幕上不是自动的。您需要调用 vkQueuePresentKHR 函数以及 VkPresentInfoKHR 结构的实例。
在这个菜谱中,你将学习如何在图像渲染完成后将其排队以供展示。
准备工作
我们代码中的展示是通过VulkanCore::Swapchain::present()方法完成的。
如何实现...
通过调用vkQueuePresentKHR请求展示获取的图像。
这次,我们需要提供imageRendered信号量,它表示渲染过程何时完成对图像的使用:
const VkPresentInfoKHR presentInfo{
.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
.waitSemaphoreCount = 1,
.pWaitSemaphores = &imageRendered_,
.swapchainCount = 1,
.pSwapchains = &swapchain_,
.pImageIndices = &imageIndex_,
};
VK_CHECK(vkQueuePresentKHR(presentQueue_, &presentInfo));
一旦调用VkQueuePresentKHR,图像不会立即展示。这个调用仅仅设置了同步机制,以便 Vulkan 知道何时可以将图像发送进行显示。
绘制三角形
现在我们已经了解了所有基本 Vulkan 对象及其工作原理,我们最终可以创建一个小型示例应用程序,在屏幕上显示静态着色三角形。
在这个菜谱中,我们将展示一个完整的示例,该示例在屏幕上绘制静态三角形。顶点数据和属性在顶点着色器中静态提供。
准备工作
这个菜谱中的代码可以在存储库中的source/chapter1/main.cpp找到。顶点和片段着色器位于source/chapter1/resources/shaders目录下的triangle.vert和triangle.frag文件中。
如何实现...
这里展示的代码是存储库中代码的完整版本。
-
对于这个菜谱,我们将使用两个着色器:
triangle.vert和triangle.frag。顶点着色器不接受任何输入,因为它所需的所有数据都定义在着色器本身中,作为两个数组:一个用于顶点数据(positions)和一个用于颜色数据(colors)。两套数据都直接以原始形式发送到输出,没有任何转换,因为它们已经处于各自输出空间中(位置数据为屏幕空间,颜色数据为输出颜色空间)。位置通过内置的
gl_VertexIndex变量输出,而颜色则写入位置0的outColor变量:#version 460 layout(location = 0) out vec4 outColor; vec2 positions[3] = vec2[]( vec2(0.0, -0.5), vec2(0.5, 0.5), vec2(-0.5, 0.5) ); vec3 colors[3] = vec3[]( vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, 1.0) ); void main() { gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0); outColor = vec4(colors[gl_VertexIndex], 1.0); } -
片段着色器接受来自顶点阶段的颜色数据,并通过位置
0的outColor变量直接将其输出为片段颜色:#version 460 layout(location = 0) in vec4 inColor; layout(location = 0) out vec4 outColor; void main() { outColor = inColor; }我们只需要一个渲染通道,它将渲染结果输出到只有一个附件的帧缓冲区,即颜色附件 0。颜色附件的加载操作是清除,因为我们将在渲染时清除它,而存储操作是存储,因为我们希望输出被记录到附件中。输出将直接进入交换链,因此获取的交换链图像是颜色附件 0。由于每个渲染通道直接输出到交换链图像,并且帧缓冲区与附件相关联且不可变,我们需要帧缓冲区的数量与交换链图像的数量相匹配。每个帧缓冲区将与一个交换链图像相关联,作为颜色附件 0。着色器不需要访问外部缓冲区,如顶点和索引或纹理。
-
第一步是初始化一个窗口并创建一个上下文,我们将使用此书中的默认功能。有关更多详细信息,请参阅存储库中的
VulkanCore::VulkanFeatureChain类。该上下文封装了实例以及物理和逻辑设备,并使用一些有用的扩展、一个图形队列和默认功能进行初始化:int main(int argc, char** argv) { initWindow(&window_); // Create Context VulkanCore::VulkanFeatureChain featureChain; VulkanCore::Context::createDefaultFeatureChain( featureChain); VulkanCore::Context context( (void*)glfwGetWin32Window(window_), {}, // layers { VK_KHR_WIN32_SURFACE_EXTENSION_NAME, VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME, }, // instance extensions {VK_KHR_SWAPCHAIN_EXTENSION_NAME}, // device // extensions VK_QUEUE_GRAPHICS_BIT, // request a graphics // queue only featureChain, true); -
交换链使用一个常见的格式和颜色空间以及物理设备的扩展进行初始化。在这个例子中,我们使用 先进先出 (FIFO) 展示模式,因为它默认支持的唯一模式:
// Create Swapchain const VkExtent2D extents = context.physicalDevice() .surfaceCapabilities() .minImageExtent; context.createSwapchain( VK_FORMAT_B8G8R8A8_UNORM, VK_COLORSPACE_SRGB_NONLINEAR_KHR, VK_PRESENT_MODE_FIFO_KHR, extents); const VkRect2D renderArea = { .offset = {.x = 0, .y = 0}, .extent = extents}; -
两个着色器都是从存储库中的资源初始化的,以及一个帧缓冲区向量。帧缓冲区的数量与交换链图像的数量相匹配,因为我们稍后需要为每个获取的图像使用一个帧缓冲区:
// Create Shader Modules const auto shadersPath = std::filesystem::current_path() / "resources/shaders"; const auto vertexShaderPath = shadersPath / "triangle.vert"; const auto fragShaderPath = shadersPath / "triangle.frag"; const auto vertexShader = context.createShaderModule( vertexShaderPath.string(), VK_SHADER_STAGE_VERTEX_BIT); const auto fragShader = context.createShaderModule( fragShaderPath.string(), VK_SHADER_STAGE_FRAGMENT_BIT); // Create Framebuffers std::vector<std::shared_ptr<VulkanCore::Framebuffer>> swapchain_framebuffers( context.swapchain()->numberImages()); -
我们只需要一个渲染通道和一个子通道。渲染通道不关联任何资源。它只指定每个帧缓冲区颜色附件的加载和存储操作以及子通道的使用。因此,我们不需要多个渲染通道,就像帧缓冲区那样。一个就足够了,并且它被用于所有交换链图像。颜色附件 0,即交换链图像的最终布局是
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,因为它将被展示:// Create Render Pass std::shared_ptr<VulkanCore::RenderPass> renderPass = context.createRenderPass( {context.swapchain()->texture(0)}, {VK_ATTACHMENT_LOAD_OP_CLEAR}, {VK_ATTACHMENT_STORE_OP_STORE}, {VK_IMAGE_LAYOUT_PRESENT_SRC_KHR}, VK_PIPELINE_BIND_POINT_GRAPHICS);最后,我们创建一个包含大多数默认参数的管道。除了之前编译的两个着色器之外,我们将视口设置为输出的大小并禁用深度测试。然后我们创建一个命令队列管理器实例来管理命令缓冲区和它们的栅栏:
// Create Graphics Pipeline auto pipeline = context.createGraphicsPipeline( VulkanCore::Pipeline::GraphicsPipelineDescriptor{ .vertexShader_ = vertexShader, .fragmentShader_ = fragShader, .viewport = context.swapchain()->extent(), .depthTestEnable = false, }, renderPass->vkRenderPass()); // Create Command Queue Manager auto commandMgr = context.createGraphicsCommandQueue( context.swapchain()->numberImages(), context.swapchain()->numberImages()); // FPS Counter EngineCore::FPSCounter fps(glfwGetTime()); -
主渲染循环执行,直到
GLFW窗口关闭。在每次迭代中,我们首先获取一个交换链图像及其索引。如果此交换链图像的帧缓冲区尚不存在,我们创建一个。然后我们获取一个用于渲染的命令缓冲区:// Main Render Loop while (!glfwWindowShouldClose(window_)) { fps.update(glfwGetTime()); const auto texture = context.swapchain()->acquireImage(); const auto swapchainImageIndex = context.swapchain()->currentImageIndex(); // Create the framebuffer the first time we get // here, once for each swapchain image if (swapchain_framebuffers[swapchainImageIndex] == nullptr) { swapchain_framebuffers[swapchainImageIndex] = context.createFramebuffer( renderPass->vkRenderPass(), {texture}, nullptr, nullptr); } auto commandBuffer = commandMgr.getCmdBufferToBegin(); -
在开始渲染之前,我们通过提供一个清除颜色(黑色)和渲染通道以及帧缓冲区句柄来开始渲染通道。然后我们将管道绑定到当前命令缓冲区,我们就可以开始渲染了:
// Begin Render Pass constexpr VkClearValue clearColor{0.0f, 0.0f, 0.0f, 0.0f}; const VkRenderPassBeginInfo renderpassInfo = { .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = renderPass->vkRenderPass(), .framebuffer = swapchain_framebuffers[swapchainImageIndex] ->vkFramebuffer(), .renderArea = renderArea, .clearValueCount = 1, .pClearValues = &clearColor, }; vkCmdBeginRenderPass(commandBuffer, &renderpassInfo, VK_SUBPASS_CONTENTS_INLINE); pipeline->bind(commandBuffer); -
最后,我们使用三个顶点和实例发出绘制调用。此调用将调用顶点着色器三次(每个顶点一次),在着色器中实例化
gl_VertexIndex变量到 0、1 和 2。我们使用此变量在着色器本身中索引位置和颜色数组。然后我们提交命令缓冲区并展示交换链图像:vkCmdDraw(commandBuffer, 3, 1, 0, 0); vkCmdEndRenderPass(commandBuffer); commandMgr.endCmdBuffer(commandBuffer); constexpr VkPipelineStageFlags flags = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; const auto submitInfo = context.swapchain()->createSubmitInfo( &commandBuffer, &flags); commandMgr.submit(&submitInfo); commandMgr.goToNextCmdBuffer(); // Present render output to the screen context.swapchain()->present(); glfwPollEvents(); // Increment frame number fps.incFrame(); } -
在渲染循环结束后,在退出程序之前,我们等待所有队列完成处理,然后以它们创建的相反顺序销毁所有 Vulkan 对象:
commandMgr.waitUntilAllSubmitsAreComplete(); glfwDestroyWindow(window_); glfwTerminate(); return 0; }此菜谱的结果应类似于 图 1**.12:

图 1.12 – 菜谱结果
Vulkan 语言较为冗长,正如之前提到的,它提供了许多自定义图形应用程序的方法。一个简单的例子就需要大约 1,000 行代码!但无需恐慌。其中大部分代码可以重用(并且将被重用)来解释本书中剩余部分的所有技术和食谱。
第二章:使用现代 Vulkan
本章的目标是向您展示如何渲染从应用程序端接受输入信息(如纹理和统一数据)的场景。本章将涵盖 Vulkan API 的高级主题,这些主题基于上一章讨论的核心概念,并提供了渲染复杂场景所需的所有信息,以及 API 的新功能。此外,本章还将演示提高渲染速度的技术。
在本章中,我们将介绍以下食谱:
-
理解 Vulkan 的内存模型
-
实例化 VMA 库
-
创建缓冲区
-
将数据上传到缓冲区
-
创建阶段缓冲区
-
如何使用环形缓冲区避免数据竞争
-
设置管道屏障
-
创建图像(纹理)
-
创建图像视图
-
创建采样器
-
提供着色器数据
-
使用专用常量自定义着色器行为
-
实现 MDI 和 PVP
-
使用动态渲染增强渲染管道的灵活性
-
在队列家族之间传输资源
技术要求
对于本章,您需要确保已安装 VS 2022 以及 Vulkan SDK。对 C++ 编程语言的基本熟悉程度以及对 OpenGL 或任何其他图形 API 的理解将很有用。请查阅 技术要求 部分的 第一章**,Vulkan 核心概念,以获取有关设置和构建本章可执行文件的详细信息。可以通过启动 Chapter02_MultiDrawIndirect.exe 可执行文件来运行本章的食谱。
理解 Vulkan 的内存模型
在 Vulkan 中,内存分配和管理至关重要,因为几乎所有的内存使用细节都不由 Vulkan 管理。除了决定内存分配的确切内存地址外,所有其他细节都是应用程序的责任。这意味着程序员必须管理内存类型、它们的大小和对齐方式,以及任何子分配。这种方法使应用程序对内存管理有更多的控制权,并允许开发者为特定用途优化他们的程序。本食谱将提供一些关于 API 提供的内存类型的基本信息,以及如何分配和绑定该内存到资源的摘要。
准备工作
图形卡有两种类型,集成和独立。集成显卡与 CPU 共享相同的内存,如图 图 2.1 所示:

图 2.1 – 独立显卡的典型内存架构
独立显卡有自己的内存(设备内存),与主内存(主机内存)分开,如图 图 2.2 所示:

图 2.2 – 集成显卡的典型内存架构
Vulkan 提供了不同类型的内存:
-
设备本地内存:这种类型的内存针对 GPU 使用进行了优化,并且是设备本地的。它通常比主机可见内存要快,但不能从 CPU 访问。通常,资源如渲染目标、存储图像和缓冲区都存储在这种内存中。
-
主机可见内存:这种类型的内存可以从 GPU 和 CPU 访问。它通常比设备本地内存要慢,但允许在 GPU 和 CPU 之间进行高效的数据传输。在非集成 GPU 的情况下,从 GPU 到 CPU 的读取发生在外围组件互连扩展(PCI-E)通道上。它通常用于设置暂存缓冲区,其中数据在传输到设备本地内存之前被存储,以及统一缓冲区,这些缓冲区由应用程序不断更新。
-
主机一致内存:这种类型的内存类似于主机可见内存,但提供了 GPU 和 CPU 之间保证的内存一致性。这种类型的内存通常比设备本地和主机可见内存都要慢,但对于需要 GPU 和 CPU 频繁更新的数据存储很有用。
图 2.3总结了上述三种内存类型。设备本地内存对主机不可见,而主机一致和主机可见的内存是可见的。对于这两种类型的内存分配,可以使用映射内存从 CPU 到 GPU 复制数据。对于设备本地内存,有必要首先使用映射内存(暂存缓冲区)将数据从 CPU 复制到主机可见内存,然后使用 Vulkan 函数从暂存缓冲区到目标(设备本地内存)复制数据:

图 2.3 – Vulkan 中内存类型及其从应用中的可见性
图像通常是设备本地内存,因为它们有自己的布局,该布局不容易被应用程序解释。缓冲区可以是上述任何一种类型。
如何做…
创建并上传数据到缓冲区的典型工作流程包括以下步骤:
-
通过使用
VkBufferCreateInfo结构体并调用vkCreateBuffer来创建一个类型为VkBuffer的缓冲区对象。 -
通过调用
vkGetBufferMemoryRequirements根据缓冲区的属性检索内存需求。设备可能需要特定的对齐方式,这可能会影响分配所需的大小以容纳缓冲区的内容。 -
创建一个类型为
VkMemoryAllocateInfo的结构体,指定分配的大小和内存类型,并调用vkAllocateMemory。 -
通过调用
vkBindBufferMemory将分配与缓冲区对象绑定。 -
如果缓冲区对主机可见,使用
vkMapMemory将指针映射到目标,复制数据,然后使用vkUnmapMemory取消映射内存。 -
如果缓冲区是设备本地缓冲区,首先将数据复制到阶段缓冲区,然后使用
vkCmdCopyBuffer函数从阶段缓冲区到设备本地内存执行最终复制。
如您所见,这是一个可以通过使用 VMA 库来简化的复杂过程,VMA 库是一个开源库,它提供了一个方便且高效的方式来管理 Vulkan 中的内存。它提供了一个高级接口,抽象了内存分配的复杂细节,让您免于手动内存管理的负担。
实例化 VMA 库
要使用 VMA,您首先需要创建库的实例,并将句柄存储在类型为VmaAllocator的变量中。要创建一个实例,您需要一个 Vulkan 物理设备和设备。
如何操作…
创建 VMA 库实例需要实例化两个不同的结构。一个存储 VMA 需要找到的其他函数指针的 API 函数指针,另一个结构提供物理设备、设备和实例以创建分配器:
VkPhysicalDevice physicalDevice; // Valid Physical Device
VkDevice device; // Valid Device
VkInstance instance; // Valid Instance
const uint32_t apiVersion = VK_API_VERSION_1_3;
const VmaVulkanFunctions vulkanFunctions = {
.vkGetInstanceProcAddr = vkGetInstanceProcAddr,
.vkGetDeviceProcAddr = vkGetDeviceProcAddr,
#if VMA_VULKAN_VERSION >= 1003000
.vkGetDeviceBufferMemoryRequirements =
vkGetDeviceBufferMemoryRequirements,
.vkGetDeviceImageMemoryRequirements =
vkGetDeviceImageMemoryRequirements,
#endif
};
VmaAllocator allocator = nullptr;
const VmaAllocatorCreateInfo allocInfo = {
.physicalDevice = physicalDevice,
.device = device,
.pVulkanFunctions = &vulkanFunctions,
.instance = instance,
.vulkanApiVersion = apiVersion,
};
vmaCreateAllocator(&allocInfo, &allocator);
分配器需要指向几个 Vulkan 函数的指针,以便它可以根据您希望使用的功能进行工作。在前面的例子中,我们只为分配和释放内存提供了最基本的内容。在上下文被销毁后,需要使用vmaDestroyAllocator释放分配器。
创建缓冲区
在 Vulkan 中,缓冲区只是一个连续的内存块,用于存储一些数据。数据可以是顶点、索引、均匀的,等等。缓冲区对象只是元数据,并不直接包含数据。与缓冲区关联的内存是在创建缓冲区之后分配的。
表 2.1 总结了缓冲区最重要的使用类型及其访问类型:
| 缓冲区类型 | 访问类型 | 用途 |
|---|---|---|
| 顶点或索引 | 只读 | |
| 均匀 | 只读 | 均匀数据存储 |
| 存储 | 读写 | 通用数据存储 |
| 均匀纹理 | 读写 | 数据被解释为纹理元素 |
| 存储纹理 | 读写 | 数据被解释为纹理元素 |
表 2.1 – 缓冲区类型
创建缓冲区很容易,但在开始创建它们之前了解存在哪些类型的缓冲区及其要求是有帮助的。在本章中,我们将提供一个创建缓冲区的模板。
准备工作
在仓库中,Vulkan 缓冲区由VulkanCore::Buffer类管理,该类提供了创建和上传数据到设备的功能,以及一个使用阶段缓冲区将数据上传到设备专用堆的实用函数。
如何操作…
使用 VMA 创建缓冲区很简单:
-
您所需的所有内容是缓冲区创建标志(对于大多数情况,标志的值为
0是正确的),缓冲区的大小(以字节为单位),其用途(这是您定义缓冲区如何使用的方式),并将这些值分配给VkBufferCreateInfo结构的实例:VkDeviceSize size; // The requested size of the buffer VmaAllocator allocator; // valid VMA Allocator VkUsageBufferFlags use; // Transfer src/dst/uniform/SSBO VkBuffer buffer; // The created buffer VkBufferCreateInfo createInfo = { .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .pNext = nullptr, .flags = {}, .size = size, .usage = use, .sharingMode = VK_SHARING_MODE_EXCLUSIVE, .queueFamilyIndexCount = {}, .pQueueFamilyIndices = {}, };您还需要一组 VmaAllocationCreateFlagBits 值:
const VmaAllocationCreateFlagBits allocCreateInfo = { VMA_ALLOCATION_CREATE_MAPPED_BIT, VMA_MEMORY_USAGE_CPU_ONLY, }; -
然后,调用
vmaCreateBuffer以获取缓冲区句柄及其分配:VmaAllocation allocation; // Needs to live until the // buffer is destroyed VK_CHECK(vmaCreateBuffer(allocator, &createInfo, &allocCreateInfo, &buffer, &allocation, nullptr)); -
下一步是可选的,但有助于调试和优化:
VmaAllocationInfo allocationInfo; vmaGetAllocationInfo(allocator, allocation, &allocationInfo);
一些创建标志会影响缓冲区的使用方式,因此您可能需要根据您打算在应用程序中使用的缓冲区进行调整:
将数据上传到缓冲区
从应用程序上传数据到 GPU 取决于缓冲区的类型。对于主机可见缓冲区,它是一个使用memcpy的直接复制。对于设备本地缓冲区,我们需要一个阶段缓冲区,这是一个既对 CPU 又对 GPU 可见的缓冲区。在这个配方中,我们将演示如何将数据从您的应用程序上传到设备可见内存(到设备上缓冲区的内存区域)。
准备工作
如果您还没有,请参考理解 Vulkan 内存 模型配方。
如何做…
上传过程取决于缓冲区的类型:
-
对于主机可见内存,只需使用
vmaMapMemory检索目标指针,并使用memcpy复制数据即可。该操作是同步的,因此一旦memcpy返回,就可以取消映射映射的指针。在创建后立即映射主机可见缓冲区并在其销毁前保持映射是完全可以接受的。这是推荐的方法,因为您不需要每次更新内存时都映射内存的开销:
VmaAllocator allocator; // Valid VMA allocator VmaAllocation allocation; // Valid VMA allocation void *data; // Data to be uploaded size_t size; // Size of data in bytes void *map = nullptr; VK_CHECK(vmaMapMemory(allocator, allocation, &map)); memcpy(map, data, size); vmaUnmapMemory(allocator_, allocation_); VK_CHECK(vmaFlushAllocation(allocator_, allocation_, offset, size)); -
将数据上传到设备本地内存需要先(1)将其复制到一个主机可见的缓冲区(称为阶段缓冲区),然后(2)使用
vkCmdCopyBuffer从阶段缓冲区复制到设备本地内存,如图图 2**.4所示。请注意,这需要一个命令缓冲区:

图 2.4 – 阶段缓冲区
-
一旦数据驻留在设备上(在主机可见缓冲区上),将其复制到设备专用缓冲区就很简单了:
VkDeviceSize srcOffset; VkDeviceSize dstOffset; VkDeviceSize size; VkCommandBuffer commandBuffer; // Valid Command Buffer VkBuffer stagingBuffer; // Valid host-visible buffer VkBuffer buffer; // Valid device-local buffer VkBufferCopy region(srcOffset, dstOffset, size); vkCmdCopyBuffer(commandBuffer, stagingBuffer, buffer, 1, ®ion);
从您的应用程序到缓冲区的数据上传可以通过直接memcpy操作或通过阶段缓冲区完成。我们在本配方中展示了如何执行这两种上传。
创建阶段缓冲区
创建阶段缓冲区就像创建常规缓冲区一样,但需要指定缓冲区是主机可见的标志。在本配方中,我们将展示如何创建一个可以作为阶段缓冲区使用的缓冲区——一个可以作为从您的应用程序上传到设备本地内存的数据的中间目标。
准备工作
创建缓冲区配方解释了如何一般地创建缓冲区,而本配方展示了您需要哪些标志和参数来创建阶段缓冲区。
如何做…
VkBufferCreateInfo::usage需要包含VK_BUFFER_USAGE_TRANSFER_SRC_BIT,因为它将是vkCmdCopyBuffer命令的源操作:
const VkBufferCreateInfo stagingBufferInfo = {
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = size,
.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
};
const VmaAllocationCreateInfo
stagingAllocationCreateInfo = {
.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
VMA_ALLOCATION_CREATE_MAPPED_BIT,
.usage = VMA_MEMORY_USAGE_CPU_ONLY,
};
const VmaAllocationCreateFlagBits allocCreateInfo = {
VMA_ALLOCATION_CREATE_MAPPED_BIT,
VMA_MEMORY_USAGE_CPU_ONLY,
};
VmaAllocation allocation; // Needs to live until the
// buffer is destroyed
VK_CHECK(vmaCreateBuffer(allocator, &stagingBufferInfo,
&allocCreateInfo, &buffer,
&allocation, nullptr));
可以使用应用程序中的包装器更好地实现阶段缓冲区。例如,包装器可以根据需要增加或减少缓冲区的大小。一个阶段缓冲区可能足以满足你的应用程序,但你需要关注某些架构提出的要求。
如何使用环形缓冲区避免数据竞争
当每个缓冲区需要每帧更新时,我们面临创建数据竞争的风险,如图图 2.5所示。数据竞争是一种情况,其中程序内的多个线程同时访问一个共享数据点,至少有一个线程执行写操作。这种并发访问可能会由于操作顺序不可预测而导致不可预见的行为。以一个存储视图、模型和视口矩阵并需要每帧更新的统一缓冲区为例。当第一个命令缓冲区正在记录并初始化(版本 1)时,缓冲区正在更新。一旦命令缓冲区开始在 GPU 上处理,缓冲区将包含正确的数据:

图 2.5 – 使用一个缓冲区时的数据竞争
在第一个命令缓冲区开始在 GPU 上处理之后,应用程序可能会尝试在 GPU 访问该数据用于渲染时更新缓冲区的内容到版本 2!
准备工作
同步无疑是 Vulkan 中最困难的部分。如果过度贪婪地使用同步元素,如信号量、栅栏和屏障,那么你的应用程序将变成一个序列,无法充分利用 CPU 和 GPU 之间的并行性。
确保你阅读了第一章**,Vulkan 核心概念中的理解 swapchain 中的同步 – 栅栏和信号量配方。这个配方和这个配方只是触及了如何处理同步的表面,但都是很好的起点。
在EngineCore::RingBuffer存储库中提供了一个环形缓冲区实现,它具有可配置的子缓冲区数量。其子缓冲区都是主机可见的持久缓冲区;也就是说,它们在创建后持续映射,以便于访问。
如何操作…
有几种方法可以避免这个问题,但最简单的一种是创建一个包含多个缓冲区(或任何其他资源)的环形缓冲区,数量等于正在飞行的帧数。图 2.6显示了有两个缓冲区可用的事件。一旦第一个命令缓冲区提交并在 GPU 上处理,应用程序就可以自由处理缓冲区的副本 1,因为它没有被设备访问:

图 2.6 – 通过多个资源副本避免数据竞争
尽管这是一个简单的解决方案,但它有一个注意事项:如果允许部分更新,则在更新缓冲区时必须小心。考虑图 2**.7,其中包含三个子分配的环形缓冲区被部分更新。该缓冲区存储视图、模型和视口矩阵。在初始化期间,所有三个子分配都被初始化为三个单位矩阵。在(10, 10, 0)。在下一次帧中,帧 1,缓冲区 1变为活动状态,并且视口矩阵被更新。因为缓冲区 1被初始化为三个单位矩阵,只更新视口矩阵会使缓冲区 0和缓冲区 1(以及缓冲区 3)不同步。为了保证部分更新能够正常工作,我们需要首先将最后一个活动缓冲区缓冲区 0复制到缓冲区 1中,然后更新视口矩阵:

图 2.7 – 如果不复制,环形缓冲区的部分更新会使所有子分配不同步
同步是一个微妙的话题,确保您的应用程序在这么多动态部分中表现正确是棘手的。希望一个简单的环形缓冲区实现可以帮助您专注于代码的其他区域。
设置管道屏障
在 Vulkan 中,当命令缓冲区正在处理时,命令可能会被重新排序,但受到某些限制。这被称为命令缓冲区重新排序,它可以通过允许驱动程序优化命令执行的顺序来提高性能。
好消息是,Vulkan 提供了一个称为管道屏障的机制,以确保依赖命令按正确的顺序执行。它们用于显式指定命令之间的依赖关系,防止它们被重新排序,以及它们可能在哪些阶段重叠。本食谱将解释管道屏障是什么以及它们的属性意味着什么。它还将向您展示如何创建和安装管道屏障。
准备就绪
考虑连续发出的两个绘制调用。第一个调用写入一个颜色附件,而第二个绘制调用在片段着色器中从该附件采样:
vkCmdDraw(...); // draws into color attachment 0
vkCmdDraw(...); // reads from color attachment 0
图 2**.8有助于可视化设备如何处理这两个命令。在图中,命令从上到下处理,并在管道中从左到右前进。时钟周期是一个宽泛的术语,因为处理可能需要多个时钟周期,但用来表示在一般情况下,某些任务必须在其他任务之后发生。
在示例中,第二个vkCmdDraw调用在C2处开始执行,在第一个绘制调用之后。这个偏移量不足以满足第二个绘制调用在片段着色器阶段读取颜色附件的需求,这需要第一个绘制调用直到达到颜色附件输出阶段才能生成。如果没有同步,这种设置可能会导致数据竞争:

图 2.8 – 同一个命令缓冲区上记录的两个连续命令,未进行同步处理
管道屏障是一个记录到命令缓冲区中的功能,它指定了需要在屏障之前和命令缓冲区继续处理之前完成所有命令的管道阶段。在屏障之前记录的命令被称为处于第一个同步作用域或第一个作用域。在屏障之后记录的命令被称为是第二个同步作用域或第二个作用域的一部分。
该屏障还允许细粒度控制,以指定屏障之后的命令必须在哪个阶段等待,直到第一个作用域中的命令完成处理。这是因为第二个作用域中的命令不需要等待第一个作用域中的命令完成。只要满足屏障中指定的条件,它们就可以尽可能快地开始处理。
在图 2.8的例子中,第一个绘制调用,在第一个作用域中,需要在第二个绘制调用可以访问它之前写入附件。第二个绘制调用不需要等待第一个绘制调用完成处理颜色附件输出阶段。它可以立即开始,只要它的片段阶段发生在第一个绘制调用完成其颜色附件输出阶段之后,如图 2.9所示:

图 2.9 – 同一个命令缓冲区上记录的两个连续命令,带有同步处理
有三种类型的屏障:
-
内存屏障是全局屏障,适用于第一个和第二个作用域中的所有命令。
-
缓冲区内存屏障是仅应用于访问缓冲区一部分的命令的屏障,因为可以指定屏障应用于缓冲区的哪一部分(偏移量 + 范围)。
-
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL布局,因为它将被读取,而下一个 mip 级别需要处于VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL布局,因为它将被写入。
如何做到这一点…
管道屏障使用vkCmdPipelineBarrier命令进行记录,在该命令中,你可以同时提供多种类型的多个屏障。以下代码片段显示了如何创建用于在图 2.9中的两个绘制调用之间创建依赖关系的屏障:
VkCommandBuffer commandBuffer; // Valid Command Buffer
VkImage image; // Valid image
const VkImageSubresourceRange subresource = {
.aspectMask =.baseMipLevel = 0,
.levelCount = VK_REMAINING_MIP_LEVELS,
.baseArrayLayer = 0,
.layerCount = 1,
};
const VkImageMemoryBarrier imageBarrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask =
VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT_KHR,
.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT_KHR,
.oldLayout = VK_IMAGE_LAYOUT_ATTACHMENT_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_READ_ONLY_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = image,
.subresourceRange = &subresource,
};
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0,
nullptr, 0, nullptr, 1, &memoryBarrier);
需要在两个绘制调用之间记录屏障:
vkCmdDraw(...); // draws into color attachment 0
vkCmdPipelineBarrier(...);
vkCmdDraw(...); // reads from color attachment 0
管道屏障在 Vulkan 中很棘手,但绝对是基础性的。在继续阅读其他食谱之前,请确保你理解它们提供了什么以及它们是如何工作的。
创建图像(纹理)
图像用于存储 1D、2D 或 3D 数据,尽管它们主要用于 2D 数据。与缓冲区不同,图像在内存布局中具有优化局部性的优势。这是因为大多数 GPU 都有一个固定功能的纹理单元或采样器,它从图像中读取纹理数据,并应用过滤和其他操作以产生最终的色彩值。图像可以有不同的格式,例如 RGB、RGBA、BGRA 等。
在 Vulkan 中,图像对象仅是元数据。其数据是单独存储的,并且以类似于缓冲区的方式创建(图 2**.10):

图 2.10 – 图片
Vulkan 中的图像不能直接访问,只能通过图像视图来访问。图像视图是通过指定子资源范围来访问图像数据子集的一种方式,该范围包括方面(如颜色或深度)、米柏级别和数组层范围。
图像的另一个非常重要的方面是它们的布局。它用于指定 Vulkan 中图像资源的预期用途,例如是否应将其用作传输操作的数据源或目标,渲染的颜色或深度附件,或作为着色器读取或写入资源。正确的图像布局非常重要,因为它确保 GPU 可以根据预期用途高效地访问和操作图像数据。使用错误的图像布局可能导致性能问题或渲染伪影,并可能导致未定义的行为。因此,在 Vulkan 应用程序中为每个图像的使用正确指定图像布局是至关重要的。常见的图像布局包括未定义(VK_IMAGE_LAYOUT_UNDEFINED)、颜色附件(VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL)、深度/模板附件(VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL)和着色器读取(VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)。图像布局转换作为 vkCmdPipelineBarrier 命令的一部分进行。
在本食谱中,你将学习如何在设备上创建图像。
准备工作
在我们仓库中的 VulkanCore::Texture 类中,我们封装了图像和图像视图的复杂管理,为处理 Vulkan 纹理提供了一个全面的解决方案。从促进高效的数据上传到处理图像布局之间的转换以及生成米柏(mipmap),Texture 类为我们提供了在 Vulkan 示例中无缝集成纹理的手段。
如何操作...
创建图像需要一些关于它的基本信息,例如类型(1D、2D、3D)、大小、格式(RGBA、BGRA 等)、米柏级别数量、层数(立方体贴图的表面)、以及其他一些信息:
VkFormat format; // Image format
VkExtents extents; // Image size
uint32_t mipLevels; // Number of mip levels
uint32_t layerCount; // Number of layers (sides of cubemap)
const VkImageCreateInfo imageInfo = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.flags = 0, // optional
.imageType = VK_IMAGE_TYPE_2D, // 1D, 2D, 3D
.format = format,
.extent = extents,
.mipLevels = mipLevels,
.arrayLayers = layerCount,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
};
以下结构告诉 VMA,该图像将是一个仅设备的图像:
const VmaAllocationCreateInfo allocCreateInfo = {
.flags = VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT,
.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE,
.priority = 1.0f,
};
结果图像的句柄将存储在 image 中:
VkImage image = VK_NULL_HANDLE;
VK_CHECK(vmaCreateImage(vmaAllocator_, &imageInfo,
&allocCreateInfo, &image,
&vmaAllocation_, nullptr));
下一步是可选的,但有助于调试或优化代码:
VmaAllocationInfo allocationInfo;
vmaGetAllocationInfo(vmaAllocator_, vmaAllocation_,
&allocationInfo);
本食谱仅向您展示了如何在 Vulkan 中创建图像,但没有展示如何将其数据上传。将数据上传到图像就像上传到缓冲区一样。
创建图像视图
图像视图提供了一种以大小、位置和格式解释图像的方法,除了它们的布局之外,布局需要显式转换并使用图像屏障进行转换。在本食谱中,您将学习如何在 Vulkan 中创建图像视图对象。
准备工作
在仓库中,图像视图由 VulkanCore::Texture 类存储和管理。
如何操作…
创建图像视图很简单;您只需要该图像的句柄以及您想要表示的图像区域:
VkDevice device; // Valid Vulkan Device
VkImage image; // Valid Image object
VkFormat format;
uint32_t numMipLevels; // Number of mip levels
uint32_t layers; // Number of layers (cubemap faces)
const VkImageViewCreateInfo imageViewInfo = {
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image,
.viewType =
VK_IMAGE_VIEW_TYPE_2D, // 1D, 2D, 3D, Cubemap
// and arrays
.format = format,
.components =
{
.r = VK_COMPONENT_SWIZZLE_IDENTITY,
.g = VK_COMPONENT_SWIZZLE_IDENTITY,
.b = VK_COMPONENT_SWIZZLE_IDENTITY,
.a = VK_COMPONENT_SWIZZLE_IDENTITY,
},
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = numMipLevels,
.baseArrayLayer = 0,
.layerCount = layers,
}};
VkImageView imageView{VK_NULL_HANDLE};
VK_CHECK(vkCreateImageView(device, &imageViewInfo,
nullptr, &imageView));
没有图像视图,着色器无法使用纹理。即使用作颜色附件,图像也需要图像视图。
创建采样器
Vulkan 中的采样器超越了简单对象;它是着色器执行和图像数据之间的重要桥梁。除了插值之外,它还控制过滤、寻址模式和米级映射。过滤器指定了纹理元素之间的插值,而寻址模式控制坐标如何映射到图像范围。各向异性过滤进一步增强了采样精度。米级映射,即下采样图像级别的金字塔,是采样器管理的另一个方面。本质上,创建采样器涉及协调这些属性,以无缝地协调图像数据和着色器的复杂性。在本食谱中,您将学习如何在 Vulkan 中创建采样器对象。
准备工作
采样器由仓库中的 VulkanCore::Sampler 类实现。
如何操作…
采样器的属性定义了图像在管道中的解释方式,通常在着色器中。过程很简单 - 实例化一个 VkSamplerCreateInfo 结构,并调用 vkCreateSampler:
VkDevice device; // Valid Vulkan Device
VkFilter minFilter;
VkFilter maxFilter;
float maxLod; // Max mip level
const VkSamplerCreateInfo samplerInfo = {
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = minFilter,
.minFilter = magFilter,
.mipmapMode = maxLod > 0
? VK_SAMPLER_MIPMAP_MODE_LINEAR
: VK_SAMPLER_MIPMAP_MODE_NEAREST,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.mipLodBias = 0,
.anisotropyEnable = VK_FALSE,
.minLod = 0,
.maxLod = maxLod,
};
VkSampler sampler{VK_NULL_HANDLE};
VK_CHECK(vkCreateSampler(device, &samplerInfo, nullptr,
&sampler));
采样器是 Vulkan 中创建的最简单的对象之一,也是最容易理解的,因为它描述了非常常见的计算机图形概念。
提供着色器数据
从您的应用程序提供将在着色器中使用的数据是 Vulkan 最复杂的部分之一,需要完成多个步骤,这些步骤需要按正确的顺序(以及正确的参数)完成。在本食谱中,通过许多较小的食谱,您将学习如何提供用于着色器的数据,例如纹理、缓冲区和采样器。
准备工作
使用 layout 关键字以及 set 和 binding 限定符指定着色器消耗的资源:
layout(set = 0, binding=0) uniform Transforms
{
mat4 model;
mat4 view;
mat4 projection;
} MVP;
每个资源都由一个绑定表示。一组是一组绑定的集合。一个绑定不一定只代表一个资源;它也可以代表同一类型的资源数组。
如何操作…
将资源作为着色器的输入是一个多步骤的过程,涉及以下步骤:
-
使用描述符集布局指定集合及其绑定。此步骤不会将实际资源与集合/绑定关联。它只是指定了集合中绑定数量和类型。
-
构建管道布局,它描述了在管道中将使用哪些集合。
-
创建一个描述符池,它将提供描述符集的实例。描述符池包含一个列表,列出了它可以按绑定类型(纹理、采样器、着色器存储缓冲区(SSBO)、统一缓冲区)提供的绑定数量。
-
使用
vkAllocateDescriptorSets从池中分配描述符集。 -
使用
vkUpdateDescriptorSets将资源绑定到绑定。在这一步中,我们将一个实际资源(一个缓冲区、一个纹理等)与一个绑定关联。 -
在渲染期间使用
vkCmdBindDescriptorSet将描述符集及其绑定绑定到管道。这一步使得在前面步骤中绑定到其集/绑定的资源对当前管道中的着色器可用。
下一个教程将展示如何执行这些步骤中的每一个。
使用描述符集布局指定描述符集
考虑以下 GLSL 代码,它指定了几个资源:
struct Vertex {
vec3 pos;
vec2 uv;
vec3 normal;
};
layout(set = 0, binding=0) uniform Transforms
{
mat4 model;
mat4 view;
mat4 projection;
} MVP;
layout(set = 1, binding = 0) uniform texture2D textures[];
layout(set = 1, binding = 1) uniform sampler samplers[];
layout(set = 2, binding = 0) readonly buffer VertexBuffer
{
Vertex vertices[];
} vertexBuffer;
代码需要三个集合(0、1 和 2),因此我们需要创建三个描述符集布局。在本教程中,你将学习如何为前面的代码创建一个描述符集布局。
准备工作
描述符集和绑定由存储库中的 VulkanCore::Pipeline 类创建、存储和管理。在 Vulkan 中,描述符集充当一个容器,用于存储资源,如缓冲区、纹理和采样器,以便由着色器使用。绑定指的是将这些描述符集与特定着色器阶段关联起来的过程,在渲染过程中实现着色器和资源之间的无缝交互。这些描述符集作为资源无缝绑定到着色器阶段的网关,协调数据和着色器执行之间的和谐。为了促进这种协同作用,该类简化了描述符集的创建和管理,并辅以在 Vulkan 渲染管道中高效绑定资源的方法。
如何操作…
描述符集布局使用 vkDescriptorSetLayout 结构声明其绑定(数量和类型)。每个绑定使用 vkDescriptorSetLayoutBinding 结构的实例进行描述。创建前面代码所需的描述符集布局的 Vulkan 结构之间的关系在 图 2**.11 中显示:

图 2.11 – 展示 GLSL 着色器的描述符集布局配置
以下代码展示了如何为集合 1 指定两个绑定,这些绑定存储在一个绑定向量中:
constexpr uint32_t kMaxBindings = 1000;
const VkDescriptorSetLayoutBinding texBinding = {
.binding = 0,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.descriptorCount = kMaxBindings,
.stageFlags = VK_SHADER_STAGE_VERTEX_BIT,
};
const VkDescriptorSetLayoutBinding samplerBinding = {
.binding = 1,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER,
.descriptorCount = kMaxBindings,
.stageFlags = VK_SHADER_STAGE_VERTEX_BIT,
};
struct SetDescriptor {
uint32_t set_;
std::vector<VkDescriptorSetLayoutBinding> bindings_;
};
std::vector<SetDescriptor> sets(1);
sets[0].set_ = 1;
sets[0].bindings_.push_back(texBinding);
sets[0].bindings_.push_back(samplerBinding);
由于每个绑定描述一个向量,而 VkDescriptorSetLayoutBinding 结构需要描述符的数量,所以我们使用了一个较大的数字,希望它能容纳数组中所有需要的元素。绑定向量存储在一个结构中,该结构描述了一个带有其编号和所有绑定的集。这个向量将用于创建描述符集布局:
constexpr VkDescriptorBindingFlags flagsToEnable =
VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT |
VK_DESCRIPTOR_BINDING_UPDATE_UNUSED_WHILE_PENDING_BIT;
for (size_t setIndex = 0;
const auto& set : sets) {
std::vector<VkDescriptorBindingFlags> bindFlags(
set.bindings_.size(), flagsToEnable);
const VkDescriptorSetLayoutBindingFlagsCreateInfo
extendedInfo{
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO,
.pNext = nullptr,
.bindingCount = static_cast<uint32_t>(
set.bindings_.size()),
.pBindingFlags = bindFlags.data(),
};
const VkDescriptorSetLayoutCreateInfo dslci = {
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.pNext = &extendedInfo,
.flags =
VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT_EXT,
.bindingCount =
static_cast<uint32_t>(set.bindings_.size()),
.pBindings = set.bindings_.data(),
};
VkDescriptorSetLayout descSetLayout{VK_NULL_HANDLE};
VK_CHECK(vkCreateDescriptorSetLayout(
context_->device(), &dslci, nullptr,
&descSetLayout));
}
每个集合都需要自己的描述符集布局,并且前一个过程需要为每个集合重复进行。描述符集布局需要被存储起来,以便将来可以引用。
使用推送常量将数据传递给着色器
推送常量是向着色器传递数据的另一种方式。虽然这是一种非常高效且简单的方法,但推送常量在大小上非常有限,Vulkan 规范只保证有 128 字节。
这个菜谱将向你展示如何通过推送常量将少量数据从你的应用程序传递到着色器,用于一个简单的着色器。
准备工作
推送常量由VulkanCore::Pipeline类存储和管理。
如何操作…
推送常量直接记录在命令缓冲区上,并且不会受到其他资源存在的相同同步问题的困扰。它们在着色器中如下声明,每个着色器有一个最大块:
layout (push_constant) uniform Transforms {
mat4 model;
} PushConstants;
推送的数据必须分割成着色器阶段。其中一部分可以被分配到不同的着色器阶段,或者分配到单个阶段。重要的是,数据量不能超过可用于推送常量的总数量。这个限制在VkPhysicalDeviceLimits::maxPushConstantsSize中提供。
在使用推送常量之前,我们需要指定每个着色器阶段使用多少字节:
const VkPushConstantRange range = {
.stageFlags = VK_SHADER_STAGE_VERTEX_BIT,
.offset = 0,
.size = 64,
};
std::vector<VkPushConstantRange> pushConsts;
pushConsts.push_back(range);
代码表明,命令缓冲区中记录的推送常量数据的前 64 个字节(一个 4x4 浮点矩阵的大小)将被顶点着色器使用。这个结构将在下一个菜谱中用于创建管道布局对象。
创建管道布局
管道布局是 Vulkan 中的一个对象,需要由应用程序创建和销毁。布局是通过定义绑定和集合的结构的结构体来指定的。在这个菜谱中,你将学习如何创建管道布局。
准备工作
在存储库中,VulkanCore::Pipeline类会根据应用程序使用VulkanCore::Pipeline::SetDescriptor结构体提供的信息自动创建一个VkPipelineLayoutCreateInfo实例。
如何操作…
在手头有了所有集合的描述符集布局和推送常量信息后,下一步是创建管道布局:
std::vector<VkDescriptoSetLayout> descLayouts;
const VkPipelineLayoutCreateInfo pipelineLayoutInfo = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
.setLayoutCount = (uint32_t)descLayouts.size(),
.pSetLayouts = descLayouts.data(),
.pushConstantRangeCount =
!pushConsts.empty()
? static_cast<uint32_t>(pushConsts.size())
: 0,
.pPushConstantRanges = !pushConsts.empty()
? pushConsts.data()
: nullptr,
};
VkPipelineLayout pipelineLayout{VK_NULL_HANDLE};
VK_CHECK(vkCreatePipelineLayout(context_->device(),
&pipelineLayoutInfo,
nullptr,
&pipelineLayout));
一旦你手头有了描述符集布局并且知道如何在你的应用程序中使用推送常量,创建管道布局就很简单了。
创建描述符池
描述符池包含它可以提供的最大描述符数量(从其中分配),按绑定类型分组。例如,如果同一集合的两个绑定每个都需要一个图像,描述符池就必须提供至少两个描述符。在这个菜谱中,你将学习如何创建描述符池。
准备工作
描述符池是在VulkanCore::Pipeline::initDescriptorPool()方法中分配的。
如何操作…
创建描述符池很简单。我们需要的只是一个绑定类型的列表和为每个类型分配的最大资源数量:
constexpr uint32_t swapchainImages = 3;
std::vector<VkDescriptorPoolSize> poolSizes;
poolSizes.emplace_back(VkDescriptorPoolSize{
VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
swapchainImages* kMaxBindings});
poolSizes.emplace_back(VkDescriptorPoolSize{
VK_DESCRIPTOR_TYPE_SAMPLER,
swapchainImages* kMaxBindings});
由于我们根据交换链图像的数量复制资源以避免 CPU 和 GPU 之间的数据竞争,我们将请求的绑定数量(kMaxBindings = 1000)乘以交换链图像的数量:
const VkDescriptorPoolCreateInfo descriptorPoolInfo = {
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags =
VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT |
VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT,
.maxSets = MAX_DESCRIPTOR_SETS,
.poolSizeCount =
static_cast<uint32_t>(poolSizes.size()),
.pPoolSizes = poolSizes.data(),
};
VkDescriptorPool descriptorPool{VK_NULL_HANDLE};
VK_CHECK(vkCreateDescriptorPool(context_->device(),
&descriptorPoolInfo,
nullptr,
&descriptorPool));
注意不要创建过大的池。实现高性能应用程序意味着不要分配比您需要的更多的资源。
分配描述符集
一旦创建了描述符布局和描述符池,在您可以使用它们之前,您需要分配一个描述符集,这是一个由描述符布局描述的布局的集合实例。在本教程中,您将学习如何分配描述符集。
准备工作
描述符集的分配是在 VulkanCore::Pipeline::allocateDescriptors() 方法中完成的。在这里,开发者定义了所需的描述符集数量,以及每个集合的绑定计数。随后的 bindDescriptorSets() 方法将描述符编织到命令缓冲区中,为着色器执行做准备。
如何做这件事...
分配描述符集(或多个描述符集)很容易。您需要填充 VkDescriptorSetAllocateInfo 结构并调用 vkAllocateDescriptorSets:
VkDescriptorSetAllocateInfo allocInfo = {
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = descriptorPool,
.descriptorSetCount = 1,
.pSetLayouts = &descSetLayout,
};
VkDescriptorSet descriptorSet{VK_NULL_HANDLE};
VK_CHECK(vkAllocateDescriptorSets(context_->device(),
&allocInfo,
&descriptorSet));
当使用多个资源副本以避免竞争条件时,有两种方法:
-
为每个资源分配一个描述符集。换句话说,为资源的每个副本调用前面的代码一次。
-
创建一个描述符集,并在需要渲染时更新它。
在渲染期间更新描述符集
一旦分配了描述符集,它就不会与任何资源相关联。这种关联必须发生一次(如果您的描述符集是不可变的)或者每次您需要将不同的资源绑定到描述符集时。在本教程中,您将学习如何在渲染期间和设置管道及其布局之后更新描述符集。
准备工作
在存储库中,VulkanCore::Pipeline 提供了更新不同类型资源的方法,因为每个绑定只能与一种类型的资源(图像、采样器或缓冲区)相关联:updateSamplersDescriptorSets()、updateTexturesDescriptorSets() 和 updateBuffersDescriptorSets()。
如何做这件事...
使用 vkUpdateDescriptorSets 函数将资源与描述符集相关联。每个对 vkUpdateDescriptorSets 的调用可以更新一个或多个集合的一个或多个绑定。在更新描述符集之前,让我们看看如何更新一个绑定。
你可以将纹理、纹理数组、采样器、采样器数组、缓冲区或缓冲区数组与一个绑定关联。要关联图像或采样器,使用VkDescriptorImageInfo结构。要关联缓冲区,使用VkDescriptorBufferInfo结构。一旦实例化了一个或多个这些结构,使用VkWriteDescriptorSet结构将它们全部绑定到一个绑定上。表示数组的绑定使用VkDescriptor*Info的向量更新。
-
考虑以下展示的着色器代码中声明的绑定:
layout(set = 1, binding = 0) uniform texture2D textures[]; layout(set = 1, binding = 1) uniform sampler samplers[]; layout(set = 2, binding = 0) readonly buffer VertexBuffer { Vertex vertices[]; } vertexBuffer; -
为了更新
textures[]数组,我们需要创建两个VkDescriptorImageInfo实例并将它们记录在第一个VkWriteDescriptorSet结构中:VkImageView imageViews[2]; // Valid Image View objects VkDescriptorImageInfo texInfos[] = { VkDescriptorImageInfo{ .imageView = imageViews[0], .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, }, VkDescriptorImageInfo{ .imageView = imageViews[1], .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, }, }; const VkWriteDescriptorSet texWriteDescSet = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = 1, ee, .dstArrayElement = 0, .descriptorCount = 2, .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, .pImageInfo = &texInfos, .pBufferInfo = nullptr, }; -
两个图像视图将被绑定到集合 1(
.dstSet = 1)和绑定 0(.dstBinding = 0)作为数组的元素 0 和 1。如果你需要将更多对象绑定到数组,你只需要更多的VkDescriptorImageInfo实例。当前绑定中绑定的对象数量由结构的descriptorCount成员指定。对于采样器对象的过程类似:
VkSampler sampler[2]; // Valid Sampler object VkDescriptorImageInfo samplerInfos[] = { VkDescriptorImageInfo{ .sampler = sampler[0], }, VkDescriptorImageInfo{ .sampler = sampler[1], }, }; const VkWriteDescriptorSet samplerWriteDescSet = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = 1, .dstBinding = 1, .dstArrayElement = 0, .descriptorCount = 2, .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, .pImageInfo = &samplerInfos, .pBufferInfo = nullptr, };这次,我们将采样器对象绑定到集合 1,绑定 1。缓冲区使用
VkDescriptorBufferInfo结构绑定:VkBuffer buffer; // Valid Buffer object VkDeviceSize bufferLength; // Range of the buffer const VkDescriptorBufferInfo bufferInfo = { .buffer = buffer, .offset = 0, .range = bufferLength, }; const VkWriteDescriptorSet bufferWriteDescSet = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = 2, .dstBinding = 0, .dstArrayElement = 0, .descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, .pImageInfo = nullptr, .pBufferInfo = &bufferInfo, };除了将
bufferInfo变量的地址存储到VkWriteDescriptorSet的.pBufferInfo成员外,我们还将一个缓冲区(.descriptorCount = 1)绑定到集合 2(.dstSet = 2)并将0(.dstBinding = 0)绑定。 -
最后一步是将所有
VkWriteDescriptorSet实例存储在一个向量中并调用vkUpdateDescriptorSets:VkDevice device; // Valid Vulkan Device std::vector<VkWriteDescriptorSet> writeDescSets; writeDescSets.push_back(texWriteDescSet); writeDescSets.push_back(samplerWriteDescSet); writeDescSets.push_back(bufferWriteDescSet); vkUpdateDescriptorSets(device, static_cast<uint32_t>(writeDescSets.size()), writeDescSets.data(), 0, nullptr);
将这项任务封装起来是避免重复和忘记更新过程中某个步骤引入的错误的最佳方式。
将资源传递给着色器(绑定描述符集)
在渲染过程中,我们需要绑定在绘制调用期间希望使用的描述符集。
准备工作
使用VulkanCore::Pipeline::bindDescriptorSets()方法绑定集合。
如何做到这一点...
为了绑定用于渲染的描述符集,我们需要调用vkCmdBindDescriptorSets:
VkCommandBuffer commandBuffer; // Valid Command Buffer
VkPipelineLayout pipelineLayout; // Valid Pipeline layout
uint32_t set; // Set number
VkDescriptorSet descSet; // Valid Descriptor Set
vkCmdBindDescriptorSets(
commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout, set, 1u, &descSet, 0, nullptr);
现在我们已经成功绑定了用于渲染的描述符集,让我们将注意力转向图形管线另一个关键方面:更新推送常量。
在渲染过程中更新推送常量
在渲染过程中通过直接将它们的值记录到正在记录的命令缓冲区中来更新推送常量。
准备工作
使用VulkanCore::Pipeline::updatePushConstants()方法更新推送常量。
如何做到这一点...
一旦渲染完成,更新推送常量就很简单。你只需要调用vkCmdPushConstants:
VkCommandBuffer commandBuffer; // Valid Command Buffer
VkPipelineLayout pipelineLayout; // Valid Pipeline Layout
glm::vec4 mat; // Valid matrix
vkCmdPushConstants(commandBuffer, pipelineLayout,
VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(glm::vec4), &mat);
这个调用将mat的内容记录到命令缓冲区中,从偏移量 0 开始,并指示这些数据将由顶点着色器使用。
使用专用常量自定义着色器行为
着色器代码编译的过程一旦完成就会变得不可变。编译过程具有相当大的时间开销,通常在运行时被规避。即使是着色器的小幅调整也需要重新编译,从而导致创建新的着色器模块,甚至可能是一个新的管道——所有这些都涉及大量的资源密集型操作。
在 Vulkan 中,特殊化常量允许你在管道创建时指定着色器参数的常量值,而不是每次更改它们时都必须重新编译着色器。当你想多次重用相同的着色器并使用不同的常量值时,这特别有用。在本食谱中,我们将深入了解 Vulkan 中特殊化常量的实际应用,以创建更高效和灵活的着色器程序,允许你在无需资源密集型重新编译的情况下进行调整。
准备就绪
特殊化常量可通过 VulkanCore::Pipeline::GraphicsPipelineDescriptor 结构在存储库中访问。你需要为每个希望应用特殊化常量的着色器类型提供一个 VkSpecializationMapEntry 结构的向量。
如何实现...
特殊化常量使用 constant_id 标识符和指定常量 ID 的整数在 GLSL 中声明:
layout (VkSpecializationInfo structure that specifies the constant values and their IDs. You then pass this structure to the VkPipelineShaderStageCreateInfo structure when creating a pipeline:
const bool kUseShaderDebug = false;
const VkSpecializationMapEntry useShaderDebug = {
.constantID = 0, // 与 constant_id 标识符匹配
.offset = 0,
.size = sizeof(bool),
};
const VkSpecializationInfo vertexSpecializationInfo = {
.mapEntryCount = 1,
.pMapEntries = &useShaderDebug,
.dataSize = sizeof(bool),
.pData = &kUseShaderDebug,
};
const VkPipelineShaderStageCreateInfo shaderStageInfo = {
...
.pSpecializationInfo = &vertexSpecializationInfo,
};
Because specialization constants are real constants, branches that depend on them may be entirely removed during the final compilation of the shader. On the other hand, specialization constants should not be used to control parameters such as uniforms, as they are not as flexible and require to be known during the construction of the pipeline.
Implementing MDI and PVP
MDI and PVP are features of modern graphics APIs that allow for greater flexibility and efficiency in vertex processing.
MDI allows issuing multiple draw calls with a single command, each of which derives its parameters from a buffer stored in the device (hence the *indirect* term). This is particularly useful because those parameters can be modified in the GPU itself.
With PVP, each shader instance retrieves its vertex data based on its index and instance IDs instead of being initialized with the vertex’s attributes. This allows for flexibility because the vertex attributes and their format are not baked into the pipeline and can be changed solely based on the shader code.
In the first sub-recipe, we will focus on the implementation of **MDI**, demonstrating how this powerful tool can streamline your graphics operations by allowing multiple draw calls to be issued from a single command, with parameters that can be modified directly in the GPU. In the following sub-recipe, we will guide you through the process of setting up **PVP**, highlighting how the flexibility of this feature can enhance your shader code by enabling changes to vertex attributes without modifying the pipeline.
Implementing MDI
For using MDI, we store all mesh data belonging to the scene in one big buffer for all the meshes’ vertices and another one for the meshes’ indices, with the data for each mesh stored sequentially, as depicted in *Figure 2**.12*.
The drawing parameters are stored in an extra buffer. They must be stored sequentially, one for each mesh, although they don’t have to be provided in the same order as the meshes:

Figure 2.12 – MDI data layout
We will now learn how to implement MDI using the Vulkan API.
Getting ready
In the repository, we provide a utility function to decompose an `EngineCore::Model` object into multiple buffers suitable for an MDI implementation, called `EngineCore::convertModel2OneBuffer()`, located in `GLBLoader.cpp`.
How to do it…
Let’s begin by looking at the indirect draw parameters’ buffer.
The commands are stored following the same layout as the `VkDrawIndexedIndirectCommand` structure:
typedef struct VkDrawIndexedIndirectCommand {
uint32_t indexCount;
uint32_t instanceCount;
uint32_t firstIndex;
int32_t vertexOffset;
uint32_t firstInstance;
} VkDrawIndexedIndirectCommand;
`indexCount` specifies how many indices are part of this command and, in our case, is the number of indices for a mesh. One command reflects one mesh, so its `instanceCount` value is one. The `firstVertex` member is the index of the first index element in the buffer to use for this mesh, while `vertexOffset` points to the first vertex element in the buffer to use. An example with the correct offsets is shown in *Figure 2**.12*.
Once the vertex, index, and indirect commands buffers are bound, calling `vkCmdDrawIndexedIndirect` consists of providing the buffer with the indirect commands and an offset into the buffer. The rest is done by the device:
VkCommandBuffer commandBuffer; // 有效的命令缓冲区
VkBuffer indirectCmdBuffer; // 有效的缓冲区
// 间接命令
uint32_t meshCount; // 间接命令的数量
// 缓冲区
uint32_t offset = 0; // 间接命令中的偏移量
// 缓冲区
vkCmdDrawIndexedIndirect(
commandBuffer, indirectCmdBuffer, offset,
meshCount,
sizeof(VkDrawIndexedIndirectDrawCommand));
In this recipe, we learned how to utilize `vkCmdDrawIndexedIndirect`, a key function in Vulkan that allows for high-efficiency drawing.
Using PVP
The PVP technique allows vertex data and their attributes to be extracted from buffers with custom code instead of relying on the pipeline to provide them to vertex shaders.
Getting ready
We will use the following structures to perform the extraction of vertex data – the `Vertex` structure, which encodes the vertex’s position (`pos`), `normal`, UV coordinates (`uv`), and its material index (`material`):
struct Vertex {
vec3 pos;
vec3 normal;
vec2 uv;
int material;
};
We will also use a buffer object, referred to in the shader as `VertexBuffer`:
layout(set = 2, binding = 0) readonly buffer VertexBuffer
{
Vertex vertices[];
} vertexBuffer;
Next, we will learn how to use the `vertexBuffer` object to access vertex data.
How to do it…
The shader code used to access the vertex data looks like this:
void main() {
Vertex vertex = vertexBuffer.vertices[gl_VertexIndex];
}
Note that the vertex and its attributes are not declared as inputs to the shader. `gl_VertexIndex` is automatically computed and provided to the shader based on the draw call and the parameters recorded in the indirect command retrieved from the indirect command buffer.
Index and vertex buffers
Note that both the index and vertex buffers are still provided and bound to the pipeline before the draw call is issued. The index buffer must have the `VK_BUFFER_USAGE_INDEX_BUFFER_BIT` flag enabled for the technique to work.
Adding flexibility to the rendering pipeline using dynamic rendering
In this recipe, we will delve into the practical application of dynamic rendering in Vulkan to enhance the flexibility of the rendering pipeline. We will guide you through the process of creating pipelines without the need for render passes and framebuffers and discuss how to ensure synchronization. By the end of this section, you will have learned how to implement this feature in your projects, thereby simplifying your rendering process by eliminating the need for render passes and framebuffers and giving you more direct control over synchronization.
Getting ready
To enable the feature, we must have access to the `VK_KHR_get_physical_device_properties2` instance extension, instantiate a structure of type `VkPhysicalDeviceDynamicRenderingFeatures`, and set its `dynamicRendering` member to `true`:
const VkPhysicalDeviceDynamicRenderingFeatures dynamicRenderingFeatures = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DYNAMIC_RENDERING_FEATURES,
.dynamicRendering = VK_TRUE,
};
This structure needs to be plugged into the `VkDeviceCreateInfo::pNext` member when creating a Vulkan device:
const VkDeviceCreateInfo dci = {
.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
.pNext = &dynamicRenderingFeatures,
...
};
Having grasped the concept of enabling dynamic rendering, we will now move forward and explore its implementation using the Vulkan API.
How to do it…
Instead of creating render passes and framebuffers, we must call the `vkCmdBeginRendering` command and provide the attachments and their load and store operations using the `VkRenderingInfo` structure. Each attachment (colors, depth, and stencil) must be specified with instances of the `VkRenderingAttachmentInfo` structure. *Figure 2**.13* presents a diagram of the structure participating in a call to `vkCmdBeginRendering`:

Figure 2.13 – Dynamic rendering structure diagram
Any one of the attachments, `pColorAttachments`, `pDepthAttachment`, and `pStencilAttachment`, can be `null`. Shader output written to location `x` is written to the color attachment at `pColorAttachment[x]`.
Transferring resources between queue families
In this recipe, we will demonstrate how to transfer resources between queue families by uploading textures to a device from the CPU using a transfer queue and generating mip-level data in a graphics queue. Generating mip levels needs a graphics queue because it utilizes `vkCmdBlitImage`, supported only by graphics queues.
Getting ready
An example is provided in the repository in `chapter2/mainMultiDrawIndirect.cpp`, which uses the `EngineCore::AsyncDataUploader` class to perform texture upload and mipmap generation on different queues.
How to do it…
In the following diagram, we illustrate the procedure of uploading texture through a transfer queue, followed by the utilization of a graphics queue for mip generation:

Figure 2.14 – Recoding and submitting commands from different threads and transferring a resource between queues from different families
The process can be summarized as follows:
1. Record the commands to upload the texture to the device and add a barrier to release the texture from the transfer queue using the `VkDependencyInfo` and `VkImageMemoryBarrier2` structures, specifying the source queue family as the family of the transfer queue and the destination queue family as the family of the graphics queue.
2. Create a semaphore and use it to signal when the command buffer finishes, and attach it to the submission of the command buffer.
3. Create a command buffer for generating mip levels and add a barrier to acquire the texture from the transfer queue into the graphics queue using the `VkDependencyInfo` and `VkImageMemoryBarrier2` structures.
4. Attach the semaphore created in *step 2* to the `SubmitInfo` structure when submitting the command buffer for processing. The semaphore will be signaled when the first command buffer has completed, allowing the mip-level-generation command buffer to start.
Two auxiliary methods will help us create acquire and release barriers for a texture. They exist in the `VulkanCore::Texture` class. The first one creates an acquire barrier:
```
void Texture::addAcquireBarrier(
VkCommandBuffer cmdBuffer,
uint32_t srcQueueFamilyIndex,
uint32_t dstQueueFamilyIndex) {
VkImageMemoryBarrier2 acquireBarrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,
.dstStageMask =
VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT,
.dstAccessMask = VK_ACCESS_2_MEMORY_READ_BIT,
.srcQueueFamilyIndex = srcQueueFamilyIndex,
.dstQueueFamilyIndex = dstQueueFamilyIndex,
.image = image_,
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT,
0, mipLevels_, 0, 1},
};
VkDependencyInfo dependency_info{
.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
.imageMemoryBarrierCount = 1,
.pImageMemoryBarriers = &acquireBarrier,
};
vkCmdPipelineBarrier2(cmdBuffer, &dependency_info);
}
```cpp
Besides the command buffer, this function requires the indices of the source and destination family queues. It also assumes a few things, such as the subresource range spanning the entire image.
5. Another method records the release barrier:
```
void Texture::addReleaseBarrier(
VkCommandBuffer cmdBuffer,
uint32_t srcQueueFamilyIndex,
uint32_t dstQueueFamilyIndex) {
VkImageMemoryBarrier2 releaseBarrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,
.srcStageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.srcQueueFamilyIndex = srcQueueFamilyIndex,
.dstQueueFamilyIndex = dstQueueFamilyIndex,
.image = image_,
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT,
0, mipLevels_, 0, 1},
};
VkDependencyInfo dependency_info{
.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
.imageMemoryBarrierCount = 1,
.pImageMemoryBarriers = &releaseBarrier,
};
vkCmdPipelineBarrier2(cmdBuffer, &dependency_info);
}
```cpp
This method makes the same assumptions as the previous one. The main differences are the source and destination stages and access masks.
6. To perform the upload and mipmap generation, we create two instances of `VulkanCore::CommandQueueManager`, one for the transfer queue and another for the graphics queue:
```
auto transferQueueMgr =
context.createTransferCommandQueue(
1, 1, "transfer queue");
auto graphicsQueueMgr =
context.createGraphicsCommandQueue(
1, 1, "graphics queue");
```cpp
7. With valid `VulkanCore::Context` and `VulkanCore::Texture` instances in hand, we can upload the texture by retrieving a command buffer from the transfer family. We also create a staging buffer for transferring the texture data to device-local memory:
```
VulkanCore::Context context; // Valid Context
std::shared_ptr<VulkanCore::Texture>
texture; // Valid Texture
void* textureData; // Valid texture data
// Upload texture
auto textureUploadStagingBuffer =
context.createStagingBuffer(
texture->vkDeviceSize(),
VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
"texture upload staging buffer");
const auto commandBuffer =
transferQueueMgr.getCmdBufferToBegin();
texture->uploadOnly(commandBuffer,
textureUploadStagingBuffer.get(),
textureData);
texture->addReleaseBarrier(
commandBuffer,
transferQueueMgr.queueFamilyIndex(),
graphicsQueueMgr.queueFamilyIndex());
transferQueueMgr.endCmdBuffer(commandBuffer);
transferQueueMgr.disposeWhenSubmitCompletes(
std::move(textureUploadStagingBuffer));
```cpp
8. For submitting the command buffer for processing, we create a semaphore to synchronize the upload command buffer and the one used for generating mipmaps:
```
VkSemaphore graphicsSemaphore;
const VkSemaphoreCreateInfo semaphoreInfo{
.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,
};
VK_CHECK(vkCreateSemaphore(context.device(),
&semaphoreInfo, nullptr,
&graphicsSemaphore));
VkPipelineStageFlags flags =
VK_PIPELINE_STAGE_TRANSFER_BIT;
auto submitInfo =
context.swapchain()->createSubmitInfo(
&commandBuffer, &flags, false, false);
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &graphicsSemaphore;
transferQueueMgr.submit(&submitInfo);
```cpp
9. The next step is to acquire a new command buffer from the graphics queue family for generating mipmaps. We also create an acquire barrier and reuse the semaphore from the previous command buffer submission:
```
// Generate mip levels
auto commandBuffer =
graphicsQueueMgr.getCmdBufferToBegin();
texture->addAcquireBarrier(
commandBuffer,
transferCommandQueueMgr_.queueFamilyIndex(),
graphicsQueueMgr.queueFamilyIndex());
texture->generateMips(commandBuffer);
graphicsQueueMgr.endCmdBuffer(commandBuffer);
VkPipelineStageFlags flags =
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
auto submitInfo =
context_.swapchain()->createSubmitInfo(
&commandBuffer, &flags, false, false);
submitInfo.pWaitSemaphores = &graphicsSemaphore;
submitInfo.waitSemaphoreCount = 1;
```cpp
In this chapter, we have navigated the complex landscape of advanced Vulkan programming, building upon the foundational concepts introduced earlier. Our journey encompassed a diverse range of topics, each contributing crucial insights to the realm of high-performance graphics applications. From mastering Vulkan’s intricate memory model and efficient allocation techniques to harnessing the power of the VMA library, we’ve equipped ourselves with the tools to optimize memory management. We explored the creation and manipulation of buffers and images, uncovering strategies for seamless data uploads, staging buffers, and ring-buffer implementations that circumvent data races. The utilization of pipeline barriers to synchronize data access was demystified, while techniques for rendering pipelines, shader customization via specialization constants, and cutting-edge rendering methodologies such as PVP and MDI were embraced. Additionally, we ventured into dynamic rendering approaches without relying on render passes and addressed the intricacies of resource handling across multiple threads and queues. With these profound understandings, you are primed to create graphics applications that harmonize technical prowess with artistic vision using the Vulkan API.
第三章:实现 GPU 驱动渲染
在本章中,我们将深入探讨专门用于 GPU 渲染的几何操作细节。传统方法在许多场景中严重依赖 CPU 执行各种任务,这可能会成为瓶颈。我们的目标是解放您的渲染技术,摆脱此类限制。我们的目标是让 GPU 处于驾驶员的位置,通过利用其并行处理能力来确保高效的处理。我们将揭示从传统 CPU 空间直接从着色器(如顶点或片段着色器)生成和绘制线条的技术。这不仅提高了效率,还开辟了新的创意领域。更进一步,我们将演示如何将这种新颖的线绘制功能扩展到从着色器显示数字。这种能力为实时显示和反馈铺平了道路,无需在 GPU 和 CPU 之间切换。然后,我们将目光转向一个更复杂的话题——在 GPU 上渲染文本。通过采用符号距离场(SDF)方法,我们将指导您在 GPU 上实现更平滑、更通用的文本渲染。
最后,我们将解决渲染中的经典挑战之一:视锥剔除。视锥剔除涉及避免渲染位于摄像机视野(FOV)之外的对象。视锥指的是通过摄像机可见的空间体积。剔除意味着丢弃或忽略位于此视锥之外的对象,因此它们不会被处理用于渲染。与传统方法不同,我们将向您展示如何直接使用计算着色器在 GPU 上实现此功能,确保位于摄像机视野之外的对象不会消耗宝贵的 GPU 资源。到本章结束时,您将全面掌握 GPU 驱动渲染,使您能够充分利用 GPU 的能力,并简化您的渲染任务。
在本章中,我们将涵盖以下配方:
-
实现由 GPU 驱动的线渲染
-
将线绘制技术扩展到从着色器渲染文本值
-
使用 SDF 绘制文本
-
使用计算着色器进行视锥剔除
技术要求
对于本章,您需要确保已安装 VS 2022 以及 Vulkan SDK。对 C++ 编程语言的基本熟悉程度以及对 OpenGL 或任何其他图形 API 的理解将很有帮助。请重新查看 技术要求 部分的 第一章**,Vulkan 核心概念,以获取有关设置和构建本章可执行文件的详细信息。本章包含多个配方,可以使用以下可执行文件启动:
-
Chapter03_GPU_Lines.exe -
Chapter03_GPU_Text.exe -
Chapter03_GPU_Text_SDF.exe -
Chapter03_GPU_Culling.exe
实现由 GPU 驱动的线渲染
在这个菜谱中,你将学习一种技术,它允许直接从着色器(如顶点或片段着色器)绘制线条。在许多图形应用程序中,当人们希望直接且高效地使用着色器的固有功能(尤其是顶点或片段着色器)来绘制线条时,会面临挑战。为了解决这个问题,我们的菜谱深入研究了针对这一特定目的量身定制的专业技术。我们将展示一个与各种管线和渲染通道无缝集成的菜谱。通过我们的方法,数据(无论是顶点还是颜色)被存储在设备缓冲区中,确保了流程的流畅。此过程的最终结果是,在后续的通道中利用这些累积的数据,然后巧妙地将这些线条渲染到帧缓冲区中。最终,你将拥有一种强大且高效的方法,可以直接使用着色器绘制线条。
准备工作
在深入菜谱之前,你应该确保已经安装了 VS 2022,并且能够按照第一章**,Vulkan 核心概念中提供的步骤构建存储库。
你应该能够从 VS 2022 中启动名为Chapter03_GPU_Lines.exe的可执行文件。
本菜谱中涵盖的代码可以在存储库中找到,位于chapter3/mainGPULines.cpp和chapter3/resources/shaders目录下的gpuLines.frag、gpuLines.vert、gpuLinesDraw.frag和gpuLinesDraw.vert文件中。
如何做到这一点...
在本节中,我们将指导你通过直接从着色器绘制线条并将其集成到最终渲染帧中的完整过程,使用 GPU 驱动的技术。通过利用专用设备缓冲区和精心安排的渲染通道,这项技术允许实时视觉反馈和流畅的图形过程。在本指南结束时,你将拥有一个强大的机制,可以高效地从 GPU 直接渲染线条,同时 CPU 的参与度最小化。
这个想法依赖于拥有一个设备缓冲区,它作为线条和用于渲染这些线条的间接绘制结构的参数的存储库。在所有渲染通道完成后,将执行一个额外的渲染通道来绘制缓冲区中的线条。以下是执行此操作的步骤:
-
第一步是创建一个缓冲区,它不仅包含线条数据,还包含用于确定缓冲区中可以容纳多少线条以及其他用于最终间接绘制调用的参数的元数据。以下代码片段定义了 C++中的缓冲区结构,其中
GPULineBuffer是用于存储/绘制线条的缓冲区结构:constexpr uint32_t kNumLines = 65'536; struct Line { glm::vec4 v0_; glm::vec4 color0_; glm::vec4 v1_; glm::vec4 color1_; }; struct Header { uint32_t maxNumlines_; uint32_t padding0 = 0u; uint32_t padding1 = 0u; uint32_t padding2 = 0u; VkDrawIndirectCommand cmd_; }; struct GPULineBuffer { Header header_; Line lines_[kNumLines]; };此结构定义了我们用来存储 GPU 生成的行的设备缓冲区,并且可以存储多达 65,536 行,以及
Header部分中的数据。图 3**.1显示了 GPU 看到的缓冲区布局:

图 3.1 – GPU 行缓冲区结构
此缓冲区是用以下使用位创建的:
-
VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT -
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT -
VK_BUFFER_USAGE_TRANSFER_DST_BIT
此缓冲区应可供所有您希望从中绘制/生成线条的渲染通道使用:
std::shared_ptr<VulkanCore::Buffer> gpuLineBuffer;
gpuLineBuffer = context.createBuffer(
kGPULinesBufferSize,
VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT |
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
VK_BUFFER_USAGE_TRANSFER_DST_BIT,
static_cast<VmaMemoryUsage>(
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT));
-
提供的代码片段使用 Vulkan API 初始化了一个
gpuLineBuffer缓冲区。这个缓冲区是通过context.createBuffer方法创建的,它被赋予了一个指定的尺寸(kGPULinesBufferSize),并用于多个目的,包括间接绘制命令、着色器数据存储以及作为缓冲区到缓冲区复制操作的目的地。此外,缓冲区的内存被设置为位于 GPU 上,确保快速访问:struct Line { vec3 v0; vec4 c0; vec3 v1; vec4 c1; }; struct VkDrawIndirectCommand { uint vertexCount; uint instanceCount; uint firstVertex; uint firstInstance; }; layout(set = 4, binding = 0) buffer GPULinesBuffer { uint size; uint row; uint pad1; uint pad2; VkDrawIndirectCommand cmd; Line lines[]; } lineBuffer; void addLine(vec3 v0, vec3 v1, vec4 c0, vec4 c1) { const uint idx = atomicAdd(lineBuffer.cmd.instanceCount, 1); if (idx >= lineBuffer.size) { atomicMin(lineBuffer.cmd.instanceCount, lineBuffer.size); return; } lineBuffer.lines[idx].v0 = v0; lineBuffer.lines[idx].v1 = v1; lineBuffer.lines[idx].c0 = c0; lineBuffer.lines[idx].c1 = c1; }函数首先通过
atomicAdd获取已存储在缓冲区中的线条数量,以检查存储线条信息的下一个可用索引。如果函数返回的索引大于缓冲区中可以容纳的最大线条数,则函数提前返回,并且是一个无操作。否则,线条数据将被存储在缓冲区中。 -
由于需要使用之前通道的数据,因此线条的渲染是在所有其他通道处理完成后通过额外的渲染通道完成的。渲染线条的顶点着色器代码如下片段所示:
#version 460 #extension GL_EXT_nonuniform_qualifier : require struct Line { vec3 v0; vec4 c0; vec3 v1; vec4 c1; }; struct VkDrawIndirectCommand { uint vertexCount; uint instanceCount; uint firstVertex; uint firstInstance; }; layout(set = 1, binding = 0) readonly buffer GPULinesBuffer { Line lines[]; } lineBuffer; layout (location = 0) out vec4 outColor; void main() { if (gl_VertexIndex == 0) { vec3 vertex = lineBuffer.lines[gl_InstanceIndex].v0; gl_Position = vec4(vertex, 1.0).xyww; outColor = lineBuffer.lines[gl_InstanceIndex].c0; } else { vec3 vertex = lineBuffer.lines[gl_InstanceIndex].v1; gl_Position = vec4(vertex, 1.0).xyww; outColor = lineBuffer.lines[gl_InstanceIndex].c1; } }在前面的代码中,引入了两个结构体:
Line和VkDrawIndirectCommand。Line结构体表示一个彩色线段,由两个 3D 端点(v0和v1)及其相应的颜色(c0和c1)定义。VkDrawIndirectCommand结构体表示一个用于间接绘制的 Vulkan 命令。着色器还建立了一个包含Line结构体数组的GPULinesBuffer缓冲区。在主函数中,根据gl_VertexIndex的值,着色器选择线实例的起始或结束点,并将相应的颜色分配给outColor。此外,请注意,在这个着色器中,我们只定义了GPULinesBuffer结构体,而没有定义头部结构体。这是因为对于绘制线条,我们在偏移量处绑定缓冲区,从而绕过了在着色器中定义Header段的需要。片段着色器仅输出通过顶点着色器提供的颜色:
#version 460 layout(location = 0) in vec4 inColor; layout(location = 0) out vec4 outColor; void main() { outColor = inColor; }在渲染线条之前,我们需要确保之前的步骤已经完成缓冲区的写入,因此我们发出一个缓冲区屏障:
const VkBufferMemoryBarrier bufferBarrier = { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, .dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .buffer = gpuLineBuffer->vkBuffer(), .offset = 0, .size = VK_WHOLE_SIZE, }; vkCmdPipelineBarrier( commandBuffer, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, 0, 0, nullptr, 1, &bufferBarrier, 0, nullptr);之后,我们发出一个间接绘制命令,其参数直接来自缓冲区本身。我们巧妙地将之前通道中存储在缓冲区中的线条数量存储在
VkDrawIndirectCommand:: instanceCount中:vkCmdDrawIndirect(commandBuffer, gpuLineBuffer->vkBuffer(), sizeof(uint32_t) * 4, 1, sizeof(VkDrawIndirectCommand)); -
最后一步是清除缓冲区,这是清除缓冲区中线条数量的必要步骤(
VkDrawIndirectCommand:: instanceCount)。在清除缓冲区之前,我们必须确保 GPU 已经完成了线条的绘制,我们可以通过发出另一个缓冲区屏障来验证这一点:const VkBufferMemoryBarrier bufferBarrierClear = { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT, .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .buffer = gpuLineBuffer->vkBuffer(), .offset = 0, .size = VK_WHOLE_SIZE, }; // Reset the number of lines in the buffer vkCmdFillBuffer(commandBuffer, gpuLineBuffer->vkBuffer(), sizeof(uint32_t) * 5, sizeof(uint32_t), 0);
在本食谱中,我们介绍了一种从着色器中直接渲染线条的强大技术。这种方法的应用范围广泛,并为更高级的图形输出奠定了基础,将您在 Vulkan GPU 编程领域的技能提升到一个更高的水平。
将线绘制技术扩展到从着色器渲染文本值
在我们之前的探索基础上继续,我们开发了直接从着色器中绘制线条的能力,我们的下一个挑战是进一步细化这一能力,以方便渲染文本值。从上一食谱中建立的基础概念中汲取灵感,我们旨在实施一种方法,允许将数值转换为线段,就像数字 LCD 显示器一样。通过这样做,我们不仅赋予了裸数值以生命力,使其更加视觉化和可解释,而且我们还利用了着色器的强大功能和效率来创建这些表示。完成之后,您将配备一套强大的工具集,使您能够从着色器中渲染清晰、可缩放且视觉上吸引人的文本数据。
准备工作
在深入到食谱之前,您应该确保已经安装了 VS 2022,并且能够按照第一章**,Vulkan 核心概念.中提供的步骤构建仓库。
您应该能够从 VS 2022 中启动名为Chapter03_GPU_Text.exe的可执行文件。
由于这个食谱高度基于之前的食谱,我们建议首先阅读关于 GPU 线绘制的上一食谱。本食谱中涵盖的代码可以在仓库中找到,位于chapter3/mainGPUText.cpp和chapter3/resources/shaders目录下,包括gpuText.frag、gpuText.vert、gpuTextDraw.frag和gpuTexDraw.vert文件。
如何操作…
策略是将数字分解为段(类似于 LCD 段显示器)并通过为每个数字绘制线条来打印值。上一食谱涵盖了如何存储和绘制线条;本食谱在此基础上打印数字。由于在本食谱中我们需要绘制数字,因此我们需要解析数字并将它们分解为线条:
- 首先,我们需要使用一种策略来仅用线条表示数字。我们选择实现一个简单的 7 段方法,可以用来显示从 0 到 9 的所有数字以及负号。我们还添加了一个额外的段来表示小数分隔符。图 3**.2显示了七个段以及小数分隔符和它们在着色器代码中使用的索引:

图 3.2 – 用于表示从 0 到 9 的所有数字以及小数分隔符和负号的段
-
除了上一食谱中定义的结构之外,我们将替换
GPULinesBuffer缓冲区的pad0成员,以便它存储行号:layout(set = 4, binding = 0) buffer GPULinesBuffer { uint size; uint row; uint pad1; uint pad2; VkDrawIndirectCommand cmd; Line lines[]; } lineBuffer; -
我们还需要 图 3**.2 中显示的段落的定义,作为两个向量:
vec2 v[] = {vec2(-0.5f, 1.0f), vec2(0.5f, 1.f), vec2(-0.5f, 0.0f), vec2(0.5f, 0.f), vec2(-0.5f, -1.0f), vec2(0.5f, -1.f), vec2( 0.0f, -0.8f), vec2(0.0f, -1.f) }; uvec2 i[] = {uvec2(0, 1), uvec2(2, 3), uvec2(4, 5), uvec2(0, 2), uvec2(1, 3), uvec2(2, 4), uvec2(3, 5), uvec2(6, 7)};数组
v代表 图 3**.2 中显示的所有顶点的坐标,在 x 方向上归一化到范围[-0.5, 0.5],在 y 方向上归一化到范围[-1.0, 1.0]。数组i描述所有段及其顶点。例如,数组的第一个元素描述了图中的段 0,从顶点 0 (-0.5,1.0) 到顶点 1 (0.5,1.0)。 -
printSegment函数将一个段(给定其索引、缩放和转换)添加到存储线的 GPU 缓冲区中:void printSegment(int segment, vec2 pos, vec2 scale) { uint idx = i[segment].x; uint idy = i[segment].y; vec3 v0 = vec3(v[idx] * scale + pos, 1.0); vec3 v1 = vec3(v[idy] * scale + pos, 1.0); addLine(v0, v1, vec4(0,0,0,1), vec4(0,0,0,1)); }此函数调用之前提出的
addLine函数来记录缓冲区中线的最终顶点和颜色。 -
printDigit函数在特定的行和列上打印一个数字的所有段,这些行和列作为参数传入:void printDigit(int digit, uint linenum, uint column) { const float charWidthPixel = 10; const float charHeightPixels = 10; const float horSpacePixels = 5; const float verSpacePixels = 5; const float charWidthNDC = charWidthPixels / screenWidth; const float charHeightNDC = charHeightPixels / screenHeight; const float horSpaceNDC = horSpacePixels / screenWidth; const float verSpaceNDC = verSpacePixels / screenHeight; const float colx = (column + 1) * (charWidthNDC + horSpaceNDC); const float coly = (linenum + 1) * (charHeightNDC + 3 * verSpaceNDC);最初,它计算
switch语句中字符的宽度和高度以决定要打印哪个数字。为了简洁起见,以下片段仅显示了如何打印数字 0 和 1、小数分隔符和负号:const vec2 pos(colx, coly); const vec2 scale(charWidthNDC, -charHeightNDC); switch (digit) { case 0: printSegment(0, pos, scale); printSegment(3, pos, scale); printSegment(4, pos, scale); printSegment(5, pos, scale); printSegment(6, pos, scale); printSegment(2, pos, scale); break; case 1: printSegment(4, pos, scale); printSegment(6, pos, scale); break; case 10: // decimal separator printSegment(7, pos, scale); break; case 11: // minus sign printSegment(1, pos, scale); break; } }之前的代码使用 switch-case 结构来识别基于传入的数字或符号应激活哪些段。例如,数字
0需要多个段来描绘其圆形形状。因此,当数字是0时,会进行多个printSegment调用来渲染0数字所需的每个段。同样,1使用其侧面的两个段形成。除了数字之外,该函数还有描绘小数分隔符和负号的条款,它们通过独特的段排列来区分。 -
printNumber函数旨在在指定的行上显示一个整数,从给定的列开始。执行后,它提供最后一个打印的数字之后的下一个可用列。如果整数是零,它简单地打印'0'。对于非零整数,该函数有效地计算数字的位数,并迭代地打印每个数字,相应地推进列:uint printNumber(highp int value, uint linenum, uint column) { if (value == 0) { printDigit(0, linenum, column); return column + 1; } int counter = 0; int copy = value; int tens = 1; while (copy > 0) { counter++; copy = copy / 10; tens *= 10; } tens /= 10; for (int i = counter; i > 0; --i) { int digit = int(value / tens); printDigit(digit, linenum, column); value = value - (digit * tens); tens /= 10; column++; } return column; }此函数解析整数参数,并逐个打印每个数字,同时增加
column索引。 -
最后,
parse函数解析一个浮点数,并以一定的小数位数打印它:void parse(float val, uint decimals) { int d = int(log(val)); int base = int(pow(10, d)); const float tens = pow(10, decimals); const uint line = atomicAdd(lineBuffer.row, 1); uint column = 0; // Minus sign if (val < 0) { printDigit(11, line, column); column++; } // Prints only positive values val = abs(val); // Integer part const int intPart = int(val); column = printNumber(intPart, line, column); // Decimal if (decimals > 0) { // Dot printDigit(10, line, column); column++; const int decimal = int(val * tens - intPart * tens); printNumber(decimal, line, column); } }该函数将浮点数分成两部分,整数部分和小数部分,并分别打印它们。如果数字是负数,它打印负号。
-
下一步是在渲染文本行后清除缓冲区。在先前的配方中,我们清除了缓冲区中的行数。这里,我们还需要清除行号:
vkCmdFillBuffer(commandBuffer, gpuLineBuffer->vkBuffer(), sizeof(uint32_t), sizeof(uint32_t), 0); -
最后一步是使用
parse函数。只需从包含这些函数的任何着色器中调用它。每次调用parse都会在新的一行上打印值。图 3**.3 展示了在顶点着色器中使用以下代码打印一些值的结果:if (gl_VertexIndex == 0) { parse(123456, 0); parse(789, 0); parse(780.12, 3); parse(-23, 1); parse(0.3, 2); }以下截图显示了我们可以如何使用这种技术进行调试或显示文本:

图 3.3 – 从顶点着色器打印值的输出
在这个配方中,我们深入探讨了使用线段表示数值的复杂过程,这让人联想到 LCD 显示器。通过将数字分解为其各个段,并利用我们从着色器中获得的基础线绘制方法,我们为您提供了一种创新的技术来可视化数字。最终结果是数字与图形的无缝集成,其清晰度让人联想到数字段显示器,丰富了整体视觉体验。
使用 SDF 绘制文本
在这个配方中,我们解决了渲染清晰且可缩放的文本的挑战,无论其大小如何。通过利用 SDF 的原则,我们将传统的文本渲染转化为一个更流畅的过程,确保清晰度和锐度。结果是美丽渲染的文本,无论您是近距离放大还是从远处观看,都保持清晰易读。
SDFs 提供了一种表示表面的方法。SDF 基本上是一个函数,对于空间中的每一个点,它返回到该形状表面的最短距离。SDFs 可用于各种用例,例如体积渲染或对形状执行膨胀、腐蚀和其他形态学操作。
传统上,文本使用位图字体进行渲染。可以使用 2D 画布来渲染文本,然后将其作为纹理绘制到 3D 上下文中的四边形。然而,这种方法创建的位图是分辨率相关的,需要由 CPU 生成并上传到设备。每个字体样式,如粗体、斜体等,也需要由 CPU 处理,这导致计算和传输每种渲染所需样式的纹理时产生额外的开销。
使用 SDF 渲染文本是一种现代方法,它使用每个字符的距离场。这些是网格,其中每个值代表每个像素到字符最近边的距离。SDFs 通过提供分辨率无关的缩放以及使用 GPU 来完成大部分工作,从而帮助避免之前提到的问题。如粗体、轮廓等样式只需更改着色器即可。
字体中的每个字母(字形)由直线和贝塞尔曲线的组合描述。图 3.4中展示了字形的一个示例,它显示了字形的衬线细节:

图 3.4 – 字形定义的细节:圆圈、三角形和正方形代表每个段(曲线或直线)的起点和终点
传统的 SDF 算法为网格中每个像素到符号边界的距离进行编码,将此信息存储在纹理中,并将其上传到 CPU。本食谱中提出的算法实现了一种不同的方法,其中像素到最近曲线的距离是在 GPU 上计算的。为此,字体中的每个符号都在 CPU 上与固定大小为 8 x 8 单元的网格进行预处理。这种预处理检测与每个单元格相交的曲线,并将信息存储在 32 位整数中,如图 图 3**.5 所示:

图 3.5 – 单元编码存储了与单元格相交的三个单独曲线的初始索引以及每个循环的长度
每个单元格包含最多三个与之相交的循环的信息,通过存储每个循环的初始索引及其长度来实现。例如,单元格(2, 0)中显示的符号与两条曲线相交,曲线 1 和曲线 2。该单元格编码的信息将包含曲线 1 的索引和长度为 2。其他索引保持为 0,因为该单元格不与任何其他曲线相交。
以下图表展示了如何使用符号表示字母 S:

图 3.6 – 表示 S 符号的曲线;单元格(2, 0)与两条曲线相交:曲线 1 和曲线 2
顶点着色器将矩形的每个角单元格索引传递给片段着色器,片段着色器接收单元格的插值坐标,使用它来检索要检查的曲线循环及其长度信息,并计算三个循环中每个曲线的最小距离,选择最小距离。
然后,这些信息被用来计算当前片段的不透明度,以及字体边缘的颜色和锐度。
在本食谱中,我们使用第三方库来捕获符号的定义,并将该信息存储在着色器友好的格式中。该库由 Dávid Kocsis 编写,可以在以下位置找到:github.com/kocsis1david/font-demo。
准备工作
在深入本食谱之前,您应该确保已安装 VS 2022,并且能够按照 第一章**,Vulkan 核心概念 中提供的步骤构建存储库。
您应该能够从 VS 2022 中启动名为 Chapter03_GPU_Text_SDF.exe 的可执行文件。
在本食谱中涵盖的代码的完整示例可以在存储库中找到,在 chapter3/mainGPUTextSDF.cpp、chapter3/FontManager.hpp、chapter3/FontManager.cpp 和 chapter3/resources/shaders 中,在 font.frag 和 font.vert 文件中。
如何操作…
在设备上使用 SDF 渲染文本的步骤如下:
-
初始任务涉及通过
FreeType库加载字体文件。这一步至关重要,因为这是我们获取每个字符的位图数据的地方。位图数据本质上代表字体中字符的基本设计,描述其独特的形状和外观。一旦我们有了这些数据,接下来的目标就是将位图数据转换为轮廓数据。轮廓数据捕捉位图形状的本质,将其分解为诸如点、单元格和针对每个字符的特定边界框等组件。这些组件本质上决定了字符如何在屏幕或显示器上渲染。为了实现从FreeType复杂的位图数据到更结构化的轮廓数据的转换,我们使用了fd_outline_convert函数。每个字符的数据被组合成一个包含点和单元格的单一流,作为顶点缓冲区上传到 GPU:FontManager fontManager; const auto &glyphData = fontManager.loadFont( (fontsFolder / "times.ttf").string()); std::vector<GlyphInfo> glyhInfoData; std::vector<uint32_t> cellsData; std::vector<glm::vec2> pointsData; uint32_t pointOffset = 0; uint32_t cellOffset = 0; for (const auto &glyph : glyphData) { glyhInfoData.push_back( {glyph.bbox, glm::uvec4(pointOffset, cellOffset, glyph.cellX, glyph.cellY)}); cellsData.insert(cellsData.end(), glyph.cellData.begin(), glyph.cellData.end()); pointsData.insert(pointsData.end(), glyph.points.begin(), glyph.points.end()); pointOffset += glyph.points.size(); cellOffset += glyph.cellData.size(); }代码深入处理字体渲染,处理位图,位图是字体表示的骨架。这里的主要元素之一是点数据。这个关键部分捕捉了构成每个位图贝塞尔曲线的每个点。目前,我们的主要关注点是 uppercase 字母。但通过观察代码的结构,很明显,如果我们愿意,可以轻松地将其扩展以包含其他字符。与点数据并行,我们还处理单元格数据。在渲染阶段,特别是在片段着色器中,它具有特殊的作用。正是这些数据帮助我们在给定单元格相交的曲线上导航,确保每个位图在屏幕上被准确和精确地描绘。总的来说,通过将点和单元格数据与片段着色器的功能相结合,我们能够有效地渲染字体的视觉复杂性。
-
接下来,我们构建一个包含每个位图边界矩形的缓冲区。这个缓冲区作为顶点缓冲区,我们绘制与显示字符串中字符数量相等的实例:
std::string textToDisplay = "GPUSDFTEXTDEMO"; std::vector<CharInstance> charsData(textToDisplay.length()); int startX = context.swapchain()->extent().width / 6.0f; int startY = context.swapchain()->extent().height / 2.0f; const float scale = 0.09f; for (int i = 0; i < textToDisplay.length(); ++i) { int glpyIndex = textToDisplay[i] - 'A'; charsData[i].glyphIndex = glpyIndex; charsData[i].sharpness = scale; charsData[i].bbox.x = (startX + glyphData[glpyIndex].bbox.x * scale) / (context.swapchain() ->extent() .width / 2.0) - 1.0; charsData[i].bbox.y = (startY - glyphData[glpyIndex].bbox.y * scale) / (context.swapchain() ->extent() .height / 2.0) - 1.0; charsData[i].bbox.z = (startX + glyphData[glpyIndex].bbox.z * scale) / (context.swapchain() ->extent() .width / 2.0) - 1.0; charsData[i].bbox.w = (startY - glyphData[glpyIndex].bbox.w * scale) / (context.swapchain() ->extent() .height / 2.0) - 1.0; startX += glyphData[glpyIndex] .horizontalAdvance * scale; } "GPUSDFTEXTDEMO" onscreen. Here, textToDisplay holds the desired text, and charsData is primed to store individual character details. The starting position, calculated from the screen dimensions, suggests a slightly offset start from the left and a vertical centering of the text. A scaling factor shrinks the characters, likely aiding design or screen fit. As we progress character by character, a mapping correlates each letter to its respective data in glyphData. The bounding box coordinates for every character are meticulously scaled and normalized to ensure their optimal display on screen. To sidestep overlap, the horizontal placement (startX) gets an update for each character, relying on its width and the scaling factor. In sum, this snippet efficiently prepares the specifics for a neatly rendered, scaled, and centered display of "GPUSDFTEXTDEMO" on the screen. -
在接下来的步骤中,我们将点、单元格数据、位图数据和字符串作为单独的缓冲区传输到 GPU。随后,我们执行一个
vkCmdDraw命令:// 4 vertex (Quad) and x (charData) instances vkCmdDraw(commandBuffer, 4, charsData.size(), 0, 0); -
顶点着色器需要访问一个包含位图数据(
GlyphInfo)的数组,这些数据打包到glyph_buffer缓冲区中。其他输入包括来自顶点缓冲区的in_rect、in_glyph_index和in_sharpness:#version 460 // Stores glyph information struct GlyphInfo { vec4 bbox; // Bounding box of the glyph // cell_info.x: point offset // cell_info.x: cell offset // cell_info.x: cell count in x // cell_info.x: cell count in y uvec4 cell_info; }; // Storage buffer object for glyphs layout(set = 0, binding = 0) buffer GlyphBuffer { GlyphInfo glyphs[]; } glyph_buffer; layout(location = 0) in vec4 in_rect; layout(location = 1) in uint in_glyph_index; layout(location = 2) in float in_sharpness; layout(location = 0) out vec2 out_glyph_pos; layout(location = 1) out uvec4 out_cell_info; layout(location = 2) out float out_sharpness; layout(location = 3) out vec2 out_cell_coord; void main() { // Get the glyph information GlyphInfo gi = glyph_buffer.glyphs[in_glyph_index]; // Corners of the rectangle vec2 pos[4] = vec2[]( vec2(in_rect.x, in_rect.y), // Bottom-left vec2(in_rect.z, in_rect.y), // Bottom-right vec2(in_rect.x, in_rect.w), // Top-left vec2(in_rect.z, in_rect.w) // Top-right ); // Corners of the glyph vec2 glyph_pos[4] = vec2[]( vec2(gi.bbox.x, gi.bbox.y), // Bottom-left vec2(gi.bbox.z, gi.bbox.y), // Bottom-right vec2(gi.bbox.x, gi.bbox.w), // Top-left vec2(gi.bbox.z, gi.bbox.w) // Top-right ); // Cell coordinates vec2 cell_coord[4] = vec2[]( vec2(0, 0), // Bottom-left vec2(gi.cell_info.z, 0), // Bottom-right vec2(0, gi.cell_info.w), // Top-left vec2(gi.cell_info.z, gi.cell_info.w) // Top-right ); gl_Position = vec4(pos[gl_VertexIndex], 0.0, 1.0); out_glyph_pos = glyph_pos[gl_VertexIndex]; out_cell_info = gi.cell_info; out_sharpness = in_sharpness; out_cell_coord = cell_coord[gl_VertexIndex]; }上述顶点着色器是为图元渲染定制的。着色器与一个名为
GlyphInfo的结构一起工作,该结构封装了有关每个图元的信息,包括其边界框和与图元的单元格定位相关的细节。在main函数中,着色器使用输入索引获取特定图元的数据。随后,它确定输入矩形的角和相应图元的边界框的位置,并计算图元的单元格坐标。使用gl_VertexIndex,它指示当前正在处理的矩形的哪个顶点,着色器设置该顶点的位置并将必要的值分配给输出变量。这些预处理信息被片段着色器利用,以产生图元的最终视觉表示。 -
接下来是使用片段着色器计算文本的图元颜色的步骤:
-
计算给定片段/像素的单元格索引。
-
根据单元格索引从单元格缓冲区获取单元格。
-
从图元的边界框计算单元格的 SDF。基于距离,计算一个 alpha 值:
// Main function of the fragment shader void main() { // Calculate the cell index uvec2 c = min(uvec2(in_cell_coord), in_cell_info.zw - 1); uint cell_index = in_cell_info.y + in_cell_info.z * c.y + c.x; // Get the cell uint cell = cell_buffer.cells[cell_index]; // Calculate the signed distance from the // glyph position to the cell float v = cell_signed_dist( in_cell_info.x, cell, in_glyph_pos); // Calculate the alpha value float alpha = clamp(v * in_sharpness + 0.5, 0.0, 1.0); out_color = vec4(1.0, 1.0, 1.0, alpha); }
菜单的结果可以在图 3.7中看到:
-

图 3.7 – 菜单的输出
在这个菜谱中,我们展示了使用 GPU 辅助渲染文本时 SDF 的应用。
参见
Inigo Quilez 在一个优秀的视频中演示了如何使用 SDF 创建形状:
https://www.youtube.com/watch?v=8--5LwHRhjk
有多个库可以生成 SDF 纹理 – 例如,libgdx.com/wiki/tools/hiero 和 https://github.com/Chlumsky/msdfgen.
使用计算着色器进行视锥剔除
在这个菜谱中,我们将展示如何使用 GPU 和计算着色器进行视锥剔除。
在实时渲染的世界中,高效的渲染是实现流畅性能和高质量视觉的关键。优化渲染最广泛使用的技术之一是视锥剔除。视锥剔除是一个通过忽略或剔除摄像机视野(视锥)内不可见对象的过程,以提高渲染速度。以下图表展示了这一过程:

图 3.8 – 视锥剔除通过忽略摄像机视图(视锥)外的对象来工作
视锥剔除通过测试场景中的每个对象是否位于摄像机的视锥体内来工作。如果一个对象完全位于视锥体之外,它将被剔除;也就是说,它不会被绘制。这可以显著减少需要绘制的原语数量。传统上,剔除是在 CPU 上完成的,但这意味着每次摄像机移动时都需要进行。我们通过使用计算着色器来展示剔除,从而消除了每次视图变化时都需要从 CPU 上传数据到 GPU 的需求。计算着色器不一定需要与渲染相关,可以处理数据结构并执行排序、物理模拟等操作,在我们的案例中,是视锥剔除。
准备工作
在深入到食谱之前,您应该确保已安装 VS 2022,并且能够按照第一章**,Vulkan 核心概念*中提供的步骤构建存储库。
您应该能够从 VS 2022 中启动名为Chapter03_GPU_Culling.exe的可执行文件。
此食谱基于第二章**,使用现代 Vulkan中的实现可编程顶点拉取和多绘制间接*食谱。本食谱中涵盖的代码可以在存储库中找到,位于chapter3/mainCullingCompute.cpp、chapter3/CullingComputePass.cpp和chapter3/resources/shaders目录下的gpuculling.comp、indirectdraw.frag和indirectdraw.vert文件中。
如何操作…
我们将基于第二章**,使用现代 Vulkan*中实现的 Multi-Draw Indirect 食谱进行构建。在那个食谱中,我们展示了vkCmdDrawIndexedIndirect的使用。在这个食谱中,我们将使用一个从设备缓冲区派生其参数数量的命令,vkCmdDrawIndexedIndirectCount。这个 Vulkan API 允许您指定一个包含绘制计数的 GPU 缓冲区,而不是由 CPU 提供。
此食谱的技术依赖于三个缓冲区:前两个分别包含间接绘制参数的结构,分别是InputIndirectDraws和OutputIndirectDraws;第三个包含要渲染的网格数量。第一个缓冲区包含场景中所有网格的参数。第二个缓冲区由计算着色器填充:未被剔除的网格将它们的间接参数原子地从InputIndirectDraws缓冲区复制到OutputIndirectDraws;被剔除的网格则没有它们的参数被复制:

图 3.9 – 顶部缓冲区:所有网格参数;底部缓冲区:未剔除的网格设置以进行渲染
此外,计算着色器还需要关于每个网格的边界框和它们的中心,以及视锥的六个平面的信息。有了这些信息,计算过程可以剔除(或不禁除)每个网格。在过程结束时,OutputIndirectDraws缓冲区只包含将要绘制的网格的参数,并由间接绘制命令使用。
接下来是分解成步骤的配方,以及来自mainCullingCompute.cpp的片段。它提供了一个高级视图,说明了如何使用 Vulkan 中的计算着色器联合使用剔除和绘制过程来执行视锥剔除。计算着色器负责确定哪些网格应该被绘制,然后图形管线负责绘制这些网格。
-
使用场景信息和场景缓冲区初始化剔除过程:
cullingPass.init(&context, &camera, *bistro.get(), buffers[3]); cullingPass.upload(commandMgr);第一步包括初始化图 3.9中显示的两个缓冲区,并将它们上传到设备。这些细节被
CullingComputePass类封装。 -
计算过程也被
CullingComputePass类封装:auto commandBuffer = commandMgr.getCmdBufferToBegin(); cullingPass.cull(commandBuffer, index);我们将更详细地讨论接下来展示的剔除方法。
-
为了防止计算过程和渲染过程之间的竞争条件,我们为剔除间接绘制和绘制计数缓冲区添加了一个屏障。这是必要的,因为后续的绘制命令依赖于剔除过程的结果:
cullingPass.addBarrierForCulledBuffers( commandBuffer, VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, context.physicalDevice() .graphicsFamilyIndex() .value(), context.physicalDevice() .graphicsFamilyIndex() .value()); -
绘制调用使用
vkCmdDrawIndexedIndirectCount命令进行记录:vkCmdDrawIndexedIndirectCount( commandBuffer, cullingPass.culledIndirectDrawBuffer() ->vkBuffer(), 0, cullingPass .culledIndirectDrawCountBuffer() ->vkBuffer(), 0, numMeshes, sizeof( EngineCore:: IndirectDrawCommandAndMeshData));
在掌握了剔除过程代码的基本要素之后,让我们深入探讨其工作机制。
它是如何工作的...
CullingComputePass::cull方法负责更新视锥数据,绑定计算管线,更新推送常量,并调用vkCmdDispatch。vkCmdDispatch将计算工作调度到 GPU。计算工作被分成更小的单元,每个单元被称为工作组。(pushConst.drawCount / 256) + 1, 1, 1)参数分别指定在x、y和z维度上调度的工作组数量:
void CullingComputePass::cull(
VkCommandBuffer cmd, int frameIndex) {
GPUCullingPassPushConstants pushConst{
.drawCount =
uint32_t(meshesBBoxData_.size()),
};
// Compute and store the six planes of the frustum
for (int i = 0;
auto &plane :
camera_->calculateFrustumPlanes()) {
frustum_.frustumPlanes[i] = plane;
++i;
}
// Upload the data to the device
camFrustumBuffer_->buffer()
->copyDataToBuffer(&frustum_,
sizeof(ViewBuffer));
// Bind the compute pipeline, update push constants
pipeline_->bind(cmd);
pipeline_->updatePushConstant(
cmd, VK_SHADER_STAGE_COMPUTE_BIT,
sizeof(GPUCullingPassPushConstants),
&pushConst);
// Bind descriptor sets
pipeline_->bindDescriptorSets(
cmd,
{
{.set = MESH_BBOX_SET,
.bindIdx = 0},
{.set = INPUT_INDIRECT_BUFFER_SET,
.bindIdx = 0},
{.set = OUTPUT_INDIRECT_BUFFER_SET,
.bindIdx = 0},
{.set =
OUTPUT_INDIRECT_COUNT_BUFFER_SET,
.bindIdx = 0},
{.set = CAMERA_FRUSTUM_SET,
.bindIdx = uint32_t(frameIndex)},
});
// Update descriptor sets
pipeline_->updateDescriptorSets();
// Dispatch the compute pass
vkCmdDispatch(
cmd, (pushConst.drawCount / 256) + 1, 1, 1);
}
CullingComputePass::addBarrierForCulledBuffers方法添加了一个管线屏障,确保在读取结果之前剔除操作已经完成。该屏障被设置为在着色器写入(剔除操作)完成之前阻止间接命令读取访问(这将在绘制调用中使用):
void CullingComputePass::
addBarrierForCulledBuffers(
VkCommandBuffer cmd,
VkPipelineStageFlags dstStage,
uint32_t computeFamilyIndex,
uint32_t graphicsFamilyIndex) {
std::array<VkBufferMemoryBarrier, 2> barriers{
VkBufferMemoryBarrier{
.sType =
VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask =
VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask =
VK_ACCESS_INDIRECT_COMMAND_READ_BIT,
.srcQueueFamilyIndex =
computeFamilyIndex,
.dstQueueFamilyIndex =
graphicsFamilyIndex,
.buffer = outputIndirectDrawBuffer_
->vkBuffer(),
.size = outputIndirectDrawBuffer_
->size(),
},
VkBufferMemoryBarrier{
.sType =
VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask =
VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask =
VK_ACCESS_INDIRECT_COMMAND_READ_BIT,
.srcQueueFamilyIndex =
computeFamilyIndex,
.dstQueueFamilyIndex =
graphicsFamilyIndex,
.buffer =
outputIndirectDrawCountBuffer_
->vkBuffer(),
.size =
outputIndirectDrawCountBuffer_
->size(),
},
};
vkCmdPipelineBarrier(
cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
dstStage, 0, 0, nullptr,
(uint32_t)barriers.size(),
barriers.data(), 0, nullptr);
}
在计算着色器中,我们需要一个函数来判断一个边界框是否完全位于视锥之外,通过比较其范围和中心点与视锥的六个平面:
void cullMesh(uint id) {
MeshBboxData meshBBoxData = meshBboxDatas[id];
bool isVisible = true;
for (int i = 0; i < 6 && isVisible; i++) {
vec3 planeNormal =
viewData.frustumPlanes[i].xyz;
float distFromPlane = dot(
meshBBoxData.centerPos.xyz, planeNormal);
float absDiff = dot(abs(planeNormal),
meshBBoxData.extents.xyz);
if (distFromPlane + absDiff +
viewData.frustumPlanes[i].w < 0.0) {
isVisible = false;
}
}
if (isVisible) {
uint index = atomicAdd(outDrawCount.count, 1);
outputIndirectDraws[index] = inputIndirectDraws[id];
}
}
如果网格被剔除,函数会提前返回。否则,它会原子性地增加IndirectDrawCount缓冲区中可见网格的数量,并使用缓冲区中先前的网格数量作为目标索引,从输入缓冲区复制间接绘制参数到输出缓冲区。
主函数唯一剩下的工作就是调用cullMesh:
layout(local_size_x = 256, local_size_y = 1,
local_size_z = 1) in;
void main() {
uint currentThreadId = gl_GlobalInvocationID.x;
if (currentThreadId == 0) {
atomicExchange(outDrawCount.count, 0);
}
barrier();
if (currentThreadId < cullData.count) {
cullMesh(currentThreadId);
}
}
通过这个配方,我们利用了 GPU 的强大功能,有效地过滤掉非必要对象,优化了我们的渲染工作流程。通过实施这种方法,您将实现更响应和资源高效的可视化,这对于复杂的 3D 场景尤为重要。
第四章:探索照明、阴影和阴影技术
欢迎探索旨在为您的场景注入现实感的照明和阴影技术。在图形领域,照明和阴影在增强 3D 视觉的美感和现实感方面发挥着至关重要的作用。本章深入探讨这些主题,展示了从基础到复杂的各种算法,这些算法可以增加场景的现实感。在本章中,我们将涵盖以下内容:
-
实现用于延迟渲染的 G 缓冲区
-
实现屏幕空间反射
-
实现用于实时阴影的阴影贴图
-
实现屏幕空间环境遮挡
-
实现照明通道以照亮场景
到本章结束时,您将对这些技术有全面的了解,这将使您能够熟练地将它们应用于渲染项目中。
技术要求
对于本章,您需要确保已安装 VS 2022 以及 Vulkan SDK。对 C++编程语言的基本了解以及 OpenGL 或任何其他图形 API 的理解将很有用。请查阅“技术要求”部分下的第一章**,Vulkan 核心概念,以获取有关设置和构建本章可执行文件的详细信息。我们还假设您现在已经熟悉了如何使用 Vulkan API 以及前几章中介绍的各种概念。本章的所有配方都封装在一个单独的可执行文件中,可以使用Chapter04_Deferred_Renderer.exe可执行文件启动。
实现用于延迟渲染的 G 缓冲区
延迟渲染是一种在场景渲染开始时添加一个额外渲染通道的技术,该通道在屏幕空间中累积有关场景的各种信息,例如位置、表面法线、表面颜色等。这些额外信息存储在称为几何缓冲区(G 缓冲区)的缓冲区中,其中每个在此步骤中计算出的值都存储在每个像素中。一旦这个初始通道完成,就可以进行最终的场景渲染,并且可以使用额外的信息(如反射、环境遮挡、大气效果等)来提高渲染质量。使用延迟渲染的好处是它提供了更有效地处理具有许多灯光的复杂场景的方法,因为每个灯光只需对每个像素计算一次,而不是对每个对象计算一次。我们本质上解耦了几何和阴影,这允许在渲染管道中具有更大的灵活性。这项技术也有一些缺点,例如增加了内存使用(对于 G 缓冲区本身),以及处理透明度和抗锯齿的难度。
在本教程中,您将了解延迟渲染的 G 缓冲区实现,了解其在管理具有多个光源的复杂场景中的优势,以及它可能带来的挑战,例如内存使用增加。
准备工作
在 Vulkan 中创建 G 缓冲区相对直接。这项技术的主体依赖于创建一个包含所有将存储场景信息(如位置、法线和材质数据)的渲染目标的引用的帧缓冲区。渲染通道还需要规定这些渲染目标在通道结束时应该如何加载和存储。最后,在片段着色器中,每个渲染目标被指定为一个输出变量,每个渲染目标的值被写入到指向正确纹理或存储缓冲区的输出。

图 4.1 – G 缓冲区纹理
在存储库中,G 缓冲区的生成被封装在GBufferPass类中。
如何做到这一点...
要生成 G 缓冲区和其衍生物,我们首先需要创建一个帧缓冲区和相应的RenderPass。在以下步骤中,我们将向您展示如何为材质的基本颜色、法线和深度组件创建目标:
-
在创建帧缓冲区对象之前,有必要创建将存储 G 缓冲区通道输出的纹理(渲染目标):
gBufferBaseColorTexture_ = context->createTexture( VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, 0, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT,… gBufferNormalTexture_ = context->createTexture( VK_IMAGE_TYPE_2D, VK_FORMAT_R16G16B16A16_SFLOAT, 0, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT,… gBufferPositionTexture_ = context->createTexture( VK_IMAGE_TYPE_2D, VK_FORMAT_R16G16B16A16_SFLOAT, 0, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT,… depthTexture_ = context->createTexture( VK_IMAGE_TYPE_2D, VK_FORMAT_D24_UNORM_S8_UINT, 0, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,… -
Framebuffer对象引用了前面的目标。这里的顺序很重要,并且应该在指定输出的着色器中镜像:frameBuffer_ = context->createFramebuffer( renderPass_->vkRenderPass(), {gBufferBaseColorTexture_, gBufferNormalTexture_, gBufferEmissiveTexture_, gBufferSpecularTexture_, gBufferPositionTexture_, depthTexture_}, nullptr, nullptr, "GBuffer framebuffer "); -
RenderPass对象描述了每个渲染目标应该如何加载和存储。操作顺序应与帧缓冲区使用的目标顺序相匹配:renderPass_ = context->createRenderPass( {gBufferBaseColorTexture_, gBufferNormalTexture_, gBufferEmissiveTexture_, gBufferSpecularTexture_, gBufferPositionTexture_, depthTexture_}, {VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR, VK_ATTACHMENT_LOAD_OP_CLEAR}, {VK_ATTACHMENT_STORE_OP_STORE, VK_ATTACHMENT_STORE_OP_STORE, VK_ATTACHMENT_STORE_OP_STORE, VK_ATTACHMENT_STORE_OP_STORE, VK_ATTACHMENT_STORE_OP_STORE, VK_ATTACHMENT_STORE_OP_STORE}, // final layout for all attachments {VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}, VK_PIPELINE_BIND_POINT_GRAPHICS, "GBuffer RenderPass"); -
在片段着色器中,除了来自管道中先前阶段的输入数据外,输出数据通过布局关键字和位置限定符被定向到每个目标。位置索引必须在帧缓冲区上与渲染目标索引匹配:
layout(location=0) in vec2 inTexCoord; layout(location=1) in flat uint inflatMeshId; layout(location=2) in flat int inflatMaterialId; layout(location=3) in vec3 inNormal; layout(location=4) in vec4 inTangent; layout(location = 0) out vec4 outgBufferBaseColor; layout(location = 1) out vec4 outgBufferWorldNormal; layout(location = 2) out vec4 outgBufferEmissive; layout(location = 3) out vec4 outgBufferSpecular; layout(location = 4) out vec4 outgBufferPosition; const vec3 n = normalize(inNormal); const vec3 t = normalize(inTangent.xyz); const vec3 b = normalize(cross(n,t) * inTangent.w); const mat3 tbn = mat3(t, b, n); outgBufferWorldNormal.rgb = normalize(tbn * normalize(normalTan));
在前面的代码片段中,基于法线和切线值计算出的世界法线被存储在outgBufferWorldNormal位置,这对应于索引为1的附件(参见步骤 2中的代码片段)。
实现屏幕空间反射
物理正确的反射涉及追踪光线在表面反弹时的路径。这个过程考虑了场景中的几何形状、材料属性、光源以及视角角度。然而,这是一个计算量非常大的过程,通常对实时渲染来说要求过高,尤其是在复杂场景或较弱的硬件上。为了在视觉效果和性能之间取得平衡,可以使用一种称为屏幕空间反射(SSR)的近似技术。SSR 是一种通过重用已经渲染到屏幕上的数据来近似反射的方法。通过利用屏幕空间变体,可以显著减少与物理正确反射相关的沉重计算成本,使其成为实时渲染的可行技术。在本教程中,我们将解释如何使用来自上一节中得到的缓冲区(如法线缓冲区和深度缓冲区)来计算反射。
准备工作
SSR 使用深度缓冲区来找到反射光线与几何形状深度的交点。反射光线基于表面法线和视图方向在世界空间中计算,并以小增量前进,直到它离开屏幕边界。对于每一步,将光线的位置投影到屏幕上,并将其坐标与深度缓冲区进行比较。如果光线位置与深度缓冲区深度的差异小于一个小阈值,则光线与某些几何形状发生了碰撞,并且表面上的光线起源点被遮挡。然后使用这个反射向量查找在已渲染图像中反射位置像素的颜色。然后使用这个颜色作为反射颜色,创造出反射的错觉。SSR 可以产生视觉上令人愉悦的反射,接近那些由计算成本更高的物理正确反射模型产生的反射;然而,它只能反射屏幕上已经可见的内容,并且对于复杂表面或屏幕边缘可能产生不准确的结果。
如何操作...
一旦计算出了深度和法线缓冲区,SSR 就可以在渲染或计算通道中轻松计算:
-
以下 SSR 代码由计算通道使用,指定了作为输入使用的缓冲区,这些缓冲区是由延迟渲染步骤生成的,以及它需要执行屏幕空间交点的转换数据:
layout(set = 0, binding = 0, rgba16f) uniform image2D SSRIntersect; layout(set = 1, binding = 0)uniform sampler2D gBufferWorldNormal; layout(set = 1, binding = 1)uniform sampler2D gBufferSpecular; layout(set = 1, binding = 2)uniform sampler2D gBufferBaseColor; layout(set = 1, binding = 3)uniform sampler2D hierarchicalDepth; layout(set = 2, binding = 0)uniform Transforms { mat4 model; mat4 view; mat4 projection; mat4 projectionInv; mat4 viewInv; } cameraData; -
在着色器中定义了两个辅助函数。它们将世界空间中的一个点投影到屏幕空间,并计算从屏幕空间(包括深度坐标)到世界空间的投影:
vec3 generatePositionFromDepth(vec2 texturePos, float depth); vec2 generateProjectedPosition(vec3 worldPos); -
在此步骤中,从 G 缓冲区获取反射计算所需的数据。这包括当前像素的世界法线、镜面数据和基础颜色。计算 UV 坐标,这些坐标用于从 G 缓冲区采样基础颜色。粗糙度,它控制反射的模糊或清晰程度,也从中提取镜面数据。我们还从 G 缓冲区的镜面数据中检查金属度值。如果材料不是金属的(
金属度 < 0.01),它假定它不会反射,并将基础颜色简单地写入结果并退出:layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; void main() { // Return if the coordinate is outside the screen ... imageStore(SSRIntersect, ivec2(gl_GlobalInvocationID.xy), vec4(0)); vec2 uv = (vec2(gl_GlobalInvocationID.xy) + vec2(0.5f)) / vec2(pushConstant.textureResolution); ivec2 pixelPos = ivec2(gl_GlobalInvocationID.xy); vec4 gbufferNormalData = texelFetch(gBufferWorldNormal, pixelPos, 0); vec4 gbufferSpecularData = texelFetch(gBufferSpecular, pixelPos, 0); vec3 basecolor = texture(gBufferBaseColor, uv).xyz; float roughness = gbufferSpecularData.g; if (gbufferSpecularData.r < .01) { // Metal-ness check imageStore(SSRIntersect, ivec2(gl_GlobalInvocationID.xy), vec4(basecolor, 1.0)); return; } -
以下代码片段从深度缓冲区获取当前像素的深度,并使用 UV 和深度生成像素的世界位置。从相机位置到像素的世界位置计算视图方向。然后使用视图方向和法线计算反射方向。着色器然后在屏幕空间中沿着反射方向进行光线追踪。它沿着反射光线步进,并在每个步骤中检查光线是否与任何几何体相交,基于光线当前位置的深度与相应屏幕位置的深度之间的差异:
float z = texelFetch(hierarchicalDepth, pixelPos, 0).r; vec3 position = generatePositionFromDepth(uv, z); vec3 normal = normalize(gbufferNormalData.xyz); vec3 camPos = cameraData.viewInv[3].xyz; vec3 viewDirection = normalize(position - camPos); vec3 reflectionDirection = reflect(viewDirection, normal); ; float stepSize = 0.05; // Initial step size vec3 currentPos = position; for (int i = 0; i < 50; i++) { currentPos += reflectionDirection * stepSize; vec2 screenPos = generateProjectedPosition(currentPos); if (screenPos.x < 0.0 || screenPos.x > 1.0 || screenPos.y < 0.0 || screenPos.y > 1.0) { break; // Ray went out of screen bounds } float depthAtCurrent = texture(hierarchicalDepth, screenPos).r; vec3 positionFromDepth = generatePositionFromDepth(screenPos, depthAtCurrent); float depthDifference = length(currentPos - positionFromDepth); -
如果找到交点,代码将获取交点的颜色并将其与基础颜色混合。混合基于粗糙度值,它代表交点处的表面特性:
if (depthDifference < 0.05) { vec3 hitColor = texture(gBufferBaseColor, screenPos).xyz; if (hitColor.x <= .1 && hitColor.y <= .1 && hitColor.z <= .1 && hitColor.x >= .08 && hitColor.y >= .08 && hitColor.z >= .08) { // .1 is considered sky color, // ignore if we hit sky hitColor = basecolor; } vec3 blendColor = hitColor * (1.0 - roughness) + roughness * basecolor; imageStore(SSRIntersect, ivec2(gl_GlobalInvocationID.xy), vec4(blendColor, 1.0)); return; } } // Fallback imageStore(SSRIntersect, ivec2(gl_GlobalInvocationID.xy), vec4(basecolor, 1.0)); }
在前面的代码中,我们学习了如何使用深度和法线缓冲区计算 SSR。
参见
以下是一些关于如何实现 SSR(屏幕空间反射)的更详细参考;我们建议您阅读这些参考以获得更深入的理解:
-
interplayoflight.wordpress.com/2022/09/28/notes-on-screenspace-reflections-with-fidelityfx-sssr/ -
interplayoflight.wordpress.com/2019/09/07/hybrid-screen-space-reflections/
实现实时阴影的阴影图实现
如其名所示,阴影图用于模拟阴影。阴影映射的目标是通过首先从光源的视角渲染场景,生成深度图,来确定场景中哪些部分处于阴影中,哪些部分被光源照亮。
此深度图(也称为阴影图)充当空间记录,存储场景中任意点到光源的最短距离。通过从光源的视角封装场景,深度图有效地捕捉了场景中直接可见于光源的区域以及被遮挡的区域。
然后,在主渲染通道中使用这个深度图来确定片段是否无法从光源到达,通过比较其深度值与深度图中的值。对于场景中的每个片段,我们执行一个测试来评估它是否位于阴影中。这是通过比较从光源处得到的片段深度值,该值是从主摄像机的视角推导出来的,与存储在深度图中的相应深度值进行比较来实现的。
如果片段的深度值超过深度图中记录的值,这意味着片段被场景中的另一个物体遮挡,因此处于阴影中。相反,如果片段的深度值小于或等于深度图值,这表示片段直接可见于光源,因此被照亮。
在这个菜谱中,你将学习如何实现阴影贴图以在 3D 场景中创建实时阴影。这涉及到理解阴影贴图的理论,从光源的角度生成深度图,并最终在主渲染通道中使用这个深度图来准确确定场景中哪些片段处于阴影中,哪些片段被照亮。
准备工作
要获取阴影贴图,我们首先需要从光源的角度渲染场景并保留深度图。这个渲染通道需要一个深度纹理来存储深度信息以及简单的顶点和片段着色器。主要的渲染通道,即场景被渲染的地方,使用深度图作为参考来确定像素是否被照亮,并需要参考前一步生成的阴影贴图,以及一个特殊的采样器来在着色器代码中访问深度图。它还包括执行片段与存储在深度图中的值之间比较的代码。
在存储库中,阴影贴图生成被封装在ShadowPass类中,阴影深度纹理的使用被封装在LightingPass类中。
如何做到这一点...
我们首先从阴影贴图通道的概述开始:
-
阴影贴图是一种常规纹理,其格式支持深度值。我们的深度纹理是正常纹理分辨率的 4 倍,并使用
VK_FORMAT_D24_UNORM_S8_UINT格式:void ShadowPass::initTextures( VulkanCore::Context *context) { depthTexture_ = context->createTexture( VK_IMAGE_TYPE_2D, VK_FORMAT_D24_UNORM_S8_UINT, 0, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, { .width = context->swapchain()->extent().width * 4, // 4x resolution for shadow maps .height = context->swapchain() ->extent() .height * 4, .depth = 1, }, 1, 1, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, false, "ShadowMap Depth buffer"); } -
渲染通道需要在开始时清除深度附加,然后在结束时存储它。阴影贴图渲染通道或帧缓冲区中没有颜色附加:
renderPass_ = context->createRenderPass( {depthTexture_}, {VK_ATTACHMENT_LOAD_OP_CLEAR}, {VK_ATTACHMENT_STORE_OP_STORE}, // final layout for all attachments {VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}, VK_PIPELINE_BIND_POINT_GRAPHICS, "ShadowMap RenderPass"); frameBuffer_ = context->createFramebuffer( renderPass_->vkRenderPass(), {depthTexture_}, nullptr, nullptr, "ShadowMap framebuffer "); -
这个渲染通道的管道定义需要匹配视口的大小与阴影贴图的大小,并使用特殊的顶点和片段着色器进行此通道。片段和顶点着色器在概念上与 G 缓冲区通道相同,但它们只需要输出深度缓冲区而不是多个几何缓冲区;它还需要一个光视图投影矩阵而不是摄像机的矩阵。作为一个未来的优化,你可以使用与 G 缓冲区通道相同的专用常量而不是使用单独的着色器:
auto vertexShader = context->createShaderModule( (resourcesFolder / "shadowpass.vert").string(), VK_SHADER_STAGE_VERTEX_BIT, "shadowmap vertex"); auto fragmentShader = context->createShaderModule( (resourcesFolder / "empty.frag").string(), VK_SHADER_STAGE_FRAGMENT_BIT, "shadowmap fragment"); const VulkanCore::Pipeline:: GraphicsPipelineDescriptor gpDesc = { .sets_ = setLayout, .vertexShader_ = vertexShader, .fragmentShader_ = fragmentShader, .dynamicStates_ = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}, .colorTextureFormats = {}, .depthTextureFormat = VK_FORMAT_D24_UNORM_S8_UINT, .sampleCount = VK_SAMPLE_COUNT_1_BIT, .cullMode = VK_CULL_MODE_BACK_BIT, .viewport = VkExtent2D( depthTexture_->vkExtents().width, depthTexture_->vkExtents().height), .depthTestEnable = true, .depthWriteEnable = true, .depthCompareOperation = VK_COMPARE_OP_LESS, }; pipeline_ = context->createGraphicsPipeline( gpDesc, renderPass_->vkRenderPass(), -
顶点着色器需要光源的变换矩阵,该矩阵设置为
depthTestEnable、depthWriteEnable和depthCompareOperation,将决定我们在此过程中如何评估和存储深度信息:#version 460 #extension GL_EXT_nonuniform_qualifier : require #extension GL_EXT_debug_printf : enable #extension GL_GOOGLE_include_directive : require #include "CommonStructs.glsl" #include "IndirectCommon.glsl" void main() { Vertex vertex = vertexAlias[VERTEX_INDEX] .vertices[gl_VertexIndex]; vec3 position = vec3(vertex.posX, vertex.posY, vertex.posZ); gl_Position = MVP.projection * MVP.view * MVP.model * vec4(position, 1.0); } -
片段着色器为空,因为它不需要输出任何颜色信息:
#version 460 void main() { }主要渲染(照明)传递使用之前计算的阴影图作为参考来确定片段是否被照亮。场景没有特殊设置,除了与阴影图一起使用的采样器需要启用比较函数。顶点和片段着色器也需要一些特殊处理以执行与阴影图的深度比较。
-
在着色器中用于访问阴影图的采样器需要启用比较函数。我们使用
VK_COMPARE_OP_LESS_OR_EQUAL函数:samplerShadowMap_ = context.createSampler( VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f, true, VK_COMPARE_OP_LESS_OR_EQUAL, "lighting pass shadow"); -
片段着色器也需要阴影图以及光源的视图投影矩阵。以下代码包括统一的
sampler2Dshadow,它包含深度图或阴影图。统一的Lights结构包含有关光源的信息,包括其位置、方向、颜色以及从光源视角的视图投影矩阵:#version 460 layout(set = 0, binding = 6)uniform sampler2DShadow shadowMap; layout(set = 1, binding = 1)uniform Lights { vec4 lightPos; vec4 lightDir; vec4 lightColor; vec4 ambientColor; // environment light color mat4 lightVP; float innerConeAngle; float outerConeAngle; } lightData; -
我们引入一个
computeShadow辅助函数,它接受光投影空间中的位置作为输入。它首先将此位置转换为归一化设备坐标(NDCs),然后在该对应位置查找阴影图并返回该点的阴影强度:#version 460 layout(set = 0, binding = 6)uniform sampler2DShadow shadowMap; layout(set = 1, binding = 1)uniform Lights { vec4 lightPos; vec4 lightDir; vec4 lightColor; vec4 ambientColor; // environment light color mat4 lightVP; float innerConeAngle; float outerConeAngle; } lightData; -
接下来,我们介绍另一个辅助函数,即
PCF函数。该函数在像素周围有16个不同的偏移量。然后,它会计算这些样本的平均值以确定像素的最终阴影值。这个过程导致阴影边缘的像素具有介于完全照亮和完全阴影之间的中间阴影值,从而创建一个柔和的过渡,使阴影看起来更自然:float PCF(vec4 shadowCoord) { vec2 texCoord = shadowCoord.xy / shadowCoord.w; texCoord = texCoord * .5 + .5; texCoord.y = 1.0 - texCoord.y; if (texCoord.x > 1.0 || texCoord.y > 1.0 || texCoord.x < 0.0 || texCoord.y < 0.0) { return 1.0; } vec2 texSize = textureSize(shadowMap, 0); float result = 0.0; vec2 offset = (1.0 / texSize) * shadowCoord.w; for(float x = -1.5; x <= 1.5; x += 1.0) { for(float y = -1.5; y <= 1.5; y += 1.0) { result += computeShadow(shadowCoord + vec4(vec2(x, y) * offset, 0.0, 0.0)); } } return result / 16.0; -
在主函数中,我们首先从 G 缓冲区检索片段的世界位置和基础颜色。如果世界位置为零(表示没有有意义的信息),我们只需将输出颜色设置为基本颜色并提前返回。作为下一步,使用光源的视图投影矩阵将世界位置转换到光投影空间,并将此位置传递给
PCF函数以计算可见性因子,该因子表示片段在阴影中的程度。如果可见性因子低于阈值(意味着片段处于深阴影中),则将其设置为固定值以提供最小量的环境光。最后,我们将计算出的outColor与可见性因子相乘以创建最终颜色,如果片段在阴影中,颜色会更暗,如果片段被照亮,颜色会更亮:void main() { vec4 worldPos = texture(gBufferPosition, fragTexCoord); vec3 basecolor = texture(gBufferBaseColor, fragTexCoord).rgb; if (worldPos.x == 0.0 && worldPos.y == 0.0 && worldPos.z == 0.0 && worldPos.w == 0.0) { outColor = vec4(basecolor, 1.0); return; } // compute outColor … vec4 shadowProjPos = lightData.lightVP * vec4(worldPos.xyz, 1.0f); float vis = PCF(shadowProjPos); if (vis <= .001) { vis = .3; } outColor.xyz *= vis; }
在上一节中,我们展示了如何实现阴影传递,但这项技术的局限性将在下一节中讨论。
还有更多……
我们已经展示了基本阴影映射技术的使用;然而,这项技术有一些局限性,如下所述:
-
走样和像素化:简单阴影映射的主要问题之一是走样问题,阴影可能看起来是像素化的或块状的。这是因为阴影图的分辨率直接影响阴影的质量。如果阴影图的分辨率太低,生成的阴影将会是像素化的。虽然提高阴影图的分辨率可以减轻这个问题,但这会以增加内存使用和计算负载为代价。
-
硬阴影边缘:基本阴影映射由于使用二进制光照或非光照测试来确定阴影而产生具有硬边缘的阴影。由于光散射(半影),阴影通常具有柔和的边缘。
-
阴影 acne 或自阴影伪影:当表面由于深度测试中的精度错误而错误地自阴影时,会出现这个问题。使用如偏置等技术来处理这个问题,但选择正确的偏置可能具有挑战性。
一些挑战可以通过以下更高级的技术来克服:
-
级联阴影图:这项技术通过将摄像机的视锥体分成多个级联或部分,每个部分都有自己的阴影图来解决分辨率问题。这允许在相机附近使用更高分辨率的阴影图,因为那里的细节更重要,而在较远的地方使用较低分辨率的阴影图。
-
瞬间阴影图:这项技术使用统计矩来存储关于像素内深度分布的更多信息,可以处理透明度并提供抗锯齿的柔和阴影。瞬间阴影图比基本阴影图需要更多的内存和计算,但可以提供更高质量的阴影。
-
参考信息
以下是一些讨论和提供高级技术(如级联阴影图和瞬间阴影)实现细节的参考:
实现屏幕空间环境遮挡
屏幕空间环境遮挡(SSAO)可以用来实时近似环境遮挡的效果。环境遮挡是一种着色和渲染方法,用于计算场景中每个点对环境光照的暴露程度。这项技术会在两个表面或物体相遇的地方,或者物体阻挡光线到达另一个物体时,添加更真实的阴影。
在这个菜谱中,你将学习如何实现 SSAO 以实时地真实地估计环境遮挡。你将掌握如何使用这种着色和渲染技术来计算场景中每个点的环境光照曝光度。
准备工作
本配方中描述的算法以环形方式计算像素深度与其邻居(样本)之间的差异。如果一个样本比中心像素更靠近相机,它将贡献到遮挡因子,使像素变暗。
算法的描述如图 图 4**.2 所示。代码在以像素为中心的几个 环 上循环,在每个环内,它进行几个样本的采样,如项目 (a) 所示。

图 4.2 – SSAO 采样模式
如项目 (b) 所示,对每个样本的位置应用少量噪声以避免带状效应。此外,对同一环上的样本应用权重,距离中心越远的环权重越小,如项目 (c) 所示。
如何做到这一点...
整个 SSAO 算法被实现为一个计算着色器,它将输出写入图像:
-
我们首先声明输入和输出。输入是深度缓冲区。输出是一个图像,我们将在这里存储算法的结果。我们还需要一个在二维中生成噪声的函数。对每个样本的位置应用少量噪声以避免带状效应:
#version 460 layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; layout(set = 0, binding = 0, rgba8) uniform image2D OutputSSAO; layout(set = 1, binding = 0) uniform sampler2D gBufferDepth; const float nearDistance = .1f; const float farDistance = 100.0f; vec2 generateRandomNoise( in vec2 coord) // generating random noise { float noiseX = (fract( sin(dot(coord, vec2(12.9898, 78.233))) * 43758.5453)); float noiseY = (fract( sin(dot(coord, vec2(12.9898, 78.233) * 2.0)) * 43758.5453)); return vec2(noiseX, noiseY) * 0.004; } -
我们还需要一个函数将深度缓冲区中的深度值转换为线性尺度,因为这些值不是以线性方式存储的:
float calculateLinearDepth(float depth) { return (2.0 * nearDistance) / (farDistance + nearDistance - depth * (farDistance - nearDistance)); } -
通过
compareDepths函数比较正在处理的像素深度值和周围样本的深度值,该函数返回样本之间的差异:float compareDepths(float depth1, float depth2) { const float aoCap = 0.5; const float aoMultiplier = 50.0; const float depthTolerance = 0.001; const float aoRange = 60.0; float depthDifference = sqrt( clamp(1.0 - (depth1 - depth2) / (aoRange / (farDistance - nearDistance)), 0.0, 1.0)); float ao = min(aoCap, max(0.0, depth1 - depth2 - depthTolerance) * aoMultiplier) * depthDifference; return ao; } -
算法的主要部分首先收集像素的位置及其深度值,该深度值被转换为线性尺度。它还计算缓冲区的大小并计算噪声:
void main() { if (gl_GlobalInvocationID.x >= pushConstant.textureResolution.x || gl_GlobalInvocationID.y >= pushConstant.textureResolution.y) { return; } imageStore(OutputSSAO, ivec2(gl_GlobalInvocationID.xy), vec4(0)); vec2 uv = (vec2(gl_GlobalInvocationID.xy) + vec2(0.5f)) / vec2(pushConstant.textureResolution); ivec2 pixelPos = ivec2(gl_GlobalInvocationID.xy); float depthBufferValue = texelFetch(gBufferDepth, pixelPos, 0).r; float depth = calculateLinearDepth(depthBufferValue); float textureWidth = float(pushConstant.textureResolution.x); float textureHeight = float(pushConstant.textureResolution.y); float aspectRatio = textureWidth / textureHeight; vec2 noise = generateRandomNoise(vec2(pixelPos)); -
检查样本区域的大小与像素的深度值成正比:像素离相机越远,区域越小:
float w = (1.0 / textureWidth) / clamp(depth, 0.05, 1.0) + (noise.x * (1.0 - noise.x)); float h = (1.0 / textureHeight) / clamp(depth, 0.05, 1.0) + (noise.y * (1.0 - noise.y)); w *= textureWidth / 2.0; h *= textureHeight / 2.0; float sampleWidth; float sampleHeight; float ao = 0.0; float totalSamples = 0.0; float fade = 1.0; const int NUM_RINGS = 3; const int NUM_SAMPLES = 6; -
算法的大部分工作在于计算环的半径和样本的数量。样本的数量与环的直径成正比。对于每个样本,我们比较它们的深度,应用环权重,并累积输出,最后在函数结束时平均:
for (int i = 0; i < NUM_RINGS; i++) { fade *= 0.5; for (int j = 0; j < NUM_SAMPLES * i; j++) { float step = 3.14159265 * 2.0 / float(NUM_SAMPLES * i); sampleWidth = (cos(float(j) * step) * float(i)); sampleHeight = (sin(float(j) * step) * float(i)); float newDepthValue = texelFetch( gBufferDepth, pixelPos + ivec2(int(sampleWidth * w), int(sampleHeight * h)), 0) .r; ao += compareDepths(depth, calculateLinearDepth( newDepthValue)) * fade; totalSamples += 1.0 * fade; } } ao /= totalSamples; ao = 1.0 - ao; imageStore(OutputSSAO, ivec2(gl_GlobalInvocationID.xy), vec4(ao,ao,ao, 1.0));
这就完成了我们的 SSAO 配方。为了更深入的理解和进一步探索,我们强烈建议访问以下章节中提供的各种资源。
参考文献还有
为了进一步理解和探索 SSAO 主题,以下资源可能会有所帮助:
-
www.gamedevs.org/uploads/comparative-study-of-ssao-methods.pdf -
research.nvidia.com/sites/default/files/pubs/2012-06_Scalable-Ambient-Obscurance/McGuire12SAO.pdf
实现用于照亮场景的光照通道
书中的最后一个食谱介绍了如何实现光照通道;这就是我们计算场景光照的地方。对于场景中的每个光源,我们绘制一个体积(对于点光源,这将是一个球体;对于方向光源,是一个全屏四边形;对于聚光灯,我们将绘制一个圆锥体),并且对于该体积中的每个像素,我们从 G 缓冲区获取数据并计算该光源对该像素的光照贡献。然后将结果通常相加(混合)到最终的渲染目标中,以获得最终图像。在演示中,我们只有一个用作演示的聚光灯,但我们可以轻松地添加多个光源。对于场景中的每个光源,我们需要考虑光源影响的区域(即,我们使用一个着色器,它从 G 缓冲区为每个像素获取相关数据,然后使用这些数据来计算这个光源对每个像素最终颜色的贡献)。例如,如果我们处理的是聚光灯,这个体积是以光源位置为中心的圆锥体,方向与光源方向一致,角度与聚光灯的扩散相匹配。圆锥体的长度或高度应等于聚光灯的范围。最后,我们使用一个基于物理的光照模型(Cook-Torrance 光照模型),它在片段着色器中应用。光照模型的输入包括光源属性(颜色、强度、位置等)和表面属性(材料颜色、光泽度、法线等),这些数据是从 G 缓冲区获取的。
准备工作
该食谱在LightingPass类和Lighting.frag着色器中实现。它简单地使用一个全屏顶点着色器来绘制一个全屏四边形。
如本食谱介绍中所述,我们使用 Cook-Torrance 光照模型,这是一个基于物理的渲染模型,它模拟了光线与表面交互的方式。它考虑了入射角、表面粗糙度和微面分布等各个因素,以渲染逼真的光照效果。该算法使用Fresnel-Schlick函数,该函数用于根据视角确定反射与折射的光的比例。GGX 分布函数计算表面微面的分布,这影响了表面看起来是粗糙还是光滑。
如何实现它...
整个算法实现为一个全屏空间四边形着色器,将其输出写入最终的颜色纹理:
-
我们首先声明输入和输出。输入包括 G-缓冲区数据(法线、镜面、基础颜色、深度和位置)、环境遮挡图、阴影贴图、相机和灯光数据。输出是简单的
fragColor,它被写入由渲染通道指定的颜色附件:#version 460 #extension GL_EXT_nonuniform_qualifier : require #extension GL_GOOGLE_include_directive : require layout(set = 0, binding = 0)uniform sampler2D gBufferWorldNormal; layout(set = 0, binding = 1)uniform sampler2D gBufferSpecular; layout(set = 0, binding = 2)uniform sampler2D gBufferBaseColor; layout(set = 0, binding = 3)uniform sampler2D gBufferDepth; layout(set = 0, binding = 4)uniform sampler2D gBufferPosition; layout(set = 0, binding = 5)uniform sampler2D ambientOcclusion; layout(set = 0, binding = 6)uniform sampler2DShadow shadowMap; layout(set = 1, binding = 0)uniform Transforms { mat4 viewProj; mat4 viewProjInv; mat4 viewInv; } cameraData; layout(set = 1, binding = 1)uniform Lights { vec4 lightPos; vec4 lightDir; vec4 lightColor; vec4 ambientColor; // environment light color mat4 lightVP; float innerConeAngle; float outerConeAngle; } lightData; layout(location=0) in vec2 fragTexCoord; layout(location = 0) out vec4 outColor; -
之后,我们定义了一些辅助函数;这些将在主函数中使用。这些函数定义在
brdf.glsl文件中。fresnelSchlick函数计算Fresnel-Schlick近似,它根据光线击中表面的角度来模拟反射和折射的量。结果用于确定镜面颜色。distributionGGX函数计算表面上的微面分布。结果模拟了表面看起来是粗糙还是光滑,影响镜面高光的扩散。geometrySchlickGGX函数使用geometrySmith函数计算几何衰减项,考虑了视图方向和光方向的总几何衰减。它计算视图和光方向的几何衰减,并将两者相乘以获得最终的几何衰减,此函数假设微面分布在任何方向上都是相同的。这些函数在Cook-Torrance BRDF模型中组合,以考虑微面遮挡和阴影效果:vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); } float distributionGGX(vec3 N, vec3 H, float roughness) { float a = roughness * roughness; float a2 = a * a; float NdotH = max(dot(N, H), 0.0); float NdotH2 = NdotH * NdotH; float nom = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = 3.14159265359 * denom * denom; return nom / denom; } float geometrySchlickGGX(float NdotV, float roughness) { float r = (roughness + 1.0) * 0.5; float r2 = r * r; float nom = NdotV; float denom = NdotV * (1.0 - r2) + r2; return nom / denom; } float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness) { float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx2 = geometrySchlickGGX(NdotV, roughness); float ggx1 = geometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2; } -
着色器首先从 G-缓冲区检索基础颜色、世界位置、相机位置、镜面数据和法线数据:
void main() { vec4 worldPos = texture(gBufferPosition, fragTexCoord); vec3 basecolor = texture(gBufferBaseColor, fragTexCoord).rgb; if (worldPos.x == 0.0 && worldPos.y == 0.0 && worldPos.z == 0.0 && worldPos.w == 0.0) { outColor = vec4(basecolor, 1.0); return; } vec2 gbufferSpecularData = texture(gBufferSpecular, fragTexCoord).rg; float metallic = gbufferSpecularData.r; float roughness = gbufferSpecularData.g; vec4 gbufferNormalData = texture(gBufferWorldNormal, fragTexCoord); vec3 N = normalize(gbufferNormalData.xyz); -
在接下来的步骤中,它计算视图向量(
V)、光向量(L)和半向量(H)。这些向量用于光照计算:vec3 camPos = cameraData.viewInv[3].xyz; vec3 V = normalize(camPos - worldPos.xyz); vec3 F0 = vec3(0.04); F0 = mix(F0, basecolor, metallic); vec3 L = normalize( lightData.lightDir.xyz - worldPos.xyz); // Using spotlight direction vec3 H = normalize(V + L); -
在这个部分,调用了
Fresnel-Schlick、distributionGGX和geometrySmith函数来计算镜面反射:vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); float D = distributionGGX(N, H, roughness); float G = geometrySmith(N, V, L, roughness); vec3 nominator = D * G * F; float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001; vec3 specular = nominator / denominator; -
在这一步,着色器计算漫反射。它是一个基于朗伯余弦定律的简单模型,但通过应用能量守恒原理进行了修改:
vec3 kS = F; vec3 kD = vec3(1.0) - kS; kD *= 1.0 - metallic; float NdotL = max(dot(N, L), 0.0); vec3 diffuse = kD * basecolor / 3.14159265359; -
在这些最后几个步骤中,环境光通过简单地乘以环境光颜色和基础颜色来计算。在这里,我们还根据到光源的距离计算衰减,并根据光方向和片段方向之间的角度计算点衰减。这些用于计算光强度。着色器通过将环境、漫反射和镜面分量相加来计算最终颜色:
vec3 ambient = lightData.ambientColor.rgb * basecolor; // Spotlight calculations vec3 lightToFragment = lightData.lightPos.xyz - worldPos.xyz; vec3 lightDirection = normalize(-lightData.lightDir.xyz); float distanceToLight = length(lightToFragment); float attenuation = 1.0 / (1.0 + 0.1 * distanceToLight + 0.01 * distanceToLight * distanceToLight); vec3 lightDir = normalize(lightToFragment); float cosTheta = dot(-lightDir, lightDirection); float spotAttenuation = smoothstep(lightData.outerConeAngle, lightData.innerConeAngle, cosTheta); vec3 lightIntensity = spotAttenuation * attenuation * lightData.lightColor.rgb; // Final light contribution vec3 finalColor = (NdotL * (lightIntensity) * (diffuse + specular)) + ambient; -
在最后一步,最终颜色乘以环境遮挡因子(从 实现屏幕空间环境遮挡 菜单中采样的环境遮挡纹理)和阴影可见性因子,以考虑阴影和环境遮挡:
float ao = texture(ambientOcclusion, fragTexCoord).r; finalColor *= ao; outColor = vec4(finalColor, 1.0); vec4 shadowProjPos = lightData.lightVP * vec4(worldPos.xyz, 1.0f); float vis = PCF(shadowProjPos); if (vis <= .001) { vis = .3; } outColor.xyz *= vis;在以下屏幕截图中,我们展示了一张使用阴影贴图技术创建的阴影图像:

图 4.3 – 使用阴影贴图创建的阴影
以下截图展示了 SSR 技术的结果:

图 4.4 – SSR
在本章中,我们开始了理解并实现使用 Vulkan API 实现实时基于物理效果的一些最具影响力的 3D 图形技术的旅程。我们的探索从 G 缓冲区生成的原理开始,这是延迟渲染的基础概念。这项技术使我们能够管理现代光照和着色的复杂性,为更高级渲染效果的实施铺平了道路。然后我们描述了如 SSR 和阴影等技术,这些技术对于模拟渲染场景中的真实感是必需的。我们还深入探讨了光照和着色的复杂性,通过深入研究 SSAO。这项技术为我们提供了模拟现实生活中光辐射复杂方式的方法,为我们的 3D 世界中的角落增添了深度和细节。最后,我们的探索以实现光照通道结束。通过计算场景中每个对象上各种光源的贡献,我们成功地照亮了我们的 3D 环境。我们希望您已经对现代光照、着色和阴影的几个核心技术有了全面的理解,这将使您能够使用 Vulkan 创建令人惊叹且逼真的 3D 图形。
第五章:解密无序透明度
渲染透明物体并不总是容易。虽然不透明物体可以按任何顺序渲染,但透明物体需要根据它们相对于摄像机的距离从远到近进行渲染,这意味着在执行实际渲染之前需要额外的排序步骤。这种深度排序确保了更远的物体首先被混合到帧缓冲区中,然后是较近的物体,从而允许准确组合透明层。
排序可能会变得计算密集且容易出错,尤其是在处理复杂场景、相交物体或实时渲染场景时。此外,排序无法解决循环重叠的问题,其中多个物体以这种方式相互穿透,以至于没有任何单个深度排序顺序可以准确地表示它们的视觉外观。
无序透明度技术试图通过以不依赖于对象处理顺序的方式累积透明度信息来解决这些问题。本章深入探讨了渲染透明对象的复杂性和挑战,这项任务需要精确和细致的执行。与可以按任何顺序渲染的不透明物体不同,透明物体需要根据它们相对于摄像机的深度进行渲染,从最远到最近。这涉及到一个额外的排序步骤,虽然确保了透明层的准确组合,但可能会变得计算密集且容易出错。
在本章中,我们将涵盖以下主要主题:
-
实现深度剥离
-
实现双重深度剥离
-
实现链表无序透明度
-
实现加权无序透明度
技术要求
对于本章,你需要确保你已经安装了 VS 2022 以及 Vulkan SDK。对 C++编程语言的基本熟悉程度以及对 OpenGL 或任何其他图形 API 的理解将很有用。请回顾第一章Chapter 1,Vulkan 核心概念,在技术要求*部分中有关设置和构建本章可执行文件的具体细节。本章的所有配方都封装在一个单独的可执行文件中,可以使用Chapter05_Transparency.exe可执行文件启动。
实现深度剥离
深度剥离技术由 Cass Everitt 在 2001 年提出,作为渲染半透明几何形状的解决方案,无需从后向前排序几何形状。该技术包括多次渲染场景(遍历)。在每次遍历中,仅渲染离相机最近的片段,并收集其深度以供下一次遍历使用。在每次遍历中(除了第一次遍历),丢弃比前一次迭代中收集的深度遍历中的片段更近的片段。这个过程将场景剥离成连续的层,从前面到后面。在过程结束时,所有层都混合成一张最终图像,然后再次与背景混合。
准备工作
在技术要求中提到的仓库中,深度剥离算法是通过DepthPeeling类实现的,该类位于source/enginecore/passes/DepthPeeling.hpp和cpp文件中。在本教程中,您将学习如何在渲染过程中剥离或逐步移除透明物体的层。这项技术通过从最远到最近逐个处理每一层,确保了准确的渲染,从而提高了具有复杂重叠透明度的场景的整体视觉质量。
该算法包括反复渲染场景,在每个遍历结束时存储深度图。离相机最近的片段与上一个遍历(或第一个遍历的空帧缓冲区)混合。如果当前遍历片段的深度小于上一个遍历的深度,则丢弃当前遍历片段的深度,如图图 5.1所示:

图 5.1 –具有 3 个平面的深度剥离算法
在前面的章节中,我们已经提供了对这个技术的基础理解。接下来,我们将深入探讨,通过详细的、分步的过程指导您如何使用 Vulkan 实际实现这项技术。
如何操作…
该算法使用两组深度图和两组颜色附件来在遍历之间执行乒乓操作。在一次遍历期间获得的深度图用作下一次遍历的参考深度图,而第二个深度图则用作深度附件。对于两个颜色附件也做同样处理:一个用于存储当前遍历的混合效果,而另一个用作参考,由前一个遍历生成。接下来的步骤将指导您了解这些操作的执行过程。借助下面提供的详细图表,您将能够可视化并更好地理解该算法的复杂工作原理。
图 5.2有效地说明了所描述的过程,有助于您理解和应用这项复杂的技术:

图 5.2 –深度剥离算法
现在我们将逐步讲解如何执行这个配方。
-
算法通过
DepthPeeling::draw方法执行,它首先清除深度图 1 和两个颜色附件:void DepthPeeling::draw( VkCommandBuffer commandBuffer, int index, const std::vector< std::shared_ptr<VulkanCore::Buffer>> &buffers, uint32_t numMeshes) { { // Clear Depth 1 vkCmdClearDepthStencilImage( commandBuffer, depthTextures_[1]->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearDepth, 1, &range); } { // Clear color attachments vkCmdClearColorImage( commandBuffer, colorTextures_[0]->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range); vkCmdClearColorImage( commandBuffer, colorTextures_[1]->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range); } -
颜色和深度附件都从索引为 0 的颜色和深度附件开始:
VulkanCore::DynamicRendering:: AttachmentDescription colorAttachmentDesc{ .imageView = colorTextures_[0]->vkImageView(), .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .attachmentLoadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .attachmentStoreOp = VK_ATTACHMENT_STORE_OP_STORE, .clearValue = clearValues[0], }; VulkanCore::DynamicRendering:: AttachmentDescription depthAttachmentDesc{ .imageView = depthTextures_[0]->vkImageView(), .imageLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, .attachmentLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, .attachmentStoreOp = VK_ATTACHMENT_STORE_OP_STORE, .clearValue = clearValues[1], }; -
算法重复多次,次数等于通道数。必须注意将每个附件转换为正确的布局,遵守乒乓机制:在之前用作颜色附件的纹理需要转换为将被着色器读取的纹理,反之亦然:
for (uint32_t currentPeel = 0; currentPeel < numPeels_; ++currentPeel) { colorTextures_[currentPeel % 2] ->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); colorTextures_[(currentPeel + 1) % 2] ->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); depthTextures_[currentPeel % 2] ->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL); depthTextures_[(currentPeel + 1) % 2] ->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); colorAttachmentDesc.imageView = colorTextures_[currentPeel % 2] ->vkImageView(); depthAttachmentDesc.imageView = depthTextures_[currentPeel % 2] ->vkImageView(); -
然后我们开始渲染通道,发出绘制调用,并结束通道:
VulkanCore::DynamicRendering::beginRenderingCmd( commandBuffer, colorTextures_[currentPeel % 2] ->vkImage(), 0, {{0, 0}, {colorTextures_[currentPeel % 2] ->vkExtents() .width, colorTextures_[currentPeel % 2] ->vkExtents() .height}}, 1, 0, {colorAttachmentDesc}, &depthAttachmentDesc, nullptr, colorTextures_[currentPeel % 2] ->vkLayout(), VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); vkCmdSetViewport(commandBuffer, 0, 1, &viewport_); vkCmdSetScissor(commandBuffer, 0, 1, &scissor_); pipeline_->bind(commandBuffer); ... // Perform the draw call VulkanCore::DynamicRendering::endRenderingCmd( commandBuffer, colorTextures_[currentPeel % 2] ->vkImage(), VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_UNDEFINED); -
通过渲染通道,图像被转换为正确的布局,因此我们只需要将当前通道的结果复制到下一个通道中将用作颜色附件的另一个纹理中:
vkCmdBlitImage( commandBuffer, colorTextures_[currentPeel % 2] ->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, colorTextures_[(currentPeel + 1) % 2] ->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion, VK_FILTER_NEAREST); -
顶点片段对于深度剥离来说并不特殊,但片段着色器必须丢弃比之前通道中收集的片段更靠近摄像机的片段。片段着色器还执行当前附件的混合:
#version 460 layout(set = 1, binding = 0) uniform ObjectProperties { vec4 color; mat4 model; }objectProperties; layout(set = 2, binding = 0) uniform sampler2D depth; layout(set = 2, binding = 2) uniform sampler2D temporaryColor; layout(location = 0) out vec4 outColor; void main() { float fragDepth = gl_FragCoord.z; float peelDepth = texture(depth, gl_FragCoord.xy / textureSize(depth, 0)) .r; if (fragDepth <= peelDepth) { discard; } vec4 tmpColor = texture(temporaryColor, gl_FragCoord.xy / textureSize(temporaryColor, 0)); vec3 mulTmpColor = tmpColor.xyz * tmpColor.a; vec3 mulObjProp = objectProperties.color.xyz * (1.0 - tmpColor.a); outColor = vec4( tmpColor.a * (objectProperties.color.a * objectProperties.color.rgb) + tmpColor.rgb, (1 - objectProperties.color.a) * tmpColor.a); }混合方程是一个特殊的方程,用于前后合成,如路易斯·巴沃伊和凯文·迈尔斯在 2008 年发表的使用双深度剥离的独立顺序透明度论文中所述。混合方程如下:
C dst = A dst(A src C src)+ C dst
A dst = (1 − A src) A dst
在下面的配方中,我们将探讨如何增强深度剥离技术,使其更高效。
实现双深度剥离
深度剥离算法的主要缺点之一是它需要多次传递,每次传递可能包括对整个场景进行光栅化。双深度剥离算法通过同时剥离两层来扩展原始的深度剥离算法,几乎有效地将传递次数减半。在本配方中,我们将专注于实现双深度剥离算法。我们将解决深度剥离算法的一个关键限制,即其需要多次传递,可能涉及对整个场景进行光栅化。您将了解双深度剥离算法如何通过同时剥离两层来改进原始算法,从而可能将传递次数减少近一半。这一见解将使您能够以更高的效率和速度处理复杂场景。
准备工作
在存储库中,深度剥离算法由DualDepthPeeling类实现,位于source/enginecore/passes/DualDepthPeeling.hpp和cpp文件中。
在开始之前,我们需要将VkPhysicalDeviceFeatures::independentBlend属性设置为 true。此属性允许我们为与图形管道关联的每个附件使用不同的混合方程。
在每个通道上,使用具有两个组件 R 和 G(在代码中我们使用VK_FORMAT_R32G32_SFLOAT格式)的深度图,同时存储前剥离和后剥离。与图一起使用的混合方程式是VK_BLEND_OP_MAX。当存储当前片段的深度时,我们将其编码为vec2(-depth, depth)。R 组件存储前剥离的负深度,而 G 组件存储后剥离的实际深度。Max混合方程式确保我们只存储最近的前剥离,通过取负值来实现。后剥离始终保证是最远的,因为它们以正深度存储。
前剥离与修改后的混合方程式混合:
C dst = A dst(A src C src)+ C dst
A dst = (1 − A src) A dst
当后剥离与常规的从后向前混合方程式混合时
C dst = A src C src + (1 − A src) C dst
在概述了准备步骤之后,你现在可以深入到双深度剥离算法的实现中。在下一节中,我们将引导你逐步执行此算法,使用上面讨论的见解和技术。
如何做到这一点...
Vulkan 中的算法包括使用 2 个颜色附件,一个用于前剥离,一个用于后剥离。它还使用 2 个具有相同格式的深度缓冲区。一个用于偶数通道,另一个用于奇数通道。
-
我们首先指定每个附件的混合操作:
const VkPipelineColorBlendAttachmentState depthBlendState = { .blendEnable = true, .srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA, .dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_MAX, .srcAlphaBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA, .dstAlphaBlendFactor = VK_BLEND_FACTOR_DST_ALPHA, .alphaBlendOp = VK_BLEND_OP_MAX, .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, }; const VkPipelineColorBlendAttachmentState frontBlendState = { // front color attachment .blendEnable = true, .srcColorBlendFactor = VK_BLEND_FACTOR_DST_ALPHA, .dstColorBlendFactor = VK_BLEND_FACTOR_ONE, .colorBlendOp = VK_BLEND_OP_ADD, .srcAlphaBlendFactor = VK_BLEND_FACTOR_ZERO, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .alphaBlendOp = VK_BLEND_OP_ADD, .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, }; const VkPipelineColorBlendAttachmentState backBlendState = { // back color attachment .blendEnable = true, .srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA, .dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD, .srcAlphaBlendFactor = VK_BLEND_FACTOR_ZERO, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .alphaBlendOp = VK_BLEND_OP_ADD, .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, };这些
VkPipelineColorBlendAttachmentState结构实例在创建图形管线时添加到VkPipelineColorBlendStateCreateInfo结构中,并且按照在帧缓冲区中设置附件的顺序提供。 -
由
DualDepthPeeling::draw方法实现的算法首先清除深度缓冲区和颜色附件:void DualDepthPeeling::draw( VkCommandBuffer commandBuffer, int index, const std::vector< std::shared_ptr<VulkanCore::Buffer>> &buffers, uint32_t numMeshes) { // Clear Depth 0 { vkCmdClearColorImage( commandBuffer, depthMinMaxTextures_[0]->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range); } // Clear Depth 1 { vkCmdClearColorImage( commandBuffer, depthMinMaxTextures_[1]->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range); } // Clear color attachments { for (uint32_t i = 0; i < colorTextures_.size(); ++i) { vkCmdClearColorImage( commandBuffer, colorTextures_[i]->vkImage(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range); } } -
然后将深度纹理转换为第一个通道:
depthMinMaxTextures_[0]->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); depthMinMaxTextures_[1]->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); -
前后纹理都被绑定为附件,并在每个通道中加载和存储。一个深度纹理也被绑定为一个附件,并在每个通道后清除到
(-99,999; 99,999)并存储。另一个深度纹理被绑定为一个供片段着色器读取的纹理:VulkanCore::DynamicRendering::AttachmentDescription colorAttachmentDesc_Front{ .imageView = colorTextures_[0] ->vkImageView(), .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .attachmentLoadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .attachmentStoreOp = VK_ATTACHMENT_STORE_OP_STORE, }; VulkanCore::DynamicRendering::AttachmentDescription colorAttachmentDesc_Back{ .imageView = colorTextures_[1] ->vkImageView(), .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .attachmentLoadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .attachmentStoreOp = VK_ATTACHMENT_STORE_OP_STORE, }; const VkClearValue clearDepthMinMax = { .color = {-99999.0f, -99999.0f, 0.0f, 0.0f}, }; VulkanCore::DynamicRendering::AttachmentDescription depthMinMaxAttachmentDesc{ .imageView = depthMinMaxTextures_[0] ->vkImageView(), .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .attachmentLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, .attachmentStoreOp = VK_ATTACHMENT_STORE_OP_STORE, .clearValue = clearDepthMinMax, }; -
对于每个通道,我们首先将颜色和深度附件转换为正确的布局:
for (uint32_t currentPeel = 0; currentPeel < numPeels_; ++currentPeel) { const uint32_t readIdx = currentPeel % 2; colorTextures_[0]->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); colorTextures_[1]->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); depthMinMaxTextures_[currentPeel % 2] ->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); depthMinMaxTextures_[(currentPeel + 1) % 2] ->transitionImageLayout( commandBuffer, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); depthMinMaxAttachmentDesc.imageView = depthMinMaxTextures_[readIdx] ->vkImageView(); -
使用动态渲染为渲染通道提供附件,渲染场景,并完成通道:
VulkanCore::DynamicRendering:: beginRenderingCmd( commandBuffer, colorTextures_[0]->vkImage(), 0, {{0, 0}, {colorTextures_[0] ->vkExtents() .width, colorTextures_[0] ->vkExtents() .height}}, 1, 0, {depthMinMaxAttachmentDesc, colorAttachmentDesc_Front, colorAttachmentDesc_Back}, &depthAttachmentDesc, nullptr, colorTextures_[0]->vkLayout(), VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); vkCmdSetViewport(commandBuffer, 0, 1, &viewport_); vkCmdSetScissor(commandBuffer, 0, 1, &scissor_); pipeline_->bind(commandBuffer); // Draw geometry VulkanCore::DynamicRendering:: endRenderingCmd( commandBuffer, colorTextures_[0]->vkImage(), VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_UNDEFINED); } -
一旦所有通道都完成,最后一步是混合最后一个前剥离和后剥离。前剥离作为颜色附件和着色器的纹理提供,而后颜色仅作为着色器读取的纹理提供。
{ VulkanCore::DynamicRendering:: beginRenderingCmd(...); vkCmdSetViewport(commandBuffer, 0, 1, &viewport_); vkCmdSetScissor(commandBuffer, 0, 1, &scissor_); pipelineFinal_->bind(commandBuffer); vkCmdDraw(commandBuffer, 4, 1, 0, 0); VulkanCore::DynamicRendering:: endRenderingCmd(...); } }最后一个通道包括绘制一个与视口大小相同的矩形,仅用于混合两个剥离层
-
主要的双深度剥离片段着色器读取前一个传递输出的片段深度值,对其进行解码,并决定是否丢弃该片段:
#version 460 const float MAX_DEPTH = 99999.0; void main() { float fragDepth = gl_FragCoord.z; vec2 lastDepth = texture(depth, gl_FragCoord.xy / textureSize(depth, 0)).rg; depthMinMax.rg = vec2(-MAX_DEPTH); frontColorOut = vec4(0.0f); backColorOut = vec4(0.0f); float nearestDepth = -lastDepth.x; float furthestDepth = lastDepth.y; float alphaMultiplier = 1.0 - lastFrontColor.a; if (fragDepth < nearestDepth || fragDepth > furthestDepth) { return; } if (fragDepth > nearestDepth && fragDepth < furthestDepth) { depthMinMax = vec2(-fragDepth, fragDepth); return; } vec4 color = objectProperties.color; if (fragDepth == nearestDepth) { frontColorOut = vec4( color.rgb * color.a, color.a); } else { backColorOut = color; } } -
最后的传递,其中前表面和后表面的剥离被混合,使用一个简单的片段着色器:
#version 460 layout(set = 0, binding = 0) uniform sampler2D front; layout(set = 0, binding = 1) uniform sampler2D back; layout(location = 0) in vec2 fragTexCoord; layout(location = 0) out vec4 outColor; void main() { const vec4 frontColor = texture(front, fragTexCoord); const vec4 backColor = texture(back, fragTexCoord); outColor = vec4(((backColor)*frontColor.a + frontColor) .rgb, (1.0 - backColor.a) * frontColor.a); }
在深入研究双深度剥离算法的复杂性之后,我们将现在将注意力转向下一道菜谱中的另一种高级技术。
实现链表无序透明度
无序透明度使用每个像素的链表来处理透明度,它利用数据结构,特别是链表,来存储每个像素的片段。链表的每个节点包含有关片段颜色和深度值的信息,节点以遵循片段到达顺序的方式连接,从而使得排序变得不必要。
这种方法有效地消除了过度绘制和与深度排序相关的伪影。通过关注每个片段的深度值,这种方法提供了更准确、视觉上更令人愉悦的透明物体表示。在本菜谱中,我们将深入了解实现链表无序透明度(OIT)技术的详细过程。您将了解这种技术如何利用每个像素的链表来有效地管理透明度,消除过度绘制和深度排序伪影的问题。
准备工作
在存储库中,链表算法是通过位于 source/enginecore/passes/ 的 OitLinkedListPass.hpp 和 cpp 文件中的 OitLinkedListPass 类实现的。相应的着色器是 source/enginecore/resources/shaders/OitLinkedListBuildPass.frag 和 source/enginecore/resources/shaders/OITLinkedListCompositePass.frag
算法首先为每个像素初始化一个空列表,并在场景渲染过程中,按照它们被处理的顺序将节点添加到列表中。这是在两个渲染阶段中完成的。在第一个传递,也称为构建传递,每个片段的深度、颜色和下一个节点指针被写入缓冲区。第二个传递,也称为解析或合成传递,为每个像素从前到后遍历列表,并根据深度值混合颜色,从而得到最终的像素颜色。
如何做到这一点...
要实现每个像素的链表,我们需要维护各种缓冲区。
-
OitLinkedListPass::init方法负责初始化各种资源。它建立了构建传递和合成传递管道。此外,它为构建传递管道安排必要的资源。下面的代码片段突出了初始化阶段配置的一些关键资源。-
atomicCounterBuffer_:此缓冲区创建用于存储原子计数器。计数器用于在链表缓冲区中为存储新片段分配槽位。 -
linkedListBuffer_:这是主要的缓冲区,它存储了每个像素的片段链表。每个像素可以有多个片段,每个片段都作为链表中的一个Node表示。这个缓冲区的大小由交换链的范围(其宽度和高度)、每个像素的槽位数和 Node 结构的大小决定。 -
linkedListHeadPtrTexture_:这个缓冲区存储了每个像素的链表的头指针。头指针指向链表中的第一个 Node。因为这个缓冲区需要存储交换链中每个像素的指针,所以它被创建为一个二维纹理(图像)。格式VK_FORMAT_R32_UINT表示纹理中的每个元素都是一个 32 位无符号整数,这对于表示指针是合适的。
atomicCounterBuffer_ = context->createBuffer( sizeof(AtomicCounter), ...); auto bufferSize = width * height * slotsPerPixel * sizeof(Node); linkedListBuffer_ = context_->createBuffer( bufferSize, ...); linkedListHeadPtrTexture_ = context->createTexture(...); -
-
算法的实际魔法发生在
draw函数期间。作为第一步,我们使用vkCmdClearColorImage函数将linkedListHeadPtrTexture_中的像素设置为零,并使用vkCmdFillBuffer函数将linkedListBuffer_和atomicCounterBuffer_填充为零,只是为了在我们开始写入之前将一切重置为空状态。vkCmdClearColorImage( commandBuffer, linkedListHeadPtrTexture_ ->vkImage(), VK_IMAGE_LAYOUT_GENERAL, &clearColor, 1, &auxClearRanges); vkCmdFillBuffer( commandBuffer, linkedListBuffer_->vkBuffer(), 0, VK_WHOLE_SIZE, 0); vkCmdFillBuffer( commandBuffer, atomicCounterBuffer_->vkBuffer(), 0, VK_WHOLE_SIZE, 0); -
下一步是设置正确的内存屏障。这些屏障确保在着色器开始从或向缓冲区读取或写入之前,所有清除操作都已完成。第一个屏障是一个内存屏障:
const VkPipelineStageFlags srcStageFlags = VK_PIPELINE_STAGE_TRANSFER_BIT; const VkPipelineStageFlags dstStageFlags = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; { const VkMemoryBarrier barrier = { .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, }; vkCmdPipelineBarrier( commandBuffer, srcStageFlags, dstStageFlags, 0, 1, &barrier, 0, VK_NULL_HANDLE, 0, VK_NULL_HANDLE); -
另外两个屏障是缓冲区屏障,一个用于链表缓冲区,一个用于原子计数器缓冲区:
const VkBufferMemoryBarrier bufferBarriers[2] = { { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, .buffer = linkedListBuffer_->vkBuffer(), .size = linkedListBuffer_->size(), }, { .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, .buffer = atomicCounterBuffer_->vkBuffer(), .size = atomicCounterBuffer_->size(), }, }; vkCmdPipelineBarrier( commandBuffer, srcStageFlags, dstStageFlags, 0, 0, nullptr, 2, &bufferBarriers[0], 0, nullptr); } -
在下一步中,绑定描述符集,更新,并将顶点和索引缓冲区绑定到管线。然后对每个网格发出索引绘制命令。
pipeline_->bind(commandBuffer); for (uint32_t meshIdx = 0; meshIdx < numMeshes; ++meshIdx) { // ... vkCmdDrawIndexed(commandBuffer, vertexCount, 1, 0, 0, 0); } -
顶点片段并不特殊,但片段着色器必须维护一个与头指针一起的链表。以下是我们对
OitLinkedListBuildPass.frag的代码分解,它负责链表 OIT 算法的构建传递:- 我们首先定义一个 Node 结构体,它代表用于处理透明度的链表中的一个节点。它包含颜色和前一个节点的索引。之后,我们声明了几个统一和缓冲区变量,用于对象属性、一个原子计数器、一个节点链表和一个头指针图像。
struct Node { vec4 color; uint previousIndex; float depth; uint padding1; // add 4 byte padding // for alignment uint padding2; // add 4 byte padding // for alignment }; layout(set = 1, binding = 0) uniform ObjectProperties { vec4 color; mat4 model; } objectProperties; layout(set = 2, binding = 0) buffer AtomicCounter { uint counter; }; layout(set = 2, binding = 1) buffer LinkedList { Node transparencyList[]; } transparencyLinkedList; layout(set = 2, binding = 2, r32ui) uniform coherent uimage2D headPointers;- 主函数对原子计数器执行原子加操作,为每个片段获取一个唯一的索引。在计算图像大小并确保新的节点索引不超过链表的最大大小时,它执行原子交换操作以将新节点插入链表的开始位置。最后,它设置链表中新节点的属性。
void main() { // Set the output color to transparent outputColor = vec4(0.0); // Atomic operation to get unique // index for each fragment, don't // return 0 since that will be used as // ll terminator uint newNodeIndex = atomicAdd(counter, 1) + 1; ivec2 size = imageSize(headPointers); // max size of linked list * width * // height if (newNodeIndex > (10 * size.x * size.y) - 1) { return; } // Atomic operation to insert the new // node at the beginning of the linked // list uint oldHeadIndex = imageAtomicExchange( headPointers, ivec2(gl_FragCoord.xy), newNodeIndex); transparencyLinkedList .transparencyList[newNodeIndex] .previousIndex = oldHeadIndex; transparencyLinkedList .transparencyList[newNodeIndex] .color = objectProperties.color; transparencyLinkedList .transparencyList[newNodeIndex] .depth = gl_FragCoord.z; transparencyLinkedList .transparencyList[newNodeIndex] .padding1 = 0; transparencyLinkedList .transparencyList[newNodeIndex] .padding2 = 0; } -
接下来和最后一步是绘制一个全屏四边形。在执行全屏四边形传递之前,我们设置内存和缓冲区屏障以确保
linkedListBuffer_和linkedListHeadPtrTexture_的同步,因为这些资源在合成传递期间被使用。 -
最后,复合通行片段着色器首先获取当前像素的链表头。该列表存储在缓冲区中,每个像素对应于影响该像素的所有片段链表中的第一个节点。创建一个数组来临时存储用于排序的节点。然后我们遍历链表,检索每个节点并将其存储在临时数组中。这个过程一直持续到达到列表的末尾(由
nodeIndex为 0 表示)或者已经检索了 20 个节点:void main() { outputColor = vec4(0.0); // Get the head of the linked list for // the current pixel uint nodeIndex = imageLoad(headPointers, ivec2(gl_FragCoord.xy)).x; // Create a temporary array to store // the nodes for sorting Node nodes[20]; // Assuming a maximum // of 20 overlapping // fragments int numNodes = 0; // Iterate over the linked list while (nodeIndex != 0 && numNodes < 20) { nodes[numNodes] = transparencyLinkedList.transparencyList[nodeIndex]; nodeIndex = nodes[numNodes].previousIndex; numNodes++; } -
使用简单的冒泡排序算法根据节点的深度值按降序对数组中的节点进行排序。这确保了离相机最近的节点最后被混合:
for (int i = 0; i < numNodes; i++) { for (int j = i + 1; j < numNodes; j++) { if (nodes[j].depth > nodes[i].depth) { Node temp = nodes[i]; nodes[i] = nodes[j]; nodes[j] = temp; } } } -
最后,使用混合函数从后向前混合每个节点的颜色:
// Blend the colors from back to front for (int i = 0; i < numNodes; i++) { outputColor = mix(outputColor, nodes[i].color, nodes[i].color.a); } }
此算法给出了非常好的结果,如果你重视正确性,它是一个极好的选择。它比下一道菜谱中介绍的要慢一些,但它是我们在本章讨论的所有不同算法中最直观的算法。
还有更多…
我们想指出一种额外的技术,称为尾部混合,它可以有效地与上述技术结合。我们方法的一个局限性是每个像素可以容纳的片段的最大数量,这通常由场景预期的深度复杂性和可用的内存决定。在具有许多重叠透明物体的复杂场景中,像素的片段数可能会超过这个限制。这时尾部混合就派上用场了。当一个链表达到其容量时,任何额外的片段都会直接与列表中最后一个节点(也称为尾部)的颜色混合,因此得名尾部混合。尾部混合的好处是它能够处理具有极高深度复杂性的场景,而无需扩展链表的最大长度,从而节省内存。然而,一个潜在的缺点是它可能产生不太精确的结果,因为混合是顺序相关的,并且与列表中其他片段无关的尾部片段被混合。
参见
请参阅以下链接:
- 探索和扩展 OIT 算法的连续性:
cwyman.org/papers/hpg16_oitContinuum.pdf
实现加权顺序无关透明度
加权顺序无关透明度(WOIT)通过使用加权平均的概念来处理透明度,而不是使用像链表或深度剥离这样的数据结构或层。
此方法不需要排序或链表或多个传递,从而减少了与这些操作相关的开销。最终颜色是通过将颜色缓冲区与权重缓冲区归一化来计算的,这提供了颜色及其权重的汇总视图。尽管在复杂场景中可能不如按像素链表精确,但 WOIT 为处理具有较低深度复杂性的场景中的透明度提供了一个性能高效的解决方案。在本食谱中,您将了解 WOIT 技术。我们将探讨这种方法如何使用加权平均值来处理透明度,避免使用链表或多个传递等数据结构,从而减少相关的开销。
准备工作
在存储库中,WOIT 算法由 OitWeightedPass 类实现,位于源代码 /enginecore/passes/ OitWeightedPass.hpp 和 cpp 文件中。相应的着色器是 source/enginecore/resources/shaders/OitWeighted.frag 和 source/enginecore/resources/shaders/OITWeightedComposite.frag
WOIT 算法首先为每个像素初始化两个空缓冲区,一个用于累积颜色,另一个用于累积权重。随着场景的渲染,算法处理每个透明片段,并在单个渲染传递中更新这些缓冲区。在此传递期间,每个片段的颜色乘以其 alpha 值(权重)并添加到颜色缓冲区,而 alpha 值本身则添加到权重缓冲区。这个过程对所有片段持续进行,根据它们的透明度累积和混合它们的贡献。一旦处理完所有片段,就会执行一个最终的复合步骤,其中颜色缓冲区中累积的颜色除以权重缓冲区中的总权重。这产生了最终的像素颜色,根据它们的权重提供了一个所有透明片段的复合视图。
如何实现它...
以下步骤提供了使用 Vulkan API 实现 WOIT 技术的指南。
-
OitWeightedPass::init方法负责初始化各种资源。它建立了累积传递和复合传递管道。此外,它为累积传递管道安排必要的资源。colorTexture_使用VK_FORMAT_R16G16B16A16_SFLOAT格式。这种格式表示 4 个通道(R、G、B、A)的 16 位浮点数,为颜色表示提供了高精度。对于颜色缓冲区来说,具有高精度格式很重要,因为在累积传递期间,来自各个片段的颜色会相加:colorTexture_ = context->createTexture(VK_IMAGE_TYPE_2D, VK_FORMAT_R16G16B16A16_SFLOAT ... -
alphaTexture_ 使用VK_FORMAT_R16_SFLOAT格式,这是一个单一的 16 位浮点数。这足够了,因为我们只存储 alpha(不透明度)值:alphaTexture_ = context->createTexture(VK_IMAGE_TYPE_2D, VK_FORMAT_R16_SFLOAT ... -
由于 WOIT 依赖于混合,因此正确设置混合附件非常重要。下面的管道描述符
gpDesc是使用两个VkPipelineColorBlendAttachmentState结构创建的,每个附件一个。对于第一个混合附件(对应于颜色纹理),源和目的地的混合因子都设置为VK_BLEND_FACTOR_ONE,混合操作为VK_BLEND_OP_ADD。这有效地实现了加法混合,其中新片段的颜色被添加到颜色缓冲区中现有的颜色。const VulkanCore::Pipeline:: GraphicsPipelineDescriptor gpDesc = { .blendAttachmentStates_ = { VkPipelineColorBlendAttachmentState{ .blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE, .dstColorBlendFactor = VK_BLEND_FACTOR_ONE, .colorBlendOp = VK_BLEND_OP_ADD, .srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .alphaBlendOp = VK_BLEND_OP_ADD, .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, }, -
对于第二个混合附件(对应于 alpha 纹理),源 alpha 混合因子为
VK_BLEND_FACTOR_ZERO,目的 alpha 混合因子为VK_BLEND_FACTOR_ONE_MINUS_SRC_COLOR。这种配置确保新片段的 alpha(或权重)在 alpha 缓冲区中累积:VkPipelineColorBlendAttachmentState{ .blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ZERO, .dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_COLOR, .colorBlendOp = VK_BLEND_OP_ADD, .srcAlphaBlendFactor = VK_BLEND_FACTOR_ZERO, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .alphaBlendOp = VK_BLEND_OP_ADD, .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT, }, }, }; -
接下来,我们需要初始化合成管道。这可以作为一个 Vulkan 子通道实现,但为了简单起见,我们将其保留为单独的通道。合成管道使用
VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA作为srcColorBlendFactor和VK_BLEND_FACTOR_SRC_ALPHA作为dstColorBlendFactor创建。这种配置会导致传入片段的颜色和 alpha 值与帧缓冲区中当前的颜色和 alpha 值混合,传入片段的 alpha 值控制传入颜色覆盖现有颜色的程度。
-
绘制函数是实际渲染发生的地方,其实现简单,使用
vkCmdDrawIndexed来绘制多个网格。下面我们展示在此步骤中使用的片段着色器。在这个片段着色器中,视图空间的深度被缩放以提供深度权重;距离较近的片段被分配更大的权重。然后,计算最大颜色分量乘以 alpha 的值,以使鲜艳的像素具有更大的权重。计算出的颜色权重确保不超过 1.0,并与 alpha 值比较以取最大值。然后计算深度权重,并将其限制在特定范围内。最终的权重是颜色和深度权重的乘积。然后,颜色通过其 alpha 值进行预乘,以防止混合过程中的过度饱和。这个着色器输出加权颜色和片段的原始 alpha 值。void main() { const float scaledDepth = -(inViewSpaceDepth * 3.0) / 200; float maxColorComponent = max(max(objectProperties.color.r, objectProperties.color.g), objectProperties.color.b); float weightedColor = maxColorComponent * objectProperties.color.a; float weightedColorAlpha = max(min(1.0, weightedColor), objectProperties.color.a); float depthWeight = 0.03 / (1e-5 + pow(scaledDepth, 4.0)); depthWeight = clamp(depthWeight, 0.01, 4000); const float weight = weightedColorAlpha * depthWeight; outputColor = vec4(objectProperties.color.rgb * objectProperties.color.a, objectProperties.color.a) * weight; outputAlpha = objectProperties.color.a; } -
最后一步是使用合成管道绘制全屏四边形,它从两个纹理(
colorData和alphaData)中读取当前片段累积的颜色和 alpha 值。累积颜色(accumulateColor)是前一步中每个片段的颜色、alpha 和权重的乘积之和。alpha 值(alpha)是片段的原始 alpha 值。在输出颜色(outColor)中,累积颜色的 RGB 分量被除以累积 alpha 值以归一化,最小限制为 0.0001 以防止除以零。这是因为累积颜色在前一步中已经通过 alpha 值(和权重)进行了预乘。void main() { vec4 accumulateColor = texture(colorData, fragTexCoord); float alpha = texture(alphaData, fragTexCoord) .r; outColor = vec4( accumulateColor.rgb / max(accumulateColor.a, .0001), alpha); }
这种技术比在“实现链表顺序无关透明度”菜谱中提到的链表技术更快,但它有其缺点,例如权重函数,如果设计不当或测试不足,可能会向结果添加伪影。
更多内容...
在本章中,我们探讨了处理透明度的各种技术。下表突出了每种方法的优缺点:
| 技术 | 内存 | 性能 | 物理正确性 |
|---|---|---|---|
| 链表 OIT | 高,取决于场景复杂性和维护的链表大小 | 中等速度,只需要两次遍历 | 高精度,处理复杂重叠几何形状非常好 |
| 双深度剥离 OIT | 中等,需要存储两个深度缓冲区 | 较慢,因为它需要多次遍历 | 中等精度,难以处理高度复杂的场景。 |
| WOIT | 低,只需存储每个片段的权重和颜色。 | 快速,因为只需要单次遍历 | 低精度,需要仔细管理权重,这可能会依赖于场景。 |
表 5.1 – 各种技术的比较
我们希望表 5.1 能帮助你根据你的用例决定使用哪种技术。
参见
请参阅以下链接以获取有关 WOIT 的更多详细信息:
第六章:抗锯齿技术
抗锯齿可以通过多种方式实现,最常见的是通常由图形 API 提供的方式。在本章中,我们首先探讨如何启用和使用 Vulkan 提供的抗锯齿,然后介绍许多更适合其他需要更好抗锯齿或需要不同算法的其他用例的技术,例如时间抗锯齿。在本章中,我们将引导您了解各种抗锯齿技术,从启用和使用 Vulkan 提供的技术开始,探索针对不同用例的更高级和更合适的方法。目标是让您具备选择和实现最适合您特定需求的最合适的抗锯齿技术的知识和技能,从而提高您渲染图形的视觉效果。
在本章中,我们将介绍以下配方:
-
启用和使用 Vulkan 的 MSAA
-
应用 FXAA
-
利用 TAA
-
应用 DLSS
技术要求
对于本章,您需要确保已安装 VS 2022 以及 Vulkan SDK。对 C++ 编程语言的基本熟悉程度以及对 OpenGL 或任何其他图形 API 的理解将很有用。请重新查看 技术要求 部分的 第一章**,Vulkan 核心概念,以获取有关设置和构建本章可执行文件的详细信息。本章包含多个配方,可以使用以下可执行文件启动:
-
Chapter06_MSAA.exe -
Chapter06_FXAA.exe -
Chapter06_TAA.exe -
Chapter06_DLSS.exe
启用和使用 Vulkan 的 MSAA
MSAA 是一种用于减少可能出现在曲线线和斜边上的锯齿边缘的抗锯齿技术。以下是它的工作概述:
-
多个样本:与常规渲染中只采样一次像素不同,MSAA 在每个像素内进行多次采样。例如,4 x MSAA 需要 4 个样本,而 8 x MSAA 需要 8 个样本。片段着色器为每个样本运行,并将它们的输出存储在 步骤 3 中进行处理。
-
边缘检测:MSAA 只对几何形状边缘的像素进行多采样。与超采样等技术相比,它更高效,后者在更高分辨率下对整个图像进行采样。
-
合并样本:一旦采样完成,它们将被平均(或解析)为像素的单色值。如果一些样本在对象内部,而一些样本在外部,则最终像素颜色将是一个混合色,从而创建更平滑的过渡,并减少锯齿边缘的出现。
在这个配方中,我们将描述您需要采取的步骤来在 Vulkan 中启用 MSAA,因为它由 API 提供。
准备工作
在 Vulkan 中启用 MSAA 需要修改源代码中的多个位置。以下是实现 MSAA 的高级步骤:
-
首先,你需要确保系统支持 MSAA。此外,你还需要确定每像素支持的样本数最大值。
-
纹理需要创建为支持相应数量的样本。
-
需要创建额外的纹理,作为合并样本后的输出(也称为解决附件)。
-
渲染通道需要指定每个附件的样本数,并提供关于解决附件的额外信息。
-
最后,帧缓冲区需要引用解决附件。
使用 MSAA 进行渲染涉及样本数大于 1 的图像。然而,这些多样本图像不能直接使用VK_IMAGE_LAYOUT_PRESENT_SRC_KHR进行展示。VK_IMAGE_LAYOUT_PRESENT_SRC_KHR布局是为准备展示的单样本图像设计的,每个像素有一个颜色值。这就是为什么需要一个解决操作来将多样本图像转换为单样本图像。最终的抗锯齿输出,适合展示,需要写入另一个具有VK_SAMPLE_COUNT_1_BIT样本数的图像。这意味着每个样本数大于 1 的颜色附件都需要一个具有VK_SAMPLE_COUNT_1_BIT样本数的关联附件。这些附加的附件,称为解决附件,用于存储最终的抗锯齿输出。在解决操作期间,多样本的值被合并并写入到解决附件中,创建出可以展示的最终单样本图像。
如何实现...
在 Vulkan 中启用 MSAA 并不困难,但需要在代码的多个部分进行更改。以下是如何一步步实现的指南:
-
在下面的代码块中,我们处理
VkPhysicalDeviceProperties对象,特别是关注framebufferColorSampleCounts和framebufferDepthSampleCounts属性。这些属性帮助我们确定每像素支持的颜色和深度样本数的最大值。这种能力取决于硬件,因此在使用之前必须先进行检查。最大支持值如下:VkPhysicalDeviceProperties::limits::framebufferColorSampleCounts VkPhysicalDeviceProperties::limits::framebufferDepthSampleCounts -
最大样本数以
VkSampleCountFlagBits类型的位字段提供,标志包括VK_SAMPLE_COUNT_1_BIT、VK_SAMPLE_COUNT_2_BIT、VK_SAMPLE_COUNT_4_BIT等,直到VK_SAMPLE_COUNT_64_BIT。 -
在图像创建过程中,必须指定纹理支持的样本数。这是通过设置
VkImageCreateInfo结构体的samples成员来完成的,该成员的类型为VkSampleCountFlagBits,例如以下内容:VkImageCreateInfo newTexture = { ... .samples = VK_SAMPLE_COUNT_8_BIT, }; -
在创建渲染通道时,附件描述必须通过设置
VkAttachmentDescription::samples字段来指示样本数:VkAttachmentDescription attachment = { ... .samples = VK_SAMPLE_COUNT_8_BIT, }; -
需要将
VkAttachmentDescription结构的实例添加到渲染通道附件列表VkSubpassDescription::pColorAttachments中,对于渲染通道中的每个解决附件。解决附件必须将其样本字段设置为VK_SAMPLE_COUNT_1_BIT,因为多采样图像的分辨率导致每个像素只有一个样本。这是因为来自多采样图像的多个样本被解析为该像素的一个最终颜色值。以下是如何创建和配置此类VkAttachmentDescription实例的方法:VkAttachmentDescription resolveAttachment = { ... .samples = VK_SAMPLE_COUNT_1_BIT, } -
必须创建
VkAttachmentReference结构的实例来引用此解决附件:VkAttachmentReference resolveAttachmentRef{ .attachment = <index of resolve texture in the attachmentDescriptor vector>, .layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, }VkAttachmentReference::attachment字段是一个整数,它指向VkRenderPassCreateInfo::pAttachments数组中相应索引的解决附件。 -
最后,描述解决附件的附件引用列表被添加到
VkSubpassDescription::pResolveAttachments字段中。图 6.1 展示了每个组件的设置方式以及它们如何由渲染通道和子通道描述结构引用。深度/模板附件必须具有与颜色附件相同的样本计数,并且解决附件的数量必须等于颜色附件的数量。

图 6.1 – 渲染通道配置
在前面的图中,我们展示了纹理采样计数配置及其由渲染通道和子通道描述结构引用的方式。这种配置对于在 Vulkan 中启用 MSAA 至关重要。
应用 FXAA
FXAA 是一种屏幕空间抗锯齿技术,它可以作为一个额外的全屏后处理通道实现。FXAA 通过识别图像中的边缘并对其进行平滑处理来减少锯齿的出现。由于不需要从场景中获取任何额外的信息,FXAA 可以轻松地集成到现有代码中。它还因为只处理最终渲染图像的像素及其一些邻居而非常快速。在本食谱中,你将了解 FXAA 技术。你将理解它作为屏幕空间抗锯齿方法的功能,如何通过后处理通道应用它,以及为什么它是一个有益的工具,因为它易于集成且速度快。请注意,FXAA 通常在伽玛校正或任何 sRGB 转换之前应用。这是因为 FXAA 在线性 RGB 数据上工作得最好。如果你在伽玛校正或 sRGB 转换后应用 FXAA,可能会导致边缘检测错误,从而降低抗锯齿效果。
准备工作
FXAA 算法通过 FXAAPass 类在我们的仓库中实现,该类位于 source/enginecore/passes/FXAA.cpp 和 FXAA.hpp 文件中。该通道使用的着色器位于 source/enginecore/resources/shaders/fxaa.frag。
如何操作...
算法可以完全在片段着色器中实现,该着色器使用最终渲染图像作为输入。着色器还需要视口大小,这可以作为推送常量提供:
-
着色器在输入和输出方面都很简单,只需要一个输入纹理和视口大小来处理:
#version 460 layout(push_constant) uniform Viewport { uvec2 size; } ViewportSize; layout(set = 0, binding = 0) uniform sampler2D inputTexture; layout(location = 0) out vec4 outColor; -
FXAA 算法作用于像素的亮度,因此我们需要一个函数将 RGB 值转换为亮度:
float rgb2luma(vec3 rgb) { return dot(rgb, vec3(0.299, 0.587, 0.114)); } -
这里有一些用于边缘检测部分的常量:
const float EDGE_THRESHOLD_MIN = (1.0 / 16.0); const float EDGE_THRESHOLD_MAX = (1.0 / 8.0); const float PIXEL_BLEND_LIMIT_TO_REDUCE_BLURRING = (3.0 / 4.0); const float MIN_PIXEL_ALIASING_REQUIRED = (1.0 / 8.0); const float NUM_LOOP_FOR_EDGE_DETECTION = 1; -
为了简化代码,我们将使用一个数组来存储相邻像素的亮度和 RGB 值。我们还将使用常量来帮助引用向量元素,而不仅仅是整数:
const int Center = 0; const int Top = 1; const int Bottom = 2; const int Left = 3; const int Right = 4; const int TopRight = 5; const int BottomRight = 6; const int TopLeft = 7; const int BottomLeft = 8; vec2 offsets[] = { vec2( 0, 0), vec2( 0, -1), vec2( 0, 1), vec2(-1, 0), vec2( 1, 0), vec2( 1, -1), vec2( 1, 1), vec2(-1, -1), vec2(-1, 1)}; -
算法封装在
applyFXAA函数中,该函数接受像素屏幕坐标、要处理的渲染图像以及视口大小:vec4 applyFXAA(vec2 screenCoord, sampler2D inputTexture, uvec2 viewportSize) { -
第一步是计算所有八个相邻像素的亮度和 RGB 值,以及最低和最高亮度的范围。如果值低于某个阈值,则不执行抗锯齿。阈值用于确定像素是否在边缘上;其值表示像素与其邻居之间必须存在的最小亮度差异,以便该像素被视为边缘的一部分:
const vec2 viewportSizeInverse = vec2( 1.0 / viewportSize.x, 1.0 / viewportSize.y); const vec2 texCoord = screenCoord * viewportSizeInverse; float minLuma = 100000000; float maxLuma = 0; float lumas[9]; vec3 rgb[9]; vec3 rgbSum = vec3(0, 0, 0); for (int i = 0; i < 9; ++i) { rgb[i] = texture(inputTexture, texCoord + offsets[i] * viewportSizeInverse) .rgb; rgbSum += rgb[i]; lumas[i] = rgb2luma(rgb[i]); if (i < 5) { minLuma = min(lumas[i], minLuma); maxLuma = max(lumas[i], maxLuma); } } const float rangeLuma = maxLuma - minLuma; if (rangeLuma < max(EDGE_THRESHOLD_MIN, EDGE_THRESHOLD_MAX * maxLuma)) { return vec4(rgb[Center], 1.0); } -
所有相邻像素的平均亮度和中心像素之间的差异告诉我们是否需要执行抗锯齿算法以及所需的混合量。它还将混合量限制在 0 和
PIXEL_BLEND_LIMIT_TO_REDUCE_BLURRING之间,以减少模糊:const float lumaTopBottom = lumas[Top] + lumas[Bottom]; const float lumaLeftRight = lumas[Left] + lumas[Right]; const float lumaTopCorners = lumas[TopLeft] + lumas[TopRight]; const float lumaBottomCorners = lumas[BottomLeft] + lumas[BottomRight]; const float lumaLeftCorners = lumas[TopLeft] + lumas[BottomLeft]; const float lumaRightCorners = lumas[TopRight] + lumas[BottomRight]; const float lumaTBLR = lumaTopBottom + lumaLeftRight; const float averageLumaTBLR = (lumaTBLR) / 4.0; const float lumaSubRange = abs(averageLumaTBLR - lumas[Center]); float pixelblendAmount = max(0.0, (lumaSubRange / rangeLuma) - MIN_PIXEL_ALIASING_REQUIRED); pixelblendAmount = min( PIXEL_BLEND_LIMIT_TO_REDUCE_BLURRING, pixelblendAmount * (1.0 / (1.0 - MIN_PIXEL_ALIASING_REQUIRED))); -
下一步是确定边缘是否比水平方向更垂直,并使用
findEndPointPosition函数初始化用于找到边缘端点的变量:const vec3 averageRGBNeighbor = rgbSum * (1.0 / 9.0); const float verticalEdgeRow1 = abs(-2.0 * lumas[Top] + lumaTopCorners); const float verticalEdgeRow2 = abs(-2.0 * lumas[Center] + lumaLeftRight); const float verticalEdgeRow3 = abs( -2.0 * lumas[Bottom] + lumaBottomCorners); const float verticalEdge = (verticalEdgeRow1 + verticalEdgeRow2 * 2.0 + verticalEdgeRow3) / 12.0; const float horizontalEdgeCol1 = abs(-2.0 * lumas[Left] + lumaLeftCorners); const float horizontalEdgeCol2 = abs(-2.0 * lumas[Center] + lumaTopBottom); const float horizontalEdgeCol3 = abs(-2.0 * lumas[Right] + lumaRightCorners); const float horizontalEdge = (horizontalEdgeCol1 + horizontalEdgeCol2 * 2.0 + horizontalEdgeCol3) / 12.0; const bool isHorizontal = horizontalEdge >= verticalEdge; const float luma1 = isHorizontal ? lumas[Top] : lumas[Left]; const float luma2 = isHorizontal ? lumas[Bottom] : lumas[Right]; const bool is1Steepest = abs(lumas[Center] - luma1) >= abs(lumas[Center] - luma2); float stepLength = isHorizontal ? -screenCoordToTextureCoord.y : -screenCoordToTextureCoord.x; float lumaHighContrastPixel; if (is1Steepest) { lumaHighContrastPixel = luma1; } else { lumaHighContrastPixel = luma2; // Also reverse the direction: stepLength = -stepLength; } vec2 outPosToFetchTexelForEdgeAntiAliasing; vec3 rgbEdgeAntiAliasingPixel = rgb[Center]; -
findEndPointPosition函数在认为需要抗锯齿时返回1,否则返回0。它还会返回将要与正在抗锯齿的像素混合的纹理像素的坐标。我们将在 步骤 11 中研究findEndPointPosition函数:const float res = findEndPointPosition( inputTexture, texCoord, lumas[Center], lumaHighContrastPixel, stepLength, screenCoordToTextureCoord, isHorizontal, outPosToFetchTexelForEdgeAntiAliasing); -
如果返回值是
1.0,我们将通过将原始像素的颜色与outPosToFetchTexelForEdgeAntiAliasing坐标处的纹理像素的颜色混合来执行抗锯齿。要使用的混合因子(pixelblendAmount)已在 步骤 7 中计算:if (res == 1.0) { rgbEdgeAntiAliasingPixel = texture( inputTexture, outPosToFetchTexelForEdgeAntiAliasing) .rgb; } return vec4(mix(rgbEdgeAntiAliasingPixel, averageRGBNeighbor, pixelblendAmount), 1.0); } -
findEndPointPosition函数执行一项重要任务——它遍历图像以寻找边缘端点,从正在处理的中心像素向两个方向移动。为了完成这项任务,它需要一些信息。首先,它需要正在处理的纹理,即函数将遍历的图像。接下来,它需要正在处理的像素的坐标,这作为函数遍历的起点。函数还需要知道像素的亮度,或亮度。此外,它必须了解最高对比度像素的亮度,这是一个基于正在检查的边缘是更水平还是更垂直而确定的元素。另一条关键信息是步长,它,就像最高对比度像素的亮度一样,也取决于边缘的角度。函数需要纹理坐标中一个像素的长度,以便准确遍历图像。最后,它需要一个标志,指示边缘是更水平还是更垂直,以正确理解边缘的方向。如果它认为需要进行抗锯齿处理,则返回1,否则返回0。它还返回包含用于抗锯齿的 RGB 值的像素坐标:float findEndPointPosition( sampler2D inputTexture, vec2 textureCoordMiddle, float lumaMiddle, float lumaHighContrastPixel, float stepLength, vec2 screenCoordToTextureCoord, bool isHorizontal, out vec2 outPosToFetchTexelForEdgeAntiAliasing) { -
根据边缘是否水平,该函数初始化高对比度像素的方向和位置:
vec2 textureCoordOfHighContrastPixel = textureCoordMiddle; // Direction of the edge vec2 edgeDir; if (isHorizontal) { textureCoordOfHighContrastPixel.y = textureCoordMiddle.y + stepLength; textureCoordOfHighContrastPixel.x = textureCoordMiddle.x; edgeDir.x = screenCoordToTextureCoord.x; edgeDir.y = 0.0; } else { textureCoordOfHighContrastPixel.x = textureCoordMiddle.x + stepLength; textureCoordOfHighContrastPixel.y = textureCoordMiddle.y; edgeDir.y = screenCoordToTextureCoord.y; edgeDir.x = 0.0; } -
在我们开始寻找边缘端点之前,我们需要设置一些在循环中使用的变量:
// Prepare for the search loop: float lumaHighContrastPixelNegDir; float lumaHighContrastPixelPosDir; float lumaMiddlePixelNegDir; float lumaMiddlePixelPosDir; bool doneGoingThroughNegDir = false; bool doneGoingThroughPosDir = false; vec2 posHighContrastNegDir = textureCoordOfHighContrastPixel - edgeDir; vec2 posHighContrastPosDir = textureCoordOfHighContrastPixel + edgeDir; vec2 posMiddleNegDir = textureCoordMiddle - edgeDir; vec2 posMiddlePosDir = textureCoordMiddle + edgeDir; -
循环最多迭代
NUM_LOOP_FOR_EDGE_DETECTION次。它通过检查从中间像素的正负方向上的亮度差异来寻找边缘。当同一方向上连续两个点的亮度差异超过阈值时,就会检测到边缘(我们将在 步骤 20 中查看processDirection函数):for (int i = 0; i < NUM_LOOP_FOR_EDGE_DETECTION; ++i) { // Negative direction processing if (!doneGoingThroughNegDir) { processDirection(doneGoingThroughNegDir, posHighContrastNegDir, posMiddleNegDir, -edgeDir, lumaHighContrastPixel, lumaMiddle); } // Positive direction processing if (!doneGoingThroughPosDir) { processDirection(doneGoingThroughPosDir, posHighContrastPosDir, posMiddlePosDir, edgeDir, lumaHighContrastPixel, lumaMiddle); } // If both directions are done, exit the loop if (doneGoingThroughNegDir && doneGoingThroughPosDir) { break; } } -
函数现在计算从中间像素到检测到的边缘端点的距离,包括负方向和正方向:
float dstNeg; float dstPos; if (isHorizontal) { dstNeg = textureCoordMiddle.x - posMiddleNegDir.x; dstPos = posMiddlePosDir.x - textureCoordMiddle.x; } else { dstNeg = textureCoordMiddle.y - posMiddleNegDir.y; dstPos = posMiddlePosDir.y - textureCoordMiddle.y; } -
它还检查哪个端点更接近中间像素:
bool isMiddlePixelCloserToNeg = dstNeg < dstPos; float dst = min(dstNeg, dstPos); float lumaEndPointOfPixelCloserToMiddle = isMiddlePixelCloserToNeg ? lumaMiddlePixelNegDir : lumaMiddlePixelPosDir; -
根据接近中间像素的端点和中间像素本身的亮度差异,认为需要进行抗锯齿处理:
bool edgeAARequired = abs(lumaEndPointOfPixelCloserToMiddle - lumaHighContrastPixel) < abs(lumaEndPointOfPixelCloserToMiddle - lumaMiddle); -
使用到边缘端点的距离,以下代码片段计算所需的抗锯齿像素偏移量:
float negInverseEndPointsLength = -1.0 / (dstNeg + dstPos); float pixelOffset = dst * negInverseEndPointsLength + 0.5; outPosToFetchTexelForEdgeAntiAliasing = textureCoordMiddle; if (isHorizontal) { outPosToFetchTexelForEdgeAntiAliasing.y += pixelOffset * stepLength; } else { outPosToFetchTexelForEdgeAntiAliasing.x += pixelOffset * stepLength; } -
如果需要边缘抗锯齿,则函数返回
1.0,否则返回0.0:return edgeAARequired ? 1.0 : 0.0; } -
processDirection检查一定方向(由edgeIncrement给出)中像素的亮度值,以检查高对比度或边缘。它将继续检查该方向上的位置,直到满足一定的对比度条件。一旦满足条件,它将设置doneGoingThroughDir标志为true,表示它已在该方向上完成处理:void processDirection(inout bool doneGoingThroughDir, inout vec2 posHighContrast, inout vec2 posMiddle, float edgeIncrement, float lumaHighContrastPixel, float lumaMiddle) { float lumaHighContrastPixelDir = rgb2luma( texture(inputTexture, posHighContrast).rgb); float lumaMiddlePixelDir = rgb2luma( texture(inputTexture, posMiddle).rgb); doneGoingThroughDir = abs(lumaHighContrastPixelDir - lumaHighContrastPixel) > abs(lumaHighContrastPixelDir - lumaMiddle) || abs(lumaMiddlePixelDir - lumaMiddle) > abs(lumaMiddlePixelDir - lumaHighContrastPixel); // Update position for next iteration if not // done if (!doneGoingThroughDir) { posHighContrast += edgeIncrement; posMiddle += edgeIncrement; } } -
片段代码调用
applyFXAA,该函数从着色器返回新的输出颜色:void main() { outColor = applyFXAA(gl_FragCoord.xy, inputTexture, ViewportSize.size); }
就这样——应用 FXAA 的配方就完成了,FXAA 是一种强大的工具,可以平滑掉图形中的锯齿。随着我们结束这个话题,请记住,FXAA 的美丽之处不仅在于其增强视觉输出的能力,还在于其灵活性和易于集成到现有系统中的便利性。
利用 TAA
与之前讨论的抗走样方法不同,这些方法只考虑空间信息,而 TAA 基于时间信息——也就是说,它利用当前帧和前一帧来平滑这些走样伪影。走样伪影发生的原因是样本不足;TAA 通过在帧序列上采样数据来解决此问题,显著减轻了单个帧的压力。
基本思想是应用子像素抖动——也就是说,为每个新帧稍微移动相机的投影矩阵。这导致每个帧的视角略有不同,比静态视角提供了更多关于场景的信息。在渲染期间采样纹理时,由于抖动,产生的颜色值可能会有所不同。这为每个帧创建了一个不同的走样模式,随着时间的积累,平均出来并减少了场景中可见的走样。这在上面的截图图 6.2中得到了演示。

图 6.2 – 时间抗走样概述
这里概述的概念在静态场景中表现非常出色。然而,在物体或相机处于运动状态的情况下,连续帧可能会表现出显著差异。这可能导致视觉伪影,移动物体似乎留下了一系列它们的幽灵,形成了所谓的拖影效果。
为了消除拖影,我们使用通常所说的速度缓冲区,并且使用运动矢量来捕捉场景中的运动。对于每个像素,计算一个运动矢量,表示像素相对于前一帧移动了多少。结果是存储这些运动矢量的速度缓冲区。然后使用速度缓冲区将先前渲染的帧重新投影到当前帧上。这意味着对于每个像素,使用运动矢量查找前一帧中相应像素的颜色。然后将这种颜色与像素的当前颜色混合,从而在时间上平滑颜色。
图 6.3展示了 TAA 算法的高级概述:

图 6.3 – TAA 帧概述
在这个菜谱中,你将学习如何实现 TAA,这是一种高级技术,可以显著减少闪烁并提供更平滑的视觉效果。你将了解 TAA 的复杂性以及如何巧妙地将其集成到代码中,为你的图形渲染工具箱添加另一个强大的工具。
准备工作
在仓库中,TAA 算法是通过位于source/enginecore/passes/TAAComputePass.hpp和cpp文件的TAAComputePass类实现的。着色器使用计算着色器实现,位于source/enginecore/resources/shaders/taaresolve.comp和source/enginecore/resources/shaders/taahistorycopyandsharpen.comp。可以通过运行Chapter06_TAA可执行文件来启动 TAA 示例。
图 6**.4展示了 TAA 算法的流程:

图 6.4 – 在延迟渲染器中的 TAA 算法
TAA(时间抗锯齿)被实现为一个两步的计算着色器:
-
第一步是 TAA 解析着色器,它接受
ColorTexture、DepthTexture、HistoryTexture和VelocityTexture作为输入,并将结果写入一个中间图像。在给定的示例中,速度、颜色和深度纹理是从Gbuffer传递中产生的;然而,从概念上讲,这些也可以在正向渲染中产生。 -
第二步是运行一个计算着色器,该着色器负责以下操作:
-
将之前产生的中间纹理结果复制到历史纹理中。
-
精炼这些中间结果时,我们不需要生成额外的纹理来存储精炼后的结果,相反,我们可以利用 TAA 解析着色器中提供的
ColorTexture。这正是最终将被显示的ColorTexture。TAA 的一个已知缺点是可能会在图像中造成轻微的模糊。为了减轻这一点,在 TAA 之后应用了一个锐化过滤器。这个锐化阶段旨在增强图像的边缘和复杂细节,从而恢复在 TAA 解析过程中可能受损的一些锐度。
-
如何做到这一点...
要实现 TAA,我们首先需要构建一个抖动矩阵。这个矩阵将在渲染过程中与模型-视图-投影(MVP)矩阵协同使用。此外,我们还需要颜色、深度和速度缓冲区。方便的是,这些缓冲区已经作为 G 缓冲区管道的一部分生成,我们在第四章“探索光照、着色和阴影技术”的实现 G 缓冲区以进行延迟渲染配方中实现了:
-
TAAComputePass::init方法负责初始化各种资源。它建立了两个管道 – 一个用于解析到输出颜色,另一个用于将输出颜色传输到历史纹理并增强输出颜色的锐度。 -
大部分工作发生在
TAAComputePass::doAA函数中。这个函数简单地操作解析计算管道,然后是处理复制历史纹理和增强输出颜色锐度的管道。我们如下突出显示了doAA函数的关键组件,省略了不那么关键的部分以避免冗长:void TAAComputePass::doAA(VkCommandBuffer cmd, int frameIndex, int isCamMoving) { pipeline_->bind(cmd); outColorTexture_->transitionImageLayout( cmd, VK_IMAGE_LAYOUT_GENERAL); historyTexture_->transitionImageLayout( cmd, VK_IMAGE_LAYOUT_GENERAL); vkCmdDispatch(…); VkImageMemoryBarrier barriers[2] = { { … }; vkCmdPipelineBarrier( cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 2, barriers); colorTexture_->transitionImageLayout( cmd, VK_IMAGE_LAYOUT_GENERAL); sharpenPipeline_->bind(cmd); vkCmdDispatch(…); colorTexture_->transitionImageLayout( cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); outColorTexture_->transitionImageLayout( cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); } -
实际的魔法发生在两个计算着色器中;特别是,解算着色器是最重要的。解算着色器在
taaresolve.comp中实现。让我们看看着色器是如何工作的。 -
首先,我们将扩展一些辅助函数。
catmullRomTextureFiltering通过使用 Catmull-Rom 插值混合帧之间的像素颜色,以平滑时间上的锯齿。Catmull-Rom 插值是一种三次插值,它比线性插值提供了更平滑的外观。该函数使用 Catmull-Rom 权重(w0, w1, w2, w3)来计算中心(w12)和中心偏移(offset12)的权重。然后,函数计算三个新的纹理位置(texPos0,texPos3, 和texPos12)并将这些位置调整以匹配纹理分辨率。然后,函数使用这些权重和纹理位置通过访问特定位置的历史缓冲区纹理,将检索到的颜色乘以相应的权重,并将它们相加来计算结果像素颜色。varianceClampColor函数在抗锯齿 TAA 中用于处理由于颜色数据的时序重投影而产生的鬼影和模糊问题。该函数通过根据其周围像素的颜色方差限制给定像素的颜色值来工作。它遍历当前像素周围的 3x3 邻域。对于每个相邻像素,函数检索其颜色数据(neighColor)并基于当前像素的欧几里得距离计算一个权重(w)。这个权重被设计为给予更接近像素更多的最终颜色结果的影响。calculateBlendFactor函数负责根据像素的速度和亮度进行计算,以确定像素的混合因子。首先,在两个层面上计算像素的移动,即整体运动和微小的子像素运动,分别得到subpixelMotion和dynamicBlendFactor的值。然后,为了调整像素的亮度或亮度,确定当前颜色与前一帧颜色之间的差异。整个过程增强了像素随时间移动和颜色变化的逼真度,当物体或相机移动时,显著提高了整体图像质量。catmullRomTextureFiltering和varianceClampColor的实现非常详细;我们建议查看taaresolve.comp以获取实现细节。 -
接下来,我们展示
main函数;这个函数通过减少由于相机或场景中物体的快速移动而产生的闪烁和鬼影伪影,有助于生成更平滑、更稳定的图像。以下子步骤将向您介绍其实施的具体细节:- 计算当前像素周围的最近深度和相应的速度:
void main() { vec2 velocity; const float closestDepth = closestVelocityAndDepth(velocity);- 使用计算出的速度将当前像素位置重投影到上一帧的位置:
vec2 reprojectedUV = uv - velocity;- 使用重投影位置的历史缓冲区计算
velocityLerp。注意使用taaConstData.isFirstFrame,这有助于确定我们是否正在处理序列的第一帧。如果是第一帧,则velocityLerp简单地初始化为0.0f。在摄像机切换或传送(即从一个视角突然切换到另一个视角)的上下文中,第一帧假设也适用。每当这些事件发生时,场景从一个帧到另一个帧会急剧变化。在这种情况下,将切换或传送后的帧视为第一帧是有益的。这是因为由于场景内容的大幅变化,前一帧的数据不再是当前帧的好参考:
float velocityLerp = (taaConstData.isFirstFrame != 0) ? texture(inHistoryBuffer, reprojectedUV) .w : 0.0f;- 加载当前帧颜色(
colorIn)并使用catmullRomTextureFiltering计算colorHistory:
vec3 colorIn = getColorData( ivec2(gl_GlobalInvocationID.xy)); vec3 colorHistory = catmullRomTextureFiltering( reprojectedUV, vec2(workSize));- 定义两个常量,
boxSizeWhenMoving和boxSizeWhenStationary。我们根据摄像机是否移动来确定boxSize的值,并根据velocityLerp在静止和移动值之间进行插值:
const float boxSizeWhenMoving = 2000.0f; const float boxSizeWhenStationary = 100.0f; float boxSize = (taaConstData.isCameraMoving == 0) ? boxSizeWhenStationary : mix(boxSizeWhenStationary, boxSizeWhenMoving, velocityLerp); boxSize = mix( 0.5f, boxSize, noGeometry ? 0.0f : smoothstep(0.02f, 0.0f, length(velocity)));- 然后,使用
varianceClampColor函数对历史颜色(colorHistory)进行夹紧,以确保颜色基于周围像素的方差在某个范围内:
vec3 clampHistory = varianceClampColor(colorHistory, boxSize);- 计算决定当前颜色和历史颜色应使用多少以获得最终颜色的
blendFactor:
float blendFactor = calculateBlendFactor( closestDepth, velocity, noGeometry, workSize, colorIn, clampHistory, velocityLerp);- 根据
blendFactor将最终颜色(colorResolve)计算为夹紧的历史颜色和当前颜色的混合,并将velocityLerp存储在alpha通道中:
vec3 colorResolve = mix(clampHistory, colorIn, blendFactor); imageStore(outColorImage, ivec2(gl_GlobalInvocationID.xy), vec4(colorResolve, velocityLerp)); } -
接下来,我们将展示
taahistorycopyandsharpen.comp的工作原理;这个着色器负责将数据复制到历史纹理中,以及锐化由第 5 步(taaresolve.comp)产生的结果。主要函数如下所示,代码很简单——它首先将incolor(即前一步骤 5 产生的图像)复制到历史纹理中。然后调用sharpen方法。该方法首先从中心及其四个直接相邻的位置(顶部、左侧、右侧和底部)加载像素颜色。然后使用非锐化掩膜技术,该技术涉及从原始图像中减去模糊或非锐化版本的图像,以创建表示图像细节的掩膜。该函数将此掩膜应用于增强原始图像,使其看起来更锐利。sharpen方法产生的最终颜色存储在outColorImage中,最终复制到 swapchain 图像中。为了简洁起见,我们在此不详细说明sharpen函数。然而,您可以在taahistorycopyandsharpen.comp文件中查看其实现:void main() { vec4 incolor = imageLoad(inColorImage, ivec2(gl_GlobalInvocationID.xy)); imageStore(outHistory, ivec2(gl_GlobalInvocationID.xy), incolor); vec3 color = sharpen(); imageStore(outColorImage, ivec2(gl_GlobalInvocationID.xy), vec4(color, 1.0f)); }尽管 TAA 应用广泛且具有许多优点,但它并非没有缺点:
-
当物体运动在屏幕上揭露新的区域时,这些区域要么在历史缓冲区中不存在,要么被运动矢量不准确地描绘。此外,相机旋转和反向平移可能导致屏幕边缘出现大量未被揭露的区域。
-
拥有亚像素尺寸的特征,如电线,可能被连续帧错过,导致它们在后续帧的运动矢量中缺失。透明表面可以生成像素,其中不透明物体的运动矢量与所描绘物体的整体运动不一致。最后,阴影和反射不会遵循它们所遮蔽的表面的运动矢量方向。
-
当 TAA(时间抗锯齿)不正常工作时,它要么导致鬼影(由整合错误值引起的模糊效果),要么暴露原始的锯齿,导致边缘参差不齐、闪烁和噪声。
参考资料也
为了进一步阅读和深入了解 TAA,请考虑探索以下资源。这些参考资料将为你提供更详细的信息、实际应用和最新进展的见解:
-
research.nvidia.com/publication/2019-03_improving-temporal-antialiasing-adaptive-ray-tracing -
community.arm.com/arm-community-blogs/b/graphics-gaming-and-vr-blog/posts/temporal-anti-aliasing
应用 DLSS
DLSS 是由 NVIDIA 为他们的 RTX 系列显卡开发的一种由 AI 驱动的技术。DLSS 利用机器学习和 AI 的力量,通过实时智能提升低分辨率图像的分辨率,从而以更少的计算能力生成高质量的、高分辨率的图像。我们还可以使用 DLSS 在较低的基线分辨率下渲染帧,然后使用 AI 将图像提升到更高的分辨率。
注意,要使用 DLSS,你必须拥有 NVIDIA RTX 系列的显卡。
在这个菜谱中,你将学习如何应用 DLSS,这是一种用于实时增强渲染帧分辨率的创新技术。你将了解 DLSS 如何利用机器学习和 AI 智能地提升低分辨率图像,从而以更少的计算能力实现更高质量的图像。
准备工作
在仓库中,DLSS 是通过位于source/enginecore/DLSS.hpp和cpp文件中的DLSS类实现的。可以通过运行chapter06_DLSS可执行文件来启动 DLSS 示例。
DLSS 还需要颜色、深度和速度纹理,这些纹理与 TAA 算法所使用的纹理相同。
如何做到这一点...
DLSS 的集成步骤如下:
-
首先,我们需要查询 DLSS 所需的设备和实例扩展;这些扩展需要在初始化 Vulkan 之前启用。NVIDIA 的 DLSS SDK 提供了
NVSDK_NGX_VULKAN_RequiredExtensions,需要用它来查询扩展。以下代码块展示了一个可以附加 DLSS 所需扩展的静态函数;这需要在初始化 Vulkan 设备之前调用:void DLSS::requiredExtensions(std::vector<std::string>& instanceExtensions, std::vector<std::string>& deviceExtensions) { unsigned int instanceExtCount; const char** instanceExt; unsigned int deviceExtCount; const char** deviceExt; auto result = NVSDK_NGX_VULKAN_RequiredExtensions( &instanceExtCount, &instanceExt, &deviceExtCount, &deviceExt); for (int i = 0; i < instanceExtCount; ++i) { if (std::find(instanceExtensions.begin(), instanceExtensions.end(), instanceExt[i]) == instanceExtensions.end()) { instanceExtensions.push_back(instanceExt[i]); } } for (int i = 0; i < deviceExtCount; ++i) { if (std::find(deviceExtensions.begin(), deviceExtensions.end(), deviceExt[i]) == deviceExtensions.end()) { deviceExtensions.push_back(deviceExt[i]); if (deviceExtensions.back() == "VK_EXT_buffer_device_address") { // we are using 1.3, this extension // has been promoted deviceExtensions.pop_back(); } } } } -
接下来,我们将探讨
DLSS init方法。此方法负责初始化 NVSDK 提供的 DLSS 功能。它接受视口的当前宽度和高度、一个放大因子以及一个指向CommandQueueManager对象的引用。函数首先设置放大因子,然后根据当前视口大小和期望的质量级别确定 DLSS 的最佳设置。然后,根据特定的标志配置 DLSS 功能,如运动矢量分辨率、帧锐化等。最后,它创建 DLSS 功能并将命令提交到 Vulkan 命令缓冲区:void DLSS::init(int currentWidth, int currentHeight, float upScaleFactor, VulkanCore::CommandQueueManager& commandQueueManager) { NVSDK_NGX_Result result = NGX_DLSS_GET_OPTIMAL_SETTINGS(paramsDLSS_, currentWidth, currentHeight, dlssQuality, &optimalRenderWidth, &optimalRenderHeight, &minRenderWidth, &minRenderHeight, &maxRenderWidth, &maxRenderHeight, &recommendedSharpness); int dlssCreateFeatureFlags = NVSDK_NGX_DLSS_Feature_Flags_None; dlssCreateFeatureFlags |= NVSDK_NGX_DLSS_Feature_Flags_MVLowRes; dlssCreateFeatureFlags |= NVSDK_NGX_DLSS_Feature_Flags_DoSharpening; NVSDK_NGX_DLSS_Create_Params dlssCreateParams{ .Feature = { .InWidth = unsigned int(currentWidth), .InHeight = unsigned int(currentHeight), .InTargetWidth = unsigned int(currentWidth * upScaleFactor), .InTargetHeight = unsigned int(currentHeight * upScaleFactor), .InPerfQualityValue = NVSDK_NGX_PerfQuality_Value_MaxQuality, }, .InFeatureCreateFlags = dlssCreateFeatureFlags, }; auto commmandBuffer = commandQueueManager.getCmdBufferToBegin(); constexpr unsigned int creationNodeMask = 1; constexpr unsigned int visibilityNodeMask = 1; NVSDK_NGX_Result createDlssResult = NGX_VULKAN_CREATE_DLSS_EXT(commmandBuffer, creationNodeMask, visibilityNodeMask, &dlssFeatureHandle_, paramsDLSS_, &dlssCreateParams); ASSERT(createDlssResult == NVSDK_NGX_Result_Success, "Failed to create NVSDK NGX DLSS feature"); commandQueueManager.endCmdBuffer(commmandBuffer); VkSubmitInfo submitInfo{ .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &commmandBuffer, }; commandQueueManager.submit(&submitInfo); commandQueueManager.waitUntilSubmitIsComplete(); } -
下一步是调用 DLSS 的
render方法,该方法负责将 DLSS 应用于提供的输入纹理以增强图像质量。它接受一个 Vulkan 命令缓冲区和几个纹理对象作为输入——颜色、深度、运动矢量和输出颜色纹理,以及一个用于相机抖动的 2D 向量。首先,我们使用NVSDK_NGX_Create_ImageView_Resource_VK函数为每个输入纹理创建资源;之后,我们将输出颜色纹理的布局转换为VK_IMAGE_LAYOUT_GENERAL以准备写入。接下来,此函数设置 DLSS 评估的参数,包括输入颜色和输出资源、锐度级别、深度资源、运动矢量资源和相机抖动偏移。最后一部分是调用NGX_VULKAN_EVALUATE_DLSS_EXT将 DLSS 应用于图像,根据提供的参数增强图像质量:void DLSS::render(VkCommandBuffer commandBuffer, VulkanCore::Texture& inColorTexture, VulkanCore::Texture& inDepthTexture, VulkanCore::Texture& inMotionVectorTexture, VulkanCore::Texture& outColorTexture, glm::vec2 cameraJitter) { NVSDK_NGX_Resource_VK inColorResource = NVSDK_NGX_Create_ImageView_Resource_VK( inColorTexture.vkImageView(), inColorTexture.vkImage(), {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}, VK_FORMAT_UNDEFINED, inColorTexture.vkExtents().width, inColorTexture.vkExtents().height, true); NVSDK_NGX_Resource_VK outColorResource = NVSDK_NGX_Create_ImageView_Resource_VK(…); NVSDK_NGX_Resource_VK depthResource = NVSDK_NGX_Create_ImageView_Resource_VK(…); NVSDK_NGX_Resource_VK motionVectorResource = NVSDK_NGX_Create_ImageView_Resource_VK(…); outColorTexture.transitionImageLayout(commandBuffer, VK_IMAGE_LAYOUT_GENERAL); NVSDK_NGX_VK_DLSS_Eval_Params evalParams = { .Feature = { .pInColor = &inColorResource, .pInOutput = &outColorResource, .InSharpness = 1.0, }, .pInDepth = &depthResource, .pInMotionVectors = &motionVectorResource, .InJitterOffsetX = cameraJitter.x, .InJitterOffsetY = cameraJitter.y, .InRenderSubrectDimensions = { .Width = static_cast<unsigned int>(inColorTexture.vkExtents().width), .Height = static_cast<unsigned int>(inColorTexture.vkExtents().height), }, .InReset = 0, .InMVScaleX = -1.0f * inColorResource.Resource.ImageViewInfo.Width, .InMVScaleY = -1.0f * inColorResource.Resource.ImageViewInfo.Height, .pInExposureTexture = nullptr, }; NVSDK_NGX_Result result = NGX_VULKAN_EVALUATE_DLSS_EXT(commandBuffer, dlssFeatureHandle_, paramsDLSS_, &evalParams); ASSERT(result == NVSDK_NGX_Result_Success, "Failed to evaluate DLSS feature"); if (result != NVSDK_NGX_Result_Success) { auto store = GetNGXResultAsString(result); } }
在下一节中,我们将提供一些有价值的链接,以供进一步阅读和深入了解该主题。
参见
对于更深入的知识和关于 DLSS 的实际见解,以下资源将非常有价值:
在本章中,我们首先介绍了 Vulkan 的 MSAA。这是一种用于对抗高对比度边缘空间走样(常在渲染图像中表现为锯齿或阶梯状线条)的方法。我们讨论了在 Vulkan 中启用 MSAA 的过程,这涉及到在管道创建期间配置多采样状态并分配一个单独的多采样图像。我们还介绍了 MSAA 是如何通过平均多个采样点的颜色来减少锯齿状外观,并为边缘提供更平滑、更自然的外观。
然后,我们讨论了 FXAA 技术。这是一种屏幕空间后处理方法,意味着它直接作用于最终图像。它的主要优势是速度和简单性,在性能和质量之间提供了一个良好的权衡。FXAA 通过寻找高对比度像素并将它们与周围环境混合来平滑边缘。尽管是一种近似,但 FXAA 通常可以在感知图像质量上提供显著的提升,尤其是在具有许多高对比度边缘的场景中。
我们讨论的第三种技术是 TAA。这种方法利用了时间重投影的概念,即它利用前一帧的信息来最小化当前帧中的锯齿伪影。我们介绍了 TAA 是如何通过在多个帧上累积样本并应用过滤器来减少时间锯齿效应(如爬行和闪烁)来工作的。当正确实现时,TAA 可以在具有高运动和细节水平的场景中提供优于纯粹空间技术的结果。
最后,我们探讨了 DLSS 这一前沿技术。DLSS 是由 NVIDIA 开发的专有技术,它利用人工智能进行工作,通过训练一个深度学习模型来预测从低分辨率输入生成的高分辨率图像。训练好的模型随后用于实时提升图像分辨率。我们还讨论了 DLSS 如何在显著提升性能的同时,保持或甚至提高视觉保真度。
本章全面概述了各种抗锯齿技术,每种技术都有其优势和适用场景。通过理解这些方法,你可以根据 Vulkan 应用程序的具体需求,做出明智的选择,决定采用哪种技术。
第七章:光线追踪与混合渲染
在本章中,我们将探索光线追踪和混合渲染这个迷人的世界。简单来说,光线追踪是计算机图形学中用于模拟光线与物体交互的一种特殊技术。这导致生成的图像如此逼真,以至于它们可能被误认为是现实。然而,纯光线追踪计算密集,需要大量的硬件资源,这使得它在当前一代硬件上的实时应用变得不可行。另一方面,还有混合渲染,它是传统光栅化技术与光线追踪真实感的结合。这种混合提供了良好的性能和惊人的视觉效果。本章将向您展示如何使用 Vulkan 实现这些技术。我们将向您展示如何设置光线追踪管线,并指导您如何将混合渲染集成到您的作品中。到本章结束时,您将更深入地了解这些高级技术是如何工作的。更重要的是,您将学习如何在您自己的项目中使用它们。
本章的第一部分专注于开发一个基于 GPU 的光线追踪器。我们将详细阐述如何有效地开发这个基于 GPU 的光线追踪器,包括涉及的步骤以及每个功能如何贡献于最终逼真的图像。本章的第二部分将围绕光线追踪器产生的阴影与光栅化延迟渲染的集成展开。我们将深入探讨如何将来自光线追踪器的阴影与延迟渲染的光栅化技术相结合,这种技术通常被称为混合渲染。
在本章中,我们将涵盖以下食谱:
-
实现 GPU 光线追踪器
-
实现混合渲染器
技术要求
对于本章,您需要确保已安装 Visual Studio 2022 以及 Vulkan SDK。对 C++编程语言的基本熟悉程度以及对光线追踪概念的理解将很有用。请回顾第一章**,Vulkan 核心概念,以获取有关设置和构建存储库中代码的详细信息。我们还假设您现在已经熟悉了 Vulkan API 以及前几章中介绍的各种概念。本章有多个食谱,可以使用以下可执行文件启动:
-
Chapter07_RayTracer.exe -
Chapter07_HybridRenderer.exe
本章的代码文件可以在以下位置找到:github.com/PacktPublishing/The-Modern-Vulkan-Cookbook。
实现 GPU 光线追踪器
光线追踪是一种渲染技术,它通过模拟光线的物理行为来生成高度逼真的图形。光线追踪通过追踪从图像传感器中的像素到其来源的光线路径来实现。每条光线都可以与场景中的物体相互作用,产生反射、折射或吸收等多种效果。这使得在复杂的 3D 场景中创建逼真的阴影、反射和光扩散效果成为可能。在之前的章节中,特别是在第四章“探索光照、着色和阴影技术”中,我们探讨了光栅化。它采取了一种更直接的方法,将构成场景的 3D 多边形直接转换为 2D 图像。它本质上根据其颜色和纹理填充每个多边形的像素。另一方面,光线追踪模拟从摄像机到场景的光线路径,考虑到这些光线如何与场景中的物体相互作用。
在我们深入探讨如何在 Vulkan 中实现光线追踪的具体细节之前,了解光线追踪的工作原理以及一些基本概念是有益的,例如双向反射分布函数(BRDF)、辐射度和辐照度。这些概念在确定光线如何与场景中的表面相互作用以及随后影响最终渲染图像方面起着至关重要的作用。
为了简化理解,让我们根据图 7.1中的描述,分解光线追踪算法的流程:

图 7.1 – 光线追踪算法
在下一节中,我们将概述光线追踪算法的基本原理:
-
对于屏幕上的每个像素,从视点或“眼睛”向场景投射一条光线。这是光线追踪的初始步骤,为后续计算奠定了基础。
-
算法随后计算光线与场景中物体之间的交点。它识别出被光线击中的最近物体,以及物体几何上的确切击中点。
-
一旦确定了交点,就在击中点进行着色。在此点计算的颜色和光照信息被添加到像素的辐射度值中,这有助于确定渲染图像中像素的最终颜色。
-
光线不会在第一次击中时停止。由于反射或折射等现象,它可以继续传播。由反射或折射产生的光线被分配一个通量值,这代表光的剩余能量。
-
递归过程可能会无限期地进行,这在计算上是非常昂贵的。为了处理这个问题,使用了诸如俄罗斯轮盘赌之类的技术。在俄罗斯轮盘赌中,递归基于射线的剩余能量以概率终止。如果射线的能量低于某个特定阈值,它有被提前终止的一定几率,这有助于控制算法的计算成本。
-
现在我们已经了解了光线追踪算法的工作原理,深入探讨辐射度学的原则是有益的。辐射度学是物理学的一个分支,它量化了光的行为,提供了一系列方法和单位来描述和测量场景中光的不同方面。理解辐射度学的基本概念包括辐射强度、辐照度和辐射亮度。以下图表(图 7.2)可以帮助你记住这些概念:

图 7.2 – 辐射度学基础
-
辐射强度:这是单位立体角内发出的光或辐射通量的功率的度量,通常以每立体角瓦特(W/sr)来衡量。立体角类似于角度测量中的弧度,用于量化三维空间中的立体角。在光线追踪的背景下,它是在计算辐射强度时作为一个关键单位,捕捉光在模拟环境中的表面上的传播情况。它是方向性的,意味着它取决于观察光的方向。
-
辐照度:辐照度衡量的是单位面积上入射到表面上的辐射通量的功率,通常以每平方米瓦特(W/m²)来衡量。在光线追踪的背景下,辐照度用于计算击中表面的光能量量,然后用于阴影计算。它在确定场景中物体亮度方面起着关键作用。
-
辐射亮度:辐射亮度指的是从特定区域发出的光或通过它的光量,考虑到观察光的方向或视角。它用于描述从场景中的特定点通过特定方向到达相机的光量。它以每平方米每立体角瓦特(W/m²/sr)来衡量。辐射亮度是光线追踪中的一个关键概念,因为它整合了方向性和位置信息,有助于生成准确的着色和光照效果。
作为下一步,我们将学习一下在光线追踪中使用的渲染方程。该方程本质上描述了从某一点沿特定方向发出的光等于该点在该方向发出的光加上该点在该方向反射的光。反射光是对所有入射光方向进行积分,其中每个入射方向都由 BRDF 和入射光与表面法线之间角度的余弦值加权。以下链接提供了一个关于渲染方程的简化解释:twitter.com/KostasAAA/status/1379918353553371139/photo/1。
-
L_o(x, ω_o)是从点 x 发出的总辐射量(光),L_s(x, ω_o)是从点 x 在方向ω_o 发出的光。这个项通常只在光源处不为零。
-
项∫_Ω_ 表示在点 x 上方的整个半球Ω上的积分。
-
f_r(x, ω_i → ω_o)是点 x 处的 BRDF,它定义了当光从方向ω_i 入射时,从 x 反射的光量。
-
L_i(x, ω_i)是点p从方向ω_i 入射的光。
-
ω_i ∙ n 是ω_i 与点 x 的法线之间的角度的余弦值。这解释了光线以较小的角度到达时,会在更大的面积上扩散的事实。dω_i 是围绕方向ω_i 的一小部分立体角。
蒙特卡洛方法
接下来,我们将讨论蒙特卡洛方法,这是一种统计技术,通过进行重复的随机抽样,允许对复杂问题进行数值求解。假设你想计算由函数 f(x) = x²在 x = 0 和 x = 1 之间描述的曲线下的面积。从数学上讲,你会使用积分来解决这个问题。然而,想象一下,如果函数非常复杂或有很多变量,以至于你不能轻易地使用标准微积分技术来积分它。这就是蒙特卡洛方法发挥作用的地方。我们不是试图精确计算积分,而是可以通过随机抽样来估计它。在光线追踪的情况下,渲染方程,它模拟了光与表面的相互作用,非常复杂,特别是因为它涉及到对所有可能的入射光方向的积分。这就是为什么使用蒙特卡洛的原因。我们不是试图计算积分的确切值,而是可以通过随机抽样入射光的方向,对每个样本求积分,然后平均结果来近似它。这个过程重复多次,以获得更准确的估计。
我们在渲染方程中简要地提到了 BRDF;它告诉我们光是如何从表面反射的。当光击中一个表面时,它不会仅仅以一个方向反射回来,而是会向许多方向散射。BRDF 为我们提供了一种预测这种行为的途径。它考虑了两个方向:光来的方向,以及光击中表面后去的方向。
想象阳光照在表面上的情景。BRDF(双向反射分布函数)帮助我们确定从太阳反射到该表面的光有多少,以及它以什么方向传播。这对于计算我们在渲染图像中看到的颜色和亮度非常重要。这就是吞吐量或贡献概念发挥作用的地方。它就像一个衡量光在从表面反射时保留或损失多少光能的指标。把它看作是光反射的效率。我们需要将其包含在我们的计算中,以获得准确的结果。
概率密度函数(PDF)是一种统计工具,帮助我们处理这些计算中的随机性。当光击中表面时,它可以以许多不同的方向反射,PDF 帮助我们确定每个可能方向的可能性。
重要性采样是光线追踪中的一种技术,我们选择在 BRDF 高的方向发送更多的光线,在 BRDF 低的方向发送较少的光线。这有助于我们用更少的光线获得更准确的结果,这可能在计算上更便宜。然而,由于我们在某些方向上发送了更多的光线,在另一些方向上发送了较少的光线,我们正在使采样偏向这些方向。我们将我们的 BRDF 结果除以 PDF 以获得我们的结果。我们之所以将 BRDF 除以 PDF,本质上是为了纠正当我们使用重要性采样来选择下一个追踪光线的方向时引入的偏差。
在光线追踪中,每条光线都携带自己的能量。每次它反射时,我们都将其携带的能量乘以 BRDF 加到图像的整体亮度上。
在 Vulkan 中,光线追踪是通过一系列不同的着色器阶段实现的。在这个菜谱中,我们将引导您通过使用 Vulkan 实现 GPU 光线追踪的过程,提供一步一步的说明,说明如何设置光线追踪过程中涉及的每个着色器阶段。到这个菜谱结束时,您将能够创建自己的光线追踪器,通过准确模拟光的行为来生成高度逼真的图形。
着色器阶段包括以下内容:
-
光线生成着色器:这是光线追踪过程的起点
-
交点着色器:这个着色器计算光线如何与场景的几何体相交
-
丢失和命中着色器:这些定义了光线击中或错过物体时的行为
通过理解和实现这些阶段,您将朝着创建视觉上令人惊叹和逼真的图形迈出重要的一步。
准备工作
Vulkan 中的光线追踪管线由六个阶段组成:射线生成、交点、任何命中、最近命中、丢失和可调用。图 7.3显示了这些阶段及其在管线中的总体布局。Vulkan 光线追踪的另一个关键组件是加速结构。这种结构在高效处理光线追踪中涉及的大量几何数据方面至关重要。加速结构的作用是以一种允许快速光线追踪计算的方式组织数据。边界体积层次结构(BVH)是一组几何对象上的算法树结构。所有几何对象都被封装在边界体积中,形成树的叶节点。然后,这些节点被配对、边界和连接,形成一个父节点。这个过程一直向上进行,直到只剩下一个边界体积:树的根。这种结构允许光线追踪算法有效地丢弃许多射线无法相交的对象,从而显著加快了过程。
加速结构分为两个级别:底部级别加速结构(BLASs)和顶部级别加速结构(TLASs):
-
BLAS(边界体积层次结构):BLAS 负责存储场景中单个物体的几何数据。每个物体可以有一个或多个与它关联的 BLAS,每个 BLAS 可以包含一个或多个几何原语,如三角形或其他 BLAS 的实例。BLAS 负责确定射线如何与它们包含的几何相交,因此它是光线追踪过程的基本部分。
-
TLAS(顶级加速结构):另一方面,TLAS 不包含几何数据。相反,它包含 BLAS(边界体积层次结构)的实例。每个实例定义了一个变换(如平移、旋转或缩放)以及应用该变换的 BLAS。在光线追踪时,系统从 TLAS 开始,逐步向下到适当的 BLAS。TLAS 本质上充当一个目录,根据射线的路径引导系统到达正确的 BLAS。

图 7.3 – 光线追踪管线及其阶段
着色器阶段如下:
-
traceRayExt函数。这些射线最终将与场景中的物体交互,以创建最终的渲染图像。 -
加速结构遍历:加速结构是优化光线追踪过程的关键组件。它作为一个场景管理树,类似于 BVH(边界体积层次结构)。其主要用途是加速场景中射线与物体之间的碰撞检测。这部分管线是固定的,意味着 Vulkan 已经实现了其背后的逻辑。
-
交点阶段:当射线穿越 BVH 时,它们可能会调用交点着色器。此着色器在处理自定义类型时特别有用,但在使用默认三角形网格原语时不是必需的。这是因为 Vulkan 已经集成了这些默认原语所需的逻辑,从而绕过了交点着色器的需求。
-
任何命中阶段:此阶段处理在交点阶段找到的交点事件;在发生交点的情况下,调用任何命中着色器。任何命中着色器确定光线和材质交点发生后采取的后续步骤,例如是否放弃交点等。根据具体要求,交点点可以被丢弃,此时认为没有发生交点。然后此过程返回到 BLAS 遍历。
-
最近命中阶段:此着色器负责处理当前距离射线原点最近且尚未被任何命中阶段丢弃的交点。这通常涉及应用光照和材质计算以渲染像素的最终颜色。
-
丢失阶段:丢失阶段确定在射线没有击中任何东西的情况下如何处理光线。这可能涉及分配默认颜色、环境颜色等。
-
可调用阶段:此阶段可以从任何其他阶段(从它们的着色器代码)调用。
在存储库中,光线追踪代码封装在 RayTracer 类中。
如何实现...
作为第一步,我们将查看需要在主机端执行代码;大部分实现都在 RayTracer 类内部:
-
在使用 Vulkan 设置光线追踪的第一步是验证我们的物理设备(GPU)是否支持光线追踪功能。此验证通过将
VkPhysicalDeviceRayTracingPipelineFeaturesKHR和VkPhysicalDeviceAccelerationStructureFeaturesKHR添加到检查支持的物理特性列表中来实现。此特性检查操作在PhysicalDevice类中实现。在这里,这些特定特性被添加到VkPhysicalDeviceFeatures2结构的链中,作为查询一组特性支持的机制。此类还提供了isRayTracingSupported函数,该函数在 Vulkan 设备创建过程中用于激活光线追踪所需的功能。在Context类中,我们引入了针对VkPhysicalDeviceAccelerationStructureFeaturesKHR和VkPhysicalDeviceRayTracingPipelineFeaturesKHR的特定特性。然而,只有当演示应用程序启用了光线追踪并且物理设备确认支持这些特性时,这些特性才会被激活。请注意,如果您的 GPU 不支持 Vulkan 光线追踪,则演示应用程序将无法运行。 -
接下来,我们为将要由光线追踪管线使用的每个着色器创建着色器模块:
auto rayGenShader = context_->createShaderModule( (resourcesFolder / "raytrace_raygen.rgen") .string(), VK_SHADER_STAGE_RAYGEN_BIT_KHR, "RayTracer RayGen Shader"); auto rayMissShader = context_->createShaderModule( (resourcesFolder / "raytrace_miss.rmiss") .string(), VK_SHADER_STAGE_MISS_BIT_KHR, "RayTracer Miss Shader"); auto rayMissShadowShader = context_->createShaderModule( (resourcesFolder / "raytrace_shadow.rmiss") .string(), VK_SHADER_STAGE_MISS_BIT_KHR, "RayTracer Miss Shadow Shader"); auto rayClosestHitShader = context_->createShaderModule( (resourcesFolder / "raytrace_closesthit.rchit") .string(), VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR, "RayTracer Closest hit Shader"); -
接下来,我们通过调用
Pipeline::createRayTracingPipeline()创建一个光线追踪管线。为了便于创建光线追踪管线,我们添加了一个辅助结构,称为Pipeline::RayTracingPipelineDescriptor,它存储了描述符集及其绑定(就像在图形和计算管线描述符中一样),以及创建光线追踪管线所需的所有着色器。必须将此结构的实例传递给VulkanCore::Pipeline类的构造函数:struct RayTracingPipelineDescriptor { std::vector<SetDescriptor> sets_; std::weak_ptr<ShaderModule> rayGenShader_; std::vector<std::weak_ptr<ShaderModule>> rayMissShaders_; std::vector<std::weak_ptr<ShaderModule>> rayClosestHitShaders_; std::vector<VkPushConstantRange> pushConstants_; };在 Vulkan 中实现光线追踪管线需要一系列着色器组结构。而不是持有着色器列表,每个
VkRayTracingShaderGroupCreateInfoKHR结构:typedef struct VkRayTracingShaderGroupCreateInfoKHR { VkStructureType sType; const void *pNext; VkRayTracingShaderGroupTypeKHR type; uint32_t generalShader; uint32_t closestHitShader; uint32_t anyHitShader; uint32_t intersectionShader; const void *pShaderGroupCaptureReplayHandle; } VkRayTracingShaderGroupCreateInfoKHR;该结构包含用于指定管线中仅四个不同阶段的着色器(
generalShader,closestHitShader,anyHitShader和intersectionShader)。这是因为丢失阶段和可调用阶段的着色器索引由generalShader字段提供。需要注意的是,这些字段的功能取决于结构中type成员的值。 -
为了简洁起见,这里只展示了创建一个着色器阶段和一个着色器组的过程。其他与管线描述符一起传递的着色器模块被分组到它们自己的着色器组中。在提供的代码片段中,我们展示了为光线生成着色器构建特定着色器组的过程。理解这一点至关重要,即 Vulkan 光线追踪中使用的每种类型的着色器都需要其自己的单独着色器组。这是必要的,因为 Vulkan 光线追踪管线的设计允许不同类型的着色器独立操作,每个在光线追踪过程中执行独特任务。通过将每种类型的着色器结构化在其自己的着色器组中,我们确保相应的任务独立且高效地执行,有助于 GPU 的并行计算能力。请参阅
Pipeline::createrayTracingPipeline()中的代码以获取更多详细信息:std::vector<VkPipelineShaderStageCreateInfo> shaderStages; std::vector<VkRayTracingShaderGroupCreateInfoKHR> shaderGroups; const VkPipelineShaderStageCreateInfo rayGenShaderInfo{ .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = rayGenShader->vkShaderStageFlags(), .module = rayGenShader->vkShaderModule(), .pName = rayGenShader->entryPoint().c_str(), }; shaderStages.push_back(rayGenShaderInfo); const VkRayTracingShaderGroupCreateInfoKHR shaderGroup{ .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR, .generalShader = static_cast<uint32_t>(shaderStages.size()) - 1, .closestHitShader = VK_SHADER_UNUSED_KHR, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, }; shaderGroups.push_back(shaderGroup); -
最后,这是创建光线追踪管线的方法:
VkRayTracingPipelineCreateInfoKHR rayTracingPipelineInfo{ .sType = VK_STRUCTURE_TYPE_RAY_TRACING_PIPELINE_CREATE_INFO_KHR, .stageCount = static_cast<uint32_t>(shaderStages.size()), .pStages = shaderStages.data(), .groupCount = static_cast<uint32_t>(shaderGroups.size()), .pGroups = shaderGroups.data(), .maxPipelineRayRecursionDepth = 10, .layout = vkPipelineLayout_, }; VK_CHECK(vkCreateRayTracingPipelinesKHR(context_->device(), VK_NULL_HANDLE, VK_NULL_HANDLE, 1, &rayTracingPipelineInfo, nullptr, &vkPipeline_)); -
下一步是创建描述 SBT 在内存中位置和结构的
VkStridedDeviceAddressRegionKHR结构:struct SBT { std::shared_ptr<VulkanCore::Buffer> buffer; VkStridedDeviceAddressRegionKHR sbtAddress; }; -
createShaderBindingTable()函数是创建 SBT 的地方。该函数首先定义了几个变量来存储应用程序中各种类型着色器的大小和数量。在代码中,handleSize和handleSizeAligned代表 SBT 中单个着色器组句柄的大小,后者确保正确的内存对齐:void EngineCore::RayTracer:: createShaderBindingTable() { const uint32_t handleSize = context_->physicalDevice() .rayTracingProperties() .shaderGroupHandleSize; const uint32_t handleSizeAligned = alignedSize(context_->physicalDevice() .rayTracingProperties() .shaderGroupHandleSize, context_->physicalDevice() .rayTracingProperties() .shaderGroupHandleAlignment);numRayGenShaders,numRayMissShaders, 和numRayClosestHitShaders代表在管线中使用每种类型着色器的数量。接下来,我们计算 SBT(Shader Binding Table,着色器绑定表)的总大小,并创建一个shaderHandleStorage向量来存储着色器句柄:const uint32_t numRayGenShaders = 1; const uint32_t numRayMissShaders = 2; // 1 for miss and 1 for shadow const uint32_t numRayClosestHitShaders = 1; const uint32_t numShaderGroups = numRayGenShaders + numRayMissShaders + numRayClosestHitShaders; const uint32_t groupCount = static_cast<uint32_t>(numShaderGroups); const uint32_t sbtSize = groupCount * handleSizeAligned; -
然后调用
vkGetRayTracingShaderGroupHandlesKHRVulkan 函数以检索着色器组句柄。这些句柄是管道中着色器组的唯一标识符。之后,我们为每种着色器类型创建单独的缓冲区(调用copyDataToBuffer方法将相关的着色器句柄从shaderHandleStorage复制到缓冲区。我们建议查看createShaderBindingTable函数:std::vector<uint8_t> shaderHandleStorage(sbtSize); VK_CHECK(vkGetRayTracingShaderGroupHandlesKHR( context_->device(), pipeline_->vkPipeline(), 0, groupCount, sbtSize, shaderHandleStorage.data())); -
每个缓冲区和相应的
VkStridedDeviceAddressRegionKHR都需要被填充。在这里,我们只展示如何填充光线生成。其他组遵循类似的模式:raygenSBT_.buffer = context_->createBuffer( context_->physicalDevice() .rayTracingProperties() .shaderGroupHandleSize * numRayGenShaders, VK_BUFFER_USAGE_SHADER_BINDING_TABLE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VMA_MEMORY_USAGE_CPU_ONLY, "RayGen SBT Buffer"); raygenSBT_.sbtAddress.deviceAddress = raygenSBT_.buffer->vkDeviceAddress(); raygenSBT_.sbtAddress.size = handleSizeAligned * numRayGenShaders; raygenSBT_.sbtAddress.stride = handleSizeAligned; raygenSBT_.buffer->copyDataToBuffer( shaderHandleStorage.data(), handleSize *numRayGenShaders); -
接下来,我们需要加载环境贴图及其加速结构。
RayTracer::loadEnvMap()方法执行环境贴图的加载和加速结构的创建。它加载context_->createTexture()。然后调用createEnvironmentAccel(),该函数负责为环境贴图的重要性采样创建加速数据结构。此函数计算一个EnvAccel结构体的向量,每个贴素一个。这些数据被上传到一个仅设备的缓冲区。 -
接下来,我们使用
RayTracer::initBottomLevelAccelStruct()和RayTracer::initTopLevelAccelStruct()方法创建 TLAS 和 BLAS。在以下步骤中,你将学习如何使用 Vulkan 设置 BLAS:
VK_GEOMETRY_TYPE_TRIANGLES_KHR) 使用来自模型缓冲区的顶点和索引:
VkAccelerationStructureGeometryKHR accelerationStructureGeometry{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR, .geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR, .geometry = { .triangles = { .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR, .vertexFormat = VK_FORMAT_R32G32B32_SFLOAT, .vertexData = vertexBufferDeviceAddress, .vertexStride = sizeof(EngineCore::Vertex), .maxVertex = numVertices, .indexType = VK_INDEX_TYPE_UINT32, .indexData = indexBufferDeviceAddress, }, }, };vkGetAccelerationStructureBuildSizesKHR函数调用返回分配加速结构和构建刮擦缓冲区所需的大小信息。bLAS_[meshIdx].buffer缓冲区作为vkGetAccelerationStructureBuildSizesKHR函数调用的直接结果被填充:
VkAccelerationStructureBuildSizesInfoKHR accelerationStructureBuildSizesInfo{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR, }; vkGetAccelerationStructureBuildSizesKHR( context_->device(), VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, &accelerationStructureBuildGeometryInfo, &numTriangles, &accelerationStructureBuildSizesInfo);bLAS_[meshIdx].buffer,用于存储 BLAS 结构。然后我们创建一个VkAccelerationStructureCreateInfoKHR类型的结构体,向其中提供刚刚创建的缓冲区、其大小,并指定它是一个 BLAS。接下来,我们调用vkCreateAccelerationStructureKHR创建实际的加速结构,并将句柄存储在bLAS_[meshIdx].handle中。我们创建了一个名为tempBuffer的临时缓冲区来存储构建加速结构时所需的临时数据。在 Vulkan 中构建加速结构时,构建过程通常需要一些临时空间来执行其计算。这个临时空间也被称为刮擦缓冲区。然后我们填充一个VkAccelerationStructureBuildGeometryInfoKHR结构体,其中包含加速结构构建的详细信息,包括加速结构的句柄、几何形状和tempBuffer的设备地址。接下来,我们创建一个VkAccelerationStructureBuildRangeInfoKHR结构体来指定构建中使用的几何形状的范围。vkCmdBuildAccelerationStructuresKHR函数将构建加速结构的命令记录到命令缓冲区:
// Creating buffer to hold the acceleration structure bLAS_[meshIdx].buffer = context_->createBuffer(...); // Creating acceleration structure VkAccelerationStructureCreateInfoKHR accelerationStructureCreateInfo{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR, .buffer = bLAS_[meshIdx].buffer->vkBuffer(), .size = accelerationStructureBuildSizesInfo.accelerationStructureSize, .type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR}; VK_CHECK(vkCreateAccelerationStructureKHR(context_->device(), &accelerationStructureCreateInfo, nullptr, &bLAS_[meshIdx].handle)); // Creating temporary buffer auto tempBuffer = context_->createBuffer(...); // Setting up geometry and build range info for acceleration structure VkAccelerationStructureBuildGeometryInfoKHR accelerationBuildGeometryInfo{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR, .dstAccelerationStructure = bLAS_[meshIdx].handle, .scratchData = {.deviceAddress = tempBuffer->vkDeviceAddress()}}; VkAccelerationStructureBuildRangeInfoKHR accelerationStructureBuildRangeInfo{ .primitiveCount = numTriangles}; // Building acceleration structure const auto commandBuffer = commandQueueMgr.getCmdBufferToBegin(); vkCmdBuildAccelerationStructuresKHR( commandBuffer, 1, &accelerationBuildGeometryInfo, &accelerationStructureBuildRangeInfo); -
在以下步骤中,你将学习如何在 Vulkan 中设置 TLAS:
- 创建加速结构实例:以下循环创建实例,每个实例都引用一个 BLAS。实例包含有关变换矩阵、掩码、标志以及它引用的 BLAS 的设备地址的信息:
for (int meshIdx = 0; meshIdx < model->meshes.size(); ++meshIdx) { VkAccelerationStructureInstanceKHR instance{}; ... instance.accelerationStructureReference = bLAS_[meshIdx].buffer->vkDeviceAddress(); accelarationInstances_.push_back(instance); }VK_GEOMETRY_TYPE_INSTANCES_KHR):
VkAccelerationStructureGeometryKHR accelerationStructureGeometry{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR, .geometryType = VK_GEOMETRY_TYPE_INSTANCES_KHR, .geometry = { .instances = { .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR, .data = instanceDataDeviceAddress, }, }, .flags = VK_GEOMETRY_OPAQUE_BIT_KHR, };vkGetAccelerationStructureBuildSizesKHR函数调用返回分配加速结构体和构建临时缓冲区所需的大小信息:
VkAccelerationStructureBuildSizesInfoKHR accelerationStructureBuildSizesInfo{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR, }; vkGetAccelerationStructureBuildSizesKHR( context_->device(), VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, &accelerationStructureBuildGeometryInfo, &primitiveCount, &accelerationStructureBuildSizesInfo);- 在类型字段中设置
VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR,因为我们正在构建 TLAS。最后一步是在命令缓冲区上记录vkCmdBuildAccelerationStructuresKHR命令,该命令在命令缓冲区提交时执行:
VkAccelerationStructureCreateInfoKHR accelerationStructureCreateInfo{ .sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR, .buffer = tLAS_.buffer->vkBuffer(), .size = accelerationStructureBuildSizesInfo .accelerationStructureSize, .type = VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR, }; VK_CHECK(vkCreateAccelerationStructureKHR( context_->device(), &accelerationStructureCreateInfo, nullptr, &tLAS_.handle)); vkCmdBuildAccelerationStructuresKHR( commandBuffer, 1, &accelerationBuildGeometryInfo, accelerationBuildStructureRangeInfos.data()); -
我们还创建了一个
RayTraced存储图像(在initRayTracedStorageImages中封装),并在初始化期间通过在管线中调用bindResource绑定资源。 -
要执行光线追踪器,我们需要调用
RayTracer::execute(),该函数负责复制相机数据、绑定管线并调用vkCmdTraceRaysKHR。这个 Vulkan 函数启动RayGen着色器。
现在我们已经了解了主机端的步骤,现在是时候了解设备端代码了。光线追踪的设备端代码是通过使用几个着色器实现的,包括 raytrace_raygen.rgen、raytrace_miss.rmiss、raytrace_closesthit.rchit 和 raytrace_shadow.rmiss:
-
该过程从通过
vkCmdTraceRaysKHR调用raytrace_raygen.rgen着色器开始。在下面的着色器代码块中,通过为每个像素样本生成具有唯一随机种子的光线,启动光线追踪过程。这些光线,由它们的起点和方向定义,在循环中追踪到场景中,直到达到最大反弹次数或满足有效载荷中的退出条件。有效载荷携带诸如起点、方向和反弹索引等基本信息。一旦使用所有样本的平均值计算出像素的最终颜色,它就被存储在输出图像中。如果应用了时间累积,着色器将检索并添加来自前一帧的颜色。时间累积在光线追踪中很有用,因为它有助于减少噪声并提高图像质量。在多帧上累积或平均颜色样本实际上增加了每个像素追踪的光线数量,而无需在单个帧中追踪额外光线:void main() { // Ray Generation // tea refers to Tiny Encryption Algorithm, used to generate a unique and reproducible seed for each task and frame. uint seed = tea(gl_LaunchIDEXT.y * gl_LaunchIDEXT.x + gl_LaunchIDEXT.x, camProps.frameId); vec3 finalOutColor = vec3(0); vec2 pixelCenter = vec2(gl_LaunchIDEXT.xy) + vec2(0.5) + vec2(rand(seed), rand(seed)); vec4 target = camProps.projInverse * vec4(pixelCenter / vec2(gl_LaunchSizeEXT.xy) * 2.0 - 1.0, 1, 1); vec4 direction = camProps.viewInverse * vec4(normalize(target.xyz / target.w), 0); // Initial Payload Setup rayPayload.currentBounceIndex = 0; rayPayload.exit = false; rayPayload.origin = (camProps.viewInverse * vec4(0, 0, 0, 1)).xyz; rayPayload.direction = direction.xyz; // Ray Tracing Loop for (int j = 0; j < MAX_BOUNCES; ++j) { rayPayload.currentBounceIndex = j; // Traces a ray using a culling mask of 0xff to include all potential intersections. traceRayEXT(topLevelAccelStruct, gl_RayFlagsOpaqueEXT, 0xff, 0, 0, 0, rayPayload.origin.xyz, 0.001, rayPayload.direction.xyz, 10000.0, 0); if (rayPayload.exit) break; } // Final Color Calculation and Image Store finalOutColor += rayPayload.radiance / MAX_SAMPLES; imageStore(outputImage, ivec2(gl_LaunchIDEXT.xy), vec4(linear2sRGB(finalOutColor), 0.0)); } -
raytrace_miss.rmiss着色器相当简单:如果光线没有与任何对象相交,则会调用它。在这种情况下,着色器从环境图中采样,根据光线与环境之间的交互点确定颜色。envMapColor函数接受一个 3D 方向向量作为输入,对其进行归一化,并将其转换为球坐标(theta 和 phi)。然后,它将这些坐标映射到一个 2D 平面(UV)上,并从环境图纹理中检索相应的颜色。以下代码块简单地调用envMapColor函数来获取当前光线负载的辐射度:void main() { rayPayload.radiance = envMapColor(gl_WorldRayDirectionEXT); rayPayload.exit = true; } -
raytrace_closesthit.rchit着色器是大多数魔法发生的地方,包括着色计算和确定光线后续方向的计算:-
该过程的初始阶段涉及提取被光线击中的网格的顶点和材质数据。这是通过利用
gl_InstanceID和gl_PrimitiveID变量实现的,这些变量由交点着色器填充相关数据。击中着色器还提供了对hitAttributeEXT vec2 attribs的访问。在三角形的情况下,这些属性代表交点的重心坐标。重心坐标是一种坐标系统,用于指定点在三角形内的位置。它们在计算机图形学中特别有用,因为它们允许在三角形之间轻松插值。通过使用这些坐标,我们可以插值顶点的位置,以确定光线在三角形内接触的确切交点。请参考raytrace_closesthit.rchit中的代码,了解我们如何使用重心坐标来获取世界空间位置。 -
下一步是调用
envSample()函数。这个函数是光线追踪过程中的关键部分,负责使用重要性采样对 HDR 环境图进行采样。环境图表示为一个 2D 纹理(经纬度格式),包含周围环境的照明数据。函数首先在环境图中均匀选择一个 texel 索引。它获取该 texel 的采样数据,包括 texel 发出的辐射度与环境图平均值的比率、texel 别名以及该 texel 及其别名的分布函数值。然后,函数根据随机变量和强度比率决定是直接选择 texel 还是选择其别名。它计算所选 texel 的 2D 整数坐标,并均匀采样像素所截取的立体角。函数将采样的 UV 坐标转换为球坐标中的方向,然后将该方向转换为笛卡尔坐标中的光方向向量。这个光方向向量随后与 texel 的 PDF 一起返回:
vec3 envLightColor = vec3(0); vec4 dirPdf = envSample(envLightColor, rayPayload.seed); vec3 lightDir = dirPdf.xyz; float lightPdf = dirPdf.w- 阴影光线在光线追踪过程中起着至关重要的作用。它们通过确定场景中哪些部分处于阴影中,从而帮助创建逼真的光照效果,增加了渲染图像的深度和真实感。下一步是从交点向光源追踪一个阴影光线,以检查是否有遮挡物体。这是确定一个点是否处于阴影中的关键步骤。使用
layout(location = 1) rayPayloadEXT bool inshadow声明了inshadow变量。当在光线追踪着色器中追踪光线时,它会携带一个有效载荷。这个有效载荷可以用来存储需要在光线追踪管道的不同阶段之间传递的信息,例如从最近击中着色器到光线生成着色器。inshadow变量是一个布尔值,用于存储特定点是否处于阴影中的信息。当从交点向光源追踪的阴影光线被另一个物体遮挡时,这个变量将被设置为 true,表示该点处于阴影中。请注意,在traceRayEXT函数中,第 6 个参数设置为1。这个值作为一个索引,用于指定应该调用哪个丢失着色器。在这种情况下,它指的是在raytrace_shadow.miss中找到的丢失着色器:
inshadow = true; const int layoutLocation = 1; // Trace the shadow ray traceRayEXT(topLevelAccelStruct, rayFlags, cullMask, 0, 0, 1, worldPosition, rayMinDist, lightDir, rayMaxDist, layoutLocation); -
下一步负责使用PbrEval函数进行光照计算。它使用材料属性(如基础颜色、镜面颜色、粗糙度和金属因素),rayPayload.radiance是光线从它遇到的所有光源中累积的颜色或光贡献。rayPayload.throughput是衡量有多少光线通过某一路径而没有被吸收或散射的量。本质上,它是光线路径剩余能量的度量。有关 PBR 理论的详细信息,请访问learnopengl.com/PBR/Theory:
if (!inshadow) {
float pdf;
// returns diffuse & specular both
vec3 F =
PbrEval(eta, metallic, roughness, baseColor.rgb,
specularColor, -rayPayload.direction, N,
lightDir, pdf);
float cosTheta = abs(dot(lightDir, N));
float misWeight =
max(0.0, powerHeuristic(lightPdf, pdf));
if (misWeight > 0.0) {
directLightColor += misWeight * F * cosTheta *
envLightColor /
(lightPdf + EPS);
}
}
rayPayload.radiance +=
directLightColor * rayPayload.throughput;
- 最后的部分是确定下一个光线方向以及下一个光线的透射率(剩余能量)。这个过程首先通过使用
PbrSample函数来采样下一个光线的方向(bsdfDirNextRay),该函数利用材料属性和当前光线的方向来生成这个方向。我们计算cosTheta,这是表面法线与下一个光线方向之间的角度的余弦值。这个值用于计算新的透射率,因为反射的光量与这个角度的余弦值成正比(bsdfDirNextRay,并且原点略微偏离当前位置以避免自相交。请注意,PBREval用于在特定方向上评估 BRDF,而PBRSample用于生成新的方向并在该方向上评估 BRDF:
Vec3 F = PbrSample(baseColor.rgb, specularColor, eta,
materialIOR, metallic, roughness,
T, B, -rayPayload.direction,
ffnormal, bsdfDirNextRay,
bsdfpdfNextRay, rayPayload.seed);
float cosTheta = abs(dot(N, bsdfDirNextRay));
rayPayload.throughput *=
F * cosTheta / (bsdfpdfNextRay);
// Russian roulette
float rrPcont =
min(max3(rayPayload.throughput) * eta * eta + 0.001,
0.95);
rayPayload.throughput /= rrPcont;
// update new ray direction & position
rayPayload.direction = bsdfDirNextRay;
rayPayload.origin = offsetRay(
worldPosition, dot(bsdfDirNextRay, worldNormal) > 0
? worldNormal
: -worldNormal);
这就结束了如何在 Vulkan 中实现一个简单的基于 GPU 的光线追踪器的各个部分的介绍。
参考内容
我们建议阅读《一周之内学习光线追踪》系列书籍:
Adam Celarek 和 Bernhard Kerbl 的 YouTube 频道包含大量关于光照和光线追踪的信息:
实现混合渲染
在这个菜谱中,我们将探讨光栅化(特别是延迟渲染)与光线追踪阴影的集成。
在第四章**,探索光照、着色和阴影技术中,我们实现了延迟渲染,并融合了如阴影映射、屏幕空间 AO 和屏幕空间反射等技术。这些技术使我们能够生成多个纹理,然后在光照过程中进行合成。在本菜谱中,你将深入了解如何使用光线追踪生成阴影纹理,这将有助于克服与屏幕空间阴影映射等技术相关联的挑战。屏幕空间阴影映射依赖于渲染图像中可用的信息。它无法完全访问整个 3D 场景几何形状。这种限制可能导致不准确性和伪影。屏幕空间阴影映射容易受到走样问题的影响,尤其是在屏幕空间纹理的边缘和边界处。光线追踪方法没有这些问题,因为它在完整场景上工作。
准备工作
在代码仓库中,混合渲染功能是通过RayTracedShadowPass和LightingPassHybridRenderer类实现的。
该过程从执行Gbuffer阶段开始,根据第四章**,探索光照、着色和阴影技术中讨论的概念生成 G-buffer 纹理。随后,RayTracedShadowPass被启动,采用前述章节中概述的光线追踪阶段。然而,在这个阶段,光线追踪被特别用于生成阴影纹理。最后一步是使用LightingPassHybridRenderer组合 G-buffer 和光线追踪阴影纹理的信息,最终生成用于显示的最终图像。
光线追踪着色器的设备端代码如下:
raytrace_raygen_shadow_hybrid.rgen, raytrace_miss_shadow_hybrid.rmiss, raytrace_closesthit_shadow_hybrid.rchit
合成部分的设备端代码如下:
hybridRenderer_lighting_composite.frag.
现在我们已经了解了代码结构,我们将在下一节中探讨如何实现它。
如何做到这一点...
代码的主机端部分位于RayTracedShadowPass,其设置与之前菜谱中描述的非常相似。我们将重点关注设备端代码,以了解我们如何生成阴影:
-
与往常一样,我们通过声明着色器将使用的输入和统一变量来开始着色器。
layout(location = 0) rayPayloadEXT float visibilityRayPayload;这一行定义了将由光线追踪操作返回的有效载荷。声明的其他统一变量用于加速结构、输出图像以及法线和位置 G-buffers 的纹理:layout(location = 0) rayPayloadEXT float visibilityRayPayload; layout(set = 0, binding = 0) uniform accelerationStructureEXT topLevelAccelStruct; layout(set = 0, binding = 1, rgba8) uniform image2D outputImage; layout(set = 1, binding = 0) uniform sampler2D gbufferNormal; layout(set = 1, binding = 1) uniform sampler2D gbufferPosition; -
main函数是实际计算发生的地方。它首先计算当前像素(或发射点)的像素中心和 UV 坐标。然后,它使用 UV 坐标从 G-buffers 中获取法线和世界位置。rayOrigin通过沿法线方向稍微偏移世界位置来计算。这是为了防止自相交,即射线可能错误地与其发射的表面相交:const vec2 pixelCenter = vec2(gl_LaunchIDEXT.xy) + vec2(0.5); const vec2 inUV = pixelCenter / vec2(gl_LaunchSizeEXT.xy); vec3 normal = normalize(texture(gbufferNormal, inUV).xyz); vec3 worldPosition = texture(gbufferPosition, inUV).xyz; vec3 rayOrigin = worldPosition + normal * 0.1f; -
着色器向光源上的随机点发射多个阴影射线。循环运行多次样本,为每个样本在光源上生成一个随机点,然后计算到该点的方向。调用
traceRayEXT函数从rayOrigin向光源追踪射线。如果射线在到达光源之前击中任何东西,有效载荷将为0,表示光源被遮挡。如果射线到达光源而没有击中任何东西,有效载荷将为1,表示光源可见。每个样本的可见性累积在visible变量中。累积的可见性,通过visible变量表示,然后存储在最终图像的相应位置:for (int i = 0; i < numSamples; i++) { vec3 randomPointOnLight = lightData.lightPos.xyz + (rand3(seed) - 0.5) * lightSize; vec3 directionToLight = normalize(randomPointOnLight - worldPosition); // Start the raytrace traceRayEXT(topLevelAccelStruct, rayFlags, 0xFF, 0, 0, 0, rayOrigin.xyz, tMin, directionToLight.xyz, tMax, 0); visible += visibilityRayPayload; } visible /= float(numSamples); -
raytrace_miss_shadow_hybrid.rmiss和raytrace_closesthit_shadow_hybrid.rchit非常直接;如果发生错过,它们将visibilityRayPayload设置为1.0,如果击中某个东西,则设置为0.0。 -
最后一步是合成步骤。这与我们在 第四章 中讨论的照明过程相同,即《探索照明、着色和阴影技术》*,唯一的区别是现在我们使用的是通过光线追踪创建的阴影纹理。
在本章中,我们探讨了 Vulkan 中的光线追踪和混合渲染的世界。我们深入研究了这些高级图形技术,了解了它们如何能够在渲染图像中提供前所未有的真实感水平。我们学习了光线追踪算法的工作原理,追踪光线路径以在 3D 场景中创建高度详细且物理上准确的反射和阴影。通过混合渲染,我们揭示了将传统光栅化与光线追踪相结合的过程,以实现性能和视觉保真度之间的平衡。这种结合允许在不需要最高精度的情况下利用光栅化的高速,同时使用光线追踪来处理光栅化难以处理的复杂光相互作用。我们探讨了 Vulkan 对这两种技术的强大支持,利用其高效的性能和显式控制硬件资源的能力。
第八章:使用 OpenXR 实现扩展现实
与 Vulkan 在图形领域的用途类似,OpenXR 是扩展现实(XR)世界的一个组成部分,是一个作为实现 XR 应用的强大工具的 API。本章提供了 OpenXR 的概述以及如何与 Vulkan 结合使用。我们从 OpenXR 的基本介绍开始,解释其在 XR 应用中的作用和重要性,然后介绍可能用于改进您的 XR 应用的食谱,例如单次多视图渲染,这是一种优化立体场景渲染的技术。本章进一步扩展到注视点渲染的领域,这是一种通过以不同分辨率渲染屏幕的不同部分来显著提高每秒帧数(FPS)的方法。我们深入探讨了使用 Vulkan 扩展的片段着色率功能实现此技术的实现,为您提供了其实际应用的理解。最后,我们探讨了半精度浮点数的使用,这是在头戴式显示器(HMDs)上节省内存空间的实用工具。到本章结束时,您将了解这些概念,并具备在 XR 项目中有效应用这些技能的能力。
在本章中,我们将涵盖以下食谱:
-
开始使用 OpenXR
-
如何实现单次多视图渲染
-
使用片段密度图实现静态注视点渲染
-
在您的应用中从 OpenXR 检索注视点信息
-
使用高通的片段密度图偏移扩展实现动态注视点渲染
-
使用半精度浮点数减少内存负载
技术要求
对于本章,您需要安装 Android Studio,并且还需要 Meta Quest 2 或 Meta Quest Pro 来运行存储库中提供的虚拟现实(VR)示例应用。请按照以下步骤安装构建、安装和运行应用程序所需的工具:
-
从
developer.android.com/studio/releases下载并安装 Android Studio Hedgehog 版本。 -
我们还建议安装 Meta Quest 开发者中心,从
developer.oculus.com/downloads/package/oculus-developer-hub-win。此工具提供了一些有助于 XR 应用开发的特性。 -
请按照以下链接中概述的步骤操作,以确保您的设备已准备好进行开发——也就是说,您可以调试、部署和测试 VR 应用:
developer.oculus.com/documentation/native/android/mobile-device-setup/.
要启动项目,只需启动 Android Studio 并打开位于source/chapter8目录中的本章project文件夹。
开始使用 OpenXR
在我们深入探讨我们的应用程序代码结构之前,让我们讨论一些重要的 OpenXR 概念:
-
XrInstance:这是 OpenXR 应用程序的起点。它表示应用程序与 OpenXR 运行时的连接。它是您创建的第一个对象,也是您最后销毁的对象。 -
XrSystemId:在创建实例后,应用程序查询系统 ID,它代表一个特定的设备或设备组,例如 VR 头盔。 -
XrViewConfigurationType:这用于选择应用程序将用于显示图像的视图配置。不同的配置可以表示不同的显示设置,例如单视图、立体视图等。 -
XrSession:一旦设置了实例并确定了系统 ID 和视图配置,就会创建一个会话。会话表示应用程序与设备之间的交互。会话管理设备的生命周期、渲染参数和输入数据。 -
XrSpace:Spaces 代表 XR 环境中的坐标系。它们用于在 3D 空间中定位对象。 -
XrSwapchain:swapchain 是一组用于缓冲显示图像的纹理。在会话建立后,swapchain 被创建来处理渲染。 -
xrBeginFrame和xrEndFrame:这些是用于开始和结束帧渲染的函数。xrBeginFrame函数表示渲染帧的开始,而xrEndFrame函数表示帧的结束。它们在渲染循环中的每一帧都会被调用。图 8.1 展示了如何使用 OpenXR 的基本概念:

图 8.1 – OpenXR 对象交互图
在这个菜谱中,我们将了解主要的 OpenXR 初始化事件,以及我们需要使用哪些函数来渲染帧并在设备上显示它们。该菜谱还将涵盖 OpenXR 代码在存储库中的处理方式。
准备工作
创建一个 OpenXR 应用程序的第一步是设置一个 XrInstance。这个实例是您的应用程序与 OpenXR 运行时之间的主要连接。要创建一个 XrInstance,您需要调用 xrCreateInstance 函数。在这样做之前,您需要决定您的应用程序需要哪些扩展。至少,您的应用程序需要启用一个图形绑定扩展,该扩展指定了将要使用的图形 API。您还可以使用 xrEnumerateInstanceExtensionProperties 来枚举平台支持的所有扩展。此外,在调用 xrCreateInstance 之前,您还需要填充 XrApplicationInfo 结构。这个结构包含有关您的应用程序的基本详细信息,例如应用程序的名称、引擎名称和版本信息。
在设置好这些细节之后,你可以调用xrCreateInstance,它将在成功创建后返回一个实例句柄。在创建XrInstance之后,下一步涉及查询SystemId并选择一个XrViewConfigurationView。SystemId代表一个特定的 XR 设备或一组设备,如 VR 头戴式设备,可以使用xrGetSystem函数检索。另一方面,XrViewConfigurationView允许你选择应用程序用于显示图像的视图配置。这可以从单眼到立体配置不等,具体取决于你的设备类型。在本章的食谱中,我们将通过指定XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO来使用立体视图。
下一步是创建一个XrSession实例。XrSession代表你的应用程序与 XR 设备之间的活跃交互。它处理渲染参数、输入数据以及应用程序与设备交互的整体生命周期。要创建一个XrSession,我们需要在XrSessionCreateInfo中填写图形绑定信息。由于我们使用 Vulkan,我们将使用XrGraphicsBindingVulkanKHR结构指定图形绑定。
在 XR 平台上跟踪空间关系非常重要。XrSpace类的一个实例代表 XR 系统正在跟踪的东西。为了与跟踪对象交互,我们将使用XrSpace句柄。有几个空间被称为参考空间,可以通过会话和枚举来访问。OpenXR 中有三种类型的参考空间:
-
XR_REFERENCE_SPACE_TYPE_LOCAL:坐着或静态空间 -
XR_REFERENCE_SPACE_TYPE_VIEW:头部锁定空间 -
XR_REFERENCE_SPACE_TYPE_STAGE:由环境包围的区域,用户可以在其中移动
要从这些枚举中获取XrSpace,你将使用xrCreateReferenceSpace。另一种你可以创建的空间是xrCreateActionSpace,当你需要从一个姿态动作创建空间时使用。例如,我们用它来创建注视位置和方向的XrSpace。xrLocateSpace是一个 API,用于确定相对于其他空间的位置转换。
要渲染图形,我们需要创建一个 swapchain,就像在 Vulkan 中一样。要创建一个 swapchain,你需要调用xrCreateSwapchain。接下来,我们将使用xrEnumerateSwapchainImages来获取多个XrSwapchainImageVulkanKHR实例,这些实例持有对vkImage的引用。
在 OpenXR 中,一个关键概念是层。想象一下,层是虚拟或增强现实体验中最终渲染场景的独立部分或元素。OpenXR 不是呈现一个平坦的单图像视图,而是通过独立渲染每个层并将它们组合成最终图像来创建一个多维视角。最常用的层是XrCompositionLayerProjection。这个层负责渲染主场景。为了创建类似于 VR 体验的深度和沉浸感,这个层结合了多个视图——每个 VR 头戴式设备中的一个眼睛的视图。这种安排产生了一个立体 3D 效果。但XrCompositionLayerProjection并不是唯一工作的层。OpenXR 还使用了诸如XrCompositionLayerQuad、XrCompositionLayerCubeKHR和XrCompositionLayerEquirectKHR之类的层。这些中的每一个都在增强最终图像的渲染中扮演着独特的角色。
现在我们将转向渲染循环;应用程序的渲染循环由三个主要函数组成:
-
xrWaitFrame会阻塞,直到 OpenXR 运行时确定开始下一帧是正确的时间。这包括基于用户头部姿态的计算和渲染。 -
xrBeginFrame由应用程序调用,以标记给定帧的渲染开始。 -
xrEndFrame提交帧以进行显示。
下一个部分是获取和释放 swapchain 图像:xrAcquireSwapchainImage提供了当前 swapchain 图像的索引,但它并不授予您写入图像的权限。要写入 swapchain 图像,您需要调用xrWaitSwapchainImage。xrReleaseSwapchainImage在xrEndFrame之前调用,在渲染完成之前。xrEndFrame将使用最近释放的 swapchain 图像来显示到设备上。
最后一个重要的调用是xrPollEvents,它用于从事件队列中检索事件。OpenXR 中的事件代表各种类型的发生,例如会话状态的变化、用户的输入或环境的变化。例如,当用户戴上或取下他们的头戴式设备、按下控制器上的按钮或跟踪系统失去或重新获得对跟踪对象的视线时,可能会生成一个事件。它通常每帧调用一次。
在仓库中,OpenXR 的代码封装在OXR::Context和OXR::OXRSwapchain类中。
如何做到这一点...
仓库中的OXR::Context类管理了大多数 OpenXR 调用和状态。在这个菜谱中,我们将向您展示这些函数的详细信息以及如何使用它们来初始化仓库中的 OpenXR 示例应用:
-
OXR::Context::initializeExtensions方法查找 OpenXR 运行时中可用的扩展,并过滤掉不受支持的请求扩展。一旦获取到可用的扩展,它将遍历请求的扩展,消除任何不可用的扩展。这导致了一个同时请求和受支持的扩展列表:void Context::initializeExtensions() { uint32_t numExtensions = 0; xrEnumerateInstanceExtensionProperties( nullptr, 0, &numExtensions, nullptr); availableExtensions_.resize( numExtensions, {XR_TYPE_EXTENSION_PROPERTIES}); xrEnumerateInstanceExtensionProperties( nullptr, numExtensions, &numExtensions, availableExtensions_.data()); requestedExtensions_.erase( std::remove_if( requestedExtensions_.begin(), requestedExtensions_.end(), this { return std::none_of( availableExtensions_.begin(), availableExtensions_.end(), ext { return strcmp(props.extensionName, ext) == 0; }); }), requestedExtensions_.end()); } -
Context::createInstance()方法负责使用基本应用程序信息和扩展详细信息创建一个 OpenXR 实例:bool Context::createInstance() { const XrApplicationInfo appInfo = { .applicationName = "OpenXR Example", .applicationVersion = 0, .engineName = "OpenXR Example", .engineVersion = 0, .apiVersion = XR_CURRENT_API_VERSION, }; const XrInstanceCreateInfo instanceCreateInfo = { .type = XR_TYPE_INSTANCE_CREATE_INFO, .createFlags = 0, .applicationInfo = appInfo, .enabledApiLayerCount = 0, .enabledApiLayerNames = nullptr, .enabledExtensionCount = static_cast<uint32_t>( requestedExtensions_.size()), .enabledExtensionNames = requestedExtensions_.data(), }; XR_CHECK(xrCreateInstance(&instanceCreateInfo, &instance_)); XR_CHECK(xrGetInstanceProperties( instance_, &instanceProps_)); } -
Context::systemInfo方法检索并存储头戴式显示器的 OpenXR 系统属性。它获取系统 ID 及其属性,包括系统名称、供应商 ID、图形属性、跟踪属性和视线支持:void Context::systemInfo() { const XrSystemGetInfo systemGetInfo = { .type = XR_TYPE_SYSTEM_GET_INFO, .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY, }; XR_CHECK(xrGetSystem(instance_, &systemGetInfo, &systemId_)); XR_CHECK(xrGetSystemProperties( instance_, systemId_, &systemProps_)); } -
Context::enumerateViewConfigurations函数枚举系统支持的所有视图配置,然后选择并存储与预定义支持的配置匹配的配置属性。如果所选配置支持所需数量的视口,它将存储配置属性和视图配置视图。 -
Context::initGraphics函数旨在初始化 Vulkan 的图形需求。它通过获取关键组件,如 Vulkan 实例和设备扩展来实现这一点。xrGetVulkanInstanceExtensionsKHR和xrGetVulkanDeviceExtensionsKHR是 OpenXR API 中使用的函数,分别用于检索特定 OpenXR 运行时所需的 Vulkan 实例和设备扩展的名称:void Context::initGraphics() { uint32_t bufferSize = 0; pfnGetVulkanInstanceExtensionsKHR( instance_, systemId_, 0, &bufferSize, NULL); requiredVkInstanceExtensionsBuffer_.resize( bufferSize); pfnGetVulkanInstanceExtensionsKHR( instance_, systemId_, bufferSize, &bufferSize, requiredVkInstanceExtensionsBuffer_.data()); pfnGetVulkanDeviceExtensionsKHR( instance_, systemId_, 0, &bufferSize, NULL); requiredVkDeviceExtensionsBuffer_.resize( bufferSize); pfnGetVulkanDeviceExtensionsKHR( instance_, systemId_, bufferSize, &bufferSize, requiredVkDeviceExtensionsBuffer_.data()); } -
Context::initializeSession函数创建一个新的 OpenXR 会话。它首先创建一个XrGraphicsBindingVulkanKHR对象,该对象用于将 Vulkan 绑定到 XR 会话。该对象包含 Vulkan 实例、物理设备和设备,以及队列家族索引。这些信息允许 OpenXR 运行时与 Vulkan API 交互。然后,创建一个XrSessionCreateInfo对象,用于指定创建新会话的参数。该对象的属性填充了要创建的会话的性质、图形绑定和系统 ID。最后,调用xrCreateSession函数来创建会话:bool Context::initializeSession( VkInstance vkInstance, VkPhysicalDevice vkPhysDevice, VkDevice vkDevice, uint32_t queueFamilyIndex) { // Bind Vulkan to XR session const XrGraphicsBindingVulkanKHR graphicsBinding = { XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR, NULL, vkInstance, vkPhysDevice, vkDevice, queueFamilyIndex, 0, }; const XrSessionCreateInfo sessionCreateInfo = { .type = XR_TYPE_SESSION_CREATE_INFO, .next = &graphicsBinding, .createFlags = 0, .systemId = systemId_, }; XR_CHECK(xrCreateSession( instance_, &sessionCreateInfo, &session_)); return true; } -
Context::enumerateReferenceSpaces函数检索当前 OpenXR 会话可用的参考空间类型。它调用xrEnumerateReferenceSpaces以填充一个包含XrReferenceSpaceType结构的向量,这些结构表示可用的参考空间类型。最后,它检查XR_REFERENCE_SPACE_TYPE_STAGE类型是否可用,并将此信息存储在stageSpaceSupported_变量中。XR_REFERENCE_SPACE_TYPE_STAGE类型代表一种站立式体验,用户有少量空间可以移动。 -
Context::createSwapchains函数负责创建渲染所需的 swapchain。根据useSinglePassStereo_的值,它要么创建一个将用于两个视图的单一 swapchain(在单次通行立体渲染的情况下),要么为每个视图创建单独的 swapchain。对于每个 swapchain,它创建一个新的OXRSwapchain实例。OXRSwapchain构造函数使用 Vulkan 上下文、OpenXR 会话、swapchain 视口和每个 swapchain 的视图数进行调用。我们调用initialize函数来初始化OXRSwapchain实例。OXRSwapchain类中的initialize函数通过调用xrCreateSwapchain函数为 OpenXR 会话设置颜色和深度 swapchain。一旦创建了XrSwapchain,我们在OXRSwapchain中调用enumerateSwapchainImages,它负责创建一个XrSwapchainImageVulkanKHR向量:void Context::createSwapchains( VulkanCore::Context &ctx) { const uint32_t numSwapchainProviders = useSinglePassStereo_ ? 1 : kNumViews; const uint32_t numViewsPerSwapchain = useSinglePassStereo_ ? kNumViews : 1; swapchains_.reserve(numSwapchainProviders); for (uint32_t i = 0; i < numSwapchainProviders; i++) { swapchains_.emplace_back( std::make_unique<OXRSwapchain>( ctx, session_, viewports_[i], numViewsPerSwapchain)); swapchains_.back()->initialize(); } } -
OXRSwapchain还提供了诸如getSurfaceTexture和releaseSwapchainImages等功能。getSurfaceTexture通过调用xrAcquireSwapchainImage和xrWaitSwapchainImage来获取 swapchain。 -
在开始渲染之前,
OXR::Context::beginFrame首先通过调用xrWaitFrame与显示进行帧提交同步,该函数返回一个XrFrameState结构。帧状态指定了当运行时预测帧将被显示时的预测显示时间。该函数还调用xrBeginFrame,必须在渲染开始之前调用,并检索一些其他重要信息,例如头部和视图姿态,并计算视图和相机变换:XrFrameState Context::beginFrame() { const XrFrameWaitInfo waitFrameInfo = { XR_TYPE_FRAME_WAIT_INFO}; XrFrameState frameState = {XR_TYPE_FRAME_STATE}; XR_CHECK(xrWaitFrame(session_, &waitFrameInfo, &frameState)); XrFrameBeginInfo beginFrameInfo = { XR_TYPE_FRAME_BEGIN_INFO}; XR_CHECK( xrBeginFrame(session_, &beginFrameInfo)); XrSpaceLocation loc = { loc.type = XR_TYPE_SPACE_LOCATION}; XR_CHECK(xrLocateSpace( headSpace_, stageSpace_, frameState.predictedDisplayTime, &loc)); XrPosef headPose = loc.pose; XrViewState viewState = {XR_TYPE_VIEW_STATE}; const XrViewLocateInfo projectionInfo = { .type = XR_TYPE_VIEW_LOCATE_INFO, .viewConfigurationType = viewConfigProps_.viewConfigurationType, .displayTime = frameState.predictedDisplayTime, .space = headSpace_, }; uint32_t numViews = views_.size(); views_[0].type = XR_TYPE_VIEW; views_[1].type = XR_TYPE_VIEW; XR_CHECK(xrLocateViews( session_, &projectionInfo, &viewState, views_.size(), &numViews, views_.data())); } -
一旦渲染完成,应用程序必须调用
OXR::endFrame方法,该方法反过来调用xrEndFrame。XrFrameEndInfo结构指定了正在呈现的层类型(及其标志)及其关联的空间(及其姿态、视场角度和可能深度信息),以及图像(们)应该如何与底层层混合。请注意,为了简洁起见,这里只显示了代码的关键部分。为了全面理解,请参阅原始源代码中的完整代码:void Context::endFrame(XrFrameState frameState) { const XrFrameEndInfo endFrameInfo = { .type = XR_TYPE_FRAME_END_INFO, .displayTime = frameState.predictedDisplayTime, .environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE, .layerCount = 1, .layers = layers, }; XR_CHECK(xrEndFrame(session_, &endFrameInfo)); } -
android_main函数位于OXR::Context类之外,它是原生 Android 活动的入口点。它初始化 OpenXR (oxrContext) 和 Vulkan (vkContext) 上下文,并设置它们所需的扩展和功能。创建实例后,它建立会话并为渲染创建 swapchain。还创建了顶点和片段着色器的着色器模块。然后该函数进入一个循环,处理 OpenXR 事件,开始帧,执行渲染操作,并结束帧。这个循环会一直持续到应用程序被请求销毁。请注意,为了简洁,这里省略了大量的细节。鼓励您查看存储库中的实际代码以获得全面的理解:void android_main(struct android_app *pApp) { OXR::Context oxrContext(pApp); oxrContext.initializeExtensions(); oxrContext.createInstance(); VulkanCore::Context vkContext( VkApplicationInfo{}); vkContext.createVkDevice( oxrContext.findVkGraphicsDevice( vkContext.instance()), oxrContext.vkDeviceExtensions(), VK_QUEUE_GRAPHICS_BIT); oxrContext.initializeSession( vkContext.instance(), vkContext.physicalDevice().vkPhysicalDevice(), vkContext.device(), vkContext.physicalDevice() .graphicsFamilyIndex() .value()); oxrContext.createSwapchains(vkContext); auto commandMgr = vkContext.createGraphicsCommandQueue(3, 3); do { auto frameState = oxrContext.beginFrame(); if (frameState.shouldRender == XR_FALSE) { oxrContext.endFrame(frameState); continue; } auto commandBuffer = commandMgr.getCmdBufferToBegin(); vkCmdDrawIndexedIndirect( commandBuffer, buffers[3]->vkBuffer(), 0, numMeshes, sizeof(EngineCore:: IndirectDrawCommandAndMeshData)); commandMgr.submit( &vkContext.swapchain()->createSubmitInfo( &commandBuffer, &VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, false, false)); commandMgr.goToNextCmdBuffer(); oxrContext.swapchain(0) ->releaseSwapchainImages(); oxrContext.endFrame(frameState); } while (!pApp->destroyRequested); }
此配方涉及一系列步骤,从初始化 OpenXR 和 Vulkan 上下文到进入游戏事件循环以处理 OpenXR 事件和渲染。这个过程很复杂,涉及启用特定功能、处理图形命令和管理帧。本指南已提供简化的概述,我们强烈建议您查看存储库中的完整代码以获得全面理解。
相关内容
如需更多详细信息,请参阅 Khronos 的 OpenXR 指南:
如何实现单遍历多视图渲染
XR 设备必须为每个帧至少渲染两次场景,为每只眼睛生成一个图像。单遍历多视图渲染是一种技术,通过允许在单个遍历中渲染多个视图来提高 XR 应用程序的性能。这实际上通过一个绘制调用实现了从双眼视角渲染场景。
在此配方中,我们将介绍如何在 Vulkan 中启用多视图渲染功能,以及如何使用它在一个渲染遍历中渲染双眼的场景。
准备工作
在 Vulkan 的上下文中,VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MULTIVIEW_FEATURES扩展指定是否在单个渲染遍历中支持多个视图。一旦启用该功能,您可以为您的渲染遍历指定多个视口和裁剪矩形。然后图形管线将在单个遍历中从不同的视角渲染场景,从而减少重复操作的需求。
除了启用 Vulkan 扩展外,您还需要在着色器代码中启用GL_EXT_multiview扩展。GL_EXT_multiview是一个 GLSL 扩展,允许在单个遍历中渲染多个视图。GL_EXT_multiview引入了一个新的内置变量gl_ViewIndex,可以在您的着色器中使用,以确定正在渲染哪个视图。它包含正在处理的当前视图的索引,并且可以根据视图索引调整您的绘制(例如,索引0可能代表左眼,而索引1可能代表右眼)。
我们还需要使用 VkPhysicalDeviceMultiviewFeatures 来查询硬件是否支持多视图。此外,在创建渲染通道时,我们需要指定我们将使用多个视图。这是通过将 VkRenderPassMultiviewCreateInfo 结构体的一个实例添加到 VkRenderPassCreateInfo 结构体的 pNext 链中实现的。另一个重要部分是,交换链图像需要具有多个层(在我们的例子中,是两个——每个眼睛一个),渲染的结果将发送到附件的不同层。你可能认为我们可以渲染相同的场景两次(一个用于左眼,一个用于右眼),但这意味着我们需要构建一个命令缓冲区,将所有几何和纹理发送两次。这个扩展帮助我们只发送一次数据,并且只有着色器被触发两次(对于每个视图 ID)。这两个执行之间的唯一区别是摄像机的统一数据。
为了支持多视图,需要在代码库的各个区域进行代码更改。在这种情况下,我们需要更改 Texture、RenderPass、Context 类和着色器文件。
如何实现...
在以下步骤中,我们将详细介绍如何实现这个菜谱:
-
将
VulkanCore::Texture扩展以支持使用VK_IMAGE_VIEW_TYPE_2D_ARRAY创建的vkImageView;如果我们有同一纹理中的多个层,这是必要的。 -
在
VulkanCore::RenderPass中添加对多视图的支持;这是通过将VkRenderPassMultiviewCreateInfo连接到VkRenderPassCreateInfo来实现的。 -
在
VulkanCore::Context中添加支持以启用多视图扩展;这通过一个名为enableMultiView的函数实现,该函数简单地启用VkPhysicalDeviceMultiviewFeatures,如果物理设备支持它的话。 -
现在顶点着色器传递了两个
Context::mvp(index),这样我们就可以查询左右眼的 MVP。 -
我们还引入了一个名为
kUseSinglePassStereo的常量,可以用来控制我们是否想要使用单通道。
由于代码分布在各个文件中,我们强烈建议深入研究存储库,以全面审查实现。特别是位于 source/chapter8/app/src/main/cpp/main.cpp 的文件应引起你的特别注意。
使用片段密度图实现静态注视点渲染
注视点渲染是一种前沿的图形渲染技术,它利用了人眼自然倾向于聚焦于场景特定区域的特点,通过将更高细节和分辨率分配给中央的注视点视觉,并逐渐减少对周边视觉的分配,从而优化计算资源。这模仿了人眼感知细节的方式,在图形渲染中提供了显著的性能提升,同时不牺牲视觉质量。
在这个菜谱中,我们将看到如何通过使用 片段密度图(FDM)扩展来实现固定注视点渲染。
准备工作
Vulkan 中的 FDM 设备扩展(VK_EXT_fragment_density)允许应用程序通过一个纹理来指定渲染目标不同区域使用不同细节级别,该纹理编码了片段着色器将对该区域调用多少次。FDM 可以在每一帧中修改,以适应用户的视线方向。此配方仅适用于提供视线检测的 HMD,如 Meta 的 Quest Pro。此处提供的配方适用于单次传递立体渲染方法。
如何操作...
在创建和使用 FDM 及其 FDM 偏移扩展之前,我们需要启用这些扩展:
-
在启用功能之前,有必要检查物理设备是否支持它。这样做需要将一个
VkPhysicalDeviceFragmentDensityMapFeaturesEXT结构实例添加到传递给vkGetPhysicalDeviceFeatures2函数的VkPhysicalDeviceFeatures2的pNext链中。VkPhysicalDeviceFragmentDensityMapFeaturesEXT::fragmentDensityMap指定设备是否支持 FDM 扩展。 -
该扩展具有需要查询以正确使用的属性。为此,还需要将一个
VkPhysicalDeviceFragmentDensityMapPropertiesEXT结构实例添加到VkPhysicalDeviceProperties2的pNext链中,并使用vkGetPhysicalDeviceProperties2查询这些属性。我们将在步骤 4中使用这些属性。 -
FDM 扩展是设备扩展,其名称需要在创建
VkDevice对象时传递:"VK_EXT_fragment_density_map"(或定义,VK_EXT_FRAGMENT_DENSITY_MAP_EXTENSION_NAME)。 -
FDM 的大小不会按一对一的比例映射到帧缓冲区。地图中的一个 texel 会影响渲染目标的一个区域。这个区域的大小可以从
VkPhysicalDeviceFragmentDensityMapPropertiesEXT中查询,从minFragmentDensityTexelSize和maxFragmentDensityTexelSize属性中获取。在我们的配方中,我们将创建一个 FDM,其 texel 映射到至少为渲染目标 32 x 32 的区域,由
minFragmentDensityTexelSize限制:const glm::vec2 mapSize = glm::vec2( std::ceilf( oxrContext.swapchain(0) ->viewport() .recommendedImageRectWidth / std::max( 32u, vkContext.physicalDevice() .fragmentDensityMapProperties() .minFragmentDensityTexelSize.width)), std::ceilf( oxrContext.swapchain(0) ->viewport() .recommendedImageRectHeight / std::max( 32u, vkContext.physicalDevice() .fragmentDensityMapProperties() .minFragmentDensityTexelSize.height))); -
FDM 是一种具有一些特殊使用标志的常规纹理:
std::shared_ptr<VulkanCore::Texture> = std::make_shared<VulkanCore::Texture>( vkContext, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8_UNORM, static_cast<VkImageCreateFlags>(0), VK_IMAGE_USAGE_FRAGMENT_DENSITY_MAP_BIT_EXT, VkExtent3D{static_cast<uint32_t>(mapSize.x), static_cast<uint32_t>(mapSize.y), 1}, 1, 2, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, false, VK_SAMPLE_COUNT_1_BIT, "fragment density map", true, VK_IMAGE_TILING_LINEAR); -
纹理的格式是
VK_FORMAT_R8G8_UNORM。地图中存储的每个像素指定了用于渲染目标该区域的片段密度,其中255表示密度应该是最高的(或默认值:每个渲染目标的像素一个片段;128为半密度,依此类推)。在我们的配方中,我们的地图初始化为128(半密度),然后操作以使中心区域具有半径等于2个 texel 的全密度:std::vector<uint8_t> fdmData(mapSize.x *mapSize.y * 2, 255); constexpr uint16_t high_res_radius = 8; const glm::vec2 center = mapSize / 2.f; for (uint32_t x = 0; x < mapSize.x; ++x) { for (uint32_t y = 0; y < mapSize.y; ++y) { const float length = glm::length(glm::vec2(x, y) - center); if (length < high_res_radius) { const uint32_t index = (y * mapSize.x * 2) + x * 2; fdmData[index] = 255; // full density fdmData[index + 1] = 255; // full density注意,图像有两个层,每个眼睛一个。数据被上传到设备两次,一次用于图像的每一层。
-
一旦地图的每一层数据都已上传,纹理的布局需要过渡到特殊布局,
VK_IMAGE_LAYOUT_FRAGMENT_DENSITY_MAP_OPTIMAL_EXT。 -
FDM(Fragment Density Map)需要在
VkAttachmentDescription结构中由渲染通道指定和引用,就像渲染通道中使用的任何其他附件一样:const auto fdmAttachDesc = VkAttachmentDescription{ .format = VK_FORMAT_R8G8_UNORM, .samples = VK_SAMPLE_COUNT_1_BIT, .loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE, .storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, .initialLayout = VK_IMAGE_LAYOUT_FRAGMENT_DENSITY_MAP_OPTIMAL_EXT, .finalLayout = VK_IMAGE_LAYOUT_FRAGMENT_DENSITY_MAP_OPTIMAL_EXT, }; -
FDM 不能出现在
VkSubpassDescription::pColorAttachments或VkSubpassDescription::pDepthStencilAttachment数组中的颜色或深度模板附件中。相反,它必须在特殊VkRenderPassFragmentDensityMapCreateInfoEXT结构的一个实例中引用:const VkRenderPassFragmentDensityMapCreateInfoEXT fdmAttachmentci = { .sType = VK_STRUCTURE_TYPE_RENDER_PASS_FRAGMENT_DENSITY_MAP_CREATE_INFO_EXT, .fragmentDensityMapAttachment = { .attachment = fragmentDensityAttachmentReference, .layout = VK_IMAGE_LAYOUT_FRAGMENT_DENSITY_MAP_OPTIMAL_EXT, }, }; refers to the *index* of the VkAttachmentDescription structure that mentions the FDM in the attachment description array passed to VkRenderPassCreateInfo::pAttachments.
故障预防通知
此结构在VkRenderPassCreateInfo:: pAttachments数组中的顺序必须与传递给VkFramebufferCreateInfo::pAttachments的VkImage数组中的索引相匹配。
-
VkRenderPassFragmentDensityMapCreateInfoEXT结构的实例需要添加到VkRenderPassCreateInfo结构的pNext链属性中:const VkRenderPassCreateInfo rpci = { .sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .pNext = &fdmAttachmentci, ... }; -
FDM 的图像视图也必须是帧缓冲区的一部分。其图像视图必须添加到
VkFramebufferCreateInfo::pAttachments数组中,并且该数组中的索引必须与传递给渲染通道创建的VkAttachmentDescription结构中的索引相匹配。
这标志着我们关于静态注视点渲染指南的结束。在接下来的章节中,我们将扩展我们的探索,进入动态注视点渲染的领域。
参见
如需更多信息,请查看以下链接中的扩展信息:
在您的应用程序中从 OpenXR 检索视线信息
虚拟现实领域已经发展到一定程度,一些 HMD(头戴式显示器)现在配备了跟踪用户视线的能力。这个功能可以识别用户正在看的方向,可以用于各种任务,增强 VR 体验的交互性和沉浸感。在本食谱中,我们将指导您在应用程序中启用和检索从 OpenXR 获取的视线数据。此外,我们还将说明如何计算焦点区域——用户正在查看的特定区域——在用于显示的渲染目标上的像素坐标。
准备工作
对于本食谱,您需要一个支持眼动功能的眼镜,例如 Meta 的 Quest Pro。您还需要允许应用程序跟踪用户的眼睛,这可以通过大多数设备上的设置菜单实现。
此外,了解如何在 OpenXR 中支持和使用空间和动作(参见“开始使用 OpenXR”食谱)。
本食谱由 Meta 的 Quest Pro 设备编写和测试,因此这里显示的一些代码是特定于该平台的。您的实现可能需要一些小的调整才能在您的设备上工作。
如何操作…
添加视线支持需要允许设备跟踪用户的眼睛。这需要执行以下步骤:
-
在您的应用程序中使用眼动追踪功能之前,您需要通过将以下行添加到您的应用程序的
AndroidManifest.xml文件中请求权限:<uses-permission android:name="com.oculus.permission.EYE_TRACKING" /> <uses-permission android:name="oculus.software.eye_tracking" /> <uses-feature android:name="oculus.software.eye_tracking"/> -
使用以下方式授予您的应用程序跟踪用户眼球的权限:
adb shell pm grant com.example.openxrsample and you might need to change it to your app’s name. -
在创建 OpenXR 实例时启用 OpenXR 扩展,通过将
XR_EXT_EYE_GAZE_INTERACTION_EXTENSION_NAME添加到XrInstanceCreateInfo::enableExtensionNames数组中:const XrApplicationInfo appInfo = { .applicationName = "OpenXR Example", .applicationVersion = 0, .engineName = "OpenXR Example", .engineVersion = 0, .apiVersion = XR_CURRENT_API_VERSION, }; std::vector<const char *> requestedExtensions = { XR_KHR_VULKAN_ENABLE_EXTENSION_NAME, XR_FB_SWAPCHAIN_UPDATE_STATE_VULKAN_EXTENSION_NAME, XR_EXT_EYE_GAZE_INTERACTION_EXTENSION_NAME, }; const XrInstanceCreateInfo instanceCreateInfo = { .type = XR_TYPE_INSTANCE_CREATE_INFO, .createFlags = 0, .applicationInfo = appInfo, .enabledApiLayerCount = 0, .enabledApiLayerNames = nullptr, .enabledExtensionCount = static_cast<uint32_t>( requestedExtensions_.size()), .enabledExtensionNames = requestedExtensions_.data(), }; XR_CHECK(xrCreateInstance(&instanceCreateInfo, &instance_)); -
我们首先向
OXR:Context类添加几个成员变量:XrActionSet eyegazeActionSet_ = XR_NULL_HANDLE; XrAction eyeGazeAction_ = XR_NULL_HANDLE; XrSpace gazeActionSpace_ = XR_NULL_HANDLE; XrSpace localReferenceSpace_ = XR_NULL_HANDLE; -
在 OpenXR 中,眼动追踪被视为输入动作,因此我们创建一个动作集来存储眼动追踪动作(
OXR::Context::eyegazeActionSet_):const XrActionSetCreateInfo actionSetInfo{ .type = XR_TYPE_ACTION_SET_CREATE_INFO, .actionSetName = "gameplay", .localizedActionSetName = "Eye Gaze Action Set", .priority = 0, }; XR_CHECK(xrCreateActionSet(instance_, &actionSetInfo, &eyegazeActionSet_)); -
然后我们创建一个表示眼球注视输入的动作:
const XrActionCreateInfo actionInfo{ .type = XR_TYPE_ACTION_CREATE_INFO, .actionName = "user_intent", .actionType = XR_ACTION_TYPE_POSE_INPUT, .localizedActionName = "Eye Gaze Action", }; XR_CHECK(xrCreateAction(eyegazeActionSet_, &actionInfo, &eyegazeAction_)); -
我们需要路径来识别输入动作及其姿态:
XrPath eyeGazeInteractionProfilePath; XR_CHECK(xrStringToPath( instance_, "/interaction_profiles/ext/eye_gaze_interaction", &eyeGazeInteractionProfilePath)); XrPath gazePosePath; XR_CHECK(xrStringToPath( instance_, "/user/eyes_ext/input/gaze_ext/pose", &gazePosePath)); -
需要使用
XrActionSuggestedBinding结构的实例将动作及其姿态绑定在一起:const XrActionSuggestedBinding bindings{ .action = eyegazeAction_, .binding = gazePosePath, }; const XrInteractionProfileSuggestedBinding suggestedBindings{ .type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, .interactionProfile = eyeGazeInteractionProfilePath, .countSuggestedBindings = 1, .suggestedBindings = &bindings, }; XR_CHECK(xrSuggestInteractionProfileBindings( instance_, &suggestedBindings)); -
行动需要附加到会话上才能工作,这可以通过调用
xrAttachSessionActionSets并传入存储眼球注视动作的动作集来完成:const XrSessionActionSetsAttachInfo attachInfo{ .type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, .countActionSets = 1, .actionSets = &eyegazeActionSet_, }; XR_CHECK(xrAttachSessionActionSets(session_, &attachInfo)); -
我们还需要为眼球注视动作创建一个动作空间,以在姿态动作的自然参考框架内定义新空间原点的位置和方向:
const XrActionSpaceCreateInfo createActionSpaceInfo{ .type = XR_TYPE_ACTION_SPACE_CREATE_INFO, .action = eyegazeAction_, .poseInActionSpace = poseIdentity_, }; XR_CHECK(xrCreateActionSpace(session_, &createActionSpaceInfo, &gazeActionSpace_)); -
最后的初始化步骤是创建一个局部参考空间,我们将使用它来基于眼球注视的位置和方向。参考空间的类型是
XR_REFERENCE_SPACE_TYPE_VIEW,因为眼球注视锁定在眼球或头戴式设备的定位和方向上。eyePoseIdentity变量初始化为身份方向,高度为1.8米:const XrPosef eyePoseIdentity = { .orientation = {.x = 0, .y = 0, .z = 0, .w = 1.f}, .position = {0, 1.8f, 0}, }; const XrReferenceSpaceCreateInfo createReferenceSpaceInfo{ .type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW, .poseInReferenceSpace = eyePoseIdentity, }; XR_CHECK(xrCreateReferenceSpace( session_, &createReferenceSpaceInfo, &localReferenceSpace_)); -
在
OXR::Context::beginFrame方法中,我们更新眼球注视动作的当前状态,但仅当应用程序的当前状态是focused时。然后我们可以使用xrGetActionStatePose获取动作的状态姿态:if (currentState_ == XR_SESSION_STATE_FOCUSED) { XrActiveActionSet activeActionSet{ .actionSet = eyegazeActionSet_, .subactionPath = XR_NULL_PATH, }; const XrActionsSyncInfo syncInfo{ .type = XR_TYPE_ACTIONS_SYNC_INFO, .countActiveActionSets = 1, .activeActionSets = &activeActionSet, }; XR_CHECK(xrSyncActions(session_, &syncInfo)); XrActionStatePose actionStatePose{ XR_TYPE_ACTION_STATE_POSE}; const XrActionStateGetInfo getActionStateInfo{ .type = XR_TYPE_ACTION_STATE_GET_INFO, .action = eyegazeAction_, }; XR_CHECK(xrGetActionStatePose(session_, &getActionStateInfo, &actionStatePose)); -
如果
actionStatePose是active,这意味着我们可以继续在localReferenceSpace中定位动作,在查询帧状态之前预测的时间:if (actionStatePose.isActive) XrEyeGazeSampleTimeEXT eyeGazeSampleTime{ XR_TYPE_EYE_GAZE_SAMPLE_TIME_EXT}; XrSpaceLocation gazeLocation{ XR_TYPE_SPACE_LOCATION, &eyeGazeSampleTime}; XR_CHECK(xrLocateSpace( gazeActionSpace_, localReferenceSpace_, frameState.predictedDisplayTime, &gazeLocation)); -
如果注视的方向和位置都有效,我们可以使用它们来计算用户在设备上展示的图像中注视的像素坐标:
const bool orientationValid = gazeLocation.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; const bool positionValid = gazeLocation.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT; if (orientationValid && positionValid) { eyeGazePositionScreen_[0] = screenCoordinatesFromEyeGazePose(gazeLocation, 0, 0); eyeGazePositionScreen_[1] = screenCoordinatesFromEyeGazePose(gazeLocation, 1, 0); } -
计算用户注视点的屏幕坐标很简单。以下函数执行所有数学运算,将
XrPosef结构(眼球注视位置)转换为屏幕上的坐标。它使用 swapchain 的尺寸将 OpenXR 中的规范视图方向(指向-Z方向)转换为屏幕空间:glm::vec3 Context::screenCoordinatesFromEyeGazePose( XrSpaceLocation gazeLocation, int eye, float offset) { XrVector3f canonicalViewDirection{0, 0, -1.f}; // Reset the position. We won't need it gazeLocation.pose.position = {0, 0, 0}; XrVector3f transformedViewDirection; XrPosef_TransformVector3f( &transformedViewDirection, &gazeLocation.pose, &canonicalViewDirection); XrMatrix4x4f proj; XrMatrix4x4f_CreateProjectionFov( &proj, GRAPHICS_OPENGL, views_[eye].fov, near_, far_); const XrVector4f tanAngle = { -transformedViewDirection.x / transformedViewDirection.z, -transformedViewDirection.y / transformedViewDirection.z, -1.f, 0}; const auto width = swapchain(0) ->viewport() .recommendedImageRectWidth; const auto height = swapchain(0) ->viewport() .recommendedImageRectHeight; XrMatrix4x4f scalem; XrMatrix4x4f_CreateScale(&scalem, 0.5f, 0.5f, 1.f); XrMatrix4x4f biasm; XrMatrix4x4f_CreateTranslation(&biasm, 0.5f, 0.5f, 0); XrMatrix4x4f rectscalem; XrMatrix4x4f_CreateScale(&rectscalem, width, height, 1.f); XrMatrix4x4f rectbiasm; XrMatrix4x4f_CreateTranslation(&rectbiasm, 0, 0, 0); XrMatrix4x4f rectfromclipm; XrMatrix4x4f_Multiply(&rectfromclipm, &rectbiasm, &rectscalem); XrMatrix4x4f_Multiply(&rectfromclipm, &rectfromclipm, &biasm); XrMatrix4x4f_Multiply(&rectfromclipm, &rectfromclipm, &scalem); XrMatrix4x4f rectfromeyem; XrMatrix4x4f_Multiply(&rectfromeyem, &rectfromclipm, &proj); rectfromeyem.m[11] = -1.f; XrVector4f texCoords; XrMatrix4x4f_TransformVector4f( &texCoords, &rectfromeyem, &tanAngle); return glm::vec3(texCoords.x, height - texCoords.y - offset, texCoords.y); }函数使用在
xr_linear.h中定义的辅助类型和函数。投影矩阵在函数中计算,而不是在类级别缓存,以便在应用程序运行时可以修改它。
存储库中的示例应用程序显示了一个褪色的圆形光标,半径约为 10 像素,如果设备支持眼动追踪,以帮助您了解眼球注视在最终输出中的行为。
使用高通的片段密度图偏移扩展实现动态注视点渲染
在 使用片段密度图实现静态注视点渲染 的配方中,我们讨论了如何使用一个指定渲染目标区域片段密度的图来渲染比每个像素一个片段更低的密度的片段。尽管很有用,但静态图的适用性有限,因为用户在环顾四周检查设备上显示的场景时,他们的注视点会发生变化。根据用户的输入重新计算和修改每帧的图,可能会造成计算成本高昂,并给 CPU 带来额外的工作负担,使得使用 FDM 获得的效果变得微不足道。
另一个选项是将偏移应用于静态 FDM,并让 GPU 执行将密度从图转换为渲染场景的繁重工作。多亏了高通的 FDM 偏移设备扩展,这是可能的。
在这个配方中,我们将向你展示如何使用这个扩展根据用户的注视方向动态转换 FDM。
准备工作
对于这个配方,你需要一个支持眼动追踪功能的 HMD,例如 Meta 的 Quest Pro。这个配方是由 Meta 的 Quest Pro 设备编写和测试的,因此这里显示的一些代码是特定于该平台的。这个配方假设你已经使用片段密度图实现了静态注视点渲染。如果没有,你可能需要参考我们之前关于该主题的指南,以了解基础知识。
如何操作…
此扩展通过在渲染时间内在渲染循环中对 FDM 应用偏移来简化应用程序代码:
-
在应用偏移到 FDM 的渲染通道中使用的所有附件都必须使用带有
VK_IMAGE_CREATE_FRAGMENT_DENSITY_MAP_OFFSET_BIT_QCOM标志创建。由于我们直接渲染到交换链图像,因此需要使用该标志创建交换链图像。交换链图像是由 OpenXR 创建的。幸运的是,Meta 设备提供了在创建交换链图像时使用额外 Vulkan 标志的能力。为此,创建一个XrVulkanSwapchainCreateInfoMETA结构体的实例,并将之前提到的标志添加到其additionalCreateFlags属性中:const XrVulkanSwapchainCreateInfoMETA vulkanImageAdditionalFlags{ .type = XR_TYPE_VULKAN_SWAPCHAIN_CREATE_INFO_META, .next = nullptr, .additionalCreateFlags = VK_IMAGE_CREATE_SUBSAMPLED_BIT_EXT | VK_IMAGE_CREATE_FRAGMENT_DENSITY_MAP_OFFSET_BIT_QCOM, }; Const XrSwapchainCreateInfo swapChainCreateInfo = { .type = XR_TYPE_SWAPCHAIN_CREATE_INFO, .next = &vulkanImageAdditionalFlags, ... };XrVulkanSwapchainCreateInfoMETA结构体的实例必须添加到XrSwapchainCreateInfo结构体的pNext链中。 -
在启用 FDM 偏移功能之前,有必要检查物理设备是否支持它。这样做需要将
VkPhysicalDeviceFragmentDensityMapOffsetFeaturesQCOM结构体的实例追加到传递给vkGetPhysicalDeviceFeatures2函数的VkPhysicalDeviceFeatures2的pNext链中。VkPhysicalDeviceFragmentDensityMapOffsetFeaturesQCOM::fragmentDensityMapOffset指定了是否支持 FDM 偏移扩展。 -
该扩展具有需要查询以正确使用的属性。为此,还需要将
VkPhysicalDeviceFragmentDensityMapOffsetPropertiesQCOM结构实例添加到VkPhysicalDeviceProperties2的pNext链中,并使用vkGetPhysicalDeviceProperties2查询这些属性。我们将在以后使用它们。 -
FDM 偏移扩展是一个设备扩展,其名称需要在创建
VkDevice对象时传递:"VK_QCOM_fragment_density_map_offset"(或VK_QCOM_FRAGMENT_DENSITY_MAP_OFFSET_EXTENSION_NAME)。 -
FDM 纹理需要使用
VK_IMAGE_CREATE_FRAGMENT_DENSITY_MAP_OFFSET_BIT_QCOM创建标志来创建。 -
偏移量通过创建
VkSubpassFragmentDensityMapOffsetEndInfoQCOM结构实例并将其添加到VkSubpassEndInfo结构的pNext链中应用于 FDM。注意,在这种情况下,您需要调用vkCmdEndRenderPass2。vkCmdEndRenderPass是不可扩展的(我们将在下一步中看到如何计算偏移量):const std::array<VkOffset2D, 2> offsets = { leftEyeOffset, rightEyeOffset, }; const VkSubpassFragmentDensityMapOffsetEndInfoQCOM offsetInfo = { .sType = VK_STRUCTURE_TYPE_SUBPASS_FRAGMENT_DENSITY_MAP_OFFSET_END_INFO_QCOM, .fragmentDensityOffsetCount = offsets.size(), // 1 for each // layer/multiview view .pFragmentDensityOffsets = offsets .data(), // aligned to // fragmentDensityOffsetGranularity }; const VkSubpassEndInfo subpassEndInfo = { .sType = VK_STRUCTURE_TYPE_SUBPASS_END_INFO, .pNext = &offsetInfo, }; vkCmdEndRenderPass2KHR(commandBuffer, &subpassEndInfo); -
eyeGazeScreenPosLeft和eyeGazeScreenPosRight偏移量可以使用之前的配方计算,即从 OpenXR 中检索您的应用中的眼动信息。在存储库中提供的示例应用中,它们可以通过OXR::Context::eyeGazeScreenPos(int eye)函数从上下文中检索:const glm::vec2 swapchainImageCenter = glm::vec2(oxrContext.swapchain(0) ->viewport() .recommendedImageRectWidth / 2.f, oxrContext.swapchain(0) ->viewport() .recommendedImageRectHeight / 2.f); const glm::vec2 offsetInPixelsLeft = glm::vec2(eyeGazeScreenPosLeft) - swapchainImageCenter; const glm::vec2 offsetInPixelsRight = glm::vec2(eyeGazeScreenPosRight) - swapchainImageCenter; const glm::vec2 fdmOffsetGranularity = glm::vec2( vkContext.physicalDevice() .fragmentDensityMapOffsetProperties() .fragmentDensityOffsetGranularity.width, vkContext.physicalDevice() .fragmentDensityMapOffsetProperties() .fragmentDensityOffsetGranularity.height); const VkOffset2D leftEyeOffset{ offsetInPixelsLeft.x, offsetInPixelsLeft.y, }; const VkOffset2D rightEyeOffset{ offsetInPixelsRight.x, offsetInPixelsRight.y, };这个扩展功能强大,因为它允许使用静态 FDM 来实现动态视野,而不需要额外增加 CPU 负载来每帧重新计算映射。图 8.2显示了在 Quest Pro 上使用 FDM 加上高通的 FDM 偏移扩展渲染餐厅场景的结果。白色圆圈是用于帮助可视化眼动方向的光标。

图 8.2 – 在 Quest Pro 上应用 FDM 渲染的餐厅场景
这就结束了我们对动态视野渲染的配方介绍。在下一个配方中,我们将学习如何减少内存负载,因为 VR 设备具有有限的 GPU 内存。
使用半精度浮点数来减少内存负载
半浮点数,也称为半精度浮点数,是一种占用 16 位的二进制浮点格式。它在特定应用中扮演着关键角色,尤其是在 VR 设备和低性能硬件中。半精度浮点数具有较小的内存占用和更少的带宽需求,这可以显著提高这类设备的性能和效率。它们非常适合那些不需要全单精度浮点数精度的场景,例如在图形中存储像素值、在机器学习模型中进行大量但简单的计算以及在 3D 图形中的某些计算。使用 16 位不仅提高了吞吐量,还减少了寄存器使用,这是 GPU 性能的关键决定因素。可以同时运行的着色器数量直接取决于可用的寄存器数量,因此其有效使用至关重要。在这个菜谱中,我们展示了如何在 Vulkan 中使用半浮点数,以及我们如何通过将顶点数据存储在 16 位浮点数而不是 32 位浮点数中来减少内存消耗。
准备工作
要在应用程序中实现半浮点数,你需要了解一些 Vulkan 和 GLSL 功能。Vulkan 通过启用storageBuffer16BitAccess和shaderFloat16功能支持半浮点数。storageBuffer16BitAccess功能允许你使用 16 位格式进行存储缓冲区,这可以节省内存和带宽。shaderFloat16功能允许你在着色器中使用 16 位浮点类型,这可以通过减少需要处理的数据量来提高性能。
在 GLSL 方面,你需要启用GL_EXT_shader_explicit_arithmetic_types_float16和GL_EXT_shader_16bit_storage扩展。GL_EXT_shader_explicit_arithmetic_types_float16扩展允许你在着色器中直接使用半精度浮点数进行算术运算。同时,GL_EXT_shader_16bit_storage扩展允许你在着色器存储块和接口块中存储半精度浮点数。
通过利用这些 Vulkan 和 GLSL 功能,你可以在应用程序中有效地集成半浮点数,优化性能,尤其是在低性能设备上。
如何操作...
按照以下步骤有效地实现 16 位浮点数,首先激活特定功能,然后修改着色器代码:
-
初始阶段,我们必须激活两个特定的功能:
storageBuffer16BitAccess(位于VkPhysicalDeviceVulkan11Features中)和shaderFloat16(位于VkPhysicalDeviceVulkan12Features中)。为了便于实现这一点,我们在VulkanCore::Context类中集成了一个函数:void Context::enable16bitFloatFeature() { enable11Features_.storageBuffer16BitAccess = VK_TRUE; enable12Features_.shaderFloat16 = VK_TRUE; } -
接下来,我们更改着色器代码并向其中添加 GLSL 扩展。这是在
app/src/main/assets/shaders/Common.glsl文件中完成的。我们还更改了该文件中的顶点结构,使用float16_t代替float。此外,我们还使用glm::packHalf1x16在加载 GLB 资源时将 32 位浮点数转换为 16 位:#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require #extension GL_EXT_shader_16bit_storage : require struct Vertex { float16_t posX; float16_t posY; float16_t posZ; float16_t normalX; float16_t normalY; float16_t normalZ; float16_t tangentX; float16_t tangentY; float16_t tangentZ; float16_t tangentW; float16_t uvX; float16_t uvY; float16_t uvX2; float16_t uvY2; int material; };
总结来说,实现 16 位浮点数在 GPU 性能上提供了显著的提升,尤其是在 VR 和其他低性能设备的环境中。通过在 Vulkan 中激活必要的特性,并在我们的 GLSL 着色器中进行适当的调整,我们可以利用 16 位浮点数带来的好处。这是一个相对直接的过程,涉及到启用特定特性、调整着色器代码以及修改数据结构以适应半精度格式。
在本章中,你开始了在 OpenXR 世界中的旅程。你首先掌握了基础知识,然后迅速过渡到掌握高级技术。你学习了如何实现单次多视图渲染以及如何利用片段密度图进行静态注视点渲染。你还获得了获取应用中注视点信息的能力。进一步地,你揭开了使用高通的片段密度图偏移扩展实现动态注视点渲染的秘密。最后,你发现了使用半精度浮点数在应用中显著减少内存负载的强大功能。
第九章:调试和性能测量技术
调试失败和逆向工程实现,以及测量系统一旦编写完成后的性能,与编写新代码一样重要。Vulkan 是一个庞大而复杂的 API,而且比以往任何时候都更需要知道如何调试它。在本章中,我们将探讨几个关于如何调试和检查你的实现的方法。我们还将演示如何在屏幕上显示图像后如何测量你的实现性能。毕竟,图形编程的全部内容都是从硬件中提取最后一滴性能,而 Vulkan 就是为了帮助你做到这一点而设计的。
在本章中,我们将涵盖以下主要主题:
-
帧调试
-
为方便调试命名 Vulkan 对象
-
在 Vulkan 中从着色器打印值
-
截获验证层消息
-
从着色器检索调试信息
-
使用时间戳查询在 Vulkan 中测量性能
技术要求
对于这一章,你需要确保你已经安装了 VS 2022 以及 Vulkan SDK。请查阅第一章技术要求部分下的Vulkan 核心概念,以获取设置细节。此外,你还需要 RenderDoc 和 Tracy 来编写这一章。下载和安装这些工具的步骤将在本章相应的菜谱中提供。
帧调试
捕获和回放帧对于调试图形应用程序非常重要。与实时捕获不同,实时捕获是在应用程序运行时捕获并显示结果,而捕获意味着记录发送到 GPU 的所有命令及其数据。这包括所有绘制调用、着色器、纹理、缓冲区以及其他用于渲染帧的资源。回放帧意味着再次执行这些记录的命令。帧回放是调试的一个强大功能,因为它允许开发者逐步仔细检查渲染过程,并确切地看到每个阶段发生的情况。如果出现错误或图形故障,帧回放可以帮助确定错误发生的确切位置和原因。有多种帧调试工具,如 RenderDoc、PIX、NVIDIA 的 Nsight Graphics 和 AMD Radeon GPU Profiler。
在这个菜谱中,我们将重点关注如何使用RenderDoc,因为它开源、跨平台,并且几乎在所有 GPU 上都能工作。
准备工作
作为第一步,你需要从renderdoc.org/builds下载 RenderDoc。
RenderDoc 的 UI 包含以下主要组件:
-
时间线:通常位于 RenderDoc 用户界面的顶部行。时间线提供了您捕获的帧中发生的所有 API 调用(事件)的图形表示。它使用颜色编码来指示不同类型的事件(如绘制调用或计算调度),这使得您能够轻松地获得帧中发生情况的总体概述。您可以在时间线中选择任何事件,在 UI 左侧的事件浏览器和 UI 右侧的各个选项卡中查看更多详细信息。
-
事件浏览器:位于 UI 的左侧,事件浏览器提供了您帧中所有事件的详细、分层视图。它显示了 API 调用的顺序,并允许您轻松地浏览它们。当您在事件浏览器中选择一个事件时,RenderDoc 将在时间线中突出显示相应的事件,并更新 UI 右侧的选项卡,以显示与该事件相关的信息。
-
选项卡(纹理查看器、网格查看器、管线状态等):这些选项卡位于 UI 的右侧,并提供有关当前选中事件的详细信息。每个选项卡都专注于渲染过程中的不同方面:
-
纹理查看器:此选项卡允许您查看您的帧中使用的所有纹理。您可以检查每个纹理的属性,可视化其内容,并查看它们在您的着色器中的使用方式。
-
网格查看器:此选项卡提供了对由绘制调用使用的顶点和索引缓冲区的视觉和数值视图。您可以检查原始缓冲区数据,查看生成的几何形状,并查看顶点如何通过您的顶点着色器进行变换。
-
管线状态:此选项卡显示了所选事件的 GPU 管线完整状态。您可以看到所有已绑定资源(如缓冲区和纹理),检查正在使用的着色器,并查看各个管线阶段的配置。
图 9**.1显示了带有打开用于检查的捕获帧的 RenderDoc 主 UI 元素。
-

图 9.1 – RenderDoc 主屏幕
在下一节中,我们将演示如何使用来自第一章,Vulkan 核心概念和第三章,实现 GPU 驱动渲染的执行文件使用 RenderDoc。
如何做到这一点…
使用 RenderDoc 捕获帧(或多个帧)可以通过编程方式或交互方式完成,使用用户界面。以下步骤将解释如何使用 RenderDoc 的用户界面从您的应用程序中捕获帧:
-
作为第一步,你可以在启动应用程序标签中选择要启动的应用程序。一旦应用程序启动,点击立即捕获帧(s)。这将捕获应用程序的当前帧,我们可以检查它。一旦捕获了帧,双击它将打开它以进行检查。你还可以将帧保存到磁盘上以供以后打开。
-
在捕获之后,我们可以在
vkCmdDraw(EID 7) 中选择绘制调用。一旦选择,你将在纹理查看器标签中看到此绘制调用所使用的所有输入和输出纹理,如图 图 9.2 所示。 -
当你对探索网格数据感兴趣时,网格查看器标签是你的首选工具。此功能提供了对输入和输出顶点数据的全面视图,有助于更深入地理解你的网格结构。假设有一个特定的顶点让你感到困扰,或者你只是想更好地理解其行为。为了实现这一点,你需要选择相关的顶点。然后右键单击将显示一个名为调试此顶点的选项。选择此选项将带你到渲染你的网格所使用的顶点着色器。请注意,着色器的源代码只有在 SPIR-V 生成时带有调试符号才会可用。

图 9.2 – RenderDoc 纹理查看器
-
管线状态标签是 RenderDoc UI 中的关键组件。它提供了对图形管线中各个阶段及其状态的广泛视图,作为分析调试渲染过程的有力工具。在顶点着色器阶段,你可以检查应用于每个顶点的操作。这包括将顶点定位在正确的 3D 空间中的变换,以及确定顶点颜色或纹理坐标的计算。你还可以选择视图着色器来检查在此绘制调用期间使用的着色器源代码。继续到片段着色器阶段,管线状态标签允许你仔细检查每个片段(潜在的像素)是如何处理的。这包括基于光照、纹理和/或其它因素确定其最终颜色的操作。调试此阶段可以帮助你解决与颜色计算、纹理映射等问题相关的问题。
-
要调试特定的片段,你需要在纹理查看器中选择一个像素。你可以通过点击你感兴趣的像素来完成此操作。此像素的值代表该特定像素的片段着色器的输出。选择像素后,你可以调试生成它的片段着色器。为此,在像素上下文窗口中右键单击像素并选择显示的调试按钮。这将打开一个新的着色器查看器标签,在那里你可以逐行执行着色器代码。对于每一行,你可以检查变量的值并查看它们在着色器执行过程中的变化。
计算着色器阶段用于在 GPU 上执行通用计算。在这里,您可以检查和调试与渲染无关的操作,例如物理模拟或剔除。在下一步中,我们将演示如何使用来自第三章,实现 GPU 驱动渲染,使用计算着色器进行视锥剔除食谱的可执行文件。
- 要了解如何调试计算着色器,我们首先需要从 RenderDoc 启动
Chapter03_GPU_Culling.exe。一旦应用程序启动,我们将进行捕获。接下来,导航到vkCmdDispatch调用。在事件浏览器中选择此调用将在管道状态选项卡中显示此调用使用的相关管道,如图图 9.3*所示:

图 9.3 – 在 RenderDoc 中看到的计算着色器
- 当涉及到剔除时,我们启动与网格数量相同的线程。然后,在 GPU 上,我们简单地丢弃任何不在视锥体内的网格。假设我们感兴趣的是了解为什么网格编号 5 在视锥体之外。为了调试这个问题,点击调试按钮并在调试计算着色器窗口中指定线程 ID。此窗口允许您指定全局线程 ID 或线程组和局部线程 ID。在我们的情况下,要调试网格编号 5,您需要在全局 X 维度中输入 5(在调度线程 ID部分)。一旦您点击调试按钮,将启动一个包含着色器源代码的新窗口。在这里,您可以检查和调试计算着色器,使您能够了解为什么特定的网格被丢弃。这个过程如图图 9.4*所示:

图 9.4 – 在 RenderDoc 中调试计算着色器
这个食谱是对一个帧调试工具的简要介绍。请注意,其他工具也存在并且以不同的方式运行。
参见
为了全面了解如何使用 RenderDoc,我们强烈建议观看以下视频教程。这些教程将为您提供详细的见解:
为 Vulkan 对象命名以方便调试
使用 Vulkan 意味着您需要创建和管理许多 Vulkan 对象。默认情况下,这些对象通过其句柄,一个数字 ID 来标识。虽然数字 ID 从应用程序的角度来看很容易维护,但对人类来说没有意义。考虑以下由验证层提供的错误消息:
VUID-VkImageViewCreateInfo-imageViewType-04974 ] Object 0: handle = 0xcb3ee80000000007, type = VK_OBJECT_TYPE_IMAGE; | MessageID = 0xc120e150 | vkCreateImageView(): Using pCreateInfo->viewType VK_IMAGE_VIEW_TYPE_2D and the subresourceRange.layerCount VK_REMAINING_ARRAY_LAYERS=(2) and must 1 (try looking into VK_IMAGE_VIEW_TYPE_*_ARRAY). The Vulkan spec states: If viewType is VK_IMAGE_VIEW_TYPE_1D, VK_IMAGE_VIEW_TYPE_2D, or VK_IMAGE_VIEW_TYPE_3D; and subresourceRange.layerCount is VK_REMAINING_ARRAY_LAYERS, then the remaining number of layers must be 1
前面的信息很有用,但找到创建错误层数量的图像很困难。
如果我们给这个图像起一个名字,验证层消息将变为以下内容:
VUID-VkImageViewCreateInfo-imageViewType-04974 ] Object 0: handle = 0xcb3ee80000000007, name = Image: Swapchain image 0, type = VK_OBJECT_TYPE_IMAGE; | MessageID = 0xc120e150 | vkCreateImageView(): Using pCreateInfo->viewType VK_IMAGE_VIEW_TYPE_2D and the subresourceRange.layerCount VK_REMAINING_ARRAY_LAYERS=(2) and must 1 (try looking into VK_IMAGE_VIEW_TYPE_*_ARRAY). The Vulkan spec states: If viewType is VK_IMAGE_VIEW_TYPE_1D, VK_IMAGE_VIEW_TYPE_2D, or VK_IMAGE_VIEW_TYPE_3D; and subresourceRange.layerCount is VK_REMAINING_ARRAY_LAYERS, then the remaining number of layers must be 1
注意,对象的名称现在已成为错误消息的一部分。这使得知道在代码中查找错误位置并修复错误变得容易得多。
在这个菜谱中,你将学习如何使用 Vulkan 扩展为所有 Vulkan 对象赋予可读的或有意义的名称。
准备工作
要能够为 Vulkan 对象分配名称,你首先需要启用VK_EXT_debug_utils实例扩展。在创建 Vulkan 实例时,需要提供该扩展的名称,可以是字符串VK_EXT_debug_utils或使用VK_EXT_DEBUG_UTILS_EXTENSION_NAME宏。以下代码片段初始化了一个启用了调试工具的 Vulkan 实例:
VkInstance instance_ = VK_NULL_HANDLE;
std::vector<const char *> instanceExtensions = {
VK_EXT_DEBUG_UTILS_EXTENSION_NAME};
const VkInstanceCreateInfo instanceInfo = {
.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
...
.enabledExtensionCount = static_cast<uint32_t>(
instanceExtensions.size()),
.ppEnabledExtensionNames =
instanceExtensions.data(),
};
VK_CHECK(vkCreateInstance(&instanceInfo, nullptr,
&instance_));
现在你已经准备好开始给你的 Vulkan 对象命名了。让我们在下一节中看看如何操作。
如何操作...
一旦启用扩展,命名对象的步骤如下:
-
一旦启用扩展,你可以通过调用
vkSetDebugUtilsObjectNameEXT函数,为给定句柄的任何 Vulkan 对象添加名称:VkDevice device_; // Valid Vulkan device VkObjectType type = VK_OBJECT_TYPE_UNKNOWN; std::string name; // human readable name const VkDebugUtilsObjectNameInfoEXT objectNameInfo = { .sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT, .objectType = type, .objectHandle = reinterpret_cast<uint64_t>(handle), .pObjectName = name.c_str(), }; VK_CHECK(vkSetDebugUtilsObjectNameEXT( device_, &objectNameInfo));对象类型(
type)是VkObject枚举的值之一,必须与对象的类型匹配(例如,对于 Vulkan 图像,为VK_OBJECT_TYPE_IMAGE)。句柄是对象的句柄,需要将其转换为类型uint64_t。 -
此函数仅在扩展也可用时才可用,因此请确保在
#ifdef块中保护它,并检查是否已为实例启用了扩展。在仓库中,
VulkanCore::Context:: setVkObjectname方法将这个函数封装在一个模板类中,并为你进行类型转换。此外,值得一提的是,名称不仅显示在验证错误消息中,它们还出现在帧捕获和调试工具中。

图 9.5 - RenderDoc 中对象名称的显示示例
图 9.5 展示了在 RenderDoc 中对象名称的显示方式。在截图上,其中一个 swapchain 图像被命名为Image: Swapchain image 1。深度缓冲区被命名为Image: depth buffer。
在 Vulkan 中从着色器打印值
作为图形程序员,我们所有人都必须同意,调试着色器是我们工作中最令人沮丧的方面之一。尽管一些帧捕获软件提供了着色器调试功能,但仍然可能很难找到你想要调试的确切像素,或者你可能需要关于一组像素的另一条信息,而不仅仅是逐个检查它们。
幸运的是,Vulkan 提供了一种直接从着色器打印值的方法。信息可以直接在 RenderDoc 上检查,例如,或从验证错误消息中检索(有关如何操作的更多详细信息,请参阅从着色器检索调试信息菜谱)。
在这个菜谱中,你将学习如何使用类似于printf的简单函数从你的着色器代码中打印值。
准备工作
要利用从着色器中打印值的功能,启用VK_KHR_shader_non_semantic_info设备扩展是先决条件。这可以通过在创建 Vulkan 设备时将VK_KHR_shader_non_semantic_info字符串或VK_KHR_SHADER_NON_SEMANTIC_INFO_EXTENSION_NAME宏添加到VkDeviceCreateInfo结构中来实现。这个过程在以下代码片段中得到了演示:
VkPhysicalDevice physicalDevice; // Valid Vulkan
// Physical Device
const std::vector<const char *> deviceExtensions =
{VK_KHR_SHADER_NON_SEMANTIC_INFO_EXTENSION_NAME};
const VkDeviceCreateInfo dci = {
.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
...
.enabledExtensionCount = static_cast<uint32_t>(
deviceExtensions.size()),
.ppEnabledExtensionNames = deviceExtensions.data(),
};
VK_CHECK(vkCreateDevice(physicalDevice_, &dci,
nullptr, &device_));
现在扩展已经启用,让我们看看直接从着色器中打印值的步骤。
如何操作...
一旦扩展被启用,你还需要将一个 GLSL 扩展添加到你的着色器中:
-
在你的着色器代码中启用
GL_EXT_debug_printf扩展:#version 460 debugPrintfEXT in your shader code whenever you would like to print values. In the following code snippet, we are printing the value of gl_VertexIndex:debugPrintfEXT("gl_VertexIndex = %i", gl_VertexIndex);
-
该函数还提供了向量值的指定符。以下是一个打印
vec3变量所有分量的调用示例:vec3 position; debugPrintfEXT("%2.3v3f", position);前面的函数调用以浮点数形式打印
position的x、y和z分量,保留 3 位小数。
这是第一章,Vulkan 核心概念中使用的顶点着色器的简略版本,增加了debugPrintfEXT调用以打印gl_VertexIndex值:
#version 460
#extension GL_EXT_debug_printf: enable
layout(location = 0) out vec4 outColor;
vec2 positions[3] = vec2[]( ... );
vec3 colors[3] = vec3[]( ... );
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
debugPrintfEXT(«gl_VertexIndex = %i», gl_VertexIndex);
outColor = vec4(colors[gl_VertexIndex], 1.0);
}
图 9.6展示了如何在 RenderDoc 中检查打印的值:

图 9.6 – RenderDoc 中可见的 debugPrintfEXT 值
截获验证层消息
在某些情况下,验证错误如此之多,以至于无法知道问题的原因。因此,当检测到错误时立即中断程序的执行将是非常理想的,尤其是在调试应用程序时。调试实用工具扩展(VK_EXT_debug_utils)允许你在检测到错误时安装一个回调函数。
在这个菜谱中,你将学习如何安装一个调试回调来截获验证层发出的错误消息,并使你的调试会话更加高效。
准备工作
要能够在错误发生时设置回调,你需要启用VK_EXT_debug_utils扩展。请参考命名 Vulkan 对象以简化调试菜谱中的准备工作部分,了解如何在创建 Vulkan 实例时启用此扩展。
如何操作...
在安装和使用回调之前,你需要定义一个。之后,一旦扩展被启用并且创建了一个 Vulkan 实例对象,你需要使用特殊的 Vulkan 函数来安装回调:
-
定义一个具有以下签名的回调函数:
typedef VkBool32( VKAPI_PTR PFN_vkDebugUtilsMessengerCallbackEXT)( VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity, VkDebugUtilsMessageTypeFlagsEXT messageTypes, const VkDebugUtilsMessengerCallbackDataEXT pCallbackData, void *pUserData); -
这是存储库中提供的用作回调的函数:
VkBool32 VKAPI_PTR debugMessengerCallback( VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity, VkDebugUtilsMessageTypeFlagsEXT messageTypes, const VkDebugUtilsMessengerCallbackDataEXT pCallbackData, void *pUserData) { if ( messageSeverity & (VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT)) { LOGE("debugMessengerCallback : MessageCode " "is %s & Message is %s", pCallbackData->pMessageIdName, pCallbackData->pMessage); #if defined(_WIN32) __debugbreak(); #else raise(SIGTRAP); #endif } else if ( messageSeverity & (VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT)) { LOGW("debugMessengerCallback : MessageCode " "is %s & Message is %s", pCallbackData->pMessageIdName, pCallbackData->pMessage); } else { LOGI("debugMessengerCallback : MessageCode " "is %s & Message is %s", pCallbackData->pMessageIdName, pCallbackData->pMessage); } return VK_FALSE; }您的回调可以根据消息的类型(一般消息、验证消息、性能消息)或其严重性(详细、信息、警告或错误)来决定如何处理消息。
pCallbackData参数(类型为VkDebugUtilsMessengerCallbackDataEXT)提供了大量您可以使用的信息,而pUserData参数可能包含您自己的数据,该数据在安装回调时提供。 -
通过创建
VkDebugUtilsMessengerCreateInfoEXT结构体的实例,在您拥有有效的 Vulkan 实例后安装回调:VkInstance instance; // Valid Vulkan Instance VkDebugUtilsMessengerEXT messenger = VK_NULL_HANDLE; const VkDebugUtilsMessengerCreateInfoEXT messengerInfo = { .sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT, .flags = 0, .messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT, .messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT, .pfnUserCallback = &debugMessengerCallback, .pUserData = nullptr, }; VK_CHECK(vkCreateDebugUtilsMessengerEXT( instance, &messengerInfo, nullptr, &messenger)); -
确保在完成您的 Vulkan 实例后销毁信使。这是必要的,因为在 Vulkan 中,任何创建的资源在不再需要时都需要显式销毁,以避免内存泄漏并释放系统资源:
vkDestroyDebugUtilsMessengerEXT(instance_, messenger_, nullptr);
调试回调非常有用,应该始终使用。确保尽快拥有一个,并了解如何使用它。
从着色器中检索调试信息
图形编程中最困难的任务之一是编写测试。无论是烟雾测试、集成测试、端到端测试还是单元测试,您如何确保您引擎的输出确实是您所期望的?除了简单的测试外,类似于截图的测试类型容易遇到几个问题。一个特别困难的问题是测试着色器代码——因为您通常无法访问硬件,测试着色器代码非常痛苦。
幸运的是,Vulkan 有一个机制,允许您通过debugPrintfEXT函数直接从验证层捕获着色器输出的值。这个机制并不新颖,可以通过Vulkan SDK 1.3.275启用,VK_EXT_layer_settings实例扩展允许您直接从您的应用程序中启用此机制,而无需手动编辑任何其他配置。
在这个菜谱中,您将学习如何启用此功能并从着色器中检索debugPrintfEXT调用的输出。
准备工作
对于这个菜谱,您需要Vulkan SDK 版本 1.3.275。尽管存储库中的所有代码都已使用SDK 版本 1.3.265进行测试,但VK_EXT_layer_settings扩展仅在SDK 1.3.275中可用。
如何操作...
启用此功能很容易,只需几个步骤。让我们来看看它们:
-
VK_EXT_layer_settings扩展为您提供了更改单个层设置的方法。每个设置都必须使用VKLayerSettingEXT结构体的实例设置,该结构体定义如下:typedef struct VkLayerSettingEXT { const char *pLayerName; const char *pSettingName; VkLayerSettingTypeEXT type; uint32_t valueCount; const void *pValues; } VkLayerSettingEXT; -
要启用允许您接收着色器输出的功能,您需要启用
VK_LAYER_KHRONOS_validation层的几个设置。让我们首先创建一个包含层名称的常量,我们将为此更改设置:const std::string layer_name = "VK_LAYER_KHRONOS_validation"; -
现在我们创建数组来存储我们将使用的设置值:
const std::array<const char *, 1> setting_debug_action = {"VK_DBG_LAYER_ACTION_BREAK"}; const std::array<const char *, 1> setting_gpu_based_action = { "GPU_BASED_DEBUG_PRINTF"}; const std::array<VkBool32, 1> setting_printf_to_stdout = {VK_TRUE};将
debug_action设置更改为VK_DBG_LAYER_ACTION_BREAK,以便每当从debugPrintfEXT收到新值时,都会调用回调。将validate_gpu_based设置设置为接收debugPrintEXT值(GPU_BASED_DEBUG_PRINTF)和printf_to_stdout设置(设置为VK_FALSE)指定我们不想将这些值发送到stdout;我们希望在回调中接收它们。 -
我们为每个想要更改的设置创建一个
VkLayerSettingEXT结构的实例。在这里,我们正在更改layer_name层的以下设置:debug_action、validate_gpu_based和printf_to_stdout:const array<VkLayerSettingEXT, 3> settings = { VkLayerSettingEXT{ .pLayerName = layer_name.c_str(), .pSettingName = "debug_action", .type = VK_LAYER_SETTING_TYPE_STRING_EXT, .valueCount = 1, .pValues = setting_debug_action.data(), }, VkLayerSettingEXT{ .pLayerName = layer_name.c_str(), .pSettingName = "validate_gpu_based", .type = VK_LAYER_SETTING_TYPE_STRING_EXT, .valueCount = 1, .pValues = setting_gpu_based_action.data(), }, VkLayerSettingEXT{ .pLayerName = layer_name.c_str(), .pSettingName = "printf_to_stdout", .type = VK_LAYER_SETTING_TYPE_BOOL32_EXT, .valueCount = 1, .pValues = setting_printf_to_stdout.data(), }, }; -
然后将这些设置添加到
VkLayerSettingsCreateInfoEXT结构的实例中:const VkLayerSettingsCreateInfoEXT layer_settings_ci = { .sType = VK_STRUCTURE_TYPE_LAYER_SETTINGS_CREATE_INFO_EXT, .pNext = nullptr, .settingCount = static_cast<uint32_t>(settings.size()), .pSettings = settings.data(), }; -
最后,我们将此实例添加到创建 Vulkan 实例时使用的
VkInstanceCreateInfo结构的pNext链中:const VkInstanceCreateInfo instanceInfo = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pNext = &layer_settings_ci, ... }; VK_CHECK(vkCreateInstance(&instanceInfo, nullptr, &instance_));
当第一章“Vulkan 核心概念”启用时,回调接收到的消息大致如下:
Validation Information: [ WARNING-DEBUG-PRINTF ] | MessageID = 0x76589099 | vkQueueSubmit(): gl_VertexIndex = 1
可以启用详细输出,在这种情况下,前面的消息将看起来像这样:
Validation Information: [ WARNING-DEBUG-PRINTF ] Object 0: handle = 0x26e6bf17bd0, type = VK_OBJECT_TYPE_QUEUE; | MessageID = 0x76589099 | vkQueueSubmit(): Command buffer (Command buffer: 0)(0x26e6c6613b0). Draw Index 0\. Pipeline (Graphics pipeline: )(0x26e78d000d0). Shader Module (Shader Module: )(0x26e73b68450). Shader Instruction Index = 92. gl_VertexIndex = 1 Debug shader printf message generated at line 21.
21: outColor = vec4(colors[gl_VertexIndex], 1.0);
希望这个功能能帮助你编写测试,测试那些以前甚至无法测试的代码的隐蔽角落。
使用时间戳查询在 Vulkan 中测量性能
交叉平台方式以最小侵入性测量 CPU 和 GPU 工作负载的性能是非常宝贵的。Tracy 分析器允许你这样做,并且使用起来非常简单,全部都在一个小型的 C++库中。
在这个菜谱中,你将学习如何将 Tracy 分析器集成到你的应用程序中,并对其进行调试以收集 GPU 性能信息。
准备工作
首件事是下载 Tracy 从github.com/wolfpld/tracy,并将其包含到你的项目中。你还应该下载 Tracy 客户端/服务器以收集和检查数据。
如何操作...
使用 Tracy 对代码进行调试以方便使用只需要几个步骤。为了能够收集关于 GPU 性能的数据,你需要一个 Tracy/Vulkan 上下文以及一个用于校准时间戳的专用命令缓冲区。之后,对代码进行调试就变得简单直接:
-
首先,将 Tracy 头文件包含到你的应用程序中:
#include <tracy/Tracy.hpp> #include <tracy/TracyVulkan.hpp> -
第二,你需要一个 Tracy/Vulkan 上下文,可以使用 Tracy 库提供的宏来创建。有两种选择:一种创建带有校准时间戳的上下文,另一种创建不带校准时间戳的上下文。Vulkan 提供了关联不同时间域操作发生时间的方法。没有校准,Tracy 只能猜测设备上的操作相对于在 CPU 上发生的操作发生的时间。以下是如何初始化更适合你需求的上下文的方法:
VkPhysicalDevice physicalDevice; VkDevice device; int graphicsQueueIndex; VkCommandBuffer commandBuffer; #if defined(VK_EXT_calibrated_timestamps) TracyVkCtx tracyCtx_ = TracyVkContextCalibrated( physicalDevice, device, graphicsQueueIndex, commandBuffer, vkGetPhysicalDeviceCalibrateableTimeDomainsKHR, vkGetCalibratedTimestampsKHR); #else TracyVkCtx tracyCtx_ = TracyVkContext( physicalDevice, device, graphicsQueueIndex, commandBuffer); #endif这里使用的命令缓冲区是专用的,它不应该与其他任何操作共享。
-
收集 GPU 信息现在变得容易。你所要做的就是使用 Tracy 提供的宏之一,例如以下内容:
TracyVkZone(tracyCtx_, commandBuffer, "Model upload");注意,在这个宏中使用的
commandBuffer变量是你希望从其中捕获数据的命令缓冲区,即正在记录的命令缓冲区。这个命令缓冲区与在构建 Tracy 上下文时提供的命令缓冲区不同。这个宏应该在你想仪器化你的 GPU 执行时添加。例如,你可能想在发出绘制调用(如
vkCmdDraw)的作用域中添加这个宏。这样你将获得关于该命令在 GPU 上处理的详细信息。在仓库中,你可以找到这个宏的使用示例。 -
Tracy 还提供了允许你使用颜色或名称标识区域的宏,例如
TracyVkNamedZone、TracyVkNamedZoneC等。有时,你需要告诉 Tracy 通过调用
TracyVkCollect来收集命令缓冲区的时间戳。这个宏可以在帧的末尾调用:TracyVkCollect(tracyCtx_, commandBuffer); -
在退出渲染循环并在关闭应用程序之前,你需要通过调用
TracyVkDestroy来销毁 Tracy 上下文:TracyVkDestroy(tracyCtx_); -
最后一步是在定义了
TRACY_ENABLE的情况下构建你的项目。如果你使用 CMake,可以在生成项目构建文件时添加以下参数:-DTRACY_ENABLE=1现在你必须做的就是编译你的代码并运行它。确保在开始之前启动 Tracy 客户端并建立连接。Tracy 客户端将自动检测你的应用程序并在启动时开始收集数据。
如果你使用 Tracy 仪器化你的代码以收集 CPU 数据,你可以使用例如
ZoneScoped、ZoneScopedC等宏来实现,收集后你将看到并排的结果。图 9.7 展示了从 第二章,使用现代 Vulkan 中捕获的一个可执行文件的结果。注意截图中的 CPU 和 GPU 区域。![图 9.7 – Tracy 分析器捕获,GPU 和 CPU 信息并排显示]()
图 9.7 – Tracy 分析器捕获,GPU 和 CPU 信息并排显示
Tracy 是一个非常易于使用的库,它提供了关于你的应用程序的无价信息。它提供了纳秒级分辨率,以及 CPU 和 GPU 性能跟踪,并且是跨平台的。如果你在代码库中还没有其他性能测量库或设施,Tracy 可以让你迅速开始使用。



浙公网安备 33010602011771号