《Vulkan Tutorial》1. Introduction And Overview

参考中文翻译:https://github.com/fangcun010/VulkanTutorialCN

Introduction

Vulkan 提供了对现代显卡更好的抽象,它允许你更好地描述程序想做什么,从而让它相对于现有的 API 有更好的性能和更少的不确定性

代价是更冗长的 API

Vulkan-Hpp 绑定

我们首先简要介绍 Vulkan 的工作原理,然后介绍如何使用 Vulkan 在屏幕上绘制一个三角形,接着我们介绍如何配置开发环境来使用 Vulkan SDK

我们还引入了 GLM 库来进行线性代数运算,引入了 GLFW 库来进行窗口的创建

每个章节大概遵循下面的结构:

  • 介绍新概念,以及它的用途
  • 将与之相关的 API 调用集成到我们的程序中去
  • 封装辅助函数(用于封装冗长的 Vulkan API)

完成使用 Vulkan 绘制三角形的程序后,我们将对其进行扩展,引入线性变换、纹理和三维模型。

Vulkan 的起源

在过去,显卡只有有限的配置选项,那时的图形 API 按照一定格式提交顶点数据,配置光照和着色选项

随着显卡架构的逐渐成熟,提供了越来越多的可编程功能,造成驱动程序要做的工作越来越复杂,应用程序开发者要处理的兼容性问题也越来越多

随着移动浪潮到来,人们对于 GPU 的要求也越来越高,但以往的图形 API 不能够进行更加精准地控制来提升效率,对多线程的支持也非常不足,导致没有发挥出图形硬件真正的潜力

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

画一个三角形的步骤

实例和物理设备选择

程序通过 VkInstance 来实例化一个 Vulkan API,创建该实例时需要描述你的应用程序和需要使用的 API 扩展

创建完该实例后,就可以查询 Vulkan 支持的硬件,选择其中一个或多个 VkPhysicalDevices 进行操作

可以查询 VRAM 大小和设备功能等属性来选择所需的设备,例如优先使用独显

逻辑设备(Logical Device)和队列族(Queue Families)

选择完合适的硬件后,我们需要使用更详细的 VkPhysicalDeviceFeatures 特性(如多视口渲染、64 位浮点)

还需要指定我们想要使用的队列族

Vulkan 将诸如绘制指令、内存操作提交到 VkQueue 中,进行异步执行

队列由队列族分配,每个队列族支持一个特定操作集合。比如,图形、计算和内存传输操作可以使用独立的队列族

队列族可以作为物理设备选择时的一个参考

比如,一个支持 Vulkan 的设备可能没有提供任何图形功能,但对于支持 Vulkan 的显卡设备而言,支持所有队列操作

Window Surface 和交换链(Swap Chain)

如果不是进行离屏渲染(offscreen rendering),通常我们需要创建一个窗口来显示渲染的图像

这一工作可以通过原生平台的窗口 API 或像 GLFW 或 SDL 这样的库来完成,在这里,我们使用的是 GLFW

我们还需要两个组件才能完成窗口渲染:window surface(VkSurfaceKHR)和交换链(VkSwapChainKHR)(这两个都有 KHR 后缀,表面它们都是 Vulkan 扩展):

Vulkan 是一个跨平台的 API,本身不能直接和窗口系统交互,为了将渲染的图像显示在窗口上,需要使用 WSI(Window System Interface,窗口系统接口)扩展来与窗口系统交互。这个扩展就主要包含了 VkSurfaceKHRVkSwapChainKHR

Surface 是一个抽象的接口,GLFW 负责将它显示到窗口上,Vulkan 只需要渲染到 Surface 上而不用关心具体的平台和实现

交换链是一个渲染目标集合,每次绘制一帧时,可以请求交换链提供一张图像。绘制完成后,图像被返回到交换链中,以便在某个时间点显示在屏幕上

渲染目标数量和图像显示到屏幕的时机依赖于显示模式,常见的显示模式有双缓冲(vsync,垂直同步)和三缓冲

某些平台允许通过 VK_KHR_displayVK_KHR_display_swapchain 扩展直接渲染到显示器,而无需与任何窗口管理器交互

图像视图(Image View)、帧缓冲(Framebuffer)和渲染 Pass

