本章将首先介绍 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 大小和设备功能等属性来选择所需的设备,例如,优先使用独立显卡。

步骤 2 - 逻辑设备和队列族

在选择要使用的正确硬件设备后,您需要创建一个 VkDevice(逻辑设备),您可以在其中更具体地描述您将要使用的 VkPhysicalDeviceFeatures,例如多视口渲染和 64 位浮点数。您还需要指定您想要使用的队列族。使用 Vulkan 执行的大多数操作,例如绘制命令和内存操作,都是通过将它们提交到 VkQueue 来异步执行的。队列是从队列族分配的,其中每个队列族在其队列中支持一组特定的操作。例如,可能存在用于图形、计算和内存传输操作的单独队列族。队列族的可用性也可以用作物理设备选择中的区分因素。具有 Vulkan 支持的设备可能不提供任何图形功能,但是今天所有具有 Vulkan 支持的显卡通常都支持我们感兴趣的所有队列操作。

步骤 3 - 窗口表面和交换链

除非您只对离屏渲染感兴趣,否则您将需要创建一个窗口来呈现渲染的图像。可以使用原生平台 API 或库(如 GLFWSDL)创建窗口。在本教程中,我们将使用 GLFW,但更多内容将在下一章中介绍。

我们需要另外两个组件才能实际渲染到窗口:窗口表面 (VkSurfaceKHR) 和交换链 (VkSwapchainKHR)。请注意 KHR 后缀,这意味着这些对象是 Vulkan 扩展的一部分。Vulkan API 本身是完全平台无关的,这就是为什么我们需要使用标准化的 WSI(窗口系统接口)扩展来与窗口管理器交互。表面是跨平台抽象,用于渲染到窗口,通常通过提供对原生窗口句柄的引用来实例化,例如 Windows 上的 HWND。幸运的是,GLFW 库具有一个内置函数来处理此平台的特定细节。

交换链是渲染目标的集合。它的基本目的是确保我们当前正在渲染的图像与当前屏幕上的图像不同。这对于确保仅显示完整的图像非常重要。每次我们想要绘制帧时,我们都必须要求交换链为我们提供要渲染的图像。当我们完成绘制帧后,图像将被返回到交换链,以便在某个时候将其呈现到屏幕上。渲染目标的数量以及将完成的图像呈现到屏幕的条件取决于呈现模式。常见的呈现模式是双缓冲(垂直同步)和三缓冲。我们将在交换链创建章节中研究这些。

某些平台允许您直接渲染到显示器,而无需通过 VK_KHR_displayVK_KHR_display_swapchain 扩展与任何窗口管理器交互。这些扩展允许您创建一个表示整个屏幕的表面,并且可以用于实现您自己的窗口管理器等。

步骤 4 - 图像视图和帧缓冲

要绘制到从交换链获取的图像,我们必须将其包装到 VkImageViewVkFramebuffer 中。图像视图引用要使用的图像的特定部分,而帧缓冲引用要用于颜色、深度和模板目标的图像视图。由于交换链中可能存在许多不同的图像,因此我们将先发制人地为每个图像创建一个图像视图和帧缓冲,并在绘制时选择正确的图像视图和帧缓冲。

步骤 5 - 渲染通道

Vulkan 中的渲染通道描述了渲染操作期间使用的图像类型、它们的使用方式以及应如何处理其内容。在我们最初的三角形渲染应用程序中,我们将告诉 Vulkan 我们将使用单个图像作为颜色目标,并且我们希望在绘制操作之前将其清除为纯色。虽然渲染通道仅描述图像的类型,但 VkFramebuffer 实际上将特定图像绑定到这些槽。

步骤 6 - 图形管线

Vulkan 中的图形管线通过创建 VkPipeline 对象来设置。它描述了显卡的可配置状态,例如视口大小和深度缓冲操作,以及使用 VkShaderModule 对象的程序状态。VkShaderModule 对象是从着色器字节码创建的。驱动程序还需要知道将在管线中使用哪些渲染目标,我们通过引用渲染通道来指定这些渲染目标。

