Vulkan-秘籍-全-
Vulkan 秘籍(全)
原文:
zh.annas-archive.org/md5/5fc6a4ce93d96ee7fc18b5cbfe163f9b译者:飞龙
前言
计算机图形学有着非常漫长和有趣的历史。许多用于生成 2D 或 3D 图像的 API 或定制方法已经出现又消失。这一历史中的一个里程碑是 OpenGL 的发明,这是最早的图形库之一,它使我们能够创建实时、高性能的 3D 图形,并且对多个操作系统上的每个人都是可用的。它至今仍在开发和广泛使用。今年,我们可以庆祝它的 25 岁生日!
自从 OpenGL 被创建以来,许多事情都发生了变化。图形硬件行业正在快速发展。最近,为了适应这些变化,提出了 3D 图形渲染的新方法。它采取了低级访问图形硬件的形式。OpenGL 被设计为一个高级 API,它允许用户轻松地在屏幕上渲染图像。但这种高级方法,虽然对用户来说很方便,但对图形驱动程序来说却很难处理。这是限制硬件以展示其全部潜力的主要原因之一。新的方法试图克服这些挑战——它给用户提供了对硬件的更多控制,但也带来了更多的责任。这样,应用程序开发者就可以释放图形硬件的全部潜力,因为驱动程序不再阻止他们。低级访问允许驱动程序变得更小、更薄。但这些好处是以开发者需要做更多工作为代价的。
图形渲染新方法的第一个传道者是 AMD 设计的 Mantle API。当它证明低级访问可以带来相当的性能优势时,其他公司开始着手开发自己的图形库。新趋势中最引人注目的是由苹果设计的 Metal API 和由微软开发的 DirectX 12。
但上述所有库都是针对特定的操作系统和/或硬件开发的。没有像 OpenGL 这样的开放和跨平台标准。直到去年。2016 年,Khronos 联盟开发的 Vulkan API 发布,该联盟维护 OpenGL 库。Vulkan 也代表了新的方法,即对图形硬件的低级访问,但与其它库不同,它对多个操作系统和硬件平台上的每个人都是可用的——从配备 Windows 或 Linux 操作系统的性能强大的桌面计算机,到运行 Android OS 的移动设备。由于它仍然非常新,因此很少有资源教授开发者如何使用它。这本书试图填补这一空白。
本书涵盖的内容
第一章,实例和设备,展示了如何开始使用 Vulkan API。本章解释了从哪里下载 Vulkan SDK,如何连接到 Vulkan 加载库,如何选择将要执行操作的物理设备,以及如何准备和创建逻辑设备。
第二章,图像呈现,描述了如何在屏幕上显示 Vulkan 生成的图像。它解释了什么是交换链以及创建它所需的参数,这样我们就可以用它进行渲染并查看我们的工作结果。
第三章,命令缓冲区和同步,涉及将各种操作记录到命令缓冲区并将它们提交到队列中,在那里它们由硬件处理。本章还介绍了各种同步机制。
第四章,资源和内存,介绍了两种基本且最重要的资源类型,图像和缓冲区,它们允许我们存储数据。我们解释了如何创建它们,如何为这些资源准备内存,以及如何从我们的应用程序(CPU)将数据上传到它们。
第五章,描述符集,解释了如何将创建的资源提供给着色器。我们解释了如何准备资源以便在着色器中使用,以及如何设置描述符集,这些描述符集构成了应用程序和着色器之间的接口。
第六章,绘制传递和帧缓冲区,展示了如何将绘制操作组织成称为子传递的单独步骤的集合,这些子传递被组织成绘制传递。在本章中,我们还展示了如何准备绘制过程中使用的附件(渲染目标)的描述,以及如何根据这些描述创建帧缓冲区,这些帧缓冲区根据这些描述绑定特定的资源。
第七章,着色器,描述了编程所有可用图形和计算着色器阶段的具体细节。本章展示了如何使用 GLSL 编程语言实现着色器程序,以及如何将它们转换为 SPIR-V 汇编——这是核心 Vulkan API 接受的唯一形式。
第八章,图形和计算管线,展示了创建两种可用管线类型的过程。这些类型用于设置图形硬件所需的全部参数,以便正确处理绘制命令或计算工作。
第九章,命令录制和绘制,涉及记录成功绘制 3D 模型或调度计算工作所需的所有操作。本章还介绍了各种优化技术,这些技术可以帮助提高应用程序的性能。
第十章,辅助食谱,展示了 3D 渲染应用程序不可或缺的便捷工具集。展示了如何从文件中加载纹理和 3D 模型,以及如何在着色器中操作几何体。
第十一章,光照,介绍了从简单的漫反射和镜面反射计算到法线贴图和阴影贴图技术的常用光照技术。
第十二章,高级渲染技术,解释了如何实现令人印象深刻的图形技术,这些技术可以在许多流行的 3D 应用程序(如游戏和基准测试)中找到。
您需要为本书准备什么
本书解释了 Vulkan 图形 API 的各个方面,该 API 是开放和跨平台的。它可在 Microsoft Windows(版本 7 及以上)或 Linux(最好是 Ubuntu 16.04 及以上)系统上使用。(Vulkan 还支持运行 7.0+ / Nougat 版本操作系统的 Android 设备,但本书提供的代码示例并未设计为在 Android OS 上执行。)
要执行示例程序或开发我们自己的应用程序,除了 Windows 7+或 Linux 操作系统外,还需要支持 Vulkan API 的图形硬件和驱动程序。请参考 3D 图形供应商的网站和/或支持,以检查哪些硬件能够运行启用 Vulkan 的软件。
当使用 Windows 操作系统时,可以使用 Visual Studio Community 2015 IDE(或更新版本)编译代码示例,该 IDE 免费且对所有人开放。要为 Visual Studio IDE 生成解决方案,需要 CMAKE 3.0 或更新版本。
在 Linux 系统上,使用 CMAKE 3.0 和 make 工具的组合进行编译。但也可以使用其他工具,如 QtCreator,来编译这些示例。
本书面向对象
本书非常适合了解 C/C++语言、对图形编程有一定了解的开发者,他们现在想利用构建下一代计算机图形过程中新的 Vulkan API。对 Vulkan 有一定的了解将有助于理解食谱。希望利用 Vulkan API 的 OpenGL 开发者也会发现本书很有用。
部分
在本书中,您会发现一些频繁出现的标题(准备工作、如何操作、工作原理、更多内容、参见)。
为了清楚地说明如何完成食谱,我们使用以下这些部分:
准备工作
本节告诉您在食谱中可以期待什么,并描述了如何设置任何软件或任何为食谱所需的初步设置。
如何操作...
本节包含遵循食谱所需的步骤。
工作原理...
本节通常包含对上一节发生情况的详细解释。
更多内容…
本节包含有关食谱的附加信息,以便使读者对食谱有更多的了解。
参见
本节提供了指向其他有用信息的链接,这些信息对食谱很有帮助。
术语约定
在这本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“将您想要激活的层的名称分配给VK_INSTANCE_LAYERS环境变量。”
代码块设置如下:
{
if( (result != VK_SUCCESS) ||
(extensions_count == 0) ) {
std::cout << "Could not enumerate device extensions." << std::endl;
return false;
}
任何命令行输入或输出都如下所示:
setx VK_INSTANCE_LAYERS VK_LAYER_LUNARG_api_dump;VK_LAYER_LUNARG_core_validation
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“从管理面板中选择系统信息。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或不喜欢什么。读者反馈对我们来说非常重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com上的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买这本书的地方。
-
点击代码下载。
您还可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。
文件下载完成后,请确保您使用最新版本的软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书的相关代码包也托管在 GitHub 上,地址为h t t p s 😕/g i t h u b . c o m /P a c k t P u b l i s h i n g /V u l k a n - C o o k b o o k。我们还有其他来自我们丰富图书和视频目录的代码包,可在h t t p s 😕/g i t h u b . c o m /P a c k t P u b l i s h i n g /找到。查看它们吧!
下载本书中的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从h t t p s 😕/w w w . p a c k t p u b . c o m /s i t e s /d e f a u l t /f i l e s /d o w n l o a d s /V u l k a n C o o k b o o k _ C o l o r I m a g e s . p d f下载此文件。
勘误
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这个问题,我们将不胜感激。这样做可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问h t t p s 😕/w w w . p a c k t p u b . c o m /s u b m i t - e r r a t a来报告,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问h t t p s 😕/w w w . p a c k t p u b . c o m /b o o k s /c o n t e n t /s u p p o r t,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
互联网上版权材料的侵权是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。
我们感谢您在保护我们作者和我们提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,您可以通过questions@packtpub.com联系我们,我们将尽力解决问题。
第一章:实例和设备
在本章中,我们将介绍以下食谱:
-
下载 Vulkan SDK
-
启用验证层
-
与 Vulkan Loader 库连接
-
准备加载 Vulkan API 函数
-
加载从 Vulkan Loader 库导出的函数
-
加载全局级函数
-
检查可用的实例扩展
-
创建一个 Vulkan 实例
-
加载实例级函数
-
列举可用的物理设备
-
检查可用的设备扩展
-
获取物理设备的功能和属性
-
检查可用的队列家族及其属性
-
选择具有所需功能的队列家族的索引
-
创建逻辑设备
-
加载设备级函数
-
获取设备队列
-
使用几何着色器和图形以及计算队列创建逻辑设备
-
销毁逻辑设备
-
销毁 Vulkan 实例
-
释放 Vulkan Loader 库
简介
Vulkan 是由 Khronos Consortium 开发的新图形 API。它被视为 OpenGL 的继任者:它是开源的且跨平台的。然而,由于 Vulkan 可以在不同的设备和操作系统上使用,我们需要创建一些基本设置代码,以便在我们的应用程序中使用 Vulkan。
在本章中,我们将介绍特定于在 Microsoft Windows 和 Ubuntu Linux 操作系统上使用 Vulkan 的主题。我们将学习 Vulkan 基础知识,例如下载 软件开发工具包(SDK)和设置 验证层,这些验证层使我们能够调试使用 Vulkan API 的应用程序。我们将开始使用 Vulkan Loader 库,加载所有 Vulkan API 函数,创建一个 Vulkan 实例,并选择我们的工作将在其上执行设备。
下载 Vulkan 的 SDK
要开始使用 Vulkan API 开发应用程序,我们需要下载一个 SDK 并在我们的应用程序中使用其一些资源。
Vulkan 的 SDK 可以在 vulkan.lunarg.com 找到。
准备就绪
在我们能够执行任何使用 Vulkan API 的应用程序之前,我们还需要安装支持 Vulkan API 的图形驱动程序。这些可以在图形硬件供应商的网站上找到。
如何操作...
在 Windows 操作系统家族中:
-
将页面滚动到最底部并选择 WINDOWS 操作系统。
-
下载并保存 SDK 安装程序文件。
-
运行安装程序并选择您想要安装 SDK 的目标位置。默认情况下,它被安装到
C:\VulkanSDK\<version>\文件夹中。 -
安装完成后,打开 Vulkan SDK 安装文件夹,然后打开
RunTimeInstaller子文件夹。执行VulkanRT-<version>-Installer文件。这将安装最新的 Vulkan Loader 版本。 -
再次,前往 SDK 安装的文件夹,并打开
Include\vulkan子文件夹。将vk_platform.h和vulkan.h头文件复制到您想要开发的应用的工程文件夹中。我们将这两个文件称为 Vulkan 头文件。
在 Linux 操作系统系列中:
- 通过运行以下命令更新系统包:
sudo apt-get update sudo apt-get dist-upgrade
- 要能够从 SDK 中构建和执行 Vulkan 示例,通过运行以下命令安装额外的开发包:
sudo apt-get install libglm-dev graphviz libxcb-dri3-0
libxcb-present0 libpciaccess0 cmake libpng-dev libxcb-dri3-
dev libx11-dev
-
滚动到页面底部并选择 LINUX 操作系统。
-
下载 SDK 的 Linux 包并将其保存到所需的文件夹中。
-
打开终端,将当前目录更改为 SDK 软件包下载的文件夹。
-
通过执行以下命令更改下载文件的访问权限:
chmod ugo+x vulkansdk-linux-x86_64-<version>.run
- 使用以下命令运行下载的 SDK 软件包安装程序文件:
./vulkansdk-linux-x86_64-<version>.run
-
将当前目录更改为由 SDK 软件包安装程序创建的
VulkanSDK/<version>文件夹。 -
通过执行以下命令设置环境变量:
sudo su VULKAN_SDK=$PWD/x86_64
echo export PATH=$PATH:$VULKAN_SDK/bin >> /etc/environment
echo export VK_LAYER_PATH=$VULKAN_SDK/etc/explicit_layer.d >>
/etc/environment
echo $VULKAN_SDK/lib >> /etc/ld.so.conf.d/vulkan.conf
ldconfig
-
将当前目录更改为
x86_64/include/vulkan文件夹。 -
将
vk_platform.h和vulkan.h头文件复制到您想要开发的应用的工程文件夹中。我们将这两个文件称为 Vulkan 头文件。 -
重新启动计算机以使更改生效。
它是如何工作的...
SDK 包含创建使用 Vulkan API 的应用程序所需的资源。Vulkan 头文件(vk_platform.h 和 vulkan.h 文件)需要包含在我们的应用程序源代码中,这样我们就可以在代码中使用 Vulkan API 函数、结构、枚举等。
Vulkan 加载器(Windows 上的 vulkan-1.dll 文件,Linux 系统上的 libvulkan.so.1 文件)是一个动态库,负责公开 Vulkan API 函数并将它们转发给图形驱动程序。我们在应用中与之连接,并从其中加载 Vulkan API 函数。
参见
本章中的以下配方:
-
启用验证层
-
连接到 Vulkan 加载器库
-
释放 Vulkan 加载器库
启用验证层
Vulkan API 是以性能为设计理念的。提高其性能的一种方法是通过降低驱动程序执行的状态和错误检查。这也是为什么 Vulkan 被称为“瘦 API”或“瘦驱动”的原因之一,它是对硬件的最小抽象,这使得 API 能够跨多个硬件供应商和设备类型(高性能桌面计算机、移动电话、集成和低功耗嵌入式系统)移植。
然而,与传统的传统高级 API(如 OpenGL)相比,这种方法使得使用 Vulkan API 创建应用程序变得更加困难。这是因为驱动程序向开发者提供的反馈非常有限,因为它期望程序员会正确使用 API 并遵守 Vulkan 规范中定义的规则。
为了减轻这个问题,Vulkan 也被设计成一个分层 API。最低层,核心,就是 Vulkan API 本身,它与 驱动程序 通信,使我们能够编程 硬件(如前图所示)。在其之上(在 应用程序 和 Vulkan API 之间),开发者可以启用额外的层,以简化调试过程。

如何操作...
在 Windows 操作系统家族中:
-
前往 SDK 安装文件夹,然后打开
Config子目录。 -
将
vk_layer_settings.txt文件复制到要调试的可执行文件目录中(要执行的应用程序文件夹中)。 -
创建一个名为
VK_INSTANCE_LAYERS的环境变量:-
打开命令行控制台(命令提示符/
cmd.exe)。 -
输入以下内容:
-
setx VK_INSTANCE_LAYERS
VK_LAYER_LUNARG_standard_validation
-
关闭控制台。
-
再次重新打开命令提示符。
-
将当前目录更改为要执行的应用程序文件夹。
-
运行应用程序;潜在警告或错误将在命令提示符的标准输出中显示。
在 Linux 操作系统家族中:
-
前往 SDK 安装文件夹,然后打开
Config子目录。 -
将
vk_layer_settings.txt文件复制到要调试的可执行文件目录中(要执行的应用程序文件夹中)。 -
创建一个名为
VK_INSTANCE_LAYERS的环境变量:-
打开终端窗口。
-
输入以下内容:
-
export
VK_INSTANCE_LAYERS=VK_LAYER_LUNARG_standard_validation
- 运行应用程序;潜在警告或错误将在终端窗口的标准输出中显示。
它是如何工作的...
Vulkan 验证层包含一组库,这些库有助于在创建的应用程序中找到潜在问题。它们的调试功能包括但不限于验证传递给 Vulkan 函数的参数、验证纹理和渲染目标格式、跟踪 Vulkan 对象及其生命周期和用法,以及检查潜在的内存泄漏或转储(显示/打印)Vulkan API 函数调用。这些功能由不同的验证层启用,但大多数都汇集到一个名为 VK_LAYER_LUNARG_standard_validation 的单个层中,该层在本食谱中启用。其他层的名称示例包括 VK_LAYER_LUNARG_swapchain、VK_LAYER_LUNARG_object_tracker、VK_LAYER_GOOGLE_threading 或 VK_LAYER_LUNARG_api_dump 等。可以同时启用多个层,方法与这里在食谱中展示的类似。只需将您想要激活的层的名称分配给 VK_INSTANCE_LAYERS 环境变量。如果您是 Windows 操作系统用户,请记住用分号分隔它们,如下例所示:
setx VK_INSTANCE_LAYERS VK_LAYER_LUNARG_api_dump;VK_LAYER_LUNARG_core_validation
如果您是 Linux 操作系统用户,请用冒号分隔它们。以下是一个示例:
export VK_INSTANCE_LAYERS=VK_LAYER_LUNARG_api_dump:VK_LAYER_LUNARG _core_validation
命名为 VK_INSTANCE_LAYERS 的环境变量也可以通过其他操作系统特定的方式设置,例如,在 Windows 上的高级操作系统设置或 Linux 上的 /etc/environment。
上述示例使验证层在全局范围内对所有应用程序生效,但它们也可以仅对我们自己的应用程序生效,在其源代码中创建实例时启用。然而,这种方法要求我们每次想要启用或禁用不同的层时都要重新编译整个程序。因此,使用上述方法启用它们更容易。这样,我们也不会忘记在发布应用程序的最终版本时禁用它们。要禁用验证层,我们只需删除 VK_INSTANCE_LAYERS 环境变量。
验证层不应在发布(发货)的应用程序的版本中启用,因为它们可能会大幅降低性能。
有关可用的所有验证层的完整列表,请参阅文档,这些文档可以在安装 Vulkan SDK 的目录中的 Documentation 子目录中找到。
参见
本章中的以下食谱:
-
下载 Vulkan 的 SDK
-
连接到 Vulkan 加载器库
-
释放 Vulkan 加载器库
连接到 Vulkan 加载器库
Vulkan API 的支持由图形硬件供应商实现,并通过图形驱动程序提供。每个供应商都可以选择任何动态库来实现它,甚至可以在驱动程序更新中更改它。
因此,除了驱动程序之外,Vulkan 加载库也被安装。我们也可以从 SDK 安装文件夹中安装它。它允许开发者通过 Windows OS 上的 vulkan-1.dll 库或 Linux OS 上的 libvulkan.so.1 库访问 Vulkan API 的入口点,无论安装了什么驱动程序,来自哪个供应商。
Vulkan 加载库负责将 Vulkan API 调用传输到适当的图形驱动程序。在给定的计算机上,可能有更多支持 Vulkan 的硬件组件,但有了 Vulkan 加载库,我们就不需要猜测应该使用哪个驱动程序,或者应该连接哪个库才能使用 Vulkan。开发者只需要知道 Vulkan 库的名称:Windows 上的 vulkan-1.dll 或 Linux 上的 libvulkan.so.1。当我们想在应用程序中使用 Vulkan 时,我们只需在代码中连接它(加载它)。
在 Windows OS 上,Vulkan 加载库被命名为 vulkan-1.dll。
在 Linux OS 上,Vulkan 加载库被命名为 libvulkan.so.1。
如何操作...
在 Windows 操作系统家族中:
-
准备一个名为
vulkan_library的HMODULE类型的变量。 -
调用
LoadLibrary( "vulkan-1.dll" )并将此操作的返回结果存储在vulkan_library变量中。 -
通过检查
vulkan_library变量的值是否不同于nullptr来确认此操作已成功执行。
在 Linux 操作系统家族中:
-
准备一个名为
vulkan_library的void*类型的变量。 -
调用
dlopen( "libvulkan.so.1", RTLD_NOW )并将此操作的返回结果存储在vulkan_library变量中。 -
通过检查
vulkan_library变量的值是否不同于nullptr来确认此操作已成功执行。
它是如何工作的...
LoadLibrary() 是 Windows 操作系统上可用的一项功能。dlopen() 是 Linux 操作系统上可用的一项功能。它们两者都将指定的动态链接库加载(打开)到我们的应用程序的内存空间中。这样我们就可以加载(获取)从给定库实现并导出的函数,并在我们的应用程序中使用它们。
在我们最感兴趣的从 Vulkan API 导出的函数的情况下,我们按照以下方式在 Windows 上加载 vulkan-1.dll 库或在 Linux 上加载 libvulkan.so.1 库:
#if defined _WIN32
vulkan_library = LoadLibrary( "vulkan-1.dll" );
#elif defined __linux
vulkan_library = dlopen( "libvulkan.so.1", RTLD_NOW );
#endif
if( vulkan_library == nullptr ) {
std::cout << "Could not connect with a Vulkan Runtime library." << std::endl;
return false;
}
return true;
在成功调用之后,我们可以加载一个用于获取所有其他 Vulkan API 程序地址的特定于 Vulkan 的函数。
参见
本章中的以下配方:
-
下载 Vulkan SDK
-
启用验证层
-
释放 Vulkan 加载库
准备加载 Vulkan API 函数
当我们想在应用程序中使用 Vulkan API 时,我们需要获取 Vulkan 文档中指定的过程。为了做到这一点,我们可以在项目中添加对 Vulkan 加载器库的依赖,将其静态链接到我们的项目中,并使用vulkan.h头文件中定义的函数原型。第二种方法是禁用vulkan.h头文件中定义的函数原型,并在我们的应用程序中动态加载函数指针。
第一种方法稍微简单一些,但它使用的是直接在 Vulkan 加载器库中定义的函数。当我们对一个给定的设备执行操作时,Vulkan 加载器需要根据我们提供的作为参数的设备句柄将函数调用重定向到适当的实现。这种重定向需要一些时间,从而影响性能。
第二种方法需要在应用程序方面做更多的工作,但允许我们跳过前面的重定向(跳转)并节省一些性能。这是通过直接从我们想要使用的设备中加载函数来实现的。这样,如果我们不需要所有功能,我们还可以选择只使用 Vulkan 函数的子集。
在这本书中,介绍了第二种方法,因为它让开发者能够更好地控制应用程序中的事情。为了从 Vulkan 加载器库中动态加载函数,将所有 Vulkan API 函数的名称包装成一组简单的宏,并将声明、定义和函数加载分成多个文件是很方便的。
如何操作...
-
在项目中定义
VK_NO_PROTOTYPES预处理器定义:在项目属性中进行此操作(当使用 Microsoft Visual Studio 或 Qt Creator 等开发环境时),或者通过在源代码中包含vulkan.h文件之前使用#define VK_NO_PROTOTYPES预处理器指令。 -
创建一个名为
ListOfVulkanFunctions.inl的新文件。 -
将以下内容输入到文件中:
#ifndef EXPORTED_VULKAN_FUNCTION
#define EXPORTED_VULKAN_FUNCTION( function )
#endif
#undef EXPORTED_VULKAN_FUNCTION
//
#ifndef GLOBAL_LEVEL_VULKAN_FUNCTION
#define GLOBAL_LEVEL_VULKAN_FUNCTION( function )
#endif
#undef GLOBAL_LEVEL_VULKAN_FUNCTION
//
#ifndef INSTANCE_LEVEL_VULKAN_FUNCTION
#define INSTANCE_LEVEL_VULKAN_FUNCTION( function )
#endif
#undef INSTANCE_LEVEL_VULKAN_FUNCTION
//
#ifndef INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
#define INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( function, extension )
#endif
#undef INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
//
#ifndef DEVICE_LEVEL_VULKAN_FUNCTION
#define DEVICE_LEVEL_VULKAN_FUNCTION( function )
#endif
#undef DEVICE_LEVEL_VULKAN_FUNCTION
//
#ifndef DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( function,
extension )
#endif
#undef DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
-
创建一个名为
VulkanFunctions.h的新头文件。 -
将以下内容插入到文件中:
#include "vulkan.h"
namespace VulkanCookbook {
#define EXPORTED_VULKAN_FUNCTION( name ) extern PFN_##name name;
#define GLOBAL_LEVEL_VULKAN_FUNCTION( name ) extern PFN_##name
name;
#define INSTANCE_LEVEL_VULKAN_FUNCTION( name ) extern PFN_##name
name;
#define INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name,
extension ) extern PFN_##name name;
#define DEVICE_LEVEL_VULKAN_FUNCTION( name ) extern PFN_##name
name;
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name,
extension ) extern PFN_##name name;
#include "ListOfVulkanFunctions.inl"
} // namespace VulkanCookbook
-
创建一个名为
VulkanFunctions.cpp的新源代码文件。 -
将以下内容插入到文件中:
#include "VulkanFunctions.h"
namespace VulkanCookbook {
#define EXPORTED_VULKAN_FUNCTION( name ) PFN_##name name;
#define GLOBAL_LEVEL_VULKAN_FUNCTION( name ) PFN_##name name;
#define INSTANCE_LEVEL_VULKAN_FUNCTION( name ) PFN_##name name;
#define INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name,
extension ) PFN_##name name;
#define DEVICE_LEVEL_VULKAN_FUNCTION( name ) PFN_##name name;
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name,
extension ) PFN_##name name;
#include "ListOfVulkanFunctions.inl"
} // namespace VulkanCookbook
它是如何工作的...
这一系列文件一开始可能看起来是不必要的,甚至令人不知所措。VulkanFunctions.h和VulkanFunctions.cpp文件用于声明和定义我们将存储指向 Vulkan API 函数的指针的变量。声明和定义是通过方便的宏定义和包含ListOfVulkanFunctions.inl文件来完成的。我们将更新此文件并添加许多来自不同级别的 Vulkan 函数的名称。这样,我们就不需要在多个地方多次重复函数名称,这有助于我们避免犯错误和打字错误。我们只需在ListOfVulkanFunctions.inl文件中一次写入所需的 Vulkan 函数名称,并在需要时包含它。
我们如何知道用于存储指向 Vulkan API 函数指针的变量的类型?这很简单。每个函数的原型类型直接从函数名称派生。当一个函数被命名为 <name> 时,其类型是 PFN_<name>。例如,创建图像的函数被命名为 vkCreateImage(),因此这个函数的类型是 PFN_vkCreateImage。这就是为什么在提供的文件集中定义的宏只有一个参数,即函数名称,可以从它轻松地推导出类型。
最后,但同样重要的是,记住我们将存储 Vulkan 函数地址的变量声明和定义应该放在命名空间、类或结构体内部。这是因为,如果它们被设置为全局的,这可能会在某些操作系统上导致问题。记住命名空间并增加我们代码的可移植性会更好。
将包含 Vulkan API 函数指针的变量声明和定义放在结构体、类或命名空间内。
现在我们已经准备好了,我们可以开始加载 Vulkan 函数。
参见
本章中的以下食谱:
-
加载从 Vulkan Loader 库导出的函数
-
加载全局级函数
-
加载实例级函数
-
加载设备级函数
加载从 Vulkan Loader 库导出的函数
当我们加载(连接)Vulkan Loader 库时,我们需要加载其函数才能在我们的应用程序中使用 Vulkan API。不幸的是,不同的操作系统有不同的方式来获取从动态库(Windows 上的 .dll 文件或 Linux 上的 .so 文件)导出的函数的地址。然而,Vulkan API 力求跨许多操作系统实现可移植性。因此,为了允许开发者加载 API 中可用的所有函数,无论他们针对的是哪个操作系统,Vulkan 引入了一个可以用来加载所有其他 Vulkan API 函数的函数。然而,这个单一函数只能以操作系统特定的方式加载。
如何做...
在 Windows 操作系统家族中:
-
创建一个名为
vkGetInstanceProcAddr的PFN_vkGetInstanceProcAddr类型的变量。 -
调用
GetProcAddress( vulkan_library, "vkGetInstanceProcAddr" ),将此操作的返回结果转换为PFN_vkGetInstanceProcAddr类型,并将其存储在vkGetInstanceProcAddr变量中。 -
通过检查
vkGetInstanceProcAddr变量的值是否不等于nullptr来确认此操作是否成功。
在 Linux 操作系统家族中:
-
创建一个名为
vkGetInstanceProcAddr的PFN_vkGetInstanceProcAddr类型的变量。 -
调用
dlsym( vulkan_library, "vkGetInstanceProcAddr" ),将此操作的返回结果转换为PFN_vkGetInstanceProcAddr类型,并将其存储在vkGetInstanceProcAddr变量中。 -
通过检查
vkGetInstanceProcAddr变量的值是否不等于nullptr来确认此操作是否成功。
它是如何工作的...
GetProcAddress() 是在 Windows 操作系统上可用的函数。dlsym() 是在 Linux 操作系统上可用的函数。它们都从已加载的动态链接库中获取指定函数的地址。所有 Vulkan 实现必须公开导出的唯一函数称为 vkGetInstanceProcAddr()。它允许我们以与操作系统无关的方式加载任何其他 Vulkan 函数。
为了简化并自动化加载多个 Vulkan 函数的过程,并降低出错的可能性,我们应该将声明、定义和加载函数的过程封装成一组方便的宏定义,如 准备加载 Vulkan API 函数 食谱中所述。这样,我们就可以将所有 Vulkan API 函数保存在一个文件中,该文件包含所有 Vulkan 函数的宏封装名称列表。然后我们可以将此单个文件包含在多个位置,并利用 C/C++ 预处理器。通过重新定义宏,我们可以声明和定义我们将存储函数指针的变量,也可以加载所有这些函数。
这里是 ListOfVulkanFunctions.inl 文件更新的片段:
#ifndef EXPORTED_VULKAN_FUNCTION
#define EXPORTED_VULKAN_FUNCTION( function )
#endif
EXPORTED_VULKAN_FUNCTION( vkGetInstanceProcAddr )
#undef EXPORTED_VULKAN_FUNCTION
其余的文件(VulkanFunctions.h 和 VulkanFunctions.h)保持不变。声明和定义是通过预处理器宏自动执行的。然而,我们仍然需要加载从 Vulkan Loader 库导出的函数。前一个食谱的实现可能如下所示:
#if defined _WIN32
#define LoadFunction GetProcAddress
#elif defined __linux
#define LoadFunction dlsym
#endif
#define EXPORTED_VULKAN_FUNCTION( name ) \
name = (PFN_##name)LoadFunction( vulkan_library, #name ); \
if( name == nullptr ) { \
std::cout << "Could not load exported Vulkan function named: " \
#name << std::endl; \
return false; \
}
#include "ListOfVulkanFunctions.inl"
return true;
首先,我们定义一个负责获取 vkGetInstanceProcAddr() 函数地址的宏。它从表示为 vulkan_library 变量的库中获取它,将此操作的结果转换为 PFN_kGetInstanceProcAddr 类型,并将其存储在名为 vkGetInstanceProcAddr 的变量中。之后,该宏检查操作是否成功,并在失败的情况下在屏幕上显示适当的消息。
所有预处理器“魔法”都是在包含 ListOfVulkanFunctions.inl 文件并执行此文件中定义的每个函数的前置操作时完成的。在这种情况下,它仅针对 vkGetInstanceProcAddr() 函数执行,但对于其他级别的函数也能达到相同的行为。
现在,当我们有一个函数加载函数时,我们可以以操作系统无关的方式获取其他 Vulkan 程序的指针。
参见
本章中以下食谱:
-
与 Vulkan Loader 库连接
-
准备加载 Vulkan API 函数
-
加载全局级别的函数
-
加载实例级别的函数
-
加载设备级别的函数
加载全局级别的函数
我们已经获取了一个 vkGetInstanceProcAddr() 函数,通过它可以以操作系统无关的方式加载所有其他 Vulkan API 入口点。
Vulkan 函数可以分为三个级别,分别是 全局、实例 和 设备。设备级别函数用于执行典型的操作,如绘图、着色器模块创建、图像创建或数据复制。实例级别函数允许我们创建 逻辑设备。为了完成所有这些,并加载设备和实例级别函数,我们需要创建一个实例。这个操作是通过全局级别函数执行的,我们首先需要加载这些函数。
如何操作...
-
创建一个名为
vkEnumerateInstanceExtensionProperties的PFN_vkEnumerateInstanceExtensionProperties类型的变量。 -
创建一个名为
vkEnumerateInstanceLayerProperties的PFN_vkEnumerateInstanceLayerProperties类型的变量。 -
创建一个名为
vkCreateInstance的PFN_vkCreateInstance类型的变量。 -
调用
vkGetInstanceProcAddr( nullptr, "vkEnumerateInstanceExtensionProperties" ),将此操作的输出转换为PFN_vkEnumerateInstanceExtensionProperties类型,并将其存储在vkEnumerateInstanceExtensionProperties变量中。 -
调用
vkGetInstanceProcAddr( nullptr, "vkEnumerateInstanceLayerProperties" ),将此操作的输出转换为PFN_vkEnumerateInstanceLayerProperties类型,并将其存储在vkEnumerateInstanceLayerProperties变量中。 -
调用
vkGetInstanceProcAddr( nullptr, "vkCreateInstance" ),将此操作的输出转换为PFN_vkCreateInstance类型,并将其存储在vkCreateInstance变量中。 -
通过检查所有前面的变量值是否不等于
nullptr来确认操作是否成功。
它是如何工作的...
在 Vulkan 中,只有三个全局级别函数:vkEnumerateInstanceExtensionProperties()、vkEnumerateInstanceLayerProperties() 和 vkCreateInstance()。它们在实例创建期间用于检查可用的实例级别扩展和层,并创建实例本身。
获取全局级别函数的过程与从 Vulkan Loader 导出的加载函数类似。这就是为什么最方便的方法是将全局级别函数的名称添加到 ListOfVulkanFunctions.inl 文件中,如下所示:
#ifndef GLOBAL_LEVEL_VULKAN_FUNCTION
#define GLOBAL_LEVEL_VULKAN_FUNCTION( function )
#endif
GLOBAL_LEVEL_VULKAN_FUNCTION( vkEnumerateInstanceExtensionProperties )
GLOBAL_LEVEL_VULKAN_FUNCTION( vkEnumerateInstanceLayerProperties )
GLOBAL_LEVEL_VULKAN_FUNCTION( vkCreateInstance )
#undef GLOBAL_LEVEL_VULKAN_FUNCTION
我们不需要更改 VulkanFunctions.h 和 VulkanFunctions.h 文件,但我们仍然需要实现前面的食谱,并按如下方式加载全局级别函数:
#define GLOBAL_LEVEL_VULKAN_FUNCTION( name ) \
name = (PFN_##name)vkGetInstanceProcAddr( nullptr, #name ); \
if( name == nullptr ) { \
std::cout << "Could not load global-level function named: " \
#name << std::endl; \
return false; \
}
#include "ListOfVulkanFunctions.inl"
return true;
一个自定义的 GLOBAL_LEVEL_VULKAN_FUNCTION 宏接受函数名称并将其提供给 vkGetInstanceProcAddr() 函数。它尝试加载给定的函数,如果失败,则返回 nullptr。vkGetInstanceProcAddr() 函数返回的任何结果都将转换为 PFN_<name> 类型并存储在适当的变量中。
如果失败,将显示一条消息,以便用户知道哪个函数无法加载。
参见
本章中的以下食谱:
-
准备加载 Vulkan API 函数
-
加载从 Vulkan Loader 库导出的函数
-
加载实例级别函数
-
加载设备级函数
检查可用的实例扩展
Vulkan 实例收集每个应用程序的状态,并允许我们创建一个逻辑设备,几乎所有操作都在这个设备上执行。在我们能够创建实例对象之前,我们应该考虑我们想要启用的实例级扩展。其中最重要的实例级扩展之一是与交换链相关的扩展,这些扩展用于在屏幕上显示图像。
与 OpenGL 不同,Vulkan 中的扩展是显式启用的。我们不能创建一个不支持的扩展的 Vulkan 实例,因为实例创建操作将失败。这就是为什么我们需要检查在给定的硬件平台上支持哪些扩展。
如何做...
-
准备一个名为
extensions_count的uint32_t类型的变量。 -
调用
vkEnumerateInstanceExtensionProperties( nullptr, &extensions_count, nullptr )。所有参数都应设置为nullptr,除了第二个参数,它应指向extensions_count变量。 -
如果函数调用成功,总可用实例级扩展的数量将存储在
extensions_count变量中。 -
为扩展属性列表准备一个存储空间。它必须包含类型为
VkExtensionProperties的元素。最佳解决方案是使用std::vector容器。可以将其命名为available_extensions。 -
调整向量的大小,以便至少可以容纳
extensions_count个元素。 -
调用
vkEnumerateInstanceExtensionProperties( nullptr, &extensions_count, &available_extensions[0] )。第一个参数再次设置为nullptr;第二个参数应指向extensions_count变量;第三个参数必须指向一个至少包含extensions_count个元素的VkExtensionProperties类型的数组。在这里,在第三个参数中,提供available_extensions向量第一个元素的地址。 -
如果函数返回成功,
available_extensions向量变量将包含在给定硬件平台上支持的所有扩展的列表。
它是如何工作的...
获取实例级扩展的代码可以分为两个阶段。首先,我们获取可用扩展的总数,如下所示:
uint32_t extensions_count = 0;
VkResult result = VK_SUCCESS;
result = vkEnumerateInstanceExtensionProperties( nullptr, &extensions_count, nullptr );
if( (result != VK_SUCCESS) ||
(extensions_count == 0) ) {
std::cout << "Could not get the number of Instance extensions." << std::endl;
return false;
}
当最后一个参数设置为 nullptr 时,vkEnumerateInstanceExtensionProperties() 函数将可用扩展的数量存储在第二个参数指向的变量中。这样,我们就知道在给定平台上有多少扩展,以及我们需要多少空间来存储所有这些扩展的参数。
当我们准备好获取扩展属性时,我们可以再次调用该函数。这次最后一个参数应指向准备好的空间(一个 VkExtensionProperties 元素数组,或者在我们的情况下是一个向量),其中将存储这些属性:
available_extensions.resize( extensions_count );
result = vkEnumerateInstanceExtensionProperties( nullptr, &extensions_count, &available_extensions[0] );
if( (result != VK_SUCCESS) ||
(extensions_count == 0) ) {
std::cout << "Could not enumerate Instance extensions." << std::endl;
return false;
}
return true;
在 Vulkan 中,调用同一函数两次的模式很常见。有多个函数,当它们的最后一个参数设置为 nullptr 时,它们会存储查询返回的元素数量。当它们的最后一个元素指向一个适当的变量时,它们会返回数据本身。
现在我们有了这个列表,我们可以查看它并检查我们想要启用的扩展是否在给定的平台上可用。
参见
-
本章中的以下配方:
- 检查可用的设备扩展
-
在 第二章 中的以下配方,图像展示:
- 创建启用了 WSI 扩展的 Vulkan 实例
创建 Vulkan 实例
Vulkan 实例是一个收集应用程序状态的对象。它包含诸如应用程序名称、用于创建应用程序的引擎名称和版本,或启用的实例级扩展和层等信息。
通过实例,我们还可以枚举可用的物理设备并在其上创建逻辑设备,在这些设备上执行典型的操作,如图像创建或绘图。因此,在我们使用 Vulkan API 之前,我们需要创建一个新的实例对象。
如何做到这一点...
-
准备一个名为
desired_extensions的std::vector<char const *>类型的变量。将所有想要启用的扩展的名称存储在desired_extensions变量中。 -
创建一个名为
available_extensions的std::vector<VkExtensionProperties>类型的变量。获取所有可用扩展的列表并将其存储在available_extensions变量中(参考 Checking available Instance extensions 配方)。 -
确保从
desired_extensions变量中每个扩展的名称也存在于available_extensions变量中。 -
准备一个名为
application_info的VkApplicationInfo类型的变量。为application_info变量的成员分配以下值:-
VK_STRUCTURE_TYPE_APPLICATION_INFO的sType值。 -
pNext的nullptr值。 -
应用程序名称为
pApplicationName。 -
应用程序版本的
applicationVersion结构成员;通过使用VK_MAKE_VERSION宏并指定其中的主要、次要和补丁值来实现。 -
用于创建应用程序的引擎名称为
pEngineName。 -
用于创建应用程序的引擎版本为
engineVersion的版本;通过使用VK_MAKE_VERSION宏来实现。 -
VK_MAKE_VERSION( 1, 0, 0 )用于apiVersion。
-
-
创建一个名为
instance_create_info的VkInstanceCreateInfo类型的变量。为instance_create_info变量的成员分配以下值:-
VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO的sType值。 -
pNext的nullptr值。 -
flags的0值。 -
指向
application_info变量的pApplicationInfo指针。 -
enabledLayerCount的0值。 -
ppEnabledLayerNames的nullptr值。 -
desired_extensions向量的元素数量为enabledExtensionCount。 -
指向
desired_extensions向量第一个元素的指针(如果为空,则为nullptr)用于ppEnabledExtensionNames。
-
-
创建一个名为
instance的VkInstance类型的变量。 -
调用
vkCreateInstance( &instance_create_info, nullptr, &instance )函数。在第一个参数中提供一个指向instance_create_info变量的指针,在第二个参数中提供一个nullptr值,在第三个参数中提供一个指向instance变量的指针。 -
通过检查
vkCreateInstance()函数调用返回的值是否等于VK_SUCCESS来确保操作成功。
它是如何工作的...
要创建实例,我们需要准备一些信息。首先,我们需要创建一个包含我们想要启用的实例级扩展名称的数组。接下来,我们需要检查它们是否在给定的硬件上受支持。这是通过获取所有可用实例级扩展的列表并检查它是否包含我们想要启用的所有扩展的名称来完成的:
std::vector<VkExtensionProperties> available_extensions;
if( !CheckAvailableInstanceExtensions( available_extensions ) ) {
return false;
}
for( auto & extension : desired_extensions ) {
if( !IsExtensionSupported( available_extensions, extension ) ) {
std::cout << "Extension named '" << extension << "' is not supported." << std::endl;
return false;
}
}
接下来,我们需要创建一个变量,我们将在此变量中提供有关我们的应用程序的信息,例如其名称和版本、用于创建应用程序的引擎的名称和版本,以及我们想要使用的 Vulkan API 的版本(目前 API 只支持第一个版本):
VkApplicationInfo application_info = {
VK_STRUCTURE_TYPE_APPLICATION_INFO,
nullptr,
application_name,
VK_MAKE_VERSION( 1, 0, 0 ),
"Vulkan Cookbook",
VK_MAKE_VERSION( 1, 0, 0 ),
VK_MAKE_VERSION( 1, 0, 0 )
};
在前面的代码示例中,application_info变量的指针通过一个包含用于创建实例的实际参数的第二个变量提供。在其中,除了之前提到的指针外,我们还提供了我们想要启用的扩展的数量和名称,以及我们想要启用的层和名称。扩展和层都不是创建有效的实例对象所必需的,我们可以跳过它们。然而,有一些非常重要的扩展,没有它们将很难创建一个功能齐全的应用程序,因此建议使用它们。层可以安全地省略。以下是为定义实例参数的变量准备示例代码:
VkInstanceCreateInfo instance_create_info = {
VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
nullptr,
0,
&application_info,
0,
nullptr,
static_cast<uint32_t>(desired_extensions.size()),
desired_extensions.size() > 0 ? &desired_extensions[0] : nullptr
};
最后,当我们准备好了前面的数据后,我们可以创建一个实例对象。这是通过vkCreateInstance()函数完成的。其第一个参数必须指向类型为VkInstanceCreateInfo的变量。第三个参数必须指向类型为VkInstance的变量。创建的实例句柄将存储在其中。第二个参数很少使用:它可能指向一个类型为VkAllocationCallbacks的变量,在其中定义了分配器回调函数。这些函数控制主机内存的分配,主要用于调试目的。大多数情况下,定义分配回调的第二个参数可以设置为nullptr:
VkResult result = vkCreateInstance( &instance_create_info, nullptr, &instance );
if( (result != VK_SUCCESS) ||
(instance == VK_NULL_HANDLE) ) {
std::cout << "Could not create Vulkan Instance." << std::endl;
return false;
}
return true;
参见
-
本章中的以下食谱:
-
检查可用的实例扩展
-
销毁 Vulkan 实例
-
-
在第二章的以下食谱中,图像呈现:
- 启用 WSI 扩展的 Vulkan 实例创建
加载实例级函数
我们已经创建了一个 Vulkan 实例对象。下一步是枚举物理设备,从中选择一个,并从它创建一个逻辑设备。这些操作是通过实例级函数执行的,我们需要获取它们的地址。
如何操作...
-
获取已创建的 Vulkan 实例的句柄。将其提供在名为
instance的VkInstance类型变量中。 -
选择您想要加载的实例级函数的名称(表示为
<function name>)。 -
创建一个名为
<function name>的PFN_<function name>类型的变量。 -
调用
vkGetInstanceProcAddr( instance, "<function name>" )。在第一个参数中提供一个创建的实例的句柄,在第二个参数中提供一个函数名。将此操作的结果转换为PFN_<function name>类型,并将其存储在<function name>变量中。 -
通过检查
<function name>变量的值是否不等于nullptr来确认此操作是否成功。
它是如何工作的...
实例级函数主要用于对物理设备进行操作。其中包含多个实例级函数,如 vkEnumeratePhysicalDevices()、vkGetPhysicalDeviceProperties()、vkGetPhysicalDeviceFeatures()、vkGetPhysicalDeviceQueueFamilyProperties()、vkCreateDevice()、vkGetDeviceProcAddr()、vkDestroyInstance() 或 vkEnumerateDeviceExtensionProperties() 等。然而,这个列表并不包括所有实例级函数。
我们如何判断一个函数是实例级还是设备级?所有设备级函数的第一个参数类型为 VkDevice、VkQueue 或 VkCommandBuffer。因此,如果一个函数没有这样的参数,并且不是全局级别的,那么它就是实例级的。如前所述,实例级函数用于操作物理设备,检查它们的属性、能力,并创建逻辑设备。
记住,扩展也可以引入新的功能。您需要将它们的函数添加到功能加载代码中,以便能够在应用程序中使用扩展。然而,在实例创建期间,您不应该在没有启用扩展的情况下加载由特定扩展引入的函数。如果这些函数在特定平台上不受支持,加载它们将失败(它将返回一个空指针)。
因此,为了加载实例级函数,我们应该按照以下方式更新 ListOfVulkanFunctions.inl 文件:
#ifndef INSTANCE_LEVEL_VULKAN_FUNCTION
#define INSTANCE_LEVEL_VULKAN_FUNCTION( function )
#endif
INSTANCE_LEVEL_VULKAN_FUNCTION( vkEnumeratePhysicalDevices )
INSTANCE_LEVEL_VULKAN_FUNCTION( vkGetPhysicalDeviceProperties )
INSTANCE_LEVEL_VULKAN_FUNCTION( vkGetPhysicalDeviceFeatures )
INSTANCE_LEVEL_VULKAN_FUNCTION( vkCreateDevice )
INSTANCE_LEVEL_VULKAN_FUNCTION( vkGetDeviceProcAddr )
//...
#undef INSTANCE_LEVEL_VULKAN_FUNCTION
//
#ifndef INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
#define INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( function, extension )
#endif
INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkGetPhysicalDeviceSurfaceSupportKHR, VK_KHR_SURFACE_EXTENSION_NAME )
INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkGetPhysicalDeviceSurfaceCapabilitiesKHR, VK_KHR_SURFACE_EXTENSION_NAME )
INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkGetPhysicalDeviceSurfaceFormatsKHR, VK_KHR_SURFACE_EXTENSION_NAME )
#ifdef VK_USE_PLATFORM_WIN32_KHR
INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkCreateWin32SurfaceKHR, VK_KHR_WIN32_SURFACE_EXTENSION_NAME )
#elif defined VK_USE_PLATFORM_XCB_KHR
INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkCreateXcbSurfaceKHR, VK_KHR_XLIB_SURFACE_EXTENSION_NAME )
#elif defined VK_USE_PLATFORM_XLIB_KHR
INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkCreateXlibSurfaceKHR, VK_KHR_XCB_SURFACE_EXTENSION_NAME )
#endif
#undef INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
在前面的代码中,我们添加了几个(但不是所有)实例级函数的名称。每个函数都被包装在 INSTANCE_LEVEL_VULKAN_FUNCTION 或 INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION 宏中,并放置在 #ifndef 和 #undef 预处理器定义之间。
要使用前面的宏实现实例级函数加载配方,我们应该编写以下代码:
#define INSTANCE_LEVEL_VULKAN_FUNCTION( name ) \
name = (PFN_##name)vkGetInstanceProcAddr( instance, #name ); \
if( name == nullptr ) { \
std::cout << "Could not load instance-level Vulkan function named: "\
#name << std::endl; \
return false; \
}
#include "ListOfVulkanFunctions.inl"
return true;
前面的宏调用了一个vkGetInstanceProcAddr()函数。这是用于加载全局级函数的相同函数,但这次,在第一个参数中提供了 Vulkan 实例的句柄。这样,我们可以加载只有在实例对象创建时才能正常工作的函数。
此函数返回一个指向第二参数中提供的函数名称的指针。返回值是void*类型,这就是为什么它随后被转换为一个适合我们获取地址的函数类型。
给定函数的原型类型是根据其名称定义的,在其名称前有一个PFN_前缀。因此,在示例中,vkEnumeratePhysicalDevices()函数的原型类型将被定义为PFN_vkEnumeratePhysicalDevices。
如果vkGetInstanceProcAddr()函数找不到请求的过程的地址,它将返回nullptr。这就是为什么我们应该进行检查,并在出现任何问题时记录适当的消息。
下一步是加载由扩展引入的函数。我们的函数加载代码获取了在ListOfVulkanFunctions.inl文件中用适当宏指定的所有函数的指针,但我们不能以相同的方式提供特定于扩展的函数,因为它们只能在适当的扩展启用时加载。当我们没有启用任何扩展时,只能加载核心 Vulkan API 函数。这就是为什么我们需要区分核心 API 函数和特定于扩展的函数。我们还需要知道哪些扩展被启用,以及哪个函数来自哪个扩展。这就是为什么为扩展引入的函数使用了一个单独的宏。这样的宏指定了一个函数名称,但也指定了一个给定函数被指定的扩展的名称。为了加载这样的函数,我们可以使用以下代码:
#define INSTANCE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name, extension ) \
for( auto & enabled_extension : enabled_extensions ) { \
if( std::string( enabled_extension ) == std::string( extension ) )
{ \
name = (PFN_##name)vkGetInstanceProcAddr( instance, #name ); \
if( name == nullptr ) { \
std::cout << "Could not load instance-level Vulkan function named: " \
#name << std::endl; \
return false; \
} \
} \
}
#include "ListOfVulkanFunctions.inl"
return true;
enabled_extensions是一个类型为std::vector<char const *>的变量,它包含所有启用实例级扩展的名称。我们遍历其所有元素,检查给定扩展的名称是否与引入提供函数的扩展的名称匹配。如果是,我们将以与正常核心 API 函数相同的方式加载该函数。否则,我们跳过指针加载代码。如果我们没有启用给定的扩展,我们就无法加载它引入的函数。
参见
本章中的以下食谱:
-
准备加载 Vulkan API 函数
-
从 Vulkan 加载器库中加载导出的函数
-
加载全局级函数
-
加载设备级函数
列出可用的物理设备
几乎所有的 Vulkan 工作都是在逻辑设备上完成的:我们在它们上创建资源,管理它们的内存,记录从它们创建的命令缓冲区,并将处理命令提交给它们的队列。在我们的应用程序中,逻辑设备代表那些已启用一组特性和扩展的物理设备。要创建一个逻辑设备,我们需要在给定的硬件平台上选择一个物理设备。我们如何知道在给定的计算机上有多少个以及哪些物理设备可用?我们需要枚举它们。
如何操作...
-
获取创建的 Vulkan 实例的句柄。通过一个名为
instance的VkInstance类型变量提供它。 -
准备一个名为
devices_count的uint32_t类型变量。 -
调用
vkEnumeratePhysicalDevices(instance, &devices_count, nullptr)。在第一个参数中,提供一个 Vulkan 实例的句柄;在第二个参数中,提供一个指向devices_count变量的指针,并将第三个参数现在设置为nullptr。 -
如果函数调用成功,
devices_count变量将包含可用物理设备总数。 -
准备物理设备列表的存储空间。最好的解决方案是使用一个名为
available_devices的std::vector变量,其元素类型为VkPhysicalDevice。调用它available_devices。 -
调整向量的大小,以便至少可以容纳
devices_count个元素。 -
调用
vkEnumeratePhysicalDevices(instance, &devices_count, &available_devices[0])。再次,第一个参数应设置为 Vulkan 实例对象的句柄,第二个参数应仍然指向devices_count变量,第三个参数必须指向一个至少包含devices_count个VkPhysicalDevice元素的数组。在这里,在第三个参数中,提供一个指向available_devices向量第一个元素的地址。 -
如果函数调用成功,
available_devices向量将包含在给定硬件平台上安装的所有支持 Vulkan API 的物理设备的列表。
它是如何工作的...
可用物理设备的枚举操作分为两个阶段:首先,我们检查在任意给定的硬件上可用的物理设备数量。这是通过调用vkEnumeratePhysicalDevices()函数并设置最后一个参数为nullptr来完成的,如下所示:
uint32_t devices_count = 0;
VkResult result = VK_SUCCESS;
result = vkEnumeratePhysicalDevices( instance, &devices_count, nullptr );
if( (result != VK_SUCCESS) ||
(devices_count == 0) ) {
std::cout << "Could not get the number of available physical devices." << std::endl;
return false;
}
这样,我们就知道有多少设备支持 Vulkan 以及我们需要为它们的句柄准备多少存储空间。当我们准备就绪并且已经准备了足够的空间时,我们可以进入第二阶段,获取物理设备的实际句柄。这是通过调用相同的vkEnumeratePhysicalDevices()函数来完成的,但这次,最后一个参数必须指向一个VkPhysicalDevice元素的数组:
available_devices.resize( devices_count );
result = vkEnumeratePhysicalDevices( instance, &devices_count, &available_devices[0] );
if( (result != VK_SUCCESS) ||
(devices_count == 0) ) {
std::cout << "Could not enumerate physical devices." << std::endl;
return false;
}
return true;
当调用成功时,准备好的存储空间将被填充上安装在我们应用程序执行的任何计算机上的物理设备的句柄。
现在我们有了设备列表,我们可以查看它,并检查每个设备的属性,检查我们可以对其执行的操作,并查看它支持哪些扩展。
参见
本章以下食谱:
-
加载实例级函数
-
检查可用的设备扩展
-
检查可用的队列家族及其属性
-
创建逻辑设备
检查可用的设备扩展
我们希望使用的某些 Vulkan 功能要求我们显式启用某些扩展(与自动/隐式启用的 OpenGL 相反)。有两种或两个级别的扩展:实例级和设备级。与实例扩展一样,设备扩展在逻辑设备创建期间启用。如果某个物理设备不支持设备扩展,我们无法为其创建逻辑设备。因此,在我们开始创建逻辑设备之前,我们需要确保所有请求的扩展都由给定的物理设备支持,或者我们需要寻找支持它们的另一个设备。
如何做到...
-
取
vkEnumeratePhysicalDevices()函数返回的一个物理设备句柄,并将其存储在一个名为physical_device的VkPhysicalDevice类型变量中。 -
准备一个名为
extensions_count的uint32_t类型变量。 -
调用
vkEnumerateDeviceExtensionProperties( physical_device, nullptr, &extensions_count, nullptr )。在第一个参数中,提供给定硬件平台上可用的物理设备的句柄:physical_device变量;第二个和最后一个参数应设置为nullptr,第三个参数应指向extensions_count变量。 -
如果函数调用成功,
extensions_count变量将包含所有可用的设备级扩展的总数。 -
为扩展属性列表准备存储空间。最佳方案是使用一个元素类型为
VkExtensionProperties的std::vector类型的变量。将其命名为available_extensions。 -
将向量的大小调整为至少可以容纳
extensions_count个元素。 -
调用
vkEnumerateDeviceExtensionProperties( physical_device, nullptr, &extensions_count, &available_extensions[0] )。然而,这次,将最后一个参数替换为指向一个元素类型为VkExtensionProperties的数组第一个元素的指针。这个数组必须有足够的空间来容纳至少extensions_count个元素。在这里,提供指向available_extensions变量第一个元素的指针。 -
如果函数调用成功,
available_extensions向量将包含给定物理设备支持的所有扩展的列表。
它是如何工作的...
获取支持设备级扩展列表的过程可以分为两个阶段:首先,我们检查给定物理设备支持多少扩展。这是通过调用名为vkEnumerateDeviceExtensionProperties()的函数并设置其最后一个参数为nullptr来完成的:
uint32_t extensions_count = 0;
VkResult result = VK_SUCCESS;
result = vkEnumerateDeviceExtensionProperties( physical_device, nullptr, &extensions_count, nullptr );
if( (result != VK_SUCCESS) ||
(extensions_count == 0) ) {
std::cout << "Could not get the number of device extensions." << std::endl;
return false;
}
其次,我们需要准备一个数组,该数组能够存储足够类型的VkExtensionProperties元素。在示例中,我们创建了一个向量变量,并将其调整大小,使其具有extensions_count数量的元素。在第二个vkEnumerateDeviceExtensionProperties()函数调用中,我们提供了available_extensions变量第一个元素的地址。当调用成功时,该变量将填充所有由给定物理设备支持的扩展的属性(名称和版本)。
available_extensions.resize( extensions_count );
result = vkEnumerateDeviceExtensionProperties( physical_device, nullptr, &extensions_count, &available_extensions[0] );
if( (result != VK_SUCCESS) ||
(extensions_count == 0) ) {
std::cout << "Could not enumerate device extensions." << std::endl;
return false;
}
return true;
再次,我们可以看到调用同一函数两次的模式:第一次调用(最后一个参数设置为nullptr)通知我们第二次调用返回的元素数量。第二次调用(最后一个参数指向一个VkExtensionProperties元素数组)返回所需的数据,在这种情况下是设备扩展,我们可以遍历并检查我们感兴趣的扩展是否在给定物理设备上可用。
参见
-
本章中以下食谱:
-
检查可用的实例扩展
-
枚举可用的物理设备
-
-
以下第二章中的食谱,图像展示:
- 创建启用 WSI 扩展的逻辑设备
获取物理设备的特性和属性
当我们创建一个启用 Vulkan 的应用程序时,它可以在许多不同的设备上执行。这可能是一台台式计算机、一台笔记本电脑或一部移动电话。每种这样的设备可能具有不同的配置,并且可能包含不同的图形硬件,提供不同的性能,或者,更重要的是,不同的功能。一台计算机可能安装了多个显卡。因此,为了找到满足我们需求且能够执行我们想在代码中实现的操作的设备,我们不仅需要检查有多少设备,而且为了能够正确选择其中一个,我们还需要检查每个设备的特性。
如何做到...
-
准备由
vkEnumeratePhysicalDevices()函数返回的物理设备句柄。将其存储在名为physical_device的VkPhysicalDevice类型的变量中。 -
创建一个名为
device_features的VkPhysicalDeviceFeatures类型的变量。 -
创建一个名为
device_properties的VkPhysicalDeviceProperties类型的第二个变量。 -
要获取给定设备的支持功能列表,调用
vkGetPhysicalDeviceFeatures( physical_device, &device_features )。设置由返回的物理设备句柄。vkEnumeratePhysicalDevices()函数的第一个参数。第二个参数必须指向device_features变量。 -
要获取给定物理设备的功能,请调用
vkGetPhysicalDeviceProperties( physical_device, &device_properties )函数。在第一个参数中提供物理设备的句柄。这个句柄必须是由vkEnumeratePhysicalDevices()函数返回的。第二个参数必须是指向device_properties变量的指针。
它是如何工作的...
在这里你可以找到前面菜谱的实现:
vkGetPhysicalDeviceFeatures( physical_device, &device_features );
vkGetPhysicalDeviceProperties( physical_device, &device_properties );
这段代码虽然简短简单,但为我们提供了关于可以使用 Vulkan API 执行操作所依赖的图形硬件的大量信息。
VkPhysicalDeviceProperties 结构体包含了关于给定物理设备的一般信息。通过它,我们可以检查设备的名称、驱动程序的版本以及支持的 Vulkan API 版本。我们还可以检查设备的类型:它是否是一个集成设备(集成在主处理器中)或一个独立(专用)的显卡,或者甚至是一个 CPU 本身。我们还可以读取给定硬件的限制(限制),例如,可以在其上创建多大的图像(纹理),在着色器中可以使用多少缓冲区,或者我们可以检查绘图操作期间使用的顶点属性的上限。
VkPhysicalDeviceFeatures 结构体列出了给定硬件可能支持但不是核心 Vulkan 规范要求的附加功能。功能包括诸如几何和细分着色器、深度裁剪和偏移、多个视口或宽线等项目。你可能想知道为什么几何和细分着色器会在列表上。图形硬件已经支持这些功能多年了。然而,不要忘记 Vulkan API 是可移植的,并且可以在许多不同的硬件平台上得到支持,不仅限于高端 PC,还包括移动电话甚至专用、便携式设备,这些设备应该尽可能节能。这就是为什么这些性能需求高的功能不在核心规范中。这允许某些驱动程序具有灵活性,更重要的是,提高能效和降低内存消耗。
关于物理设备功能,还有一件额外的事情你应该知道。像扩展一样,它们默认是未启用的,不能直接使用。它们必须在逻辑设备创建期间隐式启用。我们无法在此操作中请求所有功能,因为如果存在任何不支持的功能,逻辑设备创建过程将失败。如果我们对某个特定功能感兴趣,我们需要检查它是否可用,并在创建逻辑设备时指定它。如果该功能不受支持,我们无法在此设备上使用该功能,我们需要寻找支持它的其他设备。
如果我们想启用给定物理设备支持的所有功能,我们只需查询可用的功能,并在创建逻辑设备时提供获取的数据。
参见
本章中的以下食谱:
-
创建逻辑设备
-
创建具有几何着色器、图形和计算队列的逻辑设备
检查可用的队列家族及其属性
在 Vulkan 中,当我们想在硬件上执行操作时,我们将它们提交到队列。单个队列中的操作按提交的顺序依次处理--这就是为什么它被称为队列。然而,提交到不同队列的操作是独立处理的(如果需要,我们可以同步它们):

不同的队列可能代表硬件的不同部分,因此可能支持不同类型的操作。并非所有操作都可以在所有队列上执行。
具有相同能力的队列被分组到家族中。一个设备可以暴露任意数量的队列家族,每个家族可以包含一个或多个队列。为了检查可以在给定硬件上执行哪些操作,我们需要查询所有队列家族的属性。
如何做到这一点...
-
取
vkEnumeratePhysicalDevices()函数返回的物理设备句柄之一,并将其存储在名为physical_device的类型为VkPhysicalDevice的变量中。 -
准备一个名为
queue_families_count的类型为uint32_t的变量。 -
调用
vkGetPhysicalDeviceQueueFamilyProperties( physical_device, &queue_families_count, nullptr )。第一个参数提供一个物理设备的句柄;第二个参数应指向queue_families_count变量,最后一个参数应设置为nullptr。 -
成功调用后,
queue_families_count变量将包含由给定物理设备暴露的所有队列家族的数量。 -
为队列家族及其属性列表准备存储空间。一个非常方便的解决方案是使用类型为
std::vector的变量。其元素必须是类型为VkQueueFamilyProperties。将变量命名为queue_families。 -
将向量的大小调整为至少可以容纳
queue_families_count个元素。 -
调用
vkGetPhysicalDeviceQueueFamilyProperties( physical_device, &queue_families_count, &queue_families[0] )。第一个和第二个参数应与上一个调用相同;最后一个参数应指向queue_families向量的第一个元素。 -
为了确保一切顺利,请检查
queue_families_count变量是否大于零。如果成功,所有队列家族的属性将存储在queue_families向量中。
它是如何工作的...
与其他查询类似,先前的食谱实现可以分为两个阶段:首先,我们获取有关给定物理设备上可用的队列家族总数的信息。这是通过调用一个 vkGetPhysicalDeviceQueueFamilyProperties() 函数来完成的,最后一个参数设置为 nullptr:
uint32_t queue_families_count = 0;
vkGetPhysicalDeviceQueueFamilyProperties( physical_device, &queue_families_count, nullptr );
if( queue_families_count == 0 ) {
std::cout << "Could not get the number of queue families." << std::endl;
return false;
}
其次,当我们知道有多少个队列家族时,我们可以准备足够的内存来存储所有这些队列家族的属性。在所提供的示例中,我们创建了一个类型为 std::vector 的变量,其元素为 VkQueueFamilyProperties,并将其调整大小为第一次查询返回的值。之后,我们执行第二次 vkGetPhysicalDeviceQueueFamilyProperties() 函数调用,最后一个参数指向创建的向量的第一个元素。在这个向量中,将存储所有可用队列家族的参数。
queue_families.resize( queue_families_count );
vkGetPhysicalDeviceQueueFamilyProperties( physical_device, &queue_families_count, &queue_families[0] );
if( queue_families_count == 0 ) {
std::cout << "Could not acquire properties of queue families." << std::endl;
return false;
}
return true;
我们可以从属性中获取的最重要信息是给定家族中队列可以执行的操作类型。队列支持的操作类型分为:
-
图形: 用于创建图形管道和绘制
-
计算: 用于创建计算管道和调度计算着色器
-
传输: 用于非常快速的内存复制操作
-
稀疏: 允许额外的内存管理功能
给定家族的队列可能支持多种操作类型。也可能存在不同队列家族支持完全相同类型的操作的情况。
家族属性还告诉我们给定家族中可用的队列数量、时间戳支持(用于时间测量)以及图像传输操作的粒度(在复制/复制操作期间可以指定图像的小部分)。
通过了解队列家族的数量、它们的属性以及每个家族中可用的队列数量,我们可以为逻辑设备创建做准备。所有这些信息都是必需的,因为我们不是自己创建队列。我们只是在逻辑设备创建期间请求它们,我们必须指定需要多少个队列以及来自哪些家族。当创建设备时,队列会自动与其一起创建。我们只需要获取所有请求队列的句柄。
参见
-
本章中的以下食谱:
-
选择具有所需功能的队列家族索引
-
创建逻辑设备
-
获取设备队列
-
使用几何着色器、图形和计算队列创建逻辑设备
-
-
第二章中的以下食谱,图像呈现:
- 选择支持向给定表面呈现的队列家族
选择具有所需功能的队列家族索引
在我们能够创建逻辑设备之前,我们需要考虑我们想要在它上执行哪些操作,因为这会影响我们从其中请求队列的队列家族(或家族)的选择。
对于简单的用例,一个支持图形操作的家族的单个队列应该足够。更高级的场景将需要支持图形和计算操作,或者甚至需要一个额外的传输队列以实现非常快速的内存复制。
在这个菜谱中,我们将探讨如何搜索支持所需操作类型的队列家族。
如何实现...
-
取
vkEnumeratePhysicalDevices()函数返回的物理设备句柄之一,并将其存储在名为physical_device的VkPhysicalDevice类型变量中。 -
准备一个名为
queue_family_index的uint32_t类型变量。我们将在此存储支持所选操作类型的队列家族的索引。 -
创建一个名为
desired_capabilities的VkQueueFlags类型的位字段变量。将所需的操作类型存储在desired_capabilities变量中——它可以是VK_QUEUE_GRAPHICS_BIT、VK_QUEUE_COMPUTE_BIT、VK_QUEUE_TRANSFER_BIT或VK_QUEUE_SPARSE_BINDING_BIT中的任何一个值的逻辑“或”操作。 -
创建一个名为
queue_families的std::vector类型变量,其元素为VkQueueFamilyProperties。 -
按照在 检查可用的队列家族及其属性 菜谱中描述的方法检查可用的队列家族数量并获取它们的属性。将此操作的成果存储在
queue_families变量中。 -
使用名为
index的uint32_t类型变量遍历queue_families向量的所有元素。 -
对于
queue_families变量的每个元素:-
检查当前元素中的队列数量(由
queueCount成员指示)是否大于零。 -
检查
desired_capabilities变量和当前迭代元素queueFlags成员之间的逻辑“与”操作是否不等于零。 -
如果两个检查都为正,将
index变量(当前循环迭代)的值存储在queue_family_index变量中,并完成迭代。
-
-
重复从 7.1 到 7.3 的步骤,直到查看
queue_families向量的所有元素。
它是如何工作的...
首先,我们获取给定物理设备上可用的队列家族属性。这是在 检查可用的队列家族及其属性 菜谱中描述的操作。我们将查询结果存储在 queue_families 变量中,该变量是具有 VkQueueFamilyProperties 元素的 std::vector 类型:
std::vector<VkQueueFamilyProperties> queue_families;
if( !CheckAvailableQueueFamiliesAndTheirProperties( physical_device, queue_families ) ) {
return false;
}
接下来,我们开始检查 queue_families 向量的所有元素:
for( uint32_t index = 0; index < static_cast<uint32_t>(queue_families.size()); ++index ) {
if( (queue_families[index].queueCount > 0) &&
(queue_families[index].queueFlags & desired_capabilities ) ) {
queue_family_index = index;
return true;
}
}
return false;
queue_families 向量的每个元素代表一个单独的队列家族。其 queueCount 成员包含给定家族中可用的队列数量。queueFlags 成员是一个位字段,其中每个位代表不同类型的操作。如果某个位被设置,则表示相应的操作类型由给定的队列家族支持。我们可以检查任何支持的组合操作,但我们可能需要为每种操作类型分别搜索单独的队列。这完全取决于硬件支持和 Vulkan API 驱动程序。
为了确保我们所获得的数据是正确的,我们还检查每个家族是否至少暴露一个队列。
更高级的现实场景可能需要我们存储每个家族暴露的队列总数。这是因为我们可能想要请求多个队列,但我们不能请求比给定家族中可用的队列更多的队列。在简单用例中,一个给定家族中的一个队列就足够了。
参见
-
本章中的以下食谱:
-
检查可用的队列家族及其属性
-
创建逻辑设备
-
获取设备队列
-
创建具有几何着色器、图形和计算队列的逻辑设备
-
-
以下食谱在第二章,图像展示:
- 选择支持向给定表面展示的队列家族
创建逻辑设备
逻辑设备是我们应用程序中创建的最重要对象之一。它代表真实硬件,以及为其启用的所有扩展和功能,以及从其请求的所有队列:

逻辑设备使我们能够执行渲染应用程序中通常执行的所有工作,例如创建图像和缓冲区、设置管线状态或加载着色器。它赋予我们最重要的能力是记录命令(例如发出绘制调用或调度计算工作)并将它们提交到队列中,在那里它们由指定的硬件执行和处理。在执行之后,我们获取提交操作的结果。这些可以是计算着色器计算的一组值,或者由绘制调用生成的其他数据(不一定是图像)。所有这些都是在逻辑设备上执行的,因此现在我们将探讨如何创建一个。
准备工作
在本食谱中,我们将使用自定义结构类型的变量。该类型称为 QueueInfo,其定义如下:
struct QueueInfo {
uint32_t FamilyIndex;
std::vector<float> Priorities;
};
在此类变量中,我们将存储关于我们想要请求的给定逻辑设备的队列信息。数据包含我们想要从哪个家族创建队列的索引,从这个家族请求的总队列数,以及分配给每个队列的优先级列表。由于优先级的数量必须等于从给定家族请求的队列数,因此我们从给定家族请求的总队列数等于Priorities向量中的元素数。
如何实现...
-
根据特征、限制、可用扩展和支持的操作类型,选择使用
vkEnumeratePhysicalDevices()函数调用获取的一个物理设备(参考枚举可用物理设备配方)。获取其句柄并将其存储在名为physical_device的VkPhysicalDevice类型变量中。 -
准备一个你想启用的设备扩展列表。将所需扩展的名称存储在名为
desired_extensions的std::vector<char const *>类型变量中。 -
创建一个名为
available_extensions的std::vector<VkExtensionProperties>类型变量。获取所有可用扩展的列表并将其存储在available_extensions变量中(参考检查可用设备扩展配方)。 -
确保从
desired_extensions变量中每个扩展的名称也存在于available_extensions变量中。 -
创建一个名为
desired_features的VkPhysicalDeviceFeatures类型的变量。 -
获取由
physical_device句柄表示的物理设备支持的一组特征,并将其存储在desired_features变量中(参考获取物理设备的特征和属性配方)。 -
确保由
physical_device变量表示的给定物理设备支持所有必需的特征。通过检查获取的desired_features结构中的相应成员是否设置为 1 来实现。清除desired_features结构中的其余成员(将它们设置为 0)。 -
根据属性(支持的操作类型),准备一个队列家族列表,从这些家族请求队列。为每个选定的队列家族准备要请求的队列数量。为给定家族中的每个队列分配一个优先级:一个从
0.0f到1.0f的浮点值(多个队列可能具有相同的优先级值)。创建一个名为queue_infos的std::vector变量,其元素为自定义类型QueueInfo。在queue_infos向量中存储队列家族的索引和优先级列表,Priorities向量的大小应等于每个家族的队列数。 -
创建一个名为
queue_create_infos的std::vector<VkDeviceQueueCreateInfo>类型变量。对于存储在queue_infos变量中的每个队列家族,向queue_create_infos向量中添加一个新元素。为新元素分配以下值:-
sType的值为VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO。 -
pNext的值为nullptr。 -
flags的值为0。 -
队列家族的索引用于
queueFamilyIndex。 -
从给定家族请求的队列数量用于
queueCount。 -
指向给定家族队列优先级列表的第一个元素的指针用于
pQueuePriorities。
-
-
创建一个类型为
VkDeviceCreateInfo的变量,命名为device_create_info。为device_create_info变量的成员分配以下值:-
sType的值为VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO。 -
pNext的值为nullptr。 -
flags的值为0。 -
queue_create_infos向量变量的元素数量用于queueCreateInfoCount。 -
指向
pQueueCreateInfos中queue_create_infos向量变量的第一个元素的指针。 -
enabledLayerCount的值为0。 -
ppEnabledLayerNames的值为nullptr。 -
desired_extensions向量变量的元素数量用于enabledExtensionCount。 -
指向
ppEnabledExtensionNames中desired_extensions向量变量的第一个元素(如果为空,则为nullptr)。 -
指向
pEnabledFeatures中desired_features变量的指针。
-
-
创建一个类型为
VkDevice的变量,命名为logical_device。 -
调用
vkCreateDevice(physical_device, &device_create_info, nullptr, &logical_device)。在第一个参数中提供物理设备的句柄,在第二个参数中提供device_create_info变量的指针,在第三个参数中使用nullptr值,在最后一个参数中提供logical_device变量的指针。 -
通过检查
vkCreateDevice()函数调用返回的值是否等于VK_SUCCESS来确保操作成功。
它是如何工作的...
要创建逻辑设备,我们需要准备相当多的数据。首先,我们需要获取给定物理设备支持的扩展列表,然后我们需要检查我们想要启用的所有扩展是否都包含在支持的扩展列表中。类似于实例创建,我们不能使用不支持扩展来创建逻辑设备。这样的操作将失败:
std::vector<VkExtensionProperties> available_extensions;
if( !CheckAvailableDeviceExtensions( physical_device, available_extensions ) ) {
return false;
}
for( auto & extension : desired_extensions ) {
if( !IsExtensionSupported( available_extensions, extension ) ) {
std::cout << "Extension named '" << extension << "' is not supported by a physical device." << std::endl;
return false;
}
}
接下来,我们准备一个名为queue_create_infos的向量变量,它将包含我们想要请求的逻辑设备的相关队列和队列家族信息。这个向量中的每个元素都是VkDeviceQueueCreateInfo类型。它包含的最重要信息是队列家族的索引和请求该家族的队列数量。向量中不能有两个元素指向同一个队列家族。
在 queue_create_infos 向量变量中,我们还提供了有关队列优先级的信息。给定家族中的每个队列可能具有不同的优先级:一个介于 0.0f 和 1.0f 之间的浮点值,值越大表示优先级越高。这意味着硬件将尝试根据此优先级调度在多个队列上执行的操作,并且可能会为具有更高优先级的队列分配更多处理时间。然而,这只是一个提示,并不能保证。它也不会影响来自其他设备的队列:
std::vector<VkDeviceQueueCreateInfo> queue_create_infos;
for( auto & info : queue_infos ) {
queue_create_infos.push_back( {
VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
nullptr,
0,
info.FamilyIndex,
static_cast<uint32_t>(info.Priorities.size()),
info.Priorities.size() > 0 ? &info.Priorities[0] : nullptr
} );
};
queue_create_infos 向量变量被提供给一个类型为 VkDeviceCreateInfo 的变量。在这个变量中,我们存储了有关我们请求逻辑设备队列的不同队列家族数量的信息,启用层和名称的数量,以及我们想要为设备启用的扩展,以及我们想要使用的功能。
层和扩展不是设备正常工作的必需品,但有一些非常有用的扩展,如果我们想在屏幕上显示由 Vulkan 生成的图像,则必须启用它们。
功能也不是必需的,因为核心 Vulkan API 为我们提供了足够的功能,使我们能够生成美丽的图像或执行复杂的计算。如果我们不想启用任何功能,我们可以为 pEnabledFeatures 成员提供一个 nullptr 值,或者提供一个填充为零的变量。然而,如果我们想使用更高级的功能,例如几何或细分着色器,我们需要通过提供一个指向适当变量的指针来启用它们,之前获取支持的功能列表,并确保我们需要的那些是可用的。不必要的功能可以(甚至应该)被禁用,因为有些功能可能会影响性能。这种情况非常罕见,但最好记住这一点。在 Vulkan 中,我们应该只做和只使用需要做和需要使用的事情:
VkDeviceCreateInfo device_create_info = {
VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(queue_create_infos.size()),
queue_create_infos.size() > 0 ? &queue_create_infos[0] : nullptr,
0,
nullptr,
static_cast<uint32_t>(desired_extensions.size()),
desired_extensions.size() > 0 ? &desired_extensions[0] : nullptr,
desired_features
};
device_create_info 变量被提供给 vkCreateDevice() 函数,该函数创建一个逻辑设备。为了确保操作成功,我们需要检查 vkCreateDevice() 函数调用返回的值是否等于 VK_SUCCESS。如果是,创建的逻辑设备的句柄将被存储在函数调用最后一个参数所指向的变量中:
VkResult result = vkCreateDevice( physical_device, &device_create_info, nullptr, &logical_device );
if( (result != VK_SUCCESS) ||
(logical_device == VK_NULL_HANDLE) ) {
std::cout << "Could not create logical device." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
枚举可用的物理设备
-
检查可用的设备扩展
-
获取物理设备的特性和属性
-
检查可用的队列家族及其属性
-
选择具有所需功能的队列家族的索引
-
摧毁逻辑设备
加载设备级函数
我们已经创建了一个逻辑设备,我们可以在这个设备上执行任何所需的操作,例如渲染 3D 场景、计算游戏中的物体碰撞或处理视频帧。这些操作是通过设备级函数执行的,但它们在我们获取它们之前是不可用的。
如何实现...
-
获取创建的逻辑设备对象的句柄。将其存储在类型为
VkDevice的变量logical_device中。 -
选择你想要加载的设备级函数的名称(表示为
<function name>)。 -
对于每个将要加载的设备级功能,创建一个类型为
PFN_<function name>的变量,命名为<function name>。 -
调用
vkGetDeviceProcAddr( device, "<function name>" ),其中你提供创建的逻辑设备句柄作为第一个参数,函数名称作为第二个参数。将此操作的结果转换为PFN_<function name>类型,并存储在<function name>变量中。 -
通过检查
<function name>变量的值是否不等于nullptr来确认操作是否成功。
它是如何工作的...
几乎所有在 3D 渲染应用程序中完成的典型工作都是使用设备级函数来完成的。它们用于创建缓冲区、图像、采样器或着色器。我们使用设备级函数来创建管线对象、同步原语、帧缓冲区以及许多其他资源。最重要的是,它们用于记录随后提交(也使用设备级函数)到队列中的操作,这些操作由硬件处理。所有这些操作都是通过设备级函数完成的。
设备级函数,就像所有其他 Vulkan 函数一样,可以使用 vkGetInstanceProcAddr() 函数来加载,但这种方法并不理想。Vulkan 被设计成一个灵活的 API。它提供了在单个应用程序中执行多个设备操作的选择,但当我们调用 vkGetInstanceProcAddr() 函数时,我们不能提供与逻辑设备相关的任何参数。因此,该函数返回的函数指针无法与我们要在其实际执行给定操作的设备相关联。这个设备在调用 vkGetInstanceProcAddr() 函数时可能甚至不存在。这就是为什么 vkGetInstanceProcAddr() 函数返回一个调度函数,该函数根据其参数调用适合给定逻辑设备的函数实现。然而,这种跳跃有性能成本:它非常小,但仍然需要一些处理器时间来调用正确的函数。
如果我们想要避免这种不必要的跳跃并获取直接对应于给定设备的函数指针,我们应该通过使用 vkGetDeviceProcAddr() 来实现。这样,我们可以避免中间函数调用并提高我们应用程序的性能。这种做法也有一些缺点:我们需要为应用程序中创建的每个设备获取函数指针。如果我们想在许多不同的设备上执行操作,我们需要为每个逻辑设备准备一个单独的函数指针列表。我们不能使用从一个设备获取的函数在另一个设备上执行操作。但是,使用 C++ 语言的预处理器,获取特定于给定设备的函数指针相当容易:

我们如何知道一个函数来自设备级别而不是全局或实例级别?设备级别函数的第一个参数是 VkDevice、VkQueue 或 VkCommandBuffer 类型。从现在开始介绍的大部分函数都属于设备级别。
要加载设备级别函数,我们应该按照以下方式更新 ListOfVulkanFunctions.inl 文件:
#ifndef DEVICE_LEVEL_VULKAN_FUNCTION
#define DEVICE_LEVEL_VULKAN_FUNCTION( function )
#endif
DEVICE_LEVEL_VULKAN_FUNCTION( vkGetDeviceQueue )
DEVICE_LEVEL_VULKAN_FUNCTION( vkDeviceWaitIdle )
DEVICE_LEVEL_VULKAN_FUNCTION( vkDestroyDevice )
DEVICE_LEVEL_VULKAN_FUNCTION( vkCreateBuffer )
DEVICE_LEVEL_VULKAN_FUNCTION( vkGetBufferMemoryRequirements )
// ...
#undef DEVICE_LEVEL_VULKAN_FUNCTION
//
#ifndef DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( function, extension )
#endif
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkCreateSwapchainKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkGetSwapchainImagesKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkAcquireNextImageKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkQueuePresentKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( vkDestroySwapchainKHR, VK_KHR_SWAPCHAIN_EXTENSION_NAME )
#undef DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION
在前面的代码中,我们添加了多个设备级别函数的名称。每个函数都被 DEVICE_LEVEL_VULKAN_FUNCTION 宏(如果它在核心 API 中定义)或 DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION 宏(如果它由扩展引入)包装,并放置在适当的 #ifndef 和 #undef 预处理器指令之间。当然,这个列表是不完整的,因为函数太多,无法在这里全部展示。
记住,我们不应该在没有首先在逻辑设备创建期间启用扩展的情况下加载由给定扩展引入的函数。如果扩展不受支持,其函数不可用,加载它们的操作将失败。这就是为什么,类似于加载实例级别函数,我们需要将函数加载代码分成两个块。
首先,为了使用前面的宏实现设备级别核心 API 函数的加载,我们应该编写以下代码:
#define DEVICE_LEVEL_VULKAN_FUNCTION( name ) \
name = (PFN_##name)vkGetDeviceProcAddr( device, #name ); \
if( name == nullptr ) { \
std::cout << "Could not load device-level Vulkan function named: " #name << std::endl; \
return false; \
}
#include "ListOfVulkanFunctions.inl"
return true;
在此代码示例中,我们创建了一个宏,该宏对于在 ListOfVulkanFunctions.inl 文件中找到的每个 DEVICE_LEVEL_VULKAN_FUNCTION() 定义,调用 vkGetDeviceProcAddr() 函数,并提供我们想要加载的过程名称。此操作的结果被转换为适当的数据类型,并存储在一个与获取到的函数名称完全相同的变量中。如果失败,任何附加信息都会显示在屏幕上。
接下来,我们需要加载由扩展引入的函数。这些扩展必须在逻辑设备创建期间已启用:
#define DEVICE_LEVEL_VULKAN_FUNCTION_FROM_EXTENSION( name,
extension ) \
for( auto & enabled_extension : enabled_extensions ) { \
if( std::string( enabled_extension ) == std::string( extension )
) { \
name = (PFN_##name)vkGetDeviceProcAddr( logical_device, #name );\
if( name == nullptr ) { \
std::cout << "Could not load device-level Vulkan function named: " \
#name << std::endl; \
return false; \
} \
} \
}
#include "ListOfVulkanFunctions.inl"
return true;
在前面的代码中,我们定义了一个宏,该宏遍历所有启用的扩展。它们定义在一个名为 enabled_extensions 的 std::vector<char const*> 类型的变量中。在每次循环迭代中,从向量中比较启用的扩展名称与为给定函数指定的扩展名称。如果它们匹配,则加载函数指针;如果不匹配,则跳过给定的函数,因为我们无法从未启用的扩展中加载函数。
参见
本章中的以下食谱:
-
准备加载 Vulkan API 函数
-
加载从 Vulkan 加载库导出的函数
-
加载全局级别的函数
-
加载实例级别的函数
获取设备队列
在 Vulkan 中,为了利用给定设备的处理能力,我们需要向设备的队列提交操作。队列不是由应用程序显式创建的。它们在设备创建期间请求:我们检查哪些族可用以及每个族包含多少队列。我们只能请求现有队列族中可用队列的子集,并且我们不能请求比给定族公开的队列更多的队列。
请求的队列将与逻辑设备自动创建。我们不需要管理它们,也不需要显式创建它们。我们也不能销毁它们;它们也会与逻辑设备一起销毁。为了使用它们并能够向设备的队列提交任何工作,我们只需要获取它们的句柄。
如何做到这一点...
-
获取创建的逻辑设备对象的句柄。将其存储在名为
logical_device的VkDevice类型变量中。 -
在
VkDeviceQueueCreateInfo结构的queueFamilyIndex成员中获取在逻辑设备创建期间提供的队列族索引。将其存储在名为queue_family_index的uint32_t类型变量中。 -
获取请求的队列族中的一个队列的索引:该索引必须小于
VkDeviceQueueCreateInfo结构的queueCount成员中请求的给定族的总队列数。将索引存储在名为queue_index的uint32_t类型变量中。 -
准备一个名为
queue的VkQueue类型变量。 -
调用
vkGetDeviceQueue(logical_device, queue_family_index, queue_index, &queue)。在第一个参数中提供创建的逻辑设备的句柄;第二个参数必须等于所选的队列族索引;第三个参数必须包含请求给定族中的一个队列的数量;然后,在最后一个参数中提供一个指向queue变量的指针。设备队列的句柄将存储在这个变量中。 -
对所有请求的所有队列族重复步骤 2 到 5。
它是如何工作的...
获取给定队列句柄的代码非常简单:
vkGetDeviceQueue( logical_device, queue_family_index, queue_index, &queue );
我们提供创建的逻辑设备的句柄、队列族的索引以及请求给定族中的队列索引。我们必须提供一个在逻辑设备创建期间提供的族索引之一。这意味着我们不能从在逻辑设备创建期间未指定的族中获取队列的句柄。同样,我们只能提供一个小于给定族请求的队列总数的队列索引。
让我们想象以下情况:一个给定的物理设备在队列族 No. 3 中支持五个队列。在创建逻辑设备期间,我们只从这个队列族 No. 3 中请求两个队列。因此,当我们调用 vkGetDeviceQueue() 函数时,我们必须提供值 3 作为队列族索引。对于队列索引,我们只能提供 0 和 1 的值。
请求的队列句柄存储在一个变量中,我们在vkGetDeviceQueue()函数调用的最后一个参数中提供这个变量的指针。我们可以多次请求同一个队列的句柄。这个调用不会创建队列——它们在逻辑设备创建过程中隐式创建。在这里,我们只是请求一个现有队列的句柄,因此我们可以多次这样做(尽管这样做可能没有太多意义)。
参见
本章中的以下配方:
-
检查可用的队列家族及其属性
-
选择具有所需功能的队列家族索引
-
创建逻辑设备
-
创建具有几何着色器、图形和计算队列的逻辑设备
创建具有几何着色器、图形和计算队列的逻辑设备
在 Vulkan 中,当我们创建各种对象时,我们需要准备许多不同的结构来描述创建过程本身,但它们可能还需要创建其他对象。
逻辑设备也不例外:我们需要枚举物理设备,检查它们的属性和受支持的队列家族,并准备一个需要更多信息的VkDeviceCreateInfo结构。
为了组织这些操作,我们将展示一个示例配方,它从一个支持几何着色器和图形以及计算队列的可用物理设备中创建一个逻辑设备。
如何做到这一点...
-
准备一个名为
logical_device的VkDevice类型的变量。 -
创建两个名为
graphics_queue和compute_queue的VkQueue类型的变量。 -
创建一个名为
physical_devices的std::vector<VkPhysicalDevice>类型的变量。 -
获取给定平台上所有可用物理设备的列表并将其存储在
physical_devices向量中(参考枚举可用物理设备配方)。 -
对于
physical_devices向量中的每个物理设备:-
创建一个名为
device_features的VkPhysicalDeviceFeatures类型的变量。 -
获取给定物理设备支持的特性的列表并将其存储在
device_features变量中。 -
检查
device_features变量的geometryShader成员是否等于VK_TRUE(不是0)。如果是,则重置device_features变量的所有其他成员(将它们的值设置为零);如果不是,则从另一个物理设备重新开始。 -
创建两个名为
graphics_queue_family_index和compute_queue_family_index的uint32_t类型的变量。 -
获取支持图形和计算操作的队列家族的索引,并将它们分别存储在
graphics_queue_family_index和compute_queue_family_index变量中(参考选择具有所需功能的队列家族索引配方)。如果这些操作中的任何一个不受支持,则寻找另一个物理设备。 -
创建一个包含
QueueInfo类型元素的std::vector变量(参考创建逻辑设备配方)。将此变量命名为requested_queues。 -
将
graphics_queue_family_index变量和一个包含1.0f值的单元素floats向量存储在requested_queues变量中。如果compute_queue_family_index变量的值与graphics_queue_family_index变量的值不同,则向requested_queues向量添加另一个元素,包含compute_queue_family_index变量和一个包含1.0f值的单元素floats向量。 -
使用
physical_device、requested_queues、device_features和logical_device变量创建一个逻辑设备(参考创建逻辑设备配方)。如果此操作失败,请使用另一个物理设备重复前面的操作。 -
如果逻辑设备创建成功,加载设备级别的函数(参考加载设备级别的函数配方)。从
graphics_queue_family_index家族获取队列句柄并将其存储在graphics_queue变量中。从compute_queue_family_index家族获取队列并将其存储在compute_queue变量中。
-
它是如何工作的...
要开始创建逻辑设备的流程,我们需要获取给定计算机上所有可用物理设备的句柄:
std::vector<VkPhysicalDevice> physical_devices;
EnumerateAvailablePhysicalDevices( instance, physical_devices );
接下来,我们需要遍历所有可用的物理设备。对于每个这样的设备,我们需要获取其特性。这将告诉我们给定的物理设备是否支持几何着色器:
for( auto & physical_device : physical_devices ) {
VkPhysicalDeviceFeatures device_features;
VkPhysicalDeviceProperties device_properties;
GetTheFeaturesAndPropertiesOfAPhysicalDevice( physical_device, device_features, device_properties );
if( !device_features.geometryShader ) {
continue;
} else {
device_features = {};
device_features.geometryShader = VK_TRUE;
}
如果支持几何着色器,我们可以重置返回的特征列表中的所有其他成员。我们将在创建逻辑设备时提供此列表,但我们不想启用任何其他功能。在这个例子中,几何着色器是我们想要使用的唯一附加功能。
接下来,我们需要检查给定的物理设备是否公开支持图形和计算操作的队列家族。这可能是一个单一的家族或两个不同的家族。我们获取此类队列家族的索引:
uint32_t graphics_queue_family_index;
if( !SelectIndexOfQueueFamilyWithDesiredCapabilities( physical_device, VK_QUEUE_GRAPHICS_BIT, graphics_queue_family_index ) ) {
continue;
}
uint32_t compute_queue_family_index;
if( !SelectIndexOfQueueFamilyWithDesiredCapabilities( physical_device, VK_QUEUE_COMPUTE_BIT, compute_queue_family_index ) ) {
continue;
}
接下来,我们需要准备一个队列家族列表,从中我们想要请求队列。我们还需要为每个家族中的每个队列分配优先级:
std::vector<QueueInfo> requested_queues = { { graphics_queue_family_index, { 1.0f } } };
if( graphics_queue_family_index != compute_queue_family_index ) {
requested_queues.push_back( { compute_queue_family_index, { 1.0f } } );
}
如果图形和计算队列家族具有相同的索引,我们只从一个队列家族请求一个队列。如果它们不同,我们需要请求两个队列:一个来自图形家族,一个来自计算家族。
我们准备创建一个逻辑设备,并为它提供准备好的数据。成功后,我们可以加载设备级别的函数并获取请求队列的句柄:
if( !CreateLogicalDevice( physical_device, requested_queues, {}, &device_features, logical_device ) ) {
continue;
} else {
if( !LoadDeviceLevelFunctions( logical_device, {} ) ) {
return false;
}
GetDeviceQueue( logical_device, graphics_queue_family_index, 0, graphics_queue );
GetDeviceQueue( logical_device, compute_queue_family_index, 0, compute_queue );
return true;
}
}
return false;
参见
本章中的以下配方:
-
枚举可用的物理设备
-
获取物理设备的特性和属性
-
选择具有所需功能的队列家族索引
-
创建逻辑设备
-
加载设备级别的函数
-
获取设备队列
-
销毁逻辑设备
销毁逻辑设备
在我们完成之后并想要退出应用程序时,我们应该清理我们的工作。尽管在销毁 Vulkan 实例时,所有资源应该由驱动程序自动销毁,但我们也应该在应用程序中显式地这样做,以遵循良好的编程指南。销毁资源的顺序应该与它们创建的顺序相反。
资源应该按照它们创建的相反顺序释放。
在本章中,逻辑设备是最后创建的对象,因此它将首先被销毁。
如何操作...
-
获取存储在名为
logical_device的VkDevice类型变量中的逻辑设备句柄。 -
调用
vkDestroyDevice( logical_device, nullptr ); 在第一个参数中提供logical_device变量,在第二个参数中提供一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值赋给logical_device变量。
它是如何工作的...
实现逻辑设备销毁的步骤非常直接:
if( logical_device ) {
vkDestroyDevice( logical_device, nullptr );
logical_device = VK_NULL_HANDLE;
}
首先,我们需要检查逻辑设备句柄是否有效,因为我们不应该销毁尚未创建的对象。然后,我们使用 vkDestroyDevice() 函数调用销毁设备,并将 VK_NULL_HANDLE 值赋给存储逻辑设备句柄的变量。我们这样做是为了以防万一——如果我们的代码中存在错误,我们不会两次销毁同一个对象。
记住,当我们销毁逻辑设备时,我们不能使用从它获得的设备级函数。
参见
- 本章中的创建逻辑设备步骤
销毁 Vulkan 实例
在销毁所有其他资源之后,我们可以销毁 Vulkan 实例。
如何操作...
-
获取存储在名为
instance的VkInstance类型变量中的已创建 Vulkan 实例对象的句柄。 -
调用
vkDestroyInstance( instance, nullptr ),将instance变量作为第一个参数,将nullptr值作为第二个参数。 -
为了安全起见,将
VK_NULL_HANDLE值赋给instance变量。
它是如何工作的...
在我们关闭应用程序之前,我们应该确保所有创建的资源都已释放。以下代码销毁 Vulkan 实例:
if( instance ) {
vkDestroyInstance( instance, nullptr );
instance = VK_NULL_HANDLE;
}
参见
- 本章中的创建 Vulkan 实例步骤
发布 Vulkan 加载器库
动态加载的库必须显式关闭(释放)。为了能够在我们的应用程序中使用 Vulkan,我们打开了 Vulkan 加载器(Windows 上的 vulkan-1.dll 库,或 Linux 上的 libvulkan.so.1 库)。因此,在我们关闭应用程序之前,我们应该释放它。
如何操作...
在 Windows 操作系统系列中:
-
获取存储在名为
vulkan_library的HMODULE类型变量中的已加载 Vulkan 加载器句柄(参见连接到 Vulkan 加载器库步骤)。 -
调用
FreeLibrary( vulkan_library )并提供vulkan_library变量作为唯一参数。 -
为了安全起见,将
nullptr值分配给vulkan_library变量。
在 Linux 操作系统家族中:
-
使用名为
vulkan_library的void*类型的变量,其中存储了已加载的 Vulkan Loader 的句柄(参考 连接到 Vulkan Loader 库 菜谱)。 -
调用
dlclose( vulkan_library ),将vulkan_library变量作为唯一参数提供。 -
为了安全起见,将
nullptr值分配给vulkan_library变量。
它是如何工作的...
在 Windows 操作系统家族中,使用 LoadLibrary() 函数打开动态库。这些库必须通过调用 FreeLibrary() 函数来关闭(释放),其中必须提供先前打开的库的句柄。
在 Linux 操作系统家族中,使用 dlopen() 函数打开动态库。这些库必须通过调用 dlclose() 函数来关闭(释放),其中必须提供先前打开的库的句柄:
#if defined _WIN32
FreeLibrary( vulkan_library );
#elif defined __linux
dlclose( vulkan_library );
#endif
vulkan_library = nullptr;
参见
- 本章中的菜谱 连接到 Vulkan Loader 库
第二章:图像呈现
在本章中,我们将介绍以下食谱:
-
创建启用 WSI 扩展的 Vulkan 实例
-
创建呈现表面
-
选择支持向给定表面呈现的队列家族
-
创建启用 WSI 扩展的逻辑设备
-
选择所需的呈现模式
-
获取呈现表面的功能
-
选择交换链图像的数量
-
选择交换链图像的大小
-
选择交换链图像的期望使用场景
-
选择交换链图像的转换
-
选择交换链图像的格式
-
创建交换链
-
获取交换链图像的句柄
-
创建具有 R8G8B8A8 格式和邮箱呈现模式的交换链
-
获取交换链图像
-
展示图像
-
销毁交换链
-
销毁呈现表面
简介
如 Vulkan 这样的 API 可以用于许多不同的目的,例如数学和物理计算、图像或视频流处理以及数据可视化。但 Vulkan 被设计的主要目的及其最常见的用途是高效渲染 2D 和 3D 图形。当我们的应用程序生成图像时,我们通常希望将其显示在屏幕上。
起初,可能会觉得令人惊讶,Vulkan API 的核心不允许在应用程序窗口中显示生成的图像。这是因为 Vulkan 是一个可移植的、跨平台的 API,但不幸的是,由于不同的操作系统具有截然不同的架构和标准,因此在不同的操作系统上显示图像没有通用的标准。
因此,为 Vulkan API 引入了一组扩展,使我们能够在应用程序的窗口中呈现生成的图像。这些扩展通常被称为窗口系统集成(WSI)。Vulkan 可用的每个操作系统都有自己的扩展集,这些扩展将 Vulkan 与特定操作系统的窗口系统集成在一起。
最重要的扩展是允许我们创建交换链的那个。交换链是一组可以呈现给用户的图像。在本章中,我们将为在屏幕上绘制图像做准备——设置图像参数,如格式、大小等。我们还将查看各种可用的 呈现 模式,这些模式决定了图像的显示方式,即定义是否启用垂直同步。最后,我们将了解如何展示图像——在应用程序窗口中显示它们。
创建启用 WSI 扩展的 Vulkan 实例
为了能够正确地在屏幕上显示图像,我们需要启用一组 WSI 扩展。根据它们引入的功能,它们分为实例级和设备级。第一步是创建一个带有启用扩展的 Vulkan 实例,这些扩展允许我们创建一个呈现表面——这是应用程序窗口的 Vulkan 表示。
如何操作...
在 Windows 操作系统家族中执行以下步骤:
-
准备一个名为
instance的类型为VkInstance的变量。 -
准备一个名为
desired_extensions的类型为std::vector<char const *>的变量。将您想要启用的所有扩展的名称存储在desired_extensions变量中。 -
将
VK_KHR_SURFACE_EXTENSION_NAME值添加到desired_extensions向量中另一个元素。 -
将
VK_KHR_WIN32_SURFACE_EXTENSION_NAME值添加到desired_extensions向量中另一个元素。 -
为
desired_extensions变量中指定的所有扩展创建一个 Vulkan 实例对象(参考第一章中的创建 Vulkan 实例配方,实例和设备)。
在具有X11窗口系统的 Linux 操作系统家族中通过XLIB接口执行以下步骤:
-
准备一个名为
instance的类型为VkInstance的变量。 -
准备一个名为
desired_extensions的类型为std::vector<char const *>的变量。将您想要启用的所有扩展的名称存储在desired_extensions变量中。 -
将
VK_KHR_SURFACE_EXTENSION_NAME值添加到desired_extensions向量中另一个元素。 -
将
VK_KHR_XLIB_SURFACE_EXTENSION_NAME值添加到desired_extensions向量中另一个元素。 -
为
desired_extensions变量中指定的所有扩展创建一个 Vulkan 实例对象(参考第一章中的创建 Vulkan 实例配方,实例和设备)。
在具有 X11 窗口系统的 Linux 操作系统家族中通过XCB接口执行以下步骤:
-
准备一个名为
instance的类型为VkInstance的变量。 -
准备一个名为
desired_extensions的类型为std::vector<char const *>的变量。将您想要启用的所有扩展的名称存储在desired_extensions变量中。 -
将
VK_KHR_SURFACE_EXTENSION_NAME值添加到desired_extensions向量中另一个元素。 -
将
VK_KHR_XCB_SURFACE_EXTENSION_NAME值添加到desired_extensions向量中另一个元素。 -
为
desired_extensions变量中指定的所有扩展创建一个 Vulkan 实例对象(参考第一章中的创建 Vulkan 实例配方,实例和设备)。
它是如何工作的...
实例级别的扩展负责管理、创建和销毁一个呈现表面。它是应用程序窗口的(跨平台)表示。通过它,我们可以检查我们是否能够向窗口绘制(显示图像、呈现是队列家族的附加属性),它的参数是什么,或者支持哪些呈现模式(如果我们想启用或禁用垂直同步)。
显示表面直接连接到我们的应用程序窗口,因此只能以特定于给定操作系统的特定方式创建。这就是为什么这种功能是通过扩展引入的,每个操作系统都有自己的扩展来创建显示表面。在 Windows 操作系统家族中,这个扩展称为 VK_KHR_win32_surface。在具有 X11 窗口系统的 Linux 操作系统家族中,这个扩展称为 VK_KHR_xlib_surface。在具有 XCB 窗口系统的 Linux 操作系统家族中,这个扩展称为 VK_KHR_xcb_surface。
销毁显示表面的功能是通过一个名为 VK_KHR_surface 的附加扩展启用的。它在所有操作系统上都是可用的。因此,为了正确管理显示表面、检查其参数并验证向其呈现的能力,我们需要在创建 Vulkan 实例时启用两个扩展。
VK_KHR_win32_surface 和 VK_KHR_surface 扩展引入了在 Windows 操作系统家族中创建和销毁显示表面的能力。
VK_KHR_xlib_surface 和 VK_KHR_surface 扩展引入了在 Linux 操作系统家族中使用 X11 窗口系统和 XLIB 接口创建和销毁显示表面的能力。
VK_KHR_xcb_surface 和 VK_KHR_surface 扩展引入了在 Linux 操作系统家族中使用 X11 窗口系统和 XCB 接口创建和销毁显示表面的能力。
为了创建一个支持创建和销毁显示表面过程的 Vulkan 实例,我们需要准备以下代码:
desired_extensions.emplace_back( VK_KHR_SURFACE_EXTENSION_NAME );
desired_extensions.emplace_back(
#ifdef VK_USE_PLATFORM_WIN32_KHR
VK_KHR_WIN32_SURFACE_EXTENSION_NAME
#elif defined VK_USE_PLATFORM_XCB_KHR
VK_KHR_XCB_SURFACE_EXTENSION_NAME
#elif defined VK_USE_PLATFORM_XLIB_KHR
VK_KHR_XLIB_SURFACE_EXTENSION_NAME
#endif
);
return CreateVulkanInstance( desired_extensions, application_name, instance );
在前面的代码中,我们从一个向量变量开始,其中存储了我们想要启用的所有扩展的名称。然后我们将所需的 WSI 扩展添加到向量中。这些扩展的名称通过方便的预处理器定义提供。它们在 vulkan.h 文件中定义。有了它们,我们不需要记住扩展的确切名称,如果出错,编译器会告诉我们。
在我们准备好所需扩展的列表后,我们可以像在 第一章 的 创建 Vulkan 实例 配方中描述的那样创建 Vulkan 实例对象。
参见
-
在 第一章 的 实例和设备 部分中,查看以下配方:
-
检查可用的实例扩展
-
创建 Vulkan 实例
-
-
本章中的以下配方:
- 启用 WSI 扩展创建逻辑设备
创建显示表面
显示表面代表应用程序的窗口。它允许我们获取窗口的参数,例如尺寸、支持的颜色格式、所需图像数量或显示模式。它还允许我们检查给定的物理设备是否能够在给定的窗口中显示图像。
因此,在我们想在屏幕上显示图像的情况下,我们首先需要创建一个显示表面,因为它将帮助我们选择一个符合我们需求的物理设备。
准备工作
要创建一个显示表面,我们需要提供应用程序窗口的参数。为了做到这一点,窗口必须已经创建。在这个菜谱中,我们将通过一个类型为 WindowParameters 的结构来提供其参数。其定义看起来像这样:
struct WindowParameters {
#ifdef VK_USE_PLATFORM_WIN32_KHR
HINSTANCE HInstance;
HWND HWnd;
#elif defined VK_USE_PLATFORM_XLIB_KHR
Display * Dpy;
Window Window;
#elif defined VK_USE_PLATFORM_XCB_KHR
xcb_connection_t * Connection;
xcb_window_t Window;
#endif
};
在 Windows 上,该结构包含以下参数:
-
一个名为
HInstance的类型为HINSTANCE的变量,其中我们存储使用GetModuleHandle()函数获取的值 -
一个名为
HWnd的类型为HWND的变量,其中我们存储了CreateWindow()函数返回的值
在 Linux 的 X11 窗口系统和 XLIB 接口中,该结构包含以下成员:
-
一个名为
Dpy的类型为Display*的变量,其中存储了XOpenDisplay()函数调用的值 -
一个名为
Window的类型为Window的变量,我们将XCreateWindow()或XCreateSimpleWindow()函数返回的值赋给它
在 Linux 的 X11 窗口系统和 XCB 接口中,WindowParameters 结构包含以下成员:
-
一个名为
Connection的类型为xcb_connection_t*的变量,其中存储了xcb_connect()函数返回的值 -
一个名为
Window的类型为xcb_window_t的变量,其中存储了xcb_generate_id()函数返回的值
如何做到这一点...
在 Windows 操作系统家族中,执行以下步骤:
-
取一个名为
instance的类型为VkInstance的变量,其中存储了一个创建的 Vulkan 实例的句柄。 -
创建一个名为
window_parameters的类型为WindowParameters的变量。为其成员分配以下值:-
CreateWindow()函数为HWnd返回的值 -
GetModuleHandle(nullptr)函数返回的值用于HInstance
-
-
创建一个名为
surface_create_info的类型为VkWin32SurfaceCreateInfoKHR的变量,并使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR的值用于sType -
nullptr的值用于pNext -
0的值用于flags -
window_parameters.HInstance成员用于hinstance -
window_parameters.HWnd成员用于hwnd
-
-
创建一个名为
presentation_surface的类型为VkSurfaceKHR的变量,并将其赋值为VK_NULL_HANDLE。 -
调用
vkCreateWin32SurfaceKHR(instance, &surface_create_info, nullptr, &presentation_surface)。在第一个参数中提供一个创建的实例的句柄,在第二个参数中提供一个指向surface_create_info变量的指针,在第三个参数中使用nullptr值,在最后一个参数中提供一个指向presentation_surface变量的指针。 -
确保调用
vkCreateWin32SurfaceKHR()函数成功,通过检查其返回值是否等于VK_SUCCESS以及presentation_surface变量的值是否不等于VK_NULL_HANDLE。
在具有 X11 窗口系统和 XLIB 接口的 Linux 操作系统家族上,执行以下步骤:
-
取名为
instance的类型为VkInstance的变量,其中存储了一个创建的 Vulkan 实例的句柄。 -
创建一个名为
window_parameters的类型为WindowParameters的变量。为其成员分配以下值:-
XOpenDisplay()函数为Dpy返回的值 -
XCreateSimpleWindow()或XCreateWindow()函数为Window返回的值
-
-
创建一个名为
surface_create_info的类型为VkXlibSurfaceCreateInfoKHR的变量,并使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_XLIB_SURFACE_CREATE_INFO_KHR的值用于sType -
pNext的值为nullptr -
flags的值为0 -
dpy的window_parameters.Dpy成员 -
window的window_parameters.Window成员
-
-
创建一个名为
presentation_surface的类型为VkSurfaceKHR的变量,并将其值设置为VK_NULL_HANDLE。 -
调用
vkCreateXlibSurfaceKHR(instance, &surface_create_info, nullptr, &presentation_surface)。在第一个参数中提供一个创建的实例的句柄,在第二个参数中提供一个指向surface_create_info变量的指针,在第三个参数中使用nullptr值,在最后一个参数中提供一个指向presentation_surface变量的指针。 -
通过检查
vkCreateXlibSurfaceKHR()函数返回的值是否等于VK_SUCCESS以及presentation_surface变量的值是否不等于VK_NULL_HANDLE,确保vkCreateXlibSurfaceKHR()函数调用成功。
在具有 X11 窗口系统和 XCB 接口的 Linux 操作系统家族上,执行以下步骤:
-
取名为
instance的类型为VkInstance的变量,其中存储了一个创建的 Vulkan 实例的句柄。 -
创建一个名为
window_parameters的类型为WindowParameters的变量。为其成员分配以下值:-
xcb_connect()函数为Connection返回的值 -
xcb_generate_id()函数为Window返回的值
-
-
创建一个名为
surface_create_info的类型为VkXcbSurfaceCreateInfoKHR的变量,并使用以下值初始化其成员:-
sType的值为VK_STRUCTURE_TYPE_XCB_SURFACE_CREATE_INFO_KHR -
pNext的值为nullptr -
flags的值为0 -
connection的window_parameters.Connection成员 -
window的window_parameters.Window成员
-
-
创建一个名为
presentation_surface的类型为VkSurfaceKHR的变量,并将其值设置为VK_NULL_HANDLE。 -
调用
vkCreateXcbSurfaceKHR(instance, &surface_create_info, nullptr, &presentation_surface)。在第一个参数中提供一个创建的实例的句柄,在第二个参数中提供一个指向surface_create_info变量的指针,在第三个参数中使用nullptr值,在最后一个参数中提供一个指向presentation_surface变量的指针。 -
通过检查
vkCreateXcbSurfaceKHR()函数返回的值是否等于VK_SUCCESS以及presentation_surface变量的值是否不等于VK_NULL_HANDLE,确保该函数调用成功。
它是如何工作的...
展示表面的创建在很大程度上依赖于特定于给定操作系统的参数。在每种操作系统上,我们需要创建不同类型的变量并调用不同的函数。以下是在 Windows 上创建展示表面的代码:
#ifdef VK_USE_PLATFORM_WIN32_KHR
VkWin32SurfaceCreateInfoKHR surface_create_info = {
VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR,
nullptr,
0,
window_parameters.HInstance,
window_parameters.HWnd
};
VkResult result = vkCreateWin32SurfaceKHR( instance, &surface_create_info, nullptr, &presentation_surface );
这里是 Linux 操作系统上使用 X11 窗口系统时执行相同操作的代码片段:
#elif defined VK_USE_PLATFORM_XLIB_KHR
VkXlibSurfaceCreateInfoKHR surface_create_info = {
VK_STRUCTURE_TYPE_XLIB_SURFACE_CREATE_INFO_KHR,
nullptr,
0,
window_parameters.Dpy,
window_parameters.Window
};
VkResult result = vkCreateXlibSurfaceKHR( instance, &surface_create_info, nullptr, &presentation_surface );
最后,这是 Linux 上的 XCB 窗口系统的部分,同样是在 Linux 上:
#elif defined VK_USE_PLATFORM_XCB_KHR
VkXcbSurfaceCreateInfoKHR surface_create_info = {
VK_STRUCTURE_TYPE_XCB_SURFACE_CREATE_INFO_KHR,
nullptr,
0,
window_parameters.Connection,
window_parameters.Window
};
VkResult result = vkCreateXcbSurfaceKHR( instance, &surface_create_info, nullptr, &presentation_surface );
#endif
上述代码示例非常相似。在每个示例中,我们创建了一个结构类型变量,并用创建的窗口的参数初始化其成员。接下来,我们调用一个vkCreate???SurfaceKHR()函数来创建展示表面,并将其句柄存储在presentation_surface变量中。之后,我们应该检查一切是否按预期工作:
if( (VK_SUCCESS != result) ||
(VK_NULL_HANDLE == presentation_surface) ) {
std::cout << "Could not create presentation surface." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
获取展示表面的能力
-
创建交换链
-
销毁展示表面
选择支持向给定表面展示的队列家族
在屏幕上显示图像是通过向设备的队列提交一个特殊命令来完成的。我们不能使用任何我们想要的队列来显示图像,换句话说,我们不能将此操作提交给任何队列。这是因为可能不支持。图像展示,连同图形、计算、传输和稀疏操作,是队列家族的另一个属性。并且与所有类型的操作类似,并非所有队列都支持它,更重要的是,甚至并非所有设备都支持它。这就是为什么我们需要检查哪个物理设备所属的队列家族允许我们在屏幕上展示图像。
如何做到这一点...
-
获取由
vkEnumeratePhysicalDevices()函数返回的物理设备的句柄。将其存储在名为physical_device的VkPhysicalDevice类型变量中。 -
将创建的展示表面及其句柄存储在名为
presentation_surface的VkSurfaceKHR类型变量中。 -
创建一个包含
VkQueueFamilyProperties类型元素的std::vector,并将其命名为queue_families。 -
枚举由
physical_device变量表示的物理设备上可用的所有队列家族(参考第一章中的检查可用的队列家族及其属性食谱)。将此操作的结果存储在queue_families变量中。 -
创建一个名为
queue_family_index的uint32_t类型变量。 -
创建一个名为
index的uint32_t类型变量。使用它来遍历queue_families向量的所有元素。对于queue_families变量的每个元素,执行以下步骤:-
创建一个名为
presentation_supported的VkBool32类型的变量。将值VK_FALSE分配给这个变量。 -
调用
vkGetPhysicalDeviceSurfaceSupportKHR(physical_device, index, presentation_surface, &presentation_supported)。在第一个参数中提供物理设备的句柄,在第二个参数中提供当前循环迭代的数字,在第三个参数中提供呈现表面的句柄。同时,在最后一个参数中提供presentation_supported变量的指针。 -
检查
vkGetPhysicalDeviceSurfaceSupportKHR()函数返回的值是否等于VK_SUCCESS,以及presentation_supported变量的值是否等于VK_TRUE。如果是,将当前循环迭代的值(index变量)存储在queue_family_index变量中,并结束循环。
-
它是如何工作的...
首先,我们需要检查给定物理设备暴露了哪些队列家族。这个操作与第一章的实例和设备部分中描述的检查可用的队列家族及其属性食谱中的方式相同:
std::vector<VkQueueFamilyProperties> queue_families;
if( !CheckAvailableQueueFamiliesAndTheirProperties( physical_device, queue_families ) ) {
return false;
}
接下来,我们可以遍历所有可用的队列家族,并检查给定的家族是否支持图像呈现。这是通过调用vkGetPhysicalDeviceSurfaceSupportKHR()函数来完成的,该函数将信息存储在指定的变量中。如果支持图像呈现,我们可以记住给定家族的索引。从这个家族的所有队列都将支持图像呈现:
for( uint32_t index = 0; index < static_cast<uint32_t>(queue_families.size()); ++index ) {
VkBool32 presentation_supported = VK_FALSE;
VkResult result = vkGetPhysicalDeviceSurfaceSupportKHR( physical_device, index, presentation_surface, &presentation_supported );
if( (VK_SUCCESS == result) &&
(VK_TRUE == presentation_supported) ) {
queue_family_index = index;
return true;
}
}
return false;
当没有队列家族由给定的物理设备导出以支持图像呈现时,我们必须检查此操作是否可在另一个物理设备上执行。
参考内容
在第一章的实例和设备部分,查看以下食谱:
-
检查可用的队列家族及其属性
-
选择具有所需功能的队列家族的索引
-
创建逻辑设备
创建启用了 WSI 扩展的逻辑设备
当我们创建了一个启用了 WSI 扩展的实例,并且找到了一个支持图像呈现的队列家族时,就是时候创建一个启用了另一个扩展的逻辑设备了。设备级别的 WSI 扩展允许我们创建一个 swapchain。这是一个由呈现引擎管理的图像集合。为了使用这些图像并将它们渲染到其中,我们需要获取它们。完成之后,我们将它们归还给呈现引擎。这个操作被称为呈现,它通知驱动程序我们想要向用户展示一个图像(在屏幕上呈现或显示它)。呈现引擎根据在 swapchain 创建期间定义的参数来显示它。我们只能在启用了 swapchain 扩展的逻辑设备上创建它。
如何操作...
-
获取一个物理设备的句柄,该设备有一个支持图像呈现的队列家族,并将其存储在一个名为
VkPhysicalDevice的变量中。 -
准备一个队列家族列表和每个家族的队列数量。为每个家族中的每个队列分配一个优先级(一个介于
0.0f和1.0f之间的浮点值)。将这些参数存储在一个名为queue_infos的std::vector变量中,其元素为自定义类型QueueInfo(参考第一章中的 创建逻辑设备 菜单,实例和设备)。请记住,至少包含一个支持图像呈现的家族的队列。 -
准备一个应启用扩展的列表。将其存储在一个名为
desired_extensions的std::vector<char const *>类型的变量中。 -
将另一个元素添加到
desired_extensions变量中,其值等于VK_KHR_SWAPCHAIN_EXTENSION_NAME。 -
使用在
physical_device和queue_infos变量中准备的参数,以及从desired_extensions向量中启用的所有扩展来创建一个逻辑设备(参考第一章中的 创建逻辑设备 菜单,实例和设备)。
它是如何工作的...
当我们想在屏幕上显示图像时,在创建逻辑设备期间需要启用一个设备级扩展。这被称为 VK_KHR_swapchain,它允许我们创建交换链。
交换链定义了与 OpenGL API 中默认绘制缓冲区参数非常相似的参数。它指定了我们要渲染到的图像的格式、图像的数量(可以认为是双缓冲或三缓冲),或呈现模式(启用或禁用 v-sync)。在交换链中创建的图像由呈现引擎拥有和管理。我们不允许自己创建或销毁它们。我们甚至不能使用它们,直到我们请求这样做。当我们想在屏幕上显示图像时,我们需要请求交换链中的一个图像(获取它),将其渲染进去,然后将图像交还给呈现引擎(呈现它)。
指定一组可显示的图像、获取它们以及在屏幕上显示它们的功能由 VK_KHR_swapchain 扩展定义。
描述的功能由 VK_KHR_swapchain 扩展定义。要在创建逻辑设备时启用它,我们需要准备以下代码:
desired_extensions.emplace_back( VK_KHR_SWAPCHAIN_EXTENSION_NAME );
return CreateLogicalDevice( physical_device, queue_infos, desired_extensions, desired_features, logical_device );
逻辑设备创建的代码与第一章中描述的 创建逻辑设备 菜单中的操作相同,实例和设备。在这里,我们只需要记住,我们必须检查给定的物理设备是否支持 VK_KHR_swapchain 扩展,然后我们需要将其包含在应启用的扩展列表中。
扩展的名称通过VK_KHR_SWAPCHAIN_EXTENSION_NAME预处理器定义指定。它在vulkan.h头文件中定义,帮助我们避免在扩展名称中出错。
参见
-
在第一章中的以下配方,实例和设备:
-
检查可用的设备扩展
-
创建逻辑设备
-
-
在本章中启用 WSI 扩展的创建 Vulkan 实例配方
选择期望的显示模式
在屏幕上显示图像是 Vulkan 交换链最重要的功能之一——实际上,这也是交换链被设计的目的。在 OpenGL 中,当我们完成对后缓冲区的渲染后,我们只需将其与前缓冲区交换,渲染的图像就会显示在屏幕上。我们只能确定是否要显示图像以及是否要使用空白间隔(如果我们想启用 v-sync)。
在 Vulkan 中,我们不仅限于只能渲染一个图像(后缓冲区)。而且,我们可以在图像在屏幕上显示的方式中选择一种或多种方式,而不是两种(启用或禁用 v-sync)。这被称为显示模式,我们需要在创建交换链时指定它。
如何实现...
-
使用
vkEnumeratePhysicalDevices()函数枚举物理设备。将其存储在名为physical_device的VkPhysicalDevice类型的变量中。 -
将创建的显示表面及其句柄存储在名为
presentation_surface的VkSurfaceKHR类型的变量中。 -
创建一个名为
desired_present_mode的VkPresentModeKHR类型的变量。将期望的显示模式存储在这个变量中。 -
准备一个名为
present_modes_count的uint32_t类型的变量。 -
调用
vkGetPhysicalDeviceSurfacePresentModesKHR(physical_device, presentation_surface, &present_modes_count, nullptr)。将物理设备的句柄和显示表面的句柄作为前两个参数提供。在第三个参数中,提供一个指向present_modes_count变量的指针。 -
如果函数调用成功,则
present_modes_count变量将包含支持的显示模式数量。 -
创建一个名为
present_modes的std::vector<VkPresentModeKHR>类型的变量。将向量的大小调整为至少包含present_modes_count个元素。 -
再次调用
vkGetPhysicalDeviceSurfacePresentModesKHR(physical_device, presentation_surface, &present_modes_count, &present_modes[0]),但这次,在最后一个参数中,提供一个指向present_modes向量第一个元素的指针。 -
如果函数返回
VK_SUCCESS值,则present_modes变量将包含在给定平台上支持的显示模式。 -
遍历
present_modes向量的所有元素。检查这些元素中是否有与存储在desired_present_mode变量中的期望显示模式相等的元素。 -
如果所需的展示模式不受支持(
present_modes向量的任何元素都不等于desired_present_mode变量),则选择 FIFO 展示模式--VK_PRESENT_MODE_FIFO_KHR的值--这始终应该被支持。
它是如何工作的...
展示模式定义了图像在屏幕上显示的方式。目前,在 Vulkan API 中定义了四种模式。
最简单的是立即模式。在这里,当展示一个图像时,它立即替换正在显示的图像。没有等待,没有队列,也没有从应用程序角度应该考虑的其他参数。正因为如此,可能会观察到屏幕撕裂:

必须支持的展示模式,即每个 Vulkan API 实现都必须支持的,是FIFO 模式。在这里,当展示一个图像时,它被添加到先进先出队列(这个队列的长度等于 swapchain 中的图像数量减一,n - 1)。从这个队列中,图像在同步消隐周期(v-sync)的情况下显示在屏幕上,始终按照它们被添加到队列中的相同顺序。在这个模式下没有撕裂,因为 v-sync 已启用。这种模式类似于 OpenGL 的缓冲区交换,其中交换间隔设置为 1。
FIFO 展示模式必须始终支持。
还有一种 FIFO 模式的轻微修改,称为FIFO RELAXED。这两种模式之间的区别在于,在RELAXED模式下,只有当图像展示得足够快,快于刷新率时,图像才会与消隐周期同步显示在屏幕上。如果一个图像被应用程序展示,并且从上次展示以来经过的时间大于两个消隐周期之间的刷新时间(FIFO 队列是空的),则图像将立即展示。所以如果我们足够快,就没有屏幕撕裂,但如果我们绘制速度慢于显示器的刷新率,屏幕撕裂将可见。这种行为类似于 OpenGL 的EXT_swap_control_tear扩展中指定的:

最后一种展示模式被称为邮箱模式。它可以被视为三缓冲。在这里,也涉及到一个队列,但它只包含一个元素。在这个队列中等待的图像将与消隐周期(v-sync 已启用)同步显示。但是,当应用程序展示一个图像时,新的图像将替换队列中等待的图像。因此,展示引擎总是显示最新的、最新的可用图像。而且没有屏幕撕裂:

要选择所需的展示模式,我们需要检查当前平台上有哪些模式可用。首先,我们需要获取所有支持展示模式的总数。这是通过调用一个vkGetPhysicalDeviceSurfacePresentModesKHR()函数来完成的,其中最后一个参数设置为nullptr:
uint32_t present_modes_count = 0;
VkResult result = VK_SUCCESS;
result = vkGetPhysicalDeviceSurfacePresentModesKHR( physical_device, presentation_surface, &present_modes_count, nullptr );
if( (VK_SUCCESS != result) ||
(0 == present_modes_count) ) {
std::cout << "Could not get the number of supported present modes." << std::endl;
return false;
}
接下来我们可以为所有支持的模式准备存储,然后再次调用相同的函数,但这次将最后一个参数指向分配的存储:
std::vector<VkPresentModeKHR> present_modes( present_modes_count );
result = vkGetPhysicalDeviceSurfacePresentModesKHR( physical_device, presentation_surface, &present_modes_count, &present_modes[0] );
if( (VK_SUCCESS != result) ||
(0 == present_modes_count) ) {
std::cout << "Could not enumerate present modes." << std::endl;
return false;
}
现在我们知道了可用的展示模式,我们可以检查所选模式是否可用。如果不可用,我们可以从获取的列表中选择另一个展示模式,或者我们退回到强制性和始终可用的默认 FIFO 模式:
for( auto & current_present_mode : present_modes ) {
if( current_present_mode == desired_present_mode ) {
present_mode = desired_present_mode;
return true;
}
}
std::cout << "Desired present mode is not supported. Selecting default FIFO mode." << std::endl;
for( auto & current_present_mode : present_modes ) {
if( current_present_mode == VK_PRESENT_MODE_FIFO_KHR ) {
present_mode = VK_PRESENT_MODE_FIFO_KHR;
return true;
}
}
参见
本章中的以下食谱:
-
选择交换链图像的数量
-
创建交换链
-
使用 R8G8B8A8 格式和邮箱展示模式创建交换链
-
获取交换链图像
-
展示图像
获取展示表面的能力
当我们创建交换链时,我们需要指定创建参数。但我们不能选择我们想要的任何值。我们必须提供适合支持限制的值,这些值可以从展示表面获得。因此,为了正确创建交换链,我们需要获取表面的能力。
如何做到...
-
使用
vkEnumeratePhysicalDevices()函数枚举的所选物理设备的句柄,并将其存储在名为physical_device的类型为VkPhysicalDevice的变量中。 -
获取创建的展示表面的句柄。将其存储在名为
presentation_surface的类型为VkSurfaceKHR的变量中。 -
创建一个名为
surface_capabilities的类型为VkSurfaceCapabilitiesKHR的变量。 -
调用
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physical_device, presentation_surface, &surface_capabilities),其中提供物理设备的句柄和一个展示表面,以及指向surface_capabilities变量的指针。 -
如果函数调用成功,
surface_capabilities变量将包含用于创建交换链的展示表面的参数、限制和能力。
它是如何工作的...
在创建交换链时获取支持的特性和参数范围非常直接:
VkResult result = vkGetPhysicalDeviceSurfaceCapabilitiesKHR( physical_device, presentation_surface, &surface_capabilities );
if( VK_SUCCESS != result ) {
std::cout << "Could not get the capabilities of a presentation surface." << std::endl;
return false;
}
return true;
我们只需调用一个 vkGetPhysicalDeviceSurfaceCapabilitiesKHR() 函数,它将参数存储在类型为 VkSurfaceCapabilitiesKHR 的变量中。这是一个结构,其中包含定义以下参数的成员:
-
允许的最小和最大交换链图像数量
-
展示表面的最小、最大和当前范围
-
支持的图像转换(可以在展示之前应用)和当前正在使用的转换
-
支持的最大图像层数量
-
支持的使用方式
-
支持的表面 alpha 值组合列表(图像的 alpha 成分应该如何影响应用程序的窗口桌面合成)
参见
本章中的以下食谱:
-
创建展示表面
-
选择交换链图像的数量
-
选择交换链图像的大小
-
选择交换链图像的期望使用场景
-
选择交换链图像的转换
-
选择 swapchain 图像的格式
-
创建 swapchain
选择 swapchain 图像的数量
当应用程序想要渲染到 swapchain 图像时,它必须从展示引擎中获取它。应用程序可以获取更多图像;我们不仅仅限制于一次获取一张图像。但是,可用图像的数量(在给定时间未被展示引擎使用)取决于指定的展示模式、应用程序当前的状态(渲染/展示历史)以及图像的数量——当我们创建 swapchain 时,我们必须指定应该创建的(最小)图像数量。
如何实现...
-
获取展示表面的能力(参考 获取展示表面能力 脚本)。将它们存储在名为
surface_capabilities的VkSurfaceCapabilitiesKHR类型变量中。 -
创建一个名为
number_of_images的uint32_t类型变量。 -
将
surface_capabilities.minImageCount + 1的值分配给number_of_images变量。 -
检查
surface_capabilities变量的maxImageCount成员是否大于零。如果是,这意味着创建图像的最大允许数量有限制。在这种情况下,检查number_of_images变量的值是否大于surface_capabilities.maxImageCount的值。如果是,将number_of_images变量的值限制在surface_capabilities变量的maxImageCount成员中定义的限制内。
它是如何工作的...
与 swapchain 一起创建(自动)的图像主要用于展示目的。但它们也允许展示引擎正常工作。屏幕上始终显示一张图像。应用程序必须用另一张图像替换它后才能使用它。展示的图像会立即替换显示的图像,或者根据所选模式等待队列中的适当时刻(v-sync)来替换它。被展示并正在被替换的图像变为未使用状态,可以被应用程序获取。
应用程序只能获取当前处于未使用状态(参考 选择所需展示模式 脚本)的图像。我们可以获取所有这些图像。但是,一旦所有未使用的图像都被获取,我们就需要展示其中至少一张,以便能够获取另一张。如果我们不这样做,获取操作可能会无限期地阻塞。
未使用图像的数量主要取决于展示模式和与 swapchain 一起创建的总图像数量。因此,我们想要创建的图像数量应根据我们想要实现的渲染场景(应用程序希望同时拥有的图像数量)和所选的展示模式来选择。
选择最小数量的图像可能如下所示:
number_of_images = surface_capabilities.minImageCount + 1;
if( (surface_capabilities.maxImageCount > 0) &&
(number_of_images > surface_capabilities.maxImageCount) ) {
number_of_images = surface_capabilities.maxImageCount;
}
return true;
通常,在最常见的渲染场景中,我们将在给定时间内渲染到单个图像。因此,最小支持的图像数量可能就足够了。创建更多图像允许我们同时获取更多图像,但更重要的是,如果实现了适当的渲染算法,这也可能提高我们应用程序的性能。但我们不能忘记图像会消耗相当大的内存。因此,我们为 swapchain 选择的图像数量应该在我们的需求、内存使用和应用程序性能之间取得平衡。
在前面的示例中,展示了这样一个折衷方案,即应用程序选择比允许呈现引擎正常工作的最小值多一个图像。之后,我们还需要检查是否存在上限,以及我们是否超过了它。如果我们超过了,我们需要将选定的值限制在支持的范围内。
参见
本章中的以下配方:
-
选择所需的呈现模式
-
获取呈现表面的能力
-
创建 swapchain
-
获取 swapchain 图像
-
呈现图像
选择 swapchain 图像的大小
通常,为 swapchain 创建的图像应该适合应用程序的窗口。支持的尺寸在呈现表面的能力中可用。但在某些操作系统上,图像的大小定义了窗口的最终大小。我们也应该记住这一点,并检查 swapchain 图像的适当尺寸。
如何做到这一点...
-
获取呈现表面的能力(参考获取呈现表面能力配方)。将它们存储在名为
surface_capabilities的VkSurfaceCapabilitiesKHR类型的变量中。 -
创建一个名为
size_of_images的VkExtent2D类型的变量,我们将存储所需的 swapchain 图像的大小。 -
检查
surface_capabilities变量的currentExtent.width成员是否等于0xFFFFFFFF(-1转换为uint32_t类型的无符号值)。如果是,这意味着图像的大小决定了窗口的大小。在这种情况下:-
为
size_of_images变量的width和height成员分配所需的值 -
将
size_of_images变量的width成员的值限制在由surface_capabilities.minImageExtent.width和surface_capabilities.maxImageExtent.width定义的范围内 -
将
size_of_images变量的height成员的值限制在由surface_capabilities.minImageExtent.height和surface_capabilities.maxImageExtent.height定义的范围内
-
-
如果
surface_capabilities变量的currentExtent.width成员的值不等于0xFFFFFFFF,在size_of_images变量中存储surface_capabilities.currentExtent的值。
它是如何工作的...
交换链图像的大小必须符合支持的极限。这些极限由表面能力定义。在大多数典型场景中,我们希望渲染到与应用程序窗口客户端区域相同尺寸的图像。这个值在表面的能力成员currentExtent中指定。
但有一些操作系统,窗口的大小由交换链图像的大小决定。这种情况通过表面能力的currentExtent.width或currentExtent.height成员的0xFFFFFFFF值来表示。在这种情况下,我们可以定义图像的大小,但它仍然必须在一个指定的范围内:
if( 0xFFFFFFFF == surface_capabilities.currentExtent.width ) {
size_of_images = { 640, 480 };
if( size_of_images.width < surface_capabilities.minImageExtent.width ) {
size_of_images.width = surface_capabilities.minImageExtent.width;
} else if( size_of_images.width > surface_capabilities.maxImageExtent.width ) {
size_of_images.width = surface_capabilities.maxImageExtent.width;
}
if( size_of_images.height < surface_capabilities.minImageExtent.height ) {
size_of_images.height = surface_capabilities.minImageExtent.height;
} else if( size_of_images.height > surface_capabilities.maxImageExtent.height ) {
size_of_images.height = surface_capabilities.maxImageExtent.height;
}
} else {
size_of_images = surface_capabilities.currentExtent;
}
return true;
参见
本章中的以下食谱:
-
获取呈现表面的能力
-
创建交换链
选择交换链图像的期望使用场景
使用交换链创建的图像通常用作颜色附件。这意味着我们希望将渲染到它们中(将它们用作渲染目标)。但我们不仅限于这种场景。我们可以将交换链图像用于其他目的--我们可以从它们中采样,将它们用作复制操作中的数据源,或者将数据复制到它们中。这些都是不同的图像使用方式,我们可以在创建交换链期间指定它们。但是,再次强调,我们需要检查这些使用是否受支持。
如何做到这一点...
-
获取呈现表面的能力(参考获取呈现表面的能力食谱)。将它们存储在一个名为
surface_capabilities的VkSurfaceCapabilitiesKHR类型变量中。 -
选择所需的图像使用方式,并将它们存储在一个名为
desired_usages的位字段类型VkImageUsageFlags的变量中。 -
在一个名为
image_usage的VkImageUsageFlags类型变量中创建一个变量,其中将存储在给定平台上受支持的请求使用列表。将image_usage变量的值设置为0。 -
遍历
desired_usages位字段变量的所有位。对于变量中的每个位:-
检查位是否设置(等于一)
-
检查
surface_capabilities变量的supportedUsageFlags成员中的对应位是否设置 -
如果前面的检查是正确的,则在
image_usage变量中设置相同的位。
-
-
通过检查
desired_usages和image_usage变量的值是否相等,确保在给定平台上所有请求的使用都是受支持的。
它是如何工作的...
可以为交换链图像选择的用法列表在呈现表面能力的supportedUsageFlags成员中可用。该成员是一个位字段,其中每个位对应于特定的用法。如果给定位被设置,则表示给定的用法受支持。
颜色附件使用(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT)必须始终受支持。
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT用法是强制性的,所有 Vulkan 实现都必须支持它。其他用法是可选的。这就是为什么我们不应该依赖于它们的可用性。同样,我们也不应该请求我们不需要的用法,因为这可能会影响我们应用程序的性能。
选择所需的用法可能看起来像这样:
image_usage = desired_usages & surface_capabilities.supportedUsageFlags;
return desired_usages == image_usage;
我们只取所需用法和支持用法的公共部分。然后检查是否所有请求的用法都受支持。我们通过比较请求的用法和“最终”用法的值来完成此操作。如果它们的值不同,我们知道并非所有所需的用法都受支持。
参考以下内容
本章中的以下食谱:
-
获取呈现表面的能力
-
创建 swapchain
选择 swapchain 图像的转换
在某些(特别是移动)设备上,图像可以从不同的方向查看。有时我们可能希望能够指定图像在屏幕上显示时应如何定位。在 Vulkan 中,我们有这样的可能性。在创建 swapchain 时,我们需要指定在呈现之前应用于图像的转换。
如何操作...
-
获取呈现表面的能力(参考获取呈现表面的能力食谱)。将它们存储在名为
surface_capabilities的VkSurfaceCapabilitiesKHR类型的变量中。 -
将所需的转换存储在名为
desired_transform的VkSurfaceTransformFlagBitsKHR类型的位字段变量中。 -
创建一个名为
surface_transform的VkSurfaceTransformFlagBitsKHR类型的变量,我们将存储受支持的转换。 -
检查在
desired_transform变量中设置的位是否也在呈现表面能力的supportedTransforms成员中设置。如果是,将desired_transform变量的值分配给surface_transform变量。 -
如果不是所有所需的转换都受支持,则通过将
surface_capabilities.currentTransform的值分配给surface_transform变量来回退到当前转换。
它是如何工作的...
呈现表面能力的supportedTransforms成员定义了在给定平台上可用的所有图像转换列表。转换定义了在屏幕上显示之前图像应该如何旋转或镜像。在创建 swapchain 期间,我们可以指定所需的转换,并且呈现引擎将其应用于图像作为显示过程的一部分。
我们可以选择任何受支持的值。以下是一个代码示例,如果可用则选择所需的转换,否则仅使用当前使用的转换:
if( surface_capabilities.supportedTransforms & desired_transform ) {
surface_transform = desired_transform;
} else {
surface_transform = surface_capabilities.currentTransform;
}
参考以下内容
本章中的以下食谱:
-
获取呈现表面的能力
-
创建 swapchain
选择 swapchain 图像的格式
格式定义了颜色组件的数量、每个组件的位数以及使用的数据类型。在创建交换链时,我们必须指定是否要使用带有或不带有 alpha 组件的红、绿、蓝通道,颜色值是否应该使用无符号整数或浮点数据类型进行编码,以及它们的精度是多少。我们还必须选择是否使用线性或非线性颜色空间进行颜色值的编码。但与其他交换链参数一样,我们只能使用由显示表面支持的值。
准备工作
在这个菜谱中,我们使用了一些可能看起来相同的术语,但实际上它们指定了不同的参数:
-
图像格式用于描述图像像素的组件数量、精度和数据类型。它对应于
VkFormat类型的变量。 -
颜色空间决定了硬件解释颜色组件值的方式,是使用线性或非线性函数进行编码或解码。颜色空间对应于
VkColorSpaceKHR类型的变量。 -
表面格式是图像格式和颜色空间的一对,由
VkSurfaceFormatKHR类型的变量表示。
如何做到这一点...
-
取
vkEnumeratePhysicalDevices()函数返回的物理设备的句柄。将其存储在名为physical_device的VkPhysicalDevice类型变量中。 -
将创建的显示表面存储在其名为
presentation_surface的VkSurfaceKHR类型变量中。 -
选择所需的图像格式和颜色空间,并将它们分配给名为
desired_surface_format的VkSurfaceFormatKHR类型变量的成员。 -
创建一个名为
formats_count的uint32_t类型变量。 -
调用
vkGetPhysicalDeviceSurfaceFormatsKHR( physical_device, presentation_surface, &formats_count, nullptr ),在第一个参数中提供物理设备的句柄,在第二个参数中提供显示表面的句柄,在第三个参数中提供一个指向formats_count变量的指针。将最后一个参数的值设置为nullptr。 -
如果函数调用成功,
formats_count变量将包含所有支持的格式-颜色空间对的数量。 -
创建一个名为
surface_formats的std::vector<VkSurfaceFormatKHR>类型的变量。调整向量大小,使其至少能容纳formats_count个元素。 -
执行以下调用,
vkGetPhysicalDeviceSurfaceFormatsKHR( physical_device, presentation_surface, &formats_count, &surface_formats[0] )。为前三个参数提供相同的参数。在最后一个参数中,提供一个指向surface_formats向量第一个元素的指针。 -
如果调用成功,所有可用的图像格式-颜色空间对都将存储在
surface_formats变量中。 -
创建一个名为
image_format的VkFormat类型变量和一个名为image_color_space的VkColorSpaceKHR类型变量,我们将存储在创建 swapchain 时使用的格式和颜色空间的选择值。 -
检查
surface_formats向量的元素数量。如果它只包含一个值为VK_FORMAT_UNDEFINED的元素,这意味着我们可以选择我们想要的任何表面格式。将desired_surface_format变量的成员分配给image_format和image_color_space变量。 -
如果
surface_formats向量包含更多元素,遍历向量的每个元素,并将format和colorSpace成员与desired_surface_format变量的相同成员进行比较。如果我们找到一个两个成员都相同的元素,这意味着所需的表面格式受支持,并且我们可以用它来创建 swapchain。将desired_surface_format变量的成员分配给image_format和image_color_space变量。 -
如果还没有找到匹配项,遍历
surface_formats向量的所有元素。检查其任何元素的format成员是否与所选surface_format.format的值相同。如果存在这样的元素,将desired_surface_format.format的值分配给image_format变量,但将从当前查看的surface_formats向量的元素中获取相应的颜色空间并分配给image_color_space变量。 -
如果
surface_formats变量不包含任何具有所选图像格式的元素,则取向量的第一个元素,并将它的format和colorSpace成员分配给image_format和image_color_space变量。
它是如何工作的...
要获取所有支持表面格式的列表,我们需要执行两次vkGetPhysicalDeviceSurfaceFormatsKHR()函数的调用。首先,我们获取所有支持格式-颜色空间对的数目:
uint32_t formats_count = 0;
VkResult result = VK_SUCCESS;
result = vkGetPhysicalDeviceSurfaceFormatsKHR( physical_device, presentation_surface, &formats_count, nullptr );
if( (VK_SUCCESS != result) ||
(0 == formats_count) ) {
std::cout << "Could not get the number of supported surface formats." << std::endl;
return false;
}
接下来,我们可以为实际值准备存储空间,并执行第二次调用以获取它们:
std::vector<VkSurfaceFormatKHR> surface_formats( formats_count );
result = vkGetPhysicalDeviceSurfaceFormatsKHR( physical_device, presentation_surface, &formats_count, &surface_formats[0] );
if( (VK_SUCCESS != result) ||
(0 == formats_count) ) {
std::cout << "Could not enumerate supported surface formats." << std::endl;
return false;
}
之后,我们可以选择一个最适合我们需求的受支持表面格式。如果只返回了一个表面格式并且它的值为VK_FORMAT_UNDEFINED,这意味着对支持的格式-颜色空间对没有限制。在这种情况下,我们可以选择我们想要的任何表面格式并在创建 swapchain 时使用它:
if( (1 == surface_formats.size()) &&
(VK_FORMAT_UNDEFINED == surface_formats[0].format) ) {
image_format = desired_surface_format.format;
image_color_space = desired_surface_format.colorSpace;
return true;
}
如果vkGetPhysicalDeviceSurfaceFormatsKHR()函数返回了更多元素,我们需要从中选择一个。首先,我们检查所选表面格式是否完全受支持--所选图像格式和颜色空间都是可用的:
for( auto & surface_format : surface_formats ) {
if( (desired_surface_format.format == surface_format.format) &&
(desired_surface_format.colorSpace == surface_format.colorSpace) ) {
image_format = desired_surface_format.format;
image_color_space = desired_surface_format.colorSpace;
return true;
}
}
如果找不到匹配项,我们寻找一个具有相同图像格式但其他颜色空间的成员。我们不能选择任何支持的格式和任何支持的颜色空间--我们必须选择与给定格式相对应的相同颜色空间:
for( auto & surface_format : surface_formats ) {
if( (desired_surface_format.format == surface_format.format) ) {
image_format = desired_surface_format.format;
image_color_space = surface_format.colorSpace;
std::cout << "Desired combination of format and colorspace is not supported. Selecting other colorspace." << std::endl;
return true;
}
}
最后,如果我们想使用的格式不受支持,我们只需取第一个可用的图像格式-颜色空间对:
image_format = surface_formats[0].format;
image_color_space = surface_formats[0].colorSpace;
std::cout << "Desired format is not supported. Selecting available format - colorspace combination." << std::endl;
return true;
参见
本章中的以下食谱:
-
创建交换链
-
使用 R8G8B8A8 格式和邮箱展示模式创建交换链
创建交换链
交换链用于在屏幕上显示图像。它是一个图像数组,应用程序可以获取这些图像,然后在我们的应用程序窗口中展示。每个图像都有相同的定义属性集。当我们准备好了所有这些参数,意味着我们选择了一个数字、大小、格式和交换链图像的使用场景,并且获取并选择了一个可用的展示模式,我们就准备好创建一个交换链了。
如何做到这一点...
-
拿到一个创建的逻辑设备对象的句柄。将其存储在一个名为
logical_device的VkDevice类型变量中。 -
将创建的展示表面句柄分配给名为
VkSurfaceKHR类型的变量presentation_surface。 -
将分配给变量
uint32_t类型的image_count的所需数量的交换链图像句柄。 -
将所选图像格式和颜色空间的值存储在名为
VkSurfaceFormatKHR类型的变量surface_format中。 -
准备所需图像大小并将其分配给名为
VkExtent2D类型的变量image_size。 -
选择交换链图像的所需使用场景,并将它们存储在名为
VkImageUsageFlags类型的位字段变量image_usage中。 -
将存储在名为
VkSurfaceTransformFlagBitsKHR类型的变量surface_transform中的所选表面转换。 -
准备一个名为
present_mode的VkPresentModeKHR类型变量,并将其分配一个所需的展示模式。 -
创建一个名为
old_swapchain的VkSwapchainKHR类型变量。如果有之前创建的交换链,将那个交换链的句柄存储在这个变量中。否则,将VK_NULL_HANDLE值分配给此变量。 -
创建一个名为
swapchain_create_info的VkSwapchainCreateInfoKHR类型变量。将以下值分配给此变量的成员:-
为
sType的VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR值 -
为
pNext的nullptr值 -
为
flags的0值 -
为
surface的presentation_surface变量 -
为
minImageCount的image_count变量 -
为
imageFormat的surface_format.format成员 -
为
imageColorSpace的surface_format.colorSpace成员 -
为
imageExtent的image_size变量 -
为
imageArrayLayers的1值(或更多,如果我们想进行分层/立体渲染) -
为
imageUsage的image_usage变量 -
为
imageSharingMode的VK_SHARING_MODE_EXCLUSIVE值 -
为
queueFamilyIndexCount的0值 -
为
pQueueFamilyIndices的nullptr值 -
为
preTransform的surface_transform变量 -
为
compositeAlpha的VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR值 -
为
presentMode的present_mode变量 -
为
clipped的VK_TRUE -
为
oldSwapchain的old_swapchain变量
-
-
创建一个名为
swapchain的VkSwapchainKHR类型变量。 -
调用
vkCreateSwapchainKHR( logical_device, &swapchain_create_info, nullptr, &swapchain )。使用已创建的逻辑设备句柄、swapchain_create_info变量的指针、nullptr值以及swapchain变量的指针作为函数的参数。 -
通过将返回值与
VK_SUCCESS值进行比较,确保调用成功。 -
调用
vkDestroySwapchainKHR( logical_device, old_swapchain, nullptr )来销毁旧 swapchain。提供一个已创建的逻辑设备句柄、旧 swapchain 的句柄,以及函数调用中的nullptr值。
它是如何工作的...
如前所述,swapchain 是一组图像。它们会自动与 swapchain 一起创建。当 swapchain 被销毁时,它们也会被销毁。尽管应用程序可以获取这些图像的句柄,但不允许创建或销毁它们。
创建 swapchain 的过程并不太复杂,但在我们能够创建它之前,我们需要准备相当多的数据:
VkSwapchainCreateInfoKHR swapchain_create_info = {
VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR,
nullptr,
0,
presentation_surface,
image_count,
surface_format.format,
surface_format.colorSpace,
image_size,
1,
image_usage,
VK_SHARING_MODE_EXCLUSIVE,
0,
nullptr,
surface_transform,
VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR,
present_mode,
VK_TRUE,
old_swapchain
};
VkResult result = vkCreateSwapchainKHR( logical_device, &swapchain_create_info, nullptr, &swapchain );
if( (VK_SUCCESS != result) ||
(VK_NULL_HANDLE == swapchain) ) {
std::cout << "Could not create a swapchain." << std::endl;
return false;
}
只能有一个 swapchain 与一个特定应用程序的窗口相关联。当我们创建一个新的 swapchain 时,我们需要销毁之前为同一窗口创建的任何 swapchain:
if( VK_NULL_HANDLE != old_swapchain ) {
vkDestroySwapchainKHR( logical_device, old_swapchain, nullptr );
old_swapchain = VK_NULL_HANDLE;
}
当 swapchain 准备就绪时,我们可以获取其图像并执行适合指定使用场景的任务。我们不仅限于获取单个图像,就像我们习惯在 OpenGL API(单个后缓冲区)中那样。图像的数量取决于应与 swapchain 一起创建的最小指定图像数量、选择的显示模式以及当前的渲染历史(当前获取和最近显示的图像数量)。
在我们获取到一个图像后,我们可以在我们的应用程序中使用它。最常见的用法是将渲染到图像中(将其用作颜色附件),但我们不仅限于这种用法,我们还可以使用 swapchain 图像执行其他任务。但我们必须确保在给定的平台上可用的相应用法,并且在创建 swapchain 时已指定。并非所有平台都支持所有用法。仅颜色附件用法是强制性的。
当我们完成对图像(或图像)的渲染或其他任务后,我们可以通过显示图像来显示它。此操作将图像返回到显示引擎,根据指定的显示模式用新图像替换当前显示的图像。
参见
本章中的以下配方:
-
创建显示表面
-
选择支持向给定表面进行显示的队列家族
-
创建启用 WSI 扩展的逻辑设备
-
选择所需的显示模式
-
获取显示表面的能力
-
选择多个 swapchain 图像
-
选择 swapchain 图像的大小
-
选择 swapchain 图像的期望使用场景
-
选择 swapchain 图像的转换
-
选择 swapchain 图像的格式
获取交换链图像的句柄
当交换链对象被创建时,获取与交换链一起创建的所有图像的数量和句柄可能非常有用。
如何做...
-
获取创建的逻辑设备对象的句柄。将其存储在名为
logical_device的VkDevice类型变量中。 -
将创建的交换链的句柄分配给名为
swapchain的VkSwapchainKHR类型变量。 -
创建一个名为
images_count的uint32_t类型变量。 -
调用
vkGetSwapchainImagesKHR(logical_device, swapchain, &images_count, nullptr),其中在第一个参数中提供创建的逻辑设备句柄,在第二个参数中提供交换链的句柄,在第三个参数中提供指向images_count变量的指针。在最后一个参数中提供nullptr值。 -
如果调用成功,即返回值等于
VK_SUCCESS,则images_count变量将包含为给定交换链对象创建的图像总数。 -
创建一个元素类型为
VkImage的std::vector。命名为swapchain_images,并调整大小以便能够容纳至少images_count个元素。 -
调用
vkGetSwapchainImagesKHR(logical_device, swapchain, &images_count, &swapchain_images[0]),并为前三个参数提供与之前相同的参数。在最后一个参数中,提供指向swapchain_images向量第一个元素的指针。 -
成功时,该向量将包含所有交换链图像的句柄。
它是如何工作的...
驱动程序可能创建比交换链创建参数中请求的更多图像。在那里,我们只定义了所需的最小数量,但 Vulkan 实现被允许创建更多。
我们需要知道创建的图像总数,以便能够获取它们的句柄。在 Vulkan 中,当我们想要将渲染输出到图像时,我们需要知道它的句柄。需要创建一个图像视图来包装图像,并在创建帧缓冲区时使用。帧缓冲区,就像 OpenGL 一样,指定了在渲染过程中使用的图像集合(大多数情况下是我们将渲染到它们)。
但这并不是唯一需要知道与交换链一起创建的图像的情况。据说当应用程序想要使用可呈现的图像时,它必须从显示引擎中获取它。图像获取的过程返回一个数字,而不是句柄本身。提供的数字代表使用vkGetSwapchainImagesKHR()函数(一个swapchain_images变量)获取的图像数组中的图像索引。因此,了解图像的总数、它们的顺序和它们的句柄对于正确使用交换链及其图像是必要的。
要获取图像的总数,我们需要使用以下代码:
uint32_t images_count = 0;
VkResult result = VK_SUCCESS;
result = vkGetSwapchainImagesKHR( logical_device, swapchain, &images_count, nullptr );
if( (VK_SUCCESS != result) ||
(0 == images_count) ) {
std::cout << "Could not get the number of swapchain images." << std::endl;
return false;
}
接下来,我们可以为所有图像准备存储空间并获取它们的句柄:
swapchain_images.resize( images_count );
result = vkGetSwapchainImagesKHR( logical_device, swapchain, &images_count, &swapchain_images[0] );
if( (VK_SUCCESS != result) ||
(0 == images_count) ) {
std::cout << "Could not enumerate swapchain images." << std::endl;
return false;
}
return true;
参见
本章中的以下内容:
-
选择交换链图像的数量
-
创建交换链
-
获取 swapchain 图像
-
显示图像
创建一个具有 R8G8B8A8 格式和存在邮箱显示模式的 swapchain
要创建 swapchain,我们需要获取大量的附加信息和准备相当数量的参数。为了展示准备阶段所需的所有步骤的顺序以及如何使用获取的信息,我们将使用任意选择的参数创建一个 swapchain。为此,我们将设置邮箱显示模式,最常用的 R8G8B8A8 颜色格式,具有无符号归一化值(类似于 OpenGL 的 RGBA8 格式),无转换,以及标准颜色附加图像使用。
如何做到这一点...
-
准备一个物理设备句柄。将其存储在名为
physical_device的类型为VkPhysicalDevice的变量中。 -
获取创建的显示表面的句柄并将其分配给名为
presentation_surface的类型为VkSurfaceKHR的变量。 -
从
physical_device变量表示的句柄中获取逻辑设备。将逻辑设备的句柄存储在名为logical_device的类型为VkDevice的变量中。 -
创建一个名为
old_swapchain的类型为VkSwapchainKHR的变量。如果之前创建了 swapchain,则将其句柄分配给old_swapchain变量。否则,将其分配给VK_NULL_HANDLE。 -
创建一个名为
desired_present_mode的类型为VkPresentModeKHR的变量。 -
检查
VK_PRESENT_MODE_MAILBOX_KHR显示模式是否受支持,并将其分配给desired_present_mode变量。如果不支持此模式,则使用VK_PRESENT_MODE_FIFO_KHR模式(参考选择期望的显示模式配方)。 -
创建一个名为
surface_capabilities的类型为VkSurfaceCapabilitiesKHR的变量。 -
获取显示表面的能力并将它们存储在
surface_capabilities变量中。 -
创建一个名为
number_of_images的类型为uint32_t的变量。根据获取的表面能力,将所需的最小图像数量分配给number_of_images变量(参考选择 swapchain 图像数量配方)。 -
创建一个名为
image_size的类型为VkExtent2D的变量。根据获取的表面能力,将 swapchain 图像的大小分配给image_size变量(参考选择 swapchain 图像大小配方)。 -
确保变量
image_size的width和height成员大于零。如果它们不是,不要尝试创建 swapchain,但不要关闭应用程序——这种情况下可能发生在窗口最小化时。 -
创建一个名为
image_usage的类型为VkImageUsageFlags的变量。将VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT图像使用分配给它(参考选择 swapchain 图像的期望使用场景配方)。 -
创建一个名为
surface_transform的VkSurfaceTransformFlagBitsKHR类型的变量。将一个单位变换(VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR的值)存储在该变量中。根据获取的表面能力,检查它是否受支持。如果不支持,将获取的能力中的currentTransform成员赋值给surface_transform变量(参考 选择交换链图像的变换 章节中的配方)。 -
创建一个名为
image_format的VkFormat类型的变量和一个名为image_color_space的VkColorSpaceKHR类型的变量。 -
使用获取的能力,尝试使用
VK_FORMAT_R8G8B8A8_UNORM图像格式和VK_COLOR_SPACE_SRGB_NONLINEAR_KHR色彩空间。如果格式或色彩空间,或两者都不受支持,则从表面能力中选择其他值(参考 选择交换链图像的格式 章节中的配方)。 -
创建一个名为
swapchain的VkSwapchainKHR类型的变量。 -
使用
logical_device、presentation_surface、number_of_images、image_format、image_color_space、size_of_images、image_usage、surface_transform、desired_present_mode和old_swapchain变量创建一个交换链,并将句柄存储在swapchain变量中。请记住检查交换链创建是否成功。(参考 创建交换链 章节中的配方)。 -
创建一个名为
swapchain_images的std::vector<VkImage>类型的变量,并将创建的交换链图像的句柄存储在其中(参考 掌握交换链图像的句柄 章节中的配方)。
它是如何工作的...
当我们想要创建一个交换链时,我们首先需要考虑我们想要使用哪种展示模式。由于邮箱模式允许我们在不出现屏幕撕裂的情况下展示最新的图像(它类似于三缓冲),这似乎是一个不错的选择:
VkPresentModeKHR desired_present_mode;
if( !SelectDesiredPresentationMode( physical_device, presentation_surface, VK_PRESENT_MODE_MAILBOX_KHR, desired_present_mode ) ) {
return false;
}
接下来,我们需要获取展示表面的能力,并使用它们来设置所需图像的数量、大小(维度)、使用场景、展示期间应用的变换以及它们的格式和色彩空间:
VkSurfaceCapabilitiesKHR surface_capabilities;
if( !GetCapabilitiesOfPresentationSurface( physical_device, presentation_surface, surface_capabilities ) ) {
return false;
}
uint32_t number_of_images;
if( !SelectNumberOfSwapchainImages( surface_capabilities, number_of_images ) ) {
return false;
}
VkExtent2D image_size;
if( !ChooseSizeOfSwapchainImages( surface_capabilities, image_size ) ) {
return false;
}
if( (0 == image_size.width) ||
(0 == image_size.height) ) {
return true;
}
VkImageUsageFlags image_usage;
if( !SelectDesiredUsageScenariosOfSwapchainImages( surface_capabilities, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, image_usage ) ) {
return false;
}
VkSurfaceTransformFlagBitsKHR surface_transform;
SelectTransformationOfSwapchainImages( surface_capabilities, VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR, surface_transform );
VkFormat image_format;
VkColorSpaceKHR image_color_space;
if( !SelectFormatOfSwapchainImages( physical_device, presentation_surface, { VK_FORMAT_R8G8B8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR }, image_format, image_color_space ) ) {
return false;
}
最后,在完成所有这些准备工作后,我们可以创建一个交换链,销毁旧的交换链(如果我们想用新的交换链替换之前创建的交换链),并获取与其一起创建的图像句柄:
if( !CreateSwapchain( logical_device, presentation_surface, number_of_images, { image_format, image_color_space }, image_size, image_usage, surface_transform, desired_present_mode, old_swapchain, swapchain ) ) {
return false;
}
if( !GetHandlesOfSwapchainImages( logical_device, swapchain, swapchain_images ) ) {
return false;
}
return true;
参见
本章中的以下配方:
-
创建一个展示表面
-
创建启用 WSI 扩展的逻辑设备
-
选择期望的展示模式
-
获取展示表面的能力
-
选择交换链图像的数量
-
选择交换链图像的大小
-
选择交换链图像的期望使用场景
-
选择交换链图像的变换
-
选择交换链图像的格式
-
创建交换链
-
掌握交换链图像的句柄
获取交换链图像
在我们能够使用交换链图像之前,我们需要向显示引擎请求它。这个过程被称为图像获取。它返回一个图像的索引,该索引是 vkGetSwapchainImagesKHR() 函数返回的图像数组中的索引,如 获取交换链图像句柄 菜单中所述。
准备就绪
要在 Vulkan 中获取图像,我们需要指定两种尚未描述的对象之一。这些是信号量和栅栏。
信号量用于同步设备的队列。这意味着当我们提交命令进行处理时,这些命令可能需要另一个任务完成。在这种情况下,我们可以指定这些命令应该在执行之前等待其他命令。这正是信号量的作用。它们用于内部队列同步,但我们不能使用它们来同步应用程序与提交的命令(请参阅 第三章,命令缓冲区和同步中的 创建信号量 菜单)。
要这样做,我们需要使用栅栏。它们用于通知应用程序某些工作已完成。应用程序可以获取栅栏的状态,并根据获取的信息检查某些命令是否仍在处理中,或者它们是否已经完成了分配的任务(请参阅 第三章,命令缓冲区和同步中的 创建栅栏 菜单)。
如何操作...
-
获取创建的逻辑设备的句柄,并将其存储在类型为
VkDevice的变量logical_device中。 -
准备一个交换链对象的句柄,并将其分配给名为
swapchain的VkSwapchainKHR变量。 -
准备一个类型为
VkSemaphore的变量semaphore的信号量,或者准备一个栅栏并将它的句柄分配给类型为VkFence的变量fence。您可以准备这两个同步对象,但至少需要其中一个(无论哪个)。 -
创建一个名为
image_index的uint32_t类型的变量。 -
调用
vkAcquireNextImageKHR( logical_device, swapchain, <timeout>, semaphore, fence, &image_index )。在第一个参数中提供逻辑设备的句柄,在第二个参数中提供交换链对象的句柄。对于第三个参数,名为<timeout>,提供函数返回超时错误的时间值。您还需要提供一个或两个同步原语——交换链和/或栅栏。对于最后一个参数,提供一个指向image_index变量的指针。 -
检查
vkAcquireNextImageKHR()函数返回的值。如果返回值等于VK_SUCCESS或VK_SUBOPTIMAL_KHR,则调用成功,image_index变量将包含一个指向由vkGetSwapchainImagesKHR()函数返回的交换链图像数组的索引(参考获取交换链图像句柄过程)。但如果返回了VK_ERROR_OUT_OF_DATE_KHR值,则无法使用交换链中的任何图像。你必须销毁给定的交换链,并重新创建它以获取图像。
它是如何工作的...
vkAcquireNextImageKHR()函数返回一个索引,该索引指向由vkGetSwapchainImagesKHR()函数返回的交换链图像数组。它不返回该图像的句柄。以下代码说明了这个过程:
VkResult result;
result = vkAcquireNextImageKHR( logical_device, swapchain, 2000000000, semaphore, fence, &image_index );
switch( result ) {
case VK_SUCCESS:
case VK_SUBOPTIMAL_KHR:
return true;
default:
return false;
}
在代码示例中,我们调用vkAcquireNextImageKHR()函数。有时由于演示引擎的内部机制,图像可能无法立即可用。甚至可能无限期地等待!这种情况发生在我们想要获取比演示引擎能提供的更多图像时。这就是为什么在前一个函数的第三个参数中,我们提供了一个以纳秒为单位的超时值。它告诉硬件我们可以等待图像多长时间。在此时间之后,函数将通知我们获取图像花费了太长时间。在前面的示例中,我们通知驱动程序我们不想等待超过 2 秒钟来获取图像。
其他有趣的参数是一个信号量和一个栅栏。当我们获取一个图像时,我们仍然可能无法立即为我们自己的目的使用它。我们需要等待所有之前提交的引用此图像的操作完成。为此,可以使用栅栏,通过它应用程序可以检查何时修改图像是安全的。但我们也可以告诉驱动程序在开始处理使用给定图像的新命令之前应该等待。为此,使用信号量,这通常是一个更好的选择。
在应用程序端等待比仅在 GPU 端等待对性能的伤害更大。
在交换链图像获取过程中,返回值也非常重要。当函数返回VK_SUBOPTIMAL_KHR值时,这意味着我们仍然可以使用该图像,但它可能不再最适合演示引擎。我们应该从获取图像的交换链中重新创建交换链。但我们不需要立即这样做。当函数返回VK_ERROR_OUT_OF_DATE_KHR值时,我们不能再使用给定交换链中的图像,并且我们需要尽快重新创建它。
关于交换链图像获取的最后一点是,在我们能够使用图像之前,我们需要更改(转换)其布局。布局是图像的内部内存组织,这可能会根据图像当前使用的目的而有所不同。如果我们想以不同的方式使用图像,我们需要更改其布局。
例如,展示引擎使用的图像必须具有 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR 布局。但如果我们想要将图像渲染到图像中,它必须具有 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL 布局。改变布局的操作称为转换(参考第四章 设置图像内存屏障 的配方,资源和内存)。
参见
-
本章中的以下配方:
-
选择所需的展示模式
-
创建一个交换链
-
获取交换链图像的句柄
-
展示一个图像
-
-
在第四章 资源和内存 中,查看以下配方:
- 设置图像内存屏障
-
在第三章 命令缓冲区和同步 中,查看以下配方:
-
创建一个信号量
-
创建一个栅栏
-
展示一个图像
在我们将图像渲染到交换链图像或用于其他任何目的之后,我们需要将图像归还给展示引擎。这个操作称为展示,它会在屏幕上显示图像。
准备工作
在这个配方中,我们将使用以下定义的自定义结构:
struct PresentInfo {
VkSwapchainKHR Swapchain;
uint32_t ImageIndex;
};
它用于定义我们想要展示图像的交换链,以及我们想要显示的图像(其索引)。对于每个交换链,我们一次只能展示一个图像。
如何进行...
-
准备一个支持展示的队列的句柄。将其存储在名为
queue的VkQueue类型的变量中。 -
准备一个名为
rendering_semaphores的std::vector<VkSemaphore>类型的变量。将与此相关的信号量插入到这个向量中,这些信号量与引用我们想要展示的图像的渲染命令相关联。 -
创建一个名为
swapchains的std::vector<VkSwapchainKHR>类型的变量,用于存储我们想要展示图像的所有交换链的句柄。 -
创建一个名为
image_indices的std::vector<uint32_t>类型的变量。将向量的大小调整为与swapchains向量相同。对于image_indices变量的每个元素,分配来自相应交换链(在swapchains向量中的相同位置)的图像的索引。 -
创建一个名为
present_info的VkPresentInfoKHR类型的变量。为其成员分配以下值:-
VK_STRUCTURE_TYPE_PRESENT_INFO_KHR对应的sType值 -
pNext的nullptr值 -
rendering_semaphores向量中的元素数量用于waitSemaphoreCount -
指向
rendering_semaphores向量第一个元素的指针用于pWaitSemaphores -
swapchainCount的swapchains向量中的元素数量 -
pSwapchains的swapchains向量的第一个元素的指针 -
pImageIndices的image_indices向量的第一个元素的指针 -
pResults的nullptr值
-
-
调用
vkQueuePresentKHR(queue, &present_info)并提供我们想要提交此操作的队列的句柄,以及present_info变量的指针。 -
通过将返回值与
VK_SUCCESS进行比较,确保调用成功。
它是如何工作的...
展示操作将图像返回给展示引擎,展示引擎根据展示模式显示图像。我们可以同时展示多个图像,但只能从给定的 swapchain 中选择一个图像。要展示一个图像,我们需要提供其索引,该索引由vkGetSwapchainImagesKHR()函数返回的数组中(参考获取 swapchain 图像句柄配方):
VkPresentInfoKHR present_info = {
VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
nullptr,
static_cast<uint32_t>(rendering_semaphores.size()),
rendering_semaphores.size() > 0 ? &rendering_semaphores[0] : nullptr,
static_cast<uint32_t>(swapchains.size()),
swapchains.size() > 0 ? &swapchains[0] : nullptr,
swapchains.size() > 0 ? &image_indices[0] : nullptr,
nullptr
};
result = vkQueuePresentKHR( queue, &present_info );
switch( result ) {
case VK_SUCCESS:
return true;
default:
return false;
}
在前面的示例中,我们想要展示图像的 swapchain 的句柄和图像的索引被放置在swapchains和image_indices向量中。
在我们可以提交一个图像之前,我们需要将其布局更改为VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,否则展示引擎可能无法正确显示此类图像。
信号量用于通知硬件何时可以安全地显示图像。当我们提交一个渲染命令时,我们可以将一个信号量与这样的提交关联。然后,当命令完成时,这个信号量将改变其状态为已触发。我们应该创建一个信号量并将其与引用可展示图像的命令关联。这样,当我们展示一个图像并提供这样的信号量时,硬件将知道何时图像不再使用,并且展示它不会中断任何先前发出的操作。
参见
-
本章中的以下配方:
-
选择一个期望的展示模式
-
创建 swapchain
-
获取 swapchain 图像句柄
-
获取 swapchain 图像
-
-
在第三章,命令缓冲区和同步中查看以下配方:
-
创建一个信号量
-
创建一个栅栏
-
-
在第四章,资源和内存中查看以下配方:
- 设置图像内存屏障
销毁 swapchain
当我们完成使用 swapchain 时,因为我们不再想要展示图像,或者因为我们只是关闭我们的应用程序,我们应该销毁它。在销毁用于给定 swapchain 创建的展示表面之前,我们需要销毁它。
如何操作...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型的变量中。 -
获取需要销毁的 swapchain 对象的句柄。将其存储在名为
swapchain的VkSwapchainKHR类型的变量中。 -
调用
vkDestroySwapchainKHR(logical_device, swapchain, nullptr),并将logical_device变量作为第一个参数,swapchain 句柄作为第二个参数。将最后一个参数设置为nullptr。 -
为了安全起见,将
VK_NULL_HANDLE值分配给swapchain变量。
它是如何工作的...
要销毁 swapchain,我们可以准备类似于以下示例的代码:
if( swapchain ) {
vkDestroySwapchainKHR( logical_device, swapchain, nullptr );
swapchain = VK_NULL_HANDLE;
}
首先,我们检查是否真的创建了一个 swapchain(如果其句柄不为空)。接下来,我们调用vkDestroySwapchainKHR()函数,然后将VK_NULL_HANDLE值赋给swapchain变量以确保我们不会尝试删除同一个对象两次。
参见
- 本章中的创建 swapchain配方
销毁展示表面
展示表面代表我们应用程序的窗口。它在创建 swapchain 的过程中被使用,以及其他目的。这就是为什么在基于给定表面的 swapchain 被销毁完成后,我们应该销毁展示表面。
如何操作...
-
准备一个 Vulkan 实例的句柄,并将其存储在名为
instance的VkInstance类型变量中。 -
获取展示表面的句柄,并将其分配给名为
presentation_surface的VkSurfaceKHR类型变量。 -
调用
vkDestroySurfaceKHR(instance, presentation_surface, nullptr),并将instance和presentation_surface变量作为前两个参数,最后一个参数设置为nullptr。 -
为了安全起见,将
VK_NULL_HANDLE值分配给presentation_surface变量。
它是如何工作的...
展示表面的销毁与其他到目前为止展示的 Vulkan 资源的销毁非常相似。我们确保不提供VK_NULL_HANDLE值,并调用vkDestroySurfaceKHR()函数。之后,我们将VK_NULL_HANDLE值分配给presentation_surface变量:
if( presentation_surface ) {
vkDestroySurfaceKHR( instance, presentation_surface, nullptr );
presentation_surface = VK_NULL_HANDLE;
}
参见
- 本章中的创建展示表面配方
第三章:命令缓冲区和同步
在本章中,我们将涵盖以下内容:
-
创建命令池
-
分配命令缓冲区
-
开始命令缓冲区记录操作
-
结束命令缓冲区记录操作
-
重置命令缓冲区
-
重置命令池
-
创建信号量
-
创建栅栏
-
等待栅栏
-
重置栅栏
-
将命令缓冲区提交到队列
-
同步两个命令缓冲区
-
检查提交的命令缓冲区的处理是否完成
-
等待直到队列中提交的所有命令完成
-
等待所有提交的命令完成
-
销毁栅栏
-
销毁信号量
-
释放命令缓冲区
-
销毁命令池
简介
与 OpenGL 等高级 API 相比,Vulkan 等低级 API 给我们提供了对硬件的更多控制。这种控制不仅通过我们可以创建、管理和操作的资源来实现,而且特别通过与硬件的通信和交互来实现。Vulkan 给我们的控制是细粒度的,因为我们明确指定了哪些命令被发送到硬件,如何以及何时发送。为此,引入了命令缓冲区;这些是 Vulkan API 向开发者公开的最重要对象之一。它们允许我们记录操作并将它们提交给硬件,在那里它们被处理或执行。更重要的是,我们可以多线程记录它们,而在 OpenGL 等高级 API 中,不仅命令在单个线程中记录,而且它们由驱动程序隐式记录并发送到硬件,开发者没有任何控制。Vulkan 还允许我们重用现有的命令缓冲区,节省额外的处理时间。所有这些都给我们提供了更多的灵活性,但也带来了更多的责任。
由于这个原因,我们需要控制的不只是我们提交的操作,还有它们的时间。特别是当某些操作依赖于其他操作的结果时,我们需要格外小心,并适当地同步提交的命令。为此,引入了信号量和栅栏。
在本章中,我们将学习如何分配、记录和提交命令缓冲区,如何创建同步原语并使用它们来控制提交操作的执行,如何内部同步命令缓冲区,直接在 GPU 上,以及如何同步应用程序与硬件处理的工作。
创建命令池
命令池是从中获取命令缓冲区内存的对象。内存本身是隐式和动态分配的,但没有它,命令缓冲区就没有存储空间来保存记录的命令。这就是为什么,在我们能够分配命令缓冲区之前,我们首先需要为它们创建一个内存池。
如何做到这一点...
-
创建一个名为
logical_device的VkDevice类型的变量,并用已创建的逻辑设备的句柄初始化它。 -
取逻辑设备请求的队列家族之一的索引。将此索引存储在名为
queue_family的uint32_t类型的变量中。 -
创建一个名为
command_pool_create_info的VkCommandPoolCreateInfo类型的变量。为此变量的成员使用以下值:-
VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO值用于sType -
nullptr值用于pNext -
表示
VkCommandPoolCreateFlags类型选择参数的位字段,用于flags -
queue_family变量用于queueFamilyIndex
-
-
创建一个名为
command_pool的VkCommandPool类型的变量,其中将存储命令池的句柄。 -
使用
logical_device变量、指向command_pool_create_info变量的指针、nullptr值和指向command_pool变量的指针调用vkCreateCommandPool(logical_device, &command_pool_create_info, nullptr, &command_pool)。 -
确保调用返回了
VK_SUCCESS值。
它是如何工作的...
命令池主要用于作为命令缓冲区的内存来源,但这并不是它们被创建的唯一原因。它们通知驱动程序从它们分配的命令缓冲区的预期用途,以及我们是否必须批量重置或释放它们,或者是否可以单独对每个命令缓冲区进行操作。这些参数通过 VkCommandPoolCreateInfo 类型变量的 flags 成员(如下通过 parameters 变量表示)指定,如下所示:
VkCommandPoolCreateInfo command_pool_create_info = {
VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
nullptr,
parameters,
queue_family
};
当我们指定 VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 位时,这意味着从给定池分配的命令缓冲区将存活很短的时间,它们将被提交很少的次数,并将立即重置或释放。当我们使用 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 时,我们可以单独重置命令缓冲区。如果没有此标志,我们只能在组中这样做 - 在所有从给定池分配的命令缓冲区上。隐式记录命令缓冲区会重置它,因此如果没有此标志,我们只能记录命令缓冲区一次。如果我们想再次记录它,我们需要重置从它分配的整个池。
命令池还控制可以提交到哪些队列。这是通过队列家族索引实现的,我们必须在创建池时提供该索引(只有逻辑设备创建期间请求的家族才能提供)。从给定池分配的命令缓冲区只能提交到指定的家族的队列。
要创建一个池,我们需要准备以下代码:
VkResult result = vkCreateCommandPool( logical_device, &command_pool_create_info, nullptr, &command_pool );
if( VK_SUCCESS != result ) {
std::cout << "Could not create command pool." << std::endl;
return false;
}
return true;
命令池不能从多个线程同时访问(同一池的命令缓冲区不能同时在多个线程上记录)。这就是为什么每个将记录命令缓冲区的应用程序线程都应该使用单独的命令池。
现在,我们已经准备好分配命令缓冲区。
参见
本章中的以下配方:
-
分配命令缓冲区
-
重置命令缓冲区
-
重置命令池
-
销毁命令池
分配命令缓冲区
命令缓冲区用于存储(记录)稍后提交到队列中的命令,在那里它们由硬件执行和处理,以给我们结果。当我们创建了一个命令池,我们可以使用它来分配命令缓冲区。
如何做...
-
拿到一个已创建的逻辑设备的句柄,并将其存储在名为
logical_device的VkDevice类型的变量中。 -
拿到一个命令池的句柄,并使用它来初始化一个名为
command_pool的VkCommandPool类型的变量。 -
创建一个名为
command_buffer_allocate_info的VkCommandBufferAllocateInfo类型的变量,并为其成员使用以下值:-
VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO值用于sType -
nullptr值用于pNext -
command_pool变量用于commandPool -
VK_COMMAND_BUFFER_LEVEL_PRIMARY值或VK_COMMAND_BUFFER_LEVEL_SECONDARY值用于level -
我们想要为
commandBufferCount分配的命令缓冲区数量
-
-
创建一个名为
command_buffers的std::vector<VkCommandBuffer>类型的向量。将向量的大小调整为能够容纳我们想要创建的命令缓冲区数量。 -
调用
vkAllocateCommandBuffers( logical_device, &command_buffer_allocate_info, &command_buffers[0] ),为它提供一个逻辑设备的句柄,command_buffer_allocate_info变量的指针以及command_buffers向量第一个元素的指针。 -
成功时,通过调用返回的
VK_SUCCESS值指示,所有创建的命令缓冲区的句柄都将存储在command_buffers向量中。
它是如何工作的...
命令缓冲区是从命令池中分配的。这允许我们控制它们的一些属性在整个组中。首先,我们只能将命令缓冲区提交到在命令池创建期间选择的家族的队列。其次,由于命令池不能并发使用,我们应该为我们的应用程序中想要记录命令的每个线程创建单独的命令池,以最小化同步并提高性能。
但是,命令缓冲区也有它们各自的属性。其中一些在开始记录操作时指定,但在命令缓冲区分配期间我们需要选择一个非常重要的参数——我们是否想要分配主或次命令缓冲区:
-
主命令缓冲区可以直接提交到队列。它们也可以执行(调用)次级命令缓冲区。
-
次级命令缓冲区只能从主命令缓冲区执行,并且不允许提交它们。
这些参数通过类型为 VkCommandBufferAllocateInfo 的变量指定,如下所示:
VkCommandBufferAllocateInfo command_buffer_allocate_info = {
VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
nullptr,
command_pool,
level,
count
};
接下来,为了分配命令缓冲区,我们需要以下代码:
command_buffers.resize( count );
VkResult result = vkAllocateCommandBuffers( logical_device, &command_buffer_allocate_info, &command_buffers[0] );
if( VK_SUCCESS != result ) {
std::cout << "Could not allocate command buffers." << std::endl;
return false;
}
return true;
现在我们已经分配了命令缓冲区,我们可以在我们的应用程序中使用它们。为此,我们需要在一个或多个命令缓冲区中记录操作,然后将它们提交到队列。
参见
本章中的以下食谱:
-
创建命令池
-
开始命令缓冲区记录操作
-
结束命令缓冲区记录操作
-
将命令缓冲区提交到队列
-
释放命令缓冲区
开始命令缓冲区记录操作
当我们想要使用硬件执行操作时,我们需要记录它们并将它们提交到队列。命令被记录在命令缓冲区中。因此,当我们想要记录它们时,我们需要开始一个选定命令缓冲区的记录操作,实际上将其设置为记录状态。
如何操作...
-
获取应记录命令的命令缓冲区的句柄,并将其存储在名为
command_buffer的VkCommandBuffer类型变量中。确保命令缓冲区是从设置了VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志的池中分配的,或者它处于初始状态(它已被重置)。 -
创建一个名为
usage的VkCommandBufferUsageFlags类型的位字段变量,并根据满足的条件设置以下位:-
如果命令缓冲区只提交一次然后重置或重新记录,设置
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT位。 -
如果它是辅助命令缓冲区并且被认为是完全在渲染通道内,设置
VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT位。 -
如果命令缓冲区需要在设备上执行时重新提交到队列(在之前提交的此命令缓冲区结束之前),设置
VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT位。
-
-
创建一个名为
secondary_command_buffer_info的VkCommandBufferInheritanceInfo *类型变量。如果它是主命令缓冲区,则使用nullptr值初始化变量。如果是辅助命令缓冲区,则使用VkCommandBufferInheritanceInfo类型变量的地址初始化变量,其成员使用以下值初始化:-
VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO的sType值。 -
pNext的nullptr值。 -
对于
renderPass,使用兼容的渲染通道的句柄,其中命令缓冲区将被执行;如果命令缓冲区不会在渲染通道内执行,则此值被忽略(参考第六章中的创建渲染通道配方,渲染通道和帧缓冲区)。 -
在渲染通道中,命令缓冲区将要执行的子通道的索引,对于
subpass(如果命令缓冲区不会在渲染通道内执行,则此值被忽略)。 -
对于
framebuffer,使用一个可选的帧缓冲区句柄,该句柄将用于命令缓冲区渲染,或者如果不知道帧缓冲区或者命令缓冲区不会在渲染通道内执行,则使用VK_NULL_HANDLE值。 -
对于
occlusionQueryEnable成员,如果命令缓冲区可以在执行此二级命令缓冲区的主命令缓冲区中活动遮挡查询时执行,则使用VK_TRUE值。否则,使用VK_FALSE值来表示命令缓冲区不能与启用的遮挡查询一起执行。 -
一组可以由活动遮挡查询用于
queryFlags的标志。 -
一组可以通过活动查询
pipelineStatistics计数的统计信息。
-
-
创建一个名为
command_buffer_begin_info的VkCommandBufferBeginInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO的sType值。 -
nullptr值用于pNext。 -
usage变量用于flags。 -
secondary_command_buffer_info变量用于pInheritanceInfo。
-
-
调用
vkBeginCommandBuffer(command_buffer, &command_buffer_begin_info)并在第一个参数中提供命令缓冲区的句柄,在第二个参数中提供一个指向command_buffer_begin_info变量的指针。 -
通过检查调用返回的值是否等于
VK_SUCCESS来确保调用成功。
它是如何工作的...
记录命令缓冲区是我们可以在 Vulkan 中执行的最重要操作。这是唯一告诉硬件它应该做什么以及如何做的途径。当我们开始记录命令缓冲区时,它们的状态是未定义的。一般来说,命令缓冲区不会继承任何状态(与 OpenGL 相反,OpenGL 会保持当前状态)。因此,当我们记录操作时,我们还需要记得设置与这些操作相关的状态。这样一个状态的例子是一个绘图命令,它使用顶点属性和索引。在我们记录绘图操作之前,我们需要绑定带有顶点数据和顶点索引的适当缓冲区。
主命令缓冲区可以调用(执行)在二级命令缓冲区中记录的命令。已执行的二级命令缓冲区不会从执行它们的 主命令缓冲区继承状态。更重要的是,二级命令缓冲区记录后,主命令缓冲区的状态也是未定义的(当我们记录主命令缓冲区,并在其中执行二级命令缓冲区,我们想要继续记录主命令缓冲区,我们需要再次设置其状态)。状态继承规则只有一个例外 - 当主命令缓冲区在渲染通道内,并且我们从其中执行二级命令缓冲区时,主命令缓冲区的渲染通道和子通道状态被保留。
在我们开始记录操作之前,我们需要准备一个 VkCommandBufferBeginInfo 类型的变量,通过它我们提供记录参数:
VkCommandBufferBeginInfo command_buffer_begin_info = {
VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
nullptr,
usage,
secondary_command_buffer_info
};
为了性能原因,我们应该避免记录带有 VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT 标志的命令缓冲区。
接下来,我们可以开始记录操作:
VkResult result = vkBeginCommandBuffer( command_buffer, &command_buffer_begin_info );
if( VK_SUCCESS != result ) {
std::cout << "Could not begin command buffer recording operation." << std::endl;
return false;
}
return true;
从现在起,我们可以将选定的操作记录到命令缓冲区中。但我们如何知道哪些命令可以记录到命令缓冲区中?所有这些函数的名称都以vkCmd前缀开头,并且它们的第一参数始终是命令缓冲区(VkCommandBuffer类型的变量)。但是,我们需要记住,并非所有命令都可以记录到主命令缓冲区和次级命令缓冲区中。
相关内容
本章中的以下配方:
-
分配命令缓冲区
-
结束命令缓冲区记录操作
-
重置命令缓冲区
-
重置命令池
-
将命令缓冲区提交到队列
结束命令缓冲区记录操作
当我们不想在命令缓冲区中记录更多命令时,我们需要停止记录。
如何操作...
-
获取处于记录状态(记录操作已开始)的命令缓冲区的句柄。将句柄存储在名为
command_buffer的VkCommandBuffer类型变量中。 -
调用
vkEndCommandBuffer(command_buffer)并提供command_buffer变量。 -
通过检查调用是否返回
VK_SUCCESS值来确保记录操作成功。
它是如何工作的...
命令在vkBeginCommandBuffer()和vkEndCommandBuffer()函数调用之间记录到命令缓冲区中。在停止记录之前,我们不能提交命令缓冲区。换句话说,当我们完成命令缓冲区的记录后,它处于可执行状态,可以被提交。
为了使记录操作尽可能快,并尽可能减少对性能的影响,记录的命令不报告任何错误。如果发生任何问题,它们将由vkEndCommandBuffer()函数报告。
因此,当我们停止记录命令缓冲区时,我们应该确保记录操作成功。我们可以这样做:
VkResult result = vkEndCommandBuffer( command_buffer );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred during command buffer recording." << std::endl;
return false;
}
return true;
如果在记录操作过程中出现错误(vkEndCommandBuffer()函数返回的值不等于VK_SUCCESS),我们无法提交这样的命令缓冲区,我们需要重置它。
相关内容
本章中的以下配方:
-
开始命令缓冲区记录操作
-
将命令缓冲区提交到队列
-
重置命令缓冲区
重置命令缓冲区
当命令缓冲区之前已被记录,或者记录操作过程中出现错误时,在可以再次重录之前,必须重置命令缓冲区。我们可以通过开始另一个记录操作隐式地做到这一点。但,我们也可以显式地做到这一点。
如何操作...
-
从使用带有
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志创建的池中分配命令缓冲区的句柄。将句柄存储在名为command_buffer的VkCommandBuffer类型变量中。 -
创建一个名为
release_resources的VkCommandBufferResetFlags类型的变量。如果想要释放缓冲区分配的内存并将其返回到池中,则在变量中存储VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT的值。否则,在变量中存储0值。 -
调用
vkResetCommandBuffer( command_buffer, release_resources )并在第一个参数中提供命令缓冲区的句柄,在第二个参数中提供release_resources变量。 -
通过检查返回值是否等于
VK_SUCCESS来确保调用成功。
它是如何工作的...
命令缓冲区可以通过重置整个命令池或单独重置来批量重置。只有当创建命令池时带有 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志时,才能执行单独的重置。
重置命令缓冲区是在开始记录时隐式执行的,或者通过调用 vkResetCommandBuffer() 函数显式执行。显式重置使我们能够控制命令缓冲区从其池中分配的内存。在显式重置期间,我们可以决定是否要将内存返回到池中,或者命令缓冲区是否应该保留它并在下一次命令记录期间重用它。
单个命令缓冲区的重置是显式执行的,如下所示:
VkResult result = vkResetCommandBuffer( command_buffer, release_resources ? VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT : 0 );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred during command buffer reset." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
创建命令池
-
开始命令缓冲区记录操作
-
重置命令池
重置命令池
当我们不希望单独重置命令缓冲区,或者如果我们没有带有 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志创建池时,我们可以一次性重置从给定池分配的所有命令缓冲区。
如何做到这一点...
-
获取逻辑设备的句柄并将其存储在一个名为
logical_device的VkDevice类型的变量中。 -
获取已创建的命令池的句柄。使用它来初始化一个名为
command_pool的VkCommandPool类型的变量。 -
创建一个名为
release_resources的VkCommandPoolResetFlags类型的变量,并将其初始化为VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT,如果应该释放由命令池分配的所有命令缓冲区保留的内存并将其返回到池中,否则初始化为0值。 -
调用
vkResetCommandPool( logical_device, command_pool, release_resources )并提供logical_device、command_pool和release_resources变量。 -
确保调用返回了
VK_SUCCESS值,这表示操作成功。
它是如何工作的...
重置命令池会导致从它分配的所有命令缓冲区返回到其初始状态,就像它们从未被记录过一样。这与分别重置所有命令缓冲区类似,但更快,我们不需要创建一个带有 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标志的命令池。
当记录命令缓冲区时,它们从池中获取它们的内存。这是自动完成的,不受我们的控制。当我们重置命令池时,我们可以选择命令缓冲区是否应该保留它们的内存以供以后使用,或者是否应该将其返回到池中。
要一次性重置从指定池分配的所有命令缓冲区,我们需要以下代码:
VkResult result = vkResetCommandPool( logical_device, command_pool, release_resources ? VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT : 0 );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred during command pool reset." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
创建命令池
-
分配命令缓冲区
-
重置命令缓冲区
创建信号量
在我们能够提交命令并利用设备的处理能力之前,我们需要知道如何同步操作。信号量是用于同步的原始数据之一。它们允许我们协调提交到队列的操作,不仅限于一个队列内部,还可以在不同的逻辑设备队列之间。
当我们向队列提交命令时使用信号量。因此,在我们能够在提交命令缓冲区时使用它们之前,我们需要创建它们。
如何操作...
-
拿到一个已创建的逻辑设备的句柄。将句柄存储在一个名为
logical_device的VkDevice类型的变量中。 -
创建一个名为
semaphore_create_info的VkSemaphoreCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO的sType值 -
pNext的nullptr值 -
flags的0值
-
-
创建一个名为
semaphore的VkSemaphore类型的变量。在这个变量中,将存储创建的信号量的句柄。 -
调用以下函数:
vkCreateSemaphore( logical_device, &semaphore_create_info, nullptr, &semaphore )。为此调用使用logical_device变量,semaphore_create_info变量的指针,一个nullptr值和一个semaphore变量的指针。 -
通过检查返回值是否等于
VK_SUCCESS来确保信号量创建成功。
它是如何工作的...
作为同步原语,信号量只有两种不同的状态:已信号量或未信号量。信号量在命令缓冲区提交期间使用。当我们将它们提供给要信号量的信号量列表时,一旦给定批次中提交的所有工作完成,它们的状态就会变为已信号量。以类似的方式,当我们向队列提交命令时,我们可以指定提交的命令应等待直到指定列表中的所有信号量都变为已信号量。这样,我们可以协调提交到队列的工作,并推迟依赖于其他命令结果的命令的处理。
当信号量被信号量并所有等待它们的命令恢复时,信号量会自动重置(它们的状态变为未信号量)并可以再次使用。
当我们从交换链获取图像时,也会使用信号量。在这种情况下,在提交引用已获取图像的命令时,必须使用此类信号量。这些命令应等待直到交换链图像不再被呈现引擎使用,这由信号量标记操作指示。这在下图中显示:

信号量是通过 vkCreateSemaphore() 函数调用创建的。创建过程中所需的参数通过 VkSemaphoreCreateInfo 类型的变量提供,如下所示:
VkSemaphoreCreateInfo semaphore_create_info = {
VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,
nullptr,
0
};
要创建一个信号量,我们需要准备一段类似于以下的代码:
VkResult result = vkCreateSemaphore( logical_device, &semaphore_create_info, nullptr, &semaphore );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a semaphore." << std::endl;
return false;
}
return true;
信号量只能用于同步提交到队列的工作,因为它们在内部协调图形硬件。应用程序无法访问信号量的状态。如果应用程序应该与提交的命令同步,则需要使用栅栏。
参见
在 第二章 的 图像呈现 中,查看以下食谱:
-
获取交换链图像
-
呈现图像
本章中的以下食谱:
-
创建栅栏
-
将命令缓冲区提交到队列
-
同步两个命令缓冲区
-
销毁信号量
创建一个栅栏
与信号量相反,栅栏用于同步应用程序与提交到图形硬件的命令。它们通知应用程序已提交的工作处理已完成。但在我们可以使用栅栏之前,我们需要创建它们。
如何做到这一点...
-
使用创建的逻辑设备并使用其句柄初始化一个名为
logical_device的VkDevice类型的变量。 -
创建一个名为
fence_create_info的VkFenceCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_FENCE_CREATE_INFO的sType值 -
pNext的值为nullptr。 -
对于
flags,如果创建的栅栏应该是未标记的,则使用0值;如果创建的栅栏应该是标记的,则使用VK_FENCE_CREATE_SIGNALED_BIT值。
-
-
创建一个名为
fence的VkFence类型的变量,它将保存创建的栅栏的句柄。 -
调用
vkCreateFence(logical_device, &fence_create_info, nullptr, &fence)并提供logical_device变量,fence_create_info变量的指针,一个nullptr值和fence变量的指针。 -
通过将返回值与
VK_SUCCESS枚举值进行比较,确保调用成功。
它是如何工作的...
栅栏,与其他同步原语类似,只有两种状态:已标记和未标记。它们可以在这两种状态中的任何一种创建,但它们的状态由应用程序重置--这将从已标记状态变为未标记状态。
为了标记一个栅栏,我们需在命令缓冲区提交时提供它。这样的栅栏,类似于信号量,一旦与栅栏一起提交的所有工作完成,就会立即被标记。但是,栅栏不能用来同步命令缓冲区。应用程序可以查询栅栏的状态,并且可以在栅栏被标记之前等待。
信号量用于同步提交的命令缓冲区之间。栅栏用于同步应用程序与提交的命令。
要创建一个栅栏,我们需要准备一个名为VkFenceCreateInfo的变量,在其中我们必须选择是否希望创建的栅栏已经处于标记状态,或者它应该是未标记的:
VkFenceCreateInfo fence_create_info = {
VK_STRUCTURE_TYPE_FENCE_CREATE_INFO,
nullptr,
signaled ? VK_FENCE_CREATE_SIGNALED_BIT : 0
};
接下来,此结构被提供给vkCreateFence()函数,该函数使用指定的参数创建一个栅栏:
VkResult result = vkCreateFence( logical_device, &fence_create_info, nullptr, &fence );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a fence." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
创建一个信号量
-
等待栅栏
-
重置栅栏
-
将命令缓冲区提交到队列
-
检查提交的命令缓冲区的处理是否完成
-
销毁一个栅栏
等待栅栏
当我们想知道提交的命令处理何时完成时,我们需要使用一个栅栏,并在命令缓冲区提交时提供它。然后,应用程序可以检查栅栏的状态,并等待它被标记。
如何做到这一点...
-
取已创建的逻辑设备,并使用其句柄初始化一个名为
logical_device的VkDevice类型的变量。 -
创建一个应用程序应该等待的栅栏列表。将所有栅栏的句柄存储在一个名为
std::vector<VkFence>的变量fences中。 -
创建一个名为
wait_for_all的VkBool32类型的变量。如果应用程序应该等待直到所有指定的栅栏都被标记,则用VK_TRUE的值初始化它。如果应用程序应该等待直到任何栅栏被标记(至少有一个),则用VK_FALSE的值初始化该变量。 -
创建一个名为
timeout的uint64_t类型的变量。用表示应用程序应该等待多长时间(以纳秒为单位)的值初始化该变量。 -
调用
vkWaitForFences(logical_device, static_cast<uint32_t>(fences.size()), &fences[0], wait_for_all, timeout)。提供逻辑设备的句柄、fences向量的元素数量、fences变量的第一个元素的指针、wait_for_all和timeout变量。 -
检查调用返回的值。如果它等于
VK_SUCCESS,则表示条件得到满足——一个或所有栅栏(取决于wait_for_all变量的值)在指定时间内被标记。如果条件未满足,则返回VK_TIMEOUT。
它是如何工作的...
vkWaitForFences() 函数会阻塞应用程序一段时间,或者直到提供的栅栏变为已发出信号。这样,我们可以将应用程序与提交到设备队列的工作同步。这也是我们了解提交的命令处理何时完成的方法。
在调用过程中,我们可以提供多个栅栏,而不仅仅是其中一个。我们还可以等待直到所有栅栏都变为已发出信号,或者任何一个都行。如果在指定的时间内条件未满足,函数将返回 VK_TIMEOUT 值。否则,它返回 VK_SUCCESS。
我们还可以通过简单地提供其句柄并指定超时值为 0 来检查栅栏的状态。这样,vkWaitForFences() 函数不会阻塞,并立即返回表示提供的栅栏当前状态的值 - 如果栅栏未发出信号(尽管没有进行真正的等待),则返回 VK_TIMEOUT 值;如果栅栏已经发出信号,则返回 VK_SUCCESS 值。
导致应用程序等待的代码可能看起来像这样:
if( fences.size() > 0 ) {
VkResult result = vkWaitForFences( logical_device, static_cast<uint32_t>(fences.size()), &fences[0], wait_for_all, timeout );
if( VK_SUCCESS != result ) {
std::cout << "Waiting on fence failed." << std::endl;
return false;
}
return true;
}
return false;
相关内容
本章以下菜谱:
-
创建一个栅栏
-
重置栅栏
-
将命令缓冲区提交到队列
-
检查提交的命令缓冲区处理是否完成
重置栅栏
信号量会自动重置。但是,当栅栏变为已发出信号时,将栅栏重置回未发出信号状态是应用程序的责任。
如何操作...
-
将创建的逻辑设备句柄存储在名为
logical_device的VkDevice类型变量中。 -
创建一个名为
fences的向量变量。它应包含VkFence类型的元素。在变量中存储所有应重置的栅栏的句柄。 -
调用
vkResetFences( logical_device, static_cast<uint32_t>(fences.size()), &fences[0] )并提供logical_device变量、fences向量中的元素数量以及fences向量第一个元素指针。 -
通过检查调用返回的值是否等于
VK_SUCCESS来确保函数调用成功。
它是如何工作的...
当我们想知道提交的命令何时完成时,我们使用栅栏。但我们不能提供一个已经发出信号的栅栏。我们必须首先重置它,这意味着我们将它的状态从已发出信号变为未发出信号。栅栏是由应用程序显式重置的,而不是像信号量那样自动。重置栅栏的操作如下:
if( fences.size() > 0 ) {
VkResult result = vkResetFences( logical_device, static_cast<uint32_t>(fences.size()), &fences[0] );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred when tried to reset fences." << std::endl;
return false;
}
return VK_SUCCESS == result;
}
return false;
相关内容
本章以下菜谱:
-
创建一个栅栏
-
等待栅栏
-
将命令缓冲区提交到队列
-
检查提交的命令缓冲区处理是否完成
将命令缓冲区提交到队列
我们已经记录了命令缓冲区,并希望利用图形硬件处理准备好的操作。接下来该做什么?我们需要将准备好的工作提交到选定的队列中。
准备工作
在这个菜谱中,我们将使用自定义的 WaitSemaphoreInfo 类型的变量。它定义如下:
struct WaitSemaphoreInfo {
VkSemaphore Semaphore;
VkPipelineStageFlags WaitingStage;
};
通过它,我们提供一个句柄,硬件在处理给定的 command buffer 之前应等待该句柄,并且我们还指定了等待应发生的管道阶段。
如何做到这一点...
-
获取应提交工作的工作队列的句柄。使用该句柄初始化一个名为
queue的VkQueue类型的变量。 -
创建一个名为
wait_semaphore_handles的std::vector<VkSemaphore>类型的变量。如果提交的命令应该等待其他命令结束,则在变量中存储所有给定队列在处理提交的命令缓冲区之前应等待的句柄。 -
创建一个名为
wait_semaphore_stages的std::vector<VkPipelineStageFlags>类型的变量。如果提交的命令应该等待其他命令结束,则使用wait_semaphore_handles变量中的管道阶段初始化该向量,以便队列在等待相应的信号时等待。 -
准备一个名为
command_buffers的std::vector<VkCommandBuffer>类型的变量。存储所有应提交到所选队列的已记录命令缓冲区的句柄。确保这些命令缓冲区中没有任何一个正在由设备处理,或者使用VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT标志记录。 -
创建一个名为
signal_semaphores的std::vector<VkSemaphore>类型的变量。在此向量中,存储所有在command_buffers变量中提交的所有命令缓冲区的处理完成后应发出信号的句柄。 -
创建一个名为
fence的VkFence类型的变量。如果应在command_buffers变量中提交的所有命令缓冲区的处理完成后发出信号,则在该变量中存储此围栏的句柄。否则,使用VK_NULL_HANDLE值初始化此变量。 -
创建一个名为
submit_info的VkSubmitInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_SUBMIT_INFO值用于sType -
nullptr值用于pNext -
wait_semaphore_handles向量中waitSemaphoreCount的元素数量 -
指向
wait_semaphore_handles向量第一个元素的指针或一个nullptr值,如果该向量为空,用于pWaitSemaphores -
指向
wait_semaphore_stages向量第一个元素的指针或一个nullptr值,如果该向量为空,用于pWaitDstStageMask -
提交的命令缓冲区数量(
command_buffers向量中的元素数量)用于commandBufferCount -
指向
command_buffers向量第一个元素的指针或一个nullptr值,如果该向量为空,用于pCommandBuffers -
signal_semaphores向量中signalSemaphoreCount的元素数量 -
指向
signal_semaphores向量第一个元素的指针或一个nullptr值,如果该向量为空,用于pSignalSemaphores
-
-
调用
vkQueueSubmit(queue, 1, &submit_info, fence)并提供应该提交工作的队列句柄,一个1值,submit_info变量的指针,以及fence变量。 -
通过检查它是否返回了
VK_SUCCESS值来确保调用成功。
它是如何工作的...
当我们将命令缓冲区提交到设备的队列时,它们将在之前提交到同一队列的命令处理完成后立即执行。从应用程序的角度来看,我们不知道命令将在何时执行。它可能立即开始,也可能在一段时间后。
当我们想要推迟提交命令的处理时,我们需要通过提供一个信号量列表来同步它们,在提交的命令缓冲区执行之前,给定的队列应该在列表上的信号量上等待。
当我们提交命令缓冲区并提供一个信号量列表时,每个信号量都与一个管线阶段相关联。命令将执行,直到它们达到指定的管线阶段,在那里它们将被暂停并等待信号量被触发。
在提交过程中,信号量和管线阶段位于不同的数组中。因此,我们需要将包含自定义类型 WaitSemaphoreInfo 元素的向量拆分为两个单独的向量:
std::vector<VkSemaphore> wait_semaphore_handles;
std::vector<VkPipelineStageFlags> wait_semaphore_stages;
for( auto & wait_semaphore_info : wait_semaphore_infos ) {
wait_semaphore_handles.emplace_back( wait_semaphore_info.Semaphore );
wait_semaphore_stages.emplace_back( wait_semaphore_info.WaitingStage );
}
现在,我们已经准备好进行常规提交。对于提交,指定命令缓冲区应该等待的信号量、执行等待的管线阶段、命令缓冲区以及应该被触发的另一个信号量列表,都是通过类型为 VkSubmitInfo 的变量来指定的:
VkSubmitInfo submit_info = {
VK_STRUCTURE_TYPE_SUBMIT_INFO,
nullptr,
static_cast<uint32_t>(wait_semaphore_infos.size()),
wait_semaphore_handles.size() > 0 ? &wait_semaphore_handles[0] : nullptr,
wait_semaphore_stages.size() > 0 ? &wait_semaphore_stages[0] : nullptr,
static_cast<uint32_t>(command_buffers.size()),
command_buffers.size() > 0 ? &command_buffers[0] : nullptr,
static_cast<uint32_t>(signal_semaphores.size()),
signal_semaphores.size() > 0 ? &signal_semaphores[0] : nullptr
};
这批数据随后以这种方式提交:
VkResult result = vkQueueSubmit( queue, 1, &submit_info, fence );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred during command buffer submission." << std::endl;
return false;
}
return true;
当我们提交命令缓冲区时,设备将执行记录的命令并产生预期的结果,例如,它将在屏幕上绘制一个 3D 场景。
在这里,我们只提交了一批命令缓冲区,但可能提交多个批次。
为了性能原因,我们应该尽可能少地使用函数调用提交尽可能多的批次。
如果命令缓冲区已经被提交且其执行尚未结束,我们不应该提交命令缓冲区。我们只能在命令缓冲区使用 VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT 标志记录时这样做,但出于性能原因,我们应该避免使用此标志。
参见
本章中接下来的菜谱:
-
开始命令缓冲区记录操作
-
结束命令缓冲区记录操作
-
创建一个信号量
-
创建一个栅栏
同步两个命令缓冲区
我们知道如何准备工作和将其提交到队列中。我们也知道如何创建信号量。在这个示例菜谱中,我们将看到如何使用信号量来同步两个命令缓冲区。更具体地说,我们将学习如何推迟处理一个命令缓冲区,直到另一个命令缓冲区的处理完成。
准备就绪
在这个菜谱中,我们将使用在 将命令缓冲区提交到队列 菜谱中引入的 WaitSemaphoreInfo 结构。为了参考,以下是它的定义:
struct WaitSemaphoreInfo {
VkSemaphore Semaphore;
VkPipelineStageFlags WaitingStage;
};
如何做到这一点...
-
获取将第一批次命令缓冲区提交到的队列的句柄。将此句柄存储在名为
first_queue的VkQueue类型的变量中。 -
创建在第一批次命令缓冲区的处理完成后应该被触发的信号量(参考创建信号量配方)。将这些信号量存储在名为
synchronizing_semaphores的std::vector<WaitSemaphoreInfo>类型的变量中。准备一个列表,指定第二批次命令缓冲区在每个信号量处应该等待的阶段。将这些阶段包含在synchronizing_semaphores向量中。 -
准备第一批次命令缓冲区并将它们提交到由
first_queue变量表示的队列中。在要触发的信号量列表中包含来自synchronizing_semaphores向量的信号量(参见将命令缓冲区提交到队列配方)。 -
获取将第二批次命令缓冲区提交到的队列的句柄。将此句柄存储在名为
second_queue的VkQueue类型的变量中。 -
准备第二批次命令缓冲区并将它们提交到由
second_queue变量表示的队列中。在等待的信号量和阶段列表中包含来自synchronizing_semaphores向量的信号量和阶段(参见将命令缓冲区提交到队列配方)。
它是如何工作的...
在这个配方中,我们提交了两批命令缓冲区。当第一批次由硬件处理并完成时,它将向要触发的信号量列表中的所有信号量发出信号。我们只取信号量的句柄,因为在信号量的信号过程中不需要管道阶段:
std::vector<VkSemaphore> first_signal_semaphores;
for( auto & semaphore_info : synchronizing_semaphores ) {
first_signal_semaphores.emplace_back( semaphore_info.Semaphore );
}
if( !SubmitCommandBuffersToQueue( first_queue, first_wait_semaphore_infos, first_command_buffers, first_signal_semaphores, VK_NULL_HANDLE ) ) {
return false;
}
接下来,我们使用这些相同的信号量,在提交第二批次命令缓冲区时使用它们。这次,我们使用两个句柄和管道阶段。第二批次将等待在指定的管道阶段提供的所有信号量。这意味着提交的命令缓冲区的某些部分可能开始被处理,但当它们达到提供的阶段时,处理将暂停,如下面的图所示:

if( !SubmitCommandBuffersToQueue( second_queue, synchronizing_semaphores, second_command_buffers, second_signal_semaphores, second_fence ) ) {
return false;
}
return true;
这展示了如何同步来自同一逻辑设备的不同队列提交的多个命令缓冲区的工作。第二次提交的命令缓冲区的处理将被推迟,直到第一批次的所有命令都完成。
参见
本章中的以下配方:
-
创建信号量
-
将命令缓冲区提交到队列
检查提交的命令缓冲区的处理是否完成
当我们使用信号量时,应用程序不参与同步命令缓冲区的过程。它不知道提交的命令的处理何时完成以及何时开始处理其他命令。所有这些都发生在“阶段之后”,对应用程序来说是透明的。
但是,当我们想知道给定命令缓冲区的处理何时结束时,我们需要使用栅栏。这样,我们可以检查提交的命令缓冲区何时被设备完全处理。
如何实现...
-
创建一个未触发的栅栏并将其存储在名为
fence的VkFence类型变量中。 -
准备一批命令缓冲区、等待提交的信号量以及提交完成后发出信号的信号量。在将命令缓冲区提交到所选队列时使用准备好的数据。在提交期间使用
fence变量(参考将命令缓冲区提交到队列配方)。 -
通过提供逻辑设备的句柄(所有利用的资源都由此设备创建)、
fence变量、定义是否等待所有提供的栅栏的VK_FALSE值以及选定的超时值来等待创建的栅栏对象(参考等待栅栏配方)。 -
当等待完成并且返回
VK_SUCCESS值时,这意味着在fence变量所在的批次中提交到队列的所有命令缓冲区的处理已成功完成。
它是如何工作的...
将应用程序与提交的命令缓冲区同步分为两步进行。首先我们创建一个栅栏,准备命令缓冲区并将它们提交到队列。我们需要记住在同一个提交中提供创建的栅栏:
if( !SubmitCommandBuffersToQueue( queue, wait_semaphore_infos, command_buffers, signal_semaphores, fence ) ) {
return false;
}
然后,我们只需要在我们的应用程序中等待,直到栅栏被触发。
return WaitForFences( logical_device, { fence }, VK_FALSE, timeout );
这样,我们可以确保提交的命令缓冲区已被设备成功处理。
但是,典型的渲染场景不应该导致我们的应用程序完全暂停,因为这只是浪费时间。我们应该检查栅栏是否被触发。如果没有,我们应该把剩余的时间花在其他任务上,例如提高人工智能或更精确地计算物理,并定期检查栅栏的状态。当栅栏被触发时,我们然后执行依赖于已提交命令的任务。
当我们想要重用命令缓冲区时,也可以使用栅栏。在我们能够重新记录它们之前,我们必须确保它们不再被设备执行。我们应该记录并提交一系列命令缓冲区。只有当我们使用完所有这些缓冲区后,我们才开始等待栅栏(每个提交的批次都应该有一个相关的栅栏)。我们拥有的独立的命令缓冲区批次越多,我们在等待栅栏上花费的时间就越少(参考第九章,命令记录和绘制中的通过增加单独渲染帧的数量来提高性能配方)。
参见
本章中的以下配方:
-
创建栅栏
-
等待栅栏
-
重置栅栏
-
将命令缓冲区提交到队列
等待直到队列中提交的所有命令完成
当我们想要将应用程序与应用程序提交给选定队列的工作同步时,我们并不总是必须使用栅栏。应用程序等待直到选定队列提交的所有任务完成是可能的。
如何做到这一点...
-
拿到提交任务的队列句柄。将其存储在一个名为
queue的VkQueue类型的变量中。 -
调用
vkQueueWaitIdle( queue )并提供queue变量。 -
我们可以通过检查调用返回的值是否等于
VK_SUCCESS来确保没有发生错误。
它是如何工作的...
vkQueueWaitIdle() 函数使应用程序暂停,直到提交给指定队列的所有工作(所有命令缓冲区的处理)完成。这样,我们就不需要创建栅栏。
但这种同步操作只应在非常罕见的情况下进行。图形硬件(GPU)通常比通用处理器(CPU)快得多,可能需要不断提交工作以使应用程序充分利用其性能。
在应用程序端执行等待可能会在图形硬件的管道中引入停滞,这会导致设备效率低下地被利用。
要等待队列直到它完成所有提交的工作,我们需要准备以下代码:
VkResult result = vkQueueWaitIdle( queue );
if( VK_SUCCESS != result ) {
std::cout << "Waiting for all operations submitted to queue failed." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
等待栅栏
-
将命令缓冲区提交到队列
-
等待所有命令完成
等待所有提交的命令完成
有时我们希望等待直到提交给所有逻辑设备队列的所有工作完成。这种等待通常在我们关闭应用程序并希望销毁所有创建或分配的资源之前进行。
如何做到这一点...
-
拿到一个创建的逻辑设备的句柄,并将其存储在一个名为
logical_device的VkDevice类型的变量中。 -
进行以下调用:
vkDeviceWaitIdle( logical_device ),为它提供逻辑设备的句柄。 -
您可以通过将返回值与
VK_SUCCESS进行比较来检查是否没有错误。
它是如何工作的...
vkDeviceWaitIdle() 函数使我们的应用程序等待,直到逻辑设备不再忙碌。这类似于等待请求给定设备的所有队列--直到提交给所有队列的命令完成。
通常在退出我们的应用程序之前调用上述函数。当我们想要销毁资源时,我们必须确保它们不再被逻辑设备使用。此函数保证我们可以安全地进行此类销毁。
等待提交给设备的所有命令是这样进行的:
VkResult result = vkDeviceWaitIdle( logical_device );
if( VK_SUCCESS != result ) {
std::cout << "Waiting on a device failed." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
等待栅栏
-
等待直到提交给队列的所有命令完成
销毁一个栅栏
栅栏可以被多次重用。但当我们不再需要它们时,通常是在我们关闭应用程序之前,我们应该销毁它们。
如何做到这一点...
-
拿起逻辑设备的把手并将其存储在一个名为
logical_device的VkDevice类型的变量中。 -
拿起应该被摧毁的栅栏把手。使用把手初始化一个名为
fence的VkFence类型的变量。 -
调用
vkDestroyFence( logical_device, fence, nullptr )并提供逻辑设备的把手、fence变量和一个nullptr值。 -
出于安全原因,将
VK_NULL_HANDLE值分配给fence变量。
它是如何工作的...
使用 vkDestroyFence() 函数摧毁栅栏,如下所示:
if( VK_NULL_HANDLE != fence ) {
vkDestroyFence( logical_device, fence, nullptr );
fence = VK_NULL_HANDLE;
}
我们不需要检查 fence 变量的值是否不等于 VK_NULL_HANDLE 值,因为驱动程序会忽略空把手的摧毁。但是,我们这样做是为了跳过一个不必要的函数调用。
但是,我们不能摧毁一个无效的对象——一个没有在给定的逻辑设备上创建或已经被摧毁的对象。这就是为什么我们将 VK_NULL_HANDLE 值分配给带有栅栏把手的变量。
参见
- 在本章中,菜谱:创建一个栅栏。
摧毁一个信号量
信号量可以被多次重用,所以通常在应用程序执行时我们不需要删除它们。但是,当我们不再需要信号量,并且我们确信它没有被设备使用(既没有挂起的等待,也没有挂起的信号操作),我们可以摧毁它。
如何做...
-
拿起逻辑设备的把手。将这个把手存储在一个名为
logical_device的VkDevice类型的变量中。 -
使用要被摧毁的信号量的把手初始化一个名为
semaphore的VkSemaphore类型的变量。确保它没有被任何提交引用。 -
进行以下调用:
vkDestroySemaphore( logical_device, semaphore, nullptr ),为它提供逻辑设备的把手、信号量的把手和一个nullptr值。 -
出于安全原因,将
VK_NULL_HANDLE值分配给semaphore变量。
它是如何工作的...
删除一个信号量相当简单:
if( VK_NULL_HANDLE != semaphore ) {
vkDestroySemaphore( logical_device, semaphore, nullptr );
semaphore = VK_NULL_HANDLE;
}
在我们能够摧毁一个信号量之前,我们必须确保它不再被任何执行的队列提交所引用。
如果我们执行了一个提交,并在要触发的信号量列表中提供了信号量,或者在给定提交应等待的信号量列表中提供了信号量,我们必须确保提交的命令已经完成。为此,我们需要使用应用程序应该等待的栅栏,或者使用等待将所有操作提交到给定队列或整个设备完成的函数之一(参考 等待栅栏、等待直到队列中所有命令完成 和 等待所有提交的命令完成 菜谱)。
参见
本章中的以下菜谱:
-
创建一个信号量
-
等待栅栏完成
-
等待直到队列中所有命令完成
-
等待所有提交的命令完成
释放命令缓冲区
当命令缓冲区不再需要,并且它们在设备上没有挂起执行时,它们可以被释放。
如何做到这一点...
-
获取逻辑设备的句柄并使用它来初始化一个名为
logical_device的VkDevice类型变量。 -
获取从逻辑设备创建的命令池的句柄,并将其存储在名为
command_pool的VkCommandPool类型变量中。 -
创建一个类型为
VkCommandBuffer的向量变量,命名为command_buffers。将向量的大小调整为能够容纳所有应该被释放的命令缓冲区。使用应该被释放的所有命令缓冲区的句柄初始化向量元素。 -
调用
vkFreeCommandBuffers( logical_device, command_pool, static_cast<uint32_t>(command_buffers.size()), &command_buffers[0] )。在调用过程中,提供逻辑设备的句柄和命令池,提供command_buffers向量中的元素数量(要释放的命令缓冲区数量)以及command_buffers向量第一个元素的指针。 -
为了安全起见,清除
command_buffers向量。
它是如何工作的...
命令缓冲区可以成组释放,但在单个 vkFreeCommandBuffers() 函数调用中,我们只能释放来自同一命令池的命令缓冲区。我们可以一次性释放任意数量的命令缓冲区:
if( command_buffers.size() > 0 ) {
vkFreeCommandBuffers( logical_device, command_pool, static_cast<uint32_t>(command_buffers.size()), &command_buffers[0] );
command_buffers.clear();
}
在我们能够释放命令缓冲区之前,我们必须确保它们没有被逻辑设备引用,并且所有提供命令缓冲区的提交都已经完成。
当我们销毁命令池时,从该池分配的命令缓冲区会隐式释放。因此,当我们想要销毁一个池时,我们不需要单独释放从它分配的所有命令缓冲区。
参见
本章中的以下食谱:
-
创建命令池
-
分配命令缓冲区
-
等待栅栏
-
等待所有命令完成
-
销毁命令池
销毁命令池
当从给定池分配的所有命令缓冲区不再使用,并且我们也不再需要该池时,我们可以安全地销毁它。
如何做到这一点...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型变量中。 -
使用应该被销毁的池的句柄来初始化一个名为
command_pool的VkCommandPool类型变量。 -
调用
vkDestroyCommandPool( logical_device, command_pool, nullptr ),为它提供逻辑设备的句柄和命令池,以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值分配给command_pool变量。
它是如何工作的...
命令池的销毁使用以下代码:
if( VK_NULL_HANDLE != command_pool ) {
vkDestroyCommandPool( logical_device, command_pool, nullptr );
command_pool = VK_NULL_HANDLE;
}
但是,我们不能销毁该池,直到从它分配的所有命令缓冲区不再在设备上挂起等待执行。为了做到这一点,我们可以等待栅栏(fences)或使用那些会导致应用程序等待直到所选队列停止处理命令,或者整个设备忙碌(从给定设备提交到所有队列的工作仍在处理)的函数。只有在这种情况下,我们才能安全地销毁命令池。
参见
本章中的以下食谱:
-
创建命令池
-
等待所有提交的命令完成
第四章:资源和内存
在本章中,我们将介绍以下食谱:
-
创建缓冲区
-
为缓冲区分配和绑定内存对象
-
设置缓冲区内存屏障
-
创建缓冲区视图
-
创建图像
-
分配并绑定内存对象到图像
-
设置图像内存屏障
-
创建图像视图
-
创建 2D 图像和视图
-
创建具有 CUBEMAP 视图的分层 2D 图像
-
映射、更新和取消映射主机可见内存
-
在缓冲区之间复制数据
-
从缓冲区复制数据到图像
-
从图像复制数据到缓冲区
-
使用阶段缓冲区更新具有设备本地内存绑定的缓冲区
-
使用阶段缓冲区更新具有设备本地内存绑定的图像
-
销毁图像视图
-
销毁图像
-
销毁缓冲区视图
-
释放内存对象
-
销毁缓冲区
简介
在 Vulkan 中,有两种非常重要的资源类型可以存储数据--缓冲区和图像。缓冲区表示数据的线性数组。图像,类似于 OpenGL 的纹理,表示一维、二维或三维数据,其组织方式(通常)针对特定硬件是特定的(因此我们不知道内部内存结构)。缓冲区和图像可用于各种目的:在着色器中,我们可以从它们读取或采样数据,或者在其中存储数据。图像可以用作颜色或深度/模板附件(渲染目标),这意味着我们可以向它们渲染。缓冲区还可以存储用于间接绘制的顶点属性、索引或参数。
非常重要的是,在资源创建期间必须指定提到的每种用法(我们一次可以提供许多)。我们还需要在应用程序中更改给定资源的使用方式时通知驱动程序。
与像 OpenGL 这样的高级 API 不同,Vulkan 中的缓冲区和图像没有自己的存储。它们需要我们特别创建和绑定适当的内存对象。
在本章中,我们将学习如何使用这些资源,以及如何为它们分配内存并将它们绑定。我们还将学习如何从 CPU 上传数据到 GPU,以及如何在资源之间复制数据。
创建缓冲区
缓冲区是最简单的资源,因为它们代表的数据只能在内存中线性布局,就像典型的 C/C++数组一样:

缓冲区可用于各种目的。它们可以通过描述符集在管道中使用,作为统一缓冲区、存储缓冲区或 texel 缓冲区等数据存储的 backing store,它们可以是顶点索引或属性的数据源,或者可以用作阶段资源--从 CPU 到 GPU 数据传输的中间资源。对于所有这些用途,我们只需要创建一个缓冲区并指定其使用方式。
如何做到这一点...
-
获取存储在名为
logical_device的VkDevice类型变量中的已创建逻辑设备的句柄。 -
创建一个名为
size的VkDeviceSize类型的变量,在其中存储一个表示缓冲区能够存储的数据大小(以字节为单位)的值。 -
考虑到缓冲区将被用于的预期场景。创建一个名为
usage的VkBufferUsageFlags类型的位域变量。分配一个值,它是所有期望的缓冲区使用的逻辑和(OR)。 -
创建一个名为
buffer_create_info的VkBufferCreateInfo类型的变量。将其成员分配以下值:-
VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO的值为sType -
nullptr的值用于pNext -
flags的值为0 -
size变量用于size -
usage变量用于usage -
VK_SHARING_MODE_EXCLUSIVE的值为sharingMode -
0的值为queueFamilyIndexCount -
nullptr的值为pQueueFamilyIndices
-
-
创建一个名为
buffer的VkBuffer类型的变量,在其中存储已创建的缓冲区的句柄。 -
调用
vkCreateBuffer(logical_device, &buffer_create_info, nullptr, &buffer),并在第一个参数中提供一个逻辑设备的句柄,在第二个参数中提供一个指向buffer_create_info变量的指针,在第三个参数中使用nullptr值,并在最后一个参数中提供一个指向buffer变量的指针。 -
通过检查返回值是否等于
VK_SUCCESS来确保函数调用成功。
它是如何工作的...
在我们能够创建缓冲区之前,我们需要知道缓冲区应该有多大以及我们希望如何使用它。缓冲区的大小由我们希望存储在其中的数据量决定。缓冲区在应用程序中将如何使用,由缓冲区的使用情况指定。我们不能以在缓冲区创建期间未定义的方式使用缓冲区。
缓冲区只能用于创建时指定的目的(使用情况)。
这里是一个支持缓冲区使用的列表:
-
VK_BUFFER_USAGE_TRANSFER_SRC_BIT指定缓冲区可以作为复制操作的数据源 -
VK_BUFFER_USAGE_TRANSFER_DST_BIT指定我们可以将数据复制到缓冲区 -
VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT表示缓冲区可以作为均匀像素缓冲区在着色器中使用 -
VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT指定缓冲区可以作为存储像素缓冲区在着色器中使用 -
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT表示缓冲区可以作为着色器中均匀变量的值的来源使用 -
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT表示我们可以在着色器中从缓冲区存储数据 -
VK_BUFFER_USAGE_INDEX_BUFFER_BIT指定缓冲区可以作为绘制期间顶点索引的源使用 -
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT表示缓冲区可以作为绘制期间指定的顶点属性的源 -
VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT表示缓冲区可以包含在间接绘制过程中使用的数据
要创建缓冲区,我们需要准备一个 VkBufferCreateInfo 类型的变量,在其中提供以下数据:
VkBufferCreateInfo buffer_create_info = {
VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
nullptr,
0,
size,
usage,
VK_SHARING_MODE_EXCLUSIVE,
0,
nullptr
};
size和usage变量分别定义了缓冲区可以存储的数据量以及我们在应用程序中使用缓冲区的方式。
为sharingMode成员提供的先前VK_SHARING_MODE_EXCLUSIVE值是另一个非常重要的参数。通过它,我们指定多个家族的队列是否可以同时访问缓冲区。独占共享模式告诉驱动程序,缓冲区只能由一个家族的队列在某一时刻引用。如果我们想使用提交给另一个家族队列的命令中的缓冲区,我们必须明确告诉驱动程序所有权的变更(当我们从一个家族转移到另一个家族时)。这个选项给我们带来了更好的性能,但代价是更多的劳动。
我们还可以指定VK_SHARING_MODE_CONCURRENT共享模式。使用它,多个家族的多个队列可以同时访问一个缓冲区,我们不需要执行所有权转移。但是,权衡是并发访问可能具有较低的性能。
在我们准备好了创建数据后,我们可以创建一个缓冲区如下:
VkResult result = vkCreateBuffer( logical_device, &buffer_create_info, nullptr, &buffer );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a buffer." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
为缓冲区分配和绑定内存对象
-
设置缓冲区内存屏障
-
创建缓冲区视图
-
使用阶段缓冲区更新具有设备本地内存绑定的缓冲区
-
销毁缓冲区
为缓冲区分配和绑定内存对象
在 Vulkan 中,缓冲区没有自己的内存。为了能够在我们的应用程序中使用缓冲区并在其中存储任何数据,我们需要分配一个内存对象并将其绑定到缓冲区。
如何操作...
-
从创建逻辑设备所用的物理设备中获取句柄。将其存储在名为
physical_device的VkPhysicalDevice类型变量中。 -
创建一个名为
physical_device_memory_properties的VkPhysicalDeviceMemoryProperties类型变量。 -
调用
vkGetPhysicalDeviceMemoryProperties( physical_device, &physical_device_memory_properties ),为它提供物理设备的句柄和指向physical_device_memory_properties变量的指针。这个调用将存储用于处理的物理设备的内存参数(堆的数量、它们的大小和类型)。 -
从物理设备创建的逻辑设备,由
physical_device变量表示。将句柄存储在名为logical_device的VkDevice类型变量中。 -
获取由名为
buffer的VkBuffer类型变量表示的已创建缓冲区的句柄。 -
创建一个名为
memory_requirements的VkMemoryRequirements类型变量。 -
获取用于缓冲区的内存参数。通过调用
vkGetBufferMemoryRequirements( logical_device, buffer, &memory_requirements )来完成,第一个参数提供逻辑设备的句柄,第二个参数提供创建的缓冲区的句柄,第三个参数提供指向memory_requirements变量的指针。 -
创建一个名为
memory_object的VkDeviceMemory类型的变量,它将表示创建的缓冲区的内存对象,并将其分配一个VK_NULL_HANDLE值。 -
创建一个名为
memory_properties的VkMemoryPropertyFlagBits类型的变量,并将额外的(选择的)内存属性存储在该变量中。 -
遍历由
physical_device_memory_properties变量的memoryTypeCount成员表示的可用物理设备内存类型。通过使用名为type的uint32_t类型的变量来完成此操作。对于每次循环迭代,执行以下步骤:-
确保在
memory_requirements变量的memoryTypeBits成员中,由type变量表示的位置上的位是设置的。 -
确保
memory_properties变量具有与memoryTypes数组中索引type的内存类型的propertyFlags成员相同的位设置。 -
如果 1 和 2 点不成立,则继续迭代循环。
-
创建一个名为
buffer_memory_allocate_info的VkMemoryAllocateInfo类型的变量,并为其成员分配以下值:-
VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO值用于sType -
pNext的nullptr值 -
memory_requirements.size变量用于allocationSize -
type变量用于memoryTypeIndex
-
-
调用
vkAllocateMemory(logical_device, &buffer_memory_allocate_info, nullptr, &memory_object),为它提供逻辑设备的句柄、指向buffer_memory_allocate_info变量的指针、一个nullptr值以及指向memory_object变量的指针。 -
通过检查调用返回的值是否等于
VK_SUCCESS来确保调用成功,并停止循环迭代。
-
-
确保循环内内存对象的分配成功,通过检查
memory_object变量是否不等于VK_NULL_HANDLE。 -
通过调用
vkBindBufferMemory(logical_device, buffer, memory_object, 0)将内存对象绑定到缓冲区,为它提供logical_device、buffer、memory_object变量和一个0值。 -
确保调用成功,并且返回值等于
VK_SUCCESS。
它是如何工作的...
为了为缓冲区(或一般内存对象)分配内存对象,我们需要知道在给定的物理设备上可用的内存类型有哪些,以及它们的数量。这通过调用vkGetPhysicalDeviceMemoryProperties()函数来完成,如下所示:
VkPhysicalDeviceMemoryProperties physical_device_memory_properties;
vkGetPhysicalDeviceMemoryProperties( physical_device, &physical_device_memory_properties );
接下来,我们需要知道给定缓冲区需要多少存储空间(缓冲区的内存可能需要比缓冲区的大小更大)以及与之兼容的内存类型。所有这些信息都存储在一个类型为VkMemoryRequirements的变量中:
VkMemoryRequirements memory_requirements;
vkGetBufferMemoryRequirements( logical_device, buffer, &memory_requirements );
接下来,我们需要检查哪种内存类型对应于缓冲区的内存需求:
memory_object = VK_NULL_HANDLE;
for( uint32_t type = 0; type < physical_device_memory_properties.memoryTypeCount; ++type ) {
if( (memory_requirements.memoryTypeBits & (1 << type)) &&
((physical_device_memory_properties.memoryTypes[type].propertyFlags & memory_properties) == memory_properties) ) {
VkMemoryAllocateInfo buffer_memory_allocate_info = {
VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
nullptr,
memory_requirements.size,
type
};
VkResult result = vkAllocateMemory( logical_device, &buffer_memory_allocate_info, nullptr, &memory_object );
if( VK_SUCCESS == result ) {
break;
}
}
}
在这里,我们遍历所有可用的内存类型,并检查给定的类型是否可以用于我们的缓冲区。我们还可以请求一些需要满足的额外内存属性。例如,如果我们想直接从我们的应用程序(从 CPU)上传数据,内存映射必须得到支持。在这种情况下,我们需要使用一个主机可见的内存类型。
当我们找到一个合适的内存类型时,我们可以用它来分配一个内存对象,并且我们可以停止循环。之后,我们确保内存被正确分配(如果我们没有在未分配对象的情况下离开循环),然后我们将其绑定到我们的缓冲区:
if( VK_NULL_HANDLE == memory_object ) {
std::cout << "Could not allocate memory for a buffer." << std::endl;
return false;
}
VkResult result = vkBindBufferMemory( logical_device, buffer, memory_object, 0 );
if( VK_SUCCESS != result ) {
std::cout << "Could not bind memory object to a buffer." << std::endl;
return false;
}
return true;
在绑定过程中,我们指定一个内存偏移量,以及其他参数。这允许我们绑定不在内存对象开始处的内存的一部分。我们可以(并且应该)使用offset参数将内存对象的不同部分绑定到多个缓冲区。
从现在起,缓冲区可以在我们的应用程序中使用。
更多...
这个配方展示了如何分配和绑定一个内存对象到缓冲区。但通常,我们不应该为每个缓冲区使用一个单独的内存对象。我们应该分配更大的内存对象,并使用它们的一部分为多个缓冲区服务。
在这个配方中,我们还通过调用vkGetPhysicalDeviceMemoryProperties()函数获取了物理设备可用内存类型的参数。但通常,为了提高我们应用程序的性能,我们不需要每次想要分配内存对象时都调用它。我们只需调用一次这个函数,在我们选择用于逻辑设备的物理设备之后(参考第一章的创建逻辑设备配方[d10e8284-6122-4d0a-8f86-ab0bc0bba47e.xhtml],实例和设备),并使用存储参数的变量。
参见
本章中的以下配方:
-
创建缓冲区
-
设置缓冲区内存屏障
-
映射、更新和取消映射主机可见内存
-
使用阶段缓冲区更新具有设备本地内存限制的缓冲区
-
释放内存对象
-
销毁缓冲区
设置缓冲区内存屏障
缓冲区可用于各种目的。对于每个缓冲区,我们可以向其上传数据或从中复制数据;我们可以通过描述符集将缓冲区绑定到管道并在着色器内部将其用作数据源,或者我们可以在着色器内部将数据存储在缓冲区中。
我们必须通知驱动程序关于此类使用的每个情况,不仅是在缓冲区创建期间,而且在预期使用之前。当我们已经使用缓冲区完成一个目的,而现在我们想要以不同的方式使用它时,我们必须告诉驱动程序关于缓冲区使用的变化。这是通过缓冲区内存屏障来完成的。它们在命令缓冲区记录期间的管道屏障部分被设置(参考第三章的开始命令缓冲区记录操作配方[fc38e0ae-51aa-4f6f-8fb3-551861273018.xhtml],命令缓冲区和同步)。
准备工作
在本食谱中,我们将使用一个名为 BufferTransition 的自定义结构体类型,其定义如下:
struct BufferTransition {
VkBuffer Buffer;
VkAccessFlags CurrentAccess;
VkAccessFlags NewAccess;
uint32_t CurrentQueueFamily;
uint32_t NewQueueFamily;
};
通过这个结构,我们将定义我们想要用于缓冲区内存屏障的参数。在 CurrentAccess 和 NewAccess 中,我们存储有关缓冲区到目前为止的使用情况和将来如何使用的相关信息(在这种情况下,使用定义为将涉及给定缓冲区的内存操作类型)。当我们在缓冲区创建期间指定 独占共享模式 时,使用 CurrentQueueFamily 和 NewQueueFamily 成员。
如何做到这一点...
-
为你想要为每个缓冲区设置屏障的每个缓冲区准备参数。将它们存储在一个名为
buffer_transitions的std::vector<BufferTransition>类型的向量中。对于每个缓冲区,存储以下参数:-
Buffer成员中的缓冲区句柄。 -
到目前为止已涉及该缓冲区的内存操作类型在
CurrentAccess成员中。 -
从现在开始(在屏障之后)将在缓冲区上执行的内存操作类型在
NewAccess成员中。 -
到目前为止一直引用该缓冲区的队列家族的索引(或者如果不想转移队列所有权,则使用
VK_QUEUE_FAMILY_IGNORED值)在CurrentQueueFamily成员中。 -
从现在开始引用该缓冲区的队列家族的索引(或者如果不想转移队列所有权,则使用
VK_QUEUE_FAMILY_IGNORED值)在NewQueueFamily成员中。
-
-
创建一个名为
buffer_memory_barriers的std::vector<VkBufferMemoryBarrier>类型的向量变量。 -
对于
buffer_transitions变量的每个元素,向buffer_memory_barriers向量中添加一个新元素。为新元素成员使用以下值:-
sType成员的值为VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER。 -
pNext的值为nullptr。 -
srcAccessMask当前元素的CurrentAccess值。 -
dstAccessMask当前元素的NewAccess值。 -
srcQueueFamilyIndex当前元素的CurrentQueueFamily值。 -
dstQueueFamilyIndex当前元素的NewQueueFamily值。 -
buffer的缓冲区句柄。 -
offset的值为0。 -
size的值为VK_WHOLE_SIZE。
-
-
拿到命令缓冲区的句柄并将其存储在一个名为
command_buffer的VkCommandBuffer类型的变量中。 -
确保由
command_buffer句柄表示的命令缓冲区处于记录状态(记录操作已为命令缓冲区启动)。 -
创建一个名为
generating_stages的位字段类型VkPipelineStageFlags变量。在这个变量中,存储表示到目前为止已使用该缓冲区的管道阶段的值。 -
创建一个名为
consuming_stages的位字段类型VkPipelineStageFlags变量。在这个变量中,存储表示屏障之后将使用该缓冲区的管道阶段的值。 -
调用
vkCmdPipelineBarrier(command_buffer, generating_stages, consuming_stages, 0, 0, nullptr, static_cast<uint32_t>(buffer_memory_barriers.size()), &buffer_memory_barriers[0], 0, nullptr),并在第一个参数中提供命令缓冲区的句柄,在第二个和第三个参数中分别提供generating_stages和consuming_stages变量。应在第七个参数中提供buffer_memory_barriers向量的元素数量,第八个参数应指向buffer_memory_barriers向量的第一个元素。
它是如何工作的...
在 Vulkan 中,提交到队列的操作是按顺序执行的,但它们是独立的。有时,某些操作可能在之前的操作完成之前开始。这种并行执行是当前图形硬件最重要的性能因素之一。但有时,某些操作等待早期操作的结果是至关重要的:这时内存屏障就派上用场了。
内存屏障用于定义命令缓冲区执行中的时刻,在这些时刻,后续的命令应该等待早期命令完成其工作。它们还导致这些操作的结果对其他操作可见。
在缓冲区的情况下,通过内存屏障,我们指定了缓冲区的使用方式和在放置屏障之前使用它的管道阶段。接下来,我们需要定义在屏障之后将使用它的管道阶段以及如何使用。有了这些信息,驱动程序可以暂停需要等待早期操作结果成为可用的操作,但执行不会引用缓冲区的操作。
缓冲区只能用于创建时定义的目的。每个这样的用途都与可以通过哪种内存操作访问缓冲区内容的内存操作类型相对应。以下是支持的内存访问类型列表:
-
当缓冲区的内容是间接绘制数据源时,使用
VK_ACCESS_INDIRECT_COMMAND_READ_BIT -
VK_ACCESS_INDEX_READ_BIT表示缓冲区的内容在绘制操作期间用作索引 -
VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT指定缓冲区是绘制期间读取的顶点属性的源 -
当缓冲区将通过着色器作为统一缓冲区访问时,使用
VK_ACCESS_UNIFORM_READ_BIT -
VK_ACCESS_SHADER_READ_BIT表示缓冲区可以在着色器内部读取(但不能作为统一缓冲区) -
VK_ACCESS_SHADER_WRITE_BIT指定着色器将数据写入缓冲区 -
当我们想要从缓冲区复制数据时,使用
VK_ACCESS_TRANSFER_READ_BIT -
当我们想要将数据复制到缓冲区时,使用
VK_ACCESS_TRANSFER_WRITE_BIT -
VK_ACCESS_HOST_READ_BIT指定应用程序将读取缓冲区的内容(通过内存映射) -
当应用程序将通过内存映射将数据写入缓冲区时,使用
VK_ACCESS_HOST_WRITE_BIT -
当缓冲区的内存将以任何未在上文中指定的其他方式读取时,使用
VK_ACCESS_MEMORY_READ_BIT -
当缓冲区的内存将通过上述未描述的任何其他方式写入时,使用
VK_ACCESS_MEMORY_WRITE_BIT
为了使内存操作对后续命令可见,需要设置障碍。如果没有它们,读取缓冲区内容的命令可能会在之前操作尚未正确写入内容之前就开始读取。但是,这种在命令缓冲区执行中的中断会导致图形硬件处理管道中的停滞。不幸的是,这可能会影响我们应用程序的性能:

我们应该尽可能在尽可能少的障碍中聚合尽可能多的缓冲区的使用和所有权转换。
要为缓冲区设置内存障碍,我们需要准备一个类型为VkBufferMemoryBarrier的变量。如果可能,我们应该在一个内存障碍中聚合多个缓冲区的数据。这就是为什么一个包含类型为VkBufferMemoryBarrier的元素的向量在这个原因上看起来非常有用,并且可以像这样填充:
std::vector<VkBufferMemoryBarrier> buffer_memory_barriers;
for( auto & buffer_transition : buffer_transitions ) {
buffer_memory_barriers.push_back( {
VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
nullptr,
buffer_transition.CurrentAccess,
buffer_transition.NewAccess,
buffer_transition.CurrentQueueFamily,
buffer_transition.NewQueueFamily,
buffer_transition.Buffer,
0,
VK_WHOLE_SIZE
} );
}
接下来,我们在命令缓冲区中设置一个内存障碍。这是在命令缓冲区的记录操作期间完成的:
if( buffer_memory_barriers.size() > 0 ) {
vkCmdPipelineBarrier( command_buffer, generating_stages, consuming_stages, 0, 0, nullptr, static_cast<uint32_t>(buffer_memory_barriers.size()), &buffer_memory_barriers[0], 0, nullptr );
}
在障碍中,我们指定在障碍之后执行的命令的哪些管道阶段应该等待在障碍之前执行的命令的哪些管道阶段的结果。
记住,我们只有在使用改变时才需要设置障碍。如果缓冲区多次用于同一目的,我们不需要这样做。想象一下这样的情况,我们想要将数据复制到缓冲区两次,来自两个不同的资源。首先,我们需要设置一个障碍,通知驱动程序我们将执行涉及VK_ACCESS_TRANSFER_WRITE_BIT类型内存访问的操作。之后,我们可以将数据复制到缓冲区,次数不限。接下来,如果我们想使用缓冲区,例如,作为顶点缓冲区(渲染期间顶点属性的来源),我们需要设置另一个障碍,表明我们将从缓冲区读取顶点属性数据--这些操作由VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT内存访问表示。当我们完成绘制并且缓冲区将用于另一个目的时,即使我们再次想要将数据复制到缓冲区,我们仍然需要设置一个带有正确参数的内存障碍。
更多内容...
我们不需要为整个缓冲区设置障碍。我们只能为缓冲区内存的一部分设置。为此,我们只需要为给定缓冲区定义的类型为VkBufferMemoryBarrier的变量的offset和size成员指定适当的值。通过这些成员,我们定义内存内容的起始位置,以及我们想要定义障碍的内存的大小。这些值以机器单位(字节)指定。
参见
本章中的以下配方:
-
开始命令缓冲区记录操作
-
创建缓冲区
-
将内存对象分配和绑定到缓冲区
-
设置图像内存屏障
-
使用阶段缓冲区更新绑定设备本地内存的缓冲区
-
使用阶段缓冲区更新绑定设备本地内存的图像
创建缓冲区视图
当我们想要将给定的缓冲区用作统一纹理缓冲区或存储纹理缓冲区时,我们需要为它创建一个缓冲区视图。
如何做...
-
从创建给定缓冲区的逻辑设备中获取句柄。将其存储在名为
logical_device的VkDevice类型的变量中。 -
获取创建的缓冲区的句柄,并将其存储在名为
buffer的VkBuffer类型的变量中。 -
为缓冲区视图选择一个格式(如何解释缓冲区的内容)并使用它初始化一个名为
format的VkFormat类型的变量。 -
选择应创建视图的缓冲区内存的一部分。在名为
memory_offset的VkDeviceSize类型的变量中设置此内存的起始点(从缓冲区内存的起始点偏移)。通过名为memory_range的VkDeviceSize类型的变量定义此内存的大小。 -
创建一个名为
buffer_view_create_info的VkBufferViewCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_BUFFER_VIEW_CREATE_INFO值用于sType -
nullptr值用于pNext -
0值用于flags -
buffer变量用于buffer -
format变量用于format -
memory_offset变量用于offset -
memory_range变量用于range
-
-
创建一个名为
buffer_view的VkBufferView类型的变量。它将用于存储创建的缓冲区视图的句柄。 -
调用
vkCreateBufferView( logical_device, &buffer_view_create_info, nullptr, &buffer_view ),其中在第一个参数中提供逻辑设备的句柄,在第二个参数中提供一个指向buffer_view_create_info变量的指针,第三个参数为nullptr值,在最后一个参数中提供一个指向buffer_view变量的指针。 -
通过检查调用返回的值是否等于
VK_SUCCESS来确保调用成功。
它是如何工作的...
要创建缓冲区视图,我们需要考虑的最重要的事情是视图的格式和视图将创建的内存部分。这样,在着色器内部,缓冲区的内容可以像图像(纹理)一样被解释。我们定义以下参数:
VkBufferViewCreateInfo buffer_view_create_info = {
VK_STRUCTURE_TYPE_BUFFER_VIEW_CREATE_INFO,
nullptr,
0,
buffer,
format,
memory_offset,
memory_range
};
接下来,我们使用指定的参数创建缓冲区本身:
VkResult result = vkCreateBufferView( logical_device, &buffer_view_create_info, nullptr, &buffer_view );
if( VK_SUCCESS != result ) {
std::cout << "Could not creat buffer view." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
创建缓冲区
-
将内存对象分配和绑定到缓冲区
-
销毁图像视图
在 第五章 的 描述符集 中,查看以下食谱:
-
创建描述符集布局
-
更新描述符集
创建图像
图像表示具有一维、二维或三维的数据,并且可以具有额外的米普贴层和层。图像数据的每个元素(一个纹理元素)也可以有一个或多个样本。
图像可用于许多不同的目的。我们可以将它们用作复制操作的数据源。我们可以通过描述符集将图像绑定到管线,并将它们用作纹理(类似于 OpenGL)。我们可以将渲染结果输出到图像中,在这种情况下,我们使用图像作为颜色或深度附件(渲染目标)。
我们在创建图像时指定图像参数,如大小、格式和其预期用途。
如何做到这一点...
-
获取我们想要在其上创建图像的逻辑设备的句柄。将其存储在名为
logical_device的VkDevice类型的变量中。 -
选择图像类型(如果图像应该有一个、两个或三个维度)并使用适当的值初始化一个名为
type的VkImageType类型的变量。 -
选择图像的格式--每个图像元素应包含的组件数和位数。将格式存储在名为
format的VkFormat类型的变量中。 -
选择图像的大小(维度)并使用它来初始化一个名为
size的VkExtent3D类型的变量。 -
选择应为图像定义的米普级别数。将米普级别数存储在名为
num_mipmaps的uint32_t类型的变量中。 -
选择应为图像定义的层数并将其存储在名为
num_layers的uint32_t类型的变量中。如果图像将用作立方体贴图,则层数必须是六的倍数。 -
创建一个名为
samples的VkSampleCountFlagBits类型的变量,并用表示样本数的值初始化它。 -
选择预期的图像用途。在名为
usage_scenarios的VkImageUsageFlags类型的变量中定义它们。 -
创建一个名为
image_create_info的VkImageCreateInfo类型的变量。为其成员使用以下值:-
sType的VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO值 -
pNext的nullptr值 -
对于
flags,如果图像应用作立方体贴图,则使用VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT值,否则使用0值。 -
imageType的type变量 -
format的format变量 -
extent的size变量 -
mipLevels的num_mipmaps变量 -
arrayLayers的num_layers变量 -
samples的samples变量 -
tiling的VK_IMAGE_TILING_OPTIMAL值 -
usage的usage_scenarios变量 -
sharingMode的VK_SHARING_MODE_EXCLUSIVE值 -
queueFamilyIndexCount的0值 -
pQueueFamilyIndices的nullptr值 -
initialLayout的VK_IMAGE_LAYOUT_UNDEFINED值
-
-
创建一个名为
image的VkImage类型的变量。在其中,将存储创建的图像的句柄。 -
调用
vkCreateImage(logical_device, &image_create_info, nullptr, &image),其中提供逻辑设备的句柄、image_create_info变量的指针、一个nullptr值和image变量的指针。 -
确保由
vkCreateImage()调用返回的值等于VK_SUCCESS。
它是如何工作的...
当我们想要创建一个图像时,我们需要准备多个参数:图像的类型、维度(大小)、组件数量以及每个组件的位数(格式)。我们还需要知道图像是否包含多级细节图(mipmap)或是否具有多个层(一个普通图像至少包含一个,立方体贴图图像至少包含六个)。我们还应该考虑预期的使用场景,这些场景也在图像创建时定义。我们不能以创建时未定义的方式使用图像。
图像只能用于创建时指定的目的(用途)。
这里列出了可以使用图像的目的:
-
VK_IMAGE_USAGE_TRANSFER_SRC_BIT表示图像可以用作复制操作的数据源 -
VK_IMAGE_USAGE_TRANSFER_DST_BIT表示我们可以将数据复制到图像中 -
VK_IMAGE_USAGE_SAMPLED_BIT表示我们可以在着色器内部从图像中采样数据 -
VK_IMAGE_USAGE_STORAGE_BIT表示图像可以用作着色器中的存储图像 -
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT表示我们可以将内容渲染到图像中(在帧缓冲区中使用它作为颜色渲染目标/附件) -
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT表示图像可以用作深度和/或模板缓冲区(作为帧缓冲区中的深度渲染目标/附件) -
VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT表示绑定到图像的内存将按需分配(延迟分配) -
VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT指定图像可以用作着色器中的输入附件
不同的使用场景需要使用不同的图像布局。这些布局通过图像内存屏障进行更改(转换)。但在创建时,我们只能指定 VK_IMAGE_LAYOUT_UNDEFINED(如果我们不关心初始内容)或 VK_IMAGE_LAYOUT_PREINITIALIZED(如果我们想通过映射主机可见的内存来上传数据),并且我们总是在实际使用之前将其转换为另一个布局。
所有图像参数都通过类型为 VkImageCreateInfo 的变量指定,如下所示:
VkImageCreateInfo image_create_info = {
VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
nullptr,
cubemap ? VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT : 0u,
type,
format,
size,
num_mipmaps,
cubemap ? 6 * num_layers : num_layers,
samples,
VK_IMAGE_TILING_OPTIMAL,
usage_scenarios,
VK_SHARING_MODE_EXCLUSIVE,
0,
nullptr,
VK_IMAGE_LAYOUT_UNDEFINED
};
当我们创建图像时,我们还需要指定平铺。它定义了图像的内存结构。有两种可用的图像平铺类型:线性和平滑。
当使用线性平铺时,正如其名所示,图像的数据在内存中线性排列,类似于缓冲区或 C/C++ 数组。这使我们能够映射图像的内存并直接从我们的应用程序中读取或初始化它,因为我们知道内存是如何组织的。不幸的是,它限制了我们可以使用图像的许多目的;例如,我们不能将图像用作深度纹理或立方体贴图(某些驱动程序可能支持它,但它不是规范所要求的,并且通常我们不应该依赖它)。线性平铺也可能降低我们应用程序的性能。
为了获得最佳性能,建议使用最佳平铺方式创建图像。
具有最佳贴图的图像可用于所有目的;它们还具有更好的性能。但这也带来了权衡——我们不知道图像的内存是如何组织的。在以下图中,我们可以看到一个图像的数据及其内部结构的示例:

每种类型的图形硬件都可以以对其最优的方式存储图像数据。正因为如此,我们无法映射图像的内存并直接从我们的应用程序中初始化或读取它。在这种情况下,我们需要使用 阶段资源。
当我们准备好时,我们可以使用以下代码创建一个图像:
VkResult result = vkCreateImage( logical_device, &image_create_info, nullptr, &image );
if( VK_SUCCESS != result ) {
std::cout << "Could not create an image." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
分配和绑定内存对象到图像
-
设置图像内存屏障
-
创建图像视图
-
创建二维图像和视图
-
使用阶段缓冲区更新具有设备本地内存绑定的图像
-
销毁图像
分配和绑定内存对象到图像
与缓冲区类似,图像不是与绑定内存存储一起创建的。我们需要隐式创建一个内存对象并将其绑定到图像上。我们也可以为此目的使用现有的内存对象。
如何做到这一点...
-
获取从物理设备创建的逻辑设备的句柄。将其存储在名为
physical_device的VkPhysicalDevice类型变量中。 -
创建一个名为
physical_device_memory_properties的VkPhysicalDeviceMemoryProperties类型的变量。 -
调用
vkGetPhysicalDeviceMemoryProperties( physical_device, &physical_device_memory_properties ),其中提供物理设备的句柄以及指向physical_device_memory_properties变量的指针。此调用将存储用于处理提交操作的物理设备的内存参数(堆的数量、它们的大小和类型)。 -
获取由
physical_device变量表示的从物理设备创建的逻辑设备的句柄。将句柄存储在名为logical_device的VkDevice类型变量中。 -
获取由名为
image的VkImage类型变量表示的已创建图像的句柄。 -
创建一个名为
memory_requirements的VkMemoryRequirements类型的变量。 -
获取用于图像所需的内存的参数。通过调用
vkGetImageMemoryRequirements( logical_device, image, &memory_requirements )并在第一个参数中提供逻辑设备的句柄,在第二个参数中提供创建的图像的句柄,以及在第三个参数中提供指向memory_requirements变量的指针来完成此操作。 -
创建一个名为
memory_object的VkDeviceMemory类型的变量,该变量将表示为图像创建的内存对象,并将其赋值为VK_NULL_HANDLE。 -
创建一个名为
memory_properties的VkMemoryPropertyFlagBits类型的变量。将额外的(选择的)内存属性存储在变量中,或者如果不需要额外的属性,则存储0值。 -
遍历由
physical_device_memory_properties变量的memoryTypeCount成员表示的可用的物理设备内存类型。通过一个名为type的uint32_t类型的变量来完成此操作。对于每次循环迭代:-
确保由
memory_requirements变量中的memoryTypeBits成员的type变量表示的位置上的位被设置。 -
确保
memory_properties变量具有与memoryTypes内存类型的propertyFlags成员相同的位,在physical_device_memory_properties变量中的索引为type。 -
如果 1 和 2 点不成立,则继续迭代循环。
-
创建一个名为
image_memory_allocate_info的VkMemoryAllocateInfo类型的变量,并为其成员分配以下值:-
VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO值用于sType -
pNext的nullptr值 -
memory_requirements.size变量用于allocationSize -
type变量用于memoryTypeIndex
-
-
调用
vkAllocateMemory(logical_device, &image_memory_allocate_info, nullptr, &memory_object),为此提供逻辑设备的句柄、image_memory_allocate_info变量的指针、一个nullptr值以及memory_object变量的指针。 -
通过检查调用返回的值是否等于
VK_SUCCESS来确保调用成功,并停止迭代循环。
-
-
通过检查
memory_object变量是否不等于VK_NULL_HANDLE,确保在循环内内存对象分配成功。 -
通过调用
vkBindImageMemory(logical_device, image, memory_object, 0)将内存对象绑定到图像,为此提供logical_device、image和memory_object变量以及一个0值。 -
确保调用成功,并且返回值等于
VK_SUCCESS。
它是如何工作的...
与为缓冲区创建的内存对象类似,我们首先检查给定物理设备上可用的内存类型及其属性。当然,我们可以省略这些步骤,并在我们应用程序的初始化阶段一次性收集这些信息:
VkPhysicalDeviceMemoryProperties physical_device_memory_properties;
vkGetPhysicalDeviceMemoryProperties( physical_device, &physical_device_memory_properties );
接下来,我们获取给定图像的具体内存需求。这些可能(并且很可能)对每个图像都不同,因为它们取决于图像的格式、大小、mipmap 数量和层数以及其他属性:
VkMemoryRequirements memory_requirements;
vkGetImageMemoryRequirements( logical_device, image, &memory_requirements );
下一步是找到一个具有适当参数且与图像内存需求兼容的内存类型:
memory_object = VK_NULL_HANDLE;
for( uint32_t type = 0; type < physical_device_memory_properties.memoryTypeCount; ++type ) {
if( (memory_requirements.memoryTypeBits & (1 << type)) &&
((physical_device_memory_properties.memoryTypes[type].propertyFlags & memory_properties) == memory_properties) ) {
VkMemoryAllocateInfo image_memory_allocate_info = {
VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
nullptr,
memory_requirements.size,
type
};
VkResult result = vkAllocateMemory( logical_device, &image_memory_allocate_info, nullptr, &memory_object );
if( VK_SUCCESS == result ) {
break;
}
}
}
在这里,我们遍历所有可用的内存类型。如果一个图像内存属性的memoryTypeBits成员的给定位被设置,这意味着具有相同编号的内存类型与图像兼容,我们可以用它来创建内存对象。我们还可以检查内存类型的其他属性,找到适合我们需求的类型。例如,我们可能想要使用可以映射到 CPU 上的内存(一个主机可见的内存)。
接下来,我们检查循环内内存对象分配是否成功。如果是,我们将创建的内存对象与我们的图像绑定:
if( VK_NULL_HANDLE == memory_object ) {
std::cout << "Could not allocate memory for an image." << std::endl;
return false;
}
VkResult result = vkBindImageMemory( logical_device, image, memory_object, 0 );
if( VK_SUCCESS != result ) {
std::cout << "Could not bind memory object to an image." << std::endl;
return false;
}
return true;
从现在起,我们可以使用图像进行其创建期间定义的所有目的。
更多...
类似于将内存对象绑定到缓冲区,我们应该分配更大的内存对象,并将它们的部分绑定到多个图像。这样,我们执行更少的内存分配,驱动程序需要跟踪的内存对象数量更少。这可能会提高我们应用程序的性能。它还可能允许我们节省一些内存,因为每次分配可能需要比分配时请求的更多内存(换句话说,其大小可能总是向上舍入到内存页面大小的倍数)。分配更大的内存对象并重复使用它们的部分来为多个图像节省了浪费的空间。
相关内容
本章中的以下菜谱:
-
创建图像
-
设置图像内存屏障
-
映射、更新和取消映射主机可见内存
-
使用阶段缓冲区更新具有设备本地内存绑定的图像
-
销毁图像
-
释放内存对象
设置图像内存屏障
图像被创建用于各种目的——它们被用作纹理,通过将它们绑定到管道的描述符集,作为渲染目标,或作为交换链中的可呈现图像。我们可以将数据复制到或从图像中——这些也是在图像创建期间定义的单独使用方式。
在我们开始使用图像进行任何目的之前,以及每次我们想要更改给定图像的当前使用方式时,我们需要通知驱动程序此操作。我们通过在命令缓冲区记录期间设置图像内存屏障来完成此操作。
准备工作
为了本菜谱的目的,引入了一个自定义结构类型 ImageTransition。它具有以下定义:
struct ImageTransition {
VkImage Image;
VkAccessFlags CurrentAccess;
VkAccessFlags NewAccess;
VkImageLayout CurrentLayout;
VkImageLayout NewLayout;
uint32_t CurrentQueueFamily;
uint32_t NewQueueFamily;
VkImageAspectFlags Aspect;
};
CurrentAccess 和 NewAccess 成员定义了在屏障之前和之后针对给定图像进行的内存操作类型。
在 Vulkan 中,用于不同目的的图像可能具有不同的内部内存组织。换句话说,给定图像的内存可能对不同图像使用具有不同的布局。当我们想要以不同的方式开始使用图像时,我们也需要更改此内存布局。这是通过 CurrentLayout 和 NewLayout 成员来完成的。
内存屏障还允许我们在图像以独占共享模式创建时转移队列家族所有权。在 CurrentQueueFamily 成员中,我们定义了一个家族的索引,该家族的队列到目前为止一直在使用图像。在 NewQueueFamily 中,我们需要定义一个队列家族索引,该索引用于屏障之后将使用图像的队列。我们还可以在两种情况下使用 VK_QUEUE_FAMILY_IGNORED 特殊值,即当我们不想转移所有权时。
Aspect 成员定义了图像的使用“上下文”。我们可以从颜色、深度或模板方面进行选择。
如何操作...
-
为你想要设置障碍的每个图像准备参数。将它们存储在名为
image_transitions的类型为std::vector<ImageTransition>的向量中。对于每个图像,使用以下值:-
图像在
Image成员中的句柄。 -
到目前为止涉及图像的内存操作类型在
CurrentAccess成员中。 -
在障碍之后,从现在开始将在图像上执行的内存操作类型在
NewAccess成员中。 -
当前图像在
CurrentLayout成员中的内部内存布局。 -
在
NewLayout成员中,图像的内存布局应在障碍后更改。 -
在
CurrentQueueFamily成员中,引用图像的队列家族的索引(或如果不想转移队列所有权,则为VK_QUEUE_FAMILY_IGNORED值)。 -
从现在开始将引用图像的队列家族的索引(或如果不想转移队列所有权,则为
VK_QUEUE_FAMILY_IGNORED值)在NewQueueFamily成员中。 -
图像在
Aspect成员中的方面(颜色、深度或模板)。
-
-
创建一个名为
image_memory_barriers的类型为std::vector<VkImageMemoryBarrier>的向量变量。 -
对于
image_transitions变量的每个元素,向image_memory_barriers向量添加一个新元素。为新元素成员使用以下值:-
sType成员的VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER值。 -
pNext的nullptr值。 -
当前元素的
srcAccessMask的CurrentAccess值。 -
当前元素的
dstAccessMask的NewAccess值。 -
当前元素的
oldLayout的CurrentLayout成员。 -
当前元素的
newLayout的NewLayout值。 -
当前元素的
srcQueueFamilyIndex的CurrentQueueFamily值。 -
当前元素的
dstQueueFamilyIndex的NewQueueFamily值。 -
图像的
image句柄。 -
新元素
subresourceRange成员的以下值:-
当前元素的
aspectMask的Aspect成员。 -
baseMipLevel的0值。 -
levelCount的VK_REMAINING_MIP_LEVELS值。 -
baseArrayLayer的0值。 -
layerCount的VK_REMAINING_ARRAY_LAYERS值。
-
-
-
捕获命令缓冲区的句柄,并将其存储在名为
command_buffer的类型为VkCommandBuffer的变量中。 -
确保由
command_buffer句柄表示的命令缓冲区处于记录状态(记录操作已开始用于命令缓冲区)。 -
创建一个名为
generating_stages的位字段类型VkPipelineStageFlags变量。在这个变量中,存储表示到目前为止已经使用图像的管道阶段的值。 -
创建一个名为
consuming_stages的位字段类型VkPipelineStageFlags变量。在这个变量中,存储表示图像在障碍之后将被引用的管道阶段的值。 -
调用
vkCmdPipelineBarrier( command_buffer, generating_stages, consuming_stages, 0, 0, nullptr, 0, nullptr, static_cast<uint32_t>(image_memory_barriers.size()), &image_memory_barriers[0] )并在第一个参数中提供命令缓冲区的句柄,以及在第二个和第三个参数中分别提供generating_stages和consuming_stages变量。应在倒数第二个参数中提供image_memory_barriers向量的元素数量,最后一个参数应指向image_memory_barriers向量的第一个元素。
它是如何工作的...
在 Vulkan 中,操作在管道中处理。尽管操作的执行需要按照它们提交的顺序开始,但管道的某些部分仍可能并发执行。但有时,我们可能需要同步这些操作,并告知驱动程序我们希望其中一些操作等待其他操作的结果。
内存障碍用于定义命令缓冲区执行中的时刻,在此时刻后续命令应等待早期命令完成其工作。它们还导致这些操作的结果对其他操作可见。
为了使内存操作在后续命令中可见,需要设置障碍。在操作将数据写入图像且后续操作将从中读取的情况下,我们需要使用图像内存障碍。相反的情况也需要使用内存障碍——覆盖图像数据的操作应该等待早期操作停止从它们那里读取数据。在两种情况下,不这样做都会使图像的内容无效。但这种情况应该尽可能少发生,否则我们的应用程序可能会遭受性能损失。这是因为命令缓冲区执行中的这种暂停会导致图形硬件处理管道中的停滞,从而浪费时间:
为了避免对我们应用程序性能的负面影响,我们应该尽可能在尽可能少的障碍中为尽可能多的图像设置参数。

图像内存屏障也用于定义图像使用方式的变化。这种使用变化通常还需要我们同步提交的操作;这就是为什么这也通过内存屏障来完成。为了改变图像的使用,我们需要定义在屏障(内存访问)之前和之后在图像上执行的记忆操作类型。我们还指定了屏障之前的内存布局,以及屏障之后内存应该如何布局。这是因为图像在用于不同目的时可能具有不同的内存组织。例如,从着色器内部采样图像数据可能需要它们以这种方式缓存,以便相邻的纹理元素在内存中也是相邻的。但是,当内存线性布局时,写入图像数据可能执行得更快。这就是为什么在 Vulkan 中引入了图像布局。每个图像使用都有自己的、指定的布局。有一个通用的布局,可以用于所有目的。但是,不建议使用通用布局,因为它可能会影响某些硬件平台上的性能。
为了获得最佳性能,建议使用特定用途指定的图像内存布局,尽管如果布局转换过于频繁,则需要小心。
定义使用变化的参数通过VkImageMemoryBarrier类型的变量指定,如下所示:
std::vector<VkImageMemoryBarrier> image_memory_barriers;
for( auto & image_transition : image_transitions ) {
image_memory_barriers.push_back( {
VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
nullptr,
image_transition.CurrentAccess,
image_transition.NewAccess,
image_transition.CurrentLayout,
image_transition.NewLayout,
image_transition.CurrentQueueFamily,
image_transition.NewQueueFamily,
image_transition.Image,
{
image_transition.Aspect,
0,
VK_REMAINING_MIP_LEVELS,
0,
VK_REMAINING_ARRAY_LAYERS
}
} );
}
但是,为了让屏障正常工作,我们还需要定义已经使用过图像的管道阶段,以及从现在开始将使用图像的管道阶段:

在前面的图中,我们可以看到两个管道屏障的例子。在左侧,颜色是由片段着色器生成的,在所有片段测试(深度测试、混合)之后,颜色数据被写入图像。然后,这个图像被用于后续命令的顶点着色器。这种情况很可能在管道中产生停滞。
右侧的示例显示了图形命令中的另一个依赖关系。在这里,数据被写入顶点着色器中的资源。然后,这些数据被下一个命令的片段着色器使用。这一次,所有顶点着色器的实例很可能在下一个命令的片段着色器开始执行之前完成它们的工作。这就是为什么减少管道屏障的数量,并在需要时正确设置绘图命令和选择屏障的管道阶段很重要。屏障的参数(生成和消耗阶段)通过以下调用对所有在屏障中指定的图像进行聚合:
if( image_memory_barriers.size() > 0 ) {
vkCmdPipelineBarrier( command_buffer, generating_stages, consuming_stages, 0, 0, nullptr, 0, nullptr, static_cast<uint32_t>(image_memory_barriers.size()), &image_memory_barriers[0] );
}
如果图像以相同的方式多次使用,并且在之间没有用于其他目的,我们实际上在使用图像之前不需要设置屏障。我们设置它来表示使用的变化,而不是使用本身。
参见
在第三章,命令缓冲区和同步中,查看以下食谱:
- 开始命令缓冲区记录操作
在本章中查看以下食谱:
-
创建图像
-
分配和绑定内存对象到图像
-
使用阶段缓冲区更新绑定设备本地内存的图像
创建图像视图
图像很少直接在 Vulkan 命令中使用。帧缓冲区和着色器(通过描述符集)通过图像视图访问图像。图像视图定义了图像内存的一部分,并指定了正确读取图像数据所需的其他信息。这就是为什么我们需要知道如何创建图像视图。
如何做到...
-
获取逻辑设备的句柄,并使用它来初始化一个名为
logical_device的VkDevice类型的变量。 -
使用创建的图像的句柄来初始化一个名为
image的VkImage类型的变量。 -
创建一个名为
image_view_create_info的VkImageViewCreateInfo类型的变量。为其成员使用以下值:-
VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO值用于sType -
nullptr值用于pNext -
0值用于flags -
image变量用于image -
图像视图的类型用于
viewType -
图像或其他兼容格式(如果您想在视图中重新解释它)的格式用于
format -
VK_COMPONENT_SWIZZLE_IDENTITY值用于components成员的所有成员 -
使用以下值作为
subresourceRange成员的成员:-
图像的方面(颜色、深度或模板)用于
aspectMask -
0值用于baseMipLevel -
VK_REMAINING_MIP_LEVELS值用于levelCount -
0用于baseArrayLayer -
VK_REMAINING_ARRAY_LAYERS用于layerCount
-
-
-
创建一个名为
image_view的VkImageView类型的变量。我们将存储创建的图像视图的句柄。 -
调用
vkCreateImageView(logical_device, &image_view_create_info, nullptr, &image_view),为它提供逻辑设备的句柄、image_view_create_info变量的指针、nullptr值和image_view变量的指针。 -
通过将返回值与
VK_SUCCESS值进行比较,确保调用成功。
它是如何工作的...
图像视图定义了用于访问图像的附加元数据。通过它,我们可以指定命令应访问图像的哪些部分。尽管这个食谱展示了如何为整个图像数据创建图像视图,但也可以指定一个更小的资源范围,该范围应被访问。例如,当我们在一个渲染通道内渲染图像时,我们可以指定只更新一个 mipmap 级别。
图像视图还定义了图像内存应该如何被解释。一个很好的例子是多层的图像。对于它,我们可以定义一个直接解释图像为分层图像的图像视图,或者我们可以使用图像视图从它创建一个立方体贴图。
这些参数指定如下:
VkImageViewCreateInfo image_view_create_info = {
VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
nullptr,
0,
image,
view_type,
format,
{
VK_COMPONENT_SWIZZLE_IDENTITY,
VK_COMPONENT_SWIZZLE_IDENTITY,
VK_COMPONENT_SWIZZLE_IDENTITY,
VK_COMPONENT_SWIZZLE_IDENTITY
},
{
aspect,
0,
VK_REMAINING_MIP_LEVELS,
0,
VK_REMAINING_ARRAY_LAYERS
}
};
图像视图的创建是通过 vkCreateImageView() 函数的单次调用来执行的。以下是一个此类调用的示例:
VkResult result = vkCreateImageView( logical_device, &image_view_create_info, nullptr, &image_view );
if( VK_SUCCESS != result ) {
std::cout << "Could not create an image view." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
创建图像
-
创建 2D 图像和视图
-
销毁图像视图
创建 2D 图像和视图
在许多流行的应用程序或游戏中使用的最常见图像类型是典型的具有四个 RGBA 分量和每 texel 32 位的 2D 纹理。要在 Vulkan 中创建此类资源,我们需要创建一个 2D 图像和一个合适的图像视图。
如何做到这一点...
-
获取一个逻辑设备句柄并使用它来初始化一个名为
logical_device的VkDevice类型的变量。 -
选择图像中使用的数据格式,并使用所选值初始化一个名为
format的VkFormat类型的变量。 -
选择图像的大小。将其存储在一个名为
size的VkExtent2D类型的变量中。 -
选择应组成图像的 mipmap 级数数量。使用所选的 mipmap 数量初始化一个名为
num_mipmaps的uint32_t类型的变量。 -
使用一个名为
num_layers的uint32_t类型的变量指定图像层数。 -
选择每 texel 样本的数量,并将其存储在一个名为
samples的VkSampleCountFlagBits类型的变量中。 -
考虑图像在应用程序中将用于的所有目的。将这些用途的逻辑和(或)存储在一个名为
usage的VkImageUsageFlags类型的变量中。 -
使用
logical_device、format、size、num_mipmaps、num_layers、samples和usage变量创建一个VK_IMAGE_TYPE_2D类型的图像。将创建的图像句柄存储在一个名为image的VkImage类型的变量中(参考 创建图像 食谱)。 -
从获取
logical_device处理器的物理设备中获取句柄。将物理设备的句柄存储在一个名为physical_device的VkPhysicalDevice类型的变量中。 -
获取
physical_device的内存属性,并使用它们来分配一个将绑定到由image变量表示的图像的内存对象。确保使用具有VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存类型。将分配的内存对象存储在一个名为memory_object的VkDeviceMemory类型的变量中(参考 分配和绑定内存对象到图像 食谱)。 -
选择用于创建图像视图的图像的方面(颜色或深度和/或模板)并将其存储在一个名为
aspect的VkImageAspectFlags类型的变量中。 -
创建一个
VK_IMAGE_VIEW_TYPE_2D类型的图像视图。在创建图像视图时使用logical_device、image、format和aspect变量。将创建的句柄存储在一个名为image_view的VkImageView类型的变量中(参考 创建图像视图 食谱)。
它是如何工作的...
图像创建需要我们执行三个一般步骤:
-
创建一个图像。
-
创建一个内存对象(或使用现有的一个)并将其绑定到图像。
-
创建一个图像视图。
对于通常用作纹理的图像,我们需要创建一个类型为VK_IMAGE_TYPE_2D和格式为VK_FORMAT_R8G8B8A8_UNORM的图像,但我们可以根据需要设置这些参数。图像的其余属性取决于图像的大小(换句话说,我们正在从现有的图像文件创建纹理,我们需要匹配其尺寸),应用于图像的过滤类型(如果我们想使用米级贴图),样本数(如果它应该是多采样),以及所需的用法场景。
在创建图像的说明中定义的图像创建可以简化为以下代码:
if( !CreateImage( logical_device, VK_IMAGE_TYPE_2D, format, { size.width, size.height, 1 }, num_mipmaps, num_layers, samples, usage, false, image ) ) {
return false;
}
接下来,我们需要按照将内存对象分配和绑定到图像的说明将内存对象分配并绑定到图像上。为了获得最佳性能,内存对象应该分配在快速、设备本地的内存上,如下所示:
if( !AllocateAndBindMemoryObjectToImage( physical_device, logical_device, image, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
如果现有的内存对象满足图像的内存要求并且有足够的存储空间,我们当然可以使用它。
之后,我们必须创建一个图像视图。有了它,我们可以告诉硬件如何解释图像数据。我们还可以为图像视图使用不同的(但仍然兼容)格式。但对于许多(如果不是大多数)用途,这并不是必要的,我们将指定与图像相同的格式。对于标准的 2D 纹理,我们也在创建视图时使用颜色方面,但对于具有深度数据的图像(换句话说,用于深度附件的图像),必须指定深度方面。有关图像视图创建的更多详细信息,请参阅创建图像视图的说明:
if( !CreateImageView( logical_device, image, VK_IMAGE_VIEW_TYPE_2D, format, aspect, image_view ) ) {
return false;
}
现在,图像已准备好在我们的应用程序中使用。我们可以从文件上传数据到图像,并在着色器(在这种情况下,我们还需要一个采样器和描述符集)内部将其用作纹理。我们还可以将图像视图绑定到帧缓冲区,并将其用作颜色附件(渲染目标)。
参见
本章中的以下说明:
-
创建图像
-
将内存对象分配和绑定到图像
-
创建图像视图
-
销毁图像视图
-
销毁图像
-
释放内存对象
创建具有 CUBEMAP 视图的分层 2D 图像
在 3D 应用程序或游戏中使用的图像的一个相当常见的例子是 CUBEMAPs,用于模拟对象反射其环境。为此目的,我们不创建 CUBEMAP 图像。我们需要创建一个分层图像,并通过图像视图告诉硬件它应该将其层解释为六个 CUBEMAP 面。
如何操作...
-
获取逻辑设备的句柄。将其存储在名为
logical_device的VkDevice类型变量中。 -
选择图像的大小,并记住它必须是正方形。将图像的尺寸保存到名为
size的uint32_t类型变量中。 -
选择图像的米级贴图级别数量。将名为
num_mipmaps的uint32_t类型变量初始化为所选的数量。 -
考虑所有不同的场景,在这些场景中,图像将被使用。将这些使用的逻辑和(OR)存储在名为
usage的VkImageUsageFlags类型变量中。 -
创建一个
VK_IMAGE_TYPE_2D类型、VK_FORMAT_R8G8B8A8_UNORM格式、六个层和一个 texel 每个样本的图像。使用logical_device、size、num_mipmaps和usage变量设置其余图像参数。将创建的图像句柄存储在名为image的VkImage类型变量中(参考创建图像配方)。 -
从获取
logical_device句柄的物理设备中获取句柄。将其存储在名为physical_device的VkPhysicalDevice类型变量中。 -
获取
physical_device的内存属性。使用具有VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存类型来分配内存对象。将分配的内存对象的句柄存储在名为memory_object的VkDeviceMemory类型变量中,并将其绑定到图像(参考将内存对象绑定到图像上配方)。 -
选择颜色方面并将其存储在一个
VkImageAspectFlags类型的变量中类型名为
aspect。 -
创建一个
VK_IMAGE_VIEW_TYPE_CUBE类型和VK_FORMAT_R8G8B8A8_UNORM格式的图像视图。在创建图像视图时使用logical_device、image和aspect变量。将创建的句柄存储在名为image_view的VkImageView类型变量中(参考创建图像视图配方)。
它是如何工作的...
创建 CUBEMAP 的过程与创建任何其他类型的图像非常相似。首先,我们创建图像本身。我们只需记住图像应该至少有六个层,这些层将被解释为六个 CUBEMAP 面。对于 CUBEMAP,我们也不能在每个 texel 上使用超过一个样本:
if( !CreateImage( logical_device, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, { size, size, 1 }, num_mipmaps, 6, VK_SAMPLE_COUNT_1_BIT, usage, true, image ) ) {
return false;
}
接下来,我们以与其他资源相同的方式分配和绑定一个内存对象:
if( !AllocateAndBindMemoryObjectToImage( physical_device, logical_device, image, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
最后,我们需要创建一个图像视图。通过它,我们指定 CUBEMAP 视图类型:
if( !CreateImageView( logical_device, image, VK_IMAGE_VIEW_TYPE_CUBE, VK_FORMAT_R8G8B8A8_UNORM, aspect, image_view ) ) {
return false;
}
当使用 CUBEMAP 图像视图时,图像层按照顺序对应于+X、-X、+Y、-Y、+Z 和-Z 的各个面。
参见
本章中的以下配方:
-
创建图像
-
将内存对象绑定到图像上
-
创建图像视图
-
销毁图像视图
-
销毁图像
-
释放内存对象
映射、更新和取消映射主机可见内存
对于在渲染过程中使用的图像和缓冲区,建议绑定位于图形硬件上的内存(设备本地内存)。这给我们提供了最佳性能。但我们不能直接访问此类内存,我们需要使用中间(准备)资源,这些资源在 GPU(设备)和 CPU(主机)之间介导数据传输。
准备资源,另一方面,需要使用主机可见的内存。要将数据上传到此类内存或从中读取数据,我们需要将其映射。
如何做到这一点...
-
获取创建的逻辑设备的句柄,并将其存储在一个名为
logical_device的VkDevice类型的变量中。 -
选择一个在具有
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT属性的内存类型上分配的内存对象。将内存对象的句柄存储在一个名为memory_object的VkDeviceMemory类型的变量中。 -
选择一个应该被映射和更新的内存区域。将内存对象内存的起始偏移量(以字节为单位)存储在一个名为
offset的VkDeviceSize类型的变量中。 -
选择要复制到所选内存对象区域的数据大小。使用一个名为
data_size的VkDeviceSize类型的变量表示数据大小。 -
准备要复制到内存对象中的数据。设置一个指向数据开始的指针,并使用它来初始化一个名为
data的void*类型的变量。 -
创建一个名为
pointer的void*类型的变量。它将包含指向映射内存范围的指针。 -
使用
vkMapMemory( logical_device, memory_object, offset, data_size, 0, &local_pointer )调用映射内存。提供逻辑设备和内存对象的句柄、从开始处的偏移量以及我们想要映射的区域的大小(以字节为单位)、一个0值以及指向pointer变量的指针。 -
确保调用成功,通过检查返回值是否等于
VK_SUCCESS。 -
将准备好的数据复制到由
pointer变量指向的内存中。可以使用以下调用完成:std::memcpy( local_pointer, data, data_size )。 -
创建一个名为
memory_ranges的std::vector<VkMappedMemoryRange>类型的变量。对于每个修改的范围,向向量中添加一个元素,并使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE值用于sType -
nullptr值用于pNext -
memory的memory_object变量 -
每个范围的偏移量用于
offset -
每个范围的尺寸用于
size
-
-
通知驱动程序哪些内存部分已更改。通过调用
vkFlushMappedMemoryRanges( logical_device, static_cast<uint32_t>(memory_ranges.size()), &memory_ranges[0] )来完成此操作,并提供logical_device变量、修改的范围数量(memory_ranges向量中的元素数量)以及memory_ranges向量第一个元素的指针。 -
确保刷新成功,并且调用返回了
VK_SUCCESS值。 -
要取消映射内存,调用
vkUnmapMemory( logical_device, memory_object )。
它是如何工作的...
映射内存是将数据上传到 Vulkan 资源的最简单方法。在映射过程中,我们指定应该映射内存的哪一部分(从内存对象开始的偏移量和映射范围的尺寸):
VkResult result;
void * local_pointer;
result = vkMapMemory( logical_device, memory_object, offset, data_size, 0, &local_pointer );
if( VK_SUCCESS != result ) {
std::cout << "Could not map memory object." << std::endl;
return false;
}
映射为我们提供了请求的内存部分的指针。我们可以像在典型的 C++ 应用程序中使用其他指针一样使用这个指针。在写入或从此类内存读取数据方面没有限制。在这个菜谱中,我们从应用程序复制数据到内存对象:
std::memcpy( local_pointer, data, data_size );
当我们更新映射的内存范围时,我们需要通知驱动程序内存内容已被修改,或者上传的数据可能不会立即对提交到队列的其他操作可见。通知 CPU(主机)执行的内存数据修改操作称为刷新。为此,我们准备一个更新内存范围的列表,该列表不需要覆盖整个映射内存:
std::vector<VkMappedMemoryRange> memory_ranges = {
{
VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
nullptr,
memory_object,
offset,
data_size
}
};
vkFlushMappedMemoryRanges( logical_device, static_cast<uint32_t>(memory_ranges.size()), &memory_ranges[0] );
if( VK_SUCCESS != result ) {
std::cout << "Could not flush mapped memory." << std::endl;
return false;
}
在我们完成处理映射内存后,我们可以取消映射它。内存映射不应影响我们应用程序的性能,并且我们可以保留获取的指针,直到我们应用程序的整个生命周期。但是,在关闭应用程序和销毁所有资源之前,我们应该释放它(取消映射):
if( unmap ) {
vkUnmapMemory( logical_device, memory_object );
} else if( nullptr != pointer ) {
*pointer = local_pointer;
}
return true;
参见
本章以下配方:
-
分配并绑定内存对象到缓冲区
-
分配并绑定内存对象到图像
-
使用阶段缓冲区更新与设备本地内存绑定的缓冲区
-
使用阶段缓冲区更新与设备本地内存绑定的图像
-
释放内存对象
在缓冲区之间复制数据
在 Vulkan 中,上传数据到缓冲区不仅限于内存映射技术。可以在不同内存类型分配的内存对象之间复制数据。
如何做...
-
获取命令缓冲区的句柄。将其存储在名为
command_buffer的类型为VkCommandBuffer的变量中。确保命令缓冲区处于记录状态(参考第三章 开始命令缓冲区记录操作 的配方,命令缓冲区和同步)。 -
从将要复制数据的缓冲区中取出。使用名为
source_buffer的类型为VkBuffer的变量来表示此缓冲区。 -
从将要上传数据的缓冲区中取出。使用名为
destination_buffer的类型为VkBuffer的变量来表示此缓冲区。 -
创建一个名为
regions的类型为std::vector<VkBufferCopy>的变量。对于应该从中复制数据的每个内存区域,向regions向量中添加一个元素。在每个元素中,指定从源缓冲区中复制数据的内存偏移量、要复制到目标缓冲区的内存偏移量以及从给定区域复制的数据大小。 -
调用
vkCmdCopyBuffer(command_buffer, source_buffer, destination_buffer, static_cast<uint32_t>(regions.size()), ®ions[0]),其中使用command_buffer、source_buffer和destination_buffer变量、regions向量中的元素数量以及该向量第一个元素的指针。
它是如何工作的...
在缓冲区之间复制数据是更新给定资源内存内容的一种另一种方式。此操作需要记录到命令缓冲区中,如下所示:
if( regions.size() > 0 ) {
vkCmdCopyBuffer( command_buffer, source_buffer, destination_buffer, static_cast<uint32_t>(regions.size()), ®ions[0] );
}
为了获得最佳性能,在渲染期间使用的资源应该具有设备本地内存绑定。但是,我们不能映射此类内存。使用 vkCmdCopyBuffer() 函数,我们可以从具有主机可见内存绑定的另一个缓冲区将数据复制到此类缓冲区。此类内存可以直接从我们的应用程序映射和更新。
可以从其中复制数据的缓冲区必须使用 VK_BUFFER_USAGE_TRANSFER_SRC_BIT 用法创建。
我们必须使用 VK_BUFFER_USAGE_TRANSFER_DST_BIT 用法创建用于传输数据的缓冲区。
当我们想要将缓冲区用作传输操作的目标(我们想要将数据复制到缓冲区)时,我们应该设置一个内存屏障,告知驱动程序从现在开始,在缓冲区上执行的操作将由 VK_ACCESS_TRANSFER_WRITE_BIT 内存访问方案表示。在将数据复制到目标缓冲区并完成复制后,我们想要使用它来完成所需的目的,我们应该设置另一个内存屏障。这次,我们应该指定之前我们正在将数据传输到缓冲区(因此操作由 VK_ACCESS_TRANSFER_WRITE_BIT 内存访问类型表示),但在屏障之后,缓冲区将以不同的方式使用,使用另一个内存访问类型来表示对其执行的操作(参考 设置缓冲区内存屏障 菜谱)。
参见
本章中的以下菜谱:
-
创建缓冲区
-
设置缓冲区内存屏障
-
映射、更新和取消映射主机可见内存
-
使用阶段缓冲区更新设备本地内存绑定的缓冲区
从缓冲区复制数据到图像
对于图像,我们可以绑定从不同内存类型分配的内存对象。只有主机可见内存可以直接从我们的应用程序映射和更新。当我们想要更新使用设备本地内存的图像的内存时,我们需要从缓冲区复制数据。
如何做到这一点...
-
取出命令缓冲区的句柄,并将其存储在一个名为
command_buffer的VkCommandBuffer类型的变量中。确保命令缓冲区已经处于记录状态(参考第三章 命令缓冲区和同步 中的 开始命令缓冲区记录操作 菜谱)。 -
取出一个将要复制数据的缓冲区。将其句柄存储在一个名为
source_buffer的VkBuffer类型的变量中。 -
取出将要复制数据的图像。使用一个名为
destination_image的VkImage类型的变量来表示这个图像。 -
创建一个名为
image_layout的VkImageLayout类型的变量,用于存储图像的当前布局。 -
创建一个名为
regions的std::vector<VkBufferImageCopy>类型的变量。对于应该从中复制数据的每个内存区域,向regions向量添加一个元素。为每个元素的成员指定以下值:-
从缓冲区内存的起始位置偏移量,从该位置复制数据到
bufferOffset。 -
缓冲区中代表单行数据的长度,或者如果数据紧密打包(根据目标图像的大小)则为
0值,对于bufferRowLength。 -
缓冲区中存储的虚拟图像的高度或如果缓冲区数据紧密打包(根据目标图像的大小)则为
0值,对于bufferImageHeight。 -
使用以下值初始化
imageSubresource成员:-
图像的方面(颜色、深度或模板),对于
aspectMask。 -
要更新的 mipmap 级别的数量(索引),对于
mipLevel。 -
要更新的第一个数组层的编号,对于
baseArrayLayer。 -
将更新的数组层数量,对于
layerCount。
-
-
图像子区域初始偏移量(以 texels 为单位),该子区域应更新为
imageOffset。 -
图像的大小(维度),对于
imageExtent。
-
-
调用
vkCmdCopyBufferToImage(command_buffer, source_buffer, destination_image, image_layout, static_cast<uint32_t>(regions.size()), ®ions[0]),其中使用command_buffer、source_buffer、destination_image和image_layout变量,regions向量的元素数量,以及该向量第一个元素的指针。
它是如何工作的...
在命令缓冲区中通过命令执行缓冲区在缓冲区和图像之间复制数据,其中我们记录以下操作:
if( regions.size() > 0 ) {
vkCmdCopyBufferToImage( command_buffer, source_buffer, destination_image, image_layout, static_cast<uint32_t>(regions.size()), ®ions[0] );
}
我们需要知道图像数据在缓冲区内的布局,以便正确上传图像的内存。我们需要提供内存偏移量(从缓冲区内存的起始位置),数据行长度和数据在缓冲区中的高度。这允许驱动程序正确地寻址内存并将缓冲区的内容复制到图像中。我们还可以提供行长度和高度为零,这意味着缓冲区包含紧密打包的数据,并且与目标图像的维度相对应。
我们还需要提供有关数据传输操作目标的信息。这涉及到定义从图像原点(以 texels 为单位从左上角开始)的 x、y 和 z 维度的偏移量,数据将要复制到的 mipmap 级别和基本数组层,以及要更新的层数量。我们还需要指定目标图像的维度。
所有的前一个参数都是通过VkBufferImageCopy元素数组指定的。我们可以一次性提供多个区域,并复制非连续的内存范围。
在由物理设备暴露的多个不同内存类型的硬件架构上,建议仅使用设备本地内存来存储在渲染过程中使用的资源(我们应用程序的性能关键路径)。这种内存通常比同时可见于主机的内存更快。仅应为主机可见内存使用阶段资源,这些资源用于从 CPU 上传数据或向 CPU 下载数据(我们的应用程序)。
在只有一种既是设备本地又是主机可见的内存类型的架构上,我们不需要为数据上传而烦恼中间阶段资源。但是,所提出的方法仍然有效,并且可能在不同执行环境中统一应用程序的行为。这可能会使我们的应用程序维护更容易。
在这两种情况下,我们可以轻松地将阶段资源的内存进行映射,并在我们的应用程序中访问它。接下来,我们可以使用它将数据从设备本地内存传输到和从它,这(通常)不能进行映射。这是通过本食谱中描述的复制操作实现的。
可以从中复制数据的缓冲区必须使用具有 VK_BUFFER_USAGE_TRANSFER_SRC_BIT 用法的创建。
我们将数据传输到其中的图像必须使用具有 VK_BUFFER_USAGE_TRANSFER_DST_BIT 用法的创建。在传输操作之前,我们还需要将图像布局转换为 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL。
在我们能够将数据传输到图像之前,我们必须更改其内存布局。我们只能将数据复制到当前内存布局设置为 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 的图像。我们也可以使用 VK_IMAGE_LAYOUT_GENERAL 布局,但由于性能较低,不推荐这样做。
因此,在我们能够将数据复制到图像之前,我们应该设置一个内存屏障,该屏障将图像的内存访问类型从迄今为止发生的类型更改为 VK_ACCESS_TRANSFER_WRITE_BIT。屏障还应执行从当前布局到 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 布局的布局转换。在我们完成将数据复制到图像并希望将其用于其他目的之后,我们应该设置另一个内存屏障。这次,我们应该将内存访问类型从 VK_ACCESS_TRANSFER_WRITE_BIT 更改为与图像将用于的目的相对应的访问类型。我们还应该将图像的布局从 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 转换为与图像的下一个使用兼容的布局(参考 设置图像内存屏障 食谱)。如果没有这些屏障,数据传输操作可能无效,而且数据可能不会对图像上执行的其他操作可见。
如果用作数据源的数据缓冲区用于其他目的,我们还应该为它设置一个内存屏障,并在传输操作前后执行类似的内存访问更改。但是,由于缓冲区是数据源,我们在第一个屏障中设置 VK_ACCESS_TRANSFER_READ_BIT 访问类型。可以使用与更改图像参数相同的管道屏障来完成。有关更多详细信息,请参阅 设置缓冲区内存屏障 食谱。
参见
本章中的以下食谱:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
设置缓冲区内存屏障
-
创建图像
-
分配和绑定内存对象到图像
-
设置图像内存屏障
-
映射、更新和取消映射主机可见内存
-
从图像到缓冲区的数据复制
从图像到缓冲区的数据复制
在 Vulkan 中,我们不仅可以从缓冲区传输数据到图像,还可以反过来--我们可以从图像复制数据到缓冲区。不管绑定到它们上的内存对象的属性是什么。但是,数据复制操作是唯一一种更新无法映射的设备本地内存的方法。
如何操作...
-
拿到一个命令缓冲区的句柄并将其存储在名为
command_buffer的VkCommandBuffer类型变量中。确保命令缓冲区已经处于记录状态(参考第三章 命令缓冲区和同步 中的 开始命令缓冲区记录操作 菜谱 Chapter 3)。 -
拿到一个将要复制数据的图像。将其句柄存储在名为
source_image的VkImage类型变量中。 -
拿到源图像的当前内存布局并使用它来初始化一个名为
image_layout的VkImageLayout类型的变量。 -
拿到将要复制数据的缓冲区。在名为
destination_buffer的VkBuffer类型变量中准备其句柄。 -
创建一个名为
regions的std::vector<VkBufferImageCopy>类型的变量。对于应该从内存中复制数据的每个区域,向regions向量中添加一个元素。为每个元素的成员指定以下值:-
从缓冲区内存开始到要复制数据的偏移量,对于
bufferOffset。 -
将组成缓冲区中单行数据的长度或如果数据紧密打包(根据源图像的大小)则为
0值的bufferRowLength。 -
缓冲区中图像的高度(行数)或如果缓冲区的数据紧密打包(根据源图像的大小)则为
0值的bufferImageHeight。 -
使用以下值初始化
imageSubresource成员:-
aspectMask的图像的方面(颜色、深度或模板)。 -
将要复制数据的米普级别(索引)的编号,对于
mipLevel。 -
将要复制内容的第一层数组索引,对于
baseArrayLayer。 -
要复制的数组层数,对于
layerCount。
-
-
图像子区域的初始偏移量(以像素为单位),从该子区域读取并复制到缓冲区,对于
imageOffset。 -
imageExtent的图像大小。
-
-
调用
vkCmdCopyImageToBuffer( command_buffer, source_image, image_layout, destination_buffer, static_cast<uint32_t>(regions.size()), ®ions[0] ),其中使用command_buffer、source_image、image_layout和destination_buffer变量,regions向量中的元素数量,以及该向量第一个元素的指针。
它是如何工作的...
从图像到缓冲区的数据复制是一个记录到命令缓冲区的操作,如下所示:
if( regions.size() > 0 ) {
vkCmdCopyImageToBuffer( command_buffer, source_image, image_layout, destination_buffer, static_cast<uint32_t>(regions.size()), ®ions[0] );
}
命令缓冲区必须已经处于记录状态。
为了正确复制数据,我们需要提供多个参数来定义数据的源和目标。这些参数包括从图像原点(从左上角的 texels 开始)到 x、y 和 z 维度的偏移量,mipmap 级别以及从其中复制数据的基数组层,以及将成为数据源的层数。还需要图像尺寸。
对于目标缓冲区,我们指定内存偏移量(从缓冲区内存的开始处),数据行长度和缓冲区中的数据高度。我们也可以为行长度和高度提供零,这意味着复制到缓冲区的数据将紧密打包,并对应于源图像的尺寸。
之前指定的参数使用VkBufferImageCopy元素数组,类似于在从缓冲区复制数据到图像的说明中描述的从缓冲区到图像的数据复制。我们可以提供许多区域,并将非连续的内存范围作为一次复制操作的一部分。
我们从其中复制数据的图像必须使用VK_BUFFER_USAGE_TRANSFER_SRC_BIT用途创建。在传输操作之前,我们还需要将图像的布局转换为VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL。
可以复制数据的缓冲区必须使用VK_BUFFER_USAGE_TRANSFER_DST_BIT用途创建。
在我们可以从图像复制数据之前,我们应该设置内存屏障并将图像的布局从当前使用的布局转换为VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL布局。我们还应该将内存访问类型从迄今为止发生的类型更改为VK_ACCESS_TRANSFER_READ_BIT。在从图像复制数据完成后,如果它将用于其他目的,我们还应设置一个屏障。这次,我们应该将内存访问类型从VK_ACCESS_TRANSFER_READ_BIT更改为与图像将用于的目的相对应的访问类型。同时,我们应该将布局从VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL转换为与图像的下一个使用兼容的布局(参考设置图像内存屏障的说明)。如果没有这些屏障,不仅数据传输操作可能会以错误的方式进行,而且后续命令可能会在传输操作完成之前覆盖图像的内容。
应为缓冲区设置类似的屏障(但它们可以是同一管道屏障的一部分)。如果之前缓冲区用于其他目的,我们应在传输操作之前将内存访问更改为VK_ACCESS_TRANSFER_WRITE_BIT,如设置缓冲区内存屏障的说明中所述。
参见
本章以下内容:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
设置缓冲区内存屏障
-
创建图像
-
分配和绑定内存对象到图像
-
设置图像内存屏障
-
映射、更新和取消映射主机可见内存
-
从缓冲区到图像复制数据
使用阶段缓冲区更新具有设备本地内存绑定的缓冲区
阶段资源用于更新非主机可见的内存内容。这种内存无法映射,因此我们需要一个中间缓冲区,其内容可以轻松映射和更新,并且可以从其中传输数据。
如何做到这一点...
-
拿到一个名为
logical_device的VkDevice类型的变量中存储的逻辑设备的句柄。 -
准备要上传到目标缓冲区的数据。设置一个指向数据源开头的指针,并将其存储在名为
data的void*类型的变量中。数据的大小(以字节为单位)应使用名为data_size的VkDeviceSize类型的变量表示。 -
创建一个名为
staging_buffer的VkBuffer类型的变量。在其中,将存储阶段缓冲区的句柄。 -
创建一个足够大的缓冲区以容纳
data_size字节数。在创建缓冲区时指定VK_BUFFER_USAGE_TRANSFER_SRC_BIT用法。在创建过程中使用logical_device变量,并将创建的句柄存储在staging_buffer变量中(参考 创建缓冲区 菜谱)。 -
从创建
logical_device句柄的物理设备中获取句柄。使用物理设备的句柄初始化一个名为physical_device的VkPhysicalDevice类型的变量。 -
创建一个名为
memory_object的VkDeviceMemory类型的变量,该变量将用于为阶段缓冲区创建内存对象。 -
使用
physical_device、logical_device和staging_buffer变量分配一个内存对象。从具有VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT属性的内存类型中分配一个内存对象。将创建的句柄存储在memory_object变量中,并将其绑定到阶段缓冲区(参考 将内存对象分配和绑定到缓冲区 菜谱)。 -
使用
logical_device变量、0偏移量和data_size变量作为映射内存的大小来映射memory_object的内存。将数据从data指针复制到由获取的指针指向的内存。取消映射内存(参考 映射、更新和取消映射主机可见内存 菜谱)。 -
拿到分配的主命令缓冲区的句柄,并使用它来初始化一个名为
command_buffer的VkCommandBuffer类型的变量。 -
开始记录
command_buffer。提供一个VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT标志(参考第三章 开始命令缓冲区记录操作 菜谱,命令缓冲区和同步)。 -
拿到将要传输数据的缓冲区的句柄。确保它是以
VK_BUFFER_USAGE_TRANSFER_DST_BIT用法创建的。将其句柄存储在名为destination_buffer的VkBuffer类型的变量中。 -
在
command_buffer变量中为destination_buffer记录一个内存屏障。提供至今为止引用了destination_buffer的管道阶段,并为生成阶段使用VK_PIPELINE_STAGE_TRANSFER_BIT阶段,对于消费阶段使用。提供至今为止引用了该缓冲区的内存访问操作类型,并为新的内存访问类型使用VK_ACCESS_TRANSFER_WRITE_BIT值。忽略队列家族索引--为两个索引都提供VK_QUEUE_FAMILY_IGNORED(参考设置缓冲区内存屏障配方)。 -
创建一个名为
destination_offset的VkDeviceSize类型变量,并用数据应传输到目标缓冲区内存中的偏移值初始化它。 -
使用
command_buffer变量从staging_buffer复制数据到destination_buffer。为源偏移提供0值,为目的地偏移提供destination_offset变量,为要传输的数据大小提供data_size变量(参考在缓冲区之间复制数据配方)。 -
在
command_buffer变量中为destination_buffer记录另一个内存屏障。为生成阶段提供一个VK_PIPELINE_STAGE_TRANSFER_BIT值,并为destination_buffer将要从现在开始使用的管道阶段集合。使用VK_ACCESS_TRANSFER_WRITE_BIT值作为当前内存访问类型,以及适合缓冲区在内存传输后使用方式的值。为队列家族索引使用VK_QUEUE_FAMILY_IGNORED值(参考设置缓冲区内存屏障配方)。 -
结束
command_buffer的记录(参考第三章,命令缓冲区和同步中的结束命令缓冲区记录操作配方)。 -
获取将要执行传输操作的队列句柄,并将其存储在名为
queue的VkQueue类型变量中。 -
创建一个当传输操作完成时应发出信号的信号量列表。将它们的句柄存储在名为
signal_semaphores的std::vector<VkSemaphore>类型变量中。 -
创建一个名为
fence的VkFence类型变量。 -
使用
logical_device变量创建一个未发出信号的门。将创建的句柄存储在fence变量中(参考第三章,命令缓冲区和同步中的创建门配方)。 -
将
command_buffer提交到queue。提供一个来自signal_semaphores向量的信号信号量列表,以及用于信号的门变量(参考第三章,命令缓冲区和同步中的将命令缓冲区提交到队列配方)。 -
使用
logical_device和fence变量等待栅栏对象被信号。提供一个期望的超时值(参考第三章,命令缓冲区和同步中的等待栅栏食谱)。 -
销毁由
staging_buffer变量表示的缓冲区(参考销毁缓冲区食谱)。 -
释放由
memory_object变量表示的内存对象(参考释放内存对象食谱)。
它是如何工作的...
要使用预存资源进行传输操作,我们需要一个可以映射的内存缓冲区。我们可以使用现有的缓冲区或创建一个新的,如下所示:
VkBuffer staging_buffer;
if( !CreateBuffer( logical_device, data_size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, staging_buffer ) ) {
return false;
}
VkDeviceMemory memory_object;
if( !AllocateAndBindMemoryObjectToBuffer( physical_device, logical_device, staging_buffer, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, memory_object ) ) {
return false;
}
接下来,我们需要映射缓冲区的内存并更新其内容:
if( !MapUpdateAndUnmapHostVisibleMemory( logical_device, memory_object, 0, data_size, data, true, nullptr ) ) {
return false;
}
预存缓冲区准备就绪后,我们可以开始一个传输操作,将数据复制到目标缓冲区。首先,我们开始记录命令缓冲区操作,并为目标缓冲区设置一个内存屏障,以将其使用改变为目标,以便进行数据复制操作。我们不需要为预存缓冲区设置内存屏障。当我们映射和更新缓冲区的内存时,其内容对其他命令可见,因为我们开始命令缓冲区记录时设置了一个隐式屏障:
if( !BeginCommandBufferRecordingOperation( command_buffer, VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, nullptr ) ) {
return false;
}
SetBufferMemoryBarrier( command_buffer, destination_buffer_generating_stages, VK_PIPELINE_STAGE_TRANSFER_BIT, { { destination_buffer, destination_buffer_current_access, VK_ACCESS_TRANSFER_WRITE_BIT, VK_QUEUE_FAMILY_IGNORED, VK_QUEUE_FAMILY_IGNORED } } );
接下来,我们可以记录从预存资源到目标缓冲区的数据复制:
CopyDataBetweenBuffers( command_buffer, staging_buffer, destination_buffer, { { 0, destination_offset, data_size } } );
之后,我们需要为目标缓冲区设置第二个内存屏障。这次,我们将它的使用从复制操作的目标改为数据传输后缓冲区将用于的状态。我们还可以结束命令缓冲区记录:
SetBufferMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, destination_buffer_consuming_stages, { { destination_buffer, VK_ACCESS_TRANSFER_WRITE_BIT, destination_buffer_new_access, VK_QUEUE_FAMILY_IGNORED, VK_QUEUE_FAMILY_IGNORED } } );
if( !EndCommandBufferRecordingOperation( command_buffer ) ) {
return false;
}
接下来,我们创建一个栅栏并将命令缓冲区提交到队列,在那里它将被处理,并且数据传输将实际执行:
VkFence fence;
if( !CreateFence( logical_device, false, fence ) ) {
return false;
}
if( !SubmitCommandBuffersToQueue( queue, {}, { command_buffer }, signal_semaphores, fence ) ) {
return false;
}
如果我们不再想使用预存缓冲区,我们可以将其销毁。但是,我们无法这样做,直到提交到队列的命令不再使用预存缓冲区:这就是为什么我们需要一个栅栏。我们等待它,直到驱动程序信号处理提交的命令缓冲区的处理已完成。然后,我们可以安全地销毁一个预存缓冲区并释放与其绑定的内存对象:
if( !WaitForFences( logical_device, { fence }, VK_FALSE, 500000000 ) ) {
return false;
}
DestroyBuffer( logical_device, staging_buffer );
FreeMemoryObject( logical_device, memory_object );
return true;
在现实场景中,我们应该使用现有的缓冲区,尽可能多地将其作为预存缓冲区重用,以避免不必要的缓冲区创建和销毁操作。这样,我们也避免了等待栅栏。
参见
在第三章,命令缓冲区和同步中,查看以下食谱:
-
开始命令缓冲区记录操作
-
结束命令缓冲区记录操作
-
创建栅栏
-
等待栅栏
-
将命令缓冲区提交到队列
在本章中查看以下食谱:
-
创建缓冲区
-
为缓冲区分配和绑定内存对象
-
设置图像内存屏障
-
映射、更新和取消映射主机可见内存
-
从缓冲区复制数据到图像
-
使用具有设备本地内存绑定的暂存缓冲区更新图像
-
释放内存对象
-
销毁缓冲区
使用具有设备本地内存绑定的暂存缓冲区更新图像
暂存缓冲区不仅可以用于在缓冲区之间传输数据,还可以用于图像之间。在这里,我们将展示如何映射缓冲区的内存并将内容复制到所需的图像中。
如何操作...
-
创建一个足够大的暂存缓冲区以容纳要传输的全部数据。为该缓冲区指定
VK_BUFFER_USAGE_TRANSFER_SRC_BIT的使用方式,并将其句柄存储在名为staging_buffer的VkBuffer类型变量中。分配一个支持VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT属性的内存对象,并将其绑定到暂存缓冲区。将内存对象的句柄存储在名为memory_object的VkDeviceMemory类型变量中。映射内存并使用要传输到图像的数据更新其内容。取消映射内存。按照在使用暂存缓冲区更新具有设备本地内存绑定的缓冲区菜谱中更详细描述的步骤执行这些操作。 -
获取主命令缓冲区的句柄,并使用它初始化一个名为
command_buffer的VkCommandBuffer类型变量。 -
开始记录
command_buffer。提供一个VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT标志(参考第三章的开始命令缓冲区记录操作菜谱,命令缓冲区和同步)。 -
获取要传输数据的目标图像的句柄,并确保它是通过指定
VK_IMAGE_USAGE_TRANSFER_DST_BIT创建的。使用句柄初始化一个名为destination_image的VkImage类型变量。 -
在
command_buffer中记录一个图像内存屏障。指定图像迄今为止使用过的阶段,并使用VK_PIPELINE_STAGE_TRANSFER_BIT阶段作为消费阶段。使用destination_image变量,提供图像的当前访问权限,并使用VK_ACCESS_TRANSFER_WRITE_BIT值作为新访问权限。指定图像的当前布局,并使用VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL值作为新布局。提供图像的方面,但忽略队列家族索引--对于两者都使用VK_QUEUE_FAMILY_IGNORED值(参考设置图像内存屏障菜谱)。 -
在
command_buffer中,记录从staging_buffer到destination_image的数据传输操作。提供一个VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL值作为图像布局,0值作为缓冲区的偏移量,0值作为缓冲区的行长度,以及0值作为缓冲区的图像高度。指定应将数据复制到的图像内存区域,通过提供所需的 mipmap 级别、基本数组层索引和要更新的层数来实现。也提供图像的方面。指定图像的 x、y 和 z 坐标(以 texels 为单位)的偏移量以及图像的大小(参考从缓冲区复制数据到图像菜谱)。 -
将另一个图像内存屏障记录到
command_buffer中。这次,指定一个VK_PIPELINE_STAGE_TRANSFER_BIT值来生成阶段,并设置在数据传输后目标图像将使用的适当阶段。在屏障中,将图像的布局从VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL更改为适合新使用的值。为两个队列家族设置VK_QUEUE_FAMILY_IGNORED值,并提供图像的方面(参考设置图像内存屏障配方)。 -
结束命令缓冲区记录操作,创建一个未通知的栅栏并使用它,在提交命令缓冲区到队列时,与应通知的信号量一起使用。等待创建的栅栏被通知,销毁临时缓冲区,并按照使用临时缓冲区更新具有设备本地内存绑定的缓冲区配方释放其内存对象。
它是如何工作的...
这个配方与使用临时缓冲区更新具有设备本地内存绑定的缓冲区配方非常相似;这就是为什么只详细描述了差异。
首先,我们创建一个临时缓冲区,为其分配一个内存对象,将其绑定到缓冲区,并将其映射到从我们的应用程序上传数据到 GPU:
VkBuffer staging_buffer;
if( !CreateBuffer( logical_device, data_size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, staging_buffer ) ) {
return false;
}
VkDeviceMemory memory_object;
if( !AllocateAndBindMemoryObjectToBuffer( physical_device, logical_device, staging_buffer, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, memory_object ) ) {
return false;
}
if( !MapUpdateAndUnmapHostVisibleMemory( logical_device, memory_object, 0, data_size, data, true, nullptr ) ) {
return false;
}
接下来,我们开始命令缓冲区记录,并为目标图像设置一个屏障,使其可以作为数据传输的目标。我们还记录了数据传输操作:
if( !BeginCommandBufferRecordingOperation( command_buffer, VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, nullptr ) ) {
return false;
}
SetImageMemoryBarrier( command_buffer, destination_image_generating_stages, VK_PIPELINE_STAGE_TRANSFER_BIT,
{
{
destination_image,
destination_image_current_access,
VK_ACCESS_TRANSFER_WRITE_BIT,
destination_image_current_layout,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_QUEUE_FAMILY_IGNORED,
VK_QUEUE_FAMILY_IGNORED,
destination_image_aspect
} } );
CopyDataFromBufferToImage( command_buffer, staging_buffer, destination_image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
{
{
0,
0,
0,
destination_image_subresource,
destination_image_offset,
destination_image_size,
} } );
接下来,我们记录另一个屏障,将图像的使用从复制操作的目标更改为适用于图像将被用于下一个目的的屏障。我们还结束了命令缓冲区记录操作:
SetImageMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, destination_image_consuming_stages,
{
{
destination_image,
VK_ACCESS_TRANSFER_WRITE_BIT,
destination_image_new_access,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
destination_image_new_layout,
VK_QUEUE_FAMILY_IGNORED,
VK_QUEUE_FAMILY_IGNORED,
destination_image_aspect
} } );
if( !EndCommandBufferRecordingOperation( command_buffer ) ) {
return false;
}
之后,我们创建一个栅栏并将命令缓冲区提交到队列。然后我们在栅栏上等待,以知道何时可以安全地删除临时缓冲区和其内存对象。我们之后这样做:
VkFence fence;
if( !CreateFence( logical_device, false, fence ) ) {
return false;
}
if( !SubmitCommandBuffersToQueue( queue, {}, { command_buffer }, signal_semaphores, fence ) ) {
return false;
}
if( !WaitForFences( logical_device, { fence }, VK_FALSE, 500000000 ) ) {
return false;
}
DestroyBuffer( logical_device, staging_buffer );
FreeMemoryObject( logical_device, memory_object );
return true;
如果我们将现有的缓冲区作为临时资源重复使用,我们不需要栅栏,因为缓冲区将存活得更久,可能整个应用程序的生命周期。这样,我们可以避免频繁且不必要的缓冲区创建和删除,以及内存对象的分配和释放。
参见
在第三章,命令缓冲区和同步,查看以下配方:
-
开始命令缓冲区记录操作
-
结束命令缓冲区记录操作
-
创建一个栅栏
-
等待栅栏
-
将命令缓冲区提交到队列
在本章中查看以下配方:
-
创建一个缓冲区
-
分配和绑定内存对象到缓冲区
-
设置图像内存屏障
-
映射、更新和取消映射主机可见内存
-
从缓冲区复制数据到图像
-
使用临时缓冲区更新具有设备本地内存绑定的图像
-
释放内存对象
-
销毁一个缓冲区
销毁图像视图
当我们不再需要图像视图时,我们应该销毁它。
如何做到这一点...
-
取一个逻辑设备的句柄并将它存储在名为
logical_device的VkDevice类型变量中。 -
取一个存储在名为
image_view的VkImageView类型变量中的图像视图句柄。 -
调用
vkDestroyImageView(logical_device, image_view, nullptr)并提供逻辑设备的句柄、图像视图的句柄以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值赋给image_view变量。
它是如何工作的...
销毁图像视图需要我们使用其句柄以及创建图像视图的逻辑设备的句柄。操作如下:
if( VK_NULL_HANDLE != image_view ) {
vkDestroyImageView( logical_device, image_view, nullptr );
image_view = VK_NULL_HANDLE;
}
首先,我们检查句柄是否不为空。我们不需要这样做--销毁一个空句柄会被静默忽略。但跳过不必要的函数调用是个好习惯。接下来,我们销毁图像视图并将一个空句柄赋值给存储句柄的变量。
相关内容
- 在本章中创建一个图像视图 菜单
销毁图像
不再使用的图像应该被销毁以释放其资源。
如何做...
-
取一个逻辑设备并将它的句柄存储在名为
logical_device的VkDevice类型变量中。 -
使用图像的句柄并使用它初始化一个名为
image的VkImage类型变量。 -
调用
vkDestroyImage(logical_device, image, nullptr)。提供逻辑设备的句柄、图像的句柄以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值赋给image变量。
它是如何工作的...
图像通过 vkDestroyImage() 函数的单次调用被销毁。为此,我们提供逻辑设备的句柄、图像的句柄以及一个 nullptr 值,如下所示:
if( VK_NULL_HANDLE != image ) {
vkDestroyImage( logical_device, image, nullptr );
image = VK_NULL_HANDLE;
}
我们还通过检查图像的句柄是否不为空来尝试避免不必要的函数调用。
相关内容
- 在本章中创建一个图像 菜单
销毁缓冲区视图
当我们不再需要缓冲区视图时,我们应该销毁它。
如何做...
-
取一个逻辑设备并将它的句柄存储在名为
logical_device的VkDevice类型变量中。 -
使用缓冲区的视图句柄并使用它初始化一个名为
buffer_view的VkBufferView类型变量。 -
调用
vkDestroyBufferView(logical_device, buffer_view, nullptr)。提供逻辑设备的句柄、缓冲区视图的句柄以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值赋给buffer_view变量。
它是如何工作的...
缓冲区视图通过 vkDestroyBufferView() 函数被销毁:
if( VK_NULL_HANDLE != buffer_view ) {
vkDestroyBufferView( logical_device, buffer_view, nullptr );
buffer_view = VK_NULL_HANDLE;
}
在调用缓冲区视图销毁函数之前,我们检查缓冲区视图的句柄是否不为空,以避免不必要的函数调用。
相关内容
- 在本章中创建一个缓冲区视图 菜单
释放内存对象
在 Vulkan 中,当我们创建资源时,我们稍后会销毁它们。另一方面,代表不同内存对象或池的资源被分配和释放。绑定到图像和缓冲区的内存对象也被释放。我们应该在我们不再需要它们时释放它们。
如何操作...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型变量中。 -
获取名为
memory_object的VkDeviceMemory类型变量,其中存储了内存对象的句柄。 -
调用
vkFreeMemory(logical_device, memory_object, nullptr)。使用逻辑设备的句柄、内存对象的句柄和一个nullptr值。 -
由于安全原因,将
VK_NULL_HANDLE值分配给memory_object变量。
工作原理...
内存对象可以在使用它们的资源被销毁之前被释放。但我们不能再使用这些资源了,我们只能销毁它们。一般来说,我们不能将一个内存对象绑定到资源,释放它,然后再将另一个内存对象绑定到同一个资源。
要释放内存对象,我们可以编写以下代码:
if( VK_NULL_HANDLE != memory_object ) {
vkFreeMemory( logical_device, memory_object, nullptr );
memory_object = VK_NULL_HANDLE;
}
内存对象必须是从由logical_device变量表示的逻辑设备中分配的。
参见
本章中的以下配方:
-
分配和绑定内存对象到缓冲区
-
分配和绑定内存对象到图像
销毁缓冲区
当一个缓冲区不再被使用时,我们应该销毁它。
如何操作...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型变量中。 -
将缓冲区的句柄存储在名为
buffer的VkBuffer类型变量中。 -
调用
vkDestroyBuffer(logical_device, buffer, nullptr)并提供逻辑设备的句柄、缓冲区的句柄和一个nullptr值。 -
由于安全原因,将
VK_NULL_HANDLE值分配给buffer变量。
工作原理...
使用vkDestroyBuffer()函数销毁缓冲区,如下所示:
if( VK_NULL_HANDLE != buffer ) {
vkDestroyBuffer( logical_device, buffer, nullptr );
buffer = VK_NULL_HANDLE;
}
logical_device是一个表示创建缓冲区的逻辑设备的变量。当我们销毁一个缓冲区时,我们将一个空句柄分配给表示此缓冲区的变量,这样我们就不会尝试两次销毁相同的资源。
参见
- 本章中的创建缓冲区视图配方
第五章:描述符集
在本章中,我们将介绍以下食谱:
-
创建采样器
-
创建采样图像
-
创建组合图像采样器
-
创建存储镜像
-
创建统一纹理缓冲区
-
创建存储纹理缓冲区
-
创建统一缓冲区
-
创建存储缓冲区
-
创建输入附加
-
创建描述符集布局
-
创建描述符池
-
分配描述符集
-
更新描述符集
-
绑定描述符集
-
使用纹理和统一缓冲区创建描述符
-
释放描述符集
-
重置描述符池
-
销毁描述符池
-
销毁描述符集布局
-
销毁采样器
简介
在现代计算机图形学中,大多数图像数据(如顶点、像素或片段)的渲染和处理都是通过可编程管道和着色器完成的。为了正确运行并生成适当的结果,着色器需要访问额外的数据源,如纹理、采样器、缓冲区或统一变量。在 Vulkan 中,这些通过描述符集提供。
描述符是表示着色器资源的不可见数据结构。它们被组织成组或集,其内容由描述符集布局指定。为了向着色器提供资源,我们将描述符集绑定到管道上。我们可以一次绑定多个集。要从着色器内部访问资源,我们需要指定从哪个集以及从集内的哪个位置(称为绑定)获取给定的资源。
在本章中,我们将学习各种描述符类型。我们将了解如何准备资源(采样器、缓冲区和图像),以便它们可以在着色器中使用。我们还将探讨如何设置应用程序和着色器之间的接口,并在着色器中使用资源。
创建采样器
采样器定义了一组参数,这些参数控制着色器内部如何加载图像数据(采样)。这些参数包括地址计算(即,环绕或重复)、过滤(线性或最近)或使用米普映射。要从着色器内部使用采样器,我们首先需要创建它们。
如何操作...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型变量中。 -
创建一个名为
sampler_create_info的VkSamplerCreateInfo类型变量,并为其成员使用以下值:-
sType的值为VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO -
pNext的值为nullptr -
flags的值为0 -
为
magFilter和minFilter指定的所需放大和缩小过滤模式(VK_FILTER_NEAREST或VK_FILTER_LINEAR) -
为
mipmapMode选择的米普映射过滤模式(VK_SAMPLER_MIPMAP_MODE_NEAREST或VK_SAMPLER_MIPMAP_MODE_LINEAR) -
用于图像 U、V 和 W 坐标超出
0.0 - 1.0范围(VK_SAMPLER_ADDRESS_MODE_REPEAT、VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT、VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE、VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER或VK_SAMPLER_ADDRESS_MODE_MIRROR_CLAMP_TO_EDGE)的选定图像寻址模式,对于addressModeU、addressModeV和addressModeW -
要添加到 mipmap 细节级别计算的期望值,对于
mipLodBias -
如果应启用各向异性过滤,则为
true值,否则对于anisotropyEnable为false -
对于
maxAnisotropy的各向异性最大值 -
如果在图像查找期间应启用与参考值的比较,则为
true值,否则对于compareEnable为false -
用于
compareOp的选定比较函数应用于获取的数据(VK_COMPARE_OP_NEVER、VK_COMPARE_OP_LESS、VK_COMPARE_OP_EQUAL、VK_COMPARE_OP_LESS_OR_EQUAL、VK_COMPARE_OP_GREATER、VK_COMPARE_OP_NOT_EQUAL、VK_COMPARE_OP_GREATER_OR_EQUAL或VK_COMPARE_OP_ALWAYS) -
用于
minLod和maxLod的将计算出的图像的细节级别值(mipmap 编号)夹断的最小和最大值 -
用于
borderColor的预定义边界颜色值之一(VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK、VK_BORDER_COLOR_INT_TRANSPARENT_BLACK、VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK、VK_BORDER_COLOR_INT_OPAQUE_BLACK、VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE或VK_BORDER_COLOR_INT_OPAQUE_WHITE) -
如果寻址应使用图像的维度,则为
true值,如果寻址应使用归一化坐标(在0.0-1.0范围内),则为false值,对于unnormalizedCoordinates
-
-
创建一个名为
sampler的VkSampler类型变量,其中将存储创建的采样器。 -
调用
vkCreateSampler(logical_device, &sampler_create_info, nullptr, &sampler),并提供logical_device变量、sampler_create_info变量的指针、nullptr值和sampler变量的指针。 -
通过检查返回值是否等于
VK_SUCCESS来确保调用成功。
它是如何工作的...
采样器控制着色器内读取图像的方式。它们可以单独使用或与采样图像结合使用。
采样器用于VK_DESCRIPTOR_TYPE_SAMPLER描述符类型。
使用类型为VkSamplerCreateInfo的变量指定采样参数,如下所示:
VkSamplerCreateInfo sampler_create_info = {
VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
nullptr,
0,
mag_filter,
min_filter,
mipmap_mode,
u_address_mode,
v_address_mode,
w_address_mode,
lod_bias,
anisotropy_enable,
max_anisotropy,
compare_enable,
compare_operator,
min_lod,
max_lod,
border_color,
unnormalized_coords
};
然后将此变量提供给创建采样器的函数:
VkResult result = vkCreateSampler( logical_device, &sampler_create_info, nullptr, &sampler );
if( VK_SUCCESS != result ) {
std::cout << "Could not create sampler." << std::endl;
return false;
}
return true;
要在着色器中指定采样器,我们需要创建一个带有sampler关键字的统一变量。
一个使用采样器的 GLSL 代码示例,从中可以生成 SPIR-V 汇编,可能看起来像这样:
layout (set=m, binding=n) uniform sampler <variable name>;
参见
-
请参阅本章中的以下配方:
- 销毁采样器
创建一个采样图像
采样图像用于在着色器内部读取图像(纹理)中的数据。通常,它们与采样器一起使用。为了能够将图像用作采样图像,它必须使用VK_IMAGE_USAGE_SAMPLED_BIT使用创建。
如何做到这一点...
-
获取存储在名为
physical_device的VkPhysicalDevice类型变量中的物理设备的句柄。 -
选择用于图像的格式。使用所选的图像格式初始化一个名为
format的VkFormat类型变量。 -
创建一个名为
format_properties的VkFormatProperties类型变量。 -
调用
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties ),为它提供physical_device变量、format变量和format_properties变量的指针。 -
确保所选的图像格式适合采样图像。通过检查
format_properties变量中optimalTilingFeatures成员的VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT位是否设置来完成此操作。 -
如果采样图像将被线性过滤或如果其 mipmap 将被线性过滤,请确保所选格式适合线性过滤的采样图像。通过检查
format_properties变量中optimalTilingFeatures成员的VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT位是否设置来完成此操作。 -
从存储在
physical_device变量中的句柄获取逻辑设备的句柄,并使用它来初始化一个名为logical_device的VkDevice类型变量。 -
使用
logical_device和format变量创建一个图像,并选择其余的图像参数。在创建图像时,不要忘记提供VK_IMAGE_USAGE_SAMPLED_BIT使用。将图像的句柄存储在一个名为sampled_image的VkImage类型变量中(参考第四章,资源和内存中的创建图像配方)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象(或使用现有内存对象的范围),并将其绑定到创建的图像上(参考第四章,资源和内存中的分配和绑定内存对象到图像配方)。 -
使用
logical_device、sampled_image和format变量创建一个图像视图,并选择其余的视图参数。将图像视图的句柄存储在一个名为sampled_image_view的VkImageView类型变量中(参考第四章,资源和内存中的创建图像视图配方)。
它是如何工作的...
采样图像用作着色器内部图像数据(纹理)的来源。要从图像中获取数据,通常需要一个采样器对象,该对象定义了数据应该如何读取(参考创建采样器配方)。
采样图像用于VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE描述符类型。
在着色器内部,我们可以使用多个采样器以不同的方式从同一图像中读取数据。我们也可以使用相同的采样器与多个图像一起使用。但在某些平台上,使用组合图像采样器对象可能更优,这些对象将采样器和采样图像组合在一个对象中。
并非所有图像格式都支持用于采样图像;这取决于应用程序执行的平台。但有一组强制性的格式,始终可以用于采样图像和线性过滤的采样图像。以下是一些此类格式的示例(但不限于):
-
VK_FORMAT_B4G4R4A4_UNORM_PACK16 -
VK_FORMAT_R5G6B5_UNORM_PACK16 -
VK_FORMAT_A1R5G5B5_UNORM_PACK16 -
VK_FORMAT_R8_UNORM和VK_FORMAT_R8_SNORM -
VK_FORMAT_R8G8_UNORM和VK_FORMAT_R8G8_SNORM -
VK_FORMAT_R8G8B8A8_UNORM,VK_FORMAT_R8G8B8A8_SNORM, 和VK_FORMAT_R8G8B8A8_SRGB -
VK_FORMAT_B8G8R8A8_UNORM和VK_FORMAT_B8G8R8A8_SRGB -
VK_FORMAT_A8B8G8R8_UNORM_PACK32,VK_FORMAT_A8B8G8R8_SNORM_PACK32, 和VK_FORMAT_A8B8G8R8_SRGB_PACK32 -
VK_FORMAT_A2B10G10R10_UNORM_PACK32 -
VK_FORMAT_R16_SFLOAT -
VK_FORMAT_R16G16_SFLOAT -
VK_FORMAT_R16G16B16A16_SFLOAT -
VK_FORMAT_B10G11R11_UFLOAT_PACK32 -
VK_FORMAT_E5B9G9R9_UFLOAT_PACK32
如果我们想使用一些不太典型的格式,我们需要检查它是否可以用于采样图像。这可以通过以下方式完成:
VkFormatProperties format_properties;
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties );
if( !(format_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) ) {
std::cout << "Provided format is not supported for a sampled image." << std::endl;
return false;
}
if( linear_filtering &&
!(format_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT) ) {
std::cout << "Provided format is not supported for a linear image filtering." << std::endl;
return false;
}
如果我们确定所选格式适合我们的需求,我们可以创建一个图像、为其创建一个内存对象以及一个图像视图(在 Vulkan 中,大多数情况下用图像视图表示图像)。在创建图像时,我们需要指定VK_IMAGE_USAGE_SAMPLED_BIT使用:
if( !CreateImage( logical_device, type, format, size, num_mipmaps, num_layers, VK_SAMPLE_COUNT_1_BIT, usage | VK_IMAGE_USAGE_SAMPLED_BIT, false, sampled_image ) ) {
return false;
}
if( !AllocateAndBindMemoryObjectToImage( physical_device, logical_device, sampled_image, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
if( !CreateImageView( logical_device, sampled_image, view_type, format, aspect, sampled_image_view ) ) {
return false;
}
return true;
当我们想在着色器内部使用图像作为采样图像之前,我们需要将图像的布局转换为VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。
为了在着色器中创建一个表示采样图像的统一变量,我们需要使用一个带有适当维度的texture关键字(可能带有前缀)。
一个 GLSL 代码示例,从中可以生成 SPIR-V 汇编代码,该代码使用采样图像,可能看起来像这样:
layout (set=m, binding=n) uniform texture2D <variable name>;
参见
-
在第四章,资源和内存,查看以下食谱:
-
创建一个图像
-
分配和绑定内存对象到图像
-
创建图像视图
-
销毁图像视图
-
销毁图像
-
释放内存对象
-
-
在本章中,查看以下食谱:
- 创建采样器
创建组合图像采样器
从应用程序(API)的角度来看,采样器和采样图像始终是单独的对象。但在着色器内部,它们可以组合成一个对象。在某些平台上,在着色器内部从组合图像采样器中采样可能比使用单独的采样器和采样图像更优。
如何做到这一点...
-
创建一个采样器对象并将它的句柄存储在名为
sampler的VkSampler类型变量中(参考创建采样器食谱)。 -
创建一个采样图像。将创建的图像句柄存储在名为
sampled_image的VkImage类型变量中。为采样图像创建一个适当的视图,并将它的句柄存储在名为sampled_image_view的VkImageView类型变量中(参考 创建一个采样图像 的配方)。
它是如何工作的...
在我们的应用程序中,组合图像采样器与普通采样器和采样图像的创建方式相同。它们在着色器内部的使用方式不同。
组合图像采样器可以绑定到 VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER 类型的描述符。
以下代码使用 创建一个采样器 和 创建一个采样图像 的配方来创建必要的对象:
if( !CreateSampler( logical_device, mag_filter, min_filter, mipmap_mode, u_address_mode, v_address_mode, w_address_mode, lod_bias, anisotropy_enable, max_anisotropy, compare_enable, compare_operator, min_lod, max_lod, border_color, unnormalized_coords, sampler ) ) {
return false;
}
bool linear_filtering = (mag_filter == VK_FILTER_LINEAR) || (min_filter == VK_FILTER_LINEAR) || (mipmap_mode == VK_SAMPLER_MIPMAP_MODE_LINEAR);
if( !CreateSampledImage( physical_device, logical_device, type, format, size, num_mipmaps, num_layers, usage, view_type, aspect, linear_filtering, sampled_image, sampled_image_view ) ) {
return false;
}
return true;
差异在于着色器内部。
要在 GLSL 着色器内部创建表示组合图像采样器的变量,我们需要使用一个 sampler 关键字(可能带有前缀)并指定适当的维度。
不要混淆采样器和组合图像采样器--两者在着色器内部都使用 sampler 关键字,但组合图像采样器还额外指定了维度,如下例所示:
layout (set=m, binding=n) uniform sampler2D <variable name>;
组合图像采样器需要单独处理,因为使用它们的应用程序在某些平台上可能会有更好的性能。因此,如果没有特定原因需要使用单独的采样器和采样图像,我们应该尝试将它们组合成单个对象。
参见
在 第四章,资源和内存 中,查看以下配方:
-
创建一个图像
-
分配和绑定内存对象到图像
-
创建一个图像视图
-
销毁一个图像视图
-
销毁一个图像
-
释放内存对象
在本章中查看以下配方:
-
创建一个采样器
-
创建一个采样图像
-
销毁一个采样器
创建一个存储图像
存储图像允许我们从绑定到管道的图像中加载(未过滤的)数据。但更重要的是,它们还允许我们在图像中存储着色器中的数据。此类图像必须使用指定了 VK_IMAGE_USAGE_STORAGE_BIT 使用标志来创建。
如何做到...
-
获取物理设备的句柄并将其存储在名为
physical_device的VkPhysicalDevice类型变量中。 -
选择用于存储图像的格式。使用所选格式初始化名为
format的VkFormat类型变量。 -
创建一个名为
format_properties的VkFormatProperties类型变量。 -
调用
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties )并提供physical_device变量、format变量和format_properties变量的指针。 -
检查所选图像格式是否适合存储图像。通过检查
format_properties变量的optimalTilingFeatures成员的VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT位是否设置来完成此操作。 -
如果将在存储图像上执行原子操作,请确保所选格式支持这些操作。通过检查
format_properties变量的optimalTilingFeatures成员的VK_FORMAT_FEATURE_STORAGE_IMAGE_ATOMIC_BIT位是否设置来完成此操作。 -
获取由
physical_device创建的逻辑设备的句柄,并使用它来初始化一个名为logical_device的VkDevice类型变量。 -
使用
logical_device和format变量创建一个图像,并选择其余的图像参数。确保在创建图像时指定VK_IMAGE_USAGE_STORAGE_BIT用法。将创建的句柄存储在一个名为storage_image的VkImage类型变量中(参考第四章,资源和内存中的创建图像配方)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象(或使用现有内存对象的一个范围)并将其绑定到图像(参考第四章,资源和内存中的将内存对象分配和绑定到图像配方)。 -
使用
logical_device、storage_image和format变量创建一个图像视图,并选择其余的视图参数。将图像视图的句柄存储在一个名为storage_image_view的VkImageView类型变量中(参考第四章,资源和内存中的创建图像视图配方)。
它是如何工作的...
当我们想在着色器内部存储图像数据时,我们需要使用存储图像。我们也可以从这样的图像中加载数据,但这些加载是不过滤的(我们不能为存储图像使用采样器)。
存储图像对应于 VK_DESCRIPTOR_TYPE_STORAGE_IMAGE 类型的描述符。
存储图像是以 VK_IMAGE_USAGE_STORAGE_BIT 用法创建的。我们也不能忘记指定适当的格式。并非所有格式都可以始终用于存储图像。这取决于我们的应用程序执行的平台。但是,有一个必需格式的列表,所有 Vulkan 驱动程序都必须支持。它包括(但不限于)以下格式:
-
VK_FORMAT_R8G8B8A8_UNORM,VK_FORMAT_R8G8B8A8_SNORM,VK_FORMAT_R8G8B8A8_UINT, 和VK_FORMAT_R8G8B8A8_SINT -
VK_FORMAT_R16G16B16A16_UINT,VK_FORMAT_R16G16B16A16_SINT和VK_FORMAT_R16G16B16A16_SFLOAT -
VK_FORMAT_R32_UINT,VK_FORMAT_R32_SINT和VK_FORMAT_R32_SFLOAT -
VK_FORMAT_R32G32_UINT,VK_FORMAT_R32G32_SINT和VK_FORMAT_R32G32_SFLOAT -
VK_FORMAT_R32G32B32A32_UINT,VK_FORMAT_R32G32B32A32_SINT和VK_FORMAT_R32G32B32A32_SFLOAT
如果我们想在存储图像上执行原子操作,则必需格式的列表要短得多,并且仅包括以下几种:
-
VK_FORMAT_R32_UINT -
VK_FORMAT_R32_SINT
如果存储图像需要其他格式,或者如果我们需要使用其他格式在存储图像上执行原子操作,我们必须检查所选格式是否在应用程序执行的平台上是受支持的。这可以通过以下代码完成:
VkFormatProperties format_properties;
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties );
if( !(format_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT) ) {
std::cout << "Provided format is not supported for a storage image." << std::endl;
return false;
}
if( atomic_operations &&
!(format_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_STORAGE_IMAGE_ATOMIC_BIT) ) {
std::cout << "Provided format is not supported for atomic operations on storage images." << std::endl;
return false;
}
如果格式受支持,我们像往常一样创建图像,但我们需要指定 VK_IMAGE_USAGE_STORAGE_BIT 使用方式。图像准备好后,我们需要创建一个内存对象,将其绑定到图像,并且我们还需要一个图像视图。这些操作可以像这样执行:
if( !CreateImage( logical_device, type, format, size, num_mipmaps, num_layers, VK_SAMPLE_COUNT_1_BIT, usage | VK_IMAGE_USAGE_STORAGE_BIT, false, storage_image ) ) {
return false;
}
if( !AllocateAndBindMemoryObjectToImage( physical_device, logical_device, storage_image, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
if( !CreateImageView( logical_device, storage_image, view_type, format, aspect, storage_image_view ) ) {
return false;
}
return true;
在我们能够从着色器中加载或存储存储图像中的数据之前,我们必须执行到 VK_IMAGE_LAYOUT_GENERAL 布局的转换。这是唯一支持这些操作的布局。
在 GLSL 着色器内部,存储图像使用 image 关键字(可能带有前缀)和适当的维度来指定。我们还需要在 layout 限定符内提供图像的格式。
下面提供了一个在 GLSL 着色器中定义存储图像的示例:
layout (set=m, binding=n, r32f) uniform image2D <variable name>;
参见
在 第四章,资源和内存 中,查看以下食谱:
-
创建图像
-
将内存对象分配和绑定到图像
-
创建图像视图
-
销毁图像视图
-
销毁图像
-
释放内存对象
创建均匀的 texel 缓冲区
均匀的 texel 缓冲区允许我们以类似于从图像读取数据的方式读取数据--它们的内 容不是解释为单个(标量)值的数组,而是解释为具有一个、两个、三个或四个组件的格式化像素(texel)。但是,通过这样的缓冲区,我们可以访问比通过常规图像提供的数据大得多的数据。
当我们想要将缓冲区用作均匀的 texel 缓冲区时,我们需要指定 VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT 使用方式。
如何做到...
-
将物理设备的句柄存储在名为
physical_device的VkPhysicalDevice类型的变量中。 -
选择一个格式,其中将存储缓冲区数据。使用该格式初始化一个名为
format的VkFormat类型的变量。 -
创建一个名为
format_properties的VkFormatProperties类型的变量。 -
调用
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties )并提供物理设备的句柄、format变量以及format_properties变量的指针。 -
通过检查
format_properties变量的bufferFeatures成员是否设置了VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT位,确保所选格式适合均匀的 texel 缓冲区。 -
从所选物理设备的句柄创建一个逻辑设备。将其存储在名为
logical_device的VkDevice类型的变量中。 -
创建一个名为
uniform_texel_buffer的VkBuffer类型的变量。 -
使用
logical_device变量创建一个具有所需大小和用途的缓冲区。不要忘记在创建缓冲区时包括VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT用途。将创建的句柄存储在uniform_texel_buffer变量中(参考第四章中的创建缓冲区配方,资源和内存)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象(或使用现有的一个)并将其绑定到缓冲区。如果分配了新的内存对象,将其存储在名为memory_object的VkDeviceMemory类型变量中(参考第四章中的分配和绑定内存对象到缓冲区配方,资源和内存)。 -
使用
logical_device、uniform_texel_buffer和format变量以及所需的偏移量和内存范围创建一个缓冲区视图。将结果句柄存储在名为uniform_texel_buffer_view的VkBufferView类型变量中(参考第四章中的创建缓冲区视图配方,资源和内存)。
它是如何工作的...
均匀的纹理缓冲区允许我们提供解释为一维图像的数据。但这个数据可能比典型的图像大得多。Vulkan 规范要求每个驱动程序至少支持 4,096 个纹理元素的 1D 图像。但对于纹理缓冲区,这个最小要求限制增加到 65,536 个元素。
均匀的纹理缓冲区绑定到VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER类型的描述符。
均匀的纹理缓冲区使用VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT用途创建。但除此之外,我们还需要选择一个合适的格式。并非所有格式都与此类缓冲区兼容。可以与均匀纹理缓冲区一起使用的强制格式列表包括(但不限于)以下格式:
-
VK_FORMAT_R8_UNORM、VK_FORMAT_R8_SNORM、VK_FORMAT_R8_UINT和VK_FORMAT_R8_SINT -
VK_FORMAT_R8G8_UNORM、VK_FORMAT_R8G8_SNORM、VK_FORMAT_R8G8_UINT和VK_FORMAT_R8G8_SINT -
VK_FORMAT_R8G8B8A8_UNORM、VK_FORMAT_R8G8B8A8_SNORM、VK_FORMAT_R8G8B8A8_UINT和VK_FORMAT_R8G8B8A8_SINT -
VK_FORMAT_B8G8R8A8_UNORM -
VK_FORMAT_A8B8G8R8_UNORM_PACK32、VK_FORMAT_A8B8G8R8_SNORM_PACK32、VK_FORMAT_A8B8G8R8_UINT_PACK32和VK_FORMAT_A8B8G8R8_SINT_PACK32 -
VK_FORMAT_A2B10G10R10_UNORM_PACK32和VK_FORMAT_A2B10G10R10_UINT_PACK32 -
VK_FORMAT_R16_UINT、VK_FORMAT_R16_SINT和VK_FORMAT_R16_SFLOAT -
VK_FORMAT_R16G16_UINT、VK_FORMAT_R16G16_SINT和VK_FORMAT_R16G16_SFLOAT -
VK_FORMAT_R16G16B16A16_UINT、VK_FORMAT_R16G16B16A16_SINT和VK_FORMAT_R16G16B16A16_SFLOAT -
VK_FORMAT_R32_UINT、VK_FORMAT_R32_SINT和VK_FORMAT_R32_SFLOAT -
VK_FORMAT_R32G32_UINT、VK_FORMAT_R32G32_SINT和VK_FORMAT_R32G32_SFLOAT -
VK_FORMAT_R32G32B32A32_UINT、VK_FORMAT_R32G32B32A32_SINT和VK_FORMAT_R32G32B32A32_SFLOAT -
VK_FORMAT_B10G11R11_UFLOAT_PACK32
要检查是否可以使用其他格式与均匀纹理缓冲区一起使用,我们需要准备以下代码:
VkFormatProperties format_properties;
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties );
if( !(format_properties.bufferFeatures & VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT) ) {
std::cout << "Provided format is not supported for a uniform texel buffer." << std::endl;
return false;
}
如果所选格式适合我们的需求,我们可以创建一个缓冲区,为它分配一个内存对象,并将其绑定到缓冲区。非常重要的一点是,我们还需要创建一个缓冲视图:
if( !CreateBuffer( logical_device, size, usage | VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT, uniform_texel_buffer ) ) {
return false;
}
if( !AllocateAndBindMemoryObjectToBuffer( physical_device, logical_device, uniform_texel_buffer, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
if( !CreateBufferView( logical_device, uniform_texel_buffer, format, 0, VK_WHOLE_SIZE, uniform_texel_buffer_view ) ) {
return false;
}
return true;
从 API 的角度来看,缓冲区内容的结构无关紧要。但在均匀纹理缓冲区的情况下,我们需要指定一个数据格式,以便着色器能够以适当的方式解释缓冲区的内容。这就是为什么需要缓冲视图的原因。
在 GLSL 着色器中,均匀纹理缓冲区通过samplerBuffer类型的变量(可能带有前缀)定义。
以下是一个在 GLSL 着色器中定义的均匀纹理缓冲区变量的示例:
layout (set=m, binding=n) uniform samplerBuffer <variable name>;
参见
在第四章,资源和内存中,查看以下食谱:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
创建缓冲视图
-
销毁缓冲视图
-
释放内存对象
-
销毁缓冲
创建存储纹理缓冲区
存储纹理缓冲区,就像均匀纹理缓冲区一样,是一种向着色器提供大量类似图像数据的方式。但它们还允许我们在其中存储数据并对它们执行原子操作。为此,我们需要创建一个具有VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT的缓冲区。
如何操作...
-
获取物理设备的句柄。将其存储在名为
physical_device的VkPhysicalDevice类型变量中。 -
为纹理缓冲区的数据选择一个格式,并使用它初始化一个名为
format的VkFormat类型变量。 -
创建一个名为
format_properties的VkFormatProperties类型变量。 -
调用
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties )并提供所选物理设备的句柄、format变量和一个指向format_properties变量的指针。 -
通过检查
format_properties变量的bufferFeatures成员是否设置了VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT位,确保所选格式适用于存储纹理缓冲区。 -
如果将在创建的存储纹理缓冲区上执行原子操作,请确保所选格式也适用于原子操作。为此,检查
format_properties变量的bufferFeatures成员是否设置了VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_ATOMIC_BIT位。 -
从所选物理设备的句柄创建一个逻辑设备句柄。将其存储在名为
logical_device的VkDevice类型变量中。 -
创建一个名为
storage_texel_buffer的VkBuffer类型变量。 -
使用
logical_device变量,创建一个具有所选大小和用途的缓冲区。确保在创建缓冲区时指定了VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT用途。将缓冲区的句柄存储在storage_texel_buffer变量中(参考第四章 创建缓冲区 的配方,资源和内存)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象(或使用现有的一个)并将其绑定到缓冲区。如果分配了新的内存对象,将其存储在名为memory_object的VkDeviceMemory类型的变量中(参考第四章 分配和绑定内存对象到缓冲区 的配方,资源和内存)。 -
使用
logical_device,storage_texel_buffer和format变量,以及所需的偏移量和内存范围创建一个缓冲区视图,并将结果句柄存储在名为storage_texel_buffer_view的VkBufferView类型的变量中(参考第四章 创建缓冲区视图 的配方,资源和内存)。
它是如何工作的...
存储 texel 缓冲区允许我们访问和存储非常大的数组中的数据。数据被解释为如果它是在一维图像内部读取或存储的。此外,我们还可以对这些缓冲区执行原子操作。
存储 texel 缓冲区可以填充类型等于 VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER 的描述符。
要将缓冲区用作存储 texel 缓冲区,它需要以 VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT 用途创建。还需要一个具有适当格式的缓冲区视图。对于存储 texel 缓冲区,我们可以选择包括以下在内的强制格式之一:
-
VK_FORMAT_R8G8B8A8_UNORM,VK_FORMAT_R8G8B8A8_SNORM,VK_FORMAT_R8G8B8A8_UINT和VK_FORMAT_R8G8B8A8_SINT -
VK_FORMAT_A8B8G8R8_UNORM_PACK32,VK_FORMAT_A8B8G8R8_SNORM_PACK32,VK_FORMAT_A8B8G8R8_UINT_PACK32和VK_FORMAT_A8B8G8R8_SINT_PACK32 -
VK_FORMAT_R32_UINT,VK_FORMAT_R32_SINT和VK_FORMAT_R32_SFLOAT -
VK_FORMAT_R32G32_UINT,VK_FORMAT_R32G32_SINT和VK_FORMAT_R32G32_SFLOAT -
VK_FORMAT_R32G32B32A32_UINT,VK_FORMAT_R32G32B32A32_SINT,和VK_FORMAT_R32G32B32A32_SFLOAT
对于原子操作,强制格式的列表要短得多,仅包括以下内容:
VK_FORMAT_R32_UINT和VK_FORMAT_R32_SINT
其他格式也可能支持存储 texel 缓冲区,但支持并不保证,必须在应用程序执行的平台上进行确认,如下所示:
VkFormatProperties format_properties;
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties );
if( !(format_properties.bufferFeatures & VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT) ) {
std::cout << "Provided format is not supported for a uniform texel buffer." << std::endl;
return false;
}
if( atomic_operations &&
!(format_properties.bufferFeatures & VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_ATOMIC_BIT) ) {
std::cout << "Provided format is not supported for atomic operations on storage texel buffers." << std::endl;
return false;
}
对于存储 texel 缓冲区,我们需要创建一个缓冲区,为缓冲区分配和绑定一个内存对象,还需要创建一个定义缓冲区数据格式的缓冲区视图:

if( !CreateBuffer( logical_device, size, usage | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT, storage_texel_buffer ) ) {
return false;
}
if( !AllocateAndBindMemoryObjectToBuffer( physical_device, logical_device, storage_texel_buffer, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
if( !CreateBufferView( logical_device, storage_texel_buffer, format, 0, VK_WHOLE_SIZE, storage_texel_buffer_view ) ) {
return false;
}
return true;
我们还可以使用现有的内存对象并将它的内存范围绑定到存储 texel 缓冲区。
从 GLSL 的角度来看,存储纹理缓冲区变量使用imageBuffer(可能带有前缀)关键字定义。
在 GLSL 着色器中定义的存储纹理缓冲区的一个例子如下所示:
layout (set=m, binding=n, r32f) uniform imageBuffer <variable name>;
参见
在第四章,资源和内存中,查看以下配方:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
创建缓冲区视图
-
销毁缓冲区视图
-
释放内存对象
-
销毁缓冲区
创建一个统一的缓冲区
在 Vulkan 中,着色器内部使用的统一变量不能放置在全局命名空间中。它们只能定义在统一缓冲区内部。对于这些,我们需要创建具有VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT用途的缓冲区。
如何做...
-
使用创建的逻辑设备及其句柄初始化一个名为
logical_device的VkDevice类型的变量。 -
创建一个名为
uniform_buffer的VkBuffer类型的变量。它将保存创建的缓冲区的句柄。 -
使用
logical_device变量创建一个缓冲区,并指定所需的大小和用途。后者必须包含至少一个VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT标志。将缓冲区的句柄存储在uniform_buffer变量中(请参阅第四章,资源和内存中的创建缓冲区配方)。 -
使用具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性(或使用现有内存对象的范围)的内存对象进行分配,并将其绑定到缓冲区(请参阅第四章,资源和内存中的分配和绑定内存对象到缓冲区配方)。
它是如何工作的...
统一缓冲区用于在着色器内部提供只读统一变量的值。
统一缓冲区可用于VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER或VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC描述符类型。
通常,统一缓冲区包含不经常更改的参数数据,即矩阵(对于少量数据,建议使用推送常量,因为更新它们通常要快得多;有关推送常量的信息,请参阅第九章,命令记录和绘制中的通过推送常量向着色器提供数据配方)。
创建用于存储统一变量数据的缓冲区需要我们在创建缓冲区时指定VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT标志。当缓冲区创建时,我们需要准备一个内存对象并将其绑定到创建的缓冲区(我们也可以使用现有的内存对象并将它的内存存储的一部分绑定到缓冲区):
if( !CreateBuffer( logical_device, size, usage | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, uniform_buffer ) ) {
return false;
}
if( !AllocateAndBindMemoryObjectToBuffer( physical_device, logical_device, uniform_buffer, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
return true;
在缓冲区和其内存对象准备就绪后,我们可以像对其他类型的缓冲区一样将数据上传到它们。我们只需记住,统一变量必须放置在适当的偏移量处。这些偏移量与 GLSL 语言中的 std140 布局相同,定义如下:
-
大小为
N的标量变量必须放置在偏移量为N的倍数的位置。 -
一个具有两个组件的向量,其中每个组件的大小为
N,必须放置在偏移量为2N的倍数的位置。 -
一个具有三个或四个组件的向量,其中每个组件的大小为
N,必须放置在偏移量为4N的倍数的位置。 -
一个大小为
N的数组必须放置在偏移量为N的倍数的位置,且向上取整到16的倍数。 -
结构必须放置在与其成员的最大偏移量相同的偏移量处,向上取整到
16的倍数(具有最大偏移量要求的成员的偏移量,向上取整到16的倍数)。 -
一个行主序矩阵必须放置在偏移量等于具有与矩阵列数相同组件数的向量的偏移量处。
-
一个列主序矩阵必须放置在其列相同的偏移量处。
动态统一缓冲区与普通统一缓冲区在指定其地址的方式上有所不同。在描述符集更新期间,我们指定用于统一缓冲区的内存大小以及从缓冲区内存开始的偏移量。对于普通统一缓冲区,这些参数保持不变。对于动态统一缓冲区,指定的偏移量成为一个基偏移量,可以在将描述符集绑定到命令缓冲区时通过动态偏移量进行修改。
在 GLSL 着色器内部,统一缓冲区和动态统一缓冲区都使用uniform限定符和块语法定义。
以下提供了一个在 GLSL 着色器中定义统一缓冲区的示例:
layout (set=m, binding=n) uniform <variable name>
{
vec4 <member 1 name>;
mat4 <member 2 name>;
// ...
};
参见
在第四章,资源和内存中,查看以下配方:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
释放内存对象
-
销毁缓冲区
创建存储缓冲区
当我们不仅想要从着色器内部的缓冲区中读取数据,还希望在其中存储数据时,我们需要使用存储缓冲区。这些缓冲区使用VK_BUFFER_USAGE_STORAGE_BUFFER_BIT用途创建。
如何做到这一点...
-
获取逻辑设备的句柄并将其存储在名为
physical_device的类型为VkPhysicalDevice的变量中。 -
创建一个名为
storage_buffer的类型为VkBuffer的变量,其中将存储创建的缓冲区的句柄。 -
使用
logical_device变量创建一个所需大小和用途的缓冲区。指定的用途必须包含至少一个VK_BUFFER_USAGE_STORAGE_BUFFER_BIT标志。将创建的句柄存储在storage_buffer变量中(参考第四章,资源和内存中的创建缓冲区配方)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象(或使用现有内存对象的范围)并将其绑定到创建的缓冲区(请参阅 第四章,资源和内存中的分配并将内存对象绑定到缓冲区配方)。
它是如何工作的...
存储缓冲区支持读写操作。我们还可以对具有无符号整数格式的存储缓冲区成员执行原子操作。
存储缓冲区对应于 VK_DESCRIPTOR_TYPE_STORAGE_BUFFER 或 VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC 描述符类型。
存储缓冲区成员的数据必须放置在适当的偏移处。满足要求的最简单方法是遵循 GLSL 语言中 std430 布局的规则。存储缓冲区的基对齐规则与统一缓冲区的规则类似,除了数组和结构--它们的偏移量不需要向上舍入到 16 的倍数。为了方便,这些规则如下指定:
-
大小为
N的标量变量必须放置在N的倍数偏移处 -
一个有两个分量的向量,其中每个分量的大小为
N,必须放置在2N的倍数偏移处 -
一个有三个或四个分量的向量,其中每个分量的大小为
N,必须放置在4N的倍数偏移处 -
一个大小为
N的元素数组必须放置在N的倍数偏移处 -
一个结构必须放置在其成员中最大偏移量的倍数偏移处(具有最大偏移量要求的成员)
-
一个行主序矩阵必须放置在一个偏移量等于具有与矩阵列数相同分量的向量偏移量
-
一个列主序矩阵必须放置在其列相同的偏移处
动态存储缓冲区在定义其基内存偏移的方式上有所不同。在描述符集更新期间指定的偏移量和范围对于正常存储缓冲区保持不变,直到下一次更新。对于它们的动态变体,指定的偏移量成为一个基地址,稍后由将描述符集绑定到命令缓冲区时指定的动态偏移量修改。
在 GLSL 着色器中,存储缓冲区和动态存储缓冲区使用 buffer 限定符和块语法定义相同。
下面提供了一个在 GLSL 着色器中使用的存储缓冲区的示例:
layout (set=m, binding=n) buffer <variable name>
{
vec4 <member 1 name>;
mat4 <member 2 name>;
// ...
};
参见
在 第四章,资源和内存,查看以下配方:
-
创建缓冲区
-
分配并将内存对象绑定到缓冲区
-
释放内存对象
-
销毁缓冲区
创建输入附件
附件是在绘制命令期间,在渲染通道中渲染到其中的图像。换句话说,它们是渲染目标。
输入附件是我们可以在片段着色器内部读取(未过滤)数据的图像资源。我们只需记住,我们只能访问与处理过的片段相对应的一个位置。
通常,对于输入附件,使用之前用作颜色或深度/模板附件的资源。但我们也可以使用其他图像(及其图像视图)。我们只需使用具有VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT使用位的VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT来创建它们。
如何实现...
-
获取执行操作的物理设备,并将其句柄存储在名为
physical_device的VkPhysicalDevice类型变量中。 -
为图像选择一个格式,并使用它初始化一个名为
format的VkFormat类型变量。 -
创建一个名为
format_properties的VkFormatProperties类型变量。 -
调用
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties ),并提供physical_device和format变量,以及format_properties变量的指针。 -
如果将读取图像的颜色数据,请确保所选格式适合此类使用。为此,请检查
format_properties变量的optimalTilingFeatures成员中是否设置了VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT位。 -
如果将读取图像的深度或模板数据,请检查所选格式是否可用于读取深度或模板数据。通过确保
format_properties变量的optimalTilingFeatures成员中设置了VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT位来完成此操作。 -
从使用的物理设备创建一个逻辑设备,并将其句柄存储在名为
logical_device的VkDevice类型变量中。 -
使用
logical_device和format变量创建一个图像,并为图像的其余参数选择适当的值。确保在创建图像期间指定VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT使用位。将创建的句柄存储在名为input_attachment的VkImage类型变量中(参考第四章中的创建一个图像配方,资源和内存)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象(或使用现有内存对象的范围),并将其绑定到图像上(参考第四章资源和内存中的分配和绑定内存对象到图像配方)。 -
使用
logical_device、input_attachment和format变量创建一个图像视图,并选择图像视图的其余参数。将创建的句柄存储在名为input_attachment_image_view的VkImageView类型变量中(参考第四章中的创建图像视图配方,资源和内存)。
它是如何工作的...
输入附件使我们能够从用作渲染通道附件的图像中读取片段着色器内的数据(通常,对于输入附件,将使用之前用作颜色或深度/模板附件的图像)。
输入附件用于 VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT 类型的描述符。
在 Vulkan 中,渲染操作被收集到渲染通道中。每个渲染通道至少有一个子通道,但可以有更多。如果我们在一个子通道中将渲染到附件,然后我们可以将其用作输入附件,并在同一渲染通道的后续子通道中从中读取数据。实际上,这是从给定渲染通道的附件中读取数据的唯一方法--在给定渲染通道中作为附件的图像只能通过着色器中的输入附件访问(它们不能绑定到描述符集用于除输入附件以外的目的)。
当从输入附件读取数据时,我们仅限于对应于处理片段位置的地点。但这种方法可能比渲染到附件、结束渲染通道、将图像绑定到描述符集作为采样图像(纹理)并开始另一个不使用给定图像作为任何附件的渲染通道更优。
对于输入附件,我们还可以使用其他图像(我们不必将它们用作颜色或深度/模板附件)。我们只需用 VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT 用法和适当的格式创建它们。以下格式对于从读取颜色数据的输入附件是强制性的:
-
VK_FORMAT_R5G6B5_UNORM_PACK16 -
VK_FORMAT_A1R5G5B5_UNORM_PACK16 -
VK_FORMAT_R8_UNORM、VK_FORMAT_R8_UINT和VK_FORMAT_R8_SINT -
VK_FORMAT_R8G8_UNORM、VK_FORMAT_R8G8_UINT和VK_FORMAT_R8G8_SINT -
VK_FORMAT_R8G8B8A8_UNORM、VK_FORMAT_R8G8B8A8_UINT、VK_FORMAT_R8G8B8A8_SINT和VK_FORMAT_R8G8B8A8_SRGB -
VK_FORMAT_B8G8R8A8_UNORM和VK_FORMAT_B8G8R8A8_SRGB -
VK_FORMAT_A8B8G8R8_UNORM_PACK32、VK_FORMAT_A8B8G8R8_UINT_PACK32、VK_FORMAT_A8B8G8R8_SINT_PACK32和VK_FORMAT_A8B8G8R8_SRGB_PACK32 -
VK_FORMAT_A2B10G10R10_UNORM_PACK32和VK_FORMAT_A2B10G10R10_UINT_PACK32 -
VK_FORMAT_R16_UINT、VK_FORMAT_R16_SINT和VK_FORMAT_R16_SFLOAT -
VK_FORMAT_R16G16_UINT、VK_FORMAT_R16G16_SINT和VK_FORMAT_R16G16_SFLOAT -
VK_FORMAT_R16G16B16A16_UINT、VK_FORMAT_R16G16B16A16_SINT和VK_FORMAT_R16G16B16A16_SFLOAT -
VK_FORMAT_R32_UINT、VK_FORMAT_R32_SINT和VK_FORMAT_R32_SFLOAT -
VK_FORMAT_R32G32_UINT、VK_FORMAT_R32G32_SINT和VK_FORMAT_R32G32_SFLOAT -
VK_FORMAT_R32G32B32A32_UINT、VK_FORMAT_R32G32B32A32_SINT和VK_FORMAT_R32G32B32A32_SFLOAT
对于将读取深度/模板数据的输入附件,以下格式是强制性的:
-
VK_FORMAT_D16_UNORM -
VK_FORMAT_X8_D24_UNORM_PACK32或VK_FORMAT_D32_SFLOAT(至少必须支持这两种格式之一) -
VK_FORMAT_D24_UNORM_S8_UINT或VK_FORMAT_D32_SFLOAT_S8_UINT(至少必须支持这两种格式之一)
其他格式也可能被支持,但对它们的支持不能保证。我们可以检查在应用程序执行的平台上的给定格式是否被支持,如下所示:
VkFormatProperties format_properties;
vkGetPhysicalDeviceFormatProperties( physical_device, format, &format_properties );
if( (aspect & VK_IMAGE_ASPECT_COLOR_BIT) &&
!(format_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT) ) {
std::cout << "Provided format is not supported for an input attachment." << std::endl;
return false;
}
if( (aspect & (VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_DEPTH_BIT)) &&
!(format_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT) ) {
std::cout << "Provided format is not supported for an input attachment." << std::endl;
return false;
}
接下来,我们只需要创建一个图像,分配一个内存对象(或使用现有的一个)并将其绑定到图像上,然后创建一个图像视图。我们可以这样做:
if( !CreateImage( logical_device, type, format, size, 1, 1, VK_SAMPLE_COUNT_1_BIT, usage | VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT, false, input_attachment ) ) {
return false;
}
if( !AllocateAndBindMemoryObjectToImage( physical_device, logical_device, input_attachment, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, memory_object ) ) {
return false;
}
if( !CreateImageView( logical_device, input_attachment, view_type, format, aspect, input_attachment_image_view ) ) {
return false;
}
return true;
以这种方式创建的图像及其视图可以用作输入附件。为此,我们需要准备一个适当的渲染通道描述,并将图像视图包含在帧缓冲区中(参考第六章,渲染通道和帧缓冲区中的指定子通道描述和创建帧缓冲区配方)。
在 GLSL 着色器代码中,使用subpassInput(可能带有前缀)关键字定义引用输入附件的变量。
以下是一个在 GLSL 中定义的输入附件的示例:
layout (input_attachment_index=i, set=m, binding=n) uniform subpassInput <variable name>;
参见
在第四章,资源和内存,查看以下配方:
-
创建一个图像
-
将内存对象分配和绑定到图像上
-
创建一个图像视图
-
销毁一个图像视图
-
销毁一个图像
-
释放内存对象
在第六章,渲染通道和**帧缓冲区,查看以下配方:
-
指定子通道描述
-
创建一个帧缓冲区
创建描述符集布局
描述符集在一个对象中聚集了许多资源(描述符)。它们稍后绑定到管道以建立我们的应用程序和着色器之间的接口。但是,为了让硬件知道哪些资源被分组在一个集中,每种类型的资源有多少个,以及它们的顺序是什么,我们需要创建一个描述符集布局。
如何做...
-
获取逻辑设备的句柄并将其分配给名为
logical_device的VkDevice类型变量。 -
创建一个元素类型为
VkDescriptorSetLayoutBinding的向量变量,并命名为bindings。 -
对于您想要创建并稍后分配给给定描述符集的每个资源,向
bindings向量中添加一个元素。为每个新元素的成员使用以下值:-
给定资源在描述符集中的选择索引用于
binding -
给定资源的期望类型用于
descriptorType -
通过着色器内部数组访问的指定类型的资源数量(如果给定资源不是通过数组访问,则为 1)用于
descriptorCount -
资源将被访问的所有着色器阶段的逻辑“或”用于
stageFlags -
nullptr的值用于pImmutableSamplers
-
-
创建一个名为
descriptor_set_layout_create_info的VkDescriptorSetLayoutCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO的值用于sType -
nullptr的值用于pNext -
0的值用于flags -
bindings向量中元素的数量用于bindingCount -
指向
bindings向量第一个元素的指针用于pBindings
-
-
创建一个名为
descriptor_set_layout的VkDescriptorSetLayout类型的变量,其中将存储创建的布局。 -
调用
vkCreateDescriptorSetLayout(logical_device, &descriptor_set_layout_create_info, nullptr, &descriptor_set_layout)并提供逻辑设备的句柄、descriptor_set_layout_create_info变量的指针、一个nullptr值以及descriptor_set_layout变量的指针。 -
通过检查返回值是否等于
VK_SUCCESS来确保调用成功。
它是如何工作的...
描述符集布局指定了描述符集的内部结构,同时严格定义了可以绑定到描述符集上的资源(我们不能使用布局中未指定的资源)。
当我们想要创建布局时,我们需要知道将使用哪些资源(描述符类型)以及它们的顺序。顺序通过绑定来指定——它们定义了资源在给定集中的索引(位置),并在着色器内部(通过layout限定符的集合号)用于指定我们想要访问的资源:
layout (set=m, binding=n) // variable definition
我们可以为绑定选择任何值,但我们应该记住,未使用的索引可能会消耗内存并影响我们应用程序的性能。
为了避免不必要的内存开销和负面的性能影响,我们应该保持描述符绑定尽可能紧凑,尽可能接近0。
要创建描述符集布局,我们首先需要指定给定集中使用的所有资源的列表:
VkDescriptorSetLayoutCreateInfo descriptor_set_layout_create_info = {
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(bindings.size()),
bindings.data()
};
接下来,我们可以创建如下布局:
VkResult result = vkCreateDescriptorSetLayout( logical_device, &descriptor_set_layout_create_info, nullptr, &descriptor_set_layout );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a layout for descriptor sets." << std::endl;
return false;
}
return true;
描述符集布局(以及推送常量范围)也形成了一个管线布局,它定义了给定管线可以访问的资源类型。除了管线布局创建之外,创建的布局在描述符集分配期间也是必需的。
参见
-
在第八章“图形和计算管线”中,查看以下配方:
- 创建管线布局
-
在本章中,查看以下配方:
- 分配描述符集
创建描述符池
描述符,收集到集合中,是从描述符池中分配的。当我们创建一个池时,我们必须定义哪些描述符,以及它们中可以有多少可以从创建的池中分配。
如何做到...
-
获取应该在之上创建描述符池的逻辑设备的句柄。将其存储在名为
logical_device的VkDevice类型变量中。 -
创建一个名为
descriptor_types的向量变量,其元素类型为VkDescriptorPoolSize。对于将从池中分配的每种描述符类型,向descriptor_types变量添加一个新元素,定义指定的描述符类型以及将从池中分配的给定类型的描述符数量。 -
创建一个名为
descriptor_pool_create_info的VkDescriptorPoolCreateInfo类型的变量。为此变量的成员使用以下值:-
VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO的sType值 -
pNext的值为nullptr -
如果应该可能释放从该池分配的单独的集,则
VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT值为1,或者为0值以仅允许通过池重置操作一次性释放所有集(对于flags)。 -
池中可以分配的最大集数
maxSets -
poolSizeCount的descriptor_types向量中的元素数量 -
pPoolSizes的descriptor_types向量的第一个元素的指针
-
-
创建一个名为
descriptor_pool的VkDescriptorPool类型的变量,其中将存储创建的池的句柄。 -
调用
vkCreateDescriptorPool(logical_device, &descriptor_pool_create_info, nullptr, &descriptor_pool)并提供logical_device变量、descriptor_pool_create_info变量的指针、一个nullptr值和一个指向descriptor_pool变量的指针。 -
确保通过检查调用是否返回了
VK_SUCCESS值来确认池是否成功创建。
它是如何工作的...
描述符池管理用于分配描述符集的资源(类似于命令池管理命令缓冲区的内存)。在创建描述符池期间,我们指定可以从给定池中分配的最大集数和可以跨所有集分配的给定类型的最大描述符数。此信息通过类型为VkDescriptorPoolCreateInfo的变量提供,如下所示:
VkDescriptorPoolCreateInfo descriptor_pool_create_info = {
VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
nullptr,
free_individual_sets ?
VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT : 0,
max_sets_count,
static_cast<uint32_t>(descriptor_types.size()),
descriptor_types.data()
};
在前面的示例中,描述符的类型及其总数是通过descriptor_types向量变量提供的。它可能包含多个元素,创建的池将足够大,可以分配所有指定的描述符。
池本身创建方式如下:
VkResult result = vkCreateDescriptorPool( logical_device, &descriptor_pool_create_info, nullptr, &descriptor_pool );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a descriptor pool." << std::endl;
return false;
}
return true;
当我们创建了一个池,我们可以从中分配描述符集。但我们必须记住,我们不能同时以多线程的方式做这件事。
我们不能在多个线程中同时从给定的池中分配描述符集。
参见
在本章中查看以下食谱:
-
分配描述符集
-
释放描述符集
-
重置描述符池
-
销毁描述符池
分配描述符集
描述符集在一个容器对象中聚集着着色器资源(描述符)。其内容、类型和资源数量由描述符集布局定义;存储是从池中获取的,我们可以从池中分配描述符集。
如何操作...
-
取逻辑设备并将其句柄存储在名为
logical_device的VkDevice类型的变量中。 -
准备一个描述符池,从该池中分配描述符集。使用池的句柄初始化一个名为
descriptor_pool的VkDescriptorPool类型的变量。 -
创建一个名为
descriptor_set_layouts的std::vector<VkDescriptorSetLayout>类型的变量。对于应该从池中分配的每个描述符集,添加一个定义相应描述符集结构的描述符集布局句柄。 -
创建一个名为
descriptor_set_allocate_info的类型为VkDescriptorSetAllocateInfo的变量,并为其成员使用以下值:-
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO的值用于sType -
pNext的值为nullptr -
descriptor_pool变量用于descriptorPool -
descriptor_set_layouts向量中descriptorSetCount的元素数量 -
descriptorSetCount向量的第一个元素指针用于pSetLayouts
-
-
创建一个名为
descriptor_sets的类型为std::vector<VkDescriptorSet>的向量变量,并将其大小调整为与descriptor_set_layouts向量的大小相匹配。 -
调用
vkAllocateDescriptorSets( logical_device, &descriptor_set_allocate_info, &descriptor_sets[0] )并提供logical_device变量、descriptor_set_allocate_info变量的指针以及descriptor_sets向量第一个元素的指针。 -
确保调用成功并返回了
VK_SUCCESS值。
它是如何工作的...
描述符集用于向着色器提供资源。它们在应用程序和可编程管道阶段之间形成一个接口。该接口的结构由描述符集布局定义。实际数据是在我们使用图像或缓冲区资源更新描述符集时提供,并在记录操作期间将这些描述符集绑定到命令缓冲区时提供。
描述符集是从池中分配的。当我们创建池时,我们指定可以从池中分配多少描述符(资源)以及其类型,以及可以从中分配的最大描述符集数量。
当我们想要分配描述符集时,我们需要指定将描述其内部结构的布局——每个描述符集一个布局。此信息指定如下:
VkDescriptorSetAllocateInfo descriptor_set_allocate_info = {
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
nullptr,
descriptor_pool,
static_cast<uint32_t>(descriptor_set_layouts.size()),
descriptor_set_layouts.data()
};
接下来,我们按照以下方式分配描述符集:
descriptor_sets.resize( descriptor_set_layouts.size() );
VkResult result = vkAllocateDescriptorSets( logical_device, &descriptor_set_allocate_info, descriptor_sets.data() );
if( VK_SUCCESS != result ) {
std::cout << "Could not allocate descriptor sets." << std::endl;
return false;
}
return true;
不幸的是,当我们分配和释放单独的描述符集时,池的内存可能会变得碎片化。在这种情况下,我们可能无法从给定的池中分配新的集合,即使我们没有达到指定的限制。这种情况在以下图中展示:

当我们首次分配描述符集时,不会出现碎片化问题。此外,如果所有描述符集使用相同类型的资源数量,则可以保证不会出现此类问题。
为了避免池碎片化问题,我们可以一次性释放所有描述符集(通过重置池)。否则,如果我们无法分配新的描述符集,并且不想重置池,我们需要创建另一个池。
参见
在本章中查看以下食谱:
-
创建描述符集布局
-
创建描述符池
-
释放描述符集
-
重置描述符池
更新描述符集
我们已经创建了一个描述符池,并从中分配了描述符集。由于创建了布局,我们知道了它们的内部结构。现在我们想要提供特定的资源(采样器、图像视图、缓冲区或缓冲区视图),这些资源稍后应通过描述符集绑定到管线。定义要使用的资源是通过更新描述符集的过程完成的。
准备工作
更新描述符集需要我们为每个参与过程描述符提供相当数量的数据。更重要的是,提供的数据取决于描述符的类型。为了简化过程并减少需要指定的参数数量,以及为了提高错误检查,在此配方中引入了自定义结构。
对于采样器和各种图像描述符,使用 ImageDescriptorInfo 类型,其定义如下:
struct ImageDescriptorInfo {
VkDescriptorSet TargetDescriptorSet;
uint32_t TargetDescriptorBinding;
uint32_t TargetArrayElement;
VkDescriptorType TargetDescriptorType;
std::vector<VkDescriptorImageInfo> ImageInfos;
};
对于统一和存储缓冲区(及其动态变体),使用 BufferDescriptorInfo 类型。其定义如下:
struct BufferDescriptorInfo {
VkDescriptorSet TargetDescriptorSet;
uint32_t TargetDescriptorBinding;
uint32_t TargetArrayElement;
VkDescriptorType TargetDescriptorType;
std::vector<VkDescriptorBufferInfo> BufferInfos;
};
对于统一和存储纹理缓冲区,引入了 TexelBufferDescriptorInfo 类型,其定义如下:
struct TexelBufferDescriptorInfo {
VkDescriptorSet TargetDescriptorSet;
uint32_t TargetDescriptorBinding;
uint32_t TargetArrayElement;
VkDescriptorType TargetDescriptorType;
std::vector<VkBufferView> TexelBufferViews;
};
当我们想要使用新描述符的句柄更新描述符集时,使用前面的结构。也可以从其他已更新的集中复制描述符数据。为此,使用 CopyDescriptorInfo 类型,其定义如下:
struct CopyDescriptorInfo {
VkDescriptorSet TargetDescriptorSet;
uint32_t TargetDescriptorBinding;
uint32_t TargetArrayElement;
VkDescriptorSet SourceDescriptorSet;
uint32_t SourceDescriptorBinding;
uint32_t SourceArrayElement;
uint32_t DescriptorCount;
};
所有前面的结构定义了应更新的描述符集的句柄、给定集中描述符的索引,以及如果我们想通过数组访问描述符,则数组中的索引。其余参数是类型特定的。
如何操作...
-
使用逻辑设备的句柄初始化一个名为
logical_device的VkDevice类型的变量。 -
创建一个名为
write_descriptors的std::vector<VkWriteDescriptorSet>类型的变量。对于每个需要更新的新描述符,向向量中添加一个新元素,并为其成员使用以下值:-
VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET的sType值 -
pNext的值为nullptr -
应更新的描述符集的句柄为
dstSet -
在指定集中描述符的索引(绑定)为
dstBinding -
如果在着色器内部通过数组访问给定的描述符,则从数组中更新的描述符的起始索引为
dstArrayElement(否则为0值) -
对于
descriptorCount,要更新的描述符数量(pImageInfo、pBufferInfo或pTexelBufferView数组中的元素数量) -
描述符的类型为
descriptorType -
在采样器或图像描述符的情况下,指定一个包含
descriptorCount个元素的数组,并在pImageInfo中提供其第一个元素的指针(将pBufferInfo和pTexelBufferView成员设置为nullptr)。为每个数组元素使用以下值:-
在采样器描述符的情况下,用于
sampler的组合图像采样器描述符的采样器句柄 -
在采样图像、存储图像、组合图像采样器和输入附件描述符的情况下,用于
imageView的图像视图句柄 -
当通过着色器访问描述符时,给定图像将处于的布局情况,对于图像描述符的
imageLayout
-
-
在均匀或存储缓冲区(及其动态变体)的情况下,指定一个包含
descriptorCount个元素的数组,并在pBufferInfo中提供其第一个元素的指针(将pImageInfo和pTexelBufferView成员设置为nullptr),并为每个数组元素使用以下值:-
缓冲区的句柄用于
buffer -
缓冲区内的内存偏移量(或动态描述符的基偏移量)用于
offset -
对于给定描述符的
range,应使用的缓冲区内存大小
-
-
在均匀的 texel 缓冲区或存储 texel 缓冲区的情况下,指定一个包含
descriptorCount个 texel 视图句柄的数组,并在pTexelBufferView中提供其第一个元素的指针(将pImageInfo和pBufferInfo成员设置为nullptr)。
-
-
创建一个名为
copy_descriptors的std::vector<VkCopyDescriptorSet>类型的变量。对于应从另一个已更新的描述符复制的数据,向此向量添加一个元素。为每个新元素的成员使用以下值:-
VK_STRUCTURE_TYPE_COPY_DESCRIPTOR_SET值用于sType -
nullptr值用于pNext -
应从其中复制数据的描述符集的句柄用于
srcSet -
源描述符集中用于
srcBinding的绑定编号 -
在源描述符集中用于
srcArrayElement的数组索引 -
应在其中更新数据的描述符集的句柄用于
dstSet -
目标描述符集中用于
dstBinding的绑定编号 -
在目标描述符集中用于
dstArrayElement的数组索引 -
应从源集复制并更新到目标集的描述符数量用于
descriptorCount
-
-
调用
vkUpdateDescriptorSets(logical_device, static_cast<uint32_t>(write_descriptors.size()), &write_descriptors[0], static_cast<uint32_t>(copy_descriptors.size()), ©_descriptors[0])并提供logical_device变量、write_descriptors向量的元素数量、write_descriptors的第一个元素的指针、copy_descriptors向量的元素数量和copy_descriptors向量的第一个元素的指针。
它是如何工作的...
更新描述符集会导致指定的资源(采样器、图像视图、缓冲区或缓冲区视图)填充指示集中的条目。当更新的集被绑定到管线时,这些资源可以通过着色器访问。
我们可以将新的(尚未使用)资源写入描述符集。在以下示例中,我们通过使用准备就绪部分中提到的自定义结构来实现这一点:
std::vector<VkWriteDescriptorSet> write_descriptors;
for( auto & image_descriptor : image_descriptor_infos ) {
write_descriptors.push_back( {
VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
nullptr,
image_descriptor.TargetDescriptorSet,
image_descriptor.TargetDescriptorBinding,
image_descriptor.TargetArrayElement,
static_cast<uint32_t>(image_descriptor.ImageInfos.size()),
image_descriptor.TargetDescriptorType,
image_descriptor.ImageInfos.data(),
nullptr,
nullptr
} );
}
for( auto & buffer_descriptor : buffer_descriptor_infos ) {
write_descriptors.push_back( {
VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
nullptr,
buffer_descriptor.TargetDescriptorSet,
buffer_descriptor.TargetDescriptorBinding,
buffer_descriptor.TargetArrayElement,
static_cast<uint32_t>(buffer_descriptor.BufferInfos.size()),
buffer_descriptor.TargetDescriptorType,
nullptr,
buffer_descriptor.BufferInfos.data(),
nullptr
} );
}
for( auto & texel_buffer_descriptor : texel_buffer_descriptor_infos ) {
write_descriptors.push_back( {
VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
nullptr,
texel_buffer_descriptor.TargetDescriptorSet,
texel_buffer_descriptor.TargetDescriptorBinding,
texel_buffer_descriptor.TargetArrayElement,
static_cast<uint32_t>(texel_buffer_descriptor.TexelBufferViews.size()),
texel_buffer_descriptor.TargetDescriptorType,
nullptr,
nullptr,
texel_buffer_descriptor.TexelBufferViews.data()
} );
}
我们还可以重用其他集的描述符。复制已填充的描述符应该比编写新的描述符更快。这可以通过以下方式完成:
std::vector<VkCopyDescriptorSet> copy_descriptors;
for( auto & copy_descriptor : copy_descriptor_infos ) {
copy_descriptors.push_back( {
VK_STRUCTURE_TYPE_COPY_DESCRIPTOR_SET,
nullptr,
copy_descriptor.SourceDescriptorSet,
copy_descriptor.SourceDescriptorBinding,
copy_descriptor.SourceArrayElement,
copy_descriptor.TargetDescriptorSet,
copy_descriptor.TargetDescriptorBinding,
copy_descriptor.TargetArrayElement,
copy_descriptor.DescriptorCount
} );
}
更新描述符集的操作通过单个函数调用执行:
vkUpdateDescriptorSets( logical_device, static_cast<uint32_t>(write_descriptors.size()), write_descriptors.data(), static_cast<uint32_t>(copy_descriptors.size()), copy_descriptors.data() );
参见
参见本章中的以下配方:
-
分配描述符集
-
绑定描述符集
-
使用纹理和统一缓冲区创建描述符
绑定描述符集
当描述符集准备就绪(我们已使用将在着色器中访问的所有资源更新了它)时,我们需要在记录操作期间将其绑定到命令缓冲区。
如何做到...
-
捕获正在记录的命令缓冲区的句柄。将句柄存储在名为
command_buffer的VkCommandBuffer类型变量中。 -
创建一个名为
pipeline_type的VkPipelineBindPoint类型变量,它将表示描述符集将使用的管道类型(图形或计算)。 -
获取管道布局并将句柄存储在名为
pipeline_layout的VkPipelineLayout类型变量中(参考第八章 创建管道布局 配方,图形和计算管道)。 -
创建一个名为
descriptor_sets的std::vector<VkDescriptorSet>类型变量。对于每个需要绑定到管道的描述符集,向向量中添加一个新元素,并用描述符集的句柄初始化它。 -
选择一个索引,将提供的列表中的第一个集绑定到该索引。将索引存储在名为
index_for_first_set的uint32_t类型变量中。 -
如果在任何被绑定的集中使用了动态统一或存储缓冲区,创建一个名为
dynamic_offsets的std::vector<uint32_t>类型变量,通过它提供所有被绑定的集中定义的每个动态描述符的内存偏移量。偏移量必须在每个集的布局中按其对应的描述符出现的顺序定义(按递增的绑定顺序)。 -
执行以下调用:
vkCmdBindDescriptorSets( command_buffer, pipeline_type,
pipeline_layout, index_for_first_set, static_cast<uint32_t>
(descriptor_sets.size()), descriptor_sets.data(),
static_cast<uint32_t>(dynamic_offsets.size()),
dynamic_offsets.data() )
对于此调用,提供 command_buffer、pipeline_type、pipeline_layout 和 index_for_first_set 变量,元素数量以及 descriptor_sets 向量第一个元素指针,以及元素数量和 dynamic_offsets 向量第一个元素指针。
它是如何工作的...
当我们开始记录命令缓冲区时,其状态(几乎全部)是未定义的。因此,在我们能够记录引用图像或缓冲区资源的绘制操作之前,我们需要将适当的资源绑定到命令缓冲区。这是通过使用 vkCmdBindDescriptorSets() 函数调用绑定描述符集来完成的,如下所示:
vkCmdBindDescriptorSets( command_buffer, pipeline_type, pipeline_layout, index_for_first_set, static_cast<uint32_t>(descriptor_sets.size()), descriptor_sets.data(), static_cast<uint32_t>(dynamic_offsets.size()), dynamic_offsets.data() )
参见
参见本章中的以下配方:
-
创建描述符集布局
-
分配描述符集
-
更新描述符集
使用纹理和统一缓冲区创建描述符
在本示例配方中,我们将了解如何创建最常用的资源:组合图像采样器和统一缓冲区。我们将为它们准备描述符集布局,创建描述符池,并从中分配描述符集。然后我们将使用创建的资源更新分配的集。这样,我们就可以稍后绑定描述符集到命令缓冲区,并在着色器中访问资源。
如何操作...
-
使用所选参数创建一个组合图像采样器(一个图像、图像视图和一个采样器)--最常用的参数包括
VK_IMAGE_TYPE_2D图像类型、VK_FORMAT_R8G8B8A8_UNORM格式、VK_IMAGE_VIEW_TYPE_2D视图类型、VK_IMAGE_ASPECT_COLOR_BIT属性、VK_FILTER_LINEAR过滤器模式以及所有纹理坐标的VK_SAMPLER_ADDRESS_MODE_REPEAT寻址模式。将创建的句柄存储在名为sampler的VkSampler类型变量中、名为sampled_image的VkImage类型变量中,以及名为sampled_image_view的VkImageView类型变量中(参考创建组合图像采样器配方)。 -
使用所选参数创建一个统一缓冲区,并将缓冲区的句柄存储在名为
uniform_buffer的VkBuffer类型变量中(参考创建统一缓冲区配方)。 -
创建一个名为
bindings的std::vector<VkDescriptorSetLayoutBinding>类型变量。 -
向
bindings变量添加一个具有以下值的元素:-
binding的值为0。 -
descriptorType的值为VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER。 -
descriptorCount的值为1。 -
stageFlags的值为VK_SHADER_STAGE_FRAGMENT_BIT。 -
stageFlags的值为nullptr。
-
-
向
bindings向量添加另一个元素,并为其成员使用以下值:-
binding的值为1。 -
descriptorType的值为VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER。 -
descriptorCount的值为1。 -
stageFlags的值为VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT。 -
pImmutableSamplers的值为nullptr。
-
-
使用
bindings变量创建一个描述符集布局,并将句柄存储在名为descriptor_set_layout的VkDescriptorSetLayout类型变量中(参考创建描述符集布局配方)。 -
创建一个名为
descriptor_types的std::vector<VkDescriptorPoolSize>类型变量。向创建的向量添加两个元素:一个具有VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER和1的值,另一个具有VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER和1的值。 -
创建一个描述符池,其中单独的描述符集不能单独释放,只能分配一个描述符集。在创建池时使用
descriptor_types变量,并将句柄存储在名为descriptor_pool的VkDescriptorPool类型变量中(参考创建描述符池配方)。 -
使用
descriptor_pool和descriptor_set_layout布局变量分配一个描述符集。将创建的句柄存储在名为descriptor_sets的std::vector<VkDescriptorSet>类型的单元素向量中(参见图 分配描述符集 食谱)。 -
创建一个名为
image_descriptor_infos的std::vector<ImageDescriptorInfo>类型的变量。向此向量添加一个元素,并使用以下值:-
TargetDescriptorSet的descriptor_sets[0] -
TargetDescriptorBinding的0值 -
TargetArrayElement的0值 -
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER的值用于TargetDescriptorType -
向
ImageInfos成员向量添加一个元素,并使用以下值:-
sampler的变量用于sampler -
为
imageView创建sampled_image_view变量 -
imageLayout的VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL值
-
-
-
创建一个名为
buffer_descriptor_infos的std::vector<BufferDescriptorInfo>类型的变量,并使用以下值初始化其一个元素:-
TargetDescriptorSet的descriptor_sets[0] -
TargetDescriptorBinding的1值 -
TargetArrayElement的0值 -
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER的值用于TargetDescriptorType -
向
BufferInfos成员向量添加一个元素,并使用以下值初始化其成员:-
buffer的uniform_buffer变量 -
offset的0值 -
range的VK_WHOLE_SIZE值
-
-
-
使用
image_descriptor_infos和buffer_descriptor_infos向量更新描述符集。
它是如何工作的...
为了准备通常使用的描述符,一个组合图像采样器和统一缓冲区,我们首先需要创建它们:
if( !CreateCombinedImageSampler( physical_device, logical_device, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, sampled_image_size, 1, 1, VK_IMAGE_USAGE_TRANSFER_DST_BIT,
VK_IMAGE_VIEW_TYPE_2D, VK_IMAGE_ASPECT_COLOR_BIT, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT,
VK_SAMPLER_ADDRESS_MODE_REPEAT, VK_SAMPLER_ADDRESS_MODE_REPEAT, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 0.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK, false,
sampler, sampled_image, sampled_image_memory_object, sampled_image_view ) ) {
return false;
}
if( !CreateUniformBuffer( physical_device, logical_device, uniform_buffer_size, VK_BUFFER_USAGE_TRANSFER_DST_BIT, uniform_buffer, uniform_buffer_memory_object ) ) {
return false;
}
接下来,我们准备一个将定义描述符集内部结构的布局:
std::vector<VkDescriptorSetLayoutBinding> bindings = {
{
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
}
};
if( !CreateDescriptorSetLayout( logical_device, bindings, descriptor_set_layout ) ) {
return false;
}
之后,我们创建一个描述符池并从其中分配一个描述符集:
std::vector<VkDescriptorPoolSize> descriptor_types = {
{
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1
},
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
}
};
if( !CreateDescriptorPool( logical_device, false, 1, descriptor_types, descriptor_pool ) ) {
return false;
}
if( !AllocateDescriptorSets( logical_device, descriptor_pool, { descriptor_set_layout }, descriptor_sets ) ) {
return false;
}
最后一件要做的事情是使用最初创建的资源更新描述符集:
std::vector<ImageDescriptorInfo> image_descriptor_infos = {
{
descriptor_sets[0],
0,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
sampler,
sampled_image_view,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
}
};
std::vector<BufferDescriptorInfo> buffer_descriptor_infos = {
{
descriptor_sets[0],
1,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
uniform_buffer,
0,
VK_WHOLE_SIZE
}
}
}
};
UpdateDescriptorSets( logical_device, image_descriptor_infos, buffer_descriptor_infos, {}, {} );
return true;
参见
参见本章中的以下食谱:
-
创建组合图像采样器
-
创建统一缓冲区
-
创建描述符集布局
-
创建描述符池
-
分配描述符集
-
更新描述符集
释放描述符集
如果我们想要返回由描述符集分配的内存并将其放回池中,我们可以释放一个给定的描述符集。
如何做到这一点...
-
使用逻辑设备的句柄初始化名为
logical_device的VkDevice类型的变量。 -
使用带有
VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT标志创建的描述符池。将其句柄存储在名为descriptor_pool的VkDescriptorPool类型的变量中。 -
创建一个名为
descriptor_sets的std::vector<VkDescriptorSet>类型的向量。将所有应释放的描述符集添加到该向量中。 -
调用
vkFreeDescriptorSets(logical_device, descriptor_pool, static_cast<uint32_t>(descriptor_sets.size()), descriptor_sets.data())。对于调用,提供logical_device和descriptor_pool变量,descriptor_sets向量的元素数量,以及descriptor_sets向量第一个元素的指针。 -
通过检查它是否返回
VK_SUCCESS值来确保调用成功。 -
由于我们不能再使用已释放描述符集的句柄,因此清除
descriptor_sets向量。
它是如何工作的...
释放描述符集会释放它所使用的内存并将其归还到池中。应该可以从池中分配相同类型的另一组描述符集,但由于池的内存碎片化,这可能不可行(在这种情况下,我们可能需要创建另一个池或重置分配该集的池)。
我们可以一次性释放多个描述符集,但所有这些描述符集都必须来自同一池。这样做:
VkResult result = vkFreeDescriptorSets( logical_device, descriptor_pool, static_cast<uint32_t>(descriptor_sets.size()), descriptor_sets.data() );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred during freeing descriptor sets." << std::endl;
return false;
}
descriptor_sets.clear();
return true;
我们不能同时从多个线程释放来自同一池的描述符集。
参见
请参阅本章中的以下食谱:
-
创建描述符池
-
分配描述符集
-
重置描述符池
-
销毁描述符池
重置描述符池
我们可以一次性释放从给定池分配的所有描述符集,而不销毁池本身。为此,我们可以重置描述符池。
如何做到这一点...
-
获取应重置的描述符池并使用其句柄初始化一个名为
descriptor_pool的VkDescriptorPool类型变量。 -
获取创建描述符池的逻辑设备的句柄。将其句柄存储在名为
logical_device的VkDevice类型变量中。 -
进行以下调用:
vkResetDescriptorPool(logical_device, descriptor_pool, 0),其中使用logical_device和descriptor_pool变量以及一个0值。 -
检查调用返回的任何错误。因为成功的操作应该返回
VK_SUCCESS。
它是如何工作的...
重置描述符池会将从该池分配的所有描述符集返回到池中。从池中分配的所有描述符集都将隐式释放,并且不能再使用(它们的句柄变为无效)。
如果池是在没有设置VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT标志的情况下创建的,那么这是释放从该池分配的描述符集的唯一方法(除了销毁池之外),因为在这样的情况下,我们无法单独释放它们。
要重置池,我们可以编写类似于以下代码的代码:
VkResult result = vkResetDescriptorPool( logical_device, descriptor_pool, 0 );
if( VK_SUCCESS != result ) {
std::cout << "Error occurred during descriptor pool reset." << std::endl;
return false;
}
return true;
参见
请参阅本章中的以下食谱:
-
创建描述符池
-
分配描述符集
-
释放描述符集
-
销毁描述符池
销毁描述符池
当我们不再需要描述符池时,我们可以销毁它(包括从池中分配的所有描述符集)。
如何做到这一点...
-
获取创建的逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型变量中。 -
通过名为
descriptor_pool的VkDescriptorPool类型变量提供描述符池的句柄。 -
调用
vkDestroyDescriptorPool(logical_device, descriptor_pool, nullptr)并提供logical_device和descriptor_pool变量以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值分配给descriptor_pool变量。
它是如何工作的...
销毁描述符池隐式释放了从它分配的所有描述符集。我们不需要首先释放单个描述符集。但是,由于这个原因,我们需要确保从池中分配的任何描述符集都没有被当前由硬件处理的命令引用。
当我们准备好时,可以像这样销毁描述符池:
if( VK_NULL_HANDLE != descriptor_pool ) {
vkDestroyDescriptorPool( logical_device, descriptor_pool, nullptr );
descriptor_pool = VK_NULL_HANDLE;
}
参见
在本章中查看以下配方:
- 创建描述符池
销毁描述符集布局
不再使用的描述符集布局应该被销毁。
如何操作...
-
使用名为
logical_device的VkDevice类型变量提供逻辑设备的句柄。 -
获取已创建的描述符集布局的句柄,并使用它来初始化一个名为
descriptor_set_layout的VkDescriptorSetLayout类型的变量。 -
调用
vkDestroyDescriptorSetLayout(logical_device, descriptor_set_layout, nullptr)并提供逻辑设备和描述符集布局的句柄,以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值分配给descriptor_set_layout变量。
它是如何工作的...
使用 vkDestroyDescriptorSetLayout() 函数销毁描述符集布局,如下所示:
if( VK_NULL_HANDLE != descriptor_set_layout ) {
vkDestroyDescriptorSetLayout( logical_device, descriptor_set_layout, nullptr );
descriptor_set_layout = VK_NULL_HANDLE;
}
参见
在本章中查看以下配方:
- 创建描述符集布局
销毁采样器
当我们不再需要采样器,并且我们确信它不再被挂起的命令使用时,我们可以销毁它。
如何操作...
-
获取创建采样器的逻辑设备的句柄,并将其存储在名为
logical_device的VkDevice类型变量中。 -
获取应该被销毁的采样器的句柄。通过名为
sampler的VkSampler类型变量提供它。 -
调用
vkDestroySampler(logical_device, sampler, nullptr)并提供logical_device和sampler变量,以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值分配给sampler变量。
它是如何工作的...
采样器是这样被销毁的:
if( VK_NULL_HANDLE != sampler ) {
vkDestroySampler( logical_device, sampler, nullptr );
sampler = VK_NULL_HANDLE;
}
我们不需要检查采样器的句柄是否为空,因为删除一个 VK_NULL_HANDLE 被忽略。我们这样做只是为了避免不必要的函数调用。但是,当我们删除采样器时,我们必须确保句柄(如果非空)是有效的。
参见
在本章中查看以下配方:
- 创建采样器
第六章:渲染通道和帧缓冲区
在本章中,我们将介绍以下食谱:
-
指定附件描述
-
指定子通道描述
-
指定子通道之间的依赖关系
-
创建渲染通道
-
创建一个帧缓冲区
-
准备用于几何渲染和后处理子通道的渲染通道
-
准备带有颜色和深度附件的渲染通道和帧缓冲区
-
开始一个渲染通道
-
转进下一个子通道
-
结束渲染通道
-
销毁帧缓冲区
-
销毁渲染通道
简介
在 Vulkan 中,绘图命令被组织到渲染通道中。渲染通道是一组子通道的集合,它描述了图像资源(颜色、深度/模板、输入附件)的使用:它们的布局是什么,以及这些布局如何在子通道之间转换,当我们向附件中渲染或从它们读取数据时,如果它们的内 容在渲染通道后还需要,或者如果它们的用途仅限于渲染通道的范围。
在渲染通道中存储的上述数据只是一个一般描述,或者说是元数据。实际参与渲染过程的资源是通过帧缓冲区指定的。通过它们,我们定义了哪些图像视图用于哪些渲染附件。
我们需要在发出(记录)渲染命令之前提前准备所有这些信息。有了这些知识,驱动程序可以大大优化绘图过程,限制渲染所需的内存量,或者甚至为某些附件使用非常快速的缓存,从而进一步提高性能。
在本章中,我们将学习如何将绘图操作组织成一系列渲染通道和子通道,这是使用 Vulkan 绘制任何内容所必需的。我们还将学习如何准备在渲染(绘图)过程中使用的渲染目标附件的描述以及如何创建帧缓冲区,这些帧缓冲区定义了将用作这些附件的实际图像视图。
指定附件描述
渲染通道代表一组称为附件的资源(图像),这些资源在渲染操作期间使用。这些分为颜色、深度/模板、输入或解析附件。在我们能够创建渲染通道之前,我们需要描述其中使用的所有附件。
如何操作...
-
创建一个类型为
VkAttachmentDescription的向量。将向量命名为attachments_descriptions。对于渲染通道中使用的每个附件,向attachments_descriptions向量中添加一个元素,并使用以下值为其成员:-
0对flags的值 -
给定附件的
format所选格式 -
samples的每像素样本数 -
对于
loadOp,指定在渲染过程开始时应在附件内容上执行的操作类型--如果附件内容应该被清除,则使用VK_ATTACHMENT_LOAD_OP_CLEAR值;如果其当前内容应该被保留,则使用VK_ATTACHMENT_LOAD_OP_LOAD值;如果打算自己覆盖整个附件并且不关心其当前内容,则使用VK_ATTACHMENT_LOAD_OP_DONT_CARE值(此参数用于颜色附件或深度/模板附件的深度方面)。 -
对于
storeOp,指定在渲染过程结束后如何处理附件的内容--如果应该保留,则使用VK_ATTACHMENT_STORE_OP_STORE值;如果渲染后不需要内容,则使用VK_ATTACHMENT_STORE_OP_DONT_CARE值(此参数用于颜色附件或深度/模板附件的深度方面)。 -
指定在渲染过程开始时,附件的模板(组件)应该如何处理,对于
stencilLoadOp(与loadOp成员相同,但用于深度/模板附件的模板方面) -
指定在渲染过程结束后,附件的模板(组件)应该如何处理,对于
stencilStoreOp(与storeOp相同,但用于深度/模板附件的模板方面) -
指定渲染过程开始时图像将具有的布局,对于
initialLayout -
指定图像在渲染过程结束后应自动过渡到的布局,对于
finalLayout
-
它是如何工作的...
当我们创建渲染过程时,我们必须创建一个附件描述数组。这是一个渲染过程中使用的所有附件的通用列表。然后,此数组中的索引用于子过程描述(参考指定子过程描述配方)。同样,当我们创建帧缓冲区并指定每个附件应使用的确切图像资源时,我们定义了一个列表,其中每个元素对应于附件描述数组中的元素。
通常,当我们绘制几何体时,我们至少将其渲染到一个颜色附件中。可能我们还想启用深度测试,因此还需要一个深度附件。此类常见场景的附件描述如下:
std::vector<VkAttachmentDescription> attachments_descriptions = {
{
0,
VK_FORMAT_R8G8B8A8_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
},
{
0,
VK_FORMAT_D16_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
}
};
在前面的示例中,我们指定了两个附件:一个使用R8G8B8A8_UNORM格式,另一个使用D16_UNORM格式。在渲染过程的开始时,这两个附件都应该被清除(类似于在帧开始时调用 OpenGL 的glClear()函数)。我们还想在渲染过程完成后保留第一个附件的内容,但不需要第二个附件的内容。对于两者,我们也指定了一个UNDEFINED初始布局。UNDEFINED布局始终可以用于初始/旧布局--这意味着当设置内存屏障时,我们不需要图像内容。
最终布局的值取决于我们打算在渲染通道之后如何使用图像。如果我们直接将渲染到交换链图像,并且希望在屏幕上显示它,我们应该使用PRESENT_SRC布局(如之前所示)。对于深度附件,如果我们不打算在渲染通道之后使用深度组件(这通常是真的),我们应该设置与渲染通道的最后一个子通道中指定的相同布局值。
也有可能渲染通道不使用任何附件。在这种情况下,我们不需要指定附件描述,但这种情况很少见。
参见
本章中的以下配方:
-
指定子通道描述
-
创建渲染通道
-
创建帧缓冲区
-
准备渲染通道和具有颜色和深度附件的帧缓冲区
指定子通道描述
在渲染通道中执行的操作被分组到子通道中。每个子通道代表我们渲染命令的一个阶段或一个阶段,其中使用渲染通道附件的一个子集(我们将数据渲染到其中或从中读取数据)。
渲染通道始终需要至少一个子通道,当开始一个渲染通道时,它会自动启动。对于每个子通道,我们需要准备一个描述。
准备工作
为了降低为每个子通道准备所需的参数数量,为此配方引入了一个自定义结构类型。它是 Vulkan 头文件中定义的VkSubpassDescription结构的一个简化版本。它具有以下定义:
struct SubpassParameters {
VkPipelineBindPoint PipelineType;
std::vector<VkAttachmentReference> InputAttachments;
std::vector<VkAttachmentReference> ColorAttachments;
std::vector<VkAttachmentReference> ResolveAttachments;
VkAttachmentReference const * DepthStencilAttachment;
std::vector<uint32_t> PreserveAttachments;
};
PipelineType成员定义了在子通道期间将使用的管道类型(图形或计算,尽管目前渲染通道内部仅支持图形管道)。InputAttachments是我们将在子通道期间读取数据的附件集合。ColorAttachments指定所有将用作颜色附件的附件(我们将在此期间将其渲染)。ResolveAttachments指定在子通道结束时应该解析哪些颜色附件(从多采样图像更改为非多采样/单采样图像)。如果使用DepthStencilAttachment,则指定在子通道期间用作深度和/或模板附件的附件。PreserveAttachments是一组在子通道中未使用但必须在整个子通道期间保留内容的附件。
如何操作...
-
创建一个名为
subpass_descriptions的std::vector<VkSubpassDescription>类型的向量变量。对于在渲染通道中定义的每个子通道,向subpass_descriptions向量添加一个元素,并使用以下值为其成员:-
flags的0值 -
pipelineBindPoint的VK_PIPELINE_BIND_POINT_GRAPHICS值(目前渲染通道内部仅支持图形管道) -
子通道中使用的输入附件数量为
inputAttachmentCount -
指向具有输入附件参数的数组中第一个元素的指针(如果子通行中没有使用输入附件,则为
nullptr值)用于pInputAttachments;对于pInputAttachments数组的每个成员,使用以下值:-
附件在所有渲染通行附件列表中的索引
attachment。 -
应在子通行开始时自动将给定的图像布局转换为
layout。
-
-
子通行中使用的颜色附件数量
colorAttachmentCount。 -
指向具有子通行颜色附件参数的数组中第一个元素的指针(如果子通行中没有使用颜色附件,则为
nullptr值)用于pColorAttachments;对于数组的每个成员,指定如第 4a 点和第 4b 点所述的值。 -
如果任何颜色附件应该被解析(从多采样变为单采样)对于
pResolveAttachments,指定与pColorAttachments具有相同元素数量的数组中第一个元素的指针,或者如果不需要解析任何颜色附件,则使用nullptr值;pResolveAttachments数组的每个成员对应于相同索引的颜色附件,并指定在子通行结束时给定颜色附件应解析到的附件;对于数组的每个成员,使用如第 4a 点和第 4b 点所述的指定值;如果给定的颜色附件不应解析,则使用VK_ATTACHMENT_UNUSED值作为附件索引。 -
对于
pDepthStencilAttachment,如果使用了深度/模板附件,则提供一个指向类型为VkAttachmentReference的变量的指针(如果子通行中没有使用深度/模板附件,则为nullptr值);对于此变量的成员,指定如第 4a 点和第 4b 点所述的值。 -
应保留内容的未使用附件数量
preserveAttachmentCount。 -
指向数组中第一个元素的指针,该数组包含应保留内容的附件索引(如果没有附件需要保留,则为
nullptr值)用于pPreserveAttachments。
-
它是如何工作的...
Vulkan 渲染通行必须至少有一个子通行。子通行参数定义在一个VkSubpassDescription元素数组中。每个这样的元素描述了在相应的子通行中如何使用附件。有单独的输入、颜色、解析和保留附件列表,以及深度/模板附件的单个条目。这些成员可能为空(或 null)。在这种情况下,对应类型的附件在子通行中不被使用。
描述的列表中的每个条目都是对在附件描述中为渲染通行指定的所有附件列表的引用(参见图指定附件描述)。此外,每个条目指定了一个图像在子通行期间应处于的布局。到指定布局的转换由驱动程序自动执行。
这里是一个使用自定义的SubpassParameters类型结构的代码示例,用于指定子通道定义:
subpass_descriptions.clear();
for( auto & subpass_description : subpass_parameters ) {
subpass_descriptions.push_back( {
0,
subpass_description.PipelineType,
static_cast<uint32_t>(subpass_description.InputAttachments.size()),
subpass_description.InputAttachments.data(),
static_cast<uint32_t>(subpass_description.ColorAttachments.size()),
subpass_description.ColorAttachments.data(),
subpass_description.ResolveAttachments.data(),
subpass_description.DepthStencilAttachment,
static_cast<uint32_t>(subpass_description.PreserveAttachments.size()),
subpass_description.PreserveAttachments.data()
} );
}
以下是一个定义一个与具有一个颜色附加项的示例相对应的子通道的代码示例:一个深度/模板附加项:
VkAttachmentReference depth_stencil_attachment = {
1,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
};
std::vector<SubpassParameters> subpass_parameters = {
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{},
{
{
0,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
}
},
{},
&depth_stencil_attachment,
{}
}
};
首先,我们为描述深度/模板附加的depth_stencil_attachment变量指定一个值。对于深度数据,使用附加描述列表中的第二个附加项;这就是为什么我们为其索引指定了1的值(参考指定附加描述配方)。并且因为我们想渲染到这个附加项,所以我们为其布局提供了VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL的值(驱动程序将自动执行转换,如果需要的话)。
在示例中,我们只使用一个颜色附加项。它是附加描述列表中的第一个附加项,因此我们为其索引使用0的值。当我们渲染到颜色附加项时,我们应该为其布局指定VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL的值。
最后一点——因为我们想渲染几何图形,我们需要使用图形管道。这是通过为PipelineType成员提供一个VK_PIPELINE_BIND_POINT_GRAPHICS值来完成的。
由于我们不使用输入附加项,并且我们不希望解析任何颜色附加项,因此它们对应的向量是空的。
参见
本章中的以下配方:
-
指定附加描述
-
创建渲染通道
-
创建帧缓冲区
-
准备用于几何渲染和后处理子通道的渲染通道
-
准备带有颜色和深度附加的渲染通道和帧缓冲区
指定子通道之间的依赖关系
当给定子通道中的操作依赖于同一渲染通道中较早子通道中的操作的结果时,我们需要指定子通道依赖。如果记录在渲染通道内的操作与之前执行的操作之间或执行在渲染通道之后的操作与在渲染通道内执行的操作之间存在依赖关系,这也需要。也可以在单个子通道内定义依赖关系。
定义子通道依赖类似于设置内存屏障。
如何做到这一点...
-
创建一个名为
subpass_dependencies的std::vector<VkSubpassDependency>类型的变量。对于每个依赖项,向subpass_dependencies向量添加一个新元素,并为其成员使用以下值:-
对于
srcSubpass,在第二组(“消耗”)操作之前(或对于渲染通道之前的VK_SUBPASS_EXTERNAL值)应该完成(“产生”)操作的子通道的索引 -
对于
dstSubpass,依赖于之前命令集(或渲染通道之后的VK_SUBPASS_EXTERNAL值)的操作的子通道的索引 -
生成由
srcStageMask的“消耗”命令读取结果的管道阶段集合 -
依赖于
dstStageMask的“生产”命令生成的数据的管道阶段集合 -
对于
srcAccessMask的“生产”命令发生的记忆操作类型 -
在
dstAccessMask的“消费”命令中将要执行的记忆操作类型 -
对于
dependencyFlags,如果依赖关系由区域定义,则使用VK_DEPENDENCY_BY_REGION_BIT值——这意味着为给定内存区域生成数据的操作必须在从同一区域读取数据的操作可以执行之前完成;如果没有指定此标志,则依赖关系是全局的,这意味着必须先生成整个图像的数据,然后才能执行“消费”命令。
-
它是如何工作的...
指定子阶段之间的依赖关系(或子阶段与渲染通道之前或之后的命令之间的依赖关系)与设置图像内存屏障非常相似,并且具有类似的目的。我们在想要指定一个子阶段的命令(或渲染通道之后的命令)依赖于另一个子阶段(或渲染通道之前执行的命令)的操作结果时这样做。我们不需要设置布局转换的依赖关系——这些是基于渲染通道附件和子阶段描述提供的信息自动执行的。更重要的是,当我们为不同的子阶段指定不同的附件布局,但在两个子阶段中,给定的附件都仅用于读取时,我们也不需要指定依赖关系。
当我们想在渲染通道内设置图像内存屏障时,也需要子阶段依赖关系。如果没有指定所谓的“自依赖关系”(源子阶段和目标子阶段的索引相同),我们无法做到这一点。然而,如果我们为给定的子阶段定义了这样的依赖关系,我们可以在其中记录一个内存屏障。在其他情况下,源子阶段索引必须低于目标子阶段索引(不包括VK_SUBPASS_EXTERNAL值)。
下面是一个示例,其中我们准备两个子阶段之间的依赖关系——第一个将几何体绘制到颜色和深度附件中,第二个使用颜色数据进行后处理(它从颜色附件中读取):
std::vector<VkSubpassDependency> subpass_dependencies = {
{
0,
1,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_INPUT_ATTACHMENT_READ_BIT,
VK_DEPENDENCY_BY_REGION_BIT
}
};
上述依赖关系设置在第一个和第二个子通道之间(索引值为 0 和 1)。对颜色附件的写入操作在 COLOR_ATTACHMENT_OUTPUT 阶段执行。后处理在片段着色器中完成,此阶段被定义为“消耗”阶段。当我们绘制几何体时,我们对颜色附件执行写入操作(访问掩码的值为 COLOR_ATTACHMENT_WRITE)。然后颜色附件被用作输入附件,在后处理子通道中我们从它读取(因此我们使用一个值为 INPUT_ATTACHMENT_READ 的访问掩码)。由于我们不需要从图像的其他部分读取数据,我们可以通过区域指定依赖关系(一个片段在第一个子通道中存储给定坐标的颜色值,下一个子通道中具有相同坐标的片段读取相同的值)。当我们这样做时,我们不应假设区域大于单个像素,因为区域的大小可能在不同的硬件平台上不同。
参见
本章中以下配方:
-
指定附件描述
-
指定子通道描述
-
创建渲染通道
-
准备几何体渲染和后处理子通道的渲染通道
创建渲染通道
渲染(绘制几何体)只能在渲染通道内执行。当我们还想要执行其他操作,例如图像后处理或准备几何体和光照预通道数据时,我们需要将这些操作排序到子通道中。为此,我们指定所有必需的附件描述、所有将操作分组到其中的子通道,以及这些操作之间必要的依赖关系。当这些数据准备就绪后,我们可以创建一个渲染通道。
准备工作
为了减少需要提供的参数数量,在本配方中,我们使用一个自定义的 SubpassParameters 类型结构(参考 指定子通道描述 配方)。
如何操作...
-
创建一个名为
attachments_descriptions的std::vector<VkAttachmentDescription>类型的变量,在其中指定所有渲染通道附件的描述(参考 指定附件描述 配方)。 -
准备一个名为
subpass_descriptions的std::vector<VkSubpassDescription>类型的变量,并使用它来定义子通道的描述(参考 指定子通道描述 配方)。 -
创建一个名为
subpass_dependencies的std::vector<VkSubpassDependency>类型的变量。为渲染通道中需要定义的每个依赖关系向此向量添加一个新成员(参考 指定子通道之间的依赖关系 配方)。 -
创建一个名为
render_pass_create_info的VkRenderPassCreateInfo类型的变量,并使用以下值初始化其成员:-
sType的值为VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO -
pNext的值为nullptr -
flags的值为0 -
attachments_descriptions向量中attachmentCount元素的个数 -
attachments_descriptions向量第一个元素的指针(如果为空,则为nullptr)用于pAttachments -
subpass_descriptions向量中subpassCount的元素数量 -
subpass_descriptions向量第一个元素的指针用于pSubpasses -
subpass_dependencies向量中dependencyCount的元素数量 -
subpass_dependencies向量第一个元素的指针(如果为空,则为nullptr)用于pDependencies
-
-
获取应创建渲染通道的逻辑设备的句柄。将其存储在名为
logical_device的VkDevice类型变量中。 -
在名为
render_pass的VkRenderPass类型变量中创建一个变量,其中将存储创建的渲染通道的句柄。 -
调用
vkCreateRenderPass(logical_device, &render_pass_create_info, nullptr, &render_pass)。对于调用,提供logical_device变量、render_pass_create_info变量的指针、一个nullptr值以及render_pass变量的指针。 -
通过检查是否返回了
VK_SUCCESS值来确保调用成功。
它是如何工作的...
渲染通道定义了所有子通道中操作使用附件的通用信息。这允许驱动程序优化工作并提高我们应用程序的性能。

渲染通道创建最重要的部分是数据准备——所有使用附件和子通道的描述以及子通道之间依赖关系的指定(参考本章中的 指定附件描述、指定子通道描述 和 指定子通道之间的依赖关系 章节中的食谱)。以下步骤可以简要表示如下:
SpecifyAttachmentsDescriptions( attachments_descriptions );
std::vector<VkSubpassDescription> subpass_descriptions;
SpecifySubpassDescriptions( subpass_parameters, subpass_descriptions );
SpecifyDependenciesBetweenSubpasses( subpass_dependencies );
然后当指定创建渲染通道函数的参数时使用这些数据:
VkRenderPassCreateInfo render_pass_create_info = {
VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(attachments_descriptions.size()),
attachments_descriptions.data(),
static_cast<uint32_t>(subpass_descriptions.size()),
subpass_descriptions.data(),
static_cast<uint32_t>(subpass_dependencies.size()),
subpass_dependencies.data()
};
VkResult result = vkCreateRenderPass( logical_device, &render_pass_create_info, nullptr, &render_pass );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a render pass." << std::endl;
return false;
}
return true;
但是,为了正确执行绘图操作,渲染通道是不够的,因为它只指定了操作如何按顺序进入子通道以及如何使用附件。没有关于用于这些附件的图像的信息。关于所有定义的附件使用的特定资源的信息存储在帧缓冲区中。
参见
本章中的以下食谱:
-
指定附件描述
-
指定子通道描述
-
指定子通道之间的依赖关系
-
创建帧缓冲区
-
开始渲染通道
-
推进到下一个子通道
-
结束渲染通道
-
销毁渲染通道
创建帧缓冲区
帧缓冲区与渲染通道一起使用。它们指定了在渲染通道中定义的相应附件应使用哪些图像资源。它们还定义了可渲染区域的大小。这就是为什么当我们想要记录绘图操作时,我们不仅需要创建渲染通道,还需要创建帧缓冲区。
如何操作...
-
获取应与帧缓冲区兼容的渲染通道句柄,并使用它初始化一个名为
render_pass的VkRenderPass类型变量。 -
准备一个表示图像子资源的图像视图句柄列表,这些子资源应用于渲染通道附件。将所有准备好的图像视图存储在名为
attachments的std::vector<VkImageView>类型变量中。 -
创建一个名为
framebuffer_create_info的VkFramebufferCreateInfo类型变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO值用于sType -
pNext的nullptr值 -
0值用于flags -
render_pass变量用于renderPass -
attachments向量中的元素数量用于attachmentCount -
指向
attachments向量第一个元素的指针(如果为空,则为nullptr值)用于pAttachments -
为
width选择可渲染区域的宽度 -
选择帧缓冲区的高度用于
height -
layers的帧缓冲区层数
-
-
获取用于创建并存储在名为
logical_device的VkDevice类型变量中的帧缓冲区句柄。 -
创建一个名为
framebuffer的VkFramebuffer类型变量,它将使用创建的帧缓冲区的句柄进行初始化。 -
调用
vkCreateFramebuffer( logical_device, &framebuffer_create_info, nullptr, &framebuffer ),我们提供logical_device变量、framebuffer_create_info变量的指针、一个nullptr值和一个指向framebuffer变量的指针。 -
确保帧缓冲区已正确创建,通过检查调用是否返回了
VK_SUCCESS值。
它是如何工作的...
帧缓冲区总是与渲染通道一起创建。它们定义了应用于渲染通道中指定附件的特定图像子资源,因此这两个对象类型应相互对应。

当我们创建帧缓冲区时,我们提供一个渲染通道对象,我们可以使用该对象使用给定的帧缓冲区。然而,我们不仅限于仅与指定的渲染通道一起使用它。我们还可以使用与提供的渲染通道兼容的所有渲染通道。
兼容的渲染通道是什么?首先,它们必须具有相同数量的子通道。每个子通道必须具有兼容的输入、颜色、解析和深度/模板附件集合。这意味着相应的附件的格式和样本数必须相同。然而,附件可以具有不同的初始、子通道和最终布局以及不同的加载和存储操作。
除了这些,帧缓冲区还定义了可渲染区域的尺寸--所有渲染都将被限制的维度。然而,我们需要记住的是,确保指定范围之外的像素/片段不被修改的责任在我们身上。为此,我们需要在管道创建期间或设置相应的动态状态时指定适当的参数(视口和裁剪测试)(参考第八章,图形和计算管道中的准备视口和裁剪测试状态食谱,以及第九章,命令记录和绘制中的设置动态视口和裁剪状态食谱)。
我们必须确保渲染只发生在在帧缓冲区创建期间指定的维度内。
当我们在命令缓冲区中开始一个渲染通道并使用给定的帧缓冲区时,我们还需要确保在该帧缓冲区中指定的图像子资源不用于任何其他目的。换句话说,如果我们将图像的某个部分用作帧缓冲区附件,那么在渲染通道期间我们不能以任何其他方式使用它。
为渲染通道附件指定的图像子资源不能在渲染通道的开始和结束之间用于任何其他(非附件)目的。
下面是一个负责创建帧缓冲区的代码示例:
VkFramebufferCreateInfo framebuffer_create_info = {
VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO,
nullptr,
0,
render_pass,
static_cast<uint32_t>(attachments.size()),
attachments.data(),
width,
height,
layers
};
VkResult result = vkCreateFramebuffer( logical_device, &framebuffer_create_info, nullptr, &framebuffer );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a framebuffer." << std::endl;
return false;
}
return true;
参见
在第四章,资源和内存,查看以下食谱:
-
创建一个图像
-
创建一个图像视图
本章中的以下食谱:
-
指定附件描述
-
创建一个帧缓冲区
准备几何渲染和后处理子通道的渲染通道
在开发游戏或 CAD 工具等应用程序时,我们经常需要先绘制几何图形,然后在整个场景渲染完毕后,应用称为后处理的附加图像效果。
在这个示例食谱中,我们将看到如何准备一个渲染通道,其中我们将有两个子通道。第一个子通道渲染到两个附件中--颜色和深度。第二个子通道从第一个颜色附件中读取数据并渲染到另一个颜色附件中--一个可以在渲染通道之后呈现(显示在屏幕上)的交换链图像。
准备工作
为了减少需要提供的参数数量,在这个食谱中我们使用一个自定义的结构体类型SubpassParameters(参考指定子通道描述食谱)。
如何做...
-
创建一个名为
attachments_descriptions的类型为std::vector<VkAttachmentDescription>的变量。向attachments_descriptions向量添加一个元素,描述第一个颜色附件。使用以下值初始化它:-
flags的0值 -
format的VK_FORMAT_R8G8B8A8_UNORM值 -
samples的VK_SAMPLE_COUNT_1_BIT值 -
loadOp的VK_ATTACHMENT_LOAD_OP_CLEAR值 -
storeOp的VK_ATTACHMENT_STORE_OP_DONT_CARE值 -
stencilLoadOp的VK_ATTACHMENT_LOAD_OP_DONT_CARE值 -
stencilStoreOp的VK_ATTACHMENT_STORE_OP_DONT_CARE值 -
initialLayout的VK_IMAGE_LAYOUT_UNDEFINED值 -
finalLayout的VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL值
-
-
向
attachments_descriptions向量中添加另一个元素,指定深度/模板附件。使用以下值初始化其成员:-
flags的0值 -
format的VK_FORMAT_D16_UNORM值 -
samples的VK_SAMPLE_COUNT_1_BIT值 -
loadOp的VK_ATTACHMENT_LOAD_OP_CLEAR值 -
storeOp的VK_ATTACHMENT_STORE_OP_DONT_CARE值 -
stencilLoadOp的VK_ATTACHMENT_LOAD_OP_DONT_CARE值 -
stencilStoreOp的VK_ATTACHMENT_STORE_OP_DONT_CARE值 -
initialLayout的VK_IMAGE_LAYOUT_UNDEFINED值 -
finalLayout的VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL值
-
-
向
attachments_descriptions向量中添加第三个元素。这次它将指定另一个颜色附件。使用以下值初始化它:-
flags的0值 -
format的VK_FORMAT_R8G8B8A8_UNORM值 -
samples的VK_SAMPLE_COUNT_1_BIT值
-
-
loadOp的VK_ATTACHMENT_LOAD_OP_CLEAR值 -
storeOp的VK_ATTACHMENT_STORE_OP_STORE值 -
stencilLoadOp的VK_ATTACHMENT_LOAD_OP_DONT_CARE值 -
stencilStoreOp的VK_ATTACHMENT_STORE_OP_DONT_CARE值 -
initialLayout的VK_IMAGE_LAYOUT_UNDEFINED值 -
finalLayout的VK_IMAGE_LAYOUT_PRESENT_SRC_KHR值
-
创建一个名为
depth_stencil_attachment的VkAttachmentReference类型的变量,并使用以下值初始化它:-
attachment的1值 -
layout的VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL值
-
-
创建一个名为
subpass_parameters的std::vector<SubpassParameters>类型的变量,并向此向量添加一个具有以下值的元素:-
PipelineType的VK_PIPELINE_BIND_POINT_GRAPHICS值 -
用于
InputAttachments的空向量 -
ColorAttachments的一个元素和一个以下值的向量:-
attachment的0值 -
layout的VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL值
-
-
用于
ResolveAttachments的空向量 -
DepthStencilAttachment的depth_stencil_attachment变量的指针 -
用于
PreserveAttachments的空向量
-
-
向
subpass_parameters中添加第二个元素,描述第二个子通道。使用以下值初始化其成员:-
PipelineType的VK_PIPELINE_BIND_POINT_GRAPHICS值 -
InputAttachments的一个元素和一个以下值的向量:-
attachment的0值 -
layout的VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL值
-
-
ColorAttachments的一个元素和一个以下值的向量:-
attachment的2值 -
layout的VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL值
-
-
用于
ResolveAttachments的空向量 -
DepthStencilAttachment的nullptr值 -
用于
PreserveAttachments的空向量
-
-
创建一个名为
subpass_dependencies的std::vector<VkSubpassDependency>类型的变量,它只有一个元素,其成员使用以下值:-
srcSubpass的0值 -
dstSubpass的1值 -
srcStageMask的VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT值 -
dstStageMask的VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT值 -
srcAccessMask的VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT值 -
dstAccessMask的VK_ACCESS_INPUT_ATTACHMENT_READ_BIT值 -
dependencyFlags的VK_DEPENDENCY_BY_REGION_BIT值
-
-
使用
attachments_descriptions、subpass_parameters和subpass_dependencies变量创建渲染通道。将句柄存储在名为render_pass的VkRenderPass类型的变量中(参考本章中的 创建渲染通道 配方)。
它是如何工作的...
在这个配方中,我们创建了一个包含三个附件的渲染通道。它们被指定如下:
std::vector<VkAttachmentDescription> attachments_descriptions = {
{
0,
VK_FORMAT_R8G8B8A8_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
},
{
0,
VK_FORMAT_D16_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
},
{
0,
VK_FORMAT_R8G8B8A8_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
},
};
首先有一个颜色附件,我们在第一个子阶段向其中渲染,并在第二个子阶段从中读取。第二个附件用于深度数据;第三个是另一个颜色附件,我们在第二个子阶段向其中渲染。由于我们不需要在渲染通道之后第一个和第二个附件的内容(我们只需要在第二个子阶段中第一个附件的内容),所以我们为它们的存储操作指定了 VK_ATTACHMENT_STORE_OP_DONT_CARE 值。我们也不需要在渲染通道开始时需要它们的内容,所以我们指定了一个 UNDEFINED 初始布局。我们还清除了所有三个附件。
接下来我们定义两个子阶段:
VkAttachmentReference depth_stencil_attachment = {
1,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
};
std::vector<SubpassParameters> subpass_parameters = {
// #0 subpass
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{},
{
{
0,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
}
},
{},
&depth_stencil_attachment,
{}
},
// #1 subpass
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{
{
0,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
},
{
{
2,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
}
},
{},
nullptr,
{}
}
};
第一个子阶段使用颜色附件和深度附件。第二个子阶段从第一个附件(在此用作输入附件)读取,并将渲染到第三个附件中。
最后,我们需要定义两个子阶段之间第一个附件的依赖关系,该附件最初是颜色附件(我们向其中写入数据),然后是输入附件(我们从其中读取数据)。之后,我们可以像这样创建渲染通道:
std::vector<VkSubpassDependency> subpass_dependencies = {
{
0,
1,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_INPUT_ATTACHMENT_READ_BIT,
VK_DEPENDENCY_BY_REGION_BIT
}
};
if( !CreateRenderPass( logical_device, attachments_descriptions, subpass_parameters, subpass_dependencies, render_pass ) ) {
return false;
}
return true;
参见
本章中的以下配方:
-
指定附件描述
-
指定子阶段描述
-
指定子阶段之间的依赖关系
-
创建渲染通道
准备带有颜色和深度附件的渲染通道和帧缓冲区
渲染 3D 场景通常不仅涉及颜色附件,还涉及用于深度测试的深度附件(我们希望远离相机的对象被靠近相机的对象遮挡)。
在这个示例配方中,我们将看到如何创建用于颜色和深度数据的图像,以及一个具有单个子阶段的渲染通道,该子阶段将渲染到颜色和深度附件中。我们还将创建一个帧缓冲区,该帧缓冲区将使用这两个图像作为渲染通道的附件。
准备工作
与本章中较早的配方一样,在这个配方中,我们将使用类型为 SubpassParameters 的自定义结构(参考 指定子阶段描述 配方)。
如何做到这一点...
-
创建一个带有
VK_FORMAT_R8G8B8A8_UNORM格式、VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT用法和VK_IMAGE_ASPECT_COLOR_BIT方面的 2D 图像及其视图。选择图像的其余参数。将创建的句柄存储在名为color_image的VkImage类型的变量中,名为color_image_memory_object的VkDeviceMemory类型的变量中,以及名为color_image_view的VkImageView类型的变量中(参考第四章 Creating a 2D image and view 中的配方,资源和内存)。 -
创建一个带有
VK_FORMAT_D16_UNORM格式、VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT用法、VK_IMAGE_ASPECT_DEPTH_BIT方面和与存储在color_image变量中的图像相同大小的第二个 2D 图像及其视图。选择图像的其余参数。将创建的句柄存储在名为depth_image的VkImage类型的变量中,名为depth_image_memory_object的VkDeviceMemory类型的变量中,以及名为depth_image_view的VkImageView类型的变量中(参考第四章 Creating a 2D image and view 中的配方,资源和内存)。 -
创建一个名为
attachments_descriptions的std::vector<VkAttachmentDescription>类型的变量,并向该向量添加两个元素。使用以下值初始化第一个元素:-
0的值用于flags -
VK_FORMAT_R8G8B8A8_UNORM的值用于format -
VK_SAMPLE_COUNT_1_BIT的值用于samples -
VK_ATTACHMENT_LOAD_OP_CLEAR的值用于loadOp -
VK_ATTACHMENT_STORE_OP_STORE的值用于storeOp -
VK_ATTACHMENT_LOAD_OP_DONT_CARE的值用于stencilLoadOp -
VK_ATTACHMENT_STORE_OP_DONT_CARE的值用于stencilStoreOp -
VK_IMAGE_LAYOUT_UNDEFINED的值用于initialLayout -
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL的值用于finalLayout
-
-
使用这些值初始化
attachments_descriptions向量第二个元素的成员:-
0的值用于flags -
VK_FORMAT_D16_UNORM的值用于format -
VK_SAMPLE_COUNT_1_BIT的值用于samples -
VK_ATTACHMENT_LOAD_OP_CLEAR的值用于loadOp -
VK_ATTACHMENT_STORE_OP_STORE的值用于storeOp -
VK_ATTACHMENT_LOAD_OP_DONT_CARE的值用于stencilLoadOp -
VK_ATTACHMENT_STORE_OP_DONT_CARE的值用于stencilStoreOp -
VK_IMAGE_LAYOUT_UNDEFINED的值用于initialLayout -
VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL的值用于finalLayout
-
-
创建一个名为
depth_stencil_attachment的VkAttachmentReference类型的变量,并使用以下值进行初始化:-
1的值用于attachment -
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL的值用于layout
-
-
创建一个名为
subpass_parameters的std::vector<SubpassParameters>类型的向量。向该向量添加一个元素,并使用以下值进行初始化:-
VK_PIPELINE_BIND_POINT_GRAPHICS的值用于PipelineType -
InputAttachments的一个空向量 -
一个只有一个元素的向量,这些值用于
ColorAttachments:-
0的值用于attachment -
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL的值用于layout
-
-
一个空的向量用于
ResolveAttachments -
一个指向
depth_stencil_attachment变量的指针,用于DepthStencilAttachment -
一个空的向量用于
PreserveAttachments
-
-
创建一个名为
subpass_dependencies的std::vector<VkSubpassDependency>类型的向量,使用这些值初始化单个元素:-
0的值用于srcSubpass -
VK_SUBPASS_EXTERNAL的值用于dstSubpass -
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT的值用于srcStageMask -
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT的值用于dstStageMask -
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT的值用于srcAccessMask -
VK_ACCESS_SHADER_READ_BIT的值用于dstAccessMask -
0的值用于dependencyFlags
-
-
使用
attachments_descriptions、subpass_parameters和subpass_dependencies向量创建一个名为render_pass的VkRenderPass类型的变量。将创建的渲染通道句柄存储在名为render_pass的变量中(参考本章中的创建渲染通道配方)。 -
使用
render_pass变量和color_image_view变量作为其第一个附件,以及depth_image_view变量作为第二个附件创建一个帧缓冲区。指定与color_image和depth_image变量相同的维度。将创建的帧缓冲区句柄存储在名为framebuffer的VkFramebuffer类型的变量中。
它是如何工作的...
在这个示例配方中,我们希望将渲染到两个图像中——一个用于颜色数据,另一个用于深度数据。我们暗示在渲染通道之后它们将被用作纹理(我们将在另一个渲染通道的着色器中采样它们);这就是为什么它们被创建为COLOR_ATTACHMENT / DEPTH_STENCIL_ATTACHMENT用法(以便我们可以将渲染到它们中)和SAMPLED用法(以便它们都可以在着色器中被采样):
if( !Create2DImageAndView( physical_device, logical_device, VK_FORMAT_R8G8B8A8_UNORM, { width, height }, 1, 1, VK_SAMPLE_COUNT_1_BIT,
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_ASPECT_COLOR_BIT, color_image, color_image_memory_object, color_image_view ) ) {
return false;
}
if( !Create2DImageAndView( physical_device, logical_device, VK_FORMAT_D16_UNORM, { width, height }, 1, 1, VK_SAMPLE_COUNT_1_BIT,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_ASPECT_DEPTH_BIT, depth_image, depth_image_memory_object, depth_image_view ) ) {
return false;
}
接下来,我们指定渲染通道的两个附件。它们都在渲染通道的开始时清除,并在渲染通道之后保留其内容:
std::vector<VkAttachmentDescription> attachments_descriptions = {
{
0,
VK_FORMAT_R8G8B8A8_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
},
{
0,
VK_FORMAT_D16_UNORM,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL,
}
};
下一步是定义一个单独的子通道。它使用第一个附件进行颜色写入,第二个附件进行深度/模板数据:
VkAttachmentReference depth_stencil_attachment = {
1,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
};
std::vector<SubpassParameters> subpass_parameters = {
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{},
{
{
0,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
}
},
{},
&depth_stencil_attachment,
{}
}
};
最后,我们定义子通道与渲染通道之后将执行的命令之间的依赖关系。这是必需的,因为我们不希望其他命令在渲染通道的内容完全写入之前开始读取我们的图像。我们还创建了渲染通道和帧缓冲区:
std::vector<VkSubpassDependency> subpass_dependencies = {
{
0,
VK_SUBPASS_EXTERNAL,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_SHADER_READ_BIT,
0
}
};
if( !CreateRenderPass( logical_device, attachments_descriptions, subpasses_parameters, subpasses_dependencies, render_pass ) ) {
return false;
}
if( !CreateFramebuffer( logical_device, render_pass, { color_image_view, depth_image_view }, width, height, 1, framebuffer ) ) {
return false;
}
return true;
参见
-
在第四章,资源和内存中,查看以下配方:
- 创建二维图像和视图
-
本章中的以下配方:
-
指定附件描述
-
指定子通道描述
-
指定子通道之间的依赖关系
-
创建渲染通道
-
创建帧缓冲区
-
开始渲染通道
当我们创建了一个渲染通道和帧缓冲区,并且准备开始记录渲染几何形状所需的命令时,我们必须记录一个开始渲染通道的操作。这也会自动开始其第一个子通道。
如何操作...
-
获取存储在类型为
VkCommandBuffer的变量command_buffer中的命令缓冲区句柄。确保命令缓冲区处于记录状态。 -
使用渲染通道的句柄初始化一个类型为
VkRenderPass的变量render_pass。 -
获取与
render_pass兼容的帧缓冲区,并将其句柄存储在类型为VkFramebuffer的变量framebuffer中。 -
指定渲染通道期间渲染将被限制的渲染区域的尺寸。此区域不能大于帧缓冲区指定的尺寸。将尺寸存储在类型为
VkRect2D的变量render_area中。 -
创建一个类型为
std::vector<VkClearValue>的变量,命名为clear_values,其元素数量等于渲染通道中附件的数量。对于每个使用清除loadOp的渲染通道附件,提供与附件索引相同的索引处的相应清除值。 -
准备一个类型为
VkSubpassContents的变量subpass_contents,描述第一个子通道中操作的记录方式。如果命令直接记录且没有执行二级命令缓冲区,则使用VK_SUBPASS_CONTENTS_INLINE值;或使用VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS值来指定子通道的命令存储在二级命令缓冲区中,并且仅使用执行二级命令缓冲区命令(参考第九章 执行主命令缓冲区内的二级命令缓冲区 的配方,命令记录和绘制)。 -
创建一个类型为
VkRenderPassBeginInfo的变量,命名为render_pass_begin_info,并使用这些值初始化其成员:-
VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO值用于sType -
nullptr值用于pNext -
render_pass变量用于renderPass -
framebuffer变量用于framebuffer -
render_area变量用于renderArea -
clear_values向量中的元素数量用于clearValueCount -
clear_values向量第一个元素的指针(如果为空,则为nullptr值)用于pClearValues
-
-
调用
vkCmdBeginRenderPass(command_buffer, &render_pass_begin_info, subpass_contents),提供command_buffer变量、render_pass_begin_info变量的指针和subpass_contents变量。
它是如何工作的...
开始渲染通道会自动开始其第一个子通道。在此操作完成之前,所有指定了清除 loadOp 的附件都会被清除--填充为单一颜色。用于清除的值(以及启动渲染通道所需的其他参数)在类型为 VkRenderPassBeginInfo 的变量中指定:
VkRenderPassBeginInfo render_pass_begin_info = {
VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,
nullptr,
render_pass,
framebuffer,
render_area,
static_cast<uint32_t>(clear_values.size()),
clear_values.data()
};
清除值的数组必须有至少与从开始到最后清除的附件(正在清除的具有最大索引的附件)对应的元素。最好有与渲染通道中附件数量相同的清除值,但我们只需要为清除的提供值。如果没有附件被清除,我们可以为清除值数组提供一个nullptr值。
当我们开始渲染通道时,我们还需要提供渲染区域的尺寸。它可以与帧缓冲区的尺寸一样大,但可以更小。确保渲染被限制在指定的区域取决于我们,或者此范围之外的像素可能成为未定义的。
要开始渲染通道,我们需要调用:
vkCmdBeginRenderPass( command_buffer, &render_pass_begin_info, subpass_contents );
参见
-
在第三章,命令缓冲区和同步中,查看以下配方:
- 开始命令缓冲区记录操作
-
在第九章,命令记录和绘制中,查看以下配方:
- 在主命令缓冲区内部执行二级命令缓冲区
-
本章中的以下配方:
-
创建渲染通道
-
创建帧缓冲区
-
进入下一个子通道
在渲染通道内记录的命令被分为子通道。当给定子通道的一组命令已经记录,而我们想要记录另一个子通道的命令时,我们需要切换(或进入)下一个子通道。
如何操作...
-
获取正在记录的命令缓冲区的句柄,并将其存储在名为
command_buffer的VkCommandBuffer类型变量中。确保开始渲染通道的操作已经记录在command_buffer中。 -
指定子通道命令的记录方式:直接或通过二级命令缓冲区。将适当的值存储在名为
subpass_contents的VkSubpassContents类型变量中(参考开始渲染通道配方)。 -
调用
vkCmdNextSubpass(command_buffer, subpass_contents)。对于调用,提供command_buffer和subpass_contents变量。
它是如何工作的...
进入下一个子通道会将当前渲染通道切换到下一个子通道。在此操作过程中,将执行适当的布局转换,并引入内存和执行依赖(类似于内存屏障)。所有这些操作都由驱动程序自动执行,如果需要,以便新子通道中的附件可以按照在渲染通道创建期间指定的方式使用。进入下一个子通道还会对指定的颜色附件执行多采样解析操作。
子通道中的命令可以直接记录,通过在命令缓冲区中内联它们,或者间接通过执行二级命令缓冲区。
要记录从一个子通道切换到另一个子通道的操作,我们需要调用一个单一函数:
vkCmdNextSubpass( command_buffer, subpass_contents );
参见
本章中的以下食谱:
-
指定子通道描述
-
创建渲染通道
-
开始渲染通道
-
结束渲染通道
结束渲染通道
当所有子通道的所有命令都已记录时,我们需要结束(停止或完成)渲染通道。
如何操作...
-
取出命令缓冲区的句柄并将其存储在名为
command_buffer的VkCommandBuffer类型变量中。确保命令缓冲区处于记录状态,并且开始渲染通道的操作已经记录在其中。 -
调用
vkCmdEndRenderPass( command_buffer ),并提供command_buffer变量。
它是如何工作的...
要结束渲染通道,我们需要调用一个单一函数:
vkCmdEndRenderPass( command_buffer );
在命令缓冲区中记录此函数执行多个操作。引入执行和内存依赖(如内存屏障中的那些)并执行图像布局转换——图像从为最后一个子通道指定的布局转换到最后布局的值(参考指定附件描述食谱)。此外,对于在最后一个子通道中指定了解决的彩色附件,执行多采样解决。另外,对于在渲染通道之后应保留内容的附件,可能将附件数据从缓存传输到图像的内存中。
参见
本章中的以下食谱:
-
指定子通道描述
-
创建渲染通道
-
开始渲染通道
-
推进到下一个子通道
销毁帧缓冲区
当帧缓冲区不再被挂起的命令使用,并且我们不再需要它时,我们可以销毁它。
如何操作...
-
使用创建帧缓冲区的逻辑设备的句柄初始化一个名为
logical_device的VkDevice类型变量。 -
取出帧缓冲区的句柄并将其存储在名为
framebuffer的VkFramebuffer类型变量中。 -
执行以下调用:
vkDestroyFramebuffer( logical_device, framebuffer, nullptr ),其中我们提供logical_device和framebuffer变量以及一个nullptr值。 -
出于安全原因,在
framebuffer变量中存储VK_NULL_HANDLE值。
它是如何工作的...
使用vkDestroyFramebuffer()函数调用销毁帧缓冲区。然而,在我们能够销毁它之前,我们必须确保不再在硬件上执行引用给定帧缓冲区的命令。
以下代码销毁了一个帧缓冲区:
if( VK_NULL_HANDLE != framebuffer ) {
vkDestroyFramebuffer( logical_device, framebuffer, nullptr );
framebuffer = VK_NULL_HANDLE;
}
参见
本章中的以下食谱:
- 创建帧缓冲区
销毁渲染通道
如果渲染通道不再需要,并且它不再被提交到硬件的命令使用,我们可以销毁它。
如何操作...
-
使用创建渲染通道的逻辑设备的句柄来初始化一个名为
logical_device的VkDevice类型变量。 -
将应销毁的渲染通道的句柄存储在名为
render_pass的VkRenderPass类型变量中。 -
调用
vkDestroyRenderPass( logical_device, render_pass, nullptr )并提供logical_device和render_pass变量以及一个nullptr值。 -
由于安全原因,将
VK_NULL_HANDLE值分配给render_pass变量。
它是如何工作的...
删除渲染通道只需一个函数调用,如下所示:
if( VK_NULL_HANDLE != render_pass ) {
vkDestroyRenderPass( logical_device, render_pass, nullptr );
render_pass = VK_NULL_HANDLE;
}
参见
本章中的以下配方:
- 创建渲染通道
第七章:着色器
在本章中,我们将涵盖以下菜谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写顶点着色器
-
编写细分控制着色器
-
编写细分评估着色器
-
编写几何着色器
-
编写片段着色器
-
编写计算着色器
-
编写一个将顶点位置乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
编写纹理顶点和片段着色器
-
使用几何着色器显示多边形法线
简介
大多数现代图形硬件平台使用可编程管线渲染图像。3D 图形数据,如顶点和片段/像素,在一系列称为阶段的步骤中处理。某些阶段始终执行相同的操作,我们只能将其配置到一定程度。然而,还有一些阶段需要编程。控制这些阶段行为的程序称为着色器。
在 Vulkan 中,有五个可编程图形管线阶段--顶点、细分控制、评估、几何和片段。我们还可以为计算管线编写计算着色器程序。在核心 Vulkan API 中,我们使用 SPIR-V 编写的程序来控制这些阶段。它是一种中间语言,允许我们对图形数据进行处理,并在向量、矩阵、图像、缓冲区或采样器上执行数学计算。这种语言的底层特性提高了编译时间。然而,这也使得编写着色器变得更加困难。这就是为什么 Vulkan SDK 中包含一个名为 glslangValidator 的工具。
glslangValidator 允许我们将用 OpenGL 着色语言(简称 GLSL)编写的着色器程序转换为 SPIR-V 汇编。这样,我们可以用更方便的高级着色语言编写着色器,也可以轻松验证它们,然后在将它们与我们的 Vulkan 应用程序一起发布之前,将它们转换为 Vulkan API 所接受的表示形式。
在本章中,我们将学习如何使用 GLSL 编写着色器。我们将了解如何实现所有可编程阶段的着色器,如何实现细分或纹理,以及如何使用几何着色器进行调试。我们还将了解如何使用与 Vulkan SDK 一起分发的 glslangValidator 程序将用 GLSL 编写的着色器转换为 SPIR-V 汇编。
将 GLSL 着色器转换为 SPIR-V 汇编
Vulkan API 要求我们以 SPIR-V 汇编的形式提供着色器。它是一种二进制、中间表示形式,因此手动编写它是一项非常困难且繁琐的任务。在 GLSL 等高级着色语言中编写着色器程序要容易得多。之后,我们只需使用 glslangValidator 工具将它们转换为 SPIR-V 格式即可。
如何做到这一点...
-
下载并安装 Vulkan SDK(请参阅第一章下载 Vulkan SDK,实例和设备)。
-
打开命令提示符/终端,转到包含应转换的着色器文件的文件夹。
-
要将存储在
<input>文件中的 GLSL 着色器转换为存储在<output>文件中的 SPIR-V 汇编,请运行以下命令:
glslangValidator -H -o <output> <input> > <output_txt>
它是如何工作的...
glslangValidator 工具与 Vulkan SDK 一起分发。它位于 SDK 的VulkanSDK/<version>/bin(64 位版本)或VulkanSDK/<version>/bin32(32 位版本)子文件夹中。它具有许多功能,但其主要功能之一是将 GLSL 着色器转换为 SPIR-V 汇编,这些汇编可以被 Vulkan 应用程序消费。
将 GLSL 着色器转换为 SPIR-V 汇编的 glslangValidator 工具与 Vulkan SDK 一起分发。
工具会自动根据<input>文件的扩展名检测着色器阶段。可用选项包括:
-
vert用于顶点着色器阶段 -
tesc用于细分控制着色器阶段 -
tese用于细分评估着色器阶段 -
geom用于几何着色器阶段 -
frag用于片段着色器阶段 -
comp用于计算着色器
工具还可以以可读的文本形式显示 SPIR-V 汇编。本配方中提供的命令将此类形式存储在选定的<output_txt>文件中。
在将 GLSL 着色器转换为 SPIR-V 之后,这些着色器可以在应用程序中加载并用于创建着色器模块(参考第八章,“图形和计算管线”中的“创建着色器模块”配方)。
参见
-
在第一章,“实例和设备”中,查看以下配方:
- 下载 Vulkan SDK
-
本章中的以下配方:
-
编写顶点着色器
-
编写细分控制着色器
-
编写细分评估着色器
-
编写几何着色器
-
编写片段着色器
-
编写计算着色器
-
创建着色器模块
-
编写顶点着色器
顶点处理是第一个可编程的图形管线阶段。其主要目的是将构成我们几何形状的顶点的位置从它们的局部坐标系转换为称为裁剪空间的坐标系。裁剪坐标系用于允许图形硬件以更简单、更优化的方式执行所有后续步骤。其中一步是裁剪,它将处理后的顶点裁剪到可能可见的顶点,因此得名该坐标系。除此之外,我们还可以执行所有其他操作,这些操作对绘制的几何形状的每个顶点执行一次。
如何操作...
-
创建一个文本文件。为文件选择一个名称,但使用
vert扩展名(例如,shader.vert)。 -
在文件的第一行插入
#version 450。 -
定义一组顶点输入变量(属性),这些变量将应用于每个顶点(除非另有说明)。对于每个输入变量:
-
使用位置布局限定符和属性索引定义其位置:
layout( location = <index> ) -
提供一个
in存储限定符 -
指定输入变量的类型(例如
vec4、float、int3) -
提供输入变量的唯一名称
-
-
如有必要,定义一个输出(可变)变量,该变量将被传递(除非另有说明,否则将进行插值)到后续管道阶段。要定义每个输出变量:
-
使用位置布局限定符和索引提供变量的位置:
layout( location = <index> ) -
指定一个
out存储限定符 -
指定输出变量的类型(例如
vec3或int) -
选择输出变量的唯一名称
-
-
如有必要,定义与应用程序中创建的描述符资源对应的统一变量。要定义统一变量:
-
指定描述符集的数量和绑定号,其中可以访问给定资源:
layout (set=<set index>, binding=<binding index>) -
提供一个
uniform存储限定符 -
指定变量的类型(例如
sampler2D、imageBuffer) -
为变量定义一个唯一的名称
-
-
在
void main()函数中创建:-
执行所需的操作
-
将输入变量传递到输出变量(带有或不带变换)
-
将处理过的顶点(可能已变换)的位置存储在
gl_Position内置变量中。
-
它是如何工作的...
顶点处理(通过顶点着色器)是图形管道中的第一个可编程阶段。在 Vulkan 中创建的每个图形管道都必须包含它。其主要目的是将应用程序传递的顶点位置从局部坐标系转换为裁剪空间。如何进行转换取决于我们;我们可以省略它并提供已在裁剪空间中的坐标。如果后续阶段(细分或几何着色器)计算位置并将它们传递到管道中,则顶点着色器也可以完全不执行任何操作。
通常情况下,顶点着色器将应用程序提供的位置作为输入变量(坐标)之一,并将其(在左侧)与模型视图投影矩阵相乘。
顶点着色器的主要目的是获取顶点的位置,将其与模型视图投影矩阵相乘,并将结果存储在 gl_Position 内置变量中。
顶点着色器还可以执行其他操作,将结果传递到图形管道的后续阶段,或将它们存储在存储图像或缓冲区中。然而,我们必须记住,所有计算都是针对绘制几何体的每个顶点单独执行的。
在以下图像中,使用管线对象中启用了线框渲染的单个三角形被绘制。为了能够绘制非实体几何体,我们需要在创建逻辑设备时启用fillModeNonSolid功能(请参阅第一章,实例和设备中的获取物理设备的特性和属性和创建逻辑设备食谱)。

要绘制这个三角形,使用了简单的顶点着色器。以下是此着色器的源代码,使用 GLSL 编写:
#version 450
layout( location = 0 ) in vec4 app_position;
void main() {
gl_Position = app_position;
}
参见
-
本章中的以下食谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写乘以投影矩阵的顶点位置的顶点着色器
-
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建着色器模块
-
指定管线顶点绑定描述、属性描述和输入状态
-
创建图形管线
-
编写细分控制着色器
细分是一个将几何体分成更小部分的过程。在图形编程中,它允许我们以更灵活的方式提高渲染对象的细节数量,或动态地改变它们的参数,如平滑度或形状。
在 Vulkan 中,细分是可选的。如果启用,它将在顶点着色器之后执行。它有三个步骤,其中两个是可编程的。第一个可编程细分阶段用于设置控制细分如何执行参数。我们通过编写细分控制着色器来指定细分因子的值。
如何做到...
-
创建一个文本文件。为文件选择一个名称,但使用
tesc扩展名(例如,shader.tesc)。 -
在文件的第一行插入
#version 450。 -
定义将形成输出补丁的顶点数量:
layout( vertices = <count> ) out;
-
定义一组从(写入)顶点着色器阶段提供的输入变量(属性)。对于每个输入变量:
-
使用位置布局限定符和属性索引定义其位置:
layout( location = <index> ) -
提供一个
in存储限定符 -
指定输入变量的类型(例如
vec3,float) -
提供输入变量的唯一名称
-
-
如果需要,定义一个输出(可变)变量,该变量将被传递(除非另有说明,否则将进行插值)到后续管线阶段。为了定义每个输出变量:
-
使用位置布局限定符和索引提供变量的位置:
layout( location = <index> ) -
指定一个
out存储限定符 -
指定输出变量的类型(例如
ivec2或bool) -
选择输出变量的唯一名称
-
确保它被定义为无大小数组
-
-
如果需要,定义与在应用程序中创建的描述符资源对应的统一变量,这些变量可以在细分控制阶段访问。为了定义一个统一变量:
-
通过布局限定符,指定可以访问给定资源的描述符集数量和绑定号:
layout (set=<集合索引>, binding=<绑定索引>) -
提供一个
uniform存储限定符。 -
指定变量的类型(例如
sampler,image1D)。 -
定义变量的唯一名称。
-
-
在其中创建一个
void main()函数:-
执行所需的操作。
-
将输入变量传递到变量的输出数组中(带有或不带变换)。
-
通过
gl_TessLevelInner变量指定内部细分级别因子。 -
通过
gl_TessLevelOuter变量指定外部细分级别因子。 -
将处理过的补丁顶点的位置(可能已变换)存储在
gl_out[gl_InvocationID].gl_Position变量中。
-
它是如何工作的...
在 Vulkan 中,细分着色器是可选的;我们不必使用它们。当我们想要使用它们时,我们总是需要同时使用细分控制和细分评估着色器。我们还需要在创建逻辑设备时启用 tessellationShader 功能。
当我们想要在我们的应用程序中使用细分时,我们需要在创建逻辑设备时启用 tessellationShader 功能,并且在创建图形管线时需要指定细分控制和评估着色器阶段。
细分阶段在补丁上操作。补丁由顶点形成,但(与传统多边形相反)每个补丁可能具有任意数量的顶点--从 1 个到至少 32 个。
如其名所示,细分控制着色器指定由补丁形成的几何形状的细分方式。这是通过在着色器代码中指定的内部和外部细分因子来完成的。内部因子由内置的 gl_TessLevelInner[] 数组表示,指定补丁内部部分的细分方式。外部因子对应于 gl_TessLevelOuter[] 内置数组,定义补丁外部边缘的细分方式。每个数组元素对应于补丁的给定边缘。
着色器细分控制阶段为输出补丁中的每个顶点执行一次。当前顶点的索引可在内置的 gl_InvocationID 变量中找到。只能写入当前正在处理的顶点(对应于当前调用),但着色器可以通过 gl_in[].gl_Position 变量访问输入补丁的所有顶点。
一个指定任意细分因子并传递未修改位置的细分控制着色器的示例可能如下所示:
#version 450
layout( vertices = 3 ) out;
void main() {
if( 0 == gl_InvocationID ) {
gl_TessLevelInner[0] = 3.0;
gl_TessLevelOuter[0] = 3.0;
gl_TessLevelOuter[1] = 4.0;
gl_TessLevelOuter[2] = 5.0;
}
gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
}
与在 编写顶点着色器 配方中看到的相同三角形,使用前面的细分控制着色器和来自 编写细分评估着色器 配方的细分评估着色器绘制,应该看起来像这样:

相关内容
-
本章中的以下配方:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写细分评估着色器
-
-
在第八章 图形和计算管线 中,查看以下配方:
-
创建着色器模块
-
指定管线细分状态
-
创建图形管线
-
编写细分评估着色器
细分评估是细分过程中的第二个可编程阶段。它在几何体已经细分(细分)时执行,用于收集细分结果以形成新顶点并进一步修改它们。当启用细分时,我们需要编写细分评估着色器以获取生成顶点的位置并将它们提供给后续的管线阶段。
如何做到这一点...
-
创建一个文本文件。为文件选择一个名称,并使用
tese扩展名(例如,shader.tese)。 -
在文件的第一行插入
#version 450。 -
使用
in布局限定符,定义形成的原语类型(isolines、triangles或quads),形成顶点之间的间距(equal_spacing、fractional_even_spacing或fractional_odd_spacing),以及生成三角形的绕行顺序(cw以保持应用程序中提供的绕行或ccw以反转应用程序中提供的绕行):
layout( <primitive>, <spacing>, <winding> ) in;
-
定义一组从细分控制阶段提供的输入数组变量。对于每个输入变量:
-
使用位置布局限定符和属性索引定义其位置:
layout( location = <index> ) -
提供一个
in存储限定符 -
指定输入变量的类型(例如
vec2或int3) -
提供输入变量的唯一名称
-
确保它被定义为数组。
-
-
如果需要,定义一个输出(可变)变量,该变量将被传递(除非另有说明,否则将插值)到后续的管线阶段。为了定义每个输出变量:
-
使用位置布局限定符和索引提供变量的位置:
layout( location = <index> ) -
指定
out存储限定符 -
指定输出变量的类型(例如
vec4) -
选择输出变量的唯一名称。
-
-
如果需要,定义与在应用程序中创建的描述符资源对应的统一变量。为了定义统一变量:
-
指定描述符集的数量和绑定号,以便可以访问给定的资源:
layout (set=<set index>, binding=<binging index>) -
提供一个
uniform存储限定符 -
指定变量的类型(例如
sampler、image1D) -
定义变量的唯一名称。
-
-
创建一个
void main()函数,在其中:-
执行所需的操作
-
使用内置的
gl_TessCoord向量变量,使用所有补丁顶点的位置生成新顶点的位置;修改结果以实现所需的结果,并将其存储在内置变量gl_Position中 -
以类似的方式,使用
gl_TessCoord生成所有其他输入变量的插值值,并将它们存储在输出变量中(如果需要,进行额外的转换)。
-
它是如何工作的...
细分控制着色器和评估着色器是细分正确工作所需的两个可编程阶段。它们之间是一个基于控制阶段提供的参数执行实际细分的阶段。细分的结果在评估阶段获得,在该阶段它们被应用于形成新几何形状。
通过细分评估,我们可以控制新基本类型的对齐和形成方式:我们指定它们的绕行顺序和生成顶点之间的间距。我们还可以选择是否希望细分阶段创建等高线、三角形或四边形。
新顶点不是直接创建的--细分器只为新顶点(权重)生成重心的细分坐标,这些坐标在内置的gl_TessCoord变量中提供。我们可以使用这些坐标在形成补丁的原顶点位置之间进行插值,并将新顶点放置在正确的位置。这就是为什么评估着色器,尽管它对每个生成的顶点只执行一次,但可以访问形成补丁的所有顶点。它们的位置通过内置数组变量gl_in[]的gl_Position成员提供。
对于常用的三角形,仅传递新顶点而不进行进一步修改的细分评估着色器可能看起来像这样:
#version 450
layout( triangles, equal_spacing, cw ) in;
void main() {
gl_Position = gl_in[0].gl_Position * gl_TessCoord.x +
gl_in[1].gl_Position * gl_TessCoord.y +
gl_in[2].gl_Position * gl_TessCoord.z;
}
参见
-
本章中的以下配方:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写细分控制着色器
-
-
在第八章,图形和计算管线中,查看以下配方:
-
创建着色器模块
-
指定管线细分状态
-
创建图形管线
-
编写几何着色器
3D 场景由称为网格的对象组成。网格是由形成对象外部表面的顶点集合。这个表面通常由三角形表示。当我们渲染一个对象时,我们提供顶点并指定它们构成的基本类型(points、lines、triangles)。在顶点和可选的细分阶段处理顶点之后,它们被组装成指定的基本类型。我们还可以启用(也是可选的)几何阶段,并编写控制或改变从顶点形成基本类型过程的几何着色器。在几何着色器中,我们甚至可以创建新的基本类型或销毁现有的基本类型。
如何做...
-
创建一个文本文件。为文件选择一个名称,并使用
geom扩展名(例如,shader.geom)。 -
在文件的第一行插入
#version 450。 -
使用
in布局限定符,定义在应用程序中绘制的原语类型:points、lines、lines_adjacency、triangles或triangles_adjacency:
layout( <primitive type> ) in;
- 使用
out布局限定符,定义几何着色器形成的原语类型(输出)(points,line_strip或triangle_strip),以及着色器可能生成的最大顶点数:
layout( <primitive type>, max_vertices = <count> ) out;
-
定义一组从顶点或细分评估阶段提供的输入数组变量。对于每个输入变量:
-
使用位置布局限定符和属性索引定义其位置:
layout( location = <index> ) -
提供一个
in存储限定符 -
指定输入变量的类型(例如
ivec4,int或float) -
提供输入变量的唯一名称
-
确保变量被定义为无大小数组
-
-
如果需要,定义一个输出(可变)变量,该变量将被传递(除非另有说明,否则将进行插值)到片段着色器阶段。要定义每个输出变量:
-
使用位置布局限定符和索引提供变量的位置:
layout( location = <index> ) -
指定一个
out存储限定符 -
指定输出变量的类型(例如
vec3或uint) -
选择输出变量的唯一名称
-
-
如果需要,定义与在应用程序中创建的描述符资源对应的统一变量。要定义统一变量:
-
指定描述符集的数量和一个绑定号,以便可以访问给定的资源:
layout (set=<set index>, binding=<binging index>) -
提供一个
uniform存储限定符 -
指定变量的类型(例如
image2D,sampler1DArray) -
定义变量的唯一名称
-
-
在
void main()函数中创建:-
执行所需的操作
-
对于每个生成的或传递的顶点:
-
将值写入输出变量
-
将顶点的位置(可能已变换)存储在内置的
gl_Position变量中 -
调用
EmitVertex()向原语添加顶点
-
-
通过调用
EndPrimitive()函数完成原语的生成(隐式地开始另一个原语)。
-
它是如何工作的...
几何阶段是图形管线中的一个可选阶段。如果没有它,当我们绘制几何图形时,基于在图形管线创建期间指定的类型,会自动生成原语。几何着色器允许我们创建额外的顶点和原语,销毁应用程序中绘制的原语,或者更改由顶点形成原语的类型。
几何着色器对应用程序绘制的每个几何原语执行一次。它可以访问构成原语的所有顶点,甚至相邻的顶点。有了这些数据,它可以传递相同的或创建新的顶点和原语。我们必须记住,我们不应该在几何着色器中创建太多的顶点。如果我们想创建许多新的顶点,细分着色器更适合这项任务(并且性能更好)。仅仅增加几何着色器可能创建的最大顶点数,即使我们并不总是形成它们,也可能降低我们应用程序的性能。
我们应该尽可能降低几何着色器发出的顶点数。
几何着色器始终生成条带原语。如果我们想创建不形成条带的单独原语,我们只需在适当的时候结束原语即可--在原语结束后发出的顶点被添加到下一个条带中,这样我们就可以创建我们选择的任意数量的单独条带。以下是一个示例,它创建了原始三角形角落中的三个单独的三角形:
#version 450
layout( triangles ) in;
layout( triangle_strip, max_vertices = 9 ) out;
void main() {
for( int vertex = 0; vertex < 3; ++vertex ) {
gl_Position = gl_in[vertex].gl_Position + vec4( 0.0, -0.2, 0.0, 0.0 );
EmitVertex();
gl_Position = gl_in[vertex].gl_Position + vec4( -0.2, 0.2, 0.0, 0.0 );
EmitVertex();
gl_Position = gl_in[vertex].gl_Position + vec4( 0.2, 0.2, 0.0, 0.0 );
EmitVertex();
EndPrimitive();
}
}
当使用简单的透传顶点着色器和片段着色器绘制单个三角形,并且使用前面的几何着色器时,结果应该看起来像这样:

参见
-
本章中的以下食谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
使用几何着色器显示多边形法线
-
-
在第八章,图形和计算管道中,查看以下食谱:
-
创建着色器模块
-
指定管道输入装配状态
-
创建图形管道
-
编写片段着色器
片段(或像素)是图像的组成部分,可能显示在屏幕上。它们是通过称为光栅化的过程从几何(绘制的原语)创建的。它们具有特定的屏幕空间坐标(x、y 和深度),但没有其他数据。我们需要编写一个片段着色器来指定需要在屏幕上显示的颜色。在片段着色器中,我们还可以选择一个附件,将给定的颜色写入其中。
如何操作...
-
创建一个文本文件。为文件选择一个名称,但使用一个
frag扩展名(例如,shader.frag)。 -
在文件的第一行插入
#version 450。 -
定义一组从早期管道阶段提供的输入变量(属性)。对于每个输入变量:
-
使用位置布局限定符和属性索引定义其位置:
layout( location = <index> ) -
提供一个
in存储限定符 -
指定输入变量的类型(例如
vec4、float、ivec3) -
提供输入变量的唯一名称
-
-
定义一个输出变量,用于写入颜色。要定义每个输出变量:
-
使用位置布局限定符和数字提供变量的位置(附件的索引):
layout( location = <index> ) -
指定一个
out存储限定符 -
指定输出变量的类型(例如
vec3或vec4) -
选择输出变量的唯一名称
-
-
如果需要,定义与在应用程序中创建的描述符资源对应的统一变量。要定义一个统一变量:
-
指定描述符集的数量和一个绑定号,以便可以访问给定的资源:
layout (set=<set index>, binding=<binding index>) -
提供一个
uniform存储限定符 -
指定变量的类型(例如
sampler1D、subpassInput或imageBuffer) -
定义变量的唯一名称
-
-
在其中创建一个
void main()函数:-
执行所需的操作和计算
-
将处理过的片段的颜色存储在输出变量中
-
它是如何工作的...
我们在应用程序中绘制的几何形状由原语组成。这些原语在称为光栅化的过程中被转换为片段(像素)。对于每个这样的片段,都会执行一个片段着色器。片段可能在着色器内部或在进行帧缓冲区测试(如深度、模板或裁剪测试)时被丢弃,因此它们甚至不会成为像素——这就是为什么它们被称为片段而不是像素。
片段着色器的主要目的是设置将被(可能)写入附件的颜色。我们通常使用它们来执行光照计算和纹理映射。与计算着色器一样,片段着色器常用于后处理效果,如辉光或延迟着色/光照。此外,只有片段着色器可以访问在渲染过程中定义的输入附件(参考第五章中的创建输入附件配方,描述符集)。

要绘制前面图中的三角形,使用了一个简单的片段着色器,它存储了一个选择的、硬编码的颜色:
#version 450
layout( location = 0 ) out vec4 frag_color;
void main() {
frag_color = vec4( 0.8, 0.4, 0.0, 1.0 );
}
参见
-
本章中的以下配方:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写纹理着色器顶点和片段着色器
-
-
在第八章,图形和计算管线中,查看以下配方:
-
创建着色器模块
-
指定管线光栅化状态
-
创建图形管线
-
编写计算着色器
计算着色器,正如其名所示,用于通用数学计算。它们在定义的、三维大小的(本地)组中执行,这些组可能可以访问一组公共数据。同时,可以执行许多本地组以更快地生成结果。
如何做到这一点...
-
创建一个文本文件。为文件选择一个名称,但使用
comp扩展名(例如,shader.comp)。 -
在文件的第一行插入
#version 450。 -
使用输入布局限定符,定义本地工作组的尺寸:
layout( local_size_x = <x size>, local_size_y = <y size>, local_size_z = <z size> ) in;
-
定义与应用程序中创建的描述符资源对应的统一变量。要定义统一变量:
-
指定描述符集的数量和绑定号,以便可以访问给定的资源:
layout (set=<set index>, binding=<binding index>) -
提供一个
uniform存储限定符 -
指定变量的类型(例如
image2D或buffer) -
定义变量的唯一名称
-
-
创建一个
void main()函数,其中:-
执行所需的操作和计算
-
将结果存储在选定的统一变量中
-
它是如何工作的...
计算着色器只能在专用计算管线中使用。它们也不能在渲染过程中(调度)执行。
计算着色器没有从早期或传递到后续管线阶段的输入或输出(用户定义)变量--它是计算管线中的唯一阶段。必须使用统一变量作为计算着色器数据的来源。同样,在计算着色器中执行的计算结果只能存储在统一变量中。
有一些内置输入变量可以提供有关给定着色器调用在局部工作组中的索引(通过uvec3 gl_LocalInvocationID变量)、同时分发的作业组数量(通过uvec3 gl_NumWorkGroups变量)或当前工作组的数量(uvec3 gl_WorkGroupID变量)的信息。还有一个变量可以唯一标识所有工作组中所有调用中的当前着色器--uvec3 gl_GlobalInvocationID。其值计算如下:
gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID
通过输入布局限定符定义局部工作组的大小。在着色器内部,定义的大小也通过内置变量uvec3 gl_WorkGroupSize可用。
在以下代码中,你可以找到一个使用gl_GlobalInvocationID变量生成简单静态分形图像的计算着色器示例:
#version 450
layout( local_size_x = 32, local_size_y = 32 ) in;
layout( set = 0, binding = 0, rgba8 ) uniform image2D StorageImage;
void main() {
vec2 z = gl_GlobalInvocationID.xy * 0.001 - vec2( 0.0, 0.4 );
vec2 c = z;
vec4 color = vec4( 0.0 );
for( int i=0; i<50; ++I ) {
z.x = z.x * z.x-- z.y * z.y + c.x;
z.y = 2.0 * z.x * z.y + c.y;
if( dot( z, z ) > 10.0 ) {
color = i * vec4( 0.1, 0.15, 0.2, 0.0 );
break;
}
}
imageStore( StorageImage, ivec2( gl_GlobalInvocationID.xy ), color );
}
前面的计算着色器在分发时生成以下结果:

参见
-
本章中的以下食谱:
- 将 GLSL 着色器转换为 SPIR-V 汇编
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建着色器模块
-
创建计算管线
-
编写乘以投影矩阵的顶点位置的计算着色器
将几何体从局部空间转换到裁剪空间通常由顶点着色器执行,尽管任何其他顶点处理阶段(细分或几何)也可能完成此任务。转换是通过指定模型、视图和投影矩阵,并将它们作为三个单独的矩阵或一个连接的模型-视图-投影矩阵(简称 MVP)从应用程序提供给着色器来完成的。最常见和简单的方法是通过统一缓冲区提供这样的矩阵。
如何操作...
-
在名为
shader.vert的文本文件中创建一个顶点着色器(参考编写顶点着色器食谱)。 -
定义一个通过它将顶点位置提供给顶点着色器的输入变量(属性):
layout(location = 0) in vec4 app_position;
- 定义一个包含
mat4类型变量的统一缓冲区,通过该缓冲区提供组合模型-视图-投影矩阵的数据:
layout(set=0, binding=0) uniform UniformBuffer {
mat4 ModelViewProjectionMatrix;
};
- 在
void main()函数内部,通过将ModelViewProjectionMatrix统一变量与app_position输入变量相乘,并在以下方式中将结果存储在内置变量gl_Position中,计算裁剪空间中的顶点位置:
gl_Position = ModelViewProjectionMatrix * app_position;
它是如何工作的...
当我们准备在 3D 应用程序中绘制的几何体时,几何体通常在局部坐标系中建模——这是艺术家创建模型更方便的坐标系。然而,图形管线期望顶点被转换到裁剪空间,因为这个坐标系进行许多操作更容易(并且更快)。通常,是顶点着色器执行这个转换。为此,我们需要准备一个表示透视或正交投影的矩阵。从局部空间到裁剪空间的转换只需将矩阵乘以顶点的位置。
除了投影之外,相同的矩阵还可能包含其他操作,通常称为模型视图变换。由于绘制的几何体可能包含数百或数千个顶点,通常在应用程序中乘以模型、视图和投影矩阵会更优,并提供一个单一的、连接的 MVP 矩阵给需要执行单个乘法的着色器:
#version 450
layout(location = 0) in vec4 app_position;
layout(set=0, binding=0) uniform UniformBuffer {
mat4 ModelViewProjectionMatrix;
};
void main() {
gl_Position = ModelViewProjectionMatrix * app_position;
}
前面的着色器要求应用程序准备一个缓冲区,其中存储矩阵数据(参考第五章,描述符集中的创建统一缓冲区食谱)。然后,这个缓冲区(在当前示例中)被绑定到描述符集的0号绑定,该描述符集随后被绑定到命令缓冲区作为0号集(参考第五章,描述符集中的更新描述符集和绑定描述符集食谱)。

参见
-
在第五章,描述符集中,查看以下食谱:
-
创建统一缓冲区
-
更新描述符集
-
绑定描述符集
-
-
本章中的以下食谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写顶点着色器
-
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建着色器模块
-
创建图形管线
-
在着色器中使用推送常量
当我们向着色器提供数据时,通常使用统一缓冲区、存储缓冲区或其他类型的描述符资源。不幸的是,更新这些资源可能并不太方便,尤其是当我们需要提供频繁变化的数据时。
为了这个目的,引入了推送常量。通过它们,我们可以以简化和更快的方式提供数据,而不是通过更新描述符资源。然而,我们需要适应更小的可用空间。
在 GLSL 着色器中访问推送常量与使用统一缓冲区类似。
如何做到这一点...
-
创建着色器文件。
-
定义一个统一块:
-
提供一个
push_constant布局限定符:layout(push_constant) -
使用
uniform存储限定符
-
-
-
提供块的唯一名称
-
在大括号内,定义一组统一变量
-
指定块实例的名称
<instance name>。
-
-
在
void main()函数内部,使用块实例名称访问统一变量:
<instance name>.<variable name>
它是如何工作的...
推送常量的定义和访问方式类似于在 GLSL 着色器中指定统一块,但有一些差异我们需要记住:
-
我们需要在块的定义之前使用
layout(push_constant)限定符 -
我们必须为该块指定一个实例名称
-
我们可以在每个着色器中定义这样一个块
-
我们通过在变量名前加上块的实例名称来访问推送常量变量:
<instance name>.<variable name>
推送常量对于提供频繁变化的小量数据非常有用,例如变换矩阵或当前时间值--更新推送常量块应该比更新描述符资源(如统一缓冲区)快得多。我们只需要记住数据大小,它比描述符资源小得多。规范要求推送常量至少存储 128 字节的数据。每个硬件平台可能允许更多的存储空间,但可能不会显著更大。
推送常量可以存储至少 128 字节的数据。
定义和使用推送常量的一个示例,通过片段着色器提供颜色,可能看起来像这样:
#version 450
layout( location = 0 ) out vec4 frag_color;
layout( push_constant ) uniform ColorBlock {
vec4 Color;
} PushConstant;
void main() {
frag_color = PushConstant.Color;
}
参见
-
本章中的以下食谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写一个乘以投影矩阵的顶点位置的顶点着色器
-
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建着色器模块
-
创建管线布局
-
-
在第九章,命令记录和绘制中,查看以下食谱:
- 通过推送常量向着色器提供数据
编写纹理化的顶点着色器和片段着色器
纹理化是一种常见的技巧,它可以显著提高渲染图像的质量。它允许我们加载一个图像并将其像壁纸一样包裹在对象周围。这会增加内存使用量,但可以节省性能,否则这些性能会被浪费在处理更复杂的几何形状上。
如何做到...
-
在名为
shader.vert的文本文件中创建一个顶点着色器(参考编写顶点着色器食谱)。 -
除了顶点位置之外,在顶点着色器中定义一个额外的输入变量(属性),通过该变量从应用程序提供纹理坐标:
layout( location = 1 ) in vec2 app_tex_coordinates;
- 在顶点着色器中,定义一个输出(可变)变量,通过该变量将从顶点着色器传递纹理坐标到片段着色器:
layout( location = 0 ) out vec2 vert_tex_coordinates;
- 在顶点着色器的
void main()函数中,将app_tex_coordinates变量赋值给vert_tex_coordinates变量:
vert_tex_coordinates = app_tex_coordinates;
-
创建一个片段着色器(参考编写片段着色器食谱)。
-
在片段着色器中,定义一个输入变量,其中将从顶点着色器提供的纹理坐标传递过来:
layout( location = 0 ) in vec2 vert_tex_coordinates;
- 创建一个统一的
sampler2D变量,它将代表应用于几何体的纹理:
layout( set=0, binding=0 ) uniform sampler2D TextureImage;
- 定义一个输出变量,其中将存储片段的最终颜色(从纹理中读取):
layout( location = 0 ) out vec4 frag_color;
- 在片段着色器的
void main()函数中,采样纹理并将结果存储在frag_color变量中:
frag_color = texture( TextureImage, vert_tex_coordinates );
它是如何工作的...
要绘制一个对象,我们需要它的所有顶点。为了能够使用纹理并将其应用于模型,除了顶点位置外,我们还需要为每个顶点指定纹理坐标。这些属性(位置和纹理坐标)传递给顶点着色器。它将位置转换到裁剪空间(如果需要),并将纹理坐标传递给片段着色器:
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec2 app_tex_coordinates;
layout( location = 0 ) out vec2 vert_tex_coordinates;
void main() {
gl_Position = app_position;
vert_tex_coordinates = app_tex_coordinates;
}
纹理操作在片段着色器中执行。构成多边形的所有顶点的纹理坐标进行插值并提供给片段着色器。它使用这些坐标从纹理中读取(采样)一个颜色。此颜色存储在输出中,并(可能)在附加中:
#version 450
layout( location = 0 ) in vec2 vert_tex_coordinates;
layout( set=0, binding=0 ) uniform sampler2D TextureImage;
layout( location = 0 ) out vec4 frag_color;
void main() {
frag_color = texture( TextureImage, vert_tex_coordinates );
}
除了向着色器提供纹理坐标外,应用程序还需要准备纹理本身。通常,这是通过创建一个组合图像采样器(参考 第五章,描述符集 中的 创建组合图像采样器 食谱)并将其提供给描述符集在第 0 个绑定(在本示例中)来实现。描述符集必须绑定到第 0 个集合索引。

参见
-
在 第五章,描述符集,查看以下食谱:
-
创建组合图像采样器
-
更新描述符集
-
绑定描述符集
-
-
本章中的以下食谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写顶点着色器
-
编写片段着色器
-
-
在 第八章,图形和计算管线,查看以下食谱:
-
创建着色器模块
-
创建图形管线
-
使用几何着色器显示多边形法线
在渲染几何体时,我们通常为每个顶点提供多个属性--用于绘制模型的顶点位置、用于纹理化的纹理坐标以及用于光照计算的法线向量。检查所有这些数据是否正确可能并不容易,但有时,当我们的渲染技术没有按预期工作,这可能是有必要的。
在图形编程中,有一些常用的调试方法。纹理坐标,通常是二维的,显示为通常的颜色。我们可以用同样的方式显示法线向量,但因为是三维的,我们也可以以线条的形式显示它们。为此,可以使用几何着色器。
如何做到...
-
创建一个名为
normals.vert的顶点着色器(参考 编写顶点着色器 食谱)。 -
定义一个输入变量,其中顶点位置将被提供给顶点着色器:
layout( location = 0 ) in vec4 app_position;
- 定义第二个输入变量,其中将提供顶点法线向量:
layout( location = 1 ) in vec3 app_normal;
- 定义一个包含两个矩阵的统一块——一个用于模型视图变换,另一个用于投影矩阵:
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
- 定义一个输出变量,通过该变量我们将提供一个从局部空间转换为视图空间的法线向量给几何着色器:
layout( location = 0 ) out vec4 vert_normal;
- 通过将
ModelViewMatrix变量与顶点位置相乘,将顶点位置转换为视图空间,并将结果存储在gl_Position内置变量中:
gl_Position = ModelViewMatrix * app_position;
- 以类似的方式,将顶点法线转换为视图空间,将结果按选定的值缩放,并将结果存储在
vert_normal输出变量中:
vert_normal = vec4( mat3( ModelViewMatrix ) * app_normal *
<scale>, 0.0 );
-
创建一个名为
normal.geom的几何着色器(参考编写几何着色器配方)。 -
定义一个
triangle输入原语类型:
layout( triangles ) in;
- 定义一个输入变量,通过该变量将从顶点着色器提供视图空间顶点法线:
layout( location = 0 ) in vec4 vert_normal[];
- 定义一个包含两个矩阵的统一块——一个用于模型视图变换,另一个用于投影矩阵:
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
- 通过输出布局限定符,指定一个最多有六个顶点的
line_strip作为生成的原语类型。
layout( line_strip, max_vertices = 6 ) out;
- 定义一个输出变量,通过该变量将从几何着色器提供颜色给片段着色器:
layout( location = 0 ) out vec4 geom_color;
-
在
void main()函数内部,使用名为vertex的int类型变量来遍历所有输入顶点。对每个输入顶点执行以下操作:-
将
ProjectionMatrix与输入顶点位置相乘,并将结果存储在gl_Position内置变量中:gl_Position = ProjectionMatrix * gl_in[vertex].gl_Position; -
在
geom_color输出变量中,存储在几何(顶点)与顶点法线线接触点处的顶点法线所需颜色:geom_color = vec4( <chosen color> ); -
通过调用
EmitVertex()函数生成一个新的顶点。 -
将
ProjectionMatrix与通过vert_normal输入变量偏移的输入顶点位置相乘。将结果存储在gl_Position内置变量中:gl_Position = ProjectionMatrix * (gl_in[vertex].gl_Position + vert_normal[vertex]); -
将顶点法线端点的颜色存储在
geom_color输出变量中:geom_color = vec4( <chosen color> ); -
通过调用
EmitVertex()函数生成一个新的顶点。 -
通过调用
EndPrimitive()函数生成一个原语(一个有两个点的线)。
-
-
创建一个名为
normals.frag的片段着色器(参考编写片段着色器配方)。 -
通过一个输入变量定义一个颜色,该颜色通过几何着色器生成的线的两个顶点之间的插值提供给片段着色器:
layout( location = 0 ) in vec4 geom_color;
- 定义一个输出变量用于片段的颜色:
layout( location = 0 ) out vec4 frag_color;
- 在
void main()函数内部,将geom_color输入变量的值存储在frag_color输出变量中:
frag_color = geom_color;
它是如何工作的...
从应用程序端显示顶点法向量分为两个步骤:首先,我们以常规方式使用一组常规着色器绘制几何图形。第二步是绘制相同的模型,但使用在此食谱中指定的顶点、几何和片段着色器的管线对象。
顶点着色器只需将顶点位置和法向量传递给几何着色器。它可以将两者都转换到视图空间,但相同的操作也可以在几何着色器中执行。以下代码展示了通过统一缓冲区提供的执行转换的顶点着色器的示例源代码:
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec3 app_normal;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( location = 0 ) out vec4 vert_normal;
void main() {
gl_Position = ModelViewMatrix * app_position;
vert_normal = vec4( mat3( ModelViewMatrix ) * app_normal * 0.2, 0.0 );
}
在前面的代码中,位置和法向量都使用模型视图矩阵转换到视图空间。如果我们打算非均匀地缩放模型(不是所有维度的缩放相同),则必须使用模型视图矩阵的逆转置来转换法向量。
代码最重要的部分是在几何内部执行的。它接收构成原始原语类型(通常是三角形)的顶点,但输出构成线段的顶点。它接收一个输入顶点,将其转换到裁剪空间并传递出去。相同的顶点被第二次使用,但这次它偏移了顶点法向量。在平移之后,它被转换到裁剪空间并传递到输出。这些操作对构成原始原语的所有顶点都执行。整个几何着色器的源代码可能看起来像这样:
#version 450
layout( triangles ) in;
layout( location = 0 ) in vec4 vert_normal[];
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( line_strip, max_vertices = 6 ) out;
layout( location = 0 ) out vec4 geom_color;
void main() {
for( int vertex = 0; vertex < 3; ++vertex ) {
gl_Position = ProjectionMatrix * gl_in[vertex].gl_Position;
geom_color = vec4( 0.2 );
EmitVertex();
gl_Position = ProjectionMatrix * (gl_in[vertex].gl_Position + vert_normal[vertex]);
geom_color = vec4( 0.6 );
EmitVertex();
EndPrimitive();
}
}
几何着色器接收由顶点着色器转换到视图空间的顶点,并将它们进一步转换到裁剪空间。这是通过在顶点着色器中使用的相同统一缓冲区提供的投影矩阵来完成的。如果我们只在顶点着色器中使用一个矩阵变量,而在几何着色器中使用第二个矩阵变量,为什么我们定义单个统一缓冲区中的两个矩阵变量?这种做法更方便,因为我们只需要创建一个缓冲区,并且只需要将一个描述符集绑定到命令缓冲区。一般来说,我们执行或记录在命令缓冲区中的操作越少,我们获得的性能就越高。因此,这种方法也应该更快。
片段着色器很简单,因为它只传递由几何着色器存储的插值颜色:
#version 450
layout( location = 0 ) in vec4 geom_color;
layout( location = 0 ) out vec4 frag_color;
void main() {
frag_color = geom_color;
}
使用前面的着色器绘制几何图形的结果,以及以常规方式绘制的模型,可以在以下图像中看到:

参见
-
本章中的以下食谱:
-
将 GLSL 着色器转换为 SPIR-V 汇编
-
编写顶点着色器
-
编写几何着色器
-
编写片段着色器
-
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建着色器模块
-
创建图形管线
-
第八章:图形和计算管道
在本章中,我们将介绍以下食谱:
-
创建着色器模块
-
指定管道着色器阶段
-
指定管道顶点绑定描述、属性描述和输入状态
-
指定管道输入装配状态
-
指定管道细分状态
-
指定管道视口和剪裁测试状态
-
指定管道光栅化状态
-
指定管道多重采样状态
-
指定管道深度和模板状态
-
指定管道混合状态
-
指定管道动态状态
-
创建管道布局
-
指定图形管道创建参数
-
创建管道缓存对象
-
从管道缓存中检索数据
-
合并多个管道缓存对象
-
创建图形管道
-
创建计算管道
-
绑定管道对象
-
创建一个包含组合图像采样器、缓冲区和推送常量范围的管道布局
-
创建一个具有顶点和片段着色器、启用深度测试以及具有动态视口和剪裁测试的图形管道
-
在多个线程上创建多个图形管道
-
销毁管道
-
销毁管道缓存
-
销毁管道布局
-
销毁着色器模块
简介
记录在命令缓冲区中并提交到队列中的操作由硬件处理。处理是通过一系列步骤进行的,这些步骤形成一个管道。当我们想要执行数学计算时,我们使用计算管道。如果我们想要绘制任何东西,我们需要一个图形管道。
管道对象控制几何图形的绘制方式或计算执行。它们管理应用程序执行的硬件的行为。它们是 Vulkan 和 OpenGL 之间最大的、最明显的差异之一。OpenGL 使用状态机。它允许我们在任何时候更改许多渲染或计算参数。我们可以设置状态,激活着色器程序,绘制几何图形,然后激活另一个着色器程序并绘制另一个几何图形。在 Vulkan 中,这是不可能的,因为整个渲染或计算状态都存储在单个、统一的对象中。当我们想要使用不同的着色器集时,我们需要准备并使用一个单独的管道。我们无法简单地切换着色器。
这可能一开始会让人感到害怕,因为许多着色器变体(不包括管道状态的其他部分)导致我们创建多个管道对象。但这服务于两个重要的目标。第一个是性能。知道整个状态的驱动程序可能会优化后续操作的执行。第二个目标是性能的稳定性。随时更改状态可能会导致驱动程序在意外和不可预测的时刻执行额外的操作,例如着色器重新编译。在 Vulkan 中,所有必要的准备,包括着色器编译,都是在管道创建期间完成的。
在本章中,我们将了解如何设置所有图形或计算管道参数以成功创建它们。我们将了解如何准备着色器模块并定义哪些着色器阶段是活动的,如何设置深度或模板测试以及如何启用混合。我们还将指定在绘制操作期间使用的顶点属性以及它们是如何提供的。最后,我们将了解如何创建多个管道以及如何提高它们创建的速度。
创建着色器模块
创建管道对象的第一步是准备着色器模块。它们代表着色器并包含用 SPIR-V 汇编编写的代码。单个模块可能包含多个着色器阶段的代码。当我们编写着色器程序并将它们转换为 SPIR-V 形式时,在我们可以在我们应用程序中使用着色器之前,我们需要创建一个着色器模块(或多个模块)。
如何操作...
-
取名为
logical_device的VkDevice类型变量的逻辑设备的句柄。 -
加载一个选定的着色器的二进制 SPIR-V 汇编并将其存储在一个名为
source_code的类型为std::vector<unsigned char>的变量中。 -
创建一个名为
shader_module_create_info的VkShaderModuleCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO值用于sType。 -
pNext的nullptr值 -
flags的0值 -
source_code向量中元素的数量(以字节为单位)用于codeSize -
指向
source_code变量第一个元素的指针用于pCode
-
-
创建一个名为
shader_module的VkShaderModule类型的变量,其中将存储创建的着色器模块的句柄。 -
进行
vkCreateShaderModule(logical_device, &shader_module_create_info, nullptr, &shader_module)函数调用,其中提供logical_device变量、指向shader_module_create_info的指针、nullptr值和指向shader_module变量的指针。 -
确保调用
vkCreateShaderModule()函数返回了VK_SUCCESS值,这表示着色器模块已正确创建。
它是如何工作的...
着色器模块包含选定的着色器程序的源代码——一个单独的 SPIR-V 汇编。它可能代表多个着色器阶段,但每个阶段必须关联一个单独的入口点。这个入口点随后作为创建管道对象时的一个参数提供(参考指定管道着色器阶段配方)。
当我们想要创建一个着色器模块时,我们需要加载一个包含二进制 SPIR-V 代码的文件或以任何其他方式获取它。然后我们像这样将其提供给一个类型为VkShaderModuleCreateInfo的变量:
VkShaderModuleCreateInfo shader_module_create_info = {
VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
nullptr,
0,
source_code.size(),
reinterpret_cast<uint32_t const *>(source_code.data())
};
接下来,将此类变量的指针提供给vkCreateShaderModule()函数,该函数创建一个模块:
VkResult result = vkCreateShaderModule( logical_device, &shader_module_create_info, nullptr, &shader_module );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a shader module." << std::endl;
return false;
}
return true;
我们只需记住,当我们创建着色器模块时,着色器不会被编译;这是在创建管道对象时完成的。
着色器编译和链接是在创建管道对象期间执行的。
参见
本章中的以下配方:
-
指定管线着色阶段
-
创建图形管线
-
创建计算管线
-
销毁着色器模块
指定管线着色阶段
在计算管线中,我们只能使用计算着色器。但图形管线可能包含多个着色阶段--顶点(这是必需的)、几何、细分控制与评估以及片段。因此,为了正确创建管线,我们需要指定在给定的管线绑定到命令缓冲区时将激活哪些可编程着色阶段。我们还需要提供所有启用着色器的源代码。
准备工作
为了简化配方并减少准备所有启用着色阶段描述所需的参数数量,引入了一个自定义的 ShaderStageParameters 类型。它具有以下定义:
structShaderStageParameters {
VkShaderStageFlagBits ShaderStage;
VkShaderModule ShaderModule;
char const * EntryPointName;
VkSpecializationInfo const * SpecializationInfo;
};
在前面的结构中,ShaderStage 定义了一个单独的管线阶段,其余参数在此指定。ShaderModule 是一个模块,可以从其中获取给定阶段的 SPIR-V 源代码,与在 EntryPointName 成员中提供的名称关联的函数相关。SpecializationInfo 参数是指向类型为 VkSpecializationInfo 的变量的指针。它允许在管线创建期间运行时修改在着色器源代码中定义的常量变量的值。但如果我们不想指定常量值,我们可以提供一个 nullptr 值。
如何操作...
-
创建一个或多个着色器模块,其中包含将在给定管线中激活的每个着色阶段的源代码(参考 创建着色器模块 配方)。
-
创建一个名为
shader_stage_create_infos的std::vector变量,其元素类型为VkPipelineShaderStageCreateInfo。 -
对于在给定管线中应启用的每个着色阶段,向
shader_stage_create_infos向量中添加一个元素,并使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO的sType值 -
pNext的nullptr值 -
flags的0值 -
为
stage选定的着色阶段 -
对于
module,包含给定着色阶段源代码的着色器模块 -
在着色器模块中实现给定着色器的函数名称(通常是
main)对于pName -
一个指向类型为
VkSpecializationInfo的变量的指针,具有常量值特殊化或如果不需要pSpecializationInfo的特殊化,则为nullptr值
-
它是如何工作的...
定义在给定管线中激活的一组着色器阶段需要我们准备一个包含类型为VkPipelineShaderStageCreateInfo的元素数组(或向量)。每个着色器阶段需要一个单独的条目,在其中我们需要指定一个着色器模块和实现给定模块中着色器行为的入口点的名称。我们还可以提供一个指向特殊化信息的指针,这允许我们在管线创建期间(在运行时)修改着色器常量变量的值。这允许我们多次使用相同的着色器代码,并略有变化。
指定管线着色器阶段信息对于图形和计算管线都是强制性的。
让我们假设我们只想使用顶点和片段着色器。我们可以准备一个包含自定义ShaderStageParameters类型元素的向量,如下所示:
std::vector<ShaderStageParameters>shader_stage_params = {
{
VK_SHADER_STAGE_VERTEX_BIT,
*vertex_shader_module,
"main",
nullptr
},
{
VK_SHADER_STAGE_FRAGMENT_BIT,
*fragment_shader_module,
"main",
nullptr
}
};
上述食谱的实现,它使用上述向量中的数据,可能看起来像这样:
shader_stage_create_infos.clear();
for( auto & shader_stage : shader_stage_params ) {
shader_stage_create_infos.push_back( {
VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
nullptr,
0,
shader_stage.ShaderStage,
shader_stage.ShaderModule,
shader_stage.EntryPointName,
shader_stage.SpecializationInfo
} );
}
数组中提供的每个着色器阶段必须是唯一的。
参见
本章中的以下食谱:
-
创建着色器模块
-
创建图形管线
-
创建计算管线
指定管线顶点绑定描述、属性描述和输入状态
当我们想要绘制几何图形时,我们准备顶点以及它们额外的属性,如法向量、颜色或纹理坐标。这样的顶点数据是我们任意选择的,因此为了硬件能够正确使用它们,我们需要指定有多少属性,它们在内存中的布局方式,或者它们从哪里获取。这些信息通过创建图形管线所需的顶点绑定描述和属性描述提供。
如何操作...
-
创建一个名为
binding_descriptions的std::vector变量,其元素类型为VkVertexInputBindingDescription。 -
在给定的管线中使用每个顶点绑定(作为顶点缓冲区绑定到命令缓冲区的缓冲区的一部分)为
binding_descriptions向量添加一个单独的条目。使用以下值来初始化其成员:-
用于
binding的绑定索引(它所代表的数字) -
缓冲区中连续元素之间的字节数用于
stride -
表示从给定绑定读取的属性值是否应按顶点(
VK_VERTEX_INPUT_RATE_VERTEX)或按实例(VK_VERTEX_INPUT_RATE_INSTANCE)递增的参数用于inputRate
-
-
创建一个名为
attribute_descriptions的std::vector变量,其元素类型为VkVertexInputAttributeDescription。 -
在给定的图形管线中,为每个提供给顶点着色器的属性在
attribute_descriptions向量变量中添加一个单独的条目。使用以下值来初始化其成员:-
用于在顶点着色器中读取给定属性的着色器位置用于
location -
将包含此属性数据源的顶点缓冲区绑定到绑定索引的绑定用于
binding -
format中属性数据的格式 -
从绑定开始到给定元素的内存偏移量用于
offset
-
-
创建一个名为
vertex_input_state_create_info的VkPipelineVertexInputStateCreateInfo类型的变量。使用以下值初始化其成员:-
sType的VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO值 -
pNext的nullptr值 -
flags的0值 -
binding_descriptions向量的元素数量用于vertexBindingDescriptionCount -
binding_descriptions向量第一个元素的指针用于pVertexBindingDescriptions -
vertexAttributeDescriptionCount中attribute_descriptions向量的元素数量 -
attribute_descriptions向量第一个元素的指针用于pVertexAttributeDescriptions
-
它是如何工作的...
顶点绑定定义了一个从绑定到选定索引的顶点缓冲区中取出的数据集合。此绑定用作顶点属性的编号数据源。我们可以使用至少 16 个独立的绑定,将单独的顶点缓冲区或同一缓冲区的不同部分绑定到这些绑定上。
顶点输入状态对于创建图形管线是必需的。
通过绑定描述,我们指定数据是从哪里取出的(从哪个绑定),如何布局(缓冲区中连续元素之间的步长是什么),以及如何读取这些数据(是否应该按顶点或按实例读取)。
例如,当我们想要使用三个属性——三个元素的顶点位置、两个元素的纹理坐标和三个元素的颜色值,这些值从 0^(th) 绑定中按顶点读取时,我们可以使用以下代码:
std::vector<VkVertexInputBindingDescription> binding_descriptions = {
{
0,
8 * sizeof( float ),
VK_VERTEX_INPUT_RATE_VERTEX
}
};
通过顶点输入描述,我们定义从给定绑定中取出的属性。对于每个属性,我们需要提供一个着色器位置(与通过 layout( location = <number> ) 限定符定义的着色器源代码中的位置相同),用于给定属性的数据格式,以及给定属性开始的内存偏移量(相对于给定元素的开始数据)。输入描述条目数指定了渲染过程中使用的属性总数。

在前一种情况下——具有三个分量的顶点位置、两个分量的纹理坐标和三个分量的颜色——我们可以使用以下代码来指定顶点输入描述:
std::vector<VkVertexInputAttributeDescription> attribute_descriptions = {
{
0,
0,
VK_FORMAT_R32G32B32_SFLOAT,
0
},
{
1,
0,
VK_FORMAT_R32G32_SFLOAT,
3 * sizeof( float )
},
{
2,
0,
VK_FORMAT_R32G32B32_SFLOAT,
5 * sizeof( float )
}
};
所有三个属性都来自0(th)绑定。位置在`0`(th)位置提供给顶点着色器,通过第一个位置提供 texcoords,通过第二个位置提供颜色值。位置和颜色是三维向量,texcoords 有两个组件。它们都使用有符号的浮点值。位置是第一个,因此没有偏移。纹理坐标接下来,因此它有三个浮点值的偏移。颜色在纹理坐标之后开始,因此它的偏移等于五个浮点值。
此配方的实现如下所示:
vertex_input_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(binding_descriptions.size()),
binding_descriptions.data(),
static_cast<uint32_t>(attribute_descriptions.size()),
attribute_descriptions.data()
};
参见
-
在第七章,着色器中,查看以下配方:
- 编写顶点着色器
-
在第九章,命令记录和绘制中,查看以下配方:
- 绑定顶点缓冲区
-
本章中创建图形管道的配方
指定管道输入装配状态
绘制几何图形(3D 模型)涉及指定从提供的顶点形成的原语类型。这是通过输入装配状态完成的。
如何操作...
-
创建一个名为
input_assembly_state_create_info的VkPipelineInputAssemblyStateCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO的值为sType -
nullptr的值为pNext -
flags的值为0 -
为
topology选择从顶点形成的原语类型(点列表、线列表、线带、三角形列表、三角形带、三角形扇、带相邻关系的线列表、带相邻关系的线带、带相邻关系的三角形列表、带相邻关系的三角形带或补丁列表) -
对于
primitiveRestartEnable成员,在绘制使用顶点索引的命令的情况下,指定是否应该使用特殊索引值来重启原语(VK_TRUE,不能用于列表原语)或者是否应该禁用原语重启(VK_FALSE)
-
它是如何工作的...
通过输入装配状态,我们定义从绘制的顶点形成的多边形类型。最常用的原语是三角形带或列表,但使用的拓扑结构取决于我们想要达到的结果。
创建图形管道需要输入装配状态。

当选择如何装配顶点时,我们只需牢记一些要求:
-
我们不能使用具有原语重启选项的列表原语。
-
只有与相邻关系相关的原语才能与几何着色器一起使用。为了正确工作,必须在创建逻辑设备时启用
geometryShader功能。 -
当我们想要使用细分着色器时,我们只能使用补丁原语。此外,我们还需要记住,在创建逻辑设备时必须启用
tessellationShader功能。
这里是一个初始化类型为 VkPipelineInputAssemblyStateCreateInfo 的变量的源代码示例:
input_assembly_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
nullptr,
0,
topology,
primitive_restart_enable
};
参见
-
本章中的以下食谱:
-
指定管线光栅化状态
-
创建图形管线
-
指定管线细分状态
细分着色器是可选的附加可编程着色器阶段之一,可以在图形管线中启用。但当我们想要激活它们时,我们还需要准备一个管线细分状态。
如何做到这一点...
-
创建一个名为
tessellation_state_create_info的类型为VkPipelineTessellationStateCreateInfo的变量。使用以下内容初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_TESSELLATION_STATE_CREATE_INFO的sType值 -
pNext的nullptr值 -
flags的0值 -
形成
patchControlPoints补丁的控制点(顶点)数
-
它是如何工作的...
要在我们的应用程序中使用细分着色器,我们需要在创建逻辑设备期间启用 tessellationShader 功能,我们需要为细分控制和评估着色器编写源代码,我们需要为它们创建着色器模块(或两个),并且我们还需要准备一个由类型为 VkPipelineTessellationStateCreateInfo 的变量表示的管线细分状态。
细分状态是可选的——我们只需要在想要在图形管线中使用细分着色器时指定它。
在细分状态中,我们只提供有关形成补丁的控制点(顶点)数量的信息。规范指出,补丁可以有至少 32 个顶点。
在补丁中支持的最大控制点(顶点)数至少为 32。
补丁只是一个由点(顶点)组成的集合,这些点被细分阶段用来生成典型的点、线或多边形,如三角形。它可以与通常的多边形完全相同。例如,我们可以取形成三角形的顶点并将它们作为补丁绘制。这种操作的成果是正确的。但对于补丁,我们可以使用任何其他不寻常的顺序和顶点数量。这使我们能够以更多的方式控制细分引擎创建新顶点的方式。
要填充类型为 VkPipelineTessellationStateCreateInfo 的变量,我们可以准备以下代码:
tessellation_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_TESSELLATION_STATE_CREATE_INFO,
nullptr,
0,
patch_control_points_count
};
参见
-
在第七章 着色器 中,查看以下食谱:
-
编写细分控制着色器
-
编写细分评估着色器
-
-
本章中的食谱 创建图形管线
指定管线视口和剪裁测试状态
在屏幕上绘制对象需要我们指定屏幕参数。创建一个交换链是不够的——我们并不总是需要绘制到整个可用图像区域。有些情况下,我们只想在整幅图像中绘制一个较小的图像,例如汽车后视镜中的反射或分屏多人游戏中的图像的一半。我们通过管道视口和裁剪测试状态定义我们想要绘制的图像区域。
准备中
指定视口和裁剪状态的参数需要我们为视口和裁剪测试提供一组单独的参数,但两组中的元素数量必须相等。为了将这两个状态的参数放在一起,本菜谱中引入了一个自定义的 ViewportInfo 类型。它具有以下定义:
struct ViewportInfo {
std::vector<VkViewport> Viewports;
std::vector<VkRect2D> Scissors;
};
第一成员,正如其名所示,包含一组视口的参数。第二个用于定义与每个视口对应的裁剪测试的参数。
如何操作...
-
如果要向多个视口进行渲染,请创建一个启用了
multiViewport功能的逻辑设备。 -
创建一个名为
viewports的std::vector<VkViewport>类型的变量。对于每个将要进行渲染的视口,向viewports向量中添加一个新元素。使用以下值初始化其成员:-
渲染区域左侧的位置(以像素为单位)对于
x -
渲染区域顶部的位置(以像素为单位)对于
y -
渲染区域宽度(以像素为单位)对于
width -
height的渲染区域高度(以像素为单位) -
视口的最小深度值,介于
0.0和1.0之间对于minDepth -
视口的最大深度值,介于
0.0和1.0之间对于maxDepth
-
-
创建一个名为
scissors的std::vector<VkRect2D>类型的变量。对于每个将要进行渲染的视口,向scissors向量变量中添加一个新元素(scissors向量必须与viewports向量具有相同数量的元素)。使用以下值初始化其成员:-
裁剪矩形左上角的位置对于
offset的x和y成员 -
裁剪矩形的宽度和高度对于
extent的width和height成员
-
-
创建一个名为
viewport_state_create_info的VkPipelineViewportStateCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO的值对于sType -
nullptr值对于pNext -
0值对于flags -
viewports向量中的元素数量对于viewportCount -
viewports向量第一个元素的指针对于pViewports -
scissors向量中的元素数量对于scissorCount -
scissors向量第一个元素的指针对于pScissors
-
它是如何工作的...
顶点位置(通常在顶点着色器内部)从局部空间转换到裁剪空间。然后硬件执行透视除法,生成归一化设备坐标。接下来,多边形被组装并光栅化——这个过程生成片段。每个片段在其自己的帧缓冲区坐标中定义了位置。此外,为了正确计算此位置,还需要视口变换。该变换的参数在视口状态中指定。
视口和剪裁测试状态是可选的,尽管常用——当光栅化被禁用时,我们不需要提供它。
通过视口状态,我们定义在帧缓冲区坐标中渲染区域的左上角、宽度和高度(屏幕上的像素)。我们还定义了视口的最小和最大深度值(介于 0.0 和 1.0 之间的浮点值,包括)。指定最大深度值小于最小深度值是有效的。
剪裁测试允许我们进一步将生成的片段裁剪到剪裁参数中指定的矩形。当我们不想裁剪片段时,我们需要指定一个与视口大小相等的区域。
在 Vulkan 中,剪裁测试始终启用。
视口和剪裁测试的参数集数量必须相等。因此,定义一个自定义类型来保持这两个属性元素数量相等可能是个好主意。以下是一个示例代码,它通过自定义 ViewportInfo 类型的变量指定一个视口和一个剪裁测试的参数:
ViewportInfo viewport_infos = {
{
{
0.0f,
0.0f,
512.0f,
512.0f,
0.0f,
1.0f
},
},
{
{
{
0,
0
},
{
512,
512
}
}
}
};
之前定义的变量可以用来创建本食谱中定义的视口和剪裁测试。该食谱的实现可能如下所示:
uint32_t viewport_count = static_cast<uint32_t>(viewport_infos.Viewports.size());
uint32_t scissor_count = static_cast<uint32_t>(viewport_infos.Scissors.size());
viewport_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
nullptr,
0,
viewport_count,
viewport_infos.Viewports.data(),
scissor_count,
viewport_infos.Scissors.data()
};
如果我们要更改视口或剪裁测试的一些参数,我们需要重新创建一个管线。但在管线创建过程中,我们可以指定视口和剪裁测试参数是动态的。这样,我们就不需要重新创建管线来更改这些参数——我们可以在命令缓冲区记录期间指定它们。但我们需要记住,视口(和剪裁测试)的数量始终在管线创建期间指定。我们无法在之后更改它。
可以将视口和剪裁测试定义为动态状态,并在命令缓冲区记录期间指定它们的参数。视口(和剪裁测试)的数量始终在图形管线创建期间指定。
我们也不能提供超过一个视口和剪裁测试,除非为逻辑设备启用了 multiViewport 功能。用于光栅化的视口变换的索引只能在几何着色器内部更改。
更改用于光栅化的视口变换索引需要我们使用几何着色器。
参见
-
在第一章 实例与设备 中,查看以下食谱:
-
获取物理设备的特性和属性
-
创建逻辑设备
-
-
在第七章,着色器中,查看以下配方:
- 编写几何着色器
-
本章中的配方创建图形管道,
指定管道光栅化状态
光栅化过程从组装的多边形生成片段(像素)。视口状态用于指定片段将在帧缓冲区坐标中的何处生成。为了指定(如果有的话)如何生成片段,我们需要准备一个光栅化状态。
如何做到...
-
创建一个名为
rasterization_state_create_info的VkPipelineRasterizationStateCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO的值用于sType。 -
pNext的值为nullptr。 -
flags的值为0。 -
对于
depthClampEnable,如果需要将深度值超出视口状态中指定的最小/最大范围的片段的深度值限制在此范围内,请使用true值;如果需要将超出此范围的片段裁剪(丢弃),请使用false值;当depthClampEnable功能未启用时,只能指定false值。 -
对于
rasterizerDiscardEnable,如果应该正常生成片段,请使用false值;如果要禁用光栅化,请使用true。 -
对于
polygonMode,指定组装的多边形应该如何渲染——完全填充或是否渲染线条或点(线条和点模式只能在启用fillModeNonSolid功能时使用)。 -
多边形的侧面——前侧、后侧、两侧或无——在
cullMode中应该被剔除。 -
多边形的侧面——在屏幕上按顺时针或逆时针顶点顺序绘制——应被视为
frontFace的前侧面。 -
对于
depthBiasEnable,如果需要为片段计算深度值添加额外的偏移,请指定true值;如果不需要进行此类修改,请指定false值。 -
当为
depthBiasConstantFactor启用深度偏移时,应添加到片段计算深度值中的常数值。 -
当为
depthBiasClamp启用深度偏移时,可以添加到片段深度中的最大(或最小)深度偏移值。 -
当为
depthBiasSlopeFactor启用深度偏移时,添加到片段斜率中的值。 -
指定渲染线条宽度的值用于
lineWidth;如果未启用wideLines功能,则只能指定1.0值;否则,也可以提供大于1.0的值。
-
它是如何工作的...
光栅化状态控制光栅化的参数。首先,它定义了光栅化是否启用或禁用。通过它,我们可以指定多边形的哪一侧是前面——如果它是屏幕上顶点按顺时针顺序出现的那一侧,或者是否是按逆时针顺序。接下来,我们需要控制是否为前面、后面、两侧启用剔除,或者是否禁用剔除。在 OpenGL 中,默认情况下,逆时针面的被认为是前面,并且剔除是禁用的。在 Vulkan 中,没有默认状态,因此如何定义这些参数取决于我们。
在创建图形管线时始终需要设置光栅化状态。
光栅化状态还控制多边形的绘制方式。通常我们希望它们被完全渲染(填充)。但我们可以指定是否只绘制它们的边缘(线条)或点(顶点)。线条或点模式只能在创建逻辑设备时启用 fillModeNonSolid 功能时使用。
对于光栅化状态,我们还需要定义如何计算生成的片段的深度值。我们可以启用深度偏移——这是一个通过一个常数值和一个额外的斜率因子偏移生成深度值的过程。我们还可以指定在启用深度偏移时可以应用于深度值的最大(或最小)偏移值。
之后,我们还需要定义对于深度值超出视口状态中指定范围的片段应该做什么。当启用深度钳位时,此类片段的深度值被钳位到定义的范围内,并且片段会被进一步处理。如果禁用深度钳位,此类片段将被丢弃。
最后一件事情是要定义渲染线条的宽度。通常我们只能指定 1.0 的值。但如果我们启用 wideLines 功能,我们可以提供大于 1.0 的值。
光栅化状态是通过一个 VkPipelineRasterizationStateCreateInfo 类型的变量定义的。以下是一个示例代码,展示了如何通过其他变量提供的值填充此类变量:
VkPipelineRasterizationStateCreateInfo rasterization_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
nullptr,
0,
depth_clamp_enable,
rasterizer_discard_enable,
polygon_mode,
culling_mode,
front_face,
depth_bias_enable,
depth_bias_constant_factor,
depth_bias_clamp,
depth_bias_slope_factor,
line_width
};
参见
-
本章中的以下食谱:
-
指定管线视口和裁剪测试状态
-
创建图形管线
-
指定管线多采样状态
多采样是一种消除绘制原语锯齿边缘的过程。换句话说,它允许我们对多边形、线条和点进行抗锯齿处理。我们通过多采样状态定义如何进行多采样(以及是否进行)。
如何实现...
-
创建一个名为
multisample_state_create_info的VkPipelineMultisampleStateCreateInfo类型的变量。使用以下值来初始化其成员:-
sType的值为VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO -
pNext的值为nullptr -
flags的值为0 -
每像素生成的样本数用于
rasterizationSamples -
如果应该启用每个样本着色(仅当启用
sampleRateShading功能时)或否则为false,则对于sampleShadingEnable的true值 -
当启用样本着色时,
minSampleShading所需的最小独特着色样本分数 -
指向一个位掩码数组的指针,该数组控制片段的静态覆盖,或一个
nullptr值以指示从片段中不删除覆盖(掩码中的所有位都启用)对于pSampleMask -
如果片段的覆盖应该基于片段的 alpha 值生成或否则为
false,对于alphaToCoverageEnable的true值 -
如果片段的颜色应该用
1.0值替换片段的 alpha 分量,对于浮点格式,或者用给定格式的最大可用值替换固定点格式(仅当启用alphaToOne功能时)或否则的值,对于alphaToOneEnable
-
它是如何工作的...
多样本状态允许我们启用绘制的原语的抗锯齿。通过它,我们可以定义每个片段生成的样本数,启用每个样本着色,指定唯一着色样本的最小数量,并定义片段的覆盖参数——样本覆盖掩码,是否应该从片段颜色的 alpha 分量生成覆盖。我们还可以指定是否应该用1.0值替换 alpha 分量。
只有当启用光栅化时,才需要多样本状态。
为了准备一个多样本状态,我们需要创建一个VkPipelineMultisampleStateCreateInfo类型的变量,如下所示:
multisample_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
nullptr,
0,
sample_count,
per_sample_shading_enable,
min_sample_shading,
sample_masks,
alpha_to_coverage_enable,
alpha_to_one_enable
};
在前面的代码中,函数的参数用于初始化multisample_state_create_info变量的成员。
参见
本章中的以下食谱:
-
指定管线光栅化状态
-
创建图形管线
指定管线深度和模板状态
通常,当我们渲染几何体时,我们想要模仿我们看到世界的方式——远离我们的物体更小,靠近我们的物体更大,并且它们覆盖了后面的物体(遮挡我们的视线)。在现代 3D 图形中,这种最后的效果(远离的物体被靠近的物体遮挡)是通过深度测试实现的。深度测试的执行方式是通过图形管线的深度和模板状态来指定的。
如何做到...
-
创建一个名为
depth_and_stencil_state_create_info的VkPipelineDepthStencilStateCreateInfo类型的变量。使用以下值来初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO的sType值 -
pNext的nullptr值 -
flags的0值 -
如果我们想要启用深度测试或否则为
false,对于depthTestEnable的true值 -
如果我们想要将深度值存储在深度缓冲区中,否则对于
depthWriteEnable为false的true值 -
选择一个比较运算符(
never,less,less and equal,equal,greater and equal,greater,not equal,always),用于控制depthCompareOp的深度测试执行方式。 -
如果我们想启用额外的深度边界测试(只有当
depthBounds功能启用时)或否则为false,则depthBoundsTestEnable的值为true。 -
如果我们想使用模板测试,则
stencilTestEnable的值为true,如果我们要禁用它,则值为false。 -
使用以下值通过
front字段初始化成员,通过该字段我们设置用于正面多边形的模板测试参数:-
当样本未通过模板测试时执行的功能,对应于
failOp。 -
当样本通过模板测试时执行的操作,对应于
passOp。 -
当样本通过模板测试但未通过深度测试时采取的操作,对应于
depthFailOp。 -
用于执行模板测试的运算符(
never,less,less and equal,equal,greater and equal,greater,not equal,always),对应于compareOp。 -
选择参与模板测试的模板值的位的掩码,对应于
compareMask。 -
选择掩码,用于选择在帧缓冲区中应更新的模板值的哪些位,对应于
writeMask。 -
用于模板测试比较的参考值。
-
-
对于
back成员,设置模板测试参数,如之前所述用于正面多边形,但这次是针对背面多边形。 -
描述
minDepthBounds的深度边界测试的最小值的0.0到1.0(包含)之间的值。 -
描述
maxDepthBounds的深度边界测试的最大值的0.0到1.0(包含)之间的值。
-
它是如何工作的...
深度和模板状态指定是否应执行深度和/或模板测试。如果其中任何一个被启用,我们还为每个这些测试定义参数。
当光栅化禁用或渲染通道中的给定子通道未使用任何深度/模板附加时,不需要深度和模板状态。
我们需要指定如何执行深度测试(如何比较深度值)以及当片段通过测试时,处理片段的深度值是否应写入深度附加。
当depthBounds功能启用时,我们还可以激活一个额外的深度边界测试。此测试检查处理片段的深度值是否在指定的minDepthBounds - maxDepthBounds范围内。如果不是,则处理片段被丢弃,就像它未通过深度测试一样。
模板测试允许我们对与每个片段关联的整数值执行附加测试。它可以用于各种目的。例如,我们可以定义在绘制过程中可以更新的屏幕的精确部分,但与剪裁测试不同,这个区域可以是任何形状,即使它非常复杂。这种方法在延迟着色/光照算法中用于限制给定光源可以照亮的图像区域。模板测试的另一个例子是使用它来显示被其他对象隐藏的对象的轮廓或突出显示鼠标指针选择的对象。
在启用模板测试的情况下,我们需要分别为前向和后向多边形定义其参数。这些参数包括当给定片段失败模板测试、通过模板测试但失败深度测试,以及通过模板和深度测试时执行的操作。对于每种情况,我们定义当前值在模板附加中的值应保持不变,重置为 0,替换为参考值,通过钳位(饱和)或通过环绕进行增加或减少,或者如果当前值应通过位运算取反。我们还通过设置比较运算符(类似于深度测试中定义的运算符)、比较和写入掩码来指定测试的执行方式,这些掩码选择应参与测试或应在模板附加中更新的模板值的位,以及一个参考值。
准备一个类型为 VkPipelineDepthStencilStateCreateInfo 的变量的示例源代码,通过该变量定义深度和模板测试,如下代码所示:
VkPipelineDepthStencilStateCreateInfo depth_and_stencil_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO,
nullptr,
0,
depth_test_enable,
depth_write_enable,
depth_compare_op,
depth_bounds_test_enable,
stencil_test_enable,
front_stencil_test_parameters,
back_stencil_test_parameters,
min_depth_bounds,
max_depth_bounds
};
参见
-
在第六章 渲染通道和帧缓冲区 中,查看以下食谱:
-
指定子通道描述
-
创建帧缓冲区
-
-
本章中的以下食谱:
-
指定管线光栅化状态
-
创建图形管线
-
指定管线混合状态
透明物体在我们每天看到的周围环境中非常常见。这类物体在 3D 应用程序中也同样常见。为了模拟透明材料并简化硬件需要执行以渲染透明物体的操作,引入了混合。它将处理片段的颜色与已存储在帧缓冲区中的颜色混合。为此操作准备参数是通过图形管线中的混合状态完成的。
如何做到...
-
创建一个名为
attachment_blend_states的类型为VkPipelineColorBlendAttachmentState的变量。 -
对于在绑定给定图形管线的子通道中使用的每个颜色附件,向
attachment_blend_states向量添加一个新元素。如果independentBlend功能未启用,添加到attachment_blend_states向量中的所有元素必须完全相同。如果此功能已启用,元素可能不同。无论如何,使用以下值初始化每个添加的元素的成员:-
是否启用混合的
true值,否则blendEnable为false -
为
srcColorBlendFactor选择处理(源)片段的颜色混合因子 -
为
dstColorBlendFactor选择已存储在(目标)附件中的颜色的混合因子 -
用于在颜色分量上执行混合操作的运算符
colorBlendOp -
为
srcAlphaBlendFactor选择用于传入(源)片段的 alpha 值的混合因子 -
为
dstAlphaBlendFactor选择已存储在目标附件中的 alpha 值的混合因子 -
用于在 alpha 分量上执行混合操作的函数
alphaBlendOp -
用于选择在附件中写入哪些分量的颜色遮罩
colorWriteMask
-
-
创建一个名为
blend_state_create_info的VkPipelineColorBlendStateCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO的sType值 -
nullptr值用于pNext -
0值用于flags -
如果应在片段的颜色和已存储在附件中的颜色之间执行逻辑运算(这将禁用混合)或否则为
false,则logicOpEnable为true -
要执行的逻辑运算的类型(如果启用了逻辑运算)
logicOp -
attachment_blend_states向量中的元素数量attachmentCount -
指向
attachment_blend_states向量第一个元素的指针pAttachments -
四个浮点值定义了用于某些混合因子的混合常数的红色、绿色、蓝色和 alpha 分量
blendConstants[4]
-
它是如何工作的...
混合状态是可选的,如果禁用光栅化或子通道中没有颜色附件,则不需要混合状态,在这种情况下,使用给定的图形管线。
混合状态主要用于定义混合操作的参数。但它也服务于其他目的。在其中,我们指定一个颜色遮罩,该遮罩选择在渲染过程中哪些颜色分量被更新(写入)。它还控制逻辑运算的状态。当启用时,在片段的颜色和已经写入帧缓冲区的颜色之间执行一个指定的逻辑运算。
仅对具有整数和归一化整数格式的附件执行逻辑运算。
支持的逻辑运算包括:
-
CLEAR:将颜色设置为零 -
AND: 源颜色(片段颜色)和目标颜色(已存储在附件中)之间的位运算“与” -
AND_REVERSE: 源颜色和反转目标颜色之间的位运算“与” -
COPY: 无修改地复制源颜色 -
AND_INVERTED: 目标颜色和反转源颜色之间的位运算“与” -
NO_OP: 保持已存储的颜色不变 -
XOR: 源颜色和目标颜色之间的位运算“异或” -
OR: 源颜色和目标颜色之间的位运算“或” -
NOR: 反转位运算“或” -
EQUIVALENT: 反转的XOR -
INVERT: 反转目标颜色 -
OR_REVERSE: 源颜色和反转目标颜色之间的位运算“或” -
COPY_INVERTED: 复制位运算反转的源颜色 -
OR_INVERTED: 目标颜色和反转源颜色之间的位运算“或” -
NAND: 反转位运算“与” -
SET: 将所有颜色位设置为 1
在渲染过程中,每个颜色附件的混合操作都是独立控制的,这发生在绑定特定图形管道的子通道中。这意味着我们需要为渲染中使用的每个颜色附件指定混合参数。但我们需要记住,如果未启用independentBlend功能,每个附件的混合参数必须完全相同。
对于混合,我们分别指定颜色组件和 alpha 组件的源和目标因子。支持的混合因子包括:
-
ZERO:0 -
ONE:1 -
SRC_COLOR:<source component> -
ONE_MINUS_SRC_COLOR: 1 -<source component> -
DST_COLOR:<destination component> -
ONE_MINUS_DST_COLOR: 1 -<destination component> -
SRC_ALPHA:<source alpha> -
ONE_MINUS_SRC_ALPHA: 1 -<source alpha> -
DST_ALPHA:<destination alpha> -
ONE_MINUS_DST_ALPHA: 1 -<destination alpha> -
CONSTANT_COLOR:<constant color component> -
ONE_MINUS_CONSTANT_COLOR: 1 -<constant color component> -
CONSTANT_ALPHA:<alpha value of a constant color> -
ONE_MINUS_CONSTANT_ALPHA: 1 -<alpha value of a constant color> -
SRC_ALPHA_SATURATE:min( <source alpha>, 1 - <destination alpha> ) -
SRC1_COLOR:<component of a second color>(used in dual source blending) -
ONE_MINUS_SRC1_COLOR: 1 -<component of a second color>(from dual source blending) -
SRC1_ALPHA:<alpha component of a second color>(in dual source blending) -
ONE_MINUS_SRC1_ALPHA: 1 -<source alpha component of a second color>(from dual source blending)
一些混合因子使用常量颜色而不是片段(源)颜色或已存储在附件中的颜色(目标)。此常量颜色可以在管道创建期间静态指定,也可以在命令缓冲区记录期间通过vkCmdSetBlendConstants()函数调用动态指定(作为动态管道状态之一)。
使用源的第二颜色(SRC1)的混合因子只能在启用dualSrcBlend功能时使用。
控制混合如何执行的反混函数也分别针对颜色和 alpha 分量单独指定。混合运算符包括:
-
ADD:<src component> * <src factor> + <dst component> * <dst factor> -
SUBTRACT:<src component> * <src factor> - <dst component> * <dst factor> -
REVERSE_SUBTRACT:<dst component> * <dst factor> - <src component> * <src factor> -
MIN:min( <src component>, <dst component> ) -
MAX:max( <src component>, <dst component> )
启用逻辑操作将禁用混合。
以下是一个设置带有禁用逻辑操作和混合的反混状态的示例:
std::vector<VkPipelineColorBlendAttachmentState> attachment_blend_states = {
{
false,
VK_BLEND_FACTOR_ONE,
VK_BLEND_FACTOR_ONE,
VK_BLEND_OP_ADD,
VK_BLEND_FACTOR_ONE,
VK_BLEND_FACTOR_ONE,
VK_BLEND_OP_ADD,
VK_COLOR_COMPONENT_R_BIT |
VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT |
VK_COLOR_COMPONENT_A_BIT
}
};
VkPipelineColorBlendStateCreateInfo blend_state_create_info;
SpecifyPipelineBlendState( false, VK_LOGIC_OP_COPY, attachment_blend_states, { 1.0f, 1.0f, 1.0f, 1.0f }, blend_state_create_info );
实现此配方的代码,填充VkPipelineColorBlendStateCreateInfo类型的变量可能如下所示:
blend_state_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
nullptr,
0,
logic_op_enable,
logic_op,
static_cast<uint32_t>(attachment_blend_states.size()),
attachment_blend_states.data(),
{
blend_constants[0],
blend_constants[1],
blend_constants[2],
blend_constants[3]
}
};
参见
-
在第六章的渲染通道和帧缓冲区中,查看以下配方:
-
指定子通道描述
-
创建帧缓冲区
-
-
在第九章的命令记录和绘制中,查看以下配方
- 动态设置混合常数状态
-
本章中的以下配方:
-
指定管线光栅化状态
-
创建图形管线
-
指定管线动态状态
创建图形管线需要我们提供大量的参数。更重要的是,一旦设置,这些参数就不能更改。这种做法是为了提高我们应用程序的性能,并为驱动程序提供一个稳定且可预测的环境。但是,不幸的是,这对开发者来说也很不方便,因为他们可能需要创建许多几乎完全相同但只有细微差别的管线对象。
为了绕过这个问题,引入了动态状态。它们允许我们通过在命令缓冲区中记录特定函数来动态地控制管线的一些参数。为了做到这一点,我们需要指定管线中哪些部分是动态的。这是通过指定管线动态状态来完成的。
如何做到这一点...
-
创建一个名为
dynamic_states的std::vector<VkDynamicState>类型的变量。对于每个应该动态设置的(唯一)管线状态,向dynamic_states向量中添加一个新元素。以下值可以使用:-
VK_DYNAMIC_STATE_VIEWPORT -
VK_DYNAMIC_STATE_SCISSOR -
VK_DYNAMIC_STATE_LINE_WIDTH -
VK_DYNAMIC_STATE_DEPTH_BIAS -
VK_DYNAMIC_STATE_BLEND_CONSTANTS -
VK_DYNAMIC_STATE_DEPTH_BOUNDS -
VK_DYNAMIC_STATE_STENCIL_COMPARE_MASK -
VK_DYNAMIC_STATE_STENCIL_WRITE_MASK -
VK_DYNAMIC_STATE_STENCIL_REFERENCE
-
-
创建一个名为
dynamic_state_creat_info的VkPipelineDynamicStateCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO值用于sType -
nullptr值用于pNext -
flags的0值 -
dynamicStateCount的dynamic_states向量中的元素数量 -
dynamic_states向量的第一个元素的指针用于pDynamicStates
-
它是如何工作的...
动态管道状态被引入,以允许在设置管道对象的状态时具有一定的灵活性。在命令缓冲区记录期间可能没有太多不同的管道部分可以设置,但选择是在性能、驱动程序的简单性、现代硬件的能力和 API 易用性之间的一种折衷。
动态状态是可选的。如果我们不想动态设置管道的任何部分,我们不需要这样做。
以下图形管道的部分可以动态设置:
-
视口:所有视口的参数通过
vkCmdSetViewport()函数调用设置,但视口数量仍在管道创建期间定义(参考指定管道视口和剪裁测试状态配方) -
剪裁:控制剪裁测试的参数通过
vkCmdSetScissor()函数调用设置,尽管用于剪裁测试的矩形数量在管道创建期间静态定义,并且必须与视口数量相同(参考指定管道视口和剪裁测试状态配方) -
线宽:绘制线的宽度不是在图形管道的状态中指定,而是通过
vkCmdSetLineWidth()函数(参考指定管道光栅化状态配方) -
深度偏差:当启用时,片段计算深度值所应用的深度偏差常数因子、斜率因子和最大(或最小)偏差通过记录
vkCmdSetDepthBias()函数定义(参考指定管道深度和模板状态配方) -
深度范围:当启用深度范围测试时,测试期间使用的最小和最大值通过
vkCmdSetDepthBounds()函数指定(参考指定管道深度和模板状态配方) -
模板比较掩码:在模板测试期间使用的模板值的特定位通过
vkCmdSetStencilCompareMask()函数调用定义(参考指定管道深度和模板状态配方) -
模板写入掩码:通过
vkCmdSetStencilWriteMask()函数指定在模板附加中可以更新的位(参考指定管道深度和模板状态配方) -
模板参考值:通过
vkCmdSetStencilReference()函数调用执行在模板测试期间使用的参考值的设置(参考指定管道深度和模板状态配方) -
混合常数:通过记录
vkCmdSetBlendConstants()函数指定混合常数的红色、绿色、蓝色和 alpha 分量的四个浮点值(参考指定管道混合状态配方)
通过创建一个VkDynamicState枚举值的数组(或向量),其中包含对应于所选状态的值,并将该数组(在以下代码中命名为dynamic_states)提供给VkPipelineDynamicStateCreateInfo类型的变量来指定给定状态是动态设置的:
VkPipelineDynamicStateCreateInfo dynamic_state_creat_info = {
VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(dynamic_states.size()),
dynamic_states.data()
};
参见
-
本章中的以下食谱:
-
指定管线视口和剪裁测试状态
-
指定管线光栅化状态
-
指定管线深度和模板状态
-
指定管线混合状态
-
创建图形管线
-
-
在第九章,“命令录制与绘制”中,查看以下食谱:
-
动态设置视口状态
-
动态设置剪裁状态
-
动态设置深度偏移状态
-
动态设置混合常数状态
-
创建管道布局
管道布局类似于描述符集布局。描述符集布局用于定义构成给定描述符集的资源类型。管道布局定义了给定管道可以访问的资源类型。它们从描述符集布局创建,并且还包含推送常量范围。
管道布局在管道创建时是必需的,因为它们通过一组、绑定、数组元素地址指定了着色器阶段和着色器资源之间的接口。相同的地址需要在着色器中(通过布局限定符)使用,以便它们可以成功访问给定的资源。即使给定的管道不使用任何描述符资源,我们也需要创建一个管道布局来通知驱动程序不需要此类接口。
如何做到这一点...
-
获取存储在名为
logical_device的VkDevice类型变量中的逻辑设备的句柄。 -
创建一个名为
descriptor_set_layouts的std::vector变量,其元素类型为VkDescriptorSetLayout。对于每个描述符集,通过它将从给定管道中的着色器访问资源,将描述符集布局添加到descriptor_set_layouts向量中。 -
创建一个名为
push_constant_ranges的std::vector<VkPushConstantRange>变量。为每个单独的范围(不同着色器阶段使用的唯一推送常量集)添加新元素到这个向量,并使用以下值来初始化其成员:-
所有访问给定
stageFlags的推送常量的着色器阶段的逻辑OR -
对于
offset,给定推送常量在内存中开始的偏移量是 4 的倍数的值 -
对于
size,给定推送常量在内存中大小的 4 的倍数的值
-
-
创建一个名为
pipeline_layout_create_info的VkPipelineLayoutCreateInfo类型的变量。使用以下值来初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO值用于sType -
pNext的nullptr值 -
flags的0值 -
descriptor_set_layouts向量中setLayoutCount的元素数量 -
pSetLayouts的descriptor_set_layouts向量首个元素的指针 -
push_constant_ranges向量中pushConstantRangeCount的元素数量 -
pPushConstantRanges的push_constant_ranges首个元素的指针
-
-
创建一个名为
pipeline_layout的VkPipelineLayout类型的变量,其中将存储创建的管道布局的句柄。 -
进行以下调用:
vkCreatePipelineLayout( logical_device, &pipeline_layout_create_info, nullptr, &pipeline_layout ),其中提供logical_device变量,pipeline_layout_create_info变量的指针,一个nullptr值,以及pipeline_layout变量的指针。 -
通过检查它是否返回了
VK_SUCCESS值来确保调用成功。
它是如何工作的...
管道布局定义了可以从给定管道的着色器访问的资源集合。当我们记录命令缓冲区时,我们将描述符集绑定到选定的索引(参考 绑定描述符集 菜谱)。此索引对应于在管道布局创建期间使用的数组中相同索引的描述符集布局(本菜谱中的 descriptor_set_layouts 向量)。相同的索引需要在着色器内部通过 layout( set = <index>, binding = <number> ) 限定符指定,以便正确访问给定的资源。

通常,多个管道会访问不同的资源。在命令缓冲区记录期间,我们绑定一个给定的管道和描述符集。只有在此之后,我们才能发出绘图命令。当我们从一个管道切换到另一个管道时,我们需要根据管道的需求绑定新的描述符集。但频繁地绑定不同的描述符集可能会影响我们应用程序的性能。这就是为什么创建具有相似(或兼容)布局的管道,并将不经常更改(对许多管道来说是共有的)的描述符集绑定到接近 0(或布局的起始部分)的索引是很好的。这样,当我们切换管道时,管道布局起始部分的描述符集(从索引 0 到某个索引 N)仍然可以使用,并且不需要更新。只需要绑定不同的描述符集——那些放置在更高索引(在给定索引 N 之后)的描述符集。但必须满足一个额外条件——为了相似(或兼容),管道布局必须使用相同的推送常量范围。
我们应该在管道布局的起始部分(接近 0^(th) 索引)绑定许多管道共有的描述符集。
管线布局还定义了推送常量的范围。它们允许我们向着色器提供一组小的常量值。它们比更新描述符集要快得多,但可以被推送常量消耗的内存也小得多——对于管线布局中定义的所有范围,至少是 128 字节。不同的硬件可能为推送常量提供更多的内存,但我们不能依赖于它,如果我们针对来自不同供应商的硬件。
例如,当我们想要为图形管线中的每个阶段定义不同的范围时,每个阶段大约有 128 / 5 = 26 字节的推送常量。当然,我们可以定义适用于多个着色器阶段的公共范围。但每个着色器阶段可能只能访问一个推送常量范围。
上述示例是最坏的情况。通常,不是所有阶段都会使用不同的推送常量范围。相当常见的是,阶段可能根本不需要访问推送常量范围。因此,应该有足够的内存来存储几个 4 分量向量或一个矩阵或两个。
每个管线阶段只能访问一个推送常量范围。
我们还需要记住,推送常量范围的尺寸和偏移量必须是 4 的倍数。
在以下代码中,我们可以看到一个实现此食谱的源代码。描述符集布局和推送常量范围分别通过descriptor_set_layouts和push_constant_ranges变量提供:
VkPipelineLayoutCreateInfo pipeline_layout_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(descriptor_set_layouts.size()),
descriptor_set_layouts.data(),
static_cast<uint32_t>(push_constant_ranges.size()),
push_constant_ranges.data()
};
VkResult result = vkCreatePipelineLayout( logical_device, &pipeline_layout_create_info, nullptr, &pipeline_layout );
if( VK_SUCCESS != result ) {
std::cout << "Could not create pipeline layout." << std::endl;
return false;
}
return true;
参见
-
在第五章,描述符集,查看以下食谱:
- 绑定描述符集
-
在第七章,着色器,查看以下食谱:
-
编写一个乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
-
本章中的以下食谱:
-
创建图形管线
-
创建计算管线
-
使用推送常量、采样图像和缓冲区创建管线布局
-
销毁管线布局
-
-
在第九章,命令记录和绘制,查看以下食谱:
- 通过推送常量向着色器提供数据
指定图形管线创建参数
创建图形管线需要我们准备许多控制其许多不同方面的参数。所有这些参数都被组合到一个类型为VkGraphicsPipelineCreateInfo的变量中,在我们可以使用它来创建管线之前,需要正确初始化它。
如何做...
-
通过名为
additional_options的VkPipelineCreateFlags位字段类型变量创建一个变量,通过它提供额外的管线创建选项:-
禁用优化:指定创建的管线不会被优化,但创建过程可能会更快
-
允许派生:指定其他管线可以从它创建
-
导数:指定此管线将基于另一个已创建的管线创建
-
-
创建一个名为
shader_stage_create_infos的std::vector<VkPipelineShaderStageCreateInfo>类型的变量。对于在给定的管线中启用的每个着色器阶段,向shader_stage_create_infos向量中添加一个新元素,指定该阶段的参数。至少顶点着色器阶段必须在shader_stage_create_infos向量中(参考 指定管线着色器阶段 菜谱)。 -
通过创建一个名为
vertex_input_state_create_info的VkPipelineVertexInputStateCreateInfo类型的变量来指定顶点绑定、属性和输入状态(参考 指定管线顶点绑定描述、属性描述和输入状态 菜谱)。 -
创建一个名为
input_assembly_state_create_info的VkPipelineInputAssemblyStateCreateInfo类型的变量。使用它来定义如何将绘制的顶点组装成多边形(参考 指定管线输入组装状态 菜谱)。 -
如果在给定的管线中应该启用细分,则创建一个名为
tessellation_state_create_info的VkPipelineTessellationStateCreateInfo类型的变量,在其中定义构成补丁的控制点的数量(参考 指定管线细分状态 菜谱)。 -
如果在给定的管线中不会禁用光栅化过程,则创建一个名为
viewport_state_create_info的VkPipelineViewportStateCreateInfo类型的变量。在该变量中,指定视口和裁剪测试参数(参考 指定管线视口和裁剪测试状态 菜谱)。 -
创建一个名为
rasterization_state_create_info的VkPipelineRasterizationStateCreateInfo类型的变量,该变量定义了光栅化的属性(参考 指定管线光栅化状态 菜谱)。 -
如果在给定的管线中启用了光栅化,则创建一个名为
multisample_state_create_info的VkPipelineMultisampleStateCreateInfo类型的变量,该变量定义了多采样(抗锯齿)参数(参考 指定管线多采样状态 菜谱)。 -
如果在给定的管线中绘制时启用了光栅化并且使用了深度和/或模板附件,则创建一个名为
depth_and_stencil_state_create_info的VkPipelineDepthStencilStateCreateInfo类型的变量。使用它来定义深度和模板测试的参数(参考 指定管线深度和模板状态 菜谱)。 -
如果没有禁用光栅化,则创建一个名为
blend_state_create_info的VkPipelineColorBlendStateCreateInfo类型的变量,通过它来指定对片段执行操作时的参数(参考 指定管线混合状态 菜谱)。 -
如果管线中有一部分应该动态设置,则创建一个名为
dynamic_state_creat_info的VkPipelineDynamicStateCreateInfo类型的变量,该变量定义了那些动态设置的部件(参考 指定管线动态状态 菜谱)。 -
创建一个管线布局并将它的句柄存储在一个名为
pipeline_layout的VkPipelineLayout类型的变量中。 -
在一个渲染通道中获取句柄,该通道将使用给定的管线绑定进行绘制。使用渲染通道句柄初始化一个名为
render_pass的VkRenderPass类型的变量(参考第六章 创建渲染通道 的配方,渲染通道和帧缓冲区)。 -
创建一个名为
subpass的uint32_t类型的变量。存储在绘制操作期间将使用给定管线的渲染通道的子通道索引(参考第六章 指定子通道描述 的配方,渲染通道和帧缓冲区)。 -
创建一个名为
graphics_pipeline_create_info的VkGraphicsPipelineCreateInfo类型的变量。使用以下值初始化其成员:-
对于
sType,提供一个VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO值。 -
对于
pNext,提供一个nullptr值。 -
对于
flags,提供一个additional_options变量。 -
对于
stageCount,提供shader_stage_create_infos向量中元素的数量。 -
对于
pStages,提供一个指向shader_stage_create_infos向量第一个元素的指针。 -
对于
pVertexInputState,提供一个指向vertex_input_state_create_info变量的指针。 -
对于
pInputAssemblyState,提供一个指向input_assembly_state_create_info变量的指针。 -
如果需要激活细分,则提供一个指向
tessellation_state_create_info变量的指针,否则如果需要禁用细分,则对于pTessellationState提供一个nullptr值。 -
如果光栅化是激活的,则提供一个指向
viewport_state_create_info变量的指针,或者如果光栅化被禁用,则对于pViewportState提供一个nullptr值。 -
对于
pRasterizationState,提供一个指向rasterization_state_create_info变量的指针。 -
如果光栅化被启用,则提供一个指向
multisample_state_create_info变量的指针,否则对于pMultisampleState提供一个nullptr值。 -
如果光栅化被启用并且
subpass中使用了深度和/或模板附件,则提供一个指向depth_and_stencil_state_create_info变量的指针,否则对于pDepthStencilState提供一个nullptr值。 -
如果光栅化被启用并且
subpass中使用了颜色附件,则提供一个指向blend_state_create_info变量的指针,否则对于pColorBlendState提供一个nullptr值。 -
如果管线中有一部分需要动态设置,则提供一个指向
dynamic_state_creat_info变量的指针,或者对于pDynamicState,如果整个管线都是静态准备的,则提供一个nullptr值。 -
对于
layout,提供一个pipeline_layout变量。 -
对于
renderPass,提供一个render_pass变量。 -
对于
subpass,提供一个subpass变量。 -
如果管线应该从另一个已创建的管线派生,则提供父管线的句柄,否则对于
basePipelineHandle提供一个VK_NULL_HANDLE。 -
如果管线应该从同一批管线中创建的另一个管线派生,则提供父管线的索引,否则对于
basePipelineIndex提供一个-1值。
-
它是如何工作的...
为创建图形管道准备数据是分多个步骤进行的,每个步骤指定图形管道的不同部分。所有这些参数都汇总在一个类型为 VkGraphicsPipelineCreateInfo 的变量中。
在管道创建过程中,我们可以提供许多类型为 VkGraphicsPipelineCreateInfo 的参数,每个参数指定将要创建的单个管道的属性。
当创建图形管道时,我们可以在记录绘图命令之前将其绑定到命令缓冲区来用于绘图。图形管道只能在渲染通道内(在记录渲染通道的开始之后)绑定到命令缓冲区。在管道创建过程中,我们指定给定管道将在哪个渲染通道中使用。然而,我们不仅限于提供的渲染通道。如果它们与指定的渲染通道兼容,我们还可以使用相同的管道。请参阅第六章(2de4339d-8912-440a-89a6-fd1f84961448.xhtml)中的“创建渲染通道”菜谱,渲染通道和帧缓冲区。
每个创建的管道与其他管道没有任何共同状态的情况是很少见的。这就是为什么,为了加快管道创建速度,可以指定一个管道可以作为其他管道的父级(允许派生)或者该管道将成为另一个管道的子级(从另一个管道派生)。为了使用此功能并缩短创建管道所需的时间,我们可以使用 VkGraphicsPipelineCreateInfo 变量的 basePipelineHandle 或 basePipelineIndex 成员(在这个菜谱中的 graphics_pipeline_create_info 变量)。
basePipelineHandle 成员允许我们指定一个已创建管道的句柄,该句柄应该是新创建管道的父级。
basePipelineIndex 成员在同时创建多个管道时使用。通过它,我们指定 vkCreateGraphicsPipelines() 函数提供的 VkGraphicsPipelineCreateInfo 类型元素数组的索引。此索引指向将在同一函数调用中与子管道一起创建的父级管道。由于它们是同时创建的,我们不能提供句柄,这就是为什么有一个单独的索引字段。一个要求是父级管道的索引必须小于子级管道的索引(它必须在 VkGraphicsPipelineCreateInfo 元素列表中先出现,在描述派生管道的元素之前)。
我们不能同时使用 basePipelineHandle 和 basePipelineIndex 成员;我们只能提供一个值。如果我们想指定句柄,我们必须为 basePipelineIndex 字段提供一个 -1 值。如果我们想指定索引,我们需要为 basePipelineHandle 成员提供一个 VK_NULL_HANDLE 值。
本章前面的食谱中描述了其余的参数。以下是如何使用它们来初始化 VkGraphicsPipelineCreateInfo 类型变量的成员的示例:
VkGraphicsPipelineCreateInfo graphics_pipeline_create_info = {
VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
nullptr,
additional_options,
static_cast<uint32_t>(shader_stage_create_infos.size()),
shader_stage_create_infos.data(),
&vertex_input_state_create_info,
&input_assembly_state_create_info,
&tessellation_state_create_info,
&viewport_state_create_info,
&rasterization_state_create_info,
&multisample_state_create_info,
&depth_and_stencil_state_create_info,
&blend_state_create_info,
&dynamic_state_creat_info,
pipeline_layout,
render_pass,
subpass,
base_pipeline_handle,
base_pipeline_index
};
参见
本章中的以下食谱:
-
指定管线着色器阶段
-
指定管线顶点绑定描述、属性描述和输入状态
-
指定管线输入装配状态
-
指定管线细分状态
-
指定管线视口和剪裁测试状态
-
指定管线光栅化状态
-
指定管线多采样状态
-
指定管线深度和模板状态
-
指定管线混合状态
-
指定管线动态状态
-
创建管线布局
创建管线缓存对象
从驱动程序的角度来看,创建管线对象是一个复杂且耗时的过程。管线对象不是创建期间设置的参数的简单包装器。它涉及到准备所有可编程和固定管线阶段的态,设置着色器和描述符资源之间的接口,编译和链接着色器程序,以及执行错误检查(即检查着色器是否正确链接)。这些操作的结果可以存储在缓存中。然后,可以使用此缓存来加速具有相似属性的管线对象的创建。要使用管线缓存对象,我们首先需要创建它。
如何操作...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型的变量中。 -
如果可用(即从其他缓存中检索),准备数据以初始化一个新创建的缓存对象。将数据存储在名为
cache_data的std::vector<unsigned char>类型的变量中。 -
创建一个名为
pipeline_cache_create_info的VkPipelineCacheCreateInfo类型的变量。使用以下值初始化其成员:-
VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO值为sType。 -
pNext的nullptr值。 -
flags的0值。 -
cache_data向量中的元素数量(初始化数据的字节大小)为initialDataSize。 -
cache_data向量第一个元素的指针为pInitialData。
-
-
创建一个名为
pipeline_cache的VkPipelineCache类型的变量,其中将存储创建的缓存对象的句柄。 -
执行以下函数调用:
vkCreatePipelineCache(logical_device, &pipeline_cache_create_info, nullptr, &pipeline_cache)。对于调用,提供logical_device变量、pipeline_cache_create_info变量的指针、nullptr值和pipeline_cache变量的指针。 -
通过检查它是否返回了
VK_SUCCESS值来确保调用成功。
它是如何工作的...
如其名所示,管线缓存存储了管线准备过程的结果。它是可选的,可以省略,但使用时可以显著加快管线对象的创建。
在管道创建期间使用缓存,我们只需创建一个缓存对象并将其提供给管道创建函数。驱动程序会自动在提供的对象中缓存结果。此外,如果缓存包含任何数据,驱动程序会自动尝试将其用于管道创建。
使用管道缓存对象最常见的场景是将其内容存储在文件中,以便在相同应用程序的不同执行之间重用。第一次运行我们的应用程序时,我们创建一个空缓存和所有需要的管道。接下来,我们检索缓存数据并将其保存到文件中。下次应用程序执行时,我们也会创建缓存,但这次我们使用从先前创建的文件中读取的内容来初始化它。从现在开始,每次我们运行应用程序时,创建管道的过程应该会短得多。当然,当我们只创建少量管道时,我们可能不会注意到任何改进。但现代 3D 应用程序,尤其是游戏,可能有数十、数百,有时甚至数千种不同的管道(由于着色器变化)。在这种情况下,缓存可以显著提高创建所有这些管道的过程。
假设缓存数据存储在一个名为cache_data的向量变量中。它可能是空的,或者初始化为从先前管道创建中检索的内容。使用此数据的管道缓存创建过程在以下代码中展示:
VkPipelineCacheCreateInfo pipeline_cache_create_info = {
VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO,
nullptr,
0,
static_cast<uint32_t>(cache_data.size()),
cache_data.data()
};
VkResult result = vkCreatePipelineCache( logical_device, &pipeline_cache_create_info, nullptr, &pipeline_cache );
if( VK_SUCCESS != result ) {
std::cout << "Could not create pipeline cache." << std::endl;
return false;
}
return true;
参见
本章中的以下食谱:
-
从管道缓存检索数据
-
合并多个管道缓存对象
-
创建图形管道
-
创建计算管道
-
在多个线程上创建多个图形管道
-
销毁管道缓存
从管道缓存检索数据
缓存使我们能够提高创建多个管道对象的速度。但为了每次执行我们的应用程序时都能使用缓存,我们需要一种方法来存储缓存的全部内容,并在我们想要的时候重用它。为此,我们可以检索缓存中收集的数据。
如何做到这一点...
-
获取逻辑设备的句柄,并使用它来初始化一个名为
logical_device的VkDevice类型的变量。 -
将从其中检索数据的管道缓存句柄存储在一个名为
pipeline_cache的VkPipelineCache类型的变量中。 -
准备一个名为
data_size的size_t类型的变量。 -
调用
vkGetPipelineCacheData(logical_device, pipeline_cache, &data_size, nullptr),提供logical_device和pipeline_cache变量,data_size变量的指针和一个nullptr值。 -
如果函数调用成功(返回了
VK_SUCCESS值),可以存储缓存内容的内存大小将存储在data_size变量中。 -
准备缓存内容的存储空间。创建一个名为
pipeline_cache_data的std::vector<unsigned char>类型的变量。 -
将
pipeline_cache_data向量的大小调整为至少可以容纳data_size个元素。 -
调用
vkGetPipelineCacheData( logical_device, pipeline_cache, &data_size, pipeline_cache_data.data() ),但这次除了之前使用的参数外,还提供一个指向pipeline_cache_data向量第一个元素的指针作为最后一个参数。 -
如果函数成功返回,缓存内容将存储在
pipeline_cache_data向量中。
它是如何工作的...
从管道缓存中检索内容是在典型的 Vulkan 双调用中执行单个函数。vkGetPipelineCacheData()函数的第一个调用,存储了存储从管道缓存中检索到的整个数据所需的字节数。这使我们能够为数据准备足够的存储空间:
size_t data_size = 0;
VkResult result = VK_SUCCESS;
result = vkGetPipelineCacheData( logical_device, pipeline_cache, &data_size, nullptr );
if( (VK_SUCCESS != result) ||
(0 == data_size) ) {
std::cout << "Could not get the size of the pipeline cache." <<
std::endl;
return false;
}
pipeline_cache_data.resize( data_size );
现在,当我们准备好获取缓存内容时,我们可以再次调用vkGetPipelineCacheData()函数。这次最后一个参数必须指向已准备的存储的开始。成功的调用将提供的字节数写入指定的内存:
result = vkGetPipelineCacheData( logical_device, pipeline_cache, &data_size, pipeline_cache_data.data());
if( (VK_SUCCESS != result) ||
(0 == data_size) ) {
std::cout << "Could not acquire pipeline cache data." << std::endl;
return false;
}
return true;
以这种方式检索的数据可以直接用于初始化任何其他新创建的缓存对象的内容。
参见
本章中的以下食谱:
-
创建管道缓存对象
-
合并多个管道缓存对象
-
创建图形管道
-
创建计算管道
-
销毁管道缓存
合并多个管道缓存对象
可能是一个常见的场景,我们将在我们的应用程序中创建多个管道。为了缩短创建所有这些管道所需的时间,将创建过程分成多个同时执行的线程可能是一个好主意。每个这样的线程应使用单独的管道缓存。所有线程完成后,我们希望在下一次应用程序执行时重用缓存。为此,最好将多个缓存对象合并成一个。
如何做到这一点...
-
将逻辑设备的句柄存储在一个名为
logical_device的类型为VkDevice的变量中。 -
将其他缓存将合并到的缓存对象取出来。使用其句柄,初始化一个名为
target_pipeline_cache的类型为VkPipelineCache的变量。 -
创建一个名为
source_pipeline_caches的类型为std::vector<VkPipelineCache>的变量。将所有应合并到source_pipeline_caches向量中的管道缓存句柄存储在该变量中(确保没有缓存对象与target_pipeline_cache缓存相同)。 -
执行以下调用:
vkMergePipelineCaches( logical_device, target_pipeline_cache, static_cast<uint32_t>(source_pipeline_caches.size()), source_pipeline_caches.data() )。对于调用,提供logical_device和target_pipeline_cache变量,source_pipeline_caches向量中的元素数量,以及指向source_pipeline_caches向量第一个元素的指针。 -
确保调用成功,并返回了
VK_SUCCESS值。
它是如何工作的...
合并管线缓存允许我们将单独的缓存对象合并成一个。这样,就可以在多个线程中使用多个单独的缓存进行多个管线创建,然后将结果合并成一个公共的缓存对象。单独的线程也可以使用相同的管线缓存对象,但缓存访问可能由驱动程序中的互斥锁保护,因此将任务分割成多个线程相当无用。将一个缓存数据保存到文件中比管理多个缓存简单。而且,在合并操作期间,驱动程序应删除重复条目,从而为我们节省一些额外的空间和内存。
合并多个管线缓存对象的操作如下:
VkResult result = vkMergePipelineCaches( logical_device, target_pipeline_cache, static_cast<uint32_t>(source_pipeline_caches.size()), source_pipeline_caches.data() );
if( VK_SUCCESS != result ) {
std::cout << "Could not merge pipeline cache objects." << std::endl;
return false;
}
return true;
我们需要记住,我们合并其他缓存对象的缓存不能出现在要合并的(源)缓存列表中。
参见
本章中的以下食谱:
-
创建管线缓存对象
-
从管线缓存中检索数据
-
创建图形管线
-
创建计算管线
-
在多个线程上创建多个图形管线
-
销毁管线缓存
创建图形管线
图形管线是允许我们在屏幕上绘制任何内容的对象。它控制图形硬件执行所有与绘图相关的操作,将应用程序提供的顶点转换为屏幕上出现的片段。通过它,我们指定绘图期间使用的着色器程序,深度和模板等测试的状态和参数,或者最终颜色是如何计算并写入任何子通道附件的。它是我们应用程序中使用的重要对象之一。在我们能够绘制任何内容之前,我们需要创建一个图形管线。如果我们愿意,我们可以一次创建多个管线。
如何操作...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型的变量中。 -
创建一个名为
graphics_pipeline_create_infos的std::vector<VkGraphicsPipelineCreateInfo>类型的变量。对于应该创建的每个管线,向graphics_pipeline_create_infos向量中添加一个元素,描述该管线的参数(参考 指定图形管线创建参数 食谱)。 -
如果在创建过程中应使用管线缓存,将其句柄存储在名为
pipeline_cache的VkPipelineCache类型的变量中。 -
创建一个名为
graphics_pipelines的std::vector<VkPipeline>类型的变量,其中将存储创建的pipeline的句柄。将向量的大小调整为与graphics_pipeline_create_infos向量中的元素数量相同。 -
调用
vkCreateGraphicsPipelines(logical_device, pipeline_cache, static_cast<uint32_t>(graphics_pipeline_create_infos.size()), graphics_pipeline_create_infos.data(), nullptr, graphics_pipelines.data())并提供logical_device变量、pipeline_cache变量或nullptr值(如果管线创建期间没有使用缓存),graphics_pipeline_create_infos向量中的元素数量,指向graphics_pipeline_create_info向量第一个元素的指针,一个nullptr值,以及指向graphics_pipeline向量第一个元素的指针。 -
确保所有管线都成功创建,通过检查调用是否返回了
VK_SUCCESS值。如果任何管线没有成功创建,将返回其他值。
它是如何工作的...
图形管线允许我们在屏幕上绘制任何东西。它控制由图形硬件实现的管线所有可编程和固定阶段的参数。以下图像展示了图形管线的一个简化图。白色块代表可编程阶段,灰色块是管线的固定部分:

可编程阶段包括顶点、曲面控制与评估、几何和片段着色器,其中只有顶点阶段是必需的。其余的都是可选的,是否启用取决于在管线创建期间指定的参数。例如,如果禁用了光栅化,则没有片段着色器阶段。如果我们启用了曲面阶段,我们需要提供曲面控制和评估着色器。
使用 vkCreateGraphicsPipelines() 函数创建一个图形管线。它允许我们一次性创建多个管线。我们需要提供一个类型为 VkGraphicsPipelineCreateInfo 的变量数组,数组中的元素数量,以及一个指向类型为 VkPipeline 的数组元素的指针。此数组必须足够大,可以容纳与类型为 VkGraphicsPipelineCreateInfo 的输入数组相同数量的元素(即 graphics_pipeline_create_infos 向量)。当我们准备 graphics_pipeline_create_infos 向量中的元素并想要使用其 basePipelineIndex 成员来指定在同一个函数调用内创建的父管线时,我们提供 graphics_pipeline_create_infos 向量中的索引。
本配方实现如下代码所示:
graphics_pipelines.resize( graphics_pipeline_create_infos.size() );
VkResult result = vkCreateGraphicsPipelines( logical_device, pipeline_cache, static_cast<uint32_t>(graphics_pipeline_create_infos.size()), graphics_pipeline_create_infos.data(), nullptr, graphics_pipelines.data() );
if( VK_SUCCESS != result ) {
std::cout << "Could not create a graphics pipeline." << std::endl;
return false;
}
return true;
参见
本章中的以下配方:
-
指定图形管线创建参数
-
创建管线缓存对象
-
绑定管线对象
-
使用顶点和片段着色器创建图形管线,启用深度测试,并具有动态视口和裁剪测试
-
在多个线程上创建多个图形管线
-
销毁管线
创建计算管线
计算管道是 Vulkan API 中可用的第二种管道类型。它用于调度计算着色器,可以执行任何数学运算。由于计算管道比图形管道简单得多,我们通过提供更少的参数来创建它。
如何做到...
-
拿到逻辑设备的句柄,并用它初始化一个名为
logical_device的VkDevice类型的变量。 -
创建一个名为
additional_options的VkPipelineCreateFlags类型的变量。用以下这些额外的管道创建选项的任意组合来初始化它:-
禁用优化:指定创建的管道不会被优化,但创建过程可能会更快
-
允许派生:指定可以从它创建其他管道
-
派生:指定此管道将基于另一个已创建的管道创建
-
-
通过
compute_shader_stage变量创建一个名为VkPipelineShaderStageCreateInfo的变量,通过它指定单个计算着色器阶段(参考 指定管道着色器阶段 菜谱)。 -
创建一个管道布局并将它的句柄存储在名为
pipeline_layout的VkPipelineLayout类型的变量中。 -
如果在管道创建期间应使用管道缓存,将创建的缓存对象的句柄存储在名为
pipeline_cache的VkPipelineCache类型的变量中。 -
创建一个名为
compute_pipeline_create_info的VkComputePipelineCreateInfo类型的变量。使用以下值来初始化其成员:-
VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO值用于sType -
pNext的值为nullptr -
additional_options变量用于flags -
compute_shader_stage变量用于stage -
pipeline_layout变量用于layout -
如果管道应该是另一个管道的子管道,提供父管道的句柄或为
basePipelineHandle提供一个VK_NULL_HANDLE值。 -
basePipelineIndex的值为-1
-
-
创建一个名为
compute_pipeline的VkPipeline类型的变量,用于存储创建的计算管道的句柄。 -
调用
vkCreateComputePipelines(logical_device, pipeline_cache, 1, &compute_pipeline_create_info, nullptr, &compute_pipeline)并提供logical_device变量,如果需要启用缓存则提供pipeline_cache变量或VK_NULL_HANDLE值,1值,指向compute_pipeline_create_info变量的指针,nullptr值,以及指向compute_pipeline变量的指针。 -
通过检查它是否返回了
VK_SUCCESS值来确保调用成功。
它是如何工作的...
当我们想要调度计算着色器时,我们使用计算管道。计算管道仅由单个计算着色器阶段组成(尽管如果需要,硬件可能实现额外的阶段)。
计算管道不能在渲染通道内使用。
计算着色器除了某些内置值外没有任何输入或输出变量。对于输入和输出数据,只能使用统一变量(缓冲区或图像)(参考第七章的编写计算着色器配方,着色器)。这就是尽管计算管道更简单,但计算着色器更通用,可以用于执行数学运算或对图像进行操作的运算。
计算管道,类似于图形管道,可以批量创建,只需要向计算管道创建函数提供多个 VkComputePipelineCreateInfo 类型的变量。此外,计算管道可以是其他计算管道的父级,并且可以从其他父级管道继承。所有这些都加快了创建过程。要使用此功能,我们需要为 VkComputePipelineCreateInfo 变量的 basePipelineHandle 或 basePipelineIndex 成员提供适当的值(参考创建图形管道配方)。
以下代码展示了创建单个计算管道的简化过程:
VkComputePipelineCreateInfo compute_pipeline_create_info = {
VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
nullptr,
additional_options,
compute_shader_stage,
pipeline_layout,
base_pipeline_handle,
-1
};
VkResult result = vkCreateComputePipelines( logical_device, pipeline_cache, 1, &compute_pipeline_create_info, nullptr, &compute_pipeline );
if( VK_SUCCESS != result ) {
std::cout << "Could not create compute pipeline." << std::endl;
return false;
}
return true;
参见
-
在第七章,着色器中,查看以下配方:
- 编写计算着色器
-
本章中的以下配方:
-
指定管道着色器阶段
-
创建管道布局
-
创建管道缓存对象
-
销毁管道
-
绑定管道对象
在我们可以发出绘制命令或调度计算工作之前,我们需要设置所有必要的命令以成功执行的状态。其中之一是绑定管道对象到命令缓冲区--如果我们想在屏幕上绘制对象,则为图形管道;如果我们想执行计算工作,则为计算管道。
如何操作...
-
获取命令缓冲区的句柄并将其存储在名为
command_buffer的VkCommandBuffer类型变量中。确保命令缓冲区处于录制状态。 -
如果需要绑定图形管道,请确保渲染通道的开始已经在
command_buffer中被记录。如果需要绑定计算管道,请确保没有开始渲染通道或在command_buffer中完成任何渲染通道。 -
获取管道对象的句柄。使用它来初始化一个名为
pipeline的VkPipeline类型的变量。 -
调用
vkCmdBindPipeline(command_buffer, pipeline_type, pipeline)。提供command_buffer变量、被绑定到命令缓冲区的管道类型(图形或计算)以及pipeline变量。
它是如何工作的...
在我们可以在命令缓冲区中绘制或调度计算工作之前,需要绑定管道。图形管道只能在渲染通道内绑定--在管道创建期间指定的或兼容的通道。计算管道不能在渲染通道内使用。如果我们想使用它们,任何已经开始的渲染通道都需要完成。
使用单个函数调用,如以下示例,绑定管道对象:
vkCmdBindPipeline( command_buffer, pipeline_type, pipeline );
参见
-
在 第三章,命令缓冲区和同步,查看以下配方:
- 开始命令缓冲区记录操作
-
在 第六章,渲染通道和帧缓冲区,查看以下配方:
-
开始渲染通道
-
结束渲染通道
-
-
本章中的以下配方:
-
创建图形管道
-
创建计算管道
-
创建具有组合图像采样器、缓冲区和推送常量范围的管道布局
我们知道如何创建描述符集布局并使用它们来创建管道布局。在这里,在这个示例配方中,我们将查看如何创建特定的管道布局——允许管道访问组合图像采样器、统一缓冲区和选定的推送常量范围。
如何做到这一点...
-
获取逻辑设备的句柄并将其存储在类型为
VkDevice的变量logical_device中。 -
创建一个类型为
std::vector<VkDescriptorSetLayoutBinding>的变量,命名为descriptor_set_layout_bindings. -
向
descriptor_set_layout_bindings向量添加新元素,并使用以下值初始化其成员:-
0的值用于binding. -
VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE的值用于descriptorType. -
1的值用于descriptorCount. -
VK_SHADER_STAGE_FRAGMENT_BIT的值用于stageFlags. -
nullptr的值用于pImmutableSamplers.
-
-
向
descriptor_set_layout_bindings向量添加第二个成员,并使用以下值初始化其成员:-
1的值用于binding. -
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER的值用于descriptorType. -
1的值用于descriptorCount. -
VK_SHADER_STAGE_VERTEX_BIT的值用于stageFlags. -
nullptr值用于pImmutableSamplers.
-
-
使用
logical_device和descriptor_set_layout_bindings变量创建描述符集布局,并将其存储在类型为VkDescriptorSetLayout的变量descriptor_set_layout中(参考 第五章,描述符集 中的 创建描述符集布局 配方)。 -
创建一个类型为
std::vector<VkPushConstantRange>的变量,命名为push_constant_ranges,并用所需的推送常量范围数量初始化它,每个范围具有所需的值(参考 创建管道布局 配方)。 -
创建一个类型为
VkPipelineLayout的变量,命名为pipeline_layout,其中将存储创建的渲染通道句柄。 -
使用
logical_device、descriptor_set_layout和push_constant_ranges变量创建管道布局。将创建的句柄存储在pipeline_layout变量中(参考 创建管道布局 配方)。
它是如何工作的...
在这个菜谱中,我们假设我们想要创建一个需要访问统一缓冲区和合成图像采样器的图形管线。这是一个常见的情况--我们在顶点着色器中使用统一缓冲区将顶点从局部空间转换到裁剪空间。片段着色器用于纹理,因此它需要访问合成图像采样器描述符。
我们需要创建一个包含这两种类型资源的描述符集。为此,我们为它创建一个布局,该布局定义了一个在顶点着色器中使用的统一缓冲区和在片段着色器中访问的合成图像采样器:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT,
nullptr
}
};
if( !CreateDescriptorSetLayout( logical_device, descriptor_set_layout_bindings, descriptor_set_layout ) ) {
return false;
}
使用这样的描述符集布局,我们可以使用一个包含推送常量范围的额外向量的信息来创建一个管线布局:
if( !CreatePipelineLayout( logical_device, { descriptor_set_layout }, push_constant_ranges, pipeline_layout ) ) {
return false;
}
return true;
现在,当我们使用这种布局创建管线时,我们可以将一个描述符集绑定到索引 0。这个描述符集必须有两个描述符资源--绑定 0 的合成图像采样器和绑定 1 的统一缓冲区。
参见
-
在 第五章,描述符集 中,查看以下菜谱:
- 创建描述符集布局
-
创建管线布局,在本章中。
创建具有顶点和片段着色器、启用深度测试以及具有动态视口和裁剪测试的图形管线
在这个菜谱中,我们将看到如何创建一个常用的图形管线,其中顶点和片段着色器是活动的,并且启用了深度测试。我们还将指定视口和裁剪测试是动态设置的。
如何做到这一点...
-
拿到一个逻辑设备的把手。使用它来初始化一个名为
logical_device的VkDevice类型的变量。 -
拿到一个顶点着色器的 SPIR-V 汇编,并使用它以及
logical_device变量来创建一个着色器模块。将其存储在一个名为vertex_shader_module的VkShaderModule类型的变量中(参考 创建着色器模块 菜谱)。 -
拿到一个片段着色器的 SPIR-V 汇编,并使用它以及
logical_device变量创建第二个着色器模块。将其句柄存储在一个名为fragment_shader_module的VkShaderModule类型的变量中(参考 创建着色器模块 菜谱)。 -
创建一个名为
shader_stage_params的std::vector类型的变量,其元素为自定义的ShaderStageParameters类型(参考 指定管线着色器阶段 菜谱)。 -
向
shader_stage_params向量中添加一个元素,并使用以下值来初始化其成员:-
VK_SHADER_STAGE_VERTEX_BIT值用于ShaderStage。 -
ShaderModule的vertex_shader_module变量。 -
EntryPointName的main字符串。 -
SpecializationInfo的nullptr值。
-
-
向
shader_stage_params向量中添加第二个元素,并使用以下值来初始化其成员:-
VK_SHADER_STAGE_FRAGMENT_BIT值用于ShaderStage。 -
ShaderModule的fragment_shader_module变量。 -
EntryPointName的main字符串。 -
SpecializationInfo的nullptr值。
-
-
创建一个名为
shader_stage_create_infos的类型为std::vector<VkPipelineShaderStageCreateInfo>的变量,并使用shader_stage_params向量的成员对其进行初始化(参考 指定管道着色器阶段 菜谱)。 -
创建一个名为
vertex_input_state_create_info的类型为VkPipelineVertexInputStateCreateInfo的变量。使用所需的顶点输入绑定和顶点属性参数对其进行初始化(参考 指定管道顶点绑定描述、属性描述和输入状态 菜谱)。 -
创建一个名为
input_assembly_state_create_info的类型为VkPipelineInputAssemblyStateCreateInfo的变量,并使用所需的原始拓扑(三角形列表或三角形带,或线列表等)对其进行初始化,并决定是否启用原始重启(参考 指定管道输入装配状态 菜谱)。 -
创建一个名为
viewport_state_create_info的类型为VkPipelineViewportStateCreateInfo的变量。使用ViewportInfo类型的变量对其进行初始化,其中视口和剪裁测试向量的元素均为一个。存储在这些向量中的值无关紧要,因为视口和模板参数将在命令缓冲区记录期间动态定义。但是,由于视口(和剪裁测试状态)的数量是静态定义的,因此这两个向量都需要有一个元素(参考 指定管道视口和剪裁测试状态 菜谱)。 -
创建一个名为
rasterization_state_create_info的类型为VkPipelineRasterizationStateCreateInfo的变量,并使用所选值对其进行初始化。记得为rasterizerDiscardEnable成员提供一个假值(参考 指定管道光栅化状态 菜谱)。 -
创建一个名为
multisample_state_create_info的类型为VkPipelineMultisampleStateCreateInfo的变量。指定多采样所需的参数(参考 指定管道多采样状态 菜谱)。 -
创建一个名为
depth_and_stencil_state_create_info的类型为VkPipelineDepthStencilStateCreateInfo的变量。记得启用深度写入和深度测试,并为深度测试指定一个VK_COMPARE_OP_LESS_OR_EQUAL操作符。根据需要定义其余的深度和模板参数(参考 指定管道深度和模板状态 菜谱)。 -
创建一个名为
blend_state_create_info的类型为VkPipelineColorBlendStateCreateInfo的变量,并使用所需的值集对其进行初始化(参考 指定管道混合状态 菜谱)。 -
创建一个名为
dynamic_states的类型为std::vector<VkDynamicState>的变量。向该向量添加两个元素,一个具有VK_DYNAMIC_STATE_VIEWPORT值,另一个具有VK_DYNAMIC_STATE_SCISSOR值。 -
创建一个名为
dynamic_state_create_info的类型为VkPipelineDynamicStateCreateInfo的变量。使用dynamic_states向量准备其内容(参考 指定管道动态状态 菜谱)。 -
创建一个名为
graphics_pipeline_create_info的VkGraphicsPipelineCreateInfo类型的变量。使用shader_stage_create_infos、vertex_input_state_create_info、input_assembly_state_create_info、viewport_state_create_info、rasterization_state_create_info、multisample_state_create_info、depth_and_stencil_state_create_info、blend_state_create_info和dynamic_state_create_info变量初始化它。提供创建的管道布局、选定的渲染通道及其子通道。使用父管道的句柄或索引。为细分状态信息提供nullptr值。 -
使用
logical_device和graphics_pipeline_create_info变量创建图形管道。如果需要,提供管道缓存句柄。将创建的管道句柄存储在名为graphics_pipeline的std::vector<VkPipeline>类型的一个元素向量变量中。
它是如何工作的...
最常用的管道之一是只有顶点和片段着色器的管道。为了准备顶点和片段着色器阶段的参数,我们可以使用以下代码:
std::vector<unsigned char> vertex_shader_spirv;
if( !GetBinaryFileContents( vertex_shader_filename, vertex_shader_spirv ) ) {
return false;
}
VkDestroyer<VkShaderModule> vertex_shader_module( logical_device );
if( !CreateShaderModule( logical_device, vertex_shader_spirv, *vertex_shader_module ) ) {
return false;
}
std::vector<unsigned char> fragment_shader_spirv;
if( !GetBinaryFileContents( fragment_shader_filename, fragment_shader_spirv ) ) {
return false;
}
VkDestroyer<VkShaderModule> fragment_shader_module( logical_device );
if( !CreateShaderModule( logical_device, fragment_shader_spirv, *fragment_shader_module ) ) {
return false;
}
std::vector<ShaderStageParameters> shader_stage_params = {
{
VK_SHADER_STAGE_VERTEX_BIT,
*vertex_shader_module,
"main",
nullptr
},
{
VK_SHADER_STAGE_FRAGMENT_BIT,
*fragment_shader_module,
"main",
nullptr
}
};
std::vector<VkPipelineShaderStageCreateInfo> shader_stage_create_infos;
SpecifyPipelineShaderStages( shader_stage_params, shader_stage_create_infos );
在前面的代码中,我们加载了顶点和片段着色器的源代码,为它们创建了着色器模块,并指定了着色器阶段的参数。
接下来我们需要选择我们想要的顶点绑定和顶点属性参数:
VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info;
SpecifyPipelineVertexInputState( vertex_input_binding_descriptions, vertex_attribute_descriptions, vertex_input_state_create_info );
VkPipelineInputAssemblyStateCreateInfo input_assembly_state_create_info;
SpecifyPipelineInputAssemblyState( primitive_topology, primitive_restart_enable, input_assembly_state_create_info );
视口和剪裁测试参数很重要。但因为我们想动态定义它们,所以在管道创建期间只关心视口的数量。这就是为什么在这里我们可以指定我们想要的任何值:
ViewportInfo viewport_infos = {
{
{
0.0f,
0.0f,
500.0f,
500.0f,
0.0f,
1.0f
}
},
{
{
{
0,
0
},
{
500,
500
}
}
}
};
VkPipelineViewportStateCreateInfo viewport_state_create_info;
SpecifyPipelineViewportAndScissorTestState( viewport_infos, viewport_state_create_info );
接下来我们需要为光栅化和多采样状态准备参数(如果我们要使用片段着色器,则必须启用光栅化):
VkPipelineRasterizationStateCreateInfo rasterization_state_create_info;
SpecifyPipelineRasterizationState( false, false, polygon_mode, culling_mode, front_face, false, 0.0f, 1.0f, 0.0f, 1.0f, rasterization_state_create_info );
VkPipelineMultisampleStateCreateInfo multisample_state_create_info;
SpecifyPipelineMultisampleState( VK_SAMPLE_COUNT_1_BIT, false, 0.0f, nullptr, false, false, multisample_state_create_info );
我们还希望启用深度测试(以及深度写入)。通常我们想要模拟人们或相机观察世界的方式,其中靠近观察者的对象阻挡了视线,并遮挡了更远处的对象。这就是为什么对于深度测试,我们指定一个VK_COMPARE_OP_LESS_OR_EQUAL运算符,它定义了具有较低或等于深度值的样本通过,而具有较高深度值的样本则深度测试失败。其他与深度相关的参数和模板测试的参数可以按我们的意愿设置,但在这里我们假设模板测试是禁用的(因此模板测试参数的值在这里不重要):
VkStencilOpState stencil_test_parameters = {
VK_STENCIL_OP_KEEP,
VK_STENCIL_OP_KEEP,
VK_STENCIL_OP_KEEP,
VK_COMPARE_OP_ALWAYS,
0,
0,
0
};
VkPipelineDepthStencilStateCreateInfo depth_and_stencil_state_create_info;
SpecifyPipelineDepthAndStencilState( true, true, VK_COMPARE_OP_LESS_OR_EQUAL, false, 0.0f, 1.0f, false, stencil_test_parameters, stencil_test_parameters, depth_and_stencil_state_create_info );
混合参数可以设置为我们想要的:
VkPipelineColorBlendStateCreateInfo blend_state_create_info;
SpecifyPipelineBlendState( logic_op_enable, logic_op, attachment_blend_states, blend_constants, blend_state_create_info );
最后一件事情是准备动态状态列表:
std::vector<VkDynamicState> dynamic_states = {
VK_DYNAMIC_STATE_VIEWPORT,
VK_DYNAMIC_STATE_SCISSOR
};
VkPipelineDynamicStateCreateInfo dynamic_state_create_info;
SpecifyPipelineDynamicStates( dynamic_states, dynamic_state_create_info );
现在我们可以创建一个管道:
VkGraphicsPipelineCreateInfo graphics_pipeline_create_info;
SpecifyGraphicsPipelineCreationParameters( additional_options, shader_stage_create_infos, vertex_input_state_create_info, input_assembly_state_create_info, nullptr, &viewport_state_create_info,
rasterization_state_create_info, &multisample_state_create_info, &depth_and_stencil_state_create_info, &blend_state_create_info, &dynamic_state_create_info, pipeline_layout, render_pass,
subpass, base_pipeline_handle, -1, graphics_pipeline_create_info );
if( !CreateGraphicsPipelines( logical_device, { graphics_pipeline_create_info }, pipeline_cache, graphics_pipeline ) ) {
return false;
}
return true;
参见
本章中的以下配方:
-
指定管道着色器阶段
-
指定管道顶点绑定描述、属性描述和输入状态
-
指定管道输入装配状态
-
指定管道视口和剪裁测试状态
-
指定管道光栅化状态
-
指定管道多采样状态
-
指定管道深度和模板状态
-
指定管道混合状态
-
指定管道动态状态
-
创建管道布局
-
指定图形管道创建参数
-
创建管道缓存对象
-
创建图形管道
在多个线程上创建多个图形管道
创建图形管道的过程可能需要(相对)较长时间。着色器编译发生在管道创建过程中,驱动程序检查编译的着色器是否可以正确链接,以及是否为着色器指定了正确状态以便它们可以正确工作。这就是为什么,特别是当我们有很多管道需要创建时,将此过程拆分为多个线程是很好的。
但是当我们有很多管道需要创建时,我们应该使用缓存来进一步加快创建速度。在这里,我们将看到如何为多个并发管道创建使用缓存,以及如何在之后合并缓存。
准备工作
在这个配方中,我们使用一个自定义模板包装类,它是VkDestroyer<>类的包装。它用于自动销毁未使用的资源。
如何操作...
-
将从其中读取缓存内容的文件名和写入缓存内容的文件名存储在一个名为
pipeline_cache_filename的std::string类型变量中。 -
创建一个名为
cache_data的std::vector<unsigned char>类型的变量。如果名为pipeline_cache_filename的文件存在,则将其内容加载到cache_data向量中。 -
获取逻辑设备的句柄并将其存储在一个名为
logical_device的VkDevice类型的变量中。 -
创建一个名为
pipeline_caches的std::vector<VkPipelineCache>类型的变量。对于每个单独的线程,创建一个管道缓存对象并将其句柄存储在pipeline_caches向量中(参考创建管道缓存对象配方)。 -
创建一个名为
threads的std::vector<std::thread>类型的变量。调整其大小以存储所需数量的线程。 -
创建一个名为
graphics_pipelines_create_infos的std::vector<std::vector<VkGraphicsPipelineCreateInfo>>类型的变量。对于每个线程,向graphics_pipelines_create_infos变量中添加新的向量,包含类型为VkGraphicsPipelineCreateInfo的变量,其中这些变量的数量应等于在给定线程上应创建的管道数量。 -
创建一个名为
graphics_pipelines的std::vector<std::vector<VkPipeline>>类型的变量。调整每个成员向量的大小,以存储在给定线程上创建的相同数量的管道。 -
创建所需数量的线程,其中每个线程使用
logical_device变量、对应于此线程的缓存(pipeline_caches[<线程编号>])以及相应类型为VkGraphicsPipelineCreateInfo的元素向量(graphics_pipelines_create_infos[<线程编号>]向量变量)创建选定的数量管道。 -
等待所有线程完成。
-
在一个名为
target_cache的VkPipelineCache类型的变量中创建新的缓存。 -
将存储在
pipeline_caches向量中的管道缓存合并到target_cache变量中(参考合并多个管道缓存对象食谱)。 -
从
target_cache变量中检索缓存内容并将其存储在cache_data向量中。 -
将
cache_data向量的内容保存到名为pipeline_cache_filename的文件中(用新数据替换文件的内容)。
它是如何工作的...
创建多个图形管道需要我们为许多不同的管道提供大量参数。但是,使用单独的线程,其中每个线程创建多个管道,应该可以减少创建所有管道所需的时间。
为了更快地完成任务,使用管道缓存是个好主意。首先,我们需要从文件中读取之前存储的缓存内容,如果它已经被创建的话。接下来,我们需要为每个单独的线程创建缓存。每个缓存都应该用从文件中加载的缓存内容(如果找到了)来初始化:
std::vector<unsigned char> cache_data;
GetBinaryFileContents( pipeline_cache_filename, cache_data );
std::vector<VkDestroyer<VkPipelineCache>> pipeline_caches( graphics_pipelines_create_infos.size() );
for( size_t i = 0; i < graphics_pipelines_create_infos.size(); ++i ) {
pipeline_caches[i] = VkDestroyer< VkPipelineCache >( logical_device );
if( !CreatePipelineCacheObject( logical_device, cache_data, *pipeline_caches[i] ) ) {
return false;
}
}
下一步是为每个线程上创建的管道句柄准备存储空间。我们还使用相应的缓存对象启动创建多个管道的所有线程:
std::vector<std::thread>threads( graphics_pipelines_create_infos.size() );
for( size_t i = 0; i < graphics_pipelines_create_infos.size(); ++i ) {
graphics_pipelines[i].resize( graphics_pipelines_create_infos[i].size() );
threads[i] = std::thread::thread( CreateGraphicsPipelines, logical_device, graphics_pipelines_create_infos[i], *pipeline_caches[i], graphics_pipelines[i] );
}
现在,我们需要等待直到所有线程都完成。之后,我们可以将不同缓存对象(来自每个线程)合并成一个,从中检索内容。这些新内容我们可以存储在最初加载内容的同一文件中(我们应该替换内容):
for( size_t i = 0; i < graphics_pipelines_create_infos.size(); ++i ) {
threads[i].join();
}
VkPipelineCache target_cache = *pipeline_caches.back();
std::vector<VkPipelineCache> source_caches( pipeline_caches.size() - 1);
for( size_t i = 0; i < pipeline_caches.size() - 1; ++i ) {
source_caches[i] = *pipeline_caches[i];
}
if( !MergeMultiplePipelineCacheObjects( logical_device, target_cache, source_caches ) ) {
return false;
}
if( !RetrieveDataFromPipelineCache( logical_device, target_cache, cache_data ) ) {
return false;
}
if( !SaveBinaryFile( pipeline_cache_filename, cache_data ) ) {
return false;
}
return true;
参见
本章中的以下食谱:
-
指定图形管道创建参数
-
创建管道缓存对象
-
从管道缓存中检索数据
-
合并多个管道缓存对象
-
创建图形管道
-
销毁管道缓存
销毁管道
当管道对象不再需要,并且我们确信它没有被任何提交的命令缓冲区中的硬件使用时,我们可以安全地销毁它。
如何操作...
-
获取逻辑设备的句柄。使用它来初始化一个名为
logical_device的VkDevice类型变量。 -
获取应该被销毁的管道对象的句柄。将其存储在名为
pipeline的VkPipeline类型变量中。确保它没有被任何提交到任何可用队列的命令引用。 -
调用
vkDestroyPipeline(logical_device, pipeline, nullptr),其中提供logical_device和pipeline变量以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值分配给pipeline变量。
它是如何工作的...
当管道不再需要时,我们可以通过调用vkDestroyPipeline()函数来销毁它,如下所示:
if( VK_NULL_HANDLE != pipeline ) {
vkDestroyPipeline( logical_device, pipeline, nullptr );
pipeline = VK_NULL_HANDLE;
}
管线对象在渲染过程中被使用。因此,在我们能够销毁它们之前,我们必须确保所有使用它们的渲染命令都已经完成。这最好通过将栅栏对象与给定命令缓冲区的提交关联起来来完成。之后,我们需要等待栅栏,然后才能销毁在命令缓冲区中引用的管线对象(参考 等待栅栏 食谱)。然而,其他同步方法也是有效的。
参见
-
在 第三章,命令缓冲区和同步 中,查看以下食谱:
-
等待栅栏
-
等待所有提交的命令完成
-
-
本章中的以下食谱:
-
创建图形管线
-
创建计算管线
-
销毁管线缓存
管线缓存不在任何记录在命令缓冲区中的命令中使用。这就是为什么,当我们创建了所有想要的管线、合并了缓存数据或检索了其内容后,我们可以销毁缓存。
如何做...
-
将逻辑设备的句柄存储在名为
logical_device的VkDevice类型变量中。 -
拿到应该被销毁的管线缓存对象句柄。使用句柄来初始化一个名为
pipeline_cache的VkPipelineCache类型变量。 -
调用
vkDestroyPipelineCache( logical_device, pipeline_cache, nullptr )并提供logical_device和pipeline_cache变量,以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE值存储在pipeline_cache变量中。
它是如何工作的...
管线缓存对象只能在创建管线、从其中检索数据以及将多个缓存合并为一个时使用。这些操作都不会记录在命令缓冲区中,因此,一旦执行上述任何操作的函数完成,我们就可以像这样销毁缓存:
if( VK_NULL_HANDLE != pipeline_cache ) {
vkDestroyPipelineCache( logical_device, pipeline_cache, nullptr );
pipeline_cache = VK_NULL_HANDLE;
}
参见
本章中的以下食谱:
-
创建管线缓存对象
-
从管线缓存中检索数据
-
合并多个管线缓存对象
-
创建图形管线
-
创建计算管线
销毁管线布局
当我们不再需要管线布局,并且不打算用它创建更多管线,绑定使用该布局的描述符集或更新推送常量,并且所有使用管线布局的操作都已经完成时,我们可以销毁布局。
如何做...
-
拿到逻辑设备的句柄。使用它来初始化一个名为
logical_device的VkDevice类型的变量。 -
拿到一个存储在名为
pipeline_layout的VkPipelineLayout类型变量中的管线布局句柄。 -
调用
vkDestroyPipelineLayout( logical_device, pipeline_layout, nullptr )。对于这个调用,提供logical_device和pipeline_layout变量以及一个nullptr值。 -
为了安全起见,将
VK_NULL_HANDLE赋值给pipeline_layout变量。
它是如何工作的...
管线布局仅在三种情况下使用--创建管线、绑定描述符集和更新推送常量。当给定的管线布局仅用于创建管线时,它可以在管线创建后立即销毁。如果我们使用它来绑定描述符集或更新推送常量,我们需要等待硬件停止处理记录了这些操作的命令缓冲区。然后,我们可以安全地使用以下代码销毁管线布局:
if( VK_NULL_HANDLE != pipeline_layout ) {
vkDestroyPipelineLayout( logical_device, pipeline_layout, nullptr );
pipeline_layout = VK_NULL_HANDLE;
}
参见
-
在第三章的命令缓冲区和同步部分,见以下食谱:
-
等待栅栏
-
等待所有提交的命令完成
-
-
在第五章的描述符集部分,见以下食谱:
- 绑定描述符集
-
本章中的以下食谱:
-
创建管线布局
-
创建图形管线
-
创建计算管线
-
通过推送常量向着色器提供数据
-
销毁着色器模块
着色器模块仅用于创建管线对象。在创建后,如果我们不再打算使用它们,我们可以立即销毁它们。
如何操作...
-
使用逻辑设备的句柄初始化一个名为
logical_device的VkDevice类型变量。 -
取出存储在名为
shader_module的VkShaderModule类型变量中的着色器模块句柄。 -
调用
vkDestroyShaderModule( logical_device, shader_module, nullptr ),提供logical_device变量、shader_module变量和一个nullptr值。 -
为了安全起见,将
shader_module变量的值设置为VK_NULL_HANDLE。
它是如何工作的...
着色器模块仅在管线创建期间使用。它们作为着色器阶段状态的一部分提供。当使用给定模块的管线已经创建时,我们可以在管线创建函数完成后立即销毁这些模块(因为它们对于驱动程序正确使用管线对象不再需要)。
创建的管线不再需要着色器模块即可成功使用。
要销毁着色器模块,请使用以下代码:
if( VK_NULL_HANDLE != shader_module ) {
vkDestroyShaderModule( logical_device, shader_module, nullptr );
shader_module = VK_NULL_HANDLE;
}
参见
本章中的以下食谱:
-
创建着色器模块
-
指定管线着色器阶段
-
创建图形管线
-
创建计算管线
第九章:命令记录和绘制
在本章中,我们将涵盖以下菜谱:
-
清除颜色图像
-
清除深度-模板图像
-
清除渲染通道附件
-
绑定顶点缓冲区
-
绑定索引缓冲区
-
通过推送常数向着色器提供数据
-
动态设置视口状态
-
动态设置裁剪状态
-
动态设置线宽状态
-
动态设置深度偏差状态
-
动态设置混合常数状态
-
绘制几何图形
-
绘制索引几何图形
-
调度计算工作
-
在主命令缓冲区内部执行次级命令缓冲区
-
记录一个具有动态视口和裁剪状态的几何图形的命令缓冲区
-
在多个线程上记录命令缓冲区
-
准备动画的单帧
-
通过增加单独渲染的帧数来提高性能
简介
Vulkan 被设计为一个图形和计算 API。其主要目的是允许我们使用由不同厂商生产的图形硬件生成动态图像。我们已经知道如何创建和管理资源,并将它们用作着色器的数据源。我们学习了不同的着色器阶段和管道对象,它们控制着渲染状态或调度计算工作。我们还知道如何记录命令缓冲区并将操作顺序放入渲染通道中。我们必须学习的最后一个步骤是如何利用这些知识来渲染图像。
在本章中,我们将了解我们可以记录哪些附加命令以及需要记录哪些命令,以便我们可以正确地渲染几何图形或执行计算操作。我们还将学习绘制命令,并在源代码中以这种方式组织它们,以最大化应用程序的性能。最后,我们将利用 Vulkan API 最伟大的优势之一——能够在多个线程中记录命令缓冲区。
清除颜色图像
在传统的图形 API 中,我们通过清除渲染目标或后缓冲区来开始渲染一个帧。在 Vulkan 中,我们应该通过指定渲染通道附件描述中的 loadOp 成员的 VK_ATTACHMENT_LOAD_OP_CLEAR 值来执行清除操作(参考第六章中的 指定附件描述 菜谱 Chapter 6,渲染通道和帧缓冲区)。但有时,我们无法在渲染通道内清除图像,我们需要隐式地执行此操作。
如何做...
-
获取存储在名为
command_buffer的VkCommandBuffer类型变量中的命令缓冲区句柄。确保命令缓冲区处于记录状态且没有渲染通道已开始。 -
获取应清除的图像句柄。通过名为
image的VkImage类型变量提供它。 -
将在清除时
image将具有的布局存储在名为image_layout的VkImageLayout类型变量中。 -
准备一个包含
image和应清除的数组层的所有米普级别的列表,并将其存储在名为image_subresource_ranges的std::vector<VkImageSubresourceRange>类型变量中。对于image的每个子资源范围,向image_subresource_ranges向量添加一个新元素,并使用以下值初始化其成员:-
对于
aspectMask,图像的方面(颜色、深度和/或模板方面不能提供) -
对于
baseMipLevel,在给定范围内需要清除的第一个米普级别 -
在给定范围内需要清除的连续米普级别数量,对于
levelCount -
在给定范围内应清除的第一个数组层编号,对于
baseArrayLayer -
需要清除的连续数组层数量,对于
layerCount
-
-
使用名为
VkClearColorValue的变量clear_color的以下成员提供图像应清除的颜色:-
int32: 当图像具有有符号整数格式时 -
uint32: 当图像具有无符号整数格式时 -
float32: 对于其余的格式
-
-
调用
vkCmdClearColorImage( command_buffer, image, image_layout, &clear_color, static_cast<uint32_t>(image_subresource_ranges.size()), image_subresource_ranges.data() )命令,它提供了command_buffer、image、image_layout变量,clear_color变量的指针,image_subresource_ranges向量的元素数量,以及image_subresource_ranges向量第一个元素的指针。
它是如何工作的...
通过在命令缓冲区中记录vkCmdClearColorImage()函数来执行清除颜色图像。vkCmdClearColorImage()命令不能在渲染通道内部记录。
它要求我们提供图像的句柄、其布局以及应清除的子资源(米普级别和/或数组层)的数组。我们还必须指定图像应清除的颜色。这些参数可以使用以下方式使用:
vkCmdClearColorImage( command_buffer, image, image_layout, &clear_color, static_cast<uint32_t>(image_subresource_ranges.size()), image_subresource_ranges.data() );
记住,通过使用此函数,我们只能清除颜色图像(具有颜色方面和颜色格式之一)。
vkCmdClearColorImage()函数只能用于使用传输目标用途创建的图像。
参见
-
在第三章,命令缓冲区和同步,查看以下配方:
- 开始命令缓冲区记录操作
-
在第四章,资源和内存,查看以下配方:
- 创建图像
-
在第六章,渲染通道和帧缓冲区,查看以下配方:
-
指定附件描述
-
清除渲染通道附件
-
清除深度-模板图像
-
清除深度-模板图像
类似于颜色图像,我们有时需要在渲染通道之外手动清除深度-模板图像。
如何操作...
-
取一个处于记录状态且当前没有在其中启动渲染通道的命令缓冲区。使用其句柄,初始化一个名为
command_buffer的VkCommandBuffer类型的变量。 -
取深度-模板图像的句柄并将其存储在名为
image的VkImage类型的变量中。 -
将表示清除期间
image将具有的布局的值存储在名为image_layout的VkImageLayout类型的变量中。 -
创建一个名为
image_subresource_ranges的std::vector<VkImageSubresourceRange>类型的变量,它将包含所有image的米普级别和数组层的列表,这些层应该被清除。对于这样的范围,向image_subresource_ranges向量添加一个新元素,并使用以下值来初始化其成员:-
对于
aspectMask,深度和/或模板方面 -
对于
baseMipLevel,给定范围内要清除的第一个米普级别 -
对于
levelCount,给定范围内连续的米普级别数 -
对于
baseArrayLayer,应该清除的第一个数组层的编号 -
在
layerCount的范围中要清除的连续数组层数
-
-
提供一个值,该值应用于使用名为
clear_value的VkClearDepthStencilValue类型变量的以下成员来清除(填充)图像:-
depth当需要清除深度方面时 -
stencil用于清除模板方面的值
-
-
调用
vkCmdClearDepthStencilImage(command_buffer, image, image_layout, &clear_value, static_cast<uint32_t>(image_subresource_ranges.size()), image_subresource_ranges.data())并提供command_buffer、image和image_layout变量,clear_value变量的指针,image_subresource_ranges向量的元素数量,以及image_subresource_ranges向量第一个元素的指针。
它是如何工作的...
在渲染通道之外清除深度-模板图像的操作如下:
vkCmdClearDepthStencilImage( command_buffer, image, image_layout, &clear_value, static_cast<uint32_t>(image_subresource_ranges.size()), image_subresource_ranges.data() );
我们只能使用此函数来创建具有传输目标使用情况(清除被视为传输操作)的图像。
参见
-
在第三章,命令缓冲区和同步,查看食谱:
- 开始命令缓冲区记录操作
-
在第四章,资源和内存,查看食谱:
- 创建一个图像
-
在第六章,渲染通道和帧缓冲区,查看以下食谱:
-
指定附加项描述
-
清除渲染通道附加项
-
-
本章中的清除颜色图像食谱
清除渲染通道附加项
有一些情况,我们不能仅仅依赖于作为初始渲染通道操作执行的隐式附加清除,我们需要在子通道之一中显式清除附加项。我们可以通过调用一个vkCmdClearAttachments()函数来实现。
如何做到这一点...
-
取一个处于记录状态的命令缓冲区,并将其句柄存储在名为
command_buffer的VkCommandBuffer类型的变量中。 -
创建一个名为
attachments的std::vector<VkClearAttachment>类型的变量。对于渲染通道当前子通道中应清除的每个framebuffer附件,向向量中添加一个元素,并用以下值初始化它:-
aspectMask的附件的方面(颜色、深度或模板) -
如果
aspectMask设置为VK_IMAGE_ASPECT_COLOR_BIT,则指定当前子通道中的颜色附件的索引colorAttachment;否则,此参数被忽略 -
颜色、深度或模板方面的期望清除值
clearValue
-
-
创建一个名为
rects的std::vector<VkClearRect>类型的变量。对于所有指定附件中应清除的每个区域,向向量中添加一个元素,并用以下值初始化它:-
要清除的矩形(左上角和宽高)
rect -
要清除的第一个层的索引
baseArrayLayer -
要清除的层数
layerCount
-
-
调用
vkCmdClearAttachments(command_buffer, static_cast<uint32_t>(attachments.size()), attachments.data(), static_cast<uint32_t>(rects.size()), rects.data())。对于函数调用,提供命令缓冲区的句柄、attachments向量中的元素数量、其第一个元素的指针、rects向量中的元素数量以及其第一个元素的指针。
它是如何工作的...
当我们想在已开始的渲染通道内显式清除用作帧缓冲区附件的图像时,我们不能使用通常的图像清除函数。我们只能通过选择哪些附件应该被清除来实现这一点。这通过 vkCmdClearAttachments() 函数来完成,如下所示:
vkCmdClearAttachments( command_buffer, static_cast<uint32_t>(attachments.size()), attachments.data(), static_cast<uint32_t>(rects.size()), rects.data() );
使用此函数,我们可以清除所有指示附件的多个区域。
我们只能在渲染通道内调用 vkCmdClearAttachments() 函数。
参见
-
在 第三章 中,命令缓冲区和同步,查看以下内容:
- 开始命令缓冲区记录操作
-
在 第六章 中,渲染通道和帧缓冲区,查看以下内容:
-
指定附件描述
-
指定子通道描述
-
开始渲染通道
-
-
本章以下内容:
-
清除颜色图像
-
清除深度-模板图像
-
绑定顶点缓冲区
当我们绘制几何体时,我们需要指定顶点的数据。至少,需要顶点位置,但我们还可以指定其他属性,如法线、切线或双切线向量、颜色或纹理坐标。这些数据来自使用 顶点缓冲区 用法创建的缓冲区。在我们可以发出绘制命令之前,我们需要将这些缓冲区绑定到指定的绑定上。
准备工作
在本食谱中,引入了一个自定义的 VertexBufferParameters 类型。它具有以下定义:
struct VertexBufferParameters {
VkBuffer Buffer;
VkDeviceSize MemoryOffset;
};
此类型用于指定缓冲区的参数:其句柄(在 Buffer 成员中)和从缓冲区内存起始位置开始的数据偏移(在 MemoryOffset 成员中)。
如何实现...
-
获取处于记录状态的命令缓冲区的句柄,并使用它初始化一个名为
command_buffer的VkCommandBuffer类型的变量。 -
创建一个名为
buffers的std::vector<VkBuffer>类型的变量。对于应绑定到命令缓冲区中特定绑定的每个缓冲区,将缓冲区的句柄添加到buffers向量中。 -
创建一个名为
offsets的std::vector<VkDeviceSize>类型的变量。对于buffers向量中的每个缓冲区,在offsets向量中添加一个新的成员,其偏移值从对应缓冲区内存的起始位置(buffers向量中相同索引的缓冲区)计算得出。 -
调用
vkCmdBindVertexBuffers( command_buffer, first_binding, static_cast<uint32_t>(buffers_parameters.size()), buffers.data(), offsets.data() ),提供命令缓冲区的句柄、第一个应绑定到其上的绑定编号、buffers(和offsets)向量中的元素数量,以及buffers向量第一个元素和offsets向量第一个元素的指针。
它是如何工作的...
在图形管线创建过程中,我们指定在绘制期间将使用(提供给着色器)的顶点属性。这是通过顶点绑定和属性描述来完成的(参考 第八章,图形和计算管线中的指定管线顶点绑定描述、属性描述和输入状态食谱)。通过它们,我们定义了属性的数量、它们的格式、着色器可以通过哪个位置访问它们,以及内存属性,如偏移和步进。我们还提供了从该绑定中读取给定属性的绑定索引。使用此绑定,我们需要将选定的缓冲区与给定属性(或属性集)的数据存储关联起来。关联是通过在给定命令缓冲区中将缓冲区绑定到选定的绑定索引来完成的,如下所示:
std::vector<VkBuffer> buffers;
std::vector<VkDeviceSize> offsets;
for( auto & buffer_parameters : buffers_parameters ) {
buffers.push_back( buffer_parameters.Buffer );
offsets.push_back( buffer_parameters.MemoryOffset );
}
vkCmdBindVertexBuffers( command_buffer, first_binding, static_cast<uint32_t>(buffers_parameters.size()), buffers.data(), offsets.data() );
在前面的代码中,通过一个名为 buffers_parameters 的 std::vector<VertexBufferParameters> 类型的变量提供了所有应绑定及其内存偏移的缓冲区句柄。
记住,我们只能绑定使用顶点缓冲区用途创建的缓冲区。
参见
-
在 第三章,命令缓冲区和同步,参见以下食谱:
- 开始命令缓冲区记录操作
-
在 第四章,资源和内存,参见以下食谱:
- 创建缓冲区
-
在 第八章,图形和计算管线,查看以下食谱:
-
指定管线顶点绑定描述
-
属性描述和输入状态
-
-
本章中的以下食谱:
-
绘制几何体
-
绘制索引几何体
-
绑定索引缓冲区
要绘制几何体,我们可以以两种方式提供顶点列表(及其属性)。第一种方式是一个典型的列表,其中顶点一个接一个地读取。第二种方法需要我们提供额外的索引,指示应读取哪些顶点以形成多边形。这个特性被称为索引绘制。它允许我们减少内存消耗,因为我们不需要多次指定相同的顶点。当每个顶点与多个属性相关联,并且每个这样的顶点被多个多边形使用时,这一点尤为重要。
索引存储在一个名为 索引缓冲区 的缓冲区中,在我们可以绘制索引几何体之前必须绑定它。
如何实现...
-
将命令缓冲区的句柄存储在名为
command_buffer的VkCommandBuffer类型的变量中。确保它处于记录状态。 -
取存储索引的缓冲区的句柄。使用其句柄初始化一个名为
buffer的VkBuffer类型的变量。 -
取一个偏移值(从缓冲区内存的起始位置),表示索引数据的开始。将偏移量存储在名为
memory_offset的VkDeviceSize类型的变量中。 -
提供用于索引的数据类型。使用
VK_INDEX_TYPE_UINT16值表示 16 位无符号整数或使用VK_INDEX_TYPE_UINT32值表示 32 位无符号整数。将值存储在名为index_type的VkIndexType类型的变量中。 -
调用
vkCmdBindIndexBuffer( command_buffer, buffer, memory_offset, index_type ),并提供命令缓冲区和缓冲区的句柄、内存偏移量值以及用于索引的数据类型(作为最后一个参数的index_type变量)。
它是如何工作的...
要将缓冲区用作顶点索引的来源,我们需要使用 索引缓冲区 用法创建它,并用适当的数据填充它--索引指示应使用哪些顶点进行绘制。索引必须紧密打包(一个接一个),它们应该仅指向顶点数据数组中的一个给定索引,因此得名。这在下图中显示:

在我们能够记录索引绘制命令之前,我们需要绑定一个索引缓冲区,如下所示:
vkCmdBindIndexBuffer( command_buffer, buffer, memory_offset, index_type );
对于调用,我们需要提供一个命令缓冲区,我们将记录函数和应作为索引缓冲区的缓冲区。还需要提供从缓冲区内存开始处的内存偏移量。它显示了驱动程序应该从缓冲区内存的哪些部分开始读取索引。上一个示例中的最后一个参数,index_type变量,指定了存储在缓冲区中的索引的数据类型--如果它们被指定为 16 位或 32 位的无符号整数。
参见
-
在第三章,命令缓冲区和同步中,查看食谱:
- 开始命令缓冲区记录操作
-
在第四章,资源和内存中,查看食谱:
- 创建缓冲区
-
本章以下食谱:
-
绑定顶点缓冲区
-
绘制索引几何体
-
通过推送常量提供数据给着色器
在绘制或调度计算工作期间,执行特定的着色器阶段--在管道创建期间定义的。因此,着色器可以完成其工作,我们需要向它们提供数据。大多数时候我们使用描述符集,因为它们允许我们通过缓冲区或图像提供千字节甚至兆字节的数据。但是使用它们相当复杂。更重要的是,描述符集的频繁更改可能会影响我们应用程序的性能。但是有时,我们需要以快速简单的方式提供少量数据。我们可以使用推送常量来完成此操作。
如何做...
-
将命令缓冲区的句柄存储在名为
command_buffer的VkCommandBuffer类型变量中。确保它处于记录状态。 -
取一个使用推送常量范围的管道布局。将布局的句柄存储在名为
pipeline_layout的VkPipelineLayout类型变量中。 -
通过名为
pipeline_stages的VkShaderStageFlags类型变量定义将访问给定推送常量数据范围的着色器阶段。 -
在名为
offset的uint32_t类型变量中指定一个偏移量(以字节为单位),从该偏移量更新推送常量内存。offset必须是 4 的倍数。 -
在名为
size的uint32_t类型变量中定义更新内存部分的字节大小。size必须是 4 的倍数。 -
使用名为
data的void *类型变量,提供一个指向内存的指针,从该内存中复制数据以推送常量内存。 -
进行以下调用:
vkCmdPushConstants( command_buffer, pipeline_layout,
pipeline_stages, offset, size, data )
- 对于调用,提供(按相同顺序)从 1 到 6 的子弹描述的变量。
它是如何工作的...
推送常量允许我们快速向着色器提供一小块数据(参考第七章中的在着色器中使用推送常量配方,着色器)。驱动程序需要提供至少 128 字节用于推送常量数据的内存。这并不多,但预计推送常量比在描述符资源中更新数据要快得多。这就是我们应该使用它们来提供非常频繁变化的数据的原因,即使是在每次绘制或计算着色器的分发中。
要推送常量数据的数据从提供的内存地址复制。记住,我们只能更新大小为 4 的倍数的数据。推送常量内存(我们复制数据的内存)中的偏移量也必须是 4 的倍数。例如,要复制四个浮点值,我们可以使用以下代码:
std::array<float, 4> color = { 0.0f, 0.7f, 0.4f, 0.1f };
ProvideDataToShadersThroughPushConstants( CommandBuffer, *PipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, static_cast<uint32_t>(sizeof( color[0] ) * color.size()), &color[0] );
ProvideDataToShadersThroughPushConstants() 是一个函数,它以下列方式实现此配方:
vkCmdPushConstants( command_buffer, pipeline_layout, pipeline_stages, offset, size, data );
参见
-
在第七章,着色器中,查看以下配方:
- 在着色器中使用推送常量
-
在第八章,图形和计算管线中,查看以下配方:
- 创建管线布局
动态设置视口状态
图形管线定义了在渲染过程中使用的许多不同状态参数。每次我们需要使用这些参数中的一些略微不同的值时,都创建单独的管线对象将会很繁琐且非常不实用。这就是为什么在 Vulkan 中有动态状态。我们可以定义一个视口变换作为其中之一。在这种情况下,我们通过记录在命令缓冲区中的函数调用来指定其参数。
如何做到这一点...
-
拿到一个处于记录状态的命令缓冲区的句柄。使用其句柄,初始化一个名为
command_buffer的VkCommandBuffer类型的变量。 -
指定应设置参数的第一个视口的编号。将数字存储在名为
first_viewport的uint32_t类型的变量中。 -
创建一个名为
viewports的std::vector<VkViewport>类型的变量。对于在管线创建过程中定义的每个视口,向viewports向量中添加一个新元素。通过它,使用以下值指定相应视口的参数:-
上左角(以像素为单位)的左侧对于
x -
上左角(以像素为单位)的顶部对于
y -
视口的宽度对于
width -
视口的宽度对于
height -
在片段深度计算过程中使用的最小深度值
minDepth -
对于
maxDepth,片段计算的最大深度值
-
-
调用
vkCmdSetViewport(command_buffer, first_viewport, static_cast<uint32_t>(viewports.size()), viewports.data())并提供command buffer的句柄、first_viewport变量、viewports向量中的元素数量以及指向viewports向量第一个元素的指针。
它是如何工作的...
视口状态可以指定为动态管线状态之一。我们在创建管线时这样做(参考第八章,图形和计算管线中的指定管线动态状态配方)。在这里,视口的尺寸通过如下函数调用指定:
vkCmdSetViewport( command_buffer, first_viewport, static_cast<uint32_t>(viewports.size()), viewports.data() );
定义在渲染过程中使用的每个视口维度的参数(参考第八章,指定管线视口和剪裁测试状态配方,来自图形和计算管线)通过一个数组指定,其中数组的每个元素对应一个给定的视口(通过firstViewport函数参数指定的值偏移,即前述代码中的first_viewport变量)。
我们只需要记住,在渲染过程中使用的视口数量始终在管线中静态指定,无论视口状态是否指定为动态。
参见
-
在第三章,命令缓冲区和同步中,查看以下配方:
- 开始命令缓冲区记录操作
-
在第八章,图形和计算管线中,查看以下配方:
-
指定管线视口和剪裁测试状态
-
指定管线动态状态
-
动态设置剪裁状态
视口定义了附件(图像)的一部分,其中剪辑空间将被映射。剪裁测试允许我们在指定的视口尺寸内进一步限制绘图到指定的矩形。剪裁测试始终启用;我们只能设置其参数的各种值。这可以在管线创建期间静态完成,也可以动态完成。后者是通过在命令缓冲区中记录的函数调用完成的。
如何实现...
-
将处于记录状态的命令缓冲区的句柄存储在名为
command_buffer的VkCommandBuffer类型变量中。 -
在名为
first_scissor的uint32_t类型变量中指定第一个剪裁矩形的编号。请记住,剪裁矩形的数量对应于视口的数量。 -
创建一个名为
scissors的std::vector<VkRect2D>类型变量。对于我们要指定的每个剪裁矩形,向scissors变量添加一个元素。使用以下值来指定其成员:-
对于
offset的x成员,从视口左上角开始的水平偏移(以像素为单位) -
对于
offset的y成员,从视口左上角开始的垂直偏移(以像素为单位) -
对于
extent的width成员的剪裁矩形的宽度(以像素为单位) -
对于
extent的height成员的剪裁矩形的宽度(以像素为单位)
-
-
调用
vkCmdSetScissor(command_buffer, first_scissor, static_cast<uint32_t>(scissors.size()), scissors.data()),并提供command_buffer和first_scissor变量、scissors向量的元素数量以及指向scissors向量第一个元素的指针。
它是如何工作的...
剪裁测试允许我们将渲染限制为视图端口内部指定的矩形区域。此测试始终启用,并且在创建管道期间必须为所有定义的视图端口指定。换句话说,指定的剪裁矩形数量必须与视图端口数量相同。如果我们动态地提供剪裁测试的参数,我们不需要在单个函数调用中完成。但在记录绘图命令之前,必须定义所有视图端口的剪裁矩形。
要定义用于剪裁测试的一组矩形,我们需要使用以下代码:
vkCmdSetScissor( command_buffer, first_scissor, static_cast<uint32_t>(scissors.size()), scissors.data() );
vkCmdSetScissor()函数允许我们仅定义视图端口子集的剪裁矩形。在scissors数组(向量)中指定索引i的参数对应于索引first_scissor + i的视图端口。
参见
-
在第三章,命令缓冲区和同步中,查看以下配方:
- 开始命令缓冲区记录操作
-
在第八章,图形和计算管道中,查看以下配方:
-
指定管道视口和剪裁测试状态
-
指定管道动态状态
-
-
动态设置视口状态,在本章中
动态设置线宽状态
在创建图形管道期间定义的参数之一是绘制线的宽度。我们可以静态地定义它。但如果我们打算绘制具有不同宽度的多条线,我们应该将线宽指定为动态状态之一。这样,我们可以使用相同的管道对象,并通过函数调用指定绘制线的宽度。
如何做到这一点...
-
获取正在记录的命令缓冲区的句柄,并使用它来初始化一个名为
command_buffer的VkCommandBuffer类型的变量。 -
通过创建一个名为
line_width的float类型的变量,通过该变量提供绘制线的宽度。 -
调用
vkCmdSetLineWidth(command_buffer, line_width),提供command_buffer和line_width变量。
它是如何工作的...
使用vkCmdSetLineWidth()函数调用动态设置给定图形管道的线宽。我们只需记住,为了使用不同的宽度,我们必须在创建逻辑设备时启用wideLines功能。否则,我们只能指定1.0f的值。在这种情况下,我们不应创建具有动态线宽状态的管道。但是,如果我们已启用所述功能并且想要指定不同的线宽值,我们可以这样做:
vkCmdSetLineWidth( command_buffer, line_width );
参见
-
在第三章,命令缓冲区和同步中,查看以下配方:
- 开始命令缓冲区记录操作
-
在第八章,图形和计算管线中,查看以下配方:
-
指定管线输入装配状态
-
指定管线光栅化状态
-
指定管线动态状态
-
动态设置深度偏移状态
当启用光栅化时,在此过程中生成的每个片段都有自己的坐标(屏幕上的位置)和深度值(距离摄像机的距离)。深度值用于深度测试,允许某些不透明物体覆盖其他物体。
启用深度偏移允许我们修改片段的计算深度值。我们可以在创建管线时提供对片段深度进行偏移的参数。但是,当深度偏移被指定为动态状态之一时,我们通过函数调用来实现。
如何做到这一点...
-
获取正在记录的命令缓冲区的句柄。使用句柄初始化一个名为
command_buffer的VkCommandBuffer类型变量。 -
将添加到片段深度中的常量偏移值存储在名为
constant_factor的float类型变量中。 -
创建一个名为
clamp的float类型变量。使用它来提供可以应用于未修改深度的最大(或最小)深度偏移。 -
准备一个名为
slope_factor的float类型变量,在其中存储应用于深度偏移计算期间使用的片段斜率的值。 -
调用
vkCmdSetDepthBias( command_buffer, constant_factor, clamp, slope_factor )函数,提供已准备的command_buffer、constant_factor、clamp和slope_factor变量,这些变量在之前的步骤中已提及。
它是如何工作的...
深度偏移用于偏移给定片段的深度值(或者更确切地说,从给定多边形生成的所有片段)。通常,当我们要绘制非常靠近其他物体的对象时使用它;例如,墙上的图片或海报。由于深度计算的性质,这些物体在从远处观看时可能会被错误地绘制(部分隐藏)。这个问题被称为深度冲突或 Z 冲突。
深度偏移修改了计算出的深度值——深度测试期间使用的值和存储在深度附加中的值,但以任何方式都不会影响渲染的图像(即,它不会增加海报与它所附着的墙壁之间的可见距离)。修改基于一个常量因子和片段的斜率。我们还指定了可以应用的深度偏移的最大或最小值(clamp)。这些参数提供如下:
vkCmdSetDepthBias( command_buffer, constant_factor, clamp, slope_factor );
参见
-
在第三章,命令缓冲区和同步中,查看以下配方:
- 开始命令缓冲区记录操作
-
在第八章,图形和计算管线中,查看以下食谱:
-
指定管线光栅化状态
-
指定管线深度和模板状态
-
指定管线动态状态
-
动态设置混合常数状态
混合是将存储在给定附件中的颜色与处理片段的颜色混合的过程。它通常用于模拟透明物体。
有多种方式可以将片段的颜色和存储在附件中的颜色组合在一起——对于混合,我们指定因子(权重)和操作,这些操作生成最终颜色。在这些计算中,也可能使用一个额外的、恒定的颜色。在管线创建过程中,我们可以指定动态提供恒定颜色的组件。在这种情况下,我们使用记录在命令缓冲区中的函数来设置它们。
如何做到...
-
获取命令缓冲区的句柄,并使用它来初始化一个名为
command_buffer的VkCommandBuffer类型的变量。 -
创建一个名为
blend_constants的std::array<float, 4>类型的变量。在数组的四个元素中,存储混合计算过程中使用的恒定颜色的红色、绿色、蓝色和 alpha 分量。 -
调用
vkCmdSetBlendConstants(command_buffer, blend_constants.data())并提供command_buffer变量以及blend_constants数组第一个元素的指针。
它是如何工作的...
在创建图形管线时,混合被启用(静态)。当我们启用它时,我们必须提供多个参数来定义此过程的行为(参考第八章,图形和计算管线中的指定管线混合状态食谱)。这些参数中包括混合常数——在混合计算过程中使用的恒定颜色的四个分量。通常,它们在管线创建过程中静态定义。但是,如果我们启用混合并打算为混合常数使用多个不同的值,我们应该指定我们将动态提供它们(参考第八章,图形和计算管线中的指定管线动态状态食谱)。这将使我们能够避免创建多个类似的图形管线对象。
混合常数的值通过单个函数调用提供,如下所示:
vkCmdSetBlendConstants( command_buffer, blend_constants.data() );
参考以下内容
-
在第三章,命令缓冲区和同步中,查看以下食谱:
- 开始命令缓冲区记录操作
-
在第八章,图形和计算管线中,查看以下食谱:
-
指定管线混合状态
-
指定管线动态状态
-
绘制几何图形
绘图是我们通常想要使用图形 API(如 OpenGL 或 Vulkan)执行的操作。它将应用程序提供的几何形状(顶点)通过顶点缓冲区发送到图形管线,在那里它通过可编程着色器和固定功能阶段逐步处理。
绘图需要我们提供我们想要处理的顶点数量(显示)。它还允许我们一次性显示同一几何形状的多个实例。
如何实现...
-
将命令缓冲区的句柄存储在类型为
VkCommandBuffer的变量command_buffer中。确保命令缓冲区目前正在被记录,并且渲染期间使用的所有状态的参数已经设置在其中(绑定到它)。还要确保渲染传递已在命令缓冲区中启动。 -
使用一个类型为
uint32_t的变量,命名为vertex_count,来保存我们想要绘制的顶点数量。 -
创建一个类型为
uint32_t的变量,命名为instance_count,并将其初始化为应显示的几何实例数量。 -
准备一个类型为
uint32_t的变量,命名为first_vertex。存储从该顶点开始绘图的第一个顶点的编号。 -
在变量
first_instance中创建一个类型为uint32_t的变量,用于存储第一个实例(实例偏移量)的编号。 -
调用以下函数:
vkCmdDraw(command_buffer, vertex_count, instance_count, first_vertex, first_instance)。对于调用,以相同的顺序提供所有前面的变量。
它是如何工作的...
绘图是通过调用vkCmdDraw()函数来执行的:
vkCmdDraw( command_buffer, vertex_count, instance_count, first_vertex, first_instance );
它允许我们绘制任意数量的顶点,其中顶点(及其属性)依次存储在顶点缓冲区中(不使用索引缓冲区)。在调用过程中,我们需要提供一个偏移量--从哪个顶点开始绘制。这可以在我们有一个顶点缓冲区中存储多个模型(例如,模型的化合物)并且我们只想绘制其中一个时使用。
前面的函数使我们能够绘制单个网格(模型),以及同一网格的多个实例。这在指定某些属性按实例而不是按顶点变化时特别有用(请参阅第八章,图形和计算管线中的指定管线顶点绑定描述、属性描述和输入状态配方)。这样,同一模型的每个绘制实例可能略有不同。

在 Vulkan 中,我们做的几乎所有事情都是在绘图时使用的。因此,在我们将绘图命令记录到命令缓冲区之前,我们必须确保所有所需的数据和参数都已正确设置。记住,每次我们记录命令缓冲区时,它没有任何状态。因此,在我们能够绘制任何内容之前,我们必须相应地设置状态。
在 Vulkan 中,没有默认状态这一说法。
一个例子可以是描述符集或动态管线状态。每次我们开始记录命令缓冲区时,在我们能够绘制任何内容之前,所有必需的描述符集(那些由着色器使用的)都必须绑定到命令缓冲区。同样,所有指定为动态的管线状态都必须通过相应的函数提供其参数。另一件需要记住的事情是渲染通道,它必须在命令缓冲区中启动,以便正确执行绘制。
绘制只能在渲染通道内执行。
参见
-
在第三章,命令缓冲区和同步中,查看以下食谱:
- 开始命令缓冲区记录操作
-
在第四章,资源和内存中,查看以下食谱:
- 创建一个缓冲区
-
在第五章,描述符集中,查看以下食谱:
- 绑定描述符集
-
在第六章,渲染通道和帧缓冲区中,查看以下食谱:
-
创建渲染通道
-
创建帧缓冲区
-
开始渲染通道
-
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建图形管线
-
绑定管线对象
-
-
本章中的以下食谱:
-
绑定顶点缓冲区
-
动态设置视口状态
-
动态设置裁剪状态
-
绘制索引几何图形
很常见的是,更方便地重用存储在顶点缓冲区中的顶点。就像立方体的角属于多个面一样,任意几何形状的顶点可能属于整个模型的多个部分。
逐个绘制对象顶点将需要我们多次存储相同的顶点(及其所有属性)。一个更好的解决方案是指出哪些顶点应该用于绘制,无论它们在顶点缓冲区中的顺序如何。为此,Vulkan API 中引入了索引绘制。要使用存储在索引缓冲区中的索引绘制几何图形,我们需要调用 vkCmdDrawIndexed() 函数。
如何做到...
-
创建一个名为
command_buffer的VkCommandBuffer类型的变量,在其中存储命令缓冲区的句柄。确保命令缓冲区处于记录状态。 -
使用要绘制的索引(和顶点)的数量初始化一个名为
index_count的uint32_t类型的变量。 -
使用要绘制的(相同几何形状的)实例数量初始化一个名为
instance_count的uint32_t类型的变量。 -
将索引缓冲区开头的偏移量(以索引数量计)存储在一个名为
first_index的uint32_t类型的变量中。从这个索引开始,将开始绘制。 -
准备一个名为
vertex_offset的uint32_t类型的变量,在其中存储顶点偏移量(添加到每个索引的值)。 -
创建一个名为
first_instance的uint32_t类型的变量,该变量应包含要绘制的第一个几何实例的编号。 -
调用以下函数:
vkCmdDrawIndexed( command_buffer, index_count, instance_count, first_index, vertex_offset, first_instance )。对于调用,提供所有前面的变量,顺序相同。
它是如何工作的...
索引绘制是减少内存消耗的方法。它允许我们从顶点缓冲区中删除重复的顶点,因此我们可以分配更小的顶点缓冲区。需要一个额外的索引缓冲区,但通常顶点数据需要更多的内存空间。这在每个顶点除了位置属性外还有更多属性(如法线、切线、双切线向量和两个纹理坐标)的情况下尤其如此,这些属性被非常频繁地使用。
索引绘制还允许图形硬件通过顶点缓存的形式重用已处理顶点的数据。在常规(非索引)绘制中,硬件需要处理每个顶点。当使用索引时,硬件有关于处理顶点的额外信息,并知道给定的顶点是否最近被处理过。如果相同的顶点最近被使用过(最后几十个处理的顶点),在许多情况下,硬件可能会重用该顶点之前处理的结果。
要使用顶点索引绘制几何体,我们需要在记录索引绘制命令之前绑定一个索引缓冲区(参考绑定索引缓冲区配方)。我们还需要启动一个渲染通道,因为索引绘制(类似于常规绘制)只能在渲染通道内记录。我们还需要绑定图形管线和所有其他所需状态(取决于图形管线使用的资源),然后我们可以调用以下函数:
vkCmdDrawIndexed( command_buffer, index_count, instance_count, first_index, vertex_offset, first_instance );
索引绘制,类似于常规绘制,只能在渲染通道内执行。
参见
-
在第三章,命令缓冲区和同步,查看配方:
- 开始命令缓冲区记录操作
-
在第四章,资源和内存,查看配方:
- 创建缓冲区
-
在第五章,描述符集,查看配方:
- 绑定描述符集
-
在第六章,渲染通道和帧缓冲区,查看以下配方:
-
创建渲染通道
-
创建帧缓冲区
-
开始渲染通道
-
-
在第八章,图形和计算管线,查看以下配方:
-
创建图形管线
-
绑定管线对象
-
-
本章中的以下配方:
-
绑定顶点缓冲区
-
绑定索引缓冲区
-
动态设置视口状态
-
动态设置剪裁状态
-
分派计算工作
除了绘图之外,Vulkan 还可以用于执行通用计算。为此,我们需要编写计算着色器并执行它们——这被称为分发。
当我们想要发出要执行的计算工作负载时,我们需要指定应该执行多少个单独的计算着色器实例以及它们如何被划分为工作组。
如何实现...
-
获取命令缓冲区的句柄并将其存储在名为
command_buffer的VkCommandBuffer类型变量中。确保命令缓冲区处于录制状态且当前没有启动渲染通道。 -
将沿
x维度的本地工作组数量存储在名为x_size的uint32_t类型变量中。 -
应将
y维度的本地工作组数量存储在名为y_size的uint32_t类型变量中。 -
使用沿
z维度的本地工作组数量来初始化一个名为z_size的uint32_t类型变量。 -
使用前面定义的变量作为参数记录
vkCmdDispatch(command_buffer, x_size, y_size, z_size)函数。
它是如何工作的...
当我们分发计算工作负载时,我们使用已绑定的计算管道中的计算着色器来执行它们被编程要完成的任务。计算着色器使用通过描述符集提供的资源。它们的计算结果也可以仅存储在通过描述符集提供的资源中。
计算着色器没有特定的目标或用例场景,它们必须满足。它们可以用于执行对从描述符资源读取的数据进行操作的计算。我们可以使用它们来执行图像后处理,例如色彩校正或模糊。我们可以执行物理计算并在缓冲区中存储变换矩阵或计算变形几何的新位置。可能性的限制仅限于所需的性能和硬件能力。
计算着色器以组的形式分发。在着色器源代码中指定了x、y和z维度中的局部调用次数(请参阅第七章中的编写计算着色器配方,着色器)。这些调用的集合称为工作组。在分发计算着色器时,我们指定每个x、y和z维度中应执行多少个工作组。这是通过vkCmdDispatch()函数的参数来完成的:
vkCmdDispatch( command_buffer, x_size, y_size, z_size );
我们只需要记住,给定维度中的工作组数量不能大于物理设备maxComputeWorkGroupCount[3]限制中相应索引的值。目前,硬件必须允许在给定维度中至少分发 65,535 个工作组。
在渲染通道内不能执行计算工作组的分发。在 Vulkan 中,渲染通道只能用于绘图。如果我们想在计算着色器内绑定计算管道并执行一些计算,我们必须结束渲染通道。
计算着色器不能在渲染通道内分发。
参见
-
在第三章,命令缓冲区和同步,查看菜谱:
- 开始命令缓冲区记录操作
-
在第五章,描述符集,查看菜谱:
- 绑定描述符集
-
在第六章,渲染通道和帧缓冲区,查看菜谱:
- 结束渲染通道
-
在第七章,着色器,查看以下菜谱:
-
编写计算着色器
-
创建计算管道
-
绑定管道对象
-
在主命令缓冲区内部执行次级命令缓冲区
在 Vulkan 中,我们可以记录两种类型的命令缓冲区——主命令缓冲区和次级命令缓冲区。主命令缓冲区可以直接提交到队列中。次级命令缓冲区只能在主命令缓冲区内部执行。
如何做...
-
获取命令缓冲区的句柄。将其存储在名为
command_buffer的VkCommandBuffer类型的变量中。确保命令缓冲区处于记录状态。 -
准备一个名为
secondary_command_buffers的std::vector<VkCommandBuffer>类型的变量,包含应在command_buffer内部执行的次级命令缓冲区。 -
记录以下命令:
vkCmdExecuteCommands(command_buffer, static_cast<uint32_t>(secondary_command_buffers.size()), secondary_command_buffers.data())。提供主命令缓冲区的句柄,secondary_command_buffers向量的元素数量,以及指向其第一个元素的指针。
它是如何工作的...
次级命令缓冲区的记录方式与主命令缓冲区类似。在大多数情况下,主命令缓冲区足以执行渲染或计算工作。但可能存在需要将工作分为两种命令缓冲区类型的情况。当我们记录了次级命令缓冲区,并希望图形硬件处理它们时,我们可以像这样在主命令缓冲区内部执行它们:
vkCmdExecuteCommands( command_buffer, static_cast<uint32_t>(secondary_command_buffers.size()), secondary_command_buffers.data() );
参见
-
在第三章,命令缓冲区和同步,查看菜谱:
- 开始命令缓冲区记录操作
记录一个带有动态视口和裁剪状态的几何图形绘制命令缓冲区
现在我们已经拥有了使用 Vulkan API 绘制图像所需的所有知识。在这个示例菜谱中,我们将汇总一些之前的菜谱,并看看如何使用它们来记录一个显示几何图形的命令缓冲区。
准备工作
要绘制几何图形,我们将使用一个自定义结构类型,其定义如下:
struct Mesh {
std::vector<float> Data;
std::vector<uint32_t> VertexOffset;
std::vector<uint32_t> VertexCount;
};
Data成员包含给定顶点的所有属性值,一个顶点接一个顶点。例如,位置属性有三个分量,法向量有三个分量,第一个顶点有两个纹理坐标。之后,是第二个顶点的位置、法向量和TexCoords的数据,依此类推。
VertexOffset 成员用于存储几何形状各个部分的顶点偏移。VertexCount 向量包含每个此类部分中的顶点数量。
在我们可以绘制存储在前述类型变量中的模型之前,我们需要将 Data 成员的内容复制到一个将绑定到命令缓冲区的缓冲区中作为顶点缓冲区。
如何做到这一点...
-
获取主命令缓冲区的句柄并将其存储在一个名为
command_buffer的VkCommandBuffer类型的变量中。 -
开始记录
command_buffer(参考第三章 开始命令缓冲区记录操作 的配方,命令缓冲区和同步)。 -
获取已获取的交换链图像的句柄,并使用它初始化一个名为
swapchain_image的VkImage类型的变量(参考第二章 获取交换链图像句柄 和 获取交换链图像 的配方,图像呈现)。 -
将用于交换链图像呈现的队列家族的索引存储在一个名为
present_queue_family_index的uint32_t类型的变量中。 -
将用于执行图形操作的队列家族的索引存储在一个名为
graphics_queue_family_index的uint32_t类型的变量中。 -
如果存储在
present_queue_family_index和graphics_queue_family_index变量中的值不同,在command_buffer中设置一个图像内存屏障(参考第四章 设置图像内存屏障 的配方,资源和内存)。为generating_stages参数使用VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT值,为consuming_stages参数使用VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT值。对于屏障,提供一个类型为ImageTransition的单个变量,并使用以下值初始化其成员:-
Image的swapchain_image变量 -
CurrentAccess的VK_ACCESS_MEMORY_READ_BIT值 -
NewAccess的VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT值 -
CurrentLayout的VK_IMAGE_LAYOUT_PRESENT_SRC_KHR值 -
NewLayout的VK_IMAGE_LAYOUT_PRESENT_SRC_KHR值 -
CurrentQueueFamily的present_queue_family_index变量 -
NewQueueFamily的graphics_queue_family_index变量 -
Aspect的VK_IMAGE_ASPECT_COLOR_BIT值
-
-
获取
render pass的句柄并将其存储在一个名为render_pass的VkRenderPass类型的变量中。 -
将与
render_pass兼容的framebuffer的句柄存储在一个名为framebuffer的VkFramebuffer类型的变量中。 -
将
framebuffer的大小存储在一个名为framebuffer_size的VkExtent2D类型的变量中。 -
创建一个名为
clear_values的std::vector<VkClearValue>类型的变量。对于在render_pass(和framebuffer)中使用的每个附件,向clear_values变量添加一个元素,并指定相应的附件应该清除的值。 -
在
command_buffer中记录一个render pass开始操作。使用render_pass、framebuffer、framebuffer_size和clear_values变量以及一个VK_SUBPASS_CONTENTS_INLINE值(参考第六章,渲染通道和帧缓冲区中的开始一个渲染通道配方)。 -
获取图形管道的句柄并使用它来初始化一个名为
graphics_pipeline的VkPipeline类型变量。确保管道是用动态视口和剪刀状态创建的。 -
将管道绑定到
command_buffer。提供一个VK_PIPELINE_BIND_POINT_GRAPHICS值和graphics_pipeline变量(参考第八章,图形和计算管道中的绑定管道对象配方)。 -
创建一个名为
viewport的VkViewport类型变量。使用以下值初始化其成员:-
x的0.0f值 -
y的0.0f值 -
framebuffer_size变量的width成员用于width -
framebuffer_size变量的height成员用于height -
minDepth的0.0f值 -
maxDepth的1.0f值
-
-
在
command_buffer中动态设置视口状态。将first_viewport参数设置为0值,并将一个包含viewport变量的std::vector<VkViewport>类型的向量作为viewports参数(参考动态设置视口状态配方)。 -
创建一个名为
scissor的VkRect2D类型变量。使用以下值初始化其成员:-
offset成员的x值为0 -
offset成员的y值为0 -
extent的width成员变量用于width -
extent的height成员变量用于height
-
-
在
command_buffer中动态设置剪刀状态。将first_scissor参数设置为0值,并将一个包含scissor变量的std::vector<VkRect2D>类型的向量作为scissors参数(参考本章中的动态设置剪刀状态配方)。 -
创建一个名为
vertex_buffers_parameters的std::vector<VertexBufferParameters>类型变量。对于每个应绑定到command_buffer作为顶点缓冲区的缓冲区,向vertex_buffers_parameters向量中添加一个元素。使用以下值初始化新元素的成员:-
应用于
Buffer的顶点缓冲区的缓冲区句柄 -
从缓冲区内存开始(应绑定到顶点缓冲区的内存部分)的字节偏移量用于
memoryoffset
-
-
将第一个绑定(第一个顶点缓冲区应绑定的绑定)的值存储在一个名为
first_vertex_buffer_binding的uint32_t类型变量中。 -
使用
first_vertex_buffer_binding和vertex_buffers_parameters变量将顶点缓冲区绑定到command_buffer(参考绑定顶点缓冲区配方)。 -
如果在绘图过程中需要使用任何描述符资源,请执行以下操作:
-
获取一个管道布局的句柄并将其存储在名为
pipeline_layout的类型为VkPipelineLayout的变量中(参考第八章,图形和计算管道中的创建管道布局配方)。 -
将要用于绘图的每个描述符集添加到名为
descriptor_sets的类型为std::vector<VkDescriptorSet>的向量变量中。 -
在名为
index_for_first_descriptor_set的类型为uint32_t的变量中存储第一个描述符集应绑定的索引。 -
使用
VK_PIPELINE_BIND_POINT_GRAPHICS值和pipeline_layout、index_for_first_descriptor_set和descriptor_sets变量将描述符集绑定到command_buffer。
-
-
在
command_buffer中绘制几何形状,指定vertex_count、instance_count、first_vertex和first_instance参数的期望值(参考绘制几何形状配方)。 -
在
command_buffer中结束一个渲染通道(参考第六章,渲染通道和帧缓冲区中的结束渲染通道配方)。 -
如果存储在
present_queue_family_index和graphics_queue_family_index变量中的值不同,在command_buffer中设置另一个图像内存屏障(参考第四章,资源和内存中的设置图像内存屏障配方)。对于屏障,提供一个类型为ImageTransition的单个变量,并使用以下值初始化:-
Image的swapchain_image变量 -
CurrentAccess的VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT值 -
NewAccess的VK_ACCESS_MEMORY_READ_BIT值 -
CurrentLayout的VK_IMAGE_LAYOUT_PRESENT_SRC_KHR值 -
NewLayout的VK_IMAGE_LAYOUT_PRESENT_SRC_KHR值 -
CurrentQueueFamily的graphics_queue_family_index变量和NewQueueFamily的present_queue_family_index变量 -
Aspect的VK_IMAGE_ASPECT_COLOR_BIT值
-
-
停止记录
command_buffer(参考第三章,命令缓冲区和同步中的结束命令缓冲区记录操作配方)。
它是如何工作的...
假设我们想要绘制单个对象。我们希望该对象直接显示在屏幕上,因此在我们开始之前,我们必须获取一个 swapchain 图像(参考第二章的获取 swapchain 图像配方,图像展示)。接下来,我们开始记录命令缓冲区(参考第三章的开始命令缓冲区记录操作配方,命令缓冲区和同步):
if( !BeginCommandBufferRecordingOperation( command_buffer, VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, nullptr ) ) {
return false;
}
我们首先需要记录的是将 swapchain 图像的布局更改为VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL布局。此操作应通过适当的渲染传递参数(初始和子传递布局)隐式执行。但是,如果用于展示和图形操作的队列来自两个不同的家族,我们必须执行所有权转移。这不能隐式完成--为此,我们需要设置一个图像内存屏障(参考第四章的设置图像内存屏障配方,资源和内存):
if( present_queue_family_index != graphics_queue_family_index ) {
ImageTransition image_transition_before_drawing = {
swapchain_image,
VK_ACCESS_MEMORY_READ_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
present_queue_family_index,
graphics_queue_family_index,
VK_IMAGE_ASPECT_COLOR_BIT
};
SetImageMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, { image_transition_before_drawing } );
}
下一步是开始一个渲染传递(参考第六章的开始渲染传递配方,渲染传递和帧缓冲区)。我们还需要绑定一个管道对象(参考第八章的绑定管道对象配方,图形和计算管道)。我们必须在设置任何与管道相关的状态之前完成此操作:
BeginRenderPass( command_buffer, render_pass, framebuffer, { { 0, 0 }, framebuffer_size }, clear_values, VK_SUBPASS_CONTENTS_INLINE );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphics_pipeline );
当管道绑定时,我们必须设置在管道创建期间标记为动态的任何状态。在这里,我们分别设置视口和剪裁测试状态(参考动态设置视口状态和动态设置剪裁状态配方)。我们还绑定了一个应该作为顶点数据源的缓冲区(参考绑定顶点缓冲区配方)。此缓冲区必须包含从类型为Mesh的变量中复制的数据:
VkViewport viewport = {
0.0f,
0.0f,
static_cast<float>(framebuffer_size.width),
static_cast<float>(framebuffer_size.height),
0.0f,
1.0f,
};
SetViewportStateDynamically( command_buffer, 0, { viewport } );
VkRect2D scissor = {
{
0,
0
},
{
framebuffer_size.width,
framebuffer_size.height
}
};
SetScissorStateDynamically( command_buffer, 0, { scissor } );
BindVertexBuffers( command_buffer, first_vertex_buffer_binding, vertex_buffers_parameters );
在这个例子中最后要做的另一件事是绑定描述符集,这些集可以在着色器内部访问(参考第五章的绑定描述符集配方,描述符集):
BindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, index_for_first_descriptor_set, descriptor_sets, {} );
现在我们已经准备好绘制几何图形。当然,在更高级的场景中,我们可能需要设置其他状态参数并绑定其他资源。例如,我们可能需要使用索引缓冲区并提供推送常数的值。但是,前面的设置对于许多情况也足够了:
for( size_t i = 0; i < geometry.Parts.size(); ++i ) {
DrawGeometry( command_buffer, geometry.Parts[i].VertexCount, instance_count, geometry.Parts[i].VertexOffset, first_instance );
}
要绘制几何图形,我们必须提供我们想要绘制的几何实例数量以及第一个实例的索引。顶点偏移量和要绘制的顶点数量来自类型为Mesh的变量的成员。
在我们能够停止记录命令缓冲区之前,我们需要结束一个渲染通道(参考第六章,渲染通道和帧缓冲区中的结束渲染通道配方)。之后,还需要在交换链图像上进行另一个转换。当我们完成单个动画帧的渲染后,我们希望展示(显示)一个交换链图像。为此,我们需要将其布局更改为VK_IMAGE_LAYOUT_PRESENT_SRC_KHR布局,因为这个布局是展示引擎正确显示图像所必需的。这个转换也应该通过渲染通道参数(最终布局)隐式执行。但是,如果用于图形操作和展示的队列不同,则需要进行队列所有权转移。这是通过另一个图像内存屏障来完成的。之后,我们停止记录命令缓冲区(参考第三章,命令缓冲区和同步中的结束命令缓冲区记录操作配方):
EndRenderPass( command_buffer );
if( present_queue_family_index != graphics_queue_family_index ) {
ImageTransition image_transition_before_present = {
swapchain_image,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
graphics_queue_family_index,
present_queue_family_index,
VK_IMAGE_ASPECT_COLOR_BIT
};
SetImageMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, { image_transition_before_present } );
}
if( !EndCommandBufferRecordingOperation( command_buffer ) ) {
return false;
}
return true;
这标志着命令缓冲区记录操作的结束。我们可以使用这个命令缓冲区并将其提交到一个(图形)队列中。它只能提交一次,因为它是以VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT标志记录的。但是,当然,我们也可以不使用这个标志来记录命令缓冲区,并多次提交。
在提交命令缓冲区后,我们可以展示一个交换链图像,因此它会在屏幕上显示。但是,我们必须记住,提交和展示操作应该是同步的(参考准备单个动画帧配方)。
参见
-
在第二章,图像展示,查看以下配方:
-
获取交换链图像
-
展示图像
-
-
在第三章,命令缓冲区和同步,查看以下配方:
-
开始命令缓冲区记录操作
-
结束命令缓冲区记录操作
-
-
在第四章,资源和内存,查看以下配方:
- 设置图像内存屏障
-
在第五章,描述符集,查看以下配方:
- 绑定描述符集
-
在第六章,渲染通道和帧缓冲区,查看以下配方:
-
开始渲染通道
-
结束渲染通道
-
-
在第八章,图形和计算管线,查看以下配方:
- 绑定管线对象
-
本章中的以下配方:
-
绑定顶点缓冲区
-
动态设置视口状态
-
动态设置裁剪状态
-
绘制几何体
-
准备单个动画帧
-
在多个线程上记录命令缓冲区
高级图形 API,如 OpenGL,使用起来更容易,但它们在许多方面也受到限制。其中一个方面是缺乏在多个线程上渲染场景的能力。Vulkan 填补了这个空白。它允许我们在多个线程上记录命令缓冲区,利用图形硬件以及主处理器的处理能力。
准备工作
为了本菜谱的目的,引入了一个新的类型。它具有以下定义:
struct CommandBufferRecordingThreadParameters {
VkCommandBuffer CommandBuffer;
std::function<bool( VkCommandBuffer )> RecordingFunction;
};
前面的结构用于存储用于记录命令缓冲区的每个线程的特定参数。将在给定线程上记录的命令缓冲区的句柄存储在 CommandBuffer 成员中。RecordingFunction 成员用于定义一个函数,在其中我们将记录命令缓冲区在单独的线程上。
如何做...
-
创建一个名为
threads_parameters的std::vector<CommandBufferRecordingThreadParameters>类型的变量。对于每个用于记录命令缓冲区的线程,向前面的向量中添加一个新元素。使用以下值初始化该元素:-
将要在单独的线程上记录的命令缓冲区的句柄用于
CommandBuffer -
用于记录给定命令缓冲区的函数(接受命令缓冲区句柄)用于
RecordingFunction
-
-
创建一个名为
threads的std::vector<std::thread>类型的变量。将其大小调整为能够容纳与threads_parameters向量相同数量的元素。 -
对于
threads_parameters向量中的每个元素,启动一个新的线程,该线程将使用RecordingFunction并将CommandBuffer作为函数的参数提供。将创建的线程的句柄存储在threads向量中的相应位置。 -
等待所有创建的线程通过连接
threads向量中的所有元素来完成它们的执行。 -
将所有记录的命令缓冲区收集到一个名为
command_buffers的std::vector<VkCommandBuffer>类型的变量中。
它是如何工作的...
当我们想在多线程应用程序中使用 Vulkan 时,我们必须记住几个规则。首先,我们不应该在多个线程上修改同一个对象。例如,我们不能从一个单一的池中分配命令缓冲区,或者我们不能从多个线程更新描述符集。
我们只能从多个线程访问只读资源或引用不同的资源。但是,由于可能难以追踪哪些资源是在哪个线程上创建的,通常,资源创建和修改应该只在单个 主 线程(我们也可以称之为 渲染线程)上执行。
在 Vulkan 中使用多线程最常见的情况是同时记录命令缓冲区。这个操作消耗了大部分处理器时间。从性能角度来看,这也是最重要的操作,因此将其分成多个线程是非常合理的。
当我们想要并行记录多个命令缓冲区时,我们不仅需要为每个线程使用独立的命令缓冲区,还需要使用独立的命令池。
我们需要为每个线程使用一个独立的命令池,命令缓冲区将记录在这个池中。换句话说——每个线程上记录的命令缓冲区必须从独立的命令池中分配。
命令缓冲区记录不会影响其他资源(除了池)。我们只准备将被提交到队列的命令,因此我们可以记录使用任何资源的任何操作。例如,我们可以记录访问相同图像或相同描述符集的操作。相同的管道可以在记录期间同时绑定到不同的命令缓冲区。我们还可以记录绘制到相同附件的操作。我们只记录(准备)操作。
在多个线程上记录命令缓冲区可以这样做:
std::vector<std::thread> threads( threads_parameters.size() );
for( size_t i = 0; i < threads_parameters.size(); ++i ) {
threads[i] = std::thread::thread( threads_parameters[i].RecordingFunction, threads_parameters[i].CommandBuffer );
}
在这里,每个线程都拥有一个独立的RecordingFunction成员,其中记录了相应的命令缓冲区。当所有线程完成它们的命令缓冲区记录后,我们需要收集这些命令缓冲区并将它们提交到队列,以便执行。
在实际应用中,我们可能希望避免以这种方式创建和销毁线程。相反,我们应该使用现有的作业/任务系统,并利用它来记录必要的命令缓冲区。但是,所展示的示例易于使用和理解。此外,它还擅长说明在使用多线程应用程序中的 Vulkan 时需要执行的操作步骤。
提交也必须只能从单个线程执行(队列,与其他资源类似,不能并发访问),因此我们需要等待所有线程完成它们的工作:
std::vector<VkCommandBuffer> command_buffers( threads_parameters.size() );
for( size_t i = 0; i < threads_parameters.size(); ++i ) {
threads[i].join();
command_buffers[i] = threads_parameters[i].CommandBuffer;
}
if( !SubmitCommandBuffersToQueue( queue, wait_semaphore_infos, command_buffers, signal_semaphores, fence ) ) {
return false;
}
return true;
只能从单个线程提交命令缓冲区到队列。
上述情况在以下图中展示:

与 swapchain 对象类似,我们只能在给定时刻从单个线程获取和展示 swapchain 图像。我们不能并发执行此操作。
swapchain 对象不能在多个线程上并发访问(修改)。获取图像和展示它应该在单个线程上完成。
但是,在单个线程上获取 swapchain 图像,然后并发记录多个渲染到该 swapchain 图像的命令缓冲区是有效的操作。我们只需确保第一个提交的命令缓冲区执行一个从VK_IMAGE_LAYOUT_PRESENT_SRC_KHR(或VK_IMAGE_LAYOUT_UNDEFINED)布局的转换。转换回VK_IMAGE_LAYOUT_PRESENT_SRC_KHR布局必须在提交到队列的最后一个命令缓冲区内部执行。这些命令缓冲区记录的顺序并不重要;只有提交顺序是关键的。
当然,当我们想要记录修改资源(例如,在缓冲区中存储值)的操作时,我们还必须记录适当的同步操作(例如,管道屏障)。这对于正确的执行是必要的,但从记录的角度来看并不重要。
参见
-
在第二章,图像展示中,查看以下配方:
-
获取 swapchain 图像
-
展示图像
-
-
在第三章,命令缓冲区和同步中,查看以下配方:
- 向队列提交命令缓冲区
准备动画的单帧
通常,当我们创建渲染图像的 3D 应用程序时,我们希望图像显示在屏幕上。为此,在 Vulkan 中创建了一个 swapchain 对象。我们知道如何从 swapchain 获取图像。我们也学习了如何展示它们。在这里,我们将看到如何连接图像获取和展示,如何在其中记录命令缓冲区,以及我们应该如何同步所有这些操作以渲染动画的单帧。
如何做到这一点...
-
获取逻辑设备的句柄并将其存储在一个名为
logical_device的VkDevice类型的变量中。 -
使用创建的 swapchain 的句柄初始化一个名为
swapchain的VkSwapchainKHR类型的变量。 -
在一个名为
image_acquired_semaphore的VkSemaphore类型的变量中准备一个信号量句柄。确保信号量未被触发或未被用于任何尚未完成的先前提交。 -
创建一个名为
image_index的uint32_t类型的变量。 -
使用
logical_device、swapchain和image_acquired_semaphore变量从swapchain获取一个图像,并将它的索引存储在image_index变量中(参考第二章中的获取 swapchain 图像配方,图像展示)。 -
准备一个将在记录绘图操作期间使用的渲染通道句柄。将其存储在一个名为
render_pass的VkRenderPass类型的变量中。 -
为所有 swapchain 图像准备图像视图。将它们存储在一个名为
swapchain_image_views的std::vector<VkImageView>类型的变量中。 -
将 swapchain 图像的大小存储在一个名为
swapchain_size的VkExtent2D类型的变量中。 -
创建一个名为
framebuffer的VkFramebuffer类型的变量。 -
使用
logical_device、swapchain_image_views[image_index]和swapchain_size变量为render_pass创建一个帧缓冲区(至少包含一个对应于 swapchain 图像在image_index位置的图像视图)。将创建的句柄存储在帧缓冲区变量中(参考第六章中的创建帧缓冲区配方,渲染通道和帧缓冲区)。 -
使用获取到的交换链图像在
image_index位置和framebuffer变量中记录一个命令缓冲区。将记录的命令缓冲区的句柄存储在名为command_buffer的VkCommandBuffer类型的变量中。 -
准备一个将处理
command_buffer中记录的命令的队列。将队列的句柄存储在名为graphics_queue的VkQueue类型的变量中。 -
获取一个未标记的信号量句柄并将其存储在名为
VkSemaphore类型的变量ready_to_present_semaphore中。 -
准备一个未标记的栅栏并将其手柄存储在名为
finished_drawing_fence的VkFence类型的变量中。 -
创建一个名为
wait_semaphore_info的WaitSemaphoreInfo类型的变量(参考第三章 将命令缓冲区提交到队列 的配方,命令缓冲区和同步)。使用以下值初始化此变量的成员:-
image_acquired_semaphore变量用于信号量 -
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT值用于WaitingStage
-
-
将
command_buffer提交到graphics_queue,指定一个包含wait_semaphore_info变量的wait_semaphore_infos参数的单元素向量,ready_to_present_semaphore变量作为要发出信号的信号量,以及finished_drawing_fence变量作为要发出信号的栅栏(参考第三章 将命令缓冲区提交到队列 的配方,命令缓冲区和同步)。 -
准备用于展示的队列的句柄。将其存储在名为
present_queue的VkQueue类型的变量中。 -
创建一个名为
present_info的PresentInfo类型的变量(参考第二章 展示图像 的配方,图像展示)。使用以下值初始化此变量的成员:-
swapchain变量用于Swapchain -
image_index变量用于ImageIndex
-
-
将获取到的交换链图像展示给
present_queue队列。提供一个包含ready_to_present_semaphore变量的rendering_semaphores参数的单元素向量,以及一个包含present_info变量的images_to_present参数的单元素向量(参考第二章 展示图像 的配方,图像展示)。
它是如何工作的...
准备一个动画的单帧可以分为五个步骤:
-
获取一个交换链图像。
-
创建一个帧缓冲区。
-
记录命令缓冲区。
-
将命令缓冲区提交到队列。
-
展示一个图像。
首先,我们必须获取一个可以渲染的交换链图像。渲染是在一个定义了附件参数的渲染通道内进行的。用于这些附件的特定资源在帧缓冲区中定义。
由于我们想要渲染到 swapchain 图像中(以在屏幕上显示图像),因此必须将此图像指定为帧缓冲区中定义的附件之一。看起来,在早期创建帧缓冲区并在渲染期间重用它是好主意。当然,这是一个有效的方法,但它有其缺点。最重要的缺点是,在应用程序的生命周期内可能很难维护它。我们只能渲染从 swapchain 获取的图像。但由于我们不知道哪个图像将被获取,我们需要为所有 swapchain 图像准备单独的帧缓冲区。更重要的是,每次创建 swapchain 对象时,我们都需要重新创建它们。如果我们的渲染算法需要更多的附件来渲染,我们将开始为 swapchain 图像和由我们创建的图像的所有组合创建多个帧缓冲区变体。这变得非常繁琐。
正因如此,在开始记录命令缓冲区之前创建帧缓冲区要容易得多。我们只使用渲染这一帧所需的资源来创建帧缓冲区。我们只需记住,我们只能在提交的命令缓冲区执行完成后销毁这样的帧缓冲区。
直到队列停止处理使用帧缓冲区的命令缓冲区之前,帧缓冲区不能被销毁。
当获取图像并创建帧缓冲区时,我们可以记录一个命令缓冲区。这些操作可以按如下方式进行:
uint32_t image_index;
if( !AcquireSwapchainImage( logical_device, swapchain, image_acquired_semaphore, VK_NULL_HANDLE, image_index ) ) {
return false;
}
std::vector<VkImageView> attachments = { swapchain_image_views[image_index] };
if( VK_NULL_HANDLE != depth_attachment ) {
attachments.push_back( depth_attachment );
}
if( !CreateFramebuffer( logical_device, render_pass, attachments, swapchain_size.width, swapchain_size.height, 1, *framebuffer ) ) {
return false;
}
if( !record_command_buffer( command_buffer, image_index, *framebuffer ) ) {
return false;
}
之后,我们就准备好将命令缓冲区提交到队列中。记录在命令缓冲区中的操作必须等待直到显示引擎允许我们使用获取到的图像。为此,我们在获取图像时指定一个信号量。这个信号量也必须在提交命令缓冲区时作为等待信号量之一提供:
std::vector<WaitSemaphoreInfo> wait_semaphore_infos = wait_infos;
wait_semaphore_infos.push_back( {
image_acquired_semaphore,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT
} );
if( !SubmitCommandBuffersToQueue( graphics_queue, wait_semaphore_infos, { command_buffer }, { ready_to_present_semaphore }, finished_drawing_fence ) ) {
return false;
}
PresentInfo present_info = {
swapchain,
image_index
};
if( !PresentImage( present_queue, { ready_to_present_semaphore }, { present_info } ) ) {
return false;
}
return true;
当队列停止处理命令缓冲区时,渲染的图像可以被呈现(显示在屏幕上),但我们不希望等待并检查何时发生这种情况。这就是为什么我们使用一个额外的信号量(前述代码中的ready_to_present_semaphore变量),当命令缓冲区的执行完成时,该信号量将被触发。然后,当呈现 swapchain 图像时,提供相同的信号量。这样,我们可以在 GPU 上内部同步操作,这比在 CPU 上同步要快得多。如果我们没有使用信号量,我们就需要等待直到栅栏被触发,然后才能呈现图像。这将使我们的应用程序停滞,并大大降低性能。
你可能会 wonder 为什么我们需要栅栏(finished_drawing_fence 在前面的代码中),因为当命令缓冲区处理完成时它也会被信号。信号量不是足够吗?不,存在一些情况下应用程序也需要知道给定命令缓冲区的执行何时结束。这种情况之一就是在销毁创建的帧缓冲区时。我们无法在先前的栅栏被信号之前销毁它。只有应用程序可以销毁它创建的资源,因此它必须知道何时可以安全地销毁它们(当它们不再被使用时)。另一个例子是命令缓冲区的重新记录。我们无法在队列上完成其执行之前再次记录它。所以我们需要知道何时发生这种情况。而且,由于应用程序无法检查信号量的状态,所以必须使用栅栏。
使用信号量和栅栏可以让我们立即提交命令缓冲区和呈现图像,而不需要不必要的等待。并且我们可以独立地对多个帧执行这些操作,从而进一步提高性能。
参见
-
在 第二章,图像呈现,查看以下菜谱:
-
获取交换链图像的句柄
-
获取交换链图像
-
呈现一个图像
-
-
在 第三章,命令缓冲区和同步,查看以下菜谱:
-
创建一个信号量
-
创建一个栅栏
-
将命令缓冲区提交到队列
-
检查提交的命令缓冲区的处理是否完成
-
-
在 第六章,渲染通道和帧缓冲区,查看以下菜谱:
-
创建一个渲染通道
-
创建一个帧缓冲区
-
通过增加独立渲染帧的数量来提高性能
渲染单个动画帧并将其提交到队列是 3D 图形应用程序(如游戏和基准测试)的目标。但一个帧是不够的。我们希望渲染和显示多个帧,否则我们无法达到动画的效果。
不幸的是,我们无法在提交它后立即重新记录相同的命令缓冲区;我们必须等待直到队列停止处理它。但是,等待直到命令缓冲区处理完成是浪费时间,并且会损害我们应用程序的性能。这就是为什么我们应该独立渲染多个动画帧的原因。
准备工作
为了本菜谱的目的,我们将使用自定义 FrameResources 类型的变量。它具有以下定义:
struct FrameResources {
VkCommandBuffer CommandBuffer;
VkDestroyer<VkSemaphore> ImageAcquiredSemaphore;
VkDestroyer<VkSemaphore> ReadyToPresentSemaphore;
VkDestroyer<VkFence> DrawingFinishedFence;
VkDestroyer<VkImageView> DepthAttachment;
VkDestroyer<VkFramebuffer> Framebuffer;
};
前面的类型用于定义管理单个动画帧生命周期的资源。
CommandBuffer 成员存储用于记录单个、独立动画帧操作的命令缓冲区的句柄。在实际应用中,单个帧可能由多个线程中记录的多个命令缓冲区组成。但在基本代码示例中,一个命令缓冲区就足够了。
ImageAcquiredSemaphore 成员用于存储在从交换链获取图像时传递给呈现引擎的信号量句柄。然后,这个信号量必须作为提交命令缓冲区到队列时的一个等待信号量提供。
ReadyToPresentSemaphore 成员指示一个当队列停止处理我们的命令缓冲区时被信号量的信号量。我们应在图像呈现时使用它,以便呈现引擎知道图像何时准备好。
DrawingFinishedFence 成员包含一个围栏句柄。我们在提交命令缓冲区时提供它。类似于 ReadyToPresentSemaphore 成员,当命令缓冲区不再在队列上执行时,这个围栏会被信号。但围栏是必要的,用于在 CPU 端(我们应用程序执行的操作)而不是 GPU(以及呈现引擎)上同步操作。当这个围栏被信号时,我们知道我们可以重新记录命令缓冲区并销毁帧缓冲区。
DepthAttachment 成员用于存储作为子通道内部深度附加的图像视图。
Framebuffer 成员用于存储为单个动画帧的生命周期创建的临时帧缓冲区句柄。
大多数前面的成员都被包装成 VkDestroyer 类型的对象。这个类型负责在对象不再必要时隐式销毁所拥有的对象。
如何做到这一点...
-
获取逻辑设备的句柄并将其存储在名为
logical_device的VkDevice类型变量中。 -
创建一个名为
frame_resources的std::vector<FrameResources>类型的变量。将其大小调整为可以容纳所需数量的独立渲染帧的资源(推荐大小为三个),并使用以下值初始化每个元素(每个元素中存储的值必须是唯一的):
-
为
commandbuffer创建的命令缓冲区的句柄 -
为
ImageAcquiredSemaphore和ReadyToPresentSemaphore创建的两个句柄 -
为
DrawingFinishedFence创建的处于已信号状态的围栏的句柄 -
作为
DepthAttachment深度附加的图像视图的句柄 -
Framebuffer的VK_NULL_HANDLE值
-
创建一个名为
frame_index的uint32_t类型的(可能静态的)变量。用0值初始化它。 -
创建一个名为
current_frame的FrameResources类型的变量,该变量引用由frame_index变量指向的frame_resources向量中的一个元素。 -
等待直到
current_frame.DrawingFinishedFence被触发。提供logical_device变量和一个等于2000000000的超时值(参考第三章中的等待围栏配方,命令缓冲区和同步)。 -
重置
current_frame.DrawingFinishedFence围栏的状态(参考第三章中的重置围栏配方,命令缓冲区和同步)。 -
如果
current_frame.Framebuffer成员包含创建的framebuffer的句柄,销毁它并将VK_NULL_HANDLE值分配给该成员(参考第六章中的销毁帧缓冲区配方,渲染通道和帧缓冲区)。 -
使用
current_frame变量的所有成员准备单个动画帧(参考准备单个动画帧配方):-
在此操作期间获取 swapchain 图像,提供
current_frame.ImageAcquiredSemaphore变量。 -
创建一个帧缓冲区并将它的句柄存储在
current_frame.Framebuffer成员中。 -
记录存储在
current_frame.CommandBuffer成员中的命令缓冲区。 -
将
current_frame.CommandBuffer成员提交给一个选定的队列,提供current_frame.ImageAcquiredSemaphore信号量作为等待的信号量之一,将current_frame.ReadyToPresentSemaphore信号量作为要触发的信号量,将current_frame.DrawingFinishedFence围栏作为在命令缓冲区执行完成后要触发的围栏。
-
-
将 swapchain 图像展示给一个选定的队列,提供包含
current_frame.ReadyToPresentSemaphore变量的一个元素向量作为rendering_semaphores参数。 -
增加存储在
frame_index变量中的值。如果它等于frame_resources向量的元素数量,将变量重置为0。
它是如何工作的...
渲染动画在一个循环中执行。渲染一帧并展示一个图像,然后通常处理操作系统消息。接下来,渲染并展示另一帧,依此类推。
当我们只有一个命令缓冲区以及准备、渲染和显示帧所需的其他资源时,我们无法立即重用它们。在之前的提交中使用过的信号量不能用于另一个提交,直到之前的提交完成。这种情况要求我们等待命令缓冲区处理的结束。但这样的等待是非常不希望的。我们在 CPU 上等待的时间越长,我们引入的图形硬件停滞就越多,我们达到的性能就越差。
为了缩短我们在应用程序中等待的时间(直到为前一帧记录的命令缓冲区执行),我们需要准备几组渲染和呈现一帧所需的资源。当我们为某一帧记录和提交命令缓冲区,并希望准备另一帧时,我们只需获取另一组资源。对于下一帧,我们使用另一组资源,直到用完所有资源。然后我们只需取最不常用的那一组——当然,我们需要检查是否可以重用它,但在这个时候,它已经被硬件处理过的可能性很高。使用多组帧资源渲染动画的过程在以下图中展示:

我们应该准备多少组资源呢?我们可能会认为资源越多越好,因为我们根本不需要等待。但不幸的是,情况并不那么简单。首先,我们增加了应用程序的内存占用。但更重要的是,我们增加了输入延迟。通常,我们根据用户的输入渲染动画,用户可能想要旋转虚拟相机、查看模型或移动角色。我们希望应用程序能够尽可能快地响应用户的输入。当我们增加独立渲染的帧数时,我们也增加了用户输入和渲染图像上的效果之间的时间。
我们需要平衡单独渲染的帧数、应用程序的性能、内存使用和输入延迟。
那么,我们应该有多少帧资源呢?这当然取决于渲染场景的复杂性、应用程序执行硬件的性能以及它实现的渲染场景类型(即我们正在创建的游戏类型——是快速的第一人称视角(FPP)射击游戏、赛车游戏,还是节奏较慢的基于角色扮演的游戏(RPG))。因此,没有一个确切的值可以适用于所有可能的场景。测试表明,将帧资源的数量从一组增加到两组可能会将性能提高 50%。增加第三组可以进一步提高性能,但这次增长并不像之前那么大。因此,每增加一组帧资源,性能提升的幅度就较小。三组渲染资源看起来是一个不错的选择,但我们应该进行自己的测试,看看什么最适合我们的特定需求。
我们可以看到使用一组、两组和三组独立资源记录和提交命令缓冲区的三个示例,如下所示:

现在我们知道了为什么我们应该使用多个独立的帧资源,我们可以看看如何使用它们来渲染一帧。
首先,我们开始检查是否可以使用给定的一组资源来准备一个帧。我们通过检查栅栏的状态来完成此操作。如果它已信号,我们就准备好了。你可能想知道,当我们渲染第一个帧时我们应该做什么——我们还没有向队列提交任何内容,所以栅栏没有机会被信号。这是真的,这就是为什么,为了准备帧资源,我们应该在已信号的状态下创建栅栏:
static uint32_t frame_index = 0;
FrameResources & current_frame = frame_resources[frame_index];
if( !WaitForFences( logical_device, { *current_frame.DrawingFinishedFence }, false, 2000000000 ) ) {
return false;
}
if( !ResetFences( logical_device, { *current_frame.DrawingFinishedFence } ) ) {
return false;
}
我们还应该检查用于该帧的帧缓冲区是否已创建。如果是,我们应该销毁它,因为它将在稍后创建。对于已获取的 swapchain 图像,InitVkDestroyer()函数使用一个新的空对象句柄初始化提供的变量,并在必要时销毁之前拥有的对象。之后,我们渲染帧并呈现图像。为此,我们需要一个命令缓冲区和两个信号量(参考准备单个动画帧食谱):
InitVkDestroyer( logical_device, current_frame.Framebuffer );
if( !PrepareSingleFrameOfAnimation( logical_device, graphics_queue, present_queue, swapchain, swapchain_size, swapchain_image_views,
*current_frame.DepthAttachment, wait_infos, *current_frame.ImageAcquiredSemaphore, *current_frame.ReadyToPresentSemaphore,
*current_frame.DrawingFinishedFence, record_command_buffer, current_frame.CommandBuffer, render_pass, current_frame.Framebuffer ) ) {
return false;
}
frame_index = (frame_index + 1) % frame_resources.size();
return true;
最后一件事情是增加当前使用的帧资源集的索引。对于下一个动画帧,我们将使用另一组,直到我们使用完所有这些,然后我们从开始:
frame_index = (frame_index + 1) % frame_resources.size();
return true;
参见也
-
在第三章,命令缓冲区和同步,查看以下食谱:
-
等待栅栏
-
重置栅栏
-
-
在第六章,渲染通道和帧缓冲区,查看以下食谱:
- 销毁帧缓冲区
-
本章中的准备单个动画帧食谱
第十章:辅助菜谱
在本章中,我们将介绍以下内容:
-
准备平移矩阵
-
准备旋转矩阵
-
准备缩放矩阵
-
准备透视投影矩阵
-
准备正交投影矩阵
-
从文件加载纹理数据
-
从 OBJ 文件加载 3D 模型
简介
在前面的章节中,我们学习了 Vulkan API 的各个方面。我们现在知道如何使用图形库以及如何创建渲染 3D 图像和执行数学计算的应用程序。但是,仅了解 Vulkan API 可能不足以生成更复杂的场景和实现各种渲染算法。有几个非常有用的操作可以帮助我们创建、操作和显示 3D 对象。
在本章中,我们将学习如何准备用于移动、旋转和缩放 3D 网格的变换矩阵。我们还将了解如何生成投影矩阵。最后,我们将使用简单但非常强大的单头库来加载存储在文件中的图像和 3D 模型。
准备平移矩阵
在 3D 模型上可以执行的基本操作包括将对象移动到选定的方向上,移动的距离为选定的单位数。
如何做...
-
准备三个名为
x、y和z的float类型变量,并将它们初始化为沿x(右/左)、y(上/下)和z(近/远)方向应用于对象的平移(移动距离)量。 -
创建一个名为
translation_matrix的std::array<float, 16>类型的变量,它将保存表示所需操作的矩阵。用以下值初始化translation_matrix数组的元素:-
所有元素都初始化为
0.0f值 -
第 0 个、第 5 个、第 10 个和第 15 个元素(主对角线)具有
1.0f值 -
存储在
x变量中的第 12 个元素 -
存储在
y变量中的第 13 个元素 -
存储在
z变量中的第 14 个元素
-
-
将
translation_matrix变量的所有元素传递给着色器(可能通过统一缓冲区或推送常量),或者将其与另一个矩阵相乘以在一个矩阵中累积多个操作。
它是如何工作的...
平移是可应用于对象的三种基本变换之一(其余的是旋转和缩放)。它允许我们将 3D 模型移动到选定的方向上,移动的距离为选定的距离:

平移也可以应用于相机,从而改变我们观察整个渲染场景的视角。
创建平移矩阵是一个简单的过程。我们需要一个单位 4x4 矩阵--除了主对角线上的元素外,所有元素都必须初始化为零(0.0f),主对角线上的元素必须初始化为一(1.0f)。现在我们将第四列的前三个元素初始化为我们想要在x、y和z轴上应用的距离,如下所示:

以下代码创建了一个平移矩阵:
std::array<float, 16> translation_matrix = {
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
x, y, z, 1.0f
};
return translation_matrix;
在前面的代码中,我们假设矩阵具有 column_major 顺序(前四个元素组成矩阵的第一列,接下来的四个元素组成第二列,依此类推),因此它与前面的图相比是转置的。但提供给着色器的矩阵元素的顺序取决于在着色器源代码中指定的 row_major 或 column_major 布局限定符。
请记住在着色器中定义的矩阵元素的顺序。它通过 row_major 或 column_major 布局限定符来指定。
参见
-
在第五章,描述符集,参见以下配方:
- 创建统一缓冲区
-
在第七章,着色器,参见以下配方:
-
编写乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
-
在第九章,命令记录和绘图,参见以下配方:
- 通过推送常量向着色器提供数据
-
本章中的以下配方:
-
准备缩放矩阵
-
准备旋转矩阵
-
准备旋转矩阵
当我们创建一个 3D 场景并操作其对象时,我们通常需要旋转它们,以便正确地将它们放置和定位在其他对象之间。旋转对象是通过旋转矩阵实现的。为此,我们需要指定一个旋转将围绕进行的向量以及一个角度——我们想要应用多少旋转。
如何做到这一点...
-
准备三个名为
x、y和z的float类型的变量。用定义任意向量的值初始化它们,该向量是旋转应围绕进行的。确保该向量是归一化的(长度等于1.0f)。 -
准备一个名为
angle的float类型的变量,并将旋转角度(以弧度为单位)存储在其中。 -
创建一个名为
c的float类型的变量。将角度的余弦值存储在其中。 -
创建一个名为
s的float类型的变量。将角度的正弦值存储在其中。 -
创建一个名为
rotation_matrix的std::array<float, 16>类型的变量,它将保存表示所需操作的矩阵。用以下值初始化rotation_matrix数组的元素:-
第 0 个元素使用公式
x * x * (1.0f - c) + c -
第 1 个元素使用公式
y * x * (1.0f - c) - z * s -
第 2 个元素使用公式
z * x * (1.0f - c) + y * s -
第 4 个元素使用公式
x * y * (1.0f - c) + z * s -
第 5 个元素使用公式
y * y * (1.0f - c) + c -
第 6 个元素使用公式
z * y * (1.0f - c) - x * s -
第 8 个元素使用公式
x * z * (1.0f - c) - y * s -
第 9 个元素使用公式
y * z * (1.0f - c) + x * s -
第 10 个元素使用公式
z * z * (1.0f - c) + c -
其余元素使用
0.0f值初始化 -
除了第 15 个元素应包含
1.0f值外
-
-
向着色器提供
rotation_matrix变量所有元素的价值(可能通过统一缓冲区或推送常量)或者乘以另一个矩阵以在一个矩阵中累积多个操作。
它是如何工作的...
准备表示一般旋转变换的矩阵相当复杂。它可以分为三个独立的矩阵——分别表示围绕每个x、y和z轴的旋转——稍后相乘以生成相同的结果。每个这样的旋转都更容易准备,但总的来说,它需要执行更多的操作,因此可能性能较差。
正因如此,最好准备一个表示围绕所选(任意)向量旋转的矩阵。为此,我们需要指定一个角度,它定义了要应用的旋转量,以及一个向量。这个向量应该是归一化的,否则应用旋转的量将按比例缩放向量的长度。
执行旋转的向量应该被归一化。
以下图显示了旋转矩阵。执行旋转变换所需的数据放置在上左边的 3x3 矩阵中。该矩阵的每一列分别定义了旋转后x、y和z轴的方向。更重要的是,转置的旋转矩阵定义了完全相反的变换:

例如,如果我们想旋转摄像机以模拟我们控制的角色左右环顾,或者如果我们想显示一辆正在左转或右转的汽车,我们应该指定一个向上指的向量(0.0f, 1.0f, 0.0f)。我们也可以指定一个向下指的向量(0.0f, -1.0f, 0.0f)。在这种情况下,对象将以相同的角度旋转,但方向相反。我们需要选择对我们来说更方便的选项:

以下是为创建旋转矩阵的代码。它首先检查我们是否想要归一化向量,并相应地修改其分量。接下来,准备辅助变量以存储临时结果。最后,初始化旋转矩阵的所有元素:
if( normalize ) {
std::array<float, 3> normalized = Normalize( x, y, z );
x = normalized[0];
y = normalized[1];
z = normalized[2];
}
const float c = cos( Deg2Rad( angle ) );
const float _1_c = 1.0f - c;
const float s = sin( Deg2Rad( angle ) );
std::array<float, 16> rotation_matrix = {
x * x * _1_c + c,
y * x * _1_c - z * s,
z * x * _1_c + y * s,
0.0f,
x * y * _1_c + z * s,
y * y * _1_c + c,
z * y * _1_c - x * s,
0.0f,
x * z * _1_c - y * s,
y * z * _1_c + x * s,
z * z * _1_c + c,
0.0f,
0.0f,
0.0f,
0.0f,
1.0f
};
return rotation_matrix;
我们需要记住数组(应用程序)中元素和着色器源代码中定义的矩阵中元素的顺序。在着色器内部,我们通过row_major或column_major布局限定符来控制它。
参见
-
在第五章的描述符集中,查看以下配方:
- 创建统一缓冲区
-
在第七章的着色器中,查看以下配方:
-
编写乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
-
在第九章的命令记录和绘制中,查看以下配方:
- 通过推送常量向着色器提供数据
-
本章中的以下配方:
-
准备平移矩阵
-
准备缩放矩阵
-
准备缩放矩阵
在 3D 模型上可以执行的第三个变换是缩放。这允许我们改变对象的大小。
如何做到...
-
准备三个名为
x、y和z的类型为float的变量,它们将分别存储应用于模型在 x(宽度)、y(高度)和 z(深度)维度上的缩放因子。 -
创建一个名为
scaling_matrix的类型为std::array<float, 16>的变量,其中将存储表示所需操作的矩阵。使用以下值初始化scaling_matrix数组的元素:-
所有元素都初始化为
0.0f值 -
第 0 个元素存储在
x变量中的值 -
第 5 个元素存储在
y变量中的值 -
第 10 个元素存储在
z变量中的值 -
第 15 个元素具有
1.0f值
-
-
将
scaling_matrix变量的所有元素值提供给着色器(可能通过统一缓冲区或推送常量)或将其与另一个矩阵相乘以在一个矩阵中累积多个操作。
它是如何工作的...
有时我们需要改变对象的大小(与其他场景中的对象相比)。例如,由于魔法咒语的效果,我们的角色缩小以适应一个非常小的洞。这种变换是通过以下这样的缩放矩阵实现的:

使用缩放矩阵,我们可以在每个维度上以不同的方式调整模型的大小:

如果我们不均匀地缩放对象,我们必须谨慎行事。通常,为了简化代码并提高性能,我们向着色器提供一个组合变换矩阵,并使用相同的矩阵来变换不仅顶点,还有法向量。当我们均匀缩放对象时,我们只需在变换后对着色器中的法向量进行归一化。但是,当我们使用在每个维度上以不同方式缩放对象的变换时,我们不能将其应用于法向量,因为光照计算将不正确(由法向量表示的方向将改变)。如果我们确实需要执行此类缩放,我们需要使用法向量变换的逆转置矩阵。我们必须单独准备它并将其提供给着色器。
当对象在每个维度上以不同的方式缩放时,必须使用逆变换矩阵变换法向量。
准备缩放矩阵可以通过以下代码执行:
std::array<float, 16> scaling_matrix = {
x, 0.0f, 0.0f, 0.0f,
0.0f, y, 0.0f, 0.0f,
0.0f, 0.0f, z, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f
};
return scaling_matrix;
与所有其他矩阵一样,我们需要记住在我们应用程序(CPU)中定义的元素顺序以及着色器源代码中矩阵元素的顺序(column_major与row_major顺序)。
参见
-
在第五章,描述符集中,查看以下食谱:
- 创建统一缓冲区
-
在第七章,着色器中,查看以下食谱:
-
编写乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
-
在第九章的命令录制和绘图部分,查看以下配方:
- 通过推送常量向着色器提供数据
-
本章中的以下配方:
-
准备平移矩阵
-
准备旋转矩阵
-
准备透视投影矩阵
3D 应用程序通常试图模拟我们感知周围世界的效果——远处的物体看起来比靠近我们的物体小。为了实现这种效果,我们需要使用透视投影矩阵。
如何做到这一点...
-
准备一个名为
aspect_ratio的float类型的变量,它将保存可渲染区域的纵横比(图像宽度除以高度)。 -
创建一个名为
field_of_view的float类型的变量。用相机垂直视场角(以弧度为单位)初始化它。 -
创建一个名为
near_plane的float类型的变量,并用相机位置到近裁剪平面的距离初始化它。 -
创建一个名为
far_plane的float类型的变量。将相机与远裁剪平面之间的距离存储在该变量中。 -
计算一个值,即
1.0f除以field_of_view半部的正切(1.0f / tan(Deg2Rad(0.5f * field_of_view))),并将结果存储在名为f的float类型的变量中。 -
创建一个名为
perspective_projection_matrix的std::array<float, 16>类型的变量,它将保存表示所需投影的矩阵。用以下值初始化perspective_projection_matrix数组的元素:-
第 0 个元素具有
f / aspect_ratio -
第 5 个元素具有
-f -
第 10 个元素具有
far_plane / (near_plane - far_plane) -
第 11 个元素具有
-1.0f值 -
第 14 个元素具有
(near_plane * far_plane) / (near_plane - far_plane) -
其余元素使用
0.0f值初始化
-
-
向着色器提供
perspective_projection_matrix变量的所有元素(可能通过统一缓冲区或推送常量)或将其乘以另一个矩阵以在一个矩阵中累积多个操作。
它是如何工作的...
图形管线在所谓的裁剪空间中操作顶点位置。通常,我们在局部(模型)坐标系中指定顶点,并直接将它们提供给顶点着色器。这就是为什么我们需要在某个顶点处理阶段(顶点、细分控制、细分评估或几何着色器)中将提供的顶点位置从其局部空间转换为裁剪空间。这种转换是通过投影矩阵完成的。如果我们想模拟透视除法的效果,我们需要使用透视投影矩阵并将其乘以顶点位置:

要创建一个透视投影矩阵,我们需要知道可渲染区域的尺寸,以计算其纵横比(宽度除以高度)。我们还需要指定一个(垂直)视野,我们可以将其视为虚拟相机的缩放:

创建透视投影矩阵所需的一个最后要素是近裁剪面和远裁剪面的两个距离。由于它们影响深度计算,它们应尽可能接近场景中的对象。如果我们为近裁剪面指定一个大的值,而远裁剪面指定一个小的值,那么我们的场景(通常)将被裁剪——我们将看到对象如何从场景中弹出和消失。另一方面,如果近距离太小,而远裁剪面的距离太大,我们将失去深度缓冲区的精度,深度计算可能是不正确的。
近裁剪面和远裁剪面应与显示的场景相对应。
使用前面描述的数据,我们可以使用以下代码创建一个透视投影矩阵:
float f = 1.0f / tan( Deg2Rad( 0.5f * field_of_view ) );
Matrix4x4 perspective_projection_matrix = {
f / aspect_ratio,
0.0f,
0.0f,
0.0f,
0.0f,
-f,
0.0f,
0.0f,
0.0f,
0.0f,
far_plane / (near_plane - far_plane),
-1.0f,
0.0f,
0.0f,
(near_plane * far_plane) / (near_plane - far_plane),
0.0f
};
return perspective_projection_matrix;
参见
-
在第五章,描述符集中,查看以下配方:
- 创建统一缓冲区
-
在第七章,着色器中,查看以下配方:
-
编写乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
-
在第九章,命令记录和绘制中,查看以下配方:
- 通过推送常量向着色器提供数据
-
本章中的以下配方:
- 准备正交投影矩阵
准备正交投影矩阵
正交投影是另一种将顶点从其局部坐标系转换到裁剪空间的操作。但与透视投影相反,它不考虑透视除法(不模拟我们感知周围环境的方式)。但与透视投影类似,它也由一个 4x4 矩阵表示,我们需要创建这个矩阵才能使用这种类型的投影。
如何做到这一点...
-
创建两个类型为
float的变量,分别命名为left_plane和right_plane,并将它们初始化为左裁剪面和右裁剪面在x轴上的位置。 -
准备两个类型为
float的变量,分别命名为bottom_plane和top_plane。将它们初始化为底部和顶部裁剪面在y轴上的位置。 -
创建两个类型为
float的变量,分别命名为near_plane和far_plane。使用它们来存储从相机到近裁剪面和远裁剪面的距离。 -
创建一个类型为
std::array<float, 16>的变量,命名为orthographic_projection_matrix。它将存储表示所需投影的矩阵。使用以下值初始化orthographic_projection_matrix数组的元素:-
矩阵的所有元素都初始化为
0.0f值 -
第 0 个元素使用
2.0f / (right_plane - left_plane) -
第 5 个元素使用
2.0f / (bottom_plane - top_plane) -
第 10 个元素使用
1.0f / (near_plane - far_plane) -
第 12 个元素使用
-(right_plane + left_plane) / (right_plane - left_plane) -
第 13 个元素使用
-(bottom_plane + top_plane) / (bottom_plane - top_plane) -
第 14 个元素使用
near_plane / (near_plane - far_plane) -
第 15 个元素使用
1.0f值
-
-
向着色器提供
orthographic_projection_matrix变量的所有元素的值(可能通过统一缓冲区或推送常量),或者将其乘以另一个矩阵以在一个矩阵中累积多个操作。
它是如何工作的...
当我们使用正交投影时,场景中的所有对象无论离相机有多远都保持其大小和屏幕位置。这就是为什么正交投影对于绘制各种UI(用户界面)非常有用。我们可以定义我们的虚拟屏幕,我们知道它的所有边(为投影定义的平面),并且我们可以轻松地在屏幕上放置和操作界面元素。如果需要,我们还可以使用深度测试。
正交投影在CAD程序(计算机辅助设计)中也广泛使用。这些工具用于设计建筑、船舶、电子电路或机械装置。在这种情况下,场景中所有对象的尺寸都必须与设计师定义的尺寸完全一致,所有方向都必须保持其关系(即所有平行线必须始终保持平行),无论对象离相机有多远,以及从哪个角度观察。
以下代码用于创建表示正交投影的矩阵:
Matrix4x4 orthographic_projection_matrix = {
2.0f / (right_plane - left_plane),
0.0f,
0.0f,
0.0f,
0.0f,
2.0f / (bottom_plane - top_plane),
0.0f,
0.0f,
0.0f,
0.0f,
1.0f / (near_plane - far_plane),
0.0f,
-(right_plane + left_plane) / (right_plane - left_plane),
-(bottom_plane + top_plane) / (bottom_plane - top_plane),
near_plane / (near_plane - far_plane),
1.0f
};
return orthographic_projection_matrix;
参见
-
在第五章,描述符集部分,查看以下配方:
- 创建一个统一缓冲区
-
在第七章,着色器部分,查看以下配方:
-
编写一个将顶点位置乘以投影矩阵的顶点着色器
-
在着色器中使用推送常量
-
-
在第九章,命令录制和绘制部分,查看以下配方:
- 通过推送常量向着色器提供数据
-
本章中的准备透视投影矩阵配方
从文件中加载纹理数据
纹理化是一种常用的技术。它允许我们以类似我们在墙上贴壁纸的方式将图像放置在物体的表面上。这样我们就不需要增加网格的几何复杂性,这会使硬件处理变得过于复杂,并且会消耗过多的内存。纹理化更容易处理,并允许我们实现更好、更令人信服的结果。
纹理可以通过程序(在代码中动态生成),但通常它们的内 容是从图像或照片中读取的。
准备工作
有许多不同的库允许我们加载图像的内容。它们各自都有特定的行为、用法和许可证。在这个菜谱中,我们将使用由Sean T. Barrett创建的stb_image库。它非常易于使用,同时支持足够多的图像格式,可以开始开发 Vulkan 应用程序。它的主要优势之一是它是一个单头文件库,所有代码都放在一个头文件中。它不依赖于任何其他库、文件或资源。另一个优点是我们可以根据需要使用它。
stb_image.h文件可在github.com/nothings/stb找到。
要在我们的应用程序中使用stb_image库,我们需要从github.com/nothings/stb下载stb_image.h文件并将其包含在我们的项目中。此文件可以在我们的代码中的许多地方包含,但为了在单个源文件中创建库的实现,我们需要包含该文件并在其前面加上一个#define STB_IMAGE_IMPLEMENTATION定义,如下所示:
#include ...
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
如何做到这一点...
-
将从其中加载纹理图像的文件名存储在一个名为
filename的char const *类型的变量中。 -
创建一个名为
num_requested_components的int类型的变量,并使用从文件中加载所需组件数量的值(1到4之间的值)或使用0值来加载所有可用组件初始化它。 -
创建三个名为
width、height和num_components的int类型的变量,并将它们全部初始化为0值。 -
创建一个名为
stbi_data的unsigned char *类型的变量。 -
调用
stbi_load( filename, &width, &height, &num_components, num_requested_components )并提供filename变量、指向width、height和num_components变量的指针以及num_requested_components变量。将函数调用的结果存储在stbi_data变量中。 -
确保调用成功加载了指定文件的内容,通过检查存储在
stbi_data变量中的值是否不等于nullptr值,以及存储在width、height和num_components变量中的值是否大于0。 -
创建一个名为
data_size的int类型的变量,并使用以下公式计算其值初始化:
*width * height * (0 < num_requested_components ? num_requested_components : num_components)*
-
创建一个名为
image_data的std::vector<unsigned char>类型的变量。将其大小调整为容纳data_size个元素。 -
使用以下调用将
stbi_data中的data_size个字节复制到从image_data向量的第一个元素开始的内存中:
std::memcpy( image_data.data(), stbi_data.get(), data_size )
- 调用
stbi_image_free( stbi_data )。
它是如何工作的...
使用stb_image库归结为调用stbi_load()函数。它接受文件名,从文件中加载的选定组件数量,并返回包含加载数据的内存指针。该库总是将图像内容转换为每通道 8 位。图像的宽度和高度以及图像中实际可用的组件数量存储在可选变量中。
加载图像的代码如下所示:
int width = 0;
int height = 0;
int num_components = 0;
std::unique_ptr<unsigned char, void(*)(void*)> stbi_data( stbi_load( filename, &width, &height, &num_components, num_requested_components ), stbi_image_free );
if( (!stbi_data) ||
(0 >= width) ||
(0 >= height) ||
(0 >= num_components) ) {
std::cout << "Could not read image!" << std::endl;
return false;
}
stbi_load()函数返回的指针必须通过调用stbi_image_free()函数并使用前一个函数返回的值作为其唯一参数来释放。这就是为什么将加载的数据复制到我们自己的变量(即向量)或直接到 Vulkan 资源(图像)中是好的,这样就不会有内存泄漏。这如下所示:
std::vector<unsigned char> image_data;
int data_size = width * height * (0 < num_requested_components ? num_requested_components : num_components);
image_data.resize( data_size );
std::memcpy( image_data.data(), stbi_data.get(), data_size );
return true;
在前面的代码中,stbi_load()函数返回的内存指针会自动释放,因为我们将其存储在std::unique_ptr类型的智能指针中。在示例中,我们将图像内容复制到一个向量中。这个向量可以在我们的应用程序中作为纹理数据的来源使用。
参见
-
在第四章,资源和内存中,查看以下配方:
-
创建图像
-
将内存对象分配和绑定到图像
-
创建图像视图
-
-
在第五章,描述符集中,查看以下配方:
-
创建采样图像
-
创建组合图像采样器
-
-
在第七章,着色器中,查看以下配方:
- 编写纹理顶点和片段着色器
从 OBJ 文件加载 3D 模型
渲染 3D 场景需要我们绘制物体,这些物体也被称为模型或网格。网格是一组顶点(点)的集合,这些顶点包含了如何形成表面或面的信息(通常是三角形)。
物体在建模软件或 CAD 程序中准备。它们可以存储在许多不同的格式中,之后在 3D 应用程序中加载,提供给图形硬件,然后进行渲染。一种较简单的文件类型,用于存储网格数据的是Wavefront OBJ。我们将学习如何加载存储在此格式的模型。
准备工作
有多个库允许我们加载 OBJ 文件(或其他文件类型)。其中之一是一个简单但非常快速且仍在改进的库,由Syoyo Fujita开发的tinyobjloader。这是一个单头库,因此我们不需要包含任何其他文件或引用任何其他库。
tinyobjloader 库可以从github.com/syoyo/tinyobjloader下载。
要使用库,我们需要从 github.com/syoyo/tinyobjloader 下载一个 tiny_obj_loader.h 文件。我们可以在代码的许多地方包含它,但为了生成其实现,我们需要在源文件中包含它,并在包含之前添加一个类似于 #define TINYOBJLOADER_IMPLEMENTATION 的定义:
#include ...
#define TINYOBJLOADER_IMPLEMENTATION
#include "tiny_obj_loader.h"
为了本菜谱的目的,我们还将使用一个自定义的 Mesh 类型,该类型将以易于与 Vulkan API 一起使用的形式存储加载的数据。此类型具有以下定义:
struct Mesh {
std::vector<float> Data;
struct Part {
uint32_t VertexOffset;
uint32_t VertexCount;
};
std::vector<Part> Parts;
};
Data 成员存储顶点属性——位置、法线和纹理坐标(法线向量和纹理坐标是可选的)。接下来是一个名为 Parts 的向量成员,它定义了模型的单独部分。每个这样的部分都需要通过单独的 API 调用(如 vkCmdDraw() 函数)来绘制。模型部分由两个参数定义。VertexOffset 定义了给定部分开始的位置(在顶点数据数组中的偏移量)。VertexCount 定义了给定部分由多少个顶点组成。
如何做到这一点...
-
准备一个类型为
char const *的变量名为filename并将文件名存储在该变量中,该文件将从中加载模型数据。 -
创建以下变量:
-
类型为
tinyobj::attrib_t的变量名为attribs -
类型为
std::vector<tinyobj::shape_t>的变量名为shapes -
类型为
std::vector<tinyobj::material_t>的变量名为materials -
类型为
std::string的变量名为error
-
-
调用
tinyobj::LoadObj( &attribs, &shapes, &materials, &error, filename ),为attribs、shapes、materials和error变量提供指针,并将filename变量作为最后一个参数。 -
确保函数调用成功从文件中加载了模型数据,通过检查函数调用是否返回了
true值。 -
创建一个类型为
Mesh的变量名为mesh,该变量将以适合 Vulkan API 的形式存储模型数据。 -
创建一个类型为
uint32_t的变量名为offset并将其初始化为0值。 -
遍历
shapes向量的所有元素。假设当前元素存储在一个类型为tinyobj::shape_t的变量shape中,对每个元素执行以下操作:-
创建一个类型为
uint32_t的变量名为part_offset。将其初始化为存储在offset变量中的值。 -
遍历
shape.mesh.indices向量的所有元素,将当前处理的元素存储在一个类型为tinyobj::index_t的变量index中,并对每个元素执行以下操作:-
将
attribs.vertices向量中的三个元素(索引分别为3 * index.vertex_index、3 * index.vertex_index + 1和3 * index.vertex_index + 2)复制为mesh.Data向量的新元素 -
如果应该加载法向量,则将
attribs.normals向量的三个元素复制到mesh.Data向量中,这些元素由等于(3 * index.normal_index)、(3 * index.normal_index + 1)和(3 * index.normal_index + 2)的索引指示 -
如果还应加载纹理坐标,则向
mesh.Data向量添加两个元素,并将它们初始化为存储在attribs.texcoords向量中的值,位置为(2 * index.texcoord_index)和(2 * index.texcoord_index + 1) -
将
offset变量的值增加一
-
-
将
offset - part_offset的计算值存储在名为part_vertex_count的uint32_t类型变量中。 -
如果
part_vertex_count变量的值大于零(0值),则向mesh.Parts向量添加一个新元素。用以下值初始化其内容:-
VertexOffset的part_offset变量 -
VertexCount的part_vertex_count变量
-
-
它是如何工作的...
3D 模型应该尽可能小,以加快加载过程并降低存储它们所需的磁盘空间。通常,当我们考虑创建游戏时,我们应该选择二进制格式之一,因为它们中的大多数都满足上述要求。
但当我们开始学习新的 API 时,选择一个更简单的格式是好的。OBJ 文件包含以文本形式存储的数据,因此我们可以轻松地查看它,甚至可以自行修改它。大多数(如果不是所有)常用的建模程序都允许将生成的模型导出为 OBJ 文件。因此,这是一个很好的入门格式。
在这里,我们将专注于仅加载顶点数据。首先,我们需要为模型准备存储空间。之后,我们可以使用 tinyobjloader 库来加载模型。如果出现任何问题,我们将检查错误信息并将其显示给用户:
tinyobj::attrib_t attribs;
std::vector<tinyobj::shape_t> shapes;
std::vector<tinyobj::material_t> materials;
std::string error;
bool result = tinyobj::LoadObj( &attribs, &shapes, &materials, &error, filename.c_str() );
if( !result ) {
std::cout << "Could not open '" << filename << "' file.";
if( 0 < error.size() ) {
std::cout << " " << error;
}
std::cout << std::endl;
return false;
}
理论上,我们可以在这里结束我们的模型加载代码,但这个数据结构并不适合 Vulkan API。尽管单个顶点的法向量和纹理坐标可能放置在单独的数组中,但它们应该放置在相同的索引位置。不幸的是,当涉及到 OBJ 文件格式时,它可能会重复使用多个顶点的相同值。因此,我们需要将加载的数据转换为一种可以轻松由图形硬件使用的格式:
Mesh mesh = {};
uint32_t offset = 0;
for( auto & shape : shapes ) {
uint32_t part_offset = offset;
for( auto & index : shape.mesh.indices ) {
mesh.Data.emplace_back( attribs.vertices[3 * index.vertex_index + 0] );
mesh.Data.emplace_back( attribs.vertices[3 * index.vertex_index + 1] );
mesh.Data.emplace_back( attribs.vertices[3 * index.vertex_index + 2] );
++offset;
if( (load_normals) &&
(attribs.normals.size() > 0) ) {
mesh.Data.emplace_back( attribs.normals[3*index.normal_index+0]);
mesh.Data.emplace_back( attribs.normals[3*index.normal_index+1]);
mesh.Data.emplace_back( attribs.normals[3*index.normal_index+2]);
}
if( (load_texcoords) &&
(attribs.texcoords.size() > 0)) {
mesh.Data.emplace_back( attribs.texcoords[2 * index.texcoord_index + 0] );
mesh.Data.emplace_back( attribs.texcoords[2 * index.texcoord_index + 1] );
}
}
uint32_t part_vertex_count = offset - part_offset;
if( 0 < part_vertex_count ) {
mesh.Parts.push_back( { part_offset, part_vertex_count } );
}
}
在前面的转换之后,存储在mesh变量的Data成员中的数据可以直接复制到顶点缓冲区。另一方面,在绘制过程中使用模型的每个部分的VertexOffset和VertexCount成员--我们可以将它们提供给vkCmdDraw()函数。
当我们创建一个图形管线,该管线将用于绘制使用 tinyobjloader 库加载并存储在自定义类型 Mesh 变量中的模型时,我们需要为输入装配状态指定一个 VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST 顶点拓扑(参考第八章,图形和计算管线中的指定管线输入装配状态食谱)。我们还需要记住,每个顶点由定义其位置的三个浮点值组成。当加载顶点法线时,它们也由三个浮点值描述。可选的纹理坐标包含两个浮点值。位置、法线和 texcoord 属性依次放置在第一个顶点之后,然后是第二个顶点的位置、法线和 texcoord 属性,依此类推。上述信息是正确设置在创建图形管线期间指定的顶点绑定和属性描述所必需的(参考第八章,图形和计算管线中的指定管线顶点绑定描述、属性描述和输入状态食谱)。
参见
-
在第四章,资源和内存中,查看以下食谱:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
使用阶段缓冲区更新绑定设备本地内存的缓冲区
-
-
在第七章,着色器中,查看以下食谱:
- 编写顶点着色器
-
在第八章,图形和计算管线中,查看以下食谱:
-
指定管线顶点绑定描述、属性描述和输入状态
-
指定管线输入装配状态
-
-
在第九章,命令录制和绘制中,查看以下食谱:
-
绑定顶点缓冲区
-
绘制几何体
-
第十一章:光照
本章将涵盖以下食谱:
-
使用顶点漫反射光照渲染几何
-
使用片段镜面光照渲染几何
-
渲染法线贴图几何
-
使用立方体贴图绘制反射和折射几何
-
向场景添加阴影
简介
光照是我们感知周围一切的重要因素之一。我们的大脑从世界中收集的大部分信息都来自我们的眼睛。人类的视觉对光照条件的最轻微变化都非常敏感。这就是为什么光照对于 3D 应用程序、游戏和电影的制作者来说也非常重要。
在 3D 图形库仅支持固定功能管道的时代,光照计算是根据预定义的规则进行的--开发者只能选择光源和被照物体的颜色。这导致大多数使用给定库的游戏和应用看起来和感觉都相似。图形硬件演化的下一步是引入片段着色器:它们的主要目的是计算片段(像素)的最终颜色。片段着色器实际上着色了几何形状,因此着色器的名字是一个自然的选择。它们的主要优势是可编程。它们不仅可以执行光照计算,还可以实现几乎任何其他算法。如今,图形硬件要复杂得多。还有其他类型的可编程图形硬件部分,它们也采用了着色器的名称。有许多不同的算法和方法,它们都使用着色器在游戏、3D 应用程序和电影中显示有趣的图像。着色器程序的基本目的即使在今天仍然非常重要--如果我们想要实现有趣、引人注目的结果,就必须执行光照计算。
在本章中,我们将学习从简单的物体漫反射光照计算到阴影映射算法的常用光照技术。
使用顶点漫反射光照渲染几何
基本的漫反射光照算法是大多数光照计算的核心。它用于模拟漫反射表面反射光线,并向许多不同方向散射。在本例中,我们将看到如何使用实现漫反射光照算法的顶点和片段着色器来渲染几何形状。
使用此方法生成的图像示例如下:

以下食谱非常详细,以便您可以更容易地理解和跟随所有步骤。后续食谱将基于这里描述的知识,因此它们更短,但也更通用。
准备工作
漫反射光照是基于由约翰·海因里希·拉姆伯特提出的余弦定律。它表明,观察到的表面光照强度与从表面到光源方向(光向量)与表面法线向量的夹角的余弦成正比:

这个定律可以在着色器中轻松实现。法线向量作为顶点属性之一由应用程序提供。所有顶点的位置也是已知的,所以我们只需要提供一个光方向或光源的位置来在着色器内部计算光向量。正常向量和光向量都必须归一化(两者都必须长度等于1.0)。下一步是计算这两个向量之间角度的余弦值。这可以通过一个dot()函数来完成,如下所示:
max( 0.0, dot( normal_vector, light_vector ) )
我们必须记住,余弦函数可以给出负值。这种情况发生在我们计算指向光源相反方向表面上的点光照时。这样的点不能被给定光源照亮(它们从给定光源的角度看处于阴影中),因此我们必须忽略这样的结果并将它们夹到0值。
在所有后续的配方中,我们将使用VkDestroyer类的对象,这允许我们自动销毁 Vulkan 资源。为了方便,还引入了一个InitVkDestroyer()函数。它的目的是将给定的资源包装在VkDestroyer对象中,并将其连接到创建的逻辑设备。
如何操作...
-
使用一组启用的交换链扩展创建一个 Vulkan 实例和一个逻辑设备。同时存储从其中创建逻辑设备的物理设备的句柄(参考第二章,启用 WSI 扩展创建 Vulkan 实例和启用 WSI 扩展创建逻辑设备配方)。
-
从逻辑设备获取图形和呈现队列的句柄(参考第一章,实例和设备中的获取设备队列配方)。
-
使用一组期望的参数创建一个交换链。存储交换链的大小(图像维度)和格式(参考第二章,使用 R8G8B8A8 格式和存在 MAILBOX 呈现模式的交换链创建配方)。
-
获取所有交换链图像的句柄(参考第二章,获取交换链图像句柄配方)。
-
为所有交换链图像创建图像视图(参考第四章,资源和内存中的创建图像视图配方)。
-
创建一组用于生成动画帧所需的资源——命令池和命令缓冲区、信号量(至少两个用于获取交换链图像和指示帧渲染完成,这在交换链图像呈现期间是必需的)、栅栏和帧缓冲区。至少创建一个这样的集合,但如果我们想分别渲染更多帧,则可以创建更多(请参阅第九章,命令记录和绘制)中的通过增加单独渲染的帧数来提高性能配方)。
-
将具有顶点位置和法向量的 3D 模型数据加载到名为
Model的Mesh类型变量中(请参阅第十章,辅助配方)中的从 OBJ 文件加载 3D 模型配方)。 -
创建一个将作为顶点缓冲区并支持
VK_BUFFER_USAGE_TRANSFER_DST_BIT和VK_BUFFER_USAGE_VERTEX_BUFFER_BIT用法的缓冲区(请参阅第四章,资源和内存)中的创建缓冲区配方)。 -
分配一个具有
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT属性的内存对象并将其绑定到顶点缓冲区(请参阅第四章,资源和内存)中的分配和绑定内存对象到缓冲区配方)。 -
使用中间缓冲区将
Model变量的Data成员中的顶点数据复制到顶点缓冲区(请参阅第四章,资源和内存)中的使用中间缓冲区更新绑定到设备本地内存的缓冲区配方)。 -
创建一个具有
VK_BUFFER_USAGE_TRANSFER_DST_BIT和VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT用法的统一缓冲区,其大小足以容纳两个 16 元素浮点值矩阵的数据(请参阅第五章,描述符集)中的创建统一缓冲区配方)。 -
创建一个描述符集布局,其中只有一个由顶点着色器阶段访问的统一缓冲区(请参阅第五章,描述符集)中的创建描述符集布局配方)。
-
从描述符池中创建一个描述符,该描述符可以分配一个统一缓冲区(请参阅第五章,描述符集)中的创建描述符池配方)。
-
使用已准备的布局从创建的池中分配一个描述符集(请参阅第五章,描述符集)中的分配描述符集配方)。
-
使用统一缓冲区的句柄更新描述符集(请参阅第五章,描述符集)中的更新描述符集配方)。
-
准备渲染通道创建的参数。首先,指定两个附加文件的描述(参考第六章中的指定附加文件描述配方,渲染通道和帧缓冲区):
-
第一个附加文件应与 swapchain 图像具有相同的格式。它应在渲染通道开始时清除,并在渲染通道结束时存储其内容。其初始布局可以是未定义的,但最终布局必须为
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR。 -
第二个附加文件应具有支持的深度格式之一(
VK_FORMAT_D16_UNORM格式必须始终支持,并且至少支持VK_FORMAT_X8_D24_UNORM_PACK32或VK_FORMAT_D32_SFLOAT中的一个)。它必须在渲染通道开始时清除,但渲染通道之后不需要保留其内容。其初始布局可能是未定义的,最终布局应与子通道中指定的布局相同(以避免不必要的布局转换)。
-
-
为渲染通道指定一个子通道,其中第一个渲染通道附加文件将作为具有
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL布局的颜色附加文件提供,第二个附加文件将用作具有VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL布局的深度附加文件(参考第六章中的指定子通道描述配方,渲染通道和帧缓冲区)。 -
为渲染通道指定两个子通道依赖项(参考第六章中的指定子通道之间的依赖项配方,渲染通道和帧缓冲区)。第一个依赖项使用以下值:
-
srcSubpass的VK_SUBPASS_EXTERNAL值 -
dstSubpass的0值 -
srcStageMask的VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT值 -
dstStageMask的VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT值 -
srcAccessMask的VK_ACCESS_MEMORY_READ_BIT值 -
dstAccessMask的VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT值 -
dependencyFlags的VK_DEPENDENCY_BY_REGION_BIT值
-
-
第二个渲染通道依赖项使用以下值:
-
srcSubpass的0值 -
dstSubpass的VK_SUBPASS_EXTERNAL值 -
srcStageMask的VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT值 -
dstStageMask的VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT值 -
srcAccessMask的VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT值 -
dstAccessMask的VK_ACCESS_MEMORY_READ_BIT值 -
dependencyFlags的VK_DEPENDENCY_BY_REGION_BIT值
-
-
使用准备好的参数创建渲染通道(参考第六章中的创建渲染通道配方,渲染通道和帧缓冲区)。
-
使用仅包含统一缓冲区的已准备描述符集布局创建管线布局(请参阅第八章 创建管线布局 的配方,图形和计算管线)。
-
使用从以下 GLSL 代码生成的 SPIR-V 代码创建一个用于顶点着色器阶段的着色器模块(请参阅第七章 将 GLSL 着色器转换为 SPIR-V 代码 的配方,着色器,以及第八章 创建着色器模块 的配方,图形和计算管线)。
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec3 app_normal;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( location = 0 ) out float vert_color;
void main() {
gl_Position = ProjectionMatrix * ModelViewMatrix *
app_position;
vec3 normal = mat3( ModelViewMatrix ) * app_normal;
vert_color = max( 0.0, dot( normal, vec3( 0.58, 0.58, 0.58 ) ) ) + 0.1;
}
- 使用从以下 GLSL 代码生成的 SPIR-V 代码创建一个用于片段着色器阶段的着色器模块:
#version 450
layout( location = 0 ) in float vert_color;
layout( location = 0 ) out vec4 frag_color;
void main() {
frag_color = vec4( vert_color );
}
-
使用顶点和片段着色器指定管线着色器阶段,两者都使用各自着色器模块中的
main函数(请参阅第八章 指定管线着色器阶段 的配方,图形和计算管线)。 -
指定管线顶点输入状态,包含两个从同一 0 绑定读取的属性。绑定应使用每个顶点读取的数据创建,步长等于
6 * sizeof( float )(请参阅第八章 指定管线顶点输入状态 的配方,图形和计算管线)。第一个属性应具有以下参数:-
location的值为0。 -
binding的值为0。 -
format的值为VK_FORMAT_R32G32B32_SFLOAT。 -
offset的值为0。
-
-
第二个顶点属性应使用以下值指定:
-
location的值为1。 -
binding的值为0。 -
format的值为VK_FORMAT_R32G32B32_SFLOAT。 -
offset的值为3 * sizeof( float)。
-
-
指定管线输入装配状态,具有
VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST顶点类型和没有原语重启(请参阅第八章 指定管线输入装配状态 的配方,图形和计算管线)。 -
仅使用一个视口和裁剪测试状态指定管线视口和裁剪测试状态。初始值不重要,因为它们将被动态设置(请参阅第八章 指定管线视口和裁剪测试状态 的配方,图形和计算管线)。
-
使用无深度裁剪、无光栅化丢弃、
VK_POLYGON_MODE_FILL、VK_CULL_MODE_BACK_BIT和VK_FRONT_FACE_COUNTER_CLOCKWISE,无深度偏差以及线宽为1.0f的光栅化状态指定管线(请参阅第八章 指定管线光栅化状态 的配方,图形和计算管线)。 -
指定一个仅包含单个样本且无样本阴影、样本掩码、alpha 到覆盖或 alpha 到一的管线多采样状态(参考第八章中“指定管线多采样状态”的配方,图形和计算管线)。
-
指定一个具有深度测试和深度写入启用的管线深度状态,使用
VK_COMPARE_OP_LESS_OR_EQUAL运算符,且无深度界限或模板测试(参考第八章中“指定管线深度和模板状态”的配方,图形和计算管线)。 -
指定一个具有逻辑运算和混合禁用的管线混合状态(参考第八章中“指定管线混合状态”的配方,图形和计算管线)。
-
将视口和裁剪测试指定为管线的动态状态(参考第八章中“指定管线动态状态”的配方,图形和计算管线)。
-
使用准备好的参数创建图形管线(参考第八章中“创建图形管线”的配方,图形和计算管线)。
-
创建一个支持
VK_BUFFER_USAGE_TRANSFER_SRC_BIT使用的 staging buffer,可以容纳两个矩阵的数据,每个矩阵包含 16 个浮点元素。该缓冲区的内存对象应在主机可见的内存上分配(参考第四章中“创建缓冲区”和“分配和绑定内存对象到缓冲区”的配方,资源和内存)。 -
创建一个 2D 图像(使用适当的内存对象)和一个与渲染通道深度附加相同格式的图像视图,大小与 swapchain 图像相同,包含一个 mipmap 级别和数组层。该图像必须支持
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT使用(参考第四章中“创建 2D 图像和视图”的配方,资源和内存)。记住,这些资源(连同 swapchain)必须在应用程序窗口大小调整时重新创建。 -
准备一个模型矩阵,它可以是一个旋转、缩放和变换矩阵的乘积(参考第十章中“准备变换矩阵”、“准备旋转矩阵”和“准备缩放矩阵”的配方,辅助配方)。将连接矩阵的内容复制到偏移量为
0的 staging buffer 中(参考第四章中“映射、更新和取消映射主机可见内存”的配方,资源和内存)。 -
根据 swapchain 尺寸的纵横比准备透视投影矩阵(参考第十章中“准备透视投影矩阵配方”的配方 Chapter 10,辅助配方)。将矩阵的内容复制到阶段缓冲区,偏移量等于模型矩阵元素数量(16)乘以单个元素的大小(
sizeof(float))。记住,每次应用程序窗口大小改变时,都要重新创建投影矩阵并将其复制到阶段缓冲区(参考第四章中“映射、更新和取消映射主机可见内存”的配方 Chapter 4,资源和内存)。 -
在渲染循环内部,对于每次循环迭代,通过获取 swapchain 图像之一,创建一个包含获取的 swapchain 图像和作为深度附加图像的 framebuffer,记录下以下所述的命令缓冲区,将其提交到图形队列,并展示获取到的图像(参考第九章中“准备单个动画帧”的配方 Chapter 9,命令记录和绘图)。
-
要记录命令缓冲区:
-
开始记录命令缓冲区,指定
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT使用(参考第三章中“开始命令缓冲区记录操作”的配方 Chapter 3,命令缓冲区和同步)。 -
如果阶段缓冲区自上一帧以来已被更新,为统一缓冲区设置缓冲区内存屏障,通知驱动器该缓冲区的内存将以不同的方式被访问,从阶段缓冲区复制数据到统一缓冲区,并设置另一个缓冲区内存屏障(参考第四章中“设置缓冲区内存屏障”和“在缓冲区之间复制数据”的配方 Chapter 4,资源和内存)。
-
当图形和呈现队列不同时,使用图像内存屏障将获取到的 swapchain 图像的所有权从呈现队列转换到图形队列(参考第四章中“设置图像内存屏障”的配方 Chapter 4,资源和内存)。
-
开始渲染通道(参考第六章中“开始渲染通道”的配方 Chapter 6,渲染通道和帧缓冲区)。
-
动态设置视口和裁剪测试状态,提供当前 swapchain 的尺寸(参考第九章中“动态设置视口状态”和“动态设置裁剪状态”的配方 Chapter 9,命令记录和绘图)。
-
将顶点缓冲区绑定到
0绑定(参考第九章中“绑定顶点缓冲区”的配方 Chapter 9,命令记录和绘图)。 -
将描述符集绑定到
0索引(参考第五章的绑定描述符集配方,描述符集)。 -
绑定图形管线(参考第八章的绑定管线对象配方,图形和计算管线)。
-
绘制模型几何形状(参考第九章的绘制几何形状配方,命令记录和绘制)。
-
结束渲染通道(参考第六章的结束渲染通道配方,渲染通道和帧缓冲区)。
-
如果图形和呈现队列不同,使用图像内存屏障将获取的交换链图像的所有权从图形队列转换为呈现队列(参考第四章的设置图像内存屏障,资源和内存)。
-
结束命令缓冲区的记录操作(参考第三章的结束命令缓冲区记录操作配方,命令缓冲区和同步)。
-
-
为了提高应用程序的性能,使用不同的资源集准备多个动画帧(参考第九章的通过增加单独渲染帧的数量来提高性能配方,命令记录和绘制)。
它是如何工作的...
假设我们已经创建了一个 Vulkan 实例和一个启用了 WSI 扩展的逻辑设备。我们还创建了一个交换链对象(这些操作的完整源代码可以在附带的代码示例中找到)。
要渲染任何几何形状,我们首先需要加载一个 3D 模型。其数据需要复制到顶点缓冲区中,因此我们还需要创建一个顶点缓冲区,为其分配和绑定内存,并需要使用阶段缓冲区来复制模型数据:
if( !Load3DModelFromObjFile( "Data/Models/knot.obj", true, false, false, true, Model ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, VertexBuffer );
if( !CreateBuffer( *LogicalDevice, sizeof( Model.Data[0] ) * Model.Data.size(), VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, *VertexBuffer ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, VertexBufferMemory );
if( !AllocateAndBindMemoryObjectToBuffer( PhysicalDevice, *LogicalDevice, *VertexBuffer, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, *VertexBufferMemory ) ) {
return false;
}
if( !UseStagingBufferToUpdateBufferWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, sizeof( Model.Data[0] ) * Model.Data.size(), &Model.Data[0], *VertexBuffer, 0, 0, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_VERTEX_INPUT_BIT, GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} ) ) {
return false;
}
接下来,需要一个统一缓冲区。我们将使用统一缓冲区向着色器提供变换矩阵:
InitVkDestroyer( LogicalDevice, UniformBuffer );
InitVkDestroyer( LogicalDevice, UniformBufferMemory );
if( !CreateUniformBuffer( PhysicalDevice, *LogicalDevice, 2 * 16 * sizeof( float ), VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
*UniformBuffer, *UniformBufferMemory ) ) {
return false;
}
统一缓冲区将在顶点着色器中被访问。为此,我们需要一个描述符集布局、一个描述符池和一个单独的描述符集,该描述符集将更新(填充)以创建统一缓冲区:
VkDescriptorSetLayoutBinding descriptor_set_layout_binding = {
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT,
nullptr
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, { descriptor_set_layout_binding }, *DescriptorSetLayout ) ) {
return false;
}
VkDescriptorPoolSize descriptor_pool_size = {
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, { descriptor_pool_size }, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
UpdateDescriptorSets( *LogicalDevice, {}, { buffer_descriptor_update }, {}, {} );
渲染操作只能在渲染通道内执行。我们需要一个具有两个附件的渲染通道:第一个是一个交换链图像;第二个是我们创建的图像,它将作为深度附件。由于我们将只渲染一个模型而不使用任何后处理技术,渲染通道只需要一个子通道就足够了。
std::vector<VkAttachmentDescription> attachment_descriptions = {
{
0,
Swapchain.Format,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
},
{
0,
DepthFormat,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
}
};
VkAttachmentReference depth_attachment = {
1,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
};
std::vector<SubpassParameters> subpass_parameters = {
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{},
{
{
0,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
}
},
{},
&depth_attachment,
{}
}
};
std::vector<VkSubpassDependency> subpass_dependencies = {
{
VK_SUBPASS_EXTERNAL,
0,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_DEPENDENCY_BY_REGION_BIT
},
{
0,
VK_SUBPASS_EXTERNAL,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_DEPENDENCY_BY_REGION_BIT
}
};
InitVkDestroyer( LogicalDevice, RenderPass );
if( !CreateRenderPass( *LogicalDevice, attachment_descriptions, subpass_parameters, subpass_dependencies, *RenderPass ) ) {
return false;
}
我们还需要一个阶段缓冲区。它将被用来从应用程序传输数据到统一缓冲区:
InitVkDestroyer( LogicalDevice, StagingBuffer );
if( !CreateBuffer( *LogicalDevice, 2 * 16 * sizeof(float), VK_BUFFER_USAGE_TRANSFER_SRC_BIT, *StagingBuffer ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, StagingBufferMemory );
if( !AllocateAndBindMemoryObjectToBuffer( PhysicalDevice, *LogicalDevice, *StagingBuffer, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, *StagingBufferMemory ) ) {
return false;
}
在我们可以渲染一个帧之前,我们需要做最后一件事:创建一个图形管线。由于创建它的代码相当直接,我们将跳过它(可以在本书附带的代码示例中看到)。
为了看到模型,我们需要准备模型和投影矩阵。模型矩阵用于将模型放置在虚拟世界中--它可以移动、缩放或旋转。这样的矩阵通常与视图矩阵结合使用,视图矩阵用于在场景中移动相机。在这里,为了简单起见,我们不会使用视图变换;但我们仍然需要一个投影矩阵。因为投影矩阵中的值取决于帧缓冲区的纵横比(在这种情况下是应用程序窗口的大小),所以每次应用程序窗口的尺寸改变时都必须重新计算:
Matrix4x4 rotation_matrix = PrepareRotationMatrix( vertical_angle, { 1.0f, 0.0f, 0.0f } ) * PrepareRotationMatrix( horizontal_angle, { 0.0f, -1.0f, 0.0f } );
Matrix4x4 translation_matrix = PrepareTranslationMatrix( 0.0f, 0.0f, -4.0f );
Matrix4x4 model_view_matrix = translation_matrix * rotation_matrix;
if( !MapUpdateAndUnmapHostVisibleMemory( *LogicalDevice, *StagingBufferMemory, 0, sizeof( model_view_matrix[0] ) * model_view_matrix.size(), &model_view_matrix[0], true, nullptr ) ) {
return false;
}
Matrix4x4 perspective_matrix = PreparePerspectiveProjectionMatrix( static_cast<float>(Swapchain.Size.width) / static_cast<float>(Swapchain.Size.height),
50.0f, 0.5f, 10.0f );
if( !MapUpdateAndUnmapHostVisibleMemory( *LogicalDevice, *StagingBufferMemory, sizeof( model_view_matrix[0] ) * model_view_matrix.size(),
sizeof( perspective_matrix[0] ) * perspective_matrix.size(), &perspective_matrix[0], true, nullptr ) ) {
return false;
最后,我们需要做的最后一件事是准备一个动画帧。这通常在渲染循环内部进行,其中对于每个循环迭代都会渲染一个单独的新帧。
首先,我们需要检查统一缓冲区的内容是否需要更新,以及数据是否需要从阶段缓冲区复制到统一缓冲区:
if( !BeginCommandBufferRecordingOperation( command_buffer, VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, nullptr ) ) {
return false;
}
if( UpdateUniformBuffer ) {
UpdateUniformBuffer = false;
BufferTransition pre_transfer_transition = {
*UniformBuffer,
VK_ACCESS_UNIFORM_READ_BIT,
VK_ACCESS_TRANSFER_WRITE_BIT,
VK_QUEUE_FAMILY_IGNORED,
VK_QUEUE_FAMILY_IGNORED
};
SetBufferMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, { pre_transfer_transition } );
std::vector<VkBufferCopy> regions = {
{
0,
0,
2 * 16 * sizeof( float )
}
};
CopyDataBetweenBuffers( command_buffer, *StagingBuffer, *UniformBuffer, regions );
BufferTransition post_transfer_transition = {
*UniformBuffer,
VK_ACCESS_TRANSFER_WRITE_BIT,
VK_ACCESS_UNIFORM_READ_BIT,
VK_QUEUE_FAMILY_IGNORED,
VK_QUEUE_FAMILY_IGNORED
};
SetBufferMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, { post_transfer_transition } );
}
接下来,我们转移交换链图像的队列所有权(在图形和呈现队列不同的情况下)。然后,我们开始渲染通道并设置渲染几何形状所需的所有状态:我们设置视口和剪裁测试状态,绑定顶点缓冲区、描述符集和图形管线。之后,绘制几何形状并完成渲染通道。再次,我们需要将队列所有权转回到呈现队列(如果图形队列不同)并停止记录命令缓冲区。现在它可以提交到队列:
if( PresentQueue.FamilyIndex != GraphicsQueue.FamilyIndex ) {
ImageTransition image_transition_before_drawing = {
Swapchain.Images[swapchain_image_index],
VK_ACCESS_MEMORY_READ_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
PresentQueue.FamilyIndex,
GraphicsQueue.FamilyIndex,
VK_IMAGE_ASPECT_COLOR_BIT
};
SetImageMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, { image_transition_before_drawing } );
}
BeginRenderPass( command_buffer, *RenderPass, framebuffer, { { 0, 0 }, Swapchain.Size }, { { 0.1f, 0.2f, 0.3f, 1.0f },{ 1.0f, 0 } }, VK_SUBPASS_CONTENTS_INLINE );
VkViewport viewport = {
0.0f,
0.0f,
static_cast<float>(Swapchain.Size.width),
static_cast<float>(Swapchain.Size.height),
0.0f,
1.0f,
};
SetViewportStateDynamically( command_buffer, 0, { viewport } );
VkRect2D scissor = {
{
0,
0
},
{
Swapchain.Size.width,
Swapchain.Size.height
}
};
SetScissorsStateDynamically( command_buffer, 0, { scissor } );
BindVertexBuffers( command_buffer, 0, { { *VertexBuffer, 0 } } );
BindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *PipelineLayout, 0, DescriptorSets, {} );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *Pipeline );
for( size_t i = 0; i < Model.Parts.size(); ++i ) {
DrawGeometry( command_buffer, Model.Parts[i].VertexCount, 1, Model.Parts[i].VertexOffset, 0 );
}
EndRenderPass( command_buffer );
if( PresentQueue.FamilyIndex != GraphicsQueue.FamilyIndex ) {
ImageTransition image_transition_before_present = {
Swapchain.Images[swapchain_image_index],
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
GraphicsQueue.FamilyIndex,
PresentQueue.FamilyIndex,
VK_IMAGE_ASPECT_COLOR_BIT
};
SetImageMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, { image_transition_before_present } );
}
if( !EndCommandBufferRecordingOperation( command_buffer ) ) {
return false;
}
return true;
当我们准备前一个帧时,法向量和顶点位置会自动从顶点缓冲区获取。位置不仅用于显示几何形状,而且与法向量一起,它们也用于光照计算。
gl_Position = ProjectionMatrix * ModelViewMatrix * app_position;
vec3 normal = mat3( ModelViewMatrix ) * app_normal;
vert_color = max( 0.0, dot( normal, vec3( 0.58, 0.58, 0.58 ) ) ) + 0.1;
为了简化,光向量在顶点着色器中是硬编码的,但通常它应该通过统一缓冲区或推送常量来提供。在这种情况下,光向量始终指向同一方向(对于所有顶点),因此它模拟了方向性光源,这通常代表太阳。
在前面的代码中,所有光照计算都是在视图空间中进行的。我们可以在任何我们想要的坐标系中进行这样的计算,但为了使计算正确,所有向量(法线、光向量、视图向量等)都必须转换到同一空间。
在计算漫反射项之后,我们还向计算出的颜色中添加一个常数值。这通常被称为环境光照,用于照亮场景(否则所有阴影/未照亮的表面都会太暗)。
下面我们可以看到在每个顶点计算的漫反射光照应用于具有不同多边形数量的几何体:左侧,详细几何体(高多边形);右侧模型,具有较少的细节(低多边形):

参见
-
在第二章,图像展示,查看以下配方:
-
使用 WSI 扩展启用创建 Vulkan 实例
-
使用 WSI 扩展启用创建逻辑设备
-
使用 R8G8B8A8 格式和存在 MAILBOX 显示模式创建交换链
-
-
在第三章,命令缓冲区和同步,配方开始命令缓冲区录制操作
-
在第八章,图形和计算管线,配方创建图形管线
-
在第九章,命令录制与绘制,配方通过增加单独渲染的帧数来提高性能
-
在第十章,辅助配方,配方从 OBJ 文件加载 3D 模型
-
本章中的以下配方:
-
使用片段镜面反射光照渲染几何体
-
渲染法线贴图几何体
-
使用片段镜面反射光照渲染几何体
镜面反射光照使我们能够在模型表面添加明亮的亮点或反射。这样渲染的几何体看起来更亮、更光滑。
使用此配方生成的图像示例如下:

准备工作
描述表面光照方式最常用的算法是Blinn-Phong模型。它是一个经验模型,虽然不是物理上正确的,但在渲染几何体简化的情况下给出的结果更可信。因此,它非常适合 3D 实时图形。
Blinn-Phong模型描述了从给定表面发出的光作为四个分量的总和:
-
发射:表面发出的光量
-
: 在整个场景中散射的反射光量,没有明显的光源(用于照亮几何体)
环境光:在整个场景中散射的反射光量,没有明显的光源(用于照亮几何体)漫反射:描述粗糙表面反射的光(基于朗伯光照方程)
- 镜面反射:描述光滑表面反射的光
上述每个组件可能具有不同的颜色,这描述了表面材料(漫反射颜色通常来自纹理)。每个光源也可以为每个组件(除了发射组件)使用单独的颜色表示。我们可以将其解释为给定光源对场景中可用环境光的影响程度,光源发出的漫反射光量等等。当然,我们可以修改前面的算法以调整它以满足我们的需求。这样我们可以得到易于计算的各种结果。
在本配方中,我们将重点关注漫反射光照和镜面反射。前者在使用顶点漫反射光照渲染几何体配方中描述。后者通过表面法线向量和半向量的点积来计算。半向量是一个位于视向量(从被照点到观察者)和光向量(从被照点到光源)之间的向量:

计算出的点积值负责在光滑表面上创建闪亮的光反射。由于以这种方式照亮的区域可能太大,因此计算出的值被提升到幂。幂值越高,物体表面的光反射就越小、越集中。在着色器中,这些计算如下:
pow( dot( half_vector, normal_vector ), shinniness );
法线向量通常沿着几何形状加载并由应用程序提供。半向量按以下方式计算:
vec3 view_vector = normalize( eye_position.xyz - vert_position.xyz );
vec3 light_vector = normalize( light_position.xyz - vert_position.xyz );
vec3 half_vector = normalize( view_vector + light_vector );
为了获得正确的结果,所有向量都必须归一化。当然,当表面未照亮(或未面向光源)时,镜面高光是不可见的。因此,只有在漫反射分量大于0时才应计算它们。
如何做到这一点...
-
按照所述的使用顶点漫反射光照渲染几何体配方准备 Vulkan 资源。
-
使用准备好的描述符集布局创建一个仅包含统一缓冲区和由片段着色器阶段访问的单个推送常量范围(从 0^(th)偏移开始,大小为
4 * sizeof( float ))的管线布局(请参阅第八章中的创建管线布局配方,图形和计算管线)。 -
使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于顶点着色器阶段的着色器模块(请参阅第七章中的将 GLSL 着色器转换为 SPIR-V 汇编配方,着色器,以及第八章中的创建着色器模块配方,图形和计算管线):
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec3 app_normal;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( location = 0 ) out vec3 vert_position;
layout( location = 1 ) out vec3 vert_normal;
void main() {
vec4 position = ModelViewMatrix * app_position;
vert_position = position.xyz;
vert_normal = mat3( ModelViewMatrix ) * app_normal;
gl_Position = ProjectionMatrix * position;
}
- 使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于片段着色器阶段的着色器模块:
#version 450
layout( location = 0 ) in vec3 vert_position;
layout( location = 1 ) in vec3 vert_normal;
layout( push_constant ) uniform LightParameters {
vec4 Position;
} Light;
layout( location = 0 ) out vec4 frag_color;
void main() {
vec3 normal_vector = normalize( vert_normal );
vec3 light_vector = normalize( Light.Position.xyz -
vert_position );
float diffuse_term = max( 0.0, dot( normal_vector, light_vector ) );
frag_color = vec4( diffuse_term + 0.1 );
if( diffuse_term > 0.0 ) {
vec3 view_vector = normalize( vec3( 0.0, 0.0, 0.0 ) -
vert_position.xyz );
vec3 half_vector = normalize( view_vector + light_vector );
float shinniness = 60.0;
float specular_term = pow( dot( half_vector, normal_vector ), shinniness );
frag_color += vec4( specular_term );
}
}
-
使用顶点和片段着色器指定管道着色器阶段,两者都使用各自着色器模块中的主函数(参考第八章,图形和计算管道中的指定管道着色器阶段配方)。
-
使用之前介绍的管道布局和着色器阶段创建一个图形管道。管道的其他参数与在使用顶点漫反射光照渲染几何体配方中展示的相同。
-
准备一个每帧执行的命令缓冲区记录(或渲染)函数。为此,我们需要开始命令缓冲区记录,将数据从阶段缓冲区复制到统一缓冲区(如果需要),设置一个图像内存屏障以转移从交换链获取的图像的所有权,开始渲染通道,动态设置视口和剪裁测试状态,绑定顶点缓冲区、描述符集和图形管道(参考使用顶点漫反射光照渲染几何体配方)。
-
准备光源的位置,并通过推送常量将其提供给着色器。为此操作,提供管道布局,一个
VK_SHADER_STAGE_FRAGMENT_BIT着色器阶段,0偏移量,大小为sizeof( float ) * 4,以及指向数据的指针,其中存储了光源的位置(参考第九章,命令记录和绘制中的通过推送常量向着色器提供数据配方)。 -
通过记录模型绘制操作、结束渲染通道、为交换链图像设置另一个图像内存屏障以及结束命令缓冲区来最终化命令缓冲区。
-
将命令缓冲区提交到图形队列,并呈现一个图像(参考第九章,命令记录和绘制中的准备单个动画帧和通过增加单独渲染帧的数量来提高性能配方)。
它是如何工作的...
整个源代码几乎与使用顶点漫反射光照渲染几何体配方中展示的相同。最重要的区别在于顶点和片段着色器,它们根据应用程序提供的数据执行光照计算。这次光照向量不是硬编码在着色器中的。相反,它使用应用程序提供的数据进行计算。位置和法向量自动作为顶点属性读取。使用推送常量读取光源的位置,因此当我们创建管道布局时需要包含推送常量范围:
std::vector<VkPushConstantRange> push_constant_ranges = {
{
VK_SHADER_STAGE_FRAGMENT_BIT, // VkShaderStageFlags stageFlags
0, // uint32_t offset
sizeof( float ) * 4 // uint32_t size
}
};
InitVkDestroyer( LogicalDevice, PipelineLayout );
if( !CreatePipelineLayout( *LogicalDevice, { *DescriptorSetLayout }, push_constant_ranges, *PipelineLayout ) ) {
return false;
}
在命令缓冲区记录操作期间提供推送常量数据:
BindVertexBuffers( command_buffer, 0, { { *VertexBuffer, 0 } } );
BindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *PipelineLayout, 0, DescriptorSets, {} );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *Pipeline );
std::array<float, 4> light_position = { 5.0f, 5.0f, 0.0f, 0.0f };
ProvideDataToShadersThroughPushConstants( command_buffer, *PipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof( float ) * 4, &light_position[0] );
通过推送常量,我们提供了一个光源的位置。这样我们的着色器就变得更加通用,因为我们可以直接在着色器中计算光照向量并将其用于光照计算。
在以下图像中,我们可以看到使用在片段着色器内计算出的漫反射和镜面光照渲染几何体的结果。在片段着色器中执行的光照计算结果比在顶点着色器中执行相同计算的结果要好得多。即使几何体相当简单,光照看起来也很不错。但当然,这伴随着性能的降低。

参见
-
在第七章,着色器中,食谱将 GLSL 着色器转换为 SPIR-V 汇编
-
在第八章,图形和计算管线中,查看以下食谱:
-
创建着色器模块
-
指定管线着色器阶段
-
创建管线布局
-
-
在第九章,命令记录与绘制中,查看以下食谱:
- 通过推送常量向着色器提供数据
-
本章中的以下食谱:
- 使用顶点漫反射光照渲染几何体
渲染法线贴图几何体
法线贴图是一种技术,允许我们在不增加模型几何复杂性的情况下增加模型表面的细节。使用这种技术,与顶点关联的法线向量在光照计算期间不被使用。它们被从图像(纹理)中读取的法线向量所替代。这样,模型的形状保持不变,因此我们不需要额外的处理能力来转换顶点。然而,光照质量要好得多,并且仅取决于法线贴图图像的质量,而不是模型复杂性。
使用此食谱生成的图像示例如下:

准备工作
法线贴图是一种图像,其中存储了从高度详细几何体获取的法线向量。它用于模拟简单(低多边形)几何体上的大量表面细节。

对于简单的光照计算,我们只需要加载位置和法向量,但法线贴图需要我们为给定的 3D 模型加载(或生成)更多的数据。除了上述属性外,我们还需要纹理坐标,这样我们就可以在片段着色器中采样法线贴图,还需要两个额外的向量:切线和副切线。法向量在给定点的表面上垂直于表面,并指向远离表面的方向。切线和副切线向量与表面相切。切线向量指向物体表面的方向,其中纹理图像水平前进,从左到右(纹理坐标的s分量增加)。副切线指向物体表面的方向,其中纹理图像垂直前进,从上到下(纹理坐标的t分量减少)。此外,所有三个向量——法线、切线和副切线——应相互垂直(允许有小的偏差)并且长度等于1.0。
法线、切线和副切线向量不是直接用于光照计算的。相反,它们形成一个旋转矩阵,可以用来将向量从纹理(或切线)空间转换为局部模型空间,反之亦然。这样我们就不需要创建一个只能应用于特定模型的法线向量纹理,而可以准备一个通用的法线贴图,并用它来处理任意几何体。使用所谓的 TBN 矩阵,我们可以从纹理中加载法线向量,并用于在更方便的坐标系中执行的光照计算。
如何实现...
-
按照第十章使用顶点漫反射光照渲染几何体食谱中描述的方式准备 Vulkan 资源。
-
从文件中加载具有法线贴图的纹理数据(参考第十章的从文件加载纹理数据食谱,辅助食谱)。
-
创建一个具有颜色方面和格式(例如
VK_FORMAT_R8G8B8A8_UNORM)的二维组合图像采样器,并支持VK_IMAGE_USAGE_SAMPLED_BIT和VK_IMAGE_USAGE_TRANSFER_DST_BIT使用(参考第五章的创建组合图像采样器食谱,描述符集)。 -
使用阶段缓冲区将加载的法线图数据复制到创建的图像中(参考第四章的使用阶段缓冲区更新绑定到设备本地内存的图像食谱,资源和内存)。
-
从文件中加载 3D 模型。除了顶点位置和法向量外,还需要加载纹理坐标,并加载或生成切线和副切线向量。创建一个(顶点)缓冲区,并使用阶段缓冲区将加载的模型数据复制到缓冲区的内存中(参考第十章的从 OBJ 文件加载 3D 模型,辅助食谱)。
-
创建一个描述符集布局,其中包含一个由顶点着色器在 0^(th)绑定处访问的统一缓冲区,以及一个由片段着色器在 1^(st)绑定处访问的联合图像采样器(参考第五章中创建描述符集布局的配方,描述符集)。
-
从创建的描述符池中分配一个统一缓冲区描述符和一个联合图像采样器描述符(参考第五章中创建描述符池的配方,描述符集)。
-
使用包含一个统一缓冲区和一个联合图像采样器的描述符集布局从创建的池中分配一个描述符集(参考第五章中分配描述符集的配方,描述符集)。
-
使用在 0^(th)绑定处访问的统一缓冲区和在 1^(st)绑定处访问的创建的联合图像采样器(具有正常图数据)更新描述符集。提供
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL作为图像的布局(参考第五章中更新描述符集的配方,描述符集)。 -
使用准备好的描述符集布局创建一个管线布局,该布局还指定了一个由片段着色器阶段访问的单个推送常量范围,起始位置为第 0 个偏移量,大小为
4 * sizeof( float )(参考第八章中创建管线布局的配方,图形和计算管线)。 -
使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于顶点着色器阶段的着色器模块(参考第七章中将 GLSL 着色器转换为 SPIR-V 汇编的配方,以及第八章中创建着色器模块的配方,图形和计算管线):
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec3 app_normal;
layout( location = 2 ) in vec2 app_texcoord;
layout( location = 3 ) in vec3 app_tangent;
layout( location = 4 ) in vec3 app_bitangent;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( location = 0 ) out vec3 vert_position;
layout( location = 1 ) out vec2 vert_texcoord;
layout( location = 2 ) out vec3 vert_normal;
layout( location = 3 ) out vec3 vert_tanget;
layout( location = 4 ) out vec3 vert_bitanget;
void main() {
vec4 position = ModelViewMatrix * app_position;
gl_Position = ProjectionMatrix * position;
vert_position = position.xyz;
vert_texcoord = app_texcoord;
vert_normal = mat3( ModelViewMatrix ) * app_normal;
vert_tanget = mat3( ModelViewMatrix ) * app_tangent;
vert_bitanget = mat3( ModelViewMatrix ) * app_bitangent;
}
- 使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于片段着色器阶段的着色器模块:
#version 450
layout( location = 0 ) in vec3 vert_position;
layout( location = 1 ) in vec2 vert_texcoord;
layout( location = 2 ) in vec3 vert_normal;
layout( location = 3 ) in vec3 vert_tanget;
layout( location = 4 ) in vec3 vert_bitanget;
layout( set = 0, binding = 1 ) uniform sampler2D ImageSampler;
layout( push_constant ) uniform LightParameters {
vec4 Position;
} Light;
layout( location = 0 ) out vec4 frag_color;
void main() {
vec3 normal = 2 * texture( ImageSampler, vert_texcoord ).rgb -
1.0;
vec3 normal_vector = normalize( mat3( vert_tanget,
vert_bitanget, vert_normal) * normal );
vec3 light_vector = normalize( Light.Position.xyz -
vert_position );
float diffuse_term = max( 0.0, dot( normal_vector, light_vector
) ) * max( 0.0, dot( vert_normal, light_vector ) );
frag_color = vec4( diffuse_term + 0.1 );
if( diffuse_term > 0.0 ) {
vec3 half_vector = normalize(normalize( -vert_position.xyz )
+ light_vector);
float specular_term = pow( dot( half_vector, normal_vector ),
60.0 );
frag_color += vec4( specular_term );
}
}
-
指定使用顶点和片段着色器的管线着色器阶段,两者都使用各自着色器模块中的
main函数(参考第八章中指定管线着色器阶段的配方,图形和计算管线)。 -
指定一个具有五个属性的管线顶点输入状态,这些属性从相同的 0^(th)绑定读取。绑定应使用每个顶点读取的数据和等于
14 * sizeof( float )的步长创建(参考第八章中指定管线顶点输入状态的配方,图形和计算管线)。第一个属性应具有以下参数:-
location的值为0 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为0
-
-
第二个属性应定义为以下内容:
-
location的值为1 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为3 * sizeof( float )
-
-
第三个属性应具有以下定义:
-
location的值为2 -
binding的值为0 -
format的值为VK_FORMAT_R32G32_SFLOAT -
offset的值为6 * sizeof( float )
-
-
第四个属性应指定如下:
-
location的值为3 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为8 * sizeof( float )
-
-
第五个属性应使用以下值:
-
location的值为4 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为11 * sizeof( float )
-
-
在动画的每一帧中,记录一个命令缓冲区,在其中将数据从阶段缓冲区复制到统一缓冲区,开始渲染过程,动态设置视口和裁剪测试状态,绑定顶点缓冲区、描述符集和图形管道(参考 使用顶点漫反射光照渲染几何体菜谱)。
-
准备光源的位置,并通过推送常量将其提供给着色器。为此操作,提供管道布局,一个
VK_SHADER_STAGE_FRAGMENT_BIT着色器阶段,0偏移量,大小为sizeof( float ) * 4,以及指向数据的指针,其中存储了光源的位置(参考第九章 通过推送常量向着色器提供数据 菜谱,命令记录和绘制)。 -
绘制模型,将所需的其他操作记录到命令缓冲区中,将命令缓冲区提交到图形队列,并呈现图像(参考第九章 准备单个动画帧和通过增加单独渲染帧的数量来提高性能 菜谱,命令记录和绘制)。
它是如何工作的...
要在我们的应用程序中使用法线贴图,我们需要准备一个存储法向量的图像。我们必须加载图像的内容,创建一个图像,并将数据复制到图像的内存中。我们还需要创建一个采样器,它与图像一起将形成一个组合图像采样器:
int width = 1;
int height = 1;
std::vector<unsigned char> image_data;
if( !LoadTextureDataFromFile( "Data/Textures/normal_map.png", 4, image_data, &width, &height ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, Sampler );
InitVkDestroyer( LogicalDevice, Image );
InitVkDestroyer( LogicalDevice, ImageMemory );
InitVkDestroyer( LogicalDevice, ImageView );
if( !CreateCombinedImageSampler( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, { (uint32_t)width, (uint32_t)height, 1 },
1, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, VK_IMAGE_VIEW_TYPE_2D, VK_IMAGE_ASPECT_COLOR_BIT, VK_FILTER_LINEAR,
VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT, VK_SAMPLER_ADDRESS_MODE_REPEAT,
VK_SAMPLER_ADDRESS_MODE_REPEAT, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 1.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK,
false, *Sampler, *Image, *ImageMemory, *ImageView ) ) {
return false;
}
VkImageSubresourceLayers image_subresource_layer = {
VK_IMAGE_ASPECT_COLOR_BIT, // VkImageAspectFlags aspectMask
0, // uint32_t mipLevel
0, // uint32_t baseArrayLayer
1 // uint32_t layerCount
};
if( !UseStagingBufferToUpdateImageWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, static_cast<VkDeviceSize>(image_data.size()),
&image_data[0], *Image, image_subresource_layer, { 0, 0, 0 }, { (uint32_t)width, (uint32_t)height, 1 }, VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, 0, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} ) ) {
return false;
}
之后,我们需要加载一个 3D 模型。我们需要加载位置、法向量以及纹理坐标。切线向量和双切线向量也必须加载,但由于 .obj 格式无法存储这么多不同的属性,我们必须生成它们(这是在 Load3DModelFromObjFile() 内部执行的):
uint32_t vertex_stride = 0;
if( !Load3DModelFromObjFile( "Data/Models/ice.obj", true, true, true, true, Model, &vertex_stride ) ) {
return false;
}
现在我们需要修改在 使用顶点漫反射光照渲染几何体 菜谱中描述的描述符集。首先,我们开始创建一个合适的布局:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, descriptor_set_layout_bindings, *DescriptorSetLayout ) ) {
return false;
}
接下来,需要一个描述符池。从它分配一个描述符集:
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
},
{
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
当描述符集被分配时,我们可以使用统一缓冲区和组合图像采样器的句柄来更新它:
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
ImageDescriptorInfo image_descriptor_update = {
DescriptorSets[0],
1,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
*Sampler,
*ImageView,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
};
UpdateDescriptorSets( *LogicalDevice, { image_descriptor_update }, { buffer_descriptor_update }, {}, {} );
这次我们有两个而不是五个顶点属性,因此我们还需要修改顶点输入状态:
std::vector<VkVertexInputBindingDescription> vertex_input_binding_descriptions = {
{
0,
vertex_stride,
VK_VERTEX_INPUT_RATE_VERTEX
}
};
std::vector<VkVertexInputAttributeDescription> vertex_attribute_descriptions = {
{
0,
0,
VK_FORMAT_R32G32B32_SFLOAT,
0
},
{
1,
0,
VK_FORMAT_R32G32B32_SFLOAT,
3 * sizeof( float )
},
{
2,
0,
VK_FORMAT_R32G32_SFLOAT,
6 * sizeof( float )
},
{
3,
0,
VK_FORMAT_R32G32B32_SFLOAT,
8 * sizeof( float )
},
{
4,
0,
VK_FORMAT_R32G32B32_SFLOAT,
11 * sizeof( float )
}
};
VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info;
SpecifyPipelineVertexInputState( vertex_input_binding_descriptions, vertex_attribute_descriptions, vertex_input_state_create_info );
上述属性在顶点着色器中读取,使用模型视图和投影矩阵将顶点位置转换到裁剪空间。此外,视图空间位置和未修改的纹理坐标被传递到片段着色器。法线、切线和二切向量也被传递到片段着色器,但它们首先使用模型视图矩阵转换到视图空间:
vec4 position = ModelViewMatrix * app_position;
gl_Position = ProjectionMatrix * position;
vert_position = position.xyz;
vert_texcoord = app_texcoord;
vert_normal = mat3( ModelViewMatrix ) * app_normal;
vert_tangent = mat3( ModelViewMatrix ) * app_tangent;
vert_bitangent = mat3( ModelViewMatrix ) * app_bitangent;
从正常映射的角度来看,最重要的部分发生在片段着色器中。它首先从纹理中读取法线向量。通常,纹理存储的值在 0.0 - 1.0 范围内(除非我们使用有符号归一化纹理格式:SNORM)。然而,法线向量的所有分量可能具有 -1.0 - 1.0 范围内的值,因此我们需要像这样扩展加载的法线向量:
vec3 normal = 2 * texture( ImageSampler, vert_texcoord ).rgb - 1.0;
片段着色器以与使用片段镜面照明渲染几何体食谱中描述的相同方式计算漫反射和镜面照明。它只是使用从纹理中加载的法线向量代替从顶点着色器提供的法线向量。它还需要执行一个额外的操作:所有向量(光和视图)都在视图空间中,但存储在正常映射中的法线向量在切线空间中,因此它也需要转换到相同的视图空间。这是通过使用由法线、切线和二切向量形成的 TBN 矩阵来完成的。它们由顶点着色器提供。因为顶点着色器通过乘以模型视图矩阵将它们从模型空间转换到视图空间,所以创建的 TBN 矩阵将法线向量直接从切线空间转换到视图空间:
vec3 normal_vector = normalize( mat3( vert_tanget, vert_bitanget, vert_normal) * normal );
mat3() 是一个从三个分量向量创建 3x3 矩阵的构造函数。使用这样的矩阵,我们可以执行旋转和缩放,但不能平移。由于我们想要变换方向(单位长度向量),这正是这种情况所需要的。
正常映射可以在非常简单的(低多边形)几何体上为我们提供令人印象深刻的照明效果。在下面的图片中,左侧我们可以看到带有许多多边形的正常映射几何体;而右侧,相似的几何体以更少的顶点呈现。

参见
-
在第四章的资源和内存中,查看使用阶段缓冲区更新绑定到设备本地内存的图像的食谱
-
在第五章的描述符集中,查看创建组合图像采样器的食谱
-
在第十章的辅助食谱中,查看以下食谱:
-
从文件中加载纹理数据
-
从 OBJ 文件中加载 3D 模型
-
-
此外,查看同一章节中的以下食谱:
-
使用顶点漫反射照明渲染几何体
-
使用片段镜面照明渲染几何体
-
使用立方体贴图绘制反射和折射几何体
在现实生活中,透明物体既会传播光线,也会反射光线。如果从高角度观察物体的表面,我们会看到更多的反射光。如果更直接地观察物体的表面,我们会看到更多的光线通过物体传播。模拟这种效果可能会产生非常逼真的结果。在本配方中,我们将了解如何渲染既具有折射性又具有反射性的几何体。
使用此配方生成的图像示例如下:

准备中
立方体贴图是覆盖立方体六个面的图像纹理。它们通常存储从给定位置观察到的场景视图。立方体贴图最常见的使用是天空盒。当我们要在给定模型的表面上映射反射时,它们也非常方便。另一个常见用途是模拟透明物体(即玻璃制成的物体),这些物体可以折射光线。非常低分辨率的立方体贴图(例如 4x4 像素)甚至可以直接用于环境光照。
立方体贴图包含六个二维图像。它们都是正方形,并且大小相同。在 Vulkan 中,使用具有六个数组层的 2D 图像创建立方体贴图,为这些数组层创建一个立方体贴图图像视图。通过它,六个数组层被解释为以下顺序的立方体贴图面:+X、-X、+Y、-Y、+Z、-Z。

图片由 Emil Persson 提供(h t t p 😕/w w w . h u m u s . n a m e)
立方体贴图的六个面对应于六个方向,就像我们站在一个位置,转身,并拍摄周围的世界的照片一样。使用这种纹理,我们可以模拟世界从物体表面反射或通过物体传播。然而,当物体移动得太远,远离纹理创建的位置时,这种幻觉就会破裂,直到我们应用一个新的、适用于新位置的纹理。
如何操作...
-
按照使用顶点漫反射光照渲染几何体配方中描述的步骤准备 Vulkan 资源。
-
从文件中加载包含顶点位置和法向量的 3D 模型数据。此模型将显示为反射和传播环境的模型(参考第十章的从 OBJ 文件加载 3D 模型配方,辅助配方)。
-
使用内存对象创建一个(顶点)缓冲区,并使用它存储我们模型的顶点数据(参考第四章的创建缓冲区、分配和绑定内存对象到缓冲区以及使用阶段缓冲区更新绑定到设备本地内存的缓冲区配方,资源和内存)。
-
加载包含立方体顶点位置的 3D 模型。此模型将用于显示被反射的环境(参考第十二章的绘制天空盒配方,高级渲染技术)。
-
创建一个缓冲区,以及一个与其绑定的内存对象,以存储环境的顶点数据(天空盒)。
-
创建一个具有六个数组层和一个立方体贴图视图的两维组合图像采样器。它必须支持
VK_IMAGE_USAGE_SAMPLED_BIT和VK_IMAGE_USAGE_TRANSFER_DST_BIT使用。必须对所有寻址维度使用VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE采样器寻址模式(参考第五章中的创建组合图像采样器配方,描述符集)。 -
从文件中加载立方体贴图的六个面的纹理数据(参考第十章中的从文件加载纹理数据配方,辅助配方)。
-
将每个加载的纹理上传到创建的组合图像采样器的单独数组层。纹理应按以下顺序上传:正负 X、正负 Y、正负 Z(参考第四章中的使用阶段缓冲区更新具有设备本地内存绑定的图像配方,资源和内存)。
-
创建一个包含两个描述符资源的描述符集布局:一个在顶点着色器中以 0^(th)绑定访问的统一缓冲区,以及一个在片段着色器中以 1^(st)绑定访问的组合图像采样器(参考第五章中的创建描述符集布局配方,描述符集)。
-
创建一个描述符池,从中可以分配一个统一缓冲区描述符和一个组合图像采样器描述符(参考第五章中的创建描述符池配方,描述符集)。
-
使用描述符集布局和统一缓冲区以及组合图像采样器资源从创建的池中分配一个描述符集(参考第五章中的分配描述符集配方,描述符集)。
-
使用在 0^(th)绑定访问的统一缓冲区和在 1^(st)绑定访问的已创建的组合图像采样器(立方体贴图)更新(填充)描述符集。将
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL值作为立方体贴图的布局(参考第五章中的更新描述符集配方,描述符集)。 -
使用准备好的描述符集布局创建一个管线布局,该布局还指定了一个由片段着色器阶段访问的单个推送常量范围,从 0^(th)偏移开始,大小为
4 * sizeof( float )(参考第八章中的创建管线布局配方,图形和计算管线)。 -
创建一个用于绘制反射和折射模型的图形管道。首先,使用以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于顶点着色器阶段的着色器模块(参考第七章 将 GLSL 着色器转换为 SPIR-V 汇编 的配方,着色器 以及第八章 图形和计算管道 中的 创建着色器模块 配方):
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec3 app_normal;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( location = 0 ) out vec3 vert_position;
layout( location = 1 ) out vec3 vert_normal;
void main() {
vert_position = app_position.xyz;
vert_normal = app_normal;
gl_Position = ProjectionMatrix * ModelViewMatrix *
app_position;
}
- 使用以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于片段着色器阶段的着色器模块:
#version 450
layout( location = 0 ) in vec3 vert_position;
layout( location = 1 ) in vec3 vert_normal;
layout( set = 0, binding = 1 ) uniform samplerCube Cubemap;
layout( push_constant ) uniform LightParameters {
vec4 Position;
} Camera;
layout( location = 0 ) out vec4 frag_color;
void main() {
vec3 view_vector = vert_position - Camera.Position.xyz;
float angle = smoothstep( 0.3, 0.7, dot( normalize( -
view_vector ), vert_normal ) );
vec3 reflect_vector = reflect( view_vector, vert_normal );
vec4 reflect_color = texture( Cubemap, reflect_vector );
vec3 refrac_vector = refract( view_vector, vert_normal, 0.3 );
vec4 refract_color = texture( Cubemap, refrac_vector );
frag_color = mix( reflect_color, refract_color, angle );
}
-
使用顶点和片段着色器指定管道着色器阶段,两者都使用各自着色器模块中的
main函数(参考第八章 图形和计算管道 中的 指定管道着色器阶段 配方)。 -
使用前面定义的管道着色器阶段创建一个用于绘制模型的图形管道,其余管道参数的设置方式与 使用顶点漫反射光照渲染几何体 配方中的方式相同。
-
创建一个用于绘制被反射的环境——天空盒的图形管道(参考第十二章 高级渲染技术 中的 绘制天空盒 配方)。
-
要渲染一帧,在每个渲染循环的迭代中记录一个命令缓冲区。在命令缓冲区中,将数据从阶段缓冲区复制到统一缓冲区,开始渲染过程,动态设置视口和裁剪测试状态,并绑定描述符集(参考 使用顶点漫反射光照渲染几何体 的配方)。
-
绑定用于反射/折射模型的图形管道和顶点缓冲区。
-
准备相机的位置,从该位置观察场景,并通过推送常量将其提供给着色器。为此操作,提供管道布局、
VK_SHADER_STAGE_FRAGMENT_BIT着色器阶段、0偏移量和sizeof( float ) * 4的大小,以及存储相机位置的数据的指针(参考第九章 通过推送常量向着色器提供数据 的配方,命令记录和绘图)。 -
绘制模型(参考第九章 绘制几何体 的配方,命令记录和绘图)。
-
绑定用于天空盒的图形管道和顶点缓冲区,并绘制它。
-
将其余所需操作记录到命令缓冲区中,将命令缓冲区提交到图形队列,并呈现一个图像(参考第九章 准备单个动画帧和通过增加单独渲染帧的数量来提高性能 的配方,命令记录和绘图)。
它是如何工作的...
我们从这个配方开始,加载和准备两个模型的缓冲区:第一个是模拟我们的主场景(反射/折射模型)的模型;第二个用于绘制环境本身(一个天空盒)。我们需要使用阶段缓冲区将顶点数据复制到两个顶点缓冲区中。
接下来,我们需要创建一个立方体贴图。我们通过创建一个组合图像采样器来完成此操作。图像必须是 2D 类型,必须具有六个数组层,并且必须支持VK_IMAGE_USAGE_SAMPLED_BIT和VK_IMAGE_USAGE_TRANSFER_DST_BIT用法。图像的格式取决于具体情况,但通常VK_FORMAT_R8G8B8A8_UNORM是一个不错的选择。创建的采样器必须对所有采样维度(u、v和w)使用VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE寻址模式,否则我们可能会看到所有立方体贴图面的边缘:
if( !CreateCombinedImageSampler( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, { 1024, 1024, 1 }, 1, 6,
VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, VK_IMAGE_VIEW_TYPE_CUBE, VK_IMAGE_ASPECT_COLOR_BIT, VK_FILTER_LINEAR,
VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 1.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK,
false, *CubemapSampler, *CubemapImage, *CubemapImageMemory, *CubemapImageView ) ) {
return false;
}
接下来,我们需要将数据上传到立方体贴图图像。在这个示例中,我们从六个单独的文件中加载数据并将其复制到图像的六个层中,如下所示:
std::vector<std::string> cubemap_images = {
"Data/Textures/Skansen/posx.jpg",
"Data/Textures/Skansen/negx.jpg",
"Data/Textures/Skansen/posy.jpg",
"Data/Textures/Skansen/negy.jpg",
"Data/Textures/Skansen/posz.jpg",
"Data/Textures/Skansen/negz.jpg"
};
for( size_t i = 0; i < cubemap_images.size(); ++i ) {
std::vector<unsigned char> cubemap_image_data;
int image_data_size;
if( !LoadTextureDataFromFile( cubemap_images[i].c_str(), 4, cubemap_image_data, nullptr, nullptr, nullptr, &image_data_size ) ) {
return false;
}
VkImageSubresourceLayers image_subresource = {
VK_IMAGE_ASPECT_COLOR_BIT,
0,
static_cast<uint32_t>(i),
1
};
UseStagingBufferToUpdateImageWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, image_data_size, &cubemap_image_data[0],
*CubemapImage, image_subresource, { 0, 0, 0 }, { 1024, 1024, 1 }, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
0, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} );
}
我们还需要一个描述符集,通过它片段着色器能够访问立方体贴图。为了分配描述符集,需要其布局:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, descriptor_set_layout_bindings, *DescriptorSetLayout ) ) {
return false;
}
描述符集是从池中分配的。因此现在我们创建一个,并分配描述符集本身:
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
},
{
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
与描述符资源相关联的最后一步是更新创建的集,包括在着色器中应访问的资源句柄:
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
ImageDescriptorInfo image_descriptor_update = {
DescriptorSets[0],
1,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
*CubemapSampler,
*CubemapImageView,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
};
UpdateDescriptorSets( *LogicalDevice, { image_descriptor_update }, { buffer_descriptor_update }, {}, {} );
在描述符集之后,是时候创建渲染通道和图形管线了,或者说两个管线:一个用于绘制模型,另一个用于绘制环境(一个天空盒)。用于模型的图形管线与使用顶点漫反射光照渲染几何体配方中创建的图形管线非常相似,除了它使用不同的着色器程序和推送常量范围,因此我们需要在管线布局创建时包含它:
std::vector<VkPushConstantRange> push_constant_ranges = {
{
VK_SHADER_STAGE_FRAGMENT_BIT,
0,
sizeof( float ) * 4
}
};
InitVkDestroyer( LogicalDevice, PipelineLayout );
if( !CreatePipelineLayout( *LogicalDevice, { *DescriptorSetLayout }, push_constant_ranges, *PipelineLayout ) ) {
return false;
}
顶点着色器,像往常一样,计算顶点的裁剪空间位置,并将未修改的位置和法线向量传递到片段着色器:
vert_position = app_position.xyz;
vert_normal = app_normal;
gl_Position = ProjectionMatrix * ModelViewMatrix * app_position;
在世界空间中计算反射或折射是最容易的,我们应该将这两个向量转换到这个坐标系。然而,为了简化配方,上面的顶点着色器假设模型已经以世界空间提供,这就是为什么未修改的向量(位置和法线)被传递到片段着色器。然后它使用这些向量并使用内置的reflect()和refract()函数来计算反射和折射向量。计算出的向量用于从立方体贴图中读取值。然后根据观察角度将它们混合在一起:
vec3 view_vector = vert_position - Camera.Position.xyz;
float angle = smoothstep( 0.3, 0.7, dot( normalize( -view_vector ), vert_normal ) );
vec3 reflect_vector = reflect( view_vector, vert_normal );
vec4 reflect_color = texture( Cubemap, reflect_vector );
vec3 refrac_vector = refract( view_vector, vert_normal, 0.3 );
vec4 refract_color = texture( Cubemap, refrac_vector );
frag_color = mix( reflect_color, refract_color, angle );
至于创建用于天空盒渲染的图形管线,第十二章中有一个专门的绘制天空盒配方,高级渲染技术。
最后我们应该关注的是命令缓冲区记录。在这里我们渲染两个对象,而不是一个,因此首先我们需要设置一个适当的州,以便正确绘制模型:
BindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *PipelineLayout, 0, DescriptorSets, {} );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *ModelPipeline );
BindVertexBuffers( command_buffer, 0, { { *ModelVertexBuffer, 0 } } );
ProvideDataToShadersThroughPushConstants( command_buffer, *PipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof( float ) * 4, &Camera.GetPosition()[0] );
for( size_t i = 0; i < Model.Parts.size(); ++i ) {
DrawGeometry( command_buffer, Model.Parts[i].VertexCount, 1, Model.Parts[i].VertexOffset, 0 );
}
在前面的代码之后,我们立即渲染天空盒:
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *SkyboxPipeline );
BindVertexBuffers( command_buffer, 0, { { *SkyboxVertexBuffer, 0 } } );
for( size_t i = 0; i < Skybox.Parts.size(); ++i ) {
DrawGeometry( command_buffer, Skybox.Parts[i].VertexCount, 1, Skybox.Parts[i].VertexOffset, 0 );
}
当然,我们不需要渲染环境--反射(和折射)存储在纹理中。然而,通常我们还想看到环境被反射,而不仅仅是反射。
本配方中所有知识的综合,加上使用顶点漫反射灯光渲染几何体配方,应该生成以下图像中看到的结果:

参见
-
在第五章,描述符集中,配方创建一个组合图像采样器
-
在第九章,命令记录与绘制中,配方通过推送常量向着色器提供数据
-
在第十章,辅助配方中,查看以下配方:
-
从文件加载纹理数据
-
从 OBJ 文件加载 3D 模型
-
-
本章中的配方使用顶点漫反射灯光渲染几何体
向场景添加阴影
灯光是 3D 应用程序执行的最重要操作之一。不幸的是,由于图形库和图形硬件本身的特性,灯光计算有一个主要的缺点--它们没有所有绘制对象位置的信息。这就是为什么生成阴影需要特殊的方法和高级渲染算法。
针对高效生成自然外观阴影,存在几种流行的技术。现在我们将学习一种称为阴影映射的技术。
使用此配方生成的图像示例如下:

准备工作
阴影映射技术要求我们渲染场景两次。首先,我们渲染投射阴影的对象。它们从灯光的视角进行渲染。这样我们就在深度附加中存储深度值(不需要颜色值)。
然后,在第二步中,我们以通常的方式渲染场景,从摄像机的视角。在着色器内部,我们使用第一步中生成的阴影映射。顶点位置被投影到阴影映射上,并与其与灯光位置的距离与从阴影映射中读取的值进行比较。如果它更大,这意味着给定点被阴影覆盖,否则它被正常照亮。
如何操作...
-
按照配方使用顶点漫反射灯光渲染几何体中描述的步骤准备 Vulkan 资源。
-
加载带有顶点位置和法向量的 3D 模型。将加载的数据存储在(顶点)缓冲区中。
-
创建一个具有
VK_BUFFER_USAGE_TRANSFER_DST_BIT和VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT用法的统一缓冲区,其大小足以容纳三个 16 元素浮点值矩阵的数据(参考第五章,描述符集中的创建统一缓冲区配方)。 -
创建一个支持
VK_BUFFER_USAGE_TRANSFER_SRC_BIT使用的暂存缓冲区,该缓冲区能够容纳三个矩阵,每个矩阵有 16 个浮点元素。缓冲区的内存对象应在主机可见的内存上分配(参考第四章的创建缓冲区和分配以及将内存对象绑定到缓冲区配方[f1332ca0-b5a2-49bd-ac41-e37068e31042.xhtml],资源和内存)。 -
创建一个应作为阴影贴图使用的组合图像采样器。图像应为二维的,并支持一种支持的深度格式(
VK_FORMAT_D16_UNORM必须始终支持),并且应支持VK_IMAGE_USAGE_SAMPLED_BIT和VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT使用(参考第五章的创建组合图像采样器配方[fe2cb528-9d22-49db-a05b-372bce2f87ee.xhtml],描述符集)。 -
创建一个包含两个描述符资源的描述符集布局:一个在顶点着色器中以 0^(th)绑定访问的统一缓冲区,以及一个在片段着色器中以 1^(st)绑定访问的组合图像采样器(参考第五章的创建描述符集布局配方[fe2cb528-9d22-49db-a05b-372bce2f87ee.xhtml],描述符集)。
-
创建一个描述符池,从中可以分配一个统一缓冲区描述符和一个组合图像采样器描述符(参考第五章的创建描述符池配方[fe2cb528-9d22-49db-a05b-372bce2f87ee.xhtml],描述符集)。
-
从创建的池中使用具有统一缓冲区和组合图像采样器资源(参考第五章的分配描述符集配方[fe2cb528-9d22-49db-a05b-372bce2f87ee.xhtml],描述符集)的描述符集布局。
-
使用在 0^(th)绑定访问的统一缓冲区和在 1^(st)绑定访问的创建的组合图像采样器(阴影贴图)更新(填充)描述符集。提供
VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL值作为图像的布局(参考第五章的更新描述符集配方[fe2cb528-9d22-49db-a05b-372bce2f87ee.xhtml],描述符集)。 -
准备用于将整个场景绘制到阴影贴图中的渲染通道的数据。此渲染通道应只有一个附件,其格式与组合图像采样器的格式相同。图像在加载时应该清除,其初始布局可能未定义。图像内容应在渲染通道结束时存储,最终布局应设置为
VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL(参考第六章的指定附件描述配方[2de4339d-8912-440a-89a6-fd1f84961448.xhtml],渲染通道和帧缓冲区)。 -
用于生成阴影图的渲染通道应只有一个子通道,并且只有一个深度附件,应使用帧缓冲区的 0^(th) 附件,其布局为
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL(参考 第六章,渲染通道和帧缓冲区中的 指定子通道描述 菜谱)。 -
为渲染通道指定两个子通道依赖项(参考 第六章,渲染通道和帧缓冲区中的 指定子通道之间的依赖项 菜谱)。对于第一个依赖项使用以下值:
-
VK_SUBPASS_EXTERNAL对应于srcSubpass的值 -
0对应于dstSubpass -
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT对应于srcStageMask的值 -
VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT对应于dstStageMask的值 -
VK_ACCESS_SHADER_READ_BIT对应于srcAccessMask的值 -
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT对应于dstAccessMask的值 -
VK_DEPENDENCY_BY_REGION_BIT对应于dependencyFlags的值
-
-
使用以下值设置第二个渲染通道的依赖项:
-
0对应于srcSubpass -
VK_SUBPASS_EXTERNAL对应于dstSubpass的值 -
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT对应于srcStageMask的值 -
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT对应于dstStageMask的值 -
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT对应于srcAccessMask的值 -
VK_ACCESS_SHADER_READ_BIT对应于dstAccessMask的值 -
VK_DEPENDENCY_BY_REGION_BIT对应于dependencyFlags的值
-
-
使用上述参数创建一个渲染通道(参考 第六章,渲染通道和帧缓冲区中的 创建渲染通道 菜谱)。
-
创建一个与创建的渲染通道兼容的帧缓冲区。帧缓冲区应有一个附件,应使用与阴影图组合图像采样器一起创建的图像视图。帧缓冲区还应具有与阴影图图像相同的尺寸(参考 第六章,渲染通道和帧缓冲区中的 创建帧缓冲区 菜谱)。
-
创建一个用于将场景正常绘制到交换链中的第二个渲染通道(参考 使用顶点漫反射光照渲染几何体 菜谱)。
-
使用准备好的描述符集布局创建一个管道布局。同时,指定一个由顶点着色器阶段访问的单个推送常量范围,从 0^(th) 偏移开始,大小为
4 * sizeof( float )(参考 第八章,图形和计算管道中的 创建管道布局 菜谱)。 -
创建一个用于将场景绘制到阴影图中的图形管线。首先,使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于顶点着色器阶段的着色器模块(参考第七章中将 GLSL 着色器转换为 SPIR-V 汇编的配方,第七章,着色器和创建着色器模块的配方,第八章,图形和计算管线):
#version 450
layout( location = 0 ) in vec4 app_position;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ShadowModelViewMatrix;
mat4 SceneModelViewMatrix;
mat4 ProjectionMatrix;
};
void main() {
gl_Position = ProjectionMatrix * ShadowModelViewMatrix *
app_position;
}
-
仅使用顶点着色器指定管线着色器阶段,它使用已准备的着色器模块中的
main函数(参考第八章中指定管线着色器阶段的配方,第八章,图形和计算管线)。 -
指定一个具有从 0^(th)绑定读取的一个属性的管线顶点输入状态。绑定应使用每个顶点读取的数据和等于
6 * sizeof( float )的步长创建(参考第八章中指定管线顶点输入状态的配方,第八章,图形和计算管线)。该属性应具有以下参数:-
location的值为0 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为0
-
-
使用一个与阴影图大小匹配的视口和裁剪测试参数指定视口和裁剪测试参数,参考第八章中指定管线视口和裁剪测试状态的配方(第八章,图形和计算管线)。
-
使用之前指定的参数创建一个图形管线。跳过混合状态,因为用于阴影图生成的渲染通道没有任何颜色附件(尽管必须启用光栅化,否则不会生成片段,并且它们的深度不会存储在阴影图中)。另外,不要使用动态状态,因为阴影图的大小不会改变(参考第八章中创建图形管线的配方,第八章,图形和计算管线)。
-
创建另一个用于渲染阴影场景的图形管线。这次,使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于顶点着色器阶段的着色器模块:
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec3 app_normal;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ShadowModelViewMatrix;
mat4 SceneModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( push_constant ) uniform LightParameters {
vec4 Position;
} Light;
layout( location = 0 ) out vec3 vert_normal;
layout( location = 1 ) out vec4 vert_texcoords;
layout( location = 2 ) out vec3 vert_light;
const mat4 bias = mat4(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.5, 0.5, 0.0, 1.0 );
void main() {
gl_Position = ProjectionMatrix * SceneModelViewMatrix *
app_position;
vert_normal = mat3( SceneModelViewMatrix ) * app_normal;
vert_texcoords = bias * ProjectionMatrix *
ShadowModelViewMatrix * app_position;
vert_light = (SceneModelViewMatrix * vec4( Light.Position.xyz,
0.0 ) ).xyz;
}
- 使用从以下 GLSL 代码生成的 SPIR-V 汇编创建一个用于片段着色器阶段的着色器模块:
#version 450
layout( location = 0 ) in vec3 vert_normal;
layout( location = 1 ) in vec4 vert_texcoords;
layout( location = 2 ) in vec3 vert_light;
layout( set = 0, binding = 1 ) uniform sampler2D ShadowMap;
layout( location = 0 ) out vec4 frag_color;
void main() {
float shadow = 1.0;
vec4 shadow_coords = vert_texcoords / vert_texcoords.w;
if( texture( ShadowMap, shadow_coords.xy ).r < shadow_coords.z - 0.005 ) {
shadow = 0.5;
}
vec3 normal_vector = normalize( vert_normal );
vec3 light_vector = normalize( vert_light );
float diffuse_term = max( 0.0, dot( normal_vector, light_vector ) );
frag_color = shadow * vec4( diffuse_term ) + 0.1;
}
-
使用顶点和片段着色器指定管线着色器阶段,两者都使用相应着色器模块中的
main函数。 -
指定一个具有从同一 0^(th)绑定读取的两个属性的管线顶点输入状态。绑定应使用每个顶点读取的数据和等于
6 * sizeof( float )的步长创建。第一个属性应具有以下参数:-
location的值为0 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为0
-
-
第二个属性应具有以下定义:
-
location的值为1 -
binding的值为0 -
format的值为VK_FORMAT_R32G32B32_SFLOAT -
offset的值为3 * sizeof( float )
-
-
使用上述着色阶段和两个属性创建一个用于渲染阴影场景的图形管线,其余参数与使用顶点漫反射光照渲染几何体配方中定义的类似。
-
准备一个视图矩阵,这可以是旋转、缩放和平移矩阵的乘积,用于从光的角度绘制场景(参考第十章的准备平移矩阵、准备旋转矩阵和准备缩放矩阵配方,辅助配方)。将连接矩阵的内容复制到偏移量为
0的暂存缓冲区(参考第四章的映射、更新和取消映射主机可见内存配方,资源和内存)。 -
准备一个用于从相机视角正常绘制场景的视图矩阵。将此矩阵的内容复制到偏移量为
16 * sizeof( float )的暂存缓冲区。 -
根据交换链尺寸的纵横比准备一个透视投影矩阵(参考第十章的准备透视投影矩阵配方,辅助配方)。将矩阵的内容复制到偏移量为
32 * sizeof( float )的暂存缓冲区。记住,每次应用程序窗口大小调整时,都要重新创建投影矩阵并将其复制到暂存缓冲区(参考第四章的映射、更新和取消映射主机可见内存配方,资源和内存)。 -
在动画的每一帧中,记录一个命令缓冲区。首先检查视图或投影矩阵是否被修改:如果被修改,则将暂存缓冲区的内容复制到统一缓冲区,由适当的管线屏障保护(参考第四章的在缓冲区之间复制数据配方,资源和内存)。
-
从光的角度绘制场景到阴影图的渲染通道开始。绑定顶点缓冲区、描述符集以及用于填充阴影图的管线。绘制几何体并结束渲染通道。
-
如果需要,转让获取的交换链图像的所有权。动态设置视口和裁剪测试状态,绑定用于渲染阴影场景的图形管线,并再次绘制几何体。结束命令缓冲区记录,将命令缓冲区提交到队列,并呈现图像。
它是如何工作的...
我们首先创建一个组合图像采样器,其中将存储来自光的角度的深度信息:
if( !CreateCombinedImageSampler( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, DepthFormat, { 512, 512, 1 }, 1, 1,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_VIEW_TYPE_2D, VK_IMAGE_ASPECT_DEPTH_BIT, VK_FILTER_LINEAR,
VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 1.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK,
false, *ShadowMapSampler, *ShadowMap.Image, *ShadowMap.Memory, *ShadowMap.View ) ) {
return false;
}
组合图像采样器,连同统一缓冲区,将在着色器中被访问,因此我们需要一个描述符集,通过它着色器可以访问两者。尽管我们使用两个不同的管线渲染场景两次,但我们仍然可以使用一个描述符集来避免不必要的状态切换:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, descriptor_set_layout_bindings, *DescriptorSetLayout ) ) {
return false;
}
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
},
{
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
我们还需要用统一缓冲区和组合图像采样器的句柄填充描述符集:
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
ImageDescriptorInfo image_descriptor_update = {
DescriptorSets[0],
1,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
*ShadowMapSampler,
*ShadowMap.View,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
}
}
};
UpdateDescriptorSets( *LogicalDevice, { image_descriptor_update }, { buffer_descriptor_update }, {}, {} );
下一步是为存储阴影图中的深度信息创建一个专门的渲染通道。它不使用任何颜色附件,因为我们只需要深度数据。我们还创建了一个帧缓冲区。它可以具有固定尺寸,因为我们不会改变阴影图的大小:
std::vector<VkAttachmentDescription> shadow_map_attachment_descriptions = {
{
0
DepthFormat,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
}
};
VkAttachmentReference shadow_map_depth_attachment = {
0,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
};
std::vector<SubpassParameters> shadow_map_subpass_parameters = {
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{},
{},
{},
&shadow_map_depth_attachment,
{}
}
};
std::vector<VkSubpassDependency> shadow_map_subpass_dependencies = {
{
VK_SUBPASS_EXTERNAL,
0,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT,
VK_ACCESS_SHADER_READ_BIT,
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
VK_DEPENDENCY_BY_REGION_BIT
},
{
0,
VK_SUBPASS_EXTERNAL,
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
VK_ACCESS_SHADER_READ_BIT,
VK_DEPENDENCY_BY_REGION_BIT
}
};
InitVkDestroyer( LogicalDevice, ShadowMapRenderPass );
if( !CreateRenderPass( *LogicalDevice, shadow_map_attachment_descriptions, shadow_map_subpass_parameters, shadow_map_subpass_dependencies,
*ShadowMapRenderPass ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, ShadowMap.Framebuffer );
if( !CreateFramebuffer( *LogicalDevice, *ShadowMapRenderPass, { *ShadowMap.View }, 512, 512, 1, *ShadowMap.Framebuffer ) ) {
return false;
}
接下来,我们创建两个图形管线。它们都使用相同的推送常量范围来降低变量的数量(尽管只有第二个管线在着色器中使用它):
std::vector<VkPushConstantRange> push_constant_ranges = {
{
VK_SHADER_STAGE_VERTEX_BIT,
0,
sizeof( float ) * 4
}
};
InitVkDestroyer( LogicalDevice, PipelineLayout );
if( !CreatePipelineLayout( *LogicalDevice, { *DescriptorSetLayout }, push_constant_ranges, *PipelineLayout ) ) {
return false;
}
第一个管线用于生成阴影图。它使用非常简单的着色器,只读取顶点位置,并从光源的角度渲染场景。
第二个管线将场景正常渲染到交换链图像中。其着色器更复杂。顶点着色器正常计算位置,但还将法线向量和光向量转换为视图空间,以进行正确的光照计算:
vert_normal = mat3( SceneModelViewMatrix ) * app_normal;
vert_light = (SceneModelViewMatrix * vec4( Light.Position.xyz, 0.0 ) ).xyz;
顶点着色器最重要的任务是计算顶点在光源视图空间中的位置。为此,我们将其乘以光源的模型视图和投影矩阵(透视除法在片段着色器中完成)。获得的结果用于从阴影图中获取数据。然而,计算出的位置值(在透视除法之后)位于-1.0 - 1.0范围内,使用归一化纹理坐标从纹理中读取数据需要提供0.0 - 1.0范围内的值。这就是为什么我们需要偏置结果:
vert_texcoords = bias * ProjectionMatrix * ShadowModelViewMatrix * app_position;
这样,片段着色器可以将插值后的位置投影到阴影图上,并从适当的坐标读取值:
float shadow = 1.0;
vec4 shadow_coords = vert_texcoords / vert_texcoords.w;
if( texture( ShadowMap, shadow_coords.xy ).r < shadow_coords.z - 0.005 ) {
shadow = 0.5;
}
从阴影图中读取的值与点与光源位置的距离(偏移一个小值)进行比较。如果距离大于阴影图中存储的值,则该点位于阴影中,不应该被照亮。我们需要添加这个小偏移,这样物体的表面就不会对自己产生阴影(只对更远的部分产生阴影)。我们也不完全丢弃光照,以避免阴影过于黑暗,因此将0.5分配给阴影变量。
上述计算可以使用textureProj()和sampler2DShadow执行。这样,透视除法、偏移距离并将其与参考值比较是自动完成的。
在本食谱中创建的其他资源与使用顶点漫反射光照渲染几何体食谱中展示的资源类似。渲染/记录命令缓冲区需要我们,除了常规内容外,还要将场景渲染两次。首先,我们从光源的角度绘制所有对象以填充阴影图。然后,在从相机视角渲染所有对象的过程中使用阴影图:
BeginRenderPass( command_buffer, *ShadowMapRenderPass, *ShadowMap.Framebuffer, { { 0, 0, }, { 512, 512 } }, { { 1.0f, 0 } }, VK_SUBPASS_CONTENTS_INLINE );
BindVertexBuffers( command_buffer, 0, { { *VertexBuffer, 0 } } );
BindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *PipelineLayout, 0, DescriptorSets, {} );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *ShadowMapPipeline );
DrawGeometry( command_buffer, Scene[0].Parts[0].VertexCount + Scene[1].Parts[0].VertexCount, 1, 0, 0 );
EndRenderPass( command_buffer );
if( PresentQueue.FamilyIndex != GraphicsQueue.FamilyIndex ) {
ImageTransition image_transition_before_drawing = {
Swapchain.Images[swapchain_image_index],
VK_ACCESS_MEMORY_READ_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
PresentQueue.FamilyIndex,
GraphicsQueue.FamilyIndex,
VK_IMAGE_ASPECT_COLOR_BIT
};
SetImageMemoryBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, { image_transition_before_drawing } );
}
BeginRenderPass( command_buffer, *SceneRenderPass, framebuffer, { { 0, 0 }, Swapchain.Size }, { { 0.1f, 0.2f, 0.3f, 1.0f }, { 1.0f, 0 } }, VK_SUBPASS_CONTENTS_INLINE );
VkViewport viewport = {
0.0f,
0.0f,
static_cast<float>(Swapchain.Size.width),
static_cast<float>(Swapchain.Size.height),
0.0f,
1.0f,
};
SetViewportStateDynamically( command_buffer, 0, { viewport } );
VkRect2D scissor = {
{
0,
0
},
{
Swapchain.Size.width,
Swapchain.Size.height
}
};
SetScissorsStateDynamically( command_buffer, 0, { scissor } );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *ScenePipeline );
ProvideDataToShadersThroughPushConstants( command_buffer, *PipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof( float ) * 4, &LightSource.GetPosition()[0] );
DrawGeometry( command_buffer, Scene[0].Parts[0].VertexCount + Scene[1].Parts[0].VertexCount, 1, 0, 0 );
EndRenderPass( command_buffer );
以下图像显示了不同模型在平坦平面上投射的阴影:

参见
-
在第五章,“描述符集”,查看以下食谱:
- 创建组合图像采样器
-
在第六章,“渲染通道和帧缓冲区”,查看以下食谱:
-
创建渲染通道
-
创建帧缓冲区
-
-
本章中的食谱使用顶点漫反射光照渲染几何体
第十二章:高级渲染技术
在本章中,我们将涵盖以下配方:
-
绘制天空盒
-
使用几何着色器绘制广告牌
-
使用计算和图形管线绘制粒子
-
渲染细分地形
-
渲染全屏四边形进行后处理
-
使用输入附件进行颜色校正后处理效果
简介
创建 3D 应用程序,如游戏、基准测试或 CAD 工具,从渲染的角度来看,通常需要准备各种资源,包括网格或纹理、在场景中绘制多个对象,以及实现对象变换、光照计算和图像处理的算法。它们都可以以任何我们想要的方式开发,以最符合我们目的的方式开发。但 3D 图形行业中也有许多常用的技术。这些技术的描述可以在书籍和教程中找到,其中包含使用各种 3D 图形 API 实现的示例。
Vulkan 仍然是一个相对较新的图形 API,因此没有太多资源介绍使用 Vulkan API 实现的常见渲染算法。在本章中,我们将学习如何使用 Vulkan 准备各种图形技术。我们将了解从游戏和基准测试中找到的流行、高级渲染算法的重要概念,以及它们如何与 Vulkan 资源相匹配。
在本章中,我们将仅关注从给定配方角度重要的代码部分。未描述的资源(例如,命令池或渲染通道创建)将按常规创建(参考第十一章的使用顶点漫反射光照渲染几何体配方,光照)。
绘制天空盒
渲染 3D 场景,特别是具有广阔视距的开世界场景,需要绘制许多对象。然而,当前图形硬件的处理能力仍然过于有限,无法渲染我们每天所见到的那么多对象。因此,为了降低绘制对象的数量并绘制场景的背景,我们通常准备一张远距离对象的图像(或照片),并只绘制该图像。
在玩家可以自由移动和环顾四周的游戏中,我们不能只绘制一张图像。我们必须绘制所有方向的图像。这些图像形成一个立方体,放置背景图像的对象称为天空盒。我们以这种方式渲染,使其始终位于背景,在可用的最远深度值处。
准备工作
绘制天空盒需要准备一个立方体贴图。它包含六个方形图像,包含所有世界方向的视图(右、左、上、下、后、前),如下面的图像所示:

图片由 Emil Persson 提供(h t t p 😕/w w w . h u m u s . n a m e)
在 Vulkan 中,立方体贴图是为具有六个数组层(或六的倍数)的图像创建的特殊图像视图。层必须按照+X,-X,+Y,-Y,+Z,-Z的顺序包含图像。
立方体贴图不仅可以用于绘制天空盒。我们还可以使用它们来绘制反射或透明物体。它们还可以用于光照计算(参考第十一章中“使用立方体贴图绘制反射和折射几何体”的配方,光照)。
如何做到这一点...
-
从文件中加载一个立方体的 3D 模型,并将顶点数据存储在顶点缓冲区中。只需要顶点位置(参考第十章中“从 OBJ 文件加载 3D 模型”的配方 Chapter 10,辅助配方)。
-
创建一个具有正方形
VK_IMAGE_TYPE_2D图像的组合图像采样器,该图像具有六个数组层(或六的倍数),一个对所有坐标使用VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE寻址模式的采样器,以及一个VK_IMAGE_VIEW_TYPE_CUBE图像视图(参考第五章中“创建组合图像采样器”的配方 Chapter 5,描述符集)。 -
加载立方体的所有六个面的图像数据,并使用阶段缓冲区将其上传到图像的内存中。图像数据必须按照以下顺序上传到六个数组层:+X,-X,+Y,-Y,+Z,-Z(参考第十章中“从文件加载纹理数据”的配方 Chapter 10,辅助配方,以及第四章中“使用阶段缓冲区更新绑定到设备本地内存的图像”的配方 Chapter 4,资源和内存)。
-
在一个统一缓冲区中创建一个用于存储变换矩阵的缓冲区(参考第五章中“创建统一缓冲区”的配方 Chapter 5,描述符集)。
-
创建一个描述符集布局,其中统一缓冲区由顶点阶段访问,组合图像采样器由片段阶段访问。使用前面的布局分配一个描述符集。使用统一缓冲区和立方体贴图/组合图像采样器更新描述符集(参考第五章中“创建描述符集布局,分配描述符集”和“更新描述符集”的配方 Chapter 5,描述符集)。
-
创建一个着色器模块,其中顶点着色器由以下 GLSL 代码创建(参考第八章中“创建着色器模块配方”的配方 Chapter 8,图形和计算管线)。
#version 450
layout( location = 0 ) in vec4 app_position;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( location = 0 ) out vec3 vert_texcoord;
void main() {
vec3 position = mat3(ModelViewMatrix) * app_position.xyz;
gl_Position = (ProjectionMatrix * vec4( position, 0.0 )).xyzz;
vert_texcoord = app_position.xyz;
}
- 创建一个着色器模块,其中片段着色器由以下 GLSL 代码创建:
#version 450
layout( location = 0 ) in vec3 vert_texcoord;
layout( set = 0, binding = 1 ) uniform samplerCube Cubemap;
layout( location = 0 ) out vec4 frag_color;
void main() {
frag_color = texture( Cubemap, vert_texcoord );
}
-
从前面的模块创建一个图形管线,使用顶点和片段着色器。该管线应使用一个具有三个组件(顶点位置)的顶点属性,并为光栅化状态的剔除模式使用
VK_CULL_MODE_FRONT_BIT值。混合应被禁用。管线布局应允许访问统一缓冲区和立方体贴图/复合图像采样器(参考第八章中的指定管线着色器阶段、指定管线顶点输入状态、指定管线光栅化状态、指定管线混合状态、创建管线布局、指定图形管线创建参数和创建图形管线配方,第八章,图形和计算管线)。 -
使用渲染几何体的其余部分绘制立方体(参考第五章中的绑定描述符集配方,第五章,描述符集,以及第八章中的绑定管线对象配方,第八章,图形和计算管线,以及第九章中的绑定顶点缓冲区和绘制几何体配方,第九章,命令记录和绘制)。
-
每当用户(摄像机)在场景中移动时,在统一缓冲区中更新模型视图矩阵。每当应用程序窗口大小调整时,在统一缓冲区中更新投影矩阵。
它是如何工作的...
要渲染天空盒,我们需要加载或准备一个形成立方体的几何体。只需要位置,因为它们也可以用作纹理坐标。
接下来,我们加载六个立方体贴图图像并创建一个包含立方体贴图视图的复合图像采样器:
InitVkDestroyer( LogicalDevice, CubemapImage );
InitVkDestroyer( LogicalDevice, CubemapImageMemory );
InitVkDestroyer( LogicalDevice, CubemapImageView );
InitVkDestroyer( LogicalDevice, CubemapSampler );
if( !CreateCombinedImageSampler( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, { 1024, 1024, 1 }, 1, 6,
VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, VK_IMAGE_VIEW_TYPE_CUBE, VK_IMAGE_ASPECT_COLOR_BIT, VK_FILTER_LINEAR,
VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 1.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK,
false, *CubemapSampler, *CubemapImage, *CubemapImageMemory, *CubemapImageView ) ) {
return false;
}
std::vector<std::string> cubemap_images = {
"Data/Textures/Skansen/posx.jpg",
"Data/Textures/Skansen/negx.jpg",
"Data/Textures/Skansen/posy.jpg",
"Data/Textures/Skansen/negy.jpg",
"Data/Textures/Skansen/posz.jpg",
"Data/Textures/Skansen/negz.jpg"
};
for( size_t i = 0; i < cubemap_images.size(); ++i ) {
std::vector<unsigned char> cubemap_image_data;
int image_data_size;
if( !LoadTextureDataFromFile( cubemap_images[i].c_str(), 4, cubemap_image_data, nullptr, nullptr, nullptr, &image_data_size ) ) {
return false;
}
VkImageSubresourceLayers image_subresource = {
VK_IMAGE_ASPECT_COLOR_BIT,
0,
static_cast<uint32_t>(i),
1
};
UseStagingBufferToUpdateImageWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, image_data_size, &cubemap_image_data[0],
*CubemapImage, image_subresource, { 0, 0, 0 }, { 1024, 1024, 1 }, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
0, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} );
}
创建的立方体贴图视图以及采样器随后通过描述符集提供给着色器。我们还需要一个统一缓冲区,其中将存储和访问着色器中的变换矩阵:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, descriptor_set_layout_bindings, *DescriptorSetLayout ) ) {
return false;
}
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
},
{
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
ImageDescriptorInfo image_descriptor_update = {
DescriptorSets[0],
1,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
*CubemapSampler,
*CubemapImageView,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
};
UpdateDescriptorSets( *LogicalDevice, { image_descriptor_update }, { buffer_descriptor_update }, {}, {} );
要绘制天空盒,我们不需要单独的、专门的渲染通道,因为我们可以在正常几何体上渲染它。更重要的是,为了节省处理能力(图像填充率),我们通常在(不透明的)几何体之后和透明物体之前绘制天空盒。它以这种方式渲染,使其顶点始终位于远裁剪平面。这样,它不会覆盖已经绘制的几何体,也不会被裁剪掉。这种效果是通过一个特殊的顶点着色器实现的。其最重要的部分是以下代码:
vec3 position = mat3(ModelViewMatrix) * app_position.xyz;
gl_Position = (ProjectionMatrix * vec4( position, 0.0 )).xyzz;
首先,我们将位置乘以一个模型视图矩阵。我们只取矩阵的旋转部分。玩家应该始终位于天空盒的中心,否则幻觉将被打破。这就是我们不希望移动天空盒的原因,我们只需要将其旋转作为玩家环顾四周的响应。
接下来,我们将顶点的视图空间位置乘以一个投影矩阵。结果存储在一个 4 元素向量中,最后两个分量相同,等于结果的 z 分量。在现代图形硬件中,透视投影是通过将位置向量除以其w分量来执行的。之后,所有x和y分量适合于<-1, 1>范围(包含)且z分量适合于<0, 1>范围(包含)的顶点都在裁剪体积内且是可见的(除非它们被其他东西遮挡)。因此,以使最后两个分量相等的方式来计算顶点位置,可以保证顶点将位于远裁剪平面。
除了顶点着色器和立方体贴图视图之外,天空盒只需要一种额外的特殊处理。我们需要记住多边形的朝向。通常,我们使用背面剔除来绘制几何体,因为我们想看到它的外部表面。对于天空盒,我们想要渲染其内部表面,因为我们是从内部看它的。这就是为什么,如果我们没有为天空盒特别准备网格,我们可能希望在天空盒渲染期间剔除前表面。我们可以这样准备管线光栅化信息:
VkPipelineRasterizationStateCreateInfo rasterization_state_create_info;
SpecifyPipelineRasterizationState( false, false, VK_POLYGON_MODE_FILL, VK_CULL_MODE_FRONT_BIT, VK_FRONT_FACE_COUNTER_CLOCKWISE, false, 0.0f, 1.0f, 0.0f, 1.0f, rasterization_state_create_info );
除了这些,图形管线是以通常的方式创建的。为了用它来绘制,我们需要绑定描述符集、顶点缓冲区和管线本身:
BindVertexBuffers( command_buffer, 0, { { *VertexBuffer, 0 } } );
BindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *PipelineLayout, 0, DescriptorSets, {} );
BindPipelineObject( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, *Pipeline );
for( size_t i = 0; i < Skybox.Parts.size(); ++i ) {
DrawGeometry( command_buffer, Skybox.Parts[i].VertexCount, 1, Skybox.Parts[i].VertexOffset, 0 );
}
以下图像是使用此配方生成的:

参见
-
在第五章,描述符集中,查看以下配方:
-
创建组合图像采样器
-
创建描述符集布局
-
分配描述符集
-
更新描述符集
-
绑定描述符集
-
-
在第八章,图形和计算管线中,查看以下配方:
-
创建着色器模块
-
指定管线着色器阶段
-
创建图形管线
-
绑定管线对象
-
-
在第九章,命令记录和绘制中,查看以下配方:
-
绑定顶点缓冲区
-
绘制几何体
-
-
在第十章,辅助配方中,查看以下配方:
-
从文件加载纹理数据
-
从 OBJ 文件加载 3D 模型
-
-
在第十一章,光照中,查看以下配方:
- 使用立方体贴图绘制反射和折射几何体
使用几何着色器绘制广告牌
在远处简化绘制的几何体是降低渲染整个场景所需处理能力的一种常见技术。可以绘制的最简单几何体是一个带有描绘物体外观的图像的平面四边形(或三角形)。为了使效果令人信服,四边形必须始终朝向相机:

始终面向摄像机的平面物体被称为 billboard。它们不仅用于作为几何体最低细节级别远距离物体,还用于粒子效果。
绘制 billboard 的一个直接技术是使用几何着色器。
如何操作...
-
使用启用
geometryShader功能的逻辑设备(参考第一章,实例和设备中的获取物理设备的特性和属性和创建逻辑设备配方)。 -
使用每个 billboard 一个顶点的方式为所有 billboard 准备位置。将它们存储在顶点缓冲区中(参考第四章,资源和内存中的创建缓冲区配方)。
-
为至少两个 4x4 变换矩阵创建一个统一缓冲区(参考第五章,描述符集中的创建统一缓冲区配方)。
-
如果 billboard 应该使用纹理,创建一个联合图像采样器并将从文件加载的纹理数据上传到图像内存中(参考第五章,描述符集中的创建联合图像采样器配方,以及第十章,辅助配方中的从文件加载纹理数据配方)。
-
为由顶点和几何阶段访问的统一缓冲区以及如果需要纹理的 billboard,由片段着色器阶段访问的联合图像采样器准备描述符集布局。创建一个描述符集,并使用创建的统一缓冲区和联合图像采样器更新它(参考第五章,描述符集中的创建描述符集布局、分配描述符集和更新描述符集配方)。
-
创建一个使用以下 GLSL 代码创建的顶点着色器的着色器模块(参考第八章,图形和计算管线中的创建着色器模块配方):
#version 450
layout( location = 0 ) in vec4 app_position;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( push_constant ) uniform TimeState {
float Time;
} PushConstant;
void main() {
gl_Position = ModelViewMatrix * app_position;
}
- 创建一个包含以下 GLSL 代码生成的几何着色器的着色器模块:
#version 450
layout( points ) in;
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( triangle_strip, max_vertices = 4 ) out;
layout( location = 0 ) out vec2 geom_texcoord;
const float SIZE = 0.1;
void main() {
vec4 position = gl_in[0].gl_Position;
gl_Position = ProjectionMatrix * (gl_in[0].gl_Position + vec4(
-SIZE, SIZE, 0.0, 0.0 ));
geom_texcoord = vec2( -1.0, 1.0 );
EmitVertex();
gl_Position = ProjectionMatrix * (gl_in[0].gl_Position + vec4(
-SIZE, -SIZE, 0.0, 0.0 ));
geom_texcoord = vec2( -1.0, -1.0 );
EmitVertex();
gl_Position = ProjectionMatrix * (gl_in[0].gl_Position + vec4(
SIZE, SIZE, 0.0, 0.0 ));
geom_texcoord = vec2( 1.0, 1.0 );
EmitVertex();
gl_Position = ProjectionMatrix * (gl_in[0].gl_Position + vec4(
SIZE, -SIZE, 0.0, 0.0 ));
geom_texcoord = vec2( 1.0, -1.0 );
EmitVertex();
EndPrimitive();
}
- 创建一个使用以下 GLSL 代码生成的 SPIR-V 汇编的片段着色器的着色器模块:
#version 450
layout( location = 0 ) in vec2 geom_texcoord;
layout( location = 0 ) out vec4 frag_color;
void main() {
float alpha = 1.0 - dot( geom_texcoord, geom_texcoord );
if( 0.2 > alpha ) {
discard;
}
frag_color = vec4( alpha );
}
-
创建一个图形管线。它必须使用前面的顶点、几何和片段着色器模块。只需要一个顶点属性(一个位置)。它将用于使用
VK_PRIMITIVE_TOPOLOGY_POINT_LIST原语绘制几何图形。管线应该能够访问包含变换矩阵的统一缓冲区以及(如果需要)一个组合图像纹理(参考第八章,图形和计算管线中的指定管线着色器阶段、指定管线顶点输入状态、指定管线输入装配状态、创建管线布局、指定图形管线创建参数和创建图形管线配方)。 -
在渲染通道内绘制几何图形(参考第五章,描述符集中的绑定描述符集配方,到第八章,图形和计算管线中的绑定管线对象配方,以及到第九章,命令记录和绘制中的绑定顶点缓冲区和绘制几何图形配方)。
-
每当用户(一个摄像机)在场景中移动时,在统一缓冲区中更新模型视图矩阵。每当应用程序窗口大小调整时,在统一缓冲区中更新投影矩阵。
它是如何工作的...
首先,我们开始准备广告牌的位置。广告牌作为点原语绘制,因此一个顶点对应一个广告牌。我们如何准备几何图形由我们自己决定,我们不需要其他属性。一个几何着色器将单个顶点转换为一个面向摄像机的四边形并计算纹理坐标。
在这个例子中,我们不使用纹理,但我们将使用纹理坐标来绘制圆形。我们只需要访问存储在如下生成的统一缓冲区中的变换矩阵:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_GEOMETRY_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, descriptor_set_layout_bindings, *DescriptorSetLayout ) ) {
return false;
}
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
UpdateDescriptorSets( *LogicalDevice, {}, { buffer_descriptor_update }, {}, {} );
下一步是创建一个图形管线。它使用以下方式定义的单个顶点属性(一个位置):
std::vector<VkVertexInputBindingDescription> vertex_input_binding_descriptions = {
{
0,
3 * sizeof( float ),
VK_VERTEX_INPUT_RATE_VERTEX
}
};
std::vector<VkVertexInputAttributeDescription> vertex_attribute_descriptions = {
{
0,
0,
VK_FORMAT_R32G32B32_SFLOAT,
0
}
};
VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info;
SpecifyPipelineVertexInputState( vertex_input_binding_descriptions, vertex_attribute_descriptions, vertex_input_state_create_info );
我们以点绘制顶点,因此在创建管线时需要指定一个合适的原语类型:
VkPipelineInputAssemblyStateCreateInfo input_assembly_state_create_info;
SpecifyPipelineInputAssemblyState( VK_PRIMITIVE_TOPOLOGY_POINT_LIST, false, input_assembly_state_create_info );
管线参数的其余部分相当典型。最重要的部分是着色器。
顶点着色器将顶点从局部空间转换到视图空间。广告牌必须始终面向摄像机,因此在视图空间中直接进行计算更容易。
几何着色器几乎完成所有工作。它取一个顶点(一个点)并发出一个由四个顶点组成的三角形带(一个四边形)。每个新顶点稍微向左/右和上/下偏移,以形成一个四边形:

此外,根据方向/偏移量,为生成的顶点分配一个纹理坐标。在我们的例子中,第一个顶点准备如下:
vec4 position = gl_in[0].gl_Position;
gl_Position = ProjectionMatrix * (gl_in[0].gl_Position + vec4( -SIZE, SIZE, 0.0, 0.0 ));
geom_texcoord = vec2( -1.0, 1.0 );
EmitVertex();
剩余的顶点以类似的方式发出。由于我们在顶点着色器中将顶点转换到视图空间,生成的四边形始终面向屏幕平面。我们所需做的只是将生成的顶点乘以一个投影矩阵,将它们转换到裁剪空间。
使用片段着色器丢弃一些片段以形成从四边形到圆形:
float alpha = 1.0 - dot( geom_texcoord, geom_texcoord );
if( 0.2 > alpha ) {
discard;
}
在以下示例中,我们可以看到在网格顶点位置渲染的布告板。图像中看到的圆是平的;它们不是球体:

参见
-
在第一章,实例和设备中,查看以下配方:
-
获取物理设备的特性和属性
-
创建逻辑设备
-
-
在第五章,描述符集中,查看以下配方:
-
创建统一缓冲区
-
创建描述符集布局
-
分配描述符集
-
更新描述符集
-
绑定描述符集
-
-
在第七章,着色器中,查看编写几何着色器配方
-
在第八章,图形和计算管道中,查看以下配方:
-
创建着色器模块
-
指定管道着色器阶段
-
指定管道顶点绑定描述、属性描述和输入状态
-
指定管道输入装配状态
-
创建管道布局
-
指定图形管道创建参数
-
创建图形管道
-
绑定管道对象
-
-
在第九章,命令录制和绘制中,查看以下配方:
-
绑定顶点缓冲区
-
绘制几何配方
-
使用计算和图形管道绘制粒子
由于图形硬件的性质以及图形管道处理对象的方式,显示诸如云、烟、火花、火、下落的雨和雪等现象相当困难。这些效果通常使用粒子系统来模拟,粒子系统是一大批根据系统实现算法行为的小精灵。
由于独立实体数量非常大,使用计算着色器实现粒子的行为和相互交互很方便。模仿每个粒子外观的精灵通常使用几何着色器显示为布告板。
在以下示例中,我们可以看到使用此配方生成的图像:

如何做到...
-
创建一个启用
geometryShader特性的逻辑设备。请求一个支持图形操作的队列和一个支持计算操作的队列(参考第一章,实例和设备中的获取物理设备的特性和属性和创建逻辑设备配方)。 -
为粒子系统生成初始数据(属性)。
-
创建一个既作为顶点缓冲区也作为存储纹理缓冲区的缓冲区。将生成的粒子数据复制到缓冲区中(参考第五章,描述符集中的创建存储纹理缓冲区做法,以及第四章,资源和内存中的使用阶段缓冲区更新设备本地内存绑定的缓冲区做法)。
-
创建一个用于两个变换矩阵的统一缓冲区。每次相机移动或窗口调整大小时更新它(参考第五章,创建统一缓冲区的做法)。
-
创建两个描述符集布局:一个由顶点和几何阶段访问的统一缓冲区;另一个由计算阶段访问的存储纹理缓冲区。创建一个描述符池,并使用上述布局分配两个描述符集。使用统一缓冲区和存储纹理缓冲区更新它们(参考第五章,创建描述符集布局,创建描述符池,分配描述符集和更新描述符集,这些做法)。
-
创建一个包含以下 GLSL 代码的计算着色器的着色器模块(参考第八章,创建着色器模块的做法)。
#version 450
layout( local_size_x = 32, local_size_y = 32 ) in;
layout( set = 0, binding = 0, rgba32f ) uniform imageBuffer
StorageTexelBuffer;
layout( push_constant ) uniform TimeState {
float DeltaTime;
} PushConstant;
const uint PARTICLES_COUNT = 2000;
void main() {
if( gl_GlobalInvocationID.x < PARTICLES_COUNT ) {
vec4 position = imageLoad( StorageTexelBuffer,
int(gl_GlobalInvocationID.x * 2) );
vec4 color = imageLoad( StorageTexelBuffer,
int(gl_GlobalInvocationID.x * 2 + 1) );
vec3 speed = normalize( cross( vec3( 0.0, 1.0, 0.0 ),
position.xyz ) ) * color.w;
position.xyz += speed * PushConstant.DeltaTime;
imageStore( StorageTexelBuffer, int(gl_GlobalInvocationID.x
*
2), position );
}
}
-
创建一个使用包含计算着色器的着色器模块的计算管线,它具有访问存储纹理缓冲区和包含一个浮点值的推送常量范围的权限(参考第八章,图形和计算管线中的指定管线着色器阶段,创建管线布局做法和创建计算管线的做法)。
-
创建一个图形管线,其中包含顶点、几何和片段着色器,如使用几何着色器绘制广告牌的做法所述。图形管线必须获取两个顶点属性,以
VK_PRIMITIVE_TOPOLOGY_POINT_LIST原语绘制顶点,并且必须启用混合(参考第八章,图形和计算管线中的指定管线顶点输入状态,指定管线输入装配状态,指定管线混合状态和创建图形管线的做法)。 -
要渲染一帧,记录一个命令缓冲区,该缓冲区调度计算工作并将其提交到支持计算操作的队列中。提供一个信号量,当队列完成处理提交的命令缓冲区时触发该信号量(参考第九章,命令记录中的通过推送常量向着色器提供数据和调度计算工作配方,以及第三章,命令缓冲区和同步中的将命令缓冲区提交到队列*配方)。
-
此外,在每一帧中记录一个命令缓冲区,该缓冲区按照使用几何着色器绘制广告牌配方绘制广告牌。将其提交到支持图形操作的队列中。在提交过程中,提供一个信号量,该信号量由计算队列触发。将其作为等待信号量提供(参考第三章,命令缓冲区和同步中的同步两个命令缓冲区配方)。
它是如何工作的...
绘制粒子系统可以分为两个步骤:
-
我们使用计算着色器计算并更新所有粒子的位置。
-
我们使用顶点、几何和片段着色器通过图形管线在更新后的位置绘制粒子。
为了准备粒子系统,我们需要考虑计算位置和绘制所有粒子所需的数据。在这个例子中,我们将使用三个参数:位置、速度和颜色。这些参数的每一组都将通过顶点缓冲区由顶点着色器访问,并且相同的数据将在计算着色器中被读取。在除了顶点着色器之外的其他阶段访问大量条目的简单方便的方法是使用 texel 缓冲区。由于我们既要读取又要存储数据,因此我们需要一个存储 texel 缓冲区。它允许我们从被视为一维图像的缓冲区中获取数据(参考第五章,描述符集中的创建存储 texel 缓冲区配方)。
首先,我们需要为我们的粒子系统生成初始数据。为了正确读取存储在存储 texel 缓冲区中的数据,它必须按照选定的格式存储。存储 texel 缓冲区有一组有限的强制格式,因此我们需要将我们的粒子参数打包到其中之一。位置和颜色每个至少需要三个值。在我们的例子中,粒子将在整个系统的中心周围移动,因此速度可以根据粒子的当前位置轻松计算。我们只需要区分粒子的速度。为此,一个用于缩放速度向量的值就足够了。
因此,我们最终得到七个值。我们将它们打包成两个 RGBA 浮点向量。首先有三个X、Y、Z分量表示位置属性。下一个值在我们的粒子系统中未使用,但为了正确读取数据,它需要被包含。我们将一个1.0f值存储为位置属性的第四个分量。之后是颜色属性的R、G、B值,以及一个缩放粒子速度向量的值。我们随机生成所有值并将它们存储在一个向量中:
std::vector<float> particles;
for( uint32_t i = 0; i < PARTICLES_COUNT; ++i ) {
Vector3 position = /* generate position */;
Vector3 color = /* generate color */;
float speed = /* generate speed scale */;
particles.insert( particles.end(), position.begin(), position.end() );
particles.push_back( 1.0f );
particles.insert( particles.end(), color.begin(), color.end() );
particles.push_back( speed );
}
生成的数据被复制到缓冲区中。我们创建一个缓冲区,它将作为渲染期间的顶点缓冲区,以及在位置计算期间的存储纹理缓冲区:
InitVkDestroyer( LogicalDevice, VertexBuffer );
InitVkDestroyer( LogicalDevice, VertexBufferMemory );
InitVkDestroyer( LogicalDevice, VertexBufferView );
if( !CreateStorageTexelBuffer( PhysicalDevice, *LogicalDevice, VK_FORMAT_R32G32B32A32_SFLOAT, sizeof( particles[0] ) * particles.size(),
VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT, false,
*VertexBuffer, *VertexBufferMemory, *VertexBufferView ) ) {
return false;
}
if( !UseStagingBufferToUpdateBufferWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, sizeof( particles[0] ) * particles.size(),
&particles[0], *VertexBuffer, 0, 0, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_VERTEX_INPUT_BIT,
GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} ) ) {
return false;
}
此外,我们还需要一个统一缓冲区,通过它我们将提供变换矩阵。存储纹理缓冲区旁边的统一缓冲区将通过描述符集提供给着色器。在这里,我们将有两个独立的集。在第一个集中,我们将只有一个由顶点和几何着色器访问的统一缓冲区。第二个描述符集用于计算着色器访问存储纹理缓冲区。为此,我们需要两个独立的描述符集布局:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_GEOMETRY_BIT,
nullptr
},
{
0,
VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER,
1,
VK_SHADER_STAGE_COMPUTE_BIT,
nullptr
}
};
DescriptorSetLayout.resize( 2 );
InitVkDestroyer( LogicalDevice, DescriptorSetLayout[0] );
InitVkDestroyer( LogicalDevice, DescriptorSetLayout[1] );
if( !CreateDescriptorSetLayout( *LogicalDevice, { descriptor_set_layout_bindings[0] }, *DescriptorSetLayout[0] ) ) {
return false;
}
if( !CreateDescriptorSetLayout( *LogicalDevice, { descriptor_set_layout_bindings[1] }, *DescriptorSetLayout[1] ) ) {
return false;
}
接下来,我们需要一个池,我们可以从中分配两个描述符集:
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
},
{
VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER,
1
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 2, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
之后,我们可以分配两个描述符集,并使用创建的缓冲区和缓冲区视图来更新它们:
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout[0], *DescriptorSetLayout[1] }, DescriptorSets ) ) {
return false;
}
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
TexelBufferDescriptorInfo storage_texel_buffer_descriptor_update = {
DescriptorSets[1],
0,
0,
VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER,
{
{
*VertexBufferView
}
}
};
UpdateDescriptorSets( *LogicalDevice, {}, { buffer_descriptor_update }, { storage_texel_buffer_descriptor_update }, {} );
下一个重要步骤是创建图形和计算管线。当涉及到移动时,计算必须基于实时值进行,因为我们通常不能依赖于固定的时间间隔。因此,计算着色器必须能够访问自上一帧以来经过的时间值。这样的值可以通过推送常量范围提供。我们可以在这里看到创建计算管线所需的代码:
std::vector<unsigned char> compute_shader_spirv;
if( !GetBinaryFileContents( "Data/Shaders/Recipes/12 Advanced Rendering Techniques/03 Drawing particles using compute and graphics pipelines/shader.comp.spv", compute_shader_spirv ) ) {
return false;
}
VkDestroyer<VkShaderModule> compute_shader_module( LogicalDevice );
if( !CreateShaderModule( *LogicalDevice, compute_shader_spirv, *compute_shader_module ) ) {
return false;
}
std::vector<ShaderStageParameters> compute_shader_stage_params = {
{
VK_SHADER_STAGE_COMPUTE_BIT,
*compute_shader_module,
"main",
nullptr
}
};
std::vector<VkPipelineShaderStageCreateInfo> compute_shader_stage_create_infos;
SpecifyPipelineShaderStages( compute_shader_stage_params, compute_shader_stage_create_infos );
VkPushConstantRange push_constant_range = {
VK_SHADER_STAGE_COMPUTE_BIT,
0,
sizeof( float )
};
InitVkDestroyer( LogicalDevice, ComputePipelineLayout );
if( !CreatePipelineLayout( *LogicalDevice, { *DescriptorSetLayout[1] }, { push_constant_range }, *ComputePipelineLayout ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, ComputePipeline );
if( !CreateComputePipeline( *LogicalDevice, 0, compute_shader_stage_create_infos[0], *ComputePipelineLayout, VK_NULL_HANDLE, VK_NULL_HANDLE, *ComputePipeline ) ) {
return false;
}
计算着色器从如下定义的存储纹理缓冲区读取数据:
layout( set = 0, binding = 0, rgba32f ) uniform imageBuffer StorageTexelBuffer;
使用imageLoad()函数从存储纹理缓冲区读取数据:
vec4 position = imageLoad( StorageTexelBuffer, int(gl_GlobalInvocationID.x * 2) );
vec4 color = imageLoad( StorageTexelBuffer, int(gl_GlobalInvocationID.x * 2 + 1) );
我们读取两个值,因此需要两个imageLoad()调用,因为每个这样的操作都返回缓冲区(在这种情况下,一个 4 分量浮点向量)定义的格式的一个元素。我们根据当前计算着色器实例的唯一值访问缓冲区。
接下来,我们进行计算并更新顶点的位置。计算是为了让粒子根据位置和向上向量围绕场景中心移动。使用cross()函数计算一个新的向量(速度):

这个计算出的速度向量被添加到获取的位置上,结果使用imageStore()函数存储在同一个缓冲区中:
imageStore( StorageTexelBuffer, int(gl_GlobalInvocationID.x * 2), position );
我们没有更新颜色或速度,所以我们只存储一个值。
由于我们只访问一个粒子的数据,我们可以从同一缓冲区读取值并将值存储在同一个缓冲区中。在更复杂的场景中,例如当粒子之间存在交互时,我们不能使用同一个缓冲区。计算着色器调用的执行顺序是未知的,因此一些调用将访问未修改的值,而其他调用将读取已经更新的数据。这将影响计算结果的准确性,并可能导致系统不可预测。
图形管线创建与“使用几何着色器绘制广告牌”方法中展示的非常相似。不同之处在于它获取两个属性而不是一个:
std::vector<VkVertexInputBindingDescription> vertex_input_binding_descriptions = {
{
0, VK_VERTEX_INPUT_RATE_VERTEX
}
};
std::vector<VkVertexInputAttributeDescription> vertex_attribute_descriptions = {
{
0,
0,
VK_FORMAT_R32G32B32A32_SFLOAT,
0
},
{
1,
0,
VK_FORMAT_R32G32B32A32_SFLOAT,
4 * sizeof( float )
}
};
VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info;
SpecifyPipelineVertexInputState( vertex_input_binding_descriptions, vertex_attribute_descriptions, vertex_input_state_create_info );
我们还以点原语的形式渲染顶点:
VkPipelineInputAssemblyStateCreateInfo input_assembly_state_create_info;
SpecifyPipelineInputAssemblyState( VK_PRIMITIVE_TOPOLOGY_POINT_LIST, false, input_assembly_state_create_info );
最后一个区别是,在这里我们启用了加法混合,因此粒子看起来像是在发光:
std::vector<VkPipelineColorBlendAttachmentState> attachment_blend_states = {
{
true,
VK_BLEND_FACTOR_SRC_ALPHA,
VK_BLEND_FACTOR_ONE,
VK_BLEND_OP_ADD,
VK_BLEND_FACTOR_ONE,
VK_BLEND_FACTOR_ONE,
VK_BLEND_OP_ADD,
VK_COLOR_COMPONENT_R_BIT |
VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT |
VK_COLOR_COMPONENT_A_BIT
}
};
VkPipelineColorBlendStateCreateInfo blend_state_create_info;
SpecifyPipelineBlendState( false, VK_LOGIC_OP_COPY, attachment_blend_states, { 1.0f, 1.0f, 1.0f, 1.0f }, blend_state_create_info );
绘制过程也被分为两个步骤。首先,我们记录一个命令缓冲区,用于调度计算工作。某些硬件平台可能有一个专门用于数学计算的队列家族,因此将带有计算着色器的命令缓冲区提交到该队列可能更可取:
if( !BeginCommandBufferRecordingOperation( ComputeCommandBuffer, VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, nullptr ) ) {
return false;
}
BindDescriptorSets( ComputeCommandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, *ComputePipelineLayout, 0, { DescriptorSets[1] }, {} );
BindPipelineObject( ComputeCommandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, *ComputePipeline );
float time = TimerState.GetDeltaTime();
ProvideDataToShadersThroughPushConstants( ComputeCommandBuffer, *ComputePipelineLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof( float ), &time );
DispatchComputeWork( ComputeCommandBuffer, PARTICLES_COUNT / 32 + 1, 1, 1 );
if( !EndCommandBufferRecordingOperation( ComputeCommandBuffer ) ) {
return false;
}
if( !SubmitCommandBuffersToQueue( ComputeQueue.Handle, {}, { ComputeCommandBuffer }, { *ComputeSemaphore }, *ComputeFence ) ) {
return false;
}
绘制以正常方式进行。我们只需要将图形队列与计算队列同步。我们通过在提交命令缓冲区到图形队列时提供一个额外的等待信号量来实现这一点。当计算队列完成处理提交的命令缓冲区时,它必须发出信号:
计算着色器被调度。
以下示例图像展示了使用不同数量的粒子渲染的相同粒子系统:

参见
-
在第一章,“实例和设备”,查看获取物理设备特性和属性的方法
-
在第五章,“描述符集”,查看以下方法:
-
创建存储纹理缓冲区
-
创建描述符集布局
-
创建描述符池
-
分配描述符集
-
更新描述符集
-
-
在第七章,“着色器”,查看编写计算着色器的方法
-
在第八章,“图形和计算管线”,查看以下方法:
-
创建着色器模块
-
创建计算管线
-
创建图形管线
-
-
在第九章,“命令录制和绘制”,查看以下方法:
-
通过推送常量向着色器提供数据
-
绘制几何体
-
调度计算工作
-
渲染细分地形
具有开放世界和长渲染距离的 3D 场景通常也包含广阔的地形。绘制地面是一个非常复杂的话题,可以以许多不同的方式执行。远处的地形不能太复杂,因为它将占用太多的内存和计算能力来显示。另一方面,靠近玩家的区域必须足够详细,以看起来令人信服和自然。这就是为什么我们需要一种方法来随着距离的增加降低细节数量,或者在摄像机附近增加地形的保真度。
这是一个使用细分着色器实现高质量渲染图像的示例。对于地形,我们可以使用具有少量顶点的平面。使用细分着色器,我们可以增加靠近摄像机的地面原素的数量。然后我们可以通过所需的量偏移生成的顶点,以增加或减少地形的高度。
以下截图是使用此配方生成的图像示例:

准备工作
绘制地形通常需要准备高度数据。这可以即时、根据某些期望的公式进行生成。然而,它也可以以高度图这种纹理的形式提前准备。它包含有关地形相对于指定海拔高度的高度信息,其中较浅的颜色表示较高的高度,较深的颜色表示较低的高度。以下图像显示了此类高度图的一个示例:

如何操作...
-
加载或生成一个水平对齐的平面模型。需要两个属性——位置和纹理坐标。将顶点数据上传到顶点缓冲区(参考第十章的从 OBJ 文件加载 3D 模型配方,辅助配方,以及第四章的创建缓冲区和使用阶段缓冲区更新设备本地内存绑定缓冲区配方,资源和内存)。
-
为两个变换矩阵创建一个统一缓冲区(参考第五章的创建统一缓冲区配方,描述符集)。
-
从图像文件加载高度信息(参考第十章的从文件加载纹理数据配方,辅助配方)。创建一个组合图像采样器并将加载的高度数据复制到图像的内存中(参考第五章的创建组合图像采样器配方,描述符集,以及第四章的使用阶段缓冲区更新具有设备本地内存绑定图像配方,资源和内存)。
-
创建一个描述符集布局,其中包含一个由细分控制和几何阶段访问的统一缓冲区和一个由细分控制和评估阶段访问的联合图像采样器(参考第五章,描述符集中的创建描述符集布局配方)。使用准备好的布局分配描述符集。使用创建的统一缓冲区和采样器以及图像视图句柄更新它(参考第五章,描述符集中的分配描述符集和更新描述符集配方)。
-
使用以下 GLSL 代码创建一个顶点着色器,并为其创建一个 SPIR-V 汇编(参考第八章,图形和计算管线中的创建着色器模块配方):
#version 450
layout( location = 0 ) in vec4 app_position;
layout( location = 1 ) in vec2 app_texcoord;
layout( location = 0 ) out vec2 vert_texcoord;
void main() {
gl_Position = app_position;
vert_texcoord = app_texcoord;
}
- 为一个细分控制阶段创建一个着色器模块。使用以下 GLSL 代码从以下内容生成 SPIR-V 汇编:
#version 450
layout( location = 0 ) in vec2 vert_texcoord[];
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( set = 0, binding = 1 ) uniform sampler2D ImageSampler;
layout( vertices = 3 ) out;
layout( location = 0 ) out vec2 tesc_texcoord[];
void main() {
if( 0 == gl_InvocationID ) {
float distances[3];
float factors[3];
for( int i = 0; i < 3; ++i ) {
float height = texture( ImageSampler, vert_texcoord[i]
).x;
vec4 position = ModelViewMatrix * (gl_in[i].gl_Position +
vec4( 0.0, height, 0.0, 0.0 ));
distances[i] = dot( position, position );
}
factors[0] = min( distances[1], distances[2] );
factors[1] = min( distances[2], distances[0] );
factors[2] = min( distances[0], distances[1] );
gl_TessLevelInner[0] = max( 1.0, 20.0 - factors[0] );
gl_TessLevelOuter[0] = max( 1.0, 20.0 - factors[0] );
gl_TessLevelOuter[1] = max( 1.0, 20.0 - factors[1] );
gl_TessLevelOuter[2] = max( 1.0, 20.0 - factors[2] );
}
gl_out[gl_InvocationID].gl_Position =
gl_in[gl_InvocationID].gl_Position;
tesc_texcoord[gl_InvocationID] =
vert_texcoord[gl_InvocationID];
}
- 从以下 GLSL 代码创建一个细分评估着色器,创建一个着色器模块:
#version 450
layout( triangles, fractional_even_spacing, cw ) in;
layout( location = 0 ) in vec2 tesc_texcoord[];
layout( set = 0, binding = 1 ) uniform sampler2D HeightMap;
layout( location = 0 ) out float tese_height;
void main() {
vec4 position = gl_in[0].gl_Position * gl_TessCoord.x +
gl_in[1].gl_Position * gl_TessCoord.y +
gl_in[2].gl_Position * gl_TessCoord.z;
vec2 texcoord = tesc_texcoord[0] * gl_TessCoord.x +
tesc_texcoord[1] * gl_TessCoord.y +
tesc_texcoord[2] * gl_TessCoord.z;
float height = texture( HeightMap, texcoord ).x;
position.y += height;
gl_Position = position;
tese_height = height;
}
- 为几何着色器创建一个着色器模块,并使用以下 GLSL 代码:
#version 450
layout( triangles ) in;
layout( location = 0 ) in float tese_height[];
layout( set = 0, binding = 0 ) uniform UniformBuffer {
mat4 ModelViewMatrix;
mat4 ProjectionMatrix;
};
layout( triangle_strip, max_vertices = 3 ) out;
layout( location = 0 ) out vec3 geom_normal;
layout( location = 1 ) out float geom_height;
void main() {
vec3 v0v1 = gl_in[1].gl_Position.xyz -
gl_in[0].gl_Position.xyz;
vec3 v0v2 = gl_in[2].gl_Position.xyz -
gl_in[0].gl_Position.xyz;
vec3 normal = normalize( cross( v0v1, v0v2 ) );
for( int vertex = 0; vertex < 3; ++vertex ) {
gl_Position = ProjectionMatrix * ModelViewMatrix *
gl_in[vertex].gl_Position;
geom_height = tese_height[vertex];
geom_normal = normal;
EmitVertex();
}
EndPrimitive();
}
- 创建一个包含片段着色器源代码的着色器模块。从以下 GLSL 代码生成 SPIR-V 汇编:
#version 450
layout( location = 0 ) in vec3 geom_normal;
layout( location = 1 ) in float geom_height;
layout( location = 0 ) out vec4 frag_color;
void main() {
const vec4 green = vec4( 0.2, 0.5, 0.1, 1.0 );
const vec4 brown = vec4( 0.6, 0.5, 0.3, 1.0 );
const vec4 white = vec4( 1.0 );
vec4 color = mix( green, brown, smoothstep( 0.0, 0.4,
geom_height ) );
color = mix( color, white, smoothstep( 0.6, 0.9, geom_height )
);
float diffuse_light = max( 0.0, dot( geom_normal, vec3( 0.58,
0.58, 0.58 ) ) );
frag_color = vec4( 0.05, 0.05, 0.0, 0.0 ) + diffuse_light *
color;
}
-
使用上述五个着色器模块创建一个图形管线。该管线应检索两个顶点属性:一个 3 分量位置和一个 2 分量纹理坐标。它必须使用
VK_PRIMITIVE_TOPOLOGY_PATCH_LIST原语。一个补丁应包含三个控制点(参考第八章,图形和计算管线中的指定管线输入装配状态、指定管线细分状态、指定图形管线创建参数和创建图形管线配方)。 -
创建剩余的资源并绘制几何图形(参考第十一章,光照中的使用顶点漫反射光照渲染几何图形配方)。
它是如何工作的...
我们通过加载一个平坦平面的模型开始绘制地形的绘制过程。它可能是一个带有超过四个顶点的简单四边形。在细分阶段生成过多的顶点可能在性能上过于昂贵,因此我们需要在基本几何的复杂性和细分因子之间找到平衡。我们可以在以下图像中看到用作细分地形基础的平面:

在本例中,我们将从纹理中加载高度信息。我们以与从文件加载数据相同的方式进行此操作。然后我们创建一个联合图像采样器并将加载的数据上传到其内存中:
int width = 1;
int height = 1;
std::vector<unsigned char> image_data;
if( !LoadTextureDataFromFile( "Data/Textures/heightmap.png", 4, image_data, &width, &height ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, HeightSampler );
InitVkDestroyer( LogicalDevice, HeightMap );
InitVkDestroyer( LogicalDevice, HeightMapMemory );
InitVkDestroyer( LogicalDevice, HeightMapView );
if( !CreateCombinedImageSampler( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, { (uint32_t)width, (uint32_t)height, 1 },
1, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, VK_IMAGE_VIEW_TYPE_2D, VK_IMAGE_ASPECT_COLOR_BIT, VK_FILTER_LINEAR,
VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 1.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK,
false, *HeightSampler, *HeightMap, *HeightMapMemory, *HeightMapView ) ) {
return false;
}
VkImageSubresourceLayers image_subresource_layer = {
VK_IMAGE_ASPECT_COLOR_BIT,
0,
0,
1
};
if( !UseStagingBufferToUpdateImageWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, static_cast<VkDeviceSize>(image_data.size()),
&image_data[0], *HeightMap, image_subresource_layer, { 0, 0, 0 }, { (uint32_t)width, (uint32_t)height, 1 }, VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, 0, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} ) ) {
return false;
}
还需要一个包含变换矩阵的统一缓冲区,以便将顶点从局部空间变换到视图空间和裁剪空间:
InitVkDestroyer( LogicalDevice, UniformBuffer );
InitVkDestroyer( LogicalDevice, UniformBufferMemory );
if( !CreateUniformBuffer( PhysicalDevice, *LogicalDevice, 2 * 16 * sizeof( float ), VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
*UniformBuffer, *UniformBufferMemory ) ) {
return false;
}
if( !UpdateStagingBuffer( true ) ) {
return false;
}
下一步是为统一缓冲区和组合图像采样器创建描述符集。统一缓冲区在镶嵌控制和几何阶段被访问。高度信息在镶嵌控制和评估阶段读取:
std::vector<VkDescriptorSetLayoutBinding> descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1,
VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT | VK_SHADER_STAGE_GEOMETRY_BIT,
nullptr
},
{
1,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT | VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, descriptor_set_layout_bindings, *DescriptorSetLayout ) ) {
return false;
}
std::vector<VkDescriptorPoolSize> descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
1
},
{
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
2
}
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, descriptor_pool_sizes, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
接下来,我们可以使用统一缓冲区句柄以及采样器和图像视图句柄更新描述符集,因为它们在我们的应用程序生命周期内不会改变(也就是说,当窗口大小修改时,我们不需要重新创建它们)。
BufferDescriptorInfo buffer_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
{
{
*UniformBuffer,
0,
VK_WHOLE_SIZE
}
}
};
std::vector<ImageDescriptorInfo> image_descriptor_updates = {
{
DescriptorSets[0],
1,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
*HeightSampler,
*HeightMapView,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
}
};
UpdateDescriptorSets( *LogicalDevice, image_descriptor_updates, { buffer_descriptor_update }, {}, {} );
下一步是创建图形管线。这次我们有一个非常复杂的管线,所有五个可编程图形阶段都被启用:
std::vector<ShaderStageParameters> shader_stage_params = {
{
VK_SHADER_STAGE_VERTEX_BIT,
*vertex_shader_module,
"main",
nullptr
},
{
VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT,
*tessellation_control_shader_module,
"main",
nullptr
},
{
VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT,
*tessellation_evaluation_shader_module,
"main",
nullptr
},
{
VK_SHADER_STAGE_GEOMETRY_BIT,
*geometry_shader_module,
"main",
nullptr
},
{
VK_SHADER_STAGE_FRAGMENT_BIT,
*fragment_shader_module,
"main",
nullptr
}
};
std::vector<VkPipelineShaderStageCreateInfo> shader_stage_create_infos;
SpecifyPipelineShaderStages( shader_stage_params, shader_stage_create_infos );
为什么我们需要所有五个阶段?顶点着色器始终是必需的。这次它只读取两个输入属性(位置和 texcoord)并将其传递到管线中。
当启用镶嵌时,我们需要控制和评估着色器阶段。镶嵌控制着色器,正如其名称所暗示的,控制处理补丁的镶嵌级别(生成的顶点数量)。在这个菜谱中,我们根据从摄像机到距离生成顶点:补丁的顶点越靠近摄像机,镶嵌器生成的顶点就越多。这样,远处的地形简单,不需要太多处理能力来渲染;但是,越靠近摄像机,地形就越复杂。
我们不能为整个补丁(在这种情况下是一个三角形)选择一个镶嵌级别。当两个相邻的三角形以不同的因子进行镶嵌时,它们在公共边上的顶点数量将不同。每个三角形的顶点将放置在不同的位置,并且它们将以不同的值偏移。这将在我们的大地上造成空洞:

在前面的图像中,我们看到两个三角形:左边由顶点 L0-L1-L7 组成,右边由顶点 R0-R1-R4 组成。其他顶点由镶嵌器生成。三角形共享一条边:L1-L7 或 R1-R4(点 L1 和 R4 表示同一个顶点;同样,点 L7 和 R1 表示同一个顶点);但是,边以不同的因子进行镶嵌。这会在由两个三角形形成的表面上造成不连续性(由条纹表示)。
为了避免这个问题,我们需要为每个三角形边计算一个镶嵌因子,使其在共享相同边的三角形之间保持固定。在这个例子中,我们将根据顶点到摄像机的距离来计算镶嵌因子。我们将对三角形中的所有顶点都这样做。然后,对于给定的三角形边,我们将选择一个更大的镶嵌因子,该因子是从边的某个顶点计算出来的:
float distances[3];
float factors[3];
for( int i = 0; i < 3; ++i ) {
float height = texture( ImageSampler, vert_texcoord[i] ).x;
vec4 position = ModelViewMatrix * (gl_in[i].gl_Position + vec4( 0.0,
height, 0.0, 0.0 ));
distances[i] = dot( position, position );
}
factors[0] = min( distances[1], distances[2] );
factors[1] = min( distances[2], distances[0] );
factors[2] = min( distances[0], distances[1] );
gl_TessLevelInner[0] = max( 1.0, 20.0 - factors[0] );
gl_TessLevelOuter[0] = max( 1.0, 20.0 - factors[0] );
gl_TessLevelOuter[1] = max( 1.0, 20.0 - factors[1] );
gl_TessLevelOuter[2] = max( 1.0, 20.0 - factors[2] );
在前面的镶嵌控制着色器代码中,我们计算了所有顶点到摄像机的距离(平方)。我们需要通过从高度图中读取的值来偏移位置,以确保整个补丁位于正确的位置,并且距离被正确计算。
接下来,对于所有三角形边,我们取两个顶点之间的较小距离。因为我们希望细分因子随着距离的减小而增加,所以我们需要反转计算出的因子。在这里,我们取一个硬编码的值20并减去一个选定的距离值。因为我们不希望细分因子小于1.0,所以我们执行额外的钳位。
这样计算的细分因子会随着生成顶点数量的增加而减小,这是故意为之,以便我们可以看到三角形是如何细分的,以及细节数量在相机附近的增加情况。然而,在实际示例中,我们应该准备这样的公式,以便效果几乎不可见。
接下来,一个细分评估着色器根据生成的顶点的权重来计算新顶点的有效位置。我们同样对纹理坐标进行相同的处理,因为我们需要从高度图中加载高度信息:
vec4 position = gl_in[0].gl_Position * gl_TessCoord.x +
gl_in[1].gl_Position * gl_TessCoord.y +
gl_in[2].gl_Position * gl_TessCoord.z;
vec2 texcoord = tesc_texcoord[0] * gl_TessCoord.x +
tesc_texcoord[1] * gl_TessCoord.y +
tesc_texcoord[2] * gl_TessCoord.z;
在计算出新顶点的位置后,我们需要对其进行偏移,以便顶点位于适当的高度:
float height = texture( HeightMap, texcoord ).x;
position.y += height;
gl_Position = position;
细分评估着色器阶段之后是几何着色器阶段。我们可以省略它,但在这里我们使用它来计算生成三角形的法向量。我们为三角形的所有顶点取一个法向量,因此在这个示例中我们将执行平面着色。
法向量是通过cross()函数计算的,该函数接受两个向量并返回一个垂直于所提供向量的向量。我们提供形成三角形两条边的向量:
vec3 v0v1 = gl_in[1].gl_Position.xyz - gl_in[0].gl_Position.xyz;
vec3 v0v2 = gl_in[2].gl_Position.xyz - gl_in[0].gl_Position.xyz;
vec3 normal = normalize( cross( v0v1, v0v2 ) );
最后,几何着色器计算所有顶点的裁剪空间位置并将它们发出:
for( int vertex = 0; vertex < 3; ++vertex ) {
gl_Position = ProjectionMatrix * ModelViewMatrix * gl_in[vertex].gl_Position;
geom_height = tese_height[vertex];
geom_normal = normal;
EmitVertex();
}
EndPrimitive();
为了简化配方,片段着色器也很简单。它根据地面以上的高度混合三种颜色:下部的草地为绿色,中间的岩石为灰色/棕色,山顶的雪为白色。它还使用漫射/朗伯光照模型进行简单的光照计算。
前面的着色器形成了一个用于绘制细分地形的图形管道。在创建管道期间,我们必须记住要考虑原语拓扑。由于启用了细分阶段,我们需要使用VK_PRIMITIVE_TOPOLOGY_PATCH_LIST拓扑。我们还需要在创建管道期间提供细分状态。因为我们想对三角形进行操作,所以我们指定一个补丁包含三个控制点:
VkPipelineInputAssemblyStateCreateInfo input_assembly_state_create_info;
SpecifyPipelineInputAssemblyState( VK_PRIMITIVE_TOPOLOGY_PATCH_LIST, false, input_assembly_state_create_info );
VkPipelineTessellationStateCreateInfo tessellation_state_create_info;
SpecifyPipelineTessellationState( 3, tessellation_state_create_info );
用于管道创建的其余参数以通常的方式定义。我们也不需要在渲染期间做任何特殊的事情。我们只需使用先前绑定的图形管道绘制一个平面,我们应该看到一个类似地形的几何形状。我们可以在以下图像中看到使用此配方生成的结果示例:

参见
-
在第四章,资源和内存,查看创建缓冲区配方
-
在第五章,描述符集中,查看以下配方:
-
创建组合图像采样器
-
创建统一缓冲区
-
创建描述符集布局
-
分配描述符集
-
更新描述符集
-
-
在第七章,着色器中,查看以下配方:
-
编写细分控制着色器
-
编写细分评估着色器
-
编写几何着色器
-
-
在第八章,图形和计算管线中,查看以下配方:
-
创建着色器模块
-
指定管线输入装配状态
-
指定管线细分状态
-
创建图形管线
-
-
在第十章,辅助配方中,查看以下配方:
-
从文件中加载纹理数据
-
从 OBJ 文件加载 3D 模型
-
-
在第十一章,光照中,查看使用顶点漫反射光照配方渲染几何体
渲染全屏四边形进行后期处理
图像处理是 3D 图形中常用的一类技术。人眼感知周围世界的方式几乎无法直接模拟。有许多效果仅通过绘制几何形状是无法显示的。例如,亮区似乎比暗区大(这通常被称为光晕);在我们焦点处的物体看起来很清晰,但距离焦点越远,这些物体变得越模糊或模糊(我们称这种效果为景深);颜色在白天和夜晚的感知可能不同,当光线非常微弱时,一切似乎都带有更多的蓝色。
这些现象很容易作为后期处理效果实现。我们通常将场景渲染到图像中。之后,我们进行另一次渲染,这次是使用存储在图像中的数据,并按照选择的算法进行处理。要渲染图像,我们需要将其放置在一个覆盖整个场景的四边形上。这种几何形状通常被称为全屏四边形。
如何做到这一点...
-
准备四边形的几何顶点数据。使用以下值(如有需要,请添加纹理坐标):
-
{ -1.0f, -1.0f, 0.0f }为左上角顶点 -
{ -1.0f, 1.0f, 0.0f }为左下角顶点 -
{ 1.0f, -1.0f, 0.0f }为右上角顶点 -
{ 1.0f, 1.0f, 0.0f }为右下角顶点
-
-
创建一个作为顶点缓冲区的缓冲区。分配一个内存对象并将其绑定到缓冲区。使用阶段资源(参考第四章,资源和内存中的创建缓冲区、分配和绑定内存对象到缓冲区和使用阶段缓冲区更新绑定设备本地内存的缓冲区)将顶点数据上传到缓冲区。
-
创建一个组合图像采样器。请记住,根据图像在渲染和后期处理期间将被访问的方式提供有效的用途:将场景渲染到图像中需要一个
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;采样图像(使用采样器读取数据)需要一个VK_IMAGE_USAGE_SAMPLED_BIT;对于图像加载/存储,我们必须提供一个VK_IMAGE_USAGE_STORAGE_BIT;可能还需要其他用途(参考第五章,描述符集中创建组合图像采样器的配方)。 -
创建一个包含一个组合图像采样器的描述符集布局。创建一个描述符池,并使用创建的布局从其中分配一个描述符集。使用图像视图的句柄和采样器更新描述符集。每次应用程序窗口大小调整且图像被重新创建时都要这样做(参考第五章,描述符集中的创建描述符集布局、创建描述符池、分配描述符集和更新描述符集的配方)。
-
如果我们想要访问许多不同的图像坐标,创建一个单独的、专用的渲染通道,其中包含一个颜色附件和至少一个子通道(参考第六章,渲染通道和帧缓冲区中的指定附件描述、指定子通道描述、指定子通道之间的依赖关系和创建渲染通道的配方)。
-
使用以下 GLSL 代码创建一个顶点着色器,并为其创建一个 SPIR-V 汇编模块(参考第八章,创建着色器模块):
#version 450
layout( location = 0 ) in vec4 app_position;
void main() {
gl_Position = app_position;
}
- 使用以下 GLSL 代码创建一个片段着色器:
#version 450
layout( set = 0, binding = 0 ) uniform sampler2D Image;
layout( location = 0 ) out vec4 frag_color;
void main() {
vec4 color = vec4( 0.5 );
color -= texture( Image, gl_FragCoord.xy + vec2( -1.0, 0.0 ) );
color += texture( Image, gl_FragCoord.xy + vec2( 1.0, 0.0 ) );
color -= texture( Image, gl_FragCoord.xy + vec2( 0.0, -1.0 ) );
color += texture( Image, gl_FragCoord.xy + vec2( 0.0, 1.0 ) );
frag_color = abs( 0.5 - color );
}
-
使用前面的着色器模块创建一个图形管线。它必须读取一个顶点属性,包含顶点位置(以及可能的一个包含纹理坐标的第二个属性)。使用
VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP拓扑,并禁用面剔除(参考第八章,指定管线顶点输入状态、指定管线输入装配状态、指定管线光栅化状态和创建图形管线的配方)。 -
将场景渲染到创建的图像中。接下来,开始另一个渲染通道,并使用准备好的图形管线绘制全屏四边形(参考第六章,渲染通道和帧缓冲区中的开始渲染通道和结束渲染通道配方,第五章,描述符集中的绑定描述符集配方,以及第九章,命令记录和绘制中的绑定顶点缓冲区和绘制几何体配方)。
它是如何工作的...
可以使用计算着色器执行图像后处理。然而,当我们想在屏幕上显示图像时,我们必须使用交换链。从着色器内部存储数据到图像中要求创建具有存储图像用法的图像。不幸的是,这种用法可能不支持交换链图像,因此需要创建额外的中间资源,这进一步增加了代码的复杂性。
使用图形管线允许我们在片段着色器内部处理图像数据并将结果存储在颜色附件中。这种用法对于交换链图像是强制性的,因此使用 Vulkan API 实现的图像处理感觉更自然。另一方面,图形管线要求我们绘制几何体,因此我们不仅需要顶点数据、顶点和片段着色器,还需要一个渲染通道和一个帧缓冲区。这就是为什么使用计算着色器可能更高效。所以,一切取决于图形硬件(可用的交换链图像用法)和给定情况支持的功能。
在这个食谱中,我们将介绍在图像后处理阶段绘制全屏四边形的方法。首先,我们需要顶点数据本身。它可以直接在裁剪空间中准备。这样我们可以创建一个更简单的顶点着色器,并避免将顶点位置乘以投影矩阵。在透视除法之后,为了使顶点适合视图,它们位置中的 x 和 y 分量存储的值必须适合 <-1, 1> 范围(包含)内,而 z 分量中的值必须在 <0, 1> 范围内。因此,如果我们想覆盖整个屏幕,我们需要以下顶点集:
std::vector<float> vertices = {
-1.0f, -1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
1.0f, -1.0f, 0.0f,
1.0f, 1.0f, 0.0f,
};
如果需要,我们可以添加归一化纹理坐标,或者我们可以依赖内置的 gl_FragCoord 值(当编写 GLSL 着色器时),它包含当前正在处理的着色器的屏幕坐标。当我们使用输入附件时,甚至不需要纹理坐标,因为我们只能访问与当前正在处理的片段关联的样本。
顶点数据需要存储在一个作为顶点缓冲区的缓冲区中。因此,我们需要创建它,分配一个内存对象并将其绑定到缓冲区,然后将顶点数据上传到缓冲区:
InitVkDestroyer( LogicalDevice, VertexBuffer );
if( !CreateBuffer( *LogicalDevice, sizeof( vertices[0] ) * vertices.size(), VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, *VertexBuffer ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, BufferMemory );
if( !AllocateAndBindMemoryObjectToBuffer( PhysicalDevice, *LogicalDevice, *VertexBuffer, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, *BufferMemory ) ) {
return false;
}
if( !UseStagingBufferToUpdateBufferWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, sizeof( vertices[0] ) * vertices.size(), &vertices[0], *VertexBuffer, 0, 0,
VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_VERTEX_INPUT_BIT, GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} ) ) {
return false;
接下来,我们需要一种方法来访问片段着色器内部的光栅数据。如果我们想从同一渲染通道的先前子通道中访问存储在颜色附件中的数据,我们可以使用一个输入附件。我们可以使用一个存储图像,分离采样器和采样图像,或者使用一个组合图像采样器。后一种在本食谱中使用。为了简化这个食谱和代码,我们从文件中读取纹理数据。但通常我们会有一个图像,场景将被渲染到这个图像中:
int width = 1;
int height = 1;
std::vector<unsigned char> image_data;
if( !LoadTextureDataFromFile( "Data/Textures/sunset.jpg", 4, image_data, &width, &height ) ) {
return false;
}
InitVkDestroyer( LogicalDevice, Sampler );
InitVkDestroyer( LogicalDevice, Image );
InitVkDestroyer( LogicalDevice, ImageMemory );
InitVkDestroyer( LogicalDevice, ImageView );
if( !CreateCombinedImageSampler( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, VK_FORMAT_R8G8B8A8_UNORM, { (uint32_t)width, (uint32_t)height, 1 },
1, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, VK_IMAGE_VIEW_TYPE_2D, VK_IMAGE_ASPECT_COLOR_BIT, VK_FILTER_NEAREST,
VK_FILTER_NEAREST, VK_SAMPLER_MIPMAP_MODE_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 0.0f, false, 1.0f, false, VK_COMPARE_OP_ALWAYS, 0.0f, 1.0f, VK_BORDER_COLOR_FLOAT_OPAQUE_BLACK, true,
*Sampler, *Image, *ImageMemory, *ImageView ) ) {
return false;
}
VkImageSubresourceLayers image_subresource_layer = {
VK_IMAGE_ASPECT_COLOR_BIT,
0,
0,
1
};
if( !UseStagingBufferToUpdateImageWithDeviceLocalMemoryBound( PhysicalDevice, *LogicalDevice, static_cast<VkDeviceSize>(image_data.size()),
&image_data[0], *Image, image_subresource_layer, { 0, 0, 0 }, { (uint32_t)width, (uint32_t)height, 1 }, VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, 0, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, GraphicsQueue.Handle, FrameResources.front().CommandBuffer, {} ) ) {
return false;
}
在前面的代码中,我们创建了一个组合图像采样器,并指定我们将使用非归一化纹理坐标来访问它。通常我们提供坐标在<0.0, 1.0>范围内(包含)。这样我们就不必担心图像的大小。另一方面,对于后期处理,我们通常希望使用屏幕空间坐标来引用纹理图像,这就是使用非归一化纹理坐标的时候——它们对应于图像的尺寸。
要访问一个图像,我们还需要一个描述符集。我们不需要统一缓冲区,因为我们没有变换几何图形,绘制出的顶点已经处于正确的空间(裁剪空间)。在我们能够分配描述符集之前,我们创建一个布局,其中包含一个在片段着色器阶段访问的组合图像采样器。之后,创建一个池,并从池中分配一个描述符集:
VkDescriptorSetLayoutBinding descriptor_set_layout_binding = {
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
};
InitVkDestroyer( LogicalDevice, DescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, { descriptor_set_layout_binding }, *DescriptorSetLayout ) ) {
return false;
}
VkDescriptorPoolSize descriptor_pool_size = {
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1
};
InitVkDestroyer( LogicalDevice, DescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, { descriptor_pool_size }, *DescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *DescriptorPool, { *DescriptorSetLayout }, DescriptorSets ) ) {
return false;
}
ImageDescriptorInfo image_descriptor_update = {
DescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
{
{
*Sampler,
*ImageView,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
};
UpdateDescriptorSets( *LogicalDevice, { image_descriptor_update }, {}, {}, {} );
在前面的代码中,我们还使用创建的采样器和图像视图的句柄更新了描述符集。不幸的是,我们将场景渲染到其中的图像通常适合屏幕。这意味着当应用程序窗口的大小改变时,我们必须重新创建它。为此,我们必须销毁旧图像并创建一个具有新尺寸的新图像。在这样操作之后,我们必须再次使用新图像的句柄(采样器不需要重新创建)来更新描述符集。因此,我们必须记住每次应用程序窗口大小改变时都要更新描述符集。
最后一件事情是创建一个图形管线。它只使用两个着色器阶段:顶点和片段。顶点着色器获取的属性数量取决于我们是否需要纹理坐标(以及其他专用属性)。全屏四边形的几何形状应该使用VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP拓扑来绘制。我们也不需要任何混合。
后期处理最重要的部分是在片段着色器中执行的。要完成的工作取决于我们想要实现的技术。在这个菜谱中,我们展示了一个边缘检测算法:
vec4 color = vec4( 0.5 );
color -= texture( Image, gl_FragCoord.xy + vec2( -1.0, 0.0 ) );
color += texture( Image, gl_FragCoord.xy + vec2( 1.0, 0.0 ) );
color -= texture( Image, gl_FragCoord.xy + vec2( 0.0, -1.0 ) );
color += texture( Image, gl_FragCoord.xy + vec2( 0.0, 1.0 ) );
frag_color = abs( 0.5 - color );
在前面的片段着色器代码中,我们采样处理中的片段周围的四个值。我们从左侧的一个样本中取一个负值,并添加从右侧的一个样本中读取的值。这样我们就知道水平方向上样本之间的差异。当差异很大时,我们知道存在一个边缘。
我们对垂直方向进行相同的操作,以便检测水平线(使用垂直差分或梯度来检测水平边缘;水平梯度使我们能够检测垂直边缘)。之后,我们在输出变量中存储一个值。我们还会取abs()值,但这只是为了可视化目的。
在前面的片段着色器中,我们访问多个纹理坐标。这可以在组合图像采样器(输入附件使我们能够访问与正在处理的片段相关联的单个坐标)。然而,要将图像绑定到描述符集作为资源而不是输入附件,我们必须结束当前的渲染过程并开始另一个。在给定的渲染过程中,图像不能同时用于附件和任何其他非附件目的。
使用前面的设置,我们应该看到以下结果(在右侧),左侧是原始图像:

参见
-
在第四章,资源和内存,查看以下食谱:
-
创建缓冲区
-
分配和绑定内存对象到缓冲区
-
使用阶段缓冲区更新绑定到设备本地内存的缓冲区
-
-
在第五章,描述符集,查看以下食谱:
-
创建组合图像采样器
-
创建描述符集布局
-
分配描述符集
-
绑定描述符集
-
更新描述符集
-
-
在第六章,渲染过程和帧缓冲区,查看以下食谱:
-
开始渲染过程
-
结束渲染过程
-
-
在第八章,图形和计算管线,查看以下食谱:
-
创建着色器模块
-
指定管线顶点输入状态
-
指定管线输入装配状态
-
指定管线光栅化状态
-
创建图形管线
-
-
在第九章,命令录制和绘制,查看以下食谱:
-
绑定顶点缓冲区
-
绘制几何体
-
使用输入附件进行颜色校正后处理效果
在 3D 应用程序中使用了许多不同的后处理技术。颜色校正就是其中之一。这相对简单,但可以产生令人印象深刻的成果,并大大提升渲染场景的外观和感觉。颜色校正可以改变场景的氛围,并诱导用户产生预期的感受。
通常,颜色校正效果需要我们读取单个当前处理样本的数据。得益于这一特性,我们可以使用输入附件来实现此效果。这允许我们在渲染整个场景的同一渲染过程中执行后处理,从而提高我们应用程序的性能。
以下是一个使用此食谱生成的图像示例:

如何做到...
-
创建一个全屏四边形,并在后处理阶段需要额外的资源(参考渲染全屏四边形进行后处理食谱)。
-
创建一个描述符集布局,其中包含一个在片段着色器阶段访问的输入附件。使用准备好的布局分配一个描述符集(参考第五章“描述符集”中的创建描述符集布局和分配描述符集配方)。
-
创建一个 2D 图像(包括内存对象和图像视图),场景将绘制到其中。在创建图像时,不仅要指定
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT使用,还要指定VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT使用。每次应用程序窗口大小调整时都要重新创建图像(参考第五章“描述符集”中的创建输入附件配方)。 -
使用创建的图像句柄通过输入附件更新描述符集。每次应用程序窗口大小调整和图像重新创建时都要这样做(参考第五章“描述符集”中的更新描述符集配方)。
-
准备所有正常渲染场景所需的资源。在创建用于渲染场景的渲染传递时,在渲染传递的末尾添加一个额外的子传递。指定先前子传递中使用的附件作为颜色附件,在额外子传递中作为输入附件。在额外子传递中应使用 swapchain 图像作为颜色附件(参考第六章“渲染传递和帧缓冲区”中的指定子传递描述和创建渲染传递配方)。
-
使用以下 GLSL 代码创建一个顶点着色器模块(参考第八章“图形和计算管线”中的创建着色器模块配方)。
#version 450
layout( location = 0 ) in vec4 app_position;
void main() {
gl_Position = app_position;
}
- 使用以下 GLSL 代码创建一个片段着色器模块:
#version 450
layout( input_attachment_index = 0, set = 0, binding = 0 )
uniform subpassInput InputAttachment;
layout( location = 0 ) out vec4 frag_color;
void main() {
vec4 color = subpassLoad( InputAttachment );
float grey = dot( color.rgb, vec3( 0.2, 0.7, 0.1 ) );
frag_color = grey * vec4( 1.5, 1.0, 0.5, 1.0 );
}
-
创建一个用于绘制后处理阶段的图形管线。使用前面的顶点和片段着色器模块。根据渲染全屏四边形进行后处理配方准备其余的管线参数。
-
在动画的每一帧中,将场景正常渲染到创建的图像中,然后进入下一个子通道(参考第六章的进入下一个子通道配方,渲染通道和帧缓冲区)。绑定用于后处理的创建的图形管线,绑定带有输入附加的描述符集,绑定包含全屏四边形数据的顶点缓冲区,并绘制全屏四边形(参考第五章的绑定描述符集配方,描述符集,以及第八章的绑定管线对象配方,图形和计算管线,以及第九章的绑定顶点缓冲区和绘制几何体配方,命令记录和绘制)。
它是如何工作的...
创建一个在场景渲染的同一渲染通道内渲染的后处理效果分为两个步骤。
在第一步中,我们需要为基本场景准备资源:其几何形状、纹理、描述符集和管线对象等。在第二步中,我们按照渲染全屏四边形用于后处理配方进行全屏四边形的相同操作。
仅为了后处理阶段准备的两个最重要的资源是一个图像和一个图形管线。当以正常方式渲染场景时,图像将作为颜色附加使用。我们只需将场景渲染到图像中,而不是渲染到交换链图像中。图像必须在场景渲染期间作为颜色附加使用,同时在后处理期间作为输入附加使用。我们还必须记住在应用程序窗口大小改变时重新创建它:
InitVkDestroyer( LogicalDevice, SceneImage );
InitVkDestroyer( LogicalDevice, SceneImageMemory );
InitVkDestroyer( LogicalDevice, SceneImageView );
if( !CreateInputAttachment( PhysicalDevice, *LogicalDevice, VK_IMAGE_TYPE_2D, Swapchain.Format, { Swapchain.Size.width,
Swapchain.Size.height, 1 }, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT, VK_IMAGE_VIEW_TYPE_2D,
VK_IMAGE_ASPECT_COLOR_BIT, *SceneImage, *SceneImageMemory, *SceneImageView ) ) {
return false;
}
将图像作为输入附加访问需要我们使用描述符集。它必须包含至少我们的输入附加,因此我们需要创建一个适当的布局。输入附加只能在片段着色器中访问,因此创建描述符集布局、描述符池和分配描述符集可能看起来像这样:
std::vector<VkDescriptorSetLayoutBinding> scene_descriptor_set_layout_bindings = {
{
0,
VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT,
1,
VK_SHADER_STAGE_FRAGMENT_BIT,
nullptr
}
};
InitVkDestroyer( LogicalDevice, PostprocessDescriptorSetLayout );
if( !CreateDescriptorSetLayout( *LogicalDevice, scene_descriptor_set_layout_bindings, *PostprocessDescriptorSetLayout ) ) {
return false;
}
std::vector<VkDescriptorPoolSize> scene_descriptor_pool_sizes = {
{
VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT,
1
}
};
InitVkDestroyer( LogicalDevice, PostprocessDescriptorPool );
if( !CreateDescriptorPool( *LogicalDevice, false, 1, scene_descriptor_pool_sizes, *PostprocessDescriptorPool ) ) {
return false;
}
if( !AllocateDescriptorSets( *LogicalDevice, *PostprocessDescriptorPool, { *PostprocessDescriptorSetLayout }, PostprocessDescriptorSets ) ) {
return false;
}
我们还必须使用我们的颜色附加/输入附加图像句柄更新描述符集。由于图像在应用程序窗口大小改变时会被重新创建,我们必须更新描述符:
ImageDescriptorInfo scene_image_descriptor_update = {
PostprocessDescriptorSets[0],
0,
0,
VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT,
{
{
VK_NULL_HANDLE,
*SceneImageView,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
}
};
UpdateDescriptorSets( *LogicalDevice, { scene_image_descriptor_update }, {}, {}, {} );
我们接下来需要描述的是渲染通道的准备。在这个配方中,渲染通道对场景渲染和后处理阶段都是通用的。场景将在自己的、专用的子通道(或子通道)中渲染。后处理阶段为渲染全屏四边形添加了一个额外的子通道。
通常,我们定义两个渲染通道附件:一个颜色附件(一个交换链图像)和一个深度附件(一个具有深度格式的图像)。这次我们需要三个附件:第一个是一个颜色附件,创建的图像将用于此;深度附件与通常相同;第三个附件也是一个颜色附件,将使用交换链图像。这样,场景就正常渲染到两个(颜色和深度附件)中。然后,第一个附件在后期处理期间用作输入附件;全屏四边形渲染到第二个颜色附件(交换链图像)中,这样最终的图像就会显示在屏幕上。
以下代码设置了渲染通道附件:
std::vector<VkAttachmentDescription> attachment_descriptions = {
{
0,
Swapchain.Format,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
},
{
0,
DepthFormat,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_CLEAR,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
},
{
0,
Swapchain.Format,
VK_SAMPLE_COUNT_1_BIT,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_STORE,
VK_ATTACHMENT_LOAD_OP_DONT_CARE,
VK_ATTACHMENT_STORE_OP_DONT_CARE,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
}
};
渲染通道定义了两个子通道,如下所示:
VkAttachmentReference depth_attachment = {
1,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
};
std::vector<SubpassParameters> subpass_parameters = {
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{},
{
{
0,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
}
},
{},
&depth_attachment,
{}
},
{
VK_PIPELINE_BIND_POINT_GRAPHICS,
{
{
0,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
}
},
{
{
2,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
}
},
{},
nullptr,
{}
}
};
我们也不能忘记渲染通道子通道依赖关系。在这里它们非常重要,因为它们同步了两个子通道。我们无法从纹理中读取数据,直到数据被写入,因此我们需要在 0 和 1 子通道(用于作为颜色和输入附件的图像)之间建立依赖关系。同样,对于交换链图像也需要依赖关系:
std::vector<VkSubpassDependency> subpass_dependencies = {
{
0,
1,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_INPUT_ATTACHMENT_READ_BIT,
VK_DEPENDENCY_BY_REGION_BIT
},
{
VK_SUBPASS_EXTERNAL,
1,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_DEPENDENCY_BY_REGION_BIT
},
{
1,
VK_SUBPASS_EXTERNAL,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_MEMORY_READ_BIT,
VK_DEPENDENCY_BY_REGION_BIT
}
};
后期处理阶段使用的图形管线是标准的。只有两件事不同:图形管线在索引为1的子通道中使用(而不是其他食谱中的0——场景在子通道0中渲染);片段着色器加载颜色数据,不是从组合图像采样器,而是从输入附件。片段着色器中的输入附件定义如下:
layout( input_attachment_index = 0, set = 0, binding = 0 ) uniform subpassInput InputAttachment;
我们使用subpassLoad()函数从中读取数据。它只接受统一变量。纹理坐标是不必要的,因为通过输入附件,我们只能从正在处理的片段关联的坐标读取数据。
vec4 color = subpassLoad( InputAttachment );
片段着色器随后从加载的颜色中计算出一个棕褐色,并将其存储在一个输出变量(颜色附件)中。所有这些组合应该引导我们创建以下结果。在左边我们看到场景正常渲染。在右边我们看到应用了后期处理效果:

参见
-
在第五章“描述符集”中,查看以下食谱:
-
创建输入附件
-
创建描述符集布局
-
分配描述符集
-
更新描述符集
-
绑定描述符集
-
-
在第六章“渲染通道和帧缓冲区”中,查看以下食谱:
-
指定子通道描述
-
创建渲染通道
-
进入下一个子通道
-
-
在第八章“图形和计算管线”中,查看以下食谱:
-
创建着色器模块
-
绑定管线对象
-
-
在第九章“命令录制和绘制”中,查看以下食谱:
-
绑定顶点缓冲区
-
绘制几何体
-
-
本章的食谱 渲染全屏四边形进行后处理


浙公网安备 33010602011771号