深入解析 Vulkan Indirect Draw(间接绘制):从 GPU-Driven 管线、Multi-Draw Indirect 到无绑定(Bindless)工程实践

深入解析 Vulkan Indirect Draw(间接绘制)

在传统的图形渲染管线中,CPU 负责每帧计算可见性、调度 LOD、更新常量缓冲并将绘制命令(Draw Call)下发给 GPU。随着场景复杂度的几何级数增长(如数百万个独立 Instance、海量植被与建筑),CPU 端遍历场景树与频繁触发 Draw Call 带来的 API 开销成为了制约渲染性能的最大瓶颈(CPU Bottleneck)。

间接绘制(Indirect Draw)GPU-Driven 渲染管线 的出现彻底颠覆了这种传统范式。通过将绘制参数存储在 GPU 显存缓冲区(VkBuffer)中,GPU 可以直接读取并驱动自身的绘制命令,甚至在 GPU 上利用 Compute Shader 完成剔除(Culling)与 LOD 选择后动态生成这些绘制参数。

本文将深度拆解 Vulkan Indirect Draw 的底层 API 结构、GPU-Driven 剔除管线、多绘制间接调用(Multi-Draw Indirect, MDI)、无绑定(Bindless)资源集成、内存屏障同步以及完整的 C++/GLSL 代码实现。


一、 范式演进:从 CPU-Driven 到 GPU-Driven

1. 传统 Direct Draw 的 CPU 瓶颈

vkCmdDrawvkCmdDrawIndexed 中,所有的绘制参数(如 vertexCountinstanceCountfirstVertexfirstInstance)均由 CPU 硬编码传入命令缓冲区(Command Buffer)。

1
2
3
4
[传统 CPU-Driven 管线]
CPU: 视锥剔除 ──> LOD计算 ──> 打包 Uniforms ──> vkCmdDraw (CPU -> GPU 阻塞与调度)

GPU: ─────────────────────────────────────────────────────▼── 渲染几何体

当场景中有 100,000 个物体时,CPU 必须执行 100,000 次视锥剔除与状态更新,并录入 100,000 个 Draw Call。即便 Vulkan 的 Draw Call 开销远低于 OpenGL/DirectX 11,CPU 依然极易陷入性能瓶颈。

2. Indirect Draw 与 GPU-Driven 渲染范式

Indirect Draw 允许绘制指令从 VkBuffer(间接缓冲,Indirect Buffer) 读取参数。配合 Compute Shader,我们可以将视锥剔除(Frustum Culling)、遮挡剔除(Occlusion Culling / HZB)与 LOD 选择完全移交给 GPU 处理:

1
2
3
4
5
6
[现代 GPU-Driven 管线]
CPU: 下发 1 次 Compute Dispatch 与 1 次 vkCmdDrawIndexedIndirectCount

GPU: [Compute Shader] ──(视锥/HZB剔除)──> 动态写入 [Indirect Buffer]
│ (片上/VRAM 传递)
[Rasterizer / VS] <────────────────────────┘ (GPU 自主驱动渲染)

CPU 的帧调度开销降至 \(\mathcal{O}(1)\) 常数复杂度,彻底释放了 CPU 算力。


二、 Vulkan Indirect Draw API 数据结构与接口解构

1. 核心数据结构内存布局

Vulkan 规范定义了两个与 VkBuffer 内部二进制布局完全对齐的数据结构:

A. 非索引间接绘制参数 (VkDrawIndirectCommand)

对应 vkCmdDrawIndirect 提取的数据:

1
2
3
4
5
6
struct VkDrawIndirectCommand {
uint32_t vertexCount; // 绘制的顶点数量
uint32_t instanceCount; // 绘制的实例数量 (若为 0 则不渲染该 Batch)
uint32_t firstVertex; // 顶点缓冲区起始偏移索引
uint32_t firstInstance; // 实例起始索引 (可作为 Shader 内 gl_InstanceIndex 偏移)
};

