在 Vulkan 里实现 GPU-Driven 渲染:Compute 剔除 + Indirect 绘制
xjm Lv1

一、DrawIndirect

普通的绘制循环是 CPU 主导的:

1
2
3
for (对象 in 场景)
if (在视锥内) // CPU 做剔除
vkCmdDrawIndexed(...) // CPU 一条条录命令

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
2
3
4
5
6
7
typedef struct VkDrawIndexedIndirectCommand {
uint32_t indexCount;
uint32_t instanceCount;
uint32_t firstIndex;
int32_t vertexOffset;
uint32_t firstInstance;
} 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
2
3
4
5
struct MyDrawCommand {
VkDrawIndexedIndirectCommand cmd; // 前 20 字节,indirect 取数器认这部分
uint32_t materialIndex; // 追加的 4 字节,只有 shader 当 SSBO 读时才看
};
// stride = sizeof(MyDrawCommand) = 24,不是 20

这样同一个 buffer 就有了双重身份:对 indirect 取数器它是命令数组,对 shader 它是一张 per-draw 数据表(下面 2.4 会用到)。

2.3 从 Indirect 到 IndirectCount:让 GPU 决定画多少

vkCmdDrawIndexedIndirectdrawCount 还是 CPU 给的常量。但剔除是 GPU 算的,CPU 这一帧根本不知道几个对象可见。解决办法是它的升级版:

1
2
3
4
5
6
7
8
vkCmdDrawIndexedIndirectCount(
cmd,
buffer, // 命令数组
offset,
countBuffer, // 另一个 buffer,里面存着一个 uint32:实际画几条
countBufferOffset,
maxDrawCount, // 上限(用来给驱动做边界保护)
stride);

差别只有一个:实际绘制条数从 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
2
3
4
5
6
7
8
9
#version 450
#extension GL_ARB_shader_draw_parameters : require // ← 必须

layout(set = 1, binding = 0, std430) readonly buffer Cmds { MyDrawCommand cmds[]; };

void main() {
uint material = cmds[gl_DrawIDARB].materialIndex; // 用 draw 序号回查
...
}

Vulkan下:

  1. 要在 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,但底层依然要求设备支持。)

  2. 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
2
3
4
VkBufferCreateInfo bi = { .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
bi.usage = VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT // 给 indirect 取数
| VK_BUFFER_USAGE_STORAGE_BUFFER_BIT // 给 compute 读写
| VK_BUFFER_USAGE_TRANSFER_DST_BIT; // 给上传/清零

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
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
#version 450
layout(local_size_x = 256) in;

struct DrawCommand { // 与 CPU 端的 MyDrawCommand 内存布局必须一致
uint indexCount, instanceCount, firstIndex;
int vertexOffset;
uint firstInstance, materialIndex;
};
struct AABB { vec3 center; vec3 extents; };

layout(set=0, binding=0, std430) readonly buffer Aabbs { AABB aabbs[]; };
layout(set=0, binding=1, std430) readonly buffer SrcCmds { DrawCommand src[]; };
layout(set=0, binding=2, std430) writeonly buffer DstCmds { DrawCommand dst[]; };
layout(set=0, binding=3, std430) buffer Counter { uint visibleCount; };
layout(set=0, binding=4, std140) uniform CullData { vec4 planes[6]; };
layout(push_constant) uniform Push { uint drawCount; };

bool visible(AABB b) {
for (int i = 0; i < 6; ++i) {
vec4 p = planes[i];
float d = dot(b.center, p.xyz); // 中心到平面的有符号距离
float r = dot(b.extents, abs(p.xyz)); // 盒子在平面法线上的投影半径
if (d + r + p.w < 0.0) return false; // 整盒在平面外侧 → 剔除
}
return true;
}

void main() {
uint i = gl_GlobalInvocationID.x;
if (i >= drawCount) return; // 最后一组会越界,必须挡住
if (visible(aabbs[i])) {
uint slot = atomicAdd(visibleCount, 1u); // 原子占一个槽位
dst[slot] = src[i]; // 把整条命令搬过去
}
}

atomicAdd 在全局计数器上原子地占一个槽,然后把命令从只读 buffer 拷到可写 buffer 的那个槽。

代价是:输出顺序是不确定的(哪个线程先抢到 atomicAdd 谁就排前面)。这对本场景无所谓——因为每条命令把自己的 materialIndex 随身带着,gl_DrawID 取到第几条、那条自带的材质就是对的,顺序乱不影响正确性。

3.3 dispatch 的维度

工作组大小定为 256(local_size_x = 256),那么组数向上取整,shader 里再用 i >= drawCount 用在住最后一个不满的组:

1
2
3
uint32_t groups = (drawCount + 255) / 256;
vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(uint32_t), &drawCount);
vkCmdDispatch(cmd, groups, 1, 1);

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
2
3
4
5
6
7
8
9
10
VkBufferMemoryBarrier b = { VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER };
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
b.buffer = visibleCountBuffer; b.offset = 0; b.size = sizeof(uint32_t);
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;

vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TRANSFER_BIT, // src 阶段:fillBuffer 在传输阶段
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // dst 阶段:compute 要用它
0, 0, nullptr, 1, &b, 0, nullptr);

漏了它,清零和累加会 race,计数器值随机偏大或偏小。

4.2 compute → draw(着色器写 → 间接命令读)

compute 把命令写进了可见命令 buffer、把数量写进了计数 buffer,而 graphics pipeline 的 indirect 取数器要去读这两块。这是两个不同 pipeline 阶段对同一块内存的”写后读”,必须显式同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
VkBufferMemoryBarrier toDraw[2] = {};
for (int i = 0; i < 2; ++i) {
toDraw[i].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
toDraw[i].srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
toDraw[i].dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT; // ← 专给 indirect/count 取数
toDraw[i].srcQueueFamilyIndex = toDraw[i].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
}
toDraw[0].buffer = visibleDrawBuffer; toDraw[0].size = VK_WHOLE_SIZE;
toDraw[1].buffer = visibleCountBuffer; toDraw[1].size = sizeof(uint32_t);

vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // src:compute 写
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, // dst:indirect 取数发生在这个阶段
0, 0, nullptr, 2, toDraw, 0, nullptr);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ── 准备(每帧) ───────────────────────────────
// 从相机 view-proj 提取 6 个视锥平面,更新视锥 UBO(map+memcpy 或 vkCmdUpdateBuffer)

// ── 剔除(compute) ───────────────────────────
vkCmdFillBuffer(cmd, countBuf, 0, 4, 0); // 计数器清零
// barrier: TRANSFER_WRITE → SHADER_READ|WRITE (4.1)
vkCmdBindPipeline(cmd, COMPUTE, cullPipeline);
vkCmdBindDescriptorSets(cmd, COMPUTE, ...); // AABB / src / dst / count / 视锥UBO
vkCmdPushConstants(cmd, ..., &drawCount);
vkCmdDispatch(cmd, (drawCount+255)/256, 1, 1);
// barrier: SHADER_WRITE → INDIRECT_COMMAND_READ (4.2) ← 头号关键

// ── 绘制(graphics) ──────────────────────────
vkCmdBeginRendering(cmd, ...); // 或 BeginRenderPass
vkCmdBindPipeline(cmd, GRAPHICS, drawPipeline);
vkCmdBindDescriptorSets(cmd, GRAPHICS, ...); // 注意 set 里绑的是"紧凑后"的命令 buffer
vkCmdBindVertexBuffers(cmd, ...);
vkCmdBindIndexBuffer(cmd, ...);
vkCmdDrawIndexedIndirectCount(cmd,
visibleDrawBuffer, 0, // 紧凑后的命令
countBuf, 0, // GPU 算出的数量
maxDrawCount, stride); // 上限 + 步长
vkCmdEndRendering(cmd);


六、容易踩的坑

  1. compute→draw 的 barrier:目标必须是 VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT 阶段 + VK_ACCESS_INDIRECT_COMMAND_READ_BIT 访问。

  2. 计数器在 host 端 vkCmdFillBuffer 清零,别在 shader 里跨工作组清,并补 TRANSFER → COMPUTE barrier

  3. 三块 buffer 都要 INDIRECT_BUFFER usage

  4. maxDrawCount 填全量容量,可见命令 buffer 也按全量分配。

  5. CPU/GPU 结构体布局要逐字节对齐。最常见的是 vec3:std430/std140 里 vec3 的对齐是 16 字节,所以

    1
    struct AABB { vec3 center; vec3 extents; };   // center@0, extents@16, 整体 32 字节

    CPU 端必须在 center 后补一个 floatextents 后再补一个,凑到 32 字节,

  6. 多帧用独立 buffer(frame-in-flight)。

  7. 开 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 端的状态切换。
由 Hexo 驱动 & 主题 Keep