GRAPHICS × PRODUCTION
中EN

皮了个牛

↓

计算机图形学

Unity SRP 02:迁移至 Render Graph

记录 Unity SRP 从手动资源管理迁移至 Render Graph 的完整路径:初始化、Pass 依赖声明、DAG 编译、拓扑排序、Dead Pass Elimination、资源池化与 Aliasing,并落到 Depth/GBuffer/Async Compute、主循环、生命周期和 Unreal RDG 对比。

2020-01-0634 分钟阅读
Unity SRP 02:迁移至 Render Graph
AI 生成概念封面

一、背景

在查阅 Core RP 源码时发现 Unity 已经实现了 Render Graph(RG)系统,于是把手动管理的 Pre-Z + GBuffer 管线迁移到 RG 框架下。

手动管理模式的核心问题在于资源生命周期完全由开发者控制:RT 在 Pipeline 构造函数中一次性分配,在 Dispose 中释放,中间无论某帧是否实际使用某张 RT,它都持续占用显存。随着管线功能增长(SSR、SSAO、Bloom、TAA 各需多张 RT),峰值显存的膨胀变得难以控制。此外,手动管理模式下 Pass 之间的执行顺序和资源依赖完全靠代码的书写顺序保证,重构一个 Pass 的位置可能引发难以排查的时序 Bug。

RG 通过声明式的图结构同时解决这两个问题:资源按需分配/释放,执行顺序由数据依赖自动推导。

二、Render Graph 初始化

RG 需要实例化一个 RenderGraph 对象,在 SRP 的构造函数中完成初始化。RG 负责管理整个帧的 Pass 依赖关系和资源生命周期。

public class MyRenderPipeline : RenderPipeline
{
    private RenderGraph m_RenderGraph;

    public MyRenderPipeline()
    {
        m_RenderGraph = new RenderGraph("MyPipeline_RenderGraph");
    }

    protected override void Dispose(bool disposing)
    {
        m_RenderGraph.Cleanup();
        m_RenderGraph = null;
    }
}

RenderGraph 对象本身是无状态的,不持有跨帧数据。每帧调用 BeginRecording() 开始图的构建,EndRecordingAndExecute() 触发编译和执行。两次调用之间的所有 AddRenderPass 注册构成当前帧的完整 Pass 图。

Render Graph 初始化:

private RenderGraph RenderGraph;

public InfinityRenderPipeline()
{
    RenderGraph = new RenderGraph(false, MSAASamples.None);
    RenderGraph.RegisterDebug();
}

三、Pass 结构设计

将 SRP 类改为 partial class,每个 Pass 独立为一个文件(如 DepthPass.cs)。每个 Pass 包含两个数据结构:

  • PassData — 当前 Pass 执行所需的参数(输入资源句柄、配置参数)
  • PassOutput — 当前 Pass 输出给后续 Pass 的资源句柄
// DepthPass.cs
partial class MyRenderPipeline
{
    class DepthPassData
    {
        public RendererListHandle rendererList;
        public TextureHandle depthTexture;
    }

    struct DepthPassOutput
    {
        public TextureHandle depth;
    }
}

RG 中的资源类型分为:

  • RenderGraphResource — 只读资源
  • RenderGraphMutableResource — 可读写资源

两者的生命周期均由 RG 自动管理,开发者无需手动处理创建和销毁。

3.1 隐式 vs 显式依赖声明

RG 中 Pass 之间的依赖有两种建立方式:

隐式依赖(数据流驱动):将 Pass A 的 Output Handle 作为 Pass B 的 Input 传入,RG 编译器在构建 DAG 时自动识别这条数据边,确保 A 先于 B 执行。这是最常用的方式,代码自然地表达了"B 需要 A 的结果"。

var depthOutput = AddDepthPass(builder, cullingResult);
// depthOutput.depth 作为 GBuffer Pass 的输入 → 隐式建立 Depth → GBuffer 的依赖
var gbufferOutput = AddGBufferPass(builder, cullingResult, depthOutput);

显式依赖:某些场景下 Pass 之间存在执行顺序要求,但不通过资源流动体现。例如一个 Debug Visualization Pass 需要在所有 Scene Pass 之后执行,但它不读取任何 Scene Pass 的输出。此时可以通过 builder.DependsOn(passHandle) 显式声明顺序约束。

