【翻译】第一章:下一代 3D 图形 API 入门指南
1

下一代 3D 图形 API 入门指南

Vulkan 是一种革命性的高性能 3D 图形与计算 API,专为现代 GPU 管线架构而设计,以满足社区日益严苛的需求。该 API 提供了一种全新的方法,用于克服现有传统 API 中的复杂性和不足。Vulkan 是一种显式 API,能够保证可预测的行为,并使你在不产生卡顿或掉帧的情况下获得平滑的渲染帧率。本章将概述 Vulkan API,并介绍它相对于其前身 OpenGL API 所具有的独特特性。我们还将了解 Vulkan 的生态系统,并理解其图形系统。

因此,本章将涵盖以下内容:

Vulkan 及其演进历程

自著名的 OpenGL API 诞生以来,至今已将近四分之一个世纪,而它仍在不断演进。从内部实现来看,OpenGL 本质上是一个纯状态机,包含多个以二进制状态(开/关)运行的开关。这些状态用于在驱动程序中构建依赖关系映射,以便管理资源并对其进行最优控制,从而获得最大的性能。

这种状态机以隐式方式自动完成资源管理,但它并不足够智能,无法捕捉应用程序的逻辑,而应用逻辑正是资源管理的核心驱动力。因此,可能会出现一些不可预期的情况,例如在应用程序并未请求的情况下,底层实现发生变化,导致着色器被重新编译。此外,OpenGL API 还可能受到其他因素的影响,例如行为不可预测、多线程扩展性不足、渲染异常等问题。本章后续将通过对比 OpenGL 与 Vulkan API,来帮助理解两者之间的差异。

Vulkan API 由 Khronos 组织于 2016 年发布,其革命性的架构能够充分利用现代图形处理器(GPU),从而构建高性能的图形与计算应用。如果你还不了解 Khronos,它是一个由成员和组织组成的联盟,专注于制定免版税的开放 API 标准。更多信息可参考:https://www.khronos.org

Vulkan 的最初概念由 AMD 设计和开发,基于其专有的 Mantle API。该 API 通过多款游戏展示了前沿的技术能力,验证了其革命性的设计思路,并满足了行业中所有具有竞争力的需求。随后,AMD 将 Mantle API 开源并捐赠给 Khronos。Khronos 联盟在众多软硬件厂商的协作下,共同推动并发布了 Vulkan。

Vulkan 并不是唯一的下一代 3D 图形 API,它的竞争者还包括微软的 DirectX 12 和苹果的 Metal。然而,DirectX 仅限于 Windows 平台,而 Metal 则仅适用于 Mac(OS X 和 iOS)。相比之下,Vulkan 的优势在于其跨平台特性,几乎支持所有主流操作系统,包括 Windows(XP、Vista、7、8 和 10)、Linux、Tizen、SteamOS 以及 Android。

Vulkan 与 OpenGL 的对比

以下是 Vulkan 相比 OpenGL 具有优势的特性与改进:

i
诸如 GLSL、HLSL 或 LLVM 等源语言的编译器,必须以 SPIR-V 规范为目标,并提供生成 SPIR-V 输入的工具。Vulkan 接收这种已准备好用于执行的二进制中间表示,并在着色器阶段使用它。

开始之前需要了解的重要术语

在深入了解基础细节之前,让我们先看一下一些在 Vulkan 中使用的重要技术术语。随着内容的推进,本书还会介绍更多这样的技术名词。

在下一节中,我们将对 Vulkan 进行总体概述,以帮助理解其工作模型和基本原理。同时,我们还将了解命令的语法规则,仅通过观察 API 命令即可对其用法形成初步认识。

学习 Vulkan 的基础知识

本节将介绍 Vulkan 的基础知识,主要包括以下内容:

Vulkan 的执行模型

