Vulkan 教程 Khronos Vulkan® Tutorial · 中文翻译 GitHub

本章先介绍 Vulkan 以及它要解决的问题。接着看看画出第一个三角形需要哪些组成部分,好让你对整体有个大致把握,从而把后续各章放到合适的位置上。最后会讲 Vulkan API 的结构与常见用法模式。

Vulkan 的由来

和以往的图形 API 一样,Vulkan 被设计为对 GPU 的跨平台抽象。 这类 API 大多有个共同问题:它们诞生时,图形硬件基本只能提供可配置的固定功能。 程序员必须以标准格式提供顶点数据,而光照和着色选项则完全受制于 GPU 厂商。

随着显卡架构逐渐成熟,硬件开始提供越来越多的可编程功能。 这些新功能总得以某种方式融入既有 API,结果就产生了并不理想的抽象,也让图形驱动不得不大量猜测程序员的意图,再映射到现代图形架构上。 这就是为什么提升游戏性能的驱动更新如此频繁,有时提升幅度还相当大。 这些驱动如此复杂,应用开发者还得应付不同厂商之间的差异,例如 着色器 可接受的语法。 除了这些新特性,过去十年里还涌现出大量配有强大图形硬件的移动和嵌入式设备。 这类移动 GPU 因功耗与体积要求不同,架构也各不相同。 分块渲染 就是一例:如果能把这项功能的控制权更多地交给程序员,性能就能从中受益。 另一个源自这些 API 年代的局限是多线程支持不足,这可能让 CPU 侧成为瓶颈。

Vulkan 从零开始针对现代图形架构设计,正是为了解决这些问题。 它用一套功能更全、但也更冗长的 API 让程序员清楚表达意图,从而降低驱动开销,并允许多个线程并行创建和提交命令。 它改用标准化的字节码格式与单一编译器,减少了着色器编译上的不一致。 最后,它把图形与计算功能统一进一套 API,正视了现代显卡的通用计算能力。

代码约定

所有 Vulkan 函数、枚举和结构体都定义在 vulkan.h 头文件中,它包含在 Vulkan SDK 里,由 LunarG 开发。下一章会讲如何安装这个 SDK。 本教程使用 vulkan.hpp 头文件提供的 C++ Vulkan API,它随官方 Vulkan SDK 一同发布。 相比原始的 C 版 Vulkan API,这个头文件提供的接口类型安全、对 RAII 友好,用起来也更顺手一些, 同时仍然与底层的 Vulkan 函数和结构体保持非常贴近的低层映射。

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

下面概览一下,在一个规范的 Vulkan 程序里渲染三角形都要经过哪些步骤。 这里引入的所有概念都会在后续章节展开。 此处只是给你一个整体认识,方便把各个组成部分对应起来。

第 1 步:实例与物理设备选择

Vulkan 应用的第一步,是通过 vk::raii::Instance 把 Vulkan API 建立起来。 创建实例时,需要描述你的应用以及将要使用的 API 扩展。 实例创建好之后,就可以查询支持 Vulkan 的硬件,并选出一个或多个 vk::raii::PhysicalDevice 来执行操作。 你可以查询 VRAM 大小、设备能力等属性来挑选想要的设备,比如优先选用独立显卡。

第 2 步:逻辑设备与队列族

选定要用的硬件设备之后,需要创建 vk::raii::Device(逻辑设备),在其中更具体地说明将要使用哪些物理设备特性,例如多视口渲染和 64 位浮点数。 你还要指定想使用哪些队列族。 Vulkan 中的大多数操作,例如绘制命令和内存操作,都是通过提交到 vk::raii::Queue 异步执行的。 队列从队列族中分配,每个队列族在其队列里支持一组特定的操作。 例如,图形、计算和内存传输可能分别有各自的队列族。 队列族的可用情况也可以作为挑选物理设备的一项区分因素。 支持 Vulkan 的设备未必提供任何图形功能;不过如今所有支持 Vulkan 的显卡,通常都支持我们关心的全部队列操作。

第 3 步:窗口表面与交换链

