“Vulkan的第一个三角形”

前言

        这篇文章仅作为个人笔记,若发现任何错误,敬请斧正。

        学了有关Vulkan的一些基本用法,在这里系统的记录一下。感觉Vulkan这个API确实非常麻烦,如果有没接触过图形API的同学看到这里,但是还没深入学习的话,说句实话不推荐把Vulkan作为入门API。因为它非常非常繁琐,这是官方自己说的。

        如果我早知道并且相信的话我一定会选DX12,或者OpenGL。但是当我看了一大半教程后才幡然醒悟已经为时已晚了,这才迫不得已强制学习。

        当然这样也是有些好处的,比如近视度数增加和坐太久了导致腰伤。还有从很底层开始接触,了解了很多东西,也说不出来什么,但应该是有的。

        学了Vulkan再学习其他API应该也会变得很容易。

        无论如何都总比什么都不学好。

        文章中提到的“明细”就是Vulkan官方的Vulkan Specification:

Vulkan Documentation :: Vulkan Documentation Project

初始化

Vulkan Loader 加载器

        我们都知道Vulkan是API,但是其实它不仅仅是图形API,还可以是计算API。图形和计算都是它功能的一部分。而一个API想要发挥作用要通过“库”的实现,而这个“库”就是加载器Loader。加载器属于初始化中的硬件初始化部分。

上面是从LunarG扒的图,详细描绘了Loader的职能:加载与Vulkan应用程序执行相关的东西到驱动。其中包括接下来会谈到的层Layer, 拓展extension。然后驱动程序再去驱动设备。

        把Loader放在第一个来讲其实是因为其实这个才是最基本的VulkanAPI实现功能的方式,而且也便于理解Layer等等是什么东西。Loader可以自己写,不过用内置的就好了。Loader的本质只是一段在程序启动时执行的代码。

        另提一嘴,如果你很好奇这个Loader在哪里,那么Khronos(开发和维护Vulkan的组织,OpenGL之父)会告诉你:"The Vulkan SDK provides a pre-built loader for Windows."(SDK里有预设的Loader),而LunarG会告诉你:"It is found in the independent hardware vendor driver installs"(Loader可以在驱动中找到)。我不知道该相信谁,但是我在SDK中没找到Loader这个字眼,所以应该是现在的驱动已经自带Loader了?

Layer 层

        看Loader那张图应该也能看出来了,所谓的“层”是指应用程序与驱动程序之间的层,用于提供特别功能,通过Loader作用,并且都是可选的。

        具体实现方式形象一点的话就是悄悄往你的API实现里塞东西,比如那一段代码加一个错误检测,这一段代码加一个计算帧率之类的,因为API还是要依靠Loader实现作用,Loader就在这时悄悄根据所启用的Layer给你的代码实现加东西。

        Layer一般是在创建Vulkan实例Instance时启用的,但是也可以通过诸如SDK里的Vulkan Configurator之类的程序启用。

Instance 实例

        Instance是用来存储Vulkan应用程序的状态信息的。不出意外的话也是我们创建的第一个可分发句柄Dispatchable Handle,对应于不可分发句柄non-Dispatchable。目前看来,这两种句柄的最主要区别就是可分发句柄一定有全局唯一值,而不可分发句柄根据具体实现可能没有全局唯一值。纠结这个目前没有实际意义(因为我也不清楚)。不过也能说明可分发句柄的重要性。

        回到Instance本身,作为初始化的一部分,它可以为驱动提供一部分的应用程序的信息,比较重要的可能是API的版本,API版本对诸多实现有影响,如验证层。

        Instance创建完后,可以说Vulkan程序的初始化已经完成,但是现在还什么都干不了。

窗口

WSI 窗口系统集成

        与OpenGL不同,Vulkan在创建设备与环境不需要包含一整套的窗口系统,而是把它们作为可选项,加入到窗口系统集成中。

        WSI设计用于将不同平台的窗口机制从Vulkan的核心编程中抽象出来。

        如果你也像我一样是一名游戏开发者,那么Vulkan的WSI是必须要用到的,不然你让玩家看什么。哦等等,除非是做服务器之类的后端开发。

WSI Surface

        目前不知道怎么翻译"Surface"这个词,有人可能会叫“展示表面”之类的。无论如何,目标平台的窗口等等展示表面都被WSI抽象成了Surface。所以Surface这个概念应该是指宿主机上的展示表面。

        所以Surface呢就是用来展示渲染结果的。它通过SurfaceKHR对象表示,后缀KHR意味着可能会有除了Khronos组织之外的Surface对象提供者。而要想使用SurfaceKHR,就必须先启用实例扩展Instance Extension : VK_KHR_surface。而要想创建一个SurfaceKHR对象,就必须启用对应平台的相关拓展,如Windows我们用VK_KHR_win32_surface,每个平台都有属于自己的创建SurfaceKHR的方法。

        一般而言创建一个输出表面用Khronos的SurfaceKHR就够了。主要是留意对应平台的拓展需要启用。在创建Surface后,可以记录它的一些信息用于后面的配置。

Extension 拓展

        拓展分类两类:Instance和Device拓展,顾名思义,这两种拓展分别实在对应类别的对象创建时指定的。

        拓展为程序提供了许多新函数,结构体,标志位之类的东西,而且其实是已经内嵌到VulkanAPI的头文件当中了,只是需要在调用前启用对应的拓展。如果你尝试使用没有启用的拓展将会导致未知行为。应该没有人会这么做吧。

        有几个比较恶心的地方,虽然说拓展的API被包含在头文件里了,但是要想调用还是得事先定义它们对应的宏。比如想要调用 VkWin32SurfaceCreateInfoKHR 就要定义VK_USE_PLATFORM_WIN32_KHR这个宏。C++风格也一样。而且还要注意查表看看这些函数的是什么拓展支持的,拓展是什么类型的,设备或实例支不支持这个拓展,拓展有没有启用之类的事情。

        无论如何,为了让我们能在Windows系统上看到东西我们将启用对应的拓展。

SDL创建Vulkan Surface

        在Khronos的官方教程中,是通过GLFW实现的Surface的创建,在我自己的实践中采用了SDL,两者都具有的跨平台性。我在这里记录一下SDL创建Surface的过程。 

        首先调用SDL_Vulkan_GetInstanceExtensions函数,该函数定义在SDL_vulkan.h头文件中。它是一个C风格的函数,顾名思义用于获取创建SDL关于创建Vulkan实例时需要一些拓展。

        我们只需要在创建实例时启用这些拓展,就可以使用SDL_Vulkan_CreateSurface(同样定义于SDL_vulkan.h)创建使用于SDL创建的窗口的Surface了。

        从此也能看出,Surface功能,或者说窗口展示功能在Vulkan中作为拓展实现,并非核心功能。

设备

        当然了,Vulkan是一个GPU的API,他需要跟GPU打交道。如果没有窗口系统,那么物理设备PhysicalDevice和逻辑设备LogicalDevice是首先要准备好的东西。

PhysicalDevice 物理设备

       物理设备需要通过实例枚举出来,并且选择合适设备,其实一般只有一个设备。与此同时可以适当的记录一下与物理设备强关联的一些信息:

  • Properties 属性

  • Features 特性

  • Extensions 拓展

  • Limits 限制

  • Formats 格式

        上述信息都对后来的程序配置很重要,适当的记录可以有条不紊。 

        物理设备是不需要创建的,因为它是一个切实存在的象征,因此也不必销毁。

