Vulkan Bindless 纹理技术实战:打破描述符绑定瓶颈与实现超大规模合批
在传统图形 API(如 OpenGL 或早期 Vulkan 1.0)中,渲染拥有不同材质的物体通常需要频繁切换 Descriptor Set(描述符集)。每一次 vkCmdBindDescriptorSets 都会带来不小的 CPU 开销,并且会强行打断 Draw Call 的合并。如果场景中有成千上万个不同纹理的物件,CPU 就会迅速成为渲染管线的瓶颈。
为了解决这个问题,现代图形 API 引入了 Bindless(无绑定) 概念。在 Vulkan 中,这通过 VK_EXT_descriptor_indexing 扩展(在 Vulkan 1.2 中已正式写入核心标准)来实现。
本文将详细介绍如何在 Vulkan 中实现 Bindless 纹理技术,从物理设备特性启用、描述符集创建,到 Shader 中的动态索引,最后探讨如何结合多半透明/不透明物体实现真正的单次 Draw Call 绘制全场景(配合 Multi-Draw Indirect)。
1. 传统绑定 vs Bindless 纹理
在传统管线中,我们的渲染循环通常是这样的:
for (const auto& mesh : meshes) {
vkCmdBindPipeline(...);
vkCmdBindDescriptorSets(..., mesh.textureDescriptorSet, ...); // 频繁切换
vkCmdDrawIndexed(...); // 产生大量的 Draw Call
}
而在 Bindless 管线中,我们不再为每个物体单独绑定描述符。相反,我们将全场景所有的纹理一次性绑定到一个巨大的数组中,并在绘制时将纹理的索引(Index)作为顶点属性、Instance 属性或 Push Constant 传给 Shader。
// 只需要在帧开始时绑定一次巨大的纹理数组
vkCmdBindDescriptorSets(..., globalBindlessDescriptorSet, ...);
// 配合 Multi-Draw Indirect,一次 Draw Call 搞定成百上千个物体
vkCmdDrawIndexedIndirect(...);
2. 启用 Vulkan Descriptor Indexing 特性
要使用 Bindless 纹理,首先必须在创建逻辑设备(VkDevice)时,显式启用相关的物理设备特性。我们需要利用 VkPhysicalDeviceDescriptorIndexingFeatures 结构体。
// 检查并填充特性结构体
VkPhysicalDeviceDescriptorIndexingFeatures indexingFeatures{};
indexingFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_INDEXING_FEATURES;
indexingFeatures.descriptorBindingPartiallyBound = VK_TRUE; // 允许描述符数组中有未绑定的空洞
indexingFeatures.descriptorBindingSampledImageUpdateAfterBind = VK_TRUE; // 允许在 Bind 后更新
indexingFeatures.shaderSampledImageArrayNonUniformIndexing = VK_TRUE; // 支持非均一索引(关键)
indexingFeatures.runtimeDescriptorArray = VK_TRUE; // 支持在 Shader 中声明未定义大小的数组
VkPhysicalDeviceFeatures2 deviceFeatures2{};
deviceFeatures2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
deviceFeatures2.pNext = &indexingFeatures;
// 将 pNext 链条传入 VkDeviceCreateInfo
VkDeviceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
createInfo.pNext = &deviceFeatures2;
// ... 填充其他设备创建信息 ...
vkCreateDevice(physicalDevice, &createInfo, nullptr, &device);
descriptorBindingPartiallyBound:极为重要。它允许我们分配一个很大的数组(比如 10000),但只填充其中用到的几百个槽位,其余槽位保留为空(null),而不会触发 Vulkan 校验层的报错。shaderSampledImageArrayNonUniformIndexing:允许我们在 Shader 中使用非常量(如来自顶点属性或 Push Constant 的变量)来索引纹理数组。
3. 创建 Bindless 描述符集布局
在创建 VkDescriptorSetLayout 时,我们需要对纹理绑定槽位设置特殊的 Flag:VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT 和 VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT。
// 1. 定义绑定
VkDescriptorSetLayoutBinding bindlessBinding{};
bindlessBinding.binding = 0;
bindlessBinding.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE;
bindlessBinding.descriptorCount = 10000; // 分配一个足够大的上界
bindlessBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
bindlessBinding.pImmutableSamplers = nullptr;
// 2. 设定 Binding Flags
VkDescriptorBindingFlags bindingFlags =
VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT |
VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT |
VK_DESCRIPTOR_BINDING_VARIABLE_DESCRIPTOR_COUNT_BIT; // 允许动态调整大小
VkDescriptorSetLayoutBindingFlagsCreateInfo layoutBindingFlagsCI{};
layoutBindingFlagsCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO;
layoutBindingFlagsCI.bindingCount = 1;
layoutBindingFlagsCI.pBindingFlags = &bindingFlags;
// 3. 创建 Layout
VkDescriptorSetLayoutCreateInfo layoutCI{};
layoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutCI.pNext = &layoutBindingFlagsCI; // 链接 Binding Flags
layoutCI.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT; // 关键 flag
layoutCI.bindingCount = 1;
layoutCI.pBindings = &bindlessBinding;
VkDescriptorSetLayout bindlessLayout;
vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &bindlessLayout);
注意:创建 Pool 和 Layout 时必须带上
UPDATE_AFTER_BIND相关的 Flag。这告诉 Vulkan 驱动,我们可能会在 Command Buffer 已经录制甚至提交后,动态去更新这个 Descriptor Set 指向的资源。
4. 动态更新 Bindless 描述符集
有了布局后,我们分配出一个全局唯一的 Bindless Descriptor Set。当场景中加载了新的纹理,或者销毁了旧纹理,我们只需要动态地更新这个 Set 中对应 Index 的槽位。
void updateTextureAtIndex(VkDescriptorSet bindlessSet, VkImageView imageView, VkSampler sampler, uint32_t index) {
VkDescriptorImageInfo imageInfo{};
imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imageInfo.imageView = imageView;
imageInfo.sampler = sampler;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = bindlessSet;
write.dstBinding = 0;
write.dstArrayElement = index; // 写入到指定的数组索引位置
write.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE;
write.descriptorCount = 1;
write.pImageInfo = &imageInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
}
通过这种方式,我们可以在游戏运行期间,随着资源的流式加载(Asset Streaming),动态地往槽位里填入或移除纹理,而不需要重建整个描述符集。
5. Shader 端的写法与非均一索引
在 GLSL 中,我们需要启用 GL_EXT_nonuniform_qualifier 扩展。纹理数组被声明为一个未指定大小的数组 sampler2D globalTextures[]。
#version 450
#extension GL_EXT_nonuniform_qualifier : enable
layout(set = 0, binding = 0) uniform sampler2D globalTextures[];
layout(location = 0) in vec2 inUV;
layout(location = 1) flat in uint inTextureIndex; // 从顶点/实例属性传过来的纹理索引
layout(location = 0) out vec4 outColor;
void main() {
// 使用 nonuniformEXT 包裹非常量索引,防止 GPU 发生分支分化时出现未定义行为
outColor = texture(globalTextures[nonuniformEXT(inTextureIndex)], inUV);
}
为什么必须使用 nonuniformEXT?
在 GPU 中,Warp/Subgroup(通常是 32 或 64 个线程)是并行执行相同指令的。如果同一个 Warp 内的不同像素(Pixel Thread)去索引不同的纹理,这就是“非均一索引”(Non-Uniform Indexing)。
如果不加 nonuniformEXT 标识,GPU 驱动会默认所有线程访问的是同一个描述符。一旦发生线程间索引不一致,会导致 mipmap 计算错误、画面闪烁、甚至 GPU 挂起(TDR)。nonuniformEXT 告诉硬件:“此处索引可能分化,请妥善处理各线程的导数计算与纹理采样”。
6. 进阶:结合 Multi-Draw Indirect 消除 CPU 提交瓶颈
实现了 Bindless 纹理之后,所有的纹理都已经在一张大表里了。此时,限制我们合批的唯一障碍就是网格数据(Mesh Data)和绘制命令(Draw Calls)。
为了达成“单次 Draw Call 绘制全场景”的目标,我们可以引入 MDI (Multi-Draw Indirect)。
核心架构设计
全局顶点/索引缓冲区(Bindless Geometry):
将场景中所有不同的三维模型合并存放到一个巨大的VkBuffer中。每个子网格(Submesh)记录其在全局 Buffer 中的偏移量(firstIndex,vertexOffset)。材质与模型信息缓冲区 (SSBO):
创建一个结构体数组存放每个物体的变换矩阵和纹理索引:struct ObjectData { mat4 modelMatrix; uint textureIndex; uint padding[3]; }; layout(std430, set = 0, binding = 1) readonly buffer ObjectBuffer { ObjectData objects[]; };构建 Indirect 命令缓冲区:
在 CPU 或 GPU(利用 Compute Shader 进行 GPU 驱动裁剪)中,填充一个VkDrawIndexedIndirectCommand数组:struct VkDrawIndexedIndirectCommand { uint32_t indexCount; uint32_t instanceCount; uint32_t firstIndex; int32_t vertexOffset; uint32_t firstInstance; // 可以映射为 ObjectBuffer 的索引 };一次调用完成绘制:
// 1. 绑定全局顶点和索引 vkCmdBindVertexBuffers(cmd, 0, 1, &globalVertexBuffer, offsets); vkCmdBindIndexBuffer(cmd, globalIndexBuffer, 0, VK_INDEX_TYPE_UINT32); // 2. 绑定包含 Bindless 纹理和 SSBO 的描述符集 vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &globalDescriptorSet, 0, nullptr); // 3. 执行多重间接绘制 vkCmdDrawIndexedIndirect( cmd, indirectBuffer, 0, drawCount, sizeof(VkDrawIndexedIndirectCommand) );
在这种管线中,GPU 运行时通过 gl_InstanceIndex 获取到 ObjectData,进而拿到 textureIndex,再去 globalTextures 数组中索引对应的纹理。CPU 只需要准备好间接命令数据,一次性递交给 GPU,即可完成上万个异构材质物体的渲染。
7. 避坑指南与最佳实践
- 硬件限制查询:
并非所有 GPU 支持的 Bindless 数组大小都一样。通过查询VkPhysicalDeviceProperties2下的VkPhysicalDeviceDescriptorIndexingProperties,重点关注maxDescriptorSetUpdateAfterBindSamplers。现代 PC 显卡通常支持高达数万到数十万(如 500,000+)个描述符,但在移动端(如 Mali/Adreno)上可能只有几千甚至更少,需要做好降级适配。 - 各向异性过滤与 Sampler 的数量:
Bindless 方案中,纹理和 Sampler 可以是解耦的。建议不要把sampler2D(即组合的 Image Sampler)放进 Bindless。更好的做法是将VkImageView(无 Sampler)存放在 Bindless 数组中,而在 Shader 中使用全局的几个静态 Sampler(例如临近采样、线性采样、各向异性采样)手动进行绑定采样。这样可以极大地节省 Descriptor 槽位与硬件 Sampler 寄存器开销。 - 同步与屏障:
由于启用了UPDATE_AFTER_BIND,在录制命令行时可能纹理还没有写好。需要合理利用VkPipelineBarrier或者在帧同步时确保内存已经通过vkUpdateDescriptorSets写入就绪,防止 GPU 读取到垃圾数据导致 Crash。