Vulkan的初始化过程始于创建一个Vulkan实例(VkInstance)。每个Vulkan实例是相互独立的,彼此之间没有影响。在创建实例时,需要指定所需的层(layer)和扩展(extension)。若不确定可用的层或扩展,可以通过查询函数枚举它们。
在获得VkInstance后,可以检测可用的GPU设备。每个GPU设备对应一个VkPhysicalDevice类型的句柄。通过该句柄,可查询GPU设备的名称、属性和功能等详细信息,具体可参考vkGetPhysicalDeviceProperties和vkGetPhysicalDeviceFeatures函数的官方文档。使用VkPhysicalDevice句柄,可以创建一个逻辑设备VkDevice,该设备表示在特定GPU上的Vulkan使用。VkDevice可以视为OpenGL中的上下文或Direct3D 11中的设备。
一个VkInstance可以包含多个VkPhysicalDevice,而一个VkPhysicalDevice可以有多个VkDevice。值得注意的是,Vulkan 1.0并不支持多GPU交互,但未来版本的Vulkan将可能支持此功能。
Vulkan要求显式设置所有参数,因此从创建VkInstance到选择VkPhysicalDevice,再到创建VkDevice,所需填写的参数较多。简化的流程为:vkCreateInstance() → vkEnumeratePhysicalDevices() → vkCreateDevice()。在简单的绘制应用中,可以直接选择第一个物理设备,待后续需要错误信息或启用可选设备特性时再进行调整。
创建VkDevice后,可以开始创建其他资源,例如VkImage和VkBuffer。Vulkan要求在创建VkImage时明确其用途,例如用于颜色附着、在着色器中进行采样或图像加载/存储等。此外,还需指定VkImage在内存中的存储方式,包括线性(LINEAR)和最优(OPTIMAL)。OPTIMAL存储方式下,图像数据在内存中的组织对用户是透明的,而在LINEAR存储方式下,图像数据以可预期的方式存放。存储方式对图像数据的直接读取和写入能力以及可用的图像类型有显著影响。
VkBuffer的创建与VkImage类似,同样需要指定用途和大小。访问图像数据需要通过VkImageView,该对象描述了访问图像数据的范围和格式。VkBuffer则是一块内存区域,若需在着色器中直接访问,则需使用VkBufferView。
在创建后,图像和缓冲并未实际分配内存。需要手动为它们分配内存。可通过调用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获取。获取的内存需求满足多种对齐要求、隐含的元数据和其他信息。此外,内存需求还包含一个掩码,指示满足该需求的内存类型。例如,使用最优存储方式的颜色附着图像仅支持DEVICE_LOCAL类型的内存,不能绑定HOST_VISIBLE类型的内存。
同类图像或缓冲需要的内存类型相同,因此只需检查所需的内存大小和对齐方式后进行分配。可以一次分配一大块内存,并通过不同的偏移值将其分配给多个图像或缓冲,以减少内存分配次数。需要注意的是,存放于同一VkDeviceMemory中的VkImage和VkBuffer之间的内存需要满足最小间隔bufferImageGranularity,该要求与性能表现相关。
绑定图像或缓冲的内存通过调用vkBindImageMemory或vkBindBufferMemory进行。在使用缓冲或图像之前,必须绑定内存,且绑定关系不可更改。
指令需先记录到指令缓冲中,然后提交给队列执行。VkCommandBuffer的分配依赖于VkCommandPool,可以为每个线程使用独立的命令池以避免同步问题。开始记录VkCommandBuffer后,调用的GPU指令将被写入该缓冲,待提交给队列执行。
记录完成后的指令缓冲需通过vkQueueSubmit提交给VkQueue。VkQueue是包含待执行GPU工作的队列。通过VkPhysicalDevice,可获取支持不同功能的队列族,例如图形队列族和计算队列族。在创建VkDevice时,从这些队列族请求一定数量的队列,创建后可通过调用vkGetDeviceQueue获取所请求的队列句柄。
使用多个队列时需要进行同步操作。为了简化起见,通常只使用一个满足所有需求的队列。需要注意的是,某些Vulkan实现可能要求为交换链呈现使用独立队列,尽管在大多数情况下并不必要。
调用vkQueueSubmit可一次提交多个指令缓冲到队列中。提交的指令缓冲将按顺序执行。Vulkan对指令执行顺序有严格要求,需特别注意官方规范中相关内容,以确保正确同步。
Vulkan的VkPipeline只能对固定功能阶段的一部分状态进行动态修改,例如视口、模板掩码和混合操作使用的常数。官方规范提供了可修改状态的完整列表,其余大量状态必须在管线创建时设置,创建后无法更改。在调用vkCreateGraphicsPipelines时,可以指定可动态修改的状态,未指定的状态将保持为图形管线创建信息结构体中指定的值。
在创建管线时,可以指定一个可选的VkPipelineCache用于缓存管线数据。这使得可以预先创建大量管线并将其缓存至VkPipelineCache中,通过调用vkGetPipelineCacheData以二进制形式读取并存储在磁盘上,使用时直接从磁盘加载所需的管线缓存,以加速管线创建。需特别注意管线缓存数据的版本控制,以避免加载过期的缓存数据。
Vulkan使用SPIR-V格式的着色器代码。使用SPIR-V着色器代码创建的VkShaderModule可以包含多个入口点,在创建管线时需指定其中一个作为实际入口点。
下面介绍Vulkan的着色器数据绑定模型:
Vulkan的基本绑定单位是描述符。描述符是一个不透明的绑定表示,可以表示图像、采样器或uniform缓冲,甚至可以表示数组(例如图像数组)。
描述符的设置通过带有特定VkDescriptorSetLayout的VkDescriptorSet进行统一设置。VkDescriptorSetLayout描述了VkDescriptorSet中每个绑定的类型,读者可以将VkDescriptorSetLayout视为结构体类型,描述了使用的成员变量的类型。VkDescriptorSet是VkDescriptorSetLayout结构体类型的实例,用于具体的数据绑定。
通过传递包含类型、数组大小和绑定的列表给Vulkan,可以创建VkDescriptorSetLayout。接着,使用它从VkDescriptorPool中分配VkDescriptorSet。VkDescriptorPool类似于VkCommandPool,可以为每个线程创建独立的VkDescriptorPool以避免同步问题。
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。不同的描述符集可以按不同频率更新数据,基于更新频率划分描述符集。
同步是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,以确保着色器读取数据前所有颜色写入操作完成。如未进行此设置,可能会读取到过期的数据。
9. 图像布局
图像资源存在名为图像布局的状态。VkImageMemoryBarrier可用于对图像资源的布局进行变换。对图像进行的操作需满足特定布局要求。尽管存在通用的布局以进行任意操作,但使用此布局的性能表现较差。针对特定操作使用特定图像布局可获得更优性能,例如用于颜色附着、深度附着和着色器采样的图像均有特定适用的图像布局。
图像在初始时处于UNDEFINED或PREINITIALIZED状态。PREINITIALIZED状态用于填充有数据的图像,而处于UNDEFINED状态的图像在转换到GENERAL状态时会丢失之前的数据,但PREINITIALIZED状态的图像在转换到GENERAL状态时则不会丢失数据。初始状态的图像不能直接被GPU使用,需进行至少一次图像布局变换后方可使用。
通常,需要准确指定图像变换前后的布局,但使用UNDEFINED作为先前的布局也是常见做法,表示不需要保留先前图像数据。
Vulkan使用VkRenderPass显式定义渲染操作流程。对于基于tile的渲染,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获取图像句柄。交换链中的图像由Vulkan自动创建,用户仅需创建对应的图像视图以访问图像。
在需要对交换链图像进行渲染操作时,可以调用vkAcquireNextImageKHR函数,该函数返回一个交换链图像的索引,用户可使用该索引访问对应的图像视图进行渲染。最后,调用vkQueuePresentKHR函数将渲染的图像呈现至屏幕。
尽管存在大量设置可用于优化交换链性能,但在简单程序中并非必需。