Device 逻辑设备

        逻辑设备是对物理设备的抽象,物理设备是有限个的,但逻辑设备可以无限创造,它们拥有自己的资源,不会相互占用其他逻辑设备的资源。可以把逻辑设备类比成操作系统,物理设备就是底层硬件。

        在逻辑设备创建以后,我们会经常看到它的身影,因为往后的各种资源都是根据逻辑设备来创建的。

        逻辑设备的创建需要通过物理设备,除此之外还跟队列有关。在逻辑设备之后的很多东西,包括资源,各种对象等都基本上通过逻辑设备创建或销毁。

Queue 队列

        队列是命令的执行队列,命令从一列一列队列中被提交Submit给设备,然后执行,命令的详细信息记录于命令缓冲CommandBuffer中。队列是Vulkan中的中间层机制(我也不懂是什么意思),队列如何映射到底层硬件是由硬件的实现决定的。

        队列被划分到一个一个队列族Family中,每个族的队列都有一种相同的功能,不同族之间的队列可以重叠。换句话说就是一个队列可以同时承担多种操作功能。

        一般都是通过物理设备获取队列族的,vkGetPhysicalDeviceQueueFamilyProperties这个函数返回一个数组,数组元素的下标就是队列族的下标(其实这个设计挺奇怪的)。所以这些下标有必要记录,方便后面依据队列族下标创建队列。

        创建逻辑设备时需要同时提供队列的创建信息,换句话说逻辑设备与它持有的队列一同创建。

Feature 特性

        这里的特性是指物理设备的特性,特性都是可选的,并且需要在逻辑设备创建时指定需要启用的特性。因为特性不是共性,所以需要检测并启用。

        要想启用特性需要首先通过物理设备获取一个描述其支持的特性的结构体,然后设置好同样的结构体并传入逻辑设备的创建信息中。

Swapchain 交换链

        在Vulkan中,交换链其实同样也是由WSI提供的机制,换句话说想要使用交换链也同样要先启用对应的拓展。之所以放到现在讲主要是因为交换链的拓展是设备拓展Device Extension,也就是说交换链与设备强相关,所以需要放在设备创建后通过设备创建。

        交换链为我们提供了图像交换和呈现Present机制,它并不是Vulkan独有的概念,而且广泛运用于图形设备中。交换链是一组与Surface关联的可呈现图像Presentable Image的抽象,可呈现图像是由平台创建的,并在Vulkan以普通的图像Image资源表示,我们可以通过Swapchain获取这些图像vkGetSwapchainImagesKHR,或者从本质上来说,交换链就是这些图像。

        一个平台的本地窗口不能同时和多个“现役”Non-retired的交换链(就是可以用的,相对于旧的Old Swapchain)绑定,而且Vulkan的交换链不能由没有VulkanAPI支持的Surface创建。

Presentable Image 可呈现图像

        可呈现图像(后简称PI)的隶属于呈现引擎Presentation Engine,它是平台的合成器compositor或显示引擎Display Engine的抽象。顾名思义,前者是用来综合合成绘制图像的,后者是用来读取并展示图像数据的。要想使用PI,就必须先用vkAcquireNextImage获取它,而且要在vkQueuePresentKHR之前使用完毕,也就是呈现前。在使用PI的过程中应该包括对PI图像布局的正确转换和渲染命令。

        在对PI动手动脚之前,我们需要指定一个同步图元来确保呈现引擎以及读取完图像了。在这之后布局转换和渲染等任务才能照常进行。如果获取了又不想用或用不到了就可以用vkReleaseSwapchainImagesEXT释放(目前需要拓展VK_EXT_swapchain_maintenance1)。

Present Mode 呈现模式

        呈现模式也是决定呈现引擎作用的关键因素,目前有这些模式:

  • VK_PRESENT_MODE_IMMEDIATE_KHR 指示呈现引擎不需要等待垂直消隐vertical blanking期来更新图像。意味着极有可能造成图像撕裂,所以一般都不会用的吧?除非有什么特殊癖好。

  • VK_PRESENT_MODE_MAILBOX_KHR 指示引擎等待垂直消隐期,并在内部维护一个单入口队列,用来容纳Present请求,如果请求塞满了并且有新的请求,那么新的请求会直接替换入口那个请求,换句话说请求对应的图像会被直接替换(在消隐期内替换,不会导致画面撕裂),意味着将没有阻塞,也可能造成资源浪费,不适用于资源敏感的平台。这种方法可以用来实现“三重缓冲”。

  • VK_PRESENT_MODE_FIFO_KHR 就是垂直同步(误)。这种模式是Vulkan一定支持的。

  • VK_PRESENT_MODE_FIFO_RELAXED_KHR 和上一种方式类似,但是以一定的画面撕裂为代价来减少呈现延迟。

  • VK_PRESENT_MODE_SHARED_DEMAND_REFRESH_KHR 指示引擎和应用共享一张图像,它被称为共享图像Shared Image,看名字也能看出来。共享图像意味着可以被引擎和应用访问。在该模式下,引擎只有在接收到请求时才更新图像,如果使用不当可能造成撕裂。

  • VK_PRESENT_MODE_SHARED_CONTINUOUS_REFRESH_KHR 和上一种方法类似,也属于共享图像类型的模式,不过它只需要一次请求,然后将会一直根据引擎自己的更新循环来更新图像。

        最后两种方式需要启用VK_KHR_shared_presentable_image拓展。

        还记得之前创建Surface时记录的信息嘛,创建交换链时需要根据这些信息选择合适的配置。

        和之前的设备连同队列一同创建一样,交换链也连同它包含的图像一同创建一同销毁,一般都会根据Surface和实际需要指定交换链图像的创建信息。

        由此可以看出拓展的使用对于Vulkan程序设计的重要性,比如交换链这一常用的图形渲染概念在Vulkan中就以拓展的形式引入(当然Layer也很重要的啦)。

        那么完成上述关于设备的相关配置后,我们的程序已经可以开始准备渲染了。

资源

        开玩笑的,连要渲染什么都不知道怎么准备渲染呢。Vulkan中最初级的两种资源就是缓冲Buffer和图像Image。

Buffer 缓冲区

        缓冲区表示用于各种目的的线性数据数组,通过描述符集Descriptor Set或某些命令将它们绑定到图形或计算管线Pipeline,或者直接将它们指定为某些命令的参数。

BufferView 缓冲区视图

        缓冲区视图表示一个缓冲区的连续范围和用于解释数据的特定格式。缓冲区视图被用来使着色器能够使用图像操作Image Operation访问缓冲区内容。为了创建有效的缓冲区视图,缓冲区必须至少使用以下使用标志之一创建:

  • VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT

  • VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT

Image 图像

        图像表示多维(最多3个)数据数组,可用于各种目的(例如附件、纹理),通过描述符集将其绑定到图形或计算管线,或直接将其指定为某些命令的参数。

