跳到主要内容

概述

本章节先简要介绍Vulkan概念,以及它所解决的问题。然后,我们介绍使用Vulkan来绘制一个三角形的步骤。最后,我们将会介绍Vulkan API的基本结构和使用方式。

Vulkan起源

以往大多数图形API设计为采用固定功能的渲染管线,应用程序需按照一定格式提交顶点数据,配置光照和着色选项。

随着显卡架构逐渐成熟,图形硬件开始提供越来越多的可编程功能,这些新功能都必须以某种方式与现有的 API 集成。这么做导致驱动程序越来越复杂,应用程序开发者要处理的兼容性问题也越来越多。随着移动浪潮的到来,人们对移动GPU的要求也越来越高,但以往的图形API不能够对图形硬件进行精准地控制来提升效率,对多线程的支持也非常有限,导致没有发挥出图形硬件真正的潜力。

Vulkan设计为一个全新的跨平台图形API,由于没有历史包袱,Vulkan完全按照现代图形架构设计,它提供了更加详细的API给开发者,大大减少了驱动程序的开销,允许多个线程并行创建和提交指令,使用标准化的着色器字节码,将图形和计算功能进行了统一。

画三角形的步骤

现在,我们介绍如何使用Vulkan绘制一个最简单的三角形,这里用到的所有概念会在下一章节进行详细说明,本章目的是为了让您对所有用到的组件有一个大致的了解。

步骤1:创建实例和选择物理设备

Vulkan应用程序从一开始就要创建VkInstance,然后才能使用其他Vulkan API。创建VkInstance后,就可以查询Vulkan支持的硬件,选择其中一个或多个VkPhysicalDevices进行操作。我们可以查询设备属性,从中选择一个适合我们的设备。

步骤2:逻辑设备和队列族

选择了合适的硬件设备后,我们需要创建一个逻辑设备VkDevice,创建参数可以选择使用哪些VkPhysicalDevice特性(比如多视口,64位浮点),还需要指定使用的队列族。Vulkan将诸如绘制指令、内存操作等指令提交到VkQueue来异步执行。队列由队列族分配,每个队列族支持一个特定的操作集合。例如,图形、计算和内存传输操作分属于不同的队列族。队列族只是选择物理设备时的一个参考,一个支持Vulkan计算的设备可能没有图形功能,但当今支持Vulkan的显卡设备通常都支持所有队列操作。

步骤3:窗口表面和交换链

除非是离屏渲染,否则我们必须创建一个窗口来显示渲染的图像。我们可以通过原生平台的窗口API或使用GLFWSDL等窗口库来创建,本教程使用的是GLFW,我们会在下一章详细介绍GLFW。

我们还需要两个组件才能完成窗口渲染:窗口表面(VkSurfaceKHR)和交换链(VkSwapChainKHR),这两个组件都有KHR后缀,表示它们属于Vulkan扩展。Vulkan API本身是平台不相关的,我们需要使用WSI(Window System Interface,窗口系统接口)扩展与原生的窗口管理器进行交互。表面(Surface)是一个渲染窗口的跨平台抽象,通常它是由原生窗口系统句柄作为参数实例化得到的,例如Windows 上的HWND。不过这些工作,GLFW已经帮我们处理了,所以不用我们关心。

交换链是渲染目标的集合,它用来确保我们正在渲染的图像和当前屏幕上显式的图像是两个不同的图像。每绘制一帧时,必须请求交换链提供要渲染的图像,绘制完成后,图像返回到交换链中,在之后某个时刻,图像被呈现到屏幕上。渲染目标的数量和图像呈现到屏幕的时机取决于显示模式。常用的显示模式有双缓冲(vsync,垂直同步)和三缓冲,我们将在创建交换链章节讨论这些内容。

步骤4:图像视图和帧缓冲

从交换链获取图像后,还不能直接在图像上进行绘制,需要先将图像包装到VkImageView和VkFramebuffer中去。图像视图代表引用图像的特定部分,帧缓冲可以使用图像视图作为颜色,深度和模板目标。交换链中可能有多个不同的图像,我们预先为每个图像都创建好图像视图和帧缓冲,然后在绘制时选择正确的图像。

步骤5:渲染流程

渲染流程描述了渲染操作时使用的图像类型、使用方式、如何处理图像内容。对于下面绘制三角形的程序,我们使用了一张图像作为颜色目标,并且在执行绘制操作前清除整个图像。渲染流程只描述了图像的类型,实际图像的绑定是通过VkFramebuffer完成的。

步骤6:图形管线

Vulkan的图形管线对应VkPipeline对象,它描述了显卡的可配置状态,比如视口大小和深度缓冲操作,以及使用VkShaderModule对象的可编程状态。VkShaderModule对象由着色器字节码创建,驱动程序还知道图形管线使用了哪些渲染目标。

Vulkan与之前的图形API相比,一个最大的不同之处是几乎所有的图形管线配置都需要提前进行。如果我们想要使用另外一个着色器或者顶点布局,就需要重新创建整个图形管线。这意味着我们必须提前创建出所有需要的图形管线,在需要时直接使用已经创建好的图形管线。图形管线只有很少一部分配置可以动态修改,比如视口大小和清除颜色。图形管线的所有状态也需要显式地描述,比如,默认的颜色混合状态。