B. 索引间接绘制参数 (VkDrawIndexedIndirectCommand)

对应 vkCmdDrawIndexedIndirect 提取的数据:

1
2
3
4
5
6
7
struct VkDrawIndexedIndirectCommand {
uint32_t indexCount; // 索引数量
uint32_t instanceCount; // 实例数量
uint32_t firstIndex; // 索引缓冲区起始偏移索引
int32_t vertexOffset; // 顶点在 VBO 中的基准偏移 (Base Vertex)
uint32_t firstInstance; // 实例起始索引
};

2. API 调用接口与参数解析

Vulkan 提供了两种精细度不同的间接绘制提交接口:

1
2
3
4
5
6
void vkCmdDrawIndexedIndirect(
VkCommandBuffer commandBuffer,
VkBuffer buffer, // 存放 VkDrawIndexedIndirectCommand 的 Buffer
VkDeviceSize offset, // Buffer 内部字节起始偏移
uint32_t drawCount, // 间接绘制命令的数量
uint32_t stride); // 两个连续命令间的字节步长 (Byte Stride)
  • 步长对齐要求(stridestride 必须是 4 的整数倍,且必须大于或等于 sizeof(VkDrawIndexedIndirectCommand)(即 20 字节)。通常设为 20 或包含自定义 User Data 的结构体对齐字节数。
  • 多次绘制(Multi-Draw Indirect, MDI):若 drawCount > 1,GPU 会在管线内部循环执行 drawCount 次绘制。此特性需要物理设备开启 VkPhysicalDeviceFeatures::multiDrawIndirect 特性支持。

3. 动态命令计数:vkCmdDrawIndexedIndirectCount

当 Compute Shader 剔除物体时,实际可见的 Draw Call 数量在 CPU 录入 Command Buffer 时是完全未知的。如果硬编码 drawCount,很多 instanceCount = 0 的无效 Draw Call 仍会侵占 GPU 的 Fetch 单元。

VK_KHR_draw_indirect_count 扩展引入了带有 Count Buffer 的绘制接口:

1
2
3
4
5
6
7
8
void vkCmdDrawIndexedIndirectCount(
VkCommandBuffer commandBuffer,
VkBuffer buffer, // 存放 Draw Commands 的 Buffer
VkDeviceSize offset, // Draw Commands 起始偏移
VkBuffer countBuffer, // 存放 uint32_t 实际有效 Draw 数量的 Buffer
VkDeviceSize countBufferOffset, // Count Buffer 字节偏移
uint32_t maxDrawCount, // 安全上限最大绘制数
uint32_t stride); // 步长

在 GPU 上,Compute Shader 处理可见性后,使用原子加操作 atomicAdd 将可见物体总数写入 countBuffer。GPU 渲染管线读取该数值,并取 \(\min(\mathrm{Count}, \mathrm{maxDrawCount})\) 作为最终执行的 Draw Call 数量,实现了真正的完全 GPU 闭环驱动。


三、 GPU-Driven 视锥体剔除(Frustum Culling)数学模型

在 Compute Shader 中,我们需要校验每个物体的世界空间包围球(Bounding Sphere)或轴对齐包围盒(AABB)是否位于相机视锥体内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
   视锥体平面 (Plane: n·x + d = 0)
/
/ +---------------+
/ | 物体 AABB | (完全位于平面前方 -> 可见)
/ +---------------+
/
/ * 包围球 (Center c, Radius R)
/ /
/ v
/ +---+
/ | c | (距离 dist < -R -> 被剔除!)
/ +---+
/

1. 平面空间包围球剔除公式

视锥体可由 6 个剪裁平面表示,每个平面由单位法向量 \(\mathbf{n} = (A, B, C)^\top\) 与截距 \(D\) 定义。对于任意点 \(\mathbf{x}\),其到平面的带符号距离 \(d_{\mathrm{plane}}\) 为:

\[ d_{\mathrm{plane}}(\mathbf{x}) = \mathbf{n} \cdot \mathbf{x} + D = A x + B y + C z + D \]

若物体的包围球中心为 \(\mathbf{c}_{\mathrm{world}}\),半径为 \(R_{\mathrm{world}}\),则该球体完全位于某个平面背侧(即不可见被剔除)的充要条件为:

\[ \exists \, i \in \{0, 1, \dots, 5\}, \quad d_{\mathrm{plane}_i}(\mathbf{c}_{\mathrm{world}}) < -R_{\mathrm{world}} \]

只要包围球满足对所有 6 个平面都有 \(d_{\mathrm{plane}_i}(\mathbf{c}_{\mathrm{world}}) \ge -R_{\mathrm{world}}\),即可判定该物体通过视锥体剔除。


2. Compute Shader 中的原子构建逻辑

当某物体 \(j\) 通过剔除检测后,Compute Shader 使用原子操作申请一个全局输出索引,并填充 VkDrawIndexedIndirectCommand 结构:

\[ \mathrm{DrawIndex} = \operatorname{atomicAdd}(\mathrm{GlobalCountBuffer}, 1) \]

\[ \mathrm{IndirectBuffer}[\mathrm{DrawIndex}].\mathrm{instanceCount} = 1 \]


四、 内存屏障与同步机制(Pipeline Barriers)

间接绘制要求处理好计算着色器写(Compute Write)间接命令读取(Indirect Read) 之间的物理数据依赖。

在提交 vkCmdDrawIndexedIndirect 之前,必须在 Command Buffer 中插入 VkBufferMemoryBarrier 强制刷刷新缓存并建立管线阶段屏障:

1
2
3
4
5
6
7
8
9
10
11
12
13
+-----------------------------------------------------------------------+
| GPU-Driven 内存同步状态流转 |
| |
| [Compute Shader Phase] |
| Access: VK_ACCESS_SHADER_WRITE_BIT |
| Stage: VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT |
| │ |
| │ vkCmdPipelineBarrier |
| ▼ |
| [Indirect Draw Phase] |
| Access: VK_ACCESS_INDIRECT_COMMAND_READ_BIT |
| Stage: VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT |
+-----------------------------------------------------------------------+

屏障配置结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
VkBufferMemoryBarrier indirectBarrier{};
indirectBarrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
indirectBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; // Compute Shader 的 SSBO 写入
indirectBarrier.dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT; // Indirect Fetch 单元的读取
indirectBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
indirectBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
indirectBarrier.buffer = indirectCommandBuffer;
indirectBarrier.offset = 0;
indirectBarrier.size = VK_WHOLE_SIZE;

vkCmdPipelineBarrier(
cmdBuffer,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // 源阶段:Compute Shader 结束
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, // 目的阶段:Indirect Command Fetch 开始
0,
0, nullptr,
1, &indirectBarrier, // Buffer Barrier
0, nullptr
);

五、 与 Bindless(无绑定)架构的深度集成

单纯使用 Indirect Draw 仍面临一个挑战:不同 Mesh 的材质纹理(Albedo, Normal, Roughness)与 Uniform 缓冲区各不相同。如果在 CPU 端频繁切换 Descriptor Set,间接绘制的优势将荡然无存。

无绑定架构(Bindless Architecture / Descriptor Indexing) 完美破解了这一难题:

  1. 全局 SSBO 与 Texture 数组:将场景中所有 Mesh 的顶点/索引/材质参数存入一个巨大的 StorageBuffer 数组,所有纹理打包进 Bindless 纹理数组 sampler2D globalTextures[]
  2. gl_DrawID / gl_InstanceIndex 映射: 在 Vulkan 中,当开启 multiDrawIndirect 时,GLSL 着色器可以内置访问 gl_DrawID(即当前处于第几个间接绘制命令)。 结合 firstInstance,顶点着色器可以直接索引全局 Buffer 获取当前 Mesh 的绝对变换矩阵与材质 ID:

\[ \mathrm{MeshID} = \mathrm{InstanceBuffer}[\mathrm{gl\_InstanceIndex}].\mathrm{meshID} \]

\[ \mathbf{P}_{\mathrm{world}} = \mathbf{M}_{\mathrm{transforms}}[\mathrm{MeshID}] \cdot \mathbf{P}_{\mathrm{local}} \]


六、 完整 C++ & GLSL 代码实现

1. Compute Shader 剔除与间接命令生成 (cull.comp)

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#version 450

// 定义 Workgroup 尺寸
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

struct ObjectData {
mat4 modelMatrix;
vec4 boundingSphere; // xyz: center, w: radius
uint indexCount;
uint firstIndex;
uint vertexOffset;
uint materialID;
};

struct IndexedIndirectCommand {
uint indexCount;
uint instanceCount;
uint firstIndex;
int vertexOffset;
uint firstInstance;
};

// 绑定 SSBO 资源
layout(binding = 0, std430) readonly buffer ObjectBuffer {
ObjectData objects[];
};

layout(binding = 1, std430) writeonly buffer IndirectBuffer {
IndexedIndirectCommand commands[];
};

layout(binding = 2, std430) buffer CountBuffer {
uint drawCount;
};

layout(push_constant) uniform CameraParams {
mat4 viewProj;
vec4 frustumPlanes[6];
uint totalObjects;
};

bool IsVisible(vec3 center, float radius) {
for (int i = 0; i < 6; ++i) {
if (dot(frustumPlanes[i].xyz, center) + frustumPlanes[i].w < -radius) {
return false; // 被某个视锥平面剔除
}
}
return true;
}

void main() {
uint gID = gl_GlobalInvocationID.x;
if (gID >= totalObjects) return;

ObjectData obj = objects[gID];

// 变换包围球中心至世界空间
vec3 worldCenter = (obj.modelMatrix * vec4(obj.boundingSphere.xyz, 1.0)).xyz;
float worldRadius = obj.boundingSphere.w; // 假设非均匀缩放已处理

if (IsVisible(worldCenter, worldRadius)) {
// 原子增加绘制计数
uint cmdIndex = atomicAdd(drawCount, 1);

// 填充硬件间接绘制命令
commands[cmdIndex].indexCount = obj.indexCount;
commands[cmdIndex].instanceCount = 1;
commands[cmdIndex].firstIndex = obj.firstIndex;
commands[cmdIndex].vertexOffset = int(obj.vertexOffset);
commands[cmdIndex].firstInstance = gID; // 将物体全局 ID 传入 firstInstance
}
}

2. Bindless 顶点着色器 (shader.vert)

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
31
32
33
34
35
36
37
38
39
#version 450
#extension GL_EXT_nonuniform_qualifier : enable

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inUV;

layout(location = 0) out vec2 outUV;
layout(location = 1) out flat uint outMaterialID;

struct ObjectData {
mat4 modelMatrix;
vec4 boundingSphere;
uint indexCount;
uint firstIndex;
uint vertexOffset;
uint materialID;
};

layout(binding = 0, std430) readonly buffer ObjectBuffer {
ObjectData objects[];
};

layout(push_constant) uniform SceneUniforms {
mat4 viewProj;
};

void main() {
// 利用 gl_InstanceIndex (包含 firstInstance 传递的全局 ID) 获取数据
uint objID = gl_InstanceIndex;
ObjectData obj = objects[objID];

outUV = inUV;
outMaterialID = obj.materialID;

// 计算世界空间与 NDC 空间位置
vec4 worldPos = obj.modelMatrix * vec4(inPosition, 1.0);
gl_Position = viewProj * worldPos;
}

3. Vulkan C++ CPU 侧调度控制

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <vulkan/vulkan.h>

void RecordGPUDrivenRenderPass(
VkCommandBuffer cmd,
VkPipeline cullPipeline,
VkPipelineLayout cullLayout,
VkPipeline renderPipeline,
VkPipelineLayout renderLayout,
VkBuffer indirectBuffer,
VkBuffer countBuffer,
uint32_t maxObjects)
{
// 1. 重置 Count Buffer (清零)
vkCmdFillBuffer(cmd, countBuffer, 0, sizeof(uint32_t), 0);

// 插入 Buffer Fill 屏障,确保填充完成
VkBufferMemoryBarrier fillBarrier{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER};
fillBarrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
fillBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
fillBarrier.buffer = countBuffer;
fillBarrier.size = sizeof(uint32_t);

vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 0, nullptr, 1, &fillBarrier, 0, nullptr);

// 2. 调度 Compute Shader 执行视锥剔除
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, cullPipeline);
uint32_t groupX = (maxObjects + 63) / 64;
vkCmdDispatch(cmd, groupX, 1, 1);