与现有 API 相比,Vulkan 最显着的特点之一是,几乎所有图形管线的配置都需要提前设置。这意味着,如果您想切换到不同的着色器或稍微更改您的顶点布局,那么您需要完全重新创建图形管线。这意味着您必须提前为渲染操作所需的所有不同组合创建许多 VkPipeline 对象。只有一些基本配置,例如视口大小和清除颜色,可以动态更改。所有状态也需要显式描述,例如,没有默认的颜色混合状态。

好消息是,由于您正在进行相当于提前编译而不是即时编译的操作,因此驱动程序有更多的优化机会,并且运行时性能更可预测,因为像切换到不同图形管线这样的大状态更改非常明确。

步骤 7 - 命令池和命令缓冲

如前所述,我们想要执行的 Vulkan 中的许多操作(例如绘制操作)都需要提交到队列。这些操作首先需要记录到 VkCommandBuffer 中,然后才能提交。这些命令缓冲是从与特定队列族关联的 VkCommandPool 分配的。要绘制一个简单的三角形,我们需要记录一个包含以下操作的命令缓冲

  • 开始渲染通道
  • 绑定图形管线
  • 绘制 3 个顶点
  • 结束渲染通道

由于帧缓冲中的图像取决于交换链将为我们提供的特定图像,因此我们需要为每个可能的图像记录一个命令缓冲,并在绘制时选择正确的命令缓冲。另一种方法是每帧再次记录命令缓冲,但这效率不高。

步骤 8 - 主循环

现在绘制命令已包装到命令缓冲中,主循环非常简单。我们首先使用 vkAcquireNextImageKHR 从交换链获取图像。然后,我们可以为该图像选择适当的命令缓冲,并使用 vkQueueSubmit 执行它。最后,我们将图像返回到交换链,以便使用 vkQueuePresentKHR 呈现到屏幕。

提交到队列的操作是异步执行的。因此,我们必须使用信号量等同步对象来确保正确的执行顺序。绘制命令缓冲的执行必须设置为等待图像获取完成,否则可能会发生我们开始渲染到仍在读取以在屏幕上呈现的图像。反过来,vkQueuePresentKHR 调用需要等待渲染完成,为此我们将使用第二个信号量,该信号量在渲染完成后发出信号。

总结

这个旋风般的旅程应该让您对绘制第一个三角形的后续工作有一个基本的了解。一个真实的程序包含更多步骤,例如分配顶点缓冲、创建 uniform 缓冲和上传纹理图像,这些将在后续章节中介绍,但我们将从简单开始,因为 Vulkan 本身就有一个陡峭的学习曲线。请注意,我们最初会通过将顶点坐标嵌入到顶点着色器中而不是使用顶点缓冲来作弊。这是因为管理顶点缓冲首先需要熟悉命令缓冲。

简而言之,要绘制第一个三角形,我们需要

  • 创建一个 VkInstance
  • 选择一个受支持的显卡 (VkPhysicalDevice)
  • 创建一个 VkDeviceVkQueue 用于绘制和呈现
  • 创建一个窗口、窗口表面和交换链
  • 将交换链图像包装到 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 或错误代码。规范描述了每个函数可以返回哪些错误代码以及它们的含义。

验证层

如前所述,Vulkan 被设计为高性能和低驱动程序开销。因此,默认情况下,它将包含非常有限的错误检查和调试功能。如果您做错了什么,驱动程序通常会崩溃而不是返回错误代码,或者更糟糕的是,它似乎在您的显卡上工作,但在其他显卡上完全失败。

Vulkan 允许您通过称为验证层的功能启用广泛的检查。验证层是可以在 API 和图形驱动程序之间插入的代码片段,用于执行诸如对函数参数运行额外检查和跟踪内存管理问题之类的操作。好处是您可以在开发期间启用它们,然后在发布应用程序时完全禁用它们,从而实现零开销。任何人都可以编写自己的验证层,但 LunarG 的 Vulkan SDK 提供了一组标准的验证层,我们将在本教程中使用。您还需要注册一个回调函数来接收来自这些层的调试消息。

由于 Vulkan 对每个操作都非常明确,并且验证层非常广泛,因此与 OpenGL 和 Direct3D 相比,找出屏幕变黑的原因实际上可能更容易!

在我们开始编写代码之前,只剩下最后一步,那就是设置开发环境