Image Layout 图像布局

        图像布局是图像在特定时期是具有的布局,会随图像的工作过程发生改变。可以尝试把他理解成为一种用于描述图像的状态。

        特定的工作会需要特定的图像布局,比如转移Transfer任务需要VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL或VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL来实现转移功能。针对特定的任务,合适的布局还可以提供更好的性能。每个图像子资源都可以同时拥有不同的图像布局。

        因此图像布局的转换是必要的,目前有屏障Barrier和子通道依赖Subpass Dependency两种办法实现图像布局的转换。

ImageView 图像视图

        管线的着色器不会直接访问图像对象以读取或写入图像数据。而是以图像视图的形式。图像视图代表图像子资源Subresource的连续范围并包含额外元数据。必须在兼容类型的图像上创建视图,并且必须表示图像子资源的有效子集。

Subresource 子资源

        这里的子资源特指上图像视图的子资源。至于子资源究竟是什么可以用微软D12文档的一张图来说明:

        上述图像可以视作一个图像资源,一个Image的内部细节。意味着其实一个Image图像只是一种资源的叫法,它自身可以包含一个图像,或者多个图像(作为一个图像数组)。这也是为什么在创建图像时需要指定纹理LOD级别Miplevel和数组层数ArrayLayers了。

        而“图像”内部的资源就是它的子资源。当我们需要获取图像中某个特定范围的子资源时,ImageView中的子资源范围SubresourceRange就被用来指定获取内容的范围。图像是一种资源,而图像视图是用于指定获取图像资源时的资源范围。比如SubresourceRange中的BaseArrayLayer指定基础层级的下标,而LayerCount指定从基础开始多少层数。通过指定各种范围确定图像视图的访问范围。

队列族与资源

        缓冲区和图像都可以在创建时指定可以访问它们的队列族。并通过共享模式SharingMode指定队列的访问模式。

  • VK_SHARING_MODE_EXCLUSIVE 专有模式:只允许某个单一队列族访问。

  • VK_SHARING_MODE_CONCURRENT 合作模式:允许多队列族访问。

         专有模式的性能表现会比合作模式高(高多少不知道......)。所以应根据实际情况选择。在创建资源时可以指定能够访问到它们的队列族和队列族数量。

        当资源为专有模式时,该资源只能被拥有该资源“所有权ownership”的队列族中的队列访问。而要想让其他队列族访问,就必须实现“队列族所有权转移”操作。

        值得注意的时,当用专有模式创建资源时,是不需要指定其所有的队列族的。该资源的所有权会隐式的赋予第一个调用该资源的队列的族。而只有使用合作模式才需要在创建时指定共享的队列族下标和数量。

        资源一般而言是以Vulkan实例为单位阐述的,但是可以实现跨实例转移资源,这里先不表。

内存与资源

        创建资源时其实时“虚分配”,也就是还没有实际的内存支持,实际内存的开辟(这里特指设备内存Device Memory)需要手动分配Allocate。

        分配内存后需要手动将资源与内存绑定Bind起来,而绑定又与资源的类型相关,资源又分为稀疏资源Sparse Resources和非稀疏资源。根据这两种类型绑定有不同的绑定情况。

        这里只说明非稀疏资源的绑定注意事项,必须要在使用前绑定,不论是创建资源视图,还是作为命令的参数,又或者是用于资源描述符。反正对于非稀疏资源,在内存分配好后立即进行绑定一般不会有什么意外了。

        一旦绑定了,在资源的生命周期内都不会改变。

        值得注意的是,很多时候资源占用的内存不等于分配资源需要的内存,也就是需要的≠需要Allocate的。更多地我们应该在分配内存前优先查询一下资源需要的内存,比如某个资源需要15Byte的内存,但是为了对齐我们的设备需要16Byte(大概就是这个意思)。所以实际的内存和需要的内存Requirement会有偏差。

Descriptor 描述符

        资源描述符,可以把它理解为一个指向资源的指针。这里的资源指的是专门用于着色器Shader的资源,如缓冲区,缓冲区视图,图像视图,采样器Sampler等等。

DescriptorSet 描述符集

        描述符集是用来管理描述符的,也是描述符在资源管理上的最小颗粒。描述符集的详细信息是由描述符布局指定的。

DescriptorSetLayout 描述符集布局

        描述符集布局对象由零个或多个描述符绑定DescriptorBinding的数组定义。每个单独的描述符绑定都由描述符类型、绑定中描述符数量的计数(数组大小)、可以访问绑定的一组着色器阶段以及(如果使用不可变采样器)采样器描述符数组指定。

        描述符集布局需要与后面的渲染管线布局PipelineLayout想关联,以实现描述符在管线中的应用。

DescriptorPool 描述符池

        要想使用描述符,就要先创建一个描述符池,从中分配描述符集。描述符池是外部同步的,这意味着应用程序不得在多个线程中同时从同一池中分配和/或释放描述符集。

        从描述符池分配的描述符集与池同生死共存亡,当池被销毁时,从池中分配的所有描述符集都会被隐式释放并变得无效。在销毁该描述符池之前,不需要释放从给定池分配的描述符集。

更新描述符集

        有很多种方式去更新描述符集,一个描述符集在分配好之后一定需要更新才能作用与Shader。最常用(官方教程中的方法)就是调用vkUpdateDescriptorSets,这个函数主要接收描述符集的写入Write和复制Copy操作。Write和Copy结构用来指定与描述符集关联的资源们,以及如何关联。

        记得要在命令中绑定描述符集,它的更新才有意义,vkCmdBindDescriptorSets就是用来干这个的。

        除此之外还可以直接在命令中更新描述符集,而不用想上述一样拆开更新和绑定两者。这主要依赖VK_KHR_push_descriptor扩展提供的功能:推送描述符,详细的用法这里不表。还有描述符缓冲区方式等等。

Push Constant 推送常量

        除了描述符集之外,推送常量也可以作为着色器的数据。

        推送常量时可通过API写入并可在着色器中访问的少量值。推送常量允许应用程序设置着色器中使用的值,而无需为每次更新创建缓冲区或绑定和更新描述符集。

        推送常量同样需要在渲染管线布局中详细配置,并最终通过命令推送到管线中给着色器使用。

Memory 内存

        Vulkan的内存分为俩大类:主机内存Host和设备内存Device。

        顾名思义,对于PC而言,一个就是我们CPU的内存,一个是GPU的内存。主机内存是我们可以直接控制的,Vulkan设置主机内存只要我为了让我们以Vulkan的名义分配内存(听起来很奇怪)。无论如何,在实际游戏开发中用到最多的都是设备内存。

        我们在创建资源并为它们分配内存时,我们往往会希望分配特定类型的内存。VkPhysicalDeviceMemoryProperties函数可以为我们获取物理设备的内存属性,其中包括堆Heap属性和能从这些堆中访问数据的内存类型MemoryType。使用特定内存类型的分配将消耗该内存类型的堆索引所指示的堆中的内存资源。多个内存类型可能共享同一个堆,堆和内存类型提供了一种机制来通告物理内存资源的准确大小,同时允许内存用于各种不同的属性。

        也就是说Vulkan会根据我们分配的内存的类型来在对应的内存堆上开辟空间。

        在内存堆中,一定至少有一个堆具有VK_MEMORY_HEAP_DEVICE_LOCAL_BIT标志,这个挺合理的,比较总是应该存在设备本地的内存。

        在内存类型中,一个内存类型可以同时具有很多标志位,如在所有内存类型中,一定保证至少有一个类型同时具有VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 和 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT这两个标志。也一定保证至少有一个类型具有VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT。意思就是说Vulkan保证上面的内存属性标志MemoryPropertyFlags都是存在的,我们可以放心用,不用担心这些类型的内存或堆有没有。

        在分配内存时,会需要我们指定内存类型下标MemoryTypeIndex,这个下标就是上述从物理设备获取的内存属性MemoryProperties中的MemoryType数组的下标。所以我们需要根据需求记录拥有对应需求的内存类型的下标,并在内存分配时使用。

        在Khronos的教程和明细中,都有相关的判断方法。使用具有不同属性的设备内存会对性能有不同影响,最经典的就是设备本地Device Local的内存要优于主机可见Host Visible,以及缓存Cached优于没有缓存Uncached。这也引出了一些针对内存优化的思考和操作。

        内存的内容还有很多,包括什么延迟分配之类的。当内存准备好需要渲染的资源和内存后,我们就可以真正着手准备渲染了。

