Vulkan教程:一、从API开始

最近教授委任我去做项目的Vulkan硬件加速。虽然是自找的麻烦,但学点东西总是好的。决定顺便写几篇文章来讲讲Vulkan。

从OpenGL到Vulkan

Vulkan是一套非常年轻的图形设备API,可以说是OpenGL的后继者。

OpenGL有着非常重的历史包袱。且不说那些令人作呕的函数名,OpenGL实在是太「重」了——甚至连着色器的编译都是OpenGL负责的。现在的图形开发者发现软件的优化已经到了一种极限,想从硬件上榨出最后一点剩余性能,于是Vulkan应运而生。

Vulkan自称是一套毫无秘密的API。就目前看了一小段时间的经验来说,是真的,太罗嗦了。「底层」意味着你能够以非常细的粒度控制硬件,同时也意味着你什么都要管,什么都要写。

多线程

OpenGL是一个全局状态机,任何操作都会反映到整个GL上下文。举个例子,我要向材质写数据,要先将材质绑定到一个全局绑定点(binding point),然后提交数据,然后解除绑定。多线程下,我们很难知道哪个绑定点是空闲的,管理绑定点还得浪费额外的CPU时间。

Vulkan是面向对象的,不保存任何状态,对任何对象的更改相互独立。甚至渲染管线都可以动态装配。即使是支持全局状态访问(Direct State Access)的OpenGL 4.6都无法达到如此的灵活性。虽然可以看得出来,OpenGL 4.6总算是有点现代图形API的样子了。

跨平台

熟悉OpenGL的人可能知道,GL在哪个硬件上运行是由操作系统决定的,要通过平台提供的API才能创建GL上下文。也就是说,即使GL的接口是平台无关的,你要跨平台,还是免不了要写一大堆适配不同操作系统的胶水代码……

Vulkan则不一样。Vulkan通过一个统一的运行时库VulkanRT与驱动/硬件沟通,让你能够完全从平台适配的工作中脱离出来。通过调用相同的API,你就可以在不同的操作系统平台上创建上下文。

可能有人不知道上下文是什么东西:上下文(context)是个比较模糊的概念,基本就是程序的执行「环境」。硬件提供什么样的机能、储存空间怎么分配等等,这些都属于上下文。比如在Windows下,我们要用OpenGL绘制图像需要一个设备上下文(Device Context)和一个渲染上下文(OpenGL Render Context)。设备上下文基本上等于输出缓冲,也就是放像素数据的地方;渲染上下文包含了显卡的信息、状态等。

硬件抽象模型

Vulkan将我们和硬件沟通的通路分为主机(host)和设备(device)两部分。这里的设备可以是GPU,也可以是CPU。从主机向设备传输指令的管道叫做队列(queue)。在主机上,我们首先查询可用的物理设备(physical device)。每个物理设备都有许多队列族(Queue Family),每个队列族中的队列都有相同的属性,比如说能否执行计算着色器/图形着色器之类。

物理设备是不可以直接为我们访问的,要先在物理设备上创建一个抽象的逻辑设备(logical device)。逻辑设备是对物理设备的抽象,这一层抽象对应用程序来说就像是隔板,来自一个程序的计算不会影响另一个程序的计算;对于驱动和硬件来说,他们可以决定如何分配执行来自不同程序的指令,而不是只听一个应用程序的号令。

为了给你一点直观的印象,整个体系应该是这样的:

Vulkan对于硬件的抽象
图 1 Vulkan对于硬件的抽象。

创建实例

VkResult vkCreateInstance(
  const VkInstanceCreateInfo*  pCreateInfo,
  const VkAllocationCallbacks* pAllocator,
  VkInstance*                  pInstance);

首先创建一个Vulkan实例。这个实例的作用和上面提到的渲染上下文类似,定义了我们Vulkan程序能够访问到的软硬件环境。创建的Vulkan实例也决定了我们能够使用哪些API,以及我们如何使用这些API。

typedef struct VkInstanceCreateInfo {
  VkStructureType          sType;
  const void*              pNext;
  VkInstanceCreateFlags    flags;
  const VkApplicationInfo* pApplicationInfo; // 关心这个就好
  uint32_t                 enabledLayerCount;
  const char* const*       ppEnabledLayerNames;
  uint32_t                 enabledExtensionCount;
  const char* const*       ppEnabledExtensionNames;
} VkInstanceCreateInfo;

先说点题外话,大多数Vulkan的调用都会要求你传一堆结构体进去。这些结构体的头两个字段一般都是sTypepNext。前者是结构体的唯一ID,必填,具体的数值可以通过名如VK_STRUCTURE_TYPE_*的枚举值得到,这个字段用来验证你给API传的结构体类型是否正确;后者是这个结构体的扩展结构,是为了方便日后加入更多字段用的保留字段,目前全填nullptr就好了。

vkCreateInstance除了pApplicationInfo,其他的字段基本都用不到,全填零即可。如果你好奇pAllocator是干什么的:aAllocator的结构体里都是函数指针,每个都指向内存管理相关的函数。提供这个结构体的感觉和C++里重载new运算符差不多,让Vulkan用你自己的内存分配器分配储存;写nullptr就表示让Vulkan用自己的内存分配函数。可能在有GC的时候会用到吧?

