WEBKT

彻底告别CPU干预:基于D3D12 Work Graphs的全新渲染流水线设计与实践

22 0 0 0

在现代高画质游戏引擎的设计中,GPU 驱动的渲染管线(GPU-Driven Rendering Pipeline)早已成为行业共识。然而,即便我们在遮挡剔除、LOD 计算等环节实现了完全的 GPU 化,传统的 ExecuteIndirect 方案依然无法彻底摆脱 CPU 的束缚。

Direct3D 12 引入的 Work Graphs(工作图) 是一项颠覆性的技术。它允许 GPU 在不需要 CPU 介入的情况下,自主创建并调度工作负载。本文将探讨如何设计一条基于 Work Graphs 的完全无 CPU 介入渲染流水线,并深度剖析其相比于传统 ExecuteIndirect 的代际优势。


传统 ExecuteIndirect 的痛点:为什么它还不够完美?

在 Work Graphs 出现之前,主流的 GPU-Driven 管线通常依赖 ExecuteIndirect 来实现动态绘制。其基本流程如下:

[CPU: 提交剔除Pass] -> [GPU: 计算Culling] -> [GPU: 将绘制参数写入ArgsBuffer] -> [GPU Barrier] -> [CPU: 发起ExecuteIndirect] -> [GPU: 绘制]

虽然渲染决策被移到了 GPU,但这个模式在底层依然存在显著的性能缺陷:

  1. 频繁的管线屏障与等待(Pipeline Barriers & Bubbles)
    由于 GPU 必须先跑完 Compute Shader 写入 Arguments Buffer,才能被后续的 DrawIndexedInstanced 读取,开发者必须在两者之间插入昂贵的 UAV Barrier(或 Transition Barrier)。这会导致 GPU 计算单元(CU/SM)出现短暂的空闲(Bubble)。
  2. 粗粒度的同步(Sync Granularity)
    ExecuteIndirect 的调度是网格级别(Grid-level)的。即使只有一个实例通过了剔除,GPU 也必须等待整个剔除 Compute Grid 执行完毕并刷新缓存后,才能启动渲染 Pass。
  3. 额外的显存带宽开销(DRAM Roundtrips)
    剔除结果和绘制命令必须写入全局显存(VRAM)的 Args Buffer 中,再由命令处理器读取。这种“写入后再读取”的操作消耗了宝贵的显存带宽。

Work Graphs 核心:一种全新的 GPU 执行模型

Work Graphs 抛弃了传统的“线形/单向”管线提交方式,引入了**生成者-消费者(Producer-Consumer)**的图结构。

在 Work Graphs 中,执行单元被称为节点(Node)。节点之间通过**记录(Records)**传递数据。一个节点(如 Culling 节点)可以直接向另一个节点(如 Rasterization 节点)请求发射(Spawn)工作,而无需通过 CPU 往返或者显存 Buffer 进行中转。

[Entry Node] --(Records)--> [Culling Node] --(Records)--> [Mesh Shader/Draw Node]

所有的工作调度、内存分配和节点流转,全部在 GPU 硬件调度器(Hardware Scheduler)内部完成,对 CPU 完全透明。


基于 Work Graphs 的无 CPU 介入管线设计

我们要设计一条包含实例剔除(Instance Culling)LOD 动态选择、以及**最终网格渲染(Mesh Shading / Draw)**的完整流水线。

1. 拓扑结构设计

我们将整个流水线划分为三个核心节点:

  • Input Entry Node (Broadcasting Launch):负责接收原始的场景实例列表,分发给剔除工作。
  • Culling & LOD Node (Coalescing Launch):执行视锥体与遮挡剔除,计算每个实例的 LOD。通过剔除的实例会被打包生成绘制记录,直接投递给下游的绘制节点。
  • Rasterization Node (Mesh/Compute Shader Node):接收剔除后的网格数据,直接进行渲染输出。

2. HLSL 节点定义与实现

以下是该流水线的核心 HLSL 节点结构设计。

首先定义在节点间传递的数据载荷(Record):

struct InstanceData
{
    float4x4 WorldMatrix;
    uint MeshID;
};

struct CulledDrawRecord
{
    uint MeshID;
    uint LodLevel;
    float4x4 WorldMatrix;
};

接下来是剔除与 LOD 节点。这里采用 [NodeLaunch("coalescing")] 属性,它能将零散生成的输入记录自动合并为批次执行,极大提高硬件利用率:

// 定义最大输出记录数
[Shader("node")]
[NodeLaunch("coalescing")]
[NodeDispatchDimensions(64, 1, 1)] // 每个工作组处理64个实例
void CullAndLODNode(
    uint recordCount : SV_NumRecords,
    RecordArrayIterator<InstanceData> inputRecords,
    [MaxRecords(64)] NodeOutput<CulledDrawRecord> outputDrawQueue
)
{
    // 1. 获取当前线程要处理的实例数据
    uint localThreadId = SV_GroupThreadID.x;
    InstanceData instance = inputRecords[localThreadId];

    // 2. 执行视锥体裁剪与LOD计算(示意逻辑)
    bool isVisible = FrustumCull(instance.WorldMatrix);
    uint targetLod = CalculateLOD(instance.WorldMatrix);

    // 3. 动态申请输出记录空间
    // 只有通过剔除的实例才会占用输出插槽
    uint activeCount = WaveActiveCountBits(isVisible);
    uint waveOffset = WavePrefixCountBits(isVisible);

    GroupNodeOutputRecords<CulledDrawRecord> outRecords = 
        outputDrawQueue.GetGroupNodeOutputRecords(activeCount);

    if (isVisible)
    {
        CulledDrawRecord record;
        record.MeshID = instance.MeshID;
        record.LodLevel = targetLod;
        record.WorldMatrix = instance.WorldMatrix;
        
        outRecords[waveOffset] = record;
    }
    
    // 硬件将自动把 outRecords 路由给下游的绘制节点,无需任何 Barrier
}