显式依赖的使用场景有限。在设计良好的管线中,几乎所有排序需求都可以通过数据流的输入输出关系自然表达。如果发现频繁使用显式依赖,通常意味着 Pass 的拆分粒度或资源设计需要重新审视。

四、Render Graph 的 DAG 编译过程

RG 在 EndRecordingAndExecute() 内部执行完整的编译流程,分为以下阶段:

4.1 Pass 排序(Topological Sort)

所有注册的 Pass 构成一个 DAG(Directed Acyclic Graph),边的方向表示数据/执行依赖。编译器对 DAG 执行拓扑排序,生成满足所有依赖约束的线性执行序列。如果存在环形依赖(A 依赖 B 的输出,B 同时依赖 A 的输出),编译器会抛出错误。

拓扑排序不唯一。对于无依赖关系的 Pass,其相对顺序由注册顺序决定。开发者的 AddRenderPass 调用顺序在无依赖冲突时保持稳定,行为可预测。

4.2 Dead Pass Elimination

拓扑排序完成后,编译器从图的终端节点(将结果写入 BackBuffer 或 External Texture 的 Pass)反向遍历 DAG。任何不在从终端节点出发的反向可达集合中的 Pass,被标记为 Dead Pass 并从执行序列中移除。

算法步骤:

  1. 标记所有将结果写入外部资源(ImportTexture/ImportBuffer 声明的资源或 BackBuffer)的 Pass 为"活跃"
  2. 从活跃 Pass 沿依赖边反向遍历,标记所有前驱 Pass 为活跃
  3. 遍历结束后,未被标记的 Pass 即为 Dead Pass

Dead Pass Elimination 的实用意义:开发者可以声明式地注册所有可能的 Pass(如 Debug、Analytics、Optional Effects),而无需关心当前帧是否实际需要它们。只要某个 Optional Pass 的输出没有被活跃路径引用,它会被自动跳过,零运行时开销。

// 即使注册了 Debug Pass,如果 finalOutput 没有引用 debugOutput,
// 编译器会自动剔除 Debug Pass
var debugOutput = AddDebugPass(builder, gbufferOutput);
var finalOutput = AddComposePass(builder, gbufferOutput);  // 只引用 gbufferOutput
// → Debug Pass 被判定为 Dead Pass,不执行

4.3 资源分配与生命周期确定

编译器在排序后的执行序列上扫描每个资源的首次使用和末次使用位置:

  • 首次使用位置:该资源在执行序列中第一次作为某个 Pass 的输出/输入出现的位置 → 分配时机
  • 末次使用位置:该资源在执行序列中最后一次作为某个 Pass 的输入出现的位置 → 释放时机

编译器据此为每个资源生成一对 Allocate/Release 指令,插入到对应 Pass 的前后。

4.4 Barrier 推导

在现代图形 API(DX12/Vulkan/Metal)中,资源状态转换需要显式 Barrier。RG 编译器根据资源在相邻 Pass 中的使用方式(写→读、读→写、写→写)自动插入 Barrier:

前序使用后续使用需要的 Barrier
RT WriteTexture ReadRenderTarget → ShaderResource
Texture ReadRT WriteShaderResource → RenderTarget
UAV WriteUAV ReadUAV → ShaderResource
UAV WriteUAV WriteUAV → UAV(执行依赖)

手动管线中这些 Barrier 需要开发者显式插入(在 Unity 层面体现为正确的 cmd.SetRenderTarget 顺序和 cmd.ReleaseTemporaryRT 时机),RG 则完全自动化。

五、RG 资源池化的实现策略

5.1 Texture Pool 的规格匹配

RG 内部维护一个 Texture Pool,存放已释放但未销毁的 RT 资源。当新 Pass 需要分配 RT 时,Pool 首先尝试匹配已有的空闲 RT:

匹配条件(全部满足才复用):

  • 分辨率相同(Width × Height)
  • 格式相同(如 R8G8B8A8_UNorm、R16G16B16A16_Float)
  • MSAA Sample Count 相同
  • Dimension 相同(2D / 3D / Cube)

如果没有完全匹配的空闲 RT,Pool 创建新的 RT。已有但规格不匹配的空闲 RT 在超过一定帧数未被使用后被释放(避免内存无限增长)。

