一、DrawIndirect
普通的绘制循环是 CPU 主导的:
1 | for (对象 in 场景) |
GPU-Driven 的核心思想是把”画哪些、画多少”这两个决定搬到 GPU:
- 画哪些:一个 compute shader 做视锥剔除,把可见对象的绘制命令紧凑地写进一个 buffer;
- 画多少:用 vkCmdDrawIndexedIndirectCount,让 GPU 从一个 buffer 里读出”幸存数量”,CPU 全程不需要知道这一帧到底画了几个。
二、Indirect 绘制:把”画的内容”都放在了 GPU 内存
2.1 vkCmdDrawIndexed
普通的 vkCmdDrawIndexed(cmd, indexCount, instanceCount, firstIndex, vertexOffset, firstInstance),这几个参数是 CPU 在录制时填进去的常量。
Indirect 绘制把这几个参数搬进一个 GPU buffer。Vulkan 规定这块 buffer 里要连续摆放这样的结构(20 字节):
1 | typedef struct VkDrawIndexedIndirectCommand { |
然后调用:
1 | vkCmdDrawIndexedIndirect(cmd, buffer, offset, drawCount, stride); |
GPU 会从 buffer + offset 处开始,每隔 stride 字节取一条命令,执行 drawCount 次。本质上,绘制参数从”CPU 录制时的立即数”变成了”GPU 运行时读取的内存”。这一步是整个 GPU-Driven 的地基:既然命令是内存,那它就可以由另一段 GPU 程序(compute shader)来生成。
2.2 stride
注意 stride 是单独的参数,而不是写死的 sizeof(VkDrawIndexedIndirectCommand)。GPU 取每条命令时只认前 20 字节当作绘制参数,stride - 20 的尾巴它直接跳过。
这给了我们一个非常有用的自由度:在每条命令后面追加自定义的 per-draw 数据,比如材质索引:
1 | struct MyDrawCommand { |
这样同一个 buffer 就有了双重身份:对 indirect 取数器它是命令数组,对 shader 它是一张 per-draw 数据表(下面 2.4 会用到)。
2.3 从 Indirect 到 IndirectCount:让 GPU 决定画多少
vkCmdDrawIndexedIndirect 的 drawCount 还是 CPU 给的常量。但剔除是 GPU 算的,CPU 这一帧根本不知道几个对象可见。解决办法是它的升级版:
1 | vkCmdDrawIndexedIndirectCount( |
差别只有一个:实际绘制条数从 countBuffer 里读一个 uint32,而不是 CPU 给的常量。规范规定实际画的数量 = min(countBuffer 里的值, maxDrawCount)。
这就是 GPU-Driven 闭环的关键:compute 把幸存数量写进 countBuffer,draw 从 countBuffer 读出来。CPU 全程不回读这个数字——一旦回读,就得等 GPU 算完。
maxDrawCount要填分配时的容量。
2.4 顶点着色器怎么知道”我是第几条 draw”
绘制时,vertex shader 常常需要知道当前是哪条 draw,好去查对应的材质/变换。Vulkan/GLSL 里这个内建变量叫 gl_DrawID(HLSL/Slang 里是 SV_DrawIndex):
1 |
|
Vulkan下:
要在 vertex shader gl_DrawID ,设备必须开启
shaderDrawParameters特性(Vulkan 1.1 核心特性,或扩展VK_KHR_shader_draw_parameters),GLSL 里还要#extension GL_ARB_shader_draw_parameters : require并用gl_DrawIDARB。不开启就编译失败或拿到错误的值。(用 Slang/HLSL 写时SV_DrawIndex会自动带上这个 capability,但底层依然要求设备支持。)gl_DrawID是相对”这次 indirect 实际遍历的命令”编号的,取值0 .. (实际画的条数 − 1)。如果你的命令 buffer 是剔除之后紧凑过的,那gl_DrawID = 3指的是”cull后的的第 3 条”,不是”原始的第 3 条”。所以 shader 里这个 SSBO 必须绑cull后的那个 buffer
2.5 Buffer 的 usage flags
一块 buffer 既要被 indirect 取数器读、又要被 compute/shader 当存储读写,usage 就得全给:
1 | VkBufferCreateInfo bi = { .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO }; |
countBuffer 也必须带 VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT——它看起来只是存一个数,但作为 vkCmdDrawIndexedIndirectCount 的 count 来源,也要具备 indirect usage。
2.6 device feature
| 能力 | 用途 | 怎么拿 |
|---|---|---|
multiDrawIndirect |
一次 indirect 调用画多条 | VkPhysicalDeviceFeatures |
drawIndirectCount |
用 ...IndirectCount 系列 |
Vulkan 1.2 核心特性,或 VK_KHR_draw_indirect_count |
shaderDrawParameters |
shader 里用 gl_DrawID |
Vulkan 1.1 核心特性,或 VK_KHR_shader_draw_parameters |
drawIndirectFirstInstance |
命令里 firstInstance != 0 |
VkPhysicalDeviceFeatures(本文恒为 0,用不到) |
maxDrawIndirectCount |
drawCount 上限 | VkPhysicalDeviceLimits |
三、Compute 剔除:让 GPU 算出”画哪些”
下一步是让一个 compute shader 来生成这块内存:输入全量命令,输出只含可见对象的命令。
3.1 输入与输出 buffer
剔除需要这几块 buffer:
- AABB buffer(只读输入):每个对象一个包围盒。
- 源命令 buffer(只读输入):全量的
MyDrawCommand数组。 - 可见命令 buffer(写输出):cull后的命令,compute 写、draw 读。按全量大小分配。
- 计数 buffer(原子写输出):一个
uint32,记录幸存数量。也是 2.3 里的countBuffer。 - 视锥平面 UBO(只读输入):6 个平面,每帧从相机的 view-proj 矩阵提取后更新。
3.2 剔除 shader
用 GLSL 写,核心是”AABB 对 6 个平面做测试 + 原子追加”:
1 |
|
atomicAdd 在全局计数器上原子地占一个槽,然后把命令从只读 buffer 拷到可写 buffer 的那个槽。
代价是:输出顺序是不确定的(哪个线程先抢到 atomicAdd 谁就排前面)。这对本场景无所谓——因为每条命令把自己的 materialIndex 随身带着,gl_DrawID 取到第几条、那条自带的材质就是对的,顺序乱不影响正确性。
3.3 dispatch 的维度
工作组大小定为 256(local_size_x = 256),那么组数向上取整,shader 里再用 i >= drawCount 用在住最后一个不满的组:
1 | uint32_t groups = (drawCount + 255) / 256; |
drawCount 用 push constant 传(它在录制期是已知常量),省一次 buffer 读。
3.4 计数器在 host 端清零
atomicAdd 累加的前提是 visibleCount 这一帧从 0 开始。能不能让 0 号线程在 shader 里清零?不能。dispatch 出去几百个工作组,组之间没有保证的执行顺序,你没法保证”清零”发生在所有”累加”之前。在 shader 里清零会和别的组累加 race。
所以清零放在 dispatch 之前、用一条传输命令做:
1 | vkCmdFillBuffer(cmd, visibleCountBuffer, 0, sizeof(uint32_t), 0); |
vkCmdFillBuffer 属于传输操作,所以它和后面的 compute 之间需要一道 barrier。这也是 buffer 要带 TRANSFER_DST usage 的原因之一。
四、同步:把两段 GPU 程序的数据通路接上
到这里,Indirect 和 Compute 两块拼图都齐了。但 Vulkan 不会自动帮你处理”compute 写的东西,draw 要读”这种依赖——所有跨阶段的内存可见性都要你显式插 barrier。
一帧里有三处需要同步,按命令录制顺序:
4.1 清零 → compute(传输写 → 着色器读写)
vkCmdFillBuffer 写了计数器,compute 马上要读写它,中间插:
1 | VkBufferMemoryBarrier b = { VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER }; |
漏了它,清零和累加会 race,计数器值随机偏大或偏小。
4.2 compute → draw(着色器写 → 间接命令读)
compute 把命令写进了可见命令 buffer、把数量写进了计数 buffer,而 graphics pipeline 的 indirect 取数器要去读这两块。这是两个不同 pipeline 阶段对同一块内存的”写后读”,必须显式同步:
1 | VkBufferMemoryBarrier toDraw[2] = {}; |
ps:
- **目标阶段是
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT,不是 vertex/fragment。 - 访问掩码是
VK_ACCESS_INDIRECT_COMMAND_READ_BIT,这是专门给 indirect/count 取数用的访问类型,既不是SHADER_READ也不是UNIFORM_READ。
补充:如果可见命令 buffer 还会被 vertex shader 当 SSBO 读(2.4 的材质回查),那条读路径要的是
SHADER_READ @ VERTEX_SHADER。可以把 dst 阶段写成DRAW_INDIRECT_BIT | VERTEX_SHADER_BIT、dst 访问写成INDIRECT_COMMAND_READ_BIT | SHADER_READ_BIT,一道 barrier 同时覆盖”取命令”和”读数据”两条路径。
4.3 上一帧 draw → 这一帧 compute(写后写 / 读后写)
如果你的输出 buffer 在多帧之间复用,还要保证”上一帧 draw 读完”再让”这一帧 compute 写”。更省心的做法是给每个 frame-in-flight 各分配一份输出 buffer(可见命令、计数、视锥 UBO 都按帧数复制),让不同帧根本不碰同一块内存——这样这道同步就由 frame 的 fence 天然覆盖了,不用额外插 barrier。:第 N 帧 compute 还在写,第 N+1 帧 CPU 已经在录命令了。
4.4 用 synchronization2 会更方便
上面用的是 vkCmdPipelineBarrier + VkBufferMemoryBarrier。如果支持 VK_KHR_synchronization2(Vulkan 1.3 核心),可以改用 vkCmdPipelineBarrier2 + VkBufferMemoryBarrier2,把 stage/access 直接写在每个 barrier 结构里,语义更清楚、也更容易写对。
五、把一帧串起来
现在按时间顺序看 CPU 在一帧里录的命令——注意它和场景里有多少对象无关:
1 | // ── 准备(每帧) ─────────────────────────────── |
。
六、容易踩的坑
compute→draw 的 barrier:目标必须是
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT阶段 +VK_ACCESS_INDIRECT_COMMAND_READ_BIT访问。计数器在 host 端
vkCmdFillBuffer清零,别在 shader 里跨工作组清,并补TRANSFER → COMPUTEbarrier三块 buffer 都要
INDIRECT_BUFFERusage。maxDrawCount填全量容量,可见命令 buffer 也按全量分配。CPU/GPU 结构体布局要逐字节对齐。最常见的是
vec3:std430/std140 里vec3的对齐是 16 字节,所以1
struct AABB { vec3 center; vec3 extents; }; // center@0, extents@16, 整体 32 字节
CPU 端必须在
center后补一个float、extents后再补一个,凑到 32 字节,多帧用独立 buffer(frame-in-flight)。
开 validation layer 的 synchronization 校验。
七、小结
- Indirect 的本质是”绘制参数本身也是一块 GPU 内存”——于是 stride、usage、count buffer、
gl_DrawID的语义全都要和这块内存的真实布局保持一致。 - Compute→draw 的传输本质是一次 stream compaction + 一道跨阶段的内存可见性 barrier——清零、原子追加、
DRAW_INDIRECT阶段同步,。
- 接下来可以做的是:
- 遮挡剔除(occlusion culling):在视锥剔除之外,再用上一帧深度构建 Hi-Z(层级深度),compute 里把被挡住的对象也剔掉。数据通路和本文一模一样,只是
visible()多一个深度测试。 - 多批次 / 多材质:每个 pipeline 一个命令 buffer,或者把材质也做成 bindless,进一步减少 CPU 端的状态切换。