Vulkan-3D-图形渲染秘籍第二版-全-

Vulkan 3D 图形渲染秘籍第二版(全)

原文:zh.annas-archive.org/md5/87b0066438928a56f87cb4a34fbdcc51

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:Vulkan 3D 图形渲染食谱,第二版:使用 Vulkan 1.3 实现专家级技术以实现高性能图形

欢迎来到 Packt 早期访问。在本书上市之前,我们为您提供独家预览。撰写一本书可能需要数月时间,但我们的作者今天有前沿信息要与您分享。早期访问通过提供章节草案,让您了解最新的发展。目前章节可能有些粗糙,但我们的作者会随着时间的推移进行更新。

您可以随意翻阅这本书,或者从头到尾跟随阅读;早期访问版设计得非常灵活。我们希望您能享受了解更多关于 Packt 书籍编写过程。

  1. 第一章:建立构建环境

  2. 第二章:Vulkan 入门

  3. 第三章:处理 Vulkan 对象

  4. 第四章:添加用户交互和生产工具

  5. 第五章:处理几何数据

  6. 第六章:使用 glTF 2.0 着色模型进行基于物理的渲染

  7. 第七章:高级 PBR 扩展

第二章:1 建立构建环境

加入我们的 Discord 书籍社区

packt.link/unitydev

在本章中,您将学习如何在 Windows 和 Linux 操作系统上设置 3D 图形开发环境。您将了解运行本书源代码包中的演示所需的软件工具:github.com/PacktPublishing/3D-Graphics-Rendering-Cookbook-Second-Edition。我们将涵盖以下主题:

  • 在 Microsoft Windows 上设置我们的开发环境

  • 在 Linux 上设置我们的开发环境

  • 为 Microsoft Windows 和 Linux 安装 Vulkan SDK

  • 管理依赖项

  • 获取演示数据

  • 为 CMake 项目创建实用工具

  • 使用 GLFW 库

  • 使用 Taskflow 进行多线程

在 Microsoft Windows 上设置我们的开发环境

在本食谱中,我们将从在 Windows 上设置我们的开发环境开始。我们将逐个详细安装每个必需的工具。

准备工作

为了在 Microsoft Windows 环境中开始使用本书的示例,您需要在系统中安装一些基本工具。

其中最重要的是 Microsoft Visual Studio 2022。其他工具包括 Git 版本控制系统、CMake 构建工具和 Python 编程语言。在整个本书中,我们仅在命令行上使用这些工具,因此不需要任何 GUI 扩展。

如何操作...

让我们逐个安装所需的每个工具。

Microsoft Visual Studio 2022

按照以下步骤安装 Microsoft Visual Studio 2022:

  1. 打开 visualstudio.microsoft.com 并下载 Visual Studio 2022 社区版安装程序。

  2. 启动安装程序并按照屏幕上的说明操作。为了本书的目的,您需要一个适用于 64 位 Intel 平台的本地 C++ 编译器。Visual Studio 开发环境的其他组件不需要运行本书的捆绑示例代码。

Git

按照以下步骤安装 Git:

  1. git-scm.com/downloads 下载最新的 Git 安装程序,运行它,并按照屏幕上的说明操作。

  2. 我们假设 Git 已添加到系统 PATH 变量中。在安装过程中启用以下图像中显示的选项:

    Figure 1.1 – Git from the command line and also from third-party software

    图 1.1 – 从命令行和第三方软件中运行 Git

  3. 选择 使用 Windows 的默认控制台窗口,如图下一张截图所示。此选项将允许您从计算机上的任何目录构建本书中的脚本。

Figure 1.2 – Use Windows’ default console window

图 1.2 – 使用 Windows 的默认控制台窗口

Git 是一种复杂的软件,本身就是一个巨大的主题。我们推荐 Jakub Narębski 编写的《精通 Git》一书,由 Packt Publishing 出版,www.packtpub.com/application-development/mastering-git,以及 François Dupire 编写的《Git 基础:Git 开发者指南》和可下载的电子书《ProGit》,第二版,由 Scott Chacon 和 Ben Straub 编写,git-scm.com/book/en/v2

CMake

要安装 CMake,请按照以下步骤操作:

  1. cmake.org/download/ 下载最新的 64 位 CMake 安装程序。

  2. 运行它并遵循屏幕上的说明。如果您已经安装了 CMake 的早期版本,建议首先卸载它。

  3. 选择将 CMake 添加到系统 PATH 以供所有用户使用选项,如图所示:

图 1.3:将 CMake 添加到系统 PATH 以供所有用户使用

图 1.3:将 CMake 添加到系统 PATH 以供所有用户使用

Python

要安装 Python,请按照以下步骤操作:

  1. www.python.org/downloads/ 下载适用于 64 位系统的最新 Python 3 安装程序。

  2. 运行它并遵循屏幕上的说明。

  3. 在安装过程中,您还需要安装 pip 功能。选择自定义安装并确保pip复选框被勾选,如图所示:

    图 1.4 – 自定义安装

    图 1.4 – 自定义安装

  4. 安装完成后,请确保将包含 python.exe 的文件夹添加到 PATH 环境变量中。

更多...

除了 Git,还有其他流行的版本控制系统,如 SVN 和 Mercurial。在开发大型软件系统时,您不可避免地需要从非 Git 仓库下载一些库。我们建议熟悉 Mercurial。

在命令行环境中工作时,拥有一些来自 Unix 环境的工具很有用,如 wgetgrepfind 等。GnuWin32 项目提供了这些工具的预编译二进制文件,可以从 gnuwin32.sourceforge.net 下载。

此外,在 Windows 环境中,传统的文件管理器使文件操作变得容易得多。我们强烈建议尝试开源的 Far Manager。您可以从 farmanager.com 下载它。它看起来像这样:

图 1.5 – Far Manager 的外观和感觉

图 1.5 – Far Manager 的外观和感觉

在 Linux 上设置我们的开发环境

Linux 正在变得越来越吸引人,尤其是在 3D 图形开发领域,包括游戏技术。让我们来了解一下在 Linux 上开始使用本书所需的一系列活动工具。

准备工作

我们假设您已安装了基于 Debian 的 GNU/Linux 操作系统的台式电脑。我们还假设您熟悉 apt 软件包管理器。

要开始在 Linux 上开发现代图形程序,您需要安装支持 Vulkan 1.3 的最新视频卡驱动程序。要构建本书中的示例,需要一个支持 C++20 的 C++ 编译器。我们使用 Clang 和 GNU 编译器集合测试了我们的代码。

如何操作...

在基于 Debian 的系统上,安装过程很简单;然而,在安装任何必需的软件包之前,我们建议运行以下命令以确保您的系统是最新的:

sudo apt-get update

让我们逐一检查必需的软件列表并安装任何缺失的软件。

  1. GCC 编译器

假设您已正确配置了 apt 软件包管理器,请运行以下命令来安装 GCC 编译器和相关工具。我们测试了 GCC 12:

sudo apt-get install build-essential
  1. CMake

CMake 构建工具也存在于标准仓库中。要安装 CMake,请输入以下命令:

sudo apt-get install cmake

本书中的代码示例需要 CMake 3.19 或更高版本。

  1. Git

要安装 Git 版本控制系统,请运行以下命令:

sudo apt-get install git
  1. Python 3

要安装 Python 3 包,请运行以下命令:

sudo apt-get install python3.7

Python 的确切版本可能因 Linux 发行版而异。本书中的脚本任何版本的 Python 3 都足够使用。

现在我们已经完成了基本软件包的安装,可以安装与图形相关的软件了。让我们继续到下一个菜谱,学习如何设置 Vulkan SDK。

安装 Windows 和 Linux 的 Vulkan SDK

在这个菜谱中,我们将学习如何开始使用 Vulkan SDK。我们将描述安装 Windows 和 Linux 的 LunarG Vulkan SDK 的要求和步骤。

在原则上,可以在没有 Vulkan SDK 的情况下编写 Vulkan 应用程序,只需使用 Khronos 提供的 C/C++ 头文件。您可以通过克隆 Git 仓库来获取这些头文件:github.com/KhronosGroup/Vulkan-Headers。然而,建议安装完整的 Vulkan SDK,以便能够使用 Vulkan 验证层和独立的 GLSL 编译器。

准备工作

确保您的操作系统有最新的视频卡驱动程序。在 Windows 上,您可以从 GPU 供应商的网站上下载视频驱动程序。对于 Ubuntu,请参阅文档:ubuntu.com/server/docs/nvidia-drivers-installation

如何操作...

要在 Linux 上安装 Vulkan 1.3,请按照以下步骤操作:

  1. 在浏览器中打开 www.lunarg.com/vulkan-sdk/ 页面并下载适用于 Windows 或 Linux 的最新 Vulkan SDK。

  2. 下载完成后,运行 Windows 安装程序文件并按照屏幕上的说明操作。如果您已安装 Ubuntu 22.04,请使用 LunarG 网站上提供的以下命令:

wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc
sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-1.3.250-jammy.list https://packages.lunarg.com/vulkan/1.3.250/lunarg-vulkan-1.3.250-jammy.list
sudo apt update
sudo apt install vulkan-sdk
  1. 对于其他 Linux 发行版,您可能需要从vulkan.lunarg.com/sdk/home#linux下载.tar.gz SDK 存档并手动解包。您需要设置环境变量以定位 Vulkan SDK 组件。使用source命令运行一个配置脚本,它会为您完成这项工作:
source ~/vulkan/1.3.250.1/setup-env.sh

还有更多...

在开发跨平台应用程序时,使用每个平台上的类似工具是很好的。由于 Linux 支持 GCC 和 Clang 编译器,因此在 Windows 上使用 GCC 或 Clang 可以确保您避免最常见的可移植性问题。C 和 C++编译器的完整包可以从www.equation.com/servlet/equation.cmd?fa=fortran下载。

在 Windows 上使用 GCC 的另一种方法是安装来自www.msys2.org的 MSYS2 环境。它具有在 Arch Linux 中使用的包管理系统,Pacman

管理依赖项

本书中的示例使用了多个开源库。为了管理这些依赖项,我们使用了一个名为Bootstrap的免费和开源工具。该工具类似于 Google 的 repo 工具,并且可以在 Windows、Linux 以及 macOS 上使用。

在这个食谱中,我们将学习如何使用 Bootstrap 下载库,以 Vulkan Headers 仓库为例。

准备工作

确保您已按照前面的食谱安装了 Git 和 Python。之后,从 GitHub 克隆 Bootstrap 仓库:

git clone https://github.com/corporateshark/bootstrapping

如何操作...

让我们查看源代码包并运行bootstrap.py脚本:

bootstrap.py

脚本将开始下载编译和运行本书源代码包所需的全部第三方库。在 Windows 上,输出尾部的样子应该如下所示。

Cloning into ‘M:\Projects.CPP\Book_Rendering\Sources\deps\src\assimp’...
remote: Enumerating objects: 25, done.
remote: Counting objects: 100% (25/25), done.
remote: Compressing objects: 100% (24/24), done.
remote: Total 51414 (delta 2), reused 10 (delta 1), pack-reused 51389
Receiving objects: 100% (51414/51414), 148.46 MiB | 3.95 MiB/s, done.
Resolving deltas: 100% (36665/36665), done.
Checking out files: 100% (2163/2163), done.

下载过程完成后,我们就可以开始构建项目了。

它是如何工作的...

Bootstrap 以 JSON 文件作为输入,默认情况下从当前目录打开bootstrap.json。它包含我们想要下载的库的元数据;例如,它们的名称、从哪里获取它们、要下载的特定版本,等等。除此之外,每个使用的库还可以有一些关于如何构建它的附加说明。这些可以是应用于原始库的补丁、解包说明、用于检查存档完整性的 SHA 散列,以及许多其他内容。

每个库的源代码可以表示为版本控制系统仓库的 URL 或包含库源文件的存档文件。

与一个库对应的典型 JSON 文件条目看起来像以下片段:

[{
 “name”: “vulkan”,
 “source”: {
  “type”: “git”,
  “url”: “https://github.com/KhronosGroup/Vulkan-Headers.git”,
   “revision”: “v1.3.252”
 }
}]

字段 type 可以有以下这些值:archivegithgsvn。第一个值对应于存档文件,例如 .zip.tar.gz.tar.bz2,而最后三种类型描述了不同的版本控制系统存储库。url 字段包含要下载的存档文件的 URL 或存储库的 URL。revision 字段可以指定要检查出的特定修订版、标签或分支。

完整的 JSON 文件是此类条目的逗号分隔列表。对于这个配方,我们只下载一个库。我们将在下一章中添加更多库。附带的源代码包包含一个包含本书中使用的所有库的 JSON 文件。

更多...

该工具有详细的文档,详细描述了其他命令行选项和 JSON 字段。可以从 github.com/corporateshark/bootstrapping 下载。

Bootstrap 工具不会区分源代码和二进制资源。所有用于您应用程序的纹理、3D 模型和其他资源也可以自动下载、更新和组织。

获取演示数据

本书尽可能地使用免费的 3D 图形数据集。大型 3D 数据集的完整列表由 Morgan McGuire 维护 – 计算机图形档案,2017 年 7 月 (casual-effects.com/data)。我们将从他的存档中下载一些大型 3D 模型,在本书中用于演示目的。让我们下载并修补其中一个。

如何操作...

打包的源代码包含一个 Python 脚本 deploy_deps.py,它将自动下载所有必需的 3D 模型。要手动下载整个 Bistro 数据集,请按照以下简单步骤操作:

  1. 在浏览器中打开 casual-effects.com/data/ 页面,并找到 Amazon Lumberyard Bistro 数据集。

  2. 点击 下载 链接,并允许浏览器下载所有数据文件。下面是 Morgan McGuire 网站上的下载链接截图。

图 1.6 – Amazon Lumberyard Bistro,如图所示在 casualeffects.com 上以 2.4 GB 的下载量

图 1.6 – Amazon Lumberyard Bistro,如图所示在 casualeffects.com 上以 2.4 GB 的下载量

为 CMake 项目创建实用工具

在这个配方中,我们将看到如何使用 CMake 配置本书中的所有代码示例,并在过程中学习一些小技巧。

对于刚开始使用 CMake 的人来说,我们建议阅读 Packt Publishing 出版的 CMake Cookbook(作者:Radovan Bast 和 Roberto Di Remigio)以及 Kitware 出版的 Mastering CMake(作者:Ken Martin 和 Bill Hoffman)。

准备工作

首先,让我们创建一个具有平凡 main() 函数的最小化 C++ 应用程序,并使用 CMake 构建:

int main() {
  printf(“Hello World!\n”);
  return 0;
}

如何操作...

让我们介绍两个用于 CMake 的辅助宏。您可以在我们的源代码包的 CMake/CommonMacros.txt 文件中找到它们,源代码包位于 github.com/PacktPublishing/3D-Graphics-Rendering-Cookbook-Second-Edition

  1. SETUP_GROUPS 宏遍历一个由空格分隔的 C 和 C++ 文件列表,无论是头文件还是源文件,并将它们各自分配到不同的组中。组名是根据每个单独文件的路径构建的。这样,我们在 Visual Studio 解决方案资源管理器窗口中的目录内得到了一个类似文件系统的良好结构,如图中右侧所示。

    图 1.7 – 没有分组(左)和有分组(右)

    图 1.7 – 没有分组(左)和有分组(右)

  2. 宏首先遍历通过 src_files 参数传入的文件列表:

macro(SETUP_GROUPS src_files)
  foreach(FILE ${src_files})
    get_filename_component(PARENT_DIR “${FILE}” PATH)
  1. 我们将父目录名作为默认的组名。对于任何操作系统,将所有反斜杠字符替换为正斜杠:
 set(GROUP “${PARENT_DIR}”)
    string(REPLACE “/” “\\” GROUP “${GROUP}”)
  1. 然后,我们可以告诉 CMake 将当前文件分配到具有此名称的源组。
 source_group(“${GROUP}” FILES “${FILE}”)
  endforeach()
endmacro()
  1. 第二个宏 SETUP_APP 被用作创建具有所有我们希望拥有的标准属性的 CMake 项目的快捷方式。当需要处理许多非常相似的子项目时,这非常方便,例如,就像在这本书中一样。
macro(SETUP_APP projname chapter)
  set(FOLDER_NAME ${chapter})
  set(PROJECT_NAME ${projname})
  project(${PROJECT_NAME} CXX)
  1. 在设置项目名称后,这个宏使用 GLOB_RECURSE 函数将所有源文件和头文件收集到 SRC_FILESHEADER_FILES 变量中。
 file(GLOB_RECURSE SRC_FILES LIST_DIRECTORIES false
       RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} src/*.c??)
  file(GLOB_RECURSE HEADER_FILES LIST_DIRECTORIES false
       RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} src/*.h)
  1. 在我们所有的代码示例中,我们将包含源文件的 src 目录也用作 include 目录。
 include_directories(src)
  1. 所有枚举的源文件和头文件都被添加到当前项目中的一个可执行文件中。
 add_executable(${PROJ_NAME} ${SRC_FILES} ${HEADER_FILES})
  1. 我们使用 步骤 1 中的 SETUP_GROUP 宏将每个源文件和头文件放置到项目内部适当的位置。
 SETUP_GROUPS(“${SRC_FILES}”)
  SETUP_GROUPS(“${HEADER_FILES}”)
  1. 接下来的三个属性为每个支持的构建配置设置了不同的可执行文件名。这些行是可选的,但在使用 Visual Studio IDE 与 CMake 一起使用时非常有用。原因是 Visual Studio 可以直接从 IDE 动态更改构建配置(或称为 CMake 中的“构建类型”),每个构建配置都可以有自己的输出文件名。我们给这些文件名添加后缀,以便它们可以在单个输出文件夹中共存。
 set_target_properties(${PROJ_NAME}
    PROPERTIES OUTPUT_NAME_DEBUG ${PROJ_NAME}_Debug)
  set_target_properties(${PROJ_NAME}
    PROPERTIES OUTPUT_NAME_RELEASE ${PROJ_NAME}_Release)
  set_target_properties(${PROJ_NAME}
    PROPERTIES OUTPUT_NAME_RELWITHDEBINFO ${PROJ_NAME}_ReleaseDebInfo)
  1. 由于我们在这本书中使用了 C++20,因此我们要求 CMake 启用它。
 set_property(
    TARGET ${PROJ_NAME} PROPERTY CXX_STANDARD 20)
  set_property(
    TARGET ${PROJ_NAME} PROPERTY CXX_STANDARD_REQUIRED ON)
  1. 为了便于使用 Visual Studio 进行调试,我们通过将应用程序类型更改为 Console 来启用控制台输出。我们还设置本地调试器的工作目录为 CMAKE_SOURCE_DIR,这将使查找资源变得更加直接和一致。还有一些针对 Apple 特定的属性,允许在 Mac 机器上构建源代码。
 if(MSVC)
    add_definitions(-D_CONSOLE)
    set_property(TARGET ${PROJ_NAME} PROPERTY
      VS_DEBUGGER_WORKING_DIRECTORY “${CMAKE_SOURCE_DIR}”)
  endif()
  if(APPLE)
    set_target_properties(${PROJECT_NAME} PROPERTIES
      XCODE_GENERATE_SCHEME TRUE
      XCODE_SCHEME_WORKING_DIRECTORY “${CMAKE_SOURCE_DIR}”)
  endif()
endmacro()
  1. 最后,我们第一个项目的顶级 CMakeLists.txt 文件将看起来像这样:
cmake_minimum_required(VERSION 3.16)
project(Chapter01)
include(../../CMake/CommonMacros.txt)
SETUP_APP(Ch01_Sample01_CMake “Chapter 01”)

您可能会注意到上面的行project(Chapter01)SETUP_APP宏内部的project()调用覆盖。这是由于以下 CMake 警告,如果不从一开始就声明一个新的项目,将会发出此警告。

CMake Warning (dev) in CMakeLists.txt:
No project() command is present. The top-level CMakeLists.txt file must contain a literal, direct call to the project() command. Add a line of project(ProjectName) near the top of the file, but after cmake_minimum_required().
  1. 要构建和测试可执行文件,创建build子文件夹,将工作目录更改为build,然后按以下方式运行 CMake:

    1. 对于 Windows 和 Visual Studio 2022,运行以下命令以配置我们的项目为 64 位目标平台架构。
cmake .. -G “Visual Studio 17 2022” -A x64
  1. 对于 Linux,我们可以使用以下方式的Unix Makefiles CMake 生成器。
cmake .. -G “Unix Makefiles”
  1. 要构建release构建类型的可执行文件,您可以在任何平台上使用以下命令。要构建调试版本,请使用--config Debug或完全跳过该参数。
cmake --build . --config Release

所有源代码包中的演示应用程序都应该在data/子文件夹所在的文件夹中运行。

还有更多...

或者,您可以使用跨平台的构建系统 Ninja 以及 CMake。只需更改 CMake 项目生成器名称即可实现。

cmake .. -G “Ninja”

从命令行调用 Ninja 来编译项目。

ninja
[2/2] Linking CXX executable Ch01_Sample01_CMake.exe

注意现在构建速度有多快,与经典的cmake --build命令相比。有关更多详细信息,请参阅ninja-build.org

现在,让我们看看如何与一些基本的开源库一起工作。

使用 GLFW 库

GLFW 库隐藏了创建窗口、图形上下文和表面以及从操作系统获取输入事件的全部复杂性。在本食谱中,我们使用 GLFW 和 Vulkan 构建了一个最小化的应用程序,以便在屏幕上显示一些基本的 3D 图形。

准备工作

我们使用 GLFW 3.4 构建我们的示例。以下是 Bootstrap 脚本的 JSON 片段,以便您可以下载正确的库版本:

{
     “name”: “glfw”,
     “source”: {
           “type”: “git”,
           “url”: “https://github.com/glfw/glfw.git”,
           “revision”: “3.4”
     }
}

本食谱的完整源代码可以在源代码包中找到,名称为Chapter01/02_GLFW

如何做到这一点...

让我们编写一个最小化的应用程序,该应用程序创建一个窗口并等待用户的exit命令——按 Esc 键。此功能将用于我们所有的后续演示,因此我们将其封装到在shared/HelpersGLFW.h中声明的辅助函数initWindow()中。让我们看看如何使用它来创建一个空白的 GLFW 窗口:

  1. 包含所有必要的头文件并确定初始窗口尺寸:
#include <shared/HelpersGLFW.h>
int main(void) {
  uint32_t width = 1280;
  uint32_t height = 800;
  1. 调用initWindow()函数以创建窗口。widthheight参数是通过引用传递的,调用之后将包含创建的窗口的实际工作区域。如果我们传递初始值0,则窗口将创建为占据整个桌面工作区域,没有重叠的任务栏。
GLFWwindow* window =
  initWindow(“GLFW example”, width, height);
  1. 对于此应用程序,主循环和清理是微不足道的:
 while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
  }
  glfwDestroyWindow(window);
  glfwTerminate();
  return 0;
}

现在,我们将查看initWindow()的内部结构,以了解一些有趣的细节。

它是如何工作的...

让我们使用这个库来创建一个打开空窗口的应用程序:

  1. 首先,我们通过 lambda 设置 GLFW 错误回调以捕获潜在的错误,然后初始化 GLFW:
GLFWwindow* initWindow(const char* windowTitle,
  uint32_t& outWidth, uint32_t& outHeight) {
  glfwSetErrorCallback([](int error,
                          const char* description) {
    printf(“GLFW Error (%i): %s\n”, error, description);
  });
  if (!glfwInit())return nullptr;
  1. 让我们决定是否要创建一个全屏桌面窗口。为非全屏窗口设置可调整大小的标志并检索所需的窗口尺寸。我们将手动初始化 Vulkan,因此不需要通过 GLFW 进行图形 API 初始化。标志wantsWholeArea确定我们是否想要一个真正的全屏窗口或一个不与系统任务栏重叠的窗口。
 const bool wantsWholeArea = !outWidth || !outHeight;
  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  glfwWindowHint(GLFW_RESIZABLE,
    wantsWholeArea ? GLFW_FALSE : GLFW_TRUE);
  GLFWmonitor* monitor    = glfwGetPrimaryMonitor();
  const GLFWvidmode* mode = glfwGetVideoMode(monitor);
  int x = 0;
  int y = 0;
  int w = mode->width;
  int h = mode->height;
  if (wantsWholeArea) {
    glfwGetMonitorWorkarea(monitor, &x, &y, &w, &h);
  } else {
    w = outWidth;
    h = outHeight;
  }
  1. 创建一个窗口并检索实际的窗口尺寸:
 GLFWwindow* window = glfwCreateWindow(
    w, h, windowTitle, nullptr, nullptr);
  if (!window) {
    glfwTerminate();
    return nullptr;
  }
  if (wantsWholeArea) glfwSetWindowPos(window, x, y);
  glfwGetWindowSize(window, &w, &h);
  outWidth  = (uint32_t)w;
  outHeight = (uint32_t)h;
  1. 设置默认的键盘回调以处理 Esc 键。一个简单的 lambda 可以为我们完成这项工作。
 glfwSetKeyCallback(window, [](GLFWwindow* window,
    int key, int, int action, int) {
    if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS) {
      glfwSetWindowShouldClose(window, GLFW_TRUE);
    }
  });
  return window;
}

如果你运行这个小程序,它将创建一个空窗口,如下面的截图所示:

图 1.8 – 我们的第一个应用

图 1.8 – 我们的第一个应用

更多内容...

关于如何使用 GLFW 的更多详细信息可以在www.glfw.org/documentation.xhtml找到。

使用 Taskflow 进行多线程

现代图形应用程序要求我们利用多个 CPU 的强大功能以实现高性能。Taskflow是一个快速、仅包含头文件的 C++库,可以帮助您快速编写具有复杂任务依赖关系的并行程序。这个库非常有用,因为它允许您快速进入开发使用高级渲染概念(如帧图和多线程命令缓冲区生成)的多线程图形应用程序。

准备工作

在这里,我们使用 Taskflow 版本 3.7.0。您可以使用以下 Bootstrap 片段下载它:

{
     “name”: “taskflow”,
     “source”: {
           “type”: “git”,
           “url”:
             “https://github.com/taskflow/taskflow.git”,
           “revision”: “v3.7.0”
     }
}

为了调试 Taskflow 生成的依赖图,建议您从www.graphviz.org安装GraphViz工具。

本菜谱的完整源代码可以在Chapter01/03_Taskflow中找到。

如何实现...

让我们通过for_each_index()算法创建并运行一组并发依赖任务。每个任务将以并发方式从数组中打印单个值。处理顺序可能在不同程序的运行之间有所不同:

  1. 包含taskflow.hpp头文件。tf::Taskflow类是创建任务依赖图的主要地方。声明一个实例和数据向量以进行处理。
#include <taskflow/taskflow.hpp>
int main() {
  tf::Taskflow taskflow;
  std::vector<int> items{ 1, 2, 3, 4, 5, 6, 7, 8 };
  1. for_each_index()成员函数返回一个实现并行 for 循环算法的任务。我们指定范围0..items.size()和步长1。返回的task可用于同步目的:
 auto task = taskflow.for_each_index(
    0u, static_cast<uint32_t>(items.size()), 1u, & {
      printf(“%i”, items[i]); }).name(“for_each_index”);
  1. 在并行任务前后添加一些工作,以便我们可以在输出中查看开始结束消息。让我们相应地称新的ST任务:
 taskflow.emplace([]() {
    printf(“\nS - Start\n”); }).name(“S”).precede(task);
  taskflow.emplace([]() {
    printf(“\nT - End\n”); }).name(“T”).succeed(task);
  1. 将生成的任务依赖图保存为.dot格式,以便我们稍后可以使用 GraphViz 的dot工具进行处理:
 std::ofstream os(“.cache/taskflow.dot”);
  taskflow.dump(os);
  1. 现在我们可以创建一个tf::executor对象并运行构建的 Taskflow 图:
 tf::Executor executor;
  executor.run(taskflow).wait();
  return 0;
}

这里要提到的一个重要部分是,依赖图只能构建一次。然后,它可以在每一帧中重复使用,以有效地运行并发任务。

前一个程序输出的内容应类似于以下列表:

S – Start
18345672
T - End

在这里,我们可以看到我们的 ST 任务。它们之间有多个线程,具有不同的 ID,并行处理 items[] 向量的不同元素。由于并发性,你的输出可能会有所不同。

还有更多...

应用程序将依赖图保存在 taskflow.dot 文件中。它可以通过 GraphViz,graphviz.org,使用以下命令转换为可视表示:

dot -Tpng taskflow.dot > output.png

生成的 .png 图片应类似于以下截图:

图 1.9 – for_each_index() 的 Taskflow 依赖图

图 1.9 – for_each_index() 的 Taskflow 依赖图

当你调试复杂的依赖图(并为你的书籍和论文生成复杂外观的图片)时,此功能非常有用。

Taskflow 库的功能非常丰富,提供了众多并行算法和性能分析功能的实现。请参阅官方文档以获取深入覆盖,链接为 taskflow.github.io/taskflow/index.xhtml

让我们继续进入下一章,学习如何开始使用 Vulkan。

第三章:2 开始使用 Vulkan

加入我们的 Discord 书籍社区

packt.link/unitydev

在本章中,我们将学习如何使用 Vulkan 进行第一步,以便我们可以处理交换链、着色器和管线。本章的食谱将帮助您使用 Vulkan 在屏幕上显示第一个三角形。本节的 Vulkan 实现基于开源库LightweightVK(github.com/corporateshark/lightweightvk),我们将在本章中对其进行探索。

在本章中,我们将介绍以下食谱:

  • 初始化 Vulkan 实例和图形设备

  • 初始化 Vulkan 交换链

  • 设置 Vulkan 调试功能

  • 使用 Vulkan 命令缓冲区

  • 初始化 Vulkan 着色器模块

  • 初始化 Vulkan 管线

技术要求

要运行本章的食谱,您必须使用配备有支持 Vulkan 1.3 的视频卡和驱动程序的 Windows 或 Linux 计算机。阅读第一章建立构建环境,了解如何正确配置它。

初始化 Vulkan 实例和图形设备

与 OpenGL 相比,Vulkan API 的语法更加冗长,因此我们必须将我们第一个图形演示应用程序的创建拆分为一系列单独的小食谱。在这个食谱中,我们将学习如何创建一个 Vulkan 实例,枚举系统中所有能够进行 3D 图形渲染的物理设备,并将其中一个设备初始化以创建一个带有附加表面的窗口。

准备工作

如果您对 Vulkan 一无所知,我们建议从一些初学者 Vulkan 书籍开始,例如 Preetish Kakkar 和 Mauricio Maurer 的The Modern Vulkan Cookbook,或者 Graham Sellers 的Vulkan Programming Guide: The Official Guide to Learning Vulkan

从 OpenGL 过渡到 Vulkan,或任何类似的现代图形 API,最困难的部分是习惯于设置渲染过程所需的显式代码量,幸运的是,这只需要做一次。了解 Vulkan 的对象模型也很有用。作为一个良好的起点,我们建议阅读 Adam Sawicki 的gpuopen.com/understanding-vulkan-objects作为参考。对于本章的后续食谱,我们设定的目标是使用最少的设置开始渲染 3D 场景。

我们所有的 Vulkan 食谱都使用 LightweightVK 库,您可以通过以下 Bootstrap 片段从github.com/corporateshark/lightweightvk下载该库:这个库实现了本书中将要讨论的所有底层 Vulkan 包装类:

{
  “name”: “ lightweightvk “,
  “source”: {
    “type”: “git”,
    “url”: “ https://github.com/corporateshark/lightweightvk.git “,
    “revision”: “1.0”
  }
}

本食谱的完整 Vulkan 示例可以在Chapter02/01_Swapchain中找到。

如何操作...

在我们深入实际实现之前,让我们探索一些使调试 Vulkan 后端变得更容易的脚手架代码。让我们从一些错误检查设施开始:

  1. 从复杂的 API 中调用的任何函数都可能失败。为了处理失败,或者至少让开发者知道失败的确切位置,LightweightVK 将大多数 Vulkan 调用包装在 VK_ASSERT()VK_ASSERT_RETURN() 宏中,这些宏检查 Vulkan 操作的结果。当从头开始编写新的 Vulkan 实现时,这将非常有帮助:
#define VK_ASSERT(func) {                                          \
    const VkResult vk_assert_result = func;                        \
    if (vk_assert_result != VK_SUCCESS) {                          \
      LLOGW(“Vulkan API call failed: %s:%i\n  %s\n  %s\n”,         \
                    __FILE__, __LINE__, #func,                     \
                    ivkGetVulkanResultString(vk_assert_result));   \
      assert(false);                                               \
    }                                                              \
  }
  1. VK_ASSERT_RETURN() 宏非常相似,并将控制权返回给调用代码:
#define VK_ASSERT_RETURN(func) {                                   \
    const VkResult vk_assert_result = func;                        \
    if (vk_assert_result != VK_SUCCESS) {                          \
      LLOGW(“Vulkan API call failed: %s:%i\n  %s\n  %s\n”,         \
                    __FILE__, __LINE__, #func,                     \
                    ivkGetVulkanResultString(vk_assert_result));   \
      assert(false);                                               \
      return getResultFromVkResult(vk_assert_result);              \
    }                                                              \
  }

现在,我们可以开始创建我们的第一个 Vulkan 应用程序。让我们探索示例应用程序 Chapter02/01_Swapchain 中正在发生的事情,该应用程序创建了一个窗口、一个 Vulkan 实例和一个设备,以及一个 Vulkan 交换链,这将在接下来的食谱中解释。应用程序代码非常简单:

  1. 我们初始化日志库并创建一个 GLFW 窗口,正如我们在第一章的食谱 使用 GLFW 库 中讨论的那样。所有 Vulkan 初始化魔法都发生在 lvk::createVulkanContextWithSwapchain() 辅助函数中,我们将在稍后探索:
int main(void) {
  minilog::initialize(nullptr, { .threadNames = false });
  int width  = 960;
  int height = 540;
  GLFWwindow* window = lvk::initWindow(
    “Simple example”, width, height);
  std::unique_ptr<lvk::IContext> ctx =
    lvk::createVulkanContextWithSwapchain(window, width, height, {});
  1. 应用程序主循环在窗口大小改变时更新帧缓冲区大小,获取一个命令缓冲区,提交它,并呈现当前的交换链图像,或者在 LightweightVK 中称为纹理:
 while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    glfwGetFramebufferSize(window, &width, &height);
    if (!width || !height) continue;
    lvk::ICommandBuffer& buf = device->acquireCommandBuffer();
    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
  }
  1. 关闭代码是标准的。在销毁 GLFW 窗口之前,我们应该销毁 IDevice 对象:
 ctx.reset();
  glfwDestroyWindow(window);
  glfwTerminate();
  return 0;
}
  1. 应用程序应该渲染一个空的黑窗口,如下面的截图所示:

图 2.1:主循环和交换链

图 2.1:主循环和交换链

让我们探索 lvk::createVulkanContextWithSwapchain() 并窥视其实现。同样,我们跳过了书中文本中的大多数错误检查,因为这些错误检查并不有助于理解:

  1. 这个辅助函数调用 LightweightVK 来创建一个基于 GLFW 窗口和操作系统显示属性的 VulkanContext 对象:
std::unique_ptr<lvk::IContext> createVulkanContextWithSwapchain(
  GLFWwindow* window, uint32_t width, uint32_t height,
  const lvk::vulkan::VulkanContextConfig& cfg,
  lvk::HWDeviceType preferredDeviceType = lvk::HWDeviceType_Discrete) {
  std::unique_ptr<vulkan::VulkanContext> ctx;
#if defined(_WIN32)
  ctx = std::make_unique<vulkan::VulkanContext>(
    cfg, (void*)glfwGetWin32Window(window));
#elif defined(__linux__)
  ctx = std::make_unique<vulkan::VulkanContext>(
    cfg, (void*)glfwGetX11Window(window), (void*)glfwGetX11Display());
#else
#  error Unsupported OS
#endif
  1. 然后,我们枚举 Vulkan 物理设备并选择最偏好的一个。首先尝试选择一个离散 GPU,如果没有,则选择一个集成 GPU:
 std::vector<HWDeviceDesc> devices;
  Result res = ctx->queryDevices(preferredDeviceType, devices);
  if (devices.empty()) {
    if (preferredDeviceType == HWDeviceType_Discrete) {
      res = ctx->queryDevices(HWDeviceType_Integrated, devices);
    }
    if (preferredDeviceType == HWDeviceType_Integrated) {
      res = ctx->queryDevices(HWDeviceType_Discrete, devices);
    }
  }
  1. 一旦选择了一个物理设备,就调用 VulkanContext::initContext(),它将创建所有 Vulkan 和 LightweightVK 内部数据结构:
 if (!res.isOk() || devices.empty()) return nullptr;
  res = ctx->initContext(devices[0]);
  if (!res.isOk()) return nullptr;
  1. 如果我们有一个非空的视口,初始化一个 Vulkan 交换链。交换链创建过程将在下一道食谱中详细解释,初始化 Vulkan 交换链
 if (width > 0 && height > 0) {
    res = ctx->initSwapchain(width, height);
    if (!res.isOk()) return nullptr;
  }
  return std::move(ctx);
}

关于高级代码,我们只需做这些。让我们深入挖掘,看看 LightweightVK 的内部结构,看看它是如何工作的。

它是如何工作的...

有多个函数涉及将 Vulkan 启动并运行。一切始于在 VulkanContext::createInstance() 中创建 Vulkan 实例。使用 Vulkan 实例,我们可以在以后获取具有所需属性的一组物理设备。

  1. 首先,我们需要指定所有运行我们的 Vulkan 图形后端所需的 Vulkan 实例扩展的名称。我们需要 VK_KHR_surface 以及另一个平台特定的扩展,该扩展接受操作系统窗口句柄并将其附加到渲染表面。在 Linux 上,我们只支持基于 libXCB 的窗口创建。同样,Wayland 协议也可以支持,但超出了本书的范围。以下是 Wayland 被添加到 LightweightVK 的方式,github.com/corporateshark/lightweightvk/pull/13
void VulkanContext::createInstance() {
  vkInstance_ = VK_NULL_HANDLE;
  const char* instanceExtensionNames[] = {
    VK_KHR_SURFACE_EXTENSION_NAME,
    VK_EXT_DEBUG_UTILS_EXTENSION_NAME,
#if defined(_WIN32)
    VK_KHR_WIN32_SURFACE_EXTENSION_NAME,
#elif defined(__linux__)
    VK_KHR_XLIB_SURFACE_EXTENSION_NAME,
#endif     VK_EXT_VALIDATION_FEATURES_EXTENSION_NAME
  };
  1. 当不需要验证时,我们禁用 VK_EXT_validation_features,例如,在发布构建中:
 const uint32_t numInstanceExtensions = config_.enableValidation ?
    (uint32_t)LVK_ARRAY_NUM_ELEMENTS(instanceExtensionNames) :
    (uint32_t)LVK_ARRAY_NUM_ELEMENTS(instanceExtensionNames) - 1;
  const VkValidationFeatureEnableEXT validationFeaturesEnabled[] = {
    VK_VALIDATION_FEATURE_ENABLE_GPU_ASSISTED_EXT };
  const VkValidationFeaturesEXT features = {
    .sType = VK_STRUCTURE_TYPE_VALIDATION_FEATURES_EXT,
    .pNext = nullptr,
    .enabledValidationFeatureCount = config_.enableValidation ?
      LVK_ARRAY_NUM_ELEMENTS(validationFeaturesEnabled) : 0u,
    .pEnabledValidationFeatures = config_.enableValidation ?
      validationFeaturesEnabled : nullptr,
  };
  1. 在构建与表面相关的扩展列表之后,我们应该填写一些关于我们应用程序的必要信息:
 const VkApplicationInfo appInfo = {
    .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
    .pNext = nullptr,
    .pApplicationName = “LVK/Vulkan”,
    .applicationVersion = VK_MAKE_VERSION(1, 0, 0),
    .pEngineName = “LVK/Vulkan”,
    .engineVersion = VK_MAKE_VERSION(1, 0, 0),
    .apiVersion = VK_API_VERSION_1_3,
  };
  1. 要创建一个 VkInstance 对象,我们应该填充 VkInstanceCreateInfo 结构。我们使用上述 appInfo 常量的指针以及 VkInstanceCreateInfo 成员字段中的扩展列表。我们使用存储在全局变量 kDefaultValidationLayers[] 中的所谓层列表,这将允许我们为每个 Vulkan 调用启用调试输出。我们书中使用的唯一层是 Khronos 验证层,VK_LAYER_KHRONOS_validation。相同的验证层列表将用于创建 Vulkan 设备。然后,我们使用 Volk 库加载为创建的 VkInstance 相关的所有实例相关 Vulkan 函数。

Volk 是 Vulkan 的元加载器。它允许你在不链接到 vulkan-1.dll 或静态链接 Vulkan 加载器的情况下动态加载使用 Vulkan 所需的入口点。Volk 通过自动加载所有相关入口点简化了 Vulkan 扩展的使用。除此之外,Volk 可以直接从驱动程序加载 Vulkan 入口点,这可以通过跳过加载器调度开销来提高性能:github.com/zeux/volk

 const VkInstanceCreateInfo ci = {
    .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
    .pNext = config_.enableValidation ? &features : nullptr,
    .flags = 0,
    .pApplicationInfo = &appInfo,
    .enabledLayerCount = config_.enableValidation ?
      LVK_ARRAY_NUM_ELEMENTS(kDefaultValidationLayers) : 0,
    .ppEnabledLayerNames = config_.enableValidation ?
      kDefaultValidationLayers : nullptr,
    .enabledExtensionCount = numInstanceExtensions,
    .ppEnabledExtensionNames = instanceExtensionNames,
  };
  VK_ASSERT(vkCreateInstance(&ci, nullptr, &vkInstance_));
  volkLoadInstance(vkInstance_);
  1. 最后但同样重要的是,让我们打印出所有可用的 Vulkan 实例扩展的格式化列表:
 uint32_t count = 0;
  VK_ASSERT(vkEnumerateInstanceExtensionProperties(
    nullptr, &count, nullptr));
  std::vector<VkExtensionProperties> allInstanceExtensions(count);
  VK_ASSERT(vkEnumerateInstanceExtensionProperties(
    nullptr, &count, allInstanceExtensions.data()));
  LLOGL(“\nVulkan instance extensions:\n”);
  for (const auto& extension : allInstanceExtensions) {
    LLOGL(“  %s\n”, extension.extensionName);
  }
}

一旦我们创建了 Vulkan 实例,我们就可以访问必要的 Vulkan 物理设备列表,以继续设置我们的 Vulkan 后端。以下是我们可以枚举 Vulkan 物理设备并选择一个合适设备的方法:

  1. 函数 vkEnumeratePhysicalDevices() 被调用两次。第一次是为了获取可用物理设备的数量并为它分配 std::vector 存储空间。第二次是为了检索实际的物理设备数据:
lvk::Result VulkanContext::queryDevices(HWDeviceType deviceType,
  std::vector<HWDeviceDesc>& outDevices) {
  outDevices.clear();
  uint32_t deviceCount = 0;
  VK_ASSERT_RETURN(
    vkEnumeratePhysicalDevices(vkInstance_, &deviceCount, nullptr));
  std::vector<VkPhysicalDevice> vkDevices(deviceCount);
  VK_ASSERT_RETURN(vkEnumeratePhysicalDevices(
    vkInstance_, &deviceCount, vkDevices.data()));
  1. 我们遍历设备向量以检索它们的属性并过滤掉不合适的设备。函数convertVulkanDeviceTypeToIGL()将 Vulkan 枚举VkPhysicalDeviceType转换为LightweightVK枚举HWDeviceType
enum HWDeviceType {
  HWDeviceType_Discrete = 1,
  HWDeviceType_External = 2,
  HWDeviceType_Integrated = 3,
  HWDeviceType_Software = 4,
};
  const HWDeviceType desiredDeviceType = deviceType;
  for (uint32_t i = 0; i < deviceCount; ++i) {
    VkPhysicalDevice physicalDevice = vkDevices[i];
    VkPhysicalDeviceProperties deviceProperties;
    vkGetPhysicalDeviceProperties(physicalDevice, &deviceProperties);
    const HWDeviceType deviceType =
      convertVulkanDeviceTypeToIGL(deviceProperties.deviceType);
    if (desiredDeviceType != HWDeviceType_Software &&         desiredDeviceType != deviceType) continue;
    outDevices.push_back(
      {.guid = (uintptr_t)vkDevices[i], .type = deviceType});
    strcpy(outDevices.back().name, deviceProperties.deviceName);
  }
  if (outDevices.empty()) return Result(RuntimeError,
    “No Vulkan devices matching your criteria”);
  return Result();
}

一旦我们选择了一个合适的 Vulkan 物理设备,我们就可以创建一个 GPU 的逻辑表示VkDevice。我们可以将 Vulkan 设备视为本质上是一组队列和内存堆。为了使用设备进行渲染,我们需要指定一个能够执行图形相关命令的队列和一个具有此类队列的物理设备。让我们探索LightweightVK和函数VulkanContext::initContext()的一些部分,该函数在后续章节中我们将讨论的许多其他事情中,检测合适的队列家族并创建一个 Vulkan 设备。再次提醒,这里将省略大部分错误检查:

  1. VulkanContext::initContext()中,我们首先打印出与我们之前选择的物理设备和 Vulkan 驱动程序相关的信息。这对于调试非常有用:
lvk::Result VulkanContext::initContext(const HWDeviceDesc& desc) {
  vkPhysicalDevice_ = (VkPhysicalDevice)desc.guid;
  vkGetPhysicalDeviceFeatures2(vkPhysicalDevice_, &vkFeatures10_);
  vkGetPhysicalDeviceProperties2(
    vkPhysicalDevice_, &vkPhysicalDeviceProperties2_);
  const uint32_t apiVersion =
    vkPhysicalDeviceProperties2_.properties.apiVersion;
  LLOGL(“Vulkan physical device: %s\n”,
    vkPhysicalDeviceProperties2_.properties.deviceName);
  LLOGL(“           API version: %i.%i.%i.%i\n”,
        VK_API_VERSION_MAJOR(apiVersion),
        VK_API_VERSION_MINOR(apiVersion),
        VK_API_VERSION_PATCH(apiVersion),
        VK_API_VERSION_VARIANT(apiVersion));
  LLOGL(“           Driver info: %s %s\n”,
        vkPhysicalDeviceDriverProperties_.driverName,
        vkPhysicalDeviceDriverProperties_.driverInfo);
  1. 让我们列举并打印出这个 Vulkan 物理设备上可用的所有扩展,这对调试非常有帮助:
 uint32_t count = 0;
  vkEnumerateDeviceExtensionProperties(
    vkPhysicalDevice_, nullptr, &count, nullptr);
  std::vector<VkExtensionProperties>     allPhysicalDeviceExtensions(count);
  vkEnumerateDeviceExtensionProperties(vkPhysicalDevice_, nullptr,
    &count, allPhysicalDeviceExtensions.data());
  LLOGL(“Vulkan physical device extensions:\n”);
  for (const auto& ext : allPhysicalDeviceExtensions) {
    LLOGL(“  %s\n”, ext.extensionName);
  }
  1. 在创建 Vulkan 设备之前,我们需要找到队列家族索引并创建队列。此代码块根据提供的物理设备上的实际队列可用性创建一个或两个设备队列,图形和计算队列。lvk::findQueueFamilyIndex()辅助函数,该函数在lvk/vulkan/VulkanUtils.cpp中实现,返回第一个匹配请求队列标志的专用队列家族索引。如果你深入研究,你可以看到它是如何确保你首先选择专用队列的。

在 Vulkan 中,queueFamilyIndex是队列所属的队列家族的索引。队列家族是一组具有相似属性和功能的 Vulkan 队列。在这里,deviceQueues_是一个成员字段,它包含一个包含队列信息的结构:

struct DeviceQueues {
  const static uint32_t INVALID = 0xFFFFFFFF;
  uint32_t graphicsQueueFamilyIndex = INVALID;
  uint32_t computeQueueFamilyIndex = INVALID;
  VkQueue graphicsQueue = VK_NULL_HANDLE;
  VkQueue computeQueue = VK_NULL_HANDLE;
};
  deviceQueues_.graphicsQueueFamilyIndex =
      lvk::findQueueFamilyIndex(vkPhysicalDevice_,
      VK_QUEUE_GRAPHICS_BIT);
  deviceQueues_.computeQueueFamilyIndex =
      lvk::findQueueFamilyIndex(vkPhysicalDevice_,
      VK_QUEUE_COMPUTE_BIT);
  const float queuePriority = 1.0f;
  const VkDeviceQueueCreateInfo ciQueue[2] = {
    {   .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
        .queueFamilyIndex = deviceQueues_.graphicsQueueFamilyIndex,
        .queueCount = 1,
        .pQueuePriorities = &queuePriority, },
    {   .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
        .queueFamilyIndex = deviceQueues_.computeQueueFamilyIndex,
        .queueCount = 1,
        .pQueuePriorities = &queuePriority, },
  };
  1. 有时,尤其是在移动 GPU 上,图形和计算队列可能是相同的。在这里,我们处理这样的边缘情况:
 const uint32_t numQueues =
    ciQueue[0].queueFamilyIndex == ciQueue[1].queueFamilyIndex ? 1:2;
    1. 我们逻辑设备必须支持的一组扩展列表。设备必须支持 swapchain 对象,这允许我们在屏幕上呈现渲染的帧。我们使用包含所有其他必要功能的 Vulkan 1.3,因此不需要额外的扩展:
 const char* deviceExtensionNames[] = {
    VK_KHR_SWAPCHAIN_EXTENSION_NAME,
  };
  1. 让我们请求我们将在后端使用所有必要的Vulkan 1.01.3功能。最重要的功能是从Vulkan 1.2开始的描述符索引和从Vulkan 1.3开始的动态渲染,我们将在后续章节中讨论。看看我们将使用的其他功能。

描述符索引是一组 Vulkan 1.2 功能,它使应用程序能够访问它们的所有资源,并在着色器中选择具有动态索引的那些资源。

动态渲染是 Vulkan 1.3 的一个特性,它允许应用程序直接将图像渲染到图像中,而不需要创建渲染通道对象或帧缓冲区。

 VkPhysicalDeviceFeatures deviceFeatures10 = {
      .geometryShader = VK_TRUE,
      .multiDrawIndirect = VK_TRUE,
      .drawIndirectFirstInstance = VK_TRUE,
      .depthBiasClamp = VK_TRUE,
      .fillModeNonSolid = VK_TRUE,
      .textureCompressionBC = VK_TRUE,
  };
  VkPhysicalDeviceVulkan11Features deviceFeatures11 = {
      .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES,
      .storageBuffer16BitAccess = VK_TRUE,
      .shaderDrawParameters = VK_TRUE,
  };
  VkPhysicalDeviceVulkan12Features deviceFeatures12 = {
      .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES,
      .pNext = &deviceFeatures11,
      .descriptorIndexing = VK_TRUE,
      .shaderSampledImageArrayNonUniformIndexing = VK_TRUE,
      .descriptorBindingSampledImageUpdateAfterBind = VK_TRUE,
      .descriptorBindingStorageImageUpdateAfterBind = VK_TRUE,
      .descriptorBindingUpdateUnusedWhilePending = VK_TRUE,
      .descriptorBindingPartiallyBound = VK_TRUE,
      .descriptorBindingVariableDescriptorCount = VK_TRUE,
      .runtimeDescriptorArray = VK_TRUE,
      .uniformBufferStandardLayout = VK_TRUE,
      .timelineSemaphore = VK_TRUE,
      .bufferDeviceAddress = VK_TRUE,
  };
  VkPhysicalDeviceVulkan13Features deviceFeatures13 = {
      .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES,
      .pNext = &deviceFeatures12,
      .subgroupSizeControl = VK_TRUE,
      .synchronization2 = VK_TRUE,
      .dynamicRendering = VK_TRUE,
      .maintenance4 = VK_TRUE,
  };
  1. 在我们创建实际设备之前,还有一些步骤,比如检查我们请求的扩展列表与可用扩展列表的对比,并在日志中打印所有缺失的扩展,然后终止:
 std::vector<VkExtensionProperties> props;
  getDeviceExtensionProps(vkPhysicalDevice_, props);
  for (const char* layer : kDefaultValidationLayers)
    getDeviceExtensionProps(vkPhysicalDevice_, props, layer);
  std::string missingExtensions;
  for (const char* ext : deviceExtensionNames)
    if (!hasExtension(ext, props))
      missingExtensions += “\n   “ + std::string(ext);
  if (!missingExtensions.empty()) {
    MINILOG_LOG_PROC(minilog::FatalError,
      “Missing Vulkan device extensions: %s\n”,
      missingExtensions.c_str());
    return Result(Result::Code::RuntimeError);
  }
  1. 最后,我们应该将所有请求的 Vulkan 特性与实际可用的特性进行核对。借助 C 宏,我们可以轻松地做到这一点。这段代码很有用,所以我们几乎将其全部打印在这里:
 {
    std::string missingFeatures;
#define CHECK_VULKAN_FEATURE(                       \
  reqFeatures, availFeatures, feature, version)     \
  if ((reqFeatures.feature == VK_TRUE) &&           \
      (availFeatures.feature == VK_FALSE))          \
        missingFeatures.append(“\n   “ version “ .” #feature);
#define CHECK_FEATURE_1_0(feature)                               \
  CHECK_VULKAN_FEATURE(deviceFeatures10, vkFeatures10_.features, \
  feature, “1.0 “);
    CHECK_FEATURE_1_0(robustBufferAccess);
    CHECK_FEATURE_1_0(fullDrawIndexUint32);
    CHECK_FEATURE_1_0(imageCubeArray);
    … // omitted a lot of other Vulkan 1.0 features here
#undef CHECK_FEATURE_1_0
#define CHECK_FEATURE_1_1(feature)                      \
  CHECK_VULKAN_FEATURE(deviceFeatures11, vkFeatures11_, \
    feature, “1.1 “);
    CHECK_FEATURE_1_1(storageBuffer16BitAccess);
    CHECK_FEATURE_1_1(uniformAndStorageBuffer16BitAccess);
    CHECK_FEATURE_1_1(storagePushConstant16);
    … // omitted a lot of other Vulkan 1.1 features here
#undef CHECK_FEATURE_1_1
#define CHECK_FEATURE_1_2(feature)                      \
  CHECK_VULKAN_FEATURE(deviceFeatures12, vkFeatures12_, \
  feature, “1.2 “);
    CHECK_FEATURE_1_2(samplerMirrorClampToEdge);
    CHECK_FEATURE_1_2(drawIndirectCount);
    CHECK_FEATURE_1_2(storageBuffer8BitAccess);
    … // omitted a lot of other Vulkan 1.2 features here
#undef CHECK_FEATURE_1_2
#define CHECK_FEATURE_1_3(feature)                      \
  CHECK_VULKAN_FEATURE(deviceFeatures13, vkFeatures13_, \
  feature, “1.3 “);
    CHECK_FEATURE_1_3(robustImageAccess);
    CHECK_FEATURE_1_3(inlineUniformBlock);
    … // omitted a lot of other Vulkan 1.3 features here
#undef CHECK_FEATURE_1_3
    if (!missingFeatures.empty()) {
      MINILOG_LOG_PROC(minilog::FatalError,
        “Missing Vulkan features: %s\n”, missingFeatures.c_str());
      return Result(Result::Code::RuntimeError);
    }
  }

当我们缺少一些 Vulkan 特性时,这段代码将打印出一个格式良好的缺失特性列表,并用相应的 Vulkan 版本标记。这对于调试和使你的 Vulkan 后端适应不同设备非常有价值。

现在,我们已经准备好创建 Vulkan 设备,使用Volk加载所有相关的 Vulkan 函数,并根据我们在本配方中之前选择的队列家族索引获取实际的设备队列:

 const VkDeviceCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
      .pNext = &deviceFeatures13,
      .queueCreateInfoCount = numQueues,
      .pQueueCreateInfos = ciQueue,
      .enabledLayerCount =
        LVK_ARRAY_NUM_ELEMENTS(kDefaultValidationLayers),
      .ppEnabledLayerNames = kDefaultValidationLayers,
      .enabledExtensionCount = 
        LVK_ARRAY_NUM_ELEMENTS(deviceExtensionNames),
      .ppEnabledExtensionNames = deviceExtensionNames,
      .pEnabledFeatures = &deviceFeatures10,
  };
  VK_ASSERT_RETURN(vkCreateDevice(
    vkPhysicalDevice_, &ci, nullptr, &vkDevice_));
  volkLoadDevice(vkDevice_);
  vkGetDeviceQueue(vkDevice_, deviceQueues_.graphicsQueueFamilyIndex,
    0, &deviceQueues_.graphicsQueue);
  vkGetDeviceQueue(vkDevice_, deviceQueues_.computeQueueFamilyIndex,
    0, &deviceQueues_.computeQueue);
  … // other code in initContext() is unrelated to this recipe
}

现在 Vulkan 设备已经准备好使用,但 Vulkan 渲染管道的初始化还远未完成。接下来我们需要做的是创建一个 swapchain 对象。让我们跟随下一个配方来学习如何做到这一点。

初始化 Vulkan swapchain

通常,每一帧都会渲染到一个离屏图像中。渲染过程完成后,离屏图像应该变得可见或“呈现”。swapchain是一个包含一组可用离屏图像的对象,或者更具体地说,是一个等待在屏幕上呈现的已渲染图像队列。在 OpenGL 中,将离屏缓冲区呈现到窗口的可视区域是通过系统依赖的函数来完成的,即 Windows 上的wglSwapBuffers(),OpenGL ES 嵌入式系统上的eglSwapBuffers(),以及 Linux 上的glXSwapBuffers()或自动在 macOS 上。Vulkan 为我们提供了更细粒度的控制。我们需要为 swapchain 图像选择一个呈现模式。

在本配方中,我们将展示如何使用之前配方中初始化的 Vulkan 实例和设备创建一个 Vulkan swapchain 对象。

准备工作

回顾之前的配方,初始化 Vulkan 实例和图形设备,它讨论了初始化 Vulkan 所需的初始步骤。本配方中讨论的源代码在lvk::VulkanSwapchain类中实现。

如何做...

在之前的配方中,我们通过探索辅助函数lvk::createVulkanContextWithSwapchain()开始学习如何创建 Vulkan 实例和设备。它引导我们到VulkanContext::initContext()函数,我们在之前的配方中对其进行了详细讨论。让我们继续我们的旅程,并探索来自LightweightVKVulkanContext::initSwapchain()和相关类VulkanSwapchain

  1. 首先,让我们看看一个检索各种表面格式支持能力并将它们存储在VulkanContext成员字段中的函数。该函数还检查深度格式支持,但仅限于可能被LightweightVK使用的深度格式:
void VulkanContext::querySurfaceCapabilities() {
   const VkFormat depthFormats[] = {
     VK_FORMAT_D32_SFLOAT_S8_UINT, VK_FORMAT_D24_UNORM_S8_UINT,
     VK_FORMAT_D16_UNORM_S8_UINT, VK_FORMAT_D32_SFLOAT,
     VK_FORMAT_D16_UNORM};
  for (const auto& depthFormat : depthFormats) {
    VkFormatProperties formatProps;
    vkGetPhysicalDeviceFormatProperties(
      vkPhysicalDevice_, depthFormat, &formatProps);
    if (formatProps.optimalTilingFeatures)
      deviceDepthFormats_.push_back(depthFormat);
  }
  1. 所有表面能力和表面格式都被检索并存储。首先,获取支持的格式数量。然后,分配存储空间来保存它们并读取实际的属性:
 vkGetPhysicalDeviceSurfaceCapabilitiesKHR(
    vkPhysicalDevice_, vkSurface_, &deviceSurfaceCaps_);
  uint32_t formatCount;
  vkGetPhysicalDeviceSurfaceFormatsKHR(
    vkPhysicalDevice_, vkSurface_, &formatCount, nullptr);
  if (formatCount) {
    deviceSurfaceFormats_.resize(formatCount);
    vkGetPhysicalDeviceSurfaceFormatsKHR(vkPhysicalDevice_,
      vkSurface_, &formatCount, deviceSurfaceFormats_.data());
  }
  1. 以类似的方式,我们也存储当前表面的模式:
 uint32_t presentModeCount;
  vkGetPhysicalDeviceSurfacePresentModesKHR(vkPhysicalDevice_,
    vkSurface_, &presentModeCount, nullptr);
  if (presentModeCount) {
    devicePresentModes_.resize(presentModeCount);
    vkGetPhysicalDeviceSurfacePresentModesKHR(vkPhysicalDevice_,
    vkSurface_, &presentModeCount, devicePresentModes_.data());
  }
}

知道所有支持的颜色表面格式后,我们可以为我们的交换链选择一个合适的格式。让我们看看chooseSwapSurfaceFormat()辅助函数是如何做到这一点的。该函数接受一个可用格式的列表和一个所需的颜色空间:

  1. 首先,它选择一个首选的表面格式,基于所需的颜色空间和 RGB/BGR 原生交换链图像格式。RGB 或 BGR 是通过遍历 Vulkan 返回的所有可用颜色格式,并选择一个格式,RGB 或 BGR, whichever is closer to the beginning of the list。如果 BGR 出现在列表的更前面,它将成为选择的格式。一旦选择了首选的图像格式和颜色空间,我们就可以遍历支持的格式列表,并尝试找到一个完全匹配的格式。
VkSurfaceFormatKHR chooseSwapSurfaceFormat(   const std::vector<VkSurfaceFormatKHR>& formats,
  lvk::ColorSpace colorSpace) {
  const VkSurfaceFormatKHR preferred = colorSpaceToVkSurfaceFormat(
    colorSpace, isNativeSwapChainBGR(formats));
  for (const auto& fmt : formats)
    if (fmt.format == preferred.format &&         fmt.colorSpace == preferred.colorSpace) return fmt;
  1. 如果您找不到匹配的格式和颜色空间,尝试只匹配格式。如果无法匹配格式,则默认为第一个可用的格式。在许多系统中,它将是VK_FORMAT_R8G8B8A8_UNORM或类似的格式:
 for (const auto& fmt : formats) {
    if (fmt.format == preferred.format) return fmt;
  }
  return formats[0];
}

此函数从VulkanSwapchain的构造函数中调用。一旦选择了格式,我们还需要进行一些额外的检查,然后才能创建实际的 Vulkan 交换链:

  1. 第一次检查是为了确保所选格式支持用于创建交换链的图形队列家族上的呈现操作:
VkBool32 queueFamilySupportsPresentation = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(ctx.getVkPhysicalDevice(),
  ctx.deviceQueues_.graphicsQueueFamilyIndex, ctx.vkSurface_,
  &queueFamilySupportsPresentation));
IGL_ASSERT(queueFamilySupportsPresentation == VK_TRUE);
  1. 第二次检查是必要的,用于选择交换链图像的使用标志。使用标志定义了交换链图像是否可以用作颜色附件、在传输操作中使用,或者作为存储图像以允许计算着色器直接在它们上操作。不同的设备有不同的能力,并且存储图像并不总是被支持,尤其是在移动 GPU 上:
VkImageUsageFlags chooseUsageFlags(   VkPhysicalDevice pd, VkSurfaceKHR surface, VkFormat format)
{
  VkImageUsageFlags usageFlags = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
                                 VK_IMAGE_USAGE_TRANSFER_DST_BIT |
                                 VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
  VkSurfaceCapabilitiesKHR caps;
  vkGetPhysicalDeviceSurfaceCapabilitiesKHR(pd, surface, &caps);
  const bool isStorageSupported =
    (caps.supportedUsageFlags & VK_IMAGE_USAGE_STORAGE_BIT) > 0;
  VkFormatProperties props;
  vkGetPhysicalDeviceFormatProperties(pd, format, &props);
  const bool isTilingOptimalSupported =
    (props.optimalTilingFeatures & VK_IMAGE_USAGE_STORAGE_BIT) > 0;
  if (isStorageSupported && isTilingOptimalSupported) {
    usageFlags |= VK_IMAGE_USAGE_STORAGE_BIT;
  }
  return usageFlags;
}
  1. 现在,我们应该选择呈现模式。首选的呈现模式是VK_PRESENT_MODE_MAILBOX_KHR,它指定 Vulkan 呈现系统应等待下一个垂直空白期来更新当前图像。在这种情况下不会观察到视觉撕裂。然而,这个呈现模式并不保证会被支持。在这种情况下,我们可以尝试选择VK_PRESENT_MODE_IMMEDIATE_KHR以获得最快的帧率而不使用 V-sync,或者我们可以始终回退到VK_PRESENT_MODE_FIFO_KHR。所有可能的呈现模式之间的区别在 Vulkan 规范中描述,见www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VkPresentModeKHR.xhtml
VkPresentModeKHR chooseSwapPresentMode(   const std::vector<VkPresentModeKHR>& modes) {
#if defined(__linux__)
  if (std::find(modes.cbegin(), modes.cend(),
      VK_PRESENT_MODE_IMMEDIATE_KHR) != modes.cend())
    return VK_PRESENT_MODE_IMMEDIATE_KHR;
#endif // __linux__
  if (std::find(modes.cbegin(), modes.cend(),
      VK_PRESENT_MODE_MAILBOX_KHR) != modes.cend())
    return VK_PRESENT_MODE_MAILBOX_KHR;
  return VK_PRESENT_MODE_FIFO_KHR;
}
  1. 我们需要的最后一个辅助函数将选择 swapchain 对象中的图像数量。它基于我们之前检索到的表面能力。我们不是直接使用minImageCount,而是请求一个额外的图像,以确保我们不会等待 GPU 完成任何操作:
uint32_t chooseSwapImageCount(const VkSurfaceCapabilitiesKHR& caps) {
  const uint32_t desired = caps.minImageCount + 1;
  const bool exceeded = caps.maxImageCount > 0 &&                         desired > caps.maxImageCount;
  return exceeded ? caps.maxImageCount : desired;
}
  1. 让我们回到构造函数VulkanSwapchain::VulkanSwapchain(),并探索它是如何使用所有上述辅助函数来创建一个 Vulkan swapchain 对象的。这里的代码变得相当简短,仅包括填充VkSwapchainCreateInfoKHR结构体:
const VkImageUsageFlags usageFlags = chooseUsageFlags(
  ctx.getVkPhysicalDevice(), ctx.vkSurface_, surfaceFormat_.format);
const VkSwapchainCreateInfoKHR ci = {
  .sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR,
  .surface = ctx.vkSurface_,
  .minImageCount = chooseSwapImageCount(ctx.deviceSurfaceCaps_),
  .imageFormat = surfaceFormat_.format,
  .imageColorSpace = surfaceFormat_.colorSpace,
  .imageExtent = {.width = width, .height = height},
  .imageArrayLayers = 1,
  .imageUsage = usageFlags,
  .imageSharingMode = VK_SHARING_MODE_EXCLUSIVE,
  .queueFamilyIndexCount = 1,
  .pQueueFamilyIndices = &ctx.deviceQueues_.graphicsQueueFamilyIndex,
  .preTransform = ctx.deviceSurfaceCaps_.currentTransform,
  .compositeAlpha = VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR,
  .presentMode = chooseSwapPresentMode(ctx.devicePresentModes_),
  .clipped = VK_TRUE,
  .oldSwapchain = VK_NULL_HANDLE,
};
vkCreateSwapchainKHR(device_, &ci, nullptr, &swapchain_);
  1. 在 swapchain 对象创建后,我们可以检索 swapchain 图像:
vkGetSwapchainImagesKHR(
  device_, swapchain_, &numSwapchainImages_, nullptr);
std::vector<VkImage> swapchainImages(numSwapchainImages_);
vkGetSwapchainImagesKHR(
  device_, swapchain_, &numSwapchainImages_, swapchainImages.data());

检索到的VkImage对象可以用来创建纹理和附件。这个主题将在第三章的食谱在 Vulkan 中使用纹理数据中讨论。

现在,我们已经初始化了 Vulkan,并且实际上可以运行我们的第一个应用程序,Chapter02/01_Swapchain。在下一个食谱中,我们将学习如何使用 Vulkan 的内置调试功能。

设置 Vulkan 调试功能

一旦我们创建了一个 Vulkan 实例,我们就可以开始跟踪所有可能的错误和警告,这些错误和警告是由验证层产生的。为了做到这一点,我们使用扩展VK_EXT_debug_utils来创建一个回调函数,并将其注册到 Vulkan 实例上。在这个食谱中,我们将学习如何设置和使用它们。

准备工作

请重新查看第一个食谱,初始化 Vulkan 实例和图形设备,以了解如何在您的应用程序中初始化 Vulkan 的详细信息。

如何操作...

我们必须提供一个回调函数给 Vulkan 以捕获调试输出。在LightweightVK中,它被称为vulkanDebugCallback()。以下是它是如何传递给 Vulkan 以拦截日志的:

  1. 让我们创建一个调试消息传递器,它将调试消息传递给一个由应用程序提供的回调函数,vulkanDebugCallback()
const VkDebugUtilsMessengerCreateInfoEXT ci = {
  .sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT,
  .messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT |
                     VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT |
                     VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
                     VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT,
  .messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT |
                 VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
                 VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT,
  .pfnUserCallback = &vulkanDebugCallback,
  .pUserData = this,
};
vkCreateDebugUtilsMessengerEXT(
  vkInstance_, &ci, nullptr, &vkDebugUtilsMessenger_);
  1. 回调本身更为详细,可以提供有关导致错误或警告的 Vulkan 对象的信息。我们不涵盖标记对象分配和关联使用的数据。一些性能警告被静音,以使调试输出更易于阅读:
VKAPI_ATTR VkBool32 VKAPI_CALL
vulkanDebugCallback(   VkDebugUtilsMessageSeverityFlagBitsEXT msgSeverity,
  VkDebugUtilsMessageTypeFlagsEXT msgType,
  const VkDebugUtilsMessengerCallbackDataEXT* cbData,
  void* userData) {
  if (msgSeverity < VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT)
    return VK_FALSE;
  const bool isError =
   (msgSeverity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) != 0;
  char errorName[128] = {};
  int object = 0;
  void* handle = nullptr;
  char typeName[128] = {};
  void* messageID = nullptr;
  if (sscanf(cbData->pMessage,
        “Validation Error : [ %127s ] Object %i: handle = %p, “         “type = %127s | MessageID = %p”,
        errorName, &object, &handle, typeName, messageID) >= 2) {
    const char* message = strrchr(cbData->pMessage, ‘|’) + 1;
    LLOGL(“%sValidation layer:\n Validation Error: %s \n Object %i: “           “handle = %p, type = %s\n MessageID = %p \n%s \n”,
      isError ? “\nERROR:\n” : ““,
      errorName, object, handle, typeName, messageID, message);
  } else {
    LLOGL(“%sValidation layer:\n%s\n”, isError ? “\nERROR:\n” : ““,
      cbData->pMessage);
  }
  if (isError) {
    VulkanContext* ctx =
      static_cast<lvk::vulkan::VulkanContext*>(userData);
    if (ctx->config_.terminateOnValidationError) {
      IGL_ASSERT(false);
      std::terminate();
    }
  }
  return VK_FALSE;
}

这段代码足以让您开始阅读验证层消息并调试您的 Vulkan 应用程序。此外,请注意,应在销毁 Vulkan 实例之前立即执行验证层回调的销毁。查看完整源代码以获取所有详细信息:github.com/corporateshark/lightweightvk/blob/master/lvk/vulkan/VulkanClasses.cpp

还有更多...

扩展VK_EXT_debug_utils为您提供了使用文本名称或标签识别特定 Vulkan 对象的能力,以改进 Vulkan 对象跟踪和调试体验。

LightweightVK 中,我们可以为我们的 VkDevice 对象分配一个名称:

lvkSetDebugObjectName(vkDevice_, VK_OBJECT_TYPE_DEVICE,
   (uint64_t)vkDevice_, “Device: VulkanContext::vkDevice_”));

此辅助函数在 lvk/vulkan/VulkanUtils.cpp 中实现,如下所示:

VkResult ivkSetDebugObjectName(VkDevice device, VkObjectType type,
  uint64_t handle, const char* name) {
  if (!name || !*name) return VK_SUCCESS;
  const VkDebugUtilsObjectNameInfoEXT ni = {
      .sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT,
      .objectType = type,
      .objectHandle = handle,
      .pObjectName = name,
  };
  return vkSetDebugUtilsObjectNameEXT(device, &ni);
}

使用 Vulkan 命令缓冲区

在之前的菜谱中,我们学习了如何创建 Vulkan 实例、用于渲染的设备以及 swapchain。在本菜谱中,我们将学习如何管理命令缓冲区并使用命令队列提交它们,这将使我们更接近使用 Vulkan 渲染第一张图像。

Vulkan 命令缓冲区用于记录 Vulkan 命令,然后可以提交到设备队列以执行。命令缓冲区从允许 Vulkan 实现将资源创建的成本分摊到多个命令缓冲区的池中分配。命令池是外部同步的,这意味着一个命令池不应在多个线程之间使用。让我们学习如何在 Vulkan 命令缓冲区和池的上方创建一个方便的用户友好包装器。

准备工作

我们将探索 LightweightVK 库中的命令缓冲区管理代码。查看 lvk/vulkan/VulkanClasses.h 中的 VulkanImmediateCommands 类。在本书的前一版中,我们使用了非常基础的命令缓冲区管理代码,它没有假设任何同步,因为每一帧都是通过 vkDeviceWaitIdle() 来“同步”的。在这里,我们将探索一个更实际的解决方案,并提供一些同步功能。

让我们回到我们的演示应用程序,该应用程序来自菜谱 初始化 Vulkan swapchain,它渲染一个黑色的空窗口,第二章/01_Swapchain。应用程序的主循环如下所示:

 while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    glfwGetFramebufferSize(window, &width, &height);
    if (!width || !height) continue;
    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
  }

在这里,我们获取一个下一个命令缓冲区,然后提交它而不向其中写入任何命令,允许LightweightVK运行其 swapchain 展示代码并渲染一个黑色窗口。让我们深入了解实现,了解lvk::VulkanImmediateCommands如何在幕后完成所有繁重的工作。

如何做到这一点...

  1. 首先,我们需要一个辅助结构体 struct SubmitHandle 来标识之前提交的命令缓冲区。这将是在有人想要安排一些工作,这些工作依赖于之前提交的命令缓冲区的结果时实现同步所必需的。它包含提交缓冲区的内部 ID 和提交的整数 ID。句柄可以方便地转换为和从 64 位整数转换。
struct SubmitHandle {
  uint32_t bufferIndex_ = 0;
  uint32_t submitId_ = 0;
  SubmitHandle() = default;
  explicit SubmitHandle(uint64_t handle) :
    bufferIndex_(uint32_t(handle & 0xffffffff)),
    submitId_(uint32_t(handle >> 32)) {}
  bool empty() const { return submitId_ == 0; }
  uint64_t handle() const   { return (uint64_t(submitId_) << 32) + bufferIndex_; }
};
  1. 另一个辅助结构体 CommandBufferWrapper 是必要的,用于封装与一个命令缓冲区相关联的所有 Vulkan 对象。在这里,我们存储最初分配的和当前活动的命令缓冲区。最近的提交句柄与这个命令缓冲区相关联。一个 Vulkan 栅栏和一个 Vulkan 信号量与这个命令缓冲区相关联。栅栏用于实现 GPU-CPU 同步。信号量是必要的,以确保命令缓冲区按顺序由 GPU 处理,因为 LightweightVK 强制所有命令缓冲区按照它们提交的顺序进行处理。这在渲染方面简化了许多事情:
struct CommandBufferWrapper {
  VkCommandBuffer cmdBuf_ = VK_NULL_HANDLE;
  VkCommandBuffer cmdBufAllocated_ = VK_NULL_HANDLE;
  SubmitHandle handle_ = {};
  VkFence fence_ = VK_NULL_HANDLE;
  VkSemaphore semaphore_ = VK_NULL_HANDLE;
  bool isEncoding_ = false;
};

现在,让我们看看 lvk::VulkanImmediateCommands 的接口:

  1. Vulkan 命令缓冲区是预分配的,并以循环方式使用。预分配的命令缓冲区数量是 kMaxCommandBuffers。如果我们用完了缓冲区,VulkanImmediateCommands 将会等待,直到一个现有的命令缓冲区通过等待栅栏变得可用。64 个命令缓冲区确保在大多数情况下非阻塞操作。构造函数接受 queueFamilyIdx 的值以检索适当的 Vulkan 队列:
class VulkanImmediateCommands final {
 public:
   static constexpr uint32_t kMaxCommandBuffers = 64;
  VulkanImmediateCommands(
    VkDevice device, uint32_t queueFamilyIdx, const char* debugName);
  ~VulkanImmediateCommands();
  1. acquire() 方法返回下一个可用的命令缓冲区。如果所有命令缓冲区都忙碌,它将等待在栅栏(fence)上,直到有一个命令缓冲区变得可用。submit() 方法将命令缓冲区提交到分配的 Vulkan 队列:
 const CommandBufferWrapper& acquire();
  SubmitHandle submit(const CommandBufferWrapper& wrapper);
  1. 接下来的两个方法提供了 GPU-GPU 同步机制。第一个方法 waitSemaphore() 使得当前命令缓冲区在运行之前等待在给定的信号量上。这个方法的典型用例是从 VulkanSwapchain 对象获取一个“获取信号量”,它等待获取一个交换链图像,并确保命令缓冲区在开始渲染到交换链图像之前会等待它。第二个方法 acquireLastSubmitSemaphore() 返回并重置信号量,该信号量在最后一个提交的命令缓冲区完成时被触发。这个信号量可以在交换链展示之前被交换链使用,以确保图像渲染完成:
 void waitSemaphore(VkSemaphore semaphore);
  VkSemaphore acquireLastSubmitSemaphore();
  1. 接下来的一组方法控制 GPU-CPU 同步。正如我们将在后面的食谱中看到的那样,提交句柄是通过 Vulkan 栅栏实现的,并且可以用来等待特定的 GPU 操作完成:
 SubmitHandle getLastSubmitHandle() const;
  bool isReady(SubmitHandle handle) const;
  void wait(SubmitHandle handle);
  void waitAll();
  1. 类的私有部分包含所有局部状态,包括一个预分配的 CommandBufferWrapper 对象数组 buffers_
 private:
  void purge();
  VkDevice device_ = VK_NULL_HANDLE;
  VkQueue queue_ = VK_NULL_HANDLE;
  VkCommandPool commandPool_ = VK_NULL_HANDLE;
  uint32_t queueFamilyIndex_ = 0;
  const char* debugName_ = ““;
  CommandBufferWrapper buffers_[kMaxCommandBuffers];
  SubmitHandle lastSubmitHandle_ = SubmitHandle();
  VkSemaphore lastSubmitSemaphore_ = VK_NULL_HANDLE;
  VkSemaphore waitSemaphore_ = VK_NULL_HANDLE;
  uint32_t numAvailableCommandBuffers_ = kMaxCommandBuffers;
  uint32_t submitCounter_ = 1;
};

VulkanImmediateCommands类对于我们的整个 Vulkan 后端操作至关重要,因此让我们逐个详细探讨其实现,一次一个方法。

让我们从类构造函数和析构函数开始。构造函数预分配所有命令缓冲区。文本中省略了错误检查和调试代码;请参阅LightweightVK库源代码以获取完整细节:

  1. 首先,我们应该检索一个 Vulkan 设备队列并分配一个命令池。我们使用VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志来指定从这个池中分配的任何命令缓冲区都可以使用 Vulkan 函数vkResetCommandBuffer()单独重置到初始状态。为了指定从这个池中分配的命令缓冲区将是短暂的,我们使用标志VK_COMMAND_POOL_CREATE_TRANSIENT_BIT,这意味着它们将在相对较短的时间内重置或释放:
lvk::VulkanImmediateCommands::VulkanImmediateCommands(VkDevice device,
  uint32_t queueFamilyIndex, const char* debugName) :
  device_(device), queueFamilyIndex_(queueFamilyIndex),
  debugName_(debugName)
{
  vkGetDeviceQueue(device, queueFamilyIndex, 0, &queue_);
  const VkCommandPoolCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
      .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT |
               VK_COMMAND_POOL_CREATE_TRANSIENT_BIT,
      .queueFamilyIndex = queueFamilyIndex,
  };
  VK_ASSERT(vkCreateCommandPool(device, &ci, nullptr, &commandPool_));
  1. 现在,我们可以从命令池中预分配所有命令缓冲区。除此之外,我们为每个命令缓冲区创建一个信号量和一个栅栏,以启用我们的同步机制:
 const VkCommandBufferAllocateInfo ai = {
      .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
      .commandPool = commandPool_,
      .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
      .commandBufferCount = 1,
  };
  for (uint32_t i = 0; i != kMaxCommandBuffers; i++) {
    auto& buf = buffers_[i];
    buf.semaphore_ = lvk::createSemaphore(device, semaphoreName);
    buf.fence_ = lvk::createFence(device, fenceName);
    VK_ASSERT(
      vkAllocateCommandBuffers(device, &ai, &buf.cmdBufAllocated_));
    buffers_[i].handle_.bufferIndex_ = i;
  }
}
  1. 析构函数很简单。我们只需要在销毁命令池、栅栏和信号量之前等待所有命令缓冲区被处理:
lvk::VulkanImmediateCommands::~VulkanImmediateCommands() {
  waitAll();
  for (auto& buf : buffers_) {
    vkDestroyFence(device_, buf.fence_, nullptr);
    vkDestroySemaphore(device_, buf.semaphore_, nullptr);
  }
  vkDestroyCommandPool(device_, commandPool_, nullptr);
}

现在,让我们看看我们最重要的函数acquire()的实现。这里省略了所有错误检查代码以简化理解:

  1. 在我们能够找到一个可用的命令缓冲区之前,我们必须确保有一个。这个忙等待循环检查当前可用的命令缓冲区数量,并调用purge()函数,该函数回收已处理的命令缓冲区并将它们重置到初始状态,直到我们至少有一个缓冲区可用:
const lvk::VulkanImmediateCommands::CommandBufferWrapper&   lvk::VulkanImmediateCommands::acquire()
{
  while (!numAvailableCommandBuffers_) purge();
  1. 一旦我们知道至少有一个命令缓冲区可用,我们可以通过遍历所有缓冲区的数组并选择第一个来找到它。

  2. 在这一点上,我们减少numAvailableCommandBuffers。这是为了确保我们下一次调用acquire()时正确地忙等待。

    isEncoding成员字段用于防止当前已编码但尚未提交的命令缓冲区的重复使用:

 VulkanImmediateCommands::CommandBufferWrapper* current = nullptr;
  for (auto& buf : buffers_) {
    if (buf.cmdBuf_ == VK_NULL_HANDLE) {
      current = &buf;
      break;
    }
  }
  current->handle_.submitId_ = submitCounter_;
  numAvailableCommandBuffers_--;
  current->cmdBuf_ = current->cmdBufAllocated_;
  current->isEncoding_ = true;
  1. 在我们在库的这一侧完成所有账目之后,我们可以调用 Vulkan API 来开始记录当前的命令缓冲区:
 const VkCommandBufferBeginInfo bi = {
      .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
      .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
  };
  VK_ASSERT(vkBeginCommandBuffer(current->cmdBuf_, &bi));
  return *current;
}
  1. 在我们探索下一系列函数之前,让我们看看上面提到的acquire()中的一个简短辅助函数purge()。这个函数使用 Vulkan 栅栏和timeout值为0调用vkWaitForFences(),这将在不等待的情况下返回栅栏的当前状态。如果栅栏被触发,我们可以重置命令缓冲区并增加numAvailableCommandBuffers
void lvk::VulkanImmediateCommands::purge() {
  for (CommandBufferWrapper& buf : buffers_) {
    if (buf.cmdBuf_ == VK_NULL_HANDLE || buf.isEncoding_)
      continue;
    const VkResult result =
      vkWaitForFences(device_, 1, &buf.fence_, VK_TRUE, 0);
    if (result == VK_SUCCESS) {
      VK_ASSERT(vkResetCommandBuffer(
        buf.cmdBuf_, VkCommandBufferResetFlags{0}));
      VK_ASSERT(vkResetFences(device_, 1, &buf.fence_));
      buf.cmdBuf_ = VK_NULL_HANDLE;
      numAvailableCommandBuffers_++;
    }
  }
}

另一个非常重要的函数是submit(),它将命令缓冲区提交到队列。让我们看看:

  1. 首先,我们应该调用vkEndCommandBuffer()来完成命令缓冲区的记录。
SubmitHandle lvk::VulkanImmediateCommands::submit(
  const CommandBufferWrapper& wrapper) {
  VK_ASSERT(vkEndCommandBuffer(wrapper.cmdBuf_));
  1. 然后,我们应该准备信号量。我们可以在 GPU 处理我们的命令缓冲区之前设置两个可选的信号量进行等待。第一个是使用waitSemaphore()函数注入的信号量。如果我们想组织某种类型的帧图,它可以是来自交换链的“获取信号量”或任何其他用户提供的信号量。第二个信号量,lastSubmitSemaphore_,是由先前提交的命令缓冲区发出的信号量。这确保了所有命令缓冲区都是顺序逐个处理的:
 const VkPipelineStageFlags waitStageMasks[] = {
    VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
    VK_PIPELINE_STAGE_ALL_COMMANDS_BIT };
  VkSemaphore waitSemaphores[] = { VK_NULL_HANDLE, VK_NULL_HANDLE };
  uint32_t numWaitSemaphores = 0;
  if (waitSemaphore_)
    waitSemaphores[numWaitSemaphores++] = waitSemaphore_;
  if (lastSubmitSemaphore_)
    waitSemaphores[numWaitSemaphores++] = lastSubmitSemaphore_;
  1. 一旦我们有了所有数据,调用vkQueueSubmit()就变得简单。我们将pSignalSemaphores设置为当前CommandBufferWrapper对象中的信号量存储,这样我们就可以在下一个submit()调用中等待它:
 const VkSubmitInfo si = {
      .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
      .waitSemaphoreCount = numWaitSemaphores,
      .pWaitSemaphores = waitSemaphores,
      .pWaitDstStageMask = waitStageMasks,
      .commandBufferCount = 1u,
      .pCommandBuffers = &wrapper.cmdBuf_,
      .signalSemaphoreCount = 1u,
      .pSignalSemaphores = &wrapper.semaphore_,
  };
  VK_ASSERT(vkQueueSubmit(queue_, 1u, &si, wrapper.fence_));
  lastSubmitSemaphore_ = wrapper.semaphore_;
  lastSubmitHandle_ = wrapper.handle_;
  1. 一旦使用waitSemaphore_对象,就丢弃它。它应该仅与一个命令缓冲区一起使用。提交计数器用于在SubmitHandle中设置submitId值。这里有一个技巧我们可以做。当命令缓冲区和submitId为零时,SubmitHandle被认为是空的。一个简单的方法是始终跳过submitCounter的零值:
 waitSemaphore_ = VK_NULL_HANDLE;
  const_cast<CommandBufferWrapper&>(wrapper).isEncoding_ = false;
  submitCounter_++;
  if (!submitCounter_) submitCounter_++;
  return lastSubmitHandle_;
}

这段代码已经足够组织应用程序中的命令缓冲区管理。然而,让我们检查VulkanImmediateCommands的其他方法,这些方法通过在SubmitHandle后面隐藏它们,使得使用 Vulkan 栅栏的工作变得更加容易。最有用的下一个方法是isReady(),它是我们的高级等价于vkWaitForFences(),超时设置为0

  1. 首先,我们进行一个简单的检查,检查提交句柄是否为空:
bool VulkanImmediateCommands::isReady(const SubmitHandle handle) const {
  if (handle.empty()) return true;
  1. 然后,我们检查一个实际的命令缓冲区包装器,并检查其命令缓冲区是否已经被回收:
 const CommandBufferWrapper& buf = buffers_[handle.bufferIndex_];
  if (buf.cmdBuf_ == VK_NULL_HANDLE) return true;
  1. 另一种情况是当命令缓冲区被回收后再次使用。在这种情况下,submitId值将不同。只有在这个比较之后,我们才能调用 Vulkan API 来获取我们的VkFence对象的状态:
 if (buf.handle_.submitId_ != handle.submitId_)  return true;
  return vkWaitForFences(device_, 1, &buf.fence_, VK_TRUE, 0) ==
    VK_SUCCESS;
}

这个isReady()方法提供了一个简单的接口,用于 Vulkan 栅栏,可以被使用LightweightVK的应用程序所使用。

有成对类似的方法允许我们等待命令缓冲区或特定的句柄或VkFence

  1. 第一个是wait(),它等待单个栅栏被发出信号。这里有两点重要的事情要提一下。我们可以使用isEncoding_标志检测未提交命令缓冲区的等待操作。此外,我们在函数的末尾调用purge(),因为我们确信现在至少有一个命令缓冲区需要回收:
void lvk::VulkanImmediateCommands::wait(const SubmitHandle handle) {
  if (isReady(handle)) return;
  if (!LVK_VERIFY(!buffers_[handle.bufferIndex_].isEncoding_)) return;
  VK_ASSERT(vkWaitForFences(device_, 1, 
    &buffers_[handle.bufferIndex_].fence_, VK_TRUE, UINT64_MAX));
  purge();
}
  1. 第二个函数等待所有命令缓冲区完成,这在我们要删除所有资源时很有用,例如在析构函数中。实现很简单,我们再次调用purge()来回收所有完成的命令缓冲区:
void lvk::VulkanImmediateCommands::waitAll() {
  VkFence fences[kMaxCommandBuffers];
  uint32_t numFences = 0;
  for (const auto& buf : buffers_) {
    if (buf.cmdBuf_ != VK_NULL_HANDLE && !buf.isEncoding_)
      fences[numFences++] = buf.fence_;
  }
  if (numFences) VK_ASSERT(vkWaitForFences(
    device_, numFences, fences, VK_TRUE, UINT64_MAX));
  purge();
}

这些都是关于低级实现的详细信息。现在,让我们看看这段代码是如何与我们的演示应用程序一起工作的。

它是如何工作的…

让我们追溯到我们的演示应用程序及其主循环。我们调用函数 VulkanContext::acquireCommandBuffer(),它返回一个对某些高级接口的引用,lvk::ICommandBuffer。然后,我们调用 VulkanContext::submit() 来提交该命令缓冲区:

 while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    glfwGetFramebufferSize(window, &width, &height);
    if (!width || !height) continue;
    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
  }

这些函数内部正在发生的事情。

  1. 第一个函数,VulkanContext::acquireCommandBuffer(),非常简单。它创建一个 lvk::CommandBuffer 对象并返回对其的引用。此对象实现了 lvk::ICommandBuffer 接口:
ICommandBuffer& VulkanContext::acquireCommandBuffer() {
  LVK_ASSERT_MSG(!pimpl_->currentCommandBuffer_.ctx_,
    “Cannot acquire more than 1 command buffer simultaneously”);
  pimpl_->currentCommandBuffer_ = CommandBuffer(this);
  return pimpl_->currentCommandBuffer_;
}
  1. 函数 VulkanContext::submit() 更加详细。除了提交命令缓冲区之外,它还接受一个可选的参数,即要呈现的交换链纹理。在这里我们将跳过这部分,只关注命令缓冲区提交部分:
void VulkanContext::submit(
  const lvk::ICommandBuffer& commandBuffer, TextureHandle present) {
  vulkan::CommandBuffer* vkCmdBuffer =
    const_cast<vulkan::CommandBuffer*>(
      static_cast<const vulkan::CommandBuffer*>(&commandBuffer));
  if (present) {
    // … do proper layout transitioning for the image …
  }
  1. 在这里,我们将从交换链注入 acquireSemaphore_VulkanImmediateCommands 对象中,以便在开始渲染之前等待其被信号:
 const bool shouldPresent = hasSwapchain() && present;
  if (shouldPresent) {
    immediate_->waitSemaphore(swapchain_->acquireSemaphore_);
  }
  1. 然后,我们调用上述 VulkanImmediateCommands::submit() 并使用其最后一个提交信号量来告诉交换链等待渲染完成:
 vkCmdBuffer->lastSubmitHandle_ =
    immediate_->submit(*vkCmdBuffer->wrapper_);
  if (shouldPresent) {
    swapchain_->present(immediate_->acquireLastSubmitSemaphore());
  }
  1. 在每次提交操作中,我们处理所谓的延迟任务。延迟任务是一个 std::packaged_task,它仅在关联的 SubmitHandle(即 VkFence)就绪时运行。这种机制非常有帮助于管理或释放资源,将在后续章节中讨论:
 processDeferredTasks();
  pimpl_->currentCommandBuffer_ = {};
}

现在,我们有一个工作子系统来处理 Vulkan 命令缓冲区并以干净直接的方式向用户应用程序暴露 VkFence 对象。我们没有在本菜谱中涵盖 ICommandBuffer 接口,但我们将在本章中简要介绍,同时进行我们的第一个 Vulkan 渲染演示。在我们能够进行渲染之前,让我们学习如何处理来自 第一章在运行时编译 Vulkan 着色器 菜单的编译好的 SPIR-V 着色器:

参见…

我们建议您参考 Pawel Lapinski 的 Vulkan 烹饪书,以深入了解交换链创建和命令队列管理。

初始化 Vulkan 着色器模块

Vulkan API 以编译好的 SPIR-V 二进制文件的形式消耗着色器。在 第一章 中的一个先前菜谱 在运行时编译 Vulkan 着色器 中,我们学习了如何使用 Khronos 的开源 glslang 编译器将 GLSL 着色器从源代码编译成 SPIR-V。在本菜谱中,我们将学习如何在 Vulkan 中使用 GLSL 着色器和预编译的二进制文件。

准备工作

在继续之前,我们建议您阅读 第一章 中的 在运行时编译 Vulkan 着色器 菜单。

如何做到这一点…

让我们看看我们的下一个演示应用程序,Chapter02/02_HelloTriangle,以了解着色器模块的高级 LightweightVK API。正如我们将看到的,在 IContext 中有一个 createShaderModule() 方法来完成这项工作:

  1. 给定IContext的指针,可以通过以下方式从 GLSL 着色器创建 Vulkan 着色器模块,其中codeVScodeFS是空终止字符串,分别持有顶点和片段着色器源代码。请注意,这些值用于初始化传递给createShaderModule()的结构体:
ctx->createShaderModule(
  { codeVS, lvk::Stage_Vert, “Shader Module: main (vert)” })
ctx->createShaderModule(
  { codeFS, lvk::Stage_Frag, “Shader Module: main (frag)” })
  1. createShaderModule()的第一个参数是一个结构体,名为ShaderModuleDesc,它包含创建 Vulkan 着色器模块所需的所有属性。如果dataSize成员字段非零,则data字段被视为二进制 SPIR-V blob。如果dataSize为零,则data被视为包含 GLSL 源代码的空终止字符串:
struct ShaderModuleDesc {
  ShaderStage stage = Stage_Frag;
  const char* data = nullptr;
  size_t dataSize = 0;
  const char* debugName = ““;
  ShaderModuleDesc(const char* source, lvk::ShaderStage stage,
    const char* debugName) : stage(stage), data(source),
    debugName(debugName) {}
  ShaderModuleDesc(const void* data, size_t dataLength,
    lvk::ShaderStage stage, const char* debugName) :
    stage(stage), data(static_cast<const char*>(data)),
    dataSize(dataLength), debugName(debugName) {}
};
  1. VulkanContext::createShaderModule()内部,我们对文本 GLSL 和二进制 SPIR-V 着色器进行分支。实际的VkShaderModule对象存储在一个池中,我们将在随后的章节中讨论:
lvk::Holder<lvk::ShaderModuleHandle>   VulkanContext::createShaderModule(const ShaderModuleDesc& desc)
{
  VkShaderModule sm = desc.dataSize ?
    // binary SPIR-V
    createShaderModule(desc.data, desc.dataSize, desc.debugName) :
    // textual GLSL
    createShaderModule(desc.stage, desc.data, desc.debugName);
  return {this, shaderModulesPool_.create(std::move(sm))};
}
  1. 从二进制 SPIR-V blob 创建 Vulkan 着色器模块的过程如下。为了简单起见,省略了错误检查:
VkShaderModule VulkanContext::createShaderModule(const void* data,
  size_t length, const char* debugName, Result* outResult) const {
  VkShaderModule vkShaderModule = VK_NULL_HANDLE;
  const VkShaderModuleCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = length,
      .pCode = (const uint32_t*)data,
  };
  vkCreateShaderModule(vkDevice_, &ci, nullptr, &vkShaderModule);
  return vkShaderModule;
}

现在,Vulkan 着色器模块已准备好在 Vulkan 管道中使用。让我们在下一个菜谱中学习如何这样做。

初始化 Vulkan 管道

Vulkan 管道是抽象图形管道的实现,它是一系列操作,用于转换顶点并光栅化生成的图像。本质上,这个想法与“冻结”的 OpenGL 状态的单一快照类似。Vulkan 管道基本上是不可变的,这意味着应该创建多个 Vulkan 管道以允许不同的数据路径通过图形管道。在这个菜谱中,我们将学习如何创建一个适合渲染彩色三角形的 Vulkan 管道,并探索如何将低级和冗长的 Vulkan 封装到简单的通用接口中。

准备工作

要获取有关 Vulkan 管道的所有基本信息,我们建议阅读 Pawel Lapinski 的Vulkan Cookbook,由 Packt 出版,或者 Alexander Overvoorde 的Vulkan Tutorial系列:vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipeline_basics/Introduction

要获取有关描述符集布局的更多信息,请查看vulkan-tutorial.com/Uniform_buffers/Descriptor_layout_and_buffer章节。

Vulkan 管道需要 Vulkan 着色器模块。在进入这个菜谱之前,请查看之前的菜谱,初始化 Vulkan 着色器模块

如何操作...

让我们深入了解如何创建和配置适合我们应用程序的 Vulkan 管道。由于 Vulkan API 的极端冗长,这个菜谱将是最长的。我们将从我们的演示应用程序中的高级代码开始,即Chapter02/02_HelloTriangle,然后深入到LightweightVK的内部。在随后的章节中,我们将更详细地介绍,例如动态状态、多采样、顶点输入等。

让我们看看 Chapter02/02_HelloTriangle 的初始化和主循环:

  1. 首先,我们创建一个 Vulkan 上下文,如前几道菜谱中所述:
std::unique_ptr<lvk::IContext> ctx =
  lvk::createVulkanContextWithSwapchain(window, width, height, {});
  1. 然后,我们需要创建一个渲染管线。LightweightVK 使用不透明的句柄来处理资源,因此在这里,lvk::RenderPipelineHandle 是一个不透明的句柄,它管理着一组 VkPipeline 对象,而 lvk::Holder 是一个 RAII 包装器,用于自动处理超出作用域的句柄。createRenderPipeline() 方法接受一个结构体,RenderPipelineDesc,它包含配置渲染管线所需的数据。对于我们的第一个三角形演示,我们希望尽可能简约,因此我们设置了顶点和片段着色器,并定义了颜色附加的格式。这是我们渲染到交换链图像所需的最小数据集:
lvk::Holder<lvk::RenderPipelineHandle> rpTriangle =
  ctx->createRenderPipeline({
    .smVert = ctx->createShaderModule(
      { codeVS, lvk::Stage_Vert, “Shader Module: vert” }).release(),
    .smFrag = ctx->createShaderModule(
      { codeFS, lvk::Stage_Frag, “Shader Module: frag” }).release(),
    .color  = { { .format = ctx->getSwapchainFormat() } },
});
  1. 在主循环内部,我们获取一个命令缓冲区,如 使用 Vulkan 命令缓冲区 菜谱中所述,并发出一些绘图命令:
while (!glfwWindowShouldClose(window)) {
  glfwPollEvents();
  glfwGetFramebufferSize(window, &width, &height);
  if (!width || !height) continue;
  lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
  1. 成员函数 cmdBeginRendering() 包装了 Vulkan 1.3 的动态渲染功能,它允许直接将渲染输出到 Vulkan 图像,而不需要显式创建任何渲染通道或帧缓冲区对象。它接受一个渲染通道的描述,lvk::RenderPass,和一个帧缓冲区的描述,lvk::Framebuffer。我们将在后续章节中更详细地探讨它。在这里,我们使用当前交换链纹理作为第一个颜色附加,并在渲染前将其清除为白色,使用附加加载操作 LoadOp_Clear,这对应于 Vulkan 中的 VK_ATTACHMENT_LOAD_OP_CLEAR。存储操作默认设置为 StoreOp_Store
 buf.cmdBeginRendering(
    {.color = {{ .loadOp = LoadOp_Clear, .clearColor = {1,1,1,1}}}},
    {.color = {{ .texture = ctx->getCurrentSwapchainTexture() }}});
  1. 可以在一行中将渲染管线绑定到命令缓冲区。然后,我们可以发出一个绘图命令,cmdDraw(),这是 vkCmdDraw() 之上的包装器。你可能已经注意到我们没有使用任何索引或顶点缓冲区。当我们查看 GLSL 着色器时,我们将会看到原因。命令 cmdEndRendering() 对应于 Vulkan 1.3 中的 vkCmdEndRendering()
 buf.cmdBindRenderPipeline(rpTriangle);
  buf.cmdDraw(lvk::Primitive_Triangle, 0, 3);
  buf.cmdEndRendering();
  ctx->submit(buf, ctx->getCurrentSwapchainTexture());
}

让我们看看 GLSL 着色器:

  1. 由于我们没有提供任何顶点输入,顶点着色器必须为三角形生成顶点数据。我们使用内置变量 gl_VertexIndex,它为每个后续顶点自动递增,并返回硬编码的位置和顶点颜色值:
#version 460
layout (location=0) out vec3 color;
const vec2 pos[3] = vec23, vec2(0.6, -0.4), vec2(0.0, 0.6) );
const vec3 col[3] = vec33, vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, 1.0) );
void main() {
  gl_Position = vec4(pos[gl_VertexIndex], 0.0, 1.0);
  color = col[gl_VertexIndex];
}
  1. 片段着色器很简单,只是输出插值后的颜色:
#version 460
layout (location=0) in vec3 color;
layout (location=0) out vec4 out_FragColor;
void main() {
  out_FragColor = vec4(color, 1.0);
}

应用程序应该渲染一个彩色三角形,如图所示。

图 2.2:你好,三角形

图 2.2:你好,三角形

我们学习了如何使用 LightweightVK 使用 Vulkan 绘制三角形。现在是时候揭开盖子,看看这个高级渲染管线管理接口是如何通过 Vulkan 实现的。

它是如何工作的…

要探索底层的 Vulkan 实现,我们必须一层层地剥开。当我们想在应用程序中创建图形管道时,我们调用成员函数 IContext::createRenderPipeline(),该函数在 VulkanContext 中实现。这个函数接收一个结构体,lvk::RenderPipelineDesc,它描述了我们的渲染管道。让我们更仔细地看看它。

  1. 该结构体包含创建有效的图形 VkPipeline 对象所需的信息子集:
struct RenderPipelineDesc final {
  VertexInput vertexInput;
  ShaderModuleHandle smVert;
  ShaderModuleHandle smGeom;
  ShaderModuleHandle smFrag;
  const char* entryPointVert = “main”;
  const char* entryPointFrag = “main”;
  const char* entryPointGeom = “main”;
  1. 颜色附件的最大数量设置为 4。我们在这里不存储使用的附件数量。相反,我们使用一个辅助函数来计算我们实际上有多少个附件:
 ColorAttachment color[LVK_MAX_COLOR_ATTACHMENTS] = {};
  uint32_t getNumColorAttachments() const {
    uint32_t n = 0;
    while (n < LVK_MAX_COLOR_ATTACHMENTS &&       color[n].format != Format_Invalid) n++;
    return n;
  }
  1. 其他成员字段代表一个典型的渲染状态,包括剔除模式、面顺时针方向、多边形模式等。
 Format depthFormat = Format_Invalid;
  Format stencilFormat = Format_Invalid;
  CullMode cullMode = lvk::CullMode_None;
  WindingMode frontFaceWinding = lvk::WindingMode_CCW;
  PolygonMode polygonMode = lvk::PolygonMode_Fill;
  StencilState backFaceStencil = {};
  StencilState frontFaceStencil = {};
  uint32_t samplesCount = 1u;
  const char* debugName = ““;
};

当我们调用 VulkanContext::createRenderPipeline() 时,它所做的只是对 RenderPipelineDesc 进行一些合理性检查,并将所有值存储在 RenderPipelineState 结构体中。正如我们之前提到的,LightweightVK 管道不能直接一对一映射到 VkPipeline 对象。这样做的原因是 RenderPipelineDesc 提供的状态比未扩展的 Vulkan 1.3 支持的更动态。例如,LightweightVK 自动管理描述符集布局。Vulkan 需要为管道对象指定一个描述符集布局。为了克服这个限制,存储在 RenderPipelineState 中的数据用于在函数 VulkanContext::getVkPipeline() 中延迟创建实际的 VkPipeline 对象。让我们看看这个机制。为了简化理解,省略了错误检查和一些不重要的细节:

  1. 构造函数需要 VulkanContextRenderPipelineDesc。它做一些准备工作但不会创建实际的 VkPipeline 对象。我们很快就会查看其实现:
class RenderPipelineState final {
  RenderPipelineDesc desc_;
  uint32_t numBindings_ = 0;
  uint32_t numAttributes_ = 0;
  1. 预缓存一些有用的值,这样我们就不必每次创建新的 Vulkan 管道对象时都重新初始化它们:
 VkVertexInputBindingDescription
    vkBindings_[VertexInput::LVK_VERTEX_BUFFER_MAX] = {};
  VkVertexInputAttributeDescription
    vkAttributes_[VertexInput::LVK_VERTEX_ATTRIBUTES_MAX] = {};
  VkDescriptorSetLayout lastVkDescriptorSetLayout_ = VK_NULL_HANDLE;
  VkShaderStageFlags shaderStageFlags_ = 0;
  VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE;
  VkPipeline pipeline_ = VK_NULL_HANDLE;
};

在所有数据结构就绪后,我们现在可以查看 VulkanContext::createRenderPipeline() 的实现代码:

  1. 构造函数遍历顶点输入属性,并将所有必要的数据预先缓存到 Vulkan 结构体中,以供后续使用:
VulkanContext::createRenderPipeline(
  const RenderPipelineDesc& desc, Result* outResult)
{
  const bool hasColorAttachments = desc.getNumColorAttachments() > 0;
  const bool hasDepthAttachment = desc.depthFormat != Format_Invalid;
  const bool hasAnyAttachments =
    hasColorAttachments || hasDepthAttachment;
  if (!LVK_VERIFY(hasAnyAttachments)) return {};
  if (!LVK_VERIFY(desc.smVert.valid())) return {};
  if (!LVK_VERIFY(desc.smFrag.valid())) return {};
  RenderPipelineState rps = {.desc_ = desc};
  1. 遍历并缓存顶点输入绑定和属性。顶点缓冲区绑定在 bufferAlreadyBound 中跟踪。其他一切都是从我们的高级数据结构到 Vulkan 的非常简单的转换代码:
 const lvk::VertexInput& vstate = rps.desc_.vertexInput;
  bool bufferAlreadyBound[VertexInput::LVK_VERTEX_BUFFER_MAX] = {};
  rps.numAttributes_ = vstate.getNumAttributes();
  for (uint32_t i = 0; i != rps.numAttributes_; i++) {
    const auto& attr = vstate.attributes[i];
    rps.vkAttributes_[i] = { .location = attr.location,
                             .binding = attr.binding,
                             .format =
                               vertexFormatToVkFormat(attr.format),
                             .offset = (uint32_t)attr.offset };
    if (!bufferAlreadyBound[attr.binding]) {
      bufferAlreadyBound[attr.binding] = true;
      rps.vkBindings_[rps.numBindings_++] = {
        .binding = attr.binding,
        .stride = vstate.inputBindings[attr.binding].stride,
        .inputRate = VK_VERTEX_INPUT_RATE_VERTEX };
    }
  }
  return {this, renderPipelinesPool_.create(std::move(rps))};
}

现在,我们可以创建实际的 Vulkan 管道。嗯,几乎是这样。一些非常长的代码片段在等待我们。这些是整本书中最长的函数,但我们至少要过一遍。话虽如此,错误检查被省略以简化事情:

  1. getVkPipeline() 函数检索与提供的管道句柄关联的 RenderPipelineState 结构体:
VkPipeline VulkanContext::getVkPipeline(RenderPipelineHandle handle)
{
  lvk::RenderPipelineState* rps = renderPipelinesPool_.get(handle);
  if (!rps) return VK_NULL_HANDLE;
  1. 然后,我们检查用于为这个VkPipeline对象创建管线布局的描述符集布局是否已更改。我们的实现使用描述符索引来管理一个巨大的描述符集中的所有纹理,并创建一个描述符集布局来存储所有纹理。一旦新纹理被加载,可能没有足够的空间来存储它们,就必须创建一个新的描述符集布局。每次发生这种情况时,我们必须删除旧的VkPipelineVkPipelineLayout对象,并创建新的:
 if (rps->lastVkDescriptorSetLayout_ != vkDSL_) {
    deferredTask(std::packaged_task<void()>(
      [device = getVkDevice(), pipeline = rps->pipeline_]() {
        vkDestroyPipeline(device, pipeline, nullptr); }));
    deferredTask(std::packaged_task<void()>(
      [device = getVkDevice(), layout = rps->pipelineLayout_]() {
        vkDestroyPipelineLayout(device, layout, nullptr); }));
    rps->pipeline_ = VK_NULL_HANDLE;
    rps->lastVkDescriptorSetLayout_ = vkDSL_;
  }
  1. 如果已经存在一个与当前描述符集布局兼容的有效图形管线,我们只需返回它:
 if (rps->pipeline_ != VK_NULL_HANDLE) {
    return rps->pipeline_;
  }
  1. 让我们准备构建一个新的 Vulkan 管线对象。并非所有颜色附件都是有效的。我们只需要为活动的颜色附件创建颜色混合附件。辅助函数,如formatToVkFormat(),将LightweightVK枚举转换为 Vulkan:
 VkPipelineLayout layout = VK_NULL_HANDLE;
  VkPipeline pipeline = VK_NULL_HANDLE;
  const RenderPipelineDesc& desc = rps->desc_;
  const uint32_t numColorAttachments = desc_.getNumColorAttachments();
  VkPipelineColorBlendAttachmentState
    colorBlendAttachmentStates[LVK_MAX_COLOR_ATTACHMENTS] = {};
  VkFormat colorAttachmentFormats[LVK_MAX_COLOR_ATTACHMENTS] = {};
  for (uint32_t i = 0; i != numColorAttachments; i++) {
    const auto& attachment = desc_.color[i];
    colorAttachmentFormats[i] = formatToVkFormat(attachment.format);
  1. 设置颜色附件的混合状态既繁琐又简单:
 if (!attachment.blendEnabled) {
      colorBlendAttachmentStates[i] =
        VkPipelineColorBlendAttachmentState{
          .blendEnable = VK_FALSE,
          .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
          .dstColorBlendFactor = VK_BLEND_FACTOR_ZERO,
          .colorBlendOp = VK_BLEND_OP_ADD,
          .srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE,
          .dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO,
          .alphaBlendOp = VK_BLEND_OP_ADD,
          .colorWriteMask = VK_COLOR_COMPONENT_R_BIT |
                            VK_COLOR_COMPONENT_G_BIT |
                            VK_COLOR_COMPONENT_B_BIT |
                            VK_COLOR_COMPONENT_A_BIT,
      };
    } else {
      colorBlendAttachmentStates[i] =
        VkPipelineColorBlendAttachmentState{
          .blendEnable = VK_TRUE,
          .srcColorBlendFactor = blendFactorToVkBlendFactor(
            attachment.srcRGBBlendFactor),
          .dstColorBlendFactor = blendFactorToVkBlendFactor(
            attachment.dstRGBBlendFactor),
          .colorBlendOp = blendOpToVkBlendOp(attachment.rgbBlendOp),
          .srcAlphaBlendFactor = blendFactorToVkBlendFactor(
            attachment.srcAlphaBlendFactor),
          .dstAlphaBlendFactor = blendFactorToVkBlendFactor(
            attachment.dstAlphaBlendFactor),
          .alphaBlendOp = blendOpToVkBlendOp(attachment.alphaBlendOp),
          .colorWriteMask = VK_COLOR_COMPONENT_R_BIT |
                            VK_COLOR_COMPONENT_G_BIT | 
                            VK_COLOR_COMPONENT_B_BIT |
                            VK_COLOR_COMPONENT_A_BIT,
      };
    }
  }
  1. 使用不透明的句柄从池中检索VkShaderModule对象。我们将在下一章讨论池的工作原理。在这里,我们只需要知道它们允许快速将整数句柄转换为与之关联的实际数据。几何着色器是可选的:
 const VkShaderModule* vert =
    ctx_->shaderModulesPool_.get(desc_.smVert);
  const VkShaderModule* geom =
    ctx_->shaderModulesPool_.get(desc_.smGeom);
  const VkShaderModule* frag =
    ctx_->shaderModulesPool_.get(desc_.smFrag);
  1. 准备一个VkSpecializationInfo结构来描述此图形管线的专用常量:
 VkSpecializationMapEntry entries[
    SpecializationConstantDesc::LVK_SPECIALIZATION_CONSTANTS_MAX] ={};
  const VkSpecializationInfo si =
    lvk::getPipelineShaderStageSpecializationInfo(
      desc.specInfo, entries);
  1. 为此管线创建一个合适的VkPipelineLayout对象。使用存储在VulkanContext中的当前描述符集布局。在这里,一个描述符集布局vkDSL_被复制多次以创建管线布局。这是必要的,以确保与 MoltenVK 的兼容性,MoltenVK 不允许不同描述符类型的别名。推送常量大小从预编译的着色器模块中检索:
 const VkDescriptorSetLayout dsls[] =
      { vkDSL_, vkDSL_, vkDSL_, vkDSL_ };
    const VkPushConstantRange range = {
      .stageFlags = rps->shaderStageFlags_,
      .offset = 0,
      .size = pushConstantsSize,
    };
    const VkPipelineLayoutCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
      .setLayoutCount = (uint32_t)LVK_ARRAY_NUM_ELEMENTS(dsls),
      .pSetLayouts = dsls,
      .pushConstantRangeCount = pushConstantsSize ? 1u : 0u,
      .pPushConstantRanges = pushConstantsSize ? &range : nullptr,
  };
  VK_ASSERT(vkCreatePipelineLayout(vkDevice_, &ci, nullptr, &layout));

这里有一个片段,用于从着色器模块中检索推送常量大小:

#define UPDATE_PUSH_CONSTANT_SIZE(sm, bit) if (sm) { \
  pushConstantsSize = std::max(pushConstantsSize,    \
  sm->pushConstantsSize);                            \
  rps->shaderStageFlags_ |= bit; }
rps->shaderStageFlags_ = 0;
uint32_t pushConstantsSize = 0;
UPDATE_PUSH_CONSTANT_SIZE(vertModule, VK_SHADER_STAGE_VERTEX_BIT);
UPDATE_PUSH_CONSTANT_SIZE(tescModule,
  VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT);
UPDATE_PUSH_CONSTANT_SIZE(teseModule,
  VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT);
UPDATE_PUSH_CONSTANT_SIZE(geomModule, VK_SHADER_STAGE_GEOMETRY_BIT);
UPDATE_PUSH_CONSTANT_SIZE(fragModule, VK_SHADER_STAGE_FRAGMENT_BIT);
#undef UPDATE_PUSH_CONSTANT_SIZE
  1. 随着我们逐步剥离更多的实现层,这里还有另一个层级可以剥离。然而,这是最后一个层级。为了方便起见,实际VkPipeline对象的创建被封装进VulkanPipelineBuilder中,它为所有我们不希望设置的众多 Vulkan 数据成员提供了合理的默认值。熟悉 Java 的人会认出一种典型的Builder设计模式:
 lvk::vulkan::VulkanPipelineBuilder()
      // from Vulkan 1.0
      .dynamicState(VK_DYNAMIC_STATE_VIEWPORT)
      .dynamicState(VK_DYNAMIC_STATE_SCISSOR)
      .dynamicState(VK_DYNAMIC_STATE_DEPTH_BIAS)
      .dynamicState(VK_DYNAMIC_STATE_BLEND_CONSTANTS)
      // from Vulkan 1.3 
      .dynamicState(VK_DYNAMIC_STATE_DEPTH_TEST_ENABLE)
      .dynamicState(VK_DYNAMIC_STATE_DEPTH_WRITE_ENABLE)
      .dynamicState(VK_DYNAMIC_STATE_DEPTH_COMPARE_OP)
      .dynamicState(VK_DYNAMIC_STATE_DEPTH_BIAS_ENABLE)
      .primitiveTopology(dynamicState.getTopology())
      .depthBiasEnable(dynamicState.depthBiasEnable_)
      .depthCompareOp(dynamicState.getDepthCompareOp())
      .depthWriteEnable(dynamicState.depthWriteEnable_)
      .rasterizationSamples(
        getVulkanSampleCountFlags(desc_.samplesCount))
      .polygonMode(polygonModeToVkPolygonMode(desc_.polygonMode))
      .stencilStateOps(VK_STENCIL_FACE_FRONT_BIT,
        stencilOpToVkStencilOp(
          desc_.frontFaceStencil.stencilFailureOp),
        stencilOpToVkStencilOp(
          desc_.frontFaceStencil.depthStencilPassOp),
        stencilOpToVkStencilOp(
          desc_.frontFaceStencil.depthFailureOp),
        compareOpToVkCompareOp(
          desc_.frontFaceStencil.stencilCompareOp))
      .stencilStateOps(VK_STENCIL_FACE_BACK_BIT,
        stencilOpToVkStencilOp(
          desc_.backFaceStencil.stencilFailureOp),
        stencilOpToVkStencilOp(
          desc_.backFaceStencil.depthStencilPassOp),
        stencilOpToVkStencilOp(
          desc_.backFaceStencil.depthFailureOp),
        compareOpToVkCompareOp(
          desc_.backFaceStencil.stencilCompareOp))
      .stencilMasks(VK_STENCIL_FACE_FRONT_BIT, 0xFF,
        desc_.frontFaceStencil.writeMask,
        desc_.frontFaceStencil.readMask)
      .stencilMasks(VK_STENCIL_FACE_BACK_BIT, 0xFF,
        desc_.backFaceStencil.writeMask,
        desc_.backFaceStencil.readMask)
  1. 着色器模块逐个提供。只有顶点和片段着色器是必需的:
 .shaderStage(lvk::getPipelineShaderStageCreateInfo(
        VK_SHADER_STAGE_VERTEX_BIT,
        vertModule->sm, desc.entryPointVert, &si))
      .shaderStage(lvk::getPipelineShaderStageCreateInfo(
        VK_SHADER_STAGE_FRAGMENT_BIT,
        fragModule->sm, desc.entryPointFrag, &si))
      .shaderStage(geomModule ? lvk::getPipelineShaderStageCreateInfo(
        VK_SHADER_STAGE_GEOMETRY_BIT,
        geomModule->sm, desc.entryPointGeom, &si)
        : VkPipelineShaderStageCreateInfo{.module = VK_NULL_HANDLE})
      .cullMode(cullModeToVkCullMode(desc_.cullMode))
      .frontFace(windingModeToVkFrontFace(desc_.frontFaceWinding))
      .vertexInputState(vertexInputStateCreateInfo_)
      .colorBlendAttachmentStates(colorBlendAttachmentStates)
      .colorAttachmentFormats(colorAttachmentFormats)
      .depthAttachmentFormat(formatToVkFormat(desc_.depthFormat))
      .stencilAttachmentFormat(formatToVkFormat(desc_.stencilFormat))
  1. 最后,我们调用VulkanPipelineBuilder::build()方法,该方法创建一个VkPipeline对象,我们可以将其存储在我们的RenderPipelineState结构中,连同管线布局一起:
 .build(
        vkDevice_, pipelineCache_, layout, &pipeline, desc.debugName);
  rps->pipeline_ = pipeline;
  rps->pipelineLayout_ = layout;
  return pipeline;
}

我们在这里想要探索的最后一个方法是VulkanPipelineBuilder::build(),这是纯 Vulkan。让我们看看它以总结管线创建过程:

  1. 首先,我们将提供的动态状态放入VkPipelineDynamicStateCreateInfo
VkResult VulkanPipelineBuilder::build(VkDevice device,
                                      VkPipelineCache pipelineCache,
                                      VkPipelineLayout pipelineLayout,
                                      VkPipeline* outPipeline,
                                      const char* debugName)
{
  const VkPipelineDynamicStateCreateInfo dynamicState = {
      .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
      .dynamicStateCount = (uint32_t)dynamicStates_.size(),
      .pDynamicStates = dynamicStates_.data(),
  };
  1. Vulkan 规范说明,如果视口和裁剪状态是动态的,则视口和裁剪可以设置为nullptr。我们当然很高兴充分利用这个机会:
 const VkPipelineViewportStateCreateInfo viewportState = {
      .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
      .viewportCount = 1,
      .pViewports = nullptr,
      .scissorCount = 1,
      .pScissors = nullptr,
  };
  1. 使用我们在本菜谱中准备的颜色混合状态和附件:
 const VkPipelineColorBlendStateCreateInfo colorBlendState = {
      .sType =
        VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
      .logicOpEnable = VK_FALSE,
      .logicOp = VK_LOGIC_OP_COPY,
      .attachmentCount = uint32_t(colorBlendAttachmentStates_.size()),
      .pAttachments = colorBlendAttachmentStates_.data(),
  };
  const VkPipelineRenderingCreateInfo renderingInfo = {
      .sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR,
      .pNext = nullptr,
      .colorAttachmentCount =
        (uint32_t)colorAttachmentFormats_.size(),
      .pColorAttachmentFormats = colorAttachmentFormats_.data(),
      .depthAttachmentFormat = depthAttachmentFormat_,
      .stencilAttachmentFormat = stencilAttachmentFormat_,
  };
  1. 将所有内容组合到VkGraphicsPipelineCreateInfo中,并调用vkCreateGraphicsPipelines()
 const VkGraphicsPipelineCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
      .pNext = &renderingInfo,
      .flags = 0,
      .stageCount = (uint32_t)shaderStages_.size(),
      .pStages = shaderStages_.data(),
      .pVertexInputState = &vertexInputState_,
      .pInputAssemblyState = &inputAssembly_,
      .pTessellationState = nullptr,
      .pViewportState = &viewportState,
      .pRasterizationState = &rasterizationState_,
      .pMultisampleState = &multisampleState_,
      .pDepthStencilState = &depthStencilState_,
      .pColorBlendState = &colorBlendState,
      .pDynamicState = &dynamicState,
      .layout = pipelineLayout,
      .renderPass = VK_NULL_HANDLE,
      .subpass = 0,
      .basePipelineHandle = VK_NULL_HANDLE,
      .basePipelineIndex = -1,
  };
  const auto result = vkCreateGraphicsPipelines(
    device, pipelineCache, 1, &ci, nullptr, outPipeline);
  numPipelinesCreated_++;
}

这段代码完成了管道创建过程。除了非常简单的示例Chapter02/02_HelloTriangle之外,我们还创建了一个稍微复杂一些的应用程序,以展示如何使用多个渲染管道,通过使用 GLM 库进行矩阵数学,渲染一个带有线框覆盖的旋转立方体。在Chapter02/03_GLM中查看它,看看它是如何使用cmdPushConstants()来动画化立方体的。它应该看起来像下面的截图。

图 2.3:GLM 使用示例

图 2.3:GLM 使用示例

还有更多……

如果你熟悉 Vulkan 的旧版本,你可能会注意到,在这个菜谱中,我们完全省略了任何关于渲染通道的引用。它们也没有在任何数据结构中提及。这样做的原因是我们使用了 Vulkan 1.3 的动态渲染功能,这使得VkPipeline对象不需要渲染通道。

如果你想在没有使用*VK_KHR_dynamic_rendering*扩展的情况下为 Vulkan 的旧版本实现类似的包装器,你可以在VulkanContext内部数组的“全局”渲染通道集合中维护一个渲染通道的整数索引,并将相应的渲染通道的整数索引作为一个数据成员添加到RenderPipelineDynamicState中。由于我们只能使用非常有限数量的不同渲染通道——比如说最多 256 个——索引可以保存为uint8_t。这将使哈希键保持在uint32_t大小内。

如果你想探索这个方法的实际工作实现,请查看 Meta 的 IGL 库在github.com/facebook/igl/blob/main/src/igl/vulkan/RenderPipelineState.h,并查看那里是如何处理renderPassIndex的。

现在,让我们跳到下一章,与 Vulkan 对象一起工作,学习如何以用户友好的方式使用 Vulkan 构建更有趣的示例。

第四章:3 使用 Vulkan 对象

加入我们的 Discord 书籍社区

图片

packt.link/unitydev

在上一章中,我们学习了如何使用 Vulkan 在屏幕上显示我们的第一个三角形。让我们继续前进,学习如何处理纹理和缓冲区来构建一个现代的 Vulkan 包装器。本章的食谱将不仅关注图形 API,还将涵盖提高图形应用程序开发和各种 3D 图形算法所必需的各种技巧和窍门。在 Vulkan 方面,我们将涵盖使其运行的基本内容。底层 Vulkan 实现基于 LightweightVK 库 (github.com/corporateshark/lightweightvk)。

在本章中,我们将涵盖以下食谱:

  • 在 Vulkan 中处理缓冲区

  • 实现阶段缓冲区

  • 在 Vulkan 中使用纹理数据

  • 存储 Vulkan 对象

  • 使用 Vulkan 描述符索引

技术要求

要运行本章的食谱,您必须使用配备支持 Vulkan 1.3 的显卡和驱动程序的 Windows 或 Linux 计算机。阅读上一章,第二章,Vulkan 入门,以了解开始使用 Vulkan 所必需的基本知识。

在 Vulkan 中处理缓冲区

Vulkan 中的缓冲区本质上是指持任意数据且能够被 GPU 访问的内存区域。更准确地说,Vulkan 缓冲区指的是与内存区域 VkDeviceMemory 相连的元数据 VkBuffer。要使用 Vulkan API 渲染 3D 场景,我们必须将场景数据转换为适合 GPU 的格式。在本食谱中,我们将描述如何创建 GPU 缓冲区并将顶点数据上传到其中。我们将使用开源的资产加载库 Assimp (github.com/assimp/assimp) 从 .obj 文件中加载 3D 模型,并使用 LightweightVK 和 Vulkan 进行渲染。此外,食谱还涵盖了 Vulkan 内存分配器 (VMA) 库的一些基本用法。

准备工作

将数据上传到 GPU 缓冲区是像任何其他 Vulkan 操作一样通过命令缓冲区执行的运算。这意味着我们需要一个能够执行传输操作的命令队列。命令缓冲区的创建和使用在上一章的 使用 Vulkan 命令缓冲区 食谱中已有介绍。

如何做到...

让我们从我们的采样应用程序中的高级代码开始,Chapter03/01_Assimp,并从那里探索到 Vulkan API:

  1. 首先,我们需要借助 Assimp 库从 .obj 文件中加载我们的模型。以下是一些基本的代码来完成这项工作。不要忘记,为了更好的性能,可以在向量上调用 reserve()。为了简单起见,我们只加载第一个网格,并只读取顶点位置和索引。
 const aiScene* scene = aiImportFile(
    “data/rubber_duck/scene.gltf”, aiProcess_Triangulate);
  const aiMesh* mesh = scene->mMeshes[0];
  std::vector<vec3> positions;
  std::vector<uint32_t> indices;
  positions.reserve(mesh->mNumVertices);
  indices.reserve(3 * mesh->mNumFaces);
  for (unsigned int i = 0; i != mesh->mNumVertices; i++) {
    const aiVector3D v = mesh->mVertices[i];
    positions.push_back(vec3(v.x, v.y, v.z));
  }
  for (unsigned int i = 0; i != mesh->mNumFaces; i++) {
    for (int j = 0; j != 3; j++)
      indices.push_back(mesh->mFaces[i].mIndices[j]);
  }
  aiReleaseImport(scene);
  1. 现在我们必须为加载的顶点位置和索引创建缓冲区。我们的顶点缓冲区将具有使用标志 BufferUsageBits_Vertex。我们要求 LightweightVKpositions.data() 开始上传初始缓冲区数据。C++20 的指定初始化器语法对于这种高级 API 非常方便。索引缓冲区有一个相应的使用标志 BufferUsageBits_Index。这两个缓冲区都存储在 GPU 内存中以提高性能。这通过指定存储类型 StorageType_Device 来确保,它在 LightweightVK 中被解析以选择这些缓冲区的适当 Vulkan 内存类型。
 Holder<lvk::BufferHandle> vertexBuffer = ctx->createBuffer(
      { .usage     = lvk::BufferUsageBits_Vertex,
        .storage   = lvk::StorageType_Device,
        .size      = sizeof(vec3) * positions.size(),
        .data      = positions.data(),
        .debugName = “Buffer: vertex” }, nullptr);
  Holder<lvk::BufferHandle> indexBuffer = ctx->createBuffer(
      { .usage     = lvk::BufferUsageBits_Index,
        .storage   = lvk::StorageType_Device,
        .size      = sizeof(uint32_t) * indices.size(),
        .data      = indices.data(),
        .debugName = “Buffer: index” }, nullptr);
  1. 要使用 Vulkan 渲染复杂的凹面网格,我们必须使用深度缓冲区。我们需要自己创建一个,如下所示。在这里我们指定 Format_Z_F32,但底层的 LightweightVK Vulkan 后端会将其替换为当前 Vulkan 实现上实际可用的最接近的格式。widthheight 的值对应于帧缓冲区的尺寸。我们只将深度纹理用作深度缓冲区,不会从中采样,这意味着指定使用标志为 TextureUsageBits_Attachment 就足够了。
 Holder<lvk::TextureHandle> depthTexture = ctx->createTexture({
        .type       = lvk::TextureType_2D,
        .format     = lvk::Format_Z_F32,
        .dimensions = {(uint32_t)width, (uint32_t)height},
        .usage      = lvk::TextureUsageBits_Attachment,
        .debugName  = “Depth buffer”,
    });
  1. 在我们继续创建渲染管线之前,正如在上一章的食谱“初始化 Vulkan 管线”中所描述的,我们必须使用上述顶点缓冲区为它们指定顶点输入状态。以下是我们可以这样做的方法。在这里,.location = 0 对应于 GLSL 顶点着色器中的输入位置,它将渲染此网格。
 const lvk::VertexInput vdesc = {
    .attributes    = { { .location = 0, 
                         .format = lvk::VertexFormat::Float3 } },
    .inputBindings = { { .stride = sizeof(vec3) } },
  };
  1. 现在我们可以创建两个渲染管线。第一个用于渲染实体网格。另一个将在其上方渲染线框网格。注意,.depthFormat 设置为我们之前创建的深度纹理的格式。
 Holder<lvk::ShaderModuleHandle> vert =
    loadShaderModule(ctx, “Chapter03/01_Assimp/src/main.vert”);
  Holder<lvk::ShaderModuleHandle> frag =
    loadShaderModule(ctx, “Chapter03/01_Assimp/src/main.frag”);
  Holder<lvk::RenderPipelineHandle> pipelineSolid =
    ctx->createRenderPipeline({
      .vertexInput = vdesc,
      .smVert      = vert,
      .smFrag      = frag,
      .color       = { { .format = ctx->getSwapchainFormat() } },
      .depthFormat = ctx->getFormat(depthTexture),
      .cullMode    = lvk::CullMode_Back,
  });
  1. 第二个渲染管线通过将 .polygonMode 字段设置为 PolygonMode_Line 来执行线框渲染。两个管线使用相同的着色器集。使用一个专用常量来改变着色器的行为。
 const uint32_t isWireframe = 1;
  Holder<lvk::RenderPipelineHandle> pipelineWireframe =
    ctx->createRenderPipeline({
      .vertexInput = vdesc,
      .smVert      = vert,
      .smFrag      = frag,
      .specInfo = { .entries = { { .constantId = 0,
                                   .size = sizeof(uint32_t) } },
        .data = &isWireframe, .dataSize = sizeof(isWireframe) },
      .color       = { { .format = ctx->getSwapchainFormat() } },
      .depthFormat = ctx->getFormat(depthTexture),
      .cullMode    = lvk::CullMode_Back,
      .polygonMode = lvk::PolygonMode_Line,
  });
  1. 在我们可以进入主循环之前,我们需要定义一个深度状态。深度状态启用深度缓冲区写入并设置适当的深度比较运算符。
 const lvk::DepthState dState = {
    .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true   };

现在我们可以看看应用程序的主循环。这里我们跳过了 GLFW 事件拉取和帧缓冲区大小更新代码。你可以在 Chapter03/01_Assimp/src/main.cpp 中找到它们:

  1. 主循环根据当前帧缓冲区宽高比更新投影矩阵 p
 while (!glfwWindowShouldClose(window)) {
    …
    const float ratio = width / height;
    const mat4 p = glm::perspective(45.0f, ratio, 0.1f, 1000.0f);
  1. 设置模型视图矩阵以启用模型绕垂直轴的逐渐旋转。模型矩阵 m 负责将模型的“向上”方向与 Vulkan 中的垂直轴对齐。视图矩阵 v 负责我们的 3D 相机方向和观察方向,它围绕垂直轴 Y 缓慢旋转。
 const mat4 m = glm::rotate(
      mat4(1.0f), glm::radians(-90.0f), vec3(1, 0, 0));
    const mat4 v = glm::rotate(glm::translate(mat4(1.0f),
      vec3(0.0f, -0.5f, -1.5f)), (float)glfwGetTime(),
      vec3(0.0f, 1.0f, 0.0f));
  1. 现在渲染通道需要指定深度缓冲区的加载操作和清除值。帧缓冲区只有一个颜色附件 - 当前的交换链图像。
 const lvk::RenderPass renderPass = {
      .color = {
        { .loadOp = LoadOp_Clear, .clearColor = { 1., 1., 1., 1\. }}
      },
      .depth = { .loadOp = LoadOp_Clear, .clearDepth = 1\. }
    };
    const lvk::Framebuffer framebuffer = {
      .color = { { .texture = ctx->getCurrentSwapchainTexture() } },
      .depthStencil = { .texture = depthTexture },
    };
  1. 准备工作完成后,我们可以按照使用 Vulkan 命令缓冲区的说明获取命令缓冲区,并开始渲染。大括号用于强调范围。
 lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
    buf.cmdBeginRendering(renderPass, framebuffer);
  1. 顶点缓冲区和索引缓冲区都应该被绑定。顶点缓冲区绑定到绑定点0。索引缓冲区使用无符号 32 位整数作为索引。
 buf.cmdBindVertexBuffer(0, vertexBuffer);
    buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
  1. 让我们使用第一个渲染管线和深度状态渲染一个实体网格。模型视图投影矩阵通过 Vulkan 推送常数发送到着色器。推送常数是一种高效机制,用于将非常少量的数据传递给着色器。Vulkan 1.3 保证至少有128字节的推送常数,这足以存储2个 4x4 矩阵或16个任意的 64 位 GPU 缓冲区地址。
 buf.cmdBindRenderPipeline(pipelineSolid);
    buf.cmdBindDepthState(dState);
    buf.cmdPushConstants(p * v * m);
    buf.cmdDrawIndexed(lvk::Primitive_Triangle, indices.size());
  1. 然后,我们在实体网格的上方渲染网格的线框副本。我们设置深度偏移,以便正确且无闪烁地渲染线框边缘。
 buf.cmdBindRenderPipeline(pipelineWireframe);
    buf.cmdSetDepthBias(0.0f, -1.0f, 0.0f);
    buf.cmdDrawIndexed(lvk::Primitive_Triangle, indices.size());
  1. 现在可以将命令缓冲区提交以执行。
 buf.cmdEndRendering();
    ctx->submit(buf, ctx->getCurrentSwapchainTexture());

演示应用程序应该渲染一个带有线框覆盖的彩色旋转鸭,如下面的截图所示。

图 3.1:使用 Assimp 加载的网格渲染

图 3.1:使用 Assimp 加载的网格渲染

高级部分很容易。现在让我们深入到底层实现,学习如何使用 Vulkan API 实现这个精简的缓冲区管理接口。

它是如何工作的...

让我们看看低级 Vulkan 代码,以了解缓冲区是如何工作的。我们的深入探索从IContext::createBuffer()开始,它接受一个缓冲区描述结构BufferDesc作为输入:

  1. BufferDesc的声明如下。
struct BufferDesc final {
  uint8_t usage = 0;
  StorageType storage = StorageType_HostVisible;
  size_t size = 0;
  const void* data = nullptr;
  const char* debugName = ““;
};

存储类型可以是以下三个枚举值之一:StorageType_DeviceStorageType_HostVisible。它们对应于 GPU 本地内存——从 CPU 端不可见——和主机可见内存。实际的 Vulkan 内存类型由底层的LightweightVK代码和VulkanMemoryAllocatorVMA)库更精确地选择。

  1. 缓冲区使用模式是以下标志的组合。这些标志非常灵活,我们可以请求任何必要的组合,除了统一和存储缓冲区是互斥的。
enum BufferUsageBits : uint8_t {
  BufferUsageBits_Index = 1 << 0,
  BufferUsageBits_Vertex = 1 << 1,
  BufferUsageBits_Uniform = 1 << 2,
  BufferUsageBits_Storage = 1 << 3,
  BufferUsageBits_Indirect = 1 << 4,
};

现在让我们看看VulkanContext::createBuffer()的实现,它将请求的 LightweightVK 缓冲区属性转换为相应的支持 Vulkan 标志:

  1. 在所有其他事情之前,我们应该检查是否应该使用一个阶段缓冲区来将数据上传到这个新的缓冲区。例如,如果阶段缓冲区被禁用,因为我们的 GPU 只有一个既是主机可见又是设备本地的共享内存堆,我们将使用StorageType_HostVisible覆盖请求的设备本地存储模式。这对于消除具有这种内存配置的 GPU 上的额外复制非常重要。
Holder<BufferHandle> VulkanContext::createBuffer(
  const BufferDesc& requestedDesc, Result* outResult) {
  BufferDesc desc = requestedDesc;
  if (!useStaging_ && (desc.storage == StorageType_Device))
    desc.storage = StorageType_HostVisible;
  1. 如果应用程序想要设备本地缓冲区,我们应该使用一个阶段缓冲区将数据传输到我们的设备本地缓冲区。设置相应的 Vulkan 标志以确保我们可以传输到和从这个缓冲区。
 VkBufferUsageFlags usageFlags = desc.storage == StorageType_Device ?
    VK_BUFFER_USAGE_TRANSFER_DST_BIT |
    VK_BUFFER_USAGE_TRANSFER_SRC_BIT : 0;
  1. 对于每个请求的使用标志,启用一组必要的特定 Vulkan 使用标志。要使用缓冲区设备地址功能并通过指针从着色器访问缓冲区,我们应该添加标志 VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT
 if (desc.usage & BufferUsageBits_Index)
    usageFlags |= VK_BUFFER_USAGE_INDEX_BUFFER_BIT;
  if (desc.usage & BufferUsageBits_Vertex)
    usageFlags |= VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
  if (desc.usage & BufferUsageBits_Uniform)
    usageFlags |= VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT |
                  VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT;
  if (desc.usage & BufferUsageBits_Storage)
    usageFlags |= VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
                  VK_BUFFER_USAGE_TRANSFER_DST_BIT |
                  VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT;
  if (desc.usage & BufferUsageBits_Indirect)
    usageFlags |= VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT |
                  VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT;
  1. 使用辅助函数获取所需的 Vulkan 内存属性,然后调用 VulkanContext::createBuffer() 的另一个变体,该变体只接受 Vulkan 标志。委托很有用,因为这个函数在 LightweightVK Vulkan 后端内部使用来创建内部辅助缓冲区。这个函数检查缓冲区大小限制并在相应的池中创建 VulkanBuffer 对象。
 const VkMemoryPropertyFlags memFlags =
    storageTypeToVkMemoryPropertyFlags(desc.storage);
  Result result;
  BufferHandle handle = createBuffer(
    desc.size, usageFlags, memFlags, &result, desc.debugName);
  1. 如果提供了初始缓冲区数据,则立即上传。
 if (desc.data) {
    upload(handle, desc.data, desc.size, 0);
  }
  Result::setResult(outResult, Result());
  return {this, handle};
}

让我们看看 VulkanBuffer 的接口,它封装了 Vulkan 缓冲区管理功能。

  1. 所有之前获得的 Vulkan 标志都传递给类构造函数。默认构造函数很简单,使 VulkanBuffer 能够存储在 LightweightVK 对象池中。我们将在后续章节中讨论这些池。
class VulkanBuffer final {
 public:
  VulkanBuffer() = default;
  VulkanBuffer(lvk::VulkanContext* ctx,
               VkDevice device,
               VkDeviceSize bufferSize,
               VkBufferUsageFlags usageFlags,
               VkMemoryPropertyFlags memFlags,
               const char* debugName = nullptr);
  ~VulkanBuffer();
  1. 一系列方法用于从主机获取数据到缓冲区和从缓冲区获取数据。所有主机可见的缓冲区都自动映射,以便我们可以通过正常的 C++ 指针访问其数据。flushMappedMemory() 函数在系统上的缓冲区不支持一致性内存时是必要的。当启用 Vulkan Memory Allocator (VMA) 库时,该函数调用 vkFlushMappedMemoryRanges()vmaFlushAllocation()
 void bufferSubData(size_t offset, size_t size, const void* data);
  void getBufferSubData(size_t offset, size_t size, void* data);
  [[nodiscard]] uint8_t* getMappedPtr() const {
    return static_cast<uint8_t*>(mappedPtr_);
  }
  bool isMapped() const { return mappedPtr_ != nullptr; }
  void flushMappedMemory(VkDeviceSize offset, VkDeviceSize size);
  1. 数据成员封装了与底层 Vulkan 缓冲区管理代码相关的所有必要内容。只有在启用 VMA 库时才使用 VMA 相关字段。
 lvk::VulkanContext* ctx_ = nullptr;
  VkDevice device_ = VK_NULL_HANDLE;
  VkBuffer vkBuffer_ = VK_NULL_HANDLE;
  VkDeviceMemory vkMemory_ = VK_NULL_HANDLE;
  VmaAllocationCreateInfo vmaAllocInfo_ = {};
  VmaAllocation vmaAllocation_ = VK_NULL_HANDLE;
  VkDeviceAddress vkDeviceAddress_ = 0;
  VkDeviceSize bufferSize_ = 0;
  VkBufferUsageFlags vkUsageFlags_ = 0;
  VkMemoryPropertyFlags vkMemFlags_ = 0;
  void* mappedPtr_ = nullptr;
};

现在我们已经准备好创建实际的 Vulkan 缓冲区对象。让我们看看代码。为了更好地理解,省略了错误处理:

  1. 构造函数参数直接用于填充 VkBufferCreateInfo 结构。
lvk::VulkanBuffer::VulkanBuffer(lvk::VulkanContext* ctx,
                                VkDevice device,
                                VkDeviceSize bufferSize,
                                VkBufferUsageFlags usageFlags,
                                VkMemoryPropertyFlags memFlags,
                                const char* debugName) :
  ctx_(ctx), device_(device), bufferSize_(bufferSize),
  vkUsageFlags_(usageFlags), vkMemFlags_(memFlags)
{
  const VkBufferCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
      .pNext = nullptr,
      .flags = 0,
      .size = bufferSize,
      .usage = usageFlags,
      .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
      .queueFamilyIndexCount = 0,
      .pQueueFamilyIndices = nullptr,
  };
  1. 现在我们决定是直接使用 Vulkan 还是让 Vulkan Memory Allocator 为我们做所有的内存分配。VMA 是主要代码路径,而直接 Vulkan 调用在必要时进行调试很有帮助。在 VMA 的情况下,我们再次转换标志。
 if (LVK_VULKAN_USE_VMA) {
    if (memFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
      vmaAllocInfo_.requiredFlags =
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT;
      vmaAllocInfo_.preferredFlags =
        VK_MEMORY_PROPERTY_HOST_COHERENT_BIT |
        VK_MEMORY_PROPERTY_HOST_CACHED_BIT;
      vmaAllocInfo_.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT |
        VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT;
    }
    if (memFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
      vmaAllocInfo_.requiredFlags |=
        VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
    vmaAllocInfo_.usage = VMA_MEMORY_USAGE_AUTO;
    vmaCreateBuffer((VmaAllocator)ctx_->getVmaAllocator(), &ci,
      &vmaAllocInfo_, &vkBuffer_, &vmaAllocation_, nullptr);
  1. 处理主机可见的内存映射缓冲区。在整个缓冲区生命周期中使用持久映射。
 if (memFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
      vmaMapMemory((VmaAllocator)ctx_->getVmaAllocator(),
        vmaAllocation_, &mappedPtr_);
    }
  } else {
  1. 直接的 Vulkan 代码路径很简单,但需要手动内存分配。有关完整的详细错误检查,请参阅 lvk/vulkan/VulkanClasses.cpp,这里省略了文本中的内容。
 vkCreateBuffer(device_, &ci, nullptr, &vkBuffer_);
    VkMemoryRequirements requirements = {};
    vkGetBufferMemoryRequirements(device_, vkBuffer_, &requirements);
    lvk::allocateMemory(ctx_->getVkPhysicalDevice(), device_,
      &requirements, memFlags, &vkMemory_));
    vkBindBufferMemory(device_, vkBuffer_, vkMemory_, 0);
  1. 主机可见的缓冲区以类似的方式处理。
 if (memFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
      vkMapMemory(device_, vkMemory_, 0, bufferSize_, 0, &mappedPtr_);
    }
  }
  1. 让我们为此缓冲区设置一个用户提供的调试名称。
 lvk::setDebugObjectName(
    device_, VK_OBJECT_TYPE_BUFFER, (uint64_t)vkBuffer_, debugName);
  1. 一旦创建缓冲区,获取一个可以在着色器中使用以访问此缓冲区的缓冲区设备地址。
 if (usageFlags & VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT) {
    const VkBufferDeviceAddressInfo ai = {
      .sType = VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO,
      .buffer = vkBuffer_,     };
    vkDeviceAddress_ = vkGetBufferDeviceAddress(device_, &ai);
  }
}

缓冲区销毁过程很有趣,值得提及,因为 Vulkan 缓冲区在 GPU 使用期间不应被删除。除了进行 VMA 和 Vulkan 调用来进行内存解映射和释放外,析构函数将实际释放延迟到缓冲区不再被 GPU 使用为止:

  1. 如果这个 VulkanBuffer 对象是用默认构造函数创建的,并且没有任何有效负载,我们就可以立即返回。deferredTask() 成员函数将 lambda 参数的执行推迟到所有先前提交的命令缓冲区完成处理的时间。我们将在后续章节中探讨此机制。
lvk::VulkanBuffer::~VulkanBuffer() {
  if (!ctx_) return;
  if (LVK_VULKAN_USE_VMA) {
    if (mappedPtr_)
      vmaUnmapMemory(
        (VmaAllocator)ctx_->getVmaAllocator(), vmaAllocation_);
    ctx_->deferredTask(std::packaged_task<void()>(
      [vma = ctx_->getVmaAllocator(),
       buffer = vkBuffer_,
       allocation = vmaAllocation_]() {
      vmaDestroyBuffer((VmaAllocator)vma, buffer, allocation);
    }));
  } else {
  1. 当我们不使用 VMA 并直接与 Vulkan 通信时,采取类似的方法。
 if (mappedPtr_)
      vkUnmapMemory(device_, vkMemory_);
    ctx_->deferredTask(std::packaged_task<void()>(
      [device = device_, buffer = vkBuffer_, memory = vkMemory_]() {
      vkDestroyBuffer(device, buffer, nullptr);
      vkFreeMemory(device, memory, nullptr);
    }));
  }
}

在我们总结如何与 Vulkan 缓冲区一起工作时,这里还有三个其他成员函数需要提及:

  1. 函数 flushMappedMemory() 用于确保当不支持一致性内存时,主机写入到映射内存的缓冲区对 GPU 可用。
void lvk::VulkanBuffer::flushMappedMemory(
  VkDeviceSize offset, VkDeviceSize size) const
{
  if (!LVK_VERIFY(isMapped()))return;
  if (LVK_VULKAN_USE_VMA) {
    vmaFlushAllocation((VmaAllocator)ctx_->getVmaAllocator(),
      vmaAllocation_, offset, size);
  } else {
    const VkMappedMemoryRange memoryRange = {
      .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
      .memory = vkMemory_,
      .offset = offset,
      .size = size,
    };
    vkFlushMappedMemoryRanges(device_, 1, &memoryRange);
  }
}
  1. 函数 getBufferSubData() 以方便的方式包装了一个 memcpy() 操作。它仅适用于内存映射的主机可见缓冲区。设备本地缓冲区使用一个阶段缓冲区单独处理。我们将在后续章节中讨论此机制。
void lvk::VulkanBuffer::getBufferSubData(
  size_t offset, size_t size, void* data) {
  LVK_ASSERT(mappedPtr_);
  if (!mappedPtr_) return;
  LVK_ASSERT(offset + size <= bufferSize_);
  const uint8_t* src = static_cast<uint8_t*>(mappedPtr_) + offset;
  memcpy(data, src, size);
}
  1. 函数 bufferSubData() 是一个类似的包装器。对于主机可见的缓冲区来说,这是微不足道的。注意这里是如何使用 memset() 来将缓冲区的内容设置为 0
void lvk::VulkanBuffer::bufferSubData(
  size_t offset, size_t size, const void* data) {
  if (!mappedPtr_) return;
  LVK_ASSERT(offset + size <= bufferSize_);
  if (data) {
    memcpy((uint8_t*)mappedPtr_ + offset, data, size);
  } else {
    memset((uint8_t*)mappedPtr_ + offset, 0, size);
  }
}

现在我们已经涵盖了运行 Chapter03/01_Assimp 应用程序所需的全部 Vulkan 代码,该应用程序通过 Assimp 加载并渲染 .obj 3D 模型。有两个小函数需要提及,分别用于绑定顶点和索引缓冲区,它们是 ICommandBuffer 接口的一部分。

  1. 第一个函数通过 vkCmdBindVertexBuffers() 绑定顶点缓冲区,用于顶点输入。需要进行一些检查以确保缓冲区的正确使用。在后续章节中,我们将学习如何完全省略顶点缓冲区,并学习 可编程顶点提取 (PVP) 方法。
void lvk::CommandBuffer::cmdBindVertexBuffer(uint32_t index,
  BufferHandle buffer, size_t bufferOffset)
{
  if (!LVK_VERIFY(!buffer.empty()))return;
  lvk::VulkanBuffer* buf = ctx_->buffersPool_.get(buffer);
  VkBuffer vkBuf = buf->vkBuffer_;
  LVK_ASSERT(buf->vkUsageFlags_ & VK_BUFFER_USAGE_VERTEX_BUFFER_BIT);
  const VkDeviceSize offset = bufferOffset;
  vkCmdBindVertexBuffers(
    wrapper_->cmdBuf_, index, 1, &vkBuf, &offset);
}
  1. 第二个函数使用 vkCmdBindIndexBuffer() 绑定索引缓冲区。除了断言之外,还有一些从 LightweightVK 到 Vulkan API 的枚举类型转换。
void lvk::CommandBuffer::cmdBindIndexBuffer(BufferHandle indexBuffer,
  IndexFormat indexFormat, size_t indexBufferOffset)
{
  lvk::VulkanBuffer* buf = ctx_->buffersPool_.get(indexBuffer);
  LVK_ASSERT(buf->vkUsageFlags_ & VK_BUFFER_USAGE_INDEX_BUFFER_BIT);
  const VkIndexType type = indexFormatToVkIndexType(indexFormat);
  vkCmdBindIndexBuffer(
    wrapper_->cmdBuf_, buf->vkBuffer_, indexBufferOffset, type);
}

应用程序 Chapter03/01_Assimp 应渲染以下图像。

图 3.2:使用 Assimp 加载的网格渲染

图 3.2:使用 Assimp 加载的网格渲染

现在我们已经完成了一些非常基本的 Vulkan 使用,并准备好向我们的示例添加一些纹理。

实现阶段缓冲区

GPU 设备本地缓冲区对主机不可见,我们可以使用各种 CPU-GPU-CPU 复制操作将数据上传到它们。在 Vulkan 中,这可以通过创建一个辅助缓冲区,称为阶段缓冲区,它是 CPU 可见或主机可见的,从主机上传一些数据到其中,然后发出 GPU 命令从该阶段缓冲区复制到设备本地缓冲区来实现。让我们学习如何在 Vulkan 中实现这项技术。

准备工作

在继续阅读之前,请查看之前的配方,处理 Vulkan 中的缓冲区,以了解如何创建不同类型的 Vulkan 缓冲区。

如何做到这一点...

如往常一样,让我们从 LightweightVK 的高级接口开始,然后深入到实现。在lvk/LVK.h中声明的接口类IContext公开以下方法来操作缓冲区。

Result upload(BufferHandle handle,
  const void* data, size_t size, size_t offset = 0) = 0;
uint8_t* getMappedPtr(BufferHandle handle) const = 0;
uint64_t gpuAddress(BufferHandle handle, size_t offset = 0) const = 0;
void flushMappedMemory(BufferHandle handle,
  size_t offset, size_t size) const = 0;

这些方法在子类VulkanContext中实现,大致对应于前一个配方中详细讨论的VulkanBuffer实现。我们还看到了VulkanContext::createBuffer()如何在有初始数据要上传到缓冲区的情况下调用VulkanContext::uload()。让我们看看这个方法内部是什么。

  1. 首先,我们必须将缓冲区句柄转换为指向VulkanBuffer对象的指针。这是通过一个存储系统中所有VulkanBuffer对象的池来完成的。池的实现将在后续配方中讨论。现在,让我们假装这是一个将整数句柄映射到VulkanBuffer对象指针的不可见机制。
Result VulkanContext::upload(lvk::BufferHandle handle,
  const void* data, size_t size, size_t offset) {
  if (!LVK_VERIFY(data)) return lvk::Result();
  lvk::VulkanBuffer* buf = buffersPool_.get(handle);
  1. 在进行一些范围检查后,我们将工作委托给成员函数VulkanStagingDevice::bufferSubData()
 if (!LVK_VERIFY(offset + size <= buf->bufferSize_))
    return Result(Result::Code::ArgumentOutOfRange, “Out of range”);
  stagingDevice_->bufferSubData(*buf, offset, size, data);
  return lvk::Result();
}

VulkanStagingDevice封装了管理 Vulkan 阶段缓冲区所需的所有功能。

  1. 阶段设备提供了访问设备本地缓冲区和图像的功能。在本配方中,我们将仅关注缓冲区部分和 2D 图像。虽然 3D 图像上传由LightweightVK支持,但本书中并未使用,我们将跳过它。如果您对学习这些细节感兴趣,鼓励您查看LightweightVK的实际源代码,位于lvk/vulkan/VulkanClasses.cpp
class VulkanStagingDevice final {
 public:
  explicit VulkanStagingDevice(VulkanContext& ctx);
  ~VulkanStagingDevice();
  void bufferSubData(VulkanBuffer& buffer,
    size_t dstOffset, size_t size, const void* data);
  void imageData2D(VulkanImage& image,
                   const VkRect2D& imageRegion,
                   uint32_t baseMipLevel,
                   uint32_t numMipLevels,
                   uint32_t layer,
                   uint32_t numLayers,
                   VkFormat format,
                   const void* data[]);
  // … imageData3D() is skipped
  1. 每次调用bufferSubData()imageData2D()都会在阶段缓冲区中占用一些空间。结构MemoryRegionDesc使用一个SubmitHandle描述这样的内存区域,该SubmitHandle用于通过它上传数据。
 private:
  struct MemoryRegionDesc {
    uint32_t srcOffset_ = 0;
    uint32_t alignedSize_ = 0;
    SubmitHandle handle_ = {};
  };
  1. 函数getNextFreeOffset()返回下一个可用的内存区域,该区域适合容纳size字节的数据。函数waitAndReset()用于内部等待,直到所有内存区域都可用。
 MemoryRegionDesc getNextFreeOffset(uint32_t size);
  void waitAndReset();
 private:
  VulkanContext& ctx_;
  BufferHandle stagingBuffer_;
  std::unique_ptr<lvk::VulkanImmediateCommands> immediate_;
  uint32_t stagingBufferFrontOffset_ = 0;
  uint32_t stagingBufferSize_ = 0;
  uint32_t bufferCapacity_ = 0;
  std::vector<MemoryRegionDesc> regions_;
};

一旦我们理解了getNextFreeOffset()辅助函数的工作原理,上传过程就变得非常简单。让我们看看:

  1. 确保请求的缓冲区大小是对齐的。某些压缩图像格式要求大小填充到16字节。我们在这里贪婪地使用那个值。使用简单的二进制算术技巧来确保大小值按要求对齐。
MemoryRegionDesc VulkanStagingDevice::getNextFreeOffset(uint32_t size) {
  constexpr uint32_t kStagingBufferAlignment_ = 16;
  uint32_t alignedSize = (size + kStagingBufferAlignment_ - 1) &
     ~(kStagingBufferAlignment_ - 1);
  1. 跟踪最合适的内存区域。检查我们是否可以重用之前使用的任何内存区域。这可能会在阶段缓冲区中引起一些内存碎片化,但这不是问题,因为这些子分配的生命周期非常短。
 auto bestIt = regions_.begin();
  for (auto it = regions_.begin(); it != regions_.end(); it++) {
    if (immediate_->isReady(SubmitHandle(it->handle_))) {
      if (it->alignedSize_ >= alignedSize) {
        SCOPE_EXIT { regions_.erase(it); };
        return *it;
      }
      if (bestIt->alignedSize_ < it->alignedSize_) bestIt = it;
    }
  }
  1. 重新获取并返回内存区域。如果没有更多空间可用在阶段缓冲区中,则重新获取所有之前的内存区域。
 if (bestIt != regions_.end() && 
      bufferCapacity_ < bestIt->alignedSize_) {
    regions_.erase(bestIt);
    return *bestIt;
  }
  if (bufferCapacity_ == 0) waitAndReset();
  1. 如果我们不能重用任何之前的内存区域,则从空闲阶段内存中分配一个新的。
 alignedSize =
    (alignedSize <= bufferCapacity_) ? alignedSize : bufferCapacity_;
  const uint32_t srcOffset = stagingBufferFrontOffset_;
  stagingBufferFrontOffset_ =
    (stagingBufferFrontOffset_ + alignedSize) % stagingBufferSize_;
  bufferCapacity_ -= alignedSize;
  return {srcOffset, alignedSize};
}

现在我们可以实现 VulkanStagingDevice::bufferSubData() 函数。这里的复杂性主要在于要上传的数据大小大于阶段缓冲区的大小。

  1. 如果目标缓冲区对主机可见,就像我们在之前的配方中讨论的那样,直接将数据内存复制到其中,处理 Vulkan 中的缓冲区
void VulkanStagingDevice::bufferSubData(VulkanBuffer& buffer,
  size_t dstOffset, size_t size, const void* data)
{
  if (buffer.isMapped()) {
    buffer.bufferSubData(dstOffset, size, data);
    return;
  }
  lvk::VulkanBuffer* stagingBuffer =
    ctx_.buffersPool_.get(stagingBuffer_);
  1. 在还有数据要上传的情况下迭代。在每次迭代中,我们尝试获取一个适合整个剩余大小的内存区域。我们相应地选择块大小:
 while (size) {
    MemoryRegionDesc desc = getNextFreeOffset((uint32_t)size);
    const uint32_t chunkSize =
      std::min((uint32_t)size, desc.alignedSize_);
  1. 阶段缓冲区本身始终对主机可见,因此我们可以将我们的数据内存复制到其中:
 stagingBuffer->bufferSubData(desc.srcOffset_, chunkSize, data);
  1. 获取命令缓冲区并发出 Vulkan 命令,在阶段缓冲区和目标缓冲区之间复制缓冲区数据:
 const VkBufferCopy copy = {desc.srcOffset_, dstOffset, chunkSize};
    auto& wrapper = immediate_->acquire();
    vkCmdCopyBuffer(wrapper.cmdBuf_,
      stagingBuffer->vkBuffer_, buffer.vkBuffer_, 1, &copy);
    desc.handle_ = immediate_->submit(wrapper);
  1. 当 GPU 正在执行复制时,我们将这个内存区域——连同其 SubmitHandle ——添加到已占用内存区域的容器中:
 regions_.push_back(desc);
    size -= chunkSize;
    data = (uint8_t*)data + chunkSize;
    dstOffset += chunkSize;
  }
}

阶段缓冲区的另一个关键作用是将像素数据复制到 Vulkan 图像中。让我们看看它是如何实现的。这个函数要复杂得多,所以我们再次省略文本中的所有错误检查,以便更好地理解代码。

  1. imageData2D() 函数可以一次性上传图像的多个层,从 layer 开始,以及从 baseMipLevel 开始的多个米普级别。LightweightVK 假设存在一个最大可能的米普级别数量。我们计算每个米普级别的字节数。
void VulkanStagingDevice::imageData2D(VulkanImage& image,
                                      const VkRect2D& imageRegion,
                                      uint32_t baseMipLevel,
                                      uint32_t numMipLevels,
                                      uint32_t layer,
                                      uint32_t numLayers,
                                      VkFormat format,
                                      const void* data[])
{
  uint32_t mipSizes[LVK_MAX_MIP_LEVELS];
  1. 由于我们知道我们想要更新的基本米普级别编号,我们可以通过位移从 Vulkan 图像范围中计算其尺寸。
 uint32_t width = image.vkExtent_.width >> baseMipLevel;
  uint32_t height = image.vkExtent_.height >> baseMipLevel;
  const Format texFormat(vkFormatToFormat(format));
  1. 现在让我们计算每层存储大小,这是为了容纳图像的所有相应米普级别所必需的。函数 getTextureBytesPerLayer() 返回具有请求图像格式的层的字节数。将结果存储在 mipSizes[] 中。
 uint32_t layerStorageSize = 0;
  for (uint32_t i = 0; i < numMipLevels; ++i) {
    const uint32_t mipSize = lvk::getTextureBytesPerLayer(
      image.vkExtent_.width, image.vkExtent_.height, texFormat, i);
    layerStorageSize += mipSize;
    mipSizes[i] = mipSize;
    width = width <= 1 ? 1 : width >> 1;
    height = height <= 1 ? 1 : height >> 1;
  }
  1. 现在我们知道存储整个图像数据所需的大小。尝试从阶段缓冲区获取下一个内存区域。LightweightVK 不提供在多个较小的块中复制图像数据的功能。如果我们得到一个小于 storageSize 的内存区域,我们应该等待一个更大的内存区域变得可用。这一结果的一个后果是,LightweightVK 无法上传内存占用大于阶段缓冲区大小的图像。
 const uint32_t storageSize = layerStorageSize * numLayers;
  MemoryRegionDesc desc = getNextFreeOffset(layerStorageSize);
  if (desc.alignedSize_ < storageSize) {
    waitAndReset();
    desc = getNextFreeOffset(storageSize);
  }
  LVK_ASSERT(desc.alignedSize_ >= storageSize);
  1. 一旦我们在阶段缓冲区中有一个合适的内存区域,我们可以遍历图像层和米普级别,为 Vulkan 准备数据。
 lvk::VulkanBuffer* stagingBuffer =
    ctx_.buffersPool_.get(stagingBuffer_);
  auto& wrapper = immediate_->acquire();
  for (uint32_t layer = 0; layer != numLayers; layer++) {
    stagingBuffer->bufferSubData(
      desc.srcOffset_ + layer * layerStorageSize,
      layerStorageSize,
      data[layer]);
    uint32_t mipLevelOffset = 0;
    for (uint32_t mipLevel = 0; mipLevel < numMipLevels; ++mipLevel) {
      const auto currentMipLevel = baseMipLevel + mipLevel;
  1. 将图像布局转换为 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,以便我们可以在 Vulkan 转移操作中使用它作为目标。
 lvk::imageMemoryBarrier(wrapper.cmdBuf_,
                              image.vkImage_,
                              0,
                              VK_ACCESS_TRANSFER_WRITE_BIT,
                              VK_IMAGE_LAYOUT_UNDEFINED,
                              VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                              VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
                              VK_PIPELINE_STAGE_TRANSFER_BIT,
                              VkImageSubresourceRange{
                                VK_IMAGE_ASPECT_COLOR_BIT,
                                currentMipLevel, 1, layer, 1});
  1. 将像素数据从阶段缓冲区复制到图像中。此级别的缓冲区偏移量位于所有米普级别开始处,加上所有之前上传的米普级别的尺寸。
 const VkRect2D region = {
        .offset = {.x = imageRegion.offset.x >> mipLevel,
                   .y = imageRegion.offset.y >> mipLevel},
        .extent = {.width =
                     max(1u, imageRegion.extent.width >> mipLevel),
                   .height=
                     max(1u, imageRegion.extent.height >> mipLevel)},
      };
      const VkBufferImageCopy copy = {
          .bufferOffset =
            desc.srcOffset_ + layer*layerStorageSize + mipLevelOffset,
          .bufferRowLength = 0,
          .bufferImageHeight = 0,
          .imageSubresource = VkImageSubresourceLayers{
            VK_IMAGE_ASPECT_COLOR_BIT, currentMipLevel, layer, 1},
          .imageOffset = {.x = region.offset.x,
                          .y = region.offset.y,
                          .z = 0},
          .imageExtent = {.width = region.extent.width,
                          .height = region.extent.height,
                          .depth = 1u},
      };
      vkCmdCopyBufferToImage(wrapper.cmdBuf_,
        stagingBuffer->vkBuffer_, image.vkImage_,
        VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &copy);
  1. 我们已经完成了这个 mip 级别和层的处理。将其图像布局从 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。这对于正常工作流程是必要的,因为任何后续处理图像的代码都期望这个图像布局。
 lvk::imageMemoryBarrier(
        wrapper.cmdBuf_,
        image.vkImage_,
        VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT,
        VK_ACCESS_SHADER_READ_BIT,
        VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
        VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
        VK_PIPELINE_STAGE_TRANSFER_BIT,
        VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
        VkImageSubresourceRange{
          VK_IMAGE_ASPECT_COLOR_BIT, currentMipLevel, 1, layer, 1});

Vulkan 图像布局是每个图像子资源的属性,它以某种不透明的实现特定方式描述了内存中数据的组织方式。当访问图像时,Vulkan 实现会考虑这个属性。未指定不同用例的正确布局可能会导致未定义的行为和扭曲的图像。

  1. 将缓冲区偏移量推进到下一个 mip 级别。
 mipLevelOffset += mipSizes[mipLevel];
    }
  }
  1. 一旦 Vulkan 命令记录在命令缓冲区中,我们就可以提交它以复制图像数据。在我们退出之前,我们将最后一个图像布局设置为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,以便稍后可以正确地发生图像布局转换。
 desc.handle_ = immediate_->submit(wrapper);
  image.vkImageLayout_ = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
  regions_.push_back(desc);
}

这一切都是关于阶段缓冲区实现以及通过它们上传设备本地缓冲区数据和图像。

还有更多...

LightweightVK 有一个名为 VulkanStagingDevice::imageData3D() 的函数,用于通过阶段缓冲区上传 3D 纹理数据。它可以在 lvk/vulkan/VulkanClasses.cpp 中找到。确保你探索它。

可以有一个由标志 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 描述的内存堆。一些 GPU 可以有一个相对较小的独立内存堆,而一些 GPU 可以将整个设备内存标记为主机可见。这被称为可调整大小的 BAR,或 ReBAR,这是一个硬件特性,允许 CPU 访问 GPU 设备内存。如果您有这样的内存堆,您可以直接使用它来向 GPU 本地内存写入数据。例如,您可以在该内存中分配一个阶段缓冲区。如果您想了解更多关于 Vulkan 内存类型及其使用方法的信息,这里有一篇由 Adam Sawicki 撰写的精彩文章:https://asawicki.info/news_1740_vulkan_memory_types_on_pc_and_how_to_use_them

现在,我们知道了如何处理 Vulkan 图像所需的一切。让我们进入下一个配方,学习如何使用 Vulkan 图像创建纹理。

在 Vulkan 中使用纹理数据

在我们能够使用 Vulkan 编写有意义的 3D 渲染应用程序之前,我们需要学习如何处理纹理。这个配方展示了如何使用 Vulkan API 实现一系列函数,以在 GPU 上创建、销毁和修改纹理对象。

准备工作

将纹理数据上传到 GPU 需要使用一个阶段缓冲区。在继续之前,请阅读配方处理缓冲区

该配方的源代码可以在 Chapter03/02_STB 中找到。

如何操作...

我们首先创建一个图像。Vulkan 图像是另一种由内存支持的类型,用于存储 1D、2D 和 3D 图像或这些图像的数组。熟悉 OpenGL 的读者可能会对立方体贴图感到好奇。立方体贴图表示为六个 2D 图像的数组,可以通过在 VkImageCreateInfo 结构中设置 VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT 标志来构建。我们稍后会回到这一点。现在让我们仅使用 2D 图像来研究基本的使用案例。让我们从高级应用程序代码开始,一直到底层 Vulkan 图像分配:

  1. 应用程序 Chapter03/02_STB 使用 STB 库(github.com/nothings/stb)从 .jpg 文件中加载像素数据。我们强制转换为 4 通道,以简化纹理处理。许多 Vulkan 实现不支持 3 通道图像。
 int w, h, comp;
  const uint8_t* img = stbi_load(“data/wood.jpg”, &w, &h, &comp, 4);
  1. 创建了一个纹理对象的句柄。纹理格式是归一化的无符号 8 位 RGBA,对应于 Vulkan 格式 VK_FORMAT_R8G8B8A8_UNORM。我们打算在着色器中使用这个纹理进行采样,因此指定了纹理使用标志 TextureUsageBits_Sampled
 lvk::Holder<lvk::TextureHandle> texture = ctx->createTexture({
      .type       = lvk::TextureType_2D,
      .format     = lvk::Format_RGBA_UN8,
      .dimensions = {(uint32_t)w, (uint32_t)h},
      .usage      = lvk::TextureUsageBits_Sampled,
      .data       = img,
      .debugName  = “03_STB.jpg”,
  });
  1. 不要忘记释放图像内存。
 stbi_image_free((void*)img);
  1. 让我们看看主循环。LightweightVK 是围绕无绑定渲染器设计构建的。无绑定渲染是一种技术,通过移除显式绑定资源(如纹理、缓冲区或采样器)的需要,从而允许更有效地管理 GPU 资源。以下是我们可以使用推送常量将纹理数据传递到着色器中的方法。之后,我们渲染一个由 4 个三角形条顶点组成的四边形。
 const struct PerFrameData {
      mat4 mvp;
      uint32_t textureId;
    } pc = {
      .mvp       = p * m,
      .textureId = texture.index(),
    };
    …
    buf.cmdPushConstants(pc);
    buf.cmdDraw(lvk::Primitive_TriangleStrip, 0, 4);
    …
  1. 顶点在 Chapter03/02_STB/src/main.vert 顶点着色器中生成,没有任何顶点输入,如下所示。
#version 460 core
layout(push_constant) uniform PerFrameData {
  uniform mat4 MVP;
  uint textureId;
};
layout (location=0) out vec2 uv;
const vec2 pos[4] = vec24, vec2(1.0,  1.0), vec2(-1.0, -1.0), vec2(-1.0, 1.0)
);
void main() {
  gl_Position = MVP * vec4(0.5 * pos[gl_VertexIndex], 0.0, 1.0);
  uv = (pos[gl_VertexIndex]+vec2(0.5)) * 0.5;
}
  1. 片段着色器更有趣。我们需要声明由 LightweightVK 提供的 2D 纹理和采样器数组。它们包含当前加载的所有纹理和所有采样器。两个数组中的元素 0 对应于一个虚拟对象。这对于安全地利用空值作为纹理标识符很有用。我们的推送常量 textureId 仅仅是 kTextures2D 数组中的一个索引。
#version 460 core
#extension GL_EXT_nonuniform_qualifier : require
layout (set = 0, binding = 0) uniform texture2D kTextures2D[];
layout (set = 0, binding = 1) uniform sampler kSamplers[];
layout (location=0) in vec2 uv;
layout (location=0) out vec4 out_FragColor;
layout(push_constant) uniform PerFrameData {
  uniform mat4 MVP;
  uint textureId;
};
  1. 这里有一个方便的辅助函数 textureBindless2D(),用于使用无绑定采样器从无绑定 2D 纹理中采样。我们将用它来代替标准的 GLSL texture() 函数,以便快速采样纹理。

在这里,我们自行提供了整个片段着色器 GLSL 源代码。如果我们省略着色器开头处的 #version 指令,LightweightVK 将会将其以及许多其他辅助函数注入到我们的 GLSL 源代码中,包括 kTextures2D[] 和其他声明。我们将在后续章节中使用这一功能来简化我们的着色器。这里列出这个函数仅出于纯粹的教育目的。

vec4 textureBindless2D(uint textureid, uint samplerid, vec2 uv) {
  return texture(sampler2D(kTextures2D[textureid],
                           kSamplers[samplerid]), uv);
}
void main() {
  out_FragColor = textureBindless2D(textureId, 0, uv);
}

如果没有动态均匀纹理索引,Vulkan API 需要使用 nonuniformEXT 类型限定符来索引描述符绑定。

生成的应用程序 Chapter03/02_STB 应该渲染一个如以下截图所示的纹理旋转四边形。

图 3.3:渲染纹理四边形

图 3.3:渲染纹理四边形

高级部分相当简短且直接,隐藏了所有 Vulkan 的复杂性。现在让我们看看底层实现,以了解它是如何工作的。

它是如何工作的…

Vulkan 纹理,即图像和图像视图,很复杂。与描述符集一起,它们是访问着色器中纹理数据的必要条件。LightweightVK 的纹理实现有很多层。让我们逐一剖析它们,并学习如何使用。

冰山一角是 VulkanContext::createTexture() 函数,该函数返回一个纹理句柄。该函数相当长,所以我们再次省略错误检查代码,以便更容易理解:

  1. 此函数将 LightweightVK 纹理描述 TextureDesc 转换为图像和图像视图的各种 Vulkan 标志。额外的参数 debugName 提供了一种方便的方法来覆盖 TextureDesc::debugName 字段。如果我们想使用相同的 TextureDesc 对象创建多个纹理,这将非常有用。
Holder< TextureHandle> VulkanContext::createTexture(
  const TextureDesc& requestedDesc,
  const char* debugName,
  Result* outResult)
{
  TextureDesc desc(requestedDesc);
  if (debugName && *debugName) desc.debugName = debugName;
  1. LightweightVK 格式转换为 Vulkan 格式。Vulkan 在支持颜色格式方面提供了一些更强的保证。因此,深度格式根据实际可用性进行转换,而颜色格式则直接转换。
 const VkFormat vkFormat = lvk::isDepthOrStencilFormat(desc.format) ?
    getClosestDepthStencilFormat(desc.format) :
    formatToVkFormat(desc.format);
  const lvk::TextureType type = desc.type;
  1. 如果图像将要分配在 GPU 设备内存中,我们应该设置 VK_IMAGE_USAGE_TRANSFER_DST_BIT 以允许 Vulkan 将数据传输到其中。其他 Vulkan 图像使用标志根据 LVK 使用标志相应设置。
 VkImageUsageFlags usageFlags = desc.storage == StorageType_Device ?
    VK_IMAGE_USAGE_TRANSFER_DST_BIT : 0;
  if (desc.usage & lvk::TextureUsageBits_Sampled)
    usageFlags |= VK_IMAGE_USAGE_SAMPLED_BIT;
  if (desc.usage & lvk::TextureUsageBits_Storage)
    usageFlags |= VK_IMAGE_USAGE_STORAGE_BIT;
  if (desc.usage & lvk::TextureUsageBits_Attachment)
    usageFlags |= lvk::isDepthOrStencilFormat(desc.format) ?
      VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT :
      VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
  1. 为了方便起见,我们始终允许从 GPU 读取图像到 CPU。然而,检查此使用标志是否实际上受支持可能是有价值的。内存标志的选择方式与我们在 处理 Vulkan 中的缓冲区 菜谱中所做的方式相同。
 usageFlags |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
  const VkMemoryPropertyFlags memFlags =
    storageTypeToVkMemoryPropertyFlags(desc.storage);
  1. 通过在提供的 debugName 字符串前添加前缀来为 Vulkan 图像和图像视图对象生成调试名称。
 const bool hasDebugName = desc.debugName && *desc.debugName;
  char debugNameImage[256] = {0};
  char debugNameImageView[256] = {0};
  if (hasDebugName) {
    snprintf(debugNameImage, sizeof(debugNameImage)-1,
      “Image: %s”, desc.debugName);
    snprintf(debugNameImageView, sizeof(debugNameImageView) - 1,
      “Image View: %s”, desc.debugName);
  }
  1. 现在我们可以推断出 VkImageCreateFlags 和 Vulkan 图像及图像视图的类型。
 VkImageCreateFlags createFlags = 0;
  uint32_t arrayLayerCount = static_cast<uint32_t>(desc.numLayers);
  VkImageViewType imageViewType;
  VkImageType imageType;
  VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT;
  switch (desc.type) {
  1. 2D 图像可以是多采样(en.wikipedia.org/wiki/Multisample_anti-aliasing)。
 case TextureType_2D:
      imageViewType = VK_IMAGE_VIEW_TYPE_2D;
      imageType = VK_IMAGE_TYPE_2D;
      samples = lvk::getVulkanSampleCountFlags(desc.numSamples);
      break;
    case TextureType_3D:
      imageViewType = VK_IMAGE_VIEW_TYPE_3D;
      imageType = VK_IMAGE_TYPE_3D;
      break;
  1. Vulkan 中的立方体贴图可以使用类型为 VK_IMAGE_VIEW_TYPE_CUBE 的图像视图和带有特殊标志 VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT 的 2D 图像来表示。层数乘以 6 以根据 Vulkan 规范容纳所有立方体贴图面。
 case TextureType_Cube:
      imageViewType = VK_IMAGE_VIEW_TYPE_CUBE;
      arrayLayerCount *= 6;
      imageType = VK_IMAGE_TYPE_2D;
      createFlags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT;
      break;
  }
  1. 现在我们可以创建一个包装对象 VulkanImage,它封装了所有必要的 VkImage 相关属性。我们将在稍后探索 createImage() 函数。
 Result result;
  std::shared_ptr<VulkanImage> image = createImage(imageType,
    VkExtent3D{ desc.dimensions.width,
                desc.dimensions.height,
                desc.dimensions.depth},
    vkFormat,
    desc.numMipLevels,
    arrayLayerCount,
    VK_IMAGE_TILING_OPTIMAL,
    usageFlags,
    memFlags,
    createFlags,
    samples,
    &result,
    debugNameImage);
  1. 要从着色器访问 Vulkan 图像,我们需要创建一个 VkImageView 对象。为此,我们必须选择应包含在视图中的图像“方面”。Vulkan 中的图像可以同时具有多个方面,例如组合深度-模板图像,因此深度和模板位是分别处理的。
 VkImageAspectFlags aspect = 0;
  if (image->isDepthFormat_ || image->isStencilFormat_) {
    if (image->isDepthFormat_) {
      aspect |= VK_IMAGE_ASPECT_DEPTH_BIT;
    } else if (image->isStencilFormat_) {
      aspect |= VK_IMAGE_ASPECT_STENCIL_BIT;
    }
  } else {
    aspect = VK_IMAGE_ASPECT_COLOR_BIT;
  }
  1. 图像视图可以控制包含哪些米级和层。在这里,我们创建一个包含图像所有级别和层的图像视图。稍后,我们需要为只能有一个层和一个米级的帧缓冲区附加物创建单独的图像视图。
 VkImageView view = image->createImageView(
    imageViewType, vkFormat, aspect, 0, VK_REMAINING_MIP_LEVELS, 0,
    arrayLayerCount, debugNameImageView);
  1. LightweightVK 调用了一对对象——VkImage,封装在 VulkanImage 类中,以及 VkImageView——作为纹理。布尔标志 awaitingCreation_ 告诉 VulkanContext 已创建纹理,并且无绑定描述符集需要更新。我们将在下一章中回到这一点。
 TextureHandle handle = texturesPool_.create(
    lvk::VulkanTexture(std::move(image), view));
  awaitingCreation_ = true;
  1. 在我们返回新创建的纹理句柄之前,让我们上传初始纹理数据。
 if (desc.data) {
    const void* mipMaps[] = {desc.data};
    upload(handle,
      {.dimensions = desc.dimensions, .numMipLevels = 1}, mipMaps);
  }
  return {this, handle};
}

上文提到的辅助函数 createImage() 创建一个 VulkanImage 对象。它执行一些错误检查,这里省略了,并将实际工作委托给 VulkanImage 的构造函数。以下是其实施的方便版本。

std::shared_ptr<VulkanImage> VulkanContext::createImage(
  VkImageType imageType, VkExtent3D extent,  VkFormat format,
  uint32_t numLevels,   uint32_t numLayers, VkImageTiling tiling,
  VkImageUsageFlags usageFlags, VkMemoryPropertyFlags memFlags,
  VkImageCreateFlags flags, VkSampleCountFlagBits samples,
  lvk::Result* outResult, const char* debugName)
{
  return std::make_shared<VulkanImage>(*this, vkDevice_, extent,
    imageType, format, numLevels, numLayers, tiling, usageFlags,
    memFlags, flags, samples, debugName);
}

我们将更感兴趣的是 VulkanImage 构造函数,它创建一个实际的 VkImage 对象。

  1. 构造函数接受一系列参数,描述图像的所有必要 Vulkan 属性。
VulkanImage::VulkanImage(VulkanContext& ctx, VkDevice device,
  VkExtent3D extent, VkImageType type, VkFormat format,
  uint32_t numLevels, uint32_t numLayers, VkImageTiling tiling,
  VkImageUsageFlags usageFlags, VkMemoryPropertyFlags memFlags,
  VkImageCreateFlags createFlags, VkSampleCountFlagBits samples,
  const char* debugName) :
  ctx_(ctx), vkDevice_(device), vkUsageFlags_(usageFlags),
  vkExtent_(extent), vkType_(type), vkImageFormat_(format),
  numLevels_(numLevels), numLayers_(numLayers), vkSamples_(samples),
  isDepthFormat_(isDepthFormat(format)),
  isStencilFormat_(isStencilFormat(format))
 {
  1. 使用这些参数,我们可以立即填写 VkImageCreateInfo 结构。LightweightVK 不与多个 Vulkan 队列一起工作,因此它将共享模式设置为 VK_SHARING_MODE_EXCLUSIVE
 const VkImageCreateInfo ci = {
      .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
      .flags = createFlags,
      .imageType = type,
      .format = vkImageFormat_,
      .extent = vkExtent_,
      .mipLevels = numLevels_,
      .arrayLayers = numLayers_,
      .samples = samples,
      .tiling = tiling,
      .usage = usageFlags,
      .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
      .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
  };
  1. 与我们在配方“处理 Vulkan 中的缓冲区”中处理缓冲区的方式相同,我们对 Vulkan 图像有两种代码路径。一个使用 Vulkan Memory Allocator 库,另一个直接调用 Vulkan 来分配内存。这对于调试目的很有用。
 if (LVK_VULKAN_USE_VMA) {
    vmaAllocInfo_.usage = memFlags &
      VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT ?
        VMA_MEMORY_USAGE_CPU_TO_GPU : VMA_MEMORY_USAGE_AUTO;
    VkResult result = vmaCreateImage(
      (VmaAllocator)ctx_.getVmaAllocator(), &ci, &vmaAllocInfo_,
      &vkImage_, &vmaAllocation_, nullptr);
  1. 我们可以有与内存映射缓冲区相同的方式拥有内存映射图像。然而,这仅适用于非平铺的图像布局。
 if (memFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
      vmaMapMemory((VmaAllocator)ctx_.getVmaAllocator(),
        vmaAllocation_, &mappedPtr_);
    }
  } else {
  1. 直接的 Vulkan 代码路径相当相似。我们调用 vkCreateImage(),然后分配一些内存并使用 vkBindImageMemory() 绑定它。内存分配的方式与我们在本章的配方“处理 Vulkan 中的缓冲区”中为缓冲区所做的相同。
 VK_ASSERT(vkCreateImage(vkDevice_, &ci, nullptr, &vkImage_));
    VkMemoryRequirements memRequirements;
    vkGetImageMemoryRequirements(device, vkImage_, &memRequirements);
    VK_ASSERT(lvk::allocateMemory(ctx.getVkPhysicalDevice(),
      vkDevice_, &memRequirements, memFlags, &vkMemory_));
    VK_ASSERT(vkBindImageMemory(vkDevice_, vkImage_, vkMemory_, 0));
    if (memFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) {
      VK_ASSERT(vkMapMemory(
        vkDevice_, vkMemory_, 0, VK_WHOLE_SIZE, 0, &mappedPtr_));
    }
  }
  1. 在退出之前,设置创建的 VkImage 对象的调试名称,并检索物理设备格式属性以供进一步使用。
 VK_ASSERT(lvk::setDebugObjectName(vkDevice_, VK_OBJECT_TYPE_IMAGE,
    (uint64_t)vkImage_, debugName));
  vkGetPhysicalDeviceFormatProperties(ctx.getVkPhysicalDevice(),
    vkImageFormat_, &vkFormatProperties_);
}

一旦我们有了 VulkanImage 包装对象,我们就可以创建一个图像视图。这要简单得多,可以使用一个简短的成员函数 createImageView() 来完成。

  1. 此函数不使用任何包装器,并直接创建一个 VkImageView 对象。
VkImageView VulkanImage::createImageView(VkImageViewType type,
  VkFormat format, VkImageAspectFlags aspectMask,
  uint32_t baseLevel, uint32_t numLevels,
  uint32_t baseLayer, uint32_t numLayers,
  const char* debugName) const
 {
  const VkImageViewCreateInfo ci = {
    .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
    .image = vkImage_,
    .viewType = type,
    .format = format,
    .components = {.r = VK_COMPONENT_SWIZZLE_IDENTITY,
                   .g = VK_COMPONENT_SWIZZLE_IDENTITY,
                   .b = VK_COMPONENT_SWIZZLE_IDENTITY,
                   .a = VK_COMPONENT_SWIZZLE_IDENTITY},
  1. 如果提供了非零的 numLevels 参数,我们将使用它来覆盖我们想要在这个新图像视图中拥有的 MIP 级别数量。否则,我们将使用当前图像中可用的所有 MIP 级别来创建图像视图。这很方便,因为用作帧缓冲区附件的图像视图应该只有 1 个 MIP 级别。
 .subresourceRange = { 
      aspectMask, baseLevel,
      numLevels ? numLevels : numLevels_,
      baseLayer, numLayers},
  };
  VkImageView vkView = VK_NULL_HANDLE;
  VK_ASSERT(vkCreateImageView(vkDevice_, &ci, nullptr, &vkView));
  VK_ASSERT(lvk::setDebugObjectName(vkDevice_,
    VK_OBJECT_TYPE_IMAGE_VIEW, (uint64_t)vkView, debugName));
  return vkView;
}

上文中创建的 VkImageVkImageView 对象可以表示纹理。LightweightVK 将它们组合到 VulkanTexture 类中。

struct VulkanTexture final {
  VulkanTexture() = default;
  VulkanTexture(std::shared_ptr<lvk::VulkanImage> image,
                VkImageView imageView);
  ~VulkanTexture();
  VkExtent3D getExtent() const { return image_->vkExtent_;  }
  VkImageView getOrCreateVkImageViewForFramebuffer(
    uint8_t level, uint16_t layer);
  std::shared_ptr<lvk::VulkanImage> image_;
  VkImageView imageView_ = VK_NULL_HANDLE; // all mip-levels
  VkImageView imageViewForFramebuffer_[LVK_MAX_MIP_LEVELS][6] = {}; // max 6 faces for cubemap rendering
};

如您所见,VulkanTexture 类只是一个数据容器,唯一有趣的部分是 getOrCreateVkImageViewForFramebuffer() 函数。正如我们之前提到的,用作帧缓冲区附件的图像视图应该只有 1 个 MIP 级别和 1 个层。这个函数以简单的方式在数组 imageViewForFramebuffer_[][] 中预缓存这样的图像视图。它最多支持 6 个层——这足以渲染到立方体贴图的各个面。

还有更多…

虽然技术上上述所有代码都足以创建 VkImageVkImageView 对象,但我们仍然无法从着色器中访问它们。要做到这一点,我们需要了解如何存储这些对象以及如何创建和管理 Vulkan 描述符集。让我们继续学习下一道菜谱来了解这一点。

存储 Vulkan 对象

在之前的菜谱中,我们提到了许多 lvk::…Handle 类,它们被封装在一个类似唯一指针的类 lvk::Holder 中。它们是 LightweightVK 管理 Vulkan 对象和其他资源的关键。处理器是轻量级值类型,作为整数传递时成本低廉,与 std::shared_ptr 和类似的引用计数智能指针相比,我们没有共享所有权的成本。当拥有某些对象是可取的时候,我们将处理器封装在 lvk::Holder 类中,从概念上讲,它类似于 std::unique_ptr

准备工作

处理器的 LightweightVK 实现受到了 Sebastian Aaltonen 在 SIGGRAPH 2023 上的演示 HypeHype Mobile Rendering Architecture 的启发。如果你想了解更多关于使用处理器进行 API 设计的底层有趣细节,请务必阅读:advances.realtimerendering.com/s2023/AaltonenHypeHypeAdvances2023.pdf

如何做到这一点…

抽象处理器由一个模板类 Handle<> 表示:

  1. 处理器被设计为指向存储在数组中的对象的指针。数组中的索引足以识别一个对象。为了处理对象被释放然后被其他对象替换的情况,我们引入了一个值 gen_,它代表对象的“生成”,每次将新对象分配到存储数组中的相同元素时,该值都会递增。
template<typename ObjectType> class Handle final {
  uint32_t index_ = 0;
  uint32_t gen_ = 0;
  1. 这些值是私有的,这样句柄就只能由友好的 Pool 类构建。Pool 类是模板化的,并且由两种类型参数化;一种对应于句柄的对象类型,另一种是存储在实现数组中的类型。它从接口中不可见。
 Handle(uint32_t index, uint32_t gen) : index_(index), gen_(gen){};
  template<typename ObjectType,
           typename ImplObjectType> friend class Pool;
 public:
  Handle() = default;
  1. 合同规定,生成等于 0 的句柄被视为空的空句柄。
 bool empty() const { return gen_ == 0; }
  bool valid() const { return gen_ != 0; }
  uint32_t index() const { return index_; }
  uint32_t gen() const { return gen_; }
  1. 当我们需要通过接受 void* 参数的第三方 C 风格接口传递句柄时,indexAsVoid() 函数很有帮助。本书中使用的例子是 ImGui 集成,这在下一章 第四章添加用户交互和生产率工具 中讨论。
 void* indexAsVoid() const 
  { return reinterpret_cast<void*>(static_cast<ptrdiff_t>(index_)); }
  bool operator==(const Handle<ObjectType>& other) const
  { return index_ == other.index_ && gen_ == other.gen_;  }
  bool operator!=(const Handle<ObjectType>& other) const
  { return index_ != other.index_ || gen_ != other.gen_; }
  1. 显式转换为 bool 是必要的,以便在条件语句(如 if (handle) …)中使用句柄。
 explicit operator bool() const { return gen_ != 0; }
};
static_assert(sizeof(Handle<class Foo>) == sizeof(uint64_t));

Handle<> 模板可以用一个提前声明的对象参数化,该对象永远不会定义。这样做是为了确保类型安全,以便异构句柄不能相互混合。以下是 LightweightVK 声明所有其 Handle<> 类型的示例。所有结构体都已声明但未定义。

using ComputePipelineHandle = lvk::Handle<struct ComputePipeline>;
using RenderPipelineHandle = lvk::Handle<struct RenderPipeline>;
using ShaderModuleHandle = lvk::Handle<struct ShaderModule>;
using SamplerHandle = lvk::Handle<struct Sampler>;
using BufferHandle = lvk::Handle<struct Buffer>;
using TextureHandle = lvk::Handle<struct Texture>;

句柄不拥有它们指向的对象。只有 Holder<> 类才拥有。让我们看看它的实现:

  1. Holder<> 类使用它可以持有的 Handle 类型进行模板化。构造函数接受一个句柄和指向 lvk::IContext 的指针,以确保句柄可以正确销毁。该类具有类似于 std::unique_ptr 的移动语义。为了简洁起见,我们省略了移动构造函数和移动赋值的定义。
template<typename HandleType> class Holder final {
 public:
  Holder() = default;
  Holder(lvk::IContext* ctx, HandleType handle)
    : ctx_(ctx), handle_(handle) {}
  1. 我们在这里没有 IContext 类的声明。这就是为什么我们使用重载的提前声明的函数 lvk::destroy() 来释放句柄。
 ~Holder() { lvk::destroy(ctx_, handle_); }
  Holder(const Holder&) = delete;
  Holder(Holder&& other):ctx_(other.ctx_), handle_(other.handle_) {…}
  Holder& operator=(const Holder&) = delete;
  Holder& operator=(Holder&& other) { … }
  1. nullptr 分配给持有者:
 Holder& operator=(std::nullptr_t) { this->reset(); return *this; }
  inline operator HandleType() const { return handle_; }
  bool valid() const { return handle_.valid(); }
  bool empty() const { return handle_.empty(); }
  1. 手动重置持有者并使其释放存储的句柄或仅在必要时返回句柄并释放所有权:
 void reset() {
    lvk::destroy(ctx_, handle_);
    ctx_ = nullptr;
    handle_ = HandleType{};
  }
  HandleType release() {
    ctx_ = nullptr;
    return std::exchange(handle_, HandleType{});
  }
  uint32_t index() const { return handle_.index(); }
  void* indexAsVoid() const { return handle_.indexAsVoid(); }
 private:
  lvk::IContext* ctx_ = nullptr;
  HandleType handle_;
};
  1. Holder 类调用一系列重载的 destroy() 函数。以下是 LightweightVK 定义它们的示例,每个句柄类型一个函数。
void destroy(lvk::IContext* ctx, lvk::ComputePipelineHandle handle);
void destroy(lvk::IContext* ctx, lvk::RenderPipelineHandle handle);
void destroy(lvk::IContext* ctx, lvk::ShaderModuleHandle handle);
void destroy(lvk::IContext* ctx, lvk::SamplerHandle handle);
void destroy(lvk::IContext* ctx, lvk::BufferHandle handle);
void destroy(lvk::IContext* ctx, lvk::TextureHandle handle);
  1. 这些函数的实现位于 lightweightvk/lvk/LVK.cpp,并且它们看起来非常相似。每个函数都会调用 IContext 中的相应重载方法。虽然这可能看起来是不必要的,但实际上这有助于避免 Holder 类和 IContext 之间的循环依赖,从而使接口更加清晰。
void destroy(lvk::IContext* ctx, lvk::ComputePipelineHandle handle) {
  if (ctx) ctx->destroy(handle);
}

关于 Holder 类以及接口中暴露的 Handle-Holder 机制的部分,这就是所有需要讨论的内容。现在,让我们深入探讨实现,了解对象 Pools 可以如何实现。

它是如何工作的…

实现从类 Pool<> 开始,该类位于 lightweightvk/lvk/Pool.h。它使用 std::vector 存储类型为 ImplObjectType 的对象集合,并可以管理这些对象的句柄。让我们看看实现细节:

  1. 每个数组元素都是一个PoolEntry结构体,它通过值存储一个ImplObjectType对象及其生成,用于检查指向此元素的句柄。字段nextFree_用于在数组内部维护空闲元素的链表。一旦句柄被释放,相应的数组元素就会被添加到空闲列表中。字段freeListHead_存储第一个空闲元素的索引,如果没有空闲元素,则为kListEndSentinel
template<typename ObjectType, typename ImplObjectType>
class Pool {
  static constexpr uint32_t kListEndSentinel = 0xffffffff;
  struct PoolEntry {
    explicit PoolEntry(ImplObjectType& obj) : obj_(std::move(obj)) {}
    ImplObjectType obj_ = {};
    uint32_t gen_ = 1;
    uint32_t nextFree_ = kListEndSentinel;
  };
  uint32_t freeListHead_ = kListEndSentinel;
public:
  std::vector<PoolEntry> objects_;

数据导向设计的支持者可能会争论说,这种结构通过将ImplObjectType的有效负载与效用值gen_nextFree_交织在一起来最小化缓存利用率。这确实是正确的。一种缓解方法是通过维护两个单独的数组。第一个数组可以紧密打包ImplObjectType值,而第二个数组可以存储必要的记录元数据。实际上,可以更进一步,正如 Sebastian Aaltonen 的原版演示中提到的,通过将高频访问的“热”对象类型与低频访问的“冷”类型分开,这些类型可以存储在不同的数组中。然而,为了简单起见,我们将把这个作为读者的练习。

  1. 创建新句柄的方法接受一个右值引用。它检查空闲列表的头部。如果数组中有空闲元素,我们可以立即将我们的对象放入其中,并从空闲列表中移除前端元素。
 Handle<ObjectType> create(ImplObjectType&& obj) {
    uint32_t idx = 0;
    if (freeListHead_ != kListEndSentinel) {
      idx = freeListHead_;
      freeListHead_ = objects_[idx].nextFree_;
      objects_[idx].obj_ = std::move(obj);
    } else {
  1. 如果内部没有空间,则将新元素追加到std::vector容器中。
 idx = (uint32_t)objects_.size();
      objects_.emplace_back(obj);
    }
    numObjects_++;
    return Handle<ObjectType>(idx, objects_[idx].gen_);
  }
  1. 销毁过程简单,但涉及一些额外的错误检查。空句柄不应被销毁。尝试从一个空池中移除非空句柄意味着逻辑错误,应该断言。如果句柄的生成与相应数组元素的生成不匹配,这意味着我们正在尝试双重删除。
 void destroy(Handle<ObjectType> handle) {
    if (handle.empty()) return;
    assert(numObjects_ > 0);
    const uint32_t index = handle.index();
    assert(index < objects_.size());
    assert(handle.gen() == objects_[index].gen_); // double deletion
  1. 如果所有检查都成功,则用空默认构造的对象替换存储的对象,并增加其生成。然后,将此数组元素放置在空闲列表的前端。
 objects_[index].obj_ = ImplObjectType{};
    objects_[index].gen_++;
    objects_[index].nextFree_ = freeListHead_;
    freeListHead_ = index;
    numObjects_--;
  }
  1. 通过get()方法进行句柄解引用,该方法有const和非const实现。它们是相同的,因此我们只需检查一个。生成不匹配使我们能够识别对已删除对象的访问。
 ImplObjectType* get(Handle<ObjectType> handle) {
    if (handle.empty()) return nullptr;
    const uint32_t index = handle.index();
    assert(index < objects_.size());
    assert(handle.gen() == objects_[index].gen_); // deleted object
    return &objects_[index].obj_;
  }
  1. 可以手动清空池,以便为每个对象调用析构函数。
 void clear() {
    objects_.clear();
    freeListHead_ = kListEndSentinel;
    numObjects_ = 0;
  }
  1. 成员字段numObjects_用于跟踪内存泄漏并防止在空池内部进行分配。
 uint32_t numObjects() const {
    return numObjects_;
  }
  uint32_t numObjects_ = 0;
};

这就是Pool的工作原理。在VulkanContext中的实际实现使用它们来存储所有从接口侧可访问的实现特定对象。这些声明可以在lightweightvk/lvk/vulkan/VulkanClasses.h中找到。在许多情况下,Vulkan 对象——例如VkShaderModuleVkSampler——可以直接存储。如果需要额外的记录,则存储一个包装对象。

Pool<lvk::ShaderModule, VkShaderModule> shaderModulesPool_;
Pool<lvk::RenderPipeline, RenderPipelineState> renderPipelinesPool_;
Pool<lvk::ComputePipeline, ComputePipelineState>
  computePipelinesPool_;
Pool<lvk::Sampler, VkSampler> samplersPool_;
Pool<lvk::Buffer, VulkanBuffer> buffersPool_;
Pool<lvk::Texture, VulkanTexture> texturesPool_;

现在我们知道了如何存储各种对象并通过句柄暴露对这些对象的访问。在我们能够总结本章的主题并完成对 Vulkan 的介绍之前,让我们看看如何构建无绑定描述符集以从 GLSL 着色器访问纹理。

使用 Vulkan 描述符索引

描述符索引从版本 1.2 开始成为 Vulkan 核心的一部分,作为一个可选特性。Vulkan 1.3 使其成为强制特性。这个特性允许应用程序将它们拥有的所有资源放入一个巨大的描述符集中,并使其对所有着色器可用。无需管理描述符池,也无需为每个着色器构建描述符集。所有内容都一次性对着色器可用。着色器可以访问系统中的所有资源,唯一实际的限制是性能。

让我们通过探索 LightweightVK 框架来学习如何使用描述符集和描述符索引 Vulkan。

如何实现...

让我们看看 lvk::VulkanContext 类中处理描述符的一些部分。在 lightweightvk/lvk/vulkan/VulkanClasses.h 中的 VulkanContext 类声明包含这些成员字段。整数变量存储当前分配的描述符集 vkDSet_ 中可以存储的最大资源数量,该描述符集是从描述符池 vkDPool_ 分配的。描述符池是根据描述符集布局 vkDSL_ 分配的。提交句柄 lastSubmitHandle_ 与描述符集是最后提交的一部分相对应。提交句柄在上一章的配方 使用 Vulkan 命令缓冲区 中进行了讨论。

 uint32_t currentMaxTextures_ = 16;
  uint32_t currentMaxSamplers_ = 16;
  VkPipelineLayout vkPipelineLayout_ = VK_NULL_HANDLE;
  VkDescriptorSetLayout vkDSL_ = VK_NULL_HANDLE;
  VkDescriptorPool vkDPool_ = VK_NULL_HANDLE;
  VkDescriptorSet vkDSet_ = VK_NULL_HANDLE;
  SubmitHandle lastSubmitHandle = SubmitHandle();

让我们从函数 growDescriptorPool() 开始探索,该函数根据所需的纹理和采样器的数量需要时重新创建 Vulkan 对象。为了可读性,省略了过多的错误检查:

  1. 首先,进行错误检查以确保资源数量在硬件特定的限制内。
Result lvk::VulkanContext::growDescriptorPool(
  uint32_t maxTextures, uint32_t maxSamplers)
{
  currentMaxTextures_ = maxTextures;
  currentMaxSamplers_ = maxSamplers;
  if (!LVK_VERIFY(maxTextures <= vkPhysicalDeviceVulkan12Properties_.
        maxDescriptorSetUpdateAfterBindSampledImages))
    LLOGW(“Max Textures exceeded: %u (max %u)”, maxTextures,
        vkPhysicalDeviceVulkan12Properties_.
          maxDescriptorSetUpdateAfterBindSampledImages);
  if (!LVK_VERIFY(maxSamplers <= vkPhysicalDeviceVulkan12Properties_.
        maxDescriptorSetUpdateAfterBindSamplers))
    LLOGW(“Max Samplers exceeded %u (max %u)”, maxSamplers,
        vkPhysicalDeviceVulkan12Properties_.
          maxDescriptorSetUpdateAfterBindSamplers);
  1. 如果存在,则释放之前的 Vulkan 资源。
 if (vkDSL_ != VK_NULL_HANDLE) {
    deferredTask(std::packaged_task<void()>(
      [device = vkDevice_, dsl = vkDSL_]() {
        vkDestroyDescriptorSetLayout(device, dsl, nullptr); })); }
  if (vkDPool_ != VK_NULL_HANDLE) {
    deferredTask(std::packaged_task<void()>(
      [device = vkDevice_, dp = vkDPool_]() {
        vkDestroyDescriptorPool(device, dp, nullptr); })); }
  if (vkPipelineLayout_ != VK_NULL_HANDLE) {
    deferredTask(std::packaged_task<void()>(
      [device = vkDevice_, layout = vkPipelineLayout_]() {
        vkDestroyPipelineLayout(device, layout, nullptr); })); }
  1. 创建一个新的描述符集布局,该布局将被所有 Vulkan 管道共享。它应包含 LightweightVK 支持的所有 Vulkan 资源 – 样本图像、采样器和存储图像。
 const VkDescriptorSetLayoutBinding bindings[kBinding_NumBindings] ={
    getDSLBinding(kBinding_Textures,
      VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, maxTextures),
    getDSLBinding(kBinding_Samplers,
      VK_DESCRIPTOR_TYPE_SAMPLER, maxSamplers),
    getDSLBinding(kBinding_StorageImages,
      VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, maxTextures),
  };
  1. 描述符索引功能允许在绑定后更新描述符集。
 const uint32_t flags = VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT |
               VK_DESCRIPTOR_BINDING_UPDATE_UNUSED_WHILE_PENDING_BIT |
                           VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT;
  VkDescriptorBindingFlags bindingFlags[kBinding_NumBindings];
  for (int i = 0; i < kBinding_NumBindings; ++i)
    bindingFlags[i] = flags;
  1. 应准备一系列 Vulkan Vk…CreateInfo 结构来创建所需的 VkDescriptorSetLayout 对象。
 const VkDescriptorSetLayoutBindingFlagsCreateInfo bindingFlagsci = {
      .sType =  VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO_EXT,
      .bindingCount = kBinding_NumBindings,
      .pBindingFlags = bindingFlags,
  };
  const VkDescriptorSetLayoutCreateInfo dslci = {
      .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
      .pNext = &bindingFlagsci,
      .flags =
       VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT_EXT,
      .bindingCount = kBinding_NumBindings,
      .pBindings = bindings,
  };
  vkCreateDescriptorSetLayout(vkDevice_, &dslci, nullptr, &vkDSL_);
  1. 使用这个新创建的描述符集布局,我们可以创建一个新的描述符池。注意标志 VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT,这是为了支持描述符集布局的相应标志 VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT
 const VkDescriptorPoolSize poolSizes[kBinding_NumBindings] = {
    VkDescriptorPoolSize{
      VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, maxTextures},
    VkDescriptorPoolSize{
      VK_DESCRIPTOR_TYPE_SAMPLER, maxSamplers},
    VkDescriptorPoolSize{
      VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, maxTextures},
  };
  const VkDescriptorPoolCreateInfo ci = {
    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
    .flags = VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT,
    .maxSets = 1,
    .poolSizeCount = kBinding_NumBindings,
    .pPoolSizes = poolSizes,
  };
  vkCreateDescriptorPool(vkDevice_, &ci, nullptr, &vkDPool_);
  1. 现在我们可以从描述符池 vkDPool_ 中分配一个描述符集。
 const VkDescriptorSetAllocateInfo ai = {
    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
    .descriptorPool = vkDPool_,
    .descriptorSetCount = 1,
    .pSetLayouts = &vkDSL_,
  };
  vkAllocateDescriptorSets(vkDevice_, &ai, &vkDSet_);
  1. 要创建 Vulkan 管线,我们需要一个管线布局。在LightweightVK中,管线布局在所有管线之间是共享的。在 Vulkan 1.3 中,使用单个描述符集布局vkDSL_来创建管线布局就足够了。然而,LightweightVK支持在 Mac 上使用 MoltenVK,而Metal Shading Language不支持在着色器中别名描述符集。因此,我们在这里进行复制以防止别名并保持与 MoltenVK 的兼容性。我们将推送常量的大小设置为128字节,这是 Vulkan 规范保证的最大大小。
 const VkDescriptorSetLayout dsls[] = {vkDSL_, vkDSL_, vkDSL_};
  const VkPushConstantRange range = {
    .stageFlags = VK_SHADER_STAGE_VERTEX_BIT |
                  VK_SHADER_STAGE_FRAGMENT_BIT |
                  VK_SHADER_STAGE_COMPUTE_BIT,
    .offset = 0,
    .size = 128,
  };
  const VkPipelineLayoutCreateInfo ci = {
    .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
    .setLayoutCount = (uint32_t)LVK_ARRAY_NUM_ELEMENTS(dsls),
    .pSetLayouts = dsls,
    .pushConstantRangeCount = 1,
    .pPushConstantRanges = &range,
  };
  vkCreatePipelineLayout(
    vkDevice_, &ci, nullptr, &vkPipelineLayout_);
  return Result();
}

growDescriptorPool()函数是我们描述符集管理机制的第一个部分。我们有一个描述符集,我们必须更新它。更新是通过另一个函数checkAndUpdateDescriptorSets()完成的,在我们可以分发 Vulkan 绘制调用之前调用。错误检查的一些部分再次被省略了:

  1. 新创建的资源可以立即使用 - 确保它们被放入描述符集中。我们之前在配方在 Vulkan 中使用纹理数据中讨论了纹理创建过程。一旦创建了一个新的纹理,awaitingCreation_标志就会被设置,以表示我们需要更新描述符集。如果没有新的纹理或样本器,则不需要更新描述符集。
void VulkanContext::checkAndUpdateDescriptorSets() {
  if (!awaitingCreation_) return;
  1. 如我们从之前的配方中学到的,存储 Vulkan 对象,纹理和样本器都存储在VulkanContext内的池中。在这里,我们根据需要扩展描述符池,以容纳所有这些纹理和样本器。
 uint32_t newMaxTextures = currentMaxTextures_;
  uint32_t newMaxSamplers = currentMaxSamplers_;
  while (texturesPool_.objects_.size() > newMaxTextures)
    newMaxTextures *= 2;
  while (samplersPool_.objects_.size() > newMaxSamplers)
    newMaxSamplers *= 2;
  if (newMaxTextures != currentMaxTextures_ ||
      newMaxSamplers != currentMaxSamplers_) {
    growDescriptorPool(newMaxTextures, newMaxSamplers);
  }
  1. 让我们准备 Vulkan 结构来更新描述符集,以包含采样和存储图像。LightweightVK总是存储一个索引为0的虚拟纹理,以避免 GLSL 着色器中的稀疏数组,并使所有着色器能够安全地采样不存在的纹理。
 std::vector<VkDescriptorImageInfo> infoSampledImages;
  std::vector<VkDescriptorImageInfo> infoStorageImages;
  infoSampledImages.reserve(texturesPool_.numObjects());
  infoStorageImages.reserve(texturesPool_.numObjects());
  VkImageView dummyImageView =
    texturesPool_.objects_[0].obj_.imageView_;
  1. 遍历纹理池,并根据图像属性填充VkDescriptorImageInfo结构。多采样图像只能通过texelFetch()从着色器中访问。这不被LightweightVK支持,所以我们在这里跳过这一步。
 for (const auto& obj : texturesPool_.objects_) {
    const VulkanTexture& texture = obj.obj_;
    const bool isTextureAvailable = texture.image_ &&
      ((texture.image_->vkSamples_ & VK_SAMPLE_COUNT_1_BIT) ==
        VK_SAMPLE_COUNT_1_BIT);
    const bool isSampledImage = isTextureAvailable &&
      texture.image_->isSampledImage();
    const bool isStorageImage = isTextureAvailable && 
      texture.image_->isStorageImage();
  1. 预期图像将处于特定的图像布局中。采样图像应使用VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,而存储图像应使用VK_IMAGE_LAYOUT_GENERALLightweightVK会自动确保正确的图像布局转换。我们将在后续章节中讨论它。
 infoSampledImages.push_back({
      VK_NULL_HANDLE,
      isSampledImage ? texture.imageView_ : dummyImageView,
      VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL});
    infoStorageImages.push_back({
      VK_NULL_HANDLE,
      isStorageImage ? texture.imageView_ : dummyImageView,
      VK_IMAGE_LAYOUT_GENERAL});
  }
  1. 样本器以非常相似的方式处理。
 std::vector<VkDescriptorImageInfo> infoSamplers;
  infoSamplers.reserve(samplersPool_.objects_.size());
  for (const auto& sampler : samplersPool_.objects_) {
    infoSamplers.push_back({
      sampler.obj_ ? sampler.obj_ : samplersPool_.objects_[0].obj_,
      VK_NULL_HANDLE,
      VK_IMAGE_LAYOUT_UNDEFINED});
  }
  1. 结构VkWriteDescriptorSet指定了描述符集写入操作的参数。我们需要为每个对应于3种不同描述符类型的3个绑定填充一个结构:VK_DESCRIPTOR_TYPE_SAMPLED_IMAGEVK_DESCRIPTOR_TYPE_SAMPLERVK_DESCRIPTOR_TYPE_STORAGE_IMAGE。这个代码片段很简单,但有些长。我们将其完整地包含在这里供您参考。
 VkWriteDescriptorSet write[kBinding_NumBindings] = {};
  uint32_t numWrites = 0;
  if (!infoSampledImages.empty())
    write[numWrites++] = VkWriteDescriptorSet{
      .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
      .dstSet = vkDSet_,
      .dstBinding = kBinding_Textures,
      .dstArrayElement = 0,
      .descriptorCount = (uint32_t)infoSampledImages.size(),
      .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
      .pImageInfo = infoSampledImages.data(),
    };
  if (!infoSamplers.empty())
    write[numWrites++] = VkWriteDescriptorSet{
      .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
      .dstSet = vkDSet_,
      .dstBinding = kBinding_Samplers,
      .dstArrayElement = 0,
      .descriptorCount = (uint32_t)infoSamplers.size(),
      .descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER,
      .pImageInfo = infoSamplers.data(),
    };
  if (!infoStorageImages.empty())
    write[numWrites++] = VkWriteDescriptorSet{
      .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
      .dstSet = vkDSet_,
      .dstBinding = kBinding_StorageImages,
      .dstArrayElement = 0,
      .descriptorCount = (uint32_t)infoStorageImages.size(),
      .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
      .pImageInfo = infoStorageImages.data(),
    };
  1. 如果我们已填充任何 VkWriteDescriptorSet 结构,则调用 Vulkan 函数 vkUpdateDescriptorSets() 来更新描述符集。由于我们正在更新整个描述符集,因此确保 Vulkan 在调用 wait() 时没有使用它,这是至关重要的,这里使用的是最后一个已知的提交句柄。这种机制在前一章的配方 使用 Vulkan 命令缓冲区 中已有讨论。
 if (numWrites) {
    immediate_->wait(std::exchange(
      lastSubmitHandle, immediate_->getLastSubmitHandle()));
    vkUpdateDescriptorSets(vkDevice_, numWrites, write, 0, nullptr);
  }
  awaitingCreation_ = false;
}

描述符集更新过程的 C++ 部分已经完成。现在唯一剩下的部分是如何从 GLSL 着色器中访问这些描述符集。让我们来看看它是如何工作的。

它是如何工作的...

VulkanContext 将一些辅助代码注入到 GLSL 着色器中,以简化与我们的无绑定描述符集的工作流程。让我们回顾一下 VulkanContext::createShaderModule() 函数,它负责注入。以下是自动插入到每个片段着色器中的 GLSL 代码:

  1. 首先,它声明了一些存储在我们的无绑定描述符集中的未绑定数组。在这里,您会注意到描述符集 ID 范围从 02。然而,不要被这个误导;这是绑定到三个不同位置的相同描述符集。正如在本配方中先前演示的那样,这是确保与 MoltenVK 兼容所必需的。
layout (set = 0, binding = 0) uniform texture2D kTextures2D[];
layout (set = 1, binding = 0) uniform texture3D kTextures3D[];
layout (set = 2, binding = 0) uniform textureCube kTexturesCube[];
layout (set = 0, binding = 1) uniform sampler kSamplers[];
layout (set = 1, binding = 1) uniform samplerShadow kSamplersShadow[];
  1. 然后,添加了一些辅助函数。这些函数对应于标准的 GLSL 函数,例如 texture()textureLod() 等。这些函数简化了使用描述符索引的过程。我们在这里只列出其中的一些,以便您能够了解整体情况。
vec4 textureBindless2D(uint textureid, uint samplerid, vec2 uv) {
  return texture(sampler2D(kTextures2D[textureid],
                           kSamplers[samplerid]), uv);
}
vec4 textureBindless2DLod(
  uint textureid, uint samplerid, vec2 uv, float lod) {
  return textureLod(sampler2D(kTextures2D[textureid],
                              kSamplers[samplerid]), uv, lod);
}
float textureBindless2DShadow(
  uint textureid, uint samplerid, vec3 uvw) {
  return texture(sampler2DShadow(kTextures2D[textureid],
                                 kSamplersShadow[samplerid]), uvw);
}
ivec2 textureBindlessSize2D(uint textureid) {
  return textureSize(kTextures2D[textureid], 0);
}
  1. 有了这个,我们的 GLSL 片段着色器 Chapter03/02_STB/main.frag 可以重写如下,无需手动声明这些冗长的数据结构。
layout (location=0) in vec2 uv;
layout (location=0) out vec4 out_FragColor;
layout(push_constant) uniform PerFrameData {
  uniform mat4 MVP;
  uint textureId;
};
void main() {
  out_FragColor = textureBindless2D(textureId, 0, uv);
};

在绑定的描述符集代码就绪后,我们可以渲染纹理对象,例如以下图像中的对象。

图 3.4:渲染纹理四边形

图 3.4:渲染纹理四边形

还有更多...

Vulkan 中高效资源管理的主题非常广泛且复杂。当我们讨论 3D 场景数据管理和复杂多纹理材质的渲染时,我们将在稍后返回到描述符集管理。

第五章:4 添加用户交互和生产工具

加入我们的 Discord 书籍社区

packt.link/unitydev

在本章中,我们将学习如何实现基本的辅助工具,以极大地简化图形应用程序的调试。这些示例使用 Vulkan 实现,并使用了前三个章节的所有材料。在 第三章与 Vulkan 对象一起工作 中,我们展示了如何包装原始 Vulkan 代码的各种实例,以创建和维护基本的 Vulkan 状态和对象。在本章中,我们将展示如何以易于扩展和适应不同应用程序的方式开始实现 Vulkan 渲染代码。从 2D 用户界面渲染开始是最好的学习方法,因为它使事情变得简单,并允许我们专注于渲染代码,而不会被复杂的 3D 图形算法所淹没。

我们将涵盖以下食谱:

  • 渲染 ImGui 用户界面

  • 将 Tracy 集成到 C++ 应用程序中

  • 添加每秒帧数计数器

  • 在 Vulkan 中使用立方体贴图纹理

  • 使用 3D 相机和基本用户交互一起工作

  • 添加相机动画和运动

  • 实现即时模式 3D 绘图画布

  • 使用 ImGui 和 ImPlot 在屏幕上渲染图表

  • 将所有内容整合到 Vulkan 应用程序中

技术要求

要在您的 Linux 或 Windows PC 上运行本章的代码,您需要一个支持 Vulkan 1.3 的最新驱动程序的 GPU。本章使用的源代码可以从 github.com/PacktPublishing/3D-Graphics-Rendering-Cookbook 下载。

渲染 ImGui 用户界面

ImGui 是一个流行的无冗余图形用户界面库,用于 C++,对于图形应用程序的交互式调试至关重要。ImGui 集成是 LightweightVK 库的一部分。在本食谱中,我们将逐步通过代码,展示如何创建一个带有 ImGui 渲染的示例应用程序。

准备工作

建议重新查看 第三章,与 Vulkan 对象一起工作 中的 使用 Vulkan 描述符索引 食谱,并回忆该章节其他食谱中描述的 Vulkan 基础知识。

本食谱涵盖了 lightweight/lvk/HelpersImGui.cpp 的源代码。本食谱的示例代码位于 Chapter04/01_ImGui

如何做到这一点...

让我们从最简单的 ImGui 示例应用程序开始,看看如何使用由 LightweightVK 提供的 ImGui Vulkan 包装器:

首先,我们创建一个 lvk::ImGuiRenderer 对象。它接受一个指向我们的 lvk::IContext 的指针,默认字体的名称以及像素中的默认字体大小。ImGuiRenderer 将负责所有低级 ImGui 初始化和代码:

std::unique_ptr<lvk::ImGuiRenderer> imgui =
  std::make_unique<lvk::ImGuiRenderer>(
    *ctx, “data/OpenSans-Light.ttf”, 30.0f);

让我们创建一些 GLFW 回调,将鼠标移动和按钮按下传递到 ImGui 中。应将 GLFW 鼠标按钮 ID 转换为 ImGui 的 ID:

glfwSetCursorPosCallback(window,
  [](auto* window, double x, double y) {
    ImGui::GetIO().MousePos = ImVec2(x, y);
  });
glfwSetMouseButtonCallback(window,
  [](auto* window, int button, int action, int mods) {
    double xpos, ypos;
    glfwGetCursorPos(window, &xpos, &ypos);
    const ImGuiMouseButton_ imguiButton =
     (button==GLFW_MOUSE_BUTTON_LEFT) ?
       ImGuiMouseButton_Left:(button == GLFW_MOUSE_BUTTON_RIGHT ?
       ImGuiMouseButton_Right : ImGuiMouseButton_Middle);
    ImGuiIO& io               = ImGui::GetIO();
    io.MousePos               = ImVec2((float)xpos, (float)ypos);
    io.MouseDown[imguiButton] = action == GLFW_PRESS;
  });

在我们的典型渲染循环中,我们可以按照以下方式调用 ImGui 渲染命令。ImGuiRenderer::beginFrame() 方法接受一个 lvk::Framebuffer 对象,以便它可以正确设置渲染管线:

lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
const lvk::Framebuffer framebuffer = {
  .color = {{ .texture = ctx->getCurrentSwapchainTexture() }}};
buf.cmdBeginRendering({ .color = { {
  .loadOp = lvk::LoadOp_Clear,
  .clearColor = {1.0f, 1.0f, 1.0f, 1.0f} } } }, framebuffer);
imgui->beginFrame(framebuffer);

让我们绘制一个带有纹理的 ImGui 窗口。一个纹理索引作为 ImTextureID 值传递给 ImGui,以便它可以与我们在上一章的 使用 Vulkan 描述符集 菜单中讨论的无绑定渲染方案一起使用:

ImGui::Begin(“Texture Viewer”, nullptr,
  ImGuiWindowFlags_AlwaysAutoResize);
ImGui::Image(ImTextureID(texture.indexAsVoid()),
  ImVec2(512, 512));
ImGui::ShowDemoWindow();
ImGui::End();

ImGuiRenderer::endFrame() 方法是一个包含实际 Vulkan 命令的命令缓冲区,然后我们可以调用 cmdEndRendering() 并提交我们的命令缓冲区:

imgui->endFrame(buf);
buf.cmdEndRendering();
ctx->submit(buf, ctx->getCurrentSwapchainTexture());

这个演示应用程序应该渲染一个类似于以下截图所示的简单 ImGui 界面:

图 4.1:ImGui 渲染

图 4.1:ImGui 渲染

现在让我们看看 LightweightVK 内部低级实现,它负责渲染 ImGui 数据。

它是如何工作的…

lvk::ImGuiRenderer 辅助类在 lvk\HelpersImGui.h 中声明。以下是它的声明。

  1. 构造函数接受对 lvk::IContext 的引用、默认 .ttf 字体文件的名称以及默认字体大小(以像素为单位)。updateFont() 方法可以在稍后阶段调用以覆盖之前使用的字体。此方法从构造函数中调用,以设置默认字体:
class ImGuiRenderer {
 public:
  explicit ImGuiRenderer(lvk::IContext& ctx,
    const char* defaultFontTTF = nullptr,
    float fontSizePixels = 24.0f);
  ~ImGuiRenderer();
  void updateFont(const char* defaultFontTTF, float fontSizePixels);
  1. beginFrame()endFrame() 方法是必要的,用于准备 ImGui 以进行渲染并从 ImGui 绘制数据生成 Vulkan 命令。setDisplayScale() 方法可以用来覆盖 ImGui 的 DisplayFramebufferScale 因子:
 void beginFrame(const lvk::Framebuffer& desc);
  void endFrame(lvk::ICommandBuffer& cmdBuffer);
  void setDisplayScale(float displayScale);
  1. lvk::ImGuiRenderer 类的私有部分包含一个创建新渲染管线和渲染所需的一组数据的函数。有一个单独的顶点和片段着色器集、一个渲染管线以及从我们在构造时提供的 .ttf 字体文件创建的纹理:
 private:
  lvk::Holder<lvk::RenderPipelineHandle> createNewPipelineState(
    const lvk::Framebuffer& desc);
 private:
  lvk::IContext& ctx_;
  lvk::Holder<lvk::ShaderModuleHandle> vert_;
  lvk::Holder<lvk::ShaderModuleHandle> frag_;
  lvk::Holder<lvk::RenderPipelineHandle> pipeline_;
  lvk::Holder<lvk::TextureHandle> fontTexture_;
  float displayScale_ = 1.0f;
  uint32_t nonLinearColorSpace_ = 0;
  uint32_t frameIndex_ = 0;
  1. 为了确保无停滞操作,LightweightVK 使用多个缓冲区将 ImGui 顶点和索引数据传递到 Vulkan(以下代码中的 vbib 分别代表顶点缓冲区和索引缓冲区):
 struct DrawableData {
    lvk::Holder<BufferHandle> vb_;
    lvk::Holder<BufferHandle> ib_;
    uint32_t numAllocatedIndices_ = 0;
    uint32_t numAllocatedVerteices_ = 0;
  };
  static constexpr uint32_t kNumSwapchainImages = 3;
  DrawableData drawables_[kNumSwapchainImages] = {};
};

现在我们可以深入到实现部分,这部分位于 lvk/HelpersImGui.cpp

  1. 顶点着色器使用可编程顶点提取,我们在上一章的 处理 Vulkan 中的缓冲区 菜单中简要提到了它。让我们更详细地看看它。

  2. ImGui 为每个顶点提供二维屏幕坐标、二维纹理坐标以及一个 RGBA 颜色。我们声明一个 Vertex 结构来存储每个顶点的数据,并将所有顶点存储在 VertexBuffer 内部的 vertices[] 数组中。buffer_reference GLSL 布局限定符声明了一个类型,而不是缓冲区的实例,这样可以在稍后阶段将对该缓冲区的引用传递到着色器中:

layout (location = 0) out vec4 out_color;
layout (location = 1) out vec2 out_uv;
layout (location = 2) out flat uint out_textureId;
struct Vertex {
  float x, y;
  float u, v;
  uint rgba;
};
layout(std430, buffer_reference) readonly buffer VertexBuffer {
  Vertex vertices[];
};
  1. 通过 Vulkan 推送常量传递对包含我们每个顶点数据的VertexBuffer的引用。除此之外,我们传递一个纹理 ID 和一些表示为vec4 LRTB内部左、右、上、下平面的 2D 视口参数:
layout(push_constant) uniform PushConstants {
  vec4 LRTB;
  VertexBuffer vb;
  uint textureId;
} pc;
void main() {
  float L = pc.LRTB.x;
  float R = pc.LRTB.y;
  float T = pc.LRTB.z;
  float B = pc.LRTB.w;

一旦我们有了视口参数,我们可以按照以下方式构造一个正交投影矩阵,这与glm::ortho()创建投影矩阵的方式相似:

 mat4 proj = mat4(
    2.0 / (R-L),              0.0,  0.0,  0.0,
    0.0,              2.0 / (T-B),  0.0,  0.0,
    0.0,                      0.0, -1.0,  0.0,
    (R+L) / (L-R),  (T+B) / (B-T),  0.0,  1.0);
  1. 使用内置的 GLSL 变量gl_VertexIndexVertexBuffer::vertices数组中提取当前顶点。RGBA 顶点颜色v.rgba打包成一个 32 位无符号整数,可以使用unpackUnorm4x8() GLSL 内置函数解包到vec4
 Vertex v = pc.vb.vertices[gl_VertexIndex];
  out_color = unpackUnorm4x8(v.rgba);
  1. 纹理坐标和纹理 ID 未经更改地传递到片段着色器。投影矩阵通过将0作为Z分量添加到vec4中,与顶点位置相乘:
 out_uv = vec2(v.u, v.v);
  out_textureId = pc.textureId;
  gl_Position = proj * vec4(v.x, v.y, 0, 1);
}

相应的 GLSL 片段着色器要简单得多,如下所示:

输入位置应与顶点着色器中的相应输出位置匹配:

layout (location = 0) in vec4 in_color;
layout (location = 1) in vec2 in_uv;
layout (location = 2) in flat uint in_textureId;
layout (location = 0) out vec4 out_color;

LightweightVK支持一些基本的 sRGB 帧缓冲区渲染。此着色器常量用于启用一些基本的色调映射。纹理 ID 用于访问所需的免绑定纹理。采样器始终是索引0的默认采样器。constant_id GLSL 修饰符用于指定 Vulkan 的专用常量:

layout (constant_id = 0) const bool kNonLinearColorSpace = false;
void main() {
  vec4 c = in_color * texture(sampler2D(
    kTextures2D[in_textureId], kSamplers[0]), in_uv);

在这里,我们可以将我们的 UI 以线性颜色空间渲染到 sRGB 帧缓冲区中:

 out_color = kNonLinearColorSpace ?
    vec4(pow(c.rgb, vec3(2.2)), c.a) : c;
}

现在让我们看看lvk::ImGuiRender实现中的 C++代码。那里有一个私有的ImGuiRenderer::createNewPipelineState()辅助函数,它负责为 ImGui 渲染创建一个新的渲染管线。由于 Vulkan 1.3 中所有相关 Vulkan 状态都可以是动态的,因此一个不可变的管线就足够了。

  1. 创建管线需要帧缓冲区描述,因为我们需要有关颜色和深度附加格式的信息:
Holder<RenderPipelineHandle> ImGuiRenderer::createNewPipelineState(
  const lvk::Framebuffer& desc)
{
  nonLinearColorSpace_ =
    ctx_.getSwapChainColorSpace() == ColorSpace_SRGB_NONLINEAR ? 1:0;
  return ctx_.createRenderPipeline({
    .smVert = vert_,
    .smFrag = frag_,
  1. 根据 swapchain 颜色空间启用 sRGB 模式,并将其作为 Vulkan 专用常量传递到着色器中:
 .specInfo = { .entries = {{.constantId = 0,
                  .size = sizeof(nonLinearColorSpace_)}},
                  .data = &nonLinearColorSpace_,
                  .dataSize = sizeof(nonLinearColorSpace_) },
  1. 所有 ImGui 元素都需要启用 alpha 混合。如果存在深度缓冲区,它将保持不变,但渲染管线应该知道这一点:
 .color = {{
      .format = ctx_.getFormat(desc.color[0].texture),
      .blendEnabled = true,
      .srcRGBBlendFactor = lvk::BlendFactor_SrcAlpha,
      .dstRGBBlendFactor = lvk::BlendFactor_OneMinusSrcAlpha,
    }},
    .depthFormat = desc.depthStencil.texture ?
      ctx_.getFormat(desc.depthStencil.texture) :
      lvk::Format_Invalid,
    .cullMode = lvk::CullMode_None},
    nullptr);
}

从构造函数中调用另一个辅助函数ImGuiRenderer::updateFont()。以下是它的实现方式。

首先,它使用提供的字体大小设置 ImGui 字体配置参数:

void ImGuiRenderer::updateFont(
  const char* defaultFontTTF, float fontSizePixels)
{
  ImGuiIO& io = ImGui::GetIO();
  ImFontConfig cfg = ImFontConfig();
  cfg.FontDataOwnedByAtlas = false;
  cfg.RasterizerMultiply = 1.5f;
  cfg.SizePixels = ceilf(fontSizePixels);
  cfg.PixelSnapH = true;
  cfg.OversampleH = 4;
  cfg.OversampleV = 4;
  ImFont* font = nullptr;

然后它从.ttf文件加载默认字体:

 if (defaultFontTTF) {
    font = io.Fonts->AddFontFromFileTTF(
      defaultFontTTF, cfg.SizePixels, &cfg);
  }
  io.Fonts->Flags |= ImFontAtlasFlags_NoPowerOfTwoHeight;

最后但同样重要的是,从 ImGui 检索光栅化的 TrueType 字体数据并将其作为LightweightVK纹理存储。这个字体纹理稍后通过其索引 ID 用于渲染:

 unsigned char* pixels;
  int width, height;
  io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
  fontTexture_ = ctx_.createTexture({
    .type = lvk::TextureType_2D,
    .format = lvk::Format_RGBA_UN8,
    .dimensions = {(uint32_t)width, (uint32_t)height},
    .usage = lvk::TextureUsageBits_Sampled,
    .data = pixels }, nullptr);
  io.Fonts->TexID = ImTextureID(fontTexture_.indexAsVoid());
  io.FontDefault = font;
}

所有准备工作都完成了,我们现在可以看看ImGuiRenderer的构造函数和析构函数。这两个成员函数都非常简短。

构造函数初始化 ImGui 和 ImPlot 上下文,以防LightweightVK编译了可选的 ImPlot 支持。目前,LightweightVK只支持单个 ImGui 上下文:

ImGuiRenderer::ImGuiRenderer(lvk::IContext& device, const char* defaultFontTTF, float fontSizePixels) : ctx_(device) {
  ImGui::CreateContext();
#if defined(LVK_WITH_IMPLOT)
  ImPlot::CreateContext();
#endif // LVK_WITH_IMPLOT

在这里,我们设置ImGuiBackendFlags_RendererHasVtxOffset标志,告诉 ImGui 我们的渲染器支持顶点偏移。它启用了使用 16 位索引输出大型网格的功能,使 UI 渲染更高效:

 ImGuiIO& io = ImGui::GetIO();
  io.BackendRendererName = “imgui-lvk”;
  io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset;

所有创建默认字体和着色器的工作都委托给了我们刚才讨论的内容:

 updateFont(defaultFontTTF, fontSizePixels);
  vert_ = ctx_.createShaderModule(
    {codeVS, Stage_Vert, “Shader Module: imgui (vert)”});
  frag_ = ctx_.createShaderModule(
    {codeFS, Stage_Frag, “Shader Module: imgui (frag)”});
}

析构函数很简单,它会清理 ImGui 和可选的 ImPlot:

ImGuiRenderer::~ImGuiRenderer() {
  ImGuiIO& io = ImGui::GetIO();
  io.Fonts->TexID = nullptr;
#if defined(LVK_WITH_IMPLOT)
  ImPlot::DestroyContext();
#endif // LVK_WITH_IMPLOT
  ImGui::DestroyContext();
}

在继续渲染之前,我们还想看看一个简单的函数:ImGuiRenderer::beginFrame()。它使用提供的帧缓冲区开始一个新的 ImGui 帧。在这里,基于实际的帧缓冲区参数懒惰地创建了一个图形管线,因为我们构造函数中没有提供任何帧缓冲区:

void ImGuiRenderer::beginFrame(const lvk::Framebuffer& desc) {
  const lvk::Dimensions dim =
    ctx_.getDimensions(desc.color[0].texture);
  ImGuiIO& io = ImGui::GetIO();
  io.DisplaySize = ImVec2(dim.width / displayScale_,
                          dim.height / displayScale_);
  io.DisplayFramebufferScale = ImVec2(displayScale_, displayScale_);
  io.IniFilename = nullptr;
  if (pipeline_.empty()) {
    pipeline_ = createNewPipelineState(desc);
  }
  ImGui::NewFrame();
}

现在,我们已经准备好在ImGuiRenderer::endFrame()函数中处理 UI 渲染。这个函数每帧运行一次,并填充一个 Vulkan 命令缓冲区。它稍微复杂一些,所以让我们一步一步地了解它是如何工作的。为了简洁起见,以下代码片段省略了错误检查。

首先,我们应该最终完成 ImGui 帧渲染并检索帧绘制数据:

void ImGuiRenderer::endFrame(lvk::ICommandBuffer& cmdBuffer) {
  ImGui::EndFrame();
  ImGui::Render();
  ImDrawData* dd = ImGui::GetDrawData();
  int fb_width  = (int)(dd->DisplaySize.x * dd->FramebufferScale.x);
  int fb_height = (int)(dd->DisplaySize.y * dd->FramebufferScale.y);

让我们准备渲染状态。我们禁用了深度测试和深度缓冲区写入。基于 ImGui 帧缓冲区大小构建了一个视口,我们之前在beginFrame()中设置,使其等于我们的LightweightVK帧缓冲区大小:

 cmdBuffer.cmdBindDepthState({});
  cmdBuffer.cmdBindViewport({
    .x = 0.0f,
    .y = 0.0f,
    .width = (dd->DisplaySize.x * dd->FramebufferScale.x),
    .height = (dd->DisplaySize.y * dd->FramebufferScale.y),
  });

正交投影矩阵的参数在这里准备。它们将通过渲染循环中的 Vulkan 推送常数传递给着色器,与其他参数一起。裁剪参数也在这里准备,以便在渲染循环中使用:

 const float L = dd->DisplayPos.x;
  const float R = dd->DisplayPos.x + dd->DisplaySize.x;
  const float T = dd->DisplayPos.y;
  const float B = dd->DisplayPos.y + dd->DisplaySize.y;
  const ImVec2 clipOff = dd->DisplayPos;
  const ImVec2 clipScale = dd->FramebufferScale;

每个帧都有一个单独的 LVK 缓冲区。这些缓冲区存储整个帧的 ImGui 顶点和索引数据:

 DrawableData& drawableData = drawables_[frameIndex_];
  frameIndex_ =
   (frameIndex_ + 1) % LVK_ARRAY_NUM_ELEMENTS(drawables_);

如果存在来自前一帧的缓冲区,并且这些缓冲区的大小不足以容纳新的顶点或索引数据,则将使用新大小重新创建缓冲区。索引缓冲区通过BufferUsageBits_Index创建:

 if (drawableData.numAllocatedIndices_ < dd->TotalIdxCount) {
    drawableData.ib_ = ctx_.createBuffer({
        .usage = lvk::BufferUsageBits_Index,
        .storage = lvk::StorageType_HostVisible,
        .size = dd->TotalIdxCount * sizeof(ImDrawIdx),
        .debugName = “ImGui: drawableData.ib_”,
    });
    drawableData.numAllocatedIndices_ = dd->TotalIdxCount;
  }

存储顶点的缓冲区实际上是一个BufferUsageBits_Storage存储缓冲区,因为我们的 GLSL 着色器使用可编程顶点提取来加载顶点:

 if (drawableData.numAllocatedVerteices_ < dd->TotalVtxCount) {
    drawableData.vb_ = ctx_.createBuffer({
        .usage = lvk::BufferUsageBits_Storage,
        .storage = lvk::StorageType_HostVisible,
        .size = dd->TotalVtxCount * sizeof(ImDrawVert),
        .debugName = “ImGui: drawableData.vb_”,
    });
    drawableData.numAllocatedVerteices_ = dd->TotalVtxCount;
  }

让我们将一些数据上传到顶点和索引缓冲区。整个 ImGui 帧数据都上传到这里。偏移量被仔细保留,这样我们就能知道每个 ImGui 绘制命令数据存储的位置:

 ImDrawVert* vtx = (ImDrawVert*)ctx_.getMappedPtr(drawableData.vb_);
  uint16_t* idx = (uint16_t*)ctx_.getMappedPtr(drawableData.ib_);
  for (int n = 0; n < dd->CmdListsCount; n++) {
    const ImDrawList* cmdList = dd->CmdLists[n];
    memcpy(vtx, cmdList->VtxBuffer.Data,
      cmdList->VtxBuffer.Size * sizeof(ImDrawVert));
    memcpy(idx, cmdList->IdxBuffer.Data,
      cmdList->IdxBuffer.Size * sizeof(ImDrawIdx));
    vtx += cmdList->VtxBuffer.Size;
    idx += cmdList->IdxBuffer.Size;
  }

需要刷新可见于主机的内存。这将允许LightweightVK在内存不是主机对齐的情况下发出相应的 Vulkan vkFlushMappedMemoryRanges()命令:

 ctx_.flushMappedMemory(
    drawableData.vb_, 0, dd->TotalVtxCount * sizeof(ImDrawVert));
  ctx_.flushMappedMemory(
    drawableData.ib_, 0, dd->TotalIdxCount * sizeof(ImDrawIdx));
  }

让我们将索引缓冲区和渲染管线绑定到命令缓冲区,并进入渲染循环,该循环遍历所有 ImGui 渲染命令:

 uint32_t idxOffset = 0;
  uint32_t vtxOffset = 0;
  cmdBuffer.cmdBindIndexBuffer(
    drawableData.ib_, lvk::IndexFormat_UI16);
  cmdBuffer.cmdBindRenderPipeline(pipeline_);
  for (int n = 0; n < dd->CmdListsCount; n++) {
    const ImDrawList* cmdList = dd->CmdLists[n];
    for (int cmd_i = 0; cmd_i < cmdList->CmdBuffer.Size; cmd_i++) {
      const ImDrawCmd& cmd = cmdList->CmdBuffer[cmd_i];

视口裁剪是在 CPU 端完成的。如果 ImGui 绘制命令完全被裁剪,我们应该跳过它:

 ImVec2 clipMin((cmd.ClipRect.x - clipOff.x) * clipScale.x,
                     (cmd.ClipRect.y - clipOff.y) * clipScale.y);
      ImVec2 clipMax((cmd.ClipRect.z - clipOff.x) * clipScale.x,
                     (cmd.ClipRect.w - clipOff.y) * clipScale.y);
      if (clipMin.x < 0.0f) clipMin.x = 0.0f;
      if (clipMin.y < 0.0f) clipMin.y = 0.0f;
      if (clipMax.x > fb_width ) clipMax.x = (float)fb_width;
      if (clipMax.y > fb_height) clipMax.y = (float)fb_height;
      if (clipMax.x <= clipMin.x || clipMax.y <= clipMin.y)
         continue;

所有必要的渲染数据都通过 Vulkan 推送常数传递到 GLSL 着色器中。它包括正交投影数据,即左、右、上、下平面,对顶点缓冲区的引用以及用于无绑定渲染的纹理 ID:

 struct VulkanImguiBindData {
        float LRTB[4];
        uint64_t vb = 0;
        uint32_t textureId = 0;
      } bindData = {
          .LRTB = {L, R, T, B},
          .vb = ctx_.gpuAddress(drawableData.vb_),
          .textureId = static_cast<uint32_t>(
            reinterpret_cast<ptrdiff_t>(cmd.TextureId)),
      };
      cmdBuffer.cmdPushConstants(bindData);

设置裁剪测试,以便它可以对 ImGui 元素进行精确裁剪:

 cmdBuffer.cmdBindScissorRect({
        uint32_t(clipMin.x),
        uint32_t(clipMin.y),
        uint32_t(clipMax.x - clipMin.x),
        uint32_t(clipMax.y - clipMin.y)});

实际的渲染是通过 cmdDrawIndexed() 完成的。在这里,我们使用索引偏移量和顶点偏移量参数来访问我们大型的每帧顶点和索引缓冲区中的正确数据:

 cmdBuffer.cmdDrawIndexed(cmd.ElemCount, 1u,
        idxOffset + cmd.IdxOffset,
        int32_t(vtxOffset + cmd.VtxOffset));
    }
    idxOffset += cmdList->IdxBuffer.Size;
    vtxOffset += cmdList->VtxBuffer.Size;
  }
}

现在我们已经完成了所有的 ImGui 渲染,并且可以使用 Vulkan 渲染整个 ImGui 用户界面。让我们跳到下一个菜谱,学习其他生产力和调试工具,例如性能分析、3D 摄像机控制、每秒帧数计数器和绘图画布。

将 Tracy 集成到 C++ 应用程序中

在上一章 使用 Vulkan 对象 中,我们学习了如何使用 Vulkan 和 LightweightVK 编写小型图形应用程序。在实际应用程序中,通常需要在运行时能够快速获取性能分析信息。在本菜谱中,我们将展示如何在使用 3D 应用程序中利用 Tracy 性能分析器。

准备工作

本菜谱的演示应用程序的完整源代码位于 Chapter04/02_TracyProfiler

请确保从 github.com/wolfpld/tracy 下载适用于您平台的预编译 Tracy 客户端应用程序。在我们的书中,我们使用的是可以从 github.com/wolfpld/tracy/releases/tag/v0.10 下载的 Tracy 版本 0.10。

如何操作...

Tracy 性能分析器本身集成在 LightweightVK 库中。我们的演示应用程序以及 LightweightVK 渲染代码的许多部分都增加了对性能分析函数的调用。这些调用被封装在一组宏中,以便不直接调用 Tracy。这允许在需要时打开和关闭分析器,甚至切换到其他分析器。让我们看一下演示应用程序,然后探索其底层低级实现:

首先,让我们看一下根 LightweightVK CMake 配置文件 deps/src/lightweightvk/CMakeLists.txt,看看 Tracy 库是如何添加到项目中的。一开始,我们应该看到一个默认启用的选项:

option(LVK_WITH_TRACY  “Enable Tracy profiler”  ON)
  1. 在同一文件的几行之后,CMake 选项被转换为 TRACY_ENABLE C++ 编译器宏定义,并将 Tracy 库添加到项目中。请注意,这是 LightweightVK Git 仓库的 third-party/deps/src/ 文件夹,它本身位于书籍仓库的 deps/src/ 文件夹中:
if(LVK_WITH_TRACY)
  add_definitions(“-DTRACY_ENABLE=1”)
  add_subdirectory(third-party/deps/src/tracy)
  lvk_set_folder(TracyClient “third-party”)
endif()
  1. 让我们继续滚动相同的文件,deps/src/lightweightvk/CMakeLists.txt,再翻几页。根据之前启用的LVK_WITH_TRACY CMake 选项,我们将LVK_WITH_TRACY C++宏定义导出到LightweightVK的所有用户。Tracy 库与LVKLibrary目标链接,这样每个使用LightweightVK的应用程序都可以访问 Tracy 函数:
if(LVK_WITH_TRACY)
  target_compile_definitions(
    LVKLibrary PUBLIC “LVK_WITH_TRACY=1”)
  target_link_libraries(LVKLibrary PUBLIC TracyClient)
endif()
  1. 现在,让我们查看lightweightvk/lvk/LVK.h并检查一些宏定义。LVK_WITH_TRACY宏用于启用或禁用 Tracy 的使用。一些预定义的 RGB 颜色被声明为宏,用于标记重要的兴趣点操作:
#if defined(LVK_WITH_TRACY)
  #include “tracy/Tracy.hpp”
  #define LVK_PROFILER_COLOR_WAIT    0xff0000
  #define LVK_PROFILER_COLOR_SUBMIT  0x0000ff
  #define LVK_PROFILER_COLOR_PRESENT 0x00ff00
  #define LVK_PROFILER_COLOR_CREATE  0xff6600
  #define LVK_PROFILER_COLOR_DESTROY 0xffa500
  #define LVK_PROFILER_COLOR_BARRIER 0xffffff
  1. 其他宏直接映射到 Tracy 函数,这样我们就可以以非侵入的方式使用 Tracy 区域:
 #define LVK_PROFILER_FUNCTION() ZoneScoped
  #define LVK_PROFILER_FUNCTION_COLOR(color) ZoneScopedC(color)
  #define LVK_PROFILER_ZONE(name, color) { \
      ZoneScopedC(color);                  \
      ZoneName(name, strlen(name))
  #define LVK_PROFILER_ZONE_END() }
  1. LVK_PROFILER_THREAD宏可以用来设置 C++线程的名称。LVK_PROFILER_FRAME宏用于在渲染期间标记下一帧的开始。它在lvk::VulkanSwapchain::present()中由LightweightVK使用,如果您想实现自己的交换链管理代码,例如在 Android 上使用 OpenXR,这可能很有帮助:
 #define LVK_PROFILER_THREAD(name) tracy::SetThreadName(name)
  #define LVK_PROFILER_FRAME(name) FrameMarkNamed(name)
  1. 一旦禁用 Tracy,所有宏都定义为无操作,区域被定义为空的 C++作用域:
#else
  #define LVK_PROFILER_FUNCTION()
  #define LVK_PROFILER_FUNCTION_COLOR(color)
  #define LVK_PROFILER_ZONE(name, color) {
  #define LVK_PROFILER_ZONE_END() }
  #define LVK_PROFILER_THREAD(name)
  #define LVK_PROFILER_FRAME(name)
#endif // LVK_WITH_TRACY

LVK_PROFILER_FUNCTIONLVK_PROFILER_FUNCTION_COLOR宏在LightweightVK代码中广泛使用,以提供良好的分析覆盖率。让我们看看如何在我们的应用程序中使用它们。

它是如何工作的...

示例应用程序位于Chapter04/02_TracyProfiler/src.main.cpp。Tracy 与LightweightVK一起自动初始化。我们现在需要做的是在我们的代码中放置相应的宏。让我们看看它是如何工作的。

在我们创建lvk::IContext的初始化部分,我们使用LVK_PROFILER_ZONELVK_PROFILER_ZONE_END来标记初始化代码中的有趣片段:

 GLFWwindow* window = nullptr;
  std::unique_ptr<lvk::IContext> ctx;
  {
    LVK_PROFILER_ZONE(“Initialization”, LVK_PROFILER_COLOR_CREATE);
    int width  = -95;
    int height = -90;
    window = lvk::initWindow(“Simple example”, width, height);
    ctx    = lvk::createVulkanContextWithSwapchain(
      window, width, height, {});
    LVK_PROFILER_ZONE_END();
  }

在渲染循环内部,我们可以以相同的方式标记不同的兴趣点代码块。十六进制值是 Tracy 在分析窗口中用于突出显示此分析区域的 RGB 颜色。在本文档中之前提到了一些预定义的颜色:

 lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
  LVK_PROFILER_ZONE(“Fill command buffer”, 0xffffff);
  …
  LVK_PROFILER_ZONE_END();

如果我们需要标记整个函数并希望为其自动分配一个名称,我们应该使用以下代码片段中的LVK_PROFILER_FUNCTION宏。此宏不需要关闭:

lvk::Result lvk::compileShader(VkDevice device,
  VkShaderStageFlagBits stage, const char* code,
  VkShaderModule* outShaderModule,
  const glslang_resource_t* glslLangResource) {
  LVK_PROFILER_FUNCTION();
  …
  return Result();
}

让我们看看在运行此演示应用程序时分析器的输出。要检索分析数据,您必须运行一个 Tracy 客户端并将其连接到您的图形应用程序。我们使用的是 Tracy 版本 0.10,可以从 GitHub 下载github.com/wolfpld/tracy/releases/tag/v0.10。以下是连接的 Tracy 客户端的屏幕截图,显示了我们的应用程序的火焰图。

图 4.2:Tracy 用户界面

图 4.2:Tracy 用户界面

这种方法允许在构建时完全透明地启用和禁用 Tracy 性能分析器。添加其他性能分析器,如提供类似 API 的 EasyProfiler 和 Optick,主要是微不足道的,并且可以很容易地作为练习自行实现。

在返回 Vulkan 渲染之前,让我们探索另一个小而实用的性能分析技巧,并学习如何实现一个简单但良好的每秒帧数计数器。

添加每秒帧数计数器

每秒帧数FPS)计数器是所有图形应用程序性能分析和测量的基石。在本教程中,我们将学习如何实现一个简单的 FPS 计数器类,并使用它来大致测量我们应用程序的性能。

准备工作

本教程的源代码位于Chapter04/03_FPSFramesPerSecondCounter类位于shared/UtilsFPS.h

如何实现...

让我们实现包含计算给定时间间隔平均 FPS 所需所有机制的FramesPerSecondCounter类:

  1. 首先,我们需要一些成员字段来存储滑动窗口的持续时间、当前间隔内渲染的帧数以及该间隔的累积时间。printFPS_ Boolean 字段可以用来启用或禁用将帧率(FPS)打印到控制台:
class FramesPerSecondCounter {
public:
  float avgInterval_ = 0.5f;
  unsigned int numFrames_  = 0;
  double accumulatedTime_  = 0;
  float currentFPS_        = 0.0f;
  bool printFPS_ = true;
  1. 单一显式构造函数可以覆盖平均间隔的默认持续时间:
public:
  explicit FramesPerSecondCounter(float avgInterval = 0.5f)
  : avgInterval_(avgInterval)
  { assert(avgInterval > 0.0f); }
  1. tick()方法应该从主循环中调用。它接受自上次调用以来经过的时间持续时间和一个布尔标志,如果在此迭代期间渲染了新帧,则该标志应设置为true。这个标志是一个便利功能,用于处理在主循环中由于各种原因(如模拟暂停)跳过帧渲染的情况。时间累积直到达到avgInterval_的值:
 bool tick(float deltaSeconds, bool frameRendered = true) {
    if (frameRendered) numFrames_++;
    accumulatedTime_ += deltaSeconds;
  1. 一旦累积了足够的时间,我们就可以进行平均,更新当前的 FPS 值,并将调试信息打印到控制台。此时,我们应该重置帧数和累积时间:
 if (accumulatedTime_ > avgInterval_) {
      currentFPS_ = static_cast<float>(
        numFrames_ / accumulatedTime_);
      if (printFPS_) printf(“FPS: %.1f\n”, currentFPS_);
      numFrames_       = 0;
      accumulatedTime_ = 0;
      return true;
    }
    return false;
  }
  1. 让我们添加一个辅助方法来获取当前的帧率值:
 inline float getFPS() const { return currentFPS_; }
};

现在,让我们看看如何在主循环中使用这个类。让我们增强我们的演示应用程序的主循环,以在控制台显示 FPS 计数器:

  1. 首先,让我们定义一个FramesPerSecondCounter对象和几个变量来存储当前时间戳和自上次渲染帧以来的时间差。我们选择使用一个临时的 0.5 秒平均间隔;请随意尝试不同的值:
 double timeStamp   = glfwGetTime();
  float deltaSeconds = 0.0f;
  FramesPerSecondCounter fpsCounter(0.5f);
  1. 在主循环中,更新当前时间戳,通过找到两个连续时间戳之间的差值来计算帧持续时间。然后,将这个计算出的差值传递给tick()方法:
 while (!glfwWindowShouldClose(window)) {
    fpsCounter.tick(deltaSeconds);
    const double newTimeStamp = glfwGetTime();
    deltaSeconds = static_cast<float>(newTimeStamp - timeStamp);
    timeStamp    = newTimeStamp;
    // ...do the rest of your rendering here...
  }

运行应用程序的控制台输出应类似于以下内容。垂直同步已关闭:

FPS: 3924.7
FPS: 4322.4
FPS: 4458.9
FPS: 4445.1
FPS: 4581.4

应用程序窗口应该看起来像以下截图所示,帧率计数器渲染在右上角:

图 4.3:带有 FPS 计数的 ImGui 和 ImPlot 用户界面

图 4.3:带有 FPS 计数的 ImGui 和 ImPlot 用户界面

让我们检查源代码,了解如何将这个 ImGui FPS 小部件添加到你的应用中。以下是一个填充命令缓冲区的片段:

  1. 我们设置了帧缓冲区参数并开始渲染。与我们在渲染 ImGui 用户界面菜谱中学到的相同,ImGui 渲染从imgui->beginFrame()开始:
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
const lvk::Framebuffer framebuffer =
  { .color = { { .texture = ctx->getCurrentSwapchainTexture() } } };
buf.cmdBeginRendering(
  { .color = { { .loadOp = lvk::LoadOp_Clear,
    .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } } }, framebuffer);
imgui->beginFrame(framebuffer);
  1. 我们从 ImGui 获取当前视口参数,并将下一个 ImGui 窗口的位置设置为与视口工作区域的右上角对齐。大小被硬编码为应用窗口的像素。ImGuiCond_Always标志告诉 ImGui 在每一帧都设置这个位置:
if (const ImGuiViewport* v = ImGui::GetMainViewport()) {
  ImGui::SetNextWindowPos({
    v->WorkPos.x + v->WorkSize.x - 15.0f,
    v->WorkPos.y + 15.0f }, ImGuiCond_Always, { 1.0f, 0.0f });
}
  1. 将下一个窗口设置为透明。我们使用SetNextWindowSize()为窗口分配一个固定的大小值。宽度是通过CalcTextSize()计算的。注意这里如何使用“FPS : _______”占位符字符串作为参数,以确保窗口的宽度不会根据数字 FPS 值的位数而波动:
ImGui::SetNextWindowBgAlpha(0.30f);
ImGui::SetNextWindowSize(
  ImVec2(ImGui::CalcTextSize(“FPS : _______”).x, 0));
  1. 一个包含 FPS 计数的 ImGui 窗口使用各种 ImGui 标志进行渲染,以便禁用所有不必要的窗口装饰,并且无法与窗口进行用户交互:
if (ImGui::Begin(“##FPS”, nullptr, 
                 ImGuiWindowFlags_NoDecoration |
                 ImGuiWindowFlags_AlwaysAutoResize |
                 ImGuiWindowFlags_NoSavedSettings |
                 ImGuiWindowFlags_NoFocusOnAppearing |
                 ImGuiWindowFlags_NoNav |
                 ImGuiWindowFlags_NoMove))
{
  ImGui::Text(“FPS : %i”, (int)fpsCounter.getFPS());
  ImGui::Text(“ms  : %.1f”, 1000.0 / fpsCounter.getFPS());
}
ImGui::End();
  1. 在我们渲染了 FPS 窗口之后,让我们绘制 ImPlot 和 ImGui 演示窗口,以便你可以探索它们。ImPlot 库将在后续菜谱中进一步介绍:
ImPlot::ShowDemoWindow();
ImGui::ShowDemoWindow();
imgui->endFrame(buf);
buf.cmdEndRendering();

现在你已经知道了如何在你的应用中显示包含一个漂亮的 FPS 计数的窗口。尽管这个功能很简单,但反复在每一个应用中包含这段代码可能会很麻烦。在接下来的菜谱中,我们将介绍一个VulkanApp辅助类,它将处理各种类似这样的实用功能。但就目前而言,让我们回到渲染,探索如何使用立方图纹理。

还有更多...

float tick(float deltaSeconds, bool frameRendered = true)中的frameRendered参数将在后续菜谱中使用,以允许 Vulkan 应用程序在交换链图像不可用时跳过帧。

在 Vulkan 中使用立方图纹理

立方图是一种包含 6 个独立的 2D 纹理的纹理,这些纹理共同构成了立方体的 6 个面。立方图的一个有用特性是它们可以使用方向向量进行采样。这在表示从不同方向进入场景的光线时非常有用。例如,我们可以将基于物理的照明方程中的漫反射部分存储在辐照度立方图中,我们将在第六章中讨论这一点。

将立方体贴图的 6 个面加载到LightweightVK中是一个相当直接的操作。然而,立方体贴图通常存储为等经线投影或垂直或水平交叉。等经线投影是一种将经度和纬度(垂直和水平线)映射为直线、均匀线的投影,这使得它成为存储光探针图像的一种非常简单且流行的存储方式,如图 4.4 中所示。

在本食谱中,我们将学习如何将此立方体贴图表示转换为 6 个面,并使用 Vulkan 进行渲染。

准备工作

有许多网站提供各种许可下的高动态范围环境纹理。请查看polyhaven.comhdrmaps.com以获取有用内容。

本食谱的完整源代码可以在源代码包中找到,名称为Chapter04/04_CubeMap

在我们开始使用立方体贴图之前,让我们介绍一个简单的Bitmap辅助类,用于处理 8 位和 32 位浮点格式的位图图像。您可以在shared/Bitmap.h中找到它:

  1. 让我们声明Bitmap类的接口部分如下:
class Bitmap {
public:
  Bitmap() = default;
  Bitmap(int w, int h, int comp, eBitmapFormat fmt);
  Bitmap(int w, int h, int d, int comp, eBitmapFormat fmt);
  Bitmap(int w, int h, int comp, eBitmapFormat fmt, const void* ptr);
  1. 让我们设置宽度、高度、深度以及每像素的组件数量:
 int w_ = 0;
  int h_ = 0;
  int d_ = 1;
  int comp_ = 3;
  1. 单个组件的类型可以是无符号字节或浮点数。此位图的类型可以是 2D 纹理或立方体贴图。为了简化,我们将此位图的实际像素数据存储在std::vector容器中:
 eBitmapFormat fmt_ = eBitmapFormat_UnsignedByte;
  eBitmapType type_  = eBitmapType_2D;
  std::vector<uint8_t> data_;
  1. 接下来我们需要一个辅助函数来获取存储指定格式的一个组件所需的字节数。这也需要一个二维图像的获取器和设置器。我们稍后会回到这个问题:
 static int getBytesPerComponent(eBitmapFormat fmt);
  void setPixel(int x, int y, const glm::vec4& c);
  glm::vec4 getPixel(int x, int y) const;
};

实现也位于shared/Bitmap.h中。现在让我们使用这个类来构建更高级的立方体贴图转换函数。

如何操作...

我们有一个位于data/piazza_bologni_1k.hdr的立方体贴图,它可在 CC0 许可下使用,最初是从hdrihaven.com/hdri/?h=piazza_bologni下载的。环境贴图图像以等经线投影形式呈现,如下所示:

图 4.4:等经线投影

图 4.4:等经线投影

让我们将它转换为垂直交叉。在垂直交叉格式中,每个立方体贴图面在整幅图像中代表为一个正方形,如下所示:

图 4.5:垂直交叉

图 4.5:垂直交叉

如果我们通过迭代其像素、计算每个像素的笛卡尔坐标并将像素保存到立方体贴图面中来将等经圆投影转换为立方体贴图面,由于结果立方体贴图采样不足,最终会得到一个严重受损的纹理,上面有摩尔纹。更好的方法是反过来操作。这意味着迭代结果立方体贴图面的每个像素,计算对应每个像素的源浮点等经圆坐标,并使用双线性插值采样等经圆纹理。这样最终的立方体贴图将没有伪影:

  1. 第一步是引入一个辅助函数,该函数将指定立方体贴图面内的整数坐标映射到浮点归一化坐标。这个辅助函数很方便,因为垂直交叉立方体贴图的各个面都有不同的垂直方向:
vec3 faceCoordsToXYZ(int i, int j, int faceID, int faceSize) {
  const float A = 2.0f * float(i) / faceSize;
  const float B = 2.0f * float(j) / faceSize;
  if (faceID == 0) return vec3(   -1.0f, A - 1.0f,  B - 1.0f);
  if (faceID == 1) return vec3(A - 1.0f,    -1.0f,  1.0f - B);
  if (faceID == 2) return vec3(    1.0f, A - 1.0f,  1.0f - B);
  if (faceID == 3) return vec3(1.0f - A,     1.0f,  1.0f - B);
  if (faceID == 4) return vec3(B - 1.0f, A - 1.0f,  1.0f);
  if (faceID == 5) return vec3(1.0f - B, A - 1.0f, -1.0f);
  return vec3();
}
  1. 转换函数开始如下,并计算结果位图的面积、宽度和高度。它位于 shared/UtilsCubemap.cpp
Bitmap convertEquirectangularMapToVerticalCross(const Bitmap& b) {
  if (b.type_ != eBitmapType_2D) return Bitmap();
  const int faceSize = b.w_ / 4;
  const int w = faceSize * 3;
  const int h = faceSize * 4;
  Bitmap result(w, h, 3);
  1. 这些点定义了交叉中各个面的位置:
 const ivec2 kFaceOffsets[] = {
    ivec2(faceSize, faceSize * 3),
    ivec2(0, faceSize),
    ivec2(faceSize, faceSize),
    ivec2(faceSize * 2, faceSize),
    ivec2(faceSize, 0),
    ivec2(faceSize, faceSize * 2)
  };
  1. 需要两个常量来限制纹理查找:
 const int clampW = b.w_ - 1;
  const int clampH = b.h_ - 1;
  1. 现在,我们可以开始迭代 6 个立方体贴图面以及每个面内的每个像素:
 for (int face = 0; face != 6; face++) {
    for (int i = 0; i != faceSize; i++) {
      for (int j = 0; j != faceSize; j++) {
  1. 我们使用三角函数从笛卡尔立方体贴图坐标计算纬度和经度坐标。
 const vec3  P = faceCoordsToXYZ(i, j, face, faceSize);
        const float R = hypot(P.x, P.y);
        const float theta = atan2(P.y, P.x);
        const float phi   = atan2(P.z, R); 
  1. 要了解更多关于球坐标系统,请点击此链接:en.wikipedia.org/wiki/Spherical_coordinate_system

  2. 现在,我们可以将纬度和经度映射到等经圆图像内的浮点坐标:

 const float Uf =
          float(2.0f * faceSize * (theta + M_PI) / M_PI);
        const float Vf =
          float(2.0f * faceSize * (M_PI / 2.0f - phi) / M_PI);
  1. 基于这些浮点坐标,我们得到两对整数 UV 坐标,我们将使用这些坐标来采样 4 个 texels 以进行双线性插值:
 const int U1 = clamp(int(floor(Uf)), 0, clampW);
        const int V1 = clamp(int(floor(Vf)), 0, clampH);
        const int U2 = clamp(U1 + 1, 0, clampW);
        const int V2 = clamp(V1 + 1, 0, clampH);
  1. 获取双线性插值的分数部分,并从等经圆图中获取 4 个样本,ABCD
 const float s = Uf - U1;
        const float t = Vf - V1;
        const vec4 A = b.getPixel(U1, V1);
        const vec4 B = b.getPixel(U2, V1);
        const vec4 C = b.getPixel(U1, V2);
        const vec4 D = b.getPixel(U2, V2);
  1. 进行双线性插值,并将结果像素值设置在垂直交叉立方体贴图中:
 const vec4 color = A * (1 - s) * (1 - t) + B * (s) * (1 - t) +
          C * (1 - s) * t + D * (s) * (t);
        result.setPixel(
          i + kFaceOffsets[face].x, j + kFaceOffsets[face].y, color);
      }
    }
  }
  return result;
}

Bitmap 类负责处理图像数据中的像素格式。

现在,我们可以编写代码来将垂直交叉切割成紧密排列的矩形立方体贴图面。以下是操作方法:

  1. 首先,让我们回顾一下与 Vulkan 立方体贴图面布局相对应的垂直交叉图像布局。

    图 4.6:垂直交叉图像布局

    图 4.6:垂直交叉图像布局

  2. 布局是 34 列的面,这使得我们可以如下计算结果立方体贴图的尺寸。代码来自 shared/UtilsCubemap.cpp

Bitmap convertVerticalCrossToCubeMapFaces(const Bitmap& b) {
  const int faceWidth  = b.w_ / 3;
  const int faceHeight = b.h_ / 4;
  Bitmap cubemap(faceWidth, faceHeight, 6, b.comp_, b.fmt_);
  1. 让我们设置指针来读取数据和写入数据。这个函数与像素格式无关,因此它需要知道每个像素的字节数,以便能够使用 memcpy() 移动像素:
 const uint8_t* src = b.data_.data();
  uint8_t* dst = cubemap.data_.data();
  const int pixelSize = cubemap.comp_ *
    Bitmap::getBytesPerComponent(cubemap.fmt_);
  1. 遍历面和每个面的每个像素。这里立方体贴图面的顺序对应于在Vulkan 规范 16.5.4中描述的 Vulkan 立方体贴图面的顺序:立方体贴图面选择
 for (int face = 0; face != 6; ++face) {
    for (int j = 0; j != faceHeight; ++j) {
      for (int i = 0; i != faceWidth; ++i) {
        int x = 0;
        int y = 0;
  1. 根据目标立方体贴图面索引计算垂直交叉布局中的源像素位置:
 switch (face) {
        case 0: // +X
          x = i;
          y = faceHeight + j;
          break;
        case 1: // -X
          x = 2 * faceWidth + i;
          y = 1 * faceHeight + j;
          break;
        case 2: // +Y
          x = 2 * faceWidth - (i + 1);
          y = 1 * faceHeight - (j + 1);
          break;
        case 3: // -Y
          x = 2 * faceWidth - (i + 1);
          y = 3 * faceHeight - (j + 1);
          break;
        case 4: // +Z
          x = 2 * faceWidth - (i + 1);
          y = b.h_ - (j + 1);
          break;
        case 5: // -Z
          x = faceWidth + i;
          y = faceHeight + j;
          break;
        }
  1. 复制像素并移动到下一个像素:
 memcpy(dst, src + (y * b.w_ + x) * pixelSize, pixelSize);
        dst += pixelSize;
      }
    }
  }
  return cubemap;
}

生成的立方体贴图包含一个包含 6 个 2D 图像的数组。让我们编写一些更多的 C++代码来加载和转换实际的纹理数据,并将其上传到LightweightVK。源代码位于Chapter04/04_CubeMap/src/main.cpp

  1. 使用STB_image浮点 API 从.hdr文件中加载高动态范围图像:
int w, h;
const float* img = stbi_loadf(
  “data/piazza_bologni_1k.hdr”, &w, &h, nullptr, 4);
Bitmap in(w, h, 4, eBitmapFormat_Float, img);
  1. 将等经线地图转换为垂直交叉并保存结果图像到.hdr文件以供进一步检查:
Bitmap out = convertEquirectangularMapToVerticalCross(in);
stbi_image_free((void*)img);
stbi_write_hdr(“.cache/screenshot.hdr”, out.w_, out.h_, out.comp_,
  (const float*)out.data_.data());
  1. 将加载的垂直交叉图像转换为实际的立方体贴图面:
Bitmap cubemap = convertVerticalCrossToCubeMapFaces(out);
  1. 现在,将纹理数据上传到 LightweightVK 是直接的。我们调用IContext::createTexture()成员函数来创建一个纹理,并提供由cubemap.data_.data()返回的立方体贴图数据的指针:
lvk::Holder<lvk::TextureHandle> cubemapTex = ctx->createTexture({
    .type       = lvk::TextureType_Cube,
    .format     = lvk::Format_RGBA_F32,
    .dimensions = {(uint32_t)cubemap.w_, (uint32_t)cubemap.h_},
    .usage      = lvk::TextureUsageBits_Sampled,
    .data       = cubemap.data_.data(),
    .debugName  = “data/piazza_bologni_1k.hdr”,
});

现在我们应该看看如何编写这个示例的 GLSL 着色器:

  1. 让我们创建一个顶点着色器Chapter04/04_CubeMap/src/main.vert,它将模型、视图和投影矩阵作为输入。我们还需要相机位置以及用于网格纹理和立方体贴图的免绑定纹理 ID:
layout(std430, buffer_reference) readonly buffer PerFrameData {
  mat4 model;
  mat4 view;
  mat4 proj;
  vec4 cameraPos;
  uint tex;
  uint texCube;
};
  1. 使用 Vulkan 推送常量(以下代码中的pc)将PerFrameData的缓冲区引用传递到着色器中:
layout(push_constant) uniform PushConstants {
  PerFrameData pc;
};
  1. 每个顶点的属性被提供给顶点着色器。PerVertex结构用于将参数传递给片段着色器。法向量使用模型矩阵的逆转置矩阵进行变换:
struct PerVertex {
  vec2 uv;
  vec3 worldNormal;
  vec3 worldPos;
};
layout (location = 0) in vec3 pos;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 uv;
layout (location=0) out PerVertex vtx;
void main() {
  gl_Position = pc.proj * pc.view * pc.model * vec4(pos, 1.0);
  mat4 model = pc.model;
  mat3 normalMatrix = transpose( inverse(mat3(pc.model)) );
  vtx.uv = uv;
  vtx.worldNormal = normal * normalMatrix;
  vtx.worldPos = (model * vec4(pos, 1.0)).xyz;
}

现在让我们看看位于Chapter04/04_CubeMap/src/main.frag的片段着色器:

  1. 它与上面提到的顶点着色器中PerVertex结构的声明共享。声明位于文件Chapter04/04_CubeMap/src/common.sp中。为了简洁起见,这里省略了它。片段着色器使用textureBindlessCube()辅助函数通过计算出的反射向量采样立方体贴图。这个函数在第三章,使用 Vulkan 对象中的使用纹理数据在 Vulkan配方中进行了详细讨论。反射方向向量使用reflect() GLSL 内置函数计算:
layout (location=0) in PerVertex vtx;
layout (location=0) out vec4 out_FragColor;
void main() {
  vec3 n = normalize(vtx.worldNormal);
  vec3 v = normalize(pc.cameraPos.xyz - vtx.worldPos);
  vec3 reflection = -normalize(reflect(v, n));
  vec4 colorRefl = textureBindlessCube(pc.texCube, 0, reflection);
  1. 为了增加更发达的视觉效果,我们使用硬编码的光方向(0, 0.1, -1)对我们的 3D 模型添加一些漫反射光照:
 vec4 Ka = colorRefl * 0.3;
  float NdotL = clamp(dot(n, normalize(vec3(0,0,-1))), 0.1, 1.0);
  vec4 Kd = textureBindless2D(pc.tex, 0, vtx.uv) * NdotL;
  out_FragColor = Ka + Kd;
};

应用程序生成的输出如下所示。注意,由于高动态范围图像直接显示在低动态范围帧缓冲区上,反射中天空的白色区域被过度曝光。我们将在第十章,基于图像的技术中回到这个问题,并实现一个简单的 HDR 色调映射算子。

图 4.7:反射的橡皮鸭

图 4.7:反光橡皮鸭

现在,让我们回到提高用户交互能力,学习如何实现一个简单的相机类来移动和调试我们的 3D 场景。

还有更多...

在 OpenGL 中,开发者必须启用一个特殊的立方体贴图采样模式,以确保所有立方体贴图面的无缝过滤。在 Vulkan 中,所有立方体贴图纹理提取都是无缝的(如 Vulkan 规范中立方体贴图边缘处理部分所述),除了那些带有VK_FILTER_NEAREST的,它们被夹在面边缘。

使用 3D 相机和基本用户交互

要调试一个图形应用程序,能够使用键盘或鼠标在 3D 场景中导航和移动是非常有帮助的。图形 API 本身并不熟悉相机和用户交互的概念,因此我们必须实现一个相机模型,该模型将用户输入转换为 Vulkan 可用的视图矩阵。在这个菜谱中,我们将学习如何创建一个简单但可扩展的 3D 相机实现,并使用它来增强我们的 Vulkan 示例的功能。

准备工作

本菜谱的源代码可以在Chapter04/05_Camera中找到。相机类在文件shared/Camera.h中声明和实现。

如何做到这一点...

我们的相机实现将根据所选的动态模型计算视图矩阵和 3D 位置点。让我们看看步骤:

  1. 首先,让我们实现Camera类,它将代表我们与 3D 相机工作的主要 API。该类存储对CameraPositionerInterface类实例的引用,这是一个底层相机模型的泛型实现,允许在运行时切换相机行为:
class Camera final {
public:
  explicit Camera(CameraPositionerInterface& positioner)
    : positioner_(&positioner)  {}
  Camera(const Camera&) = default;
  Camera& operator = (const Camera&) = default;
  mat4 getViewMatrix() const {
    return positioner_->getViewMatrix();
  }
  vec3 getPosition() const {
    return positioner_->getPosition();
  }
private:
      const CameraPositionerInterface* positioner_;
};

CameraPositionerInterface的接口只包含纯虚方法和一个默认的虚析构函数:

class CameraPositionerInterface {
public:
  virtual ~CameraPositionerInterface() = default;
  virtual mat4 getViewMatrix() const = 0;
  virtual vec3 getPosition() const = 0;
};
  1. 现在我们可以实现实际的相机模型。我们将从一个基于四元数的第一人称相机开始,该相机可以在空间中自由地向任何方向移动。让我们看看CameraPositioner_FirstPerson类。内部Movement结构包含布尔标志,用于定义我们相机的当前运动状态。这对于将键盘和鼠标输入与相机控制逻辑解耦非常有用:
class CameraPositioner_FirstPerson final:
  public CameraPositionerInterface
{
public:
  struct Movement {
    bool forward_   = false;
    bool backward_  = false;
    bool left_      = false;
    bool right_     = false;
    bool up_        = false;
    bool down_      = false;
    bool fastSpeed_ = false;
  } movement_;
  1. 不同的数值参数定义了相机对加速度和阻尼的响应程度。这些参数可以根据您的需要进行调整:
 float mouseSpeed_   = 4.0f;
  float acceleration_ = 150.0f;
  float damping_      = 0.2f;
  float maxSpeed_     = 10.0f;
  float fastCoef_     = 10.0f;
  1. 我们需要某些私有数据成员来控制相机状态,例如前一个鼠标位置、当前相机位置和方向、当前移动速度以及表示“向上”方向的向量:
private:
  vec2 mousePos_          = vec2(0);
  vec3 cameraPosition_    = vec3(0.0f, 10.0f, 10.0f);
  quat cameraOrientation_ = quat(vec3(0));
  vec3 moveSpeed_         = vec3(0.0f);
  vec3 up_                = vec3(0.0f, 0.0f, 1.0f);
  1. 非默认构造函数接受相机的初始位置、目标位置以及指向向上的向量。这个输入类似于人们通常用来构造查看矩阵的输入。实际上,我们使用glm::lookAt()函数来初始化相机:
public:
  CameraPositioner_FirstPerson() = default;
  CameraPositioner_FirstPerson(const vec3& pos,
    const vec3& target, const vec3& up)
  : cameraPosition_(pos)
  , cameraOrientation_(glm::lookAt(pos, target, up))
  , up_(up)
  {}
  1. 现在,我们可以给我们的摄像机模型添加一些动态效果。update() 方法应该在每一帧调用,并获取自上一帧以来经过的时间,以及鼠标位置和鼠标按钮按下标志:
 void update(double deltaSeconds,
    const glm::vec2& mousePos, bool mousePressed)
  {
    if (mousePressed) {
      const glm::vec2 delta = mousePos - mousePos_;
      const glm::quat deltaQuat =
        glm::quat(glm::vec3(
        mouseSpeed_ * delta.y, mouseSpeed_ * delta.x, 0.0f));
      cameraOrientation_ =
        glm::normalize(deltaQuat * cameraOrientation_);
      setUpVector(up_);
    }
    mousePos_ = mousePos;

现在,当鼠标按钮被按下时,我们计算一个与之前鼠标位置的增量向量,并使用它来构造一个旋转四元数。这个四元数用于旋转摄像机。一旦摄像机旋转被应用,我们应该更新鼠标位置状态。

  1. 现在我们应该建立摄像机的坐标系以计算摄像机移动。让我们从 mat4 视图矩阵中提取前向、右向和上向量:
 const mat4 v = glm::mat4_cast(cameraOrientation_);
    const vec3 forward = -vec3(v[0][2], v[1][2], v[2][2]);
    const vec3 right   =  vec3(v[0][0], v[1][0], v[2][0]);
    const vec3 up = cross(right, forward);

forward 向量对应于摄像机的方向,即摄像机指向的方向。right 向量对应于摄像机空间的正 X 轴。up 向量是摄像机空间的正 Y 轴,它与前两个向量垂直,可以通过它们的叉积来计算。

  1. 摄像机坐标系已经建立。现在我们可以将来自 Movement 结构的输入状态应用到我们的摄像机上以控制其移动:
 vec3 accel(0.0f);
    if (movement_.forward_) accel += forward;
    if (movement_.backward_) accel -= forward;
    if (movement_.left_) accel -= right;
    if (movement_.right_) accel += right;
    if (movement_.up_) accel += up;
    if (movement_.down_) accel -= up;
    if (movement_.fastSpeed_) accel *= fastCoef_;

我们不是直接控制摄像机速度或位置,而是让用户输入直接控制加速度向量。这样,摄像机的行为会更加平滑、自然,不会出现突然的运动。

  1. 如果根据输入状态,计算出的摄像机加速度为零,我们应该根据 damping_ 参数逐渐减速摄像机的运动。否则,我们应该使用简单的欧拉积分来积分摄像机运动。最大可能的速度值根据 maxSpeed_ 参数进行限制:
 if (accel == vec3(0)) {
      moveSpeed_ -= moveSpeed_ * std::min((1.0f / damping_) *
        static_cast<float>(deltaSeconds), 1.0f);
    }
    else {
      moveSpeed_ += accel * acceleration_ *
        static_cast<float>(deltaSeconds);
      const float maxSpeed =
        movement_.fastSpeed_ ? maxSpeed_ * fastCoef_ : maxSpeed_;
      if (glm::length(moveSpeed_) > maxSpeed)
        moveSpeed_ = glm::normalize(moveSpeed_) * maxSpeed;
    }
    cameraPosition_ += moveSpeed_ *
      static_cast<float>(deltaSeconds);
  }
  1. 视图矩阵可以通过以下方式从摄像机方向四元数和摄像机位置计算得出:
 virtual mat4 getViewMatrix() const override {
    const mat4 t = glm::translate(mat4(1.0f), -cameraPosition_);
    const mat4 r = glm::mat4_cast(cameraOrientation_);
    return r * t;
  }

平移部分是从 cameraPosition_ 向量推断出来的,旋转部分直接从方向四元数计算得出。

  1. 有用的获取器和设置器很简单,除了 setUpVector() 方法,它必须使用现有的摄像机位置和方向重新计算摄像机方向,如下所示:
 virtual vec3 getPosition() const override {
    return cameraPosition_;
  }
  void setPosition(const vec3& pos) {
    cameraPosition_ = pos;
  }
  void setUpVector(const vec3& up) {
    const mat4 view = getViewMatrix();
    const vec3 dir  = -vec3(view[0][2], view[1][2], view[2][2]);
    cameraOrientation_ =
      glm::lookAt(cameraPosition_, cameraPosition_ + dir, up);
  }
  1. 需要一个额外的辅助函数来重置之前的鼠标位置,以防止例如鼠标光标离开窗口时出现突然的旋转运动:
 void resetMousePosition(const vec2& p) { mousePos_ = p; };
};

上述类可以在 3D 应用中使用,以移动观众。让我们看看它是如何工作的。

它是如何工作的...

演示应用程序基于之前 在 Vulkan 中使用立方体贴图纹理 菜单中的立方体贴图示例。更新的代码位于 Chapter04/05_Camera/src/main.cpp

我们添加一个鼠标状态并定义 CameraPositionerCamera。让它们成为全局变量:

struct MouseState {
  vec2 pos         = vec2(0.0f);
  bool pressedLeft = false;
} mouseState;
const vec3 kInitialCameraPos    = vec3(0.0f, 1.0f, -1.5f);
const vec3 kInitialCameraTarget = vec3(0.0f, 0.5f,  0.0f);
CameraPositioner_FirstPerson positioner(
  kInitialCameraPos,
  kInitialCameraTarget,
  vec3(0.0f, 1.0f, 0.0f));
Camera camera(positioner);

GLFW 光标位置回调应该按照以下方式更新 mouseState

glfwSetCursorPosCallback(
  window, [](auto* window, double x, double y) {
    int width, height;
    glfwGetFramebufferSize(window, &width, &height);
    mouseState.pos.x = static_cast<float>(x / width);
    mouseState.pos.y = 1.0f - static_cast<float>(y / height);
  }
);

在这里,我们将窗口像素坐标转换为归一化的 0...1 坐标,并适应反转的 Y 轴。

GLFW 鼠标按钮回调将 GLFW 鼠标事件传递给 ImGui,并在左鼠标按钮被按下时设置pressedLeft标志:

glfwSetMouseButtonCallback(
  window, [](auto* window, int button, int action, int mods) {
    if (button == GLFW_MOUSE_BUTTON_LEFT)
      mouseState.pressedLeft = action == GLFW_PRESS;
    double xpos, ypos;
    glfwGetCursorPos(window, &xpos, &ypos);
    const ImGuiMouseButton_ imguiButton =
     (button == GLFW_MOUSE_BUTTON_LEFT) ?
       ImGuiMouseButton_Left :
         (button == GLFW_MOUSE_BUTTON_RIGHT ?
           ImGuiMouseButton_Right :
           ImGuiMouseButton_Middle);
    ImGuiIO& io = ImGui::GetIO();
    io.MousePos = ImVec2((float)xpos, (float)ypos);
    io.MouseDown[imguiButton] = action == GLFW_PRESS;
  });

为了处理相机移动的键盘输入,让我们编写以下 GLFW 键盘回调函数:

glfwSetKeyCallback(window,
  [](GLFWwindow* window, int key, int, int action, int mods) {
    const bool press = action != GLFW_RELEASE;
    if (key == GLFW_KEY_ESCAPE)
      glfwSetWindowShouldClose(window, GLFW_TRUE);
    if (key == GLFW_KEY_W) positioner.movement_.forward_ = press;
    if (key == GLFW_KEY_S) positioner.movement_.backward_= press;
    if (key == GLFW_KEY_A) positioner.movement_.left_  = press;
    if (key == GLFW_KEY_D) positioner.movement_.right_ = press;
    if (key == GLFW_KEY_1) positioner.movement_.up_   = press;
    if (key == GLFW_KEY_2) positioner.movement_.down_ = press;
    if (mods & GLFW_MOD_SHIFT)
      positioner.movement_.fastSpeed_ = press;
    if (key == GLFW_KEY_SPACE) {
      positioner.lookAt(kInitialCameraPos,
        kInitialCameraTarget, vec3(0.0f, 1.0f, 0.0f));
      positioner.setSpeed(vec3(0));
    }
  });

WSAD键用于移动相机,空格键用于将相机向上向量重新定向到世界(0, 1, 0)向量,并将相机位置重置到初始位置。Shift 键用于加快相机移动速度。

我们可以使用以下语句从主循环中更新相机定位器:

positioner.update(
  deltaSeconds, mouseState.pos, mouseState.pressedLeft);

这里有一个代码片段,用于将矩阵上传到 Vulkan 每帧均匀缓冲区,类似于在前面章节中使用固定值时的操作:

const vec4 cameraPos = vec4(camera.getPosition(), 1.0f);
const mat4 p  = glm::perspective(
  glm::radians(60.0f), ratio, 0.1f, 1000.0f);
const mat4 m1 = glm::rotate(
  mat4(1.0f), glm::radians(-90.0f), vec3(1, 0, 0));
const mat4 m2 = glm::rotate(
  mat4(1.0f), (float)glfwGetTime(), vec3(0.0f, 1.0f, 0.0f));
const mat4 v  = glm::translate(mat4(1.0f), vec3(cameraPos));
const PerFrameData pc = {
  .model     = m2 * m1,
  .view      = camera.getViewMatrix(),
  .proj      = p,
  .cameraPos = cameraPos,
  .tex       = texture.index(),
  .texCube   = cubemapTex.index(),
};
ctx->upload(bufferPerFrame, &pc, sizeof(pc));

Chapter04/05_Camera运行演示,以使用键盘和鼠标进行操作:

图 4.8:相机

图 4.8:相机

还有更多...

这种相机设计方法可以扩展以适应不同的运动行为。在下一个菜谱中,我们将学习如何实现一些其他有用的相机定位器。

本菜谱中引入的 3D 相机功能对我们这本书来说非常有价值。为了减少代码重复,我们创建了一个名为VulkanApp的辅助类。这个类封装了第一人称相机定位器以及其他功能,如每秒帧数计数器、ImGuiRenderer等。VulkanApp类将在本书的所有后续菜谱中用到。你可以在shared/VulkanApp.hshared/VulkanApp.cpp文件中找到它。

添加相机动画和运动

除了拥有用户控制的第一个人称相机外,能够在 3D 场景中编程地定位和移动相机也很方便——这在需要组织带有相机移动的自动截图测试时进行调试时很有帮助。在这个菜谱中,我们将展示如何做到这一点,并扩展前面菜谱中的最小化 3D 相机框架。我们将使用 ImGui 绘制一个组合框,以在两个相机模式之间进行选择:一个第一人称自由相机和一个移动到用户从 UI 设置的指定点的固定相机。

准备工作

这个菜谱的完整源代码是本章最终演示应用程序的一部分,你可以在Chapter04/06_DemoApp中找到它。所有新的相机相关功能实现都位于shared/Camera.h文件中。

如何做到这一点...

让我们看看如何使用基于 ImGui 的简单用户界面来编程控制我们的 3D 相机:

  1. 首先,我们需要添加一个新的CameraPosition_MoveTo相机定位器,该定位器能够自动将相机移动到指定的vec3点。为此,我们必须声明一系列全局常量和变量:
const vec3 kInitialCameraPos    = vec3(0.0f, 1.0f, -1.5f);
const vec3 kInitialCameraAngles = vec3(-18.5f, 180.0f, 0.0f);
CameraPositioner_MoveTo positionerMoveTo(
  kInitialCameraPos, kInitialCameraAngles);
  1. 在主循环内部,我们应该更新我们新的相机定位器。第一人称相机定位器在前面菜谱中提到的VulkanApp类内部自动更新:
positioner_moveTo.update(
  deltaSeconds, mouseState.pos, mouseState.pressedLeft);

现在,让我们绘制一个 ImGui 组合框来选择应该使用哪个相机定位器来控制相机运动:

  1. 首先,一些额外的全局变量将很有用,用于存储当前相机类型、组合框 UI 的项以及组合框中选中的新值:
const char* cameraType = “FirstPerson”;
const char* comboBoxItems[] = { “FirstPerson”, “MoveTo” };
const char* currentComboBoxItem = cameraType;
  1. 要使用组合框渲染相机控制 UI,让我们编写以下代码。一个新的 ImGui 窗口通过调用ImGui::Begin()开始:
ImGui::Begin(“Camera Controls”, nullptr,
  ImGuiWindowFlags_AlwaysAutoResize);
{
  1. 组合框本身是通过ImGui::BeginCombo()渲染的。第二个参数是在打开组合框之前显示的预览标签名称。如果用户点击了标签,此函数将返回 true:
 if (ImGui::BeginCombo(“##combo”, currentComboBoxItem)) {
    for (int n = 0; n < IM_ARRAYSIZE(comboBoxItems); n++) {
      const bool isSelected =
        currentComboBoxItem == comboBoxItems[n];
  1. 您可以在打开组合框时设置初始焦点。如果您想在组合框内支持滚动或键盘导航,这很有用:
 if (ImGui::Selectable(comboBoxItems[n], isSelected))
        currentComboBoxItem = comboBoxItems[n];
      if (isSelected)
        ImGui::SetItemDefaultFocus();
    }
  1. 完成 ImGui 组合框的渲染:
 ImGui::EndCombo();
  }
  1. 如果选择MoveTo相机类型,则渲染vec3输入滑块以从用户获取相机位置和欧拉角:
 if (!strcmp(cameraType, “MoveTo”)) {
    if (ImGui::SliderFloat3(“Position”,
      glm::value_ptr(cameraPos), -10.0f, +10.0f)) {
      positionerMoveTo.setDesiredPosition(cameraPos);
    }
    if (ImGui::SliderFloat3(“Pitch/Pan/Roll”,
      glm::value_ptr(cameraAngles), -180.0f, +180.0f)) {
      positionerMoveTo.setDesiredAngles(cameraAngles);
    }
  }
  1. 如果新选中的组合框项与当前相机类型不同,则打印调试消息并更改活动相机模式:
 if (currentComboBoxItem &&
      strcmp(currentComboBoxItem, cameraType)) {
    printf(“Selected new camera type: %s\n”,
             currentComboBoxItem);
    cameraType = currentComboBoxItem;
    reinitCamera(app);
  }

生成的组合框应该看起来如下截图所示:

图 4.9:相机控制

图 4.9:相机控制

上述代码在每帧的主循环中被调用以绘制 ImGui。查看Chapter04/06_DemoApp/src/main.cpp文件以获取完整的源代码。

它是如何工作的...

让我们看看我们之前在步骤 1步骤 2中提到的shared/Camera.hCameraPositioner_MoveTo类的实现。与之前菜谱中引入的第一人称相机定位器不同,它依赖于四元数,这个新的定位器采用简单的欧拉角方法来存储相机方向。这种方法对用户来说更友好,也更直观,用于控制相机。以下是一些步骤,帮助我们理解这个相机定位器是如何工作的:

  1. 首先,我们希望有一些用户可配置的参数,用于线性阻尼系数和角阻尼系数:
class CameraPositioner_MoveTo final :
  public CameraPositionerInterface
{
public:
  float dampingLinear_ = 10.0f;
  vec3 dampingEulerAngles_ = vec3(5.0f, 5.0f, 5.0f);
  1. 我们将当前和期望的相机位置以及两套俯仰、偏航和翻滚欧拉角存储在vec3成员字段中。当前相机变换每帧更新并保存在mat4字段中:
private:
  vec3 positionCurrent_ = vec3(0.0f);
  vec3 positionDesired_ = vec3(0.0f);
  vec3 anglesCurrent_ = vec3(0.0f); // pitch, pan, roll
  vec3 anglesDesired_ = vec3(0.0f);
  mat4 currentTransform_ = mat4(1.0f);
  1. 构造函数初始化相机的当前和期望数据集:
public:
  CameraPositioner_MoveTo(const vec3& pos, const vec3& angles)
  : positionCurrent_(pos)
  , positionDesired_(pos)
  , anglesCurrent_(angles)
  , anglesDesired_(angles)
  {}
  1. 最有趣的部分发生在update()函数中。当前相机位置会改变,以移动到所需的相机位置。移动速度与这两个位置之间的距离成正比,并使用线性阻尼系数进行缩放:
 void update(
    float deltaSeconds, const vec2& mousePos, bool mousePressed)
  {
    positionCurrent_ += dampingLinear_ *
      deltaSeconds * (positionDesired_ - positionCurrent_);
  1. 现在,让我们处理欧拉角。我们应该相应地剪辑它们,以确保它们保持在0…360度的范围内。这是防止我们的相机围绕对象旋转2*Pi次所必需的:
 anglesCurrent_ = clipAngles(anglesCurrent_);
    anglesDesired_ = clipAngles(anglesDesired_);
  1. 类似于我们处理相机位置的方式,欧拉角是根据期望和当前角度集合之间的距离进行更新的。在计算相机变换矩阵之前,再次剪辑更新的角度并将值从度转换为弧度。注意在转发到 glm::yawPitchRoll() 之前,俯仰、偏航和翻滚角度是如何进行置换的:
 anglesCurrent_ -= angleDelta(anglesCurrent_, anglesDesired_)
      * dampingEulerAngles_ * deltaSeconds;
    anglesCurrent_ = clipAngles(anglesCurrent_);
    const vec3 ang = glm::radians(anglesCurrent_);
    currentTransform_ = glm::translate(
      glm::yawPitchRoll(ang.y, ang.x, ang.z), -positionCurrent_);
  }
  1. 角度剪辑的函数很简单,如下所示:
private:
  static inline float clipAngle(float d) {
    if (d < -180.0f) return d + 360.0f;
    if (d > +180.0f) return d - 360.f;
    return d;
  }
  static inline vec3 clipAngles(const vec3& angles) {
    return vec3( std::fmod(angles.x, 360.0f),
                 std::fmod(angles.y, 360.0f),
                 std::fmod(angles.z, 360.0f) );
  }
  1. 两组角度之间的差值可以按以下方式计算:
 static inline vec3 angleDelta( const vec3& anglesCurrent,
                                 const vec3& anglesDesired )
  {
    const vec3 d =
      clipAngles(anglesCurrent) - clipAngles(anglesDesired);
    return vec3(
      clipAngle(d.x), clipAngle(d.y), clipAngle(d.z));
  }
};

尝试运行演示应用程序,Chapter04/06_DemoApp。切换到 MoveTo 相机,并从 ImGui 用户界面更改位置和方向。

还有更多...

可以在这个示例实现的基础上构建更多的相机功能。另一个有用的扩展可能是一个跟随使用一组关键点定义的位置和目标的样条曲线的相机。我们将把这个留给你作为练习。

实现即时模式 3D 绘图画布

《第二章,Vulkan 入门》中的设置 Vulkan 调试功能配方只是触及了图形应用程序调试的表面。Vulkan API 提供的验证层非常有价值,但它们不允许你调试逻辑和计算相关的错误。为了看到我们虚拟世界中的情况,我们需要能够渲染辅助图形信息,例如对象的边界框和绘制不同值的时变图表或普通直线。Vulkan API 不提供任何即时模式渲染设施。它所能做的就是向计划稍后提交的命令缓冲区添加命令。为了克服这个困难并向我们的应用程序添加即时模式渲染画布,我们必须编写一些额外的代码。让我们在本配方中学习如何做到这一点。

准备工作

确保你精通《第三章,与 Vulkan 对象一起工作》中所有的渲染配方。检查 shared/LineCanvas.hshared/LineCanvas.cpp 文件以获取此配方的有效实现。一个如何使用新的 LineCanvas3D 3D 线条绘制类的示例是演示应用程序 Chapter04/06_DemoApp 的一部分。

如何操作...

LineCanvas3D 类包含一个由两个点和颜色定义的 3D 线条的 CPU 可访问列表。在每一帧中,用户可以调用 line() 方法来绘制一个新的 3D 线条,该线条应在当前帧中渲染。为了将这些线条渲染到帧缓冲区中,我们维护一个 Vulkan 缓冲区集合来存储线条几何数据,我们将每帧更新这些数据。让我们看看这个类的接口:

  1. LineCanvas3D 类将其内部 3D 线条表示为每条线的顶点对,而每个顶点由一个 vec4 位置和一个颜色组成。每个 linesBuffer 缓冲区包含 lines_ 容器的 GPU 可见副本。我们为每个 swapchain 图像有一个缓冲区,以避免任何额外的 Vulkan 同步:
struct LineCanvas3D {
  mat4 mvp_ = mat4(1.0f);
  struct LineData {
    vec4 pos;
    vec4 color;
  };
  std::vector<LineData> lines_;
 lvk::Holder<lvk::ShaderModuleHandle> vert_;
  lvk::Holder<lvk::ShaderModuleHandle> frag_;
  lvk::Holder<lvk::RenderPipelineHandle> pipeline_;
 constexpr uint32_t kNumImages = 3;
  lvk::Holder<lvk::BufferHandle> linesBuffer_[kNumImages] = {};
  uint32_t currentBufferSize_[kNumImages] = {};
  uint32_t currentFrame_ = 0;
  void setMatrix(const mat4& mvp) { mvp_ = mvp; }
  1. 实际的绘图功能由一系列函数组成。我们希望能够清除画布,渲染一条线,以及渲染一些有用的原语,例如 3D 平面、盒子和视锥体。可以在 line() 成员函数提供的功能之上轻松构建更多实用函数:
 void clear() { lines_.clear(); }
  void line(const vec3& p1, const vec3& p2, const vec4& c);
  void plane(vec3& orig, const vec3& v1, const vec3& v2,
    int n1, int n2, float s1, float s2,
    const vec4& color, const vec4& outlineColor);
  void box(const mat4& m, const BoundingBox& box,
    const vec4& color);
  void box(const mat4& m, const vec3& size, const vec4& color);
  void frustum(const mat4& camView, const mat4& camProj,
    const vec4& color);
  1. 这个类中最长的方法是 render(),它将 Vulkan 命令生成到提供的命令缓冲区中,以渲染 LineCanvas3D 的当前内容。我们将在稍后查看其实现:
 void render(lvk::IContext& ctx, const lvk::Framebuffer& desc,
    lvk::ICommandBuffer& buf, uint32_t width, uint32_t height);
};

现在,让我们处理代码的非 Vulkan 部分:

  1. line() 成员函数本身只是将两个彩色的 vec3 点添加到容器中:
void LineCanvas3D::line(
  const vec3& p1, const vec3& p2, const vec4& c) {
  lines_.push_back({ .pos = vec4(p1, 1.0f), .color = c });
  lines_.push_back({ .pos = vec4(p2, 1.0f), .color = c });
}
  1. plane() 方法内部使用 line() 函数来创建由 v1v2 向量以及半尺寸 s1s2 以及原点 o 张成的三维平面的可视化表示。n1n2 参数指定了我们想要在每个坐标方向上渲染多少条线:
void LineCanvas3D::plane(
  const vec3& o, const vec3& v1, const vec3& v2, int n1, int n2,
  float s1, float s2,
  const vec4& color, const vec4& outlineColor)
  1. 绘制代表平面段的 4 条外线:
 line(o - s1 / 2.0f * v1 - s2 / 2.0f * v2,
       o - s1 / 2.0f * v1 + s2 / 2.0f * v2, outlineColor);
  line(o + s1 / 2.0f * v1 - s2 / 2.0f * v2,
       o + s1 / 2.0f * v1 + s2 / 2.0f * v2, outlineColor);
  line(o - s1 / 2.0f * v1 + s2 / 2.0f * v2,
       o + s1 / 2.0f * v1 + s2 / 2.0f * v2, outlineColor);
  line(o - s1 / 2.0f * v1 - s2 / 2.0f * v2,
       o + s1 / 2.0f * v1 - s2 / 2.0f * v2, outlineColor);
  1. 在平面上绘制 n1 条水平线和 n2 条垂直线:
 for (int i = 1; i < n1; i++) {
    float t = ((float)i - (float)n1 / 2.0f) * s1/(float)n1;
    const vec3 o1 = o + t * v1;
    line(o1 - s2 / 2.0f * v2, o1 + s2 / 2.0f * v2, color);
  }
  for (int i = 1; i < n2; i++) {
    const float t = ((float)i - (float)n2 / 2.0f) * s2/(float)n2;
    const vec3 o2 = o + t * v2;
    line(o2 - s1 / 2.0f * v1, o2 + s1 / 2.0f * v1, color);
  }
  1. box() 成员函数使用提供的 m 模型矩阵和沿 XYZ 轴的半尺寸 size 绘制一个彩色盒子。其想法是创建盒子的 8 个角点,并使用 m 矩阵变换它们:
void LineCanvas3D::box(
  const mat4& m, const vec3& size, const vec4& color)
{
  vec3 pts[8] = { vec3(+size.x, +size.y, +size.z),
                  vec3(+size.x, +size.y, -size.z),
                  vec3(+size.x, -size.y, +size.z),
                  vec3(+size.x, -size.y, -size.z),
                  vec3(-size.x, +size.y, +size.z),
                  vec3(-size.x, +size.y, -size.z),
                  vec3(-size.x, -size.y, +size.z),
                  vec3(-size.x, -size.y, -size.z) };
  for (auto& p : pts) p = vec3(m * vec4(p, 1.f));
  1. 然后使用 line() 函数渲染盒子的所有 12 条边:
 line(pts[0], pts[1], color);
  line(pts[2], pts[3], color);
  line(pts[4], pts[5], color);
  line(pts[6], pts[7], color);
  line(pts[0], pts[2], color);
  line(pts[1], pts[3], color);
  line(pts[4], pts[6], color);
  line(pts[5], pts[7], color);
  line(pts[0], pts[4], color);
  line(pts[1], pts[5], color);
  line(pts[2], pts[6], color);
  line(pts[3], pts[7], color);
}
  1. box() 函数还有一个重载版本,它接受在 shared/UtilsMath.h 中声明的 BoundingBox 类。它只是对这个函数之前版本的简单包装:
void LineCanvas3D::box(const mat4& m,
  const BoundingBox& box, const vec4& color)
{
  this->box(m * glm::translate(mat4(1.f),
    0.5f * (box.min_ + box.max_)),
    0.5f * vec3(box.max_ - box.min_), color);
}
  1. 最有趣的绘图函数是 frustum(),它使用 camProj 视矩阵在世界上渲染一个由 camView 矩阵定位的 3D 视锥体。简而言之,如果你在你的世界中有一个 3D 摄像机,并且它的视图和投影矩阵分别是 camViewcamProj,你可以使用这个函数来可视化该摄像机的视锥体:

这段代码在调试诸如阴影图或视锥体裁剪等问题时非常有价值。我们将在本书的最后一章中大量使用它。

void LineCanvas3D::frustum(
   const mat4& camView,
   const mat4& camProj, const vec4& color)
{
  1. 这个想法与上面提到的 box() 函数有些相似。我们在一个立方体上创建一组角点,对应于摄像机视锥体的 8 个角(在以下代码中,这些点被称为 pp)。然后,我们使用提供的视图-投影矩阵的逆变换每个这些点,本质上是将一个盒子变形为视锥体形状。然后我们使用 line() 函数连接这些点:
 const vec3 corners[] = { vec3(-1, -1, -1),
                           vec3(+1, -1, -1),
                           vec3(+1, +1, -1),
                           vec3(-1, +1, -1),
                           vec3(-1, -1, +1),
                           vec3(+1, -1, +1),
                           vec3(+1, +1, +1),
                           vec3(-1, +1, +1) };
  vec3 pp[8];
  for (int i = 0; i < 8; i++) {
    glm::vec4 q = glm::inverse(camView) * glm::inverse(camProj) *
      glm::vec4(corners[i], 1.0f);
    pp[i] = glm::vec3(q.x / q.w, q.y / q.w, q.z / q.w);
  }
  1. 这四条线代表摄像机视锥体的侧面边缘:
 line(pp[0], pp[4], color);
  line(pp[1], pp[5], color);
  line(pp[2], pp[6], color);
  line(pp[3], pp[7], color);
  1. 侧面边缘绘制完成后,我们需要绘制近平面。我们使用额外的两条线在近平面内画一个十字:
 line(pp[0], pp[1], color);
  line(pp[1], pp[2], color);
  line(pp[2], pp[3], color);
  line(pp[3], pp[0], color);
  line(pp[0], pp[2], color);
  line(pp[1], pp[3], color);
  1. 接下来,我们处理远平面。在这里,我们再次使用额外的两条线来画一个十字,以提供更好的视觉提示:
 line(pp[4], pp[5], color);
  line(pp[5], pp[6], color);
  line(pp[6], pp[7], color);
  line(pp[7], pp[4], color);
  line(pp[4], pp[6], color);
  line(pp[5], pp[7], color);
  1. 现在,让我们绘制视锥体的侧面,以获得良好的体积感。我们使用较暗的颜色和每侧 100 条线:
 const vec4 gridColor = color * 0.7f;
  const int gridLines  = 100;
  1. 这里是底部和顶部两侧:
 { vec3 p1       = pp[0];
    vec3 p2       = pp[1];
    const vec3 s1 = (pp[4] - pp[0]) / float(gridLines);
    const vec3 s2 = (pp[5] - pp[1]) / float(gridLines);
    for (int i = 0; i != gridLines; i++, p1 += s1, p2 += s2)
      line(p1, p2, gridColor); }
  { vec3 p1       = pp[2];
    vec3 p2       = pp[3];
    const vec3 s1 = (pp[6] - pp[2]) / float(gridLines);
    const vec3 s2 = (pp[7] - pp[3]) / float(gridLines);
    for (int i = 0; i != gridLines; i++, p1 += s1, p2 += s2)
      line(p1, p2, gridColor); }
  1. 我们应该对截锥体的左右两侧做同样的处理:
 { vec3 p1       = pp[0];
    vec3 p2       = pp[3];
    const vec3 s1 = (pp[4] - pp[0]) / float(gridLines);
    const vec3 s2 = (pp[7] - pp[3]) / float(gridLines);
    for (int i = 0; i != gridLines; i++, p1 += s1, p2 += s2)
      line(p1, p2, gridColor); }
  { vec3 p1       = pp[1];
    vec3 p2       = pp[2];
    const vec3 s1 = (pp[5] - pp[1]) / float(gridLines);
    const vec3 s2 = (pp[6] - pp[2]) / float(gridLines);
    for (int i = 0; i != gridLines; i++, p1 += s1, p2 += s2)
      line(p1, p2, gridColor); }
}

这就涵盖了我们的线条绘制 API 的用户界面部分。让我们看看实际的渲染代码,了解它在应用程序中的工作方式。

它是如何工作的…

所有渲染和图形管线创建都是在单个 render() 函数中完成的。

该函数接受一个 LightweightVK 上下文、一个帧缓冲区和命令缓冲区:

void LineCanvas3D::render(lvk::IContext& ctx,
  const lvk::Framebuffer& desc,
  lvk::ICommandBuffer& buf, uint32_t width, uint32_t height)
{

所需的 GPU 缓冲区大小是根据当前行数计算的。如果当前缓冲区容量不足,则重新分配缓冲区:

 const uint32_t requiredSize = lines_.size() * sizeof(LineData);
  if (currentBufferSize_[currentFrame_] < requiredSize) {
    linesBuffer_[currentFrame_] = ctx.createBuffer({
      .usage = lvk::BufferUsageBits_Storage,
      .storage = lvk::StorageType_HostVisible,
      .size = requiredSize, .data = lines_.data() });
    currentBufferSize_[currentFrame_] = requiredSize;
  } else {
    ctx.upload(
      linesBuffer_[currentFrame_], lines_.data(), requiredSize);
  }

如果没有可用的渲染管线,我们应该创建一个新的。我们使用 lvk::Topology_Line,它与 VK_PRIMITIVE_TOPOLOGY_LINE_LIST 匹配。使用简单的 alpha 混合来渲染所有线条:

 if (pipeline_.empty()) {
    vert_ = ctx.createShaderModule({
      codeVS, lvk::Stage_Vert, “Shader Module: imgui (vert)” });
    frag_ = ctx.createShaderModule({
      codeFS, lvk::Stage_Frag, “Shader Module: imgui (frag)” });
    pipeline_ = ctx.createRenderPipeline({
      .topology = lvk::Topology_Line,
      .smVert   = vert_,
      .smFrag   = frag_,
      .color    = { {
         .format = ctx.getFormat(desc.color[0].texture),
         .blendEnabled      = true,
         .srcRGBBlendFactor = lvk::BlendFactor_SrcAlpha,
         .dstRGBBlendFactor = lvk::BlendFactor_OneMinusSrcAlpha,
      } },
      .depthFormat = desc.depthStencil.texture ?
        ctx.getFormat(desc.depthStencil.texture) :
        lvk::Format_Invalid,
      .cullMode = lvk::CullMode_None,
    }, nullptr);
  }

我们线条绘制的顶点着色器接受当前组合的模型视图投影矩阵 mvp 和包含线条数据的 GPU 缓冲区引用。所有内容都使用 Vulkan 推送常数进行更新:

 struct {
    mat4 mvp;
    uint64_t addr;
  } pc {
    .mvp  = mvp_,
    .addr = ctx.gpuAddress(linesBuffer_[currentFrame_]),
  };
  buf.cmdBindRenderPipeline(pipeline_);
  buf.cmdPushConstants(pc);

一旦准备好的 Vulkan 渲染状态,我们就可以绘制线条并切换到下一帧以使用可用的缓冲区之一:

 buf.cmdDraw(lines_.size());
  currentFrame_ =
    (currentFrame_ + 1) % LVK_ARRAY_NUM_ELEMENTS(linesBuffer_);
}

值得快速查看一下线绘制 GLSL 着色器。

顶点着色器如下。使用可编程顶点提取从提供的缓冲区中提取线条数据:

layout (location = 0) out vec4 out_color;
layout (location = 1) out vec2 out_uv;
struct Vertex {
  vec4 pos;
  vec4 rgba;
};
layout(std430, buffer_reference) readonly buffer VertexBuffer {
  Vertex vertices[];
};
layout(push_constant) uniform PushConstants {
  mat4 mvp;
  VertexBuffer vb;
};
void main() {
  Vertex v = vb.vertices[gl_VertexIndex];
  out_color = v.rgba;
  gl_Position = mvp * v.pos;
}

片段着色器很简单,只是输出提供的颜色:

layout (location = 0) in vec4 in_color;
layout (location = 0) out vec4 out_color;
void main() {
  out_color = in_color;
}

这就是关于绘制 3D 线条的所有内容。要查看如何使用此 3D 绘图画布的综合示例,请查看本章末尾的 将所有内容组合到 Vulkan 应用程序中 配方。

下一个配方将通过展示如何使用 ImGui 和 ImPlot 库绘制 2D 线条和图表来总结 Vulkan 辅助渲染。

使用 ImGui 和 ImPlot 在屏幕上渲染图表

在上一个配方中,我们学习了如何在 Vulkan 中使用基本绘图功能创建即时模式绘图功能。那个 3D 画布是在与它共享视图投影矩阵的 3D 场景之上渲染的。在这个配方中,我们将继续向我们的框架添加有用的调试功能,并学习如何实现纯 2D 线条绘制功能。可以以类似于 LineCanvas3D 的方式实现此类。然而,正如在 渲染 ImGui 用户界面 配方中所描述的,我们已经在我们的应用程序中使用了 ImGui 库。让我们将其用于渲染我们的 2D 线条。

准备工作

我们建议重新查看 渲染 ImGui 用户界面实现即时模式 3D 绘图画布 配方,以更好地了解如何实现简单的 Vulkan 绘图画布。

如何实现...

在这一点上,我们本质上需要将 2D 图表或图形分解成一系列线条并使用 ImGui 进行渲染。让我们通过代码看看如何实现:

  1. 我们引入一个 LineCanvas2D 类来渲染 2D 线条。它存储了一组 2D 线条:
class LineCanvas2D {
public:
  void clear() { lines_.clear(); }
  void line(const vec2& p1, const vec2& p2, const vec4& c) {
    lines_.push_back({ .p1 = p1, .p2 = p2, .color = c }); }
  void render(const char* name, uint32_t width, uint32_t height);
private:
  struct LineData {
    vec2 p1, p2;
    vec4 color;
  };
  std::vector<LineData> lines_;
};
  1. render()方法相当简单。我们创建一个新全屏 ImGui 窗口,移除所有装饰并禁用用户输入:
void LineCanvas2D::render(const char* nameImGuiWindow) {
  ImGui::SetNextWindowPos(ImVec2(0, 0));
  ImGui::SetNextWindowSize(ImGui::GetMainViewport()->Size);
  ImGui::Begin(nameImGuiWindow, nullptr,
    ImGuiWindowFlags_NoDecoration |
    ImGuiWindowFlags_AlwaysAutoResize | 
    ImGuiWindowFlags_NoSavedSettings |
    ImGuiWindowFlags_NoFocusOnAppearing |
    ImGuiWindowFlags_NoNav |
    ImGuiWindowFlags_NoBackground |
    ImGuiWindowFlags_NoInputs);
  1. 然后我们获取 ImGui 的背景绘制列表,并将所有我们的彩色线条逐一添加到其中。其余的渲染将作为 ImGui 用户界面渲染的一部分来处理,如渲染 ImGui 用户界面菜谱中所述:
 ImDrawList* drawList = ImGui::GetBackgroundDrawList();
  for (const LineData& l : lines_) {
    drawList->AddLine(
      ImVec2(l.p1.x, l.p1.y),
      ImVec2(l.p2.x, l.p2.y),
      ImColor(l.color.r, l.color.g, l.color.b, l.color.a));
  }
  ImGui::End();
}
  1. Chapter04/06_DemoApp/src/main.cpp演示应用程序内部,我们可以以下这种方式与LineCanvas2D的实例一起工作:
canvas2d.clear();
canvas2d.line({ 100, 300 }, { 100, 400 }, vec4(1, 0, 0, 1));
canvas2d.line({ 100, 400 }, { 200, 400 }, vec4(0, 1, 0, 1));
canvas2d.line({ 200, 400 }, { 200, 300 }, vec4(0, 0, 1, 1));
canvas2d.line({ 200, 300 }, { 100, 300 }, vec4(1, 1, 0, 1));
canvas2d.render(“##plane”);

这种功能足以渲染用于各种调试目的的 2D 线条。然而,还有另一种使用 ImPlot 库进行渲染的方法。让我们用它来渲染 FPS 图表。辅助代码在shared/Graph.h中:

  1. 我们声明另一个小的LinearGraph辅助类来绘制变化值的图表,例如每秒渲染的帧数:
class LinearGraph {
  const char* name_ = nullptr;
  const size_t maxPoints_ = 0;
  std::deque<float> graph_;
public:
  explicit LinearGraph(const char* name,
                       size_t maxGraphPoints = 256)
  : name_(name)
  , maxPoints_(maxGraphPoints)
  {}
  1. 随着我们向图表添加更多点,旧点被弹出,使图表看起来像在屏幕上从右到左滚动。这对于观察诸如每秒帧数计数器等值的局部波动很有帮助:
 void addPoint(float value) {
    graph_.push_back(value);
    if (graph_.size() > maxPoints_) graph_.erase(graph_.begin());
  }
  1. 策略是找到最小值和最大值,并将图表归一化到0...1的范围:
 void renderGraph(uint32_t x, uint32_t y,
    uint32_t width, uint32_t height,
    const vec4& color = vec4(1.0)) const {
    float minVal = std::numeric_limits<float>::max();
    float maxVal = std::numeric_limits<float>::min();
    for (float f : graph_) {
      if (f < minVal) minVal = f;
      if (f > maxVal) maxVal = f;
    }
    const float range = maxVal - minVal;
    float valX = 0.0;
    std::vector<float> dataX_;
    std::vector<float> dataY_;
    dataX_.reserve(graph_.size());
    dataY_.reserve(graph_.size());
    for (float f : graph_) {
      const float valY = (f - minVal) / range;
      valX += 1.0f / maxPoints_;
      dataX_.push_back(valX);
      dataY_.push_back(valY);
    }
  1. 然后我们需要创建一个ImGui窗口来容纳我们的图表。ImPlot绘制只能在ImGui窗口内部进行。所有装饰和用户交互都被禁用:
 ImGui::SetNextWindowPos(ImVec2(x, y));
    ImGui::SetNextWindowSize(ImVec2(width, height));
    ImGui::Begin(_, nullptr,
      ImGuiWindowFlags_NoDecoration |
      ImGuiWindowFlags_AlwaysAutoResize |
      ImGuiWindowFlags_NoSavedSettings |
      ImGuiWindowFlags_NoFocusOnAppearing |
      ImGuiWindowFlags_NoNav |
      ImGuiWindowFlags_NoBackground |
      ImGuiWindowFlags_NoInputs);
  1. 可以以类似的方式启动一个新的ImPlot图表。我们禁用ImPlot轴的装饰,并设置线条绘制的颜色:
 if (ImPlot::BeginPlot(name_, ImVec2(width, height),
      ImPlotFlags_CanvasOnly |
      ImPlotFlags_NoFrame | ImPlotFlags_NoInputs)) {
      ImPlot::SetupAxes(nullptr, nullptr,
        ImPlotAxisFlags_NoDecorations,
        ImPlotAxisFlags_NoDecorations);
      ImPlot::PushStyleColor(ImPlotCol_Line,
        ImVec4(color.r, color.g, color.b, color.a));
      ImPlot::PushStyleColor(ImPlotCol_PlotBg,
        ImVec4(0, 0, 0, 0));
  1. ImPlot::PlotLine()函数使用我们收集的点的XY值来渲染图表:
 ImPlot::PlotLine(“#line”, dataX_.data(), dataY_.data(),
        (int)graph_.size(), ImPlotLineFlags_None);
      ImPlot::PopStyleColor(2);
      ImPlot::EndPlot();
    }
    ImGui::End();
  }

这是整个底层实现代码。

现在我们来看一下Chapter04/06_DemoApp/src/main.cpp,学习 2D 图表渲染是如何工作的。

工作原理...

Chapter04/06_DemoApp应用程序使用LinearGraph来渲染 FPS 图表,以及一个简单的正弦图作为参考。以下是它是如何工作的:

  1. 两个图表都声明为全局变量。它们可以渲染多达2048个点:
LinearGraph fpsGraph(“##fpsGraph”, 2048);
LinearGraph sinGraph(“##sinGraph”, 2048);
  1. 在主循环内部,我们像这样向两个图表添加点:
fpsGraph.addPoint(app.fpsCounter_.getFPS());
sinGraph.addPoint(sinf(glfwGetTime() * 20.0f));
  1. 然后我们按照以下方式渲染两个图表:
sinGraph.renderGraph(0, height * 0.7f, width, height * 0.2f,
  vec4(0.0f, 1.0f, 0.0f, 1.0f));
fpsGraph.renderGraph(0, height * 0.8f, width, height * 0.2f);

结果图表看起来如下截图所示。

图 4.10:每秒帧数和正弦波图表

图 4.10:每秒帧数和正弦波图表

将所有内容整合到一个 Vulkan 应用程序中

在这个菜谱中,我们使用本章之前所有菜谱中的所有材料来构建一个结合 3D 场景渲染和 2D 及 3D 调试线绘制功能的 Vulkan 演示应用程序。

准备工作

这个菜谱是将本章中所有材料整合到一个最终演示应用程序中。回顾所有之前的菜谱可能会有助于掌握本章中描述的不同用户交互和调试技术。

本菜谱的完整源代码可以在Chapter04/06_DemoApp中找到。本菜谱中使用的VulkanApp类在shared/VulkanApp.h中声明。

如何操作...

让我们快速浏览源代码,看看我们如何将所有配方中的功能集成到一个单一的应用程序中。我们将所有源代码放在这里,以便在后续章节中必要时进行引用。为了简洁起见,再次跳过所有错误检查:

  1. shared/VulkanApp.h头文件提供了LightweightVK上下文创建和 GLFW 窗口生命周期管理的包装器。有关更多详细信息,请查看第二章中的初始化 Vulkan 实例和图形设备初始化 Vulkan 交换链配方:
#include “shared/VulkanApp.h”
#include <assimp/cimport.h>
#include <assimp/postprocess.h>
#include <assimp/scene.h>
#include “shared/LineCanvas.h”
  1. 在这里,我们演示了一个用于添加相机动画和运动配方的相机定位器:
const vec3 kInitialCameraPos    = vec3(0.0f, 1.0f, -1.5f);
const vec3 kInitialCameraTarget = vec3(0.0f, 0.5f,  0.0f);
const vec3 kInitialCameraAngles = vec3(-18.5f, 180.0f, 0.0f);
CameraPositioner_MoveTo positionerMoveTo(
  kInitialCameraPos, kInitialCameraAngles);
vec3 cameraPos    = kInitialCameraPos;
vec3 cameraAngles = kInitialCameraAngles;
const char* cameraType          = “FirstPerson”;
const char* comboBoxItems[]     = { “FirstPerson”, “MoveTo” };
const char* currentComboBoxItem = cameraType;
  1. 以下是为之前使用 ImGui 和 ImPlot 在屏幕上渲染图形配方中描述的 FPS 图表:
LinearGraph fpsGraph(“##fpsGraph”, 2048);
LinearGraph sinGraph(“##sinGraph”, 2048);
  1. VulkanApp类如使用 3D 相机和基本用户交互配方中所述内置了第一人称相机。我们提供了一个初始相机位置和目标,以及为了绘制漂亮的快速移动图形而减少 FPS 平均间隔:
int main()
{
  VulkanApp app({
    .initialCameraPos = kInitialCameraPos,
    .initialCameraTarget = kInitialCameraTarget });
  app.fpsCounter_.avgInterval_ = 0.002f;
  app.fpsCounter_.printFPS_    = false;
  LineCanvas2D canvas2d;
  LineCanvas3D canvas3d;
  1. 让我们创建一个局部变量,以便更方便地访问存储在VulkanApp中的lvk::IContext。我们稍后显式调用ctx.release()
 std::unique_ptr<lvk::IContext> ctx(app.ctx_.get());
  1. 所有着色器都是从文件中加载的。立方体贴图渲染已在在 Vulkan 中使用立方体贴图纹理配方中描述:
 lvk::Holder<lvk::ShaderModuleHandle> vert =
    loadShaderModule(
      ctx, “Chapter04/04_CubeMap/src/main.vert”);
  lvk::Holder<lvk::ShaderModuleHandle> frag =
    loadShaderModule(
      ctx, “Chapter04/04_CubeMap/src/main.frag”);
  lvk::Holder<lvk::ShaderModuleHandle> vertSkybox =
    loadShaderModule(
      ctx, “Chapter04/04_CubeMap/src/skybox.vert”);
  lvk::Holder<lvk::ShaderModuleHandle> fragSkybox =
    loadShaderModule(
      ctx, “Chapter04/04_CubeMap/src/skybox.frag”);
  1. 橡皮鸭网格渲染管线创建如下:
 struct VertexData {
    vec3 pos;
    vec3 n;
    vec2 tc;
  };
  const lvk::VertexInput vdesc = {
    .attributes   = {{ .location = 0,
                       .format = lvk::VertexFormat::Float3,
                       .offset = offsetof(VertexData, pos) },
                     { .location = 1,
                       .format = lvk::VertexFormat::Float3,
                       .offset = offsetof(VertexData, n) },
                     { .location = 2,
                       .format = lvk::VertexFormat::Float2,
                       .offset = offsetof(VertexData, tc) }, },
    .inputBindings = { { .stride = sizeof(VertexData) } },
  };
  lvk::Holder<lvk::RenderPipelineHandle> pipeline =
    ctx->createRenderPipeline({
      .vertexInput = vdesc,
      .smVert      = vert,
      .smFrag      = frag,
      .color       = { {.format = ctx->getSwapchainFormat()} },
      .depthFormat = app.getDepthFormat(),
      .cullMode    = lvk::CullMode_Back,
  });
  1. 天空盒渲染管线使用可编程顶点提取,并且没有顶点输入状态。有关详细信息,请参阅在 Vulkan 中使用立方体贴图纹理配方:
 lvk::Holder<lvk::RenderPipelineHandle> pipelineSkybox =
    ctx->createRenderPipeline({
      .smVert      = vertSkybox,
      .smFrag      = fragSkybox,
      .color       = { {.format = ctx->getSwapchainFormat()} },
      .depthFormat = app.getDepthFormat(),
  });
  const lvk::DepthState dState = {
    .compareOp = lvk::CompareOp_Less,
    .isDepthWriteEnabled = true };
  1. 让我们从.gltf文件中加载橡皮鸭并将其打包到verticesindices数组中:
 const aiScene* scene = aiImportFile(
    “data/rubber_duck/scene.gltf”, aiProcess_Triangulate);
  const aiMesh* mesh = scene->mMeshes[0];
  std::vector<VertexData> vertices;
  for (uint32_t i = 0; i != mesh->mNumVertices; i++) {
    const aiVector3D v = mesh->mVertices[i];
    const aiVector3D n = mesh->mNormals[i];
    const aiVector3D t = mesh->mTextureCoords[0][i];
    vertices.push_back({ .pos = vec3(v.x, v.y, v.z),
                         .n   = vec3(n.x, n.y, n.z),
                         .tc  = vec2(t.x, t.y) });
  }
  std::vector<uint32_t> indices;
  for (uint32_t i = 0; i != mesh->mNumFaces; i++)
    for (uint32_t j = 0; j != 3; j++)
      indices.push_back(mesh->mFaces[i].mIndices[j]);
  aiReleaseImport(scene);
  1. 创建两个 GPU 缓冲区来存储indicesvertices
 size_t kSizeIndices  = sizeof(uint32_t) * indices.size();
  size_t kSizeVertices = sizeof(VertexData) * vertices.size();
  lvk::Holder<lvk::BufferHandle> bufferIndices =
    ctx->createBuffer({
      .usage     = lvk::BufferUsageBits_Index,
      .storage   = lvk::StorageType_Device,
      .size      = kSizeIndices,
      .data      = indices.data(),
      .debugName = “Buffer: indices” }, nullptr);
  lvk::Holder<lvk::BufferHandle> bufferVertices =
    ctx->createBuffer({
      .usage     = lvk::BufferUsageBits_Vertex,
      .storage   = lvk::StorageType_Device,
      .size      = kSizeVertices,
      .data      = vertices.data(),
      .debugName = “Buffer: vertices” }, nullptr);
  1. 使用统一缓冲区来存储每帧数据,例如模型视图投影矩阵、相机位置以及两个纹理的无绑定 ID:
 struct PerFrameData {
    mat4 model;
    mat4 view;
    mat4 proj;
    vec4 cameraPos;
    uint32_t tex     = 0;
    uint32_t texCube = 0;
  };
  lvk::Holder<lvk::BufferHandle> bufferPerFrame =
    ctx->createBuffer({
      .usage     = lvk::BufferUsageBits_Uniform,
      .storage   = lvk::StorageType_Device,
      .size      = sizeof(PerFrameData),
      .debugName = “Buffer: per-frame” }, nullptr);
  1. 现在让我们引入一个 2D 纹理用于橡皮鸭模型,以及一个立方体贴图纹理用于我们的天空盒,如在 Vulkan 中使用立方体贴图纹理配方中所述:
 lvk::Holder<lvk::TextureHandle> texture = loadTexture(
    ctx, “data/rubber_duck/textures/Duck_baseColor.png”);
  lvk::Holder<lvk::TextureHandle> cubemapTex;
  int w, h;
  const float* img = stbi_loadf(
    “data/piazza_bologni_1k.hdr”, &w, &h, nullptr, 4);
  Bitmap in(w, h, 4, eBitmapFormat_Float, img);
  Bitmap out = convertEquirectangularMapToVerticalCross(in);
  stbi_image_free((void*)img);
  stbi_write_hdr(“.cache/screenshot.hdr”, out.w_, out.h_,
    out.comp_, (const float*)out.data_.data());
  Bitmap cubemap = convertVerticalCrossToCubeMapFaces(out);
  cubemapTex = ctx->createTexture({
    .type       = lvk::TextureType_Cube,
    .format     = lvk::Format_RGBA_F32,
    .dimensions = {(uint32_t)cubemap.w_, (uint32_t)cubemap.h_},
    .usage      = lvk::TextureUsageBits_Sampled,
    .data       = cubemap.data_.data(),
    .debugName  = “data/piazza_bologni_1k.hdr” });
  1. 使用VulkanApp::run()方法提供的 lambda 表达式运行主循环。相机定位器如添加相机动画和运动配方中所述进行更新:
 app.run(& {
    positionerMoveTo.update(deltaSeconds, app.mouseState_.pos,
      ImGui::GetIO().WantCaptureMouse ?
        false : app.mouseState_.pressedLeft);
    const mat4 p  = glm::perspective(glm::radians(60.0f),
      aspectRatio, 0.1f, 1000.0f);
    const mat4 m1 = glm::rotate(mat4(1.0f),
      glm::radians(-90.0f), vec3(1, 0, 0));
    const mat4 m2 = glm::rotate(mat4(1.0f),
      (float)glfwGetTime(), vec3(0.0f, 1.0f, 0.0f));
    const mat4 v  = glm::translate(mat4(1.0f),
      app.camera_.getPosition());
    const PerFrameData pc = {
      .model     = m2 * m1,
      .view      = app.camera_.getViewMatrix(),
      .proj      = p,
      .cameraPos = vec4(app.camera_.getPosition(), 1.0f),
      .tex       = texture.index(),
      .texCube   = cubemapTex.index(),
    };
    ctx->upload(bufferPerFrame, &pc, sizeof(pc));
  1. 要回顾渲染通道和帧缓冲区的详细信息,请查看第三章中的在 Vulkan 中处理缓冲区配方:
 const lvk::RenderPass renderPass = {
      .color = { { .loadOp = lvk::LoadOp_Clear,
                   .clearColor = {1.0f, 1.0f, 1.0f, 1.0f} } },
      .depth = { .loadOp = lvk::LoadOp_Clear,
                 .clearDepth = 1.0f } };
    const lvk::Framebuffer framebuffer = {
      .color = {
        { .texture = ctx->getCurrentSwapchainTexture() } },
      .depthStencil = { .texture = app.getDepthTexture() } };
    lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
    buf.cmdBeginRendering(renderPass, framebuffer);
  1. 我们按照在 Vulkan 中使用立方体贴图纹理配方中的描述渲染天空盒。请注意,使用了 36 个顶点来绘制天空盒:
 buf.cmdPushConstants(ctx->gpuAddress(bufferPerFrame));
    buf.cmdPushDebugGroupLabel(“Skybox”, 0xff0000ff);
    buf.cmdBindRenderPipeline(pipelineSkybox);
    buf.cmdDraw(36);
    buf.cmdPopDebugGroupLabel();
  1. 橡皮鸭网格的渲染过程如下:
 buf.cmdPushDebugGroupLabel(“Mesh”, 0xff0000ff);
    buf.cmdBindVertexBuffer(0, bufferVertices);
    buf.cmdBindRenderPipeline(pipeline);
    buf.cmdBindDepthState(dState);
    buf.cmdBindIndexBuffer(bufferIndices, lvk::IndexFormat_UI32);
    buf.cmdDrawIndexed(indices.size());
    buf.cmdPopDebugGroupLabel();
  1. 以备忘录形式渲染 ImGui 窗口以提供键盘提示的操作如下:
 app.imgui_->beginFrame(framebuffer);
    ImGui::SetNextWindowPos(ImVec2(10, 10));
    ImGui::Begin(“Keyboard hints:”, nullptr,
        ImGuiWindowFlags_AlwaysAutoResize |
        ImGuiWindowFlags_NoFocusOnAppearing |
        ImGuiWindowFlags_NoInputs |
        ImGuiWindowFlags_NoCollapse);
    ImGui::Text(“W/S/A/D - camera movement”);
    ImGui::Text(“1/2 - camera up/down”);
    ImGui::Text(“Shift - fast movement”);
    ImGui::Text(“Space - reset view”);
    ImGui::End();
  1. 我们按照添加帧率计数器配方中的描述渲染帧率计数器:
 if (const ImGuiViewport* v = ImGui::GetMainViewport()) {
      ImGui::SetNextWindowPos({
        v->WorkPos.x + v->WorkSize.x - 15.0f,
        v->WorkPos.y + 15.0f }, ImGuiCond_Always,
        { 1.0f, 0.0f });
    }
    ImGui::SetNextWindowBgAlpha(0.30f);
    ImGui::SetNextWindowSize(
      ImVec2(ImGui::CalcTextSize(“FPS : _______”).x, 0));
    if (ImGui::Begin(“##FPS”, nullptr,
          ImGuiWindowFlags_NoDecoration |
          ImGuiWindowFlags_AlwaysAutoResize | 
          ImGuiWindowFlags_NoSavedSettings |
          ImGuiWindowFlags_NoFocusOnAppearing | 
          ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove)) {
      ImGui::Text(“FPS : %i”, (int)app.fpsCounter_.getFPS());
      ImGui::Text(
        “Ms  : %.1f”, 1000.0 / app.fpsCounter_.getFPS());
    }
    ImGui::End();
  1. 我们按照使用 ImGui 和 ImPlot 在屏幕上渲染图形配方中的描述处理屏幕上的图形和 2D 绘图画布:
 sinGraph.renderGraph(0, height * 0.7f, width,
      height * 0.2f, vec4(0.0f, 1.0f, 0.0f, 1.0f));
    fpsGraph.renderGraph(0, height * 0.8f, width, height * 0.2f);
    canvas2d.clear();
    canvas2d.line({ 100, 300 }, { 100, 400 }, vec4(1, 0, 0, 1));
    canvas2d.line({ 100, 400 }, { 200, 400 }, vec4(0, 1, 0, 1));
    canvas2d.line({ 200, 400 }, { 200, 300 }, vec4(0, 0, 1, 1));
    canvas2d.line({ 200, 300 }, { 100, 300 }, vec4(1, 1, 0, 1));
    canvas2d.render(“##plane”);
  1. 以下代码处理 3D 绘图画布,如实现即时模式 3D 绘图画布配方中所述。为了演示frustum()函数,我们通过lookAt()perspective()GLM 函数渲染了一个临时旋转的棱台:
 canvas3d.clear();
    canvas3d.setMatrix(pc.proj * pc.view);
    canvas3d.plane(vec3(0, 0, 0), vec3(1, 0, 0), vec3(0, 0, 1),
      40, 40, 10.0f, 10.0f, vec4(1, 0, 0, 1), vec4(0, 1, 0, 1));
    canvas3d.box(mat4(1.0f), BoundingBox(vec3(-2), vec3(+2)),
      vec4(1, 1, 0, 1));
    canvas3d.frustum(
      glm::lookAt(vec3(cos(glfwGetTime()),
                  kInitialCameraPos.y, sin(glfwGetTime())),
                  kInitialCameraTarget, vec3(0.0f, 1.0f, 0.0f)),
      glm::perspective(glm::radians(60.0f), aspectRatio, 0.1f,
        30.0f), vec4(1, 1, 1, 1));
    canvas3d.render(*ctx.get(), framebuffer, buf, width, height);
  1. 最后,完成渲染,将命令缓冲区提交给 GPU,并更新图表:
 app.imgui_->endFrame(buf);
    buf.cmdEndRendering();
    ctx->submit(buf, ctx->getCurrentSwapchainTexture());
    fpsGraph.addPoint(app.fpsCounter_.getFPS());
    sinGraph.addPoint(sinf(glfwGetTime() * 20.0f));
  });
  ctx.release();
  return 0;
}

以下是从运行中的应用程序中截取的屏幕截图。白色图表显示平均 FPS 值,旋转的白色棱台可用于调试阴影映射,正如我们在后续章节中所做的那样:

图 4.11:演示应用程序

图 4.11:演示应用程序

本章重点介绍了将多个渲染方面结合到一个工作 Vulkan 应用程序中。图形方面仍缺少一些基本功能,例如高级光照和材质,但我们几乎已经准备好开始渲染更复杂的场景。接下来的几章将涵盖更复杂的网格渲染技术以及基于 glTF2 格式的基于物理的光照计算。

第六章:5 与几何数据一起工作

加入我们的 Discord 书籍社区

packt.link/unitydev

以前,我们尝试了不同的临时方法来存储和处理我们的图形应用程序中的 3D 几何数据。每个演示应用程序中的网格数据布局(顶点和索引缓冲区)都是硬编码的。这样,我们更容易关注图形管线中的其他重要部分。随着我们进入更复杂的图形应用程序领域,我们需要对系统内存和 GPU 缓冲区中不同 3D 网格的存储有更多的控制。然而,我们的重点仍然是在指导您了解主要原理和实践,而不是纯粹的效率。

在本章中,我们将学习如何以更组织化的方式存储和处理网格几何数据。我们将涵盖以下食谱:

  • 使用 MeshOptimizer 生成细节级别网格

  • 实现可编程顶点提取

  • 渲染实例几何

  • 使用计算着色器实现实例网格

  • 实现无限网格 GLSL 着色器

  • 将细分集成到图形管线中

  • 组织网格数据存储

  • 实现自动几何转换

  • Vulkan 中的间接渲染

  • 使用计算着色器在 Vulkan 中生成纹理

  • 实现计算网格

技术要求

要在您的 Linux 或 Windows PC 上运行本章的代码,您需要一个支持 Vulkan 1.3 的最新驱动程序的 GPU。源代码可以从 github.com/PacktPublishing/3D-Graphics-Rendering-Cookbook 下载。

要运行本章的演示应用程序,建议您从 McGuire 计算机图形档案 casual-effects.com/data/index.xhtml 下载并解压整个 Amazon Lumberyard Bistro 数据集。您可以通过运行 deploy_deps.py 脚本来自动完成此操作。

使用 MeshOptimizer 生成细节级别网格

要开始进行几何操作,让我们实现一个使用 MeshOptimizer 库的网格几何简化演示,该库除了网格优化外,还可以生成简化网格,以便我们以后可能想要使用的实时离散 细节级别LOD)算法。简化是提高渲染性能的有效方法。

为了使 GPU 高效地渲染网格,顶点缓冲区中的所有顶点都应该是唯一的,没有重复。在任何现代 3D 内容管线中,有效地解决这个问题可能是一个复杂且计算密集的任务。MeshOptimizer 是由 Arseny Kapoulkine 开发的开源 C++ 库,它提供算法来帮助优化网格以适应现代 GPU 顶点和索引处理管线。它可以重新索引现有的索引缓冲区,或者从一个未索引的顶点缓冲区生成一组全新的索引。

让我们学习如何使用 MeshOptimizer 优化和生成简化网格。

准备工作

建议您重新阅读第三章与 Vulkan 对象一起工作。本菜谱的完整源代码可以在 Chapter05/01_MeshOptimizer 中找到。

如何做到这一点...

MeshOptimizer 可以为指定的索引和顶点集生成所有必要的 LOD 网格。一旦我们使用 Assimp 加载了网格,我们就可以将其传递给 MeshOptimizer。下面是如何做到这一点:

  1. 让我们使用 Assimp.gltf 文件中加载一个网格。对于这个演示,我们只需要顶点位置和索引:
 const aiScene* scene = aiImportFile(
    “data/rubber_duck/scene.gltf”, aiProcess_Triangulate);
  const aiMesh* mesh = scene->mMeshes[0];
  std::vector<vec3> positions;
  std::vector<uint32_t> indices;
  for (unsigned int i = 0; i != mesh->mNumVertices; i++) {
    const aiVector3D v = mesh->mVertices[i];
    positions.push_back(vec3(v.x, v.y, v.z));
  }
  for (unsigned int i = 0; i != mesh->mNumFaces; i++) {
    for (int j = 0; j != 3; j++)
      indices.push_back(mesh->mFaces[i].mIndices[j]);
  }
  aiReleaseImport(scene);
  1. LOD 网格表示为索引集合,这些索引从用于原始网格的相同顶点构建一个新的简化网格。这样我们只需要存储一组顶点,并且可以通过切换索引缓冲区数据来渲染相应的 LOD。像之前一样,我们为了简单起见,将所有索引存储为无符号 32 位整数。现在我们应该为现有的顶点和索引数据生成一个重映射表:
 std::vector<uint32_t> remap(indices.size());
  const size_t vertexCount =
    meshopt_generateVertexRemap(remap.data(), indices.data(),
      indices.size(), positions.data(), indices.size(), sizeof(vec3));

MeshOptimizer 文档(github.com/zeux/meshoptimizer)告诉我们以下内容:

“…重映射表是根据输入顶点的二进制等价性生成的,因此生成的网格将以相同的方式渲染。”

  1. 返回的 vertexCount 值对应于重映射后保留的唯一顶点的数量。让我们分配空间并生成新的顶点和索引缓冲区:
 std::vector<uint32_t> remappedIndices(indices.size());
  std::vector<vec3> remappedVertices(vertexCount);
  meshopt_remapIndexBuffer(remappedIndices.data(), indices.data(),
    indices.size(), remap.data());
  meshopt_remapVertexBuffer(remappedVertices.data(), positions.data(),
    positions.size(), sizeof(vec3), remap.data());

现在,我们可以使用其他 MeshOptimizer 算法进一步优化这些缓冲区。官方文档非常直接。

  1. 当我们想要渲染一个网格时,GPU 必须通过顶点着色器转换每个顶点。GPU 可以通过内置的小缓存来重用转换后的顶点,通常在内部存储 16 到 32 个顶点。为了有效地使用这个小缓存,我们需要重新排序三角形以最大化顶点引用的局部性。如何在 MeshOptimizer 中实现这一点将在下面展示。请注意,这里只接触到了索引数据:
 meshopt_optimizeVertexCache(remappedIndices.data(),
    remappedIndices.data(), indices.size(), vertexCount);
  1. 转换后的顶点形成三角形,被发送进行光栅化以生成片段。通常,每个片段首先通过深度测试,通过深度测试的片段将执行片段着色器以计算最终颜色。由于片段着色器变得越来越昂贵,减少片段着色器调用的数量变得越来越重要。这可以通过减少网格中的像素过度绘制来实现,并且通常需要使用视图相关算法。然而,MeshOptimizer 实现了启发式算法来重新排序三角形并最小化来自各个方向的过度绘制。我们可以如下使用它:
 meshopt_optimizeOverdraw(
    remappedIndices.data(), remappedIndices.data(),
    indices.size(), glm::value_ptr(remappedVertices[0]), vertexCount,
    sizeof(vec3), 1.05f);

最后一个参数,1.05,是确定算法可以妥协顶点缓存命中率的阈值。我们使用文档中推荐的默认值。

  1. 一旦我们将网格优化以减少像素过度绘制,顶点缓冲区访问模式仍然可以优化以提高内存效率。GPU 必须从顶点缓冲区获取指定的顶点属性并将这些数据传递到顶点着色器。为了加快此获取过程,使用了一个内存缓存,这意味着优化顶点缓冲区访问的局部性非常重要。我们可以使用 MeshOptimizer 来优化我们的索引和顶点缓冲区以实现顶点获取效率,如下所示:
 meshopt_optimizeVertexFetch(
    remappedVertices.data(), remappedIndices.data(), indices.size(),
    remappedVertices.data(), vertexCount, sizeof(vec3));

这个函数将重新排序顶点缓冲区中的顶点并重新生成索引以匹配顶点缓冲区的新内容。

  1. 在这个配方中,我们将要做的最后一件事是简化网格。MeshOptimizer 可以生成一个新的索引缓冲区,它使用顶点缓冲区中的现有顶点,并减少三角形数量。这个新的索引缓冲区可以用来渲染 LOD 网格。以下代码片段展示了如何使用默认的阈值和目标误差值来完成这个操作:
 const float threshold           = 0.2f;
  const size_t target_index_count =
    size_t(remappedIndices.size() * threshold);
  const float target_error        = 0.01f;
  std::vector<uint32_t> indicesLod;
  indicesLod.resize(remappedIndices.size());
  indicesLod.resize(meshopt_simplify(
    &indicesLod[0], remappedIndices.data(), remappedIndices.size(),
    &remappedVertices[0].x, vertexCount, sizeof(vec3),
    target_index_count, target_error));
  indices   = remappedIndices;
  positions = remappedVertices;

现在让我们看看 LOD 网格的渲染是如何工作的。

它是如何工作的…

为了渲染网格及其低级 LOD,我们需要存储一个顶点缓冲区和两个索引缓冲区——一个用于网格,一个用于 LOD:

  1. 这是存储顶点位置的顶点缓冲区:
 lvk::Holder<lvk::BufferHandle> vertexBuffer = ctx->createBuffer({
    .usage     = lvk::BufferUsageBits_Vertex,
    .storage   = lvk::StorageType_Device,
    .size      = sizeof(vec3) * positions.size(),
    .data      = positions.data(),
    .debugName = “Buffer: vertex” }, nullptr);
  1. 我们使用两个索引缓冲区来存储两组索引:
 lvk::Holder<lvk::BufferHandle> indexBuffer = ctx->createBuffer({
    .usage     = lvk::BufferUsageBits_Index,
    .storage   = lvk::StorageType_Device,
    .size      = sizeof(uint32_t) * indices.size(),
    .data      = indices.data(),
    .debugName = “Buffer: index” }, nullptr);
  lvk::Holder<lvk::BufferHandle> indexBufferLod = ctx->createBuffer({
    .usage     = lvk::BufferUsageBits_Index,
    .storage   = lvk::StorageType_Device,
    .size      = sizeof(uint32_t) * indicesLod.size(),
    .data      = indicesLod.data(),
    .debugName = “Buffer: index LOD” }, nullptr);
  1. 渲染部分很简单,为了简洁起见,这里省略了图形管线设置。我们使用第一个索引缓冲区渲染主网格:
 buf.cmdBindVertexBuffer(0, vertexBuffer, 0);
  buf.cmdBindRenderPipeline(pipeline);
  buf.cmdBindDepthState(dState);
  buf.cmdPushConstants(p * v1 * m);
  buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
  buf.cmdDrawIndexed(indices.size());
  1. 然后我们使用第二个索引缓冲区渲染 LOD 网格:
 buf.cmdPushConstants(p * v2 * m);
  buf.cmdBindIndexBuffer(indexBufferLod, lvk::IndexFormat_UI32);
  buf.cmdDrawIndexed(indicesLod.size());

这里是运行中的演示应用程序的屏幕截图。

图 5.1:具有离散 LOD 的网格

图 5.1:具有离散 LOD 的网格

尝试在代码中更改 threshold 参数以生成具有不同 LOD 的网格。

还有更多...

MeshOptimizer 库包含许多其他有用的算法,例如三角形带生成、索引和顶点缓冲区压缩以及网格动画数据压缩。所有这些算法可能对你的几何预处理阶段非常有用,具体取决于你正在编写的图形软件类型。查看官方文档和发布页面,以获取最新的功能,请访问 github.com/zeux/meshoptimizer

第九章高级渲染技术和优化 中,我们将学习如何以 GPU 友好和高效的方式渲染 LOD。

实现可编程顶点拉取

可编程顶点提取PVP)的概念是在 Daniel Rákos 发表的一篇文章《介绍可编程顶点提取渲染管线》中提出的,这篇文章发表在 2012 年出版的令人惊叹的书籍《OpenGL Insights》中。该文章深入探讨了当时 GPU 的架构以及为什么使用这种数据存储方法是有益的。最初,顶点提取的想法是将顶点数据存储在一维缓冲区纹理中,而不是设置标准的顶点输入绑定。然后在顶点着色器中使用texelFetch()和 GLSL samplerBuffer读取数据。内置的 OpenGL GLSL gl_VertexID变量被用作索引来计算用于纹理提取的纹理坐标。这种技巧的原因是,由于开发者遇到了许多 draw 调用时的 CPU 限制,将多个网格合并到单个缓冲区中并在单个 draw 调用中渲染它们,而不需要重新绑定任何顶点数组或缓冲区对象,这有助于提高 draw 调用的批处理。

现在,缓冲区纹理不再需要,顶点数据可以直接从存储或统一缓冲区中通过内置的 Vulkan GLSL gl_VertexIndex变量计算出的偏移量来获取。

这种技术为合并实例化提供了可能性,其中许多小网格可以合并成一个更大的网格,作为同一批次的处理部分。从第七章图形渲染管线开始,我们将广泛使用这种技术。

在这个配方中,我们将使用存储缓冲区来实现与 Vulkan 1.3 和LightweightVK类似的技巧。

准备工作

本配方完整的源代码可以在源代码包中找到,名称为Chapter05/02_VertexPulling

如何做到这一点...

让我们渲染之前配方中的橡胶鸭 3D 模型data/rubber_duck/scene.gltf。然而,这次,我们不会使用顶点属性,而是将使用可编程顶点提取技术。想法是分配两个缓冲区,一个用于索引,另一个用于存储顶点数据的缓冲区,并在顶点着色器中访问它们以获取顶点位置。这就是我们如何做到这一点:

  1. 首先,我们通过Assimp加载 3D 模型,就像之前的配方中那样:
const aiScene* scene = aiImportFile(
  “data/rubber_duck/scene.gltf”, aiProcess_Triangulate);
const aiMesh* mesh = scene->mMeshes[0];
  1. 将每顶点数据转换为适合我们 GLSL 着色器的格式。我们将使用vec3表示位置和vec2表示纹理坐标:
struct Vertex {
  vec3 pos;
  vec2 uv;
};
std::vector<Vertex> positions;
for (unsigned int i = 0; i != mesh->mNumVertices; i++) {
  const aiVector3D v = mesh->mVertices[i];
  const aiVector3D t = mesh->mTextureCoords[0][i];
  positions.push_back({
   .pos = vec3(v.x, v.y, v.z), .uv = vec2(t.x, t.y) });
}
  1. 为了简单起见,我们将索引存储为无符号 32 位整数。在实际应用中,考虑使用 16 位索引来处理小网格,并能够在这两者之间切换:
std::vector<uint32_t> indices;
for (unsigned int i = 0; i != mesh->mNumFaces; i++) {
  for (int j = 0; j != 3; j++)
    indices.push_back(mesh->mFaces[i].mIndices[j]);
}
aiReleaseImport(scene);
  1. 一旦索引和顶点数据准备就绪,我们就可以将它们上传到 Vulkan 缓冲区。我们应该创建两个缓冲区,一个用于顶点,一个用于索引。注意,在这里,尽管称之为顶点缓冲区,但我们设置了使用标志为lvk::BufferUsageBits_Storage
lvk::Holder<lvk::BufferHandle> vertexBuffer = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Storage,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(Vertex) * positions.size(),
  .data      = positions.data(),
  .debugName = “Buffer: vertex” }, nullptr);
lvk::Holder<lvk::BufferHandle> indexBuffer = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Index,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(uint32_t) * indices.size(),
  .data      = indices.data(),
  .debugName = “Buffer: index” }, nullptr);
  1. 现在我们可以为我们的网格创建一个渲染管线。我们将在几分钟后查看着色器代码:
lvk::Holder<lvk::ShaderModuleHandle> vert =
  loadShaderModule(ctx, “Chapter05/02_VertexPulling/src/main.vert”);
lvk::Holder<lvk::ShaderModuleHandle> geom =
  loadShaderModule(ctx, “Chapter05/02_VertexPulling/src/main.geom”);
lvk::Holder<lvk::ShaderModuleHandle> frag =
  loadShaderModule(ctx, “Chapter05/02_VertexPulling/src/main.frag”);
lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid =
  ctx->createRenderPipeline({
        .smVert      = vert,
        .smGeom      = geom,
        .smFrag      = frag,
        .color       = { { .format = ctx->getSwapchainFormat() } },
        .depthFormat = app.getDepthFormat(),
        .cullMode    = lvk::CullMode_Back,
    });
  1. 让我们为我们的网格加载一个纹理并创建适当的深度状态:
lvk::Holder<lvk::TextureHandle> texture =
  loadTexture(ctx, “data/rubber_duck/textures/Duck_baseColor.png”);
const lvk::DepthState dState =
  { .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true };
  1. 在我们进行实际渲染之前,我们应该将纹理 ID 和存储缓冲区地址传递给我们的 GLSL 着色器。我们可以使用 Vulkan 推送常量来完成此操作。模型视图投影矩阵的计算是从之前的配方中复用的。
const struct PushConstants {
  mat4 mvp;
  uint64_t vertices;
  uint32_t texture;
} pc {
  .mvp      = p * v * m,
  .vertices = ctx->gpuAddress(vertexBuffer),
  .texture  = texture.index(),
};
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
buf.cmdBeginRendering(renderPass, framebuffer);
buf.cmdPushConstants(pc);
  1. 现在,网格渲染可以按照以下方式进行。
buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
buf.cmdBindRenderPipeline(pipelineSolid);
buf.cmdBindDepthState(dState);
buf.cmdDrawIndexed(indices.size());
buf.cmdEndRendering();

其余的 C++代码可以在Chapter05/02_VertexPulling/src/main.cpp中找到。现在,我们必须查看 GLSL 顶点着色器,以了解如何从缓冲区中读取顶点数据。顶点着色器位于Chapter05/02_VertexPulling/src/main.vert

  1. 首先,我们有一些在所有着色器之间共享的声明。这样做的原因是我们的片段着色器需要访问推送常量以检索纹理 ID。请注意,Vertex结构不使用vec2vec3成员字段来保持紧密填充并防止任何 GPU 对齐问题。这个结构反映了我们的 C++代码如何将顶点数据写入缓冲区。该缓冲区包含一个无界数组in_Vertices[]。每个元素正好对应一个顶点。
struct Vertex {
  float x, y, z;
  float u, v;
};
layout(std430, buffer_reference) readonly buffer Vertices {
  Vertex in_Vertices[];
};
layout(push_constant) uniform PerFrameData {
  mat4 MVP;
  Vertices vtx;
  uint texture;
};
  1. 让我们引入两个访问器函数,使着色器代码更易于阅读。组装vec3vec2值来自存储缓冲区中的原始浮点值。
vec3 getPosition(int i) {
  return vec3(vtx.in_Vertices[i].x,
              vtx.in_Vertices[i].y,
              vtx.in_Vertices[i].z);
}
vec2 getTexCoord(int i) {
  return vec2(vtx.in_Vertices[i].u,
              vtx.in_Vertices[i].v);
}
  1. 剩余的着色器部分很简单。之前提到的函数用于加载顶点位置和纹理坐标,这些坐标随后被传递到图形管线中。
layout (location=0) out vec2 uv;
void main() {
  gl_Position = MVP * vec4(getPosition(gl_VertexIndex), 1.0);
  uv = getTexCoord(gl_VertexIndex);
}

PVP 部分的介绍就到这里。片段着色器应用纹理,并使用前一章中描述的加权坐标技巧进行线框渲染。程序输出的结果应该看起来像以下截图:

图 5.2:使用 PVP 进行纹理网格渲染

图 5.2:使用 PVP 进行纹理网格渲染

还有更多...

PVP 是一个复杂的话题,并且有不同的性能影响。有一个开源项目对 PVP 性能进行了深入分析,并基于不同的顶点数据布局和访问方法,如将数据存储为结构数组或数组结构,以多个浮点数或单个向量类型读取数据等,进行了运行时度量。您可以在github.com/nlguillemot/ProgrammablePulling查看它。当您在设计应用程序中的 PVP 管线时,它应该成为您的首选工具之一。

渲染实例化几何体

几何渲染中的一项常见任务是绘制多个具有相同几何形状但具有不同变换和材质的网格。这可能导致生成所有必要命令以指导 GPU 单独绘制每个网格的 CPU 开销增加。尽管 Vulkan API 已经具有显著较低的 CPU 开销,但这种情况仍然会发生。现代图形 API(如 Vulkan)提供的一个可能的解决方案是实例渲染。API 绘制命令可以接受多个实例作为参数,顶点着色器可以访问当前的实例编号gl_InstanceIndex。结合前一个配方中演示的 PVP 方法,这种技术可以变得非常灵活。实际上,gl_InstanceIndex可以用来从缓冲区中读取所有必要的材质属性、变换和其他数据。让我们看看一个基本的实例几何演示,以了解如何在 Vulkan 中实现它。

准备工作

确保阅读之前的配方,实现可编程顶点提取,以了解在顶点着色器内部生成顶点数据的概念。这个配方的源代码可以在Chapter05/03_MillionCubes中找到。

如何实现...

为了演示实例渲染如何工作,让我们渲染一百万个带有颜色的旋转立方体。每个立方体都应该围绕其对角线有一个独特的旋转角度,并且应该用几种不同颜色之一进行纹理覆盖。让我们看看03_MillionCubes/src/main.cpp中的 C++代码:

  1. 首先,让我们为我们的立方体生成一个程序纹理。一个异或模式纹理看起来相当有趣。它是通过异或当前纹理单元的xy坐标,然后将结果通过位移应用到所有三个 BGR 通道中生成的。
const uint32_t texWidth  = 256;
const uint32_t texHeight = 256;
std::vector<uint32_t> pixels(texWidth * texHeight);
for (uint32_t y = 0; y != texHeight; y++)
  for (uint32_t x = 0; x != texWidth; x++)
    pixels[y * texWidth + x] =
      0xFF000000 + ((x^y) << 16) + ((x^y) << 8) + (x^y);
lvk::Holder<lvk::TextureHandle> texture = ctx->createTexture({
   .type       = lvk::TextureType_2D,
   .format     = lvk::Format_BGRA_UN8,
   .dimensions = {texWidth, texHeight},
   .usage      = lvk::TextureUsageBits_Sampled,
   .data       = pixels.data(),
   .debugName  = “XOR pattern”,
});
  1. 让我们为 100 万个立方体创建vec3位置和float初始旋转角度。我们可以将这些数据组织到vec4容器中,并将它们存储在一个不可变存储缓冲区中。然后 GLSL 着色器代码将根据经过的时间进行计算。
const uint32_t kNumCubes = 1024 * 1024;
std::vector<vec4> centers(kNumCubes);
for (vec4& p : centers)
  p = vec4(glm::linearRand(-vec3(500.0f), +vec3(500.0f)),
           glm::linearRand(0.0f, 3.14159f));
lvk::Holder<lvk::BufferHandle> bufferPosAngle = ctx->createBuffer({
  .usage   = lvk::BufferUsageBits_Storage,
  .storage = lvk::StorageType_Device,
  .size    = sizeof(vec4) * kNumCubes,
  .data    = centers.data(),
});
  1. 我们跳过了传统的帧缓冲区和管线创建代码,直接进入主渲染循环。摄像机运动是硬编码的,所以它会通过立方体的群移动来来回回。
buf.cmdBeginRendering(renderPass, framebuffer);
const mat4 view = translate(mat4(1.0f),
  vec3(0.0f, 0.0f,
       -1000.0f + 500.0f * (1.0f - cos(-glfwGetTime() * 0.5f))));
  1. 我们使用推送常数将所有必要的数据传递给着色器。我们的顶点着色器将需要当前时间来根据初始立方体位置和旋转进行计算。
const struct {
  mat4 viewproj;
  uint32_t textureId;
  uint64_t bufferPosAngle;
  float time;
} pc {
  .viewproj       = proj * view,
  .textureId      = texture.index(),
  .bufferPosAngle = ctx->gpuAddress(bufferPosAngle),
  .time           = (float)glfwGetTime(),
};
  1. 渲染是通过vkCmdDraw()开始的,它隐藏在cmdDraw()中。第一个参数是我们需要生成一个立方体所使用的三角形原语的数量。我们稍后会看看它在顶点着色器中的处理方式。第二个参数kNumCubes是要渲染的实例数量。
buf.cmdPushConstants(pc);
buf.cmdBindRenderPipeline(pipelineSolid);
buf.cmdBindDepthState(dState);
buf.cmdDraw(36, kNumCubes);
buf.cmdEndRendering();

现在,让我们看看 GLSL 代码,以了解这个实例演示在底层是如何工作的。

它是如何工作的...

  1. 我们的顶点着色器首先声明与上面提到的 C++代码中相同的 PerFrameData 结构。着色器输出每个顶点的颜色和纹理坐标。存储缓冲区包含所有立方体的位置和初始角度。
layout(push_constant) uniform PerFrameData {
  mat4 viewproj;
  uint textureId;
  uvec2 bufId;
  float time;
};
layout (location=0) out vec3 color;
layout (location=1) out vec2 uv;
layout(std430, buffer_reference) readonly buffer Positions {
  vec4 pos[]; // pos, initialAngle
};
  1. 如您可能已经注意到的,如何做… 部分的 C++代码没有向着色器提供任何索引数据。相反,我们将在顶点着色器中生成顶点数据。让我们在这里声明索引映射。我们需要索引来使用三角形构造 6 个立方体面。每个面两个三角形给出每个面的 6 个点,总共 36 个索引。这是传递给 vkCmdDraw() 的索引数。
const int indices[36] = int36;
  1. 这里是我们立方体的每个实例的颜色。
const vec3 colors[7] = vec37,
  vec3(0.0, 1.0, 0.0),
  vec3(0.0, 0.0, 1.0),
  vec3(1.0, 1.0, 0.0),
  vec3(0.0, 1.0, 1.0),
  vec3(1.0, 0.0, 1.0),
  vec3(1.0, 1.0, 1.0));
  1. 由于没有传递给顶点着色器的平移和旋转矩阵,我们不得不在这里自己生成一切。这是一个 GLSL 函数,用于将向量 v 的平移应用于当前变换 m。这个函数是 C++函数 glm::translate() 的对应物。
mat4 translate(mat4 m, vec3 v) {
  mat4 Result = m;
  Result[3] = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3];
  return Result;
}
  1. 旋转以类似的方式处理。这是将 glm::rotate() 端口到 GLSL 的类似物。
mat4 rotate(mat4 m, float angle, vec3 v) {
  float a = angle;
  float c = cos(a);
  float s = sin(a);
  vec3 axis = normalize(v);
  vec3 temp = (float(1.0) - c) * axis;
  mat4 r;
  r[0][0] = c + temp[0] * axis[0];
  r[0][1] = temp[0] * axis[1] + s * axis[2];
  r[0][2] = temp[0] * axis[2] - s * axis[1];
  r[1][0] = temp[1] * axis[0] - s * axis[2];
  r[1][1] = c + temp[1] * axis[1];
  r[1][2] = temp[1] * axis[2] + s * axis[0];
  r[2][0] = temp[2] * axis[0] + s * axis[1];
  r[2][1] = temp[2] * axis[1] - s * axis[0];
  r[2][2] = c + temp[2] * axis[2];
  mat4 res;
  res[0] = m[0] * r[0][0] + m[1] * r[0][1] + m[2] * r[0][2];
  res[1] = m[0] * r[1][0] + m[1] * r[1][1] + m[2] * r[1][2];
  res[2] = m[0] * r[2][0] + m[1] * r[2][1] + m[2] * r[2][2];
  res[3] = m[3];
  return res;
}
  1. 现在我们有了这个广泛的工具集,我们可以编写顶点着色器的 main() 函数。内置的 gl_InstanceIndex 变量用于索引存储缓冲区并检索位置和角度。然后,使用 rotate()translate() 辅助函数计算当前立方体的模型矩阵。
void main() {
  vec4 center = Positions(bufId).pos[gl_InstanceIndex];
  mat4 model = rotate(translate(mat4(1.0f), center.xyz),
                 time + center.w, vec3(1.0f, 1.0f, 1.0f));
  1. 内置的 gl_VertexIndex 变量从 035,帮助我们提取出我们顶点的特定索引。然后我们使用这个简单的二进制公式为这 8 个顶点中的每一个生成 vec3 位置。
 uint idx = indices[gl_VertexIndex];
  vec3 xyz = vec3(idx & 1, (idx & 4) >> 2, (idx & 2) >> 1);
  1. 0...1 的顶点坐标重新映射到 -1…+1 的坐标,并按所需的 边长 缩放:
 const float edge = 1.0;
  gl_Position =
    viewproj * model * vec4(edge * (xyz - vec3(0.5)), 1.0);
  1. UV 坐标是按面选择的,颜色是按实例分配的:
 int face = gl_VertexIndex / 6;
  if (face == 0 || face == 3) uv = vec2(xyz.x, xyz.z);
  if (face == 1 || face == 4) uv = vec2(xyz.x, xyz.y);
  if (face == 2 || face == 5) uv = vec2(xyz.y, xyz.z);
  color = colors[gl_InstanceIndex % 7];
}
  1. 顶点着色器中发生的所有魔法就这些。片段着色器相当简单且简短:
layout (location=0) in vec3 color;
layout (location=1) in vec2 uv;
layout (location=0) out vec4 out_FragColor;
layout(push_constant) uniform PerFrameData {
  mat4 proj;
  uint textureId;
};
void main() {
  out_FragColor = textureBindless2D(
    textureId, 0, uv) * vec4(color, 1.0);
}

运行中的演示应该看起来如下截图所示。你正在飞越一百万个立方体的群体:

图 5.3:使用实例渲染的一百万个立方体

图 5.3:使用实例渲染的一百万个立方体

还有更多...

虽然这个例子是自包含的,并且与非实例渲染相比非常快,但如果将索引移出顶点着色器并存储在专用索引缓冲区中,以利用硬件顶点缓存,并且模型矩阵是按实例而不是按顶点计算的,它还可以更快。我们将在下一个菜谱中介绍这一点。

现在我们将这个实例化示例扩展得更进一步,并使用真实的网格数据绘制一些网格。

使用计算着色器实现实例网格

在上一个食谱中,我们学习了实例渲染的基础知识。尽管那种方法涵盖了渲染几何体实例的各个方面,例如处理模型矩阵和材质,但这还不是一种实用的实现。让我们扩展这个例子,并演示如何渲染从.gltf文件加载的实例网格。

为了给这个例子增加一点复杂性,我们将通过使用计算着色器预先计算每个实例的模型矩阵来增强它。

准备工作

确保你已经阅读了之前的食谱,渲染实例几何体。这个食谱的源代码可以在Chapter05/04_InstancedMeshes中找到。

如何操作...

让我们快速浏览一下 C++代码,以了解整体情况。

  1. 首先,我们为我们的网格生成随机位置和初始旋转角度。我们使用 32,000 个网格,因为我们的 GPU 无法处理使用这种原始的暴力方法处理一百万个网格。将其推至一百万个网格是可能的,我们将在第十一章高级渲染技术和优化中展示一些接近这个数字的技巧。
const uint32_t kNumMeshes = 32 * 1024;
std::vector<vec4> centers(kNumMeshes);
for (vec4& p : centers)
   p = vec4(glm::linearRand(-vec3(500.0f), +vec3(500.0f)),
            glm::linearRand(0.0f, 3.14159f));
  1. 中心点和角度以与上一个食谱完全相同的方式加载到存储缓冲区中:
lvk::Holder<lvk::BufferHandle> bufferPosAngle   = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Storage,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(vec4) * kNumMeshes,
  .data      = centers.data(),
  .debugName = “Buffer: angles & positions”,
});
  1. 为了存储我们实例的模型矩阵,我们需要两个缓冲区。在偶数帧和奇数帧之间,我们将以轮询的方式交替使用它们,以防止不必要的同步。
lvk::Holder<lvk::BufferHandle> bufferMatrices[] = {
  ctx->createBuffer({ .usage     = lvk::BufferUsageBits_Storage,
                      .storage   = lvk::StorageType_Device,
                      .size      = sizeof(mat4) * kNumMeshes,
                      .debugName = “Buffer: matrices 1” }),
  ctx->createBuffer({ .usage     = lvk::BufferUsageBits_Storage,
                      .storage   = lvk::StorageType_Device,
                      .size      = sizeof(mat4) * kNumMeshes,
                      .debugName = “Buffer: matrices 2” }),
};.
  1. 橡皮鸭 3D 模型以下列方式从.gltf加载。这次,除了顶点位置和纹理坐标外,我们还需要法向量来进行一些即兴的照明。当我们渲染这么多网格时,我们将需要它。
const aiScene* scene =
  aiImportFile(“data/rubber_duck/scene.gltf”, aiProcess_Triangulate);
struct Vertex {
  vec3 pos;
  vec2 uv;
  vec3 n;
};
const aiMesh* mesh = scene->mMeshes[0];
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
for (unsigned int i = 0; i != mesh->mNumVertices; i++) {
  const aiVector3D v = mesh->mVertices[i];
  const aiVector3D t = mesh->mTextureCoords[0][i];
  const aiVector3D n = mesh->mNormals[i];
  vertices.push_back({ .pos = vec3(v.x, v.y, v.z),
                       .uv  = vec2(t.x, t.y),
                       .n   = vec3(n.x, n.y, n.z) });
}
for (unsigned int i = 0; i != mesh->mNumFaces; i++)
  for (int j = 0; j != 3; j++)
    indices.push_back(mesh->mFaces[i].mIndices[j]);
aiReleaseImport(scene);
  1. 网格数据被上传到索引和顶点缓冲区:
lvk::Holder<lvk::BufferHandle> vertexBuffer = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Storage,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(Vertex) * vertices.size(),
  .data      = vertices.data(),
  .debugName = “Buffer: vertex” }, nullptr);
lvk::Holder<lvk::BufferHandle> indexBuffer = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Index,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(uint32_t) * indices.size(),
  .data      = indices.data(),
  .debugName = “Buffer: index” }, nullptr);
  1. 让我们加载纹理并创建计算和渲染管线。计算着色器将根据已过时间生成我们实例的模型矩阵,遵循在之前食谱渲染实例网格中使用的顶点着色器的方法。然而,这次,我们将基于每个实例而不是每个顶点来执行。
lvk::Holder<lvk::TextureHandle> texture =
  loadTexture(ctx, “data/rubber_duck/textures/Duck_baseColor.png”);
lvk::Holder<lvk::ShaderModuleHandle> comp =
  loadShaderModule(ctx, “Chapter05/04_InstancedMeshes/src/main.comp”);
lvk::Holder<lvk::ComputePipelineHandle> pipelineComputeMatrices =
  ctx->createComputePipeline({ smComp = comp });
lvk::Holder<lvk::ShaderModuleHandle> vert =
  loadShaderModule(ctx, “Chapter05/04_InstancedMeshes/src/main.vert”);
lvk::Holder<lvk::ShaderModuleHandle> frag =
  loadShaderModule(ctx, “Chapter05/04_InstancedMeshes/src/main.frag”);
lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid =
  ctx->createRenderPipeline({
    .smVert      = vert,
    .smFrag      = frag,
    .color       = { { .format = ctx->getSwapchainFormat() } },
    .depthFormat = app.getDepthFormat(),
    .cullMode    = lvk::CullMode_Back });
  1. 主循环是这样的。我们使用frameId计数器来促进在偶数帧和奇数帧之间切换包含模型矩阵的缓冲区。
uint32_t frameId = 0;
app.run(& {
  const mat4 proj =
    glm::perspective(45.0f, aspectRatio, 0.2f, 1500.0f);
  const lvk::RenderPass renderPass = {
    .color = { { .loadOp = lvk::LoadOp_Clear,
                .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
    .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f }
  };
  const lvk::Framebuffer framebuffer = {
    .color = { { .texture = ctx->getCurrentSwapchainTexture() } },
    .depthStencil = { .texture = app.getDepthTexture() },
  };
  lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
  1. 为了方便起见,推送常数在计算和渲染管线之间共享:
 const mat4 view = translate(mat4(1.0f), vec3(0.0f, 0.0f,
    -1000.0f + 500.0f * (1.0f - cos(-glfwGetTime() * 0.5f))));
  const struct {
    mat4 viewproj;
    uint32_t textureId;
    uint64_t bufferPosAngle;
    uint64_t bufferMatrices;
    uint64_t bufferVertices;
    float time;
  } pc {
    .viewproj       = proj * view,
    .textureId      = texture.index(),
    .bufferPosAngle = ctx->gpuAddress(bufferPosAngle),
    .bufferMatrices = ctx->gpuAddress(bufferMatrices[frameId]),
    .bufferVertices = ctx->gpuAddress(vertexBuffer),
    .time           = (float)glfwGetTime(),
  };
  buf.cmdPushConstants(pc);
  1. 分发计算着色器。每个本地工作组处理 32 个网格——许多 GPU 支持的常见便携式基线:
 buf.cmdBindComputePipeline(pipelineComputeMatrices);
  buf.cmdDispatchThreadGroups({ .width = kNumMeshes / 32 } });
  1. 计算着色器完成更新模型矩阵后,我们可以开始渲染。请注意,这里有一个非空的依赖参数,它指的是包含模型矩阵的缓冲区。这是必要的,以确保LightweightVK发出适当的 Vulkan 缓冲区内存屏障,以防止计算着色器和顶点着色器之间的竞态条件。
 buf.cmdBeginRendering(renderPass, framebuffer,
    { .buffers = { lvk::BufferHandle(bufferMatrices[frameId]) } });

现在,让我们看看屏障。源和目标阶段分别是:

VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT

并且:

VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT

基础的 Vulkan 屏障如下所示:

void lvk::CommandBuffer::bufferBarrier(BufferHandle handle,
  VkPipelineStageFlags srcStage, VkPipelineStageFlags dstStage)
{
  lvk::VulkanBuffer* buf = ctx_->buffersPool_.get(handle);
  const VkBufferMemoryBarrier barrier = {
    .sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
    .srcAccessMask = VK_ACCESS_SHADER_READ_BIT |
                     VK_ACCESS_SHADER_WRITE_BIT,
    .dstAccessMask = VK_ACCESS_SHADER_READ_BIT |
                     VK_ACCESS_SHADER_WRITE_BIT,
    .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
    .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
    .buffer = buf->vkBuffer_,
    .offset = 0,
    .size = VK_WHOLE_SIZE,
  };
  vkCmdPipelineBarrier(wrapper_->cmdBuf_, srcStage, dstStage,
    VkDependencyFlags{}, 0, nullptr, 1, &barrier, 0, nullptr);
}
  1. 渲染代码的其余部分相当标准。LightweightVK 绘制调用命令 cmdDrawIndexed() 接收我们网格中的索引数量和实例数量 kNumMeshes
 buf.cmdBindRenderPipeline(pipelineSolid);
  buf.cmdBindDepthState({
    .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true });
  buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
  buf.cmdDrawIndexed(indices.size(), kNumMeshes);
  buf.cmdEndRendering();
  ctx->submit(buf, ctx->getCurrentSwapchainTexture());
  frameId = (frameId + 1) & 1;
});

现在,让我们深入了解 GLSL 的实现细节,以了解其内部工作原理。

它是如何工作的…

第一部分是计算着色器,它为渲染准备数据。让我们看看 Chapter05/04_InstancedMeshes/src/main.comp

  1. 计算着色器在一个局部工作组中处理 32 个网格。常量推送在计算着色器和图形管线之间共享。它们在包含文件 Chapter05/04_InstancedMeshes/src/common.sp 中声明。我们在此提供该文件以供您方便使用:
layout(local_size_x = 32, local_size_y = 1, local_size_z = 1) in;
// included from <Chapter05/04_InstancedMeshes/src/common.sp>
layout(push_constant) uniform PerFrameData {
  mat4 viewproj;
  uint textureId;
  uvec2 bufPosAngleId;
  uvec2 bufMatricesId;
  uvec2 bufVerticesId;
  float time;
};
layout(std430, buffer_reference) readonly buffer Positions {
  vec4 pos[]; // pos, initialAngle
};
// end of #include
  1. 矩阵缓冲区引用声明不共享。在这里,在计算着色器中,它被声明为 writeonly,而顶点着色器将声明为 readonly
layout(std430, buffer_reference) writeonly buffer Matrices {
  mat4 mtx[];
};
  1. 辅助函数 translate()rotate() 模仿 GLSL 中的 glm::translate()glm::rotate() C++ 函数。它们从先前的菜谱 Rendering instanced geometry 完整地重用。由于它们相当长,我们在此不会重复它们。
mat4 translate(mat4 m, vec3 v);
mat4 rotate(mat4 m, float angle, vec3 v);
  1. main() 函数读取包含中心点和初始角度的 vec4 值,并计算模型矩阵。这正是我们在先前的菜谱中顶点着色器中做的相同计算。然后,模型矩阵存储在由 bufMatricesId 引用的存储缓冲区中。
void main() {
  uint idx = gl_GlobalInvocationID.x;
  vec4 center = Positions(bufPosAngleId).pos[idx];
  mat4 model = rotate(translate(mat4(1.0f),
    center.xyz), time + center.w, vec3(1.0f, 1.0f, 1.0f));
  Matrices(bufMatricesId).mtx[idx] = model;
}

由于我们将大部分计算移动到了计算着色器中,渲染管线的着色器变得显著更短。

  1. 顶点着色器使用相同的共享声明用于常量推送和缓冲区引用:
#include <Chapter05/04_InstancedMeshes/src/common.sp>
layout (location=0) out vec2 uv;
layout (location=1) out vec3 normal;
layout (location=2) out vec3 color;
layout(std430, buffer_reference) readonly buffer Matrices {
  mat4 mtx[];
};
  1. 顶点数据包含法向量,正如在前面菜谱中用 C++ 代码声明的:
struct Vertex {
  float x, y, z;
  float u, v;
  float nx, ny, nz;
};
layout(std430, buffer_reference) readonly buffer Vertices {
  Vertex in_Vertices[];
};
  1. 顶点数据从“vertex”存储缓冲区检索,模型矩阵从矩阵缓冲区获得,该缓冲区由计算着色器更新:
const vec3 colors[3] = vec33,
                               vec3(0.0, 1.0, 0.0),
                               vec3(1.0, 1.0, 1.0));
void main() {
  Vertex vtx = Vertices(bufVerticesId).in_Vertices[gl_VertexIndex];
  mat4 model = Matrices(bufMatricesId).mtx[gl_InstanceIndex];
  1. 现在我们可以计算 gl_Position 的值,并将法向量和纹理坐标传递给我们的片段着色器:
 const float scale = 10.0;
  gl_Position = viewproj * model *
    vec4(scale * vtx.x, scale * vtx.y, scale * vtx.z, 1.0);
  mat3 normalMatrix = transpose( inverse(mat3(model)) );
  uv = vec2(vtx.u, vtx.v);
  normal = normalMatrix * vec3(vtx.nx, vtx.ny, vtx.nz);
  color = colors[gl_InstanceIndex % 3];
}
  1. 片段着色器相当直接。我们执行一些即兴的漫反射光照计算,以增强网格的区分度:
layout (location=0) in vec2 uv;
layout (location=1) in vec3 normal;
layout (location=2) in vec3 color;
layout (location=0) out vec4 out_FragColor;
layout(push_constant) uniform PerFrameData {
  mat4 viewproj;
  uint textureId;
};
void main() {
  vec3 n = normalize(normal);
  vec3 l = normalize(vec3(1.0, 0.0, 1.0));
  float NdotL = clamp(dot(n, l), 0.3, 1.0);
  out_FragColor =
    textureBindless2D(textureId, 0, uv) * NdotL * vec4(color, 1.0);
};

运行的演示应用程序应该渲染一群旋转的橡皮鸭,如图下截图所示,同时摄像机穿越其中。

图 5.4:使用实例渲染的旋转橡皮鸭群

图 5.4:使用实例渲染的旋转橡皮鸭群

还有更多…

如您所注意到的,这个演示只使用了 32,768 个实例,而先前的菜谱中使用了 1 百万个实例。这种差异的原因是,先前的示例中使用的立方体只有 36 个索引,而在这个案例中的橡皮鸭模型有 33,216,几乎是前者的 1,000 倍。

对于这个数据集,简单的暴力方法是不够的。我们需要采用额外的技巧来渲染 1 百万只鸭子,例如剔除和 GPU 级别细节管理。我们将在 第十一章高级渲染技术和优化 中深入研究一些这些主题。

现在,让我们转换一下思路,学习如何在继续更复杂的网格渲染示例之前渲染一些调试网格几何形状。

实现无限网格 GLSL 着色器

在本章前面的食谱中,我们学习了如何处理几何渲染。为了调试我们的应用程序,有一个可见的坐标系表示是有用的,这样观众可以通过查看渲染图像快速推断摄像机的方向和位置。在图像中表示坐标系的一种自然方式是渲染一个无限网格,其中网格平面与坐标平面之一对齐。让我们学习如何在 GLSL 中实现一个看起来不错的网格。

准备工作

本食谱的完整 C++ 源代码可以在 Chapter05/05_Grid 中找到。相应的 GLSL 着色器将在后续食谱中重用,因此它们位于共享数据文件夹中的 data/shaders/Grid.vertdata/shaders/Grid.frag 文件中。

如何实现...

为了参数化我们的网格,我们应该引入一些常数。它们可以在 data/shaders/GridParameters.h GLSL 包含文件中找到并调整。让我们看看里面:

  1. 首先,我们需要定义我们的网格范围在世界坐标中的大小。这就是网格距离摄像机的可见距离:
float gridSize = 100.0;
  1. 一个网格单元的大小以与网格大小相同的单位指定:
float gridCellSize = 0.025;
  1. 让我们定义网格线的颜色。我们将使用两种不同的颜色,一种用于常规细线,另一种用于每第十行渲染的粗线。由于我们是在白色背景上渲染一切,所以我们使用黑色和 50% 灰色是合适的。
vec4 gridColorThin = vec4(0.5, 0.5, 0.5, 1.0);
vec4 gridColorThick = vec4(0.0, 0.0, 0.0, 1.0);
  1. 我们的网格实现将根据网格 LOD 改变渲染线条的数量。当两个相邻网格单元线条之间的像素数低于在片段着色器中计算的这个值时,我们将切换 LOD:
const float gridMinPixelsBetweenCells = 2.0;
  1. 让我们看看我们用来生成和变换网格顶点的简单顶点着色器。它接受当前模型视图投影矩阵、当前摄像机位置和网格原点。原点位于世界空间中,可以用来移动网格。
layout(push_constant) uniform PerFrameData {
  mat4 MVP;
  vec4 cameraPos;
  vec4 origin;
};
layout (location=0) out vec2 uv;
layout (location=1) out vec2 out_camPos;
const vec3 pos[4] = vec34,
  vec3( 1.0, 0.0, -1.0),
  vec3( 1.0, 0.0,  1.0),
  vec3(-1.0, 0.0,  1.0)
);
const int indices[6] = int6;
  1. 内置的 gl_VertexIndex 变量用于访问硬编码的四边形索引和顶点 pos[]-1…+1 点按所需的网格大小进行缩放。生成的顶点位置在水平平面上由 2D 摄像机进行平移,然后由 3D 原点位置进行平移:
void main() {
  int idx = indices[gl_VertexIndex];
  vec3 position = pos[idx] * gridSize;
  position.x += cameraPos.x;
  position.z += cameraPos.z;
  position += origin.xyz;
  out_camPos = cameraPos.xz;
  gl_Position = MVP * vec4(position, 1.0);
  uv = position.xz;
}

片段着色器稍微复杂一些。它将计算一个看起来像网格的程序化纹理。网格线是根据uv坐标在屏幕空间中变化的快慢来渲染的,以避免摩尔纹,因此我们需要屏幕空间导数。你的着色器中变量的屏幕空间导数衡量了该变量从一个像素到下一个像素的变化量。GLSL 函数dFdx()代表水平变化,而dFdy()代表垂直变化。它衡量了当你移动到屏幕上时 GLSL 变量的变化速度,在微积分术语中近似其偏导数。这种近似是由于依赖于每个片段的离散样本,而不是进行数学上的变化评估:

  1. 首先,我们引入一系列 GLSL 辅助函数来帮助我们计算。它们可以在data/shaders/GridCalculation.h中找到。函数名satf()satv()分别代表饱和浮点数和饱和向量:
float log10(float x) {
  return log(x) / log(10.0);
}
float satf(float x) {
  return clamp(x, 0.0, 1.0);
}
vec2 satv(vec2 x) {
  return clamp(x, vec2(0.0), vec2(1.0));
}
float max2(vec2 v) {
  return max(v.x, v.y);
}
  1. 让我们来看看gridColor()函数,它是在main()函数中被调用的,首先计算我们在顶点着色器中之前生成的uv坐标的导数的屏幕空间长度。我们使用内置的dFdx()dFdy()函数来计算所需的导数:
vec2 dudv = vec2( length(vec2(dFdx(uv.x), dFdy(uv.x))),
                  length(vec2(dFdx(uv.y), dFdy(uv.y))) );
  1. 知道了导数,我们可以以下方式计算我们网格的当前 LOD。gridMinPixelsBetweenCells值控制我们想要我们的 LOD 增加的速度。在这种情况下,它是网格相邻单元格线之间的最小像素数:
float lodLevel = max(0.0, log10((length(dudv) *
  gridMinPixelsBetweenCells) / gridCellSize) + 1.0);
float lodFade = fract(lodLevel);

除了 LOD 值之外,我们还需要一个衰减因子来渲染相邻级别之间的平滑过渡。这可以通过取浮点数 LOD 级别的分数部分来获得。使用以 10 为底的对数来确保每个 LOD 比前一个大小覆盖更多的单元格。

  1. LOD 级别之间相互混合。为了渲染它们,我们必须为每个 LOD 计算单元格大小。在这里,我们不是三次计算pow(),这纯粹是为了解释,我们可以只计算lod0,然后将每个后续 LOD 的单元格大小乘以10.0
float lod0 = gridCellSize * pow(10.0, floor(lodLevel));
float lod1 = lod0 * 10.0;
float lod2 = lod1 * 10.0;
  1. 为了能够使用 alpha 透明度绘制抗锯齿线,我们需要增加我们线的屏幕覆盖率。让我们确保每条线覆盖多达4个像素。将网格坐标移动到抗锯齿线的中心,以便进行后续的 alpha 计算:
dudv *= 4.0;
uv += dudv * 0.5;
  1. 现在我们应该得到与每个计算 LOD 级别相对应的覆盖率 alpha 值。为此,我们计算每个 LOD 到单元格线中心的绝对距离,并选择最大坐标:
float lod0a = max2( vec2(1.0) - abs(
  satv(mod(uv, lod0) / dudv) * 2.0 - vec2(1.0)) );
float lod1a = max2( vec2(1.0) - abs(
  satv(mod(uv, lod1) / dudv) * 2.0 - vec2(1.0)) );
float lod2a = max2( vec2(1.0) - abs(
  satv(mod(uv, lod2) / dudv) * 2.0 - vec2(1.0)) );
  1. 非零的 alpha 值表示网格的非空过渡区域。让我们使用两种颜色在它们之间进行混合,以处理 LOD 过渡:
vec4 c = lod2a > 0.0 ?
  gridColorThick :
  lod1a > 0.0 ?
    mix(gridColorThick, gridColorThin, lodFade) : gridColorThin;
  1. 最后但同样重要的是,当网格远离摄像机时,让它消失。使用gridSize值来计算不透明度衰减:
uv -= camPos;
float opacityFalloff = (1.0 - satf(length(uv) / gridSize));
  1. 现在,我们可以混合 LOD 级别的 alpha 值,并使用不透明度衰减因子缩放结果。生成的像素颜色值可以存储在帧缓冲区中:
c.a *= 
  lod2a > 0.0 ? lod2a : lod1a > 0.0 ? lod1a : (lod0a * (1.0-lodFade));
c.a *= opacityFalloff;
out_FragColor = c;
  1. data/shaders/GridCalculation.h中提到的着色器应使用以下渲染管线状态进行渲染,该状态在Chapter05/05_Grid/src/main.cpp中创建:
lvk::Holder<lvk::RenderPipelineHandle> pipeline =
  ctx->createRenderPipeline({
    .smVert      = vert,
    .smFrag      = frag,
    .color       = { {
      .format            = ctx->getSwapchainFormat(),
      .blendEnabled      = true,
      .srcRGBBlendFactor = lvk::BlendFactor_SrcAlpha,
      .dstRGBBlendFactor = lvk::BlendFactor_OneMinusSrcAlpha,
    } },
    .depthFormat = app.getDepthFormat() });
  1. 同一文件中的 C++渲染代码如下所示:
buf.cmdBindRenderPipeline(pipeline);
buf.cmdBindDepthState({});
struct {
  mat4 mvp;
  vec4 camPos;
  vec4 origin;
} pc = {
  .mvp    = glm::perspective(
    45.0f, aspectRatio, 0.1f, 1000.0f) * app.camera_.getViewMatrix(),
  .camPos = vec4(app.camera_.getPosition(), 1.0f),
  .origin = vec4(0.0f),
};
buf.cmdPushConstants(pc);
buf.cmdDraw(6);

查看完整的Chapter05/05_Grid以获取自包含的演示应用程序。可以使用 WASD 键和鼠标控制摄像机。生成的图像应类似于以下截图:

图 5.5:GLSL 网格

图 5.5:GLSL 网格

还有更多...

除了仅考虑到摄像机的距离来计算抗锯齿衰减因子外,我们还可以使用视向量与网格线之间的角度。这将使网格的整体外观和感觉更加视觉上令人愉悦,如果您想将网格不仅作为内部调试工具,还作为面向客户的产品的部分,如编辑器,这将是一个有趣的改进。

这个实现受到了Our Machinery博客的启发。不幸的是,它已经不再可用。然而,互联网上还有一些其他高级材料展示了如何渲染更复杂的网格,这些网格适合面向客户的渲染。请确保您阅读了 Ben Golus 的博客文章The Best Darn Grid Shader (Yet) bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8,该文章将网格渲染推进了很多。

在我们继续下一个菜谱之前,我们想提一下,网格渲染非常方便,我们已经将其包含在我们大多数后续的演示应用程序中。您可以使用VulkanApp::drawGrid()函数在任何您想要的位置渲染此网格。

将细分集成到图形管线中

让我们转换一下话题,学习如何将硬件细分集成到 Vulkan 图形渲染管线中。

硬件细分在图形管线中实现为一组两个新的着色器阶段类型。第一个着色器阶段称为细分控制着色器,第二个阶段称为细分评估着色器。细分控制着色器在一系列顶点上操作,这些顶点称为控制点,并定义了一个称为补丁的几何表面。着色器可以操纵控制点并计算所需的细分级别。细分评估着色器可以访问细分三角形的重心坐标,并可以使用它们来插值任何所需的每个顶点属性,如纹理坐标和颜色。让我们通过代码来看看如何使用这些新的着色器阶段根据到摄像机的距离对网格进行三角化。

使用细分着色器进行硬件细分可能不如使用网格着色器高效。遗憾的是,截至本书编写时,核心 Vulkan 中还没有标准化的网格着色器 API。因此,现在让我们继续使用旧的细分方法。

准备工作

此菜谱的完整源代码位于Chapter05/06_Tessellation

如何实现...

我们现在要编写的是计算基于到相机距离的每个顶点的细分级别的着色器。这样,我们可以在靠近观察者的区域渲染更多的几何细节。为此,我们应该从Chapter05/06_Tessellation/src/main.vert顶点着色器开始,它将计算顶点的世界位置并将它们传递给细分控制着色器:

  1. 我们每帧的数据包括通常的视图和投影矩阵,以及当前相机在世界空间中的位置,以及细分缩放因子,这是用户可控制的,并来自 ImGui 小部件。这些数据不适合放入128字节的推送常量中,所以我们将其全部放入缓冲区。几何体使用 PVP 技术访问,并使用vec3存储顶点位置和vec2存储纹理坐标:
// included from <Chapter05/06_Tessellation/src/common.sp>
struct Vertex {
  float x, y, z;
  float u, v;
};
layout(std430, buffer_reference) readonly buffer Vertices {
  Vertex in_Vertices[];
};
layout(std430, buffer_reference) readonly buffer PerFrameData {
  mat4 model;
  mat4 view;
  mat4 proj;
  vec4 cameraPos;
  uint texture;
  float tesselationScale;
  Vertices vtx;
};
layout(push_constant) uniform PushConstants {
  PerFrameData pc;
};
  1. 让我们编写一些辅助函数来使用传统的 GLSL 数据类型访问顶点位置和纹理坐标:
vec3 getPosition(int i) {
  return vec3(pc.vtx.in_Vertices[i].x,
              pc.vtx.in_Vertices[i].y,
              pc.vtx.in_Vertices[i].z);
}
vec2 getTexCoord(int i) {
  return vec2(pc.vtx.in_Vertices[i].u,
              pc.vtx.in_Vertices[i].v);
}
  1. 顶点着色器输出 UV 纹理坐标和每个顶点的世界位置。实际计算如下:
layout (location=0) out vec2 uv_in;
layout (location=1) out vec3 worldPos_in;
void main() {
  vec4 pos = vec4(getPosition(gl_VertexIndex), 1.0);
  gl_Position = pc.proj * pc.view * pc.model * pos;
  uv_in = getTexCoord(gl_VertexIndex);
  worldPos_in = (pc.model * pos).xyz;
}

现在我们可以进一步到下一个着色器阶段,看看细分控制着色器Chapter05/06_Tessellation/src/main.tesc

  1. 着色器在一个由3个顶点组成的组上操作,这些顶点对应于输入数据中的单个三角形。uv_inworldPos_in变量对应于顶点着色器中的那些。注意这里我们有数组而不是单个孤立的值。PerFrameData结构在此示例的所有着色器阶段中都应该完全相同,并来自common.sp
#include <Chapter05/06_Tessellation/src/common.sp>
layout (vertices = 3) out;
layout (location = 0) in vec2 uv_in[];
layout (location = 1) in vec3 worldPos_in[];
  1. 让我们描述与每个单独的顶点对应的输入和输出数据结构。除了所需的顶点位置外,我们存储vec2纹理坐标:
in gl_PerVertex {
  vec4 gl_Position;
} gl_in[];
out gl_PerVertex {
  vec4 gl_Position;
} gl_out[];
struct vertex {
  vec2 uv;
};
struct vertex {
  vec2 uv;
};
layout(location = 0) out vertex Out[];
  1. getTessLevel()函数根据两个相邻顶点到相机的距离计算所需的细分级别。用于切换级别的硬编码距离值使用来自 UI 的tessellationScale统一变量进行缩放:
float getTessLevel(float distance0, float distance1) {
  const float distanceScale1 = 1.2;
  const float distanceScale2 = 1.7;
  const float avgDistance =
    (distance0 + distance1) / (2.0 * pc.tesselationScale);
  if (avgDistance <= distanceScale1) return 5.0;
  if (avgDistance <= distanceScale2) return 3.0;
  return 1.0;
}
  1. main()函数很简单。它直接传递位置和 UV 坐标,然后计算三角形中每个顶点到相机的距离:
void main() {  
  gl_out[gl_InvocationID].gl_Position =
    gl_in[gl_InvocationID].gl_Position;
  Out[gl_InvocationID].uv = uv_in[gl_InvocationID];
  vec3 c = pc.cameraPos.xyz;
  float eyeToVertexDistance0 = distance(c, worldPos_in[0]);
  float eyeToVertexDistance1 = distance(c, worldPos_in[1]);
  float eyeToVertexDistance2 = distance(c, worldPos_in[2]);
  1. 根据这些距离,我们可以以下方式计算所需的内部和外部细分级别。内部细分级别定义了三角形内部如何细分成更小的三角形。外部级别定义了三角形的外边缘如何细分,以便它们可以正确地连接到相邻的三角形:
 gl_TessLevelOuter[0] =
    getTessLevel(eyeToVertexDistance1, eyeToVertexDistance2);
  gl_TessLevelOuter[1] =
    getTessLevel(eyeToVertexDistance2, eyeToVertexDistance0);
  gl_TessLevelOuter[2] =
    getTessLevel(eyeToVertexDistance0, eyeToVertexDistance1);
  gl_TessLevelInner[0] = gl_TessLevelOuter[2];
};

让我们看看细分评估着色器 Chapter05/06_Tessellation/src/main.tese

  1. 我们应该指定三角形作为输入。equal_spacing 间距模式告诉 Vulkan,细分级别 n 应该被限制在 0...64 范围内,并四舍五入到最接近的整数。之后,相应的边应该被分成 n 个相等的段。当细分原语生成器生成三角形时,可以通过使用标识符 cwccw 的输入布局声明来指定三角形的方向。我们使用逆时针方向:
layout(triangles, equal_spacing, ccw) in;
struct vertex {
  vec2 uv;
};
in gl_PerVertex {
  vec4 gl_Position;
} gl_in[];
layout(location = 0) in vertex In[];
out gl_PerVertex {
  vec4 gl_Position;
};
layout (location=0) out vec2 uv;
  1. 这两个辅助函数对于使用当前顶点的重心坐标在原始三角形的角之间插值 vec2vec4 属性值很有用。内置的 gl_TessCoord 变量包含所需的权重坐标,0…1
vec2 interpolate2(in vec2 v0, in vec2 v1, in vec2 v2) {
  return v0 * gl_TessCoord.x +
         v1 * gl_TessCoord.y +
         v2 * gl_TessCoord.z;
}
vec4 interpolate4(in vec4 v0, in vec4 v1, in vec4 v2) {
  return v0 * gl_TessCoord.x +
         v1 * gl_TessCoord.y +
         v2 * gl_TessCoord.z;
}
  1. main() 中的实际插值代码很简单,可以写成以下方式:
void main() {
  gl_Position = interpolate4(gl_in[0].gl_Position,
                             gl_in[1].gl_Position,
                             gl_in[2].gl_Position);
  uv = interpolate2(In[0].uv, In[1].uv, In[2].uv);
};

我们硬件细分图形管道的下一阶段是几何着色器 Chapter05/06_Tessellation/src/main.geom。我们使用它为所有的小细分三角形生成重心坐标。它用于在我们之前在本章的 使用 MeshOptimizer 生成 LODs 菜单中,在着色网格上渲染一个漂亮的抗锯齿线框覆盖:

  1. 几何着色器消耗由硬件细分器生成的三角形,并输出由单个三角形组成的三角形带:
#version 460 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
layout(location=0) in vec2 uv[];
layout(location=0) out vec2 uvs;
layout(loc
ation=1) out vec3 barycoords;
  1. 使用以下硬编码常量为每个顶点分配重心坐标:
void main() {
  const vec3 bc[3] = vec3[]( vec3(1.0, 0.0, 0.0),
                             vec3(0.0, 1.0, 0.0),
                             vec3(0.0, 0.0, 1.0) );
  for ( int i = 0; i < 3; i++ ) {
    gl_Position = gl_in[i].gl_Position;
    uvs = uv[i];
    barycoords = bc[i];
    EmitVertex();
  }
  EndPrimitive();
}

此渲染管道的最终阶段是片段着色器 Chapter05/06_Tessellation/src/main.frag

  1. 我们从几何着色器中获取重心坐标,并使用它们来计算覆盖我们网格的线框覆盖:
#include <Chapter05/06_Tessellation/src/common.sp>
layout(location=0) in vec2 uvs;
layout(location=1) in vec3 barycoords;
layout(location=0) out vec4 out_FragColor;
  1. 一个辅助函数根据到边的距离和所需的线框轮廓厚度返回混合因子。本质上,当 3 个权重坐标值之一接近 0 时,它表示当前片段接近三角形的一条边。到零的距离控制渲染边的可见厚度:
float edgeFactor(float thickness) {
  vec3 a3 = smoothstep( vec3(0.0),
              fwidth(barycoords) * thickness, barycoords);
  return min( min( a3.x, a3.y ), a3.z );
}
  1. 让我们使用提供的 UV 值采样纹理,然后结束:
void main() {
  vec4 color = textureBindless2D(pc.texture, 0, uvs);
  out_FragColor = mix( vec4(0.1), color, edgeFactor(0.75) );
}

我们 Vulkan 硬件细分管道的 GLSL 着色器部分已经完成,现在是时候查看 C++ 代码了。源代码位于 Chapter05/06_Tessellation/src/main.cpp 文件中:

  1. 细分网格渲染的着色器以下方式加载:
lvk::Holder<lvk::ShaderModuleHandle> vert =
  loadShaderModule(ctx, “Chapter05/06_Tessellation/src/main.vert”);
lvk::Holder<lvk::ShaderModuleHandle> tesc =
  loadShaderModule(ctx, “Chapter05/06_Tessellation/src/main.tesc”);
lvk::Holder<lvk::ShaderModuleHandle> geom =
  loadShaderModule(ctx, “Chapter05/06_Tessellation/src/main.geom”);
lvk::Holder<lvk::ShaderModuleHandle> tese =
  loadShaderModule(ctx, “Chapter05/06_Tessellation/src/main.tese”);
lvk::Holder<lvk::ShaderModuleHandle> frag =
  loadShaderModule(ctx, “Chapter05/06_Tessellation/src/main.frag”);
  1. 现在我们创建相应的渲染管道:
lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid =
  ctx->createRenderPipeline({
    .topology    = lvk::Topology_Patch,
    .smVert      = vert,
    .smTesc      = tesc,
    .smTese      = tese,
    .smGeom      = geom,
    .smFrag      = frag,
    .color       = { { .format = ctx->getSwapchainFormat() } },
    .depthFormat = app.getDepthFormat(),
    .patchControlPoints = 3,
  });;

data/rubber_duck/scene.gltf 网格加载代码与之前菜谱中的相同,所以这里我们将跳过它。更重要的是我们如何渲染网格和 ImGui 小部件来控制镶嵌缩放因子。让我们看看渲染循环的主体:

  1. 首先,我们计算我们的网格的模型视图投影矩阵:
const mat4 m = glm::rotate(mat4(1.0f),
  glm::radians(-90.0f), vec3(1, 0, 0));
const mat4 v = glm::rotate(glm::translate(mat4(1.0f),
  vec3(0.0f, -0.5f, -1.5f)),
  (float)glfwGetTime(), vec3(0.0f, 1.0f, 0.0f));
const mat4 p = glm::perspective(45.0f, aspectRatio, 0.1f, 1000.0f);
  1. 每帧数据被上传到缓冲区。
const PerFrameData pc = {
  .model             = v * m,
  .view              = app.camera_.getViewMatrix(),
  .proj              = p,
  .cameraPos         = vec4(app.camera_.getPosition(), 1.0f),
  .texture           = texture.index(),
  .tessellationScale = tessellationScale,
  .vertices          = ctx->gpuAddress(vertexBuffer),
};
ctx->upload(bufferPerFrame, &pc, sizeof(pc));
  1. 使用常量推送来传递缓冲区的地址到着色器。然后我们可以使用我们的镶嵌管道渲染网格。
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
buf.cmdBeginRendering(renderPass, framebuffer);
buf.cmdPushConstants(ctx->gpuAddress(bufferPerFrame));
buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
buf.cmdBindRenderPipeline(pipelineSolid);
buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                        .isDepthWriteEnabled = true });
buf.cmdDrawIndexed(indices.size());
  1. 我们在上面添加了一个网格,正如本章前面在 实现无限网格 GLSL 着色器 菜谱中描述的那样。原点用于将网格放置在鸭模型下方。在我们的帧渲染循环中,我们可以像往常一样访问所有的 ImGui 渲染功能。在这里,我们只渲染一个包含镶嵌缩放因子的浮点值的单个滑块:
app.drawGrid(buf, p, vec3(0, -0.5f, 0));
app.imgui_->beginFrame(framebuffer);
ImGui::Begin(“Camera Controls”, nullptr,
  ImGuiWindowFlags_AlwaysAutoResize);
ImGui::SliderFloat(
  “Tessellation scale”, &tessellationScale, 0.7f, 1.2f, “%.1f”);
ImGui::End();
app.imgui_->endFrame(buf);
buf.cmdEndRendering();

这里是运行中的演示应用程序的截图:

图 5.6:镶嵌鸭

图 5.6:镶嵌鸭

注意不同的镶嵌级别如何根据与摄像机的距离而变化。尝试调整控制滑块以强调效果。

更多...

这个菜谱可以用作你在 Vulkan 应用程序中硬件网格镶嵌技术的基石。一个自然的下一步就是使用法线向量的方向将位移图应用到细粒度镶嵌的顶点上。查看这个页面以获取灵感:www.geeks3d.com/20100804/test-opengl-4-tessellation-with-displacement-mapping。对于那些想要在细分表面自适应镶嵌上深入研究的人,GPU Gems 2 书籍中有一个章节详细介绍了这个高级主题。现在它可以在developer.nvidia.com/gpugems/gpugems2/part-i-geometric-complexity/chapter-7-adaptive-tessellation-subdivision-surfaces在线获取。

组织网格数据存储

在前面的章節中,我們為我們的網格使用了固定的硬編碼頂點格式,這些格式在演示之間會變化,並隱式地包含了材料描述。例如,使用硬編碼的紋理來提供顏色信息。一個三角形網格由索引和頂點定義。每個頂點被定義為一組屬性,它們具有與 lvk::VertexInput 顶点输入描述相對應的獨特數據格式。一個物體的所有輔助物理屬性,如碰撞檢測數據、質量和慣量矩,都可以通過網格表示,而其他信息,如表面材料屬性,可以作為外部元數據存儲在網格之外。值得注意的是,像我們之前使用的橡皮鴨這樣的小型 3D 模型可以非常快地加載。然而,更大、更複雜的現實世界 3D 模型,尤其是在使用 .gltf 等傳輸格式時,可能需要幾分鐘才能加載。通過消除任何解析並用平坦緩衝區加載替換它,運行時網格格式可以通過匹配內部渲染數據結構來解決這個問題。這涉及到使用像 fread() 和類似的快速函數。

讓我們定義一個統一的網格存儲格式,以涵蓋本書後續部分的所有用例。

准備工作

本食谱描述了我们将用于在整个书中存储网格数据的基礎數據結構。完整的相應源代碼位於頭文件 shared/Scene/VtxData.h 中。在繼續進行之前,請確保您已經閱讀了 第二章開始使用 Vulkan

如何操作...

存儲在連續位置的均勻 頂點屬性 的向量被稱為 頂點流。這些屬性的例子包括頂點位置、紋理坐標和法向量,每個三個代表一個屬性。每個流必須有一個格式。頂點位置是 vec3,紋理坐標可以是 vec2,法向量可以使用打包格式 Int_2_10_10_10_REV,等等。

我們將 LOD 定義為一個較小尺寸的索引緩衝區,它使用現有的頂點,因此可以直接使用原始頂點緩衝區進行渲染。我們在這一章的 使用 MeshOptimizer 生成 LOD 製品中學習了如何創建 LOD。

我們將 網格 定義為所有頂點數據流和所有索引緩衝區的集合,每個 LOD 一個。每個頂點數據流中的元素數量相同,稱為“頂點數量”,我們將在下面的說明中遇到。為了讓事情簡單一點,我們總是使用 32 位偏移量和索引來存儲我們的數據。

所有的顶点数据流和 LOD 索引缓冲区都被打包到一个单一的 blob 中。这允许我们通过单个fread()调用加载数据,甚至可以使用内存映射来允许直接数据访问。这种简单的顶点数据表示也使得直接将网格上传到 GPU 成为可能。特别有趣的是,它能够将多个网格的数据合并到一个文件中。或者,这也可以通过将数据合并到两个大缓冲区中实现——一个用于索引,另一个用于顶点属性。这将在我们学习如何在 GPU 上实现 LOD 切换技术时非常有用。

在这个配方中,我们只处理几何数据。LOD 创建过程在配方使用 MeshOptimizer 生成 LOD中介绍,而材质数据导出过程在随后的章节中介绍。让我们看一下shared/Scene/VtxData.h并声明我们网格的主要数据结构:

  1. 首先,我们需要定义一个单独的网格描述。我们故意避免使用指针,因为指针隐藏内存分配并禁止简单保存和加载数据。我们存储指向各个数据流和 LOD 索引缓冲区的偏移量,这些偏移量相当于指针,但更灵活,最重要的是,更符合 GPU 的要求。Mesh结构中的所有偏移量都是相对于数据块开始的位置给出的。让我们声明我们网格的主要数据结构。它包含 LOD 的数量和顶点数据流。LOD 计数,其中原始网格被视为一个 LOD,必须严格小于kMaxLODs,因为我们不存储 LOD 索引缓冲区的大小,而是从偏移量计算它们。为了计算这些大小,我们在末尾存储一个额外的空 LOD 级别。顶点数据流的数量直接存储,没有修改。
constexpr const uint32_t kMaxLODs = 8;
struct Mesh final {
  uint32_t lodCount = 1;
  uint32_t indexOffset = 0;
  uint32_t vertexOffset = 0;
  1. vertexCount字段包含此网格中的顶点总数。这个数字描述了顶点缓冲区的内容,并且可能大于任何单个细节级别上的顶点数。我们将材质数据存储的问题推迟到下一章。为了优雅地完成这项工作,让我们引入一个间接级别。materialID字段包含一个抽象标识符,允许我们引用存储在其他地方的材料数据:
 uint32_t vertexCount = 0;
  uint32_t materialID = 0;
  1. 每个网格可能以不同的 LOD 显示。文件包含所有细节级别的索引和每个 LOD 开始的偏移量存储在lodOffset数组中。此数组在末尾包含一个额外的项,用作计算最后一个 LOD 大小的标记:
 uint32_t lodOffset[kMaxLODs+1] = { 0 };
  1. 而不是存储每个 LOD 的索引数,我们定义了一个小辅助函数来计算这个数字:
 inline uint32_t getLODIndicesCount(uint32_t lod) const {
    return lod < lodCount ? lodOffset[lod + 1] - lodOffset[lod] : 0;
  }
};

正如你可能已经注意到的,Mesh结构只是对包含数据的其他缓冲区的索引,例如索引和顶点缓冲区。让我们看一下那个数据容器:

  1. 顶点流的格式由结构 lvk::VertexInput 描述。我们在 第二章Vulkan 入门 中已经使用过它。这种顶点流描述形式允许非常灵活的存储。
struct MeshData {
  lvk::VertexInput streams = {};
  1. 实际的索引和顶点缓冲区数据存储在这些容器中。它们可以容纳多个网格。为了简单起见,我们在这本书中使用 32 位索引。
 std::vector<uint32_t> indexData;
  std::vector<uint8_t> vertexData;
  1. 另一个 std::vector 存储每个单独的网格描述:
 std::vector<Mesh> meshes;
  1. 为了完整性,我们还将在这里为每个网格存储一个边界框。边界框对于剔除非常有用,并且预先计算它们可以显著加快加载过程。
 std::vector<BoundingBox> boxes;
};

注意

对于这本书,我们只关注紧密打包(非交错)的顶点属性流。然而,通过使用 lvk::VertexInput::VertexInputBinding 中的步进参数,将提出的模式扩展到支持交错数据存储并不困难。一个主要的缺点是这种数据重组将需要我们更改着色器中的所有顶点提取代码。如果您正在开发生产代码,在确定一种特定方法之前,请测量在目标硬件上哪种存储格式运行得更快。

在我们能够将这些网格数据结构存储到文件中之前,我们需要某种类型的文件标题:

  1. 为了确保数据完整性和检查标题的有效性,我们在标题的前 4 个字节中存储了一个魔数十六进制值 0x12345678
struct MeshFileHeader {
  uint32_t magicValue;
  1. 该文件中 Mesh 描述符的数量存储在 meshCount 字段中:
 uint32_t meshCount;
  1. 最后两个成员字段分别存储索引和顶点数据的大小(以字节为单位)。这些值在检查网格文件完整性时非常有用:
 uint32_t indexDataSize;
  uint32_t vertexDataSize;
};

文件接着是 Mesh 结构的列表。在标题和单个网格描述符列表之后,我们存储一个大的索引和顶点数据块,可以一次性加载。

它是如何工作的...

让我们通过 shared/Scene/VtxData.cpp 中的伪代码来了解如何加载这样的文件,它只是几个看起来如下所示的 fread() 调用。本书文本中省略了错误检查,但在实际代码中是存在的:

  1. 首先,我们使用网格数量读取文件标题。本书中省略了错误检查,但在捆绑的源代码中是存在的:
MeshFileHeader loadMeshData(const char* meshFile, MeshData& out) {
  FILE* f = fopen(meshFile, “rb”);
  SCOPE_EXIT { fclose(f); };
  MeshFileHeader header;
  fread(&header, 1, sizeof(header), f)
  fread(&out.streams, 1, sizeof(out.streams), f);
  1. 读取了标题后,我们调整网格描述符数组的大小,并读取所有 Mesh 描述:
 out.meshes.resize(header.meshCount);
  fread(out.meshes.data(), sizeof(Mesh), header.meshCount, f);
  out.boxes.resize(header.meshCount);
  fread(out.boxes.data(), sizeof(BoundingBox), header.meshCount, f);
  1. 然后我们读取该网格的主几何数据块,其中包含实际的索引和顶点数据:
 out.indexData.resize(header.indexDataSize / sizeof(uint32_t));
  out.vertexData.resize(header.vertexDataSize);
  fread(out.indexData.data(), 1, header.indexDataSize, f);
  fread(out.vertexData.data(), 1, header.vertexDataSize, f);
  return header;
};

或者,索引和顶点缓冲区可以合并成一个单独的大字节数据缓冲区。我们将这个作为读者的练习。

之后,indexDatavertexData 容器可以直接上传到 GPU。我们将在本章后续的食谱中重新审视这个想法。

尽管您可以在本章的演示应用Chapter05/07_MeshRenderer中看到此代码的结果,但还有一些额外的功能需要实现。在我们运行并见证演示之前,让我们先探讨更多的话题。

还有更多...

这种几何数据格式对于存储静态网格数据来说非常简单和直接。如果网格可能会更改、重新加载或异步加载,我们可以在专用文件中存储单独的网格。

由于无法预测所有用例,而且本书主要关于渲染而不是通用游戏引擎的创建,因此添加额外功能(如网格蒙皮或其他功能)的决定取决于读者。这样一个决定的简单例子是将材质数据直接添加到网格文件中。技术上,我们只需要在MeshFileHeader中添加一个materialCount字段,并在网格列表之后存储材质描述列表。即使这样简单的事情也会立即引发更多问题。我们应该在同一个文件中打包纹理数据吗?如果是这样,纹理格式应该有多复杂?我们应该使用哪种材质模型?等等。目前,我们将网格几何数据与材质描述分开。我们将在后续章节中回到材质。

实现自动几何转换

在前面的章节中,我们学习了如何使用Assimp库加载和渲染存储在不同文件格式中的 3D 模型。在实际的图形应用中,加载模型的过程可能既繁琐又多阶段。除了加载之外,我们可能还希望以某种特定方式优化网格,例如优化几何形状和计算多个 LOD 网格。对于较大的网格,这个过程可能会变得很慢,因此在应用程序开始之前离线预处理网格,并在应用程序中稍后加载它们,就像在之前的配方组织网格数据存储中描述的那样,是非常合理的。让我们学习如何实现一个简单的自动几何预处理和转换框架。

在本书的前一版中,我们创建了一个独立的几何转换工具,需要在后续的演示应用加载转换后的数据之前执行。结果证明,这是我们疏忽大意的一个重大错误,因为许多读者直接运行了演示应用,然后报告了当事情没有像他们预期的那样立即工作时的各种问题。在这里,我们已经纠正了这个错误。如果一个应用需要转换后的数据,但找不到它,它将触发所有必要的代码来从存储资源中加载数据并将其转换为我们的运行时格式。

准备工作

我们几何转换框架的源代码位于Chapter05/07_MeshRenderer。低级加载函数定义在shared/Scene/VtxData.cpp中。整个演示应用程序由本章的多个配方覆盖,包括组织网格数据存储实现自动几何转换Vulkan 中的间接渲染

如何做到这一点...

让我们看看如何使用Assimp库导出网格数据,并使用组织网格数据存储配方中定义的数据结构将其保存到二进制文件中:

  1. 我们首先探索一个名为convertAIMesh()的函数,该函数将Assimp网格表示转换为我们的运行时格式,并将其附加到引用的MeshData参数。同时更新全局索引和顶点偏移量。该函数相当长,但我们将在这里详细探讨。错误检查被省略:
Mesh convertAIMesh(const aiMesh* m, MeshData& meshData,
  uint32_t& indexOffset, uint32_t& vertexOffset)
{
  const bool hasTexCoords = m->HasTextureCoords(0);
  1. 实际网格几何数据存储在以下两个数组中。我们无法逐个输出转换后的网格,至少在单遍工具中不能,因为我们事先不知道数据的总大小,所以我们为所有数据分配内存存储,然后将这些数据块写入输出文件。我们还需要一个全局顶点缓冲区的引用,我们将从这个aiMesh中添加新顶点。outLods容器是每个 LOD 的索引缓冲区。然后我们只需遍历aiMesh的所有顶点并将它们转换即可:
 std::vector<float> srcVertices;
  std::vector<uint32_t> srcIndices;
  std::vector<uint8_t>& vertices = meshData.vertexData;
  std::vector<std::vector<uint32_t>> outLods;
  1. 对于这个配方,我们假设只有一个 LOD,并且所有顶点数据都存储为连续的数据流。换句话说,我们有一个数据交错存储。我们还忽略了所有材质信息,目前只处理索引和顶点数据。
 for (size_t i = 0; i != m->mNumVertices; i++) {
    const aiVector3D v = m->mVertices[i];
    const aiVector3D n = m->mNormals[i];
    const aiVector2D t = !hasTexCoords ? aiVector2D() : aiVector2D(
      m->mTextureCoords[0][i].x,
      m->mTextureCoords[0][i].y);
    if (g_calculateLODs) {
      srcVertices.push_back(v.x);
      srcVertices.push_back(v.y);
      srcVertices.push_back(v.z);
    }
  1. 一旦我们有了顶点的流数据,我们就可以将其输出到顶点缓冲区。位置vvec3存储。纹理坐标uv以半浮点vec2存储,以节省空间。法向量被转换为2_10_10_10_REV,大小为uint32_t——对于3个浮点数来说还不错。

put()是一个模板函数,它将第二个参数的值从其复制到uint8_t的向量中:

 put(vertices, v);
    put(vertices, glm::packHalf2x16(vec2(t.x, t.y)));
    put(vertices, glm::packSnorm3x10_1x2(vec4(n.x, n.y, n.z, 0)));
  }
  1. 描述我们演示的顶点流:位置、纹理坐标和法向量。步长来自vec3位置的大小、打包到uint32_t的半浮点vec2纹理坐标,以及打包到uint32_t2_10_10_10_REV法向量。
 meshData.streams = {
    .attributes = {{ .location = 0,
                     .format = lvk::VertexFormat::Float3,
                     .offset = 0 },
                   { .location = 1,
                     .format = lvk::VertexFormat::HalfFloat2,
                     .offset = sizeof(vec3) },
                   { .location = 2,
                     .format = lvk::VertexFormat::Int_2_10_10_10_REV,
                     .offset = sizeof(vec3) + sizeof(uint32_t) } },
    .inputBindings = { { .stride =
      sizeof(vec3) + sizeof(uint32_t) + sizeof(uint32_t) } },
  };
  1. 遍历所有面并创建索引缓冲区:
 for (unsigned int i = 0; i != m->mNumFaces; i++) {
    if (m->mFaces[i].mNumIndices != 3) continue;
    for (unsigned j = 0; j != m->mFaces[i].mNumIndices; j++)
      srcIndices.push_back(m->mFaces[i].mIndices[j]);
  }
  1. 如果不需要 LOD 计算,我们只需将srcIndices存储为 LOD 0 即可。否则,我们调用processLods()函数,该函数根据使用 MeshOptimizer 生成 LOD配方计算此网格的 LOD 级别。
 if (!g_calculateLODs)
     outLods.push_back(srcIndices);
  else
     processLods(srcIndices, srcVertices, outLods);
  1. 在更新indexOffsetvertexOffset参数之前,让我们将它们的值存储在生成的Mesh结构中。它们的值代表在我们开始转换此aiMesh之前,所有先前索引和顶点数据结束的位置。
 Mesh result = {
    .indexOffset  = indexOffset,
    .vertexOffset = vertexOffset,
    .vertexCount  = m->mNumVertices,
  };
  1. 依次流出到所有 LOD 级别的所有索引:
 uint32_t numIndices = 0;
  for (size_t l = 0; l < outLods.size(); l++) {
    for (size_t i = 0; i < outLods[l].size(); i++)
      meshData.indexData.push_back(outLods[l][i]);
    result.lodOffset[l] = numIndices;
    numIndices += (int)outLods[l].size();
  }
  result.lodOffset[outLods.size()] = numIndices;
  result.lodCount                  = (uint32_t)outLods.size();
  1. 在处理输入网格后,我们增加索引和当前起始顶点的偏移计数器:
 indexOffset += numIndices;
  vertexOffset += m->mNumVertices;
  return result;
}

使用 Assimp 处理 3D 资产文件包括加载场景并将每个网格转换为内部格式。让我们看看loadMeshFile()函数,看看如何操作:

  1. aiImportFile()函数的标志列表包括允许在不进行任何额外处理的情况下进一步使用导入数据的选项。例如,所有变换层次结构都被简化,并且结果变换矩阵应用于网格顶点。
void loadMeshFile(const char* fileName, MeshData& meshData)
{
  const unsigned int flags = aiProcess_JoinIdenticalVertices |
                             aiProcess_Triangulate |
                             aiProcess_GenSmoothNormals |
                             aiProcess_LimitBoneWeights | 
                             aiProcess_SplitLargeMeshes |
                             aiProcess_ImproveCacheLocality |
                             aiProcess_RemoveRedundantMaterials |
                             aiProcess_FindDegenerates |
                             aiProcess_FindInvalidData |
                             aiProcess_GenUVCoords;
  const aiScene* scene = aiImportFile(fileName, flags);
  1. 在导入 Assimp 场景后,我们相应地调整网格描述容器的大小,并对场景中的每个网格调用convertAIMesh()indexOffsetvertexOffset偏移量是逐步累积的:
 meshData.meshes.reserve(scene->mNumMeshes);
  meshData.boxes.reserve(scene->mNumMeshes);
  uint32_t indexOffset = 0;
  uint32_t vertexOffset = 0;
  for (unsigned int i = 0; i != scene->mNumMeshes; i++)
    meshData.meshes.push_back(
      convertAIMesh(scene->mMeshes[i], meshData,
        indexOffset, vertexOffset));
  1. 最后,我们预先计算网格数据的轴对齐边界框:
 recalculateBoundingBoxes(meshData);
}

尽管数据加载和预处理应根据每个演示应用的需求进行定制,但保存几乎是标准的,因为MeshData包含了我们所需的所有信息。因此,保存函数saveMeshData()定义在shared/Scene/VtxData.cpp

将转换后的网格保存到我们的文件格式是组织网格数据存储配方中描述的从文件读取网格的逆过程:

  1. 首先,我们使用网格编号和偏移量填充文件头部结构:
void saveMeshData(const char* fileName, const MeshData& m) {
  FILE* f = fopen(fileName, “wb”);
  1. 我们计算索引和顶点数据缓冲区的字节数,并将它们存储在头部:
 const MeshFileHeader header = {
    .magicValue    = 0x12345678,
    .meshCount     = (uint32_t)m.meshes.size(),
    .indexDataSize =
      (uint32_t)(m.indexData.size() * sizeof(uint32_t)),
    .vertexDataSize = (uint32_t)(m.vertexData.size()),
  };
  1. 一旦所有尺寸都已知,我们就保存头部信息和网格描述列表:
 fwrite(&header, 1, sizeof(header), f);
  fwrite(&m.streams, 1, sizeof(m.streams), f);
  fwrite(m.meshes.data(), sizeof(Mesh), header.meshCount, f);
  fwrite(m.boxes.data(), sizeof(BoundingBox), header.meshCount, f);
  1. 在头部和其他元数据之后,存储了两个包含索引和顶点数据的块。
 fwrite(m.indexData.data(), 1, header.indexDataSize, f);
  fwrite(m.vertexData.data(), 1, header.vertexDataSize, f);
  fclose(f);
}

让我们把所有这些代码应用到我们的演示应用中。

它是如何工作的...

网格转换框架是我们演示应用的一部分。让我们看看Chapter05/07_MeshRenderer/src/main.cpp文件中的这部分内容:

  1. 首先,我们检查是否有有效的缓存网格数据可用。isMeshDataValid() 函数检查指定的缓存网格文件是否存在,并对数据大小进行一些常规的合理性检查。
bool isMeshDataValid(const char* meshFile) {
  FILE* f = fopen(meshFile, “rb”);
  if (!f)  false;
  SCOPE_EXIT { fclose(f); };
  MeshFileHeader header;
  if (fread(&header, 1, sizeof(header), f) != sizeof(header))
    return false;
  if (fseek(f, sizeof(Mesh) * header.meshCount, SEEK_CUR))
    return false;
  if (fseek(f, sizeof(BoundingBox) * header.meshCount, SEEK_CUR))
    return false;
  if (fseek(f, header.indexDataSize, SEEK_CUR))
    return false;
  if (fseek(f, header.vertexDataSize, SEEK_CUR))
    return false;
  return true;
}
  1. 然后,我们使用isMeshDataValid()函数加载 Lumberyard Bistro 数据集:
const char* meshMeshes = “.cache/ch05_bistro.meshes”;
int main() {
  if (!isMeshDataValid(meshMeshes)) {
    printf(“No cached mesh data found. Precaching...\n\n”);
    MeshData meshData;
    loadMeshFile(“deps/src/bistro/Exterior/exterior.obj”, meshData);
    saveMeshData(meshMeshes, meshData);
  }
  1. 如果数据已经预缓存,我们只需使用前面在本配方中描述的loadMeshData()加载它:
 MeshData meshData;
  const MeshFileHeader header = loadMeshData(meshMeshes, meshData);

输出的网格数据被保存到文件.cache/ch05_bistro.meshes中。让我们继续本章的其余部分,学习如何使用 Vulkan 渲染这个网格。

Vulkan 中的间接渲染

间接渲染是将绘图命令发送到图形 API 的过程,其中这些命令的大多数参数来自 GPU 缓冲区。它是许多现代 GPU 使用范例的一部分,并以某种形式存在于所有当代渲染 API 中。例如,我们可以使用 Vulkan 的vkCmdDraw*Indirect*()函数系列进行间接渲染。在这里不处理低级 Vulkan,让我们更深入地了解如何将 Vulkan 中的间接渲染与我们在组织网格数据存储配方中介绍的网格数据格式相结合。

准备工作

在前面的配方中,我们介绍了构建网格预处理管道和将 3D 网格从.gltf2等传输格式转换为我们的运行时网格数据格式。为了结束这一章,让我们展示如何渲染这些数据。为了探索新的内容,让我们探讨如何使用间接渲染技术实现这一点。

一旦我们定义了网格数据结构,我们还需要渲染它们。为此,我们使用之前描述的函数为顶点和索引数据分配 GPU 缓冲区,将所有数据上传到 GPU,最后渲染所有网格。

之前定义的Mesh数据结构的全部意义在于能够在单个 Vulkan 命令中渲染多个网格。自 API 的 1.0 版本以来,Vulkan 支持间接渲染技术。这意味着我们不需要为每个网格发出vkCmdDraw()命令。相反,我们创建一个 GPU 缓冲区,并用VkDrawIndirectCommand结构数组的填充,然后在这些结构中填充适当的偏移量到我们的索引和顶点数据缓冲区中,最后发出单个vkCmdDrawIndirect()调用。组织网格数据存储配方中描述的Mesh结构包含填充VkDrawIndirectCommand所需的数据。

本配方完整的源代码位于Chapter05/07_MeshRenderer。在继续阅读之前,建议重新查看组织网格数据存储实现自动几何转换配方。

如何实现...

让我们实现一个简单的辅助类VKMesh,使用LightweightVK来渲染我们的网格:

  1. 我们需要三个缓冲区,一个索引缓冲区、一个顶点缓冲区和间接缓冲区,以及三个着色器和渲染管线:
class VKMesh final {
  lvk::Holder<lvk::BufferHandle> bufferIndices_;
  lvk::Holder<lvk::BufferHandle> bufferVertices_;
  lvk::Holder<lvk::BufferHandle> bufferIndirect_;
  lvk::Holder<lvk::ShaderModuleHandle> vert_;
  lvk::Holder<lvk::ShaderModuleHandle> geom_;
  lvk::Holder<lvk::ShaderModuleHandle> frag_;
  lvk::Holder<lvk::RenderPipelineHandle> pipeline_;
  1. 构造函数接受对MeshFileHeaderMeshData的引用,我们在之前的配方实现了自动几何转换中加载了它们。数据缓冲区按原样使用并直接上传到相应的 Vulkan 缓冲区。索引的数量从索引缓冲区大小推断出来,假设索引以 32 位无符号整数存储。需要深度格式规范来创建相应的渲染管道,就在这个类中:
public:
  uint32_t numIndices_ = 0;
  VKMesh(const std::unique_ptr<lvk::IContext>& ctx,
         const MeshFileHeader& header,
         const MeshData& meshData,
         lvk::Format depthFormat)
  : numIndices_(header.indexDataSize / sizeof(uint32_t)) {
    const uint32_t* indices = meshData.indexData.data();
    const uint8_t* vertexData = meshData.vertexData.data();
  1. 创建顶点和索引缓冲区并将数据上传到它们:
 bufferVertices_ = ctx->createBuffer({
      .usage     = lvk::BufferUsageBits_Vertex,
      .storage   = lvk::StorageType_Device,
      .size      = header.vertexDataSize,
      .data      = vertexData,
      .debugName = “Buffer: vertex” }, nullptr);
    bufferIndices_ = ctx->createBuffer({
      .usage     = lvk::BufferUsageBits_Index,
      .storage   = lvk::StorageType_Device,
      .size      = header.indexDataSize,
      .data      = indices,
      .debugName = “Buffer: index” }, nullptr);
  1. 为我们的间接缓冲区分配数据存储:
 std::vector<uint8_t> drawCommands;
    const uint32_t numCommands = header.meshCount;
    drawCommands.resize(
      sizeof(DrawIndexedIndirectCommand) * numCommands +
        sizeof(uint32_t));
  1. 在间接缓冲区的开头存储绘制命令的数量。这种方法在本演示中没有使用,但在 GPU 驱动的渲染中可能很有用,当 GPU 计算绘制命令的数量并将其存储在缓冲区中时。
 memcpy(drawCommands.data(), &numCommands, sizeof(numCommands));
    DrawIndexedIndirectCommand* cmd = std::launder(
      reinterpret_cast<DrawIndexedIndirectCommand*>(
        drawCommands.data() + sizeof(uint32_t)));
  1. 填充我们的间接命令缓冲区的内容。每个命令对应一个单独的Mesh结构。间接缓冲区应该使用BufferUsageBits_Indirect使用标志分配。
 for (uint32_t i = 0; i != numCommands; i++)
      *cmd++ = {
        .count         = meshData.meshes[i].getLODIndicesCount(0),
        .instanceCount = 1,
        .firstIndex    = meshData.meshes[i].indexOffset,
        .baseVertex    = meshData.meshes[i].vertexOffset,
        .baseInstance  = 0,
      };

DrawIndexedIndirectCommand只是我们与VkDrawIndexedIndirectCommand的镜像,以防止将 Vulkan 头文件包含到我们的应用程序中。虽然这可能对 Vulkan 书籍来说有些夸张,但这种类型的分离在现实世界的应用程序中可能很有用,考虑到这种数据结构在 Vulkan、Metal 和 OpenGL 中都是兼容的。

struct DrawIndexedIndirectCommand { uint32_t count; uint32_t instanceCount; uint32_t firstIndex; uint32_t baseVertex; uint32_t baseInstance;};

 bufferIndirect_ = ctx->createBuffer({
      .usage     = lvk::BufferUsageBits_Indirect,
      .storage   = lvk::StorageType_Device,
      .size      = sizeof(DrawIndexedIndirectCommand) *
        numCommands + sizeof(uint32_t),
      .data      = drawCommands.data(),
      .debugName = “Buffer: indirect” }, nullptr);
  1. 渲染管道使用一组顶点、几何和片段着色器创建。几何着色器用于生成重心坐标以渲染漂亮的线框。可以直接使用meshData.streams中的顶点流描述来初始化管道的顶点输入:
 vert_ = loadShaderModule(
      ctx, “Chapter05/07_MeshRenderer/src/main.vert”);
    geom_ = loadShaderModule(
      ctx, “Chapter05/07_MeshRenderer/src/main.geom”);
    frag_ = loadShaderModule(
      ctx, “Chapter05/07_MeshRenderer/src/main.frag”);
    pipeline_ = ctx->createRenderPipeline({
      .vertexInput = meshData.streams,
      .smVert      = vert_,
      .smGeom      = geom_,
      .smFrag      = frag_,
      .color       = { { .format = ctx->getSwapchainFormat() } },
      .depthFormat = depthFormat,
      .cullMode    = lvk::CullMode_Back,
    });
  }
  1. draw()方法填充相应的 Vulkan 命令缓冲区以渲染整个网格。注意cmdDrawIndexedIndirect()跳过了间接缓冲区中命令数量的前 32 位。我们将在第十一章高级渲染技术和优化中使用这个数字:
 void draw(lvk::ICommandBuffer& buf, const MeshFileHeader& header) {
    buf.cmdBindIndexBuffer(bufferIndices_, lvk::IndexFormat_UI32);
    buf.cmdBindVertexBuffer(0, bufferVertices_);
    buf.cmdBindRenderPipeline(pipeline_);
    buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                            .isDepthWriteEnabled = true });
    buf.cmdDrawIndexedIndirect(
      bufferIndirect_, sizeof(uint32_t), header.meshCount);
  }
};
  1. 在加载 Bistro 网格后,此类被如下使用:
MeshData meshData;
const MeshFileHeader header = loadMeshData(meshMeshes, meshData);
const VKMesh mesh(ctx, header, meshData, app.getDepthFormat());

这完成了我们初始化过程的描述。现在,让我们转向 GLSL 源代码:

  1. 顶点着色器Chapter05/07_MeshRenderer/src/main.vert相当简单。我们为了简单起见不使用可编程顶点提取。仅使用标准的顶点属性来简化示例。
layout(push_constant) uniform PerFrameData {
  mat4 MVP;
};
layout (location=0) in vec3 in_pos;
layout (location=1) in vec2 in_tc;
layout (location=2) in vec3 in_normal;
layout (location=0) out vec2 uv;
layout (location=1) out vec3 normal;
void main() {
  gl_Position = MVP * vec4(in_pos, 1.0);
  uv = in_tc;
  normal = in_normal;
};
  1. 几何着色器Chapter05/07_MeshRenderer/src/main.geom提供了必要的重心坐标,如本章前面所述:
#version 460 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
layout(location=0) in vec2 uv[];
layout(location=1) in vec3 normal[];
layout(location=0) out vec2 uvs;
layout(location=1) out vec3 barycoords;
layout(location=2) out vec3 normals;
void main() {
  const vec3 bc[3] = vec3[](vec3(1.0, 0.0, 0.0),
                            vec3(0.0, 1.0, 0.0),
                            vec3(0.0, 0.0, 1.0) );
  for ( int i = 0; i < 3; i++ ) {
    gl_Position = gl_in[i].gl_Position;
    uvs = uv[i];
    barycoords = bc[i];
    normals = normal[i];
    EmitVertex();
  }
  EndPrimitive();
}
  1. 片段着色器Chapter05/07_MeshRenderer/src/main.frag计算一些改进的光照,并根据几何着色器生成的重心坐标应用线框轮廓,如将细分集成到图形管道配方中所述:
layout (location=0) in vec2 uvs;
layout (location=1) in vec3 barycoords;
layout (location=2) in vec3 normal;
layout (location=0) out vec4 out_FragColor;
float edgeFactor(float thickness) {
  vec3 a3 = smoothstep(
    vec3( 0.0 ), fwidth(barycoords) * thickness, barycoords);
  return min( min( a3.x, a3.y ), a3.z );
}
void main() {
  float NdotL = clamp(dot(normalize(normal),
                          normalize(vec3(-1,1,-1))), 0.5, 1.0);
  vec4 color = vec4(1.0, 1.0, 1.0, 1.0) * NdotL;
  out_FragColor = mix( vec4(0.1), color, edgeFactor(1.0) );
};
  1. 最终的 C++渲染代码片段很简单,因为所有繁重的工作已经在VKMesh辅助类内部完成了:
buf.cmdBeginRendering(renderPass, framebuffer);
buf.cmdPushConstants(mvp);
mesh.draw(buf, header);
app.drawGrid(buf, p, vec3(0, -0.0f, 0));
app.imgui_->beginFrame(framebuffer);
app.drawFPS();
app.imgui_->endFrame(buf);
buf.cmdEndRendering();

如果使用 Lumberyard Bistro 网格加载图像,运行中的应用程序将渲染以下图像:

图 5.7:Amazon Lumberyard Bistro 网格几何加载和渲染

图 5.7:Amazon Lumberyard Bistro 网格几何加载和渲染

使用计算着色器在 Vulkan 中生成纹理

我们在本章早期学习了如何使用基本的计算着色器,在 使用计算着色器实现实例网格 菜谱中。现在是时候通过一些示例来了解如何使用它们了。让我们从一些基本的程序纹理生成开始。在这个菜谱中,我们实现了一个小程序来显示动画纹理,其 texel 值是在我们的自定义计算着色器中实时计算的。为了给这个菜谱增加更多的价值,我们将从 www.shadertoy.com 将一个 GLSL 着色器移植到我们的 Vulkan 计算着色器中。

准备工作

计算管线创建代码和 Vulkan 应用程序初始化与 使用计算着色器实现实例网格 菜谱相同。在继续之前,请确保您已经阅读了它。为了使用和显示生成的纹理,我们需要一个纹理的全屏四边形渲染器。其 GLSL 源代码可以在 data/shaders/Quad.vertdata/shaders/Quad.frag 中找到。然而,实际使用的几何形状是一个覆盖整个屏幕的三角形。我们在这里不会关注其内部结构,因为在这个阶段,您应该能够使用前几章中的材料自己渲染全屏四边形。

我们将要在这里使用以生成 Vulkan 纹理的原始着色器,“工业综合体”,是由 Gary “Shane” Warne (rhomboid.com) 创建的,并且可以从 ShaderToy 下载:www.shadertoy.com/view/MtdSWS

如何做...

让我们先讨论编写纹理生成 GLSL 计算着色器的过程。最简单的着色器是生成一个 RGBA 图像,它不使用任何输入数据,通过使用内置变量 gl_GlobalInvocationID 来计算输出哪个像素。这直接映射到 ShaderToy 着色器的工作方式,因此我们只需添加一些针对计算着色器和 Vulkan 特定的输入和输出参数以及布局修饰符,就可以将它们转换为计算着色器。让我们看看一个创建红色-绿色渐变纹理的最小化计算着色器:

  1. 与所有其他计算着色器一样,在开头有一行强制性的代码告诉驱动程序如何在 GPU 上分配工作负载。在我们的情况下,我们正在处理 16x16 像素的瓦片 - 16x16 的本地工作组大小在许多 GPU 上得到支持,我们将用它来确保兼容性:
layout (local_size_x = 16, local_size_y = 16) in;
  1. 我们需要指定的唯一缓冲区绑定是输出图像。这是我们在这本书中第一次使用图像类型 image2D。在这里,这意味着数组 kTextures2DOut 包含一个 2D 图像数组,其元素只是纹理的像素。writeonly 布局限定符指示编译器假设我们不会在着色器中从这个图像中读取。绑定是从 第二章,Vulkan 入门 中的 使用 Vulkan 描述符索引 菜谱的 C++ 代码中更新的:
layout (set = 0, binding = 2, rgba8)
  uniform writeonly image2D kTextures2DOut[];
  1. GLSL 计算着色语言提供了一套辅助函数来检索各种图像属性。我们使用内置的imageSize()函数来确定图像的像素大小,并将图像 ID 从推送常量中加载:
layout(push_constant) uniform uPushConstant {
  uint tex;
  float time;
} pc;
void main() {
  ivec2 dim = imageSize(kTextures2DOut[pc.tex]);
  1. 内置的gl_GlobalInvocationID变量告诉我们正在处理我们的计算网格的哪个全局元素。要将它的值转换为 2D 图像坐标,我们需要将其除以图像的尺寸。由于我们处理的是 2D 纹理,只有xy分量是重要的。从 C++侧调用的代码执行vkCmdDispatch()函数,并将输出图像大小作为本地工作组的 X 和 Y 数字传递:
 vec2 uv = vec2(gl_GlobalInvocationID.xy) / dim;
  1. 在这个着色器中我们实际要做的工作是调用imageStore() GLSL 函数:
 imageStore(kTextures2DOut[pc.tex],
    ivec2(gl_GlobalInvocationID.xy), vec4(uv, 0.0, 1.0));
}

现在,这个例子相当有限,你只能得到一个红绿渐变图像。让我们稍作修改,使用 ShaderToy 的实际着色器代码。渲染 ShaderToy 中“Industrial Complex”着色器的 Vulkan 版本的计算着色器,可通过以下 URL 获取shadertoy.com/view/MtdSWS,可以在Chapter05/08_ComputeTexture/src/main.comp文件中找到。

  1. 首先,让我们将整个原始 ShaderToy GLSL 代码复制到我们的新计算着色器中。其中有一个名为mainImage()的函数,其声明如下:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
  1. 我们应该用一个返回vec4颜色的函数来替换它,而不是将其存储在输出参数中:
vec4 mainImage(in vec2 fragCoord)

不要忘记在最后添加一个适当的return语句。

  1. 现在,让我们将我们的计算着色器的main()函数更改为正确调用mainImage()并执行 5x5 累积抗锯齿。这是一个相当巧妙的技巧:
void main() {
  ivec2 dim = imageSize(kTextures2DOut[pc.tex]);
  vec4 c = vec4(0.0);
  for (int dx = -2; dx != 3; dx++)
    for (int dy = -2; dy != 3; dy++) {
      vec2 uv = vec2(gl_GlobalInvocationID.xy) / dim +
                vec2(dx, dy) / (3.0 * dim);
      c += mainImage(uv * dim);
    }
  imageStore(kTextures2DOut[pc.tex],
    ivec2(gl_GlobalInvocationID.xy), c / 25.0);
}
  1. 在我们可以运行此代码之前,还有一个问题需要解决。ShaderToy 代码使用了两个自定义输入变量,iTime用于已过时间,iResolution包含结果的图像大小。为了防止在原始 GLSL 代码中进行任何搜索和替换,我们模拟了这些变量,一个作为推送常量,另一个使用硬编码值以简化处理。
layout(push_constant) uniform uPushConstant {
  uint tex;
  float time;
} pc;
vec2 iResolution = vec2( 1280.0, 720.0 );
float iTime = pc.time;

注意

GLSL 的imageSize()函数可以根据我们纹理的实际大小获取iResolution值。我们将其留给读者作为练习。

C++代码相当简短,包括创建一个纹理,调用之前提到的计算着色器,插入一个 Vulkan 管道屏障,并渲染一个纹理的全屏四边形:

  1. 使用TextureUsageBits_Storage使用标志创建一个纹理,使其对计算着色器可访问:
lvk::Holder<lvk::TextureHandle> texture = ctx->createTexture({
  .type       = lvk::TextureType_2D,
  .format     = lvk::Format_RGBA_UN8,
  .dimensions = {1024, 720},
  .usage      = lvk::TextureUsageBits_Sampled |
                lvk::TextureUsageBits_Storage,
  .debugName  = “Texture: compute”,
});
  1. 计算和渲染管道的创建方式如下:
lvk::Holder<lvk::ShaderModuleHandle> comp = loadShaderModule(
  ctx, “Chapter05/08_ComputeTexture/src/main.comp”);
lvk::Holder<lvk::ComputePipelineHandle> pipelineComputeMatrices =
  ctx->createComputePipeline({ smComp = comp });
lvk::Holder<lvk::ShaderModuleHandle> vert = loadShaderModule(
  ctx, “data/shaders/Quad.vert”);
lvk::Holder<lvk::ShaderModuleHandle> frag = loadShaderModule(
  ctx, “data/shaders/Quad.frag”);
lvk::Holder<lvk::RenderPipelineHandle> pipelineFullScreenQuad =
  ctx->createRenderPipeline({
    .smVert = vert,
    .smFrag = frag,
    .color  = { { .format = ctx->getSwapchainFormat() } },
});
  1. 在进行渲染时,我们通过推送常量将纹理 ID 提供给两个着色器,并将当前时间提供给计算着色器:
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
const struct {
  uint32_t textureId;
  float time;
} pc {
  .textureId = texture.index(),
  .time      = (float)glfwGetTime(),
};
buf.cmdPushConstants(pc);
buf.cmdBindComputePipeline(pipelineComputeMatrices);
buf.cmdDispatchThreadGroups(
  { .width = 1024 / 16, .height = 720 / 16 });
  1. 当我们指定所需的纹理依赖项时,LightweightVK 会创建一个管道屏障,确保计算着色器在纹理采样之前完成。查看lvk::CommandBuffer::transitionToShaderReadOnly()以获取低级图像屏障代码。由于我们的全屏着色器使用覆盖整个屏幕的三角形,所以绘制该三角形的 3 个顶点。
buf.cmdBeginRendering(renderPass, framebuffer,
  { .textures = { { lvk::TextureHandle(texture) } } });
buf.cmdBindRenderPipeline(pipelineFullScreenQuad);
buf.cmdDraw(3);
buf.cmdEndRendering();
ctx->submit(buf, ctx->getCurrentSwapchainTexture());

运行的应用程序应渲染以下图像,该图像类似于www.shadertoy.com/view/MtdSWS的输出:

图 5.8:使用计算着色器生成纹理

图 5.8:使用计算着色器生成纹理

在下一个配方中,我们将继续学习 Vulkan 计算管线并实现一个网格生成计算着色器。

实现计算网格

在“使用计算着色器在 Vulkan 中生成纹理”的配方中,我们学习了如何从计算着色器中将像素数据写入纹理。在下一章中,我们需要这些数据来实现一个用于基于物理渲染管道的 BRDF 预计算工具。但在那之前,让我们学习一些简单而有趣的方法来在 Vulkan 中使用计算着色器,并将这一特性与 GPU 上的网格几何生成相结合。

我们将运行一个计算着色器来创建具有不同PQ参数的 3D 环面结形状的三角化几何。

注意

环面结是一种特殊的结,它位于三维空间中未打结的环面表面上。每个环面结由一对互质的整数 p 和 q 指定。要了解更多信息,请查看维基百科页面:en.wikipedia.org/wiki/Torus_knot

计算着色器生成顶点数据,包括位置、纹理坐标和法向量。这些数据存储在缓冲区中,稍后作为顶点缓冲区用于图形管线中的网格渲染。为了使结果更具视觉吸引力,我们将实现两个不同环面结之间的实时变形,该变形可以通过 ImGui 小部件进行控制。让我们开始吧。

准备工作

本例的源代码位于Chapter05/09_ComputeMesh

如何实现...

应用程序由多个部分组成:C++部分(驱动 UI 和 Vulkan 命令)、网格生成计算着色器、纹理生成计算着色器以及一个具有简单顶点和片段着色器的渲染管线。Chapter05/09_ComputeMesh/src/main.cpp中的 C++部分相当简短,所以让我们先处理它:

  1. 我们存储一个 P-Q 对的队列,它定义了形态的顺序。队列始终至少有两个元素,它们定义了当前和下一个环面结。我们还存储一个浮点值 g_MorphCoef,它是队列中相邻两个 P-Q 对之间的形态因子 0...1。网格在每一帧中都会重新生成,形态系数会增加,直到达到 1.0。在此点,我们将停止形态或,如果队列中有超过两个元素,从队列中移除顶部元素,将 g_MorphCoef 重置为 zero 并重复。g_AnimationSpeed 值定义了一个环面结网格如何快速地形态为另一个。g_UseColoredMesh 布尔标志用于在网格的彩色和纹理着色之间切换:
std::deque<std::pair<uint32_t, uint32_t>> morphQueue =
  { { 5, 8 }, { 5, 8 } };
float morphCoef = 0.0f;
float animationSpeed = 1.0f;
bool g_UseColoredMesh = false;
  1. 两个全局常量定义了环面结的细分级别。您可以随意调整它们:
constexpr uint32_t kNumU = 1024;
constexpr uint32_t kNumV = 1024;
  1. 无论 PQ 参数值如何,我们都有一个顺序,应该遍历顶点以生成环面结三角形。generateIndices() 函数为此目的准备索引缓冲区数据。在这里,6 是每个由 2 个三角形组成的矩形网格元素生成的索引数:
void generateIndices(uint32_t* indices) {
  for (uint32_t j = 0; j < kNumV - 1; j++) {
    for (uint32_t i = 0; i < kNumU - 1; i++) {
      uint32_t ofs = (j * (kNumU - 1) + i) * 6;
      uint32_t i1 = (j + 0) * kNumU + (i + 0);
      uint32_t i2 = (j + 0) * kNumU + (i + 1);
      uint32_t i3 = (j + 1) * kNumU + (i + 1);
      uint32_t i4 = (j + 1) * kNumU + (i + 0);
      indices[ofs + 0] = i1;
      indices[ofs + 1] = i2;
      indices[ofs + 2] = i4;
      indices[ofs + 3] = i2;
      indices[ofs + 4] = i3;
      indices[ofs + 5] = i4;
    }
  }
}

此外,我们的 C++ 代码运行一个 ImGui UI,用于选择环面结的配置和管理各种参数。这有助于了解代码的流程,因此让我们更仔细地检查它:

  1. 每个环面结由一对互质的整数 PQ 定义。在这里,我们预先选择了一些产生视觉上有趣结果的配对。
void renderGUI(lvk::TextureHandle texture) {
  static const std::vector<std::pair<uint32_t, uint32_t>> PQ = {
    {1, 1}, {2, 3}, {2, 5}, {2, 7}, {3, 4},
    {2, 9}, {3, 5}, {5, 8}, {8, 9} };
  ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Appearing);
  ImGui::Begin(“Torus Knot params”, nullptr,
    ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse);
  1. 我们可以控制形态动画的速度。这就是一个网格如何快速地形态为另一个。我们还可以在网格的彩色和纹理着色之间切换。
 ImGui::Checkbox(“Use colored mesh”, &g_UseColoredMesh);
  ImGui::SliderFloat(
    “Morph animation speed”, &g_AnimationSpeed, 0.0f, 2.0f);
  1. 当我们点击一个具有不同 P-Q 参数集的按钮时,我们不会立即重新生成网格。相反,我们通过向队列添加一个新对来让任何正在进行的动画完成。在主循环中,当前形态动画结束后,我们从队列中移除前面的元素并再次启动动画。
 for (size_t i = 0; i != PQ.size(); i++) {
    const std::string title = std::to_string(PQ[i].first) + “, “ +
      std::to_string(PQ[i].second);
    if (ImGui::Button(title.c_str(), ImVec2(128, 0))) {
      if (PQ[i] != g_MorphQueue.back())
        g_MorphQueue.push_back(PQ[i]);
    }
  }
  1. 这里打印了形态队列的内容。当前的 P-Q 对用 “<---” 标记:
 ImGui::Text(“Morph queue:”);
  for (size_t i = 0; i != g_MorphQueue.size(); i++) {
    const bool isLastElement = (i + 1) == g_MorphQueue.size();
    ImGui::Text(“  P = %u, Q = %u %s”, g_MorphQueue[i].first,
      g_MorphQueue[i].second, isLastElement ? “<---” : ““);
  }
  ImGui::End();
  1. 如果我们为网格着色应用动画纹理,让我们也使用 ImGui::Image() 展示它:
 if (!g_UseColoredMesh) {
    const ImVec2 size = ImGui::GetIO().DisplaySize;
    const float  dim  = std::max(size.x, size.y);
    const ImVec2 sizeImg(0.25f * dim, 0.25f * dim);
    ImGui::SetNextWindowPos(ImVec2(size.x - sizeImg.x - 25, 0),
      ImGuiCond_Appearing);
    ImGui::Begin(“Texture”, nullptr,
      ImGuiWindowFlags_AlwaysAutoResize);
    ImGui::Image(texture.indexAsVoid(), sizeImg);
    ImGui::End();
  }
};

现在,让我们检查负责创建缓冲区并将初始数据填充到其中的 C++ 代码:

  1. 索引是不可变的,并使用之前提到的 generateIndices() 函数生成。顶点缓冲区大小基于每个顶点的 12float 元素计算。这是 vec4 位置、vec4 纹理坐标和 vec4 法向量。这种填充用于简化写入顶点缓冲区的计算着色器。
std::vector<uint32_t> indicesGen((kNumU - 1) * (kNumV - 1) * 6);
generateIndices(indicesGen.data());
const uint32_t vertexBufferSize = 12 * sizeof(float) * kNumU * kNumV;
const uint32_t indexBufferSize  =
  sizeof(uint32_t) * (kNumU - 1) * (kNumV - 1) * 6;
lvk::Holder<lvk::BufferHandle> bufferIndex  = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Index,
  .storage   = lvk::StorageType_Device,
  .size      = indicesGen.size() * sizeof(uint32_t),
  .data      = indicesGen.data(),
  .debugName = “Buffer: index” });
  1. 让我们创建一个将由计算着色器生成的纹理。这与之前的配方类似,使用计算着色器在 Vulkan 中生成纹理
lvk::Holder<lvk::TextureHandle> texture = ctx->createTexture({
  .type       = lvk::TextureType_2D,
  .format     = lvk::Format_RGBA_UN8,
  .dimensions = {1024, 1024},
  .usage      = lvk::TextureUsageBits_Sampled |
                lvk::TextureUsageBits_Storage,
  .debugName  = “Texture: compute” });
  1. 顶点缓冲区应该使用 BufferUsageBits_Storage 标志创建,以允许顶点着色器使用:
lvk::Holder<lvk::BufferHandle> bufferVertex = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Vertex |
               lvk::BufferUsageBits_Storage,
  .storage   = lvk::StorageType_Device,
  .size      = vertexBufferSize,
  .debugName = “Buffer: vertex” });
  1. 我们创建了两个计算管线:一个用于网格生成,另一个用于纹理生成。
lvk::Holder<lvk::ShaderModuleHandle> compMesh = loadShaderModule(ctx,
  “Chapter05/09_ComputeMesh/src/main_mesh.comp”);
lvk::Holder<lvk::ShaderModuleHandle> compTexture =
  loadShaderModule(ctx,
  “Chapter05/09_ComputeMesh/src/main_texture.comp”);
lvk::Holder<lvk::ComputePipelineHandle> pipelineComputeMesh =
  ctx->createComputePipeline({ .smComp = compMesh });
lvk::Holder<lvk::ComputePipelineHandle> pipelineComputeTexture =
  ctx->createComputePipeline({ .smComp = compTexture });
  1. 着色器模块按常规加载。几何着色器为线框轮廓生成重心坐标。如果你将 kNumUkNumV 设置为 64 或更低的值,将启用线框轮廓渲染。
lvk::Holder<lvk::ShaderModuleHandle> vert =
  loadShaderModule(ctx, “Chapter05/09_ComputeMesh/src/main.vert”);
lvk::Holder<lvk::ShaderModuleHandle> geom =
  loadShaderModule(ctx, “Chapter05/09_ComputeMesh/src/main.geom”);
lvk::Holder<lvk::ShaderModuleHandle> frag =
  loadShaderModule(ctx, “Chapter05/09_ComputeMesh/src/main.frag”);
  1. 对于顶点输入,我们使用 vec4 来简化填充问题——或者,更准确地说,是为了完全消除它们:
const lvk::VertexInput vdesc = {
  .attributes  = { { .location = 0,
                     .format = VertexFormat::Float4,
                     .offset = 0 },
                   { .location = 1,
                     .format = VertexFormat::Float4,
                     .offset = sizeof(vec4) },
                   { .location = 2,
                     .format = VertexFormat::Float4,
                     .offset = sizeof(vec4)+sizeof(vec4) } },
  .inputBindings = { { .stride = 3 * sizeof(vec4) },
};
  1. 相同的着色器代码针对纹理和彩色着色使用专用常数进行专门化:
const uint32_t specColored = 1;
const uint32_t specNotColored = 0;
lvk::Holder<lvk::RenderPipelineHandle> pipelineMeshColored =
  ctx->createRenderPipeline({
    .vertexInput = vdesc,
    .smVert      = vert,
    .smGeom      = geom,
    .smFrag      = frag,
    .specInfo    = { .entries = { { .constantId = 0,
                                    .size = sizeof(uint32_t) } },
                     .data = &specColored,
                     .dataSize = sizeof(specColored) },
    .color       = { { .format = ctx->getSwapchainFormat() } },
    .depthFormat = app.getDepthFormat() });
lvk::Holder<lvk::RenderPipelineHandle> pipelineMeshTextured =
  ctx->createRenderPipeline({
    .vertexInput = vdesc,
    .smVert      = vert,
    .smGeom      = geom,
    .smFrag      = frag,
    .specInfo    = { .entries = { { .constantId = 0,
                                    .size = sizeof(uint32_t) } },
                     .data = &specNotColored,
                     .dataSize = sizeof(specNotColored) },
    .color       = { { .format = ctx->getSwapchainFormat() } },
    .depthFormat = app.getDepthFormat() });

C++ 代码的最后一部分是渲染循环。让我们探索如何填充命令缓冲区:

  1. 首先,我们需要从形变队列中访问当前的 P-Q 对:
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
const mat4 m = glm::translate(mat4(1.0f), vec3(0.0f, 0.0f, -18.f));
const mat4 p = glm::perspective(45.0f, aspectRatio, 0.1f, 1000.0f);
auto iter = g_MorphQueue.begin();
  1. 着色器的参数数量比通常情况下要多,精确到 128 字节。为了方便起见并简化代码,推送常数在所有着色器、计算和图形之间共享。
struct PerFrame {
  mat4 mvp;
  uint64_t buffer;
  uint32_t textureId;
  float time;
  uint32_t numU, numV;
  float minU, maxU;
  float minV, maxV;
  uint32_t p1, p2;
  uint32_t q1, q2;
  float morph;
} pc = {
  .mvp       = p * m,
  .buffer    = ctx->gpuAddress(bufferVertex),
  .textureId = texture.index(),
  .time      = (float)glfwGetTime(),
  .numU      = kNumU,
  .numV      = kNumU,
  .minU      = -1.0f,
  .maxU      = +1.0f,
  .minV      = -1.0f,
  .maxV      = +1.0f,
  .p1        = iter->first,
  .p2        = (iter + 1)->first,
  .q1        = iter->second,
  .q2        = (iter + 1)->second,
  .morph     = easing(g_MorphCoef),
};
buf.cmdPushConstants(pc);
  1. 当我们调度网格生成计算着色器时,我们需要指定适当的内存屏障以确保前一帧已经完成渲染:
buf.cmdBindComputePipeline(pipelineComputeMesh);
buf.cmdDispatchThreadGroups(
  { .width = (kNumU * kNumV) / 2 },
  { .buffers = { { lvk::BufferHandle(bufferVertex) } } });
  1. 对于纹理生成着色器也做同样的处理。我们必须确保通过发出适当的管道阶段和掩码的 Vulkan 图像布局转换,纹理的重新生成与渲染正确同步。这是通过 LightweightVKlvk::CommandBuffer::cmdDispatchThreadGroups() 中使用一些经验规则完成的。
if (!g_UseColoredMesh) {
  buf.cmdBindComputePipeline(pipelineComputeTexture);
  buf.cmdDispatchThreadGroups(
    { .width = 1024 / 16, .height = 1024 / 16 },
    { .textures = { { lvk::TextureHandle(texture) } } });
}
  1. 当我们开始渲染时,指定两个依赖项——纹理和顶点缓冲区是至关重要的。渲染管道的选择取决于我们是否旨在渲染彩色或纹理网格。
buf.cmdBeginRendering(
  renderPass, framebuffer,
  { .textures = { { lvk::TextureHandle(texture) } },
    .buffers  = { { lvk::BufferHandle(bufferVertex) } } });
buf.cmdBindRenderPipeline(
  g_UseColoredMesh ? pipelineMeshColored : pipelineMeshTextured);
buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less, .isDepthWriteEnabled = true });
buf.cmdBindVertexBuffer(0, bufferVertex);
buf.cmdBindIndexBuffer(bufferIndex, lvk::IndexFormat_UI32);
buf.cmdDrawIndexed(indicesGen.size());
app.imgui_->beginFrame(framebuffer);
  1. renderGUI() 函数渲染 ImGui UI,如本食谱中之前所述。在这种情况下,我们将其生成的纹理传递给它进行展示。
renderGUI(texture);
app.drawFPS();
app.imgui_->endFrame(buf);
buf.cmdEndRendering();

这就涵盖了 C++ 部分。现在,让我们深入了解 GLSL 着色器,以了解整个演示是如何工作的。

它是如何工作的...

让我们从计算着色器开始,Chapter05/09_ComputeMesh/src/main_mesh.comp,它负责生成顶点数据:

  1. 推送常数在 Chapter05/09_ComputeMesh/src/common.sp 中声明。它们在所有着色器之间共享,我们在这里粘贴它们以方便使用:
layout (local_size_x = 2, local_size_y = 1, local_size_z = 1) in;
// included from <Chapter05/09_ComputeMesh/src/common.sp>
layout(push_constant) uniform PerFrameData {
  mat4 MVP;
  uvec2 bufferId;
  uint textureId;
  float time;
  uint numU, numV;
  float minU, maxU, minV, maxV;
  uint P1, P2, Q1, Q2;
  float morph;
} pc;
  1. 这是包含我们旨在生成并写入由 VertexBuffer 引用的缓冲区中的顶点数据的结构,该缓冲区存储在 pc.bufferId 几行之上。
struct VertexData {
  vec4 pos;
  vec4 tc;
  vec4 norm;
};
layout (buffer_reference) buffer VertexBuffer {
  VertexData vertices[];
} vbo;
  1. 我们网格生成算法的核心是 torusKnot() 函数,它使用以下参数化来对环面结进行三角剖分 en.wikipedia.org/wiki/Torus_knot
x = r * cos(u)
y = r * sin(u)
z = -sin(v)
  1. torusKnot() 函数相当长,并且直接从之前提到的参数化实现。您可以随意调整 baseRadiussegmentRadiustubeRadius 的值:
VertexData torusKnot(vec2 uv, float p, float q) {
  const float baseRadius    = 5.0;
  const float segmentRadius = 3.0;
  const float tubeRadius    = 0.5;
  float ct = cos( uv.x );
  float st = sin( uv.x );
  float qp = q / p;
  float qps = qp * segmentRadius;
  float arg = uv.x * qp;
  float sqp = sin( arg );
  float cqp = cos( arg );
  float BSQP = baseRadius + segmentRadius * cqp;
  float dxdt = -qps * sqp * ct - st * BSQP;
  float dydt = -qps * sqp * st + ct * BSQP;
  float dzdt =  qps * cqp;
  vec3 r    = vec3(BSQP * ct, BSQP * st, segmentRadius * sqp);
  vec3 drdt = vec3(dxdt, dydt, dzdt);
  vec3 v1 = normalize(cross(r, drdt));
  vec3 v2 = normalize(cross(v1, drdt));
  float cv = cos( uv.y );
  float sv = sin( uv.y );
  VertexData res;
  res.pos  = vec4(r + tubeRadius * ( v1 * sv + v2 * cv ), 1);
  res.norm = vec4(cross(v1 * cv - v2 * sv, drdt ), 0);
  return res;
}
  1. 我们在每一帧中运行这个计算着色器,因此,而不是生成静态的顶点集,我们实际上可以预先变换它们,使网格看起来像是在旋转。这里有一些辅助函数来计算适当的旋转矩阵:
mat3 rotY(float angle) {
  float c = cos(angle), s = sin(angle);
  return mat3(c, 0, -s, 0, 1, 0, s, 0, c);
}
mat3 rotZ(float angle) {
  float c = cos(angle), s = sin(angle);
  return mat3(c, -s, 0, s, c, 0, 0, 0, 1);
}
  1. 使用前面提到的辅助函数,我们的计算着色器的main()函数现在很简单,唯一值得注意的有趣之处是实时变形,它将具有不同PQ参数的两个环面结混合在一起。这很简单,因为顶点的总数始终保持不变:
void main() {
  uint index = gl_GlobalInvocationID.x;
  vec2 numUV = vec2(pc.numU, pc.numV);
  vec2 ij = vec2(float(index / pc.numV), float(index % pc.numV));
  1. 需要计算两组 UV 坐标以进行参数化:
 const vec2 maxUV1 = 2.0 * 3.141592653 * vec2(pc.P1, 1.0);
  vec2 uv1 = ij * maxUV1 / (numUV - vec2(1));
  const vec2 maxUV2 = 2.0 * 3.141592653 * vec2(pc.P2, 1.0);
  vec2 uv2 = ij * maxUV2 / (numUV - vec2(1));
  1. 通过组合两个旋转矩阵来计算我们的网格的模型矩阵:
 mat3 modelMatrix = rotY(0.5 * pc.time) * rotZ(0.5 * pc.time);
  1. 计算由两组P-Q参数定义的两个不同环面结的两个顶点位置:P1-Q1P2-Q2
 VertexData v1 = torusKnot(uv1, pc.P1, pc.Q1);
  VertexData v2 = torusKnot(uv2, pc.P2, pc.Q2);
  1. 使用pc.morph系数在这两者之间执行线性混合。我们只需要混合位置和法线向量。虽然法线向量可以更优雅地插值,但我们将其留作读者另一项练习:
 vec3 pos = mix(v1.pos.xyz, v2.pos.xyz, pc.morph);
  vec3 norm = mix(v1.norm.xyz, v2.norm.xyz, pc.morph);
  1. 填充结果VertexData结构并将其存储在输出顶点缓冲区中:
 VertexData vtx;
  vtx.pos  = vec4(modelMatrix * pos, 1);
  vtx.tc   = vec4(ij / numUV, 0, 0);
  vtx.norm = vec4(modelMatrix * norm, 0);
  VertexBuffer(pc.bufferId).vertices[index] = vtx;
}

顶点着色器如下所示。请注意,这里的顶点属性有适当的类型:

layout(push_constant) uniform PerFrameData {
  mat4 MVP;
};
layout (location=0) in vec4 in_pos;
layout (location=1) in vec2 in_uv;
layout (location=2) in vec3 in_normal;
layout (location=0) out vec2 uv;
layout (location=1) out vec3 normal;
void main() {
  gl_Position = MVP * in_pos;
  uv = in_uv;
  normal = in_normal;
}

几何着色器很简单,因为它所做的唯一事情是生成如将细分整合到图形管线中中所述的重心坐标。所以,让我们直接跳到片段着色器,Chapter05/09_ComputeMesh/src/main.frag

  1. 专用常量用于在着色器的彩色和纹理版本之间切换:
#include <Chapter05/09_ComputeMesh/src/common.sp>
layout (location=0) in vec2 uv;
layout (location=1) in vec3 normal;
layout (location=2) in vec3 barycoords;
layout (location=0) out vec4 out_FragColor;
layout (constant_id = 0) const bool isColored = false;
  1. 将细分整合到图形管线中配方中所述的线框轮廓的边缘因子计算:
float edgeFactor(float thickness) {
  vec3 a3 = smoothstep( vec3( 0.0 ),
    fwidth(barycoords) * thickness, barycoords);
  return min( min( a3.x, a3.y ), a3.z );
}
  1. 一个函数,根据浮点“色调”值计算我们的网格的 RGB 颜色:
vec3 hue2rgb(float hue) {
  float h = fract(hue);
  float r = abs(h * 6 - 3) - 1;
  float g = 2 - abs(h * 6 - 2);
  float b = 2 - abs(h * 6 - 4);
  return clamp(vec3(r,g,b), vec3(0), vec3(1));
}
void main() {
  float NdotL = dot(normalize(normal), normalize(vec3(0, 0, +1)));
  float intensity = 1.0 * clamp(NdotL, 0.75, 1);
  vec3 color = isColored ?
    intensity * hue2rgb(uv.x) :
    textureBindless2D(pc.textureId, 0, vec2(8,1) * uv).xyz;
  out_FragColor = vec4(color, 1.0);
  1. 对于numUnumV的高值,细分级别非常密集,以至于没有明显的线框边缘可见——一切都会塌缩成一个黑色的摩尔纹混乱。我们禁用大于64的值上的线框叠加:
 if (isColored && pc.numU <= 64 && pc.numV <= 64)
    out_FragColor =
      vec4( mix( vec3(0.0), color, edgeFactor(1.0) ), 1.0 );
}

最后但同样重要的是,有一个计算着色器负责生成动画纹理。您可以在Chapter05/09_ComputeMesh/src/main_texture.comp中找到它,其思路与之前配方中描述的方法相同,使用计算着色器在 Vulkan 中生成纹理。我们在此处不复制和粘贴该着色器。

演示应用程序将生成与以下截图类似的各种环面结。每次您从 UI 中选择新的P-Q参数对时,形态动画就会启动,将一个结转换成另一个。勾选使用彩色网格框将应用颜色到网格而不是计算的纹理:

图 5.9:带有实时动画的计算网格

图 5.9:带有实时动画的计算网格

还有更多…

请参考维基百科页面en.wikipedia.org/wiki/Torus_knot以获取关于数学细节的额外解释。尝试将kNumUkNumV的值设置为32,同时勾选“使用彩色网格”。

这是一个涉及两个独立计算着色器之间显式同步过程以及渲染过程的初始配方。LightweightVK实现旨在通过显式依赖指定使这种同步尽可能无缝。在更复杂的现实世界 3D 应用中,更精细的同步机制将是所希望的。请参考 Khronos 的 Vulkan 同步指南,以获取关于如何有效处理它的宝贵见解:github.com/KhronosGroup/Vulkan-Docs/wiki/Synchronization-Examples

第七章:6 使用 glTF 2.0 着色模型进行基于物理的渲染

加入我们的 Discord 书籍社区

packt.link/unitydev

本章将介绍将基于物理的渲染PBR)集成到您的图形应用程序中。我们以 glTF 2.0 着色模型为例。PBR 不是一个特定的单一技术,而是一系列概念,例如使用测量的表面值和逼真的着色模型,以准确表示现实世界的材料。将 PBR 添加到您的图形应用程序或对现有渲染引擎进行 PBR 改造可能具有挑战性,因为它需要在正确渲染图像之前同时完成多个大型任务。

  1. 我们的目标是展示如何从头开始实现所有这些步骤。其中一些步骤,如预计算辐照度图或双向反射分布函数BRDF)查找表,需要编写额外的工具。我们不会使用任何第三方工具,并将展示如何从头开始实现整个 PBR 管道骨架,包括创建基本的工具来工作。一些预计算可以使用通用图形处理单元GPGPU)技术和计算着色器来完成,所有这些内容都将在此涵盖。

在本章中,我们将学习以下食谱:

  • glTF 2.0 物理基础着色模型的简介

  • 渲染未着光的 glTF 2.0 材料实现

  • 预计算 BRDF 查找表

  • 预计算辐照度图和漫反射卷积

  • 实现 glTF 2.0 核心金属-粗糙度着色模型

  • 实现 glTF 2.0 核心光泽度着色模型

在所有未来对 glTF 的引用中,我们指的是 glTF 2.0 规范。由于 glTF 1.0 已过时并已弃用,我们在此书中不涉及它。

glTF 2.0 物理基础着色模型的简介

在本节中,我们将学习 PBR 材料的基础知识,并为实际实现一些批准的 glTF 2.0 PBR 扩展提供足够的背景信息。实际的代码将在后续的食谱和章节中展示。由于 PBR 渲染的主题非常广泛,我们将关注一个简约的实现,仅为了指导您并帮助您开始。在本节中,我们将关注 glTF 2.0 PBR 着色模型的 GLSL 着色器代码。简而言之,渲染基于物理的图像不过是运行一个花哨的像素着色器,并使用一系列纹理。

准备工作

我们假设您已经对线性代数和微积分有了一些基本了解。建议您熟悉 glTF 2.0 规范,该规范可在 registry.khronos.org/glTF/specs/2.0/glTF-2.0.xhtml 找到。

什么是 PBR?

基于物理的渲染PBR)是一套旨在模拟光与真实世界材料相互作用的技术。通过使用光散射和反射的逼真模型,PBR 材质可以创造出比传统方法更逼真、更沉浸式的视觉效果。

glTF PBR 材质模型是 glTF 2.0 格式中表示基于物理材质的一种标准化方法。此模型允许您在多种平台和应用中创建高度逼真的 3D 内容,使其成为现代 3D 开发的重要工具。

光与物体相互作用

让我们退后一步,看看光线作为一个物理现象是什么——它是一束光线沿其传播的几何线,或者是一束光。它有一个起点和传播方向。光与表面的重要相互作用有两种:反射扩散(分别称为“镜面”和“漫反射”)。虽然我们通过日常经验直观地理解这些概念,但它们的物理特性可能不太熟悉。

当光线遇到表面时,其中一部分会以与表面法线相反的方向弹回,就像球在墙上以角度反弹一样。这种在光滑表面上发生的反射,产生了一种称为镜面反射的效果(源自拉丁语中的“speculum”,意为“镜子”)。

然而,并非所有光线都会反射。有些光线穿透表面,在那里它可以被吸收、转化为热量或向各个方向散射。从表面再次散出的散射光被称为漫射光光子扩散次表面散射。这些术语都指同一物理现象——光子运动。然而,扩散和散射在如何分散光子方面是不同的。散射涉及光子被重新导向到各个方向,而扩散涉及光子均匀地扩散开来。

材料吸收和散射漫射光的方式因光的不同波长而异,赋予物体独特的颜色。例如,吸收大多数颜色但散射蓝光的物体将呈现蓝色。这种散射通常是如此混乱,以至于从所有方向看起来都一样,与镜面反射不同。

图 6.1:漫反射和镜面反射

图 6.1:漫反射和镜面反射

在计算机图形学中模拟这种行为通常只需要一个输入,即漫反射率,它表示由各种光波长的混合比例散射回表面的颜色。术语漫反射颜色通常与漫反射率互换使用。

当材料具有更宽的散射角度,如人类皮肤或牛奶时,模拟它们的照明需要比简单的表面光相互作用更复杂的方法。这是因为这些材料中的光散射不仅限于表面,还发生在材料本身内部。

对于薄物体,光甚至可以散射到它们的背面,使它们变得半透明。随着散射进一步减少,就像在玻璃中一样,材料变得透明,允许整个图像通过它,保持其可见形状。

这些独特的光散射行为与典型的“接近表面”扩散有很大不同,需要特殊处理才能准确渲染。

能量守恒

PBR 的基本原则围绕着能量守恒定律。这条定律断言,在一个孤立系统中,总能量保持不变。在渲染的背景下,它表示场景中任何给定位置的入射光量等于在该位置反射、透射和吸收的光量之和。

在 PBS 中强制执行能量守恒至关重要。它允许资产在不无意中违反物理定律的情况下调整材料的反射率和反照率值,这通常会导致看起来不自然的结果。在代码中实施这些约束可以防止资产偏离现实太远或在不同的光照条件下变得不一致。

在着色系统中实现这一原则很简单。我们只需在计算漫反射之前减去反射光。这意味着高反射物体将表现出最小到没有漫反射光,因为大部分光被反射而不是穿透表面。相反,具有强烈扩散的材料不能特别具有反射性。

表面特性

在任何给定环境中,你可以轻松观察到各种具有独特光相互作用的复杂表面。这些独特的表面特性由称为双向散射分布函数BSDFs)的通用数学函数表示。

将 BSDF 视为一个方程,描述光遇到表面时的散射情况。它考虑了表面的物理特性,并预测了入射光从某一方向散射到其他方向的概率。

虽然 BSDF 这个术语可能听起来很复杂,但让我们将其分解:

  • 双向性:这指的是光与表面相互作用的两种性质。入射光从一个方向到达表面,然后向各个方向散射。

  • 散射:这描述了入射光如何被重新导向到多个出射方向。这可能涉及反射、透射或两者的组合。

  • 分布函数:这定义了基于表面特性的光线在特定方向上散射的概率。分布可以从完全均匀散射到单一方向上的集中反射。

在实践中,BSDF 通常分为两部分,分别处理:

  • 双向反射分布函数BRDFs):这些函数专门描述入射光线如何从表面反射。它们解释了为什么看似白色的光源照亮香蕉会使它看起来是黄色的。BRDF 揭示了香蕉主要反射光谱中的黄色部分的光线,同时吸收或传输其他波长。

  • 双向透射分布函数BTDFs):这些函数专门描述光线如何通过材料。这在玻璃和塑料等材料中很明显,我们可以看到入射光线如何穿过材料。

此外,还存在其他类型的 BSDF,用于解释更复杂的光相互作用现象,例如次表面散射。这发生在光线进入材料并在重新以新的方向出现之前在显著远离入射光线入射点的位置反弹。

图 6.2:BRDF 和 BTDF

图 6.2:BRDF 和 BTDF

反射类型

有四种主要的表面类型,由它们的 BRDF 定义,这些 BRDF 定义了光线在不同方向上散射的可能性:

  • 漫反射表面在所有方向上均匀地散射光线,例如哑光油漆的均匀颜色。

  • 光滑镜面表面优先在特定的反射方向上散射光线,表现出模糊的反射,例如塑料上的镜面高光。

  • 完美镜面表面精确地在单一输出方向上散射光线,相对于表面法线反射入射光线——类似于在完美镜子中看到的无瑕疵反射。

  • 反光表面主要在入射方向上散射光线,返回到光源,类似于在天鹅绒或路标上观察到的镜面高光。

然而,现实世界的表面严格遵循这些模型之一的可能性很小。因此,大多数材料都可以被建模为这些表面类型的复杂组合。

此外,每种反射类型——漫反射、光滑镜面、完美镜面和反光——都可以表现出各向同性或各向异性分布:

  • 各向同性反射在一点上保持一致的反射光量,不受物体旋转角度的影响。这一特性与日常生活中遇到的大多数表面的行为相一致。

  • 各向异性反射根据物体相对于光源的朝向而变化反射光量。这是由于小表面不规则性的排列主要在一个方向上,导致反射延长并模糊。这种行为在刷金属和天鹅绒等材料中尤为明显。

传输

反射分布类型也可以用于传输,除了全向反射。相反,当光线穿过材料时,其路径会受到材料特性的影响。为了说明这与反射的不同,考虑一束光线穿过材料,如完美的镜面透射。在完美的镜面透射中,介质的折射率决定了光传播的方向。这种行为遵循斯涅尔定律,该定律使用方程 n1θ1 = n2θ2 来描述。

图 6.3:折射率

图 6.3:折射率

在这里,n代表第一和第二介质的折射率,而θ表示入射光相对于表面法线的角度。因此,当两种介质具有相同的折射率时,光线将沿完美直线传播。相反,如果折射率不同,光线在进入下一介质时会改变方向。一个显著的例子是当光线从空气进入水中时方向改变,导致水下观察的扭曲。这与完美的镜面反射形成对比,其中入射角始终等于出射角。

菲涅耳方程

对于基于物理的渲染器来说,了解表面反射或透射的光量是很重要的。这些效果的组合描述了诸如蜂蜜和彩色玻璃这样的物质,它们都具有颜色且可以透过。

这些数值彼此直接相关,并由菲涅耳方程描述。

这些方程针对两种类型的介质,导体金属)和介电体非金属)。金属不透光;它们只完全反射,或者实际上完全反射。介电体具有漫反射的特性——光线穿过材料的表面,其中一些被吸收,而一些以反射的形式返回。这在这些材料的镜面高光中尤为明显——对于金属,它将是彩色的,而对于介电体,它看起来是白色的,或者更准确地说,保留了入射光的颜色。

虽然导体和介电体都受到同一组菲涅耳方程的约束,但 glTF 2.0 选择为介电体开发一个独特的评估函数。这种选择是为了利用这些方程在折射率肯定是实数时所假设的明显简单结构。

  • 非金属(电介质):这些材料如玻璃、塑料和陶瓷,缺乏独特的金属特性。

  • 金属(导体):这些材料可以在一定程度上传导热量和电流。例如,包括许多金属,如铜、银和金,尽管并非所有金属都表现出这种特性。与电介质不同,导体不传导光;相反,它们吸收一些入射光,将其转化为热量。

微 facet

根据微 facet 理论,粗糙表面由无数微 facet 或微小的表面元素组成,每个元素相对于表面法线都有自己的方向。由于这些微 facet 的方向不同,它们会散射入射光,从而产生漫反射而不是完美的镜面反射。

  • Blinn-Phong 模型:它由詹姆斯·F·布林于 1977 年提出,作为 1973 年由裴东光(Bui Tuong Phong)设计的经验 Phong 反射模型的改进。

此模型根据观察者方向与半向量 h=(L+V)/length(L+V) 之间的角度计算反射光的强度,半向量 h 位于光方向 L 和观察方向 V 之间。该模型包括一个提供表面高光的镜面项,模拟了光滑表面的效果。

  • Cook-Torrance 模型:1982 年,罗伯特·库克和肯尼思·托伦斯提出了一种反射模型,与 Phong 和 Blinn-Phong 模型相比,它提供了对光反射的更精确描述。微 facet BRDF 方程如下:

其中:

  • f[r]​(ω[i]​,ω[o]​) 是微 facet BRDF

  • F(ω[i]​,h) 是菲涅耳项

  • D(h) 是微 facet 分布函数

  • G(ω[i]​,ω[o]​,h) 是几何函数

  • ω[i] 是入射光方向

  • ω[o]​ 是出射光方向

  • h 是半向量

  • n 是表面法线

提出的方法具有多功能性,具有三个可互换的成分函数FDG,可以用你偏好的方程替换。此外,它证明了在准确表示广泛的真实世界材料方面的效率。

此方程表示在特定方向 ωo 上反射的光量,给定入射光方向 ωi 和表面特性。初始成分 F 表示菲涅耳效应,随后的成分 D 是一个正态分布函数NDF),最后的成分考虑了阴影因子 G,称为G 项

在这个公式的所有因素中,NDF 项通常具有最大的重要性。NDF 的具体形式受到 BRDF 粗糙度的影响很大。为了有效地采样微 facet BRDF 模型,通常首先采样 NDF,获得一个符合 NDF 的随机微 facet 法线,然后沿着这个法线反射入射辐射,以确定出射方向。

微 facet 理论中 NDF 的归一化要求确保在不同粗糙度级别上,表面反射或透射的总能量保持一致。

在微 facet 理论中,NDF 描述了表面微 facet 法线的统计分布。它指定了找到具有特定方向的微 facet 的概率密度。当对所有可能的方向进行 BRDF 的积分时,积分应得到一个表示表面总反射率或透射率的值。

NDF 的归一化保证了无论表面粗糙度如何,表面反射或透射的总光量保持不变。这确保了能量守恒,这是物理学的一个基本原理,即能量不能被创造或摧毁,只能被转换或转移。

常用的几种 NDF 用于模拟具有微观粗糙度的表面行为。一些例子是 GGX、Beckmann 和 Blinn。在接下来的食谱中,我们将学习如何实现其中的一些。

什么是材质?

材质作为高级描述,用于表示表面,由 BRDF 和 BTDF 的组合定义。这些 BSDF 被表述为控制材料视觉特性的参数。例如,可以通过指定漫反射值来阐明光线与表面的相互作用,以及一个标量粗糙度值来表征其纹理,从而定义一个哑光材料。要从哑光材料过渡到塑料材料,只需简单地将哑光材料附加一个光泽镜面反射值,从而重现塑料典型的镜面高光。

glTF PBR 规范

glTF PBR 规范以强调真实感、效率和在不同渲染引擎和应用程序之间的一致性为方法来处理材质表示。

glTF PBR 规范的一个关键方面是它遵循基于物理的原则。这意味着 glTF 中定义的材质能够准确模拟现实世界的特性,例如光线与表面的相互作用。如基础颜色(反照率)、粗糙度、金属和镜面等参数用于描述材质,与物理属性如表面颜色、平滑度、金属性和镜面反射率相一致。

glTF PBR 方法的一个显著特点是它的简单性和易于实现。通过标准化用于描述材质的参数,glTF 简化了创建和导出具有 PBR 材质的 3D 模型的过程。这种在不同应用程序和渲染引擎之间的一致性简化了艺术家和开发者的工作流程,使他们能够更高效、更灵活地工作。

此外,glTF PBR 规范是为实时渲染应用设计的,使其非常适合用于交互式体验、游戏和其他实时图形应用。它对材质的高效表示和优化的文件格式有助于加快加载时间并在实时渲染场景中提供更好的性能。

总体而言,glTF PBR 规范因其对物理精度、简洁性和效率的承诺而脱颖而出,使其成为 3D 图形应用中材质表示的首选选择。它在各个平台上的广泛应用和支持进一步巩固了其在 PBR 材质表示领域的领先地位。

Khronos 3D 格式工作组 不断通过引入新的扩展规范来提高 PBR 材质的功能。你可以通过访问 Khronos GitHub 页面来始终了解已批准扩展的状态:github.com/KhronosGroup/glTF/blob/main/extensions/README.md

还有更多...

对于那些希望获得更深入知识的人来说,请确保阅读由 Matt Pharr、Wenzel Jakob 和 Greg Humphreys 撰写的免费书籍 基于物理的渲染:从理论到实现,可在www.pbr-book.org在线获取。另一本优秀的参考书籍是 Tomas Akenine-Möller、Eric Haines 和 Naty Hoffman 撰写的 实时渲染,第 4 版

此外,我们还推荐 SIGGRAPH 的 基于物理的渲染 课程。例如,你可以在 GitHub 上找到一个全面的链接集合:github.com/neil3d/awesome-pbr

此外,Filament 渲染引擎提供了对 PBR 材质的非常全面的解释:google.github.io/filament/Filament.md.xhtml

渲染未光照的 glTF 2.0 材质

在这个菜谱中,我们将开始开发一个代码框架,使我们能够加载和渲染 glTF 2.0 资产。

我们从 unlit 材质开始,因为它是最简单的 glTF 2.0 PBR 材质扩展,实际的着色器实现非常简单直接。该扩展的官方名称是 KHR_materials_unlit。以下是其规范的链接:github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_unlit

从技术上讲,unlit 材质不是基于 PBR 的,因为它可能会破坏能量守恒的假设,或者提供不反映任何物理定律的艺术表现。unlit 材质的设计考虑了以下动机:

  • 资源有限的移动设备,其中未光照材料提供了比高质量着色模型更高效的替代方案。

  • 立体摄影测量,其中光照信息已经预先烘焙到纹理数据中,不应再应用额外的光照。

  • 不希望用于美学原因的样式化材料(如类似“动漫”或手绘艺术的作品)。

让我们开始基本实现。

准备工作

此菜谱的源代码位于 Chapter06/01_Unlit/main.cpp。相应的 GLSL 顶点和片段着色器位于 main.vertmain.frag

如何做到这一点...

在接下来的章节中,我们将创建一个功能丰富的 glTF 查看器。在本章中,我们开始构建一个简单的框架,使我们能够加载和渲染基本的 glTF 模型。

我们将使用上一章中的 VulkanAppAssimp 库。与往常一样,本书文本中省略了大多数错误检查,但在实际的源代码文件中是存在的:

  1. 让我们使用 Assimp 加载我们的 .gltf 文件:
const aiScene* scene = aiImportFile(“deps/src/glTF-Sample-
  Assets/Models/DamagedHelmet/glTF/DamagedHelmet.gltf”,
    aiProcess_Triangulate);
  1. 如您所见,加载函数是一行代码。Assimp 默认支持加载 .gltf.glb 文件。我们使用官方 Khronos 存储库中的 DamagedHelmet 资产:github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/DamagedHelmet。此模型使用 Metallic-Roughness 材质,但出于演示目的,我们将应用 unlit 着色器。

  2. 下一步是构建网格几何形状。unlit 材质仅使用材质的 baseColor 属性,以三种不同的输入形式:作为顶点属性、作为静态片段着色器颜色因子和作为基础颜色纹理输入。对于我们的顶点格式,这意味着我们需要提供以下三个每顶点属性:

struct Vertex {
  vec3 position;
  vec4 color;
  vec2 uv;
};
  1. 为了填写这些属性,我们将使用以下代码。空颜色用白色颜色值 (1, 1, 1, 1) 填充,空纹理坐标根据 glTF 规范用零 (0, 0, 0) 填充:
std::vector<Vertex> vertices;
vertices.reserve(mesh->mNumVertices);
for (unsigned int i = 0; i != mesh->mNumVertices; i++) {
  const aiVector3D v = mesh->mVertices[i];
  const aiColor4D  c = mesh->mColors[0] ?
    mesh->mColors[0][i] : aiColor4D(1, 1, 1, 1);
  const aiVector3D t = mesh->mTextureCoords[0] ?
    mesh->mTextureCoords[0][i] : aiVector3D(0, 0, 0);
  vertices.push_back({ .position = vec3(v.x, v.y, v.z),
                       .color    = vec4(c.r, c.g, c.b, c.a),
                       .uv       = vec2(t.x, 1.0f - t.y) });
}
  1. 如果网格中没有呈现顶点颜色,则我们将其替换为默认的白色颜色。这是一种简化最终着色器排列的便捷方式,我们可以简单地组合所有三个输入。我们将在本菜谱的后面再次提到它。

  2. 我们使用 Assimp 网格面信息构建索引缓冲区:

std::vector<uint32_t> indices;
indices.reserve(3 * mesh->mNumFaces);
for (unsigned int i = 0; i != mesh->mNumFaces; i++) {
  for (int j = 0; j != 3; j++)
    indices.push_back(mesh->mFaces[i].mIndices[j]);
}
  1. 之后,我们应该加载漫反射或反照率基础颜色纹理。为了简单起见,我们将在这里使用硬编码的文件路径,而不是从 .gltf 模型中获取它:
lvk::Holder<lvk::TextureHandle> baseColorTexture =
  loadTexture(ctx, “deps/src/glTF-Sample-
    Assets/Models/DamagedHelmet/glTF/Default_albedo.jpg”);
  1. 顶点和索引数据是静态的,可以上传到相应的 Vulkan 缓冲区:
lvk::Holder<lvk::BufferHandle> vertexBuffer = ctx->createBuffer(
  { .usage     = lvk::BufferUsageBits_Vertex,
    .storage   = lvk::StorageType_Device,
    .size      = sizeof(Vertex) * vertices.size(),
    .data      = vertices.data(),
    .debugName = “Buffer: vertex” });
lvk::Holder<lvk::BufferHandle> indexBuffer = ctx->createBuffer(
  { .usage     = lvk::BufferUsageBits_Index,
    .storage   = lvk::StorageType_Device,
    .size      = sizeof(uint32_t) * indices.size(),
    .data      = indices.data(),
    .debugName = “Buffer: index” });
  1. 为了完成网格设置,我们需要加载 GLSL 着色器并创建渲染管线。VertexInput 结构的成员字段对应于上面提到的 Vertex 结构:
const lvk::VertexInput vdesc = {
  .attributes    = { { .location = 0,
                       .format = lvk::VertexFormat::Float3,
                       .offset = 0  },
                   {   .location = 1,
                       .format = lvk::VertexFormat::Float4,
                       .offset = sizeof(vec3) },
                   {   .location = 2,
                       .format = lvk::VertexFormat::Float2,
                       .offset = sizeof(vec3) + sizeof(vec4) }},
  .inputBindings = { { .stride = sizeof(Vertex) } }};
lvk::Holder<lvk::ShaderModuleHandle> vert =
  loadShaderModule(ctx, “Chapter06/01_Unlit/src/main.vert”);
lvk::Holder<lvk::ShaderModuleHandle> frag =
  loadShaderModule(ctx, “Chapter06/01_Unlit/src/main.frag”);
lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid =
  ctx->createRenderPipeline({
    .vertexInput = vdesc,
    .smVert      = vert,
    .smFrag      = frag,
    .color       = {{ .format = ctx->getSwapchainFormat() }},
    .depthFormat = app.getDepthFormat(),
    .cullMode    = lvk::CullMode_Back });

这就是准备代码。现在让我们看看应用程序的主循环内部:

  1. 在渲染循环中,我们准备模型视图投影矩阵mvp,并通过推送常量和PerFrameData结构将其传递到 GLSL 着色器中,包括基色值和漫反射纹理 ID:
const mat4 m1 = glm::rotate(
  mat4(1.0f), glm::radians(+90.0f), vec3(1, 0, 0));
const mat4 m2 = glm::rotate(
  mat4(1.0f), (float)glfwGetTime(), vec3(0.0f, 1.0f, 0.0f));
const mat4 v = app.camera_._.getViewMatrix();
const mat4 p = glm::perspective(
  45.0f, aspectRatio, 0.1f, 1000.0f);
struct PerFrameData {
  mat4 mvp;
  vec4 baseColor;
  uint32_t baseTextureId;
} perFrameData = {
  .mvp           = p * v * m2 * m1,
  .baseColor     = vec4(1, 1, 1, 1),
  .baseTextureId = baseColorTexture.index(),
};
  1. 现在,实际的 3D 网格渲染很简单,所以我们在这里完整地发布代码:
const lvk::RenderPass renderPass = {
  .color = { { .loadOp = lvk::LoadOp_Clear,
               .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
  .depth = { .loadOp = lvk::LoadOp_Clear,
             .clearDepth = 1.0f }
};
const lvk::Framebuffer framebuffer = {
  .color  = {{ .texture = ctx->getCurrentSwapchainTexture() }},
  .depthStencil = { .texture = app.getDepthTexture() },
};
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
buf.cmdBeginRendering(renderPass, framebuffer);
buf.cmdBindVertexBuffer(0, vertexBuffer, 0);
buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
buf.cmdBindRenderPipeline(pipelineSolid);
buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                        .isDepthWriteEnabled = true });
buf.cmdPushConstants(perFrameData);
buf.cmdDrawIndexed(indices.size());
  1. 最后,让我们在结束时添加一些漂亮的细节来渲染无限网格,如第五章菜谱中所述的,实现无限网格 GLSL 着色器,以及帧率计数器,如第四章菜谱中所述的添加每秒帧数计数器
app.drawGrid(buf, p, vec3(0, -1.0f, 0));
app.imgui_->beginFrame(framebuffer);
app.drawFPS();
app.imgui_->endFrame(buf);
buf.cmdEndRendering();
ctx->submit(buf, ctx->getCurrentSwapchainTexture());

这就是所有的 C++代码。现在,让我们深入到这个示例的 GLSL 着色器。它们很简单且简短:

  1. 顶点着色器Chapter06/01_Unlit/src/main.vert执行顶点变换,并将每个顶点的颜色与提供的基色值进行预乘,该基色值来自推送常量:
layout(push_constant) uniform PerFrameData {
  mat4 MVP;
  vec4 baseColor;
  uint textureId;
};
layout (location = 0) in vec3 pos;
layout (location = 1) in vec4 color;
layout (location = 2) in vec2 uv;
layout (location = 0) out vec2 outUV;
layout (location = 1) out vec4 outVertexColor;
void main() {
  gl_Position = MVP * vec4(pos, 1.0);
  outUV = uv;
  outVertexColor = color * baseColor;
}
  1. 片段着色器Chapter06/01_Unlit/src/main.frag同样很简单。它所做的只是将预先计算的每个顶点的基色值乘以从漫反射纹理中采样的颜色值。glTF 2.0 规范保证至少提供一个baseColorFactor值用于金属-粗糙度属性,并且只要我们保持所有其他参数等于1,这保证了结果的正确性:
layout(push_constant) uniform PerFrameData {
  mat4 MVP;
  vec4 baseColor;
  uint textureId;
};
layout (location = 0) in vec2 uv;
layout (location = 1) in vec4 vertexColor;
layout (location=0) out vec4 out_FragColor;
void main() {
  vec4 baseColorTexture = textureBindless2D(textureId, 0, uv);
  out_FragColor =
    textureBindless2D(textureId, 0, uv) * vertexColor;
}
  1. 运行的应用程序Chapter06/01_Unlit/src/main.cpp应该看起来像下面的截图。

图 6.4:未光照的 glTF 2.0 模型

图 6.4:未光照的 glTF 2.0 模型

在这个例子中,我们强制使用漫反射纹理进行渲染以保持代码简单,使其更容易理解。在随后的章节中,我们将完全支持 glTF 2.0 规范中指定的各种材料参数组合,提供更准确和完整的 glTF 2.0 查看器实现。

预计算 BRDF 查找表

在之前的菜谱中,我们学习了 glTF 2.0 PBR 背后的基本理论,并实现了一个简单的未光照的 glTF 2.0 渲染器。让我们继续我们的 PBR 探索,并学习如何为即将到来的 glTF 2.0 查看器预先计算 Smith GGX BRDF查找表(LUT)

  1. 要渲染 PBR 图像,我们必须在渲染表面的每个点上评估 BRDF,考虑到表面特性和观察方向。这计算成本很高,包括 Khronos 的参考 glTF-Sample-Viewer 在内的许多实时实现,都使用某种预先计算的表格来查找 BRDF 值,基于表面粗糙度和观察方向。

  2. BRDF LUT 可以存储为二维纹理。X 轴表示表面法向量与观察方向的点积,而 Y 轴表示表面粗糙度值 0...1。每个 texel 包含三个 16 位浮点值。前两个值表示 F0 的缩放和偏移,F0 是正常入射时的镜面反射率。第三个值用于光泽材料扩展,将在下一章中介绍。

我们将使用 Vulkan 在 GPU 上计算这个 LUT 纹理,并实现一个计算着色器来完成它。

准备工作

  1. 有助于回顾一下来自 第五章 的配方 使用计算着色器在 Vulkan 中生成纹理 中的 Vulkan 计算管线创建。我们的实现基于来自 github.com/KhronosGroup/glTF-Sample-Viewer/blob/main/source/shaders/ibl_filtering.frag 的着色器,该着色器在片段着色器中执行非常相似的运算。我们的 GLSL 计算着色器可以在 Chapter06/02_BRDF_LUT/src/main.comp 中找到。

为什么预计算?

在本章早期,我们解释了什么是 BRDF,并介绍了其主要组成部分,例如 Fresnel 项 F、法线分布函数 NDF 和几何项 G。如您所注意到的,BRDF 的结果取决于几个因素,例如入射光和出射光的方向、表面法线和观察者的方向:

其中各个项的含义如下:

  • D 是 GGX NDF 微观面分布函数:

  • G 考虑了微观面的相互阴影,其形式如下:

  • Fresnel F 项定义了在给定入射角下从表面反射的光量:

如果我们检查 BRDF 的任何分量,我们会看到它们对于实时每像素计算来说都非常复杂。因此,我们可以使用离线过程预计算 BRDF 方程的一些部分。

如您所见,G 项和 F 项的一些部分只依赖于 vhRoughness 参数。我们将利用这一点来进行预计算。同时,请注意,我们永远不需要单独的 nv,因此我们可以始终使用它们的点积。

还有一个重要的问题。我们如何迭代所有可能的 vn 组合?为了做到这一点,我们需要在一个半球上对所有角度进行积分,但我们可以使用一个更简单的近似。为了使其高效,我们使用两个假设。首先,我们需要找到一种方法,用有限数量的样本进行积分。其次,我们需要明智地选择样本,而不仅仅是随机选择。

如书中第二十章基于 GPU 的重要性采样,在《GPU Gems 3》中所述developer.nvidia.com/gpugems/gpugems3/part-iii-rendering/chapter-20-gpu-based-importance-sampling,解决方案是使用蒙特卡洛估计配合重要性采样。蒙特卡洛估计允许我们通过随机样本的加权求和来近似积分。重要性采样利用了这样的想法:半球上某些随机点的值对被估计的函数有更大的影响。

由 Brian Karis 撰写的论文Real Shading in Unreal Engine 4提供了所有数学方面的详细解释。我们强烈建议您阅读它,以更好地理解 PBR 背后的数学:blog.selfshadow.com/publications/s2013-shading-course/karis/s2013_pbs_epic_notes_v2.pdf

如何实现...

在我们研究 GLSL 着色器代码之前,让我们实现所有必要的 C++代码来处理 GPU 上的数据数组。

为了在 GPU 上操作数据缓冲区并有效地利用数据,我们需要四个基本操作:加载着色器模块、创建计算管线、创建缓冲区以及调度计算命令。之后,我们需要将数据从 GPU 缓冲区传输到主机内存,并保存为纹理文件。让我们通过检查Chapter06/02_BRDF_LUT/src/main.cpp中的代码来逐步了解这些步骤:

  1. calculateLUT()函数实现了大部分描述的功能。我们将从加载着色器模块和创建计算管线开始。使用常量kNumSamples对 GLSL 着色器进行特殊化,该常量定义了 LUT 计算的蒙特卡洛试验次数。我们将在缓冲区中存储 16 位浮点 RGBA 值:
const uint32_t kBrdfW      = 256;
const uint32_t kBrdfH      = 256;
const uint32_t kNumSamples = 1024;
const uint32_t kBufferSize =
  4u * sizeof(uint16_t) * kBrdfW * kBrdfH;
void calculateLUT(const std::unique_ptr<lvk::IContext>& ctx,
  void* output, uint32_t size)
{
  lvk::Holder<lvk::ShaderModuleHandle> comp = loadShaderModule(
    ctx, “Chapter06/02_BRDF_LUT/src/main.comp”);
  lvk:Holder<lvk::ComputePipelineHandle> computePipelineHandle =
    ctx->createComputePipeline({
      .smComp = comp,
      .specInfo = {.entries = {{ .constantId = 0,
                                 .size = sizeof(kNumSamples) }},
                   .data     = &kNumSamples,
                   .dataSize = sizeof(kNumSamples),},
  });
  1. 下一步是为我们的输出数据创建一个 GPU 存储缓冲区:
 lvk::Holder<lvk::BufferHandle> dstBuffer = ctx->createBuffer({
    .usage     = lvk::BufferUsageBits_Storage,
    .storage   = lvk::StorageType_HostVisible,
    .size      = size,
    .debugName = “Compute: BRDF LUT” });
  1. 最后,我们获取一个命令缓冲区,更新推送常量,并调度计算命令:
 lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
  buf.cmdBindComputePipeline(computePipelineHandle);
  struct {
    uint32_t w = kBrdfW;
    uint32_t h = kBrdfH;
    uint64_t addr;
  } pc {
    .addr = ctx->gpuAddress(dstBuffer),
  };
  buf.cmdPushConstants(pc);
  buf.cmdDispatchThreadGroups({ kBrdfW / 16, kBrdfH / 16, 1 });
  1. 在将生成的数据回读至 CPU 内存之前,我们必须等待 GPU 完成缓冲区的处理。这可以通过使用wait()函数来实现,该函数会等待命令缓冲区完成。我们曾在第二章的配方使用 Vulkan 命令缓冲区中讨论过这一点。一旦 GPU 完成工作,我们就可以将内存映射的缓冲区复制回 CPU 内存,该内存由指针output引用:
 ctx->wait(ctx->submit(buf));
  memcpy(output, ctx->getMappedPtr(dstBuffer), kBufferSize);
}

那就是 C++部分。现在,让我们研究Chapter06/02_BRDF_LUT/src/main.comp中的 GLSL 着色器计算代码:

  1. 为了将我们的工作分解成更小的部分,我们将从 BRDF LUT 计算着色器的着色器前缀和main()函数开始。前缀代码设置了计算着色器调度参数。在我们的情况下,LUT 纹理的 16x16 块由一个 GPU 工作组计算。数值积分的蒙特卡洛试验次数被声明为一个特殊常量,我们可以从 C++代码中覆盖它:
layout (local_size_x=16, local_size_y=16, local_size_z=1) in;
layout (constant_id = 0) const uint NUM_SAMPLES = 1024u;
layout(std430, buffer_reference) readonly buffer Data {
  float16_t floats[];
};
  1. 我们使用用户提供的宽度和高度来计算我们的输出缓冲区尺寸。PI是在着色器中使用的全局“物理”常数:
layout (push_constant) uniform constants {
  uint BRDF_W;
  uint BRDF_H;
  Data data;
};
const float PI = 3.1415926536;
  1. main()函数封装BRDF()函数调用并存储结果。首先,我们重新计算工作 ID 以输出数组索引:
void main() {
  vec2 uv;
  uv.x = (float(gl_GlobalInvocationID.x) + 0.5) / float(BRDF_W);
  uv.y = (float(gl_GlobalInvocationID.y) + 0.5) / float(BRDF_H);
  1. BRDF()函数执行所有实际工作。计算出的值被放入输出数组中:
 vec3 v = BRDF(uv.x, 1.0 - uv.y);
  uint offset = gl_GlobalInvocationID.y * BRDF_W +
                gl_GlobalInvocationID.x;
  data.floats[offset * 4 + 0] = float16_t(v.x);
  data.floats[offset * 4 + 1] = float16_t(v.y);
  data.floats[offset * 4 + 2] = float16_t(v.z);
}
  1. 如您所见,我们使用纹理的三个通道。RG通道用于 GGX BRDF LUT,第三个通道用于 Charlie BRDF LUT,这是Sheen材质扩展所必需的,将在第七章高级 PBR 扩展中介绍。

现在我们已经描述了我们的计算着色器的框架部分,我们可以看到 BRDF LUT 值是如何计算的。让我们看看步骤:

  1. 为了在半球中生成随机方向,我们将使用所谓的 Hammersley 点,该点由以下函数计算:
vec2 hammersley2d(uint i, uint N) {
  uint bits = (i << 16u) | (i >> 16u);
  bits = ((bits & 0x55555555u)<<1u)|((bits & 0xAAAAAAAAu)>>1u);
  bits = ((bits & 0x33333333u)<<2u)|((bits & 0xCCCCCCCCu)>>2u);
  bits = ((bits & 0x0F0F0F0Fu)<<4u)|((bits & 0xF0F0F0F0u)>>4u);
  bits = ((bits & 0x00FF00FFu)<<8u)|((bits & 0xFF00FF00u)>>8u);
  float rdi = float(bits) * 2.3283064365386963e-10;
  return vec2(float(i) / float(N), rdi);
}

重要提示

代码基于以下帖子:holger.dammertz.org:80/stuff/notes_HammersleyOnHemisphere.xhtml。这本书名为Hacker’s Delight的 Henry J. Warren 彻底检查了这种和许多其他应用的位操作魔法。感兴趣的读者还可以查找“Van der Corput 序列”以了解为什么它可以作为半球上的一系列随机方向使用。

  1. 我们还需要某种类型的伪随机数生成器。我们使用输出数组索引作为输入并通过另一组神奇的公式传递:
float random(vec2 co) {
  float a  = 12.9898;
  float b  = 78.233;
  float c  = 43758.5453;
  float dt = dot( co.xy ,vec2(a,b) );
  float sn = mod(dt, 3.14);
  return fract(sin(sn) * c);
}
  1. 查看此链接以获取有关此代码的一些有用细节:byteblacksmith.com/improvements-to-the-canonical-one-liner-glsl-rand-for-opengl-es-2-0

  2. 让我们看看如何根据 Brian Karis 的论文Real Shading in Unreal Engine 4实现重要性采样。查看cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf的第四页。此函数将第i个 2D 点Xi映射到基于表面粗糙度的半球上:

vec3 importanceSample_GGX(vec2 Xi, float roughness, vec3 normal)
{
  float alpha = roughness * roughness;
  float phi = 2.0 * PI * Xi.x + random(normal.xz) * 0.1;
  float cosTheta =
    sqrt((1.0 - Xi.y) / (1.0 + (alpha*  alpha - 1.0) * Xi.y));
  float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
  vec3 H =
    vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
  1. 计算在切线空间中进行,由向量uptangentXtangentY定义,然后转换为世界空间:
 vec3 up = abs(normal.z) < 0.999 ?
    vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
  vec3 tangentX = normalize(cross(up, normal));
  vec3 tangentY = normalize(cross(normal, tangentX));
  return normalize(tangentX * H.x +
                   tangentY * H.y +
                     normal * H.z);
}
  1. 另一个实用函数G_SchlicksmithGGX()计算 GGX 几何阴影因子:
float G_SchlicksmithGGX(
  float dotNL, float dotNV, float roughness)
{
  float k = (roughness * roughness) / 2.0;
  float GL = dotNL / (dotNL * (1.0 - k) + k);
  float GV = dotNV / (dotNV * (1.0 - k) + k);
  return GL * GV;
}
  1. 我们还预先计算了光泽材料的 LUT,因此还有两个额外的辅助函数,V_Ashikhmin()D_Charlie()。它们基于 Filament 引擎的代码:github.com/google/filament/blob/master/shaders/src/brdf.fs#L136
float V_Ashikhmin(float NdotL, float NdotV) {
  return clamp(
    1.0 / (4.0 * (NdotL + NdotV - NdotL * NdotV)), 0.0, 1.0);
}
float D_Charlie(float sheenRoughness, float NdotH) {
  sheenRoughness = max(sheenRoughness, 0.000001); // clamp (0,1]
  float invR = 1.0 / sheenRoughness;
  float cos2h = NdotH * NdotH;
  float sin2h = 1.0 - cos2h;
  return (2.0 + invR) * pow(sin2h, invR * 0.5) / (2.0 * PI);
}
  1. 这里是针对光泽材料的相应采样函数,importanceSample_Charlie(),它与 importanceSample_GGX() 非常相似:
vec3 importanceSample_Charlie(
  vec2 Xi, float roughness, vec3 normal)
{
  float alpha = roughness * roughness;
  float phi = 2.0 * PI * Xi.x;
  float sinTheta = pow(Xi.y, alpha / (2.0*  alpha + 1.0));
  float cosTheta = sqrt(1.0 - sinTheta * sinTheta);
  vec3 H = vec3(
    sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
  vec3 up = abs(normal.z) < 0.999 ?
    vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
  vec3 tangentX = normalize(cross(up, normal));
  vec3 tangentY = normalize(cross(normal, tangentX));
  return normalize(tangentX * H.x +
                   tangentY * H.y +
                     normal * H.z);
}
  1. BRDF 的值按照以下方式计算,使用我们上面声明的所有辅助函数。蒙特卡洛试验次数 NUM_SAMPLES 之前设置为 1024。法向量 N 对于 2D 查找始终指向 Z 轴:
vec3 BRDF(float NoV, float roughness) {
  const vec3 N = vec3(0.0, 0.0, 1.0);
  vec3 V = vec3(sqrt(1.0 - NoV*  NoV), 0.0, NoV);
  vec3 LUT = vec3(0.0);
  1. 第一个循环计算我们 LUT 的 RG 分量,分别对应于对 F0 的缩放和偏移:
 for (uint i = 0u; i < NUM_SAMPLES; i++) {
    vec2 Xi = hammersley2d(i, NUM_SAMPLES);
    vec3 H = importanceSample_GGX(Xi, roughness, N);
    vec3 L = 2.0 * dot(V, H) * H - V;
    float dotNL = max(dot(N, L), 0.0);
    float dotNV = max(dot(N, V), 0.0);
    float dotVH = max(dot(V, H), 0.0); 
    float dotNH = max(dot(H, N), 0.0);
    if (dotNL > 0.0) {
      float G = G_SchlicksmithGGX(dotNL, dotNV, roughness);
      float G_Vis = (G * dotVH) / (dotNH * dotNV);
      float Fc = pow(1.0 - dotVH, 5.0);
      LUT.rg += vec2((1.0 - Fc) * G_Vis, Fc * G_Vis);
    }
  }
  1. 用于光泽材料的第三个分量 B 在另一个循环中计算。我们将在 第七章高级 PBR 扩展 中重新讨论它:
 for(uint i = 0u; i < NUM_SAMPLES; i++) {
    vec2 Xi = hammersley2d(i, NUM_SAMPLES);
    vec3 H = importanceSample_Charlie(Xi, roughness, N);
    vec3 L = 2.0 * dot(V, H) * H - V;
    float dotNL = max(dot(N, L), 0.0);
    float dotNV = max(dot(N, V), 0.0);
    float dotVH = max(dot(V, H), 0.0); 
    float dotNH = max(dot(H, N), 0.0);
    if (dotNL > 0.0) {
      float sheenDistribution = D_Charlie(roughness, dotNH);
      float sheenVisibility = V_Ashikhmin(dotNL, dotNV);
      LUT.b +=
        sheenVisibility * sheenDistribution * dotNL * dotVH;
    }
  }
  return LUT / float(NUM_SAMPLES);
}

那就是整个用于预计算查找表的 GLSL 计算着色器。现在让我们看看它是如何与 C++ 的 main() 函数一起工作的。

它是如何工作的...

main() 函数使用 KTX-Software 库创建一个 KTX 纹理,以便我们的 16 位 RGBA LUT 纹理可以保存为 .ktx 格式,从而保留数据。然后,它调用我们上面讨论的 calculateLUT() 函数,该函数将生成的 LUT 数据输出到 KTX 纹理中。纹理保存在 data/brdfLUT.ktx

int main() {
  std::unique_ptr<lvk::IContext> ctx =
    lvk::createVulkanContextWithSwapchain(nullptr, 0, 0, {});
  ktxTextureCreateInfo createInfo = {
    .glInternalformat = GL_RGBA16F,
    .vkFormat         = VK_FORMAT_R16G16B16A16_SFLOAT,
    .baseWidth        = kBrdfW,
    .baseHeight       = kBrdfH,
    .baseDepth        = 1u,
    .numDimensions    = 2u,
    .numLevels        = 1,
    .numLayers        = 1u,
    .numFaces         = 1u,
    .generateMipmaps  = KTX_FALSE,
  };
  ktxTexture1* lutTexture = nullptr;
  ktxTexture1_Create(
    &createInfo, KTX_TEXTURE_CREATE_ALLOC_STORAGE, &lutTexture);
  calculateLUT(ctx, lutTexture->pData, kBufferSize);
  ktxTexture_WriteToNamedFile(
    ktxTexture(lutTexture), “data/brdfLUT.ktx”);
  ktxTexture_Destroy(ktxTexture(lutTexture));
  return 0;
}
  1. 您可以使用 Pico Pixel (pixelandpolygon.com) 来查看生成的图像。它应该类似于下面的截图。水平轴表示表面法向量与观察方向的点积,而垂直轴表示表面粗糙度值 0...1。每个纹理像素包含三个 16 位浮点值。前两个值表示对 F0 的缩放和偏移,F0 是正入射时的镜面反射率。第三个值用于光泽材料扩展,这将在下一章中介绍:

图 6.5:BRDF 查找表

图 6.5:BRDF 查找表

这就完成了 BRDF 查找表工具的描述。我们还需要另一个工具来从环境立方体贴图计算辐照度立方体贴图,这将在下一道菜谱中介绍。

还有更多...

上文描述的方法可以用于使用高质量的蒙特卡洛积分预计算 BRDF 查找表,并将它们作为纹理存储。在某些移动平台上,依赖于纹理的提取可能很昂贵。Unreal Engine 中使用了一个有趣的运行时近似,它不依赖于任何预计算,如 Brian Karis 在博客文章 Physically Based Shading on Mobile 中所述:www.unrealengine.com/en-US/blog/physically-based-shading-on-mobile。以下是 GLSL 源代码:

vec3 EnvBRDFApprox(vec3 specularColor, float roughness, float NoV) {
  const vec4 c0 = vec4(-1, -0.0275, -0.572, 0.022);
  const vec4 c1 = vec4( 1,  0.0425,  1.04, -0.04 );
  vec4 r = roughness * c0 + c1;
  float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
  vec2 AB = vec2(-1.04, 1.04) * a004 + r.zw;
  return specularColor * AB.x + AB.y;
}

预计算辐照度图和漫反射卷积

如我们在菜谱glTF 2.0 物理着色模型简介中之前讨论的,计算 glTF 2.0 物理着色模型所需的分割和近似法的第二部分来自辐照度立方体贴图,该立方体贴图通过卷积输入环境立方体贴图和我们的着色模型的 GGX 分布来预计算。我们的实现基于github.com/KhronosGroup/glTF-Sample-Viewer/blob/main/source/shaders/ibl_filtering.frag中的代码。

基于图像的照明IBL)是一种使用捕获的光信息照亮场景的技术。这些信息可以存储为全景照片图像(例如,参见图 6.6)。模拟整个真实世界环境非常困难,因此捕获真实世界并使用图像是如今产生逼真渲染的非常常见的技术。使用 IBL 允许我们预计算漫反射和镜面 BRDF 方程的部分,并使它们在运行时更加友好。

注意,预计算辐照度和扩散是一个相当数学的过程。如果您想了解更多关于这些计算背后的理论,请确保您阅读 Brian Karis 的论文Real Shading in Unreal Engine 4cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf

准备工作

Chapter06/03_FilterEnvmap中查看此菜谱的源代码。

如何操作...

我们将在片段着色器内部进行蒙特卡洛积分,该着色器位于此处:Chapter06/03_FilterEnvmap/src/main.frag

C++源代码可以在Chapter06/03_FilterEnvmap/src/main.cpp文件中找到。让我们通过函数prefilterCubemap()来预计算辐照度和漫反射图:

  1. 首先,我们需要创建一个立方体贴图纹理来存储预过滤的结果。我们将使用 32 位 RGBA 浮点像素格式,因为我们的大多数颜色相关计算都在线性空间中进行。低端移动设备可能性能不足,在这种情况下,动态范围可以限制为 16 位甚至 8 位,但这可能会显著影响视觉效果。

我们使用立方体贴图 MIP 级别来预计算不同材料粗糙度0…1的不同值的多个查找。该函数接受distribution参数,该参数传递给着色器以选择适当的分布,Lambertian、GGX 或 Charlie:

void prefilterCubemap(
  const std::unique_ptr<lvk::IContext>& ctx,
  ktxTexture1* cube, const char* envPrefilteredCubemap,
  lvk::TextureHandle envMapCube,
  Distribution distribution,
  uint32_t sampler,
  uint32_t sampleCount)
{
  lvk::Holder<lvk::TextureHandle> prefilteredMapCube =
    ctx->createTexture({
        .type         = lvk::TextureType_Cube,
        .format       = lvk::Format_RGBA_F32,
        .dimensions   = {cube->baseWidth, cube->baseHeight, 1},
        .usage        = lvk::TextureUsageBits_Sampled |
                        lvk::TextureUsageBits_Attachment,
        .numMipLevels = (uint32_t)cube->numLevels,
        .debugName    = envPrefilteredCubemap,
      }, envPrefilteredCubemap);
  1. 我们需要 GLSL 着色器模块和渲染管线:
 lvk::Holder<lvk::ShaderModuleHandle> vert = loadShaderModule(
    ctx, “Chapter06/03_FilterEnvmap/src/main.vert”);
  lvk::Holder<lvk::ShaderModuleHandle> frag = loadShaderModule(
    ctx, “Chapter06/03_FilterEnvmap/src/main.frag”);
  lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid =
    ctx->createRenderPipeline({
      .smVert   = vert,
      .smFrag   = frag,
      .color    = { { .format =
                        ctx->getFormat(prefilteredMapCube) } },
      .cullMode = lvk::CullMode_Back,
  });
  1. 现在,我们可以在立方体贴图中开始实际渲染。一个命令缓冲区填充了渲染6个立方体贴图面所需的所有命令,包括所有所需的 MIP 级别:
 lvk::ICommandBuffer& buf = ctx,->acquireCommandBuffer();
  for (uint32_t mip = 0; mip < cube->numLevels; mip++) {
    for (uint32_t face = 0; face < 6; face++) {
  1. 我们设置要渲染的立方体贴图面和特定的 MIP 级别:
 buf.cmdBeginRendering(
        { .color = { { .loadOp     = lvk::LoadOp_Clear,
                       .layer      = (uint8_t)face,
                       .level      = (uint8_t)mip,
                       .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f },
                   } } },
        { .color = {{ .texture = prefilteredMapCube }} });
      buf.cmdBindRenderPipeline(pipelineSolid);
      buf.cmdBindDepthState({});
  1. 使用推送常量将所有数据传递到着色器中:
 struct PerFrameData {
        uint32_t face;
        float roughness;
        uint32_t sampleCount;
        uint32_t width;
        uint32_t envMap;
        uint32_t distribution;
        uint32_t sampler;
      } perFrameData = {
        .face         = face,
        .roughness    = (float)(mip) / (cube->numLevels - 1),
        .sampleCount  = sampleCount,
        .width        = cube->baseWidth,
        .envMap       = envMapCube.index(),
        .distribution = uint32_t(distribution),
        .sampler      = sampler,
      };
      buf.cmdPushConstants(perFrameData);
  1. 然后,渲染一个全屏三角形以覆盖整个立方体贴图面,并让片段着色器完成其工作。在命令缓冲区填满后,我们可以提交它:
 buf.cmdDraw(3);
      buf.cmdEndRendering();
    }
  }
  ctx->submit(buf);
  ... // save results to a .ktx file
}

prefilterCubemap() 函数的剩余部分从 GPU 获取生成的立方体贴图数据,并将其保存为 .ktx 文件。让我们看看 GLSL 片段着色器代码,它位于 Chapter06/03_FilterEnvmap/src/main.frag 目录中,执行了所有繁重的工作:

  1. 为了解开着色器逻辑,让我们从入口点 main() 开始。代码很简单,调用了两个函数。函数 uvToXYZ() 将立方体贴图面索引和 vec2 坐标转换为 vec3 立方体贴图采样方向。函数 filterColor() 执行实际的蒙特卡洛采样,我们稍后会回到这个话题:
void main() {
  vec2 newUV = uv * 2.0 - vec2(1.0);
  vec3 scan = uvToXYZ(perFrameData.face, newUV);
  vec3 direction = normalize(scan);
  out_FragColor = vec4(filterColor(direction), 1.0);
}
  1. 这是 uvToXYZ() 的代码,供您参考:
vec3 uvToXYZ(uint face, vec2 uv) {
  if (face == 0) return vec3(   1., uv.y,  uv.x);
  if (face == 1) return vec3(  -1., uv.y, -uv.x);
  if (face == 2) return vec3(+uv.x,   1.,  uv.y);  
  if (face == 3) return vec3(+uv.x,  -1., -uv.y);
  if (face == 4) return vec3(+uv.x, uv.y,   -1.);
  if (face == 5) return vec3(-uv.x, uv.y,    1.);
}
  1. 函数 filterColor() 为辐照度和朗伯漫反射卷积执行积分部分。参数 N 是立方体贴图采样方向向量。我们迭代 sampleCount 个样本,并获取重要性采样信息,包括重要性采样方向和该方向的 概率密度函数PDF)。数学部分在本博客文章中有详细描述:bruop.github.io/ibl。在这里,我们将专注于构建一个最小化工作实现:
vec3 filterColor(vec3 N) {
  vec3  color  = vec3(0.f);
  float weight = 0.0f;
  for(uint i = 0; i < perFrameData.sampleCount; i++) {
    vec4 importanceSample =
      getImportanceSample(i, N, perFrameData.roughness);
    vec3 H = vec3(importanceSample.xyz);
    float pdf = importanceSample.w;
  1. 如同在 GPU Gems 320.4映射和扭曲 中描述的那样,对米普贴样本进行过滤:在较低分辨率下采样朗伯,以避免过亮的像素,也称为 飞火
 float lod = computeLod(pdf);
    if (perFrameData.distribution == cLambertian) {
      vec3 lambertian = textureBindlessCubeLod(
        perFrameData.envMap,
        perFrameData.samplerIdx, H, lod).xyz;
      color += lambertian;
    } else if (perFrameData.distribution == cGGX ||
               perFrameData.distribution == cCharlie) {
      vec3 V = N;
      vec3 L = normalize(reflect(-V, H));
      float NdotL = dot(N, L);
      if (NdotL > 0.0) {
        if (perFrameData.roughness == 0.0) lod = 0.0;
        vec3 sampleColor = textureBindlessCubeLod(
          perFrameData.envMap,
          perFrameData.samplerIdx, L, lod).xyz;
        color += sampleColor * NdotL;
        weight += NdotL;
      }
    }
  }
  1. 输出颜色值使用所有 NdotL 权重的总和进行重新归一化,或者对于朗伯情况下的样本数量:
 color /= (weight != 0.0f) ?
    weight : float(perFrameData.sampleCount);
  return color.rgb;
}
  1. 重要性采样函数 getImportanceSample() 返回一个 vec4 值,其中重要性采样方向位于 .xyz 组件中,而 PDF 标量值位于 .w 组件中。我们生成一个 Hammersley 点,正如我们在之前的配方中描述的,预计算 BRDF 查找表,然后根据分布类型(朗伯、GGX 或 Charlie)生成一个样本,并在法线方向上旋转它。此函数使用辅助结构 MicrofacetDistributionSample
struct MicrofacetDistributionSample {
  float pdf;
  float cosTheta;
  float sinTheta;
  float phi;
};
vec4 getImportanceSample(
  uint sampleIndex, vec3 N, float roughness)
{
  vec2 xi = hammersley2d(sampleIndex, perFrameData.sampleCount);
  MicrofacetDistributionSample importanceSample;
  1. 在半球上生成点,其映射对应于所需的分布。例如,朗伯分布使用余弦重要性:
 if (perFrameData.distribution == cLambertian)
    importanceSample = Lambertian(xi, roughness);
  else if (perFrameData.distribution == cGGX)
    importanceSample = GGX(xi, roughness);
  else if (perFrameData.distribution == cCharlie)
    importanceSample = Charlie(xi, roughness);
  1. 将半球采样点转换为切线坐标系。辅助函数 generateTBN() 从提供的法线向量生成切线-切线-法线坐标系:
 vec3 localSpaceDirection = normalize(vec3(
    importanceSample.sinTheta * cos(importanceSample.phi), 
    importanceSample.sinTheta * sin(importanceSample.phi), 
    importanceSample.cosTheta));
  mat3 TBN = generateTBN(N);
  vec3 direction = TBN * localSpaceDirection;
  return vec4(direction, importanceSample.pdf);
}
  1. 我们将跳过个别分布计算函数 Lambertian()GGX()Charlie() 的细节。实际的 GLSL 着色器 Chapter06/03_FilterEnvmap/src/main.frag 包含所有必要的代码。

重要性采样的过程可能会引入视觉伪影。在不影响性能的情况下提高视觉质量的一种方法是通过利用硬件加速的米波映射进行快速过滤和采样。这个想法在以下论文中提出:cgg.mff.cuni.cz/~jaroslav/papers/2007-sketch-fis/Final_sap_0073.pdf。此链接对该主题有更详细的说明:developer.nvidia.com/gpugems/gpugems3/part-iii-rendering/chapter-20-gpu-based-importance-sampling。在这里,我们使用一个公式,它接受一个PDF值并为其计算适当的米波映射 LOD 级别:

float computeLod(float pdf) {
  float w = float(perFrameData.width);
  float h = float(perFrameData.height);
  float sampleCount = float(perFrameData.sampleCount);
  return 0.5 * log2( 6.0 * w * h / (sampleCount * pdf));
}

代码的其余部分涉及纯粹机械的任务,例如从文件中加载立方体贴图图像,调用各种分布类型(朗伯、GGX 和 Charlie)的渲染函数,以及使用 KTX 库保存结果。让我们检查以下输入图像的预过滤结果:

图 6.6:环境立方体贴图

图 6.6:环境立方体贴图

卷积后的图像应该看起来像以下截图:

图 6.7:使用漫反射卷积预过滤的环境立方体贴图

图 6.7:使用漫反射卷积预过滤的环境立方体贴图

现在,我们已经准备好了所有辅助部分来渲染 PBR 图像。在下一个配方“实现 glTF 2.0 金属-粗糙度着色模型”中,我们将把所有内容组合到一个简单的应用程序中,以渲染基于物理的 glTF 2.0 3D 模型。

还有更多...

Paul Bourke 创建了一套工具和丰富的资源,解释了如何将立方体贴图转换为不同的格式。请确保查看:paulbourke.net/panorama/cubemaps/index.xhtml

实现 glTF 2.0 金属-粗糙度着色模型

本配方将介绍如何将 PBR 集成到您的图形管线中。由于 PBR 的主题非常广泛,我们将关注最小化实现,仅为了指导您并让您开始。在本节中,我们将关注金属-粗糙度着色模型和最小化 C++ 观察器实现。在接下来的章节中,我们将创建一个更复杂、功能更丰富的 glTF 观察器,包括高级材质扩展和几何特征。

准备工作

  1. 在继续进行之前,建议您重新阅读配方《glTF 2.0 基于物理的着色模型简介》。有关 glTF 2.0 着色模型的轻量级介绍,可以在github.com/KhronosGroup/glTF-Sample-Viewer/tree/glTF-WebGL-PBR找到。

  2. 此配方的 C++ 源代码位于 Chapter06/04_MetallicRoughness 文件夹中。负责 PBR 计算的 GLSL 着色器代码可以在 Chapter06/04_MetallicRoughness/src/PBR.sp 中找到。

如何做到这一点...

在我们深入 GLSL 代码之前,我们将查看从 C++ 端设置输入数据的方式。我们将使用 Khronos 提供的 损坏头盔 3D 模型。您可以在以下位置找到 glTF 文件:github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/DamagedHelmet/glTF/DamagedHelmet.gltf

让我们先从结构和辅助函数开始:

  1. 辅助结构 GLTFGlobalSamplers 包含了访问 glTF IBL 纹理所需的三个采样器。它在 shared/UtilsGLTF.h 中声明:
struct GLTFGlobalSamplers {
  GLTFGlobalSamplers(const std::unique_ptr<lvk::IContext>& ctx);
  lvk::Holder<lvk::SamplerHandle> clamp;
  lvk::Holder<lvk::SamplerHandle> wrap;
  lvk::Holder<lvk::SamplerHandle> mirror;
};
  1. GLTFGlobalSamplers 构造函数以以下方式创建所有三个采样器:
GLTFGlobalSamplers(const std::unique_ptr<lvk::IContext>& ctx) {
  clamp = ctx->createSampler({
    .minFilter = lvk::SamplerFilter::SamplerFilter_Linear,
    .magFilter = lvk::SamplerFilter::SamplerFilter_Linear,
    .mipMap    = lvk::SamplerMip::SamplerMip_Linear,
    .wrapU     = lvk::SamplerWrap::SamplerWrap_Clamp,
    .wrapV     = lvk::SamplerWrap::SamplerWrap_Clamp,
    .wrapW     = lvk::SamplerWrap::SamplerWrap_Clamp,
    .debugName = “Clamp Sampler” });
  wrap = ctx->createSampler({
    .minFilter = lvk::SamplerFilter::SamplerFilter_Linear,
    .magFilter = lvk::SamplerFilter::SamplerFilter_Linear,
    .mipMap    = lvk::SamplerMip::SamplerMip_Linear,
    .wrapU     = lvk::SamplerWrap::SamplerWrap_Repeat,
    .wrapV     = lvk::SamplerWrap::SamplerWrap_Repeat,
    .wrapW     = lvk::SamplerWrap::SamplerWrap_Repeat,
    .debugName = “Wrap Sampler” });
  mirror = ctx->createSampler({
    .minFilter = lvk::SamplerFilter::SamplerFilter_Linear,
    .magFilter = lvk::SamplerFilter::SamplerFilter_Linear,
    .mipMap    = lvk::SamplerMip::SamplerMip_Linear,
    .wrapU     = lvk::SamplerWrap::SamplerWrap_MirrorRepeat,
    .wrapV     = lvk::SamplerWrap::SamplerWrap_MirrorRepeat,
    .debugName = “Mirror Sampler” });
  }
  1. 辅助结构 EnvironmentMapTextures 存储了所有 IBL 环境图纹理和 BRDF 查找表,为了简单起见提供了默认纹理:
struct EnvironmentMapTextures {
  lvk::Holder<lvk::TextureHandle> texBRDF_LUT;
  lvk::Holder<lvk::TextureHandle> envMapTexture;
  lvk::Holder<lvk::TextureHandle> envMapTextureCharlie;
  lvk::Holder<lvk::TextureHandle> envMapTextureIrradiance;
  1. 请参阅之前的配方 预计算辐照度图和漫反射卷积,了解如何预计算 IBL 纹理的详细信息。BRDF 查找表是在配方 预计算 BRDF 查找表 中预计算的。
 explicit EnvironmentMapTextures(
    const std::unique_ptr<lvk::IContext>& ctx) :
  EnvironmentMapTextures(ctx,
    “data/brdfLUT.ktx”,
    “data/piazza_bologni_1k_prefilter.ktx”,
    “data/piazza_bologni_1k_irradiance.ktx”,
    “data/piazza_bologni_1k_charlie.ktx”) {}
  EnvironmentMapTextures(
    const std::unique_ptr<lvk::IContext>& ctx,
    const char* brdfLUT,
    const char* prefilter,
    const char* irradiance,
    const char* prefilterCharlie = nullptr)
  {
    texBRDF_LUT = loadTexture(ctx, brdfLUT, lvk::TextureType_2D);
    envMapTexture = loadTexture(
      ctx, prefilter, lvk::TextureType_Cube);
    envMapTextureIrradiance = loadTexture(
      ctx, irradiance, lvk::TextureType_Cube);
  }
};
  1. 结构 GLTFMaterialTextures 包含了渲染我们演示中支持的任何 glTF 2.0 模型所需的所有纹理。它是一个包含多个 Holder<TextureHandle> 对象的容器,如下所示:
struct GLTFMaterialTextures {
  // MetallicRoughness / SpecularGlossiness     
  lvk::Holder<lvk::TextureHandle> baseColorTexture;
  lvk::Holder<lvk::TextureHandle> surfacePropertiesTexture;
  // Common properties
  lvk::Holder<lvk::TextureHandle> normalTexture;
  lvk::Holder<lvk::TextureHandle> occlusionTexture;
  lvk::Holder<lvk::TextureHandle> emissiveTexture;
  // Sheen
  lvk::Holder<lvk::TextureHandle> sheenColorTexture;
  lvk::Holder<lvk::TextureHandle> sheenRoughnessTexture;
  … many other textures go here
}
  1. 辅助函数 loadMaterialTextures() 不可共享,并且每个应用程序中都会有所不同。此函数的变体加载了金属-粗糙度演示所需纹理的子集:
GLTFMaterialTextures loadMaterialTextures(
  const std::unique_ptr<lvk::IContext>& ctx,
  const char* texAOFile,
  const char* texEmissiveFile,
  const char* texAlbedoFile,
  const char* texMeRFile,
  const char* texNormalFile)
{
  glTFMaterialTextures mat;
  mat.baseColorTexture = loadTexture(
    ctx, texAlbedoFile, lvk::TextureType_2D, true);
  if (mat.baseColorTexture.empty()) return {};     
  mat.occlusionTexture = loadTexture(ctx, texAOFile);
  if (mat.occlusionTexture.empty()) return {};     
  mat.normalTexture = loadTexture(ctx, texNormalFile);
  if (mat.normalTexture.empty()) return {};
  mat.emissiveTexture = loadTexture(
    ctx, texEmissiveFile, lvk::TextureType_2D, true);
  if (mat.emissiveTexture.empty()) return {};
  mat.surfacePropertiesTexture = loadTexture(ctx, texMeRFile);
  if (mat.surfacePropertiesTexture.empty()) return {};
  mat.wasLoaded = true;
  return mat;
}} 
  1. 一个重要步骤是加载材质数据并填写 MetallicRoughnessDataGPU 结构。我们将使用 Assimp API 获取材质属性并填写相应的值。glTF 规范要求非可选和可选属性有明确的默认值,因此我们也在这个片段中填写了它们。对于每个纹理,我们读取并设置采样状态和 uv 坐标索引的数据:
struct MetallicRoughnessData {
  vec4 baseColorFactor = vec4(1.0f, 1.0f, 1.0f, 1.0f);
  1. 在这里,我们将 metallicFactorroughnessFactornormalScaleocclusionStrength glTF 属性打包到一个 vec4 成员字段 metallicRoughnessNormalOcclusion 中。

  2. 我们这样做是为了进行非常基本的优化。GPU 将此数据存储在向量寄存器中,如果我们将所有参数打包到一个单一的 vec4 值中,读取它将更加高效。另一个原因是避免任何额外的对齐要求,特别是对于 vec3 类型。类似的打包也用于 vec3 glTF 属性 emissiveFactor 和一个 floatalphaCutoff,它们都被打包到一个单一的 vec4 值中:

 vec4 metallicRoughnessNormalOcclusion =
    vec4(1.0f, 1.0f, 1.0f, 1.0f);
  vec4 emissiveFactorAlphaCutoff = vec4(0.0f, 0.0f, 0.0f, 0.5f);
  1. 其他成员字段持有用于我们的无绑定着色器的纹理和采样器 ID。除了 0 以外没有默认值:
 uint32_t occlusionTexture        = 0;
  uint32_t occlusionTextureSampler = 0;
  uint32_t occlusionTextureUV      = 0;
  uint32_t emissiveTexture         = 0;
  uint32_t emissiveTextureSampler  = 0;
  uint32_t emissiveTextureUV       = 0;
  uint32_t baseColorTexture        = 0;
  uint32_t baseColorTextureSampler = 0;
  uint32_t baseColorTextureUV              = 0;
  uint32_t metallicRoughnessTexture        = 0;
  uint32_t metallicRoughnessTextureSampler = 0;
  uint32_t metallicRoughnessTextureUV      = 0;
  uint32_t normalTexture        = 0;
  uint32_t normalTextureSampler = 0;
  uint32_t normalTextureUV      = 0;
  1. alphaMode 属性定义了如何解释 alpha 值。alpha 值本身应从金属-粗糙度材质模型的基色 4-th 组件中获取:
 uint32_t alphaMode = 0;
  enum AlphaMode {
    AlphaMode_Opaque = 0,
    AlphaMode_Mask   = 1,
    AlphaMode_Blend  = 2,
  };
};
  1. 使用辅助函数 setupMetallicRoughnessData() 填充 MetallicRoughnessDataGPU 结构。上面讨论了 GLTFMaterialTextures 结构:
MetallicRoughnessDataGPU setupMetallicRoughnessData(
  const GLTFGlobalSamplers& samplers,
  const GLTFMaterialTextures& mat,
  const aiMaterial* mtlDescriptor)
{
  MetallicRoughnessDataGPU res = {
    .baseColorFactor              = vec4(1.0f, 1.0f, 1.0f, 1.0f),
    .metallicRoughnessNormalOcclusion =
      vec4(1.0f, 1.0f, 1.0f, 1.0f),
    .emissiveFactorAlphaCutoff    = vec4(0.0f, 0.0f, 0.0f, 0.5f),
    .occlusionTexture             = mat.occlusionTexture.index(),
    .emissiveTexture              = mat.emissiveTexture.index(),
    .baseColorTexture             = mat.baseColorTexture.index(),
    .metallicRoughnessTexture     =
      mat.surfacePropertiesTexture.index(),
    .normalTexture                = mat.normalTexture.index(),
  };
  1. 函数的其余部分继续读取各种 glTF 材质属性,使用 Assimp API。我们在这里粘贴其代码的起始部分。所有其他材质属性都以类似重复的模式加载:
 aiColor4D aiColor;
  if (mtlDescriptor->Get(AI_MATKEY_COLOR_DIFFUSE, aiColor) ==
      AI_SUCCESS) {
    res.baseColorFactor = vec4(
      aiColor.r, aiColor.g, aiColor.b, aiColor.a);
  }
  assignUVandSampler(samplers,
    mtlDescriptor,
    aiTextureType_DIFFUSE,
    res.baseColorTextureUV,
    res.baseColorTextureSampler);
  … many other glTF material properties are loaded here
  1. 辅助函数 assignUVandSampler() 的样子如下:
bool assignUVandSampler(
  const GLTFGlobalSamplers& samplers,
  const aiMaterial* mtlDescriptor,
  aiTextureType textureType,
  uint32_t& uvIndex,
  uint32_t& textureSampler, int index)
{
  aiString path;
  aiTextureMapMode mapmode[3] = {
    aiTextureMapMode_Clamp,
    aiTextureMapMode_Clamp,
    aiTextureMapMode_Clamp };
  bool res = mtlDescriptor->GetTexture(textureType, index,
    &path, 0, &uvIndex, 0, 0, mapmode) == AI_SUCCESS;
  switch (mapmode[0]) {
    case aiTextureMapMode_Clamp:
      textureSampler = samplers.clamp.index();
      break;
    case aiTextureMapMode_Wrap:
      textureSampler = samplers.wrap.index();
      break;
    case aiTextureMapMode_Mirror:
      textureSampler = samplers.mirror.index();
      break;
  }
  return res;
}
  1. 现在,让我们通过 main() 函数来分析:

  2. 首先,我们使用 Assimp 加载 glTF 文件。我们只支持三角化拓扑;因此,使用标志 aiProcess_Triangulate 指示 Assimp 在导入期间对网格进行三角化:

const aiScene* scene = aiImportFile(“deps/src/glTF-Sample-
  Assets/Models/DamagedHelmet/glTF/DamagedHelmet.gltf”,
  aiProcess_Triangulate);
const aiMesh* mesh = scene->mMeshes[0];
const vec4 white = vec4(1.0f, 1.0f, 1.0f, 1.0f);
  1. 我们填充顶点数据。结构 Vertex 在所有 glTF 演示中共享,并在 shared/UtilsGLTF.h 中声明:
struct Vertex {
  vec3 position;
  vec3 normal;
  vec4 color;
  vec2 uv0;
  vec2 uv1;
};
std::vector<Vertex> vertices;
for (     uint32_t i = 0; i != mesh->mNumVertices; i++) {
  const aiVector3D v   = mesh->mVertices[i];
  const aiVector3D n   = mesh->mNormals ?
    mesh->mNormals[i] : aiVector3D(0.0f, 1.0f, 0.0f);
  const aiColor4D  c   = mesh->mColors[0] ?
    mesh->mColors[0][i] : aiColor4D(1.0f, 1.0f, 1.0f, 1.0f);
  1. glTF 模型通常使用两组 UV 纹理坐标。第一组,uv0,用于主纹理映射,例如漫反射颜色、镜面反射或法线映射。这些坐标通常用于表面细节和颜色信息。第二组,uv1,通常用于光照图或反射图。这些图通常需要单独的纹理坐标来正确映射到模型上,与主纹理坐标不同。glTF 规范指出,查看器应用程序应至少支持两组纹理坐标集:
 const aiVector3D uv0 = mesh->mTextureCoords[0] ?
    mesh->mTextureCoords[0][i] : aiVector3D(0.0f, 0.0f, 0.0f);
  const aiVector3D uv1 = mesh->mTextureCoords[1] ?
    mesh->mTextureCoords[1][i] : aiVector3D(0.0f, 0.0f, 0.0f);
  vertices.push_back({ .position = vec3(v.x, v.y, v.z),
                       .normal   = vec3(n.x, n.y, n.z),
                       .color    = vec4(c.r, c.g, c.b, c.a),
                       .uv0      = vec2(uv0.x, 1.0f - uv0.y),
                       .uv1      = vec2(uv1.x, 1.0f - uv1.y) });
}
  1. 让我们设置定义我们的三角形的索引,并将生成的顶点和索引数据上传到相应的缓冲区:
std::vector<uint32_t> indices;
for (unsigned int i = 0; i != mesh->mNumFaces; i++)
  for (int j = 0; j != 3; j++)
    indices.push_back(mesh->mFaces[i].mIndices[j]);
lvk::Holder<BufferHandle> vertexBuffer = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Vertex,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(Vertex) * vertices.size(),
  .data      = vertices.data(),
  .debugName = “Buffer: vertex” });
lvk::Holder<lvk::BufferHandle> indexBuffer = ctx->createBuffer({
  .usage     = lvk::BufferUsageBits_Index,
  .storage   = lvk::StorageType_Device,
  .size      = sizeof(uint32_t) * indices.size(),
  .data      = indices.data(),
  .debugName = “Buffer: index” });
  1. 下一步是加载所有材质纹理。我们的大多数 glTF 演示使用相同的纹理组合,因此我们将它们存储在结构 GLTFMaterialTextures 中,该结构在 shared/UtilsGLTF.h 中声明:
std::unique_ptr<GLTFMaterialTextures> mat =
  loadMaterialTextures(ctx,
    “deps/src/glTF-Sample-Assets/Models/
      DamagedHelmet/glTF/Default_AO.jpg”,
    “deps/src/glTF-Sample-Assets/Models/
      DamagedHelmet/glTF/Default_emissive.jpg”,
    “deps/src/glTF-Sample-Assets/Models/
      DamagedHelmet/glTF/Default_albedo.jpg”,
    “deps/src/glTF-Sample-Assets/Models/
      DamagedHelmet/glTF/Default_metalRoughness.jpg”,
    “deps/src/glTF-Sample-Assets/Models/
      DamagedHelmet/glTF/Default_normal.jpg”);
  1. 在我们继续创建图形管线和渲染之前,我们必须设置 IBL 样本、纹理和 BRDF 查找表。这些数据在所有我们的演示中是共享的,因此我们引入了一些辅助结构来为我们完成所有这些工作。以下是 main() 函数中的定义:
GLTFGlobalSamplers samplers(ctx);
EnvironmentMapTextures envMapTextures(ctx);
  1. 下一步是为我们的 glTF 渲染创建一个渲染管线步骤。我们必须提供一个顶点输入描述。以下是为我们的模型创建一个描述的方法:
 const lvk::VertexInput vdesc = {
    .attributes    = {
      { .location=0, .format=VertexFormat::Float3, .offset=0  },
      { .location=1, .format=VertexFormat::Float3, .offset=12 },
      { .location=2, .format=VertexFormat::Float4, .offset=24 },
      { .location=3, .format=VertexFormat::Float2, .offset=40 },
      { .location=4, .format=VertexFormat::Float2, .offset=48 }},
    .inputBindings = { { .stride = sizeof(Vertex) } },
  };
  1. 渲染管线应按以下方式创建。我们将在 How it works… 部分研究 GLSL 着色器:
 lvk::Holder<lvk::ShaderModuleHandle> vert = loadShaderModule(
    ctx, “Chapter06/04_MetallicRoughness/src/main.vert”);
  lvk::Holder<lvk::ShaderModuleHandle> frag = loadShaderModule(
    ctx, “Chapter06/04_MetallicRoughness/src/main.frag”);
  lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid =
    ctx->createRenderPipeline({
      .vertexInput = vdesc,
      .smVert      = vert,
      .smFrag      = frag,
      .color       = { { .format = ctx->getSwapchainFormat() } },
      .depthFormat = app.getDepthFormat(),
      .cullMode    = lvk::CullMode_Back,
    });
  1. 我们可以调用 setupMetallicRoughnessData() 来从 glTF 加载所有材质数据,并在 CPU 端正确打包:
 const aiMaterial* mtlDescriptor =
    scene->mMaterials[mesh->mMaterialIndex];
  const MetallicRoughnessMaterialsPerFrame matPerFrame = {
    .materials = { setupMetallicRoughnessData(
                     samplers, mat, mtlDescriptor) },
  };
  1. 我们将材质数据存储在一个专用的 Vulkan 缓冲区中,并在 GLSL 着色器中使用缓冲区设备地址访问它。此地址通过 Vulkan 推送常量传递到着色器中:
 lvk::Holder<lvk::BufferHandle> matBuffer = ctx->createBuffer({
    .usage     = lvk::BufferUsageBits_Uniform,
    .storage   = lvk::StorageType_HostVisible,
    .size      = sizeof(matPerFrame),
    .data      = &matPerFrame,
    .debugName = “PerFrame materials” });
  1. 相同的处理也适用于我们的环境纹理。它们也应该为 GPU 打包:
 const EnvironmentsPerFrame envPerFrame = {
    .environments = { {
      .envMapTexture =
        envMapTextures.envMapTexture.index(),
      .envMapTextureSampler = samplers.clamp.index(),
      .envMapTextureIrradiance =
        envMapTextures.envMapTextureIrradiance.index(),
      .envMapTextureIrradianceSampler = samplers.clamp.index(),
      .lutBRDFTexture = envMapTextures.texBRDF_LUT.index(),
      .lutBRDFTextureSampler = samplers.clamp.index() } },
  };
  lvk::Holder<lvk::BufferHandle> envBuffer = ctx->createBuffer({
    .usage     = lvk::BufferUsageBits_Uniform,
    .storage   = lvk::StorageType_HostVisible,
    .size      = sizeof(envPerFrame),
    .data      = &envPerFrame,
    .debugName = “PerFrame materials” });
  1. 允许的最大推送常量大小为 128 字节。为了处理超出此大小的数据,我们将设置几个循环缓冲区:
 struct PerDrawData {
    mat4 model;
    mat4 view;
    mat4 proj;
    vec4 cameraPos;
    uint32_t matId;
    uint32_t envId;
  };
  lvk::Holder<lvk::BufferHandle> drawableBuffers[2] = {
    ctx->createBuffer({
          .usage     = lvk::BufferUsageBits_Uniform,
          .storage   = lvk::StorageType_HostVisible,
          .size      = sizeof(PerDrawData),
          .debugName = “PerDraw 1” }),
    ctx->createBuffer({
          .usage     = lvk::BufferUsageBits_Uniform,
          .storage   = lvk::StorageType_HostVisible,
          .size      = sizeof(PerDrawData),
          .debugName = “PerDraw 2” }),
  };
  1. 其他一切都是网格渲染,类似于前几章中执行的方式。以下是生成渲染 glTF 网格的 draw 命令的方式:
buf.cmdBindVertexBuffer(0, vertexBuffer, 0);
buf.cmdBindIndexBuffer(indexBuffer, lvk::IndexFormat_UI32);
buf.cmdBindRenderPipeline(pipelineSolid);
buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                        .isDepthWriteEnabled = true });
struct PerFrameData {
  uint64_t draw;
  uint64_t materials;
  uint64_t environments;
} perFrameData = {
  .draw        = ctx->gpuAddress(drawableBuffers[currentBuffer]),
  .materials   = ctx->gpuAddress(matBuffer),
  .environments= ctx->gpuAddress(envBuffer),
};
buf.cmdPushConstants(perFrameData);
buf.cmdDrawIndexed(indices.size());
…

让我们跳过其余的 C++ 代码,这些代码包含平凡的命令缓冲区提交和其他框架,并检查 GLSL 着色器是如何工作的。

它是如何工作的…

有两个 GLSL 着色器用于渲染我们的金属-粗糙度 PBR 模型,一个是顶点着色器 Chapter06/04_MetallicRoughness/src/main.vert,另一个是片段着色器 Chapter06/04_MetallicRoughness/src/main.frag,它们包括用于共享输入声明和我们的 glTF PBR 代码 GLSL 库的附加文件。顶点着色器使用可编程顶点提取来从缓冲区读取顶点数据。顶点着色器最重要的方面是我们定义了自己的函数,例如 getModel()getTexCoord(),以隐藏顶点提取的实现细节。这允许我们在想要更改输入数据结构时更加灵活。我们为片段着色器采用类似的方法。

实际工作是由片段着色器完成的。让我们看看:

  1. 首先,我们检查我们的输入。我们将使用与 C++ 结构 MetallicRoughnessDataGPUEnvironmentMapDataGPU 对应的材料和环境缓冲区的引用:
layout(std430, buffer_reference) buffer Materials;
layout(std430, buffer_reference) buffer Environments;
layout(std430, buffer_reference) buffer PerDrawData {
  mat4 model;
  mat4 view;
  mat4 proj;
  vec4 cameraPos;
  uint matId;
  uint envId;
};
  1. 我们使用四个辅助函数,getMaterialId()getMaterial()getEnvironmentId()getEnvironment(),作为访问推送常量中提供的缓冲区引用的快捷方式:
layout(push_constant) uniform PerFrameData {
  PerDrawData drawable;
  Materials materials;
  Environments environments;
} perFrame;
uint getMaterialId() {
  return perFrame.drawable.matId;
}
uint getEnvironmentId() {
  return perFrame.drawable.envId;
}
MetallicRoughnessDataGPU getMaterial(uint idx) {
  return perFrame.materials.material[idx];
}
EnvironmentMapDataGPU getEnvironment(uint idx) {
  return perFrame.environments.environment[idx];
}
  1. 在文件 Chapter06/04_MetallicRoughness/src/inputs.frag 中,有一系列辅助函数,例如 sampleAO()samplerEmissive()sampleAlbedo() 以及许多其他函数,它们根据材料 mat 从各种 glTF PBR 纹理贴图中采样。所有这些函数都使用无绑定纹理和采样器:
vec4 sampleAO(InputAttributes tc, MetallicRoughnessDataGPU mat) {
  return textureBindless2D(
    mat.occlusionTexture,
    mat.occlusionTextureSampler,
    tc.uv[mat.occlusionTextureUV]);
}
vec4 sampleEmissive(
  InputAttributes tc, MetallicRoughnessDataGPU mat) {
  return textureBindless2D(
      mat.emissiveTexture,
      mat.emissiveTextureSampler,
      tc.uv[mat.emissiveTextureUV]
    ) * vec4(mat.emissiveFactorAlphaCutoff.xyz, 1.0f);
}
vec4 sampleAlbedo(
  InputAttributes tc, MetallicRoughnessDataGPU mat) {
  return textureBindless2D(
    mat.baseColorTexture,
    mat.baseColorTextureSampler,
    tc.uv[mat.baseColorTextureUV]) * mat.baseColorFactor;
}
  1. 在片段着色器的 main() 函数中,我们使用这些辅助函数根据 getMaterialId() 返回的材料 ID 值采样纹理贴图:
layout (location=0) in vec4 uv0uv1;
layout (location=1) in vec3 normal;
layout (location=2) in vec3 worldPos;
layout (location=3) in vec4 color;
layout (location=0) out vec4 out_FragColor;
void main() {
  InputAttributes tc;
  tc.uv[0] = uv0uv1.xy;
  tc.uv[1] = uv0uv1.zw;
  MetallicRoughnessDataGPU mat = getMaterial(getMaterialId());
  vec4 Kao = sampleAO(tc, mat);
  vec4 Ke  = sampleEmissive(tc, mat);
  vec4 Kd  = sampleAlbedo(tc, mat) * color;
  vec4 mrSample = sampleMetallicRoughness(tc, mat);
  1. 为了根据提供的法线贴图计算适当的标准法线映射效果,我们评估每个像素的法线向量。我们在世界空间中执行此操作。法线贴图位于切线空间中。因此,perturbNormal() 函数使用纹理坐标的导数来计算每个像素的切线空间,该函数在 data/shaders/UtilsPBR.sp 中实现,并将扰动的法线转换到世界空间。

    最后一步是对双面材料的法线取反。我们使用 gl_FrontFacing 内置变量进行检查:

 vec3 n = normalize(normal); // world-space normal
  vec3 normalSample = sampleNormal(tc, getMaterialId()).xyz;
  n = perturbNormal(
    n, worldPos, normalSample, getNormalUV(tc, mat));
  if (!gl_FrontFacing) n *= -1.0f;
  1. 现在,我们准备填写 PBRInfo 结构,该结构包含多个输入,这些输入随后在 PBR 着色方程中的各种函数中使用:
 PBRInfo pbrInputs = calculatePBRInputsMetallicRoughness(
    Kd, n, perFrame.drawable.cameraPos.xyz, worldPos, mrSample);
  1. 下一步是计算 IBL 环境光照的镜面和漫反射颜色贡献。我们可以直接将diffuse_colorspecular_color相加,因为我们的预计算的 BRDF LUT 已经处理了能量守恒:
 vec3 specular_color =
    getIBLRadianceContributionGGX(pbrInputs, 1.0);
  vec3 diffuse_color = getIBLRadianceLambertian(
    pbrInputs.NdotV, n, pbrInputs.perceptualRoughness,
    pbrInputs.diffuseColor, pbrInputs.reflectance0, 1.0);
  vec3 color = specular_color + diffuse_color;
  1. 对于这个演示应用程序,我们只使用一个硬编码的方向性光源(0, 0, -5)。让我们计算它的光照贡献:
 vec3 lightPos = vec3(0, 0, -5);
  color += calculatePBRLightContribution(
    pbrInputs, normalize(lightPos - worldPos), vec3(1.0) );
  1. 现在,我们应该将颜色乘以环境遮蔽因子。如果没有环境遮蔽纹理可用,则使用1.0
 color = color * ( Kao.r < 0.01 ? 1.0 : Kao.r );
  1. 最后,我们应用发射颜色贡献。在写入帧缓冲区输出之前,我们使用硬编码的伽玛值2.2将结果颜色转换回 sRGB 颜色空间:
 color = pow( Ke.rgb + color, vec3(1.0/2.2) );
  out_FragColor = vec4(color, 1.0);
}

我们提到了一些使用PBRInfo结构的辅助函数,例如getIBLRadianceContributionGGX()getIBLRadianceLambertian()calculatePBRLightContribution()。让我们查看Chapter06/04_MetallicRoughness/src/PBR.sp文件,看看它们是如何工作的。我们的实现基于 Khronos 的 glTF 2.0 Sample Viewer 的参考实现:github.com/KhronosGroup/glTF-Sample-Viewer/tree/glTF-WebGL-PBR:

  1. 首先,这是PBRInfo结构,它包含我们金属-粗糙度 glTF PBR 着色模型的多个输入参数。前几个值代表当前点的表面几何属性:
struct PBRInfo {
  float NdotL; // cos angle between normal and light direction
  float NdotV; // cos angle between normal and view direction
  float NdotH; // cos angle between normal and half vector
  float LdotH; // cos angle between light dir and half vector
  float VdotH; // cos angle between view dir and half vector
  vec3 n;      // normal at surface point
  vec3 v;      // vector from surface point to camera
  1. 以下值代表材料属性:
 float perceptualRoughness; // roughness value (input to shader)
  vec3 reflectance0;    // full reflectance color
  vec3 reflectance90;   // reflectance color at grazing angle
  float alphaRoughness; // remapped linear roughness
  vec3 diffuseColor;    // contribution from diffuse lighting
  vec3 specularColor;   // contribution from specular lighting
};
  1. sRGB 到线性颜色空间的转换例程是这样实现的。这是一个为了简单而做的流行近似:
vec4 SRGBtoLINEAR(vec4 srgbIn) {
  vec3 linOut = pow( srgbIn.xyz,vec3(2.2) );
  return vec4(linOut, srgbIn.a);
}
  1. 基于图像的光源的光照贡献计算分为两部分——漫反射辐照度和镜面辐射率。首先,让我们从辐射率部分开始。我们将使用 Lambertian 漫反射项。Khronos 的实现相当复杂;在这里,我们将跳过其中的一些细节。对于想要了解底层数学理论的读者,请参阅bruop.github.io/ibl/#single_scattering_results:
vec3 getIBLRadianceLambertian(float NdotV, vec3 n,
  float roughness, vec3 diffuseColor, vec3 F0,
  float specularWeight)
{
  vec2 brdfSamplePoint =
    clamp(vec2(NdotV, roughness), vec2(0., 0.), vec2(1., 1.));
  EnvironmentMapDataGPU envMap =
    getEnvironment(getEnvironmentId());
  vec2 f_ab =
    sampleBRDF_LUT(brdfSamplePoint, envMap).rg;
  vec3 irradiance =
    sampleEnvMapIrradiance(n.xyz, envMap).rgb;
  vec3 Fr = max(vec3(1.0 - roughness), F0) - F0;
  vec3 k_S = F0 + Fr * pow(1.0 - NdotV, 5.0);
  vec3 FssEss = specularWeight * k_S * f_ab.x + f_ab.y;
  float Ems = (1.0 - (f_ab.x + f_ab.y));
  vec3 F_avg = specularWeight * (F0 + (1.0 - F0) / 21.0);
  vec3 FmsEms = Ems * FssEss * F_avg / (1.0 - F_avg * Ems);
  vec3 k_D = diffuseColor * (1.0 - FssEss + FmsEms);
  return (FmsEms + k_D) * irradiance;
}
  1. 辐射率贡献使用 GGX 模型。请注意,我们将粗糙度用作预计算米普查找的 LOD 级别。这个技巧允许我们节省性能,避免过多的纹理查找和积分:
vec3 getIBLRadianceContributionGGX(
  PBRInfo pbrInputs, float specularWeight)
{
  vec3 n = pbrInputs.n;
  vec3 v =  pbrInputs.v;
  vec3 reflection = -normalize(reflect(v, n));
  EnvironmentMapDataGPU envMap =
    getEnvironment(getEnvironmentId());
  float mipCount =
    float(sampleEnvMapQueryLevels(envMap));
  float lod = pbrInputs.perceptualRoughness * (mipCount - 1);
  1. 从 BRDF 查找表中检索F0的缩放和偏移量:
 vec2 brdfSamplePoint = clamp(
    vec2(pbrInputs.NdotV, pbrInputs.perceptualRoughness),
    vec2(0.0, 0.0),
    vec2(1.0, 1.0));
  vec3 brdf =
    sampleBRDF_LUT(brdfSamplePoint, envMap).rgb;
  1. 从立方体贴图中获取值。不需要转换为线性颜色空间,因为 HDR 立方体贴图已经是线性的:
 vec3 specularLight =
    sampleEnvMapLod(reflection.xyz, lod, envMap).rgb;
  vec3 Fr = max(vec3(1.0 - pbrInputs.perceptualRoughness),
                pbrInputs.reflectance0
            ) - pbrInputs.reflectance0;
  vec3 k_S =
    pbrInputs.reflectance0 + Fr * pow(1.0-pbrInputs.NdotV, 5.0);
  vec3 FssEss = k_S * brdf.x + brdf.y;
  return specularWeight * specularLight * FssEss;
}

现在,让我们逐一查看所有必要的辅助函数,这些函数用于计算渲染方程的不同部分:

  1. 函数 diffuseBurley() 实现了漫反射项,如布伦特·伯利在论文《迪士尼的基于物理的着色》中所述:blog.selfshadow.com/publications/s2012-shading-course/burley/s2012_pbs_disney_brdf_notes_v3.pdf
vec3 diffuseBurley(PBRInfo pbrInputs) {
  float f90 = 2.0 * pbrInputs.LdotH * pbrInputs.LdotH *
    pbrInputs.alphaRoughness - 0.5;
  return (pbrInputs.diffuseColor / M_PI) * 
    (1.0 + f90 * pow((1.0 - pbrInputs.NdotL), 5.0)) *
    (1.0 + f90 * pow((1.0 - pbrInputs.NdotV), 5.0));
}
  1. 下一个函数模拟了渲染方程中的菲涅耳镜面反射率项,也称为 F 项:
vec3 specularReflection(PBRInfo pbrInputs) {
  return pbrInputs.reflectance0 +
    (pbrInputs.reflectance90 - pbrInputs.reflectance0) *
     pow(clamp(1.0 - pbrInputs.VdotH, 0.0, 1.0), 5.0);
}
  1. 函数 geometricOcclusion() 计算镜面几何衰减 G,其中粗糙度较高的材料将反射较少的光线给观察者:
float geometricOcclusion(PBRInfo pbrInputs) {
  float NdotL = pbrInputs.NdotL;
  float NdotV = pbrInputs.NdotV;
  float rSqr =
    pbrInputs.alphaRoughness * pbrInputs.alphaRoughness;
  float attenuationL = 2.0 * NdotL /
    (NdotL + sqrt(rSqr + (1.0 - rSqr) * (NdotL * NdotL)));
  float attenuationV = 2.0 * NdotV /
    (NdotV + sqrt(rSqr + (1.0 - rSqr) * (NdotV * NdotV)));
  return attenuationL * attenuationV;
}
  1. 函数 microfacetDistribution() 模拟了正在绘制区域上微面法线分布 D
float microfacetDistribution(PBRInfo pbrInputs) {
  float roughnessSq =
    pbrInputs.alphaRoughness * pbrInputs.alphaRoughness;
  float f = (pbrInputs.NdotH * roughnessSq - pbrInputs.NdotH) *
    pbrInputs.NdotH + 1.0;
  return roughnessSq / (M_PI * f * f);
}
  1. 此实现基于 T. S. Trowbridge 和 K. P. Reitz 的论文《粗糙化表面的平均不规则性表示法,用于光线反射》:

  2. 实用函数 perturbNormal() 根据输入提供世界空间中的法线。它期望从法线图 normalSample 中采样,采样在 uv 纹理坐标上,一个顶点法线 n 和一个顶点位置 v

vec3 perturbNormal(vec3 n, vec3 v, vec3 normalSample, vec2 uv) {
  vec3 map = normalize( 2.0 * normalSample - vec3(1.0) );
  mat3 TBN = cotangentFrame(n, v, uv);
  return normalize(TBN * map);
}
  1. 函数 cotangentFrame() 基于顶点位置 p、每个顶点的法线向量 Nuv 纹理坐标创建切线空间。这不是获取切线基的最佳方式,因为它受到 uv 映射不连续性的影响,但在没有提供每个顶点预先计算的切线基的情况下,使用它是可以接受的:
mat3 cotangentFrame( vec3 N, vec3 p, vec2 uv ) {
  vec3 dp1 = dFdx( p );
  vec3 dp2 = dFdy( p );
  vec2 duv1 = dFdx( uv );
  vec2 duv2 = dFdy( uv );
  vec3 dp2perp = cross( dp2, N );
  vec3 dp1perp = cross( N, dp1 );
  vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
  vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
  float invmax = inversesqrt( max( dot(T,T), dot(B,B) ) );
  1. 计算结果切线框架的右手性,并在必要时调整切线向量:
 float w = dot(cross(N, T), B) < 0.0 ? -1.0 : 1.0;
  T = T * w;
  return mat3( T * invmax, B * invmax, N );
}
  1. 有很多所谓的脚手架是必要的,以实现 glTF PBR 着色模型。在我们能够从光源计算光贡献之前,我们应该填写 PBRInfo 结构的字段。让我们看看 calculatePBRInputsMetallicRoughness() 函数的代码,以了解这是如何完成的:

  2. 正如 glTF 2.0 中所预期的,粗糙度存储在 green 通道中,而金属度存储在 blue 通道中。这种布局有意保留了 red 通道用于可选的遮挡图数据。我们将最小粗糙度值设置为 0.04。这是许多 PBR 实现中广泛使用的常数,它来源于即使介电体至少有 4% 的镜面反射的假设:

PBRInfo calculatePBRInputsMetallicRoughness( vec4 albedo,
  vec3 normal, vec3 cameraPos, vec3 worldPos, vec4 mrSample)
{
  PBRInfo pbrInputs;
  MetallicRoughnessDataGPU mat = getMaterial(getMaterialId());
  float perceptualRoughness = 
    getRoughnessFactor(mat) * mrSample.g;
  float metallic = getMetallicFactor(mat) * mrSample.b;
  const float c_MinRoughness = 0.04;
  perceptualRoughness =
    clamp(perceptualRoughness, c_MinRoughness, 1.0);
  1. 粗糙度被编写为感知粗糙度;按照惯例,我们通过平方感知粗糙度将其转换为材料粗糙度。感知粗糙度是由 Burley (disneyanimation.com/publications/physically-based-shading-at-disney) 引入的,以使粗糙度分布更加线性。漫反射值可以从基础纹理或纯色中定义。让我们以下这种方式计算镜面反射率:
 float alphaRoughness = perceptualRoughness *
                         perceptualRoughness;
  vec4 baseColor = albedo;
  vec3 f0 = vec3(0.04);
  vec3 diffuseColor = mix(baseColor.rgb, vec3(0), metallic);
  vec3 specularColor = mix(f0, baseColor.rgb, metallic);
  float reflectance =
    max(max(specularColor.r, specularColor.g), specularColor.b);
  1. 对于典型的入射反射率范围在 4% 到 100% 之间,我们应该将掠射反射率设置为 100% 以获得典型的菲涅耳效应。对于高度漫反射物体上的非常低的反射率范围,低于 4%,逐步将掠射反射率降低到 0%:
 float reflectance90 = clamp(reflectance * 25.0, 0.0, 1.0);
  vec3 specularEnvironmentR0 = specularColor.rgb;
  vec3 specularEnvironmentR90 =
    vec3(1.0, 1.0, 1.0) * reflectance90;
  vec3 n = normalize(normal);
  vec3 v = normalize(cameraPos - worldPos);
  1. 最后,我们应该用这些值填充 PBRInfo 结构。它用于计算场景中每个单独光线的贡献:
 pbrInputs.NdotV = clamp(abs(dot(n, v)), 0.001, 1.0);
  pbrInputs.perceptualRoughness = perceptualRoughness;
  pbrInputs.reflectance0 = specularEnvironmentR0;
  pbrInputs.reflectance90 = specularEnvironmentR90;
  pbrInputs.alphaRoughness = alphaRoughness;
  pbrInputs.diffuseColor = diffuseColor;
  pbrInputs.specularColor = specularColor;
  pbrInputs.n = n;
  pbrInputs.v = v;
  return pbrInputs;
}
  1. 可以使用以下方式计算单个光源的光照贡献,使用从 PBRInfo 预先计算出的值:

  2. 这里,ld 是从表面点到光源的向量,而 hldv 之间的半向量:

vec3 calculatePBRLightContribution(
  inout PBRInfo pbrInputs, vec3 lightDirection, vec3 lightColor)
{
  vec3 n = pbrInputs.n;
  vec3 v = pbrInputs.v;
  vec3 ld = normalize(lightDirection);
  vec3 h = normalize(ld +  v);
  float NdotV = pbrInputs.NdotV;
  float NdotL = clamp(dot(n, ld), 0.001, 1.0);
  float NdotH = clamp(dot(n, h), 0.0, 1.0);
  float LdotH = clamp(dot(ld, h), 0.0, 1.0);
  float VdotH = clamp(dot(v, h), 0.0, 1.0);
  vec3 color = vec3(0);
  1. 检查光的方向是否正确,并使用本食谱中前面描述的辅助函数计算微 facet 镜面反射光照模型的阴影项 FGD
 if (NdotL > 0.0 || NdotV > 0.0) {
    pbrInputs.NdotL = NdotL;
    pbrInputs.NdotH = NdotH;
    pbrInputs.LdotH = LdotH;
    pbrInputs.VdotH = VdotH;
    vec3  F = specularReflection(pbrInputs);
    float G = geometricOcclusion(pbrInputs);
    float D = microfacetDistribution(pbrInputs);
  1. 计算分析光照贡献。我们得到最终强度作为反射率(BRDF),并按光的能量(余弦定律)进行缩放:
 vec3 diffuseContrib = (1.0 - F) * diffuseBurley(pbrInputs);
    vec3 specContrib = F * G * D / (4.0 * NdotL * NdotV);
    color = NdotL * lightColor * (diffuseContrib + specContrib);
  }
  return color;
}
  1. 就这些了,应该足够实现 glTF 金属-粗糙度 PBR 光照模型。生成的演示应用程序应该渲染以下图像。也可以尝试使用不同的 glTF 2.0 文件:

图 6.8:损坏头盔 glTF 2.0 模型的基于物理的渲染

图 6.8:损坏头盔 glTF 2.0 模型的基于物理的渲染

还有更多...

基于物理的渲染的整个领域非常广泛,鉴于本书的篇幅限制,我们只能触及其表面。在现实生活中,可以创建更复杂的 PBR 实现,这些实现通常基于内容制作流程的要求。为了获得无尽的灵感,我们建议查看 GitHub 上免费提供的 Unreal Engine 源代码:github.com/EpicGames/UnrealEngine/tree/release/Engine/Shaders/Private

在下一个食谱中,我们将探索另一个重要的 PBR 光照模型,即 glTF 2.0 镜面-光泽度模型,并为其实现一个演示应用程序。

实现 glTF 2.0 镜面-光泽度光照模型

镜面光泽度是官方 Khronos 存储库中已弃用和存档的扩展,但我们要演示如何使用它,因为它仍然适用于许多现有资产。在下一章中,我们将介绍一个新的 glTF 镜面扩展,它取代了这个旧的镜面光泽度着色模型。我们将展示如何将旧的镜面光泽度转换为新的镜面扩展。镜面光泽度模型最初被添加到 glTF PBR 中作为一个扩展,以解决艺术驱动的方法。例如,游戏开发通常需要更大的灵活性来控制镜面效果的确切性,而在这种情况下调整光泽度是一个常见的需求。后来,glTF PBR 着色模型收到了更多高级扩展,与最初的这一扩展相矛盾。引入了一个新的镜面扩展,以提供与标准金属-粗糙度模型类似的功能。因此,Khronos 建议停止使用此扩展,并将其存档。然而,我们将探讨它,考虑到许多现有的 3D 模型都是使用这个镜面光泽度着色模型创建的。

什么是镜面光泽度?

如您从上面的 PBR 部分注意到的那样,PBR 并不强制规定材质模型应该如何表达。金属-粗糙度模型使用一种简化和直观的方式来描述任何表面为非金属-金属和光滑-粗糙。这些参数仍然不符合物理规律,但它们可以表达我们周围现实物体中广泛的各种材料。在某些情况下,仅表达材料外观的变化是不够的,而镜面光泽度模型提供了这种灵活性。

镜面光泽度工作流程是一种基于材质镜面特征的表征技术。在这个方法中,材质使用三个图来描述:漫反射图、镜面图和光泽度图。漫反射图决定了材质的颜色,镜面图定义了其反射性或材质的镜面颜色,光泽度图定义了其光泽度或光滑度。

金属-粗糙度工作流程和镜面光泽度工作流程之间的一个区别在于指定材料在 RGB 通道中反射性的镜面属性。每个通道——红色、绿色和蓝色——代表不同角度的反射性,与金属-粗糙度相比,提供了更广泛的各种材质外观。另一个区别是,这个属性有一个重要的流程,不能在 glTF PBR 模型中使用。由于其本质,这个值不能区分介电体和金属,这也是为什么大多数 glTF PBR 扩展与镜面光泽度模型不兼容的原因。Khronos 小组引入了KHR 材质镜面扩展,允许您向金属-粗糙度模型添加光谱镜面颜色。

准备工作

本食谱的 C++ 源代码位于 Chapter06/05_SpecularGlossiness 文件夹中。负责 PBR 计算的 GLSL 着色器代码可以在 Chapter06/05_ SpecularGlossiness/src/PBR.sp 中找到。

如何做到这一点...

这个食谱与上一个非常相似。实际上,大部分的模型加载和渲染代码保持不变,除了获取不同材质属性值。我们将金属粗糙度数据扩展到包括镜面光泽度参数,然后根据材质类型将这些参数应用于着色器中。

我们将使用 Khronos 提供的 3D 模型 SpecGlossVsMetalRough。它提供了使用金属粗糙度着色和镜面光泽度着色渲染的相同模型的并排比较。您可以在以下位置找到 glTF 文件:deps/src/glTF-Sample-Assets/Models/SpecGlossVsMetalRough/glTF/

让我们开始吧。以下是必要的 C++ 代码更改,以适应镜面光泽度参数:

  1. 我们将通过添加两个新的数据成员来修改我们的材质数据结构,vec4 specularGlossinessuint32_t materialType。第一个将提供镜面光泽度材质所需的参数,第二个将指定确切的材质类型。

  2. 请注意结构体末尾的填充成员。我们需要它来保持此结构体的二进制表示与 GLSL 着色器输入对齐。GLSL st430 布局和对齐规则并不复杂,但可能不会由不同的硬件供应商正确实现,尤其是在移动设备上。在这种情况下,手动填充只是简单且足够好的方法来修复所有 GPU 之间的兼容性。为了进一步阅读,我们推荐官方 Khronos Vulkan 指南文档(github.com/KhronosGroup/Vulkan-Guide/blob/main/chapters/shader_memory_layout.adoc):

struct SpecularGlossinessDataGPU {
  vec4 baseColorFactor = vec4(1.0f, 1.0f, 1.0f, 1.0f);
  vec4 metallicRoughnessNormalOcclusion =
    vec4(1.0f, 1.0f, 1.0f, 1.0f);
  vec4 specularGlossiness = vec4(1.0f, 1.0f, 1.0f, 1.0f);
  … everything else remains the same 
      as in the structure MetallicRoughnessDataGPU …
  uint32_t materialType = 0;
  uint32_t padding[3]   = {};
  enum AlphaMode : uint32_t {
    AlphaMode_Opaque = 0,
    AlphaMode_Mask   = 1,
    AlphaMode_Blend  = 2,
  };
};
  1. 这是我们识别 glTF 材质类型的方法。我们在 shared/UtilsGLTF.cpp 中实现了一个辅助函数,detectMaterialType()
MaterialType detectMaterialType(const aiMaterial* mtl) {
  aiShadingMode shadingMode = aiShadingMode_NoShading;
  if (mtl->Get(AI_MATKEY_SHADING_MODEL, shadingMode) ==
      AI_SUCCESS) {
    if (shadingMode == aiShadingMode_Unlit)
      return MaterialType_Unlit;
  }
  if (shadingMode == aiShadingMode_PBR_BRDF) {
    ai_real factor = 0;
    if (mtl->Get(AI_MATKEY_GLOSSINESS_FACTOR, factor) ==
        AI_SUCCESS) {
      return MaterialType_SpecularGlossiness;
    } else if (mtl->Get(AI_MATKEY_METALLIC_FACTOR, factor) ==
               AI_SUCCESS) {
      return MaterialType_MetallicRoughness;
    }
  }
  LLOGW(“Unknown material type\n”);
  return MaterialType_Invalid;
}
  1. 下一个差异是我们如何加载额外的材质属性。这里没有太多有趣的内容;只需使用 Assimp API 加载并分配值即可:
SpecularGlossinessDataGPU res;
…
if (materialType == MaterialType_SpecularGlossiness) {
  ai_real specularFactor[3];
  if (mtlDescriptor->Get(AI_MATKEY_SPECULAR_FACTOR,
      specularFactor) == AI_SUCCESS) {
    res.specularGlossiness.x = specularFactor[0];
    res.specularGlossiness.y = specularFactor[1];
    res.specularGlossiness.z = specularFactor[2];
  }
  assignUVandSampler(      samplers, mtlDescriptor,
    aiTextureType_SPECULAR, res.surfacePropertiesTextureUV,
    res.surfacePropertiesTextureSampler);
  ai_real glossinessFactor;
  if (mtlDescriptor->Get(AI_MATKEY_GLOSSINESS_FACTOR,
                         glossinessFactor) == AI_SUCCESS) {
    res.specularGlossiness.w = glossinessFactor;
  }
}

其余的 C++ 代码与上一个食谱基本相同,实现 glTF 2.0 金属粗糙度着色模型。突出显示的差异与调整不同材质属性和提供必要数据相关。

现在,让我们看看相应的 GLSL 着色器。

差异体现在片段着色器 05_SpecularGlossinesss/src/main.frag 的代码中,我们在其中计算并应用 PBRInfo 参数:

  1. 首先,我们将识别镜面光泽度材质类型:
PBRInfo calculatePBRInputsMetallicRoughness( vec4 albedo,
  vec3 normal, vec3 cameraPos, vec3 worldPos, vec4 mrSample)
{
  PBRInfo pbrInputs;
  SpecularGlossinessDataGPU mat = getMaterial(getMaterialId());
  bool isSpecularGlossiness =
    getMaterialType(mat) == MaterialType_SpecularGlossiness;
  1. 根据材料类型,我们将计算perceptualRoughnessf0,以及漫反射和镜面反射的颜色贡献。我们将遵循官方 Khronos 推荐(kcoley.github.io/glTF/extensions/2.0/Khronos/KHR_materials_pbrSpecularGlossiness)将值从镜面光泽度转换为金属粗糙度:
 float perceptualRoughness = isSpecularGlossiness ?
    getGlossinessFactor(mat):
    getRoughnessFactor(mat);
  float metallic = getMetallicFactor(mat) * mrSample.b;
  metallic = clamp(metallic, 0.0, 1.0);
  vec3 f0 = isSpecularGlossiness ?
    getSpecularFactor(mat) * mrSample.rgb :
    vec3(0.04);
  const float c_MinRoughness = 0.04;
  perceptualRoughness = isSpecularGlossiness ?
    1.0 - mrSample.a * perceptualRoughness :
    clamp(mrSample.g * perceptualRoughness, c_MinRoughness, 1.0);
  float alphaRoughness = perceptualRoughness *
                         perceptualRoughness;
  vec4 baseColor = albedo;
  vec3 diffuseColor = isSpecularGlossiness ?
    baseColor.rgb * (1.0 - max(max(f0.r, f0.g), f0.b)) :
    mix(baseColor.rgb, vec3(0), metallic);
  vec3 specularColor = isSpecularGlossiness ?
    f0 : mix(f0, baseColor.rgb, metallic);
  …
}

剩余的片段着色器代码保持不变。此示例产生的图像应该看起来像以下截图:

图 6.9:镜面光泽度与金属粗糙度材料对比

图 6.9:镜面光泽度与金属粗糙度材料对比

一个瓶子使用了金属粗糙度材料,另一个则使用了镜面光泽度材料。正如你所见,物体看起来完全相同,通过在兼容范围内调整镜面和光泽度值,你可以使用两种不同的着色模型达到完全相同的结果。

还有更多...

这里有一篇由 Don McCurdy 撰写的文章,解释了从镜面光泽度着色模型切换到金属粗糙度模型的原因:www.donmccurdy.com/2022/11/28/converting-gltf-pbr-materials-from-specgloss-to-metalrough

第八章:7 高级 PBR 扩展

加入我们的 Discord 书籍社区

packt.link/unitydev

在本章中,我们将深入探讨基于基础金属-粗糙度模型的 glTF 高级 PBR 扩展。虽然基础金属-粗糙度模型提供了一个起点,但它无法完全捕捉现实生活材料的全谱。为了解决这个问题,glTF 引入了额外的材质层,每个层都有特定的参数来定义其独特的表现。我们的目标是引导你从零开始实现这些层。我们将介绍层的概念,分解它们背后的某些数学原理,然后展示如何将每一层集成到 GLSL 着色器代码中。

本章提供的 C++ 代码大多适用于我们在这里和本书其余部分将要涵盖的所有食谱。

在本章中,你将学习以下食谱:

  • glTF PBR 扩展简介

  • 实现 KHR_materials_clearcoat 扩展

  • 实现 KHR_materials_sheen 扩展

  • 实现 KHR_materials_transmission 扩展

  • 实现 KHR_materials_volume 扩展

  • 实现 KHR_materials_ior 扩展

  • 实现 KHR_materials_specular 扩展

  • 实现 KHR_materials_emissive_strength 扩展

  • 使用 KHR_lights_punctual 扩展增强分析光支持

我们的 GLSL 着色器代码基于官方的 Khronos 示例查看器,并作为这些扩展的示例实现。

glTF PBR 扩展简介

在这个食谱中,我们将探讨 PBR 材料扩展的设计方法,提供丰富的上下文来帮助你实现各种 glTF PBR 扩展。实际代码将在后续食谱中分享,章节结构将遵循 Khronos 开发这些 PBR 扩展的顺序。

PBR 规范迅速发展,读者应意识到,到阅读时,一些扩展可能会被弃用或过时。

准备工作

我们假设读者对线性代数和微积分有一些基本了解。建议手头备有 glTF 2.0 认可的扩展规范列表,可以在 github.com/KhronosGroup/glTF/blob/main/extensions/README.md 找到。

glTF 2.0 PBR 模型是如何设计的?

在上一章中,我们探讨了核心的金属-粗糙度 PBR 模型。这个模型非常适合描绘多种金属和非金属材质,但现实世界要复杂得多。

为了更好地捕捉这种复杂性,Khronos 决定不仅仅扩展金属-粗糙度模型。相反,他们引入了一种分层方法,就像洋葱的层一样。这种方法允许你逐渐向 PBR 材料添加复杂性,类似于如何在 Adobe 标准表面github.com/Autodesk/standard-surfacehttps://github.com/Autodesk/standard-surface中构建层。

分层通过堆叠多个层来模拟现实世界的材料结构,每个层都有其自己的光交互属性。为了保持物理准确性,第一层,称为基层,应该是完全不透明(如金属表面)或完全透明(如玻璃或皮肤)。之后,可以逐个添加额外的层,称为电介质板。

当光线击中两层之间的边界时,它可以反射并反弹到相反的方向。然而,我们在这里的主要关注点是继续通过材料堆栈的光线。当这束光穿过底层时,它可能被材料吸收。

混合操作为材料建模提供了一种独特的方法。你可以将其视为两种不同材料的统计加权混合,其中你将一定比例的材料A与一定比例的材料B相结合。虽然这种技术在创造新材料方面非常出色,但重要的是要记住,并非所有组合都是物理上现实的。例如,混合油和水不会产生可信的材料。

当混合操作以线性插值的方式进行时,它自然遵循能量守恒的原则。这意味着结果材料中的总能量保持不变,与物理学的基本定律一致。

图 7.1:glTF PBR 分层和混合

图 7.1:glTF PBR 分层和混合

在以下配方中,我们将深入了解几个高级材料层:镜面反光(光泽)、涂层镜面反射和漫反射透射。我们还将探讨如何将这些层组合起来以创建更广泛的外观材料。

准备工作

本章使用单个 glTF 查看器示例代码来展示所有配方。main.cpp文件在配方之间只有两种变化:它们使用不同的模型文件来演示所涵盖的特定 glTF PBR 扩展,并且初始相机位置被调整以展示模型的美观。

glTF 查看器的源代码本身位于文件shared/UtilsGLTF.cpp中。相应的 GLSL 顶点和片段着色器位于data/shaders/gltf/文件夹中。

这些 GLSL 着色器的结构不同于上一章中介绍的那些。我们将探索每个单独配方中的具体实现差异。

如何做到这一点…

让我们回顾一下前一章中我们的 glTF 观察器实现和最新提出的统一版本之间的主要区别。

  1. 我们重构了代码,并在 shared/UtilsGLTF.h 中引入了一个非常基本的结构来存储所有必要的应用程序数据。本章将解释所有结构成员字段。
struct GLTFContext {
  explicit GLTFContext(VulkanApp& app_)
  : app(app_)
  , samplers(app_.ctx_)
  , envMapTextures(app_.ctx_) {}
  GLTFDataHolder glTFDataholder;
  MaterialsPerFrame matPerFrame;
  GLTFGlobalSamplers samplers;
  EnvironmentMapTextures envMapTextures;
  GLTFFrameData frameData;
  std::vector<GLTFTransforms> transforms;
  std::vector<GLTFNode> nodesStorage;
  std::vector<GLTFMesh> meshesStorage;
  std::vector<uint32_t> opaqueNodes;
  std::vector<uint32_t> transmissionNodes;
  std::vector<uint32_t> transparentNodes;
  lvk::Holder<lvk::BufferHandle> envBuffer;
  lvk::Holder<lvk::BufferHandle> perFrameBuffer;
  lvk::Holder<lvk::BufferHandle> transformBuffer;
  lvk::Holder<lvk::RenderPipelineHandle> pipelineSolid;
  lvk::Holder<lvk::RenderPipelineHandle> pipelineTransparent;
  lvk::Holder<lvk::ShaderModuleHandle> vert;
  lvk::Holder<lvk::ShaderModuleHandle> frag;
  lvk::Holder<lvk::BufferHandle> vertexBuffer;
  lvk::Holder<lvk::BufferHandle> indexBuffer;
  lvk::Holder<lvk::BufferHandle> matBuffer;
  lvk::Holder<lvk::TextureHandle> offscreenTex[3] = {};
  uint32_t currentOffscreenTex = 0;
  GLTFNodeRef root;
  VulkanApp& app;
  bool volumetricMaterial = false;
  bool isScreenCopyRequired() const {
    return volumetricMaterial;
  }
};
  1. 让我们为它引入一个非常基本的加载和渲染 API。rebuildRenderList 参数表示应该重建 glTF 节点的模型到世界变换:
void loadglTF(GLTFContext& context,
  const char* gltfName, const char* glTFDataPath);
void renderglTF(GLTFContext& context,
  const mat4& model, const mat4& view, const mat4& proj,
  bool rebuildRenderList = false);
  1. 我们扩展了 GPU 数据结构以包括所需的材质属性,并添加了一个名为 MaterialType 的新 enum,允许我们根据需要提供材质 ID。上一章 第六章,使用 glTF 2.0 着色模型的基于物理的渲染 覆盖了旧材质:未光照、金属-粗糙度和镜面-光泽度。新材质将在本章后续的食谱中介绍。
enum MaterialType : uint32_t {
  MaterialType_Invalid            = 0,
  MaterialType_Unlit              = 0xF,
  MaterialType_MetallicRoughness  = 0x1,
  MaterialType_SpecularGlossiness = 0x2,
  MaterialType_Sheen              = 0x4,
  MaterialType_ClearCoat          = 0x8,
  MaterialType_Specular           = 0x10,
  MaterialType_Transmission       = 0x20,
  MaterialType_Volume             = 0x40,
};
  1. 我们添加了 3 个不同的向量容器来保存 glTF 节点的列表:不透明、透明和透射。以下是 buildTransformsList() 函数,用于构建节点变换并收集其他节点数据:
void buildTransformsList(GLTFContext& gltf) {
  gltf.transforms.clear();
  gltf.opaqueNodes.clear();
  gltf.transmissionNodes.clear();
  gltf.transparentNodes.clear();
  1. 我们的递归遍历函数的主体被声明为一个局部 C++ lambda。它将所有变换收集到 gltf.transforms 中,并将不透明、透明和透射节点添加到它们相应的容器中:
 std::function<void(GLTFNodeRef gltfNode)> traverseTree =
    & {
      const GLTFNode& node = gltf.nodesStorage[nodeRef];
      for (GLTFNodeRef meshId : node.meshes) {
        const GLTFMesh& mesh = gltf.meshesStorage[meshId];
        gltf.transforms.push_back({
          .model = node.transform,
          .matId = mesh.matIdx,
          .nodeRef = nodeRef,
          .meshRef = meshId,
          .sortingType = mesh.sortingType });
  1. 将刚刚添加到 gltf.transforms 中的变换索引推送到上述代码块中。
 uint32_t lastTransformIndex = gltf.transforms.size() – 1;
        if (mesh.sortingType == SortingType_Transparent) {
          gltf.transparentNodes.push_back(lastTransformIndex);
        } else if (mesh.sortingType==SortingType_Transmission) {
          gltf.transmissionNodes.push_back(lastTransformIndex);
        } else {
          gltf.opaqueNodes.push_back(lastTransformIndex );
        }
      }
      for (GLTFNodeRef child : node.children)
        traverseTree(child);
    };
  1. 调用 lambda 函数遍历从根节点开始的整个 glTF 节点树,并将所有结果变换存储在缓冲区中:
 traverseTree(gltf.root);
  gltf.transformBuffer = gltf.app.ctx_->createBuffer({
    .usage     = lvk::BufferUsageBits_Uniform,
    .storage   = lvk::StorageType_HostVisible,
    .size      = gltf.transforms.size() * sizeof(GLTFTransforms),
    .data      = &gltf.transforms[0],
    .debugName = “Per Frame data” });
};
  1. 让我们添加一个节点排序函数,以正确渲染 glTF 节点并支持透明度。我们使用一个非常简单的算法,根据相机到节点中心的距离对节点进行排序。为了正确渲染透明节点,它们应该从后向前依次渲染。
void sortTransparentNodes(
  GLTFContext& gltf, const vec3& cameraPos) {
  std::sort(
    gltf.transparentNodes.begin(),
    gltf.transparentNodes.end(),
    & {
      float sqrDistA = glm::length2(
        cameraPos-vec3(gltf.transforms[a].model[3]));
      float sqrDistB = glm::length2(
        cameraPos-vec3(gltf.transforms[b].model[3]));
      return sqrDistA < sqrDistB;
  });
}
  1. 现在我们必须更改实际的渲染函数 renderGLTF() 以适应上述所有更改。

  2. 首先,我们必须更新变换列表并根据当前相机到 glTF 节点的距离对 glTF 节点进行排序。

void renderGLTF(GLTFContext& gltf,
  const mat4& model, const mat4& view, const mat4& proj,
  bool rebuildRenderList)
{
  auto& ctx = gltf.app.ctx_;
  const vec4 camPos = glm::inverse(view)[3];
  if (rebuildRenderList || gltf.transforms.empty()) {
    buildTransformsList(gltf);
  }
  sortTransparentNodes(gltf, camPos);
  1. 存储每帧的相机参数并准备所有必要的缓冲区和纹理的推送常量:
 gltf.frameData = {
    .model     = model,
    .view      = view,
    .proj      = proj,
    .cameraPos = camPos,
  };
  struct PushConstants {
    uint64_t draw;
    uint64_t materials;
    uint64_t environments;
    uint64_t transforms;
    uint32_t envId;
    uint32_t transmissionFramebuffer;
    uint32_t transmissionFramebufferSampler;
  } pushConstants = {
    .draw         = ctx->gpuAddress(gltf.perFrameBuffer),
    .materials    = ctx->gpuAddress(gltf.matBuffer),
    .environments = ctx->gpuAddress(gltf.envBuffer),
    .transforms   = ctx->gpuAddress(gltf.transformBuffer),
    .envId        = 0,
    .transmissionFramebuffer = 0,
    .transmissionFramebufferSampler =
      gltf.samplers.clamp.index(),
  };
  ctx->upload(
    gltf.perFrameBuffer, &gltf.frameData, sizeof(GLTFFrameData));
  …
  1. 让我们渲染所有不透明节点。对于这个传递,不需要透射帧缓冲区:
 const lvk::RenderPass renderPass = {
    .color = { { .loadOp = lvk::LoadOp_Clear,
                 .clearColor = { 1.0f, 1.0f, 1.0f, 1.0f } } },
    .depth = { .loadOp = lvk::LoadOp_Clear, .clearDepth = 1.0f },
  };
  const lvk::Framebuffer framebuffer = {
    .color        = { {
     .texture = screenCopy ?
       gltf.offscreenTex[gltf.currentOffscreenTex] : 
       ctx->getCurrentSwapchainTexture() } },
    .depthStencil = { .texture = gltf.app.getDepthTexture() },
  };
  buf.cmdBeginRendering(renderPass, framebuffer);
  buf.cmdBindVertexBuffer(0, gltf.vertexBuffer, 0);
  buf.cmdBindIndexBuffer(
    gltf.indexBuffer, lvk::IndexFormat_UI32);
  buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                          .isDepthWriteEnabled = true });
  buf.cmdBindRenderPipeline(gltf.pipelineSolid);
  buf.cmdPushConstants(pushConstants);
  for (uint32_t transformId : gltf.opaqueNodes) {
    GLTFTransforms transform = gltf.transforms[transformId];
    buf.cmdPushDebugGroupLabel(
      gltf.nodesStorage[transform.nodeRef].name.c_str(),
      0xff0000ff);
    const GLTFMesh submesh =
      gltf.meshesStorage[transform.meshRef];
  1. 我们使用内置的 GLSL 变量 gl_BaseInstancetransformId 的值传递到着色器中。这样,我们就不必为每个绘制调用更新推送常量。这是最有效的方法。

    vkCmdDrawIndexed()firstInstance 参数被分配给内置的 GLSL 变量 gl_BaseInstance。这允许你在不涉及任何缓冲区或推送常量的情况下,将任意每绘制调用的 uint32_t 值传递到顶点着色器中。这是一个非常快速的技术,应该尽可能使用。

    vkCmdDrawIndexed(VkCommandBuffer commandBuffer,
                     uint32_t        indexCount,    
                     uint32_t        instanceCount, 
                     uint32_t        firstIndex,
                     int32_t         vertexOffset,
                     uint32_t        firstInstance);
    
 buf.cmdDrawIndexed(submesh.indexCount, 1,
      submesh.indexOffset, submesh.vertexOffset, transformId);
    buf.cmdPopDebugGroupLabel();
  }
  buf.cmdEndRendering();
  …
  1. 现在,我们应该在非透明节点之上渲染透明节点。一些透明节点可能需要屏幕拷贝来渲染各种效果,例如体积或折射率。这是一个非常简单的方法来获得它:
 if (screenCopy) {
    buf.cmdCopyImage(
      gltf.offscreenTex[gltf.currentOffscreenTex],
      ctx->getCurrentSwapchainTexture(),
      ctx->getDimensions(ctx->getCurrentSwapchainTexture()));
    buf.cmdGenerateMipmap(
      gltf.offscreenTex[gltf.currentOffscreenTex]);
    pushConstants.transmissionFramebuffer =
      gltf.offscreenTex[gltf.currentOffscreenTex].index();
    buf.cmdPushConstants(pushConstants);
  }
  1. 当我们开始下一个渲染通道并使用离屏纹理时,我们必须正确同步它:
 buf.cmdBeginRendering(renderPass, framebuffer, {
    .textures = { lvk::TextureHandle(
      gltf.offscreenTex[gltf.currentOffscreenTex]) } });
  buf.cmdBindVertexBuffer(0, gltf.vertexBuffer, 0);
  buf.cmdBindIndexBuffer(
    gltf.indexBuffer, lvk::IndexFormat_UI32);
  buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                          .isDepthWriteEnabled = true });
  1. 现在我们渲染透射节点:
 buf.cmdBindRenderPipeline(gltf.pipelineSolid);
  for (uint32_t transformId : gltf.transmissionNodes) {
    const GLTFTransforms transform =
      gltf.transforms[transformId];
    buf.cmdPushDebugGroupLabel(
      gltf.nodesStorage[transform.nodeRef].name.c_str(),
      0x00FF00ff);
    const GLTFMesh submesh =
      gltf.meshesStorage[transform.meshRef];
    buf.cmdDrawIndexed(submesh.indexCount, 1,
      submesh.indexOffset, submesh.vertexOffset, transformId);
    buf.cmdPopDebugGroupLabel();
  }
  1. 透明节点最后处理。使用相同的 gl_BaseInstance 技巧传递每个 glTF 网格的 transformId 值:
 buf.cmdBindRenderPipeline(gltf.pipelineTransparent);
  for (uint32_t transformId : gltf.transparentNodes) {
    const GLTFTransforms transform =
      gltf.transforms[transformId];
    buf.cmdPushDebugGroupLabel(
      gltf.nodesStorage[transform.nodeRef].name.c_str(),
      0x00FF00ff);
    const GLTFMesh submesh =
      gltf.meshesStorage[transform.meshRef];
    buf.cmdDrawIndexed(submesh.indexCount, 1,
      submesh.indexOffset, submesh.vertexOffset, transformId);
    buf.cmdPopDebugGroupLabel();
  }
  1. 一旦命令缓冲区被填满,我们就可以提交它,并以轮询方式使用另一个离屏纹理。
 buf.cmdEndRendering();
  ctx->submit(buf, ctx->getCurrentSwapchainTexture());
  gltf.currentOffscreenTex = (gltf.currentOffscreenTex + 1) %
    LVK_ARRAY_NUM_ELEMENTS(gltf.offscreenTex);

这是对我们通用 glTF 渲染代码的完整概述。真正的魔法发生在 GLSL 着色器内部。在下一系列的菜谱中,我们将逐步学习如何实现不同的 glTF 材料扩展。

还有更多...

Khronos 3D Formats Working Group 正在通过引入新的扩展规范来不断改进 PBR 材料的功能。要了解已批准扩展的状态,您可以访问 Khronos GitHub 页面:github.com/KhronosGroup/glTF/blob/main/extensions/README.md

实现 KHR_materials_clearcoat 扩展

KHR_materials_clearcoat 扩展通过在另一种材料或表面之上添加一个清晰、反射的层来改进 glTF 的核心 基于物理的渲染PBR)模型。此层既反射来自自身的光线,也反射来自下层的光线。此效果包括汽车漆面的光泽效果或抛光鞋的光泽。

这里有一个链接到 Khronos glTF PBR 扩展:github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_clearcoat/README.md

清漆参数

以下参数由 KHR_materials_clearcoat 扩展提供:

clearcoatFactor / clearcoatTexture: 此参数表示涂层的强度。可以使用标量因子或纹理进行设置。0 的值表示没有涂层,而 1 的值表示存在涂层。介于两者之间的值应仅用于涂层和非涂层区域之间的边界。

clearcoatNormalTexture: 此参数允许将法线图应用于涂层层,为涂层表面引入变化和细节。

clearcoatRoughnessFactor / clearcoatRoughnessTexture: 此参数表示涂层的粗糙度。它可以设置为粗糙度标量因子或粗糙度纹理。它的工作方式与基材料的粗糙度参数类似,但应用于涂层层。

清漆层的镜面 BRDF

清漆层的镜面 BRDF 使用了 glTF 2.0 金属-粗糙度材质中的镜面项。然而,为了在使用简单的分层函数时在材料内部保持能量守恒,进行了一些微调。

微面 Fresnel 项使用NdotV项而不是VdotH项来计算,实际上忽略了清漆层内部的微观表面方向。这种简化是合理的,因为清漆层通常具有非常低的粗糙度,这意味着微面主要与法线方向对齐。因此,NdotV变得大约等同于NdotL。这种方法通过简单的分层函数确保材料内部的能量守恒,并通过省略VdotH项来保持计算效率。

如前一章使用 glTF 2.0 着色模型的物理渲染中所述,N代表表面点的法向量,V是从表面指向观察者的向量,L是从表面点指向光源的向量,而H是位于光源L和观察者V方向之间的半向量。

在 BRDF 框架内提供的clearcoat层实现中,做出了一些假设,这些假设忽略了某些现实世界的材料属性。以下是这些限制的分解:

  • 无限薄的层:清漆层被视为无限薄,忽略了其实际厚度。

  • 忽略折射:折射,即光通过清漆层时的弯曲,没有被考虑在内。

  • 独立的 Fresnel 项:清漆层和底层的折射率被视为独立,它们的 Fresnel 项分别计算,不考虑它们之间的任何相互作用。

  • 省略的散射:当前模型没有考虑清漆层和底层之间的光散射。

  • 忽略衍射:衍射效应,即光在微观面边缘的轻微弯曲,没有被考虑在内。

尽管存在这些限制,但清漆层 BRDF 在材料建模中模拟清漆效果是一个有价值的工具。它在计算效率和产生视觉上可信的结果之间取得了良好的平衡,特别是对于低粗糙度的清漆层。

重要通知

清漆扩展旨在与 glTF 的核心 PBR 着色模型一起工作,并且与 Unlit 或 Specular-glossiness 等其他着色模型不兼容。然而,它仍然可以与其他 PBR 参数一起使用,例如发射材料,其中发射的光受到清漆层的影响。

准备工作

这个菜谱的源代码可以在Chapter07/01_Clearcoat/中找到。

如何做到这一点...

让我们看看Chapter07/01_Clearcoat/src/main.cpp中的 C++代码。

  1. 首先,让我们使用我们新的 glTF API 加载一个.gltf文件:
 VulkanApp app({
      .initialCameraPos    = vec3(0.0f, -0.2f, -1.5f),
      .initialCameraTarget = vec3(0.0f, -0.5f, 0.0f),
  });
  GLTFContext gltf(app);
  loadGLTF(gltf,
    “deps/src/glTF-Sample-Assets/Models/ClearcoatWicker/
      glTF/ClearcoatWicker.gltf”,
    “deps/src/glTF-Sample-Assets/Models/ClearcoatWicker/glTF/”);
  1. 然后我们使用之前菜谱 glTF PBR 扩展简介 中描述的 renderGLTF() 函数进行渲染:
 const mat4 t = glm::translate(mat4(1.0f), vec3(0, -1, 0));
  app.run(& {
    const mat4 m = t * glm::rotate(
      mat4(1.0f),  (float)glfwGetTime(), vec3(0.0f, 1.0f, 0.0f));
    const mat4 v = app.camera_.getViewMatrix();
    const mat4 p = glm::perspective(
      45.0f, aspectRatio, 0.01f, 100.0f);
    renderGLTF(gltf, m, v, p);
  });

为了从 .gltf 文件中加载 clearcoat 参数,我们必须对我们的 GLTF 材料加载器进行一些更改。让我们看看完成此操作所需的步骤。

  1. 首先,我们应该在文件 shared/UtilsGLTF.h 中的 GLTFMaterialDataGPU 结构体中添加一些新的成员字段来存储标量值和相应的纹理:
struct GLTFMaterialDataGPU {
  …
  vec4 clearcoatTransmissionThickness = vec4(1, 1, 1, 1);
  uint32_t clearCoatTexture                 = 0;
  uint32_t clearCoatTextureSampler          = 0;
  uint32_t clearCoatTextureUV               = 0;
  uint32_t clearCoatRoughnessTexture        = 0;
  uint32_t clearCoatRoughnessTextureSampler = 0;
  uint32_t clearCoatRoughnessTextureUV      = 0;
  uint32_t clearCoatNormalTexture           = 0;
  uint32_t clearCoatNormalTextureSampler    = 0;
  uint32_t clearCoatNormalTextureUV         = 0;
  …
}
  1. 让我们使用 Assimp 库在 shared/UtilsGLTF.cpp 中加载纹理和属性。在这里,我们只强调与 clearcoat 扩展相关的属性:
GLTFMaterialDataGPU setupglTFMaterialData(
  const std::unique_ptr<lvk::IContext>& ctx,
  const GLTFGlobalSamplers& samplers,
  const aiMaterial* mtlDescriptor,
  const char* assetFolder,
  GLTFDataHolder& glTFDataholder,
  bool& useVolumetric)
{
  …
  // clearcoat
  loadMaterialTexture(mtlDescriptor, aiTextureType_CLEARCOAT,
    assetFolder, mat.clearCoatTexture, ctx, true, 0);
  loadMaterialTexture(mtlDescriptor, aiTextureType_CLEARCOAT,
    assetFolder, mat.clearCoatRoughnessTexture, ctx, false, 1);
  loadMaterialTexture(mtlDescriptor, aiTextureType_CLEARCOAT,
    assetFolder, mat.clearCoatNormalTexture, ctx, false, 2);
  …
  bool useClearCoat = !mat.clearCoatTexture.empty() ||
                      !mat.clearCoatRoughnessTexture.empty() ||
                      !mat.clearCoatNormalTexture.empty();
  ai_real clearcoatFactor;
  if (mtlDescriptor->Get(AI_MATKEY_CLEARCOAT_FACTOR,
    clearcoatFactor) == AI_SUCCESS) {
    res.clearcoatTransmissionThickness.x = clearcoatFactor;
    useClearCoat = true;
  }
  ai_real clearcoatRoughnessFactor;
  if (mtlDescriptor->Get(AI_MATKEY_CLEARCOAT_ROUGHNESS_FACTOR,
    clearcoatRoughnessFactor) == AI_SUCCESS) {
    res.clearcoatTransmissionThickness.y =
      clearcoatRoughnessFactor;
    useClearCoat = true;
  }
  if (assignUVandSampler(
        samplers, mtlDescriptor, aiTextureType_CLEARCOAT,
        res.clearCoatTextureUV,
        res.clearCoatNormalTextureSampler, 0)) {
    useClearCoat = true;
  }
  if (assignUVandSampler(
        samplers, mtlDescriptor, aiTextureType_CLEARCOAT,
        res.clearCoatRoughnessTextureUV, 
        res.clearCoatRoughnessTextureSampler, 1)) {
    useClearCoat = true;
  }
  if (assignUVandSampler(
        samplers, mtlDescriptor, aiTextureType_CLEARCOAT,
        res.clearCoatNormalTextureUV,
        res.clearCoatNormalTextureSampler, 2)) {
    useClearCoat = true;
  }
  if (useClearCoat) 
    res.materialTypeFlags |= MaterialType_ClearCoat;

请注意,我们只有在检查通过并且扩展存在于 .gltf 文件中时才设置 MaterialType_ClearCoat 标志。虽然从技术上讲,始终启用 clearcoat 层是可能的——因为默认设置实际上禁用了它——但这样做效率非常低。clearcoat 层增加了二级 BRDF 样本,这计算成本很高。最好只使用实际需要的昂贵功能!

C++ 的更改到此为止。现在,让我们看看 GLSL 着色器更改,实际的渲染工作就在这里进行:

  1. 与为新参数进行的 C++ 更改类似,我们在 data/shaders/gltf/inputs.frag 中添加了 GLSL 工具函数,用于从纹理和输入缓冲区中读取 clearcoat 数据。clearcoat 因子和粗糙度分别打包到纹理的 rg 通道中:
float getClearcoatFactor(InputAttributes tc,
                         MetallicRoughnessDataGPU mat)
{
  return textureBindless2D(mat.clearCoatTexture,
    mat.clearCoatTextureSampler,
    tc.uv[mat.clearCoatTextureUV]
  ).r * mat.clearcoatTransmissionThickness.x;
}
float getClearcoatRoughnessFactor(InputAttributes tc,
                                  MetallicRoughnessDataGPU mat)
{
  return textureBindless2D(mat.clearCoatRoughnessTexture,
    mat.clearCoatRoughnessTextureSampler,
    tc.uv[mat.clearCoatRoughnessTextureUV]
  ).g * mat.clearcoatTransmissionThickness.y;
}
  1. 我们按照本菜谱开头描述的方法计算 clearcoat 贡献。我们使用 GGX BRDF 进行额外的查找,并在 data/shaders/gltf/main.frag 中提供 clearcoat 粗糙度、反射率 clearcoatF0 和法线作为输入。请注意,我们使用了 IOR 参数,它将在菜谱 实现 IOR 扩展 中稍后介绍:
 vec3 clearCoatContrib = vec3(0);
  if (isClearCoat) {
    pbrInputs.clearcoatFactor = getClearcoatFactor(tc, mat);
    pbrInputs.clearcoatRoughness =
      clamp(getClearcoatRoughnessFactor(tc, mat), 0.0, 1.0);
    pbrInputs.clearcoatF0 = vec3(pow((pbrInputs.ior - 1.0) /
                            (pbrInputs.ior + 1.0), 2.0));
    pbrInputs.clearcoatF90 = vec3(1.0);
    if (mat.clearCoatNormalTextureUV > -1) {
      pbrInputs.clearcoatNormal = mat3(
        pbrInputs.t, pbrInputs.b, pbrInputs.ng) *
        sampleClearcoatNormal(tc, mat).rgb;
    } else {
      pbrInputs.clearcoatNormal = pbrInputs.ng;
    }
    clearCoatContrib = getIBLRadianceGGX(
      pbrInputs.clearcoatNormal, pbrInputs.v,
      pbrInputs.clearcoatRoughness,
      pbrInputs.clearcoatF0, 1.0, envMap);
  }
  1. 我们使用类似的方法计算 clearcoat 层的 Fresnel 项。我们应用 Schlick 近似,但使用针对 clearcoat 的特定输入数据:
 vec3 clearcoatFresnel = vec3(0);
  if (isClearCoat) {
    clearcoatFresnel = F_Schlick(
      pbrInputs.clearcoatF0,
      pbrInputs.clearcoatF90,
      clampedDot(pbrInputs.clearcoatNormal, pbrInputs.v));
  }
  1. 最后,在片段着色器 data/shaders/gltf/main.frag 的最后,我们在所有层(包括发射层)之上应用 clearcoat 贡献!注意这里的 sheenColor 值,它将在下一道菜谱 实现 sheen 材料扩展 中介绍。
 vec3 color =
    specularColor + diffuseColor + emissiveColor + sheenColor;
  color = color *
    (1.0 - pbrInputs.clearcoatFactor * clearcoatFresnel) +
    clearCoatContrib;

这就完成了实现 clearcoat 扩展所需的所有必要的 GLSL 更改。演示应用应该看起来像下面的截图:

图 7.2:glTF PBR KHR_materials_clearcoat 示例

图 7.2:glTF PBR KHR_materials_clearcoat 示例

注意球体顶部的光泽层——这就是 clearcoat!恭喜你,我们已经完成了我们的第一个高级 PBR 扩展。

还有更多...

Khronos glTF 扩展存储库包括清晰涂层材料的综合参考列表:github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_clearcoat/README.md

实现 KHR_materials_sheen 扩展

KHR_materials_sheen 扩展通过添加模拟丝绸或刷金属等织物上光泽效果的层来改进 glTF 2.0 金属-粗糙度材质。这种增强创造了更真实和视觉上吸引人的光泽高光。

Sheen BRDF 位于 glTF 2.0 金属-粗糙度材质之上。如果之前的扩展 KHR_materials_clearcoat 也处于活动状态,它将叠加在光泽效果之上。

sheenColorFactor 属性控制光泽效果的基本强度,独立于观察角度。值为 0 将完全禁用 光泽

光泽参数

sheenColorTexture / sheenColorFactor: 如果定义了纹理,光泽颜色通过将 sheenColorFactor 与纹理的 RGB 值相乘来计算。

sheenRoughnessTexture / sheenRoughnessFactor: 如果定义了,光泽粗糙度通过乘以纹理的 alpha 通道值来计算。

如果没有指定纹理,sheenColorFactor 直接控制光泽颜色,而 sheenRoughnessFactor 直接控制光泽粗糙度。

模拟光泽效果

sheen BRDF 模拟了光线从绒面材料上散射的方式。它模拟了光线如何从垂直于表面的微小纤维上反射。光泽粗糙度控制这些纤维偏离该方向的程度:

  • 较低的光泽粗糙度:纤维更整齐,当光线掠过表面时,会创建更锐利的光泽高光。

  • 较高的光泽粗糙度:纤维更分散,导致更柔和的光泽高光。

光泽 BRDF 在数学上基于指数正弦分布,该分布源自微面理论(Conty & Kulla,2017 blog.selfshadow.com/publications/s2017-shading-course/#course_content)。粗糙度通过 r=sheenRoughness² 映射,以便更直观地理解粗糙度变化。

光泽粗糙度独立于材质的基本粗糙度。这使得材质可以具有粗糙的表面纹理(高基本粗糙度),同时仍然显示出锐利的光泽效果(低光泽粗糙度)。

并非所有入射光都与微纤维相互作用。一些光可能直接到达底层,或者在与纤维碰撞之前在其之间反射。这种光的行为受底层 glTF 2.0 PBR 金属-粗糙度材质属性的控制。

准备工作

本食谱的源代码可在 Chapter07/02_Sheen/ 中找到。

如何实现它…

与之前的小节 实现清漆材质扩展 类似,我们引入了一组新的材质参数。让我们看看 C++ 代码。

  1. 让我们加载一个 .gltf 文件来演示效果 Chapter07/02_Sheen/src/main.cpp
 GLTFContext gltf(app);
  loadGLTF(gltf,
    “deps/src/glTF-Sample-Assets/Models/
      SheenChair/glTF/SheenChair.gltf”,
    “deps/src/glTF-Sample-Assets/Models/SheenChair/glTF/”);
  1. 在文件 shared/UtilsGLTF.h 中,我们在 GLTFMaterialDataGPU 结构体中添加了新的成员字段:
struct GLTFMaterialDataGPU {
  …
  vec4 sheenFactors       = vec4(1.0f, 1.0f, 1.0f, 1.0f);
  uint32_t sheenColorTexture            = 0;
  uint32_t sheenColorTextureSampler     = 0;
  uint32_t sheenColorTextureUV          = 0;
  uint32_t sheenRoughnessTexture        = 0;
  uint32_t sheenRoughnessTextureSampler = 0;
  uint32_t sheenRoughnessTextureUV      = 0;
  1. 让我们通过 Assimp 在 shared/UtilsGLTF.cpp 中加载新的参数并将它们存储到光泽度材质中。光泽度颜色纹理是 sRGB,索引为 0,粗糙度纹理的索引为 1
 loadMaterialTexture(mtlDescriptor, aiTextureType_SHEEN,
    assetFolder, mat.sheenColorTexture, ctx, true, 0);
  loadMaterialTexture(mtlDescriptor, aiTextureType_SHEEN,
    assetFolder, mat.sheenRoughnessTexture, ctx, false, 1);
  bool useSheen = !mat.sheenColorTexture.empty() ||
                  !mat.sheenRoughnessTexture.empty();
  aiColor4D sheenColorFactor;
  if (mtlDescriptor->Get(AI_MATKEY_SHEEN_COLOR_FACTOR,
      sheenColorFactor) == AI_SUCCESS) {
    res.sheenFactors = vec4(sheenColorFactor.r,
                            sheenColorFactor.g,
                            sheenColorFactor.b,
                            sheenColorFactor.a);
    useSheen      = true;
  }
  ai_real sheenRoughnessFactor;
  if (mtlDescriptor->Get(AI_MATKEY_SHEEN_ROUGHNESS_FACTOR,
      sheenRoughnessFactor) == AI_SUCCESS) {
    res.sheenFactors.w = sheenRoughnessFactor;
    useSheen = true;
  }
  if (assignUVandSampler(samplers, mtlDescriptor,
      aiTextureType_SHEEN, res.sheenColorTextureUV,
      res.sheenColorTextureSampler, 0)) {
    useSheen = true;
  }
  if (assignUVandSampler(samplers, mtlDescriptor,
      aiTextureType_SHEEN, res.sheenRoughnessTextureUV,
      res.sheenRoughnessTextureSampler, 1)) {
    useSheen = true;
  }
  if (useSheen) res.materialTypeFlags |= MaterialType_Sheen;
  …

如您所见,我们遵循与清漆扩展相同的模式,并且仅在使用此扩展时设置标志 MaterialType_Sheen。这就是主要的 C++ 代码。

光泽度扩展需要一个不同的 BRDF 函数,这在之前的 第六章,使用 glTF 2.0 着色模型进行基于物理的渲染 中的 预计算 BRDF 查找表 小节中讨论过。我们建议回顾那个小节以刷新您对预计算 BRDF LUT 工作原理的理解,并重新审视实现细节。

现在,让我们看看遵循类似模式的 GLSL 着色器代码更改:

  1. 让我们在 data/shaders/gltf/inputs.frag 中引入一些实用函数。我们可以通过预先乘以光泽度因子来简化这些函数。在 C++ 代码中,我们将 sheenColorTexturesheenRoughnessTexture 设置为使用白色 1x1 纹理,以防在 .gltf 资产中没有提供纹理数据。在这种情况下,总是正确地将这些值乘以单位因子。我们仍然会对这个小型纹理进行纹理查找,但开销最小。这些小型纹理应该始终适合 GPU 的最快缓存:
vec4 getSheenColorFactor(InputAttributes tc,
  MetallicRoughnessDataGPU mat) {
  return vec4(mat.sheenFactors.xyz, 1.0f) *
    textureBindless2D(mat.sheenColorTexture,
                      mat.sheenColorTextureSampler,
                      tc.uv[mat.sheenColorTextureUV]);
}
float getSheenRoughnessFactor(InputAttributes tc,
  MetallicRoughnessDataGPU mat) {
  return mat.sheenFactors.a * textureBindless2D(
    mat.sheenRoughnessTexture,
    mat.sheenRoughnessTextureSampler,
    tc.uv[mat.sheenRoughnessTextureUV]).a;
}
  1. 光泽度扩展的 GLSL 代码分散在主片段着色器 data/shaders/gltf/main.frag 和 PBR 模块 data/shaders/gltf/PBR.sp 之间。在 main.frag 中,我们应用光泽度参数:
 …
  if (isSheen) {
    pbrInputs.sheenColorFactor =
      getSheenColorFactor(tc, mat).rgb;
    pbrInputs.sheenRoughnessFactor =
      getSheenRoughnessFactor(tc, mat);
  }
  …

在 IBL 计算的下一步中,我们累积使用 Charlie 分布计算的光泽度贡献:

 vec3 sheenColor = vec3(0);
  if (isSheen) {
    sheenColor += getIBLRadianceCharlie(pbrInputs, envMap);
  }
  1. 让我们看看文件 data/shaders/gltf/PBR.spgetIBLRadianceCharlie() 的实现。这个函数与用于金属-粗糙度的 getIBLRadianceGGX() 类似,但更简单。光泽度扩展提供了自己的粗糙度值,因此不需要感知调整。我们在这里要做的只是将 sheenRoughnessFactor 乘以总的米普级数 mipCount 以确定正确的米普级,采样预计算的环境图,然后将其乘以 BRDFsheenColor
vec3 getIBLRadianceCharlie(PBRInfo pbrInputs,
  EnvironmentMapDataGPU envMap) {
  float sheenRoughness = pbrInputs.sheenRoughnessFactor;
  vec3 sheenColor = pbrInputs.sheenColorFactor;
  float mipCount = float(sampleEnvMapQueryLevels(envMap));
  float lod = sheenRoughness * float(mipCount - 1);
  vec3 reflection =
    normalize(reflect(-pbrInputs.v, pbrInputs.n));
  vec2 brdfSamplePoint = clamp(vec2(pbrInputs.NdotV,
    sheenRoughness), vec2(0.0, 0.0), vec2(1.0, 1.0));
  float brdf = sampleBRDF_LUT(brdfSamplePoint, envMap).b;
  vec3 sheenSample = sampleCharlieEnvMapLod(
    reflection.xyz, lod, envMap).rgb;
  return sheenSample * sheenColor * brdf;
}
  1. 让我们回到 data/shaders/gltf/main.frag。我们根据 occlusionStrength 的值修改光泽度贡献。光的 l 光泽度计算 lights_sheen 将在本章的最后一个小节 扩展分析光支持 中介绍。现在,假设它只是零。
 vec3 lights_sheen = vec3(0);
  sheenColor = lights_sheen +
    mix(sheenColor, sheenColor * occlusion, occlusionStrength);

这是实现 Sheen 扩展所需的所有额外代码。运行中的演示应用应该看起来如下截图所示:

图 7.3:glTF PBR KHR_materials_sheen 示例

图 7.3:glTF PBR KHR_materials_sheen 示例

还有更多...

Khronos glTF 扩展存储库有一个关于 Sheen 材料的综合参考列表:github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_sheen/README.md

实现 KHR_materials_transmission 扩展

glTF 2.0 核心规范使用了一种处理透明度的基本方法,称为 alpha-as-coverage。虽然这种方法对于像纱或麻布这样的简单材料效果很好,但它并不能很好地表示像玻璃或塑料这样的更复杂透明材料。这些材料涉及复杂的光学相互作用——如反射、折射、吸收和散射——而 alpha-as-coverage 无法独立准确地模拟这些相互作用。

Alpha-as-coverage 基本上决定了表面是否存在。值为 0 表示没有任何东西可见,而值为 1 表示表面是实心的。这种方法对于有孔或缝隙的材料效果很好,这些缝隙可以让光线通过而不实际进入材料。然而,对于像玻璃这样的材料,光线与表面的相互作用更为复杂——如反射、折射,甚至吸收。Alpha-as-coverage 无法处理这些类型的相互作用。此外,它还会影响反射的强度,使得更透明的材料反射更弱。这与现实世界中的透明材料相反,后者即使在透明时也常常有强烈的反射。

为了克服 alpha-as-coverage 的限制,KHR_materials_transmission 扩展提供了一种在 glTF 中更真实地渲染透明材料的方法。它允许根据入射角和光的波长来模拟吸收、反射和透射光线的材料。这个扩展对于准确表示像塑料和玻璃这样的薄表面材料特别有用。

KHR_materials_transmission 扩展针对的是光学透明度最简单的情况:无限薄的、无折射、散射或色散的材料。这种简化使得折射和吸收的计算变得高效。

透射参数

KHR_materials_transmission 扩展为定义材料的透射特性添加了新的属性:

transmissionFactor:一个介于 01 之间的标量值,表示材料的整体不透明度。值为 0 表示材料完全不透明,而值为 1 表示材料完全透明。

transmissionFilter:一个颜色值,会改变通过材料的灯光颜色。

透射 BTDF

KHR_materials_transmission 扩展引入了一个基于微 facet 模型的镜面 BTDF(双向透射分布函数)。它使用与镜面 BRDF(双向反射分布函数)相同的 Trowbridge-Reitz 分布,但沿着视向量而不是反射方向进行采样。这种方法模拟了微 facet 如何像微小的棱镜一样作用,使透射光变得模糊。

透射过程被建模为两个背对背的表面,代表一种薄材料。这种方法通过避免平均折射的复杂性,而是专注于微 facet 层的折射,简化了过程。粗糙度参数影响反射和透射,因为微 facet 分布影响表面的两侧。让我们看看如何实现这个 glTF 扩展。

准备工作

本食谱的源代码位于 Chapter07/03_Transmission/

如何做到这一点…

这是本章讨论的最复杂的扩展,需要更改 C++ 代码以处理透明度渲染的复杂性。除了更新 C++ 渲染代码外,我们还需要在 GLSL 着色器代码中实现镜面 BTDF,并将两层混合结合起来,以准确地表示薄材料。

让我们从 C++ 代码更改开始。

  1. 首先,我们应该在 Chapter07/03_Transmission/src/main.cpp 中加载相应的 .gltf 样本模型:
 GLTFContext gltf(app);
  loadGLTF(gltf, “deps/src/glTF-Sample-Assets/Models/
      TransmissionRoughnessTest/glTF/
      TransmissionRoughnessTest.gltf”,
    “deps/src/glTF-Sample-Assets/Models/
      TransmissionRoughnessTest/glTF/”);
  1. 参数解析在 shared/UtilsGLTF.cpp 中,相当简单:
 loadMaterialTexture(mtlDescriptor, aiTextureType_TRANSMISSION,
    assetFolder, mat.transmissionTexture, ctx, true, 0);
  …
  bool useTransmission = !mat.transmissionTexture.empty();
  ai_real transmissionFactor = 0.0f;
  if (mtlDescriptor->Get(AI_MATKEY_TRANSMISSION_FACTOR,
      transmissionFactor) == AI_SUCCESS) {
    res.clearcoatTransmissionThickness.z = transmissionFactor;
    useTransmission = true;
  }
  if (useTransmission) {
    res.materialTypeFlags |= MaterialType_Transmission;
    useVolumetric = true;
  }
  assignUVandSampler(samplers, mtlDescriptor,
    aiTextureType_TRANSMISSION, res.transmissionTextureUV,
    res.transmissionTextureSampler, 0);

对渲染函数 renderGLTF() 已进行了重大更改。我们在第一个食谱 glTF PBR 扩展简介 中提到了一些这些更改。现在,让我们更详细地看看这些更改。为了有效地渲染透明和透射表面,我们需要遵循以下步骤:

  1. 准备完全不透明、透射和透明节点的列表,因为这些节点应该按照特定顺序渲染:首先是不透明节点,然后是透射,最后是透明节点。

    请记住,渲染透射节点并不会自动使它们变得透明!相反,我们需要使用渲染不透明节点的结果。为此,我们必须创建渲染表面的副本,并将其用作透射节点的输入。

  2. 预分配一个离屏纹理来存储渲染的不透明节点:

 if (gltf.offscreenTex[0].empty() || isSizeChanged) {
    const lvk::Dimensions res =
      ctx->getDimensions(ctx->getCurrentSwapchainTexture());
    for (Holder<TextureHandle>& holder : gltf.offscreenTex) {
      holder = ctx->createTexture({
          .type         = lvk::TextureType_2D,
          .format       = ctx->getSwapchainFormat(),
          .dimensions   = {res.width, res.height},
          .usage        = lvk::TextureUsageBits_Attachment |
                          lvk::TextureUsageBits_Sampled,
          .numMipLevels = lvk::calcNumMipLevels(res.width,
                                                res.height),
          .debugName    = “offscreenTex” });
    }
  }
  1. 在必要时创建屏幕副本,并将其句柄作为 transmissionFramebuffer: 传递。
 const bool screenCopy = gltf.isScreenCopyRequired();
  if (screenCopy) {
    buf.cmdCopyImage(
      gltf.offscreenTex[gltf.currentOffscreenTex],],
      ctx->getCurrentSwapchainTexture(),
      ctx->getDimensions(ctx->getCurrentSwapchainTexture()));
    buf.cmdGenerateMipmap(
      gltf.offscreenTex[gltf.currentOffscreenTex]);
    pushConstants.transmissionFramebuffer =
      gltf.offscreenTex[gltf.currentOffscreenTex].index();
    buf.cmdPushConstants(pushConstants);
  }
  1. 现在,我们可以使用屏幕副本作为输入来渲染透射节点。重要的是要注意,我们在这个过程中不使用 alpha 混合。节点仍然以不透明的方式渲染,我们通过采样屏幕副本来模拟透明度。我们还在这里为 LightweightVK 指定了一个纹理依赖项,以确保应用正确的 Vulkan 障碍:
 buf.cmdBeginRendering(renderPass, framebuffer, { .textures = {
      lvk::TextureHandle(
        gltf.offscreenTex[gltf.currentOffscreenTex]) } });
  buf.cmdBindVertexBuffer(0, gltf.vertexBuffer, 0);
  buf.cmdBindIndexBuffer(
    gltf.indexBuffer, lvk::IndexFormat_UI32);
  buf.cmdBindDepthState({ .compareOp = lvk::CompareOp_Less,
                          .isDepthWriteEnabled = true });
  buf.cmdBindRenderPipeline(gltf.pipelineSolid);
  for (uint32_t transformId : gltf.transmissionNodes) {
    const GLTFTransforms transform =
      gltf.transforms[transformId];
    const GLTFMesh submesh =
      gltf.meshesStorage[transform.meshRef];
    buf.cmdDrawIndexed(submesh.indexCount, 1,
      submesh.indexOffset, submesh.vertexOffset, transformId);
  }
  1. 下一步是按照前后顺序渲染所有透明节点。我们没有更改推送常量,这些透明节点使用与输入相同的离屏纹理:
 buf.cmdBindRenderPipeline(gltf.pipelineTransparent);
  for (uint32_t transformId : gltf.transparentNodes) {
    const GLTFTransforms transform =
      gltf.transforms[transformId];

    const GLTFMesh submesh =
      gltf.meshesStorage[transform.meshRef];
    buf.cmdDrawIndexed(submesh.indexCount, 1,
      submesh.indexOffset, submesh.vertexOffset, transformId);
  }}

C++的更改就到这里。现在让我们看看 GLSL 着色器的更改。

  1. 首先,我们在data/shaders/gltf/inputs.frag中引入一个实用函数来读取材料输入。传输因子存储在纹理的r通道中:
float getTransmissionFactor(InputAttributes tc,
  MetallicRoughnessDataGPU mat) {
  return mat.clearcoatTransmissionThickness.z
         textureBindless2D(mat.transmissionTexture,
           mat.transmissionTextureSampler,
           tc.uv[mat.transmissionTextureUV]
         ).r;
}
  1. 然后,如果此材料的传输扩展被启用,我们在data/shaders/gltf/main.frag中填充输入:
 if (isTransmission) {
    pbrInputs.transmissionFactor =
      getTransmissionFactor(tc, mat);
  }
  1. 我们计算传输贡献。体积部分将在我们关于KHR_materials_volume扩展的下一个配方中详细说明:实现体积扩展。在没有体积扩展的纯传输实现中,传输部分将与 GGX/Lambertian 类似,但不是使用反射向量,而是使用点积NdotV。仅实现传输扩展而不支持体积是不切实际的,因为体积扩展提供了更大的灵活性来表示折射、吸收或散射等效果,而不会在传输之上增加过多的复杂性:
 vec3 transmission = vec3(0,0,0);
  if (isTransmission) {
    transmission += getIBLVolumeRefraction(
      pbrInputs.n,
      pbrInputs.v,
      pbrInputs.perceptualRoughness,
      pbrInputs.diffuseColor,
      pbrInputs.reflectance0,
      pbrInputs.reflectance90,
      worldPos, getModel(), getViewProjection(),
      pbrInputs.ior,
      pbrInputs.thickness,
      pbrInputs.attenuation.rgb,
      pbrInputs.attenuation.a);
  }
  1. 最后,我们将计算出的传输值添加到由transmissionFactor缩放的漫反射贡献中:
 if (isTransmission) {
    diffuseColor = mix(diffuseColor, transmission,
                       pbrInputs.transmissionFactor);
  }

生成的渲染 3D 模型应如下截图所示:

图 7.4:glTF PBR KHR_materials_transmission 示例

图 7.4:glTF PBR KHR_materials_transmission 示例

还有更多...

在实时中高效且准确地渲染透明物体具有挑战性,尤其是在处理重叠的透明多边形时。依赖于顺序的透明度和需要为吸收和反射执行单独的混合操作等问题增加了复杂性。我们将在第十一章,高级渲染技术和优化中解决一些这些问题。

Khronos 扩展存储库为传输扩展提供了全面的参考材料:github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_transmission/README.md

实现 KHR_materials_volume 扩展

KHR_materials_volume扩展为 glTF 2.0 生态系统添加了体积效果,使得可以创建具有深度和内部结构的材料。这对于准确渲染烟雾、雾、云和半透明物体至关重要。

体积效果与基于表面的材料不同。基于表面的材料关注光线与表面的相互作用,而体积材料描述光线通过介质的方式。这包括模拟光线在通过体积时的散射和吸收。

为了创建逼真的体积效果,KHR_materials_volume 扩展需要与其他定义材料表面与光线交互的扩展协同工作。KHR_materials_transmission 扩展在这里是关键,因为它允许光线穿过表面并进入体积。一旦进入,光线与材料的交互不再受表面属性的影响。相反,光线穿过体积,经历折射和衰减。当它离开体积时,其方向由它离开体积边界的角度决定。

让我们探索如何将此扩展添加到我们的 glTF 渲染器中。

体积参数

KHR_materials_volume 扩展定义以下参数来描述体积材料:

  • thicknessFactor:一个表示体积基础厚度的标量浮点值。此值乘以厚度纹理值(如果可用)以确定表面任何点的最终厚度。

  • attenuationDistance:一个表示体积密度随距离降低的距离的浮点值。此参数控制体积不透明度随光线通过而迅速减淡的速度。

  • attenuationColor:一个表示体积衰减的基础颜色的颜色值。此颜色影响光线在穿过体积时的吸收情况。

  • thicknessTexture:一个可选的纹理,它提供了关于体积厚度额外的细节。纹理中的值会乘以 thicknessFactor 以确定表面每个点的最终厚度。

这些参数是如何一起工作的:

  • ThicknessthicknessFactorthicknessTexture 相乘以定义体积的深度。较高的厚度值会导致体积更厚。

  • AttenuationattenuationDistanceattenuationColor 控制光线在穿过体积时的吸收情况。attenuationDistance 的较小值会导致更快的衰减。attenuationColor 的值决定了由于吸收而产生的颜色变化。

目前,KHR_materials_volume 扩展假设体积是均匀的,其中材料属性在整个体积中是均匀的。未来的扩展可能会添加对具有不同属性的非均匀体积的支持。

准备工作

此菜谱的源代码可以在 Chapter07/04_Volume/ 中找到。请查阅之前的菜谱 实现传输扩展,其中我们介绍了传输和体积 C++ 渲染流程。

如何实现...

此扩展只需要在我们的现有 C++ 和 GLSL 着色器代码中进行少量更改。此扩展需要 KHR_materials_transmission 支持,并且仅与其一起工作。

让我们探索如何使用 C++.++ 代码创建高级体积效果,如折射、吸收或散射。

  1. 首先,我们在 Chapter07/04_Volume/main.cpp 中加载相应的 .gltf 模型:
 GLTFContext gltf(app);
  loadGLTF(gltf, “deps/src/glTF-Sample-Assets/Models/
    DragonAttenuation/glTF/DragonAttenuation.gltf”,
   “deps/src/glTF-Sample-Assets/Models/DragonAttenuation/glTF/”);
  1. 使用 Assimp 在shared/UtilsGLTF.cpp中解析体积参数:
 …
  loadMaterialTexture(mtlDescriptor, aiTextureType_TRANSMISSION,
    assetFolder, mat.thicknessTexture, ctx, true, 1);
  bool useVolume = !mat.thicknessTexture.empty();
  ai_real thicknessFactor = 0.0f;
  if (mtlDescriptor->Get(AI_MATKEY_VOLUME_THICKNESS_FACTOR,
      thicknessFactor) == AI_SUCCESS) {
    res.clearcoatTransmissionThickness.w = thicknessFactor;
    useVolume = true;
  }
  ai_real attenuationDistance = 0.0f;
  if (mtlDescriptor->Get(AI_MATKEY_VOLUME_ATTENUATION_DISTANCE,
      attenuationDistance) == AI_SUCCESS) {
    res.attenuation.w = attenuationDistance;
    useVolume =      true;
  }
  aiColor4D volumeAttnuationColorvolumeAttnuationColore;
  if (mtlDescriptor->Get(AI_MATKEY_VOLUME_ATTENUATION_COLOR,
      volumeAttnuationColorvolumeAttnuationColore)==AI_SUCCESS) {
    res.attenuation.x =
      volumeAttnuationColorvolumeAttnuationColore.r;
    res.attenuation.y =
      volumeAttnuationColorvolumeAttnuationColore.g;
    res.attenuation.z =
      volumeAttnuationColorvolumeAttnuationColore.b;
    useVolume = true;
  }
  if (useVolume) {
    res.materialTypeFlags |= MaterialType_Transmission |
                             MaterialType_Volume;
    useVolumetric = true;
  }
  assignUVandSampler(samplers, mtlDescriptor,
    aiTextureType_TRANSMISSION, res.thicknessTextureUV,
    res.thicknessTextureSampler, 1);
  …

大部分的魔法都隐藏在 GLSL 着色器中。让我们进入data/shaders/gltf/PBR.sp并检查重要的辅助函数。

  1. 函数getVolumeTransmissionRay()计算折射光线的方向refractionVector,并使用modelScale因子在体积内获取实际的查找向量。请注意,thickness因子被设计为归一化到网格的实际比例。
vec3 getVolumeTransmissionRay(
  vec3 n, vec3 v, float thickness, float ior, mat4 modelMatrix)
{
  vec3 refractionVector = refract(-v, n, 1.0 / ior);
  1. 计算模型矩阵的旋转无关缩放。thickness因子在局部空间中指定:
 vec3 modelScale = vec3(length(modelMatrix[0].xyz),
                         length(modelMatrix[1].xyz),
                         length(modelMatrix[2].xyz));
  return
    normalize(refractionVector) * thickness * modelScale.xyz;
}

另一个辅助函数是getIBLVolumeRefraction()。此函数有几个重要步骤:

  1. 第一步是获取一个透射光线transmissionRay并计算最终的折射位置:
vec3 getIBLVolumeRefraction(vec3 n, vec3 v,
  float perceptualRoughness, vec3 baseColor, vec3 f0, vec3 f90,
  vec3 position, mat4 modelMatrix, mat4 viewProjMatrix,
  float ior, float thickness, vec3 attenuationColor,
  float attenuationDistance)
{
  vec3 transmissionRay =
    getVolumeTransmissionRay(n, v, thickness, ior, modelMatrix);
  vec3 refractedRayExit = position + transmissionRay;
  1. 我们将折射向量投影到帧缓冲区,并将其映射到归一化设备坐标以采样折射光线击中帧缓冲区的像素的颜色。折射的帧缓冲区坐标应从-1...+1范围转换为0...1,然后垂直翻转:
 vec4 ndcPos = viewProjMatrix * vec4(refractedRayExit, 1.0);
  vec2 refractionCoords = ndcPos.xy / ndcPos.w;
  refractionCoords += 1.0;
  refractionCoords /= 2.0;
  refractionCoords.y = 1.0 - refractionCoords.y;
  vec3 transmittedLight = getTransmissionSample(
    refractionCoords, perceptualRoughness, ior);
  1. 之后,我们应用体积衰减并采样 GGX BRDF 以获取镜面分量,并通过baseColorattenuatedColor对其进行调制:
 vec3 attenuatedColor = applyVolumeAttenuation(
    transmittedLight,
    length(transmissionRay), attenuationColor,
    attenuationDistance);
  float NdotV = clampedDot(n, v);
  vec2 brdfSamplePoint = clamp(vec2(NdotV, perceptualRoughness),
    vec2(0.0, 0.0), vec2(1.0, 1.0));
  vec2 brdf = sampleBRDF_LUT(brdfSamplePoint,
    getEnvironmentMap(getEnvironmentId())).rg;
  vec3 specularColor = f0 * brdf.x + f90 * brdf.y;
  return (1.0 - specularColor) * attenuatedColor * baseColor;
}
  1. 这是函数getTransmissionSample()。我们使用先前的配方实现透射扩展中解释的帧缓冲区副本:
vec3 getTransmissionSample(
  vec2 fragCoord, float roughness, float ior)
{
  const ivec2 size =
    textureBindlessSize2D(perFrame.transmissionFramebuffer);
  const vec2 uv = fragCoord;
  float framebufferLod =
    log2(float(size.x)) * applyIorToRoughness(roughness, ior);
  vec3 transmittedLight = textureBindless2DLod(
    perFrame.transmissionFramebuffer,
    perFrame.transmissionFramebufferSampler,
    uv, framebufferLod).rgb;
  return transmittedLight;
}
  1. 辅助函数applyVolumeAttenuation()看起来如下。0的衰减距离意味着透射颜色完全没有衰减。光衰减是使用 Beer-Lambert 定律计算的en.wikipedia.org/wiki/Beer%E2%80%93Lambert_law
vec3 applyVolumeAttenuation(vec3 radiance,
  float transmissionDistance, vec3 attenuationColor,
  float attenuationDistance)
{
  if (attenuationDistance == 0.0) return radiance;
  vec3 attenuationCoefficient =
    -log(attenuationColor) / attenuationDistance;
  vec3 transmittance =
    exp(-attenuationCoefficient * transmissionDistance);
  return transmittance * radiance;
}
  1. 现在,我们可以回到data/shaders/gltf/main.frag并使用getIBLVolumeRefraction(),以及其他在先前的配方实现透射扩展中描述的辅助函数:
 …
  vec3 transmission = vec3(0,0,0);
  if (isTransmission) {
    transmission += getIBLVolumeRefraction(
      pbrInputs.n, pbrInputs.v,
      pbrInputs.perceptualRoughness,
      pbrInputs.diffuseColor, pbrInputs.reflectance0,
      pbrInputs.reflectance90,
      worldPos, getModel(), getViewProjection(),
      pbrInputs.ior, pbrInputs.thickness,
      pbrInputs.attenuation.rgb, pbrInputs.attenuation.w);
  }

结果的演示应用程序应该渲染一个半透明的龙,类似于下面的截图。你可以围绕龙移动相机来观察光线从不同角度与体积的交互。这将允许你看到光线如何穿过介质并与体积材料交互。

图 7.5: glTF PBR KHR_materials_volume 示例

在我们探索了一系列复杂的 PBR glTF 扩展之后,是时候转换方向了。让我们看看一些更直接的东西:实现折射率扩展。这个更简单的扩展是继续构建你的理解的好方法,同时让你从我们之前覆盖的更复杂主题中休息一下。

还有更多...

实现此扩展的另一种方法可能涉及体积光线投射或光线追踪。我们将此留作读者练习。

Khronos 扩展存储库为此扩展提供了全面的参考材料:github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_volume/README.md#overview

实现 KHR_materials_ior 扩展

glTF 2.0 的 KHR_materials_ior 扩展为材质添加了折射率IOR)的概念,允许更精确和逼真的透明物体模拟。折射率是关键材质属性,决定了光线通过物质时的弯曲程度 en.wikipedia.org/wiki/Refractive_index

IOR 是一个无量纲数,表示光在真空中速度与在特定介质中速度的比值。不同材料有不同的 IOR 值,这影响了光线进入或离开材料时的弯曲程度。较高的 IOR 意味着更多的折射。例如,空气的 IOR 接近 1,水的 IOR 大约是 1.33,而玻璃的 IOR 大约是 1.5

IOR 参数

KHR_materials_ior 扩展向 glTF 材质定义添加了一个属性:

ior:表示材料折射率的浮点值。

此值与 KHR_materials_transmission 扩展结合使用,以计算光线通过材料时的折射方向。

准备工作

此菜谱的源代码可在 Chapter07/05_IOR/ 中找到。查看菜谱 实现传输扩展 以回顾 KHR_materials_transmission 的实现方式。

如何实现它...

此扩展只需对 C++ 和 GLSL 着色器代码进行少量更改。让我们从 C++ 开始。

  1. Chapter07/05_IOR/main.cpp 中,加载相应的 .gltf 模型:
 GLTFContext gltf(app);
  loadGLTF(gltf,
    “deps/src/glTF-Sample-Assets/Models/
      MosquitoInAmber/glTF/MosquitoInAmber.gltf”,
    “deps/src/glTF-Sample-Assets/Models/MosquitoInAmber/glTF/”);
  1. 这里是 shared/UtilsGLTF.cpp 中用于使用 Assimp 解析 IOR 材质参数的代码。你会注意到我们没有设置任何材质标志,这不是必需的。IOR 只是一个值,不会改变着色器的功能。
ai_real ior;
if (mtlDescriptor->Get(AI_MATKEY_REFRACTI, ior) == AI_SUCCESS) {
  res.ior = ior;
}

下面是 GLSL 着色器代码。我们需要修改几行:

  1. 第一行在 PBR 模块 data/shaders/gltf/PBR.sp 中的函数 calculatePBRInputsMetallicRoughness() 中。默认折射率值 ior = 1.5 导致 f0 项计算为 0.04
PBRInfo calculatePBRInputsMetallicRoughness(InputAttributes tc,
  vec4 albedo, vec4 mrSample, MetallicRoughnessDataGPU mat) {
  PBRInfo pbrInputs;
  …
  vec3 f0 = isSpecularGlossiness ?
    getSpecularFactor(mat) * mrSample.rgb :
    vec3(pow((pbrInputs.ior - 1)/( pbrInputs.ior + 1), 2));

第二行在 data/shaders/gltf/main.frag 中,并修改了清漆反射率值:

 if (isClearCoat) {
    …
    pbrInputs.clearcoatF0 = vec3(
      pow((pbrInputs.ior - 1.0) / (pbrInputs.ior + 1.0), 2.0));
    …
  }

就这样!应用程序现在应该可以渲染出被琥珀包裹的蚊子,如下面的截图所示:

图 7.6:glTF PBR KHR_materials_ior 示例

图 7.6:glTF PBR KHR_materials_ior 示例

还有更多...

官方扩展规范包括一个规范性部分,解释了此 glTF 扩展KHR_material_ior如何与其他扩展交互:github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_ior/README.md

实现 KHR_materials_specular 扩展

在上一章中,我们讨论了镜面-光泽度PBR 模型。其主要问题之一是与大多数其他扩展不兼容。这是因为它引入了非物理基础的材料属性,包括在镜面-光泽度模式下对介电质和金属的模糊区分,这使得它无法与金属-粗糙度模型或其他扩展属性结合。

作为一种替代方案,Khronos 提出了KHR_materials_specular扩展,它解决了这些问题,并提供了KHR_materials_pbrSpecularGlossiness的功能,同时不牺牲金属-粗糙度PBR 模型的物理精度。这使得它与大多数 glTF PBR 扩展兼容。在撰写本文时,KHR_materials_specular扩展仅与KHR_materials_pbrSpecularGlossinessKHR_materials_unlit扩展不兼容。

KHR_materials_specular扩展允许对 glTF 材质中的镜面反射进行更精确的控制。虽然核心的 glTF 规范包括基本的镜面 BRDF,但此扩展引入了额外的参数,以更好地微调镜面高光的外观。

镜面参数

KHR_materials_specular扩展引入了几个参数以增强镜面反射:

  • specularFactor/specularTexture: 一个标量值,用于缩放整体镜面反射的强度。

  • specularColorFactor/specularColorTexture: 一个颜色值,用于修改镜面反射的颜色。

镜面-光泽度转换

您可以使用上一节中描述的KHR_materials_ior扩展将镜面-光泽度材质转换为金属-粗糙度工作流程。通过将IOR参数设置为0,材质被视为具有最大镜面反射的介电质。IOR参数控制镜面反射强度的上限,将其设置为0将最大化这种强度,允许通过specularColorFactor完全控制镜面反射。这种方法消除了将材料分类为介电质或金属的需求。需要注意的是,使用KHR_materials_volume扩展的材料与此转换不兼容,因为它们的IOR值不为零。对于新材料,通常直接使用金属-粗糙度模型会更好。

准备工作

这个菜谱的源代码可以在Chapter07/06_Specular/中找到。重新阅读两个之前的菜谱实现折射率扩展实现体积扩展,因为这个扩展与它们交互。

如何做到这一点...

除了通过 Assimp 读取额外的材质属性之外,这个扩展不需要对 C++代码进行任何重大更改:

  1. 让我们在Chapter07/06_Specular/src/main.cpp中加载一个新的.gltf模型:
 VulkanApp app({
      .initialCameraPos    = vec3(0.0f, -0.5f, -1.0f),
      .initialCameraTarget = vec3(0.0f, -1.0f, 0.0f),
  });
  GLTFContext gltf(app);
  loadGLTF(gltf, “deps/src/glTF-Sample-Assets/Models/
    SpecularSilkPouf/glTF/SpecularSilkPouf.gltf”,
    “deps/src/glTF-Sample-Assets/Models/SpecularSilkPouf/glTF/”);
  1. 为了使这个例子更有趣,我们在我们的 3D 模型中添加了旋转:
 const bool rotateModel = true;
  const mat4 t = glm::translate(mat4(1.0f), vec3(0, -1, 0));
  app.run(&
  {
    const mat4 m = t * glm::rotate(mat4(1.0f),
      rotateModel ? (float)glfwGetTime() : 0.0f,
      vec3(0.0f, 1.0f, 0.0f));
    const mat4 p =
      glm::perspective(45.0f, aspectRatio, 0.01f, 100.0f);
    renderGLTF(gltf, m, app.camera_.getViewMatrix(), p);
  });
  1. 现在,让我们在shared/UtilsGLTF.cpp中加载材质属性:
 loadMaterialTexture(mtlDescriptor, aiTextureType_SPECULAR,
    assetFolder, mat.specularTexture, ctx, true, 0);
  loadMaterialTexture(mtlDescriptor, aiTextureType_SPECULAR,
    assetFolder, mat.specularColorTexture, ctx, true, 1);…
  bool useSpecular = !mat.specularColorTexture.empty() ||
                     !mat.specularTexture.empty();
  ai_real specularFactor;
  if (mtlDescriptor->Get(AI_MATKEY_SPECULAR_FACTOR,
      specularFactor) == AI_SUCCESS) {
    res.specularFactors.w = specularFactor;
    useSpecular           = true;
  }
  assignUVandSampler(samplers, mtlDescriptor,
    aiTextureType_SPECULAR, res.specularTextureUV,
    res.specularTextureSampler, 0);
  aiColor4D specularColorFactor;
  if (mtlDescriptor->Get(AI_MATKEY_COLOR_SPECULAR,
      specularColorFactor) == AI_SUCCESS) {
    res.specularFactors = vec4(specularColorFactor.r,
                               specularColorFactor.g,
                               specularColorFactor.b,
                               res.specularFactors.w);
    useSpecular = true;
  }
  assignUVandSampler(samplers, mtlDescriptor,
    aiTextureType_SPECULAR, res.specularColorTextureUV,
    res.specularColorTextureSampler, 1);
  if (useSpecular)
    res.materialTypeFlags |= MaterialType_Specular;
}

GLSL 着色器更改稍微复杂一些。specularColor参数将颜色变化引入到镜面反射中。它集成到菲涅耳项中,影响不同观察角度的镜面反射率。在正常入射时,镜面颜色直接缩放基础反射率(F0),而在掠射角时,反射率接近1.0,无论镜面颜色如何。为了保持能量守恒,使用镜面颜色的最大分量来计算菲涅耳项的缩放因子,防止镜面反射中能量过多。

  1. 首先,我们在data/shaders/gltf/inputs.frag中引入一些实用函数:
vec3 getSpecularColorFactor(InputAttributes tc,
  MetallicRoughnessDataGPU mat) {
  return mat.specularFactors.rgb *
    textureBindless2D(mat.specularColorTexture,
                      mat.specularColorTextureSampler,
                      tc.uv[mat.specularColorTextureUV]).rgb;
}
float getSpecularFactor(InputAttributes tc,
  MetallicRoughnessDataGPU mat) {
  return mat.specularFactors.a *
    textureBindless2D(mat.specularTexture,
                      mat.specularTextureSampler,
                      tc.uv[mat.specularTextureUV]).a;
}
  1. 我们在data/shaders/gltf/PBR.sp中的PBRInfo结构中添加了一个新的字段specularWeight
struct PBRInfo {
  …
  float specularWeight;
};
  1. 我们修改了获取F0反射率的方法,并在calculatePBRInputsMetallicRoughness()函数中填充specularWeigthspecularWeigthh字段:
PBRInfo calculatePBRInputsMetallicRoughness(InputAttributes tc,
  vec4 albedo, vec4 mrSample, MetallicRoughnessDataGPU mat) { 
  …
  if (isSpecular) {
    vec3 dielectricSpecularF0 =
      min(f0 *       getSpecularColorFactor(tc, mat), vec3(1.0));
    f0 = mix(dielectricSpecularF0, pbrInputs.baseColor.rgb,
             metallic);
    pbrInputs.specularWeight = getSpecularFactor(tc, mat);
  }
  …
  vec3 specularColor = isSpecularGlossiness ? f0 :
    mix(f0, pbrInputs.baseColor.rgb, metallic);
  float reflectance = max(
    max(specularColor.r, specularColor.g), specularColor.b);
  …
  1. 现在,我们可以在data/shaders/gltf/main.frag中使用这些参数来计算 IBL 贡献的镜面和漫反射分量:
 vec3 specularColor = getIBLRadianceContributionGGX(
    pbrInputs, pbrInputs.specularWeight, envMap);
  vec3 diffuseColor = getIBLRadianceLambertian(pbrInputs.NdotV,
    n, pbrInputs.perceptualRoughness, pbrInputs.diffuseColor,
    pbrInputs.reflectance0, pbrInputs.specularWeight, envMap);
  1. 这里是如何计算镜面贡献的。请注意,我们在getIBLRadianceContributionGGX()函数的末尾乘以specularWeight
vec3 getIBLRadianceContributionGGX(PBRInfo pbrInputs,
  float specularWeight, EnvironmentMapDataGPU envMap) {
  vec3 n = pbrInputs.n;
  vec3 v = pbrInputs.v;
  vec3 reflection = normalize(reflect(-v, n));
  float mipCount = float(sampleEnvMapQueryLevels(envMap));
  float lod = pbrInputs.perceptualRoughness * (mipCount - 1);
  vec2 brdfSamplePoint = clamp(
    vec2(pbrInputs.NdotV, pbrInputs.perceptualRoughness),
    vec2(0.0, 0.0), vec2(1.0, 1.0));
  vec3 brdf = sampleBRDF_LUT(brdfSamplePoint, envMap).rgb;
  vec3 specularLight =
    sampleEnvMapLod(reflection.xyz, lod, envMap).rgb;
  vec3 Fr = max(vec3(1.0 - pbrInputs.perceptualRoughness),
                pbrInputs.reflectance0) - pbrInputs.reflectance0;
  vec3 k_S =
    pbrInputs.reflectance0 + Fr * pow(1.0-pbrInputs.NdotV, 5.0);
  vec3 FssEss = k_S * brdf.x + brdf.y;
  return specularWeight * specularLight * FssEss;
}
  1. 漫反射贡献看起来是这样的。注意,我们缩放菲涅耳项,并用vec3 RGB 值替换它,以将specularColor贡献纳入F0
vec3 getIBLRadianceLambertian(float NdotV, vec3 n,
  float roughness, vec3 diffuseColor, vec3 F0,
  float specularWeight, EnvironmentMapDataGPU envMap) {
  vec2 brdfSamplePoint =
    clamp(vec2(NdotV, roughness), vec2(0., 0.), vec2(1., 1.));
  vec2 f_ab = sampleBRDF_LUT(brdfSamplePoint, envMap).rg;
  vec3 irradiance = sampleEnvMapIrradiance(n.xyz, envMap).rgb;
  vec3 Fr = max(vec3(1.0 - roughness), F0) - F0;
  vec3 k_S = F0 + Fr * pow(1.0 - NdotV, 5.0);
  vec3 FssEss = specularWeight * k_S * f_ab.x + f_ab.y;
  float Ems = (1.0 - (f_ab.x + f_ab.y));
  vec3 F_avg = specularWeight * (F0 + (1.0 - F0) / 21.0);
  vec3 FmsEms = Ems * FssEss * F_avg / (1.0 - F_avg * Ems);
  vec3 k_D = diffuseColor * (1.0 - FssEss + FmsEms);
  return (FmsEms + k_D) * irradiance;
}

这些就是实现我们 glTF 渲染器中的KHR_materials_specular扩展所需的所有更改。演示应用程序应该渲染一个如以下截图所示的旋转环:

图 7.7:glTF PBR KHR_materials_specular 示例

图 7.7:glTF PBR KHR_materials_specular 示例

关于这种方法的动机的更多细节,请参阅 Khronos 规范github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_specular/README.md#implementation

还有更多...

Khronos 扩展页面 github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_specular/README.md 提供了关于扩展各个方面的全面信息。它包括对 BRDF 的详细解释以及不同 PBR 模型之间转换的额外见解。

实现 KHR_materials_emissive_strength 扩展

金属-粗糙度核心模型支持光发射,但在 KHR_materials_emissive_strength 扩展引入之前,控制材质光发射的强度很困难。这使得创建逼真的发光物体或场景中作为光源的材质变得具有挑战性。

KHR_materials_emissive_strength 扩展通过引入一个名为 emissiveStrength 的新属性来克服这一限制。这个属性允许对材质发出的光强度进行精确控制。从 0.0(无发射)到更高的值(增加强度),艺术家和设计师可以更好地控制场景中的照明。

准备工作

本菜谱的源代码可在 Chapter07/07_EmissiveStrength/ 中找到。

如何实现...

这个扩展是最容易实现的之一。它只需要加载一个强度值并将其应用于现有的发射值。本质上,你只需要从 Assimp 中获取 emissiveStrength 属性,该属性决定了材质发出的光应该有多强烈,并将其乘以发射颜色。

  1. 让我们在 Chapter07/07_EmissiveStrength/src/main.cpp 中加载一个新的 3D 模型来演示这个扩展。
 VulkanApp app({
    .initialCameraPos    = vec3(0.0f, 5.0f, -10.0f),
  });
  GLTFContext gltf(app);
  loadGLTF(gltf, “deps/src/glTF-Sample-Assets/Models/
      EmissiveStrengthTest/glTF/EmissiveStrengthTest.gltf”,
    “deps/src/glTF-Sample-Assets/Models/
      EmissiveStrengthTest/glTF/”);
  1. 下面是存储在 shared/UtilsGLTF.cpp 中的 C++ 代码,用于从 Assimp 中检索材质属性:
 if (mtlDescriptor->Get(AI_MATKEY_COLOR_EMISSIVE,
     aiColor) == AI_SUCCESS) {
    res.emissiveFactorAlphaCutoff = vec4(aiColor.r,
                                         aiColor.g,
                                         aiColor.b, 0.5f);
  }
  assignUVandSampler(samplers, mtlDescriptor,
    aiTextureType_EMISSIVE,
    res.emissiveTextureUV,
    res.emissiveTextureSampler);
  ai_real emissiveStrength = 1.0f;
  if (mtlDescriptor->Get(AI_MATKEY_EMISSIVE_INTENSITY,
     emissiveStrength) == AI_SUCCESS) {
    res.emissiveFactorAlphaCutoff *= vec4(
      emissiveStrength, emissiveStrength, emissiveStrength, 1.0);
  }

最终的演示应用程序应该渲染一组五个发光的立方体,如下面的截图所示:

图 7.8:glTF PBR KHR_materials_emissive_strength 示例

图 7.8:glTF PBR KHR_materials_emissive_strength 示例

现在,让我们跳到本章的最后一道菜谱,我们将深入实现对 glTF 分析光的支持。

使用 KHR_lights_punctual 扩展扩展分析光支持

这是本章的最后一道菜谱,我们将为我们的 glTF 查看器添加对分析光源的支持。在下一章中,我们将介绍 KHR_lights_punctual 扩展,它将允许我们直接从 glTF 资产中加载照明信息。在这个菜谱中,我们只处理着色器更改。

在 glTF PBR 的背景下,术语“分析”和“点光源”经常互换使用,以描述同一种类型的光源:

  • 分析光: 这指的是由数学方程定义的光源,能够精确计算其对照明效果的影响。

  • 点光源:这描述了无限小的点光源,在特定方向和强度上发射光线。

我们将在下一章中更详细地探讨这些概念。在本食谱中,为了简单起见,我们将这两个术语交替使用。

基于图像的光照与点光源

让我们回顾一下基于图像(IBL)和点光源之间的区别。

IBL 使用预计算的环境图模拟环境中的间接光照。在 glTF PBR 中,该环境图根据粗糙度和法线方向进行过滤,以近似入射辐射。反射光使用基于表面材料属性的 BRDF双向反射分布函数)进行计算,通过半球进行积分,以考虑来自所有方向的光。另一方面,点光源表示特定的光源,如点光源、聚光灯和方向性光源。对于每个表面点,计算光的方向和距离,并根据光源的远近应用衰减。还考虑了阴影,以检查光线是否到达表面。然后,使用 BRDF 根据光方向、表面法线和材料属性计算反射光。由于需要为每个单独的光源计算光照,因此这种方法比 IBL 计算成本更高。

让我们看看如何将 glTF 点光源添加到我们的查看器中。

准备工作

本食谱的源代码可以在 Chapter07/08_AnalyticalLight 中找到。

如何实现它...

C++ 代码更改很小且直接。我们引入了额外的结构来提供光照信息数据。

  1. 首先,让我们在 Chapter07/08_AnalyticalLight/src/main.cpp 中加载相应的 .gltf 模型。
 VulkanApp app({
    .initialCameraPos    = vec3(0.0f, 3.5f, -5.0f),
    .initialCameraTarget = vec3(0.0f, 2.0f, 0.0f),
  });
  GLTFContext gltf(app);
  loadGLTF(gltf, “deps/src/glTF-Sample-Assets/Models/
      LightsPunctualLamp/glTF/LightsPunctualLamp.gltf”,
    “deps/src/glTF-Sample-Assets/Models/
      LightsPunctualLamp/glTF/”);
  1. shared/UtilsGLTF.h 中声明一个用于不同光照类型的枚举:
enum LightType : uint32_t {
  LightType_Directional = 0,
  LightType_Point       = 1,
  LightType_Spot        = 2,
};
  1. 这里有一个名为 LightDataGPU 的结构,用于在 GPU 缓冲区中存储光照信息。它具有定义一个虚拟方向光源的默认值:
struct LightDataGPU {
  vec3 direction     = vec3(0, 0, 1);
  float range        = 10000.0;
  vec3 color         = vec3(1, 1, 1);
  float intensity    = 1.0;
  vec3 position      = vec3(0, 0, -5);
  float innerConeCos = 0.0;
  float outerConeCos = 0.78;
  LightType type     = LightType_Directional;
  int padding[2];
};
struct EnvironmentsPerFrame {
  EnvironmentMapDataGPU environments[kMaxEnvironments];
  LightDataGPU lights[kMaxLights];
  uint32_t lightCount;
};
  1. 我们在 shared/UtilsGLTF.cpp 中将光源设置为每帧常量的一部分:
const EnvironmentsPerFrame envPerFrame = {
  .environments = { {
      …
    } },
  .lights     = { LightDataGPU() },
  .lightCount = 1,
};

GLSL 着色器代码的更改很大。我们需要重新实现金属-粗糙度和其他扩展的镜面和漫反射贡献,并对每个光源单独应用这些计算。在本食谱中,我们不会深入探讨实现细节,但强烈建议您查看注释中提供的实际着色器和参考材料,以全面理解这个主题。以下是更改的简要概述。

  1. 让我们在 data/shaders/gltf/inputs.frag 中引入一些实用函数,以便方便地访问光照数据:
uint getLightsCount() {
  return perFrame.environments.lightsCount;
}
Light getLight(uint i) {
  return perFrame.environments.lights[i];
}
  1. data/shaders/gltf/main.frag 中,我们为每个单独的贡献组件引入累积变量:
 vec3 lights_diffuse      = vec3(0);
  vec3 lights_specular     = vec3(0);
  vec3 lights_sheen        = vec3(0);
  vec3 lights_clearcoat    = vec3(0);
  vec3 lights_transmission = vec3(0);
  float albedoSheenScaling = 1.0;
  1. 我们遍历所有光源,计算每个光源所需的项,并检查光源是否可以从渲染点看到。然后我们计算光强度:
 for (int i = 0; i < getLightsCount(); ++i) {
    Light light = getLight(i);
    vec3 l = normalize(pointToLight);
    vec3 h = normalize(l + v);
    float NdotL = clampedDot(n, l);
    float NdotV = clampedDot(n, v);
    float NdotH = clampedDot(n, h);
    float LdotH = clampedDot(l, h);
    float VdotH = clampedDot(v, h);
    if (NdotL > 0.0 || NdotV > 0.0) {
      vec3 intensity = getLightIntensity(light, pointToLight);

为每个对象评估所有光源可能会相当昂贵。在这种情况下,集群或延迟着色等替代方案可以帮助提高性能。

  1. 然后我们计算这个光源的漫反射和镜面反射贡献:
 lights_diffuse += intensity * NdotL *
        getBRDFLambertian(pbrInputs.reflectance0,
          pbrInputs.reflectance90, pbrInputs.diffuseColor,
          pbrInputs.specularWeight, VdotH);
      lights_specular += intensity * NdotL *
        getBRDFSpecularGGX(pbrInputs.reflectance0,
          pbrInputs.reflectance90, pbrInputs.alphaRoughness,
          pbrInputs.specularWeight, VdotH, NdotL, NdotV, NdotH);
  1. 光泽贡献现在是按照以下方式计算的:
 if (isSheen) {
        lights_sheen += intensity *
          getPunctualRadianceSheen(pbrInputs.sheenColorFactor,
            pbrInputs.sheenRoughnessFactor, NdotL, NdotV, NdotH);
        albedoSheenScaling =
          min(1.0 - max3(pbrInputs.sheenColorFactor) *
            albedoSheenScalingFactor(NdotV,
            pbrInputs.sheenRoughnessFactor),
          1.0 - max3(pbrInputs.sheenColorFactor) *
          albedoSheenScalingFactor(NdotL,
            pbrInputs.sheenRoughnessFactor));
      }
  1. 新的清漆贡献以类似的方式计算。为了简洁,这里省略了传输和体积贡献:
 if (isClearCoat) {
        lights_clearcoat += intensity *
          getPunctualRadianceClearCoat(
            pbrInputs.clearcoatNormal, v, l, h, VdotH,
        pbrInputs.clearcoatF0, pbrInputs.clearcoatF90,
          pbrInputs.clearcoatRoughness);
      }
      … // transmission & volume effects are skipped for brevity
    }
  }
  1. 我们使用了一个辅助函数 getLightIntensity(),该函数在 data/shaders/gltf/PBR.sp 文件中声明:
vec3 getLightIntensity(Light light, vec3 pointToLight) {
  float rangeAttenuation = 1.0;
  float spotAttenuation = 1.0;
  if (light.type != LightType_Directional) {
    rangeAttenuation =
      getRangeAttenuation(light.range, length(pointToLight));
  }
  if (light.type == LightType_Spot) {
    spotAttenuation = getSpotAttenuation(pointToLight,
      light.direction, light.outerConeCos, light.innerConeCos);
  }
  return rangeAttenuation * spotAttenuation *
    light.intensity * light.color;
}

其他辅助函数,例如 getBRDFLambertian()getBRDFSpecularGGX()getBRDFSpecularSheen()getPunctualRadianceSheen() 以及在前面提到的 GLSL 代码中提到的许多其他函数,都在 data/shaders/gltf/PBR.sp 文件中定义。这些函数包含了计算特定项的数学公式。为了简洁,我们在此不包括它们。

运行的应用程序应该渲染一个由分析方向光源照亮的网格,如下面的截图所示:

图 7.9:分析光源示例

图 7.9:分析光源示例

通过这个例子,我们结束了关于高级 glTF PBR 扩展的章节。我们深入探讨了复杂主题,并扩展了我们如何使用各种光照模型和扩展的理解。在下一章,图形渲染管线中,我们将关注 3D 场景的更广泛组织。我们将探讨需要高效管理和渲染多个 3D 模型的各种数据结构和策略。这将涉及对如何结构和优化数据以进行渲染的详细分析,以确保复杂场景的平滑和有效可视化。

posted @ 2025-10-25 10:35  绝不原创的飞龙  阅读(32)  评论(0)    收藏  举报