WEBKT

彻底解放CPU!Vulkan中使用vkCmdDrawIndexedIndirectCount实现超大规模GPU驱动渲染

5 0 0 0

在传统的渲染管线中,CPU 一直扮演着“指挥官”的角色:视锥体裁剪、遮挡剔除、LOD 计算,最后还要把筛选出来的网格挨个打包成 Draw Call 塞给 GPU。当场景中的物件(Instance)达到数十万甚至数百万级别时,CPU 就会成为无可争议的系统瓶颈。

为了解决这个问题,现代图形 API 引入了 GPU 驱动渲染(GPU-Driven Rendering)。而在 Vulkan 中,实现这一技术的终极利器之一就是 vkCmdDrawIndexedIndirectCount。它不仅能让 GPU 自己生成绘制命令,还能让 GPU 自己决定“画多少”,真正实现了渲染流水线对 CPU 的“脱钩”。

为什么是 vkCmdDrawIndexedIndirectCount?

在探讨具体实现之前,我们先看看 Vulkan 绘制 API 的演进过程:

  1. vkCmdDrawIndexed:最传统的绘制,顶点数、实例数等参数全部由 CPU 硬编码或通过 Uniform 传入。
  2. vkCmdDrawIndexedIndirect:CPU 将绘制参数写入一个 GPU Buffer(VkDrawIndexedIndirectCommand),GPU 读取该 Buffer 进行绘制。虽然数据在 GPU 上,但 CPU 依然需要告诉 Vulkan 最大绘制次数(drawCount)
  3. vkCmdDrawIndexedIndirectCount:Vulkan 1.2 引入的核心特性(或通过 VK_KHR_draw_indirect_count 扩展支持)。它不仅从 Buffer 读取绘制参数,还从另一个 Buffer 读取实际的绘制次数。这意味着,CPU 甚至不需要知道有多少个物体通过了裁剪,整个过滤和绘制流程完全在 GPU 内部闭环。

架构设计:无 CPU 介入的渲染管线

要实现完全无 CPU 介入的动态渲染,我们需要设计一个两阶段的管线:Compute Pass(计算阶段)Graphics Pass(绘制阶段)

[ 原始 Instance 数据 Buffer ] 
       │
       ▼
 ┌───────────┐
 │  Compute  │ ──> 进行视锥体/遮挡裁剪
 │  Shader   │ ──> 写入合格物体的 DrawCommand
 └───────────┘ ──> 原子递增 DrawCount Buffer
       │
       ▼  [VkPipelineBarrier (Compute Write -> Indirect Read)]
 ┌───────────┐
 │ Graphics  │ ──> vkCmdDrawIndexedIndirectCount
 │ Pipeline  │ ──> 读取 Command Buffer 和 Count Buffer 绘制
 └───────────┘

1. 核心 Buffer 准备

我们需要在 GPU 端准备三张核心表(Buffer):

  • Instance Data Buffer(只读):存放所有待渲染物体的基础信息(如世界矩阵、包围盒、LOD 信息等)。
  • Indirect Commands Buffer(Compute 写,Graphics 读):存放 VkDrawIndexedIndirectCommand 结构体数组。
  • Draw Count Buffer(Compute 写,Graphics 读):仅包含一个 uint32_t 类型的变量,记录通过裁剪的物体数量。

着色器实现:Compute Shader 中的裁剪与写入

在 Compute Shader 中,每个 Thread 处理一个 Instance。通过裁剪测试的 Instance 会被写入到间接绘制缓冲区中,并更新计数器。

GLSL 示例代码:

#version 450

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

struct InstanceData {
    mat4 modelMatrix;
    vec3 boxMin;
    vec3 boxMax;
    // 其他材质或LOD参数
};

// 输入:所有Instance数据
layout(std430, set = 0, binding = 0) readonly buffer InstanceBuffer {
    InstanceData instances[];
};

// 输出:待绘制的命令
layout(std430, set = 0, binding = 1) writeonly buffer IndirectDrawBuffer {
    DrawIndexedIndirectCommand commands[];
};

// 输出:实际绘制计数
layout(std430, set = 0, binding = 2) buffer DrawCountBuffer {
    uint drawCount;
};

// 视锥体参数等
layout(push_constant) uniform UniformBlock {
    mat4 viewProjection;
    vec4 frustumPlanes[6];
} ubo;

bool IsVisible(vec3 boxMin, vec3 boxMax) {
    // 简易的视锥体裁剪算法 (AABB vs Planes)
    // 返回 true 表示在视锥体内
    return true; 
}

layout(local_size_x = 64) in;

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

    InstanceData inst = instances[gID];

    // 1. 进行裁剪测试
    if (IsVisible(inst.boxMin, inst.boxMax)) {
        // 2. 测试通过,获取唯一的写入索引
        uint writeIndex = atomicAdd(drawCount, 1);

        // 3. 填充绘制命令
        commands[writeIndex].indexCount = 36; // 假设是立方体
        commands[writeIndex].instanceCount = 1;
        commands[writeIndex].firstIndex = 0;
        commands[writeIndex].vertexOffset = 0;
        commands[writeIndex].firstInstance = gID; // 将全局ID传给顶点着色器作索引
    }
}