一个支持 Vulkan 的系统能够查询系统并暴露其上可用的物理设备数量。每个物理设备都会公布一个或多个队列。这些队列被划分为不同的队列族(queue family),而每个队列族都具有非常明确、特定的功能。例如,这些功能可能包括图形、计算、数据传输以及稀疏内存管理。队列族中的每个成员可以包含一个或多个相似的队列,使它们彼此兼容。举例来说,某些实现可能在同一个队列上同时支持数据传输和图形操作。

Vulkan 允许应用程序显式地管理内存控制。它会暴露设备上可用的各种堆(heap)类型,而每个堆都属于不同的内存区域。Vulkan 的执行模型相当简单且直观:命令缓冲区被提交到队列中,然后由物理设备按顺序取出并进行处理。

一个 Vulkan 应用程序负责通过录制大量命令到命令缓冲区中,并将这些命令缓冲区提交到队列,从而控制一组支持 Vulkan 的设备。该队列由驱动程序读取,驱动会按照提交的顺序立即执行这些任务。命令缓冲区的构建过程开销较大,因此一旦构建完成,就可以将其缓存起来,并根据需要多次提交到队列中执行。此外,在应用程序中还可以使用多线程并行地同时构建多个命令缓冲区。

下图展示了执行模型的一个简化示意图:

在该模型中,应用程序会录制两个包含多条命令的命令缓冲区。随后,根据任务的性质,这些命令会被提交到一个或多个队列中。队列再将这些命令缓冲区任务提交给设备进行处理。最终,设备处理完成后,要么将结果显示到输出设备上,要么将结果返回给应用程序以供进一步处理。

在 Vulkan 中,应用程序需要负责以下内容:

Vulkan 的队列

队列是 Vulkan 中将命令缓冲区送入设备进行处理的媒介。命令缓冲区中录制了一条或多条命令,并被提交到所需的队列中。设备可能会暴露多个队列,因此,将命令缓冲区提交到正确的队列是应用程序的责任。

命令缓冲区可以被提交到以下类型的队列中:

Vulkan 提供了多种同步原语,使你能够对执行任务的执行顺序进行相对精细的控制,无论是在单个队列内还是在多个队列之间。具体如下:

对象模型

在应用层面,所有实体(包括设备、队列、命令缓冲区、帧缓冲、管线等)都被称为 Vulkan 对象。在 API 的内部层面,这些 Vulkan 对象通过句柄来标识。句柄分为两种类型:可分派句柄和不可分派句柄。

VkInstance VkCommandBuffer VkPhysicalDevice VkDevice VkQueue
VkSemaphore VkFence VkQueryPool VkBufferView
VkDeviceMemory VkBuffer VkImage VkPipeline
VkShaderModule VkSampler VkRenderPass VkDescriptorPool
VkDescriptorSetLayout VkFramebuffer VkPipelineCache VkDescriptorSet
VkEvent VkCommandPool VkPipelineLayout VkImageView

对象的生命周期与命令语法

在 Vulkan 中,对象的创建与销毁需要按照应用程序的逻辑显式进行,其生命周期由应用程序自行管理。

Vulkan 对象通过 Create 命令创建,并通过 Destroy 命令销毁:

对于作为现有对象池(object pool)或堆(heap)一部分创建的对象,则使用 Allocate 命令进行分配,并通过 Free 命令从对象池或堆中释放:

与具体 Vulkan 实现相关的信息,都可以通过 vkGet* 命令方便地获取。以 vkCmd* 形式命名的 API 用于在命令缓冲区中录制命令。

错误检查与验证

Vulkan 在设计时以性能为核心,通过将错误检查和验证机制设为可选,以实现尽可能高的执行效率。在运行时,错误检查和验证带来的性能开销极低,从而使命令缓冲区的构建与提交过程非常高效。

这些可选能力可以通过 Vulkan 的分层架构来启用。该架构允许在系统运行过程中动态注入各种层(如调试层和验证层),以提供调试与验证支持。

理解 Vulkan 应用程序

本节将概述 Vulkan 应用程序的各个组成部分,这些部分对构建 Vulkan 应用程序至关重要。

