彻底告别CPU干预:基于D3D12 Work Graphs的全新渲染流水线设计与实践
在现代高画质游戏引擎的设计中,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,但这个模式在底层依然存在显著的性能缺陷:
- 频繁的管线屏障与等待(Pipeline Barriers & Bubbles):
由于 GPU 必须先跑完 Compute Shader 写入 Arguments Buffer,才能被后续的DrawIndexedInstanced读取,开发者必须在两者之间插入昂贵的 UAV Barrier(或 Transition Barrier)。这会导致 GPU 计算单元(CU/SM)出现短暂的空闲(Bubble)。 - 粗粒度的同步(Sync Granularity):
ExecuteIndirect的调度是网格级别(Grid-level)的。即使只有一个实例通过了剔除,GPU 也必须等待整个剔除 Compute Grid 执行完毕并刷新缓存后,才能启动渲染 Pass。 - 额外的显存带宽开销(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 方案中,CullPass 和 DrawPass 是串行的。而在 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 带来了梦幻般的特性,但在实际落地时,开发者仍需注意以下限制:
- Backing Store 预估:
Work Graphs 需要在显存中申请一块Backing Store作为内部调度的临时缓冲。如果图结构深度极深、生成的数据量暴增,可能会导致内存溢出。需要对最大并发 Record 数进行精确的数学估算,避免过度分配。 - 硬件兼容性:
该技术需要硬件与驱动层面的深度支持(如 NVIDIA Ada Lovelace / AMD RDNA3 及以上架构),并且需要使用 Agility SDK 1.613 或更高版本。对于老旧设备,仍需保留ExecuteIndirect作为 Backp 方案。 - 调试工具链:
传统的 RenderDoc 在调试 Work Graphs 时可能无法像以前那样线性抓取 Buffer。建议深度依赖 PIX on Windows,它提供了对 Work Graphs 执行拓扑、节点延迟和 Record 吞吐的专属可视化分析面板。
总结
Work Graphs 的推出标志着 GPU-Driven 渲染管线迈入了真正意义上的“自治阶段”。通过消除无谓的 CPU 等待、降解冗余的显存屏障,它不仅解放了 CPU,更重构了 GPU 本身的任务调度逻辑。对于追求极致性能的 3D 引擎开发者而言,越早将管线向 Work Graphs 迁移,越能在下一代图形技术竞争中占据先机。