概述
本章将从介绍 Vulkan 及其解决的问题开始。之后,我们将了解绘制第一个三角形所需的各个组成部分。这将为您提供一个宏观视角,帮助您理解后续各章的内容。我们将通过介绍 Vulkan API 的结构和一般使用模式来结束本章。
Vulkan 的起源
与之前的图形 API 一样,Vulkan 被设计为 GPU 的跨平台抽象层。这些 API 大多数的问题在于,它们设计的时代中,图形硬件主要限于可配置的固定功能。程序员必须以标准格式提供顶点数据,并且在照明和着色选项方面受制于 GPU 制造商。
随着图形卡架构的成熟,它们开始提供越来越多的可编程功能。所有这些新功能必须以某种方式与现有 API 集成。这导致了不太理想的抽象,以及图形驱动程序方面需要进行大量猜测工作,以将程序员的意图映射到现代图形架构上。这就是为什么有这么多驱动程序更新来提高游戏性能,有时甚至是显著提升。由于这些驱动程序的复杂性,应用程序开发人员还需要处理供应商之间的不一致性,比如 着色器 接受的语法。
除了这些新功能外,过去十年还涌现了大量具有强大图形硬件的移动和嵌入式设备。这些移动 GPU 基于其能源和空间需求有着不同的架构。一个例子是 分块渲染,通过为程序员提供对此功能的更多控制,可以提高性能。另一个源自这些 API 年代的限制是有限的多线程支持,这可能导致 CPU 端的瓶颈。
Vulkan 通过从头开始为现代图形架构设计来解决这些问题。它通过允许程序员使用更加功能丰富但详细的 API 清晰地表达其意图,减少了驱动程序的开销,并允许多个线程并行创建和提交命令。它通过切换到具有单一编译器的标准化字节码格式,减少了着色器编译中的不一致性。最后,它通过将图形和计算功能统一到单一 API 中,承认了现代图形卡的通用处理能力。
绘制三角形需要什么
我们现在将概述在一个行为良好的 Vulkan 程序中渲染三角形所需的所有步骤。这里介绍的所有概念将在后续章节中详细阐述。这只是为了给您提供一个宏观视角,帮助您理解各个组件之间的关系。
步骤 1 - 实例和物理设备选择
Vulkan 应用程序通过创建 VkInstance 来开始设置 Vulkan API。通过描述您的应用程序和您将使用的任何 API 扩展来创建实例。创建实例后,您可以查询支持 Vulkan 的硬件,并选择一个或多个 VkPhysicalDevice 用于操作。您可以查询 VRAM 大小和设备功能等属性来选择所需的设备,例如,优先选择专用图形卡。
对于 vk::raii,我们需要使用 vk::raii::Context,它管理不绑定到 VkInstance 或 VkPhysicalDevice 的函数。
步骤 2 - 逻辑设备和队列族
选择了正确的硬件设备后,您需要创建一个 VkDevice(逻辑设备),在其中更具体地描述您将使用的 VkPhysicalDeviceFeatures,如多视口渲染和 64 位浮点数。您还需要指定要使用的队列族。
使用 Vulkan 执行的大多数操作,如绘制命令和内存操作,都是通过提交到 VkQueue 来异步执行的。队列是从队列族中分配的,每个队列族在其队列中支持一组特定的操作。例如,可能有用于图形、计算和内存传输操作的单独队列族。队列族的可用性也可以作为物理设备选择的区分因素。支持 Vulkan 的设备可能不提供任何图形功能;然而,今天所有支持 Vulkan 的图形卡通常都支持我们感兴趣的所有队列操作。
步骤 3 - 窗口表面和交换链
除非您只对离屏渲染感兴趣,否则您需要创建一个窗口来呈现渲染的图像。窗口可以使用原生平台 API 或像 GLFW 和 SDL 这样的库创建。在本教程中,我们将使用 GLFW,但更多关于这方面的内容将在下一章介绍。
我们需要两个额外的部分来实际渲染到窗口:窗口表面(VkSurfaceKHR)和交换链(VkSwapchainKHR)。注意 KHR 后缀,这表示这些对象是 Vulkan 扩展的一部分。Vulkan API 本身完全与平台无关,这就是为什么我们需要使用标准化的 WSI(窗口系统接口)扩展来与窗口管理器交互。表面是一个跨平台的抽象,用于渲染到窗口,通常通过提供对原生窗口句柄的引用来实例化,例如 Windows 上的 HWND。幸运的是,GLFW 库有一个内置函数来处理这方面的平台特定细节。
交换链是渲染目标的集合。其基本目的是确保我们当前正在渲染的图像与当前显示在屏幕上的图像不同。这对于确保只显示完整的图像非常重要。每次我们想要绘制一帧时,我们必须向交换链请求一个要渲染的图像。当我们完成绘制一帧后,图像会返回到交换链,以便在某个时刻呈现到屏幕上。渲染目标的数量和将完成的图像呈现到屏幕上的条件取决于呈现模式。常见的呈现模式有双缓冲(垂直同步)和三缓冲。我们将在交换链创建章节中详细了解这些。
一些平台允许您通过 VK_KHR_display 和 VK_KHR_display_swapchain 扩展直接渲染到显示器,而无需与任何窗口管理器交互。这些允许您创建代表整个屏幕的表面,并可用于实现您自己的窗口管理器,例如。
步骤 4 - 图像视图和帧缓冲
要绘制到从交换链获取的图像,我们必须将其包装到 VkImageView 和 VkFramebuffer 中。图像视图引用要使用的图像的特定部分,而帧缓冲引用要用作颜色、深度和模板目标的图像视图。由于交换链中可能有许多不同的图像,我们将预先为每个图像创建一个图像视图和帧缓冲,并在绘制时选择正确的一个。
步骤 5 - 渲染通道
Vulkan 中的渲染通道描述了在渲染操作期间使用的图像类型、它们将如何使用以及如何处理它们的内容。在我们初始的三角形渲染应用程序中,我们将告诉 Vulkan 我们将使用单个图像作为颜色目标,并且我们希望它在绘制操作之前被清除为纯色。虽然渲染通道只描述图像的类型,但 VkFramebuffer 实际上将特定图像绑定到这些槽位。
步骤 6 - 图形管线
Vulkan 中的图形管线是通过创建 VkPipeline 对象来设置的。它描述了图形卡的可配置状态,如视口大小和深度缓冲区操作,以及使用 VkShaderModule 对象的可编程状态。VkShaderModule 对象是从着色器字节码创建的。驱动程序还需要知道管线中将使用哪些渲染目标,我们通过引用渲染通道来指定这一点。
与现有 API 相比,Vulkan 最显著的特点之一是,图形管线的几乎所有配置都需要提前设置。这意味着如果您想切换到不同的着色器或稍微更改顶点布局,那么您需要完全重新创建图形管线。这意味着您将需要提前为渲染操作所需的所有不同组合创建许多 VkPipeline 对象。只有一些基本配置,如视口大小和清除颜色,可以动态更改。所有状态也需要明确描述;例如,没有默认的颜色混合状态。
好消息是,因为您正在进行相当于提前编译而不是即时编译,所以驱动程序有更多的优化机会。运行时性能更可预测,因为像切换到不同的图形管线这样的大型状态更改变得非常明确。
步骤 7 - 命令池和命令缓冲区
如前所述,我们想要执行的许多 Vulkan 操作,如绘制操作,需要提交到队列。这些操作首先需要记录到 VkCommandBuffer 中,然后才能提交。这些命令缓冲区是从与特定队列族相关联的 VkCommandPool 分配的。要绘制一个简单的三角形,我们需要记录一个包含以下操作的命令缓冲区:
-
开始渲染通道
-
绑定图形管线
-
绘制三个顶点
-
结束渲染通道
因为帧缓冲中的图像取决于交换链将给我们的特定图像,所以我们需要为每个可能的图像记录一个命令缓冲区,并在绘制时选择正确的一个。另一种选择是每帧重新记录命令缓冲区,但这不太高效。
步骤 8 - 主循环
现在绘制命令已经包装到命令缓冲区中,主循环相当简单。我们首先使用 vkAcquireNextImageKHR 从交换链获取一个图像。然后我们可以为该图像选择适当的命令缓冲区,并使用 vkQueueSubmit 执行它。最后,我们使用 vkQueuePresentKHR 将图像返回到交换链,以便在屏幕上呈现。
提交到队列的操作是异步执行的。因此,我们必须使用信号量等同步对象来确保执行顺序正确。绘制命令缓冲区的执行必须设置为等待图像获取完成;否则,可能会出现我们开始渲染到一个仍在被读取以在屏幕上呈现的图像的情况。vkQueuePresentKHR 调用反过来需要等待渲染完成,为此我们将使用第二个信号量,该信号量在渲染完成后发出信号。
总结
这个快速浏览应该让您对绘制第一个三角形的工作有一个基本的了解。实际的程序包含更多步骤,如分配顶点缓冲区、创建统一缓冲区和上传纹理图像,这些将在后续章节中介绍。然而,我们将从简单开始,因为 Vulkan 本身的学习曲线已经足够陡峭了。请注意,我们最初会通过在顶点着色器中嵌入顶点坐标而不是使用顶点缓冲区来作弊。这是因为管理顶点缓冲区首先需要熟悉命令缓冲区。
所以简而言之,要绘制第一个三角形,我们需要:
-
创建一个 VkInstance
-
选择支持的图形卡(VkPhysicalDevice)
-
创建用于绘制和呈现的 VkDevice 和 VkQueue
-
创建窗口、窗口表面和交换链
-
将交换链图像包装到 VkImageView 中
-
创建指定渲染目标和用法的渲染通道
-
为渲染通道创建帧缓冲
-
设置图形管线
-
为每个可能的交换链图像分配并记录包含绘制命令的命令缓冲区
-
通过获取图像、提交正确的绘制命令缓冲区并将图像返回到交换链来绘制帧
这是很多步骤,但每个步骤的目的将在接下来的章节中变得基本而清晰。如果您对某个步骤与整个程序的关系感到困惑,应该回到本章参考。
API 概念
本章将以 Vulkan API 在较低层次上的结构的简要概述作为结束。
编码约定
所有 Vulkan 函数、枚举和结构体都在 vulkan.h 头文件中定义,该头文件包含在由 LunarG 开发的 Vulkan SDK 中。我们将在下一章中了解如何安装这个 SDK。
函数有一个小写的 vk 前缀,类型如枚举和结构体有一个 Vk 前缀,枚举值有一个 VK_ 前缀。API 大量使用结构体来向函数提供参数。例如,对象创建通常遵循这种模式:
VkXXXCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_XXX_CREATE_INFO;
createInfo.pNext = nullptr;
createInfo.foo = ...;
createInfo.bar = ...;
VkXXX object;
if (vkCreateXXX(&createInfo, nullptr, &object) != VK_SUCCESS) {
std::cerr << "failed to create object" << std::endl;
return false;
}
Vulkan 中的许多结构体要求您在 sType 成员中明确指定结构体的类型。pNext 成员可以指向扩展结构体,在本教程中将始终为 nullptr。创建或销毁对象的函数将有一个 VkAllocationCallbacks 参数,允许您为驱动程序内存使用自定义分配器,在本教程中也将为 nullptr。
几乎所有函数都返回一个 VkResult,它要么是 VK_SUCCESS,要么是一个错误代码。规范描述了每个函数可以返回的错误代码及其含义。
为了帮助说明使用 RAII C++ Vulkan 抽象的实用性;这是使用我们现代 API 编写的相同代码:
auto createInfo = vk::xxx();
auto object = vk::raii::XXX(context, createInfo);
此类调用的失败通过 C++ 异常报告。异常将提供有关错误的更多信息,包括前面提到的 vkResult,这使我们能够从一个调用中检查多个命令,并保持命令语法清晰。
验证层
如前所述,Vulkan 设计用于高性能和低驱动程序开销。因此,默认情况下,它将包含非常有限的错误检查和调试功能。如果您做错了什么,驱动程序通常会崩溃而不是返回错误代码,或者更糟的是,它可能在您的图形卡上看起来工作正常,但在其他卡上完全失败。
Vulkan 允许您通过一个称为 验证层 的功能启用广泛的检查。验证层是可以插入到 API 和图形驱动程序之间的代码片段,用于执行诸如对函数参数进行额外检查和跟踪内存管理问题等操作。好处是您可以在开发期间启用它们,然后在发布应用程序时完全禁用它们,以实现零开销。任何人都可以编写自己的验证层,但 LunarG 的 Vulkan SDK 提供了一套标准的验证层,我们将在本教程中使用。您还需要注册一个回调函数来接收来自层的调试消息。
因为 Vulkan 对每个操作都非常明确,而且验证层非常全面,所以与 OpenGL 和 Direct3D 相比,实际上可能更容易找出为什么您的屏幕是黑的!
在我们开始编写代码之前还有最后一步,那就是 设置开发环境。