下面的框图展示了系统中不同组件模块及其相互间的连接关系:

驱动

一个支持 Vulkan 的系统至少包含一个 CPU 和一个 GPU。独立硬件厂商(IHV)会针对其专用的 GPU 架构,提供符合 Vulkan 规范的驱动程序实现。驱动程序充当应用程序与设备本身之间的接口。它为应用程序提供高层次的功能,使应用能够与设备进行通信。例如,驱动程序会向应用提供系统中可用设备的数量、这些设备所支持的队列及队列能力、可用的内存堆及其相关属性等信息。

应用

应用是指用户编写的程序,目的是使用 Vulkan API 来执行图形或计算任务。应用首先进行硬件和软件的初始化;在此过程中,它会检测驱动程序并加载所有 Vulkan API。显示层通过 Vulkan 的窗口系统集成(WSI)API 进行初始化;WSI 有助于将绘制得到的图像呈现到显示表面上。应用创建资源,并使用描述符将这些资源绑定到着色器阶段。描述符集布局用于将已创建的资源绑定到所创建的底层管线对象(图形管线或计算管线)。最后,应用记录命令缓冲区,并将其提交到队列中进行处理。

WSI

窗口系统集成(Window System Integration,WSI)是 Khronos 提供的一组扩展,用于在不同平台(如 Linux、Windows 和 Android)之间统一显示层的实现方式。

SPIR-V

SPIR-V 为 Vulkan 提供了一种用于描述着色器的预编译二进制格式。针对多种着色器源语言(包括 GLSL 和 HLSL 的不同变体),都提供了相应的编译器,可将其编译生成 SPIR-V。

LunarG SDK

LunarG 提供的 Vulkan SDK 包含多种工具和资源,用于辅助 Vulkan 应用程序的开发。这些工具和资源包括 Vulkan 加载器、验证层、跟踪与回放工具、SPIR-V 工具、Vulkan 运行时安装程序、文档、示例以及演示程序。有关如何开始使用 LunarG SDK 的详细说明,请参见第 3 章《与设备握手》。更多信息可访问:http://lunarg.com/vulkan-sdk

入门 Vulkan 编程模型

让我们详细探讨 Vulkan 的编程模型。这里假设读者是一名完全的初学者,通过本节内容可以理解以下概念:

下图展示了 Vulkan 应用程序编程模型的自顶向下视角;我们将对这一流程进行深入讲解,并进一步探讨各个子层级组件及其功能。

硬件初始化

当一个 Vulkan 应用程序启动时,其首要任务是进行硬件初始化。在这一阶段,应用程序通过与加载器进行通信来激活 Vulkan 驱动程序。下图展示了 加载器 及其子组件的框图结构。

加载器:加载器是一段在应用程序启动阶段使用的代码,用于以跨平台、统一的方式在系统中定位 Vulkan 驱动程序。加载器的主要职责包括:

Vulkan 应用程序首先与加载器库进行一次握手,并初始化 Vulkan 实现的驱动程序。加载器库以动态方式加载 Vulkan API。加载器还提供了一种机制,可将特定的层自动加载到所有 Vulkan 应用程序中,这种层被称为 隐式启用层(Implicit-Enabled layer)

在加载器定位到驱动程序并成功与 API 建立链接之后,应用程序需要负责完成以下工作:

窗口呈现表面

当加载器成功定位到 Vulkan 实现的驱动程序后,就可以开始使用 Vulkan API 进行绘制了。为此,需要准备一张用于执行绘制操作的图像,并将其呈现到显示窗口中:

构建用于呈现的图像以及创建窗口本身都是高度依赖平台的工作。在 OpenGL 中,窗口系统与渲染过程是紧密耦合的;窗口系统的帧缓冲会在创建上下文/设备时一并生成。与 OpenGL 的这种方式相比,Vulkan 的一个重大区别在于:Vulkan 中的上下文/设备创建完全不需要涉及窗口系统,相关工作通过 窗口系统集成(WSI) 来管理。