渲染

Shader 着色器

        其实着色器也应该被算作是一种资源,不过不是传统意义上Vulkan的资源。对于游戏开发者,Shader肯定不陌生。一句话描述就是在GPU上运行的程序,仅此而已。

        在Vulkan中,Shader同样有多种方式创建。比如通过启用VK_EXT_shader_object拓展,可以创建对应的Shader对象ShaderObject以及使用一系列相关API。它主要是将Shader和渲染管线剥离,在需要时在命令中绑定。

        而在Khronos教程中,使用的是ShaderModule,着色器模块。与ShaderObject不同,ShaderModule是在创建渲染管线的时候加入到管线中,称为管线的一部分。

        目前不懂这两种不同的Shader用法有什么实质的区别,可能就是一个比较灵活,一个比较固定......

        Shader的种类很多,而且每个都有自己的一些小细节。我这里只记录对游戏开发而言比较常见的顶点和片元着色器。

Vertex Shader 顶点着色器

        每个顶点着色器调用都对一个顶点及其关联的顶点属性Attribute数据进行操作,并输出一个顶点和关联的数据。使用图元着色Primitive Shading的图形管线必须包含顶点着色器,顶点着色器阶段始终是图形管线中的第一个着色器阶段。

Fragment Shader 片元着色器

        片段着色器在图形管线中作为片段操作被调用。每个片段着色器调用都对单个片段Fragment及其相关数据进行操作。除了少数例外情况,片段着色器无法访问与其他片段相关的任何数据,并且被认为是在与其他片段关联的片段着色器调用隔离的情况下执行的。

SPIR

        SPIR(Standard Portable Intermediate Representation)最初是为OpenCL开发的,SPIR 1.2和2.0版本基于LLVM。SPIR现已发展成为一种跨API中间语言,由Khronos完全定义,并对Vulkan等API使用的着色器和内核功能提供本地支持,称为SPIR-V。

        之所以定义这玩意看起来是因为Khronos被厂商们搞犯了,所以推出了一种比较规范的表示着色器的数据格式。

RenderPass 渲染通道(渲染流程)

        RenderPass就是对一次渲染过程的抽象。如果你用过Unity的话,应该会对这个概念有印象。我个人将其理解成“渲染流程”,相对于渲染管线而言,RenderPass包含实际的渲染对象(那些图像资源),这些对象在Vulkan中被称为附件Attachment。

        渲染管线只是固定的机器流水线,而RenderPass才是真正决定放在管线上渲染的是什么东西,以及这些东西要怎么渲染。

        Vulkan中这样定义RenderPass:RenderPass对象表示附件Attachment、子通道Subpass和子过程之间的依赖关系Dependency的集合,并描述了在子过程中如何使用附件。

Attachment 附件

        就Vulkan来看,附件就是参与渲染的图像资源。附件不用手动创建,因为严格来说它就是已经存在的资源,只需要换种方式引用附件,就像图像资源的View视图。附件描述AttachmentDescription就是用来干这个的。描述了参与渲染的图像的属性,包括其格式Format、采样数Sample以及在每个渲染通道实例的开始Load Operation和结束Store Operation时如何处理其内容。

Subpass 子通道

        子通道表示渲染的一个阶段,在该阶段中会读取和写入渲染通道中的附件的某个子集(一部分或全部)。渲染命令就记录在某个渲染通道实例的特定子通道中。和附件一样,子通道也不需要实际创建出来,只需要描述,使用子通道描述SubpassDescription。子通道描述,描述了执行子通道所涉及的附件。每个子通道都可以从一些附件中进行读取操作将其作为输入附件,向一些附件中进行写入操作并将其作为颜色附件或深度/模板附件,对颜色附件或深度/模板附件执行着色器解析操作Resolve Operation,以及为解析附件Resolve Attachment执行多重采样MSAA解析操作。子通道描述还可以包括一组保留附件Preserve Attachment,这些附件不是子通道读取或写入的,但其内容必须在整个子通道中保留。

        一个渲染通道可以包好多个子通道,并且每个子通道执行的操作依赖于经过前一个子通道的帧缓冲区Framebuffer的内容。可以理解为附件经过一个又一个子通道,在其中发生各式各样的操作。

        子通道可以引用以下类型的附件:

  • pInputAttachments: 从着色器中读取的附件

  • pColorAttachments: 代表图像数据的附件

  • pResolveAttachments: 多重采样结果的颜色附件

  • pDepthStencilAttachment: 用于深度模板测试的附件

  • pPreserveAttachments: 不会被子通道使用,但会保留数据的附件

        每种附件都需要通过AttachmentReferance指定其下标和引用时的布局。 其中下标就是创建渲染通道时指定的AttachmentDescription数组的下标,而布局决定了子通道引用它们时会自动转变为的布局。

        对于InputAttachment和ColorAttachment而言,它们各自数组的下标还与片元着色器中的输入(Input)或输出(Color)位置下标一致(数组下标 X  等价于 location =  X)。

SubpassDependency 子通道依赖

        子通道依赖是用来描述子通道之间的执行依赖Execution Dependency和内存依赖Memory Dependency的,两者被引入两组“操作”之间发挥作用(后面会讲)。所谓的“操作”Operation在Vulkan就是指在主机,设备或某个外部实体(比如呈现引擎Presentation Engine)上执行的工作。

        如果存在多个子通道,子通道的执行顺序可能会与其他子通道重叠或乱序执行,除非执行依赖另有规定。每个子通道只遵守自己通道中记录的命令的提交顺序,以及用于分隔渲染过程的vkCmdBeginRenderPass和vkCmdEndRenderPass命令,但是不包括其他子通道中的命令。这会影响大多数其他隐式排序保证机制。

        所以子通道依赖和子通道一样具有改变通道中附件图像布局的能力。

