本章将首先介绍 Vulkan 及其解决的问题。之后,我们将介绍第一个三角形所需的要素。这将为您提供一个总体框架,以便您阅读后续章节。最后,我们将介绍 Vulkan API 的结构和一般使用模式。
Vulkan 的起源
与之前的图形 API 一样,Vulkan 被设计为基于GPU 的 跨平台抽象。大多数此类 API 的问题在于,它们设计时的图形硬件大多仅限于可配置的固定功能。程序员必须以标准格式提供顶点数据,并且在照明和着色选项方面完全听命于 GPU 制造商。
随着显卡架构的成熟,它们开始提供越来越多的可编程功能。所有这些新功能都必须以某种方式与现有的 API 集成。这导致了不太理想的抽象,并且在图形驱动程序方面需要进行大量猜测才能将程序员的意图映射到现代图形架构上。这就是为什么有如此多的驱动程序更新来提高游戏性能,有时甚至有显著的提升。由于这些驱动程序的复杂性,应用程序开发人员还需要处理供应商之间的不一致问题,例如 着色器 所接受的语法。除了这些新功能之外,过去十年还出现了大量具有强大图形硬件的移动设备。这些移动 GPU 根据其能量和空间要求具有不同的架构。一个这样的例子是 平铺渲染,它可以通过为程序员提供对此功能的更多控制来提高性能。这些 API 的过时带来的另一个限制是有限的多线程支持,这可能导致 CPU 端的瓶颈。
Vulkan 解决了这些问题,因为它是为现代图形架构而设计的。它允许程序员使用更详细的 API 明确指定其意图,从而减少了驱动程序开销,并允许多个线程并行创建和提交命令。它通过使用单个编译器切换到标准化字节码格式来减少着色器编译中的不一致问题。最后,它通过将图形和计算功能统一到单个 API 中来承认现代显卡的通用处理能力。
画三角形需要什么
现在,我们将概述在运行良好的 Vulkan 程序中渲染三角形所需的所有步骤。这里介绍的所有概念都将在下一章中详细阐述。这只是为了让您对所有单个组件有一个大致的了解。
步骤 1 - 实例和物理设备选择
Vulkan 应用程序首先通过 设置 Vulkan API VkInstance。通过描述您的应用程序和您将使用的任何 API 扩展来创建实例。创建实例后,您可以查询 Vulkan 支持的硬件并选择一个或多个 VkPhysicalDevice 用于操作。您可以查询 VRAM 大小和设备功能等属性以选择所需的设备,例如优先使用专用显卡。
第 2 步 - 逻辑设备和队列系列
选择正确的硬件设备后,您需要创建一个 VkDevice (逻辑设备),在其中更具体地描述 VkPhysicalDeviceFeatures 您将使用哪个设备,例如多视口渲染和 64 位浮点数。您还需要指定要使用的队列系列。使用 Vulkan 执行的大多数操作(例如绘制命令和内存操作)都是通过将它们提交给来异步执行的 VkQueue。队列是从队列系列分配的,其中每个队列系列都支持其队列中的一组特定操作。例如,图形、计算和内存传输操作可以有单独的队列系列。队列系列的可用性也可以用作物理设备选择的区别因素。支持 Vulkan 的设备可能不提供任何图形功能,但是当今所有支持 Vulkan 的显卡通常都支持我们感兴趣的所有队列操作。
步骤 3 - 窗口界面和交换链
除非你只对屏幕外渲染感兴趣,否则你需要创建一个窗口来显示渲染的图像。可以使用本机平台 API 或 GLFW 和 SDL 等库创建窗口。我们将在本教程中使用 GLFW,但下一章将详细介绍它。
我们还需要两个组件才能真正渲染到窗口:窗口表面 ( VkSurfaceKHR) 和交换链 ( VkSwapchainKHR)。请注意 KHR 后缀,这意味着这些对象是 Vulkan 扩展的一部分。Vulkan API 本身完全与平台无关,这就是为什么我们需要使用标准化 WSI(窗口系统接口)扩展与窗口管理器交互的原因。表面是要渲染到的窗口的跨平台抽象,通常通过提供对本机窗口句柄的引用来实例化,例如 HWND 在 Windows 上。幸运的是,GLFW 库有一个内置函数来处理平台特定的细节。
交换链是渲染目标的集合。其基本目的是确保我们当前渲染的图像与当前屏幕上的图像不同。这对于确保只显示完整的图像非常重要。每次我们想要绘制一帧时,都必须要求交换链为我们提供要渲染的图像。当我们完成绘制一帧时,图像将返回到交换链,以便在某个时间点将其呈现到屏幕上。渲染目标的数量和将完成的图像呈现到屏幕上的条件取决于呈现模式。常见的呈现模式是双缓冲 (vsync) 和三重缓冲。我们将在交换链创建章节中讨论这些。
某些平台允许您直接渲染到显示器,而无需通过 VK_KHR_display 和 VK_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 相比,找出屏幕变黑的原因实际上要容易得多!
在我们开始编写代码之前只剩一步了,那就是 设置开发环境。