WSI 提供了一组跨平台的窗口管理扩展,其特点包括:

WSI 支持多种窗口系统(如 Wayland、X 和 Windows),同时还通过交换链(swapchain)来管理图像的所有权。

WSI 提供了一种交换链机制;通过这种机制,可以使用多张图像,从而实现当窗口系统正在显示一张图像时,应用程序可以同时准备下一张图像。

下图展示了双重缓冲(double-buffering)的图像交换过程。图中包含两张图像,分别命名为 第一张图像第二张图像。在 WSI 的帮助下,这两张图像在 应用程序显示端 之间进行交换:

WSI 充当了 显示端应用程序 之间的接口。它确保 显示端应用程序 以互斥的方式获取这两张图像。因此,当 应用程序 正在处理 第一张图像 时,WSI 会将 第二张图像 移交给显示端以渲染其内容。一旦 应用程序 完成了对 第一张图像 的绘制,就会将其提交给 WSI,并随即获取 第二张图像 进行处理,反之亦然。

这一步,我们需要进行以下操作:

资源设置

设置资源意味着将数据存储到内存区域中。这些数据可以是任何类型的,例如顶点属性(如位置、颜色),或者图像类型/名称。当然,这些数据必须已经存在于内存中的某个位置,才能被 Vulkan 访问。

与 OpenGL 不同,OpenGL 通过提示(hints)在幕后管理内存,而 Vulkan 提供了完全的底层访问权限和对内存的控制。Vulkan 会在物理设备上公布各种可用的内存类型,为应用程序显式地管理这些不同类型的内存提供了良好的机会。

内存堆(Memory heaps)可以根据性能分为两种类型:

内存堆还可以根据其内存类型配置进一步划分:

在 Vulkan 中,资源由应用程序显式地进行管理,应用程序对内存管理拥有完全且独占的控制权。以下是资源管理的流程:

TIP

物理内存分配的开销很大;因此,一个良好的实践是先分配一大块物理内存,然后在其上对子对象进行子分配。

相比之下,OpenGL 的资源管理并不提供对内存的细粒度控制。它并不存在主机内存与设备内存的概念;驱动程序会在后台悄悄完成所有的内存分配。此外,这些分配与子分配过程并非完全透明,并且可能因驱动程序不同而有所变化。这种不一致性和隐藏的内存管理会导致不可预测的行为。而 Vulkan 则不同,它会直接在所选择的内存中分配对象,从而使行为高度可预测。

因此,在资源设置阶段,你需要执行以下任务:

  1. 创建资源对象。
  2. 查询合适的内存实例,并创建内存对象,例如缓冲区和图像。
  3. 获取分配所需的内存需求。
  4. 分配内存空间并将数据存储到其中。
  5. 将分配的内存绑定到之前创建的资源对象上。

管线设置

管线是一组按照应用程序逻辑所定义的固定顺序发生的事件。这些事件包括:提供着色器、将着色器绑定到资源,以及管理相关的状态。

描述符集与描述符池

描述符集是资源与着色器之间的接口。它是一种简单的数据结构,用于将着色器与资源信息(如图像或缓冲区)进行绑定。描述符集关联(或绑定)了着色器即将使用的资源内存。描述符集具有以下特性:

TIP

更新或更改描述符集是 Vulkan 渲染过程中性能最关键的路径之一。因此,描述符集的设计是实现最高性能的重要因素。Vulkan 支持在逻辑上将多个描述符集进行划分,分别用于场景级(低频更新)、模型级(中频更新)以及绘制级(高频更新)。这种划分可以确保高频更新的描述符不会影响低频更新的描述符资源。

使用 SPIR-V 的着色器

在 Vulkan 中,指定着色器或计算内核的唯一方式是通过 SPIR-V。其主要特性包括:

管线管理