5.2 资源 Aliasing

对于生命周期不重叠的资源,RG 可以将它们映射到同一块物理内存。例如:

Pass 1: 写入 TempA (生命周期: Pass 1 ~ Pass 3)
Pass 2: 写入 TempB (生命周期: Pass 2 ~ Pass 4)
Pass 4: TempA 释放
Pass 5: 写入 TempC (生命周期: Pass 5 ~ Pass 7)

TempA 和 TempC 的生命周期不重叠(TempA 在 Pass 4 结束,TempC 在 Pass 5 开始),因此 TempC 可以复用 TempA 的物理内存。在 DX12 Placed Resources、Vulkan Memory Aliasing 这类支持 Memory Aliasing 的图形 API 上,这能显著降低峰值显存。

Unity 的 RG 在当前版本(2021.x)中主要通过 Pool 级别的复用实现 Aliasing,即 TempC 分配时从 Pool 取到了 TempA 之前释放的 RT。更激进的基于 Memory Heap 的 Aliasing 把多个逻辑资源绑定到同一段 Heap 内存,这种方案在 HDRP 的某些路径中有限使用。

六、Depth Pass 实现

Texture 创建和 Renderer List 构建通过 GraphBuilder 完成。使用 GraphBuilder.UseDepthBuffer() 绑定 Depth Buffer,使用 GraphBuilder.UseRendererList() 绑定绘制对象列表,并将它们注册为该 Pass 的资源。

通过 AddRenderPass<T>() 声明 Pass 的参数类型,在 GraphBuilder.SetRenderFunc() 中获取 RenderGraphContext(封装了 ScriptableRenderContext 和 CommandBuffer)以及已注册的资源参数,随后执行具体的 CommandBuffer 操作(Set / Draw / Dispatch)。

绘制逻辑被封装为 DrawRendererList() 方法供后续 Pass 复用。其完整实现包含 isValid 检查、stateBlock 分支判断以及 Instancing/Dynamic Batching 的开关设置:

public static void DrawRendererList(ScriptableRenderContext renderContext,
    RendererList rendererList, CommandBuffer cmd)
{
    if (!rendererList.isValid) {
        throw new ArgumentException("Invalid renderer list provided to DrawRendererList");
    }

    rendererList.drawSettings.enableInstancing = true;
    rendererList.drawSettings.enableDynamicBatching = true;

    renderContext.ExecuteCommandBuffer(cmd);
    cmd.Clear();

    if (rendererList.stateBlock == null) {
        renderContext.DrawRenderers(rendererList.cullingResult,
            ref rendererList.drawSettings, ref rendererList.filteringSettings);
    } else {
        var stateBlock = rendererList.stateBlock.Value;
        renderContext.DrawRenderers(rendererList.cullingResult,
            ref rendererList.drawSettings, ref rendererList.filteringSettings, ref stateBlock);
    }
}

这个封装的要点:先 ExecuteCommandBuffer 并 Clear 确保之前录制的命令已提交(如 SetRenderTarget),再通过 context.DrawRenderers 执行绘制。stateBlock 的 null 分支用于处理是否需要强制覆盖渲染状态(如 Override Depth/Stencil)的情况。

DepthPassOutput AddDepthPass(RenderGraph graph, CullingResults culling)
{
    using (var builder = graph.AddRenderPass<DepthPassData>("DepthPass", out var passData))
    {
        // 创建 Depth Texture
        var depthDesc = new TextureDesc(Screen.width, Screen.height)
        {
            depthBufferBits = DepthBits.Depth32,
            clearBuffer = true,
            clearColor = Color.black,
            name = "DepthBuffer"
        };
        passData.depthTexture = builder.UseDepthBuffer(
            graph.CreateTexture(depthDesc), DepthAccess.Write);

        // 创建 Renderer List
        var listDesc = new RendererListDesc(new ShaderTagId("DepthOnly"), culling, camera)
        {
            sortingCriteria = SortingCriteria.CommonOpaque,
            renderQueueRange = RenderQueueRange.opaque
        };
        passData.rendererList = builder.UseRendererList(
            graph.CreateRendererList(listDesc));

        builder.SetRenderFunc((DepthPassData data, RenderGraphContext ctx) =>
        {
            CoreUtils.DrawRendererList(ctx.renderContext, ctx.cmd, data.rendererList);
        });

        return new DepthPassOutput { depth = passData.depthTexture };
    }
}
// Depth Pass 输出
var depthOutput = AddDepthPass(builder, cullingResult);

