深入解析 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
瓶颈
在 vkCmdDraw 或 vkCmdDrawIndexed
中,所有的绘制参数(如
vertexCount、instanceCount、firstVertex、firstInstance)均由
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; uint32_t firstVertex; uint32_t firstInstance; };
|
B.
索引间接绘制参数 (VkDrawIndexedIndirectCommand)
对应 vkCmdDrawIndexedIndirect 提取的数据:
1 2 3 4 5 6 7
| struct VkDrawIndexedIndirectCommand { uint32_t indexCount; uint32_t instanceCount; uint32_t firstIndex; int32_t vertexOffset; uint32_t firstInstance; };
|
2. API 调用接口与参数解析
Vulkan 提供了两种精细度不同的间接绘制提交接口:
1 2 3 4 5 6
| void vkCmdDrawIndexedIndirect( VkCommandBuffer commandBuffer, VkBuffer buffer, VkDeviceSize offset, uint32_t drawCount, uint32_t stride);
|
- 步长对齐要求(
stride):stride
必须是 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, VkDeviceSize offset, VkBuffer countBuffer, VkDeviceSize countBufferOffset, 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; indirectBarrier.dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT; 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, VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, 0, 0, nullptr, 1, &indirectBarrier, 0, nullptr );
|
五、 与
Bindless(无绑定)架构的深度集成
单纯使用 Indirect Draw 仍面临一个挑战:不同 Mesh 的材质纹理(Albedo,
Normal, Roughness)与 Uniform 缓冲区各不相同。如果在 CPU 端频繁切换
Descriptor Set,间接绘制的优势将荡然无存。
无绑定架构(Bindless Architecture / Descriptor
Indexing) 完美破解了这一难题:
- 全局 SSBO 与 Texture 数组:将场景中所有 Mesh
的顶点/索引/材质参数存入一个巨大的
StorageBuffer
数组,所有纹理打包进 Bindless 纹理数组
sampler2D globalTextures[]。
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
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
struct ObjectData { mat4 modelMatrix; vec4 boundingSphere; uint indexCount; uint firstIndex; uint vertexOffset; uint materialID; };
struct IndexedIndirectCommand { uint indexCount; uint instanceCount; uint firstIndex; int vertexOffset; uint firstInstance; };
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; } }
|
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() { uint objID = gl_InstanceIndex; ObjectData obj = objects[objID];
outUV = inUV; outMaterialID = obj.materialID;
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) { vkCmdFillBuffer(cmd, countBuffer, 0, sizeof(uint32_t), 0);
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);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, cullPipeline); uint32_t groupX = (maxObjects + 63) / 64; vkCmdDispatch(cmd, groupX, 1, 1);
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);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, renderPipeline); vkCmdDrawIndexedIndirectCount( cmd, indirectBuffer, 0, countBuffer, 0, maxObjects, sizeof(VkDrawIndexedIndirectCommand) ); }
|
七、 性能选型矩阵与方案对比
| 评估维度 |
传统 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
切换瓶颈; *
通过掌握这一管线,渲染引擎能够轻松支撑数百万级的动态几何体绘制,将设备的硬件算力发挥至极致。