渲染通道与帧缓冲区,渲染管线

        所以一整个渲染通道其实只描述了子通道和附件的结构,与附件对应的实际图像资源(图像视图)无关。实际放入渲染通道的图像资源及其详细信息需要在帧缓冲区Framebuffer中指定。帧缓冲区也需要根据与它兼容的特定渲染通道来创建。总的来说,渲染通道和帧缓冲区定义了一个或多个子通道的完整渲染目标状态以及子通道之间的算法依赖关系。

        不论是帧缓冲区还是渲染管线,都需要根据特定的渲染通道来创建。而且只有那个特定的渲染通道或者是和它适配的其他渲染管线才能和两者一起使用,至于有关适配的详细定义在明细里有。当用某个渲染通道创建帧缓冲区时,我们就说该帧缓冲区跟该渲染通道是适配的Compatible。

        可能这里很乱,因为实际就是很乱啊啊啊,我已经尽力改了翻译尽量看起来容易理解一点。

Framebuffer 帧缓冲区

        帧缓冲区本质上就是一个缓冲区数组,在Vulkan定义为渲染通道实例使用的特定内存附件的一个集合。

        在Framebuffer的创建信息结构体中,和渲染通道一样有一个叫做attachments的成员,但是这里的attachment不在是一个描述description,而是实实在在的图像资源对象,以图像视图ImageView的形式被引用。这里的“attachment”才是真正被塞入渲染通道中的资源。

        而且要注意这里的attachment下标要与创建渲染通道时对应的下标一致。也就是说不论数量还是类型,两者的attachment都要对应起来。

        一般情境下,我们需要的根据交换链中的图像个数来创建同等数量的图像视图和帧缓冲区,因为每一帧都应该有自己的帧缓冲区。

        有了渲染通道和所需的图像资源后我们就可以创建帧缓冲区了。

Pipeline 渲染管线

        渲染管线是一个庞大的话题,在Vulkan中有多种渲染管线可选,这里我们只关注图形管线Graphyics Pipeline。

        图形管线有两种模式,图元着色模式Primitive Shading和网格着色模式Mesh Shading。最经典的就是图元模式。

        

        上图是官方教程中的图形管线的简化示意图,我直接把介绍复制粘贴过来了:

Input Assembler 输入装配器

        输入汇编器从您指定的缓冲区(顶点缓冲区VertexBuffer)收集原始顶点数据,还可以使用索引缓冲区(IndexBuffer)重复某些元素,而无需复制顶点数据本身。

Vertex Shader 顶点着色器

        顶点着色器对每个顶点运行,通常应用变换将顶点位置从模型空间转换到屏幕空间。它还将逐顶点数据沿管道传递。

Tessellation 表面细分

        细分着色器允许您根据某些规则细分几何体,以提高网格质量。这通常用于使砖墙和楼梯等表面在附近时看起来不那么平坦。

Geometry  Shader 几何着色器

        几何着色器在每个图元(三角形、直线、点)上运行,可以丢弃它或输出比原来更多的图元。这类似于镶嵌着色器,但更灵活。然而,在当今的应用中,它的使用并不多,因为除了英特尔的集成GPU外,大多数图形卡的性能都不太好。

Rasterization 光栅化

        光栅化阶段将图元Primitive离散化为片段Fragment(也可以叫片元)。片段是将要填充在帧缓冲区上的像素元素。任何落在屏幕外的片段都会被丢弃,顶点着色器输出的属性会在片段之间插值,如图所示。通常,由于深度测试,在初始片段后面的片段也会被丢弃。

Fragment Shader 片段着色器

        为每个幸存的片段调用片段着色器,并确定片段写入哪个帧缓冲区FrameBuffer以及使用哪个颜色和深度值。它可以使用顶点着色器的插值数据来实现这一点,这些数据可以包括纹理坐标和光照法线等。

Color Blending 颜色混合

        颜色混合阶段应用操作来混合映射到帧缓冲区中同一像素的不同片段。片段可以简单地相互覆盖、相加或根据透明度混合。

        带有绿色的阶段被称为固定功能阶段。这些阶段允许您使用参数调整它们的操作,但它们的工作方式是预定义的。

        橙色的阶段是可编程的,这意味着您可以将自己的代码上传到图形卡上,以应用您想要的操作。例如,这允许您使用片段着色器来实现从纹理和照明到光线跟踪器的任何功能。这些程序同时在多个GPU内核上运行,以并行处理许多对象,如顶点和片段。

        如果您以前使用过OpenGL和Direct3D等旧API,那么您将习惯于通过glBlendFunc和OMSetBlendState等调用随意更改任何管道设置。Vulkan中的图形管道几乎是完全不可变的,因此如果你想更改着色器、绑定不同的帧缓冲区或更改混合函数,你必须从头开始重新创建管道。缺点是,您必须创建多个管道,以表示您希望在渲染操作中使用的所有不同状态组合。但是,由于您将在管道中执行的所有操作都是预先知道的,因此驱动程序可以更好地进行优化。

        根据您的意图,一些可编程阶段是可选的。例如,如果您只是绘制简单的几何图形,则可以禁用镶嵌和几何阶段。如果您只对深度值感兴趣,则可以禁用片段着色器阶段,这对阴影贴图生成很有用。

Dynamic state 动态阶段

        可以不必一次性配置完所有的管线阶段,可以将一部分阶段设置为动态阶段,并在相应的动态阶段信息结构体中指明它们。

        绑定管线对象时,任何未指定为动态的管线对象状态都将应用于命令缓冲区状态。此时,指定为动态的管道对象状态不会应用于命令缓冲区状态。

        相反,动态状态可以随时修改,并在命令缓冲区的生命周期内持续存在,或者直到被另一个动态状态设置命令修改,或者通过静态绑定指定该状态的管线而无效。

        最常见的是把视口阶段和裁剪阶段设为动态阶段,因为窗口经常会变,然后在记录命令时再设置它们。

管线配置指南

        其实只需要根据图形管线的创造结构体里面的状态一个一个配置就好了。

        有一些可能需要注意的细节(待完善):

        1.在顶点输入属性vertex input binding 中定义的格式format的用途:
指定shader读取数据时的大小和格式。之所以用的图像的format大概是官方懒。

        因为每个location其实都有4个组件component,所以格式和shader中指定的数据类型不一致可能会导致数据裁剪和填充。

        比如:对于同一个属性, format r32g32 ;数据类型 vec3 。这时vec3有三个组件,而格式是两个,那么shader读取时会自动填充第三个为指定的组件,数值为 1 (整形或浮点依据格式)。当指定的格式多于数据类型时,就会被裁剪。换句话说就是以shader中指定的数据类型为主(合理)。

命令

        命令是实现一个Vulkan图形程序的最后一环。介于这个部分的重要性,我决定严谨的搬运Vulkan明细中的内容(偷懒)。