物理设备包含一系列硬件设置,用于决定如何解释并绘制所提交的几何体输入数据。这些设置统称为 管线状态(pipeline states)。它们包括光栅化状态、混合状态以及深度/模板状态;同时还包括所提交几何体的图元拓扑类型(点 / 线 / 三角形)以及用于渲染的着色器。管线状态分为两类:动态状态和静态状态。这些管线状态用于创建管线对象(图形管线或计算管线),而管线对象的创建正是一个性能关键路径。因此,我们不希望反复创建管线对象,而是希望一次创建、重复使用。

Vulkan 允许你通过管线对象,并结合 管线缓存对象(Pipeline Cache Object,PCO) 以及 管线布局(pipeline layout) 来控制这些状态:

在管线管理阶段,主要会发生以下流程:

命令录制

命令录制是命令缓冲区形成的过程。命令缓冲区从命令池的内存中分配,命令池也可以用于多次分配多个命令缓冲区。命令缓冲区的录制是在应用程序所定义的起始与结束范围内,通过向其中提交一系列命令来完成的。下图展示了一个绘制命令缓冲区的录制过程。可以看到,它由多条按照自上而下顺序录制的命令组成,这些命令共同负责对象的绘制。

i
需要注意的是,命令缓冲区中包含的命令会根据具体任务需求而有所不同。该图仅作为示意,涵盖了在绘制图元时最常见的一些步骤。

绘制过程的主要组成部分如下:

TIP

命令缓冲区的创建是一项开销较大的操作,属于性能最关键的路径之一。如果在多个帧中需要重复执行相同的工作,命令缓冲区可以被多次复用,并且无需重新录制即可再次提交。同时,还可以通过多线程并行地生成多个命令缓冲区。Vulkan 在设计之初就充分考虑了多线程可扩展性;在多线程环境中使用命令池可以避免资源锁竞争。

下图展示了一种基于多核、多线程的可扩展命令缓冲区创建模型。该模型能够在多核处理器上实现真正的并行执行。

在该模型中,每个线程都会使用独立的命令缓冲池,由其分配一个或多个命令缓冲区,从而避免对资源锁的竞争。

队列提交

一旦命令缓冲区构建完成,就可以将其提交到队列中进行处理。Vulkan 向应用程序暴露了多种类型的队列,例如图形队列、DMA / 传输队列以及计算队列。提交时所选择的队列类型高度依赖于具体的任务性质。例如,与图形相关的任务必须提交到图形队列;同样地,对于计算操作,计算队列通常是最佳选择。已提交的任务会以异步方式执行。命令缓冲区可以被推送到多个彼此兼容的队列中,从而实现并行执行。应用程序需要自行负责命令缓冲区内部、不同队列之间,甚至主机与设备之间的所有同步工作。

队列提交阶段主要执行以下操作:

总结

本入门章节将 Vulkan 的复杂性提炼到了一个使初学者也能轻松理解的层次。在本章中,我们了解了 Vulkan 的发展演进,并探究了其背后的历史与贡献者。随后,我们将 Vulkan 与 OpenGL 进行了对比,理解了它在现代计算时代存在的原因和意义。我们还回顾了与该 API 相关的重要技术术语,并给出了简明易懂的定义。Vulkan API 的基础知识部分为其工作模型提供了一个精确且详尽的概览。我们还介绍了 Vulkan 生态系统中的基本构建模块,并了解了它们各自的角色、职责以及相互之间的关联。最后,在本章结尾,我们通过一种易于理解的伪编程模型,对 Vulkan 的工作方式进行了说明。

完成本章后,你应当能够对 Vulkan API 以及其详细的工作模型建立起基本的理解,并熟悉相关的技术术语,从而迈出 Vulkan 编程的第一步。

在下一章中,我们将采用伪代码的方式正式开始 Vulkan 编程实践。我们会构建一个简单示例,在不过多深入细节的前提下,涵盖 Vulkan API 的重要核心概念、基础内容以及关键数据结构,以帮助理解 Vulkan 中图形管线编程的完整流程。