概述
本章将从介绍Vulkan和它所解决的问题开始。之后,我们要看一下第一个三角形所需要的步骤。这里将给你一个描述一个大纲,描述的内容你在之后的章节都能看到。 最后,我们将介绍Vulkan API的结构和一般使用模式。
Vulkan的起源
就像之前的图形API一样,Vulkan被设计为GPUs上的一个跨平台抽象。这些API的问题在于,它们所处的时代的特点是图形硬件大多限于可配置的固定功能。程序员不得不以标准格式提供顶点数据,并在光照和阴影选项方面受制于GPU制造商。
随着显卡架构的成熟,它们开始提供越来越多的可编程功能。所有这些新功能都必须以某种方式与现有的API集成。这导致了不太理想的抽象和图形驱动方面的大量猜测工作,以将程序员的意图映射到现代图形架构上。这就是为什么有这么多的驱 动程序更新,以提高游戏的性能,有时会有很大的差距。由于这些驱动程序的复杂性,应用程序开发人员还需要处理供应商之间的不一致问题,如着色器所接受的语法。除了这些新功能,在过去的十年中,还涌现了大量具有强大图形硬件的移动设备。这些移动GPUs根据其能量和空间要求,有不同的架构。其中一个例子是平铺渲染,通过为程序员提供对这一功能的更多控制,它将受益于性能的提高。另一个源于这些API年代的限制是有限的多线程支持,这可能导致CPU方面的瓶颈。
Vulkan通过为现代图形架构从头设计解决了这些问题。它通过允许程序员使用更粗略的API清楚地指定他们的意图来减少驱动开销,并允许多线程并行创建和提交命令。它通过改用单一编译器的标准化字节码格式,减少了着色器编译中的不一致性。最后,它通过将图形和计算功能统一到一个API中,承认了现代图形卡的通用处理能力。
画一个三角形需要什么
我们现在来看看在一个良好的Vulkan程序中渲染一个三角形所需的所有步骤的概述。这里介绍的所有概念都将在接下来的章节中详细阐述。这只是为了给你一个大的画面,以便将所有单独的组件联系起来。
第1步-实例和物理设备选择
一个Vulkan应用程序从通过VkInstance
设置Vulkan API开始。通过描述您的应用程序和您将要使用的任何API扩展来创建一个实例。在创建实例后,你可以查询Vulkan支持的硬件,并选择一个或多个VkPhysicalDevices
来进行操作。您可以查询VRAM大小和设备能力等属性,以选择所需的设备,例如,倾向于使用专用显卡。
第2步-逻辑设备和队列系列
在选择了正确的硬件设备后,你需要创建一个VkDevice
(逻辑设备),在这里你可以更具体地描述你将使用哪些VkPhysicalDeviceFeatures
,比如多视口渲染和64位浮点。你还需要指定你想使用的队列系列。使用Vulkan进行的大多数操作,如绘制命令和内存操作,都是通过提交给VkQueue
来异步执行的。队列是由队列家族分配的,每个队列家族在其队列中支持一组特定的操作。例如,可以为图形、计算和内存传输操作提供单独的队列家族。队列家族的可用性也可以作为物理设备选择中的一个区分因素。支持Vulkan的设备有可能不提供任何图形功能,但是目前所有支持Vulkan的显卡一般都会支持我们感兴趣的所有队列操作。
第3步-窗口表面和交换链
除非你只对屏幕外的渲染感兴趣,否则你将需要创建一个窗口来呈现渲染的图像。窗口可以通过本地平台的API或者GLFW和SDL等库来创建。我们将在本教程中使用GLFW,但在下一章中会有更多的介绍。
我们还需要两个组件来实际渲染到一个窗口:一个窗口表面(VkSurfaceKHR
)和一个交换链(VkSwapchainKHR
)。注意KHR
的后缀,这意味着这些对象是Vulkan扩展的一部分。Vulkan API本身是完全与平台无关的,这就是为什么我们需要使用标准化的WSI(Window System Interface)扩展来与窗口管理器交互。表面是一个跨平台的窗口抽象,用于渲染,一般通过提供对本地窗口句柄的引用来实例化,例如Windows的HWND
。幸运的是,GLFW库有一个内置函数来处理这个平台的具体细节。
交换链是一个渲染目标的集合。它的基本目的是确保我们当前正在渲染的图像与当前屏幕上的图像不同。这对于确保只显示完整的图像是很重要的。每次我们想画一个帧时,我们必须要求交换链为我们提供一个要渲染的图像。当我们完成了一个帧的绘制,图像就会被送回交换链,以便在某一时刻被呈现到屏幕上。渲染目标的数量和将完成的图像呈现到屏幕上的条件取决于呈现模式。常见的呈现模式是双缓冲(vsync)和三缓冲。我们将在交换链创建章节中研究这些。
一些平台允许你通过VK_KHR_display
和VK_KHR_display_swapchain
扩展直接渲染到显示器上,而不与任何窗口管理器交互。这些允许你创建一个代表整个屏幕的表面,例如,可以用来实现你自己的窗口管理器。
第4步-图像视图和帧缓冲器
为了绘制从交换链获得的图像,我们必须把它包装成一个VkImageView
和VkFramebuffer
。一个图像视图引用一个要使用的图像的特定部分,而一个帧缓冲区引用要用于颜色、深度和模板目标的图像视图。因为在交换链中可能有许多不同的图像,我们将预先为每个图像创建一个图像视图和framebuffer,并在绘制时选择合适的。
第5步-渲染通道
Vulkan中的渲染传递描述了在渲染操作中使用的图像类型,它们将如何被使用,以及它们的内容应该如何被处理。在我们最初的三角形渲染应用中,我们将告诉Vulkan,我们将使用一个单一的图像作为颜色目标,并且我们希望在绘图操作之前将其清除为纯色。渲染通道只描述了图像的类型,而VkFramebuffer
实际上是将特定的图像绑定到这些槽中。
第6步-图形管线
Vulkan中的图形管道是通过创建一个VkPipeline
对象来设置的。它描述了显卡的可配置状态,比如视口大小和深度缓冲器的操作,以及使用VkShaderModule
对象的可编程状态。VkShaderModule
对象是由着色器字节码创建的。驱动程序还需要知道哪些渲染目标将在管道中 使用,我们通过引用渲染通道来指定。
与现有的API相比,Vulkan最突出的特点之一是几乎所有的图形管道配置都需要提前设置。这意味着,如果你想切换到一个不同的着色器或稍微改变你的顶点布局,那么你需要完全重新创建图形管道。这意味着你将不得不为你的渲染操作所需的所有不同组合提前创建许多VkPipeline
对象。只有一些基本的配置,比如视口大小和透明颜色,可以动态地改变。所有的状态也需要明确地描述,例如,没有默认的颜色混合状态。
好消息是,因为你做的是相当于提前编译而不是及时编译,所以驱动程序有更多的优化机会,运行时的性能也更可预测,因为像切换到不同的图形管道的大型状态变化是非常明确的。
第7步-命令池和命令缓冲区
如前所述,Vulkan中许多我们想要执行的操作,如绘图操作,都需要提交给队列。这些操作首先需要被记录到VkCommandBuffer
中,然后才能被提交。这些命令缓冲区是从VkCommandPool
中分配的,它与特定的队列系列相关。为了画一个简单的三角形,我们需要记录一个有以下操作的命令缓冲区。
- 开始渲染通道
- 绑定图形管线
- 绘制3个顶点
- 结束渲染过程
因为帧缓冲区中的图像取决于交换链将给我们的具体图像,所以我们需要为每个可能的图像记录一个命令缓冲区,并在绘制时选择正确的图像。另一种方法是每一帧都重新记录命令缓冲区,这样做的效率就不高了。
第8步-主循环
现在,绘图命令已经被包装成一个命令缓冲区,主循环就非常简单了。我们首先用vkAcquireNextImageKHR
从交换链中获取一个图像。然后,我们可以为该图像选择合适的命令缓冲区,并用vkQueueSubmit
来执行它。最后,我们用vkQueuePresentKHR
将图像返回到交换链,以便呈现在屏幕上。
提交给队列的操作是异步执行的。因此,我们必须使用同步对象,如semaphores来确保正确的执行顺序。绘制命令缓冲区的执行必须被设置为等待图像采集完成,否则可能会发生我们开始对一个仍在读取的图像进行渲染,以便在屏幕上呈现。vkQueuePresentKHR
调用又需要等待渲染完成,为此我们将使用第二个semaphore,在渲染完成后发出信号。
总结
这次旋风式的旅行应该让你对绘制第一个三角形的工作有一个基本的了解。一个真实世界的程序包含更多的步骤,比如分配顶点缓冲区、创建统一缓冲区和上传纹理图像,这些将在随后的章节中讲述,但我们将从简单的开始,因为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相比,要找出你的屏幕变黑的原因实际上会容易得多。
在开始写代码之前,搭建开发环境的步骤请参考设置开发环境。