除非你只关心离屏渲染,否则就需要创建一个窗口来呈现渲染结果。 窗口可以用平台原生 API 创建,也可以借助 GLFW、https://www.libsdl.org/[SDL] 这类库。 本教程使用 GLFW,下一章会详细说明。

真正往窗口里渲染还需要另外两样东西:窗口表面(vk::raii::SurfaceKHR)和交换链(vk::raii::SwapchainKHR)。 注意 KHR 后缀,它表示这些对象属于某个 Vulkan 扩展。 Vulkan API 本身完全与平台无关,因此我们要通过标准化的 WSI(Window System Interface,窗口系统接口)扩展与窗口管理器打交道。 窗口表面是跨平台的可渲染窗口抽象,通常通过传入原生窗口句柄的引用(例如 Windows 上的 HWND)来创建。 好在 GLFW 库自带函数来处理这些平台相关的细节。

交换链是一组渲染目标。 它的基本用途是保证我们正在渲染的图像与当前显示在屏幕上的图像不是同一张。 这对确保屏幕只显示完整的图像很重要。 每次要绘制一帧,我们都得向交换链要一张可以渲染的图像。 一帧绘制完成后,图像会交还交换链,由它在某个时刻呈现到屏幕上。 渲染目标的数量以及把画完的图像呈现到屏幕的条件,取决于呈现模式(present mode)。 常见的呈现模式有双缓冲(垂直同步)和三缓冲。 创建交换链那一章会细讲。

某些平台允许借助 VK_KHR_display 和 VK_KHR_display_swapchain 扩展,绕过任何窗口管理器直接渲染到显示器。 它们让你创建代表整个屏幕的表面,例如可以用来实现你自己的窗口管理器。

第 4 步:交换链图像视图

要往从交换链获取的图像上绘制,通常先把它包装成 vk::raii::ImageView。 图像视图引用图像的某个特定部分,供颜色写入等用途使用。 交换链里的图像可能不止一张,所以我们会预先为每张都创建图像视图,绘制时再挑出合适的那一个。

第 5 步:动态渲染概览

在较早的 Vulkan 版本中,渲染通道配合帧缓冲来定义渲染操作如何进行:它会指明所用图像的类型(如颜色、深度),以及这些图像的内容该如何处理(如清除、加载或存储)。 vk::raii::RenderPass 负责定义子通道和附件用途,vk::raii::Framebuffer 则把具体的图像视图绑定到这些附件上。

不过有了动态渲染(Vulkan 1.3 引入)之后,就完全不必创建 vk::raii::Framebuffer 了。 动态渲染免去了预定义渲染通道和帧缓冲的需求,让你可以在录制命令时直接指定渲染附件。 这让 API 简单得多:我们可以随时定义渲染目标,不必操心管理帧缓冲的开销。

使用动态渲染时,不再需要预定义 vk::raii::RenderPass 或 vk::raii::Framebuffer。 你只需在开始录制命令时指定渲染附件:调用 vk::raii::CommandBuffer::beginRendering,并用 vk::RenderingInfo 这类结构体动态提供全部必要的附件信息。

在最初的三角形渲染程序中,我们会用动态渲染指定一张图像作为颜色目标, 并让 Vulkan 在绘制前把它清除为一种纯色。

第 6 步:图形管线

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

与现有 API 相比,Vulkan 最显著的特点之一是:图形管线的几乎所有配置都必须提前设定。 这意味着,如果你想换一个着色器,或者稍微改动顶点布局,就得把整条图形管线重新创建。 渲染操作需要的每一种组合,都得提前创建对应的 vk::Pipeline 对象。 只有视口大小、清除颜色这类基础配置可以动态修改。 所有状态也必须显式描述,例如颜色混合状态就没有默认值。

好消息是:这相当于把即时编译换成了提前编译,驱动因此有更多优化机会。 像切换图形管线这样的大幅状态变更变得非常明确,运行时性能也就更可预测。

第 7 步:命令池与命令缓冲区