CommandBuffer 命令缓冲区

        命令缓冲区是用于记录命令的对象,这些命令随后可以提交到设备队列中执行。命令缓冲区有两个级别:次要命令缓冲区和主要命令缓冲区,主要命令缓冲区可以执行辅助命令缓冲区或提交给队列。而辅助命令缓冲区可以由主命令缓冲区执行,但不直接提交给队列。        

        可被记录的命令包括将管线和描述符集绑定到命令缓冲区的命令、修改动态状态的命令、绘制命令(用于图形渲染)、调度命令(用于计算)、执行辅助命令缓冲区(仅用于主命令缓冲区)的命令、复制缓冲区和图像的命令以及其他命令。

        每个命令缓冲区独立于其他命令缓冲区管理状态。主命令缓冲区和辅助命令缓冲区之间或辅助命令缓冲区时没有状态继承。当命令缓冲区开始记录时,该命令缓冲区中的所有状态都是未定义的。当记录了在主命令缓冲区上执行的辅助命令缓冲区时,辅助命令缓冲区不会继承主命令缓冲的任何状态,并且在记录了执行辅助命令缓冲命令后,主命令缓冲区域的所有状态都是未定义的。此规则有一个例外:如果主命令缓冲区位于渲染通道实例内,则执行辅助命令缓冲区不会干扰渲染通道和子通道状态。对于依赖于状态的命令(如绘制和分发),这些命令所消耗的任何状态都不能是未定义的。

        除非另有规定,并且没有显式同步,否则通过命令缓冲区提交到队列的各种命令可以以相对于彼此的任意顺序执行或并发执行。此外,如果没有显式的内存依赖关系,这些命令的内存副作用可能对其他命令不直接可见。在命令缓冲区内以及提交给给定队列的命令缓冲区之间都是如此。

命令缓冲区的生命周期

        每个命令缓冲总会处在以下状态中的某个:        

Initial 初始

        当分配命令缓冲区完毕时,它处于初始状态。某些命令能够将命令缓冲区(或一组命令缓冲区)从任何可执行、记录或无效状态重置回此状态。处于初始状态的命令缓冲区只能移动到录制状态或释放。

Recording 记录

        vkBeginCommandBuffer将命令缓冲区的状态从初始状态更改为记录状态。一旦命令缓冲区处于记录状态,就可以使用vkCmd*命令记录到命令缓冲区。

Executable 可执行 

        vkEndCommandBuffer结束命令缓冲区的记录,并将其从记录状态移动到可执行状态。可执行命令缓冲区可以提交、重置或记录到另一个命令缓冲区。

Pending 挂起

        队列提交命令缓冲区会将命令缓冲区的状态从可执行状态更改为挂起状态。在挂起状态下,应用程序不得以任何方式尝试修改命令缓冲区,因为设备可能正在处理记录到其中的命令。一旦命令缓冲区的执行完成,命令缓冲区要么恢复到可执行状态,要么如果它是用VK_command_buffer_USAGE_ONE_TIME_SUBMIT_BIT记录的,它将移动到无效状态。应使用同步命令来检测何时发生这种情况。

Invalid 无效

        某些操作,例如修改或删除记录到命令缓冲区的命令中使用的资源,会将该命令缓冲区状态转换为无效状态。处于无效状态的命令缓冲区只能重置或释放。

命令缓冲区生命周期图:

        在命令缓冲区上操作的任何给定命令都有自己的要求,即命令缓冲区必须处于什么状态,这些要求在该命令的有效使用约束中有详细说明。

        重置命令缓冲区是一种丢弃任何先前记录的命令并将命令缓冲区置于初始状态的操作。重置是vkResetCommanBuffer或vkResetCommandPool的结果,也可以是vkBeginCommandBuffer的一部分(它还将命令缓冲区置于记录状态)。

主缓冲区与次缓冲区

        辅助命令缓冲区可以通过vkCmdExecuteCommands记录到主命令缓冲区。这在一定程度上将两个命令缓冲区的生命周期联系在一起:如果主缓冲区提交到队列,则主缓冲区和记录到其中的任何次缓冲区都会进入挂起状态。一旦主命令的执行完成,其中记录的任何辅助命令也会完成。在每个命令缓冲区的所有执行完成后,它们都会移动到相应的完成状态(如上所述,可以移动到可执行状态或无效状态)。

        如果辅助缓冲区移动到无效状态或初始状态,则所有主缓冲区都会被记录在移动到无效的状态中。主设备移动到任何其他状态都不会影响其中记录的次设备的状态。

        重置或释放主命令缓冲区会删除与记录到其中的所有辅助命令缓冲区的生命周期链接。

CommandPool 命令池

        可能像描述符和命令这种在Vulkan程序中一定会被经常重复使用的对象会被Vulkan以池的方式管理,就像我们游戏开发中常用的设计模式:对象池。

        命令池是从中分配命令缓冲区内存的不透明对象,它允许实现跨多个命令缓冲区分摊资源创建的成本。命令池是外部同步的,这意味着命令池不能在多个线程中同时使用。这包括通过记录从池中分配的任何命令缓冲区上的命令来使用,以及分配、释放和重置命令缓冲区或池本身的操作。

        命令池的创建还是很简单的,主要要留意创建时需要提供一个队列族下标,并且所有通过该命令池分配的命令缓冲只能通过该队列族提交。

命令的记录与提交

        因为这个内容小细节比较多,最好还是在明细里按需查询。

        对于记录Record而言,基本要注意的点就是主次命令之间的记录方式的不同,还有每个命令之间的执行顺序关系,不要范一些逻辑上的错误,比如先设置管线的动态状态,然后才绑定管线。或者压根就忘了设置管线的动态状态。对于C风格,名字里带cmd的指令都是可以记录到命令缓冲中的命令,对于C++风格可能没有这样的提示。

        在Vulkan中,既可以预先记录好命令缓冲再提交,也可以每次提交前都记录一次,当然后者更灵活但是开销更大。

        对于提交Submit而言,一定要事先规划好思路,能进“批处理”操作的尽量进行,因为每次提交命令的成本都是相对大的。就像DrawCall一样,能尽量少而精就是最好的,或者说DrawCall实际上就是一次渲染命令的提交。

Draw Command 绘制命令

        在Vulkan中有很多种命令类型,但是对于图形渲染我们最常用的命令类型就是绘制命令。

        绘制命令仅适用于图形管线,非常合理。而且它的执行过程取决于绑定的管线或着色器对象(拓展提供的那个),所以在记录绘制命令前,一定要确保管线或着色器对象已经绑定好了。

        绘制有两种实现模式:Mesh Shading网格着色通过网格着色器组装图元;而另一种经典的Primitive Shading图元着色通过设备处理并组装成图元。

        图元的具体组装主要通过在创建图形管线时的输入装配状态InputAssemblyState指定。比较重要的还包括了设定图元的拓扑结构Topology和专用于顶点绘制的图元重启功能。具体的拓扑方式和图元顺序的详细信息尽量还是自己在明细种看。

        对于图元着色而言,在组装完图元后,将进入管线的顶点着色阶段。如果绘图包含多个实例,这个实例不是指Vulkan的实例,而是指“实例渲染Instance Rendering”的实例,简单来讲就是一种简化复杂对象的绘制方式。则该组图元将多次发送到顶点着色阶段,每个实例发送一次。

        有时候顶点着色可能会发生在被舍弃的不完整图元上,这些取决的设备实际情况。如果它确实发生了,那么可能会有点副作用(简直是废话)。

        图元着色模式的绘制命令大致分为两大类:一类无索引绘制Non-indexed,顾名思义就是没有顶点索引的绘制,其实是有顶点索引的,只不过这个顶点索引是设备按照顶点顺序自动生成的,然后就直接传给顶点着色器;而另一类有索引绘制Indexed,就是需要绑定我们自己创建的索引缓冲区Index Buffer(其实就是个Vulkan的Buffer),而非让设备自己生成。

        所以一般情况下,都是用第二种方式的绘制命令,因为自己创建的用着才放心(误),而且可以自己做索引优化。这两种方式也很好区分啊,看到有“Indexed”的就是第二种了,没有的一律按第一种处理。

        然后注意使用第二种命令要先使用vkCmdBindIndexBuffer绑定索引缓冲,不然你用什么当索引呢。