typedef struct VkApplicationInfo {
  VkStructureType sType;
  const void*     pNext;
  const char*     pApplicationName; // 应用程序名
  uint32_t        applicationVersion; // 应用程序版本号
  const char*     pEngineName; // 引擎名
  uint32_t        engineVersion; // 引擎版本号
  uint32_t        apiVersion; // 重点!API版本号!
} VkApplicationInfo;

创建上下文的过程就跟GL有点不一样了:为什么我还要填我程序和引擎的信息?这个是为了驱动程序对特定应用程序和游戏引擎做优化用的,在DX/GL时代,驱动程序只能靠猜来决定这是个什么程序、该怎么优化;现在Vulkan程序可以直接让驱动知道自己是谁了。当然,这个特性跟你没什么关系,不填也可以(逃

最后一项apiVersion是所有字段里最重要的一个。它决定了你将使用什么版本的VulkanRT。目前Vulkan已经放出两个版本了,1.0和1.1。尽管从1.0到1.1只是小版本号更改,但API还是有些变化的……如果要写针对Vulkan 1.1的程序,就去看1.1的Spec,用1.1的函数。因为版本号不同用了不该用的函数,是可能要出错误的。

枚举物理设备

VkResult vkEnumeratePhysicalDevices(
  VkInstance        instance,
  uint32_t*         pPhysicalDeviceCount,
  VkPhysicalDevice* pPhysicalDevices);

我们要调用这个函数两次。第一次把pPhysicalDevices设为nullptr,然后Vulkan会从pPhysicalDeviceCount把设备数量传出来。分配好足够大的缓冲区,地址填进pPhysicalDevices,调用第二次,Vulkan会把设备信息写进缓冲,这样我们就拿到所有物理设备的资料了。这种两步查询的流程在所有枚举查询里都会用到。

枚举物理设备的队列族

void vkGetPhysicalDeviceQueueFamilyProperties(
  VkPhysicalDevice         physicalDevice,
  uint32_t*                pQueueFamilyPropertyCount,
  VkQueueFamilyProperties* pQueueFamilyProperties);

看到这个函数签名就知道该怎么做了吧?-w-

找到适用的队列族

typedef struct VkQueueFamilyProperties {
  VkQueueFlags queueFlags; // 重要
  uint32_t     queueCount; // 还算重要
  uint32_t     timestampValidBits;
  VkExtent3D   minImageTransferGranularity; // 之后再说
} VkQueueFamilyProperties;

queueCount是当前可用队列族的数量。

queueFlags标记了当前队列族的功能,我们主要关注两个位标记:VK_QUEUE_GRAPHICS_BITVK_QUEUE_COMPUTE_BITVK_QUEUE_GRAPHICS_BIT表示这个队列接受图形着色器管线;VK_QUEUE_COMPUTE_BIT则表示这个队列接受计算着色器管线。VK_QUEUE_TRANSFER_BIT表示这个队列能够在设备和主机间传输数据,但是只要这个设备接受计算/图形管线就一定能传输数据,所以可以不用管它。不知道在什么情况下会需要这个……- -

实例化逻辑设备

VkResult vkCreateDevice(
  VkPhysicalDevice             physicalDevice,
  const VkDeviceCreateInfo*    pCreateInfo, // 重点
  const VkAllocationCallbacks* pAllocator,
  VkDevice*                    pDevice);

我们现在已经收集到了所有要用到的信息,终于可以创建逻辑设备了。(现在应该已经能猜到参数都是什么了吧ww)

typedef struct VkDeviceCreateInfo {
  VkStructureType                 sType;
  const void*                     pNext;
  VkDeviceCreateFlags             flags;
  uint32_t                        queueCreateInfoCount; // 重要
  const VkDeviceQueueCreateInfo*  pQueueCreateInfos; // 重要
  uint32_t                        enabledLayerCount;
  const char* const*              ppEnabledLayerNames;
  uint32_t                        enabledExtensionCount;
  const char* const*              ppEnabledExtensionNames;
  const VkPhysicalDeviceFeatures* pEnabledFeatures; // 还算重要
} VkDeviceCreateInfo;

这里我们先不管没有标记的几个字段,一般都不会用到,填零就好了。

typedef struct VkDeviceQueueCreateInfo {
  VkStructureType          sType;
  const void*              pNext;
  VkDeviceQueueCreateFlags flags;
  uint32_t                 queueFamilyIndex; // 重要
  uint32_t                 queueCount; // 重要
  const float*             pQueuePriorities;
} VkDeviceQueueCreateInfo;

创建逻辑设备的同时也会创建其附属队列。我们可以通过queueCreateInfoCountpQueueCreateInfos指定我们需要的队列族,派生自该族的队列会被创建出来。「派生」也就意味着所有这些同族队列拥有相同的属性和机能。

typedef struct VkPhysicalDeviceFeatures {
  // …一大堆!
} VkPhysicalDeviceFeatures;

这结构太长了我就不列出来啦(

不常用的特性一般都要在pEnabledFeatures中声明,每个特性对应一个VkBool32字段。因为这两天在写加速运算的代码,我就正好用上了这个字段。比如说如果要用到几何着色器,则需要启用将geometryShader字段设为true;要动态索引缓冲块里的数组,则需要设shaderStorageBufferArrayDynamicIndexingtrue

结语

现在我们已经准备好了和硬件沟通的通路。虽然什么都干不了呢(喂

下一篇文章,我将简单介绍如何加载着色器,完成一套完整的渲染管线,并渲染一个最经典的彩色三角形出来。