概述

本章将首先介绍 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 - 图像视图和帧缓冲

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

步骤 5 - 渲染通道

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

步骤 6 - 图形管线

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

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

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

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

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

  • 开始渲染通道

  • 绑定图形管线

  • 绘制 3 个顶点

  • 结束渲染通道

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

步骤 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 或一个错误代码。规范描述了每个函数可以返回的错误代码以及它们的含义。

验证层

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

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

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

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