WEBKT

GPU-Driven管线中多材质变体的ExecuteIndirect分批调度方案

23 0 0 0

在现代GPU-Driven渲染管线中,ExecuteIndirect(DX12)或 vkCmdDrawIndexedIndirectCount(Vulkan)是实现超多实例剔除与绘制的核心技术。然而,当场景中存在大量不同材质(PSO变体)的对象时,管线会遭遇一个经典矛盾:GPU-driven要求尽量减少CPU提交次数(合并为一个或少数几个Draw Call),但不同的材质变体必须绑定不同的PSO,而一个ExecuteIndirect命令本身无法在执行中途动态切换PSO。

为了解决多变体材质与GPU驱动绘制的冲突,工业界主流的做法是采用GPU端分类(Binning/Classification)与多批次ExecuteIndirect调度。本文将拆解这一方案的架构设计、GPU分类着色器实现以及CPU端的高效调度细节。


1. 核心架构设计

解决该问题的核心思想是:CPU依然按照PSO来组织批次,但具体的裁剪、实例过滤、绘制参数装配全部移交到GPU端进行分类(Binning)写入。

 [ 原始Instance Buffer ]
          │
          ▼ (Compute Shader 裁剪与分类)
 ┌──────────────────────────────────────────────┐
 │ 根据物体的PSO ID,分流到对应的 Indirect Argument Buffer  │
 └──────────────────────────────────────────────┘
    │                  │                  │
    ▼                  ▼                  ▼
[PSO 0 Arg Buffer] [PSO 1 Arg Buffer] [PSO 2 Arg Buffer]
    │                  │                  │
    ▼                  ▼                  ▼
 [ExecuteIndirect]  [ExecuteIndirect]  [ExecuteIndirect] (CPU循环发射)

数据结构设计

为了实现高效的分流,我们需要准备以下几种缓冲区:

  1. Global Instance Buffer (SRV):存放场景中所有实例的原始数据(变换矩阵、材质ID、包围盒等)。
  2. Instance Index Mapping Buffer (SRV):一个索引数组,记录每个PSO包含了哪些原始Instance的ID,用于避免Compute Shader全量遍历。
  3. PSO Argument Buffers (UAV/SRV):为每一个PSO分配一段连续的Indirect Draw Argument空间。
  4. PSO Count Buffer (UAV/SRV):存放每个PSO当前帧实际通过剔除的实例数量(用于作为ExecuteIndirect的CounterBuffer)。

2. GPU端分类与剔除(Compute Shader)

在Compute Shader中,我们不仅要进行视锥体/遮挡剔除,还要根据实例所属的PSO,将绘制命令写入到对应PSO的参数缓冲区中。

缓冲区定义 (HLSL)

struct DrawIndexedArguments
{
    uint IndexCountPerInstance;
    uint InstanceCount;
    uint StartIndexLocation;
    int  BaseVertexLocation;
    uint StartInstanceLocation;
};

// 原始实例数据
struct InstanceData
{
    float4x4 LocalToWorld;
    float3 Center;
    float3 Extents;
    uint PSOIndex; // 该实例对应的PSO ID
    uint MeshIndex; // 对应的网格数据索引
    // ... 其他材质参数
};

StructuredBuffer<InstanceData> g_GlobalInstances : register(t0);
StructuredBuffer<uint> g_ActiveInstanceIndices : register(t1); // 待处理的实例索引流

// 每个PSO拥有独立的Draw Call参数区
// 实际应用中,可以整合成一个大Buffer,利用Offset区分不同PSO
RWStructuredBuffer<DrawIndexedArguments> g_PSOArgumentBuffers : register(u0);
RWStructuredBuffer<uint> g_PSOCountBuffer : register(u1); // 记录每个PSO通过剔除的数量

分类与写入逻辑

假设我们已经通过CPU将同类PSO的物体归类。为了提高并行的写入效率,我们在GPU上通过 InterlockedAdd 动态获取每个PSO的写入位置:

[numthreads(64, 1, 1)]
void CS_GPU_Binning(uint3 dtid : SV_DispatchThreadID)
{
    uint totalInstances = g_ActiveInstanceCount; // 由ConstantBuffer传入
    if (dtid.x >= totalInstances) return;

    uint instanceIdx = g_ActiveInstanceIndices[dtid.x];
    InstanceData instance = g_GlobalInstances[instanceIdx];

    // 1. 视锥体裁剪 (Culling)
    if (IsCulled(instance.Center, instance.Extents))
    {
        return; 
    }

    // 2. 获取该实例所属的PSO索引
    uint psoIdx = instance.PSOIndex;

    // 3. 动态向该PSO的计数器累加1,获取当前实例在当前PSO绘制列表中的唯一偏移
    uint drawOffset;
    InterlockedAdd(g_PSOCountBuffer[psoIdx], 1, drawOffset);

    // 4. 填充对应的Indirect Draw Argument
    // 注意:这里的具体装配方式取决于你是采用 "Multi-Draw" 还是 "One Draw per Instance"
    // 方案 A: 每一个通过剔除的物体,都作为独立的一行 DrawArgument 写入 (适用于多Mesh、多子网格情况)
    DrawIndexedArguments arg;
    arg.IndexCountPerInstance = GetIndexCount(instance.MeshIndex);
    arg.InstanceCount = 1; // 每个Argument对应一个Instance
    arg.StartIndexLocation = GetStartIndexLocation(instance.MeshIndex);
    arg.BaseVertexLocation = GetBaseVertexLocation(instance.MeshIndex);
    arg.StartInstanceLocation = instanceIdx; // 将原始InstanceID传给VS,用于拉取矩阵

    // 写入到该PSO对应的Argument Buffer段落中
    // 假设每个PSO在Buffer中预留了 MaxInstancesPerPSO 的空间
    uint writeIndex = psoIdx * g_MaxInstancesPerPSO + drawOffset;
    g_PSOArgumentBuffers[writeIndex] = arg;
}