VkImage 代表一个 GPU 纹理或渲染目标,但它本身是一个存储资源,无法直接用于着色器或渲染管线。

需要将图像先包装进 VkImageView 中,VkImageView 提供了 VkImage 的访问方式,使 Vulkan 知道如何解释 VkImage 的数据(如格式、子资源范围、纹理类型等)

通过 VkImageView,同一个 VkImage 可以以不同的方式(颜色、深度/模板等)使用

VkRenderPass 定义了一次渲染的结构,它是一个附件(attachments)和 subpass 的集合,每个 subpass 都可以定义它需要哪些附件作为输入,哪些附件作为输出

VkFramebuffer 绑定VkRenderPass 所需的附件,并为其提供具体的渲染目标

真正的渲染在 CommandBuffer

图形管线(Graphics Pipeline)

Vulkan 的图形管线可以通过 VkPipeline 对象建立,它描述了显卡的可配置状态,比如视口大小和深度缓冲操作,以及使用 VkShaderModule 对象的可编程状态

VkShaderModule 对象由着色器字节码创建而来。驱动程序知道哪些渲染目标被图形管线使用

Vulkan 与之前的图形 API 的一个最大不同是几乎所有图形管线的配置都需要提前完成,只有很少一部分配置可以动态修改。图形管线的所有状态也需要显式地描述

这样做的好处类似于预编译相比于即时编译,驱动程序可以有更大的优化空间

指令池(Command Pool)和指令缓冲(Command Buffer)

Vulkan 的许多操作需要提交到队列才能执行

这些操作首先被记录到一个 VkCommandBuffer 对象中,然后提交给队列。VkCommandBuffer 对象由一个关联了特定队列族的 VkCommandPool 分配而来

为了绘制三角形,我们需要记录下列操作到 VkCommandBuffer 对象中去:

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

我们可以提前为每个图像建立指令缓冲,然后在绘制时,直接选择对应的指令缓冲使用

主循环(Main Loop)

将绘制指令包装进指令缓冲后,主循环变得非常简单:

  • 首先使用 vkAcquireNextImageKHR 函数从交换链获取一个 VkImage
  • 然后使用 vkQueueSubmit 函数将图像提交到对应的指令缓冲中
  • 最后使用 vkQueuePresentKHR 函数将图像返回给交换链,随后该图像会被显示到屏幕上

提交给队列的操作会被异步执行。我们需要采取同步措施比如信号量来确保操作按正确的顺序执行

总结

总之,为了绘制一个三角形,我们需要采取的步骤包括:

  • 创建一个 VkInstance
  • 选择一个支持的显卡(VkPhysicalDevice
  • 为绘制和显示操作创建 VkDeviceVkQueue
  • 创建一个窗口、Surface 和交换链
  • 将交换链图像包装进 VkImageView
  • 创建一个渲染 Pass,指定渲染目标和使用方式
  • 为渲染 Pass 创建帧缓冲
  • 配置图形管线
  • 为每一个交换链图片分配指令缓冲
  • 从交换链获取图像进行绘制操作,提交图像对应的指令缓冲,绘制完成后返回图像到交换链

API 相关概念

编码约定

所有的函数、枚举和结构体放在 vulkan.h 头文件中

函数前带 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 成员可以指向一个扩展的结构体,本教程中不使用它,它被设置为 nullptr

Vulkan 中创建和销毁对象的函数都有一个 VkAllocationCallbacks 参数,可以被用来自定义内存分配器,在这里不使用它,将其设置为 nullptr

几乎所有 Vulkan 都会返回一个 VkResult 来表示调用的执行情况,它的值要么是 VK_SUCCESS,要么是一个错误代码

校验层(Validation Layers)

Vulkan 的设计目标是高性能、低驱动程序开销。所以,默认情况下,它提供的错误检测和调试功能非常有限。驱动程序会在发生错误时直接崩溃,而不是返回一个错误代码

可以通过 Vulkan 的校验层特性来进行一定的错误检查措施,校验层是一段被插入在 Vulkan API 和驱动程序之间的代码,可以对 Vulkan API 函数的参数进行检查,跟踪内存分配

Vulkan SDK 提供了一组校验层,当然也可以自己编写