// GBuffer Pass 声明对 Depth 的依赖
var gbufferOutput = AddGBufferPass(builder, cullingResult, depthOutput);
// RG 根据 input 参数自动推导执行顺序

// 最终 Compose Pass
AddComposePass(builder, gbufferOutput);

RG 的依赖关系通过 Pass 之间的数据流(输出→输入)隐式建立。开发者无需手动指定执行顺序:只要将前一个 Pass 的 Output Handle 作为后续 Pass 的 Input 传入,RG 在编译时自动建立 DAG 拓扑。如果某个 Pass 的输出未被任何后续 Pass 引用,RG 会自动将其剔除(Dead Pass Elimination)。

七、GBuffer Pass 实现

GBuffer Pass 的结构与 Depth Pass 基本一致,区别在于:

  • 增加 GraphBuilder.UseColorBuffer() 注册多个 SV_Target 输出
  • 输入依赖为 Depth Pass 的 Output,RG 据此自动建立 Pass 间的执行依赖,GBuffer Pass 可直接读取前一步写入的深度数据

4 个 GBuffer RT 的具体格式如下:

RT名称GraphicsFormat用途
0GBufferBaseColorR8G8B8A8_UNormAlbedo + 保留
1GBufferMicrofaceR8G8B8A8_UNormRoughness / Metallic / AO
2GBufferNormalA2B10G10R10_UNormPack3210bit 精度法线(Octahedral)
3GBufferEmissiveR16G16B16A16_SFloat半精度浮点自发光
Pass_GBufferOutput RenderPass_ThinGBuffer(Camera RenderCamera, RenderGraph RenderGraph,
    CullingResults CullingData, RenderGraphMutableResource DepthBuffer)
{
    Pass_GBufferOutput GBufferPassOut;
    using (var GraphBuilder = RenderGraph.AddRenderPass<Pass_GBufferData>(
        "InfinityPass_GBuffer", out var GBufferData, CustomSamplerId.GBuffer.GetSampler()))
    {
        // 声明 GBuffer RT(4 张 MRT)
        TextureDesc GBufferBaseColorDesc = new TextureDesc(Vector2.one, true, true) {
            clearBuffer = true, clearColor = Color.clear,
            name = "GBufferBaseColor",
            colorFormat = GraphicsFormat.R8G8B8A8_UNorm,
        };
        TextureDesc GBufferMicrofaceDesc = new TextureDesc(Vector2.one, true, true) {
            clearBuffer = true, clearColor = Color.clear,
            name = "GBufferMicroface",
            colorFormat = GraphicsFormat.R8G8B8A8_UNorm,
        };
        TextureDesc GBufferNormalDesc = new TextureDesc(Vector2.one, true, true) {
            clearBuffer = true, clearColor = Color.clear,
            name = "GBufferNormal",
            colorFormat = GraphicsFormat.A2B10G10R10_UNormPack32,
        };
        TextureDesc GBufferEmissiveDesc = new TextureDesc(Vector2.one, true, true) {
            clearBuffer = true, clearColor = Color.clear,
            name = "GBufferEmissive",
            colorFormat = GraphicsFormat.R16G16B16A16_SFloat,
        };

        // 读取 Depth Pass 输出 → 建立依赖
        GraphBuilder.UseDepthBuffer(DepthBuffer, DepthAccess.Read);
        GBufferData.GBufferBaseColor = GraphBuilder.UseColorBuffer(
            RenderGraph.CreateTexture(GBufferBaseColorDesc, InfinityShaderIDs.RT_GBufferBaseColor), 0);
        GBufferData.GBufferMicroface = GraphBuilder.UseColorBuffer(
            RenderGraph.CreateTexture(GBufferMicrofaceDesc, InfinityShaderIDs.RT_GBufferMicroface), 1);
        GBufferData.GBufferWorldNormal = GraphBuilder.UseColorBuffer(
            RenderGraph.CreateTexture(GBufferNormalDesc, InfinityShaderIDs.RT_GBufferNormal), 2);
        GBufferData.GBufferEmissive = GraphBuilder.UseColorBuffer(
            RenderGraph.CreateTexture(GBufferEmissiveDesc, InfinityShaderIDs.RT_GBufferEmissive), 3);

        GBufferData.RendererList = GraphBuilder.UseRendererList(
            RenderGraph.CreateRendererList(
                CreateOpaqueRendererListDesc(CullingData, RenderCamera, InfinityPassIDs.GBufferPass)));

        GraphBuilder.SetRenderFunc((Pass_GBufferData PassData, RenderGraphContext ctx) =>
        {
            DrawRendererList(ctx.renderContext,
                ctx.resources.GetRendererList(PassData.RendererList), ctx.cmd);
        });

        GBufferPassOut.GBufferBaseColor = GBufferData.GBufferBaseColor;
        GBufferPassOut.GBufferMicroface = GBufferData.GBufferMicroface;
        GBufferPassOut.GBufferWorldNormal = GBufferData.GBufferWorldNormal;
        GBufferPassOut.GBufferEmissive = GBufferData.GBufferEmissive;
    }
    return GBufferPassOut;
}

