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)的地址引用。同时它还是一块不透明的内存,由驱动程序负责维护。在此之上,又进一步划分出了 DescriptorSetLayoutDescriptorPoolDescriptorSet 等概念,对于初学者的理解产生不便。

  • 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
// 创建 DescriptorSetLayout
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);

// 创建 DescriptorPool
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
// 从池中分配一个 DescriptorSet
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};

// 通过 vkUpdateDescriptorSets 更新
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
// 绑定这个刚刚更新好的 DescriptorSet
vkCmdBindDescriptorSets(
commandBuffer,
VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout,
0, // firstSet
1, // setCount
&descriptorSet, // 传入具体的Set对象
0,
nullptr
);

// ... 然后 vkCmdDraw ...

描述符缓冲区 (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 扩展并入核心,在创建 VkInstanceVkDevice 时,必须在启用列表里加入 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_BITVK_BUFFER_USAGE_RESOURCE_DESCRIPTOR_BUFFER_BIT_EXTVK_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);

// 查询当前硬件设备 SamplerDescriptorSize 所对应大小
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;

// --- Binding 0: Uniform Buffer ---
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);

// --- Binding 1: Sampler Array (512 elements) ---
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; // 由于为每一个管线分配了独立的 Buffer,所以这里不需要计算大 Buffer 中的偏移量

vkCmdSetDescriptorBufferOffsetsEXT(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, /*firstSet=*/0, /*setCount=*/1, &bufferIndex, &offset);

// ... 然后发起 vkCmdDraw ...

碎碎念:毫无价值的扩展

直到整个完成向 Descriptor Buffers 的迁移后,我才意识到重构并未带来任何实际意义上的好处。经典 Descriptor 模型虽然繁琐,每次资源更新之前需要填写若干冗长的结构(VkWriteDescriptorSetVkDescriptorImageInfoVkDescriptorBufferInfo等),但也因此保留直观的特点和高可维护性。相反的,Descriptor Buffers 扩展为了资源更新的灵活性(不再通过 API 发起更新请求,转而直接写入被映射过的内存),将原本由驱动程序维护的不透明内存模型直接暴露给开发者,同时手动计算资源之间 offset ,极大增加了开发者的心智负担(尤其是极端情况下,所有管线共用一个 Buffer,真正意义上的大 offset 嵌套小 offset),稍有不慎就是 UB。而这一切的带来的好处仅仅只是少调用几次资源更新的 API,降低 CPU 侧的开销。由于驱动程序对于 API 的实现是黑箱,一次更新调用对 CPU 的开销也无从得知,但所带来的零星性能提升也不足以弥补失去的可维护性与可读性。同时地,截帧工具对 Descriptor Buffers 扩展的支持程度普遍不高,大量在图形程序中使用此扩展或为调试带来不便。

因此,我不认为 Descriptor Buffers 具有替代经典 Descriptor 流程的潜力,甚至 Vulkan1.4 标准也未将其收入进核心。