前面提过,Vulkan 中许多想执行的操作(例如绘制)都要提交到队列。 这些操作必须先录制到 vk::raii::CommandBuffer 中,才能提交。 命令缓冲区从与某个特定队列族关联的 vk::raii::CommandPool 中分配。 在动态渲染出现之前,要画一个三角形,需要录制包含下列操作的命令缓冲区:

  • 开始渲染通道

  • 绑定图形管线

  • 绘制三个顶点

  • 结束渲染通道

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

这简化了流程:必要的附件可以在需要时再指定,于是更能适应交换链图像被动态选取的场景。 因此你既不必为交换链里的每张图像各录一份命令缓冲区,也不必每帧反复重录同一份。 操作变得更简洁高效,也让 Vulkan 在处理各种渲染场景时更灵活。

第 8 步:主循环

绘制命令都封装进命令缓冲区之后,主循环就相当直白了。 先用 device.acquireNextImageKHR 从交换链获取一张图像。 接着为该图像选出合适的命令缓冲区,用 graphicsQueue.submit() 执行。 最后通过 presentQueue.presentKHR(presentInfo) 把图像交还交换链,呈现到屏幕。

提交到队列的操作是异步执行的。 因此必须用信号量之类的同步对象来保证执行顺序正确。 绘制命令缓冲区的执行要设置为等待图像获取完成;否则可能出现这种情况:我们开始往一张图像上渲染,而它还在被屏幕呈现读取。 反过来,presentQueue.presentKHR(presentInfoKHR) 调用需要等待渲染结束,这里会用第二个信号量,在渲染完成后发出信号。

小结

这趟速览应该能让你对画第一个三角形之前要做的工作有个基本认识。 真实程序还会有更多步骤,比如分配顶点缓冲区、创建统一缓冲区、上传纹理图像,这些会在后面的章节讲到。 不过我们先从简单的开始,毕竟 Vulkan 的学习曲线本来就够陡了。 注意我们一开始会偷个懒:把顶点坐标直接写在顶点着色器里,而不用顶点缓冲区。 这是因为管理顶点缓冲区需要先对命令缓冲区有所熟悉。

简而言之,要画出第一个三角形,我们需要:

  • 创建实例

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

  • 为绘制和呈现创建 Device 与 Queue

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

  • 把交换链图像包装成 vk::raii::ImageView

  • 设置动态渲染

  • 设置图形管线

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

  • 通过获取图像、提交对应的绘制命令缓冲区、再把图像交还交换链来绘制各帧

步骤是不少,但每一步的目的都会在接下来几章里被讲得简单明了。 如果你搞不清某一步与整个程序是什么关系,可以回头再看本章。

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。 创建或销毁对象的函数会有一个 vk::AllocationCallbacks 参数,让你为驱动内存指定自定义分配器,本教程中它也保持为 nullptr。

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

这类调用失败会通过 C++ 异常报告。 异常会携带更多错误信息,其中包括 vk::Result。 这样一来,我们就能在一次调用中检查多个命令,同时保持命令书写简洁。

验证层

前面提过,Vulkan 面向高性能和低驱动开销设计,因此默认只包含非常有限的错误检查与调试能力。 如果你用错了,驱动往往会直接崩溃,而不是返回错误码;更糟的是,它可能在你的显卡上看起来一切正常,在别人的显卡上却彻底失败。

Vulkan 允许通过名为验证层(validation layer)的特性开启大量检查。 验证层是一些可以插在 API 与图形驱动之间的代码,用来对函数参数做额外检查、跟踪内存管理问题等。 它的一大好处是:开发期间可以开启,发布应用时完全关闭,从而零开销。 任何人都能编写自己的验证层,而 LunarG 的 Vulkan SDK 提供了一套标准的验证层,本教程就使用它。 你还需要注册一个回调函数,用来接收验证层发出的调试消息。

由于 Vulkan 对每个操作都如此显式,验证层又如此全面,相比 OpenGL 和 Direct3D,查清屏幕为什么一片漆黑其实可能容易得多!

开始写代码之前只剩一步:搭建开发环境。