这有点类似于预编译而不是即时编译,这样做,驱动程序可以有更大的优化空间,并且以图形管线为切换单位,渲染效果的预期也变得十分容易,不用担心切换时,遗漏某个微小的设置,造成结果的巨大差异。

步骤7:指令池和指令缓冲

之前提到,Vulkan的许多操作(如绘制、计算)需要提交到队列才能执行。这些操作首先被记录到一个VkCommandBuffer对象中,然后才能提交给队列。VkCommandBuffer对象由一个关联了特定队列族的VkCommandPool分配而来。为了绘制三角形,我们需要记录以下操作到VkCommandBuffer对象中:

  • 开始渲染
  • 绑定图形管线
  • 绘制三角形的顶点
  • 结束渲染

由于帧缓冲绑定的图像依赖于交换链提供,我们需要为每个图像创建指令缓冲,在绘制时,直接使用对应的指令缓冲。另一种方法是在每一帧重新录制指令缓冲,但这样做效率很低。

步骤8:主循环

将绘制指令包装进指令缓冲后,主循环变得非常简单。我们首先使用vkAcquireNextImageKHR函数从交换链获取一张图像。接着使用vkQueueSubmit函数提交图像对应的指令缓冲。最后,使用vkQueuePresentKHR函数将图像返回给交换链,并将图像显示到屏幕上。

提交给队列的操作会被异步执行,因此我们需要使用信号量等同步措施来确保操作按正确的顺序执行。绘制指令必须在获取图像之后才能执行,否则可能会出现屏幕读取图像数据的时刻,绘制操作进行了数据写入这样的读写冲突,造成屏幕显示的数据并非来自同一帧。同样,vkQueuePresentKHR函数调用需要等到绘制完成后才能执行。

总结

本章节通过绘制一个简单的三角形来使读者快速了解使用Vulkan的步骤。一个真正的实际程序会包含更多的步骤,比如分配顶点缓冲,创建Uniform缓冲,上传纹理图像等等。为了降低学习难度,我们从最简单的形式开始,逐步复杂化。

对于绘制一个三角形,我们需要的步骤包括:

  • 创建一个VkInstance
  • 选择一支持Vulkan的图形设备(VkPhysicalDevice)
  • 为绘制和显示操作创建VkDevice和VkQueue
  • 创建窗口,窗口表面和交换链
  • 将交换链图像包装进VkImageView
  • 创建渲染过程(vkRenderPass)指定渲染目标和渲染方式
  • 为渲染过程(vkRenderPass)创建帧缓冲(vkFrameBuffer)
  • 配置图形管线
  • 为每一个交换链图像分配指令缓冲
  • 从交换链获取图像,记录绘制操作到指令缓冲,提交指令缓冲,返回图像到交换链

看起来步骤很多,在接下来的章节里,我们会对每个步骤进行详细地说明。如果以后你对程序中的某个步骤与整个程序的关系感到困惑,可以回顾一下本章内容。

API介绍

代码风格

Vulkan的所有函数、枚举和结构体都定义在vulkan.h中,可以在Vulkan SDK中找到这一头文件。下一章节,我们介绍如何安装Vulkan SDK。

Vulkan API的函数都带有一个小写的vk前缀,枚举和结构体名带有一个Vk前缀,枚举值带有一个VK_前缀。Vulkan对结构体非常依赖,大量函数的参数由结构体提供,Vulkan创建对象的一般形式如下:

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成员可以指向一个扩展结构体,本教程不使用它,pNext都设置为nullptr。Vulkan创建和销毁对象的函数都有一个VkAllocationCallbacks参数,可以用来自定义内存分配器,本教程也不使用它,将其设置为nullptr。

几乎所有Vulkan都会返回一个VkResult来表示执行结果,它的值要么是VK_SUCCESS,要么是一个错误代码,Vulkan规范文档描述了这些函数返回错误的含义。

校验层

之前提到,Vulkan的设计目标是高性能、低驱动程序开销。因此默认情况下,它只提供了有限的错误检测和调试功能。驱动程序通常会在发生错误时直接崩溃,而不是返回一个错误代码。这可能导致程序在某种显卡上可以工作,但在其它显卡上出现驱动程序崩溃的情况。

可以通过启用Vulkan的校验层(Validation layers)进行一些错误检查。校验层是一段插入在Vulkan API和驱动程序之间的代码,可以执行Vulkan API的参数检查,跟踪内存分配等工作。我们可以在开发期间开启校验层,然后在发布程序时关闭校验层,减少性能损失。校验层可以完全自己编写,但为了方便,我们的教程直接使用了Vulkan SDK提供的标准校验层,我们还需要注册回调函数接受来自校验层的调试信息。

由于Vulkan对每个操作都很明确,加之校验层的使用,因此调试Vulkan程序要比调试OpenGL和Direct3D的程序容易得多。

接下来让我们配置开发环境,开始Vulkan编程之旅吧!