Vulkan1.4 标准于2024年12月3日发布,在接近一年时间几乎没有引起任何讨论。这里简要列出1.4标准的新增内容:
串流传输:Vulkan 1.4 要求移动、跨平台应用能够串流大量数据至设备,同时保证高性能的渲染。
之前对高性能应用至关重要的可选扩展和功能现时在Vulkan 1.4中是强制性的,这确保了多平台的可信赖可用性。它们包括 push descriptors(描述符推送)、dynamic rendering local reads(动态渲染本地读取)和 scalar block layouts(标量区块布局)。
包括 VK_KHR_maintenance6 在内的维护扩展现已成为 Vulkan 1.4 核心规范的一部分。
高达8个独立渲染目标的 8K 渲染现在已保证支持,同时还有其他几项限制的增加。
标准没有引入任何新渲染特性,所做的仅是将部分扩展并入核心。可见此次 API 升级对于大部分图形项目毫无吸引力,所带来的影响也仅仅是在碎片化的移动平台强制兼容部分扩展。我将跳过此次的标准解读,转而去探索一些可能会具备潜力的扩展。
Descriptor 的经典工作流
在 Vulkan1.3 标准引入 Descriptor Buffers 扩展之前,Vulkan 中的资源绑定一直遵循 Descriptor 的经典模型(中文称之为描述符,个人并不喜欢这个词不达意的翻译)从创建更新到销毁的生命周期。而 Descriptor 的一系列概念存在的原因,是在管线/着色器被创建之前,提前明确资源绑定的一切信息。(Descriptor 精确地告诉驱动:“我将要运行的着色器,在绑定点0需要一个UBO,在绑定点1需要一个纹理采样器)这极大的减少了与驱动程序的交互次数,显著降低了CPU开销。相反的,在 OpenGL 等旧 API 时代,驱动程序只有在绘制调用(Draw Call)那一刻才能拼凑出着色器需要的所有资源,这使得提前优化变得非常困难。
事实上,Descriptor 本身并不直接储存资源,它储存的是对于已创建资源(UBO,SSBO,Sampler)的地址引用。同时它还是一块不透明的内存,由驱动程序负责维护。在此之上,又进一步划分出了 DescriptorSetLayout,DescriptorPool,DescriptorSet 等概念,对于初学者的理解产生不便。
DescriptorSetLayout:如果一个 Descriptor 是一个C++函数,那么描述符就是这个函数的参数列表。它定义类型。比如,“需要一个UBO和一个纹理”。
DescriptorSet:按照 DescriptorSetLayout 给定的“形参列表”提供实例。比如,“具体用这个camera_buffer作为UBO,用这张brick_texture.jpg作为纹理”。
DescriptorPool:用以分配 DescriptorSet 的内存池,这个概念似乎并没有什么存在的必要。
对于 Layout/Set 的分离与解耦,允许用同一个布局来创建多个不同的管线,只需绑定管线/着色器各自的VkDescriptorSet即可。
一个经典的 Descriptor 工作流示范如下:
准备阶段 (初始化时)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| VkDescriptorSetLayoutBinding uboBinding = {0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, VK_SHADER_STAGE_VERTEX_BIT, nullptr}; VkDescriptorSetLayoutBinding samplerBinding = {1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr}; std::vector<VkDescriptorSetLayoutBinding> bindings = {uboBinding, samplerBinding};
VkDescriptorSetLayoutCreateInfo layoutInfo = {VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; layoutInfo.bindingCount = bindings.size(); layoutInfo.pBindings = bindings.data(); vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout);
VkDescriptorPoolSize poolSize1 = {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 100}; VkDescriptorPoolSize poolSize2 = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 100}; std::vector<VkDescriptorPoolSize> poolSizes = {poolSize1, poolSize2};
VkDescriptorPoolCreateInfo poolInfo = {VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; poolInfo.maxSets = 100; poolInfo.poolSizeCount = poolSizes.size(); poolInfo.pPoolSizes = poolSizes.data(); vkCreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool);
|
更新阶段 (每次材质变化时)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| VkDescriptorSetAllocateInfo allocInfo = {VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; allocInfo.descriptorPool = descriptorPool; allocInfo.descriptorSetCount = 1; allocInfo.pSetLayouts = &descriptorSetLayout; vkAllocateDescriptorSets(device, &allocInfo, &descriptorSet);
VkDescriptorBufferInfo bufferInfo = {uniformBuffer, 0, sizeof(MVP)}; VkDescriptorImageInfo imageInfo = {sampler, imageView, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL};
VkWriteDescriptorSet writeUbo = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; writeUbo.dstSet = descriptorSet; writeUbo.dstBinding = 0; writeUbo.descriptorCount = 1; writeUbo.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writeUbo.pBufferInfo = &bufferInfo;
VkWriteDescriptorSet writeSampler = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; writeSampler.dstSet = descriptorSet; writeSampler.dstBinding = 1; writeSampler.descriptorCount = 1; writeSampler.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; writeSampler.pImageInfo = &imageInfo;
std::vector<VkWriteDescriptorSet> writes = {writeUbo, writeSampler}; vkUpdateDescriptorSets(device, writes.size(), writes.data(), 0, nullptr);
|
绑定阶段 (录制CommandBuffer时)
1 2 3 4 5 6 7 8 9 10 11 12 13
| vkCmdBindDescriptorSets( commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &descriptorSet, 0, nullptr );
|
描述符缓冲区 (Descriptor Buffers)
Descriptor Buffers 是 Vulkan1.3 标准引入的全新资源绑定方式。Descriptor 自此将变得内存透明,你可以自由规划管线的资源分布:比如所有管线共用一个 Descriptor Buffer,也可以为每个管线各自维护其专属的 Descriptor Buffer(有点类似于 DescriptorSet)。在新工作流中,只存在 Layout 与 Buffer 两个概念。
传统 DescriptorSet 被视为内存不透明的API对象。请求 vkAllocateDescriptorSets后,驱动程序自己管理内存,然后通过一个结构化的API调用 (vkUpdateDescriptorSets) 去更新这些对象。
- 流程:
创建池 (Pool) -> 分配集 (Set) -> 更新集 (Update) -> 绑定集 (Bind)
而 Descriptor Buffers 为普通的缓冲区(VkBuffer),就像顶点数据或Uniform数据一样。需要手动创建,自己计算好数据应该放在缓冲区的哪个位置,然后直接用 memcpy 把描述符的“地址”或“句柄”写入这个缓冲区。
- 流程:
创建缓冲区 (Buffer) -> 获取资源句柄 (Handle) -> 写入缓冲区 (Write) -> 绑定缓冲区 (Bind)
需要注意的是,Vulkan1.4 并未将 Descriptor Buffers 扩展并入核心,在创建 VkInstance 和 VkDevice 时,必须在启用列表里加入 VK_EXT_descriptor_buffer 扩展。同时,VkDescriptorSetLayout 仍被需要,它现在主要用于定义着色器期望的描述符在缓冲区中的内存布局,在创建 VkDescriptorSetLayout 时,必须添加一个新的标志 VK_DESCRIPTOR_SET_LAYOUT_CREATE_DESCRIPTOR_BUFFER_BIT_EXT。
创建 DescriptorSetLayout
1 2 3 4 5
| VkDescriptorSetLayoutCreateInfo layoutInfo{}; layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; layoutInfo.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_DESCRIPTOR_BUFFER_BIT_EXT; layoutInfo.bindingCount = 1; layoutInfo.pBindings = &samplerLayoutBinding;
|
创建描述符缓冲区(替换 VkDescriptorPool/ VkDescriptorSet)
这里注意创建 buffer 时,要特别添加 VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT 、 VK_BUFFER_USAGE_RESOURCE_DESCRIPTOR_BUFFER_BIT_EXT 和VK_BUFFER_USAGE_SAMPLER_DESCRIPTOR_BUFFER_BIT_EXT 三个标志。并映射一块 CPU 侧可见的内存,以后针对 GPU 侧的资源更新只需直接写入这块内存即可。
1 2 3 4 5
| VkDeviceSize descriptorSetLayoutSize; vkGetDescriptorSetLayoutSizeEXT(device, universalDescriptorSetLayout, &descriptorSetLayoutSize); VkDeviceSize bufferSize = descriptorSetLayoutSize; createBufferWithAddress(bufferSize, VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_RESOURCE_DESCRIPTOR_BUFFER_BIT_EXT | VK_BUFFER_USAGE_SAMPLER_DESCRIPTOR_BUFFER_BIT_EXT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, descriptorBuffer, descriptorBufferMemory); vkMapMemory(device, descriptorBufferMemory, 0, bufferSize, 0, &mappedDescriptorBuffer);
|
将资源直接写入缓冲区(替换 vkUpdateDescriptorSets)
这里我们假设,为每个管线分配一个独立的 Descriptor Buffer,省去了从大 Buffer 中通过偏移量搜索目标管线所对应的 Descriptor 位置这一繁琐步骤。注意,不同的硬件设备所对应的 SamplerDescriptorSize 可能会有所差异,这将会对 offset 的计算产生影响,需要提前查询。
1 2 3 4 5 6
| void getAndCopyDescriptor(VkDevice device, VkDescriptorGetInfoEXT& getInfo, size_t descriptorSize, void* dest) { char* descriptorData = (char*)alloca(descriptorSize); vkGetDescriptorEXT(device, &getInfo, descriptorSize, descriptorData); memcpy(dest, descriptorData, descriptorSize); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| VkDeviceSize offset_0_ubo, offset_1_samplers; vkGetDescriptorSetLayoutBindingOffsetEXT(device, MPipeline::universalDescriptorSetLayout, 0, &offset_0_ubo); vkGetDescriptorSetLayoutBindingOffsetEXT(device, MPipeline::universalDecriptorSetLayout, 1, &offset_1_samplers);
VkPhysicalDeviceDescriptorBufferPropertiesEXT descriptorBufferProps = {}; descriptorBufferProps.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_BUFFER_PROPERTIES_EXT; VkPhysicalDeviceProperties2 props = {}; props.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; props.pNext = &descriptorBufferProps; vkGetPhysicalDeviceProperties2(physicalDevice, &props); const VkDeviceSize samplerDescriptorSize = descriptorBufferProps.combinedImageSamplerDescriptorSize;
VkDescriptorGetInfoEXT getInfo = { VK_STRUCTURE_TYPE_DESCRIPTOR_GET_INFO_EXT }; getInfo.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; VkDescriptorAddressInfoEXT addrInfo = { VK_STRUCTURE_TYPE_DESCRIPTOR_ADDRESS_INFO_EXT }; addrInfo.address = getBufferDeviceAddress(uniformBuffer); addrInfo.range = UNIFROM_BUFFER_SIZE; getInfo.data.pUniformBuffer = &addrInfo; getAndCopyDescriptor(device, getInfo, descriptorBufferProps.uniformBufferDescriptorSize, bufferBase + offset_0_ubo);
getInfo.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; for (size_t i = 0; i < image2DInfos.size(); ++i) { getInfo.data.pCombinedImageSampler = &image2DInfos[i];
void* dest = bufferBase + offset_1_samplers + (i * samplerDescriptorSize); getAndCopyDescriptor(device, getInfo, samplerDescriptorSize, dest); }
|
绑定描述符缓冲区(替换 vkCmdBindDescriptorSets)
在命令缓冲区录制期间,绑定方式也完全改变了。同时,由于为每一个管线分配了独立的 Buffer,所以这里不需要计算大 Buffer 中的偏移量。至此,已通过 Descriptor Buffers 向管线完成资源传递。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| VkDescriptorBufferBindingInfoEXT bindingInfo{}; bindingInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_BUFFER_BINDING_INFO_EXT; bindingInfo.address = getBufferDeviceAddress(my_descriptor_buffer); bindingInfo.usage = VK_BUFFER_USAGE_RESOURCE_DESCRIPTOR_BUFFER_BIT_EXT | VK_BUFFER_USAGE_SAMPLER_DESCRIPTOR_BUFFER_BIT_EXT;
vkCmdBindDescriptorBuffersEXT(cmd, 1, &bindingInfo);
uint32_t bufferIndex = 0; VkDeviceSize bufferOffset = 0;
vkCmdSetDescriptorBufferOffsetsEXT(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &bufferIndex, &offset);
|
碎碎念:毫无价值的扩展
直到整个完成向 Descriptor Buffers 的迁移后,我才意识到重构并未带来任何实际意义上的好处。经典 Descriptor 模型虽然繁琐,每次资源更新之前需要填写若干冗长的结构(VkWriteDescriptorSet,VkDescriptorImageInfo,VkDescriptorBufferInfo等),但也因此保留直观的特点和高可维护性。相反的,Descriptor Buffers 扩展为了资源更新的灵活性(不再通过 API 发起更新请求,转而直接写入被映射过的内存),将原本由驱动程序维护的不透明内存模型直接暴露给开发者,同时手动计算资源之间 offset ,极大增加了开发者的心智负担(尤其是极端情况下,所有管线共用一个 Buffer,真正意义上的大 offset 嵌套小 offset),稍有不慎就是 UB。而这一切的带来的好处仅仅只是少调用几次资源更新的 API,降低 CPU 侧的开销。由于驱动程序对于 API 的实现是黑箱,一次更新调用对 CPU 的开销也无从得知,但所带来的零星性能提升也不足以弥补失去的可维护性与可读性。同时地,截帧工具对 Descriptor Buffers 扩展的支持程度普遍不高,大量在图形程序中使用此扩展或为调试带来不便。
因此,我不认为 Descriptor Buffers 具有替代经典 Descriptor 流程的潜力,甚至 Vulkan1.4 标准也未将其收入进核心。
Author:
Floraison
Permalink:
https://floraison.io/2025/09/08/
License:
Copyright (c) 2019 CC-BY-NC-4.0 LICENSE