主程序实现:Vulkan API 调用流程

在 CPU 端,我们的任务变得极其简单。我们不再需要每帧计算裁剪,只需要在录制 Command Buffer 时,编排好 Compute Shader 和 Graphics Shader 的先后顺序,并拉起同步屏障。

步骤一:清零 Draw Count Buffer

在 Compute Shader 运行前,必须将 DrawCountBuffer 的值重置为 0。可以使用 vkCmdFillBuffer 快速完成:

vkCmdFillBuffer(
    commandBuffer,
    drawCountBuffer,
    0,                  // dstOffset
    sizeof(uint32_t),   // size
    0                   // data (清零)
);

步骤二:执行 GPU Culling

绑定 Compute Pipeline 和 Descriptor Sets,分发(Dispatch)计算任务:

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, pipelineLayout, 0, 1, &computeDescriptorSet, 0, nullptr);
vkCmdPushConstants(commandBuffer, pipelineLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(PushConstants), &pushConstants);

uint32_t groupCount = (totalInstances + 63) / 64;
vkCmdDispatch(commandBuffer, groupCount, 1, 1);

步骤三:插入管线屏障(Pipeline Barrier)

这是最关键的一步。Compute Shader 写入了 Buffer,而随后的 Graphics Pipeline 需要读取这些 Buffer 作为间接命令输入。如果不做同步,会导致严重的写后读(RAW)数据竞争。

VkBufferMemoryBarrier barriers[2] = {};

// Indirect Commands Buffer Barrier
barriers[0].sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
barriers[0].srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; // Compute 写
barriers[0].dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT; // 间接命令读
barriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barriers[0].buffer = indirectCommandsBuffer;
barriers[0].offset = 0;
barriers[0].size = VK_WHOLE_SIZE;

// Draw Count Buffer Barrier
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].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barriers[1].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barriers[1].buffer = drawCountBuffer;
barriers[1].offset = 0;
barriers[1].size = VK_WHOLE_SIZE;

vkCmdPipelineBarrier(
    commandBuffer,
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,      // srcStageMask
    VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT,       // dstStageMask
    0,
    0, nullptr,
    2, barriers,                               // Buffer 屏障
    0, nullptr
);

步骤四:间接绘制

现在,所有数据准备就绪。我们直接调用 vkCmdDrawIndexedIndirectCount

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
// 绑定顶点缓冲区、索引缓冲区、常规 Descriptor Sets...

vkCmdDrawIndexedIndirectCount(
    commandBuffer,
    indirectCommandsBuffer,                  // 存放 VkDrawIndexedIndirectCommand 的 Buffer
    0,                                       // offset
    drawCountBuffer,                         // 存放实际绘制次数的 Buffer
    0,                                       // countBufferOffset
    maxDrawCount,                            // 最大允许绘制数(防止意外越界,通常设为 Instance 总数)
    sizeof(VkDrawIndexedIndirectCommand)     // stride
);

关键细节与避坑指南

1. 硬件特性支持

vkCmdDrawIndexedIndirectCount 是 Vulkan 1.2 的核心特性。但在初始化 Device 时,你依然需要显式地启用它:

VkPhysicalDeviceVulkan12Features features12 = {};
features12.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES;
features12.drawIndirectCount = VK_TRUE; // 必须开启

VkPhysicalDeviceFeatures2 deviceFeatures2 = {};
deviceFeatures2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
deviceFeatures2.pNext = &features12;

// 传入 vkCreateDevice...

2. 内存对齐(Stride)

VkDrawIndexedIndirectCommand 在 GPU 端的排布必须严格对齐。stride 参数必须是 4 的倍数(通常刚好是 sizeof(VkDrawIndexedIndirectCommand) = 20 字节)。如果开启了 multiDrawIndirect,请确保物理设备限制中的 minIndirectBufferOffsetAlignment 满足对齐要求。

3. 数据生命周期

由于 CPU 不再介入每帧的提交,CPU 无法通过常规手段获知这一帧到底画了多少个物体。如果你的游戏逻辑(如 UI 上的“当前渲染物体数”计数器)需要这个数值,不要每帧用 vkMapMemory 回读 Count Buffer,这会导致严重的 CPU-GPU 同步阻塞。建议使用 vkCmdCopyBuffer 将数据拷贝到 Host 可见内存,并延迟 2-3 帧进行异步回读。

总结

通过 vkCmdDrawIndexedIndirectCount,Vulkan 释放了其作为现代低开销 API 的完全体实力。CPU 只需在初始化时构建好整套渲染拓扑,运行时便可化身为“纯粹的旁观者”,只负责提交极少数的调度指令。

这种将决策权完全交还给 GPU 的架构,是现代 3D 引擎(如 Unreal Engine 5 的 Nanite、塞尔达传说:王国之泪的渲染器)走向超大规模场景渲染的技术基石。

孤波 VulkanGPU驱动渲染渲染管线

评论点评