狸村 Mystic Web · 狸村
目录

本章将首先介绍 Vulkan 及其要解决的问题。之后,我们将了解绘制第一个三角形所需的各个要素。这将帮助你建立一个全局观,以便将后续各章的内容放到正确的位置。最后,我们还会概述 Vulkan API 的结构和通用使用模式。

Vulkan 的起源

与之前的图形 API 一样,Vulkan 被设计为对 GPU 的跨平台抽象。 这些 API 大多存在一个共性问题:它们设计时所处的时代,图形硬件主要限于可配置的固定功能。程序员必须以标准格式提供顶点数据,而在光照和着色选项方面则完全受制于 GPU 制造商。

随着图形卡架构日趋成熟,它们开始提供越来越多的可编程功能。所有这些新功能必须以某种方式集成到现有的 API 中。这导致抽象不够理想,图形驱动程序端需要大量猜测来将程序员的意图映射到现代图形架构上。这就是为什么有如此多的驱动程序更新来提升游戏性能,有时提升幅度还相当显著。由于这些驱动程序的复杂性,应用程序开发者还需要处理不同厂商之间的不一致性,例如着色器接受的语法差异。 除了这些新功能,过去十年间,拥有强大图形硬件的移动和嵌入式设备也大量涌现。这些移动 GPU 根据其能耗和空间需求,采用了不同的架构。其中一例便是 tile-based 渲染,如果能给予程序员对此功能更多的控制权,将能带来性能提升。 这些 API 年代久远带来的另一个限制是多线程支持不足,这可能导致 CPU 端成为瓶颈。

Vulkan 通过为现代图形架构从头设计来解决这些问题。它通过提供更丰富但更冗长的 API,允许程序员清晰地表达其意图,从而减少了驱动程序的开销,并允许多个线程并行创建和提交命令。它通过切换到标准化的字节码格式并使用单一编译器,减少了着色器编译的不一致性。最后,它将图形和计算功能统一到单个 API 中,认可了现代图形卡通用计算的能力。

编码约定

所有 Vulkan 函数、枚举和结构体都在 vulkan.h 头文件中定义,该头文件包含在由 LunarG 开发的 Vulkan SDK 中。我们将在下一章中学习如何安装此 SDK。 在本教程中,我们将使用由 vulkan.hpp 头文件提供的 C++ Vulkan API,该头文件随官方 Vulkan SDK 一同发布。此头文件在原始 C 语言 Vulkan API 之上提供了类型安全、支持 RAII、且更符合人体工程学的接口,同时仍然保持着与底层 Vulkan 函数和结构体非常贴近、低层次的映射关系。

绘制一个三角形需要做些什么

现在,我们将概述在一个规范的 Vulkan 程序中渲染一个三角形所需的所有步骤。 这里引入的所有概念都将在后续章节中详细阐述。这里只是为了让你有一个全局观,以便将各个独立组件联系起来。

步骤 1 - 实例与物理设备选择

Vulkan 应用程序通过创建一个 vk::Instance 来启动 Vulkan API。 创建实例时需要描述你的应用程序以及将要使用的任何 API 扩展。 创建实例之后,你可以查询支持 Vulkan 的硬件,并选择一个或多个 vk::PhysicalDevice 用于操作。你可以查询显存大小、设备能力等属性来选择所需的设备,例如,优先使用独立显卡。

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

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

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

除非你只关心离屏渲染,否则你需要创建一个窗口来呈现渲染后的图像。 窗口可以使用原生平台 API 或像 GLFWSDL 这样的库来创建。本教程将使用 GLFW,更多细节将在下一章介绍。

要真正渲染到窗口,我们还需要两个部分:窗口表面 (vk::SurfaceKHR) 和交换链 (vk::SwapchainKHR)。 注意 KHR 后缀,这意味着这些对象属于 Vulkan 扩展的一部分。Vulkan API 本身是完全平台无关的,因此我们需要使用标准化的 WSI(窗口系统接口)扩展来与窗口管理器交互。 表面是对窗口的跨平台抽象,用于渲染,通常通过提供原生窗口句柄(例如 Windows 上的 HWND)来实例化。 幸运的是,GLFW 库有一个内置函数来处理这些平台相关的细节。

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

某些平台允许你通过 VK_KHR_displayVK_KHR_display_swapchain 扩展,在不与任何窗口管理器交互的情况下直接渲染到显示器。 这些扩展允许你创建一个代表整个屏幕的表面,例如可用于实现你自己的窗口管理器。

步骤 4 - 图像视图与动态渲染

要绘制从交换链获取的图像,我们通常需要将其包装到 vk::ImageViewvk::Framebuffer 中。图像视图引用要使用的图像的特定部分,而帧缓冲则引用将用于颜色、深度和模板目标的图像视图。 由于交换链中可能有多个不同的图像,我们会预先为每个图像创建图像视图和帧缓冲,并在绘制时选择正确的那个。

步骤 5 - 动态渲染概述

在 Vulkan 的早期版本中,渲染通道(render pass)定义了如何使用帧缓冲进行渲染操作,指定了使用的图像类型(如颜色、深度)以及如何处理其内容(如清除、加载或存储)。vk::RenderPass 会定义子通道和附件用法,而 vk::Framebuffer 则将特定的图像视图绑定到这些附件上。