// 3. 插入屏障:Compute Write -> Indirect Command Read
VkBufferMemoryBarrier barriers[2]{};
barriers[0].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
barriers[0].srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
barriers[0].dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT;
barriers[0].buffer = indirectBuffer;
barriers[0].size = VK_WHOLE_SIZE;

barriers[1].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
barriers[1].srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
barriers[1].dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT;
barriers[1].buffer = countBuffer;
barriers[1].size = sizeof(uint32_t);

vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT,
0, 0, nullptr, 2, barriers, 0, nullptr);

// 4. 执行 GPU 驱动的 Multi-Draw Indirect Count
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, renderPipeline);

// 绑定全局 Bindless 资源与 Geometry VBO/IBO ...

vkCmdDrawIndexedIndirectCount(
cmd,
indirectBuffer,
0, // Indirect Buffer Offset
countBuffer,
0, // Count Buffer Offset
maxObjects, // maxDrawCount 安全上限
sizeof(VkDrawIndexedIndirectCommand) // Stride
);
}

七、 性能选型矩阵与方案对比

评估维度 传统 vkCmdDrawIndexed 基础 vkCmdDrawIndexedIndirect GPU-Driven + IndirectCount
Draw Call 控制主体 CPU (逐个 Call 下发) CPU (下发指令数组) GPU (Compute Shader 动态下发)
CPU 端算法复杂度 \(\mathcal{O}(N)\) (遍历/剔除/打包) \(\mathcal{O}(N)\) (需要 CPU 填充 Buffer) \(\mathcal{O}(1)\) (仅 Dispatch + Indirect Draw)
CPU-GPU 带宽瓶颈 高 (频繁状态与 API 调用) 中 (上传 Indirect Buffer) 极低 (全量数据驻留 VRAM)
硬件 Culling 支持 依赖 CPU 粗粒度 Bounds 依赖 CPU GPU 细粒度并行 (Per-Instance / Meshlet)
空 Draw Call 开销 存在 存在 (需 instanceCount = 0 规避) 无 (通过 Count Buffer 完全消除)
Vulkan 扩展要求 核心 API 核心 API (multiDrawIndirect) 扩展 VK_KHR_draw_indirect_count

八、 总结

Vulkan Indirect Draw 与 GPU-Driven 渲染管线代表了实时图形学架构的演进方向: * 核心变革在于控制权的转移:将原本属于 CPU 的可见性测试、LOD 调度与绘制命令生成彻底交由 GPU 的海量 ALU 并行处理; * 工程落地的三要素:利用 Compute Shader 执行视锥/HZB 剔除、利用 VK_KHR_draw_indirect_count 消除空 Draw Call 开销、利用 Bindless(无绑定)资源架构 消除 Descriptor 切换瓶颈; * 通过掌握这一管线,渲染引擎能够轻松支撑数百万级的动态几何体绘制,将设备的硬件算力发挥至极致。

本文作者:Berg Zha

本文链接:http://junglemanpro.com/2026/08/06/indirectDraw/

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

ESC 关闭 | 导航 | Enter 打开
输入关键词开始搜索