3. CPU端的调度与状态屏障

GPU分类完成后,g_PSOArgumentBuffersg_PSOCountBuffer 已经填充完毕。接下来的任务是在CPU端安全、高效地依次执行这些 ExecuteIndirect

状态过渡 (Barrier)

在Compute Shader写入完成后,这两个Buffer处于 D3D12_RESOURCE_STATE_UNORDERED_ACCESS。在调用 ExecuteIndirect 之前,必须将它们转换为只读状态:

D3D12_RESOURCE_BARRIER barriers[2];
barriers[0] = CD3DX12_RESOURCE_BARRIER::Transition(
    g_PSOArgumentBuffers.Get(),
    D3D12_RESOURCE_STATE_UNORDERED_ACCESS,
    D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT
);
barriers[1] = CD3DX12_RESOURCE_BARRIER::Transition(
    g_PSOCountBuffer.Get(),
    D3D12_RESOURCE_STATE_UNORDERED_ACCESS,
    D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT
);
commandList->ResourceBarrier(2, barriers);

CPU分批分发

在CPU端,我们循环遍历所有活跃的PSO。虽然有循环,但由于所有裁剪和数据组装已经在GPU上完成,这里的循环不包含复杂的CPU逻辑,仅仅是快速的状态切换和绘制指令发射:

// 预先创建好的 Command Signature
// 因为是DrawIndexedInstanced类型,所以签名大小固定
ID3D12CommandSignature* commandSignature = RenderSystem::GetDrawIndexedSignature();

for (uint32_t psoIdx = 0; psoIdx < totalPSOCount; ++psoIdx)
{
    // 1. 如果该PSO在本帧没有任何实例,是否需要跳过?
    // 注意:直接在CPU侧判断CountBuffer的值会引入GPU-CPU同步(回读),这会造成致命的流水线停顿(Stall)。
    // 正确的做法是:直接无脑调用 ExecuteIndirect。
    // 如果 CountBuffer 中记录的值为 0,GPU会极快地跳过这个调用,几乎无开销。

    // 2. 绑定对应的PSO和Root Signature
    commandList->SetPipelineState(g_PSOPool[psoIdx]);

    // 3. 计算偏移量
    uint32_t argumentBufferOffset = psoIdx * g_MaxInstancesPerPSO * sizeof(DrawIndexedArguments);
    uint32_t countBufferOffset = psoIdx * sizeof(uint32_t);

    // 4. 发射 ExecuteIndirect
    commandList->ExecuteIndirect(
        commandSignature,
        g_MaxInstancesPerPSO, // 最大可能绘制的批次数
        g_PSOArgumentBuffers.Get(),
        argumentBufferOffset,
        g_PSOCountBuffer.Get(),
        countBufferOffset
    );
}

4. 关键瓶颈与避坑指南

1. 规避空PSO调度的开销 (Empty Dispatch Optimization)

虽然上文提到“无脑发射ExecuteIndirect,Count为0时开销很小”,但在拥有成百上千个PSO变体的复杂引擎中,哪怕是切换PSO本身(PipelineState Switch)也会带来不可忽视的CPU/GPU上下文开销。

解决方案:
在GPU Binning阶段,引入一个额外的 Global Active PSO Bitmask

  • 在GPU上,当某个PSO的 InterlockedAdd 返回的旧值是 0 时(说明它是该PSO的第一个通过剔除的实例),使用 InterlockedOr 将全局活跃PSO掩码的对应位设为1。
  • 在当前帧结束时,将这个轻量级的 Bitmask 写回到一个极小的 Buffer。
  • 回读优化:如果能容忍一帧延迟,可以在下一帧CPU端读回这个Bitmask,在CPU端直接跳过未激活PSO的 ExecuteIndirectSetPipelineState 绑定。

2. 内存浪费与动态显存分配

每个PSO如果都分配一个 MaxInstancesPerPSO 大小的Argument空间,面对成百上千个变体,显存消耗会呈指数级上升。

  • 优化策略: 采用 两级Binning。第一阶段只做 Culling 和计数,得出每个PSO本帧实际需要的空间大小;第二阶段利用前缀和(Prefix Sum / Scan)在GPU端计算出每个PSO在统一连续Buffer中的精确Offset,再安全写入。这种方法能将显存利用率提升至100%,但会增加Compute Shader的Pass数,需要根据场景的复杂程度做权衡。

3. 命令签名(Command Signature)的设计

在多变体渲染中,不同材质可能需要不同的常量(Constant)或资源绑定(SRV)。如果仅靠DrawArgument无法满足,可以在 Command Signature 中加入 D3D12_INDIRECT_ARGUMENT_TYPE_CONSTANTD3D12_INDIRECT_ARGUMENT_TYPE_CONSTANT_BUFFER_VIEW,将材质参数索引直接通过Indirect Parameter注入到 Shader 中,实现彻底的“真·GPU-driven 材质系统”。

摩尔纹渲染工 GPU驱动渲染DirectX12

评论点评