然而,有了动态渲染(在 Vulkan 1.3 中引入),你完全不再需要创建 vk::Framebuffer。 动态渲染消除了对预定义渲染通道和帧缓冲的需求,允许你在命令录制期间直接指定渲染附件。这大大简化了 API,因为我们可以动态地定义渲染目标,而无需担心管理帧缓冲的开销。

有了动态渲染,你不再需要预先定义 vk::RenderPassvk::Framebuffer。 相反,你可以在开始录制命令时指定渲染附件,使用 vk::beginRendering 和诸如 vk::RenderingInfo 这样的结构体来动态提供所有必要的附件信息。

在我们最初的三角形渲染应用程序中,我们将使用动态渲染来指定单个图像作为颜色目标,并指示 Vulkan 在绘制之前将其清除为纯色。

步骤 6 - 图形管线

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

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

好消息是,因为你在做相当于提前编译(ahead-of-time compilation)而非即时编译(just-in-time compilation)的工作,驱动程序有更多的优化机会。运行时性能更加可预测,因为像切换到不同图形管线这样的大状态变化被做得非常明确。

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

如前所述,我们想要执行的许多 Vulkan 操作(如绘制操作)都需要提交到队列。 这些操作在提交之前,首先需要被记录到 vk::CommandBuffer 中。 这些命令缓冲从与特定队列族关联的 vk::CommandPool 中分配。 在动态渲染出现之前,要绘制一个三角形,我们需要记录一个包含以下操作的命令缓冲:

  • 开始渲染通道

  • 绑定图形管线

  • 绘制三个顶点

  • 结束渲染通道

然而,动态渲染改变了这一点。不再是“开始”和“结束”渲染通道,而是在你开始渲染时直接使用 vk::BeginRendering 定义渲染附件。

这简化了流程,允许你动态地指定必要的附件,使其更适应交换链图像被动态选择的场景。因此,你不再需要为交换链中的每个图像记录命令缓冲,也不必每帧重复记录相同的命令缓冲。操作变得更加精简高效,使 Vulkan 在处理渲染场景时更加灵活。

步骤 8 - 主循环

现在绘制命令已经被包装到命令缓冲中,主循环就变得非常直接了。 我们首先使用 device.acquireNextImageKHR 从交换链获取一个图像。 然后,我们可以为该图像选择适当的命令缓冲,并使用 graphicsQueue.submit() 执行它。 最后,我们使用 presentQueue.presentKHR(presentInfo) 将图像返回给交换链,以呈现到屏幕上。

提交到队列的操作是异步执行的。因此,我们必须使用像信号量这样的同步对象来确保正确的执行顺序。 绘制命令缓冲的执行必须设置为等待图像获取完成;否则,可能会出现在我们开始渲染时,图像仍在被读取用于屏幕显示的情况。 而 presentQueue.presentKHR(presentInfoKHR) 调用则需要等待渲染完成,为此我们将使用第二个信号量,它在渲染完成后发出信号。

总结

这个快速概览应该能让你对绘制第一个三角形所需的工作有一个基本了解。 一个真实的程序会包含更多步骤,比如分配顶点缓冲、创建统一缓冲和上传纹理图像,这些将在后续章节中介绍。但我们会从简单开始,因为 Vulkan 本身的学习曲线已经足够陡峭了。 请注意,我们最初会稍微取巧,将顶点坐标直接嵌入顶点着色器,而不是使用顶点缓冲。这是因为管理顶点缓冲首先需要你对命令缓冲有一定的熟悉度。

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

  • 创建一个实例(Instance)

  • 选择一块支持的显卡(PhysicalDevice)

  • 创建用于绘制和呈现的 Device 和 Queue

  • 创建窗口、窗口表面和交换链

  • 将交换链图像包装到 VkImageView

  • 设置动态渲染

  • 设置图形管线

  • 为每个可能的交换链图像分配并记录包含绘制命令的命令缓冲

  • 通过获取图像、提交正确的绘制命令缓冲并将图像返回给交换链来绘制帧

步骤很多,但每个步骤的目的将在接下来的章节中变得基础而清晰。如果你对某个步骤在整个程序中的关系感到困惑,应该回头参考本章。

API 概念

本章最后简要概述一下 Vulkan API 在较低层次上的结构。

例如,对象创建通常遵循以下模式:

vk::XXXCreateInfo createInfo{};
createInfo.sType = vk::StructureType::eXXXCreateInfo;
createInfo.pNext = nullptr;
createInfo.foo = ...;
createInfo.bar = ...;

vk::XXX object;


try {
    object = device.createXXX(createInfo);
} catch (vk::SystemError& err) {
    std::cerr << "Failed to create object: " << err.what() << std::endl;
    return false;
}

Vulkan 中的许多结构体都要求你在 sType 成员中显式指定结构体的类型。 pNext 成员可以指向一个扩展结构体,在本教程中它将始终为 nullptr。 创建或销毁对象的函数会有一个 VkAllocationCallbacks 参数,允许你为驱动内存使用自定义分配器,在本教程中也将保持为 nullptr

几乎所有函数都返回一个 vk::Result,要么是 vk::result::eSuccess,要么是一个错误代码。 规范描述了每个函数可能返回哪些错误代码及其含义。

这些调用的失败会通过 C++ 异常来报告。异常会包含有关错误的更多信息,包括一个 vk::Result。这使我们能够从一次调用中检查多个命令,并保持命令语法的简洁。

验证层

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

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

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

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

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。