八、Async Compute Pass 在 RG 中的声明

RG 支持将特定 Pass 标记为 Async Compute,使其在独立的 Compute Queue 上与 Graphics Queue 并行执行。声明方式:

using (var builder = graph.AddRenderPass<SSAOPassData>("SSAO_Async", out var passData))
{
    builder.EnableAsyncCompute(true);  // 标记为 Async Compute Pass
    
    passData.depthInput = builder.ReadTexture(depthOutput.depth);
    passData.normalInput = builder.ReadTexture(gbufferOutput.normal);
    passData.aoOutput = builder.WriteTexture(
        graph.CreateTexture(new TextureDesc(w, h) { enableRandomWrite = true }));

    builder.SetRenderFunc((SSAOPassData data, RenderGraphContext ctx) =>
    {
        ctx.cmd.DispatchCompute(ssaoCS, kernelIndex, groupsX, groupsY, 1);
    });
}

Async Compute 的约束:

  • Pass 内只能使用 Compute Shader(DispatchCompute),不能执行光栅化绘制
  • 输入资源必须声明为 Read(不能同时被 Graphics Queue 写入)
  • 输出资源必须启用 enableRandomWrite(UAV)
  • RG 编译器自动在 Async Pass 前后插入 Fence 同步点

RG 编译器在处理 Async Pass 时,会分析其与 Graphics Queue Pass 的依赖关系。如果 Async Pass 的输入在某个 Graphics Pass 之后才就绪,编译器在 Async Pass 开始前插入 Wait Fence;如果后续 Graphics Pass 需要 Async Pass 的输出,编译器在该 Graphics Pass 开始前插入 Wait Fence。开发者只需声明数据依赖,同步逻辑由编译器自动生成。

实际收益取决于 GPU 的 Async Compute 能力。Nvidia 的 Async Compute Engine 在 ALU-bound 的 Compute Shader(如 SSAO、Light Culling)与 Bandwidth-bound 的 Graphics Pass 重叠执行时收益最大。AMD GCN/RDNA 架构的 Async 能力更强,可以实现更高的重叠度。Mobile GPU 普遍不支持 Async Compute。

九、主循环修改

在 SRP.cs 的 Render 函数中,将原来手动管理的绘制流程替换为:

  1. 按顺序调用各 Pass 的设置函数,声明 Dependency
  2. 从 CommandBufferPool 获取 CommandBuffer
  3. 调用 RenderGraph.Execute() 执行整个图
  4. 将 CommandBuffer 和 ScriptableRenderContext 提交给 RG
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
    foreach (var camera in cameras)
    {
        context.SetupCameraProperties(camera);
        var cullingResult = context.Cull(ref cullParams);

        var cmd = CommandBufferPool.Get("RenderGraph");
        
        var rgParams = new RenderGraphParameters()
        {
            scriptableRenderContext = context,
            commandBuffer = cmd,
            currentFrameIndex = Time.frameCount
        };

        m_RenderGraph.BeginRecording(rgParams);
        
        var depthOutput = AddDepthPass(m_RenderGraph, cullingResult);
        var gbufferOutput = AddGBufferPass(m_RenderGraph, cullingResult, depthOutput);
        AddComposePass(m_RenderGraph, gbufferOutput);
        
        m_RenderGraph.EndRecordingAndExecute();

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
        context.Submit();
    }
}