渲染通道实例RenderPass Instance与绘制命令

        对于绘制命令而言,它们必须被记录在一个渲染通道实例内,一个渲染通道实例定义了用于渲染的图像资源。注意“渲染通道”不等于“渲染通道实例”,绘制本质上需要的是一个“实例”。

        事实上,并不需要创建一个渲染通道对象也能开启渲染通道实例,所以这里还是将“渲染通道”视作“渲染流程”可能更好理解,一个流程的实例就象征着一次渲染流程。

        我们可以用vkCmdBeginRendering开启一个渲染流程实例,并用vkCmdEndRendering结束,而绘制命令就可以记录在两者之间,相对于使用渲染流程对象,这种方式比较简便,只用在开启时设置好对应的结构体参数即可。

        我们之间章节讲到的“渲染流程”其实只是指“渲染流程对象RenderPass Object”,它其实是以一种“预定义”的方式处理渲染流程实例,就是一张蓝图,我们设计好这张蓝图后在什么地方都可以用(还是要注意适配性的),而不用每次录制命令时手动设置。这样的好处是可以为渲染流程添加更多规范和增益,比如SubpassDependency之类的;坏处就是创建开销比较大(其实也不会有多大,因为一般只创建一次)。

        当我们使用渲染流程对象进行命令记录时,是以Subpas为单位记录的,也就是说当使用vkCmdBeginRenderPass开启一个渲染流程的实例后,就默认开启了第一个Subpass的记录,倘若我们记录命令完后直接用vkCmdEndRenderPass结束,那么那些命令其实是记录于第一个Subpass的。如果想要接着记录下一个Subpass而非结束渲染流程实例,就应使用vkCmdNextSubpass移动到下一个Subpass记录,同时意味着上一个Subpass记录结束,每次使用它相当于递增一次RenderPass对象中子通道对象数组的索引。

        每次Subpass索引的移动(包括开头)时一般都会需要指定一个Content参数,用来描述这个Subpass会怎么样执行,例如:

VK_SUBPASS_CONTENTS_INLINE :Subpass将会被记录在主命令缓冲中,而且Subpass中不得执行次命令缓冲。

VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS :Subpass将会被记录在次命令缓冲中,而且只能记录执行次命令的命令vkCmdExecuteCommands直到该Subpass记录结束(移动到下一个或者RenderPass实例结束)。

        从上面也不难看出,其实Vulkan中的命令记录顺序还是非常讲究的,所以一般都同时放在一起记录。而且每个命令似乎都有自己“域”,在自己的域内有一些规定,还是非常玄乎的,不过一般可能可以根据常识判断。

        同时也说明了一个RenderPass对象其实是一整套“流程”的封装,包括了很多“子流程”和它们所用到的附件,我们要开启渲染操作就是要开启一个渲染“实例”,而RenderPass对象仅仅是其中一种方式。

显示

交换链呈现Present

        渲染和呈现没有必然联系,两者也没有必要按固定顺序执行。但是一般情况下,都习惯将渲染命令的提交与呈现的执行放在一起,两者通过同步图元进行同步,以先渲染然后呈现的顺序执行。

        一般而言,我们需要先从交换链中获取可呈现图像vkAcquireNextImageKHR的索引,然后再通过vkQueuePresentKHR呈现它。Vulkan中呈现图像的顺序与获取图像索引的顺序没有必然关系,我们可以随意呈现不同的图像索引。上面两个函数会返回一些Result,这些Result对程序设计很有帮助,比如VK_SUCCESS表示成功,或者VK_SUBOPTIMAL_KHR表示交换链“欠优化”,即可能不再适用于当前Surface,但还是能用。还有VK_ERROR_OUT_OF_DATE_KHR指示交换链已经“过期了”,需要重新创建等等。

        在交换链一章中有提到过获取图像时需要用到同步图元来确保呈现引擎以及读取完图像了,这些同步图元作为vkAcquireNextImageKHR的参数出现,同时还有一个Timeout参数,用来指定获取操作的等待时间(可能有的设备比较慢,读取图像会慢些),当Timeout为0时,不会等待;为UINT64_MAX时,将视为无限Infinite等待,一直阻塞到获取或有错误结果。获取成功后vkAcquireNextImageKHR会返回一个索引,对应于用vkGetSwapchainImagesKHR返回的数组中的元素,也就是交换链图像中的索引。

        值得注意,当同步图元出现在除了操作自身的函数之外的函数中时,往往都是作为被激活对象,例如vkAcquireNextImageKHR成功获取图像后就会激活给定的两个同步图元(也可以不给定,而给个空指针),而且在这种情况下同步图元要保证为“未激活状态Unsignaled State”。

        还有注意上面说的函数都是属于swapchain拓展的一部分,所以后面一般都带有KHR这种后缀,在实际开发中要注意是否启用了对应的拓展。

        vkQueuePresentKHR相对于获取来说就没什么好讲的了,很多返回值和参数都是大同小异的,最值得注意的可能是用来Present的图像的布局一定要转换为VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,如果是共享呈现图像的话就是VK_IMAGE_LAYOUT_SHARED_PRESENT_KHR。

        还有很多相关细节最好去明细看,包括很多不一样的实现方式,比如什么vkAcquireNextImage123456789KHR之类的,一时间是不太可能看得完的咧。

Synchronization Primitive 同步图元

        因为Vulkan“爽了驱动,苦了程序员”的原则,Vulkan中的同步控制非常重要和精细,除了在一些小小命令间的隐式同步之外,剩下的只能靠开发人员自己手动控制同步流了。

        为了了解同步机制的核心原理,我们先要简单了解一下依赖Dependency:

Dependency 依赖

        在之间的RenderPass一章,我们遇见了SubpassDependency,也就是子通道依赖,这个东西算是渲染通道同步机制的核心,对渲染操作很有帮助。

        那么依赖到底是什么呢?我们已经了解了Vulkan种的操作Operation是什么,即“任意数量的工作”。而依赖作用于由某个同步命令的两组同步范围synchronization scope,定义的两组操作之间,即依赖的作用域是两组由同步范围定义的操作之间。且依赖由同步命令显式引入。

        同步范围定义了同步命令能够与哪些其他操作创建执行依赖关系。不在同步命令同步范围内的任何类型的操作都不会包含在生成的依赖关系中。例如,对于许多同步命令,同步范围可以仅限于在特定流水线阶段执行的操作,这允许从依赖关系中排除其他流水线阶段。根据特定的命令,还有其他可能的范围选项。

        所谓的执行依赖Execution Dependency是一种保证,保证了对于两组操作,第一组必须发生在第二组之前。如果一个操作发生在另一个操作之前,那么第一个操作必须在第二个操作启动之前完成。

        还有一个概念,执行依赖链Execution Dependency chain是一系列执行依赖关系,简单来讲就是,通过前后两个依赖之间存在交集(或者相等)从而生成链式结构的一堆依赖,目前不重要。

        仅凭执行依赖一种,不足以保证从一组操作中写入的值可以从另一组操作读取,所以需要内存依赖Memory Dependency。

        所谓的内存依赖,其实就是包含了可用性Availability和可见性Visibility操作的执行依赖,它保证了两组操作之间的内存存取和执行顺序。对的它本质上还是一种执行依赖,不过比较“全面”。

        关于什么是可用性和可见性操作,这里按下不表,详细请参考明细“内存模型”那一章(懒)。        

        执行和内存依赖用于解决数据危害,即确保读写操作按照明确的顺序进行。“先写再读Write-after-read”危害可以通过执行依赖来解决,但“写后读”和“写后写”危害需要在它们之间包含适当的内存依赖性。如果应用程序不包含解决这些危害的依赖,则内存访问的结果和执行顺序是未定义的。

        依赖是Vulkan同步的重要机制,或多或少蕴含在同步图元之中。因为同步图元使用太多样,细节比较细,所以详细用法最好自己去看明细,这里只记录概念。

        目前有5种同步机制Mechanism,下面讲逐个介绍:

