WEBKT

Vulkan Bindless 纹理技术实战:打破描述符绑定瓶颈与实现超大规模合批

3 0 0 0

在传统图形 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_BITVK_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)

核心架构设计

  1. 全局顶点/索引缓冲区(Bindless Geometry)
    将场景中所有不同的三维模型合并存放到一个巨大的 VkBuffer 中。每个子网格(Submesh)记录其在全局 Buffer 中的偏移量(firstIndexvertexOffset)。

  2. 材质与模型信息缓冲区 (SSBO)
    创建一个结构体数组存放每个物体的变换矩阵和纹理索引:

    struct ObjectData {
        mat4 modelMatrix;
        uint textureIndex;
        uint padding[3];
    };
    layout(std430, set = 0, binding = 1) readonly buffer ObjectBuffer {
        ObjectData objects[];
    };
    
  3. 构建 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 的索引
    };
    
  4. 一次调用完成绘制

    // 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。
极客渲染说 VulkanBindless图形学

评论点评