精通-Vulkan-图形编程-全-

精通 Vulkan 图形编程(全)

原文:zh.annas-archive.org/md5/bd99537db5aea50bb67b1f343d4f582e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Vulkan 现在是一个成熟且灵活的多平台图形 API。它已被广泛应用于许多行业,包括游戏开发、医学成像、电影制作和媒体播放。

了解 Vulkan 是理解现代图形 API 如何在桌面和移动设备上工作的基础步骤。

在《精通 Vulkan 图形编程》中,你将从开发渲染框架的基础开始。你将学习如何利用高级 Vulkan 特性来编写现代渲染引擎。你将了解如何自动化资源绑定和依赖关系。然后,你将利用 GPU 驱动渲染来扩展场景的大小,最后,你将熟悉提高渲染图像视觉质量的光线追踪技术。

在本书结束时,你将深入理解现代渲染引擎的内部工作原理以及实现最先进效果的图形技术。本书中开发的框架将成为你未来所有实验的起点。

本书面向对象

本书面向希望深入了解如何在 Vulkan 中编写现代且高性能渲染引擎的专业或业余图形和游戏开发者。

用户应熟悉图形编程的基本概念(即矩阵和向量)以及 Vulkan 的基本知识。

本书涵盖内容

第一章介绍 Raptor 引擎和 Hydra,通过概述主要组件的结构来介绍我们的框架。然后我们将看到如何为 Windows 和 Linux 编译代码。

第二章改进资源管理,通过将我们的渲染器移至使用无绑定纹理来简化渲染纹理的管理。我们还将通过解析生成的 SPIR-V 来自动化管线布局生成,并演示如何实现管线缓存。

第三章解锁多线程,详细介绍了基于任务并行性的概念,这将帮助我们利用多个核心。我们将利用这项技术异步加载资源,并并行记录多个命令缓冲区。

第四章实现帧图,帮助我们开发帧图,这是一种包含我们的渲染通道及其相互依赖的数据结构。我们将利用这个数据结构来自动化资源屏障放置,并通过资源别名提高内存使用效率。

第五章解锁异步计算,说明了如何利用 Vulkan 中的异步计算队列。我们介绍了时间线信号量,这使得队列同步管理更加容易。最后,我们将实现一个简单的布料模拟,它在单独的队列上运行。

第六章, GPU 驱动渲染,将我们的渲染器从网格转换为 meshlets(用于实现 GPU 剔除的小三角形组),我们将介绍 mesh shaders 并解释如何利用它们实现现代剔除技术。

第七章, 使用集群延迟渲染渲染多个光源,描述了我们的 G 缓冲区实现,然后转向集群光渲染。我们将演示如何利用屏幕瓦片和深度分箱实现高效渲染。

第八章, 使用网格着色器添加阴影,简要回顾了阴影技术的历史,然后继续描述我们选择的方法。我们利用 meshlets 和 mesh shaders 支持高效渲染立方体贴图阴影贴图。我们还将演示如何使用稀疏资源来减少内存使用。

第九章, 实现可变率着色,概述了可变率着色及其用途。然后我们将描述如何使用 Vulkan 扩展将此技术添加到我们的渲染器中。

第十章, 添加体积雾,从第一原理实现体积效果。然后我们将讨论空间和时间滤波以改善最终结果的质量。

第十一章, 时间抗锯齿技术概述,简要回顾了抗锯齿技术的发展历史。然后我们将描述实现健壮时间抗锯齿解决方案所需的全部步骤。

第十二章, 使用光线追踪入门,概述了使用 Vulkan 中光线追踪扩展所需的关键概念。然后我们将提供创建光线追踪管线、着色器绑定表和加速结构的实现细节。

第十三章, 使用光线追踪重新审视阴影,提出了一种使用光线追踪的阴影实现方案。我们将描述一个算法,该算法利用每个光源的动态光线数量,并结合空间和时间滤波器以产生稳定的结果。

第十四章, 使用光线追踪添加动态漫反射全局照明,涉及将全局照明添加到我们的场景中。我们将描述我们使用光线追踪生成探针数据的方法,并提供一种最小化光泄漏的解决方案。

第十五章, 使用光线追踪添加反射,简要介绍了屏幕空间反射及其不足之处。然后我们将描述我们的光线追踪反射实现。最后,我们将实现一个降噪器,以便结果可用于最终的光照计算。

为了充分利用这本书

本书假设读者熟悉 Vulkan 或其他现代渲染 API(如 DirectX 12 或 Metal)的基本概念。您应该熟悉编辑和编译 C 或 C++代码以及 GLSL 着色器代码。

本书涵盖的软件/硬件 操作系统要求
Vulkan 1.2 Windows 或 Linux

您需要一个支持 C++17 的 C++编译器。还需要在系统上安装最新的 Vulkan SDK。我们提供了 Visual Studio 解决方案以及 CMake 文件来编译项目。

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误

对于每一章,我们建议您运行代码并确保您理解其工作原理。每一章都是基于前一章的概念构建的,因此在继续之前,您必须对这些概念有深刻的理解。我们还建议您进行自己的更改,以尝试不同的方法。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

下载彩色图像

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“对于每种资源类型,我们在DescriptorSetCreation对象上调用相对方法。”

代码块设置如下:

export VULKAN_SDK=~/vulkan/1.2.198.1/x86_64 
export PATH=$VULKAN_SDK/bin:$PATH 
export LD_LIBRARY_PATH=$VULKAN_SDK/lib:$LD_LIBRARY_PATH 
export VK_LAYER_PATH=$VULKAN_SDK/etc/vulkan/explicit_layer.d

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

VkPhysicalDeviceFeatures2 device_features{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2, &indexing_features }; 
vkGetPhysicalDeviceFeatures2( vulkan_physical_device, 
&device_features ); 
    bindless_supported = indexing_features.
descriptorBindingPartiallyBound && indexing_features.
runtimeDescriptorArray;

任何命令行输入或输出都应如下编写:

$ tar -xvf vulkansdk-linux-x86_64-1.2.198.1.tar.gz

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“我们通过点击启动来启动应用程序,我们会注意到一个显示帧时间和渲染帧数的覆盖层。”

小贴士或重要提示

看起来是这样的。

联系我们

我们欢迎读者的反馈。

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

勘误:尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告此错误。请访问www.packtpub.com/support/errata并填写表格。

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

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

分享您的想法

一旦您阅读了《精通 Vulkan 图形编程》,我们非常乐意听到您的想法!请选择www.amazon.com/review/create-review/error?asin=1803244798为此书评分并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

https://packt.link/free-ebook/9781803244792

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分:现代渲染引擎的基础

在本部分,我们将为我们的渲染引擎打下坚实的基础。本节将涵盖以下章节:

  • 第一章**,介绍 Raptor 引擎和 Hydra

  • 第二章**,改进资源管理

  • 第三章**,解锁多线程

  • 第四章**,实现帧图

  • 第五章**,解锁异步计算

第一章:介绍 Raptor 引擎和 Hydra

当我们开始编写本书时,我们决定我们的目标是开始于一个传统的 Vulkan 教程可能结束的地方。无论是印刷品还是网络上的资源,都有很多很好的资源可以帮助初学者发现和理解 Vulkan API。

我们决定编写这本书,因为我们感觉到这些入门教程和更高级的材料之间存在差距。其中一些主题可能在文章和博客文章中有所涉及,但我们找不到一个资源能够将它们组织成一个单一和连贯的格式。

虽然我们假设您对 Vulkan 有一定的了解,但在本章中,我们有机会回顾一些我们将贯穿本书剩余部分的基本概念。我们将展示本书中使用的代码组织、类和库。

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

  • 如何阅读本书

  • 理解代码结构

  • 理解 glTF 场景格式

  • 物理渲染概述

  • 关于 GPU 调试的一些建议

到本章结束时,您将熟悉 Raptor 引擎和本书中开发的渲染框架。您还将了解 glTF 模型格式的结构和物理渲染背后的基本概念。

技术要求

您需要一个至少支持 Vulkan 1.1 的 GPU。在本书编写时,Vulkan 1.3 刚刚发布,许多厂商,如 AMD 和 Nvidia,都提供了第一天支持。我们保留了较低的要求,以便尽可能多的人能够跟随学习。

一些后续章节将使用一些较老显卡可能不具备的硬件功能。 wherever possible, we will provide an alternative software solution. If it’s not feasible, we try to focus more on the generic aspects of the implementation and less on the API details.

本章的完整代码可在 GitHub 上找到,链接为 github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter1

Windows

代码已在 Windows 系统上使用 Visual Studio 2019 16.11 和 Vulkan SDK 版本 1.2.198.1 进行了测试(在编写本书时,这可能会发生变化)。

要在 Windows 上安装 Vulkan SDK,您需要下载并运行以下可执行文件:

sdk.lunarg.com/sdk/download/1.2.198.1/windows/VulkanSDK-1.2.198.1-Installer.exe

在安装 Vulkan SDK 后,请确保您可以在Bin文件夹中运行 vulkaninfoSDK.exe 程序,以确认 SDK 已正确安装,并且您的图形驱动程序支持 Vulkan。

如果你需要关于安装过程的更多详细信息,请参考官方文档(vulkan.lunarg.com/doc/sdk/latest/windows/getting_started.xhtml)。

我们提供了一个包含本书全部代码的 Visual Studio 解决方案,它允许你轻松地为每一章构建可执行文件。

一旦解决方案构建完成,将Chapter1项目设置为运行目标并运行程序。你应该看到以下内容:

图 1.1 – 渲染结果

图 1.1 – 渲染结果

Linux

对于 Linux,我们使用了 Visual Studio Code、GCC 9 或更高版本以及 CMake 3.22.1。Vulkan SDK 的版本与 Windows 上的版本相匹配。我们在 Debian 11 和 Ubuntu 20.04 上进行了测试。

我们使用了 CMake 来支持不同的构建系统,但我们只测试了 Makefile。

要安装 Vulkan SDK,你需要下载此文件:sdk.lunarg.com/sdk/download/1.2.198.1/linux/vulkansdk-linux-x86_64-1.2.198.1.tar.gz

假设你已经将它下载到了~/Downloads文件夹中,通过运行以下命令来解压该包:

$ tar -xvf vulkansdk-linux-x86_64-1.2.198.1.tar.gz

这将创建1.2.198.1顶级文件夹。

有两种方法可以使 SDK 可用于构建代码:

  • 你可以将以下环境变量添加到你的~/.bashrc文件中(或者如果你不使用 Bash,那么是你的 shell 的主要配置文件)。请注意,你可能需要创建此文件:

    export VULKAN_SDK=~/vulkan/1.2.198.1/x86_64
    
    export PATH=$VULKAN_SDK/bin:$PATH
    
    export LD_LIBRARY_PATH=$VULKAN_SDK/lib:
    
    $LD_LIBRARY_PATH
    
    export VK_LAYER_PATH=$VULKAN_SDK/etc/vulkan/
    
    explicit_layer.d
    
  • 另一种选择是将以下内容添加到你的~/.bashrc文件中:

    source ~/Downloads/1.2.198.1/setup-env.sh
    

在你编辑了~/.bashrc文件之后,重启你的终端。现在你应该能够运行vulkaninfo了。如果不行,请再次尝试前面的步骤。如果你需要关于安装过程的更多详细信息,请参考官方 LunarG 指南(vulkan.lunarg.com/doc/sdk/latest/linux/getting_started.xhtml)。

要生成构建文件,你需要运行以下命令:

$ cmake -B build -DCMAKE_BUILD_TYPE=Debug

如果你想要创建一个发布版本,请运行以下命令:

$ cmake -B build -DCMAKE_BUILD_TYPE=Release

这将在build文件夹中创建构建文件。当然,你也可以为文件夹使用不同的名称。

要为这一章构建代码,请运行以下命令:

$ cmake --build build --target chapter1 -- -j 4

-j后面的数字告诉编译器使用多少线程并行编译代码。建议使用处理器核心数作为数值。

构建完成后,Chapter1可执行文件已经创建并准备好运行!

注意

在本书的编写过程中,我们的技术审查员和 Beta 读者对 Windows 和 Linux 的构建进行了测试,但可能有一些问题未被注意到。如果你有任何问题或想要报告问题,请打开 GitHub 问题或通过 Twitter 联系我们:@marco_castorina@GabrielSassone

macOS

Vulkan 在 macOS 上不是原生可用的,但通过苹果开发的图形 API Metal 的转换层提供。这个转换层由 Vulkan SDK 和 MoltenVK 库提供。

由于这种间接性,并非所有功能和扩展都在 macOS 上可用。鉴于我们将在本书中使用一些高级功能,如光线追踪,我们不希望为 macOS 提供一个部分工作的代码版本。目前,这个平台不受支持。

如何阅读本书

我们已经组织了本书的内容,以便逐步构建更高级的功能。本书的高级章节将依赖于本书早期暴露的主题。因此,我们建议你按顺序阅读本书。

然而,关于光线追踪的一些后续章节可以按任何顺序阅读,因为它们涵盖了可以独立发展的主题。如果你已经在某个章节中对这个主题有所了解,我们仍然建议你快速浏览一下,因为你可能还会找到一些有价值的信息。

理解代码结构

在本节中,我们将深入探讨本书中使用的底层代码,并解释我们做出的一些决策背后的原因。

当我们开始考虑要使用的代码时,目标很明确:我们需要一个轻量级、简单且足够基础的代码,以便我们能够在此基础上进行构建。一个功能齐全的库会过于复杂。

此外,我们需要一些我们熟悉的东西来使开发过程更加顺利并增强我们的信心。

现在有多个优秀的库,例如 Sokol (github.com/floooh/sokol) 或 BGFX (github.com/bkaradzic/bgfx),以及其他一些库,但它们都有一些缺点,似乎存在问题。

例如,Sokol 虽然是一个优秀的库,但它不支持 Vulkan API,并且其接口仍然基于较旧的图形 API(如 OpenGL 和 D3D11)。

BGFX 是一个更完整的库,但它过于通用且功能丰富,以至于我们无法在此基础上进行构建。

经过一番研究,我们倾向于选择 Hydra 引擎——这是一个加布里埃尔在过去几年中开发,用于实验和撰写关于渲染文章的代码库。

以下是使用 Hydra 引擎(github.com/JorenJoestar/DataDrivenRendering)并逐步将其发展成为 Raptor 引擎的一些优势:

  • 代码熟悉度

  • 小巧简单的代码库

  • 基于 Vulkan 的 API

  • 没有高级功能,但强大的构建块

Hydra 引擎看起来完美,小巧但可用且熟悉。从 API 设计的角度来看,与其他两位作者过去使用的库相比,它具有明显的优势。

Gabriel 从头开始设计,通过本书演变代码时,对底层架构有全面了解。

从 Hydra 引擎开始,我们对一些代码进行了修改,使其更加专注于 Vulkan,因此诞生了 Raptor 引擎。在接下来的章节中,我们将简要地看一下代码架构,以便您熟悉将在所有章节中使用的构建块。

我们还将查看用于将网格、纹理和材料导入 Raptor 引擎的 glTF 数据格式。

代码层

Raptor 引擎是以基于层的思维方式创建的,其中一层只能与较低层交互。

这个选择是为了简化层之间的通信,简化 API 设计和最终用户期望的行为。

Raptor 有三个层:

  • 基础

  • 图形

  • 应用

source/raptor

每一章都有自己的source/chapter1/graphics实现。

在开发 Raptor 引擎时,我们根据所在的层强制执行通信方向,以便一层只能与同一层内的代码和底层交互。

在这种情况下,基础层只能与层内的其他代码交互,图形层可以与基础层交互,应用层与所有层交互。

可能会有需要从底层到上层进行通信的情况,解决这个问题的方法是,在上层创建代码来驱动底层之间的通信。

例如,Camera类定义在基础层,它是一个包含所有驱动渲染相机的数学代码的类。

如果我们需要用户输入来移动相机,比如用鼠标或游戏手柄,怎么办?

基于这个决定,我们在应用层创建了GameCamera,它包含输入代码,接收用户输入,并根据需要修改相机。

这种上层桥接将在代码的其他区域使用,并在需要时进行解释。

以下章节将为您概述主要层和一些基本代码,以便您熟悉将在整本书中使用的所有可用构建块。

基础层

基础层是一组不同的类,它们作为框架中所需一切的基本砖块。

这些类非常专业化,覆盖了不同的需求类型,但它们是构建本书中编写的渲染代码所必需的。它们包括数据结构、文件操作、日志记录和字符串处理。

虽然 C++标准库提供了类似的数据结构,但我们决定自己编写,因为在大多数情况下我们只需要功能子集。这也允许我们仔细控制和跟踪内存分配。

我们为了获得对内存生命周期更精细的控制和更好的编译时间,牺牲了一些舒适度(即自动释放内存)。这些至关重要的数据结构用于不同的需求,将在图形层中被大量使用。

我们将简要地回顾每个基础模块,以帮助您熟悉它们。

内存管理

让我们从source/raptor/foundation/memory.hpp)开始。

在这里做出的一个关键 API 决策是采用显式分配模型,因此对于任何动态分配的内存,都需要一个分配器。这一点在代码库中的所有类中都有体现。

这个基础模块定义了不同分配器使用的主要分配器 API。

HeapAllocator,基于tlsf分配器,一个固定大小的线性分配器,一个基于 malloc 的分配器,一个固定大小的栈分配器,以及一个固定大小的双栈分配器。

虽然我们不会在这里介绍内存管理技术,因为它与本书的目的不太相关,但您可以在代码库中窥见更专业的内存管理思维。

数组

接下来,我们将查看source/raptor/foundation/array.hpp)。

可能是所有软件工程中最基本的数据结构,数组用于表示连续和动态分配的数据,其接口类似于更为人所知的std::vector (en.cppreference.com/w/cpp/container/vector)。

std实现相比,代码更简单,需要显式初始化分配器。

std::vector相比,唯一的显著区别可能在于方法,例如push_use(),它扩展数组并返回新分配的元素以便填充,以及delete_swap()方法,它删除一个元素并将其与最后一个元素交换。

哈希表

source/raptor/foundation/hash_map.hpp)是另一种基本的数据结构,因为它们提高了搜索操作的性能,并且在代码库中被广泛使用:每当需要根据一些简单的搜索标准(按名称搜索纹理)快速查找对象时,哈希表就是事实上的标准数据结构。

关于哈希表的信息量巨大,超出了本书的范围,但最近谷歌在其 Abseil 库中记录并共享了一个良好的哈希表全面实现(代码在此处可用:github.com/abseil/abseil-cpp)。

Abseil 哈希表是 SwissTable 哈希表的演变,每个条目存储一些额外的元数据以快速拒绝元素,使用线性探测插入元素,最后使用单指令多数据(SIMD)指令快速测试更多条目。

重要提示

对于了解 Abseil 哈希图实现背后的思想,有几篇很好的文章可以阅读。它们可以在以下位置找到:

文章 1: gankra.github.io/blah/hashbrown-tldr/

文章 2: blog.waffles.space/2018/12/07/deep-dive-into-hashbrown/

文章 1是关于这个主题的良好概述,而文章 2则对实现进行了更深入的探讨。

文件操作

接下来,我们将查看source/raptor/foundation/file.hpp)。

在引擎中执行的一组常见操作是文件处理,例如,从硬盘读取纹理、着色器或文本文件。

这些操作遵循与 C 文件 API 类似的模式,例如file_openfopen函数类似(www.cplusplus.com/reference/cstdio/fopen/)。

在这个函数集里,也有一些用于创建和删除文件夹,或者一些如扩展文件名或路径扩展名的实用工具。

例如,要创建一个纹理,你首先需要在内存中打开纹理文件,然后将其发送到图形层以创建一个 Vulkan 表示,以便 GPU 能够正确使用。

序列化

(source/raptor/foundation/blob_serialization.hpp),将可读性文件转换为二进制对应物的过程也在这里。

这个主题非常广泛,信息并不像它应得的那么多,但一个好的起点是文章yave.handmade.network/blog/p/2723-how_media_molecule_does_serialization,或者jorenjoestar.github.io/post/serialization_for_games

我们将使用序列化来处理一些可读性文件(主要是 JSON 文件),并将它们转换为更定制的文件,以满足需要。

这个过程是为了加快文件加载速度,因为可读性格式非常适合表达事物并且可以修改,但可以创建二进制文件以满足应用程序的需求。

这是在任何游戏相关技术中的基本步骤,也称为资源烘焙

为了本代码的目的,我们将使用最小化的序列化,但就像内存管理一样,这是设计任何高效代码时需要考虑的话题。

日志

(source/raptor/foundation/log.hpp)是将一些用户定义的文本写入以帮助理解代码流程和调试应用程序的过程。

它可以用来写入系统的初始化步骤或报告一些带有附加信息的错误,以便用户可以使用。

代码中提供了一个简单的日志服务,提供添加用户定义的回调和拦截任何消息的选项。

日志使用的一个例子是 Vulkan 调试层,当需要时,它将任何警告或错误输出到日志服务,为用户提供关于应用程序行为的即时反馈。

字符串处理

接下来,我们将查看source/raptor/foundation/string.hpp)。

字符串是用于存储文本的字符数组。在 Raptor 引擎中,需要干净地控制内存和简单的接口,这增加了编写自定义字符串代码的需求。

提供的主要类是StringBuffer类,它允许用户分配最大固定数量的内存,并在该内存中执行典型的字符串操作:连接、格式化和子字符串。

提供的第二种类型是StringArray类,它允许用户在连续的内存块中高效地存储和跟踪不同的字符串。

例如,当检索文件和文件夹列表时,这被使用。最后一个实用工具字符串类是StringView类,用于只读访问字符串。

时间管理

接下来是source/raptor/foundation/time.hpp))。

在开发自定义引擎时,时间管理非常重要,而拥有一些帮助计算不同时间间隔的函数正是时间管理函数的作用。

例如,任何应用程序都需要计算时间差,用于推进时间和各种方面的计算,通常被称为delta time

这将在应用层手动计算,但使用时间函数来完成。它也可以用来测量 CPU 性能,例如,定位慢速代码或在执行某些操作时收集统计数据。

定时方法方便地允许用户以不同的单位计算时间间隔,从秒到毫秒。

进程执行

最后一个实用工具区域是source/raptor/foundation/process.hpp))——定义为在我们的代码中运行任何外部程序。

在 Raptor 引擎中,外部进程最重要的用途之一是执行 Vulkan 的着色器编译器,将 GLSL 着色器转换为 SPIR-V 格式,如www.khronos.org/registry/SPIR-V/specs/1.0/SPIRV.xhtml所示。Khronos 规范是着色器能够被 Vulkan 使用的必要条件。

我们已经介绍了所有不同的实用工具构建块(许多看似无关),它们涵盖了现代渲染引擎的基础。

这些基础本身与图形无关,但它们是构建一个允许最终用户完全控制所发生情况的图形应用程序所必需的,这代表了现代游戏引擎幕后所做工作的简化心态。

接下来,我们将介绍图形层,在那里可以看到一些基础组件的实际应用,并代表为本书开发的代码库中最重要的一部分。

图形层

最重要的架构层是图形层,这将是本书的主要焦点。图形将包括所有用于在屏幕上使用 GPU 绘制任何内容的 Vulkan 相关代码和抽象。

在源代码的组织中存在一个注意事项:由于本书分为不同的章节并且有一个 GitHub 仓库,因此需要对每个章节的图形代码进行快照;因此,图形代码将在每个章节的代码中重复并演变,贯穿整个游戏。

随着本书的进展,我们预计这个文件夹中的代码将增长,而不仅限于这里,因为我们还将开发着色器并使用其他数据资源,但了解我们从哪里开始或特定时间在书中的位置是基本的。

再次强调,API 设计来自 Hydra 如下:

  • 使用包含所有必要参数的creation结构创建图形资源

  • 资源作为句柄外部传递,因此它们易于复制并且安全传递

这一层的主要类是GpuDevice类,它负责以下内容:

  • Vulkan API 抽象和用法

  • 图形资源的创建、销毁和更新

  • 创建、销毁、调整大小和更新交换链

  • 命令缓冲区请求和提交到 GPU

  • GPU 时间戳管理

  • GPU-CPU 同步

我们将图形资源定义为任何位于 GPU 上的东西,例如以下内容:

  • 纹理:用于读取和写入的图像

  • 缓冲区:同质或异质数据的数组

  • 采样器:从原始 GPU 内存转换为着色器所需的任何内容

  • 着色器:SPIR-V 编译的 GPU 可执行代码

  • 管线:GPU 状态的几乎完整快照

图形资源的使用是任何类型渲染算法的核心。

因此,GpuDevice (source/chapter1/graphics/gpu_device.hpp) 是创建渲染算法的入口。

下面是GpuDevice接口资源的一个片段:

struct GpuDevice {
  BufferHandle create_buffer( const BufferCreation& bc );
  TextureHandle create_texture( const TextureCreation& tc
  );
  ...
  void destroy_buffer( BufferHandle& handle );
  void destroy_texture( TextureHandle& handle );

下面是一个创建和销毁VertexBuffer的示例,取自 Raptor 的ImGUIsource/chapter1/graphics/raptor_imgui.hpp)后端:

GpuDevice gpu;
// Create the main ImGUI vertex buffer
BufferCreation bc;
bc.set( VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
  ResourceUsageType::Dynamic, 65536 )
  .set_name( "VB_ImGui" );
BufferHandle imgui_vb = gpu.create(bc);
…
// Destroy the main ImGUI vertex buffer
gpu.destroy(imgui_vb);

在 Raptor Engine 中,图形资源(source/chapter1/graphics/gpu_resources.hpp)与 Vulkan 具有相同的粒度,但增强了以帮助用户编写更简单、更安全的代码。

让我们看看Buffer类:

struct Buffer {
    VkBuffer                        vk_buffer;
    VmaAllocation                   vma_allocation;
    VkDeviceMemory                  vk_device_memory;
    VkDeviceSize                    vk_device_size;
    VkBufferUsageFlags              type_flags      = 0;
    u32                             size            = 0;
    u32                             global_offset   = 0;
    BufferHandle                    handle;
    BufferHandle                    parent_buffer;
    const char* name                = nullptr;
}; // struct Buffer

如我们所见,Buffer结构包含相当多的额外信息。

首先,VkBuffer是 API 中使用的 Vulkan 主要结构。然后有一些与 GPU 上内存分配相关的成员,例如设备内存和大小。

注意,在 Raptor Engine 中使用的实用类称为虚拟内存分配器VMA)(github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator),这是编写 Vulkan 代码的事实上标准实用库。

这里,它体现在 vma_allocation 成员变量中。

此外,还有一些使用标志——大小和偏移量,以及全局偏移量——当前缓冲区句柄和父句柄(我们将在本书后面的章节中看到它们的用法),以及一个便于调试的易读字符串。这个 Buffer 可以被视为 Raptor 引擎中其他抽象创建的蓝图,以及它们如何帮助用户编写更简单、更安全的代码。

它们仍然尊重 Vulkan 的设计和哲学,但可以隐藏一些在用户关注渲染算法时可能不那么重要的实现细节。

我们对图形层进行了简要概述,这是本书代码中最重要的一部分。我们将在每一章之后更新其代码,并在整本书中深入探讨设计选择和实现细节。

接下来是应用层,它是用户与应用程序之间的最终步骤。

应用层

应用层负责处理引擎的实际应用方面——从基于操作系统的窗口创建和更新到从鼠标和键盘收集用户输入。

在这一层还包括一个非常实用的 ImGui 后端(github.com/ocornut/imgui),这是一个设计 UI 的惊人库,可以增强用户与应用程序的交互,使其更容易控制其行为。

这里有一个应用类,它将成为本书中创建的任何演示应用程序的蓝图,以便用户可以更多地关注应用程序的图形方面。

基础层和应用层的代码位于 source/raptor 文件夹中。这本书中,这段代码几乎保持不变,但因为我们主要编写的是图形系统,所以它被放在所有章节之间的共享文件夹中。

在本节中,我们解释了代码的结构,并展示了 Raptor 引擎的三个主要层:基础层、图形层和应用层。对于这些层中的每一个,我们都突出了一些主要类,如何使用它们,以及我们做出选择背后的推理和灵感。

在下一节中,我们将介绍我们选择的文件格式,以及我们如何将其集成到引擎中。

理解 glTF 场景格式

这些年已经开发了许多 3D 文件格式,对于这本书,我们选择使用 glTF。近年来,它变得越来越受欢迎;它有一个开放的规范,并且默认支持基于物理的渲染(PBR)模型。

我们选择这个格式是因为它的开放规范和易于理解的结构。我们可以使用 Khronos 在 GitHub 上提供的几个模型来测试我们的实现,并将我们的结果与其他框架进行比较。

它是一个基于 JSON 的格式,我们为本书构建了一个自定义解析器。JSON 数据将被反序列化为一个 C++ 类,我们将使用它来驱动渲染。

我们现在概述 glTF 格式的主体部分。在其根目录下,我们有一个场景列表,每个场景可以有多个节点。您可以在以下代码中看到这一点:

"scene": 0,
"scenes": [
    {
        "nodes": [
            0,
            1,
            2,
            3,
            4,
            5
        ]
    }
],

每个节点都包含一个在 mesh 数组中存在的索引:

"nodes": [
    {
        "mesh": 0,
        "name": "Hose_low"
    },
]

场景的数据存储在一个或多个缓冲区中,每个缓冲区的部分由一个缓冲区视图描述:

"buffers": [
    {
        "uri": "FlightHelmet.bin",
        "byteLength": 3227148
    }
],
"bufferViews": [
    {
        "buffer": 0,
        "byteLength": 568332,
        "name": "bufferViewScalar"
    },
]

每个缓冲区视图引用包含实际数据的缓冲区及其大小。访问器通过定义数据类型、偏移量和大小来指向缓冲区视图:

"accessors": [
    {
        "bufferView": 1,
        "byteOffset": 125664,
        "componentType": 5126,
        "count": 10472,
        "type": "VEC3",
        "name": "accessorNormals"
    }
]

mesh 数组包含一个条目列表,每个条目由一个或多个网格原语组成。网格原语包含指向访问器数组的属性列表、索引访问器的索引以及材质的索引:

"meshes": [
    {
        "primitives": [
            {
                "attributes": {
                    "POSITION": 1,
                    "TANGENT": 2,
                    "NORMAL": 3,
                    "TEXCOORD_0": 4
                },
                "indices": 0,
                "material": 0
            }
        ],
        "name": "Hose_low"
    }
]

materials 对象定义了哪些纹理被使用(漫反射颜色、法线贴图、粗糙度等)以及控制材质渲染的其他参数:

"materials": [
    {
        "pbrMetallicRoughness": {
            "baseColorTexture": {
                "index": 2
            },
            "metallicRoughnessTexture": {
                "index": 1
            }
        },
        "normalTexture": {
            "index": 0
        },
        "occlusionTexture": {
            "index": 1
        },
        "doubleSided": true,
        "name": "HoseMat"
    }
]

每个纹理都指定为一个图像和一个采样器的组合:

"textures": [
    {
        "sampler": 0,
        "source": 0,
        "name": "FlightHelmet_Materials_RubberWoodMat_Nor
                 mal.png"
    },
],
"images": [
    {
        "uri": "FlightHelmet_Materials_RubberWoodMat_Nor
                mal.png"
    },
],
"samplers": [
    {
        "magFilter": 9729,
        "minFilter": 9987
    }
]

glTF 格式可以指定许多其他细节,包括动画数据和相机。我们在这本书中使用的模型大多数不使用这些功能,但当我们使用这些功能时,我们将突出显示它们。

JSON 数据被反序列化为一个 C++ 类,然后用于渲染。我们省略了结果对象中的 glTF 扩展,因为它们在本书中未使用。我们现在正在通过一个代码示例来展示如何使用我们的解析器读取 glTF 文件。第一步是将文件加载到一个 glTF 对象中:

char gltf_file[512]{ };
memcpy( gltf_file, argv[ 1 ], strlen( argv[ 1] ) );
file_name_from_path( gltf_file );
glTF::glTF scene = gltf_load_file( gltf_file );

现在我们已经将 glTF 模型加载到 scene 变量中。

下一步是将模型中作为渲染一部分的缓冲区、纹理和采样器上传到 GPU 进行渲染。我们首先处理纹理和采样器:

Array<TextureResource> images;
images.init( allocator, scene.images_count );
for ( u32 image_index = 0; image_index
  < scene.images_count; ++image_index ) {
    glTF::Image& image = scene.images[ image_index ];
    TextureResource* tr = renderer.create_texture(
        image.uri.data, image.uri.data );
    images.push( *tr );
}
Array<SamplerResource> samplers;
samplers.init( allocator, scene.samplers_count );
for ( u32 sampler_index = 0; sampler_index
  < scene.samplers_count; ++sampler_index ) {
  glTF::Sampler& sampler = scene.samplers[ sampler_index ];
  SamplerCreation creation;
  creation.min_filter = sampler.min_filter == glTF::
      Sampler::Filter::LINEAR ? VK_FILTER_LINEAR :
          VK_FILTER_NEAREST;
  creation.mag_filter = sampler.mag_filter == glTF::
      Sampler::Filter::LINEAR ? VK_FILTER_LINEAR :
          VK_FILTER_NEAREST;
  SamplerResource* sr = renderer.create_sampler( creation
  );
  samplers.push( *sr );
}

每个资源都存储在一个数组中。我们遍历数组中的每个条目并创建相应的 GPU 资源。然后,我们将刚刚创建的资源存储在一个单独的数组中,该数组将在渲染循环中使用。

现在我们来看看我们如何处理缓冲区和缓冲区视图,如下所示:

Array<void*> buffers_data;
buffers_data.init( allocator, scene.buffers_count );
for ( u32 buffer_index = 0; buffer_index
  < scene.buffers_count; ++buffer_index ) {
    glTF::Buffer& buffer = scene.buffers[ buffer_index ];
    FileReadResult buffer_data = file_read_binary(
        buffer.uri.data, allocator );
    buffers_data.push( buffer_data.data );
}
Array<BufferResource> buffers;
buffers.init( allocator, scene.buffer_views_count );
for ( u32 buffer_index = 0; buffer_index
  < scene.buffer_views_count; ++buffer_index ) {
    glTF::BufferView& buffer = scene.buffer_views[
        buffer_index ];
    u8* data = ( u8* )buffers_data[ buffer.buffer ] +
        buffer.byte_offset;
    VkBufferUsageFlags flags =
        VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
            VK_BUFFER_USAGE_INDEX_BUFFER_BIT;
    BufferResource* br = renderer.create_buffer( flags,
        ResourceUsageType::Immutable, buffer.byte_length,
            data, buffer.name.data );
    buffers.push( *br );
}

首先,我们将完整的缓冲区数据读入 CPU 内存。然后,我们遍历每个缓冲区视图并创建其对应的 GPU 资源。我们将新创建的资源存储在一个数组中,该数组将在渲染循环中使用。

最后,我们读取网格定义以创建其对应的绘制数据。以下代码提供了一个读取位置缓冲区的示例。请参阅 chapter1/main.cpp 中的代码以获取完整实现:

for ( u32 mesh_index = 0; mesh_index < scene.meshes_count;
  ++mesh_index ) {
    glTF::Mesh& mesh = scene.meshes[ mesh_index ];
    glTF::MeshPrimitive& mesh_primitive = mesh.primitives[
        0 ];
    glTF::Accessor& position_accessor = scene.accessors[
        gltf_get_attribute_accessor_index(
        mesh_primitive.attributes, mesh_primitive.
        attribute_count, "POSITION" ) ];
    glTF::BufferView& position_buffer_view =
        scene.buffer_views[ position_accessor.buffer_view
        ];
    BufferResource& position_buffer_gpu = buffers[
        position_accessor.buffer_view ];
    MeshDraw mesh_draw{ };
    mesh_draw.position_buffer = position_buffer_gpu.handle;
    mesh_draw.position_offset = position_accessor.
                                byte_offset;
}

我们将渲染一个网格所需的全部 GPU 资源组合到一个 MeshDraw 数据结构中。我们检索由 Accessor 对象定义的缓冲区和纹理,并将它们存储在一个 MeshDraw 对象中,以便在渲染循环中使用。

在本章中,我们在应用程序开始时加载一个模型,并且它不会改变。得益于这个约束,我们可以在开始渲染之前只创建所有描述符集:

DescriptorSetCreation rl_creation{};
rl_creation.set_layout( cube_rll ).buffer( cube_cb, 0 );
rl_creation.texture_sampler( diffuse_texture_gpu.handle,
    diffuse_sampler_gpu.handle, 1 );
rl_creation.texture_sampler( roughness_texture_gpu.handle,
    roughness_sampler_gpu.handle, 2 );
rl_creation.texture_sampler( normal_texture_gpu.handle,
    normal_sampler_gpu.handle, 3 );
rl_creation.texture_sampler( occlusion_texture_gpu.handle,
    occlusion_sampler_gpu.handle, 4 );
 mesh_draw.descriptor_set = gpu.create_descriptor_set(
     rl_creation );

对于每种资源类型,我们在DescriptorSetCreation对象上调用相对方法。此对象存储将要用于通过 Vulkan API 创建描述符集的数据。

我们已经定义了我们需要的所有渲染对象。在我们的渲染循环中,我们只需遍历所有网格,绑定每个网格缓冲区和描述符集,然后调用draw

for ( u32 mesh_index = 0; mesh_index < mesh_draws.size;
  ++mesh_index ) {
    MeshDraw mesh_draw = mesh_draws[ mesh_index ];
    gpu_commands->bind_vertex_buffer( sort_key++,
        mesh_draw.position_buffer, 0,
            mesh_draw.position_offset );
    gpu_commands->bind_vertex_buffer( sort_key++,
        mesh_draw.tangent_buffer, 1,
            mesh_draw.tangent_offset );
    gpu_commands->bind_vertex_buffer( sort_key++,
        mesh_draw.normal_buffer, 2,
            mesh_draw.normal_offset );
    gpu_commands->bind_vertex_buffer( sort_key++,
        mesh_draw.texcoord_buffer, 3,
            mesh_draw.texcoord_offset );
    gpu_commands->bind_index_buffer( sort_key++,
        mesh_draw.index_buffer, mesh_draw.index_offset );
    gpu_commands->bind_descriptor_set( sort_key++,
        &mesh_draw.descriptor_set, 1, nullptr, 0 );
    gpu_commands->draw_indexed( sort_key++,
        TopologyType::Triangle, mesh_draw.count, 1, 0, 0,
            0 );
}

我们将在本书的整个过程中逐步完善此代码,但这对您尝试加载不同的模型或实验着色器代码(下一节将详细介绍)已经是一个很好的起点。

在线有关于 glTF 格式的几个教程,其中一些链接在进一步阅读部分。glTF 规范也是一个很好的细节来源,并且易于理解。我们建议您在阅读书籍或代码时,如果对格式的某些内容不太清楚,可以参考它。

在本节中,我们分析了 glTF 格式,并展示了与我们渲染器最相关的 JSON 对象示例。然后我们演示了如何使用我们添加到框架中的 glTF 解析器,并展示了如何将几何和纹理数据上传到 GPU。最后,我们展示了如何使用这些数据绘制构成模型的网格。

在下一节中,我们将解释我们刚刚解析并上传到 GPU 的数据是如何用于使用基于物理的渲染实现来渲染我们的模型的。

PBR 概述

PBR 是许多渲染引擎的核心。它最初是为离线渲染开发的,但得益于硬件能力的进步和图形社区的研究努力,现在也可以用于实时渲染。

如其名所示,这种技术旨在模拟光和物质的物理相互作用,并且在某些实现中,确保系统中的能量量保持不变。

有许多深入的资源可以详细描述 PBR。尽管如此,我们仍想简要概述我们的实现,供参考。我们遵循了 glTF 规范中提出的实现。

为了计算我们表面的最终颜色,我们必须确定漫反射和镜面反射成分。现实世界中镜面反射的量由表面的粗糙度决定。表面越光滑,反射的光量就越大。镜子(几乎)反射它接收到的所有光线。

表面粗糙度通过纹理进行建模。在 glTF 格式中,这个值与金属度和遮挡值打包在单个纹理中,以优化资源使用。我们在导体(或金属)和介电(非金属)表面之间区分材料。

金属材料只有镜面项,而非金属材料则同时具有漫反射和镜面项。为了模拟同时具有金属和非金属成分的材料,我们使用金属度项在两者之间进行插值。

由木材制成的物体可能具有金属度为 0,塑料将具有金属度和粗糙度的混合,而汽车的主体将由金属成分主导。

由于我们正在模拟材料的真实世界响应,我们需要一个函数,该函数接受视图和光方向,并返回反射的光量。这个函数被称为双向分布函数BRDF)。

我们使用 Trowbridge-Reitz/GGX 分布来模拟镜面 BRDF,其实现如下:

float NdotH = dot(N, H);
float alpha_squared = alpha * alpha;
float d_denom = ( NdotH * NdotH ) * ( alpha_squared - 1.0 )
    + 1.0;
float distribution = ( alpha_squared * heaviside( NdotH ) )
    / ( PI * d_denom * d_denom );
float NdotL = dot(N, L);
float NdotV = dot(N, V);
float HdotL = dot(H, L);
float HdotV = dot(H, V);
float visibility = ( heaviside( HdotL ) / ( abs( NdotL ) +
  sqrt( alpha_squared + ( 1.0 - alpha_squared ) *
  ( NdotL * NdotL ) ) ) ) * ( heaviside( HdotV ) /
  ( abs( NdotV ) + sqrt( alpha_squared +
  ( 1.0 - alpha_squared ) *
  ( NdotV * NdotV ) ) ) );
float specular_brdf = visibility * distribution;

首先,我们根据 glTF 规范中提供的公式计算分布和可见性项。然后,我们将它们相乘以获得镜面 BRDF 项。

还可以使用其他近似方法,我们鼓励你进行实验,用不同的方法替换我们的实现!

然后,我们按照以下方式计算漫反射 BDRF:

vec3 diffuse_brdf = (1 / PI) * base_colour.rgb;

现在我们介绍菲涅耳项。它根据观察角度和材料的折射率确定反射的颜色。以下是 Schlick 近似法的实现,适用于金属和非介电成分:

// f0 in the formula notation refers to the base colour
   here
vec3 conductor_fresnel = specular_brdf * ( base_colour.rgb
  + ( 1.0 - base_colour.rgb ) * pow( 1.0 - abs( HdotV ),
      5 ) );
// f0 in the formula notation refers to the value derived
   from ior = 1.5
float f0 = 0.04; // pow( ( 1 - ior ) / ( 1 + ior ), 2 )
float fr = f0 + ( 1 - f0 ) * pow(1 - abs( HdotV ), 5 );
vec3 fresnel_mix = mix( diffuse_brdf, vec3(
                        specular_brdf ), fr );

在这里,我们根据 glTF 规范中的公式计算导体和介电成分的菲涅耳项。

现在我们已经计算了模型的全部组件,我们将根据材料的金属度在它们之间进行插值,如下所示:

vec3 material_colour = mix( resnel_mix,
                            conductor_fresnel, metalness );

遮挡项没有被使用,因为它只影响间接光,而我们还没有实现这一点。

我们意识到这只是一个非常简短的介绍,我们跳过了许多使这些近似方法生效的理论。然而,它应该为更深入的研究提供了一个良好的起点。

如果你想进行实验并修改我们的基本实现,我们在进一步阅读部分添加了一些链接。

在下一节中,我们将介绍一个调试工具,每当遇到非平凡的渲染问题时,我们都会依赖它。在撰写本书的过程中,它已经帮了我们很多次!

关于 GPU 调试的一些建议

无论你在图形编程方面有多少经验,总会有需要调试问题时的时候。当 GPU 执行你的程序时,理解 GPU 正在做什么并不像在 CPU 上那样直接。幸运的是,GPU 调试工具已经取得了长足的进步,帮助我们处理程序不符合预期的情况。

GPU 供应商提供了出色的工具来调试和性能分析你的着色器:Nvidia 开发了 Nsight 图形工具,AMD 则提供了一套包括 Radeon GPU 分析器和 Radeon GPU 分析器的工具集。

对于这本书,我们主要使用了 RenderDoc(可在renderdoc.org/找到)。它是图形编程社区的标准工具,因为它允许你捕获帧并记录在该帧期间发出的所有 Vulkan API 调用。

使用 RenderDoc 非常简单。你首先提供应用程序的路径,如下所示:

图 1.2 – 在 RenderDoc 中设置应用程序路径

图 1.2 – 在 RenderDoc 中设置应用程序路径

然后,通过点击启动来启动应用程序,你会注意到一个报告帧时间和渲染帧数的覆盖层。如果你按下F12,RenderDoc 将记录当前帧。现在你可以关闭你的应用程序,记录的帧将自动加载。

在左侧,你有 API 调用列表,这些调用按渲染通道分组。此视图还列出了事件 IDEID),这是 RenderDoc 定义的递增数字。这对于比较多个帧中的事件很有用:

图 1.3 – 捕获帧的 Vulkan API 调用列表

图 1.3 – 捕获帧的 Vulkan API 调用列表

在应用程序窗口的右侧,你有多个标签页,允许你在绘制调用时检查哪些纹理被绑定,缓冲区内容以及管道的状态。

下图显示了纹理查看器标签页。它显示了给定绘制后的渲染输出以及哪些输入纹理被绑定:

图 1.4 – RenderDoc 纹理查看器

图 1.4 – RenderDoc 纹理查看器

如果你右键点击纹理查看器标签页中的一个像素,你可以检查该像素的历史记录以了解哪些绘制影响了它。

此外,还有一个调试功能,允许你逐步查看着色器代码并分析中间值。使用此功能时要小心,因为我们注意到这些值并不总是准确的。

这是对 RenderDoc 及其功能性的快速概述。你学习了如何在运行图形应用程序时在 RenderDoc 中捕获帧。我们展示了主要面板的分解、它们的功能以及如何使用它们来理解最终图像是如何渲染的。

我们鼓励你在 RenderDoc 下运行本章的代码,以更好地理解帧是如何构建的。

摘要

在本章中,我们为本书的其余部分奠定了基础。到现在为止,你应该熟悉代码的结构和使用方法。我们介绍了 Raptor 引擎,并提供了本书中将使用的主要类和库的概述。

我们介绍了 3D 模型的 glTF 格式以及如何将此格式解析成用于渲染的对象。我们简要介绍了 PBR 建模及其实现。最后,我们介绍了 RenderDoc 以及如何使用它来调试渲染问题或了解帧是如何构建的。

在下一章中,我们将探讨如何改进我们的资源管理!

进一步阅读

我们只是对所讨论的主题进行了初步探讨。在此,我们提供了您可以使用的资源链接,以获取本章中展示的概念的更多信息,这些信息将在整本书中都有用。

虽然我们已经编写了自己的标准库替代品,但如果您正在启动自己的项目,还有其他选择。我们强烈建议您考虑 EA 开发的github.com/electronicarts/EASTL

第二章:改进资源管理

在本章中,我们将改进资源管理,使其更容易处理可能具有不同数量纹理的材料。这种技术通常被称为无绑定,尽管这并不完全准确。我们仍然会绑定一个资源列表;然而,我们可以通过使用索引来访问它们,而不是在特定绘制过程中必须指定确切要使用哪些资源。

我们将要进行的第二个改进是自动生成管线布局。大型项目有成百上千个着色器,根据特定应用程序使用的材料组合编译出许多不同的变体。如果开发者每次更改都要手动更新他们的管线布局定义,那么很少会有应用程序能够上市。本章中提出的实现依赖于 SPIR-V 二进制格式提供的信息。

最后,我们将向我们的 GPU 设备实现中添加管线缓存。此解决方案提高了首次运行后管线对象的创建时间,并且可以显著提高应用程序的加载时间。

总结来说,在本章中,我们将涵盖以下主要主题:

  • 解锁并实现无绑定资源

  • 自动化管线布局生成

  • 使用管线缓存改进加载时间

到本章结束时,你将了解如何在 Vulkan 中启用和使用无绑定资源。你还将能够解析 SPIR-V 二进制数据来自动生成管线布局。最后,你将能够通过使用管线缓存来加快应用程序的加载时间。

技术要求

本章的代码可以在以下网址找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter2.

解锁并实现无绑定渲染

在上一章中,我们必须手动绑定每个材料的纹理。这也意味着,如果我们想支持需要不同数量纹理的不同类型的材料,我们就需要单独的着色器和管线。

Vulkan 提供了一种机制,可以绑定一个可用于多个着色器的纹理数组。然后,每个纹理都可以通过索引访问。在以下章节中,我们将突出显示我们对 GPU 设备实现所做的更改,以启用此功能,并描述如何使用它。

在以下章节中,我们首先将检查启用无绑定资源所需的扩展是否在给定的 GPU 上可用。然后,我们将展示对描述符池创建和描述符集更新的更改,以利用无绑定资源。最后一步将是更新我们的着色器,以便在纹理数组中使用索引进行渲染。

检查支持

大多数桌面 GPU,即使相对较旧,只要您有最新的驱动程序,都应该支持VK_EXT_descriptor_indexing扩展。仍然是一个好习惯来验证扩展是否可用,并且在生产实现中,如果扩展不可用,提供使用标准绑定模型的替代代码路径。

要验证您的设备是否支持此扩展,您可以使用以下代码,或者您可以使用 Vulkan SDK 提供的vulkaninfo应用程序。参见第一章介绍 Raptor 引擎和 Hydra,了解如何安装 SDK。

第一步是查询物理设备以确定 GPU 是否支持此扩展。以下代码段完成了这项任务:

VkPhysicalDeviceDescriptorIndexingFeatures indexing
_features{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR
           _INDEXING_FEATURES, nullptr };
    VkPhysicalDeviceFeatures2 device_features{
        VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
            &indexing_features };
    vkGetPhysicalDeviceFeatures2( vulkan_physical_device,
                                  &device_features );
    bindless_supported = indexing_features.
                         descriptorBindingPartiallyBound &&
                         indexing_features.
                         runtimeDescriptorArray;

我们必须填充VkPhysicalDeviceDescriptorIndexingFeatures结构并将其链接到VkPhysicalDeviceFeatures2结构。驱动程序在调用vkGetPhysicalDeviceFeatures2时将填充indexing_features变量成员。为了验证描述符索引扩展是否受支持,我们检查descriptorBindingPartiallyBoundruntimeDescriptorArray的值是否为true

一旦我们确认扩展受支持,我们可以在创建设备时启用它:

VkPhysicalDeviceFeatures2 physical_features2 = {
    VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2 };
vkGetPhysicalDeviceFeatures2( vulkan_physical_device,
                              &physical_features2 );
VkDeviceCreateInfo device_create_info = {};
// same code as chapter 1
device_create_info.pNext = &physical_features2;
if ( bindless_supported ) {
    physical_features2.pNext = &indexing_features;
}
vkCreateDevice( vulkan_physical_device,
                &device_create_info,
                vulkan_allocation_callbacks,
                &vulkan_device );

我们必须将indexing_features变量链接到创建设备时使用的physical_features2变量。其余的代码与第一章中的代码相同,介绍 Raptor 引擎Hydra

创建描述符池

下一步是从中可以分配支持在绑定后更新纹理内容的描述符集的描述符池:

VkDescriptorPoolSize pool_sizes_bindless[] =
{
    { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
      k_max_bindless_resources },
      { VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
      k_max_bindless_resources },
};
pool_info.flags = VK_DESCRIPTOR_POOL_CREATE_UPDATE
                  _AFTER_BIND_BIT_EXT;
pool_info.maxSets = k_max_bindless_resources * ArraySize(
                    pool_sizes_bindless );
pool_info.poolSizeCount = ( u32 )ArraySize(
                            pool_sizes_bindless );
pool_info.pPoolSizes = pool_sizes_bindless;
vkCreateDescriptorPool( vulkan_device, &pool_info,
                        vulkan_allocation_callbacks,
                        &vulkan_bindless_descriptor_pool);

第一章中的代码相比,主要区别是添加了VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT_EXT标志。此标志是允许创建在绑定后可以更新的描述符集所必需的。

接下来,我们必须定义描述符集布局绑定:

const u32 pool_count = ( u32 )ArraySize(
                         pool_sizes_bindless );
VkDescriptorSetLayoutBinding vk_binding[ 4 ];
VkDescriptorSetLayoutBinding& image_sampler_binding =
    vk_binding[ 0 ];
image_sampler_binding.descriptorType = VK_DESCRIPTOR
                                       _TYPE_COMBINED
                                       _IMAGE_SAMPLER;
image_sampler_binding.descriptorCount =
    k_max_bindless_resources;
image_sampler_binding.binding = k_bindless_texture_binding;
VkDescriptorSetLayoutBinding& storage_image_binding =
    vk_binding[ 1 ];
storage_image_binding.descriptorType = VK_DESCRIPTOR
                                       _TYPE_STORAGE_IMAGE;
storage_image_binding.descriptorCount =
    k_max_bindless_resources;
storage_image_binding.binding = k_bindless_texture_binding
                                + 1;

注意,descriptorCount不再具有1的值,而必须容纳我们可以使用的最大纹理数量。现在我们可以使用这些数据来创建描述符集布局:

VkDescriptorSetLayoutCreateInfo layout_info = {
    VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO };
layout_info.bindingCount = pool_count;
layout_info.pBindings = vk_binding;
layout_info.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE
                    _UPDATE_AFTER_BIND_POOL_BIT_EXT;
VkDescriptorBindingFlags bindless_flags =
    VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT_EXT |
        VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT_EXT;
VkDescriptorBindingFlags binding_flags[ 4 ];
binding_flags[ 0 ] = bindless_flags;
binding_flags[ 1 ] = bindless_flags;
VkDescriptorSetLayoutBindingFlagsCreateInfoEXT
extended_info{
    VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT
        _BINDING_FLAGS_CREATE_INFO_EXT, nullptr };
extended_info.bindingCount = pool_count;
extended_info.pBindingFlags = binding_flags;
layout_info.pNext = &extended_info;
vkCreateDescriptorSetLayout( vulkan_device, &layout_info,
                             vulkan_allocation_callbacks,
                             &vulkan_bindless
                             _descriptor_layout );

代码与上一章中看到的版本非常相似;然而,我们添加了bindless_flags值以启用描述符集的部分更新。我们还需要将VkDescriptorSetLayoutBindingFlagsCreateInfoEXT结构链接到layout_info变量。最后,我们可以创建将在应用程序生命周期内使用的描述符集:

VkDescriptorSetAllocateInfo alloc_info{
    VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO };
alloc_info.descriptorPool = vulkan_bindless
                            _descriptor_pool;
alloc_info.descriptorSetCount = 1;
alloc_info.pSetLayouts = &vulkan_bindless_descriptor
                         _layout;
vkAllocateDescriptorSets( vulkan_device, &alloc_info,
                          &vulkan_bindless_descriptor_set
                         );

我们只需用我们之前定义的值填充VkDescriptorSetAllocateInfo结构并调用vkAllocateDescriptorSets

更新描述符集

到目前为止,我们已经完成了大部分繁重的工作。当我们调用 GpuDevice::create_texture 时,新创建的资源会被添加到 texture_to_update_bindless 数组中:

if ( gpu.bindless_supported ) {
    ResourceUpdate resource_update{
        ResourceDeletionType::Texture,
            texture->handle.index, gpu.current_frame };
    gpu.texture_to_update_bindless.push( resource_update );
}

还可以将特定的采样器关联到给定的纹理上。例如,当我们为某个材质加载纹理时,我们添加以下代码:

gpu.link_texture_sampler( diffuse_texture_gpu.handle,
                          diffuse_sampler_gpu.handle );

这将散布的纹理与其采样器关联起来。这个信息将在下一节代码中用来确定我们是否使用默认采样器或刚刚分配给纹理的采样器。

在处理下一帧之前,我们使用上一节创建的描述符集更新任何已上传的新纹理:

for ( i32 it = texture_to_update_bindless.size - 1;
  it >= 0; it-- ) {
    ResourceUpdate& texture_to_update =
        texture_to_update_bindless[ it ];
   Texture* texture = access_texture( {
                      texture_to_update.handle } );
    VkWriteDescriptorSet& descriptor_write =
        bindless_descriptor_writes[ current_write_index ];
    descriptor_write = {
        VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET };
    descriptor_write.descriptorCount = 1;
    descriptor_write.dstArrayElement =
        texture_to_update.handle;
    descriptor_write.descriptorType =
        VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
    descriptor_write.dstSet =
        vulkan_bindless_descriptor_set;
    descriptor_write.dstBinding =
        k_bindless_texture_binding;
    Sampler* vk_default_sampler = access_sampler(
                                  default_sampler );
    VkDescriptorImageInfo& descriptor_image_info =
        bindless_image_info[ current_write_index ];
    if ( texture->sampler != nullptr ) {
        descriptor_image_info.sampler =
        texture->sampler->vk_sampler;
    }
    else {
        descriptor_image_info.sampler =
        vk_default_sampler->vk_sampler;
    }
descriptor_image_info.imageView = 
        texture->vk_format != VK_FORMAT_UNDEFINED ? 
        texture->vk_image_view : vk_dummy_texture-> 
        vk_image_view;
    descriptor_image_info.imageLayout =
        VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    descriptor_write.pImageInfo = &descriptor_image_info;
    texture_to_update.current_frame = u32_max;
    texture_to_update_bindless.delete_swap( it );
    ++current_write_index;
}

上述代码与上一个版本非常相似。我们已突出显示主要差异:采样器选择,正如我们上段所述,以及如果槽位为空时使用虚拟纹理。我们仍然需要为每个槽位分配一个纹理,因此如果未指定,则使用虚拟纹理。这也有助于在场景中查找任何缺失的纹理。

如果你更喜欢紧密打包的纹理数组,另一个选项是启用 VK_DESCRIPTOR_BINDING_VARIABLE_DESCRIPTOR_COUNT_BIT_EXT 标志,并在创建描述符集时链式连接一个 VkDescriptorSetVariableDescriptorCountAllocateInfoEXT 结构。我们已经有了一些启用此功能的初步代码,并鼓励你完成实现!

更新着色器代码

使用绑定无关渲染的最后一部分是在着色器代码中,因为它需要以不同的方式编写。

对于所有使用绑定无关资源的着色器,步骤都是相似的,并且将它们定义在公共头文件中将是有益的。不幸的是,这并不完全由 OpenGL 着色语言GLSL 支持。

我们建议自动化这一步骤,因为它可以在编译引擎代码中的着色器时轻松添加。

首先要做的是在 GLSL 代码中启用非均匀限定符:

#extension GL_EXT_nonuniform_qualifier : enable

这将在当前着色器中启用扩展,而不是全局;因此,它必须在每个着色器中编写。

以下代码是正确绑定无关纹理的声明,但有一个限制:

layout ( set = 1, binding = 10 ) uniform sampler2D global_textures[];
layout ( set = 1, binding = 10 ) uniform sampler3D global_textures_3d[];

这是一个已知的技巧,可以将纹理声明别名到相同的绑定点。这允许我们拥有一个全局的绑定无关纹理数组,但一次支持所有类型的纹理(一维、二维、三维及其数组对应物)!

这简化了在引擎和着色器中绑定无关纹理的使用。

最后,为了读取纹理,着色器中的代码需要按以下方式修改:

texture(global_textures[nonuniformEXT(texture_index)],
        vTexcoord0)

让我们按以下顺序进行:

  1. 首先,我们需要来自常量的整数索引。在这种情况下,texture_index 将包含与绑定无关数组中纹理位置相同的数字。

  2. 第二点,这是一个关键的变化,我们需要用nonuniformEXT限定符(github.com/KhronosGroup/GLSL/blob/master/extensions/ext/GL_EXT_nonuniform_qualifier.txt)包装索引;这基本上会在不同的执行之间同步程序,以确保正确读取纹理索引,以防索引在不同线程的同一着色器调用中不同。

这可能一开始听起来很复杂,但把它看作是一个需要同步的多线程问题,以确保每个线程都能正确读取适当的纹理索引,从而使用正确的纹理。

  1. 最后,使用我们从global_textures数组中读取的同步索引,我们终于得到了我们想要的纹理样本!

我们现在已将无绑定纹理支持添加到 Raptor 引擎中!我们首先检查 GPU 是否支持此功能。然后我们详细说明了我们对描述符池和描述符集创建所做的更改。

最后,我们展示了如何随着新纹理上传到 GPU,更新描述符集,以及必要的着色器修改以使用无绑定纹理。从现在开始的所有渲染都将使用这个功能;因此,这个概念将变得熟悉。

接下来,我们将通过解析着色器的二进制数据来添加自动管线生成,以提升我们的引擎功能。

自动化管线布局生成

在本节中,我们将利用 SPIR-V 二进制格式提供的数据来提取创建管线布局所需的信息。SPIR-V 是着色器源代码在传递给 GPU 之前编译成的中间表示IR)。

与标准的 GLSL 着色器源代码(纯文本)相比,SPIR-V 是一种二进制格式。这意味着它是一个在分发应用程序时更紧凑的格式。更重要的是,开发者不必担心他们的着色器根据其代码运行的 GPU 和驱动程序被编译成不同的一组高级指令。

然而,SPIR-V 二进制文件不包含 GPU 将要执行的最终指令。每个 GPU 都会将 SPIR-V 数据块进行最终编译成 GPU 指令。这一步仍然是必需的,因为不同的 GPU 和驱动程序版本可以为相同的 SPIR-V 二进制文件生成不同的汇编代码。

将 SPIR-V 作为中间步骤仍然是一个巨大的改进。着色器代码的验证和解析是在离线完成的,开发者可以将他们的着色器与他们的应用程序代码一起编译。这允许我们在尝试运行着色器代码之前发现任何语法错误。

拥有中间表示形式的另一个好处是能够将不同语言编写的着色器编译为 SPIR-V,以便它们可以与 Vulkan 一起使用。例如,可以将用 HLSL 编写的着色器编译为 SPIR-V,并在 Vulkan 渲染器中重用它。

在此选项可用之前,开发者要么必须手动移植代码,要么必须依赖将着色器从一种语言重写到另一种语言的工具。

到现在为止,您应该已经相信 SPIR-V 的引入为开发者和 Vulkan API 带来了优势。

在接下来的章节中,我们将使用我们的一个着色器来向您展示如何将其编译为 SPIR-V,并解释如何使用二进制数据中的信息自动生成管线布局。

将 GLSL 编译为 SPIR-V

我们将使用我们在第一章介绍 Raptor 引擎和 Hydra中开发的顶点着色器代码。之前,我们将着色器代码字符串存储在main.cpp文件中,并且在将其传递给 Vulkan API 以创建管线之前,我们没有将其编译为 SPIR-V。

从本章开始,我们将把所有着色器代码存储在每个章节的shaders文件夹中。对于第二章改进资源管理,您将找到两个文件:main.vert用于顶点着色器,main.frag用于片段着色器。以下是main.vert的内容:

#version 450
layout ( std140, binding = 0 ) uniform LocalConstants {
    mat4        model;
    mat4        view_projection;
    mat4        model_inverse;
    vec4        eye;
    vec4        light;
};
layout(location=0) in vec3 position;
layout(location=1) in vec4 tangent;
layout(location=2) in vec3 normal;
layout(location=3) in vec2 texCoord0;
layout (location = 0) out vec2 vTexcoord0;
layout (location = 1) out vec3 vNormal;
layout (location = 2) out vec4 vTangent;
layout (location = 3) out vec4 vPosition;
void main() {
    gl_Position = view_projection * model * vec4(position,
                                                 1);
    vPosition = model * vec4(position, 1.0);
    vTexcoord0 = texCoord0;
    vNormal = mat3(model_inverse) * normal;
    vTangent = tangent;
}

这段代码对于一个顶点着色器来说相当标准。我们有四个数据流,用于位置、切线、法线和纹理坐标。我们还定义了一个LocalConstants统一缓冲区,用于存储所有顶点的公共数据。最后,我们定义了将传递给片段着色器的out变量。

Vulkan SDK 提供了将 GLSL 编译为 SPIR-V 以及将生成的 SPIR-V 反汇编成人类可读形式的工具。这可以用于调试表现不佳的着色器。

要编译我们的顶点着色器,我们运行以下命令:

glslangValidator -V main.vert -o main.vert.spv

这将生成一个包含二进制数据的main.vert.spv文件。要查看此文件的内容以人类可读格式,我们运行以下命令:

spirv-dis main.vert.spv

此命令将在终端上打印出反汇编的 SPIR-V。我们现在将检查输出的相关部分。

理解 SPIR-V 输出

从输出的顶部开始,以下是我们提供的第一组信息:

      OpCapability Shader
%1 = OpExtInstImport "GLSL.std.450"
      OpMemoryModel Logical GLSL450
      OpEntryPoint Vertex %main "main" %_ %position
      %vPosition %vTexcoord0 %texCoord0 %vNormal %normal
      %vTangent %tangent
      OpSource GLSL 450
      OpName %main "main"

这个前缀定义了编写着色器所使用的 GLSL 版本。OpEntryPoint指令引用了主函数,并列出了着色器的输入和输出。惯例是变量以%为前缀,并且可以提前声明稍后定义的变量。

下一个部分定义了在此着色器中可用的输出变量:

OpName %gl_PerVertex "gl_PerVertex"
OpMemberName %gl_PerVertex 0 "gl_Position"
OpMemberName %gl_PerVertex 1 "gl_PointSize"
OpMemberName %gl_PerVertex 2 "gl_ClipDistance"
OpMemberName %gl_PerVertex 3 "gl_CullDistance"
OpName %_ ""

这些是由编译器自动注入的变量,由 GLSL 规范定义。我们可以看到一个 gl_PerVertex 结构体,它反过来有四个成员:gl_Positiongl_PointSizegl_ClipDistancegl_CullDistance。还有一个未命名的变量定义为 %_。我们很快就会发现它指的是什么。

现在,我们继续到我们定义的结构体:

OpName %LocalConstants "LocalConstants"
OpMemberName %LocalConstants 0 "model"
OpMemberName %LocalConstants 1 "view_projection"
OpMemberName %LocalConstants 2 "model_inverse"
OpMemberName %LocalConstants 3 "eye"
OpMemberName %LocalConstants 4 "light"
OpName %__0 ""

在这里,我们有我们的 LocalConstants 统一缓冲区的条目,其成员以及它们在结构体中的位置。我们再次看到了一个未命名的 %__0 变量。我们很快就会了解它。SPIR-V 允许你定义成员装饰来提供有助于确定数据布局和结构体内位置的信息:

OpMemberDecorate %LocalConstants 0 ColMajor
OpMemberDecorate %LocalConstants 0 Offset 0
OpMemberDecorate %LocalConstants 0 MatrixStride 16
OpMemberDecorate %LocalConstants 1 ColMajor
OpMemberDecorate %LocalConstants 1 Offset 64
OpMemberDecorate %LocalConstants 1 MatrixStride 16
OpMemberDecorate %LocalConstants 2 ColMajor
OpMemberDecorate %LocalConstants 2 Offset 128
OpMemberDecorate %LocalConstants 2 MatrixStride 16
OpMemberDecorate %LocalConstants 3 Offset 192
OpMemberDecorate %LocalConstants 4 Offset 208
OpDecorate %LocalConstants Block

从这些条目中,我们可以开始对结构体中每个成员的类型有所了解。例如,我们可以识别前三个条目为矩阵。最后一个条目只有一个偏移量。

对于我们的目的来说,偏移量值是最相关的值,因为它允许我们知道每个成员的确切起始位置。当从 CPU 向 GPU 转移数据时,这一点至关重要,因为每个成员的对齐规则可能不同。

接下来的两行定义了我们的结构体的描述符集和绑定:

OpDecorate %__0 DescriptorSet 0
OpDecorate %__0 Binding 0

如您所见,这些装饰项引用了未命名的 %__0 变量。我们现在已经到达了定义变量类型的部分:

%float = OpTypeFloat 32
%v4float = OpTypeVector %float 4
%uint = OpTypeInt 32 0
%uint_1 = OpConstant %uint 1
%_arr_float_uint_1 = OpTypeArray %float %uint_1
%gl_PerVertex = OpTypeStruct %v4float %float
                %_arr_float_uint_1 %_arr_float_uint_1
%_ptr_Output_gl_PerVertex = OpTypePointer Output
                            %gl_PerVertex
%_ = OpVariable %_ptr_Output_gl_PerVertex Output

对于每个变量,我们都有其类型,并且根据类型,还有与之相关的附加信息。例如,%float 变量是 32 位 float 类型;%v4float 变量是 vector 类型,并且包含 4 个 %float 值。

这对应于 GLSL 中的 vec4。然后我们有一个无符号值 1 的常量定义和一个长度为 1 的固定大小的 float 类型的数组。

%gl_PerVertex 变量的定义如下。它是 struct 类型,并且,正如我们之前看到的,它有四个成员。它们的类型是 vec4 用于 gl_Positionfloat 用于 gl_PointSize,以及 float[1] 用于 gl_ClipDistancegl_CullDistance

SPIR-V 规范要求每个可读或可写的变量都通过指针来引用。这正是我们看到的 %_ptr_Output_gl_PerVertex:它是指向 gl_PerVertex 结构体的指针。最后,我们可以看到未命名的 %_ 变量的类型是指向 gl_PerVertex 结构体的指针。

最后,我们有我们自己的统一数据的类型定义:

%LocalConstants = OpTypeStruct %mat4v4float %mat4v4float
                  %mat4v4float %v4float %v4float
%_ptr_Uniform_LocalConstants = OpTypePointer Uniform
                               %LocalConstants
%__0 = OpVariable %_ptr_Uniform_LocalConstants
       Uniform

如前所述,我们可以看到 %LocalConstants 是一个具有五个成员的结构体,其中三个是 mat4 类型,两个是 vec4 类型。然后我们有我们统一结构体的指针类型定义,最后是此类型的 %__0 变量。请注意,此变量具有 Uniform 属性。这意味着它是只读的,我们将在以后利用这个信息来确定要添加到管道布局中的描述符类型。

解析的其余部分包含输入和输出变量定义。它们的定义结构与迄今为止我们所看到的变量相同,因此我们在这里不会分析它们。

解析还包含着着色器主体的指令。虽然看到 GLSL 代码如何被转换成 SPIR-V 指令很有趣,但这与管道创建无关,我们在这里不会涉及这个细节。

接下来,我们将展示如何利用所有这些数据来自动化管道创建。

从 SPIR-V 到管道布局

Khronos 已经提供了解析 SPIR-V 数据以创建管道布局的功能。您可以在 github.com/KhronosGroup/SPIRV-Reflect 找到其实施。对于这本书,我们决定编写一个简化的解析器版本,我们认为它更容易跟随,因为我们只对一小部分条目感兴趣。

您可以在 source\chapter2\graphics\spirv_parser.cpp 中找到实现。让我们看看如何使用这个 API 以及它在底层是如何工作的:

spirv::ParseResult parse_result{ };
spirv::parse_binary( ( u32* )spv_vert_data,
                       spv_vert_data_size, name_buffer,
                       &parse_result );
spirv::parse_binary( ( u32* )spv_frag_data,
                       spv_frag_data_size, name_buffer,
                       &parse_result );

在这里,我们假设顶点和片段着色器的二进制数据已经读取到 spv_vert_dataspv_frag_data 变量中。我们必须定义一个空的 spirv::ParseResult 结构,它将包含解析的结果。其定义相当简单:

struct ParseResult {
    u32 set_count;
    DescriptorSetLayoutCreation sets[MAX_SET_COUNT];
};

它包含了我们从二进制数据中识别出的集合数量以及每个集合的条目列表。

解析的第一步是确保我们正在读取有效的 SPIR-V 数据:

u32 spv_word_count = safe_cast<u32>( data_size / 4 );
u32 magic_number = data[ 0 ];
RASSERT( magic_number == 0x07230203 );
u32 id_bound = data[3];

我们首先计算二进制中包含的 32 位单词数量。然后我们验证前四个字节是否匹配标识 SPIR-V 二进制的魔数。最后,我们检索二进制中定义的 ID 数量。

接下来,我们遍历二进制中的所有单词以检索所需的信息。每个 ID 定义都以 Op 类型及其组成的单词数量开始:

SpvOp op = ( SpvOp )( data[ word_index ] & 0xFF );
u16 word_count = ( u16 )( data[ word_index ] >> 16 );

Op 类型存储在单词的最低 16 位中,单词计数在最高 16 位中。接下来,我们解析我们感兴趣的 Op 类型的数据。在本节中,我们不会涵盖所有 Op 类型,因为所有类型的结构都是相同的。我们建议您参考 SPIR-V 规范(在 进一步阅读 部分链接),以获取每个 Op 类型的更多详细信息。

我们从当前正在解析的着色器类型开始:

case ( SpvOpEntryPoint ):
{
    SpvExecutionModel model = ( SpvExecutionModel )data[
                                word_index + 1 ];
    stage = parse_execution_model( model );
    break;
}

我们提取执行模型,将其转换为 VkShaderStageFlags 值,并将其存储在 stage 变量中。

接下来,我们解析描述符集索引和绑定:

case ( SpvOpDecorate ):
{
    u32 id_index = data[ word_index + 1 ];
    Id& id= ids[ id_index ];
    SpvDecoration decoration = ( SpvDecoration )data[
                                 word_index + 2 ];
    switch ( decoration )
    {
        case ( SpvDecorationBinding ):
        {
            id.binding = data[ word_index + 3 ];
            break;
        }
        case ( SpvDecorationDescriptorSet ):
        {
            id.set = data[ word_index + 3 ];
            break;
        }
    }
    break;
}

首先,我们检索 ID 的索引。如前所述,变量可以是前向声明的,我们可能需要多次更新相同 ID 的值。接下来,我们检索装饰的值。我们只对描述符集索引(SpvDecorationDescriptorSet)和绑定(SpvDecorationBinding)感兴趣,并将它们的值存储在这个 ID 的条目中。

我们接着用一个变量类型的例子来说明:

case ( SpvOpTypeVector ):
{
    u32 id_index = data[ word_index + 1 ];
    Id& id= ids[ id_index ];
    id.op = op;
    id.type_index = data[ word_index + 2 ];
    id.count = data[ word_index + 3 ];
    break;
}

正如我们在反汇编中看到的,一个向量由其条目类型和计数定义。我们将其存储在 ID 结构体的type_indexcount成员中。在这里,我们还可以看到如果需要,ID 可以引用另一个 ID。type_index成员存储对ids数组中另一个条目的索引,并且可以在以后用于检索额外的类型信息。

接下来,我们有一个样本定义:

case ( SpvOpTypeSampler ):
{
    u32 id_index = data[ word_index + 1 ];
    RASSERT( id_index < id_bound );
    Id& id= ids[ id_index ];
    id.op = op;
    break;
}

我们只需要存储这个条目的Op类型。最后,我们有变量类型的条目:

case ( SpvOpVariable ):
{
    u32 id_index = data[ word_index + 2 ];
    Id& id= ids[ id_index ];
    id.op = op;
    id.type_index = data[ word_index + 1 ];
    id.storage_class = ( SpvStorageClass )data[
                         word_index + 3 ];
    break;
}

这个条目的相关信息是type_index,它将始终引用一个pointer类型的条目和存储类。存储类告诉我们哪些是我们感兴趣的变量条目,哪些可以跳过。

这正是代码的下一部分所做的事情。一旦我们解析完所有 ID,我们就遍历每个 ID 条目并识别我们感兴趣的条目。我们首先识别所有变量:

for ( u32 id_index = 0; id_index < ids.size; ++id_index ) {
    Id& id= ids[ id_index ];
    if ( id.op == SpvOpVariable ) {

接下来,我们使用变量存储类来确定它是否是一个统一变量:

switch ( id.storage_class ) {
    case ( SpvStorageClassUniform ):
    case ( SpvStorageClassUniformConstant ):
    {

我们只对UniformUniformConstant变量感兴趣。然后我们检索uniform类型。记住,检索变量实际类型存在双重间接引用:首先,我们获取pointer类型,然后从pointer类型获取变量的实际类型。我们已突出显示执行此操作的代码:

Id& uniform_type = ids[ ids[ id.type_index ].type_index ];
DescriptorSetLayoutCreation& setLayout =
parse_result->sets[ id.set ];
setLayout.set_set_index( id.set );
DescriptorSetLayoutCreation::Binding binding{ };
binding.start = id.binding;
binding.count = 1;

在检索类型后,我们获取这个变量所属集合的DescriptorSetLayoutCreation条目。然后我们创建一个新的binding条目并存储binding值。我们总是假设每个资源有一个1的计数。

在这个最后步骤中,我们确定这个绑定的资源类型,并将其条目添加到集合布局中:

switch ( uniform_type.op ) {
    case (SpvOpTypeStruct):
    {
        binding.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
        binding.name = uniform_type.name.text;
        break;
    }
    case (SpvOpTypeSampledImage):
    {
        binding.type = VK_DESCRIPTOR_TYPE_COMBINED
        _IMAGE_SAMPLER;
        binding.name = id.name.text;
        break;
    }
}
setLayout.add_binding_at_index( binding, id.binding );

我们使用Op类型来确定我们找到的资源类型。到目前为止,我们只对统一缓冲区的Struct和纹理的SampledImage感兴趣。在本书的剩余部分,如果需要,我们将添加对更多类型的支持。

虽然可以在统一缓冲区和存储缓冲区之间进行区分,但二进制数据无法确定缓冲区是动态的还是静态的。在我们的实现中,应用程序代码需要指定这个细节。

另一个选择是使用命名约定(例如,在动态缓冲区前加上dyn_),以便可以自动识别动态缓冲区。

这就结束了我们对 SPIR-V 二进制格式的介绍。可能需要阅读几遍才能完全理解它是如何工作的,但不用担心,我们确实花了一些迭代时间才能完全理解它!

知道如何解析 SPIR-V 数据是自动化图形开发其他方面的重要工具。例如,它可以用来自动化生成 C++头文件,以保持 CPU 和 GPU 结构同步。我们鼓励你扩展我们的实现,以添加你可能需要的功能支持!

在本节中,我们解释了如何将着色器源编译成 SPIR-V。我们展示了 SPIR-V 二进制格式的组织方式以及如何解析这些数据以帮助我们自动创建管道布局。

在本章的下一节和最后一节中,我们将向我们的 GPU 设备实现中添加管道缓存。

使用管道缓存提高加载时间

每次我们创建一个图形管道,以及在较小程度上创建一个计算管道时,驱动程序都必须分析并编译我们提供的着色器。它还必须检查我们在创建结构中定义的状态,并将其转换为编程 GPU 不同单元的指令。这个过程相当昂贵,这也是为什么在 Vulkan 中我们必须提前定义大部分管道状态的原因之一。

在本节中,我们将向我们的 GPU 设备实现中添加管道缓存以提高加载时间。如果你的应用程序需要创建成千上万的管道,它可能会产生显著的启动时间,或者对于游戏来说,在关卡之间的加载时间可能会很长。

本节中描述的技术将有助于减少创建管道所需的时间。你首先会注意到GpuDevice::create_pipeline方法接受一个新可选参数,该参数定义了管道缓存文件的路径:

GpuDevice::create_pipeline( const PipelineCreation&
                            creation, const char*
                            cache_path )

然后我们需要定义VkPipelineCache结构:

VkPipelineCache pipeline_cache = VK_NULL_HANDLE;
VkPipelineCacheCreateInfo pipeline_cache_create_info {
    VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO };

下一步是检查管道缓存文件是否已经存在。如果存在,我们加载文件数据并将其添加到管道缓存创建中:

FileReadResult read_result = file_read_binary( cache_path,
                                               allocator );
pipeline_cache_create_info.initialDataSize =
  read_result.size;
pipeline_cache_create_info.pInitialData = read_result.data;

如果文件不存在,我们不需要对创建结构做任何进一步的修改。现在我们可以调用vkCreatePipelineCache

vkCreatePipelineCache( vulkan_device,
                       &pipeline_cache_create_info,
                       vulkan_allocation_callbacks,
                       &pipeline_cache );

这将返回一个指向VkPipelineCache对象的句柄,我们将在创建管道对象时使用它:

vkCreateGraphicsPipelines( vulkan_device, pipeline_cache,
                           1, &pipeline_info,
                           vulkan_allocation_callbacks,
                           &pipeline->vk_pipeline );

我们可以对计算管道做同样的操作:

vkCreateComputePipelines( vulkan_device, pipeline_cache, 1,
                          &pipeline_info,
                          vulkan_allocation_callbacks,
                          &pipeline->vk_pipeline );

如果我们已加载管道缓存文件,驱动程序将使用这些数据来加速管道创建。另一方面,如果我们是第一次创建给定的管道,我们现在可以查询并存储管道缓存数据以供以后重用:

sizet cache_data_size = 0;
vkGetPipelineCacheData( vulkan_device, pipeline_cache,
                        &cache_data_size, nullptr );
void* cache_data = allocator->allocate( cache_data_size, 64 );
vkGetPipelineCacheData( vulkan_device, pipeline_cache,
                        &cache_data_size, cache_data );
file_write_binary( cache_path, cache_data, cache_data_size );

我们首先使用nullptr调用vkGetPipelineCacheData来获取数据成员的缓存数据大小。然后,我们分配存储缓存数据的内存,并再次调用vkGetPipelineCacheData,这次使用一个指向将要存储缓存数据的内存的指针。最后,我们将这些数据写入在调用GpuDevice::create_pipeline时指定的文件中。

现在我们已经完成了管道缓存数据结构,可以销毁它:

vkDestroyPipelineCache( vulkan_device, pipeline_cache,
                        vulkan_allocation_callbacks );

在我们总结之前,我们想提到管道缓存的一个缺点。缓存中的数据由每个供应商的驱动程序实现控制。当发布新的驱动程序版本时,缓存的数据格式可能会改变,变得与之前存储在缓存文件中的数据不兼容。在这种情况下,拥有缓存文件可能不会带来任何好处,因为驱动程序无法使用它。

因此,每个驱动程序都必须在缓存数据前加上以下头部信息:

struct VkPipelineCacheHeaderVersionOne {
    uint32_t                       headerSize;
    VkPipelineCacheHeaderVersion   headerVersion;
    uint32_t                       vendorID;
    uint32_t                       deviceID;
    uint8_t                        pipeline
                                   CacheUUID[VK_UUID_SIZE];
}

当我们从磁盘加载缓存数据时,我们可以将头部中的值与驱动程序和 GPU 返回的值进行比较:

VkPipelineCacheHeaderVersionOne* cache_header =
    (VkPipelineCacheHeaderVersionOne*)read_result.data;
if ( cache_header->deviceID == vulkan_physical
     _properties.deviceID && cache_header->vendorID ==
     vulkan_physical_properties.vendorID &&
     memcmp( cache_header->pipelineCacheUUID,
     vulkan_physical_properties.pipelineCacheUUID,
     VK_UUID_SIZE ) == 0 ) {
    pipeline_cache_create_info.initialDataSize =
    read_result.size;
    pipeline_cache_create_info.pInitialData =
    read_result.data;
}
else
{
    cache_exists = false;
}

如果头部的值与我们正在运行的设备上的值匹配,我们就像以前一样使用缓存数据。如果不匹配,我们将像缓存不存在一样操作,并在管道创建后存储一个新的版本。

在本节中,我们展示了如何利用管道缓存来在运行时加快管道创建速度。我们强调了我们对 GPU 设备实现所做的更改,以利用此功能,以及它在本章代码中的应用。

摘要

在本章中,我们改进了我们的 GPU 设备实现,使其更容易管理大量使用无绑定资源的纹理。我们解释了需要哪些扩展,并详细说明了在创建描述符集布局以允许使用无绑定资源时需要哪些更改。然后,我们展示了在创建描述符集以更新正在使用的纹理数组时所需的更改。

然后,我们通过解析glslang编译器为我们着色器生成的 SPIR-V 二进制文件,添加了自动管道布局生成。我们提供了 SPIR-V 二进制数据格式的概述,并解释了如何解析它以提取绑定到着色器的资源,以及如何使用这些信息来创建管道布局。

最后,我们通过添加管道缓存来增强我们的管道创建 API,以改善应用程序首次运行后的加载时间。我们介绍了生成或加载管道缓存数据所需的 Vulkan API。我们还解释了管道缓存的局限性以及如何处理它们。

本章中提出的所有技术都有一个共同的目标,那就是使处理大型项目更容易,并在修改我们的着色器或材质时将手动代码更改减少到最低。

我们将在下一章通过添加多线程来记录多个命令缓冲区或并行提交多个工作负载到 GPU,继续扩展我们的引擎。

进一步阅读

我们只涵盖了 SPIR-V 规范的一小部分。如果您想根据您的需求扩展我们的解析器实现,我们强烈建议您查阅官方规范:www.khronos.org/registry/SPIR-V/specs/unified1/SPIRV.xhtml

我们为这一章节编写了一个定制的 SPIR-V 解析器,主要是为了教育目的。对于您自己的项目,我们建议使用 Khronos 提供的现有反射库:github.com/KhronosGroup/SPIRV-Reflect

它提供了本章所述的功能,用于推断着色器二进制的管道布局以及许多其他特性。

第三章:解锁多线程

在本章中,我们将讨论如何将多线程添加到 Raptor 引擎中。

这需要底层架构的巨大变化以及一些 Vulkan 特定的更改和同步工作,以便 CPU 和 GPU 的不同核心能够以最正确和最快的方式合作。

多线程渲染是一个多年来多次讨论的话题,也是自从多核架构时代爆发以来大多数游戏引擎都需要的功能。PlayStation 2 和 Sega Saturn 等游戏机已经提供了多线程支持,而后续的世代继续这一趋势,通过提供越来越多的核心供开发者利用。

游戏引擎中多线程渲染的首次痕迹可以追溯到 2008 年,当时克里斯蒂尔·埃里克森撰写了一篇博客文章(realtimecollisiondetection.net/blog/?p=86),并展示了并行化和优化用于在屏幕上渲染对象的命令生成的可能性。

较旧的 API,如 OpenGL 和 DirectX(直到版本 11),没有适当的并行多线程支持,尤其是因为它们是大型状态机,具有全局上下文,跟踪每个命令后的每个更改。尽管如此,不同对象之间的命令生成可能需要几毫秒,因此多线程在性能上已经是一个很大的提升。

幸运的是,Vulkan 原生支持多线程命令缓冲区,尤其是在VkCommandBuffer类的创建后,从 Vulkan API 的架构角度来看。

一直到如今,Raptor 引擎都是一个单线程应用程序,因此需要一些架构上的更改以完全支持多线程。在本章中,我们将看到这些更改,学习如何使用名为 enkiTS 的基于任务的并行多线程库,然后解锁异步资源加载和多线程命令记录。

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

  • 如何使用基于任务的并行多线程库

  • 如何异步加载资源

  • 如何在并行线程中绘图

到本章结束时,我们将知道如何同时运行加载资源和在屏幕上绘制对象的并发任务。通过学习如何与基于任务的并行多线程系统进行推理,我们将在未来的章节中也能够执行其他并行任务。

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter3.

使用 enkiTS 进行基于任务的并行多线程

为了实现并行处理,我们需要了解一些基本概念和选择,这些概念和选择导致了本章中开发的架构。首先,我们应该注意,当我们谈论软件工程中的并行处理时,我们指的是同时执行代码块的行为。

这是因为现代硬件有不同的可以独立操作的单元,操作系统有专门的执行单元称为线程

实现并行的一种常见方式是通过任务进行推理——这些是小型的独立执行单元,可以在任何线程上运行。

为什么需要基于任务的并行处理?

多线程不是一个新主题,自从它在游戏引擎的早期年份被添加以来,就有不同的实现方式。游戏引擎是那些以最有效的方式使用所有可用硬件的软件组件,从而为更优化的软件架构铺平了道路。

因此,我们将从游戏引擎和与游戏相关的演示中汲取一些想法。最初的实现是通过添加一个执行单一任务的线程开始的——比如渲染单个线程,异步的输入/输出I/O)线程等。

这有助于增加并行执行可以做的事情的粒度,对于只有两个核心的旧 CPU 来说,这是完美的,但很快它就变得有限了。

有必要以更无关的方式使用核心,以便几乎任何核心都可以完成任何类型的工作,并提高性能。这导致了两种新架构的出现:基于任务基于纤维的架构。

基于任务的并行处理是通过为多个线程提供不同的任务并通过依赖关系来协调它们来实现的。任务本质上是平台无关的,并且不能被中断,这导致了对调度和组织与它们一起执行的代码的更直接的能力。

另一方面,纤维是类似于任务的软件结构,但它们严重依赖于调度器来中断它们的流程并在需要时恢复。这种主要区别使得编写合适的纤维系统变得困难,通常会导致许多微妙的错误。

由于使用任务而不是纤维的简单性以及实现基于任务并行处理的库的更大可用性,选择了 enkiTS 库来处理所有事情。对于那些对更深入的解释感兴趣的人,有一些关于这些架构的非常好的演示。

任务驱动引擎的一个很好的例子是《命运》系列游戏背后的引擎(你可以在www.gdcvault.com/play/1021926/Destiny-s-Multithreaded-Rendering查看其深入架构),而基于纤程的引擎则被游戏工作室 Naughty Dog 用于他们的游戏(有关它的介绍可以在www.gdcvault.com/play/1022186/Parallelizing-the-Naughty-Dog-Engine找到)。

使用 enkiTS(任务调度器)库

基于任务的线程多线程是基于任务的概念,定义为“可以在 CPU 的任何核心上执行的独立工作单元”。

为了做到这一点,需要一个调度器来协调不同的任务并处理它们之间可能存在的依赖关系。任务的另一个有趣方面是,它可能有一个或多个依赖项,这样它就只能在某些任务执行完毕后才能调度运行。

这意味着任务可以在任何时候提交给调度器,并且通过适当的依赖关系,我们创建了一个基于图的引擎执行。如果做得正确,每个核心都可以充分利用,从而实现引擎的最佳性能。

调度器是所有任务背后的大脑:它检查依赖和优先级,根据需要调度或删除任务,并且它是添加到 Raptor 引擎中的新系统。

当初始化调度器时,库会生成一定数量的线程,每个线程都在等待执行一个任务。当向调度器添加任务时,它们会被插入到一个队列中。当调度器被告知执行挂起任务时,每个线程从队列中获取下一个可用的任务——根据依赖和优先级——并执行它。

需要注意的是,运行任务可以生成其他任务。这些任务将被添加到线程的本地队列中,但如果另一个线程空闲,它们也可以被抢占。这种实现被称为工作窃取队列

初始化调度器就像创建一个配置并调用Initialize方法一样简单:

enki::TaskSchedulerConfig config;
config.numTaskThreadsToCreate = 4;
enki::TaskScheduler task_scheduler;
task_scheduler.Initialize( config );

使用此代码,我们告诉任务调度器生成四个线程,它将使用这些线程来执行其任务。enkiTS 使用TaskSet类作为工作单元,并且它使用继承和 lambda 函数来驱动调度器中任务的执行:

Struct ParallelTaskSet : enki::ItaskSet {
    void ExecuteRange(  enki::TaskSetPartition range_,
                        uint32_t threadnum_ ) override {
        // do something here, can issue tasks with
           task_scheduler
    }
};
int main(int argc, const char * argv[]) {
    enki::TaskScheduler task_scheduler;
    task_scheduler.Initialize( config );
    ParallelTaskSet task; // default constructor has a set
                             size of 1
    task_scheduler.AddTaskSetToPipe( &task );
    // wait for task set (running tasks if they exist)
    // since we've just added it and it has no range we'll
       likely run it.
    Task_scheduler.WaitforTask( &task );
    return 0;
}

在这个简单的代码片段中,我们看到如何创建一个空的TaskSet(正如其名所暗示的,一组任务),它定义了任务将如何执行代码,而将决定需要多少个任务以及哪个线程将被使用的任务留给了调度器。

之前代码的一个更简洁的版本使用了 lambda 函数:

enki::TaskSet task( 1, []( enki::TaskSetPartition range_,
  uint32_t threadnum_  ) {
         // do something here
  }  );
task_scheduler.AddTaskSetToPipe( &task );

这个版本在阅读代码时可能更容易理解,因为它不会打断代码流,但它在功能上与上一个版本等效。

enkiTS 调度器的另一个特点是能够添加固定任务——这些特殊任务将被绑定到线程,并始终在那里执行。我们将在下一节中看到固定任务的使用,以执行异步 I/O 操作。

在本节中,我们简要介绍了不同类型的多线程,以便我们能够表达选择使用基于任务的线程的原因。然后我们展示了 enkiTS 库的一些简单示例及其用法,为 Raptor 引擎添加了多线程功能。

在下一节中,我们将最终看到在引擎中的真实用例,即资源的异步加载。

异步加载

资源的加载是任何框架中可以执行的最慢的操作之一(如果不是最慢的)。这是因为要加载的文件很大,它们可以来自不同的来源,例如光盘单元(DVD 和蓝光),硬盘,甚至网络。

这是一个很好的话题,但最重要的概念是要理解读取内存的内在速度:

图 3.1 – 内存层次结构

图 3.1 – 内存层次结构

如前图所示,最快的内存是寄存器内存。在寄存器之后是缓存,具有不同的级别和访问速度:寄存器和缓存都直接在处理单元中(CPU 和 GPU 都有寄存器和缓存,尽管底层架构不同)。

主存储器指的是 RAM,这是通常填充应用程序使用的数据的区域。它的速度比缓存慢,但它是加载操作的目标,因为它是唯一可以从代码直接访问的。然后是磁盘(硬盘)和光盘驱动器——速度更慢,但容量更大。它们通常包含将要加载到主存储器中的资产数据。

最后的内存是在远程存储中,例如来自某些服务器,它是最慢的。我们在这里不会处理它,但可以在处理具有某种形式在线服务应用程序时使用,例如多人游戏。

为了优化应用程序中的读取访问,我们希望将所有需要的数据传输到主存储器,因为我们不能与缓存和寄存器交互。为了隐藏磁性和光盘驱动器的慢速,可以做的事情之一是将任何来自任何介质的资源的加载并行化,这样就不会减慢应用程序的流畅性。

做这件事最常见的方式,也是我们之前简要提到的线程专用架构的一个例子,是有一个单独的线程来处理资源的加载,并与其他系统交互以更新引擎中使用的资源。

在接下来的章节中,我们将讨论如何设置 enkiTS 并创建用于并行化 Raptor 引擎的任务,以及讨论 Vulkan 队列,这对于并行命令提交是必要的。最后,我们将深入探讨用于异步加载的实际代码。

创建 I/O 线程和任务

在 enkiTS 库中,有一个名为固定任务的功能,它将一个任务与一个特定的线程关联,以便它在那里持续运行,除非用户停止它或在该线程上调度了更高优先级的任务。

为了简化问题,我们将添加一个新线程并避免它被应用程序使用。这个线程将大部分时间处于空闲状态,因此上下文切换应该很低:

config.numTaskThreadsToCreate = 4;

然后,我们创建一个固定任务并将其与一个线程 ID 关联:

// Create IO threads at the end
RunPinnedTaskLoopTask run_pinned_task;
run_pinned_task.threadNum = task_scheduler.
                            GetNumTaskThreads() - 1;
task_scheduler.AddPinnedTask( &run_pinned_task );

在这一点上,我们可以创建实际负责异步加载的任务,将其与固定任务相同的线程关联:

// Send async load task to external thread
AsynchronousLoadTask async_load_task;
async_load_task.threadNum = run_pinned_task.threadNum;
task_scheduler.AddPinnedTask( &async_load_task );

最后一个拼图是这两个任务的实际代码。首先,让我们看看第一个固定任务:

struct RunPinnedTaskLoopTask : enki::IPinnedTask {
    void Execute() override {
        while ( task_scheduler->GetIsRunning() && execute )
         {
            task_scheduler->WaitForNewPinnedTasks();
            // this thread will 'sleep' until there are new
               pinned tasks
            task_scheduler->RunPinnedTasks();
        }
    }
    enki::TaskScheduler*task_scheduler;
    bool execute = true;
}; // struct RunPinnedTaskLoopTask

这个任务将等待任何其他固定任务,并在可能的情况下运行它们。我们已经添加了一个execute标志,以便在需要时停止执行,例如,当退出应用程序时,但它也可以在其他情况下(例如,当应用程序最小化时)用于暂停它。

另一个任务是使用AsynchronousLoader类执行异步加载:

struct AsynchronousLoadTask : enki::IPinnedTask {
    void Execute() override {
        while ( execute ) {
            async_loader->update();
        }
    }
    AsynchronousLoader*async_loader;
    enki::TaskScheduler*task_scheduler;
    bool execute = true;
}; // struct AsynchronousLoadTask

这个任务背后的想法是始终保持活跃状态并等待资源加载的请求。while循环确保根固定任务永远不会在这个线程上调度其他任务,从而将其锁定到 I/O,达到预期效果。

在查看AsynchronousLoader类之前,我们需要查看 Vulkan 中的一个重要概念,即队列,以及为什么它们对于异步加载是一个很好的补充。

Vulkan 队列和首次并行命令生成

队列的概念——可以定义为将记录在VkCommandBuffers中的命令提交到 GPU 的入口点——是 Vulkan 相对于 OpenGL 的一个新增功能,需要特别注意。

使用队列的提交是一个单线程操作,这是一个代价高昂的操作,成为 CPU 和 GPU 之间同步的一个点。通常,有一个主队列,在呈现帧之前,引擎将命令缓冲区提交到该队列。这将把工作发送到 GPU 并创建预期的渲染图像。

但是,有一个队列,可以有更多。为了增强并行执行,我们可以创建不同的队列——并在不同的线程中使用它们而不是主线程。

可以在github.com/KhronosGroup/Vulkan-Guide/blob/master/chapters/queues.adoc找到对队列的更深入探讨,但我们需要知道的是,每个队列可以提交某些类型的命令,这些命令可以通过队列的标志来查看:

  • VK_QUEUE_GRAPHICS_BIT可以提交所有vkCmdDraw命令

  • VK_QUEUE_COMPUTE 可以提交所有 vkCmdDispatchvkCmdTraceRays(用于光线追踪)

  • VK_QUEUE_TRANSFER 可以提交复制命令,例如 vkCmdCopyBuffervkCmdCopyBufferToImagevkCmdCopyImageToBuffer

每个可用的队列都通过队列家族公开。每个队列家族可以有多个功能,并可以公开多个队列。以下是一个澄清的例子:

{
    "VkQueueFamilyProperties": {
        "queueFlags": [
            "VK_QUEUE_GRAPHICS_BIT",
            "VK_QUEUE_COMPUTE_BIT",
            "VK_QUEUE_TRANSFER_BIT",
            "VK_QUEUE_SPARSE_BINDING_BIT"
        ],
        "queueCount": 1,
    }
},
{
    "VkQueueFamilyProperties": {
        "queueFlags": [
            "VK_QUEUE_COMPUTE_BIT",
            "VK_QUEUE_TRANSFER_BIT",
            "VK_QUEUE_SPARSE_BINDING_BIT"
        ],
        "queueCount": 2,
    }
},
{
    "VkQueueFamilyProperties": {
        "queueFlags": [
            "VK_QUEUE_TRANSFER_BIT",
            "VK_QUEUE_SPARSE_BINDING_BIT"
        ],
        "queueCount": 2,
    }
}

第一个队列公开所有功能,而我们只有一个。下一个队列可用于计算和传输,第三个队列用于传输(我们现在将忽略稀疏功能)。我们为这些家族中的每个家族都有两个队列。

保证在 GPU 上始终至少有一个队列可以提交所有类型的命令,并且那将是我们的主队列。

然而,在某些 GPU 中,可能有专门的队列,它们只激活了 VK_QUEUE_TRANSFER 标志,这意味着它们可以使用 直接内存访问DMA)来加速 CPU 和 GPU 之间数据传输的速度。

最后一点:Vulkan 逻辑设备负责创建和销毁队列——这是一个通常在应用程序启动/关闭时进行的操作。让我们简要地看看查询不同队列支持的代码:

u32 queue_family_count = 0;
    vkGetPhysicalDeviceQueueFamilyProperties(
    vulkan_physical_device, &queue_family_count, nullptr );
    VkQueueFamilyProperties*queue_families = (
        VkQueueFamilyProperties* )ralloca( sizeof(
            VkQueueFamilyProperties ) * queue_family_count,
                temp_allocator );
        vkGetPhysicalDeviceQueueFamilyProperties(
            vulkan_physical_device, &queue_family_count,
                queue_families );
    u32 main_queue_index = u32_max, transfer_queue_index =
    u32_max;
    for ( u32 fi = 0; fi < queue_family_count; ++fi) {
        VkQueueFamilyProperties queue_family =
            queue_families[ fi ];
        if ( queue_family.queueCount == 0 ) {
            continue;
        }
        // Search for main queue that should be able to do
           all work (graphics, compute and transfer)
        if ( (queue_family.queueFlags & (
              VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_COMPUTE_BIT |
              VK_QUEUE_TRANSFER_BIT )) == (
              VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_COMPUTE_BIT |
              VK_QUEUE_TRANSFER_BIT ) ) {
                 main_queue_index = fi;
        }
        // Search for transfer queue
        if ( ( queue_family.queueFlags &
               VK_QUEUE_COMPUTE_BIT ) == 0 &&
               (queue_family.queueFlags &
               VK_QUEUE_TRANSFER_BIT) ) {
            transfer_queue_index = fi;
        }
    }

如前述代码所示,我们获取了所选 GPU 的所有队列列表,并检查了标识可以在此处执行命令类型的不同位。

在我们的情况下,我们将保存 主队列传输队列(如果 GPU 上有),并将保存 队列 的索引以在设备创建后检索 VkQueue。某些设备不公开单独的传输队列。在这种情况下,我们将使用主队列来执行传输操作,并确保队列的访问在上传和图形提交时正确同步。

让我们看看如何创建 队列

// Queue creation
VkDeviceQueueCreateInfo queue_info[ 2 ] = {};
VkDeviceQueueCreateInfo& main_queue = queue_info[ 0 ];
main_queue.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE
                   _CREATE_INFO;
main_queue.queueFamilyIndex = main_queue_index;
main_queue.queueCount = 1;
main_queue.pQueuePriorities = queue_priority;
if ( vulkan_transfer_queue_family < queue_family_count ) {
    VkDeviceQueueCreateInfo& transfer_queue_info =
        queue_info[ 1 ];
    transfer_queue_info.sType = VK_STRUCTURE_TYPE
                                _DEVICE_QUEUE_CREATE_INFO;
    transfer_queue_info.queueFamilyIndex = transfer_queue
                                           _index;
transfer_queue_info.queueCount = 1;
transfer_queue_info.pQueuePriorities = queue_priority;
}
VkDeviceCreateInfo device_create_info {
    VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO };
device_create_info.queueCreateInfoCount = vulkan_transfer
    _queue_family < queue_family_count ? 2 : 1;
device_create_info.pQueueCreateInfos = queue_info;
...
result = vkCreateDevice( vulkan_physical_device,
                         &device_create_info,
                         vulkan_allocation_callbacks,
                         &vulkan_device );

如前所述,vkCreateDevice 是通过在 VkDeviceCreateInfo 结构中添加 pQueueCreateInfos 来创建 队列 的命令。

设备创建后,我们可以查询所有队列,如下所示:

// Queue retrieval
// Get main queue
vkGetDeviceQueue( vulkan_device, main_queue_index, 0,
                  &vulkan_main_queue );
// Get transfer queue if present
if ( vulkan_transfer_queue_family < queue_family_count ) {
    vkGetDeviceQueue( vulkan_device, transfer_queue_index,
                      0, &vulkan_transfer_queue );
}

到目前为止,我们已经准备好了主队列和传输队列,可以用来并行提交工作。

我们已经了解了如何提交并行工作以在 GPU 上复制内存,而不阻塞 GPU 或 CPU,并创建了一个特定的类来完成这项工作,AsynchronousLoader,我们将在下一节中介绍。

异步加载器类

在这里,我们将最终看到实现异步加载的类的代码。

AsynchronousLoader 类有以下职责:

  • 处理来自文件的请求负载

  • 处理 GPU 上传传输

  • 管理一个阶段缓冲区以处理数据的复制

  • 将带有复制命令的命令缓冲区入队

  • 向渲染器发出信号,表示纹理已完成传输

在专注于上传数据到 GPU 的代码之前,有一些与命令池、传输队列和使用暂存缓冲区相关的特定于 Vulkan 的代码是重要的,需要理解。

为传输队列创建命令池

为了将命令提交到传输队列,我们需要创建与该队列链接的命令池:

for ( u32 i = 0; i < GpuDevice::k_max_frames; ++i) {
VkCommandPoolCreateInfo cmd_pool_info = {
    VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, nullptr };
cmd_pool_info.queueFamilyIndex = gpu->vulkan
                                 _transfer_queue_family;
cmd_pool_info.flags = VK_COMMAND_POOL_CREATE_RESET
                      _COMMAND_BUFFER_BIT;
vkCreateCommandPool( gpu->vulkan_device, &cmd_pool_info,
                     gpu->vulkan_allocation_callbacks,
                     &command_pools[i]);
}

重要的是queueFamilyIndex,将CommandPool链接到传输队列,以便从这个池分配的每个命令缓冲区都可以正确提交到传输队列。

接下来,我们将简单地分配与新创建的池链接的命令缓冲区:

for ( u32 i = 0; i < GpuDevice::k_max_frames; ++i) {
    VkCommandBufferAllocateInfo cmd = {
        VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
            nullptr };
       cmd.commandPool = command_pools[i];
cmd.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cmd.commandBufferCount = 1;
vkAllocateCommandBuffers( renderer->gpu->vulkan_device,
                          &cmd, &command_buffers[i].
                          vk_command_buffer );

使用这种设置,我们现在可以使用命令缓冲区提交命令到传输队列。

接下来,我们将查看暂存缓冲区——这是一个附加功能,以确保从 CPU 到 GPU 的传输尽可能快。

创建暂存缓冲区

为了在 CPU 和 GPU 之间最优地传输数据,需要创建一个可以用于发出与复制数据到 GPU 相关的命令的内存区域。

为了实现这一点,我们将创建一个暂存缓冲区,一个将为此目的服务的持久缓冲区。我们将看到用于创建持久暂存缓冲区的 Raptor 包装器和特定于 Vulkan 的代码。

在以下代码中,我们将分配一个 64MB 的持久映射缓冲区:

BufferCreation bc;
bc.reset().set( VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
                ResourceUsageType::Stream, rmega( 64 )
                ).set_name( "staging_buffer" ).
                set_persistent( true );
BufferHandle staging_buffer_handle = gpu->create_buffer
                                     ( bc );

这对应于以下代码:

VkBufferCreateInfo buffer_info{
    VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
buffer_info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
buffer_info.size = 64 * 1024 * 1024; // 64 MB
VmaAllocationCreateInfo allocation_create_info{};
allocation_create_info.flags = VMA_ALLOCATION_CREATE
_STRATEGY_BEST_FIT_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo allocation_info{};
check( vmaCreateBuffer( vma_allocator, &buffer_info,
       &allocation_create_info, &buffer->vk_buffer,
       &buffer->vma_allocation, &allocation_info ) );

这个缓冲区将是内存传输的来源,VMA_ALLOCATION_CREATE_MAPPED_BIT标志确保它始终被映射。

我们可以从allocation_info结构中检索并使用分配数据的指针,该结构由vmaCreateBuffer填充:

buffer->mapped_data = static_cast<u8*>(allocation_info.
                                       pMappedData);

我们现在可以使用暂存缓冲区进行任何操作,将数据发送到 GPU,如果需要更大的分配,我们可以创建一个新的更大尺寸的暂存缓冲区。

接下来,我们需要查看创建用于提交和同步 CPU 和 GPU 命令执行的信号量和栅栏的代码。

为 GPU 同步创建信号量和栅栏

这里的代码很简单;唯一重要的一部分是创建一个已标记的栅栏,因为它将允许代码开始处理上传:

VkSemaphoreCreateInfo semaphore_info{
    VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO };
vkCreateSemaphore( gpu->vulkan_device, &semaphore_info,
                   gpu->vulkan_allocation_callbacks,
                   &transfer_complete_semaphore );
VkFenceCreateInfo fence_info{
    VK_STRUCTURE_TYPE_FENCE_CREATE_INFO };
fence_info.flags = VK_FENCE_CREATE_SIGNALED_BIT;
vkCreateFence( gpu->vulkan_device, &fence_info,
               gpu->vulkan_allocation_callbacks,
               &transfer_fence );

最后,我们现在已经到达了处理请求的阶段。

处理文件请求

文件请求不是特定于 Vulkan 的,但了解它们是如何完成的是有用的。

我们使用 STB 图像库(github.com/nothings/stb)将纹理加载到内存中,然后简单地将加载的内存和相关的纹理添加到创建上传请求。这将负责使用传输队列将数据从内存复制到 GPU:

FileLoadRequest load_request = file_load_requests.back();
// Process request
int x, y, comp;
u8* texture_data = stbi_load( load_request.path, &x, &y,
                              &comp, 4 );
// Signal the loader that an upload data is ready to be
   transferred to the GPU
UploadRequest& upload_request = upload_requests.push_use();
upload_request.data = texture_data;
upload_request.texture = load_request.texture;

接下来,我们将看到如何处理上传请求。

处理上传请求

这是最终将数据上传到 GPU 的部分。首先,我们需要确保栅栏被标记为已信号,这就是为什么我们事先已经标记了它。

如果它已被信号,我们可以将其重置,以便在提交完成后让 API 对其进行信号:

// Wait for transfer fence to be finished
if ( vkGetFenceStatus( gpu->vulkan_device, transfer_fence )
     != VK_SUCCESS ) {
return;
}
// Reset if file requests are present.
vkResetFences( gpu->vulkan_device, 1, &transfer_fence );

然后我们继续接收请求,从预演缓冲区分配内存,并使用命令缓冲区上传 GPU:

// Get last request
UploadRequest request = upload_requests.back();
const sizet aligned_image_size = memory_align(
                                 texture->width *
                                 texture->height *
                                 k_texture_channels,
                                 k_texture_alignment );
// Request place in buffer
const sizet current_offset = staging_buffer_offset +
                             aligned_image_size;
CommandBuffer* cb = &command_buffers[ gpu->current_frame ;
cb->begin();
cb->upload_texture_data( texture->handle, request.data,
                         staging_buffer->handle,
                         current_offset );
free( request.data );
cb->end();

upload_texture_data方法负责上传数据和添加所需的屏障。这可能很棘手,所以我们包括了代码以展示如何完成。

首先,我们需要将数据复制到预演缓冲区:

// Copy buffer_data to staging buffer
memcpy( staging_buffer->mapped_data +
        staging_buffer_offset, texture_data,
        static_cast< size_t >( image_size ) );

然后,我们可以准备一个复制,在这种情况下,从预演缓冲区到图像。在这里,指定预演缓冲区中的偏移量很重要:

VkBufferImageCopy region = {};
region.bufferOffset = staging_buffer_offset;
region.bufferRowLength = 0;
region.bufferImageHeight = 0;

然后我们继续添加一个预复制内存屏障以执行布局转换,并指定数据正在使用传输队列。

这使用了 Khronos Group 提供的同步示例中建议的代码(github.com/KhronosGroup/Vulkan-Docs/wiki/Synchronization-Examples)。

再次展示经过一些实用函数简化的原始 Vulkan 代码,突出显示重要的行:

// Pre copy memory barrier to perform layout transition
VkImageMemoryBarrier preCopyMemoryBarrier;
...
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = image,
.subresourceRange = ... };
...

纹理现在已准备好复制到 GPU:

// Copy from the staging buffer to the image
vkCmdCopyBufferToImage( vk_command_buffer,
                        staging_buffer->vk_buffer,
                        texture->vk_image,
                        VK_IMAGE_LAYOUT_TRANSFER_DST
                        _OPTIMAL, 1, &region );

纹理现在已在 GPU 上,但它仍然不能从主队列中使用。

这就是为什么我们需要另一个内存屏障,它也将转移所有权:

// Post copy memory barrier
VkImageMemoryBarrier postCopyTransferMemoryBarrier = {
...
.srcAccessMask = VK_ACCESS_TRANFER_WRITE_BIT,
.dstAccessMask = 0,
.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
.srcQueueFamilyIndex = transferQueueFamilyIndex,
.dstQueueFamilyIndex = graphicsQueueFamilyIndex,
.image = image,
.subresourceRange = ... };

一旦所有权转移,需要一个最终的屏障来确保传输完成,并且纹理可以从着色器中读取,但这将由渲染器完成,因为它需要使用主队列。

通知渲染器传输完成

信号是通过简单地将纹理添加到要更新的互斥纹理列表中实现的,以确保线程安全。

在这一点上,我们需要为每个传输的纹理执行一个最终的屏障。我们选择在所有渲染完成后和当前步骤之前添加这些屏障,但也可以在帧的开始时进行。

如前所述,需要一个最后的屏障来指示新更新的图像已准备好由着色器读取,并且所有写入操作都已完成:

VkImageMemoryBarrier postCopyGraphicsMemoryBarrier = {
...
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
.srcQueueFamilyIndex = transferQueueFamilyIndex,
.dstQueueFamilyIndex = graphicsQueueFamilyIndex,
.image = image,
.subresourceRange = ... };

现在,我们已准备好在着色器中使用 GPU 上的纹理,异步加载正在工作。为上传缓冲区创建了一条非常相似的路径,但将在书中省略,但在代码中存在。

在本节中,我们展示了如何通过使用传输队列和不同的命令缓冲区来解锁资源到 GPU 的异步加载。我们还展示了如何管理队列之间的所有权转移。然后,我们最终看到了使用任务调度器设置任务的初步步骤,该调度器用于向 Raptor 引擎添加多线程功能。

在下一节中,我们将使用所获得的知识来添加命令的并行记录,以在屏幕上绘制对象。

在多个线程上记录命令

要使用多个线程记录命令,必须使用不同的命令缓冲区,至少每个线程一个,以记录命令然后将它们提交到主队列。更精确地说,在 Vulkan 中,任何类型的池都需要由用户外部同步;因此,最佳选项是将线程与池关联起来。

在命令缓冲区的情况下,它们是从关联的池中分配的,并在其中注册了命令。池可以是CommandPoolsDescriptorSetPoolsQueryPools(用于时间和遮挡查询),一旦与线程关联,就可以在执行线程内部自由使用。

命令缓冲区的执行顺序基于提交到主队列的数组的顺序——因此,从 Vulkan 的角度来看,可以在命令缓冲区级别进行排序。

我们将看到命令缓冲区的分配策略有多么重要,以及一旦分配到位,并行绘制是多么容易。我们还将讨论不同类型的命令缓冲区,这是 Vulkan 的独特特性。

分配策略

并行记录命令的成功是通过考虑线程访问和帧访问来实现的。在创建命令池时,不仅每个线程需要一个唯一的池来分配命令缓冲区和命令,而且它还需要不在 GPU 上飞行。

一种简单的分配策略是决定将记录命令的最大线程数(我们将它们称为T)和可以飞行的最大帧数(我们将它们称为F),然后分配F * T的命令池。

对于每个想要渲染的任务,使用帧-线程 ID 对,我们将保证没有任何池会处于飞行状态或被另一个线程使用。

这是一个非常保守的方法,可能导致命令生成不平衡,但它可以是一个很好的起点,在我们的情况下,足以提供对 Raptor 引擎并行渲染的支持。

此外,我们将分配最多五个空命令缓冲区,两个主缓冲区和三个次级缓冲区,以便更多任务可以并行执行渲染的片段。

负责此功能的类是CommandBufferManager类,可以从设备访问,并且它通过get_command_buffer方法给用户提供了请求命令缓冲区的可能性。

在下一节中,我们将看到主命令缓冲区和次级命令缓冲区之间的区别,这对于决定并行绘制帧的任务粒度是必要的。

命令缓冲区回收

与分配策略相关的是缓冲区的回收。当一个缓冲区已被执行后,它可以被重新用于记录新的命令,而不是总是分配新的缓冲区。

多亏了我们选择的分配策略,我们将固定数量的CommandPools与每个帧关联起来,因此为了重用命令缓冲区,我们将重置其对应的CommandPool而不是手动释放缓冲区:这在 CPU 时间上已被证明效率更高。

注意,我们并没有释放与缓冲区关联的内存,而是给CommandPool自由使用在将被记录的命令缓冲区之间分配的总内存,并且它会将其所有命令缓冲区的所有状态重置到初始状态。

在每一帧的开始,我们调用一个简单的方法来重置池:

void CommandBufferManager::reset_pools( u32 frame_index ) {
    for ( u32 i = 0; i < num_pools_per_frame; i++ ) {
        const u32 pool_index = pool_from_indices(
                               frame_index, i );
        vkResetCommandPool( gpu->vulkan_device,
                            vulkan_command_pools[
                            pool_index ], 0 );
    }
}

基于线程和帧,有一个计算池索引的实用方法。

在重置池之后,我们可以重用命令缓冲区来记录命令,而无需为每个命令显式地这样做。

我们最终可以查看不同类型的命令缓冲区。

主命令缓冲区与辅助命令缓冲区的比较

Vulkan API 在命令缓冲区可以做什么方面有一个独特差异:命令缓冲区可以是主缓冲区或辅助缓冲区。

主命令缓冲区是最常用的,可以执行任何命令——绘图、计算或复制命令,但它们的粒度相当粗糙——至少必须使用一个渲染通道,并且没有通道可以进一步并行化。

辅助命令缓冲区功能更加有限——它们实际上只能在渲染通道内执行绘图命令——但它们可以用来并行化包含许多绘图调用(如 G-Buffer 渲染通道)的渲染通道的渲染。

因此,做出关于任务粒度的明智决策至关重要,特别是理解何时使用主缓冲区或辅助缓冲区进行记录尤为重要。

第四章 实现帧图中,我们将看到帧图如何提供足够的信息来决定使用哪种类型的命令缓冲区以及在一个任务中应该使用多少对象和渲染通道。

在下一节中,我们将看到如何使用主命令缓冲区和辅助命令缓冲区。

使用主命令缓冲区进行绘图

使用主命令缓冲区进行绘图是使用 Vulkan 最常见的方式,也是最简单的方式。正如之前所述,主命令缓冲区可以无限制地执行任何类型的命令,并且是唯一可以提交到队列以在 GPU 上执行的一个。

创建一个主命令缓冲区只需在传递给vkAllocateCommandBuffers函数的VkCommandBufferAllocateInfo结构中使用VK_COMMAND_BUFFER_LEVEL_PRIMARY即可。

一旦创建,在任何时候,我们都可以开始命令记录(使用vkBeginCommandBuffer函数),绑定通道和管线,并发出绘图命令、复制命令和计算命令。

一旦记录完成,必须使用vkEndCommandBuffer函数来表示记录结束并准备缓冲区以便提交到队列:

VkSubmitInfo submit_info = {
    VK_STRUCTURE_TYPE_SUBMIT_INFO };
submit_info.commandBufferCount = num_queued_command
                                 _buffers;
submit_info.pCommandBuffers = enqueued_command_buffers;
...
vkQueueSubmit( vulkan_main_queue, 1, &submit_info,
               *render_complete_fence );

为了并行记录命令,记录线程必须遵守以下两个条件:

  • 在同一CommandPool上同时记录是禁止的

  • RenderPass相关的命令只能在单个线程中执行

如果一个传递(例如,典型的前向或 G-Buffer 传递)包含大量的绘制调用,从而需要并行渲染,会发生什么?这就是次级命令缓冲区可以发挥作用的地方。

使用次级命令缓冲区进行绘制

次级命令缓冲区有一组非常具体的条件需要使用——它们只能记录与一个渲染传递相关的命令。

这就是为什么允许用户记录多个次级命令缓冲区很重要:可能需要多个传递的每传递并行性,因此可能需要多个次级命令缓冲区。

次级缓冲区始终需要一个主缓冲区,并且不能直接提交到任何队列:它们必须被复制到主缓冲区,并且仅在开始记录命令时继承RenderPassFrameBuffers

让我们看看涉及次级命令缓冲区使用的不同步骤。首先,我们需要有一个主命令缓冲区,该缓冲区需要设置一个渲染传递和要渲染的帧缓冲区,因为这是绝对必要的,因为没有次级命令缓冲区可以提交到队列或设置RenderPassFrameBuffer

这些将是唯一从主命令缓冲区继承的状态,因此,即使在开始记录命令时,视口和模板状态也必须重新设置。

让我们先展示一个主命令缓冲区的设置:

VkClearValue clearValues[2];
VkRenderPassBeginInfo renderPassBeginInfo {};
renderPassBeginInfo.renderPass = renderPass;
renderPassBeginInfo.framebuffer = frameBuffer;
vkBeginCommandBuffer(primaryCommandBuffer, &cmdBufInfo);

当开始一个将被分配给一个或多个次级命令缓冲区的渲染传递时,我们需要添加VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS标志:

vkCmdBeginRenderPass(primaryCommandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS);

然后,我们可以将inheritanceInfo结构体传递给次级缓冲区:

VkCommandBufferInheritanceInfo inheritanceInfo {};
inheritanceInfo.renderPass = renderPass;
inheritanceInfo.framebuffer = frameBuffer;

然后我们可以开始次级命令缓冲区:

VkCommandBufferBeginInfo commandBufferBeginInfo {};
commandBufferBeginInfo.flags =
VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT;
commandBufferBeginInfo.pInheritanceInfo = &inheritanceInfo;
VkBeginCommandBuffer(secondaryCommandBuffer,
                     &commandBufferBeginInfo);

次级命令缓冲区现在已准备好开始发出绘制命令:

vkCmdSetViewport(secondaryCommandBuffers.background, 0, 1,
                 &viewport);
vkCmdSetScissor(secondaryCommandBuffers.background, 0, 1,
                &scissor);
vkCmdBindPipeline(secondaryCommandBuffers.background,
                  VK_PIPELINE_BIND_POINT_GRAPHICS,
                  pipelines.starsphere);
VkDrawIndexed(…)

注意,必须在开始时设置裁剪和视口,因为没有状态在边界渲染传递和帧缓冲区之外被继承。

一旦我们完成命令的记录,我们可以调用VkEndCommandBuffer函数并将缓冲区放入主命令缓冲区中的可复制状态。为了将次级命令缓冲区复制到主缓冲区,需要调用一个特定的函数,即vkCmdExecuteCommands

vkCmdExecuteCommands(primaryCommandBuffer,
                     commandBuffers.size(),
                     commandBuffers.data());

此函数接受一个次级命令缓冲区的数组,这些缓冲区将被顺序复制到主缓冲区中。

为了确保记录的命令的正确顺序,多线程(因为线程可以以任何顺序完成)不能保证,我们可以给每个命令缓冲区一个执行索引,将它们全部放入一个数组中,排序它们,然后使用这个排序后的数组在vkCmdExecuteCommands函数中。

在这一点上,主命令缓冲区可以记录其他命令或提交到队列中,因为它包含了从辅助命令缓冲区复制过来的所有命令。

启动多个任务以记录命令缓冲区

最后一步是创建多个任务以并行记录命令缓冲区。我们决定将多个网格分组到每个命令缓冲区作为一个例子,但通常,你会在每个渲染通道中记录单独的命令缓冲区。

让我们来看看代码:

SecondaryDrawTask secondary_tasks[ parallel_recordings ]{ };
u32 start = 0;
for ( u32 secondary_index = 0;
      secondary_index < parallel_recordings;
      ++secondary_index ) {
    SecondaryDrawTask& task = secondary_tasks[
                              secondary_index ];
    task.init( scene, renderer, gpu_commands, start,
               start + draws_per_secondary );
    start += draws_per_secondary;
    task_scheduler->AddTaskSetToPipe( &task );
}

我们为每个网格组添加一个任务到调度器中。每个任务将记录一系列网格的命令缓冲区。

一旦我们添加了所有任务,我们必须等待它们完成,然后才能添加辅助命令缓冲区以在主命令缓冲区上执行:

for ( u32 secondary_index = 0;
      secondary_index < parallel_recordings;
      ++secondary_index ) {
    SecondaryDrawTask& task = secondary_tasks[
                              secondary_index ];
    task_scheduler->WaitforTask( &task );
    vkCmdExecuteCommands( gpu_commands->vk_command_buffer,
                          1, &task.cb->vk_command_buffer );
}

我们建议阅读本章的代码以获取更多关于实现的详细信息。

在本节中,我们描述了如何并行记录多个命令缓冲区以优化 CPU 上的此操作。我们详细说明了命令缓冲区的分配策略以及它们如何在帧间重用。

我们已经突出了主缓冲区和辅助缓冲区之间的差异,以及它们在我们渲染器中的使用方式。最后,我们展示了如何并行记录多个命令缓冲区。

在下一章中,我们将介绍帧图,这是一个允许我们定义多个渲染通道并可以利用我们描述的任务系统来并行记录每个渲染通道命令缓冲区的系统。

摘要

在本章中,我们学习了基于任务并行性的概念,并看到了如何使用如 enkiTS 之类的库快速将多线程能力添加到 Raptor 引擎中。

然后,我们学习了如何使用异步加载器将数据从文件加载到 GPU 上。我们还专注于与 Vulkan 相关的代码,以拥有一个可以与负责绘制的队列并行运行的第二个执行队列。我们看到了主命令缓冲区和辅助命令缓冲区之间的区别。

我们讨论了缓冲区分配策略的重要性,以确保在并行记录命令时的安全性,特别是考虑到帧间命令的重用。

最后,我们逐步展示了如何使用两种类型的命令缓冲区,这应该足以向任何决定使用 Vulkan 作为其图形 API 的应用程序添加所需的并行级别。

在下一章中,我们将处理一个名为帧图的数据结构,这将为我们提供足够的信息来自动化一些记录过程,包括屏障,并简化关于执行并行渲染的任务粒度的决策。

进一步阅读

基于任务的系统已经使用了多年。www.gdcvault.com/play/1012321/Task-based-Multithreading-How-to 提供了一个很好的概述。

可以在 blog.molecular-matters.com/2015/09/08/job-system-2-0-lock-free-work-stealing-part-2-a-specialized-allocator/ 找到许多关于工作窃取队列的文章,这些文章是该主题的良好起点。

PlayStation 3 和 Xbox 360 使用 IBM 的 Cell 处理器,通过多个核心为开发者提供更多性能。特别是 PlayStation 3 有几个协同处理器单元SPUs),开发者可以使用它们从主处理器卸载工作。

有许多演示文稿和文章详细介绍了开发者如何巧妙地使用这些处理器,例如,www.gdcvault.com/play/1331/The-PlayStation-3-s-SPUgdcvault.com/play/1014356/Practical-Occlusion-Culling-on

第四章:实现帧图

在本章中,我们介绍了帧图,这是一种新的系统,用于控制给定帧的渲染步骤。正如其名所示,我们将组织渲染帧所需的步骤(通道)在一个有向无环图DAG)中。这将使我们能够确定每个通道的执行顺序以及哪些通道可以并行执行。

拥有一个图也为我们提供了许多其他好处,例如以下内容:

  • 它允许我们自动化渲染通道和帧缓冲区的创建和管理,因为每个通道定义了它将从中读取的输入资源以及它将写入的资源。

  • 它帮助我们通过一种称为内存别名的技术减少帧所需的内存。我们可以通过分析图来确定资源将使用多长时间。在资源不再需要后,我们可以将其内存重新用于新的资源。

  • 最后,我们将在执行过程中让图来管理内存屏障和布局转换的插入。每个输入和输出资源定义了它将被如何使用(例如,纹理与附加),我们可以根据这些信息推断出其下一个布局。

总结来说,在本章中,我们将涵盖以下主要主题:

  • 理解帧图的结构和我们的实现细节

  • 实现拓扑排序以确保通道按正确顺序执行

  • 使用图驱动渲染和自动化资源管理和布局转换

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter4

理解帧图

到目前为止,Raptor 引擎中的渲染只包含一个通道。虽然这种方法在我们所涵盖的主题中表现良好,但它无法扩展到一些后续章节。更重要的是,它不会代表现代渲染引擎组织工作的方式。一些游戏和引擎实现了数百个通道,手动管理它们可能会变得繁琐且容易出错。

因此,我们决定在本书中介绍帧图是合适的时间。在本节中,我们将展示我们图的架构以及如何在代码中操作它的主要接口。

让我们从图的基本概念开始。

构建图

在我们介绍帧图的解决方案和实现之前,我们希望提供一些我们将贯穿本章使用的构建块。如果您熟悉帧图或一般图,请随意浏览本节。

图由两个元素定义:节点(或顶点)和。每个节点可以连接到一个或多个节点,每个连接由一条边定义。

图 4.1 – 从节点 A 到 B 的边

图 4.1 – 从节点 A 到 B 的边

在本章的介绍中,我们提到帧图是一个有向无环图(DAG)。我们的帧图具有这些属性非常重要,否则我们无法执行它:

  • 有向:这意味着边有方向。例如,如果我们定义一个从节点 A 到节点 B 的边,我们不能使用相同的边从 BA。我们需要一个不同的边从 BA

图 4.2 – 有向图中从 A 连接到 B 和从 B 连接到 A

图 4.2 – 有向图中从 A 连接到 B 和从 B 连接到 A

  • 无环:这意味着图中不能有任何环。当我们沿着从一个子节点开始的路径返回到给定的节点时,就会引入一个环。如果发生这种情况,我们的帧图将进入无限循环。

图 4.3 – 包含环的图的示例

图 4.3 – 包含环的图的示例

在帧图的情况下,每个节点代表一个渲染过程:深度预扫描、g-缓冲区、光照等。我们不显式地定义边。相反,每个节点将定义多个输出,如果需要,还可以定义多个输入。当给定过程的输出被用作另一个过程的输入时,就隐含了一个边。

图 4.4 – 全帧图的示例

图 4.4 – 全帧图的示例

这两个概念,节点和边,就是理解帧图所需的所有内容。接下来,我们将展示我们如何决定编码这个数据结构。

数据驱动方法

一些引擎只提供代码接口来构建帧图,而其他引擎允许开发者以人类可读的格式(例如 JSON)指定图,这样更改图就不一定需要代码更改。

经过一些考虑,我们决定在 JSON 中定义我们的图,并实现一个解析器来实例化所需的类。我们选择这种方法有几个原因:

  • 这允许我们在不重新编译代码的情况下对图进行一些更改。例如,如果我们想更改渲染目标的尺寸或格式,我们只需要在图的 JSON 定义中进行更改,然后重新运行程序。

  • 我们还可以重新组织图,并删除其中的一些节点,而无需更改代码。

  • 理解图的流程更容易。根据实现方式,代码中对图的定义可能分布在不同的代码位置,甚至不同的文件中。这使得确定图结构变得更加困难。

  • 对于非技术贡献者来说,更改更容易。图定义也可以通过可视化工具完成,并将其转换为 JSON。如果图定义完全在代码中完成,这种方法是不可行的。

现在,我们可以看看我们的帧图中的一个节点:

{
    "inputs":
    [
        {
            "type": "attachment",
            "name": "depth"
        }
    ],
    "name": "gbuffer_pass",
    "outputs":
    [
        {
            "type": "attachment",
            "name": "gbuffer_colour",
            "format": "VK_FORMAT_B8G8R8A8_UNORM",
            "resolution": [ 1280, 800 ],
            "op": "VK_ATTACHMENT_LOAD_OP_CLEAR"
        },
        {
            "type": "attachment",
            "name": "gbuffer_normals",
            "format": "VK_FORMAT_R16G16B16A16_SFLOAT",
            "resolution": [ 1280, 800 ],
            "op": "VK_ATTACHMENT_LOAD_OP_CLEAR"
        },
        ...
    ]
}

节点由三个变量定义:

  • name: 这有助于我们在执行期间识别节点,同时也为其他元素提供了有意义的名称,例如,与该节点关联的渲染通道。

  • inputs: 这列出了该节点的输入。这些是其他节点产生的资源。请注意,在图中定义一个未由其他节点产生的输入将是一个错误。唯一的例外是外部资源,这些资源在渲染图中外部管理,并且用户必须在运行时将它们提供给图。

  • outputs: 这些是由给定节点产生的资源。

我们根据其用途定义了四种不同类型的资源:

  • attachment: 附件列表用于确定给定节点的渲染通道和帧缓冲区组成。正如你在前面的示例中注意到的,附件可以定义在输入和输出中。这是在多个节点上继续处理资源所必需的。例如,在运行深度预通道之后,我们希望加载深度数据并在 g 缓冲区通道中使用它,以避免为隐藏在其他物体后面的物体着色像素。

  • texture: 这种类型用于区分图像和附件。附件必须是节点渲染通道和帧缓冲区定义的一部分,而纹理在通道期间读取,是着色器数据定义的一部分。

这种区分对于确定哪些图像需要过渡到不同的布局并需要图像屏障也很重要。我们将在本章后面更详细地介绍这一点。

我们在这里不需要指定纹理的大小和格式,因为我们已经在第一次定义资源为输出时这样做过了。

  • buffer: 这种类型表示我们可以写入或读取的存储缓冲区。与纹理一样,我们需要插入内存屏障以确保在另一个通道访问缓冲区数据之前完成上一个通道的写入。

  • reference: 这种类型仅用于确保在节点之间计算正确的边缘,而不创建新的资源。

所有类型都很直观,但我们认为引用类型需要举例说明,以便更好地理解为什么我们需要这种类型:

{
    "inputs":
    [
        {
            "type": "attachment",
            "name": "lighting"
        },
        {
            "type": "attachment",
            "name": "depth"
        }
    ],
    "name": "transparent_pass",
    "outputs":
    [
        {
            "type": "reference",
            "name": "lighting"
        }
    ]
}

在这种情况下,光照是attachment类型的输入资源。在处理图时,我们将正确地将产生光照资源的节点链接到这个节点。然而,我们还需要确保使用光照资源的下一个节点创建到这个节点的连接,否则,节点顺序将是不正确的。

因此,我们在透明通道的输出中添加了对光照资源的引用。我们在这里不能使用attachment类型,否则,在创建渲染通道和帧缓冲区时,我们会重复计算光照资源。

现在你已经很好地理解了帧图结构,是时候看看一些代码了!

实现帧图

在本节中,我们将定义将在本章中使用的整个数据结构,即资源和节点。接下来,我们将解析图的 JSON 定义以创建用于后续步骤的资源节点。

让我们从我们数据结构的定义开始。

资源

资源定义了一个节点的输入或输出。它们决定了给定节点对资源的使用,并且正如我们稍后将要解释的,它们用于定义帧图节点之间的边。资源结构如下:

struct FrameGraphResource {
    FrameGraphResourceType type;
    FrameGraphResourceInfo resource_info;
    FrameGraphNodeHandle producer;
    FrameGraphResourceHandle output_handle;
    i32 ref_count = 0;
    const char* name = nullptr;
};

资源可以是节点的输入或输出。以下列表中的每个字段都值得仔细研究:

  • type:定义了我们是否在处理图像或缓冲区。

  • resource_info:包含基于type的资源详细信息(例如大小、格式等)。

  • producer:存储输出资源的节点的引用。这将用于确定图的边。

  • output_handle:存储父资源。稍后我们将更清楚地了解为什么需要这个字段。

  • ref_count:在计算哪些资源可以被别名化时使用。别名化是一种允许多个资源共享相同内存的技术。我们将在本章稍后提供更多关于它是如何工作的详细信息。

  • name:包含资源在 JSON 中定义的名称。这对于调试和通过名称检索资源非常有用。

接下来,我们将查看一个图节点:

struct FrameGraphNode {
    RenderPassHandle render_pass;
    FramebufferHandle framebuffer;
    FrameGraphRenderPass* graph_render_pass;
    Array<FrameGraphResourceHandle> inputs;
    Array<FrameGraphResourceHandle> outputs;
    Array<FrameGraphNodeHandle> edges;
    const char* name = nullptr;
};

节点存储了它在执行期间将使用的输入列表和它将产生的输出。每个输入和输出都是FrameGraphResource的不同实例。output_handle字段用于将输入与其输出资源链接起来。我们需要单独的资源,因为它们的类型可能不同;一个图像可能被用作输出附件,然后用作输入纹理。这是一个重要的细节,将被用于自动化内存屏障放置。

节点还存储了一个列表,列出了它连接到的节点、它的名称、根据其输入和输出的定义创建的帧缓冲区和渲染通道。像资源一样,节点也存储了在 JSON 中定义的名称。

最后,一个节点包含指向渲染实现的指针。我们将在稍后讨论如何将节点与其渲染通道链接起来。

这些是我们用来定义帧图的主要数据结构。我们还创建了一个FrameGraphBuilder辅助类,该类将被FrameGraph类使用。FrameGraphBuilder辅助类包含创建节点和资源的功能。

让我们看看这些构建块是如何用来定义我们的帧图的!

解析图

现在我们已经定义了构成我们图的数据结构,我们需要解析图的 JSON 定义来填充这些结构并创建我们的帧图定义。以下是执行解析帧图所需的步骤:

  1. 我们首先初始化一个FrameGraphBuilderFrameGraph类:

    FrameGraphBuilder frame_graph_builder;
    
    frame_graph_builder.init( &gpu );
    
    FrameGraph frame_graph;
    
    frame_graph.init( &frame_graph_builder );
    
  2. 接下来,我们调用parse方法来读取图的 JSON 定义,并为其创建资源和节点:

    frame_graph.parse( frame_graph_path,
    
                       &scratch_allocator );
    
  3. 一旦我们有了图定义,我们就有了编译步骤:

    frame_graph.compile();
    

这一步是魔法发生的地方。我们分析图以计算节点之间的边,为每个类创建帧缓冲区和渲染过程,并确定哪些资源可以被别名化。我们将在下一节中详细解释这些步骤。

  1. 一旦我们编译了我们的图,我们需要注册我们的渲染过程:

    frame_graph->builder->register_render_pass(
    
        "depth_pre_pass", &depth_pre_pass );
    
    frame_graph->builder->register_render_pass(
    
        "gbuffer_pass", &gbuffer_pass );
    
    frame_graph->builder->register_render_pass(
    
        "lighting_pass", &light_pass );
    
    frame_graph->builder->register_render_pass(
    
        "transparent_pass", &transparent_pass );
    
    frame_graph->builder->register_render_pass(
    
        "depth_of_field_pass", &dof_pass );
    

这允许我们通过简单地交换为给定过程注册的类来测试每个过程的不同实现。甚至可以在运行时交换这些过程。

  1. 最后,我们准备好渲染我们的场景:

    frame_graph->render( gpu_commands, scene );
    

现在我们将详细查看compilerender方法。

实现拓扑排序

正如我们在前一节中提到的,帧图实现中最有趣的部分在compile方法中。为了清晰起见,我们在以下章节中简化了一些代码。

请参阅章节中提到的技术要求部分的 GitHub 链接以获取完整的实现。

下面是我们用来计算节点之间边的算法的分解:

  1. 我们执行的第一步是创建节点之间的边:

    for ( u32 r = 0; r < node->inputs.size; ++r ) {
    
        FrameGraphResource* resource = frame_graph->
    
            get_resource( node->inputs[ r ].index );
    
        u32 output_index = frame_graph->find_resource(
    
            hash_calculate( resource->name ) );
    
        FrameGraphResource* output_resource = frame_graph
    
            ->get_resource( output_index );
    

我们通过遍历每个输入并检索相应的输出资源来完成此操作。请注意,在内部,图按名称键存储输出。

  1. 接下来,我们将输出细节保存到输入资源中。这样我们就可以在输入中直接访问这些数据:

        resource->producer = output_resource->producer;
    
        resource->resource_info = output_resource->
    
                                  resource_info;
    
        resource->output_handle = output_resource->
    
                                  output_handle;
    
  2. 最后,我们在产生此输入的节点和当前正在处理的节点之间创建一个边:

        FrameGraphNode* parent_node = ( FrameGraphNode*)
    
                                        frame_graph->
    
                                        get_node(
    
                                        resource->
    
                                        producer.index );
    
        parent_node->edges.push( frame_graph->nodes[
    
                                 node_index ] );
    
    }
    

在这个循环结束时,每个节点将包含它连接的节点列表。虽然我们目前没有这样做,但在这一阶段,可以删除图中没有边的节点。

现在我们已经计算了节点之间的连接,我们可以按拓扑顺序对它们进行排序。在这个步骤结束时,我们将获得一个节点列表,以确保产生输出的节点在利用该输出的节点之前。

下面是排序算法的分解,我们突出显示了代码中最相关的部分:

  1. sorted_node数组将包含按逆序排序的节点:

    Array<FrameGraphNodeHandle> sorted_nodes;
    
    sorted_nodes.init( &local_allocator, nodes.size );
    
  2. 将使用visited数组来标记我们已经处理过的节点。我们需要跟踪这些信息以避免无限循环:

    Array<u8> visited;
    
    visited.init( &local_allocator, nodes.size, nodes.size
    
    );
    
    memset( visited.data, 0, sizeof( bool ) * nodes.size );
    
  3. 最后,stack数组用于跟踪我们还需要处理的节点。我们需要这个数据结构,因为我们的实现没有使用递归:

    Array<FrameGraphNodeHandle> stack;
    
    stack.init( &local_allocator, nodes.size );
    
  4. 图是通过使用深度优先搜索DFS)遍历的。下面的代码执行了这项任务:

    for ( u32 n = 0; n < nodes.size; ++n ) {
    
        stack.push( nodes[ n ] );
    
  5. 我们遍历每个节点并将其添加到栈中。我们这样做是为了确保我们处理图中的所有节点:

        while ( stack.size > 0 ) {
    
            FrameGraphNodeHandle node_handle =
    
                stack.back();
    
  6. 然后,我们有一个第二个循环,它将在我们处理完刚刚添加到栈中的节点的所有连接节点后才会停止:

            if (visited[ node_handle.index ] == 2) {
    
                stack.pop();
    
                continue;
    
            }
    

如果一个节点已经被访问并添加到排序节点的列表中,我们只需将其从栈中移除并继续处理其他节点。传统的图处理实现没有这一步。

我们必须将其作为节点可能会产生多个输出。这些输出反过来可能会链接到多个节点,我们不希望将产生节点多次添加到排序节点列表中。

  1. 如果我们当前正在处理的节点已经被访问并且是通过栈到达的,这意味着我们已经处理了它的所有子节点,并且它可以被添加到排序节点的列表中。正如以下代码中提到的,我们也会将其标记为已添加,这样我们就不会多次将其添加到列表中:

            if ( visited[ node_handle.index ]  == 1) {
    
                visited[ node_handle.index ] = 2; // added
    
                sorted_nodes.push( node_handle );
    
                stack.pop();
    
                continue;
    
            }
    
  2. 当我们第一次到达一个节点时,我们将其标记为已访问。正如以下代码块中提到的,这是为了确保我们不会多次处理相同的节点:

            visited[ node_handle.index ] = 1; // visited
    
  3. 如果我们正在处理的节点没有边,我们继续迭代:

            FrameGraphNode* node = ( FrameGraphNode* )
    
                                     builder->node_cache.
    
                                     nodes.access_resource
    
                                     ( node_handle.index
    
                                    );
    
            // Leaf node
    
            if ( node->edges.size == 0 ) {
    
                continue;
    
            }
    
  4. 另一方面,如果节点连接到其他节点,我们将它们添加到栈中以供处理,然后再次迭代。如果你第一次看到图遍历的迭代实现,可能不会立即清楚它与递归实现的关系。我们建议多次阅读代码,直到你理解它;这是一个在关键时刻非常有用的强大技术!

            for ( u32 r = 0; r < node->edges.size; ++r ) {
    
                FrameGraphNodeHandle child_handle =
    
                    node->edges[ r ];
    
                if ( !visited[ child_handle.index ] ) {
    
                    stack.push( child_handle );
    
                }
    
            }
    
  5. 最后一步是遍历排序节点数组并将它们以相反的顺序添加到图节点中:

    for ( i32 i = sorted_nodes.size - 1; i >= 0; --i ) {
    
        nodes.push( sorted_nodes[ i ] );
    
    }
    

我们现在已经完成了图的拓扑排序!节点排序后,我们可以继续分析图以确定哪些资源可以被别名化。

计算资源别名

大型框架图必须处理数百个节点和资源。这些资源的生命周期可能不会跨越整个图,这为我们提供了重用不再需要的资源内存的机会。这种技术被称为内存别名,因为多个资源可以指向相同的内存分配。

图 4.5 – 框架中资源生命周期的示例

图 4.5 – 框架中资源生命周期的示例

在这个例子中,我们可以看到gbuffer_colour资源对于整个帧不是必需的,其内存可以被重用,例如,用于final资源。

我们首先需要确定使用给定资源的第一个和最后一个节点。一旦我们有了这些信息,我们就可以确定给定节点是否可以为其资源重用现有内存。以下代码实现了这一技术。

我们首先分配几个辅助数组:

sizet resource_count = builder->resource_cache.resources.
                       used_indices;
Array<FrameGraphNodeHandle> allocations;
allocations.init( &local_allocator, resource_count,
                  resource_count );
for ( u32 i = 0; i < resource_count; ++i) {
    allocations[ i ].index = k_invalid_index;
}
Array<FrameGraphNodeHandle> deallocations;
deallocations.init( &local_allocator, resource_count,
                    resource_count );
for ( u32 i = 0; i < resource_count; ++i) {
    deallocations[ i ].index = k_invalid_index;
}
Array<TextureHandle> free_list;
free_list.init( &local_allocator, resource_count );

它们对于算法来说不是严格必需的,但它们对调试和确保我们的实现没有错误很有帮助。allocations数组将跟踪给定资源是在哪个节点上分配的。

同样,deallocations数组包含可以解除分配给定资源的节点。最后,free_list将包含已被释放并可重复使用的资源。

接下来,我们将查看跟踪资源分配和解除分配的算法:

for ( u32 i = 0; i < nodes.size; ++i ) {
    FrameGraphNode* node = ( FrameGraphNode* )builder->
                             node_cache.nodes.access
                             _resource( nodes[ i ].index );
    for ( u32 j = 0; j < node->inputs.size; ++j ) {
        FrameGraphResource* input_resource =
            builder->resource_cache.resources.get(
                node->inputs[ j ].index );
        FrameGraphResource* resource =
            builder->resource_cache.resources.get(
                input_resource->output_handle.index );
        resource->ref_count++;
    }
}

首先,我们遍历所有输入资源,每次它们作为输入使用时,都会增加它们的引用计数。我们还在allocations数组中标记哪个节点分配了资源:

for ( u32 i = 0; i < nodes.size; ++i ) {
    FrameGraphNode* node = builder->get_node(
                           nodes[ i ].index );
    for ( u32 j = 0; j < node->outputs.size; ++j ) {
        u32 resource_index = node->outputs[ j ].index;
        FrameGraphResource* resource =
            builder->resource_cache.resources.get(
                resource_index );

下一步是遍历所有节点及其输出。以下代码负责执行内存分配:

if ( !resource->resource_info.external && 
  allocations[ resource_index ].index == 
  k_invalid_index ) { 
      allocations[ resource_index ] = nodes[ i ]; 
if ( resource->type == 
  FrameGraphResourceType_Attachment ) { 
     FrameGraphResourceInfo& info = 
        resource->resource_info; 
                if ( free_list.size > 0 ) {
                    TextureHandle alias_texture =
                        free_list.back();
                    free_list.pop();
                    TextureCreation texture_creation{ };
                    TextureHandle handle =
                        builder->device->create_texture(
                            texture_creation );
                    info.texture.texture = handle;
                } else {
                    TextureCreation texture_creation{ };
                    TextureHandle handle =
                        builder->device->create_texture(
                            texture_creation );
                    info.texture.texture = handle;
                }
            }
         }
    }

对于每个输出资源,我们首先检查是否有任何可重用的可用资源。如果有,我们将空闲资源传递给TextureCreation结构。内部,GpuDevice将使用该资源的内存并将其绑定到新创建的资源。如果没有可用的空闲资源,我们继续创建新的资源。

循环的最后一部分负责确定哪些资源可以被释放并添加到空闲列表中:

    for ( u32 j = 0; j < node->inputs.size; ++j ) {
        FrameGraphResource* input_resource =
            builder->resource_cache.resources.get(
                node->inputs[ j ].index );
        u32 resource_index = input_resource->
                             output_handle.index;
        FrameGraphResource* resource =
            builder->resource_cache.resources.get(
                resource_index );
        resource->ref_count--;
if ( !resource->resource_info.external && 
  resource->ref_count == 0 ) { 
     deallocations[ resource_index ] = nodes[ i ]; 
if ( resource->type == 
  FrameGraphResourceType_Attachment || 
  resource->type == 
  FrameGraphResourceType_Texture ) { 
     free_list.push( resource->resource_info. 
     texture.texture ); 
            }
         }
    }
}

我们再次遍历输入,并减少每个资源的引用计数。如果引用计数达到0,这意味着这是最后一个使用该资源的节点。我们将节点保存在deallocations数组中,并将资源添加到空闲列表中,以便用于我们接下来要处理的下一个节点。

这就完成了图分析的实施。我们创建的资源用于创建framebuffer对象,此时图就准备好进行渲染了!

我们将在下一节中介绍图的执行。

使用帧图驱动渲染

在分析完图之后,我们拥有了渲染所需的所有详细信息。以下代码负责执行每个节点并确保所有资源都处于正确的状态,以便该节点可以使用:

for ( u32 n = 0; n < nodes.size; ++n ) {
    FrameGraphNode*node = builder->get_node( nodes
                          [ n ].index );
    gpu_commands->clear( 0.3, 0.3, 0.3, 1 );
    gpu_commands->clear_depth_stencil( 1.0f, 0 );
for ( u32 i = 0; i < node->inputs.size; ++i ) { 
   FrameGraphResource* resource = 
   builder->get_resource( node->inputs[ i ].index 
   );
if ( resource->type == 
  FrameGraphResourceType_Texture ) { 
     Texture* texture = 
     gpu_commands->device->access_texture( 
     resource->resource_info.texture.texture 
     ); 
util_add_image_barrier( gpu_commands-> 
    vk_command_buffer, texture->vk_image, 
    RESOURCE_STATE_RENDER_TARGET, 
    RESOURCE_STATE_PIXEL_SHADER_RESOURCE, 
    0, 1, resource->resource_info. 
    texture.format == 
    VK_FORMAT_D32_SFLOAT ); 
        } else if ( resource->type ==
                    FrameGraphResourceType_Attachment ) {
            Texture*texture = gpu_commands->device->
                              access_texture( resource->
                              resource_info.texture.texture
                              ); }
    }

我们首先遍历节点的所有输入。如果资源是纹理,我们插入一个屏障,将资源从附件布局(用于渲染通道)转换为着色器阶段布局(用于片段着色器)。

此步骤很重要,以确保在读取此资源之前,任何之前的写入都已完成:

    for ( u32 o = 0; o < node->outputs.size; ++o ) {
        FrameGraphResource* resource =
            builder->resource_cache.resources.get(
                node->outputs[ o ].index );
        if ( resource->type ==
             FrameGraphResourceType_Attachment ) {
            Texture* texture =
                gpu_commands->device->access_texture(
                    resource->resource_info.texture.texture
                );
            width = texture->width;
            height = texture->height;
        if ( texture->vk_format == VK_FORMAT_D32_SFLOAT ) {
            util_add_image_barrier(
            gpu_commands->vk_command_buffer,
            texture->vk_image, RESOURCE_STATE_UNDEFINED,
            RESOURCE_STATE_DEPTH_WRITE, 0, 1, resource->
            resource_info.texture.format ==
            VK_FORMAT_D32_SFLOAT );
            } else {
                 util_add_image_barrier( gpu_commands->
                 vk_command_buffer, texture->vk_image,
                 RESOURCE_STATE_UNDEFINED,
                 RESOURCE_STATE_RENDER_TARGET, 0, 1,
                 resource->resource_info.texture.format ==
                 VK_FORMAT_D32_SFLOAT );
            }
        }
    }

接下来,我们遍历节点的输出。再次强调,我们需要确保资源处于正确的状态,以便在渲染通道中使用。完成此步骤后,我们的资源就准备好进行渲染了。

每个节点的渲染目标可能具有不同的分辨率。以下代码确保我们的裁剪和视口大小是正确的:

    Rect2DInt scissor{ 0, 0,( u16 )width, ( u16 )height };
    gpu_commands->set_scissor( &scissor );
    Viewport viewport{ };
    viewport.rect = { 0, 0, ( u16 )width, ( u16 )height };
    viewport.min_depth = 0.0f;
    viewport.max_depth = 1.0f;
    gpu_commands->set_viewport( &viewport );

一旦视口和剪刀设置正确,我们就在每个节点上调用pre_render方法。这允许每个节点执行必须在渲染通道外发生的任何操作。例如,用于景深效果的渲染通道会获取输入纹理并计算该资源的 MIP 贴图:

    node->graph_render_pass->pre_render( gpu_commands,
                                         render_scene );

最后,我们绑定该节点的渲染通道,调用为该节点注册的渲染通道的render方法,并通过结束渲染通道来结束循环:

    gpu_commands->bind_pass( node->render_pass, node->
                             framebuffer, false );
    node->graph_render_pass->render( gpu_commands,
                                     render_scene );
    gpu_commands->end_current_render_pass();
}

这就完成了本章的代码概述!我们已经覆盖了很多内容;现在是简要回顾的好时机:我们首先定义了我们帧图实现所使用的主要数据结构。接下来,我们解释了如何通过使用输入和输出计算节点之间的边来解析图。

完成这一步后,我们可以按拓扑顺序对节点进行排序,以确保它们按正确的顺序执行。然后,我们创建执行图所需的资源,并利用内存别名优化内存使用。最后,我们遍历每个节点进行渲染,确保所有资源都处于正确的状态。

有些功能我们还没有实现,这些功能可以提高我们帧图的功能性和健壮性。例如,我们应该确保图中没有循环,并且输入不是由同一节点产生的,该节点正在使用这个输入。

对于内存别名实现,我们采用贪婪策略,简单地选择第一个可以容纳新资源的空闲资源。这可能导致碎片化和内存使用不优化。

我们鼓励你尝试修改代码并对其进行改进!

摘要

在本章中,我们实现了一个帧图来改进渲染通道的管理,并使我们在未来章节中扩展渲染管道变得更加容易。我们首先介绍了定义图的基礎概念、节点和边。

接下来,我们概述了我们的图结构及其在 JSON 格式中的编码方式。我们还提到了为什么我们选择这种方法而不是在代码中完全定义图。

在最后一部分,我们详细说明了如何处理图并使其准备就绪执行。我们概述了用于图的 主要数据结构,并介绍了如何解析图以创建节点和资源,以及如何计算边。接下来,我们解释了节点的拓扑排序,这确保了它们按正确的顺序执行。随后,我们介绍了内存分配策略,这允许我们从不再需要的节点资源中重用内存。最后,我们概述了渲染循环以及我们如何确保资源处于正确的渲染状态。

在下一章中,我们将利用在前两章中开发的技巧。我们将利用多线程和我们的帧图实现来展示如何并行使用计算和图形管线进行布料模拟。

进一步阅读

我们的实现受到了 Frostbite 引擎中帧图实现的极大启发,我们推荐您观看这个演示:www.gdcvault.com/play/1024045/FrameGraph-Extensible-Rendering-Architecture-in

许多其他引擎都实现了帧图来组织和优化它们的渲染管线。我们鼓励您查看其他实现,并找到最适合您需求的解决方案!

第五章:解锁异步计算

在本章中,我们将通过允许计算工作与图形任务并行执行来改进我们的渲染器。到目前为止,我们一直将所有工作记录并提交给单个队列。我们仍然可以向该队列提交计算任务以与图形工作一起执行:例如,在本章中,我们已经开始使用计算着色器进行全屏光照渲染过程。在这种情况下,我们不需要单独的队列,因为我们想减少不同队列之间的同步量。

然而,在单独的队列上运行其他计算工作负载可能是有益的,这样可以允许 GPU 充分利用其计算单元。在本章中,我们将实现一个简单的布料模拟,使用计算着色器在单独的计算队列上运行。为了解锁这一新功能,我们需要对我们的引擎进行一些修改。

在本章中,我们将涵盖以下主要内容:

  • 使用单个时间线信号量来避免多个栅栏

  • 为异步计算添加单独的队列

  • 使用异步计算实现布料模拟

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter5

用单个时间线信号量替换多个栅栏

在本节中,我们将解释当前如何在我们的渲染器中使用栅栏和信号量,以及如何通过利用时间线信号量来减少我们必须使用的对象数量。

我们的引擎已经支持使用栅栏并行渲染多个帧。必须使用栅栏来确保 GPU 已经完成对给定帧的资源使用。这是通过在提交新一批命令到 GPU 之前在 CPU 上等待来实现的。

图 5.1 – CPU 正在处理当前帧,而 GPU 正在渲染上一帧

图 5.1 – CPU 正在处理当前帧,而 GPU 正在渲染上一帧

然而,有一个缺点;我们需要为每个正在飞行的帧创建一个栅栏。这意味着我们将至少需要管理两个栅栏来实现双缓冲,如果我们想支持三缓冲,则需要三个。

我们还需要多个信号量来确保 GPU 在继续之前等待某些操作完成。例如,我们需要在渲染完成后发出一个信号量,并将该信号量传递给显示命令。这是为了保证在我们尝试显示交换链图像之前渲染已完成。

下面的图示说明了两种场景;在第一种场景中,没有信号量,交换链图像可以在渲染仍在进行时显示到屏幕上。

在第二种场景中,我们添加了一个在渲染提交中信号并在展示之前等待的信号量。这确保了应用程序的正确行为。如果我们没有这个信号量,我们可能会展示仍在渲染的图像,并显示损坏的数据。

图 5.2 – 两个场景说明了在渲染和展示之间需要信号量的必要性

图 5.2 – 两个场景说明了在渲染和展示之间需要信号量的必要性

当我们开始考虑多个队列时,情况会变得更糟。在本章中,我们将添加一个单独的计算队列。这意味着我们需要添加更多的栅栏来等待 CPU 完成计算工作。我们还需要新的信号量来同步计算和图形队列,以确保计算队列产生的数据准备好被图形队列使用。

即使我们没有使用计算队列,我们也可能想要将我们的渲染工作分成多个提交。每个提交都需要它自己的信号和等待信号量,根据每个工作负载的依赖关系。对于有数十个甚至数百个提交的大型场景,这可能会迅速失控。

幸运的是,我们有一个解决方案。如果我们这么想,栅栏和信号量持有相同的信息;它们在提交完成后被信号。如果有一种方法可以在 CPU 和 GPU 上使用单个对象会怎样?这种确切的功能正是由时间线信号量提供的。

如其名所示,时间线信号量保持一个单调递增的值。我们可以定义我们希望信号量被信号通知的值和希望等待的值。这个对象可以被 GPU 和 CPU 等待,大大减少了实现正确同步所需的对象数量。

现在我们将展示如何在 Vulkan 中使用时间线信号量。

启用时间线信号量扩展

时间线信号量功能在 Vulkan 1.2 中被提升为核心功能。然而,它不是一个强制性的扩展,因此在使用它之前,我们首先需要查询支持。这通常是通过枚举设备暴露的扩展并查找扩展名称来完成的:

vkEnumerateDeviceExtensionProperties( 
    vulkan_physical_device, nullptr, 
        &device_extension_count, extensions );
for ( size_t i = 0; i < device_extension_count; i++ ) {
    if ( !strcmp( extensions[ i ].extensionName, 
         VK_KHR_TIMELINE_SEMAPHORE_EXTENSION_NAME ) ) {
             timeline_semaphore_extension_present = true;
             continue;
         }
}

如果扩展存在,我们需要填充一个额外的结构,该结构将在设备创建时使用,如下面的代码所示:

VkPhysicalDeviceFeatures2 physical_features2 { 
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2 };
void* current_pnext = nullptr;
VkPhysicalDeviceTimelineSemaphoreFeatures timeline_sempahore_features{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES  };
if ( timeline_semaphore_extension_present ) {
    timeline_sempahore_features.pNext = current_pnext;
    current_pnext = &timeline_sempahore_features;
}
physical_features2.pNext = current_pnext;
vkGetPhysicalDeviceFeatures2( vulkan_physical_device, 
    &physical_features2 );

我们还需要将扩展名添加到已启用扩展的列表中:

if ( timeline_semaphore_extension_present ) {
    device_extensions.push( 
        VK_KHR_TIMELINE_SEMAPHORE_EXTENSION_NAME );
}

最后,我们使用我们刚刚在创建设备时检索到的数据:

VkDeviceCreateInfo device_create_info { 
    VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO };
device_create_info.enabledExtensionCount = 
    device_extensions.size;  
device_create_info.ppEnabledExtensionNames = 
    device_extensions.data; 
device_create_info.pNext = &physical_features2;
vkCreateDevice( vulkan_physical_device, 
    &device_create_info, vulkan_allocation_callbacks, 
        &vulkan_device );

现在,我们已经准备好在我们的代码中使用时间线信号量了!我们将在下一节中看到如何创建时间线信号量。

创建时间线信号量

创建时间线信号量相当简单。我们首先定义标准的创建结构:

VkSemaphoreCreateInfo semaphore_info{ 
    VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO };

然后,我们需要传递一个额外的结构来告诉 API 我们想要创建一个时间线信号量:

VkSemaphoreTypeCreateInfo semaphore_type_info{ 
    VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO };
semaphore_type_info.semaphoreType = 
    VK_SEMAPHORE_TYPE_TIMELINE;
semaphore_info.pNext = &semaphore_type_info;
Finally, we call the create function:
vkCreateSemaphore( vulkan_device, &semaphore_info, 
    vulkan_allocation_callbacks, &vulkan_timeline_semaphore );

就这样!我们现在有一个可以用于我们的渲染器的时间线信号量。在下一节中,我们将探讨如何使用这种类型的信号量的一些示例。

在 CPU 上等待时间线信号量

如前所述,我们可以在 CPU 上等待时间线信号量被触发。以下代码正是这样做的:

u64 timeline_value = …;

VkSemaphoreWaitInfo semaphore_wait_info{ 
    VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO };
semaphore_wait_info.semaphoreCount = 1;
semaphore_wait_info.pSemaphores = 
    &vulkan_timeline_semaphore;
semaphore_wait_info.pValues = &timeline_value;

vkWaitSemaphores( vulkan_device, &semaphore_wait_info, 
                  timeout );

如您可能已经注意到的,我们可以同时等待多个信号量,并为每个信号量指定不同的值。这在渲染到多个窗口时可能很有用,每个窗口使用不同的信号量。VkSemaphoreWaitInfo结构还有一个flags字段。

在此字段中使用VK_SEMAPHORE_WAIT_ANY_BIT值将在任何一个信号量达到我们等待的值时终止等待。否则,只有在所有信号量都达到各自值时,等待才会终止。

上述代码的最后一个重要方面是超时值。这个值以纳秒为单位指定。如果在给定时间内等待条件没有得到满足,调用将返回VK_TIMEOUT。我们通常将超时设置为无限,因为我们绝对需要信号量被触发。

然而,存在一个风险,即等待调用可能永远不会返回,例如,如果等待和信号值的组合导致 GPU 上的死锁。一种替代方法是设置超时为一个相对较大的值——例如 1 秒。如果在这个时间间隔内等待未完成,很可能我们的提交存在问题,并且我们可以将错误信息通知用户。

在本节中,我们展示了如何在 CPU 上等待时间线信号量。在下一节中,我们将介绍如何在 GPU 上使用时间线信号量。

在 GPU 上使用时间线信号量

在本节中,我们将展示如何更新时间线信号量的值,以及如何在 GPU 上等待特定的值。

注意

在我们开始之前,我们想指出我们正在使用VK_KHR_synchronization2扩展。这个扩展简化了屏障和信号量的代码编写。请参考完整代码,以了解如何使用旧 API 实现这一点。

我们首先定义我们想要等待的信号量列表:

VkSemaphoreSubmitInfoKHR wait_semaphores[]{
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_image_acquired_semaphore, 0, 
       VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR,
       0 },
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_timeline_semaphore, absolute_frame - ( 
       k_max_frames - 1 ), 
       VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT_KHR , 0 }
};

这个列表可以包含标准信号量和时间线信号量。对于标准信号量,signal值被忽略。

同样,我们需要定义一个要等待的信号量列表:

VkSemaphoreSubmitInfoKHR signal_semaphores[]{
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       *render_complete_semaphore, 0, 
       VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR, 
       0 },
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_timeline_semaphore, absolute_frame + 1, 
       VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR 
       , 0 }
};

如前所述,我们可以使用不同的信号量类型,对于标准信号量,信号值被忽略。对于时间线信号量,信号值始终需要增加。如果我们提交相同的值两次或更小的值,将会得到一个验证错误。

我们还需要小心我们用于等待和发出的值。如果我们等待在同一个提交中设置的值,我们将会使 GPU 陷入死锁。一般来说,始终尝试使用一个保证已被前一个提交设置的值。验证层也将帮助您捕获此类错误。

最后一步是将两个列表传递给提交信息结构:

VkSubmitInfo2KHR submit_info{ 
    VK_STRUCTURE_TYPE_SUBMIT_INFO_2_KHR };
submit_info.waitSemaphoreInfoCount = 2;
submit_info.pWaitSemaphoreInfos = wait_semaphores;
submit_info.commandBufferInfoCount = 
    num_queued_command_buffers;
submit_info.pCommandBufferInfos = command_buffer_info;
submit_info.signalSemaphoreInfoCount = 2;
submit_info.pSignalSemaphoreInfos = signal_semaphores;

queue_submit2( vulkan_main_queue, 1, &submit_info, 
    VK_NULL_HANDLE );

如您可能已经注意到的,我们现在可以在提交中等待和发出相同的时间线信号量。我们也不再需要栅栏。这极大地简化了代码并减少了所需的同步对象数量。

在本节中,我们展示了如何启用使用时间线信号量的扩展,以及如何创建和使用它们在 CPU 上等待。最后,我们展示了如何在 GPU 上等待和发出时间线信号量。

在下一节中,我们将利用新获得的知识添加一个用于异步计算工作的单独队列。

添加用于异步计算的单独队列

在本节中,我们将说明如何使用单独的队列进行图形和计算工作,以充分利用我们的 GPU。现代 GPU 拥有许多通用的计算单元,这些单元既可以用于图形工作,也可以用于计算工作。根据给定帧的工作负载(着色器复杂性、屏幕分辨率、渲染过程之间的依赖关系等),GPU 可能无法充分利用。

使用计算着色器将一些在 CPU 上完成的计算移动到 GPU 上可以提高性能并实现更好的 GPU 利用率。这是可能的,因为 GPU 调度器可以确定是否有任何计算单元处于空闲状态,并将工作分配给它们以重叠现有工作:

图 5.3 – 顶部:图形工作负载没有充分利用 GPU;底部:计算工作负载可以利用未使用的资源以实现最佳的 GPU 利用率

图 5.3 – 顶部:图形工作负载没有充分利用 GPU;底部:计算工作负载可以利用未使用的资源以实现最佳的 GPU 利用率

在本节的剩余部分,我们将演示如何使用上一节中引入的时间线信号量来同步两个队列之间的数据访问。

在单独的队列上提交工作

我们已经在第三章中设置了多个队列,解锁多线程。现在我们需要确保两个队列之间的数据访问正确同步;否则,我们可能会访问过时或更糟的数据,即尚未初始化的数据。

在此过程的第一个步骤是创建一个单独的命令缓冲区。由于同一个命令缓冲区不能提交到不同的队列,因此必须为计算工作使用不同的命令缓冲区。这可以通过从我们的GpuDevice实现请求一个新的命令缓冲区轻松实现:

CommandBuffer* cb = gpu.get_command_buffer( 0, 
gpu.current_frame, true );

接下来,我们需要创建一个新的时间线信号量,该信号量将被计算队列使用。这与我们在上一节中展示的代码相同,我们不会在这里重复。

然后,我们需要在每次计算提交时增加我们时间线信号量的值:

bool has_wait_semaphore = last_compute_semaphore_value > 0;
VkSemaphoreSubmitInfoKHR wait_semaphores[]{
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_compute_semaphore, 
       last_compute_semaphore_value, 
       VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT_KHR, 0 }
};

last_compute_semaphore_value++;

VkSemaphoreSubmitInfoKHR signal_semaphores[]{
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_compute_semaphore, 
       last_compute_semaphore_value, 
       VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT_KHR, 0 },
};

这段代码与我们在提交时间线信号量之前展示的代码类似。主要区别在于等待阶段,现在必须是VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT_KHR。现在我们已经有了等待和信号信号量的列表,它们已准备好用于我们的提交:

VkCommandBufferSubmitInfoKHR command_buffer_info{ 
    VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO_KHR };
command_buffer_info.commandBuffer = 
    command_buffer->vk_command_buffer;

VkSubmitInfo2KHR submit_info{ 
    VK_STRUCTURE_TYPE_SUBMIT_INFO_2_KHR };
submit_info.waitSemaphoreInfoCount = 
    has_wait_semaphore ? 1 : 0;
submit_info.pWaitSemaphoreInfos = wait_semaphores;
submit_info.commandBufferInfoCount = 1;
submit_info.signalSemaphoreInfoCount = 1;
submit_info.pSignalSemaphoreInfos = signal_semaphores;

queue_submit2( vulkan_compute_queue, 1, &submit_info, 
    VK_NULL_HANDLE );

再次强调,这应该是一个熟悉的代码。我们想强调的是,我们只在第一次提交后添加等待信号量。如果我们要在第一次提交时等待信号量,GPU 将会死锁,因为信号量永远不会被触发。幸运的是,验证层会突出显示这个问题,并且可以很容易地纠正。

现在我们已经提交了计算工作负载,我们需要确保图形队列等待数据准备就绪。我们可以通过在提交图形队列时将计算信号量添加到等待信号量的列表中来实现这一点。我们将只突出显示新的代码:

bool wait_for_compute_semaphore = ( 
    last_compute_semaphore_value > 0 ) && has_async_work; 
VkSemaphoreSubmitInfoKHR wait_semaphores[]{
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_image_acquired_semaphore, 0, 
       VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR, 
       0 },
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_compute_semaphore, 
       last_compute_semaphore_value, 
       VK_PIPELINE_STAGE_2_VERTEX_ATTRIBUTE_INPUT_BIT_KHR, 
       0 },
    { VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO_KHR, nullptr, 
       vulkan_graphics_semaphore, 
       absolute_frame - ( k_max_frames - 1 ), 
       VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT_KHR , 0 },
};

在将计算信号量添加到列表时,必须采取相同的谨慎。我们只想在至少执行了一个计算提交时等待。对于某些帧,我们可能没有任何计算工作待处理。在这种情况下,我们也不希望等待计算信号量。

在我们的案例中,我们将等待阶段设置为VK_PIPELINE_STAGE_2_VERTEX_ATTRIBUTE_INPUT_BIT_KHR,因为我们正在修改我们的网格的顶点。如果,例如,您正在使用计算队列更新一个直到片段着色器阶段才使用的纹理,这将需要调整。使用正确的等待阶段对于获得最佳性能很重要。

在本节中,我们展示了如何检索用于计算工作的单独队列。然后我们解释了如何使用新创建的队列提交计算工作,并正确同步来自不同队列的数据访问以确保正确的结果。

在下一节中,我们将通过实现一个简单的布料模拟来展示一个具体的示例,使用的是计算着色器。

使用异步计算实现布料模拟

在本节中,我们将实现一个简单的布料模拟,作为计算工作负载的示例用例。我们首先解释为什么在某些任务上运行 GPU 可能是有益的。接下来,我们提供一个计算着色器的概述。最后,我们展示如何将代码从 CPU 迁移到 GPU,并突出两个平台之间的差异。

使用计算着色器的优势

在过去,物理模拟主要在 CPU 上运行。GPU 只有足够的计算能力来处理图形工作,并且大多数管道阶段都是由只能执行一个任务的专用硬件块实现的。随着 GPU 的发展,管道阶段转移到通用的计算块,这些块可以执行不同的任务。

这种灵活性和计算能力的增加使得引擎开发者可以将一些工作负载移动到 GPU 上。除了原始性能之外,在 GPU 上运行一些计算可以避免从 CPU 内存到 GPU 内存的昂贵复制。内存速度没有像处理器速度那样快速发展,尽可能少地在设备之间移动数据是应用程序性能的关键。

在我们的例子中,布料模拟必须更新所有顶点的位置并将更新的数据复制到 GPU。根据网格的大小和要更新的网格数量,这可能会占帧时间的很大一部分。

这些工作负载在 GPU 上也可以更好地扩展,因为我们能够并行更新更多的网格。

现在我们将概述计算着色器是如何执行的。如果你熟悉计算着色器或者之前使用过 CUDA 或 OpenCL,可以自由地浏览下一节。

计算着色器概述

GPU 执行模型被称为单指令多线程SIMT)。它与现代 CPU 提供的单指令多数据SIMD)类似,用于使用单个指令操作多个数据条目。

然而,GPU 在单个指令中操作的数据点更多。另一个主要区别是,与 SIMD 指令相比,GPU 上的每个线程都更加灵活。GPU 架构是一个迷人的话题,但其范围超出了本书的范围。我们将在本章末尾提供进一步阅读的参考文献。

注意

线程组在不同的 GPU 供应商中有不同的名称。你可能会在他们的文档中看到术语 warp 或 wave。我们将使用线程组来避免混淆。

每个计算着色器调用可以在计算单元内使用多个线程,并且可以控制使用多少线程。在 Vulkan 中,这是通过计算着色器内的以下指令实现的:

layout (local_size_x = 8, local_size_y = 8, 
local_size_z = 1) in;

这定义了局部组的大小;我们将在稍后解释它是如何工作的。目前,主要点是我们在告诉 GPU 我们想要执行 64 个线程(8x8)。每个 GPU 都有一个最佳线程组大小。你应该检查每个供应商的文档,并在可能的情况下调整线程组大小以获得最佳性能。

在调用计算着色器时,我们还需要定义一个全局组大小:

gpu_commands->dispatch( ceilu32( renderer->
    gpu->swapchain_width * 1.f / 8 ), 
      ceilu32( renderer->gpu->swapchain_height * 1.f / 8 ), 
      1 );

这段代码取自我们的光照传递实现。在这种情况下,我们想要处理渲染目标纹理中的所有像素。正如你可能注意到的,我们将其大小除以 8。这是确保我们不会多次处理相同像素所必需的。让我们通过一个例子来澄清局部和全局组大小的工作原理。

假设我们的渲染目标是 1280x720。将宽度乘以高度将给出图像中的总像素数。当我们定义局部组大小时,我们确定每个着色器调用将处理多少像素(在我们的例子中再次是 64)。着色器调用的数量计算如下:

shader_invocation_count = total_pixels / 64

dispatch命令需要三个值,因为局部和全局组大小都被定义为三个值的向量。这就是为什么我们将每个维度除以8的原因:

global_group_size_x = width / 8
global_group_size_y = height / 8

由于我们正在处理 2D 纹理,我们不会修改z值。我们可以通过以下代码验证我们正在处理正确的像素数量:

local_thread_group_count = 64
shader_invocation_count = global_group_size_x * 
    global_group_size_y
total_pixels =  shader_invocation_count * 
    local_thread_group_count

我们可以通过使用 GLSL 提供的这个变量来确定在着色器内部正在运行哪个调用:

ivec3 pos = ivec3( gl_GlobalInvocationID.xyz );

每个线程将看到一个独特的位置值,我们可以使用它来访问我们的纹理。

这只是计算着色器执行模型的简要概述。我们将在进一步阅读部分提供更深入的资源。

现在我们对计算着色器的执行有了更好的理解,我们将演示如何将 CPU 代码转换为 GPU 计算着色器。

编写计算着色器

为计算着色器编写代码与编写顶点或片段着色器类似。主要区别在于我们在计算着色器中具有更多的灵活性来定义要访问哪些数据。例如,在顶点着色器中,我们通常访问属性缓冲区中的一个条目。同样适用于片段着色器,其中由着色器调用着色的片段由 GPU 确定。

由于增加了灵活性,我们还需要更仔细地考虑我们的访问模式和线程之间的同步。例如,如果有多个线程必须写入相同的内存位置,我们需要添加内存屏障以确保对该内存的先前写入已完成,并且所有线程都看到正确的值。在伪代码中,这转化为以下内容:

// code
MemoryBarrier()
// all threads have run the code before the barrier

如果在着色器调用之间需要访问相同的内存位置,GLSL 还提供了原子操作。

考虑到这一点,让我们看看布料模拟 CPU 版本的伪代码:

for each physics mesh in the scene:
    for each vertex in the mesh:
        compute the force applied to the vertex
    // We need two loops because each vertex references 
       other vertices position
    // First we need to compute the force applied to each 
       vertex, 
    // and only after update each vertex position
       for each vertex in the mesh:
    update the vertex position and store its velocity

    update the mesh normals and tangents
    copy the vertices to the GPU

我们为布料模拟使用了常见的弹簧模型,但其实现超出了本章的范围。我们建议查看代码以获取更多细节,并在进一步阅读部分引用了我们使用的论文。

正如你所注意到的,在循环的末尾,我们必须将更新的顶点、法线和切线缓冲区复制到 GPU 上。根据网格的数量和它们的复杂性,这可能是一个昂贵的操作。如果布料模拟依赖于在 GPU 上运行的其它系统的数据,这一步可能会更加昂贵。

例如,如果动画系统在 GPU 上运行,而布料模拟在 CPU 上运行,我们现在需要执行两个复制操作,并且在管道中还有额外的同步点。出于这些原因,将布料模拟移动到 GPU 上可能是有益的。

让我们先看看顶点缓冲区的设置:

BufferCreation creation{ };
sizet buffer_size = positions.size * sizeof( vec3s );
creation.set( flags, ResourceUsageType::Immutable, 
    buffer_size ).set_data( positions.data )
        .set_name( nullptr ).set_persistent( true );

BufferResource* cpu_buffer = renderer->
    create_buffer( creation );
cpu_buffers.push( *cpu_buffer );

这是我们之前需要的唯一缓冲区。因为我们必须在 CPU 上更新数据,所以我们只能使用主机一致性的缓冲区,这样 CPU 上的写入在 CPU 上才是可见的。使用这种类型的缓冲区对 GPU 的性能有影响,因为这种内存可能访问速度较慢,尤其是当缓冲区大小很大时。

由于我们现在将在 GPU 上执行更新,我们可以使用标记为device_only的缓冲区。这就是我们创建缓冲区的方式:

creation.reset().set( flags, ResourceUsageType::Immutable, 
    buffer_size ).set_device_only( true )
        .set_name( "position_attribute_buffer" );

BufferResource* gpu_buffer = renderer->
    create_buffer( creation );
gpu_buffers.push( *gpu_buffer );

最后,我们只从 CPU 复制一次数据到 GPU。复制完成后,我们可以释放 CPU 缓冲区:

async_loader->request_buffer_copy( cpu_buffer->handle, 
                                   gpu_buffer->handle );

我们已经展示了位置缓冲区的示例。所有其他缓冲区(法线、切线、纹理坐标和索引)都以相同的方式进行管理。

现在我们有了我们的缓冲区,我们需要创建一个将被我们的计算着色器使用的描述符集:

DescriptorSetLayoutHandle physics_layout = renderer->
    gpu->get_descriptor_set_layout
        ( cloth_technique->passes[ 0 ].pipeline, 
            k_material_descriptor_set_index );
ds_creation.reset().buffer( physics_cb, 0 )
    .buffer( mesh.physics_mesh->gpu_buffer, 1 )
    .buffer( mesh.position_buffer, 2 )
    .buffer( mesh.normal_buffer, 3 )
    .buffer( mesh.index_buffer, 4 )
    .set_layout( physics_layout );

mesh.physics_mesh->descriptor_set = renderer->
    gpu->create_descriptor_set( ds_creation );

我们可以将前一个缓冲区的绑定与以下着色器代码相匹配:

layout ( std140, set = MATERIAL_SET, binding = 0 ) uniform 
    PhysicsData {
    ...
};

layout ( set = MATERIAL_SET, binding = 1 ) buffer 
    PhysicsMesh {
        uint index_count;
        uint vertex_count;

    PhysicsVertex physics_vertices[];
};

layout ( set = MATERIAL_SET, binding = 2 ) buffer 
    PositionData {
        float positions[];
};

layout ( set = MATERIAL_SET, binding = 3 ) buffer 
    NormalData {
        float normals[];
};

layout ( set = MATERIAL_SET, binding = 4 ) readonly buffer 
    IndexData {
        uint indices[];
};

重要的是要注意几个点。因为我们不知道每个缓冲区在运行时的尺寸,我们必须使用单独的存储块。每个存储块只能有一个运行时数组,并且它必须是块的最后一个成员。

我们还必须使用浮点数组而不是vec3数组;否则,向量中的每个条目都会填充到 16 字节,GPU 上的数据将不再与 CPU 上的数据布局匹配。我们可以使用vec4作为类型,但我们会为每个顶点浪费 4 字节。当你有数百万,甚至数十亿个顶点时,这会累积起来!

最后,我们将IndexData块标记为readonly。这是因为在这个着色器中我们永远不会修改索引缓冲区。标记每个块为正确的属性很重要,因为这会给着色器编译器提供更多的优化机会。

我们可以通过不同的数据排列来减少块的数量,例如:

struct MeshVertex {
    vec3 position;
    vec3 normal;
    vec3 tangent;
};

layout ( set = MATERIAL_SET, binding = 2 ) buffer MeshData {
    MeshVertex mesh_vertices[];
};

这种解决方案通常被称为结构数组AoS),而之前我们展示的代码使用了数组结构SoA)。虽然 AoS 解决方案简化了绑定,但它也使得无法单独使用每个数组。例如,在我们的深度遍历中,我们只需要位置信息。因此,我们更倾向于使用 SoA 方法。

我们已经展示了如何调度计算着色器以及如何同步计算和图形队列之间的访问,所以我们不会在这里重复那段代码。现在我们可以转向着色器实现。我们只将展示相关部分;您可以通过查看代码来获取完整的列表。

我们首先计算每个顶点所受的力:

vec3 spring_force = vec3( 0, 0, 0 );

for ( uint j = 0; j < physics_vertices[ v ]
    .joint_count; ++j ) {
        pull_direction = ...;
        spring_force += pull_direction;
}

vec3 viscous_damping = physics_vertices[ v ]
    .velocity * -spring_damping;

vec3 viscous_velocity = ...;

vec3 force = g * m;
force -= spring_force;
force += viscous_damping;
force += viscous_velocity;

physics_vertices[ v ].force = force;

注意我们每次是如何访问physics_vertices数组的。在 CPU 代码中,我们可以简单地获取结构体的引用,并且每个字段都会被正确更新。然而,GLSL 不支持引用,因此我们需要非常小心,确保我们没有写入局部变量。

与 CPU 代码一样,在计算每个顶点的力向量之后,我们需要更新其位置:

vec3 previous_position = physics_vertices[ v ]
    .previous_position;
vec3 current_position = physics_vertices[ v ].position;

vec3 new_position = ...;

physics_vertices[ v ].position = new_position;
physics_vertices[ v ].previous_position = current_position;

physics_vertices[ v ].velocity = new_position - current_position;

再次注意,我们每次都从缓冲区中读取。最后,我们更新网格的顶点位置:

for ( uint v = 0; v < vertex_count; ++v ) {
     positions[ v * 3 + 0 ] = physics_vertices[ v ]
         .position.x;
     positions[ v * 3 + 1 ] = physics_vertices[ v ]
         .position.y;
     positions[ v * 3 + 2 ] = physics_vertices[ v ]
         .position.z;
}

因为所有这些都是在 GPU 上执行的,所以位置可能首先由另一个系统(如动画)更新,但我们不再需要昂贵的从 GPU 到 GPU 的复制操作。

在我们得出结论之前,我们想指出,我们为每个网格调用一个着色器,并且性能是通过在同一个调度中对多个网格更新布料模拟来实现的。另一种方法可能是为每个网格有一个调度,其中每个着色器调用更新一个单独的顶点。

虽然在技术上是一个有效的方法,但它需要在线程组内以及着色器调用之间进行更多的同步。正如我们提到的,我们必须首先计算每个顶点的力,然后才能更新它们的位置。另一个解决方案可能是将更新分成两个着色器,一个用于计算力,另一个用于更新位置。

这仍然需要在每个着色器调度之间使用管道屏障。虽然 GPU 必须保证每个命令的执行顺序与记录的顺序相同;但它不保证完成的顺序。出于这些原因,我们决定为每个网格使用一个线程。

在本节中,我们解释了计算着色器的执行模型以及在 GPU 上运行选定的计算以提高性能和避免额外的内存复制的好处。然后我们演示了如何将针对 CPU 编写的代码移植到 GPU 上,以及在使用计算着色器时需要注意的一些方面。

我们建议查看代码以获取更多细节。尝试对布料模拟进行修改以实现不同的模拟技术,或者向引擎中添加您自己的计算着色器!

摘要

在本章中,我们为我们的渲染器构建了支持计算着色器的基础。我们首先介绍了时间线信号量和它们如何被用来替换多个信号量和栅栏。我们展示了如何在 CPU 上等待时间线信号量,以及时间线信号量可以作为队列提交的一部分使用,无论是为了发出信号还是等待。

接下来,我们演示了如何使用新引入的时间线信号量来同步图形和计算队列之间的执行。

在上一节中,我们展示了如何将针对 CPU 编写的代码移植到 GPU 上的一个示例。我们首先解释了在 GPU 上运行计算的一些好处。接下来,我们概述了计算着色器的执行模型以及局部和全局工作组大小的配置。最后,我们给出了一个用于布料模拟的计算着色器具体示例,并突出了与为 CPU 编写的相同代码的主要差异。

在下一章中,我们将通过添加网格着色器来改进我们的管线,对于不支持这些着色器的设备,我们将编写一个计算着色器替代方案。

进一步阅读

同步可能是 Vulkan 中最复杂的方面之一。我们在本章和前几章中提到了一些概念。如果您想提高您的理解,我们建议阅读以下资源:

当涉及到计算着色器时,我们只是触及了表面。以下资源更深入地探讨了这些内容,并提供了一些针对个别设备的优化建议:

计算机图形学中的实时布料模拟已经是一个研究了许多年的主题。我们的实现基于这篇论文:斯坦福大学 Rigidcloth 论文

在这篇论文中,介绍了一种流行的另一种方法:CMU Baraff 论文

最后,这次 GDC 演讲给了我们使用布料模拟来展示如何使用计算着色器的想法:

Ubisoft 布料模拟性能分析

第二部分:GPU 驱动渲染

从这部分开始,我们将专注于现代渲染技术。本节将涵盖以下章节:

  • 第六章**,GPU 驱动渲染

  • 第七章**,使用集群延迟渲染渲染多个光源

  • 第八章**,使用网格着色器添加阴影

  • 第九章**,实现可变率着色

  • 第十章**,添加体积雾

第六章:GPU 驱动渲染

在本章中,我们将升级几何管线以使用最新的可用技术:网格着色器网格块。这种技术的理念是将网格渲染的流程从 CPU 移动到 GPU,将剔除和绘制命令生成移动到不同的着色器中。

我们首先将在 CPU 上处理网格结构,通过将其分离成不同的网格块,每个网格块是包含最多 64 个三角形的组,每个三角形都有一个单独的边界球。然后我们将使用计算着色器进行剔除,并在不同的传递中写入绘制网格块的命令列表。最后,我们将使用网格着色器渲染网格块。还将提供一个计算版本,因为目前网格着色器仅适用于 Nvidia GPU。

传统上,几何剔除是在 CPU 上执行的。场景中的每个网格通常由一个轴对齐的边界框AABB)表示。AABB 可以很容易地与相机视锥体进行剔除,但随着场景复杂性的增加,大量帧时间可能会花费在剔除步骤上。

这通常是渲染管线中的第一步,因为我们需要确定哪些网格需要提交进行绘制。这意味着很难找到其他可以并行执行的工作。在 CPU 上执行视锥剔除的另一个痛点是难以确定哪些对象被遮挡且不需要绘制。

在每一帧中,我们需要根据相机位置重新排序所有元素。当场景中有成千上万的元素时,这通常是不切实际的。最终,一些网格,例如地形,被组织在大的区域中,这些区域总是会被绘制出来,即使只有其中的一小部分是可见的。

幸运的是,我们可以将一些计算转移到 GPU 上,并利用其并行能力。本章中我们将要介绍的技术将允许我们在 GPU 上执行视锥和遮挡剔除。为了使过程尽可能高效,我们将直接在 GPU 上生成绘制命令列表。

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

  • 将大型网格分解成网格块

  • 使用任务和网格着色器处理网格块以执行背面和视锥剔除

  • 使用计算着色器进行高效的遮挡剔除

  • 在 GPU 上生成绘制命令并使用间接绘制函数

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter6

将大型网格分解成网格块

在本章中,我们将主要关注管道的几何阶段,即着色阶段之前的一个阶段。向管道的几何阶段添加一些复杂性将在后续阶段带来回报,因为我们将减少需要着色的像素数量。

注意

当我们提到图形管道的几何阶段时,我们并不是指几何着色器。管道的几何阶段指的是输入装配IA)、顶点处理和原语装配PA)。顶点处理可以进一步运行一个或多个以下着色器:顶点、几何、细分、任务和网格着色器。

内容几何形状有多种形状、大小和复杂性。渲染引擎必须能够处理从小型详细对象到大型地形的网格。大型网格(如地形或建筑)通常由艺术家分解,以便渲染引擎可以根据这些对象与摄像机的距离选择不同的细节级别。

将网格分解成更小的块可以帮助剔除不可见的几何形状,但其中一些网格仍然足够大,以至于我们需要完全处理它们,即使只有一小部分是可见的。

网格细分是为了解决这些问题而开发的。每个网格被细分为顶点组(通常是 64 个),这些顶点可以在 GPU 上更容易地处理。

下图说明了网格如何被分解成网格细分:

图 6.1 – 网格细分示例

图 6.1 – 网格细分示例

这些顶点可以组成任意数量的三角形,但我们通常根据我们运行的硬件调整这个值。在 Vulkan 中,推荐值为126(如developer.nvidia.com/blog/introduction-turing-mesh-shaders/中所述,这个数字是为了为每个网格保留一些内存以写入原始计数)。

注意

在撰写本文时,网格和任务着色器仅通过其扩展在 Nvidia 硬件上可用。虽然本章中描述的一些 API 是特定于这个扩展的,但概念可以普遍应用并使用通用计算着色器实现。Khronos 委员会目前正在开发这个扩展的更通用版本,以便网格和任务着色器很快就能从其他供应商那里获得!

现在我们有更少的三角形数量,我们可以通过剔除不可见或被其他对象遮挡的网格来使用它们进行更细粒度的控制。

除了顶点和三角形的列表之外,我们还为每个网格生成一些额外的数据,这些数据在稍后执行背面、视锥体和遮挡剔除时将非常有用。

另一个额外的可能性(将在未来添加)是选择网格的细节级别LOD),从而根据任何想要的启发式方法选择不同的 meshlets 子集。

这额外数据中的第一个代表 meshlet 的边界球体,如下面的截图所示:

图 6.2 – meshlet 边界球示例;为了清晰起见,一些较大的球已被隐藏

图 6.2 – meshlet 边界球示例;为了清晰起见,一些较大的球已被隐藏

有些人可能会问:为什么不使用 AABBs?AABBs 至少需要两个vec3的数据:一个用于中心,一个用于半大小向量。另一种编码方式是存储最小和最大的角点。相反,球体可以用单个vec4编码:一个vec3用于中心加上半径。

由于我们可能需要处理数百万个 meshlets,每个节省的字节都很重要!球体也更容易进行视锥体和遮挡剔除的测试,正如我们将在本章后面描述的。

我们接下来要使用的数据的下一部分是 meshlet 圆锥,如下面的截图所示:

图 6.3 – meshlet 圆锥示例;为了清晰起见,并非所有圆锥都显示

图 6.3 – meshlet 圆锥示例;为了清晰起见,并非所有圆锥都显示

圆锥表示 meshlet 面向的方向,并将用于背面剔除。

现在我们更好地理解了 meshlets 为何有用以及我们如何使用它们来改进大型网格的剔除,让我们看看如何在代码中生成它们!

生成 meshlets

我们使用一个开源库,称为MeshOptimizer (github.com/zeux/meshoptimizer)来生成 meshlets。另一个替代库是meshlete (github.com/JarkkoPFC/meshlete),我们鼓励您尝试两者,以找到最适合您需求的库。

在加载给定网格的数据(顶点和索引)之后,我们将生成 meshlets 的列表。首先,我们确定可以为我们网格生成的最大 meshlets 数量,并为描述 meshlets 的顶点和索引数组分配内存:

const sizet max_meshlets = meshopt_buildMeshletsBound( 
    indices_accessor.count, max_vertices, max_triangles );

Array<meshopt_Meshlet> local_meshlets;
local_meshlets.init( temp_allocator, max_meshlets, 
    max_meshlets );

Array<u32> meshlet_vertex_indices;
meshlet_vertex_indices.init( temp_allocator, max_meshlets * 
    max_vertices, max_meshlets* max_vertices );
Array<u8> meshlet_triangles;
meshlet_triangles.init( temp_allocator, max_meshlets * 
    max_triangles * 3, max_meshlets* max_triangles * 3 );

注意索引和三角形数组的类型。我们并没有修改原始的顶点或索引缓冲区,而是只在原始缓冲区中生成一个索引列表。另一个有趣的方面是,我们只需要 1 个字节来存储三角形索引。再次强调,节省内存对于保持 meshlet 处理效率非常重要!

下一步是生成我们的 meshlets:

const sizet max_vertices = 64;
const sizet max_triangles = 124;
const f32 cone_weight = 0.0f;

sizet meshlet_count = meshopt_buildMeshlets( 
    local_meshlets.data, 
    meshlet_vertex_indices.data, 
    meshlet_triangles.data, indices, 
    indices_accessor.count, 
    vertices, 
    position_buffer_accessor.count, 
    sizeof( vec3s ), 
    max_vertices, 
    max_triangles, 
    cone_weight );

如前所述,我们需要告诉库一个 meshlet 可以包含的最大顶点和三角形数量。在我们的案例中,我们使用 Vulkan API 推荐值。其他参数包括原始顶点和索引缓冲区,以及我们将创建的包含 meshlets 数据的数组。

让我们更详细地看看每个 meshlet 的数据结构:

struct meshopt_Meshlet
{
unsigned int vertex_offset;
unsigned int triangle_offset;

unsigned int vertex_count;
unsigned int triangle_count;
};

每个 meshlet 由两个偏移量和两个计数描述,一个用于顶点索引,一个用于三角形索引。请注意,这些偏移量指的是由库填充的meshlet_vertex_indicesmeshlet_triangles,而不是原始的顶点和索引缓冲区。

现在我们有了 meshlet 数据,我们需要将其上传到 GPU。为了将数据大小保持在最小,我们在全分辨率下存储位置,同时将每个维度的法线压缩为 1 字节,将 UV 坐标压缩为每个维度半浮点。在伪代码中,如下所示:

meshlet_vertex_data.normal = ( normal + 1.0 ) * 127.0;
meshlet_vertex_data.uv_coords = quantize_half( uv_coords );

下一步是从每个 meshlet 中提取额外的数据(边界球体和圆锥):

for ( u32 m = 0; m < meshlet_count; ++m ) {
    meshopt_Meshlet& local_meshlet = local_meshlets[ m ];

    meshopt_Bounds meshlet_bounds = 
    meshopt_computeMeshletBounds( 
    meshlet_vertex_indices.data + 
    local_meshlet.vertex_offset,
    meshlet_triangles.data +
    local_meshlet.triangle_offset,
    local_meshlet.triangle_count,
    vertices, 
    position_buffer_accessor
    .count,
    sizeof( vec3s ) );

    ...
}

我们遍历所有 meshlets,并调用 MeshOptimizer API 来计算每个 meshlet 的边界。让我们更详细地看看返回数据的结构:

struct meshopt_Bounds
{
    float center[3];
    float radius;

    float cone_apex[3];
    float cone_axis[3];
    float cone_cutoff;

    signed char cone_axis_s8[3];
    signed char cone_cutoff_s8;
};

前四个浮点数代表边界球体。接下来,我们有圆锥定义,它由圆锥方向(cone_axis)和圆锥角度(cone_cutoff)组成。我们没有使用cone_apex值,因为它使得背面剔除计算更加昂贵。然而,它可以带来更好的结果。

再次提醒,量化值(cone_axis_s8cone_cutoff_s8)帮助我们减少了每个 meshlet 所需的数据大小。

最后,meshlet 数据被复制到 GPU 缓冲区,并在任务和 mesh shaders 的执行期间使用。

对于每个处理的 mesh,我们还会保存 meshlets 的偏移量和计数,以便基于父 mesh 进行粗略剔除:如果 mesh 可见,则其 meshlets 将被添加。

在本节中,我们描述了什么是 meshlets 以及为什么它们对在 GPU 上改进几何剔除很有用。接下来,我们展示了我们实现中使用的数据结构。现在我们的数据已经准备好了,它将开始被任务和 mesh shaders 消费。这就是下一节的主题!

理解任务和 mesh shaders

在我们开始之前,我们应该提到 mesh shaders 可以在没有 task shaders 的情况下使用。例如,如果您想在 CPU 上对 meshlets 执行剔除或其他预处理步骤,您完全可以这样做。

此外,请注意,任务和 mesh shaders 在图形管线中替代了 vertex shaders。mesh shaders 的输出将被 fragment shader 直接消费。

下面的图示展示了传统几何管线和 mesh shader 管线之间的区别:

图 6.4 – 传统管线和 mesh 管线之间的区别

图 6.4 – 传统与网格管道之间的区别

在本节中,我们将概述任务着色器和网格着色器的工作原理,然后使用这些信息来实现使用任务着色器的背面剔除和视锥剔除。

任务着色器和网格着色器使用与计算着色器相同的执行模型,有一些小的变化。任务着色器的输出直接由网格着色器消费,对于这两种类型,我们都可以指定线程组大小。

任务着色器(有时也称为放大着色器)可以被视为过滤器。在调用任务着色器时,我们将所有网格元素提交给处理,任务着色器将输出通过过滤器的网格元素。

以下图表提供了一个由任务着色器处理的网格元素的示例。被拒绝的网格元素将不再进一步处理。

图 6.5 – 任务着色器确定要剔除的网格元素。被剔除的网格元素不会被网格着色器处理

图 6.5 – 任务着色器确定要剔除的网格元素。被剔除的网格元素不会被网格着色器处理

然后网格着色器将处理活动的网格元素,并执行与在顶点着色器中通常执行的处理。

虽然这只是一个关于任务着色器和网格着色器的高级概述,但内容并不多。如果您想了解更多关于该功能内部工作原理的信息,我们将在进一步阅读部分提供更多资源。

接下来,我们将解释如何在 Vulkan 中实现任务着色器和网格着色器!

实现任务着色器

如我们之前提到的,任务着色器和网格着色器通过 Vulkan API 的扩展提供。我们之前已经展示了如何检查扩展,因此在本章中我们没有重复代码。请参阅代码以获取更多详细信息。

该扩展还引入了两个新的管道阶段,VK_PIPELINE_STAGE_TASK_SHADER_BIT_NVVK_PIPELINE_STAGE_MESH_SHADER_BIT_NV,可用于放置管道屏障以确保这些阶段使用的数据正确同步。

任务着色器可以像任何计算着色器一样处理:我们创建一个包含(可选)任务着色器模块、网格着色器和片段着色器的管道。调用任务着色器使用以下 API 进行:

vkCmdDrawMeshTasksNV( vk_command_buffer, task_count, 
    first_task );

task_count视为计算着色器的工作组大小。还有一个间接变体,可以从缓冲区中读取多个绘制的调用详情:

vkCmdDrawMeshTasksIndirectCountNV( vk_command_buffer, 
    mesh_draw_buffer, 0, draw_count, stride );

我们在代码中使用这个变体,因为它允许我们每个场景只进行一次绘制调用,并让 GPU 完全控制哪些网格元素将被绘制。

在间接渲染中,我们像在 CPU 上一样在 GPU 程序中写入命令,并且我们还会读取一个缓冲区以了解有多少命令。我们将在本章的使用计算进行 GPU 剔除部分看到命令写入。

现在,我们将注意力转向着色器实现。任务和网格着色器要求启用它们的 GLSL 扩展,否则编译器可能会将代码视为常规计算着色器:

#extension GL_NV_mesh_shader: require

由于我们使用间接命令来调用我们的着色器,我们需要启用另一个扩展,这将使我们能够访问当前着色器调用的draw ID:

#extension GL_ARB_shader_draw_parameters : enable

注意,此扩展是在platform.h头文件中启用的,而不是直接在着色器代码中。正如我们提到的,任务着色器类似于计算着色器。实际上,我们着色器的第一个指令是确定线程组大小:

layout(local_size_x = 32) in;

在这里,即使指定了local_size_ylocal_size_z,也会被忽略。现在我们可以进入着色器的主体部分。我们首先确定需要处理哪个网格和网格块:

uint thread_index = gl_LocalInvocationID.x;
uint group_index = gl_WorkGroupID.x;
uint meshlet_index = group_index * 32 + thread_index;

uint mesh_instance_index = draw_commands[ gl_DrawIDARB ]
    .drawId;

gl_DrawIDARB绘制索引来自通过间接缓冲区中写入的命令对每个vkCmdDrawMeshTasksNV的调用。

接下来,我们加载当前网格块的数据。首先,我们确定网格块的世界位置和包围球的大小:

vec4 center = model * vec4(meshlets[mi].center, 1);
float scale = length( model[0] );
float radius = meshlets[mi].radius * scale;

接下来,我们恢复cone_axis值(记住,它们存储为一个字节)和cone_cutoff

vec3 cone_axis = mat3( model ) * 
   vec3(int(meshlets[mi].cone_axis[0]) / 127.0, 
   int(meshlets[mi].cone_axis[1]) / 127.0, 
   int(meshlets[mi].cone_axis[2]) / 127.0); 
float cone_cutoff = int(meshlets[mi].cone_cutoff) / 127.0;

现在我们有了执行背面和截锥剔除所需的所有数据:

accept = !coneCull(center.xyz, radius, cone_axis, 
    cone_cutoff, eye.xyz);

接下来,coneCull的实现如下:

bool coneCull(vec3 center, float radius, vec3 cone_axis, 
float cone_cutoff, vec3 camera_position)
{
    return dot(center - camera_position, cone_axis) >= 
        cone_cutoff * length(center - camera_position) + 
        radius;
}

此代码首先计算圆锥轴与从包围球中心指向相机的向量的夹角的余弦值。然后,它将圆锥截止值(即截止半角的余弦值)乘以相机与包围球中心的距离,并加上包围球的半径。

这决定了圆锥是否指向相机,应该被剔除,或者如果它指向相机,则应该保留。

下一步是执行截锥剔除。首先,我们将包围球的中心转换到相机空间:

center = world_to_camera * center;

接下来,我们检查六个截锥平面,以确定包围球是否在截锥内:

for ( uint i = 0; i < 6; ++i ) {
    frustum_visible = frustum_visible && 
        (dot( frustum_planes[i], center) > -radius);
}

如果网格块既可见又不是背面,我们就接受它:

accept = accept && frustum_visible;

最后一步是写出可见网格块的索引及其数量。输出数据结构定义如下:

out taskNV block
{
    uint meshletIndices[32];
};

我们使用 GLSL 的子组指令来完成这一步,如果这是你第一次看到这种语法,逐行分析是值得的。要访问这些指令,必须启用以下扩展:

#extension GL_KHR_shader_subgroup_ballot: require

首先,根据网格块是否被认为是可见的,为活动的着色器调用设置一个位:

uvec4 ballot = subgroupBallot(accept);

接下来,我们确定前一个调用设置的位,并使用它来存储活动的网格块索引:

uint index = subgroupBallotExclusiveBitCount(ballot);

if (accept)
    meshletIndices[index] = meshlet_index;

最后,我们计算这个线程组中所有设置的位,并将它们存储在gl_TaskCountNV变量中:

uint count = subgroupBallotBitCount(ballot);

if (ti == 0)
    gl_TaskCountNV = count;

gl_TaskCountNV变量由 GPU 用来确定需要多少个网格着色器调用才能处理未被遮挡的网格块。if语句是必需的,以确保每个网格块只写入一次TaskCount

这就完成了我们的任务着色器实现。接下来,我们将查看我们的网格着色器实现。

实现网格着色器

在任务着色器中执行网格片裁剪后,我们需要处理活动网格片。这类似于常规顶点着色器,然而,有一些重要的区别我们想要指出。

就像任务着色器一样,网格着色器可以被视为计算着色器,第一个指令是确定线程组大小:

layout(local_size_x = 32) in;

然后,我们必须读取任务着色器写入的数据:

in taskNV block
{
    uint meshletIndices[32];
};

接下来,我们定义我们将要输出的数据。我们首先确定我们可以写入的最大顶点数和原语(在我们的例子中是三角形)数:

layout(triangles, max_vertices = 64, max_primitives = 124) out;

我们继续使用我们可能通常从顶点着色器输出的相同数据:

layout (location = 0) out vec2 vTexcoord0[];
layout (location = 1) out vec4 vNormal_BiTanX[];
layout (location = 2) out vec4 vTangent_BiTanY[];
layout (location = 3) out vec4 vPosition_BiTanZ[];
layout (location = 4) out flat uint mesh_draw_index[];

注意,尽管如此,我们正在使用一个值数组,因为我们可以为每次调用输出多达 64 个顶点。

现在我们有了输入和输出值,我们可以转向着色器实现。像之前一样,我们首先确定我们的网格和网格片索引:

uint ti = gl_LocalInvocationID.x;
uint mi = meshletIndices[gl_WorkGroupID.x];

MeshDraw mesh_draw = mesh_draws[ meshlets[mi].mesh_index ];
uint mesh_instance_index = draw_commands[gl_DrawIDARB + 
total_count].drawId;

接下来,我们确定活动网格片的顶点和索引偏移量以及计数:

uint vertexCount = uint(meshlets[mi].vertexCount);
uint triangleCount = uint(meshlets[mi].triangleCount);
uint indexCount = triangleCount * 3;

uint vertexOffset = meshlets[mi].dataOffset;
uint indexOffset = vertexOffset + vertexCount;

然后,我们处理活动网格片的顶点:

for (uint i = ti; i < vertexCount; i += 32)
{
    uint vi = meshletData[vertexOffset + i];

vec3 position = vec3(vertex_positions[vi].v.x, 
   vertex_positions[vi].v.y, 
   vertex_positions[vi].v.z); 

    // normals, tangents, etc.

    gl_MeshVerticesNV[ i ].gl_Position = view_projection * 
        (model * vec4(position, 1));

    mesh_draw_index[ i ] = meshlets[mi].mesh_index;
}

注意我们正在写入gl_MeshVerticesNV变量。这个变量被 GPU 用来跟踪我们输出的顶点和它们的索引。这些数据随后将被光栅化器用来在屏幕上绘制生成的三角形。

接下来,我们写入索引:

uint indexGroupCount = (indexCount + 3) / 4;

for (uint i = ti; i < indexGroupCount; i += 32)
{
    writePackedPrimitiveIndices4x8NV(i * 4, 
        meshletData[indexOffset + i]);
}

writePackedPrimitiveIndices4x8NV指令是专门为网格着色器引入的,它允许它们一次写入四个索引。正如我们之前提到的,索引只需要 1 个字节来存储,因为我们不能有大于 64 的值。它们被打包到meshletData中,这是一个无符号int数组。

如果索引以不同的格式存储,我们就需要将它们逐个写入到gl_PrimitiveIndicesNV变量中。

最后,我们在适当的变量中写入原语计数:

if (ti == 0)
    gl_PrimitiveCountNV = uint(meshlets[mi].triangleCount);

这就完成了我们的网格着色器实现。

在本节中,我们概述了任务和网格着色器的工作原理以及它们与计算着色器的关系。接下来,我们提供了我们的任务和网格着色器实现的概述,并突出了与常规顶点着色器的主要区别。

在下一节中,我们将通过添加遮挡裁剪来扩展我们的实现。

使用计算进行 GPU 裁剪

在上一节中,我们展示了如何在网格片中执行背面和视锥裁剪。在本节中,我们将使用计算着色器实现视锥和遮挡裁剪。

根据渲染管线,遮挡裁剪通常通过深度预扫描来完成,我们只写入深度缓冲区。深度缓冲区随后可以在 G-Buffer 扫描期间使用,以避免着色那些我们已经知道被遮挡的片段。

这种方法的缺点是我们必须绘制场景两次,除非有其他工作可以与深度预遍历重叠,否则必须等待深度预遍历完成才能进行下一步。

本节中描述的算法最初在advances.realtimerendering.com/s2015/aaltonenhaar_siggraph2015_combined_final_footer_220dpi.pdf上提出。

这里是如何工作的:

  1. 使用上一帧的深度缓冲区,我们渲染场景中的可见对象,并执行网格和网格集的视锥体和遮挡剔除。这可能导致错误的否定,例如,在本帧中可见但之前不可见的网格或网格集。我们存储这些对象的列表,以便在下一阶段解决任何错误的肯定。

  2. 上一步在计算着色器中直接生成了一组绘制命令。此列表将用于使用间接绘制命令绘制可见对象。

  3. 现在我们有了更新的深度缓冲区,我们也更新了深度金字塔。

  4. 我们现在可以重新测试第一阶段中剔除的对象,并生成一个新的绘制列表以删除任何错误的肯定。

  5. 我们绘制剩余的对象并生成最终的深度缓冲区。然后,它将被用作下一帧的起点,这个过程将重复。

现在我们对遮挡算法的步骤有了更好的理解,让我们详细看看它是如何实现的。

深度金字塔生成

当描述遮挡算法时,我们提到了深度缓冲区的使用。然而,我们并没有直接使用深度缓冲区。我们使用的是所谓的深度金字塔。你可以将其视为深度缓冲区的 MIP 映射。

与传统的 MIP 映射相比,我们不能使用双线性插值来计算低级。如果我们使用常规插值,我们将计算场景中不存在的深度值。

注意

正如我们将在本书后面看到的那样,这通常适用于采样深度纹理。你应该使用最近邻采样或具有 min/max 比较操作的特殊采样器。有关更多信息,请参阅www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VkSamplerReductionMode.xhtml

相反,我们读取我们想要减少的四个片段,并选择最大值。我们选择最大值是因为我们的深度值从01,我们需要确保覆盖所有值范围。如果你使用反转 z,深度值从10,因此必须使用最小值。

我们使用计算着色器执行此步骤。我们首先将深度纹理转换为读取状态:

util_add_image_barrier( gpu, gpu_commands->
    vk_command_buffer, depth_texture, 
        RESOURCE_STATE_SHADER_RESOURCE, 0, 1, true );

然后,我们遍历深度金字塔的级别:

u32 width = depth_pyramid_texture->width;
u32 height = depth_pyramid_texture->
    height for ( u32 mip_index = 0; mip_index < 
    depth_pyramid_texture->mipmaps; ++mip_index ) {
    util_add_image_barrier( gpu, gpu_commands->
    vk_command_buffer, depth_pyramid_texture->
    vk_image, RESOURCE_STATE_UNDEFINED, 
    RESOURCE_STATE_UNORDERED_ACCESS, 
    mip_index, 1, false );

在前面的示例中,屏障是必需的,以确保我们写入的图像被正确设置。接下来,我们计算这一级的组大小并调用计算着色器:

    u32 group_x = ( width + 7 ) / 8;
    u32 group_y = ( height + 7 ) / 8;

    gpu_commands->dispatch( group_x, group_y, 1 );

如我们稍后将看到的,计算着色器的线程组大小设置为 8x8。我们必须考虑到这一点来计算正确的组大小。

最后,我们将当前级的图像进行转换,以便在下一迭代中安全地读取它:

    util_add_image_barrier( gpu, gpu_commands->
        vk_command_buffer, depth_pyramid_texture->
        vk_image, RESOURCE_STATE_UNORDERED_ACCESS, 
        RESOURCE_STATE_SHADER_RESOURCE, mip_index, 
        1, false );

    width /= 2;
    height /= 2;
}

我们还将宽度和高度更新为与下一级的大小匹配。计算着色器的实现相对简单:

ivec2 texel_position00 = ivec2( gl_GlobalInvocationID.xy )
    * 2;
ivec2 texel_position01 = texel_position00 + ivec2(0, 1);
ivec2 texel_position10 = texel_position00 + ivec2(1, 0);
ivec2 texel_position11 = texel_position00 + ivec2(1, 1);

我们首先计算我们想要减少的 texels 的位置。然后,我们读取这些 texels 的深度值:

float color00 = texelFetch( src, texel_position00, 0 ).r;
float color01 = texelFetch( src, texel_position01, 0 ).r;
float color10 = texelFetch( src, texel_position10, 0 ).r;
float color11 = texelFetch( src, texel_position11, 0 ).r;

最后,我们计算最大值并将其存储在金字塔中下一级的正确位置:

float result = max( max( max( color00, color01 ), 
    color10 ), color11 );
imageStore( dst, ivec2( gl_GlobalInvocationID.xy ), 
    vec4( result, 0, 0, 0 ) );

max操作是必需的,因为深度从0(靠近相机)到1(远离相机)。当使用inverse-depth时,应将其设置为min。在降采样时,我们希望取四个样本中最远的,以避免过度遮挡。

现在我们已经计算了深度金字塔,让我们看看它将如何用于遮挡剔除。

遮挡剔除

这一步的实现完全在计算着色器中完成。我们将突出显示代码的主要部分。我们首先加载当前网格:

uint mesh_draw_index = 
   mesh_instance_draws[mesh_instance_index] 
   .mesh_draw_index; 

MeshDraw mesh_draw = mesh_draws[mesh_draw_index];

mat4 model = 
   mesh_instance_draws[mesh_instance_index].model;

接下来,我们计算视空间中边界球的位置和半径:

vec4 bounding_sphere = mesh_bounds[mesh_draw_index];

vec4 world_bounding_center = model * 
    vec4(bounding_sphere.xyz, 1);
vec4 view_bounding_center = world_to_camera * 
    world_bounding_center;

float scale = length( model[0] );
float radius = bounding_sphere.w * scale;

注意,这是整个网格的边界球,而不是 meshlet。我们将以相同的方式处理 meshlets。

下一步是对边界球执行视锥剔除。这与我们在实现任务着色器部分中展示的代码相同,我们不会在这里重复它。

如果网格通过视锥剔除,我们将检查遮挡剔除。首先,我们计算透视投影球的边界矩形。这一步是必要的,因为投影球的形状可能是椭球体。我们的实现基于这篇论文:jcgt.org/published/0002/02/05/ 和 Niagara 项目 (github.com/zeux/niagara/)。

我们将仅突出显示最终的实现;我们建议阅读完整论文以获取更多关于理论和推导的细节。

我们首先检查球体是否完全位于近平面后面。如果是这样,则不需要进一步处理:

bool project_sphere(vec3 C, float r, float znear, 
    float P00, float P11, out vec4 aabb) {
        if (-C.z - r < znear)
        return false;

为什么是–C.z?因为在我们的实现中,我们查看一个负方向向量,因此可见像素的z始终是负的。

接下来,我们计算x轴上的最小和最大点。我们这样做是通过仅考虑xz平面,找到球体在此平面上的投影,并计算该投影的最小和最大x坐标:

vec2 cx = vec2(C.x, -C.z);
vec2 vx = vec2(sqrt(dot(cx, cx) - r * r), r);
vec2 minx = mat2(vx.x, vx.y, -vx.y, vx.x) * cx;
vec2 maxx = mat2(vx.x, -vx.y, vx.y, vx.x) * cx;

我们对y坐标重复相同的程序(此处省略)。计算出的点在世界空间中,但我们需要它们在透视投影空间中的值。这是通过以下代码实现的:

aabb = vec4(minx.x / minx.y * P00, miny.x / miny.y * P11, 
       maxx.x / maxx.y * P00, maxy.x / maxy.y * P11);

P00P11是视图-投影矩阵的前两个对角值。最后一步是将这些值从屏幕空间转换到 UV 空间。在 UV 空间中操作将有助于算法的下一部分。

变换是通过以下代码执行的:

aabb = aabb.xwzy * vec4(0.5f, -0.5f, 0.5f, -0.5f) + 
vec4(0.5f);

屏幕空间中的坐标在[-1, 1]范围内,而 UV 坐标在[0, 1]范围内。这种变换执行一个范围到另一个范围的映射。我们使用负偏移量y,因为屏幕空间有一个左下角的起点,而 UV 空间有一个左上角的起点。

现在我们有了网格球体的二维边界框,我们可以检查它是否被遮挡。首先,我们确定应该使用深度金字塔的哪个级别:

ivec2 depth_pyramid_size = 
   textureSize(global_textures[nonuniformEXT 
   (depth_pyramid_texture_index)], 0); 
float width = (aabb.z - aabb.x) * depth_pyramid_size.x ;
float height = (aabb.w - aabb.y) * depth_pyramid_size.y ;

float level = floor(log2(max(width, height)));

我们简单地通过深度金字塔纹理的顶层大小来缩放之前步骤中计算的边界框大小。然后,我们取宽度和高度中最大的对数的对数,以确定我们应该使用金字塔的哪个级别来进行深度值查找。

通过这一步,我们将边界框减少到单个像素查找。记住,在计算金字塔的级别时,减少步骤存储最远的深度值。正因为如此,我们可以安全地查找单个片段以确定边界框是否被遮挡。

这是通过以下代码实现的:

float depth = 
   textureLod(global_textures[nonuniformEXT 
   (depth_pyramid_texture_index)], (aabb.xy + aabb.zw) 
   0.5, level).r;

首先,我们在金字塔中查找球体边界框的深度值。接下来,我们计算边界球体的最近深度。

我们还计算边界球体的最近深度:

float depth_sphere = z_near / (view_bounding_center.z – 
                     radius); 

最后,我们通过将球体的深度与从金字塔中读取的深度进行比较来确定球体是否被遮挡:

occlusion_visible = (depth_sphere <= depth);

如果网格通过了视锥体和遮挡剔除,我们就将绘制命令添加到命令列表中:

draw_commands[draw_index].drawId = mesh_instance_index;
draw_commands[draw_index].taskCount = 
    (mesh_draw.meshlet_count + 31) / 32;
draw_commands[draw_index].firstTask = 
    mesh_draw.meshlet_offset / 32;

然后,我们将使用这个命令列表来绘制可见网格的 meshlets(如理解任务和网格着色器部分所示)并更新深度金字塔。

最后一步将是重新运行第一次遍历中丢弃的网格的剔除。使用更新的深度金字塔,我们可以生成一个新的命令列表来绘制任何被错误剔除的网格。

这就完成了我们的遮挡剔除实现。在本节中,我们解释了一个在 GPU 上高效执行遮挡剔除的算法。我们首先详细说明了该技术执行的步骤。

我们随后突出了代码中执行深度金字塔创建的主要部分,该金字塔用于基于每个网格的边界球体进行遮挡剔除。

在 GPU 上执行剔除是一种强大的技术,它帮助开发者克服了传统几何管道的一些限制,并使我们能够渲染更复杂和详细的场景。

摘要

在本章中,我们介绍了网格块的概念,这是一种帮助我们将大型网格分解成更易管理的块,并可用于在 GPU 上执行遮挡计算的构造。我们展示了如何使用我们选择的库(MeshOptimizer)生成网格块,我们还展示了对于遮挡操作有用的额外数据结构(锥体和边界球体)。

我们引入了网格和任务着色器。从概念上讲,它们与计算着色器相似,允许我们在 GPU 上快速处理网格块。我们展示了如何使用任务着色器执行背面和视锥剔除,以及如何通过并行处理和生成多个原语来替换顶点着色器。

最后,我们探讨了遮挡剔除的实现。我们首先列出了组成这项技术的步骤。接下来,我们展示了如何从现有的深度缓冲区计算深度金字塔。最后,我们分析了遮挡剔除的实现,并突出了计算着色器中最相关的部分。这一步还生成了一组可用于间接绘制调用的命令。

到目前为止,我们的场景只使用了一盏灯。在下一章中,我们将实现集群延迟光照,这将使我们能够在场景中渲染数百盏灯。

进一步阅读

正如我们在前面的章节中提到的,任务和网格着色器仅在 Nvidia GPU 上可用。这篇博客文章有更多关于它们内部工作原理的细节:developer.nvidia.com/blog/introduction-turing-mesh-shaders/

我们的实现受到了这些资源中描述的算法和技术的大力启发:

我们用于任务和网格着色器的首选参考实现是这个项目:github.com/zeux/niagara,该项目还附带了一系列展示其开发的视频:www.youtube.com/playlist?list=PL0JVLUVCkk-l7CWCn3-cdftR0oajugYvd

这些库可以用来生成网格块:

在遮挡剔除领域的一个较新的发展是可见性缓冲区概念。该技术在这些资源中进行了详细描述:

第七章:使用集群延迟渲染渲染许多光源

到目前为止,我们的场景一直是由一个点光源照亮的。虽然到目前为止这已经足够好了,因为我们更关注于构建渲染引擎的基础,但这并不是一个非常吸引人和逼真的用例。现代游戏在给定场景中可以拥有数百个光源,并且高效地执行照明阶段并在帧预算内完成是非常重要的。

在本章中,我们将首先描述在延迟和前向着色中常用的最常见技术。我们将突出每种技术的优缺点,以便您可以确定哪种最适合您的需求。

接下来,我们将概述我们的 G 缓冲区设置。虽然 G 缓冲区从一开始就存在,但我们还没有详细讨论其实现。现在是深入了解的好时机,因为延迟渲染器的选择将影响我们聚类照明的策略。

最后,我们将详细描述我们的聚类算法,并突出代码中的相关部分。虽然算法本身并不复杂,但有很多细节对于获得稳定的解决方案非常重要。

在本章中,我们将介绍以下主要主题:

  • 集成照明的简史

  • 我们的 G 缓冲区设置和实现

  • 使用屏幕瓦片和 Z 分箱实现集群照明

技术要求

到本章结束时,您将对我们 G 缓冲区实现有一个稳固的理解。您还将学习如何实现一个能够处理数百个光源的尖端光聚类解决方案。

本章的代码可以在以下网址找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter7

集成照明的简史

在本节中,我们将探讨集群照明是如何产生的以及它是如何随着时间演变的背景。

在实时应用中,直到 2000 年代初,处理照明最常见的方式是使用所谓的前向渲染,这是一种在每个屏幕对象上渲染所有所需信息的技术,包括光照信息。这种方法的缺点是它将能够处理的光源数量限制在一个很低的数字,例如 4 或 8,在 2000 年代初这个数字已经足够了。

延迟渲染的概念,以及更具体地说,只对同一个像素进行一次着色,早在 1988 年由迈克尔·德林及其同事在一篇开创性的论文《三角形处理器和法线向量着色器:一种用于高性能图形的 VLSI 系统》(The triangle processor and normal vector shader: a VLSI system for high performance graphics)中就已经被提出,尽管当时还没有使用“延迟”这个术语。

另一个关键概念,G 缓冲区几何缓冲区,是由 Saito Takafumi 和 Takahashi Tokiichiro 在另一篇开创性的论文《3D 形状的可理解渲染》中提出的。在这篇论文中,作者为每个像素缓存深度和法线以进行后处理图像——在这种情况下,是为了向图像添加视觉辅助和可理解性。

尽管第一款具有延迟渲染器的商业游戏是 2001 年在原始 Xbox 上的《Shrek》,但随着游戏《Stalker》及其配套论文《Stalker 中的延迟着色》(developer.nvidia.com/gpugems/gpugems2/part-ii-shading-lighting-and-shadows/chapter-9-deferred-shading-stalker)的流行,以及 2010 年 Siggraph 上 CryEngine 演示的《达到光速》(advances.realtimerendering.com/s2010/Kaplanyan-CryEngine3%28SIGGRAPH%202010%20Advanced%20RealTime%20Rendering%20Course%29.pdf),它变得越来越受欢迎。

在 2000 年代末/2010 年代初,延迟渲染非常流行,基本上,所有引擎都在实现它的某种变体。

当 AMD 在 2012 年推出名为“Leo”的演示时,前向渲染在 2012 年卷土重来,得益于新的计算着色器技术,他们为每个屏幕空间瓦片引入了光列表并创建了Forward+

AMD Leo 论文可以在这里找到:takahiroharada.files.wordpress.com/2015/04/forward_plus.pdf.

在那篇论文发表后的几周,第一款使用 Forward+的商业游戏是《Dirt Showdown》,但仅限于 PC 版本,因为游戏机仍然没有支持该领域的 API:web.archive.org/web/20210621112015/https://www.rage3d.com/articles/gaming/codemaster_dirt_showdown_tech_review/

随着,前向+技术重新得到应用,因为光限制已经消失,并且它在不同领域增加了许多算法探索(例如,用于延迟深度预处理的后期处理抗锯齿)。

在接下来的几年里,开发了更精细的细分算法,瓦片变成了簇,从简单的 2D 屏幕空间瓦片发展到完全的视锥体形状的 3D 簇。

这个概念随着 Emil Persson 的《Just Cause 3》论文而闻名,www.humus.name/Articles/PracticalClusteredShading.pdf,其他人进一步增强了这个概念,用于延迟和前向渲染(www.cse.chalmers.se/~uffe/clustered_shading_preprint.pdf)。

聚类是一个很好的想法,但拥有 3D 网格的内存消耗可能很大,尤其是在渲染分辨率不断提高的情况下。

当前聚类技术的先进状态来自 Activision,这是我们选择解决方案,我们将在本章的“实现轻量级聚类”部分详细看到它。

现在我们已经提供了实时光照渲染技术的简要历史概述,我们将在下一节更深入地探讨前向渲染和延迟渲染之间的区别。

前向和延迟技术之间的区别

在讨论了前向渲染和延迟渲染技术的历史之后,我们想强调它们的关键区别,并讨论它们共同的问题:光照分配

前向渲染的主要优点如下:

  • 渲染材料时的完全自由

  • 不透明和透明对象相同的渲染路径

  • 支持多采样抗锯齿MSAA

  • GPU 内的内存带宽较低

前向渲染的主要缺点如下:

  • 可能需要深度预处理步骤来减少着色片段的数量。如果没有这个预处理步骤,包含大量对象的场景可能会浪费大量处理时间,因为它们着色了不可见对象的片段。因此,在帧开始时执行一个仅写入深度缓冲区的步骤。

然后将深度测试函数设置为相等,这样只有可见对象的片段才会着色。根据场景的复杂度,这个预处理步骤可能会很昂贵,在某些情况下,使用简化几何体来降低此步骤的成本,但会略微降低结果的准确性。你还必须小心,并确保在图形管线中未禁用早期 Z 测试。

这发生在从片段着色器写入深度缓冲区时,或者当片段着色器包含丢弃指令时。

  • 场景着色的复杂性是对象数量(N)乘以光照数量(L)。由于我们事先不知道哪些光照会影响给定的片段,因此必须为每个对象处理所有光照。

  • 着色器变得越来越复杂,需要执行大量操作,因此 GPU 寄存器压力(使用的寄存器数量)非常高,影响性能。

延迟渲染(有时被称为延迟着色)主要是为了解耦几何形状的渲染和光照计算。在延迟渲染中,我们创建多个渲染目标。通常,我们有一个用于漫反射、法线、PBR 参数(粗糙度、金属度和遮挡 - 见第二章改进资源管理,了解更多详情)和深度的渲染目标。

一旦创建了这些渲染目标,对于每个片段,我们处理场景中的灯光。我们仍然面临之前的问题,因为我们仍然不知道哪些灯光影响给定的着色器;然而,我们的场景复杂度已从N x L变为N + L

延迟着色的主要优势如下:

  • 降低着色复杂性

  • 无需深度预遍历

  • 较简单的着色器,因为将信息写入 G 缓冲区和处理灯光是分开的操作

然而,这种方法也有一些缺点,如下:

  • 高内存使用:我们列出了三个必须存储在内存中的渲染目标。随着现代游戏分辨率的提高,这些开始累积,尤其是在需要更多渲染目标用于其他技术时——例如,用于时间反走样TAA)的运动矢量,这将在后面的章节中讨论。因此,开发者倾向于压缩一些数据,这有助于减少 G 缓冲区所需的内存量。

  • 法线精度损失:法线通常作为完整浮点数(或可能作为 16 位浮点数)作为几何的一部分进行编码。在写入法线渲染目标时为了节省内存,这些值被压缩到 8 位,显著降低了这些值的精度。

为了进一步减少内存使用,开发者利用法线是归一化的这一事实。这允许我们只存储两个值并重建第三个值。还有其他可以用来压缩法线的技术,这些将在进一步阅读部分中提及。我们将在下一节中详细解释我们使用的技术。

  • 透明物体需要一个单独的遍历,并且需要使用前向技术进行着色。

  • 特殊材质需要将所有参数打包到 G 缓冲区中。

如您可能已注意到的,这两个技术都存在一个共同问题:在处理单个对象或片段时,我们必须遍历所有灯光。我们现在将描述两种最常用的解决此问题的技术:瓦片和簇。

光瓦片

减少给定片段处理灯光数量的一个方法是在屏幕空间中创建一个网格,并确定哪些灯光影响给定的瓦片。在渲染场景时,我们确定我们正在着色的片段属于哪个瓦片,并且只遍历覆盖该瓦片的灯光。

下图显示了场景中一个灯光(绿色球体)及其覆盖的屏幕区域(黄色)。我们将使用这些数据来确定哪些瓦片受到给定灯光的影响。

图 7.1 – 屏幕空间中点光源覆盖的区域

图 7.1 – 屏幕空间中点光源覆盖的区域

瓦片的构建可以在 CPU 上完成,也可以在 GPU 上的计算着色器中完成。瓦片数据可以存储在一个扁平数组中;我们将在本章后面更详细地解释这个数据结构。

传统光源瓦片需要深度预遍历来确定最小和最大 Z 值。这种方法可能会出现深度不连续性;然而,最终的数据结构通常是密集排列的,这意味着我们没有浪费内存。

光簇

光簇将视锥体细分为 3D 网格。至于瓦片,光源被分配到每个单元格,在渲染时,我们只遍历给定片段所属的灯光。

下图说明了相机轴之一簇的形状。每个簇由一个较小的视锥体组成:

图 7.2 – 被点光源覆盖的视锥体簇

图 7.2 – 被点光源覆盖的视锥体簇

光源可以存储在 3D 网格中(例如,3D 纹理)或更复杂的数据结构中——例如,边界体积层次BVH)或八叉树。

构建光簇时,我们不需要深度预遍历。大多数实现为每个光源构建轴对齐边界框AABBs),并将它们投影到裁剪空间。这种方法允许轻松的 3D 查找,并且根据可以分配给数据结构的内存量,可以实现相当准确的结果。

在本节中,我们强调了前向和延迟渲染的优缺点。我们介绍了可以帮助减少每个片段需要处理的灯光数量的分块和聚类技术。

在下一节中,我们将概述我们的 G 缓冲区实现。

实现 G 缓冲区

从这个项目的开始,我们就决定要实现一个延迟渲染器。这是更常见的方法之一,其中一些渲染目标将在后面的章节中用于其他技术:

  1. 在 Vulkan 中设置多个渲染目标的第一步是创建帧缓冲区——存储 G 缓冲区数据的纹理——和渲染通道。

这一步骤是自动化的,归功于帧图(参见第四章**,实现帧图,详情);然而,我们想强调我们使用了一个简化渲染通道和帧缓冲区创建的新 Vulkan 扩展。该扩展是VK_KHR_dynamic_rendering

注意

这个扩展已成为 Vulkan 1.3 的核心规范的一部分,因此可以在数据结构和 API 调用中省略KHR后缀。

  1. 使用这个扩展,我们不必担心提前创建渲染通道和帧缓冲区。我们将首先分析创建管道时所需的变化:

    VkPipelineRenderingCreateInfoKHR pipeline_rendering_create_info{
    
      VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR };
    
    pipeline_rendering_create_info.viewMask = 0;
    
    pipeline_rendering_create_info.colorAttachmentCount =
    
        creation.render_pass.num_color_formats;
    
    pipeline_rendering_create_info.pColorAttachmentFormats
    
        = creation.render_pass.num_color_formats > 0 ?
    
            creation.render_pass.color_formats : nullptr;
    
    pipeline_rendering_create_info.depthAttachmentFormat =
    
        creation.render_pass.depth_stencil_format;
    
    pipeline_rendering_create_info.stencilAttachmentFormat
    
        = VK_FORMAT_UNDEFINED;
    
    pipeline_info.pNext = &pipeline_rendering_create_info;
    

我们必须填充一个VkPipelineRenderingCreateInfoKHR结构,其中包含我们将要使用的附件数量及其格式。我们还需要指定深度和模板格式,如果使用的话。

一旦填充了这个结构,我们就将其链接到 VkGraphicsPipelineCreateInfo 结构。当使用此扩展时,我们不填充 VkGraphicsPipelineCreateInfo::renderPass 成员。

  1. 在渲染时,我们不是调用 vkCmdBeginRenderPass,而是调用一个新的 API,vkCmdBeginRenderingKHR。我们首先创建一个数组来保存我们的 attachments 详细信息:

    Array<VkRenderingAttachmentInfoKHR> color_attachments_info;
    
    color_attachments_info.init( device->allocator,
    
        framebuffer->num_color_attachments,
    
            framebuffer->num_color_attachments );
    
  2. 接下来,我们为每个条目填充每个附加的详细信息:

    for ( u32 a = 0; a < framebuffer->
    
        num_color_attachments; ++a ) {
    
            Texture* texture = device->
    
                access_texture( framebuffer->
    
                    color_attachments[a] );
    
        VkAttachmentLoadOp color_op = ...;
    
    VkRenderingAttachmentInfoKHR&
    
    color_attachment_info = color_attachments_info[ a ];
    
    color_attachment_info.sType =
    
        VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR;
    
    color_attachment_info.imageView = texture->
    
        vk_image_view;
    
    color_attachment_info.imageLayout =
    
        VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    
    color_attachment_info.resolveMode =
    
        VK_RESOLVE_MODE_NONE;
    
    color_attachment_info.loadOp = color_op;
    
    color_attachment_info.storeOp =
    
        VK_ATTACHMENT_STORE_OP_STORE;
    
    color_attachment_info.clearValue = render_pass->
    
        output.color_operations[ a ] ==
    
            RenderPassOperation::Enum::Clear ? clears[ 0 ]
    
                : VkClearValue{ };
    
    }
    
  3. 我们必须填充一个类似的数据结构用于 depth 附加:

    VkRenderingAttachmentInfoKHR depth_attachment_info{
    
        VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR };
    
    bool has_depth_attachment = framebuffer->
    
        depth_stencil_attachment.index != k_invalid_index;
    
    if ( has_depth_attachment ) {
    
        Texture* texture = device->access_texture(
    
            framebuffer->depth_stencil_attachment );
    
        VkAttachmentLoadOp depth_op = ...;
    
    depth_attachment_info.imageView = texture->
    
        vk_image_view;
    
    depth_attachment_info.imageLayout =
    
        VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
    
    depth_attachment_info.resolveMode =
    
        VK_RESOLVE_MODE_NONE;
    
    depth_attachment_info.loadOp = depth_op;
    
    depth_attachment_info.storeOp =
    
        VK_ATTACHMENT_STORE_OP_STORE;
    
    depth_attachment_info.clearValue = render_pass->
    
        output.depth_operation ==
    
            RenderPassOperation::Enum::Clear ? clears[ 1 ]
    
                : VkClearValue{ };
    
    }
    
  4. 最后,我们填充 VkRenderingInfoKHR 结构,该结构将被传递给 vkCmdBeginRenderingKHR

    VkRenderingInfoKHR rendering_info{
    
        VK_STRUCTURE_TYPE_RENDERING_INFO_KHR };
    
    rendering_info.flags = use_secondary ?
    
        VK_RENDERING_CONTENTS_SECONDARY_COMMAND
    
            _BUFFERS_BIT_KHR : 0;
    
    rendering_info.renderArea = { 0, 0, framebuffer->
    
        width, framebuffer->height };
    
    rendering_info.layerCount = 1;
    
    rendering_info.viewMask = 0;
    
    rendering_info.colorAttachmentCount = framebuffer->
    
        num_color_attachments;
    
    rendering_info.pColorAttachments = framebuffer->
    
        num_color_attachments > 0 ?
    
            color_attachments_info.data : nullptr;
    
    rendering_info.pDepthAttachment =
    
        has_depth_attachment ? &depth_attachment_info :
    
            nullptr;
    
    rendering_info.pStencilAttachment = nullptr;
    

一旦渲染完成,我们将调用 vkCmdEndRenderingKHR 而不是 vkCmdEndRenderPass

现在我们已经设置了渲染目标,我们将描述它们在我们 G 缓冲区着色器中的使用。我们的 G 缓冲区有四个渲染目标加上深度缓冲区。正如我们在上一节中提到的,不需要深度预传递,尽管您可能会注意到在早期的一些章节中为了测试目的已经启用了它。

第一步是在片段着色器中声明多个输出:

layout (location = 0) out vec4 color_out;
layout (location = 1) out vec2 normal_out;
layout (location = 2) out vec4
    occlusion_roughness_metalness_out;
layout (location = 3) out vec4 emissive_out;

位置索引必须与调用 vkCmdBeginRenderingKHR(或创建渲染通道和帧缓冲区对象)时附加的顺序相对应。向给定的渲染目标写入是通过写入我们刚刚声明的变量之一来完成的:

colour_out = texture(global_textures[nonuniformEXT
    (albedo_texture)], uv);

正如我们在上一节中提到的,我们必须注意内存使用。如您所注意到的,我们只为法线存储两个通道。我们使用八面体编码,只允许存储两个值。我们可以在照明通道中重建完整的法线。

这里是编码函数:

vec2 octahedral_encode(vec3 n) {
    // Project the sphere onto the octahedron, and then
       onto the xy plane
    vec2 p = n.xy * (1.0f / (abs(n.x) + abs(n.y) +
        abs(n.z)));
    // Reflect the folds of the lower hemisphere over the
       diagonals
    return (n.z < 0.0f) ? ((1.0 - abs(p.yx)) *
        sign_not_zero(p)) : p;
}

以及解码函数:

vec3 octahedral_decode(vec2 f) {
    vec3 n = vec3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y));
    float t = max(-n.z, 0.0);
    n.x += n.x >= 0.0 ? -t : t;
    n.y += n.y >= 0.0 ? -t : t;
    return normalize(n);
}

以下表格展示了我们 G 缓冲区传递的数据排列:

表 7.1 – G 缓冲区内存布局

表 7.1 – G 缓冲区内存布局

这里是我们渲染目标的截图:

图 7.3 – 从上到下:反射率、法线和组合遮挡(红色)、粗糙度(绿色)和金属度(蓝色)

图 7.3 – 从上到下:反射率、法线和组合遮挡(红色)、粗糙度(绿色)和金属度(蓝色)

我们可能进一步减少渲染目标的数量:我们知道在 G 缓冲区传递中,我们只对不透明对象进行着色,因此不需要 alpha 通道。也没有什么阻止我们混合不同渲染目标的数据——例如,我们可能有以下内容:

  • rgbnormal_1

  • normal_2roughnessmetalnessocclusion

  • emissive

我们还可以尝试使用不同的纹理格式(例如 R11G11B10)来提高我们数据精度。我们鼓励您尝试不同的解决方案,并找到最适合您用例的方法!

在本节中,我们介绍了一个新的 Vulkan 扩展,该扩展简化了渲染通道和帧缓冲区的创建和使用。我们还提供了我们 G 缓冲区实现的详细信息,并强调了潜在的优化。在下一节中,我们将查看我们已实现的光簇解决方案。

实现光簇

在本节中,我们将描述我们的光簇算法实现。它基于这个演示:www.activision.com/cdn/research/2017_Sig_Improved_Culling_final.pdf。主要(并且非常聪明)的想法是将 XY 平面与 Z 范围分开,结合了瓦片和聚类方法的优点。算法组织如下:

  1. 我们按相机空间中的深度值对灯光进行排序。

  2. 然后,我们将深度范围划分为大小相等的箱,尽管根据您的深度范围,对数划分可能更好。

  3. 接下来,如果它们的边界框在箱范围内,我们将灯光分配给每个箱。我们只为给定箱存储最小和最大光索引,因此每个箱只需要 16 位,除非你需要超过 65,535 个灯光!

  4. 然后,我们将屏幕划分为瓦片(在我们的例子中是 8x8 像素)并确定哪些灯光覆盖了给定的瓦片。每个瓦片将存储活动灯光的位字段表示。

  5. 对于我们想要着色的片段,我们确定片段的深度并读取箱索引。

  6. 最后,我们从该箱中的最小光索引迭代到最大光索引,并读取相应的瓦片以查看光是否可见,这次使用 xy 坐标来检索瓦片。

此解决方案为遍历给定片段的活动灯光提供了一种非常有效的方法。

CPU 灯光分配

现在,我们将查看实现过程。在每一帧中,我们执行以下步骤:

  1. 我们首先根据它们的深度值对灯光进行排序:

    float z_far = 100.0f;
    
    for ( u32 i = 0; i < k_num_lights; ++i ) {
    
        Light& light = lights[ i ];
    
        vec4s p{ light.world_position.x,
    
            light.world_position.y,
    
                light.world_position.z, 1.0f };
    
        vec3s p_min = glms_vec3_add( light.world_position,
    
            glms_vec3_scale(
    
                light_camera_dir,
    
                    -light.radius ) );
    
        vec3s p_max = glms_vec3_add( light.world_position,
    
            glms_vec3_scale(
    
                light_camera_dir,
    
                    light.radius ) );
    
        vec4s projected_p = glms_mat4_mulv(
    
            world_to_camera, p );
    
        vec4s projected_p_min = glms_mat4_mulv(
    
            world_to_camera, p_min4 );
    
        vec4s projected_p_max = glms_mat4_mulv(
    
            world_to_camera, p_max4 );
    
       SortedLight& sorted_light = sorted_lights[ i ];
    
        sorted_light.light_index = i;
    
        sorted_light.projected_z = ( -projected_p.z –
    
            scene_data.z_near ) / ( z_far –
    
                scene_data.z_near );
    
        sorted_light.projected_z_min = ( -
    
            projected_p_min.z - scene_data.z_near ) / (
    
                z_far - scene_data.z_near );
    
        sorted_light.projected_z_max = ( -
    
            projected_p_max.z - scene_data.z_near ) / (
    
                z_far - scene_data.z_near );
    
    }
    

我们从相机的视角计算光球的最小和最大点。请注意,我们使用较近的 far 深度平面以在深度范围内获得精度。

  1. 为了避免对灯光列表进行排序,我们只对光索引进行排序:

    qsort( sorted_lights.data, k_num_lights, sizeof(
    
        SortedLight ), sorting_light_fn );
    
    u32* gpu_light_indices = ( u32* )gpu.map_buffer(
    
        cb_map );
    
    if ( gpu_light_indices ) {
    
        for ( u32 i = 0; i < k_num_lights; ++i ) {
    
            gpu_light_indices[ i ] = sorted_lights[ i ]
    
                .light_index;
    
        }
    
        gpu.unmap_buffer( cb_map );
    
    }
    

这种优化使我们只需上传一次光数组,而只需更新光索引。

  1. 接下来,我们进行瓦片分配。我们首先定义我们的位字段数组和一些辅助变量,这些变量将用于在数组中计算索引:

    Array<u32> light_tiles_bits;
    
    light_tiles_bits.init( context.scratch_allocator,
    
        tiles_entry_count, tiles_entry_count );
    
    float near_z = scene_data.z_near;
    
    float tile_size_inv = 1.0f / k_tile_size;
    
    u32 tile_stride = tile_x_count * k_num_words;
    
  2. 我们首先将相机空间中的光位置进行转换:

    for ( u32 i = 0; i < k_num_lights; ++i ) {
    
        const u32 light_index = sorted_lights[ i ]
    
            .light_index;
    
        Light& light = lights[ light_index ];
    
        vec4s pos{ light.world_position.x,
    
            light.world_position.y,
    
                light.world_position.z, 1.0f };
    
        float radius = light.radius;
    
        vec4s view_space_pos = glms_mat4_mulv(
    
            game_camera.camera.view, pos );
    
        bool camera_visible = view_space_pos.z - radius <
    
            game_camera.camera.near_plane;
    
        if ( !camera_visible &&
    
            context.skip_invisible_lights ) {
    
            continue;
    
        }
    

如果光在相机后面,我们不做任何进一步的处理。

  1. 接下来,我们计算 AABB 的角点在裁剪空间中的投影:

    for ( u32 c = 0; c < 8; ++c ) {
    
        vec3s corner{ ( c % 2 ) ? 1.f : -1.f, ( c & 2 ) ?
    
            1.f : -1.f, ( c & 4 ) ? 1.f : -1.f };
    
        corner = glms_vec3_scale( corner, radius );
    
        corner = glms_vec3_add( corner, glms_vec3( pos ) );
    
        vec4s corner_vs = glms_mat4_mulv(
    
            game_camera.camera.view,
    
                glms_vec4( corner, 1.f ) );
    
        corner_vs.z = -glm_max(
    
            game_camera.camera.near_plane, -corner_vs.z );
    
        vec4s corner_ndc = glms_mat4_mulv(
    
            game_camera.camera.projection, corner_vs );
    
        corner_ndc = glms_vec4_divs( corner_ndc,
    
            corner_ndc.w );
    
        aabb_min.x = glm_min( aabb_min.x, corner_ndc.x );
    
        aabb_min.y = glm_min( aabb_min.y, corner_ndc.y );
    
        aabb_max.x = glm_max( aabb_max.x, corner_ndc.x );
    
        aabb_max.y = glm_max( aabb_max.y, corner_ndc.y );
    
    }
    
    aabb.x = aabb_min.x;
    
    aabb.z = aabb_max.x;
    
    aabb.w = -1 * aabb_min.y;
    
    aabb.y = -1 * aabb_max.y;
    
  2. 然后,我们继续确定屏幕空间中四边形的尺寸:

    vec4s aabb_screen{ ( aabb.x * 0.5f + 0.5f ) * (
    
        gpu.swapchain_width - 1 ),
    
        ( aabb.y * 0.5f + 0.5f ) * (
    
        gpu.swapchain_height - 1 ),
    
        ( aabb.z * 0.5f + 0.5f ) * (
    
        gpu.swapchain_width - 1 ),
    
        ( aabb.w * 0.5f + 0.5f ) *
    
        ( gpu.swapchain_height - 1 ) };
    
    f32 width = aabb_screen.z - aabb_screen.x;
    
    f32 height = aabb_screen.w - aabb_screen.y;
    
    if ( width < 0.0001f || height < 0.0001f ) {
    
        continue;
    
    }
    
    float min_x = aabb_screen.x;
    
    float min_y = aabb_screen.y;
    
    float max_x = min_x + width;
    
    float max_y = min_y + height;
    
    if ( min_x > gpu.swapchain_width || min_y >
    
        gpu.swapchain_height ) {
    
        continue;
    
    }
    
    if ( max_x < 0.0f || max_y < 0.0f ) {
    
        continue;
    
    }
    

如果光在屏幕上不可见,我们移动到下一个光。

  1. 最后一步是在所有覆盖的瓦片上为正在处理的灯光设置位:

    min_x = max( min_x, 0.0f );
    
    min_y = max( min_y, 0.0f );
    
    max_x = min( max_x, ( float )gpu.swapchain_width );
    
    max_y = min( max_y, ( float )gpu.swapchain_height );
    
    u32 first_tile_x = ( u32 )( min_x * tile_size_inv );
    
    u32 last_tile_x = min( tile_x_count - 1, ( u32 )(
    
        max_x * tile_size_inv ) );
    
    u32 first_tile_y = ( u32 )( min_y * tile_size_inv );
    
    u32 last_tile_y = min( tile_y_count - 1, ( u32 )(
    
        max_y * tile_size_inv ) );
    
    for ( u32 y = first_tile_y; y <= last_tile_y; ++y ) {
    
        for ( u32 x = first_tile_x; x <= last_tile_x; ++x
    
            ) {
    
                  u32 array_index = y * tile_stride + x;
    
                      u32 word_index = i / 32;
    
                          u32 bit_index = i % 32;
    
        light_tiles_bits[ array_index + word_index ] |= (
    
            1 << bit_index );
    
        }
    
    }
    

然后,我们将所有光瓦片和区间数据上传到 GPU。

在这个计算结束时,我们将有一个包含每个深度切片的最小和最大光 ID 的区间表。以下表格展示了前几个切片的值示例:

表 7.2 – 深度区间中包含的数据示例

表 7.2 – 深度区间中包含的数据示例

我们计算的其他数据结构是一个二维数组,其中每个条目包含一个位字段,用于跟踪对应屏幕瓦片的活动光。以下表格展示了这个数组的内容示例:

表 7.3 – 每个瓦片跟踪活动光值的位字段值示例

表 7.3 – 每个瓦片跟踪活动光值的位字段值示例

在前面的示例中,我们将屏幕划分为 4x4 的网格,每个瓦片条目都有一个位被设置,以表示覆盖该瓦片的光。请注意,每个瓦片条目可以由多个 32 位值组成,具体取决于场景中的光数量。

在本节中,我们概述了我们实现的光分配给给定聚类的算法。然后我们详细说明了实现算法的步骤。在下一节中,我们将使用我们刚刚获得的数据在 GPU 上处理光。

GPU 光处理

现在我们已经在 GPU 上有了所有需要的数据,我们可以在光照计算中使用它:

  1. 我们首先确定我们的片段属于哪个深度区间:

    vec4 pos_camera_space = world_to_camera * vec4(
    
        world_position, 1.0 );
    
    float z_light_far = 100.0f;
    
    float linear_d = ( -pos_camera_space.z - z_near ) / (
    
        z_light_far - z_near );
    
    int bin_index = int( linear_d / BIN_WIDTH );
    
    uint bin_value = bins[ bin_index ];
    
    uint min_light_id = bin_value & 0xFFFF;
    
    uint max_light_id = ( bin_value >> 16 ) & 0xFFFF;
    
  2. 我们提取最小和最大光索引,因为它们将在光计算循环中使用:

    uvec2 position = gl_GlobalInvocationID.xy;
    
    uvec2 tile = position / uint( TILE_SIZE );
    
    uint stride = uint( NUM_WORDS ) *
    
        ( uint( resolution.x ) / uint( TILE_SIZE ) );
    
    uint address = tile.y * stride + tile.x;
    
  3. 我们首先确定在瓦片位字段数组中的地址。接下来,我们检查这个深度区间中是否有光:

    if ( max_light_id != 0 ) {
    
        min_light_id -= 1;
    
        max_light_id -= 1;
    
  4. 如果max_light_id0,这意味着我们没有在这个区间存储任何光,因此没有光会影响这个片段。接下来,我们遍历这个深度区间的光:

        for ( uint light_id = min_light_id; light_id <=
    
            max_light_id; ++light_id ) {
    
                uint word_id = light_id / 32;
    
                uint bit_id = light_id % 32;
    
  5. 在我们计算单词和位索引后,我们确定深度区间中的哪些光也覆盖了屏幕瓦片:

            if ( ( tiles[ address + word_id ] &
    
                ( 1 << bit_id ) ) != 0 ) {
    
                    uint global_light_index =
    
                        light_indices[ light_id ];
    
                    Light point_light = lights[
    
                        global_light_index ];
    
                    final_color.rgb +=
    
                        calculate_point_light_contribution
    
                          ( albedo, orm, normal, emissive,
    
                              world_position, V, F0, NoV,
    
                                  point_light );
    
            }
    
        }
    
    }
    

这就完成了我们的光聚类算法。着色器代码还包含了一个优化的版本,它利用子组指令来提高寄存器利用率。代码中有很多注释来解释它是如何工作的。

在本节中,我们涵盖了相当多的代码,所以如果你在第一次阅读时对某些内容不太清楚,请不要担心。我们首先描述了算法的步骤。然后解释了光如何在深度区间中排序,以及我们如何确定覆盖屏幕上给定瓦片的光。最后,我们展示了这些数据结构如何在光照着色器中用来确定哪些光照影响给定的片段。

注意,这项技术既可用于前向渲染,也可用于延迟渲染。现在我们已经有了一个性能良好的光照解决方案,但我们的场景中仍然缺少一个重要元素:阴影!这将是下一章的主题。

概述

在本章中,我们实现了一个光聚类解决方案。我们首先解释了前向和延迟渲染技术及其主要优点和缺点。接下来,我们描述了两种将灯光分组以减少单个片段着色所需计算的方法。

我们首先通过列出我们使用的渲染目标来概述了我们的 G 缓冲区实现。我们详细说明了我们使用VK_KHR_dynamic_rendering扩展的情况,该扩展使我们能够简化渲染通道和帧缓冲区的使用。我们还突出了 G 缓冲区着色器中写入多个渲染目标的相应代码,并提供了我们正常编码和解码的实现。最后,我们提出了一些优化建议,以进一步减少我们 G 缓冲区实现使用的内存。

在最后一节中,我们描述了我们选择的实现光聚类的算法。我们首先按深度值对灯光进行排序,将其放入深度桶中。然后,我们使用位字段数组存储影响给定屏幕瓦片的灯光。最后,我们在光照着色器中使用了这两个数据结构,以减少每个片段需要评估的灯光数量。

优化任何游戏或应用程序的照明阶段对于保持交互式帧率至关重要。我们描述了一个可能的解决方案,但还有其他选项可用,我们建议您尝试它们,以找到最适合您用例的解决方案!

现在我们已经添加了许多灯光,但场景仍然看起来很平,因为还缺少一个重要元素:阴影。这就是下一章的主题!

进一步阅读

第八章:使用网格着色器添加阴影

在上一章中,我们使用集群延迟技术添加了对多个光源的支持,并引入了最新的创新。

我们添加了一个硬限制,最多 256 个光源,每个光源都可以是动态的,并且具有独特的属性。

在本章中,我们将为这些光源中的每一个添加投射阴影的可能性,以进一步增强 Raptor Engine 中显示的任何资产的可视效果,并且我们将利用网格着色器提供的可能性,让许多这些光源投射阴影,同时保持合理的帧时间。

我们还将探讨使用稀疏资源来提高阴影图内存使用,将拥有许多阴影投射光源的可能性从几乎不可能变为在当前硬件上可能且性能良好的状态。

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

  • 阴影技术的简要历史

  • 使用网格着色器实现阴影映射

  • 使用 Vulkan 的稀疏资源改进阴影内存

技术要求

本章的代码可以在以下网址找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter8

阴影技术的简要历史

阴影是任何渲染框架中最大的新增功能之一,因为它们确实增强了场景中深度和体积的感知。作为一个与光线相关的现象,它们在图形文献中被研究了数十年,但问题仍然远未解决。

目前最常用的阴影技术是阴影映射,但最近,得益于硬件支持的光线追踪,光线追踪阴影作为一种更真实解决方案正在变得流行。

有些游戏——特别是Doom 3——也使用了阴影体积作为使光线产生阴影的解决方案,但现在已经不再使用。

阴影体积

阴影体积是一个古老的概念,由 Frank Crow 在 1977 年提出。它们被定义为沿着光线方向和向无限延伸的每个三角形的顶点的投影,从而创建一个体积。

阴影非常清晰,并且需要针对每个三角形和每束光进行处理。最新的实现使用了模板缓冲区,这一变化使得它能够实时使用。

阴影体积的问题在于它们需要大量的几何工作,并且变得填充率很高,在这种情况下,阴影图是一个明显的赢家。

阴影映射

所有技术中最常用的技术,首次出现在 1978 年左右,是实时和离线渲染的行业标准。阴影映射背后的想法是从光线的视角渲染场景,并保存每个像素的深度。

之后,当从摄像机视角渲染场景时,像素位置可以转换为阴影坐标系,并测试与阴影贴图中的相应像素是否重叠,以确定当前像素是否处于阴影中。

阴影贴图的分辨率非常重要,以及其中保存的信息类型。随着时间的推移,过滤器开始出现,使用数学工具添加软化阴影的可能性,或者添加计算来使阴影在靠近阻挡几何体时变得更硬。

阴影映射也存在许多问题,但由于它是事实上的标准,因此使用了许多技术来减轻这些问题。可能遇到的一些问题是走样、阴影痤疮和彼得·潘效应。

寻找一个稳健的阴影解决方案是渲染引擎中最复杂的步骤之一,通常需要大量的试错和针对不同场景和情况定制的解决方案。

光线追踪阴影

在过去几年中,光线追踪——一种使用光线追踪任何类型渲染信息的技术——在客户 GPU 上获得了硬件支持,使得渲染程序员可以使用不同的场景表示来追踪光线,并增强不同渲染现象的外观。

我们将在本书的末尾探讨光线追踪,但就目前而言,只需说使用这种特殊的场景表示(不同于我们已使用的网格和网格块),对于屏幕上的每个像素,都可以向影响该像素的每个光源追踪一条光线,并计算该像素的最终阴影贡献即可。

这是阴影最先进和最真实的形式,但性能方面——尽管有硬件支持——可能仍然较慢,并且支持它的 GPU 的扩散程度并不像所需的那样高,以使其成为新的标准。

正因如此,阴影映射仍然是标准——任何硬件,包括移动电话,都可以渲染阴影贴图,并且仍然可以呈现出令人信服的外观。基于这一考虑,我们选择将阴影映射作为 Raptor Engine 的主要阴影技术。

使用网格着色器实现阴影映射

现在我们已经探讨了渲染阴影的不同方法,我们将描述用于一次渲染多个阴影贴图的算法和实现细节,利用网格着色器的强大功能。

概述

在本节中,我们将概述该算法。我们试图实现的是使用网格块和网格着色器来渲染阴影,但这将需要一些计算工作来生成实际绘制网格块的命令。

我们将绘制由点光源产生的阴影,并使用立方体贴图作为纹理来存储必要的信息。我们将在下一节中讨论立方体贴图。

回到算法,第一步将是针对灯光剪裁网格实例。这是在计算着色器中完成的,并将保存每个灯光的可见网格实例列表。网格实例用于稍后检索关联的网格,并且将使用任务着色器稍后执行每个网格集的剪裁。

第二步是将间接绘制网格集参数写入以执行网格集的实际渲染到阴影图中,这同样是在计算着色器中完成的。这里有一个需要注意的地方,将在“关于多视图渲染”部分进行解释。

第三步是使用间接网格着色器绘制网格集,将其绘制到实际的阴影图中。

我们将使用分层立方体贴图阴影纹理进行绘制,每个层对应每个灯光。

第四步和最后一步是在场景照明时采样阴影纹理。

我们将以几乎无过滤的方式渲染阴影,因为本章的重点是网格着色器驱动的阴影,但我们将提供过滤选项的链接,放在本章的末尾。

下面是算法的视觉概述:

图 8.1 – 算法概述

图 8.1 – 算法概述

在下一节中,我们将讨论立方体贴图阴影,用于存储点光源的阴影。

立方体贴图阴影

立方体贴图是一种将 3D 方向(xyz)映射到包含图像信息的六个面的通用方法。

它们不仅用于阴影渲染,而且在一般情况下也用于绘制环境(如天空盒或远处的景观),并且它们已经标准化到连硬件都包含对立方体贴图采样和过滤的支持。

立方体贴图的每个方向通常都有一个名称、一个方向和一个与之关联的单个纹理:

  • 正向 x

  • 负向 x

  • 正向 y

  • 负向 y

  • 正向 z

  • 负向 z

当渲染到一个面时,我们需要提供矩阵,使其朝正确的方向查看。

在读取时,一个单独的向量将被(在幕后)转换为相应的图像。对于阴影,这个过程将是手动的,因为我们将为每个面提供一个视图投影矩阵,网格集将读取它以将渲染引导到正确的面。

对于这一点,还有一个需要注意的地方,即我们需要为每个面复制绘图命令,因为一个顶点只能渲染到与每个面关联的图像视图。

有一些扩展可以将一个顶点与多个图像关联,正如我们将在下一节中看到的,但它们在编写时的网格着色器中的支持仍然有限。

提出的阴影渲染的另一个重要方面是,我们将使用立方体贴图数组,这样我们就可以通过分层渲染来读取和写入每个阴影。

下面是针对一个点光源的展开立方体贴图阴影渲染,每个立方体贴图面都有一个纹理:

图 8.2 – 从灯光视角渲染的六个立方体贴图面

图 8.2 – 从光线视角渲染的六个立方体贴图面

如我们所见,只有正Z方向在渲染内容。我们将提供一些剔除机制,以避免在空立方体贴图面上渲染网格块。

关于多视图渲染的说明

如前所述,有一个扩展可以帮助在多个立方体贴图面上渲染顶点:多视图渲染。这个扩展在虚拟现实应用中广泛使用,可以在双目投影的两个视图中渲染一个顶点,也可以与立方体贴图一起使用。

在撰写本文时,网格着色器还没有得到适当的扩展支持,所以我们使用 NVIDIA 的 Vulkan 扩展,但这并不支持多视图渲染,因此我们手动为每个面生成命令并使用这些命令进行绘制。

我们知道一个多供应商扩展正在路上,因此我们将相应地更新代码,但核心算法不会改变,因为多视图渲染更多的是一种优化。

我们现在可以查看算法步骤。

每光线网格实例剔除

准备阴影渲染的第一步是在计算着色器中进行的粗粒度剔除。在 Raptor 中,我们有网格和网格块表示,因此我们可以使用网格及其边界体积作为与网格块链接的更高层次

我们将执行一个非常简单的光线球与网格球相交操作,如果相交,我们将添加相应的网格块。首先要知道的是,我们将使用网格实例和光线一起调度这个计算着色器,因此我们将为每个光线和每个网格实例计算光线是否影响网格实例。

然后,我们将输出一个每个光线的网格块实例列表,定义为网格实例和全局网格块索引的组合。我们还将写入每个光线的网格块实例计数,以跳过空灯光并正确读取索引。

因此,第一步是重置每个光线的计数:

layout (local_size_x = 32, local_size_y = 1, local_size_z =
        1) in;
void main() {
    if (gl_GlobalInvocationID.x == 0 ) {
        for ( uint i = 0; i < NUM_LIGHTS; ++i ) {
            per_light_meshlet_instances[i * 2] = 0;
            per_light_meshlet_instances[i * 2 + 1] = 0;
        }
    }
    global_shader_barrier();

然后,我们将跳过那些将在超出范围的灯光上工作的线程。当我们调度时,我们在除以 32 后向上取整数字,因此一些线程可能正在处理空灯光。

这个计算调度将通过将每个网格实例与每个光线链接来完成,如下所示:

图 8.3 – 使用单个绘制调用渲染多个光线的立方体贴图的命令缓冲区组织

图 8.3 – 使用单个绘制调用渲染多个光线的立方体贴图的命令缓冲区组织

这里是提前退出和光线索引计算:

    uint light_index = gl_GlobalInvocationID.x %
                       active_lights;
    if (light_index >= active_lights) {
        return;
    }
    const Light = lights[light_index];

以类似的方式,我们计算网格实例索引,并在调度向上取整过多时再次提前退出

    uint mesh_instance_index = gl_GlobalInvocationID.x /
                               active_lights;
    if (mesh_instance_index >= num_mesh_instances) {
        return;
    }
    uint mesh_draw_index = mesh_instance_draws
                           [mesh_instance_index].
                           mesh_draw_index;
    // Skip transparent meshes
    MeshDraw mesh_draw = mesh_draws[mesh_draw_index];
    if ( ((mesh_draw.flags & (DrawFlags_AlphaMask |
           DrawFlags_Transparent)) != 0 ) ){
        return;
    }

我们最终可以收集网格实例和模型的边界球,并简单地计算世界空间边界球:

    vec4 bounding_sphere = mesh_bounds[mesh_draw_index];
    mat4 model = mesh_instance_draws
                 [mesh_instance_index].model;
    // Calculate mesh instance bounding sphere
    vec4 mesh_world_bounding_center = model * vec4
        (bounding_sphere.xyz, 1);
    float scale = length( model[0] );
    float mesh_radius = bounding_sphere.w * scale * 1.1;
    // Artificially inflate bounding sphere
    // Check if mesh is inside light
    const bool mesh_intersects_sphere =
    sphere_intersect(mesh_world_bounding_center.xyz,
        mesh_radius, light.world_position, light.radius )
            || disable_shadow_meshes_sphere_cull();
    if (!mesh_intersects_sphere) {
        return;
    }

到目前为止,我们知道网格实例受到光的影响,因此增加每个光源的网格块计数,并添加所有必要的索引以绘制网格块:

    uint per_light_offset =
        atomicAdd(per_light_meshlet_instances[light_index],
            mesh_draw.meshlet_count);
    // Mesh inside light, add meshlets
    for ( uint m = 0; m < mesh_draw.meshlet_count; ++m ) {
        uint meshlet_index = mesh_draw.meshlet_offset + m;
         meshlet_instances[light_index *
             per_light_max_instances + per_light_offset
                 + m] = uvec2( mesh_instance_index,
                     meshlet_index );
    }
}

我们将在接下来的任务着色器中同时写入网格实例索引以检索世界矩阵,以及全局网格块索引以检索网格块数据。但在那之前,我们需要生成一个间接绘制命令列表,我们将在下一节中看到这一点。

此外,根据场景,我们有网格块实例的最大数量,并且我们为每个光源预先分配它们。

间接绘制命令生成

这个计算着色器将为每个光源生成一个间接命令列表。我们将使用每个光源网格块实例的着色器存储缓冲对象SSBO)的最后一个元素来原子计数间接命令的数量。

如前所述,重置用于间接命令计数的atomic int

layout (local_size_x = 32, local_size_y = 1, local_size_z =
        1) in;
void main() {
    if (gl_GlobalInvocationID.x == 0 ) {
        // Use this as atomic int
        per_light_meshlet_instances[NUM_LIGHTS] = 0;
    }
    global_shader_barrier();

我们将提前终止执行以处理四舍五入的光索引:

    // Each thread writes the command of a light.
    uint light_index = gl_GlobalInvocationID.x;
    if ( light_index >= active_lights ) {
        return;
    }

我们最终可以写入间接数据和打包的光索引,前提是光源包含可见的网格。

注意,我们写入六个命令,每个立方体贴图面一个:

    // Write per light shadow data
    const uint visible_meshlets =
        per_light_meshlet_instances[light_index];
    if (visible_meshlets > 0) {
        const uint command_offset =
            atomicAdd(per_light_meshlet_instances[
                NUM_LIGHTS], 6);
        uint packed_light_index = (light_index & 0xffff)
                                   << 16;
        meshlet_draw_commands[command_offset] =
            uvec4( ((visible_meshlets + 31) / 32), 1, 1,
                packed_light_index | 0 );
        meshlet_draw_commands[command_offset + 1] =
            uvec4( ((visible_meshlets + 31) / 32), 1, 1,
                packed_light_index | 1 );
   ... same for faces 2 to 5.
    }
}

现在我们有一个间接绘制命令列表,每个光源六个。我们将在任务着色器中执行进一步的剔除,下一节将展示。

阴影立方体贴图面剔除

在间接绘制任务着色器中,我们将添加一个机制来剔除网格块与立方体贴图的交集以优化渲染。为此,我们有一个实用方法,它将计算给定立方体贴图和轴对齐的边界框时,哪个面将在立方体贴图中可见。它是使用立方体贴图面法线来计算中心点和范围是否包含在定义六个立方体贴图面之一的四个平面中:

uint get_cube_face_mask( vec3 cube_map_pos, vec3 aabb_min,
                         vec3 aabb_max ) {
    vec3 plane_normals[] = {
        vec3(-1, 1, 0), vec3(1, 1, 0), vec3(1, 0, 1),
            vec3(1, 0, -1), vec3(0, 1, 1), vec3(0, -1, 1)
    };
    vec3 abs_plane_normals[] = {
        vec3(1, 1, 0), vec3(1, 1, 0), vec3(1, 0, 1),
            vec3(1, 0, 1), vec3(0, 1, 1), vec3(0, 1, 1) };
    vec3 aabb_center = (aabb_min + aabb_max) * 0.5f;
    vec3 center = aabb_center - cube_map_pos;
    vec3 extents = (aabb_max - aabb_min) * 0.5f;
    bool rp[ 6 ];
    bool rn[ 6 ];
    for ( uint  i = 0; i < 6; ++i ) {
        float dist = dot( center, plane_normals[ i ] );
        float radius = dot( extents, abs_plane_normals[ i ]
        );
        rp[ i ] = dist > -radius;
        rn[ i ] = dist < radius;
    }
    uint fpx = (rn[ 0 ] && rp[ 1 ] && rp[ 2 ] && rp[ 3 ] &&
                aabb_max.x > cube_map_pos.x) ? 1 : 0;
    uint fnx = (rp[ 0 ] && rn[ 1 ] && rn[ 2 ] && rn[ 3 ] &&
                aabb_min.x < cube_map_pos.x) ? 1 : 0;
    uint fpy = (rp[ 0 ] && rp[ 1 ] && rp[ 4 ] && rn[ 5 ] &&
                aabb_max.y > cube_map_pos.y) ? 1 : 0;
    uint fny = (rn[ 0 ] && rn[ 1 ] && rn[ 4 ] && rp[ 5 ] &&
                aabb_min.y < cube_map_pos.y) ? 1 : 0;
    uint fpz = (rp[ 2 ] && rn[ 3 ] && rp[ 4 ] && rp[ 5 ] &&
                aabb_max.z > cube_map_pos.z) ? 1 : 0;
    uint fnz = (rn[ 2 ] && rp[ 3 ] && rn[ 4 ] && rn[ 5 ] &&
                aabb_min.z < cube_map_pos.z) ? 1 : 0;
    return fpx | ( fnx << 1 ) | ( fpy << 2 ) | ( fny << 3 )
    | ( fpz << 4 ) | ( fnz << 5 );
}

这些方法返回一个位掩码,其中每个六位都设置为1,当当前轴对齐的边界框在该面上可见时。

网格块阴影渲染 – 任务着色器

现在我们已经有了这个实用方法,我们可以看看任务着色器。我们改变了一些其他任务着色器的内容,以适应间接绘制并使用分层渲染来写入不同的立方体贴图。

我们将传递uint到网格着色器,它打包一个光和一个面索引以检索相应的立方体贴图视图投影矩阵并将其写入正确的层:

out taskNV block {
    uint meshlet_indices[32];
     uint light_index_face_index;
};
void main() {
    uint task_index = gl_LocalInvocationID.x;
     uint meshlet_group_index = gl_WorkGroupID.x;

网格块计算比较复杂,因为需要全局计算索引。我们首先计算间接绘制全局的网格块索引:

    // Calculate meshlet and light indices
    const uint meshlet_index = meshlet_group_index * 32 +
                               task_index;

然后,我们在剔除计算着色器中写入的网格块实例中外推光索引和读取偏移量:

    uint packed_light_index_face_index =
        meshlet_draw_commands[gl_DrawIDARB].w;
    const uint light_index =
        packed_light_index_face_index >> 16;
    const uint meshlet_index_read_offset =
        light_index * per_light_max_instances;

我们最终可以读取正确的网格块和网格实例索引:

uint global_meshlet_index = 
   meshlet_instances[meshlet_index_read_offset + 
   meshlet_index].y; 
   uint mesh_instance_index =
        meshlet_instances[meshlet_index_read_offset +
            meshlet_index].x;

现在,我们计算面索引,然后我们可以开始剔除阶段:

    const uint face_index = (packed_light_index_face_index
                             & 0xf);
    mat4 model = mesh_instance_draws[mesh_instance_index]
                 .model;

剔除与之前的任务着色器执行方式类似,但我们还添加了按面剔除:

    vec4 world_center = model * vec4(meshlets
                        [global_meshlet_index].center, 1);
    float scale = length( model[0] );
    float radius = meshlets[global_meshlet_index].radius *
                   scale * 1.1;   // Artificially inflate
                                     bounding sphere
vec3 cone_axis = 
   mat3( model ) * vec3(int(meshlets 
   [global_meshlet_index].cone_axis[0]) / 127.0, 
   int(meshlets[global_meshlet_index]. 
   cone_axis[1]) / 127.0, 
   int(meshlets[global_meshlet_index]. 
   cone_axis[2]) / 127.0); 
   float cone_cutoff = int(meshlets[global_meshlet_index].
                           cone_cutoff) / 127.0;
    bool accept = false;
    const vec4 camera_sphere = camera_spheres[light_index];
    // Cone cull
    accept = !coneCull(world_center.xyz, radius, cone_axis,
             cone_cutoff, camera_sphere.xyz) ||
             disable_shadow_meshlets_cone_cull();
    // Sphere culling
    if ( accept ) {
        accept = sphere_intersect( world_center.xyz,
                 radius, camera_sphere.xyz,
                 camera_sphere.w) ||
                 disable_shadow_meshlets_sphere_cull();
    }
    // Cubemap face culling
    if ( accept ) {
        uint visible_faces =
        get_cube_face_mask( camera_sphere.xyz,
            world_center.xyz - vec3(radius),
                world_center.xyz + vec3(radius));
        switch (face_index) {
            case 0:
                accept = (visible_faces & 1) != 0;
                break;
            case 1:
                accept = (visible_faces & 2) != 0;
                break;
...same for faces 2 to 5.
                    }
        accept = accept || disable_shadow_meshlets_cubemap
                 _face_cull();
    }

在这个着色器点,我们写入每个可见的网格块:

         uvec4 ballot = subgroupBallot(accept);
    uint index = subgroupBallotExclusiveBitCount(ballot);
    if (accept)
        meshlet_indices[index] = global_meshlet_index;
    uint count = subgroupBallotBitCount(ballot);
    if (task_index == 0)
        gl_TaskCountNV = count;

最后,我们写入打包的光和面索引:

        light_index_face_index =
            packed_light_index_face_index;
}

接下来,我们将看到网格着色器。

网格块阴影渲染 – 网格着色器

在这个网格着色器中,我们需要检索要写入的立方体贴图数组中的层索引和读取正确视图投影变换的光索引。

重要的是要注意,每个面都有自己的变换,因为我们实际上是在单独渲染每个面。

注意,立方体贴图的每个面都被视为一个层,因此第一个立方体贴图将在层 0-5 中渲染,第二个在层 6-11 中,依此类推。

这里是代码:

void main() {
   ...
    const uint light_index = light_index_face_index >> 16;
    const uint face_index = (light_index_face_index & 0xf);
    const int layer_index = int(CUBE_MAP_COUNT *
                                light_index + face_index);
    for (uint i = task_index; i < vertex_count; i +=
       32)    {
        uint vi = meshletData[vertexOffset + i];
        vec3 position = vec3(vertex_positions[vi].v.x,
                        vertex_positions[vi].v.y,
                        vertex_positions[vi].v.z);
        gl_MeshVerticesNV[ i ].gl_Position =
        view_projections[layer_index] *
           (model * vec4(position, 1));
    }
    uint indexGroupCount = (indexCount + 3) / 4;
    for (uint i = task_index; i < indexGroupCount; i += 32) {
        writePackedPrimitiveIndices4x8NV(i * 4,
            meshletData[indexOffset + i]);
    }

在这里,我们为每个基元写入层索引。这些偏移量的使用是为了在写入时避免银行冲突,正如之前着色器中看到的那样:

     gl_MeshPrimitivesNV[task_index].gl_Layer =
         layer_index;
    gl_MeshPrimitivesNV[task_index + 32].gl_Layer =
        layer_index;
    gl_MeshPrimitivesNV[task_index + 64].gl_Layer =
        layer_index;
    gl_MeshPrimitivesNV[task_index + 96].gl_Layer =
        layer_index;
    if (task_index == 0) {
        gl_PrimitiveCountNV =
            uint(meshlets[global_meshlet_index]
                .triangle_count);
    }
}

在完成这个网格着色器渲染的阴影之后,由于没有关联的片段着色器,我们现在可以在光照着色器中读取生成的阴影纹理,如下一节所述。

阴影贴图采样

由于我们只是使用没有过滤的硬阴影贴图,因此采样它的代码是标准的立方体贴图代码。我们计算世界到光向量和使用它来采样立方体贴图。

由于这是一个分层立方体贴图,我们需要 3D 方向向量和层索引,这些我们已经在光源本身中保存:

    vec3 shadow_position_to_light = world_position –
                                    light.world_position;
const float closest_depth =
    texture(global_textures_cubemaps_array
    [nonuniformEXT(cubemap_shadows_index)],
    vec4(shadow_position_to_light,
    shadow_light_index)).r;

然后,我们使用vector_to_depth_value实用方法将深度转换为原始深度值,该方法从光向量中获取主轴并将其转换为原始深度,这样我们就可以比较从立方体贴图中读取的值:

    const float current_depth = vector_to_depth_value
                                (shadow_position_to_light,
                                 light.radius);
    float shadow = current_depth - bias < closest_depth ?
                   1 : 0;

这里展示了vector_to_depth_value方法:

float vector_to_depth_value( inout vec3 Vec, float radius) {
    vec3 AbsVec = abs(Vec);
    float LocalZcomp = max(AbsVec.x, max(AbsVec.y,
                           AbsVec.z));
    const float f = radius;
    const float n = 0.01f;
    float NormZComp = -(f / (n - f) - (n * f) / (n - f) /
                        LocalZcomp);
    return NormZComp;
}

它从方向向量中获取主轴,并使用来自投影矩阵的公式将其转换为原始深度。这个值现在可以与存储在阴影贴图中的任何深度值一起使用。

这里是一个来自点光源的阴影示例:

图 8.4 – 场景中单个点光源产生的阴影

图 8.4 – 场景中单个点光源产生的阴影

如我们所见,阴影在渲染中是一个巨大的改进,为观众提供了物体与其环境之间基本视觉线索。

到目前为止,我们看到了如何实现基于网格着色器的阴影,但仍有改进的空间,尤其是在内存使用方面。目前,这个解决方案为每个光源预先分配一个立方体贴图,如果我们考虑每个光源有六个纹理,内存可能会很快变得很大。

在下一节中,我们将探讨使用稀疏资源降低阴影贴图内存的解决方案。

使用 Vulkan 的稀疏资源改进阴影内存

正如我们在上一节末尾提到的,我们目前为所有光源的每个立方体贴图分配全部内存。根据光源的屏幕大小,我们可能会浪费内存,因为远处的和小的光源无法利用阴影贴图的高分辨率。

因此,我们实现了一种技术,可以根据相机位置动态确定每个立方体贴图的分辨率。有了这些信息,我们就可以管理稀疏纹理,并在运行时根据给定帧的要求重新分配其内存。

稀疏纹理(有时也称为虚拟纹理)可以手动实现,但幸运的是,它们在 Vulkan 中是原生支持的。我们现在将描述如何使用 Vulkan API 来实现它们。

创建和分配稀疏纹理

Vulkan 中的常规资源必须绑定到单个内存分配,并且无法将给定的资源绑定到不同的分配。这对于在运行时已知且我们预计不会更改的资源来说效果很好。

然而,当使用具有动态分辨率的立方体贴图时,我们需要能够将内存的不同部分绑定到给定的资源。Vulkan 提供了两种方法来实现这一点:

  • 稀疏资源允许我们将资源绑定到非连续的内存分配,但完整的资源需要绑定。

  • 稀疏驻留允许我们将资源部分绑定到不同的内存分配。这正是我们实现所需的功能,因为我们可能只会使用立方体贴图每一层的子集。

这两种方法都允许用户在运行时将资源重新绑定到不同的分配。开始使用稀疏资源所需的第一步是在创建资源时传递正确的标志:

VkImageCreateInfo image_info = {
    VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
image_info.flags = VK_IMAGE_CREATE_SPARSE_RESIDENCY_BIT |
                   VK_IMAGE_CREATE_SPARSE_BINDING_BIT;

在这里,我们正在请求支持稀疏驻留的资源。一旦创建了一个图像,我们就不需要立即为其分配内存。相反,我们将从内存的一个区域分配,然后从该区域中子分配单个页面。

重要的是要注意,Vulkan 对单个页面的大小有严格的要求。这些是从 Vulkan 规范中获取的所需大小:

表 8.1 – 图像的稀疏块大小

表 8.1 – 图像的稀疏块大小

我们需要这些信息来确定为给定大小的立方体贴图分配多少页面。我们可以使用以下代码检索给定图像的详细信息:

VkPhysicalDeviceSparseImageFormatInfo2 format_info{
    VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SPARSE_IMAGE_FORMAT
        _INFO_2 };
format_info.format = texture->vk_format;
format_info.type = to_vk_image_type( texture->type );
format_info.samples = VK_SAMPLE_COUNT_1_BIT;
format_info.usage = texture->vk_usage;
format_info.tiling = VK_IMAGE_TILING_OPTIMAL;

此结构的信息已经包含在我们的纹理数据结构中。接下来,我们检索给定图像的块大小:

Array<VkSparseImageFormatProperties2> properties;
vkGetPhysicalDeviceSparseImageFormatProperties2(
    vulkan_physical_device, &format_info, &property_count,
        properties.data );
u32 block_width = properties[ 0 ].properties.
                  imageGranularity.width;
u32 block_height = properties[ 0 ].properties.
                   imageGranularity.height;

使用这些信息,我们现在可以分配一个页面池。首先,我们检索图像的内存要求:

VkMemoryRequirements memory_requirements{ };
vkGetImageMemoryRequirements( vulkan_device, texture->
                              vk_image,
                              &memory_requirements );

这是我们用于常规纹理的相同代码;然而,memory_requirements.alignment将包含给定图像格式的块大小。

接下来,我们计算给定池大小所需的块数量:

u32 block_count = pool_size / ( block_width * block_height );

最后一步是为我们将用于写入立方体贴图的页面分配:

VmaAllocationCreateInfo allocation_create_info{ };
allocation_create_info.usage = VMA_MEMORY_USAGE_GPU_ONLY;
VkMemoryRequirements page_memory_requirements;
page_memory_requirements.memoryTypeBits =
    memory_requirements.memoryTypeBits;
page_memory_requirements.alignment =
    memory_requirements.alignment;
page_memory_requirements.size =
    memory_requirements.alignment;
vmaAllocateMemoryPages( vma_allocator,
                        &page_memory_requirements,
                        &allocation_create_info,
                        block_count, page_pool->
                        vma_allocations.data, nullptr );

vmaAllocateMemoryPages,用于一次性分配多个页面。

现在我们已经为我们的阴影映射分配了内存,我们需要确定每个立方体贴图的分辨率。

选择每光照阴影内存使用

为了确定给定光线的立方体贴图的分辨率,我们需要找到它对场景的影响程度。直观上,距离更远的光线将有更小的影响,这取决于它的半径(至少对于点光源),但我们需要量化它的影响量。我们实现了一个类似于在 More Efficient Virtual Shadow Maps for Many Lights 论文中提出的解决方案。

我们将重用前一章中引入的概念:簇。我们将屏幕细分为瓦片,并在 z 轴上 切割 截锥体。这将给我们更小的截锥体(由轴对齐的边界框近似),我们将使用它来确定哪些区域被给定光线覆盖。

让我们看看实现这一点的代码:

  1. 我们首先计算每个光线在相机空间中的边界框:

    for ( u32 l = 0; l < light_count; ++l ) {
    
        Light& light = scene->lights[ l ];
    
        vec4s aabb_min_view = glms_mat4_mulv(
    
                              last_camera.view,
    
                              light.aabb_min );
    
        vec4s aabb_max_view = glms_mat4_mulv(
    
                              last_camera.view,
    
                              light.aabb_max );
    
        lights_aabb_view[ l * 2 ] = vec3s{
    
            aabb_min_view.x, aabb_min_view.y,
    
                aabb_min_view.z };
    
        lights_aabb_view[ l * 2 + 1 ] = vec3s{
    
            aabb_max_view.x, aabb_max_view.y,
    
                aabb_max_view.z };
    
    }
    
  2. 接下来,我们遍历瓦片和每个深度切片,计算每个簇的位置和大小。我们首先计算每个瓦片的相机空间位置:

    vec4s max_point_screen = vec4s{ f32( ( x + 1 ) *
    
                             tile_size ), f32( ( y + 1 ) *
    
                             tile_size ), 0.0f, 1.0f };
    
                             // Top Right
    
    vec4s min_point_screen = vec4s{ f32( x * tile_size ),
    
                             f32( y * tile_size ),
    
                             0.0f, 1.0f }; // Top Right
    
    vec3s max_point_view = screen_to_view(
    
                           max_point_screen );
    
    vec3s min_point_view = screen_to_view(
    
                           min_point_screen );
    
  3. 然后,我们需要确定每个切片的最小和最大深度:

    f32 tile_near = z_near * pow( z_ratio, f32( z ) *
    
                                  z_bin_range );
    
    f32 tile_far  = z_near * pow( z_ratio, f32( z + 1 ) *
    
                                  z_bin_range );
    
  4. 最后,我们将这两个值结合起来,以检索簇的位置和大小:

    vec3s min_point_near = line_intersection_to_z_plane(
    
                           eye_pos, min_point_view,
    
                           tile_near );
    
    vec3s min_point_far  = line_intersection_to_z_plane(
    
                           eye_pos, min_point_view,
    
                           tile_far );
    
    vec3s max_point_near = line_intersection_to_z_plane(
    
                           eye_pos, max_point_view,
    
                           tile_near );
    
    vec3s max_point_far  = line_intersection_to_z_plane(
    
                           eye_pos, max_point_view,
    
                           tile_far );
    
    vec3s min_point_aabb_view = glms_vec3_minv( glms_vec3_minv( min_point_near, min_point_far ), glms_vec3_minv( max_point_near, max_point_far ) );
    
    vec3s max_point_aabb_view = glms_vec3_maxv( glms_vec3_maxv( min_point_near, min_point_far ), glms_vec3_maxv( max_point_near, max_point_far ) );
    

现在我们已经获得了簇,我们遍历每个光线,以确定它是否覆盖了簇以及簇在光线上的投影;我们将在稍后澄清这究竟意味着什么。

  1. 下一步是在光线和簇之间进行盒相交测试:

    f32 minx = min( min( light_aabb_min.x,
    
                    light_aabb_max.x ), min(
    
                    min_point_aabb_view.x,
    
                    max_point_aabb_view.x ) );
    
    f32 miny = min( min( light_aabb_min.y,
    
                    light_aabb_max.y ), min(
    
                    min_point_aabb_view.y,
    
                    max_point_aabb_view.y ) );
    
    f32 minz = min( min( light_aabb_min.z,
    
                    light_aabb_max.z ), min(
    
                    min_point_aabb_view.z,
    
                    max_point_aabb_view.z ) );
    
    f32 maxx = max( max( light_aabb_min.x,
    
                    light_aabb_max.x ), max(
    
                    min_point_aabb_view.x,
    
                    max_point_aabb_view.x ) );
    
    f32 maxy = max( max( light_aabb_min.y,
    
                    light_aabb_max.y ), max(
    
                    min_point_aabb_view.y,
    
                    max_point_aabb_view.y ) );
    
    f32 maxz = max( max( light_aabb_min.z,
    
                    light_aabb_max.z ), max(
    
                    min_point_aabb_view.z,
    
                    max_point_aabb_view.z ) );
    
    f32 dx = abs( maxx - minx );
    
    f32 dy = abs( maxy - miny );
    
    f32 dz = abs( maxz - minz );
    
    f32 allx = abs( light_aabb_max.x - light_aabb_min.x )
    
               + abs( max_point_aabb_view.x –
    
               min_point_aabb_view.x );
    
    f32 ally = abs( light_aabb_max.y - light_aabb_min.y )
    
               + abs( max_point_aabb_view.y –
    
               min_point_aabb_view.y );
    
    f32 allz = abs( light_aabb_max.z - light_aabb_min.z )
    
               + abs( max_point_aabb_view.z –
    
               min_point_aabb_view.z );
    
     bool intersects = ( dx <= allx ) && ( dy < ally ) &&
    
                       ( dz <= allz );
    

如果它们确实相交,我们将计算光线在簇上的投影面积的一个近似值:

f32 d = glms_vec2_distance( sphere_screen, tile_center );
f32 diff = d * d - tile_radius_sq;
if ( diff < 1.0e-4 ) {
    continue;
}
f32 solid_angle = ( 2.0f * rpi ) * ( 1.0f - ( sqrtf(
                    diff ) / d ) );
f32 resolution = sqrtf( ( 4.0f * rpi * tile_pixels ) /
                        ( 6 * solid_angle ) );

策略是取光线和簇中心在屏幕空间中的距离,计算簇对光线位置的立体角,并使用簇的像素大小来计算立方体贴图的分辨率。更多细节请参阅论文。

我们保留最大分辨率,并将使用计算出的值来绑定每个立方体贴图的内存。

将渲染到稀疏阴影贴图中

现在我们已经确定了给定帧的立方体贴图的分辨率,我们需要将预分配的页面分配给我们的纹理:

  1. 第一步是记录哪些页面分配给每个图像:

    VkImageAspectFlags aspect = TextureFormat::has_depth(
    
    texture->vk_format ) ? VK_IMAGE_ASPECT_DEPTH_BIT : VK_IMAGE_ASPECT_COLOR_BIT;
    
    for ( u32 block_y = 0; block_y < num_blocks_y;
    
          ++block_y ) {
    
          for ( u32 block_x = 0; block_x < num_blocks_x;
    
                ++block_x ) {
    
                    VkSparseImageMemoryBind sparse_bind{ };
    
                    VmaAllocation allocation = 
    
                       page_pool-> vma_allocations
    
                          [ page_pool->used_pages++ ];
    
                    VmaAllocationInfo allocation_info{ };
    
                    vmaGetAllocationInfo( vma_allocator,
    
                                          allocation,
    
                                          &allocation_info );
    

我们首先获取我们将要用于给定块的分配的详细信息,因为我们需要访问 VkDeviceMemory 处理器和它从池中分配的偏移量。

  1. 接下来,我们计算每个块的纹理偏移量:

            i32 dest_x = ( i32 )( block_x * block_width +
    
                                  x );
    
            i32 dest_y = ( i32 )( block_y * block_height +
    
                                  y );
    
  2. 然后,我们将此信息记录到 VkSparseImageMemoryBind 数据结构中,该结构将用于稍后更新绑定到立方体贴图纹理的内存:

            sparse_bind.subresource.aspectMask = aspect;
    
            sparse_bind.subresource.arrayLayer = layer;
    
            sparse_bind.offset = { dest_x, dest_y, 0 };
    
            sparse_bind.extent = { block_width,
    
                                   block_height, 1 };
    
            sparse_bind.memory =
    
                allocation_info.deviceMemory;
    
            sparse_bind.memoryOffset =
    
                allocation_info.offset;
    
            pending_sparse_queue_binds.push( sparse_bind
    
                                           );
    
        }
    
    }
    

重要的是要注意,正如我们之前提到的,我们只使用一个具有许多层的图像。层变量确定每个分配将属于哪个层。请参阅完整代码以获取更多详细信息。

  1. 最后,我们记录这些页面将绑定到哪些图像上:

    SparseMemoryBindInfo bind_info{ };
    
    bind_info.image = texture->vk_image;
    
    bind_info.binding_array_offset = array_offset;
    
    bind_info.count = num_blocks;
    
    pending_sparse_memory_info.push( bind_info );
    

array_offsetpending_sparse_queue_binds 数组中的一个偏移量,这样我们就可以在一个数组中存储所有挂起的分配。

现在我们已经记录了分配更新的列表,我们需要将它们提交到队列中,以便 GPU 执行。

  1. 首先,我们为每个层填充一个VkSparseImageMemoryBindInfo结构:

    for ( u32 b = 0; b < pending_sparse_memory_info.size;
    
          ++b ) {
    
        SparseMemoryBindInfo& internal_info =
    
            pending_sparse_memory_info[ b ];
    
        VkSparseImageMemoryBindInfo& info =
    
            sparse_binding_infos[ b ];
    
        info.image = internal_info.image;
    
        info.bindCount = internal_info.count;
    
        info.pBinds = pending_sparse_queue_binds.data +
    
                      internal_info.binding_array_offset;
    
    }
    
  2. 接下来,我们将所有挂起的绑定操作提交到主队列:

    VkBindSparseInfo sparse_info{
    
        VK_STRUCTURE_TYPE_BIND_SPARSE_INFO };
    
    sparse_info.imageBindCount =
    
        sparse_binding_infos.size;
    
    sparse_info.pImageBinds = sparse_binding_infos.data;
    
    sparse_info.signalSemaphoreCount = 1;
    
    sparse_info.pSignalSemaphores =
    
        &vulkan_bind_semaphore;
    
    vkQueueBindSparse( vulkan_main_queue, 1, &sparse_info,
    
                       VK_NULL_HANDLE );
    

需要注意的是,确保在访问我们刚刚更新的资源分配之前完成此操作是用户的责任。我们通过发出一个信号量vulkan_bind_semaphore来实现这一点,然后主渲染工作提交将等待该信号量。

需要注意的是,我们在其上调用vkQueueBindSparse的队列必须具有VK_QUEUE_SPARSE_BINDING_BIT标志。

在本节中,我们介绍了分配和使用稀疏纹理所需的步骤。我们首先解释了稀疏纹理的工作原理以及为什么它们对我们使用立方体贴图的情况很有用。

接下来,我们说明了我们用来根据每个灯光对场景的贡献动态确定每个立方体贴图分辨率的算法。最后,我们展示了如何使用 Vulkan API 将内存绑定到稀疏资源。

摘要

在本章中,我们将我们的照明系统扩展到支持许多点光源,并实现了高效的实现。我们从阴影算法的简要历史开始,讨论了它们的优缺点,直到最近利用光线追踪硬件的一些技术。

接下来,我们介绍了我们对许多点光源阴影的实现。我们解释了为每个灯光生成立方体贴图的方法,以及我们为了使算法能够扩展到许多灯光而实施的优化。特别是,我们强调了从主几何体遍历中重用的剔除方法以及每个灯光使用单个间接绘制调用的方法。

在最后一节中,我们介绍了稀疏纹理,这是一种允许我们动态地将内存绑定到给定资源的技术。我们强调了我们用来确定每个点光源对场景的贡献的算法,以及我们如何使用这些信息来确定每个立方体贴图的分辨率。最后,我们展示了如何使用 Vulkan API 与稀疏资源一起使用。

尽管我们本章只涵盖了点光源,但一些技术可以与其他类型的灯光一起重用。一些步骤也可以进一步优化:例如,可以进一步降低立方体贴图的分辨率,仅考虑几何体可见的区域。

为了清晰起见,集群计算目前是在 CPU 上完成的,以避免从 GPU 读取集群数据,这可能是一个缓慢的操作,但可能值得将实现移至 GPU。我们鼓励您尝试代码并添加更多功能!

进一步阅读

书籍《实时阴影》提供了许多实现阴影的技术的好概述,其中许多至今仍在使用。

《GPU Pro 360 阴影指南》收集了专注于阴影的《GPU Pro》系列文章。

书中描述的一种有趣的技术称为四面体阴影映射:其想法是将阴影映射投影到一个四面体上,然后将其展开到一个单独的纹理上。

原始概念是在使用四面体映射进行全向光阴影映射章节中引入的(最初发表在GPU Pro中),后来在基于瓦片的全方位阴影(最初发表在GPU Pro 6中)中进行了扩展。

更多细节,请参考作者提供的代码:www.hd-prg.com/tileBasedShadows.xhtml.

我们的稀疏纹理实现基于这个 SIGGRAPH 演示:efficientshading.com/wp-content/uploads/s2015_shadows.pdf.

这扩展了他们的原始论文,可以在以下链接找到:newq.net/dl/pub/MoreEfficientClusteredShadowsPreprint.pdf.

虽然我们在这章中没有实现它,但阴影映射缓存是一种重要的技术,可以减少计算阴影映射的成本,并将阴影映射更新分摊到多个帧上。

一个好的起点是这个演示:www.activision.com/cdn/research/2017_DD_Rendering_of_COD_IW.pdf.

我们集群计算的方法与本文中提出的方法非常接近:www.aortiz.me/2018/12/21/CG.xhtml#part-2.

Vulkan 规范提供了更多关于如何使用 API 进行稀疏资源的信息:registry.khronos.org/vulkan/specs/1.2-extensions/html/vkspec.xhtml#sparsememory.

第九章:实现可变率着色

在本章中,我们将实现一种最近变得相当流行的技术:可变率着色。这项技术允许开发者指定以何种速率对单个像素进行着色,同时保持相同的视觉质量感知。这种方法使我们能够减少某些渲染过程所需的时间,而这些节省下来的时间可以用来实现更多功能或以更高的分辨率进行渲染。

Vulkan 提供了多种将此技术集成到应用程序中的选项,我们将概述所有这些选项。此功能通过一个仅支持在最新硬件上运行的扩展提供,但可以使用计算着色器手动实现它。我们不会在这里介绍这个选项,但将在进一步阅读部分指出相关资源。

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

  • 可变率着色介绍

  • 使用 Vulkan API 实现可变率着色

  • 使用专用常量配置计算着色器

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter9

可变率着色介绍

可变率着色VRS)是一种允许开发者控制片段着色速率的技术。当此功能禁用时,所有片段都使用 1x1 的速率进行着色,这意味着片段着色器将为图像中的所有片段运行。

随着虚拟现实(VR)头显的引入,开发者开始研究减少渲染一帧所需时间的方法。这至关重要,不仅因为 VR 需要渲染两帧(一帧用于右眼,一帧用于左眼),而且因为 VR 对帧延迟非常敏感,需要更高的帧率来避免用户出现运动病。

开发出来的一种技术被称为视野渲染:其想法是在全速率渲染图像中心的同时降低中心以外的质量。开发者注意到,用户主要关注图像的中央区域,而不会注意到周围区域的低质量。

事实证明,这种方法可以推广到 VR 之外。因此,DirectX®和 Vulkan 等 API 已经原生地添加了对该功能的支持。

使用这种更通用的方法,可以为单个片段指定多个着色速率。通常推荐使用的速率是 1x1、1x2、2x1 和 2x2。虽然可能采用更高的着色速率,但这通常会导致最终帧中出现可见的伪影。

正如我们提到的,1x1 的比率意味着片段着色器将在图像中的所有片段上运行,没有时间节省。这是未启用 VRS 时的默认行为。

1x2 或 2x1 的比率意味着两个片段将由单个片段着色器调用着色,计算出的值应用于这两个片段。同样,2x2 的阴影率意味着单个片段调用将计算并应用单个值到四个片段上。

确定阴影率

有多种选择来为单个片段选择阴影率,我们实现的方法是在光照过程之后运行基于亮度的边缘检测过滤器。

理念是在图像中亮度均匀的区域降低阴影率,在过渡区域使用全率。这种方法有效,因为人眼对这些区域的改变比那些具有更均匀值的区域更敏感。

我们使用的过滤器是 3x3 配置中的传统 Sobel 过滤器。对于每个片段,我们计算两个值:

图 9.1 – 用于近似给定片段的 x 和 y 导数的过滤器(来源:维基百科 –https://en.wikipedia.org/wiki/Sobel_operator)

图 9.1 – 用于近似给定片段的 x 和 y 导数的过滤器(来源:维基百科 –https://en.wikipedia.org/wiki/Sobel_operator)

我们随后使用以下公式计算最终的导数值:

图 9.2 – 近似导数值的公式(来源:维基百科 –https://en.wikipedia.org/wiki/Sobel_operator)

图 9.2 – 近似导数值的公式(来源:维基百科 –https://en.wikipedia.org/wiki/Sobel_operator)

让我们将 Sobel 过滤器应用于以下图像:

图 9.3 – 光照过程后的渲染帧

图 9.3 – 光照过程后的渲染帧

它为我们提供了以下阴影率掩码:

图 9.4 – 计算出的阴影率掩码

图 9.4 – 计算出的阴影率掩码

在我们的实现中,我们将为G值(如图 9.2中的公式计算)大于0.1的片段使用完整的 1x1 比率。这些是图 9.4中的黑色像素。

对于G值低于0.1的片段,我们将使用 2x2 的比率,这些片段是图 9.4中的截图中的红色像素。我们将在下一节中解释如何计算掩码中的值。

在本节中,我们介绍了可变率着色的概念,并提供了我们实现的详细信息。在下一节中,我们将演示如何使用 Vulkan API 实现此功能。

使用 Vulkan 集成可变率着色

正如我们在上一节中提到的,片段着色率功能是通过 VK_KHR_fragment_shading_rate 扩展提供的。与其他选项扩展一样,在使用相关 API 之前,请确保您使用的设备支持它。

Vulkan 提供了三种方法来控制着色率:

  • 每个绘制

  • 每个原语

  • 使用图像附件进行渲染通道

要使用每个绘制自定义着色率,有两种选择。我们可以在创建管道时传递 VkPipelineFragmentShadingRateStateCreateInfoKHR 结构,或者我们可以在运行时调用 vkCmdSetFragmentShadingRateKHR

当我们事先知道某些绘制可以在不影响质量的情况下以较低速率执行时,这种方法很有用。这可能包括我们知道远离相机的天空或物体。

还可以为每个原语提供一个着色率。这是通过从顶点着色器或网格着色器中填充内置着色器变量 PrimitiveShadingRateKHR 来实现的。

如果,例如,我们确定可以在网格着色器中使用较低级别的细节并降低渲染特定原语的速率,这可能会很有用。

对于我们的实现,我们决定使用第三种方法,因为它对我们的用例来说更灵活。正如我们在上一节中提到的,我们首先需要计算可变率着色率掩码。这是通过一个计算着色器完成的,该着色器填充着色率图像。

我们首先填充一个在着色器调用中共享的表格:

shared float local_image_data[ LOCAL_DATA_SIZE ][
   LOCAL_DATA_SIZE ];
local_image_data[ local_index.y ][ local_index.x ] = 
   luminance( texelFetch( global_textures[ 
   color_image_index ], global_index, 0 ).rgb ); 
barrier();

表格中的每个条目都包含此着色器调用中片段的亮度值。

我们使用这种方法来减少我们需要执行的纹理读取次数。如果每个着色器线程都必须单独读取它需要的值,我们可能需要八次纹理读取。使用这个解决方案,每个线程只需要一次读取。

对于我们正在处理的区域的边界上的片段线程,有一个需要注意的地方。随着每次着色器调用,我们处理 16x16 个片段,但由于 Sobel 滤波器的工作方式,我们需要填充一个 18x18 的表格。对于边界的线程,我们需要进行一些额外的处理以确保表格被完全填充。为了简洁,这里省略了代码。

注意,我们必须使用 barrier() 方法来确保这个工作组内的所有线程都完成了它们的写入。如果没有这个调用,线程将计算错误的价值,因为表格将不会正确填充。

接下来,我们计算给定片段的导数值:

float dx = local_image_data[ local_index.y - 1 ][
    local_index.x - 1 ] - local_image_data[
    local_index.y - 1 ][ local_index.x + 1 ] +
    2 * local_image_data[ local_index.y ][
    local_index.x - 1 ] -
    2 * local_image_data[ local_index.y ][
    local_index.x + 1 ] +
    local_image_data[ local_index.y + 1][
    local_index.x - 1 ] -
    local_image_data[ local_index.y + 1 ][
    local_index.x + 1 ];
float dy = local_image_data[ local_index.y - 1 ][
    local_index.x - 1 ] +
    2 * local_image_data[ local_index.y - 1 ][
    local_index.x ] +
    local_image_data[ local_index.y - 1 ][
    local_index.x + 1 ] -
    local_image_data[ local_index.y + 1 ][
    local_index.x - 1 ] -
    2 * local_image_data[ local_index.y + 1 ][
    local_index.x ] -
    local_image_data[ local_index.y + 1 ][
    local_index.x + 1 ];
float d = pow( dx, 2 ) + pow( dy, 2 );

这只是简单地应用我们在上一节中介绍的公式。现在我们已经计算了导数,我们需要存储这个片段的着色率:

uint rate = 1 << 2 | 1;
if ( d > 0.1 ) {
    rate = 0;
}
imageStore( global_uimages_2d[ fsr_image_index ], ivec2(
    gl_GlobalInvocationID.xy ), uvec4( rate, 0, 0, 0 ) );

速率是按照 Vulkan 规范中的公式计算的:

size_w = 2^( ( texel / 4 ) & 3 )
size_h = 2^( texel & 3 )

在我们的情况下,我们正在计算前一个公式中的 texel 值。我们为 xy 着色率设置指数(01),并将值存储在着色率图像中。

一旦着色率图像被填充,我们就可以使用它为下一帧的渲染通道提供着色率。在使用此图像之前,我们需要将其转换为正确的布局:

VK_IMAGE_LAYOUT_FRAGMENT_SHADING_RATE_ATTACHMENT_OPTIMAL_
    KHR

我们还需要使用一个新的管线阶段:

VK_PIPELINE_STAGE_FRAGMENT_SHADING_RATE_ATTACHMENT_BIT_KHR

有几种方法可以将新创建的着色率图像作为渲染通道的一部分使用。VkSubpassDescription2结构可以通过VkFragmentShadingRateAttachmentInfoKHR结构扩展,该结构指定了要使用哪个附加作为片段着色率。由于我们尚未使用RenderPass2扩展,我们选择扩展我们现有的动态渲染实现。

我们必须使用以下代码扩展VkRenderingInfoKHR结构:

VkRenderingFragmentShadingRateAttachmentInfoKHR
shading_rate_info {
    VK_STRUCTURE_TYPE_RENDERING_FRAGMENT_SHADING
        _RATE_ATTACHMENT_INFO_KHR };
shading_rate_info.imageView = texture->vk_image_view;
shading_rate_info.imageLayout =
    VK_IMAGE_LAYOUT_FRAGMENT_SHADING_RATE
        _ATTACHMENT_OPTIMAL_KHR;
shading_rate_info.shadingRateAttachmentTexelSize = { 1, 1 };
rendering_info.pNext = ( void* )&shading_rate_info;

就这样!用于渲染的着色器不需要任何修改。

在本节中,我们详细说明了修改我们的渲染代码以使用着色率图像所需的更改。我们还提供了实现基于索贝尔滤波器的边缘检测算法的计算着色器的实现。

此算法的结果然后用于确定每个片段的着色率。

在下一节中,我们将介绍特殊化常量,这是一个 Vulkan 特性,允许我们控制计算着色器的工作组大小以获得最佳性能。

利用特殊化常量

特殊化常量是 Vulkan 的一个特性,允许开发者在创建管线时定义常量值。这在需要相同着色器但只有一些常量值不同的多个用例时特别有用,例如材料。与预处理定义相比,这是一个更优雅的解决方案,因为它们可以在运行时动态控制,而无需重新编译着色器。

在我们的情况下,我们希望能够根据我们运行的硬件控制计算着色器的工作组大小以获得最佳性能:

  1. 实现的第一步是确定着色器是否使用特殊化常量。我们现在在解析着色器 SPIR-V 时识别任何被以下类型装饰的变量:

    case ( SpvDecorationSpecId ):
    
    {
    
        id.binding = data[ word_index + 3 ];
    
        break;
    
    }
    
  2. 在解析所有变量时,我们现在保存特殊化常量的详细信息,以便在编译使用此着色器的管线时使用:

    switch ( id.op ) {
    
        case ( SpvOpSpecConstantTrue ):
    
        case ( SpvOpSpecConstantFalse ):
    
        case ( SpvOpSpecConstant ):
    
        case ( SpvOpSpecConstantOp ):
    
        case ( SpvOpSpecConstantComposite ):
    
        {
    
            Id& id_spec_binding = ids[ id.type_index ];
    
    SpecializationConstant& 
    
       specialization_constant = parse_result-> 
    
       specialization_constants[ 
    
       parse_result-> 
    
       specialization_constants_count 
    
       ]; 
    
            specialization_constant.binding =
    
                id_spec_binding.binding;
    
            specialization_constant.byte_stride =
    
                id.width / 8;
    
            specialization_constant.default_value =
    
                id.value;
    
            SpecializationName& specialization_name =
    
             parse_result->specialization_names[
    
                 parse_result->
    
                 specialization_constants_count ];
    
            raptor::StringView::copy_to(
    
                id_spec_binding.name,
    
                     specialization_name.name, 32 );
    
            ++parse_result->
    
                specialization_constants_count;
    
            break;
    
        }
    
    }
    
  3. 现在我们有了特殊化常量的信息,我们可以在创建管线时更改它们的值。我们首先填充一个VkSpecializationInfo结构:

    VkSpecializationInfo specialization_info;
    
    VkSpecializationMapEntry specialization_entries[
    
        spirv::k_max_specialization_constants ];
    
    u32 specialization_data[
    
        spirv::k_max_specialization_constants ];
    
    specialization_info.mapEntryCount = shader_state->
    
        parse_result->specialization_constants_count;
    
    specialization_info.dataSize = shader_state->
    
        parse_result->specialization_constants_count *
    
            sizeof( u32 );
    
    specialization_info.pMapEntries =
    
        specialization_entries;
    
    specialization_info.pData = specialization_data;
    
  4. 然后我们为每个特殊化常量条目设置值:

    for ( u32 i = 0; i < shader_state->parse_result->
    
        specialization_constants_count; ++i ) {
    
        const spirv::SpecializationConstant&
    
            specialization_constant = shader_state->
    
                parse_result->
    
                    specialization_constants[ i ];
    
        cstring specialization_name = shader_state->
    
            parse_result->specialization_names[ i ].name;
    
        VkSpecializationMapEntry& specialization_entry =
    
            specialization_entries[ i ];
    
        if ( strcmp(specialization_name, "SUBGROUP_SIZE")
    
            == 0 ) {
    
                       specialization_entry.constantID =
    
                          specialization_constant.binding;
    
            specialization_entry.size = sizeof( u32 );
    
            specialization_entry.offset = i * sizeof( u32 );
    
            specialization_data[ i ] = subgroup_size;
    
        }
    
    }
    

在我们的情况下,我们正在寻找一个名为SUBGROUP_SIZE的变量。最后一步是将特殊化常量细节存储在创建管线时将使用的着色器阶段结构中:

shader_stage_info.pSpecializationInfo =
    &specialization_info;

在编译过程中,驱动程序和编译器将覆盖着色器中现有的值,使用我们指定的值。

在本节中,我们说明了如何利用专用常量在运行时修改着色器行为。我们详细介绍了在解析 SPIR-V 二进制文件时我们进行的更改,以识别专用常量。然后,我们强调了在创建管线时覆盖专用常量值所需的新代码。

摘要

在本章中,我们介绍了可变率着色技术。我们简要概述了这种方法及其如何用于在不损失感知质量的情况下提高某些渲染通道的性能。我们还解释了用于确定每个片段着色率的边缘检测算法。

在下一节中,我们说明了启用和使用 Vulkan API 中此功能所需的更改。我们详细介绍了在绘制、原语和渲染通道级别更改着色率的选项。然后,我们解释了使用计算着色器实现的边缘检测算法以及如何使用结果生成着色率图像。

在最后一节中,我们介绍了专用常量,这是 Vulkan API 提供的一种机制,可以在编译时修改着色器常量值。我们说明了如何使用此功能根据代码运行的设备来控制计算着色器的组大小以实现最佳性能。

在下一章中,我们将向场景中引入体积效果。这项技术允许我们设定环境的氛围,并可用于引导玩家注意特定区域。

进一步阅读

我们只对可变率着色的 Vulkan API 给了一个简要概述。我们建议阅读规范以获取更多详细信息:registry.khronos.org/vulkan/specs/1.3-extensions/html/vkspec.xhtml#primsrast-fragment-shading-rate

在线可用的资源似乎大多集中在 DirectX API 上,但相同的策略可以转换为 Vulkan。本博客文章提供了一些关于 VRS 优势的细节:devblogs.microsoft.com/directx/variable-rate-shading-a-scalpel-in-a-world-of-sledgehammers/

这两个视频提供了将 VRS 集成到现有游戏引擎中的深入细节。特别是关于如何使用计算着色器实现 VRS 的部分特别有趣:

本文说明了 VRS 也可以有其他用途,例如,加速光线追踪:interplayoflight.wordpress.com/2022/05/29/accelerating-raytracing-using-software-vrs/

第十章:添加体积雾

在上一章添加了可变率着色后,我们将实现另一种现代技术,这将增强 Raptor 引擎的视觉效果:体积雾。体积渲染和雾在渲染文献中是非常古老的话题,但直到几年前,它们还被认为不适合实时使用。

使这一技术在实时中可行的是观察到的雾是一个低频效应;因此渲染可以比屏幕分辨率低得多,从而提高实时使用中的性能。

此外,计算着色器的引入,以及因此通用的 GPU 编程,加上对技术体积方面的近似和优化巧妙观察,为实时体积雾的解锁铺平了道路。

主要思想来源于巴特·沃罗尼斯基在 2014 年 Siggraph 会议上发表的奠基性论文(bartwronski.files.wordpress.com/2014/08/bwronski_volumetric_fog_siggraph2014.pdf),他在那里描述了即使在几乎 10 年后,这一技术的核心思想仍然如此。

实现这一技术对于了解帧的不同渲染部分之间的协同作用也很重要:开发单一技术可能具有挑战性,但与其他技术的交互同样重要,并且可以增加技术的挑战性

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

  • 介绍体积雾渲染

  • 实现体积雾基础技术

  • 添加空间和时间滤波以改善视觉效果

到本章结束时,我们将把体积雾集成到 Raptor 引擎中,与场景和所有动态灯光交互,如下图所示:

图 10.1 – 带有密度体积和三个投射阴影的光的体积雾

图 10.1 – 带有密度体积和三个投射阴影的光的体积雾

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter10

介绍体积雾渲染

那么,体积雾渲染究竟是什么?正如其名所示,它是体积渲染和雾现象的结合。现在我们将对这些组件进行一些背景介绍,并看看它们如何在最终技术中结合在一起。

让我们从体积渲染开始。

体积渲染

这种渲染技术描述了当光线穿过参与介质时发生的视觉效果。参与介质是一个包含密度或反照率局部变化的体积。

以下图表总结了在参与介质中光子发生的情况:

图 10.2 – 参与介质中的光行为

图 10.2 – 参与介质中的光行为

我们试图描述的是光通过参与介质(即雾体积、云或大气散射)时的变化。

有三种主要现象发生,如下所述:

  • 吸收:这发生在光线简单地被困在介质内部,无法逸出。这是一个能量的净损失。

  • 外散射:这在图 10.2 中用绿色箭头表示,并且是能量从介质中逸出(因此可见)的损失。

  • 内散射:这是来自与介质相互作用的灯光的能量。

虽然这三个现象足以描述光的行为,但在完全理解体渲染之前,还需要了解三个其他组成部分。

相位函数

第一个组成部分是相位函数。这个函数描述了光在不同方向上的散射。它依赖于光向量与出射方向之间的角度。

这个函数可能很复杂,试图以现实的方式描述散射,但最常用的是亨尼海-格林斯坦函数,这是一个也考虑各向异性的函数。

亨尼海-格林斯坦函数的公式如下:

图 10.3 – 亨尼海-格林斯坦函数

图 10.3 – 亨尼海-格林斯坦函数

在前面的方程中,角度 theta 是视向量与光向量之间的角度。我们将在着色器代码中看到如何将其转换为可用的东西。

灭减

第二个组成部分是灭减。灭减是描述光散射程度的量。我们将在算法的中间步骤中使用它,但为了应用计算出的雾,我们需要透射率。

透射率

第三个也是最后一个组成部分是透射率。透射率是光通过介质一段的灭减,它使用比尔-朗伯定律来计算:

图 10.4 – 比尔-朗伯定律

图 10.4 – 比尔-朗伯定律

在最终的积分步骤中,我们将计算透射率并使用它来选择如何将雾应用到场景中。这里重要的是要掌握基本概念;章节末尾将提供链接以加深对数学背景的理解。

我们现在拥有了查看体雾实现细节所需的所有概念。

体雾

现在我们已经了解了贡献于体渲染的不同组成部分,我们可以从算法的角度进行鸟瞰。巴特·沃罗尼斯基在开发这项技术时,第一个也是最巧妙的想法之一是使用一个截锥体对齐的体积纹理,如下所示:

图 10.5 – 视锥对齐体积纹理

图 10.5 – 视锥对齐体积纹理

使用体积纹理和与标准光栅化渲染相关的数学,我们可以创建相机视锥和纹理之间的映射。这种映射已经在渲染的不同阶段发生,例如,在乘以视图投影矩阵的顶点位置时,所以这不是什么新东西。

新的是在体积纹理中存储信息以计算体积渲染。这个纹理的每个元素通常被称为froxel,代表视锥体素

我们选择了一个宽度、高度和深度为 128 个单位的纹理,但其他解决方案使用宽度高度取决于屏幕分辨率,类似于聚簇着色。

我们将使用具有这种分辨率的纹理作为中间步骤,并且对于额外的滤波,我们将在稍后讨论。一个额外的决定是使用非线性深度分布来增加相机的分辨率,将线性范围映射到指数范围。

我们将使用一个分布函数,例如 Id 在他们的 iD Tech 引擎中使用的函数,如下所示:

图 10.6 – 体积纹理在 Z 坐标函数上的深度切片

图 10.6 – 体积纹理在 Z 坐标函数上的深度切片

现在我们已经决定了体积纹理和世界单位之间的映射,我们可以描述实现完整工作体积雾解决方案所需的步骤。

算法在以下图中概述,其中矩形代表着色器执行,而椭圆代表纹理:

图 10.7 – 算法概述

图 10.7 – 算法概述

我们现在将逐步查看算法的每个步骤,以创建一个心理模型来了解正在发生的事情,我们将在本章后面回顾着色器。

数据注入

第一步是数据注入。这个着色器将把一些以颜色和密度形式存在的彩色雾添加到只包含数据的第一个视锥对齐纹理中。我们决定添加一个常量雾、一个基于高度的雾和一个雾体积,以模拟更真实的游戏开发设置。

光散射

在执行光散射时,我们计算场景中灯光的入射散射。

在拥有一个工作的聚簇光照算法后,我们将重用相同的数据结构来计算每个 froxel 的光贡献,注意以不同于标准聚簇光照的方式处理光 – 这里没有漫反射或镜面反射,而只是一个由衰减、阴影和相位给出的全局项。

我们还采样与灯光相关的阴影图,以实现更逼真的行为。

空间滤波

为了消除一些噪声,我们仅在视锥对齐纹理的 X 轴和 Y 轴上应用高斯滤波器,然后传递到最重要的滤波器,即时间滤波器。

时间滤波

这个过滤器通过在算法的不同步骤添加一些噪声来消除一些色带,从而真正提高了视觉效果。它将读取前一帧的最终纹理(集成之前的那个),并根据某个常数因子将当前的光散射结果与之前的结果混合。

这是一个非常困难的话题,因为时间滤波和重投影可能会引起一些问题。我们将在下一章讨论时间****反走样TAA)时进行更深入的讨论。

在散射和消光完成后,我们可以执行光积分,从而准备场景将要采样的纹理。

光积分

这一步准备另一个对齐体积纹理来包含雾的积分。基本上,这个着色器模拟了低分辨率的射线投射,以便这个结果可以被场景采样。

射线投射通常从摄像机开始,向场景的远平面延伸。对齐体积纹理和这个积分的结合为每个 froxel 提供了一个缓存的射线投射光散射,以便场景可以轻松采样。在这个步骤中,我们从之前纹理中保存的所有消光中最终计算出透射率,并使用它将雾合并到场景中。

这和时序滤波是一些重大创新,解锁了此算法的实时可能性。在更高级的解决方案中,例如在游戏《荒野大镖客救赎 2》中,可以添加额外的射线投射来模拟更远距离的雾。

它还允许雾和体积云(使用纯射线投射方法)混合,实现几乎无缝的过渡。这在上 Siggraph 关于《荒野大镖客救赎 2》渲染的演示中详细解释。

集群光照中的场景应用

最后一步是使用世界位置在光照着色器中读取体积纹理。我们可以读取深度缓冲区,计算世界位置,计算 froxel 坐标并采样纹理。

为了进一步平滑体积外观,可以先将场景应用渲染到半分辨率纹理,然后使用几何感知上采样将其应用到场景中,但这个步骤将留给你作为练习来完成。

实现体积雾渲染

现在我们已经拥有了读取使此算法完全工作的代码所需的所有知识。从 CPU 的角度来看,它只是一系列计算着色器调度,所以很简单。

这种技术的核心在各个着色器中实现,因此也在 GPU 上实现,几乎处理了我们在上一节中提到的关于对齐体积纹理的几乎所有步骤。

图 10.7 展示了不同的算法步骤,我们将在接下来的章节中逐一介绍。

数据注入

在第一个着色器中,我们将从不同雾现象的颜色和密度开始编写散射和消光:

我们决定添加三种不同的雾效果,如下所示:

  • 恒定雾

  • 高度雾

  • 体积中的雾

对于每种雾,我们需要计算散射和消光并将它们累积:

以下代码将颜色和密度转换为散射和消光:

vec4 scattering_extinction_from_color_density( vec3 color,
    float density ) {
    const float extinction = scattering_factor * density;
    return vec4( color * extinction, extinction );
}

我们现在可以查看主着色器。这个着色器,就像本章中的大多数其他着色器一样,将被调度为每个 froxel 单元一个线程:

在第一部分,我们将看到用于计算世界位置的调度和代码:

layout (local_size_x = 8, local_size_y = 8, local_size_z =
        1) in;
void main() {
    ivec3 froxel_coord = ivec3(gl_GlobalInvocationID.xyz);
    vec3 world_position = world_from_froxel(froxel_coord);
    vec4 scattering_extinction = vec4(0);

我们添加了一个可选的噪声来动画化雾并打破恒定的密度:

    vec3 sampling_coord = world_position *
       volumetric_noise_position_multiplier +
       vec3(1,0.1,2) * current_frame *
       volumetric_noise_speed_multiplier; 
    vec4 sampled_noise = texture(
       global_textures_3d[volumetric_noise_texture_index],
       sampling_coord);
    float fog_noise = sampled_noise.x;

这里,我们添加并累积恒定雾:

    // Add constant fog
    float fog_density = density_modifier * fog_noise;
    scattering_extinction += 
       scattering_extinction_from_color_density( 
       vec3(0.5), fog_density ); 

然后,添加并累积高度雾:

    // Add height fog
    float height_fog = height_fog_density *
       exp(-height_fog_falloff * max(world_position.y, 0)) *
       fog_noise;
    scattering_extinction += 
       scattering_extinction_from_color_density( 
       vec3(0.5), height_fog ); 

最后,从盒子中添加密度:

    // Add density from box
    vec3 box = abs(world_position - box_position);
    if (all(lessThanEqual(box, box_size))) {
        vec4 box_fog_color = unpack_color_rgba( box_color
                                              );
        scattering_extinction +=
            scattering_extinction_from_color_density(
                box_fog_color.rgb, box_fog_density *
                    fog_noise);
    }

我们最终存储散射和消光,以便在下一个着色器中照明:

    imageStore(global_images_3d[froxel_data_texture_index],
               froxel_coord.xyz, scattering_extinction );
}

计算光照贡献

照明将使用已在通用照明函数中使用的 Clustered Lighting 数据结构执行。在这个着色器中,我们计算光线的入射散射:

着色器调度与上一个着色器相同,一个线程对应一个 froxel:

layout (local_size_x = 8, local_size_y = 8, local_size_z =
        1) in;
void main() {
    ivec3 froxel_coord = ivec3(gl_GlobalInvocationID.xyz);
    vec3 world_position = world_from_froxel(froxel_coord);
    vec3 rcp_froxel_dim = 1.0f / froxel_dimensions.xyz;

我们从注入着色器的结果中读取散射和消光:

vec4 scattering_extinction = texture(global_textures_3d 
   [nonuniformEXT(froxel_data_texture_index)], 
   froxel_coord * rcp_froxel_dim);
   float extinction = scattering_extinction.a;

然后开始累积光线并使用集群的箱子:

注意不同渲染算法之间的协作:由于已经开发了集群的箱子,我们可以使用它从世界空间位置查询定义体积中的光源:

vec3 lighting = vec3(0);
vec3 V = normalize(camera_position.xyz - world_position);
// Read clustered lighting data
// Calculate linear depth
float linear_d = froxel_coord.z * 1.0f /
   froxel_dimension_z;
linear_d = raw_depth_to_linear_depth(linear_d,
   froxel_near, froxel_far) / froxel_far;
// Select bin
int bin_index = int( linear_d / BIN_WIDTH );
uint bin_value = bins[ bin_index ];
// As in calculate_lighting method, cycle through
// lights to calculate contribution
for ( uint light_id = min_light_id;
    light_id <= max_light_id;
    ++light_id ) {
    // Same as calculate_lighting method
    // Calculate point light contribution
    // Read shadow map for current light
    float shadow = current_depth –
       bias < closest_depth ? 1 : 0;
    const vec3 L = normalize(light_position –
       world_position);
    float attenuation = attenuation_square_falloff(
       L, 1.0f / light_radius) * shadow;

到目前为止,代码几乎与照明中使用的代码相同,但我们添加了phase_function来最终化光照因子:

    lighting += point_light.color *
       point_light.intensity *
       phase_function(V, -L,
         phase_anisotropy_01) *
       attenuation;
                    }

最终的散射计算并存储,如下所示:

vec3 scattering = scattering_extinction.rgb * lighting;
imageStore( global_images_3d
            [light_scattering_texture_index],
            ivec3(froxel_coord.xyz), vec4(scattering,
            extinction) );
}

现在,我们将查看积分/光线追踪着色器,以总结实现算法体积部分所需的主要着色器。

散射和消光的积分

这个着色器负责在 froxel 纹理中执行光线追踪并在每个单元格中进行中间计算。它仍然会写入一个与锥体对齐的纹理,但每个单元格将包含从该单元格开始的累积散射和透射率:

注意,我们现在使用透射率而不是消光,透射率是一个将消光积分到一定空间的量。调度仅针对锥体纹理的 XY 轴,读取光线散射纹理,因为我们将在主循环中执行积分步骤并将结果写入每个 froxel:

最终存储的结果是散射和透射率,因此可以更容易地将其应用于场景:

// Dispatch with Z = 1 as we perform the integration.
layout (local_size_x = 8, local_size_y = 8, local_size_z =
        1) in;
void main() {
    ivec3 froxel_coord = ivec3(gl_GlobalInvocationID.xyz);
    vec3 integrated_scattering = vec3(0,0,0);
    float integrated_transmittance = 1.0f;
    float current_z = 0;
    vec3 rcp_froxel_dim = 1.0f / froxel_dimensions.xyz;

我们在 Z 轴上积分,因为该纹理是锥体对齐的:

首先,我们计算深度差以获得消光积分所需的厚度:

    for ( int z = 0; z < froxel_dimension_z; ++z ) {
        froxel_coord.z = z;
         float next_z = slice_to_exponential_depth(
                        froxel_near, froxel_far, z + 1,
                        int(froxel_dimension_z) );
        const float z_step = abs(next_z - current_z);
        current_z = next_z;

我们将计算散射和透射率,并将它们累积到下一个在 Z 轴上的单元格:

        // Following equations from Physically Based Sky,
           Atmosphere and Cloud Rendering by Hillaire
        const vec4 sampled_scattering_extinction =
        texture(global_textures_3d[
        nonuniformEXT(light_scattering_texture_index)],
        froxel_coord * rcp_froxel_dim);
        const vec3 sampled_scattering =
            sampled_scattering_extinction.xyz;
        const float sampled_extinction =
            sampled_scattering_extinction.w;
        const float clamped_extinction =
            max(sampled_extinction, 0.00001f);
        const float transmittance = exp(-sampled_extinction
                                        * z_step);
        const vec3 scattering = (sampled_scattering –
                                (sampled_scattering *
                                transmittance)) /
                                clamped_extinction;
        integrated_scattering += scattering *
                                 integrated_transmittance;
        integrated_transmittance *= transmittance;
        imageStore( global_images_3d[
           integrated_light_scattering_texture_index],
           froxel_coord.xyz,
           vec4(integrated_scattering,
              integrated_transmittance) );
    }
}

现在我们有一个包含光线追踪散射和透射值的体积纹理,可以从帧中的任何位置查询,以了解有多少雾以及该点的颜色。

这就完成了算法的主要体积渲染方面。我们现在来看看将雾应用于场景有多容易。

将体积雾应用于场景

我们最终可以应用体积雾。为此,我们使用屏幕空间坐标来计算纹理的采样坐标。这个函数将在延迟和前向渲染路径的照明计算结束时使用。

我们首先计算采样坐标:

vec3 apply_volumetric_fog( vec2 screen_uv, float raw_depth,
                           vec3 color ) {
    const float near = volumetric_fog_near;
    const float far = volumetric_fog_far;
    // Fog linear depth distribution
    float linear_depth = raw_depth_to_linear_depth(
                         raw_depth, near, far );
    // Exponential
    float depth_uv = linear_depth_to_uv( near, far,
        linear_depth, volumetric_fog_num_slices );
vec4 scattering_transmittance =
   texture(global_textures_3d
   [nonuniformEXT(volumetric_fog_texture_index)], 
   froxel_uvw);

在读取指定位置的散射和透射值之后,我们使用透射值来调制当前场景颜色并添加雾散射颜色,如下所示:

    color.rgb = color.rgb * scattering_transmittance.a +
                scattering_transmittance.rgb;
    return color;
}

这就完成了完全实现体积雾渲染的必要步骤。但仍然存在一个大问题:带状效应

这是一个在几篇论文中讨论的广泛主题,但为了简单起见,我们可以这样说,拥有低分辨率的体积纹理会增加带状问题,但这是实现实时性能所必需的。

添加过滤器

为了进一步提高视觉效果,我们添加了两个不同的过滤器:一个时间过滤器和空间过滤器。

时间过滤器真正实现了差异,因为它给了我们在算法的不同部分添加噪声的可能性,从而消除了带状效应。空间过滤器进一步平滑了雾。

空间过滤

这个着色器将通过应用高斯过滤器来平滑体积纹理的 X 和 Y 轴。它将读取光散射的结果并将其写入 froxel 数据纹理,在当前帧的这个点尚未使用,从而消除了创建临时纹理的需要。

我们首先定义高斯函数及其表示代码:

#define SIGMA_FILTER 4.0
#define RADIUS 2
float gaussian(float radius, float sigma) {
    const float v = radius / sigma;
    return exp(-(v*v));
}

然后我们读取光散射纹理,并且只有在计算出的坐标有效时才累加值和权重:

    vec4 scattering_extinction =
       texture( global_textures_3d[
       nonuniformEXT(light_scattering_texture_index)],
       froxel_coord * rcp_froxel_dim );
    if ( use_spatial_filtering == 1 ) {
        float accumulated_weight = 0;
        vec4 accumulated_scattering_extinction = vec4(0);
        for (int i = -RADIUS; i <= RADIUS; ++i ) {
            for (int j = -RADIUS; j <= RADIUS; ++j ) {
                ivec3 coord = froxel_coord + ivec3(i, j,
                                                   0);
                // if inside
                if (all(greaterThanEqual(coord, ivec3(0)))
                    && all(lessThanEqual(coord,
                    ivec3(froxel_dimension_x,
                    froxel_dimension_y,
                    froxel_dimension_z)))) {
                    const float weight =
                        gaussian(length(ivec2(i, j)),
                            SIGMA_FILTER);
                    const vec4 sampled_value =
                      texture(global_textures_3d[
                        nonuniformEXT(
                          light_scattering_texture_index)],
                            coord * rcp_froxel_dim);
                  accumulated_scattering_extinction.rgba +=
                      sampled_value.rgba * weight;
                    accumulated_weight += weight;
                }
            }
        }
        scattering_extinction =
           accumulated_scattering_extinction /
           accumulated_weight;
    }

我们将结果存储在 froxel 数据纹理中:

    imageStore(global_images_3d[froxel_data_texture_index],
               froxel_coord.xyz, scattering_extinction );
}

下一步是时间过滤。

时间过滤

这个着色器将接受当前计算的 3D 光散射纹理并应用一个时间过滤器。为了做到这一点,它将需要两个纹理,一个用于当前帧,一个用于前一帧,多亏了无绑定,我们只需要更改索引来使用它们。

分派与本章中的大多数着色器类似,每个 froxel 元素都有一个线程。让我们从读取当前光散射纹理开始。

这目前位于froxel_data_texture中,来自空间过滤:

    vec4 scattering_extinction =
       texture( global_textures_3d[
       nonuniformEXT(froxel_data_texture_index)],
       froxel_coord * rcp_froxel_dim );

我们需要计算之前的屏幕空间位置来读取前一个帧的纹理。

我们将计算世界位置,然后使用之前的视图投影来获取 UVW 坐标以读取纹理:

    // Temporal reprojection
    if (use_temporal_reprojection == 1) {
        vec3 world_position_no_jitter =
            world_from_froxel_no_jitter(froxel_coord);
        vec4 sceen_space_center_last =
            previous_view_projection *
                vec4(world_position_no_jitter, 1.0);
        vec3 ndc = sceen_space_center_last.xyz /
                   sceen_space_center_last.w;
        float linear_depth = raw_depth_to_linear_depth(
                             ndc.z, froxel_near, froxel_far
                             );
        float depth_uv = linear_depth_to_uv( froxel_near,
                         froxel_far, linear_depth,
                         int(froxel_dimension_z) );
        vec3 history_uv = vec3( ndc.x * .5 + .5, ndc.y * -
                                .5 + .5, depth_uv );

然后我们检查计算出的 UVW 是否有效,如果是,我们将读取前一个纹理:

        // If history UV is outside the frustum, skip
        if (all(greaterThanEqual(history_uv, vec3(0.0f)))
             && all(lessThanEqual(history_uv, vec3(1.0f)))) {
            // Fetch history sample
            vec4 history = textureLod(global_textures_3d[
               previous_light_scattering_texture_index],
               history_uv, 0.0f);

一旦读取了样本,我们可以根据用户定义的百分比将当前结果与上一个结果合并:

            scattering_extinction.rgb = mix(history.rgb,
                scattering_extinction.rgb,
                    temporal_reprojection_percentage);
            scattering_extinction.a = mix(history.a,
                scattering_extinction.a,
                    temporal_reprojection_percentage);
        }
    }

我们将结果存储回光散射纹理中,以便积分可以在算法的体积部分的最后一步使用它。

    imageStore(global_images_3d[light_scattering_texture_in
               dex],
               froxel_coord.xyz, scattering_extinction );
}

到目前为止,我们已经看到了体积雾完整算法的所有步骤。

最后要看到的是用于动画雾的体积噪声生成,并简要讨论用于去除条纹的噪声和抖动。

体积噪声生成

为了使雾密度更有趣,我们可以采样体积噪声纹理来稍微修改密度。我们可以添加一个单次执行的计算着色器,它将 Perlin 噪声存储在 3D 纹理中,然后在采样雾密度时读取它。

此外,我们可以动画化这个噪声来模拟风动画。着色器很简单,并使用以下 Perlin 噪声函数:

layout (local_size_x = 8, local_size_y = 8, local_size_z =
        1) in;
void main() {
    ivec3 pos = ivec3(gl_GlobalInvocationID.xyz);
    vec3 xyz = pos / volumetric_noise_texture_size;
    float perlin_data = get_perlin_7_octaves(xyz, 4.0);
    imageStore( global_images_3d[output_texture_index],
                pos, vec4(perlin_data, 0, 0, 0) );
}

结果是一个单通道体积纹理,其中包含用于采样的 Perlin 噪声。我们还使用一个特殊的采样器,它在UVW轴上有重复过滤器。

蓝色噪声

作为用于在不同区域偏移采样的附加噪声,我们使用蓝色噪声,从纹理中读取它,并给它添加一个时间组件。

蓝色噪声有许多有趣的特性,并且有许多关于为什么它是视觉感知中优秀噪声的文献,我们将在本章末尾提供链接,但现在,我们只是从具有两个通道的纹理中读取噪声,并将其映射到-11的范围。

映射函数如下:

float triangular_mapping( float noise0, float noise1 ) {
    return noise0 + noise1 - 1.0f;
}

以下操作用于读取蓝色噪声:

float generate_noise(vec2 pixel, int frame, float scale) {
    vec2 uv = vec2(pixel.xy / blue_noise_dimensions.xy);
    // Read blue noise from texture
    vec2 blue_noise = texture(global_textures[
        nonuniformEXT(blue_noise_128_rg_texture_index)],
                      uv ).rg;
    const float k_golden_ratio_conjugate = 0.61803398875;
    float blue_noise0 = fract(ToLinear1(blue_noise.r) +
        float(frame % 256) * k_golden_ratio_conjugate);
    float blue_noise1 = fract(ToLinear1(blue_noise.g) +
        float(frame % 256) * k_golden_ratio_conjugate);
    return triangular_noise(blue_noise0, blue_noise1) *
        scale;
}

最终值将在-11之间,并且可以按任何需要缩放并用于任何地方。

有一个动画蓝色噪声论文承诺更好的质量,但由于许可问题,我们选择了使用这个免费版本。

摘要

在本章中,我们介绍了体积雾渲染技术。在展示代码之前,我们提供了简短的数学背景和算法概述。我们还展示了不同的技术,以改善条纹——这是一个需要仔细平衡噪声和时间重投影的广泛主题。

提出的算法也是一个几乎完整的实现,可以在许多商业游戏中找到。我们还讨论了过滤,特别是与下一章相关的时间滤波器,我们将讨论一种使用时间重投影的防锯齿技术。

在下一章中,我们将看到时间抗锯齿和用于在体积雾中抖动采样的噪声之间的协同作用如何减轻视觉条纹。我们还将展示一种使用用于生成体积噪声的单次使用计算着色器生成自定义纹理的可行方法。

这种技术也用于其他体积算法,如体积云,以存储更多用于生成云形状的自定义噪声。

进一步阅读

本章引用了许多不同的论文,但最重要的是关于基于 GPU 的实时体积渲染的实时体积渲染论文:patapom.com/topics/Revision2013/Revision%202013%20-%20Real-time%20Volumetric%20Rendering%20Course%20Notes.pdf.

该算法仍然是 Bart Wronski 的开创性论文的衍生:bartwronski.files.wordpress.com/2014/08/bwronski_volumetric_fog_siggraph2014.pdf.

在以下链接中,有一些进化和数学改进:www.ea.com/frostbite/news/physically-based-unified-volumetric-rendering-in-frostbite.

对于深度分布,我们参考了 iD Tech 6 中使用的公式:advances.realtimerendering.com/s2016/Siggraph2016_idTech6.pdf.

对于带状和噪声,最全面的论文来自 Playdead:

关于动画蓝色噪声的信息:blog.demofox.org/2017/10/31/animating-noise-for-integration-over-time/

关于抖动、蓝色噪声和黄金比例序列的信息:bartwronski.com/2016/10/30/dithering-part-two-golden-ratio-sequence-blue-noise-and-highpass-and-remap/

可以在这里找到免费的蓝色噪声纹理:momentsingraphics.de/BlueNoise.xhtml.

第三部分:高级渲染技术

在这部分,我们将继续向我们的渲染器添加高级技术,并探讨如何使用光线追踪替换或改进早期章节中开发的一些技术。

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

  • 第十一章**,时间反走样

  • 第十二章**,Ray Tracing 入门

  • 第十三章**,使用 Ray Tracing 重新审视阴影

  • 第十四章**,使用 Ray Tracing 添加动态漫反射全局照明

  • 第十五章**,使用 Ray Tracing 添加反射

第十一章:时间抗锯齿

在本章中,我们将扩展在前一章中提到的概念,当时我们讨论了时间重投影。提高图像质量最常见的方法是采样更多数据(超采样),并将其过滤到所需的采样频率。

在渲染过程中使用的主要技术是多样本抗锯齿,或称为MSAA。另一种用于超采样的技术是时间超采样,或使用两个或更多帧的样本来重建更高品质的图像。

在体积雾技术中,采用类似的方法以非常有效的方式消除由体积纹理低分辨率引起的带状条纹。我们将看到如何通过使用时间****抗锯齿TAA)来实现更好的图像质量。

这种技术在近年来被广泛采用,因为越来越多的游戏开始在核心中使用延迟渲染,并且由于在延迟渲染上应用 MSAA 的困难。有各种尝试使 MSAA 和延迟渲染协同工作,但当时在时间和内存性能方面始终证明是不可行的,因此开始开发替代解决方案。

进入后处理抗锯齿及其众多缩写。第一个被广泛使用的是形态学抗锯齿,或称为MLAA,由当时在 Intel 工作的亚历山大·雷什托夫开发,并在 2009 年的高性能图形会议上展示。

该算法是为了在 CPU 上使用 Intel 的流式单指令多数据扩展SSE)指令而开发的,并提出了一些有趣的解决方案来寻找和改进几何边缘渲染,这推动了后续的实现。后来,索尼圣莫尼卡在《战神 III》中采用了 MLAA,使用 Cell 协同处理单元SPUs)以实时性能执行。

后处理抗锯齿终于在 2011 年由豪尔赫·吉梅内斯和其他人开发出了 GPU 实现,开辟了一个新的渲染研究领域。各种其他游戏工作室开始开发定制的后处理抗锯齿技术并分享他们的细节。

所有这些技术都是基于几何边缘识别和图像增强。

另一个开始出现的新方面是重用前帧的信息以进一步增强视觉效果,例如在锐化形态学抗锯齿,或称为SMAA中,它开始添加时间组件以增强最终图像。

最广泛采用的抗锯齿技术是 TAA,它带来了一系列挑战,但非常适合渲染管线,并允许其他技术(如体积雾)通过引入动画抖动来减少带状条纹,从而提高视觉效果。

TAA 现在已成为大多数游戏引擎的标准,无论是商业的还是私人的。它带来了一些挑战,例如处理透明物体和图像模糊,但我们将看到如何解决这些问题。

在本章的剩余部分,我们将首先看到算法概述,然后深入到实现。我们还将创建一个初始的、极其简单的实现,仅为了展示算法的基本构建块,让您了解如何从头开始编写自定义 TAA 实现。最后,我们将看到算法中的不同改进区域。

让我们看看一个示例场景并突出 TAA 的改进:

图 11.1 – 时间反走样场景

图 11.1 – 时间反走样场景

以下是一些最终结果的截图,有和没有启用 TAA。

图 11.2 – 图 11.1 无(左)和有(右)TAA 的细节

图 11.2 – 图 11.1 无(左)和有(右)TAA 的细节

在本章中,我们将探讨以下主题:

  • 创建最简单的 TAA 实现

  • 技术的逐步改进

  • TAA 之外图像锐化技术概述

  • 使用噪声和 TAA 改善不同图像区域的带状

技术要求

本章的代码可以在以下网址找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter11

概述

在本节中,我们将看到 TAA 渲染技术的算法概述。

TAA 基于通过应用小的偏移到相机投影矩阵并应用一些过滤器来生成最终图像的样本集合,如下所示:

图 11.3 – 楔形抖动

图 11.3 – 楔形抖动

有各种数值序列可以用来偏移相机,正如我们在实现部分将看到的。移动相机被称为 抖动,通过抖动相机,我们可以收集额外的数据来增强图像。

以下是对 TAA 着色器的概述:

图 11.4 – TAA 算法概述

图 11.4 – TAA 算法概述

根据 图 11**.4,我们将算法分为步骤(蓝色矩形)和纹理读取(黄色椭圆)。

  1. 我们计算读取速度的坐标,表示为 速度坐标 块。

这通常是通过读取当前像素位置周围的 3x3 像素邻域并找到最近的像素,使用当前帧的 深度纹理 来完成的。从 3x3 邻域读取已被证明可以减少鬼影并提高边缘质量。

  1. 我们使用从 速度纹理 块中找到的新坐标读取速度,注意使用线性采样器,因为速度不仅仅是像素的增量,还可以在像素之间。

  2. 我们从历史纹理块读取颜色信息。这基本上是上一帧的 TAA 输出。我们可以选择应用一个过滤器来读取纹理,以进一步提高质量。

  3. 我们将读取当前场景的颜色。在这个步骤中,我们还将通过读取当前像素位置周围的邻域来缓存信息,以约束我们之前读取的历史颜色,并引导最终的解决阶段。

  4. 历史约束。我们试图将前一帧的颜色限制在当前颜色的一个区域内,以拒绝来自遮挡或去遮挡的无效样本。如果不这样做,会有很多幽灵效果。

  5. 第六步和最后一步是解决。我们将当前颜色和约束历史颜色结合,通过应用一些额外的过滤器来生成最终的像素颜色。

当前帧的 TAA 结果将是下一帧的历史纹理,所以我们只需每帧简单地切换纹理(历史和 TAA 结果),无需像某些实现那样复制结果。

现在我们已经看到了算法的概述,我们可以开始实现一个初始的 TAA 着色器。

最简单的 TAA 实现

理解这种技术的最好方法是构建一个缺少一些重要步骤的基本实现,并且渲染模糊或抖动,因为这很容易做到。

如果正确执行,这种技术的基本成分很简单,但每个都必须以精确的方式进行。我们首先给相机添加抖动,这样我们就可以渲染场景的不同视角并收集额外的数据。

我们将添加运动矢量,以便我们可以在正确的位置读取前一帧的颜色信息。最后,我们将重新投影,或者说,读取历史帧的颜色数据并将其与当前帧的数据结合。

让我们看看不同的步骤。

抖动相机

这一步的目标是通过xy轴将投影相机平移一个小量。

我们在GameCamera类中添加了一些实用代码:

void GameCamera::apply_jittering( f32 x, f32 y ) {
    // Reset camera projection
    camera.calculate_projection_matrix();
    // Calculate jittering translation matrix and modify
       projection matrix
    mat4s jittering_matrix = glms_translate_make( { x, y,
                                                  0.0f } );
    camera.projection = glms_mat4_mul( jittering_matrix,
                                       camera.projection );
    camera.calculate_view_projection();
}

每一步都很重要且容易出错,所以请小心。

我们首先想要重置投影矩阵,因为我们将会手动修改它。然后我们构建一个包含xy抖动值的平移矩阵,我们稍后会看到如何计算它们。

最后,我们将投影矩阵乘以抖动矩阵,并计算新的视图投影矩阵。注意乘法顺序,因为如果顺序错误,即使不移动相机,你也会看到一个模糊的、抖动的混乱!

在这个功能正常工作后,我们可以通过移除矩阵构建和乘法来优化代码,得到更干净且错误更少的代码,如下所示:

void GameCamera::apply_jittering( f32 x, f32 y ) {
   camera.calculate_projection_matrix();
   // Perform the same calculations as before, with the
      observation that
   // we modify only 2 elements in the projection matrix:
   camera.projection.m20 += x;
   camera.projection.m21 += y;
   camera.calculate_view_projection();
}

选择抖动序列

现在我们将构建一个xy值的序列来抖动相机。通常有不同序列被使用:

  • Halton

  • Hammersley

  • 马丁·罗伯特的 R2

  • 交错梯度

代码中包含了前面序列的所有实现,每个实现都可以让图像呈现出略微不同的外观,因为它改变了我们随时间收集样本的方式。

关于使用我们将提供的不同序列的丰富材料,我们将在本章末尾提供链接;现在重要的是要知道我们有一个重复几次帧后抖动相机的两个数字的序列。

假设我们选择了 Halton 序列。我们首先想要计算xy的值:

   f32 jitter_x = halton( jitter_index, 2 );
   f32 jitter_y = halton( jitter_index, 3 );

这些值在[0,1]范围内,但我们想要在两个方向上抖动,因此我们将其映射到[-1.1]范围:

    f32 jitter_offset_x = jitter_x * 2 - 1.0f;
    f32 jitter_offset_y = jitter_y * 2 - 1.0f;

我们现在将它们应用到apply jitter方法中,但有一个注意事项:我们想要添加亚像素抖动,因此我们需要将这些偏移量除以屏幕分辨率:

game_camera.apply_jittering( jitter_offset_x / gpu.swapchain_width, jitter_offset_y / gpu.swapchain_height );

最后,我们需要选择一个抖动周期,在重复抖动数字后经过多少帧,更新如下:

jitter_index = ( jitter_index + 1 ) % jitter_period;

通常一个好的周期是四帧,但在附带的代码中,有改变这个数字并查看对渲染图像影响的可能性。

另一个基本的事情是要缓存先前和当前的抖动值并将它们发送到 GPU,这样运动向量就可以考虑完整的移动。

我们在场景统一变量中添加了jitter_xyprevious_jitter_xy作为变量,以便在所有着色器中访问。

添加运动向量

现在我们正确地抖动了相机并保存了偏移量,是时候添加运动向量以正确读取前一帧的颜色数据了。有两个运动来源:相机运动和动态对象运动。

我们添加了一个 R16G16 格式的速度纹理来存储每个像素的速度。对于每一帧,我们将它清除到(0,0)并计算不同的运动。对于相机运动,我们将计算当前和先前的屏幕空间位置,考虑到抖动和运动向量。

我们将在计算着色器中执行以下操作:

layout (local_size_x = 8, local_size_y = 8, local_size_z =
        1) in;
void main() {
    ivec3 pos = ivec3(gl_GlobalInvocationID.xyz);
    // Read the raw depth and reconstruct NDC coordinates.
    const float raw_depth = texelFetch(global_textures[
        nonuniformEXT(depth_texture_index)], pos.xy, 0).r;
    const vec2 screen_uv = uv_nearest(pos.xy, resolution);
    vec4 current_position_ndc = vec4(
        ndc_from_uv_raw_depth( screen_uv, raw_depth ), 1.0f
        );
    // Reconstruct world position and previous NDC position
    const vec3 pixel_world_position =
        world_position_from_depth
           (screen_uv, raw_depth, inverse_view_projection);
    vec4 previous_position_ndc = previous_view_projection *
        vec4(pixel_world_position, 1.0f);
    previous_position_ndc.xyz /= previous_position_ndc.w;
    // Calculate the jittering difference.
    vec2 jitter_difference = (jitter_xy –
                              previous_jitter_xy)* 0.5f;
    // Pixel velocity is given by the NDC [-1,1] difference
       in X and Y axis
    vec2 velocity = current_position_ndc.xy –
                    previous_position_ndc.xy;
    // Take in account jittering
    velocity -= jitter_difference;
    imageStore( motion_vectors, pos.xy, vec4(velocity, 0,
                                             0) );

动态网格需要在顶点着色器或网格着色器中写入额外的输出,并在相机运动着色器中进行类似的计算:

// Mesh shader version
gl_MeshVerticesNV[ i ].gl_Position = view_projection *
    (model * vec4(position, 1));
vec4 world_position = model * vec4(position, 1.0);
vec4 previous_position_ndc = previous_view_projection *
    vec4(world_position, 1.0f);
previous_position_ndc.xyz /= previous_position_ndc.w;
vec2 jitter_difference = (jitter_xy - previous_jitter_xy) *
                          0.5f;
vec2 velocity = gl_MeshVerticesNV[ i ].gl_Position.xy –
    previous_position_ndc.xy - jitter_difference;
vTexcoord_Velocity[i] = velocity;

然后,只需将速度写入其自己的渲染目标即可。

现在我们有了运动向量,我们终于可以看到一个极其基本的 TAA 着色器的实现。

首次实现代码

我们再次运行计算着色器来计算 TAA。最简单可能的着色器实现如下:

vec3 taa_simplest( ivec2 pos ) {
    const vec2 velocity = sample_motion_vector( pos );
    const vec2 screen_uv = uv_nearest(pos, resolution);
    const vec2 reprojected_uv = screen_uv - velocity;
    vec3 current_color = sample_color(screen_uv.xy).rgb;
    vec3 history_color =
        sample_history_color(reprojected_uv).rgb;
    // source_weight is normally around 0.9.
    return mix(current_color, previous_color,
               source_weight);
}

在代码中,步骤很简单:

  1. 在像素位置采样速度。

  2. 在像素位置采样当前颜色。

  3. 使用运动向量在先前的像素位置采样历史颜色。

  4. 混合颜色,取当前帧颜色的 10%左右。

在继续进行任何改进之前,确保这一点完美运行至关重要。

你应该看到一个模糊度更大的图像,但存在一个大问题:在移动相机或物体时会出现鬼影。如果相机和场景是静态的,则不应该有像素移动。这是判断抖动和重投影是否正常工作的基本依据。

随着这种实现方式的工作,我们现在可以查看不同的改进区域,以获得更稳固的 TAA。

改进 TAA

有五个方面可以改进 TAA:重投影、历史采样、场景采样、历史约束和解决。

每个部分都有不同的参数需要调整,以满足项目的渲染需求——TAA 并不精确或完美,因此从视觉角度来看需要额外注意。

让我们详细看看不同的区域,以便使伴随的代码更清晰。

重投影

首先要做的是改进重投影,从而计算读取速度的坐标来驱动历史 采样部分。

为了计算历史纹理像素坐标,最常见的方法是获取当前像素周围 3x3 正方形中的最近像素,这是 Brian Karis 的一个想法。我们将读取深度纹理,并使用深度值作为确定最近像素的方法,并缓存该像素的xy位置:

void find_closest_fragment_3x3(ivec2 pixel, out ivec2
                               closest_position, out
                               float closest_depth) {
    closest_depth = 1.0f;
    closest_position = ivec2(0,0);
    for (int x = -1; x <= 1; ++x ) {
        for (int y = -1; y <= 1; ++y ) {
            ivec2 pixel_position = pixel + ivec2(x, y);
                pixel_position = clamp(pixel_position,
                    ivec2(0), ivec2(resolution.x - 1,
                        resolution.y - 1));
            float current_depth =
                texelFetch(global_textures[
                    nonuniformEXT(depth_texture_index)],
                        pixel_position, 0).r;
            if ( current_depth < closest_depth ) {
                closest_depth = current_depth;
                closest_position = pixel_position;
            }
        }
    }
}

只使用找到的像素位置作为运动向量的读取坐标,鬼影将变得不那么明显,边缘将更加平滑:

        float closest_depth = 1.0f;
        ivec2 closest_position = ivec2(0,0);
        find_closest_fragment_3x3( pos.xy,
                                   closest_position,
                                   closest_depth );
        const vec2 velocity = sample_motion_vector
            (closest_position.xy);
        // rest of the TAA shader

可能还有其他读取速度的方法,但这种方法已经在质量和性能之间证明了最佳权衡。另一种实验方法是在类似的 3x3 像素邻域中使用最大速度。

没有完美的解决方案,因此强烈鼓励进行实验和渲染技术的参数化。在我们计算出读取历史纹理的像素位置后,我们最终可以对其进行采样。

历史采样

在这种情况下,最简单的事情就是直接在计算的位置读取历史纹理。现实情况是,我们还可以应用一个过滤器来增强读取的视觉效果。

在代码中,我们添加了尝试不同过滤器的选项,这里的标准选择是使用 Catmull-Rom 过滤器来增强采样:

   // Sample motion vectors.
    const vec2 velocity = sample_motion_vector_point(
                          closest_position );
    const vec2 screen_uv = uv_nearest(pos.xy, resolution);
    const vec2 reprojected_uv = screen_uv - velocity;
    // History sampling: read previous frame samples and
       optionally apply a filter to it.
    vec3 history_color = vec3(0);
    history_color = sample_history_color(
                    reprojected_uv ).rgb;
    switch (history_sampling_filter) {
        case HistorySamplingFilterSingle:
            history_color = sample_history_color(
                            reprojected_uv ).rgb;
            break;
        case HistorySamplingFilterCatmullRom:
            history_color = sample_texture_catmull_rom(
                            reprojected_uv,
                            history_color_texture_index );
            break;
    }

在我们得到历史颜色后,我们将采样当前场景颜色,并缓存历史约束和最终解决阶段所需的信息。

如果不进行进一步处理就使用历史颜色,会导致鬼影效果。

场景采样

在这个阶段,鬼影效果不那么明显但仍存在,因此我们可以用类似寻找最近像素的心态,在当前像素周围搜索以计算颜色信息并对其应用过滤器。

基本上,我们将像素视为信号而不是简单的颜色。这个主题可以相当长且有趣,在章节末尾,将提供资源以深入了解这一点。此外,在这一步中,我们将缓存用于约束来自前一帧的颜色的历史边界所需的信息。

我们需要知道的是,我们将围绕当前像素采样另一个 3x3 区域,并计算约束发生所需的信息。最有价值的信息是此区域中的最小和最大颜色,方差裁剪(我们将在稍后查看)还需要计算平均颜色和平方平均颜色(称为)以帮助历史约束。最后,我们还将对颜色采样应用一些过滤。

让我们看看代码:

// Current sampling: read a 3x3 neighborhood and cache
   color and other data to process history and final
   resolve.
    // Accumulate current sample and weights.
    vec3 current_sample_total = vec3(0);
    float current_sample_weight = 0.0f;
    // Min and Max used for history clipping
    vec3 neighborhood_min = vec3(10000);
    vec3 neighborhood_max = vec3(-10000);
    // Cache of moments used in the constraint phase
    vec3 m1 = vec3(0);
    vec3 m2 = vec3(0);
    for (int x = -1; x <= 1; ++x ) {
        for (int y = -1; y <= 1; ++y ) {
            ivec2 pixel_position = pos + ivec2(x, y);
            pixel_position = clamp(pixel_position,
                ivec2(0), ivec2(resolution.x - 1,
                    resolution.y - 1));
            vec3 current_sample =
            sample_current_color_point(pixel_position).rgb;
            vec2 subsample_position = vec2(x * 1.f, y *
                                           1.f);
            float subsample_distance = length(
                                       subsample_position
                                       );
            float subsample_weight = subsample_filter(
                                     subsample_distance );
            current_sample_total += current_sample *
                                    subsample_weight;
            current_sample_weight += subsample_weight;
            neighborhood_min = min( neighborhood_min,
                                    current_sample );
            neighborhood_max = max( neighborhood_max,
                                     current_sample );
            m1 += current_sample;
            m2 += current_sample * current_sample;
        }
    }
vec3 current_sample = current_sample_total /
                      current_sample_weight;

所有这些代码所做的就是采样颜色,过滤它,并为历史约束缓存信息,因此我们可以继续到下一阶段。

历史约束

最后,我们到达了历史采样颜色的约束。基于前面的步骤,我们创建了一个我们认为有效的可能颜色值范围。如果我们认为每个颜色通道都是一个值,我们基本上创建了一个有效的颜色区域,我们将对其进行约束。

约束是一种接受或丢弃来自历史纹理的颜色信息的方式,可以减少鬼影到几乎为零。随着时间的推移,为了寻找更好的标准来丢弃颜色,人们开发了不同的约束历史采样颜色的方法。

一些实现也尝试依赖于深度或速度差异,但这似乎是更稳健的解决方案。

我们添加了四个约束来测试:

  • RGB 夹紧

  • RGB 裁剪

  • 方差裁剪

  • 带有夹紧 RGB 的方差裁剪

最好的质量是由带有夹紧 RGB 的方差裁剪提供的,但看到其他选项也很有趣,因为它们是首次实现中使用的。

下面是代码:

    switch (history_clipping_mode) {
        // This is the most complete and robust history
           clipping mode:
        case HistoryClippingModeVarianceClipClamp:
        default: {
            // Calculate color AABB using color moments m1
               and m2
            float rcp_sample_count = 1.0f / 9.0f;
            float gamma = 1.0f;
            vec3 mu = m1 * rcp_sample_count;
            vec3 sigma = sqrt(abs((m2 * rcp_sample_count) –
                         (mu * mu)));
            vec3 minc = mu - gamma * sigma;
            vec3 maxc = mu + gamma * sigma;
            // Clamp to new AABB
            vec3 clamped_history_color = clamp(
                                         history_color.rgb,
                                         neighborhood_min,
                                         neighborhood_max
                                         );
            history_color.rgb = clip_aabb(minc, maxc,
                                vec4(clamped_history_color,
                                1), 1.0f).rgb;
            break;
        }
    }

clip_aabb函数是限制采样历史颜色在最小和最大颜色值之间的方法。

简而言之,我们试图在颜色空间中构建一个 AABB 来限制历史颜色位于该范围内,从而使最终颜色与当前颜色相比更可信。

TAA 着色器的最后一步是解析,即结合当前和历史颜色,并应用一些过滤器以生成最终的像素颜色。

解析

再次,我们将应用一些额外的过滤器来决定前一个像素是否可用以及可用程度。

默认情况下,我们开始只使用当前帧像素的 10%,并依赖于历史信息,因此如果没有这些过滤器,图像将会相当模糊:

// Resolve: combine history and current colors for final
   pixel color
    vec3 current_weight = vec3(0.1f);
    vec3 history_weight = vec3(1.0 - current_weight);

我们将看到的第一个过滤器是时间过滤器,它使用缓存的邻域最小和最大颜色来计算当前和先前颜色混合的程度:

    // Temporal filtering
    if (use_temporal_filtering() ) {
        vec3 temporal_weight = clamp(abs(neighborhood_max –
                                      neighborhood_min) /
                                      current_sample,
                                      vec3(0), vec3(1));
        history_weight = clamp(mix(vec3(0.25), vec3(0.85),
                               temporal_weight), vec3(0),
                               vec3(1));
        current_weight = 1.0f - history_weight;
    }

接下来的两个过滤器是相关的;因此,我们将它们放在一起。

他们两者都使用亮度,一个用于抑制所谓的萤火虫,即当有强烈光源时图像中可能存在的非常亮的单个像素,而另一个则使用亮度差异来进一步引导权重,使其偏向当前或之前的颜色:

    // Inverse luminance filtering
    if (use_inverse_luminance_filtering() ||
        use_luminance_difference_filtering() ) {
        // Calculate compressed colors and luminances
        vec3 compressed_source = current_sample /
            (max(max(current_sample.r, current_sample.g),
                current_sample.b) + 1.0f);
        vec3 compressed_history = history_color /
            (max(max(history_color.r, history_color.g),
                history_color.b) + 1.0f);
        float luminance_source = use_ycocg() ?
            compressed_source.r :
                luminance(compressed_source);
        float luminance_history = use_ycocg() ?
            compressed_history.r :
                luminance(compressed_history);
        if ( use_luminance_difference_filtering() ) {
            float unbiased_diff = abs(luminance_source –
            luminance_history) / max(luminance_source,
            max(luminance_history, 0.2));
            float unbiased_weight = 1.0 - unbiased_diff;
            float unbiased_weight_sqr = unbiased_weight *
                                        unbiased_weight;
            float k_feedback = mix(0.0f, 1.0f,
                                   unbiased_weight_sqr);
            history_weight = vec3(1.0 - k_feedback);
            current_weight = vec3(k_feedback);
        }
        current_weight *= 1.0 / (1.0 + luminance_source);
        history_weight *= 1.0 / (1.0 + luminance_history);
    }

我们使用新计算出的权重组合结果,最后输出颜色:

    vec3 result = ( current_sample * current_weight +
                    history_color * history_weight ) /
                    max( current_weight + history_weight,
                    0.00001 );
    return result;

到目前为止,着色器已经完成,准备好使用。在附带的演示中,将有许多调整参数来学习不同过滤器以及涉及的步骤之间的差异。

关于 TAA 最常见的抱怨之一是图像的模糊。接下来我们将看到几种改善这一问题的方法。

锐化图像

在最基本实现中可以注意到的一件事,以及与 TAA 经常相关的问题,是图像锐度的降低。

我们已经通过在采样场景时使用过滤器来改进了它,但我们可以在 TAA 之外以不同的方式处理最终图像的外观。我们将简要讨论三种不同的方法来提高图像的锐化。

锐化后处理

提高图像锐度的方法之一是在后处理链中添加一个简单的锐化着色器。

代码很简单,它是基于亮度的:

    vec4 color = texture(global_textures[
                 nonuniformEXT(texture_id)], vTexCoord.xy);
    float input_luminance = luminance(color.rgb);
    float average_luminance = 0.f;
    // Sharpen
    for (int x = -1; x <= 1; ++x ) {
        for (int y = -1; y <= 1; ++y ) {
            vec3 sampled_color = texture(global_textures[
                nonuniformEXT(texture_id)], vTexCoord.xy +
                    vec2( x / resolution.x, y /
                        resolution.y )).rgb;
            average_luminance += luminance( sampled_color
                                          );
        }
    }
    average_luminance /= 9.0f;
    float sharpened_luminance = input_luminance –
                                average_luminance;
    float final_luminance = input_luminance +
                            sharpened_luminance *
                            sharpening_amount;
    color.rgb = color.rgb * (final_luminance /
                input_luminance);

根据这段代码,当锐化量为0时,图像不会被锐化。标准值是1

负 MIP 偏差

一种全局减少模糊的方法是修改VkSamplerCreateInfo结构中的mipLodBias字段,使其为负数,例如-0.25,从而将纹理MIP,即纹理的逐渐缩小的图像金字塔,移动到更高的值。

这应该通过考虑性能差异来完成,因为我们正在更高 MIP 级别上进行采样,如果级别太高,我们可能会重新引入走样。

一个全局引擎选项来调整将是一个很好的解决方案。

解除纹理 UV 抖动

另一个可能的解决方案来采样更锐利的纹理是像这样计算 UVs,就像相机没有任何抖动时一样:

vec2 unjitter_uv(float uv, vec2 jitter) {
    return uv - dFdxFine(uv) * jitter.x + dFdyFine(uv) *
        jitter.y;
}

我个人没有尝试这种方法,但觉得它很有趣,值得实验。它被 Emilio Lopez 在他的 TAA 文章中提到,该文章链接在参考部分,并引用了一位名叫 Martin Sobek 的同事,他想出了这个主意。

TAA 和锐化的结合极大地改善了图像的边缘,同时保留了物体内部的细节。

我们还需要关注图像的最后一个方面:带状效应。

改善带状效应

带状效应是影响帧渲染各个步骤的问题。例如,它会影响体积雾和光照计算。

图 11.5 – 体积雾中带状问题细节

图 11.5 – 体积雾中带状问题细节

我们可以在图 11.5中看到,如果没有实施解决方案,这可以在体积雾中存在。解决视觉中带状效应的方法是在帧的各个通道中添加一些随机抖动,但这也会向图像添加视觉噪声。

随机抖动(Dithering)被定义为有意添加噪声以消除带状效应。可以使用不同类型的噪声,正如我们将在随附的代码中看到的那样。添加时间重投影可以平滑添加的噪声,因此成为提高图像视觉质量的最佳方法之一。

第十章“添加体积雾”中,我们看到了一个非常简单的时间重投影方案,并且我们也向算法的各个步骤添加了噪声。我们现在已经看到了一个更复杂的时间重投影方案的实施,以增强图像,并且它应该更清楚地解释了动画抖动背后的原理:动画抖动有效地提供了更多的样本,并且由于时间重投影,有效地使用了它们。抖动与其自己的时间重投影相关联,因此在体积雾步骤中,抖动比例可能太大,无法被 TAA 清理。

当将体积雾(Volumetric Fog)应用于场景时,我们可以添加一个小型的、动态的随机抖动,以增强雾的视觉效果,同时通过 TAA 进行清理。另一个随机抖动的应用是在光照着色器中,同样是在像素级别,因此有资格被 TAA 清理。

注意

尝试获得无噪声的图像很困难,因为时间重投影使用了多个帧,因此不可能在这里通过图像展示在随附的应用程序中出现的无带状效果。

摘要

在本章中,我们介绍了 TAA 渲染技术。

我们通过尝试突出涉及的不同着色器步骤来概述了算法。然后我们继续创建最简单的 TAA 着色器:一个练习,以让我们更深入地理解这项技术本身。

在此之后,我们开始使用过滤器以及从当前场景中获取的信息来增强各个步骤。我们鼓励您添加自定义过滤器,调整参数以及不同场景,以进一步理解和开发这项技术。

另一个可以尝试的想法是将历史约束应用于体积雾的时间重投影阶段,这是我的朋友 Marco Vallario 几个月前建议的。

在下一章中,我们将向 Raptor Engine 添加对光线追踪的支持,这是一项最近的技术进步,它解锁了高质量的光照技术,我们将在接下来的章节中介绍。

进一步阅读

在本章中,我们讨论了多个主题,从后处理抗锯齿的历史到 TAA 的实现,再到带状和噪声。

多亏了图形社区,他们分享了大量的发现信息,我们才能在这个主题上加深我们的知识。

以下是一些阅读链接:

第十二章:光线追踪入门

在本章中,我们将光线追踪引入我们的渲染管道。得益于现代 GPU 中光线追踪硬件支持的添加,现在可以将光线追踪技术集成到实时渲染中。

与传统渲染管道相比,光线追踪需要不同的设置,因此我们专门用一整章来设置光线追踪管道。我们将详细介绍如何设置着色器绑定表,以便 API 知道在给定光线交点测试成功或失败时调用哪些着色器。

接下来,我们将解释如何创建底部级加速结构BLAS)和顶部级加速结构TLAS)。这些加速结构AS)用于加速场景光线遍历并确保光线追踪可以以交互式速率进行。

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

  • Vulkan 中光线追踪简介

  • 构建 BLAS 和 TLAS

  • 定义和创建光线追踪管道

技术要求

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter12

Vulkan 中光线追踪简介

硬件中的光线追踪支持首次于 2018 年随 NVIDIA RTX 系列推出。最初,Vulkan 中的光线追踪支持仅通过 NVIDIA 扩展提供,但后来,该功能通过 Khronos 扩展得到认可,允许多个供应商支持 Vulkan 中的光线追踪 API。我们专门用一整章来介绍光线追踪管道的设置,因为它需要针对光线追踪的新构造。

与传统渲染管道的第一个不同之处在于需要将我们的场景组织成加速结构。这些结构用于加速场景遍历,因为它们允许我们跳过整个网格,而光线没有机会与之相交。

这些加速结构通常实现为边界体积层次结构BVH)。BVH 将场景和单个网格划分为边界框,然后将其组织成树状结构。树的叶节点是唯一包含几何数据的节点,而父节点定义了包含子节点的体积的位置和范围。

以下图像展示了简单场景及其 BVH 表示:

图 12.1 – 左侧的场景示例及其右侧的 BVH 表示(来源:维基百科)

图 12.1 – 左侧的场景示例及其右侧的 BVH 表示(来源:维基百科)

Vulkan API 在 TLAS 和 BLAS 之间做出了进一步的区分。BLAS 包含单个网格定义。然后,这些定义可以组合成一个 TLAS,通过定义它们的变换矩阵,可以在场景中放置多个相同网格的实例。

如下图所示,这是这种组织的示意图:

图 12.2 – 每个 BLAS 可以多次添加到 TLAS 中,具有不同的着色和变换细节(来源:Vulkan 规范)

图 12.2 – 每个 BLAS 可以多次添加到 TLAS 中,具有不同的着色和变换细节(来源:Vulkan 规范)

现在我们已经定义了我们的加速结构,我们可以将注意力转向光线追踪管线。引入光线追踪管线的主要变化是能够在着色器内部调用其他着色器。这是通过定义着色器绑定表来实现的。这些表中的每个槽位定义了以下着色器类型之一:

  • 光线生成:在传统的光线追踪管线中,这是生成光线的入口点。正如我们将在后面的章节中看到的,光线也可以从片段和计算着色器中生成。

  • 交点:此着色器允许应用程序实现自定义几何体原语。在 Vulkan 中,我们只能定义三角形和轴对齐边界框AABB)。

  • 任何命中:这是在触发交点着色器之后执行的。其主要用途是确定是否应该进一步处理命中或忽略它。

  • 最近命中:这是当光线第一次击中原始几何体时触发的着色器。

  • 丢失:如果光线没有击中任何原始几何体,则触发此着色器。

  • 可调用:这些是可以从现有着色器中调用的着色器。

流程总结如下图所示:

图 12.3 – 光线追踪管线的着色器流程(来源:Vulkan 规范)

图 12.3 – 光线追踪管线的着色器流程(来源:Vulkan 规范)

在本节中,我们概述了在 Vulkan API 中实现光线追踪的方法。在下一节中,我们将更详细地探讨如何创建加速结构。

构建 BLAS 和 TLAS

如前所述,光线追踪管线需要将几何体组织到加速结构中,以加快场景中光线的遍历。在本节中,我们将解释如何在 Vulkan 中实现这一点。

我们首先在解析场景时创建一个 VkAccelerationStructureGeometryKHR 列表。对于每个网格,这个数据结构定义如下:

VkAccelerationStructureGeometryKHR geometry{
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR };
geometry.geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR;
geometry.flags =  mesh.is_transparent() ? 0 :
    VK_GEOMETRY_OPAQUE_BIT_KHR;

每个几何结构可以定义三种类型的条目:三角形、AABB 和实例。我们将在这里使用三角形,因为这是我们定义网格的方式。我们将在定义 TLAS 时使用实例。

以下代码演示了如何使用 triangles 结构:

geometry.geometry.triangles.sType =
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY
        _TRIANGLES_DATA_KHR;
geometry.geometry.triangles.vertexFormat =
    VK_FORMAT_R32G32B32_SFLOAT;
geometry.geometry.triangles.vertexData.deviceAddress =
    renderer->gpu->get_buffer_device_address(
        mesh.position_buffer ) + mesh.position_offset;
geometry.geometry.triangles.vertexStride = sizeof( float )
    * 3;
geometry.geometry.triangles.maxVertex = vertex_count;
geometry.geometry.triangles.indexType = mesh.index_type;
geometry.geometry.triangles.indexData.deviceAddress =
    renderer->gpu->get_buffer_device_address(
        mesh.index_buffer );

几何数据定义方式与传统绘制相同:我们需要提供一个顶点和索引缓冲区、顶点步长和顶点格式。原始计数在下一个结构中定义。

最后,我们还需要填充一个 VkAccelerationStructureBuildRangeInfoKHR 结构来存储我们的网格的原始定义:

VkAccelerationStructureBuildRangeInfoKHR build_range_info{ };
build_range_info.primitiveCount = vertex_count;
build_range_info.primitiveOffset = mesh.index_offset;

现在我们已经有了网格的详细信息,我们可以开始构建 BLAS。这是一个两步过程。首先,我们需要查询我们的 AS 需要多少内存。我们通过定义一个 VkAccelerationStructureBuildGeometryInfoKHR 结构来实现:

VkAccelerationStructureBuildGeometryInfoKHR as_info{
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD
        _GEOMETRY_INFO_KHR };
as_info.type =
    VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR;
as_info.mode =
    VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR;
as_info.geometryCount = scene->geometries.size;
as_info.pGeometries = scene->geometries.data;

这些标志告诉 Vulkan API,这个 BLAS 在未来可能会被更新或压缩:

as_info.flags =
    VK_BUILD_ACCELERATION_STRUCTURE_ALLOW_UPDATE_BIT_KHR |
        VK_BUILD_ACCELERATION_STRUCTURE_ALLOW
            _COMPACTION_BIT_KHR;

在查询 AS 的大小时,我们需要提供一个列表,其中包含每个几何条目中原始数的最大数量:

for ( u32 range_index = 0; range_index < scene->
    geometries.size; range_index++ ) {
        max_primitives_count[ range_index ] = scene->
           build_range_infos[ range_index ].primitiveCount;
}

现在,我们已经准备好查询我们的 AS 的大小:

VkAccelerationStructureBuildSizesInfoKHR as_size_info{
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD
        _SIZES_INFO_KHR };
vkGetAccelerationStructureBuildSizesKHR( gpu.vulkan_device,
    VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR,
      &as_info, max_primitives_count.data, &as_size_info );

在构建 AS 时,我们需要提供两个缓冲区:一个用于实际的 AS 数据,另一个用于构建过程中使用的临时缓冲区。这两个缓冲区的创建方式如下:

as_buffer_creation.set(
    VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR,
        ResourceUsageType::Immutable,
            as_size_info.accelerationStructureSize )
                .set_device_only( true )
                    .set_name( "blas_buffer" );
scene->blas_buffer = gpu.create_buffer(
    as_buffer_creation );
as_buffer_creation.set(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
    VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT_KHR,
        ResourceUsageType::Immutable,
            as_size_info.buildScratchSize )
                .set_device_only( true )
                    .set_name( "blas_scratch_buffer" );
BufferHandle blas_scratch_buffer_handle =
    gpu.create_buffer( as_buffer_creation );

这与之前多次使用的创建缓冲区代码类似,但有两大关键区别我们要强调:

  • AS 缓冲区需要使用 VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR 使用标志来创建

  • 需要使用 VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT_KHR 来创建临时缓冲区。光线追踪扩展还要求 VK_KHR_buffer_device_address 扩展。这允许我们查询给定缓冲区的 GPU 虚拟地址,但它必须使用此使用标志创建。

现在我们已经拥有了创建我们的 BLAS 所需的一切。首先,我们检索我们的 AS 的句柄:

VkAccelerationStructureCreateInfoKHR as_create_info{
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE
        _CREATE_INFO_KHR };
as_create_info.buffer = blas_buffer->vk_buffer;
as_create_info.offset = 0;
as_create_info.size =
    as_size_info.accelerationStructureSize;
as_create_info.type =
    VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR;
vkCreateAccelerationStructureKHR( gpu.vulkan_device,
    &as_create_info, gpu.vulkan_allocation_callbacks,
        &scene->blas );

到目前为止,scene->blas 仍然只是一个句柄。为了构建我们的加速结构,我们需要填充我们的 VkAccelerationStructureBuildGeometryInfoKHR 结构的剩余字段:

as_info.dstAccelerationStructure = scene->blas;
as_info.scratchData.deviceAddress =
    gpu.get_buffer_device_address(
        blas_scratch_buffer_handle );
VkAccelerationStructureBuildRangeInfoKHR* blas_ranges[] = {
    scene->build_range_infos.data
};

最后,我们记录构建 AS 的命令:

vkCmdBuildAccelerationStructuresKHR( gpu_commands->
    vk_command_buffer, 1, &as_info, blas_ranges );
gpu.submit_immediate( gpu_commands );

注意我们立即提交了这个命令。这是必需的,因为无法在同一提交中构建 BLAS 和 TLAS,因为 TLAS 依赖于完全构建的 BLAS。

下一步和最后一步是构建 TLAS。过程与之前描述的 BLAS 类似,我们将强调其中的区别。TLAS 通过指定多个 BLAS 的实例来定义,其中每个 BLAS 可以有自己的转换。这与传统的实例化非常相似:我们定义一次几何形状,可以通过简单地改变其转换来多次渲染。

我们首先定义一个 VkAccelerationStructureInstanceKHR 结构:

VkAccelerationStructureInstanceKHR tlas_structure{ };
tlas_structure.transform.matrix[ 0 ][ 0 ] = 1.0f;
tlas_structure.transform.matrix[ 1 ][ 1 ] = 1.0f;
tlas_structure.transform.matrix[ 2 ][ 2 ] = 1.0f;
tlas_structure.mask = 0xff;
tlas_structure.flags = VK_GEOMETRY_INSTANCE_TRIANGLE_FACING_CULL_DISABLE_BIT_KHR;
tlas_structure.accelerationStructureReference =
    blas_address;

如前所述,我们提供了一个 BLAS 引用及其转换。然后我们需要创建一个缓冲区来存储这些数据:

as_buffer_creation.reset().set(
    VK_BUFFER_USAGE_ACCELERATION_STRUCTURE
    _BUILD_INPUT_READ_ONLY_BIT_KHR | VK_BUFFER_USAGE_
    SHADER_DEVICE_ADDRESS_BIT,
    ResourceUsageType::Immutable, sizeof(
    VkAccelerationStructureInstanceKHR ) )
    .set_data( &tlas_structure )
    .set_name( "tlas_instance_buffer" );
BufferHandle tlas_instance_buffer_handle =
    gpu.create_buffer( as_buffer_creation );

注意到 VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR 使用标志,这是将要用于 AS 构建期间的缓冲区所必需的。

接下来,我们定义一个 VkAccelerationStructureGeometryKHR 结构:

VkAccelerationStructureGeometryKHR tlas_geometry{
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR };
tlas_geometry.geometryType =
    VK_GEOMETRY_TYPE_INSTANCES_KHR;
tlas_geometry.geometry.instances.sType =
    VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE
        _GEOMETRY_INSTANCES_DATA_KHR;
tlas_geometry.geometry.instances.arrayOfPointers = false;
tlas_geometry.geometry.instances.data.deviceAddress =
    gpu.get_buffer_device_address(
        tlas_instance_buffer_handle );

现在我们已经定义了我们的 TLAS 结构,我们需要查询它的大小。我们不会重复完整的代码,但这里是在创建 BLAS 时与VkAccelerationStructureBuildGeometryInfoKHR结构相比的差异:

as_info.type =
    VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR;
as_info.geometryCount = 1;
as_info.pGeometries = &tlas_geometry;

在创建 TLAS 的数据和临时缓冲区之后,我们就可以获取 TLAS 句柄了:

as_create_info.buffer = tlas_buffer->vk_buffer;
as_create_info.offset = 0;
as_create_info.size =
    as_size_info.accelerationStructureSize;
as_create_info.type =
    VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR;
vkCreateAccelerationStructureKHR( gpu.vulkan_device,
                                  &as_create_info, 
                                  gpu.vulkan_allocation_
                                     callbacks,
                                  &scene->tlas );

最后,我们可以构建我们的 TLAS:

as_info.dstAccelerationStructure = scene->tlas;
as_info.scratchData.deviceAddress =
    gpu.get_buffer_device_address(
        tlas_scratch_buffer_handle );
VkAccelerationStructureBuildRangeInfoKHR tlas_range_info{ };
    tlas_range_info.primitiveCount = 1;
VkAccelerationStructureBuildRangeInfoKHR* tlas_ranges[] = {
    &tlas_range_info
};
vkCmdBuildAccelerationStructuresKHR( gpu_commands->
    vk_command_buffer, 1, &as_info, tlas_ranges );

如前所述,我们立即提交此命令,以便在开始渲染时 TLAS 已准备好。虽然无法在同一提交中构建 BLAS 和 TLAS,但可以并行创建多个 BLAS 和 TLAS。

我们现在可以将加速结构用于光线追踪了!

在本节中,我们详细介绍了创建 BLAS 和 TLAS 所需的步骤。我们首先记录了我们的几何体的三角形原语。然后我们使用这些数据创建了一个 BLAS 实例,该实例随后被用作 TLAS 的一部分。

在下一节中,我们将定义一个利用这些加速结构的射线追踪管线。

定义和创建光线追踪管线

现在我们已经定义了我们的加速结构,我们可以将注意力转向光线追踪管线。正如我们之前提到的,光线追踪着色器与传统图形和计算着色器的工作方式不同。光线追踪着色器被设置为根据着色器绑定表设置调用其他着色器。

如果你熟悉 C++,你可以将此设置视为一种简单的多态形式:射线追踪管线的接口始终相同,但我们可以在运行时动态地覆盖哪些着色器(方法)被调用。我们不需要定义所有入口点。

在这个例子中,例如,我们只定义了光线生成、最近击中和丢失着色器。我们现在忽略任何击中和交点着色器。

如其名所示,着色器绑定表可以以表格形式表示。这是我们将在示例中构建的绑定表:

图片

表格中的顺序很重要,因为这是驱动程序用来告诉 GPU 根据已触发的阶段调用哪个着色器的顺序。

在我们开始构建管线之前,让我们看看我们将要使用的三个示例着色器。我们首先从光线生成着色器开始,它负责生成光线以遍历我们的场景。首先,我们必须启用用于光线追踪的 GLSL 扩展:

#extension GL_EXT_ray_tracing : enable

接下来,我们必须定义一个将被其他着色器填充的变量:

layout( location = 0 ) rayPayloadEXT vec4 payload;

我们接下来定义一个统一变量,它将包含对我们的 AS 的引用:

layout( binding = 1, set = MATERIAL_SET ) uniform
    accelerationStructureEXT as;

最后,我们定义了我们的光线生成调用参数:

layout( binding = 2, set = MATERIAL_SET ) uniform rayParams
{
    uint sbt_offset;
    uint sbt_stride;
    uint miss_index;
    uint out_image_index;
};

sbt_offset 是着色器绑定表中的偏移量,在着色器绑定表中定义了多个同类型的着色器时可以使用。在我们的例子中,这将是一个0,因为我们为每个着色器只有一个条目。

sbt_stride是绑定表中每个条目的大小。这个值必须通过传递一个VkPhysicalDeviceRayTracingPipelinePropertiesKHR结构到vkGetPhysicalDeviceProperties2来为每个设备查询。

miss_index用于计算 miss 着色器的索引。如果绑定表中存在多个 miss 着色器,则可以使用此功能。在我们的用例中,它将是0

最后,out_image_index是我们将要写入的无绑定图像数组中图像的索引。

现在我们已经定义了我们的光线生成着色器的输入和输出,我们可以调用该函数来追踪光线进入场景!

traceRayEXT( as, // top level acceleration structure
                gl_RayFlagsOpaqueEXT, // rayFlags
                0xff, // cullMask
                sbt_offset,
                sbt_stride,
                miss_index,
                camera_position.xyz, // origin
                0.0, // Tmin
                compute_ray_dir( gl_LaunchIDEXT,
                gl_LaunchSizeEXT ),
                100.0, // Tmax
                0 // payload
            );

第一个参数是我们想要遍历的 TLAS。由于这是traceRayEXT函数的参数,我们可以在同一个着色器中将光线投射到多个加速结构中。

rayFlags是一个位掩码,它决定了哪些几何体将触发对我们的着色器的回调。在这种情况下,我们只对具有不透明标志的几何体感兴趣。

cullMask用于匹配具有相同掩码值的 AS 中的条目。这允许我们定义一个可以用于多个目的的单个 AS。

最后,有效载荷决定了我们在这里定义的光线追踪有效载荷的位置索引。这允许我们多次调用traceRayEXT,每次调用使用不同的有效载荷变量。

其他字段都是不言自明的或者之前已经解释过。接下来,我们将更详细地看看如何计算光线方向:

vec3 compute_ray_dir( uvec3 launchID, uvec3 launchSize) {

光线追踪着色器与计算着色器非常相似,并且,像计算着色器一样,每个调用都有一个 ID。对于光线追踪着色器,这由gl_LaunchIDEXT变量定义。同样,gl_LaunchSizeEXT定义了总的调用大小。这类似于计算着色器的工作组大小。

在我们的情况下,图像中的每个像素都有一个调用。我们按照以下方式在归一化设备坐标NDCs)中计算xy

    float x = ( 2 * ( float( launchID.x ) + 0.5 ) / float(
        launchSize.x ) - 1.0 );
    float y = ( 1.0 - 2 * ( float( launchID.y ) + 0.5 ) /
        float( launchSize.y ) );

注意,我们必须反转y坐标,否则我们的最终图像将会是颠倒的。

最后,我们通过乘以inverse_view_projection矩阵来计算我们的世界空间方向:

   vec4 dir = inverse_view_projection * vec4( x, y, 1, 1 );
   dir = normalize( dir );
   return dir.xyz;
}

一旦traceRayEXT返回,有效载荷变量将包含通过其他着色器计算出的值。光线生成的最后一步是将此像素的颜色保存下来:

imageStore( global_images_2d[ out_image_index ], ivec2(
    gl_LaunchIDEXT.xy ), payload );

现在,我们将查看一个最邻近命中着色器的示例:

layout( location = 0 ) rayPayloadInEXT vec4 payload;
void main() {
    payload = vec4( 1.0, 0.0, 0.0, 1.0 );
}

与光线生成着色器的主要区别是,现在使用rayPayloadInEXT限定符定义了有效载荷。同样重要的是,位置必须与光线生成着色器中定义的位置相匹配。

miss 着色器与之前相同,只是我们使用了不同的颜色来区分两者。

现在我们已经定义了我们的着色器代码,我们可以开始构建我们的管线。编译光线追踪着色器模块的方式与其他着色器相同。主要区别是着色器类型。对于光线追踪,已经添加了以下枚举:

  • VK_SHADER_STAGE_RAYGEN_BIT_KHR

  • VK_SHADER_STAGE_ANY_HIT_BIT_KHR

  • VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR

  • VK_SHADER_STAGE_MISS_BIT_KHR

  • VK_SHADER_STAGE_INTERSECTION_BIT_KHR

  • VK_SHADER_STAGE_CALLABLE_BIT_KHR

对于光线追踪管道,我们必须填充一个新的VkRayTracingShaderGroupCreateInfoKHR结构:

shader_group_info.sType =
    VK_STRUCTURE_TYPE_RAY_TRACING_SHADER
        _GROUP_CREATE_INFO_KHR;
shader_group_info.type =
    VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR;
shader_group_info.generalShader = stage index;
shader_group_info.closestHitShader = VK_SHADER_UNUSED_KHR;
shader_group_info.anyHitShader = VK_SHADER_UNUSED_KHR;
shader_group_info.intersectionShader =
    VK_SHADER_UNUSED_KHR;

在这个例子中,我们定义了一个通用着色器,它可以是一个生成、丢失或可调用着色器。在我们的情况下,我们定义了我们的射线生成着色器。正如你所见,在同一组条目内也可以定义其他着色器。我们决定为每种着色器类型设置单独的条目,因为它使我们构建着色器绑定表时具有更大的灵活性。

其他着色器类型以类似方式定义,我们在此不再重复。作为一个快速示例,以下是定义最近击中着色器的方法:

shader_group_info.type =
    VK_RAY_TRACING_SHADER_GROUP_TYPE
        _TRIANGLES_HIT_GROUP_KHR;
shader_group_info.closestHitShader = stage_index;

现在我们已经定义了着色器组,我们可以创建我们的管道对象:

VkRayTracingPipelineCreateInfoKHR pipeline_info{
    VK_STRUCTURE_TYPE_RAY_TRACING_PIPELINE_CREATE_INFO_KHR };
pipeline_info.stageCount = shader_state_data->
    active_shaders;
pipeline_info.pStages = shader_state_data->
    shader_stage_info;
pipeline_info.groupCount = shader_state_data->
    active_shaders;
pipeline_info.pGroups = shader_state_data->
    shader_group_info;
pipeline_info.maxPipelineRayRecursionDepth = 1;
pipeline_info.layout = pipeline_layout;
vkCreateRayTracingPipelinesKHR( vulkan_device,
    VK_NULL_HANDLE, pipeline_cache, 1, &pipeline_info,
        vulkan_allocation_callbacks, &pipeline->vk_pipeline );
pipeline->vk_bind_point =
    VkPipelineBindPoint::VK_PIPELINE
        _BIND_POINT_RAY_TRACING_KHR;

注意maxPipelineRayRecursionDepth字段。它决定了在递归调用rayTraceEXT函数时最大调用栈的数量。这对于编译器确定此管道在运行时可能使用的内存量是必需的。

我们省略了pLibraryInfopLibraryInterface字段,因为我们没有使用它们。多个光线追踪管道可以组合在一起创建一个更大的程序,类似于在 C++中链接多个对象。这可以帮助减少光线追踪管道的编译时间,因为单个组件只需要编译一次。

最后一步是创建我们的着色器绑定表。我们首先计算我们的表所需的尺寸:

u32 group_handle_size =
    ray_tracing_pipeline_properties.shaderGroupHandleSize;
sizet shader_binding_table_size = group_handle_size *
    shader_state_data->active_shaders;

我们只需将句柄大小乘以我们表中条目的数量。

接下来,我们调用vkGetRayTracingShaderGroupHandlesKHR来获取光线追踪管道中的组句柄:

Array<u8> shader_binding_table_data{ };
shader_binding_table_data.init( allocator,
    shader_binding_table_size, shader_binding_table_size );
vkGetRayTracingShaderGroupHandlesKHR( vulkan_device,
    pipeline->vk_pipeline, 0, shader_state_data->
        active_shaders, shader_binding_table_size,
            shader_binding_table_data.data );

一旦我们有了着色器组句柄,我们就可以将它们组合起来为每种着色器类型创建单独的表。它们存储在单独的缓冲区中:

BufferCreation shader_binding_table_creation{ };
shader_binding_table_creation.set(
    VK_BUFFER_USAGE_SHADER_BINDING_TABLE_BIT_KHR |
    VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT_KHR,
    ResourceUsageType::Immutable, group_handle_size
    ).set_data( shader_binding_table_data.data
    ).set_name(  "shader_binding_table_raygen" );
pipeline->shader_binding_table_raygen = create_buffer(
    shader_binding_table_creation );
shader_binding_table_creation.set_data(
    shader_binding_table_data.data + group_handle_size )
        .set_name( "shader_binding_table_hit" );
pipeline->shader_binding_table_hit = create_buffer(
    shader_binding_table_creation );
shader_binding_table_creation.set_data(
    shader_binding_table_data.data + ( group_handle_size *
        2 ) ).set_name( "shader_binding_table_miss" );
pipeline->shader_binding_table_miss = create_buffer(
    shader_binding_table_creation );

每个表中只有一个条目,所以我们只需将每个组句柄复制到其缓冲区中。请注意,该缓冲区必须使用VK_BUFFER_USAGE_SHADER_BINDING_TABLE_BIT_KHR使用标志创建。

这就完成了我们的光线追踪管道创建。剩下要做的就是实际使用它来生成图像!这是通过以下代码实现的:

u32 shader_group_handle_size = gpu_device->
    ray_tracing_pipeline_properties.shaderGroupHandleSize;
VkStridedDeviceAddressRegionKHR raygen_table{ };
raygen_table.deviceAddress = gpu_device->
    get_buffer_device_address( pipeline->
        shader_binding_table_raygen );
raygen_table.stride = shader_group_handle_size;
raygen_table.size = shader_group_handle_size;
VkStridedDeviceAddressRegionKHR hit_table{ };
hit_table.deviceAddress = gpu_device->
    get_buffer_device_address( pipeline->
        shader_binding_table_hit );
VkStridedDeviceAddressRegionKHR miss_table{ };
miss_table.deviceAddress = gpu_device->
    get_buffer_device_address( pipeline->
        shader_binding_table_miss );
VkStridedDeviceAddressRegionKHR callable_table{ };
vkCmdTraceRaysKHR( vk_command_buffer, &raygen_table,
    &miss_table, &hit_table, &callable_table, width,
        height, depth );

我们为每个着色器绑定表定义VkStridedDeviceAddressRegionKHR。我们使用之前创建的表缓冲区。请注意,即使我们不使用它们,我们仍然需要为可调用着色器定义一个表。widthheightdepth参数决定了我们的光线追踪着色器的调用大小。

在本节中,我们说明了如何创建和使用光线追踪管道。我们首先定义了我们的着色器绑定表的组织结构。接下来,我们查看了一个基本的射线生成和最近击中着色器。然后,我们展示了如何创建光线追踪管道对象以及如何检索着色器组句柄。

这些句柄随后被用来填充我们的着色器绑定表的缓冲区。最后,我们演示了如何将这些组件组合起来以调用我们的光线追踪管线。

摘要

在本章中,我们提供了如何在 Vulkan 中使用光线追踪的详细信息。我们首先解释了两个基本概念:

  • 加速结构:这些结构是加快场景遍历所必需的。这对于实现实时结果至关重要。

  • 着色器绑定表:光线追踪管线可以调用多个着色器,这些表用于告诉 API 在哪个阶段使用哪个着色器。

在下一节中,我们提供了创建 TLAS 和 BLAS 的实现细节。我们首先记录组成我们的网格的几何体列表。接下来,我们使用这个列表来创建一个 BLAS。每个 BLAS 可以在 TLAS 中实例化多次,因为每个 BLAS 实例定义了自己的变换。有了这些数据,我们就可以创建我们的 TLAS。

在第三和最后一节中,我们解释了如何创建光线追踪管线。我们从创建单个着色器类型开始。接下来,我们展示了如何将这些单个着色器组合成一个光线追踪管线,以及如何从一个给定的管线生成着色器绑定表。

然后,我们展示了如何编写一个简单的光线生成着色器,该着色器与最近命中着色器和丢失着色器一起使用。最后,我们演示了如何将这些组件组合起来以在我们的场景中追踪光线。

在下一章中,我们将利用本章的所有知识来实现光线追踪阴影!

进一步阅读

和往常一样,我们只提供了如何使用 Vulkan API 的最相关细节。我们建议您阅读 Vulkan 规范以获取更多详细信息。以下是相关部分列表:

这个网站提供了关于加速结构的更多详细信息:www.scratchapixel.com/lessons/3d-basic-rendering/introduction-acceleration-structure/introduction

在线关于实时光线追踪的资源非常丰富。这仍然是一个新兴领域,并且正在持续研究中。以下这两本免费书籍提供了良好的起点:

第十三章:重新审视使用光线追踪的阴影

在本章中,我们将使用光线追踪来实现阴影效果。在第八章中,使用网格着色器添加阴影,我们使用了传统的阴影映射技术来获取每个光源的可见性,并使用这些信息来计算最终图像的阴影项。使用光线追踪来实现阴影可以让我们获得更详细的结果,并且可以根据每个光源的距离和强度对结果的质量进行更精细的控制。

我们将实现两种技术:第一种技术与离线渲染中使用的类似,即向每个光源发射光线以确定可见性。虽然这种方法可以给我们带来最佳结果,但它可能相当昂贵,这取决于场景中光源的数量。

第二种技术基于《光线追踪宝石》中的一篇最新文章。我们使用一些启发式方法来确定每个光源需要发射多少条光线,并将结果与空间和时间滤波器结合,以使结果稳定。

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

  • 实现简单的基于光线追踪的阴影

  • 实现基于光线追踪的阴影的高级技术

技术要求

到本章结束时,你将学会如何实现基本的基于光线追踪的阴影效果。你还将熟悉一种更高级的技术,它能够渲染带有柔和阴影的多光源。

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter13

实现简单的基于光线追踪的阴影

正如我们在引言中提到的,阴影映射技术多年来一直是实时渲染的基石。在 GPU 引入光线追踪功能之前,使用其他技术成本太高。

这并没有阻止图形社区想出聪明的解决方案来提高结果的质量,同时保持低成本。传统技术的主要问题是它们基于从每个光源的角度捕获深度缓冲区。这对于靠近光源和摄像机的物体来说效果很好,但随着我们远离,深度不连续性会导致最终结果中出现伪影。

解决这个问题的方法包括对结果进行滤波——例如,使用百分比更近滤波PCF)或级联阴影映射CSM)。这种技术需要捕获多个深度切片——级联以保持足够的分辨率,当我们远离光源时。这通常仅用于日光,因为它可能需要大量的内存和时间来多次重新渲染场景。它也可能很难在级联的边界处获得良好的结果。

阴影映射的另一个主要问题是,由于深度缓冲区的分辨率和它引入的不连续性,可能难以获得硬阴影。我们可以通过光线追踪来缓解这些问题。离线渲染已经使用了多年光线和路径追踪来实现照片级效果,包括阴影。

当然,他们有等待数小时或数天才能完成单个帧的奢侈,但我们可以实时获得类似的结果。在前一章中,我们使用了vkCmdTraceRaysKHR命令将射线投射到场景中。

对于这个实现,我们引入了射线查询,它允许我们从片段和计算着色器遍历我们设置的加速结构。

我们将修改我们的光照传递中的calculate_point_light_contribution方法,以确定每个片段可以看到哪些光源,并确定最终的阴影项。

首先,我们需要启用VK_KHR_ray_query设备扩展。我们还需要启用相关的着色器扩展:

#extension GL_EXT_ray_query : enable

然后,我们不是从每个光源的视角计算立方体贴图,而是简单地从片段世界位置向每个光源发射射线。

我们首先初始化一个rayQueryEXT对象:

rayQueryEXT rayQuery;
rayQueryInitializeEXT(rayQuery, as, gl_RayFlagsOpaqueEXT |
    gl_RayFlagsTerminateOnFirstHitEXT, 0xff,
        world_position, 0.001, l, d);

注意gl_RayFlagsTerminateOnFirstHitEXT参数,因为我们对这个射线只对第一次命中感兴趣。"l"是从world_position到光源的方向,我们使用从射线原点的小偏移量来避免自相交。

最后一个参数d是从world_position到灯光位置的距离。指定这个值很重要,因为否则射线查询可能会报告超过灯光位置的交点,我们可能会错误地将一个片段标记为处于阴影中。

现在我们已经初始化了射线查询对象,我们调用以下方法来开始场景遍历:

rayQueryProceedEXT( rayQuery );

这将在找到命中点或射线终止时返回。当使用射线查询时,我们不需要指定着色器绑定表。为了确定射线遍历的结果,我们可以使用几种方法来查询结果。在我们的情况下,我们只想知道射线是否击中了任何几何体:

if ( rayQueryGetIntersectionTypeEXT( rayQuery, true ) ==
    gl_RayQueryCommittedIntersectionNoneEXT ) {
        shadow = 1.0;
}

如果不是,这意味着我们可以从这个片段看到我们正在处理的灯光,我们可以在最终计算中考虑这个灯光的贡献。我们对每个灯光重复此操作以获得总的阴影项。

虽然这个实现非常简单,但它主要适用于点光源。对于其他类型的灯光——例如面光源——我们需要发射多根射线来确定可见性。随着光源数量的增加,使用这种简单技术可能会变得过于昂贵。

在本节中,我们演示了一个简单的实现,以开始实时光线追踪阴影。在下一节中,我们将介绍一种新的技术,它具有更好的扩展性,可以支持多种类型的灯光。

改进光线追踪阴影

在上一节中,我们描述了一个可以用来计算场景中可见性项的简单算法。正如我们提到的,这在大规模灯光和不同类型灯光需要大量样本的情况下扩展性不好。

在本节中,我们将实现一个受《Ray Tracing Gems》书中“Ray Traced Shadows”文章启发的不同算法。正如在本章和即将到来的章节中常见的那样,主要思想是将计算成本分散到时间上。

这仍然可能导致结果噪声,因为我们仍在使用低样本数。为了达到我们想要的质量,我们将利用空间和时间滤波,类似于我们在第十一章中做的,时间反走样

该技术通过三个过程实现,我们还将利用运动向量。现在我们将详细解释每个过程。

运动向量

正如我们在第十一章中看到的,时间反走样,运动向量是确定给定片段中的物体在帧之间移动多远所必需的。我们需要这些信息来确定我们计算中要保留和丢弃哪些信息。这有助于我们避免最终图像中的鬼影伪影。

对于本章的技术,我们需要以不同于时间反走样TAA)的方式计算运动向量。我们首先计算两帧之间深度的比例差异:

float depth_diff = abs( 1.0 - ( previous_position_ndc.z /
    current_position_ndc.z ) );

接下来,我们计算一个 epsilon 值,该值将用于确定可接受的深度变化:

float c1 = 0.003;
float c2 = 0.017;
float eps = c1 + c2 * abs( view_normal.z );

最后,我们使用这两个值来决定重投影是否成功:

vec2 visibility_motion = depth_diff < eps ? vec2(
    current_position_ndc.xy - previous_position_ndc.xy ) :
        vec2( -1, -1 );

下图显示了这一计算的结果:

图 13.1 – 运动向量的纹理

图 13.1 – 运动向量的纹理

我们将把这个值存储在一个纹理中,以供以后使用。下一步是计算过去四帧的可见性变化。

计算可见性方差

这种技术使用过去四帧的数据来确定每个片段每个光所需的样本数。我们将可见性值存储在一个 3D RGBA16 纹理中,其中每个通道是前几帧的可见性值。每一层存储单个光的可见性历史。

这是第一个使用 3D 调度大小的计算着色器之一。值得强调的是dispatch调用:

gpu_commands->dispatch( x, y, render_scene->active_lights );

在这个过程中,我们只是计算过去四帧中的最小值和最大值之间的差异:

vec4 last_visibility_values = texelFetch(
    global_textures_3d[ visibility_cache_texture_index ],
        tex_coord, 0 );
float max_v = max( max( max( last_visibility_values.x,
    last_visibility_values.y ), last_visibility_values.z ),
        last_visibility_values.w );
float min_v = min( min( min( last_visibility_values.x,
    last_visibility_values.y ), last_visibility_values.z ),
        last_visibility_values.w );
float delta = max_v - min_v;

在第一帧中,历史值被设置为0。我们将增量存储在另一个 3D 纹理中,以便在下一轮中使用。下图显示了这一轮的结果:

图 13.2 – 过去四帧的可见性变化

图 13.2 – 过去四帧的可见性变化

计算可见性

这一次传递负责根据过去四帧的方差计算每个光需要发射多少光线。

这一次传递需要从不同的纹理中读取大量数据。我们将使用局部数据存储(LDS)来缓存一个着色器调用内所有线程的值:

local_image_data[ local_index.y ][ local_index.x ] =
    texelFetch( global_textures_3d[ variation_texture_index
        ], global_index, 0 ).r;

正如我们在第九章中解释的,实现可变率着色,我们需要小心同步这些写入,通过在访问存储在local_image_data中的数据之前放置一个barrier()调用。同样,我们还需要填充矩阵边缘的值。代码与之前相同,我们在这里不会重复它。

接下来,我们将对这些数据进行滤波,使其更具时间稳定性。第一步是在一个 5x5 区域内计算最大值,并将结果存储在另一个 LDS 矩阵中:

local_max_image_data[ local_index.y ][ local_index.x ] =
    max_filter( local_index );

max_filter的实现如下:

for ( int y = -2; y <= 2; ++y  ) {
    for ( int x = -2; x <= 2; ++x ) {
        ivec2 xy = index.xy + ivec2( x, y );
        float v = local_image_data[ xy.y ][ xy.x ];
        max_v = max( max_v, v );
    }
}

在计算了max值之后,我们将它们通过一个 13x13 的帐篷滤波器:

float spatial_filtered_value = 0.0;
for ( int y = -6; y <= 6; ++y ) {
    for ( int x = -6; x <= 6; ++x ) {
        ivec2 index = local_index.xy + ivec2( x, y );
        float v = local_max_image_data[ index.y ][ index.x
        ];
        float f = tent_kernel[ y + 6 ][ x + 6 ];
        spatial_filtered_value += v * f;
    }
}

这样做是为了平滑相邻片段之间的差异,同时仍然给正在处理的片段更多的权重。然后我们将这个值与时间数据结合起来:

vec4 last_variation_values = texelFetch(
    global_textures_3d[ variation_cache_texture_index ],
        global_index, 0 );
float filtered_value = 0.5 * ( spatial_filtered_value +
   0.25 * ( last_variation_values.x +
          last_variation_values.y +
          last_variation_values.z +
          last_variation_values.w ) );

在继续之前,我们更新下一帧的变差缓存:

last_variation_values.w = last_variation_values.z;
last_variation_values.z = last_variation_values.y;
last_variation_values.y = last_variation_values.x;
last_variation_values.x = texelFetch( global_textures_3d[
    variation_texture_index ], global_index, 0 ).r;

我们现在利用刚刚获得的数据来计算可见性项。首先,我们需要确定样本计数。如果前一次传递中的重投影失败,我们简单地使用最大的样本计数:

uint sample_count = MAX_SHADOW_VISIBILITY_SAMPLE_COUNT;
if ( motion_vectors_value.r != -1.0 ) {

如果重投影成功,我们得到最后一帧的样本计数,并确定样本计数在过去四帧中是否稳定:

    sample_count = sample_count_history.x;
    bool stable_sample_count = 
      ( sample_count_history.x == sample_count_history.y ) &&
      ( sample_count_history.x == sample_count_history.z ) &&
      ( sample_count_history.x == sample_count_history.w );

然后,我们将这些信息与我们之前计算出的滤波值结合起来,以确定这一帧的样本计数:

    float delta = 0.2;
    if ( filtered_value > delta && sample_count <
        MAX_SHADOW_VISIBILITY_SAMPLE_COUNT ) {
            sample_count += 1;
    } else if ( stable_sample_count &&
          sample_count >= 1 ) {
              sample_count -= 1;
      }

如果滤波值超过一个给定的阈值,我们将增加样本计数。这意味着我们在过去四帧中识别出高方差值,我们需要更多的样本来收敛到一个更好的结果。

如果相反,样本计数在过去四帧中保持稳定,我们将减少样本计数。

虽然这在实践中效果很好,但如果场景稳定(例如,当相机不移动时),样本计数可能会达到0。这会导致一个未照亮的场景。因此,如果过去四帧的样本计数也为0,我们强制样本计数为1

    bvec4 hasSampleHistory = lessThan(
        sample_count_history, uvec4( 1 ) );
    bool zeroSampleHistory = all( hasSampleHistory );
    if ( sample_count == 0 && zeroSampleHistory ) {
        sample_count = 1;
    }
}

下面是样本计数缓存纹理的一个示例:

图 13.3 – 样本计数缓存纹理

图 13.3 – 样本计数缓存纹理

注意到看到光的片段通常需要更多的样本,这是预期的。

现在我们知道了需要多少样本,我们可以继续计算可见性值:

float visibility = 0.0;
if ( sample_count > 0 ) {
    // world position and normal are computed the same as
       before
    visibility = get_light_visibility(
       gl_GlobalInvocationID.z, sample_count,
       pixel_world_position, normal, frame_index );
}

get_light_visibility是追踪场景中光线的方法。它的实现如下:

const vec3 position_to_light = light.world_position –
    world_position;
const vec3 l = normalize( position_to_light );
const float NoL = clamp(dot(normal, l), 0.0, 1.0);
float d = sqrt( dot( position_to_light, position_to_light ) );

我们首先计算一些参数,就像我们之前为我们的光照实现所做的那样。此外,我们计算d,即这个片段的世界位置与正在处理的光之间的距离。

接下来,我们仅在光线足够接近且不在该片段的几何体后面时,才通过场景追踪光线。这是通过以下代码实现的:

float visiblity = 0.0;
float attenuation =
    attenuation_square_falloff(position_to_light,
        1.0f / light.radius);
const float scaled_distance = r / d;
if ( ( NoL > 0.001f ) && ( d <= r ) && ( attenuation >
    0.001f ) ) {

然后,我们为每个样本追踪一条光线。为了确保结果随时间收敛,我们通过使用预先计算的泊松盘来计算光线方向:

    for ( uint s = 0; s < sample_count; ++s ) {
        vec2 poisson_sample = POISSON_SAMPLES[ s *
            FRAME_HISTORY_COUNT + frame_index ];
        vec3 random_dir = normalize( vec3( l.x +
            poisson_sample.x, l.y + poisson_sample.y, l.z )
            );
        vec3 random_x = x_axis * poisson_sample.x *
            (scaled_distance) * 0.01;
        vec3 random_y = y_axis * poisson_sample.y *
            (scaled_distance) * 0.01;
        vec3 random_dir = normalize(l + random_x +
            random_y);

现在我们已经计算了我们的光线方向,我们可以开始光线遍历:

        rayQueryEXT rayQuery;
        rayQueryInitializeEXT(rayQuery, as,
           gl_RayFlagsOpaqueEXT |
           gl_RayFlagsTerminateOnFirstHitEXT,
           0xff, world_position, 0.001,
           random_dir, d);
        rayQueryProceedEXT( rayQuery );

这段代码与我们第一部分中展示的代码非常相似,但在这个案例中,我们累计光线可见的每个方向上的visibility值:

        if (rayQueryGetIntersectionTypeEXT(rayQuery, true)
            != gl_RayQueryCommittedIntersectionNoneEXT) {
                visibility +=
                    rayQueryGetIntersectionTEXT(rayQuery,
                        true) < d ? 0.0f : 1.0f;
        }
        else {
            visiblity += 1.0f;
        }
    }
}

最后,我们返回计算出的visibility值的平均值:

return visiblity / float( sample_count );

现在我们有了这一帧的visibility值,我们需要更新我们的可见性历史缓存。如果重投影成功,我们只需添加新值:

vec4 last_visibility_values = vec4(0);
if ( motion_vectors_value.r != -1.0 ) {
    last_visibility_values = texelFetch(
        global_textures_3d[ visibility_cache_texture_index
            ], global_index, 0 );
    last_visibility_values.w = last_visibility_values.z;
    last_visibility_values.z = last_visibility_values.y;
    last_visibility_values.y = last_visibility_values.x;

如果,另一方面,重投影失败,我们用新的visibility值覆盖所有history条目:

} else {
    last_visibility_values.w = visibility;
    last_visibility_values.z = visibility;
    last_visibility_values.y = visibility;
}
last_visibility_values.x = visibility;

最后一步是更新样本计数缓存:

sample_count_history.w = sample_count_history.z;
sample_count_history.z = sample_count_history.y;
sample_count_history.y = sample_count_history.x;
sample_count_history.x = sample_count;

现在我们已经更新了这一帧的可见性项并更新了所有缓存,我们可以移动到最后一个遍历并计算用于光照计算的滤波可见性。

计算滤波可见性

如果我们使用上一节计算出的visibility值,输出将会非常嘈杂。对于每一帧,我们可能会有不同的样本数和样本位置,尤其是如果相机或物体在移动。

由于这个原因,在我们能够使用它之前,我们需要清理结果。一种常见的方法是使用去噪器。去噪器通常实现为一系列计算遍历,正如其名所示,将尽可能减少噪声。去噪器可能需要相当长的时间,特别是当分辨率增加时。

在我们的情况下,我们将使用一个简单的时域和空域滤波器来减少这种技术所需的时间。与之前的遍历一样,我们首先需要将数据读入 LDS。这是通过以下两行代码实现的:

local_image_data[ local_index.y ][ local_index.x ] =
    visibility_temporal_filter( global_index );
local_normal_data[ local_index.y ][ local_index.x ] =
    get_normal( global_index );

visibility_temporal_filter的实现如下:

vec4 last_visibility_values = texelFetch(
    global_textures_3d[ visibility_cache_texture_index ],
        ivec3( xy, index.z ), 0 );
float filtered_visibility = 0.25 * (
    last_visibility_values.x + last_visibility_values.y +
    last_visibility_values.z + last_visibility_values.w );

我们首先读取给定光在此片段的历史可见性数据,并简单地计算平均值。这是我们的时间滤波器。根据您的用例,您可能使用不同的加权函数,给予较近的值更多的重视。

对于空域滤波,我们将使用高斯核。原始文章使用根据可见性方差可变大小的核。在我们的实现中,我们决定使用固定的 5x5 高斯核,因为它提供了足够好的结果。

计算滤波值的循环实现如下:

vec3 p_normal = local_normal_data[ local_index.y ][
    local_index.x ];

首先,我们将法线存储在我们的片段位置。然后我们遍历核大小来计算最终项:

for ( int y = -2; y <= 2; ++y ) {
    for ( int x = -2; x <= 2; ++x ) {
        ivec2 index = local_index.xy + ivec2( x, y );
        vec3 q_normal = local_normal_data[ local_index.y +
            y ][ local_index.x + x ];
        if ( dot( p_normal, q_normal ) <= 0.9 ) {
            continue;
        }

如文章所述,如果相邻片段的法线发散,我们忽略这个数据点。这样做是为了防止阴影泄露。

最后,我们将已经通过时间滤波器的值与核值相结合:

        float v = local_image_data[ index.y ][ index.x ];
        float k = gaussian_kernel[ y + 2 ][ x + 2 ];
        spatial_filtered_value += v * k;
    }
}

下一个图展示了过滤后的可见性纹理的内容:

图 13.4 – 过滤后的可见性纹理

图 13.4 – 过滤后的可见性纹理

这就完成了每个灯光的可见性值的计算。现在,我们可以使用这些信息在我们的光照过程中,如下一节所述。

使用过滤后的可见性

使用我们的可见性项非常简单。在calculate_point_light_contribution方法中,我们只需读取在前几步中计算出的可见性:

float shadow = texelFetch( global_textures_3d[
    shadow_visibility_texture_index ], ivec3( screen_uv,
        shadow_light_index ), 0 ).r;
float attenuation =
    attenuation_square_falloff(position_to_light, 1.0f /
        light.radius) * shadow;
if ( attenuation > 0.0001f && NoL > 0.0001f ) {
// same code as before

可能可以将传统的阴影映射与类似于我们在这里描述的射线追踪实现相结合。这完全取决于该技术的帧预算、场景中的灯光类型以及期望的质量。

在本节中,我们提出了射线追踪阴影的不同实现方法。第一步是计算并存储过去四帧的可见性方差。接下来,我们使用max滤波器后跟帐篷滤波器来计算每个片段和每个灯光的样本计数。

然后,我们使用这个样本计数将射线追踪到场景中,以确定一个原始的可见性值。在最后一遍中,我们将这个可见性值通过时间和空间滤波器来减少噪声。最后,我们使用这个过滤后的值在我们的光照计算中。

摘要

在本章中,我们介绍了射线追踪阴影的两个实现方法。在第一部分,我们提供了一个类似于你可能在离线渲染器中找到的简单实现。我们简单地针对每个片段向每个灯光发射一束射线,以确定从该位置是否可见。

虽然这对于点光源来说效果很好,但要支持其他灯光类型并渲染软阴影,则需要许多射线。因此,我们还提供了一个替代方案,该方案利用空间和时间信息来确定每个灯光需要使用多少样本。

我们首先计算过去四帧的可见性方差。然后,我们过滤这个值以确定每个灯光对每个片段需要发射多少射线。我们使用这个计数来遍历场景并确定每个片段的可见性值。最后,我们过滤我们获得的可见性以减少噪声。过滤后的可见性随后用于光照计算以确定最终的阴影项。

在下一章中,我们将通过实现全局光照来继续我们的射线追踪之旅!

进一步阅读

本章中我们实现的技术在《光线追踪宝石》一书的第十三章“用光线追踪重新审视阴影”中有详细描述。该书可在以下链接免费获取:www.realtimerendering.com/raytracinggems/rtg/index.xhtml.

我们只使用了可用于光线追踪的 GLSL API 的有限集合。我们建议阅读 GLSL 扩展规范以查看所有可用的选项:

在本章中,我们使用了几个过滤器。信号处理是一个广阔而精彩的领域,它在图形编程中的应用比人们意识到的要多。为了帮助您入门,我们推荐 Bart Wronski 的这篇文章:bartwronski.com/2021/02/15/bilinear-down-upsampling-pixel-grids-and-that-half-pixel-offset/.

第十四章:使用光线追踪添加动态漫反射全局照明

到目前为止,本书中的照明一直基于来自点光源的直接照明。在本章中,我们将通过添加间接照明(在视频游戏环境中通常称为全局照明)来增强照明。

这种照明类型来源于模拟光的行为。不深入量子物理和光学,我们需要考虑的信息是光在表面反射几次,直到其能量变为零。

在电影和视频游戏中,全局照明一直是照明的一个重要方面,但通常无法实时执行。

在电影中,渲染一帧通常需要几分钟(如果不是几个小时),直到全局照明被开创。视频游戏受到了这种启发,现在也包括了它在其照明中。

在本章中,我们将通过涵盖以下主题来发现如何实现实时全局照明:

  • 间接照明简介

  • 动态漫反射全局照明(DDGI)简介

  • 实现 DDGI

每个主题都将包含子节,以便您可以扩展所提供知识。

以下图表显示了本章中的代码如何帮助间接照明:

图 14.1 – 间接照明输出

图 14.1 – 间接照明输出

图 14.1 中,场景左侧有一个点光源。我们可以看到光线从左侧的窗帘反射到地板和右侧的柱子和窗帘上,形成了绿色。

在远处的地板上,我们可以看到天空的颜色染上了墙壁。由于它的可见性提供的遮挡,对拱门的光贡献非常低。

技术要求

本章的代码可以在以下网址找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter14

间接照明简介

回到直接和间接照明,直接照明仅显示光与物质之间的第一次相互作用,但光继续在空间中传播,有时会反射。

从渲染的角度来看,我们使用 G 缓冲区信息来计算从我们的视角可见的表面与光的第一次相互作用,但我们对我们视野之外的数据知之甚少。

以下图表显示了直接照明:

图 14.2 – 直接照明

图 14.2 – 直接照明

图 14.2 描述了当前的照明设置。有发光光线,这些光线与表面相互作用。光从这些表面上反射并被相机捕捉,成为像素颜色。这是一个对现象的极度简化视图,但它包含了我们需要的基本知识。

对于间接照明,仅依靠摄像机的视角是不够的,因为我们还需要计算其他光线和几何形状如何贡献并仍然影响场景中可见的部分,即使它们位于视野之外,以及可见的表面。

对于这个问题,光线追踪是最好的工具:它是一种查询场景空间的方法,我们可以用它来计算不同光线弹跳如何贡献到给定片段的最终值。

下面是一个显示间接照明的图表:

图 14.3 – 间接照明

图 14.3 – 间接照明

图 14.3显示了间接光线从表面弹跳,直到再次击中摄像机。

图中突出显示了两个光线:

  • 间接光线 0,从隐藏表面弹跳到蓝色地板,最终进入摄像机

  • 间接光线 0,从另一个表面弹跳,然后从红色墙上弹跳,最终进入摄像机

使用间接照明,我们想要捕捉光线从表面弹跳的现象,无论是隐藏的还是可见的。

例如,在这个设置中,红色和蓝色表面之间存在一些光线,它们将在彼此之间弹跳,使相应颜色的表面较近的部分着色。

将间接照明添加到照明中可以增强图像的真实感和视觉质量,但我们如何实现这一点呢?

在下一节中,我们将讨论我们选择实现的方法:动态漫反射全局照明,或DDGI,这主要是由 Nvidia 的研究人员开发的,但正在迅速成为 AAA 游戏中使用最广泛的一种解决方案。

动态漫反射全局照明(DDGI)简介

在本节中,我们将解释 DDGI 背后的算法。DDGI 基于两个主要工具:光照探针和辐照度体积:

  • 光照探针是空间中的点,表示为球体,它们编码了光线信息

  • 辐照度体积定义为包含三维网格的光照探针的空间,探针之间有固定的间距

当布局规则时,采样更容易,尽管我们稍后会看到一些改进放置的方法。探针使用八面体映射进行编码,这是一种将正方形映射到球体的便捷方法。在进一步阅读部分提供了八面体映射背后的数学链接。

DDGI 背后的核心思想是使用光线追踪动态更新探针:对于每个探针,我们将发射一些光线并计算三角形交点的辐射度。辐射度是通过引擎中动态存在的光源计算的,能够实时响应任何光线或几何形状的变化。

由于网格的分辨率相对于屏幕上的像素较低,唯一可能的光照现象就是漫反射。以下图表概述了算法,显示了着色器(绿色矩形)和纹理(黄色椭圆)之间的关系和顺序:

图 14.4 – 算法概述

图 14.4 – 算法概述

在详细查看每个步骤之前,让我们快速概述一下算法:

  1. 对每个探针执行光线追踪并计算辐射度和距离。

  2. 使用一些滞后更新所有探针的辐照度,同时使用计算出的辐射度。

  3. 使用光线追踪过程中的距离更新所有探针的可见性数据,再次使用一些滞后。

  4. (可选)使用光线追踪距离计算每个探针的偏移位置。

  5. 通过读取更新的辐照度、可见性和探针偏移来计算间接光照。

在以下小节中,我们将介绍算法的每个步骤。

对每个探针进行光线追踪

这是算法的第一步。对于每个需要更新的探针的每条射线,我们必须使用动态光照对场景进行光线追踪。

在光线追踪的击中着色器中,我们计算击中三角形的全局位置和法线,并执行简化的漫反射光照计算。可选的,但成本更高,我们可以读取其他辐照度探头来为光照计算添加无限次的反弹,使其看起来更加逼真。

这里特别重要的是纹理布局:每一行代表单个探针的射线。因此,如果我们每个探针有 128 条射线,我们将有一个 128 个 texels 的行,而每一列代表一个探针。

因此,具有 128 条射线和 24 个探针的配置将产生 128x24 的纹理维度。我们将光照计算作为辐射度存储在纹理的 RGB 通道中,并将击中距离存储在 Alpha 通道中。

击中距离将用于帮助处理光泄漏和计算探针偏移。

探针偏移

当辐照度体积被加载到世界中或其属性发生变化(如间距或位置)时,会执行探针偏移步骤。使用光线追踪步骤中的击中距离,我们可以计算探针是否直接放置在表面上,然后为其创建偏移量。

偏移量不能大于到其他探针距离的一半,这样网格仍然在网格索引和它们的位置之间保持一定的连贯性。这一步骤只执行几次(通常,大约五次是一个合适的数字),因为持续执行会导致探针无限移动,从而引起光闪烁。

一旦计算出偏移量,每个探针都将具有最终的全局位置,这极大地提高了间接光照的视觉效果。

在这里,我们可以看到计算这些偏移量后的改进:

图 14.5 – 带有(左)和没有(右)探针偏移的全局光照

图 14.5 – 带有(左)和没有(右)探针偏移的全局光照

如您所见,位于几何体内部的探针不仅不会对采样做出光照贡献,还可以创建视觉伪影。

多亏了探针偏移,我们可以将探针放置在更好的位置。

探针辐照度和可见性更新

现在我们有了每个探针在应用动态光照后追踪的每条射线的结果。我们如何编码这些信息?如动态漫反射全局照明(DDGI)简介部分所示,其中一种方法就是使用八面体映射,它将球体展开成矩形。

由于我们正在将每个探针的辐照度存储为一个 3D 体积,我们需要一个包含每个探针矩形的纹理。我们将选择创建一个包含 MxN 个探针层的一行纹理,而高度包含其他层。

例如,如果我们有一个 3x2x4 的探针网格,每一行将包含 6 个探针(3x2),最终纹理将有 4 行。我们将执行这个步骤两次,一次用于从辐照度更新辐照度,另一次用于从每个探针的距离更新可见性。

可见性对于最小化光泄漏至关重要,辐照度和可见性存储在不同的纹理中,并且可以有不同的尺寸。

有一个需要注意的事项是,为了添加对双线性过滤的支持,我们需要在每个矩形周围存储一个额外的 1 像素边框;这在这里也会更新。

着色器将读取计算出的新辐照度和距离,以及前一帧的辐照度和可见性纹理,以混合值以避免闪烁,就像体量雾使用时间重投影那样,通过简单的滞后效应来实现。

如果光照条件发生剧烈变化,滞后效应可以动态地改变,以对抗使用滞后效应的缓慢更新。结果通常对光运动的反应较慢,但这是为了避免闪烁而必须接受的缺点。

着色器的最后部分涉及更新双线性过滤的边缘。双线性过滤需要按照特定的顺序读取样本,如下面的图所示:

图 14.6 – 双线性过滤样本。外部网格复制每个矩形内写入的像素位置

图 14.6 – 双线性过滤样本。外部网格复制每个矩形内写入的像素位置

图 14**.6 展示了复制像素的坐标计算:中心区域是执行了完整的辐照度/可见性更新的区域,而边缘则复制指定坐标处的像素值。

我们将运行两个不同的着色器 – 一个用于更新探针辐照度,另一个用于更新探针可见性。

在着色器代码中,我们将看到实际执行此操作的代码。我们现在准备好采样探针的辐照度,如下一小节所示。

探针采样

这一步涉及读取辐照度探针并计算间接光照贡献。我们将从主相机的视角进行渲染,并且给定一个世界位置和方向,我们将采样最近的八个探针。可见性纹理用于最小化泄漏并软化光照结果。

由于漫反射间接组件具有软光照特性,为了获得更好的性能,我们选择在四分之一分辨率下采样,因此我们需要特别注意采样位置以避免像素不精确。

当查看探针光线追踪、辐照度更新、可见性更新、探针偏移和探针采样时,我们描述了实现 DDGI 所需的所有基本步骤。

可以包括其他步骤来使渲染更快,例如使用距离来计算非活动探针。还可以包括其他扩展,例如包含一系列体积和手动放置的体积,这些体积为 DDGI 提供了在视频游戏中使用的最佳灵活性,因为不同的硬件配置可以决定算法选择。

在下一节中,我们将学习如何实现 DDGI。

实现 DDGI

我们将首先读取的是光线追踪着色器。正如我们在第十二章中看到的,“开始使用光线追踪”,这些着色器作为一个包含光线生成、光线击中和光线丢失着色器的包提供。

这里将使用一组不同的方法将世界空间转换为网格索引,反之亦然,这些方法将在这里使用;它们包含在代码中。

首先,我们想要定义射线负载——即在光线追踪查询执行后缓存的那些信息:

struct RayPayload {
    vec3 radiance;
    float distance;
};

光线生成着色器

第一个着色器称为光线生成。它使用球面上的随机方向和球面斐波那契序列从探针位置生成光线。

就像 TAA 和体积雾的抖动一样,使用随机方向和时间累积(在探针更新着色器中发生)可以让我们获得更多关于场景的信息,从而增强视觉效果:

layout( location = 0 ) rayPayloadEXT RayPayload payload;
void main() {
const ivec2 pixel_coord = ivec2(gl_LaunchIDEXT.xy);
    const int probe_index = pixel_coord.y;
    const int ray_index = pixel_coord.x;
    // Convert from linear probe index to grid probe 
       indices and then position:
    ivec3 probe_grid_indices = probe_index_to_grid_indices( 
      probe_index );
    vec3 ray_origin = grid_indices_to_world( 
      probe_grid_indices probe_index );
    vec3 direction = normalize( mat3(random_rotation) * 
      spherical_fibonacci(ray_index, probe_rays) );
    payload.radiance = vec3(0);
    payload.distance = 0;
    traceRayEXT(as, gl_RayFlagsOpaqueEXT, 0xff, 0, 0, 0, 
      ray_origin, 0.0, direction, 100.0, 0);

    // Store the result coming from Hit or Miss shaders
    imageStore(global_images_2d[ radiance_output_index ], 
    pixel_coord, vec4(payload.radiance, payload.distance));
} 

光线击中着色器

这里是所有重负载发生的地方。

首先,我们必须声明负载和重心坐标来计算正确的三角形数据:

layout( location = 0 ) rayPayloadInEXT RayPayload payload;
hitAttributeEXT vec2 barycentric_weights;

然后,检查背面三角形,只存储距离,因为不需要光照:

void main() {
    vec3 radiance = vec3(0);
    float distance = 0.0f;
    if (gl_HitKindEXT == gl_HitKindBackFacingTriangleEXT) {
        // Track backfacing rays with negative distance
        distance = gl_RayTminEXT + gl_HitTEXT;
        distance *= -0.2;        
    }

否则,计算三角形数据并执行光照:

    else {

接下来,读取网格实例数据和索引缓冲区:

    uint mesh_index = mesh_instance_draws[ 
      gl_GeometryIndexEXT ].mesh_draw_index;
    MeshDraw mesh = mesh_draws[ mesh_index ];

    int_array_type index_buffer = int_array_type( 
      mesh.index_buffer );
    int i0 = index_buffer[ gl_PrimitiveID * 3 ].v;
    int i1 = index_buffer[ gl_PrimitiveID * 3 + 1 ].v;
    int i2 = index_buffer[ gl_PrimitiveID * 3 + 2 ].v;

现在,我们可以从网格缓冲区读取顶点并计算世界空间位置:

    float_array_type vertex_buffer = float_array_type( 
      mesh.position_buffer );
    vec4 p0 = vec4(vertex_buffer[ i0 * 3 + 0 ].v, 
      vertex_buffer[ i0 * 3 + 1 ].v,
      vertex_buffer[ i0 * 3 + 2 ].v, 1.0 );
    // Calculate p1 and p2 using i1 and i2 in the same 
       way.   

计算世界位置:

    const mat4 transform = mesh_instance_draws[ 
      gl_GeometryIndexEXT ].model;
    vec4 p0_world = transform * p0;
    // calculate as well p1_world and p2_world

就像读取顶点位置一样,读取 UV 缓冲区并计算三角形的最终 UV 值:

    float_array_type uv_buffer = float_array_type( 
      mesh.uv_buffer );
    vec2 uv0 = vec2(uv_buffer[ i0 * 2 ].v, uv_buffer[ 
      i0 * 2 + 1].v);
    // Read uv1 and uv2 using i1 and i2 
    float b = barycentric_weights.x;
    float c = barycentric_weights.y;
    float a = 1 - b - c;

    vec2 uv = ( a * uv0 + b * uv1 + c * uv2 );

读取漫反射纹理。我们还可以读取较低的 MIP 级别以改善性能:

    vec3 diffuse = texture( global_textures[ 
      nonuniformEXT( mesh.textures.x ) ], uv ).rgb;

读取三角形法线并计算最终法线。您不需要读取法线纹理,因为缓存的计算结果非常小,这些细节已经丢失:

    float_array_type normals_buffer = 
      float_array_type( mesh.normals_buffer );
    vec3 n0 = vec3(normals_buffer[ i0 * 3 + 0 ].v,
      normals_buffer[ i0 * 3 + 1 ].v,
      normals_buffer[ i0 * 3 + 2 ].v );
    // Similar calculations for n1 and n2 using i1 and 
       i2
    vec3 normal = a * n0 + b * n1 + c * n2;
    const mat3 normal_transform = mat3(mesh_instance_draws
      [gl_GeometryIndexEXT ].model_inverse);
    normal = normal_transform * normal;

我们可以计算世界位置和法线,然后计算直接光照:

    const vec3 world_position = a * p0_world.xyz + b * 
      p1_world.xyz + c * p2_world.xyz;
    vec3 diffuse = albedo * direct_lighting(world_position, 
      normal);
    // Optional: infinite bounces by samplying previous 
       frame Irradiance:
    diffuse += albedo * sample_irradiance( world_position, 
      normal, camera_position.xyz ) * 
      infinite_bounces_multiplier;

最后,我们可以缓存辐射度和距离:

    radiance = diffuse;
    distance = gl_RayTminEXT + gl_HitTEXT;
    }

现在,让我们将结果写入负载:

    payload.radiance = radiance;
    payload.distance = distance;
}

光线丢失着色器

在这个着色器中,我们简单地返回天空颜色。或者,如果存在,可以添加环境立方体贴图:

layout( location = 0 ) rayPayloadInEXT RayPayload payload;
void main() {
payload.radiance = vec3( 0.529, 0.807, 0.921 );
payload.distance = 1000.0f;
}

更新探测器的辐照度和可见性着色器

这个计算着色器将读取前一帧的辐照度/可见性和当前帧的辐射/距离,并更新每个探测器的八面体表示。这个着色器将执行两次——一次用于更新辐照度,一次用于更新可见性。它还将更新边界以支持双线性过滤。

首先,我们必须检查当前像素是否是边界。如果是,我们必须更改模式:

layout (local_size_x = 8, local_size_y = 8, local_size_z = 
        1) in;
void main() {
    ivec3 coords = ivec3(gl_GlobalInvocationID.xyz);
    const uint probe_with_border_side = probe_side_length + 
                                        2;
    const uint probe_last_pixel = probe_side_length + 1;
    int probe_index = get_probe_index_from_pixels
      (coords.xy, int(probe_with_border_side), 
      probe_texture_width);
    // Check if thread is a border pixel
    bool border_pixel = ((gl_GlobalInvocationID.x % 
      probe_with_border_side) == 0) || 
      ((gl_GlobalInvocationID.x % probe_with_border_side ) 
      == probe_last_pixel );
    border_pixel = border_pixel || 
      ((gl_GlobalInvocationID.y % probe_with_border_side) 
      == 0) || ((gl_GlobalInvocationID.y % 
      probe_with_border_side ) == probe_last_pixel );

对于非边界像素,根据射线方向和用八面体坐标编码的球体方向计算权重,并将辐照度计算为辐射的总权重:

    if ( !border_pixel ) {
        vec4 result = vec4(0);
        uint backfaces = 0;
        uint max_backfaces = uint(probe_rays * 0.1f); 

添加每个射线的贡献:

        for ( int ray_index = 0; ray_index < probe_rays; 
              ++ray_index ) {
            ivec2 sample_position = ivec2( ray_index, 
              probe_index );
            vec3 ray_direction = normalize( 
              mat3(random_rotation) * 
              spherical_fibonacci(ray_index, probe_rays) );
            vec3 texel_direction = oct_decode
              (normalized_oct_coord(coords.xy));
            float weight = max(0.0, dot(texel_direction, 
              ray_direction));

读取这个射线的距离,如果背面太多则提前退出:

            float distance = texelFetch(global_textures
              [nonuniformEXT(radiance_output_index)], 
              sample_position, 
              0).w;
            if ( distance < 0.0f && 
                 use_backfacing_blending() ) {
                ++backfaces;
                // Early out: only blend ray radiance into 
                   the probe if the backface threshold 
                   hasn't been exceeded
                if (backfaces >= max_backfaces) {
                    return;
                }
                continue;
            }

在这一点上,根据我们是在更新辐照度还是可见性,我们将执行不同的计算。

对于辐照度,我们必须做以下事情:

            if (weight >= EPSILON) {
                vec3 radiance = texelFetch(global_textures
                  [nonuniformEXT(radiance_output_index)], 
                  sample_position, 0).rgb;
                radiance.rgb *= energy_conservation;

                // Storing the sum of the weights in alpha 
                   temporarily
                result += vec4(radiance * weight, weight);
            }

对于可见性,我们必须读取并限制距离:

            float probe_max_ray_distance = 1.0f * 1.5f;
            if (weight >= EPSILON) {
                float distance = texelFetch(global_textures
                  [nonuniformEXT(radiance_output_index)], 
                  sample_position, 0).w;
                // Limit distance
                distance = min(abs(distance), 
                  probe_max_ray_distance);
                vec3 value = vec3(distance, distance * 
                  distance, 0);
                // Storing the sum of the weights in alpha 
                   temporarily
                result += vec4(value * weight, weight);
            }
        }

最后,应用权重:

        if (result.w > EPSILON) {
            result.xyz /= result.w;
            result.w = 1.0f;
        }

现在,我们可以读取前一帧的辐照度或可见性,并使用滞后性进行混合。

对于辐照度,我们必须做以下事情:

        vec4 previous_value = imageLoad( irradiance_image, 
          coords.xy );
        result = mix( result, previous_value, hysteresis );
        imageStore(irradiance_image, coords.xy, result);

对于可见性,我们必须做以下事情:

        vec2 previous_value = imageLoad( visibility_image, 
          coords.xy ).rg;
        result.rg = mix( result.rg, previous_value, 
          hysteresis );
        imageStore(visibility_image, coords.xy, 
          vec4(result.rg, 0, 1));

在这一点上,我们结束非边界像素的着色器。我们将等待局部组完成并将像素复制到边界:

        // NOTE: returning here.
        return;
    }

接下来,我们必须处理边界像素。

由于我们正在处理一个与每个正方形一样大的本地线程组,当组完成时,我们可以使用当前更新的数据复制边界像素。这是一个优化过程,有助于我们避免调度其他两个着色器并添加屏障等待更新完成。

在实现前面的代码后,我们必须等待组完成:

    groupMemoryBarrier();
    barrier();

一旦这些屏障在着色器代码中,所有组都将完成。

我们有最终存储在纹理中的辐照度/可见性,因此我们可以复制边界像素以添加双线性采样支持。如图图 14**.6所示,我们需要按特定顺序读取像素以确保双线性过滤正常工作。

首先,我们必须计算源像素坐标:

    const uint probe_pixel_x = gl_GlobalInvocationID.x % 
      probe_with_border_side;
    const uint probe_pixel_y = gl_GlobalInvocationID.y % 
      probe_with_border_side;
    bool corner_pixel = (probe_pixel_x == 0 || 
      probe_pixel_x == probe_last_pixel) && (probe_pixel_y 
      == 0 || probe_pixel_y == probe_last_pixel);
    bool row_pixel = (probe_pixel_x > 0 && probe_pixel_x < 
      probe_last_pixel);
    ivec2 source_pixel_coordinate = coords.xy;
    if ( corner_pixel ) {
        source_pixel_coordinate.x += probe_pixel_x == 0 ? 
          probe_side_length : -probe_side_length;
        source_pixel_coordinate.y += probe_pixel_y == 0 ? 
          probe_side_length : -probe_side_length;
     }
    else if ( row_pixel ) {
        source_pixel_coordinate.x += 
          k_read_table[probe_pixel_x - 1];
        source_pixel_coordinate.y += (probe_pixel_y > 0) ? 
          -1 : 1;
     }
    else {
        source_pixel_coordinate.x += (probe_pixel_x > 0) ? 
          -1 : 1;
        source_pixel_coordinate.y += 
          k_read_table[probe_pixel_y - 1];
     }

接下来,我们必须将源像素复制到当前边界。

对于辐照度,我们必须做以下事情:

    vec4 copied_data = imageLoad( irradiance_image, 
      source_pixel_coordinate );
    imageStore( irradiance_image, coords.xy, copied_data );

对于可见性,我们必须做以下事情:

    vec4 copied_data = imageLoad( visibility_image, 
      source_pixel_coordinate );
    imageStore( visibility_image, coords.xy, copied_data );
}

现在,我们已经有了更新的辐照度和可见性,准备好被场景采样。

间接光照采样

这个计算着色器负责读取间接辐照度,以便它可用于照明。它使用一个名为sample_irradiance的实用方法,该方法也用于射线命中着色器以模拟无限反弹。

首先,让我们看看计算着色器。当使用四分之一分辨率时,遍历 2x2 像素的邻域,获取最近的深度,并保存像素索引:

layout (local_size_x = 8, local_size_y = 8, local_size_z = 
        1) in;
void main() {
    ivec3 coords = ivec3(gl_GlobalInvocationID.xyz);
    int resolution_divider = output_resolution_half == 1 ? 
      2 : 1;
    vec2 screen_uv = uv_nearest(coords.xy, resolution / 
      resolution_divider);

    float raw_depth = 1.0f;
    int chosen_hiresolution_sample_index = 0;
    if (output_resolution_half == 1) {
        float closer_depth = 0.f;
        for ( int i = 0; i < 4; ++i ) {
            float depth = texelFetch(global_textures
             [nonuniformEXT(depth_fullscreen_texture_index)
             ], (coords.xy) * 2 + pixel_offsets[i], 0).r;
            if ( closer_depth < depth ) {
                closer_depth = depth;
                chosen_hiresolution_sample_index = i;
            }
        }

        raw_depth = closer_depth;
    }

使用最近深度的缓存索引读取法线:

    vec3 normal = vec3(0);
    if (output_resolution_half == 1) {
        vec2 encoded_normal = texelFetch(global_textures
          [nonuniformEXT(normal_texture_index)],      
          (coords.xy) * 2 + pixel_offsets
          [chosen_hiresolution_sample_index], 0).rg;
       normal = normalize(octahedral_decode(encoded_normal)
       );
    }

现在我们已经计算了深度和法线,我们可以收集世界位置并使用法线来采样辐照度:

    const vec3 pixel_world_position = 
      world_position_from_depth(screen_uv, raw_depth, 
      inverse_view_projection)
    vec3 irradiance = sample_irradiance( 
      pixel_world_position, normal, camera_position.xyz );
    imageStore(global_images_2d[ indirect_output_index ], 
      coords.xy, vec4(irradiance,1));
}

着色器的第二部分是关于sample_irradiance函数,它执行实际的重负载。

它首先计算一个偏差向量,将采样移动到几何体前方一点,以帮助解决泄漏问题:

vec3 sample_irradiance( vec3 world_position, vec3 normal, 
  vec3 camera_position ) {
    const vec3 V = normalize(camera_position.xyz – 
      world_position);
    // Bias vector to offset probe sampling based on normal 
       and view vector.
    const float minimum_distance_between_probes = 1.0f;
    vec3 bias_vector = (normal * 0.2f + V * 0.8f) * 
      (0.75f  minimum_distance_between_probes) * 
      self_shadow_bias;
    vec3 biased_world_position = world_position + 
      bias_vector;

    // Sample at world position + probe offset reduces 
       shadow leaking.
    ivec3 base_grid_indices = 
      world_to_grid_indices(biased_world_position);
    vec3 base_probe_world_position = 
      grid_indices_to_world_no_offsets( base_grid_indices 
      );

现在我们有了采样世界位置(加上偏差)的网格世界位置和索引。

现在,我们必须计算采样位置在单元格内的每个轴上的值:

    // alpha is how far from the floor(currentVertex) 
       position. on [0, 1] for each axis.
    vec3 alpha = clamp((biased_world_position – 
      base_probe_world_position) , vec3(0.0f), vec3(1.0f));

在这一点上,我们可以采样采样点的八个相邻探头:

    vec3  sum_irradiance = vec3(0.0f);
    float sum_weight = 0.0f;

对于每个探头,我们必须根据索引计算其世界空间位置:

    // Iterate over adjacent probe cage
    for (int i = 0; i < 8; ++i) {
        // Compute the offset grid coord and clamp to the 
           probe grid boundary
        // Offset = 0 or 1 along each axis
        ivec3  offset = ivec3(i, i >> 1, i >> 2) & 
          ivec3(1);
        ivec3  probe_grid_coord = clamp(base_grid_indices + 
          offset, ivec3(0), probe_counts - ivec3(1));
        int probe_index = 
          probe_indices_to_index(probe_grid_coord);
        vec3 probe_pos = 
          grid_indices_to_world(probe_grid_coord, 
          probe_index); 

根据网格单元顶点计算三线性权重,以在探头之间平滑过渡:

        vec3 trilinear = mix(1.0 - alpha, alpha, offset);
        float weight = 1.0;

现在,我们可以看到如何使用可见性纹理。它存储深度和深度平方值,对防止光泄漏有很大帮助。

此测试基于方差,例如方差阴影图:

        vec3 probe_to_biased_point_direction = 
          biased_world_position - probe_pos;
        float distance_to_biased_point = 
          length(probe_to_biased_point_direction);
        probe_to_biased_point_direction *= 1.0 / 
          distance_to_biased_point;
       {
            vec2 uv = get_probe_uv
              (probe_to_biased_point_direction,
              probe_index, probe_texture_width, 
              probe_texture_height, 
              probe_side_length );
            vec2 visibility = textureLod(global_textures
            [nonuniformEXT(grid_visibility_texture_index)],
            uv, 0).rg;
            float mean_distance_to_occluder = visibility.x;
            float chebyshev_weight = 1.0;

检查采样探头是否处于“阴影”中,并计算 Chebyshev 权重:

            if (distance_to_biased_point > 
                mean_distance_to_occluder) {
                float variance = abs((visibility.x * 
                  visibility.x) - visibility.y);
                const float distance_diff = 
                  distance_to_biased_point – 
                  mean_distance_to_occluder;
                chebyshev_weight = variance / (variance + 
                  (distance_diff * distance_diff));
                // Increase contrast in the weight
                chebyshev_weight = max((chebyshev_weight * 
                  chebyshev_weight * chebyshev_weight), 
                    0.0f);
            }

            // Avoid visibility weights ever going all of 
               the way to zero
           chebyshev_weight = max(0.05f, chebyshev_weight);
           weight *= chebyshev_weight;
        }

使用为此探头计算的权重,我们可以应用三线性偏移,读取辐照度,并计算其贡献:

         vec2 uv = get_probe_uv(normal, probe_index, 
           probe_texture_width, probe_texture_height, 
           probe_side_length );
        vec3 probe_irradiance = 
          textureLod(global_textures
          [nonuniformEXT(grid_irradiance_output_index)],
          uv, 0).rgb;
         // Trilinear weights
        weight *= trilinear.x * trilinear.y * trilinear.z + 
          0.001f;
        sum_irradiance += weight * probe_irradiance;
        sum_weight += weight;
    }

在采样所有探头后,最终辐照度相应缩放并返回:

    vec3 irradiance = 0.5f * PI * sum_irradiance / 
      sum_weight;
    return irradiance;
}

通过这样,我们已经完成了对辐照度采样计算着色器和实用函数的查看。

可以应用更多过滤器来平滑采样,但这是由可见性数据增强的最基本版本。

现在,让我们学习如何修改calculate_lighting方法以添加漫反射间接光照。

calculate_lighting方法的修改

在我们的lighting.h着色器文件中,一旦完成直接光照计算,添加以下行:

    vec3 F = fresnel_schlick_roughness(max(dot(normal, V), 
      0.0), F0, roughness);
    vec3 kS = F;
    vec3 kD = 1.0 - kS;
    kD *= 1.0 - metallic;
    vec3 indirect_irradiance = textureLod(global_textures
      [nonuniformEXT(indirect_lighting_texture_index)], 
      screen_uv, 0).rgb;
    vec3 indirect_diffuse = indirect_irradiance * 
      base_colour.rgb;
    const float ao = 1.0f;
    final_color.rgb += (kD * indirect_diffuse) * ao;

在这里,base_colour是从 G 缓冲区来的漫反射,而final_color是计算了所有直接光照贡献的像素颜色。

基本算法已完成,但还有最后一个着色器要查看:探头偏移着色器。它计算每个探头的世界空间偏移量,以避免探头与几何体相交。

探头偏移着色器

此计算着色器巧妙地使用来自射线追踪传递的每条射线距离来根据后表面和前表面计数计算偏移量。

首先,我们必须检查无效的探头索引以避免写入错误的内存:

layout (local_size_x = 32, local_size_y = 1, local_size_z = 
        1) in;
void main() {
    ivec3 coords = ivec3(gl_GlobalInvocationID.xyz);
    // Invoke this shader for each probe
    int probe_index = coords.x;
    const int total_probes = probe_counts.x * 
      probe_counts.y * probe_counts.z;
    // Early out if index is not valid
    if (probe_index >= total_probes) {
        return;
    }

现在,我们必须根据已计算的射线追踪距离搜索前表面和后表面击中点:

首先,声明所有必要的变量:

    int closest_backface_index = -1;
    float closest_backface_distance = 100000000.f;
    int closest_frontface_index = -1;
    float closest_frontface_distance = 100000000.f;
    int farthest_frontface_index = -1;
    float farthest_frontface_distance = 0;
    int backfaces_count = 0;

对于这个探头的每条射线,读取距离并计算它是否是前表面或后表面。我们在击中着色器中存储后表面的负距离:

    // For each ray cache front/backfaces index and 
       distances.
    for (int ray_index = 0; ray_index < probe_rays; 
         ++ray_index) {
        ivec2 ray_tex_coord = ivec2(ray_index, 
          probe_index);
        float ray_distance = texelFetch(global_textures
          [nonuniformEXT(radiance_output_index)], 
          ray_tex_coord, 0).w;
        // Negative distance is stored for backface hits in 
           the Ray Tracing Hit shader.
        if ( ray_distance <= 0.0f ) {
            ++backfaces_count;
            // Distance is a positive value, thus negate 
               ray_distance as it is negative already if
            // we are inside this branch.
            if ( (-ray_distance) < 
                  closest_backface_distance ) {
                closest_backface_distance = ray_distance;
                closest_backface_index = ray_index;
            }
        }
        else {
            // Cache either closest or farther distance and 
               indices for this ray.
            if (ray_distance < closest_frontface_distance) 
            {
                closest_frontface_distance = ray_distance;
                closest_frontface_index = ray_index;
            } else if (ray_distance > 
                       farthest_frontface_distance) {
                farthest_frontface_distance = ray_distance;
                farthest_frontface_index = ray_index;
            }
        }
    }

我们知道这个探头的正面和背面索引及距离。鉴于我们逐步移动探头,读取前一帧的偏移量:

       vec4 current_offset = vec4(0);
    // Read previous offset after the first frame.
    if ( first_frame == 0 ) {
        const int probe_counts_xy = probe_counts.x * 
          probe_counts.y;
        ivec2 probe_offset_sampling_coordinates = 
          ivec2(probe_index % probe_counts_xy, probe_index 
          / probe_counts_xy);
        current_offset.rgb = texelFetch(global_textures
          [nonuniformEXT(probe_offset_texture_index)], 
          probe_offset_sampling_coordinates, 0).rgb;
    }

现在,我们必须检查探测器是否可以被认为是位于几何体内部,并计算一个偏离该方向的偏移量,但在这个探测器的间距限制内,我们可以称之为“单元格”:

    vec3 full_offset = vec3(10000.f);
    vec3 cell_offset_limit = max_probe_offset * 
      probe_spacing;
    // Check if a fourth of the rays was a backface, we can 
       assume the probe is inside a geometry.
    const bool inside_geometry = (float(backfaces_count) / 
      probe_rays) > 0.25f;
    if (inside_geometry && (closest_backface_index != -1)) 
    {
        // Calculate the backface direction.
        const vec3 closest_backface_direction = 
          closest_backface_distance * normalize( 
          mat3(random_rotation) * 
          spherical_fibonacci(closest_backface_index, 
          probe_rays) );        

在单元格内找到最大偏移量以移动探测器:

        const vec3 positive_offset = (current_offset.xyz + 
          cell_offset_limit) / closest_backface_direction;
        const vec3 negative_offset = (current_offset.xyz – 
          cell_offset_limit) / closest_backface_direction;
        const vec3 maximum_offset = vec3(max
          (positive_offset.x, negative_offset.x), 
          max(positive_offset.y, negative_offset.y), 
          max(positive_offset.z, negative_offset.z));
        // Get the smallest of the offsets to scale the 
           direction
        const float direction_scale_factor = min(min
          (maximum_offset.x, maximum_offset.y),
          maximum_offset.z) - 0.001f;
        // Move the offset in the opposite direction of the 
           backface one.
        full_offset = current_offset.xyz – 
          closest_backface_direction * 
          direction_scale_factor;
    }

如果我们没有击中背面,我们必须稍微移动探测器,使其处于静止位置:

    else if (closest_frontface_distance < 0.05f) {
        // In this case we have a very small hit distance.
        // Ensure that we never move through the farthest 
           frontface
        // Move minimum distance to ensure not moving on a 
           future iteration.
        const vec3 farthest_direction = min(0.2f, 
          farthest_frontface_distance) * normalize( 
          mat3(random_rotation) * 
          spherical_fibonacci(farthest_frontface_index, 
          probe_rays) );
        const vec3 closest_direction = normalize(mat3
          (random_rotation) * spherical_fibonacci
          (closest_frontface_index, probe_rays));
        // The farthest frontface may also be the closest 
           if the probe can only 
        // see one surface. If this is the case, don't move 
           the probe.
        if (dot(farthest_direction, closest_direction) < 
            0.5f) {
            full_offset = current_offset.xyz + 
              farthest_direction;
        }
    } 

只有在偏移量在间距或单元格限制内时才更新偏移量。然后,将值存储在适当的纹理中:

    if (all(lessThan(abs(full_offset), cell_offset_limit)))
    {
        current_offset.xyz = full_offset;
    }
    const int probe_counts_xy = probe_counts.x * 
      probe_counts.y;
    const int probe_texel_x = (probe_index % 
      probe_counts_xy);
    const int probe_texel_y = probe_index / 
      probe_counts_xy;
    imageStore(global_images_2d[ probe_offset_texture_index 
      ], ivec2(probe_texel_x, probe_texel_y), 
      current_offset);
}

这样,我们就计算了探测器的偏移量。

再次,这个着色器展示了如何巧妙地使用你已有的信息——在这种情况下,每条光线的探测器距离——将探测器移动到相交几何体之外。

我们展示了 DDGI 的完整功能版本,但还有一些改进可以做出,该技术可以在不同方向上扩展。一些改进的例子包括一个分类系统来禁用非贡献探测器,或者添加一个围绕相机中心具有不同网格间距的移动网格。与手动放置的体积结合,可以创建一个完整的漫反射全局照明系统。

虽然拥有具有光线追踪功能的 GPU 对于这项技术是必要的,但我们可以在静态场景部分烘焙辐照度和可见性,并在较旧的 GPU 上使用它们。另一个改进可以根据探测器的亮度变化更改滞后性,或者根据距离和重要性添加基于距离的交错探测器更新。

所有这些想法都展示了 DDGI 是多么强大和可配置,我们鼓励读者进行实验并创造其他改进。

摘要

在本章中,我们介绍了 DDGI 技术。我们首先讨论了全局照明,这是 DDGI 实现的照明现象。然后,我们概述了该算法,并更详细地解释了每个步骤。

最后,我们对实现中的所有着色器进行了编写和注释。DDGI 已经增强了渲染帧的照明,但它可以进一步改进和优化。

DDGI 的一个使其有用的方面是其可配置性:你可以更改辐照度和可见性纹理的分辨率,并更改光线的数量、探测器的数量和探测器的间距,以支持低端具有光线追踪功能的 GPU。

在下一章中,我们将添加另一个元素,这将帮助我们提高照明解决方案的准确性:反射!

进一步阅读

全局照明是一个非常大的主题,在所有渲染文献中都有广泛的覆盖,但我们想强调与 DDGI 实现更紧密相关的链接。

DDGI 本身是一个主要来自 2017 年 Nvidia 团队的想法,其核心思想在morgan3d.github.io/articles/2019-04-01-ddgi/index.xhtml中进行了描述。

DDGI 及其演变的原始文章如下。它们还包含了一些非常有帮助的补充代码,这些代码在实现该技术时极为有用:

以下是对具有球谐函数支持的 DDGI 的精彩概述,以及唯一一个用于双线性插值复制边界像素的图表。它还描述了其他有趣的主题:handmade.network/p/75/monter/blog/p/7288-engine_work__global_illumination_with_irradiance_probes

可以在 Nvidia 的 DDGI 演示文稿中找到:developer.download.nvidia.com/video/gputechconf/gtc/2019/presentation/s9900-irradiance-fields-rtx-diffuse-global-illumination-for-local-and-cloud-graphics.pdf

以下是对全局照明的直观介绍:www.scratchapixel.com/lessons/3d-basic-rendering/global-illumination-path-tracing

全局照明 汇编: people.cs.kuleuven.be/~philip.dutre/GI/

最后,这里是实时渲染的最佳网站:www.realtimerendering.com/

第十五章:使用光线追踪添加反射

在本章中,我们将使用光线追踪来实现反射。在引入光线追踪硬件之前,应用程序使用屏幕空间技术来实现反射。然而,这种技术有缺点,因为它只能使用屏幕上可见的信息。如果其中一条光线超出屏幕上可见的几何形状,我们通常会回退到环境贴图。由于这种限制,渲染的反射可能会根据相机位置而不一致。

通过引入光线追踪硬件,我们可以克服这一限制,因为我们现在可以访问屏幕上不可见的几何形状。缺点是我们可能需要进行一些昂贵的光照计算。如果反射的几何形状在屏幕外,这意味着我们没有 G 缓冲区的数据,需要从头开始计算颜色、光照和阴影数据。

为了降低这种技术的成本,开发者通常会以半分辨率追踪反射,或者在屏幕空间反射失败时才使用光线追踪。另一种方法是使用较低分辨率的几何形状在光线追踪路径中,以降低光线遍历的成本。在本章中,我们将实现仅使用光线追踪的解决方案,因为这可以提供最佳质量的结果。然后,将很容易在它之上实现之前提到的优化。

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

  • 屏幕空间反射的工作原理

  • 实现光线追踪反射

  • 实现降噪器以使光线追踪输出可用

技术要求

到本章结束时,你将很好地理解可用于反射的不同解决方案。你还将学习如何实现光线追踪反射以及如何借助降噪器来提高最终结果。

本章的代码可以在以下 URL 找到:github.com/PacktPublishing/Mastering-Graphics-Programming-with-Vulkan/tree/main/source/chapter15

屏幕空间反射的工作原理

反射是重要的渲染元素,可以提供更好的沉浸感。因此,多年来,开发者已经开发了一些技术来包括这种效果,甚至在光线追踪硬件可用之前。

最常见的方法之一是在 G 缓冲区数据可用后对场景进行光线追踪。一个表面是否会产生反射由材料的粗糙度决定。只有粗糙度低的材料才会产生反射。这也帮助减少了这种技术的成本,因为通常只有少数表面会满足这一要求。

射线追踪是一种类似于光线追踪的技术,它在第十章中介绍,即添加体积雾。作为快速提醒,射线追踪的工作原理与光线追踪相似。我们不是遍历场景以确定射线是否击中任何几何体,而是通过固定次数的迭代,以小的增量沿着射线的方向移动。

这既有优点也有缺点。优点是,这项技术具有固定的成本,与场景的复杂度无关,因为每条射线的最大迭代次数是预先确定的。缺点是,结果的质量取决于步长和迭代次数。

为了获得最佳质量,我们希望有大量的迭代和小的步长,但这会使技术变得过于昂贵。折衷方案是使用一个步长,以产生足够好的结果,然后将结果通过去噪滤波器传递,以尝试减少由低频采样引入的伪影。

如其名所示,这项技术类似于其他技术,如屏幕空间环境遮挡SSAO),在屏幕空间中工作。对于给定的片段,我们首先确定它是否产生反射。如果产生,我们根据表面法线和视方向确定反射射线的方向。

接下来,我们沿着反射射线的方向移动给定次数的迭代和步长。在每一步中,我们检查深度缓冲区以确定是否击中任何几何体。由于深度缓冲区具有有限的分辨率,通常我们定义一个 delta 值来决定是否将给定的迭代视为击中。

如果射线深度与深度缓冲区中存储的值的差异小于这个 delta 值,我们可以退出循环;否则,我们必须继续。这个 delta 值的大小可能因场景的复杂度而异,通常需要手动调整。

如果射线追踪循环击中了可见几何体,我们查找该片段的颜色值并将其用作反射颜色。否则,我们返回黑色或使用环境图确定反射颜色。

我们在这里跳过一些实现细节,因为它们与本章无关。我们在进一步阅读部分提供了更详细的信息。

如前所述,这项技术仅限于屏幕上可见的信息。主要缺点是,如果反射的几何体不再在屏幕上渲染,随着摄像机的移动,反射将消失。另一个缺点来自射线追踪,因为我们对可以采取的步数和大小有限制。

这可能会在反射中引入孔洞,这通常通过积极的过滤来解决。这可能导致反射模糊,并使得根据场景和视点难以获得清晰的反射。

在本节中,我们介绍了屏幕空间反射。我们解释了这种技术背后的主要思想及其一些不足。在下一节中,我们将实现光线追踪反射,这可以减少这种技术的一些限制。

实现光线追踪反射

在本节中,我们将利用硬件光线追踪功能来实现反射。在深入代码之前,这里是对算法的概述:

  1. 我们从 G 缓冲区数据开始。我们检查给定片段的粗糙度是否低于某个阈值。如果是,我们进入下一步。否则,我们不再处理这个片段。

  2. 为了使这种技术在实时中可行,我们只为每个片段发射一条反射光线。我们将展示两种选择反射光线方向的方法:一种模拟类似镜面的表面,另一种为给定片段采样 GGX 分布。

  3. 如果反射光线击中某些几何形状,我们需要计算其表面颜色。我们向通过重要性采样选择的光源发射另一条光线。如果选择的光源是可见的,我们使用我们的标准光照模型计算表面的颜色。

  4. 由于我们只为每个片段使用一个样本,最终输出将会是噪声的,尤其是因为我们随机选择反射方向。因此,光线追踪步骤的输出将通过去噪器进行处理。我们实现了一种称为时空方差引导滤波SVGF)的技术,它专门为这种用途开发。该算法将利用空间和时间数据来生成一个包含少量噪声的结果。

  5. 最后,我们在光照计算期间使用去噪数据来检索镜面颜色。

现在你已经对涉及的步骤有了很好的了解,让我们深入探讨!第一步是检查给定片段的粗糙度是否高于某个阈值:

if ( roughness <= 0.3 ) {

我们选择了0.3,因为它给出了我们想要的结果,尽管你可以自由地尝试其他值。如果这个片段对反射计算有贡献,我们初始化随机数生成器,并计算两个值以采样 GGX 分布:

rng_state = seed( gl_LaunchIDEXT.xy ) + current_frame;
float U1 = rand_pcg() * rnd_normalizer;
float U2 = rand_pcg() * rnd_normalizer;

两个随机函数可以如下实现:

uint seed(uvec2 p) {
    return 19u * p.x + 47u * p.y + 101u;
}
uint rand_pcg() {
    uint state = rng_state;
    rng_state = rng_state * 747796405u + 2891336453u;
    uint word = ((state >> ((state >> 28u) + 4u)) ^ state) 
                  277803737u;
    return (word >> 22u) ^ word;
}

这两个函数是从优秀的《GPU 渲染的哈希函数》论文中提取的,我们强烈推荐。它包含许多其他你可以实验的函数。我们选择这个种子函数,以便我们可以使用片段的位置。

接下来,我们需要选择我们的反射向量。如前所述,我们已经实现了两种技术。对于第一种技术,我们只需将视向量围绕表面法线反射以模拟类似镜面的表面。这可以计算如下:

vec3 reflected_ray = normalize( reflect( incoming, normal ) );

当使用这种方法时,我们得到以下输出:

图 15.1 – 类似镜面的反射

图 15.1 – 类似镜子的反射

另一种方法通过随机采样 GGX 分布来计算法线:

vec3 normal = sampleGGXVNDF( incoming, roughness, roughness, 
                             U1, U2 );
vec3 reflected_ray = normalize( reflect( incoming, normal ) );

sampleGGXVNDF函数来自《采样可见法线的 GGX 分布》论文。其实现方式在这篇论文中有详细描述;我们建议您阅读以获取更多细节。

简而言之,这种方法根据材料的 BRDF 和视图方向计算一个随机法线。这个过程是为了确保计算出的反射更加物理上准确。

接下来,我们必须在场景中追踪一条光线:

traceRayEXT( as, // topLevel
            gl_RayFlagsOpaqueEXT, // rayFlags
            0xff, // cullMask
            sbt_offset, // sbtRecordOffset
            sbt_stride, // sbtRecordStride
            miss_index, // missIndex
            world_pos, // origin
            0.05, // Tmin
            reflected_ray, // direction
            100.0, // Tmax
            0 // payload index
        );

如果光线有击中点,我们使用重要性采样来选择一个灯光用于最终颜色计算。重要性采样的主要思想是根据给定的概率分布确定哪个元素,在我们的情况下是哪个灯光,更有可能被选中。

我们采用了来自《Ray Tracing Gems》一书中“在 GPU 上对许多灯光进行重要性采样”章节中描述的重要性值。

我们首先遍历场景中的所有灯光:

for ( uint l = 0; l < active_lights; ++l ) {
    Light light = lights[ l ];

接下来,我们计算光线与被击中三角形的法线之间的角度:

    vec3 p_to_light = light.world_position - p_world.xyz;
    float point_light_angle = dot( normalize( p_to_light ), 
                              triangle_normal );
    float theta_i = acos( point_light_angle );

然后,我们计算灯光与片段在世界空间中的位置之间的距离:

    float distance_sq = dot( p_to_light, p_to_light );
    float r_sq = light.radius * light.radius;

然后,我们使用这两个值来确定是否应该考虑这个灯光:

    bool light_active = ( point_light_angle > 1e-4 ) && ( 
                          distance_sq <= r_sq );

下一步涉及计算一个方向参数。这告诉我们灯光是直接照在片段上还是以某个角度照射:

    float theta_u = asin( light.radius / sqrt( distance_sq 
    ) );
    float theta_prime = max( 0, theta_i - theta_u );
    float orientation = abs( cos( theta_prime ) );

最后,我们必须通过考虑灯光的强度来计算重要性值:

    float importance = ( light.intensity * orientation ) / 
                         distance_sq; 
    float final_value = light_active ? importance : 0.0;
    lights_importance[ l ] = final_value;

如果给定的灯光对于这个片段不被考虑为活动状态,其重要性值将为0。最后,我们必须累积这个灯光的重要性值:

    total_importance += final_value;
}

现在我们有了重要性值,我们需要对它们进行归一化。像任何其他概率分布函数一样,我们的值需要加起来等于1

for ( uint l = 0; l < active_lights; ++l ) {
    lights_importance[ l ] /= total_importance;
}

现在我们可以选择用于这个帧的灯光。首先,我们必须生成一个新的随机值:

float rnd_value = rand_pcg() * rnd_normalizer;

接下来,我们必须遍历灯光并累积每个灯光的重要性。一旦累积值大于我们的随机值,我们就找到了要使用的灯光:

for ( ; light_index < active_lights; ++light_index ) {
    accum_probability += lights_importance[ light_index ];
     if ( accum_probability > rnd_value ) {
        break;
    }
}

现在我们已经选择了灯光,我们必须向它发射一条光线以确定它是否可见。如果它是可见的,我们使用我们的光照模型计算反射表面的最终颜色。

我们计算阴影因子,如第十三章中所述,“使用光线追踪重新审视阴影”,颜色计算方式与第十四章中相同,“使用光线追踪添加动态漫反射全局照明”。

这是结果:

图 15.2 – 光线追踪步骤的噪声输出

图 15.2 – 光线追踪步骤的噪声输出

在本节中,我们展示了我们实现的基于光线追踪的反射。首先,我们描述了选择射线方向的两个方法。然后,我们演示了如何使用重要性采样来选择用于计算的灯光。最后,我们描述了如何使用选定的灯光来确定反射表面的最终颜色。

此步骤的结果将会是噪声的,不能直接用于我们的光照计算。在下一节中,我们将实现一个降噪器,这将帮助我们移除大部分噪声。

实现降噪器

为了使我们的反射输出可用于光照计算,我们需要将其通过一个降噪器。我们实现了一个名为 SVGF 的算法,该算法是为了重建路径追踪中的颜色数据而开发的。

SVGF 由三个主要步骤组成:

  1. 首先,我们计算亮度积分颜色和矩。这是算法的时间步骤。我们将前一帧的数据与当前帧的结果相结合。

  2. 接下来,我们计算方差的估计值。这是通过使用我们在第一步中计算的第一和第二矩值来完成的。

  3. 最后,我们执行五次小波滤波器的步骤。这是算法的空间步骤。在每次迭代中,我们应用一个 5x5 滤波器,尽可能多地减少剩余的噪声。

现在你已经了解了主要算法,我们可以继续处理代码细节。我们首先计算当前帧的矩:

float u_1 = luminance( reflections_color );
float u_2 = u_1 * u_1;
vec2 moments = vec2( u_1, u_2 );

接下来,我们使用运动矢量值——与我们在第十一章,“时间反走样”中计算出的相同值——来确定我们是否可以将当前帧的数据与前一帧的数据合并。

首先,我们计算前一帧在屏幕上的位置:

bool check_temporal_consistency( uvec2 frag_coord ) {
    vec2 frag_coord_center = vec2( frag_coord ) + 0.5; 
    vec2 motion_vector = texelFetch( global_textures[ 
                         motion_vectors_texture_index ], 
                         ivec2( frag_coord ), 0 ).rg; 
    vec2 prev_frag_coord = frag_coord_center + 
                           motion_vector;

接下来,我们检查旧的片段坐标是否有效:

    if ( any( lessThan( prev_frag_coord, vec2( 0 ) ) ) || 
          any( greaterThanEqual( prev_frag_coord, 
                                 resolution ) ) ) {
              return false;
    }

然后,我们检查网格 ID 是否与前一顿一致:

    uint mesh_id = texelFetch( global_utextures[ 
                               mesh_id_texture_index ], 
                               ivec2( frag_coord ), 0 ).r;
    uint prev_mesh_id = texelFetch( global_utextures[ 
                        history_mesh_id_texture_index ], 
                        ivec2( prev_frag_coord ), 0 ).r;

    if ( mesh_id != prev_mesh_id ) {
        return false;
    }

接下来,我们检查是否存在大的深度不连续性,这可能是由于前一帧的遮挡造成的。我们利用当前帧和前一帧深度之间的差异,以及当前帧深度在屏幕空间中的导数:

        float z = texelFetch( global_textures[ 
                              depth_texture_index ], 
                              ivec2( frag_coord ), 0 ).r;
    float prev_z = texelFetch( global_textures[ 
                               history_depth_texture ], 
                               ivec2( prev_frag_coord ), 0 
                               ).r;

    vec2 depth_normal_dd = texelFetch( global_textures[ 
                           depth_normal_dd_texture_index ], 
                           ivec2( frag_coord ), 0 ).rg;
    float depth_diff = abs( z - prev_z ) / ( 
                       depth_normal_dd.x + 1e-2 );

    if ( depth_diff > 10 ) {
        return false;
    }

最后的一致性检查是通过使用法线值来完成的:

    float normal_diff = distance( normal, prev_normal ) / ( 
                                  depth_normal_dd.y + 1e-2 
                                  );
    if ( normal_diff > 16.0 ) {
        return false;
    }

如果所有这些测试都通过,这意味着前一帧的值可以用于时间累积:

if ( is_consistent ) {
    vec3 history_reflections_color = texelFetch( 
    global_textures[ history_reflections_texture_index ], 
    ivec2( frag_coord ), 0 ).rgb;
    vec2 history_moments = texelFetch( global_textures[ 
                           history_moments_texture_index ], 
                           ivec2( frag_coord ), 0 ).rg;

    float alpha = 0.2;
    integrated_color_out = reflections_color * alpha + 
    ( 1 - alpha ) * history_reflections_color;
    integrated_moments_out = moments * alpha + ( 1 - alpha 
    ) * moments;

如果一致性检查失败,我们只将使用当前帧的数据:

} else {
    integrated_color_out = reflections_color;
    integrated_moments_out = moments;
}

这完成了累积步骤。这是我们获得的结果:

图 15.3 – 累积步骤后的颜色输出

图 15.3 – 累积步骤后的颜色输出

下一步是计算方差。这可以很容易地按照以下方式完成:

float variance = moments.y - pow( moments.x, 2 );

现在我们有了累积值,我们可以开始实现小波滤波器。如前所述,这是一个 5x5 交叉双边滤波器。我们从一个熟悉的双重循环开始,注意不要访问越界值:

for ( int y = -2; y <= 2; ++y) {
    for( int x = -2; x <= 2; ++x ) {
        ivec2 offset = ivec2( x, y );
        ivec2 q = frag_coord + offset;

        if ( any( lessThan( q, ivec2( 0 ) ) ) || any( 
             greaterThanEqual( q, ivec2( resolution ) ) ) ) 
             {
                 continue;
        }

接下来,我们计算滤波器核值和加权值,w

        float h_q = h[ x + 2 ] * h[ y + 2 ];
        float w_pq = compute_w( frag_coord, q );
        float sample_weight = h_q * w_pq;

我们将在稍后解释加权函数的实现。接下来,我们加载给定片段的集成颜色和方差:

        vec3 c_q = texelFetch( global_textures[ 
        integrated_color_texture_index ], q, 0 ).rgb;
        float prev_variance = texelFetch( global_textures[ 
        variance_texture_index ], q, 0 ).r;

最后,我们累积新的颜色和方差值:

        new_filtered_color += h_q * w_pq * c_q;
        color_weight += sample_weight;

        new_variance += pow( h_q, 2 ) * pow( w_pq, 2 ) * 
                        prev_variance;
        variance_weight += pow( sample_weight, 2 );
    }
}

在存储新计算出的值之前,我们需要将它们除以累积的权重:

    new_filtered_color /= color_weight;
    new_variance /= variance_weight;

我们重复这个过程五次。生成的颜色输出将被用于我们的镜面颜色光照计算。

正如承诺的那样,我们现在将查看权重计算。权重有三个要素:法线、深度和亮度。在代码中,我们尽量遵循论文中的命名,以便更容易与我们的公式实现相匹配。

我们从法线开始:

vec2 encoded_normal_p = texelFetch( global_textures[ 
                        normals_texture_index ], p, 0 ).rg;
vec3 n_p = octahedral_decode( encoded_normal_p );

vec2 encoded_normal_q = texelFetch( global_textures[ 
                        normals_texture_index ], q, 0 ).rg;
vec3 n_q = octahedral_decode( encoded_normal_q );

float w_n = pow( max( 0, dot( n_p, n_q ) ), sigma_n );

我们计算当前片段的法线与过滤器中片段之间的余弦值,以确定法线成分的权重。

我们接下来关注深度:

float z_dd = texelFetch( global_textures[ depth_normal_dd_
                         texture_index ], p, 0 ).r;
float z_p = texelFetch( global_textures[ depth_texture_index ], 
                        p, 0 ).r;
float z_q = texelFetch( global_textures[ depth_texture_index ], 
                        q, 0 ).r;

float w_z = exp( -( abs( z_p – z_q ) / ( sigma_z * abs( 
            z_dd ) + 1e-8 ) ) );

与累积步骤类似,我们利用两个片段之间的深度值之间的差异。屏幕空间导数也被包括在内。像以前一样,我们想要惩罚大的深度不连续性。

最后一个权重元素是亮度。我们首先计算我们正在处理的片段的亮度:

vec3 c_p = texelFetch( global_textures[ integrated_color_
                       texture_index ], p, 0 ).rgb;
vec3 c_q = texelFetch( global_textures[ integrated_color_
                       texture_index ], q, 0 ).rgb;

float l_p = luminance( c_p );
float l_q = luminance( c_q );

接下来,我们将方差值通过高斯滤波器传递以减少不稳定性:

float g = 0.0;
const int radius = 1;
for ( int yy = -radius; yy <= radius; yy++ ) {
    for ( int xx = -radius; xx <= radius; xx++ ) {
        ivec2 s = p + ivec2( xx, yy );
        float k = kernel[ abs( xx ) ][ abs( yy ) ];
        float v = texelFetch( global_textures[ 
                  variance_texture_index ], s, 0 ).r;
        g += v * k;
    }
}

最后,我们计算亮度权重并将其与其他两个权重值结合:

float w_l = exp( -( abs( l_p - l_q ) / ( sigma_l * sqrt
            ( g ) + 1e-8 ) ) );

return w_z * w_n * w_l;

这就完成了 SVGF 算法的实现。经过五次迭代后,我们得到以下输出:

图 15.4 – 去噪步骤结束时的输出

图 15.4 – 去噪步骤结束时的输出

在本节中,我们描述了如何实现一个常见的去噪算法。该算法包括三个步骤:一个用于颜色和亮度矩的累积阶段,一个用于计算亮度方差,以及一个用于小波滤波的步骤,该步骤重复五次。

概述

在本章中,我们描述了如何实现光线追踪反射。我们从一个概述开始,即屏幕空间反射,这是一种在光线追踪硬件可用之前使用了多年的技术。我们解释了它是如何工作的以及它的局限性。

接下来,我们描述了我们的光线追踪实现以确定反射值。我们提供了两种确定反射射线方向的方法,并解释了如果返回一个碰撞,如何计算反射颜色。

由于我们只为每个片段使用一个样本,因此这一步骤的结果是噪声的。为了尽可能减少这种噪声,我们实现了一个基于 SVGF 的去噪器。这项技术包括三个步骤。首先,有一个时间累积步骤来计算颜色和亮度矩。然后,我们计算亮度方差。最后,我们通过五个小波滤波器的迭代来处理颜色输出。

本章也标志着我们书籍的结束!我们希望您阅读这本书的乐趣与我们写作时的乐趣一样。当谈到现代图形技术时,一本书中能涵盖的内容是有限的。我们包括了我们认为在 Vulkan 中实现时最有趣的一些特性和技术。我们的目标是为您提供一套可以构建和扩展的工具。我们祝愿您在掌握图形编程的道路上有一个美好的旅程!

我们非常欢迎您的反馈和纠正,所以请随时与我们联系。

进一步阅读

我们只提供了屏幕空间反射的简要介绍。以下文章更详细地介绍了它们的实现、局限性以及如何改进最终结果:

我们只使用了论文《GPU 渲染的哈希函数》中介绍的许多哈希技术之一:jcgt.org/published/0009/03/02/

此链接包含了更多关于我们用来通过采样 BRDF 确定反射向量的采样技术细节:采样可见法线的 GGX 分布jcgt.org/published/0007/04/01/

关于我们介绍的 SVGF 算法的更多细节,我们建议阅读原始论文和相关材料:research.nvidia.com/publication/2017-07_spatiotemporal-variance-guided-filtering-real-time-reconstruction-path-traced

我们使用了重要性采样来确定每一帧使用哪种光线。在过去几年中变得流行的一种技术是水库时空重要性重采样ReSTIR)。我们强烈建议您阅读原始论文并查找受其启发的其他技术:research.nvidia.com/publication/2020-07_spatiotemporal-reservoir-resampling-real-time-Ray-Tracing-dynamic-direct

在本章中,我们为了教学目的从头开始实现了 SVGF 算法。我们的实现是一个很好的起点,但我们也建议查看 AMD 和 Nvidia 的生产级去噪器以比较结果:

posted @ 2025-10-26 09:04  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报