最后是绘制消费者节点。在 Work Graphs 中,下游节点可以直接是 Mesh Shader 或者是兼容的 Compute Shader:

[Shader("node")]
[NodeLaunch("broadcasting")]
[NodeDispatchDimensions(32, 1, 1)]
void RasterizeNode(
    DispatchNodeInputRecord<CulledDrawRecord> drawRecord,
    uint3 threadId : SV_GroupThreadID
)
{
    // 直接消费上游传递过来的数据,进行Meshlet解码与渲染
    uint meshID = drawRecord.MeshID;
    float4x4 mat = drawRecord.WorldMatrix;

    // Mesh Shading 阶段的处理
    // ...
}

为什么 Work Graphs 能够降维打击 ExecuteIndirect

通过上述设计,我们可以清晰地看到 Work Graphs 在架构层面的巨大优势:

1. 消除 Pipeline Barrier,释放 GPU 吞吐量

ExecuteIndirect 方案中,CullPassDrawPass 是串行的。而在 Work Graphs 中,只要 CullNode 输出了一条 CulledDrawRecord,对应的 RasterizeNode 就可以立刻在空闲的计算单元上启动
GPU 硬件内部会自动处理数据流的依赖关系,不再需要开发者手动插入 UAV Barrier,从而彻底消除了流水线空闲期(Bubbles),实现了真正的并发执行。

特性 ExecuteIndirect Work Graphs
执行单元切换 强同步(Barrier 等待) 异步、微任务级流转
GPU 资源状态 需要频繁切换 Resource State 内部节点管理,无状态切换损耗

2. 本地化数据传输(On-Chip Memory VS VRAM)

ExecuteIndirect 强制要求将绘制参数写回显存(VRAM)。
Work Graphs 的 Record 传递机制允许驱动程序将这些小型的 Record 数据直接保存在 **GPU 高速缓存(LDS / L1 Cache)**中。数据还没落入 VRAM,就已经被下游节点消费掉了。这极大地减轻了现代游戏在超多实例渲染时的显存带宽瓶颈。

3. 解决“空尾(Tail Effects)”问题

传统的 Compute Shader 调度必须预先分配固定的 Grid 大小。如果剔除后剩下的实例极少,后续的 Shader 依然要消耗整组 Thread 去空转。
Work Graphs 具有**动态分支深度(Dynamic Branching Depth)**的特性。如果上游只输出了 3 个有效实例,下游节点就只会启动 3 个线程实例。这种按需分配的粒度是 ExecuteIndirect 无法企及的。

4. 彻底解耦 CPU

使用 Work Graphs 后,CPU 的工作精简到了极致:

// 每一帧,CPU 只需要下达一次启动图的指令
ID3D12GraphicsCommandList9::ExecuteBundle / DispatchGraph(...);

之后不管是 10 万个实例的剔除,还是复杂的层级裁剪、粒子系统裂变,全部在 GPU 内部自我循环。CPU 线程可以被彻底释放去处理游戏逻辑、物理模拟或音频。


生产环境实践建议

尽管 Work Graphs 带来了梦幻般的特性,但在实际落地时,开发者仍需注意以下限制:

  1. Backing Store 预估
    Work Graphs 需要在显存中申请一块 Backing Store 作为内部调度的临时缓冲。如果图结构深度极深、生成的数据量暴增,可能会导致内存溢出。需要对最大并发 Record 数进行精确的数学估算,避免过度分配。
  2. 硬件兼容性
    该技术需要硬件与驱动层面的深度支持(如 NVIDIA Ada Lovelace / AMD RDNA3 及以上架构),并且需要使用 Agility SDK 1.613 或更高版本。对于老旧设备,仍需保留 ExecuteIndirect 作为 Backp 方案。
  3. 调试工具链
    传统的 RenderDoc 在调试 Work Graphs 时可能无法像以前那样线性抓取 Buffer。建议深度依赖 PIX on Windows,它提供了对 Work Graphs 执行拓扑、节点延迟和 Record 吞吐的专属可视化分析面板。

总结

Work Graphs 的推出标志着 GPU-Driven 渲染管线迈入了真正意义上的“自治阶段”。通过消除无谓的 CPU 等待、降解冗余的显存屏障,它不仅解放了 CPU,更重构了 GPU 本身的任务调度逻辑。对于追求极致性能的 3D 引擎开发者而言,越早将管线向 Work Graphs 迁移,越能在下一代图形技术竞争中占据先机。

基卡管线师 GPU驱动渲染

评论点评