Fence 栅栏

        同步图元之一。栅栏主要用于主机于设备之间的同步,可将依赖从队列注入到主机端。它有两种状态——触发Sied和没触发Unsignaled。栅栏可以作为队列提交命令Submit Commnad执行的一部分发出。使用vkResetFences可以在主机端上重置栅栏的状态为Unsignaled。在主机端可以使用vkWaitForFences命令等待围栏,也可以使用vkGetFenceStatus查询当前状态。

        一个经典的例子就是每帧的DrawCall等待上一帧的结束,因为DrawCall是CPU进行的,而渲染是设备进行的,所以Fence同步了两者之间的执行。

Semaphore 信号量

        同步图元之一。信号量可用于在队列操作之间或队列操作与主机之间插入依赖关系。二进制信号量Binary Semaphore有两种状态——触发和没触发。时间线信号量Timeline Semaphore具有严格递增的64位无符号整数有效载荷,并相对于特定参考值发出信号。信号量可以在队列执行命令完成后触发,队列操作也可以在开始执行之前等待信号量触发。时间线信号量还可以通过vkSignalSaphore命令从主机端触发,并通过vkWaitSemaphores命令从主机端等待。

        比如在“从交换链获取可用图像”,“提交渲染命令”和“提交呈现命令”之间用两个信号量规范它们的执行:先拿到图像,然后才能提交渲染命令对图像进行渲染,最后呈现渲染结果,这些看似很有逻辑的事在Vulkan种却需要显式实现。

Event 事件

        同步图元之一。可用于在提交到同一队列的多个命令之间或主机和队列之间插入颗粒度很小的依赖关系。事件不得用于在提交到不同队列的命令之间插入依赖关系(一个队列内)。事件有两种状态——触发和没触发。应用程序可以在主机或设备上触发vkSetEvent或重置事件状态vkResetEvent。可以使设备在执行进一步操作之前(进一步执行命令前)等待事件触发(vkCmdWaitEvents和vkCmdSetEvent)。不存在等待事件在主机上触发的命令(没有Fence那种功能),但可以在主机端查询事件的当前状态。

        颗粒度比较小的一种同步图元。同时也能看出Fence,Semaphore,Event三者的粒度依次减小,Fence的颗粒度为“主机”,Semaphore为“队列”,Event为“命令”。

Pipeline Barrier 管线关卡(管线屏障)

        管道关卡也在多个命令之间提供同步控制,但只用执行单次,而不用单独的触发和等待操作。管线关卡可用于控制单个队列内的资源访问vkCmdPipelineBarrier。

        当vkCmdPipelineBarrier被提交到一个队列中时,会在它提交前的命令和提交后的命令之间注入内存依赖,就像一个“管卡”或“屏障”一样截断两边的命令。

        借助Barrier可以发挥许多有趣的功能,比如官方教程中的借助内存管卡Memory Barrier,来实现图像布局的转换,又或者实现队列“所有权”的转移。内存关卡用于显式控制对缓冲区Buffer和图像子资源范围SubresourceRange的访问。内存关卡用于在队列族之间转移所有权、更改图像布局以及定义可用性和可见性操作。它们明确定义了访问类型以及缓冲区和图像子资源范围,包含在由包含它们的同步命令创建的内存依赖关系的访问范围中(没错原文就是如此拗口)。

RenderPass Object 渲染通道对象

        我们的老熟人。渲染通道对象为渲染任务提供了一个同步框架,许多需要应用程序使用其他同步图元的情况可以更有效地表示为渲染过程的一部分。渲染过程对象可用于控制单个队列内的资源访问。

        这也是选用渲染通道对象而非直接记录渲染命令的好处之一。

其他

        下面是想写什么写什么环节。

开发语言的选择

        目前我所知的有C,C++,Rust,C#几种语言支持Vulkan开发,后面两种可能是被封装过后的VulkanAPI。C/C++可以接触原生API,所以我一般用C/C++开发。

        Vulkan是C99的API,所以自然用C写Vulkan会很舒服,但是我用过C++之后就觉得有很多情况下用C++很舒服,比如:

1.一些C风格的API函数需要二次调用才能实现全部功能,比如名字里有Enumerate的那些,只要关注它们的用法就会发现一些非常蠢的共性,先要使用一次获取数量,再用一次接收数据。而使用C++就可以直接返回给你一个vector,非常方便。

2.得力于C++是一门面向对象语言,所以它的构造函数可以方便我们构造对象,最明显的应该是C风格每次创建对象时都要指定一下结构体类型VK_STRUCTURE_TYPE_XXX,但是C++的已经封装好了每个结构体的类型,我们就不用走这多余的一步。

3.C++还有标准库的支持(好吧C也有,但是不够现代?),而且就算再不济,也能在C++里面写C哈哈。

        所以我个人还是倾向于用C++开发。

        至于Rust,和C#。Rust我完全不懂,安全版的C++应该跟C++也差不多,但是C#貌似需要经过一层.NET的封装,然后可以用C#风格的API,比较麻烦,所以还是用偏底层的开发语言可能好点。

API前后缀

        本来不想提的,但是怕自己以后忘记。VulkanAPI的命名很奇葩,或者应该说很有意思?

        当你看到API里有什么KHR,MS,什么ABCDEFG那些熟悉的公司,品牌缩写,那么大部分应该都是Vulkan拓展中的API,或者干脆就是直接带个“EXT”,Extension后缀。

        当看到API里面有阿拉伯数字,什么123456789作为后缀的,那么大部分都是新版本的同名API,并且有相应的升级(不一定是升级,新版的API一般比较麻烦)。比如vkQueueSubmit和vkQueueSubmit2,以及vkQueueSubmit2KHR之类的。至于要不要用这些新API因人而异吧,感觉它们的作用都是相同的,只是形式不同。

结语

        我无法保证会不会继续更新这篇文章,我觉得其实已经足够了。

        剩下的与Vulkan相关的内容肯定会以单篇的形式记录,因为写这么多实在很累。

        一篇文章绝对不足以囊括Vulkan的所有内容,甚至可能还连入门都算不上,所以再接来历吧。     

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Moweiii

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值