Vulkan 是一套革命性的高性能 3D 图形、计算 API,适用于现代 GPU 管线系统,用来满足社区的苛刻要求。 这套 API 提供了一种全新的方式来克服现有传统 API 的复杂性和差异性。
Vulkan 是一套“显式”的 API,承诺可预测的行为,并允许您在不引起延迟或拖尾的情况下拥有平滑的渲染帧率。 本章将对 Vulkan API 以及相对于它的前身 OpenGL API 来说一些独特的功能进行一下简单的概述。 我们首先来了解一下 Vulkan 的生态系统,并理解它的图形系统。
所以我们将涵盖以下主题:
- Vulkan 及其演变
- Vulkan 与 OpenGL
- 在我们开始之前重要的话题
- 学习 Vulkan 的基础知识
- 了解 Vulkan 应用程序
- Vulkan 编程模型入门
Vulkan 及其演变
距有名的 OpenGL API 诞生已有将近四分之一世纪的时间,而且还在不断发展中, 在其内部,它是一个纯粹的状态机,包含多个工作在二进制状态下(开状态 / 关状态)的开关。 这些状态用于在驱动程序中构建依赖关系的映射,实现资源的管理并以最优的方式控制资源,从而获得最佳的性能。 这种状态机隐式地进行自动化资源的管理,但是对于捕获应用程序的逻辑来说,还不够智能,而这恰恰正是资源管理背后的驱动力, 因此,可能会出现一些意想不到的情况,例如实现中断,即使应用程序未请求重新编译着色器,也会导致重新编译着色器。 另外,OpenGL API 可能还会受到其他因素的影响,例如不可预知的行为,多线程可伸缩性,渲染故障等。 在本章的后续内容中,我们将比较 OpenGL 和 Vulkan API,以了解两者之间的区别。
Vulkan API 于 2016 年由 Khronos 推出,是一种具有革命性的架构,充分利用了现代图形处理器单元,用来生成高性能的图形和计算应用程序。 如果您不知道 Khronos,它是一个多个会员和组织的协会,专注于为免授权费的 API 制定开放标准。 有关更多信息,请参阅 https://www.khronos.org。
Vulkan 的最初概念是由 AMD 根据其专有的 Mantle API 设计和开发的。 该 API 通过几款游戏展示了先进的功能,从而证明了其革命性的方法并满足了行业的所有竞争需求。 AMD 将他们的 Mantle API 开源并将其捐赠给 Khronos。 Khronos 联盟在许多其他硬件和软件供应商的帮助下,共同努力发布 Vulkan。
Vulkan 并非唯一的次世代 3D 图形 API; 还有一些竞争对手,比如微软的 Direct-X 12 和苹果的 Metal。 但是,Direct-X 仅限于 Windows 系统,而 Metal 只适用于 Mac 系统(OS X 和 iOS)。 Vulkan 在这方面比较与众不同。 其跨平台特性支持几乎所有可用的操作系统平台,其中包括 Windows(XP,Vista,7,8 和 10),Linux,Tizen,SteamOS 以及 Android。
Vulkan 对比 OpenGL
以下是 Vulkan 中的特性和改进,相较于 OpenGL 具有更多的优势:
降低驱动程序的开销以及 CPU 使用率:Vulkan 旨在更接近底层的图形硬件。 因此,它为上层的应用程序员提供了对主机计算资源的直接控制,以使 GPU 尽可能快地进行渲染, 这种方式同时也允许软件直接访问图形处理器,从而获得更好的性能。
多线程可扩展性:OpenGL 中的多线程扩展效果非常差,要想利用线程的特性,从而更好地利用 CPU 是一件非常困难的事。 然而,Vulkan 对此进行了专门设计,用于允许终端用户以一种非常透明的方式充分利用 CPU 的多线程特性,不存在隐式的全局状态。 从创建作业以及提交作业(用于执行)时开始,不同线程下的作业之间会保持分离。
一套“显式”的 API:OpenGL 是一套“隐式”的 API,其中的资源管理是驱动程序的责任。 驱动程序需要应用程序的提示并跟踪资源的状态,这是一种不必要的开销。Vulkan 是一套“显式”的 API; 在这里,驱动程序不负责跟踪资源以及它们之间的关系, 把这项任务分配给了应用程序。 这种干净的方法更可预测;驱动程序不会在幕后执行某些操作来管理资源(就像在 OpenGL 中一样)。 因此,作业的处理简化且直接,从而实现最佳性能和可预测的行为。
预编译的中间着色语言:与需要着色器 shader 作为 OpenGL 着色语言(GLSL)源代码提供的 OpenGL 不同,SPIR-V(Standard Portable Intermediate Language :标准可移植中间语言)是 Vulkan 用于并行计算和图形操作的标准中间语言。
注意!
用于源语言的编译器,例如 GLSL,HLSL 或 LLVM 必须符合 SPIR-V 规范,并提供实用的工具程序来提供 SPIR-V 的输入。 Vulkan 采用 Vulkan 这种即时执行的二进制中间输入形式并会在着色器阶段使用。
- 驱动程序层和应用程序层:在 OpenGL 中,与驱动程序层相比,应用程序层更薄,因为驱动程序的自动化考虑了资源管理和状态跟踪,Vulkan 正好与此相反。 它会确保驱动程序更接近底层的硬件且开销更小。 管理逻辑、资源和状态是应用程序的责任。 下图显示了这两个 API 的驱动程序和应用程序代码库的厚度:
内存控制:Vulkan 能够在系统上暴露若干种内存类型,并要求应用程序开发人员为每个资源的预期用途选择适当的内存类型。 相比之下,OpenGL 驱动程序则会根据内部启发模式决定资源的放置位置,不同供应商之间的启发模式存在一定的差异,如果稍后驱动程序移动了资源,OpenGL 可能会产生次优的放置或出现意外的故障。
可预测性:与 OpenGL 相比,Vulkan 具有高度的可预测性;它在渲染时不会导致任何滞后或挂起。 一旦将作业提供给驱动程序,就会立即提交作业,而 OpenGL 作业提交过程不是预先提供的,而是受到驱动程序调度程序的支配。
一套 API:OpenGL 为桌面 API(OpenGL)和嵌入式 API(OpenGL ES)提供了不同的版本。 Vulkan 很干净,只有一套适用于所有平台的 API。 Vulkan 支持移动平台作为一等公民,对所有的平台一视同仁,而 OpenGL 并非如此。 通常,OpenGL 实现首先出现在基于桌面的版本上,随后才是可用于嵌入式的 OpenGL ES API。
直接访问 GPU:Vulkan 通过公开其功能和硬件设施,为应用程序用户提供了很多控制权。 它公开了各种可用的物理设备、内存类型、命令缓冲区队列以及扩展。 这种行为可以确保软件层更接近真实的硬件。
错误检查和验证:当使用 OpenGL 时,运行良好的应用程序在检查错误的时候会付出一些代价,而错误在执行的时候根本就不会触发。 相比之下,Vulkan 将这些检查和验证作为附加服务来提供,可以在需要时启用和禁用。 这些检查是可选的,可以通过启用错误检查和其他的验证层注入到运行时。 因此,通过避免不必要的检查,可以减少 CPU 开销。 理想情况下,这些错误和验证层必须在开发阶段的调试期间打开,并在发布期间关闭。
支持各种 GPU 硬件:Vulkan 支持移动设备光栅化器和桌面光栅器作为实现的集成部分。 它支持嵌入式平台的基于瓦片或延期的光栅化器以及本地基于平铺的前反馈光栅化器。
在我们开始之前重要的话题
在深入探讨基本细节之前,先来看看 Vulkan 中用到的一些比较重要技术术语。 随着我们的进一步深入,本书还会涵盖更多的技术术语。
物理设备和设备:系统可能包含多个具有 Vulkan 功能的物理硬件设备。 物理设备表示唯一的设备,而设备 device 则是指应用程序中物理设备的逻辑表示,即逻辑设备。
队列:队列表示执行引擎和应用程序之间的接口。 物理设备始终包含一个或多个队列(图形队列、计算队列、DMA 队列 / 传输队列等)。 队列的职责是收集作业(命令缓冲区)并将其分派给物理设备进行处理。
内存类型:Vulkan 公开了各种内存类型。 在更广泛的层面上,有两种类型的内存:主机内存和设备内存。 在我们继续阅读本章时,我们就会介绍这些内容。
-
命令:命令是做某种行为的指令。 命令可以大致分为动作,设置状态或者同步。
动作命令:这些命令可用于绘制图元、清除表面、复制缓冲区,查询 / 时间戳操作以及开始 / 结束子通道操作。 这些命令能够更改帧缓冲区附件,读取或写入内存(缓冲区或图像)以及编写查询池。
设置状态命令:这些命令用来绑定管线、描述符集和缓冲区;它们还用于设置动态状态并渲染通道 / 子通状态。
同步命令:同步有助于满足两个或多个操作命令的要求,这些操作命令可能会争夺资源或具有一些内存依赖性。 其中包括设置事件、等待事件,插入管线屏障以及渲染通道或子通道的依赖关系。
命令缓冲区:命令缓冲区是一组命令;它会记录这些命令并将它们提交给队列。
在下一节中,我们会对 Vulkan 进行阐述,以帮助我们了解它的工作模式以及一般的基础知识。 我们还会理解其命令的语法规则,通过简单地查看它们的声明来了解 API 命令的概念。
学习 Vulkan 的基础知识
本节将会介绍 Vulkan 的基础知识。 这里我们将讨论以下内容:
- Vulkan 的执行模型
- Vulkan 的队列
- 对象模型
- 对象生命周期和命令语法
- 错误检查和验证
Vulkan 的执行模型
具有 Vulkan 功能的系统能够查询并显示系统上可用的物理设备的数量。 每个物理设备会暴露一个或多个队列。 这些队列分为不同的族,每个族都有特定的功能。 例如,这些功能可能包括图形、计算、数据传输以及稀疏内存管理。 队列族的每个成员都可以包含一个或多个类似的队列,从而使它们相互兼容。 例如,给定的实现可能支持同一队列上的数据传输和图形操作。
Vulkan 允许开发人员通过应用程序对内存控制进行显式的管理, 它公开了设备上可用的各种类型的堆,其中的每个堆属于不同的内存区域。 Vulkan 的执行模式非常简单直接, 此处,命令缓冲区会被提交到队列中,然后由物理设备使用,以便进行各种处理。
Vulkan 应用程序负责控制各种 Vulkan 设备,这是通过把大量的命令记录到命令缓冲区并将它们提交到队列中实现的。 该队列由驱动程序读取,驱动程序会按提交的顺序预先执行作业。 命令缓冲区的构建非常昂贵, 因此,一旦构建完成,就可以对它进行缓存以及将其提交到队列中,以便根据具体的需求执行若干次。 此外,在应用程序中,可以使用多线程同时并行构建多个命令缓冲区。
下图显示了执行模型的简化图示:
在这里,应用程序记录了两个包含多个命令的命令缓冲区。 然后根据工作性质将这些命令提供给一个或多个队列。 队列将这些命令缓冲区作业提交给设备进行处理。 最后,设备处理结果并将其显示在输出显示屏上,或将它们返回给应用程序进行进一步的处理。
在 Vulkan 中,应用程序负责以下内容:
为成功执行命令提供所有必要的先决条件:这其中可能包括准备资源、预编译的着色器以及将资源附加到着色器、指定渲染状态、构建管线以及绘制调用。
内存管理
-
同步
- 主机和设备之间的同步
- 设备上可用的不同队列之间的同步
危害管理
Vulkan 的队列
队列是 Vulkan 中的媒介,就是通过它将命令缓冲区送入设备的。 命令缓冲区会记录一个或多个命令并将它们提交到所需的队列。 设备也可能会公开多种队列,因此,应用程序有责任将命令缓冲区提交给正确的队列。
可以将命令缓冲区提交给以下几项:
- 一个队列
- 命令缓冲区的提交顺序以及执行、回放都将保持不变
- 命令缓冲区以串行方式执行
- 多个队列
- 允许在两个或多个队列中并行执行命令缓冲区。
- 除非明确指定,否则无法保证命令缓冲区的提交和执行顺序, 同步它们的顺序是应用程序的责任;如果没有进行同步,执行的顺序可能完全超出预期。
Vulkan 提供了几种同步方式,使您可以在单个队列或跨越多个队列对作业的执行进行相对控制。 这些同步方式有:
-
信号量(Semaphore):
该同步机制可以跨多个队列进行同步,或在单个队列中同步粗粒度的命令缓冲区提交。 -
事件(Events):
事件用来控制细粒度的同步并且被应用于单个队列,允许我们对提交给单个队列的一个命令缓冲区或若干个命令缓冲区序列之间进行同步工作。 宿主机也可以参与基于事件的同步。 - 栅栏(Fences) :该方式能够在主机和设备之间进行同步操作。
- 管线屏障(Pipeline barriers): 管线屏障是一个插入指令,用于确保在在该命令缓冲区中,在管线屏障之前的命令必须在被指定的、管线屏障之后的命令之前执行。
对象模型
在应用程序级别,所有的实体(包括设备、队列、命令缓冲区、帧缓冲区、管线等)都称为 Vulkan 对象。 在内部,在 API 级别,这些 Vulkan 对象用句柄进行识别。 这些句柄有两种类型:可分发句柄和不可分发句柄。
- 可分发句柄(A dispatchable handle):这是一个指针,指向了内部不透明形状的实体(opaque-shaped entity)。 不透明的类型不允许您直接访问这种结构的字段。 只能使用 API 例程访问这些字段。 每个可分发句柄都有一个关联的可分发类型(dispatchable type)----- 用于在 API 命令中作为参数传递。 下面是一些示例:
VkInstance | VkCommandBuffer | VkPhysicalDevice | VkDevice | VkQueue |
---|
- 不可分发句柄(Non-dispatchable handles):这些是 64 位整型类型的句柄,可以包含对象信息本身,而不是指向结构的指针。 示例如下:
VkSemaphore | VkFence | VkQueryPool | VkBufferView |
---|---|---|---|
VkDeviceMemory | VkBuffer | VkImage | VkPipeline |
VkShaderModule | VkSampler | VkRenderPass | VkDescriptorPool |
VkDescriptorSetLayout | VkFramebuffer | VkPipelineCache | VkCommandPool |
VkDescriptorSet | VkEvent | VkPipelineLayout | VkImageView |
对象生命周期和命令语法
在 Vulkan 中,根据每个应用程序的逻辑,都要显式创建和销毁对象,并且应用程序要负责管理这些对象。
Vulkan 中的对象使用 Create 创建并使用 Destroy 命令销毁:
- 创建语法:对象是使用 vkCreate *形式的命令创建的;这类命令接受一个 Vk * CreateInfo 结构作为输入参数。
- 销毁语法:对应的,使用 vkCreate*命令生成的对象要使用 vkDestroy *进行销毁。
作为现有对象池或堆的一部分而创建的对象要使用 Allocate 命令创建并使用 Free 命令从池或者堆中进行释放。
- 分配语法:作为对象池的一部分创建的对象使用 vkAllocate *形式的命令,并且使用 Vk * AllocateInfo 作为输入参数。
- 销毁语法:相应的,使用 vkFree *命令把对象释放回池或者内存中。
使用 vkGet *命令可以轻松访问任何给定的实现信息。 vkCmd *形式的 API 实现用于在命令缓冲区中记录命令。
错误检查和验证
Vulkan 专为提供高性能而设计,这是通过保持错误检查和验证功能作为一种可选项的形式实现的。 在运行时,错误检查和验证的部分少之又少,从而使得构建命令缓冲区和提交变得更加高效。 这些可选功能可以通过 Vulkan 的分层体系结构来实现,该分层体系结构允许把各种层(调试层和验证层)动态注入到正在运行的系统中。
了解 Vulkan 应用
本节将为您提供有助于构建 Vulkan 应用程序的各种组件的概述。
以下框图显示了系统中不同组件模块以及对应的联系:
驱动程序
具有 Vulkan 功能的系统至少包含一个 CPU 和 GPU。 IHV 的供应商为其专用 GPU 架构提供了指定 Vulkan 规范实现的驱动程序。 驱动程序充当应用程序和设备本身之间的接口。 它为应用程序提供了一些高级设施,以便能够与设备进行通信。 例如,驱动程序通知了系统上可用的设备数量、它们的队列和队列功能、可用的堆及其相关属性等。
应用程序
应用程序是指用户编写的程序,旨在利用 Vulkan API 来执行图形或计算任务。 应用程序从硬件和软件的初始化开始,它会检测驱动程序并加载所有的 Vulkan API。 展示层(presentation layer)使用 Vulkan 的 窗口系统集成 ---Window System Integration ---(WSI)API 进行初始化;WSI 将用于渲染期间在显示表面(display surface)上绘制图形图像。 应用程序创建资源并使用描述符(descriptors)将它们绑定到着色器阶段(shader stage)。 描述符集布局(descriptor set layout)用于把创建的资源绑定到创建的底层管线对象 pipeline object(图形或计算类型 graphics or compute type)。 最后,记录命令缓冲区并将其提交到队列进行处理。
WSI
窗口系统集成 WSI(Windows System Integration )是来自 Khronos 的一组扩展,用于跨平台(如 Linux,Windows 和 Android)。
SPIR-V
SPIR-V 提供了一种预编译的二进制格式,用于指定 Vulkan 着色器。 编译器可用于各种着色器语言,其中包括能够生成 SPIR-V 的 GLSL 和 HLSL 变种。
LunarG SDK
LunarG 的 Vulkan SDK 包含了各种工具和资源,用以辅助 Vulkan 应用程序的开发。 这些工具和资源包括 Vulkan 加载程序、验证层、跟踪和回放工具、SPIR-V 工具、Vulkan 运行时安装程序、文档,示例以及演示,请参阅第 3 章“与设备握手”查看详细的介绍,以便开始使用 LunarG SDK。 你可以在 http://lunarg.com/vulkan-sdk 阅读关于它的更多信息。
Vulkan 编程模型入门
我们来详细讨论一下 Vulkan 的编程模型。 在本章,考虑到了一些读者可能是初学者,因此使用循序渐进的方式。各位看官在这里将会理解以下一些概念:
- Vulkan 编程模型
- 渲染执行模型,将使用伪代码逐步骤的方式进行描述
- Vulkan 工作原理
下图显示了 Vulkan 应用程序编程模型自顶向下的方法;我们会详细了解这一过程,并深入研究各个子组件及其功能:
硬件初始化
当 Vulkan 应用程序启动的时候,它的第一个工作就是硬件的初始化。 在这个阶段,应用程序通过与加载器进行通信来激活 Vulkan 驱动程序。 下图展示了一个加载器 Loader 及其子组件的框图:
Loader: 加载程序是应用程序启动时使用的一段代码,可以跨平台、以一种统一的方式在系统中定位 Vulkan 驱动程序。 以下是加载程序 Loader 的职责:
- 定位驱动程序 Locating drivers: 作为其主要的任务,加载程序知道在给定系统中到哪里搜索驱动程序。 它会找到正确的驱动程序并加载。
- 不依赖平台 Platform-independent: 初始化 Vulkan 在所有平台上都是一致的。 这与 OpenGL 不同,OpenGL 创建上下文需要针对每个环境(EGL,GLX 和 WGL)使用不同的窗口系统 API。 Vulkan 中的平台差异以扩展名表示。
- 可注入的层 Injectable layers:加载器支持层次化的体系结构并提供在运行时注入各层的能力。 最大的改进就是驱动程序在确定应用程序对 API 的使用是否有效时不需要执行任何工作,也不会保留执行该工作所需的任何状态。 因此,建议在开发阶段根据应用要求打开选定的可注入层,并在部署阶段将其关闭。 例如,可注入层可以提供以下内容:
- 跟踪 Vulkan API 命令
- 捕获要渲染的场景并在稍后执行场景的渲染
- 用于调试目的的错误检查和验证
Vulkan 应用程序首先执行与加载程序库的握手并初始化 Vulkan 具体实现的驱动程序。 加载程序库会动态加载 Vulkan API。 加载程序还提供了一种机制,允许将特定的层自动加载到所有 Vulkan 应用程序中;这被称为隐式启用层。
一旦加载程序找到驱动程序并成功链接到 API,应用程序就要负责以下操作:
- 创建一个 Vulkan 实例
- 查询物理设备的可用队列
- 查询扩展并将它们存储为函数指针,如 WSI 或特殊功能的 API
- 启用可注入层,进行错误检查、调试或验证操作
窗口展示表面 Window presentation surfaces
一旦加载器找到 Vulkan 具体实现的驱动程序,我们就可以用 Vulkan API 绘制一些东西。 为此,我们需要一个图像来执行绘图任务,并将其放在展示窗口中进行显示:
构建展示图像和创建窗口是特定于具体平台的操作。 在 OpenGL 中,窗口更是和平台密切相关的;窗口系统的帧缓冲区与上下文或设备一起创建。 与 GL 的最大区别在于,Vulkan 中的上下文或者设备的创建根本不需要涉及窗口系统;它会通过窗口系统集成Window System Integration(WSI)进行管理。
WSI 包含一组跨平台的窗口管理扩展:
- 针对大多数平台(如 Windows,Linux,Android 和其他操作系统)的独有跨平台实现。
- 一致的 API 标准,可轻松创建表面并在无需深入细节的情况下显示表面。
WSI 支持 Wayland,X 和 Windows 等多种窗口系统,并且通过交换链管理图像的所有权。
WSI 提供了交换链机制,这允许以这样的方式使用多个图像,即当窗口系统显示一个图像时,应用程序可以准备下一个图像。
以下屏幕截图显示了双缓冲交换图像的过程。 它包含名为第一幅图像和第二幅图像的两个图像。 在 WSI 的帮助下,这些图像在 Application 和 Display 之间交换:
WSI 作为 Display 和 Application 之间的接口。 它确保两个图像都是通过显示 Display 和应用程序 Application 以互斥的方式获取的。 因此,当应用程序 Application 在第一张图像上工作时,WSI 将切换第二张图像以显示其内容。 一旦应用程序 Application 完成绘画第一张图片,它将其提交给 WSI,并作为回报获得第二张图片,反之亦然。
此时,请执行以下任务:
- 创建一个本地窗口(如 Windows 操作系统中的 CreateWindow 方法)
- 创建一个 WSI 表面并附加到窗口
- 创建交换链,用于呈现到表面
- 从创建的交换链请求绘图图像
设置资源
设置资源意味着将数据存储到内存区域,资源可以是任何类型的数据,例如,顶点属性,如位置、颜色或图像类型、名称。 当然,为了 Vulkan 能欧访问到这些数据,它们已经存储在了内存中。
与使用提示在背后管理内存的 OpenGL 不同,Vulkan 提供了完全底层的访问和内存控制。 Vulkan 发布了可以在物理设备上使用的各种内存类型,这为应用程序提供了一个很好的机会来显式管理这些不同类型的内存。
本地主机:这是一种较慢的内存类型
本地设备:这是一种高带宽的内存;速度更快
堆内存可以根据其内存类型配置进一步划分:
-
本地设备:
- 这种类型的内存物性地连接到物理设备:
- 对设备可见,对主机不可见
-
本地设备,主机可见:
- 此类内存也物理性地连接到设备:
- 对设备可见,对主机可见
-
本地主机,主机可见:
- 这是指主机的本地内存,但比本地设备慢:
- 对设备可见,对主机可见
在 Vulkan 中,资源由应用程序显式处理,并独占内存管理的控制权。 以下是资源管理的过程:
资源对象:对于资源设置,应用程序负责为资源分配内存;这些资源可以是图像或缓冲区对象。
分配和二次分配:当刚创建资源对象时,只有逻辑地址与它们相关联;并没有实际可用的物理空间支持。 应用程序会分配物理内存并将这些逻辑地址绑定到物理内存。 由于分配存储空间是一个昂贵的过程,因此二次分配是管理内存的有效方式;它会立即分配一大块物理内存并将不同的资源对象放入其中。 二次分配是应用程序的责任。 下图显示了分配的大量物理内存中,二次分配对象的情况:
-
提示
稀疏内存 Sparse memory:对于非常大的图像对象,Vulkan 完全支持稀疏内存及其所有功能。 稀疏内存是一项特殊功能,可让您存储大型图像资源;图像在内存中的存储容量远大于实际的存储容量。 这种技术是将图像分解为图块,并仅加载适合应用程序逻辑的图块。
暂存缓冲区 Staging buffers:使用暂存的方式完成对对象和图像缓冲区的填充,其中使用两个不同的内存区域用于物理存储的分配。 主机可能无法看到资源的理想内存布局。 在这种情况下,应用程序必须首先将资源填充到主机可见的暂存缓冲区中,然后将其转移到理想的位置。
异步传输 Asynchronous transfer:使用图形队列或 DMA 队列 、传输队列之类的异步命令异步传输数据。
物理内存分配很昂贵;因此,一个好的做法是分配一个大的物理内存,然后在其中二次分配对象。
相比之下,OpenGL 资源管理不提供对内存的精细控制。 没有主机内存和设备内存的概念;驱动程序在后台中秘密地做了所有的分配工作, 而且,这些分配和二次分配的过程并非完全透明,并且不同的驱动程序之间可能还会对这个操作进行调整,从而产生一些差异。 缺乏一致性以及隐藏的内存管理会导致不可预知的行为。 而另一方面,Vulkan 在所选内存中分配对象,使其具有高度的可预测性。
因此,在资源设置阶段,您需要执行以下任务:
- 创建资源对象。
- 查询适当的内存实例并创建内存对象,比如缓冲区和图像。
- 获取分配的内存要求。
- 在其中再次分配空间并存储数据。
- 将内存与我们创建的资源对象绑定。
管线设置
管线是由应用程序逻辑定义的、固定序列中发生的一组事件。 这些事件包含以下内容:提供着色器,将它们绑定到资源并管理状态:
描述符集和描述符池
描述符集是资源和着色器之间的接口。 这是一个简单的结构,用于将着色器与资源信息(如图像或缓冲区)绑定。 它关联或绑定着色器将要使用的资源内存。 以下是与描述符集相关的特征:
频繁变化:本质上,描述符集会经常变化;一般而言,它包含诸如材质、纹理等属性。
描述符池:考虑到描述符集的性质,它们是从描述符池中分配的,而不引入全局同步。
多线程可伸缩性:这允许多个线程同时更新描述符集。
提示
在 Vulkan 渲染中,更新或更改描述符集是改善性能最关键的途径之一。 因此,描述符集的设计是实现性能最大化的一个重要方面。 Vulkan 支持在场景(低频更新),模型(中频更新)和绘图级别(高频更新)的多个描述符集的逻辑分区。 这确保了高频更新描述符不影响低频率描述符资源。
使用 SPIR-V 的着色器
在 Vulkan 中指定着色器或计算核心的唯一方法是通过 SPIR-V。 以下是与之相关的一些特性:
多种输入:生成的 SPIR-V 编译器适用于各种源语言,包括 GLSL 和 HLSL。 这些编译工具可用于将人类可读的着色器转换为 SPIR-V 中间表示形式。
离线编译:Shaders / 内核被离线编译并预先注入。
glslangValidator:LunarG SDK 提供 glslangValidator 编译器,可用于从等效的 GLSL 着色器创建 SPIR-V 着色器。
多个入口点:着色器 shader 对象提供了多个入口点。 这对于减少 SPIR-V 着色器的打包尺寸(和加载尺寸)非常有利。 着色器的各种变体还可以打包成一个模块。
管线管理
物理设备包含一系列的硬件设置,用于确定提交的、给定的几何图形需要的输入数据如何解释和绘制。 这些设置统称为管线状态 pipeline states。 这其中包括光栅器状态、混合状态和深度模板状态;它们还包括提交几何体的基本拓扑类型(点 / 线 / 三角形)以及要用于渲染的着色器。 有两种状态类型:动态和静态。 管线状态用于创建管线对象(图形对象或计算对象),这是一个性能优化关键点。 因此,我们不想一次又一次地重复创造管线对象;我们希望创建一次并对其进行重用。
Vulkan 允许您使用管线对象以及管线缓存对象 Pipeline Cache Object (PCO)和管线布局 pipeline layout来控制状态:
管线对象 Pipeline objects:管线的创建非常昂贵,其中包括着色器重新编译、资源绑定、渲染传递、帧缓冲区管理以及其他的相关操作。 管线对象的状态可能有成百上千之多;因此,每个不同的状态组合都存储为一个单独的管线对象。
PCO:管线的创建非常昂贵;因此一旦创建,就可以对该管线进行缓存。 当请求新的管线时,驱动程序可以寻找比较接近的匹配并使用基本管线创建新的管线。
管线缓存是不透明的,并且驱动程序使用它们的细节也并未做详细说明。 如果应用程序希望在运行时重用缓存,并且在创建管线时提供合适的缓存(如果应用程序希望获得潜在的好处),则应用程序要对这个缓存负责持久化。
- 管线布局:管线布局描述了要与管线一起使用的描述符集,指示着色器中每个绑定槽所连接的资源类型。 不同的管线对象可以使用相同的管线布局。
在管线管理阶段,会发生如下操作:
应用程序将着色器编译为 SPIR-V 格式,并在管线着色器状态中指定这种格式的着色器。
描述符帮助我们将这些资源连接到着色器本身。 应用程序从描述符池中分配描述符集,并将传入或传出的资源连接到着色器中的绑定槽。
应用程序创建管线对象,其中包含静态和动态状态配置,用来控制硬件的设置。 应该从管线缓存池创建管线以获得更好的性能。
录制命令
录制命令是命令缓冲区形成的过程。 命令缓冲区是从命令池存储空间中进行分配的。 命令池也可以用于多个分配。 在由应用程序定义的给定的起始和结束范围内,通过提供命令来记录命令缓冲区。 下图说明了绘图命令缓冲区的记录情况,如您所见,它包含许多以自上而下的顺序记录的命令,负责对象的绘制。
注意
请注意,命令缓冲区中的命令可能因作业要求而异。 此图只是一个说明,其中包括了绘制元素时所执行的最常见步骤。
绘图的主要部分覆盖以下几个方面:
范围:范围定义了命令缓冲区记录的开始和结束。
渲染通道:它定义了可能影响帧缓冲区缓存作业的执行过程。 其中可能包含附件,子通道以及这些子通道之间的依赖关系。 附件是指在哪个图像上执行绘图操作。 在子通道中,类似附件的图像可以被子通道化,用于进行多重采样解析。Render Pass 还控制着在通道开始时如何处理帧缓冲区:其或者会保留有关它的最后一个信息,或是使用给定的颜色对其进行清除。 同样,在 Render Pass 结束时,结果会被丢弃或存储。
管线:包含由管线对象表示的状态(静态 / 动态)信息。
描述符:这会将资源信息绑定到管线。
绑定资源:指定顶点缓冲区、图像或其他几何相关的信息。
视口:这决定了要在绘图表面的哪个区域执行图元渲染。
裁剪器:这定义了一个矩形的空间区域,超出这个范围就不会绘制任何东西。
绘图:绘图命令指定几何缓冲区的属性,例如开始索引、总计数等。
提示
创建命令缓冲区是一个昂贵的操作;它被认为是性能关键的所在。 如果许多帧需要进行相同的操作,则它可以重复使用很多次。 命令缓冲区可以重新提交而无需重新录制。 此外,可以使用多个线程同时生成多个命令缓冲区。 Vulkan 是专门为利用多线程的可伸缩性而设计的。 如果在多线程环境中使用,则命令池确保不存在锁定竞争。
下图显示了一个具有多核和多线程方法的、可扩展的命令缓冲区创建模型。 该模型使用多核处理器的特性提供了真正意义上的并行性。
在这里,每个线程都使用一个独立的命令缓冲池,同时该命令缓冲池又分配了一个或多个命令缓冲区,不允许与资源锁冲突。
队列提交
一旦构建了命令缓冲区,就可以将它们提交到队列进行处理。 Vulkan 向应用程序公开了不同类型的队列,例如图形队列、DMA 队列、传输队列以及计算队列。 用于提交的、队列的选择在很大程度上取决于作业的性质。 例如,图形相关的任务必须提交给图形队列。
同样,对于计算操作,计算队列就是最佳的选择。 提交的作业以异步方式执行。 命令缓冲区可以被 push 到独立的兼容队列中,并允许并行执行。 应用程序负责命令缓冲区或队列之间的所有类型的同步,即使在主机和设备本身之间也是如此。
队列提交会执行以下工作:
- 从交换链中获取下一帧要被绘制的图像,即在该图像上进行绘制。
- 部署所有同步机制(如信号量和 fence 栏栅)需要收集的命令缓冲区并将其提交到所需的设备队列进行处理。
- 请求在输出设备上显示完成的、绘制好的图像。
总结
本章对 Vulkan 进行了介绍性的描述,以便让初学者更容易理解。 在本章中,我们了解了 Vulkan 的发展历程,并了解了其背后的历史和人物。 然后,我们将这套 Vulkan API 与 OpenGL 进行了一些对比区分,并理解了 Vulkan 在现代计算时代存在的原因。 我们还查看了与此 API 关联的、重要技术术语的简单定义。 Vulkan API 的基本原理为其工作模型提供了精确以及丰富的概述。 我们还看到了 Vulkan 生态系统的基本组成部分,并了解它们在互连方面的作用和责任。 最后,在本章的末尾,我们了解到 Vulkan 如何使用易于理解的分步操作的伪代码编程模式的方法。
完成本章的学习后,您会对 Vulkan API 及其详细的工作模型会有一个基本的理解,并熟悉了其技术术语,以便在 Vulkan 编程中迈出第一步。
在下一章中,我们将使用伪代码的方式开始 Vulkan 编程。 我们将创建一个简单的示例,但不会涉及太多细节,不过仍会涵盖重要的核心方面、Vulkan API 的基础知识和数据结构,以此来了解 Vulkan 中图形管线编程的完整流程。