关于 Vulkan 的学习,网上有一篇很火的文章:《Vulkan in 30 minutes》。
这篇文章是英文的,原文链接如下:
https://renderdoc.org/vulkan-in-30-minutes.html
恰好在知乎上有位大佬将它翻译成了中文,知乎作者就是:fangcun ,链接如下:
https://zhuanlan.zhihu.com/p/59695433
通过如下的链接可以下载文章对应的 PDF 文件和代码演示:
http://web.engr.oregonstate.edu/~mjb/vulkan/VulkanIn30Minutes.pdf
这里转载一波大佬的的翻译,通俗易懂,值得收藏。
对于 Vulkan 的学习,如果有兴趣也可以看看我写的 Vulkan 版本的 GPUImage。
用 Vulkan 渲染写一个 Android GPUImage
以下就是翻译原文:
本文主要面向具有一定图形API(D3D11或OpenGL)使用经验的读者,此外,我们还希望读者对多线程,暂存资源,同步等知识有所了解。我们将要介绍的Vulkan大量使用了这些知识。
本文仅仅是为了让读者能够对Vulkan的工作方式有一个大致的了解,所以忽略了很多细节。
读者在阅读完本文之后,可以参考Vulkan的官方规范或其它Vulkan教程了解我们所忽略的细节部分。
概述
在本文的结尾,我们给出了使用Vulkan来绘制一个三角形的伪代码,读者可以参考它来理解本文。
下面是一些有关Vulkan的小知识:
- Vulkan是一个标准的C API。
- Vulkan API对类型的使用非常重度。
- Vulkan API大量使用结构体作为函数调用的参数。
- Vulkan API中用于创建和清除对象的函数带有一个VkAllocationCallbacks结构体指针参数,允许我们使用它来自定义CPU端的内存分配器。如果不想自定义这个CPU端的内存分配器,可以将其设置为NULL来使用Vulkan自带的CPU端的内存分配器。
需要读者注意的是,本文没有讨论任何有关错误处理的内容,如果真正地使用Vulkan编写程序,需要根据Vulkan具体实现的限制,进行相关处理。
第一步
我们通过创建一个Vulkan实例(VkInstance)来完成Vulkan的初始化。
每个Vulkan实例是完全独立的,一个Vulkan实例对另一个Vulkan实例不存在任何影响。创建Vulkan实例时,我们指定了需要使用的层(layer)和扩展。
如果不知道有哪些层(layer)或扩展可以使用,可以使用查询函数来枚举可用的层(layer)和扩展。
有了VkInstance后,我们可以检测可用的GPU设备(Vulkan不光可以用于GPU,这里为了方便,统称为GPU设备)。
每个GPU设备有一个VkPhysicalDevice类型的句柄。通过GPU设备的句柄,我们可以查询GPU设备的名称,属性,功能等等。可以查询的详细信息可以参考vkGetPhysicalDeviceProperties和vkGetPhysicalDeviceFeatures函数的官方规范。使用GPU设备句柄VkPhysicalDevice,我们可以创建一个VkDevice。一个VkDevice代表了一个逻辑链接,表明我们在这一GPU上使用Vulkan。可以认为VkDevice等价于OpenGL中的context或D3D11中的device。
一个VkInstance可以有多个VkPhysicalDevice。一个VkPhysicalDevice也可以有多个VkDevice。对于Vulkan 1.0来说,还不支持多GPU交互,但未来版本的Vulkan将会允许多个GPU进行交互。
Vulkan要求我们显式地设置一切参数,所以从创建VkInstance到选择使用地VkPhysicalDevice,再到创建VkDevice需要填写的参数相当多。抛去参数填写,大致过程看起来是这样的:vkCreateInstance() → vkEnumeratePhysicalDevices() → vkCreateDevice()。对于我们这样一个绘制三角形的简单程序,可以先直接选择第一个物理设备,等到后面需要错误信息、启用可选的设备特性时再回来根据需要选择物理设备。
图像和缓冲
现在我们已经创建了一个VkDevice,可以开始创建其它所需的资源。比如VkImage和VkBuffer。
Vulkan要求我们在VkImage创建时指定它的用途。比如它是用作颜色附着,还是用于在着色器中进行采样、还是用于图像加载/存储等等。
此外,我们还需要指定VkImage在内存中的存储方式:LINEAR还是OPTIMAL。OPTIMAL存储方式下,图像数据在内存中的组织方式对我们完全不透明。
LINEAR存储方式下,图像数据会按照我们可以预期的形式存放。图像的存储方式对图像数据是否可以被直接读取和写入,以及可以使用的图像类型有一定影响。不同存储方式可以支持的图像类型不同。
缓冲和图像类似,需要我们在创建时指定缓冲的用途,以及大小。
我们并不能直接访问图像数据,需要通过VkImageView来访问图像数据。VkImageView描述了需要访问的图像数据范围,以及将图像数据作为何种格式进行访问。
缓冲只是一块内存,可以被直接使用。但如果需要在着色器中直接访问缓冲中的数据,则需要通过VkBufferView进行。
分配GPU内存
缓冲和图像在创建后并没有实际为它们分配内存。
我们需要自己为它们分配内存。调用vkGetPhysicalDeviceMemoryProperties函数可以获取可以用于分配的内存信息。这些信息包括可以用于内存分配的一个或多个堆的信息、堆的大小以及可以分配的内存类型。每种内存类型对应一个可以分配这一类型内存的堆。通常,对于带有独立显卡的PC设备,会存在两个可以用于内存分配的堆:一个可以分配系统内存,一个可以分配GPU内存。所有不同类型的内存都由这两个堆之一进行分配。
不同类型的内存具有不同的属性。一些类型的内存可以被CPU访问,一些不可以。一些类型可以在GPU和CPU间保持数据一致性、一些类型可以被CPU缓存使用等等。可以通过查询物理设备获取这些信息。我们可以根据需要使用不同的内存类型,比如对于暂存资源,我们需要使用可以被CPU访问的内存类型。对于用于渲染的图像,我们通常为其分配GPU内存。此外,内存分配还存在一个限制,我们会在下一节讨论。
内存分配需要调用vkAllocateMemory函数。调用它需要使用VkDevice和一个描述内存分配信息的结构体作为参数。我们使用这一结构体指定需要分配的内存类型、内存大小以及分配它的堆。vkAllocateMemory函数调用后会返回一个VkDeviceMemory句柄。
对于CPU可以访问的内存类型,可以使用vkMapMemory/vkUnmapMemory函数对其进行映射。这一映射是持久化的,只要进行了正确的同步,可以在GPU使用这一内存区域时访问它。
vkMapMemory函数返回的指针可以被保存使用,只要进行了正确的同步,甚至可以在GPU使用这一内存区域时对其进行写入操作,同步规则可以保证CPU不会写入数据到GPU正在使用的那部分内存。
显式刷新的非一致性内存调试起来要比一致性内存方便得多。显式刷新为我们提供了非常好用的断点位置。
RenderDoc会对一个使用显式刷新的内存区域关闭代价极高的内存一致性追踪功能。在调试时,我们可以对一致性内存进行显式刷新,来获得更好的调试体验。
绑定内存
VkBuffer和VkImage的内存需求可以通过调用vkGetBufferMemoryRequirements函数和vkGetImageMemoryRequirements函数获取。
获取的内存需求满足了多个细化级别间的对齐、隐含的元数据和其它需要占用内存的信息的需求。此外,内存需求还包含了一个掩码,表明满足此内存需求的内存类型。对于使用OPTIMAL存储方式的用于颜色附着色图像,只有DEVICE_LOCAL类型的内存可以使用,不能对它绑定HOST_VISIBLE类型的内存。
对于同一类的图像或缓冲,它们需要的内存类型是一样的,只需要对需要的内存大小和对齐方式进行检查,然后分配内存即可。
我们可以一次分配一大块内存,然后将这一大块内存通过使用不同的偏移值分配给多个图像或缓冲使用。分配的偏移值需要满足图像或缓冲的对齐需求。通常,实践中由于内存分配的总次数有一定限制,我们总是这样做来减少内存分配次数。
同一个VkDeviceMemory中存放的VkImage和VkBuffer使用的内存之间还需要满足一个最小间隔bufferImageGranularity。读者可以阅读Vulkan规范,获取有关它的更多信息。这一要求和性能表现有关。
绑定图像或缓冲的内存可以通过调用vkBindImageMemory函数或vkBindBufferMemory函数进行。我们需要在使用缓冲或图像前对它们绑定内存,并且绑定是不可更改的。
指令缓冲和提交指令
指令需要先被记录到指令缓冲中,然后提交给队列执行。
VkCommandBuffer需要使用VkCommandPool来分配。我们可以为每个线程使用一个独立的VkCommandPool来避免进行同步,不同VkCommandPool使用自己的内存资源分配VkCommandBuffer。
开始记录VkCommandBuffer后,调用的GPU指令,会被写入VkCommandBuffer。等待提交给队列执行。
指令缓冲完成指令记录后,会被提交给VkQueue。可以认为VkQueue是一个包含了GPU待执行工作的队列。通过VkPhysicalDevice,我们可以获取物理设备所支持的具有不同功能的队列族。比如图形队列族和计算队列族。在创建VkDevice时,可以从这些队列族请求一定数量的队列,在VkDevice创建后通过调用vkGetDeviceQueue获取请求的队列句柄。
使用多个队列需要进行同步操作,这里,为了简单起见,我们只使用一个可以满足所有需要的队列。需要注意有些Vulkan实现可能会要求为交换链呈现使用独立的队列,虽然大多数情况下应该不需要,但还是提醒读者注意,更多信息可以参考Vulkan的官方规范。
可以通过调用vkQueueSubmit函数一次提交多个指令缓冲到一个队列中,提交到队列的指令缓冲会按顺序被执行。Vulkan对于指令执行顺序有非常具体的要求,读者需要特别注意Vulkan官方规范中有关这一部分的说明,保证进行了正确的同步操作。
着色器和管线状态对象
下面介绍Vulkan的着色器数据绑定模型:
- 每个着色器阶段有自己独立的命名空间,片段着色器的0号纹理绑定和顶点着色器的0号纹理绑定没有任何关系。
- 不同类型的资源位于不同的命名空间,0号uniform缓冲绑定和0号纹理绑定没有任何关系。
- 资源被独立地进行绑定和解绑定。
Vulkan的基本绑定单位是描述符。描述符是一个不透明的绑定表示。它可以表示一个图像、一个采样器或一个uniform缓冲等等。它甚至可以表示数组,比如一个图像数组。
描述符的设置并不是独立进行的,它被带有特定VkDescriptorSetLayout的VkDescriptorSet进行统一设置。VkDescriptorSetLayout描述了VkDescriptorSet中每个绑定的类型。
读者可以这样理解:把VkDescriptorSetLayout看作是一个结构体类型,它描述了使用的成员变量的变量类型。VkDescriptorSet是VkDescriptorSetLayout结构体类型的一个实例,它被用于具体的数据绑定。
我们传递一个包含了类型、数组大小和绑定的列表给Vulkan来创建VkDescriptorSetLayout。然后使用它从VkDescriptorPool中分配VkDescriptorSet。VkDescriptorPool和VkCommandPool类似,我们可以为每个线程创建独立的VkDescriptorPool来避免进行同步操作。
代码语言:javascript复制VkDescriptorSetLayoutBinding bindings[] = {
// binding 0 is a UBO, array size 1, visible to all stages
{ 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, VK_SHADER_STAGE_ALL_GRAPHICS, NULL },
// binding 1 is a sampler, array size 1, visible to all stages
{ 1, VK_DESCRIPTOR_TYPE_SAMPLER, 1, VK_SHADER_STAGE_ALL_GRAPHICS, NULL },
// binding 5 is an image, array size 10, visible only to fragment shader
{ 5, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 10, VK_SHADER_STAGE_FRAGMENT_BIT, NULL },
};
有了描述符集后,我们就可以通过绑定来更新数据,以及在不同描述符集间复制数据。
在创建管线时,可以对一个VkPipelineLayout指定多个需要使用的VkDescriptorSetLayouts。进行数据绑定时,只能使用匹配的VkDescriptorSet。不同的描述符集可以按照不同的频率更新数据,可以按照更新频率来划分描述符集。
继续考虑之前的类比,我们可以将管线看作一个函数,它具有多个结构体参数。创建管线时,它的每个参数的类型被确定(VkDescriptorSetLayout),进行数据绑定时我们将实例(VkDescriptorSet)传递给管线。
着色器中的绑定设置相对来说就很简单了,只需要指定资源来自哪个描述符集和描述符集中的哪一绑定即可。
代码语言:javascript复制#version 430
layout(set = 0, binding = 0) uniform MyUniformBufferType {
// ...
} MyUniformBufferInstance;
// note in the C sample above, this is just a sampler ‐ not a combined image sampler
// as is typical in GL.
layout(set = 0, binding = 1) sampler MySampler;
layout(set = 0, binding = 5) uniform image2D MyImages[10];
同步
同步大概是Vulkan最难处理的部分,甚至有时忘记进行某一同步操作,程序运行后看起来也跟完全没有问题一样。
在两个不同的线程上使用同一个VkQueue需要进行同步,否则会引起程序崩溃。
对于在多个线程使用某一对象是否需要同步可以参考Vulkan的官方规范。一般来说,使用VkDevice作为参数的创建函数不需要进行同步,但像记录指令和提交指令缓冲这类操作需要进行同步。
Vulkan没有对使用的资源进行引用计数,我们需要自己保证在不再使用资源时释放它。
Vulkan提供了VkEvent、VkSemaphore和VkFence用于CPU-GPU和GPU-GPU同步。Vulkan的官方规范对于执行顺序的明确规定很少,进行同步操作需要格外小心。
管线屏障是一个新的概念。它被用来保证GPU端操作的执行顺序。比如可以保证在开始一个操作前某个操作已经完成,或在某一资源上的某一类型操作已经完成可以开始另一类型操作。
有三种内存屏障类型:VkMemoryBarrier、VkBufferMemoryBarrier和VkImageMemoryBarrier。VkMemoryBarrier可以进行所有内存资源的同步操作,其它两种类型的内存屏障用于同步特定的内存资源。
我们通过内存屏障指定需要进行的同步操作。比如设置内存屏障的srcAccessMask = ACCESS_COLOR_ATTACHMENT_WRITE和dstAccessMask = ACCESS_SHADER_READ后,着色器读取数据前所有颜色写入操作必须完成。如果不进行这样的设置,我们可能会读取到过期的数据。
图像布局
图像资源存在一个叫做图像布局的状态。VkImageMemoryBarrier可以对图像资源的图像布局进行变换。对图像进行的操作需要图像满足一定的布局。存在一个通用的可以进行任意操作的图像布局,但使用它的性能表现不佳。对于需要在图像上进行的特定操作使用特定的图像布局性能表现更好。比如用作颜色附着、深度附着和需要在着色器中进行采样的图像都有一个特别适合的图像布局。
图像初始时处于UNDEFINED或PREINITIALIZED状态。PREINITIALIZED状态用于填充有数据的图像。对于处于UNDEFINED状态 的图像,将它变换到GENERAL状态时,会丢失之前的图像数据,但处于PREINITIALIZED状态的图像变换到GENERAL状态时,不会丢失之前的图像数据。处于这两个初始图像布局状态的图像都不能直接被GPU使用,需要进行至少一次图像布局变换才可以被GPU使用。
通常我们需要准确指定图像变换之前的布局和变换之后的布局。但使用UNDEFINED作为之前的图像布局也是常见的,它表明我们不需要之前的图像数据,只需要将图像变换为需要的新布局。
渲染流程
Vulkan使用VkRenderpass来显式地定义渲染操作流程。对于基于tile的渲染,VkRenderpass可以极大的提高内存利用,减少频繁的数据传输。
一个VkRenderPass包含了一系列的子流程。对于我们这个简单的程序,它只包含了一个子流程。子流程指定了帧缓冲的颜色附着、深度模板附着。如果有多个子流程可能会为它们指定不同的附着设置,一个子流程将其用作数据输入,另一个子流程可能将其用作数据输出。
绘制指令只可以在VkRenderPass中执行,复制数据和清除数据的指令只可以在VkRenderPass外执行。状态绑定的指令的执行可以在VkRenderPass外也可以VkRenderPass内。
子流程不会继承之前的状态。所以每次开始一个VkRenderPass或进入一个新的子流程,我们必须重新绑定所有状态。子流程还指定了读写附着时执行的附加操作。比如使用值1.0来清除深度附着的内容,接下来颜色附着会被新数据完全覆盖掉,不进行颜色附着的清除。这些信息为驱动程序优化提供了很大空间。
最后需要考虑的是多个不同对象之间的匹配问题。创建VkRenderPass(以及它的所有子流程)时我们指定了使用的所有附着以及附着的格式。之后,创建VkFramebuffer时,指定使用我们创建的VkRenderPass。这样指定后,并不意味着之后必须使用这一个VkRenderPass,只要和指定的这一个VkRenderPass相兼容(具有相同的附着和附着格式)的VkRenderPass都可以在之后被VkFramebuffer使用。创建VkPipeline时也需要指定使用的VkRenderPass和子流程,同样之后只要与指定的VkRenderPass和子流程相兼容的对象都可以供VkPipeline使用。
如果渲染流程带有多个子流程,就需要定义子流程之间的依赖和内存屏障,以及它们使用的附着及其用途。更多信息可以参考Vulkan的官方规范。
后台缓冲和呈现
Vulkan通过扩展来和原生窗口系统进行交互。我们需要在创建VkInstance和VkDevice时显式地请求这一扩展。
首先,我们使用原生窗口系统的信息创建一个VkSurfaceKHR。
然后,为它创建一个VkSwapchainKHR。这需要我们查询VkSurfaceKHR支持的图像数据格式,以及我们可以在交换链中使用的后台缓冲个数。
我们可以调用vkGetSwapchainImagesKHR函数从VkSwapchainKHR获取VkImage图像句柄。交换链中的图像由Vulkan自动创建。我们只需要创建对应的图像视图就可以访问它们。
当需要对交换链图像进行渲染操作时,可以调用vkAcquireNextImageKHR函数,它会返回一个交换链图像的索引,我们使用这一索引使用对应图像视图来对图像进行渲染。最后调用vkQueuePresentKHR函数将渲染的图像呈现到屏幕上。
有大量设置可以用于优化交换链的性能表现,但对于我们这样一个简单的程序,并非必要。
总结
本文跳过了大量繁琐的细节,也没有对稀疏资源,主要和次要指令缓冲等一些很酷的特性进行介绍。有关这些内容读者可以参考Vulkan的官方规范。