RG 在 Execute 时根据依赖关系自动确定执行顺序、资源分配和生命周期管理。

十、RG 资源生命周期管理

RG 中创建的 Texture/Buffer 资源具有自动化生命周期。资源在第一个使用它的 Pass 执行前分配,在最后一个使用它的 Pass 执行后归还到池中;RG 内部维护的 Texture/Buffer Pool 会复用规格匹配的资源,避免每次重新创建。通过 ImportTexture() 标记的外部资源(如 History Buffer)不参与自动释放,可以跨帧持有。

10.1 与手动管理模式的内存占用对比

以一个包含 Pre-Z、GBuffer、SSAO、Lighting、Bloom(5-tap Downsample × 4 级)、TAA 的管线为例,1080p 分辨率:

资源手动管理(常驻显存)RG(峰值显存)
Depth Buffer (D32)8 MB8 MB
GBuffer × 3 (RGBA8)24 MB24 MB
SSAO RT (R8)2 MB2 MB(Lighting 后释放)
Lighting Result (RGBA16F)16 MB16 MB
Bloom Chain × 4 (RGBA16F)5.3 MB5.3 MB(各级别依次释放)
TAA History (RGBA16F)16 MB16 MB(Import,常驻)
合计71.3 MB峰值 ~56 MB

RG 的峰值优势来自:

  • SSAO RT 在 Lighting Pass 完成后即释放,其物理内存可被 Bloom Chain 的第一级复用
  • Bloom Downsample Chain 的高层级 RT(1/4、1/8、1/16 分辨率)依次释放后可被 Upsample Chain 复用
  • 任何被 Dead Pass Elimination 剔除的 Pass 对应的资源根本不分配

手动管理模式的显存占用是所有 RT 的总和(因为它们在整个管线生命周期内都存在),而 RG 的占用是同一时刻活跃资源的最大值。管线越复杂(Pass 越多、中间 RT 越多),RG 的显存节约越显著。

实测中,HDRP 在 RG 模式下相比非 RG 模式的峰值显存占用降低约 15-25%(取决于开启的特性数量)。

十一、对比 Unreal RDG

Unity 的 RG 与 Unreal 的 RDG 在设计思路上存在差异:Unreal 在创建 RDG 实例时即绑定 Command List,而 Unity 将 CommandBuffer 的绑定推迟到最终 Execute 阶段。结合 Unity 的图形封装,DrawMesh 和 Dispatch 的调用相对直接,整体使用体验较为流畅。

维度Unity RGUnreal RDG
CommandBuffer 绑定时机Execute 阶段统一绑定创建 GraphBuilder 时即绑定
Pass 内操作方式通过 RenderGraphContext 获取 CB直接操作 FRHICommandList
资源生命周期全自动(创建-使用-释放)全自动,策略相同
Dead Pass Elimination支持支持
Async Compute 支持有限(Unity 图形封装约束)完整(独立 Async Compute Pass)
资源 Aliasing 策略Pool 级复用为主支持 Memory Heap Aliasing
编译时 Barrier 推导自动自动
Pass Merging(Subpass)不支持(2021.x)支持(Vulkan Subpass)
调试可视化RenderGraph Viewer(2021.2+)RDG Insight 面板

从使用体验上,Unity RG 受益于引擎的高层封装,DrawMesh/Dispatch 调用相对直接,入门成本低。Unreal RDG 更贴近底层,灵活度更高但模板代码更多。两者的设计目标一致:通过图结构实现资源生命周期自动化和执行顺序优化。

Unreal RDG 的一个独有能力是 Subpass Merging:在 Vulkan/Metal 后端上,RDG 编译器可以将多个使用相同 Attachment 的 Pass 合并为一个 Render Pass 的多个 Subpass,利用 Tile Memory 避免中间结果回写主存。这对 Mobile GPU 的带宽优化非常关键。Unity RG 在 2021.x 版本中尚未实现这一优化:Mobile 平台上的 Subpass 合并需要开发者在 RG 之外使用 NativeRenderPass API 手动处理。